Skip to content

perf(formatter): inline AnyNodeRef range access#26473

Closed
camc314 wants to merge 1 commit into
astral-sh:mainfrom
camc314:c/perf-inline-anynoderef-range
Closed

perf(formatter): inline AnyNodeRef range access#26473
camc314 wants to merge 1 commit into
astral-sh:mainfrom
camc314:c/perf-inline-anynoderef-range

Conversation

@camc314

@camc314 camc314 commented Jun 30, 2026

Copy link
Copy Markdown

Summary

I had codex look at improving performance, based on oxc-project/oxc#17459.

Inline AnyNodeRef::range and the formatter comment visitor callbacks that call it during source-order traversal. This lets the compiler see the concrete AnyNodeRef variant at hot call sites and fold away the large generated match.

Formatter benchmark results improved across the sampled fixtures:

Fixture Before After Change
large_dataset.py 1.2636 ms 1.1948 ms -5.45%
numpy_ctypeslib.py 288.17 us 266.36 us -7.81%
numpy_globals.py 22.055 us 21.078 us -5.58%
pydantic_types.py 525.96 us 495.98 us -5.07%
unicode_pypinyin.py 73.862 us 70.939 us -4.91%

What's going on?

The hot path is formatter comment extraction.

Before, source-order traversal builds an AnyNodeRef for each AST node, then CommentsVisitor::enter_node / leave_node call generic range helpers on it:

fn walk_stmt(visitor, stmt: &Stmt) {
    let node = AnyNodeRef::from(stmt);

    visitor.enter_node(node);
    stmt.visit_source_order(visitor);
    visitor.leave_node(node);
}

fn enter_node(&mut self, node: AnyNodeRef) {
    let node_range = node.range(); // huge match on AnyNodeRef
    ...
}

impl Ranged for AnyNodeRef {
    fn range(&self) -> TextRange {
        match self {
            AnyNodeRef::StmtFunctionDef(node) => node.range(),
            AnyNodeRef::StmtClassDef(node) => node.range(),
            AnyNodeRef::ExprCall(node) => node.range(),
            AnyNodeRef::ExprName(node) => node.range(),
            AnyNodeRef::Keyword(node) => node.range(),
            // many more variants...
        }
    }
}

The problem: without inlining, enter_node calls AnyNodeRef::range as a generic helper, so the compiled code may retain the full AnyNodeRef match.

After, AnyNodeRef::range and the formatter visitor callbacks are #[inline(always)]:

#[inline(always)]
fn enter_node(&mut self, node: AnyNodeRef) {
    let node_range = node.range();
    ...
}

impl Ranged for AnyNodeRef {
    #[inline(always)]
    fn range(&self) -> TextRange {
        match self {
            AnyNodeRef::StmtFunctionDef(node) => node.range(),
            AnyNodeRef::ExprCall(node) => node.range(),
            // ...
        }
    }
}

At call sites like:

let node = AnyNodeRef::StmtFunctionDef(stmt);
visitor.enter_node(node);

the compiler can inline through enter_node into range, see that node is definitely AnyNodeRef::StmtFunctionDef, and reduce the helper to:

let node_range = stmt.range();

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 derived Ranged methods like node.start() and node.end() that internally depend on range().

Test Plan

This is a perf optimization - it should be covered by existing tests

@camc314
camc314 requested a review from MichaReiser as a code owner June 30, 2026 15:11
@MichaReiser

MichaReiser commented Jun 30, 2026

Copy link
Copy Markdown
Member

Interesting. Would it be sufficient to use inline here? Did you compare performance with LTO enabled/disabled? I'm a bit worried about using inline_always because the method itself is rather large, and inlining it everywhere is probably undesired. (and I know from my own experience with codex that it loves sprinkling inline attributes everywhere, including inline(never) without proving that they're needed)

@MichaReiser MichaReiser added performance Potential performance improvement formatter Related to the formatter labels Jun 30, 2026
@astral-sh-bot

astral-sh-bot Bot commented Jun 30, 2026

Copy link
Copy Markdown

Memory usage report

Memory usage unchanged ✅

@camc314

camc314 commented Jun 30, 2026

Copy link
Copy Markdown
Author

Interesting. Would it be sufficient to use inline here? Did you compare performance with LTO enabled/disabled? I'm a bit worried about using inline_always because the method itself is rather large, and inlining it everywhere is probably undesired. (and I know from my own experience with codex that it loves sprinkling inline attributes everywhere, including inline(never) without proving that they're needed)

let me check this when i get back home.

https://app.codspeed.io/astral-sh/ruff/branches/camc314%3Ac%2Fperf-inline-anynoderef-range?q=is%3Auntouched+mode%3Asimulation+formatter

i'm a bit dissapointed with the codspeed benchmarks - I expected a bigger diff

@camc314

camc314 commented Jun 30, 2026

Copy link
Copy Markdown
Author

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 main, and this change has been covered up somewhere

@camc314 camc314 closed this Jun 30, 2026
charliermarsh added a commit that referenced this pull request Jul 2, 2026
## 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% |
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

formatter Related to the formatter performance Potential performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants