perf(formatter): inline AnyNodeRef range access#26473
Conversation
|
Interesting. Would it be sufficient to use |
Memory usage reportMemory usage unchanged ✅ |
let me check this when i get back home. i'm a bit dissapointed with the codspeed benchmarks - I expected a bigger diff |
|
Apologies for the noise - I re-benched this and the perf change I observed originally seems to have gone! Not sure why - it's possibly i was doing this on an older version of |
## Summary The formatter calls `Printer::print_element` for every rendered format element and `Printer::print_text` for every text element. LLVM leaves both private methods out of line even when they are marked `#[inline]` and the release profile uses fat LTO, so these hot loops retain out-of-line calls. This marks both functions as `#[inline(always)]`, allowing the printing loop and its common text paths to optimize together. It follows the same general source-level inlining approach as #26429 and closed #26473, but targets the printing pass rather than fitting or comment extraction. The methods have a small, fixed set of call sites, and the release binary's `.text` grows by 11,264 bytes (0.36%). ## Performance To test whether ordinary `#[inline]` is sufficient and whether LTO changes the result, I built the exact base with no annotation, `#[inline]`, and `#[inline(always)]` under both the profiling profile (LTO disabled) and the release profile (fat LTO). Plain `#[inline]` neither removed the out-of-line symbols nor changed `.text`, and CodSpeed reported 0.0% in both profiles. Forced inlining removed both symbols and preserved the improvement under fat LTO: | Profile | Annotation | Overall impact | `.text` delta | | --- | --- | ---: | ---: | | Profiling (no LTO) | `#[inline]` | 0.0% | 0 bytes | | Profiling (no LTO) | `#[inline(always)]` | +6.2% | +11,152 bytes (+0.35%) | | Release (fat LTO) | `#[inline]` | 0.0% | 0 bytes | | Release (fat LTO) | `#[inline(always)]` | +6.0% | +11,264 bytes (+0.36%) | The release/fat-LTO comparison on the existing non-microbenchmark formatter suite reports: | Benchmark | Base | Head | Speedup | | --- | ---: | ---: | ---: | | `formatter[large/dataset.py]` | 7.53 ms | 7.02 ms | 7.3% | | `formatter[pydantic/types.py]` | 2.99 ms | 2.79 ms | 7.2% | | `formatter[numpy/ctypeslib.py]` | 1.57 ms | 1.48 ms | 5.9% | | `formatter[unicode/pypinyin.py]` | 580.30 µs | 550.17 µs | 5.5% | | `formatter[numpy/globals.py]` | 217.95 µs | 209.04 µs | 4.3% |
Summary
I had codex look at improving performance, based on oxc-project/oxc#17459.
Inline
AnyNodeRef::rangeand the formatter comment visitor callbacks that call it during source-order traversal. This lets the compiler see the concreteAnyNodeRefvariant at hot call sites and fold away the large generated match.Formatter benchmark results improved across the sampled fixtures:
What's going on?
The hot path is formatter comment extraction.
Before, source-order traversal builds an
AnyNodeReffor each AST node, thenCommentsVisitor::enter_node/leave_nodecall generic range helpers on it:The problem: without inlining,
enter_nodecallsAnyNodeRef::rangeas a generic helper, so the compiled code may retain the fullAnyNodeRefmatch.After,
AnyNodeRef::rangeand the formatter visitor callbacks are#[inline(always)]:At call sites like:
the compiler can inline through
enter_nodeintorange, see thatnodeis definitelyAnyNodeRef::StmtFunctionDef, and reduce the helper to:So the big match over every AST node kind disappears for those statically known traversal calls. The “helpers being matched” are mainly
AnyNodeRef::range()calls, plus derivedRangedmethods likenode.start()andnode.end()that internally depend onrange().Test Plan
This is a perf optimization - it should be covered by existing tests