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:
- variant
==,!=,<,<=,>,>= - variant
+and* - all six string comparisons (inline
repz cmpsb, no call, no heap -- and 0.00 at-O0TOO, so this row is not an optimiser effect) - list indexing
lst[i] for e in lstiteration- attribute read
obj.f len()- user function call, 2-arg call, method call, closure call, call returning a str
d[k]read,d[k] =store,k in d
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.