← board

Something in the roofs frame allocates and it is not the primitives

Two measurements that are both correct and do not fit together. That is the finding; the resolution is open.

What each one says, with its population

7a's roofs profile — gdb SIGINT sampling, lekkerzeilen roofs scene, 2026-09-22. 20.2% of frame time in allocator/refcount, 14.6 of those points in the allocator proper. The binary had no debug symbols — evidenced by gdb printing "(No debugging symbols found in .../bin/demo)" in the run that produced the samples, and corroborated by 7a having had to resolve symbols by hand from the .map, which is only necessary when -g is absent. So this is a default -O2 binary and NOT the -g-implies--O0 trap (debugging-playbook.md, "Profile the SHIPPING binary").

The allocation sweep — compiler 59b5bf39acd1 at HEAD (not the pin), x86-64 linux, -dPXX_ALLOC_CENSUS, allocations per operation from (allocs@11000 - allocs@1000) / 10000 so every fixed cost cancels. At the default -O2: variant ==/</+/*, lst[i], for e in lst, attribute read, len() and all six string comparisons are 0.00 per evaluation. Two controls held in the same run — integer arithmetic at 0.00 (so fixed cost is not being attributed to the loop) and an object allocated per iteration at 1.01 (so the instrument can see an allocation at all). Without both, the zeros would be silence rather than measurements.

EXCLUDED — this list is the value of the ticket

A negative result that names its exclusions is a finding; one that does not is a shrug. Measured at 0.00 allocations per evaluation at the default -O2, on the tree and with the controls above, and therefore NOT the source of the 14.6 allocator points:

The call, dict and iteration rows matter most, because those are the operations a frame loop is MADE of and the ones a reader would reach for first.

The question

Something in that frame allocates at -O2, and it is not the operations anyone would have guessed. Neither measurement is in doubt; what is missing is the call site.

What DOES allocate at -O2, same sweep — the candidate set

operation alloc/op at -O2
s[a:b] slice 1.01
s + t concat 1.01
s.upper() (5 chars) 4.59
[a, b, c] list literal 1.79
object construction, list.append > 0

s.upper() is the interesting row: PyStrMapCase starts at '' and appends per character, one reallocation each, so its cost scales with string LENGTH and 5 characters already cost 4.59. Anything formatting text per frame hits that.

THIS TABLE IS NOT A LOCATION AND MUST NOT BE READ AS ONE (the shape is frankb-8e's, 2026-09-22, ead9b069e: their exclusion of the program axis was rigorous, and the LOCATION of the cause inherited that rigour without earning it). The exclusions above are MEASURED, with two controls, and they are exclusions. These rows are a different claim with a different standing: they say these operations allocate in a microbenchmark, not that the roofs frame performs them. Nobody has checked whether roofs slices strings, calls upper(), or builds list literals per frame. Sitting below a rigorous exclusion list, they read as continuous with it, and they are not. Establishing that a candidate is actually EXECUTED in the frame is the work; this table only says which operations are worth grepping the demo for.

AND THE 20.2% IS ITSELF AN UNSPLIT BUCKET -- two mechanisms added together. 8e's RTTI rung came apart the same way: of 174,350 B in a vmt/rtti slot bucket, 94.7% was reflection BY NAME and actual dispatch slots were 222 B, 0.1%, with the proposed fix reaching only one of the five mechanisms inside. Here the bucket is allocator/refcount: 14.6 points allocator, the remainder refcount. Removing an allocation does not remove refcount traffic. A static MSTR_STATIC_RC block still has retain/release CALLED on it -- the calls no-op, they do not vanish -- so the s[i] fix should move the allocator half and leave the refcount half untouched. A re-profile that shows the total barely moving is therefore NOT evidence the fix failed; split the bucket before reading the result.

FIRST MOVE, and it may answer the whole thing

s[i] allocated 1.0054/op at -O2 until 2026-09-22 — a fresh one-character string per subscript, and the same for a for-in variable and list(s), which all lower to pystr_charat. That is fixed now (a static 256-entry const table shared by pystr_charat and pychr_s). 7a's profile predates the fix. A THIRD spelling of the same one-byte-string allocation, pystr_ofchar -- the compiler's char->str promotion, wired at four sites in pasparser_lval.inc, ir.inc and pyparser.inc -- is NOT yet on the table and still allocates.

So: re-profile roofs at a tree carrying the fix before investigating anything else. If the allocator share drops, the question is answered and this ticket closes. If it does not, the 14.6 points are still unexplained and the next instrument is a reverse call graph on PXXAlloc over the roofs binary — build -g -O2 (BOTH: pxx emits no section headers without -g, and -g alone silently means -O0), then attribute each call site to its enclosing proc via an ADDRESS-SORTED map. pxx writes the map in neither address nor name order, so taking "the next line" yields a plausible wrong range.

READ THAT GRAPH AS A CENSUS, NEVER AS A RANKING (frankb-8e, 2026-09-22, relayed by frankz-e5). It enumerates every direct call site, so it has no first-wins step -- but it credits the DIRECT callee, and a proc that allocates through a shared helper is credited to the HELPER, not to itself. That is 8e's --dce-why inversion one subsystem over: measured against a differential, its first-reason totals over-stated TPyFile.writelines 8.6x because it heads a shared path and under-stated TPyDict.update 3.3x because cost spread thin across small bodies is invisible to a top-twenty listing. Wrong in both directions at once, so no fudge factor rescues it. A call-graph census answers "does this allocate at all" honestly and answers "how much" not at all.

AND KEEP A CONTROL WHOSE EXPECTED VALUE IS ZERO (8e, same message). Not merely so fixed cost is not attributed to the loop -- that is the defensive reading. A row whose expected value is 0 admits no explaining-away, so it is the row that catches an instrument the agreeing rows would have certified. 8e's body-count column was out by 4 while its byte column agreed exactly, and the only row that exposed it was the one where bytes were 0.

What would make this ticket wrong

A re-profile showing the allocator share was always small and 20.2% was a sampling artefact. 7a's own instrument had a defect that made an empty capture present as twenty samples (its === SAMPLE header), found and fixed the same day — so the sample COUNT of the run quoted here is worth re-confirming before a lot of work is spent on the strength of it.