Skip to content

Packed loop: a throw whose value is CONSTRUCTED stays on the generic path (7.99 vs 0.95 ns) — needs a GC-safety argument, not just admission #9232

Description

@proggeramlug

Follow-up to #9210 / #9230. Recording a measured 8.4× opportunity together with the reason I did not take it, so the next person starts from the hazard rather than rediscovering it.

Where things stand

Quiet host (Mac mini, load 1.5), arr 512 f64 elements, never-taken throw, 5 reps × 2 runs, stable to 0.01 ns/op:

loop body perry node
throw PRE (pre-built value) 0.95 1.10 perry 1.16× faster
throw new Error("bad") 7.99 7.73 perry 1.03× slower
throw new Error("bad " + i) 7.99 7.74
throw new Error() 7.99 7.75
throw "bad " + i 7.99 7.74
no throw at all 0.95 1.10 perry 1.16× faster

Every CONSTRUCTED operand lands on exactly 7.99 — one shared generic path, and the Error-ness is irrelevant (a bare string concat behaves identically). Node shows the same cliff, so we are at parity here rather than behind; the prize is that 0.95 is achievable and node cannot reach it.

Why it is not just an admission tweak

#9185 admitted exactly these shapes and produced a silent wrong answer. #9230 fixed the writeback half of that, so the accumulator staleness is no longer the blocker. The remaining question is one #9185 never asked:

Block::contains_gc_unsafe_call (crates/perry-codegen/src/block.rs:299) exempts a block ending in unreachable:

if matches!(self.instructions.last(), Some(LlInst::Unreachable)) {
    return false;
}

That is the codebase sanctioning "control never returns, so a pointer the collector moved is never dereferenced" — the reasoning #9185 quoted. But it covers only the TERMINATING block. A constructed operand is lowered into blocks that PRECEDE it and end in ordinary branches, so the exemption does not reach them, and those blocks contain the allocating call.

The packed-f64 versioned loop does not run that scan at all (it is used by the class-field loop at loops.rs:4669 and :4167), so its safety rests entirely on the admission predicates. That is why the wrong answer got through: nothing downstream was going to catch it.

What a fix has to establish

Not "is the throw block safe" — that part is already argued — but: the fast clone caches the array data pointer, and an allocation between the cache and the unwind can trigger a moving collection. Someone has to show either that the cached pointer is dead from the allocation onward (plausible: the loop never resumes, and #9230's flush already stores the accumulators before the throw), or that it is properly rooted across it.

I did not attempt this. It is the [[cc-8770]] bug class — a bare pointer held across an allocation — which took eighteen sessions to root-cause the last time, and 3.4% against node is not the reason to re-enter it at the end of a long session. It is worth a design pass on its own terms, where the payoff is 8.4× and beating node on a very common validation shape rather than merely matching it.

PERRY_PACKED_LOOP_TRACE=1 (#9204) names the rejecting gate if you pick this up.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions