← board

An imported .npy module costs ~13x per function versus the same code inline

Full measurement, method and the refuted hypotheses: devdocs/perf/lekkerzeilen-build-time.md.

Why this is ranked where it is

It is the dominant cost of compiling lekkerzeilen, which is a stated goal ("have lekkerzeilen compile under nilpy as demo"). It is not ranked on the count of modules that hit it — every .npy import hits it, so that count is a property of the language, not evidence about the fix.

The condition that would retire this ticket

A per-function cost for an imported module within ~2x of the inline arm, at K=400, measured the same way. Stated as a rate rather than a wall-clock figure deliberately: the 125 s lekkerzeilen number moves whenever the demo or the compiler does, and a summary that cites a row which fires today acquires a dependency on that row staying broken.

What is NOT claimed

That the import path is doing redundant work — that is the obvious reading and it is not measured. The output being byte-comparable is consistent with redundancy and equally consistent with a different, slower route to the same result. Locating it needs the profile.

2026-09-21 — cause found, two fixes landed, timing still open

ffe476877 fixes both instances; bb6c6c6d4 writes up the second.

The question this ticket asked — "what does an import require that inlining does not?" — has an answer, and it is not something an import requires. It is that several parser routines scan the token ARRAY, the array is shared by every module, and an import concatenates its module into it. Nothing about the imported code is special; it is present, and presence is the cost.

Read that as the thing to grep for, not as two closed bugs. Any routine that scans from 0, or to TokCount, or to MainProgramTokCount, once per definition has this shape. Two were found by reading a profile; there is no reason to believe two is the population.

The allocation finding is the one with reach beyond NilPy. CaseEqual(GetTokenStr(idx), nm) appears 177 times across the compiler — 62 in pyparser.inc (fixed), 62 in pasparser_prog.inc, 35 in pasparser_generic.inc, and the rest scattered. Every one of them allocates a string to throw it away. The Pascal frontend was not measured and no claim is made about it; the owner's scope for this lane is compiling Python faster, so the other 115 sites are deliberately untouched and are a separate question for whoever owns Pascal parse time.

On the ceiling, and why the optimistic number must not come back. The 9.0% was measured with the walk disabled outright, on a synthetic whose import closure is ONE module. Under the model that fits that data — per-definition cost C + K/2, with C the closure's definition count and K the module's own — a one-module closure is the smallest C there is. So 9.0% is a ceiling for that population and may be a floor for lekkerzeilen's. The owner raised this without seeing the model. It is not settled, and the arithmetic that once allowed "~5x" is not evidence for anything.

The 13x re-measured, 2026-09-22 — it is 6.2x and it is still there

The ticket stayed open partly because nobody had re-run the original observable after the three fixes landed. Re-run with the ticket's own method (devdocs/perf/lekkerzeilen-build-time.md:119), min-of-3, arms interleaved:

functions inline imported ratio
100 2.30 s 3.25 s 1.4x
200 2.50 s 4.52 s 1.8x
400 2.93 s 7.13 s 2.4x
per function, off the 100->400 span:  inline 2.10 ms   imported 12.93 ms   6.2x
implied fixed cost:                   inline 2.09 s    imported 1.96 s
compiler 734d10ec7b53, commit cbb8f81c0, CWD repo root, load ~4, stable

Against the original inline ~3.4 ms / imported ~45 ms = 13.2x. The observable is not gone; it more than halved.

What may and may not be quoted from this. The two arms here were interleaved in one session, so shared load divides out of the ratio — that comparison is sound. The original's per-function milliseconds were taken in a different session at an unrecorded load, so 45 ms -> 12.93 ms is not a clean 3.5x and must not be stated as one. Both rows stand with their own conditions.

Sanity check that the slope is real: the implied fixed cost comes out at 2.09 s and 1.96 s for the two arms independently. A fixed term that agrees across arms is what says the per-function slope is the thing that differs, rather than the ratio being an artefact of a large constant.

Output check: at 400 functions the two binaries are 2,039,100 and 2,043,324 bytes — 0.2% apart, the import bookkeeping. The original recorded 18 bytes, so this is not the same near-identity and I am not claiming it; the arms build the same program modulo the import, which is what the comparison needs.

