Skip to content

LexFilter's hwTokenFetch is too large for the JIT to optimize and zeroes a large stack frame for every token #20632

Description

@nojaf

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

  1. 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
  2. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions