On resolve_ssa, when there's a cycle among spilled vregs, parcopy would break it using a scratch reg like this:
Mov SCRATCH_REG, [Stack[0]] ; cycle parked here
Mov [Stack[0]], [Stack[1]] ; scratch_split: Mov SCRATCH_REG, [Stack[1]]
; Mov [Stack[0]], SCRATCH_REG
Mov [Stack[1]], SCRATCH_REG ; reads the clobbered value
as shown above, moving one stack slot to another also needs a scratch reg, and on x86_64 it currently uses the same SCRATCH_REG, clobbering the parked value.
When we implemented the previous register allocator with register spill support ruby#14936, this was not a problem because:
- We designed it so that only a single pass (
scratch_split) is allowed to use scratch registers. No other passes like resolve_ssa could clobber them by sharing the same scratch register.
- We used to use separate pools of stack slots for spilled block params and spilled vregs. So we never created a cycle by passing spilled vregs to spilled block params.
The current register allocator ruby#16295 got rid of (1), but I think we should have never left the design of resolving ParallelMov in scratch_split. It makes such a miscomp easy to rule out, it should reduce the number of scratch registers required for arm64, and it's easier to break such cycles in scratch_split which has two scratch regs for x86_64.
Fixing that for x86_64 with the current design would mean that you either use an extra stack slot, which results in slower code, or use two scratch regs in resolve_ssa, which makes it even more fragile and less maintainable.
Repro
def test(a, b, n)
i = 0
while i < n
a, b = b, a
i += 1
end
[a, b]
end
test(10, 20, 4)
test(10, 20, 4)
p test(10, 20, 5)
$ ./miniruby -v ~/tmp/a.rb
ruby 4.1.0dev (2026-08-21T15:07:09Z master d32793fe8e) +PRISM [x86_64-linux]
[20, 10]
$ ./miniruby -v --zjit-call-threshold=2 ~/tmp/a.rb
ruby 4.1.0dev (2026-08-21T15:07:09Z master d32793fe8e) +ZJIT dev_nodebug +PRISM [x86_64-linux]
[20, 20]
This was introduced as a latent bug in ruby#16295. ruby#17853 was the first revision that surfaced the problem for this repro.
On resolve_ssa, when there's a cycle among spilled vregs, parcopy would break it using a scratch reg like this:
as shown above, moving one stack slot to another also needs a scratch reg, and on x86_64 it currently uses the same SCRATCH_REG, clobbering the parked value.
When we implemented the previous register allocator with register spill support ruby#14936, this was not a problem because:
scratch_split) is allowed to use scratch registers. No other passes likeresolve_ssacould clobber them by sharing the same scratch register.The current register allocator ruby#16295 got rid of (1), but I think we should have never left the design of resolving
ParallelMovinscratch_split. It makes such a miscomp easy to rule out, it should reduce the number of scratch registers required for arm64, and it's easier to break such cycles inscratch_splitwhich has two scratch regs for x86_64.Fixing that for x86_64 with the current design would mean that you either use an extra stack slot, which results in slower code, or use two scratch regs in
resolve_ssa, which makes it even more fragile and less maintainable.Repro
This was introduced as a latent bug in ruby#16295. ruby#17853 was the first revision that surfaced the problem for this repro.