perf: parameter-bank uniforms and per-arch carry path (+20.5% sm_120) #22

Merged
grenade merged 3 commits from perf/sm120-spills into main 2026-09-13 17:20:54 +00:00
Owner

Closes the first half of the gap against qpow-cuda/1.0.7. Two kernel changes that measure, one dead end recorded so it is not retried.

Measured, parity verified

Three interleaved rounds per figure, spread <=0.2%, parity against the CPU on every host.

host card / arch before after change
beast 2x 5090, sm_120 1127.1 1358.0 MH/s +20.5%
benjy 4090, sm_89 457.9 461.8 MH/s +0.85%
quadbrat 3060, sm_86 83.5 84.6 MH/s +1.38%

Fleet ~1.67 -> ~1.91 GH/s.

1. Launch uniforms in the parameter bank

midstate, target and the nonce base were device buffers copied into per-thread register arrays and held live across the whole nonce loop -- 56 registers the permutation's working set needed more. ptxas spilled 104 bytes per thread on sm_120 and 44 on sm_86/89, with 26 LDL + 26 STL in the hot loop.

They are launch-uniform, so they now ride in a by-value MiningUniforms parameter: constant memory, broadcast and cached, no register residency, and it arrives with the launch so three memcpy_htod calls per batch go with it. 128 -> 80/80/106 registers, zero spill everywhere. Both sides static_assert the 224-byte layout, so a mismatch is a build error and not a silent mining bug.

2. Per-architecture carry path

An asm block is opaque to nvcc's optimiser, so carry arithmetic written as inline PTX blocks CSE and strength reduction across neighbouring field operations. On Blackwell that costs a quarter of the kernel. On Ada and Ampere the hand-written sequence still wins, by a similar margin the other way:

sm_120 instrs beast sm_86/89 instrs benjy quadbrat
PTX_CARRY=1 37,432 1127.1 37,027 461.6 85.5
PTX_CARRY=0 27,837 1358.0 41,196 401.5 76.3

So the default is chosen per device pass. sm_86/89 keep the exact configuration they had -- identical macro values, identical 37,027 instructions -- so those cards are unchanged by construction. LAIR_PTX_ACC is now 1 everywhere rather than following LAIR_PTX_CARRY: in the Acc accumulators the add-with-carry pair is the whole operation, and on sm_120 with the carry path off, Acc PTX on measured 1333.3 against 1285.8 with it off.

PR #17 measured the PTX carry path as a win on every card. That was true of the kernel it was written for, which spilled 104 bytes per thread; once the spills went the trade reversed on Blackwell only.

3. Recorded, not applied

  • Specialised squaring. a^2 = a0^2 + a0*a1*2^33 + a1^2*2^64 is three 32x32 multiplies where the general product needs four, and nvcc does not find it. Implemented, verified bit-exact against __int128 over 40M values plus limb and power-of-two boundaries, measured 5.7% slower (1072.9 vs 1137.7). It saves 6.7% of widening multiplies and costs 11% more instructions; four formulations all compiled to the same 5 IMAD.WIDE in 56 instructions against 6 in 48, so that is the floor.
  • Per-arch LAIR_INT_UNROLL=1 on sm_120. Better by every static measure (78 registers instead of 106, three resident blocks instead of two) and 0.92% slower on beast. These cards are power-bound: the extra warps buy power draw and the card clocks down to pay for it.

Both are comments in mining.cu so the next person does not rediscover them.

How this was found

Profiling, not reading. ncu put us at 43,700 executed instructions per nonce against the rival binary's 22,229 on the same card -- a 1.97x ratio matching the 2.05x throughput gap -- with 82% of the excess on the ALU pipe, not the multiply pipe. That ruled out the sparse-path theory, the multiply itself, occupancy and launch shape in one measurement, and pointed straight at our own carry machinery.

