perf(formatter_core): make printer queues cursor-based#24098
Conversation
Merging this PR will improve performance by 3.94%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | Simulation | formatter[types.ts] |
13 ms | 12.4 ms | +4.73% |
| ⚡ | Simulation | formatter[handle-comments.js] |
2.7 ms | 2.6 ms | +4.18% |
| ⚡ | Simulation | formatter[Search.tsx] |
1.6 ms | 1.6 ms | +4.02% |
| ⚡ | Simulation | formatter[core.js] |
1.5 ms | 1.5 ms | +4.02% |
| ⚡ | Simulation | formatter[App.tsx] |
48.9 ms | 47 ms | +3.92% |
| ⚡ | Simulation | formatter[RadixUIAdoptionSection.jsx] |
403 µs | 388 µs | +3.88% |
| ⚡ | Simulation | formatter[index.tsx] |
3.7 ms | 3.5 ms | +3.78% |
| ⚡ | Simulation | formatter[next.ts] |
2.2 ms | 2.2 ms | +3.7% |
| ⚡ | Simulation | formatter[errors.ts] |
559.4 µs | 541.9 µs | +3.23% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing perf/formatter-printer-hot-loops (d0b1992) with main (51cc574)2
Footnotes
-
19 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
-
No successful run was found on
main(66c3868) during the generation of this report, so 51cc574 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩
42dbff2 to
cdb595a
Compare
cdb595a to
7926d18
Compare
`num-bigint` 0.4.6 → 0.5.1 (#24192) reduced heap allocations when parsing BigInt literals. `kitchen-sink.tsx` is the only tracked fixture containing BigInt literals, so its parser row changed: 2058 → 2051 sys allocs (an improvement, not a regression). The snapshot went stale on main because the Allocations job's change filter (`check-changes.js`) skips the measurement when no files under `crates/` changed — a `Cargo.lock`-only dependency update never re-measures. The mismatch then surfaces on any unrelated PR that touches the snapshot paths (currently blocking #24098). Verified by bisecting locally: parent commits of #24192 measure 2058, #24192 and current main measure 2051, matching CI exactly. The same filter gap affects Conformance, Minsize, and Linter timings jobs; a follow-up may address the filter itself.
Merge activity
|
### Summary Profiling the formatter benchmarks (samply, symbolicated to instruction level) showed the printer's two hottest functions — `Printer::fits` (12.9–17.5% self time depending on workload) and `Printer::print_element` (11–12.9%) — spending a significant share of their time in `Queue::pop` plumbing: every popped element consulted the slice stack (`Vec::last` for the print queue; a two-level `StackedStack::top()` `or_else` fallback for the fits queue, alone ~9% of `fits` self time) plus a bounds-checked index. The whole change is one representational move: **the not-yet-consumed remainder of the top slice moves from `stack.top()` + `next_index` into a dedicated `current` field**; every other hunk is forced by that move. `pop()` becomes `split_first` plus a rare refill, and the slice stack is only touched at slice boundaries and in `extend_back`. The invariant that keeps every access a single length check: slices stored on the stack are never empty, and `current` is eagerly refilled when it drains — so the queue is exhausted iff `current` is empty, and `top()` / `is_empty()` never need to consult the stack. Blast radius: one file, net −2 source lines. No call site in `mod.rs` changes; `stack.rs`, the call stacks, `QueueContentIterator`, and the fits predicates are untouched. The formatter allocation snapshot is regenerated because the slice stack now starts unallocated (the old `PrintQueue::new` eagerly built a one-element `vec![slice]` per print) — 1–2 fewer system reallocs per file across every tracked fixture, no increases. A companion change caching the top call-stack frame in a field looked equally justified from the profile (`CallStack::top` was ~6% of `fits`), but was measured and **rejected**: alone it was worth only ~1–2%, and combined with this change it consistently *regressed* vs. this change alone (App.tsx −12.0% → −6.2%) — the extra frame copy on every tag push/pop outweighs the cheaper per-element read. ### Performance `cargo bench --bench formatter` (criterion, Apple Silicon), old and new measured back-to-back in the same session: | workload | Δ mean | | workload | Δ mean | |---|---|---|---|---| | App.tsx (415 KB) | **−13.1%** | | Search.tsx | −5.1% | | handle-comments.js | −13.4% | | core.js | −4.8% | | index.tsx | −11.2% | | errors.ts | −4.6% | | next.ts | −8.6% | | RadixUIAdoptionSection.jsx | −8.1% | | types.ts | −8.4% | | | | Median ≈ −8%. Deltas are the conservative per-file minimum across two independent old-code baseline runs (a few fixtures are noisy run-to-run, so single-baseline comparisons can overstate). CodSpeed (deterministic instruction counts) confirms the direction on all 9 formatter benchmarks: +3.2% to +4.7% efficiency, no regressions anywhere in the suite. Wall-time gains exceed instruction-count gains because part of the win is fewer data-dependent branches in the pop path, which simulated counts undervalue. Output is byte-identical: Prettier conformance unchanged (js 746/753, ts 591/601, no snapshot diffs), `oxc_formatter` fixture tests and the JSON/CSS/GraphQL consumers of the same printer all pass, clippy clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
d0b1992 to
468e1e3
Compare
…nges (#24199) ### Summary The include-mode filter in `check-changes.js` short-circuits to "skip" when no changed files are under `crates/`, so it never reaches the cargo tree dependency check for `Cargo.lock`-only dependency updates or `rust-toolchain.toml` bumps. Conformance, Minsize, Allocations, and Linter timings are all gated on this filter. This is how #24192 (`num-bigint` 0.4.6 → 0.5.1) landed with a stale parser allocation snapshot: the update changed BigInt-literal parsing allocation counts, the Allocations job never re-measured on main, and the mismatch surfaced on an unrelated PR (#24098). The snapshot itself is fixed in #24198. This PR treats `Cargo.lock` and `rust-toolchain.toml` changes as affecting every crate, so measurement jobs always re-run on dependency and toolchain updates. The cost is that renovate dependency PRs now run these four jobs; dependency updates are exactly the class of change that can shift measured behavior, so that is the intended behavior. Exclude-mode and paths-only jobs are unaffected (exclude mode already runs on such changes by construction). ### Trade-off For Conformance specifically this closes a correctness gap, not just a snapshot-staleness one: a dependency update that changes parser/transformer behavior currently skips conformance entirely. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
# Oxlint ### 🚀 Features - 7db7a29 allocator: Add `ReplaceWith` trait (#24012) (overlookmotel) - a2c97f3 linter/unicorn: Implement `explicit-timer-delay` rule (#23612) (Mikhail Baev) - 85735cb linter/unicorn: Implement `no-confusing-array-with` rule (#23638) (Shekhu☺️ ) - cb4fbb9 linter/eslint: Implement no-unreachable-loop rule (#23975) (Todor Andonov) - dc32112 linter/eslint/no-constant-binary-expression: Check relational comparisons (#24088) (camc314) - 439c344 linter/jsdoc: Added missing options to `jsdoc/require-param` rule (#23364) (kapobajza) - 62af717 linter/unicorn/filename-case: Add `lowercase` and `screamingSnakeCase` (#24045) (Boshen) - d963967 linter/unicorn/no-array-sort: Add `allowAfterSpread` option (#24043) (Boshen) - 0a75682 linter: Add per-rule timings for type-aware linting (#22488) (camchenry) - 743e222 linter/react: Add `disallowedValues` option for `forbid-dom-props` rule (#23970) (Mikhail Baev) ### 🐛 Bug Fixes - 7b80010 linter: Use direct binding symbol ids (#24216) (camc314) - 8f94b49 linter/import/no-duplicates: Don't flag a type-only import beside a side-effect import (#24030) (Boshen) - d8c3fee linter/react/rules-of-hooks: Flag `useEffectEvent` escapes (#23764) (Rayan Salhab) - 0a7312b linter/no-deprecated-functions: Map `require.requireActual` to `jest.requireActual` (#23627) (Jerry Zhao) - d9e3ab3 linter/eslint/no-useless-return: Handle switch case continuation (#23984) (camc314) - 0b25582 ast: Type binding node `typeAnnotation` as `TSTypeAnnotation | null` (#23113) (Boshen) - 122d112 linter/eslint/no-restricted-imports: Flag dynamic import() expressions (#24029) (Boshen) - 59b6b83 linter: Avoid `OnceLock` re-entry on cyclic `export *` re-exports (#23632) (Jerry Zhao) - dd09af0 linter/import/namespace: Avoid panic on destructuring of an unresolvable namespace re-export (#23626) (Jerry Zhao) - bdb51c7 linter/jest/prefer-ending-with-an-expect: Validate config patterns (#24122) (camc314) - e383843 linter/unicorn/prefer-modern-dom-apis: Skip fixer for non identifier arguments (#23630) (Jerry Zhao) - 0ac4c83 linter: Detect circular config extends (#24115) (camc314) - bae1edf linter/import/namespace: Check namespace imports after named imports (#24094) (camc314) - cd8fdfe linter/eslint/no-eval: Recognize Array.from family thisArg (#24091) (camc314) - 851ee43 linter/eslint/no-eval: Resolve this binding for functions returned from an IIFE (#23643) (Jerry Zhao) - 002ab35 linter/unicorn: Avoid prefer-array-find rest destructuring false positive (#23654) (ColemanDunn) - 01c8775 linter/unicorn/filename-case: Keep digits attached in screamingSnakeCase (#24056) (Boshen) - f256941 linter: Recognize `@effect/vitest` as a vitest import source (#24025) (Boshen) - 73eeb1d linter/import/extensions: Honor per-extension `never` for explicit extensions (#24031) (Boshen) - d4ebe1f linter: Reject non-object oxlint config files (#24026) (Boshen) - 45d607d linter/react/forbid-component-props: Make allow/disallow lists optional in schema (#24024) (Boshen) - 54076ad linter/unicorn/no-array-for-each: Suggest entries loop for index callbacks (#24004) (camc314) - d057736 linter/jsdoc: Avoid param root underflow (#23945) (camc314) - 29c76bf linter/unicorn/prefer-at: Skip object numeric-key access (#23909) (Gaurav Dubey) ### ⚡ Performance - 657a8fc linter/oxc/bad-array-method-on-arguments: Only run on member expressions instead of all identifiers (#24164) (camchenry) - 073d9e7 linter/eslint/prefer-rest-params: Run on functions instead of all identifiers (#24163) (camchenry) - e5a4162 linter/jest/no-confusing-set-timeout: Early exit fast path (#24092) (camc314) - bca7ce5 linter: Only run react-perf rules on JSX attribute nodes (#24083) (camchenry) - 6881bf6 linter: Compute `apply_overrides` rule set lazily (#23648) (Jerry Zhao) - 911c106 linter/eslint/no-obj-calls: Use resolved reference instead of scope walk (#23895) (Marius Schulz) - dc8fd9a linter/unicorn/prefer-dom-node-text-content: Change dispatch to run only on less common node types (#23897) (Connor Shea) - fdbd34d linter/eslint/no-useless-call: Fast-path static callees (#24077) (camc314) - b1be114 linter/import/extensions: Skip empty config and borrow extensions (#24075) (camc314) - 4781b2d linter/eslint/no-obj-calls: Use direct global matches (#24076) (camc314) - e6cee89 linter: Avoid node-chain allocation for non-Jest calls (#23907) (Yagiz Nizipli) - 30dc517 linter/typescript/no-restricted-types: O(1) banned-type lookups (#23827) (Yagiz Nizipli) ### 📚 Documentation - 6ca9125 linter/typescript: Clarify consistent-type-imports behavior (#23972) (camc314) # Oxfmt ### 🚀 Features - 4f4313e formatter_css: Update oxc-css-parser 0.0.5 (#24120) (leaysgur) - 0ccd8a1 formatter_graphql: Update oxc-graphql-parser 0.0.5 (#24106) (leaysgur) - 89ec3d9 formatter_core: Add literal line and root indention primitives (#24051) (leaysgur) - 213a96b formatter_core: Add no-expand-parent for multiline text (#24050) (leaysgur) - 0e5bcc9 formatter_graphql: Update oxc-graphql-parser 0.0.4 (#24039) (leaysgur) - e0b35a1 formatter_css: Update `[email protected]` (#23974) (leaysgur) ### 🐛 Bug Fixes - 1fe6546 formatter: Omit unneeded `;` for type members with `no-semi` (#24212) (leaysgur) - 0ad7316 formatter: Print space for `ForStatement`.`update` only if exists (#24211) (leaysgur) - 3abbed5 formatter: Print `;` before jsdoc type-cast parens with no-semi (#24208) (leaysgur) - 9af3833 formatter_css: Make scss formatter consistent (#24207) (leaysgur) - 46d7194 formatter_css: Use fill IR for `@forward` members (#24206) (leaysgur) - e31038f formatter_css: Keep comment inside sass config list (#24205) (leaysgur) - d3b9591 formatter: Add parens around `await/yield` with `<T>` (#24202) (leaysgur) - 2121a55 oxfmt: Reuse tinypool process during the same LSP process (#24197) (leaysgur) - 9bf4b4a formatter_css: Align CSS output to Prettier 3.9.1 (#24100) (leaysgur) - cd2452e formatter_css: Align SCSS output to Prettier 3.9.1 (#24097) (leaysgur) - 4ee8745 formatter_css: Keep selector value contain line-break without breaking line (#24055) (leaysgur) - e1ece97 formatter_graphql: Break `implements` list by print-width (#23997) (leaysgur) - 0a6b16c formatter_json: Preserve key and literal value for json-stringify (#23996) (leaysgur) - 903ab6e formatter_css: Preserve newlines in css-in-js selector list (#23992) (leaysgur) - ea5d095 oxfmt: Update `--migrate prettier` (#23963) (leaysgur) ### ⚡ Performance - 468e1e3 formatter_core: Make printer queues cursor-based (#24098) (Boshen) - c59f2fe rust: Return impl ExactSizeIterator from slice-backed accessors (#24144) (Boshen) - c292fb2 formatter: Inline fits element dispatcher (#23982) (camc314) Co-authored-by: Boshen <[email protected]>
Summary
Profiling the formatter benchmarks (samply, symbolicated to instruction level) showed the printer's two hottest functions —
Printer::fits(12.9–17.5% self time depending on workload) andPrinter::print_element(11–12.9%) — spending a significant share of their time inQueue::popplumbing: every popped element consulted the slice stack (Vec::lastfor the print queue; a two-levelStackedStack::top()or_elsefallback for the fits queue, alone ~9% offitsself time) plus a bounds-checked index.The whole change is one representational move: the not-yet-consumed remainder of the top slice moves from
stack.top()+next_indexinto a dedicatedcurrentfield; every other hunk is forced by that move.pop()becomessplit_firstplus a rare refill, and the slice stack is only touched at slice boundaries and inextend_back.The invariant that keeps every access a single length check: slices stored on the stack are never empty, and
currentis eagerly refilled when it drains — so the queue is exhausted iffcurrentis empty, andtop()/is_empty()never need to consult the stack.Blast radius: one file, net −2 source lines. No call site in
mod.rschanges;stack.rs, the call stacks,QueueContentIterator, and the fits predicates are untouched. The formatter allocation snapshot is regenerated because the slice stack now starts unallocated (the oldPrintQueue::neweagerly built a one-elementvec![slice]per print) — 1–2 fewer system reallocs per file across every tracked fixture, no increases.A companion change caching the top call-stack frame in a field looked equally justified from the profile (
CallStack::topwas ~6% offits), but was measured and rejected: alone it was worth only ~1–2%, and combined with this change it consistently regressed vs. this change alone (App.tsx −12.0% → −6.2%) — the extra frame copy on every tag push/pop outweighs the cheaper per-element read.Performance
cargo bench --bench formatter(criterion, Apple Silicon), old and new measured back-to-back in the same session:Median ≈ −8%. Deltas are the conservative per-file minimum across two independent old-code baseline runs (a few fixtures are noisy run-to-run, so single-baseline comparisons can overstate).
CodSpeed (deterministic instruction counts) confirms the direction on all 9 formatter benchmarks: +3.2% to +4.7% efficiency, no regressions anywhere in the suite. Wall-time gains exceed instruction-count gains because part of the win is fewer data-dependent branches in the pop path, which simulated counts undervalue.
Output is byte-identical: Prettier conformance unchanged (js 746/753, ts 591/601, no snapshot diffs),
oxc_formatterfixture tests and the JSON/CSS/GraphQL consumers of the same printer all pass, clippy clean.🤖 Generated with Claude Code