LexFilterImpl.hwTokenFetch in LexFilter.fs compiles to a single method of 110,356 bytes of IL with 565 locals. That is over the JIT's size limit for optimizing a method (60,000 bytes of IL, DEFAULT_MIN_OPTS_CODE_SIZE), so the JIT compiles it with MinOpts and never recompiles it. The method runs once per token, and each call sets up and zeroes a stack frame of 11 KB to 20 KB depending on how it was compiled.
Splitting the method in three, without changing any rule, made parsing 28% faster in Fantomas, which compiles the same source as FCS.
Repro steps
-
Parse a set of files with the compiler from the SDK, with ReadyToRun disabled so that the method is JIT-compiled, and ask the JIT for its disassembly:
{ echo --parseonly; echo --nologo; find src/Compiler -name '*.fs' | head -300; } > files.rsp
DOTNET_ReadyToRun=0 DOTNET_JitDisasm=hwTokenFetch DOTNET_JitStdOutFile=disasm.txt \
dotnet <sdk>/FSharp/fsc.dll @files.rsp
-
The top of disasm.txt (SDK 10.0.401, Linux x64):
; Assembly listing for method FSharp.Compiler.LexFilter+LexFilterImpl:hwTokenFetch(bool):FSharp.Compiler.Parser+token:this (Tier0-FullOpts-MinOpts)
; Tier-0 switched to FullOpts, then to MinOpts code
...
lea rbp, [rsp+0x4930]
...
; Total bytes of code 173570
An 18 KB frame, zeroed in the prologue on every call, around 173 KB of unoptimized code.
Expected behavior
The offside rule is optimized by the JIT like the rest of the lexer and parser, with a frame small enough that zeroing it per token does not matter.
Actual behavior
Where the code is JIT-compiled, hwTokenFetch stays at MinOpts. That covers the FSharp.Compiler.Service NuGet package, which is not ReadyToRun (checked for 43.9.300), and so every editor and tool built on it. Every local gets its own stack slot, and the prologue zeroes the whole frame.
I measured this in Fantomas, which compiles the FCS syntax tree sources from commit 74ec4f7. That version of hwTokenFetch is identical to the one on main. With perf on Linux, parsing the 688 source files of this repository:
hwTokenFetch alone was 28% of parse time.
- The loop that zeroes its 20 KB frame was the hottest code in the parse: 14% to 20% of all samples, over two runs.
The SDK's FSharp.Compiler.Service.dll is ReadyToRun. Its precompiled hwTokenFetch is optimized, but still has an 11.7 KB frame, of which it zeroes 0x2a90 bytes (10.9 KB) per call:
lea -0x2db8(%rsp),%r11
call *0x12f1970(%rip) ; stack probe
...
movabs $0xffffffffffffd570,%rax
movaps %xmm8,-0x70(%rbp,%rax,1)
movaps %xmm8,-0x60(%rbp,%rax,1)
movaps %xmm8,-0x50(%rbp,%rax,1)
add $0x30,%rax
jne ...
In fsc --parseonly over the files above, that loop was 6.9% of the samples taken while parsing. A dotnet build pays this as well, just less than the JIT-compiled package does.
Proposed fix
Split the rule chain of the big match token, offsideStack with into consecutive parts, each a local function that the previous part falls through to:
let rec hwTokenFetch useBlockRule =
let tokenTup = popNextTokenTup()
// ... the same locals and helpers as today ...
let hwTokenFetchPart3 () =
match token, offsideStack with
| LARROW, _ when isControlFlowOrNotSameLine() -> ... // the rules from here to the end, unchanged
...
| _ -> returnToken tokenLexbufState token
let hwTokenFetchPart2 () =
match token, offsideStack with
| _, CtxtInterfaceHead offsidePos :: _ when ... -> ... // the middle rules, unchanged
...
| _ -> hwTokenFetchPart3 ()
match token, offsideStack with
| _ when tokensThatNeedNoProcessingCount > 0 -> ... // the first rules, unchanged
...
| _ -> hwTokenFetchPart2 ()
A match takes the first rule that matches, and a token that matches nothing in one part reaches the next part with the same token and offsideStack, as no rule has run yet. So every token is handled by the same rule as before. The split points are between whole rules, never inside an or-pattern: after the rule for a token offside from CtxtDo, and before the LARROW rule. Apart from the two new let lines and the two | _ -> lines, the diff only indents two blocks of rules by four spaces, so it is small with whitespace ignored.
The F# compiler lifts both local functions to static methods that take the captured values as arguments, so there is no closure allocated per token. In Fantomas's copy:
|
IL |
Frame |
JIT |
hwTokenFetch before |
110 KB |
20 KB |
MinOpts |
hwTokenFetch after |
20.6 KB |
904 bytes |
optimized |
hwTokenFetchPart2 |
37.4 KB |
392 bytes |
optimized |
hwTokenFetchPart3 |
9.6 KB |
504 bytes |
optimized |
Parsing the 688 source files of this repository that Fantomas formats, without defines, averaged over warm runs:
|
Time |
Allocated |
| Before |
about 1.59 s |
732 MB |
| After |
about 1.14 s |
731 MB |
Fantomas's output for 902 files in 4 configurations (3608 outputs) is byte for byte the same with and without the split, and its test suites pass. I have not yet built the compiler itself with the change or run its test suite.
The parts can grow past the limit again as rules are added, so a comment at the split saying why it is there would help keep it.
Known workarounds
None from outside the compiler. Setting the JIT's MinOpts thresholds through DOTNET_JITMinOpts* has no effect on a release runtime.
Related information
LexFilterImpl.hwTokenFetchin LexFilter.fs compiles to a single method of 110,356 bytes of IL with 565 locals. That is over the JIT's size limit for optimizing a method (60,000 bytes of IL,DEFAULT_MIN_OPTS_CODE_SIZE), so the JIT compiles it with MinOpts and never recompiles it. The method runs once per token, and each call sets up and zeroes a stack frame of 11 KB to 20 KB depending on how it was compiled.Splitting the method in three, without changing any rule, made parsing 28% faster in Fantomas, which compiles the same source as FCS.
Repro steps
Parse a set of files with the compiler from the SDK, with ReadyToRun disabled so that the method is JIT-compiled, and ask the JIT for its disassembly:
{ echo --parseonly; echo --nologo; find src/Compiler -name '*.fs' | head -300; } > files.rsp DOTNET_ReadyToRun=0 DOTNET_JitDisasm=hwTokenFetch DOTNET_JitStdOutFile=disasm.txt \ dotnet <sdk>/FSharp/fsc.dll @files.rspThe top of
disasm.txt(SDK 10.0.401, Linux x64):An 18 KB frame, zeroed in the prologue on every call, around 173 KB of unoptimized code.
Expected behavior
The offside rule is optimized by the JIT like the rest of the lexer and parser, with a frame small enough that zeroing it per token does not matter.
Actual behavior
Where the code is JIT-compiled,
hwTokenFetchstays at MinOpts. That covers the FSharp.Compiler.Service NuGet package, which is not ReadyToRun (checked for 43.9.300), and so every editor and tool built on it. Every local gets its own stack slot, and the prologue zeroes the whole frame.I measured this in Fantomas, which compiles the FCS syntax tree sources from commit 74ec4f7. That version of
hwTokenFetchis identical to the one onmain. Withperfon Linux, parsing the 688 source files of this repository:hwTokenFetchalone was 28% of parse time.The SDK's
FSharp.Compiler.Service.dllis ReadyToRun. Its precompiledhwTokenFetchis optimized, but still has an 11.7 KB frame, of which it zeroes 0x2a90 bytes (10.9 KB) per call:In
fsc --parseonlyover the files above, that loop was 6.9% of the samples taken while parsing. Adotnet buildpays this as well, just less than the JIT-compiled package does.Proposed fix
Split the rule chain of the big
match token, offsideStack withinto consecutive parts, each a local function that the previous part falls through to:A
matchtakes the first rule that matches, and a token that matches nothing in one part reaches the next part with the sametokenandoffsideStack, as no rule has run yet. So every token is handled by the same rule as before. The split points are between whole rules, never inside an or-pattern: after the rule for a token offside fromCtxtDo, and before theLARROWrule. Apart from the two newletlines and the two| _ ->lines, the diff only indents two blocks of rules by four spaces, so it is small with whitespace ignored.The F# compiler lifts both local functions to static methods that take the captured values as arguments, so there is no closure allocated per token. In Fantomas's copy:
hwTokenFetchbeforehwTokenFetchafterhwTokenFetchPart2hwTokenFetchPart3Parsing the 688 source files of this repository that Fantomas formats, without defines, averaged over warm runs:
Fantomas's output for 902 files in 4 configurations (3608 outputs) is byte for byte the same with and without the split, and its test suites pass. I have not yet built the compiler itself with the change or run its test suite.
The parts can grow past the limit again as rules are added, so a comment at the split saying why it is there would help keep it.
Known workarounds
None from outside the compiler. Setting the JIT's MinOpts thresholds through
DOTNET_JITMinOpts*has no effect on a release runtime.Related information