perf(kernel): read a plain Python import line instead of re-parsing the file for each package-import base

Merged
BO
12d ago
+47-1

Syncing one changed file in CPython takes a median 5.5 s instead of 7.7 s on Node and 5.2 s instead of 8.1 s on Bun (29% and 35% faster, 4 alternating runs per build).

Bar chart: a one-file sync in CPython takes 5.46 s instead of 7.67 s on Node (29% lower) and 5.22 s instead of 8.05 s on Bun (35% lower); the sync's retry phase on Node takes 2.28 s instead of 4.57 s (50% lower)

Metric, CPython (3,710 files)BeforeAfterChange
One-file sync, Node7.67 s5.46 s29% lower
One-file sync, Bun8.05 s5.22 s35% lower
Retry phase of the sync, Node4.57 s2.28 s50% lower
Full index, Node38.41 s36.91 sno clear change (ranges overlap)
Full index, Bun39.68 s35.99 sno clear change (one run on the new build, 39.82 s, is slower than one before, 39.22 s)
Full index, peak memory, Node4,305 MB4,390 MBno clear change
Full index, Django (guard, Node, 2 runs per build)12.07 s12.37 sno clear change

Each sync is codegraph sync after appending one line to Lib/os.py, run on a fresh index; figures are the median of 4 runs per build, builds alternated B F F B B F F B. Every run on the new build is faster than every run before, in both runtimes (Node 5.25–5.60 s against 7.43–7.87 s). Bun's index peak memory was also 7% lower (3,641 MB against 3,929 MB, ranges not overlapping); I did not look into why and make no claim about it. The method and every run are in the plan notes, not in this repository.

What changes

When a Python class has no method of the name being linked (a test class that passes Handler.__init__ as a value, say), python_descendant_members looks at every class that has a method of that name, to find one deriving from the first. In CPython that is 2,567 candidate classes for __init__. For each candidate it resolves the base classes, and a base named through a package import (import unittest, then unittest.TestCase) went through python_package_import, which parsed the whole file to check the import statement. The resolver's tree cache holds 32 files, so each of about 900 such lookups per scan parsed a file again, at about 0.9 ms. Two references, B.__init__ and Handler.__init__, cost about a second each, and the retry of unresolved references after a sync took 4.6 s.

python_package_import now reads a plain one-line import a.b, c from the line itself (plain_import_names). Lines of any other shape, such as a backslash continuation, from imports, or a statement that does not start the line, still use the tree.

The graph is unchanged. CPython has 167,929 nodes, 600,467 edges and 364,595 unresolved references in both builds, and Django 62,500, 185,361 and 144,585. No edge row differs in either direction (kind, source, target, provenance), and scripts/index-metrics.mjs reports no call edge only in one build (325,350 in CPython, 49,080 in Django). In a separate CPython index the line-based answer was compared with the tree answer on all 284,129 calls and agreed every time.

Checks

  • Full vitest suite on this head: 702 files, 8,952 passed, 39 skipped, 0 failed; kernel golden dumps unchanged (no .dump diff).
  • cargo test --lib plain_import: 2 passed (the plain forms, and the shapes that fall back to the tree). The kernel build's clippy gate passes.
  • eval:precision on flask: absent 0/0, present 2/2 held. Flask has almost no package-import bases, so this is a smoke check; the edge comparison above is the graph evidence.
  • README: checked "About this fork". The one-file sync table's CPython row (fork 7.34 s on Node, 7.80 s on Bun against upstream's 6.83 s), the sentence listing "a one-file sync in CPython (+7% on Node, +27% on Bun)" among the comparisons where the fork is worse, and the chart's count ("worse in 12") describe f045e5f7 against upstream 34dd3d2f and are now out of date in the fork's disfavor. I left them unchanged: they compare against an upstream run, and the fork-against-upstream benchmark has to be re-run to move them honestly (recorded in the plan). No other row mentions this path. CHANGELOG entry added under Fixes.

Not covered: the retry phase still takes about 2.1 s in a sync against upstream's 0.75–1.0 s in a stage-by-stage comparison, so part of the CPython sync gap to upstream remains; other Python corpora than Django and CPython were not timed; Windows and macOS were not run.


Summary by cubic

Speeds up Python reference resolution by reading plain one-line import statements directly from the line instead of parsing the whole file again for each package-import base.

When a class has no method of the name being linked, the resolver scans every class with that method for one deriving from it. In CPython that's 2,567 candidates for __init__, and each base reached through a package import (import unittest, then unittest.TestCase) re-parsed the file. Two such references cost about a second each.

python_package_import now reads a one-line import a.b, c from the line itself; other shapes (from imports, backslash continuations, a statement that doesn't start the line) still use the tree. A one-file sync in CPython takes a median 5.5 s instead of 7.7 s on Node and 5.2 s instead of 8.1 s on Bun (29% and 35% faster); the retry phase drops from 4.6 s to 2.3 s. The graph is unchanged on CPython and Django (no edge differs), and a full index is within run-to-run variation.

Summary by CodeRabbit

  • Bug Fixes
    • Improved Python large-project sync performance when resolving references to inherited methods.
    • One-file syncs run faster on Node and Bun, while graph results remain unchanged on CPython and Django.