Skip to content

Use a flat Python AST in Ruff and ty - #29817

Draft
MichaReiser wants to merge 56 commits into
mainfrom
micha/flat-python-ast
Draft

MichaReiser wants to merge 56 commits into
mainfrom
micha/flat-python-ast

Conversation

@MichaReiser

@MichaReiser MichaReiser commented Oct 29, 2026 •

Copy link
Copy Markdown
Member

Summary

Ruff and ty parse Python directly into a table of 24-byte AST rows. Generated typed accessors resolve four-byte child handles and borrow fields from the parsed module. Expression and statement consumers pass View<Expr> and View<Stmt> by value and call classify() to match individual variants. Ruff lint rules can materialize a subtree when constructing replacement syntax. ty compacts the backing storage before caching a module and resolves its saved node references directly by arena row. Python AST storage and traversal

Reviewing the change

Start with the architecture guide in commit 2, then review the schema in commit 1 and parser construction in commit 3.

  1. Review the field layout and both Python generators. All three generated Rust files, the ty rule reference, and the Cargo lockfile are confined to this commit. Schema and generators
  2. Read the architecture guide and follow how concrete nodes widen to expression or statement views, how visitors traverse them, and how field accessors read a row. AST storage and traversal
  3. Follow a node from parser construction into a cached module. Parser and cached modules
  4. Review how the checker resolves names and how code generation and edits use borrowed nodes. Ruff checker and semantic model
  5. Lint rules read typed fields. PT008, NumPy, and Airflow use descendant searches for expressions in a lambda or a try body. Ruff lint rules
  6. Formatting rules, comment placement, and range formatting borrow nodes from the parsed module. One AsFormat implementation covers every formattable AST view. Use the flat AST in Ruff’s formatter
  7. Follow definitions, scopes, and module resolution through indexed node references. ty semantic index
  8. See how type inference and IDE queries read the indexed syntax. ty inference and IDE queries
  9. Review the row-indexed binding table and the return and yield walks that can be skipped using the arena. Lint performance
  10. An invalid nested pattern reuses the original expression handles so each literal is stored once. Parser error recovery
  11. Follow how ty resolves saved row handles after cache eviction, looks up annotations by source position, and preserves dynamic class identities across edits. Remove ty’s node-ID pass

The parser and AST crates build at commit 3, Ruff’s linter at commit 5, and its formatter at commit 6. The full workspace builds from commit 8.

Performance

CodSpeed compared 310009a with 3b5d4a6 and reported 169 improved, 1 regressed, 114 unchanged, and 14 skipped. Pre-parsed linting with all preview rules on numpy/globals.py had 4.17% lower simulated efficiency. Parser peak heap on large/dataset.py fell from 645.3 KB to 513.3 KB (20.5%). Benchmark report

In the earlier comparison below, the local CodSpeed runner executed 1,050,949 instructions on 86b7fd1 and 1,013,610 on d22dfa8 (3.5% fewer). Simulated L1 instruction-cache misses rose from 24,982 to 28,561 (14.3%), while L1 data-read misses fell from 2,631 to 2,236. The two functions that dispatch expression and statement rules grew from 45.5 to 65.8 KiB and 40.9 to 51.8 KiB in these instrumented binaries. Most of the instruction-cache difference was present before the ty node-ID change. Its parent recorded 28,264 misses.

Native Criterion timings for pre-parsed preview lint on one core were inconclusive against the earlier base. Across 24 alternating pairs, d22dfa8 took a median 0.45% less time than 86b7fd1 (95% bootstrap interval, 0.70% less time to 0.13% more). Against the boxed-AST revision 52cca7e, it took 0.68% less time across 18 pairs (95% interval, 0.37% to 1.85% less).

Same-machine CLI measurements

These runs compare d22dfa8 and 52cca7e on the Python 3.12 standard library with four workers pinned to the same four cores. Each command produced identical output before timing. The time change is the median of 33 alternating pairs. The interval is a 95% bootstrap interval over those pairs.

