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.
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),
arr512 f64 elements, never-taken throw, 5 reps × 2 runs, stable to 0.01 ns/op:throw PRE(pre-built value)throw new Error("bad")throw new Error("bad " + i)throw new Error()throw "bad " + iEvery 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 inunreachable: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:4669and: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.