← board

What it is

Three functions decide whether a method must carry the function-object ABI. PyMethodUsedAsValue is the entry point; it consults PyModuleHasComputedGetattr first, and that one is an all-or-nothing switch:

if PyModuleHasComputedGetattr then
begin
  Result := True;    { every name, unconditionally }
  Exit;
end;

The comment says so and says why: "A COMPUTED getattr reads a method by a name no token spells, so the scan below cannot see it... Normalise every method in a module that does this. Coarse on purpose."

The comment says "in a module". The code says "in the program", and before 2026-09-20 those two readings were nearly the same thing — the scan walked 1..MainProgramTokCount, the main file only, so a library or a demo whose computed getattr lived in an imported module simply never tripped it. The widening in 0c508e507 made the code's reading the operative one.

Why it is not simply a regression to revert

The widening is the fix for bug-n-a-bare-read-of-a-method-as-a-value-yields-a-garbage-code-address. A computed getattr in an imported module answered False, no method was normalised, and pydynattr_get_v's bound-method arm then bound mi^.Code through pybound_new_star against a native signature. That is not a diagnostic; it is a jump to a garbage code address. Two committed fixtures, rc=139 on pin v413 and rc=0 at HEAD.

So the coarse arm is load-bearing and the blast radius is the price of it.

The measurement, with its population

demo        /home/neo/lekkerzeilen, HEAD 25e188f, clean tree
command     pascal26 --threadsafe -dSDL_DISABLE_IMMINTRIN_H \
              -dGL_GLEXT_PROTOTYPES lekkerzeilen/__main__.py <out>
CWD         /home/neo/frankB (repo root)

before   code=12047464B  procs=11594     bin/build.log, 18:23, compiler 55f1ef09*
after    code=12159489B  procs=11594     compiler 7e5bea1ba120 at 1fbe6e104

delta    +112025B code (+0.93%), procs unchanged

CONTROLLED RE-RUN, franks-5b, 5b1045dad -- one tree, one CWD, both binaries in-tree under distinct names, --where | grep -c MISSING equal on both arms:

                 24c2f18de          HEAD (0c508e507 + 1fbe6e104)
code=            12,047,464 B       12,159,489 B      (+0.93%)
procs=               11,594             11,594        identical
compile              130.70/130.95 s    104.15/104.19 s   (-20.3%)

The size rows reproduce to the byte. The compile row is a RANGE of two commits and is the memoisation, not this ticket's subject: the widening alone should cost time, so 1fbe6e104 is plausibly worth more than the 26.5 s it nets. Nobody has separated them and nobody needs to for this ticket.

THIS COMPARISON IS NOT CONTROLLED AND MUST NOT BE QUOTED AS IF IT WERE. The "before" row is another seat's build from another checkout, and the two compilers differ by ~44 commits, not by mine alone. What it establishes is a bound and a direction, not an attribution. The controlled experiment -- build a compiler at 24c2f18de (the parent; franks-5b measured its sha as 55f1ef09492b), compile the demo from the same root with the same CWD, and diff code=/procs= against HEAD -- has been run, by franks-5b at 5b1045dad, and its numbers are the second table above. This paragraph said "nobody has run it" while sitting fifteen lines below the result it was asking for.

procs being identical is the informative half: normalisation did not add procedures, so the growth is boxing emitted inside bodies that already existed. Code size is not run time, and no run-time number has been taken.

Why narrowing is hard, so nobody spends a day discovering it

The obvious narrowings do not work:

What might work, unexplored: restricting to classes that are reachable as a computed-getattr receiver, which is a reachability analysis nobody has written; or accepting the boxing and making the boxed path cheaper, which is where the frame-rate profile already points.

Where it connects

A 100-sample PC profile of the demo's frame loop the same evening put ~80% of thread-1 self time in the pxx/NilPy runtime — variant tag-dispatch, retain/release, boxing, allocation — with no single site above 15% and only ~15% in application code. That profile was taken on the 18:23 binary, i.e. BEFORE this change. So the demo the owner is watching does not yet carry this cost, and a rebuild at HEAD is the first build that will. That makes the controlled experiment above worth running before anyone re-measures the frame rate, or the delta will be attributed to whatever else landed.

What would retire this ticket

Either a measured run-time cost small enough to close it as chosen — in which case say so and record the number — or a narrowing that keeps both fixtures passing. The 20.3% build-time win recorded above is not that number and does not bear on it; it is the memoisation paying for the widening at compile time, in a range that contains both commits, and the boxing it leaves in the emitted code is untouched by it. Both fixtures must pass: they are the only thing standing between this arm and a SIGSEGV.