Standing after this PR: 35,785 instructions per nonce against their 22,229. The multiply pipe is near parity (8,405 vs 7,030, 1.20x); ALU is still 26,586 vs 14,413, so 89% of the remaining gap is ALU. gf_reduce's borrow/mask/carry-fold chain is the largest single term, roughly 7 ALU ops across 1472 multiplies per nonce.

Risk

Kernel arithmetic only, plus the host-side uniform struct. No protocol or engine-selection changes, so the coupling rules in #1 hold. Every knob remains overridable through MINER_NVCC_FLAGS. Parity ran on all three architectures: 25/25 on quadbrat and benjy, 60/60 on beast.

Refs #3.

🤖 Generated with Claude Code

https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D

Closes the first half of the gap against `qpow-cuda/1.0.7`. Two kernel changes that measure, one dead end recorded so it is not retried. ## Measured, parity verified Three interleaved rounds per figure, spread <=0.2%, parity against the CPU on every host. | host | card / arch | before | after | change | | --- | --- | --- | --- | --- | | beast | 2x 5090, sm_120 | 1127.1 | **1358.0 MH/s** | **+20.5%** | | benjy | 4090, sm_89 | 457.9 | 461.8 MH/s | +0.85% | | quadbrat | 3060, sm_86 | 83.5 | 84.6 MH/s | +1.38% | Fleet ~1.67 -> ~1.91 GH/s. ## 1. Launch uniforms in the parameter bank `midstate`, `target` and the nonce base were device buffers copied into per-thread register arrays and held live across the whole nonce loop -- 56 registers the permutation's working set needed more. ptxas spilled 104 bytes per thread on sm_120 and 44 on sm_86/89, with 26 `LDL` + 26 `STL` in the hot loop. They are launch-uniform, so they now ride in a by-value `MiningUniforms` parameter: constant memory, broadcast and cached, no register residency, and it arrives with the launch so three `memcpy_htod` calls per batch go with it. 128 -> 80/80/106 registers, zero spill everywhere. Both sides `static_assert` the 224-byte layout, so a mismatch is a build error and not a silent mining bug. ## 2. Per-architecture carry path An `asm` block is opaque to nvcc's optimiser, so carry arithmetic written as inline PTX blocks CSE and strength reduction across neighbouring field operations. On Blackwell that costs a quarter of the kernel. On Ada and Ampere the hand-written sequence still wins, by a similar margin the other way: | | sm_120 instrs | beast | sm_86/89 instrs | benjy | quadbrat | | --- | --- | --- | --- | --- | --- | | `PTX_CARRY=1` | 37,432 | 1127.1 | 37,027 | 461.6 | 85.5 | | `PTX_CARRY=0` | 27,837 | **1358.0** | 41,196 | 401.5 | 76.3 | So the default is chosen per device pass. sm_86/89 keep the exact configuration they had -- identical macro values, identical 37,027 instructions -- so those cards are unchanged by construction. `LAIR_PTX_ACC` is now 1 everywhere rather than following `LAIR_PTX_CARRY`: in the `Acc` accumulators the add-with-carry pair is the whole operation, and on sm_120 with the carry path off, Acc PTX on measured 1333.3 against 1285.8 with it off. PR #17 measured the PTX carry path as a win on every card. That was true of the kernel it was written for, which spilled 104 bytes per thread; once the spills went the trade reversed on Blackwell only. ## 3. Recorded, not applied - **Specialised squaring.** `a^2 = a0^2 + a0*a1*2^33 + a1^2*2^64` is three 32x32 multiplies where the general product needs four, and nvcc does not find it. Implemented, verified bit-exact against `__int128` over 40M values plus limb and power-of-two boundaries, measured **5.7% slower** (1072.9 vs 1137.7). It saves 6.7% of widening multiplies and costs 11% more instructions; four formulations all compiled to the same 5 IMAD.WIDE in 56 instructions against 6 in 48, so that is the floor. - **Per-arch `LAIR_INT_UNROLL=1` on sm_120.** Better by every static measure (78 registers instead of 106, three resident blocks instead of two) and **0.92% slower** on beast. These cards are power-bound: the extra warps buy power draw and the card clocks down to pay for it. Both are comments in `mining.cu` so the next person does not rediscover them. ## How this was found Profiling, not reading. `ncu` put us at 43,700 executed instructions per nonce against the rival binary's 22,229 on the same card -- a 1.97x ratio matching the 2.05x throughput gap -- with 82% of the excess on the ALU pipe, not the multiply pipe. That ruled out the sparse-path theory, the multiply itself, occupancy and launch shape in one measurement, and pointed straight at our own carry machinery. Standing after this PR: 35,785 instructions per nonce against their 22,229. The multiply pipe is near parity (8,405 vs 7,030, 1.20x); ALU is still 26,586 vs 14,413, so 89% of the remaining gap is ALU. `gf_reduce`'s borrow/mask/carry-fold chain is the largest single term, roughly 7 ALU ops across 1472 multiplies per nonce. ## Risk Kernel arithmetic only, plus the host-side uniform struct. No protocol or engine-selection changes, so the coupling rules in #1 hold. Every knob remains overridable through `MINER_NVCC_FLAGS`. Parity ran on all three architectures: 25/25 on quadbrat and benjy, 60/60 on beast. Refs #3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D
grenade added 3 commits 2026-09-13 17:20:36 +00:00
The kernel copied midstate, target and the nonce base into per-thread
register arrays and held all 56 registers live across the whole nonce
loop, which is exactly what the permutation's own working set needed.
ptxas spilled 104 bytes per thread on sm_120 and 44 on sm_86/89; the SASS
carried 26 LDL + 26 STL inside the hot loop.

