#9185 admitted Stmt::Throw to the packed-f64 versioned loop. That is a silent wrong answer on main today, and I am reverting the admission in the linked PR. This issue records what would be needed to re-admit it.
The defect
break, continue and return leave the fast clone through normal CFG edges, and those edges flush the loop-carried locals back to their frame slots. An unwind edge does not. So any local the loop wrote reads back stale after the throw:
const arr: number[] = [];
for (let i = 0; i < 64; i++) arr.push(i);
const PRE = new Error("boom");
let s = 0;
try {
for (let i = 0; i < arr.length; i++) { if (arr[i] === 40) throw PRE; s += arr[i]; }
} catch (e) { console.log(s); } // perry: 0 node: 780
Measured on 84185b5656, both orderings wrong:
| shape |
node |
perry |
PERRY_PACKED_LOOP_ABRUPT=0 |
taken break, read after |
780 |
780 |
780 |
taken continue, read after |
1024 |
1024 |
1024 |
taken throw, accumulate after |
780 |
0 |
780 |
taken throw, accumulate before |
820 |
0 |
820 |
taken throw, read via closure |
780 |
780 |
780 |
The 0.5.1220 release (pre-#9185) also gives 780, confirming the regression.
Why the existing tests passed
#9185 shipped two taken-throw tests and both are blind to this. throwPre reads the thrown value and an untouched parameter; throwValue throws s itself. Both observe the clone's live SSA value, which is correct — never the frame slot left behind. The closure row above is correct for the same underlying reason from the other side: a captured accumulator is boxed rather than register-promoted, so there is no promoted copy to lose.
The general rule this is the second instance of: an abrupt-exit optimisation is only tested by an exit that is actually taken AND a subsequent read of something the loop wrote. #9154's labeled-break bug had the identical shape.
To re-admit
Emit the loop-carried writeback at the throw site rather than relying on the exit block, i.e. flush promoted locals immediately before the throw is emitted. The admission predicate is not the missing piece; the flush is. Worth confirming the same edge is handled for any other construct that can leave a clone without passing through its exit block.
#9185admittedStmt::Throwto the packed-f64 versioned loop. That is a silent wrong answer onmaintoday, and I am reverting the admission in the linked PR. This issue records what would be needed to re-admit it.The defect
break,continueandreturnleave the fast clone through normal CFG edges, and those edges flush the loop-carried locals back to their frame slots. An unwind edge does not. So any local the loop wrote reads back stale after the throw:Measured on
84185b5656, both orderings wrong:PERRY_PACKED_LOOP_ABRUPT=0break, read aftercontinue, read afterthrow, accumulate afterthrow, accumulate beforethrow, read via closureThe 0.5.1220 release (pre-#9185) also gives 780, confirming the regression.
Why the existing tests passed
#9185shipped two taken-throw tests and both are blind to this.throwPrereads the thrown value and an untouched parameter;throwValuethrowssitself. Both observe the clone's live SSA value, which is correct — never the frame slot left behind. The closure row above is correct for the same underlying reason from the other side: a captured accumulator is boxed rather than register-promoted, so there is no promoted copy to lose.The general rule this is the second instance of: an abrupt-exit optimisation is only tested by an exit that is actually taken AND a subsequent read of something the loop wrote. #9154's labeled-
breakbug had the identical shape.To re-admit
Emit the loop-carried writeback at the throw site rather than relying on the exit block, i.e. flush promoted locals immediately before the throw is emitted. The admission predicate is not the missing piece; the flush is. Worth confirming the same edge is handled for any other construct that can leave a clone without passing through its exit block.