BLOCKED-BY WIRED 2026-09-22 (frankz-e5, at the ticket owner's request): NARROWING THIS ARM UNMASKS AN -O2 SEGFAULT

The edge is an ORDERING dependency, not a shared cause, and it points the way the ranker needs it to point: narrowing the coarse arm is the whole point of this ticket, and narrowing it is what exposes bug-a-the-nilpy-print-promo-argument-temp-is-never-zero-initialised in programs that work today.

franks-5b's measurement, relayed and attributed rather than re-run here: when the coarse arm fires, every method takes variant params and results, the tyPromoInt64 pair disappears into tyVariant, and the crash goes with it. Its first benchmark for this ticket ran clean because it contained a computed getattr; deleting the getattr is what produced the segfault.

So whoever takes this ships a narrowing that turns working programs into SIGSEGVs unless the -O2 bug lands first — and the reds will look like their own work, which is the self-blaming reading this fleet has now recorded twice in one day as the one that terminates a search.

What this edge does to the ranking, said out loud because I wired it: this ticket is already an edge under umbrella-lekkerzeilen-runs-at-15-fps (p95), so the blocker now inherits p95 transitively where its own number is p80. That is the intended effect and it is the argument for the edge — a crash at the default -O that gates this umbrella's own perf work should not rank below the perf work it gates.

What would retire the edge: the -O2 bug landing, or a measurement showing the narrowing can be done without un-boxing the promo-int pair. Nothing else.

2026-09-22 — the run-time cost, measured: 4.01x, and it is not the heap

The ticket's one open question was the RUN-TIME cost of the boxing. It could not be asked on lekkerzeilen without the display, which is another seat's, so it is asked on a CPU-bound synthetic instead — with the caveat that owns the result: this measures the fixture, and the fixture's inner loop is almost nothing but method calls. Read it as an upper bound for call-dominated code, not as a prediction for the demo.

The fixture

bench_mod.py holds a three-method class and one computed getattr in a function that is never called, so the coarse arm fires while the program never executes the dynamic path — which is what lets the two arms run the same program. bench_main.py calls all three methods in a 2,000,000-iteration loop.

Both arms are one -d apart from one tree; p26_ga_on is byte-identical to compiler/pascal26 (fda77c48b8ee4b03), so the ON arm is the shipped compiler and not a variant of it.

Controls, before the number

The numbers

interleaved, min-of-5, load 3.08 before / 2.92 after

boxed  (arm fires)   8.91  8.85  8.96  8.81  8.69   -> min 8.69 s
native (arm off)     2.25  2.20  2.17  2.24  2.23   -> min 2.17 s
                                                       4.01x

code    502,733 B  vs  399,713 B     +103,020 B, +25.8%
procs        2232  vs       2232     identical
allocs          7  vs          7     IDENTICAL

The allocation row is the informative one. 6,000,000 method calls, seven allocations, the same seven in both arms. The 4x is not the heap: it is ABI width and variant tag dispatch inside bodies that already existed — which is the same story procs being identical tells about the code growth.

That matters for the fix direction. The ticket's own suggestion was "accept the boxing and make the boxed path cheaper"; this says the target is dispatch and parameter passing, and that chasing the allocator would find nothing.

Read the two halves separately, because only one of them is what was asked for. The ticket wanted a COST and it got one: 4.01x. It also carried an unstated MODEL — that boxing means heap traffic — and the same run refutes it. Six million calls, seven allocations, the same seven in both arms. A measurement that confirms the cost and falsifies its assumed mechanism reads as merely confirmatory unless the second half is pointed at, and the second half is the one that decides where the work goes: it eliminates the fix everyone would have tried first and turns "boxing is slow" into something a great deal narrower. The negative row is the finding.

What this does NOT say

It does not say lekkerzeilen is 4x slower. The demo's frame does far more than call methods, the profile already puts ~80% of thread-1 self time in the runtime generally, and no one has run the demo at HEAD. A 4.01x on a fixture whose loop is only method calls is the ceiling of this effect, and the demo's share of it is unmeasured.

The switch is committed

-dPXX_COARSE_GETATTR_OFF, documented at the arm, so the price is re-derivable without rebuilding the instrument. It is timing only — it suppresses the crash fix, and a program that actually executes a computed getattr jumps to a garbage code address without it.

A blocker discovered by this fixture, and it gates any narrowing

The first version of this benchmark segfaulted in the native arm, and it was not the arm's fault: bug-a-the-nilpy-print-promo-argument-temp-is-never-zero-initialised. The NilPy print() promo argument temp is never zero-initialised and the cleanup path releases it, on pin v418 and at HEAD. The frame-shape and -O2 framings this ticket originally cited were both retired on 2026-09-22 -- the fault depends on what the previous frame left on the stack, so it reproduces at every -O level and the local count only chooses which slot inherits which bytes. Boxing masks it — when the coarse arm fires, the promo-int pair becomes tyVariant and the crash disappears. The benchmark above only runs because a fourth local was added to leave that composition.

So narrowing this arm will turn working programs into segfaults until that bug is fixed. Whoever takes this ticket needs it closed first, or the reds will look like their own work.

MY OWN BENCHMARK PINS AN AXIS I DID NOT ENUMERATE — the 4.01x is a FLOOR for the dispatch half, not a measurement of it

Source reading only, 2026-09-22, no box time, unmeasured and flagged as such. Recording it against my own number before someone else finds it.

The boxed call does not merely widen the ABI. It routes through a run-time method lookup that the direct call does not have. pyparser.inc:16940 names the chain: the emitted pydyn_meth<n>(recv, 'name', a0..) goes to PyDynMethL, which resolves a declared METHOD first (PyFindMethCI + PyHostCall), then pydynattr_get for a callable ATTRIBUTE, then AttributeError. And PyFindMethCI (compiler/builtin/pyeval.pas:973) is a linear scan with a case-insensitive compare per entry, walking the parent chain, with no cache and no interning:

  curr := cls;
  while curr <> nil do
  begin
    if curr^.MethCount > 0 then
      for i := 0 to Integer(curr^.MethCount) - 1 do
        if PyEqCI(meths[i].NamePtr^, name) then ...
    curr := PClassRTTI(curr^.ParentRTTI);
  end;

O(methods x inheritance depth) per dispatched call.

WHY THIS DOES NOT CONTRADICT THE ALLOCATION ROW. PyEqCI is explicitly allocation-free and says so at its own definition — a previous lowercase-both-then-compare version cost two PXXStrFromLit buffers per call and was removed. So allocations IDENTICAL at 7 stands, and the lookup is pure CPU. The two measurements agree.

WHY THE 4.01x UNDERSTATES THE DISPATCH HALF. PyEqCI rejects on a length mismatch before comparing a single character. My fixture's receiver is a three-method Vec with a shallow hierarchy and distinct-length method names, so the scan is roughly three integer compares and a hit. That is the cheapest value this axis can take, and my benchmark holds it there by construction — not by choice, but because a fixture is a minimised artefact and a minimal class has few methods, short chains, and names that differ. The axis is a property of the RECEIVER'S CLASS SHAPE, which nothing in the fixture varies and nothing in the write-up named.

This is CLAUDE.md's "a minimal case pins an axis you did not enumerate" landing on my own measurement, one section below where I said the cost was ABI width and tag dispatch. That attribution may still be right for MY fixture and cannot be carried to lekkerzeilen, whose classes are neither three-method nor shallow.

E4 — vary the receiver's class shape, prediction written first

Setup: widen Vec from 3 methods to ~30, give them equal-length names so PyEqCI's length short-circuit cannot fire, and put the called method LAST in the table (CLAUDE.md's interesting-element-last rule — first-position is the arrangement that passes). Hold the loop, the arity and the argument types fixed. Re-run both arms interleaved, min-of-5.

PREDICTION:

  1. The unboxed arm stays flat — a direct call is statically resolved and never enters PyFindMethCI.
  2. The boxed arm inflates, so the ratio rises above 4.01x.

WHAT WOULD FALSIFY IT: the ratio not moving. That would mean the boxed path does not reach this lookup at all — in which case my "variant tag dispatch" attribution is naming something I have not identified, and the mechanism is unestablished rather than merely unquantified.

Either outcome is worth the run, because a ratio that is a function of class shape cannot be quoted as a single number, which is what this ticket currently does.

NOT FILED AS ITS OWN TICKET, DELIBERATELY

No open ticket mentions PyFindMethCI's cost (checked urgent working unfinished blocked backlog-nilpy backlog-core low-prio rainy-day; the only open hit is a prose aside in the nested-class ticket, and every other hit is in done/). It is not filed separately because it is unmeasured, and a perf ticket whose number is a source reading is the thing this repo files instead of measuring. It belongs here, where the measurement that would settle it is already set up.