← board

String literals still allocate at 11,329 sites despite the static-handle pass

Measured

70-sample gdb profile, -g -O2 self-compile, binary c6cf4c33684a, at 4b3d34f74. Frame #0, with the ?? frames resolved through the compiler's own .map file:

samples
PXXStrFromLit 10 14.3%
its register-save thunk at 0x400109 2 2.9%
PXXAlloc 7 10.0%
PXXFree 6 8.6%
inline refcount inc/dec thunks 6 8.6%
PXXHdrSetMeta, PXXDynArrayRelease 2 2.9%
managed-string + heap runtime, total 33 47%

objdump -d on that binary: 11,329 call sites to the thunk. Each is the same three instructions — movabs $len, %rdi / movabs $ptr, %rsi / call 0x400109 — i.e. a heap allocation and a byte copy per evaluation of a string literal.

Why that is surprising

EmitStaticLitHandle (ir_codegen.inc) exists precisely to make a literal an address rather than a call, using the ready-made managed-string header InternStr lays down in the pool. Its own comment records the prize: "PXXStrFromLit was 9.28% of uforth's profile and the allocator around it most of another 19%."

It is not disabled. Its gate is if OptLevel < 2 then Exit, and -O2 is the default (compiler.pas:877), so it fires in every ordinary build — promoted from -O3 by 440c822e6. (The paragraph above it still said "behind -O3 for now"; corrected in the same commit as this ticket.)

So the pass runs and 11,329 sites still call the runtime. That gap is the ticket.

The suspicion, explicitly unverified

EmitStaticLitHandle takes the static path only for a node with IRKind[node] = IR_CONST_STR. A literal that reaches codegen wearing a different IR shape — a const AnsiString parameter, a comparison operand, a concat operand, a literal already folded into another node — would miss it and fall to the runtime call. This is a hypothesis from reading one guard, not a measurement. Confirm it by attributing the call sites before changing anything: they carry their length and pointer in the two movabs immediates, so the pool offset identifies the literal and Strs[] maps it back to text.

Gate

The static-literal count in a self-compiled -O2 compiler binary drops materially from 11,329, PXXStrFromLit's share of a fresh profile drops from 14.3%, and an interleaved min-of-N self-compile is faster. Self-host fixedpoint byte-identical; optdiff clean, since this changes emitted code at the default level.

Do not re-derive


RESOLUTION 2026-08-31 (frankB) — no defect; the ticket measured a -O0 binary

Binary f92c42a698509b6112d882bac1097efd63b1eefed50e960d6c2bad9e77e320a1 (converged after 2 round(s)), tree at 763233473.

The premise does not survive a positive control

Same source, two opt levels, counting the call sites to the PXXStrFromLit register-save thunk and the static-handle sites (inc QWORD PTR [rax-0x10]):

calls to PXXStrFromLit thunk static-handle sites
-O1 (pass gated off) 11,329 3
-O2 (pass on) 1,282 9,307

11,329 is the -O1 number, and it reproduces to the digit. The pass converts 9,307 of 10,589 literal sites — 87.9% — exactly as designed. There was never a gap between "the pass runs" and "the sites remain"; the sites counted were from a build where the pass was gated off.

Two further tells, both checkable without rebuilding anything:

The two competing explanations, both tested

The profile in 06524ef5b landed ~10 minutes before 191af3440, the fix for the -O2 inliner dropping a narrowing store. So there were two live explanations, and they predict opposite things:

(b) is ruled out by direct measurement. Building the fixedpoint at 4b3d34f74 — the exact sha the profile was taken at, seeded from stable_linux_amd64/default/pinned with the seed's mtime backdated, converged after 2 round(s), sha b11f52fb431669ab5701ca70a494d2cda640ed48327f3a2dca46032dd04a8186:

4b3d34f74, default -O2
static-handle sites 9,307
calls to PXXStrFromLit thunk 1,282

Identical to today's numbers, digit for digit, on a tree that predates the inliner fix. The pass was firing normally at the sha the profile was taken at, so no -O2 binary of that hour could have shown 11,329 sites. Only (a) survives, and the 0x400109 tell above says the same thing independently.

devdocs/dev/debugging-playbook.md already carries this exact trap under "Profile the SHIPPING binary — -g alone silently means -O0", including the instruction to check that -O2 -g's reported code=NNNN matches the plain build's. Doing that check is what closed this: -O2 -g gives code=9821976B, identical to the default build.

The real -O2 profile

tools/pxxprof, 30,520 samples, self-compile of compiler.pas by the -O2 -g build, symbolised through the compiler's own .map (3,849 entries) so the runtime blobs resolve. user 12.80 vs wall 13.18 — pure user CPU — and the <outside .text / vdso> bucket came out at 0.00%, so no renormalisation is needed.

share ticket claimed
string RELEASE thunk (dec [rax-16] + PXXFree) 7.72% 8.6% (as "refcount thunks")
PXXAlloc 4.07% 10.0%
str-slot assign thunk (StrFromLit + release) 2.93%
PXXFree 2.48% 8.6%
str RETAIN thunk 0.30%
PXXStrDecRef 0.21%
PXXStrFromLit 0.18% 17.1%
PXXStrAppend / PXXStrConcat / PXXHdrSetMeta / PXXDynArrayRelease 0.24% 2.9%
managed-string + heap, total 18.2% ~47%

PXXStrFromLit is 0.18%. It is not a target and there is nothing here to optimise. The 1,282 residual sites are literals that legitimately do not reach codegen as IR_CONST_STR; at 0.18% total, attributing them would cost more than it could return. The hypothesis in "The suspicion, explicitly unverified" is therefore left untested on purpose, not abandoned.

What survives, and where it goes

The group's real target moved, and it moved onto an existing ticket. The hot managed-string cost is refcount release traffic: one thunk, 300,745 call sites, 7.72% — the hottest thing in the compiler after ParseFactorCore (9.88%) — and its sample distribution piles onto the entry test rax,rax (3.35%) and the ret (2.60%) rather than the body, which is the signature of call overhead rather than work.

That thunk is the same x86-64 hand-emitted blob that bug-a-string-release-has-two-implementations-that-already-disagree documents as diverging from PXXStrDecRef. That ticket is now the centre of the string group, and it is a perf ticket as much as a correctness one.

Gate

None run beyond the build: no code changed. The self-host fixedpoint above is the build itself.

Log