Command Base median PR median Paired time change 95% interval Peak RSS change
Ruff lint 207.6 ms 193.7 ms -5.71% -9.02% to -2.74% +0.29 MiB
Ruff format check 1,625.0 ms 1,576.3 ms -4.73% -6.12% to +5.43% +1.76 MiB
ty check 2,287.7 ms 2,203.2 ms -3.62% -6.81% to +0.84% -14.87 MiB

Stripped binary sizes for these builds were Ruff +1.91% (27.50 to 28.02 MiB) and ty -0.49% (35.16 to 34.99 MiB).

Linting an already-parsed module

The repository’s linter benchmark parses the module before the timed batch. Each cell shows the median elapsed-time change over eight alternating pairs of 52cca7e and a4ee55d on one CPU core. Negative values mean less time. Linter benchmark

Fixture Default rules All rules All rules with preview
large/dataset.py -12.78% -7.51% -6.46%
numpy/ctypeslib.py -5.52% -2.05% -1.25%
numpy/globals.py -2.72% +1.90% +1.10%
pydantic/types.py -5.53% -0.71% -0.91%
unicode/pypinyin.py -5.25% -0.57% -0.07%

A separate 24-block run repeated numpy/globals.py on the same core with balanced execution order. The paired time changes and 95% bootstrap intervals were -3.22% (-4.12% to -1.99%) for default rules, +1.53% (+0.66% to +2.13%) for all stable rules, -0.33% (-0.74% to +0.44%) for all rules with preview.

Callgrind counted 0.66% to 2.60% fewer instructions in the pre-parsed, all-stable lint kernel across the five fixtures. On numpy/globals.py, it counted 1.16% fewer instructions and 9.48% more simulated L1 instruction-cache misses. The cache counts come from simulation, while the elapsed times above come from native execution.

ty module cache

Caching an ordinary module trims the parser's buffers and uses the existing arena rows as node IDs. The two source-order lookup tables took 77,580 bytes on the 9,698-row large/dataset.py fixture. The complete cached parse now takes 432,147 bytes, down from 509,719 bytes. Over 60 samples, median cache preparation time on that fixture fell from 117.33 µs to 1.25 µs. Callgrind counted 1,015,995 instructions per call to prepare the arena on a4ee55d and 7,668 on d22dfa8. Node identity

Isolating the node-ID change (a4ee55d to d22dfa8) changed end-to-end ty check time on the standard library by -6.64% (95% interval -10.40% to -3.30%) and peak RSS by -3.36 MiB over 33 pairs. In 32 alternating pairs per project, Pygments 2.17.2 took 7.46% less time (95% interval, 2.49% to 15.96% less). Jinja2 3.1.2 took 1.96% more time (95% interval, 2.09% less to 8.96% more). Median peak RSS fell by 2.55 MiB and 1.79 MiB, respectively. Diagnostics matched in every pair.

Native lint, traversal, CLI, and binary size measurements

The native measurements below compare 243a767 with boxed baseline dc988f6, before the rebase and recovery change.

The native lint benchmarks start with a parsed module. Each cell shows the median elapsed-time change in eight alternating pairs on one CPU core. Negative values mean less time.

Fixture Default rules All rules All rules with preview
large/dataset.py −14.96% −7.96% −8.34%
numpy/ctypeslib.py −8.31% −4.09% −4.12%
numpy/globals.py −5.60% −0.32% −1.51%
pydantic/types.py −6.90% −2.58% −2.79%
unicode/pypinyin.py −7.78% −4.16% −4.22%

Across these 15 lint-only cases, the simulator counted 1.00% to 13.78% fewer instructions than on the boxed baseline.

On numpy/globals.py, a separate 21-pair measurement gave −0.66% with all stable rules (95% interval −1.37% to +0.36%) and −1.15% with preview rules enabled (95% interval −1.70% to −0.38%).

The source-order visitor used 9.20% to 18.32% fewer instructions than the boxed visitor across these five files. The descendant iterator used 4.32% to 17.22% fewer. Node counts and checksums of node kinds and source ranges matched in every case. Peak allocated bytes during parsing fell by 18.67% to 32.24%.

