← board

A string release has two implementations, and they already disagree

Measured

grep -c over the backends, at 4f6b70995c3a:

backend calls PXXStrDecRef hand-emitted release blob
x86-64 5 4
arm32 1 0
aarch64 1 0
riscv32 10 0

x86-64 is the outlier. ir_codegen.inc, at AnsiStrReleaseAddr:

  EmitAcquireHeapLock
  test rax, rax        ; jz done
  dec qword [rax-16]   ; (lock dec under --threadsafe)
  jnz done
  ...free...

and PXXStrDecRef in builtinheap.pas:

  if PWord(rcAddr)^ >= PXX_STATIC_RC_FLOOR then Exit;   { static literal }
  rc := PWord(rcAddr)^ - 1;
  PWord(rcAddr)^ := rc;
  if rc = 0 then PXXFree(...);

The guard is the difference. PXXStrIncRef's comment states why it exists: a static literal block "must never be WRITTEN, not merely never freed", because a store dirties a page shared with code — measured at 1600x under qemu — and defeats ever placing those blocks in a non-writable segment. The blob writes.

Why this is a bug and not a style note

The two paths are not interchangeable, so "one concept, two implementations" is not the whole complaint: they have already drifted, and the drift is silent. It is bounded today, and the reason matters because the obvious one is wrong. builtinheap.pas's own PXX_STATIC_RC_FLOOR comment refutes "$40000000 is out of reach" in terms — "2.5M literal stores a second for 400 seconds". What actually bounds it is that the mismatch un-arms itself: an unguarded decrement takes rc to FLOOR-1, at which point every guarded increment stops being skipped and behaves normally, so rc oscillates just under the floor, bounded by the number of simultaneously live references and never by elapsed time. (On x86-64 the pair happens to be balanced outright — EmitStaticLitHandle emits an unguarded inc qword [rax-16] to match the blob's unguarded dec.) Nothing is freed wrongly either way — the cost is the dirtied page and the fact that the next change to release semantics will be made once and be wrong on five targets, or made twice and drift again.

How it was found, which is the part worth keeping

A PXX_HEAP_DEBUG check added to PXXStrDecRef (a stale-handle detector) was structurally unable to fire on x86-64 — the target it was developed on — because releases there never enter that routine. It was caught only by a positive control aimed at the exact mechanism; the generic data-write control passed happily while the thing it stood in for could not be observed at all.

Fix, and the reason to be careful

Deleting the blob in favour of the call is the obvious shape and is a performance decision as much as a correctness one: the blob exists because the call cost showed up in a profile (~11% of a one-line NilPy compile was on the pushes and pops it removes, per the comment above it). So the honest options are (a) delete the blob and measure what it costs, or (b) keep it and add the PXX_STATIC_RC_FLOOR test to it, which removes the disagreement without removing the mechanism. (b) is smaller and does not close the question, and the question — why release is emitted twice — is what root-cause-over-microfix would have you answer.

Gate

make compiler/pascal26 plus a literal-heavy repro; if (a), a benchmark.

2026-08-31 — the static-literal convention exists on TWO of seven backends

Measured while running an IR differential for [[bug-a-no-cross-target-can-build-the-compiler-itself]]: compile one program, dump the shared IR, then compare where each backend emits retains and releases against that same IR.

EmitStaticLitHandle (ir_codegen.inc) and EmitStaticLitHandleA64 (ir_codegen_aarch64.inc) are the only two. grep -c EmitStaticLitHandle:

backend static-literal path
x86-64 yes
aarch64 yes
i386, arm32, riscv32, wasm32, xtensa none

So for an IR_CONST_STR node, x86-64 and aarch64 at -O2 hand back the pooled literal's address as a ready-made managed handle, and the other five call PXXStrFromLit — a runtime call, a PXXAlloc, a byte copy of data already in the image, and a PXXFree when the reference dies, per literal evaluation. Confirmed by disassembly, resolving BL/BL-imm26 targets inside one procedure through the --map file: for a := 'hello', arm32 emits a PXXStrFromLit call and aarch64 emits none, with retain/release counts otherwise identical (5 PXXStrDecRef + 1 PXXStrIncRef on both).

