54 Commits
v4.0.1 ... main

Author SHA1 Message Date
2160ed63eb Merge pull request 'deploy: require nvcc at build time' (#32) from ci/deploy-require-cuda into main
All checks were successful
ci / fmt (push) Successful in 19s
deploy / build (push) Successful in 1m33s
ci / clippy (push) Successful in 1m42s
deploy / deploy (loop, 1, benjy.hanzalova.internal, cuda-loop, bob.hanzalova.internal) (push) Successful in 34s
deploy / deploy (unrolled, 2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
deploy / deploy (unrolled, 1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 36s
ci / doc (push) Successful in 1m58s
ci / test (push) Successful in 6m51s
2026-09-14 17:18:12 +00:00
6e300d537c deploy: require nvcc at build time
All checks were successful
ci / fmt (pull_request) Successful in 19s
ci / clippy (pull_request) Successful in 1m46s
ci / doc (pull_request) Successful in 2m0s
ci / test (pull_request) Successful in 6m50s
The CUDA build script warns and embeds an empty kernel when nvcc is
absent, which is right for the rust runner's test job and wrong for a
deploy build. MINER_CUDA_REQUIRE=1 turns that into a build failure
before any host is touched; the release workflow already sets it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 20:10:06 +03:00
bb92224788 Merge pull request 'engine-cuda: loop kernel diagonal from launch parameters (+1.3% loop kernel), multiply-add screening variants' (#31) from perf/int-round-chain into main
All checks were successful
ci / fmt (push) Successful in 19s
deploy / build (push) Successful in 1m32s
ci / clippy (push) Successful in 1m44s
deploy / deploy (unrolled, 1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 49s
ci / doc (push) Successful in 2m2s
deploy / deploy (loop, 1, benjy.hanzalova.internal, cuda-loop, bob.hanzalova.internal) (push) Successful in 56s
deploy / deploy (unrolled, 2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 56s
ci / test (push) Successful in 6m53s
2026-09-14 17:01:15 +00:00
1fea51cfc3 engine-cuda: loop kernel takes the diagonal from the launch parameters
All checks were successful
ci / fmt (pull_request) Successful in 19s
bench / build (pull_request) Successful in 1m11s
ci / clippy (pull_request) Successful in 1m46s
ci / doc (pull_request) Successful in 1m42s
bench / measure (pull_request) Successful in 2m50s
ci / test (pull_request) Successful in 7m1s
MiningUniforms carries the twelve diagonal constants (416 bytes now) and
the loop kernel reads them there (LAIR_LOOP_PARAM_DIAG=1) instead of from
the __constant__ table. Static: still 13 loads per internal round (ptxas
reloads either way) but spill 88 -> 32 bytes. Measured, three interleaved
rounds, loop kernel only: beast 2135 -> 2162 MH/s (+1.3%), benjy 725 ->
730 (+0.7%); parity 300/300 and 200/200. The unrolled kernel is byte-
identical (21,120 instructions, 110 registers).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 19:51:22 +03:00
4572a5e567 engine-cuda: attribute the loop kernel's internal-round excess; drop two no-op knobs
Constant hoisting in C and 32-bit-halves delivery of the diagonal both
compile to byte-identical SASS and are removed. The remaining 40
instructions per round over the quanpool kernel are itemised in the
kernel comment.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 19:43:59 +03:00
338b4a7d32 engine-cuda: LAIR_MULADD screening variants (0 stays; 1-4 measured worse)
Static screen on sm_120, loop kernel internal round body: 0 = 239,
1 = 260, 2 = 283, 3 = 275, 4 = 265 instructions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 19:40:59 +03:00
4a6ed42d6b Merge pull request 'readme: 4090 at 727 MH/s on the loop kernel, how to select it' (#30) from docs/readme-lair2 into main
All checks were successful
ci / fmt (push) Successful in 20s
release / build (push) Successful in 1m35s
ci / clippy (push) Successful in 1m43s
ci / doc (push) Successful in 1m49s
ci / test (push) Successful in 6m35s
2026-09-14 16:39:23 +00:00
bbbaf815ac readme: 4090 at 727 MH/s on the loop kernel, how to select it
All checks were successful
ci / fmt (pull_request) Successful in 19s
ci / clippy (pull_request) Successful in 1m48s
ci / doc (pull_request) Successful in 2m3s
ci / test (pull_request) Successful in 6m49s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 19:30:43 +03:00
ad77d99dfa Merge pull request 'engine-cuda: loop-structured mining_loop kernel, opt-in via MINER_CUDA_KERNEL=loop' (#29) from perf/loop-kernel into main
All checks were successful
ci / fmt (push) Successful in 21s
deploy / build (push) Successful in 1m32s
ci / clippy (push) Successful in 1m37s
deploy / deploy (unrolled, 1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 1m10s
deploy / deploy (unrolled, 2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 1m15s
deploy / deploy (loop, 1, benjy.hanzalova.internal, cuda-loop, bob.hanzalova.internal) (push) Successful in 1m18s
ci / doc (push) Successful in 2m1s
ci / test (push) Successful in 6m50s
2026-09-14 13:44:55 +00:00
12bd36aee2 deploy: per-host CUDA kernel choice; benjy runs the loop kernel
All checks were successful
ci / fmt (pull_request) Successful in 21s
bench / build (pull_request) Successful in 1m0s
ci / clippy (pull_request) Successful in 2m42s
ci / doc (pull_request) Successful in 2m46s
bench / measure (pull_request) Successful in 2m56s
ci / test (pull_request) Successful in 7m54s
The matrix gains `cuda_kernel` (rendered into miner.env as
MINER_CUDA_KERNEL) next to `kernel`, the id validate asserts. benjy's
4090 measured +1.7% on the loop kernel (727-736 vs 716-718 MH/s, parity
200/200), so it runs loop / cuda-loop; quadbrat (-1.8%) and beast
(-3.4%) stay on unrolled / cuda. A rollback to a binary that predates
the switch is safe: it ignores the variable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 16:36:02 +03:00
816b1efb61 engine-cuda: loop-structured mining_loop kernel behind MINER_CUDA_KERNEL
Some checks failed
ci / fmt (pull_request) Successful in 19s
bench / build (pull_request) Successful in 1m13s
bench / measure (pull_request) Failing after 14s
ci / clippy (pull_request) Successful in 1m49s
ci / doc (pull_request) Successful in 2m25s
ci / test (pull_request) Successful in 8m23s
A second entry point in the same fatbin, sharing every arithmetic
primitive with mining_main, with the round loops rolled and the round
constants fetched from constant memory by index (quantus/miner#27). The
per-nonce body is one template used by both entries, so the deployed
kernel is byte-identical (sm_120 21,120 instructions, 110 registers).

Selected at run time: MINER_CUDA_KERNEL=loop loads mining_loop, reports
kernel id cuda-loop on the per-device metrics, and defaults the block
size to 512 (LAIR_LOOP_TPB); unset or unrolled keeps mining_main and the
cuda kernel id the deploy validate asserts.

Measured on beast, three interleaved rounds, parity 500/500 on both:
unrolled 2206, loop 2130 MH/s (-3.4%). The loop kernel is 11,840 static
instructions at 128 registers (88 bytes of spill) on sm_120; 88 registers
with no spill on sm_86/89. Shapes tried for it: 192 registers 1976 (one
block per SM, card underfilled), 80 registers 2005 (424 B spill), 64
registers 373-532 (716-872 B spill), two internal rounds per iteration
1976 against one at 2105, 256/128-thread blocks below 512.

Not the win #27 hoped for yet: the loop structure alone does not lower
this arithmetic's register footprint the way the quanpool kernel's is,
and its internal round is 254 instructions to their 199. Kept as an
opt-in path so the next steps (the round arithmetic as one carry chain)
can be measured against the deployed kernel without touching it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 16:30:15 +03:00
8b01387e97 Merge pull request 'release: tag-triggered binaries and a README that states the measured rates' (#28) from release/binaries-and-readme into main
All checks were successful
ci / fmt (push) Successful in 19s
release / build (push) Successful in 1m21s
deploy / build (push) Successful in 1m31s
ci / clippy (push) Successful in 1m51s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 36s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 36s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 36s
ci / doc (push) Successful in 2m0s
ci / test (push) Successful in 6m56s
2026-09-13 22:24:05 +00:00
7831323362 release: tag-triggered binaries, README section with measured rates
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m15s
ci / clippy (pull_request) Successful in 1m43s
ci / doc (pull_request) Successful in 2m0s
bench / measure (pull_request) Successful in 2m52s
ci / test (pull_request) Successful in 6m55s
For independent miners (quantus/miner#27): a `v<version>-lair.<n>` tag
builds quantus-miner and quantus-bench on the cuda-13.0 runner with the
tag's commit in --version, and publishes a Gitea release with the
tarball, its sha256 and a build report. The README now leads with what
this fork is, the measured rates per card against upstream and the two
closed pool binaries, where the binaries are, how to reproduce the
numbers with quantus-bench, and that there is no fee, licence server or
telemetry. release.yaml is excluded from deploy triggers.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 01:13:49 +03:00
c1cac91419 engine-cuda: LAIR_MINBLOCKS launch-bounds knob, with the occupancy results
No default change. Records on the knob what forcing the register budget
measured on beast against the quanpool kernel (quantus/miner#27): 64
registers by any launch shape spills and loses 5%, 80 is neutral.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-14 00:49:11 +03:00
17caeabc10 Merge pull request 'perf: whole-grid batches, early reject on element 0, precomputed nonce direction (+2.9% on beast)' (#26) from perf/scheduling into main
All checks were successful
ci / fmt (push) Successful in 19s
deploy / build (push) Successful in 1m32s
ci / clippy (push) Successful in 1m45s
ci / doc (push) Successful in 1m54s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 33s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 47s
ci / test (push) Successful in 6m46s
2026-09-13 21:06:31 +00:00
2e6fce162e engine-cuda: batches of whole grids, early reject on element 0, nonce direction
All checks were successful
ci / fmt (pull_request) Successful in 19s
bench / build (pull_request) Successful in 1m15s
ci / clippy (pull_request) Successful in 1m45s
ci / doc (pull_request) Successful in 1m58s
bench / measure (pull_request) Successful in 2m51s
ci / test (pull_request) Successful in 6m46s
Three scheduling changes measured against the #23 kernel, three interleaved
rounds each, parity clean on every host:

- Batches are rounded up to whole grids, two per batch by default
  (MINER_CUDA_BATCH_ALIGN, MINER_CUDA_BATCH_WAVES). The CLI's 1M-nonce
  batch left a half-empty tail wave and paid the launch gap every 1M
  hashes. beast +1.6% for one grid, +0.4% more for two; benjy +0.5% for
  the second grid; quadbrat neutral.
- LAIR_EARLY_REJECT: the second permutation stops before its last linear
  layer, computes output 0 alone (the hash's top 64 bits) and rejects on
  it; only the rare survivors pay for the other eleven outputs. +0.6%.
- LAIR_NONCE_DIR: the host precomputes the state after the first linear
  layer for the batch's first nonce (first_layer_after_absorb, unit test
  against the matrix column), and the kernel derives each nonce's state
  as that plus a scalar times column 7 of the external matrix; nonces
  past a carry out of the low limb take the old path. +0.25% on beast,
  +0.6% on the 4090 and 3060.

Also tried and rejected: 512-thread blocks (-0.4%), a smaller grid via
MINER_CUDA_THREADS_PER_SM=16384 (-0.6%), a larger one (65536, within
noise of two waves), and the whole S-box as one PTX block, which
compiles to byte-identical SASS. The threads-per-block knob
(MINER_CUDA_THREADS_PER_BLOCK with LAIR_TPB) stays for experiments.

beast 2x5090: 2146 -> ~2206 MH/s (+2.8%); benjy 4090: 711 -> ~718;
quadbrat 3060: 140.2 -> 141.2.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-13 23:54:17 +03:00
dc6c58ceb6 Merge pull request 'perf: fused-PTX field arithmetic, +54..66% on every card' (#23) from perf/fused-field-ptx into main
All checks were successful
ci / fmt (push) Successful in 19s
deploy / build (push) Successful in 1m33s
ci / clippy (push) Successful in 1m46s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 46s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 46s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 47s
ci / doc (push) Successful in 1m58s
ci / test (push) Successful in 7m1s
2026-09-13 19:40:26 +00:00
3763996ae6 engine-cuda: fused-PTX field arithmetic, +54..66% on every card
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m16s
ci / clippy (pull_request) Successful in 1m44s
bench / measure (pull_request) Successful in 2m52s
ci / doc (pull_request) Successful in 2m1s
ci / test (pull_request) Successful in 6m59s
The closed-source qpow-cuda kernel runs 2x our hashrate on the same
silicon with the same 1472 multiplies per hash. Its PTX shows why: every
field operation is one self-contained inline-PTX block, the 128-bit
product is reduced with mad.lo.cc/madc.hi.cc (one IMAD.WIDE with a
carry-out) plus three ALU ops, the internal layer's row sum rides in the
mad.wide partial products for free, and accumulators are 32-bit limb
chains that ptxas turns into IADD/IADD.X with merged carries. Ours paid
a compare-and-select reduction (~14 ops), a 64-bit add-with-carry per
diagonal element, and five instructions per accumulator add on sm_120.

This ports those primitives behind LAIR_FUSED_MUL (default 1): gf_mul,
gf_sqr (three-product squaring), gf_mul_add with the addend folded into
the partial products, acc_add/acc_add2 as limb chains, and a four-op
acc_reduce. The round constant now joins the unreduced sum in the
internal layer.

LAIR_EXACT_REDUCE (default 0) keeps the reduction bit-exact for three
more ops per multiply. With 0 the final borrow is dropped, as in their
kernel: wrong only when the product's bits 64..95 are zero and its low
64 bits are below its top 32 bits, about 2^-64 per multiply (emulated
against a reference: 0/3M random mismatches, 2^48*2^48 fails as
predicted). Exactness measured at -15% on beast, so it is off.

Measured, three interleaved rounds, parity 40/40 on each host:
  beast 2x5090  1356 -> 2146 MH/s  (+58%)
  benjy 4090     460 ->  711 MH/s  (+54%)
  quadbrat 3060   84 ->  140 MH/s  (+66%)
Static sm_120 31,280 -> 20,888 instructions, sm_86/89 39,208 -> 22,576,
no spills. ncu: 22,913 instructions per nonce against their 22,233.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ue5ZZm1Hiv5zPnucykKKuF
2026-09-13 22:29:46 +03:00
78dee04ff6 Merge pull request 'perf: parameter-bank uniforms and per-arch carry path (+20.5% sm_120)' (#22) from perf/sm120-spills into main
All checks were successful
ci / fmt (push) Successful in 20s
ci / doc (push) Successful in 2m34s
ci / clippy (push) Successful in 2m39s
ci / test (push) Successful in 7m8s
deploy / build (push) Successful in 1m46s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 35s
2026-09-13 17:20:53 +00:00
575c4d0abd 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
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
2026-09-13 19:35:32 +03:00
206aa0d876 engine-cuda: record why gf_sqr uses the general product
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
2026-09-13 18:42:48 +03:00
7804d9d442 engine-cuda: launch uniforms in the parameter bank, no spills
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
2026-09-13 18:32:20 +03:00
2cfb2aae1d Merge pull request 'deploy: miner credentials from the mainnet chain' (#21) from deploy/mainnet-chain into main
All checks were successful
deploy / build (push) Successful in 1m19s
ci / fmt (push) Successful in 20s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 38s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 46s
ci / clippy (push) Successful in 2m8s
ci / doc (push) Successful in 1m55s
ci / test (push) Successful in 7m18s
2026-09-09 09:04:30 +00:00
b1c6eb2ba7 deploy: miner credentials from the mainnet chain
All checks were successful
ci / fmt (pull_request) Successful in 22s
ci / clippy (pull_request) Successful in 1m54s
ci / doc (pull_request) Successful in 2m6s
ci / test (pull_request) Successful in 7m39s
Mainnet cut over 2026-09-09 09:01 UTC; the miners already run with the
credentials from chains/mainnet/ (installed by hand). The push default
now matches, so the next deploy copies the same files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-09 12:03:21 +03:00
fd965ea312 Merge pull request 'deploy: beast joins the miner fleet for mainnet' (#20) from deploy/enable-beast into main
All checks were successful
ci / fmt (push) Successful in 20s
deploy / build (push) Successful in 1m37s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 37s
ci / clippy (push) Successful in 2m25s
deploy / deploy (2, beast.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 33s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 53s
ci / doc (push) Successful in 2m30s
ci / test (push) Successful in 7m32s
2026-09-09 06:15:02 +00:00
7ac9608b3d deploy: beast joins the miner fleet for mainnet
Some checks failed
ci / fmt (pull_request) Successful in 26s
ci / doc (pull_request) Successful in 2m25s
ci / test (pull_request) Successful in 7m33s
ci / clippy (pull_request) Failing after 12m11s
Mainnet call 2026-09-09: beast's two RTX 5090s mine alongside benjy and
quadbrat; neuron (cortex inference) is disabled on all three hosts. The
node's miners list and lair/quantus's SCRAPE_MINERS gain beast in the
lair/quantus counterpart.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-09 09:14:32 +03:00
03b9c4239f Merge pull request 'deploy: chain id as a dispatch input for the miner credential path' (#19) from deploy/chain-input into main
All checks were successful
ci / fmt (push) Successful in 39s
ci / doc (push) Successful in 2m5s
ci / clippy (push) Successful in 2m57s
deploy / build (push) Successful in 3m43s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 42s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 45s
ci / test (push) Successful in 8m50s
2026-09-09 05:05:36 +00:00
9f41e0f3b9 deploy: chain id as a dispatch input for the miner credential path
All checks were successful
ci / fmt (pull_request) Successful in 46s
ci / clippy (pull_request) Successful in 2m56s
ci / test (pull_request) Successful in 6m49s
ci / doc (pull_request) Successful in 2m18s
The node generates the miner auth token and TLS pin per chain under
<base-path>/chains/<id>/, so the mainnet cutover needs one miner deploy
pointed at the new id (lair/quantus's node deploy prints it). The binary
is unchanged by a chain switch; only the credentials trigger the restart.
CHAIN stays the push-time default and is to be bumped after the cutover.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-09 08:05:04 +03:00
f061ccfeea Merge pull request 'bench: record the memory clock alongside the SM clock (schema 2)' (#18) from bench/record-memory-clock into main
All checks were successful
ci / fmt (push) Successful in 28s
deploy / build (push) Successful in 1m23s
ci / clippy (push) Successful in 1m38s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 38s
ci / doc (push) Successful in 1m45s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 2m38s
ci / test (push) Successful in 6m38s
2026-09-03 15:54:47 +00:00
a7a8ff0532 bench: record the memory clock alongside the SM clock (schema 2)
All checks were successful
ci / fmt (pull_request) Successful in 36s
bench / build (pull_request) Successful in 1m37s
ci / doc (pull_request) Successful in 1m42s
ci / clippy (pull_request) Successful in 2m5s
bench / measure (pull_request) Successful in 3m48s
ci / test (pull_request) Successful in 7m3s
The memory clock is a confound like the power limit: locking it at 810 MHz
gave +13% on the 4090 at the same watts (lair/quantus#10). Records from
before and after that change must be distinguishable.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 18:47:17 +03:00
ad66f1334f Merge pull request 'engine-cuda: deferred carries, carry-flag arithmetic, one nonce per thread' (#17) from cuda/tuning into main
All checks were successful
ci / fmt (push) Successful in 21s
deploy / build (push) Successful in 1m15s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 56s
ci / clippy (push) Successful in 2m13s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 37s
ci / doc (push) Successful in 2m17s
ci / test (push) Successful in 6m53s
2026-09-03 13:01:46 +00:00
b640d3a711 engine-cuda: fold round constants into the preceding linear layer; bench on cuda paths
All checks were successful
ci / fmt (pull_request) Successful in 23s
bench / build (pull_request) Successful in 1m0s
ci / clippy (pull_request) Successful in 1m57s
bench / measure (pull_request) Successful in 2m51s
ci / doc (pull_request) Successful in 1m52s
ci / test (pull_request) Successful in 7m25s
Each linear layer now folds the constant of the round that follows it into
its final deferred-carry reduction (or, for the internal rounds, into the
element-0 multiply-add), removing the per-element gf_add before every S-box.
Parity verified. On benjy the effect is within run-to-run noise: three
interleaved rounds gave medians of 418.5 vs 409.7 MH/s against the previous
default, with SM clock swinging 1935-2025 MHz across rounds. Kept on as
LAIR_FOLD_RC=1 for the instruction count; not claimed as a measured gain.

bench.yaml: crates/engine-cuda/** now triggers the harness on PRs. The
previous PR's kernel changes did not run it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 15:56:23 +03:00
2bf3246634 engine-cuda: deferred carries, carry-flag arithmetic, one nonce per thread
All checks were successful
ci / fmt (pull_request) Successful in 21s
ci / clippy (pull_request) Successful in 1m43s
ci / doc (pull_request) Successful in 1m41s
ci / test (pull_request) Successful in 6m52s
quantus/miner#3 step 5, measured on benjy (RTX 4090, 250 W, miner paused,
interleaved rounds, parity verified on every variant):

  variant                                  MH/s
  straight port (PR #16)                   297-305
  + grid cap 32768 threads/SM              304   (1 nonce per thread up to 4M)
  + deferred-carry linear layers           344   (+13%)
  + carry-flag gf_add / gf_reduce (PTX)    385   (+12%)
  + carry-flag accumulators                414   (+7%)

414 MH/s is 2.86x origin's wgpu build (144.4) on the same card and limit.
Fewer instructions per hash also let the power-capped card clock higher
(1920 -> 1995 MHz), which is part of the gain.

Rejected by measurement: __noinline__ permute (-13%: code size was not the
limit, the call convention was), -maxrregcount 80/64 (flat), internal-round
unroll 1 or 22 (flat, within noise of 2).

The batch sweep found batch size irrelevant once the grid keeps one nonce
per thread (304-306 MH/s from 1M to 16M); batch stays at 1M for the
smallest stale cost. All knobs remain as -D macros and MINER_NVCC_FLAGS
so the harness can keep testing variants.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 15:51:11 +03:00
d5e72d6a15 Merge pull request 'engine-cuda: native CUDA mining engine behind MinerEngine' (#16) from cuda/engine-skeleton into main
All checks were successful
ci / fmt (push) Successful in 20s
deploy / build (push) Successful in 1m19s
ci / clippy (push) Successful in 1m54s
deploy / deploy (1, benjy.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 58s
ci / doc (push) Successful in 1m57s
deploy / deploy (1, quadbrat.hanzalova.internal, cuda, bob.hanzalova.internal) (push) Successful in 58s
ci / test (push) Successful in 7m25s
2026-09-03 11:57:42 +00:00
6ecc5ee6c7 engine-cuda: native CUDA mining engine behind MinerEngine
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m15s
ci / clippy (pull_request) Successful in 1m40s
ci / doc (pull_request) Successful in 2m6s
bench / measure (pull_request) Successful in 2m51s
ci / test (pull_request) Successful in 7m45s
quantus/miner#3. New crate, nothing in engine-gpu touched.

- kernels/mining.cu: the same host contract and sponge schedule as
  mining_u64.wgsl (host midstate, low-256-bit nonce increments, lazy second
  squeeze), bit-exact with pow_core. The field multiply is __umul64hi plus a
  plain multiply and one reduction, instead of four 32-bit partials with
  carry reconstruction.
- build.rs: generates poseidon2_constants.cuh from qp-poseidon-constants
  (a hash change at origin is a dependency bump, never a hand-copied table)
  and, when nvcc is present, compiles one fat binary with cubins for
  sm_86/89/120 plus compute_120 PTX. Without nvcc it writes an empty image,
  the crate still builds, and CudaEngine::try_new fails with a clear message
  so the miner falls back to wgpu.
- lib.rs: CudaEngine with the same batch loop, cancellation, thread-local
  device assignment, metrics (kernel label "cuda") and stale accounting as
  engine-gpu. cudarc 0.19 with dynamic loading; no link-time CUDA dependency.
- miner-service: resolve_gpu_configuration tries CUDA first unless
  --gpu-engine wgpu; --gpu-engine cuda makes its absence an error.
  miner-cli gains the flag on serve and benchmark (MINER_GPU_ENGINE).
- bench-harness: --engine auto|cuda|wgpu.
- deploy.yaml: matrix rows carry the kernel validate expects; a wgpu
  fallback on a CUDA host now fails the deploy instead of passing at a
  fraction of the hashrate.

Measured on quadbrat (RTX 3060, 130 W, miner paused, 2 x 10 s windows,
parity 5/5 verified against CPU): wgpu 37.3 MH/s, CUDA 59.5 MH/s (1.59x).
Grid sizing 8192 threads/SM or more is flat; 2048 loses 10%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 14:52:10 +03:00
48ab3933ba Merge pull request 'metrics: stale work is the batch in flight at cancellation, not the whole search' (#15) from metrics/stale-semantics into main
All checks were successful
ci / fmt (push) Successful in 20s
deploy / build (push) Successful in 1m5s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 1m10s
ci / clippy (push) Successful in 2m33s
ci / doc (push) Successful in 2m32s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 47s
ci / test (push) Successful in 7m25s
2026-09-03 11:28:43 +00:00
20749c622b metrics: stale work is the batch in flight at cancellation, not the whole search
All checks were successful
ci / fmt (pull_request) Successful in 21s
bench / build (pull_request) Successful in 1m1s
ci / clippy (pull_request) Successful in 1m55s
ci / doc (pull_request) Successful in 1m55s
bench / measure (pull_request) Successful in 2m56s
ci / test (pull_request) Successful in 6m35s
miner_stale_hashes_total was recorded in the QUIC loop for every result whose
job id no longer matched, which is every cancelled search: 74% of all hashes
on benjy flagged as stale on the first hour of data. That is the loop's
notion of a stale *result*, not wasted work; the hashes were done while the
job was current.

The wasted work is the batch that completes after the job was superseded.
Record that in the engine at the cancellation check, per device and kernel,
and drop the loop-level accounting. Expect one batch per job switch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 14:23:37 +03:00
1c8174951c Merge pull request 'metrics: attribute hashrate to build, device, kernel and job outcomes' (#14) from metrics/build-device-job into main
All checks were successful
ci / fmt (push) Successful in 32s
deploy / build (push) Successful in 1m14s
ci / clippy (push) Successful in 1m42s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 51s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 51s
ci / doc (push) Successful in 1m55s
ci / test (push) Successful in 6m56s
2026-09-03 11:16:25 +00:00
3ba996dcdf metrics: attribute hashrate to build, device, kernel and job outcomes
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 1m2s
ci / clippy (pull_request) Successful in 1m37s
ci / doc (pull_request) Successful in 1m58s
bench / measure (pull_request) Successful in 2m55s
ci / test (pull_request) Successful in 6m50s
quantus/miner#9. Additive metrics in a lair-owned module of the metrics
crate; origin's metrics are untouched so the fleet dashboard keeps working
across origin merges.

Identity:
- miner_build_info{version, commit}: the join key for everything below.
- miner_config_info{engine, gpu_batch_size, gpu_devices, cpu_workers,
  gpu_throttle_ms}: two deploys of one commit with different flags are
  different experiments.

Per device (handles resolved once at engine init, no label lookups in the
batch path):
- miner_device_hashes_total{device, kernel}, miner_device_solutions_total,
  miner_device_lost_total.
- miner_gpu_batch_seconds{device, kernel, phase=gpu|host}: submit-to-mapped
  on the device versus everything else in the batch. This is the bubble
  #4, #5 and #6 attack, measured directly.

Jobs and results:
- miner_jobs_received_total, miner_stale_hashes_total{engine},
  miner_job_pickup_seconds{engine} (issued to picked up; the cancel latency
  of a busy worker), miner_results_submitted_total,
  miner_results_send_failed_total, miner_seal_latency_seconds (found to
  sent), miner_job_idle_seconds_total (sent to next job, node-attributable).

Connection: miner_connects_total, miner_connect_failures_total,
miner_disconnects_total, miner_connected, miner_disconnected_seconds_total.

Origin-owned code touched, each block marked lair: engine-gpu gains a
metrics dependency, a DeviceMetrics handle on GpuContext and timing points
in run_single_batch; miner-service gains found_at on WorkerResult,
created_at on MiningJob and the recording calls; miner-cli sets build info
at startup. Deploy validate now asserts miner_build_info carries the
deployed commit.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 14:09:43 +03:00
f1064345e5 Merge pull request 'deploy: wait for the miner to connect before judging its metrics' (#13) from deploy/validate-readiness into main
All checks were successful
ci / fmt (push) Successful in 44s
deploy / build (push) Successful in 1m9s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 1m31s
ci / clippy (push) Successful in 1m51s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Successful in 50s
ci / doc (push) Successful in 2m16s
ci / test (push) Successful in 7m7s
2026-09-03 10:38:21 +00:00
40b4ea32ca deploy: restore the previous binary with install, not cp
All checks were successful
ci / fmt (pull_request) Successful in 22s
ci / clippy (pull_request) Successful in 1m51s
ci / doc (pull_request) Successful in 2m10s
ci / test (pull_request) Successful in 6m56s
The first rollback failed with "cp: cannot create regular file
'/usr/local/bin/quantus-miner': Text file busy": cp writes in place and the
binary was executing. install unlinks the destination first, the same reason
rsync's temp-file-and-rename push works. Sudoers grant updated and applied
to benjy and quadbrat.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:38:09 +03:00
b10f2f0099 deploy: wait for the miner to connect before judging its metrics
All checks were successful
ci / fmt (pull_request) Successful in 20s
ci / clippy (pull_request) Successful in 1m49s
ci / doc (pull_request) Successful in 2m40s
ci / test (pull_request) Successful in 8m1s
The first deploy-mode run sampled /metrics one second after the restart.
miner_gpu_devices is exported only once the miner has connected to the node
and miner_hashes_total only once it has hashed, so both were absent, validate
failed, and rollback restored the previous binary on a good deploy.

Validate now waits up to 90 s for miner_gpu_devices to appear (readiness),
then takes the first non-empty miner_hashes_total sample as the baseline and
requires a larger one within 120 s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:37:23 +03:00
95b6420793 Merge pull request 'deploy: build on push to main and land the binary on the mining hosts' (#12) from deploy/on-main into main
Some checks failed
ci / test (push) Blocked by required conditions
ci / fmt (push) Successful in 25s
deploy / build (push) Successful in 1m5s
ci / clippy (push) Successful in 1m50s
ci / doc (push) Successful in 1m49s
deploy / deploy (1, benjy.hanzalova.internal, bob.hanzalova.internal) (push) Failing after 2m37s
deploy / deploy (1, quadbrat.hanzalova.internal, bob.hanzalova.internal) (push) Failing after 2m30s
2026-09-03 10:33:17 +00:00
b9e1d21ec9 deploy: build on push to main and land the binary on the mining hosts
All checks were successful
ci / fmt (pull_request) Successful in 24s
ci / clippy (pull_request) Successful in 1m38s
ci / doc (pull_request) Successful in 2m0s
ci / test (pull_request) Successful in 7m47s
quantus/miner#8. The miner is now deployed from this repo, not from
lair/quantus's release-pinned workflow (lair/quantus#4 retires that half).

- .gitea/workflows/deploy.yaml: build on cuda-13.0 (Fedora 43 like the
  hosts, and the runner with nvcc for #3) with the commit embedded, then per
  host in the matrix: sudoers preflight against deploy/infra-setup.sh,
  credentials copied from the node, miner.env rendered with the node's mesh
  IP resolved on the miner host, checksum-gated push of binary and unit,
  scoped firewalld rule for the exporter, restart only on change. Validate
  asserts the unit is active, --version carries this commit, the miner sees
  the matrix's device count, and miner_hashes_total advances within 120 s.
  If validate fails after a restart, the previous binary is restored.
- deploy/: the unit, sysusers, firewalld service and env template moved
  verbatim from lair/quantus asset/, plus infra-setup.sh with the miner role
  (and two new cp grants for the rollback). Applied to benjy and quadbrat.
- Matrix: benjy and quadbrat; beast present but commented out per the
  operating policy in #1.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:28:52 +03:00
ad846af984 Merge pull request 'bench: quantus-bench harness and a workflow that measures on a mining host' (#11) from bench/harness into main
All checks were successful
ci / fmt (push) Successful in 20s
ci / clippy (push) Successful in 1m55s
ci / doc (push) Successful in 1m52s
ci / test (push) Successful in 6m21s
2026-09-03 10:19:33 +00:00
ca6642960c bench: serialise measurements per host with a flock, per-run file names
All checks were successful
ci / fmt (pull_request) Successful in 31s
bench / build (pull_request) Successful in 1m1s
ci / clippy (pull_request) Successful in 1m55s
bench / measure (pull_request) Successful in 2m59s
ci / test (pull_request) Successful in 9m7s
ci / doc (pull_request) Successful in 1m42s
Gitea's concurrency group did not serialise two runs on benjy: the second
run's scp failed with ETXTBSY on the binary the first was executing (safe,
it happened before the miner was touched, but the run was lost). The pause,
lock, measure and resume sequence now lives in bench-on-host.sh, executed
over one ssh call under flock on the host, with per-run binary and record
names so staging never collides.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:06:45 +03:00
5f3c51c5cb bench: allow more worker threads than cards to measure submission overlap
Some checks failed
ci / fmt (pull_request) Successful in 21s
bench / build (pull_request) Successful in 56s
bench / measure (pull_request) Failing after 8s
ci / clippy (pull_request) Successful in 1m49s
ci / doc (pull_request) Successful in 1m59s
ci / test (pull_request) Successful in 7m0s
Two threads on one card overlap one thread's readback with the other's
dispatch, which is the bubble quantus/miner#4 and #5 target. Measuring it in
the harness first means #4 lands with a number instead of a hypothesis.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 13:03:21 +03:00
36ffbbadbf Merge pull request 'ci: gitea workflow, drop inherited github workflows, embed commit in --version' (#10) from ci/gitea-workflows-and-build-sha into main
All checks were successful
ci / fmt (push) Successful in 51s
ci / clippy (push) Successful in 1m46s
ci / doc (push) Successful in 2m50s
ci / test (push) Successful in 10m57s
2026-09-03 10:02:39 +00:00
bad02fbc33 bench: quantus-bench harness and a workflow that measures on a mining host
All checks were successful
ci / fmt (pull_request) Successful in 20s
bench / build (pull_request) Successful in 57s
ci / doc (pull_request) Successful in 1m46s
ci / clippy (pull_request) Successful in 2m16s
bench / measure (pull_request) Successful in 2m49s
ci / test (pull_request) Successful in 10m33s
quantus/miner#2. The measurement gate every kernel and submission change
passes before merge.

crates/bench-harness (lair-owned, never touched by origin merges):
- drives GpuEngine through MinerEngine, one worker thread per card, with
  fixed timed windows after a warm-up; reports per-worker median MH/s and
  spread and refuses (exit 5) when spread exceeds 2%
- captures nvidia-smi state (driver, power limit, draw, clock, temperature)
  before and after, and refuses to run (exit 3) when the enforced power limit
  is not the expected one, since that is the largest confound on these cards
- GPU/CPU parity on random jobs, same shape as engine-gpu's gpu_cpu_parity
  example, exit 4 on any mismatch
- one JSON record per run (schema 1) plus a markdown summary on stdout
- build.rs embeds the commit like miner-cli's

.gitea/workflows/bench.yaml:
- runner containers on the GPU hosts have no device passthrough, so the
  binary is built on cuda-13.0 (Fedora 43, matching the hosts; the rust
  image is Fedora 44 and its binaries need a newer glibc) and executed on
  the host over ssh as gitea_ci
- stops quantus-miner.service for the window and starts it again under a
  trap; refuses to measure a card that is busy with the miner stopped
- benjy (4090, dedicated) by default; host, duration, runs, batch size and
  workers are dispatch inputs; PRs touching engine or service crates trigger
  it and get the summary as a comment
- one measurement per host at a time (concurrency group)

The stop/start sudoers grants were added to lair/quantus infra-setup.sh
(lair/quantus#5) and applied to benjy and quadbrat.

Origin coupling: one line in the workspace members list, marked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 12:58:48 +03:00
db6316713a ci: add rustfmt and clippy components explicitly
All checks were successful
ci / fmt (pull_request) Successful in 20s
ci / clippy (pull_request) Successful in 2m4s
ci / doc (pull_request) Successful in 2m8s
ci / test (pull_request) Successful in 6m27s
rustup auto-installs the pinned toolchain from rust-toolchain on the runner
but not its listed components; the fmt job failed with "cargo-fmt is not
installed for the toolchain". Add the component in the two jobs that need it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 12:54:29 +03:00
094536a341 ci: gitea workflow, drop inherited github workflows, embed commit in --version
Some checks failed
ci / fmt (pull_request) Failing after 39s
ci / clippy (pull_request) Has been skipped
ci / test (pull_request) Has been skipped
ci / doc (pull_request) Has been skipped
First lair-only change on top of origin v4.0.2 (quantus/miner#8, #1).

- Delete .github/ (workflows and the disk action). Gitea Actions reads
  .github/workflows too, so origin's GitHub-hosted jobs would queue forever
  for runners that do not exist here. Standing resolution on every
  origin-main merge: `git rm -r .github/workflows`.
- Add .gitea/workflows/ci.yml on the `rust` runner: fmt, clippy with
  -D warnings (same invocation as clippy.sh), build, test, doc, and a check
  that the built binary reports a commit in --version.
- crates/miner-cli/build.rs embeds MINER_BUILD_SHA (env override, else
  git rev-parse with a -dirty marker, else "unknown") and --version prints
  "<semver> (<sha>)". A deploy that follows main needs to assert the running
  binary is the commit it shipped; the semver alone cannot do that.

Origin coupling: .gitea/ and build.rs are new files; main.rs gains one const
and one attribute argument behind a `// lair:` marker.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CBgs2nSi4H2mdh8kD8vMX5
2026-09-03 12:50:45 +03:00
Nikolaus Heger
cb6deb9139 bump version to v4.0.2 (#93)
Some checks failed
CI / 🏁 Fast Checks (Format) (push) Has been cancelled
CI / 🛠️ Build & Test (push) Has been cancelled
CI / 🤖 Analysis (Clippy & Doc) (push) Has been cancelled
CI / 🏃 Benchmark (push) Has been cancelled
Co-authored-by: n13 <n13@users.noreply.github.com>
2026-09-01 23:25:24 +08:00
Nikolaus Heger
09323d33e4 Different kernels for Apple Metal vs Nvidia and everything else (#92)
* Select Apple Metal 4.0.1 kernel; keep 4.0.0 u64 elsewhere

NVIDIA was ~37% slower on the 4.0.1 Metal-tuned kernel. Split
Poseidon2 kernels and pick by backend:

- Metal + SHADER_INT64: v4.0.1 u64
- other SHADER_INT64: v4.0.0 u64
- no SHADER_INT64: 32-bit fallback (same as 4.0.0)

CI uploads the Linux release binary as an artifact so we can
bench NVIDIA without building on the GPU box.

* Drop version numbers from GPU kernel labels

Log native-u64 vs native-u64 Apple Metal without tying
the strings to release tags.
2026-09-01 23:21:43 +08:00
Nikolaus Heger
ceff470a5f ci: pin the stable rust toolchain (#91)
CI resolved the floating stable channel, so Rust 1.98 shipped the
chunks_exact_to_as_chunks clippy lint and failed checks that passed on
an older local stable. Pin the exact version, matching how chain pins
its toolchains.
2026-09-01 19:03:54 +08:00
43 changed files with 5003 additions and 559 deletions

143
.gitea/workflows/bench.yaml Normal file
View File

@@ -0,0 +1,143 @@
---
# GPU hashrate and parity measurement on a fleet mining host (quantus/miner#2).
#
# Runner containers on the GPU hosts have no device passthrough (gongfoo gives
# them `devices: None`), so the benchmark cannot run inside the runner. The
# binary is built on a runner and executed on the host over ssh as gitea_ci,
# which has the GPU device nodes (0666) and the Vulkan ICD, exactly like the
# miner. The host's quantus-miner.service is stopped for the window and started
# again afterwards, under a trap, so a failed run cannot leave the host idle.
#
# Benjy (4090, dedicated) is the reference card; beast (2x 5090) also serves
# inference and is only benchmarked by manual dispatch.
name: bench
on:
workflow_dispatch:
inputs:
host:
description: mining host to measure
default: benjy.hanzalova.internal
type: choice
options:
- benjy.hanzalova.internal
- quadbrat.hanzalova.internal
- beast.hanzalova.internal
duration_secs:
description: seconds per timed window
default: "30"
runs:
description: timed windows (median reported)
default: "5"
batch_size:
description: nonces per GPU batch
default: "1000000"
workers:
description: worker threads (one per card)
default: "1"
pull_request:
paths:
- crates/engine-gpu/**
- crates/engine-cuda/**
- crates/engine-cpu/**
- crates/pow-core/**
- crates/miner-service/**
- crates/bench-harness/**
- Cargo.lock
- .gitea/workflows/bench.yaml
env:
CARGO_TERM_COLOR: always
BENCH_HOST: ${{ github.event.inputs.host || 'benjy.hanzalova.internal' }}
DURATION: ${{ github.event.inputs.duration_secs || '30' }}
RUNS: ${{ github.event.inputs.runs || '5' }}
BATCH: ${{ github.event.inputs.batch_size || '1000000' }}
WORKERS: ${{ github.event.inputs.workers || '1' }}
# One measurement per host at a time; a second one would share the card.
concurrency:
group: bench-${{ github.event.inputs.host || 'benjy.hanzalova.internal' }}
cancel-in-progress: false
jobs:
build:
# cuda-13.0, not rust: the rust image is Fedora 44 and the mining hosts are
# Fedora 43, so a binary built there needs a glibc the hosts do not have
# (observed: "GLIBC_2.43 not found"). cuda-13.0 is Fedora 43 based, matches
# the hosts, and is what the deploy in #8 builds on anyway.
runs-on: cuda-13.0
steps:
- uses: actions/checkout@v4
- name: build quantus-bench
env:
MINER_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
run: cargo build --release --locked -p bench-harness
- uses: actions/upload-artifact@v3
with:
name: quantus-bench
path: target/release/quantus-bench
measure:
runs-on: fedora-43
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: quantus-bench, path: _bin }
- name: write ssh key
run: |
set -euo pipefail
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 0600 ~/.ssh/id_gitea_ci
- name: measure on ${{ env.BENCH_HOST }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"$BENCH_HOST" "$@"; }
# Per-run file names: two runs may overlap on the host until the
# host-side flock serialises them, and scp over a running binary
# fails with ETXTBSY.
id="${{ github.run_id }}"
dir=/var/lib/gitea_ci/bench
run "install -d -m 0750 $dir"
scp $SSHOPTS -q _bin/quantus-bench gitea_ci@"$BENCH_HOST":$dir/quantus-bench.$id
scp $SSHOPTS -q crates/bench-harness/bench-on-host.sh gitea_ci@"$BENCH_HOST":$dir/bench-on-host.$id.sh
run "chmod 0755 $dir/quantus-bench.$id"
cleanup() { ssh $SSHOPTS gitea_ci@"$BENCH_HOST" "rm -f $dir/quantus-bench.$id $dir/bench-on-host.$id.sh $dir/record.$id.json"; }
trap cleanup EXIT
label="${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}"
# The host script pauses the miner, holds the per-host lock, measures,
# and resumes the miner under its own trap.
run "bash $dir/bench-on-host.$id.sh $dir/quantus-bench.$id $DURATION $RUNS $BATCH $WORKERS $label $dir/record.$id.json" | tee bench.log
# The summary is everything from the harness's markdown header on.
sed -n '/^## quantus-bench/,$p' bench.log > bench.md
scp $SSHOPTS -q gitea_ci@"$BENCH_HOST":$dir/record.$id.json record.json
{ echo; cat bench.md; } >> "$GITHUB_STEP_SUMMARY"
- uses: actions/upload-artifact@v3
if: always()
with:
name: bench-record-${{ env.BENCH_HOST }}
path: |
record.json
bench.md
- name: comment on the pull request
if: github.event_name == 'pull_request'
run: |
set -euo pipefail
body=$(python3 - <<'PY'
import json, pathlib
print(json.dumps({"body": pathlib.Path("bench.md").read_text()}))
PY
)
curl -fsS -X POST \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
-H "Content-Type: application/json" \
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${{ github.event.pull_request.number }}/comments" \
-d "$body" > /dev/null

81
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,81 @@
---
# Lint, build and test on the fleet's `rust` runner (architecture/gitea-runners.md).
#
# Replaces origin's .github/workflows/ci.yml, which targeted GitHub-hosted
# runners that do not exist here. Gitea Actions also reads .github/workflows,
# so origin's files are deleted rather than left inert; on every origin merge
# `git rm -r .github/workflows` is the standing resolution (quantus/miner#1).
#
# Deliberately not here: origin's CPU benchmark job (meaningless on a shared
# 4-CPU runner; the real gate is the GPU harness, quantus/miner#2) and taplo
# (not on the runner image; add it to runner-rust per gitea-runners.md §5
# rather than `cargo install` on every run).
name: ci
on:
pull_request:
push:
branches: [main, origin-main]
concurrency:
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_INCREMENTAL: "0"
CARGO_TERM_COLOR: always
# rust-toolchain pins 1.93.0. rustup on the runner auto-installs the toolchain
# on first use but NOT the components the file lists (observed: "cargo-fmt is
# not installed for the toolchain"), so each job that needs one adds it
# explicitly; the call is a no-op once present.
jobs:
fmt:
runs-on: rust
steps:
- uses: actions/checkout@v4
- name: toolchain
run: rustup component add rustfmt
- name: rustfmt
run: cargo fmt --all -- --check
clippy:
runs-on: rust
needs: fmt
steps:
- uses: actions/checkout@v4
- name: toolchain
run: rustup component add clippy
- name: clippy (all targets, all features, warnings denied)
# Same invocation as clippy.sh so local and CI agree.
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
test:
runs-on: rust
needs: fmt
steps:
- uses: actions/checkout@v4
- name: build
run: cargo build --locked --workspace
- name: test
# GPU tests skip themselves when no adapter is present; the runner has none.
run: cargo test --locked --workspace
- name: version carries the commit
# build.rs embeds the SHA; a binary that says "unknown" cannot be
# matched to a deploy (quantus/miner#8).
run: |
set -euo pipefail
v=$(./target/debug/quantus-miner --version)
echo "$v"
case "$v" in
*unknown*) echo "build SHA not embedded" >&2; exit 1 ;;
esac
doc:
runs-on: rust
needs: fmt
steps:
- uses: actions/checkout@v4
- name: doc
run: cargo doc --locked --workspace --no-deps --all-features

View File

@@ -0,0 +1,397 @@
---
# Deploy — or validate — the miner on the fleet's mining hosts (quantus/miner#8).
#
# Every push to main builds the binary and lands it on each host in the matrix,
# validated, with rollback. This workflow is the source of infra truth for the
# miner: hosts and per-host settings live in the `deploy` matrix and nowhere
# else. The node it attaches to is deployed by quantus/chain; the fleet's
# monitoring, GPU power limits and nvidia metrics stay in lair/quantus.
#
# Build on cuda-13.0: it is Fedora 43 like the hosts (the `rust` image is
# Fedora 44 and its binaries fail on the hosts with GLIBC_2.43 not found), and
# it is the only runner with nvcc for the CUDA engine (#3). Deploy on
# fedora-43: ssh + rsync are on every runner (gitea-runners.md §3).
name: deploy
on:
push:
branches: [main]
paths-ignore:
- "**.md"
- .gitea/workflows/ci.yml
- .gitea/workflows/bench.yaml
- .gitea/workflows/release.yaml
workflow_dispatch:
inputs:
mode:
description: "deploy (apply) or validate (check only, no changes)"
required: false
default: deploy
type: choice
options: [deploy, validate]
chain:
description: "chain id under the node's chains/ directory to take miner credentials from (overrides CHAIN); the node deploy's validate step prints it"
required: false
# Never half-apply two deploys at once. (Not relied on for correctness of a
# single host: the restart is checksum-gated and the binary is rsynced atomically.)
concurrency:
group: deploy-miner
cancel-in-progress: false
env:
# The chain spec: the miner credential path on the node host derives from it.
# Must agree with lair/quantus's node deploy. Mainnet since 2026-09-09; the
# node writes the credentials under chains/mainnet/.
CHAIN: mainnet
MINER_LINK_PORT: "9833"
MINER_METRICS_PORT: "9900"
# Fleet Prometheus host; the miner's exporter is unauthenticated and bound to
# 0.0.0.0, so this is the only host allowed to reach it.
METRICS_HOST: golgafrinchans.kosherinata.internal
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: cuda-13.0
steps:
- uses: actions/checkout@v4
- name: build quantus-miner
env:
# Embedded by crates/miner-cli/build.rs into --version; validate below
# asserts the host runs exactly this commit.
MINER_BUILD_SHA: ${{ github.sha }}
# Fail the build here if nvcc is missing rather than shipping a
# binary that falls back to wgpu and failing validate on the hosts.
# (The same warning in ci.yml's test job is expected: the `rust`
# runner has no nvcc and only lints and tests.)
MINER_CUDA_REQUIRE: "1"
run: |
set -euo pipefail
cargo build --release --locked -p miner-cli
./target/release/quantus-miner --version
- uses: actions/upload-artifact@v3
with:
name: quantus-miner
path: target/release/quantus-miner
deploy:
runs-on: fedora-43
needs: build
strategy:
fail-fast: false # one host's failure must not abort the other
matrix:
include:
# `node` is the host whose QUIC control channel this miner attaches
# to, and whose reward address therefore receives what it earns. The
# node's deploy (quantus/chain) must list this host in its `miners`
# so the firewalld rich rule admits it. lair/quantus's SCRAPE_MINERS
# must list it so Prometheus scrapes it.
#
# Mainnet call 2026-09-09 (quantus/miner#1): all three hosts mine;
# neuron (cortex inference) is disabled on each of them.
# `kernel` is the kernel id validate expects on the per-device metric:
# cuda (engine-cuda, #3) on every NVIDIA host once the build carries
# the fat binary; u64 would mean the miner silently fell back to wgpu.
# `cuda_kernel` selects the kernel entry (MINER_CUDA_KERNEL, #29):
# unrolled is the default; loop measured +1.7% on the 4090 and
# -1.8% / -3.4% on the 3060 / 5090 (2026-09-14), so only benjy runs
# it. `kernel` must be the id that choice reports: cuda or cuda-loop.
- host: benjy.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1x RTX 4090 (sm_89); reference card for #2
cuda_kernel: loop
kernel: cuda-loop
- host: quadbrat.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "1" # 1x RTX 3060 (sm_86)
cuda_kernel: unrolled
kernel: cuda
- host: beast.hanzalova.internal
node: bob.hanzalova.internal
gpu_devices: "2" # 2x RTX 5090 (sm_120)
cuda_kernel: unrolled
kernel: cuda
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v3
with: { name: quantus-miner, path: _bin }
- name: write ssh key
run: |
set -euo pipefail
install -d -m 0700 ~/.ssh
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
chmod 0600 ~/.ssh/id_gitea_ci
- name: reachability
run: |
set -euo pipefail
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
gitea_ci@${{ matrix.host }} hostname -f
- name: preflight — sudoers covers this deploy
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
# Every path deploy/infra-setup.sh grants must already be permitted on
# the host. Compare up front, from the script itself so the two cannot
# drift, and name the missing paths instead of failing forty lines into
# an rsync with "sudo: a password is required".
sed -n "/quantus-miner_gitea_ci.tmp/,/^SUDO$/p" deploy/infra-setup.sh \
| grep '^gitea_ci ALL=' | grep -oE '(/etc|/usr|/var)/[^ ]*' | sort -u > expected-paths.txt
ssh $SSHOPTS gitea_ci@${{ matrix.host }} 'sudo -n -l' \
| grep -oE '(/etc|/usr|/var)/[^ ]*' | sort -u > permitted-paths.txt
comm -23 expected-paths.txt permitted-paths.txt > missing-paths.txt
if [ -s missing-paths.txt ]; then
echo "the miner sudoers on ${{ matrix.host }} is out of date." >&2
echo "not permitted, but this deploy needs them:" >&2
sed 's/^/ /' missing-paths.txt >&2
echo "" >&2
echo "run: ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub" >&2
exit 1
fi
echo "sudoers covers all $(wc -l < expected-paths.txt) paths this deploy needs"
- name: deploy miner
id: deploy
if: ${{ github.event.inputs.mode != 'validate' }}
env:
GPU_DEVICES: ${{ matrix.gpu_devices }}
CUDA_KERNEL: ${{ matrix.cuda_kernel }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
nrun() { ssh $SSHOPTS gitea_ci@"${{ matrix.node }}" "$@"; }
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
# -c (checksum), not rsync's default size+mtime quick check: the
# artifact is freshly built every run so mtimes always differ, and the
# default heuristic would report a change on every deploy. Restart is
# gated on an itemised content difference only.
RESTART=0
push() {
local out
out=$(rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic "$@")
if [ -n "$out" ]; then
RESTART=1
printf '%s\n' "$out" | sed 's/^/ changed: /'
fi
}
# 1. service account + dirs
push --mkpath --chmod=F0644 \
deploy/quantus-miner.sysusers.conf \
gitea_ci@"${{ matrix.host }}":/etc/sysusers.d/quantus-miner.conf
run sudo systemd-sysusers
run sudo install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
run sudo install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
# 2. The miner's credentials are GENERATED BY THE NODE on first start
# and regenerate if the node's base-path is ever wiped. Copying them
# on every deploy is what makes that self-healing instead of a
# silent auth failure. They pass through the runner in memory,
# never the workspace.
umask 077
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
nrun sudo cat /var/lib/quantus-node/chains/${{ github.event.inputs.chain || env.CHAIN }}/miner-auth-token \
> "$tmp/miner-auth-token"
nrun sudo cat /var/lib/quantus-node/chains/${{ github.event.inputs.chain || env.CHAIN }}/miner-tls-cert-sha256 \
> "$tmp/miner-tls-cert-sha256"
test -s "$tmp/miner-auth-token" || { echo "node auth token empty — has ${{ matrix.node }} started?" >&2; exit 1; }
test -s "$tmp/miner-tls-cert-sha256" || { echo "node TLS pin empty — has ${{ matrix.node }} started?" >&2; exit 1; }
push --chown=root:quantus-miner --chmod=F0640 \
"$tmp/miner-auth-token" gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner-auth-token
push --chown=root:quantus-miner --chmod=F0640 \
"$tmp/miner-tls-cert-sha256" gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner-tls-cert-sha256
# 3. non-secret runtime config.
# --node-addr parses as a Rust SocketAddr: an IP and a port, with NO
# DNS resolution. Resolve on the MINER host — it is the one
# dialling — and keep the 10.x literal out of the repo.
node_addrs=$(run "getent ahostsv4 ${{ matrix.node }}")
node_ip=$(awk '{print $1; exit}' <<<"$node_addrs")
case "$node_ip" in
10.*) echo "node address: ${{ matrix.node }} -> ${node_ip}" ;;
*) echo "refusing to point the miner at non-mesh address '${node_ip}'" >&2; exit 1 ;;
esac
export NODE_ADDR="${node_ip}:${{ env.MINER_LINK_PORT }}"
python3 - <<'PY'
import os, pathlib
t = pathlib.Path("deploy/miner.env.tmpl").read_text()
t = t.replace("{{QUANTUS_NODE_ADDR}}", os.environ["NODE_ADDR"])
t = t.replace("{{QUANTUS_GPU_DEVICES}}", os.environ["GPU_DEVICES"])
t = t.replace("{{MINER_CUDA_KERNEL}}", os.environ["CUDA_KERNEL"])
pathlib.Path("miner.env").write_text(t)
PY
push --chown=root:quantus-miner --chmod=F0640 \
miner.env gitea_ci@"${{ matrix.host }}":/etc/quantus-miner/miner.env
# 4. binary + unit. Keep the running binary as .prev first so a failed
# validate can put it back (rollback step below).
if run test -x /usr/local/bin/quantus-miner; then
run sudo cp -p /usr/local/bin/quantus-miner /usr/local/bin/quantus-miner.prev
echo "previous binary kept: $(run /usr/local/bin/quantus-miner.prev --version)"
fi
push --chmod=F0755 _bin/quantus-miner gitea_ci@"${{ matrix.host }}":/usr/local/bin/quantus-miner
push --chmod=F0644 deploy/quantus-miner.service \
gitea_ci@"${{ matrix.host }}":/etc/systemd/system/quantus-miner.service
run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
# 5. firewalld for the exporter, scoped to the scrape host. The miner
# binds metrics on 0.0.0.0 unconditionally.
rsync -e "ssh $SSHOPTS" --rsync-path='sudo rsync' -ic --mkpath --chmod=F0644 \
deploy/quantus-miner-metrics.xml \
gitea_ci@"${{ matrix.host }}":/etc/firewalld/services/quantus-miner-metrics.xml \
| sed 's/^/ changed: /'
run sudo firewall-cmd --reload
zone=$(run sudo firewall-cmd --get-default-zone)
metrics_addrs=$(run "getent ahostsv4 ${{ env.METRICS_HOST }}")
metrics_ip=$(awk '{print $1; exit}' <<<"$metrics_addrs")
case "$metrics_ip" in
10.*) echo "scrape source: ${metrics_ip}" ;;
*) echo "refusing to expose metrics to non-mesh address '${metrics_ip}'" >&2; exit 1 ;;
esac
mrich="rule family=ipv4 source address=${metrics_ip}/32 service name=quantus-miner-metrics accept"
if run "sudo firewall-cmd --zone=$zone --query-rich-rule='$mrich'"; then
echo "firewalld: metrics rich rule already present in ${zone}"
else
run "sudo firewall-cmd --permanent --zone=$zone --add-rich-rule='$mrich'"
run "sudo firewall-cmd --zone=$zone --add-rich-rule='$mrich'"
fi
run sudo systemctl enable quantus-miner.service # idempotent
if [ "$RESTART" = 1 ]; then
echo "changes applied — restarting"
run sudo systemctl daemon-reload
run sudo systemctl restart quantus-miner.service
elif run systemctl is-active --quiet quantus-miner.service; then
echo "nothing changed and the miner is running — left alone"
else
echo "nothing changed but the miner is down — starting it"
run sudo systemctl restart quantus-miner.service
fi
echo "restarted=$RESTART" >> "$GITHUB_OUTPUT"
- name: validate miner
id: validate
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
fail=0
echo "--- unit (${{ matrix.host }} -> ${{ matrix.node }}) ---"
run systemctl is-active quantus-miner.service
echo "--- version ---"
got=$(run /usr/local/bin/quantus-miner --version)
echo "installed: ${got}"
case "$got" in
*"${{ github.sha }}"*) echo "binary is this commit" ;;
*)
if [ "${{ github.event.inputs.mode }}" = validate ]; then
echo "note: installed binary is not this commit (validate mode; not a failure)"
else
echo "installed binary does NOT carry commit ${{ github.sha }}" >&2; fail=1
fi ;;
esac
echo "--- gpu ---"
# An `active` miner that found no adapter still looks healthy to
# systemd; assert the GPU is enumerated and that the miner sees the
# number of devices the matrix says it should.
run "nvidia-smi --query-gpu=name,power.draw,utilization.gpu --format=csv,noheader"
echo "--- hashing ---"
# The counter is the only honest evidence this process is doing work
# rather than idling on a failed connection. After a restart the miner
# exports miner_gpu_devices only once it has connected to the node and
# miner_hashes_total only once it has hashed, so first wait for the
# gauge to appear (readiness), then require the counter to advance.
# Here-strings, not pipes: a pipe whose reader exits early SIGPIPEs the
# writer and pipefail turns that into exit 141.
scrape() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "curl -fsS http://127.0.0.1:${{ env.MINER_METRICS_PORT }}/metrics" || true; }
deadline=$((SECONDS + 90))
devs=""
while [ $SECONDS -lt $deadline ]; do
m=$(scrape)
devs=$(awk '/^miner_gpu_devices /{print $2; exit}' <<<"$m")
[ -n "$devs" ] && break
sleep 5
done
if [ -z "$devs" ]; then
echo " miner did not connect to ${{ matrix.node }} within 90s (no miner_gpu_devices exported)" >&2; fail=1
elif [ "${devs%.*}" != "${{ matrix.gpu_devices }}" ]; then
echo " miner_gpu_devices is ${devs}, matrix says ${{ matrix.gpu_devices }}" >&2; fail=1
else
echo " ok connected; miner_gpu_devices ${devs%.*}"
fi
# The build-info gauge is what ties every other metric to a commit
# (quantus/miner#9). In deploy mode it must carry this commit.
if grep -q "^miner_build_info{.*commit=\"${{ github.sha }}\"" <<<"$m"; then
echo " ok miner_build_info carries ${{ github.sha }}"
elif [ "${{ github.event.inputs.mode }}" != validate ]; then
echo " miner_build_info does not carry commit ${{ github.sha }}" >&2; fail=1
fi
# The kernel actually running. A wgpu fallback on a host that should
# run CUDA passes every other check at a fraction of the hashrate.
if grep -q "^miner_device_hashes_total{.*kernel=\"${{ matrix.kernel }}\"" <<<"$m"; then
echo " ok kernel ${{ matrix.kernel }} on device 0"
else
echo " expected kernel ${{ matrix.kernel }}, exported series:" >&2
grep "^miner_device_hashes_total" <<<"$m" >&2 || echo " (none yet)" >&2
if [ "${{ github.event.inputs.mode }}" != validate ]; then fail=1; fi
fi
deadline=$((SECONDS + 120))
h1=""; h2=""; ok=0
while [ $SECONDS -lt $deadline ]; do
m=$(scrape)
h=$(awk '/^miner_hashes_total /{print $2; exit}' <<<"$m")
if [ -n "$h" ]; then
if [ -z "$h1" ]; then
h1="$h"
elif [ "${h%.*}" -gt "${h1%.*}" ]; then
h2="$h"; ok=1; break
fi
fi
sleep 10
done
if [ "$ok" = 1 ]; then
echo " ok miner_hashes_total ${h1%.*} -> ${h2%.*}"
else
echo " miner_hashes_total did not advance within 120s (${h1:-none} -> ${h2:-none})" >&2
fail=1
fi
exit $fail
- name: rollback
# Only when this run replaced the binary and validate then failed.
if: ${{ failure() && steps.deploy.outputs.restarted == '1' }}
run: |
set -euo pipefail
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
run() { ssh $SSHOPTS gitea_ci@"${{ matrix.host }}" "$@"; }
if run test -x /usr/local/bin/quantus-miner.prev; then
echo "validate failed after a restart — restoring the previous binary"
# install, not cp: cp writes in place and fails with "Text file busy"
# on a running binary; install unlinks the destination first (the
# same reason rsync's temp-file-and-rename works for the push).
run sudo install -m 0755 /usr/local/bin/quantus-miner.prev /usr/local/bin/quantus-miner
run sudo restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
run sudo systemctl restart quantus-miner.service
echo "restored: $(run /usr/local/bin/quantus-miner --version)"
else
echo "no previous binary to restore" >&2
fi
- name: journal
if: always()
run: |
ssh -i ~/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new \
gitea_ci@${{ matrix.host }} journalctl -u quantus-miner.service -n 60 --no-pager

View File

@@ -0,0 +1,78 @@
---
# Release binaries for independent miners (quantus/miner#27).
#
# Pushing a tag `v<upstream version>-lair.<n>` builds the miner and the bench
# harness on the cuda-13.0 runner (Fedora 43 like the mining hosts, and the
# only runner with nvcc, so the binary carries the sm_86/sm_89/sm_120 cubins
# and PTX), records what was built, and publishes a Gitea release with the
# binaries, their checksums and the build report attached. The tag's commit
# is embedded in `--version` so a downloaded binary identifies itself.
#
# Deploying to the fleet is deploy.yaml's job and follows main; a release is
# only a public snapshot and changes nothing on the hosts.
name: release
on:
push:
tags: ["v*-lair.*"]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: cuda-13.0
steps:
- uses: actions/checkout@v4
- name: build quantus-miner and quantus-bench
env:
MINER_BUILD_SHA: ${{ github.sha }}
MINER_CUDA_REQUIRE: "1"
run: |
set -euo pipefail
cargo build --release --locked -p miner-cli -p bench-harness
./target/release/quantus-miner --version
./target/release/quantus-bench --version || true
- name: package
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
dir="quantus-miner-${tag}-linux-x86_64"
mkdir -p "dist/${dir}"
cp target/release/quantus-miner target/release/quantus-bench "dist/${dir}/"
cp LICENSE "dist/${dir}/"
{
echo "quantus-miner ${tag}"
echo "commit: ${GITHUB_SHA}"
echo "built: $(date -u +%Y-%m-%dT%H:%M:%SZ) on $(source /etc/os-release && echo "$PRETTY_NAME"), $(ldd --version | head -1)"
echo "cuda: $(nvcc --version | grep -oE 'release [0-9.]+' | head -1)"
echo "cubins: sm_86 sm_89 sm_120 + compute_120 PTX"
echo "version string: $(./target/release/quantus-miner --version)"
} > "dist/${dir}/BUILD.txt"
(cd dist && tar -czf "${dir}.tar.gz" "${dir}" && sha256sum "${dir}.tar.gz" > "${dir}.tar.gz.sha256")
cat "dist/${dir}/BUILD.txt" "dist/${dir}.tar.gz.sha256"
- name: publish release
run: |
set -euo pipefail
tag="${GITHUB_REF_NAME}"
dir="quantus-miner-${tag}-linux-x86_64"
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
body=$(python3 - "$tag" "dist/${dir}/BUILD.txt" "dist/${dir}.tar.gz.sha256" <<'PY'
import json, sys, pathlib
tag, build, sha = sys.argv[1], pathlib.Path(sys.argv[2]).read_text(), pathlib.Path(sys.argv[3]).read_text().strip()
text = (
f"quantus-miner {tag}: Linux x86_64, NVIDIA RTX 30/40/50 (sm_86, sm_89, sm_120, plus PTX), "
"glibc 2.42 or newer, driver with CUDA 13.0 support.\n\n"
"No dev fee, no licence server, no telemetry. Measured rates and how to reproduce them are in the README.\n\n"
f"```\n{build}```\n\nsha256: `{sha}`\n"
)
print(json.dumps({"tag_name": tag, "name": f"quantus-miner {tag}", "body": text, "draft": False, "prerelease": False}))
PY
)
id=$(curl -fsS -X POST -H "$auth" -H "Content-Type: application/json" "${api}/releases" -d "$body" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
echo "release id ${id}"
for f in "dist/${dir}.tar.gz" "dist/${dir}.tar.gz.sha256" "dist/${dir}/BUILD.txt"; do
curl -fsS -X POST -H "$auth" "${api}/releases/${id}/assets?name=$(basename "$f")" -F "attachment=@${f}" > /dev/null
echo "attached $(basename "$f")"
done

View File

@@ -1,15 +0,0 @@
---
name: free disk space
description: when rust compiling, free up some disk space
runs:
using: composite
steps:
- name: free disk space
uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # 1.3.1
with:
android: true
dotnet: false
haskell: false
large-packages: false
swap-storage: false

View File

@@ -1,91 +0,0 @@
---
name: CI
on:
pull_request:
paths-ignore:
- "docs/**"
- "*.md"
- "LICENSE"
push:
branches:
- main
paths-ignore:
- "docs/**"
- "*.md"
- "LICENSE"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_INCREMENTAL: 0
CARGO_TERM_COLOR: always
jobs:
fast-checks:
name: 🏁 Fast Checks (Format)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: rustfmt
- name: install taplo
run: cargo install taplo-cli --locked
- name: Run format checks
run: |
taplo format --check --config taplo.toml
cargo fmt --all -- --check
timeout-minutes: 5
build-and-test:
name: 🛠️ Build & Test
needs: fast-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: compile (gpu)
run: |
cargo build --locked --workspace
timeout-minutes: 90
- name: test (gpu)
run: |
cargo test --locked --workspace
timeout-minutes: 15
analysis:
name: 🤖 Analysis (Clippy & Doc)
needs: fast-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
components: clippy
- name: clippy (all features)
run: cargo clippy --locked --workspace --all-features
timeout-minutes: 30
- name: doc
run: cargo doc --locked --workspace --no-deps --all-features
timeout-minutes: 15
benchmark:
name: 🏃 Benchmark
needs: fast-checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: build benchmark binary
run: cargo build -p miner-cli --release
timeout-minutes: 60
- name: run cpu benchmark
run: ./target/release/quantus-miner benchmark --cpu-workers 2 --duration 5
timeout-minutes: 10

View File

@@ -1,13 +0,0 @@
name: Dependency cooldown audit
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
uses: Quantus-Network/shared-workflows/.github/workflows/dependency-cooldown-audit.yml@v1

View File

@@ -1,14 +0,0 @@
name: Dependency cooldown
on:
pull_request:
# labeled/unlabeled/edited are required so the gate re-runs when an
# emergency bypass label or reason is added.
types: [opened, synchronize, reopened, labeled, unlabeled, edited]
permissions:
contents: read
jobs:
dependency-cooldown:
uses: Quantus-Network/shared-workflows/.github/workflows/dependency-cooldown.yml@v1

View File

@@ -1,199 +0,0 @@
---
name: Release Proposal
env:
CARGO_TERM_COLOR: always
on:
workflow_dispatch:
inputs:
target_branch:
description: "Target branch for the PR (default: main)"
required: false
type: string
default: "main"
version_type:
description: "Type of version bump"
required: true
default: "patch"
type: choice
options:
- patch
- minor
- major
- custom
custom_version:
description: 'Custom version string (e.g., 1.2.3). Only used if version_type is "custom".'
required: false
is_draft:
description: "Is this a draft release?"
required: true
type: boolean
default: false
jobs:
calculate-next-version:
name: 🧮 Calculate Next Version
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.versioner.outputs.new_version }}
new_tag: ${{ steps.versioner.outputs.new_tag }}
commit_sha_short: ${{ steps.vars.outputs.commit_sha_short }}
source_branch: ${{ steps.vars.outputs.source_branch }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
fetch-tags: true
- name: Get current branch and commit SHA
id: vars
run: |
echo "commit_sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
echo "source_branch=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT
- name: Get latest tag
id: latest_tag
run: |
# Get all version tags and sort them by version
latest_semver_tag=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" | sort -V | tail -n 1)
# If no tags found, use default
if [ -z "$latest_semver_tag" ]; then
latest_semver_tag="v0.0.0"
fi
echo "latest_tag_found=$latest_semver_tag" >> $GITHUB_OUTPUT
echo "Latest semantic version tag found: $latest_semver_tag"
- name: Calculate new version
id: versioner
env:
LATEST_TAG: ${{ steps.latest_tag.outputs.latest_tag_found }}
VERSION_TYPE: ${{ github.event.inputs.version_type }}
CUSTOM_VERSION: ${{ github.event.inputs.custom_version }}
run: |
# Remove 'v' prefix and any suffix for processing
current_version=${LATEST_TAG#v}
# Remove any suffix after the version number
current_version=$(echo "$current_version" | sed -E 's/-[^-]+$//')
if [[ "$VERSION_TYPE" == "custom" ]]; then
if [[ -z "$CUSTOM_VERSION" ]]; then
echo "Error: Custom version is selected but no custom_version string provided."
exit 1
fi
if [[ ! "$CUSTOM_VERSION" =~ ^v ]]; then
echo "Error: Custom version string MUST start with 'v' (e.g., v1.2.3)."
exit 1
fi
new_version="$CUSTOM_VERSION"
else
# Split version and pre-release part
IFS='-' read -r version_core prerelease_part <<< "$current_version"
IFS='.' read -r major minor patch <<< "$version_core"
# Increment based on type
if [[ "$VERSION_TYPE" == "major" ]]; then
major=$((major + 1))
minor=0
patch=0
elif [[ "$VERSION_TYPE" == "minor" ]]; then
minor=$((minor + 1))
patch=0
elif [[ "$VERSION_TYPE" == "patch" ]]; then
patch=$((patch + 1))
else
echo "Error: Invalid version_type: $VERSION_TYPE"
exit 1
fi
new_version="v$major.$minor.$patch"
fi
echo "New version: $new_version"
echo "new_version=$new_version" >> $GITHUB_OUTPUT
echo "new_tag=$new_version" >> $GITHUB_OUTPUT
update-cargo-toml:
name: 📝 Update version files
needs: calculate-next-version
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: Create version bump branch and PR
env:
NEW_VERSION: ${{ needs.calculate-next-version.outputs.new_version }}
NEW_TAG: ${{ needs.calculate-next-version.outputs.new_tag }}
GITHUB_TOKEN: ${{ secrets.ADMIN_PAT }}
SOURCE_BRANCH: ${{ needs.calculate-next-version.outputs.source_branch }}
TARGET_BRANCH: ${{ github.event.inputs.target_branch }}
run: |
set -ex
new_cargo_version=${NEW_VERSION#v}
branch_name="release/${NEW_VERSION}"
# Create new branch from source branch
git checkout "$SOURCE_BRANCH"
git checkout -b "$branch_name"
# Update version in workspace Cargo.toml (safer than cargo set-version)
echo "Updating workspace Cargo.toml to version: $new_cargo_version"
sed -i -E "s/^version\s*=\s*\"[0-9a-zA-Z.-]+\"/version = \"$new_cargo_version\"/" Cargo.toml
# Regenerate Cargo.lock with precise updates for our packages only
cargo update -p miner-cli --precise "$new_cargo_version"
cargo update -p miner-service --precise "$new_cargo_version"
cargo update -p pow-core --precise "$new_cargo_version"
cargo update -p metrics --precise "$new_cargo_version"
cargo update -p miner-telemetry --precise "$new_cargo_version"
cargo update -p engine-cpu --precise "$new_cargo_version"
cargo update -p engine-gpu --precise "$new_cargo_version"
# Verify everything compiles correctly
cargo check --workspace
# Commit changes
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"
git add Cargo.toml Cargo.lock
git commit -m "bump version to $NEW_VERSION"
git push origin "$branch_name"
# Prepare PR title and body
PR_TITLE="Release $NEW_VERSION"
PR_BODY="Automated version bump for release $NEW_VERSION.
## Overview
- Version bump: ${{ github.event.inputs.version_type }}
- Type: ${{ github.event.inputs.version_type }}
- Draft: ${{ github.event.inputs.is_draft }}
## What changed
- Updated version in Cargo.toml and Cargo.lock
Triggered by workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
# Prepare labels
PR_LABELS="release-proposal"
if [[ "${{ github.event.inputs.is_draft }}" == "true" ]]; then
PR_LABELS="$PR_LABELS,draft-release"
fi
gh pr create \
--title "$PR_TITLE" \
--body "$PR_BODY" \
--base "$TARGET_BRANCH" \
--head "$branch_name" \
--label "$PR_LABELS"

View File

@@ -1,157 +0,0 @@
---
name: Release Publish
on:
pull_request:
types: [closed]
branches:
- main
permissions:
contents: write
packages: write
env:
CARGO_TERM_COLOR: always
jobs:
create-tag:
name: Create Tag
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release-proposal')
runs-on: ubuntu-latest
outputs:
version: ${{ steps.extract_version.outputs.version }}
tag: ${{ steps.extract_version.outputs.tag }}
is_draft: ${{ steps.extract_version.outputs.is_draft }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version from PR title
id: extract_version
run: |
# Extract version from PR title (format: "Release vX.Y.Z")
VERSION_TAG=$(echo "${{ github.event.pull_request.title }}" | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+')
if [ -z "$VERSION_TAG" ]; then
echo "Error: Could not extract version from PR title: ${{ github.event.pull_request.title }}"
exit 1
fi
VERSION=${VERSION_TAG#v}
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT
# Check if this is a draft release
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'draft-release') }}" == "true" ]]; then
echo "is_draft=true" >> $GITHUB_OUTPUT
else
echo "is_draft=false" >> $GITHUB_OUTPUT
fi
echo "Extracted version: $VERSION"
echo "Extracted tag: $VERSION_TAG"
- name: Create and push tag
run: |
git config user.name "${{ github.actor }}"
git config user.email "${{ github.actor }}@users.noreply.github.com"
git tag -a "${{ steps.extract_version.outputs.tag }}" -m "Release ${{ steps.extract_version.outputs.tag }}"
git push origin "${{ steps.extract_version.outputs.tag }}"
build:
needs: create-tag
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
# Linux builds
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary_name: quantus-miner
asset_name: quantus-miner-linux-x86_64
# Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: quantus-miner.exe
asset_name: quantus-miner-windows-x86_64.exe
# macOS builds (Intel)
- os: macos-15-intel
target: x86_64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-x86_64
# macOS builds (Apple Silicon)
- os: macos-latest
target: aarch64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-aarch64
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-tag.outputs.tag }}
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
with:
target: ${{ matrix.target }}
- name: build binary
shell: bash
run: |
if [[ -n "${{ matrix.features }}" ]]; then
cargo build --release --locked --target ${{ matrix.target }} --features ${{ matrix.features }}
else
cargo build --release --locked --target ${{ matrix.target }}
fi
- name: prepare binary
shell: bash
run: |
cd target/${{ matrix.target }}/release
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
cp ${{ matrix.binary_name }} ${{ matrix.asset_name }}
else
cp ${{ matrix.binary_name }} ${{ matrix.asset_name }}
strip ${{ matrix.asset_name }}
fi
- name: upload binary artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.asset_name }}
path: target/${{ matrix.target }}/release/${{ matrix.asset_name }}
release:
needs: [create-tag, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ needs.create-tag.outputs.tag }}
- name: download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: create release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ needs.create-tag.outputs.tag }}
IS_DRAFT: ${{ needs.create-tag.outputs.is_draft }}
run: |
# Prepare draft flag
if [[ "$IS_DRAFT" == "true" ]]; then
DRAFT_FLAG="--draft"
else
DRAFT_FLAG=""
fi
# Create release with all artifacts
gh release create "$TAG" \
--title "Release $TAG" \
--generate-notes \
$DRAFT_FLAG \
artifacts/*/quantus-miner-*

83
Cargo.lock generated
View File

@@ -107,7 +107,7 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
"libloading",
"libloading 0.8.9",
]
[[package]]
@@ -122,6 +122,23 @@ version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
[[package]]
name = "bench-harness"
version = "4.0.2"
dependencies = [
"clap",
"engine-cpu",
"engine-cuda",
"engine-gpu",
"env_logger",
"log",
"pow-core",
"primitive-types 0.13.1",
"rand 0.9.2",
"serde",
"serde_json",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -524,6 +541,15 @@ dependencies = [
"typenum",
]
[[package]]
name = "cudarc"
version = "0.19.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "804764d10e844da09765a7b2ca9641a0851523d1702efb0d7299d73e31b86e80"
dependencies = [
"libloading 0.9.0",
]
[[package]]
name = "data-encoding"
version = "2.10.0"
@@ -577,7 +603,7 @@ dependencies = [
[[package]]
name = "engine-cpu"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"criterion",
"hex",
@@ -586,9 +612,22 @@ dependencies = [
"rand 0.9.2",
]
[[package]]
name = "engine-cuda"
version = "4.0.2"
dependencies = [
"cudarc",
"engine-cpu",
"log",
"metrics",
"pow-core",
"primitive-types 0.13.1",
"qp-poseidon-constants",
]
[[package]]
name = "engine-gpu"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"bytemuck",
"criterion",
@@ -597,6 +636,7 @@ dependencies = [
"futures",
"hex",
"log",
"metrics",
"pow-core",
"primitive-types 0.13.1",
"qp-plonky2",
@@ -636,7 +676,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1276,7 +1316,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -1373,7 +1413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
"libloading",
"libloading 0.8.9",
"pkg-config",
]
@@ -1411,6 +1451,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
@@ -1476,7 +1526,7 @@ dependencies = [
[[package]]
name = "metrics"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"anyhow",
"log",
@@ -1504,7 +1554,7 @@ dependencies = [
[[package]]
name = "miner-cli"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"clap",
"engine-cpu",
@@ -1522,11 +1572,12 @@ dependencies = [
[[package]]
name = "miner-service"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"anyhow",
"crossbeam-channel",
"engine-cpu",
"engine-cuda",
"engine-gpu",
"getrandom 0.2.17",
"hex",
@@ -1543,7 +1594,7 @@ dependencies = [
[[package]]
name = "miner-telemetry"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"anyhow",
"futures",
@@ -2003,7 +2054,7 @@ dependencies = [
[[package]]
name = "pool-service"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"anyhow",
"clap",
@@ -2049,7 +2100,7 @@ dependencies = [
[[package]]
name = "pow-core"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"hex",
"primitive-types 0.13.1",
@@ -2291,7 +2342,7 @@ dependencies = [
[[package]]
name = "quic-transport"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"anyhow",
"quantus-miner-api",
@@ -2871,7 +2922,7 @@ dependencies = [
[[package]]
name = "solver-wasm"
version = "4.0.1"
version = "4.0.2"
dependencies = [
"primitive-types 0.13.1",
"qpow-math",
@@ -3702,7 +3753,7 @@ dependencies = [
"js-sys",
"khronos-egl",
"libc",
"libloading",
"libloading 0.8.9",
"log",
"metal",
"naga",
@@ -3762,7 +3813,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -1,6 +1,8 @@
[workspace]
members = [
"crates/bench-harness", # lair: quantus/miner#2
"crates/engine-cpu",
"crates/engine-cuda", # lair: quantus/miner#3
"crates/engine-gpu",
"crates/metrics",
"crates/miner-cli",
@@ -18,7 +20,7 @@ resolver = "2"
edition = "2021"
authors = ["Quantus Network"]
description = "Quantus External Miner Workspace"
version = "4.0.1"
version = "4.0.2"
[workspace.dependencies]
anyhow = "1"

View File

@@ -2,6 +2,58 @@
High-performance external mining service for Quantus Network with support for CPU, GPU, and hybrid CPU+GPU mining.
## This fork
Like the official miner, but fast: a native CUDA engine for NVIDIA cards, kept
bit-exact with the reference hash and measured against every other miner we
can get hold of. No dev fee, no licence server, no telemetry, solo or pool.
Apache-2.0, like upstream.
**Measured hashrate**, three interleaved 30-second rounds per card at the
enforced power cap, memory clock locked at 810 MHz, CUDA 13.0 driver 580:
| card | power cap | this fork | upstream `--cuda-gpu` | quantusminer.com qpow-cuda 1.0.7 | quanpool-miner 6.2.4 (5% fee) |
|---|---|---|---|---|---|
| RTX 5090 | 400 W | **1104 MH/s** | 944 | 1171 | 1234 (1172 after fee) |
| RTX 4090 | 250 W | **727 MH/s** (loop kernel) | | | |
| RTX 3060 | 130 W | **141 MH/s** | | | |
The 5090 column was measured on the same card in the same session
(2026-09-13); the closed pool binaries were run in benchmark mode only. How
the pool kernels get their remaining edge, and what has been tried, is
written up in [issue #27](https://git.lair.cafe/quantus/miner/issues/27).
Since v4.0.2-lair.2 the binary carries two kernels. The default (`unrolled`)
is fastest on the RTX 5090 and 3060; RTX 40-series cards measure 1.7% better
on the rolled-loop one, selected with `MINER_CUDA_KERNEL=loop` in the
environment (it reports as kernel `cuda-loop` on the metrics). Both are
bit-exact with the CPU reference; try each with `quantus-bench` on your card.
**Binaries** are on the [releases page](https://git.lair.cafe/quantus/miner/releases):
one Linux x86_64 `quantus-miner` carrying cubins for sm_86 (RTX 30), sm_89
(RTX 40) and sm_120 (RTX 50) plus PTX for anything newer, built on Fedora 43
(glibc 2.42 or newer), and `quantus-bench`, the measurement harness. Nothing
is compiled at run time; the driver needs CUDA 13.0 support (580.x).
**Reproduce the numbers.** `quantus-bench` pins the power limit it expects,
warms up, reports the median of timed windows with their spread, and checks
GPU hashes against the CPU reference for random jobs:
```bash
# hashrate: median of 5 x 30 s windows, one worker thread per card
./quantus-bench --duration-secs 30 --runs 5 --workers 1 --expect-power-limit 400
# parity: 2000 random jobs, every found hash recomputed on the CPU
./quantus-bench --duration-secs 3 --runs 1 --parity-jobs 2000
```
Compare builds with interleaved rounds (A, B, A, B, ...) rather than one
block of runs each: on a power-capped card, clock drift between blocks looks
like a 0.5% code change.
**Solo mining** against your own node is the `serve` command below with the
node's auth token and certificate fingerprint; nothing else is needed.
## Building
```bash

View File

@@ -0,0 +1,23 @@
[package]
name = "bench-harness"
version.workspace = true
edition.workspace = true
publish = false
description = "lair: reproducible GPU hashrate and parity measurement for the Quantus miner (quantus/miner#2)"
[[bin]]
name = "quantus-bench"
path = "src/main.rs"
[dependencies]
engine-cpu = { path = "../engine-cpu" }
engine-gpu = { path = "../engine-gpu" }
engine-cuda = { path = "../engine-cuda" }
pow-core = { path = "../pow-core" }
primitive-types = { workspace = true }
clap = { workspace = true, features = ["derive", "env"] }
serde = { workspace = true }
serde_json = { workspace = true, features = ["std"] }
rand = { workspace = true }
env_logger = { workspace = true }
log = { workspace = true }

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bash
# lair: run quantus-bench on a mining host, as gitea_ci, with the miner paused.
#
# Executed over ssh by .gitea/workflows/bench.yaml. Everything that must be
# serialised per host lives here under one flock: Gitea's workflow concurrency
# group did NOT serialise two runs on the same host (observed: the second run's
# scp hit ETXTBSY on the binary the first was executing), so the host is the
# arbiter, not the forge.
#
# usage: bench-on-host.sh <binary> <duration_secs> <runs> <batch_size> <workers> <label> <out_json>
set -euo pipefail
BIN="$1"; DURATION="$2"; RUNS="$3"; BATCH="$4"; WORKERS="$5"; LABEL="$6"; OUT="$7"
DIR=/var/lib/gitea_ci/bench
LOCK="$DIR/.lock"
exec 9>"$LOCK"
if ! flock -w 1800 9; then
echo "another measurement has held $LOCK for 30 minutes; giving up" >&2
exit 1
fi
echo "--- host ---"
hostname -f
limit=$(nvidia-smi --query-gpu=power.limit --format=csv,noheader,nounits | awk 'NR==1{printf "%d", $1}')
echo "power limit: ${limit} W"
"$BIN" --version
echo "--- pause miner ---"
was_active=0
if systemctl is-active --quiet quantus-miner.service; then
was_active=1
sudo systemctl stop quantus-miner.service
else
echo "quantus-miner.service was not active; nothing to pause"
fi
resume() {
if [ "$was_active" = 1 ]; then
echo "--- resume miner ---"
sudo systemctl start quantus-miner.service
systemctl is-active quantus-miner.service
fi
}
trap resume EXIT
# Refuse to measure a card something else is using (on beast that would be
# inference). A few seconds for the miner to release it.
sleep 3
util=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits | awk 'NR==1{printf "%d", $1}')
echo "utilisation before measuring: ${util}%"
if [ "$util" -gt 5 ]; then
echo "GPU is busy (${util}%) with the miner stopped; refusing to measure" >&2
exit 1
fi
echo "--- measure ---"
cd "$DIR"
RUST_LOG=info "$BIN" \
--duration-secs "$DURATION" --runs "$RUNS" --batch-size "$BATCH" --workers "$WORKERS" \
--expect-power-limit "$limit" --label "$LABEL" --json "$OUT"

View File

@@ -0,0 +1,51 @@
// lair: embed the git commit in the binary so `--version` and the build-info
// metric identify a deployed build by commit, not by the workspace semver
// (which does not change between commits on a branch that deploys on push).
//
// Resolution order:
// 1. MINER_BUILD_SHA in the environment (CI sets it from the checked-out ref)
// 2. `git rev-parse HEAD` of the workspace, with "-dirty" if the tree differs
// 3. "unknown"
use std::process::Command;
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let s = s.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn main() {
println!("cargo:rerun-if-env-changed=MINER_BUILD_SHA");
let sha = match std::env::var("MINER_BUILD_SHA") {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => match git(&["rev-parse", "--short=12", "HEAD"]) {
Some(head) => {
// Re-run when HEAD moves so a rebuild after a commit picks it up.
if let Some(dir) = git(&["rev-parse", "--git-dir"]) {
println!("cargo:rerun-if-changed={dir}/HEAD");
println!("cargo:rerun-if-changed={dir}/refs/heads");
}
let dirty = git(&["status", "--porcelain", "--untracked-files=no"])
.map(|s| !s.is_empty())
.unwrap_or(false);
if dirty {
format!("{head}-dirty")
} else {
head
}
}
None => "unknown".to_string(),
},
};
println!("cargo:rustc-env=MINER_BUILD_SHA={sha}");
}

View File

@@ -0,0 +1,506 @@
//! lair: `quantus-bench`, the measurement gate for quantus/miner#2.
//!
//! Drives a GPU engine through the same `MinerEngine` trait the miner uses,
//! records hashrate over fixed windows with the GPU's power and clock state
//! captured before and after, verifies GPU/CPU parity on random jobs, and
//! writes one JSON record so runs are comparable across commits.
//!
//! It deliberately measures one card per worker thread, with no node, no
//! QUIC and no job churn: this is the kernel-plus-submission number. The
//! production number, with all of that included, is quantus/miner#9.
use clap::Parser;
use engine_cpu::{AtomicBoolCancelCheck, EngineStatus, MinerEngine, Range};
use engine_cuda::CudaEngine;
use engine_gpu::GpuEngine;
use primitive_types::U512;
use rand::RngCore;
use serde::Serialize;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
/// Reproducible GPU hashrate and parity measurement.
#[derive(Parser, Debug)]
#[command(version = VERSION, about)]
struct Args {
/// Seconds per timed window.
#[arg(long, default_value_t = 30)]
duration_secs: u64,
/// Number of timed windows (median and spread are reported).
#[arg(long, default_value_t = 5)]
runs: usize,
/// Seconds of untimed warm-up before the first window.
#[arg(long, default_value_t = 5)]
warmup_secs: u64,
/// Nonces per GPU batch (the miner's --gpu-batch-size).
#[arg(long, default_value_t = 1_000_000)]
batch_size: u32,
/// GPU engine: auto (cuda if this binary carries a kernel and a driver is
/// present, else wgpu), cuda, or wgpu.
#[arg(long, default_value = "auto")]
engine: String,
/// Worker threads. The engine assigns threads to devices round-robin, so
/// on a multi-card host N threads measure N cards. More threads than cards
/// is allowed on purpose: two threads on one card overlap one thread's
/// readback with the other's dispatch, which is the submission bubble
/// quantus/miner#4 and #5 are about, measured without touching the miner.
#[arg(long, default_value_t = 1)]
workers: usize,
/// Random jobs for the GPU/CPU parity check (0 to skip).
#[arg(long, default_value_t = 25)]
parity_jobs: usize,
/// Refuse to run unless every GPU reports exactly this enforced power
/// limit in watts. Power limit is the largest confound on these cards.
#[arg(long)]
expect_power_limit: Option<u32>,
/// Commit the binary under test was built from.
#[arg(long, default_value = BUILD_SHA)]
commit: String,
/// Free-form label stored in the record (e.g. the PR number).
#[arg(long, default_value = "")]
label: String,
/// Write the JSON record here.
#[arg(long)]
json: Option<std::path::PathBuf>,
}
// Set by build.rs (same resolution as miner-cli's).
const BUILD_SHA: &str = env!("MINER_BUILD_SHA");
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("MINER_BUILD_SHA"),
")"
);
#[derive(Serialize, Debug, Clone)]
struct GpuState {
index: u32,
name: String,
driver: String,
power_limit_w: f64,
power_draw_w: f64,
sm_clock_mhz: f64,
/// The memory clock is a confound like the power limit: the miner never
/// touches VRAM, and a card with memory locked low gives the SMs more of
/// the same power budget (lair/quantus#10).
mem_clock_mhz: f64,
temperature_c: f64,
}
#[derive(Serialize, Debug)]
struct WorkerResult {
worker: usize,
/// MH/s per timed window, in order.
windows_mhs: Vec<f64>,
median_mhs: f64,
/// (max - min) / median over the windows.
spread: f64,
}
#[derive(Serialize, Debug)]
struct Parity {
jobs: usize,
found: usize,
ok: bool,
}
#[derive(Serialize, Debug)]
struct Record {
schema: u32,
timestamp_unix: u64,
host: String,
commit: String,
label: String,
engine: String,
batch_size: u32,
duration_secs: u64,
runs: usize,
warmup_secs: u64,
workers: usize,
gpu_before: Vec<GpuState>,
gpu_after: Vec<GpuState>,
results: Vec<WorkerResult>,
/// Sum of worker medians: the host's number.
total_median_mhs: f64,
parity: Option<Parity>,
}
/// Either GPU engine behind the same trait; the harness drives them identically.
enum Engine {
Wgpu(GpuEngine),
Cuda(CudaEngine),
}
impl Engine {
fn open(kind: &str, batch_size: u32) -> Engine {
match kind {
"cuda" => {
Engine::Cuda(CudaEngine::try_new(batch_size, 0).expect("CUDA engine init failed"))
}
"wgpu" => Engine::Wgpu(
GpuEngine::try_new(batch_size, 0, false).expect("GPU engine init failed"),
),
"auto" => match CudaEngine::try_new(batch_size, 0) {
Ok(e) => Engine::Cuda(e),
Err(e) => {
log::info!("CUDA engine unavailable ({e}); using wgpu");
Engine::Wgpu(
GpuEngine::try_new(batch_size, 0, false).expect("GPU engine init failed"),
)
}
},
other => panic!("unknown --engine {other}; use auto, cuda or wgpu"),
}
}
fn device_count(&self) -> usize {
match self {
Engine::Wgpu(e) => e.device_count(),
Engine::Cuda(e) => e.device_count(),
}
}
fn as_dyn(&self) -> &dyn MinerEngine {
match self {
Engine::Wgpu(e) => e,
Engine::Cuda(e) => e,
}
}
}
fn nvidia_smi() -> Vec<GpuState> {
let out = Command::new("nvidia-smi")
.args([
"--query-gpu=index,name,driver_version,power.limit,power.draw,clocks.sm,clocks.mem,temperature.gpu",
"--format=csv,noheader,nounits",
])
.output();
let Ok(out) = out else {
log::warn!("nvidia-smi not available; GPU state not recorded");
return Vec::new();
};
if !out.status.success() {
log::warn!("nvidia-smi failed; GPU state not recorded");
return Vec::new();
}
String::from_utf8_lossy(&out.stdout)
.lines()
.filter_map(|l| {
let f: Vec<&str> = l.split(',').map(str::trim).collect();
if f.len() != 8 {
return None;
}
let num = |s: &str| s.parse::<f64>().unwrap_or(f64::NAN);
Some(GpuState {
index: f[0].parse().ok()?,
name: f[1].to_string(),
driver: f[2].to_string(),
power_limit_w: num(f[3]),
power_draw_w: num(f[4]),
sm_clock_mhz: num(f[5]),
mem_clock_mhz: num(f[6]),
temperature_c: num(f[7]),
})
})
.collect()
}
fn hostname() -> String {
Command::new("hostname")
.arg("-f")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".into())
}
fn median(xs: &[f64]) -> f64 {
let mut v = xs.to_vec();
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = v.len();
if n == 0 {
return f64::NAN;
}
if n % 2 == 1 {
v[n / 2]
} else {
(v[n / 2 - 1] + v[n / 2]) / 2.0
}
}
/// One search window: search from a random start until `cancel` fires,
/// returning hashes and elapsed seconds. The difficulty is high enough that
/// a solution is effectively impossible, matching production where the second
/// squeeze is almost never taken.
fn window(engine: &dyn MinerEngine, secs: u64, rng: &mut impl RngCore) -> (u64, f64) {
let mut header = [0u8; 32];
rng.fill_bytes(&mut header);
let ctx = engine.prepare_context(header, U512::from(1u64) << 200);
let mut start_bytes = [0u8; 64];
rng.fill_bytes(&mut start_bytes);
start_bytes[0] = 0; // keep clear of the top so the range cannot wrap
let start = U512::from_big_endian(&start_bytes);
let range = Range {
start,
end: start + (U512::from(1u64) << 200),
};
let flag = Arc::new(AtomicBool::new(false));
let timer_flag = flag.clone();
let timer = std::thread::spawn(move || {
std::thread::sleep(Duration::from_secs(secs));
timer_flag.store(true, Ordering::Relaxed);
});
let t0 = Instant::now();
let status = engine.search_range(&ctx, range, &AtomicBoolCancelCheck(&flag));
let elapsed = t0.elapsed().as_secs_f64();
timer.join().expect("timer thread");
let hashes = match status {
EngineStatus::Cancelled { hash_count } | EngineStatus::Exhausted { hash_count } => {
hash_count
}
EngineStatus::Found { hash_count, .. } => {
log::warn!("window found a solution at 2^-200 odds; counting hashes anyway");
hash_count
}
other => panic!("unexpected engine status: {other:?}"),
};
(hashes, elapsed)
}
fn parity(engine: &dyn MinerEngine, jobs: usize) -> Parity {
// Same shape as engine-gpu's gpu_cpu_parity example, kept in lockstep with it.
let mut rng = rand::rng();
let cancel_flag = AtomicBool::new(false);
let cancel = AtomicBoolCancelCheck(&cancel_flag);
let mut found = 0usize;
let mut ok = true;
for job in 0..jobs {
let mut header = [0u8; 32];
rng.fill_bytes(&mut header);
let ctx = engine.prepare_context(header, U512::from(100_000u64));
let start = if job == 0 {
// Cross a 2^256 boundary to exercise the midstate batch clamp.
(U512::from(3u64) << 256) - U512::from(1_000u64)
} else {
let mut b = [0u8; 64];
rng.fill_bytes(&mut b);
b[0] = 0;
U512::from_big_endian(&b)
};
let range = Range {
start,
end: start + U512::from(10_000_000u64),
};
match engine.search_range(&ctx, range.clone(), &cancel) {
EngineStatus::Found { candidate, .. } => {
let cpu = pow_core::hash_from_nonce(&ctx, candidate.nonce);
let in_range = candidate.nonce >= range.start && candidate.nonce <= range.end;
if cpu != candidate.hash || cpu >= ctx.target || !in_range {
log::error!(
"parity job {job}: nonce {} gpu_hash {} cpu_hash {} in_range {in_range}",
candidate.nonce,
candidate.hash,
cpu
);
ok = false;
}
found += 1;
}
EngineStatus::Exhausted { .. } => {}
other => {
log::error!("parity job {job}: unexpected status {other:?}");
ok = false;
}
}
}
if found == 0 {
log::error!("parity: no solutions found across {jobs} jobs");
ok = false;
}
Parity { jobs, found, ok }
}
fn main() {
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
let args = Args::parse();
if args.runs == 0 || args.workers == 0 {
eprintln!("--runs and --workers must be at least 1");
std::process::exit(2);
}
let gpu_before = nvidia_smi();
if let Some(want) = args.expect_power_limit {
for g in &gpu_before {
if (g.power_limit_w - want as f64).abs() > 0.5 {
eprintln!(
"gpu {} ({}) power limit is {:.0} W, expected {want} W; refusing to measure",
g.index, g.name, g.power_limit_w
);
std::process::exit(3);
}
}
}
let engine = Arc::new(Engine::open(&args.engine, args.batch_size));
let devices = engine.device_count();
if args.workers > devices {
log::warn!(
"{} workers on {devices} device(s): threads share cards, so per-worker numbers \
are shares and total_median_mhs is the number to compare",
args.workers
);
}
log::info!(
"engine {} with {devices} device(s); measuring {} worker(s), {} x {}s windows after {}s warm-up, batch {}",
engine.as_dyn().name(),
args.workers,
args.runs,
args.duration_secs,
args.warmup_secs,
args.batch_size
);
let mut handles = Vec::new();
for w in 0..args.workers {
let engine = engine.clone();
let (runs, dur, warm) = (args.runs, args.duration_secs, args.warmup_secs);
handles.push(std::thread::spawn(move || {
let mut rng = rand::rng();
if warm > 0 {
let _ = window(engine.as_dyn(), warm, &mut rng);
}
let mut windows = Vec::with_capacity(runs);
for i in 0..runs {
let (h, s) = window(engine.as_dyn(), dur, &mut rng);
let mhs = h as f64 / s / 1e6;
log::info!("worker {w} window {i}: {h} hashes in {s:.2}s = {mhs:.2} MH/s");
windows.push(mhs);
}
let med = median(&windows);
let (mn, mx) = windows
.iter()
.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &x| {
(a.min(x), b.max(x))
});
WorkerResult {
worker: w,
windows_mhs: windows,
median_mhs: med,
spread: if med > 0.0 { (mx - mn) / med } else { f64::NAN },
}
}));
}
let results: Vec<WorkerResult> = handles
.into_iter()
.map(|h| h.join().expect("worker thread"))
.collect();
let gpu_after = nvidia_smi();
let parity_result = if args.parity_jobs > 0 {
Some(parity(engine.as_dyn(), args.parity_jobs))
} else {
None
};
let record = Record {
schema: 2,
timestamp_unix: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
host: hostname(),
commit: args.commit.clone(),
label: args.label.clone(),
engine: engine.as_dyn().name().to_string(),
batch_size: args.batch_size,
duration_secs: args.duration_secs,
runs: args.runs,
warmup_secs: args.warmup_secs,
workers: args.workers,
gpu_before,
gpu_after,
total_median_mhs: results.iter().map(|r| r.median_mhs).sum(),
results,
parity: parity_result,
};
// Human summary on stdout (the workflow appends it to the job summary).
println!("## quantus-bench {} on {}", record.commit, record.host);
println!();
println!("| worker | median MH/s | spread | windows |");
println!("| --- | --- | --- | --- |");
for r in &record.results {
let w: Vec<String> = r.windows_mhs.iter().map(|x| format!("{x:.1}")).collect();
println!(
"| {} | {:.2} | {:.1}% | {} |",
r.worker,
r.median_mhs,
r.spread * 100.0,
w.join(", ")
);
}
println!();
println!(
"total median: **{:.2} MH/s** (batch {}, {} x {}s, engine {})",
record.total_median_mhs,
record.batch_size,
record.runs,
record.duration_secs,
record.engine
);
for g in &record.gpu_after {
println!(
"- gpu {} {}: driver {}, limit {:.0} W, draw {:.0} W, sm {:.0} MHz, mem {:.0} MHz, {:.0} C",
g.index,
g.name,
g.driver,
g.power_limit_w,
g.power_draw_w,
g.sm_clock_mhz,
g.mem_clock_mhz,
g.temperature_c
);
}
if let Some(p) = &record.parity {
println!(
"- parity: {} ({}/{} jobs found solutions, all verified against CPU)",
if p.ok { "OK" } else { "FAILED" },
p.found,
p.jobs
);
}
if let Some(path) = &args.json {
let s = serde_json::to_string_pretty(&record).expect("serialise record");
std::fs::write(path, s).expect("write json record");
log::info!("record written to {}", path.display());
}
let spread_bad = record.results.iter().any(|r| r.spread > 0.02);
if spread_bad {
eprintln!("spread above 2% on at least one worker; run is not stable enough to compare");
}
if record.parity.as_ref().is_some_and(|p| !p.ok) {
eprintln!("PARITY FAILED");
std::process::exit(4);
}
if spread_bad {
std::process::exit(5);
}
}

View File

@@ -0,0 +1,18 @@
[package]
name = "engine-cuda"
version.workspace = true
edition.workspace = true
publish = false
description = "lair: native CUDA mining engine behind MinerEngine (quantus/miner#3)"
[dependencies]
engine-cpu = { path = "../engine-cpu" }
pow-core = { path = "../pow-core" }
metrics = { path = "../metrics" }
primitive-types = { workspace = true }
log = { workspace = true }
cudarc = { version = "0.19", default-features = false, features = ["driver", "nvrtc", "dynamic-loading", "cuda-13000"] }
qp-poseidon-constants = "1.1"
[build-dependencies]
qp-poseidon-constants = "1.1"

196
crates/engine-cuda/build.rs Normal file
View File

@@ -0,0 +1,196 @@
//! lair: build the CUDA mining kernel into a fat binary (quantus/miner#3, #8).
//!
//! 1. Generate `poseidon2_constants.cuh` from `qp-poseidon-constants`, so a
//! change to the hash at origin reaches the kernel as a dependency bump and
//! never as a hand-copied table (rule from quantus/miner#1).
//! 2. If `nvcc` is available, compile `src/kernels/mining.cu` once with one
//! cubin per fleet architecture plus PTX for the newest, into
//! `$OUT_DIR/mining.fatbin`. The driver picks the matching cubin at load;
//! an unknown future card JIT-compiles the PTX.
//! 3. If `nvcc` is not available (the `rust` lint runner, a workstation without
//! CUDA), write an empty fat binary and warn. The crate still compiles;
//! `CudaEngine::try_new` then fails with a clear message and the miner
//! falls back to the wgpu engine.
//!
//! Knobs (environment):
//! NVCC / CUDA_HOME where nvcc is
//! MINER_CUDA_ARCHS comma-separated SM list, default 86,89,120
//! MINER_NVCC_CCBIN host compiler for nvcc (-ccbin)
//! MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER=1 pass -allow-unsupported-compiler
//! MINER_CUDA_REQUIRE=1 fail the build instead of warning when nvcc is missing
//! MINER_NVCC_FLAGS extra nvcc arguments (whitespace-separated), e.g. -DLAIR_LAZY_ADD=0
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn write_constants(out: &Path) {
use qp_poseidon_constants as c;
let mut s = String::new();
s.push_str("// Generated by engine-cuda/build.rs from qp-poseidon-constants. Do not edit.\n");
s.push_str("#pragma once\n");
let arr = |name: &str, v: &[u64]| {
let body: Vec<String> = v.iter().map(|x| format!("0x{x:016x}ull")).collect();
format!(
"__device__ __constant__ unsigned long long {}[{}] = {{\n {}\n}};\n",
name,
v.len(),
body.join(",\n ")
)
};
let arr2 = |name: &str, v: &[[u64; 12]]| {
let rows: Vec<String> = v
.iter()
.map(|r| {
let body: Vec<String> = r.iter().map(|x| format!("0x{x:016x}ull")).collect();
format!(" {{{}}}", body.join(", "))
})
.collect();
format!(
"__device__ __constant__ unsigned long long {}[{}][12] = {{\n{}\n}};\n",
name,
v.len(),
rows.join(",\n")
)
};
s.push_str(&arr("RC_INTERNAL", &c::POSEIDON2_INTERNAL_CONSTANTS_RAW));
s.push_str(&arr2(
"RC_INITIAL",
&c::POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW,
));
s.push_str(&arr2(
"RC_TERMINAL",
&c::POSEIDON2_TERMINAL_EXTERNAL_CONSTANTS_RAW,
));
s.push_str(&arr("MDS_DIAG", &c::POSEIDON2_MATRIX_DIAG_12_RAW));
s.push_str(&format!(
"#define SPONGE_WIDTH {}\n#define N_EXTERNAL_HALF {}\n#define N_INTERNAL {}\n",
c::SPONGE_WIDTH,
c::POSEIDON2_EXTERNAL_ROUNDS / 2,
c::POSEIDON2_INTERNAL_ROUNDS
));
fs::write(out.join("poseidon2_constants.cuh"), s).expect("write constants header");
}
fn find_nvcc() -> Option<PathBuf> {
if let Ok(p) = env::var("NVCC") {
let p = PathBuf::from(p);
if p.is_file() {
return Some(p);
}
}
if let Ok(home) = env::var("CUDA_HOME").or_else(|_| env::var("CUDA_PATH")) {
let p = PathBuf::from(home).join("bin").join("nvcc");
if p.is_file() {
return Some(p);
}
}
if let Ok(path) = env::var("PATH") {
for dir in env::split_paths(&path) {
let p = dir.join("nvcc");
if p.is_file() {
return Some(p);
}
}
}
// The toolkit the fleet drivers support; prefer an exact 13.0 over "latest".
for cand in [
"/usr/local/cuda-13.0",
"/usr/local/cuda-13",
"/usr/local/cuda",
] {
let p = PathBuf::from(cand).join("bin").join("nvcc");
if p.is_file() {
return Some(p);
}
}
None
}
fn main() {
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src/kernels/mining.cu");
for v in [
"NVCC",
"CUDA_HOME",
"CUDA_PATH",
"MINER_CUDA_ARCHS",
"MINER_NVCC_CCBIN",
"MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER",
"MINER_CUDA_REQUIRE",
"MINER_NVCC_FLAGS",
] {
println!("cargo:rerun-if-env-changed={v}");
}
let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
write_constants(&out);
let archs = env::var("MINER_CUDA_ARCHS").unwrap_or_else(|_| "86,89,120".to_string());
let sms: Vec<String> = archs
.split(',')
.map(|s| s.trim().trim_start_matches("sm_").to_string())
.filter(|s| !s.is_empty())
.collect();
println!("cargo:rustc-env=MINER_CUDA_ARCHS={}", sms.join(","));
let fatbin = out.join("mining.fatbin");
let Some(nvcc) = find_nvcc() else {
if env::var("MINER_CUDA_REQUIRE")
.map(|v| v == "1")
.unwrap_or(false)
{
panic!("nvcc not found and MINER_CUDA_REQUIRE=1; set NVCC or CUDA_HOME");
}
println!("cargo:warning=engine-cuda: nvcc not found; CUDA kernel not compiled into this binary (wgpu engine will be used)");
fs::write(&fatbin, []).expect("write empty fatbin");
return;
};
let mut cmd = Command::new(&nvcc);
cmd.arg("-fatbin")
.arg("-O3")
.arg("-std=c++17")
.arg("-I")
.arg(&out)
.arg("-o")
.arg(&fatbin);
if let Ok(cc) = env::var("MINER_NVCC_CCBIN") {
cmd.arg("-ccbin").arg(cc);
}
if env::var("MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER")
.map(|v| v == "1")
.unwrap_or(false)
{
cmd.arg("-allow-unsupported-compiler");
}
if let Ok(extra) = env::var("MINER_NVCC_FLAGS") {
for a in extra.split_whitespace() {
cmd.arg(a);
}
}
for sm in &sms {
cmd.arg("--generate-code")
.arg(format!("arch=compute_{sm},code=sm_{sm}"));
}
if let Some(newest) = sms.iter().max_by_key(|s| s.parse::<u32>().unwrap_or(0)) {
cmd.arg("--generate-code")
.arg(format!("arch=compute_{newest},code=compute_{newest}"));
}
cmd.arg("src/kernels/mining.cu");
println!(
"cargo:warning=engine-cuda: {} for sm_{{{}}}",
nvcc.display(),
sms.join(",")
);
let status = cmd.status().expect("run nvcc");
if !status.success() {
panic!("nvcc failed ({status}); see output above");
}
let size = fs::metadata(&fatbin).map(|m| m.len()).unwrap_or(0);
if size == 0 {
panic!("nvcc produced an empty fat binary");
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,681 @@
#![deny(rust_2018_idioms)]
//! lair: native CUDA mining engine behind `MinerEngine` (quantus/miner#3).
//!
//! Same host contract and batch loop as `engine-gpu` (midstate per batch, no
//! carry into the high nonce half, cancellation between batches, thread-local
//! worker-to-device assignment), with the kernel in `kernels/mining.cu` built
//! into a fat binary by `build.rs`. Nothing in `engine-gpu` is touched; the
//! service picks this engine when the binary carries a kernel and a CUDA
//! driver is present, and falls back to wgpu otherwise.
use cudarc::driver::{
CudaContext, CudaFunction, CudaSlice, CudaStream, DeviceRepr, DriverError, LaunchConfig,
PushKernelArg,
};
use cudarc::nvrtc::Ptx;
use engine_cpu::{CancelCheck, Candidate, EngineStatus, FoundOrigin, MinerEngine, Range};
use pow_core::{format_hashrate, format_u512, JobContext};
use primitive_types::U512;
use std::cell::RefCell;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Instant;
/// The fat binary produced by build.rs; empty when nvcc was not available.
const FATBIN: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/mining.fatbin"));
/// SM list the fat binary carries, for `--version` and the config metric.
pub const CUDA_ARCHS: &str = env!("MINER_CUDA_ARCHS");
/// lair: which kernel entry the module runs (quantus/miner#27). `unrolled`
/// is `mining_main`, the deployed kernel; `loop` is `mining_loop`, the
/// rolled-round twin under evaluation. Selected with MINER_CUDA_KERNEL so a
/// host can be switched without a rebuild, and reported as the kernel id on
/// the per-device metrics so the deploy validate and the dashboards can tell
/// them apart.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum KernelChoice {
Unrolled,
Loop,
}
impl KernelChoice {
fn from_env() -> Self {
match std::env::var("MINER_CUDA_KERNEL").as_deref() {
Ok("loop") => KernelChoice::Loop,
Ok("unrolled") | Ok("") | Err(_) => KernelChoice::Unrolled,
Ok(other) => {
log::warn!(target: "cuda_engine", "MINER_CUDA_KERNEL={other:?} is not unrolled|loop; using unrolled");
KernelChoice::Unrolled
}
}
}
fn function(self) -> &'static str {
match self {
KernelChoice::Unrolled => "mining_main",
KernelChoice::Loop => "mining_loop",
}
}
/// Kernel id on metrics: `cuda` for the deployed kernel (what the deploy
/// validate asserts), `cuda-loop` for the candidate.
fn id(self) -> &'static str {
match self {
KernelChoice::Unrolled => "cuda",
KernelChoice::Loop => "cuda-loop",
}
}
}
/// Threads per block. Must match the kernel's `__launch_bounds__` (LAIR_TPB,
/// default 256); override for experiments with MINER_CUDA_THREADS_PER_BLOCK
/// on a kernel compiled with the same value.
const THREADS_PER_BLOCK_DEFAULT: u32 = 256;
/// The loop kernel's `LAIR_LOOP_TPB`.
const LOOP_THREADS_PER_BLOCK_DEFAULT: u32 = 512;
fn threads_per_block(kernel: KernelChoice) -> u32 {
std::env::var("MINER_CUDA_THREADS_PER_BLOCK")
.ok()
.and_then(|v| v.parse().ok())
.filter(|v: &u32| *v > 0 && v.is_multiple_of(32))
.unwrap_or(match kernel {
KernelChoice::Unrolled => THREADS_PER_BLOCK_DEFAULT,
KernelChoice::Loop => LOOP_THREADS_PER_BLOCK_DEFAULT,
})
}
/// lair: size every batch to whole grids. The CLI default batch (1M nonces)
/// fills a 5090 about eleven waves deep with a half-empty tail wave and pays
/// the per-launch gap every 1M hashes. Rounding the batch up to a multiple of
/// the grid (SMs x threads per SM, one nonce per thread) measured +1.6% on
/// beast, and two grids per batch (each thread loops over two nonces) a
/// further +0.4% there and +0.5% on the 4090, neutral on the 3060
/// (quantus/miner#3, 2026-09-13). A batch is then ~10 ms on every card, so a
/// job switch discards about 0.1% of a block interval. Overrides:
/// MINER_CUDA_BATCH_ALIGN=0 for the CLI batch as given, MINER_CUDA_BATCH_WAVES
/// for the minimum number of grids per batch.
const BATCH_WAVES_DEFAULT: u32 = 2;
fn batch_align() -> bool {
std::env::var("MINER_CUDA_BATCH_ALIGN")
.map(|v| v != "0")
.unwrap_or(true)
}
fn batch_waves() -> u32 {
std::env::var("MINER_CUDA_BATCH_WAVES")
.ok()
.and_then(|v| v.parse().ok())
.filter(|v: &u32| *v > 0)
.unwrap_or(BATCH_WAVES_DEFAULT)
}
/// Threads per SM the grid is sized for (override: MINER_CUDA_THREADS_PER_SM).
/// Sets how many nonces each thread loops over for a given batch; tuned with #2.
// Measured on the 4090 (#3): 8192 caps the grid at ~1M threads, so batches
// above 1M loop nonces per thread and lose up to 4%; 32768 keeps one nonce per
// thread up to 4M and is flat across batch sizes at ~305 MH/s.
const THREADS_PER_SM_DEFAULT: u32 = 32768;
fn threads_per_sm() -> u32 {
std::env::var("MINER_CUDA_THREADS_PER_SM")
.ok()
.and_then(|v| v.parse().ok())
.filter(|v: &u32| *v > 0)
.unwrap_or(THREADS_PER_SM_DEFAULT)
}
static ENGINE_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
thread_local! {
static ASSIGNED_DEVICE: RefCell<Option<(usize, usize)>> = const { RefCell::new(None) };
static WORKER_RESOURCES: RefCell<Option<(usize, WorkerResources)>> = const { RefCell::new(None) };
static DEVICE_LOST: RefCell<Option<usize>> = const { RefCell::new(None) };
}
struct Device {
ctx: Arc<CudaContext>,
func: CudaFunction,
name: String,
sm_count: u32,
kernel: KernelChoice,
threads_per_block: u32,
threads_per_sm: u32,
metrics: metrics::DeviceMetrics,
}
struct WorkerResources {
stream: Arc<CudaStream>,
results: CudaSlice<u32>,
host_results: Vec<u32>,
/// Target limbs for this job context, recomputed when the target changes.
target_u32s: [u32; 16],
/// Target the limbs above were derived from.
target_written: Option<U512>,
}
const RESULTS_LEN: usize = 1 + 16 + 16;
/// lair: the kernel's launch-uniform inputs, passed by value so they land in
/// the parameter bank (constant memory) instead of per-thread registers.
///
/// Layout must match `struct MiningUniforms` in `kernels/mining.cu`. Passing
/// them as parameters rather than device buffers took the kernel from 128
/// registers with 104 bytes of per-thread spill on sm_120 (44 on sm_86/89) to
/// 106 / 80 registers with none, and removed three host-to-device copies per
/// batch.
#[repr(C)]
#[derive(Clone, Copy)]
struct MiningUniforms {
midstate: [u64; 12],
start_nonce: [u32; 16],
target: [u32; 16],
/// lair: the state after the first external linear layer (and its round
/// constant) for the batch's first nonce. That layer is linear, and only
/// the least significant nonce limb changes within a batch, so the kernel
/// derives each nonce's state from this by one scalar times a fixed
/// small-integer column instead of recomputing the layer (quantus/miner#3).
layer0_base: [u64; 12],
/// lair: the internal layer's diagonal, for the loop kernel (#27). In the
/// parameter bank ptxas reads it straight from the instruction operand;
/// from `__constant__` it reloaded all twelve values every round.
diag: [u64; 12],
}
// SAFETY: `#[repr(C)]` plain-old-data with no padding (12 x u64, 32 x u32, 24 x u64),
// matching the kernel's parameter layout; there is nothing to validate beyond
// the layout, which is what this marker asserts.
unsafe impl DeviceRepr for MiningUniforms {}
// A mismatch here would silently feed the kernel the wrong midstate or target,
// so it is a build error rather than a mining bug. The kernel carries the same
// assertion.
const _: () = assert!(std::mem::size_of::<MiningUniforms>() == 416);
pub struct CudaEngine {
engine_id: usize,
devices: Vec<Arc<Device>>,
device_counter: AtomicUsize,
batch_size: u32,
throttle_ms: u64,
}
impl CudaEngine {
/// Returns an error when the binary carries no kernel, no CUDA driver is
/// present, or no device initialises.
pub fn try_new(batch_size: u32, throttle_ms: u64) -> Result<Self, String> {
if batch_size == 0 {
return Err("batch size must be non-zero".into());
}
if FATBIN.is_empty() {
return Err("CUDA kernel not compiled into this binary (built without nvcc)".into());
}
let count =
CudaContext::device_count().map_err(|e| format!("CUDA driver unavailable: {e:?}"))?;
if count <= 0 {
return Err("no CUDA devices".into());
}
let mut devices = Vec::new();
for ordinal in 0..count as usize {
match Device::init(ordinal) {
Ok(d) => {
log::info!(
target: "cuda_engine",
"CUDA device {ordinal}: {} ({} SMs) using {} kernel [sm_{{{}}}]",
d.name,
d.sm_count,
d.kernel.id(),
CUDA_ARCHS
);
devices.push(Arc::new(d));
}
Err(e) => {
log::warn!(target: "cuda_engine", "CUDA device {ordinal} failed to initialise: {e:?}; skipping");
}
}
}
if devices.is_empty() {
return Err("no CUDA device could be initialised".into());
}
log::info!(
target: "cuda_engine",
"CUDA engine initialized with {} devices (batch size: {} nonces, throttle: {}ms)",
devices.len(),
batch_size,
throttle_ms
);
Ok(Self {
engine_id: ENGINE_ID_COUNTER.fetch_add(1, Ordering::SeqCst),
devices,
device_counter: AtomicUsize::new(0),
batch_size,
throttle_ms,
})
}
pub fn device_count(&self) -> usize {
self.devices.len()
}
/// Drop the calling thread's device resources (call on worker exit).
pub fn clear_worker_resources() {
WORKER_RESOURCES.with(|r| *r.borrow_mut() = None);
ASSIGNED_DEVICE.with(|a| *a.borrow_mut() = None);
}
}
impl Device {
fn init(ordinal: usize) -> Result<Self, DriverError> {
let ctx = CudaContext::new(ordinal)?;
let name = ctx.name()?;
let sm_count = ctx.attribute(
cudarc::driver::sys::CUdevice_attribute::CU_DEVICE_ATTRIBUTE_MULTIPROCESSOR_COUNT,
)? as u32;
let module = ctx.load_module(Ptx::from_binary(FATBIN.to_vec()))?;
let kernel = KernelChoice::from_env();
let func = module.load_function(kernel.function())?;
Ok(Self {
ctx,
func,
name,
sm_count,
kernel,
threads_per_sm: threads_per_sm(),
threads_per_block: threads_per_block(kernel),
metrics: metrics::DeviceMetrics::new(ordinal, kernel.id()),
})
}
fn create_resources(&self) -> Result<WorkerResources, DriverError> {
let stream = self.ctx.new_stream()?;
Ok(WorkerResources {
results: stream.alloc_zeros::<u32>(RESULTS_LEN)?,
host_results: vec![0u32; RESULTS_LEN],
target_u32s: [0u32; 16],
target_written: None,
stream,
})
}
}
enum BatchResult {
Found {
candidate: Candidate,
hash_count: u64,
},
NotFound {
hash_count: u64,
},
DeviceLost,
}
impl MinerEngine for CudaEngine {
fn name(&self) -> &'static str {
"gpu-cuda"
}
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
JobContext::new(header_hash, difficulty)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn search_range(
&self,
ctx: &JobContext,
range: Range,
cancel: &dyn CancelCheck,
) -> EngineStatus {
if DEVICE_LOST.with(|l| *l.borrow() == Some(self.engine_id)) {
return EngineStatus::DeviceLost { hash_count: 0 };
}
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
if cancel.is_cancelled() {
return EngineStatus::Cancelled { hash_count: 0 };
}
let device_index = ASSIGNED_DEVICE.with(|a| {
let mut a = a.borrow_mut();
match *a {
Some((id, idx)) if id == self.engine_id => idx,
_ => {
let idx = if self.devices.len() == 1 {
0
} else {
self.device_counter.fetch_add(1, Ordering::SeqCst) % self.devices.len()
};
*a = Some((self.engine_id, idx));
log::info!(target: "cuda_engine", "Worker thread assigned to CUDA device {idx} (of {} total devices)", self.devices.len());
idx
}
}
});
let dev = &self.devices[device_index];
WORKER_RESOURCES.with(|cell| {
let mut slot = cell.borrow_mut();
let need_new = !matches!(&*slot, Some((id, _)) if *id == self.engine_id);
if need_new {
match dev.create_resources() {
Ok(r) => *slot = Some((self.engine_id, r)),
Err(e) => {
log::error!(target: "cuda_engine", "CUDA device {device_index} resource allocation failed: {e:?}");
DEVICE_LOST.with(|l| *l.borrow_mut() = Some(self.engine_id));
return EngineStatus::DeviceLost { hash_count: 0 };
}
}
}
let (_, res) = slot.as_mut().expect("resources present");
self.search_on(dev, device_index, res, ctx, range, cancel)
})
}
}
impl CudaEngine {
fn search_on(
&self,
dev: &Device,
device_index: usize,
res: &mut WorkerResources,
ctx: &JobContext,
range: Range,
cancel: &dyn CancelCheck,
) -> EngineStatus {
// Target is per job; convert it to limbs once per context. It reaches
// the kernel in the parameter bank, so there is nothing to upload.
if res.target_written != Some(ctx.target) {
let target_bytes = ctx.target.to_little_endian();
for i in 0..16 {
res.target_u32s[i] =
u32::from_le_bytes(target_bytes[i * 4..(i + 1) * 4].try_into().unwrap());
}
res.target_written = Some(ctx.target);
}
let search_start = Instant::now();
let mut total_hashes: u64 = 0;
let mut current_start = range.start;
let mut batch_num = 0u64;
let mut last_batch_hashes: u64 = 0;
log::info!(
target: "cuda_engine",
"CUDA {} search started: range {}..{}, batch size: {} nonces",
device_index,
format_u512(range.start),
format_u512(range.end),
self.batch_size
);
while current_start <= range.end {
if cancel.is_cancelled() {
dev.metrics.record_stale_hashes(last_batch_hashes);
let elapsed = search_start.elapsed();
log::info!(
target: "cuda_engine",
"CUDA {} cancelled before batch {} ({} total hashes in {:.2}s, {})",
device_index,
batch_num,
total_hashes,
elapsed.as_secs_f64(),
format_hashrate(total_hashes as f64 / elapsed.as_secs_f64())
);
return EngineStatus::Cancelled {
hash_count: total_hashes,
};
}
// Clamp so nonce increments never carry into the high 256 bits,
// which the midstate precompute relies on.
let remaining = range
.end
.saturating_sub(current_start)
.saturating_add(U512::one());
let headroom =
(U512::one() << 256) - (current_start & ((U512::one() << 256) - U512::one()));
let cap = remaining.min(headroom);
let want: u32 = if batch_align() {
let grid = (dev.sm_count * dev.threads_per_sm).max(1);
let waves = self.batch_size.div_ceil(grid).max(batch_waves());
waves.saturating_mul(grid)
} else {
self.batch_size
};
let this_batch: u32 = if cap > U512::from(want) {
want
} else {
cap.low_u32()
};
match self.run_single_batch(dev, res, ctx, current_start, this_batch) {
BatchResult::Found {
candidate,
hash_count,
} => {
total_hashes += hash_count;
return EngineStatus::Found {
candidate,
hash_count: total_hashes,
origin: FoundOrigin::GpuG1,
};
}
BatchResult::NotFound { hash_count } => {
total_hashes += hash_count;
last_batch_hashes = hash_count;
}
BatchResult::DeviceLost => return self.device_lost(dev, total_hashes),
}
current_start = current_start.saturating_add(U512::from(this_batch));
batch_num += 1;
if self.throttle_ms > 0 && current_start <= range.end {
let step = std::time::Duration::from_millis((self.throttle_ms / 10).max(1));
let mut remaining = std::time::Duration::from_millis(self.throttle_ms);
while remaining > std::time::Duration::ZERO {
if cancel.is_cancelled() {
return EngineStatus::Cancelled {
hash_count: total_hashes,
};
}
let s = remaining.min(step);
std::thread::sleep(s);
remaining = remaining.saturating_sub(s);
}
}
}
let elapsed = search_start.elapsed();
log::info!(
target: "cuda_engine",
"CUDA {} search exhausted: {} hashes in {} batches ({:.2}s, {})",
device_index,
total_hashes,
batch_num,
elapsed.as_secs_f64(),
format_hashrate(total_hashes as f64 / elapsed.as_secs_f64())
);
EngineStatus::Exhausted {
hash_count: total_hashes,
}
}
fn device_lost(&self, dev: &Device, hash_count: u64) -> EngineStatus {
dev.metrics.record_device_lost();
DEVICE_LOST.with(|l| *l.borrow_mut() = Some(self.engine_id));
log::error!(target: "cuda_engine", "CUDA device lost or unresponsive - stopping worker");
EngineStatus::DeviceLost { hash_count }
}
fn run_single_batch(
&self,
dev: &Device,
res: &mut WorkerResources,
ctx: &JobContext,
batch_start: U512,
batch_size: u32,
) -> BatchResult {
let batch_start_at = Instant::now();
// Grid: enough threads to fill the card several waves deep, then loop
// the remainder per thread.
let max_threads = (dev.sm_count * dev.threads_per_sm) as u64;
let logical_threads = (batch_size as u64).min(max_threads).max(1);
let tpb = dev.threads_per_block;
let num_blocks = ((logical_threads as u32).div_ceil(tpb)).max(1);
let total_threads = (num_blocks * tpb) as u64;
let nonces_per_thread = ((batch_size as u64).div_ceil(total_threads)).max(1) as u32;
let total_threads_u32 = total_threads as u32;
let start_nonce_bytes = batch_start.to_little_endian();
let mut start_u32s = [0u32; 16];
for i in 0..16 {
start_u32s[i] =
u32::from_le_bytes(start_nonce_bytes[i * 4..(i + 1) * 4].try_into().unwrap());
}
let nonce_be = batch_start.to_big_endian();
let midstate = pow_core::mining_midstate(ctx.header, nonce_be[..32].try_into().unwrap());
let layer0_base = first_layer_after_absorb(&midstate, &start_u32s[..8]);
let uni = MiningUniforms {
midstate,
start_nonce: start_u32s,
target: res.target_u32s,
layer0_base,
diag: qp_poseidon_constants::POSEIDON2_MATRIX_DIAG_12_RAW,
};
let stream = res.stream.clone();
let r: Result<(), DriverError> = (|| {
stream.memset_zeros(&mut res.results)?;
let cfg = LaunchConfig {
grid_dim: (num_blocks, 1, 1),
block_dim: (tpb, 1, 1),
shared_mem_bytes: 0,
};
let mut launch = stream.launch_builder(&dev.func);
launch
.arg(&mut res.results)
.arg(&uni)
.arg(&total_threads_u32)
.arg(&nonces_per_thread)
.arg(&batch_size);
// SAFETY: the kernel signature matches the argument list above and
// every pointer is a device buffer of at least the size the kernel reads.
unsafe { launch.launch(cfg) }?;
Ok(())
})();
if let Err(e) = r {
log::error!(target: "cuda_engine", "CUDA batch submit failed: {e:?}");
return BatchResult::DeviceLost;
}
let submitted_at = Instant::now();
if let Err(e) = stream
.memcpy_dtoh(&res.results, &mut res.host_results)
.and_then(|_| stream.synchronize())
{
log::error!(target: "cuda_engine", "CUDA batch readback failed: {e:?}");
return BatchResult::DeviceLost;
}
let gpu_time = submitted_at.elapsed();
dev.metrics
.observe_batch(gpu_time, batch_start_at.elapsed() - gpu_time);
let out = &res.host_results;
let dispatched = (total_threads * nonces_per_thread as u64).min(batch_size as u64);
if out[0] != 0 {
let nonce = U512::from_little_endian(&bytemuck_cast(&out[1..17]));
let hash = U512::from_little_endian(&bytemuck_cast(&out[17..33]));
let work = nonce.to_big_endian();
let hashes_computed = if nonce >= batch_start {
let logical_index = (nonce - batch_start).as_u64();
let winning_iteration = logical_index % (nonces_per_thread as u64);
(total_threads * (winning_iteration + 1)).min(dispatched)
} else {
dispatched
};
dev.metrics.record_hashes(hashes_computed);
dev.metrics.record_solution();
return BatchResult::Found {
candidate: Candidate { nonce, work, hash },
hash_count: hashes_computed,
};
}
dev.metrics.record_hashes(dispatched);
BatchResult::NotFound {
hash_count: dispatched,
}
}
}
fn bytemuck_cast(words: &[u32]) -> Vec<u8> {
words.iter().flat_map(|w| w.to_le_bytes()).collect()
}
// lair: host-side twin of the kernel's absorb + first external linear layer,
// in canonical Goldilocks arithmetic. Bit-exact with `ext_layer_rc` in
// kernels/mining.cu modulo p (the kernel keeps lazy representatives).
const GOLDILOCKS_P: u128 = 0xFFFF_FFFF_0000_0001;
fn gf_add(a: u64, b: u64) -> u64 {
((a as u128 + b as u128) % GOLDILOCKS_P) as u64
}
/// State after absorbing the low nonce limbs into the midstate and applying
/// the first external layer plus the first round constant, for the batch's
/// first nonce. `nonce_le` are the low 8 little-endian u32 limbs.
fn first_layer_after_absorb(midstate: &[u64; 12], nonce_le: &[u32]) -> [u64; 12] {
let mut st = *midstate;
for i in 0..8 {
st[i] = gf_add(st[i], nonce_le[7 - i].swap_bytes() as u64);
}
let mut out = [0u64; 12];
for chunk in 0..3 {
let o = chunk * 4;
let (x0, x1, x2, x3) = (st[o], st[o + 1], st[o + 2], st[o + 3]);
let t01 = gf_add(x0, x1);
let t23 = gf_add(x2, x3);
let t0123 = gf_add(t01, t23);
let t01123 = gf_add(t0123, x1);
let t01233 = gf_add(t0123, x3);
out[o + 3] = gf_add(t01233, gf_add(x0, x0));
out[o + 1] = gf_add(t01123, gf_add(x2, x2));
out[o] = gf_add(t01123, t01);
out[o + 2] = gf_add(t01233, t23);
}
let mut sums = [0u64; 4];
for k in 0..4 {
sums[k] = gf_add(gf_add(out[k], out[k + 4]), out[k + 8]);
}
let rc = &qp_poseidon_constants::POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW[0];
for i in 0..12 {
out[i] = gf_add(gf_add(out[i], sums[i & 3]), rc[i]);
}
out
}
#[cfg(test)]
mod layer0_tests {
use super::*;
/// The kernel's fast path assumes column 7 of the external matrix is this
/// vector: applying the layer to the unit vector e7 must reproduce it.
#[test]
fn column_seven_of_external_matrix() {
let zero = [0u64; 12];
// absorb puts bswap(limb 0) into element 7: choose limb 0 so that it becomes 1
let mut nonce = [0u32; 8];
nonce[0] = 1u32.swap_bytes();
let with = first_layer_after_absorb(&zero, &nonce);
let without = first_layer_after_absorb(&zero, &[0u32; 8]);
let rc = &qp_poseidon_constants::POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW[0];
let expect: [u64; 12] = [1, 1, 3, 2, 2, 2, 6, 4, 1, 1, 3, 2];
for i in 0..12 {
assert_eq!(without[i], rc[i]);
let diff = (with[i] as u128 + GOLDILOCKS_P - without[i] as u128) % GOLDILOCKS_P;
assert_eq!(diff as u64, expect[i], "column entry {i}");
}
}
}

View File

@@ -19,6 +19,7 @@ simd-poseidon2 = []
[dependencies]
engine-cpu = { path = "../engine-cpu" }
metrics = { path = "../metrics" } # lair: per-device metrics (quantus/miner#9)
pow-core = { path = "../pow-core" }
primitive-types = { workspace = true }
log = { workspace = true }

View File

@@ -46,9 +46,10 @@ impl Runner {
.await
.unwrap();
let kernel = engine_gpu::Kernel::for_adapter(&adapter);
let desc = wgpu::ShaderModuleDescriptor {
label: None,
source: wgpu::ShaderSource::Wgsl(include_str!("../src/mining_u64.wgsl").into()),
label: Some(kernel.label()),
source: wgpu::ShaderSource::Wgsl(kernel.source().into()),
};
let shader = if trusted {
unsafe {

View File

@@ -0,0 +1,573 @@
// Native-u64 Poseidon2 mining kernel. Default path for non-Apple GPUs.
// Requires wgpu Features::SHADER_INT64. Bit-exact with mining.wgsl / pow_core.
@group(0) @binding(0) var<storage, read_write> results: array<atomic<u32>>;
// Sponge state after absorbing header + high nonce half (12 felts as LE u32 pairs),
// precomputed on the host per batch. See pow_core::mining_midstate.
@group(0) @binding(1) var<storage, read> midstate: array<u32, 24>;
@group(0) @binding(2) var<storage, read> start_nonce: array<u32, 16>;
@group(0) @binding(3) var<storage, read> difficulty_target: array<u32, 16>;
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 3>;
const P64: u64 = 0xFFFFFFFF00000001lu;
// EPS64 = 2^32 - 1 = 2^64 mod P
const EPS64: u64 = 0xFFFFFFFFlu;
const RC_INTERNAL: array<u64, 22> = array<u64, 22>(
0x97f7798a784ad863lu, 0xd1d2bf082f60d4f0lu, 0x69a377a79f9ad206lu, 0xa9d06906a3858e24lu, 0x295275001eede5b5lu, 0x5874e441117bd746lu, 0x8a084bbba8ed86cclu, 0x3defd7645cde6425lu, 0x3998cfe6871cc137lu, 0x3e52ef8bca48314alu, 0x964a209f85dc9ecclu, 0x3fcc9ee82cc4577elu, 0x8e79b4a5d0096d6dlu, 0x8492362ad2392556lu, 0xee72f470262574d6lu, 0x1e0e18496da2444alu, 0x0f3a74bf215eaac6lu, 0x1b061b76a1c0ded3lu, 0x192c42d86803d7a6lu, 0xf6d49ff997ae0260lu, 0x3ec372e7a0fa3786lu, 0x5538cdf4f23445d3lu
);
const RC_INITIAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
array<u64, 12>(0xc002e770975b1607lu, 0xbca51a8dfe14593alu, 0x72938dfbe774f7f9lu, 0xe4f2fe29e03234aclu, 0xd5e0ba2f541b6449lu, 0xec33b868f3cc46c1lu, 0x486dcb55419d475alu, 0x6c1cb2a358cc24f1lu, 0xe3f30d509a1436bblu, 0xd9a64f068dca7c29lu, 0xe59b3f57aabba1aelu, 0x2a3dd4505b478fdclu),
array<u64, 12>(0xada1f8dc7676ed25lu, 0x2711aa8b5509d516lu, 0x4ae6acd0c9c92897lu, 0x56eb3d6b5256d67alu, 0x1f7a9d55923bf51elu, 0x3600427d397a7f68lu, 0xe5076df75b72c3d0lu, 0xfcd59aa12c6090adlu, 0xcd895e8c68b57a9elu, 0x41df7ef9d730ae3elu, 0xee3e2b889abe977dlu, 0xd29bb7edbeb9c405lu),
array<u64, 12>(0x7d5c08eef608e382lu, 0x89ae889caaf0802clu, 0xb35a8e976d2af617lu, 0xdb14234eafaf5173lu, 0x78f04462d48b1c98lu, 0x265293b0e47ce88alu, 0x999a649b69b9d32flu, 0x64b0a186698e01d3lu, 0xee0b22d0dfae8bb8lu, 0x4fd53e50ca04a7eelu, 0x5762bfe181f25047lu, 0xf51593e2beb5e3bdlu),
array<u64, 12>(0x1e5e2b5760e32477lu, 0x622462a1f9aaaeedlu, 0xaa284b3ecdb222aelu, 0x63c8e72f542bf3fclu, 0x3ba588cacb43b5e0lu, 0x23eda6f3c99150ddlu, 0xaad3bea4baac9a5alu, 0xe9da8d699b94184alu, 0xcdb13f4cd93e024clu, 0x902cbd0956f655e3lu, 0x5b4e40ffc759532flu, 0xde795c20a2357af7lu)
);
const RC_TERMINAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
array<u64, 12>(0x7b72c539e0ea4c6elu, 0x144573dae2ce9976lu, 0x802028b68f35fc88lu, 0x6d36c5022c4fe7c2lu, 0xa205d0ffa9b9def3lu, 0xf6e7e38b1ea6ba2flu, 0x34f7909ae5258d64lu, 0xb0464d9d77b97fcalu, 0x64ddb9d5de7e00a6lu, 0x0ed0d75c27975d97lu, 0x1cbb36f11127338blu, 0x6673e505cfd0b6balu),
array<u64, 12>(0x605f902830872e01lu, 0x3fd5eb927e95fe4flu, 0xe81025b5a24c69cdlu, 0xf7d0ce75de23f74elu, 0xf39942b6a8585089lu, 0x6d808a08f7b71df6lu, 0xf8806b6588f49a8blu, 0x57df2d8c2a32107alu, 0x16e7c2074d654a2dlu, 0x213de241fcf33835lu, 0xb0f2b8905a0976f6lu, 0xd8e3cf2bbd355417lu),
array<u64, 12>(0xe498691679d9330flu, 0x763b45d2a3821b28lu, 0x0908bf65eb0a1f0dlu, 0x7691eb2d194b24f4lu, 0x0e43551233ae13b2lu, 0x93c393dbfc2fe76flu, 0x98f607485d48cdealu, 0xe3d95f30309819c0lu, 0x1ef581a93eaf6acflu, 0x0b24c1b7a030fca4lu, 0x624370be5670b327lu, 0x5f1e28615a11e486lu),
array<u64, 12>(0xfe04051f909e042blu, 0x7257e5b147fd3803lu, 0xe6ae134bb82f2e78lu, 0x5711fd5cf4784511lu, 0xf83a42660c08c0bclu, 0x2cd8c96d9a3ce855lu, 0x7d2ffb1bb0e17271lu, 0x85ae1528caea3811lu, 0x52a345d5c7adb0b8lu, 0x504c4c51f3faee94lu, 0xbce34a649cfccaf9lu, 0xe0a3389266fb6dc9lu)
);
const MDS_DIAG: array<u64, 12> = array<u64, 12>(
0xc3b6c08e23ba9300lu, 0xd84b5de94a324fb6lu, 0x0d0c371c5b35b84flu, 0x7964f570e7188037lu, 0x5daf18bbd996604blu, 0x6743bc47b9595257lu, 0x5528b9362c59bb70lu, 0xac45e25b7127b68blu, 0xa2077d7dfbb606b5lu, 0xf3faac6faee378aelu, 0x0c6388b51545e883lu, 0xd27dbb6944917b60lu
);
// a + b mod P in lazy form. Wrapping carries fold back via 2^64 ≡ EPS64 (mod P).
fn gf64_add(a: u64, b: u64) -> u64 {
let s0 = a + b;
let c1 = s0 < a;
let s1 = s0 + select(0lu, EPS64, c1);
let c2 = c1 && (s1 < s0);
return s1 + select(0lu, EPS64, c2);
}
// Reduce a 128-bit value (lo + hi*2^64) mod P using
// 2^64 ≡ EPS64 and 2^96 ≡ -1 (mod P).
fn gf64_reduce(lo: u64, hi: u64) -> u64 {
let hi_hi = hi >> 32u;
let hi_lo = hi & EPS64;
var t0 = lo - hi_hi;
t0 = t0 - select(0lu, EPS64, lo < hi_hi);
let t1 = hi_lo * EPS64;
let t2 = t0 + t1;
return t2 + select(0lu, EPS64, t2 < t0);
}
fn gf64_mul(a: u64, b: u64) -> u64 {
let a_lo = a & EPS64;
let a_hi = a >> 32u;
let b_lo = b & EPS64;
let b_hi = b >> 32u;
let ll = a_lo * b_lo;
let lh = a_lo * b_hi;
let hl = a_hi * b_lo;
let hh = a_hi * b_hi;
let mid = lh + hl;
let mid_c = select(0lu, 1lu, mid < lh);
let lo = ll + (mid << 32u);
let lo_c = select(0lu, 1lu, lo < ll);
let hi = hh + (mid >> 32u) + (mid_c << 32u) + lo_c;
return gf64_reduce(lo, hi);
}
fn gf64_sqr(a: u64) -> u64 {
let a_lo = a & EPS64;
let a_hi = a >> 32u;
let ll = a_lo * a_lo;
let lh = a_lo * a_hi;
let hh = a_hi * a_hi;
let mid = lh << 1u;
let mid_c = lh >> 63u;
let lo = ll + (mid << 32u);
let lo_c = select(0lu, 1lu, lo < ll);
let hi = hh + (mid >> 32u) + (mid_c << 32u) + lo_c;
return gf64_reduce(lo, hi);
}
fn gf64_sbox(x: u64) -> u64 {
let x2 = gf64_sqr(x);
let x4 = gf64_sqr(x2);
let x6 = gf64_mul(x4, x2);
return gf64_mul(x6, x);
}
fn gf64_canon(a: u64) -> u64 {
return a - select(0lu, P64, a >= P64);
}
// External linear layer: 4x4 MDS on each chunk, then circulant sums.
fn ext_layer64(state: ptr<function, array<u64, 12>>) {
for (var chunk = 0u; chunk < 3u; chunk++) {
let o = chunk * 4u;
let x0 = (*state)[o];
let x1 = (*state)[o + 1u];
let x2 = (*state)[o + 2u];
let x3 = (*state)[o + 3u];
let t01 = gf64_add(x0, x1);
let t23 = gf64_add(x2, x3);
let t0123 = gf64_add(t01, t23);
let t01123 = gf64_add(t0123, x1);
let t01233 = gf64_add(t0123, x3);
(*state)[o + 3u] = gf64_add(t01233, gf64_add(x0, x0));
(*state)[o + 1u] = gf64_add(t01123, gf64_add(x2, x2));
(*state)[o] = gf64_add(t01123, t01);
(*state)[o + 2u] = gf64_add(t01233, t23);
}
var sums: array<u64, 4>;
for (var k = 0u; k < 4u; k++) {
sums[k] = gf64_add(gf64_add((*state)[k], (*state)[k + 4u]), (*state)[k + 8u]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add((*state)[i], sums[i % 4u]);
}
}
// Internal linear layer: diagonal matrix plus full sum.
fn int_layer64(state: ptr<function, array<u64, 12>>) {
var sum = (*state)[0];
for (var i = 1u; i < 12u; i++) {
sum = gf64_add(sum, (*state)[i]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add(gf64_mul((*state)[i], MDS_DIAG[i]), sum);
}
}
fn permute64(state: ptr<function, array<u64, 12>>) {
ext_layer64(state);
for (var r = 0u; r < 4u; r++) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add((*state)[i], RC_INITIAL[r][i]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_sbox((*state)[i]);
}
ext_layer64(state);
}
for (var r = 0u; r < 22u; r++) {
(*state)[0] = gf64_sbox(gf64_add((*state)[0], RC_INTERNAL[r]));
int_layer64(state);
}
for (var r = 0u; r < 4u; r++) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_add((*state)[i], RC_TERMINAL[r][i]);
}
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf64_sbox((*state)[i]);
}
ext_layer64(state);
}
}
fn bswap32(v: u32) -> u32 {
return ((v & 0xFFu) << 24u) | ((v & 0xFF00u) << 8u) | ((v >> 8u) & 0xFF00u) | (v >> 24u);
}
@compute @workgroup_size(256)
fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
if (atomicLoad(&results[0]) != 0u) {
return;
}
let thread_id = global_id.x;
let total_threads = dispatch_config[0];
let nonces_per_thread = dispatch_config[1];
let total_nonces = dispatch_config[2];
if (thread_id >= total_threads) {
return;
}
let base_index = thread_id * nonces_per_thread;
// Hoist uniform storage reads out of the nonce loop
var mid: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
mid[i] = (u64(midstate[2u * i + 1u]) << 32u) | u64(midstate[2u * i]);
}
var tgt: array<u32, 16>;
for (var i = 0u; i < 16u; i++) {
tgt[i] = difficulty_target[i];
}
var nonce_base: array<u32, 16>;
for (var i = 0u; i < 16u; i++) {
nonce_base[i] = start_nonce[i];
}
for (var j = 0u; j < nonces_per_thread; j = j + 1u) {
let logical_index = base_index + j;
if (logical_index >= total_nonces) {
break;
}
if (j > 0u && atomicLoad(&results[0]) != 0u) {
return;
}
// The host guarantees a batch never carries into the high nonce half
// (limbs 8..15), so only the low 256 bits are incremented here.
var current_nonce: array<u32, 16>;
let val0 = nonce_base[0];
let sum0 = val0 + logical_index;
current_nonce[0] = sum0;
var carry = select(0u, 1u, sum0 < val0);
for (var i = 1u; i < 8u; i++) {
let val = nonce_base[i];
let sum = val + carry;
current_nonce[i] = sum;
carry = select(0u, 1u, sum < val);
}
for (var i = 8u; i < 16u; i++) {
current_nonce[i] = nonce_base[i];
}
// Resume the sponge from the precomputed midstate: absorb the low
// nonce half, pad, squeeze twice (3 permutations instead of 5).
var st: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
st[i] = mid[i];
}
for (var i = 0u; i < 8u; i++) {
st[i] = gf64_add(st[i], u64(bswap32(current_nonce[7u - i])));
}
permute64(&st);
st[0] = gf64_add(st[0], 1lu);
st[1] = gf64_add(st[1], 1lu);
permute64(&st);
// First squeeze yields the most significant 256 bits of the hash, which
// decide hash-vs-target on their own unless they exactly equal the
// target's high half. Only candidates pay for the second squeeze, and
// byte-swapped hash words are produced on demand during the compare.
var first: array<u32, 8>;
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
first[2u * i] = u32(c & EPS64);
first[2u * i + 1u] = u32(c >> 32u);
}
var cmp = 0u;
for (var i = 0u; i < 8u; i++) {
let h = bswap32(first[i]);
let t = tgt[15u - i];
if (h != t) {
cmp = select(2u, 1u, h > t);
break;
}
}
if (cmp == 1u) {
continue;
}
var hash_le: array<u32, 16>;
for (var i = 0u; i < 8u; i++) {
hash_le[15u - i] = bswap32(first[i]);
}
permute64(&st);
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
hash_le[7u - 2u * i] = bswap32(u32(c & EPS64));
hash_le[6u - 2u * i] = bswap32(u32(c >> 32u));
}
var below = cmp == 2u;
if (!below) {
for (var i = 0u; i < 8u; i++) {
let h = hash_le[7u - i];
let t = tgt[7u - i];
if (h != t) {
below = h < t;
break;
}
}
}
if (below) {
if (atomicExchange(&results[0], 1u) == 0u) {
for (var i = 0u; i < 16u; i++) {
atomicStore(&results[1u + i], current_nonce[i]);
atomicStore(&results[17u + i], hash_le[i]);
}
}
return;
}
}
}
// ---------------------------------------------------------------------------
// Compatibility layer: same API as mining.wgsl, backed by the u64 core above.
// Only used by the component test harness; the mining kernel never calls it.
// All outputs are canonical, matching the reference implementation.
// ---------------------------------------------------------------------------
struct GoldilocksField {
limb0: u32,
limb1: u32,
}
const INTERNAL_CONSTANTS: array<array<u32, 2>, 22> = array<array<u32, 2>, 22>(
array<u32, 2>(2018170979u, 2549578122u),
array<u32, 2>(794875120u, 3520249608u),
array<u32, 2>(2677723654u, 1772320679u),
array<u32, 2>(2743438884u, 2849007878u),
array<u32, 2>(518907317u, 693269760u),
array<u32, 2>(293328710u, 1484055617u),
array<u32, 2>(2834138828u, 2315799483u),
array<u32, 2>(1558078501u, 1039128420u),
array<u32, 2>(2266808631u, 966316006u),
array<u32, 2>(3393728842u, 1045622667u),
array<u32, 2>(2245828300u, 2521440415u),
array<u32, 2>(751064958u, 1070374632u),
array<u32, 2>(3490278765u, 2390340773u),
array<u32, 2>(3526960470u, 2224174634u),
array<u32, 2>(639988950u, 4000511088u),
array<u32, 2>(1839350858u, 504240201u),
array<u32, 2>(559852230u, 255489215u),
array<u32, 2>(2713771731u, 453385078u),
array<u32, 2>(1745082278u, 422331096u),
array<u32, 2>(2544763488u, 4141129721u),
array<u32, 2>(2700752774u, 1052996327u),
array<u32, 2>(4063512019u, 1429786100u)
);
const INITIAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
array<array<u32, 2>, 12>(
array<u32, 2>(2539329031u, 3221415792u),
array<u32, 2>(4262746426u, 3164936845u),
array<u32, 2>(3883202553u, 1922272763u),
array<u32, 2>(3761386668u, 3841130025u),
array<u32, 2>(1411081289u, 3588274735u),
array<u32, 2>(4090250945u, 3962812520u),
array<u32, 2>(1100826458u, 1215155029u),
array<u32, 2>(1489773809u, 1813820067u),
array<u32, 2>(2585015995u, 3824356688u),
array<u32, 2>(2378857513u, 3651555078u),
array<u32, 2>(2864423342u, 3852156759u),
array<u32, 2>(1531416540u, 708695120u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(1987505445u, 2913073372u),
array<u32, 2>(1426707734u, 655469195u),
array<u32, 2>(3385403543u, 1256631504u),
array<u32, 2>(1381422714u, 1458257259u),
array<u32, 2>(2453402910u, 528129365u),
array<u32, 2>(964329320u, 905986685u),
array<u32, 2>(1534247888u, 3842469367u),
array<u32, 2>(744525997u, 4241857185u),
array<u32, 2>(1756723870u, 3448331916u),
array<u32, 2>(3610291774u, 1105166073u),
array<u32, 2>(2596181885u, 3997051784u),
array<u32, 2>(3199845381u, 3533420525u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(4127777666u, 2103183598u),
array<u32, 2>(2867888172u, 2309916828u),
array<u32, 2>(1831532055u, 3009056407u),
array<u32, 2>(2947502451u, 3675530062u),
array<u32, 2>(3565886616u, 2029012066u),
array<u32, 2>(3833391242u, 642945968u),
array<u32, 2>(1773785903u, 2577032347u),
array<u32, 2>(1770914259u, 1689297286u),
array<u32, 2>(3752758200u, 3993707216u),
array<u32, 2>(3389302766u, 1339375184u),
array<u32, 2>(2180141127u, 1466089441u),
array<u32, 2>(3199591357u, 4111832034u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(1625498743u, 509487959u),
array<u32, 2>(4188712685u, 1646551713u),
array<u32, 2>(3451003566u, 2854767422u),
array<u32, 2>(1412166652u, 1674110767u),
array<u32, 2>(3410212320u, 1000704202u),
array<u32, 2>(3381743837u, 602777331u),
array<u32, 2>(3131873882u, 2866003620u),
array<u32, 2>(2610174026u, 3923414377u),
array<u32, 2>(3644719692u, 3450945356u),
array<u32, 2>(1458984419u, 2418851081u),
array<u32, 2>(3344519983u, 1531855103u),
array<u32, 2>(2721413879u, 3732495392u)
)
);
const TERMINAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
array<array<u32, 2>, 12>(
array<u32, 2>(3773451374u, 2071119161u),
array<u32, 2>(3805190518u, 340095962u),
array<u32, 2>(2402679944u, 2149591222u),
array<u32, 2>(743434178u, 1832305922u),
array<u32, 2>(2847530739u, 2718290175u),
array<u32, 2>(514243119u, 4142392203u),
array<u32, 2>(3844443492u, 888639642u),
array<u32, 2>(2008645578u, 2957397405u),
array<u32, 2>(3732799654u, 1692252629u),
array<u32, 2>(664231319u, 248567644u),
array<u32, 2>(287781771u, 482031345u),
array<u32, 2>(3486561978u, 1718871301u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(814165505u, 1616875560u),
array<u32, 2>(2123759183u, 1070984082u),
array<u32, 2>(2722916813u, 3893372341u),
array<u32, 2>(3726899022u, 4157656693u),
array<u32, 2>(2824360073u, 4086907574u),
array<u32, 2>(4155973110u, 1837140488u),
array<u32, 2>(2297731723u, 4169165669u),
array<u32, 2>(707924090u, 1474243980u),
array<u32, 2>(1298483757u, 384287239u),
array<u32, 2>(4243798069u, 557703745u),
array<u32, 2>(1510569718u, 2968696976u),
array<u32, 2>(3174388759u, 3638808363u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(2044277519u, 3835193622u),
array<u32, 2>(2743212840u, 1983595986u),
array<u32, 2>(3943309069u, 151568229u),
array<u32, 2>(424355060u, 1989274413u),
array<u32, 2>(867046322u, 239293714u),
array<u32, 2>(4230997871u, 2479068123u),
array<u32, 2>(1565052394u, 2566260552u),
array<u32, 2>(815274432u, 3822673712u),
array<u32, 2>(1051683535u, 519405993u),
array<u32, 2>(2687564964u, 186958263u),
array<u32, 2>(1450226471u, 1648586942u),
array<u32, 2>(1511122054u, 1595811937u)
),
array<array<u32, 2>, 12>(
array<u32, 2>(2426274859u, 4261676319u),
array<u32, 2>(1207777283u, 1918363057u),
array<u32, 2>(3090099832u, 3870167883u),
array<u32, 2>(4101522705u, 1460796764u),
array<u32, 2>(201900220u, 4164567654u),
array<u32, 2>(2587682901u, 752404845u),
array<u32, 2>(2967564913u, 2100296475u),
array<u32, 2>(3404347409u, 2242778408u),
array<u32, 2>(3350048952u, 1386431957u),
array<u32, 2>(4093308564u, 1347177553u),
array<u32, 2>(2633812729u, 3169012324u),
array<u32, 2>(1727753673u, 3768793234u)
)
);
fn gf_pack(g: GoldilocksField) -> u64 {
return (u64(g.limb1) << 32u) | u64(g.limb0);
}
fn gf_unpack(v: u64) -> GoldilocksField {
let c = gf64_canon(v);
return GoldilocksField(u32(c & EPS64), u32(c >> 32u));
}
fn gf_from_limbs(l0: u32, l1: u32) -> GoldilocksField {
return GoldilocksField(l0, l1);
}
fn gf_zero() -> GoldilocksField {
return GoldilocksField(0u, 0u);
}
fn gf_one() -> GoldilocksField {
return GoldilocksField(1u, 0u);
}
fn gf_from_u32(val: u32) -> GoldilocksField {
return GoldilocksField(val, 0u);
}
fn gf_from_u64_parts(low: u32, high: u32) -> GoldilocksField {
return gf_unpack((u64(high) << 32u) | u64(low));
}
fn gf_from_const(val: array<u32, 2>) -> GoldilocksField {
return gf_from_u64_parts(val[0], val[1]);
}
fn gf_add(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_add(gf_pack(a), gf_pack(b)));
}
fn gf_mul(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_mul(gf_pack(a), gf_pack(b)));
}
fn sbox(x: GoldilocksField) -> GoldilocksField {
return gf_unpack(gf64_sbox(gf_pack(x)));
}
fn state_pack(state: ptr<function, array<GoldilocksField, 12>>, out: ptr<function, array<u64, 12>>) {
for (var i = 0u; i < 12u; i++) {
(*out)[i] = gf_pack((*state)[i]);
}
}
fn state_unpack(v: ptr<function, array<u64, 12>>, state: ptr<function, array<GoldilocksField, 12>>) {
for (var i = 0u; i < 12u; i++) {
(*state)[i] = gf_unpack((*v)[i]);
}
}
fn external_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
ext_layer64(&st);
state_unpack(&st, state);
}
fn internal_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
int_layer64(&st);
state_unpack(&st, state);
}
fn poseidon2_permute(state: ptr<function, array<GoldilocksField, 12>>) {
var st: array<u64, 12>;
state_pack(state, &st);
permute64(&st);
state_unpack(&st, state);
}
fn bytes_to_field_elements(input: array<u32, 24>) -> array<GoldilocksField, 25> {
var felts: array<GoldilocksField, 25>;
for (var i = 0u; i < 24u; i++) {
felts[i] = gf_from_u32(input[i]);
}
felts[24] = gf_one();
return felts;
}
fn field_elements_to_bytes(felts: array<GoldilocksField, 4>) -> array<u32, 8> {
var result: array<u32, 8>;
for (var i = 0u; i < 4u; i++) {
result[i * 2u] = felts[i].limb0;
result[i * 2u + 1u] = felts[i].limb1;
}
return result;
}
fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
var st: array<u64, 12>;
for (var i = 0u; i < 12u; i++) {
st[i] = 0lu;
}
for (var chunk = 0u; chunk < 3u; chunk++) {
for (var i = 0u; i < 8u; i++) {
st[i] = gf64_add(st[i], u64(input[chunk * 8u + i]));
}
permute64(&st);
}
st[0] = gf64_add(st[0], 1lu);
st[1] = gf64_add(st[1], 1lu);
permute64(&st);
var result: array<u32, 16>;
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
result[2u * i] = u32(c & EPS64);
result[2u * i + 1u] = u32(c >> 32u);
}
permute64(&st);
for (var i = 0u; i < 4u; i++) {
let c = gf64_canon(st[i]);
result[8u + 2u * i] = u32(c & EPS64);
result[8u + 2u * i + 1u] = u32(c >> 32u);
}
return result;
}
fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
return poseidon2_hash_squeeze_twice(input);
}

View File

@@ -1,7 +1,5 @@
// Quantus Mining Shader - native-u64 variant (requires wgpu Features::SHADER_INT64)
// Implements Poseidon2 hash over the Goldilocks field with plonky2-style lazy reduction:
// values live in [0, 2^64) and are only canonicalized when squeezed out.
// Must produce byte-identical results to mining.wgsl / qp-poseidon-core.
// Native-u64 Poseidon2 mining kernel. Apple Metal path only.
// Requires wgpu Features::SHADER_INT64. Bit-exact with mining.wgsl / pow_core.
@group(0) @binding(0) var<storage, read_write> results: array<atomic<u32>>;
// Sponge state after absorbing header + high nonce half (12 felts as LE u32 pairs),

View File

@@ -0,0 +1,116 @@
//! Poseidon2 mining kernels.
//!
//! Same `mining_main` bindings; must stay bit-exact with `pow_core`.
//!
//! - Apple Metal + `SHADER_INT64` → Apple Metal u64 (`mining_u64_apple.wgsl`)
//! - other GPUs + `SHADER_INT64` → native u64 (`mining_u64.wgsl`)
//! - no `SHADER_INT64` → 32-bit fallback (`mining.wgsl`)
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Kernel {
U32,
Default,
Apple,
}
impl Kernel {
pub const fn needs_int64(self) -> bool {
!matches!(self, Self::U32)
}
pub const fn id(self) -> &'static str {
match self {
Self::U32 => "u32",
Self::Default => "u64",
Self::Apple => "u64-apple",
}
}
pub const fn label(self) -> &'static str {
match self {
Self::U32 => "32-bit",
Self::Default => "native-u64",
Self::Apple => "native-u64 Apple Metal",
}
}
pub const fn source(self) -> &'static str {
match self {
Self::U32 => include_str!("mining.wgsl"),
Self::Default => include_str!("mining_u64.wgsl"),
Self::Apple => include_str!("mining_u64_apple.wgsl"),
}
}
pub fn for_adapter(adapter: &wgpu::Adapter) -> Self {
Self::for_adapter_info(&adapter.get_info(), adapter.features())
}
pub fn for_adapter_info(info: &wgpu::AdapterInfo, features: wgpu::Features) -> Self {
if !features.contains(wgpu::Features::SHADER_INT64) {
return Self::U32;
}
if info.backend == wgpu::Backend::Metal {
Self::Apple
} else {
Self::Default
}
}
pub const fn all() -> &'static [Self] {
&[Self::U32, Self::Default, Self::Apple]
}
}
#[cfg(test)]
mod tests {
use super::*;
fn info(backend: wgpu::Backend) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: "test".into(),
vendor: 0,
device: 0,
device_type: wgpu::DeviceType::DiscreteGpu,
driver: String::new(),
driver_info: String::new(),
backend,
}
}
#[test]
fn metal_with_int64_selects_apple() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Metal), wgpu::Features::SHADER_INT64),
Kernel::Apple
);
}
#[test]
fn vulkan_with_int64_selects_default() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Vulkan), wgpu::Features::SHADER_INT64),
Kernel::Default
);
}
#[test]
fn dx12_with_int64_selects_default() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Dx12), wgpu::Features::SHADER_INT64),
Kernel::Default
);
}
#[test]
fn no_int64_falls_back_to_u32() {
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Metal), wgpu::Features::empty()),
Kernel::U32
);
assert_eq!(
Kernel::for_adapter_info(&info(wgpu::Backend::Vulkan), wgpu::Features::empty()),
Kernel::U32
);
}
}

View File

@@ -2,10 +2,13 @@
#![deny(unsafe_code)]
mod gpu_tiers;
mod kernels;
pub mod end_to_end_tests;
pub mod tests;
pub use kernels::Kernel;
use engine_cpu::{CancelCheck, Candidate, EngineStatus, FoundOrigin, MinerEngine, Range};
use pow_core::{format_hashrate, format_u512, JobContext};
use primitive_types::U512;
@@ -17,6 +20,8 @@ use std::sync::{
/// Represents a single GPU device context.
struct GpuContext {
// lair: per-device metric handles, labelled with the kernel id (quantus/miner#9).
metrics: metrics::DeviceMetrics,
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::ComputePipeline,
@@ -153,10 +158,10 @@ impl GpuContext {
/// Create the mining shader module without naga's runtime bounds checks and
/// loop bounding (~9% faster kernels).
///
/// SAFETY: the sources are the static mining shaders compiled into this binary;
/// SAFETY: the sources are the static mining kernels compiled into this binary;
/// every buffer access is a constant-bounded loop index into fixed-size
/// bindings the engine itself allocates, and all loops have static bounds
/// (verified by the component test suites against both shader variants).
/// (verified by the component test suites against every kernel path).
#[allow(unsafe_code)]
fn create_trusted_shader(device: &wgpu::Device, shader_source: &str) -> wgpu::ShaderModule {
unsafe {
@@ -357,15 +362,13 @@ impl GpuEngine {
);
log::debug!(target: "gpu_engine", "Adapter {i} raw info: {info:?}");
// Prefer the native-u64 shader where supported (Apple/NVIDIA/modern AMD):
// Goldilocks arithmetic on u64 is far cheaper than 32-bit limb emulation.
let use_u64 = adapter.features().contains(wgpu::Features::SHADER_INT64);
let kernel = Kernel::for_adapter(&adapter);
// Try to initialize this adapter with a proper timeout.
// If the driver hangs, we'll skip this adapter after the timeout.
let device_future = adapter.request_device(&wgpu::DeviceDescriptor {
label: Some("Mining Device"),
required_features: if use_u64 {
required_features: if kernel.needs_int64() {
wgpu::Features::SHADER_INT64
} else {
wgpu::Features::empty()
@@ -412,18 +415,14 @@ impl GpuEngine {
// Shader and pipeline creation are synchronous - can't timeout, but usually fast
let pipeline_start = std::time::Instant::now();
let shader_source = if use_u64 {
include_str!("mining_u64.wgsl")
} else {
include_str!("mining.wgsl")
};
log::info!(
target: "gpu_engine",
"GPU device {i} ({}) using {} shader",
"GPU device {i} ({}) using {} [{}]",
info.name,
if use_u64 { "native-u64" } else { "32-bit" }
kernel.label(),
kernel.id()
);
let shader = create_trusted_shader(&device, shader_source);
let shader = create_trusted_shader(&device, kernel.source());
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Mining Pipeline"),
@@ -446,6 +445,7 @@ impl GpuEngine {
initialized.push(InitializedGpu {
context: Arc::new(GpuContext {
metrics: metrics::DeviceMetrics::new(i, kernel.id()),
device,
queue,
pipeline,
@@ -618,6 +618,9 @@ impl MinerEngine for GpuEngine {
let mut total_hashes: u64 = 0;
let mut current_start = range.start;
let mut batch_num = 0u64;
// lair: hashes of the most recent batch; if the job turns out to have
// been superseded while it ran, that batch was wasted work.
let mut last_batch_hashes: u64 = 0;
log::info!(
target: "gpu_engine",
@@ -632,6 +635,7 @@ impl MinerEngine for GpuEngine {
while current_start <= range.end {
// Check for cancellation at host level BEFORE starting each batch
if cancel.is_cancelled() {
gpu_ctx.metrics.record_stale_hashes(last_batch_hashes); // lair
let elapsed = search_start.elapsed();
let hash_rate = total_hashes as f64 / elapsed.as_secs_f64();
log::info!(
@@ -699,10 +703,12 @@ impl MinerEngine for GpuEngine {
}
BatchResult::NotFound { hash_count } => {
total_hashes += hash_count;
last_batch_hashes = hash_count; // lair
}
BatchResult::DeviceLost => {
// GPU device is lost/unresponsive - mark as permanently dead
// and clear resources to prevent "buffer already mapped" panics
gpu_ctx.metrics.record_device_lost(); // lair
// GPU device is lost/unresponsive - mark as permanently dead
// and clear resources to prevent "buffer already mapped" panics
DEVICE_LOST.with(|lost| *lost.borrow_mut() = Some(self.engine_id));
WORKER_RESOURCES.with(|res| *res.borrow_mut() = None);
@@ -796,7 +802,8 @@ fn run_single_batch(
batch_start: U512,
batch_size: u32,
) -> BatchResult {
// Calculate dispatch configuration for this batch
let batch_start_at = std::time::Instant::now(); // lair: batch phase timing
// Calculate dispatch configuration for this batch
let threads_per_workgroup = 256u32;
let limits = gpu_ctx.device.limits();
let max_workgroups = limits.max_compute_workgroups_per_dimension;
@@ -868,6 +875,7 @@ fn run_single_batch(
);
gpu_ctx.queue.submit(Some(encoder.finish()));
let submitted_at = std::time::Instant::now(); // lair
// Wait for GPU to complete (blocking)
let buffer_slice = resources.staging_buffer.slice(..);
@@ -914,6 +922,12 @@ fn run_single_batch(
// Only reach here if final_status == 1 (success), buffer is mapped
debug_assert_eq!(final_status, 1);
// lair: gpu = submit to mapped; host = the rest of the batch so far plus
// the readback below, which is small and constant.
let gpu_time = submitted_at.elapsed();
gpu_ctx
.metrics
.observe_batch(gpu_time, batch_start_at.elapsed() - gpu_time);
// Read results
let data = buffer_slice.get_mapped_range();
@@ -942,6 +956,8 @@ fn run_single_batch(
drop(data);
resources.staging_buffer.unmap();
gpu_ctx.metrics.record_hashes(hashes_computed); // lair
gpu_ctx.metrics.record_solution(); // lair
return BatchResult::Found {
candidate: Candidate { nonce, work, hash },
hash_count: hashes_computed,
@@ -951,6 +967,7 @@ fn run_single_batch(
drop(data);
resources.staging_buffer.unmap();
gpu_ctx.metrics.record_hashes(dispatched_nonces); // lair
BatchResult::NotFound {
hash_count: dispatched_nonces,
}

View File

@@ -13,37 +13,28 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.expect("no GPU adapter");
let mut failures = 0usize;
let has_int64 = adapter.features().contains(wgpu::Features::SHADER_INT64);
{
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor::default())
.await?;
failures += run_suite(
&device,
&queue,
include_str!("mining.wgsl"),
"32-bit (mining.wgsl)",
)
.await;
}
if adapter.features().contains(wgpu::Features::SHADER_INT64) {
for kernel in engine_gpu::Kernel::all() {
if kernel.needs_int64() && !has_int64 {
println!(
"\nSHADER_INT64 not supported on this adapter; skipping {}",
kernel.label()
);
continue;
}
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("u64 Test Device"),
required_features: wgpu::Features::SHADER_INT64,
label: Some(kernel.label()),
required_features: if kernel.needs_int64() {
wgpu::Features::SHADER_INT64
} else {
wgpu::Features::empty()
},
..Default::default()
})
.await?;
failures += run_suite(
&device,
&queue,
include_str!("mining_u64.wgsl"),
"native-u64 (mining_u64.wgsl)",
)
.await;
} else {
println!("\nSHADER_INT64 not supported on this adapter; skipping mining_u64.wgsl suite");
failures += run_suite(&device, &queue, kernel.source(), kernel.label()).await;
}
if failures > 0 {

344
crates/metrics/src/lair.rs Normal file
View File

@@ -0,0 +1,344 @@
//! lair: metrics that attribute performance to builds, devices and jobs
//! (quantus/miner#9). Additive to origin's metrics in `lib.rs`; nothing there
//! changes, so the fleet dashboard keeps working across origin merges.
//!
//! Naming: `miner_*` like origin's. Per-device series carry `device` (engine
//! index) and `kernel` (the kernel id the engine selected), so a silent
//! fallback from one kernel to another shows up as a label change rather than
//! as an unexplained hashrate drop.
use crate::REGISTRY;
use once_cell::sync::Lazy;
use prometheus::{
Counter, Histogram, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge,
IntGaugeVec, Opts,
};
use std::time::Duration;
fn reg<M: prometheus::core::Collector + Clone + 'static>(m: M) -> M {
REGISTRY
.register(Box::new(m.clone()))
.expect("register lair metric");
m
}
// ---------------------------------------------------------------------------
// Identity: which build and which configuration produced the numbers
// ---------------------------------------------------------------------------
static BUILD_INFO: Lazy<IntGaugeVec> = Lazy::new(|| {
reg(IntGaugeVec::new(
Opts::new(
"miner_build_info",
"Build identity of the running miner, always 1",
),
&["version", "commit"],
)
.expect("miner_build_info"))
});
static CONFIG_INFO: Lazy<IntGaugeVec> = Lazy::new(|| {
reg(IntGaugeVec::new(
Opts::new(
"miner_config_info",
"Effective mining configuration, always 1",
),
&[
"engine",
"gpu_batch_size",
"gpu_devices",
"cpu_workers",
"gpu_throttle_ms",
],
)
.expect("miner_config_info"))
});
/// Set once at startup. `commit` is the SHA embedded by build.rs.
pub fn set_build_info(version: &str, commit: &str) {
BUILD_INFO.with_label_values(&[version, commit]).set(1);
}
/// Set once the engines are resolved. Two deploys of one commit with different
/// flags must not look like the same experiment.
pub fn set_config_info(
engine: &str,
gpu_batch_size: u32,
gpu_devices: usize,
cpu_workers: usize,
gpu_throttle_ms: u64,
) {
CONFIG_INFO
.with_label_values(&[
engine,
&gpu_batch_size.to_string(),
&gpu_devices.to_string(),
&cpu_workers.to_string(),
&gpu_throttle_ms.to_string(),
])
.set(1);
}
// ---------------------------------------------------------------------------
// Per device: throughput, outcomes, and where the time goes inside a batch
// ---------------------------------------------------------------------------
static DEVICE_HASHES: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_device_hashes_total",
"Hashes computed, per GPU device and kernel",
),
&["device", "kernel"],
)
.expect("miner_device_hashes_total"))
});
static DEVICE_SOLUTIONS: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_device_solutions_total",
"Solutions found, per GPU device and kernel",
),
&["device", "kernel"],
)
.expect("miner_device_solutions_total"))
});
static DEVICE_LOST: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_device_lost_total",
"Times a GPU device was lost or unresponsive and its worker stopped",
),
&["device", "kernel"],
)
.expect("miner_device_lost_total"))
});
static DEVICE_STALE_HASHES: Lazy<IntCounterVec> = Lazy::new(|| {
reg(IntCounterVec::new(
Opts::new(
"miner_stale_hashes_total",
"Hashes computed after the job they were for had been superseded: the batch in \
flight when a new job arrived. The wasted-work cost of batch size.",
),
&["device", "kernel"],
)
.expect("miner_stale_hashes_total"))
});
static GPU_BATCH_SECONDS: Lazy<HistogramVec> = Lazy::new(|| {
reg(HistogramVec::new(
HistogramOpts::new(
"miner_gpu_batch_seconds",
"Per-batch time split into the GPU executing (phase=gpu) and the host \
preparing, submitting and reading back (phase=host)",
)
.buckets(vec![
0.0005, 0.001, 0.002, 0.004, 0.008, 0.016, 0.032, 0.064, 0.128, 0.256, 0.512, 1.0, 2.0,
]),
&["device", "kernel", "phase"],
)
.expect("miner_gpu_batch_seconds"))
});
/// Handles for one device, resolved once so the per-batch path does no label
/// lookups. Created by the engine when it initialises a device.
#[derive(Clone)]
pub struct DeviceMetrics {
hashes: IntCounter,
solutions: IntCounter,
lost: IntCounter,
stale: IntCounter,
batch_gpu: Histogram,
batch_host: Histogram,
}
impl DeviceMetrics {
pub fn new(device: usize, kernel: &str) -> Self {
let d = device.to_string();
Self {
hashes: DEVICE_HASHES.with_label_values(&[&d, kernel]),
solutions: DEVICE_SOLUTIONS.with_label_values(&[&d, kernel]),
lost: DEVICE_LOST.with_label_values(&[&d, kernel]),
stale: DEVICE_STALE_HASHES.with_label_values(&[&d, kernel]),
batch_gpu: GPU_BATCH_SECONDS.with_label_values(&[&d, kernel, "gpu"]),
batch_host: GPU_BATCH_SECONDS.with_label_values(&[&d, kernel, "host"]),
}
}
pub fn record_hashes(&self, n: u64) {
self.hashes.inc_by(n);
}
pub fn record_solution(&self) {
self.solutions.inc();
}
pub fn record_device_lost(&self) {
self.lost.inc();
}
/// The batch that completed after its job was superseded.
pub fn record_stale_hashes(&self, n: u64) {
self.stale.inc_by(n);
}
/// `gpu` is submit-to-completion on the device; `host` is everything else
/// in the batch (buffer writes, encoding, readback, bookkeeping).
pub fn observe_batch(&self, gpu: Duration, host: Duration) {
self.batch_gpu.observe(gpu.as_secs_f64());
self.batch_host.observe(host.as_secs_f64());
}
}
// ---------------------------------------------------------------------------
// Jobs and results: the efficiency side of every throughput trade-off
// ---------------------------------------------------------------------------
static JOBS_RECEIVED: Lazy<IntCounter> = Lazy::new(|| {
reg(
IntCounter::new("miner_jobs_received_total", "Jobs received from the node")
.expect("miner_jobs_received_total"),
)
});
static JOB_PICKUP_SECONDS: Lazy<HistogramVec> = Lazy::new(|| {
reg(HistogramVec::new(
HistogramOpts::new(
"miner_job_pickup_seconds",
"Job issued to a worker starting on it. For a busy worker this is the \
time to notice cancellation and finish the in-flight batch",
)
.buckets(vec![
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
]),
&["engine"],
)
.expect("miner_job_pickup_seconds"))
});
static RESULTS_SUBMITTED: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_results_submitted_total",
"Solutions sent to the node",
)
.expect("miner_results_submitted_total"))
});
static RESULTS_SEND_FAILED: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_results_send_failed_total",
"Solutions that could not be sent to the node",
)
.expect("miner_results_send_failed_total"))
});
static SEAL_LATENCY: Lazy<Histogram> = Lazy::new(|| {
reg(Histogram::with_opts(
HistogramOpts::new(
"miner_seal_latency_seconds",
"Solution found on a worker to the result sent to the node",
)
.buckets(vec![
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
]),
)
.expect("miner_seal_latency_seconds"))
});
static JOB_IDLE_SECONDS: Lazy<Counter> = Lazy::new(|| {
reg(Counter::new(
"miner_job_idle_seconds_total",
"Seconds spent between sending a result and receiving the next job (node-attributable idle)",
)
.expect("miner_job_idle_seconds_total"))
});
pub fn record_job_received() {
JOBS_RECEIVED.inc();
}
pub fn observe_job_pickup(engine: &str, d: Duration) {
JOB_PICKUP_SECONDS
.with_label_values(&[engine])
.observe(d.as_secs_f64());
}
pub fn record_result_submitted(found_to_sent: Option<Duration>) {
RESULTS_SUBMITTED.inc();
if let Some(d) = found_to_sent {
SEAL_LATENCY.observe(d.as_secs_f64());
}
}
pub fn record_result_send_failed() {
RESULTS_SEND_FAILED.inc();
}
pub fn record_job_idle(d: Duration) {
JOB_IDLE_SECONDS.inc_by(d.as_secs_f64());
}
// ---------------------------------------------------------------------------
// Connection to the node: is a hashrate drop the miner or the node?
// ---------------------------------------------------------------------------
static CONNECTS: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_connects_total",
"Connections established to the node",
)
.expect("miner_connects_total"))
});
static CONNECT_FAILURES: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_connect_failures_total",
"Connection attempts to the node that failed",
)
.expect("miner_connect_failures_total"))
});
static DISCONNECTS: Lazy<IntCounter> = Lazy::new(|| {
reg(IntCounter::new(
"miner_disconnects_total",
"Connections to the node that were lost",
)
.expect("miner_disconnects_total"))
});
static CONNECTED: Lazy<IntGauge> = Lazy::new(|| {
reg(
IntGauge::new("miner_connected", "1 while connected to the node, else 0")
.expect("miner_connected"),
)
});
static DISCONNECTED_SECONDS: Lazy<Counter> = Lazy::new(|| {
reg(Counter::new(
"miner_disconnected_seconds_total",
"Seconds spent without a node connection after the first successful connection",
)
.expect("miner_disconnected_seconds_total"))
});
pub fn record_connected() {
CONNECTS.inc();
CONNECTED.set(1);
}
pub fn record_connect_failed() {
CONNECT_FAILURES.inc();
CONNECTED.set(0);
}
pub fn record_disconnected() {
DISCONNECTS.inc();
CONNECTED.set(0);
}
pub fn record_disconnected_time(d: Duration) {
DISCONNECTED_SECONDS.inc_by(d.as_secs_f64());
}

View File

@@ -39,6 +39,10 @@ use anyhow::Result;
static REGISTRY: Lazy<Registry> = Lazy::new(Registry::new);
// lair: build, device and job metrics (quantus/miner#9); see lair.rs.
mod lair;
pub use lair::*;
// ---------------------------------------------------------------------------
// Hash Rate Metrics
// ---------------------------------------------------------------------------

51
crates/miner-cli/build.rs Normal file
View File

@@ -0,0 +1,51 @@
// lair: embed the git commit in the binary so `--version` and the build-info
// metric identify a deployed build by commit, not by the workspace semver
// (which does not change between commits on a branch that deploys on push).
//
// Resolution order:
// 1. MINER_BUILD_SHA in the environment (CI sets it from the checked-out ref)
// 2. `git rev-parse HEAD` of the workspace, with "-dirty" if the tree differs
// 3. "unknown"
use std::process::Command;
fn git(args: &[&str]) -> Option<String> {
let out = Command::new("git").args(args).output().ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8(out.stdout).ok()?;
let s = s.trim();
if s.is_empty() {
None
} else {
Some(s.to_string())
}
}
fn main() {
println!("cargo:rerun-if-env-changed=MINER_BUILD_SHA");
let sha = match std::env::var("MINER_BUILD_SHA") {
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
_ => match git(&["rev-parse", "--short=12", "HEAD"]) {
Some(head) => {
// Re-run when HEAD moves so a rebuild after a commit picks it up.
if let Some(dir) = git(&["rev-parse", "--git-dir"]) {
println!("cargo:rerun-if-changed={dir}/HEAD");
println!("cargo:rerun-if-changed={dir}/refs/heads");
}
let dirty = git(&["status", "--porcelain", "--untracked-files=no"])
.map(|s| !s.is_empty())
.unwrap_or(false);
if dirty {
format!("{head}-dirty")
} else {
head
}
}
None => "unknown".to_string(),
},
};
println!("cargo:rustc-env=MINER_BUILD_SHA={sha}");
}

View File

@@ -87,6 +87,11 @@ enum Command {
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
allow_integrated: bool,
/// lair: GPU engine: auto (CUDA when this binary carries a kernel and a
/// driver is present, else wgpu), cuda, or wgpu (quantus/miner#3).
#[arg(long = "gpu-engine", env = "MINER_GPU_ENGINE", default_value = "auto")]
gpu_engine: String,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
@@ -118,15 +123,29 @@ enum Command {
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
allow_integrated: bool,
/// lair: GPU engine: auto (CUDA when this binary carries a kernel and a
/// driver is present, else wgpu), cuda, or wgpu (quantus/miner#3).
#[arg(long = "gpu-engine", env = "MINER_GPU_ENGINE", default_value = "auto")]
gpu_engine: String,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
},
}
// lair: semver plus the commit the binary was built from (see build.rs), so a
// deploy can assert the running binary is the commit it shipped.
const VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
" (",
env!("MINER_BUILD_SHA"),
")"
);
/// Quantus External Miner CLI
#[derive(Parser, Debug)]
#[command(author, version, about, long_about = None)]
#[command(author, version = VERSION, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
@@ -135,6 +154,8 @@ struct Args {
#[tokio::main]
async fn main() {
let args = Args::parse();
// lair: identify this build in the metrics (quantus/miner#9).
metrics::set_build_info(env!("CARGO_PKG_VERSION"), env!("MINER_BUILD_SHA"));
let Some(command) = args.command else {
eprintln!("Error: No command provided. Use 'serve' to start mining (defaults to local node at 127.0.0.1:9833).");
@@ -160,6 +181,7 @@ async fn main() {
gpu_throttle_ms,
metrics_port,
allow_integrated,
gpu_engine,
verbose,
} => {
init_logger(verbose);
@@ -207,6 +229,7 @@ async fn main() {
cpu_batch_size,
gpu_throttle_ms,
allow_integrated,
gpu_engine,
};
if let Err(e) = run(config).await {
@@ -222,6 +245,7 @@ async fn main() {
cpu_batch_size,
duration,
allow_integrated,
gpu_engine,
verbose,
} => {
init_logger(verbose);
@@ -232,6 +256,7 @@ async fn main() {
cpu_batch_size,
duration,
allow_integrated,
gpu_engine,
)
.await;
}
@@ -293,9 +318,9 @@ fn init_logger(verbose: bool) {
if std::env::var("RUST_LOG").is_err() {
// Filter out noisy wgpu/naga shader compilation logs
let log_level = if verbose {
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
"debug,miner=debug,gpu_engine=debug,cuda_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
} else {
"info,miner=info,gpu_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
"info,miner=info,gpu_engine=info,cuda_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
};
std::env::set_var("RUST_LOG", log_level);
}
@@ -309,6 +334,7 @@ async fn run_benchmark(
cpu_batch_size: u64,
duration: u64,
allow_integrated: bool,
gpu_engine_pref: String,
) {
let effective_cpu_workers = cpu_workers.unwrap_or_else(num_cpus::get);
@@ -318,6 +344,7 @@ async fn run_benchmark(
gpu_batch_size,
0,
allow_integrated,
&gpu_engine_pref,
) {
Ok((engine, count)) => (engine, count),
Err(e) => {

View File

@@ -31,4 +31,5 @@ quantus-miner-api = { workspace = true }
pow-core = { path = "../pow-core" }
engine-cpu = { path = "../engine-cpu", optional = true }
engine-gpu = { path = "../engine-gpu" }
engine-cuda = { path = "../engine-cuda" } # lair: quantus/miner#3
metrics = { path = "../metrics" }

View File

@@ -39,6 +39,9 @@ pub struct ServiceConfig {
pub gpu_throttle_ms: u64,
/// Allow integrated GPUs even when discrete GPUs are available
pub allow_integrated: bool,
/// lair: GPU engine preference: "auto" (CUDA when available, else wgpu),
/// "cuda" or "wgpu" (quantus/miner#3).
pub gpu_engine: String,
}
/// Engine type for tracking metrics per compute type.
@@ -62,6 +65,8 @@ pub struct WorkerResult {
pub hash_count: u64,
/// Whether this worker has finished its range.
pub completed: bool,
/// lair: when the candidate was found (seal latency, quantus/miner#9).
pub found_at: Option<std::time::Instant>,
}
/// A successful mining candidate.
@@ -90,6 +95,8 @@ pub struct MiningJob {
pub ctx: pow_core::JobContext,
/// Job ID to detect stale results after job transitions
pub job_id: u64,
/// lair: when the job was issued (pickup latency, quantus/miner#9).
created_at: std::time::Instant,
}
/// Persistent worker thread pool that keeps threads alive between jobs.
@@ -208,6 +215,7 @@ impl WorkerPool {
let job = MiningJob {
ctx,
job_id: new_job_id,
created_at: std::time::Instant::now(), // lair
};
// Dispatch job to all workers using bounded channels (capacity 16).
@@ -340,6 +348,9 @@ fn worker_loop(
log::debug!("[WORKER {type_str}-{thread_id}] Drained {skipped} stale jobs from queue");
}
// lair: issued-to-picked-up; for a busy worker this is the cancel latency.
metrics::observe_job_pickup(type_str, job.created_at.elapsed());
// Capture the job's ID for later validation
let job_id = job.job_id;
log::debug!("[WORKER {type_str}-{thread_id}] Received job {job_id}");
@@ -398,6 +409,7 @@ fn worker_loop(
candidate: None, // Discard the stale candidate
hash_count,
completed: true,
found_at: None,
});
continue;
}
@@ -442,6 +454,7 @@ fn worker_loop(
candidate: None,
hash_count,
completed: true,
found_at: None,
});
break; // Exit the worker loop
}
@@ -452,6 +465,7 @@ fn worker_loop(
};
// Send result (non-blocking to avoid deadlock if receiver is full)
let found_at = candidate.as_ref().map(|_| std::time::Instant::now()); // lair
let _ = result_tx.try_send(WorkerResult {
thread_id,
engine_type,
@@ -459,12 +473,14 @@ fn worker_loop(
candidate,
hash_count,
completed: true,
found_at,
});
}
// Clean up GPU resources on thread exit
if engine_type == EngineType::Gpu {
engine_gpu::GpuEngine::clear_worker_resources();
engine_cuda::CudaEngine::clear_worker_resources(); // lair
}
log::debug!("{type_str} worker {thread_id} exited");
@@ -476,12 +492,37 @@ pub fn resolve_gpu_configuration(
batch_size: u32,
throttle_ms: u64,
allow_integrated: bool,
gpu_engine: &str,
) -> anyhow::Result<(Option<Arc<dyn MinerEngine>>, usize)> {
// Explicit 0 means no GPU
if requested_devices == Some(0) {
return Ok((None, 0));
}
// lair: native CUDA engine first unless wgpu was asked for (quantus/miner#3).
if gpu_engine != "wgpu" {
match engine_cuda::CudaEngine::try_new(batch_size, throttle_ms) {
Ok(engine) => {
let available = engine.device_count();
let count = match requested_devices {
Some(n) if n > available => anyhow::bail!(
"Requested {n} GPU devices but only {available} available (CUDA)"
),
Some(n) => n,
None => {
log::info!("Auto-detected {available} CUDA device(s)");
available
}
};
return Ok((Some(Arc::new(engine)), count));
}
Err(e) if gpu_engine == "cuda" => {
anyhow::bail!("CUDA engine requested but unavailable: {e}")
}
Err(e) => log::info!("CUDA engine unavailable ({e}); using wgpu"),
}
}
// Try to initialize GPU engine
let engine = engine_gpu::GpuEngine::try_new(batch_size, throttle_ms, allow_integrated);
let engine = match engine {
@@ -529,6 +570,7 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
config.gpu_batch_size,
config.gpu_throttle_ms,
config.allow_integrated,
&config.gpu_engine,
)?;
// Resolve CPU workers
@@ -562,6 +604,14 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
cpu_workers,
gpu_devices
);
// lair: expose the effective configuration as labels (quantus/miner#9).
metrics::set_config_info(
gpu_engine.as_ref().map(|e| e.name()).unwrap_or("cpu"),
config.gpu_batch_size,
gpu_devices,
cpu_workers,
config.gpu_throttle_ms,
);
if let Some(ref engine) = cpu_engine {
let name = engine.name();

View File

@@ -38,6 +38,8 @@ pub async fn connect_and_mine(
let mut reconnect_delay = Duration::from_secs(1);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
// lair: time without a connection, counted after the first success.
let mut disconnected_since: Option<Instant> = None;
loop {
log::info!("⛏️ Connecting to node at {}...", node_addr);
@@ -45,6 +47,10 @@ pub async fn connect_and_mine(
match establish_connection(node_addr, auth_token, tls_cert_sha256).await {
Ok((connection, send, recv)) => {
log::info!("⛏️ Connected to node at {}", node_addr);
metrics::record_connected(); // lair
if let Some(t) = disconnected_since.take() {
metrics::record_disconnected_time(t.elapsed());
}
let mut authenticated = false;
if let Err(e) =
@@ -61,6 +67,8 @@ pub async fn connect_and_mine(
}
log::info!("⛏️ Connection lost: {}", e);
}
metrics::record_disconnected(); // lair
disconnected_since = Some(Instant::now());
// Only clear backoff after the node accepted auth (first NewJob).
// connect() already treats explicit "auth failed" as permanent;
// this covers any other post-Ready close that looked like success.
@@ -76,6 +84,7 @@ pub async fn connect_and_mine(
return Err(e);
}
log::warn!("⛏️ Failed to connect to node: {}", e);
metrics::record_connect_failed(); // lair
}
}
@@ -145,6 +154,8 @@ async fn handle_connection(
let mut cpu_hashes: u64 = 0;
let mut gpu_hashes: u64 = 0;
let mut result_sent_for_current_job = false;
// lair: when the last result went out, for node-attributable idle time.
let mut result_sent_at: Option<Instant> = None;
log::info!("⛏️ Waiting for mining jobs from node...");
@@ -214,7 +225,15 @@ async fn handle_connection(
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
if let Err(e) = send_message_checked(&connection, &mut send, &msg).await {
metrics::record_result_send_failed(); // lair
return Err(e);
}
// lair: seal latency and the start of the idle window.
metrics::record_result_submitted(
worker_result.found_at.map(|t| t.elapsed()),
);
result_sent_at = Some(Instant::now());
}
}
}
@@ -239,6 +258,10 @@ async fn handle_connection(
match msg_result {
Ok(MinerMessage::NewJob(request)) => {
*authenticated = true;
metrics::record_job_received(); // lair
if let Some(t) = result_sent_at.take() {
metrics::record_job_idle(t.elapsed());
}
log::info!(
"⛏️ Received job: id={}, hash=0x{}",
request.job_id,

42
deploy/README.md Normal file
View File

@@ -0,0 +1,42 @@
# deploy
Everything the CI deploy of the miner ships or needs (`.gitea/workflows/deploy.yaml`).
Moved from `lair/quantus` on 2026-09-03 so that one repo owns the deployed miner;
the fleet's monitoring, GPU power limits and nvidia metrics stay there.
| File | Lands at |
| --- | --- |
| `quantus-miner.service` | `/etc/systemd/system/quantus-miner.service` |
| `quantus-miner.sysusers.conf` | `/etc/sysusers.d/quantus-miner.conf` |
| `quantus-miner-metrics.xml` | `/etc/firewalld/services/quantus-miner-metrics.xml` |
| `miner.env.tmpl` | rendered to `/etc/quantus-miner/miner.env` |
| `infra-setup.sh` | operator-run once per host; installs the `gitea_ci` sudoers |
The miner's credentials are generated by the node and copied host-to-host by the
deploy on every run. Nothing secret lives here.
## Traps, all previously hit
- **Adding a file or privileged command means re-running `infra-setup.sh`.** The
deploy preflights the host's `sudo -n -l` against the script's SUDO block and
names the missing paths, instead of failing partway with `sudo: a password is required`.
- **Deploys must be no-ops when nothing changed.** `push()` uses `rsync -ic`
(checksum). The artifact is rebuilt every run so mtimes always differ. Restart
only on an itemised content change.
- **`--node-addr` takes an IP, not a name.** It parses as a Rust `SocketAddr`
with no DNS. The deploy resolves the node on the miner host and renders the
literal; the `10.x` never enters the repo.
- **Pipes in validate steps.** The shell is `bash -e -o pipefail`; a reader that
exits early (`awk ... exit`, `grep -q`, `head`) SIGPIPEs the writer, exit 141.
Capture into a variable and parse with a here-string.
- **ssh argument quoting.** `run()` is `ssh ... "$@"` and the remote shell
re-splits; any argument with a space is passed as one pre-quoted string.
- **`PrivateDevices=false`** in the unit is deliberate: the miner needs `/dev/nvidia*`.
- **`systemctl is-active` is not evidence of mining.** A miner that found no
adapter, or never got a job, is `active`. Validate asserts `miner_hashes_total`
advances and `miner_gpu_devices` matches the matrix.
- **Binaries must be built on `cuda-13.0`** (Fedora 43, like the hosts). The
`rust` runner is Fedora 44; its binaries fail with `GLIBC_2.43 not found`.
- **A benchmark (`bench.yaml`) stops the miner on its host.** A deploy landing
on the same host during a measurement would find the unit down and start it
mid-window. Both are serialised only by not running them together.

135
deploy/infra-setup.sh Executable file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# One-time provisioning of a mining host for the CI deploy in
# .gitea/workflows/deploy.yaml (quantus/miner#8). The miner role of
# lair/quantus's script/infra-setup.sh, moved here so the deployer and its
# sudoers grant list live in the same repo and cannot drift: the deploy
# preflights the target's `sudo -n -l` against the SUDO block below.
#
# Convention: ~/git/architecture/deployment-gitea-actions.md §1§2. Run from a
# workstation with admin (sudo) ssh access to the targets — NOT the gitea_ci
# account. Idempotent; re-running is a no-op. Skips unreachable hosts.
#
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub --miner-hosts benjy.hanzalova.internal
#
# The runner keypair is NOT generated here. It already exists at
# ~/.ssh/id_gitea_ci and is shared by every project's RSYNC_SSH_KEY secret and
# every host's gitea_ci authorized_keys — regenerating it would silently break
# every other deploy on the fleet.
#
# Re-run this whenever the deploy gains a new file to ship or a new privileged
# command; the preflight fails up front naming the missing paths rather than
# dying partway through an rsync with "sudo: a password is required".
set -euo pipefail
ADMIN_USER="${ADMIN_USER:-$USER}"
# Must agree with the deploy matrix in .gitea/workflows/deploy.yaml.
MINER_HOSTS="${MINER_HOSTS-benjy.hanzalova.internal quadbrat.hanzalova.internal beast.hanzalova.internal}"
PUBKEY=""
while [ $# -gt 0 ]; do
case "$1" in
--pubkey) PUBKEY="$2"; shift 2 ;;
--miner-hosts) MINER_HOSTS="$2"; shift 2 ;;
--admin) ADMIN_USER="$2"; shift 2 ;;
*) echo "unknown arg: $1" >&2; exit 2 ;;
esac
done
[ -n "$PUBKEY" ] && [ -r "$PUBKEY" ] || { echo "--pubkey <file> is required and must be readable" >&2; exit 2; }
provision_miner() {
local host="$1"
echo "== ${host} (miner) =="
if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then
echo " ! unreachable as ${ADMIN_USER} — skipping" >&2
return 1
fi
# All privileged work in one remote `sudo bash`. The runner pubkey is the only
# dynamic value, passed as $1 (single line, no quoting hazard). The sudoers
# `\=` are the required escapes — visudo rejects a bare `=` in a command arg.
# The `*` in an rsync line matches rsync's --server arg vector; the trailing
# literal destination is what actually bounds the rule.
ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE'
PUBKEY="$1"
# /bin/bash, NOT nologin. The deploy runs `ssh gitea_ci@host <command>`, and
# a nologin shell refuses that with "This account is currently not
# available": the key authenticates, then the command cannot run.
if ! getent passwd gitea_ci >/dev/null; then
useradd --system --create-home --home-dir /var/lib/gitea_ci \
--shell /bin/bash gitea_ci
echo " + created gitea_ci"
else
echo " = gitea_ci already present"
fi
cur=$(getent passwd gitea_ci | cut -d: -f7)
if [ "$cur" != /bin/bash ]; then
usermod -s /bin/bash gitea_ci
echo " ~ gitea_ci shell was ${cur} — set to /bin/bash so ssh commands can run"
fi
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
ak=/var/lib/gitea_ci/.ssh/authorized_keys
touch "$ak"
grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak"
chown gitea_ci:gitea_ci "$ak"
chmod 0600 "$ak"
usermod -aG systemd-journal gitea_ci
# The GPU must be present and enumerable before a miner deploy is worth
# attempting; failing here beats a green deploy that mines nothing.
if ! command -v nvidia-smi >/dev/null; then
echo " ! nvidia-smi not found — this host has no usable NVIDIA driver" >&2
exit 1
fi
nvidia-smi --query-gpu=name --format=csv,noheader | sed 's/^/ = gpu: /'
tmp=/etc/sudoers.d/.quantus-miner_gitea_ci.tmp
cat > "$tmp" <<'SUDO'
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-miner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-miner.conf
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner.env
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-auth-token
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-tls-cert-sha256
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-miner-metrics.xml
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-rich-rule\=*
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-rich-rule\=*
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-rich-rule\=*
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-miner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-miner.service
# stop/start (not just restart) so the benchmark harness (bench.yaml) can pause
# mining for a measurement window and resume it afterwards.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop quantus-miner.service
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start quantus-miner.service
# Rollback: the deploy keeps the previous binary as .prev (cp) and restores it
# with install when validate fails; cp back would hit "Text file busy" on the
# running binary, install unlinks the destination first. Deploying from main means a bad commit reaches production;
# this is what makes that survivable.
gitea_ci ALL=(root) NOPASSWD: /usr/bin/cp -p /usr/local/bin/quantus-miner /usr/local/bin/quantus-miner.prev
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -m 0755 /usr/local/bin/quantus-miner.prev /usr/local/bin/quantus-miner
SUDO
chmod 0440 "$tmp"
visudo -cf "$tmp"
mv "$tmp" /etc/sudoers.d/quantus-miner_gitea_ci
echo " = sudoers quantus-miner_gitea_ci installed and visudo-verified"
REMOTE
}
rc=0
for h in $MINER_HOSTS; do
provision_miner "$h" || rc=1
done
echo
echo "done. remaining operator steps:"
echo " 1. confirm RSYNC_SSH_KEY is set in the repo's Actions secrets"
echo " 2. push to main, or run the deploy workflow manually (mode: validate first)"
exit $rc

8
deploy/miner.env.tmpl Normal file
View File

@@ -0,0 +1,8 @@
# Rendered by .gitea/workflows/deploy.yaml (quantus/miner) and rsynced to
# /etc/quantus-miner/miner.env (0640 root:quantus-miner). Not secret — the
# miner's actual credentials are the auth token and TLS pin, which are copied
# from the node host as separate 0640 files.
QUANTUS_NODE_ADDR={{QUANTUS_NODE_ADDR}}
QUANTUS_GPU_DEVICES={{QUANTUS_GPU_DEVICES}}
# Kernel entry in the CUDA engine: unrolled (mining_main) or loop (mining_loop), quantus/miner#29.
MINER_CUDA_KERNEL={{MINER_CUDA_KERNEL}}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<service>
<short>quantus-miner-metrics</short>
<description>Quantus miner Prometheus exporter. The miner binds this on
0.0.0.0 unconditionally (crates/metrics: SocketAddr from [0,0,0,0]) — there is
no loopback option — so the firewalld rich rule scoped to the scrape host is
the only thing bounding who can reach it.</description>
<port protocol="tcp" port="9900"/>
</service>

View File

@@ -0,0 +1,64 @@
# Quantus external miner. Runs on the GPU host and connects OUT to the node's
# QUIC control channel; it listens on nothing but its loopback metrics port, so
# it ships no firewalld service of its own.
#
# Hardened per ~/git/architecture/generic.md §8, with one deliberate relaxation:
#
# PrivateDevices=false
# The miner needs /dev/nvidia*; PrivateDevices=true hides them and wgpu
# finds no adapter, silently falling back to nothing.
#
# Poseidon2-over-Goldilocks is compute-bound in registers, not memory-bound, so
# there is no tuning knob here worth more than the GPU power limit — which is a
# host-level concern (nvidia-smi -pl), not a unit-file one.
#
# --cpu-workers 0 is deliberate: a current-gen desktop CPU contributes ~4 MH/s
# next to a 4090's ~183 MH/s, for a couple hundred watts. Measured, not assumed.
[Unit]
Description=Quantus external miner (GPU)
Documentation=https://github.com/Quantus-Network/quantus-miner
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=quantus-miner
Group=quantus-miner
Environment=RUST_LOG=info
WorkingDirectory=/var/lib/quantus-miner
ExecStart=/usr/local/bin/quantus-miner serve \
--node-addr ${QUANTUS_NODE_ADDR} \
--auth-token-file /etc/quantus-miner/miner-auth-token \
--tls-cert-sha256-file /etc/quantus-miner/miner-tls-cert-sha256 \
--gpu-devices ${QUANTUS_GPU_DEVICES} \
--cpu-workers 0 \
--metrics-port 9900
EnvironmentFile=/etc/quantus-miner/miner.env
# The node may be down, still syncing, or mid-redeploy; reconnecting is normal
# operation, not an error condition.
Restart=always
RestartSec=15s
LimitNOFILE=65536
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=false
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
SystemCallArchitectures=native
MemoryDenyWriteExecute=false
ReadWritePaths=/var/lib/quantus-miner
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,4 @@
#Type Name ID GECOS Home directory Shell
u quantus-miner - "Quantus miner service account" /var/lib/quantus-miner /usr/sbin/nologin
m quantus-miner video
m quantus-miner render

View File

@@ -1,4 +1,4 @@
[toolchain]
channel = "stable"
channel = "1.93.0"
components = ["clippy", "rustfmt"]
profile = "minimal"