Each CLI comparison ran both profiling builds on the CPython 3.12 standard library in 33 pairs using four workers. The commands produced identical output. Elapsed time is the median paired difference. Intervals are distribution-free 95% confidence intervals for that median. RSS is the difference in median peak resident memory.

Command Elapsed time 95% interval Faster pairs Peak RSS
Ruff lint −10.30% −13.21% to −6.66% 31/33 +1.91 MiB
Ruff format +0.73% −4.11% to +5.78% 15/33 +2.14 MiB
ty check −1.89% −3.80% to −0.02% 23/33 −10.88 MiB

In the stripped Linux profiling binaries, Ruff grew by 587 KiB (+2.08%) and ty grew by 70 KiB (+0.20%).

Test plan

All 9,464 selected workspace tests passed with the file-watching test binary excluded. Strict workspace Clippy passed across all targets and features. The tests cover resolving handles after cache eviction, quoted annotation key limits, IDE annotation lookup, and dynamic class identity across edits. The recovery change produced identical ASTs and parser errors on 562 inputs, including all 561 Python parser fixtures.

@MichaReiser MichaReiser added performance Potential performance improvement parser Related to the parser ty The ty type checker memory An issue regarding poor memory usage, or a PR improving memory usage labels Oct 29, 2026
@astral-automations-bot

astral-automations-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Typing conformance results

No changes detected ✅

Current numbers
The percentage of diagnostics emitted that were expected errors held steady at 98.60%. The percentage of expected errors that received a diagnostic held steady at 98.86%. The number of fully passing files held steady at 137/146.

Results for 7610fac · Workflow run

@astral-automations-bot

astral-automations-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Memory usage report

Summary

Project Old New Diff Outcome
flake8 38.03MB 33.25MB -12.56% (4.78MB) ⬇️
trio 92.31MB 85.08MB -7.84% (7.23MB) ⬇️
sphinx 185.58MB 170.92MB -7.90% (14.65MB) ⬇️
prefect 455.21MB 426.27MB -6.36% (28.94MB) ⬇️

Significant changes

Click to expand detailed breakdown

flake8

Name Old New Diff Outcome
parsed_module 14.45MB 9.87MB -31.69% (4.58MB) ⬇️
semantic_index 9.26MB 9.06MB -2.14% (202.67kB) ⬇️

trio

Name Old New Diff Outcome
parsed_module 21.98MB 15.12MB -31.21% (6.86MB) ⬇️
semantic_index 18.56MB 18.19MB -2.01% (381.67kB) ⬇️

sphinx

Name Old New Diff Outcome
parsed_module 44.42MB 30.53MB -31.28% (13.89MB) ⬇️
semantic_index 35.05MB 34.29MB -2.17% (777.59kB) ⬇️

prefect

Name Old New Diff Outcome
parsed_module 87.34MB 60.25MB -31.01% (27.08MB) ⬇️
semantic_index 87.32MB 85.46MB -2.13% (1.86MB) ⬇️

Results for 7610fac · Workflow run

@astral-automations-bot

astral-automations-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

ecosystem-analyzer results

No diagnostic changes detected ✅

Full report with detailed diff · Timing results


Results for 7610fac · Workflow run

@astral-automations-bot

astral-automations-bot Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

ruff-ecosystem results

Linter (stable)

✅ ecosystem check detected no linter changes.

Linter (preview)

✅ ecosystem check detected no linter changes.

Formatter (stable)

✅ ecosystem check detected no format changes.

Formatter (preview)

✅ ecosystem check detected no format changes.

Linter (stable) · Linter (preview) · Formatter (stable) · Formatter (preview)


Results for 7610fac · Workflow run

Comment on lines +9 to +10
let rewritten = owned_pattern_to_expr(pattern.view(storage).to_owned_node());
let id = ast::flat::Lower::lower(&rewritten, storage);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P3] Avoid retaining entire subtrees during pattern recovery