Two things follow, and they pull in opposite directions:

  1. It sharpens this ticket. The disagreement is not only how release is emitted, it is what release is being asked to release — an owned rc=1 heap block on five targets, a saturated static block on two. Any change to release semantics now has to be correct under both conventions.
  2. It is also a Track O item on its own (~a heap round-trip per literal on five backends, including both 32-bit targets and both ESP ISAs). Not filed separately yet: the O charter wants delivered value measured, not opportunity inferred from an instruction census, and nobody has benchmarked it.

What it does NOT explain: the arm32 write-after-free that prompted the differential. riscv32 and i386 also lack the static path and are clean (0 reports), so heap-resident literals are not sufficient to produce the symptom.


RESOLUTION 2026-08-31 (frankB) — guarded both blobs, deleted the compensation

Binary 4ae31c9e10cf607bad6debc43131f75f66944d93bafbaf8c6bab96b44cbdcbf5, converged after 2 round(s).

What changed, and why it is one change and not three

compiler/ir_codegen.inc, three edits that only make sense together:

  1. Release blob (AnsiStrReleaseAddr) — added cmp qword [rax-16], MSTR_STATIC_RC / jae done, the guard PXXStrDecRef has always had and this blob bypassed.
  2. Retain blob (EmitAnsiStrRetainLocked) — the same guard, because builtinheap.pas says in terms that the pair "must move together, because suppressing one direction only is what would let a static block's count drift."
  3. EmitStaticLitHandledeleted its inc qword [rax-16]. That increment was never wanted for itself; its own comment says it existed to restore the ownership convention that the release blob's unguarded dec broke. Guard the dec and the compensation has nothing left to compensate.

That is the root-cause shape rather than the microfix: the pair is now balanced by both sides doing nothing, not by an increment cancelling a decrement. It is also exactly what EmitStaticLitHandleA64 has done since 2026-08-30 — this brings the hand-written x86-64 path to the design the other six backends already had, which is normalise-dont-special-case applied to the second path that stayed broken.

Verified

Measured

workload before after
self-compile of compiler.pas 12.93 s 12.65 s not a claim — a null A/B of two identical binaries on this box moved 1.2%, so 2.2% is not resolved
shared-literal parallel for, 4M, 12 workers 1.18 s 0.95 s ~19%
same loop serial, --threadsafe 0.14 s 0.12 s ~14%

The self-compile number is reported as unresolved on purpose. The parallel and serial threadsafe numbers are far outside that noise floor and are the real result: they are the locked inc/dec on a shared static block disappearing.

Option (a) is refuted, not deferred

The ticket left open whether to delete the blob and call PXXStrDecRef. The profile answers it: the release blob is 7.72% of a self-compile (30,520 samples, tools/pxxprof through the .map), and its samples pile onto the entry test rax,rax (3.35%) and the ret (2.60%) rather than the body — the signature of call overhead, on 300,745 call sites. Replacing a 4-instruction leaf with a call into compiled Pascal would be strictly worse. The two implementations stay, now agreeing.

Residual, handed to the ticket that owns it

Under --threadsafe both blobs still call EmitAcquireHeapLock before the nil test and the guard, so releasing a static literal on 12 workers still serialises every worker on one spinlock line for a path that then does nothing. That is why the parallel loop above is still ~8x its serial time after this fix. Hoisting the nil test and the guard above the lock is safe — the lock protects the free list, and a saturated block's count is immutable, which is the same argument PXXStrIncRef already makes for its own lock-free read — but it needs rel32 patching across the lock body and lands in threadsafe-only code, so it is a separate change. Written up in [[bug-a-a-shared-ansistring-handle-in-a-parallel-loop-is-11x-slower]].

Log