They are launch-uniform, so they now travel as a by-value MiningUniforms
parameter. Parameters live in constant memory -- broadcast and cached, no
register residency -- and arrive with the launch, so three memcpy_htod
calls per batch are gone with them. Only the low eight nonce limbs are
materialised now; on a hit the high half is written from the parameter
bank. 128 -> 80/80/106 registers on sm_86/89/120, zero spill everywhere.
Both sides assert the 224-byte layout, so a mismatch is a build error
rather than a silent mining bug.

Measured A/B on the fleet, same session, same silicon, parity verified
against the CPU (25/25 on quadbrat and benjy, 40/40 on beast):

  quadbrat  3060    83.45 -> 84.60 MH/s   +1.38%
  benjy     4090   457.87 -> 461.77 MH/s  +0.85%
  beast   2x5090  1133.97 -> 1138.79 MH/s +0.43%
  fleet           1675.29 -> 1685.16 MH/s +0.59%

Not taken: a per-arch LAIR_INT_UNROLL of 1 on sm_120. It is better by
every static measure -- 78 registers instead of 106, three resident
blocks per SM instead of two, no spill either way -- and measured 0.92%
slower on beast over three interleaved rounds. These cards are
power-bound, not latency-bound: the extra warps buy power draw and the
card clocks down to pay for it. Benjy shows the same thing from the other
side, delivering +0.85% at a lower clock (2085 vs 2160 MHz), +4.4% per
MHz. Occupancy is not the lever here; work per hash is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D
The three-multiply squaring is a real algebraic saving and a measured
loss. Implemented, verified bit-exact against __int128 over 40M random
values plus limb and power-of-two boundaries, then benchmarked on beast:
1072.9 vs 1137.7 MH/s over three interleaved rounds, 5.7% slower.

It trades 6.7% of the kernel's widening multiplies for 11% more
instructions -- 5 IMAD.WIDE in 56 instructions against the general
product's 6 in 48 -- and four formulations (condition-code carry, compare
carry, two mad.wide.u32 variants) all compiled to the same 5/56, so that
is the floor rather than a tuning failure.