A malformed class pattern whose head is a sequence reaches this conversion at each nesting level. to_owned_node() copies the entire previously recovered subtree, and Lower::lower appends its literal bytes to the same Builder without reclaiming the originals. Nesting around one large string therefore retains memory proportional to literal size × nesting depth, allowing an attacker-controlled file in an untrusted language-server workspace to exhaust host memory. The previous implementation moved owned children. Construct the recovery nodes using existing child handles to avoid retaining these repeated copies.

@codspeed

codspeed Bot commented Oct 29, 2026 •

Copy link
Copy Markdown

Merging this PR will regress 1 benchmark

⚡ 165 improved benchmarks
❌ 1 regressed benchmark
✅ 118 untouched benchmarks
⏩ 14 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
❌ CPU Simulation linter/all-with-preview-rules[numpy/globals.py] 1.3 ms 1.4 ms -5.25%
⚡ Memory parser[numpy/globals.py] 16.7 KB 11.4 KB +47.28%
⚡ Memory parser[unicode/pypinyin.py] 44.3 KB 33.8 KB +30.86%
⚡ Memory parser[pydantic/types.py] 281.3 KB 218.1 KB +28.94%
⚡ Memory parser[large/dataset.py] 645.3 KB 513.3 KB +25.71%
⚡ Memory parser[numpy/ctypeslib.py] 134.8 KB 109.8 KB +22.82%
⚡ Walltime auto_imports[regular-package/repeated-request] 14.5 ms 12.2 ms +18.66%
⚡ Walltime auto_imports[namespace-package/repeated-request] 14.1 ms 11.9 ms +18.44%
⚡ Memory ty_micro[pandas_tdd] 26.6 MB 23.1 MB +15.29%
⚡ Memory ty_micro[very_large_tuple] 7.4 MB 6.4 MB +14.47%
⚡ Memory ty_micro[async_context_manager_recursive_protocol] 7.8 MB 6.8 MB +14.05%
⚡ Memory ty_micro[complex_constrained_attributes_3] 7.6 MB 6.7 MB +13.83%
⚡ Memory ty_micro[split_literal_union_1024] 4.8 MB 4.2 MB +13.65%
⚡ Memory ty_micro[invariant_generic_return_union] 7.8 MB 6.9 MB +13.61%
⚡ Walltime auto_imports[regular-package/after-module-create] 22.3 ms 19.6 ms +13.38%
⚡ Memory ty_micro[sequence_literal_union_access] 7.9 MB 7 MB +13.32%
⚡ Memory ty_micro[mixed_typed_dict_union_copy] 9.1 MB 8 MB +13.27%
⚡ Memory ty_micro[optional_str_enum_comparison] 8.5 MB 7.5 MB +13.14%
⚡ Walltime auto_imports[regular-package/first-request] 426.4 ms 376.9 ms +13.14%
⚡ Memory ty_micro[enum_literal_union_comparison] 8.3 MB 7.3 MB +13.09%
... ... ... ... ... ...

ℹ️ Only the first 20 benchmarks are displayed. Go to the app to view all benchmarks.

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing micha/flat-python-ast (7610fac) with main (049c351)

Open in CodSpeed

Footnotes

  1. 14 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. ↩

@AlexWaygood AlexWaygood changed the title [ty] Use a flat Python AST in Ruff and ty Use a flat Python AST in Ruff and ty Oct 29, 2026
@AlexWaygood AlexWaygood removed the ty The ty type checker label Oct 29, 2026
@MichaReiser
MichaReiser force-pushed the micha/flat-python-ast branch from bd231d4 to 59e0aa9 Compare October 29, 2026 19:19
@dcreager

Copy link
Copy Markdown
Member

YES

This branch was successfully deployed

1 active deployment
automations — 7610fac2 Deployed Nov 1, 2026 by MichaReiser via security-review / security review #96481
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

memory An issue regarding poor memory usage, or a PR improving memory usage parser Related to the parser performance Potential performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants