Skip to content

perf(codegen): emit one function per rule instead of one giant switch - #718

Merged
ddehilster merged 1 commit into
masterfrom
perf/one-function-per-rule
Aug 25, 2026
Merged

perf(codegen): emit one function per rule instead of one giant switch#718
ddehilster merged 1 commit into
masterfrom
perf/one-function-per-rule

Conversation

@ddehilster

Copy link
Copy Markdown
Member

Stacked on #717 — retarget to master once that merges.

The problem

Irule::genRule emitted every rule in a pass as a case of a single switch (ruleno) inside one matchRule<id>(). MSVC lays out the union of all the cases' locals in one frame, so the frame grew with the pass. In parse-en-us:

matchRule75 89,404 bytes of stack
matchRule48 46,472 bytes

Every rule attempt paid _chkstk touching ~22 stack pages before doing any matching — 5.9% of self time in a profile of a compiled run.

This is not a case for turning off stack probes. Building with /Gs1048576 crashes with an access violation, which is exactly what a frame that large means.

The change

One function per rule, with matchRule<id>() reduced to a dispatcher. Safe because generated rule bodies use no goto/continue, never touch done (only the default arm does), and never use pcoll — verified across all 139 generated passes of parse-en-us.

Dispatch goes through a table of function pointers, not a switch calling each function directly. That detail matters: each per-rule function has exactly one call site, so the compiler inlines them all straight back into the dispatcher and rebuilds the frame. Measured — with a plain switch, zero per-rule functions survived in run.dll and matchRule75 still asked for 89,404 bytes.

The per-rule functions are also marked noinline. MSVC doesn't need it once dispatch is indirect (verified), but it keeps the frame-size guarantee from resting on one compiler's inlining heuristics, and analyzers are compiled with gcc and clang too.

Results

No generated dispatcher needs a stack probe at all. _chkstk disappears from the profile; matchRule75 self time goes 6.4% → 0.6%.

Interleaved A/B, 8 runs per arm, swapping only run.dll:

minp25median
one big switch7.63s7.73s8.03s
one fn per rule7.31s7.40s7.68s

1.04x – 1.05x. Modest, because only the probe overhead goes away — the rule bodies still do the same work, just in their own frames.

An earlier measurement suggested 13%; that was noise from background load. Interleaving the two DLLs on a quiet machine gives the number above. Flagging it because the honest figure is the smaller one.

Correctness

All 18 analyzer output files byte-identical before and after.

🤖 Generated with Claude Code

@ddehilster
ddehilster changed the base branch from perf/compiled-runtime-output-and-matching to masterAugust 25, 2026 14:07
Irule::genRule emitted every rule in a pass as a `case` of a single
`switch (ruleno)` inside one matchRule<id>() function. MSVC lays out the
union of all the cases' locals in one frame, so the frame grew with the
pass. In parse-en-us:
matchRule75 89,404 bytes of stack
matchRule48 46,472 bytes
Every rule attempt paid _chkstk touching ~22 stack pages before doing any
matching. Profiling a compiled run put _chkstk at 5.9% of self time.
This is not a case for disabling stack probes: building with
/Gs1048576 crashes with an access violation, which is what a frame that
large means.
Emit one function per rule, so each frame holds only what that rule
needs, and leave matchRule<id>() as a dispatcher. Safe because the
generated rule bodies use no goto/continue, never touch `done` (only the
default arm does), and never use pcoll -- verified across all 139
generated passes of parse-en-us.
Dispatch goes through a table of function pointers rather than a switch
calling each function directly. Each per-rule function has exactly one
call site, so the compiler inlines them all straight back into the
dispatcher and rebuilds the frame we just split up -- measured: zero
per-rule functions survived and matchRule75 still asked for 89,404
bytes. The per-rule functions are also marked noinline. MSVC does not
need that once dispatch is indirect, but it keeps the frame-size
guarantee from resting on one compiler's inlining heuristics, which
matters because analyzers are also compiled with gcc and clang.
After: no generated dispatcher needs a stack probe at all, _chkstk
disappears from the profile, and matchRule75 self time goes 6.4% -> 0.6%.
Interleaved A/B, 8 runs per arm, swapping only run.dll:
one big switch min 7.63s p25 7.73s median 8.03s
one fn per rule min 7.31s p25 7.40s median 7.68s 1.04x - 1.05x
Modest, because only the probe overhead goes away -- the rule bodies
still do the same work, just in their own frames. An earlier run
suggested 13%, but that was measurement noise from background load;
interleaving the two DLLs on a quiet machine gives the number above.
All 18 analyzer output files are byte-identical before and after.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ddehilster
ddehilsterforce-pushed the perf/one-function-per-rule branch from a5796ca to ec03762CompareAugust 25, 2026 15:39
@ddehilster

Copy link
Copy Markdown
MemberAuthor

Rebased onto master and bumped 3.8.6 → 3.8.8.

v3.8.7 has been tagged and released, and it contains #717 and #719 but not this PR — so the 3.8.6 bump this branch carried would have set the version backward. Git caught it as a conflict in nlp/main.cpp rather than merging silently, but it needed resolving either way.

No change to the code itself; only the version line moved.

@ddehilster
ddehilster merged commit a8f27fd into masterAug 25, 2026
7 checks passed
ddehilster added a commit that referenced this pull request Aug 25, 2026
Profiling a compiled parse-en-us run after 3.8.7 left ~13% of self time
in ntdll!ZwWriteFile. That is not write volume: a plain ofstream writes
the same 6.1 MB in 0.034s at 170 MB/s. The stacks pointed at
Parse::finExecute -> Parse::finalTree -> Tree<Pn>::Traverse
-> operator<< -> Iarg::genArg -> std::flush
Parse::finalTree writes a 3 MB final.tree, and Iarg::genArg/genArgs
flushed after nearly every fragment they emitted -- 22 std::flush calls
on that path, so each node's attribute list cost a handful of WriteFile
syscalls. Same defect as the Arun::out per-write flush fixed in 3.8.5,
in a different writer.
Removed; the stream still flushes when it closes, so the file on disk is
unchanged. Interleaved A/B, 8 runs per arm, swapping only nlp.exe:
flush per token min 7.69s p25 7.73s median 8.12s
buffered min 7.00s p25 7.00s median 7.16s 1.10x
All 18 analyzer output files byte-identical, final.tree included
(3,085,335 bytes before and after).
Version note: assumes #718 (3.8.8) lands first.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@ddehilster