What would retire the remainder: the parser scans are still O(tokens) per definition — only each step got cheaper — so the structural one-pass version is the open work, and this 6.2x is the number it has to beat.

2026-09-22 — the shape was censused and there IS a third instance

This ticket said, in its own words, "there is no reason to believe two is the population." Censused rather than profiled: loops in pyparser.inc that start at 0/1 and are bounded by TokCount/MainProgramTokCount — four hits in three routines. Two are benign (ParsePyProgram runs once per program; PyBuildEnclTable IS the one-pass fix). The third is PyDefUsedAsValue, called from PyParseDefHeader once per definition per pass, and it only exits early when it FINDS something, so the common answer pays the whole stream.

arm       fns  calls  width    token visits
inline    400  800     13,611   10.9M
imported  400  800    250,059  200.0M      18.4x, identical call count

Fixed by the same transformation ffe476877 applied twice: a one-pass candidate table. Sound because the predicate is name-independent — every condition is about a token's neighbours. Verified with a crosscheck against the scan it replaces: 0 disagreements, from an instrument shown to report 368 across 55 files on a deliberately broken table, plus the 224-test value/Callable corpus byte-identical to .expected on both sides.

Worth 1.50x on the 400-function imported arm (interleaved min-of-3, same tree, one define apart). The inline arms sit at 0.97-1.03x — a control that came free, and the reason the win is attributable to the import closure rather than to load. Full write-up, both levers left, and the numbers with their conditions: devdocs/perf/lekkerzeilen-build-time.md.

This does NOT close the ticket and the census does not retire the warning. The census is blind to a scan bounded by a saved copy of the count or by Length(Tokens), and it only covered pyparser.inc. Three found is not a population either.

2026-09-22 — instances four and five, measured and NOT built

PyClsAttrWriteScan and PyDynAttrEverAssigned, both compiler/pyparser.inc, both j := 1; while j < MainProgramTokCount, both name-parameterised with every structural condition about a token's neighbours — the same shape as PyDefUsedAsValue and convertible by the same transformation.

The census above missed them and the reason is worth more than the instances. It reported "four hits in three routines" and it filtered on loops that start at 0. These start at 1. The blind spot this ticket named for itself was the bound spelling (Length(Tokens), a saved copy); Length(Tokens) turns out not to occur anywhere in compiler/** at all. The actual blind spot was the start value, which nobody had written down as an axis. That is the minimal-case rule arriving in a census: the filter fixed an axis its author never enumerated.

Measured on lekkerzeilen, and the ceiling does not justify the fix:

PyClsAttrWriteScan     165 calls, 47,776,271 token visits
PyDynAttrEverAssigned    0 calls
removing the scan outright:  4.1%  (min-of-3 interleaved, 62.93 -> 60.35 s)
control: both defines ->  clsattr_visits=0

PyClsAttrWriteScan has no early exit by design — it answers two questions at once — and the obvious one-line remedy is worth nothing: if classW and instW then Break is semantically exact and removes zero visits on this program, because classW needs a write through the literal class name and that never co-occurs with an instance write. So the 4.1% is available only via the table.

Not built, and the reason is rank rather than difficulty. ~200 lines in the routine that decides class-attribute lowering, for 4.1% of a compile, at this ticket's own prio of 60. The design is straightforward if someone wants it: a name-keyed index over the write sites, staleness key on MainProgramTokCount, overflow stand-down to the scan, _OFF/_CROSSCHECK switches — the same house pattern as PyBuildDValTable. The switches to re-derive the ceiling are committed and documented at the routine.

Two retractions, both mine, both in devdocs/perf/lekkerzeilen-build-time.md. I reported 1.1% off round 1 of a three-round sweep and called it a null (the per-round differences were 0.71, 3.03, 2.58). And I explained the small number by claiming lekkerzeilen's build is not parse-dominated — false, and refuted by this ticket's own headline row, where the FindUClass index alone is 38.72% of that build. The explanation is magnitude and nothing else: 4.31e9 steps against 4.78e7, ninety times fewer for nine times less time.