Skip to content

Carry the loop's float remainder as bits, so that it needs no stack slot - #55

Merged
folbricht merged 1 commit into
mainfrom
float-mod-spill
Oct 21, 2026
Merged

folbricht merged 1 commit into
mainfrom
float-mod-spill

Conversation

@folbricht

Copy link
Copy Markdown
Owner

Fixes the 1–4% slowdown on loop-bound scripts that #53 (81ddf67) introduced. #54 found it while rerunning the benchmarks.

Cause. The inline float remainder kept its result in a float register across the branch for a zero remainder (r = math.Float64frombits(x.n & (1 << 63))). The register allocator spilled that float to a stack slot, which grew fast's frame from 0x30 to 0x38 bytes. That moved the slot where the dispatch head stores pc from 0x78 to 0x80, which is past the reach of a one-byte displacement. The store therefore grew from 5 to 8 bytes, and the indirect jump right after it now crosses a 32-byte boundary. The Go assembler pads such jumps with a NOP (the mitigation for Intel's JCC erratum), so every bytecode the loop dispatches runs one more instruction:

before #53                    #53                           this PR
MOVQ R10, 0x78(SP)            MOVQ R10, 0x80(SP)            MOVQ R10, 0x78(SP)
LEAQ table(IP), CX            LEAQ table(IP), CX            LEAQ table(IP), CX
JMP 0(CX)(R9*8)               NOPL 0(AX)                    JMP 0(CX)(R9*8)
                              JMP 0(CX)(R9*8)

Fix. Build the result's bits in an integer register and convert once, in setFloat. The semantics are unchanged: the zero case still takes bit 63 of the dividend. The frame and the dispatch head are back to what they were before #53, and the float remainder stays inline.

Instructions per run, fast loop:

Script before #53 main this PR
fib 179.4M 184.1M 180.6M
nbody 475.6M 484.2M 475.7M
fieldread 171.1M 174.1M 171.1M
floatloop 170.1M 173.1M 170.1M
closures 425.7M 445.7M 430.2M

In time (minimum of 5 interleaved runs), against main, on the fast loop:

  • nbody -2.7%
  • fieldread -2.8%
  • spectralnorm -2.3%
  • policy -1.2%
  • fib -0.2%: a NOP costs almost nothing in time

The asmgate baseline goes back to its pre-#53 trade: GETFIELD 7 stack accesses, GETTABLE 2 (instead of 6 and 3). On fieldread, which is mostly GETFIELD, that trade runs fewer instructions than main.

The float remainder that 81ddf67 added kept its result in a float across the
branch for a zero remainder, and the register allocator gave it a stack
slot. That grew fast's frame by 8 bytes, which moved the dispatch head's
store of pc to 0x80(SP), past the reach of a one-byte displacement: the
store grew by 3 bytes, and the indirect jump that follows now crossed a
32-byte boundary, which the assembler pads with a NOP. Every instruction
the loop ran paid for it; fib ran 2.7% more instructions, nbody 1.8%.

Built as bits in an integer register, the result needs no slot: the frame
and the head are as before 81ddf67, and instruction counts are back to
within 0.7% of it. The gate's baseline returns to its earlier trade:
GETFIELD 7, GETTABLE 2.
@folbricht
folbricht merged commit 9ef8306 into main Oct 21, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant