Compare commits
205
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1cac91419
|
||
|
|
17caeabc10 | ||
|
|
2e6fce162e
|
||
|
|
dc6c58ceb6 | ||
|
|
3763996ae6
|
||
|
|
78dee04ff6 | ||
|
|
575c4d0abd
|
||
|
|
206aa0d876
|
||
|
|
7804d9d442
|
||
|
|
2cfb2aae1d | ||
|
|
b1c6eb2ba7
|
||
|
|
fd965ea312 | ||
|
|
7ac9608b3d
|
||
|
|
03b9c4239f | ||
|
|
9f41e0f3b9
|
||
|
|
f061ccfeea | ||
|
|
a7a8ff0532
|
||
|
|
ad66f1334f | ||
|
|
b640d3a711
|
||
|
|
2bf3246634
|
||
|
|
d5e72d6a15 | ||
|
|
6ecc5ee6c7
|
||
|
|
48ab3933ba | ||
|
|
20749c622b
|
||
|
|
1c8174951c | ||
|
|
3ba996dcdf
|
||
|
|
f1064345e5 | ||
|
|
40b4ea32ca
|
||
|
|
b10f2f0099
|
||
|
|
95b6420793 | ||
|
|
b9e1d21ec9
|
||
|
|
ad846af984 | ||
|
|
ca6642960c
|
||
|
|
5f3c51c5cb
|
||
|
|
36ffbbadbf | ||
|
|
bad02fbc33
|
||
|
|
db6316713a
|
||
|
|
094536a341
|
||
|
|
cb6deb9139 | ||
|
|
09323d33e4 | ||
|
|
ceff470a5f | ||
|
|
41ec132139 | ||
|
|
ea5e5e742e | ||
|
|
696eb51125 | ||
|
|
8d0c9f8023 | ||
|
|
37f7837f76 | ||
|
|
2c8d530de4 | ||
|
|
2b966e780a | ||
|
|
b0cfc3005e | ||
|
|
9b16df1dff | ||
|
|
7e449c0a5f | ||
|
|
22d87d4772 | ||
|
|
8aa81ec853 | ||
|
|
58615ed3fa | ||
|
|
d55b970ab6 | ||
|
|
0135e65278 | ||
|
|
2eb0341a70 | ||
|
|
0c61e3da34 | ||
|
|
71781a0a80 | ||
|
|
15edf4a806 | ||
|
|
a47db3d1a2 | ||
|
|
8359892406 | ||
|
|
d99be29a64 | ||
|
|
7296c003bb | ||
|
|
d37f1dc0e5 | ||
|
|
4d53fff54b | ||
|
|
18e8539fa3 | ||
|
|
4ab6914331 | ||
|
|
16fb76c5e8 | ||
|
|
157aa6fb5c | ||
|
|
5ce62b3e12 | ||
|
|
8de756f456 | ||
|
|
d7bf27854f | ||
|
|
f563d24241 | ||
|
|
334e852edd | ||
|
|
67cce3f3c9 | ||
|
|
9ffb5b12e2 | ||
|
|
a60d95c825 | ||
|
|
417a3efd86 | ||
|
|
443f0f9f89 | ||
|
|
0a07bb2de5 | ||
|
|
35df5a3554 | ||
|
|
bf8721f41d | ||
|
|
590ce82440 | ||
|
|
689e5f9bfd | ||
|
|
059fa8322c | ||
|
|
8295d27d25 | ||
|
|
e817c1a5e3 | ||
|
|
a8ecc6f03c | ||
|
|
91170c6aea | ||
|
|
4131b60ebb | ||
|
|
de3ffa9fb9 | ||
|
|
ad46f200c0 | ||
|
|
d14ebdf65c | ||
|
|
6834dd2dff | ||
|
|
af5564796b | ||
|
|
ffc5b8f83c | ||
|
|
3a694ecee1 | ||
|
|
b9e080e20c | ||
|
|
3705fa96e7 | ||
|
|
c3e7d70a75 | ||
|
|
0b0731b869 | ||
|
|
3b5f759fbd | ||
|
|
06933d5745 | ||
|
|
96853cad23 | ||
|
|
010eff6e71 | ||
|
|
4e7280cb0f | ||
|
|
c2773cb80d | ||
|
|
b79a9368e0 | ||
|
|
f0aa971326 | ||
|
|
23c617bda9 | ||
|
|
2a36eeb3c3 | ||
|
|
a548f74f7d | ||
|
|
97ec1350de | ||
|
|
ac87758996 | ||
|
|
bdcd939bc9 | ||
|
|
a51df35ca1 | ||
|
|
41579e8223 | ||
|
|
516286836a | ||
|
|
63a1d1f2d4 | ||
|
|
ad2d2c90ba | ||
|
|
b3cef7bab8 | ||
|
|
a97d79632e | ||
|
|
51ba298a68 | ||
|
|
3ae66edf13 | ||
|
|
5a6de314ab | ||
|
|
3ca29b6d8a | ||
|
|
c276a1173e | ||
|
|
6ae4658209 | ||
|
|
f7209be81b | ||
|
|
b4999ab448 | ||
|
|
1eaea91909 | ||
|
|
52318aeaad | ||
|
|
22fecf183e | ||
|
|
0978d3bebd | ||
|
|
8e073c110a | ||
|
|
c13b0027f3 | ||
|
|
3cb91c4798 | ||
|
|
43e857dfba | ||
|
|
83e0c9edce | ||
|
|
1509e2283b | ||
|
|
d423b5c1b1 | ||
|
|
6c7835f348 | ||
|
|
46202989df | ||
|
|
09b63783e0 | ||
|
|
e8e0d6acf4 | ||
|
|
14022a367f | ||
|
|
587303b480 | ||
|
|
9c88ac7aff | ||
|
|
ffe58596d4 | ||
|
|
0ab3049703 | ||
|
|
30c3b3f1a7 | ||
|
|
ccca629c49 | ||
|
|
5705d7fb1a | ||
|
|
9bb8c3cb3c | ||
|
|
44e62ce2f4 | ||
|
|
ded099d8bd | ||
|
|
0ebb6c4058 | ||
|
|
d8ab215b94 | ||
|
|
0850cb9c92 | ||
|
|
0bbeee332d | ||
|
|
57814d50df | ||
|
|
aa1cf2bb46 | ||
|
|
66d75b9d9f | ||
|
|
1a34013d0b | ||
|
|
765a51553e | ||
|
|
9f35010ce4 | ||
|
|
09be940f14 | ||
|
|
aec1e76dfc | ||
|
|
d0adeb5cf6 | ||
|
|
da19865de8 | ||
|
|
16c400dfaa | ||
|
|
7d4f126bc7 | ||
|
|
a91eaf6f32 | ||
|
|
675e8564da | ||
|
|
8d18ad1fb1 | ||
|
|
cf50bc6cb8 | ||
|
|
9d70218fa9 | ||
|
|
4a6c8936b3 | ||
|
|
021683b20e | ||
|
|
2d3c940432 | ||
|
|
79e30ce9a2 | ||
|
|
82793591a6 | ||
|
|
a83d5e32a4 | ||
|
|
b89cd1af72 | ||
|
|
ed89a1f1d2 | ||
|
|
12a2c94717 | ||
|
|
a8e7bc669f | ||
|
|
067abe7c18 | ||
|
|
46282c3852 | ||
|
|
7aba4eac70 | ||
|
|
4d0a8c425a | ||
|
|
45e0d7d384 | ||
|
|
36cb0efb8a | ||
|
|
93696a5f4b | ||
|
|
e7156dd890 | ||
|
|
9635482098 | ||
|
|
c214aa9d47 | ||
|
|
6fc2e9a209 | ||
|
|
4578e076ce | ||
|
|
50958ef4da | ||
|
|
029cf6640a | ||
|
|
6715169630 | ||
|
|
8c0938333d | ||
|
|
6831d6a493 |
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,382 @@
|
||||
---
|
||||
# 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
|
||||
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 }}
|
||||
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.
|
||||
- host: benjy.hanzalova.internal
|
||||
node: bob.hanzalova.internal
|
||||
gpu_devices: "1" # 1x RTX 4090 (sm_89); reference card for #2
|
||||
kernel: cuda
|
||||
- host: quadbrat.hanzalova.internal
|
||||
node: bob.hanzalova.internal
|
||||
gpu_devices: "1" # 1x RTX 3060 (sm_86)
|
||||
kernel: cuda
|
||||
- host: beast.hanzalova.internal
|
||||
node: bob.hanzalova.internal
|
||||
gpu_devices: "2" # 2x RTX 5090 (sm_120)
|
||||
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 }}
|
||||
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"])
|
||||
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
|
||||
@@ -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
|
||||
@@ -1,129 +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
|
||||
run: cargo build --locked --workspace
|
||||
timeout-minutes: 90
|
||||
- name: test
|
||||
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
|
||||
run: cargo clippy --locked --workspace
|
||||
timeout-minutes: 30
|
||||
- name: doc
|
||||
run: cargo doc --locked --workspace --no-deps
|
||||
timeout-minutes: 15
|
||||
|
||||
cuda-build:
|
||||
name: 🚀 CUDA Build (cuda=${{ matrix.cuda_tag }} sm=${{ matrix.sm }})
|
||||
needs: fast-checks
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
cuda_tag: ["12.9.0", "13.0.0"]
|
||||
sm: ["86", "89", "120"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Check if cuda-builder image exists
|
||||
id: check-image
|
||||
run: |
|
||||
if docker manifest inspect ghcr.io/quantus-network/cuda-builder:${{ matrix.cuda_tag }} >/dev/null 2>&1; then
|
||||
echo "exists=true" >> "$GITHUB_OUTPUT"
|
||||
echo "✅ CUDA builder image exists for ${{ matrix.cuda_tag }}"
|
||||
else
|
||||
echo "exists=false" >> "$GITHUB_OUTPUT"
|
||||
echo "⚠️ CUDA builder image not found for ${{ matrix.cuda_tag }}, skipping build"
|
||||
fi
|
||||
- name: Build with cuda-builder image
|
||||
if: steps.check-image.outputs.exists == 'true'
|
||||
id: build
|
||||
env:
|
||||
CUDA_TAG: ${{ matrix.cuda_tag }}
|
||||
SM: ${{ matrix.sm }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
set -eux
|
||||
SHORT_TAG="$(echo "${CUDA_TAG}" | sed -E 's/^([0-9]+\.[0-9]+).*/\1/')"
|
||||
BIN="quantus-miner-cuda-${SHORT_TAG}-sm-${SM}"
|
||||
mkdir -p .cargo-cache target
|
||||
docker run --rm \
|
||||
-e CUDA_ARCH=sm_${SM} \
|
||||
-e CARGO_TERM_COLOR=always \
|
||||
-e GITHUB_SHA="${GITHUB_SHA}" \
|
||||
-v "${PWD}:/workspace" \
|
||||
-v "${PWD}/.cargo-cache:/opt/cargo/registry" \
|
||||
-v "${PWD}/target:/workspace/target" \
|
||||
ghcr.io/quantus-network/cuda-builder:${CUDA_TAG} \
|
||||
bash -lc 'cd /workspace && echo "CUDA version:" && nvcc --version && cargo build -p miner-cli --features cuda --release && strip target/release/quantus-miner || true'
|
||||
install -m 0755 ./target/release/quantus-miner "./${BIN}"
|
||||
echo "bin_path=./${BIN}" >> "$GITHUB_OUTPUT"
|
||||
echo "short_tag=${SHORT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
- name: Upload artifact
|
||||
if: steps.check-image.outputs.exists == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ format('quantus-miner-cuda-{0}-sm-{1}', steps.build.outputs.short_tag, matrix.sm) }}
|
||||
path: ${{ steps.build.outputs.bin_path }}
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
@@ -1,83 +0,0 @@
|
||||
# name: Cuda Docker Image Builder
|
||||
|
||||
# on:
|
||||
# workflow_dispatch:
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# paths:
|
||||
# - cuda-builder.cf
|
||||
# pull_request:
|
||||
# branches:
|
||||
# - main
|
||||
# paths:
|
||||
# - cuda-builder.cf
|
||||
|
||||
# permissions:
|
||||
# contents: read
|
||||
# packages: write
|
||||
|
||||
# jobs:
|
||||
# build-and-push:
|
||||
# name: cuda-builder:${{ matrix.cuda_tag }}
|
||||
# runs-on: ubuntu-latest
|
||||
# strategy:
|
||||
# fail-fast: false
|
||||
# matrix:
|
||||
# cuda_tag:
|
||||
# - 12.9.0
|
||||
# - 13.0.0
|
||||
# steps:
|
||||
# - shell: bash
|
||||
# run: |
|
||||
# echo "IMAGE_NAME=ghcr.io/${GITHUB_REPOSITORY_OWNER,,}/cuda-builder" >> "$GITHUB_ENV"
|
||||
|
||||
# - name: Checkout repository
|
||||
# uses: actions/checkout@v4
|
||||
|
||||
# - uses: ./.github/actions/disk
|
||||
|
||||
# - name: Set up Docker Buildx
|
||||
# uses: docker/setup-buildx-action@v3
|
||||
|
||||
# - name: Prepare Buildx cache
|
||||
# uses: actions/cache@v4
|
||||
# with:
|
||||
# path: /tmp/.buildx-cache
|
||||
# key: ${{ runner.os }}-buildx-cuda-builder-${{ matrix.cuda_tag }}-${{ github.sha }}
|
||||
# restore-keys: |
|
||||
# ${{ runner.os }}-buildx-cuda-builder-${{ matrix.cuda_tag }}-
|
||||
|
||||
# - name: Log in to GHCR
|
||||
# if: github.event_name == 'push'
|
||||
# uses: docker/login-action@v3
|
||||
# with:
|
||||
# registry: ghcr.io
|
||||
# username: ${{ github.repository_owner }}
|
||||
# password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# - name: Build and (conditionally) push cuda-builder image
|
||||
# uses: docker/build-push-action@v5
|
||||
# with:
|
||||
# context: .
|
||||
# file: cuda-builder.cf
|
||||
# build-args: |
|
||||
# CUDA_TAG=${{ matrix.cuda_tag }}
|
||||
# pull: true
|
||||
# push: ${{ github.event_name == 'push' }}
|
||||
# tags: |
|
||||
# ${{ env.IMAGE_NAME }}:${{ matrix.cuda_tag }}
|
||||
# labels: |
|
||||
# org.opencontainers.image.source=${{ github.repository }}
|
||||
# org.opencontainers.image.description=Pristine CUDA builder image (CUDA ${{ matrix.cuda_tag }})
|
||||
# org.opencontainers.image.licenses=Apache-2.0
|
||||
# org.opencontainers.image.title=cuda-builder
|
||||
# org.opencontainers.image.vendor=Quantus Network
|
||||
# cache-from: type=local,src=/tmp/.buildx-cache
|
||||
# cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
|
||||
# provenance: false
|
||||
|
||||
# - name: Move Buildx cache
|
||||
# run: |
|
||||
# rm -rf /tmp/.buildx-cache
|
||||
# mv /tmp/.buildx-cache-new /tmp/.buildx-cache
|
||||
@@ -1,200 +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-cuda --precise "$new_cargo_version"
|
||||
cargo update -p engine-gpu-opencl --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"
|
||||
@@ -1,145 +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:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary_name: quantus-miner
|
||||
asset_name: quantus-miner-linux-x86_64
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary_name: quantus-miner.exe
|
||||
asset_name: quantus-miner-windows-x86_64.exe
|
||||
- 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
|
||||
run: cargo build --release --locked --target ${{ matrix.target }}
|
||||
|
||||
- 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-linux-x86_64/quantus-miner-linux-x86_64 \
|
||||
artifacts/quantus-miner-windows-x86_64.exe/quantus-miner-windows-x86_64.exe \
|
||||
artifacts/quantus-miner-macos-aarch64/quantus-miner-macos-aarch64
|
||||
Generated
+1895
-761
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -1,13 +1,17 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/bench-harness", # lair: quantus/miner#2
|
||||
"crates/engine-cpu",
|
||||
"crates/engine-gpu-cuda",
|
||||
"crates/engine-gpu-opencl",
|
||||
"crates/engine-cuda", # lair: quantus/miner#3
|
||||
"crates/engine-gpu",
|
||||
"crates/metrics",
|
||||
"crates/miner-cli",
|
||||
"crates/miner-service",
|
||||
"crates/miner-telemetry",
|
||||
"crates/pool-service",
|
||||
"crates/pow-core",
|
||||
"crates/quic-transport",
|
||||
"crates/solver-wasm",
|
||||
]
|
||||
default-members = ["crates/miner-cli"]
|
||||
resolver = "2"
|
||||
@@ -16,7 +20,7 @@ resolver = "2"
|
||||
edition = "2021"
|
||||
authors = ["Quantus Network"]
|
||||
description = "Quantus External Miner Workspace"
|
||||
version = "1.0.0"
|
||||
version = "4.0.2"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
@@ -30,18 +34,15 @@ num-bigint = { version = "0.4", features = ["rand"] }
|
||||
num-traits = "0.2"
|
||||
num_cpus = "1.16"
|
||||
primitive-types = { version = "0.13.1", default-features = false }
|
||||
qp-poseidon-core = { version = "1.0.1", default-features = false }
|
||||
qpow-math = { git = "https://github.com/Quantus-Network/chain.git", package = "qpow-math", default-features = false }
|
||||
quantus-miner-api = { version = "0.0.3", default-features = false }
|
||||
rand = { version = "0.8.5", default-features = false }
|
||||
qp-poseidon-core = { version = "3.1.0", default-features = false }
|
||||
qpow-math = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.7.1-q-day-2", package = "qpow-math", default-features = false }
|
||||
quantus-miner-api = "0.3.0"
|
||||
rand = { version = "0.9", default-features = false, features = ["std", "std_rng", "thread_rng"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = { version = "1.0.132", default-features = false }
|
||||
thiserror = "1"
|
||||
tokio = { version = "1.36", features = ["full"] }
|
||||
warp = "0.3"
|
||||
# pow-core now delegates to qpow-math for core functionality while providing optimized mining scaffolding.
|
||||
|
||||
# GPU backend dependencies are declared in individual engine crates (no optional deps in workspace)
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
# External Miner Protocol Specification
|
||||
|
||||
This document defines the JSON-based HTTP protocol for communication between the Resonance Network node and an external QPoW miner service.
|
||||
|
||||
## Overview
|
||||
|
||||
The node delegates the mining task (finding a valid nonce) to an external service. The node provides the necessary parameters (header hash, difficulty, nonce range) and the external miner searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. The miner returns the result, including the winning nonce, when found.
|
||||
|
||||
## Data Types
|
||||
|
||||
See the `resonance-miner-api` crate for the canonical Rust definitions of these structures.
|
||||
|
||||
- `job_id`: String (UUID recommended) - Unique identifier for a specific mining task, generated by the node.
|
||||
- `mining_hash`: String (64 hex chars, no 0x) - The header hash for which to find a nonce.
|
||||
- `difficulty`: String (u64 as string) - The target difficulty for the mining job.
|
||||
- `nonce_start`: String (128 hex chars, no 0x) - The starting nonce value (inclusive) for the search range.
|
||||
- `nonce_end`: String (128 hex chars, no 0x) - The ending nonce value (inclusive) for the search range.
|
||||
- `status`: Enum (`ApiResponseStatus`) - Indicates the state or result of an API call.
|
||||
- `message`: String (optional) - Provides details for `Error` status responses.
|
||||
- `nonce`: String (Hex, no 0x) - Represents the `U512` value of the current or winning nonce.
|
||||
- `work`: String (128 hex chars, no 0x) - Represents the winning nonce as `[u8; 64]`. This is the value the node needs for verification.
|
||||
- `hash_count`: Number (u64) - Number of nonces checked by the miner for the job.
|
||||
- `elapsed_time`: Number (f64) - Time in seconds the miner spent on the job.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Submit Mining Job
|
||||
|
||||
- **Endpoint:** `POST /mine`
|
||||
- **Description:** The node requests the external miner to start searching for a valid nonce.
|
||||
- **Request Body (`MiningRequest`):**
|
||||
```json
|
||||
{
|
||||
"job_id": "...",
|
||||
"mining_hash": "...",
|
||||
"difficulty": "...",
|
||||
"nonce_start": "...",
|
||||
"nonce_end": "..."
|
||||
}
|
||||
```
|
||||
- **Response Body (`MiningResponse`):**
|
||||
- Success (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"job_id": "..."
|
||||
}
|
||||
```
|
||||
- Error (400 Bad Request - Invalid Input / 409 Conflict - Duplicate Job ID):
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"job_id": "...",
|
||||
"message": "..." // e.g., "Job already exists", "Invalid mining_hash (...)"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Get Job Result
|
||||
|
||||
- **Endpoint:** `GET /result/{job_id}`
|
||||
- **Description:** The node polls the external miner to check the status and retrieve the result.
|
||||
- **Path Parameter:**
|
||||
- `job_id`: String (UUID) - The ID of the job to query.
|
||||
- **Response Body (`MiningResult`):**
|
||||
- Job Completed (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "completed",
|
||||
"job_id": "...",
|
||||
"nonce": "...", // U512 hex value of winning nonce
|
||||
"work": "...", // [u8; 64] hex value of winning nonce
|
||||
"hash_count": ..., // u64
|
||||
"elapsed_time": ... // f64 seconds
|
||||
}
|
||||
```
|
||||
- Job Still Running (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "running",
|
||||
"job_id": "...",
|
||||
"nonce": "...", // Current nonce being checked (U512 hex)
|
||||
"work": null,
|
||||
"hash_count": ..., // u64
|
||||
"elapsed_time": ... // f64 seconds
|
||||
}
|
||||
```
|
||||
- Job Failed (e.g., nonce range exhausted) (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"job_id": "...",
|
||||
"nonce": "...", // Final nonce checked (U512 hex)
|
||||
"work": null,
|
||||
"hash_count": ..., // u64
|
||||
"elapsed_time": ... // f64 seconds
|
||||
}
|
||||
```
|
||||
- Job Not Found (404 Not Found):
|
||||
```json
|
||||
{
|
||||
"status": "not_found",
|
||||
"job_id": "...",
|
||||
"nonce": null,
|
||||
"work": null,
|
||||
"hash_count": 0,
|
||||
"elapsed_time": 0.0
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Cancel Mining Job
|
||||
|
||||
- **Endpoint:** `POST /cancel/{job_id}`
|
||||
- **Description:** The node requests the external miner to stop working on a specific job.
|
||||
- **Path Parameter:**
|
||||
- `job_id`: String (UUID) - The ID of the job to cancel.
|
||||
- **Request Body:** (Empty)
|
||||
- **Response Body (`MiningResponse`):
|
||||
- Success (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "cancelled",
|
||||
"job_id": "..."
|
||||
}
|
||||
```
|
||||
- Job Not Found (404 Not Found):
|
||||
```json
|
||||
{
|
||||
"status": "not_found",
|
||||
"job_id": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- All hex values (`mining_hash`, `nonce_start`, `nonce_end`, `nonce`, `work`) should be sent **without** the `0x` prefix.
|
||||
- The miner must implement the validation logic defined in `qpow_math::is_valid_nonce`.
|
||||
- The node relies primarily on the `work` field in the `MiningResult` (when status is `completed`) for constructing the `QPoWSeal`.
|
||||
|
||||
# External Miner Protocol Specification
|
||||
|
||||
This document defines the JSON-based HTTP protocol for communication between the node and an external QPoW miner.
|
||||
|
||||
## Overview
|
||||
|
||||
The node delegates the mining task to an external service. The node provides the necessary parameters (mining hash, difficulty, and a nonce range) and the external miner searches for a valid nonce within that range. The miner returns the nonce and the resulting work hash when a solution is found.
|
||||
|
||||
## Data Types
|
||||
|
||||
- `job_id`: String (UUID recommended) - Identifier for a specific mining task.
|
||||
- `mining_hash`: String (Hex-encoded, 32-byte hash, H256) - The hash derived from the block header data that the miner needs to solve.
|
||||
- `difficulty`: String (Decimal representation of u64) - The target difficulty for the block.
|
||||
- `nonce_start`: String (Hex-encoded, 64-byte value, U512) - The starting nonce value (inclusive).
|
||||
- `nonce_end`: String (Hex-encoded, 64-byte value, U512) - The ending nonce value (inclusive).
|
||||
- `nonce`: String (Hex-encoded, 64-byte value, U512) - The solution found by the miner.
|
||||
- `work`: String (Hex-encoded, 32-byte hash, H256) - The hash resulting from the combination of `mining_hash` and `nonce`, meeting the difficulty requirement.
|
||||
- `status`: String Enum - Indicates the state or result of an API call.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### 1. Start Mining Job
|
||||
|
||||
- **Endpoint:** `POST /mine`
|
||||
- **Description:** The node requests the external miner to start searching for a valid nonce within the specified range for the given parameters.
|
||||
- **Request Body (application/json):**
|
||||
```json
|
||||
{
|
||||
"job_id": "...", // String (UUID), generated by the node
|
||||
"mining_hash": "...", // Hex String (H256)
|
||||
"difficulty": "...", // String (u64 decimal)
|
||||
"nonce_start": "...", // Hex String (U512 hex)
|
||||
"nonce_end": "..." // Hex String (U512 hex)
|
||||
}
|
||||
```
|
||||
- **Response Body (application/json):**
|
||||
- Success (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "accepted",
|
||||
"job_id": "..." // String (UUID), confirming the job ID received
|
||||
}
|
||||
```
|
||||
- Error (e.g., 400 Bad Request, 500 Internal Server Error):
|
||||
```json
|
||||
{
|
||||
"status": "rejected",
|
||||
"reason": "..." // String (Description of error)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Get Job Result
|
||||
|
||||
- **Endpoint:** `GET /result/{job_id}`
|
||||
- **Description:** The node polls the external miner to check the status and retrieve the result of a previously submitted job.
|
||||
- **Path Parameter:**
|
||||
- `job_id`: String (UUID) - The ID of the job to query.
|
||||
- **Response Body (application/json):**
|
||||
- Solution Found (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "found",
|
||||
"job_id": "...", // String (UUID)
|
||||
"nonce": "...", // Hex String (U512 hex)
|
||||
"work": "CAFEBABE01.." // Hex String (H256 hex)
|
||||
}
|
||||
```
|
||||
- Still Working (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "working",
|
||||
"job_id": "..." // String (UUID)
|
||||
}
|
||||
```
|
||||
- Job Stale/Cancelled (200 OK): Indicates the job is no longer valid (e.g., the node requested cancellation or submitted work for a newer block).
|
||||
```json
|
||||
{
|
||||
"status": "stale",
|
||||
"job_id": "..." // String (UUID)
|
||||
}
|
||||
```
|
||||
- Job Not Found (404 Not Found):
|
||||
```json
|
||||
{
|
||||
"status": "not_found",
|
||||
"job_id": "..." // String (UUID)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Cancel Mining Job
|
||||
|
||||
- **Endpoint:** `POST /cancel/{job_id}`
|
||||
- **Description:** The node requests the external miner to stop working on a specific job. This is typically used when the node receives a new block or its mining parameters change, making the old job obsolete.
|
||||
- **Path Parameter:**
|
||||
- `job_id`: String (UUID) - The ID of the job to cancel.
|
||||
- **Request Body:** (Empty)
|
||||
- **Response Body (application/json):**
|
||||
- Success (200 OK):
|
||||
```json
|
||||
{
|
||||
"status": "cancelled",
|
||||
"job_id": "..." // String (UUID)
|
||||
}
|
||||
```
|
||||
- Job Not Found (404 Not Found):
|
||||
```json
|
||||
{
|
||||
"status": "not_found",
|
||||
"job_id": "..." // String (UUID)
|
||||
}
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- The external miner should iterate from `nonce_start` up to and including `nonce_end` when searching for a valid nonce.
|
||||
- The miner should return the `nonce` and the calculated `work` hash when a solution is found.
|
||||
- The node uses the returned `nonce` and `work` (along with the fetched `difficulty`) to construct the `QPoWSeal` and submit it.
|
||||
- The external miner should not need to know anything about the runtime or the code; it only needs to perform the nonce search and return the results.
|
||||
@@ -1,244 +1,159 @@
|
||||
# External Miner Service for Quantus Network
|
||||
|
||||
Note: This repository is now a Cargo workspace. Build and run the CLI with:
|
||||
- cargo build -p miner-cli --release
|
||||
- cargo run -p miner-cli -- --port 9833 [--metrics-port 9900] [--workers N]
|
||||
|
||||
This crate provides an external mining service that can be used with a Quantus Network node. It exposes an HTTP API for
|
||||
managing mining jobs.
|
||||
High-performance external mining service for Quantus Network with support for CPU, GPU, and hybrid CPU+GPU mining.
|
||||
|
||||
## Building
|
||||
|
||||
To build the external miner service, navigate to the `miner` directory within the repository and use Cargo:
|
||||
|
||||
```bash
|
||||
cd quantus-miner
|
||||
cargo build --release
|
||||
# CPU-only build (default)
|
||||
cargo build -p miner-cli --release
|
||||
|
||||
# With GPU support (recommended)
|
||||
cargo build -p miner-cli --release
|
||||
```
|
||||
|
||||
This will compile the binary and place it in the `target/release/` directory.
|
||||
|
||||
## CUDA Build (optional, Linux only)
|
||||
|
||||
The CUDA backend is feature-gated and currently supported on Linux with NVIDIA GPUs. macOS is not supported for CUDA.
|
||||
|
||||
Build with CUDA enabled:
|
||||
```bash
|
||||
# Build the CLI with CUDA feature (compiles .cu kernels to PTX via nvcc)
|
||||
cargo build -p miner-cli --features cuda --release
|
||||
```
|
||||
|
||||
At runtime, select the engine with:
|
||||
```bash
|
||||
# Will error if CUDA runtime/driver is unavailable
|
||||
./target/release/quantus-miner --engine gpu-cuda --metrics-port 9919
|
||||
```
|
||||
|
||||
Environment knobs used by the CUDA build:
|
||||
- NVCC: Path to the nvcc binary (optional if in PATH)
|
||||
- CUDA_HOME or CUDA_PATH: Used to locate nvcc at $CUDA_HOME/bin/nvcc when NVCC is unset
|
||||
- CUDA_ARCH: Compute capability target for PTX (default: sm_70)
|
||||
|
||||
If nvcc isn’t found, the build will continue but skip compiling kernels; the GPU engine will then fall back to CPU at runtime.
|
||||
|
||||
### Ubuntu (22.04/24.04) setup
|
||||
|
||||
Install NVIDIA driver and CUDA toolkit:
|
||||
```bash
|
||||
# Install proprietary driver (pick latest recommended)
|
||||
sudo ubuntu-drivers autoinstall
|
||||
sudo reboot
|
||||
|
||||
# CUDA toolkit (provides nvcc). Option A: Ubuntu package (may be older):
|
||||
sudo apt update
|
||||
sudo apt install -y nvidia-cuda-toolkit
|
||||
|
||||
# Verify nvcc
|
||||
nvcc --version
|
||||
```
|
||||
For newer toolkits, consider NVIDIA’s official repo: https://developer.nvidia.com/cuda-downloads
|
||||
|
||||
### Fedora 42 (dnf5) setup
|
||||
|
||||
Install NVIDIA driver and CUDA toolkit:
|
||||
```bash
|
||||
# Enable RPM Fusion for proprietary NVIDIA drivers (driver comes from here)
|
||||
sudo dnf5 install -y https://download1.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm
|
||||
|
||||
# Install driver (akmod builds the module for your current kernel)
|
||||
sudo dnf5 install -y akmod-nvidia
|
||||
sudo reboot
|
||||
|
||||
# Add NVIDIA CUDA repo (for toolkit only) by creating a pinned repo file
|
||||
sudo tee /etc/yum.repos.d/cuda-fedora$(rpm -E %fedora).repo >/dev/null <<'EOF'
|
||||
[cuda-fedora$releasever-x86_64]
|
||||
name=NVIDIA CUDA Fedora $releasever - x86_64
|
||||
baseurl=https://developer.download.nvidia.com/compute/cuda/repos/fedora$releasever/x86_64/
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
gpgkey=https://developer.download.nvidia.com/compute/cuda/repos/fedora$releasever/x86_64/7fa2af80.pub
|
||||
# Keep NVIDIA drivers from RPM Fusion; do not install any drivers from this repo
|
||||
excludepkgs=cuda-drivers*,nvidia-driver*,xorg-x11-drv-nvidia*,kernel*
|
||||
EOF
|
||||
|
||||
# Install CUDA toolkit and versioned nvcc; drivers remain from RPM Fusion due to excludepkgs above
|
||||
# Discover nvcc package version and install alongside toolkit (example uses 13-0):
|
||||
sudo dnf5 search cuda-nvcc
|
||||
sudo dnf5 install -y cuda-toolkit cuda-nvcc-13-0 gcc14
|
||||
|
||||
# Verify nvcc
|
||||
nvcc --version
|
||||
|
||||
# If nvcc is not on PATH, add it or set CUDA_HOME/NVCC (example for version 13.0):
|
||||
export CUDA_HOME=/usr/local/cuda-13.0
|
||||
export PATH="$CUDA_HOME/bin:$PATH"
|
||||
# Or point the build directly at nvcc:
|
||||
export NVCC="$CUDA_HOME/bin/nvcc"
|
||||
```
|
||||
|
||||
#### Fedora 42 (dnf5): match CUDA toolkit to the installed driver (RPM Fusion)
|
||||
|
||||
If your NVIDIA driver comes from RPM Fusion (recommended) and reports a CUDA Version (via nvidia-smi) that doesn’t match the CUDA toolkit available in the NVIDIA Fedora 42 repo, install the matching toolkit from the NVIDIA archive. This avoids PTX/CUBIN incompatibilities between toolkit and driver.
|
||||
|
||||
- Check your driver’s CUDA Version:
|
||||
```bash
|
||||
nvidia-smi | grep "CUDA Version"
|
||||
# Example: CUDA Version: 12.9
|
||||
```
|
||||
|
||||
- Download the matching “local installer” repo RPM from the NVIDIA archive with a resilient downloader (NVIDIA servers can be flaky; use resume/retry):
|
||||
```bash
|
||||
# Example for CUDA 12.9 on Fedora 41 (works on Fedora 42 too):
|
||||
curl --fail --location --retry 9999 --retry-delay 3 --retry-max-time 0 \
|
||||
--continue-at - \
|
||||
--output ~/Downloads/cuda-repo-fedora41-12-9-local-12.9.0_575.51.03-1.x86_64.rpm \
|
||||
--url https://developer.download.nvidia.com/compute/cuda/12.9.0/local_installers/cuda-repo-fedora41-12-9-local-12.9.0_575.51.03-1.x86_64.rpm
|
||||
```
|
||||
|
||||
- Install the local repo RPM:
|
||||
```bash
|
||||
sudo dnf5 install -y ~/Downloads/cuda-repo-fedora41-12-9-local-12.9.0_575.51.03-1.x86_64.rpm
|
||||
```
|
||||
|
||||
- Prevent driver packages from the NVIDIA repo (keep drivers from RPM Fusion):
|
||||
```bash
|
||||
# Add excludes to the generated repo file (name may vary slightly)
|
||||
sudo sed -i '/^\[cuda-/,/^$/ {
|
||||
/^\s*excludepkgs=/d
|
||||
}' /etc/yum.repos.d/cuda-fedora41-12-9-local.repo
|
||||
|
||||
echo "excludepkgs=cuda-drivers*,nvidia-driver*,xorg-x11-drv-nvidia*,kernel*" | \
|
||||
sudo tee -a /etc/yum.repos.d/cuda-fedora41-12-9-local.repo
|
||||
```
|
||||
|
||||
- Install the matching toolkit and nvcc from the local NVIDIA repo (exclude drivers):
|
||||
```bash
|
||||
sudo dnf5 install -y cuda-toolkit cuda-nvcc-12-9 --exclude='cuda-drivers*'
|
||||
```
|
||||
|
||||
- Make nvcc available to the build (12.9 example):
|
||||
```bash
|
||||
export CUDA_HOME=/usr/local/cuda-12.9
|
||||
export PATH="$CUDA_HOME/bin:$PATH"
|
||||
export NVCC="$CUDA_HOME/bin/nvcc"
|
||||
|
||||
# Verify
|
||||
nvcc --version
|
||||
```
|
||||
|
||||
- Build with the matching toolkit (example, RTX 3060/Ampere):
|
||||
```bash
|
||||
CUDA_ARCH=sm_86 cargo build -p miner-cli --features cuda --release
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Using the Fedora 41 local repo RPM on Fedora 42 is acceptable for the CUDA user-space toolkit; we only need nvcc and toolchain, not the driver.
|
||||
- Always keep the NVIDIA driver from RPM Fusion. The excludepkgs line ensures the toolkit install will not replace your driver.
|
||||
- If nvcc is still not on PATH after install, set NVCC explicitly as above.
|
||||
|
||||
### Notes
|
||||
|
||||
- Ensure your user can access the GPU device nodes (e.g., part of the video group when required).
|
||||
- The build script compiles any .cu under crates/engine-gpu-cuda/src/kernels into PTX and sets ENGINE_GPU_CUDA_PTX_DIR for the crate to load at runtime.
|
||||
- Architecture targeting (optional, per-GPU tuning):
|
||||
- Ampere (RTX 3060): `CUDA_ARCH=sm_86 cargo build -p miner-cli --features cuda --release`
|
||||
- Ada (RTX 4090): `CUDA_ARCH=sm_89 cargo build -p miner-cli --features cuda --release`
|
||||
- CC 12.0 (e.g., RTX 5090):
|
||||
- Forward-compatible PTX: `CUDA_ARCH=compute_120 cargo build -p miner-cli --features cuda --release`
|
||||
- Tuned for SM: `CUDA_ARCH=sm_120 cargo build -p miner-cli --features cuda --release`
|
||||
- If unset, the default `sm_70` PTX will still JIT on newer GPUs (just with less arch-specific tuning).
|
||||
|
||||
## Configuration
|
||||
|
||||
The service can be configured using command-line arguments or environment variables.
|
||||
|
||||
| Argument | Environment Variable | Description | Default |
|
||||
|-------------------|----------------------|--------------------------------------------|---------------|
|
||||
| `--port <PORT>` | `MINER_PORT` | The port for the HTTP server to listen on. | `9833` |
|
||||
| `--workers <N>` | `MINER_WORKERS` | The number of worker threads (logical CPUs) to use for mining. | Auto-detected (leaves ~half available) |
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
# Run on the default port 9833 using about half of total cpu resources
|
||||
../target/release/quantus-miner
|
||||
|
||||
# Run on a custom port with 4 workers (logical CPUs)
|
||||
../target/release/quantus-miner --port 8000 --workers 4
|
||||
|
||||
# Equivalent using environment variables
|
||||
export MINER_PORT=8000
|
||||
export MINER_WORKERS=4
|
||||
../target/release/quantus-miner
|
||||
```
|
||||
The binary will be available at `target/release/quantus-miner`.
|
||||
|
||||
## Running
|
||||
|
||||
After building the service, you can run it directly from the command line:
|
||||
The node requires a shared auth token and TLS cert pin. Both live under the
|
||||
node's chain config dir (`<base-path>/chains/<chain>/`):
|
||||
|
||||
- `miner-auth-token` — shared secret (**not** logged by the node; read this file)
|
||||
- `miner-tls-cert-sha256` — SHA-256 of the miner TLS cert (also printed in node logs)
|
||||
|
||||
```bash
|
||||
# Run with default settings
|
||||
RUST_LOG=info ../target/release/quantus-miner
|
||||
# Preferred: mount/read the node's chain config files
|
||||
./target/release/quantus-miner serve \
|
||||
--node-addr 127.0.0.1:9833 \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--cpu-workers 4
|
||||
|
||||
# Run with a specific port and 2 workers
|
||||
RUST_LOG=info ../target/release/quantus-miner --port 12345 --workers 2
|
||||
|
||||
# Run in debug mode
|
||||
RUST_LOG=info,miner=debug ../target/release/quantus-miner --workers 4
|
||||
# Or pass values directly (token from miner-auth-token; fingerprint also in node logs)
|
||||
./target/release/quantus-miner serve \
|
||||
--node-addr 127.0.0.1:9833 \
|
||||
--auth-token <TOKEN> \
|
||||
--tls-cert-sha256 <FINGERPRINT> \
|
||||
--gpu-devices 1
|
||||
|
||||
# Hybrid CPU+GPU mining
|
||||
./target/release/quantus-miner serve \
|
||||
--node-addr 127.0.0.1:9833 \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--cpu-workers 4 \
|
||||
--gpu-devices 1
|
||||
```
|
||||
|
||||
The service will start and log messages to the console, indicating the port it's listening on and the number of worker threads in use.
|
||||
## Configuration
|
||||
|
||||
Example output:
|
||||
| Argument | Environment Variable | Description | Default |
|
||||
|----------|---------------------|-------------|---------|
|
||||
| `--node-addr <ADDR>` | `MINER_NODE_ADDR` | Node address to connect to | `127.0.0.1:9833` |
|
||||
| `--auth-token <TOKEN>` | `MINER_AUTH_TOKEN` | Shared secret from the node's `miner-auth-token` file (not logged) | required |
|
||||
| `--auth-token-file <PATH>` | `MINER_AUTH_TOKEN_FILE` | Read the shared secret from a file (preferred) | — |
|
||||
| `--tls-cert-sha256 <HEX>` | `MINER_TLS_CERT_SHA256` | SHA-256 of the node's miner TLS cert (`miner-tls-cert-sha256` / node logs) | required |
|
||||
| `--tls-cert-sha256-file <PATH>` | `MINER_TLS_CERT_SHA256_FILE` | Read the TLS cert fingerprint from a file | — |
|
||||
| `--cpu-workers <N>` | `MINER_CPU_WORKERS` | Number of CPU worker threads | Auto-detect |
|
||||
| `--gpu-devices <N>` | `MINER_GPU_DEVICES` | Number of GPU devices | Auto-detect |
|
||||
| `--gpu-batch-size <N>` | `MINER_GPU_BATCH_SIZE` | GPU batch size in nonces | 1000000 |
|
||||
| `--cpu-batch-size <N>` | `MINER_CPU_BATCH_SIZE` | CPU batch size in hashes | 10000 |
|
||||
| `--gpu-throttle-ms <MS>` | `MINER_GPU_THROTTLE_MS` | Sleep duration (ms) between GPU batches | 0 |
|
||||
| `--metrics-port <PORT>` | `MINER_METRICS_PORT` | Prometheus metrics port | 9900 |
|
||||
|
||||
```
|
||||
INFO external_miner > Starting external miner service...
|
||||
INFO external_miner > Using auto-detected workers (leaving headroom): 4
|
||||
INFO external_miner > Server starting on 0.0.0.0:9833
|
||||
## GPU Mining
|
||||
|
||||
GPU support uses WGPU for cross-platform acceleration:
|
||||
|
||||
- **macOS**: Metal backend (Apple Silicon & Intel)
|
||||
- **Linux**: Vulkan/OpenGL backends
|
||||
- **Windows**: DirectX 12/Vulkan backends
|
||||
|
||||
### Setup
|
||||
|
||||
**Build with GPU support:**
|
||||
```bash
|
||||
cargo build -p miner-cli --release
|
||||
```
|
||||
|
||||
## API Specification
|
||||
**Platform requirements:**
|
||||
- **macOS**: Works out-of-the-box
|
||||
- **Linux**: Install GPU drivers (`nvidia-driver`, `mesa-vulkan-drivers`)
|
||||
- **Windows**: Ensure recent graphics drivers are installed
|
||||
|
||||
The detailed API specification is defined using OpenAPI 3.0 and can be found in the `api/openapi.yaml` file.
|
||||
### Performance Monitoring
|
||||
|
||||
This specification details all endpoints, request/response formats, and expected status codes.
|
||||
You can use tools like [Swagger Editor](https://editor.swagger.io/)
|
||||
or [Swagger UI](https://swagger.io/tools/swagger-ui/) to view and interact with the API definition.
|
||||
- **macOS**: `sudo powermetrics --samplers gpu_power -i 1000`
|
||||
- **Linux**: `nvidia-smi` (NVIDIA) or `radeontop` (AMD)
|
||||
- **Windows**: Task Manager GPU tab
|
||||
|
||||
## A note on workers
|
||||
## Examples
|
||||
|
||||
The miner previously used a flag named `--num-cores`. To better reflect intent, this has been replaced by `--workers`, which specifies the number of worker threads (logical CPUs). When not provided, the miner auto-detects an effective CPU set (honoring cgroup cpusets when present) and defaults to a value that leaves roughly half of the system resources available to other processes.
|
||||
All `serve` examples need the auth token and TLS pin (files or inline values).
|
||||
|
||||
## API Endpoints (Summary)
|
||||
```bash
|
||||
# CPU mining with 8 workers
|
||||
./target/release/quantus-miner serve \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--cpu-workers 8
|
||||
|
||||
* `POST /mine`: Submits a new mining job.
|
||||
* `GET /result/{job_id}`: Retrieves the status and result of a specific mining job.
|
||||
* `POST /cancel/{job_id}`: Cancels an ongoing mining job.
|
||||
# Pure GPU mining
|
||||
./target/release/quantus-miner serve \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--gpu-devices 1
|
||||
|
||||
## Implementation and PR review docs
|
||||
# GPU mining with throttle (reduce GPU utilization)
|
||||
./target/release/quantus-miner serve \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--gpu-devices 1 --gpu-throttle-ms 50
|
||||
|
||||
These documents provide reviewers with the authoritative context for changes. Commits and pull requests should link to the relevant prompt/response entry.
|
||||
# Hybrid mining: 4 CPU + 1 GPU workers
|
||||
./target/release/quantus-miner serve \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--cpu-workers 4 --gpu-devices 1
|
||||
|
||||
- Authoring/process guide: agents.md
|
||||
# With verbose logging
|
||||
RUST_LOG=debug ./target/release/quantus-miner serve \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--cpu-workers 2 --gpu-devices 1
|
||||
|
||||
# Production setup with metrics
|
||||
./target/release/quantus-miner serve \
|
||||
--node-addr 127.0.0.1:9833 \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256 \
|
||||
--cpu-workers 6 \
|
||||
--gpu-devices 1 \
|
||||
--metrics-port 9900
|
||||
```
|
||||
|
||||
## Protocol
|
||||
|
||||
The miner uses a QUIC-based protocol for communication with the node:
|
||||
|
||||
- **Transport**: QUIC with TLS 1.3 (self-signed certificate, pinned by SHA-256)
|
||||
- **Auth**: `Ready { token }` must match the node's `miner-auth-token`
|
||||
- **ALPN**: `quantus-miner/2`
|
||||
- **Port**: 9833 (default)
|
||||
- **Messages**: `Ready` (miner→node), `NewJob` (node→miner), `JobResult` (miner→node)
|
||||
|
||||
For full protocol specification, see the node's `MINING.md`.
|
||||
|
||||
## Benchmarking
|
||||
|
||||
```bash
|
||||
# Benchmark CPU performance
|
||||
./target/release/quantus-miner benchmark --cpu-workers 8 --duration 30
|
||||
|
||||
# Benchmark GPU performance
|
||||
./target/release/quantus-miner benchmark --gpu-devices 1 --duration 30
|
||||
|
||||
# Benchmark hybrid performance
|
||||
./target/release/quantus-miner benchmark --cpu-workers 4 --gpu-devices 1 --duration 30
|
||||
```
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# Security Policy
|
||||
|
||||
Quantus Network takes the security of our users seriously and welcomes reports
|
||||
from security researchers.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
**Please do not open a public issue, pull request, or social-media post for a
|
||||
security vulnerability.** Public disclosure before a fix is available puts
|
||||
users' funds at risk. Use one of the private channels below and we will
|
||||
coordinate a fix and disclosure with you.
|
||||
|
||||
1. **GitHub private security advisory (preferred).**
|
||||
[Report a vulnerability](https://github.com/quantus-network/quantus-miner/security/advisories/new).
|
||||
This gives you a private, structured thread with the maintainers and is the
|
||||
fastest way to reach us.
|
||||
2. **Email.** Send details to **security@quantus.com**.
|
||||
|
||||
### What to include
|
||||
|
||||
- A clear description of the issue and its security impact.
|
||||
- A working proof of concept is required. It must run directly against this
|
||||
project, using a release build or locally built version, and demonstrate the
|
||||
reported behavior and security impact. Standalone code, mathematical
|
||||
examples, or simulations that only reproduce the theory without exercising
|
||||
the project do not satisfy this requirement.
|
||||
- Step-by-step reproduction instructions.
|
||||
- Affected platforms and versions.
|
||||
- Any relevant logs, addresses, or transaction IDs (for on-chain issues).
|
||||
|
||||
If you used AI tooling to find or write up the report, please say so.
|
||||
|
||||
## Our commitment (safe harbor)
|
||||
|
||||
We consider security research conducted in good faith under this policy to be
|
||||
authorized. We will not pursue or support legal action against researchers who:
|
||||
|
||||
- make a good-faith effort to avoid privacy violations, data destruction, and
|
||||
interruption or degradation of our services;
|
||||
- only interact with accounts they own or have explicit permission to access;
|
||||
and
|
||||
- give us a reasonable opportunity to fix an issue before disclosing it
|
||||
publicly.
|
||||
|
||||
If in doubt about whether an action is authorized, ask us first at
|
||||
security@quantus.com.
|
||||
|
||||
## What to expect
|
||||
|
||||
- **Acknowledgement:** within **2 business days**.
|
||||
- **Triage and initial assessment:** within **7 business days**.
|
||||
- **Coordinated disclosure:** we aim to ship a fix and coordinate public
|
||||
disclosure within **90 days** of the report. We will keep you updated on
|
||||
progress and agree on a disclosure date with you.
|
||||
- **Credit:** with your permission, we are happy to publicly credit you for the
|
||||
report once a fix is released.
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:** the external mining service (CPU and GPU) in this repository — anything that could lead to theft or misattribution of mining rewards, invalid blocks being produced, or remote code execution or crash via its service API.
|
||||
|
||||
**Out of scope:** issues in third-party services or nodes we do not operate;
|
||||
reports generated solely by automated scanners without a demonstrated impact;
|
||||
low-severity or informational issues on our marketing and landing websites;
|
||||
and social-engineering or physical attacks.
|
||||
|
||||
## Rewards
|
||||
|
||||
At our **sole discretion**, we may offer a reward for a valid report. To be
|
||||
eligible, a report must be submitted **privately** through one of the channels
|
||||
above and identify a genuine vulnerability with real impact on users. Trivial
|
||||
or low-impact findings, automated-scanner output without a working proof of
|
||||
concept, and already-known issues are **not** eligible. There is no fixed
|
||||
bounty and no guaranteed payout; whether a report qualifies, and any amount,
|
||||
are determined solely by Quantus Network.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Only the latest release is supported; security fixes are delivered in new
|
||||
versions. Please keep your installation up to date.
|
||||
@@ -113,7 +113,7 @@ Use the stable toolchain.
|
||||
- cargo test --workspace --locked
|
||||
|
||||
- Runtime sanity (typical)
|
||||
- cargo run -p miner-cli -- --engine cpu-fast --workers <n>
|
||||
- cargo run -p miner-cli -- --engine cpu --workers <n>
|
||||
- Check logs and metrics if enabled (see below)
|
||||
|
||||
Notes:
|
||||
@@ -138,15 +138,14 @@ If PRs introduce new feature flags or targets, document how CI should build them
|
||||
## Engine selection policy (naming and runtime)
|
||||
|
||||
Naming:
|
||||
- CPU engines use Cpu-prefixed variants in the CLI to avoid future collisions and clarify behavior:
|
||||
- cpu-baseline, cpu-fast, cpu-chain-manipulator
|
||||
- GPU placeholders are exposed for UX and planning but currently unimplemented:
|
||||
- gpu-cuda, gpu-opencl
|
||||
- CPU engines use simple, clear names:
|
||||
- cpu, cpu-chain-manipulator
|
||||
- GPU engine is implemented and functional:
|
||||
- gpu for cross-platform GPU mining using WGPU
|
||||
|
||||
Runtime behavior:
|
||||
- If a GPU engine is selected, the service logs a clear error and exits non-zero:
|
||||
- “engine 'gpu-cuda' is not implemented yet; use cpu-fast or cpu-baseline.”
|
||||
- Reviewers expect this behavior to remain until real GPU engines are implemented.
|
||||
- All engines are fully implemented and production-ready
|
||||
- GPU engine automatically detects and optimizes for available hardware
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
openapi: 3.0.0
|
||||
info:
|
||||
title: Quantus External Miner API
|
||||
version: 0.1.0
|
||||
description: API for managing QPoW mining jobs for the Resonance Network external miner service.
|
||||
|
||||
paths:
|
||||
/mine:
|
||||
post:
|
||||
summary: Submit a new mining job
|
||||
description: Accepts a mining job request and adds it to the queue if valid.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Job accepted successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningResponseAccepted'
|
||||
'400':
|
||||
description: Invalid request (e.g., missing fields, invalid format).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningResponseError'
|
||||
'409':
|
||||
description: Conflict (e.g., duplicate job ID).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningResponseError'
|
||||
|
||||
/result/{job_id}:
|
||||
get:
|
||||
summary: Get mining job status and result
|
||||
description: Retrieves the current status and potentially the found nonce for a given job ID.
|
||||
parameters:
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
description: The ID of the mining job to retrieve.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Job status retrieved successfully (could be running, completed, or failed).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningResult'
|
||||
'404':
|
||||
description: Job ID not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningResultNotFound'
|
||||
|
||||
/cancel/{job_id}:
|
||||
post:
|
||||
summary: Cancel a mining job
|
||||
description: Attempts to cancel an ongoing mining job.
|
||||
parameters:
|
||||
- name: job_id
|
||||
in: path
|
||||
required: true
|
||||
description: The ID of the mining job to cancel.
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Job cancelled successfully.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningResponseCancelled'
|
||||
'404':
|
||||
description: Job ID not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MiningResponseNotFound'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
ApiResponseStatus:
|
||||
type: string
|
||||
description: Status code indicating the result of an API call or the state of a mining job.
|
||||
enum:
|
||||
- accepted
|
||||
- running
|
||||
- completed
|
||||
- failed
|
||||
- cancelled
|
||||
- not_found
|
||||
- error
|
||||
example: running
|
||||
|
||||
MiningRequest:
|
||||
type: object
|
||||
required:
|
||||
- job_id
|
||||
- mining_hash
|
||||
- difficulty
|
||||
- nonce_start
|
||||
- nonce_end
|
||||
properties:
|
||||
job_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: Unique identifier for the mining job (UUID format recommended).
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
mining_hash:
|
||||
type: string
|
||||
description: The header hash to mine on (hex encoded, 64 characters, no 0x).
|
||||
example: "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0"
|
||||
pattern: '^[a-fA-F0-9]{64}$'
|
||||
difficulty:
|
||||
type: string
|
||||
description: Target difficulty for the mining job (string representation of a u64).
|
||||
example: "10000000000"
|
||||
nonce_start:
|
||||
type: string
|
||||
description: Starting nonce value (hex encoded, 128 characters, no 0x).
|
||||
example: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
|
||||
pattern: '^[a-fA-F0-9]{128}$'
|
||||
nonce_end:
|
||||
type: string
|
||||
description: Ending nonce value (hex encoded, 128 characters, no 0x).
|
||||
example: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
||||
pattern: '^[a-fA-F0-9]{128}$'
|
||||
|
||||
MiningResponseBase:
|
||||
type: object
|
||||
required:
|
||||
- status
|
||||
- job_id
|
||||
properties:
|
||||
status:
|
||||
$ref: '#/components/schemas/ApiResponseStatus'
|
||||
job_id:
|
||||
type: string
|
||||
format: uuid
|
||||
description: The ID of the job associated with the response.
|
||||
example: "123e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
MiningResponseAccepted:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/MiningResponseBase'
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
example: accepted
|
||||
|
||||
MiningResponseCancelled:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/MiningResponseBase'
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
example: cancelled
|
||||
|
||||
MiningResponseError:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/MiningResponseBase'
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
example: error
|
||||
message:
|
||||
type: string
|
||||
description: Details about the error that occurred.
|
||||
example: "Job already exists"
|
||||
|
||||
MiningResponseNotFound:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/MiningResponseBase'
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
example: not_found
|
||||
|
||||
MiningResult:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/MiningResponseBase'
|
||||
- type: object
|
||||
required:
|
||||
- hash_count
|
||||
- elapsed_time
|
||||
properties:
|
||||
nonce:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The current or winning nonce (U512 hex, no 0x).
|
||||
example: "1a2b3c"
|
||||
pattern: '^[a-fA-F0-9]+$'
|
||||
work:
|
||||
type: string
|
||||
nullable: true
|
||||
description: The winning nonce ([u8; 64] hex, 128 chars, no 0x). Only present if status is 'completed'.
|
||||
example: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a2b3c"
|
||||
pattern: '^[a-fA-F0-9]{128}$'
|
||||
hash_count:
|
||||
type: integer
|
||||
format: uint64
|
||||
description: Number of nonces checked so far for this job.
|
||||
example: 123456
|
||||
elapsed_time:
|
||||
type: number
|
||||
format: double
|
||||
description: Time elapsed (in seconds) since the job started.
|
||||
example: 15.732
|
||||
|
||||
MiningResultNotFound:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/MiningResponseBase'
|
||||
- type: object
|
||||
properties:
|
||||
status:
|
||||
example: not_found
|
||||
nonce:
|
||||
type: string
|
||||
nullable: true
|
||||
example: null
|
||||
work:
|
||||
type: string
|
||||
nullable: true
|
||||
example: null
|
||||
hash_count:
|
||||
type: integer
|
||||
format: uint64
|
||||
example: 0
|
||||
elapsed_time:
|
||||
type: number
|
||||
format: double
|
||||
example: 0.0
|
||||
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run the same clippy command as CI (see .github/workflows/ci.yml)
|
||||
set -euo pipefail
|
||||
|
||||
cargo +nightly fmt
|
||||
taplo format
|
||||
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
@@ -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 }
|
||||
Executable
+60
@@ -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"
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -15,15 +15,11 @@ default = ["baseline"]
|
||||
|
||||
# Map engine features to pow-core features so engine-cpu consumers can toggle them.
|
||||
baseline = ["pow-core/baseline"]
|
||||
montgomery = ["pow-core/montgomery"]
|
||||
simd-poseidon2 = ["pow-core/simd-poseidon2"]
|
||||
|
||||
[dependencies]
|
||||
pow-core = { path = "../pow-core" }
|
||||
primitive-types = { workspace = true }
|
||||
log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hex = { workspace = true }
|
||||
|
||||
@@ -1,31 +1,32 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use engine_cpu::{FastCpuEngine, MinerEngine, Range};
|
||||
use engine_cpu::{AtomicBoolCancelCheck, FastCpuEngine, MinerEngine, Range};
|
||||
use pow_core::{hash_from_nonce, JobContext};
|
||||
use primitive_types::U512;
|
||||
use rand::RngCore;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn bench_cpu_fast_engine(c: &mut Criterion) {
|
||||
// Create the engine
|
||||
let engine = FastCpuEngine::new();
|
||||
// Create the engine with batch size of 10000
|
||||
let engine = FastCpuEngine::new(10_000);
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
let large_range = Range {
|
||||
start: U512::from(1000u64),
|
||||
end: U512::from(101000u64), // 100,000 nonces
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(100000u64), // 100,000 nonces
|
||||
};
|
||||
|
||||
c.bench_function("cpu_fast_large_range", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(1000u64);
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(10_000_000u64);
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(large_range.clone()),
|
||||
black_box(&cancel_flag),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
@@ -35,7 +36,7 @@ fn bench_cpu_fast_engine(c: &mut Criterion) {
|
||||
fn bench_hash_from_nonce(c: &mut Criterion) {
|
||||
// Create a test job context
|
||||
let mut header = [0u8; 32];
|
||||
rand::thread_rng().fill_bytes(&mut header);
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(1000u64);
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
|
||||
+97
-265
@@ -1,23 +1,14 @@
|
||||
#![deny(rust_2018_idioms)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! CPU mining engine scaffolding and trait definition.
|
||||
//! CPU mining engine for Quantus.
|
||||
//!
|
||||
//! This crate defines a minimal `MinerEngine` trait so the service layer can
|
||||
//! orchestrate mining without knowing about specific math/device details,
|
||||
//! plus a baseline CPU implementation that uses the reference path in `pow-core`.
|
||||
//!
|
||||
//! The baseline engine performs a straightforward linear scan across an inclusive
|
||||
//! nonce range, computing distance per nonce using the context (header-derived)
|
||||
//! constants. It is intended as a correctness reference and may be replaced at
|
||||
//! runtime by faster engines (e.g., incremental CPU path, CUDA/OpenCL engines).
|
||||
//! This crate defines the `MinerEngine` trait for mining orchestration,
|
||||
//! plus a fast CPU implementation using optimized paths from `pow-core`.
|
||||
|
||||
use core::cmp::Ordering;
|
||||
|
||||
use pow_core::{is_valid_nonce, JobContext};
|
||||
use pow_core::JobContext;
|
||||
use primitive_types::U512;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
|
||||
use std::time::Duration;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
|
||||
/// An inclusive nonce range to search.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -44,13 +35,6 @@ pub enum FoundOrigin {
|
||||
}
|
||||
|
||||
/// Status from an engine search.
|
||||
///
|
||||
/// For synchronous `search_range` calls, the final outcome will be one of:
|
||||
/// - `Found`
|
||||
/// - `Exhausted`
|
||||
/// - `Cancelled`
|
||||
///
|
||||
/// `Running` is included for potential future async/streaming engines.
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum EngineStatus {
|
||||
Running {
|
||||
@@ -67,140 +51,120 @@ pub enum EngineStatus {
|
||||
Cancelled {
|
||||
hash_count: u64,
|
||||
},
|
||||
/// GPU device lost or unresponsive. Worker should exit permanently.
|
||||
DeviceLost {
|
||||
hash_count: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// Cancellation checker passed to search_range.
|
||||
/// Returns true if the search should be cancelled.
|
||||
pub trait CancelCheck: Send + Sync {
|
||||
fn is_cancelled(&self) -> bool;
|
||||
}
|
||||
|
||||
/// Simple cancel check using an AtomicBool flag.
|
||||
/// Useful for benchmarks and simple scenarios.
|
||||
pub struct AtomicBoolCancelCheck<'a>(pub &'a AtomicBool);
|
||||
|
||||
impl CancelCheck for AtomicBoolCancelCheck<'_> {
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.0.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel check using job ID comparison.
|
||||
/// When a new job starts, the current_job_id is incremented.
|
||||
/// Workers compare their job_id against the current to detect cancellation.
|
||||
pub struct JobIdCancelCheck<'a> {
|
||||
pub current_job_id: &'a AtomicU64,
|
||||
pub my_job_id: u64,
|
||||
}
|
||||
|
||||
impl CancelCheck for JobIdCancelCheck<'_> {
|
||||
fn is_cancelled(&self) -> bool {
|
||||
self.current_job_id.load(Ordering::Relaxed) != self.my_job_id
|
||||
}
|
||||
}
|
||||
|
||||
/// Abstract mining engine interface.
|
||||
///
|
||||
/// The service layer depends only on this trait to manage jobs.
|
||||
/// Different engines (baseline CPU, optimized CPU, CUDA, OpenCL) can implement
|
||||
/// this trait and be selected at runtime via configuration.
|
||||
/// Different engines (CPU, CUDA, OpenCL) can implement this trait.
|
||||
pub trait MinerEngine: Send + Sync {
|
||||
/// Human-readable engine name (for logs/metrics).
|
||||
/// Human-readable engine name (for logs).
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Prepare a precomputed context for a job (header + difficulty).
|
||||
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext;
|
||||
|
||||
/// Search an inclusive nonce range with cancellation support.
|
||||
///
|
||||
/// Implementations should:
|
||||
/// - Respect `cancel` promptly to minimize wasted work after a solution is found.
|
||||
/// - Return `Found` with a `Candidate` on success.
|
||||
/// - Return `Exhausted` if the range is fully searched without a solution.
|
||||
/// - Return `Cancelled` if `cancel` was observed during the search.
|
||||
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus;
|
||||
fn search_range(
|
||||
&self,
|
||||
ctx: &JobContext,
|
||||
range: Range,
|
||||
cancel: &dyn CancelCheck,
|
||||
) -> EngineStatus;
|
||||
|
||||
/// Enable downcasting to concrete engine types.
|
||||
fn as_any(&self) -> &dyn std::any::Any;
|
||||
}
|
||||
|
||||
/// Baseline CPU engine.
|
||||
///
|
||||
/// This implementation uses the reference path from `pow-core` for distance
|
||||
/// computation and scans the range linearly, one nonce at a time.
|
||||
#[derive(Default)]
|
||||
pub struct BaselineCpuEngine;
|
||||
|
||||
impl BaselineCpuEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
/// Fast CPU engine using optimized pow-core helpers.
|
||||
pub struct FastCpuEngine {
|
||||
/// How often to check for cancellation (in hashes)
|
||||
batch_size: u64,
|
||||
}
|
||||
|
||||
impl MinerEngine for BaselineCpuEngine {
|
||||
fn name(&self) -> &'static str {
|
||||
"cpu-baseline"
|
||||
}
|
||||
|
||||
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
|
||||
JobContext::new(header_hash, difficulty)
|
||||
}
|
||||
|
||||
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
|
||||
// Ensure start <= end (inclusive range). If not, treat as exhausted.
|
||||
if range.start > range.end {
|
||||
return EngineStatus::Exhausted { hash_count: 0 };
|
||||
}
|
||||
|
||||
let mut current = range.start;
|
||||
let mut hash_count: u64 = 0;
|
||||
|
||||
loop {
|
||||
// Cancellation check
|
||||
if cancel.load(AtomicOrdering::Relaxed) {
|
||||
return EngineStatus::Cancelled { hash_count };
|
||||
}
|
||||
|
||||
// Compute hash for this nonce using Bitcoin-style double Poseidon2
|
||||
let (is_valid, hash) =
|
||||
is_valid_nonce(ctx.header, current.to_big_endian(), ctx.difficulty);
|
||||
hash_count = hash_count.saturating_add(1);
|
||||
|
||||
// Check if it meets difficulty target
|
||||
if is_valid {
|
||||
let work = current.to_big_endian();
|
||||
let candidate = Candidate {
|
||||
nonce: current,
|
||||
work,
|
||||
hash,
|
||||
};
|
||||
return EngineStatus::Found {
|
||||
candidate,
|
||||
hash_count,
|
||||
origin: FoundOrigin::Cpu,
|
||||
};
|
||||
}
|
||||
|
||||
// Advance or finish
|
||||
match current.cmp(&range.end) {
|
||||
Ordering::Less => {
|
||||
current = current.saturating_add(U512::one());
|
||||
}
|
||||
_ => {
|
||||
// End of inclusive range reached
|
||||
break EngineStatus::Exhausted { hash_count };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export commonly used items for convenience by consumers.
|
||||
|
||||
// Fast CPU engine using incremental pow-core helpers (init_worker_y0 + step_mul)
|
||||
#[derive(Default)]
|
||||
pub struct FastCpuEngine;
|
||||
|
||||
impl FastCpuEngine {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
pub fn new(batch_size: u64) -> Self {
|
||||
Self {
|
||||
batch_size: batch_size.max(1), // Ensure at least 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MinerEngine for FastCpuEngine {
|
||||
fn name(&self) -> &'static str {
|
||||
"cpu-fast"
|
||||
"cpu"
|
||||
}
|
||||
|
||||
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
|
||||
JobContext::new(header_hash, difficulty)
|
||||
}
|
||||
|
||||
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn search_range(
|
||||
&self,
|
||||
ctx: &JobContext,
|
||||
range: Range,
|
||||
cancel: &dyn CancelCheck,
|
||||
) -> EngineStatus {
|
||||
use pow_core::{hash_from_nonce, is_valid_hash, step_nonce};
|
||||
|
||||
// Ensure start <= end (inclusive range). If not, treat as exhausted.
|
||||
if range.start > range.end {
|
||||
return EngineStatus::Exhausted { hash_count: 0 };
|
||||
}
|
||||
|
||||
let mut current = range.start;
|
||||
let mut hash_count: u64 = 0;
|
||||
// Use decrementing counter to avoid modulo division in hot loop
|
||||
// Initialize to 0 so we check cancellation immediately on first iteration
|
||||
let mut until_check: u64 = 0;
|
||||
|
||||
loop {
|
||||
// Cancellation check
|
||||
if cancel.load(AtomicOrdering::Relaxed) {
|
||||
return EngineStatus::Cancelled { hash_count };
|
||||
// Check for cancellation every batch_size hashes
|
||||
if until_check == 0 {
|
||||
if cancel.is_cancelled() {
|
||||
return EngineStatus::Cancelled { hash_count };
|
||||
}
|
||||
until_check = self.batch_size;
|
||||
}
|
||||
until_check -= 1;
|
||||
|
||||
// Compute hash using Bitcoin-style double Poseidon2
|
||||
let hash = hash_from_nonce(ctx, current);
|
||||
hash_count = hash_count.saturating_add(1);
|
||||
|
||||
@@ -221,7 +185,6 @@ impl MinerEngine for FastCpuEngine {
|
||||
break;
|
||||
}
|
||||
|
||||
// Advance to next nonce
|
||||
current = step_nonce(current);
|
||||
}
|
||||
|
||||
@@ -229,136 +192,8 @@ impl MinerEngine for FastCpuEngine {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct ChainManipulatorEngine {
|
||||
/// Base sleep per batch in nanoseconds; actual sleep increases linearly with job count.
|
||||
pub base_delay_ns: u64,
|
||||
/// Number of nonce attempts between sleeps.
|
||||
pub step_batch: u64,
|
||||
/// Optional cap for solved-block throttling (sleep index will not exceed this).
|
||||
pub throttle_cap: Option<u64>,
|
||||
/// Monotonically increasing solved-block counter used to scale throttling.
|
||||
/// Public so the service can initialize from CLI (pick up where we left off).
|
||||
pub job_index: AtomicU64,
|
||||
}
|
||||
|
||||
impl ChainManipulatorEngine {
|
||||
pub fn new() -> Self {
|
||||
// Start fast: first block has no throttle (job_index = 0 -> 0ns sleep),
|
||||
// then 0.5ms per batch at block 1, 1.0ms at block 2, etc.
|
||||
Self {
|
||||
base_delay_ns: 500_000, // 0.5 ms
|
||||
step_batch: 10_000, // sleep every 10k nonce checks
|
||||
throttle_cap: None, // unlimited by default
|
||||
job_index: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MinerEngine for ChainManipulatorEngine {
|
||||
fn name(&self) -> &'static str {
|
||||
"cpu-chain-manipulator"
|
||||
}
|
||||
|
||||
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
|
||||
// Per-block throttling: do NOT increment here. We increment on Found (i.e., when a block is solved).
|
||||
let ctx = JobContext::new(header_hash, difficulty);
|
||||
// Debug: log current throttle state at job start
|
||||
log::debug!(
|
||||
target: "miner",
|
||||
"manipulator throttle start: solved_blocks={}, sleep_ns_per_batch={}, step_batch={}",
|
||||
self.job_index.load(AtomicOrdering::Relaxed),
|
||||
self.base_delay_ns.saturating_mul(self.job_index.load(AtomicOrdering::Relaxed)),
|
||||
self.step_batch
|
||||
);
|
||||
ctx
|
||||
}
|
||||
|
||||
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
|
||||
use pow_core::{hash_from_nonce, is_valid_hash, step_nonce};
|
||||
|
||||
if range.start > range.end {
|
||||
return EngineStatus::Exhausted { hash_count: 0 };
|
||||
}
|
||||
|
||||
// Bitcoin-style hashing path
|
||||
let mut current = range.start;
|
||||
let mut hash_count: u64 = 0;
|
||||
let mut batch_counter: u64 = 0;
|
||||
|
||||
// Per-block throttling: derived from blocks solved so far (apply cap if configured).
|
||||
let mut solved_blocks = self.job_index.load(AtomicOrdering::Relaxed);
|
||||
if let Some(cap) = self.throttle_cap {
|
||||
if solved_blocks > cap {
|
||||
solved_blocks = cap;
|
||||
}
|
||||
}
|
||||
let sleep_ns = self.base_delay_ns.saturating_mul(solved_blocks);
|
||||
let do_sleep = sleep_ns > 0;
|
||||
|
||||
loop {
|
||||
if cancel.load(AtomicOrdering::Relaxed) {
|
||||
return EngineStatus::Cancelled { hash_count };
|
||||
}
|
||||
|
||||
// Compute hash using Bitcoin-style double Poseidon2
|
||||
let hash = hash_from_nonce(ctx, current);
|
||||
hash_count = hash_count.saturating_add(1);
|
||||
batch_counter = batch_counter.saturating_add(1);
|
||||
|
||||
// Optional detailed debug of throttle progression within a job
|
||||
#[allow(unused_variables)]
|
||||
let _dbg_batch = batch_counter;
|
||||
|
||||
if is_valid_hash(ctx, hash) {
|
||||
let work = current.to_big_endian();
|
||||
// Increment solved-block counter so the NEXT block throttles more.
|
||||
let _new_idx = self.job_index.fetch_add(1, AtomicOrdering::Relaxed) + 1;
|
||||
{
|
||||
let capped = if let Some(cap) = self.throttle_cap {
|
||||
std::cmp::min(_new_idx, cap)
|
||||
} else {
|
||||
_new_idx
|
||||
};
|
||||
log::debug!(
|
||||
target: "miner",
|
||||
"manipulator throttle increment: solved_blocks={} (next sleep_ns_per_batch={}, cap={:?})",
|
||||
_new_idx,
|
||||
self.base_delay_ns.saturating_mul(capped),
|
||||
self.throttle_cap
|
||||
);
|
||||
}
|
||||
return EngineStatus::Found {
|
||||
candidate: Candidate {
|
||||
nonce: current,
|
||||
work,
|
||||
hash,
|
||||
},
|
||||
hash_count,
|
||||
origin: FoundOrigin::Cpu,
|
||||
};
|
||||
}
|
||||
|
||||
// Throttle after each batch to artificially slow down based on blocks solved so far.
|
||||
if do_sleep && batch_counter >= self.step_batch {
|
||||
std::thread::sleep(Duration::from_nanos(sleep_ns));
|
||||
batch_counter = 0;
|
||||
}
|
||||
|
||||
// Advance
|
||||
if current < range.end {
|
||||
current = step_nonce(current);
|
||||
} else {
|
||||
break EngineStatus::Exhausted { hash_count };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub use {
|
||||
BaselineCpuEngine as DefaultEngine, Candidate as EngineCandidate,
|
||||
ChainManipulatorEngine as ChainEngine, FastCpuEngine as FastEngine, Range as EngineRange,
|
||||
};
|
||||
// Re-exports for convenience
|
||||
pub use {Candidate as EngineCandidate, Range as EngineRange};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -366,33 +201,26 @@ mod tests {
|
||||
use primitive_types::U512;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn make_ctx() -> JobContext {
|
||||
let header = [1u8; 32];
|
||||
let difficulty = U512::from(1u64); // easy difficulty for "found" parity test
|
||||
JobContext::new(header, difficulty)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_returns_exhausted_when_no_solution_in_range() {
|
||||
// Use a very hard difficulty to make solutions effectively impossible in a tiny range.
|
||||
let header = [2u8; 32];
|
||||
let difficulty = U512::MAX;
|
||||
let difficulty = U512::MAX; // impossible difficulty
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let range = Range {
|
||||
start: U512::from(1u64),
|
||||
end: U512::from(1000u64), // small range; probability of accidental match is negligible
|
||||
end: U512::from(1000u64),
|
||||
};
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
let baseline = BaselineCpuEngine::new();
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel);
|
||||
let engine = FastCpuEngine::new(1000); // check every 1000 hashes
|
||||
|
||||
let status = baseline.search_range(&ctx, range.clone(), &cancel);
|
||||
let status = engine.search_range(&ctx, range.clone(), &cancel_check);
|
||||
match status {
|
||||
EngineStatus::Exhausted { hash_count } => {
|
||||
// Inclusive range length = end - start + 1
|
||||
let expected = (range.end - range.start + U512::one()).as_u64();
|
||||
assert_eq!(hash_count, expected, "hash_count should equal range length");
|
||||
assert_eq!(hash_count, expected);
|
||||
}
|
||||
other => panic!("expected Exhausted, got {other:?}"),
|
||||
}
|
||||
@@ -400,19 +228,23 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn engine_respects_immediate_cancellation() {
|
||||
let ctx = make_ctx();
|
||||
let header = [1u8; 32];
|
||||
let difficulty = U512::from(1u64);
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let range = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(1_000_000u64),
|
||||
};
|
||||
let cancel = AtomicBool::new(true); // cancelled before starting
|
||||
let baseline = BaselineCpuEngine::new();
|
||||
|
||||
let status = baseline.search_range(&ctx, range, &cancel);
|
||||
let cancel = AtomicBool::new(true); // pre-cancelled
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel);
|
||||
let engine = FastCpuEngine::new(1); // check every hash for immediate cancellation
|
||||
|
||||
let status = engine.search_range(&ctx, range, &cancel_check);
|
||||
match status {
|
||||
EngineStatus::Cancelled { hash_count } => {
|
||||
// Cancellation was pre-set; allow zero or near-zero work depending on timing.
|
||||
assert_eq!(hash_count, 0, "expected no work when cancelled immediately");
|
||||
assert_eq!(hash_count, 0);
|
||||
}
|
||||
other => panic!("expected Cancelled, got {other:?}"),
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,876 @@
|
||||
// lair: Poseidon2-over-Goldilocks mining kernel, native CUDA (quantus/miner#3).
|
||||
//
|
||||
// Same host contract as engine-gpu's mining_u64.wgsl, and bit-exact with it and
|
||||
// with pow_core:
|
||||
// - the host precomputes the sponge midstate after absorbing the 32-byte
|
||||
// header and the high 32 bytes of the big-endian nonce (pow_core::
|
||||
// mining_midstate), so each nonce costs 2 permutations instead of 5;
|
||||
// - a batch never carries into the high 256 bits of the nonce, so only the
|
||||
// low 8 u32 limbs are incremented here;
|
||||
// - the 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 such candidates pay for the second squeeze.
|
||||
//
|
||||
// What CUDA buys over WGSL: a 64x64 -> 128-bit multiply is `__umul64hi` plus a
|
||||
// plain multiply, instead of four 32-bit partial products with carry
|
||||
// reconstruction. The field multiply is the whole cost of this hash.
|
||||
//
|
||||
// Field elements are kept in lazy (non-canonical) form below 2^64, exactly as
|
||||
// the WGSL kernel does, and canonicalised only when bytes are produced.
|
||||
|
||||
#include <stdint.h>
|
||||
#include "poseidon2_constants.cuh"
|
||||
|
||||
typedef unsigned long long u64;
|
||||
typedef unsigned int u32;
|
||||
|
||||
#define P64 0xFFFFFFFF00000001ull
|
||||
// 2^64 mod P = 2^32 - 1
|
||||
#define EPS 0xFFFFFFFFull
|
||||
|
||||
// lair: whether gf_add and gf_reduce should use inline-PTX carry arithmetic or
|
||||
// plain C is architecture-dependent, and the difference is large in both
|
||||
// directions. An `asm` block is opaque to nvcc's optimiser, so carry arithmetic
|
||||
// written as PTX blocks common-subexpression elimination and strength reduction
|
||||
// across neighbouring field operations. On sm_120 that costs a quarter of the
|
||||
// kernel; on sm_86/89 the hand-written sequence still wins.
|
||||
//
|
||||
// Whole-kernel instructions / ALU ops (ptxas + cuobjdump, CUDA 13.0) and
|
||||
// measured hashrate, three interleaved rounds each, parity 40/40 vs CPU:
|
||||
//
|
||||
// sm_120 sm_86/89
|
||||
// CARRY=1 37,432 / 26,688 1126.6 37,027 benjy 461.6 quadbrat 85.5
|
||||
// CARRY=0 27,837 / 19,519 1333.3 41,196 benjy 401.5 quadbrat 76.3
|
||||
//
|
||||
// So +18.3% on the 5090s and -13.0% / -10.8% on the 4090 and 3060. Hence the
|
||||
// per-arch default. Unlike LAIR_INT_UNROLL -- where the static measures pointed
|
||||
// one way and the cards' power envelope the other -- this one was measured on
|
||||
// hardware on all three architectures, and the static count predicted the sign
|
||||
// correctly on each.
|
||||
//
|
||||
// LAIR_PTX_ACC stays 1 everywhere: in the Acc accumulators the add-with-carry
|
||||
// pair is the whole operation, so there is nothing around it to optimise. On
|
||||
// sm_120, carry off with Acc off measured 1285.8 against 1333.3 with Acc on.
|
||||
//
|
||||
// PR #17 measured the PTX carry path as a win on every card, and that was true
|
||||
// of the kernel it was written for -- which spilled 104 bytes per thread. Once
|
||||
// the spills went (see MiningUniforms) the trade reversed on Blackwell only.
|
||||
// Re-measure per architecture before touching this.
|
||||
#ifndef LAIR_PTX_CARRY
|
||||
# if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1200
|
||||
# define LAIR_PTX_CARRY 0
|
||||
# else
|
||||
# define LAIR_PTX_CARRY 1
|
||||
# endif
|
||||
#endif
|
||||
#ifndef LAIR_PTX_ACC
|
||||
#define LAIR_PTX_ACC 1
|
||||
#endif
|
||||
// Fold the next round's constant into the linear layer's final reduction
|
||||
// instead of a separate gf_add per element before the S-box.
|
||||
#ifndef LAIR_FOLD_RC
|
||||
#define LAIR_FOLD_RC 1
|
||||
#endif
|
||||
|
||||
#if LAIR_PTX_CARRY
|
||||
// Carry-flag arithmetic: the wrap is read from the condition code instead of
|
||||
// a compare, and folded with a multiply-add (c * EPS is exact for c in {0,1}).
|
||||
__device__ __forceinline__ u64 gf_add(u64 a, u64 b) {
|
||||
u64 s0, c1, s1, c2;
|
||||
asm("add.cc.u64 %0, %2, %3;\n\taddc.u64 %1, 0, 0;" : "=l"(s0), "=l"(c1) : "l"(a), "l"(b));
|
||||
asm("add.cc.u64 %0, %2, %3;\n\taddc.u64 %1, 0, 0;" : "=l"(s1), "=l"(c2) : "l"(s0), "l"(c1 * EPS));
|
||||
return s1 + c2 * EPS;
|
||||
}
|
||||
|
||||
__device__ __forceinline__ u64 gf_reduce(u64 lo, u64 hi) {
|
||||
u64 hi_hi = hi >> 32;
|
||||
u64 hi_lo = hi & EPS;
|
||||
u64 t0, bm;
|
||||
// bm = -borrow: all ones when lo < hi_hi.
|
||||
asm("sub.cc.u64 %0, %2, %3;\n\tsubc.u64 %1, 0, 0;" : "=l"(t0), "=l"(bm) : "l"(lo), "l"(hi_hi));
|
||||
t0 -= (bm & EPS);
|
||||
u64 t1 = hi_lo * EPS;
|
||||
u64 t2, c;
|
||||
asm("add.cc.u64 %0, %2, %3;\n\taddc.u64 %1, 0, 0;" : "=l"(t2), "=l"(c) : "l"(t0), "l"(t1));
|
||||
return t2 + c * EPS;
|
||||
}
|
||||
#else
|
||||
// a + b mod P, lazy. A wrapped carry folds back as 2^64 = EPS (mod P); the
|
||||
// second fold can only be needed when the first one wrapped.
|
||||
__device__ __forceinline__ u64 gf_add(u64 a, u64 b) {
|
||||
u64 s0 = a + b;
|
||||
u64 c1 = s0 < a;
|
||||
u64 s1 = s0 + (c1 ? EPS : 0ull);
|
||||
u64 c2 = c1 & (s1 < s0);
|
||||
return s1 + (c2 ? EPS : 0ull);
|
||||
}
|
||||
|
||||
// Reduce lo + hi * 2^64 mod P using 2^64 = EPS and 2^96 = -1 (mod P).
|
||||
__device__ __forceinline__ u64 gf_reduce(u64 lo, u64 hi) {
|
||||
u64 hi_hi = hi >> 32;
|
||||
u64 hi_lo = hi & EPS;
|
||||
u64 t0 = lo - hi_hi;
|
||||
if (lo < hi_hi) t0 -= EPS;
|
||||
u64 t1 = hi_lo * EPS;
|
||||
u64 t2 = t0 + t1;
|
||||
if (t2 < t0) t2 += EPS;
|
||||
return t2;
|
||||
}
|
||||
#endif
|
||||
|
||||
// lair: field arithmetic as self-contained PTX blocks (quantus/miner#3).
|
||||
//
|
||||
// Measured against the closed-source qpow-cuda 1.0.7 kernel on the same
|
||||
// silicon: its multiply-plus-reduce is 11 SASS instructions on sm_120 and a
|
||||
// squaring 10, against ~14 per multiply here, and it folds the internal
|
||||
// layer's row sum into the diagonal multiply's addend for free. The whole
|
||||
// difference is in how the 128-bit product is reduced: `mad.lo.cc.u32` +
|
||||
// `madc.hi.cc.u32` become one IMAD.WIDE.U32 with a carry-out predicate, and
|
||||
// the 2^96 = -1 term is folded with three more ALU ops. Each block below is
|
||||
// one complete field operation, so there is nothing across its boundary for
|
||||
// nvcc to optimise -- unlike LAIR_PTX_CARRY, whose fragments starved the
|
||||
// optimiser on sm_120.
|
||||
//
|
||||
// LAIR_EXACT_REDUCE: the closed-source kernel drops the final borrow of the
|
||||
// reduction. That is wrong only when the 128-bit product has bits 64..95 all
|
||||
// zero and its low 64 bits below its top 32 bits: about 2^-64 of multiplies
|
||||
// with field-element inputs (verified by emulating the chain against a
|
||||
// reference multiply: 0 mismatches in 3M random products, and the constructed
|
||||
// product 2^48 * 2^48 = 2^96 does fail). The other apparent hazard, the
|
||||
// 32-bit wrap of hh + carry, cannot occur: hh = 2^32 - 1 needs both operands
|
||||
// within 2^32 of 2^64, and then the low half is too small to carry. So with 0
|
||||
// the kernel computes a wrong hash for about 1472 * 2^-64 ~ 1e-16 of nonces,
|
||||
// which can neither hide a real solution nor pass a false one in practice
|
||||
// (a wrong hash still has to beat the target, 2^-47 at mainnet difficulty,
|
||||
// to be submitted, and the node would reject it); with 1 every hash is
|
||||
// bit-exact, for three more instructions per multiply. Measured on
|
||||
// beast's 5090s, three interleaved rounds: 0 = 1996 MH/s, 1 = 1695 MH/s,
|
||||
// against 1356 for the C reduction -- exactness costs 15%.
|
||||
#ifndef LAIR_FUSED_MUL
|
||||
#define LAIR_FUSED_MUL 1
|
||||
#endif
|
||||
#ifndef LAIR_EXACT_REDUCE
|
||||
#define LAIR_EXACT_REDUCE 0
|
||||
#endif
|
||||
|
||||
#if LAIR_FUSED_MUL
|
||||
// EPS as an operand ptxas cannot see the value of: with an immediate it would
|
||||
// rewrite `x * EPS` into shift-and-subtract, which loses on this hardware.
|
||||
__device__ __constant__ u32 LAIR_EPS32 = 0xFFFFFFFFu;
|
||||
|
||||
// Reduce {ll, lh} + 2^64 * {hl, hh} in place, lazily (result < 2^64, not
|
||||
// canonical), using 2^64 = EPS and 2^96 = -1 (mod P). E is the EPS operand.
|
||||
// {ll,lh} += hl * EPS, carry cy (mad.lo.cc / madc.hi.cc)
|
||||
// c = hh + cy; lh += cy (+cy * 2^32 ...)
|
||||
// {ll,lh} -= c (... - cy - hh = +cy*EPS - hh)
|
||||
#if LAIR_EXACT_REDUCE
|
||||
#define LAIR_REDUCE_PTX(E) \
|
||||
"mad.lo.cc.u32 ll, hl, " E ", ll;\n\t" \
|
||||
"madc.hi.cc.u32 lh, hl, " E ", lh;\n\t" \
|
||||
"addc.u32 c, hh, 0;\n\t" \
|
||||
"addc.u32 lh, lh, 0;\n\t" \
|
||||
"sub.cc.u32 ll, ll, c;\n\t" \
|
||||
"subc.cc.u32 lh, lh, 0;\n\t" \
|
||||
"subc.u32 c, 0, 0;\n\t" \
|
||||
"sub.cc.u32 ll, ll, c;\n\t" \
|
||||
"subc.u32 lh, lh, 0;\n\t"
|
||||
#else
|
||||
#define LAIR_REDUCE_PTX(E) \
|
||||
"mad.lo.cc.u32 ll, hl, " E ", ll;\n\t" \
|
||||
"madc.hi.cc.u32 lh, hl, " E ", lh;\n\t" \
|
||||
"addc.u32 c, hh, 0;\n\t" \
|
||||
"addc.u32 lh, lh, 0;\n\t" \
|
||||
"sub.cc.u32 ll, ll, c;\n\t" \
|
||||
"subc.u32 lh, lh, 0;\n\t"
|
||||
#endif
|
||||
|
||||
__device__ __forceinline__ u64 gf_mul(u64 a, u64 b) {
|
||||
u64 r;
|
||||
u32 e = LAIR_EPS32;
|
||||
asm("{\n\t"
|
||||
".reg .b32 a0, a1, b0, b1, p0l, p0h, m0, m1, cw, ll, lh, hl, hh, c;\n\t"
|
||||
".reg .b64 p0, m, m2, p3, t, hi;\n\t"
|
||||
"mov.b64 {a0, a1}, %1;\n\t"
|
||||
"mov.b64 {b0, b1}, %2;\n\t"
|
||||
"mul.wide.u32 p0, a0, b0;\n\t"
|
||||
"mul.wide.u32 m, a1, b0;\n\t"
|
||||
"mul.wide.u32 m2, a0, b1;\n\t"
|
||||
"add.cc.u64 m, m, m2;\n\t"
|
||||
"addc.u32 cw, 0, 0;\n\t"
|
||||
"mov.b64 {p0l, p0h}, p0;\n\t"
|
||||
"mov.b64 {m0, m1}, m;\n\t"
|
||||
"mov.b64 t, {m1, cw};\n\t"
|
||||
"mul.wide.u32 p3, a1, b1;\n\t"
|
||||
"add.cc.u32 lh, p0h, m0;\n\t"
|
||||
"addc.u64 hi, p3, t;\n\t"
|
||||
"mov.b64 {hl, hh}, hi;\n\t"
|
||||
"mov.b32 ll, p0l;\n\t"
|
||||
LAIR_REDUCE_PTX("%3")
|
||||
"mov.b64 %0, {ll, lh};\n\t"
|
||||
"}"
|
||||
: "=l"(r) : "l"(a), "l"(b), "r"(e));
|
||||
return r;
|
||||
}
|
||||
|
||||
// a^2 with three 32x32 products: a0^2 + 2*a0*a1*2^32 + a1^2*2^64.
|
||||
__device__ __forceinline__ u64 gf_sqr(u64 a) {
|
||||
u64 r;
|
||||
u32 e = LAIR_EPS32;
|
||||
asm("{\n\t"
|
||||
".reg .b32 a0, a1, p0l, p0h, m2l, m2h, c32, ll, lh, hl, hh, c;\n\t"
|
||||
".reg .b64 p0, m, m2, p3, t, hi;\n\t"
|
||||
"mov.b64 {a0, a1}, %1;\n\t"
|
||||
"mul.wide.u32 p0, a0, a0;\n\t"
|
||||
"mul.wide.u32 m, a0, a1;\n\t"
|
||||
"shl.b64 m2, m, 1;\n\t"
|
||||
"shr.u64 t, m, 63;\n\t"
|
||||
"mov.b64 {m2l, m2h}, m2;\n\t"
|
||||
"mov.b64 {p0l, p0h}, p0;\n\t"
|
||||
"add.cc.u32 lh, p0h, m2l;\n\t"
|
||||
"cvt.u32.u64 c32, t;\n\t"
|
||||
"mov.b64 t, {m2h, c32};\n\t"
|
||||
"mul.wide.u32 p3, a1, a1;\n\t"
|
||||
"addc.u64 hi, p3, t;\n\t"
|
||||
"mov.b64 {hl, hh}, hi;\n\t"
|
||||
"mov.b32 ll, p0l;\n\t"
|
||||
LAIR_REDUCE_PTX("%2")
|
||||
"mov.b64 %0, {ll, lh};\n\t"
|
||||
"}"
|
||||
: "=l"(r) : "l"(a), "r"(e));
|
||||
return r;
|
||||
}
|
||||
|
||||
#else
|
||||
__device__ __forceinline__ u64 gf_mul(u64 a, u64 b) {
|
||||
return gf_reduce(a * b, __umul64hi(a, b));
|
||||
}
|
||||
|
||||
// Squaring deliberately uses the general product. nvcc does not specialise
|
||||
// `a * a`: it emits the same six IMAD.WIDE in 48 instructions as gf_mul
|
||||
// (measured, sm_120). The textbook saving is real but does not pay here --
|
||||
// a = a1*2^32 + a0 gives a^2 = a0^2 + a0*a1*2^33 + a1^2*2^64, three 32x32
|
||||
// multiplies instead of four, and it was implemented, verified bit-exact
|
||||
// against __int128 over 40M values, and measured 5.7% SLOWER on beast's 5090s
|
||||
// (1072.9 vs 1137.7 MH/s, three interleaved rounds). It saves 6.7% of the
|
||||
// kernel's widening multiplies and costs 11% more instructions: 5 multiplies
|
||||
// in 56 instructions against 6 in 48. Four formulations (condition-code
|
||||
// carry, compare carry, and two mad.wide.u32 variants) all compiled to the
|
||||
// same 5/56, so 56 is the floor. The scarce resource in this kernel is
|
||||
// instruction issue, not multiply throughput -- which is also why raising
|
||||
// occupancy lost (see LAIR_INT_UNROLL). Anything that wins here has to remove
|
||||
// work, not re-express it.
|
||||
__device__ __forceinline__ u64 gf_sqr(u64 a) {
|
||||
return gf_reduce(a * a, __umul64hi(a, a));
|
||||
}
|
||||
#endif // LAIR_FUSED_MUL
|
||||
|
||||
// x^7. Writing the four steps as one PTX block in 32-bit halves was tried
|
||||
// (2026-09-13) and compiles to byte-identical SASS: the 64-bit pair moves
|
||||
// between blocks are free, so there is nothing to save here.
|
||||
__device__ __forceinline__ u64 gf_sbox(u64 x) {
|
||||
u64 x2 = gf_sqr(x);
|
||||
u64 x4 = gf_sqr(x2);
|
||||
u64 x6 = gf_mul(x4, x2);
|
||||
return gf_mul(x6, x);
|
||||
}
|
||||
|
||||
__device__ __forceinline__ u64 gf_canon(u64 a) {
|
||||
return a - (a >= P64 ? P64 : 0ull);
|
||||
}
|
||||
|
||||
#ifndef LAIR_LAZY_ADD
|
||||
#define LAIR_LAZY_ADD 1
|
||||
#endif
|
||||
// lair: 2 stays the default on every architecture. Unroll 1 looks better on
|
||||
// sm_120 by every static measure -- 78 registers instead of 106, no spill
|
||||
// either way, a third resident block per SM instead of two -- and measured
|
||||
// 0.92% SLOWER on beast's 5090s (1128.4 vs 1138.8 MH/s, three interleaved
|
||||
// rounds, spread <=0.1%). These cards are power-bound, not latency-bound: at
|
||||
// the 400 W cap the extra resident warps buy power draw, and the card clocks
|
||||
// down to pay for it (2325/2385 MHz against 2355/2415). Occupancy is not the
|
||||
// lever here; work per hash is. Do not "fix" this without a measurement.
|
||||
#ifndef LAIR_INT_UNROLL
|
||||
#define LAIR_INT_UNROLL 2
|
||||
#endif
|
||||
// nvcc does not macro-expand `#pragma unroll N`; stringise through _Pragma.
|
||||
#define LAIR_PRAGMA(x) _Pragma(#x)
|
||||
#define LAIR_UNROLL(n) LAIR_PRAGMA(unroll n)
|
||||
#ifndef LAIR_PTX_CARRY
|
||||
# if defined(__CUDA_ARCH__) && __CUDA_ARCH__ >= 1200
|
||||
# define LAIR_PTX_CARRY 0
|
||||
# else
|
||||
# define LAIR_PTX_CARRY 1
|
||||
# endif
|
||||
#endif
|
||||
#ifndef LAIR_PTX_ACC
|
||||
#define LAIR_PTX_ACC 1
|
||||
#endif
|
||||
// Fold the next round's constant into the linear layer's final reduction
|
||||
// instead of a separate gf_add per element before the S-box.
|
||||
#ifndef LAIR_FOLD_RC
|
||||
#define LAIR_FOLD_RC 1
|
||||
#endif
|
||||
#ifndef LAIR_NOINLINE_PERMUTE
|
||||
#define LAIR_NOINLINE_PERMUTE 0
|
||||
#endif
|
||||
// Threads per block; the host's MINER_CUDA_THREADS_PER_BLOCK must agree.
|
||||
#ifndef LAIR_TPB
|
||||
#define LAIR_TPB 256
|
||||
#endif
|
||||
// Minimum resident blocks per SM the compiler must fit (caps registers at
|
||||
// 65536 / (LAIR_TPB * LAIR_MINBLOCKS)); 0 leaves the register budget to ptxas.
|
||||
// Measured 2026-09-13 on beast against the 110-register default (2205 MH/s):
|
||||
// 64 registers by any route (1024 threads, or 256 x 4, or 512 x 2) spills
|
||||
// 216-264 bytes and loses 5% (2091-2107); 80 registers (256 x 3, 52 bytes of
|
||||
// spill) is neutral (2207). The quanpool kernel runs 1024 threads per SM at
|
||||
// 64 registers without spilling because its rounds are loops with the
|
||||
// constants in constant memory; this unrolled kernel cannot get there.
|
||||
#ifndef LAIR_MINBLOCKS
|
||||
#define LAIR_MINBLOCKS 0
|
||||
#endif
|
||||
#if LAIR_MINBLOCKS
|
||||
#define LAIR_LAUNCH_BOUNDS __launch_bounds__(LAIR_TPB, LAIR_MINBLOCKS)
|
||||
#else
|
||||
#define LAIR_LAUNCH_BOUNDS __launch_bounds__(LAIR_TPB)
|
||||
#endif
|
||||
// Decide most nonces on element 0 of the final state before computing the
|
||||
// rest of the last linear layer: at mainnet difficulty the top ~47 bits of
|
||||
// the hash must be zero, and they live in that element.
|
||||
#ifndef LAIR_EARLY_REJECT
|
||||
#define LAIR_EARLY_REJECT 1
|
||||
#endif
|
||||
// Derive each nonce's state after the first linear layer from the host's
|
||||
// precomputed base state plus one scalar times column 7 of the external
|
||||
// matrix, instead of absorbing and recomputing the layer per nonce. Falls
|
||||
// back to the full path for the nonces past a carry out of the low limb.
|
||||
#ifndef LAIR_NONCE_DIR
|
||||
#define LAIR_NONCE_DIR 1
|
||||
#endif
|
||||
#if LAIR_NONCE_DIR && !(LAIR_LAZY_ADD && LAIR_FOLD_RC && LAIR_FUSED_MUL)
|
||||
#error "LAIR_NONCE_DIR needs LAIR_LAZY_ADD, LAIR_FOLD_RC and LAIR_FUSED_MUL"
|
||||
#endif
|
||||
|
||||
// Deferred-carry accumulator: the value is lo + hi * 2^64 with hi small. Adds
|
||||
// cost an add-with-carry instead of two compare-and-fold steps; the folds are
|
||||
// paid once per output when the accumulator is reduced. Sound as long as
|
||||
// hi < 2^32, which a linear layer's sums (at most a few dozen terms) satisfy.
|
||||
struct Acc {
|
||||
u64 lo;
|
||||
u32 hi;
|
||||
};
|
||||
|
||||
__device__ __forceinline__ Acc acc_of(u64 a) {
|
||||
Acc r; r.lo = a; r.hi = 0u; return r;
|
||||
}
|
||||
|
||||
#if LAIR_PTX_ACC && LAIR_FUSED_MUL
|
||||
// 32-bit limb chains. On sm_120 ptxas turns `add.cc.u64` + `addc.u32` into
|
||||
// two IADD.64 (one for the carry, one for the sum) and a select, five
|
||||
// instructions per add; the 32-bit chain is IADD, IADD.X and one IADD3.X
|
||||
// that ptxas merges with the neighbouring carry, under three.
|
||||
__device__ __forceinline__ Acc acc_add(Acc a, u64 b) {
|
||||
u64 s; u32 c;
|
||||
asm("{\n\t"
|
||||
".reg .b32 al, ah, bl, bh;\n\t"
|
||||
"mov.b64 {al, ah}, %2;\n\t"
|
||||
"mov.b64 {bl, bh}, %3;\n\t"
|
||||
"add.cc.u32 al, al, bl;\n\t"
|
||||
"addc.cc.u32 ah, ah, bh;\n\t"
|
||||
"addc.u32 %1, %4, 0;\n\t"
|
||||
"mov.b64 %0, {al, ah};\n\t"
|
||||
"}"
|
||||
: "=l"(s), "=r"(c) : "l"(a.lo), "l"(b), "r"(a.hi));
|
||||
a.lo = s;
|
||||
a.hi = c;
|
||||
return a;
|
||||
}
|
||||
|
||||
__device__ __forceinline__ Acc acc_add2(Acc a, Acc b) {
|
||||
u64 s; u32 c;
|
||||
asm("{\n\t"
|
||||
".reg .b32 al, ah, bl, bh;\n\t"
|
||||
"mov.b64 {al, ah}, %2;\n\t"
|
||||
"mov.b64 {bl, bh}, %3;\n\t"
|
||||
"add.cc.u32 al, al, bl;\n\t"
|
||||
"addc.cc.u32 ah, ah, bh;\n\t"
|
||||
"addc.u32 %1, %4, %5;\n\t"
|
||||
"mov.b64 %0, {al, ah};\n\t"
|
||||
"}"
|
||||
: "=l"(s), "=r"(c) : "l"(a.lo), "l"(b.lo), "r"(a.hi), "r"(b.hi));
|
||||
a.lo = s;
|
||||
a.hi = c;
|
||||
return a;
|
||||
}
|
||||
#elif LAIR_PTX_ACC
|
||||
__device__ __forceinline__ Acc acc_add(Acc a, u64 b) {
|
||||
u64 s; u32 c;
|
||||
asm("add.cc.u64 %0, %2, %3;\n\taddc.u32 %1, 0, 0;" : "=l"(s), "=r"(c) : "l"(a.lo), "l"(b));
|
||||
a.lo = s;
|
||||
a.hi += c;
|
||||
return a;
|
||||
}
|
||||
|
||||
__device__ __forceinline__ Acc acc_add2(Acc a, Acc b) {
|
||||
u64 s; u32 c;
|
||||
asm("add.cc.u64 %0, %2, %3;\n\taddc.u32 %1, %4, %5;" : "=l"(s), "=r"(c) : "l"(a.lo), "l"(b.lo), "r"(a.hi), "r"(b.hi));
|
||||
a.lo = s;
|
||||
a.hi = c;
|
||||
return a;
|
||||
}
|
||||
#else
|
||||
__device__ __forceinline__ Acc acc_add(Acc a, u64 b) {
|
||||
u64 s = a.lo + b;
|
||||
a.hi += (s < a.lo) ? 1u : 0u;
|
||||
a.lo = s;
|
||||
return a;
|
||||
}
|
||||
|
||||
__device__ __forceinline__ Acc acc_add2(Acc a, Acc b) {
|
||||
u64 s = a.lo + b.lo;
|
||||
a.hi += b.hi + ((s < a.lo) ? 1u : 0u);
|
||||
a.lo = s;
|
||||
return a;
|
||||
}
|
||||
#endif
|
||||
|
||||
#if LAIR_FUSED_MUL
|
||||
// lo + hi * 2^64 = lo + hi * EPS (mod P), one fused reduction. Exact: with
|
||||
// no 2^96 term there is no borrow to drop.
|
||||
__device__ __forceinline__ u64 acc_reduce(Acc a) {
|
||||
u64 r;
|
||||
u32 e = LAIR_EPS32;
|
||||
asm("{\n\t"
|
||||
".reg .b32 ll, lh, c;\n\t"
|
||||
"mov.b64 {ll, lh}, %1;\n\t"
|
||||
"mad.lo.cc.u32 ll, %2, %3, ll;\n\t"
|
||||
"madc.hi.cc.u32 lh, %2, %3, lh;\n\t"
|
||||
"addc.u32 c, 0, 0;\n\t"
|
||||
"addc.u32 lh, lh, 0;\n\t"
|
||||
"sub.cc.u32 ll, ll, c;\n\t"
|
||||
"subc.u32 lh, lh, 0;\n\t"
|
||||
"mov.b64 %0, {ll, lh};\n\t"
|
||||
"}"
|
||||
: "=l"(r) : "l"(a.lo), "r"(a.hi), "r"(e));
|
||||
return r;
|
||||
}
|
||||
|
||||
// a * b + c mod P. The addend rides in the two `mad.wide` partial products
|
||||
// (a0*b0 + c_lo and a1*b0 + c_hi both fit in 64 bits), so it costs nothing.
|
||||
__device__ __forceinline__ u64 gf_mul_add(u64 a, u64 b, u64 c) {
|
||||
u64 r;
|
||||
u32 e = LAIR_EPS32;
|
||||
u64 c0 = c & EPS, c1 = c >> 32;
|
||||
asm("{\n\t"
|
||||
".reg .b32 a0, a1, b0, b1, p0l, p0h, p3l, p3h, m0, m1, cw, ll, lh, hl, hh, c;\n\t"
|
||||
".reg .b64 p0, m, m2, p3;\n\t"
|
||||
"mov.b64 {a0, a1}, %1;\n\t"
|
||||
"mov.b64 {b0, b1}, %2;\n\t"
|
||||
"mad.wide.u32 p0, a0, b0, %3;\n\t"
|
||||
"mad.wide.u32 m, a1, b0, %4;\n\t"
|
||||
"mul.wide.u32 m2, a0, b1;\n\t"
|
||||
"mul.wide.u32 p3, a1, b1;\n\t"
|
||||
"mov.b64 {p0l, p0h}, p0;\n\t"
|
||||
"mov.b64 {p3l, p3h}, p3;\n\t"
|
||||
"add.cc.u64 m, m, m2;\n\t"
|
||||
"addc.u32 cw, p3h, 0;\n\t"
|
||||
"mov.b64 {m0, m1}, m;\n\t"
|
||||
"add.cc.u32 lh, p0h, m0;\n\t"
|
||||
"addc.cc.u32 hl, p3l, m1;\n\t"
|
||||
"addc.u32 hh, cw, 0;\n\t"
|
||||
"mov.b32 ll, p0l;\n\t"
|
||||
LAIR_REDUCE_PTX("%5")
|
||||
"mov.b64 %0, {ll, lh};\n\t"
|
||||
"}"
|
||||
: "=l"(r) : "l"(a), "l"(b), "l"(c0), "l"(c1), "r"(e));
|
||||
return r;
|
||||
}
|
||||
|
||||
// a * b + (c.lo + c.hi * 2^64) mod P: reduce the addend first (4 ops), then
|
||||
// fold it into the product for free.
|
||||
__device__ __forceinline__ u64 gf_mul_add_acc(u64 a, u64 b, Acc c) {
|
||||
return gf_mul_add(a, b, acc_reduce(c));
|
||||
}
|
||||
#else
|
||||
// lo + hi * 2^64 = lo + hi * EPS (mod P); hi * EPS < 2^64, so this is one
|
||||
// lazy gf_add.
|
||||
__device__ __forceinline__ u64 acc_reduce(Acc a) {
|
||||
return gf_add(a.lo, (u64)a.hi * EPS);
|
||||
}
|
||||
|
||||
// a * b + c mod P: the addend is folded into the 128-bit product before the
|
||||
// single reduction, saving a gf_add per term.
|
||||
__device__ __forceinline__ u64 gf_mul_add(u64 a, u64 b, u64 c) {
|
||||
u64 lo = a * b;
|
||||
u64 hi = __umul64hi(a, b);
|
||||
u64 s = lo + c;
|
||||
hi += (s < lo) ? 1ull : 0ull;
|
||||
return gf_reduce(s, hi);
|
||||
}
|
||||
|
||||
// a * b + (c.lo + c.hi * 2^64) mod P, for a deferred-carry addend.
|
||||
__device__ __forceinline__ u64 gf_mul_add_acc(u64 a, u64 b, Acc c) {
|
||||
u64 lo = a * b;
|
||||
u64 hi = __umul64hi(a, b);
|
||||
u64 s = lo + c.lo;
|
||||
hi += ((s < lo) ? 1ull : 0ull) + (u64)c.hi;
|
||||
return gf_reduce(s, hi);
|
||||
}
|
||||
#endif // LAIR_FUSED_MUL
|
||||
|
||||
#if LAIR_LAZY_ADD
|
||||
// External linear layer with deferred carries: every output is a sum of a
|
||||
// handful of inputs, reduced once.
|
||||
// `rc` is the next round's constant row to fold into the outputs, or nullptr.
|
||||
__device__ __forceinline__ void ext_layer_rc(u64 st[12], const u64* rc) {
|
||||
Acc acc[12];
|
||||
#pragma unroll
|
||||
for (int chunk = 0; chunk < 3; chunk++) {
|
||||
int o = chunk * 4;
|
||||
u64 x0 = st[o], x1 = st[o + 1], x2 = st[o + 2], x3 = st[o + 3];
|
||||
Acc t01 = acc_add(acc_of(x0), x1);
|
||||
Acc t23 = acc_add(acc_of(x2), x3);
|
||||
Acc t0123 = acc_add2(t01, t23);
|
||||
Acc t01123 = acc_add(t0123, x1);
|
||||
Acc t01233 = acc_add(t0123, x3);
|
||||
acc[o + 3] = acc_add(acc_add(t01233, x0), x0);
|
||||
acc[o + 1] = acc_add(acc_add(t01123, x2), x2);
|
||||
acc[o] = acc_add2(t01123, t01);
|
||||
acc[o + 2] = acc_add2(t01233, t23);
|
||||
}
|
||||
Acc sums[4];
|
||||
#pragma unroll
|
||||
for (int k = 0; k < 4; k++) {
|
||||
sums[k] = acc_add2(acc_add2(acc[k], acc[k + 4]), acc[k + 8]);
|
||||
}
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) {
|
||||
Acc o = acc_add2(acc[i], sums[i & 3]);
|
||||
if (rc != nullptr) o = acc_add(o, rc[i]);
|
||||
st[i] = acc_reduce(o);
|
||||
}
|
||||
}
|
||||
|
||||
__device__ __forceinline__ void ext_layer(u64 st[12]) { ext_layer_rc(st, nullptr); }
|
||||
|
||||
// Output 0 of the external linear layer alone, inputs untouched: the chunk
|
||||
// sums it needs (about a tenth of the full layer).
|
||||
__device__ __forceinline__ u64 ext_layer_out0(const u64 st[12]) {
|
||||
Acc a[3];
|
||||
#pragma unroll
|
||||
for (int chunk = 0; chunk < 3; chunk++) {
|
||||
int o = chunk * 4;
|
||||
Acc t01 = acc_add(acc_of(st[o]), st[o + 1]);
|
||||
Acc t23 = acc_add(acc_of(st[o + 2]), st[o + 3]);
|
||||
Acc t01123 = acc_add(acc_add2(t01, t23), st[o + 1]);
|
||||
a[chunk] = acc_add2(t01123, t01);
|
||||
}
|
||||
Acc sum0 = acc_add2(acc_add2(a[0], a[1]), a[2]);
|
||||
return acc_reduce(acc_add2(a[0], sum0));
|
||||
}
|
||||
|
||||
// Internal linear layer: one deferred sum, folded into each diagonal multiply.
|
||||
// `rc0` is the next internal round's constant for element 0, folded into that
|
||||
// element's multiply-add; `rc_row` a full row (the first terminal round's).
|
||||
__device__ __forceinline__ void int_layer_rc(u64 st[12], u64 rc0, bool has_rc0, const u64* rc_row) {
|
||||
Acc s = acc_of(st[0]);
|
||||
#pragma unroll
|
||||
for (int i = 1; i < 12; i++) s = acc_add(s, st[i]);
|
||||
u64 sum = acc_reduce(s);
|
||||
if (rc_row != nullptr) {
|
||||
// The constant joins the unreduced sum: one more limb add, then the
|
||||
// same single reduction.
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = gf_mul_add_acc(st[i], MDS_DIAG[i], acc_add(s, rc_row[i]));
|
||||
} else {
|
||||
if (has_rc0) {
|
||||
st[0] = gf_mul_add_acc(st[0], MDS_DIAG[0], acc_add(s, rc0));
|
||||
} else {
|
||||
st[0] = gf_mul_add(st[0], MDS_DIAG[0], sum);
|
||||
}
|
||||
#pragma unroll
|
||||
for (int i = 1; i < 12; i++) st[i] = gf_mul_add(st[i], MDS_DIAG[i], sum);
|
||||
}
|
||||
}
|
||||
|
||||
__device__ __forceinline__ void int_layer(u64 st[12]) { int_layer_rc(st, 0ull, false, nullptr); }
|
||||
#else
|
||||
// External linear layer: 4x4 MDS on each chunk, then circulant sums.
|
||||
__device__ __forceinline__ void ext_layer(u64 st[12]) {
|
||||
#pragma unroll
|
||||
for (int chunk = 0; chunk < 3; chunk++) {
|
||||
int o = chunk * 4;
|
||||
u64 x0 = st[o], x1 = st[o + 1], x2 = st[o + 2], x3 = st[o + 3];
|
||||
u64 t01 = gf_add(x0, x1);
|
||||
u64 t23 = gf_add(x2, x3);
|
||||
u64 t0123 = gf_add(t01, t23);
|
||||
u64 t01123 = gf_add(t0123, x1);
|
||||
u64 t01233 = gf_add(t0123, x3);
|
||||
st[o + 3] = gf_add(t01233, gf_add(x0, x0));
|
||||
st[o + 1] = gf_add(t01123, gf_add(x2, x2));
|
||||
st[o] = gf_add(t01123, t01);
|
||||
st[o + 2] = gf_add(t01233, t23);
|
||||
}
|
||||
u64 sums[4];
|
||||
#pragma unroll
|
||||
for (int k = 0; k < 4; k++) {
|
||||
sums[k] = gf_add(gf_add(st[k], st[k + 4]), st[k + 8]);
|
||||
}
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) {
|
||||
st[i] = gf_add(st[i], sums[i & 3]);
|
||||
}
|
||||
}
|
||||
|
||||
__device__ __forceinline__ u64 ext_layer_out0(const u64 st[12]) {
|
||||
u64 a[3];
|
||||
#pragma unroll
|
||||
for (int chunk = 0; chunk < 3; chunk++) {
|
||||
int o = chunk * 4;
|
||||
u64 t01 = gf_add(st[o], st[o + 1]);
|
||||
u64 t23 = gf_add(st[o + 2], st[o + 3]);
|
||||
a[chunk] = gf_add(gf_add(gf_add(t01, t23), st[o + 1]), t01);
|
||||
}
|
||||
return gf_add(a[0], gf_add(gf_add(a[0], a[1]), a[2]));
|
||||
}
|
||||
|
||||
// Internal linear layer: diagonal matrix plus full sum.
|
||||
__device__ __forceinline__ void int_layer(u64 st[12]) {
|
||||
u64 sum = st[0];
|
||||
#pragma unroll
|
||||
for (int i = 1; i < 12; i++) sum = gf_add(sum, st[i]);
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = gf_add(gf_mul(st[i], MDS_DIAG[i]), sum);
|
||||
}
|
||||
#endif
|
||||
|
||||
// `skip_last_ext`: leave the state before the final external linear layer,
|
||||
// for the caller to finish with ext_layer_out0 / ext_layer.
|
||||
#if LAIR_NOINLINE_PERMUTE
|
||||
// One copy of the (fully unrolled) permutation instead of three inlined ones:
|
||||
// 17k instructions instead of 51k, for instruction-cache pressure.
|
||||
__device__ __noinline__ void permute(u64 st[12], bool skip_last_ext = false, bool skip_first_ext = false) {
|
||||
#else
|
||||
__device__ __forceinline__ void permute(u64 st[12], bool skip_last_ext = false, bool skip_first_ext = false) {
|
||||
#endif
|
||||
#if LAIR_LAZY_ADD && LAIR_FOLD_RC
|
||||
// Each linear layer folds the constant of the round that follows it.
|
||||
// `skip_first_ext`: the caller supplies the state after this layer.
|
||||
if (!skip_first_ext) ext_layer_rc(st, RC_INITIAL[0]);
|
||||
#pragma unroll
|
||||
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = gf_sbox(st[i]);
|
||||
if (r + 1 < N_EXTERNAL_HALF) {
|
||||
ext_layer_rc(st, RC_INITIAL[r + 1]);
|
||||
} else {
|
||||
ext_layer(st);
|
||||
st[0] = gf_add(st[0], RC_INTERNAL[0]);
|
||||
}
|
||||
}
|
||||
LAIR_UNROLL(LAIR_INT_UNROLL)
|
||||
for (int r = 0; r < N_INTERNAL; r++) {
|
||||
st[0] = gf_sbox(st[0]);
|
||||
if (r + 1 < N_INTERNAL) {
|
||||
int_layer_rc(st, RC_INTERNAL[r + 1], true, nullptr);
|
||||
} else {
|
||||
int_layer_rc(st, 0ull, false, RC_TERMINAL[0]);
|
||||
}
|
||||
}
|
||||
#pragma unroll
|
||||
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = gf_sbox(st[i]);
|
||||
if (r + 1 < N_EXTERNAL_HALF) {
|
||||
ext_layer_rc(st, RC_TERMINAL[r + 1]);
|
||||
} else if (!skip_last_ext) {
|
||||
ext_layer(st);
|
||||
}
|
||||
}
|
||||
#else
|
||||
ext_layer(st);
|
||||
#pragma unroll
|
||||
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = gf_sbox(gf_add(st[i], RC_INITIAL[r][i]));
|
||||
ext_layer(st);
|
||||
}
|
||||
LAIR_UNROLL(LAIR_INT_UNROLL)
|
||||
for (int r = 0; r < N_INTERNAL; r++) {
|
||||
st[0] = gf_sbox(gf_add(st[0], RC_INTERNAL[r]));
|
||||
int_layer(st);
|
||||
}
|
||||
#pragma unroll
|
||||
for (int r = 0; r < N_EXTERNAL_HALF; r++) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = gf_sbox(gf_add(st[i], RC_TERMINAL[r][i]));
|
||||
if (r + 1 < N_EXTERNAL_HALF || !skip_last_ext) ext_layer(st);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
__device__ __forceinline__ u32 bswap32(u32 v) {
|
||||
return __byte_perm(v, 0, 0x0123);
|
||||
}
|
||||
|
||||
// Bindings mirror the WGSL kernel:
|
||||
// results [flag, nonce(16 u32 LE), hash(16 u32 LE)] = 33 u32
|
||||
// midstate 12 felts as u64
|
||||
// start_nonce 16 u32 LE limbs
|
||||
// target 16 u32 LE limbs
|
||||
// lair: the launch-uniform inputs ride in the parameter bank instead of being
|
||||
// copied into per-thread registers (quantus/miner#3). Keeping midstate (24
|
||||
// registers), target (16) and the nonce base (16) live across the whole nonce
|
||||
// loop cost 56 registers of residency that the permutation's own working set
|
||||
// needs more: ptxas spilled 104 bytes per thread on sm_120 and 44 on sm_86/89.
|
||||
// Parameters are constant memory -- broadcast and cached, no register cost --
|
||||
// and they arrive with the launch, so the host no longer copies midstate,
|
||||
// start_nonce or target to the device once per batch.
|
||||
struct MiningUniforms {
|
||||
u64 midstate[12];
|
||||
u32 start_nonce[16];
|
||||
u32 target[16];
|
||||
// State after the first external layer + its round constant for the
|
||||
// batch's first nonce (host: first_layer_after_absorb).
|
||||
u64 layer0_base[12];
|
||||
};
|
||||
static_assert(sizeof(MiningUniforms) == 320, "MiningUniforms must match the host layout");
|
||||
|
||||
#if LAIR_NONCE_DIR
|
||||
// Column 7 of the external matrix circ(2*M4, M4, M4): what the layer adds per
|
||||
// unit of element 7, which is where the low nonce limb is absorbed. Checked by
|
||||
// engine-cuda's `column_seven_of_external_matrix` test.
|
||||
__device__ __constant__ u64 LAIR_DIR7[12] = {1, 1, 3, 2, 2, 2, 6, 4, 1, 1, 3, 2};
|
||||
#endif
|
||||
|
||||
extern "C" __global__ void LAIR_LAUNCH_BOUNDS
|
||||
mining_main(u32* __restrict__ results,
|
||||
const MiningUniforms uni,
|
||||
u32 total_threads,
|
||||
u32 nonces_per_thread,
|
||||
u32 total_nonces)
|
||||
{
|
||||
if (*((volatile u32*)results) != 0u) return;
|
||||
u32 tid = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (tid >= total_threads) return;
|
||||
u32 base_index = tid * nonces_per_thread;
|
||||
|
||||
for (u32 j = 0; j < nonces_per_thread; j++) {
|
||||
u32 logical_index = base_index + j;
|
||||
if (logical_index >= total_nonces) break;
|
||||
if (j > 0u && *((volatile u32*)results) != 0u) return;
|
||||
|
||||
// Low 256 bits only; the host guarantees no carry into limbs 8..15, so
|
||||
// the high half is never materialised here -- on a hit it is written
|
||||
// straight from the parameter bank.
|
||||
u32 current_nonce[8];
|
||||
u32 val0 = uni.start_nonce[0];
|
||||
u32 sum0 = val0 + logical_index;
|
||||
current_nonce[0] = sum0;
|
||||
u32 carry = sum0 < val0 ? 1u : 0u;
|
||||
#pragma unroll
|
||||
for (int i = 1; i < 8; i++) {
|
||||
u32 val = uni.start_nonce[i];
|
||||
u32 sum = val + carry;
|
||||
current_nonce[i] = sum;
|
||||
carry = sum < val ? 1u : 0u;
|
||||
}
|
||||
|
||||
// Resume the sponge from the midstate: absorb the low nonce half,
|
||||
// pad, squeeze twice (3 permutations instead of 5; the second squeeze
|
||||
// only for candidates).
|
||||
u64 st[12];
|
||||
#if LAIR_NONCE_DIR
|
||||
if (current_nonce[1] == uni.start_nonce[1]) {
|
||||
// Only the low limb differs from the batch's first nonce, so only
|
||||
// element 7 of the absorbed state does, by d; the layer is linear.
|
||||
u64 a = (u64)bswap32(current_nonce[0]);
|
||||
u64 b = (u64)bswap32(uni.start_nonce[0]);
|
||||
u64 d = a >= b ? a - b : a + (P64 - b);
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = gf_mul_add(d, LAIR_DIR7[i], uni.layer0_base[i]);
|
||||
} else {
|
||||
// Past a carry out of the low limb: absorb and apply the layer.
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = uni.midstate[i];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; i++) st[i] = gf_add(st[i], (u64)bswap32(current_nonce[7 - i]));
|
||||
ext_layer_rc(st, RC_INITIAL[0]);
|
||||
}
|
||||
permute(st, false, true);
|
||||
#else
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 12; i++) st[i] = uni.midstate[i];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; i++) st[i] = gf_add(st[i], (u64)bswap32(current_nonce[7 - i]));
|
||||
permute(st);
|
||||
#endif
|
||||
st[0] = gf_add(st[0], 1ull);
|
||||
st[1] = gf_add(st[1], 1ull);
|
||||
#if LAIR_EARLY_REJECT
|
||||
// Element 0 of the final state is the hash's top 64 bits. Compute it
|
||||
// alone, and only nonces that do not already exceed the target there
|
||||
// pay for the other eleven outputs of the last linear layer.
|
||||
permute(st, true);
|
||||
{
|
||||
u64 c0 = gf_canon(ext_layer_out0(st));
|
||||
u32 h0 = bswap32((u32)(c0 & EPS));
|
||||
u32 t0 = uni.target[15];
|
||||
if (h0 > t0) continue;
|
||||
if (h0 == t0 && bswap32((u32)(c0 >> 32)) > uni.target[14]) continue;
|
||||
}
|
||||
ext_layer(st);
|
||||
#else
|
||||
permute(st);
|
||||
#endif
|
||||
|
||||
// First squeeze: most significant 256 bits of the hash.
|
||||
u32 first[8];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 4; i++) {
|
||||
u64 c = gf_canon(st[i]);
|
||||
first[2 * i] = (u32)(c & EPS);
|
||||
first[2 * i + 1] = (u32)(c >> 32);
|
||||
}
|
||||
u32 cmp = 0u; // 0 equal so far, 1 above target, 2 below target
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; i++) {
|
||||
u32 h = bswap32(first[i]);
|
||||
u32 t = uni.target[15 - i];
|
||||
if (h != t) { cmp = h > t ? 1u : 2u; break; }
|
||||
}
|
||||
if (cmp == 1u) continue;
|
||||
|
||||
u32 hash_le[16];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; i++) hash_le[15 - i] = bswap32(first[i]);
|
||||
permute(st);
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 4; i++) {
|
||||
u64 c = gf_canon(st[i]);
|
||||
hash_le[7 - 2 * i] = bswap32((u32)(c & EPS));
|
||||
hash_le[6 - 2 * i] = bswap32((u32)(c >> 32));
|
||||
}
|
||||
bool below = (cmp == 2u);
|
||||
if (!below) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; i++) {
|
||||
u32 h = hash_le[7 - i];
|
||||
u32 t = uni.target[7 - i];
|
||||
if (h != t) { below = h < t; break; }
|
||||
}
|
||||
}
|
||||
|
||||
if (below) {
|
||||
if (atomicExch(&results[0], 1u) == 0u) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; i++) results[1 + i] = current_nonce[i];
|
||||
#pragma unroll
|
||||
for (int i = 8; i < 16; i++) results[1 + i] = uni.start_nonce[i];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 16; i++) results[17 + i] = hash_le[i];
|
||||
__threadfence();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,630 @@
|
||||
#![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");
|
||||
const KERNEL_ID: &str = "cuda";
|
||||
/// 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;
|
||||
|
||||
fn threads_per_block() -> 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(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,
|
||||
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],
|
||||
}
|
||||
|
||||
// SAFETY: `#[repr(C)]` plain-old-data with no padding (12 x u64, 32 x u32, 12 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>() == 320);
|
||||
|
||||
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_ID} kernel [sm_{{{}}}]",
|
||||
d.name,
|
||||
d.sm_count,
|
||||
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 func = module.load_function("mining_main")?;
|
||||
Ok(Self {
|
||||
ctx,
|
||||
func,
|
||||
name,
|
||||
sm_count,
|
||||
threads_per_sm: threads_per_sm(),
|
||||
threads_per_block: threads_per_block(),
|
||||
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,
|
||||
};
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
[package]
|
||||
name = "engine-gpu-cuda"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "CUDA-based GPU mining engine for the Quantus External Miner (placeholder crate)"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable CUDA integrations when implementing the engine.
|
||||
cuda = ["dep:cust"]
|
||||
# Enable metrics emission from the CUDA engine (e.g., false-positive counter).
|
||||
metrics = ["dep:metrics"]
|
||||
|
||||
[dependencies]
|
||||
pow-core = { path = "../pow-core" }
|
||||
engine-cpu = { path = "../engine-cpu" }
|
||||
primitive-types = { workspace = true }
|
||||
log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
# Metrics (optional; used for emitting generic counters/gauges from this engine)
|
||||
metrics = { path = "../metrics", optional = true }
|
||||
|
||||
# CUDA ecosystem (optional for now; will be used when implementing GPU backend)
|
||||
cust = { version = "0.3", optional = true }
|
||||
rustacuda = { version = "0.1", optional = true }
|
||||
|
||||
# Host-side hashing and big-int precompute for GPU constants
|
||||
qp-poseidon-core = { workspace = true }
|
||||
num-bigint = "0.4"
|
||||
num-traits = { workspace = true }
|
||||
@@ -1,489 +0,0 @@
|
||||
use std::env;
|
||||
use std::ffi::OsStr;
|
||||
use std::fs;
|
||||
use std::io::{self};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
/// Return true when the host GCC major version is greater than 14.
|
||||
/// We try $CC --version first, then gcc --version, and parse the first X.Y-like token.
|
||||
fn host_gcc_too_new() -> bool {
|
||||
fn probe(bin: &str) -> Option<String> {
|
||||
Command::new(bin)
|
||||
.arg("--version")
|
||||
.stdout(Stdio::piped())
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).into_owned())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let cc = env::var("CC").unwrap_or_else(|_| "gcc".to_string());
|
||||
let ver_out = probe(&cc).or_else(|| probe("gcc"));
|
||||
if let Some(s) = ver_out {
|
||||
// Scan tokens separated by non [0-9|.] and take the first X.Y...
|
||||
for tok in s.split(|c: char| !c.is_ascii_digit() && c != '.') {
|
||||
if tok.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some((maj, _)) = tok.split_once('.') {
|
||||
if let Ok(m) = maj.parse::<u32>() {
|
||||
return m > 14;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Only do CUDA work if this crate is built with the `cuda` feature.
|
||||
let cuda_feature_enabled = env::var_os("CARGO_FEATURE_CUDA").is_some();
|
||||
println!("cargo:rerun-if-env-changed=CARGO_FEATURE_CUDA");
|
||||
|
||||
if !cuda_feature_enabled {
|
||||
// Generate empty bindings so include! compiles; skip CUDA work.
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
||||
generate_empty_bindings(&out_dir)?;
|
||||
println!("cargo:warning=engine-gpu-cuda built without 'cuda' feature; skipping kernel compilation.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Gather kernel sources (.cu) from common locations
|
||||
let kernel_roots = [
|
||||
Path::new("src").join("kernels"),
|
||||
Path::new("src").join("cuda"),
|
||||
];
|
||||
let mut cu_files: Vec<PathBuf> = Vec::new();
|
||||
for root in kernel_roots.iter() {
|
||||
if root.is_dir() {
|
||||
scan_cu_files(root, &mut cu_files)?;
|
||||
}
|
||||
}
|
||||
|
||||
// If there are no kernels yet, still generate an empty bindings file so include! compiles.
|
||||
if cu_files.is_empty() {
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
||||
generate_empty_bindings(&out_dir)?;
|
||||
println!("cargo:warning=No CUDA kernels (.cu) found under src/kernels or src/cuda; generated empty PTX/CUBIN bindings.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Emit rebuild hints for kernels and env vars that affect compilation
|
||||
for cu in &cu_files {
|
||||
println!("cargo:rerun-if-changed={}", cu.display());
|
||||
}
|
||||
println!("cargo:rerun-if-env-changed=NVCC");
|
||||
println!("cargo:rerun-if-env-changed=CUDA_HOME");
|
||||
println!("cargo:rerun-if-env-changed=CUDA_PATH");
|
||||
println!("cargo:rerun-if-env-changed=CUDA_ARCH");
|
||||
println!("cargo:rerun-if-env-changed=MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER");
|
||||
println!("cargo:rerun-if-env-changed=MINER_NVCC_CCBIN");
|
||||
|
||||
// Determine nvcc path
|
||||
let nvcc = find_nvcc().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"Unable to locate `nvcc`. Set NVCC or CUDA_HOME/CUDA_PATH, or ensure nvcc is in PATH.",
|
||||
)
|
||||
})?;
|
||||
|
||||
// Architecture (SM version) can be overridden; default to sm_70 as a reasonable baseline for modern GPUs
|
||||
let raw_arch = env::var("CUDA_ARCH").unwrap_or_else(|_| "sm_70".to_string());
|
||||
// Normalize to a compute/sm pair:
|
||||
// - Use compute_* for -arch
|
||||
// - Use sm_* for -code
|
||||
let (arch_compute, arch_sm) = if let Some(s) = raw_arch.strip_prefix("sm_") {
|
||||
(format!("compute_{s}"), raw_arch.clone())
|
||||
} else if let Some(s) = raw_arch.strip_prefix("compute_") {
|
||||
(raw_arch.clone(), format!("sm_{s}"))
|
||||
} else if let Some(s) = raw_arch.strip_prefix("sm") {
|
||||
(format!("compute{s}"), raw_arch.clone())
|
||||
} else {
|
||||
("compute_70".to_string(), "sm_70".to_string())
|
||||
};
|
||||
println!("cargo:warning=NVCC={}", nvcc.display());
|
||||
println!("cargo:warning=CUDA_ARCH (normalized): compute={arch_compute}, sm={arch_sm}");
|
||||
|
||||
// Output directory for artifacts
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR")?);
|
||||
println!(
|
||||
"cargo:rustc-env=ENGINE_GPU_CUDA_PTX_DIR={}",
|
||||
out_dir.display()
|
||||
);
|
||||
|
||||
// Preflight: locate CUDA include directories and ensure cuda_runtime.h exists.
|
||||
// Prefer $CUDA_HOME/targets/x86_64-linux/include, then $CUDA_HOME/include. Derive CUDA_HOME from NVCC if needed.
|
||||
let cuda_home_env = env::var_os("CUDA_HOME").map(PathBuf::from).or_else(|| {
|
||||
// Derive CUDA_HOME from NVCC path (<home>/bin/nvcc)
|
||||
nvcc.parent()
|
||||
.and_then(Path::parent)
|
||||
.map(|p| p.to_path_buf())
|
||||
});
|
||||
|
||||
let mut include_dirs: Vec<PathBuf> = Vec::new();
|
||||
|
||||
// If CUDA_HOME was found (either env or derived from NVCC), add its common include locations.
|
||||
if let Some(home) = cuda_home_env.as_ref() {
|
||||
let t_inc = home.join("targets").join("x86_64-linux").join("include");
|
||||
let i_inc = home.join("include");
|
||||
if t_inc.is_dir() {
|
||||
include_dirs.push(t_inc);
|
||||
}
|
||||
if i_inc.is_dir() {
|
||||
include_dirs.push(i_inc);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: common system install prefix in CUDA devel images.
|
||||
// This allows builds to succeed even when CUDA_HOME isn't set in the container.
|
||||
for fallback in [
|
||||
Path::new("/usr/local/cuda/targets/x86_64-linux/include"),
|
||||
Path::new("/usr/local/cuda/include"),
|
||||
] {
|
||||
if fallback.is_dir() {
|
||||
include_dirs.push(fallback.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate include dirs to avoid noisy logs and duplicate -I flags
|
||||
include_dirs.sort();
|
||||
include_dirs.dedup();
|
||||
|
||||
for inc in &include_dirs {
|
||||
println!("cargo:warning=CUDA_INCLUDE_DIR={}", inc.display());
|
||||
}
|
||||
|
||||
let has_runtime_h = include_dirs
|
||||
.iter()
|
||||
.any(|d| d.join("cuda_runtime.h").is_file());
|
||||
if !has_runtime_h {
|
||||
return Err(Box::new(io::Error::other(format!(
|
||||
"engine-gpu-cuda: missing CUDA headers (cuda_runtime.h). \
|
||||
Ensure the matching CUDA toolkit is installed (see README), \
|
||||
and CUDA_HOME is set (current CUDA_HOME={:?}). Searched: {}",
|
||||
cuda_home_env,
|
||||
include_dirs
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
))));
|
||||
}
|
||||
|
||||
// Extra nvcc options and optional host compiler override
|
||||
let mut extra_nvcc_flags: Vec<String> = Vec::new();
|
||||
let allow_unsupported = env::var("MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER")
|
||||
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
.unwrap_or(false);
|
||||
if allow_unsupported || host_gcc_too_new() {
|
||||
println!("cargo:warning=nvcc: enabling -allow-unsupported-compiler");
|
||||
extra_nvcc_flags.push("-allow-unsupported-compiler".to_string());
|
||||
}
|
||||
let ccbin = env::var("MINER_NVCC_CCBIN").ok();
|
||||
if let Some(ref cc) = ccbin {
|
||||
println!("cargo:warning=nvcc: using -ccbin {cc}");
|
||||
}
|
||||
|
||||
// Compile each .cu into artifacts
|
||||
// - PTX: for driver JIT (fallback)
|
||||
// - CUBIN: native SASS per-SM (preferred; avoids PTX JIT/ISA mismatches)
|
||||
let mut generated_ptx: Vec<(String, PathBuf)> = Vec::new();
|
||||
let mut generated_cubin: Vec<(String, PathBuf)> = Vec::new();
|
||||
|
||||
for cu in &cu_files {
|
||||
let stem = cu
|
||||
.file_stem()
|
||||
.and_then(OsStr::to_str)
|
||||
.ok_or_else(|| io::Error::other("Invalid kernel filename"))?
|
||||
.to_string();
|
||||
|
||||
let ptx_path = out_dir.join(format!("{stem}.ptx"));
|
||||
let cubin_path = out_dir.join(format!("{stem}.cubin"));
|
||||
|
||||
// PTX
|
||||
match compile_to_ptx(
|
||||
&nvcc,
|
||||
&arch_compute,
|
||||
cu,
|
||||
&ptx_path,
|
||||
&include_dirs,
|
||||
ccbin.as_deref(),
|
||||
&extra_nvcc_flags,
|
||||
) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"cargo:warning=nvcc PTX OK: {} -> {}",
|
||||
cu.display(),
|
||||
ptx_path.display()
|
||||
);
|
||||
generated_ptx.push((stem.clone(), ptx_path.clone()));
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("cargo:warning=nvcc PTX failed for {}: {err}", cu.display());
|
||||
eprintln!(
|
||||
"cargo:warning=Skipping PTX for {stem} (will rely on CUBIN if available)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// CUBIN (preferred at runtime)
|
||||
match compile_to_cubin(
|
||||
&nvcc,
|
||||
&arch_compute,
|
||||
&arch_sm,
|
||||
cu,
|
||||
&cubin_path,
|
||||
&include_dirs,
|
||||
ccbin.as_deref(),
|
||||
&extra_nvcc_flags,
|
||||
) {
|
||||
Ok(()) => {
|
||||
println!(
|
||||
"cargo:warning=nvcc CUBIN OK: {} -> {}",
|
||||
cu.display(),
|
||||
cubin_path.display()
|
||||
);
|
||||
generated_cubin.push((stem, cubin_path.clone()));
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"cargo:warning=nvcc CUBIN failed for {}: {err}",
|
||||
cu.display()
|
||||
);
|
||||
eprintln!(
|
||||
"cargo:warning=Skipping CUBIN for {stem} (will fall back to PTX if present)"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fail fast if no artifacts were generated (prevents producing a GPU binary without embeds)
|
||||
if generated_ptx.is_empty() && generated_cubin.is_empty() {
|
||||
return Err(Box::new(io::Error::other(
|
||||
"engine-gpu-cuda: no CUDA artifacts embedded (PTX/CUBIN). Ensure NVCC is set and CUDA_ARCH=sm_XX; see README Fedora section.",
|
||||
)));
|
||||
}
|
||||
|
||||
// Generate an embedded PTX/CUBIN bindings file so the crate can include!() the artifacts at compile time.
|
||||
// Output: $OUT_DIR/ptx_bindings.rs with:
|
||||
// - pub mod ptx_embedded { pub const <STEM>_PTX: &str = "..."; pub fn get(name: &str) -> Option<&'static str>; }
|
||||
// - pub mod cubin_embedded { pub static <STEM>_CUBIN: &'static [u8] = &[…]; pub fn get_cubin(name: &str) -> Option<&'static [u8]>; }
|
||||
let bindings_path = out_dir.join("ptx_bindings.rs");
|
||||
let mut rs = String::new();
|
||||
rs.push_str("// @generated by engine-gpu-cuda/build.rs — DO NOT EDIT MANUALLY\n");
|
||||
rs.push_str("#[allow(dead_code)]\n");
|
||||
|
||||
// PTX module (string)
|
||||
rs.push_str("pub mod ptx_embedded {\n");
|
||||
for (stem, path) in &generated_ptx {
|
||||
let const_name = to_const_name(stem, "_PTX");
|
||||
let ptx = std::fs::read_to_string(path).unwrap_or_else(|_| String::new());
|
||||
rs.push_str(&format!(" pub const {const_name}: &str = r###\""));
|
||||
rs.push_str(&ptx);
|
||||
rs.push_str("\"###;\n");
|
||||
}
|
||||
rs.push_str(" pub fn get(name: &str) -> Option<&'static str> {\n");
|
||||
rs.push_str(" match name {\n");
|
||||
for (stem, _) in &generated_ptx {
|
||||
let const_name = to_const_name(stem, "_PTX");
|
||||
rs.push_str(&format!(" \"{stem}\" => Some({const_name}),\n"));
|
||||
}
|
||||
rs.push_str(" _ => None,\n");
|
||||
rs.push_str(" }\n");
|
||||
rs.push_str(" }\n");
|
||||
rs.push_str("}\n");
|
||||
|
||||
// CUBIN module (bytes)
|
||||
rs.push_str("pub mod cubin_embedded {\n");
|
||||
for (stem, path) in &generated_cubin {
|
||||
let const_name = to_const_name(stem, "_CUBIN");
|
||||
let bytes = std::fs::read(path).unwrap_or_default();
|
||||
rs.push_str(&format!(
|
||||
" pub static {const_name}: &'static [u8] = &[\n "
|
||||
));
|
||||
for (i, b) in bytes.iter().enumerate() {
|
||||
if i > 0 && i % 16 == 0 {
|
||||
rs.push_str("\n ");
|
||||
}
|
||||
rs.push_str(&format!("{b},"));
|
||||
}
|
||||
rs.push_str("\n ];\n");
|
||||
}
|
||||
rs.push_str(" pub fn get_cubin(name: &str) -> Option<&'static [u8]> {\n");
|
||||
rs.push_str(" match name {\n");
|
||||
for (stem, _) in &generated_cubin {
|
||||
let const_name = to_const_name(stem, "_CUBIN");
|
||||
rs.push_str(&format!(" \"{stem}\" => Some({const_name}),\n"));
|
||||
}
|
||||
rs.push_str(" _ => None,\n");
|
||||
rs.push_str(" }\n");
|
||||
rs.push_str(" }\n");
|
||||
rs.push_str("}\n");
|
||||
|
||||
std::fs::write(&bindings_path, rs)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn to_const_name(stem: &str, suffix: &str) -> String {
|
||||
let mut s = String::with_capacity(stem.len() + suffix.len());
|
||||
for c in stem.chars() {
|
||||
if c.is_ascii_alphanumeric() {
|
||||
s.push(c.to_ascii_uppercase());
|
||||
} else {
|
||||
s.push('_');
|
||||
}
|
||||
}
|
||||
s.push_str(suffix);
|
||||
s
|
||||
}
|
||||
|
||||
fn generate_empty_bindings(out_dir: &Path) -> io::Result<()> {
|
||||
let bindings_path = out_dir.join("ptx_bindings.rs");
|
||||
let mut rs = String::new();
|
||||
rs.push_str("// @generated by engine-gpu-cuda/build.rs — DO NOT EDIT MANUALLY\n");
|
||||
rs.push_str("#[allow(dead_code)]\n");
|
||||
rs.push_str("pub mod ptx_embedded { pub fn get(_: &str) -> Option<&'static str> { None } }\n");
|
||||
rs.push_str(
|
||||
"pub mod cubin_embedded { pub fn get_cubin(_: &str) -> Option<&'static [u8]> { None } }\n",
|
||||
);
|
||||
std::fs::write(&bindings_path, rs)
|
||||
}
|
||||
|
||||
fn scan_cu_files(root: &Path, out: &mut Vec<PathBuf>) -> io::Result<()> {
|
||||
for entry in fs::read_dir(root)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
scan_cu_files(&path, out)?;
|
||||
} else if path.extension().and_then(OsStr::to_str) == Some("cu") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_nvcc() -> Option<PathBuf> {
|
||||
// 1) Explicit override
|
||||
if let Some(p) = env::var_os("NVCC") {
|
||||
let pb = PathBuf::from(p);
|
||||
if pb.is_file() {
|
||||
return Some(pb);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) CUDA_HOME or CUDA_PATH
|
||||
if let Some(home) = env::var_os("CUDA_HOME").or_else(|| env::var_os("CUDA_PATH")) {
|
||||
let mut candidate = PathBuf::from(home);
|
||||
candidate.push("bin");
|
||||
candidate.push(exe("nvcc"));
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
// 3) PATH lookup — try invoking `nvcc --version`
|
||||
if let Ok(output) = Command::new("nvcc").arg("--version").output() {
|
||||
if output.status.success() {
|
||||
return Some(PathBuf::from("nvcc"));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn compile_to_ptx(
|
||||
nvcc: &Path,
|
||||
arch: &str,
|
||||
src: &Path,
|
||||
out: &Path,
|
||||
includes: &[PathBuf],
|
||||
ccbin: Option<&str>,
|
||||
extra_flags: &[String],
|
||||
) -> io::Result<()> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = out.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// nvcc -ptx -arch=<compute_XX> -I<inc>... [-ccbin cc] [extra] -o <out.ptx> <src.cu>
|
||||
let mut cmd = Command::new(nvcc);
|
||||
cmd.args(["-ptx", "-arch", arch]).arg("-o").arg(out);
|
||||
if let Some(cc) = ccbin {
|
||||
cmd.arg("-ccbin").arg(cc);
|
||||
}
|
||||
for f in extra_flags {
|
||||
cmd.arg(f);
|
||||
}
|
||||
for inc in includes {
|
||||
cmd.arg("-I").arg(inc);
|
||||
}
|
||||
cmd.arg(src);
|
||||
|
||||
let status = cmd.status()?;
|
||||
if !status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"nvcc returned non-zero exit status (ptx): {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn compile_to_cubin(
|
||||
nvcc: &Path,
|
||||
arch_compute: &str,
|
||||
arch_sm: &str,
|
||||
src: &Path,
|
||||
out: &Path,
|
||||
includes: &[PathBuf],
|
||||
ccbin: Option<&str>,
|
||||
extra_flags: &[String],
|
||||
) -> io::Result<()> {
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = out.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
// nvcc -cubin -arch=<compute_XX> -code=<sm_XX> -I<inc>... [-ccbin cc] [extra] -o <out.cubin> <src.cu>
|
||||
let mut cmd = Command::new(nvcc);
|
||||
cmd.args(["-cubin", "-arch", arch_compute, "-code", arch_sm])
|
||||
.arg("-o")
|
||||
.arg(out);
|
||||
if let Some(cc) = ccbin {
|
||||
cmd.arg("-ccbin").arg(cc);
|
||||
}
|
||||
for f in extra_flags {
|
||||
cmd.arg(f);
|
||||
}
|
||||
for inc in includes {
|
||||
cmd.arg("-I").arg(inc);
|
||||
}
|
||||
cmd.arg(src);
|
||||
|
||||
let status = cmd.status()?;
|
||||
if !status.success() {
|
||||
return Err(io::Error::other(format!(
|
||||
"nvcc returned non-zero exit status (cubin): {status}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn exe(name: &str) -> String {
|
||||
format!("{name}.exe")
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
fn exe(name: &str) -> String {
|
||||
name.to_string()
|
||||
}
|
||||
@@ -1,212 +0,0 @@
|
||||
# engine-gpu-cuda (CUDA backend) – G1 bring‑up
|
||||
|
||||
This crate provides the CUDA GPU backend for the Quantus External Miner. It currently implements "G1" bring‑up: the per‑nonce modular multiply loop runs on the GPU (512‑bit Montgomery CIOS), while Poseidon2‑512 and the threshold check run on the host CPU. This allows correctness and plumbing to be validated before we move Poseidon2 and early‑exit onto the device in G2.
|
||||
|
||||
The backend is feature‑gated. When built with `--features cuda`, the crate’s build script compiles the CUDA kernel and embeds device images into the binary (CUBIN preferred, PTX as fallback). At runtime the engine selects an embedded image and launches the kernel to produce normalized `y` values per iteration.
|
||||
|
||||
---
|
||||
|
||||
## Status
|
||||
|
||||
- G1 (current):
|
||||
- Device: 512‑bit Montgomery multiply (CIOS using 64×64→128 via `__umul64hi`), maintaining `ŷ` in Montgomery domain and converting to normal domain for output.
|
||||
- Host: Poseidon2‑512 and threshold compare (and orchestration).
|
||||
- Correctness: parity against CPU small‑range tests.
|
||||
- Performance: primarily limited by PCIe copy‑back and host Poseidon2. See "Tuning" below.
|
||||
|
||||
- G2 (next):
|
||||
- Device: Poseidon2‑512 optimized for 64‑byte input.
|
||||
- Device: threshold compare + global early‑exit flag (atomic) + tiny candidate write.
|
||||
- Device: move constants to `__constant__` memory.
|
||||
- Host: poll early‑exit; no large copy‑backs (only candidate or counters).
|
||||
- Result: removes PCIe and host‑Poseidon2 bottlenecks; enables real GPU‑bound throughput. Selection will be enabled via `MINER_CUDA_MODE=g2` once available.
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
The build script (for this crate only) compiles `crates/engine-gpu-cuda/src/kernels/qpow_kernel.cu` with `nvcc` and embeds both:
|
||||
- CUBIN (native SASS for a specific SM, preferred at runtime).
|
||||
- PTX (forward‑compatible target, used as a fallback when necessary).
|
||||
|
||||
Header preflight: the build verifies CUDA headers (e.g., `cuda_runtime.h`) exist. If it cannot embed either PTX or CUBIN, the build fails fast to prevent emitting a GPU‑broken binary.
|
||||
|
||||
Supported build modes:
|
||||
- Native build (host has a CUDA toolkit): `cargo build -p miner-cli --features cuda --release`
|
||||
- Containerized build (recommended for CI): build inside NVIDIA’s CUDA devel images; extract the binary (see repo workflow and Containerfile).
|
||||
|
||||
Notes:
|
||||
- The kernel is compiled for one SM arch per build. Choose your SM via `CUDA_ARCH=sm_86|sm_89|sm_120` (see “Env knobs – build‑time”).
|
||||
- For driver/toolkit compatibility (e.g., CUDA 12.9 driver), build the device images with a matching toolkit (e.g., 12.9).
|
||||
|
||||
---
|
||||
|
||||
## Runtime selection and embeds
|
||||
|
||||
At startup, the engine prefers the embedded CUBIN; if absent it falls back to the embedded PTX. You can override with `MINER_CUDA_IMAGE=cubin|ptx`. To attempt the G2 path (device Poseidon2 + early-exit), set `MINER_CUDA_MODE=g2`; if the G2 kernel isn't embedded/available for the current device, the engine will fall back to G1 automatically. You'll see logs like:
|
||||
- `CUDA: using CUBIN (embedded)`
|
||||
- `CUDA: using PTX source = embedded`
|
||||
- (If neither exists, the engine logs the absence and delegates to CPU fast engine.)
|
||||
|
||||
When a job runs, the engine prints its launch configuration and per‑launch outcomes:
|
||||
- `CUDA launch config: block_dim=…, threads=…, iters=…`
|
||||
- `CUDA launch: grid_dim=…, block_dim=…, threads=…, iters=…`
|
||||
- `CUDA kernel and sync OK`
|
||||
- `CUDA copy-back OK: elems=…`
|
||||
|
||||
---
|
||||
|
||||
## Env knobs – runtime (G1)
|
||||
|
||||
These knobs affect GPU launch shape and how much work is returned to the host (and thus how much Poseidon2 the CPU must perform per launch).
|
||||
|
||||
- `MINER_CUDA_BLOCK_DIM` (default `256`)
|
||||
- Threads per block (`blockDim.x`). Use a multiple of 32 (warp size). 256 is a good default.
|
||||
- `MINER_CUDA_THREADS`
|
||||
- Total threads (grid workload). Grid dimension is `grid_dim = ceil(threads / block_dim)`. Target at least “#SMs × 1–2 blocks” for decent occupancy (e.g., RTX 3060 has 28 SMs → 28 or 32 blocks).
|
||||
- `MINER_CUDA_ITERS`
|
||||
- Iterations per thread. Higher values produce larger output buffers and more host Poseidon2 work per launch.
|
||||
- `MINER_CUDA_IMAGE` = `cubin` | `ptx` (optional)
|
||||
- Overrides the embedded image choice (debugging/testing). Default is to prefer CUBIN.
|
||||
- `MINER_CUDA_HASH_THREADS` (optional)
|
||||
- Number of host Poseidon2 worker threads to use to consume GPU output. Defaults to available parallelism.
|
||||
- `MINER_CUDA_PINNED` = `1|true` (optional)
|
||||
- Use pinned (page-locked) host buffers and asynchronous device-to-host copies for G1 copy-back to reduce PCIe latency.
|
||||
- `MINER_CUDA_MODE` = `g2` (optional)
|
||||
- Attempt G2 kernel (device Poseidon2-512 + threshold compare + early-exit). Falls back to G1 if the G2 kernel is not available for the current device image.
|
||||
|
||||
How much data per launch?
|
||||
- y_out bytes = `threads × iters × 64`.
|
||||
- Keep this around 64–128 MB in G1 to avoid PCIe and host Poseidon2 dominating.
|
||||
|
||||
Example configs (RTX 3060, SM 86):
|
||||
- ~64 MB per launch:
|
||||
- `BLOCK_DIM=256`, `THREADS=8192`, `ITERS=128`
|
||||
- ~128 MB per launch:
|
||||
- `BLOCK_DIM=256`, `THREADS=8192`, `ITERS=256`
|
||||
- Good occupancy with ~117 MB:
|
||||
- `BLOCK_DIM=256`, `THREADS=7168` (28 blocks), `ITERS=256`
|
||||
|
||||
Service workers:
|
||||
- Keep `--workers 1` for the GPU engine to avoid competing GPU launches.
|
||||
- The engine internally orchestrates chunking and cancellation.
|
||||
|
||||
---
|
||||
|
||||
## Env knobs – build‑time (crate build script)
|
||||
|
||||
- `CUDA_ARCH` (required for device image quality)
|
||||
- One of: `sm_86` (Ampere 3060), `sm_89` (Ada 4090), `sm_120` (CC 12.0 / 5090), etc.
|
||||
- Normalized to `(compute_XX, sm_XX)` internally.
|
||||
- `NVCC` or `CUDA_HOME` or `CUDA_PATH`
|
||||
- Path to `nvcc` or CUDA toolkit root.
|
||||
- `MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER` = `1` (optional)
|
||||
- Adds `-allow-unsupported-compiler` to `nvcc` if host GCC is newer than the toolkit supports.
|
||||
- `MINER_NVCC_CCBIN` = `/path/to/g++-14` (optional)
|
||||
- Forces `nvcc -ccbin` to a specific (supported) host C++ compiler.
|
||||
- (Container builds) The script attempts `/usr/local/cuda/targets/x86_64-linux/include` and `/usr/local/cuda/include` if `CUDA_HOME` is unset.
|
||||
|
||||
The build fails with clear messages if:
|
||||
- `nvcc` cannot be found,
|
||||
- CUDA headers cannot be found,
|
||||
- neither PTX nor CUBIN artifacts were embedded.
|
||||
|
||||
---
|
||||
|
||||
## Tuning guide (G1)
|
||||
|
||||
Goal in G1: balance kernel time (GPU) against copy-back time (PCIe) and host Poseidon2 time (CPU) to avoid starving the GPU or overwhelming the host. Practical steps:
|
||||
|
||||
1) Size the output buffer:
|
||||
- Start with 64–128 MB per launch: `bytes ≈ threads × iters × 64`.
|
||||
- Increase `threads` to raise occupancy (more blocks). Start with `block_dim=256`.
|
||||
- Increase `iters` only while host Poseidon2 still keeps up.
|
||||
|
||||
2) Watch timings:
|
||||
- The engine logs `kernel_ms` and `copy_ms`.
|
||||
- If `copy_ms > kernel_ms`, try lowering `iters` or increasing `threads` to make the kernel heavier relative to copy.
|
||||
- If CPU is pegged (hashing), lower `iters`.
|
||||
|
||||
3) SM occupancy:
|
||||
- Ensure `grid_dim` (blocks) is at least the number of SMs on the device (e.g., 28+ on RTX 3060).
|
||||
- Use `threads ≈ block_dim × blocks` with `block_dim=256`.
|
||||
|
||||
4) Service settings:
|
||||
- Keep `--workers 1` when testing the GPU engine to avoid contention.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- “missing CUDA headers (cuda_runtime.h)” at build time:
|
||||
- Install the matching CUDA toolkit or build inside an NVIDIA CUDA devel image (container).
|
||||
- Ensure `CUDA_HOME=/usr/local/cuda` (container) or set `NVCC` directly.
|
||||
|
||||
- “CUDA init failed” at runtime:
|
||||
- Check driver is installed, `nvidia-smi` works under the service user.
|
||||
- Ensure the unit has access to `/dev/nvidia*` (no device sandboxing).
|
||||
|
||||
- “no embedded PTX/CUBIN” at runtime:
|
||||
- The binary you deployed might be corrupted (download/transfer). Verify by:
|
||||
- `strings -a /path/to/binary | grep -m1 QPOW_KERNEL_CUBIN`
|
||||
- `strings -a /path/to/binary | grep -m1 qpow_montgomery_g1_kernel`
|
||||
- Rebuild and redeploy; avoid text-mode transfers; verify checksums.
|
||||
|
||||
- Driver/toolkit mismatch:
|
||||
- A CUBIN built by a newer toolkit may fail to load on older drivers. Use a toolkit matching your driver to produce the device images (e.g., CUDA 12.9 for driver 12.9).
|
||||
|
||||
---
|
||||
|
||||
## Roadmap to G2
|
||||
|
||||
- Device Poseidon2‑512 tuned for 64B input.
|
||||
- On‑device threshold compare and early‑exit flag (atomic).
|
||||
- Host polling and tiny candidate copy‑back.
|
||||
- Constants in `__constant__` memory.
|
||||
- With G2, copy‑backs and host hashing disappear from the steady‑state path, enabling real GPU‑bound throughput.
|
||||
|
||||
---
|
||||
|
||||
## Appendix: build in containers (summary)
|
||||
|
||||
- Use the repo `Containerfile` and pass:
|
||||
- `--build-arg CUDA_TAG=12.9.0|13.0.0`
|
||||
- `--build-arg SM=86|89|120`
|
||||
- The builder stage compiles and embeds PTX/CUBIN; the dist stage contains `/usr/local/bin/quantus-miner`.
|
||||
- Export the dist stage filesystem (`buildx --target dist --output type=local,dest=./out`) and pick up `./out/usr/local/bin/quantus-miner`.
|
||||
- Artifacts can be uploaded directly from CI or rsynced to your distribution host.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference (env knobs)
|
||||
|
||||
Runtime:
|
||||
- `MINER_CUDA_BLOCK_DIM` (default `256`) — threads per block.
|
||||
- `MINER_CUDA_THREADS` — total threads (increase for more blocks).
|
||||
- `MINER_CUDA_ITERS` — iterations per thread (controls y_out size).
|
||||
- `MINER_CUDA_IMAGE` = `cubin|ptx` — force embedded image selection (optional).
|
||||
- `MINER_CUDA_HASH_THREADS` — parallel host Poseidon2 workers (optional).
|
||||
- `MINER_CUDA_PINNED` = `1|true` — use pinned host buffers + async D2H copy (G1 optimization).
|
||||
- `MINER_CUDA_MODE` = `g2` — try device Poseidon2 + early-exit; falls back to G1 if G2 kernel isn't available.
|
||||
|
||||
Build-time:
|
||||
- `CUDA_ARCH` = `sm_86|sm_89|sm_120|…` — SM target for device images (normalized internally).
|
||||
- `NVCC` or `CUDA_HOME` or `CUDA_PATH` — where to find the toolkit.
|
||||
- `MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER` = `1` — add `-allow-unsupported-compiler`.
|
||||
- `MINER_NVCC_CCBIN` = `/path/to/g++-14` — force a specific host compiler.
|
||||
|
||||
---
|
||||
|
||||
## Example environment presets
|
||||
|
||||
Presets are provided under `examples/.env` and follow a “lower” (≈1× SMs blocks) and “upper” (≈2× SMs blocks) pattern per GPU:
|
||||
- RTX 3060: `cuda-miner-3060-lower.env`, `cuda-miner-3060-upper.env`
|
||||
- RTX 3080: `cuda-miner-3080-lower.env`, `cuda-miner-3080-upper.env`
|
||||
- RTX 3090: `cuda-miner-3090-lower.env`, `cuda-miner-3090-upper.env`
|
||||
- RTX 4080: `cuda-miner-4080-lower.env`, `cuda-miner-4080-upper.env`
|
||||
- RTX 4090: `cuda-miner-4090-lower.env`, `cuda-miner-4090-upper.env`
|
||||
- RTX 5090: `cuda-miner-5090-lower.env`, `cuda-miner-5090-upper.env`
|
||||
- RTX A5000: `cuda-miner-a5000-lower.env`, `cuda-miner-a5000-upper.env`
|
||||
- RTX A6000: `cuda-miner-a6000-lower.env`, `cuda-miner-a6000-upper.env`
|
||||
|
||||
Each preset uses `MINER_CUDA_MODE=g2` (device Poseidon2 + early-exit) and `MINER_CUDA_BLOCK_DIM=256`, and sizes `MINER_CUDA_THREADS` as `blocks × 256`. Adjust `MINER_CUDA_ITERS` to tune kernel dwell time vs early-exit responsiveness. If a G2 kernel image isn't embedded for your device, the engine falls back to G1 automatically.
|
||||
@@ -1,760 +0,0 @@
|
||||
/**
|
||||
* Quantus External Miner - CUDA Kernel (G1 bring-up)
|
||||
*
|
||||
* This kernel provides a minimal, correctness-first pipeline for the GPU path:
|
||||
* - Implement 512-bit Montgomery multiplication (8×64-bit limbs) on device.
|
||||
* - Keep y in Montgomery domain during iteration and convert to normal domain before output.
|
||||
* - For bring-up (G1), the kernel writes normalized y values back to host memory.
|
||||
* The host will compute SHA3-512(y_be64) and distances for parity validation.
|
||||
*
|
||||
* Notes:
|
||||
* - Limbs are little-endian: limb 0 is the least significant 64 bits.
|
||||
* - CIOS Montgomery reduction is used with 64×64→128 products via __umul64hi.
|
||||
* - This skeleton intentionally excludes early-exit and on-device SHA3; those are part of G2.
|
||||
*
|
||||
* Build:
|
||||
* - The engine-gpu-cuda crate provides a build.rs that compiles this .cu into PTX when the
|
||||
* "cuda" feature is enabled, placing artifacts under $OUT_DIR and exposing ENGINE_GPU_CUDA_PTX_DIR.
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <cuda_runtime.h>
|
||||
|
||||
extern "C" {
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Utilities: 64×64→128 multiply (lo, hi), add-with-carry helpers, compare/subtract
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
__device__ __forceinline__ void mul64wide(uint64_t a, uint64_t b, uint64_t &lo, uint64_t &hi) {
|
||||
lo = a * b;
|
||||
hi = __umul64hi(a, b);
|
||||
}
|
||||
|
||||
// sum := x + y, carry_out returns 0 or 1
|
||||
__device__ __forceinline__ uint64_t add64_carry(uint64_t x, uint64_t y, uint64_t &carry_out) {
|
||||
uint64_t s = x + y;
|
||||
carry_out = (s < x) ? 1ull : 0ull;
|
||||
return s;
|
||||
}
|
||||
|
||||
// sum := x + y + carry_in, carry_out returns 0 or 1
|
||||
__device__ __forceinline__ uint64_t add64_2carry(uint64_t x, uint64_t y, uint64_t carry_in, uint64_t &carry_out) {
|
||||
uint64_t s1 = x + y;
|
||||
uint64_t c1 = (s1 < x) ? 1ull : 0ull;
|
||||
uint64_t s2 = s1 + carry_in;
|
||||
uint64_t c2 = (s2 < s1) ? 1ull : 0ull;
|
||||
carry_out = c1 + c2;
|
||||
return s2;
|
||||
}
|
||||
|
||||
// return true if a (LE limbs) >= b (LE limbs), by numeric value
|
||||
__device__ __forceinline__ bool ge_le_8(const uint64_t a[8], const uint64_t b[8]) {
|
||||
// Compare from most significant limb to least
|
||||
for (int i = 7; i >= 0; --i) {
|
||||
if (a[i] != b[i]) {
|
||||
return a[i] > b[i];
|
||||
}
|
||||
}
|
||||
return true; // equal
|
||||
}
|
||||
|
||||
// a := a - b (LE limbs)
|
||||
__device__ __forceinline__ void sub_le_in_place_8(uint64_t a[8], const uint64_t b[8]) {
|
||||
uint64_t borrow = 0;
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
uint64_t bi = b[i];
|
||||
uint64_t ai = a[i];
|
||||
uint64_t tmp = ai - bi - borrow;
|
||||
// borrow occurs if ai < (bi + borrow)
|
||||
uint64_t needed = (ai < bi) || (borrow && ai == bi) ? 1ull : 0ull;
|
||||
a[i] = tmp;
|
||||
borrow = needed;
|
||||
}
|
||||
}
|
||||
|
||||
// Convert 8 LE limbs into 64 BE bytes into out[64] (for host hashing later, if needed)
|
||||
// Not used inside the kernel (G1 writes limbs), but kept here for reference.
|
||||
// __device__ __forceinline__ void le8_to_be64(const uint64_t le[8], uint8_t out[64]) {
|
||||
// for (int i = 0; i < 8; ++i) {
|
||||
// uint64_t limb = le[7 - i]; // most significant limb first
|
||||
// for (int b = 0; b < 8; ++b) {
|
||||
// out[i * 8 + (7 - b)] = (uint8_t)((limb >> (b * 8)) & 0xFF);
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Montgomery arithmetic (CIOS) for 512-bit numbers (8×64-bit limbs), little-endian.
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// out <- (a * b * R^{-1}) mod n
|
||||
__device__ __forceinline__ void mont_mul_512(
|
||||
const uint64_t a[8],
|
||||
const uint64_t b[8],
|
||||
const uint64_t n[8],
|
||||
const uint64_t n0_inv,
|
||||
uint64_t out[8]
|
||||
) {
|
||||
// 9-limb accumulator (LE); accumulates 128-bit intermediates via split-add with carries
|
||||
uint64_t acc[9];
|
||||
#pragma unroll
|
||||
for (int k = 0; k < 9; ++k) acc[k] = 0ull;
|
||||
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
// acc += a[i] * b
|
||||
uint64_t ai = a[i];
|
||||
uint64_t carry = 0ull;
|
||||
#pragma unroll
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
uint64_t lo, hi;
|
||||
mul64wide(ai, b[j], lo, hi);
|
||||
|
||||
// acc[j] += lo + carry, propagate carry to hi
|
||||
uint64_t c0, c1;
|
||||
uint64_t s0 = add64_carry(acc[j], lo, c0);
|
||||
uint64_t s1 = add64_carry(s0, carry, c1);
|
||||
acc[j] = s1;
|
||||
// new carry = hi + c0 + c1
|
||||
carry = hi + c0 + c1;
|
||||
}
|
||||
// acc[8] += carry
|
||||
uint64_t c_acc8;
|
||||
acc[8] = add64_carry(acc[8], carry, c_acc8);
|
||||
// c_acc8 overflow beyond 9th limb is discarded (by design in CIOS with next steps)
|
||||
|
||||
// m = (acc[0] * n0_inv) mod 2^64
|
||||
uint64_t m = (uint64_t)(acc[0] * n0_inv);
|
||||
|
||||
// acc += m * n
|
||||
uint64_t carry2 = 0ull;
|
||||
#pragma unroll
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
uint64_t lo2, hi2;
|
||||
mul64wide(m, n[j], lo2, hi2);
|
||||
|
||||
uint64_t c0, c1;
|
||||
uint64_t s0 = add64_carry(acc[j], lo2, c0);
|
||||
uint64_t s1 = add64_carry(s0, carry2, c1);
|
||||
acc[j] = s1;
|
||||
carry2 = hi2 + c0 + c1;
|
||||
}
|
||||
// acc[8] += carry2
|
||||
uint64_t c_acc8_b;
|
||||
acc[8] = add64_carry(acc[8], carry2, c_acc8_b);
|
||||
|
||||
// Shift acc right by one limb (drop acc[0])
|
||||
#pragma unroll
|
||||
for (int j = 0; j < 8; ++j) {
|
||||
acc[j] = acc[j + 1];
|
||||
}
|
||||
acc[8] = 0ull;
|
||||
}
|
||||
|
||||
// Conditional subtract: if acc >= n, subtract n
|
||||
if (ge_le_8(acc, n)) {
|
||||
sub_le_in_place_8(acc, n);
|
||||
}
|
||||
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
out[i] = acc[i];
|
||||
}
|
||||
}
|
||||
|
||||
// to_mont(x) = x * R^2 mod n
|
||||
__device__ __forceinline__ void to_mont_512(
|
||||
const uint64_t x[8],
|
||||
const uint64_t r2[8],
|
||||
const uint64_t n[8],
|
||||
const uint64_t n0_inv,
|
||||
uint64_t out[8]
|
||||
) {
|
||||
mont_mul_512(x, r2, n, n0_inv, out);
|
||||
}
|
||||
|
||||
// from_mont(x̂) = x̂ * 1 mod n
|
||||
__device__ __forceinline__ void from_mont_512(
|
||||
const uint64_t xhat[8],
|
||||
const uint64_t n[8],
|
||||
const uint64_t n0_inv,
|
||||
uint64_t out[8]
|
||||
) {
|
||||
// Multiply by 1 (Montgomery): one = [1,0,..,0]
|
||||
uint64_t one[8];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) one[i] = 0ull;
|
||||
one[0] = 1ull;
|
||||
mont_mul_512(xhat, one, n, n0_inv, out);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Kernel: G1 bring-up
|
||||
//
|
||||
// Each thread:
|
||||
// - Loads y0 (normal domain) for that thread.
|
||||
// - Computes y_hat0 = to_mont(y0) on device.
|
||||
// - Iterates iters_per_thread times:
|
||||
// y_hat = mont_mul(y_hat, m_hat)
|
||||
// y = from_mont(y_hat)
|
||||
// Writes y (LE limbs) to y_out at [thread_offset + iter]
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
extern "C" __global__ void qpow_montgomery_g1_kernel(
|
||||
// Per-job constants (each 8 limbs, LE)
|
||||
const uint64_t* __restrict__ m, // not used in G1 directly (we pass m_hat)
|
||||
const uint64_t* __restrict__ n,
|
||||
const uint64_t n0_inv,
|
||||
const uint64_t* __restrict__ r2,
|
||||
const uint64_t* __restrict__ m_hat,
|
||||
|
||||
// Per-thread starting state (normal domain)
|
||||
const uint64_t* __restrict__ y0, // length: num_threads * 8 limbs
|
||||
|
||||
// Output buffer for normalized y (for host SHA3 in G1)
|
||||
uint64_t* __restrict__ y_out, // length: num_threads * iters_per_thread * 8 limbs
|
||||
|
||||
// Threading parameters
|
||||
const uint32_t num_threads,
|
||||
const uint32_t iters_per_thread
|
||||
) {
|
||||
const uint32_t tid = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (tid >= num_threads) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local copies of constants (consider placing in __constant__ memory for G2+)
|
||||
uint64_t n_loc[8], r2_loc[8], mhat_loc[8];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
n_loc[i] = n[i];
|
||||
r2_loc[i] = r2[i];
|
||||
mhat_loc[i] = m_hat[i];
|
||||
}
|
||||
|
||||
// Load this thread's y0 (normal domain)
|
||||
uint64_t y0_loc[8];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
y0_loc[i] = y0[tid * 8u + i];
|
||||
}
|
||||
|
||||
// Transform to Montgomery domain
|
||||
uint64_t yhat[8];
|
||||
to_mont_512(y0_loc, r2_loc, n_loc, n0_inv, yhat);
|
||||
|
||||
// Iterate and emit normalized y per step
|
||||
// Output stride per thread: iters_per_thread * 8 limbs
|
||||
uint64_t* out_base = y_out + (static_cast<size_t>(tid) * static_cast<size_t>(iters_per_thread) * 8ull);
|
||||
|
||||
for (uint32_t iter = 0; iter < iters_per_thread; ++iter) {
|
||||
// y_hat = y_hat * m_hat
|
||||
uint64_t yhat_next[8];
|
||||
mont_mul_512(yhat, mhat_loc, n_loc, n0_inv, yhat_next);
|
||||
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
yhat[i] = yhat_next[i];
|
||||
}
|
||||
|
||||
// y = from_mont(y_hat)
|
||||
uint64_t y_norm[8];
|
||||
from_mont_512(yhat, n_loc, n0_inv, y_norm);
|
||||
|
||||
// Store normalized y (LE limbs) for host SHA3 and distance validation
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
out_base[iter * 8u + i] = y_norm[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// Host-callable launcher wrapper (optional; typically loaded via PTX and launched from Rust)
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// The Rust host side will load this kernel from PTX and launch it using `cust`/`rustacuda`.
|
||||
// Example signature in Rust (pseudo):
|
||||
//
|
||||
// launch!(module.qpow_montgomery_g1_kernel<<<grid, block, 0, stream>>>(
|
||||
// d_m.as_device_ptr(),
|
||||
// d_n.as_device_ptr(),
|
||||
// n0_inv,
|
||||
// d_r2.as_device_ptr(),
|
||||
// d_mhat.as_device_ptr(),
|
||||
// d_y0.as_device_ptr(),
|
||||
// d_y_out.as_device_ptr(),
|
||||
// num_threads,
|
||||
// iters_per_thread
|
||||
// ))?;
|
||||
//
|
||||
// Note: For G1, the host will compute SHA3-512(y) and distances on the CPU,
|
||||
// validating correctness against cpu-fast/cpu-montgomery on small ranges.
|
||||
//
|
||||
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
// G2 additions: device SHA3-512, threshold compare, and early-exit
|
||||
// -------------------------------------------------------------------------------------------------
|
||||
|
||||
// 64-bit rotate-left with defined behavior for all n
|
||||
__device__ __forceinline__ uint64_t rotl64(uint64_t x, unsigned int n) {
|
||||
n &= 63u;
|
||||
return (x << n) | (x >> ((64u - n) & 63u));
|
||||
}
|
||||
|
||||
// Keccak-f[1600] round constants
|
||||
__device__ __constant__ uint64_t KECCAK_RC[24] = {
|
||||
0x0000000000000001ULL, 0x0000000000008082ULL,
|
||||
0x800000000000808aULL, 0x8000000080008000ULL,
|
||||
0x000000000000808bULL, 0x0000000080000001ULL,
|
||||
0x8000000080008081ULL, 0x8000000000008009ULL,
|
||||
0x000000000000008aULL, 0x0000000000000088ULL,
|
||||
0x0000000080008009ULL, 0x000000008000000aULL,
|
||||
0x000000008000808bULL, 0x800000000000008bULL,
|
||||
0x8000000000008089ULL, 0x8000000000008003ULL,
|
||||
0x8000000000008002ULL, 0x8000000000000080ULL,
|
||||
0x000000000000800aULL, 0x800000008000000aULL,
|
||||
0x8000000080008081ULL, 0x8000000000008080ULL,
|
||||
0x0000000080000001ULL, 0x8000000080008008ULL
|
||||
};
|
||||
|
||||
// Optional per-job constants in constant memory (host may set; kernel remains compatible)
|
||||
// If C_CONSTS_READY == 1, G2 kernel will prefer these over parameter pointers.
|
||||
__device__ __constant__ uint64_t C_N[8];
|
||||
__device__ __constant__ uint64_t C_R2[8];
|
||||
__device__ __constant__ uint64_t C_MHAT[8];
|
||||
__device__ __constant__ uint64_t C_N0_INV;
|
||||
__device__ __constant__ int C_CONSTS_READY;
|
||||
// Optional constant-memory target/threshold for device compare
|
||||
__device__ __constant__ uint64_t C_TARGET[8];
|
||||
__device__ __constant__ uint64_t C_THRESH[8];
|
||||
// Optional sampler controls/output (host may read these symbols when enabled)
|
||||
__device__ __constant__ int C_SAMPLER_ENABLE;
|
||||
__device__ __constant__ uint32_t C_ABI_VERSION = 3u;
|
||||
__device__ uint8_t C_SAMPLER_Y_BE[64];
|
||||
__device__ uint8_t C_SAMPLER_H_BE[64];
|
||||
__device__ uint8_t C_SAMPLER_TARGET_BE[64];
|
||||
__device__ uint8_t C_SAMPLER_THRESH_BE[64];
|
||||
__device__ uint32_t C_SAMPLER_INDEX;
|
||||
__device__ uint32_t C_SAMPLER_DECISION;
|
||||
__device__ __constant__ int C_DEBUG_FORCE_WIN;
|
||||
|
||||
// Load/store helpers (little- and big-endian)
|
||||
__device__ __forceinline__ uint64_t load64_le(const uint8_t* p) {
|
||||
return ((uint64_t)p[0]) |
|
||||
((uint64_t)p[1] << 8) |
|
||||
((uint64_t)p[2] << 16) |
|
||||
((uint64_t)p[3] << 24) |
|
||||
((uint64_t)p[4] << 32) |
|
||||
((uint64_t)p[5] << 40) |
|
||||
((uint64_t)p[6] << 48) |
|
||||
((uint64_t)p[7] << 56);
|
||||
}
|
||||
__device__ __forceinline__ void store64_le(uint8_t* p, uint64_t v) {
|
||||
p[0] = (uint8_t)(v);
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
p[2] = (uint8_t)(v >> 16);
|
||||
p[3] = (uint8_t)(v >> 24);
|
||||
p[4] = (uint8_t)(v >> 32);
|
||||
p[5] = (uint8_t)(v >> 40);
|
||||
p[6] = (uint8_t)(v >> 48);
|
||||
p[7] = (uint8_t)(v >> 56);
|
||||
}
|
||||
__device__ __forceinline__ void store64_be(uint8_t* p, uint64_t v) {
|
||||
p[0] = (uint8_t)(v >> 56);
|
||||
p[1] = (uint8_t)(v >> 48);
|
||||
p[2] = (uint8_t)(v >> 40);
|
||||
p[3] = (uint8_t)(v >> 32);
|
||||
p[4] = (uint8_t)(v >> 24);
|
||||
p[5] = (uint8_t)(v >> 16);
|
||||
p[6] = (uint8_t)(v >> 8);
|
||||
p[7] = (uint8_t)(v);
|
||||
}
|
||||
|
||||
// Convert 8 LE limbs into 64 BE bytes (big-endian numeric representation)
|
||||
__device__ __forceinline__ void le8_to_be64_bytes(const uint64_t le[8], uint8_t out[64]) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
uint64_t limb = le[7 - i]; // most significant limb first
|
||||
#pragma unroll
|
||||
for (int b = 0; b < 8; ++b) {
|
||||
out[i * 8 + (7 - b)] = (uint8_t)((limb >> (b * 8)) & 0xFF);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Compare two 64-byte big-endian numbers: return true if a <= b
|
||||
__device__ __forceinline__ bool be64_leq(const uint8_t a[64], const uint8_t b[64]) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
if (a[i] != b[i]) {
|
||||
return a[i] < b[i];
|
||||
}
|
||||
}
|
||||
return true; // equal
|
||||
}
|
||||
|
||||
// Keccak-f[1600] permutation (unrolled mapping)
|
||||
__device__ __forceinline__ void keccak_f1600(uint64_t s[25]) {
|
||||
#pragma unroll
|
||||
for (int round = 0; round < 24; ++round) {
|
||||
// ---- Theta ---------------------------------------------------------
|
||||
uint64_t Aba=s[0], Aga=s[5], Aka=s[10], Ama=s[15], Asa=s[20];
|
||||
uint64_t Abe=s[1], Age=s[6], Ake=s[11], Ame=s[16], Ase=s[21];
|
||||
uint64_t Abi=s[2], Agi=s[7], Aki=s[12], Ami=s[17], Asi=s[22];
|
||||
uint64_t Abo=s[3], Ago=s[8], Ako=s[13], Amo=s[18], Aso=s[23];
|
||||
uint64_t Abu=s[4], Agu=s[9], Aku=s[14], Amu=s[19], Asu=s[24];
|
||||
|
||||
uint64_t Ca = Aba ^ Aga ^ Aka ^ Ama ^ Asa;
|
||||
uint64_t Ce = Abe ^ Age ^ Ake ^ Ame ^ Ase;
|
||||
uint64_t Ci = Abi ^ Agi ^ Aki ^ Ami ^ Asi;
|
||||
uint64_t Co = Abo ^ Ago ^ Ako ^ Amo ^ Aso;
|
||||
uint64_t Cu = Abu ^ Agu ^ Aku ^ Amu ^ Asu;
|
||||
|
||||
uint64_t Da = rotl64(Ce, 1) ^ Cu;
|
||||
uint64_t De = rotl64(Ci, 1) ^ Ca;
|
||||
uint64_t Di = rotl64(Co, 1) ^ Ce;
|
||||
uint64_t Do = rotl64(Cu, 1) ^ Ci;
|
||||
uint64_t Du = rotl64(Ca, 1) ^ Co;
|
||||
|
||||
Aba ^= Da; Abe ^= De; Abi ^= Di; Abo ^= Do; Abu ^= Du;
|
||||
Aga ^= Da; Age ^= De; Agi ^= Di; Ago ^= Do; Agu ^= Du;
|
||||
Aka ^= Da; Ake ^= De; Aki ^= Di; Ako ^= Do; Aku ^= Du;
|
||||
Ama ^= Da; Ame ^= De; Ami ^= Di; Amo ^= Do; Amu ^= Du;
|
||||
Asa ^= Da; Ase ^= De; Asi ^= Di; Aso ^= Do; Asu ^= Du;
|
||||
|
||||
// ---- Rho + Pi ------------------------------------------------------
|
||||
uint64_t Bba = Aba;
|
||||
uint64_t Bbe = rotl64(Age, 44);
|
||||
uint64_t Bbi = rotl64(Aki, 43);
|
||||
uint64_t Bbo = rotl64(Amo, 21);
|
||||
uint64_t Bbu = rotl64(Asu, 14);
|
||||
|
||||
uint64_t Bga = rotl64(Abo, 28);
|
||||
uint64_t Bge = rotl64(Agu, 20);
|
||||
uint64_t Bgi = rotl64(Aka, 3);
|
||||
uint64_t Bgo = rotl64(Ame, 45);
|
||||
uint64_t Bgu = rotl64(Asi, 61);
|
||||
|
||||
uint64_t Bka = rotl64(Abe, 1);
|
||||
uint64_t Bke = rotl64(Agi, 6);
|
||||
uint64_t Bki = rotl64(Ako, 25);
|
||||
uint64_t Bko = rotl64(Amu, 8);
|
||||
uint64_t Bku = rotl64(Asa, 18);
|
||||
|
||||
uint64_t Bma = rotl64(Abu, 27);
|
||||
uint64_t Bme = rotl64(Aga, 36);
|
||||
uint64_t Bmi = rotl64(Ake, 10);
|
||||
uint64_t Bmo = rotl64(Ami, 15);
|
||||
uint64_t Bmu = rotl64(Aso, 56);
|
||||
|
||||
uint64_t Bsa = rotl64(Abi, 62);
|
||||
uint64_t Bse = rotl64(Ago, 55);
|
||||
uint64_t Bsi = rotl64(Aku, 39);
|
||||
uint64_t Bso = rotl64(Ama, 41);
|
||||
uint64_t Bsu = rotl64(Ase, 2);
|
||||
|
||||
// ---- Chi -----------------------------------------------------------
|
||||
Aba = Bba ^ ((~Bbe) & Bbi);
|
||||
Abe = Bbe ^ ((~Bbi) & Bbo);
|
||||
Abi = Bbi ^ ((~Bbo) & Bbu);
|
||||
Abo = Bbo ^ ((~Bbu) & Bba);
|
||||
Abu = Bbu ^ ((~Bba) & Bbe);
|
||||
|
||||
Aga = Bga ^ ((~Bge) & Bgi);
|
||||
Age = Bge ^ ((~Bgi) & Bgo);
|
||||
Agi = Bgi ^ ((~Bgo) & Bgu);
|
||||
Ago = Bgo ^ ((~Bgu) & Bga);
|
||||
Agu = Bgu ^ ((~Bga) & Bge);
|
||||
|
||||
Aka = Bka ^ ((~Bke) & Bki);
|
||||
Ake = Bke ^ ((~Bki) & Bko);
|
||||
Aki = Bki ^ ((~Bko) & Bku);
|
||||
Ako = Bko ^ ((~Bku) & Bka);
|
||||
Aku = Bku ^ ((~Bka) & Bke);
|
||||
|
||||
Ama = Bma ^ ((~Bme) & Bmi);
|
||||
Ame = Bme ^ ((~Bmi) & Bmo);
|
||||
Ami = Bmi ^ ((~Bmo) & Bmu);
|
||||
Amo = Bmo ^ ((~Bmu) & Bma);
|
||||
Amu = Bmu ^ ((~Bma) & Bme);
|
||||
|
||||
Asa = Bsa ^ ((~Bse) & Bsi);
|
||||
Ase = Bse ^ ((~Bsi) & Bso);
|
||||
Asi = Bsi ^ ((~Bso) & Bsu);
|
||||
Aso = Bso ^ ((~Bsu) & Bsa);
|
||||
Asu = Bsu ^ ((~Bsa) & Bse);
|
||||
|
||||
// ---- Iota ----------------------------------------------------------
|
||||
Aba ^= KECCAK_RC[round];
|
||||
|
||||
// Store back
|
||||
s[0]=Aba; s[5]=Aga; s[10]=Aka; s[15]=Ama; s[20]=Asa;
|
||||
s[1]=Abe; s[6]=Age; s[11]=Ake; s[16]=Ame; s[21]=Ase;
|
||||
s[2]=Abi; s[7]=Agi; s[12]=Aki; s[17]=Ami; s[22]=Asi;
|
||||
s[3]=Abo; s[8]=Ago; s[13]=Ako; s[18]=Amo; s[23]=Aso;
|
||||
s[4]=Abu; s[9]=Agu; s[14]=Aku; s[19]=Amu; s[24]=Asu;
|
||||
}
|
||||
}
|
||||
|
||||
// Device SHA3-512 for a single 64-byte message; writes lane-LE bytes to out_le64
|
||||
// Note: Input is treated as raw message bytes. We absorb them directly into the Keccak rate
|
||||
// as little-endian 64-bit lanes to mirror the host sha3 crate semantics.
|
||||
__device__ __forceinline__ void sha3_512_64bytes_le(const uint8_t in_msg_bytes[64], uint8_t out_le64[64]) {
|
||||
// Initialize state to zero
|
||||
uint64_t s[25];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 25; ++i) s[i] = 0ull;
|
||||
|
||||
// Absorb (rate = 72 bytes). Message is 64 bytes: append 0x06 then pad with zeros and set last of rate |= 0x80
|
||||
uint8_t block[72];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 72; ++i) block[i] = 0;
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) block[i] = in_msg_bytes[i];
|
||||
block[64] = 0x06;
|
||||
block[71] ^= 0x80;
|
||||
|
||||
// XOR into state lanes as little-endian 64-bit words
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 9; ++i) {
|
||||
s[i] ^= load64_le(&block[i * 8]);
|
||||
}
|
||||
|
||||
// Permute
|
||||
keccak_f1600(s);
|
||||
|
||||
// Squeeze 64 bytes (8 lanes) into little-endian lane bytes
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
store64_le(&out_le64[i * 8], s[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Kernel: G2 — device SHA3-512 + threshold compare + early-exit
|
||||
extern "C" __global__ void qpow_montgomery_g2_kernel(
|
||||
// Per-job constants (each 8 limbs, LE)
|
||||
const uint64_t* __restrict__ m,
|
||||
const uint64_t* __restrict__ n,
|
||||
const uint64_t n0_inv,
|
||||
const uint64_t* __restrict__ r2,
|
||||
const uint64_t* __restrict__ m_hat,
|
||||
|
||||
// Per-thread starting state (normal domain)
|
||||
const uint64_t* __restrict__ y0, // length: num_threads * 8 limbs
|
||||
|
||||
// G2-specific inputs/outputs
|
||||
const uint8_t* __restrict__ target_be, // 64 bytes
|
||||
const uint8_t* __restrict__ threshold_be, // 64 bytes
|
||||
int* __restrict__ found_flag, // 0 -> not found, 1 -> found
|
||||
uint32_t* __restrict__ out_index, // linear index (t * iters + j)
|
||||
uint32_t* __restrict__ out_win_tid, // winner thread id (optional)
|
||||
uint32_t* __restrict__ out_win_j, // winner iteration j (optional)
|
||||
uint8_t* __restrict__ out_distance_be, // 64 bytes
|
||||
// Debug output buffers (optional; host may pass nullptrs)
|
||||
uint8_t* __restrict__ out_dbg_y_be, // 64 bytes (optional)
|
||||
uint8_t* __restrict__ out_dbg_h_be, // 64 bytes (optional)
|
||||
|
||||
// Threading parameters
|
||||
const uint32_t num_threads,
|
||||
const uint32_t iters_per_thread,
|
||||
const uint64_t covered_elems
|
||||
) {
|
||||
const uint32_t tid = blockIdx.x * blockDim.x + threadIdx.x;
|
||||
if (tid >= num_threads) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Quick early-exit check
|
||||
if (atomicAdd(found_flag, 0) != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Local copies of constants (prefer __constant__ if available)
|
||||
uint64_t n_loc[8], r2_loc[8], mhat_loc[8];
|
||||
if (C_CONSTS_READY) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
n_loc[i] = C_N[i];
|
||||
r2_loc[i] = C_R2[i];
|
||||
mhat_loc[i] = C_MHAT[i];
|
||||
}
|
||||
} else {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
n_loc[i] = n[i];
|
||||
r2_loc[i] = r2[i];
|
||||
mhat_loc[i] = m_hat[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Load this thread's y0 (normal domain) and move to Montgomery domain
|
||||
uint64_t y0_loc[8];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
y0_loc[i] = y0[tid * 8u + i];
|
||||
}
|
||||
const uint64_t n0i = C_CONSTS_READY ? C_N0_INV : n0_inv;
|
||||
uint64_t yhat[8];
|
||||
to_mont_512(y0_loc, r2_loc, n_loc, n0i, yhat);
|
||||
|
||||
// Prepare target/threshold big-endian bytes (numeric)
|
||||
uint8_t target_be_bytes[64], thresh_be_bytes[64];
|
||||
if (C_CONSTS_READY) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
store64_be(&target_be_bytes[i * 8], C_TARGET[i]);
|
||||
store64_be(&thresh_be_bytes[i * 8], C_THRESH[i]);
|
||||
}
|
||||
} else {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
target_be_bytes[i] = target_be[i];
|
||||
thresh_be_bytes[i] = threshold_be[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Bound per-thread work to the assigned coverage to avoid hashing beyond the range
|
||||
uint64_t thread_base = (uint64_t)tid * (uint64_t)iters_per_thread;
|
||||
if (thread_base >= covered_elems) {
|
||||
return;
|
||||
}
|
||||
uint64_t remain_for_thread = covered_elems - thread_base;
|
||||
uint32_t iters = iters_per_thread;
|
||||
if ((uint64_t)iters > remain_for_thread) {
|
||||
iters = (uint32_t)remain_for_thread;
|
||||
}
|
||||
// Iterate and check threshold
|
||||
for (uint32_t j = 0; j < iters; ++j) {
|
||||
// Respect early-exit
|
||||
if (atomicAdd(found_flag, 0) != 0) {
|
||||
return;
|
||||
}
|
||||
// Debug: force a winner to exercise GPU->CPU signaling
|
||||
if (C_DEBUG_FORCE_WIN != 0 && tid == 0 && j == 0) {
|
||||
if (atomicCAS(found_flag, 0, 1) == 0) {
|
||||
if (out_index) {
|
||||
*out_index = tid * iters_per_thread + j;
|
||||
}
|
||||
if (out_win_tid) {
|
||||
*out_win_tid = tid;
|
||||
}
|
||||
if (out_win_j) {
|
||||
*out_win_j = j;
|
||||
}
|
||||
if (out_distance_be) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
out_distance_be[i] = 0;
|
||||
}
|
||||
}
|
||||
if (out_dbg_y_be) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
out_dbg_y_be[i] = 0;
|
||||
}
|
||||
}
|
||||
if (out_dbg_h_be) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
out_dbg_h_be[i] = 0;
|
||||
}
|
||||
}
|
||||
if (C_SAMPLER_ENABLE && tid == 0) {
|
||||
C_SAMPLER_INDEX = tid * iters_per_thread + j;
|
||||
C_SAMPLER_DECISION = 1u;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// y_hat = y_hat * m_hat
|
||||
uint64_t yhat_next[8];
|
||||
mont_mul_512(yhat, mhat_loc, n_loc, n0i, yhat_next);
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 8; ++i) {
|
||||
yhat[i] = yhat_next[i];
|
||||
}
|
||||
|
||||
// y = from_mont(y_hat)
|
||||
uint64_t y_norm[8];
|
||||
from_mont_512(yhat, n_loc, n0i, y_norm);
|
||||
|
||||
// y_be64 (64 bytes) from LE limbs
|
||||
uint8_t y_be[64];
|
||||
le8_to_be64_bytes(y_norm, y_be);
|
||||
|
||||
// H = SHA3-512(y_be) -> produce lane-LE bytes
|
||||
uint8_t h_le[64];
|
||||
sha3_512_64bytes_le(y_be, h_le);
|
||||
|
||||
// Use SHA3 output bytes directly to match host pow-core digest semantics
|
||||
uint8_t digest_be[64];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
digest_be[i] = h_le[i];
|
||||
}
|
||||
|
||||
// distance = target_be XOR digest_be (bytewise, big-endian order)
|
||||
uint8_t dist_be[64];
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
dist_be[i] = target_be_bytes[i] ^ digest_be[i];
|
||||
}
|
||||
|
||||
// Compare distance <= threshold (lexicographic on big-endian bytes)
|
||||
bool decision = be64_leq(dist_be, thresh_be_bytes);
|
||||
|
||||
// Optional sampler (first thread/iter): capture y/H/target/thresh for parity
|
||||
if (C_SAMPLER_ENABLE && tid == 0 && j == 0) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
C_SAMPLER_Y_BE[i] = y_be[i];
|
||||
C_SAMPLER_H_BE[i] = digest_be[i];
|
||||
C_SAMPLER_TARGET_BE[i] = target_be_bytes[i];
|
||||
C_SAMPLER_THRESH_BE[i] = thresh_be_bytes[i];
|
||||
}
|
||||
C_SAMPLER_INDEX = tid * iters_per_thread + j;
|
||||
C_SAMPLER_DECISION = decision ? 1u : 0u;
|
||||
}
|
||||
|
||||
if (decision) {
|
||||
// Try to claim the flag
|
||||
if (atomicCAS(found_flag, 0, 1) == 0) {
|
||||
// Write linear index for host to reconstruct nonce
|
||||
if (out_index) {
|
||||
*out_index = tid * iters_per_thread + j;
|
||||
}
|
||||
// Record winner thread and iteration for host-side nonce reconstruction
|
||||
if (out_win_tid) {
|
||||
*out_win_tid = tid;
|
||||
}
|
||||
if (out_win_j) {
|
||||
*out_win_j = j;
|
||||
}
|
||||
// Write distance and debug buffers (if provided)
|
||||
if (out_distance_be) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
out_distance_be[i] = dist_be[i];
|
||||
}
|
||||
}
|
||||
if (out_dbg_y_be) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
out_dbg_y_be[i] = y_be[i];
|
||||
}
|
||||
}
|
||||
if (out_dbg_h_be) {
|
||||
#pragma unroll
|
||||
for (int i = 0; i < 64; ++i) {
|
||||
out_dbg_h_be[i] = digest_be[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
return; // early-exit after claiming (or if already claimed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
[package]
|
||||
name = "engine-gpu-opencl"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "OpenCL-based GPU mining engine for the Quantus External Miner (placeholder crate)"
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
crate-type = ["rlib"]
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable OpenCL integrations when implementing the engine.
|
||||
opencl = ["dep:ocl"]
|
||||
|
||||
[dependencies]
|
||||
pow-core = { path = "../pow-core" }
|
||||
primitive-types = { workspace = true }
|
||||
log = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
# OpenCL ecosystem (optional for now; will be used when implementing GPU backend)
|
||||
ocl = { version = "0.19", optional = true }
|
||||
@@ -1,101 +0,0 @@
|
||||
// #![deny(rust_2018_idioms)]
|
||||
// #![forbid(unsafe_code)]
|
||||
|
||||
// //! OpenCL-based GPU mining engine (placeholder)
|
||||
// //!
|
||||
// //! This crate is a scaffold for a future OpenCL backend that will implement the
|
||||
// //! mining engine interface used by the service layer. It currently provides:
|
||||
// //! - An `OpenClEngine` type with a constructor and basic helpers.
|
||||
// //! - Documentation of the intended integration points.
|
||||
// //!
|
||||
// //! Planned responsibilities (non-exhaustive):
|
||||
// //! - Accept a prepared `JobContext` (from `pow-core`) per job.
|
||||
// //! - Partition nonce ranges into GPU work assignments.
|
||||
// //! - Run an OpenCL kernel that performs, per nonce in the range:
|
||||
// //! - y <- y * m (mod n) using Montgomery multiplication (in Montgomery domain)
|
||||
// //! - nonce_element <- SHA3_512(y) in the normal domain
|
||||
// //! - distance <- target XOR nonce_element
|
||||
// //! - if distance <= threshold: report solution and signal early-cancel
|
||||
// //! - Coordinate early-exit via device-global flags and host polling.
|
||||
// //!
|
||||
// //! Notes:
|
||||
// //! - This crate deliberately does NOT implement the `MinerEngine` trait yet,
|
||||
// //! because the engine trait currently lives in `engine-cpu`. Once the trait
|
||||
// //! is promoted to a shared crate (or re-exported for engines), this crate
|
||||
// //! will implement it and become selectable at runtime via the service config.
|
||||
// //! - OpenCL bindings (e.g., via the `ocl` crate) and kernels will be added
|
||||
// //! behind feature flags (e.g., `opencl`). For now, we only offer placeholders
|
||||
// //! so the workspace compiles cleanly and the integration points are clear.
|
||||
|
||||
// use pow_core::JobContext;
|
||||
// use primitive_types::U512;
|
||||
|
||||
// /// Placeholder type for the OpenCL engine.
|
||||
// ///
|
||||
// /// When fully implemented, this engine will manage OpenCL platform/device
|
||||
// /// discovery, context/queue creation, kernel compilation, memory transfers,
|
||||
// /// and kernel launches. It will expose the same search-range semantics as
|
||||
// /// the CPU engine(s) but backed by the GPU.
|
||||
// #[derive(Default, Debug)]
|
||||
// pub struct OpenClEngine {
|
||||
// // Future fields (examples):
|
||||
// // platform_id: usize,
|
||||
// // device_id: usize,
|
||||
// // context: ocl::Context,
|
||||
// // queue: ocl::Queue,
|
||||
// // program: ocl::Program,
|
||||
// // kernel: ocl::Kernel,
|
||||
// }
|
||||
|
||||
// impl OpenClEngine {
|
||||
// /// Construct a new OpenCL engine placeholder.
|
||||
// ///
|
||||
// /// Future versions may accept configuration (e.g., platform/device index).
|
||||
// pub fn new() -> Self {
|
||||
// Self::default()
|
||||
// }
|
||||
|
||||
// /// Human-readable name for logs/metrics.
|
||||
// pub fn name(&self) -> &'static str {
|
||||
// "gpu-opencl (placeholder)"
|
||||
// }
|
||||
|
||||
// /// Prepare a precomputed job context for a given header and threshold.
|
||||
// ///
|
||||
// /// This defers to `pow-core` to derive (m, n) and `target` from the header.
|
||||
// /// In a full OpenCL implementation, this context will be uploaded to device
|
||||
// /// constant buffers or passed as kernel arguments.
|
||||
// pub fn prepare_context(&self, header_hash: [u8; 32], threshold: U512) -> JobContext {
|
||||
// JobContext::new(header_hash, threshold)
|
||||
// }
|
||||
|
||||
// /// Returns whether this build has OpenCL support compiled in.
|
||||
// ///
|
||||
// /// When actual OpenCL integration is added behind a feature flag, this will
|
||||
// /// return true only if that feature is enabled.
|
||||
// pub fn opencl_available(&self) -> bool {
|
||||
// // Adjust once actual OpenCL integration is implemented behind a feature:
|
||||
// // cfg!(feature = "opencl")
|
||||
// false
|
||||
// }
|
||||
// }
|
||||
|
||||
// #[cfg(test)]
|
||||
// mod tests {
|
||||
// use super::*;
|
||||
// use primitive_types::U512;
|
||||
|
||||
// #[test]
|
||||
// fn placeholder_engine_basics() {
|
||||
// let eng = OpenClEngine::new();
|
||||
// assert_eq!(eng.name(), "gpu-opencl (placeholder)");
|
||||
|
||||
// // Ensure context creation works and is deterministic in shape.
|
||||
// let header = [1u8; 32];
|
||||
// let threshold = U512::from(12345u64);
|
||||
// let ctx = eng.prepare_context(header, threshold);
|
||||
|
||||
// assert_eq!(ctx.header, header);
|
||||
// assert_eq!(ctx.threshold, threshold);
|
||||
// }
|
||||
// }
|
||||
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "engine-gpu"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "GPU mining engine implementation for the Quantus External Miner"
|
||||
|
||||
# [lib]
|
||||
# path = "src/main.rs"
|
||||
# crate-type = ["rlib"]
|
||||
|
||||
[features]
|
||||
# Default to the baseline/reference implementation; enable others as needed.
|
||||
default = ["baseline"]
|
||||
|
||||
# Map engine features (pow-core dependency removed for now)
|
||||
baseline = []
|
||||
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 }
|
||||
qp-poseidon-core = { workspace = true }
|
||||
qp-poseidon-constants = "1.1"
|
||||
qp-plonky2-field = { version = "1.1.1" }
|
||||
plonky2 = { package = "qp-plonky2", version = "1.1.3" }
|
||||
|
||||
wgpu = { version = "27.0.1" } # GPU compute library
|
||||
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "time"] } # Async runtime with timeout support
|
||||
futures = "0.3" # For async oneshot channels in test modules
|
||||
bytemuck = "1.16" # For buffer mapping
|
||||
rand = { workspace = true, features = ["std", "std_rng"] }
|
||||
rand_chacha = "0.9" # For deterministic random test generation
|
||||
regex = "1" # For GPU name pattern matching
|
||||
|
||||
[dev-dependencies]
|
||||
hex = { workspace = true }
|
||||
criterion = "0.5"
|
||||
rand = { workspace = true, features = ["std", "std_rng"] }
|
||||
env_logger.workspace = true
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } # Add macros for #[tokio::main]
|
||||
|
||||
[[bench]]
|
||||
name = "gpu_engine_bench"
|
||||
harness = false
|
||||
@@ -0,0 +1,363 @@
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use engine_cpu::{AtomicBoolCancelCheck, FastCpuEngine, MinerEngine, Range};
|
||||
use engine_gpu::GpuEngine;
|
||||
use pow_core::JobContext;
|
||||
use primitive_types::U512;
|
||||
use rand::RngCore;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
/// Drop thread-local wgpu buffers before `GpuEngine` is dropped. Criterion
|
||||
/// creates a fresh engine per group; without this, TLS buffers outlive the
|
||||
/// device and the next group panics (`Buffer[…] does not exist`).
|
||||
fn teardown_gpu(engine: GpuEngine) {
|
||||
GpuEngine::clear_worker_resources();
|
||||
drop(engine);
|
||||
}
|
||||
|
||||
fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
// Small range: 10K nonces - reasonable for benchmarking
|
||||
let small_range = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(10_000u64),
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("small_range_10k");
|
||||
group.sample_size(10);
|
||||
group.measurement_time(std::time::Duration::from_secs(3));
|
||||
|
||||
group.bench_function("cpu", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = cpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(small_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("gpu", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(small_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
// Medium range: 100K nonces
|
||||
let medium_range = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(100_000u64),
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("medium_range_100k");
|
||||
group.sample_size(10);
|
||||
group.measurement_time(std::time::Duration::from_secs(3));
|
||||
|
||||
group.bench_function("cpu", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = cpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(medium_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("gpu", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(medium_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
// Large range: 1M nonces - where GPU should really shine
|
||||
let large_range = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(1_000_000u64),
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("large_range_1m");
|
||||
group.sample_size(10);
|
||||
group.measurement_time(std::time::Duration::from_secs(5));
|
||||
|
||||
group.bench_function("cpu", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = cpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(large_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("gpu", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(large_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_solution_finding(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
// Range where we expect to find solutions quickly
|
||||
let solution_range = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(50_000u64),
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("solution_finding");
|
||||
group.sample_size(10);
|
||||
group.measurement_time(std::time::Duration::from_secs(3));
|
||||
|
||||
group.bench_function("cpu_find_solution", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(10_000u64); // Easy difficulty - should find solution
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = cpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(solution_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("gpu_find_solution", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(10_000u64); // Easy difficulty - should find solution
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(solution_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_throughput_per_second(c: &mut Criterion) {
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
// Fixed time benchmark - see how many hashes we can do in 1 second
|
||||
let throughput_range = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(10_000_000u64), // 10M nonce range
|
||||
};
|
||||
|
||||
let mut group = c.benchmark_group("throughput_comparison");
|
||||
group.sample_size(10);
|
||||
group.measurement_time(std::time::Duration::from_secs(5));
|
||||
|
||||
group.bench_function("cpu_throughput", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = cpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(throughput_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("gpu_throughput", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(throughput_range.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_gpu_batch_efficiency(c: &mut Criterion) {
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
let mut group = c.benchmark_group("gpu_batch_sizes");
|
||||
group.sample_size(10);
|
||||
group.measurement_time(std::time::Duration::from_secs(3));
|
||||
|
||||
// Test different batch sizes to see GPU efficiency
|
||||
let small_batch = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(1_000u64), // 1K nonces - very small for GPU
|
||||
};
|
||||
|
||||
let medium_batch = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(50_000u64), // 50K nonces - medium
|
||||
};
|
||||
|
||||
let large_batch = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(500_000u64), // 500K nonces - large
|
||||
};
|
||||
|
||||
group.bench_function("gpu_1k_batch", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX);
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(small_batch.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("gpu_50k_batch", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX);
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(medium_batch.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.bench_function("gpu_500k_batch", |b| {
|
||||
b.iter(|| {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::from(u64::MAX);
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
let result = gpu_engine.search_range(
|
||||
black_box(&ctx),
|
||||
black_box(large_batch.clone()),
|
||||
black_box(&cancel_check),
|
||||
);
|
||||
black_box(result)
|
||||
})
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_cpu_vs_gpu_small,
|
||||
bench_cpu_vs_gpu_medium,
|
||||
bench_cpu_vs_gpu_large,
|
||||
bench_solution_finding,
|
||||
bench_throughput_per_second,
|
||||
bench_gpu_batch_efficiency
|
||||
);
|
||||
criterion_main!(benches);
|
||||
@@ -0,0 +1,63 @@
|
||||
use engine_cpu::{AtomicBoolCancelCheck, EngineStatus, MinerEngine, Range};
|
||||
use engine_gpu::GpuEngine;
|
||||
use primitive_types::U512;
|
||||
use rand::RngCore;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let jobs: usize = std::env::args()
|
||||
.nth(1)
|
||||
.map(|s| s.parse().expect("job count"))
|
||||
.unwrap_or(25);
|
||||
|
||||
let engine = GpuEngine::try_new(1_000_000, 0, false).expect("GPU init failed");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel = AtomicBoolCancelCheck(&cancel_flag);
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let mut found = 0usize;
|
||||
for job in 0..jobs {
|
||||
let mut header = [0u8; 32];
|
||||
rng.fill_bytes(&mut header);
|
||||
let difficulty = U512::from(100_000u64);
|
||||
let ctx = engine.prepare_context(header, difficulty);
|
||||
|
||||
let start = if job == 0 {
|
||||
// Cross a 2^256 boundary: the high nonce half changes mid-range,
|
||||
// exercising the midstate batch clamp.
|
||||
(U512::from(3u64) << 256) - U512::from(1_000u64)
|
||||
} else {
|
||||
let mut start_bytes = [0u8; 64];
|
||||
rng.fill_bytes(&mut start_bytes);
|
||||
// Keep clear of the very top so range arithmetic cannot wrap
|
||||
start_bytes[0] = 0;
|
||||
U512::from_big_endian(&start_bytes)
|
||||
};
|
||||
let range = Range {
|
||||
start,
|
||||
end: start + U512::from(10_000_000u64),
|
||||
};
|
||||
|
||||
match engine.search_range(&ctx, range.clone(), &cancel) {
|
||||
EngineStatus::Found { candidate, .. } => {
|
||||
let cpu_hash = pow_core::hash_from_nonce(&ctx, candidate.nonce);
|
||||
assert_eq!(
|
||||
cpu_hash, candidate.hash,
|
||||
"job {job}: GPU hash != CPU hash for nonce {}",
|
||||
candidate.nonce
|
||||
);
|
||||
assert!(cpu_hash < ctx.target, "job {job}: hash not below target");
|
||||
assert!(
|
||||
candidate.nonce >= range.start && candidate.nonce <= range.end,
|
||||
"job {job}: nonce outside range"
|
||||
);
|
||||
found += 1;
|
||||
}
|
||||
EngineStatus::Exhausted { .. } => {}
|
||||
other => panic!("job {job}: unexpected status {other:?}"),
|
||||
}
|
||||
}
|
||||
assert!(found > 0, "no solutions found across {jobs} jobs");
|
||||
println!("PARITY OK: {found}/{jobs} jobs found solutions, all verified against CPU");
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
fn main() {
|
||||
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::PRIMARY,
|
||||
..Default::default()
|
||||
});
|
||||
for adapter in instance.enumerate_adapters(wgpu::Backends::PRIMARY) {
|
||||
let info = adapter.get_info();
|
||||
let features = adapter.features();
|
||||
println!("{} ({:?}, {:?})", info.name, info.device_type, info.backend);
|
||||
println!(
|
||||
" SHADER_INT64: {}",
|
||||
features.contains(wgpu::Features::SHADER_INT64)
|
||||
);
|
||||
println!(
|
||||
" SUBGROUP: {}",
|
||||
features.contains(wgpu::Features::SUBGROUP)
|
||||
);
|
||||
println!(
|
||||
" TIMESTAMP_QUERY: {}",
|
||||
features.contains(wgpu::Features::TIMESTAMP_QUERY)
|
||||
);
|
||||
let limits = adapter.limits();
|
||||
println!(
|
||||
" max_workgroup_size_x: {}, max_invocations: {}, max_workgroups_per_dim: {}",
|
||||
limits.max_compute_workgroup_size_x,
|
||||
limits.max_compute_invocations_per_workgroup,
|
||||
limits.max_compute_workgroups_per_dimension
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
use engine_cpu::{AtomicBoolCancelCheck, MinerEngine, Range};
|
||||
use engine_gpu::GpuEngine;
|
||||
use primitive_types::U512;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let total: u64 = args
|
||||
.get(1)
|
||||
.map(|s| s.parse().expect("total nonces"))
|
||||
.unwrap_or(4_000_000);
|
||||
let batch: u32 = args
|
||||
.get(2)
|
||||
.map(|s| s.parse().expect("batch size"))
|
||||
.unwrap_or(1_000_000);
|
||||
|
||||
let engine = GpuEngine::try_new(batch, 0, false).expect("GPU init failed");
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel = AtomicBoolCancelCheck(&cancel_flag);
|
||||
|
||||
let header = [42u8; 32];
|
||||
let ctx = engine.prepare_context(header, U512::from(u64::MAX));
|
||||
|
||||
let range = Range {
|
||||
start: U512::from(1u64) << 200,
|
||||
end: (U512::from(1u64) << 200) + U512::from(total - 1),
|
||||
};
|
||||
|
||||
// Warmup
|
||||
let warm = Range {
|
||||
start: range.start,
|
||||
end: range.start + U512::from(200_000u64),
|
||||
};
|
||||
engine.search_range(&ctx, warm, &cancel);
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let status = engine.search_range(&ctx, range, &cancel);
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
|
||||
let hashes = match status {
|
||||
engine_cpu::EngineStatus::Exhausted { hash_count } => hash_count,
|
||||
engine_cpu::EngineStatus::Found { hash_count, .. } => hash_count,
|
||||
other => panic!("unexpected status: {other:?}"),
|
||||
};
|
||||
println!(
|
||||
"{} hashes in {:.3}s = {:.3} MH/s",
|
||||
hashes,
|
||||
elapsed,
|
||||
hashes as f64 / elapsed / 1e6
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
use pow_core::{hash_from_nonce, mining_midstate, JobContext};
|
||||
use primitive_types::U512;
|
||||
use std::time::Instant;
|
||||
|
||||
fn u512_to_u32s_le(v: U512) -> [u32; 16] {
|
||||
let bytes = v.to_little_endian();
|
||||
let mut out = [0u32; 16];
|
||||
for i in 0..16 {
|
||||
out[i] = u32::from_le_bytes(bytes[i * 4..(i + 1) * 4].try_into().unwrap());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Runner {
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
results: wgpu::Buffer,
|
||||
midstate: wgpu::Buffer,
|
||||
start_nonce: wgpu::Buffer,
|
||||
target: wgpu::Buffer,
|
||||
cfg: wgpu::Buffer,
|
||||
staging: wgpu::Buffer,
|
||||
}
|
||||
|
||||
impl Runner {
|
||||
async fn new(trusted: bool) -> Self {
|
||||
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::PRIMARY,
|
||||
..Default::default()
|
||||
});
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions::default())
|
||||
.await
|
||||
.expect("no adapter");
|
||||
assert!(
|
||||
adapter.features().contains(wgpu::Features::SHADER_INT64),
|
||||
"SHADER_INT64 required"
|
||||
);
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor {
|
||||
label: None,
|
||||
required_features: wgpu::Features::SHADER_INT64,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let kernel = engine_gpu::Kernel::for_adapter(&adapter);
|
||||
let desc = wgpu::ShaderModuleDescriptor {
|
||||
label: Some(kernel.label()),
|
||||
source: wgpu::ShaderSource::Wgsl(kernel.source().into()),
|
||||
};
|
||||
let shader = if trusted {
|
||||
unsafe {
|
||||
device.create_shader_module_trusted(desc, wgpu::ShaderRuntimeChecks::unchecked())
|
||||
}
|
||||
} else {
|
||||
device.create_shader_module(desc)
|
||||
};
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
module: &shader,
|
||||
entry_point: Some("mining_main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
let mk = |size: u64, usage| {
|
||||
device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size,
|
||||
usage,
|
||||
mapped_at_creation: false,
|
||||
})
|
||||
};
|
||||
use wgpu::BufferUsages as U;
|
||||
Runner {
|
||||
pipeline,
|
||||
results: mk(132, U::STORAGE | U::COPY_SRC | U::COPY_DST),
|
||||
midstate: mk(96, U::STORAGE | U::COPY_DST),
|
||||
start_nonce: mk(64, U::STORAGE | U::COPY_DST),
|
||||
target: mk(64, U::STORAGE | U::COPY_DST),
|
||||
cfg: mk(12, U::STORAGE | U::COPY_DST),
|
||||
staging: mk(132, U::MAP_READ | U::COPY_DST),
|
||||
device,
|
||||
queue,
|
||||
}
|
||||
}
|
||||
|
||||
fn run_batch(&self, header: [u8; 32], start: U512, batch: u32, target: U512) -> f64 {
|
||||
let nonce_be = start.to_big_endian();
|
||||
let mid = mining_midstate(header, nonce_be[..32].try_into().unwrap());
|
||||
let mut mid_u32 = [0u32; 24];
|
||||
for (i, f) in mid.iter().enumerate() {
|
||||
mid_u32[2 * i] = *f as u32;
|
||||
mid_u32[2 * i + 1] = (*f >> 32) as u32;
|
||||
}
|
||||
self.queue
|
||||
.write_buffer(&self.midstate, 0, bytemuck::cast_slice(&mid_u32));
|
||||
self.queue.write_buffer(
|
||||
&self.start_nonce,
|
||||
0,
|
||||
bytemuck::cast_slice(&u512_to_u32s_le(start)),
|
||||
);
|
||||
self.queue.write_buffer(
|
||||
&self.target,
|
||||
0,
|
||||
bytemuck::cast_slice(&u512_to_u32s_le(target)),
|
||||
);
|
||||
self.queue
|
||||
.write_buffer(&self.cfg, 0, bytemuck::cast_slice(&[batch, 1u32, batch]));
|
||||
self.queue.write_buffer(&self.results, 0, &[0u8; 132]);
|
||||
|
||||
let layout = self.pipeline.get_bind_group_layout(0);
|
||||
let entries: Vec<wgpu::BindGroupEntry<'_>> = [
|
||||
(0, &self.results),
|
||||
(1, &self.midstate),
|
||||
(2, &self.start_nonce),
|
||||
(3, &self.target),
|
||||
(4, &self.cfg),
|
||||
]
|
||||
.iter()
|
||||
.map(|(i, b)| wgpu::BindGroupEntry {
|
||||
binding: *i,
|
||||
resource: b.as_entire_binding(),
|
||||
})
|
||||
.collect();
|
||||
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &layout,
|
||||
entries: &entries,
|
||||
});
|
||||
|
||||
let t0 = Instant::now();
|
||||
let mut encoder = self
|
||||
.device
|
||||
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
|
||||
{
|
||||
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: None,
|
||||
timestamp_writes: None,
|
||||
});
|
||||
cpass.set_pipeline(&self.pipeline);
|
||||
cpass.set_bind_group(0, &bind_group, &[]);
|
||||
cpass.dispatch_workgroups(batch.div_ceil(256), 1, 1);
|
||||
}
|
||||
encoder.copy_buffer_to_buffer(&self.results, 0, &self.staging, 0, 132);
|
||||
self.queue.submit(Some(encoder.finish()));
|
||||
|
||||
let slice = self.staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
|
||||
loop {
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
if rx.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let elapsed = t0.elapsed().as_secs_f64();
|
||||
self.staging.unmap();
|
||||
elapsed
|
||||
}
|
||||
|
||||
fn read_results(&self) -> [u32; 33] {
|
||||
let slice = self.staging.slice(..);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
slice.map_async(wgpu::MapMode::Read, move |r| tx.send(r).unwrap());
|
||||
let _ = self.device.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
});
|
||||
rx.recv().unwrap().unwrap();
|
||||
let data = slice.get_mapped_range();
|
||||
let mut out = [0u32; 33];
|
||||
out.copy_from_slice(bytemuck::cast_slice(&data));
|
||||
drop(data);
|
||||
self.staging.unmap();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let batch: u32 = std::env::args()
|
||||
.nth(1)
|
||||
.map(|s| s.parse().unwrap())
|
||||
.unwrap_or(8_000_000);
|
||||
let iters = 4u32;
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
|
||||
for trusted in [false, true] {
|
||||
let label = if trusted {
|
||||
"trusted (no checks)"
|
||||
} else {
|
||||
"checked (default)"
|
||||
};
|
||||
let runner = rt.block_on(Runner::new(trusted));
|
||||
|
||||
let header = [9u8; 32];
|
||||
let ctx = JobContext::new(header, U512::one());
|
||||
let start = (U512::from(7u64) << 300) | U512::from(123456789u64);
|
||||
runner.run_batch(header, start, 256, ctx.target);
|
||||
let r = runner.read_results();
|
||||
assert_eq!(r[0], 1, "{label}: no solution with target=MAX");
|
||||
let nonce = U512::from_little_endian(bytemuck::cast_slice(&r[1..17]));
|
||||
let hash = U512::from_little_endian(bytemuck::cast_slice(&r[17..33]));
|
||||
assert_eq!(hash, hash_from_nonce(&ctx, nonce), "{label}: hash mismatch");
|
||||
println!("{label}: correctness OK");
|
||||
|
||||
let header = [42u8; 32];
|
||||
let start = U512::from(1u64) << 200;
|
||||
let target = U512::one();
|
||||
runner.run_batch(header, start, batch, target);
|
||||
let mut total = 0.0;
|
||||
for i in 0..iters {
|
||||
total += runner.run_batch(
|
||||
header,
|
||||
start + U512::from((i as u64 + 1) * batch as u64),
|
||||
batch,
|
||||
target,
|
||||
);
|
||||
}
|
||||
let mhs = (batch as f64 * iters as f64) / total / 1e6;
|
||||
println!("{label}: {batch} nonces x{iters} in {total:.3}s = {mhs:.3} MH/s");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
use engine_cpu::{AtomicBoolCancelCheck, EngineStatus, FastCpuEngine, MinerEngine, Range};
|
||||
use engine_gpu::GpuEngine;
|
||||
use primitive_types::U512;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
fn main() {
|
||||
// Initialize logging
|
||||
env_logger::builder()
|
||||
.filter_level(log::LevelFilter::Info)
|
||||
.parse_default_env()
|
||||
.init();
|
||||
|
||||
log::info!("Starting verify_nonce example");
|
||||
|
||||
// 1. Setup Context
|
||||
// Use a fixed header and easy difficulty (1) so any nonce is valid
|
||||
let header = [1u8; 32];
|
||||
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
|
||||
let cpu_engine = FastCpuEngine::new(10_000);
|
||||
let ctx = cpu_engine.prepare_context(header, difficulty);
|
||||
|
||||
log::info!("Context prepared. Difficulty: {}", difficulty);
|
||||
|
||||
let cancel = AtomicBool::new(false);
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel);
|
||||
|
||||
// 3. Verify with GPU engine
|
||||
log::info!("Initializing GPU engine...");
|
||||
let gpu_engine = GpuEngine::try_new(10_000_000, 0, false).expect("Failed to init GPU");
|
||||
|
||||
// Search a small range around the valid nonce
|
||||
let gpu_range = Range {
|
||||
start: U512::from(0u64),
|
||||
end: U512::from(1_000_000u64), // Search 1,000,000 nonces
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Searching for nonce with GPU engine in range {} - {}",
|
||||
gpu_range.start,
|
||||
gpu_range.end
|
||||
);
|
||||
let start = std::time::Instant::now();
|
||||
let gpu_result = gpu_engine.search_range(&ctx, gpu_range, &cancel_check);
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
log::info!("GPU search took {:?}", elapsed);
|
||||
|
||||
match gpu_result {
|
||||
EngineStatus::Found { candidate, .. } => {
|
||||
log::info!("GPU found nonce: {}", candidate.nonce);
|
||||
log::info!("GPU hash: {:x}", candidate.hash);
|
||||
}
|
||||
EngineStatus::Exhausted { .. } => {
|
||||
log::info!("GPU exhausted range (expected)");
|
||||
}
|
||||
EngineStatus::Cancelled { .. } => {
|
||||
log::error!("FAILURE: GPU search cancelled!");
|
||||
}
|
||||
EngineStatus::DeviceLost { .. } => {
|
||||
log::error!("FAILURE: GPU device lost!");
|
||||
}
|
||||
EngineStatus::Running { .. } => {
|
||||
log::error!("FAILURE: GPU returned Running status!");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
use pow_core::{hash_from_nonce, JobContext};
|
||||
use primitive_types::U512;
|
||||
use rand::Rng;
|
||||
use rand::SeedableRng;
|
||||
use wgpu::util::DeviceExt;
|
||||
|
||||
pub async fn test_end_to_end_mining(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Running End-to-End Mining Test...");
|
||||
|
||||
// 1. Setup a job context
|
||||
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(12345);
|
||||
let mut header = [0u8; 32];
|
||||
rng.fill(&mut header);
|
||||
|
||||
// Use difficulty 1 so target is MAX. Any hash should pass.
|
||||
let difficulty = U512::from(1u64);
|
||||
let ctx = JobContext::new(header, difficulty);
|
||||
|
||||
// 2. Pick a nonce
|
||||
let nonce_val = U512::from(123456789u64);
|
||||
|
||||
// 3. Compute expected hash using CPU (pow-core)
|
||||
let expected_hash = hash_from_nonce(&ctx, nonce_val);
|
||||
println!("CPU Expected Hash: {:x}", expected_hash);
|
||||
|
||||
// 4. Run GPU Mining for this specific nonce
|
||||
|
||||
// Midstate Buffer (header + high nonce half absorbed on CPU)
|
||||
let nonce_be = nonce_val.to_big_endian();
|
||||
let midstate = pow_core::mining_midstate(ctx.header, nonce_be[..32].try_into().unwrap());
|
||||
let mut midstate_u32s = [0u32; 24];
|
||||
for (i, felt) in midstate.iter().enumerate() {
|
||||
midstate_u32s[2 * i] = *felt as u32;
|
||||
midstate_u32s[2 * i + 1] = (*felt >> 32) as u32;
|
||||
}
|
||||
let midstate_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Midstate Buffer"),
|
||||
contents: bytemuck::cast_slice(&midstate_u32s),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
|
||||
// Target Buffer
|
||||
let target_bytes = ctx.target.to_little_endian();
|
||||
let mut target_u32s = [0u32; 16];
|
||||
for i in 0..16 {
|
||||
let chunk = &target_bytes[i * 4..(i + 1) * 4];
|
||||
target_u32s[i] = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
}
|
||||
let target_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Target Buffer"),
|
||||
contents: bytemuck::cast_slice(&target_u32s),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
|
||||
// Start Nonce Buffer
|
||||
// We set start_nonce = nonce_val. Thread 0 will check start_nonce + 0 = nonce_val.
|
||||
let start_nonce_bytes = nonce_val.to_little_endian();
|
||||
let mut start_nonce_u32s = [0u32; 16];
|
||||
for i in 0..16 {
|
||||
let chunk = &start_nonce_bytes[i * 4..(i + 1) * 4];
|
||||
start_nonce_u32s[i] = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
}
|
||||
let start_nonce_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Start Nonce Buffer"),
|
||||
contents: bytemuck::cast_slice(&start_nonce_u32s),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
|
||||
// Results Buffer
|
||||
let results_size = (1 + 16 + 16) * 4;
|
||||
let results_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Results Buffer"),
|
||||
size: results_size as u64,
|
||||
usage: wgpu::BufferUsages::STORAGE
|
||||
| wgpu::BufferUsages::COPY_SRC
|
||||
| wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
// Clear results buffer
|
||||
let zeros = vec![0u8; results_size];
|
||||
queue.write_buffer(&results_buffer, 0, &zeros);
|
||||
|
||||
// Dispatch config buffer: [total_threads, nonces_per_thread, total_nonces]
|
||||
let dispatch_config_data: [u32; 3] = [256, 1, 256];
|
||||
let dispatch_config_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Dispatch Config Buffer"),
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(
|
||||
&dispatch_config_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&dispatch_config_data),
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Mining Shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader_src.into()),
|
||||
});
|
||||
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Mining Pipeline"),
|
||||
layout: None,
|
||||
module: &shader,
|
||||
entry_point: Some("mining_main"),
|
||||
compilation_options: Default::default(),
|
||||
cache: None,
|
||||
});
|
||||
|
||||
let bind_group_layout = pipeline.get_bind_group_layout(0);
|
||||
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: Some("Mining Bind Group"),
|
||||
layout: &bind_group_layout,
|
||||
entries: &[
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: results_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: midstate_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
resource: start_nonce_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 3,
|
||||
resource: target_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 4,
|
||||
resource: dispatch_config_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Run Compute Pass
|
||||
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
|
||||
label: Some("Mining Encoder"),
|
||||
});
|
||||
|
||||
{
|
||||
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
|
||||
label: Some("Mining Compute Pass"),
|
||||
timestamp_writes: None,
|
||||
});
|
||||
cpass.set_pipeline(&pipeline);
|
||||
cpass.set_bind_group(0, &bind_group, &[]);
|
||||
cpass.dispatch_workgroups(1, 1, 1); // 1 workgroup, 256 threads. Thread 0 will check nonce_val.
|
||||
}
|
||||
|
||||
// Read Results
|
||||
let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Staging Buffer"),
|
||||
size: results_size as u64,
|
||||
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
|
||||
encoder.copy_buffer_to_buffer(&results_buffer, 0, &staging_buffer, 0, results_size as u64);
|
||||
queue.submit(Some(encoder.finish()));
|
||||
|
||||
let buffer_slice = staging_buffer.slice(..);
|
||||
let (sender, receiver) = futures::channel::oneshot::channel();
|
||||
buffer_slice.map_async(wgpu::MapMode::Read, move |v| sender.send(v).unwrap());
|
||||
|
||||
device
|
||||
.poll(wgpu::PollType::Wait {
|
||||
submission_index: None,
|
||||
timeout: None,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
if let Ok(Ok(())) = receiver.await {
|
||||
let data = buffer_slice.get_mapped_range();
|
||||
let result_u32s: &[u32] = bytemuck::cast_slice(&data);
|
||||
|
||||
if result_u32s[0] != 0 {
|
||||
println!("GPU found solution!");
|
||||
|
||||
// Parse nonce
|
||||
let mut nonce_bytes = [0u8; 64];
|
||||
for i in 0..16 {
|
||||
let bytes = result_u32s[1 + i].to_le_bytes();
|
||||
nonce_bytes[i * 4..(i + 1) * 4].copy_from_slice(&bytes);
|
||||
}
|
||||
let found_nonce = U512::from_little_endian(&nonce_bytes);
|
||||
println!("GPU Nonce: {}", found_nonce);
|
||||
|
||||
// Parse hash from GPU result
|
||||
let mut hash_bytes = [0u8; 64];
|
||||
for i in 0..16 {
|
||||
let bytes = result_u32s[17 + i].to_le_bytes();
|
||||
hash_bytes[i * 4..(i + 1) * 4].copy_from_slice(&bytes);
|
||||
}
|
||||
let gpu_hash = U512::from_little_endian(&hash_bytes);
|
||||
println!("GPU Hash: {:x}", gpu_hash);
|
||||
|
||||
// Verify: compute hash on CPU for the found nonce and compare
|
||||
let cpu_verified_hash = hash_from_nonce(&ctx, found_nonce);
|
||||
println!("CPU verified hash: {:x}", cpu_verified_hash);
|
||||
|
||||
assert_eq!(
|
||||
gpu_hash, cpu_verified_hash,
|
||||
"GPU hash doesn't match CPU verification for the found nonce!"
|
||||
);
|
||||
|
||||
// Verify hash is below target (should always pass with difficulty 1)
|
||||
assert!(gpu_hash <= ctx.target, "Hash is not below target!");
|
||||
|
||||
println!("✅ End-to-End Test Passed!");
|
||||
} else {
|
||||
println!("❌ GPU did not find solution (should have passed with MAX target)");
|
||||
return Err("GPU did not find solution".into());
|
||||
}
|
||||
} else {
|
||||
return Err("Failed to map buffer".into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,748 @@
|
||||
//! GPU tier detection using regex patterns
|
||||
//!
|
||||
//! This module provides a table-driven approach to GPU detection,
|
||||
//! using regex patterns with word boundaries to avoid substring matching issues.
|
||||
|
||||
use regex::Regex;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// GPU tier configuration
|
||||
struct GpuTier {
|
||||
/// Regex pattern to match GPU name (case-insensitive)
|
||||
pattern: &'static str,
|
||||
/// Human-readable tier name
|
||||
name: &'static str,
|
||||
/// Divisor for max_workgroups (higher = more conservative)
|
||||
workgroup_divisor: u32,
|
||||
/// Minimum workgroups to use
|
||||
min_workgroups: u32,
|
||||
}
|
||||
|
||||
/// Compiled GPU tier with regex
|
||||
struct CompiledGpuTier {
|
||||
regex: Regex,
|
||||
name: &'static str,
|
||||
workgroup_divisor: u32,
|
||||
min_workgroups: u32,
|
||||
}
|
||||
|
||||
impl CompiledGpuTier {
|
||||
fn from_tier(tier: &GpuTier) -> Self {
|
||||
Self {
|
||||
regex: Regex::new(&format!("(?i){}", tier.pattern)).expect("Invalid GPU tier regex"),
|
||||
name: tier.name,
|
||||
workgroup_divisor: tier.workgroup_divisor,
|
||||
min_workgroups: tier.min_workgroups,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GPU tiers are checked in order - first match wins
|
||||
// Use word boundaries (\b) to avoid substring issues like "550" matching "5500"
|
||||
|
||||
const NVIDIA_TIERS: &[GpuTier] = &[
|
||||
// Blackwell (RTX 50 series)
|
||||
GpuTier {
|
||||
pattern: r"\b50[89]0\b",
|
||||
name: "NVIDIA RTX 50 Flagship (Blackwell)",
|
||||
workgroup_divisor: 6,
|
||||
min_workgroups: 5120,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\b50[67]0\b|rtx 50",
|
||||
name: "NVIDIA RTX 50 (Blackwell)",
|
||||
workgroup_divisor: 7,
|
||||
min_workgroups: 4608,
|
||||
},
|
||||
// Ada Lovelace (RTX 40 series)
|
||||
GpuTier {
|
||||
pattern: r"\b40[89]0\b",
|
||||
name: "NVIDIA RTX 40 Flagship (Ada)",
|
||||
workgroup_divisor: 8,
|
||||
min_workgroups: 4096,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\b40[67]0\b|rtx 40",
|
||||
name: "NVIDIA RTX 40 (Ada)",
|
||||
workgroup_divisor: 10,
|
||||
min_workgroups: 3072,
|
||||
},
|
||||
// Ampere/Turing (RTX 30/20 series)
|
||||
GpuTier {
|
||||
pattern: r"\b30[5-9]0\b|\b20[6-8]0\b|rtx 30|rtx 20",
|
||||
name: "NVIDIA RTX 30/20 (Ampere/Turing)",
|
||||
workgroup_divisor: 12,
|
||||
min_workgroups: 2048,
|
||||
},
|
||||
// Turing/Pascal (GTX 16/10 series)
|
||||
GpuTier {
|
||||
pattern: r"\b16[56]0\b|\b10[3-8]0\b|gtx 16|gtx 10",
|
||||
name: "NVIDIA GTX 16/10 (Turing/Pascal)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1024,
|
||||
},
|
||||
// Maxwell (GTX 900 series)
|
||||
GpuTier {
|
||||
pattern: r"\b9[5-8]0\b|gtx 9",
|
||||
name: "NVIDIA GTX 900 (Maxwell)",
|
||||
workgroup_divisor: 18,
|
||||
min_workgroups: 768,
|
||||
},
|
||||
// Kepler/Maxwell (GTX 700 series)
|
||||
GpuTier {
|
||||
pattern: r"\b7[5-8]0\b|gtx 7",
|
||||
name: "NVIDIA GTX 700 (Kepler/Maxwell)",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
// Legacy GTX
|
||||
GpuTier {
|
||||
pattern: r"gtx [456]",
|
||||
name: "NVIDIA GTX Legacy (Fermi/Kepler)",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 384,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\bgtx\b",
|
||||
name: "NVIDIA GTX (Unknown)",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
// Mobile
|
||||
GpuTier {
|
||||
pattern: r"\bmx[1-5]\d0|geforce mx",
|
||||
name: "NVIDIA MX (Mobile)",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 384,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"geforce gt\b|\bgt \d{3}\b",
|
||||
name: "NVIDIA GT (Entry-Level)",
|
||||
workgroup_divisor: 28,
|
||||
min_workgroups: 256,
|
||||
},
|
||||
// Professional
|
||||
GpuTier {
|
||||
pattern: r"quadro|rtx a\d|tesla|\ba100\b|\bh100\b|\bl4\b",
|
||||
name: "NVIDIA Quadro/Professional",
|
||||
workgroup_divisor: 10,
|
||||
min_workgroups: 2560,
|
||||
},
|
||||
];
|
||||
|
||||
const AMD_TIERS: &[GpuTier] = &[
|
||||
// RDNA 4 (RX 9000 series)
|
||||
GpuTier {
|
||||
pattern: r"rx 9|\b90[78]0\b",
|
||||
name: "AMD RX 9000 (RDNA 4)",
|
||||
workgroup_divisor: 8,
|
||||
min_workgroups: 4096,
|
||||
},
|
||||
// RDNA 3 Discrete
|
||||
GpuTier {
|
||||
pattern: r"\b7900\b",
|
||||
name: "AMD RX 7900 (RDNA 3 Flagship)",
|
||||
workgroup_divisor: 9,
|
||||
min_workgroups: 3584,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"rx 7|\b7[6-8]00\b",
|
||||
name: "AMD RX 7000 (RDNA 3)",
|
||||
workgroup_divisor: 10,
|
||||
min_workgroups: 3072,
|
||||
},
|
||||
// RDNA 3 APUs - check before discrete to avoid substring match
|
||||
GpuTier {
|
||||
pattern: r"\b780m\b|radeon 780m",
|
||||
name: "AMD Radeon 780M (RDNA 3 APU)",
|
||||
workgroup_divisor: 12,
|
||||
min_workgroups: 2048,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\b7[46]0m\b|radeon 7[46]0m",
|
||||
name: "AMD Radeon 7x0M (RDNA 3 APU)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1024,
|
||||
},
|
||||
// RDNA 2 Discrete
|
||||
GpuTier {
|
||||
pattern: r"\b6[89][05]0\b",
|
||||
name: "AMD RX 6900/6800 (RDNA 2 Flagship)",
|
||||
workgroup_divisor: 12,
|
||||
min_workgroups: 2560,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\b6[67][05]0\b",
|
||||
name: "AMD RX 6700/6600 (RDNA 2)",
|
||||
workgroup_divisor: 14,
|
||||
min_workgroups: 2048,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\b6[45]00\b",
|
||||
name: "AMD RX 6500/6400 (RDNA 2 Entry)",
|
||||
workgroup_divisor: 22,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"rx 6\d{3}",
|
||||
name: "AMD RX 6000 (RDNA 2)",
|
||||
workgroup_divisor: 14,
|
||||
min_workgroups: 2048,
|
||||
},
|
||||
// RDNA 2 APUs - check before discrete
|
||||
GpuTier {
|
||||
pattern: r"\b680m\b|radeon 680m",
|
||||
name: "AMD Radeon 680M (RDNA 2 APU)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1536,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\b6[16]0m\b|radeon 6[16]0m",
|
||||
name: "AMD Radeon 6x0M (RDNA 2 APU)",
|
||||
workgroup_divisor: 22,
|
||||
min_workgroups: 768,
|
||||
},
|
||||
// RDNA 1 (RX 5000 series) - 4-digit patterns to avoid matching Polaris 3-digit
|
||||
GpuTier {
|
||||
pattern: r"\b5700\b",
|
||||
name: "AMD RX 5700 (RDNA 1)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1536,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\b5[56]00\b|rx 5\d{3}",
|
||||
name: "AMD RX 5000 (RDNA 1)",
|
||||
workgroup_divisor: 18,
|
||||
min_workgroups: 1024,
|
||||
},
|
||||
// Polaris (RX 400/500 series) - 3-digit models with boundaries
|
||||
GpuTier {
|
||||
pattern: r"rx [45]\d0\b|\b[45][6-9]0\b|\b590\b|rx 5.0x",
|
||||
name: "AMD RX 500/400 (Polaris)",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 768,
|
||||
},
|
||||
// Vega
|
||||
GpuTier {
|
||||
pattern: r"radeon vii\b",
|
||||
name: "AMD Radeon VII (Vega 20)",
|
||||
workgroup_divisor: 12,
|
||||
min_workgroups: 2048,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"vega\s*64",
|
||||
name: "AMD Vega 64 (Discrete)",
|
||||
workgroup_divisor: 14,
|
||||
min_workgroups: 1536,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"vega\s*56",
|
||||
name: "AMD Vega 56 (Discrete)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1280,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"vega",
|
||||
name: "AMD Vega (APU)",
|
||||
workgroup_divisor: 28,
|
||||
min_workgroups: 384,
|
||||
},
|
||||
// GCN
|
||||
GpuTier {
|
||||
pattern: r"fury|nano",
|
||||
name: "AMD R9 Fury/Nano (Fiji)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1280,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"r9.*(3[89]0|2[89]0)|\b[23][89]0x?\b",
|
||||
name: "AMD R9 (GCN)",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 768,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"r7.*(3[67]0|2[67]0)|\b[23][67]0x?\b",
|
||||
name: "AMD R7 (GCN)",
|
||||
workgroup_divisor: 22,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
// Professional
|
||||
GpuTier {
|
||||
pattern: r"radeon pro|instinct mi|mi[123]\d0|firepro|w[567]\d{3}",
|
||||
name: "AMD Radeon Pro/Instinct",
|
||||
workgroup_divisor: 10,
|
||||
min_workgroups: 2560,
|
||||
},
|
||||
// OEM/APU fallbacks
|
||||
GpuTier {
|
||||
pattern: r"radeon\s*\(tm\)\s*[67]\d{2}\b|radeon [67]\d{2}\b",
|
||||
name: "AMD Radeon OEM (Polaris Rebrand)",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"radeon\s*(\(tm\)\s*)?graphics",
|
||||
name: "AMD Radeon Graphics (APU)",
|
||||
workgroup_divisor: 26,
|
||||
min_workgroups: 384,
|
||||
},
|
||||
];
|
||||
|
||||
const INTEL_TIERS: &[GpuTier] = &[
|
||||
// Battlemage (Arc B-Series)
|
||||
GpuTier {
|
||||
pattern: r"arc b|\bb5[78]0\b",
|
||||
name: "Intel Arc B-Series (Battlemage)",
|
||||
workgroup_divisor: 10,
|
||||
min_workgroups: 2560,
|
||||
},
|
||||
// Alchemist Mobile - check BEFORE desktop (a770m contains a770)
|
||||
GpuTier {
|
||||
pattern: r"\ba7[37]0m\b",
|
||||
name: "Intel Arc A7 Mobile (Alchemist)",
|
||||
workgroup_divisor: 14,
|
||||
min_workgroups: 1536,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\ba5[57]0m\b",
|
||||
name: "Intel Arc A5 Mobile (Alchemist)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1024,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\ba3[57]0m\b",
|
||||
name: "Intel Arc A3 Mobile (Alchemist)",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
// Alchemist Desktop
|
||||
GpuTier {
|
||||
pattern: r"\ba7[57]0\b",
|
||||
name: "Intel Arc A7 (Alchemist)",
|
||||
workgroup_divisor: 12,
|
||||
min_workgroups: 2048,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\ba580\b",
|
||||
name: "Intel Arc A5 (Alchemist)",
|
||||
workgroup_divisor: 14,
|
||||
min_workgroups: 1536,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\ba3[18]0\b",
|
||||
name: "Intel Arc A3 (Alchemist)",
|
||||
workgroup_divisor: 18,
|
||||
min_workgroups: 768,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"arc a|\barc\b",
|
||||
name: "Intel Arc (Unknown)",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1024,
|
||||
},
|
||||
// Integrated
|
||||
GpuTier {
|
||||
pattern: r"iris xe max",
|
||||
name: "Intel Iris Xe Max (Discrete)",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"iris xe",
|
||||
name: "Intel Iris Xe (Integrated)",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 384,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"iris pro",
|
||||
name: "Intel Iris Pro (Integrated)",
|
||||
workgroup_divisor: 26,
|
||||
min_workgroups: 256,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"iris plus|iris\b",
|
||||
name: "Intel Iris Plus (Integrated)",
|
||||
workgroup_divisor: 26,
|
||||
min_workgroups: 320,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"uhd.*(7\d{2}|graphics 7)",
|
||||
name: "Intel UHD 700 (Integrated)",
|
||||
workgroup_divisor: 26,
|
||||
min_workgroups: 320,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"uhd.*(6\d{2}|graphics 6)",
|
||||
name: "Intel UHD 600 (Integrated)",
|
||||
workgroup_divisor: 28,
|
||||
min_workgroups: 256,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"uhd",
|
||||
name: "Intel UHD Graphics (Integrated)",
|
||||
workgroup_divisor: 28,
|
||||
min_workgroups: 256,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"hd graphics|hd [456]\d{2}",
|
||||
name: "Intel HD Graphics (Integrated)",
|
||||
workgroup_divisor: 30,
|
||||
min_workgroups: 192,
|
||||
},
|
||||
];
|
||||
|
||||
const QUALCOMM_TIERS: &[GpuTier] = &[
|
||||
// Snapdragon X (Adreno X1)
|
||||
GpuTier {
|
||||
pattern: r"x elite|x plus|adreno x|x1-[89]",
|
||||
name: "Qualcomm Adreno X1 (Snapdragon X)",
|
||||
workgroup_divisor: 14,
|
||||
min_workgroups: 1536,
|
||||
},
|
||||
// Adreno 700 series (730, 740, 750, etc.)
|
||||
GpuTier {
|
||||
pattern: r"adreno\s*7[0-9]{2}\b|\b7[345]0\b",
|
||||
name: "Qualcomm Adreno 700 Series",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 1024,
|
||||
},
|
||||
// Adreno 600 series (610, 612, 615, 618, 619, 620, 630, 640, 650, 660, etc.)
|
||||
GpuTier {
|
||||
pattern: r"adreno\s*6[0-9]{2}\b|\b6[1-6][0-9]\b",
|
||||
name: "Qualcomm Adreno 600 Series",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 512,
|
||||
},
|
||||
// Adreno 500 series (505, 506, 508, 509, 510, 512, 530, 540)
|
||||
GpuTier {
|
||||
pattern: r"adreno\s*5[0-4][0-9]\b|\b5[0-4][0-9]\b",
|
||||
name: "Qualcomm Adreno 500 Series",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 384,
|
||||
},
|
||||
];
|
||||
|
||||
const APPLE_TIERS: &[GpuTier] = &[
|
||||
// M5 series
|
||||
GpuTier {
|
||||
pattern: r"m5 ultra",
|
||||
name: "Apple M5 Ultra",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 1600,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m5 max",
|
||||
name: "Apple M5 Max",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 800,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m5 pro",
|
||||
name: "Apple M5 Pro",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 400,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\bm5\b",
|
||||
name: "Apple M5",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 200,
|
||||
},
|
||||
// M4 series
|
||||
GpuTier {
|
||||
pattern: r"m4 ultra",
|
||||
name: "Apple M4 Ultra",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 1600,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m4 max",
|
||||
name: "Apple M4 Max",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 800,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m4 pro",
|
||||
name: "Apple M4 Pro",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 400,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\bm4\b",
|
||||
name: "Apple M4",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 200,
|
||||
},
|
||||
// M3 series
|
||||
GpuTier {
|
||||
pattern: r"m3 ultra",
|
||||
name: "Apple M3 Ultra",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 1520,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m3 max",
|
||||
name: "Apple M3 Max",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 800,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m3 pro",
|
||||
name: "Apple M3 Pro",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 360,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\bm3\b",
|
||||
name: "Apple M3",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 200,
|
||||
},
|
||||
// M2 series
|
||||
GpuTier {
|
||||
pattern: r"m2 ultra",
|
||||
name: "Apple M2 Ultra",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 1520,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m2 max",
|
||||
name: "Apple M2 Max",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 760,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m2 pro",
|
||||
name: "Apple M2 Pro",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 380,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\bm2\b",
|
||||
name: "Apple M2",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 200,
|
||||
},
|
||||
// M1 series
|
||||
GpuTier {
|
||||
pattern: r"m1 ultra",
|
||||
name: "Apple M1 Ultra",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 1280,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m1 max",
|
||||
name: "Apple M1 Max",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 640,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"m1 pro",
|
||||
name: "Apple M1 Pro",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 320,
|
||||
},
|
||||
GpuTier {
|
||||
pattern: r"\bm1\b",
|
||||
name: "Apple M1",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 160,
|
||||
},
|
||||
];
|
||||
|
||||
/// Compiled GPU tier tables (lazily initialized)
|
||||
struct GpuTierTables {
|
||||
nvidia: Vec<CompiledGpuTier>,
|
||||
amd: Vec<CompiledGpuTier>,
|
||||
intel: Vec<CompiledGpuTier>,
|
||||
qualcomm: Vec<CompiledGpuTier>,
|
||||
apple: Vec<CompiledGpuTier>,
|
||||
}
|
||||
|
||||
static GPU_TIERS: LazyLock<GpuTierTables> = LazyLock::new(|| GpuTierTables {
|
||||
nvidia: NVIDIA_TIERS
|
||||
.iter()
|
||||
.map(CompiledGpuTier::from_tier)
|
||||
.collect(),
|
||||
amd: AMD_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
|
||||
intel: INTEL_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
|
||||
qualcomm: QUALCOMM_TIERS
|
||||
.iter()
|
||||
.map(CompiledGpuTier::from_tier)
|
||||
.collect(),
|
||||
apple: APPLE_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
|
||||
});
|
||||
|
||||
/// Result of GPU tier matching
|
||||
pub struct GpuTierMatch {
|
||||
pub name: &'static str,
|
||||
pub workgroup_divisor: u32,
|
||||
pub min_workgroups: u32,
|
||||
pub is_fallback: bool,
|
||||
}
|
||||
|
||||
/// Find matching tier from a list of compiled tiers
|
||||
fn find_matching_tier(name: &str, tiers: &[CompiledGpuTier]) -> Option<GpuTierMatch> {
|
||||
tiers
|
||||
.iter()
|
||||
.find(|tier| tier.regex.is_match(name))
|
||||
.map(|tier| GpuTierMatch {
|
||||
name: tier.name,
|
||||
workgroup_divisor: tier.workgroup_divisor,
|
||||
min_workgroups: tier.min_workgroups,
|
||||
is_fallback: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Detect GPU tier based on adapter info
|
||||
///
|
||||
/// Returns (tier_name, workgroup_divisor, min_workgroups, is_fallback)
|
||||
pub fn detect_gpu_tier(vendor_name: &str, vendor_id: u32, is_metal_backend: bool) -> GpuTierMatch {
|
||||
let name_lower = vendor_name.to_lowercase();
|
||||
|
||||
// Determine vendor and find matching tier
|
||||
if name_lower.contains("nvidia") || vendor_id == 0x10DE {
|
||||
find_matching_tier(&name_lower, &GPU_TIERS.nvidia).unwrap_or(GpuTierMatch {
|
||||
name: "NVIDIA Unknown",
|
||||
workgroup_divisor: 20,
|
||||
min_workgroups: 512,
|
||||
is_fallback: true,
|
||||
})
|
||||
} else if name_lower.contains("amd") || name_lower.contains("radeon") || vendor_id == 0x1002 {
|
||||
find_matching_tier(&name_lower, &GPU_TIERS.amd).unwrap_or(GpuTierMatch {
|
||||
name: "AMD Unknown",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 512,
|
||||
is_fallback: true,
|
||||
})
|
||||
} else if name_lower.contains("intel") || vendor_id == 0x8086 {
|
||||
find_matching_tier(&name_lower, &GPU_TIERS.intel).unwrap_or(GpuTierMatch {
|
||||
name: "Intel Unknown",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 256,
|
||||
is_fallback: true,
|
||||
})
|
||||
} else if name_lower.contains("qualcomm")
|
||||
|| name_lower.contains("adreno")
|
||||
|| vendor_id == 0x5143
|
||||
{
|
||||
find_matching_tier(&name_lower, &GPU_TIERS.qualcomm).unwrap_or(GpuTierMatch {
|
||||
name: "Qualcomm Adreno (Unknown)",
|
||||
workgroup_divisor: 24,
|
||||
min_workgroups: 384,
|
||||
is_fallback: true,
|
||||
})
|
||||
} else if is_metal_backend {
|
||||
// Apple Silicon - detected by Metal backend
|
||||
find_matching_tier(&name_lower, &GPU_TIERS.apple).unwrap_or(GpuTierMatch {
|
||||
name: "Apple Silicon Unknown",
|
||||
workgroup_divisor: 4,
|
||||
min_workgroups: 160,
|
||||
is_fallback: true,
|
||||
})
|
||||
} else {
|
||||
GpuTierMatch {
|
||||
name: "Unknown GPU",
|
||||
workgroup_divisor: 16,
|
||||
min_workgroups: 512,
|
||||
is_fallback: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_nvidia_detection() {
|
||||
// RTX 40 series
|
||||
let tier = detect_gpu_tier("NVIDIA GeForce RTX 4090", 0x10DE, false);
|
||||
assert_eq!(tier.name, "NVIDIA RTX 40 Flagship (Ada)");
|
||||
assert!(!tier.is_fallback);
|
||||
|
||||
let tier = detect_gpu_tier("NVIDIA GeForce RTX 4070 Ti", 0x10DE, false);
|
||||
assert_eq!(tier.name, "NVIDIA RTX 40 (Ada)");
|
||||
|
||||
// RTX 30 series
|
||||
let tier = detect_gpu_tier("NVIDIA GeForce RTX 3080", 0x10DE, false);
|
||||
assert_eq!(tier.name, "NVIDIA RTX 30/20 (Ampere/Turing)");
|
||||
|
||||
// GTX 10 series
|
||||
let tier = detect_gpu_tier("NVIDIA GeForce GTX 1080 Ti", 0x10DE, false);
|
||||
assert_eq!(tier.name, "NVIDIA GTX 16/10 (Turing/Pascal)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amd_rdna_vs_polaris() {
|
||||
// RDNA 1 - should NOT match Polaris
|
||||
let tier = detect_gpu_tier("AMD Radeon RX 5500 XT", 0x1002, false);
|
||||
assert_eq!(tier.name, "AMD RX 5000 (RDNA 1)");
|
||||
assert!(!tier.is_fallback);
|
||||
|
||||
let tier = detect_gpu_tier("AMD Radeon RX 5600 XT", 0x1002, false);
|
||||
assert_eq!(tier.name, "AMD RX 5000 (RDNA 1)");
|
||||
|
||||
let tier = detect_gpu_tier("AMD Radeon RX 5700 XT", 0x1002, false);
|
||||
assert_eq!(tier.name, "AMD RX 5700 (RDNA 1)");
|
||||
|
||||
// Polaris - should match Polaris
|
||||
let tier = detect_gpu_tier("AMD Radeon RX 580", 0x1002, false);
|
||||
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
|
||||
|
||||
let tier = detect_gpu_tier("AMD Radeon RX 560X", 0x1002, false);
|
||||
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
|
||||
|
||||
let tier = detect_gpu_tier("AMD Radeon RX 550", 0x1002, false);
|
||||
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_amd_vega_apu() {
|
||||
let tier = detect_gpu_tier("AMD Radeon(TM) Vega 8 Graphics", 0x1002, false);
|
||||
assert_eq!(tier.name, "AMD Vega (APU)");
|
||||
assert!(!tier.is_fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_intel_arc_mobile_vs_desktop() {
|
||||
// Mobile should match mobile tier
|
||||
let tier = detect_gpu_tier("Intel Arc A770M", 0x8086, false);
|
||||
assert_eq!(tier.name, "Intel Arc A7 Mobile (Alchemist)");
|
||||
assert!(!tier.is_fallback);
|
||||
|
||||
// Desktop should match desktop tier
|
||||
let tier = detect_gpu_tier("Intel Arc A770", 0x8086, false);
|
||||
assert_eq!(tier.name, "Intel Arc A7 (Alchemist)");
|
||||
assert!(!tier.is_fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apple_silicon() {
|
||||
let tier = detect_gpu_tier("Apple M1 Pro", 0, true);
|
||||
assert_eq!(tier.name, "Apple M1 Pro");
|
||||
assert!(!tier.is_fallback);
|
||||
|
||||
let tier = detect_gpu_tier("Apple M3 Max", 0, true);
|
||||
assert_eq!(tier.name, "Apple M3 Max");
|
||||
assert!(!tier.is_fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qualcomm_adreno_series() {
|
||||
// Adreno 627 should match 600 series, NOT 700 series
|
||||
let tier = detect_gpu_tier("Qualcomm Adreno 627", 0x5143, false);
|
||||
assert_eq!(tier.name, "Qualcomm Adreno 600 Series");
|
||||
assert!(!tier.is_fallback);
|
||||
|
||||
// Adreno 730 should match 700 series
|
||||
let tier = detect_gpu_tier("Qualcomm Adreno 730", 0x5143, false);
|
||||
assert_eq!(tier.name, "Qualcomm Adreno 700 Series");
|
||||
assert!(!tier.is_fallback);
|
||||
|
||||
// Adreno 540 should match 500 series
|
||||
let tier = detect_gpu_tier("Qualcomm Adreno 540", 0x5143, false);
|
||||
assert_eq!(tier.name, "Qualcomm Adreno 500 Series");
|
||||
assert!(!tier.is_fallback);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,923 @@
|
||||
// Quantus Mining Shader - WGSL Compute Shader for GPU Mining
|
||||
// Implements Poseidon2 hash function over Goldilocks field
|
||||
|
||||
// Goldilocks field constants: p = 2^64 - 2^32 + 1 = 18446744069414584321
|
||||
// Since WGSL doesn't support 64-bit literals, we work with 32-bit chunks
|
||||
// p = 0xFFFFFFFF00000001 = [0x00000001, 0xFFFFFFFF] in little-endian u32 pairs
|
||||
const GOLDILOCKS_PRIME_LOW: u32 = 1u; // Low 32 bits
|
||||
const GOLDILOCKS_PRIME_HIGH: u32 = 4294967295u; // High 32 bits (2^32 - 1)
|
||||
|
||||
// Poseidon2 constants
|
||||
const WIDTH: u32 = 12u;
|
||||
const RATE: u32 = 8u;
|
||||
const EXTERNAL_ROUNDS: u32 = 4u;
|
||||
const INTERNAL_ROUNDS: u32 = 22u;
|
||||
|
||||
// Individual constants for testing
|
||||
const INTERNAL_CONST_0_LOW: u32 = 2018170979u;
|
||||
const INTERNAL_CONST_0_HIGH: u32 = 2549578122u;
|
||||
const INTERNAL_CONST_1_LOW: u32 = 794875120u;
|
||||
const INTERNAL_CONST_1_HIGH: u32 = 3520249608u;
|
||||
|
||||
// Real Poseidon2 constants extracted from qp-poseidon-constants
|
||||
// Internal round constants (22 values)
|
||||
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)
|
||||
);
|
||||
|
||||
// Initial external round constants (4 rounds x 12 elements)
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
// Terminal external round constants (4 rounds x 12 elements)
|
||||
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)
|
||||
)
|
||||
);
|
||||
|
||||
// Helper function to create GoldilocksField from constant array
|
||||
fn gf_from_const(val: array<u32, 2>) -> GoldilocksField {
|
||||
return gf_from_u64_parts(val[0], val[1]);
|
||||
}
|
||||
|
||||
// MDS matrix constants for width 12 - circulant matrix first row
|
||||
// MDS matrix diagonal for width 12 Goldilocks (from p3_goldilocks constants)
|
||||
// Each element is stored as [low32, high32] pairs
|
||||
const MDS_MATRIX_DIAG_12: array<array<u32, 2>, 12> = array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(0x23ba9300u, 0xc3b6c08eu), // 0xc3b6c08e23ba9300u
|
||||
array<u32, 2>(0x4a324fb6u, 0xd84b5de9u), // 0xd84b5de94a324fb6u
|
||||
array<u32, 2>(0x5b35b84fu, 0x0d0c371cu), // 0x0d0c371c5b35b84fu
|
||||
array<u32, 2>(0xe7188037u, 0x7964f570u), // 0x7964f570e7188037u
|
||||
array<u32, 2>(0xd996604bu, 0x5daf18bbu), // 0x5daf18bbd996604bu
|
||||
array<u32, 2>(0xb9595257u, 0x6743bc47u), // 0x6743bc47b9595257u
|
||||
array<u32, 2>(0x2c59bb70u, 0x5528b936u), // 0x5528b9362c59bb70u
|
||||
array<u32, 2>(0x7127b68bu, 0xac45e25bu), // 0xac45e25b7127b68bu
|
||||
array<u32, 2>(0xfbb606b5u, 0xa2077d7du), // 0xa2077d7dfbb606b5u
|
||||
array<u32, 2>(0xaee378aeu, 0xf3faac6fu), // 0xf3faac6faee378aeu
|
||||
array<u32, 2>(0x1545e883u, 0x0c6388b5u), // 0x0c6388b51545e883u
|
||||
array<u32, 2>(0x44917b60u, 0xd27dbb69u) // 0xd27dbb6944917b60u
|
||||
);
|
||||
|
||||
// Storage buffers
|
||||
@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>; // 64 bytes
|
||||
@group(0) @binding(3) var<storage, read> difficulty_target: array<u32, 16>; // 64 bytes (U512 target)
|
||||
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 3>; // [total_threads, nonces_per_thread, total_nonces]
|
||||
|
||||
// Goldilocks field element represented as [limb0, limb1]
|
||||
// where the value is limb0 + limb1*2^32
|
||||
struct GoldilocksField {
|
||||
limb0: u32,
|
||||
limb1: u32,
|
||||
}
|
||||
|
||||
// Field modulus P = 2^64 - 2^32 + 1 = 0xFFFFFFFF00000001
|
||||
// In u32 limbs: P = [1, 0xFFFFFFFF]
|
||||
const P_LIMB0: u32 = 1u;
|
||||
const P_LIMB1: u32 = 0xFFFFFFFFu;
|
||||
|
||||
// EPSILON = 2^32 - 1 = 0x00000000FFFFFFFF
|
||||
// In u32 limbs: EPSILON = [0xFFFFFFFF, 0]
|
||||
const EPSILON_LIMB0: u32 = 0xFFFFFFFFu;
|
||||
const EPSILON_LIMB1: u32 = 0u;
|
||||
|
||||
// Helper to create field elements
|
||||
fn gf_from_limbs(l0: u32, l1: u32) -> GoldilocksField {
|
||||
return GoldilocksField(l0, l1);
|
||||
}
|
||||
|
||||
fn gf_zero() -> GoldilocksField {
|
||||
return gf_from_limbs(0u, 0u);
|
||||
}
|
||||
|
||||
fn gf_one() -> GoldilocksField {
|
||||
return gf_from_limbs(1u, 0u);
|
||||
}
|
||||
|
||||
fn gf_from_u32(val: u32) -> GoldilocksField {
|
||||
return GoldilocksField(val, 0u);
|
||||
}
|
||||
|
||||
// Convert a 64-bit value (as two u32s) to GoldilocksField
|
||||
fn gf_from_u64_parts(low: u32, high: u32) -> GoldilocksField {
|
||||
var result = GoldilocksField(low, high);
|
||||
|
||||
// Reduce if >= P
|
||||
if (gf_compare(result, gf_from_limbs(P_LIMB0, P_LIMB1)) != 2u) {
|
||||
result = gf_sub(result, gf_from_limbs(P_LIMB0, P_LIMB1));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Addition with carry for u32 values
|
||||
// Returns vec2<u32>(sum, carry)
|
||||
fn u32_add_with_carry(a: u32, b: u32, carry_in: u32) -> vec2<u32> {
|
||||
let sum1 = a + b;
|
||||
let c1 = select(0u, 1u, sum1 < a);
|
||||
let sum2 = sum1 + carry_in;
|
||||
let c2 = select(0u, 1u, sum2 < sum1);
|
||||
return vec2<u32>(sum2, c1 + c2);
|
||||
}
|
||||
|
||||
// Subtraction with borrow for u32 values
|
||||
// Returns vec2<u32>(diff, borrow)
|
||||
fn u32_sub_with_borrow(a: u32, b: u32, borrow_in: u32) -> vec2<u32> {
|
||||
let diff1 = a - b;
|
||||
let b1 = select(0u, 1u, diff1 > a); // if a - b > a, then underflow happened (since b > 0)
|
||||
// Wait, if a < b, a - b wraps around to a large number.
|
||||
// e.g. 2 - 3 = 0xFFFFFFFF. 0xFFFFFFFF > 2. Correct.
|
||||
|
||||
let diff2 = diff1 - borrow_in;
|
||||
let b2 = select(0u, 1u, diff2 > diff1);
|
||||
|
||||
return vec2<u32>(diff2, b1 + b2);
|
||||
}
|
||||
|
||||
// Multiply two u32 values to get a u64 result (as vec2<u32>)
|
||||
fn u32_mul_to_u64(a: u32, b: u32) -> vec2<u32> {
|
||||
let a_lo = a & 0xFFFFu;
|
||||
let a_hi = a >> 16u;
|
||||
let b_lo = b & 0xFFFFu;
|
||||
let b_hi = b >> 16u;
|
||||
|
||||
let p0 = a_lo * b_lo;
|
||||
let p1 = a_hi * b_lo;
|
||||
let p2 = a_lo * b_hi;
|
||||
let p3 = a_hi * b_hi;
|
||||
|
||||
let sum_mid_part = p1 + p2;
|
||||
let carry_mid = select(0u, 1u, sum_mid_part < p1);
|
||||
|
||||
let term_mid_lo = sum_mid_part << 16u;
|
||||
let term_mid_hi = (sum_mid_part >> 16u) | (carry_mid << 16u);
|
||||
|
||||
let res_lo = p0 + term_mid_lo;
|
||||
let carry_lo = select(0u, 1u, res_lo < p0);
|
||||
|
||||
let res_hi = p3 + term_mid_hi + carry_lo;
|
||||
|
||||
return vec2<u32>(res_lo, res_hi);
|
||||
}
|
||||
|
||||
// Compare two GoldilocksField values
|
||||
// Returns: 0 if a == b, 1 if a > b, 2 if a < b
|
||||
fn gf_compare(a: GoldilocksField, b: GoldilocksField) -> u32 {
|
||||
// Compare from most significant limb to least
|
||||
if (a.limb1 != b.limb1) {
|
||||
return select(2u, 1u, a.limb1 > b.limb1);
|
||||
}
|
||||
if (a.limb0 != b.limb0) {
|
||||
return select(2u, 1u, a.limb0 > b.limb0);
|
||||
}
|
||||
return 0u; // Equal
|
||||
}
|
||||
|
||||
// Goldilocks field addition
|
||||
fn gf_add(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
|
||||
// Add limb by limb with carry propagation
|
||||
let add0 = u32_add_with_carry(a.limb0, b.limb0, 0u);
|
||||
let add1 = u32_add_with_carry(a.limb1, b.limb1, add0.y);
|
||||
|
||||
var result = GoldilocksField(add0.x, add1.x);
|
||||
|
||||
// Handle overflow: if carry out, we've computed a + b = result + 2^64
|
||||
// In Goldilocks: 2^64 ≡ 2^32 - 1 (mod P)
|
||||
if (add1.y != 0u) {
|
||||
// Add EPSILON = 0xFFFFFFFF00000000 (Wait, EPSILON is 2^32 - 1)
|
||||
// EPSILON = 0x00000000FFFFFFFF
|
||||
// EPSILON_LIMB0 = 0xFFFFFFFF, EPSILON_LIMB1 = 0
|
||||
let eps_add0 = u32_add_with_carry(result.limb0, EPSILON_LIMB0, 0u);
|
||||
let eps_add1 = u32_add_with_carry(result.limb1, EPSILON_LIMB1, eps_add0.y);
|
||||
result = GoldilocksField(eps_add0.x, eps_add1.x);
|
||||
|
||||
// If adding EPSILON caused another overflow, add EPSILON again
|
||||
if (eps_add1.y != 0u) {
|
||||
let eps2_add0 = u32_add_with_carry(result.limb0, EPSILON_LIMB0, 0u);
|
||||
let eps2_add1 = u32_add_with_carry(result.limb1, EPSILON_LIMB1, eps2_add0.y);
|
||||
result = GoldilocksField(eps2_add0.x, eps2_add1.x);
|
||||
}
|
||||
}
|
||||
|
||||
// Final reduction if result >= P
|
||||
let p = gf_from_limbs(P_LIMB0, P_LIMB1);
|
||||
if (gf_compare(result, p) != 2u) { // if result >= P
|
||||
result = gf_sub_no_underflow(result, p);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Helper for subtraction without underflow (assumes a >= b)
|
||||
fn gf_sub_no_underflow(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
|
||||
let sub0 = u32_sub_with_borrow(a.limb0, b.limb0, 0u);
|
||||
let sub1 = u32_sub_with_borrow(a.limb1, b.limb1, sub0.y);
|
||||
return GoldilocksField(sub0.x, sub1.x);
|
||||
}
|
||||
|
||||
// Goldilocks field subtraction
|
||||
fn gf_sub(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
|
||||
// If a >= b, do direct subtraction
|
||||
if (gf_compare(a, b) != 2u) {
|
||||
return gf_sub_no_underflow(a, b);
|
||||
}
|
||||
|
||||
// Otherwise: a < b, so compute a - b + P
|
||||
let p = gf_from_limbs(P_LIMB0, P_LIMB1);
|
||||
let a_plus_p = gf_add_no_reduction(a, p);
|
||||
return gf_sub_no_underflow(a_plus_p, b);
|
||||
}
|
||||
|
||||
// Addition without final modular reduction (used internally)
|
||||
fn gf_add_no_reduction(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
|
||||
let add0 = u32_add_with_carry(a.limb0, b.limb0, 0u);
|
||||
let add1 = u32_add_with_carry(a.limb1, b.limb1, add0.y);
|
||||
return GoldilocksField(add0.x, add1.x);
|
||||
}
|
||||
|
||||
// Simplified multiplication that handles carries more carefully
|
||||
fn gf_mul_unreduced(a: GoldilocksField, b: GoldilocksField) -> array<u32, 4> {
|
||||
// p0 = a0 * b0 (64 bits)
|
||||
let p0 = u32_mul_to_u64(a.limb0, b.limb0);
|
||||
|
||||
// p1 = a0 * b1 (64 bits)
|
||||
let p1 = u32_mul_to_u64(a.limb0, b.limb1);
|
||||
|
||||
// p2 = a1 * b0 (64 bits)
|
||||
let p2 = u32_mul_to_u64(a.limb1, b.limb0);
|
||||
|
||||
// p3 = a1 * b1 (64 bits)
|
||||
let p3 = u32_mul_to_u64(a.limb1, b.limb1);
|
||||
|
||||
// result[0] = p0.x
|
||||
// result[1] = p0.y + p1.x + p2.x + carry
|
||||
// result[2] = p1.y + p2.y + p3.x + carry
|
||||
// result[3] = p3.y + carry
|
||||
|
||||
var r0 = p0.x;
|
||||
|
||||
let sum1 = u32_add_with_carry(p0.y, p1.x, 0u);
|
||||
let sum2 = u32_add_with_carry(sum1.x, p2.x, 0u);
|
||||
var r1 = sum2.x;
|
||||
var c1 = sum1.y + sum2.y; // carry to next limb
|
||||
|
||||
let sum3 = u32_add_with_carry(p1.y, p2.y, c1);
|
||||
let sum4 = u32_add_with_carry(sum3.x, p3.x, 0u);
|
||||
var r2 = sum4.x;
|
||||
var c2 = sum3.y + sum4.y;
|
||||
|
||||
let sum5 = u32_add_with_carry(p3.y, 0u, c2);
|
||||
var r3 = sum5.x;
|
||||
|
||||
return array<u32, 4>(r0, r1, r2, r3);
|
||||
}
|
||||
|
||||
// Reduce a 4-limb number modulo the Goldilocks prime
|
||||
fn gf_reduce_4limb(limbs: array<u32, 4>) -> GoldilocksField {
|
||||
// x_lo = limbs[0], limbs[1]
|
||||
let x_lo = gf_from_limbs(limbs[0], limbs[1]);
|
||||
|
||||
// x_hi = limbs[2], limbs[3]
|
||||
// x_hi_hi = limbs[3] (upper 32 bits of x_hi)
|
||||
let x_hi_hi = gf_from_limbs(limbs[3], 0u);
|
||||
|
||||
// x_hi_lo = limbs[2] (lower 32 bits of x_hi)
|
||||
let x_hi_lo = gf_from_limbs(limbs[2], 0u);
|
||||
|
||||
// Step 1: t0 = x_lo - x_hi_hi (with underflow detection)
|
||||
var t0: GoldilocksField;
|
||||
var underflow = false;
|
||||
|
||||
let sub0 = u32_sub_with_borrow(x_lo.limb0, x_hi_hi.limb0, 0u);
|
||||
let sub1 = u32_sub_with_borrow(x_lo.limb1, x_hi_hi.limb1, sub0.y);
|
||||
t0 = GoldilocksField(sub0.x, sub1.x);
|
||||
|
||||
if (sub1.y != 0u) {
|
||||
underflow = true;
|
||||
}
|
||||
|
||||
// Step 2: if underflow { t0 -= NEG_ORDER; }
|
||||
if (underflow) {
|
||||
let eps_sub0 = u32_sub_with_borrow(t0.limb0, EPSILON_LIMB0, 0u);
|
||||
let eps_sub1 = u32_sub_with_borrow(t0.limb1, EPSILON_LIMB1, eps_sub0.y);
|
||||
t0 = GoldilocksField(eps_sub0.x, eps_sub1.x);
|
||||
}
|
||||
|
||||
// Step 3: t1 = x_hi_lo * NEG_ORDER
|
||||
// NEG_ORDER = 2^32 - 1
|
||||
// x_hi_lo * (2^32 - 1) = (x_hi_lo << 32) - x_hi_lo
|
||||
// x_hi_lo is [limbs[2], 0]
|
||||
// x_hi_lo << 32 is [0, limbs[2]]
|
||||
|
||||
let shifted = GoldilocksField(0u, x_hi_lo.limb0);
|
||||
let t1_sub0 = u32_sub_with_borrow(shifted.limb0, x_hi_lo.limb0, 0u);
|
||||
let t1_sub1 = u32_sub_with_borrow(shifted.limb1, x_hi_lo.limb1, t1_sub0.y);
|
||||
let t1 = GoldilocksField(t1_sub0.x, t1_sub1.x);
|
||||
|
||||
// Step 4: result = t0 + t1 (with overflow handling like CPU)
|
||||
let add0 = u32_add_with_carry(t0.limb0, t1.limb0, 0u);
|
||||
let add1 = u32_add_with_carry(t0.limb1, t1.limb1, add0.y);
|
||||
var result = GoldilocksField(add0.x, add1.x);
|
||||
|
||||
// If overflow, add NEG_ORDER
|
||||
if (add1.y != 0u) {
|
||||
let eps_add0 = u32_add_with_carry(result.limb0, EPSILON_LIMB0, 0u);
|
||||
let eps_add1 = u32_add_with_carry(result.limb1, EPSILON_LIMB1, eps_add0.y);
|
||||
result = GoldilocksField(eps_add0.x, eps_add1.x);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Main Goldilocks field multiplication
|
||||
fn gf_mul(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
|
||||
// Handle special cases
|
||||
if (a.limb0 == 0u && a.limb1 == 0u) { return gf_zero(); }
|
||||
if (b.limb0 == 0u && b.limb1 == 0u) { return gf_zero(); }
|
||||
if (a.limb0 == 1u && a.limb1 == 0u) { return b; }
|
||||
if (b.limb0 == 1u && b.limb1 == 0u) { return a; }
|
||||
|
||||
// General case: multiply and reduce
|
||||
let unreduced = gf_mul_unreduced(a, b);
|
||||
return gf_reduce_4limb(unreduced);
|
||||
}
|
||||
|
||||
// S-box: x^7 in Goldilocks field (efficient approach)
|
||||
fn sbox(x: GoldilocksField) -> GoldilocksField {
|
||||
let x2 = gf_mul(x, x);
|
||||
let x4 = gf_mul(x2, x2);
|
||||
let x6 = gf_mul(x4, x2);
|
||||
return gf_mul(x6, x);
|
||||
}
|
||||
|
||||
// External linear layer for width 12 using correct 4x4 MDS matrix
|
||||
// Standard MDSMat4: [[2, 3, 1, 1], [1, 2, 3, 1], [1, 1, 2, 3], [3, 1, 1, 2]]
|
||||
fn external_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
|
||||
// First apply the 4x4 MDS matrix to each consecutive 4 elements
|
||||
for (var chunk = 0u; chunk < 3u; chunk++) {
|
||||
let offset = chunk * 4u;
|
||||
var x: array<GoldilocksField, 4>;
|
||||
|
||||
// Copy chunk
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
x[i] = (*state)[offset + i];
|
||||
}
|
||||
|
||||
// Optimized 4x4 MDS application using only additions and doubles,
|
||||
// matching apply_external_linear_layer_to_chunk in the CPU implementation.
|
||||
let t01 = gf_add(x[0], x[1]);
|
||||
let t23 = gf_add(x[2], x[3]);
|
||||
let t0123 = gf_add(t01, t23);
|
||||
let t01123 = gf_add(t0123, x[1]);
|
||||
let t01233 = gf_add(t0123, x[3]);
|
||||
|
||||
let two_x0 = gf_add(x[0], x[0]);
|
||||
let two_x2 = gf_add(x[2], x[2]);
|
||||
|
||||
// The order of updates matches the reference algorithm:
|
||||
// x[3] = t01233 + 2*x[0]
|
||||
// x[1] = t01123 + 2*x[2]
|
||||
// x[0] = t01123 + t01
|
||||
// x[2] = t01233 + t23
|
||||
let new_3 = gf_add(t01233, two_x0);
|
||||
let new_1 = gf_add(t01123, two_x2);
|
||||
let new_0 = gf_add(t01123, t01);
|
||||
let new_2 = gf_add(t01233, t23);
|
||||
|
||||
// Copy back
|
||||
(*state)[offset + 0u] = new_0;
|
||||
(*state)[offset + 1u] = new_1;
|
||||
(*state)[offset + 2u] = new_2;
|
||||
(*state)[offset + 3u] = new_3;
|
||||
}
|
||||
|
||||
// Now apply the circulant matrix part
|
||||
// Precompute the four sums of every four elements
|
||||
var sums: array<GoldilocksField, 4>;
|
||||
for (var k = 0u; k < 4u; k++) {
|
||||
sums[k] = gf_zero();
|
||||
for (var j = k; j < 12u; j += 4u) {
|
||||
sums[k] = gf_add(sums[k], (*state)[j]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add the appropriate sum to each element
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf_add((*state)[i], sums[i % 4u]);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal linear layer using diagonal matrix for width 12
|
||||
fn internal_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
|
||||
var result: array<GoldilocksField, 12>;
|
||||
|
||||
// Sum all elements
|
||||
var sum = gf_zero();
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
sum = gf_add(sum, (*state)[i]);
|
||||
}
|
||||
|
||||
// Apply diagonal matrix: result[i] = state[i] * diag[i] + sum
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
let diag_val = gf_from_u64_parts(
|
||||
MDS_MATRIX_DIAG_12[i][0], // low 32 bits
|
||||
MDS_MATRIX_DIAG_12[i][1] // high 32 bits
|
||||
);
|
||||
let scaled = gf_mul((*state)[i], diag_val);
|
||||
result[i] = gf_add(scaled, sum);
|
||||
}
|
||||
|
||||
// Copy result back to state
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = result[i];
|
||||
}
|
||||
}
|
||||
|
||||
// Fixed Poseidon2 permutation with proper structure
|
||||
fn poseidon2_permute(state: ptr<function, array<GoldilocksField, 12>>) {
|
||||
|
||||
// Initial MDS permutation (required by p3-poseidon2 spec)
|
||||
external_linear_layer(state);
|
||||
|
||||
// Initial external rounds (4 rounds)
|
||||
for (var round = 0u; round < 4u; round++) {
|
||||
// Add round constants
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf_add((*state)[i], gf_from_const(INITIAL_EXTERNAL_CONSTANTS[round][i]));
|
||||
}
|
||||
|
||||
// S-box on all elements
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = sbox((*state)[i]);
|
||||
}
|
||||
|
||||
// External linear layer (4x4 MDS matrix)
|
||||
external_linear_layer(state);
|
||||
|
||||
}
|
||||
|
||||
// Internal rounds (22 rounds)
|
||||
for (var round = 0u; round < 22u; round++) {
|
||||
// Add round constant to first element only
|
||||
(*state)[0] = gf_add((*state)[0], gf_from_const(INTERNAL_CONSTANTS[round]));
|
||||
// S-box on first element only
|
||||
(*state)[0] = sbox((*state)[0]);
|
||||
// Internal linear layer (diagonal matrix)
|
||||
internal_linear_layer(state);
|
||||
}
|
||||
|
||||
// Terminal external rounds (4 rounds)
|
||||
for (var round = 0u; round < 4u; round++) {
|
||||
// Add round constants
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf_add((*state)[i], gf_from_const(TERMINAL_EXTERNAL_CONSTANTS[round][i]));
|
||||
}
|
||||
// S-box on all elements
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = sbox((*state)[i]);
|
||||
}
|
||||
// External linear layer (4x4 MDS matrix)
|
||||
external_linear_layer(state);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert bytes to Goldilocks field elements matching reference implementation
|
||||
// Reference uses 4 bytes per field element with injective padding, creating 25 field elements from 96 bytes
|
||||
fn bytes_to_field_elements(input: array<u32, 24>) -> array<GoldilocksField, 25> {
|
||||
var felts: array<GoldilocksField, 25>;
|
||||
|
||||
// Convert u32 array to bytes (96 bytes total)
|
||||
var bytes: array<u32, 96>; // Using u32s to store bytes for easier processing
|
||||
for (var i = 0u; i < 24u; i++) {
|
||||
let val = input[i];
|
||||
bytes[i * 4u + 0u] = val & 0xFFu; // byte 0
|
||||
bytes[i * 4u + 1u] = (val >> 8u) & 0xFFu; // byte 1
|
||||
bytes[i * 4u + 2u] = (val >> 16u) & 0xFFu; // byte 2
|
||||
bytes[i * 4u + 3u] = (val >> 24u) & 0xFFu; // byte 3
|
||||
}
|
||||
|
||||
// Apply injective padding: add 1 byte, then pad with zeros to 4-byte alignment
|
||||
var padded_len = 96u + 1u; // 96 bytes + 1 marker byte = 97
|
||||
let padding_needed = (4u - (padded_len % 4u)) % 4u;
|
||||
padded_len = padded_len + padding_needed; // Should be 100 bytes (25 u32s worth)
|
||||
|
||||
// Create padded byte array
|
||||
var padded_bytes: array<u32, 100>;
|
||||
for (var i = 0u; i < 96u; i++) {
|
||||
padded_bytes[i] = bytes[i];
|
||||
}
|
||||
padded_bytes[96] = 1u; // End marker
|
||||
for (var i = 97u; i < 100u; i++) {
|
||||
padded_bytes[i] = 0u; // Padding zeros
|
||||
}
|
||||
|
||||
// Convert every 4 bytes to one field element (25 field elements total)
|
||||
for (var i = 0u; i < 25u; i++) {
|
||||
let byte_idx = i * 4u;
|
||||
// Create u32 from 4 bytes in little-endian order
|
||||
let val = padded_bytes[byte_idx] |
|
||||
(padded_bytes[byte_idx + 1u] << 8u) |
|
||||
(padded_bytes[byte_idx + 2u] << 16u) |
|
||||
(padded_bytes[byte_idx + 3u] << 24u);
|
||||
felts[i] = gf_from_u32(val);
|
||||
}
|
||||
|
||||
return felts;
|
||||
}
|
||||
|
||||
// Convert field elements back to bytes
|
||||
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;
|
||||
}
|
||||
|
||||
// Fixed Poseidon2 hash function with proper sponge construction
|
||||
fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
|
||||
var state: array<GoldilocksField, 12>;
|
||||
|
||||
// Initialize state to zero
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
state[i] = gf_zero();
|
||||
}
|
||||
|
||||
// Convert input to field elements (25 total: 24 from input + 1 terminator)
|
||||
let input_felts = bytes_to_field_elements(input);
|
||||
|
||||
// Sponge construction matching CPU reference exactly:
|
||||
// CPU processes field elements using push_to_buf() which absorbs in chunks of RATE=8
|
||||
|
||||
// Process first 24 elements (3 complete chunks of 8)
|
||||
for (var chunk = 0u; chunk < 3u; chunk++) {
|
||||
// Absorb 8 elements for this chunk
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let felt_idx = chunk * 8u + i;
|
||||
state[i] = gf_add(state[i], input_felts[felt_idx]);
|
||||
}
|
||||
// Permute after each complete chunk
|
||||
poseidon2_permute(&state);
|
||||
}
|
||||
|
||||
// Process remaining 1 element (partial chunk)
|
||||
// Now we have element 24 (the terminator = 1) remaining
|
||||
state[0] = gf_add(state[0], input_felts[24u]); // Add the terminator (value 1)
|
||||
|
||||
// Add sponge padding marker (ONE) to the next position
|
||||
state[1] = gf_add(state[1], gf_one());
|
||||
|
||||
// Final permutation
|
||||
poseidon2_permute(&state);
|
||||
|
||||
// First squeeze - get first 4 field elements (POSEIDON2_OUTPUT=4 -> 32 bytes)
|
||||
let first_output = field_elements_to_bytes(array<GoldilocksField, 4>(
|
||||
state[0], state[1], state[2], state[3]
|
||||
));
|
||||
|
||||
// Second squeeze
|
||||
poseidon2_permute(&state);
|
||||
|
||||
let second_output = field_elements_to_bytes(array<GoldilocksField, 4>(
|
||||
state[0], state[1], state[2], state[3]
|
||||
));
|
||||
|
||||
// Combine both squeezes into 64-byte output
|
||||
var result: array<u32, 16>;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
result[i] = first_output[i];
|
||||
result[i + 8u] = second_output[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Poseidon2 hash function for 64-byte input (used in double hash)
|
||||
fn poseidon2_hash_squeeze_twice_64(input: array<u32, 16>) -> array<u32, 16> {
|
||||
var state: array<GoldilocksField, 12>;
|
||||
|
||||
// Initialize state to zero
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
state[i] = gf_zero();
|
||||
}
|
||||
|
||||
// Convert input to field elements
|
||||
// 64 bytes = 16 u32s = 16 felts
|
||||
// Padding adds 1 byte + 3 zeros = 4 bytes = 1 felt (value 1)
|
||||
// Total 17 felts
|
||||
|
||||
// Process first 16 elements (4 complete chunks of 4)
|
||||
for (var chunk = 0u; chunk < 4u; chunk++) {
|
||||
// Absorb 4 elements for this chunk
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let felt_idx = chunk * 4u + i;
|
||||
state[i] = gf_add(state[i], gf_from_u32(input[felt_idx]));
|
||||
}
|
||||
// Permute after each complete chunk
|
||||
poseidon2_permute(&state);
|
||||
}
|
||||
|
||||
// Handle the last felt (padding marker = 1) and sponge padding
|
||||
// The last felt is 1 (from bytes 1, 0, 0, 0)
|
||||
state[0] = gf_add(state[0], gf_one());
|
||||
|
||||
// Add sponge padding marker (ONE) to the next position
|
||||
state[1] = gf_add(state[1], gf_one());
|
||||
|
||||
// Final permutation
|
||||
poseidon2_permute(&state);
|
||||
|
||||
// First squeeze
|
||||
let first_output = field_elements_to_bytes(array<GoldilocksField, 4>(
|
||||
state[0], state[1], state[2], state[3]
|
||||
));
|
||||
|
||||
// Second squeeze
|
||||
poseidon2_permute(&state);
|
||||
|
||||
let second_output = field_elements_to_bytes(array<GoldilocksField, 4>(
|
||||
state[0], state[1], state[2], state[3]
|
||||
));
|
||||
|
||||
// Combine both squeezes into 64-byte output
|
||||
var result: array<u32, 16>;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
result[i] = first_output[i];
|
||||
result[i + 8u] = second_output[i];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Single Poseidon2 hash with double squeeze (512-bit output)
|
||||
fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
|
||||
return poseidon2_hash_squeeze_twice(input);
|
||||
}
|
||||
|
||||
fn bswap32(v: u32) -> u32 {
|
||||
return ((v & 0xFFu) << 24u) | ((v & 0xFF00u) << 8u) | ((v >> 8u) & 0xFF00u) | (v >> 24u);
|
||||
}
|
||||
|
||||
// Main mining kernel
|
||||
@compute @workgroup_size(256)
|
||||
fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
// If solution already found, exit early
|
||||
if (atomicLoad(&results[0]) != 0u) {
|
||||
return;
|
||||
}
|
||||
|
||||
let thread_id = global_id.x;
|
||||
// Read dispatch configuration from buffer
|
||||
let total_threads = dispatch_config[0]; // Total logical threads in this dispatch
|
||||
let nonces_per_thread = dispatch_config[1]; // Nonces processed by each thread
|
||||
let total_nonces = dispatch_config[2]; // Total logical nonces this dispatch should cover
|
||||
|
||||
// Guard against threads beyond configured total_threads
|
||||
if (thread_id >= total_threads) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Base logical index for this thread
|
||||
let base_index = thread_id * nonces_per_thread;
|
||||
|
||||
// Work coarsening: each thread processes a contiguous block of nonces
|
||||
for (var j = 0u; j < nonces_per_thread; j = j + 1u) {
|
||||
let logical_index = base_index + j;
|
||||
if (logical_index >= total_nonces) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if solution already found (early exit for entire dispatch)
|
||||
if (atomicLoad(&results[0]) != 0u) {
|
||||
return;
|
||||
}
|
||||
|
||||
// current_nonce = start_nonce + logical_index. The host guarantees a batch
|
||||
// never carries into the high nonce half (limbs 8..15).
|
||||
var current_nonce: array<u32, 16>;
|
||||
var carry: u32 = 0u;
|
||||
|
||||
let val0 = start_nonce[0];
|
||||
let sum0 = val0 + logical_index;
|
||||
current_nonce[0] = sum0;
|
||||
carry = select(0u, 1u, sum0 < val0);
|
||||
|
||||
for (var i = 1u; i < 8u; i++) {
|
||||
let val = start_nonce[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] = start_nonce[i];
|
||||
}
|
||||
|
||||
// Resume the sponge from the precomputed midstate: absorb the low nonce
|
||||
// half (as Big Endian words), pad, squeeze twice (3 permutations instead of 5).
|
||||
var state: array<GoldilocksField, 12>;
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
state[i] = gf_from_limbs(midstate[2u * i], midstate[2u * i + 1u]);
|
||||
}
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let val = current_nonce[7u - i];
|
||||
let rev = ((val & 0xFFu) << 24u) |
|
||||
((val & 0xFF00u) << 8u) |
|
||||
((val & 0xFF0000u) >> 8u) |
|
||||
((val & 0xFF000000u) >> 24u);
|
||||
state[i] = gf_add(state[i], gf_from_u32(rev));
|
||||
}
|
||||
poseidon2_permute(&state);
|
||||
state[0] = gf_add(state[0], gf_one());
|
||||
state[1] = gf_add(state[1], gf_one());
|
||||
poseidon2_permute(&state);
|
||||
|
||||
// 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.
|
||||
let first_output = field_elements_to_bytes(array<GoldilocksField, 4>(
|
||||
state[0], state[1], state[2], state[3]
|
||||
));
|
||||
var hash_le: array<u32, 16>;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
hash_le[15u - i] = bswap32(first_output[i]);
|
||||
}
|
||||
var cmp = 0u;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let h = hash_le[15u - i];
|
||||
let t = difficulty_target[15u - i];
|
||||
if (h != t) {
|
||||
cmp = select(2u, 1u, h > t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cmp == 1u) {
|
||||
continue;
|
||||
}
|
||||
|
||||
poseidon2_permute(&state);
|
||||
let second_output = field_elements_to_bytes(array<GoldilocksField, 4>(
|
||||
state[0], state[1], state[2], state[3]
|
||||
));
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
hash_le[i] = bswap32(second_output[7u - i]);
|
||||
}
|
||||
var below = cmp == 2u;
|
||||
if (!below) {
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let h = hash_le[7u - i];
|
||||
let t = difficulty_target[7u - i];
|
||||
if (h != t) {
|
||||
below = h < t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (below) {
|
||||
// Try to claim the solution
|
||||
if (atomicExchange(&results[0], 1u) == 0u) {
|
||||
// results layout: [0]=found, [1..16]=nonce, [17..32]=hash
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
atomicStore(&results[1u + i], current_nonce[i]);
|
||||
atomicStore(&results[17u + i], hash_le[i]);
|
||||
}
|
||||
}
|
||||
return; // Exit loop after finding solution
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
// 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),
|
||||
// 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;
|
||||
|
||||
// Padded with a trailing zero so the internal layer can add the next round's
|
||||
// constant unconditionally.
|
||||
const RC_INTERNAL: array<u64, 23> = array<u64, 23>(
|
||||
0x97f7798a784ad863lu, 0xd1d2bf082f60d4f0lu, 0x69a377a79f9ad206lu, 0xa9d06906a3858e24lu, 0x295275001eede5b5lu, 0x5874e441117bd746lu, 0x8a084bbba8ed86cclu, 0x3defd7645cde6425lu, 0x3998cfe6871cc137lu, 0x3e52ef8bca48314alu, 0x964a209f85dc9ecclu, 0x3fcc9ee82cc4577elu, 0x8e79b4a5d0096d6dlu, 0x8492362ad2392556lu, 0xee72f470262574d6lu, 0x1e0e18496da2444alu, 0x0f3a74bf215eaac6lu, 0x1b061b76a1c0ded3lu, 0x192c42d86803d7a6lu, 0xf6d49ff997ae0260lu, 0x3ec372e7a0fa3786lu, 0x5538cdf4f23445d3lu, 0lu
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
// Sum accumulator: value = lo + carries * 2^64, folded once via 2^64 ≡ EPS64 (mod P).
|
||||
struct Acc {
|
||||
lo: u64,
|
||||
carries: u32,
|
||||
}
|
||||
|
||||
fn acc_add(a: Acc, b: u64) -> Acc {
|
||||
let s = a.lo + b;
|
||||
return Acc(s, a.carries + select(0u, 1u, s < a.lo));
|
||||
}
|
||||
|
||||
fn acc_add2(a: Acc, b: Acc) -> Acc {
|
||||
let s = a.lo + b.lo;
|
||||
return Acc(s, a.carries + b.carries + select(0u, 1u, s < a.lo));
|
||||
}
|
||||
|
||||
fn acc_fold(a: Acc) -> u64 {
|
||||
let c = u64(a.carries);
|
||||
let t = a.lo + ((c << 32u) - c);
|
||||
return t + select(0lu, EPS64, t < a.lo);
|
||||
}
|
||||
|
||||
struct U128 {
|
||||
lo: u64,
|
||||
hi: u64,
|
||||
}
|
||||
|
||||
// Reduce a 128-bit value (lo + hi*2^64) mod P using
|
||||
// 2^64 ≡ EPS64 and 2^96 ≡ -1 (mod P).
|
||||
fn gf64_reduce(v: U128) -> u64 {
|
||||
let hi_hi = v.hi >> 32u;
|
||||
let hi_lo = v.hi & EPS64;
|
||||
var t0 = v.lo - hi_hi;
|
||||
t0 = t0 - select(0lu, EPS64, v.lo < hi_hi);
|
||||
let t1 = hi_lo * EPS64;
|
||||
let t2 = t0 + t1;
|
||||
return t2 + select(0lu, EPS64, t2 < t0);
|
||||
}
|
||||
|
||||
fn mul_wide(a: u64, b: u64) -> U128 {
|
||||
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 = (ll >> 32u) + (lh & EPS64) + (hl & EPS64);
|
||||
return U128((mid << 32u) | (ll & EPS64), hh + (lh >> 32u) + (hl >> 32u) + (mid >> 32u));
|
||||
}
|
||||
|
||||
// (a*b + addend) mod P for b <= 2^64 - 2^32 (all MDS_DIAG entries): the addend's
|
||||
// value and carries are folded into the 128-bit product before reduction.
|
||||
fn gf64_mul_add(a: u64, b: u64, addend: Acc) -> u64 {
|
||||
let v = mul_wide(a, b);
|
||||
let lo = v.lo + addend.lo;
|
||||
return gf64_reduce(U128(lo, v.hi + u64(addend.carries) + select(0lu, 1lu, lo < v.lo)));
|
||||
}
|
||||
|
||||
fn gf64_mul(a: u64, b: u64) -> u64 {
|
||||
return gf64_reduce(mul_wide(a, b));
|
||||
}
|
||||
|
||||
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 = (ll >> 32u) + ((lh & EPS64) << 1u);
|
||||
return gf64_reduce(U128((mid << 32u) | (ll & EPS64), hh + ((lh >> 32u) << 1u) + (mid >> 32u)));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 4x4 MDS circ(2, 3, 1, 1) on one chunk, results left unreduced.
|
||||
fn mds4(x0: u64, x1: u64, x2: u64, x3: u64) -> array<Acc, 4> {
|
||||
let t01 = acc_add(Acc(x0, 0u), x1);
|
||||
let t23 = acc_add(Acc(x2, 0u), x3);
|
||||
let t0123 = acc_add2(t01, t23);
|
||||
let t01123 = acc_add(t0123, x1);
|
||||
let t01233 = acc_add(t0123, x3);
|
||||
return array<Acc, 4>(
|
||||
acc_add2(t01123, t01),
|
||||
acc_add2(t01123, acc_add(Acc(x2, 0u), x2)),
|
||||
acc_add2(t01233, t23),
|
||||
acc_add2(t01233, acc_add(Acc(x0, 0u), x0))
|
||||
);
|
||||
}
|
||||
|
||||
// External linear layer: 4x4 MDS on each chunk, then circulant sums, plus the
|
||||
// next round's constants. Additions are accumulated unreduced (at most 27
|
||||
// carries) and folded once per output.
|
||||
fn ext_layer64(state: ptr<function, array<u64, 12>>, rc: array<u64, 12>) {
|
||||
var y: array<Acc, 12>;
|
||||
for (var chunk = 0u; chunk < 3u; chunk++) {
|
||||
let o = chunk * 4u;
|
||||
let m = mds4((*state)[o], (*state)[o + 1u], (*state)[o + 2u], (*state)[o + 3u]);
|
||||
y[o] = m[0];
|
||||
y[o + 1u] = m[1];
|
||||
y[o + 2u] = m[2];
|
||||
y[o + 3u] = m[3];
|
||||
}
|
||||
for (var k = 0u; k < 4u; k++) {
|
||||
let s = acc_add2(acc_add2(y[k], y[k + 4u]), y[k + 8u]);
|
||||
(*state)[k] = acc_fold(acc_add(acc_add2(y[k], s), rc[k]));
|
||||
(*state)[k + 4u] = acc_fold(acc_add(acc_add2(y[k + 4u], s), rc[k + 4u]));
|
||||
(*state)[k + 8u] = acc_fold(acc_add(acc_add2(y[k + 8u], s), rc[k + 8u]));
|
||||
}
|
||||
}
|
||||
|
||||
// Internal linear layer: diagonal matrix plus full sum, plus the next round's
|
||||
// constant on element 0 (the S-box output, which is summed last).
|
||||
fn int_layer64(state: ptr<function, array<u64, 12>>, rc0: u64) {
|
||||
let s12 = acc_add(Acc((*state)[1], 0u), (*state)[2]);
|
||||
let s34 = acc_add(Acc((*state)[3], 0u), (*state)[4]);
|
||||
let s56 = acc_add(Acc((*state)[5], 0u), (*state)[6]);
|
||||
let s78 = acc_add(Acc((*state)[7], 0u), (*state)[8]);
|
||||
let s910 = acc_add(Acc((*state)[9], 0u), (*state)[10]);
|
||||
let s1234 = acc_add2(s12, s34);
|
||||
let s5678 = acc_add2(s56, s78);
|
||||
let s91011 = acc_add(s910, (*state)[11]);
|
||||
let sum = acc_add(acc_add2(acc_add2(s1234, s5678), s91011), (*state)[0]);
|
||||
(*state)[0] = gf64_mul_add((*state)[0], MDS_DIAG[0], acc_add(sum, rc0));
|
||||
(*state)[1] = gf64_mul_add((*state)[1], MDS_DIAG[1], sum);
|
||||
(*state)[2] = gf64_mul_add((*state)[2], MDS_DIAG[2], sum);
|
||||
(*state)[3] = gf64_mul_add((*state)[3], MDS_DIAG[3], sum);
|
||||
(*state)[4] = gf64_mul_add((*state)[4], MDS_DIAG[4], sum);
|
||||
(*state)[5] = gf64_mul_add((*state)[5], MDS_DIAG[5], sum);
|
||||
(*state)[6] = gf64_mul_add((*state)[6], MDS_DIAG[6], sum);
|
||||
(*state)[7] = gf64_mul_add((*state)[7], MDS_DIAG[7], sum);
|
||||
(*state)[8] = gf64_mul_add((*state)[8], MDS_DIAG[8], sum);
|
||||
(*state)[9] = gf64_mul_add((*state)[9], MDS_DIAG[9], sum);
|
||||
(*state)[10] = gf64_mul_add((*state)[10], MDS_DIAG[10], sum);
|
||||
(*state)[11] = gf64_mul_add((*state)[11], MDS_DIAG[11], sum);
|
||||
}
|
||||
|
||||
fn sbox_lanes(state: ptr<function, array<u64, 12>>, lanes: u32) {
|
||||
for (var i = 0u; i < lanes; i++) {
|
||||
(*state)[i] = gf64_sbox((*state)[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn add_rc(state: ptr<function, array<u64, 12>>, rc: array<u64, 12>) {
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf64_add((*state)[i], rc[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const RC_ZERO: array<u64, 12> = array<u64, 12>(0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu);
|
||||
// Constants added by each external layer, which feeds the S-box that follows it:
|
||||
// entries 0..3 precede the initial rounds, entry 4 carries the first internal
|
||||
// round's constant on element 0, entries 5..7 precede terminal rounds 1..3.
|
||||
const RC_EXT: array<array<u64, 12>, 9> = array<array<u64, 12>, 9>(
|
||||
RC_INITIAL[0], RC_INITIAL[1], RC_INITIAL[2], RC_INITIAL[3],
|
||||
array<u64, 12>(RC_INTERNAL[0], 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu, 0lu),
|
||||
RC_TERMINAL[1], RC_TERMINAL[2], RC_TERMINAL[3], RC_ZERO
|
||||
);
|
||||
|
||||
// One loop drives all 30 rounds so each layer is emitted once: step 0 is the
|
||||
// initial external layer, steps 1..4 and 27..30 are external rounds, steps
|
||||
// 5..26 are internal rounds. Round constants are added by the linear layer
|
||||
// preceding each S-box (the last internal layer is followed by the first
|
||||
// terminal round's constants).
|
||||
fn permute64(state: ptr<function, array<u64, 12>>) {
|
||||
for (var k = 0u; k < 31u; k++) {
|
||||
let is_ext = k < 5u || k > 26u;
|
||||
if (k > 0u) {
|
||||
sbox_lanes(state, select(1u, 12u, is_ext));
|
||||
}
|
||||
if (is_ext) {
|
||||
ext_layer64(state, RC_EXT[select(k, k - 22u, k > 26u)]);
|
||||
} else {
|
||||
int_layer64(state, RC_INTERNAL[k - 4u]);
|
||||
if (k == 26u) {
|
||||
add_rc(state, RC_TERMINAL[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 = mid;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
st[i] = gf64_add(st[i], u64(bswap32(current_nonce[7u - i])));
|
||||
}
|
||||
// Squeeze-and-compare phases share one inlined permutation: phase 0
|
||||
// pads after absorbing, phase 1 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, and only candidates run phase 2 for the
|
||||
// low half. Byte-swapped hash words are produced on demand.
|
||||
var hash_le: array<u32, 16>;
|
||||
var cmp = 0u;
|
||||
var below = false;
|
||||
for (var phase = 0u; phase < 3u; phase++) {
|
||||
permute64(&st);
|
||||
if (phase == 0u) {
|
||||
st[0] = gf64_add(st[0], 1lu);
|
||||
st[1] = gf64_add(st[1], 1lu);
|
||||
continue;
|
||||
}
|
||||
var words: array<u32, 8>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let c = gf64_canon(st[i]);
|
||||
words[2u * i] = bswap32(u32(c & EPS64));
|
||||
words[2u * i + 1u] = bswap32(u32(c >> 32u));
|
||||
}
|
||||
let base = select(15u, 7u, phase == 2u);
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
hash_le[base - i] = words[i];
|
||||
}
|
||||
if (phase == 1u) {
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let h = words[i];
|
||||
let t = tgt[15u - i];
|
||||
if (h != t) {
|
||||
cmp = select(2u, 1u, h > t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cmp == 1u) {
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
below = cmp == 2u;
|
||||
if (!below) {
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let h = words[i];
|
||||
let t = tgt[7u - i];
|
||||
if (h != t) {
|
||||
below = h < t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cmp == 1u) {
|
||||
continue;
|
||||
}
|
||||
|
||||
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, RC_ZERO);
|
||||
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, 0lu);
|
||||
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);
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
mod end_to_end_tests;
|
||||
mod tests;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::PRIMARY,
|
||||
..Default::default()
|
||||
});
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions::default())
|
||||
.await
|
||||
.expect("no GPU adapter");
|
||||
|
||||
let mut failures = 0usize;
|
||||
let has_int64 = 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(kernel.label()),
|
||||
required_features: if kernel.needs_int64() {
|
||||
wgpu::Features::SHADER_INT64
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
failures += run_suite(&device, &queue, kernel.source(), kernel.label()).await;
|
||||
}
|
||||
|
||||
if failures > 0 {
|
||||
return Err(format!("{failures} test group(s) failed").into());
|
||||
}
|
||||
println!("\nAll tests completed!");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn run_suite(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
label: &str,
|
||||
) -> usize {
|
||||
println!("\n==== Running Poseidon2 GPU tests against {label} ====\n");
|
||||
let mut failures = 0usize;
|
||||
macro_rules! run {
|
||||
($name:literal, $fut:expr) => {
|
||||
if let Err(e) = $fut.await {
|
||||
eprintln!("❌ {} failed: {}", $name, e);
|
||||
failures += 1;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
run!(
|
||||
"gf_from_const",
|
||||
tests::test_gf_from_const(device, queue, shader_src)
|
||||
);
|
||||
run!("gf_mul", tests::test_gf_mul(device, queue, shader_src));
|
||||
run!("sbox", tests::test_sbox(device, queue, shader_src));
|
||||
run!(
|
||||
"mds matrix",
|
||||
tests::test_mds_matrix(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"internal linear layer",
|
||||
tests::test_internal_linear_layer(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"external linear layer",
|
||||
tests::test_external_linear_layer(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"initial external rounds",
|
||||
tests::test_poseidon2_initial_external_rounds(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"terminal external rounds",
|
||||
tests::test_poseidon2_terminal_external_rounds(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"constants verification",
|
||||
tests::test_poseidon2_constants_verification(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"internal constants verification",
|
||||
tests::test_poseidon2_internal_constants_verification(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"internal rounds only",
|
||||
tests::test_poseidon2_internal_rounds_only(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"terminal external constants verification",
|
||||
tests::test_poseidon2_terminal_external_constants_verification(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"poseidon2 permutation",
|
||||
tests::test_poseidon2_permutation(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"bytes to field elements",
|
||||
tests::test_bytes_to_field_elements(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"field elements to bytes",
|
||||
tests::test_field_elements_to_bytes(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"poseidon2 squeeze-twice",
|
||||
tests::test_poseidon2_squeeze_twice(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"hash squeeze twice",
|
||||
tests::test_hash_squeeze_twice(device, queue, shader_src)
|
||||
);
|
||||
run!(
|
||||
"end-to-end mining",
|
||||
end_to_end_tests::test_end_to_end_mining(device, queue, shader_src)
|
||||
);
|
||||
|
||||
failures
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,11 +6,9 @@ publish = false
|
||||
description = "Metrics and Prometheus exporter utilities for the Quantus External Miner"
|
||||
|
||||
[features]
|
||||
# Provide a Registry by default; HTTP exporter is opt-in.
|
||||
default = ["registry"]
|
||||
registry = []
|
||||
default = []
|
||||
# Enable a Warp-based HTTP exporter (used when --metrics-port is provided).
|
||||
http-exporter = ["dep:serde", "dep:serde_json", "dep:tokio", "dep:warp"]
|
||||
http-exporter = ["dep:tokio", "dep:warp"]
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
@@ -21,5 +19,3 @@ prometheus = { version = "0.13", default-features = false }
|
||||
# Optional HTTP exporter stack (enabled via `http-exporter` feature)
|
||||
warp = { workspace = true, optional = true }
|
||||
tokio = { workspace = true, features = ["full"], optional = true }
|
||||
serde = { workspace = true, features = ["derive"], optional = true }
|
||||
serde_json = { workspace = true, optional = true }
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
+215
-578
@@ -1,59 +1,95 @@
|
||||
#![deny(rust_2018_idioms)]
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
//! Minimal metrics scaffolding for the Quantus External Miner.
|
||||
//! Minimal metrics for the Quantus External Miner.
|
||||
//!
|
||||
//! - Provides a global Prometheus registry and a handful of default metrics.
|
||||
//! - Exposes helper functions to update metrics from the service layer.
|
||||
//! - Optionally runs a Warp-based HTTP endpoint (/metrics) when the
|
||||
//! `http-exporter` feature is enabled. This is gated at runtime by
|
||||
//! the presence of a metrics port in the CLI (`--metrics-port`).
|
||||
//! Exposes a small set of Prometheus metrics for monitoring mining performance:
|
||||
//! - `miner_hash_rate`: Total hash rate (CPU + GPU combined)
|
||||
//! - `miner_cpu_hash_rate`: CPU-only hash rate
|
||||
//! - `miner_gpu_hash_rate`: GPU-only hash rate
|
||||
//! - `miner_hashes_total`: Total hashes computed (all time)
|
||||
//! - `miner_active_jobs`: Currently running jobs (0 or 1)
|
||||
//! - `miner_workers`: Total worker count
|
||||
//! - `miner_cpu_workers`: Number of CPU workers
|
||||
//! - `miner_gpu_devices`: Number of GPU devices
|
||||
//! - `miner_effective_cpus`: Detected CPU cores
|
||||
//!
|
||||
//! Default metrics:
|
||||
//! - miner_jobs_total{status} : number of jobs by terminal state
|
||||
//! - miner_hashes_total : total nonces tested
|
||||
//! - miner_hash_rate : current estimated hash rate (nonces/sec)
|
||||
//! - miner_http_requests_total{code,endpoint} : HTTP request counts for miner API
|
||||
//!
|
||||
//! Notes:
|
||||
//! - The HTTP exporter is spawned as a background task and does not block.
|
||||
//! - If the `http-exporter` feature is disabled, `start_http_exporter` becomes
|
||||
//! a no-op that returns immediately.
|
||||
//! - When jobs/threads end, prefer removing gauge label children (series) rather than
|
||||
//! writing a zero; this avoids scrape-timing artifacts and produces cleaner rollups.
|
||||
//! - Service-level observability: expose an `active_jobs` gauge and a `mine_requests_total`
|
||||
//! counter (labeled by result) to disambiguate "idle because no jobs" vs "actively mining".
|
||||
//! Optionally runs a Warp-based HTTP endpoint (`/metrics`) when the
|
||||
//! `http-exporter` feature is enabled.
|
||||
|
||||
use once_cell::sync::Lazy;
|
||||
use prometheus::{
|
||||
opts, Encoder, Gauge, GaugeVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Registry,
|
||||
TextEncoder,
|
||||
};
|
||||
use prometheus::{IntCounter, IntGauge, Registry};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(feature = "http-exporter")]
|
||||
use {
|
||||
anyhow::Result,
|
||||
prometheus::{Encoder, TextEncoder},
|
||||
std::net::SocketAddr,
|
||||
warp::{http::Response, Filter},
|
||||
warp::Filter,
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "http-exporter"))]
|
||||
use anyhow::Result;
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Mutex;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Global Registry and Default Metrics
|
||||
// -------------------------------------------------------------------------------------
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global Registry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static REGISTRY: Lazy<Registry> = Lazy::new(Registry::new);
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// High-level service metrics
|
||||
// -------------------------------------------------------------------------------------
|
||||
// lair: build, device and job metrics (quantus/miner#9); see lair.rs.
|
||||
mod lair;
|
||||
pub use lair::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hash Rate Metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new("miner_hash_rate", "Total hash rate in hashes per second")
|
||||
.expect("create miner_hash_rate");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_hash_rate");
|
||||
g
|
||||
});
|
||||
|
||||
static CPU_HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new("miner_cpu_hash_rate", "CPU hash rate in hashes per second")
|
||||
.expect("create miner_cpu_hash_rate");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_cpu_hash_rate");
|
||||
g
|
||||
});
|
||||
|
||||
static GPU_HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new("miner_gpu_hash_rate", "GPU hash rate in hashes per second")
|
||||
.expect("create miner_gpu_hash_rate");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_gpu_hash_rate");
|
||||
g
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Counter Metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static HASHES_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
|
||||
let c = IntCounter::new("miner_hashes_total", "Total hashes computed")
|
||||
.expect("create miner_hashes_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_hashes_total");
|
||||
c
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Gauge Metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static ACTIVE_JOBS: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new(
|
||||
@@ -67,25 +103,37 @@ static ACTIVE_JOBS: Lazy<IntGauge> = Lazy::new(|| {
|
||||
g
|
||||
});
|
||||
|
||||
static ENGINE_BACKEND_INFO: Lazy<GaugeVec> = Lazy::new(|| {
|
||||
let g = GaugeVec::new(
|
||||
opts!(
|
||||
"miner_engine_backend",
|
||||
"Engine backend info (label-only gauge set to 1). Labels: engine, backend"
|
||||
),
|
||||
&["engine", "backend"],
|
||||
)
|
||||
.expect("create miner_engine_backend");
|
||||
static WORKERS: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new("miner_workers", "Total number of worker threads")
|
||||
.expect("create miner_workers");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_engine_backend");
|
||||
.expect("register miner_workers");
|
||||
g
|
||||
});
|
||||
|
||||
static CPU_WORKERS: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new("miner_cpu_workers", "Number of CPU worker threads")
|
||||
.expect("create miner_cpu_workers");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_cpu_workers");
|
||||
g
|
||||
});
|
||||
|
||||
static GPU_DEVICES: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new("miner_gpu_devices", "Number of GPU devices")
|
||||
.expect("create miner_gpu_devices");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_gpu_devices");
|
||||
g
|
||||
});
|
||||
|
||||
static EFFECTIVE_CPUS: Lazy<IntGauge> = Lazy::new(|| {
|
||||
let g = IntGauge::new(
|
||||
"miner_effective_cpus",
|
||||
"Detected logical CPU capacity available to this process (cpuset-aware)",
|
||||
"Detected logical CPU cores available to this process",
|
||||
)
|
||||
.expect("create miner_effective_cpus");
|
||||
REGISTRY
|
||||
@@ -94,583 +142,172 @@ static EFFECTIVE_CPUS: Lazy<IntGauge> = Lazy::new(|| {
|
||||
g
|
||||
});
|
||||
|
||||
static MINE_REQUESTS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_mine_requests_total",
|
||||
"Count of /mine requests by result"
|
||||
),
|
||||
&["result"], // accepted, duplicate, invalid, error
|
||||
)
|
||||
.expect("create miner_mine_requests_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_mine_requests_total");
|
||||
c
|
||||
});
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hash Rate Tracking
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static JOBS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!("miner_jobs_total", "Number of jobs by status"),
|
||||
&["status"],
|
||||
)
|
||||
.expect("create miner_jobs_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_jobs_total");
|
||||
c
|
||||
});
|
||||
|
||||
static HASHES_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
|
||||
let c = IntCounter::new("miner_hashes_total", "Total nonces tested")
|
||||
.expect("create miner_hashes_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_hashes_total");
|
||||
c
|
||||
});
|
||||
|
||||
static HASH_RATE: Lazy<Gauge> = Lazy::new(|| {
|
||||
let g =
|
||||
Gauge::new("miner_hash_rate", "Estimated hash rate (nonces per second)").expect("create");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_hash_rate");
|
||||
g
|
||||
});
|
||||
|
||||
static HTTP_REQUESTS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_http_requests_total",
|
||||
"HTTP requests count by endpoint and status code"
|
||||
),
|
||||
&["endpoint", "code"],
|
||||
)
|
||||
.expect("create miner_http_requests_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_http_requests_total");
|
||||
c
|
||||
});
|
||||
|
||||
/// Access the global Prometheus registry used by this crate.
|
||||
// Engine-aware, per-job, and per-thread labeled metrics
|
||||
pub fn set_effective_cpus(n: i64) {
|
||||
EFFECTIVE_CPUS.set(n);
|
||||
/// Tracks cumulative hashes to compute rolling hash rates.
|
||||
///
|
||||
/// The hash rate is computed as total_hashes / elapsed_time since mining started.
|
||||
/// Call `reset()` when a new mining session begins to get accurate rates.
|
||||
struct HashRateTracker {
|
||||
/// Cumulative CPU hashes since last reset
|
||||
cpu_total: u64,
|
||||
/// Cumulative GPU hashes since last reset
|
||||
gpu_total: u64,
|
||||
/// When tracking started - set via reset() when mining begins
|
||||
start_time: Option<Instant>,
|
||||
}
|
||||
|
||||
static JOB_HASHES_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_job_hashes_total",
|
||||
"Total nonces tested per job and engine"
|
||||
),
|
||||
&["engine", "job_id"],
|
||||
)
|
||||
.expect("create miner_job_hashes_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_job_hashes_total");
|
||||
c
|
||||
});
|
||||
impl HashRateTracker {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
cpu_total: 0,
|
||||
gpu_total: 0,
|
||||
start_time: None,
|
||||
}
|
||||
}
|
||||
|
||||
static JOB_HASH_RATE: Lazy<GaugeVec> = Lazy::new(|| {
|
||||
let g = GaugeVec::new(
|
||||
opts!(
|
||||
"miner_job_hash_rate",
|
||||
"Estimated hash rate (nonces per second) per job and engine"
|
||||
),
|
||||
&["engine", "job_id"],
|
||||
)
|
||||
.expect("create miner_job_hash_rate");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_job_hash_rate");
|
||||
g
|
||||
});
|
||||
/// Reset the tracker - call this when a new mining session starts.
|
||||
fn reset(&mut self) {
|
||||
self.cpu_total = 0;
|
||||
self.gpu_total = 0;
|
||||
self.start_time = Some(Instant::now());
|
||||
}
|
||||
|
||||
static JOB_ESTIMATED_RATE: Lazy<GaugeVec> = Lazy::new(|| {
|
||||
let g = GaugeVec::new(
|
||||
opts!(
|
||||
"miner_job_estimated_rate",
|
||||
"Estimated work rate (nonces per second) per engine and backend"
|
||||
),
|
||||
&["engine", "backend"],
|
||||
)
|
||||
.expect("create miner_job_estimated_rate");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_job_estimated_rate");
|
||||
g
|
||||
});
|
||||
fn record_cpu(&mut self, hashes: u64) {
|
||||
self.cpu_total += hashes;
|
||||
self.update_rates();
|
||||
}
|
||||
|
||||
static CANDIDATES_FOUND_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_candidates_found_total",
|
||||
"Total candidates found per job and engine"
|
||||
),
|
||||
&["engine", "job_id"],
|
||||
)
|
||||
.expect("create miner_candidates_found_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_candidates_found_total");
|
||||
c
|
||||
});
|
||||
fn record_gpu(&mut self, hashes: u64) {
|
||||
self.gpu_total += hashes;
|
||||
self.update_rates();
|
||||
}
|
||||
|
||||
static CANDIDATES_FALSE_POSITIVE_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_candidates_false_positive_total",
|
||||
"Total false-positive candidates rejected by host re-verification per engine"
|
||||
),
|
||||
&["engine"],
|
||||
)
|
||||
.expect("create miner_candidates_false_positive_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_candidates_false_positive_total");
|
||||
c
|
||||
});
|
||||
|
||||
static SAMPLE_MISMATCH_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_sample_mismatch_total",
|
||||
"Total decision parity mismatches between engine and host per engine"
|
||||
),
|
||||
&["engine"],
|
||||
)
|
||||
.expect("create miner_sample_mismatch_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_sample_mismatch_total");
|
||||
c
|
||||
});
|
||||
|
||||
static MISSED_WINNER_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
|
||||
let c = IntCounter::new(
|
||||
"miner_gpu_g2_missed_winner_total",
|
||||
"Host detected a winner in G2 batch sample but device did not flag early-exit",
|
||||
)
|
||||
.expect("create miner_gpu_g2_missed_winner_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_gpu_g2_missed_winner_total");
|
||||
c
|
||||
});
|
||||
|
||||
static FOUND_BY_ORIGIN_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_found_by_origin_total",
|
||||
"Count of candidates found by origin per engine"
|
||||
),
|
||||
&["engine", "origin"],
|
||||
)
|
||||
.expect("create miner_found_by_origin_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_found_by_origin_total");
|
||||
c
|
||||
});
|
||||
|
||||
static THREAD_HASHES_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_thread_hashes_total",
|
||||
"Total nonces tested per thread, job, and engine"
|
||||
),
|
||||
&["engine", "job_id", "thread_id"],
|
||||
)
|
||||
.expect("create miner_thread_hashes_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_thread_hashes_total");
|
||||
c
|
||||
});
|
||||
|
||||
static THREAD_HASH_RATE: Lazy<GaugeVec> = Lazy::new(|| {
|
||||
let g = GaugeVec::new(
|
||||
opts!(
|
||||
"miner_thread_hash_rate",
|
||||
"Estimated hash rate (nonces per second) per thread, job, and engine"
|
||||
),
|
||||
&["engine", "job_id", "thread_id"],
|
||||
)
|
||||
.expect("create miner_thread_hash_rate");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_thread_hash_rate");
|
||||
g
|
||||
});
|
||||
|
||||
static JOBS_BY_ENGINE_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
let c = IntCounterVec::new(
|
||||
opts!(
|
||||
"miner_jobs_by_engine_total",
|
||||
"Number of jobs by engine and terminal status"
|
||||
),
|
||||
&["engine", "status"],
|
||||
)
|
||||
.expect("create miner_jobs_by_engine_total");
|
||||
REGISTRY
|
||||
.register(Box::new(c.clone()))
|
||||
.expect("register miner_jobs_by_engine_total");
|
||||
c
|
||||
});
|
||||
|
||||
static JOB_STATUS_GAUGE: Lazy<IntGaugeVec> = Lazy::new(|| {
|
||||
let g = IntGaugeVec::new(
|
||||
opts!(
|
||||
"miner_job_status",
|
||||
"Job status gauge (set to 1 for current status)"
|
||||
),
|
||||
&["engine", "job_id", "status"],
|
||||
)
|
||||
.expect("create miner_job_status");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_job_status");
|
||||
g
|
||||
});
|
||||
|
||||
static JOB_FOUND_ORIGIN: Lazy<GaugeVec> = Lazy::new(|| {
|
||||
let g = GaugeVec::new(
|
||||
opts!(
|
||||
"miner_job_found_origin",
|
||||
"Job found origin gauge (set to 1 for the origin that found the candidate)"
|
||||
),
|
||||
&["engine", "job_id", "origin"],
|
||||
)
|
||||
.expect("create miner_job_found_origin");
|
||||
REGISTRY
|
||||
.register(Box::new(g.clone()))
|
||||
.expect("register miner_job_found_origin");
|
||||
g
|
||||
});
|
||||
|
||||
pub fn default_registry() -> &'static Registry {
|
||||
®ISTRY
|
||||
fn update_rates(&self) {
|
||||
if let Some(start) = self.start_time {
|
||||
let elapsed = start.elapsed().as_secs_f64();
|
||||
// Require at least 0.1 seconds of data for meaningful rate
|
||||
if elapsed >= 0.1 {
|
||||
let cpu_rate = (self.cpu_total as f64 / elapsed) as i64;
|
||||
let gpu_rate = (self.gpu_total as f64 / elapsed) as i64;
|
||||
CPU_HASH_RATE.set(cpu_rate);
|
||||
GPU_HASH_RATE.set(gpu_rate);
|
||||
HASH_RATE.set(cpu_rate + gpu_rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Update Helpers (to be called from the service layer)
|
||||
// -------------------------------------------------------------------------------------
|
||||
static HASH_TRACKER: Lazy<Mutex<HashRateTracker>> =
|
||||
Lazy::new(|| Mutex::new(HashRateTracker::new()));
|
||||
|
||||
/// Increment the total hashes counter by `n` (number of nonces tested).
|
||||
// Labeled helpers for engine/job/thread metrics
|
||||
//
|
||||
// Service-level helpers
|
||||
pub fn set_active_jobs(n: i64) {
|
||||
ACTIVE_JOBS.set(n);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API - Hash Recording
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Reset the hash rate tracker - call this when mining starts.
|
||||
/// This resets the cumulative hash counts and starts a fresh timing window.
|
||||
pub fn reset_hash_tracker() {
|
||||
if let Ok(mut tracker) = HASH_TRACKER.lock() {
|
||||
tracker.reset();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_engine_backend(engine: &str, backend: &str) {
|
||||
ENGINE_BACKEND_INFO
|
||||
.with_label_values(&[engine, backend])
|
||||
.set(1.0);
|
||||
/// Record CPU hashes and update hash rate metrics.
|
||||
pub fn record_cpu_hashes(n: u64) {
|
||||
HASHES_TOTAL.inc_by(n);
|
||||
if let Ok(mut tracker) = HASH_TRACKER.lock() {
|
||||
tracker.record_cpu(n);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inc_mine_requests(result: &str) {
|
||||
MINE_REQUESTS_TOTAL.with_label_values(&[result]).inc();
|
||||
/// Record GPU hashes and update hash rate metrics.
|
||||
pub fn record_gpu_hashes(n: u64) {
|
||||
HASHES_TOTAL.inc_by(n);
|
||||
if let Ok(mut tracker) = HASH_TRACKER.lock() {
|
||||
tracker.record_gpu(n);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn inc_job_hashes(engine: &str, job_id: &str, n: u64) {
|
||||
touch_job(engine, job_id);
|
||||
JOB_HASHES_TOTAL
|
||||
.with_label_values(&[engine, job_id])
|
||||
.inc_by(n);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API - Hash Rates (for direct setting, kept for compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set the total hash rate (CPU + GPU combined).
|
||||
pub fn set_hash_rate(rate: i64) {
|
||||
HASH_RATE.set(rate);
|
||||
}
|
||||
|
||||
pub fn set_job_hash_rate(engine: &str, job_id: &str, rate: f64) {
|
||||
touch_job(engine, job_id);
|
||||
JOB_HASH_RATE.with_label_values(&[engine, job_id]).set(rate);
|
||||
/// Set the CPU-only hash rate.
|
||||
pub fn set_cpu_hash_rate(rate: i64) {
|
||||
CPU_HASH_RATE.set(rate);
|
||||
}
|
||||
|
||||
pub fn set_job_estimated_rate_backend(engine: &str, backend: &str, rate: f64) {
|
||||
JOB_ESTIMATED_RATE
|
||||
.with_label_values(&[engine, backend])
|
||||
.set(rate);
|
||||
/// Set the GPU-only hash rate.
|
||||
pub fn set_gpu_hash_rate(rate: i64) {
|
||||
GPU_HASH_RATE.set(rate);
|
||||
}
|
||||
|
||||
pub fn set_job_estimated_rate(engine: &str, _job_id: &str, rate: f64) {
|
||||
// Compatibility shim: map per-job series to backend="unknown" to avoid cardinality growth
|
||||
JOB_ESTIMATED_RATE
|
||||
.with_label_values(&[engine, "unknown"])
|
||||
.set(rate);
|
||||
}
|
||||
|
||||
pub fn inc_candidates_found(engine: &str, job_id: &str) {
|
||||
touch_job(engine, job_id);
|
||||
CANDIDATES_FOUND_TOTAL
|
||||
.with_label_values(&[engine, job_id])
|
||||
.inc();
|
||||
}
|
||||
|
||||
pub fn inc_candidates_false_positive(engine: &str) {
|
||||
CANDIDATES_FALSE_POSITIVE_TOTAL
|
||||
.with_label_values(&[engine])
|
||||
.inc();
|
||||
}
|
||||
|
||||
pub fn inc_sample_mismatch(engine: &str) {
|
||||
SAMPLE_MISMATCH_TOTAL.with_label_values(&[engine]).inc();
|
||||
}
|
||||
|
||||
pub fn inc_found_by_origin(engine: &str, origin: &str) {
|
||||
FOUND_BY_ORIGIN_TOTAL
|
||||
.with_label_values(&[engine, origin])
|
||||
.inc();
|
||||
}
|
||||
|
||||
pub fn inc_gpu_g2_missed_winner() {
|
||||
MISSED_WINNER_TOTAL.inc();
|
||||
}
|
||||
|
||||
pub fn set_job_found_origin(engine: &str, job_id: &str, origin: &str) {
|
||||
touch_job(engine, job_id);
|
||||
JOB_FOUND_ORIGIN
|
||||
.with_label_values(&[engine, job_id, origin])
|
||||
.set(1.0);
|
||||
}
|
||||
|
||||
pub fn job_estimated_rate_backend(engine: &str, backend: &str, rate: f64) {
|
||||
JOB_ESTIMATED_RATE
|
||||
.with_label_values(&[engine, backend])
|
||||
.set(rate);
|
||||
}
|
||||
|
||||
pub fn job_estimated_rate(engine: &str, _job_id: &str, rate: f64) {
|
||||
// Compatibility shim: prefer new API using backend; fallback to backend="unknown"
|
||||
JOB_ESTIMATED_RATE
|
||||
.with_label_values(&[engine, "unknown"])
|
||||
.set(rate);
|
||||
}
|
||||
|
||||
pub fn inc_thread_hashes(engine: &str, job_id: &str, thread_id: &str, n: u64) {
|
||||
THREAD_HASHES_TOTAL
|
||||
.with_label_values(&[engine, job_id, thread_id])
|
||||
.inc_by(n);
|
||||
}
|
||||
|
||||
pub fn set_thread_hash_rate(engine: &str, job_id: &str, thread_id: &str, rate: f64) {
|
||||
touch_thread(engine, job_id, thread_id);
|
||||
THREAD_HASH_RATE
|
||||
.with_label_values(&[engine, job_id, thread_id])
|
||||
.set(rate);
|
||||
}
|
||||
|
||||
pub fn inc_jobs_by_engine(engine: &str, status: &str) {
|
||||
JOBS_BY_ENGINE_TOTAL
|
||||
.with_label_values(&[engine, status])
|
||||
.inc();
|
||||
}
|
||||
|
||||
pub fn set_job_status_gauge(engine: &str, job_id: &str, status: &str, value: i64) {
|
||||
touch_job(engine, job_id);
|
||||
JOB_STATUS_GAUGE
|
||||
.with_label_values(&[engine, job_id, status])
|
||||
.set(value);
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API - Counters (kept for compatibility)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Increment the total hashes counter (without updating rates).
|
||||
/// Prefer `record_cpu_hashes` or `record_gpu_hashes` instead.
|
||||
pub fn inc_hashes(n: u64) {
|
||||
HASHES_TOTAL.inc_by(n);
|
||||
}
|
||||
|
||||
/// Set the current estimated hash rate (nonces per second).
|
||||
pub fn set_hash_rate(rate: f64) {
|
||||
HASH_RATE.set(rate);
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API - Gauges
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Set the number of active jobs (0 or 1).
|
||||
pub fn set_active_jobs(n: i64) {
|
||||
ACTIVE_JOBS.set(n);
|
||||
}
|
||||
|
||||
/// Increment the jobs counter for a terminal status: completed | failed | cancelled.
|
||||
pub fn inc_job_status(status: &str) {
|
||||
JOBS_TOTAL.with_label_values(&[status]).inc();
|
||||
/// Set the total number of workers.
|
||||
pub fn set_workers(n: i64) {
|
||||
WORKERS.set(n);
|
||||
}
|
||||
|
||||
/// Increment HTTP request counters for an endpoint with a status code.
|
||||
pub fn inc_http_request(endpoint: &str, code: u16) {
|
||||
HTTP_REQUESTS_TOTAL
|
||||
.with_label_values(&[endpoint, &code.to_string()])
|
||||
.inc();
|
||||
/// Set the number of CPU workers.
|
||||
pub fn set_cpu_workers(n: i64) {
|
||||
CPU_WORKERS.set(n);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// Removal helpers for end-of-life series
|
||||
// -------------------------------------------------------------------------------------
|
||||
|
||||
const METRICS_TTL_SECS: u64 = 300;
|
||||
const JANITOR_INTERVAL_SECS: u64 = 60;
|
||||
|
||||
type JobKey = (String, String); // (engine, job_id)
|
||||
type ThreadKey = (String, String, String); // (engine, job_id, thread_id)
|
||||
|
||||
static JOB_KEYS: Lazy<Mutex<HashSet<JobKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
|
||||
static JOB_LAST: Lazy<Mutex<HashMap<JobKey, Instant>>> = Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
static THREAD_KEYS: Lazy<Mutex<HashSet<ThreadKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
|
||||
static THREAD_LAST: Lazy<Mutex<HashMap<ThreadKey, Instant>>> =
|
||||
Lazy::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
static JANITOR_INIT: Lazy<()> = Lazy::new(|| {
|
||||
thread::spawn(|| loop {
|
||||
thread::sleep(Duration::from_secs(JANITOR_INTERVAL_SECS));
|
||||
prune_stale();
|
||||
});
|
||||
});
|
||||
|
||||
fn prune_stale() {
|
||||
let now = Instant::now();
|
||||
// Jobs
|
||||
let mut to_remove_jobs: Vec<JobKey> = Vec::new();
|
||||
{
|
||||
let last = JOB_LAST.lock().unwrap();
|
||||
for (k, ts) in last.iter() {
|
||||
if now.duration_since(*ts).as_secs() > METRICS_TTL_SECS {
|
||||
to_remove_jobs.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (engine, job_id) in to_remove_jobs {
|
||||
let _ = JOB_HASHES_TOTAL.remove_label_values(&[&engine, &job_id]);
|
||||
let _ = JOB_HASH_RATE.remove_label_values(&[&engine, &job_id]);
|
||||
let _ = CANDIDATES_FOUND_TOTAL.remove_label_values(&[&engine, &job_id]);
|
||||
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "completed"]);
|
||||
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "failed"]);
|
||||
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "cancelled"]);
|
||||
// Remove per-job origin gauge variants (known origins)
|
||||
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "cpu"]);
|
||||
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "gpu-g1"]);
|
||||
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "gpu-g2"]);
|
||||
JOB_KEYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(engine.clone(), job_id.clone()));
|
||||
JOB_LAST.lock().unwrap().remove(&(engine, job_id));
|
||||
}
|
||||
// Threads
|
||||
let mut to_remove_threads: Vec<ThreadKey> = Vec::new();
|
||||
{
|
||||
let last = THREAD_LAST.lock().unwrap();
|
||||
for (k, ts) in last.iter() {
|
||||
if now.duration_since(*ts).as_secs() > METRICS_TTL_SECS {
|
||||
to_remove_threads.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
for (engine, job_id, thread_id) in to_remove_threads {
|
||||
let _ = THREAD_HASHES_TOTAL.remove_label_values(&[&engine, &job_id, &thread_id]);
|
||||
let _ = THREAD_HASH_RATE.remove_label_values(&[&engine, &job_id, &thread_id]);
|
||||
THREAD_KEYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(engine.clone(), job_id.clone(), thread_id.clone()));
|
||||
THREAD_LAST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(engine, job_id, thread_id));
|
||||
}
|
||||
/// Set the number of GPU devices.
|
||||
pub fn set_gpu_devices(n: i64) {
|
||||
GPU_DEVICES.set(n);
|
||||
}
|
||||
|
||||
fn touch_job(engine: &str, job_id: &str) {
|
||||
*JANITOR_INIT; // ensure janitor starts
|
||||
let key = (engine.to_string(), job_id.to_string());
|
||||
JOB_KEYS.lock().unwrap().insert(key.clone());
|
||||
JOB_LAST.lock().unwrap().insert(key, Instant::now());
|
||||
/// Set the detected CPU core count.
|
||||
pub fn set_effective_cpus(n: i64) {
|
||||
EFFECTIVE_CPUS.set(n);
|
||||
}
|
||||
|
||||
fn touch_thread(engine: &str, job_id: &str, thread_id: &str) {
|
||||
*JANITOR_INIT; // ensure janitor starts
|
||||
let key = (
|
||||
engine.to_string(),
|
||||
job_id.to_string(),
|
||||
thread_id.to_string(),
|
||||
);
|
||||
THREAD_KEYS.lock().unwrap().insert(key.clone());
|
||||
THREAD_LAST.lock().unwrap().insert(key, Instant::now());
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP Exporter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Explicitly remove all job-scoped metrics for a job (call on job completion)
|
||||
pub fn remove_job_metrics(engine: &str, job_id: &str) {
|
||||
let _ = JOB_HASHES_TOTAL.remove_label_values(&[engine, job_id]);
|
||||
let _ = JOB_HASH_RATE.remove_label_values(&[engine, job_id]);
|
||||
let _ = CANDIDATES_FOUND_TOTAL.remove_label_values(&[engine, job_id]);
|
||||
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "completed"]);
|
||||
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "failed"]);
|
||||
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "cancelled"]);
|
||||
// Remove per-job origin gauge variants (known origins)
|
||||
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "cpu"]);
|
||||
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "gpu-g1"]);
|
||||
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "gpu-g2"]);
|
||||
JOB_KEYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(engine.to_string(), job_id.to_string()));
|
||||
JOB_LAST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(engine.to_string(), job_id.to_string()));
|
||||
}
|
||||
|
||||
/// Explicitly remove all thread-scoped metrics for a job
|
||||
pub fn remove_thread_metrics_for_job(engine: &str, job_id: &str) {
|
||||
// Collect matching thread keys
|
||||
let keys: Vec<ThreadKey> = {
|
||||
let set = THREAD_KEYS.lock().unwrap();
|
||||
set.iter()
|
||||
.filter(|(e, j, _)| e == engine && j == job_id)
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
for (e, j, t) in keys {
|
||||
let _ = THREAD_HASHES_TOTAL.remove_label_values(&[&e, &j, &t]);
|
||||
let _ = THREAD_HASH_RATE.remove_label_values(&[&e, &j, &t]);
|
||||
THREAD_KEYS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(e.clone(), j.clone(), t.clone()));
|
||||
THREAD_LAST
|
||||
.lock()
|
||||
.unwrap()
|
||||
.remove(&(e.clone(), j.clone(), t.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove the per-job hash rate series for a finished job.
|
||||
pub fn remove_job_hash_rate(engine: &str, job_id: &str) {
|
||||
let _ = JOB_HASH_RATE.remove_label_values(&[engine, job_id]);
|
||||
}
|
||||
|
||||
/// Remove the per-thread hash rate series for a finished thread.
|
||||
pub fn remove_thread_hash_rate(engine: &str, job_id: &str, thread_id: &str) {
|
||||
let _ = THREAD_HASH_RATE.remove_label_values(&[engine, job_id, thread_id]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------
|
||||
// HTTP Exporter (feature: http-exporter)
|
||||
// -------------------------------------------------------------------------------------
|
||||
|
||||
/// Start the Prometheus HTTP exporter on 0.0.0.0:`port`.
|
||||
/// Start the Prometheus HTTP exporter on `0.0.0.0:port`.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Spawns the exporter as a background task and returns immediately.
|
||||
/// - Serves plaintext metrics at GET /metrics.
|
||||
/// - If called multiple times, multiple servers may be created (call once).
|
||||
/// Spawns the exporter as a background task and returns immediately.
|
||||
/// Serves plaintext metrics at `GET /metrics`.
|
||||
#[cfg(feature = "http-exporter")]
|
||||
pub async fn start_http_exporter(port: u16) -> Result<()> {
|
||||
// Encoder is created inside the handler to avoid capturing non-Clone state
|
||||
// Use REGISTRY.gather() directly in the handler
|
||||
|
||||
// GET /metrics -> plaintext Prometheus format
|
||||
let metrics_route = warp::path("metrics").and(warp::get()).map(|| {
|
||||
let encoder = TextEncoder::new();
|
||||
let metric_families = REGISTRY.gather();
|
||||
let mut buffer = Vec::with_capacity(16 * 1024);
|
||||
let mut buffer = Vec::with_capacity(4096);
|
||||
encoder
|
||||
.encode(&metric_families, &mut buffer)
|
||||
.unwrap_or_default();
|
||||
|
||||
Response::builder()
|
||||
warp::http::Response::builder()
|
||||
.header("Content-Type", encoder.format_type())
|
||||
.body(String::from_utf8(buffer).unwrap_or_default())
|
||||
});
|
||||
@@ -683,7 +320,7 @@ pub async fn start_http_exporter(port: u16) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// If the exporter feature is not enabled, expose a no-op to keep call sites simple.
|
||||
/// No-op when HTTP exporter feature is disabled.
|
||||
#[cfg(not(feature = "http-exporter"))]
|
||||
pub async fn start_http_exporter(_port: u16) -> Result<()> {
|
||||
log::warn!(
|
||||
|
||||
@@ -5,16 +5,19 @@ edition.workspace = true
|
||||
publish = false
|
||||
description = "CLI binary to run the Quantus External Miner service"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
cuda = ["miner-service/cuda"]
|
||||
|
||||
[dependencies]
|
||||
miner-service = { path = "../miner-service" }
|
||||
metrics = { path = "../metrics", features = ["http-exporter"] }
|
||||
quic-transport = { path = "../quic-transport" }
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
env_logger = { workspace = true }
|
||||
log = { workspace = true }
|
||||
engine-cpu = { path = "../engine-cpu" }
|
||||
engine-gpu = { path = "../engine-gpu" }
|
||||
primitive-types = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
num_cpus = { workspace = true }
|
||||
|
||||
[[bin]]
|
||||
name = "quantus-miner"
|
||||
|
||||
+43
-126
@@ -1,134 +1,51 @@
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::io::{self, Read};
|
||||
use std::path::{Path, PathBuf};
|
||||
// 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() {
|
||||
// Always re-run if version env or git state might change
|
||||
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
|
||||
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
println!("cargo:rerun-if-changed=.git/packed-refs");
|
||||
println!("cargo:rerun-if-env-changed=MINER_BUILD_SHA");
|
||||
|
||||
let pkg_version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
|
||||
let short_sha = match env::var("GITHUB_SHA") {
|
||||
Ok(s) => s.get(0..8).map(|t| t.to_string()),
|
||||
Err(_) => discover_short_git_sha().ok(),
|
||||
};
|
||||
|
||||
let version = match short_sha {
|
||||
Some(sha) if !sha.is_empty() => format!("{pkg_version}+{sha}"),
|
||||
_ => pkg_version.clone(),
|
||||
};
|
||||
|
||||
// Expose to the crate as an env! binding
|
||||
println!("cargo:rustc-env=MINER_VERSION={version}");
|
||||
|
||||
// Optional extra info for diagnostics
|
||||
if let Ok(sha) = env::var("GITHUB_SHA") {
|
||||
println!("cargo:rustc-env=MINER_BUILD_SHA={}", sha);
|
||||
}
|
||||
|
||||
// Log the computed version for debugging
|
||||
println!("miner-cli: computed MINER_VERSION={version}");
|
||||
}
|
||||
|
||||
/// Attempts to determine the short git SHA (first 8 hex chars) without requiring the `git` binary.
|
||||
/// Handles both normal repositories and worktrees where `.git` is a file pointing to the real gitdir.
|
||||
fn discover_short_git_sha() -> io::Result<String> {
|
||||
let git_dir = find_git_dir()?;
|
||||
let head_path = git_dir.join("HEAD");
|
||||
let head = fs::read_to_string(&head_path)?.trim().to_string();
|
||||
|
||||
// HEAD can be either a direct SHA or a ref: refs/heads/branch
|
||||
if let Some(ref_line) = head.strip_prefix("ref: ") {
|
||||
let ref_path = git_dir.join(ref_line.trim());
|
||||
if ref_path.is_file() {
|
||||
let mut contents = String::new();
|
||||
fs::File::open(ref_path)?.read_to_string(&mut contents)?;
|
||||
let sha = contents.trim();
|
||||
return Ok(shorten_sha(sha));
|
||||
}
|
||||
// Not a loose ref; try packed-refs
|
||||
let packed = git_dir.join("packed-refs");
|
||||
if packed.is_file() {
|
||||
let contents = fs::read_to_string(packed)?;
|
||||
for line in contents.lines() {
|
||||
// Skip comments and peeled lines
|
||||
if line.starts_with('#') || line.starts_with('^') || line.trim().is_empty() {
|
||||
continue;
|
||||
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");
|
||||
}
|
||||
// Format: <sha> <ref>
|
||||
if let Some((sha, r)) = line.split_once(' ') {
|
||||
if r.trim() == ref_line.trim() {
|
||||
return Ok(shorten_sha(sha.trim()));
|
||||
}
|
||||
let dirty = git(&["status", "--porcelain", "--untracked-files=no"])
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
if dirty {
|
||||
format!("{head}-dirty")
|
||||
} else {
|
||||
head
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!(
|
||||
"Could not resolve ref '{}' in {:?}",
|
||||
ref_line.trim(),
|
||||
git_dir
|
||||
),
|
||||
))
|
||||
} else {
|
||||
// HEAD contained a SHA directly (e.g., detached HEAD)
|
||||
Ok(shorten_sha(head.trim()))
|
||||
}
|
||||
}
|
||||
None => "unknown".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
/// Finds the actual .git directory, handling both:
|
||||
/// - Standard repo: a ".git" directory exists, or
|
||||
/// - Worktree/submodule: ".git" is a file containing "gitdir: <path>"
|
||||
fn find_git_dir() -> io::Result<PathBuf> {
|
||||
let mut dir = env::current_dir()?;
|
||||
loop {
|
||||
let dot_git = dir.join(".git");
|
||||
if dot_git.is_dir() {
|
||||
return Ok(dot_git);
|
||||
} else if dot_git.is_file() {
|
||||
// Read the pointer file
|
||||
let contents = fs::read_to_string(&dot_git)?;
|
||||
if let Some(p) = contents.strip_prefix("gitdir:") {
|
||||
let p = p.trim();
|
||||
let path = Path::new(p);
|
||||
// If relative, it's relative to the location of the .git file's parent
|
||||
let resolved = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
dir.join(path)
|
||||
};
|
||||
if resolved.is_dir() {
|
||||
return Ok(resolved);
|
||||
} else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
format!("Resolved gitdir does not exist: {}", resolved.display()),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Ascend until root
|
||||
if !dir.pop() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"Could not locate .git directory",
|
||||
))
|
||||
}
|
||||
|
||||
fn shorten_sha(full: &str) -> String {
|
||||
let s = full.trim();
|
||||
if s.len() >= 8 {
|
||||
s[..8].to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
println!("cargo:rustc-env=MINER_BUILD_SHA={sha}");
|
||||
}
|
||||
|
||||
+503
-164
@@ -1,184 +1,523 @@
|
||||
use clap::{Parser, ValueEnum};
|
||||
use miner_service::{run, EngineSelection, ServiceConfig};
|
||||
use clap::{Parser, Subcommand};
|
||||
use engine_cpu::{AtomicBoolCancelCheck, EngineRange, MinerEngine};
|
||||
use miner_service::{run, ServiceConfig};
|
||||
use primitive_types::U512;
|
||||
use rand::RngCore;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
// CLI defaults
|
||||
const DEFAULT_GPU_BATCH_SIZE: u32 = 1_000_000;
|
||||
const DEFAULT_CPU_BATCH_SIZE: u64 = 10_000;
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
enum Command {
|
||||
/// Run the mining service
|
||||
Serve {
|
||||
/// Address of the node to connect to
|
||||
#[arg(long, env = "MINER_NODE_ADDR", default_value = "127.0.0.1:9833")]
|
||||
node_addr: std::net::SocketAddr,
|
||||
|
||||
/// Shared auth token from the node's `miner-auth-token` file
|
||||
/// (`<base-path>/chains/<chain>/miner-auth-token`). Prefer `--auth-token-file`.
|
||||
#[arg(long, env = "MINER_AUTH_TOKEN", conflicts_with = "auth_token_file")]
|
||||
auth_token: Option<String>,
|
||||
|
||||
/// Path to the node's `miner-auth-token` file (trimmed). Preferred over
|
||||
/// `--auth-token` so the secret is not placed on the command line.
|
||||
#[arg(long, env = "MINER_AUTH_TOKEN_FILE", conflicts_with = "auth_token")]
|
||||
auth_token_file: Option<PathBuf>,
|
||||
|
||||
/// SHA-256 fingerprint of the node's miner TLS certificate (64 hex chars).
|
||||
/// Prefer `--tls-cert-sha256-file` pointing at `miner-tls-cert-sha256`
|
||||
/// (the node also logs this fingerprint).
|
||||
#[arg(
|
||||
long,
|
||||
env = "MINER_TLS_CERT_SHA256",
|
||||
conflicts_with = "tls_cert_sha256_file"
|
||||
)]
|
||||
tls_cert_sha256: Option<String>,
|
||||
|
||||
/// Path to the node's `miner-tls-cert-sha256` file.
|
||||
#[arg(
|
||||
long,
|
||||
env = "MINER_TLS_CERT_SHA256_FILE",
|
||||
conflicts_with = "tls_cert_sha256"
|
||||
)]
|
||||
tls_cert_sha256_file: Option<PathBuf>,
|
||||
|
||||
/// Number of CPU worker threads to use for mining (default: auto-detect)
|
||||
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
|
||||
cpu_workers: Option<usize>,
|
||||
|
||||
/// Number of GPU devices to use for mining (default: auto-detect)
|
||||
#[arg(long = "gpu-devices", env = "MINER_GPU_DEVICES")]
|
||||
gpu_devices: Option<usize>,
|
||||
|
||||
/// GPU batch size in nonces - controls how often GPU checks for cancellation
|
||||
#[arg(long = "gpu-batch-size", env = "MINER_GPU_BATCH_SIZE", default_value_t = DEFAULT_GPU_BATCH_SIZE, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
gpu_batch_size: u32,
|
||||
|
||||
/// CPU batch size in hashes - controls how often CPU checks for cancellation
|
||||
#[arg(long = "cpu-batch-size", env = "MINER_CPU_BATCH_SIZE", default_value_t = DEFAULT_CPU_BATCH_SIZE, value_parser = clap::value_parser!(u64).range(1..))]
|
||||
cpu_batch_size: u64,
|
||||
|
||||
/// Port for Prometheus metrics HTTP endpoint (default: 9900)
|
||||
#[arg(
|
||||
long = "metrics-port",
|
||||
env = "MINER_METRICS_PORT",
|
||||
default_value_t = 9900
|
||||
)]
|
||||
metrics_port: u16,
|
||||
|
||||
/// GPU throttle delay in milliseconds between batches (0 = no throttle)
|
||||
#[arg(
|
||||
long = "gpu-throttle-ms",
|
||||
env = "MINER_GPU_THROTTLE_MS",
|
||||
default_value_t = 0
|
||||
)]
|
||||
gpu_throttle_ms: u64,
|
||||
|
||||
/// Allow integrated GPUs (APUs) even when discrete GPUs are available.
|
||||
/// By default, integrated GPUs are skipped when a discrete GPU is present
|
||||
/// to avoid resource contention and driver instability.
|
||||
#[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,
|
||||
},
|
||||
|
||||
/// Run a quick benchmark of the mining engines
|
||||
Benchmark {
|
||||
/// Number of CPU workers to use for benchmark
|
||||
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
|
||||
cpu_workers: Option<usize>,
|
||||
|
||||
/// Number of GPU devices to use for benchmark
|
||||
#[arg(long = "gpu-devices", env = "MINER_GPU_DEVICES")]
|
||||
gpu_devices: Option<usize>,
|
||||
|
||||
/// GPU batch size in nonces - controls how often GPU checks for cancellation
|
||||
#[arg(long = "gpu-batch-size", env = "MINER_GPU_BATCH_SIZE", default_value_t = DEFAULT_GPU_BATCH_SIZE, value_parser = clap::value_parser!(u32).range(1..))]
|
||||
gpu_batch_size: u32,
|
||||
|
||||
/// CPU batch size in hashes - controls how often CPU checks for cancellation
|
||||
#[arg(long = "cpu-batch-size", env = "MINER_CPU_BATCH_SIZE", default_value_t = DEFAULT_CPU_BATCH_SIZE, value_parser = clap::value_parser!(u64).range(1..))]
|
||||
cpu_batch_size: u64,
|
||||
|
||||
/// Benchmark duration in seconds (default: 10)
|
||||
#[arg(short, long, default_value_t = 10)]
|
||||
duration: u64,
|
||||
|
||||
/// Allow integrated GPUs (APUs) even when discrete GPUs are available
|
||||
#[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 = option_env!("MINER_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")), about, long_about = None)]
|
||||
#[command(author, version = VERSION, about, long_about = None)]
|
||||
struct Args {
|
||||
/// Port number to listen on for the miner HTTP API
|
||||
#[arg(short, long, env = "MINER_PORT", default_value_t = 9833)]
|
||||
port: u16,
|
||||
|
||||
/// Number of worker threads (logical CPUs) to use for mining (defaults to all available)
|
||||
#[arg(long = "workers", env = "MINER_WORKERS")]
|
||||
workers: Option<usize>,
|
||||
|
||||
/// Optional Prometheus metrics exporter port; if omitted, metrics are disabled
|
||||
#[arg(long, env = "MINER_METRICS_PORT")]
|
||||
metrics_port: Option<u16>,
|
||||
|
||||
/// Target milliseconds for per-thread progress updates (chunking).
|
||||
/// Smaller values increase metrics freshness but add a bit of overhead.
|
||||
#[arg(long = "progress-chunk-ms", env = "MINER_PROGRESS_CHUNK_MS")]
|
||||
progress_chunk_ms: Option<u64>,
|
||||
|
||||
/// For cpu-chain-manipulator: start throttle index at this many solved blocks
|
||||
/// to "pick up where we left off" after restarts.
|
||||
#[arg(long = "manip-solved-blocks", env = "MINER_MANIP_SOLVED_BLOCKS")]
|
||||
manip_solved_blocks: Option<u64>,
|
||||
|
||||
/// For cpu-chain-manipulator: base sleep per batch in nanoseconds (default 500_000 ns)
|
||||
#[arg(long = "manip-base-delay-ns", env = "MINER_MANIP_BASE_DELAY_NS")]
|
||||
manip_base_delay_ns: Option<u64>,
|
||||
|
||||
/// For cpu-chain-manipulator: number of nonce attempts between sleeps (default 10_000)
|
||||
#[arg(long = "manip-step-batch", env = "MINER_MANIP_STEP_BATCH")]
|
||||
manip_step_batch: Option<u64>,
|
||||
|
||||
/// For cpu-chain-manipulator: optional cap on solved-blocks throttle index
|
||||
#[arg(long = "manip-throttle-cap", env = "MINER_MANIP_THROTTLE_CAP")]
|
||||
manip_throttle_cap: Option<u64>,
|
||||
|
||||
/// Mining engine to use (default: cpu-fast).
|
||||
/// Options: cpu-baseline, cpu-fast, cpu-chain-manipulator, gpu-cuda, gpu-opencl
|
||||
/// Note: GPU engines are currently unimplemented and will return a clear error at runtime.
|
||||
#[arg(long, env = "MINER_ENGINE", value_enum, default_value_t = EngineCli::CpuFast)]
|
||||
engine: EngineCli,
|
||||
|
||||
/// Telemetry endpoints (repeat --telemetry-endpoint or comma-separated)
|
||||
#[arg(long = "telemetry-endpoint", env = "MINER_TELEMETRY_ENDPOINTS", value_delimiter = ',', num_args = 0.., value_name = "URL")]
|
||||
telemetry_endpoints: Option<Vec<String>>,
|
||||
|
||||
/// Enable or disable telemetry explicitly
|
||||
#[arg(long = "telemetry-enabled", env = "MINER_TELEMETRY_ENABLED")]
|
||||
telemetry_enabled: Option<bool>,
|
||||
|
||||
/// Telemetry verbosity level (0..=4 typical)
|
||||
#[arg(long = "telemetry-verbosity", env = "MINER_TELEMETRY_VERBOSITY")]
|
||||
telemetry_verbosity: Option<u8>,
|
||||
|
||||
/// Interval seconds for system.interval messages
|
||||
#[arg(
|
||||
long = "telemetry-interval-secs",
|
||||
env = "MINER_TELEMETRY_INTERVAL_SECS"
|
||||
)]
|
||||
telemetry_interval_secs: Option<u64>,
|
||||
|
||||
/// Default association: chain name
|
||||
#[arg(long = "telemetry-chain", env = "MINER_TELEMETRY_CHAIN")]
|
||||
telemetry_chain: Option<String>,
|
||||
|
||||
/// Default association: genesis hash (hex)
|
||||
#[arg(long = "telemetry-genesis", env = "MINER_TELEMETRY_GENESIS")]
|
||||
telemetry_genesis: Option<String>,
|
||||
|
||||
/// Default association: node telemetry id
|
||||
#[arg(long = "telemetry-node-id", env = "MINER_TELEMETRY_NODE_ID")]
|
||||
telemetry_node_id: Option<String>,
|
||||
|
||||
/// Default association: node libp2p peer id
|
||||
#[arg(long = "telemetry-node-peer-id", env = "MINER_TELEMETRY_NODE_PEER_ID")]
|
||||
telemetry_node_peer_id: Option<String>,
|
||||
|
||||
/// Default association: node name
|
||||
#[arg(long = "telemetry-node-name", env = "MINER_TELEMETRY_NODE_NAME")]
|
||||
telemetry_node_name: Option<String>,
|
||||
|
||||
/// Default association: node version
|
||||
#[arg(long = "telemetry-node-version", env = "MINER_TELEMETRY_NODE_VERSION")]
|
||||
telemetry_node_version: Option<String>,
|
||||
}
|
||||
|
||||
#[allow(clippy::enum_variant_names)]
|
||||
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
|
||||
enum EngineCli {
|
||||
/// Baseline CPU engine (reference implementation)
|
||||
CpuBaseline,
|
||||
/// Optimized CPU engine (incremental precompute + step_mul)
|
||||
CpuFast,
|
||||
/// Throttling CPU engine that slows per block to help reduce difficulty
|
||||
CpuChainManipulator,
|
||||
/// CUDA GPU engine (unimplemented; selecting will return an error)
|
||||
GpuCuda,
|
||||
/// OpenCL GPU engine (unimplemented; selecting will return an error)
|
||||
GpuOpencl,
|
||||
}
|
||||
|
||||
impl From<EngineCli> for EngineSelection {
|
||||
fn from(value: EngineCli) -> Self {
|
||||
match value {
|
||||
EngineCli::CpuBaseline => EngineSelection::CpuBaseline,
|
||||
EngineCli::CpuFast => EngineSelection::CpuFast,
|
||||
EngineCli::CpuChainManipulator => EngineSelection::CpuChainManipulator,
|
||||
EngineCli::GpuCuda => EngineSelection::GpuCuda,
|
||||
EngineCli::GpuOpencl => EngineSelection::GpuOpenCl,
|
||||
}
|
||||
}
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
}
|
||||
|
||||
#[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"));
|
||||
|
||||
// Initialize logger early to capture startup messages.
|
||||
// If RUST_LOG is not set, default to info level for our app.
|
||||
if std::env::var("RUST_LOG").is_err() {
|
||||
std::env::set_var("RUST_LOG", "info");
|
||||
}
|
||||
env_logger::init();
|
||||
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).");
|
||||
eprintln!(
|
||||
"Example: quantus-miner serve --node-addr 127.0.0.1:9833 \
|
||||
--auth-token-file /path/to/miner-auth-token \
|
||||
--tls-cert-sha256-file /path/to/miner-tls-cert-sha256"
|
||||
);
|
||||
std::process::exit(1);
|
||||
};
|
||||
|
||||
// Telemetry CLI passthrough to env for miner-service bootstrap
|
||||
if let Some(eps) = args.telemetry_endpoints.as_ref() {
|
||||
if !eps.is_empty() {
|
||||
std::env::set_var("MINER_TELEMETRY_ENDPOINTS", eps.join(","));
|
||||
match command {
|
||||
Command::Serve {
|
||||
node_addr,
|
||||
auth_token,
|
||||
auth_token_file,
|
||||
tls_cert_sha256,
|
||||
tls_cert_sha256_file,
|
||||
cpu_workers,
|
||||
gpu_devices,
|
||||
gpu_batch_size,
|
||||
cpu_batch_size,
|
||||
gpu_throttle_ms,
|
||||
metrics_port,
|
||||
allow_integrated,
|
||||
gpu_engine,
|
||||
verbose,
|
||||
} => {
|
||||
init_logger(verbose);
|
||||
|
||||
let auth_token = match resolve_auth_token(auth_token, auth_token_file) {
|
||||
Ok(token) => token,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
let tls_cert_sha256 =
|
||||
match resolve_tls_cert_sha256(tls_cert_sha256, tls_cert_sha256_file) {
|
||||
Ok(fp) => fp,
|
||||
Err(e) => {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
// Fail closed on permanent misconfig before metrics/workers start.
|
||||
if let Err(e) = quic_transport::validate_auth_config(&auth_token, &tls_cert_sha256) {
|
||||
eprintln!("Error: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
log::info!("Starting external miner service...");
|
||||
|
||||
// Start metrics HTTP server
|
||||
if let Err(e) = metrics::start_http_exporter(metrics_port).await {
|
||||
log::error!("Failed to start metrics exporter: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
log::info!(
|
||||
"Metrics available at http://0.0.0.0:{}/metrics",
|
||||
metrics_port
|
||||
);
|
||||
|
||||
let config = ServiceConfig {
|
||||
node_addr,
|
||||
auth_token,
|
||||
tls_cert_sha256,
|
||||
cpu_workers,
|
||||
gpu_devices,
|
||||
gpu_batch_size,
|
||||
cpu_batch_size,
|
||||
gpu_throttle_ms,
|
||||
allow_integrated,
|
||||
gpu_engine,
|
||||
};
|
||||
|
||||
if let Err(e) = run(config).await {
|
||||
log::error!("Miner service terminated with error: {e:?}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Command::Benchmark {
|
||||
cpu_workers,
|
||||
gpu_devices,
|
||||
gpu_batch_size,
|
||||
cpu_batch_size,
|
||||
duration,
|
||||
allow_integrated,
|
||||
gpu_engine,
|
||||
verbose,
|
||||
} => {
|
||||
init_logger(verbose);
|
||||
run_benchmark(
|
||||
cpu_workers,
|
||||
gpu_devices,
|
||||
gpu_batch_size,
|
||||
cpu_batch_size,
|
||||
duration,
|
||||
allow_integrated,
|
||||
gpu_engine,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
if let Some(v) = args.telemetry_enabled {
|
||||
std::env::set_var("MINER_TELEMETRY_ENABLED", if v { "1" } else { "0" });
|
||||
}
|
||||
if let Some(v) = args.telemetry_verbosity {
|
||||
std::env::set_var("MINER_TELEMETRY_VERBOSITY", v.to_string());
|
||||
}
|
||||
if let Some(v) = args.telemetry_interval_secs {
|
||||
std::env::set_var("MINER_TELEMETRY_INTERVAL_SECS", v.to_string());
|
||||
}
|
||||
if let Some(v) = args.telemetry_chain.as_ref() {
|
||||
std::env::set_var("MINER_TELEMETRY_CHAIN", v);
|
||||
}
|
||||
if let Some(v) = args.telemetry_genesis.as_ref() {
|
||||
std::env::set_var("MINER_TELEMETRY_GENESIS", v);
|
||||
}
|
||||
if let Some(v) = args.telemetry_node_id.as_ref() {
|
||||
std::env::set_var("MINER_TELEMETRY_NODE_ID", v);
|
||||
}
|
||||
if let Some(v) = args.telemetry_node_peer_id.as_ref() {
|
||||
std::env::set_var("MINER_TELEMETRY_NODE_PEER_ID", v);
|
||||
}
|
||||
if let Some(v) = args.telemetry_node_name.as_ref() {
|
||||
std::env::set_var("MINER_TELEMETRY_NODE_NAME", v);
|
||||
}
|
||||
if let Some(v) = args.telemetry_node_version.as_ref() {
|
||||
std::env::set_var("MINER_TELEMETRY_NODE_VERSION", v);
|
||||
}
|
||||
}
|
||||
|
||||
// Log effective configuration (concise; see ServiceConfig Display)
|
||||
log::info!("Starting external miner service...");
|
||||
fn resolve_auth_token(
|
||||
auth_token: Option<String>,
|
||||
auth_token_file: Option<PathBuf>,
|
||||
) -> Result<String, String> {
|
||||
resolve_required_secret(
|
||||
auth_token,
|
||||
auth_token_file,
|
||||
"--auth-token",
|
||||
"--auth-token-file",
|
||||
"miner auth token required: pass --auth-token-file <PATH> to the node's \
|
||||
miner-auth-token file (or --auth-token <TOKEN>)",
|
||||
)
|
||||
}
|
||||
|
||||
let config = ServiceConfig {
|
||||
port: args.port,
|
||||
workers: args.workers,
|
||||
metrics_port: args.metrics_port,
|
||||
progress_chunk_ms: args.progress_chunk_ms,
|
||||
manip_solved_blocks: args.manip_solved_blocks,
|
||||
manip_base_delay_ns: args.manip_base_delay_ns,
|
||||
manip_step_batch: args.manip_step_batch,
|
||||
manip_throttle_cap: args.manip_throttle_cap,
|
||||
engine: args.engine.into(),
|
||||
fn resolve_tls_cert_sha256(value: Option<String>, file: Option<PathBuf>) -> Result<String, String> {
|
||||
resolve_required_secret(
|
||||
value,
|
||||
file,
|
||||
"--tls-cert-sha256",
|
||||
"--tls-cert-sha256-file",
|
||||
"TLS cert fingerprint required: pass --tls-cert-sha256-file <PATH> to the node's \
|
||||
miner-tls-cert-sha256 file (or --tls-cert-sha256 <HEX>; also printed in node logs)",
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_required_secret(
|
||||
value: Option<String>,
|
||||
file: Option<PathBuf>,
|
||||
value_flag: &str,
|
||||
file_flag: &str,
|
||||
missing_msg: &str,
|
||||
) -> Result<String, String> {
|
||||
if let Some(value) = value {
|
||||
let value = value.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Err(format!("{value_flag} is empty"));
|
||||
}
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(path) = file {
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.map_err(|e| format!("failed to read {file_flag} {}: {}", path.display(), e))?;
|
||||
let value = contents.trim().to_string();
|
||||
if value.is_empty() {
|
||||
return Err(format!("{file_flag} {} is empty", path.display()));
|
||||
}
|
||||
return Ok(value);
|
||||
}
|
||||
Err(missing_msg.into())
|
||||
}
|
||||
|
||||
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,cuda_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
|
||||
} else {
|
||||
"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);
|
||||
}
|
||||
env_logger::init();
|
||||
}
|
||||
|
||||
async fn run_benchmark(
|
||||
cpu_workers: Option<usize>,
|
||||
gpu_devices: Option<usize>,
|
||||
gpu_batch_size: u32,
|
||||
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);
|
||||
|
||||
// Initialize GPU engine (no throttle for benchmark)
|
||||
let (gpu_engine, effective_gpu_devices) = match miner_service::resolve_gpu_configuration(
|
||||
gpu_devices,
|
||||
gpu_batch_size,
|
||||
0,
|
||||
allow_integrated,
|
||||
&gpu_engine_pref,
|
||||
) {
|
||||
Ok((engine, count)) => (engine, count),
|
||||
Err(e) => {
|
||||
eprintln!("❌ ERROR: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
log::info!("Effective config: {config}");
|
||||
|
||||
if let Err(e) = run(config).await {
|
||||
log::error!("Miner service terminated with error: {e:?}");
|
||||
let total_workers = effective_cpu_workers + effective_gpu_devices;
|
||||
|
||||
println!("🚀 Quantus Miner Benchmark");
|
||||
println!("==========================");
|
||||
println!(
|
||||
"CPU Workers: {} (Available: {})",
|
||||
effective_cpu_workers,
|
||||
num_cpus::get()
|
||||
);
|
||||
println!("GPU Devices: {}", effective_gpu_devices);
|
||||
if effective_cpu_workers > 0 {
|
||||
println!("CPU batch size: {cpu_batch_size} hashes");
|
||||
}
|
||||
if effective_gpu_devices > 0 {
|
||||
println!("GPU batch size: {gpu_batch_size} nonces");
|
||||
}
|
||||
println!("Duration: {duration} seconds");
|
||||
println!();
|
||||
|
||||
if total_workers == 0 {
|
||||
eprintln!("Error: No workers specified");
|
||||
std::process::exit(1);
|
||||
}
|
||||
|
||||
// Create CPU engine
|
||||
let cpu_engine: Option<Arc<dyn MinerEngine>> = if effective_cpu_workers > 0 {
|
||||
Some(Arc::new(engine_cpu::FastCpuEngine::new(cpu_batch_size)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let cancel_flag = Arc::new(AtomicBool::new(false));
|
||||
let benchmark_start = Instant::now();
|
||||
|
||||
// Random header hash for benchmark
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
let difficulty = U512::MAX; // High difficulty - no solutions expected
|
||||
|
||||
let ref_engine = cpu_engine.as_ref().or(gpu_engine.as_ref()).unwrap();
|
||||
let ctx = ref_engine.prepare_context(header, difficulty);
|
||||
|
||||
println!("⛏️ Starting benchmark...");
|
||||
|
||||
// Spawn worker threads
|
||||
let mut handles = Vec::new();
|
||||
let total_hashes = Arc::new(std::sync::Mutex::new(0u64));
|
||||
|
||||
// Floor range widths at the old constants: engines still batch at the flag's
|
||||
// size internally, but tiny flags don't turn per-call harness overhead into
|
||||
// the measured quantity.
|
||||
let cpu_chunk = cpu_batch_size.max(10_000);
|
||||
let gpu_chunk = (gpu_batch_size as u64).max(1_000_000);
|
||||
|
||||
for worker_id in 0..total_workers {
|
||||
let (engine, nonces_per_batch) = if worker_id < effective_cpu_workers {
|
||||
(cpu_engine.as_ref().unwrap().clone(), cpu_chunk)
|
||||
} else {
|
||||
(gpu_engine.as_ref().unwrap().clone(), gpu_chunk)
|
||||
};
|
||||
|
||||
let ctx = ctx.clone();
|
||||
let cancel = cancel_flag.clone();
|
||||
let hashes = total_hashes.clone();
|
||||
let start = benchmark_start;
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let stride = U512::from(1_000_000_000_000u64);
|
||||
let mut current = U512::from(worker_id as u64).saturating_mul(stride);
|
||||
let step = U512::from(nonces_per_batch);
|
||||
|
||||
loop {
|
||||
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
|
||||
let worker_range = EngineRange {
|
||||
start: current,
|
||||
end: current
|
||||
.saturating_add(step)
|
||||
.saturating_sub(U512::from(1u64)),
|
||||
};
|
||||
|
||||
let cancel_check = AtomicBoolCancelCheck(&cancel);
|
||||
let result = engine.search_range(&ctx, worker_range, &cancel_check);
|
||||
|
||||
match result {
|
||||
engine_cpu::EngineStatus::Found { hash_count, .. }
|
||||
| engine_cpu::EngineStatus::Exhausted { hash_count }
|
||||
| engine_cpu::EngineStatus::Cancelled { hash_count }
|
||||
| engine_cpu::EngineStatus::DeviceLost { hash_count } => {
|
||||
*hashes.lock().unwrap() += hash_count;
|
||||
}
|
||||
engine_cpu::EngineStatus::Running { .. } => {}
|
||||
}
|
||||
|
||||
if matches!(result, engine_cpu::EngineStatus::DeviceLost { .. }) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = current.saturating_add(step);
|
||||
|
||||
if start.elapsed() >= Duration::from_secs(duration) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
engine_gpu::GpuEngine::clear_worker_resources();
|
||||
});
|
||||
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Progress updates
|
||||
let mut last_update = Instant::now();
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
|
||||
if benchmark_start.elapsed() >= Duration::from_secs(duration) {
|
||||
cancel_flag.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
|
||||
if last_update.elapsed() >= Duration::from_secs(1) {
|
||||
let current = *total_hashes.lock().unwrap();
|
||||
let elapsed = benchmark_start.elapsed().as_secs_f64();
|
||||
if current > 0 {
|
||||
let rate = current as f64 / elapsed;
|
||||
println!("⏱️ {:.1}s - {} H/s", elapsed, format_hash_rate(rate));
|
||||
}
|
||||
last_update = Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for threads
|
||||
for handle in handles {
|
||||
let _ = handle.join();
|
||||
}
|
||||
|
||||
let total_elapsed = benchmark_start.elapsed();
|
||||
let final_hashes = *total_hashes.lock().unwrap();
|
||||
let avg_rate = final_hashes as f64 / total_elapsed.as_secs_f64();
|
||||
|
||||
println!();
|
||||
println!("📊 Benchmark Results");
|
||||
println!("===================");
|
||||
println!("Total time: {:.2}s", total_elapsed.as_secs_f64());
|
||||
println!("Total hashes: {}", final_hashes);
|
||||
println!("Average rate: {} H/s", format_hash_rate(avg_rate));
|
||||
|
||||
if total_workers > 1 {
|
||||
let per_worker = avg_rate / total_workers as f64;
|
||||
println!("Per-worker: {} H/s", format_hash_rate(per_worker));
|
||||
}
|
||||
|
||||
println!("✅ Benchmark completed!");
|
||||
}
|
||||
|
||||
fn format_hash_rate(rate: f64) -> String {
|
||||
if rate >= 1_000_000.0 {
|
||||
format!("{:.2}M", rate / 1_000_000.0)
|
||||
} else if rate >= 1_000.0 {
|
||||
format!("{:.2}K", rate / 1_000.0)
|
||||
} else {
|
||||
format!("{:.0}", rate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,43 +3,33 @@ name = "miner-service"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "Service layer: HTTP API, job orchestration, and engine abstraction for the Quantus External Miner"
|
||||
description = "QUIC-based mining service for the Quantus External Miner"
|
||||
|
||||
[features]
|
||||
default = ["cpu", "metrics"]
|
||||
# Enable CPU engine by default.
|
||||
default = ["cpu"]
|
||||
cpu = ["engine-cpu"]
|
||||
# Optional metrics/observability (Prometheus endpoint).
|
||||
metrics = [
|
||||
"dep:metrics",
|
||||
"engine-gpu-cuda?/metrics",
|
||||
"metrics/http-exporter",
|
||||
]
|
||||
# Optional GPU backends (off by default).
|
||||
cuda = ["dep:engine-gpu-cuda", "engine-gpu-cuda/cuda"]
|
||||
opencl = ["engine-gpu-opencl"]
|
||||
|
||||
[dependencies]
|
||||
# Workspace-shared deps
|
||||
warp = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
primitive-types = { workspace = true }
|
||||
log = { workspace = true }
|
||||
crossbeam-channel = { workspace = true }
|
||||
num_cpus = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
# Protocol types from the node repo (keep API compatibility)
|
||||
# QUIC transport
|
||||
quinn = "0.10"
|
||||
quic-transport = { path = "../quic-transport" }
|
||||
getrandom = "0.2"
|
||||
|
||||
# Protocol types from the node repo
|
||||
quantus-miner-api = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
pow-core = { path = "../pow-core" }
|
||||
engine-cpu = { path = "../engine-cpu", optional = true }
|
||||
engine-gpu-cuda = { path = "../engine-gpu-cuda", optional = true, features = ["cuda"] }
|
||||
engine-gpu-opencl = { path = "../engine-gpu-opencl", optional = true }
|
||||
metrics = { path = "../metrics", optional = true }
|
||||
miner-telemetry = { path = "../miner-telemetry" }
|
||||
engine-gpu = { path = "../engine-gpu" }
|
||||
engine-cuda = { path = "../engine-cuda" } # lair: quantus/miner#3
|
||||
metrics = { path = "../metrics" }
|
||||
|
||||
+531
-1624
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,360 @@
|
||||
//! QUIC client for connecting to blockchain nodes.
|
||||
//!
|
||||
//! This module provides a QUIC client that connects to a blockchain node
|
||||
//! and handles bidirectional streaming for receiving mining jobs and
|
||||
//! sending results.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use engine_cpu::MinerEngine;
|
||||
use primitive_types::U512;
|
||||
|
||||
use quantus_miner_api::{
|
||||
read_message, write_message, ApiResponseStatus, MinerMessage, MiningResult,
|
||||
};
|
||||
|
||||
use crate::{EngineType, WorkerPool};
|
||||
use pow_core::format_hashrate;
|
||||
|
||||
/// Connect to a node and start mining.
|
||||
///
|
||||
/// This function connects to the node, receives mining jobs, and sends results.
|
||||
/// It automatically reconnects if the connection is lost.
|
||||
///
|
||||
/// Uses a persistent worker pool to avoid thread creation overhead between jobs.
|
||||
pub async fn connect_and_mine(
|
||||
node_addr: SocketAddr,
|
||||
auth_token: &str,
|
||||
tls_cert_sha256: &str,
|
||||
cpu_engine: Option<Arc<dyn MinerEngine>>,
|
||||
gpu_engine: Option<Arc<dyn MinerEngine>>,
|
||||
cpu_workers: usize,
|
||||
gpu_devices: usize,
|
||||
) -> anyhow::Result<()> {
|
||||
// Create persistent worker pool once - it lives for the entire miner lifetime
|
||||
let worker_pool = WorkerPool::new(cpu_engine, gpu_engine, cpu_workers, gpu_devices);
|
||||
|
||||
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);
|
||||
|
||||
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) =
|
||||
handle_connection(connection, send, recv, &worker_pool, &mut authenticated)
|
||||
.await
|
||||
{
|
||||
// Cancel any running job when connection drops
|
||||
worker_pool.cancel();
|
||||
if e.downcast_ref::<quic_transport::PermanentConnectError>()
|
||||
.is_some()
|
||||
{
|
||||
log::error!("⛏️ Permanent connection error (not retrying): {e}");
|
||||
return Err(e);
|
||||
}
|
||||
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.
|
||||
if authenticated {
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if e.downcast_ref::<quic_transport::PermanentConnectError>()
|
||||
.is_some()
|
||||
{
|
||||
log::error!("⛏️ Permanent connection error (not retrying): {e}");
|
||||
return Err(e);
|
||||
}
|
||||
log::warn!("⛏️ Failed to connect to node: {}", e);
|
||||
metrics::record_connect_failed(); // lair
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("⛏️ Reconnecting in {:?}...", reconnect_delay);
|
||||
tokio::time::sleep(reconnect_delay).await;
|
||||
reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a QUIC connection to the node (shared transport crate).
|
||||
async fn establish_connection(
|
||||
addr: SocketAddr,
|
||||
auth_token: &str,
|
||||
tls_cert_sha256: &str,
|
||||
) -> anyhow::Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream)> {
|
||||
let result = quic_transport::connect(addr, auth_token, tls_cert_sha256).await?;
|
||||
log::info!(
|
||||
"⛏️ QUIC connection and bidirectional stream established to {}",
|
||||
addr
|
||||
);
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Helper to send a message while monitoring connection health.
|
||||
async fn send_message_checked(
|
||||
connection: &quinn::Connection,
|
||||
send: &mut quinn::SendStream,
|
||||
msg: &MinerMessage,
|
||||
) -> anyhow::Result<()> {
|
||||
tokio::select! {
|
||||
biased;
|
||||
reason = connection.closed() => {
|
||||
Err(anyhow::anyhow!("Connection closed: {}", reason))
|
||||
}
|
||||
result = write_message(send, msg) => {
|
||||
result.map_err(|e| anyhow::anyhow!("Failed to send message: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle an established connection, receiving jobs and sending results.
|
||||
///
|
||||
/// Sets `authenticated` when the first `NewJob` arrives (proof the node
|
||||
/// accepted our Ready token).
|
||||
async fn handle_connection(
|
||||
connection: quinn::Connection,
|
||||
mut send: quinn::SendStream,
|
||||
mut recv: quinn::RecvStream,
|
||||
worker_pool: &WorkerPool,
|
||||
authenticated: &mut bool,
|
||||
) -> anyhow::Result<()> {
|
||||
use crossbeam_channel::RecvTimeoutError;
|
||||
|
||||
// Set static metrics once per connection
|
||||
metrics::set_effective_cpus(num_cpus::get() as i64);
|
||||
metrics::set_workers(worker_pool.worker_count() as i64);
|
||||
metrics::set_cpu_workers(worker_pool.cpu_worker_count() as i64);
|
||||
metrics::set_gpu_devices(worker_pool.gpu_worker_count() as i64);
|
||||
metrics::reset_hash_tracker();
|
||||
|
||||
// Current job state
|
||||
// - node_job_id: The string ID from the node (e.g., "27") - used in protocol messages
|
||||
// - internal_job_id: Our internal numeric ID from WorkerPool - used to detect stale results
|
||||
let mut node_job_id: Option<String> = None;
|
||||
let mut internal_job_id: u64 = 0;
|
||||
let mut job_start_time: Option<Instant> = None;
|
||||
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...");
|
||||
|
||||
loop {
|
||||
// Poll for worker results (non-blocking via spawn_blocking)
|
||||
let poll_result = if node_job_id.is_some() && !result_sent_for_current_job {
|
||||
let rx = worker_pool.result_receiver().clone();
|
||||
tokio::task::spawn_blocking(move || rx.recv_timeout(Duration::from_millis(10)))
|
||||
.await
|
||||
.unwrap_or(Err(RecvTimeoutError::Disconnected))
|
||||
} else {
|
||||
Err(RecvTimeoutError::Timeout)
|
||||
};
|
||||
|
||||
// Handle worker result if any
|
||||
if let Ok(worker_result) = poll_result {
|
||||
// Track hashes by engine type (always, even for stale results)
|
||||
match worker_result.engine_type {
|
||||
EngineType::Cpu => {
|
||||
cpu_hashes += worker_result.hash_count;
|
||||
metrics::record_cpu_hashes(worker_result.hash_count);
|
||||
}
|
||||
EngineType::Gpu => {
|
||||
gpu_hashes += worker_result.hash_count;
|
||||
metrics::record_gpu_hashes(worker_result.hash_count);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this result is for the current job (not stale)
|
||||
if worker_result.job_id != internal_job_id {
|
||||
log::debug!(
|
||||
"⏰ Discarding stale result from worker {} (result job_id {} != current {})",
|
||||
worker_result.thread_id,
|
||||
worker_result.job_id,
|
||||
internal_job_id
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only send result for the FIRST solution found for THIS job
|
||||
if let Some(candidate) = worker_result.candidate {
|
||||
if !result_sent_for_current_job {
|
||||
if let Some(ref job_id) = node_job_id {
|
||||
let total_hashes = cpu_hashes + gpu_hashes;
|
||||
let elapsed = job_start_time
|
||||
.map(|t| t.elapsed().as_secs_f64())
|
||||
.unwrap_or(0.0);
|
||||
|
||||
log::info!(
|
||||
"⛏️ Job {job_id} completed: {total_hashes} hashes in {elapsed:.2}s ({})",
|
||||
format_hashrate(total_hashes as f64 / elapsed.max(0.001))
|
||||
);
|
||||
|
||||
// Mark as sent BEFORE sending to prevent duplicates
|
||||
result_sent_for_current_job = true;
|
||||
worker_pool.cancel();
|
||||
metrics::set_active_jobs(0);
|
||||
|
||||
let result = MiningResult {
|
||||
status: ApiResponseStatus::Completed,
|
||||
job_id: job_id.clone(),
|
||||
nonce: Some(format!("{:x}", candidate.nonce)),
|
||||
work: Some(hex::encode(candidate.work)),
|
||||
hash_count: total_hashes,
|
||||
elapsed_time: elapsed,
|
||||
miner_id: None,
|
||||
};
|
||||
|
||||
let msg = MinerMessage::JobResult(result);
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for incoming messages and connection health
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
reason = connection.closed() => {
|
||||
let msg = reason.to_string();
|
||||
if !*authenticated && msg.to_ascii_lowercase().contains("auth") {
|
||||
return Err(quic_transport::PermanentConnectError(format!(
|
||||
"node rejected miner auth ({msg}); check --auth-token / miner-auth-token"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
return Err(anyhow::anyhow!("Connection closed: {}", msg));
|
||||
}
|
||||
|
||||
msg_result = read_message(&mut recv) => {
|
||||
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,
|
||||
request.mining_hash
|
||||
);
|
||||
|
||||
// Parse header hash
|
||||
let header_hash: [u8; 32] = match hex::decode(&request.mining_hash) {
|
||||
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
|
||||
_ => {
|
||||
log::warn!("Invalid mining_hash in request");
|
||||
let result = MiningResult {
|
||||
status: ApiResponseStatus::Failed,
|
||||
job_id: request.job_id,
|
||||
nonce: None,
|
||||
work: None,
|
||||
hash_count: 0,
|
||||
elapsed_time: 0.0,
|
||||
miner_id: None,
|
||||
};
|
||||
let msg = MinerMessage::JobResult(result);
|
||||
send_message_checked(&connection, &mut send, &msg).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Parse difficulty
|
||||
let difficulty = match U512::from_dec_str(&request.difficulty) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
log::warn!("Invalid difficulty in request: parse error");
|
||||
let result = MiningResult {
|
||||
status: ApiResponseStatus::Failed,
|
||||
job_id: request.job_id,
|
||||
nonce: None,
|
||||
work: None,
|
||||
hash_count: 0,
|
||||
elapsed_time: 0.0,
|
||||
miner_id: None,
|
||||
};
|
||||
let msg = MinerMessage::JobResult(result);
|
||||
send_message_checked(&connection, &mut send, &msg).await?;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Reject zero difficulty to prevent division-by-zero panic in JobContext::new
|
||||
if difficulty.is_zero() {
|
||||
log::warn!("Invalid difficulty in request: zero is not allowed");
|
||||
let result = MiningResult {
|
||||
status: ApiResponseStatus::Failed,
|
||||
job_id: request.job_id,
|
||||
nonce: None,
|
||||
work: None,
|
||||
hash_count: 0,
|
||||
elapsed_time: 0.0,
|
||||
miner_id: None,
|
||||
};
|
||||
let msg = MinerMessage::JobResult(result);
|
||||
send_message_checked(&connection, &mut send, &msg).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reset state for new job
|
||||
cpu_hashes = 0;
|
||||
gpu_hashes = 0;
|
||||
job_start_time = Some(Instant::now());
|
||||
node_job_id = Some(request.job_id.clone());
|
||||
result_sent_for_current_job = false;
|
||||
|
||||
log::debug!("Starting job {}", request.job_id);
|
||||
metrics::set_active_jobs(1);
|
||||
|
||||
// start_job returns the internal job ID used to detect stale results
|
||||
internal_job_id = worker_pool.start_job(header_hash, difficulty);
|
||||
}
|
||||
Ok(MinerMessage::JobResult(_)) => {
|
||||
log::warn!("Received unexpected JobResult from node");
|
||||
}
|
||||
Ok(MinerMessage::Ready { .. }) => {
|
||||
log::warn!("Received unexpected Ready from node");
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
return Err(anyhow::anyhow!("Node disconnected"));
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(anyhow::anyhow!("Read error: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Short sleep to yield when no messages
|
||||
_ = tokio::time::sleep(Duration::from_millis(1)) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,4 @@ serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] }
|
||||
url = "2"
|
||||
uuid = { version = "1", features = ["fast-rng", "v4"] }
|
||||
|
||||
@@ -106,33 +106,28 @@ impl TelemetryHandle {
|
||||
}
|
||||
|
||||
/// Emit a fully custom payload (must include `"msg"`).
|
||||
pub async fn emit_payload(&self, payload: Value, verbosity: Option<u8>) {
|
||||
///
|
||||
/// This is intentionally lossy: if the internal channel is full, the message is dropped
|
||||
/// silently to avoid blocking mining paths or causing unbounded memory growth.
|
||||
pub fn emit_payload(&self, payload: Value, verbosity: Option<u8>) {
|
||||
if self.senders.is_empty() {
|
||||
return;
|
||||
}
|
||||
let msg = EnvelopeMsg { payload, verbosity };
|
||||
for tx in self.senders.iter() {
|
||||
// Drop on full; do not block mining paths. This is intentionally lossy under backpressure.
|
||||
if tx.try_send(msg.clone()).is_err() {
|
||||
// Best-effort: if try_send failed due to full buffer, attempt bounded send with small timeout.
|
||||
// Avoid blocking indefinitely.
|
||||
let tx = tx.clone();
|
||||
let msg = msg.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = tx.send(msg).await;
|
||||
});
|
||||
}
|
||||
// Drop on full; do not block mining paths.
|
||||
let _ = tx.try_send(msg.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// Emit a `system.connected` message using configuration defaults and an optional link.
|
||||
pub async fn emit_system_connected(&self, link: Option<&TelemetryNodeLink>) {
|
||||
pub fn emit_system_connected(&self, link: Option<&TelemetryNodeLink>) {
|
||||
let payload = build_system_connected_payload(&self.config, link);
|
||||
self.emit_payload(payload, Some(self.verbosity)).await;
|
||||
self.emit_payload(payload, Some(self.verbosity));
|
||||
}
|
||||
|
||||
/// Emit a `system.interval` message with lightweight health stats and an optional link hint.
|
||||
pub async fn emit_system_interval(&self, interval: &SystemInterval, link_hint: Option<&str>) {
|
||||
pub fn emit_system_interval(&self, interval: &SystemInterval, link_hint: Option<&str>) {
|
||||
let mut payload = json!({
|
||||
"msg": "system.interval",
|
||||
"uptime_ms": interval.uptime_ms,
|
||||
@@ -169,7 +164,7 @@ impl TelemetryHandle {
|
||||
.insert("linked_node_hint".to_string(), json!(hint));
|
||||
}
|
||||
|
||||
self.emit_payload(payload, Some(self.verbosity)).await;
|
||||
self.emit_payload(payload, Some(self.verbosity));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -463,9 +458,7 @@ mod tests {
|
||||
let cfg = TelemetryConfig::default();
|
||||
let handle = start(cfg);
|
||||
assert!(!handle.is_enabled());
|
||||
handle
|
||||
.emit_system_interval(&SystemInterval::default(), None)
|
||||
.await;
|
||||
handle.emit_system_connected(None).await;
|
||||
handle.emit_system_interval(&SystemInterval::default(), None);
|
||||
handle.emit_system_connected(None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
[package]
|
||||
name = "pool-service"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "Captcha share pool: turns browser proof-of-work captcha solves into Quantus mining shares"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
clap = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
log = { workspace = true }
|
||||
primitive-types = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["std"] }
|
||||
tokio = { workspace = true }
|
||||
warp = { workspace = true }
|
||||
|
||||
# QUIC transport (shared with miner-service)
|
||||
quinn = "0.10"
|
||||
quic-transport = { path = "../quic-transport" }
|
||||
|
||||
# Constant-time comparison for the site secret
|
||||
constant_time_eq = "0.3"
|
||||
|
||||
# Protocol types from the node repo
|
||||
quantus-miner-api = { workspace = true }
|
||||
|
||||
# Local crates
|
||||
pow-core = { path = "../pow-core" }
|
||||
@@ -0,0 +1,219 @@
|
||||
//! HTTP API for the captcha widget (session/share) and for protected sites
|
||||
//! (siteverify), plus optional static file serving for the demo page.
|
||||
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use primitive_types::U512;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use warp::Filter;
|
||||
|
||||
use crate::rate_limit::SessionIssuerLimiter;
|
||||
use crate::state::{PoolState, ShareOutcome};
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SessionResponse {
|
||||
session_id: String,
|
||||
/// Hex, 32 bytes, no 0x prefix.
|
||||
header_hash: String,
|
||||
/// Hex U512, no 0x prefix. Solver iterates nonce_start..nonce_end.
|
||||
nonce_start: String,
|
||||
nonce_end: String,
|
||||
/// Share target as hex U512: a share is valid iff hash < share_target.
|
||||
/// (Target form avoids the solver needing U512 division.)
|
||||
share_target: String,
|
||||
/// Expected number of hashes to find a share (= share difficulty), FYI/UX.
|
||||
expected_hashes: u64,
|
||||
expires_in_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ShareRequest {
|
||||
session_id: String,
|
||||
/// Hex U512 nonce, no 0x prefix.
|
||||
nonce: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ShareResponse {
|
||||
success: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<&'static str>,
|
||||
block_found: bool,
|
||||
}
|
||||
|
||||
/// Request shape mirrors reCAPTCHA/Turnstile: `secret` + `response`.
|
||||
#[derive(Deserialize)]
|
||||
struct SiteVerifyRequest {
|
||||
secret: String,
|
||||
response: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SiteVerifyResponse {
|
||||
success: bool,
|
||||
#[serde(rename = "challenge_ts", skip_serializing_if = "Option::is_none")]
|
||||
challenge_ts: Option<u64>,
|
||||
#[serde(rename = "error-codes", skip_serializing_if = "Vec::is_empty")]
|
||||
error_codes: Vec<&'static str>,
|
||||
}
|
||||
|
||||
pub async fn serve(
|
||||
state: Arc<PoolState>,
|
||||
limiter: Arc<SessionIssuerLimiter>,
|
||||
addr: SocketAddr,
|
||||
serve_dir: Option<PathBuf>,
|
||||
) {
|
||||
let with_state = {
|
||||
let state = state.clone();
|
||||
warp::any().map(move || state.clone())
|
||||
};
|
||||
let with_limiter = {
|
||||
let limiter = limiter.clone();
|
||||
warp::any().map(move || limiter.clone())
|
||||
};
|
||||
|
||||
// POST /api/session -> new captcha challenge
|
||||
let session = warp::path!("api" / "session")
|
||||
.and(warp::post())
|
||||
.and(warp::addr::remote())
|
||||
.and(with_limiter.clone())
|
||||
.and(with_state.clone())
|
||||
.map(
|
||||
|remote: Option<SocketAddr>,
|
||||
limiter: Arc<SessionIssuerLimiter>,
|
||||
state: Arc<PoolState>| {
|
||||
let ip = remote
|
||||
.map(|a| a.ip())
|
||||
.unwrap_or(IpAddr::from([127, 0, 0, 1]));
|
||||
if let Err(reason) = limiter.check(ip) {
|
||||
return reply_error(warp::http::StatusCode::TOO_MANY_REQUESTS, reason);
|
||||
}
|
||||
match state.issue_session() {
|
||||
Err(reason) => reply_error(warp::http::StatusCode::SERVICE_UNAVAILABLE, reason),
|
||||
Ok(s) => {
|
||||
let target = U512::MAX / s.share_difficulty;
|
||||
let expected = s.share_difficulty.min(U512::from(u64::MAX)).as_u64();
|
||||
warp::reply::with_status(
|
||||
warp::reply::json(&SessionResponse {
|
||||
session_id: s.id,
|
||||
header_hash: hex::encode(s.job.header),
|
||||
nonce_start: format!("{:x}", s.nonce_start),
|
||||
nonce_end: format!("{:x}", s.nonce_end),
|
||||
share_target: format!("{:x}", target),
|
||||
expected_hashes: expected,
|
||||
expires_in_secs: crate::state::SESSION_TTL.as_secs(),
|
||||
}),
|
||||
warp::http::StatusCode::OK,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/share -> verify a solved nonce, mint a token
|
||||
let share = warp::path!("api" / "share")
|
||||
.and(warp::post())
|
||||
.and(warp::body::content_length_limit(4096))
|
||||
.and(warp::body::json())
|
||||
.and(with_state.clone())
|
||||
.map(|req: ShareRequest, state: Arc<PoolState>| {
|
||||
let nonce = match U512::from_str_radix(req.nonce.trim_start_matches("0x"), 16) {
|
||||
Ok(n) => n,
|
||||
Err(_) => {
|
||||
return warp::reply::with_status(
|
||||
warp::reply::json(&ShareResponse {
|
||||
success: false,
|
||||
token: None,
|
||||
error: Some("malformed_nonce"),
|
||||
block_found: false,
|
||||
}),
|
||||
warp::http::StatusCode::BAD_REQUEST,
|
||||
)
|
||||
}
|
||||
};
|
||||
match state.submit_share(&req.session_id, nonce) {
|
||||
ShareOutcome::Accepted { token, block_found } => warp::reply::with_status(
|
||||
warp::reply::json(&ShareResponse {
|
||||
success: true,
|
||||
token: Some(token),
|
||||
error: None,
|
||||
block_found,
|
||||
}),
|
||||
warp::http::StatusCode::OK,
|
||||
),
|
||||
ShareOutcome::Rejected(reason) => warp::reply::with_status(
|
||||
warp::reply::json(&ShareResponse {
|
||||
success: false,
|
||||
token: None,
|
||||
error: Some(reason),
|
||||
block_found: false,
|
||||
}),
|
||||
warp::http::StatusCode::FORBIDDEN,
|
||||
),
|
||||
}
|
||||
});
|
||||
|
||||
// POST /siteverify -> protected site's backend redeems a token
|
||||
let siteverify = warp::path!("siteverify")
|
||||
.and(warp::post())
|
||||
.and(warp::body::content_length_limit(4096))
|
||||
// Accept both JSON and form encoding, like the incumbents do.
|
||||
.and(
|
||||
warp::body::json()
|
||||
.or(warp::body::form())
|
||||
.unify()
|
||||
.map(|req: SiteVerifyRequest| req),
|
||||
)
|
||||
.and(with_state.clone())
|
||||
.map(|req: SiteVerifyRequest, state: Arc<PoolState>| {
|
||||
match state.verify_token(&req.secret, &req.response) {
|
||||
Ok(ts) => warp::reply::json(&SiteVerifyResponse {
|
||||
success: true,
|
||||
challenge_ts: Some(ts),
|
||||
error_codes: vec![],
|
||||
}),
|
||||
Err(code) => warp::reply::json(&SiteVerifyResponse {
|
||||
success: false,
|
||||
challenge_ts: None,
|
||||
error_codes: vec![code],
|
||||
}),
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/stats -> pool counters
|
||||
let stats = warp::path!("api" / "stats")
|
||||
.and(warp::get())
|
||||
.and(with_state.clone())
|
||||
.map(|state: Arc<PoolState>| warp::reply::json(&state.stats()));
|
||||
|
||||
let cors = warp::cors()
|
||||
.allow_any_origin()
|
||||
.allow_methods(vec!["GET", "POST", "OPTIONS"])
|
||||
.allow_headers(vec!["content-type"]);
|
||||
|
||||
let api = session.or(share).or(siteverify).or(stats);
|
||||
|
||||
log::info!("HTTP API listening on {}", addr);
|
||||
if let Some(dir) = serve_dir {
|
||||
log::info!("Serving demo statics from {}", dir.display());
|
||||
let statics = warp::get().and(warp::fs::dir(dir));
|
||||
warp::serve(api.or(statics).with(cors)).run(addr).await;
|
||||
} else {
|
||||
warp::serve(api.with(cors)).run(addr).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn reply_error(
|
||||
status: warp::http::StatusCode,
|
||||
error: &'static str,
|
||||
) -> warp::reply::WithStatus<warp::reply::Json> {
|
||||
#[derive(Serialize)]
|
||||
struct Err<'a> {
|
||||
error: &'a str,
|
||||
}
|
||||
warp::reply::with_status(warp::reply::json(&Err { error }), status)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
//! Quantus captcha share pool.
|
||||
//!
|
||||
//! Sits between a quantus-node (external miner protocol) and browser captcha
|
||||
//! solvers: hands out low-difficulty share challenges over the real block
|
||||
//! header, verifies solves, mints single-use tokens for site backends, and
|
||||
//! submits any share that happens to meet full network difficulty as a block.
|
||||
|
||||
mod http;
|
||||
mod rate_limit;
|
||||
mod state;
|
||||
mod upstream;
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use primitive_types::U512;
|
||||
|
||||
use crate::rate_limit::SessionRateLimit;
|
||||
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "pool-service", about = "Quantus captcha share pool")]
|
||||
struct Args {
|
||||
/// HTTP listen address for the captcha/siteverify API.
|
||||
#[arg(long, default_value = "127.0.0.1:8787", env = "POOL_HTTP_ADDR")]
|
||||
http_addr: SocketAddr,
|
||||
|
||||
/// Address of the quantus-node external-miner QUIC endpoint.
|
||||
/// If omitted, runs in standalone mode with synthetic jobs (demo only).
|
||||
#[arg(long, env = "POOL_NODE_ADDR")]
|
||||
node_addr: Option<SocketAddr>,
|
||||
|
||||
/// Shared auth token from the node's `miner-auth-token` file.
|
||||
/// Required when `--node-addr` is set. Prefer `--auth-token-file`.
|
||||
#[arg(long, env = "POOL_AUTH_TOKEN", conflicts_with = "auth_token_file")]
|
||||
auth_token: Option<String>,
|
||||
|
||||
/// Path to the node's `miner-auth-token` file (trimmed).
|
||||
#[arg(long, env = "POOL_AUTH_TOKEN_FILE", conflicts_with = "auth_token")]
|
||||
auth_token_file: Option<PathBuf>,
|
||||
|
||||
/// SHA-256 fingerprint of the node's miner TLS certificate (64 hex chars).
|
||||
/// Required when `--node-addr` is set. Prefer `--tls-cert-sha256-file`.
|
||||
#[arg(
|
||||
long,
|
||||
env = "POOL_TLS_CERT_SHA256",
|
||||
conflicts_with = "tls_cert_sha256_file"
|
||||
)]
|
||||
tls_cert_sha256: Option<String>,
|
||||
|
||||
/// Path to the node's `miner-tls-cert-sha256` file.
|
||||
#[arg(
|
||||
long,
|
||||
env = "POOL_TLS_CERT_SHA256_FILE",
|
||||
conflicts_with = "tls_cert_sha256"
|
||||
)]
|
||||
tls_cert_sha256_file: Option<PathBuf>,
|
||||
|
||||
/// Share difficulty: expected number of hashes per captcha solve.
|
||||
/// Measured browser WASM rate ≈ 120 kH/s on an M-series laptop, so
|
||||
/// 50000 ≈ 0.4 s desktop / ~2 s phone. Raise for stronger rate limiting.
|
||||
#[arg(long, default_value = "50000", env = "POOL_SHARE_DIFFICULTY")]
|
||||
share_difficulty: u64,
|
||||
|
||||
/// Secret that protected-site backends must present to /siteverify.
|
||||
#[arg(long, default_value = "dev-secret", env = "POOL_SITE_SECRET")]
|
||||
site_secret: String,
|
||||
|
||||
/// Optional directory of static files to serve (demo page / widget).
|
||||
#[arg(long, env = "POOL_SERVE_DIR")]
|
||||
serve_dir: Option<PathBuf>,
|
||||
|
||||
/// Job rotation interval in standalone mode, seconds.
|
||||
#[arg(long, default_value = "20")]
|
||||
standalone_job_secs: u64,
|
||||
|
||||
/// Maximum live captcha sessions; /api/session returns 503 when full.
|
||||
#[arg(long, default_value = "100000", env = "POOL_MAX_SESSIONS")]
|
||||
max_sessions: usize,
|
||||
|
||||
/// Maximum live (unredeemed) share tokens; shares are refused when full.
|
||||
#[arg(long, default_value = "100000", env = "POOL_MAX_TOKENS")]
|
||||
max_tokens: usize,
|
||||
|
||||
/// Max /api/session issuances per client IP per minute.
|
||||
#[arg(long, default_value = "60", env = "POOL_SESSIONS_PER_IP_PER_MIN")]
|
||||
sessions_per_ip_per_min: u32,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
let args = Args::parse();
|
||||
|
||||
anyhow::ensure!(args.share_difficulty > 0, "--share-difficulty must be > 0");
|
||||
if args.site_secret == "dev-secret" {
|
||||
log::warn!("Using default site secret; set --site-secret in production");
|
||||
}
|
||||
|
||||
// Resolve + validate node auth before starting HTTP / upstream tasks so a
|
||||
// bad fingerprint or oversized token exits instead of retrying forever.
|
||||
let node_upstream = match args.node_addr {
|
||||
Some(addr) => {
|
||||
let auth_token = resolve_auth_token(args.auth_token, args.auth_token_file)?;
|
||||
let tls_cert_sha256 =
|
||||
resolve_tls_cert_sha256(args.tls_cert_sha256, args.tls_cert_sha256_file)?;
|
||||
quic_transport::validate_auth_config(&auth_token, &tls_cert_sha256)?;
|
||||
Some((addr, auth_token, tls_cert_sha256))
|
||||
}
|
||||
None => {
|
||||
if args.auth_token.is_some()
|
||||
|| args.auth_token_file.is_some()
|
||||
|| args.tls_cert_sha256.is_some()
|
||||
|| args.tls_cert_sha256_file.is_some()
|
||||
{
|
||||
log::warn!("Ignoring auth/TLS pin flags in standalone mode (no --node-addr)");
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let (solution_tx, solution_rx) = tokio::sync::mpsc::channel(16);
|
||||
let state = state::PoolState::new(
|
||||
U512::from(args.share_difficulty),
|
||||
args.site_secret.clone(),
|
||||
solution_tx,
|
||||
state::Limits {
|
||||
max_sessions: args.max_sessions,
|
||||
max_tokens: args.max_tokens,
|
||||
},
|
||||
);
|
||||
|
||||
let limiter = Arc::new(rate_limit::SessionIssuerLimiter::new(SessionRateLimit {
|
||||
max_per_ip: args.sessions_per_ip_per_min,
|
||||
window: Duration::from_secs(60),
|
||||
}));
|
||||
|
||||
// Periodic cleanup of expired sessions/tokens and stale rate-limit windows.
|
||||
{
|
||||
let state = state.clone();
|
||||
let limiter = limiter.clone();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||
state.gc();
|
||||
limiter.gc();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Job source. Node mode reconnects across transient disconnects (node
|
||||
// restart); only a PermanentConnectError ends the task — then we tear
|
||||
// down HTTP so the process does not look healthy on a bad token/pin.
|
||||
match node_upstream {
|
||||
Some((addr, auth_token, tls_cert_sha256)) => {
|
||||
log::info!("Upstream: quantus-node at {}", addr);
|
||||
let state_for_upstream = state.clone();
|
||||
let upstream = tokio::spawn(async move {
|
||||
upstream::run_node_client(
|
||||
state_for_upstream,
|
||||
addr,
|
||||
auth_token,
|
||||
tls_cert_sha256,
|
||||
solution_rx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
result = upstream => {
|
||||
match result {
|
||||
Ok(Ok(())) => anyhow::bail!(
|
||||
"upstream task exited unexpectedly (should reconnect forever)"
|
||||
),
|
||||
Ok(Err(e)) => {
|
||||
log::error!("Upstream permanent failure; shutting down: {e}");
|
||||
Err(e.into())
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!("upstream task panicked: {e}")),
|
||||
}
|
||||
}
|
||||
_ = http::serve(state, limiter, args.http_addr, args.serve_dir) => Ok(()),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
log::warn!("No --node-addr given: running STANDALONE with synthetic jobs");
|
||||
let state_for_upstream = state.clone();
|
||||
tokio::spawn(async move {
|
||||
upstream::run_standalone(
|
||||
state_for_upstream,
|
||||
Duration::from_secs(args.standalone_job_secs),
|
||||
solution_rx,
|
||||
)
|
||||
.await
|
||||
});
|
||||
http::serve(state, limiter, args.http_addr, args.serve_dir).await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_auth_token(
|
||||
auth_token: Option<String>,
|
||||
auth_token_file: Option<PathBuf>,
|
||||
) -> anyhow::Result<String> {
|
||||
resolve_required_secret(
|
||||
auth_token,
|
||||
auth_token_file,
|
||||
"--auth-token",
|
||||
"--auth-token-file",
|
||||
"when --node-addr is set, pass --auth-token-file <PATH> to the node's \
|
||||
miner-auth-token file (or --auth-token <TOKEN>)",
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_tls_cert_sha256(value: Option<String>, file: Option<PathBuf>) -> anyhow::Result<String> {
|
||||
resolve_required_secret(
|
||||
value,
|
||||
file,
|
||||
"--tls-cert-sha256",
|
||||
"--tls-cert-sha256-file",
|
||||
"when --node-addr is set, pass --tls-cert-sha256-file <PATH> to the node's \
|
||||
miner-tls-cert-sha256 file (or --tls-cert-sha256 <HEX>; also printed in node logs)",
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_required_secret(
|
||||
value: Option<String>,
|
||||
file: Option<PathBuf>,
|
||||
value_flag: &str,
|
||||
file_flag: &str,
|
||||
missing_msg: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
if let Some(value) = value {
|
||||
let value = value.trim().to_string();
|
||||
anyhow::ensure!(!value.is_empty(), "{value_flag} is empty");
|
||||
return Ok(value);
|
||||
}
|
||||
if let Some(path) = file {
|
||||
let contents = std::fs::read_to_string(&path)
|
||||
.map_err(|e| anyhow::anyhow!("failed to read {file_flag} {}: {}", path.display(), e))?;
|
||||
let value = contents.trim().to_string();
|
||||
anyhow::ensure!(!value.is_empty(), "{file_flag} {} is empty", path.display());
|
||||
return Ok(value);
|
||||
}
|
||||
anyhow::bail!("{missing_msg}");
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//! Per-client rate limiting for unauthenticated endpoints.
|
||||
//!
|
||||
//! `/api/session` is free, so a hard map cap alone still lets an attacker
|
||||
//! churn entries at the TTL boundary. A per-IP issuance ceiling bounds that
|
||||
//! even when the global cap has headroom.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct SessionRateLimit {
|
||||
/// Max session issuances per IP per window.
|
||||
pub max_per_ip: u32,
|
||||
pub window: Duration,
|
||||
}
|
||||
|
||||
impl Default for SessionRateLimit {
|
||||
fn default() -> Self {
|
||||
SessionRateLimit {
|
||||
// 60 sessions/min/IP is generous for real users (a page load is
|
||||
// one) but stops a single host from dominating issuance.
|
||||
max_per_ip: 60,
|
||||
window: Duration::from_secs(60),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct IpWindow {
|
||||
count: u32,
|
||||
window_start: Option<Instant>,
|
||||
}
|
||||
|
||||
pub struct SessionIssuerLimiter {
|
||||
cfg: SessionRateLimit,
|
||||
by_ip: Mutex<HashMap<IpAddr, IpWindow>>,
|
||||
}
|
||||
|
||||
impl SessionIssuerLimiter {
|
||||
pub fn new(cfg: SessionRateLimit) -> Self {
|
||||
SessionIssuerLimiter {
|
||||
cfg,
|
||||
by_ip: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns Ok(()) if this IP may issue another session, Err otherwise.
|
||||
pub fn check(&self, ip: IpAddr) -> Result<(), &'static str> {
|
||||
let now = Instant::now();
|
||||
let mut map = self.by_ip.lock().unwrap();
|
||||
let entry = map.entry(ip).or_default();
|
||||
let window_start = entry.window_start.get_or_insert_with(Instant::now);
|
||||
if now.duration_since(*window_start) >= self.cfg.window {
|
||||
*window_start = now;
|
||||
entry.count = 0;
|
||||
}
|
||||
if entry.count >= self.cfg.max_per_ip {
|
||||
return Err("rate_limited");
|
||||
}
|
||||
entry.count += 1;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop stale IP windows so the side table doesn't grow forever.
|
||||
pub fn gc(&self) {
|
||||
let cutoff = self.cfg.window * 2;
|
||||
let now = Instant::now();
|
||||
self.by_ip.lock().unwrap().retain(|_, w| {
|
||||
w.window_start
|
||||
.map(|t| now.duration_since(t) < cutoff)
|
||||
.unwrap_or(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn allows_up_to_cap_then_rejects() {
|
||||
let lim = SessionIssuerLimiter::new(SessionRateLimit {
|
||||
max_per_ip: 2,
|
||||
window: Duration::from_secs(60),
|
||||
});
|
||||
let ip = IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1));
|
||||
assert!(lim.check(ip).is_ok());
|
||||
assert!(lim.check(ip).is_ok());
|
||||
assert_eq!(lim.check(ip), Err("rate_limited"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ips_are_independent() {
|
||||
let lim = SessionIssuerLimiter::new(SessionRateLimit {
|
||||
max_per_ip: 1,
|
||||
window: Duration::from_secs(60),
|
||||
});
|
||||
assert!(lim.check(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4))).is_ok());
|
||||
assert!(lim.check(IpAddr::V6(Ipv6Addr::LOCALHOST)).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
//! Core pool state: the current mining job, captcha sessions with disjoint
|
||||
//! nonce ranges, and single-use share tokens.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use primitive_types::U512;
|
||||
use rand::RngCore;
|
||||
|
||||
/// How long a captcha session stays solvable after issuance.
|
||||
pub const SESSION_TTL: Duration = Duration::from_secs(120);
|
||||
/// How long a share token stays redeemable via /siteverify.
|
||||
pub const TOKEN_TTL: Duration = Duration::from_secs(300);
|
||||
/// Each session gets a disjoint slice of the nonce space this many nonces wide.
|
||||
/// 2^64 nonces is unreachable within a session TTL at browser hash rates.
|
||||
pub const SESSION_RANGE_BITS: u32 = 64;
|
||||
|
||||
/// Caps on live entries in the in-memory maps. `/api/session` is free and
|
||||
/// unauthenticated, so without a ceiling a request flood becomes a memory-DoS
|
||||
/// on the anti-abuse service itself. At the ~300 bytes/entry ballpark the
|
||||
/// defaults bound each map to tens of MB.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Limits {
|
||||
pub max_sessions: usize,
|
||||
pub max_tokens: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Limits {
|
||||
max_sessions: 100_000,
|
||||
max_tokens: 100_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The job the pool is currently handing out to captcha sessions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Job {
|
||||
/// Job id assigned by the upstream node (or synthesized in standalone mode).
|
||||
pub job_id: String,
|
||||
/// Block header hash to mine over.
|
||||
pub header: [u8; 32],
|
||||
/// Full network difficulty for this job (block found if a share meets it).
|
||||
pub network_difficulty: U512,
|
||||
}
|
||||
|
||||
/// A captcha session: one issued challenge, bound to a disjoint nonce range.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
/// Snapshot of the job at issuance time. Shares are verified against this
|
||||
/// header even if the pool has since moved to a newer job (grace period =
|
||||
/// session TTL); only network-difficulty solutions for the *current* job
|
||||
/// are submitted upstream.
|
||||
pub job: Job,
|
||||
pub nonce_start: U512,
|
||||
pub nonce_end: U512, // exclusive
|
||||
pub share_difficulty: U512,
|
||||
pub issued_at: Instant,
|
||||
pub solved: bool,
|
||||
}
|
||||
|
||||
/// A verified share, redeemable exactly once by the protected site's backend.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareToken {
|
||||
pub created_at: Instant,
|
||||
pub created_unix: u64,
|
||||
pub consumed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, serde::Serialize)]
|
||||
pub struct PoolStats {
|
||||
pub sessions_issued: u64,
|
||||
pub shares_accepted: u64,
|
||||
pub shares_rejected: u64,
|
||||
pub tokens_verified: u64,
|
||||
pub blocks_found: u64,
|
||||
}
|
||||
|
||||
pub struct PoolState {
|
||||
/// Current job, None until the first job arrives from the job source.
|
||||
current_job: Mutex<Option<Job>>,
|
||||
sessions: Mutex<HashMap<String, Session>>,
|
||||
tokens: Mutex<HashMap<String, ShareToken>>,
|
||||
stats: Mutex<PoolStats>,
|
||||
/// Monotonic counter used to carve disjoint nonce ranges out of U512 space.
|
||||
range_counter: AtomicU64,
|
||||
/// Difficulty for captcha shares (expected hashes per solve).
|
||||
pub share_difficulty: U512,
|
||||
/// Site secret checked by /siteverify.
|
||||
///
|
||||
/// NOTE: single-tenant by design for now. All tokens are interchangeable
|
||||
/// across any backend holding this secret; per-sitekey secrets land with
|
||||
/// the host-registration follow-up.
|
||||
pub site_secret: String,
|
||||
limits: Limits,
|
||||
/// Sender used to push network-difficulty solutions upstream.
|
||||
pub solution_tx: tokio::sync::mpsc::Sender<FoundBlock>,
|
||||
}
|
||||
|
||||
/// A share that met full network difficulty: a real block solution.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FoundBlock {
|
||||
pub job_id: String,
|
||||
pub nonce: U512,
|
||||
}
|
||||
|
||||
pub enum ShareOutcome {
|
||||
/// Share accepted; token the client can hand to the protected site.
|
||||
Accepted {
|
||||
token: String,
|
||||
block_found: bool,
|
||||
},
|
||||
Rejected(&'static str),
|
||||
}
|
||||
|
||||
fn random_id() -> String {
|
||||
let mut bytes = [0u8; 24];
|
||||
rand::rng().fill_bytes(&mut bytes);
|
||||
hex::encode(bytes)
|
||||
}
|
||||
|
||||
fn unix_now() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
impl PoolState {
|
||||
pub fn new(
|
||||
share_difficulty: U512,
|
||||
site_secret: String,
|
||||
solution_tx: tokio::sync::mpsc::Sender<FoundBlock>,
|
||||
limits: Limits,
|
||||
) -> Arc<Self> {
|
||||
assert!(
|
||||
!share_difficulty.is_zero(),
|
||||
"share difficulty must be non-zero"
|
||||
);
|
||||
Arc::new(PoolState {
|
||||
current_job: Mutex::new(None),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
tokens: Mutex::new(HashMap::new()),
|
||||
stats: Mutex::new(PoolStats::default()),
|
||||
range_counter: AtomicU64::new(0),
|
||||
share_difficulty,
|
||||
site_secret,
|
||||
limits,
|
||||
solution_tx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Install a new job (from the upstream node or the standalone generator).
|
||||
/// Existing sessions keep their job snapshot and remain solvable until TTL.
|
||||
pub fn set_job(&self, job: Job) {
|
||||
log::info!(
|
||||
"New job {}: header=0x{} network_difficulty={}",
|
||||
job.job_id,
|
||||
hex::encode(job.header),
|
||||
pow_core::format_u512(job.network_difficulty)
|
||||
);
|
||||
*self.current_job.lock().unwrap() = Some(job);
|
||||
}
|
||||
|
||||
pub fn current_job(&self) -> Option<Job> {
|
||||
self.current_job.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Drop the current job (e.g. upstream disconnected). New sessions will
|
||||
/// see `no_job_available` until the next `set_job`.
|
||||
pub fn clear_job(&self) {
|
||||
if self.current_job.lock().unwrap().take().is_some() {
|
||||
log::info!("Cleared current job (upstream unavailable)");
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue a new captcha session with a disjoint nonce range.
|
||||
pub fn issue_session(&self) -> Result<Session, &'static str> {
|
||||
let job = self.current_job().ok_or("no_job_available")?;
|
||||
|
||||
if self.sessions.lock().unwrap().len() >= self.limits.max_sessions {
|
||||
return Err("at_capacity");
|
||||
}
|
||||
|
||||
// Carve the next disjoint 2^64-nonce slice. The counter is 64 bits and
|
||||
// slices are 2^64 wide, so slices live in the bottom 2^128 of the U512
|
||||
// nonce space and never collide with each other.
|
||||
let index = self.range_counter.fetch_add(1, Ordering::Relaxed);
|
||||
let nonce_start = U512::from(index) << SESSION_RANGE_BITS;
|
||||
let nonce_end = nonce_start + (U512::one() << SESSION_RANGE_BITS);
|
||||
|
||||
let session = Session {
|
||||
id: random_id(),
|
||||
job,
|
||||
nonce_start,
|
||||
nonce_end,
|
||||
share_difficulty: self.share_difficulty,
|
||||
issued_at: Instant::now(),
|
||||
solved: false,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(session.id.clone(), session.clone());
|
||||
self.stats.lock().unwrap().sessions_issued += 1;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
/// Verify a submitted share and, if valid, mint a single-use token.
|
||||
pub fn submit_share(&self, session_id: &str, nonce: U512) -> ShareOutcome {
|
||||
let session = {
|
||||
let mut sessions = self.sessions.lock().unwrap();
|
||||
match sessions.get_mut(session_id) {
|
||||
None => return self.reject("unknown_session"),
|
||||
Some(s) => {
|
||||
if s.solved {
|
||||
return self.reject("session_already_solved");
|
||||
}
|
||||
if s.issued_at.elapsed() > SESSION_TTL {
|
||||
sessions.remove(session_id);
|
||||
return self.reject("session_expired");
|
||||
}
|
||||
// Mark solved optimistically; unmark on verification failure
|
||||
// would allow retries, but a failed share means a buggy or
|
||||
// malicious client, so we burn the session either way.
|
||||
s.solved = true;
|
||||
s.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if nonce < session.nonce_start || nonce >= session.nonce_end {
|
||||
return self.reject("nonce_out_of_range");
|
||||
}
|
||||
|
||||
let (share_valid, hash) = pow_core::is_valid_nonce(
|
||||
session.job.header,
|
||||
nonce.to_big_endian(),
|
||||
session.share_difficulty,
|
||||
);
|
||||
if !share_valid {
|
||||
return self.reject("share_below_target");
|
||||
}
|
||||
|
||||
// Jackpot check: does this share meet full network difficulty for the
|
||||
// job it was issued against? Only submit if that job is still current.
|
||||
// Target derivation delegates to pow-core's canonical JobContext.
|
||||
let network_target =
|
||||
pow_core::JobContext::new(session.job.header, session.job.network_difficulty).target;
|
||||
let mut block_found = false;
|
||||
if hash < network_target {
|
||||
let still_current = self
|
||||
.current_job()
|
||||
.map(|j| j.job_id == session.job.job_id)
|
||||
.unwrap_or(false);
|
||||
if still_current {
|
||||
log::info!(
|
||||
"BLOCK FOUND by captcha share! job={} nonce={:x}",
|
||||
session.job.job_id,
|
||||
nonce
|
||||
);
|
||||
// This is the revenue event: count it and be loud if the
|
||||
// upstream queue can't take it.
|
||||
match self.solution_tx.try_send(FoundBlock {
|
||||
job_id: session.job.job_id.clone(),
|
||||
nonce,
|
||||
}) {
|
||||
Ok(()) => {
|
||||
block_found = true;
|
||||
self.stats.lock().unwrap().blocks_found += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"DROPPED block solution for job {}: upstream queue unavailable ({})",
|
||||
session.job.job_id,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let token = random_id();
|
||||
{
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
// A full token map means someone is farming shares faster than
|
||||
// TTL expiry; refuse rather than grow without bound. The client
|
||||
// paid hashes for this, so make the refusal explicit.
|
||||
if tokens.len() >= self.limits.max_tokens {
|
||||
return self.reject("at_capacity");
|
||||
}
|
||||
tokens.insert(
|
||||
token.clone(),
|
||||
ShareToken {
|
||||
created_at: Instant::now(),
|
||||
created_unix: unix_now(),
|
||||
consumed: false,
|
||||
},
|
||||
);
|
||||
}
|
||||
self.stats.lock().unwrap().shares_accepted += 1;
|
||||
ShareOutcome::Accepted { token, block_found }
|
||||
}
|
||||
|
||||
/// Redeem a share token (the reCAPTCHA/Turnstile "siteverify" semantics):
|
||||
/// valid exactly once, only with the correct site secret.
|
||||
pub fn verify_token(&self, secret: &str, token: &str) -> Result<u64, &'static str> {
|
||||
if !constant_time_eq::constant_time_eq(secret.as_bytes(), self.site_secret.as_bytes()) {
|
||||
return Err("invalid_secret");
|
||||
}
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
match tokens.get_mut(token) {
|
||||
None => Err("invalid_token"),
|
||||
Some(t) if t.consumed => Err("token_already_consumed"),
|
||||
Some(t) if t.created_at.elapsed() > TOKEN_TTL => {
|
||||
tokens.remove(token);
|
||||
Err("token_expired")
|
||||
}
|
||||
Some(t) => {
|
||||
t.consumed = true;
|
||||
let ts = t.created_unix;
|
||||
self.stats.lock().unwrap().tokens_verified += 1;
|
||||
Ok(ts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stats(&self) -> PoolStats {
|
||||
self.stats.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Drop expired sessions and tokens. Called periodically.
|
||||
pub fn gc(&self) {
|
||||
self.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|_, s| s.issued_at.elapsed() <= SESSION_TTL);
|
||||
self.tokens
|
||||
.lock()
|
||||
.unwrap()
|
||||
.retain(|_, t| t.created_at.elapsed() <= TOKEN_TTL);
|
||||
}
|
||||
|
||||
fn reject(&self, reason: &'static str) -> ShareOutcome {
|
||||
self.stats.lock().unwrap().shares_rejected += 1;
|
||||
ShareOutcome::Rejected(reason)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_state(
|
||||
share_difficulty: u64,
|
||||
) -> (Arc<PoolState>, tokio::sync::mpsc::Receiver<FoundBlock>) {
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(4);
|
||||
let state = PoolState::new(
|
||||
U512::from(share_difficulty),
|
||||
"secret".into(),
|
||||
tx,
|
||||
Limits::default(),
|
||||
);
|
||||
state.set_job(Job {
|
||||
job_id: "1".into(),
|
||||
header: [7u8; 32],
|
||||
network_difficulty: U512::MAX, // effectively unreachable
|
||||
});
|
||||
(state, rx)
|
||||
}
|
||||
|
||||
/// Brute-force a valid share within the session's range.
|
||||
fn solve(session: &Session) -> U512 {
|
||||
let mut nonce = session.nonce_start;
|
||||
loop {
|
||||
let (ok, _) = pow_core::is_valid_nonce(
|
||||
session.job.header,
|
||||
nonce.to_big_endian(),
|
||||
session.share_difficulty,
|
||||
);
|
||||
if ok {
|
||||
return nonce;
|
||||
}
|
||||
nonce += U512::one();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_share_and_verifies_token_once() {
|
||||
let (state, _rx) = test_state(2); // ~2 hashes per solve
|
||||
let session = state.issue_session().unwrap();
|
||||
let nonce = solve(&session);
|
||||
|
||||
let token = match state.submit_share(&session.id, nonce) {
|
||||
ShareOutcome::Accepted { token, block_found } => {
|
||||
assert!(!block_found);
|
||||
token
|
||||
}
|
||||
ShareOutcome::Rejected(r) => panic!("rejected: {r}"),
|
||||
};
|
||||
|
||||
assert!(state.verify_token("secret", &token).is_ok());
|
||||
assert_eq!(
|
||||
state.verify_token("secret", &token),
|
||||
Err("token_already_consumed")
|
||||
);
|
||||
assert_eq!(state.verify_token("wrong", &token), Err("invalid_secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_out_of_range_nonce() {
|
||||
let (state, _rx) = test_state(1); // difficulty 1: every nonce is a valid hash
|
||||
let session = state.issue_session().unwrap();
|
||||
// A nonce outside the assigned slice must be rejected even though its
|
||||
// hash trivially meets the share target.
|
||||
let outside = session.nonce_end;
|
||||
match state.submit_share(&session.id, outside) {
|
||||
ShareOutcome::Rejected("nonce_out_of_range") => {}
|
||||
other => panic!(
|
||||
"expected out-of-range rejection, got {:?}",
|
||||
matches_str(&other)
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn burns_session_after_one_submission() {
|
||||
let (state, _rx) = test_state(1);
|
||||
let session = state.issue_session().unwrap();
|
||||
let nonce = session.nonce_start;
|
||||
assert!(matches!(
|
||||
state.submit_share(&session.id, nonce),
|
||||
ShareOutcome::Accepted { .. }
|
||||
));
|
||||
match state.submit_share(&session.id, nonce) {
|
||||
ShareOutcome::Rejected("session_already_solved") => {}
|
||||
other => panic!("expected burned session, got {:?}", matches_str(&other)),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disjoint_ranges() {
|
||||
let (state, _rx) = test_state(1);
|
||||
let a = state.issue_session().unwrap();
|
||||
let b = state.issue_session().unwrap();
|
||||
assert!(a.nonce_end <= b.nonce_start || b.nonce_end <= a.nonce_start);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_found_signalled_when_share_meets_network_difficulty() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel(4);
|
||||
// Network difficulty == share difficulty == 2: any valid share is a block.
|
||||
let state = PoolState::new(U512::from(2u64), "secret".into(), tx, Limits::default());
|
||||
state.set_job(Job {
|
||||
job_id: "1".into(),
|
||||
header: [9u8; 32],
|
||||
network_difficulty: U512::from(2u64),
|
||||
});
|
||||
let session = state.issue_session().unwrap();
|
||||
let nonce = solve(&session);
|
||||
match state.submit_share(&session.id, nonce) {
|
||||
ShareOutcome::Accepted { block_found, .. } => assert!(block_found),
|
||||
ShareOutcome::Rejected(r) => panic!("rejected: {r}"),
|
||||
}
|
||||
let block = rx
|
||||
.try_recv()
|
||||
.expect("block solution should be queued upstream");
|
||||
assert_eq!(block.job_id, "1");
|
||||
assert_eq!(block.nonce, nonce);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_cap_rejects_flood() {
|
||||
let (tx, _rx) = tokio::sync::mpsc::channel(4);
|
||||
let state = PoolState::new(
|
||||
U512::from(1u64),
|
||||
"secret".into(),
|
||||
tx,
|
||||
Limits {
|
||||
max_sessions: 2,
|
||||
max_tokens: 2,
|
||||
},
|
||||
);
|
||||
state.set_job(Job {
|
||||
job_id: "1".into(),
|
||||
header: [7u8; 32],
|
||||
network_difficulty: U512::MAX,
|
||||
});
|
||||
assert!(state.issue_session().is_ok());
|
||||
assert!(state.issue_session().is_ok());
|
||||
assert!(matches!(state.issue_session(), Err("at_capacity")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropped_block_does_not_count_or_flag() {
|
||||
// Zero-capacity channel: try_send always fails, simulating a stalled
|
||||
// upstream. The share must still be accepted (the user did the work),
|
||||
// but block_found must be false and blocks_found must stay 0.
|
||||
let (tx, rx) = tokio::sync::mpsc::channel(1);
|
||||
drop(rx); // closed channel -> try_send errors
|
||||
let state = PoolState::new(U512::from(2u64), "secret".into(), tx, Limits::default());
|
||||
state.set_job(Job {
|
||||
job_id: "1".into(),
|
||||
header: [9u8; 32],
|
||||
network_difficulty: U512::from(2u64),
|
||||
});
|
||||
let session = state.issue_session().unwrap();
|
||||
let nonce = solve(&session);
|
||||
match state.submit_share(&session.id, nonce) {
|
||||
ShareOutcome::Accepted { block_found, .. } => assert!(!block_found),
|
||||
ShareOutcome::Rejected(r) => panic!("rejected: {r}"),
|
||||
}
|
||||
assert_eq!(state.stats().blocks_found, 0);
|
||||
}
|
||||
|
||||
fn matches_str(o: &ShareOutcome) -> &'static str {
|
||||
match o {
|
||||
ShareOutcome::Accepted { .. } => "accepted",
|
||||
ShareOutcome::Rejected(r) => r,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Job sources: either a QUIC connection to a real quantus-node (speaking the
|
||||
//! external miner protocol), or a standalone generator for demos/tests.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use primitive_types::U512;
|
||||
use quantus_miner_api::{
|
||||
read_message, write_message, ApiResponseStatus, MinerMessage, MiningResult,
|
||||
};
|
||||
use tokio::sync::mpsc::Receiver;
|
||||
|
||||
use crate::state::{FoundBlock, Job, PoolState};
|
||||
|
||||
/// Connect to a node as an external miner and keep the pool's job current.
|
||||
/// Solutions arriving on `solutions` are pushed upstream as JobResults.
|
||||
///
|
||||
/// Transient disconnects (node restart, network blip) clear the current job
|
||||
/// and reconnect with backoff — the pool process stays up. Only
|
||||
/// [`quic_transport::PermanentConnectError`] (bad pin / rejected auth) returns
|
||||
/// `Err` so `main` can shut down the HTTP side.
|
||||
pub async fn run_node_client(
|
||||
state: Arc<PoolState>,
|
||||
node_addr: SocketAddr,
|
||||
auth_token: String,
|
||||
tls_cert_sha256: String,
|
||||
mut solutions: Receiver<FoundBlock>,
|
||||
) -> Result<(), quic_transport::PermanentConnectError> {
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
|
||||
// A block taken off the channel but not yet acknowledged by a successful
|
||||
// upstream write. Kept across reconnects so a failed write doesn't drop
|
||||
// the block (the node may still accept it after reconnection if the job
|
||||
// hasn't moved on).
|
||||
let mut pending: Option<FoundBlock> = None;
|
||||
|
||||
loop {
|
||||
log::info!("Connecting to node at {}...", node_addr);
|
||||
match quic_transport::connect(node_addr, &auth_token, &tls_cert_sha256).await {
|
||||
Ok((connection, mut send, mut recv)) => {
|
||||
log::info!("Connected to node at {}", node_addr);
|
||||
let mut authenticated = false;
|
||||
|
||||
loop {
|
||||
// Retry any unsubmitted block before doing anything else.
|
||||
if let Some(block) = pending.take() {
|
||||
match submit_block(&mut send, &block).await {
|
||||
Ok(()) => {
|
||||
log::info!(
|
||||
"Submitted block solution to node (job {})",
|
||||
block.job_id
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to submit block solution, will retry: {}", e);
|
||||
pending = Some(block);
|
||||
break; // reconnect and retry
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
biased;
|
||||
|
||||
reason = connection.closed() => {
|
||||
let msg = reason.to_string();
|
||||
if !authenticated && msg.to_ascii_lowercase().contains("auth") {
|
||||
state.clear_job();
|
||||
return Err(quic_transport::PermanentConnectError(format!(
|
||||
"node rejected miner auth ({msg}); check --auth-token / miner-auth-token"
|
||||
)));
|
||||
}
|
||||
log::warn!("Node connection closed: {}", msg);
|
||||
break;
|
||||
}
|
||||
|
||||
// A captcha share met network difficulty: submit it.
|
||||
block = solutions.recv() => {
|
||||
if let Some(block) = block {
|
||||
// Park it in `pending`; the top of the loop
|
||||
// submits it and keeps it on write failure.
|
||||
pending = Some(block);
|
||||
}
|
||||
}
|
||||
|
||||
msg = read_message(&mut recv) => {
|
||||
match msg {
|
||||
Ok(MinerMessage::NewJob(request)) => {
|
||||
authenticated = true;
|
||||
match parse_job(&request.job_id, &request.mining_hash, &request.difficulty) {
|
||||
Ok(job) => state.set_job(job),
|
||||
Err(e) => log::warn!("Ignoring malformed job from node: {}", e),
|
||||
}
|
||||
}
|
||||
Ok(other) => {
|
||||
log::warn!("Unexpected message from node: {:?}", other);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Read error from node: {}", e);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node gone / stream dropped: stop handing out captchas on a
|
||||
// stale header until we reconnect and get a fresh NewJob.
|
||||
state.clear_job();
|
||||
if authenticated {
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if let Some(perm) = e.downcast_ref::<quic_transport::PermanentConnectError>() {
|
||||
state.clear_job();
|
||||
return Err(quic_transport::PermanentConnectError(perm.0.clone()));
|
||||
}
|
||||
log::warn!("Failed to connect to node: {}", e);
|
||||
state.clear_job();
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Reconnecting in {:?}...", reconnect_delay);
|
||||
tokio::time::sleep(reconnect_delay).await;
|
||||
reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
async fn submit_block(send: &mut quinn::SendStream, block: &FoundBlock) -> std::io::Result<()> {
|
||||
let result = MiningResult {
|
||||
status: ApiResponseStatus::Completed,
|
||||
job_id: block.job_id.clone(),
|
||||
nonce: Some(format!("{:x}", block.nonce)),
|
||||
work: Some(hex::encode(block.nonce.to_big_endian())),
|
||||
hash_count: 0,
|
||||
elapsed_time: 0.0,
|
||||
miner_id: None,
|
||||
};
|
||||
write_message(send, &MinerMessage::JobResult(result)).await
|
||||
}
|
||||
|
||||
fn parse_job(job_id: &str, mining_hash: &str, difficulty: &str) -> anyhow::Result<Job> {
|
||||
let bytes = hex::decode(mining_hash)?;
|
||||
let header: [u8; 32] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("mining_hash must be 32 bytes"))?;
|
||||
let network_difficulty =
|
||||
U512::from_dec_str(difficulty).map_err(|e| anyhow::anyhow!("bad difficulty: {:?}", e))?;
|
||||
if network_difficulty.is_zero() {
|
||||
anyhow::bail!("zero difficulty");
|
||||
}
|
||||
Ok(Job {
|
||||
job_id: job_id.to_string(),
|
||||
header,
|
||||
network_difficulty,
|
||||
})
|
||||
}
|
||||
|
||||
/// Standalone mode: synthesize a fresh random job every `interval` so the
|
||||
/// captcha works without a running node. Solutions are logged and dropped.
|
||||
pub async fn run_standalone(
|
||||
state: Arc<PoolState>,
|
||||
interval: Duration,
|
||||
mut solutions: Receiver<FoundBlock>,
|
||||
) {
|
||||
use rand::RngCore;
|
||||
let mut counter: u64 = 0;
|
||||
|
||||
loop {
|
||||
let mut header = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut header);
|
||||
counter += 1;
|
||||
state.set_job(Job {
|
||||
job_id: format!("standalone-{}", counter),
|
||||
header,
|
||||
// Unreachable "network" difficulty: standalone mode never finds blocks.
|
||||
network_difficulty: U512::MAX,
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(interval) => {}
|
||||
block = solutions.recv() => {
|
||||
if let Some(block) = block {
|
||||
log::info!("(standalone) would submit block for job {}", block.job_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ default = ["baseline", "std"]
|
||||
# std feature wires through to dependencies that have separate std/no_std builds.
|
||||
std = [
|
||||
"primitive-types/std",
|
||||
"qp-poseidon-core/std",
|
||||
]
|
||||
|
||||
# Baseline/reference implementation using BigUint-backed modular exponentiation.
|
||||
@@ -34,14 +33,9 @@ simd-poseidon2 = []
|
||||
gpu-interop = []
|
||||
|
||||
[dependencies]
|
||||
log = { workspace = true }
|
||||
primitive-types = { workspace = true }
|
||||
num-bigint = { workspace = true }
|
||||
num-traits = { workspace = true }
|
||||
qp-poseidon-core = { workspace = true }
|
||||
qpow-math = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
hex = { workspace = true }
|
||||
|
||||
@@ -1,8 +1,56 @@
|
||||
use primitive_types::U512;
|
||||
use qp_poseidon_core::{Goldilocks, Poseidon2};
|
||||
|
||||
pub use qp_poseidon_core::SPONGE_WIDTH;
|
||||
pub use qpow_math::{get_nonce_hash, is_valid_nonce, mine_range};
|
||||
|
||||
/// Job context for Bitcoin-style PoW mining with double Poseidon2 hashing
|
||||
/// Sponge state (canonical u64 felts) after absorbing the 32-byte header and the
|
||||
/// high 32 bytes of the big-endian nonce — the first two of the five Poseidon2
|
||||
/// permutations of `get_nonce_hash`. This state is identical for every nonce in a
|
||||
/// batch as long as incrementing the nonce never carries into its high 256 bits,
|
||||
/// so GPU kernels can resume the sponge from here and skip 2 of 5 permutations.
|
||||
pub fn mining_midstate(header: [u8; 32], nonce_high_be: [u8; 32]) -> [u64; SPONGE_WIDTH] {
|
||||
let poseidon2 = Poseidon2::new();
|
||||
let mut state = [Goldilocks::ZERO; SPONGE_WIDTH];
|
||||
for (i, chunk) in header.chunks_exact(4).enumerate() {
|
||||
state[i] += Goldilocks::from_u64(u32::from_le_bytes(chunk.try_into().unwrap()) as u64);
|
||||
}
|
||||
poseidon2.permute_mut(&mut state);
|
||||
for (i, chunk) in nonce_high_be.chunks_exact(4).enumerate() {
|
||||
state[i] += Goldilocks::from_u64(u32::from_le_bytes(chunk.try_into().unwrap()) as u64);
|
||||
}
|
||||
poseidon2.permute_mut(&mut state);
|
||||
state.map(|g| g.as_canonical_u64())
|
||||
}
|
||||
|
||||
/// Format a U512 in a human-readable way (scientific notation for large numbers).
|
||||
pub fn format_u512(n: U512) -> String {
|
||||
if n.is_zero() {
|
||||
return "0".to_string();
|
||||
}
|
||||
let s = format!("{}", n);
|
||||
let len = s.len();
|
||||
if len <= 12 {
|
||||
s
|
||||
} else {
|
||||
format!("{}e{}", &s[..4], len - 1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a hash rate with appropriate units (H/s, KH/s, MH/s, GH/s).
|
||||
pub fn format_hashrate(hashes_per_sec: f64) -> String {
|
||||
if hashes_per_sec >= 1_000_000_000.0 {
|
||||
format!("{:.2} GH/s", hashes_per_sec / 1_000_000_000.0)
|
||||
} else if hashes_per_sec >= 1_000_000.0 {
|
||||
format!("{:.2} MH/s", hashes_per_sec / 1_000_000.0)
|
||||
} else if hashes_per_sec >= 1_000.0 {
|
||||
format!("{:.2} KH/s", hashes_per_sec / 1_000.0)
|
||||
} else {
|
||||
format!("{:.2} H/s", hashes_per_sec)
|
||||
}
|
||||
}
|
||||
|
||||
/// Job context for PoW mining with Poseidon2 hash_squeeze_twice
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct JobContext {
|
||||
pub header: [u8; 32],
|
||||
@@ -11,7 +59,12 @@ pub struct JobContext {
|
||||
}
|
||||
|
||||
impl JobContext {
|
||||
/// Build a new context from header and difficulty
|
||||
/// Build a new context from header and difficulty.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if `difficulty` is zero (division by zero in target calculation).
|
||||
/// Callers must validate that difficulty is non-zero before calling this function.
|
||||
pub fn new(header: [u8; 32], difficulty: U512) -> Self {
|
||||
// In Bitcoin-style PoW, target = max_target / difficulty
|
||||
let max_target = U512::MAX;
|
||||
@@ -35,7 +88,7 @@ pub fn step_nonce(nonce: U512) -> U512 {
|
||||
nonce.saturating_add(U512::from(1u64))
|
||||
}
|
||||
|
||||
/// Compute hash for the current nonce using Bitcoin-style double Poseidon2
|
||||
/// Compute hash for the current nonce using Poseidon2 hash_squeeze_twice
|
||||
pub fn hash_from_nonce(ctx: &JobContext, nonce: U512) -> U512 {
|
||||
let nonce_bytes = nonce.to_big_endian();
|
||||
qpow_math::get_nonce_hash(ctx.header, nonce_bytes)
|
||||
@@ -189,4 +242,47 @@ mod tests {
|
||||
// With impossible difficulty, should not find solution
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_midstate_resumes_to_full_hash() {
|
||||
let header = [7u8; 32];
|
||||
let nonce = (U512::from(0xdeadbeefcafeu64) << 300) | U512::from(0x1234567890abcdefu64);
|
||||
let nonce_be = nonce.to_big_endian();
|
||||
|
||||
let mut state =
|
||||
mining_midstate(header, nonce_be[..32].try_into().unwrap()).map(Goldilocks::from_u64);
|
||||
let poseidon2 = Poseidon2::new();
|
||||
for (i, chunk) in nonce_be[32..].chunks_exact(4).enumerate() {
|
||||
state[i] += Goldilocks::from_u64(u32::from_le_bytes(chunk.try_into().unwrap()) as u64);
|
||||
}
|
||||
poseidon2.permute_mut(&mut state);
|
||||
state[0] += Goldilocks::ONE;
|
||||
state[1] += Goldilocks::ONE;
|
||||
poseidon2.permute_mut(&mut state);
|
||||
|
||||
let mut hash = [0u8; 64];
|
||||
for i in 0..4 {
|
||||
hash[i * 8..(i + 1) * 8].copy_from_slice(&state[i].as_canonical_u64().to_le_bytes());
|
||||
}
|
||||
poseidon2.permute_mut(&mut state);
|
||||
for i in 0..4 {
|
||||
hash[32 + i * 8..32 + (i + 1) * 8]
|
||||
.copy_from_slice(&state[i].as_canonical_u64().to_le_bytes());
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
U512::from_big_endian(&hash),
|
||||
qpow_math::get_nonce_hash(header, nonce_be)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "division by zero")]
|
||||
fn test_zero_difficulty_panics() {
|
||||
// Zero difficulty causes division by zero in target calculation.
|
||||
// Callers MUST validate difficulty > 0 before calling JobContext::new.
|
||||
let header = [7u8; 32];
|
||||
let zero_difficulty = U512::zero();
|
||||
let _ctx = JobContext::new(header, zero_difficulty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[package]
|
||||
name = "quic-transport"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "Shared QUIC client transport for connecting to a quantus-node's external-miner endpoint"
|
||||
|
||||
[dependencies]
|
||||
anyhow = { workspace = true }
|
||||
quantus-miner-api = { workspace = true }
|
||||
quinn = "0.10"
|
||||
rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = "0.10"
|
||||
tokio = { workspace = true, features = ["macros", "time"] }
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Shared QUIC client used by miner-service and pool-service to connect to a
|
||||
//! quantus-node's external-miner endpoint.
|
||||
//!
|
||||
//! The node uses a persisted self-signed certificate. Callers must supply the
|
||||
//! expected SHA-256 fingerprint of that certificate (lowercase hex); the
|
||||
//! transport rejects any other server cert.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use quantus_miner_api::{write_message, MinerMessage, MAX_MESSAGE_SIZE};
|
||||
use quinn::{ClientConfig, Endpoint};
|
||||
use rustls::client::ServerCertVerified;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Permanent misconfiguration / auth failure: callers must not reconnect-loop.
|
||||
#[derive(Debug)]
|
||||
pub struct PermanentConnectError(pub String);
|
||||
|
||||
impl std::fmt::Display for PermanentConnectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PermanentConnectError {}
|
||||
|
||||
/// Parse and validate miner auth inputs that never become valid by retrying.
|
||||
///
|
||||
/// Call this at process startup (before workers / HTTP) so a bad fingerprint
|
||||
/// or oversized token exits immediately instead of looping forever.
|
||||
pub fn validate_auth_config(auth_token: &str, tls_cert_sha256_hex: &str) -> anyhow::Result<()> {
|
||||
parse_fingerprint(tls_cert_sha256_hex)?;
|
||||
validate_ready_frame(auth_token)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse a 64-hex (optional whitespace/`:` separators) SHA-256 fingerprint.
|
||||
pub fn parse_fingerprint(tls_cert_sha256_hex: &str) -> anyhow::Result<[u8; 32]> {
|
||||
normalize_fingerprint(tls_cert_sha256_hex).ok_or_else(|| {
|
||||
PermanentConnectError("invalid TLS cert SHA-256 fingerprint (expected 64 hex chars)".into())
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_ready_frame(auth_token: &str) -> anyhow::Result<()> {
|
||||
let frame = serde_json::to_vec(&MinerMessage::Ready {
|
||||
token: auth_token.to_string(),
|
||||
})
|
||||
.map_err(|e| PermanentConnectError(format!("failed to serialize Ready frame: {e}")))?;
|
||||
if frame.len() > MAX_MESSAGE_SIZE as usize {
|
||||
return Err(PermanentConnectError(format!(
|
||||
"auth token serializes to a {}-byte Ready frame; max is {} \
|
||||
(shorten the token or avoid characters that require JSON escaping)",
|
||||
frame.len(),
|
||||
MAX_MESSAGE_SIZE
|
||||
))
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Establish a QUIC connection to the node's external-miner endpoint, open
|
||||
/// the bidirectional stream, and send the initial `Ready { token }` message.
|
||||
///
|
||||
/// `tls_cert_sha256_hex` is the lowercase hex SHA-256 of the node's miner TLS
|
||||
/// certificate DER (contents of the node's `miner-tls-cert-sha256` file).
|
||||
///
|
||||
/// Wrong tokens are rejected by the node closing with "auth failed"; that is
|
||||
/// returned as [`PermanentConnectError`] so callers do not reconnect forever.
|
||||
pub async fn connect(
|
||||
addr: SocketAddr,
|
||||
auth_token: &str,
|
||||
tls_cert_sha256_hex: &str,
|
||||
) -> anyhow::Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream)> {
|
||||
// Re-check here so library callers that skip startup validation still fail
|
||||
// closed instead of looping on a deterministic parse error.
|
||||
let expected = parse_fingerprint(tls_cert_sha256_hex)?;
|
||||
validate_ready_frame(auth_token)?;
|
||||
|
||||
let mut crypto = rustls::ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_custom_certificate_verifier(Arc::new(PinnedCertVerifier { expected }))
|
||||
.with_no_client_auth();
|
||||
// Versioned with the wire protocol (node side: MINER_ALPN). `/2` = the
|
||||
// authenticated `Ready { token }` protocol; a mismatched node/miner pair
|
||||
// fails at the TLS handshake with "no application protocol" instead of an
|
||||
// opaque auth/deserialize error.
|
||||
crypto.alpn_protocols = vec![b"quantus-miner/2".to_vec()];
|
||||
|
||||
let mut client_config = ClientConfig::new(Arc::new(crypto));
|
||||
let mut transport_config = quinn::TransportConfig::default();
|
||||
transport_config.keep_alive_interval(Some(Duration::from_secs(5)));
|
||||
transport_config.max_idle_timeout(Some(Duration::from_secs(15).try_into().unwrap()));
|
||||
client_config.transport_config(Arc::new(transport_config));
|
||||
|
||||
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
|
||||
endpoint.set_default_client_config(client_config);
|
||||
|
||||
let connection = match endpoint.connect(addr, "localhost")?.await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
let msg = e.to_string();
|
||||
// Wrong pin fails the TLS handshake deterministically.
|
||||
if msg.to_ascii_lowercase().contains("fingerprint") {
|
||||
return Err(PermanentConnectError(format!(
|
||||
"TLS certificate pin rejected ({msg}); check --tls-cert-sha256 / \
|
||||
miner-tls-cert-sha256"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
let (mut send, recv) = connection.open_bi().await?;
|
||||
write_message(
|
||||
&mut send,
|
||||
&MinerMessage::Ready {
|
||||
token: auth_token.to_string(),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Ready is fire-and-forget on the wire; the node closes immediately on a
|
||||
// bad token. Wait briefly so that surfaces as a permanent error here
|
||||
// instead of "connected" + 1s reconnect churn.
|
||||
const AUTH_REJECT_GRACE: Duration = Duration::from_secs(2);
|
||||
tokio::select! {
|
||||
reason = connection.closed() => {
|
||||
let msg = reason.to_string();
|
||||
if msg.to_ascii_lowercase().contains("auth") {
|
||||
return Err(PermanentConnectError(format!(
|
||||
"node rejected miner auth ({msg}); check --auth-token / miner-auth-token"
|
||||
))
|
||||
.into());
|
||||
}
|
||||
return Err(anyhow::anyhow!("connection closed during auth handshake: {msg}"));
|
||||
}
|
||||
_ = tokio::time::sleep(AUTH_REJECT_GRACE) => {}
|
||||
}
|
||||
|
||||
Ok((connection, send, recv))
|
||||
}
|
||||
|
||||
fn normalize_fingerprint(input: &str) -> Option<[u8; 32]> {
|
||||
let hex: String = input
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace() && *c != ':')
|
||||
.map(|c| c.to_ascii_lowercase())
|
||||
.collect();
|
||||
if hex.len() != 64 || !hex.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return None;
|
||||
}
|
||||
let mut out = [0u8; 32];
|
||||
for i in 0..32 {
|
||||
out[i] = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?;
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// Accepts only a server certificate whose SHA-256 fingerprint matches.
|
||||
struct PinnedCertVerifier {
|
||||
expected: [u8; 32],
|
||||
}
|
||||
|
||||
impl rustls::client::ServerCertVerifier for PinnedCertVerifier {
|
||||
fn verify_server_cert(
|
||||
&self,
|
||||
end_entity: &rustls::Certificate,
|
||||
_intermediates: &[rustls::Certificate],
|
||||
_server_name: &rustls::ServerName,
|
||||
_scts: &mut dyn Iterator<Item = &[u8]>,
|
||||
_ocsp_response: &[u8],
|
||||
_now: std::time::SystemTime,
|
||||
) -> Result<ServerCertVerified, rustls::Error> {
|
||||
let actual = Sha256::digest(&end_entity.0);
|
||||
if actual.as_slice() != self.expected.as_slice() {
|
||||
return Err(rustls::Error::General(
|
||||
"server certificate SHA-256 fingerprint mismatch".into(),
|
||||
));
|
||||
}
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_short_fingerprint() {
|
||||
let err = validate_auth_config("token", "nope").unwrap_err();
|
||||
assert!(
|
||||
err.downcast_ref::<PermanentConnectError>().is_some(),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_valid_fingerprint_and_token() {
|
||||
let fp = "a".repeat(64);
|
||||
validate_auth_config("deadbeef", &fp).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_token_whose_ready_frame_exceeds_limit() {
|
||||
let fp = "a".repeat(64);
|
||||
// Escape-heavy token: raw length under a naive cap can still blow the frame.
|
||||
let token = "\"".repeat(600);
|
||||
let err = validate_auth_config(&token, &fp).unwrap_err();
|
||||
assert!(
|
||||
err.downcast_ref::<PermanentConnectError>().is_some(),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.to_string().contains("Ready frame"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "solver-wasm"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "Browser WASM solver for Quantus captcha shares (raw C ABI, no wasm-bindgen)"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
primitive-types = { workspace = true }
|
||||
qpow-math = { workspace = true }
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Browser captcha solver: grinds Poseidon2 nonce hashes over a block header.
|
||||
//!
|
||||
//! Exposed as a raw C-ABI WASM module (no wasm-bindgen) so it can be built
|
||||
//! with a bare `cargo build --target wasm32-unknown-unknown` and loaded with
|
||||
//! minimal JS glue. All I/O goes through one static buffer:
|
||||
//!
|
||||
//! ```text
|
||||
//! offset 0..32 header hash (in)
|
||||
//! offset 32..96 nonce, 64-byte big-endian (in: start; out: found/next)
|
||||
//! offset 96..160 share target, 64-byte BE (in)
|
||||
//! offset 160..224 winning hash, 64-byte BE (out, only when found)
|
||||
//! ```
|
||||
//!
|
||||
//! JS calls `solve(iters)` in chunks (e.g. 5–20k iterations) to keep the
|
||||
//! worker responsive; a return of 0 means "keep going" (the nonce field has
|
||||
//! been advanced), 1 means the nonce field now holds a valid share.
|
||||
|
||||
use primitive_types::U512;
|
||||
|
||||
const HEADER_OFF: usize = 0;
|
||||
const NONCE_OFF: usize = 32;
|
||||
const TARGET_OFF: usize = 96;
|
||||
const HASH_OFF: usize = 160;
|
||||
const IO_SIZE: usize = 224;
|
||||
|
||||
static mut IO: [u8; IO_SIZE] = [0u8; IO_SIZE];
|
||||
|
||||
/// Pointer to the shared I/O buffer in WASM linear memory.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn io_ptr() -> *mut u8 {
|
||||
core::ptr::addr_of_mut!(IO) as *mut u8
|
||||
}
|
||||
|
||||
/// Size of the shared I/O buffer, for JS-side sanity checks.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn io_len() -> u32 {
|
||||
IO_SIZE as u32
|
||||
}
|
||||
|
||||
/// Grind up to `iters` nonces. Returns 1 if a share was found.
|
||||
#[no_mangle]
|
||||
pub extern "C" fn solve(iters: u32) -> u32 {
|
||||
let io = unsafe { &mut *core::ptr::addr_of_mut!(IO) };
|
||||
|
||||
let mut header = [0u8; 32];
|
||||
header.copy_from_slice(&io[HEADER_OFF..HEADER_OFF + 32]);
|
||||
let mut nonce = U512::from_big_endian(&io[NONCE_OFF..NONCE_OFF + 64]);
|
||||
let target = U512::from_big_endian(&io[TARGET_OFF..TARGET_OFF + 64]);
|
||||
|
||||
for _ in 0..iters {
|
||||
let nonce_bytes = nonce.to_big_endian();
|
||||
let hash = qpow_math::get_nonce_hash(header, nonce_bytes);
|
||||
if hash < target {
|
||||
io[NONCE_OFF..NONCE_OFF + 64].copy_from_slice(&nonce_bytes);
|
||||
io[HASH_OFF..HASH_OFF + 64].copy_from_slice(&hash.to_big_endian());
|
||||
return 1;
|
||||
}
|
||||
nonce = nonce.saturating_add(U512::one());
|
||||
}
|
||||
|
||||
// Not found: persist progress so the next chunk resumes where we stopped.
|
||||
io[NONCE_OFF..NONCE_OFF + 64].copy_from_slice(&nonce.to_big_endian());
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn solve_matches_pool_verification() {
|
||||
// Difficulty 4 -> target = MAX/4; expect a solution within a few iters.
|
||||
let header = [7u8; 32];
|
||||
let difficulty = U512::from(4u64);
|
||||
let target = U512::MAX / difficulty;
|
||||
|
||||
let io = unsafe { &mut *core::ptr::addr_of_mut!(IO) };
|
||||
io[HEADER_OFF..HEADER_OFF + 32].copy_from_slice(&header);
|
||||
let start = U512::from(1u64) << 64; // arbitrary "session range" start
|
||||
io[NONCE_OFF..NONCE_OFF + 64].copy_from_slice(&start.to_big_endian());
|
||||
io[TARGET_OFF..TARGET_OFF + 64].copy_from_slice(&target.to_big_endian());
|
||||
|
||||
let mut found = 0;
|
||||
for _ in 0..64 {
|
||||
found = solve(256);
|
||||
if found == 1 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert_eq!(found, 1, "should find a share at difficulty 4");
|
||||
|
||||
let nonce = U512::from_big_endian(&io[NONCE_OFF..NONCE_OFF + 64]);
|
||||
let (valid, hash) = qpow_math::is_valid_nonce(header, nonce.to_big_endian(), difficulty);
|
||||
assert!(
|
||||
valid,
|
||||
"pool-side verification must accept the solver's nonce"
|
||||
);
|
||||
let reported = U512::from_big_endian(&io[HASH_OFF..HASH_OFF + 64]);
|
||||
assert_eq!(hash, reported);
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
Executable
+135
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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}}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -14,8 +14,8 @@
|
||||
#
|
||||
# Notes:
|
||||
# - CPUAffinity must be a subset of the service’s allowed cpuset (e.g., cgroups).
|
||||
# - The miner logs the detected cpuset mask at startup and, when metrics are enabled,
|
||||
# exports a miner_effective_cpus gauge for dashboards.
|
||||
# - The miner counts CPUs via the process affinity mask and exports a
|
||||
# miner_effective_cpus gauge on the always-on metrics endpoint.
|
||||
# - Adjust the CPU list and worker count to match your hardware and cpuset.
|
||||
#
|
||||
# Example below assumes an 8‑way logical CPU system (IDs 0‑7).
|
||||
@@ -40,9 +40,8 @@ IOSchedulingPriority=4
|
||||
# Default time-sharing policy is fine for CPU-bound workloads when pinned.
|
||||
CPUSchedulingPolicy=other
|
||||
|
||||
# Optional: pin worker count and enable metrics (uncomment and adjust as needed).
|
||||
# Environment=MINER_ENGINE=cpu-fast
|
||||
# Environment=MINER_WORKERS=4
|
||||
# Optional: pin worker count and change the metrics port (uncomment and adjust as needed).
|
||||
# Environment=MINER_CPU_WORKERS=4
|
||||
# Environment=MINER_METRICS_PORT=9900
|
||||
# Environment=RUST_LOG=info,miner=debug
|
||||
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
# - CPUAffinity MUST be a subset of the service’s allowed cpuset (cgroup v2:
|
||||
# /sys/fs/cgroup/cpuset.cpus.effective). If your deployment already constrains
|
||||
# the miner with a cpuset, you can omit CPUAffinity here to inherit that mask.
|
||||
# - The miner logs the detected cpuset mask at startup (debug) and, when metrics
|
||||
# are enabled, exports miner_effective_cpus for dashboards.
|
||||
# - Set MINER_WORKERS to match the number of CPUs in your affinity mask if you want
|
||||
# full utilization; otherwise the miner default (50% of effective CPUs) is used
|
||||
# when --workers is omitted.
|
||||
# - The miner counts CPUs via the process affinity mask and exports
|
||||
# miner_effective_cpus on the always-on metrics endpoint.
|
||||
# - Set MINER_CPU_WORKERS to match the number of CPUs in your affinity mask if you
|
||||
# want full utilization; otherwise the miner auto-detects when --cpu-workers is
|
||||
# omitted.
|
||||
|
||||
[Service]
|
||||
# Bind miner to ALL logical CPUs intended for mining. EDIT this to match your host.
|
||||
@@ -46,10 +46,9 @@ IOSchedulingPriority=0
|
||||
TasksMax=infinity
|
||||
MemoryMax=infinity
|
||||
|
||||
# Optional: pin worker count and enable metrics (uncomment and adjust).
|
||||
# Ensure MINER_WORKERS matches the size of CPUAffinity (or the cpuset mask) to use all CPUs.
|
||||
# Environment=MINER_ENGINE=cpu-fast
|
||||
# Environment=MINER_WORKERS=16
|
||||
# Optional: pin worker count and change the metrics port (uncomment and adjust).
|
||||
# Ensure MINER_CPU_WORKERS matches the size of CPUAffinity (or the cpuset mask) to use all CPUs.
|
||||
# Environment=MINER_CPU_WORKERS=16
|
||||
# Environment=MINER_METRICS_PORT=9900
|
||||
# Environment=RUST_LOG=info,miner=debug
|
||||
|
||||
@@ -64,5 +63,5 @@ MemoryMax=infinity
|
||||
# and compare with:
|
||||
# cat /sys/fs/cgroup/cpuset.cpus.effective
|
||||
#
|
||||
# - The service logs the cpuset mask at startup and the metrics exporter (if enabled)
|
||||
# exposes miner_effective_cpus for observability.
|
||||
# - The always-on metrics exporter exposes miner_effective_cpus for
|
||||
# observability of the detected CPU count (MINER_METRICS_PORT changes the port).
|
||||
|
||||
@@ -15,14 +15,24 @@
|
||||
#
|
||||
# Notes:
|
||||
# - The miner CLI supports environment variables for all flags (see README):
|
||||
# MINER_PORT, MINER_METRICS_PORT, MINER_WORKERS, MINER_ENGINE,
|
||||
# MINER_PROGRESS_CHUNK_MS,
|
||||
# MINER_MANIP_SOLVED_BLOCKS, MINER_MANIP_BASE_DELAY_NS,
|
||||
# MINER_MANIP_STEP_BATCH, MINER_MANIP_THROTTLE_CAP.
|
||||
# MINER_NODE_ADDR, MINER_AUTH_TOKEN_FILE (or MINER_AUTH_TOKEN),
|
||||
# MINER_TLS_CERT_SHA256_FILE (or MINER_TLS_CERT_SHA256),
|
||||
# MINER_CPU_WORKERS, MINER_GPU_DEVICES, MINER_GPU_BATCH_SIZE,
|
||||
# MINER_CPU_BATCH_SIZE, MINER_GPU_THROTTLE_MS, MINER_METRICS_PORT,
|
||||
# MINER_ALLOW_INTEGRATED, MINER_VERBOSE.
|
||||
# This unit relies on those env vars (set in /etc/default|/etc/sysconfig) so
|
||||
# ExecStart can remain stable and simple.
|
||||
# - The service logs the detected cpuset mask (if any) and emits a
|
||||
# miner_effective_cpus metric when metrics are enabled.
|
||||
# - The node requires miner auth: MINER_AUTH_TOKEN_FILE and
|
||||
# MINER_TLS_CERT_SHA256_FILE (or their inline variants) are mandatory —
|
||||
# the miner exits at startup without them. Copy the node's
|
||||
# miner-auth-token and miner-tls-cert-sha256 files somewhere this service
|
||||
# can read (e.g. /etc/quantus-miner/): ProtectHome=true below blocks
|
||||
# paths under /home and /root, where the node's base path usually lives.
|
||||
# - The miner counts CPUs via the process affinity mask (so CPUAffinity=
|
||||
# is respected) and emits a miner_effective_cpus metric.
|
||||
# - The Prometheus exporter is ALWAYS on and binds plaintext HTTP on
|
||||
# 0.0.0.0:9900 by default; MINER_METRICS_PORT only changes the port.
|
||||
# Firewall the port if the host is reachable from untrusted networks.
|
||||
|
||||
[Unit]
|
||||
Description=Quantus External Miner Service
|
||||
@@ -53,7 +63,9 @@ Environment=RUST_LOG=info
|
||||
|
||||
# Launch the miner. All configuration should be passed via environment vars
|
||||
# (preferred) or via EXTRA_MINER_FLAGS in the env file for additional arguments.
|
||||
ExecStart=/usr/local/bin/quantus-miner $EXTRA_MINER_FLAGS
|
||||
# MINER_AUTH_TOKEN_FILE and MINER_TLS_CERT_SHA256_FILE must be set in the env
|
||||
# file (see readme.md) or the miner exits immediately.
|
||||
ExecStart=/usr/local/bin/quantus-miner serve $EXTRA_MINER_FLAGS
|
||||
|
||||
# Graceful shutdown and restart behavior
|
||||
Restart=always
|
||||
|
||||
+74
-37
@@ -4,7 +4,7 @@ This directory contains a production-friendly systemd unit and drop-in overrides
|
||||
|
||||
Contents
|
||||
- quantus-miner.service
|
||||
- A baseline unit file that runs the miner with journald logging and sensible security hardening.
|
||||
- A service unit file that runs the miner with journald logging and sensible security hardening.
|
||||
- Uses environment variables for configuration (preferred for stable ExecStart).
|
||||
- overrides/
|
||||
- 10-shared-hardware.conf
|
||||
@@ -18,6 +18,18 @@ Prerequisites
|
||||
- The quantus-miner binary installed at /usr/local/bin/quantus-miner (or adjust ExecStart).
|
||||
- A service account (recommended):
|
||||
- sudo useradd --system --no-create-home --shell /usr/sbin/nologin quantus
|
||||
- The node's miner auth token and TLS cert fingerprint (required since the node
|
||||
authenticates miners). The node creates both on first start with
|
||||
--miner-listen-port and logs their paths (default:
|
||||
`<base-path>/chains/<chain>/miner-auth-token` and
|
||||
`<base-path>/chains/<chain>/miner-tls-cert-sha256`). Copy them where the
|
||||
service can read them — the unit sets ProtectHome=true, so paths under /home
|
||||
or /root are not readable:
|
||||
- sudo install -d -m 0755 /etc/quantus-miner
|
||||
- sudo install -m 0640 -o root -g quantus \
|
||||
"<base-path>/chains/<chain>/miner-auth-token" /etc/quantus-miner/miner-auth-token
|
||||
- sudo install -m 0644 \
|
||||
"<base-path>/chains/<chain>/miner-tls-cert-sha256" /etc/quantus-miner/miner-tls-cert-sha256
|
||||
|
||||
Install (unit)
|
||||
1) Copy the service file
|
||||
@@ -26,21 +38,22 @@ Install (unit)
|
||||
2) Create a writable working directory (managed by systemd via StateDirectory)
|
||||
sudo install -d -o quantus -g quantus /var/lib/quantus-miner
|
||||
|
||||
3) (Optional) Provide environment variables
|
||||
3) Provide environment variables (auth vars are REQUIRED)
|
||||
- Debian/Ubuntu: sudoedit /etc/default/quantus-miner
|
||||
- RHEL/CentOS/Fed: sudoedit /etc/sysconfig/quantus-miner
|
||||
|
||||
Common variables (examples):
|
||||
MINER_ENGINE=cpu-fast
|
||||
MINER_PORT=9833
|
||||
MINER_METRICS_PORT=9900 # enable Prometheus exporter
|
||||
MINER_WORKERS=4 # leave unset to use default (50% of effective CPUs)
|
||||
MINER_PROGRESS_CHUNK_MS=2000
|
||||
# Throttling engine (cpu-chain-manipulator) knobs:
|
||||
# MINER_MANIP_SOLVED_BLOCKS=0
|
||||
# MINER_MANIP_BASE_DELAY_NS=500000
|
||||
# MINER_MANIP_STEP_BATCH=10000
|
||||
# MINER_MANIP_THROTTLE_CAP=0
|
||||
Common variables (examples). Note: systemd EnvironmentFile= does not strip
|
||||
inline comments — keep each assignment on its own line with nothing after
|
||||
the value.
|
||||
MINER_NODE_ADDR=127.0.0.1:9833
|
||||
# Required: node auth token + TLS cert pin (see Prerequisites)
|
||||
MINER_AUTH_TOKEN_FILE=/etc/quantus-miner/miner-auth-token
|
||||
MINER_TLS_CERT_SHA256_FILE=/etc/quantus-miner/miner-tls-cert-sha256
|
||||
# CPU worker threads; leave unset to auto-detect (~50% of available CPUs)
|
||||
MINER_CPU_WORKERS=4
|
||||
MINER_GPU_DEVICES=0
|
||||
# Prometheus exporter port (the exporter is always on; this only changes the port)
|
||||
MINER_METRICS_PORT=9900
|
||||
# Extra CLI flags (kept stable ExecStart):
|
||||
# EXTRA_MINER_FLAGS="--some-future-flag value"
|
||||
|
||||
@@ -65,31 +78,44 @@ Dedicated hardware (miner only)
|
||||
sudo systemctl restart quantus-miner
|
||||
|
||||
Configuration reference (environment variables)
|
||||
- MINER_ENGINE
|
||||
- cpu-fast (default), cpu-baseline, cpu-chain-manipulator
|
||||
- gpu-cuda, gpu-opencl are placeholders; selecting them fails with a clear error.
|
||||
- MINER_PORT
|
||||
- HTTP API port (default 9833)
|
||||
- MINER_NODE_ADDR
|
||||
- Address of the node's miner QUIC endpoint (default 127.0.0.1:9833).
|
||||
- MINER_AUTH_TOKEN_FILE (required, or MINER_AUTH_TOKEN inline)
|
||||
- Path to a copy of the node's miner-auth-token file. The file variant is
|
||||
preferred so the secret stays off the command line and out of `systemctl show`.
|
||||
- MINER_TLS_CERT_SHA256_FILE (required, or MINER_TLS_CERT_SHA256 inline)
|
||||
- Path to a copy of the node's miner-tls-cert-sha256 file (the fingerprint is
|
||||
also printed in the node's startup logs).
|
||||
- MINER_CPU_WORKERS
|
||||
- CPU worker threads. If unset, auto-detected.
|
||||
- MINER_GPU_DEVICES
|
||||
- Number of GPU devices to use. If unset, auto-detected.
|
||||
- MINER_GPU_BATCH_SIZE / MINER_CPU_BATCH_SIZE
|
||||
- Nonces/hashes per cancellation check (defaults 1000000 / 10000).
|
||||
- MINER_GPU_THROTTLE_MS
|
||||
- Delay between GPU batches in milliseconds (default 0 = no throttle).
|
||||
- MINER_METRICS_PORT
|
||||
- Enable Prometheus exporter when set (e.g., 9900). If unset, metrics exporter is disabled.
|
||||
- MINER_WORKERS
|
||||
- Worker threads (logical CPUs). If unset, defaults to ~50% of effective CPUs (clamped to [1, effective-1]).
|
||||
- MINER_PROGRESS_CHUNK_MS
|
||||
- Target milliseconds for per-thread progress updates (default 2000ms).
|
||||
- Throttling engine (cpu-chain-manipulator) knobs
|
||||
- MINER_MANIP_SOLVED_BLOCKS, MINER_MANIP_BASE_DELAY_NS, MINER_MANIP_STEP_BATCH, MINER_MANIP_THROTTLE_CAP
|
||||
- Prometheus exporter port (default 9900). The exporter is ALWAYS on and
|
||||
binds plaintext HTTP on all interfaces (0.0.0.0); this variable only
|
||||
changes the port — there is no disable or loopback-only option. Firewall
|
||||
the port or restrict it to your monitoring network.
|
||||
- MINER_ALLOW_INTEGRATED
|
||||
- Allow integrated GPUs even when discrete GPUs are present.
|
||||
- EXTRA_MINER_FLAGS
|
||||
- Optional extra CLI flags appended to ExecStart.
|
||||
|
||||
CPU affinity, cpusets, and workers
|
||||
- The miner detects the effective CPU capacity (logical CPUs) visible to the process by preferring cgroup v2 cpuset (cpuset.cpus.effective), falling back to v1, else using all logical CPUs.
|
||||
- At startup (debug level), the miner logs the detected cpuset mask (if any).
|
||||
- A Prometheus gauge miner_effective_cpus is emitted (when metrics are enabled) with the effective count for dashboards/alerts.
|
||||
- If --workers (or MINER_WORKERS) exceeds effective CPUs, it is clamped and a warning is logged.
|
||||
- If omitted, the miner defaults to ~50% of effective CPUs (but always at least 1 and less than or equal to effective-1).
|
||||
- The miner counts available CPUs via the process CPU affinity mask (num_cpus),
|
||||
so a systemd CPUAffinity= setting (or a cgroup cpuset) is reflected in the count.
|
||||
- If MINER_CPU_WORKERS is unset, the miner uses ~50% of the detected CPUs
|
||||
(at least 1) and logs the choice at startup ("Auto-detected N CPU workers").
|
||||
- An explicit MINER_CPU_WORKERS value is used as-is — it is NOT clamped to the
|
||||
affinity mask, so an oversized value oversubscribes the pinned CPUs.
|
||||
- A Prometheus gauge miner_effective_cpus is emitted with the detected count for dashboards/alerts.
|
||||
- When pinning CPUAffinity at the systemd level:
|
||||
- Ensure CPUAffinity is a subset of the cgroup cpuset mask.
|
||||
- Consider setting MINER_WORKERS to match the number of CPUs in the affinity mask if you want full utilization, or rely on the default 50% policy.
|
||||
- Set MINER_CPU_WORKERS to the number of CPUs in the affinity mask for full
|
||||
utilization, or leave it unset for the ~50% default.
|
||||
|
||||
Security hardening (in the unit)
|
||||
- NoNewPrivileges=true
|
||||
@@ -101,6 +127,9 @@ Security hardening (in the unit)
|
||||
- RestrictSUIDSGID=true
|
||||
- SystemCallFilter=@system-service
|
||||
Adjust or relax as needed for your environment.
|
||||
Note: the Prometheus exporter always listens on 0.0.0.0:<MINER_METRICS_PORT>
|
||||
(default 9900) — the unit cannot disable it or bind it to loopback. Firewall
|
||||
the port if the host is reachable from untrusted networks.
|
||||
|
||||
Validation and troubleshooting
|
||||
- Check service status and logs:
|
||||
@@ -110,20 +139,28 @@ Validation and troubleshooting
|
||||
taskset -cp "$pid"
|
||||
- Verify cpuset mask (cgroup v2):
|
||||
cat /sys/fs/cgroup/cpuset.cpus.effective
|
||||
- Metrics:
|
||||
- If MINER_METRICS_PORT is set, curl http://127.0.0.1:<port>/metrics
|
||||
- Metrics (always on, default port 9900):
|
||||
- curl http://127.0.0.1:${MINER_METRICS_PORT:-9900}/metrics
|
||||
- Look for miner_effective_cpus and per-job/thread metrics.
|
||||
- Common pitfalls:
|
||||
- ExecStart path wrong (ensure /usr/local/bin/quantus-miner exists and is executable).
|
||||
- Service user/group missing (create quantus or adjust User/Group).
|
||||
- Miner exits immediately with "miner auth token required" / "TLS cert fingerprint required":
|
||||
set MINER_AUTH_TOKEN_FILE and MINER_TLS_CERT_SHA256_FILE in the env file (see Prerequisites).
|
||||
- Auth/TLS files unreadable: ProtectHome=true blocks /home and /root; copy the
|
||||
files to /etc/quantus-miner/ and make them readable by the quantus user.
|
||||
- Node rejects the miner after the node's base path changed or its credential
|
||||
files (miner-auth-token, miner-tls-cert.der/-key.der) were deleted and
|
||||
regenerated: re-copy both files and restart. (A plain purge-chain does NOT
|
||||
rotate them — it removes only the database, so the same token and cert are
|
||||
reloaded on the next start.)
|
||||
- CPUAffinity not a subset of the cgroup cpuset (adjust cpuset or affinity).
|
||||
- MINER_ENGINE set to gpu-* (currently unimplemented, exits with clear error).
|
||||
- Insufficient permissions to write WorkingDirectory (systemd StateDirectory creates /var/lib/quantus-miner with correct ownership).
|
||||
|
||||
Operational tips
|
||||
- For shared machines: prefer 10-shared-hardware.conf and leave MINER_WORKERS unset (defaults to ~50%).
|
||||
- For dedicated machines: use 20-dedicated-hardware.conf and set MINER_WORKERS to the number of CPUs in CPUAffinity (or omit CPUAffinity to inherit cpuset).
|
||||
- Use RUST_LOG=info,miner=debug temporarily to verify startup detection (cpuset mask, effective CPUs) and to observe mining loop behavior; then turn back down to reduce log volume.
|
||||
- For shared machines: prefer 10-shared-hardware.conf and leave MINER_CPU_WORKERS unset (auto-detect).
|
||||
- For dedicated machines: use 20-dedicated-hardware.conf and set MINER_CPU_WORKERS to the number of CPUs in CPUAffinity (or omit CPUAffinity to inherit cpuset).
|
||||
- Use RUST_LOG=info,miner=debug temporarily to verify startup detection (worker auto-detection, GPU discovery) and to observe mining loop behavior; then turn back down to reduce log volume.
|
||||
|
||||
Support
|
||||
- Repository: https://github.com/Quantus-Network/quantus-miner
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
channel = "1.93.0"
|
||||
components = ["clippy", "rustfmt"]
|
||||
profile = "minimal"
|
||||
|
||||
@@ -1,207 +0,0 @@
|
||||
use primitive_types::U512;
|
||||
use quantus_miner::*;
|
||||
use resonance_miner_api::*;
|
||||
use warp::test::request;
|
||||
use warp::Filter; // Import shared API types
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mine_endpoint() {
|
||||
let state = MiningState::new();
|
||||
let state_clone = state.clone();
|
||||
let state_filter = warp::any().map(move || state_clone.clone());
|
||||
|
||||
let mine_route = warp::post()
|
||||
.and(warp::path("mine"))
|
||||
.and(warp::body::json())
|
||||
.and(state_filter.clone())
|
||||
.and_then(handle_mine_request);
|
||||
|
||||
// Test valid request
|
||||
let valid_request = MiningRequest {
|
||||
job_id: "test".to_string(),
|
||||
mining_hash: "a".repeat(64),
|
||||
distance_threshold: "1000".to_string(),
|
||||
nonce_start: "0".repeat(128),
|
||||
nonce_end: "1".repeat(128),
|
||||
};
|
||||
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/mine")
|
||||
.json(&valid_request)
|
||||
.reply(&mine_route)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
|
||||
assert_eq!(body.status, ApiResponseStatus::Accepted);
|
||||
assert_eq!(body.job_id, "test");
|
||||
|
||||
// Test duplicate job ID
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/mine")
|
||||
.json(&valid_request)
|
||||
.reply(&mine_route)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), 409);
|
||||
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
|
||||
assert_eq!(body.status, ApiResponseStatus::Error);
|
||||
assert!(body.message.is_some());
|
||||
assert!(body.message.unwrap().contains("Job already exists"));
|
||||
|
||||
// Test invalid request
|
||||
let invalid_request = MiningRequest {
|
||||
job_id: "".to_string(), // Empty job ID
|
||||
mining_hash: "a".repeat(64),
|
||||
distance_threshold: "1000".to_string(),
|
||||
nonce_start: "0".repeat(128),
|
||||
nonce_end: "1".repeat(128),
|
||||
};
|
||||
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/mine")
|
||||
.json(&invalid_request)
|
||||
.reply(&mine_route)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), 400);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_result_endpoint() {
|
||||
let state = MiningState::new();
|
||||
let state_clone = state.clone();
|
||||
let state_filter = warp::any().map(move || state_clone.clone());
|
||||
|
||||
// First create a job
|
||||
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
|
||||
state.add_job("test".to_string(), job).await.unwrap();
|
||||
|
||||
let result_route = warp::get()
|
||||
.and(warp::path("result"))
|
||||
.and(warp::path::param())
|
||||
.and(state_filter.clone())
|
||||
.and_then(handle_result_request);
|
||||
|
||||
// Test existing job
|
||||
let resp = request()
|
||||
.method("GET")
|
||||
.path("/result/test")
|
||||
.reply(&result_route)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
|
||||
assert_eq!(body.status, ApiResponseStatus::Running);
|
||||
assert_eq!(body.job_id, "test");
|
||||
|
||||
// Test non-existent job
|
||||
let resp = request()
|
||||
.method("GET")
|
||||
.path("/result/nonexistent")
|
||||
.reply(&result_route)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), 404);
|
||||
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
|
||||
assert_eq!(body.status, ApiResponseStatus::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_cancel_endpoint() {
|
||||
let state = MiningState::new();
|
||||
let state_clone = state.clone();
|
||||
let state_filter = warp::any().map(move || state_clone.clone());
|
||||
|
||||
// First create a job
|
||||
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
|
||||
state.add_job("test".to_string(), job).await.unwrap();
|
||||
|
||||
let cancel_route = warp::post()
|
||||
.and(warp::path("cancel"))
|
||||
.and(warp::path::param())
|
||||
.and(state_filter.clone())
|
||||
.and_then(handle_cancel_request);
|
||||
|
||||
// Test cancel existing job
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/cancel/test")
|
||||
.reply(&cancel_route)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
|
||||
assert_eq!(body.status, ApiResponseStatus::Cancelled);
|
||||
assert_eq!(body.job_id, "test");
|
||||
|
||||
// Test cancel non-existent job
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/cancel/nonexistent")
|
||||
.reply(&cancel_route)
|
||||
.await;
|
||||
|
||||
assert_eq!(resp.status(), 404);
|
||||
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
|
||||
assert_eq!(body.status, ApiResponseStatus::NotFound);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_concurrent_access() {
|
||||
let state = MiningState::new();
|
||||
let state_clone = state.clone();
|
||||
let state_filter = warp::any().map(move || state_clone.clone());
|
||||
|
||||
// Create multiple jobs concurrently
|
||||
let mut handles = vec![];
|
||||
for i in 0..10 {
|
||||
let state = state.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
|
||||
state.add_job(format!("test{}", i), job).await
|
||||
});
|
||||
handles.push(handle);
|
||||
}
|
||||
|
||||
// Wait for all jobs to be created
|
||||
for handle in handles {
|
||||
assert!(handle.await.unwrap().is_ok());
|
||||
}
|
||||
|
||||
// Verify all jobs exist
|
||||
for i in 0..10 {
|
||||
assert!(state.get_job(&format!("test{}", i)).await.is_some());
|
||||
}
|
||||
|
||||
// Test concurrent result checks
|
||||
let result_route = warp::get()
|
||||
.and(warp::path("result"))
|
||||
.and(warp::path::param())
|
||||
.and(state_filter.clone())
|
||||
.and_then(handle_result_request);
|
||||
|
||||
let mut result_handles = vec![];
|
||||
for i in 0..10 {
|
||||
let route = result_route.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
request()
|
||||
.method("GET")
|
||||
.path(&format!("/result/test{}", i))
|
||||
.reply(&route)
|
||||
.await
|
||||
});
|
||||
result_handles.push(handle);
|
||||
}
|
||||
|
||||
// Verify all result checks succeed
|
||||
for handle in result_handles {
|
||||
let resp = handle.await.unwrap();
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
|
||||
assert_eq!(body.status, ApiResponseStatus::Running);
|
||||
}
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
// Verifying that our API spec is in sync with the implementation
|
||||
// Manually generated from the OpenAPI spec but it should still flag when we get out of sync and need to update.
|
||||
|
||||
use primitive_types::U512;
|
||||
use quantus_miner::*;
|
||||
use resonance_miner_api::*; // Use the shared API types
|
||||
use serde_json::json;
|
||||
use warp::test::request;
|
||||
use warp::Filter; // For sending custom/invalid JSON
|
||||
|
||||
// Helper function to setup the routes with a fresh state for each test
|
||||
fn setup_routes() -> (
|
||||
MiningState,
|
||||
impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone,
|
||||
) {
|
||||
let state = MiningState::new();
|
||||
let state_clone = state.clone();
|
||||
let state_filter = warp::any().map(move || state_clone.clone());
|
||||
|
||||
let mine_route = warp::post()
|
||||
.and(warp::path("mine"))
|
||||
.and(warp::body::json::<MiningRequest>()) // Expect valid MiningRequest
|
||||
.and(state_filter.clone())
|
||||
.and_then(handle_mine_request);
|
||||
|
||||
let result_route = warp::get()
|
||||
.and(warp::path("result"))
|
||||
.and(warp::path::param::<String>())
|
||||
.and(state_filter.clone())
|
||||
.and_then(handle_result_request);
|
||||
|
||||
let cancel_route = warp::post()
|
||||
.and(warp::path("cancel"))
|
||||
.and(warp::path::param::<String>())
|
||||
.and(state_filter.clone())
|
||||
.and_then(handle_cancel_request);
|
||||
|
||||
let routes = mine_route.or(result_route).or(cancel_route);
|
||||
(state, routes)
|
||||
}
|
||||
|
||||
// --- /mine Endpoint Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn spec_mine_valid_request() {
|
||||
let (_state, routes) = setup_routes();
|
||||
let valid_req = MiningRequest {
|
||||
job_id: "job-valid-1".to_string(),
|
||||
mining_hash: "a".repeat(64),
|
||||
distance_threshold: "12345".to_string(),
|
||||
nonce_start: "0".repeat(128),
|
||||
nonce_end: "f".repeat(128),
|
||||
};
|
||||
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/mine")
|
||||
.json(&valid_req)
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Spec: 200 OK, response matches MiningResponseAccepted
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
200,
|
||||
"Spec requires 200 OK for valid /mine request"
|
||||
);
|
||||
let body: MiningResponse = serde_json::from_slice(resp.body())
|
||||
.expect("Valid response should deserialize to MiningResponse");
|
||||
assert_eq!(
|
||||
body.status,
|
||||
ApiResponseStatus::Accepted,
|
||||
"Spec requires status 'accepted'"
|
||||
);
|
||||
assert_eq!(body.job_id, valid_req.job_id);
|
||||
assert!(
|
||||
body.message.is_none(),
|
||||
"Spec shows no message field for success"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spec_mine_duplicate_job_id() {
|
||||
let (state, routes) = setup_routes();
|
||||
let valid_req = MiningRequest {
|
||||
job_id: "job-duplicate-1".to_string(),
|
||||
mining_hash: "b".repeat(64),
|
||||
distance_threshold: "54321".to_string(),
|
||||
nonce_start: "1".repeat(128),
|
||||
nonce_end: "e".repeat(128),
|
||||
};
|
||||
|
||||
// Add the job first
|
||||
let job = MiningJob::new(
|
||||
hex::decode(valid_req.mining_hash.clone())
|
||||
.unwrap()
|
||||
.try_into()
|
||||
.unwrap(),
|
||||
U512::from_dec_str(&valid_req.distance_threshold).unwrap(),
|
||||
U512::from_str_radix(&valid_req.nonce_start, 16).unwrap(),
|
||||
U512::from_str_radix(&valid_req.nonce_end, 16).unwrap(),
|
||||
);
|
||||
state.add_job(valid_req.job_id.clone(), job).await.unwrap();
|
||||
|
||||
// Try to submit again
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/mine")
|
||||
.json(&valid_req)
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Spec: 409 Conflict, response matches MiningResponseError
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
409,
|
||||
"Spec requires 409 Conflict for duplicate job ID"
|
||||
);
|
||||
let body: MiningResponse = serde_json::from_slice(resp.body())
|
||||
.expect("Valid error response should deserialize to MiningResponse");
|
||||
assert_eq!(
|
||||
body.status,
|
||||
ApiResponseStatus::Error,
|
||||
"Spec requires status 'error'"
|
||||
);
|
||||
assert!(
|
||||
body.message.is_some(),
|
||||
"Spec requires message field for error"
|
||||
);
|
||||
assert!(body.message.unwrap().contains("Job already exists"));
|
||||
assert_eq!(body.job_id, valid_req.job_id);
|
||||
}
|
||||
|
||||
// Test cases for invalid formats (hash length, nonce length, difficulty non-numeric)
|
||||
// These should ideally result in 400 Bad Request
|
||||
#[tokio::test]
|
||||
async fn spec_mine_invalid_hash_format() {
|
||||
let (_state, routes) = setup_routes();
|
||||
let invalid_req = json!({
|
||||
"job_id": "job-invalid-hash",
|
||||
"mining_hash": "a".repeat(63), // Too short
|
||||
"distance_threshold": "1000",
|
||||
"nonce_start": "0".repeat(128),
|
||||
"nonce_end": "f".repeat(128)
|
||||
});
|
||||
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/mine")
|
||||
.json(&invalid_req)
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Spec: 400 Bad Request, response matches MiningResponseError
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"Spec requires 400 Bad Request for invalid hash format"
|
||||
);
|
||||
let body: MiningResponse =
|
||||
serde_json::from_slice(resp.body()).expect("Error response should deserialize");
|
||||
assert_eq!(body.status, ApiResponseStatus::Error);
|
||||
assert!(body.message.is_some());
|
||||
assert!(body
|
||||
.message
|
||||
.unwrap()
|
||||
.contains("mining_hash must be 64 hex characters"));
|
||||
}
|
||||
|
||||
// Add similar tests for invalid nonce_start, nonce_end, difficulty format...
|
||||
|
||||
#[tokio::test]
|
||||
async fn spec_mine_missing_required_field() {
|
||||
let (_state, routes) = setup_routes();
|
||||
// Missing 'difficulty'
|
||||
let invalid_req = json!({
|
||||
"job_id": "job-missing-field",
|
||||
"mining_hash": "c".repeat(64),
|
||||
"nonce_start": "0".repeat(128),
|
||||
"nonce_end": "f".repeat(128)
|
||||
});
|
||||
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path("/mine")
|
||||
.json(&invalid_req)
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Warp's default JSON body deserialization handles missing fields
|
||||
// It should return 400 Bad Request based on warp::body::json()
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
400,
|
||||
"Spec requires 400 Bad Request for missing required field"
|
||||
);
|
||||
// The body might be a plain text error from Warp, not necessarily our JSON structure
|
||||
// We can check if the body indicates a deserialization error
|
||||
let body_bytes = resp.body();
|
||||
let body_string = String::from_utf8_lossy(body_bytes);
|
||||
assert!(
|
||||
body_string.contains("Failed to deserialize") || body_string.contains("missing field"),
|
||||
"Response body should indicate deserialization failure"
|
||||
);
|
||||
}
|
||||
|
||||
// --- /result/{job_id} Endpoint Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn spec_result_job_running() {
|
||||
let (state, routes) = setup_routes();
|
||||
let job_id = "job-running-1".to_string();
|
||||
// Add a job
|
||||
let job = MiningJob::new([1u8; 32], U512::from(1000), U512::from(0), U512::from(1000));
|
||||
state.add_job(job_id.clone(), job).await.unwrap();
|
||||
|
||||
let resp = request()
|
||||
.method("GET")
|
||||
.path(&format!("/result/{}", job_id))
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Spec: 200 OK, response matches MiningResult with status 'running'
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: MiningResult =
|
||||
serde_json::from_slice(resp.body()).expect("Should deserialize to MiningResult");
|
||||
assert_eq!(body.status, ApiResponseStatus::Running);
|
||||
assert_eq!(body.job_id, job_id);
|
||||
assert!(body.nonce.is_none()); // Should be none initially
|
||||
assert!(body.work.is_none()); // No work yet
|
||||
// hash_count and elapsed_time should be present
|
||||
// assert_eq!(body.hash_count, 0); // Initial state
|
||||
// assert!(body.elapsed_time >= 0.0);
|
||||
}
|
||||
|
||||
// Add tests for spec_result_job_completed, spec_result_job_failed (need to manipulate state)
|
||||
// Need to carefully simulate a completed/failed state or run miner loop briefly
|
||||
|
||||
#[tokio::test]
|
||||
async fn spec_result_job_not_found() {
|
||||
let (_state, routes) = setup_routes();
|
||||
let job_id = "job-not-found-1";
|
||||
|
||||
let resp = request()
|
||||
.method("GET")
|
||||
.path(&format!("/result/{}", job_id))
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Spec: 404 Not Found, response matches MiningResultNotFound
|
||||
assert_eq!(resp.status(), 404);
|
||||
let body: MiningResult = serde_json::from_slice(resp.body())
|
||||
.expect("Should deserialize to MiningResult (NotFound variant)");
|
||||
assert_eq!(body.status, ApiResponseStatus::NotFound);
|
||||
assert_eq!(body.job_id, job_id);
|
||||
assert!(body.nonce.is_none());
|
||||
assert!(body.work.is_none());
|
||||
assert_eq!(body.hash_count, 0);
|
||||
assert_eq!(body.elapsed_time, 0.0);
|
||||
}
|
||||
|
||||
// --- /cancel/{job_id} Endpoint Tests ---
|
||||
|
||||
#[tokio::test]
|
||||
async fn spec_cancel_existing_job() {
|
||||
let (state, routes) = setup_routes();
|
||||
let job_id = "job-cancel-1".to_string();
|
||||
let job = MiningJob::new([2u8; 32], U512::from(1000), U512::from(0), U512::from(1000));
|
||||
state.add_job(job_id.clone(), job).await.unwrap();
|
||||
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path(&format!("/cancel/{}", job_id))
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Spec: 200 OK, response matches MiningResponseCancelled
|
||||
assert_eq!(resp.status(), 200);
|
||||
let body: MiningResponse =
|
||||
serde_json::from_slice(resp.body()).expect("Should deserialize to MiningResponse");
|
||||
assert_eq!(body.status, ApiResponseStatus::Cancelled);
|
||||
assert_eq!(body.job_id, job_id);
|
||||
assert!(body.message.is_none());
|
||||
|
||||
// Verify job is actually cancelled
|
||||
let job = state.get_job(&job_id).await.unwrap();
|
||||
assert_eq!(job.status, JobStatus::Cancelled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spec_cancel_non_existent_job() {
|
||||
let (_state, routes) = setup_routes();
|
||||
let job_id = "job-cancel-not-found-1";
|
||||
|
||||
let resp = request()
|
||||
.method("POST")
|
||||
.path(&format!("/cancel/{}", job_id))
|
||||
.reply(&routes)
|
||||
.await;
|
||||
|
||||
// Spec: 404 Not Found, response matches MiningResponseNotFound
|
||||
assert_eq!(resp.status(), 404);
|
||||
let body: MiningResponse = serde_json::from_slice(resp.body())
|
||||
.expect("Should deserialize to MiningResponse (NotFound variant)");
|
||||
assert_eq!(body.status, ApiResponseStatus::NotFound);
|
||||
assert_eq!(body.job_id, job_id);
|
||||
assert!(body.message.is_some());
|
||||
}
|
||||
|
||||
// Removed placeholder test
|
||||
Reference in New Issue
Block a user