Taken with the LAIR_INT_UNROLL result, the picture is consistent: this
kernel is bound by instruction issue and by the cards' power envelope,
not by multiply throughput or occupancy. Re-expressing the same work
cannot win; only removing work can. Left as a comment so the next person
does not spend the afternoon rediscovering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D
engine-cuda: per-arch carry path, +20.5% on sm_120
All checks were successful
ci / fmt (pull_request) Successful in 19s
bench / build (pull_request) Successful in 1m31s
ci / clippy (pull_request) Successful in 2m21s
ci / doc (pull_request) Successful in 2m24s
bench / measure (pull_request) Successful in 4m15s
ci / test (pull_request) Successful in 7m51s
575c4d0abd
An `asm` block is opaque to nvcc's optimiser, so carry arithmetic written
as inline PTX blocks common-subexpression elimination and strength
reduction across neighbouring field operations. On Blackwell that costs a
quarter of the kernel. On Ada and Ampere the hand-written sequence still
wins, by a similar margin in the other direction, so the default is now
chosen per device pass.

Whole-kernel instructions (ptxas + cuobjdump, CUDA 13.0) and measured
hashrate, three interleaved rounds each:

                    sm_120                sm_86/89
  PTX_CARRY=1   37,432   1127.1 MH/s   37,027   benjy 461.6  quadbrat 85.5
  PTX_CARRY=0   27,837   1358.0 MH/s   41,196   benjy 401.5  quadbrat 76.3

sm_86/89 keep the exact configuration they had, so those cards are
unchanged by construction -- identical macro values, identical 37,027
instructions. Parity 60/60 against the CPU on beast.

LAIR_PTX_ACC is now 1 everywhere rather than following LAIR_PTX_CARRY. In
the Acc accumulators the add-with-carry pair is the whole operation, so
there is nothing around it for the optimiser to fold; on sm_120 with the
carry path off, Acc PTX on measured 1333.3 against 1285.8 with it off.

PR #17 measured the PTX carry path as a win on every card. That was true
of the kernel it was written for, which spilled 104 bytes per thread;
once the spills went the trade reversed on Blackwell only.

Found by profiling rather than reading: ncu puts us at 43,700 executed
instructions per nonce against the rival binary's 22,229 on the same
card, a 1.97x ratio that matches the 2.05x throughput gap, with 82% of
the excess on the ALU pipe rather than the multiply pipe. This lands
35,785. The remaining gap is still ALU: 26,586 against their 14,413,
while the multiply pipe is now close to parity at 8,405 against 7,030.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ytCkttecH9SNTUEw8VT9D
grenade merged commit 78dee04ff6 into main 2026-09-13 17:20:54 +00:00

quantus-bench 575c4d0abd on benjy.hanzalova.internal

worker median MH/s spread windows
0 456.92 0.1% 457.4, 457.2, 456.7, 456.9, 456.8

total median: 456.92 MH/s (batch 1000000, 5 x 30s, engine gpu-cuda)

  • gpu 0 NVIDIA GeForce RTX 4090: driver 580.173.02, limit 250 W, draw 250 W, sm 2055 MHz, mem 810 MHz, 59 C
  • parity: OK (25/25 jobs found solutions, all verified against CPU)
    --- resume miner ---
    active
## quantus-bench 575c4d0abd7438392feb07e684a18c94d59a2707 on benjy.hanzalova.internal | worker | median MH/s | spread | windows | | --- | --- | --- | --- | | 0 | 456.92 | 0.1% | 457.4, 457.2, 456.7, 456.9, 456.8 | total median: **456.92 MH/s** (batch 1000000, 5 x 30s, engine gpu-cuda) - gpu 0 NVIDIA GeForce RTX 4090: driver 580.173.02, limit 250 W, draw 250 W, sm 2055 MHz, mem 810 MHz, 59 C - parity: OK (25/25 jobs found solutions, all verified against CPU) --- resume miner --- active
Sign in to join this conversation.
No Reviewers
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: blackbeard/miner#22