Compare commits
59 Commits
v3.3.1
...
perf/minbl
| 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 |
@@ -1,42 +0,0 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.github
|
||||
|
||||
# Build artifacts
|
||||
target/
|
||||
*.rs.bk
|
||||
*.pdb
|
||||
|
||||
# IDE
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Documentation
|
||||
docs/
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
# Examples and tests (not needed for runtime)
|
||||
examples/
|
||||
|
||||
# Other
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# CI/CD
|
||||
.github/
|
||||
|
||||
# License
|
||||
LICENSE
|
||||
|
||||
# Dockerfiles themselves
|
||||
Dockerfile*
|
||||
.dockerignore
|
||||
|
||||
# Cargo cache
|
||||
.cargo/
|
||||
|
||||
143
.gitea/workflows/bench.yaml
Normal file
143
.gitea/workflows/bench.yaml
Normal file
@@ -0,0 +1,143 @@
|
||||
---
|
||||
# GPU hashrate and parity measurement on a fleet mining host (quantus/miner#2).
|
||||
#
|
||||
# Runner containers on the GPU hosts have no device passthrough (gongfoo gives
|
||||
# them `devices: None`), so the benchmark cannot run inside the runner. The
|
||||
# binary is built on a runner and executed on the host over ssh as gitea_ci,
|
||||
# which has the GPU device nodes (0666) and the Vulkan ICD, exactly like the
|
||||
# miner. The host's quantus-miner.service is stopped for the window and started
|
||||
# again afterwards, under a trap, so a failed run cannot leave the host idle.
|
||||
#
|
||||
# Benjy (4090, dedicated) is the reference card; beast (2x 5090) also serves
|
||||
# inference and is only benchmarked by manual dispatch.
|
||||
name: bench
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
host:
|
||||
description: mining host to measure
|
||||
default: benjy.hanzalova.internal
|
||||
type: choice
|
||||
options:
|
||||
- benjy.hanzalova.internal
|
||||
- quadbrat.hanzalova.internal
|
||||
- beast.hanzalova.internal
|
||||
duration_secs:
|
||||
description: seconds per timed window
|
||||
default: "30"
|
||||
runs:
|
||||
description: timed windows (median reported)
|
||||
default: "5"
|
||||
batch_size:
|
||||
description: nonces per GPU batch
|
||||
default: "1000000"
|
||||
workers:
|
||||
description: worker threads (one per card)
|
||||
default: "1"
|
||||
pull_request:
|
||||
paths:
|
||||
- crates/engine-gpu/**
|
||||
- crates/engine-cuda/**
|
||||
- crates/engine-cpu/**
|
||||
- crates/pow-core/**
|
||||
- crates/miner-service/**
|
||||
- crates/bench-harness/**
|
||||
- Cargo.lock
|
||||
- .gitea/workflows/bench.yaml
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
BENCH_HOST: ${{ github.event.inputs.host || 'benjy.hanzalova.internal' }}
|
||||
DURATION: ${{ github.event.inputs.duration_secs || '30' }}
|
||||
RUNS: ${{ github.event.inputs.runs || '5' }}
|
||||
BATCH: ${{ github.event.inputs.batch_size || '1000000' }}
|
||||
WORKERS: ${{ github.event.inputs.workers || '1' }}
|
||||
|
||||
# One measurement per host at a time; a second one would share the card.
|
||||
concurrency:
|
||||
group: bench-${{ github.event.inputs.host || 'benjy.hanzalova.internal' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# cuda-13.0, not rust: the rust image is Fedora 44 and the mining hosts are
|
||||
# Fedora 43, so a binary built there needs a glibc the hosts do not have
|
||||
# (observed: "GLIBC_2.43 not found"). cuda-13.0 is Fedora 43 based, matches
|
||||
# the hosts, and is what the deploy in #8 builds on anyway.
|
||||
runs-on: cuda-13.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: build quantus-bench
|
||||
env:
|
||||
MINER_BUILD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
run: cargo build --release --locked -p bench-harness
|
||||
- uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: quantus-bench
|
||||
path: target/release/quantus-bench
|
||||
|
||||
measure:
|
||||
runs-on: fedora-43
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/download-artifact@v3
|
||||
with: { name: quantus-bench, path: _bin }
|
||||
|
||||
- name: write ssh key
|
||||
run: |
|
||||
set -euo pipefail
|
||||
install -d -m 0700 ~/.ssh
|
||||
printf '%s\n' "${{ secrets.RSYNC_SSH_KEY }}" > ~/.ssh/id_gitea_ci
|
||||
chmod 0600 ~/.ssh/id_gitea_ci
|
||||
|
||||
- name: measure on ${{ env.BENCH_HOST }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
SSHOPTS="-i $HOME/.ssh/id_gitea_ci -o StrictHostKeyChecking=accept-new"
|
||||
run() { ssh $SSHOPTS gitea_ci@"$BENCH_HOST" "$@"; }
|
||||
|
||||
# Per-run file names: two runs may overlap on the host until the
|
||||
# host-side flock serialises them, and scp over a running binary
|
||||
# fails with ETXTBSY.
|
||||
id="${{ github.run_id }}"
|
||||
dir=/var/lib/gitea_ci/bench
|
||||
run "install -d -m 0750 $dir"
|
||||
scp $SSHOPTS -q _bin/quantus-bench gitea_ci@"$BENCH_HOST":$dir/quantus-bench.$id
|
||||
scp $SSHOPTS -q crates/bench-harness/bench-on-host.sh gitea_ci@"$BENCH_HOST":$dir/bench-on-host.$id.sh
|
||||
run "chmod 0755 $dir/quantus-bench.$id"
|
||||
cleanup() { ssh $SSHOPTS gitea_ci@"$BENCH_HOST" "rm -f $dir/quantus-bench.$id $dir/bench-on-host.$id.sh $dir/record.$id.json"; }
|
||||
trap cleanup EXIT
|
||||
|
||||
label="${{ github.event.pull_request.number && format('pr-{0}', github.event.pull_request.number) || github.ref_name }}"
|
||||
# The host script pauses the miner, holds the per-host lock, measures,
|
||||
# and resumes the miner under its own trap.
|
||||
run "bash $dir/bench-on-host.$id.sh $dir/quantus-bench.$id $DURATION $RUNS $BATCH $WORKERS $label $dir/record.$id.json" | tee bench.log
|
||||
# The summary is everything from the harness's markdown header on.
|
||||
sed -n '/^## quantus-bench/,$p' bench.log > bench.md
|
||||
scp $SSHOPTS -q gitea_ci@"$BENCH_HOST":$dir/record.$id.json record.json
|
||||
{ echo; cat bench.md; } >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: always()
|
||||
with:
|
||||
name: bench-record-${{ env.BENCH_HOST }}
|
||||
path: |
|
||||
record.json
|
||||
bench.md
|
||||
|
||||
- name: comment on the pull request
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
body=$(python3 - <<'PY'
|
||||
import json, pathlib
|
||||
print(json.dumps({"body": pathlib.Path("bench.md").read_text()}))
|
||||
PY
|
||||
)
|
||||
curl -fsS -X POST \
|
||||
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
"${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}/issues/${{ github.event.pull_request.number }}/comments" \
|
||||
-d "$body" > /dev/null
|
||||
81
.gitea/workflows/ci.yml
Normal file
81
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
# Lint, build and test on the fleet's `rust` runner (architecture/gitea-runners.md).
|
||||
#
|
||||
# Replaces origin's .github/workflows/ci.yml, which targeted GitHub-hosted
|
||||
# runners that do not exist here. Gitea Actions also reads .github/workflows,
|
||||
# so origin's files are deleted rather than left inert; on every origin merge
|
||||
# `git rm -r .github/workflows` is the standing resolution (quantus/miner#1).
|
||||
#
|
||||
# Deliberately not here: origin's CPU benchmark job (meaningless on a shared
|
||||
# 4-CPU runner; the real gate is the GPU harness, quantus/miner#2) and taplo
|
||||
# (not on the runner image; add it to runner-rust per gitea-runners.md §5
|
||||
# rather than `cargo install` on every run).
|
||||
name: ci
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main, origin-main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: "0"
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
# rust-toolchain pins 1.93.0. rustup on the runner auto-installs the toolchain
|
||||
# on first use but NOT the components the file lists (observed: "cargo-fmt is
|
||||
# not installed for the toolchain"), so each job that needs one adds it
|
||||
# explicitly; the call is a no-op once present.
|
||||
|
||||
jobs:
|
||||
fmt:
|
||||
runs-on: rust
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: toolchain
|
||||
run: rustup component add rustfmt
|
||||
- name: rustfmt
|
||||
run: cargo fmt --all -- --check
|
||||
|
||||
clippy:
|
||||
runs-on: rust
|
||||
needs: fmt
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: toolchain
|
||||
run: rustup component add clippy
|
||||
- name: clippy (all targets, all features, warnings denied)
|
||||
# Same invocation as clippy.sh so local and CI agree.
|
||||
run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
|
||||
|
||||
test:
|
||||
runs-on: rust
|
||||
needs: fmt
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: build
|
||||
run: cargo build --locked --workspace
|
||||
- name: test
|
||||
# GPU tests skip themselves when no adapter is present; the runner has none.
|
||||
run: cargo test --locked --workspace
|
||||
- name: version carries the commit
|
||||
# build.rs embeds the SHA; a binary that says "unknown" cannot be
|
||||
# matched to a deploy (quantus/miner#8).
|
||||
run: |
|
||||
set -euo pipefail
|
||||
v=$(./target/debug/quantus-miner --version)
|
||||
echo "$v"
|
||||
case "$v" in
|
||||
*unknown*) echo "build SHA not embedded" >&2; exit 1 ;;
|
||||
esac
|
||||
|
||||
doc:
|
||||
runs-on: rust
|
||||
needs: fmt
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: doc
|
||||
run: cargo doc --locked --workspace --no-deps --all-features
|
||||
382
.gitea/workflows/deploy.yaml
Normal file
382
.gitea/workflows/deploy.yaml
Normal file
@@ -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
|
||||
15
.github/actions/disk/action.yml
vendored
15
.github/actions/disk/action.yml
vendored
@@ -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
|
||||
91
.github/workflows/ci.yml
vendored
91
.github/workflows/ci.yml
vendored
@@ -1,91 +0,0 @@
|
||||
---
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "*.md"
|
||||
- "LICENSE"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "*.md"
|
||||
- "LICENSE"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
CARGO_INCREMENTAL: 0
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
fast-checks:
|
||||
name: 🏁 Fast Checks (Format)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: rustfmt
|
||||
- name: install taplo
|
||||
run: cargo install taplo-cli --locked
|
||||
- name: Run format checks
|
||||
run: |
|
||||
taplo format --check --config taplo.toml
|
||||
cargo fmt --all -- --check
|
||||
timeout-minutes: 5
|
||||
|
||||
build-and-test:
|
||||
name: 🛠️ Build & Test
|
||||
needs: fast-checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- name: compile (gpu)
|
||||
run: |
|
||||
cargo build --locked --workspace
|
||||
timeout-minutes: 90
|
||||
- name: test (gpu)
|
||||
run: |
|
||||
cargo test --locked --workspace
|
||||
timeout-minutes: 15
|
||||
|
||||
analysis:
|
||||
name: 🤖 Analysis (Clippy & Doc)
|
||||
needs: fast-checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
components: clippy
|
||||
- name: clippy (all features)
|
||||
run: cargo clippy --locked --workspace --all-features
|
||||
timeout-minutes: 30
|
||||
- name: doc
|
||||
run: cargo doc --locked --workspace --no-deps --all-features
|
||||
timeout-minutes: 15
|
||||
|
||||
benchmark:
|
||||
name: 🏃 Benchmark
|
||||
needs: fast-checks
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
- name: build benchmark binary
|
||||
run: cargo build -p miner-cli --release
|
||||
timeout-minutes: 60
|
||||
- name: run cpu benchmark
|
||||
run: ./target/release/quantus-miner benchmark --cpu-workers 2 --duration 5
|
||||
timeout-minutes: 10
|
||||
139
.github/workflows/docker-image.yml
vendored
139
.github/workflows/docker-image.yml
vendored
@@ -1,139 +0,0 @@
|
||||
name: Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
specific_version:
|
||||
description: "Optional: Specify a full version (e.g., v0.3.0 or v0.3.1-beta.1) to build. If empty, uses the latest release tag. MUST start with 'v'."
|
||||
required: false
|
||||
default: ""
|
||||
use_main_dockerfile:
|
||||
description: "Optional: Use the main Dockerfile instead of the tag version-specific Dockerfile. If empty, uses the tag version-specific Dockerfile."
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
determine_version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version_with_v: ${{ steps.version_info.outputs.version_with_v }}
|
||||
is_valid_version: ${{ steps.version_info.outputs.is_valid_version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version information
|
||||
id: version_info
|
||||
run: |
|
||||
set -ex
|
||||
TARGET_VERSION=""
|
||||
SPECIFIC_VERSION="${{ github.event.inputs.specific_version }}"
|
||||
|
||||
if [[ -n "$SPECIFIC_VERSION" ]]; then
|
||||
if [[ ! "$SPECIFIC_VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then
|
||||
echo "::error::Specified version '$SPECIFIC_VERSION' is not a valid format. It must start with 'v' (e.g., v0.3.0 or v0.3.1-beta.1)."
|
||||
echo "is_valid_version=false" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
TARGET_VERSION="$SPECIFIC_VERSION"
|
||||
echo "Using specified version: $TARGET_VERSION"
|
||||
else
|
||||
echo "No specific version provided, determining latest release tag..."
|
||||
TARGET_VERSION=$(git tag --list 'v*' --sort=-v:refname | head -n 1)
|
||||
if [[ -z "$TARGET_VERSION" ]]; then
|
||||
echo "::error::No version tags starting with 'v' (e.g., vX.Y.Z) found in the repository."
|
||||
echo "is_valid_version=false" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
echo "Latest release version found: $TARGET_VERSION"
|
||||
fi
|
||||
|
||||
echo "version_with_v=$TARGET_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "is_valid_version=true" >> $GITHUB_OUTPUT
|
||||
|
||||
build_and_publish_image:
|
||||
needs: determine_version
|
||||
if: needs.determine_version.outputs.is_valid_version == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
env:
|
||||
GHCR_IMAGE_PATH: ghcr.io/quantus-network/quantus-miner
|
||||
TARGET_VERSION_WITH_V: ${{ needs.determine_version.outputs.version_with_v }}
|
||||
|
||||
steps:
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Check if Docker image already exists and fail if so
|
||||
id: check_image
|
||||
run: |
|
||||
set -ex
|
||||
IMAGE_TO_CHECK="${{ env.GHCR_IMAGE_PATH }}:${{ env.TARGET_VERSION_WITH_V }}"
|
||||
echo "Checking for image: $IMAGE_TO_CHECK"
|
||||
|
||||
if docker manifest inspect "$IMAGE_TO_CHECK" > /dev/null 2>&1; then
|
||||
echo "::error::Image $IMAGE_TO_CHECK already exists. Aborting."
|
||||
exit 1
|
||||
else
|
||||
echo "Image $IMAGE_TO_CHECK does not exist. Proceeding with build."
|
||||
fi
|
||||
|
||||
- name: Checkout code at tag version
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TARGET_VERSION_WITH_V }}
|
||||
|
||||
- name: Checkout main branch for Dockerfile (if needed)
|
||||
if: github.event.inputs.use_main_dockerfile == 'true'
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
sparse-checkout: |
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
sparse-checkout-cone-mode: false
|
||||
path: dockerfile-source
|
||||
|
||||
- name: Copy Dockerfile from main (if needed)
|
||||
if: github.event.inputs.use_main_dockerfile == 'true'
|
||||
run: |
|
||||
cp dockerfile-source/Dockerfile ./
|
||||
[ -f dockerfile-source/.dockerignore ] && cp dockerfile-source/.dockerignore ./ || true
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
provenance: false
|
||||
tags: |
|
||||
${{ env.GHCR_IMAGE_PATH }}:${{ env.TARGET_VERSION_WITH_V }}
|
||||
${{ env.GHCR_IMAGE_PATH }}:latest
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Print completion message
|
||||
run: |
|
||||
echo "Successfully built and pushed ${{ env.GHCR_IMAGE_PATH }}:${{ env.TARGET_VERSION_WITH_V }}"
|
||||
echo "Image is also tagged as latest: ${{ env.GHCR_IMAGE_PATH }}:latest"
|
||||
|
||||
199
.github/workflows/release-proposal.yml
vendored
199
.github/workflows/release-proposal.yml
vendored
@@ -1,199 +0,0 @@
|
||||
---
|
||||
name: Release Proposal
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target_branch:
|
||||
description: "Target branch for the PR (default: main)"
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
version_type:
|
||||
description: "Type of version bump"
|
||||
required: true
|
||||
default: "patch"
|
||||
type: choice
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
- custom
|
||||
custom_version:
|
||||
description: 'Custom version string (e.g., 1.2.3). Only used if version_type is "custom".'
|
||||
required: false
|
||||
is_draft:
|
||||
description: "Is this a draft release?"
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
calculate-next-version:
|
||||
name: 🧮 Calculate Next Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
new_version: ${{ steps.versioner.outputs.new_version }}
|
||||
new_tag: ${{ steps.versioner.outputs.new_tag }}
|
||||
commit_sha_short: ${{ steps.vars.outputs.commit_sha_short }}
|
||||
source_branch: ${{ steps.vars.outputs.source_branch }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Get current branch and commit SHA
|
||||
id: vars
|
||||
run: |
|
||||
echo "commit_sha_short=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
echo "source_branch=$(git rev-parse --abbrev-ref HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get latest tag
|
||||
id: latest_tag
|
||||
run: |
|
||||
# Get all version tags and sort them by version
|
||||
latest_semver_tag=$(git tag -l "v[0-9]*.[0-9]*.[0-9]*" | sort -V | tail -n 1)
|
||||
|
||||
# If no tags found, use default
|
||||
if [ -z "$latest_semver_tag" ]; then
|
||||
latest_semver_tag="v0.0.0"
|
||||
fi
|
||||
|
||||
echo "latest_tag_found=$latest_semver_tag" >> $GITHUB_OUTPUT
|
||||
echo "Latest semantic version tag found: $latest_semver_tag"
|
||||
|
||||
- name: Calculate new version
|
||||
id: versioner
|
||||
env:
|
||||
LATEST_TAG: ${{ steps.latest_tag.outputs.latest_tag_found }}
|
||||
VERSION_TYPE: ${{ github.event.inputs.version_type }}
|
||||
CUSTOM_VERSION: ${{ github.event.inputs.custom_version }}
|
||||
run: |
|
||||
# Remove 'v' prefix and any suffix for processing
|
||||
current_version=${LATEST_TAG#v}
|
||||
# Remove any suffix after the version number
|
||||
current_version=$(echo "$current_version" | sed -E 's/-[^-]+$//')
|
||||
|
||||
if [[ "$VERSION_TYPE" == "custom" ]]; then
|
||||
if [[ -z "$CUSTOM_VERSION" ]]; then
|
||||
echo "Error: Custom version is selected but no custom_version string provided."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$CUSTOM_VERSION" =~ ^v ]]; then
|
||||
echo "Error: Custom version string MUST start with 'v' (e.g., v1.2.3)."
|
||||
exit 1
|
||||
fi
|
||||
new_version="$CUSTOM_VERSION"
|
||||
else
|
||||
# Split version and pre-release part
|
||||
IFS='-' read -r version_core prerelease_part <<< "$current_version"
|
||||
IFS='.' read -r major minor patch <<< "$version_core"
|
||||
|
||||
# Increment based on type
|
||||
if [[ "$VERSION_TYPE" == "major" ]]; then
|
||||
major=$((major + 1))
|
||||
minor=0
|
||||
patch=0
|
||||
elif [[ "$VERSION_TYPE" == "minor" ]]; then
|
||||
minor=$((minor + 1))
|
||||
patch=0
|
||||
elif [[ "$VERSION_TYPE" == "patch" ]]; then
|
||||
patch=$((patch + 1))
|
||||
else
|
||||
echo "Error: Invalid version_type: $VERSION_TYPE"
|
||||
exit 1
|
||||
fi
|
||||
new_version="v$major.$minor.$patch"
|
||||
fi
|
||||
|
||||
echo "New version: $new_version"
|
||||
echo "new_version=$new_version" >> $GITHUB_OUTPUT
|
||||
echo "new_tag=$new_version" >> $GITHUB_OUTPUT
|
||||
|
||||
update-cargo-toml:
|
||||
name: 📝 Update version files
|
||||
needs: calculate-next-version
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
|
||||
- name: Create version bump branch and PR
|
||||
env:
|
||||
NEW_VERSION: ${{ needs.calculate-next-version.outputs.new_version }}
|
||||
NEW_TAG: ${{ needs.calculate-next-version.outputs.new_tag }}
|
||||
GITHUB_TOKEN: ${{ secrets.ADMIN_PAT }}
|
||||
SOURCE_BRANCH: ${{ needs.calculate-next-version.outputs.source_branch }}
|
||||
TARGET_BRANCH: ${{ github.event.inputs.target_branch }}
|
||||
run: |
|
||||
set -ex
|
||||
new_cargo_version=${NEW_VERSION#v}
|
||||
branch_name="release/${NEW_VERSION}"
|
||||
|
||||
# Create new branch from source branch
|
||||
git checkout "$SOURCE_BRANCH"
|
||||
git checkout -b "$branch_name"
|
||||
|
||||
# Update version in workspace Cargo.toml (safer than cargo set-version)
|
||||
echo "Updating workspace Cargo.toml to version: $new_cargo_version"
|
||||
sed -i -E "s/^version\s*=\s*\"[0-9a-zA-Z.-]+\"/version = \"$new_cargo_version\"/" Cargo.toml
|
||||
|
||||
# Regenerate Cargo.lock with precise updates for our packages only
|
||||
cargo update -p miner-cli --precise "$new_cargo_version"
|
||||
cargo update -p miner-service --precise "$new_cargo_version"
|
||||
cargo update -p pow-core --precise "$new_cargo_version"
|
||||
cargo update -p metrics --precise "$new_cargo_version"
|
||||
cargo update -p miner-telemetry --precise "$new_cargo_version"
|
||||
cargo update -p engine-cpu --precise "$new_cargo_version"
|
||||
cargo update -p engine-gpu --precise "$new_cargo_version"
|
||||
|
||||
# Verify everything compiles correctly
|
||||
cargo check --workspace
|
||||
|
||||
# Commit changes
|
||||
git config user.name "${{ github.actor }}"
|
||||
git config user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
|
||||
git add Cargo.toml Cargo.lock
|
||||
git commit -m "bump version to $NEW_VERSION"
|
||||
git push origin "$branch_name"
|
||||
|
||||
# Prepare PR title and body
|
||||
PR_TITLE="Release $NEW_VERSION"
|
||||
PR_BODY="Automated version bump for release $NEW_VERSION.
|
||||
|
||||
## Overview
|
||||
- Version bump: ${{ github.event.inputs.version_type }}
|
||||
- Type: ${{ github.event.inputs.version_type }}
|
||||
- Draft: ${{ github.event.inputs.is_draft }}
|
||||
|
||||
## What changed
|
||||
- Updated version in Cargo.toml and Cargo.lock
|
||||
|
||||
Triggered by workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
# Prepare labels
|
||||
PR_LABELS="release-proposal"
|
||||
if [[ "${{ github.event.inputs.is_draft }}" == "true" ]]; then
|
||||
PR_LABELS="$PR_LABELS,draft-release"
|
||||
fi
|
||||
|
||||
gh pr create \
|
||||
--title "$PR_TITLE" \
|
||||
--body "$PR_BODY" \
|
||||
--base "$TARGET_BRANCH" \
|
||||
--head "$branch_name" \
|
||||
--label "$PR_LABELS"
|
||||
157
.github/workflows/release-publish.yml
vendored
157
.github/workflows/release-publish.yml
vendored
@@ -1,157 +0,0 @@
|
||||
---
|
||||
name: Release Publish
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [closed]
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
create-tag:
|
||||
name: Create Tag
|
||||
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release-proposal')
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.extract_version.outputs.version }}
|
||||
tag: ${{ steps.extract_version.outputs.tag }}
|
||||
is_draft: ${{ steps.extract_version.outputs.is_draft }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Extract version from PR title
|
||||
id: extract_version
|
||||
run: |
|
||||
# Extract version from PR title (format: "Release vX.Y.Z")
|
||||
VERSION_TAG=$(echo "${{ github.event.pull_request.title }}" | grep -o 'v[0-9]\+\.[0-9]\+\.[0-9]\+')
|
||||
if [ -z "$VERSION_TAG" ]; then
|
||||
echo "Error: Could not extract version from PR title: ${{ github.event.pull_request.title }}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
VERSION=${VERSION_TAG#v}
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if this is a draft release
|
||||
if [[ "${{ contains(github.event.pull_request.labels.*.name, 'draft-release') }}" == "true" ]]; then
|
||||
echo "is_draft=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "is_draft=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "Extracted version: $VERSION"
|
||||
echo "Extracted tag: $VERSION_TAG"
|
||||
|
||||
- name: Create and push tag
|
||||
run: |
|
||||
git config user.name "${{ github.actor }}"
|
||||
git config user.email "${{ github.actor }}@users.noreply.github.com"
|
||||
git tag -a "${{ steps.extract_version.outputs.tag }}" -m "Release ${{ steps.extract_version.outputs.tag }}"
|
||||
git push origin "${{ steps.extract_version.outputs.tag }}"
|
||||
|
||||
build:
|
||||
needs: create-tag
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
# Linux builds
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
binary_name: quantus-miner
|
||||
asset_name: quantus-miner-linux-x86_64
|
||||
# Windows builds
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
binary_name: quantus-miner.exe
|
||||
asset_name: quantus-miner-windows-x86_64.exe
|
||||
# macOS builds (Intel)
|
||||
- os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
binary_name: quantus-miner
|
||||
asset_name: quantus-miner-macos-x86_64
|
||||
# macOS builds (Apple Silicon)
|
||||
- os: macos-latest
|
||||
target: aarch64-apple-darwin
|
||||
binary_name: quantus-miner
|
||||
asset_name: quantus-miner-macos-aarch64
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.create-tag.outputs.tag }}
|
||||
|
||||
- name: setup rust
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
|
||||
- name: build binary
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ -n "${{ matrix.features }}" ]]; then
|
||||
cargo build --release --locked --target ${{ matrix.target }} --features ${{ matrix.features }}
|
||||
else
|
||||
cargo build --release --locked --target ${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: prepare binary
|
||||
shell: bash
|
||||
run: |
|
||||
cd target/${{ matrix.target }}/release
|
||||
if [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
||||
cp ${{ matrix.binary_name }} ${{ matrix.asset_name }}
|
||||
else
|
||||
cp ${{ matrix.binary_name }} ${{ matrix.asset_name }}
|
||||
strip ${{ matrix.asset_name }}
|
||||
fi
|
||||
|
||||
- name: upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ${{ matrix.asset_name }}
|
||||
path: target/${{ matrix.target }}/release/${{ matrix.asset_name }}
|
||||
|
||||
release:
|
||||
needs: [create-tag, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.create-tag.outputs.tag }}
|
||||
|
||||
- name: download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: create release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ needs.create-tag.outputs.tag }}
|
||||
IS_DRAFT: ${{ needs.create-tag.outputs.is_draft }}
|
||||
run: |
|
||||
# Prepare draft flag
|
||||
if [[ "$IS_DRAFT" == "true" ]]; then
|
||||
DRAFT_FLAG="--draft"
|
||||
else
|
||||
DRAFT_FLAG=""
|
||||
fi
|
||||
|
||||
# Create release with all artifacts
|
||||
gh release create "$TAG" \
|
||||
--title "Release $TAG" \
|
||||
--generate-notes \
|
||||
$DRAFT_FLAG \
|
||||
artifacts/*/quantus-miner-*
|
||||
150
Cargo.lock
generated
150
Cargo.lock
generated
@@ -107,7 +107,7 @@ version = "0.38.0+1.3.281"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"libloading 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -122,6 +122,23 @@ version = "0.21.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||
|
||||
[[package]]
|
||||
name = "bench-harness"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"engine-cpu",
|
||||
"engine-cuda",
|
||||
"engine-gpu",
|
||||
"env_logger",
|
||||
"log",
|
||||
"pow-core",
|
||||
"primitive-types 0.13.1",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@@ -377,6 +394,12 @@ dependencies = [
|
||||
"unicode-xid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "constant_time_eq"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
@@ -518,6 +541,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cudarc"
|
||||
version = "0.19.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "804764d10e844da09765a7b2ca9641a0851523d1702efb0d7299d73e31b86e80"
|
||||
dependencies = [
|
||||
"libloading 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.10.0"
|
||||
@@ -571,7 +603,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "engine-cpu"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"hex",
|
||||
@@ -580,9 +612,22 @@ dependencies = [
|
||||
"rand 0.9.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "engine-cuda"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"cudarc",
|
||||
"engine-cpu",
|
||||
"log",
|
||||
"metrics",
|
||||
"pow-core",
|
||||
"primitive-types 0.13.1",
|
||||
"qp-poseidon-constants",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "engine-gpu"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"criterion",
|
||||
@@ -591,12 +636,13 @@ dependencies = [
|
||||
"futures",
|
||||
"hex",
|
||||
"log",
|
||||
"metrics",
|
||||
"pow-core",
|
||||
"primitive-types 0.13.1",
|
||||
"qp-plonky2",
|
||||
"qp-plonky2-field",
|
||||
"qp-poseidon-constants",
|
||||
"qp-poseidon-core",
|
||||
"qp-poseidon-core 3.1.0",
|
||||
"rand 0.9.2",
|
||||
"rand_chacha 0.9.0",
|
||||
"regex",
|
||||
@@ -1367,7 +1413,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libloading",
|
||||
"libloading 0.8.9",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
@@ -1405,6 +1451,16 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libm"
|
||||
version = "0.2.16"
|
||||
@@ -1470,7 +1526,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "metrics"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"log",
|
||||
@@ -1498,7 +1554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "miner-cli"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"engine-cpu",
|
||||
@@ -1509,17 +1565,19 @@ dependencies = [
|
||||
"miner-service",
|
||||
"num_cpus",
|
||||
"primitive-types 0.13.1",
|
||||
"quic-transport",
|
||||
"rand 0.9.2",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miner-service"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"crossbeam-channel",
|
||||
"engine-cpu",
|
||||
"engine-cuda",
|
||||
"engine-gpu",
|
||||
"getrandom 0.2.17",
|
||||
"hex",
|
||||
@@ -1529,14 +1587,14 @@ dependencies = [
|
||||
"pow-core",
|
||||
"primitive-types 0.13.1",
|
||||
"quantus-miner-api",
|
||||
"quic-transport",
|
||||
"quinn",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miner-telemetry"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"futures",
|
||||
@@ -1994,6 +2052,28 @@ dependencies = [
|
||||
"plotters-backend",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pool-service"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"constant_time_eq",
|
||||
"env_logger",
|
||||
"hex",
|
||||
"log",
|
||||
"pow-core",
|
||||
"primitive-types 0.13.1",
|
||||
"quantus-miner-api",
|
||||
"quic-transport",
|
||||
"quinn",
|
||||
"rand 0.9.2",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
"warp",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.13.1"
|
||||
@@ -2020,10 +2100,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pow-core"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"hex",
|
||||
"primitive-types 0.13.1",
|
||||
"qp-poseidon-core 3.1.0",
|
||||
"qpow-math",
|
||||
]
|
||||
|
||||
@@ -2231,6 +2312,12 @@ version = "2.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "78172860cd960773c72e97fba07ead9e5a1c13086c5746283744933e2e4a577b"
|
||||
|
||||
[[package]]
|
||||
name = "qp-poseidon-core"
|
||||
version = "3.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5872607e25ea4ee5fb37e64bf1462168e1a36a4e719cdc8a105533c708253918"
|
||||
|
||||
[[package]]
|
||||
name = "qpow-math"
|
||||
version = "0.1.0"
|
||||
@@ -2239,19 +2326,33 @@ dependencies = [
|
||||
"hex",
|
||||
"log",
|
||||
"primitive-types 0.13.1",
|
||||
"qp-poseidon-core",
|
||||
"qp-poseidon-core 2.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quantus-miner-api"
|
||||
version = "0.2.0"
|
||||
source = "git+https://github.com/Quantus-Network/chain.git?tag=v0.7.1-q-day-2#5ea94cb1e5347b12d091b6fa9f9234ceb897ec42"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "725bbf1de0bc7d2ace83f8e49ea754be5f45b84bc9471ddd3f417a9e2bb20553"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quic-transport"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"quantus-miner-api",
|
||||
"quinn",
|
||||
"rustls 0.21.12",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.10.2"
|
||||
@@ -2751,6 +2852,17 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -2808,6 +2920,14 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "solver-wasm"
|
||||
version = "4.0.2"
|
||||
dependencies = [
|
||||
"primitive-types 0.13.1",
|
||||
"qpow-math",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "spin"
|
||||
version = "0.5.2"
|
||||
@@ -3633,7 +3753,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"khronos-egl",
|
||||
"libc",
|
||||
"libloading",
|
||||
"libloading 0.8.9",
|
||||
"log",
|
||||
"metal",
|
||||
"naga",
|
||||
|
||||
11
Cargo.toml
11
Cargo.toml
@@ -1,12 +1,17 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/bench-harness", # lair: quantus/miner#2
|
||||
"crates/engine-cpu",
|
||||
"crates/engine-cuda", # lair: quantus/miner#3
|
||||
"crates/engine-gpu",
|
||||
"crates/metrics",
|
||||
"crates/miner-cli",
|
||||
"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"
|
||||
@@ -15,7 +20,7 @@ resolver = "2"
|
||||
edition = "2021"
|
||||
authors = ["Quantus Network"]
|
||||
description = "Quantus External Miner Workspace"
|
||||
version = "3.3.1"
|
||||
version = "4.0.2"
|
||||
|
||||
[workspace.dependencies]
|
||||
anyhow = "1"
|
||||
@@ -29,9 +34,9 @@ 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 = "2.1.0", 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 = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.7.1-q-day-2" }
|
||||
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 }
|
||||
|
||||
54
Dockerfile
54
Dockerfile
@@ -1,54 +0,0 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
############################
|
||||
# Builder stage
|
||||
############################
|
||||
FROM rust:1.85-slim-bookworm AS builder
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
# Copy workspace files
|
||||
COPY Cargo.toml Cargo.lock rust-toolchain taplo.toml ./
|
||||
COPY crates ./crates
|
||||
|
||||
# Build the miner-cli in release mode
|
||||
RUN cargo build --release -p miner-cli --locked
|
||||
|
||||
# Strip debug symbols to reduce binary size
|
||||
RUN strip target/release/quantus-miner || true
|
||||
|
||||
############################
|
||||
# Runtime-only stage
|
||||
############################
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apt-get update \
|
||||
&& DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy binary from builder stage
|
||||
COPY --from=builder /build/target/release/quantus-miner /usr/local/bin/quantus-miner
|
||||
|
||||
# Expose miner API port and metrics port
|
||||
EXPOSE 9833 9900
|
||||
|
||||
# Run as unprivileged user
|
||||
RUN useradd --system --uid 10001 quantus
|
||||
USER 10001:10001
|
||||
|
||||
# Default working directory
|
||||
WORKDIR /data
|
||||
|
||||
# Start the miner
|
||||
ENTRYPOINT ["quantus-miner"]
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
# External Miner Protocol Specification
|
||||
|
||||
This document defines the QUIC-based protocol for communication between the Quantus Network node and external QPoW miner services.
|
||||
|
||||
## Overview
|
||||
|
||||
The node delegates the mining task (finding a valid nonce) to external miner services over persistent QUIC connections. The node provides the necessary parameters (header hash, difficulty) and each external miner independently searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. Miners push results back when found.
|
||||
|
||||
### Key Benefits of QUIC
|
||||
|
||||
- **Lower latency**: Results are pushed immediately when found (no polling)
|
||||
- **Connection resilience**: Built-in connection migration and recovery
|
||||
- **Multiplexed streams**: Multiple operations on single connection
|
||||
- **Built-in TLS**: Encrypted by default
|
||||
|
||||
## Architecture
|
||||
|
||||
### Connection Model
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Node │
|
||||
│ (QUIC Server on port 9833) │
|
||||
│ │
|
||||
┌──────────┐ │ Broadcasts: NewJob │
|
||||
│ Miner 1 │ ──connect───► │ Receives: JobResult │
|
||||
└──────────┘ │ │
|
||||
│ Supports multiple miners │
|
||||
┌──────────┐ │ First valid result wins │
|
||||
│ Miner 2 │ ──connect───► │ │
|
||||
└──────────┘ └─────────────────────────────────┘
|
||||
|
||||
┌──────────┐
|
||||
│ Miner 3 │ ──connect───►
|
||||
└──────────┘
|
||||
```
|
||||
|
||||
- **Node** acts as the QUIC server, listening on port 9833 (default)
|
||||
- **Miners** act as QUIC clients, connecting to the node
|
||||
- Single bidirectional stream per miner connection
|
||||
- Connection persists across multiple mining jobs
|
||||
- Multiple miners can connect simultaneously
|
||||
|
||||
### Multi-Miner Operation
|
||||
|
||||
When multiple miners are connected:
|
||||
1. Node broadcasts the same `NewJob` to all connected miners
|
||||
2. Each miner independently selects a random starting nonce
|
||||
3. First miner to find a valid solution sends `JobResult`
|
||||
4. Node uses the first valid result, ignores subsequent results for same job
|
||||
5. New job broadcast implicitly cancels work on all miners
|
||||
|
||||
### Message Types
|
||||
|
||||
The protocol uses only **two message types**:
|
||||
|
||||
| Direction | Message | Description |
|
||||
|-----------|---------|-------------|
|
||||
| Node → Miner | `NewJob` | Submit a mining job (implicitly cancels any previous job) |
|
||||
| Miner → Node | `JobResult` | Mining result (completed, failed, or cancelled) |
|
||||
|
||||
### Wire Format
|
||||
|
||||
Messages are length-prefixed JSON:
|
||||
|
||||
```
|
||||
┌─────────────────┬─────────────────────────────────┐
|
||||
│ Length (4 bytes)│ JSON payload (MinerMessage) │
|
||||
│ big-endian u32 │ │
|
||||
└─────────────────┴─────────────────────────────────┘
|
||||
```
|
||||
|
||||
Maximum message size: 16 MB
|
||||
|
||||
## Data Types
|
||||
|
||||
See the `quantus-miner-api` crate for the canonical Rust definitions.
|
||||
|
||||
### MinerMessage (Enum)
|
||||
|
||||
```rust
|
||||
pub enum MinerMessage {
|
||||
NewJob(MiningRequest),
|
||||
JobResult(MiningResult),
|
||||
}
|
||||
```
|
||||
|
||||
### MiningRequest
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `job_id` | String | Unique identifier (UUID recommended) |
|
||||
| `mining_hash` | String | Header hash (64 hex chars, no 0x prefix) |
|
||||
| `distance_threshold` | String | Difficulty (U512 as decimal string) |
|
||||
|
||||
Note: Nonce range is not specified - each miner independently selects a random starting point.
|
||||
|
||||
### MiningResult
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `status` | ApiResponseStatus | Result status (see below) |
|
||||
| `job_id` | String | Job identifier |
|
||||
| `nonce` | Option<String> | Winning nonce (U512 hex, no 0x prefix) |
|
||||
| `work` | Option<String> | Winning nonce as bytes (128 hex chars) |
|
||||
| `hash_count` | u64 | Number of nonces checked |
|
||||
| `elapsed_time` | f64 | Time spent mining (seconds) |
|
||||
|
||||
### ApiResponseStatus (Enum)
|
||||
|
||||
| Value | Description |
|
||||
|-------|-------------|
|
||||
| `completed` | Valid nonce found |
|
||||
| `failed` | Nonce range exhausted without finding solution |
|
||||
| `cancelled` | Job was cancelled (new job received) |
|
||||
| `running` | Job still in progress (not typically sent) |
|
||||
|
||||
## Protocol Flow
|
||||
|
||||
### Normal Mining Flow
|
||||
|
||||
```
|
||||
Miner Node
|
||||
│ │
|
||||
│──── QUIC Connect ─────────────────────────►│
|
||||
│◄─── Connection Established ────────────────│
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│
|
||||
│ │
|
||||
│ (picks random nonce, starts mining) │
|
||||
│ │
|
||||
│──── JobResult { job_id: "abc", ... } ─────►│ (found solution!)
|
||||
│ │
|
||||
│ (node submits block, gets new work) │
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "def", ... } ─────────│
|
||||
│ │
|
||||
```
|
||||
|
||||
### Job Cancellation (Implicit)
|
||||
|
||||
When a new block arrives before the miner finds a solution, the node simply sends a new `NewJob`. The miner automatically cancels the previous job:
|
||||
|
||||
```
|
||||
Miner Node
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│
|
||||
│ │
|
||||
│ (mining "abc") │
|
||||
│ │
|
||||
│ (new block arrives at node!) │
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "def", ... } ─────────│
|
||||
│ │
|
||||
│ (cancels "abc", starts "def") │
|
||||
│ │
|
||||
│──── JobResult { job_id: "def", ... } ─────►│
|
||||
```
|
||||
|
||||
### Miner Connect During Active Job
|
||||
|
||||
When a miner connects while a job is active, it immediately receives the current job:
|
||||
|
||||
```
|
||||
Miner (new) Node
|
||||
│ │ (already mining job "abc")
|
||||
│──── QUIC Connect ─────────────────────────►│
|
||||
│◄─── Connection Established ────────────────│
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│ (current job sent immediately)
|
||||
│ │
|
||||
│ (joins mining effort) │
|
||||
```
|
||||
|
||||
### Stale Result Handling
|
||||
|
||||
If a result arrives for an old job, the node discards it:
|
||||
|
||||
```
|
||||
Miner Node
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "abc", ... } ─────────│
|
||||
│ │
|
||||
│◄─── NewJob { job_id: "def", ... } ─────────│ (almost simultaneous)
|
||||
│ │
|
||||
│──── JobResult { job_id: "abc", ... } ─────►│ (stale, node ignores)
|
||||
│ │
|
||||
│──── JobResult { job_id: "def", ... } ─────►│ (current, node uses)
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Node
|
||||
|
||||
```bash
|
||||
# Listen for external miner connections on port 9833
|
||||
quantus-node --miner-listen-port 9833
|
||||
```
|
||||
|
||||
### Miner
|
||||
|
||||
```bash
|
||||
# Connect to node
|
||||
quantus-miner serve --node-addr 127.0.0.1:9833
|
||||
```
|
||||
|
||||
## TLS Configuration
|
||||
|
||||
The node generates a self-signed TLS certificate at startup. The miner skips certificate verification by default (insecure mode). For production deployments, consider:
|
||||
|
||||
1. **Certificate pinning**: Configure the miner to accept only specific certificate fingerprints
|
||||
2. **Proper CA**: Use certificates signed by a trusted CA
|
||||
3. **Network isolation**: Run node and miner on a private network
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Connection Loss
|
||||
|
||||
The miner automatically reconnects with exponential backoff:
|
||||
- Initial delay: 1 second
|
||||
- Maximum delay: 30 seconds
|
||||
|
||||
The node continues operating with remaining connected miners.
|
||||
|
||||
### Validation Errors
|
||||
|
||||
If the miner receives an invalid `MiningRequest`, it sends a `JobResult` with status `failed`.
|
||||
|
||||
## Notes
|
||||
|
||||
- All hex values should be sent **without** the `0x` prefix
|
||||
- The miner implements validation logic from `qpow_math::is_valid_nonce`
|
||||
- The node uses the `work` field from `MiningResult` to construct `QPoWSeal`
|
||||
- ALPN protocol identifier: `quantus-miner`
|
||||
- Each miner independently generates a random nonce starting point using cryptographically secure randomness
|
||||
- With a 512-bit nonce space, collision between miners is statistically impossible
|
||||
91
README.md
91
README.md
@@ -16,18 +16,34 @@ The binary will be available at `target/release/quantus-miner`.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# CPU-only mining (default: auto-detected CPU cores)
|
||||
./target/release/quantus-miner serve --cpu-workers 4
|
||||
The node requires a shared auth token and TLS cert pin. Both live under the
|
||||
node's chain config dir (`<base-path>/chains/<chain>/`):
|
||||
|
||||
# GPU-only mining
|
||||
./target/release/quantus-miner serve --gpu-devices 1
|
||||
- `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
|
||||
# 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
|
||||
|
||||
# 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 --cpu-workers 4 --gpu-devices 1
|
||||
|
||||
# Custom port and metrics
|
||||
./target/release/quantus-miner serve --cpu-workers 2 --port 8000 --metrics-port 9900
|
||||
./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
|
||||
```
|
||||
|
||||
## Configuration
|
||||
@@ -35,6 +51,10 @@ The binary will be available at `target/release/quantus-miner`.
|
||||
| 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 |
|
||||
@@ -70,27 +90,46 @@ cargo build -p miner-cli --release
|
||||
|
||||
## Examples
|
||||
|
||||
All `serve` examples need the auth token and TLS pin (files or inline values).
|
||||
|
||||
```bash
|
||||
# CPU mining with 8 workers
|
||||
./target/release/quantus-miner serve --cpu-workers 8
|
||||
./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
|
||||
|
||||
# Pure GPU mining
|
||||
./target/release/quantus-miner serve --gpu-devices 1
|
||||
./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 mining with throttle (reduce GPU utilization)
|
||||
./target/release/quantus-miner serve --gpu-devices 1 --gpu-throttle-ms 50
|
||||
./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
|
||||
|
||||
# Hybrid mining: 4 CPU + 1 GPU workers
|
||||
./target/release/quantus-miner serve --cpu-workers 4 --gpu-devices 1
|
||||
./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
|
||||
|
||||
# With verbose logging
|
||||
RUST_LOG=debug ./target/release/quantus-miner serve --cpu-workers 2 --gpu-devices 1
|
||||
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 \
|
||||
--port 9833 \
|
||||
--metrics-port 9900
|
||||
```
|
||||
|
||||
@@ -98,25 +137,13 @@ RUST_LOG=debug ./target/release/quantus-miner serve --cpu-workers 2 --gpu-device
|
||||
|
||||
The miner uses a QUIC-based protocol for communication with the node:
|
||||
|
||||
- **Transport**: QUIC with TLS 1.3 (self-signed certificates)
|
||||
- **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**: `NewJob` (from node) and `JobResult` (from miner)
|
||||
- **Messages**: `Ready` (miner→node), `NewJob` (node→miner), `JobResult` (miner→node)
|
||||
|
||||
For full protocol specification, see `EXTERNAL_MINER_PROTOCOL.md`.
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
# Quick start
|
||||
docker pull ghcr.io/quantus-network/quantus-miner:latest
|
||||
docker run -d -p 9833:9833 -p 9900:9900 \
|
||||
ghcr.io/quantus-network/quantus-miner:latest \
|
||||
--cpu-workers 4 --metrics-port 9900
|
||||
|
||||
# Build from source
|
||||
docker build -t quantus-miner .
|
||||
docker run -d -p 9833:9833 quantus-miner serve --cpu-workers 4
|
||||
```
|
||||
For full protocol specification, see the node's `MINING.md`.
|
||||
|
||||
## Benchmarking
|
||||
|
||||
|
||||
80
SECURITY.md
Normal file
80
SECURITY.md
Normal file
@@ -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.
|
||||
7
clippy.sh
Executable file
7
clippy.sh
Executable file
@@ -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
|
||||
23
crates/bench-harness/Cargo.toml
Normal file
23
crates/bench-harness/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "bench-harness"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "lair: reproducible GPU hashrate and parity measurement for the Quantus miner (quantus/miner#2)"
|
||||
|
||||
[[bin]]
|
||||
name = "quantus-bench"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
engine-cpu = { path = "../engine-cpu" }
|
||||
engine-gpu = { path = "../engine-gpu" }
|
||||
engine-cuda = { path = "../engine-cuda" }
|
||||
pow-core = { path = "../pow-core" }
|
||||
primitive-types = { workspace = true }
|
||||
clap = { workspace = true, features = ["derive", "env"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true, features = ["std"] }
|
||||
rand = { workspace = true }
|
||||
env_logger = { workspace = true }
|
||||
log = { workspace = true }
|
||||
60
crates/bench-harness/bench-on-host.sh
Executable file
60
crates/bench-harness/bench-on-host.sh
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# lair: run quantus-bench on a mining host, as gitea_ci, with the miner paused.
|
||||
#
|
||||
# Executed over ssh by .gitea/workflows/bench.yaml. Everything that must be
|
||||
# serialised per host lives here under one flock: Gitea's workflow concurrency
|
||||
# group did NOT serialise two runs on the same host (observed: the second run's
|
||||
# scp hit ETXTBSY on the binary the first was executing), so the host is the
|
||||
# arbiter, not the forge.
|
||||
#
|
||||
# usage: bench-on-host.sh <binary> <duration_secs> <runs> <batch_size> <workers> <label> <out_json>
|
||||
set -euo pipefail
|
||||
|
||||
BIN="$1"; DURATION="$2"; RUNS="$3"; BATCH="$4"; WORKERS="$5"; LABEL="$6"; OUT="$7"
|
||||
DIR=/var/lib/gitea_ci/bench
|
||||
LOCK="$DIR/.lock"
|
||||
|
||||
exec 9>"$LOCK"
|
||||
if ! flock -w 1800 9; then
|
||||
echo "another measurement has held $LOCK for 30 minutes; giving up" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- host ---"
|
||||
hostname -f
|
||||
limit=$(nvidia-smi --query-gpu=power.limit --format=csv,noheader,nounits | awk 'NR==1{printf "%d", $1}')
|
||||
echo "power limit: ${limit} W"
|
||||
"$BIN" --version
|
||||
|
||||
echo "--- pause miner ---"
|
||||
was_active=0
|
||||
if systemctl is-active --quiet quantus-miner.service; then
|
||||
was_active=1
|
||||
sudo systemctl stop quantus-miner.service
|
||||
else
|
||||
echo "quantus-miner.service was not active; nothing to pause"
|
||||
fi
|
||||
resume() {
|
||||
if [ "$was_active" = 1 ]; then
|
||||
echo "--- resume miner ---"
|
||||
sudo systemctl start quantus-miner.service
|
||||
systemctl is-active quantus-miner.service
|
||||
fi
|
||||
}
|
||||
trap resume EXIT
|
||||
|
||||
# Refuse to measure a card something else is using (on beast that would be
|
||||
# inference). A few seconds for the miner to release it.
|
||||
sleep 3
|
||||
util=$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader,nounits | awk 'NR==1{printf "%d", $1}')
|
||||
echo "utilisation before measuring: ${util}%"
|
||||
if [ "$util" -gt 5 ]; then
|
||||
echo "GPU is busy (${util}%) with the miner stopped; refusing to measure" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- measure ---"
|
||||
cd "$DIR"
|
||||
RUST_LOG=info "$BIN" \
|
||||
--duration-secs "$DURATION" --runs "$RUNS" --batch-size "$BATCH" --workers "$WORKERS" \
|
||||
--expect-power-limit "$limit" --label "$LABEL" --json "$OUT"
|
||||
51
crates/bench-harness/build.rs
Normal file
51
crates/bench-harness/build.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
// lair: embed the git commit in the binary so `--version` and the build-info
|
||||
// metric identify a deployed build by commit, not by the workspace semver
|
||||
// (which does not change between commits on a branch that deploys on push).
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. MINER_BUILD_SHA in the environment (CI sets it from the checked-out ref)
|
||||
// 2. `git rev-parse HEAD` of the workspace, with "-dirty" if the tree differs
|
||||
// 3. "unknown"
|
||||
use std::process::Command;
|
||||
|
||||
fn git(args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git").args(args).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?;
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=MINER_BUILD_SHA");
|
||||
|
||||
let sha = match std::env::var("MINER_BUILD_SHA") {
|
||||
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => match git(&["rev-parse", "--short=12", "HEAD"]) {
|
||||
Some(head) => {
|
||||
// Re-run when HEAD moves so a rebuild after a commit picks it up.
|
||||
if let Some(dir) = git(&["rev-parse", "--git-dir"]) {
|
||||
println!("cargo:rerun-if-changed={dir}/HEAD");
|
||||
println!("cargo:rerun-if-changed={dir}/refs/heads");
|
||||
}
|
||||
let dirty = git(&["status", "--porcelain", "--untracked-files=no"])
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
if dirty {
|
||||
format!("{head}-dirty")
|
||||
} else {
|
||||
head
|
||||
}
|
||||
}
|
||||
None => "unknown".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
println!("cargo:rustc-env=MINER_BUILD_SHA={sha}");
|
||||
}
|
||||
506
crates/bench-harness/src/main.rs
Normal file
506
crates/bench-harness/src/main.rs
Normal file
@@ -0,0 +1,506 @@
|
||||
//! lair: `quantus-bench`, the measurement gate for quantus/miner#2.
|
||||
//!
|
||||
//! Drives a GPU engine through the same `MinerEngine` trait the miner uses,
|
||||
//! records hashrate over fixed windows with the GPU's power and clock state
|
||||
//! captured before and after, verifies GPU/CPU parity on random jobs, and
|
||||
//! writes one JSON record so runs are comparable across commits.
|
||||
//!
|
||||
//! It deliberately measures one card per worker thread, with no node, no
|
||||
//! QUIC and no job churn: this is the kernel-plus-submission number. The
|
||||
//! production number, with all of that included, is quantus/miner#9.
|
||||
|
||||
use clap::Parser;
|
||||
use engine_cpu::{AtomicBoolCancelCheck, EngineStatus, MinerEngine, Range};
|
||||
use engine_cuda::CudaEngine;
|
||||
use engine_gpu::GpuEngine;
|
||||
use primitive_types::U512;
|
||||
use rand::RngCore;
|
||||
use serde::Serialize;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// Reproducible GPU hashrate and parity measurement.
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(version = VERSION, about)]
|
||||
struct Args {
|
||||
/// Seconds per timed window.
|
||||
#[arg(long, default_value_t = 30)]
|
||||
duration_secs: u64,
|
||||
|
||||
/// Number of timed windows (median and spread are reported).
|
||||
#[arg(long, default_value_t = 5)]
|
||||
runs: usize,
|
||||
|
||||
/// Seconds of untimed warm-up before the first window.
|
||||
#[arg(long, default_value_t = 5)]
|
||||
warmup_secs: u64,
|
||||
|
||||
/// Nonces per GPU batch (the miner's --gpu-batch-size).
|
||||
#[arg(long, default_value_t = 1_000_000)]
|
||||
batch_size: u32,
|
||||
|
||||
/// GPU engine: auto (cuda if this binary carries a kernel and a driver is
|
||||
/// present, else wgpu), cuda, or wgpu.
|
||||
#[arg(long, default_value = "auto")]
|
||||
engine: String,
|
||||
|
||||
/// Worker threads. The engine assigns threads to devices round-robin, so
|
||||
/// on a multi-card host N threads measure N cards. More threads than cards
|
||||
/// is allowed on purpose: two threads on one card overlap one thread's
|
||||
/// readback with the other's dispatch, which is the submission bubble
|
||||
/// quantus/miner#4 and #5 are about, measured without touching the miner.
|
||||
#[arg(long, default_value_t = 1)]
|
||||
workers: usize,
|
||||
|
||||
/// Random jobs for the GPU/CPU parity check (0 to skip).
|
||||
#[arg(long, default_value_t = 25)]
|
||||
parity_jobs: usize,
|
||||
|
||||
/// Refuse to run unless every GPU reports exactly this enforced power
|
||||
/// limit in watts. Power limit is the largest confound on these cards.
|
||||
#[arg(long)]
|
||||
expect_power_limit: Option<u32>,
|
||||
|
||||
/// Commit the binary under test was built from.
|
||||
#[arg(long, default_value = BUILD_SHA)]
|
||||
commit: String,
|
||||
|
||||
/// Free-form label stored in the record (e.g. the PR number).
|
||||
#[arg(long, default_value = "")]
|
||||
label: String,
|
||||
|
||||
/// Write the JSON record here.
|
||||
#[arg(long)]
|
||||
json: Option<std::path::PathBuf>,
|
||||
}
|
||||
|
||||
// Set by build.rs (same resolution as miner-cli's).
|
||||
const BUILD_SHA: &str = env!("MINER_BUILD_SHA");
|
||||
const VERSION: &str = concat!(
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (",
|
||||
env!("MINER_BUILD_SHA"),
|
||||
")"
|
||||
);
|
||||
|
||||
#[derive(Serialize, Debug, Clone)]
|
||||
struct GpuState {
|
||||
index: u32,
|
||||
name: String,
|
||||
driver: String,
|
||||
power_limit_w: f64,
|
||||
power_draw_w: f64,
|
||||
sm_clock_mhz: f64,
|
||||
/// The memory clock is a confound like the power limit: the miner never
|
||||
/// touches VRAM, and a card with memory locked low gives the SMs more of
|
||||
/// the same power budget (lair/quantus#10).
|
||||
mem_clock_mhz: f64,
|
||||
temperature_c: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct WorkerResult {
|
||||
worker: usize,
|
||||
/// MH/s per timed window, in order.
|
||||
windows_mhs: Vec<f64>,
|
||||
median_mhs: f64,
|
||||
/// (max - min) / median over the windows.
|
||||
spread: f64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct Parity {
|
||||
jobs: usize,
|
||||
found: usize,
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug)]
|
||||
struct Record {
|
||||
schema: u32,
|
||||
timestamp_unix: u64,
|
||||
host: String,
|
||||
commit: String,
|
||||
label: String,
|
||||
engine: String,
|
||||
batch_size: u32,
|
||||
duration_secs: u64,
|
||||
runs: usize,
|
||||
warmup_secs: u64,
|
||||
workers: usize,
|
||||
gpu_before: Vec<GpuState>,
|
||||
gpu_after: Vec<GpuState>,
|
||||
results: Vec<WorkerResult>,
|
||||
/// Sum of worker medians: the host's number.
|
||||
total_median_mhs: f64,
|
||||
parity: Option<Parity>,
|
||||
}
|
||||
|
||||
/// Either GPU engine behind the same trait; the harness drives them identically.
|
||||
enum Engine {
|
||||
Wgpu(GpuEngine),
|
||||
Cuda(CudaEngine),
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
fn open(kind: &str, batch_size: u32) -> Engine {
|
||||
match kind {
|
||||
"cuda" => {
|
||||
Engine::Cuda(CudaEngine::try_new(batch_size, 0).expect("CUDA engine init failed"))
|
||||
}
|
||||
"wgpu" => Engine::Wgpu(
|
||||
GpuEngine::try_new(batch_size, 0, false).expect("GPU engine init failed"),
|
||||
),
|
||||
"auto" => match CudaEngine::try_new(batch_size, 0) {
|
||||
Ok(e) => Engine::Cuda(e),
|
||||
Err(e) => {
|
||||
log::info!("CUDA engine unavailable ({e}); using wgpu");
|
||||
Engine::Wgpu(
|
||||
GpuEngine::try_new(batch_size, 0, false).expect("GPU engine init failed"),
|
||||
)
|
||||
}
|
||||
},
|
||||
other => panic!("unknown --engine {other}; use auto, cuda or wgpu"),
|
||||
}
|
||||
}
|
||||
fn device_count(&self) -> usize {
|
||||
match self {
|
||||
Engine::Wgpu(e) => e.device_count(),
|
||||
Engine::Cuda(e) => e.device_count(),
|
||||
}
|
||||
}
|
||||
fn as_dyn(&self) -> &dyn MinerEngine {
|
||||
match self {
|
||||
Engine::Wgpu(e) => e,
|
||||
Engine::Cuda(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn nvidia_smi() -> Vec<GpuState> {
|
||||
let out = Command::new("nvidia-smi")
|
||||
.args([
|
||||
"--query-gpu=index,name,driver_version,power.limit,power.draw,clocks.sm,clocks.mem,temperature.gpu",
|
||||
"--format=csv,noheader,nounits",
|
||||
])
|
||||
.output();
|
||||
let Ok(out) = out else {
|
||||
log::warn!("nvidia-smi not available; GPU state not recorded");
|
||||
return Vec::new();
|
||||
};
|
||||
if !out.status.success() {
|
||||
log::warn!("nvidia-smi failed; GPU state not recorded");
|
||||
return Vec::new();
|
||||
}
|
||||
String::from_utf8_lossy(&out.stdout)
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let f: Vec<&str> = l.split(',').map(str::trim).collect();
|
||||
if f.len() != 8 {
|
||||
return None;
|
||||
}
|
||||
let num = |s: &str| s.parse::<f64>().unwrap_or(f64::NAN);
|
||||
Some(GpuState {
|
||||
index: f[0].parse().ok()?,
|
||||
name: f[1].to_string(),
|
||||
driver: f[2].to_string(),
|
||||
power_limit_w: num(f[3]),
|
||||
power_draw_w: num(f[4]),
|
||||
sm_clock_mhz: num(f[5]),
|
||||
mem_clock_mhz: num(f[6]),
|
||||
temperature_c: num(f[7]),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
Command::new("hostname")
|
||||
.arg("-f")
|
||||
.output()
|
||||
.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
.unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
|
||||
fn median(xs: &[f64]) -> f64 {
|
||||
let mut v = xs.to_vec();
|
||||
v.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
let n = v.len();
|
||||
if n == 0 {
|
||||
return f64::NAN;
|
||||
}
|
||||
if n % 2 == 1 {
|
||||
v[n / 2]
|
||||
} else {
|
||||
(v[n / 2 - 1] + v[n / 2]) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
/// One search window: search from a random start until `cancel` fires,
|
||||
/// returning hashes and elapsed seconds. The difficulty is high enough that
|
||||
/// a solution is effectively impossible, matching production where the second
|
||||
/// squeeze is almost never taken.
|
||||
fn window(engine: &dyn MinerEngine, secs: u64, rng: &mut impl RngCore) -> (u64, f64) {
|
||||
let mut header = [0u8; 32];
|
||||
rng.fill_bytes(&mut header);
|
||||
let ctx = engine.prepare_context(header, U512::from(1u64) << 200);
|
||||
|
||||
let mut start_bytes = [0u8; 64];
|
||||
rng.fill_bytes(&mut start_bytes);
|
||||
start_bytes[0] = 0; // keep clear of the top so the range cannot wrap
|
||||
let start = U512::from_big_endian(&start_bytes);
|
||||
let range = Range {
|
||||
start,
|
||||
end: start + (U512::from(1u64) << 200),
|
||||
};
|
||||
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let timer_flag = flag.clone();
|
||||
let timer = std::thread::spawn(move || {
|
||||
std::thread::sleep(Duration::from_secs(secs));
|
||||
timer_flag.store(true, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
let t0 = Instant::now();
|
||||
let status = engine.search_range(&ctx, range, &AtomicBoolCancelCheck(&flag));
|
||||
let elapsed = t0.elapsed().as_secs_f64();
|
||||
timer.join().expect("timer thread");
|
||||
|
||||
let hashes = match status {
|
||||
EngineStatus::Cancelled { hash_count } | EngineStatus::Exhausted { hash_count } => {
|
||||
hash_count
|
||||
}
|
||||
EngineStatus::Found { hash_count, .. } => {
|
||||
log::warn!("window found a solution at 2^-200 odds; counting hashes anyway");
|
||||
hash_count
|
||||
}
|
||||
other => panic!("unexpected engine status: {other:?}"),
|
||||
};
|
||||
(hashes, elapsed)
|
||||
}
|
||||
|
||||
fn parity(engine: &dyn MinerEngine, jobs: usize) -> Parity {
|
||||
// Same shape as engine-gpu's gpu_cpu_parity example, kept in lockstep with it.
|
||||
let mut rng = rand::rng();
|
||||
let cancel_flag = AtomicBool::new(false);
|
||||
let cancel = AtomicBoolCancelCheck(&cancel_flag);
|
||||
let mut found = 0usize;
|
||||
let mut ok = true;
|
||||
for job in 0..jobs {
|
||||
let mut header = [0u8; 32];
|
||||
rng.fill_bytes(&mut header);
|
||||
let ctx = engine.prepare_context(header, U512::from(100_000u64));
|
||||
let start = if job == 0 {
|
||||
// Cross a 2^256 boundary to exercise the midstate batch clamp.
|
||||
(U512::from(3u64) << 256) - U512::from(1_000u64)
|
||||
} else {
|
||||
let mut b = [0u8; 64];
|
||||
rng.fill_bytes(&mut b);
|
||||
b[0] = 0;
|
||||
U512::from_big_endian(&b)
|
||||
};
|
||||
let range = Range {
|
||||
start,
|
||||
end: start + U512::from(10_000_000u64),
|
||||
};
|
||||
match engine.search_range(&ctx, range.clone(), &cancel) {
|
||||
EngineStatus::Found { candidate, .. } => {
|
||||
let cpu = pow_core::hash_from_nonce(&ctx, candidate.nonce);
|
||||
let in_range = candidate.nonce >= range.start && candidate.nonce <= range.end;
|
||||
if cpu != candidate.hash || cpu >= ctx.target || !in_range {
|
||||
log::error!(
|
||||
"parity job {job}: nonce {} gpu_hash {} cpu_hash {} in_range {in_range}",
|
||||
candidate.nonce,
|
||||
candidate.hash,
|
||||
cpu
|
||||
);
|
||||
ok = false;
|
||||
}
|
||||
found += 1;
|
||||
}
|
||||
EngineStatus::Exhausted { .. } => {}
|
||||
other => {
|
||||
log::error!("parity job {job}: unexpected status {other:?}");
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if found == 0 {
|
||||
log::error!("parity: no solutions found across {jobs} jobs");
|
||||
ok = false;
|
||||
}
|
||||
Parity { jobs, found, ok }
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
let args = Args::parse();
|
||||
if args.runs == 0 || args.workers == 0 {
|
||||
eprintln!("--runs and --workers must be at least 1");
|
||||
std::process::exit(2);
|
||||
}
|
||||
|
||||
let gpu_before = nvidia_smi();
|
||||
if let Some(want) = args.expect_power_limit {
|
||||
for g in &gpu_before {
|
||||
if (g.power_limit_w - want as f64).abs() > 0.5 {
|
||||
eprintln!(
|
||||
"gpu {} ({}) power limit is {:.0} W, expected {want} W; refusing to measure",
|
||||
g.index, g.name, g.power_limit_w
|
||||
);
|
||||
std::process::exit(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let engine = Arc::new(Engine::open(&args.engine, args.batch_size));
|
||||
let devices = engine.device_count();
|
||||
if args.workers > devices {
|
||||
log::warn!(
|
||||
"{} workers on {devices} device(s): threads share cards, so per-worker numbers \
|
||||
are shares and total_median_mhs is the number to compare",
|
||||
args.workers
|
||||
);
|
||||
}
|
||||
log::info!(
|
||||
"engine {} with {devices} device(s); measuring {} worker(s), {} x {}s windows after {}s warm-up, batch {}",
|
||||
engine.as_dyn().name(),
|
||||
args.workers,
|
||||
args.runs,
|
||||
args.duration_secs,
|
||||
args.warmup_secs,
|
||||
args.batch_size
|
||||
);
|
||||
|
||||
let mut handles = Vec::new();
|
||||
for w in 0..args.workers {
|
||||
let engine = engine.clone();
|
||||
let (runs, dur, warm) = (args.runs, args.duration_secs, args.warmup_secs);
|
||||
handles.push(std::thread::spawn(move || {
|
||||
let mut rng = rand::rng();
|
||||
if warm > 0 {
|
||||
let _ = window(engine.as_dyn(), warm, &mut rng);
|
||||
}
|
||||
let mut windows = Vec::with_capacity(runs);
|
||||
for i in 0..runs {
|
||||
let (h, s) = window(engine.as_dyn(), dur, &mut rng);
|
||||
let mhs = h as f64 / s / 1e6;
|
||||
log::info!("worker {w} window {i}: {h} hashes in {s:.2}s = {mhs:.2} MH/s");
|
||||
windows.push(mhs);
|
||||
}
|
||||
let med = median(&windows);
|
||||
let (mn, mx) = windows
|
||||
.iter()
|
||||
.fold((f64::INFINITY, f64::NEG_INFINITY), |(a, b), &x| {
|
||||
(a.min(x), b.max(x))
|
||||
});
|
||||
WorkerResult {
|
||||
worker: w,
|
||||
windows_mhs: windows,
|
||||
median_mhs: med,
|
||||
spread: if med > 0.0 { (mx - mn) / med } else { f64::NAN },
|
||||
}
|
||||
}));
|
||||
}
|
||||
let results: Vec<WorkerResult> = handles
|
||||
.into_iter()
|
||||
.map(|h| h.join().expect("worker thread"))
|
||||
.collect();
|
||||
let gpu_after = nvidia_smi();
|
||||
|
||||
let parity_result = if args.parity_jobs > 0 {
|
||||
Some(parity(engine.as_dyn(), args.parity_jobs))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let record = Record {
|
||||
schema: 2,
|
||||
timestamp_unix: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
host: hostname(),
|
||||
commit: args.commit.clone(),
|
||||
label: args.label.clone(),
|
||||
engine: engine.as_dyn().name().to_string(),
|
||||
batch_size: args.batch_size,
|
||||
duration_secs: args.duration_secs,
|
||||
runs: args.runs,
|
||||
warmup_secs: args.warmup_secs,
|
||||
workers: args.workers,
|
||||
gpu_before,
|
||||
gpu_after,
|
||||
total_median_mhs: results.iter().map(|r| r.median_mhs).sum(),
|
||||
results,
|
||||
parity: parity_result,
|
||||
};
|
||||
|
||||
// Human summary on stdout (the workflow appends it to the job summary).
|
||||
println!("## quantus-bench {} on {}", record.commit, record.host);
|
||||
println!();
|
||||
println!("| worker | median MH/s | spread | windows |");
|
||||
println!("| --- | --- | --- | --- |");
|
||||
for r in &record.results {
|
||||
let w: Vec<String> = r.windows_mhs.iter().map(|x| format!("{x:.1}")).collect();
|
||||
println!(
|
||||
"| {} | {:.2} | {:.1}% | {} |",
|
||||
r.worker,
|
||||
r.median_mhs,
|
||||
r.spread * 100.0,
|
||||
w.join(", ")
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(
|
||||
"total median: **{:.2} MH/s** (batch {}, {} x {}s, engine {})",
|
||||
record.total_median_mhs,
|
||||
record.batch_size,
|
||||
record.runs,
|
||||
record.duration_secs,
|
||||
record.engine
|
||||
);
|
||||
for g in &record.gpu_after {
|
||||
println!(
|
||||
"- gpu {} {}: driver {}, limit {:.0} W, draw {:.0} W, sm {:.0} MHz, mem {:.0} MHz, {:.0} C",
|
||||
g.index,
|
||||
g.name,
|
||||
g.driver,
|
||||
g.power_limit_w,
|
||||
g.power_draw_w,
|
||||
g.sm_clock_mhz,
|
||||
g.mem_clock_mhz,
|
||||
g.temperature_c
|
||||
);
|
||||
}
|
||||
if let Some(p) = &record.parity {
|
||||
println!(
|
||||
"- parity: {} ({}/{} jobs found solutions, all verified against CPU)",
|
||||
if p.ok { "OK" } else { "FAILED" },
|
||||
p.found,
|
||||
p.jobs
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(path) = &args.json {
|
||||
let s = serde_json::to_string_pretty(&record).expect("serialise record");
|
||||
std::fs::write(path, s).expect("write json record");
|
||||
log::info!("record written to {}", path.display());
|
||||
}
|
||||
|
||||
let spread_bad = record.results.iter().any(|r| r.spread > 0.02);
|
||||
if spread_bad {
|
||||
eprintln!("spread above 2% on at least one worker; run is not stable enough to compare");
|
||||
}
|
||||
if record.parity.as_ref().is_some_and(|p| !p.ok) {
|
||||
eprintln!("PARITY FAILED");
|
||||
std::process::exit(4);
|
||||
}
|
||||
if spread_bad {
|
||||
std::process::exit(5);
|
||||
}
|
||||
}
|
||||
18
crates/engine-cuda/Cargo.toml
Normal file
18
crates/engine-cuda/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "engine-cuda"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish = false
|
||||
description = "lair: native CUDA mining engine behind MinerEngine (quantus/miner#3)"
|
||||
|
||||
[dependencies]
|
||||
engine-cpu = { path = "../engine-cpu" }
|
||||
pow-core = { path = "../pow-core" }
|
||||
metrics = { path = "../metrics" }
|
||||
primitive-types = { workspace = true }
|
||||
log = { workspace = true }
|
||||
cudarc = { version = "0.19", default-features = false, features = ["driver", "nvrtc", "dynamic-loading", "cuda-13000"] }
|
||||
qp-poseidon-constants = "1.1"
|
||||
|
||||
[build-dependencies]
|
||||
qp-poseidon-constants = "1.1"
|
||||
196
crates/engine-cuda/build.rs
Normal file
196
crates/engine-cuda/build.rs
Normal file
@@ -0,0 +1,196 @@
|
||||
//! lair: build the CUDA mining kernel into a fat binary (quantus/miner#3, #8).
|
||||
//!
|
||||
//! 1. Generate `poseidon2_constants.cuh` from `qp-poseidon-constants`, so a
|
||||
//! change to the hash at origin reaches the kernel as a dependency bump and
|
||||
//! never as a hand-copied table (rule from quantus/miner#1).
|
||||
//! 2. If `nvcc` is available, compile `src/kernels/mining.cu` once with one
|
||||
//! cubin per fleet architecture plus PTX for the newest, into
|
||||
//! `$OUT_DIR/mining.fatbin`. The driver picks the matching cubin at load;
|
||||
//! an unknown future card JIT-compiles the PTX.
|
||||
//! 3. If `nvcc` is not available (the `rust` lint runner, a workstation without
|
||||
//! CUDA), write an empty fat binary and warn. The crate still compiles;
|
||||
//! `CudaEngine::try_new` then fails with a clear message and the miner
|
||||
//! falls back to the wgpu engine.
|
||||
//!
|
||||
//! Knobs (environment):
|
||||
//! NVCC / CUDA_HOME where nvcc is
|
||||
//! MINER_CUDA_ARCHS comma-separated SM list, default 86,89,120
|
||||
//! MINER_NVCC_CCBIN host compiler for nvcc (-ccbin)
|
||||
//! MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER=1 pass -allow-unsupported-compiler
|
||||
//! MINER_CUDA_REQUIRE=1 fail the build instead of warning when nvcc is missing
|
||||
//! MINER_NVCC_FLAGS extra nvcc arguments (whitespace-separated), e.g. -DLAIR_LAZY_ADD=0
|
||||
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
fn write_constants(out: &Path) {
|
||||
use qp_poseidon_constants as c;
|
||||
let mut s = String::new();
|
||||
s.push_str("// Generated by engine-cuda/build.rs from qp-poseidon-constants. Do not edit.\n");
|
||||
s.push_str("#pragma once\n");
|
||||
let arr = |name: &str, v: &[u64]| {
|
||||
let body: Vec<String> = v.iter().map(|x| format!("0x{x:016x}ull")).collect();
|
||||
format!(
|
||||
"__device__ __constant__ unsigned long long {}[{}] = {{\n {}\n}};\n",
|
||||
name,
|
||||
v.len(),
|
||||
body.join(",\n ")
|
||||
)
|
||||
};
|
||||
let arr2 = |name: &str, v: &[[u64; 12]]| {
|
||||
let rows: Vec<String> = v
|
||||
.iter()
|
||||
.map(|r| {
|
||||
let body: Vec<String> = r.iter().map(|x| format!("0x{x:016x}ull")).collect();
|
||||
format!(" {{{}}}", body.join(", "))
|
||||
})
|
||||
.collect();
|
||||
format!(
|
||||
"__device__ __constant__ unsigned long long {}[{}][12] = {{\n{}\n}};\n",
|
||||
name,
|
||||
v.len(),
|
||||
rows.join(",\n")
|
||||
)
|
||||
};
|
||||
s.push_str(&arr("RC_INTERNAL", &c::POSEIDON2_INTERNAL_CONSTANTS_RAW));
|
||||
s.push_str(&arr2(
|
||||
"RC_INITIAL",
|
||||
&c::POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW,
|
||||
));
|
||||
s.push_str(&arr2(
|
||||
"RC_TERMINAL",
|
||||
&c::POSEIDON2_TERMINAL_EXTERNAL_CONSTANTS_RAW,
|
||||
));
|
||||
s.push_str(&arr("MDS_DIAG", &c::POSEIDON2_MATRIX_DIAG_12_RAW));
|
||||
s.push_str(&format!(
|
||||
"#define SPONGE_WIDTH {}\n#define N_EXTERNAL_HALF {}\n#define N_INTERNAL {}\n",
|
||||
c::SPONGE_WIDTH,
|
||||
c::POSEIDON2_EXTERNAL_ROUNDS / 2,
|
||||
c::POSEIDON2_INTERNAL_ROUNDS
|
||||
));
|
||||
fs::write(out.join("poseidon2_constants.cuh"), s).expect("write constants header");
|
||||
}
|
||||
|
||||
fn find_nvcc() -> Option<PathBuf> {
|
||||
if let Ok(p) = env::var("NVCC") {
|
||||
let p = PathBuf::from(p);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
if let Ok(home) = env::var("CUDA_HOME").or_else(|_| env::var("CUDA_PATH")) {
|
||||
let p = PathBuf::from(home).join("bin").join("nvcc");
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
if let Ok(path) = env::var("PATH") {
|
||||
for dir in env::split_paths(&path) {
|
||||
let p = dir.join("nvcc");
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The toolkit the fleet drivers support; prefer an exact 13.0 over "latest".
|
||||
for cand in [
|
||||
"/usr/local/cuda-13.0",
|
||||
"/usr/local/cuda-13",
|
||||
"/usr/local/cuda",
|
||||
] {
|
||||
let p = PathBuf::from(cand).join("bin").join("nvcc");
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
println!("cargo:rerun-if-changed=src/kernels/mining.cu");
|
||||
for v in [
|
||||
"NVCC",
|
||||
"CUDA_HOME",
|
||||
"CUDA_PATH",
|
||||
"MINER_CUDA_ARCHS",
|
||||
"MINER_NVCC_CCBIN",
|
||||
"MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER",
|
||||
"MINER_CUDA_REQUIRE",
|
||||
"MINER_NVCC_FLAGS",
|
||||
] {
|
||||
println!("cargo:rerun-if-env-changed={v}");
|
||||
}
|
||||
|
||||
let out = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR"));
|
||||
write_constants(&out);
|
||||
|
||||
let archs = env::var("MINER_CUDA_ARCHS").unwrap_or_else(|_| "86,89,120".to_string());
|
||||
let sms: Vec<String> = archs
|
||||
.split(',')
|
||||
.map(|s| s.trim().trim_start_matches("sm_").to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
println!("cargo:rustc-env=MINER_CUDA_ARCHS={}", sms.join(","));
|
||||
|
||||
let fatbin = out.join("mining.fatbin");
|
||||
let Some(nvcc) = find_nvcc() else {
|
||||
if env::var("MINER_CUDA_REQUIRE")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
panic!("nvcc not found and MINER_CUDA_REQUIRE=1; set NVCC or CUDA_HOME");
|
||||
}
|
||||
println!("cargo:warning=engine-cuda: nvcc not found; CUDA kernel not compiled into this binary (wgpu engine will be used)");
|
||||
fs::write(&fatbin, []).expect("write empty fatbin");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut cmd = Command::new(&nvcc);
|
||||
cmd.arg("-fatbin")
|
||||
.arg("-O3")
|
||||
.arg("-std=c++17")
|
||||
.arg("-I")
|
||||
.arg(&out)
|
||||
.arg("-o")
|
||||
.arg(&fatbin);
|
||||
if let Ok(cc) = env::var("MINER_NVCC_CCBIN") {
|
||||
cmd.arg("-ccbin").arg(cc);
|
||||
}
|
||||
if env::var("MINER_CUDA_ALLOW_UNSUPPORTED_COMPILER")
|
||||
.map(|v| v == "1")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
cmd.arg("-allow-unsupported-compiler");
|
||||
}
|
||||
if let Ok(extra) = env::var("MINER_NVCC_FLAGS") {
|
||||
for a in extra.split_whitespace() {
|
||||
cmd.arg(a);
|
||||
}
|
||||
}
|
||||
for sm in &sms {
|
||||
cmd.arg("--generate-code")
|
||||
.arg(format!("arch=compute_{sm},code=sm_{sm}"));
|
||||
}
|
||||
if let Some(newest) = sms.iter().max_by_key(|s| s.parse::<u32>().unwrap_or(0)) {
|
||||
cmd.arg("--generate-code")
|
||||
.arg(format!("arch=compute_{newest},code=compute_{newest}"));
|
||||
}
|
||||
cmd.arg("src/kernels/mining.cu");
|
||||
|
||||
println!(
|
||||
"cargo:warning=engine-cuda: {} for sm_{{{}}}",
|
||||
nvcc.display(),
|
||||
sms.join(",")
|
||||
);
|
||||
let status = cmd.status().expect("run nvcc");
|
||||
if !status.success() {
|
||||
panic!("nvcc failed ({status}); see output above");
|
||||
}
|
||||
let size = fs::metadata(&fatbin).map(|m| m.len()).unwrap_or(0);
|
||||
if size == 0 {
|
||||
panic!("nvcc produced an empty fat binary");
|
||||
}
|
||||
}
|
||||
876
crates/engine-cuda/src/kernels/mining.cu
Normal file
876
crates/engine-cuda/src/kernels/mining.cu
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
630
crates/engine-cuda/src/lib.rs
Normal file
630
crates/engine-cuda/src/lib.rs
Normal file
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ simd-poseidon2 = []
|
||||
|
||||
[dependencies]
|
||||
engine-cpu = { path = "../engine-cpu" }
|
||||
metrics = { path = "../metrics" } # lair: per-device metrics (quantus/miner#9)
|
||||
pow-core = { path = "../pow-core" }
|
||||
primitive-types = { workspace = true }
|
||||
log = { workspace = true }
|
||||
|
||||
@@ -6,6 +6,14 @@ 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");
|
||||
@@ -55,6 +63,7 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
|
||||
@@ -106,6 +115,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
|
||||
@@ -157,6 +167,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_solution_finding(c: &mut Criterion) {
|
||||
@@ -208,6 +219,7 @@ fn bench_solution_finding(c: &mut Criterion) {
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_throughput_per_second(c: &mut Criterion) {
|
||||
@@ -259,6 +271,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
fn bench_gpu_batch_efficiency(c: &mut Criterion) {
|
||||
@@ -335,6 +348,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
|
||||
});
|
||||
|
||||
group.finish();
|
||||
teardown_gpu(gpu_engine);
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
|
||||
63
crates/engine-gpu/examples/gpu_cpu_parity.rs
Normal file
63
crates/engine-gpu/examples/gpu_cpu_parity.rs
Normal file
@@ -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");
|
||||
}
|
||||
30
crates/engine-gpu/examples/gpu_features.rs
Normal file
30
crates/engine-gpu/examples/gpu_features.rs
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
52
crates/engine-gpu/examples/hashrate.rs
Normal file
52
crates/engine-gpu/examples/hashrate.rs
Normal file
@@ -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
|
||||
);
|
||||
}
|
||||
229
crates/engine-gpu/examples/trusted_hashrate.rs
Normal file
229
crates/engine-gpu/examples/trusted_hashrate.rs
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ 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...");
|
||||
|
||||
@@ -28,15 +29,17 @@ pub async fn test_end_to_end_mining(
|
||||
|
||||
// 4. Run GPU Mining for this specific nonce
|
||||
|
||||
// Header Buffer
|
||||
let mut header_u32s = [0u32; 8];
|
||||
for (i, item) in header_u32s.iter_mut().enumerate() {
|
||||
let chunk = &ctx.header[i * 4..(i + 1) * 4];
|
||||
*item = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
// 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 header_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Header Buffer"),
|
||||
contents: bytemuck::cast_slice(&header_u32s),
|
||||
let midstate_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: Some("Midstate Buffer"),
|
||||
contents: bytemuck::cast_slice(&midstate_u32s),
|
||||
usage: wgpu::BufferUsages::STORAGE,
|
||||
});
|
||||
|
||||
@@ -82,11 +85,11 @@ pub async fn test_end_to_end_mining(
|
||||
let zeros = vec![0u8; results_size];
|
||||
queue.write_buffer(&results_buffer, 0, &zeros);
|
||||
|
||||
// Dispatch config buffer: [total_threads, nonces_per_thread, total_nonces, cancel_check_interval]
|
||||
let dispatch_config_data: [u32; 4] = [256, 1, 256, 10000];
|
||||
// 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: 16,
|
||||
size: 12,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -96,25 +99,9 @@ pub async fn test_end_to_end_mining(
|
||||
bytemuck::cast_slice(&dispatch_config_data),
|
||||
);
|
||||
|
||||
// Cancel flag buffer: 0 = running, 1 = cancel requested
|
||||
let cancel_flag_data: [u32; 1] = [0];
|
||||
let cancel_flag_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Cancel Flag Buffer"),
|
||||
size: 4,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
queue.write_buffer(
|
||||
&cancel_flag_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&cancel_flag_data),
|
||||
);
|
||||
|
||||
// Load Shader
|
||||
let shader_source = include_str!("mining.wgsl");
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Mining Shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
|
||||
source: wgpu::ShaderSource::Wgsl(shader_src.into()),
|
||||
});
|
||||
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
@@ -137,7 +124,7 @@ pub async fn test_end_to_end_mining(
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: header_buffer.as_entire_binding(),
|
||||
resource: midstate_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
@@ -151,10 +138,6 @@ pub async fn test_end_to_end_mining(
|
||||
binding: 4,
|
||||
resource: dispatch_config_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 5,
|
||||
resource: cancel_flag_buffer.as_entire_binding(),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
|
||||
@@ -423,6 +423,31 @@ const QUALCOMM_TIERS: &[GpuTier] = &[
|
||||
];
|
||||
|
||||
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",
|
||||
|
||||
@@ -191,7 +191,9 @@ const MDS_MATRIX_DIAG_12: array<array<u32, 2>, 12> = array<array<u32, 2>, 12>(
|
||||
|
||||
// Storage buffers
|
||||
@group(0) @binding(0) var<storage, read_write> results: array<atomic<u32>>;
|
||||
@group(0) @binding(1) var<storage, read> header: array<u32, 8>; // 32 bytes
|
||||
// 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]
|
||||
@@ -787,17 +789,8 @@ fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
|
||||
return poseidon2_hash_squeeze_twice(input);
|
||||
}
|
||||
|
||||
// Check if hash < target (U512 comparison)
|
||||
fn is_below_target(hash: array<u32, 16>, difficulty_tgt: array<u32, 16>) -> bool {
|
||||
// Compare from most significant to least significant
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
if (hash[15u - i] < difficulty_tgt[15u - i]) {
|
||||
return true;
|
||||
} else if (hash[15u - i] > difficulty_tgt[15u - i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false; // Equal, not below
|
||||
fn bswap32(v: u32) -> u32 {
|
||||
return ((v & 0xFFu) << 24u) | ((v & 0xFF00u) << 8u) | ((v >> 8u) & 0xFF00u) | (v >> 24u);
|
||||
}
|
||||
|
||||
// Main mining kernel
|
||||
@@ -834,61 +827,90 @@ fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
return;
|
||||
}
|
||||
|
||||
// current_nonce = start_nonce + logical_index
|
||||
// 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;
|
||||
|
||||
// Add logical_index into the low limb and propagate carry through the U512 nonce
|
||||
let val0 = start_nonce[0];
|
||||
let sum0 = val0 + logical_index;
|
||||
current_nonce[0] = sum0;
|
||||
carry = select(0u, 1u, sum0 < val0);
|
||||
|
||||
// Propagate carry through remaining limbs
|
||||
for (var i = 1u; i < 16u; i++) {
|
||||
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);
|
||||
}
|
||||
|
||||
// Construct input (96 bytes = 24 u32s)
|
||||
// Header (32 bytes = 8 u32s) followed by Nonce (64 bytes = 16 u32s)
|
||||
var input: array<u32, 24>;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
input[i] = header[i];
|
||||
for (var i = 8u; i < 16u; i++) {
|
||||
current_nonce[i] = start_nonce[i];
|
||||
}
|
||||
// Nonce needs to be Big Endian in the byte stream for hashing.
|
||||
// current_nonce is Little Endian words.
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
let val = current_nonce[15u - i];
|
||||
// Reverse bytes
|
||||
|
||||
// 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);
|
||||
input[8u + i] = rev;
|
||||
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);
|
||||
|
||||
// Hash (Big Endian)
|
||||
let hash_be = hash_squeeze_twice(input);
|
||||
|
||||
// Convert to Little Endian for difficulty check and storage
|
||||
// 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 < 16u; i++) {
|
||||
let val = hash_be[15u - i];
|
||||
// Reverse bytes in u32
|
||||
hash_le[i] = ((val & 0xFFu) << 24u) |
|
||||
((val & 0xFF00u) << 8u) |
|
||||
((val & 0xFF0000u) >> 8u) |
|
||||
((val & 0xFF000000u) >> 24u);
|
||||
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;
|
||||
}
|
||||
|
||||
// Check target
|
||||
if (is_below_target(hash_le, difficulty_target)) {
|
||||
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) {
|
||||
// We won! Write nonce and hash
|
||||
// results layout: [0]=found, [1..16]=nonce, [17..32]=hash
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
atomicStore(&results[1u + i], current_nonce[i]);
|
||||
573
crates/engine-gpu/src/kernels/mining_u64.wgsl
Normal file
573
crates/engine-gpu/src/kernels/mining_u64.wgsl
Normal file
@@ -0,0 +1,573 @@
|
||||
// Native-u64 Poseidon2 mining kernel. Default path for non-Apple GPUs.
|
||||
// Requires wgpu Features::SHADER_INT64. Bit-exact with mining.wgsl / pow_core.
|
||||
|
||||
@group(0) @binding(0) var<storage, read_write> results: array<atomic<u32>>;
|
||||
// Sponge state after absorbing header + high nonce half (12 felts as LE u32 pairs),
|
||||
// precomputed on the host per batch. See pow_core::mining_midstate.
|
||||
@group(0) @binding(1) var<storage, read> midstate: array<u32, 24>;
|
||||
@group(0) @binding(2) var<storage, read> start_nonce: array<u32, 16>;
|
||||
@group(0) @binding(3) var<storage, read> difficulty_target: array<u32, 16>;
|
||||
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 3>;
|
||||
|
||||
const P64: u64 = 0xFFFFFFFF00000001lu;
|
||||
// EPS64 = 2^32 - 1 = 2^64 mod P
|
||||
const EPS64: u64 = 0xFFFFFFFFlu;
|
||||
|
||||
const RC_INTERNAL: array<u64, 22> = array<u64, 22>(
|
||||
0x97f7798a784ad863lu, 0xd1d2bf082f60d4f0lu, 0x69a377a79f9ad206lu, 0xa9d06906a3858e24lu, 0x295275001eede5b5lu, 0x5874e441117bd746lu, 0x8a084bbba8ed86cclu, 0x3defd7645cde6425lu, 0x3998cfe6871cc137lu, 0x3e52ef8bca48314alu, 0x964a209f85dc9ecclu, 0x3fcc9ee82cc4577elu, 0x8e79b4a5d0096d6dlu, 0x8492362ad2392556lu, 0xee72f470262574d6lu, 0x1e0e18496da2444alu, 0x0f3a74bf215eaac6lu, 0x1b061b76a1c0ded3lu, 0x192c42d86803d7a6lu, 0xf6d49ff997ae0260lu, 0x3ec372e7a0fa3786lu, 0x5538cdf4f23445d3lu
|
||||
);
|
||||
const RC_INITIAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
|
||||
array<u64, 12>(0xc002e770975b1607lu, 0xbca51a8dfe14593alu, 0x72938dfbe774f7f9lu, 0xe4f2fe29e03234aclu, 0xd5e0ba2f541b6449lu, 0xec33b868f3cc46c1lu, 0x486dcb55419d475alu, 0x6c1cb2a358cc24f1lu, 0xe3f30d509a1436bblu, 0xd9a64f068dca7c29lu, 0xe59b3f57aabba1aelu, 0x2a3dd4505b478fdclu),
|
||||
array<u64, 12>(0xada1f8dc7676ed25lu, 0x2711aa8b5509d516lu, 0x4ae6acd0c9c92897lu, 0x56eb3d6b5256d67alu, 0x1f7a9d55923bf51elu, 0x3600427d397a7f68lu, 0xe5076df75b72c3d0lu, 0xfcd59aa12c6090adlu, 0xcd895e8c68b57a9elu, 0x41df7ef9d730ae3elu, 0xee3e2b889abe977dlu, 0xd29bb7edbeb9c405lu),
|
||||
array<u64, 12>(0x7d5c08eef608e382lu, 0x89ae889caaf0802clu, 0xb35a8e976d2af617lu, 0xdb14234eafaf5173lu, 0x78f04462d48b1c98lu, 0x265293b0e47ce88alu, 0x999a649b69b9d32flu, 0x64b0a186698e01d3lu, 0xee0b22d0dfae8bb8lu, 0x4fd53e50ca04a7eelu, 0x5762bfe181f25047lu, 0xf51593e2beb5e3bdlu),
|
||||
array<u64, 12>(0x1e5e2b5760e32477lu, 0x622462a1f9aaaeedlu, 0xaa284b3ecdb222aelu, 0x63c8e72f542bf3fclu, 0x3ba588cacb43b5e0lu, 0x23eda6f3c99150ddlu, 0xaad3bea4baac9a5alu, 0xe9da8d699b94184alu, 0xcdb13f4cd93e024clu, 0x902cbd0956f655e3lu, 0x5b4e40ffc759532flu, 0xde795c20a2357af7lu)
|
||||
);
|
||||
const RC_TERMINAL: array<array<u64, 12>, 4> = array<array<u64, 12>, 4>(
|
||||
array<u64, 12>(0x7b72c539e0ea4c6elu, 0x144573dae2ce9976lu, 0x802028b68f35fc88lu, 0x6d36c5022c4fe7c2lu, 0xa205d0ffa9b9def3lu, 0xf6e7e38b1ea6ba2flu, 0x34f7909ae5258d64lu, 0xb0464d9d77b97fcalu, 0x64ddb9d5de7e00a6lu, 0x0ed0d75c27975d97lu, 0x1cbb36f11127338blu, 0x6673e505cfd0b6balu),
|
||||
array<u64, 12>(0x605f902830872e01lu, 0x3fd5eb927e95fe4flu, 0xe81025b5a24c69cdlu, 0xf7d0ce75de23f74elu, 0xf39942b6a8585089lu, 0x6d808a08f7b71df6lu, 0xf8806b6588f49a8blu, 0x57df2d8c2a32107alu, 0x16e7c2074d654a2dlu, 0x213de241fcf33835lu, 0xb0f2b8905a0976f6lu, 0xd8e3cf2bbd355417lu),
|
||||
array<u64, 12>(0xe498691679d9330flu, 0x763b45d2a3821b28lu, 0x0908bf65eb0a1f0dlu, 0x7691eb2d194b24f4lu, 0x0e43551233ae13b2lu, 0x93c393dbfc2fe76flu, 0x98f607485d48cdealu, 0xe3d95f30309819c0lu, 0x1ef581a93eaf6acflu, 0x0b24c1b7a030fca4lu, 0x624370be5670b327lu, 0x5f1e28615a11e486lu),
|
||||
array<u64, 12>(0xfe04051f909e042blu, 0x7257e5b147fd3803lu, 0xe6ae134bb82f2e78lu, 0x5711fd5cf4784511lu, 0xf83a42660c08c0bclu, 0x2cd8c96d9a3ce855lu, 0x7d2ffb1bb0e17271lu, 0x85ae1528caea3811lu, 0x52a345d5c7adb0b8lu, 0x504c4c51f3faee94lu, 0xbce34a649cfccaf9lu, 0xe0a3389266fb6dc9lu)
|
||||
);
|
||||
const MDS_DIAG: array<u64, 12> = array<u64, 12>(
|
||||
0xc3b6c08e23ba9300lu, 0xd84b5de94a324fb6lu, 0x0d0c371c5b35b84flu, 0x7964f570e7188037lu, 0x5daf18bbd996604blu, 0x6743bc47b9595257lu, 0x5528b9362c59bb70lu, 0xac45e25b7127b68blu, 0xa2077d7dfbb606b5lu, 0xf3faac6faee378aelu, 0x0c6388b51545e883lu, 0xd27dbb6944917b60lu
|
||||
);
|
||||
|
||||
// a + b mod P in lazy form. Wrapping carries fold back via 2^64 ≡ EPS64 (mod P).
|
||||
fn gf64_add(a: u64, b: u64) -> u64 {
|
||||
let s0 = a + b;
|
||||
let c1 = s0 < a;
|
||||
let s1 = s0 + select(0lu, EPS64, c1);
|
||||
let c2 = c1 && (s1 < s0);
|
||||
return s1 + select(0lu, EPS64, c2);
|
||||
}
|
||||
|
||||
// Reduce a 128-bit value (lo + hi*2^64) mod P using
|
||||
// 2^64 ≡ EPS64 and 2^96 ≡ -1 (mod P).
|
||||
fn gf64_reduce(lo: u64, hi: u64) -> u64 {
|
||||
let hi_hi = hi >> 32u;
|
||||
let hi_lo = hi & EPS64;
|
||||
var t0 = lo - hi_hi;
|
||||
t0 = t0 - select(0lu, EPS64, lo < hi_hi);
|
||||
let t1 = hi_lo * EPS64;
|
||||
let t2 = t0 + t1;
|
||||
return t2 + select(0lu, EPS64, t2 < t0);
|
||||
}
|
||||
|
||||
fn gf64_mul(a: u64, b: u64) -> u64 {
|
||||
let a_lo = a & EPS64;
|
||||
let a_hi = a >> 32u;
|
||||
let b_lo = b & EPS64;
|
||||
let b_hi = b >> 32u;
|
||||
let ll = a_lo * b_lo;
|
||||
let lh = a_lo * b_hi;
|
||||
let hl = a_hi * b_lo;
|
||||
let hh = a_hi * b_hi;
|
||||
let mid = lh + hl;
|
||||
let mid_c = select(0lu, 1lu, mid < lh);
|
||||
let lo = ll + (mid << 32u);
|
||||
let lo_c = select(0lu, 1lu, lo < ll);
|
||||
let hi = hh + (mid >> 32u) + (mid_c << 32u) + lo_c;
|
||||
return gf64_reduce(lo, hi);
|
||||
}
|
||||
|
||||
fn gf64_sqr(a: u64) -> u64 {
|
||||
let a_lo = a & EPS64;
|
||||
let a_hi = a >> 32u;
|
||||
let ll = a_lo * a_lo;
|
||||
let lh = a_lo * a_hi;
|
||||
let hh = a_hi * a_hi;
|
||||
let mid = lh << 1u;
|
||||
let mid_c = lh >> 63u;
|
||||
let lo = ll + (mid << 32u);
|
||||
let lo_c = select(0lu, 1lu, lo < ll);
|
||||
let hi = hh + (mid >> 32u) + (mid_c << 32u) + lo_c;
|
||||
return gf64_reduce(lo, hi);
|
||||
}
|
||||
|
||||
fn gf64_sbox(x: u64) -> u64 {
|
||||
let x2 = gf64_sqr(x);
|
||||
let x4 = gf64_sqr(x2);
|
||||
let x6 = gf64_mul(x4, x2);
|
||||
return gf64_mul(x6, x);
|
||||
}
|
||||
|
||||
fn gf64_canon(a: u64) -> u64 {
|
||||
return a - select(0lu, P64, a >= P64);
|
||||
}
|
||||
|
||||
// External linear layer: 4x4 MDS on each chunk, then circulant sums.
|
||||
fn ext_layer64(state: ptr<function, array<u64, 12>>) {
|
||||
for (var chunk = 0u; chunk < 3u; chunk++) {
|
||||
let o = chunk * 4u;
|
||||
let x0 = (*state)[o];
|
||||
let x1 = (*state)[o + 1u];
|
||||
let x2 = (*state)[o + 2u];
|
||||
let x3 = (*state)[o + 3u];
|
||||
let t01 = gf64_add(x0, x1);
|
||||
let t23 = gf64_add(x2, x3);
|
||||
let t0123 = gf64_add(t01, t23);
|
||||
let t01123 = gf64_add(t0123, x1);
|
||||
let t01233 = gf64_add(t0123, x3);
|
||||
(*state)[o + 3u] = gf64_add(t01233, gf64_add(x0, x0));
|
||||
(*state)[o + 1u] = gf64_add(t01123, gf64_add(x2, x2));
|
||||
(*state)[o] = gf64_add(t01123, t01);
|
||||
(*state)[o + 2u] = gf64_add(t01233, t23);
|
||||
}
|
||||
var sums: array<u64, 4>;
|
||||
for (var k = 0u; k < 4u; k++) {
|
||||
sums[k] = gf64_add(gf64_add((*state)[k], (*state)[k + 4u]), (*state)[k + 8u]);
|
||||
}
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf64_add((*state)[i], sums[i % 4u]);
|
||||
}
|
||||
}
|
||||
|
||||
// Internal linear layer: diagonal matrix plus full sum.
|
||||
fn int_layer64(state: ptr<function, array<u64, 12>>) {
|
||||
var sum = (*state)[0];
|
||||
for (var i = 1u; i < 12u; i++) {
|
||||
sum = gf64_add(sum, (*state)[i]);
|
||||
}
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf64_add(gf64_mul((*state)[i], MDS_DIAG[i]), sum);
|
||||
}
|
||||
}
|
||||
|
||||
fn permute64(state: ptr<function, array<u64, 12>>) {
|
||||
ext_layer64(state);
|
||||
for (var r = 0u; r < 4u; r++) {
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf64_add((*state)[i], RC_INITIAL[r][i]);
|
||||
}
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf64_sbox((*state)[i]);
|
||||
}
|
||||
ext_layer64(state);
|
||||
}
|
||||
for (var r = 0u; r < 22u; r++) {
|
||||
(*state)[0] = gf64_sbox(gf64_add((*state)[0], RC_INTERNAL[r]));
|
||||
int_layer64(state);
|
||||
}
|
||||
for (var r = 0u; r < 4u; r++) {
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf64_add((*state)[i], RC_TERMINAL[r][i]);
|
||||
}
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf64_sbox((*state)[i]);
|
||||
}
|
||||
ext_layer64(state);
|
||||
}
|
||||
}
|
||||
|
||||
fn bswap32(v: u32) -> u32 {
|
||||
return ((v & 0xFFu) << 24u) | ((v & 0xFF00u) << 8u) | ((v >> 8u) & 0xFF00u) | (v >> 24u);
|
||||
}
|
||||
|
||||
@compute @workgroup_size(256)
|
||||
fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
|
||||
if (atomicLoad(&results[0]) != 0u) {
|
||||
return;
|
||||
}
|
||||
let thread_id = global_id.x;
|
||||
let total_threads = dispatch_config[0];
|
||||
let nonces_per_thread = dispatch_config[1];
|
||||
let total_nonces = dispatch_config[2];
|
||||
if (thread_id >= total_threads) {
|
||||
return;
|
||||
}
|
||||
let base_index = thread_id * nonces_per_thread;
|
||||
|
||||
// Hoist uniform storage reads out of the nonce loop
|
||||
var mid: array<u64, 12>;
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
mid[i] = (u64(midstate[2u * i + 1u]) << 32u) | u64(midstate[2u * i]);
|
||||
}
|
||||
var tgt: array<u32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
tgt[i] = difficulty_target[i];
|
||||
}
|
||||
var nonce_base: array<u32, 16>;
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
nonce_base[i] = start_nonce[i];
|
||||
}
|
||||
|
||||
for (var j = 0u; j < nonces_per_thread; j = j + 1u) {
|
||||
let logical_index = base_index + j;
|
||||
if (logical_index >= total_nonces) {
|
||||
break;
|
||||
}
|
||||
if (j > 0u && atomicLoad(&results[0]) != 0u) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The host guarantees a batch never carries into the high nonce half
|
||||
// (limbs 8..15), so only the low 256 bits are incremented here.
|
||||
var current_nonce: array<u32, 16>;
|
||||
let val0 = nonce_base[0];
|
||||
let sum0 = val0 + logical_index;
|
||||
current_nonce[0] = sum0;
|
||||
var carry = select(0u, 1u, sum0 < val0);
|
||||
for (var i = 1u; i < 8u; i++) {
|
||||
let val = nonce_base[i];
|
||||
let sum = val + carry;
|
||||
current_nonce[i] = sum;
|
||||
carry = select(0u, 1u, sum < val);
|
||||
}
|
||||
for (var i = 8u; i < 16u; i++) {
|
||||
current_nonce[i] = nonce_base[i];
|
||||
}
|
||||
|
||||
// Resume the sponge from the precomputed midstate: absorb the low
|
||||
// nonce half, pad, squeeze twice (3 permutations instead of 5).
|
||||
var st: array<u64, 12>;
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
st[i] = mid[i];
|
||||
}
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
st[i] = gf64_add(st[i], u64(bswap32(current_nonce[7u - i])));
|
||||
}
|
||||
permute64(&st);
|
||||
st[0] = gf64_add(st[0], 1lu);
|
||||
st[1] = gf64_add(st[1], 1lu);
|
||||
permute64(&st);
|
||||
|
||||
// First squeeze yields the most significant 256 bits of the hash, which
|
||||
// decide hash-vs-target on their own unless they exactly equal the
|
||||
// target's high half. Only candidates pay for the second squeeze, and
|
||||
// byte-swapped hash words are produced on demand during the compare.
|
||||
var first: array<u32, 8>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let c = gf64_canon(st[i]);
|
||||
first[2u * i] = u32(c & EPS64);
|
||||
first[2u * i + 1u] = u32(c >> 32u);
|
||||
}
|
||||
var cmp = 0u;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let h = bswap32(first[i]);
|
||||
let t = tgt[15u - i];
|
||||
if (h != t) {
|
||||
cmp = select(2u, 1u, h > t);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cmp == 1u) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var hash_le: array<u32, 16>;
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
hash_le[15u - i] = bswap32(first[i]);
|
||||
}
|
||||
permute64(&st);
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let c = gf64_canon(st[i]);
|
||||
hash_le[7u - 2u * i] = bswap32(u32(c & EPS64));
|
||||
hash_le[6u - 2u * i] = bswap32(u32(c >> 32u));
|
||||
}
|
||||
var below = cmp == 2u;
|
||||
if (!below) {
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
let h = hash_le[7u - i];
|
||||
let t = tgt[7u - i];
|
||||
if (h != t) {
|
||||
below = h < t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (below) {
|
||||
if (atomicExchange(&results[0], 1u) == 0u) {
|
||||
for (var i = 0u; i < 16u; i++) {
|
||||
atomicStore(&results[1u + i], current_nonce[i]);
|
||||
atomicStore(&results[17u + i], hash_le[i]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Compatibility layer: same API as mining.wgsl, backed by the u64 core above.
|
||||
// Only used by the component test harness; the mining kernel never calls it.
|
||||
// All outputs are canonical, matching the reference implementation.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct GoldilocksField {
|
||||
limb0: u32,
|
||||
limb1: u32,
|
||||
}
|
||||
|
||||
const INTERNAL_CONSTANTS: array<array<u32, 2>, 22> = array<array<u32, 2>, 22>(
|
||||
array<u32, 2>(2018170979u, 2549578122u),
|
||||
array<u32, 2>(794875120u, 3520249608u),
|
||||
array<u32, 2>(2677723654u, 1772320679u),
|
||||
array<u32, 2>(2743438884u, 2849007878u),
|
||||
array<u32, 2>(518907317u, 693269760u),
|
||||
array<u32, 2>(293328710u, 1484055617u),
|
||||
array<u32, 2>(2834138828u, 2315799483u),
|
||||
array<u32, 2>(1558078501u, 1039128420u),
|
||||
array<u32, 2>(2266808631u, 966316006u),
|
||||
array<u32, 2>(3393728842u, 1045622667u),
|
||||
array<u32, 2>(2245828300u, 2521440415u),
|
||||
array<u32, 2>(751064958u, 1070374632u),
|
||||
array<u32, 2>(3490278765u, 2390340773u),
|
||||
array<u32, 2>(3526960470u, 2224174634u),
|
||||
array<u32, 2>(639988950u, 4000511088u),
|
||||
array<u32, 2>(1839350858u, 504240201u),
|
||||
array<u32, 2>(559852230u, 255489215u),
|
||||
array<u32, 2>(2713771731u, 453385078u),
|
||||
array<u32, 2>(1745082278u, 422331096u),
|
||||
array<u32, 2>(2544763488u, 4141129721u),
|
||||
array<u32, 2>(2700752774u, 1052996327u),
|
||||
array<u32, 2>(4063512019u, 1429786100u)
|
||||
);
|
||||
|
||||
const INITIAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(2539329031u, 3221415792u),
|
||||
array<u32, 2>(4262746426u, 3164936845u),
|
||||
array<u32, 2>(3883202553u, 1922272763u),
|
||||
array<u32, 2>(3761386668u, 3841130025u),
|
||||
array<u32, 2>(1411081289u, 3588274735u),
|
||||
array<u32, 2>(4090250945u, 3962812520u),
|
||||
array<u32, 2>(1100826458u, 1215155029u),
|
||||
array<u32, 2>(1489773809u, 1813820067u),
|
||||
array<u32, 2>(2585015995u, 3824356688u),
|
||||
array<u32, 2>(2378857513u, 3651555078u),
|
||||
array<u32, 2>(2864423342u, 3852156759u),
|
||||
array<u32, 2>(1531416540u, 708695120u)
|
||||
),
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(1987505445u, 2913073372u),
|
||||
array<u32, 2>(1426707734u, 655469195u),
|
||||
array<u32, 2>(3385403543u, 1256631504u),
|
||||
array<u32, 2>(1381422714u, 1458257259u),
|
||||
array<u32, 2>(2453402910u, 528129365u),
|
||||
array<u32, 2>(964329320u, 905986685u),
|
||||
array<u32, 2>(1534247888u, 3842469367u),
|
||||
array<u32, 2>(744525997u, 4241857185u),
|
||||
array<u32, 2>(1756723870u, 3448331916u),
|
||||
array<u32, 2>(3610291774u, 1105166073u),
|
||||
array<u32, 2>(2596181885u, 3997051784u),
|
||||
array<u32, 2>(3199845381u, 3533420525u)
|
||||
),
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(4127777666u, 2103183598u),
|
||||
array<u32, 2>(2867888172u, 2309916828u),
|
||||
array<u32, 2>(1831532055u, 3009056407u),
|
||||
array<u32, 2>(2947502451u, 3675530062u),
|
||||
array<u32, 2>(3565886616u, 2029012066u),
|
||||
array<u32, 2>(3833391242u, 642945968u),
|
||||
array<u32, 2>(1773785903u, 2577032347u),
|
||||
array<u32, 2>(1770914259u, 1689297286u),
|
||||
array<u32, 2>(3752758200u, 3993707216u),
|
||||
array<u32, 2>(3389302766u, 1339375184u),
|
||||
array<u32, 2>(2180141127u, 1466089441u),
|
||||
array<u32, 2>(3199591357u, 4111832034u)
|
||||
),
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(1625498743u, 509487959u),
|
||||
array<u32, 2>(4188712685u, 1646551713u),
|
||||
array<u32, 2>(3451003566u, 2854767422u),
|
||||
array<u32, 2>(1412166652u, 1674110767u),
|
||||
array<u32, 2>(3410212320u, 1000704202u),
|
||||
array<u32, 2>(3381743837u, 602777331u),
|
||||
array<u32, 2>(3131873882u, 2866003620u),
|
||||
array<u32, 2>(2610174026u, 3923414377u),
|
||||
array<u32, 2>(3644719692u, 3450945356u),
|
||||
array<u32, 2>(1458984419u, 2418851081u),
|
||||
array<u32, 2>(3344519983u, 1531855103u),
|
||||
array<u32, 2>(2721413879u, 3732495392u)
|
||||
)
|
||||
);
|
||||
|
||||
const TERMINAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(3773451374u, 2071119161u),
|
||||
array<u32, 2>(3805190518u, 340095962u),
|
||||
array<u32, 2>(2402679944u, 2149591222u),
|
||||
array<u32, 2>(743434178u, 1832305922u),
|
||||
array<u32, 2>(2847530739u, 2718290175u),
|
||||
array<u32, 2>(514243119u, 4142392203u),
|
||||
array<u32, 2>(3844443492u, 888639642u),
|
||||
array<u32, 2>(2008645578u, 2957397405u),
|
||||
array<u32, 2>(3732799654u, 1692252629u),
|
||||
array<u32, 2>(664231319u, 248567644u),
|
||||
array<u32, 2>(287781771u, 482031345u),
|
||||
array<u32, 2>(3486561978u, 1718871301u)
|
||||
),
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(814165505u, 1616875560u),
|
||||
array<u32, 2>(2123759183u, 1070984082u),
|
||||
array<u32, 2>(2722916813u, 3893372341u),
|
||||
array<u32, 2>(3726899022u, 4157656693u),
|
||||
array<u32, 2>(2824360073u, 4086907574u),
|
||||
array<u32, 2>(4155973110u, 1837140488u),
|
||||
array<u32, 2>(2297731723u, 4169165669u),
|
||||
array<u32, 2>(707924090u, 1474243980u),
|
||||
array<u32, 2>(1298483757u, 384287239u),
|
||||
array<u32, 2>(4243798069u, 557703745u),
|
||||
array<u32, 2>(1510569718u, 2968696976u),
|
||||
array<u32, 2>(3174388759u, 3638808363u)
|
||||
),
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(2044277519u, 3835193622u),
|
||||
array<u32, 2>(2743212840u, 1983595986u),
|
||||
array<u32, 2>(3943309069u, 151568229u),
|
||||
array<u32, 2>(424355060u, 1989274413u),
|
||||
array<u32, 2>(867046322u, 239293714u),
|
||||
array<u32, 2>(4230997871u, 2479068123u),
|
||||
array<u32, 2>(1565052394u, 2566260552u),
|
||||
array<u32, 2>(815274432u, 3822673712u),
|
||||
array<u32, 2>(1051683535u, 519405993u),
|
||||
array<u32, 2>(2687564964u, 186958263u),
|
||||
array<u32, 2>(1450226471u, 1648586942u),
|
||||
array<u32, 2>(1511122054u, 1595811937u)
|
||||
),
|
||||
array<array<u32, 2>, 12>(
|
||||
array<u32, 2>(2426274859u, 4261676319u),
|
||||
array<u32, 2>(1207777283u, 1918363057u),
|
||||
array<u32, 2>(3090099832u, 3870167883u),
|
||||
array<u32, 2>(4101522705u, 1460796764u),
|
||||
array<u32, 2>(201900220u, 4164567654u),
|
||||
array<u32, 2>(2587682901u, 752404845u),
|
||||
array<u32, 2>(2967564913u, 2100296475u),
|
||||
array<u32, 2>(3404347409u, 2242778408u),
|
||||
array<u32, 2>(3350048952u, 1386431957u),
|
||||
array<u32, 2>(4093308564u, 1347177553u),
|
||||
array<u32, 2>(2633812729u, 3169012324u),
|
||||
array<u32, 2>(1727753673u, 3768793234u)
|
||||
)
|
||||
);
|
||||
|
||||
fn gf_pack(g: GoldilocksField) -> u64 {
|
||||
return (u64(g.limb1) << 32u) | u64(g.limb0);
|
||||
}
|
||||
|
||||
fn gf_unpack(v: u64) -> GoldilocksField {
|
||||
let c = gf64_canon(v);
|
||||
return GoldilocksField(u32(c & EPS64), u32(c >> 32u));
|
||||
}
|
||||
|
||||
fn gf_from_limbs(l0: u32, l1: u32) -> GoldilocksField {
|
||||
return GoldilocksField(l0, l1);
|
||||
}
|
||||
|
||||
fn gf_zero() -> GoldilocksField {
|
||||
return GoldilocksField(0u, 0u);
|
||||
}
|
||||
|
||||
fn gf_one() -> GoldilocksField {
|
||||
return GoldilocksField(1u, 0u);
|
||||
}
|
||||
|
||||
fn gf_from_u32(val: u32) -> GoldilocksField {
|
||||
return GoldilocksField(val, 0u);
|
||||
}
|
||||
|
||||
fn gf_from_u64_parts(low: u32, high: u32) -> GoldilocksField {
|
||||
return gf_unpack((u64(high) << 32u) | u64(low));
|
||||
}
|
||||
|
||||
fn gf_from_const(val: array<u32, 2>) -> GoldilocksField {
|
||||
return gf_from_u64_parts(val[0], val[1]);
|
||||
}
|
||||
|
||||
fn gf_add(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
|
||||
return gf_unpack(gf64_add(gf_pack(a), gf_pack(b)));
|
||||
}
|
||||
|
||||
fn gf_mul(a: GoldilocksField, b: GoldilocksField) -> GoldilocksField {
|
||||
return gf_unpack(gf64_mul(gf_pack(a), gf_pack(b)));
|
||||
}
|
||||
|
||||
fn sbox(x: GoldilocksField) -> GoldilocksField {
|
||||
return gf_unpack(gf64_sbox(gf_pack(x)));
|
||||
}
|
||||
|
||||
fn state_pack(state: ptr<function, array<GoldilocksField, 12>>, out: ptr<function, array<u64, 12>>) {
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*out)[i] = gf_pack((*state)[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn state_unpack(v: ptr<function, array<u64, 12>>, state: ptr<function, array<GoldilocksField, 12>>) {
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
(*state)[i] = gf_unpack((*v)[i]);
|
||||
}
|
||||
}
|
||||
|
||||
fn external_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
|
||||
var st: array<u64, 12>;
|
||||
state_pack(state, &st);
|
||||
ext_layer64(&st);
|
||||
state_unpack(&st, state);
|
||||
}
|
||||
|
||||
fn internal_linear_layer(state: ptr<function, array<GoldilocksField, 12>>) {
|
||||
var st: array<u64, 12>;
|
||||
state_pack(state, &st);
|
||||
int_layer64(&st);
|
||||
state_unpack(&st, state);
|
||||
}
|
||||
|
||||
fn poseidon2_permute(state: ptr<function, array<GoldilocksField, 12>>) {
|
||||
var st: array<u64, 12>;
|
||||
state_pack(state, &st);
|
||||
permute64(&st);
|
||||
state_unpack(&st, state);
|
||||
}
|
||||
|
||||
fn bytes_to_field_elements(input: array<u32, 24>) -> array<GoldilocksField, 25> {
|
||||
var felts: array<GoldilocksField, 25>;
|
||||
for (var i = 0u; i < 24u; i++) {
|
||||
felts[i] = gf_from_u32(input[i]);
|
||||
}
|
||||
felts[24] = gf_one();
|
||||
return felts;
|
||||
}
|
||||
|
||||
fn field_elements_to_bytes(felts: array<GoldilocksField, 4>) -> array<u32, 8> {
|
||||
var result: array<u32, 8>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
result[i * 2u] = felts[i].limb0;
|
||||
result[i * 2u + 1u] = felts[i].limb1;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
|
||||
var st: array<u64, 12>;
|
||||
for (var i = 0u; i < 12u; i++) {
|
||||
st[i] = 0lu;
|
||||
}
|
||||
for (var chunk = 0u; chunk < 3u; chunk++) {
|
||||
for (var i = 0u; i < 8u; i++) {
|
||||
st[i] = gf64_add(st[i], u64(input[chunk * 8u + i]));
|
||||
}
|
||||
permute64(&st);
|
||||
}
|
||||
st[0] = gf64_add(st[0], 1lu);
|
||||
st[1] = gf64_add(st[1], 1lu);
|
||||
permute64(&st);
|
||||
|
||||
var result: array<u32, 16>;
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let c = gf64_canon(st[i]);
|
||||
result[2u * i] = u32(c & EPS64);
|
||||
result[2u * i + 1u] = u32(c >> 32u);
|
||||
}
|
||||
permute64(&st);
|
||||
for (var i = 0u; i < 4u; i++) {
|
||||
let c = gf64_canon(st[i]);
|
||||
result[8u + 2u * i] = u32(c & EPS64);
|
||||
result[8u + 2u * i + 1u] = u32(c >> 32u);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
|
||||
return poseidon2_hash_squeeze_twice(input);
|
||||
}
|
||||
650
crates/engine-gpu/src/kernels/mining_u64_apple.wgsl
Normal file
650
crates/engine-gpu/src/kernels/mining_u64_apple.wgsl
Normal file
@@ -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);
|
||||
}
|
||||
116
crates/engine-gpu/src/kernels/mod.rs
Normal file
116
crates/engine-gpu/src/kernels/mod.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
//! Poseidon2 mining kernels.
|
||||
//!
|
||||
//! Same `mining_main` bindings; must stay bit-exact with `pow_core`.
|
||||
//!
|
||||
//! - Apple Metal + `SHADER_INT64` → Apple Metal u64 (`mining_u64_apple.wgsl`)
|
||||
//! - other GPUs + `SHADER_INT64` → native u64 (`mining_u64.wgsl`)
|
||||
//! - no `SHADER_INT64` → 32-bit fallback (`mining.wgsl`)
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum Kernel {
|
||||
U32,
|
||||
Default,
|
||||
Apple,
|
||||
}
|
||||
|
||||
impl Kernel {
|
||||
pub const fn needs_int64(self) -> bool {
|
||||
!matches!(self, Self::U32)
|
||||
}
|
||||
|
||||
pub const fn id(self) -> &'static str {
|
||||
match self {
|
||||
Self::U32 => "u32",
|
||||
Self::Default => "u64",
|
||||
Self::Apple => "u64-apple",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::U32 => "32-bit",
|
||||
Self::Default => "native-u64",
|
||||
Self::Apple => "native-u64 Apple Metal",
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn source(self) -> &'static str {
|
||||
match self {
|
||||
Self::U32 => include_str!("mining.wgsl"),
|
||||
Self::Default => include_str!("mining_u64.wgsl"),
|
||||
Self::Apple => include_str!("mining_u64_apple.wgsl"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn for_adapter(adapter: &wgpu::Adapter) -> Self {
|
||||
Self::for_adapter_info(&adapter.get_info(), adapter.features())
|
||||
}
|
||||
|
||||
pub fn for_adapter_info(info: &wgpu::AdapterInfo, features: wgpu::Features) -> Self {
|
||||
if !features.contains(wgpu::Features::SHADER_INT64) {
|
||||
return Self::U32;
|
||||
}
|
||||
if info.backend == wgpu::Backend::Metal {
|
||||
Self::Apple
|
||||
} else {
|
||||
Self::Default
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn all() -> &'static [Self] {
|
||||
&[Self::U32, Self::Default, Self::Apple]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn info(backend: wgpu::Backend) -> wgpu::AdapterInfo {
|
||||
wgpu::AdapterInfo {
|
||||
name: "test".into(),
|
||||
vendor: 0,
|
||||
device: 0,
|
||||
device_type: wgpu::DeviceType::DiscreteGpu,
|
||||
driver: String::new(),
|
||||
driver_info: String::new(),
|
||||
backend,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metal_with_int64_selects_apple() {
|
||||
assert_eq!(
|
||||
Kernel::for_adapter_info(&info(wgpu::Backend::Metal), wgpu::Features::SHADER_INT64),
|
||||
Kernel::Apple
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vulkan_with_int64_selects_default() {
|
||||
assert_eq!(
|
||||
Kernel::for_adapter_info(&info(wgpu::Backend::Vulkan), wgpu::Features::SHADER_INT64),
|
||||
Kernel::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dx12_with_int64_selects_default() {
|
||||
assert_eq!(
|
||||
Kernel::for_adapter_info(&info(wgpu::Backend::Dx12), wgpu::Features::SHADER_INT64),
|
||||
Kernel::Default
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_int64_falls_back_to_u32() {
|
||||
assert_eq!(
|
||||
Kernel::for_adapter_info(&info(wgpu::Backend::Metal), wgpu::Features::empty()),
|
||||
Kernel::U32
|
||||
);
|
||||
assert_eq!(
|
||||
Kernel::for_adapter_info(&info(wgpu::Backend::Vulkan), wgpu::Features::empty()),
|
||||
Kernel::U32
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
#![deny(rust_2018_idioms)]
|
||||
#![forbid(unsafe_code)]
|
||||
#![deny(unsafe_code)]
|
||||
|
||||
mod gpu_tiers;
|
||||
mod kernels;
|
||||
|
||||
pub mod end_to_end_tests;
|
||||
pub mod tests;
|
||||
|
||||
pub use kernels::Kernel;
|
||||
|
||||
use engine_cpu::{CancelCheck, Candidate, EngineStatus, FoundOrigin, MinerEngine, Range};
|
||||
use pow_core::{format_hashrate, format_u512, JobContext};
|
||||
use primitive_types::U512;
|
||||
@@ -17,6 +20,8 @@ use std::sync::{
|
||||
|
||||
/// Represents a single GPU device context.
|
||||
struct GpuContext {
|
||||
// lair: per-device metric handles, labelled with the kernel id (quantus/miner#9).
|
||||
metrics: metrics::DeviceMetrics,
|
||||
device: wgpu::Device,
|
||||
queue: wgpu::Queue,
|
||||
pipeline: wgpu::ComputePipeline,
|
||||
@@ -27,7 +32,7 @@ struct GpuContext {
|
||||
|
||||
#[derive(Clone)]
|
||||
struct GpuResources {
|
||||
header_buffer: wgpu::Buffer,
|
||||
midstate_buffer: wgpu::Buffer,
|
||||
target_buffer: wgpu::Buffer,
|
||||
start_nonce_buffer: wgpu::Buffer,
|
||||
results_buffer: wgpu::Buffer,
|
||||
@@ -37,29 +42,34 @@ struct GpuResources {
|
||||
}
|
||||
|
||||
pub struct GpuEngine {
|
||||
engine_id: usize,
|
||||
contexts: Vec<Arc<GpuContext>>,
|
||||
device_counter: AtomicUsize,
|
||||
batch_size: u32,
|
||||
throttle_ms: u64,
|
||||
}
|
||||
|
||||
// Thread-local storage for consistent GPU device assignment per worker thread
|
||||
static ENGINE_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
// Thread-local storage for consistent GPU device assignment per worker thread.
|
||||
// Entries are tagged with the owning engine's id so resources created for one
|
||||
// GpuEngine (and its devices) are never used with another (issue seen in benches
|
||||
// where multiple engines exist in one process).
|
||||
thread_local! {
|
||||
static ASSIGNED_GPU_DEVICE: RefCell<Option<usize>> = const { RefCell::new(None) };
|
||||
static WORKER_RESOURCES: RefCell<Option<GpuResources>> = const { RefCell::new(None) };
|
||||
/// Set to true when this worker's GPU device is lost/unresponsive.
|
||||
/// Once set, the worker will immediately return Cancelled on any search attempt.
|
||||
static DEVICE_LOST: RefCell<bool> = const { RefCell::new(false) };
|
||||
static ASSIGNED_GPU_DEVICE: RefCell<Option<(usize, usize)>> = const { RefCell::new(None) };
|
||||
static WORKER_RESOURCES: RefCell<Option<(usize, GpuResources)>> = const { RefCell::new(None) };
|
||||
/// Engine id whose GPU device was lost/unresponsive for this worker thread.
|
||||
static DEVICE_LOST: RefCell<Option<usize>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
impl GpuContext {
|
||||
fn create_resources(&self) -> GpuResources {
|
||||
let bind_group_layout = self.pipeline.get_bind_group_layout(0);
|
||||
|
||||
// Header: 8 u32s
|
||||
let header_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Header Buffer"),
|
||||
size: 32,
|
||||
// Midstate: 12 felts as LE u32 pairs
|
||||
let midstate_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Midstate Buffer"),
|
||||
size: 96,
|
||||
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
@@ -116,7 +126,7 @@ impl GpuContext {
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 1,
|
||||
resource: header_buffer.as_entire_binding(),
|
||||
resource: midstate_buffer.as_entire_binding(),
|
||||
},
|
||||
wgpu::BindGroupEntry {
|
||||
binding: 2,
|
||||
@@ -134,7 +144,7 @@ impl GpuContext {
|
||||
});
|
||||
|
||||
GpuResources {
|
||||
header_buffer,
|
||||
midstate_buffer,
|
||||
target_buffer,
|
||||
start_nonce_buffer,
|
||||
results_buffer,
|
||||
@@ -145,6 +155,26 @@ impl GpuContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the mining shader module without naga's runtime bounds checks and
|
||||
/// loop bounding (~9% faster kernels).
|
||||
///
|
||||
/// SAFETY: the sources are the static mining kernels compiled into this binary;
|
||||
/// every buffer access is a constant-bounded loop index into fixed-size
|
||||
/// bindings the engine itself allocates, and all loops have static bounds
|
||||
/// (verified by the component test suites against every kernel path).
|
||||
#[allow(unsafe_code)]
|
||||
fn create_trusted_shader(device: &wgpu::Device, shader_source: &str) -> wgpu::ShaderModule {
|
||||
unsafe {
|
||||
device.create_shader_module_trusted(
|
||||
wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Mining Shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
|
||||
},
|
||||
wgpu::ShaderRuntimeChecks::unchecked(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank backends for mining: native compute APIs first.
|
||||
fn backend_rank(backend: wgpu::Backend) -> u8 {
|
||||
match backend {
|
||||
@@ -332,11 +362,17 @@ impl GpuEngine {
|
||||
);
|
||||
log::debug!(target: "gpu_engine", "Adapter {i} raw info: {info:?}");
|
||||
|
||||
let kernel = Kernel::for_adapter(&adapter);
|
||||
|
||||
// Try to initialize this adapter with a proper timeout.
|
||||
// If the driver hangs, we'll skip this adapter after the timeout.
|
||||
let device_future = adapter.request_device(&wgpu::DeviceDescriptor {
|
||||
label: Some("Mining Device"),
|
||||
required_features: wgpu::Features::empty(),
|
||||
required_features: if kernel.needs_int64() {
|
||||
wgpu::Features::SHADER_INT64
|
||||
} else {
|
||||
wgpu::Features::empty()
|
||||
},
|
||||
required_limits: wgpu::Limits::default(),
|
||||
memory_hints: Default::default(),
|
||||
..Default::default()
|
||||
@@ -379,11 +415,14 @@ impl GpuEngine {
|
||||
// Shader and pipeline creation are synchronous - can't timeout, but usually fast
|
||||
let pipeline_start = std::time::Instant::now();
|
||||
|
||||
let shader_source = include_str!("mining.wgsl");
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Mining Shader"),
|
||||
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
|
||||
});
|
||||
log::info!(
|
||||
target: "gpu_engine",
|
||||
"GPU device {i} ({}) using {} [{}]",
|
||||
info.name,
|
||||
kernel.label(),
|
||||
kernel.id()
|
||||
);
|
||||
let shader = create_trusted_shader(&device, kernel.source());
|
||||
|
||||
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
|
||||
label: Some("Mining Pipeline"),
|
||||
@@ -406,6 +445,7 @@ impl GpuEngine {
|
||||
|
||||
initialized.push(InitializedGpu {
|
||||
context: Arc::new(GpuContext {
|
||||
metrics: metrics::DeviceMetrics::new(i, kernel.id()),
|
||||
device,
|
||||
queue,
|
||||
pipeline,
|
||||
@@ -451,6 +491,7 @@ impl GpuEngine {
|
||||
);
|
||||
|
||||
Ok(Self {
|
||||
engine_id: ENGINE_ID_COUNTER.fetch_add(1, Ordering::SeqCst),
|
||||
contexts,
|
||||
device_counter: AtomicUsize::new(0),
|
||||
batch_size,
|
||||
@@ -464,11 +505,20 @@ impl GpuEngine {
|
||||
}
|
||||
|
||||
/// Explicitly clear thread-local GPU resources.
|
||||
/// Call this before thread exit to avoid TLS destruction order issues with wgpu.
|
||||
/// Call this before dropping a `GpuEngine` (or before thread exit) so
|
||||
/// buffers are destroyed while the device is still alive — otherwise the
|
||||
/// next engine on this thread can reuse dead buffer IDs and panic with
|
||||
/// `Buffer[…] does not exist`.
|
||||
pub fn clear_worker_resources() {
|
||||
WORKER_RESOURCES.with(|resources| {
|
||||
*resources.borrow_mut() = None;
|
||||
});
|
||||
ASSIGNED_GPU_DEVICE.with(|assigned| {
|
||||
*assigned.borrow_mut() = None;
|
||||
});
|
||||
DEVICE_LOST.with(|lost| {
|
||||
*lost.borrow_mut() = None;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -496,8 +546,8 @@ impl MinerEngine for GpuEngine {
|
||||
return EngineStatus::Exhausted { hash_count: 0 };
|
||||
}
|
||||
|
||||
// Check if this worker's GPU device was previously lost
|
||||
let device_is_lost = DEVICE_LOST.with(|lost| *lost.borrow());
|
||||
// Check if this worker's GPU device was previously lost (for this engine)
|
||||
let device_is_lost = DEVICE_LOST.with(|lost| *lost.borrow() == Some(self.engine_id));
|
||||
if device_is_lost {
|
||||
// Device was lost in a previous call - signal worker should exit
|
||||
return EngineStatus::DeviceLost { hash_count: 0 };
|
||||
@@ -516,50 +566,42 @@ impl MinerEngine for GpuEngine {
|
||||
// Use thread-local assignment for consistent worker-to-GPU mapping
|
||||
let device_index = ASSIGNED_GPU_DEVICE.with(|assigned| {
|
||||
let mut assigned_ref = assigned.borrow_mut();
|
||||
if let Some(index) = *assigned_ref {
|
||||
index
|
||||
} else {
|
||||
let index = if self.contexts.len() == 1 {
|
||||
0
|
||||
} else {
|
||||
self.device_counter.fetch_add(1, Ordering::SeqCst) % self.contexts.len()
|
||||
};
|
||||
*assigned_ref = Some(index);
|
||||
log::info!(
|
||||
target: "gpu_engine",
|
||||
"Worker thread assigned to GPU device {} (of {} total devices)",
|
||||
index,
|
||||
self.contexts.len()
|
||||
);
|
||||
index
|
||||
match *assigned_ref {
|
||||
Some((engine_id, index)) if engine_id == self.engine_id => index,
|
||||
_ => {
|
||||
let index = if self.contexts.len() == 1 {
|
||||
0
|
||||
} else {
|
||||
self.device_counter.fetch_add(1, Ordering::SeqCst) % self.contexts.len()
|
||||
};
|
||||
*assigned_ref = Some((self.engine_id, index));
|
||||
log::info!(
|
||||
target: "gpu_engine",
|
||||
"Worker thread assigned to GPU device {} (of {} total devices)",
|
||||
index,
|
||||
self.contexts.len()
|
||||
);
|
||||
index
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let gpu_ctx = &self.contexts[device_index];
|
||||
|
||||
// Ensure resources are initialized for this thread
|
||||
WORKER_RESOURCES.with(|resources_cell| {
|
||||
// Ensure resources are initialized for this thread and belong to this engine
|
||||
let resources = WORKER_RESOURCES.with(|resources_cell| {
|
||||
let mut resources = resources_cell.borrow_mut();
|
||||
if resources.is_none() {
|
||||
*resources = Some(gpu_ctx.create_resources());
|
||||
match &*resources {
|
||||
Some((engine_id, res)) if *engine_id == self.engine_id => res.clone(),
|
||||
_ => {
|
||||
let res = gpu_ctx.create_resources();
|
||||
*resources = Some((self.engine_id, res.clone()));
|
||||
res
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let resources = WORKER_RESOURCES
|
||||
.with(|resources_cell| resources_cell.borrow().as_ref().unwrap().clone());
|
||||
|
||||
// Pre-convert header and target (only needs to be done once per job)
|
||||
let mut header_u32s = [0u32; 8];
|
||||
for (i, item) in header_u32s.iter_mut().enumerate() {
|
||||
let chunk = &ctx.header[i * 4..(i + 1) * 4];
|
||||
*item = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
|
||||
}
|
||||
gpu_ctx.queue.write_buffer(
|
||||
&resources.header_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&header_u32s),
|
||||
);
|
||||
|
||||
// Pre-convert target (only needs to be done once per job)
|
||||
let target_bytes = ctx.target.to_little_endian();
|
||||
let mut target_u32s = [0u32; 16];
|
||||
for i in 0..16 {
|
||||
@@ -576,6 +618,9 @@ impl MinerEngine for GpuEngine {
|
||||
let mut total_hashes: u64 = 0;
|
||||
let mut current_start = range.start;
|
||||
let mut batch_num = 0u64;
|
||||
// lair: hashes of the most recent batch; if the job turns out to have
|
||||
// been superseded while it ran, that batch was wasted work.
|
||||
let mut last_batch_hashes: u64 = 0;
|
||||
|
||||
log::info!(
|
||||
target: "gpu_engine",
|
||||
@@ -590,6 +635,7 @@ impl MinerEngine for GpuEngine {
|
||||
while current_start <= range.end {
|
||||
// Check for cancellation at host level BEFORE starting each batch
|
||||
if cancel.is_cancelled() {
|
||||
gpu_ctx.metrics.record_stale_hashes(last_batch_hashes); // lair
|
||||
let elapsed = search_start.elapsed();
|
||||
let hash_rate = total_hashes as f64 / elapsed.as_secs_f64();
|
||||
log::info!(
|
||||
@@ -606,22 +652,27 @@ impl MinerEngine for GpuEngine {
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate batch range
|
||||
// Calculate batch range. The batch is additionally clamped so nonce
|
||||
// increments never carry into the high 256 bits, which the midstate
|
||||
// precompute (and the kernels) rely 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 batch_size_u512 = U512::from(self.batch_size);
|
||||
let this_batch_size: u32 = if remaining > batch_size_u512 {
|
||||
let this_batch_size: u32 = if cap > batch_size_u512 {
|
||||
self.batch_size
|
||||
} else {
|
||||
// remaining fits in u32 since it's <= batch_size which is u32
|
||||
remaining.low_u32()
|
||||
// cap fits in u32 since it's <= batch_size which is u32
|
||||
cap.low_u32()
|
||||
};
|
||||
|
||||
// Run single batch
|
||||
let batch_result =
|
||||
run_single_batch(gpu_ctx, &resources, current_start, this_batch_size);
|
||||
run_single_batch(gpu_ctx, &resources, ctx, current_start, this_batch_size);
|
||||
|
||||
match batch_result {
|
||||
BatchResult::Found {
|
||||
@@ -652,11 +703,13 @@ impl MinerEngine for GpuEngine {
|
||||
}
|
||||
BatchResult::NotFound { hash_count } => {
|
||||
total_hashes += hash_count;
|
||||
last_batch_hashes = hash_count; // lair
|
||||
}
|
||||
BatchResult::DeviceLost => {
|
||||
// GPU device is lost/unresponsive - mark as permanently dead
|
||||
// and clear resources to prevent "buffer already mapped" panics
|
||||
DEVICE_LOST.with(|lost| *lost.borrow_mut() = true);
|
||||
gpu_ctx.metrics.record_device_lost(); // lair
|
||||
// GPU device is lost/unresponsive - mark as permanently dead
|
||||
// and clear resources to prevent "buffer already mapped" panics
|
||||
DEVICE_LOST.with(|lost| *lost.borrow_mut() = Some(self.engine_id));
|
||||
WORKER_RESOURCES.with(|res| *res.borrow_mut() = None);
|
||||
|
||||
log::error!(
|
||||
@@ -745,10 +798,12 @@ enum BatchResult {
|
||||
fn run_single_batch(
|
||||
gpu_ctx: &GpuContext,
|
||||
resources: &GpuResources,
|
||||
ctx: &JobContext,
|
||||
batch_start: U512,
|
||||
batch_size: u32,
|
||||
) -> BatchResult {
|
||||
// Calculate dispatch configuration for this batch
|
||||
let batch_start_at = std::time::Instant::now(); // lair: batch phase timing
|
||||
// Calculate dispatch configuration for this batch
|
||||
let threads_per_workgroup = 256u32;
|
||||
let limits = gpu_ctx.device.limits();
|
||||
let max_workgroups = limits.max_compute_workgroups_per_dimension;
|
||||
@@ -777,6 +832,20 @@ fn run_single_batch(
|
||||
.queue
|
||||
.write_buffer(&resources.start_nonce_buffer, 0, &start_nonce_bytes);
|
||||
|
||||
// Precompute the sponge midstate for this batch (header + high nonce half)
|
||||
let nonce_be = batch_start.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;
|
||||
}
|
||||
gpu_ctx.queue.write_buffer(
|
||||
&resources.midstate_buffer,
|
||||
0,
|
||||
bytemuck::cast_slice(&midstate_u32s),
|
||||
);
|
||||
|
||||
// Reset results buffer
|
||||
const RESULTS_SIZE: usize = (1 + 16 + 16) * 4;
|
||||
const ZEROS: [u8; RESULTS_SIZE] = [0; RESULTS_SIZE];
|
||||
@@ -806,6 +875,7 @@ fn run_single_batch(
|
||||
);
|
||||
|
||||
gpu_ctx.queue.submit(Some(encoder.finish()));
|
||||
let submitted_at = std::time::Instant::now(); // lair
|
||||
|
||||
// Wait for GPU to complete (blocking)
|
||||
let buffer_slice = resources.staging_buffer.slice(..);
|
||||
@@ -852,6 +922,12 @@ fn run_single_batch(
|
||||
|
||||
// Only reach here if final_status == 1 (success), buffer is mapped
|
||||
debug_assert_eq!(final_status, 1);
|
||||
// lair: gpu = submit to mapped; host = the rest of the batch so far plus
|
||||
// the readback below, which is small and constant.
|
||||
let gpu_time = submitted_at.elapsed();
|
||||
gpu_ctx
|
||||
.metrics
|
||||
.observe_batch(gpu_time, batch_start_at.elapsed() - gpu_time);
|
||||
|
||||
// Read results
|
||||
let data = buffer_slice.get_mapped_range();
|
||||
@@ -880,6 +956,8 @@ fn run_single_batch(
|
||||
drop(data);
|
||||
resources.staging_buffer.unmap();
|
||||
|
||||
gpu_ctx.metrics.record_hashes(hashes_computed); // lair
|
||||
gpu_ctx.metrics.record_solution(); // lair
|
||||
return BatchResult::Found {
|
||||
candidate: Candidate { nonce, work, hash },
|
||||
hash_count: hashes_computed,
|
||||
@@ -889,6 +967,7 @@ fn run_single_batch(
|
||||
drop(data);
|
||||
resources.staging_buffer.unmap();
|
||||
|
||||
gpu_ctx.metrics.record_hashes(dispatched_nonces); // lair
|
||||
BatchResult::NotFound {
|
||||
hash_count: dispatched_nonces,
|
||||
}
|
||||
|
||||
@@ -3,174 +3,130 @@ mod tests;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
run().await
|
||||
}
|
||||
|
||||
async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Setup GPU device and queue
|
||||
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
|
||||
backends: wgpu::Backends::METAL, // Force Metal on Apple
|
||||
backends: wgpu::Backends::PRIMARY,
|
||||
..Default::default()
|
||||
});
|
||||
let adapter = instance
|
||||
.request_adapter(&wgpu::RequestAdapterOptions::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let (device, queue) = adapter
|
||||
.request_device(&wgpu::DeviceDescriptor::default())
|
||||
.await?;
|
||||
.expect("no GPU adapter");
|
||||
|
||||
println!("Running Poseidon2 GPU Component Tests...\n");
|
||||
let mut failures = 0usize;
|
||||
let has_int64 = adapter.features().contains(wgpu::Features::SHADER_INT64);
|
||||
|
||||
// Run all component tests
|
||||
if let Err(e) = tests::test_gf_from_const(&device, &queue).await {
|
||||
eprintln!("❌ gf_from_const tests failed: {}", e);
|
||||
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 let Err(e) = tests::test_gf_mul(&device, &queue).await {
|
||||
eprintln!("❌ gf_mul tests failed: {}", e);
|
||||
if failures > 0 {
|
||||
return Err(format!("{failures} test group(s) failed").into());
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_sbox(&device, &queue).await {
|
||||
eprintln!("❌ S-box tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_mds_matrix(&device, &queue).await {
|
||||
eprintln!("❌ MDS matrix tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_internal_linear_layer(&device, &queue).await {
|
||||
eprintln!("❌ Internal linear layer tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_external_linear_layer(&device, &queue).await {
|
||||
eprintln!("❌ External linear layer tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_initial_external_rounds(&device, &queue).await {
|
||||
eprintln!("❌ Initial external rounds tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_terminal_external_rounds(&device, &queue).await {
|
||||
eprintln!("❌ Terminal external rounds tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_constants_verification(&device, &queue).await {
|
||||
eprintln!("❌ Constants verification test failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_internal_constants_verification(&device, &queue).await {
|
||||
eprintln!("❌ Internal constants verification test failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_internal_rounds_only(&device, &queue).await {
|
||||
eprintln!("❌ Internal rounds only test failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) =
|
||||
tests::test_poseidon2_terminal_external_constants_verification(&device, &queue).await
|
||||
{
|
||||
eprintln!(
|
||||
"❌ Terminal external constants verification test failed: {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_permutation(&device, &queue).await {
|
||||
eprintln!("❌ Poseidon2 permutation tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_bytes_to_field_elements(&device, &queue).await {
|
||||
eprintln!("❌ Bytes to field elements tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_field_elements_to_bytes(&device, &queue).await {
|
||||
eprintln!("❌ Field elements to bytes tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_poseidon2_squeeze_twice(&device, &queue).await {
|
||||
eprintln!("❌ Poseidon2 squeeze-twice tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = tests::test_hash_squeeze_twice(&device, &queue).await {
|
||||
eprintln!("❌ Hash squeeze twice tests failed: {}", e);
|
||||
}
|
||||
|
||||
if let Err(e) = end_to_end_tests::test_end_to_end_mining(&device, &queue).await {
|
||||
eprintln!("❌ End-to-end mining test failed: {}", e);
|
||||
}
|
||||
|
||||
println!("\nAll tests completed!");
|
||||
if let Err(e) = end_to_end_tests::test_end_to_end_mining(&device, &queue).await {
|
||||
eprintln!("❌ End-to-end mining test failed: {}", e);
|
||||
}
|
||||
|
||||
println!("\nAll tests completed!");
|
||||
|
||||
// generate_correct_wgsl_constants();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn generate_correct_wgsl_constants() {
|
||||
use qp_poseidon_constants::*;
|
||||
|
||||
println!("🔧 Generating correct WGSL constants...");
|
||||
|
||||
println!("// Initial external round constants (4 rounds x 12 elements)");
|
||||
println!("const INITIAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(");
|
||||
|
||||
for (round_idx, round) in POSEIDON2_INITIAL_EXTERNAL_CONSTANTS_RAW.iter().enumerate() {
|
||||
println!(" array<array<u32, 2>, 12>(");
|
||||
for (elem_idx, &value) in round.iter().enumerate() {
|
||||
let low = value as u32;
|
||||
let high = (value >> 32) as u32;
|
||||
if elem_idx == 11 {
|
||||
println!(" array<u32, 2>({}u, {}u)", low, high);
|
||||
} else {
|
||||
println!(" array<u32, 2>({}u, {}u),", low, high);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if round_idx == 3 {
|
||||
println!(" )");
|
||||
} else {
|
||||
println!(" ),");
|
||||
}
|
||||
};
|
||||
}
|
||||
println!(");");
|
||||
|
||||
println!("\n// Terminal external round constants (4 rounds x 12 elements)");
|
||||
println!("const TERMINAL_EXTERNAL_CONSTANTS: array<array<array<u32, 2>, 12>, 4> = array<array<array<u32, 2>, 12>, 4>(");
|
||||
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)
|
||||
);
|
||||
|
||||
for (round_idx, round) in POSEIDON2_TERMINAL_EXTERNAL_CONSTANTS_RAW.iter().enumerate() {
|
||||
println!(" array<array<u32, 2>, 12>(");
|
||||
for (elem_idx, &value) in round.iter().enumerate() {
|
||||
let low = value as u32;
|
||||
let high = (value >> 32) as u32;
|
||||
if elem_idx == 11 {
|
||||
println!(" array<u32, 2>({}u, {}u)", low, high);
|
||||
} else {
|
||||
println!(" array<u32, 2>({}u, {}u),", low, high);
|
||||
}
|
||||
}
|
||||
if round_idx == 3 {
|
||||
println!(" )");
|
||||
} else {
|
||||
println!(" ),");
|
||||
}
|
||||
}
|
||||
println!(");");
|
||||
|
||||
println!("\n// Internal round constants (22 values)");
|
||||
println!("const INTERNAL_CONSTANTS: array<array<u32, 2>, 22> = array<array<u32, 2>, 22>(");
|
||||
for (idx, &value) in POSEIDON2_INTERNAL_CONSTANTS_RAW.iter().enumerate() {
|
||||
let low = value as u32;
|
||||
let high = (value >> 32) as u32;
|
||||
if idx == 21 {
|
||||
println!(" array<u32, 2>({}u, {}u)", low, high);
|
||||
} else {
|
||||
println!(" array<u32, 2>({}u, {}u),", low, high);
|
||||
}
|
||||
}
|
||||
println!(");");
|
||||
failures
|
||||
}
|
||||
|
||||
@@ -1166,6 +1166,7 @@ fn generate_true_internal_only_test_vectors() -> Vec<InternalRoundsTestCase> {
|
||||
pub async fn test_poseidon2_permutation(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_poseidon2_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -1202,7 +1203,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -1370,6 +1371,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_initial_external_rounds(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Test just the initial 4 external rounds
|
||||
let test_vectors = generate_gf_from_const_test_vectors();
|
||||
@@ -1415,7 +1417,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -1630,6 +1632,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_terminal_external_rounds(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Test just the terminal 4 external rounds
|
||||
let _total_tests = 2;
|
||||
@@ -1674,7 +1677,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -1888,6 +1891,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_gf_from_const(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Test the gf_from_const function that converts constant arrays to GoldilocksField
|
||||
let test_vectors = generate_gf_from_const_test_vectors();
|
||||
@@ -1908,7 +1912,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
output_fields[index] = gf_from_const(input_constants[index]);
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -2023,6 +2027,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_constants_verification(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut passed_tests = 0;
|
||||
let mut failed_tests = Vec::new();
|
||||
@@ -2049,7 +2054,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -2160,6 +2165,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_internal_rounds_only(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Test ONLY the internal rounds (22 rounds) without initial/terminal external rounds
|
||||
let test_vectors = generate_true_internal_only_test_vectors();
|
||||
@@ -2209,7 +2215,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -2343,6 +2349,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_internal_constants_verification(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut passed_tests = 0;
|
||||
let mut failed_tests = Vec::new();
|
||||
@@ -2364,7 +2371,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -2462,6 +2469,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_terminal_external_rounds_issue(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("🔍 Testing terminal external rounds issue for sequential input...");
|
||||
|
||||
@@ -2502,7 +2510,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -2630,6 +2638,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_terminal_external_constants_verification(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut passed_tests = 0;
|
||||
let mut failed_tests = Vec::new();
|
||||
@@ -2656,7 +2665,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -2764,6 +2773,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_gf_mul(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_gf_mul_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -2786,7 +2796,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
output[i] = gf_mul(input_a[i], input_b[i]);
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -2947,6 +2957,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_mds_matrix(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_mds_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -2955,7 +2966,7 @@ pub async fn test_mds_matrix(
|
||||
let mut failure_details = Vec::new();
|
||||
|
||||
// Read the mining shader source and create a test wrapper
|
||||
let mining_shader_source = include_str!("mining.wgsl");
|
||||
let mining_shader_source = shader_src;
|
||||
let shader_source = format!(
|
||||
"
|
||||
{}
|
||||
@@ -3215,6 +3226,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_internal_linear_layer(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_internal_linear_layer_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -3223,7 +3235,7 @@ pub async fn test_internal_linear_layer(
|
||||
let mut failure_details = Vec::new();
|
||||
|
||||
// Read the mining shader source and create a test wrapper
|
||||
let mining_shader_source = include_str!("mining.wgsl");
|
||||
let mining_shader_source = shader_src;
|
||||
let shader_source = format!(
|
||||
"
|
||||
{}
|
||||
@@ -3448,6 +3460,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_external_linear_layer(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_external_linear_layer_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -3485,7 +3498,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -3625,6 +3638,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_sbox(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_sbox_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -3662,7 +3676,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
output_data[index] = result;
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl"),
|
||||
shader_src,
|
||||
batch.len()
|
||||
);
|
||||
|
||||
@@ -3807,6 +3821,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_poseidon2_squeeze_twice(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_poseidon2_squeeze_twice_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -3842,7 +3857,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -3985,6 +4000,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_bytes_to_field_elements(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_bytes_to_field_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -4020,7 +4036,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -4168,6 +4184,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_field_elements_to_bytes(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_field_to_bytes_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -4203,7 +4220,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
@@ -4336,6 +4353,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
pub async fn test_hash_squeeze_twice(
|
||||
device: &wgpu::Device,
|
||||
queue: &wgpu::Queue,
|
||||
shader_src: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let test_vectors = generate_hash_squeeze_twice_test_vectors();
|
||||
let total_tests = test_vectors.len();
|
||||
@@ -4371,7 +4389,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
|
||||
}}
|
||||
}}
|
||||
",
|
||||
include_str!("mining.wgsl")
|
||||
shader_src
|
||||
);
|
||||
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
|
||||
344
crates/metrics/src/lair.rs
Normal file
344
crates/metrics/src/lair.rs
Normal file
@@ -0,0 +1,344 @@
|
||||
//! lair: metrics that attribute performance to builds, devices and jobs
|
||||
//! (quantus/miner#9). Additive to origin's metrics in `lib.rs`; nothing there
|
||||
//! changes, so the fleet dashboard keeps working across origin merges.
|
||||
//!
|
||||
//! Naming: `miner_*` like origin's. Per-device series carry `device` (engine
|
||||
//! index) and `kernel` (the kernel id the engine selected), so a silent
|
||||
//! fallback from one kernel to another shows up as a label change rather than
|
||||
//! as an unexplained hashrate drop.
|
||||
|
||||
use crate::REGISTRY;
|
||||
use once_cell::sync::Lazy;
|
||||
use prometheus::{
|
||||
Counter, Histogram, HistogramOpts, HistogramVec, IntCounter, IntCounterVec, IntGauge,
|
||||
IntGaugeVec, Opts,
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
fn reg<M: prometheus::core::Collector + Clone + 'static>(m: M) -> M {
|
||||
REGISTRY
|
||||
.register(Box::new(m.clone()))
|
||||
.expect("register lair metric");
|
||||
m
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity: which build and which configuration produced the numbers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static BUILD_INFO: Lazy<IntGaugeVec> = Lazy::new(|| {
|
||||
reg(IntGaugeVec::new(
|
||||
Opts::new(
|
||||
"miner_build_info",
|
||||
"Build identity of the running miner, always 1",
|
||||
),
|
||||
&["version", "commit"],
|
||||
)
|
||||
.expect("miner_build_info"))
|
||||
});
|
||||
|
||||
static CONFIG_INFO: Lazy<IntGaugeVec> = Lazy::new(|| {
|
||||
reg(IntGaugeVec::new(
|
||||
Opts::new(
|
||||
"miner_config_info",
|
||||
"Effective mining configuration, always 1",
|
||||
),
|
||||
&[
|
||||
"engine",
|
||||
"gpu_batch_size",
|
||||
"gpu_devices",
|
||||
"cpu_workers",
|
||||
"gpu_throttle_ms",
|
||||
],
|
||||
)
|
||||
.expect("miner_config_info"))
|
||||
});
|
||||
|
||||
/// Set once at startup. `commit` is the SHA embedded by build.rs.
|
||||
pub fn set_build_info(version: &str, commit: &str) {
|
||||
BUILD_INFO.with_label_values(&[version, commit]).set(1);
|
||||
}
|
||||
|
||||
/// Set once the engines are resolved. Two deploys of one commit with different
|
||||
/// flags must not look like the same experiment.
|
||||
pub fn set_config_info(
|
||||
engine: &str,
|
||||
gpu_batch_size: u32,
|
||||
gpu_devices: usize,
|
||||
cpu_workers: usize,
|
||||
gpu_throttle_ms: u64,
|
||||
) {
|
||||
CONFIG_INFO
|
||||
.with_label_values(&[
|
||||
engine,
|
||||
&gpu_batch_size.to_string(),
|
||||
&gpu_devices.to_string(),
|
||||
&cpu_workers.to_string(),
|
||||
&gpu_throttle_ms.to_string(),
|
||||
])
|
||||
.set(1);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per device: throughput, outcomes, and where the time goes inside a batch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static DEVICE_HASHES: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
reg(IntCounterVec::new(
|
||||
Opts::new(
|
||||
"miner_device_hashes_total",
|
||||
"Hashes computed, per GPU device and kernel",
|
||||
),
|
||||
&["device", "kernel"],
|
||||
)
|
||||
.expect("miner_device_hashes_total"))
|
||||
});
|
||||
|
||||
static DEVICE_SOLUTIONS: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
reg(IntCounterVec::new(
|
||||
Opts::new(
|
||||
"miner_device_solutions_total",
|
||||
"Solutions found, per GPU device and kernel",
|
||||
),
|
||||
&["device", "kernel"],
|
||||
)
|
||||
.expect("miner_device_solutions_total"))
|
||||
});
|
||||
|
||||
static DEVICE_LOST: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
reg(IntCounterVec::new(
|
||||
Opts::new(
|
||||
"miner_device_lost_total",
|
||||
"Times a GPU device was lost or unresponsive and its worker stopped",
|
||||
),
|
||||
&["device", "kernel"],
|
||||
)
|
||||
.expect("miner_device_lost_total"))
|
||||
});
|
||||
|
||||
static DEVICE_STALE_HASHES: Lazy<IntCounterVec> = Lazy::new(|| {
|
||||
reg(IntCounterVec::new(
|
||||
Opts::new(
|
||||
"miner_stale_hashes_total",
|
||||
"Hashes computed after the job they were for had been superseded: the batch in \
|
||||
flight when a new job arrived. The wasted-work cost of batch size.",
|
||||
),
|
||||
&["device", "kernel"],
|
||||
)
|
||||
.expect("miner_stale_hashes_total"))
|
||||
});
|
||||
|
||||
static GPU_BATCH_SECONDS: Lazy<HistogramVec> = Lazy::new(|| {
|
||||
reg(HistogramVec::new(
|
||||
HistogramOpts::new(
|
||||
"miner_gpu_batch_seconds",
|
||||
"Per-batch time split into the GPU executing (phase=gpu) and the host \
|
||||
preparing, submitting and reading back (phase=host)",
|
||||
)
|
||||
.buckets(vec![
|
||||
0.0005, 0.001, 0.002, 0.004, 0.008, 0.016, 0.032, 0.064, 0.128, 0.256, 0.512, 1.0, 2.0,
|
||||
]),
|
||||
&["device", "kernel", "phase"],
|
||||
)
|
||||
.expect("miner_gpu_batch_seconds"))
|
||||
});
|
||||
|
||||
/// Handles for one device, resolved once so the per-batch path does no label
|
||||
/// lookups. Created by the engine when it initialises a device.
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceMetrics {
|
||||
hashes: IntCounter,
|
||||
solutions: IntCounter,
|
||||
lost: IntCounter,
|
||||
stale: IntCounter,
|
||||
batch_gpu: Histogram,
|
||||
batch_host: Histogram,
|
||||
}
|
||||
|
||||
impl DeviceMetrics {
|
||||
pub fn new(device: usize, kernel: &str) -> Self {
|
||||
let d = device.to_string();
|
||||
Self {
|
||||
hashes: DEVICE_HASHES.with_label_values(&[&d, kernel]),
|
||||
solutions: DEVICE_SOLUTIONS.with_label_values(&[&d, kernel]),
|
||||
lost: DEVICE_LOST.with_label_values(&[&d, kernel]),
|
||||
stale: DEVICE_STALE_HASHES.with_label_values(&[&d, kernel]),
|
||||
batch_gpu: GPU_BATCH_SECONDS.with_label_values(&[&d, kernel, "gpu"]),
|
||||
batch_host: GPU_BATCH_SECONDS.with_label_values(&[&d, kernel, "host"]),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_hashes(&self, n: u64) {
|
||||
self.hashes.inc_by(n);
|
||||
}
|
||||
|
||||
pub fn record_solution(&self) {
|
||||
self.solutions.inc();
|
||||
}
|
||||
|
||||
pub fn record_device_lost(&self) {
|
||||
self.lost.inc();
|
||||
}
|
||||
|
||||
/// The batch that completed after its job was superseded.
|
||||
pub fn record_stale_hashes(&self, n: u64) {
|
||||
self.stale.inc_by(n);
|
||||
}
|
||||
|
||||
/// `gpu` is submit-to-completion on the device; `host` is everything else
|
||||
/// in the batch (buffer writes, encoding, readback, bookkeeping).
|
||||
pub fn observe_batch(&self, gpu: Duration, host: Duration) {
|
||||
self.batch_gpu.observe(gpu.as_secs_f64());
|
||||
self.batch_host.observe(host.as_secs_f64());
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Jobs and results: the efficiency side of every throughput trade-off
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static JOBS_RECEIVED: Lazy<IntCounter> = Lazy::new(|| {
|
||||
reg(
|
||||
IntCounter::new("miner_jobs_received_total", "Jobs received from the node")
|
||||
.expect("miner_jobs_received_total"),
|
||||
)
|
||||
});
|
||||
|
||||
static JOB_PICKUP_SECONDS: Lazy<HistogramVec> = Lazy::new(|| {
|
||||
reg(HistogramVec::new(
|
||||
HistogramOpts::new(
|
||||
"miner_job_pickup_seconds",
|
||||
"Job issued to a worker starting on it. For a busy worker this is the \
|
||||
time to notice cancellation and finish the in-flight batch",
|
||||
)
|
||||
.buckets(vec![
|
||||
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
|
||||
]),
|
||||
&["engine"],
|
||||
)
|
||||
.expect("miner_job_pickup_seconds"))
|
||||
});
|
||||
|
||||
static RESULTS_SUBMITTED: Lazy<IntCounter> = Lazy::new(|| {
|
||||
reg(IntCounter::new(
|
||||
"miner_results_submitted_total",
|
||||
"Solutions sent to the node",
|
||||
)
|
||||
.expect("miner_results_submitted_total"))
|
||||
});
|
||||
|
||||
static RESULTS_SEND_FAILED: Lazy<IntCounter> = Lazy::new(|| {
|
||||
reg(IntCounter::new(
|
||||
"miner_results_send_failed_total",
|
||||
"Solutions that could not be sent to the node",
|
||||
)
|
||||
.expect("miner_results_send_failed_total"))
|
||||
});
|
||||
|
||||
static SEAL_LATENCY: Lazy<Histogram> = Lazy::new(|| {
|
||||
reg(Histogram::with_opts(
|
||||
HistogramOpts::new(
|
||||
"miner_seal_latency_seconds",
|
||||
"Solution found on a worker to the result sent to the node",
|
||||
)
|
||||
.buckets(vec![
|
||||
0.001, 0.002, 0.005, 0.01, 0.02, 0.05, 0.1, 0.2, 0.5, 1.0, 2.0, 5.0,
|
||||
]),
|
||||
)
|
||||
.expect("miner_seal_latency_seconds"))
|
||||
});
|
||||
|
||||
static JOB_IDLE_SECONDS: Lazy<Counter> = Lazy::new(|| {
|
||||
reg(Counter::new(
|
||||
"miner_job_idle_seconds_total",
|
||||
"Seconds spent between sending a result and receiving the next job (node-attributable idle)",
|
||||
)
|
||||
.expect("miner_job_idle_seconds_total"))
|
||||
});
|
||||
|
||||
pub fn record_job_received() {
|
||||
JOBS_RECEIVED.inc();
|
||||
}
|
||||
|
||||
pub fn observe_job_pickup(engine: &str, d: Duration) {
|
||||
JOB_PICKUP_SECONDS
|
||||
.with_label_values(&[engine])
|
||||
.observe(d.as_secs_f64());
|
||||
}
|
||||
|
||||
pub fn record_result_submitted(found_to_sent: Option<Duration>) {
|
||||
RESULTS_SUBMITTED.inc();
|
||||
if let Some(d) = found_to_sent {
|
||||
SEAL_LATENCY.observe(d.as_secs_f64());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn record_result_send_failed() {
|
||||
RESULTS_SEND_FAILED.inc();
|
||||
}
|
||||
|
||||
pub fn record_job_idle(d: Duration) {
|
||||
JOB_IDLE_SECONDS.inc_by(d.as_secs_f64());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connection to the node: is a hashrate drop the miner or the node?
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static CONNECTS: Lazy<IntCounter> = Lazy::new(|| {
|
||||
reg(IntCounter::new(
|
||||
"miner_connects_total",
|
||||
"Connections established to the node",
|
||||
)
|
||||
.expect("miner_connects_total"))
|
||||
});
|
||||
|
||||
static CONNECT_FAILURES: Lazy<IntCounter> = Lazy::new(|| {
|
||||
reg(IntCounter::new(
|
||||
"miner_connect_failures_total",
|
||||
"Connection attempts to the node that failed",
|
||||
)
|
||||
.expect("miner_connect_failures_total"))
|
||||
});
|
||||
|
||||
static DISCONNECTS: Lazy<IntCounter> = Lazy::new(|| {
|
||||
reg(IntCounter::new(
|
||||
"miner_disconnects_total",
|
||||
"Connections to the node that were lost",
|
||||
)
|
||||
.expect("miner_disconnects_total"))
|
||||
});
|
||||
|
||||
static CONNECTED: Lazy<IntGauge> = Lazy::new(|| {
|
||||
reg(
|
||||
IntGauge::new("miner_connected", "1 while connected to the node, else 0")
|
||||
.expect("miner_connected"),
|
||||
)
|
||||
});
|
||||
|
||||
static DISCONNECTED_SECONDS: Lazy<Counter> = Lazy::new(|| {
|
||||
reg(Counter::new(
|
||||
"miner_disconnected_seconds_total",
|
||||
"Seconds spent without a node connection after the first successful connection",
|
||||
)
|
||||
.expect("miner_disconnected_seconds_total"))
|
||||
});
|
||||
|
||||
pub fn record_connected() {
|
||||
CONNECTS.inc();
|
||||
CONNECTED.set(1);
|
||||
}
|
||||
|
||||
pub fn record_connect_failed() {
|
||||
CONNECT_FAILURES.inc();
|
||||
CONNECTED.set(0);
|
||||
}
|
||||
|
||||
pub fn record_disconnected() {
|
||||
DISCONNECTS.inc();
|
||||
CONNECTED.set(0);
|
||||
}
|
||||
|
||||
pub fn record_disconnected_time(d: Duration) {
|
||||
DISCONNECTED_SECONDS.inc_by(d.as_secs_f64());
|
||||
}
|
||||
@@ -39,6 +39,10 @@ use anyhow::Result;
|
||||
|
||||
static REGISTRY: Lazy<Registry> = Lazy::new(Registry::new);
|
||||
|
||||
// lair: build, device and job metrics (quantus/miner#9); see lair.rs.
|
||||
mod lair;
|
||||
pub use lair::*;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hash Rate Metrics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,7 @@ description = "CLI binary to run the Quantus External Miner service"
|
||||
[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 }
|
||||
|
||||
51
crates/miner-cli/build.rs
Normal file
51
crates/miner-cli/build.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
// lair: embed the git commit in the binary so `--version` and the build-info
|
||||
// metric identify a deployed build by commit, not by the workspace semver
|
||||
// (which does not change between commits on a branch that deploys on push).
|
||||
//
|
||||
// Resolution order:
|
||||
// 1. MINER_BUILD_SHA in the environment (CI sets it from the checked-out ref)
|
||||
// 2. `git rev-parse HEAD` of the workspace, with "-dirty" if the tree differs
|
||||
// 3. "unknown"
|
||||
use std::process::Command;
|
||||
|
||||
fn git(args: &[&str]) -> Option<String> {
|
||||
let out = Command::new("git").args(args).output().ok()?;
|
||||
if !out.status.success() {
|
||||
return None;
|
||||
}
|
||||
let s = String::from_utf8(out.stdout).ok()?;
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-env-changed=MINER_BUILD_SHA");
|
||||
|
||||
let sha = match std::env::var("MINER_BUILD_SHA") {
|
||||
Ok(s) if !s.trim().is_empty() => s.trim().to_string(),
|
||||
_ => match git(&["rev-parse", "--short=12", "HEAD"]) {
|
||||
Some(head) => {
|
||||
// Re-run when HEAD moves so a rebuild after a commit picks it up.
|
||||
if let Some(dir) = git(&["rev-parse", "--git-dir"]) {
|
||||
println!("cargo:rerun-if-changed={dir}/HEAD");
|
||||
println!("cargo:rerun-if-changed={dir}/refs/heads");
|
||||
}
|
||||
let dirty = git(&["status", "--porcelain", "--untracked-files=no"])
|
||||
.map(|s| !s.is_empty())
|
||||
.unwrap_or(false);
|
||||
if dirty {
|
||||
format!("{head}-dirty")
|
||||
} else {
|
||||
head
|
||||
}
|
||||
}
|
||||
None => "unknown".to_string(),
|
||||
},
|
||||
};
|
||||
|
||||
println!("cargo:rustc-env=MINER_BUILD_SHA={sha}");
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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;
|
||||
@@ -20,6 +21,34 @@ enum Command {
|
||||
#[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>,
|
||||
@@ -29,11 +58,11 @@ enum Command {
|
||||
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)]
|
||||
#[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)]
|
||||
#[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)
|
||||
@@ -58,6 +87,11 @@ enum Command {
|
||||
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
|
||||
allow_integrated: bool,
|
||||
|
||||
/// lair: GPU engine: auto (CUDA when this binary carries a kernel and a
|
||||
/// driver is present, else wgpu), cuda, or wgpu (quantus/miner#3).
|
||||
#[arg(long = "gpu-engine", env = "MINER_GPU_ENGINE", default_value = "auto")]
|
||||
gpu_engine: String,
|
||||
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long, env = "MINER_VERBOSE")]
|
||||
verbose: bool,
|
||||
@@ -74,11 +108,11 @@ enum Command {
|
||||
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)]
|
||||
#[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)]
|
||||
#[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)
|
||||
@@ -89,15 +123,29 @@ enum Command {
|
||||
#[arg(long = "allow-integrated", env = "MINER_ALLOW_INTEGRATED")]
|
||||
allow_integrated: bool,
|
||||
|
||||
/// lair: GPU engine: auto (CUDA when this binary carries a kernel and a
|
||||
/// driver is present, else wgpu), cuda, or wgpu (quantus/miner#3).
|
||||
#[arg(long = "gpu-engine", env = "MINER_GPU_ENGINE", default_value = "auto")]
|
||||
gpu_engine: String,
|
||||
|
||||
/// Enable verbose logging
|
||||
#[arg(short, long, env = "MINER_VERBOSE")]
|
||||
verbose: bool,
|
||||
},
|
||||
}
|
||||
|
||||
// lair: semver plus the commit the binary was built from (see build.rs), so a
|
||||
// deploy can assert the running binary is the commit it shipped.
|
||||
const VERSION: &str = concat!(
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (",
|
||||
env!("MINER_BUILD_SHA"),
|
||||
")"
|
||||
);
|
||||
|
||||
/// Quantus External Miner CLI
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(author, version, about, long_about = None)]
|
||||
#[command(author, version = VERSION, about, long_about = None)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
@@ -106,16 +154,26 @@ struct Args {
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let args = Args::parse();
|
||||
// lair: identify this build in the metrics (quantus/miner#9).
|
||||
metrics::set_build_info(env!("CARGO_PKG_VERSION"), env!("MINER_BUILD_SHA"));
|
||||
|
||||
let Some(command) = args.command else {
|
||||
eprintln!("Error: No command provided. Use 'serve' to start mining (defaults to local node at 127.0.0.1:9833).");
|
||||
eprintln!("Example: quantus-miner serve --node-addr 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);
|
||||
};
|
||||
|
||||
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,
|
||||
@@ -123,10 +181,32 @@ async fn main() {
|
||||
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
|
||||
@@ -141,12 +221,15 @@ async fn main() {
|
||||
|
||||
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 {
|
||||
@@ -162,6 +245,7 @@ async fn main() {
|
||||
cpu_batch_size,
|
||||
duration,
|
||||
allow_integrated,
|
||||
gpu_engine,
|
||||
verbose,
|
||||
} => {
|
||||
init_logger(verbose);
|
||||
@@ -172,19 +256,71 @@ async fn main() {
|
||||
cpu_batch_size,
|
||||
duration,
|
||||
allow_integrated,
|
||||
gpu_engine,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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>)",
|
||||
)
|
||||
}
|
||||
|
||||
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,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
|
||||
"debug,miner=debug,gpu_engine=debug,cuda_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
|
||||
} else {
|
||||
"info,miner=info,gpu_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
|
||||
"info,miner=info,gpu_engine=info,cuda_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
|
||||
};
|
||||
std::env::set_var("RUST_LOG", log_level);
|
||||
}
|
||||
@@ -198,6 +334,7 @@ async fn run_benchmark(
|
||||
cpu_batch_size: u64,
|
||||
duration: u64,
|
||||
allow_integrated: bool,
|
||||
gpu_engine_pref: String,
|
||||
) {
|
||||
let effective_cpu_workers = cpu_workers.unwrap_or_else(num_cpus::get);
|
||||
|
||||
@@ -207,6 +344,7 @@ async fn run_benchmark(
|
||||
gpu_batch_size,
|
||||
0,
|
||||
allow_integrated,
|
||||
&gpu_engine_pref,
|
||||
) {
|
||||
Ok((engine, count)) => (engine, count),
|
||||
Err(e) => {
|
||||
@@ -225,7 +363,13 @@ async fn run_benchmark(
|
||||
num_cpus::get()
|
||||
);
|
||||
println!("GPU Devices: {}", effective_gpu_devices);
|
||||
println!("Duration: {} seconds", duration);
|
||||
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 {
|
||||
@@ -257,8 +401,11 @@ async fn run_benchmark(
|
||||
let mut handles = Vec::new();
|
||||
let total_hashes = Arc::new(std::sync::Mutex::new(0u64));
|
||||
|
||||
let cpu_chunk = 10_000u64;
|
||||
let gpu_chunk = 1_000_000u64;
|
||||
// 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 {
|
||||
@@ -274,21 +421,23 @@ async fn run_benchmark(
|
||||
|
||||
let handle = thread::spawn(move || {
|
||||
let stride = U512::from(1_000_000_000_000u64);
|
||||
let worker_start = U512::from(worker_id as u64).saturating_mul(stride);
|
||||
let worker_range = EngineRange {
|
||||
start: worker_start,
|
||||
end: worker_start
|
||||
.saturating_add(U512::from(nonces_per_batch))
|
||||
.saturating_sub(U512::from(1u64)),
|
||||
};
|
||||
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.clone(), &cancel_check);
|
||||
let result = engine.search_range(&ctx, worker_range, &cancel_check);
|
||||
|
||||
match result {
|
||||
engine_cpu::EngineStatus::Found { hash_count, .. }
|
||||
@@ -300,11 +449,12 @@ async fn run_benchmark(
|
||||
engine_cpu::EngineStatus::Running { .. } => {}
|
||||
}
|
||||
|
||||
// Exit if device is lost
|
||||
if matches!(result, engine_cpu::EngineStatus::DeviceLost { .. }) {
|
||||
break;
|
||||
}
|
||||
|
||||
current = current.saturating_add(step);
|
||||
|
||||
if start.elapsed() >= Duration::from_secs(duration) {
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ anyhow = { workspace = true }
|
||||
|
||||
# QUIC transport
|
||||
quinn = "0.10"
|
||||
rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] }
|
||||
quic-transport = { path = "../quic-transport" }
|
||||
getrandom = "0.2"
|
||||
|
||||
# Protocol types from the node repo
|
||||
@@ -31,4 +31,5 @@ quantus-miner-api = { workspace = true }
|
||||
pow-core = { path = "../pow-core" }
|
||||
engine-cpu = { path = "../engine-cpu", optional = true }
|
||||
engine-gpu = { path = "../engine-gpu" }
|
||||
engine-cuda = { path = "../engine-cuda" } # lair: quantus/miner#3
|
||||
metrics = { path = "../metrics" }
|
||||
|
||||
@@ -23,6 +23,10 @@ use std::thread;
|
||||
pub struct ServiceConfig {
|
||||
/// Address of the node to connect to (e.g., "127.0.0.1:9833").
|
||||
pub node_addr: std::net::SocketAddr,
|
||||
/// Shared secret that must match the node's miner auth token.
|
||||
pub auth_token: String,
|
||||
/// SHA-256 fingerprint (hex) of the node's miner TLS certificate DER.
|
||||
pub tls_cert_sha256: String,
|
||||
/// Number of CPU worker threads to use for mining (None = auto-detect)
|
||||
pub cpu_workers: Option<usize>,
|
||||
/// Number of GPU devices to use for mining (None = auto-detect)
|
||||
@@ -35,6 +39,9 @@ pub struct ServiceConfig {
|
||||
pub gpu_throttle_ms: u64,
|
||||
/// Allow integrated GPUs even when discrete GPUs are available
|
||||
pub allow_integrated: bool,
|
||||
/// lair: GPU engine preference: "auto" (CUDA when available, else wgpu),
|
||||
/// "cuda" or "wgpu" (quantus/miner#3).
|
||||
pub gpu_engine: String,
|
||||
}
|
||||
|
||||
/// Engine type for tracking metrics per compute type.
|
||||
@@ -58,6 +65,8 @@ pub struct WorkerResult {
|
||||
pub hash_count: u64,
|
||||
/// Whether this worker has finished its range.
|
||||
pub completed: bool,
|
||||
/// lair: when the candidate was found (seal latency, quantus/miner#9).
|
||||
pub found_at: Option<std::time::Instant>,
|
||||
}
|
||||
|
||||
/// A successful mining candidate.
|
||||
@@ -86,6 +95,8 @@ pub struct MiningJob {
|
||||
pub ctx: pow_core::JobContext,
|
||||
/// Job ID to detect stale results after job transitions
|
||||
pub job_id: u64,
|
||||
/// lair: when the job was issued (pickup latency, quantus/miner#9).
|
||||
created_at: std::time::Instant,
|
||||
}
|
||||
|
||||
/// Persistent worker thread pool that keeps threads alive between jobs.
|
||||
@@ -204,6 +215,7 @@ impl WorkerPool {
|
||||
let job = MiningJob {
|
||||
ctx,
|
||||
job_id: new_job_id,
|
||||
created_at: std::time::Instant::now(), // lair
|
||||
};
|
||||
|
||||
// Dispatch job to all workers using bounded channels (capacity 16).
|
||||
@@ -336,6 +348,9 @@ fn worker_loop(
|
||||
log::debug!("[WORKER {type_str}-{thread_id}] Drained {skipped} stale jobs from queue");
|
||||
}
|
||||
|
||||
// lair: issued-to-picked-up; for a busy worker this is the cancel latency.
|
||||
metrics::observe_job_pickup(type_str, job.created_at.elapsed());
|
||||
|
||||
// Capture the job's ID for later validation
|
||||
let job_id = job.job_id;
|
||||
log::debug!("[WORKER {type_str}-{thread_id}] Received job {job_id}");
|
||||
@@ -394,6 +409,7 @@ fn worker_loop(
|
||||
candidate: None, // Discard the stale candidate
|
||||
hash_count,
|
||||
completed: true,
|
||||
found_at: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -438,6 +454,7 @@ fn worker_loop(
|
||||
candidate: None,
|
||||
hash_count,
|
||||
completed: true,
|
||||
found_at: None,
|
||||
});
|
||||
break; // Exit the worker loop
|
||||
}
|
||||
@@ -448,6 +465,7 @@ fn worker_loop(
|
||||
};
|
||||
|
||||
// Send result (non-blocking to avoid deadlock if receiver is full)
|
||||
let found_at = candidate.as_ref().map(|_| std::time::Instant::now()); // lair
|
||||
let _ = result_tx.try_send(WorkerResult {
|
||||
thread_id,
|
||||
engine_type,
|
||||
@@ -455,12 +473,14 @@ fn worker_loop(
|
||||
candidate,
|
||||
hash_count,
|
||||
completed: true,
|
||||
found_at,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up GPU resources on thread exit
|
||||
if engine_type == EngineType::Gpu {
|
||||
engine_gpu::GpuEngine::clear_worker_resources();
|
||||
engine_cuda::CudaEngine::clear_worker_resources(); // lair
|
||||
}
|
||||
|
||||
log::debug!("{type_str} worker {thread_id} exited");
|
||||
@@ -472,12 +492,37 @@ pub fn resolve_gpu_configuration(
|
||||
batch_size: u32,
|
||||
throttle_ms: u64,
|
||||
allow_integrated: bool,
|
||||
gpu_engine: &str,
|
||||
) -> anyhow::Result<(Option<Arc<dyn MinerEngine>>, usize)> {
|
||||
// Explicit 0 means no GPU
|
||||
if requested_devices == Some(0) {
|
||||
return Ok((None, 0));
|
||||
}
|
||||
|
||||
// lair: native CUDA engine first unless wgpu was asked for (quantus/miner#3).
|
||||
if gpu_engine != "wgpu" {
|
||||
match engine_cuda::CudaEngine::try_new(batch_size, throttle_ms) {
|
||||
Ok(engine) => {
|
||||
let available = engine.device_count();
|
||||
let count = match requested_devices {
|
||||
Some(n) if n > available => anyhow::bail!(
|
||||
"Requested {n} GPU devices but only {available} available (CUDA)"
|
||||
),
|
||||
Some(n) => n,
|
||||
None => {
|
||||
log::info!("Auto-detected {available} CUDA device(s)");
|
||||
available
|
||||
}
|
||||
};
|
||||
return Ok((Some(Arc::new(engine)), count));
|
||||
}
|
||||
Err(e) if gpu_engine == "cuda" => {
|
||||
anyhow::bail!("CUDA engine requested but unavailable: {e}")
|
||||
}
|
||||
Err(e) => log::info!("CUDA engine unavailable ({e}); using wgpu"),
|
||||
}
|
||||
}
|
||||
|
||||
// Try to initialize GPU engine
|
||||
let engine = engine_gpu::GpuEngine::try_new(batch_size, throttle_ms, allow_integrated);
|
||||
let engine = match engine {
|
||||
@@ -525,6 +570,7 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
|
||||
config.gpu_batch_size,
|
||||
config.gpu_throttle_ms,
|
||||
config.allow_integrated,
|
||||
&config.gpu_engine,
|
||||
)?;
|
||||
|
||||
// Resolve CPU workers
|
||||
@@ -558,6 +604,14 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
|
||||
cpu_workers,
|
||||
gpu_devices
|
||||
);
|
||||
// lair: expose the effective configuration as labels (quantus/miner#9).
|
||||
metrics::set_config_info(
|
||||
gpu_engine.as_ref().map(|e| e.name()).unwrap_or("cpu"),
|
||||
config.gpu_batch_size,
|
||||
gpu_devices,
|
||||
cpu_workers,
|
||||
config.gpu_throttle_ms,
|
||||
);
|
||||
|
||||
if let Some(ref engine) = cpu_engine {
|
||||
let name = engine.name();
|
||||
@@ -582,6 +636,8 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
|
||||
log::info!("🌐 Connecting to node at {node_addr}");
|
||||
quic::connect_and_mine(
|
||||
config.node_addr,
|
||||
&config.auth_token,
|
||||
&config.tls_cert_sha256,
|
||||
cpu_engine,
|
||||
gpu_engine,
|
||||
cpu_workers,
|
||||
|
||||
@@ -10,8 +10,6 @@ use std::time::{Duration, Instant};
|
||||
|
||||
use engine_cpu::MinerEngine;
|
||||
use primitive_types::U512;
|
||||
use quinn::{ClientConfig, Endpoint};
|
||||
use rustls::client::ServerCertVerified;
|
||||
|
||||
use quantus_miner_api::{
|
||||
read_message, write_message, ApiResponseStatus, MinerMessage, MiningResult,
|
||||
@@ -28,6 +26,8 @@ use pow_core::format_hashrate;
|
||||
/// 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,
|
||||
@@ -38,23 +38,53 @@ pub async fn connect_and_mine(
|
||||
|
||||
let mut reconnect_delay = Duration::from_secs(1);
|
||||
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
|
||||
// lair: time without a connection, counted after the first success.
|
||||
let mut disconnected_since: Option<Instant> = None;
|
||||
|
||||
loop {
|
||||
log::info!("⛏️ Connecting to node at {}...", node_addr);
|
||||
|
||||
match establish_connection(node_addr).await {
|
||||
match establish_connection(node_addr, auth_token, tls_cert_sha256).await {
|
||||
Ok((connection, send, recv)) => {
|
||||
log::info!("⛏️ Connected to node at {}", node_addr);
|
||||
reconnect_delay = Duration::from_secs(1);
|
||||
metrics::record_connected(); // lair
|
||||
if let Some(t) = disconnected_since.take() {
|
||||
metrics::record_disconnected_time(t.elapsed());
|
||||
}
|
||||
|
||||
if let Err(e) = handle_connection(connection, send, recv, &worker_pool).await {
|
||||
log::info!("⛏️ Connection lost: {}", e);
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,38 +94,18 @@ pub async fn connect_and_mine(
|
||||
}
|
||||
}
|
||||
|
||||
/// Establish a QUIC connection to the node.
|
||||
/// 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 mut crypto = rustls::ClientConfig::builder()
|
||||
.with_safe_defaults()
|
||||
.with_custom_certificate_verifier(Arc::new(InsecureCertVerifier))
|
||||
.with_no_client_auth();
|
||||
|
||||
crypto.alpn_protocols = vec![b"quantus-miner".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 = endpoint.connect(addr, "localhost")?.await?;
|
||||
log::info!("⛏️ QUIC connection established to {}", addr);
|
||||
|
||||
log::info!("⛏️ Opening bidirectional stream to node...");
|
||||
let (mut send, recv) = connection.open_bi().await?;
|
||||
|
||||
// Send Ready message to establish the stream
|
||||
write_message(&mut send, &MinerMessage::Ready).await?;
|
||||
log::info!("⛏️ Bidirectional stream established");
|
||||
|
||||
Ok((connection, send, recv))
|
||||
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.
|
||||
@@ -116,11 +126,15 @@ async fn send_message_checked(
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -140,6 +154,8 @@ async fn handle_connection(
|
||||
let mut cpu_hashes: u64 = 0;
|
||||
let mut gpu_hashes: u64 = 0;
|
||||
let mut result_sent_for_current_job = false;
|
||||
// lair: when the last result went out, for node-attributable idle time.
|
||||
let mut result_sent_at: Option<Instant> = None;
|
||||
|
||||
log::info!("⛏️ Waiting for mining jobs from node...");
|
||||
|
||||
@@ -209,7 +225,15 @@ async fn handle_connection(
|
||||
};
|
||||
|
||||
let msg = MinerMessage::JobResult(result);
|
||||
send_message_checked(&connection, &mut send, &msg).await?;
|
||||
if let Err(e) = send_message_checked(&connection, &mut send, &msg).await {
|
||||
metrics::record_result_send_failed(); // lair
|
||||
return Err(e);
|
||||
}
|
||||
// lair: seal latency and the start of the idle window.
|
||||
metrics::record_result_submitted(
|
||||
worker_result.found_at.map(|t| t.elapsed()),
|
||||
);
|
||||
result_sent_at = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -220,12 +244,24 @@ async fn handle_connection(
|
||||
biased;
|
||||
|
||||
reason = connection.closed() => {
|
||||
return Err(anyhow::anyhow!("Connection closed: {}", reason));
|
||||
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,
|
||||
@@ -305,7 +341,7 @@ async fn handle_connection(
|
||||
Ok(MinerMessage::JobResult(_)) => {
|
||||
log::warn!("Received unexpected JobResult from node");
|
||||
}
|
||||
Ok(MinerMessage::Ready) => {
|
||||
Ok(MinerMessage::Ready { .. }) => {
|
||||
log::warn!("Received unexpected Ready from node");
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
@@ -322,20 +358,3 @@ async fn handle_connection(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Certificate verifier that accepts any certificate (for self-signed certs).
|
||||
struct InsecureCertVerifier;
|
||||
|
||||
impl rustls::client::ServerCertVerifier for InsecureCertVerifier {
|
||||
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> {
|
||||
Ok(ServerCertVerified::assertion())
|
||||
}
|
||||
}
|
||||
|
||||
32
crates/pool-service/Cargo.toml
Normal file
32
crates/pool-service/Cargo.toml
Normal file
@@ -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" }
|
||||
219
crates/pool-service/src/http.rs
Normal file
219
crates/pool-service/src/http.rs
Normal file
@@ -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)
|
||||
}
|
||||
250
crates/pool-service/src/main.rs
Normal file
250
crates/pool-service/src/main.rs
Normal file
@@ -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}");
|
||||
}
|
||||
104
crates/pool-service/src/rate_limit.rs
Normal file
104
crates/pool-service/src/rate_limit.rs
Normal file
@@ -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());
|
||||
}
|
||||
}
|
||||
527
crates/pool-service/src/state.rs
Normal file
527
crates/pool-service/src/state.rs
Normal file
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
192
crates/pool-service/src/upstream.rs
Normal file
192
crates/pool-service/src/upstream.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ gpu-interop = []
|
||||
|
||||
[dependencies]
|
||||
primitive-types = { workspace = true }
|
||||
qp-poseidon-core = { workspace = true }
|
||||
qpow-math = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
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};
|
||||
|
||||
/// 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() {
|
||||
@@ -222,6 +243,39 @@ mod tests {
|
||||
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() {
|
||||
|
||||
15
crates/quic-transport/Cargo.toml
Normal file
15
crates/quic-transport/Cargo.toml
Normal file
@@ -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"] }
|
||||
218
crates/quic-transport/src/lib.rs
Normal file
218
crates/quic-transport/src/lib.rs
Normal file
@@ -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}");
|
||||
}
|
||||
}
|
||||
13
crates/solver-wasm/Cargo.toml
Normal file
13
crates/solver-wasm/Cargo.toml
Normal file
@@ -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 }
|
||||
101
crates/solver-wasm/src/lib.rs
Normal file
101
crates/solver-wasm/src/lib.rs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
42
deploy/README.md
Normal file
42
deploy/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# deploy
|
||||
|
||||
Everything the CI deploy of the miner ships or needs (`.gitea/workflows/deploy.yaml`).
|
||||
Moved from `lair/quantus` on 2026-09-03 so that one repo owns the deployed miner;
|
||||
the fleet's monitoring, GPU power limits and nvidia metrics stay there.
|
||||
|
||||
| File | Lands at |
|
||||
| --- | --- |
|
||||
| `quantus-miner.service` | `/etc/systemd/system/quantus-miner.service` |
|
||||
| `quantus-miner.sysusers.conf` | `/etc/sysusers.d/quantus-miner.conf` |
|
||||
| `quantus-miner-metrics.xml` | `/etc/firewalld/services/quantus-miner-metrics.xml` |
|
||||
| `miner.env.tmpl` | rendered to `/etc/quantus-miner/miner.env` |
|
||||
| `infra-setup.sh` | operator-run once per host; installs the `gitea_ci` sudoers |
|
||||
|
||||
The miner's credentials are generated by the node and copied host-to-host by the
|
||||
deploy on every run. Nothing secret lives here.
|
||||
|
||||
## Traps, all previously hit
|
||||
|
||||
- **Adding a file or privileged command means re-running `infra-setup.sh`.** The
|
||||
deploy preflights the host's `sudo -n -l` against the script's SUDO block and
|
||||
names the missing paths, instead of failing partway with `sudo: a password is required`.
|
||||
- **Deploys must be no-ops when nothing changed.** `push()` uses `rsync -ic`
|
||||
(checksum). The artifact is rebuilt every run so mtimes always differ. Restart
|
||||
only on an itemised content change.
|
||||
- **`--node-addr` takes an IP, not a name.** It parses as a Rust `SocketAddr`
|
||||
with no DNS. The deploy resolves the node on the miner host and renders the
|
||||
literal; the `10.x` never enters the repo.
|
||||
- **Pipes in validate steps.** The shell is `bash -e -o pipefail`; a reader that
|
||||
exits early (`awk ... exit`, `grep -q`, `head`) SIGPIPEs the writer, exit 141.
|
||||
Capture into a variable and parse with a here-string.
|
||||
- **ssh argument quoting.** `run()` is `ssh ... "$@"` and the remote shell
|
||||
re-splits; any argument with a space is passed as one pre-quoted string.
|
||||
- **`PrivateDevices=false`** in the unit is deliberate: the miner needs `/dev/nvidia*`.
|
||||
- **`systemctl is-active` is not evidence of mining.** A miner that found no
|
||||
adapter, or never got a job, is `active`. Validate asserts `miner_hashes_total`
|
||||
advances and `miner_gpu_devices` matches the matrix.
|
||||
- **Binaries must be built on `cuda-13.0`** (Fedora 43, like the hosts). The
|
||||
`rust` runner is Fedora 44; its binaries fail with `GLIBC_2.43 not found`.
|
||||
- **A benchmark (`bench.yaml`) stops the miner on its host.** A deploy landing
|
||||
on the same host during a measurement would find the unit down and start it
|
||||
mid-window. Both are serialised only by not running them together.
|
||||
135
deploy/infra-setup.sh
Executable file
135
deploy/infra-setup.sh
Executable file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env bash
|
||||
# One-time provisioning of a mining host for the CI deploy in
|
||||
# .gitea/workflows/deploy.yaml (quantus/miner#8). The miner role of
|
||||
# lair/quantus's script/infra-setup.sh, moved here so the deployer and its
|
||||
# sudoers grant list live in the same repo and cannot drift: the deploy
|
||||
# preflights the target's `sudo -n -l` against the SUDO block below.
|
||||
#
|
||||
# Convention: ~/git/architecture/deployment-gitea-actions.md §1–§2. Run from a
|
||||
# workstation with admin (sudo) ssh access to the targets — NOT the gitea_ci
|
||||
# account. Idempotent; re-running is a no-op. Skips unreachable hosts.
|
||||
#
|
||||
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub
|
||||
# ./deploy/infra-setup.sh --pubkey ~/.ssh/id_gitea_ci.pub --miner-hosts benjy.hanzalova.internal
|
||||
#
|
||||
# The runner keypair is NOT generated here. It already exists at
|
||||
# ~/.ssh/id_gitea_ci and is shared by every project's RSYNC_SSH_KEY secret and
|
||||
# every host's gitea_ci authorized_keys — regenerating it would silently break
|
||||
# every other deploy on the fleet.
|
||||
#
|
||||
# Re-run this whenever the deploy gains a new file to ship or a new privileged
|
||||
# command; the preflight fails up front naming the missing paths rather than
|
||||
# dying partway through an rsync with "sudo: a password is required".
|
||||
set -euo pipefail
|
||||
|
||||
ADMIN_USER="${ADMIN_USER:-$USER}"
|
||||
# Must agree with the deploy matrix in .gitea/workflows/deploy.yaml.
|
||||
MINER_HOSTS="${MINER_HOSTS-benjy.hanzalova.internal quadbrat.hanzalova.internal beast.hanzalova.internal}"
|
||||
PUBKEY=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--pubkey) PUBKEY="$2"; shift 2 ;;
|
||||
--miner-hosts) MINER_HOSTS="$2"; shift 2 ;;
|
||||
--admin) ADMIN_USER="$2"; shift 2 ;;
|
||||
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
[ -n "$PUBKEY" ] && [ -r "$PUBKEY" ] || { echo "--pubkey <file> is required and must be readable" >&2; exit 2; }
|
||||
|
||||
provision_miner() {
|
||||
local host="$1"
|
||||
echo "== ${host} (miner) =="
|
||||
if ! ssh -o ConnectTimeout=8 -o BatchMode=yes "${ADMIN_USER}@${host}" true; then
|
||||
echo " ! unreachable as ${ADMIN_USER} — skipping" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# All privileged work in one remote `sudo bash`. The runner pubkey is the only
|
||||
# dynamic value, passed as $1 (single line, no quoting hazard). The sudoers
|
||||
# `\=` are the required escapes — visudo rejects a bare `=` in a command arg.
|
||||
# The `*` in an rsync line matches rsync's --server arg vector; the trailing
|
||||
# literal destination is what actually bounds the rule.
|
||||
ssh "${ADMIN_USER}@${host}" "sudo bash -seu -- '$(cat "$PUBKEY")'" <<'REMOTE'
|
||||
PUBKEY="$1"
|
||||
|
||||
# /bin/bash, NOT nologin. The deploy runs `ssh gitea_ci@host <command>`, and
|
||||
# a nologin shell refuses that with "This account is currently not
|
||||
# available": the key authenticates, then the command cannot run.
|
||||
if ! getent passwd gitea_ci >/dev/null; then
|
||||
useradd --system --create-home --home-dir /var/lib/gitea_ci \
|
||||
--shell /bin/bash gitea_ci
|
||||
echo " + created gitea_ci"
|
||||
else
|
||||
echo " = gitea_ci already present"
|
||||
fi
|
||||
cur=$(getent passwd gitea_ci | cut -d: -f7)
|
||||
if [ "$cur" != /bin/bash ]; then
|
||||
usermod -s /bin/bash gitea_ci
|
||||
echo " ~ gitea_ci shell was ${cur} — set to /bin/bash so ssh commands can run"
|
||||
fi
|
||||
|
||||
install -d -o gitea_ci -g gitea_ci -m 0700 /var/lib/gitea_ci/.ssh
|
||||
ak=/var/lib/gitea_ci/.ssh/authorized_keys
|
||||
touch "$ak"
|
||||
grep -qxF "$PUBKEY" "$ak" || printf '%s\n' "$PUBKEY" >> "$ak"
|
||||
chown gitea_ci:gitea_ci "$ak"
|
||||
chmod 0600 "$ak"
|
||||
usermod -aG systemd-journal gitea_ci
|
||||
|
||||
# The GPU must be present and enumerable before a miner deploy is worth
|
||||
# attempting; failing here beats a green deploy that mines nothing.
|
||||
if ! command -v nvidia-smi >/dev/null; then
|
||||
echo " ! nvidia-smi not found — this host has no usable NVIDIA driver" >&2
|
||||
exit 1
|
||||
fi
|
||||
nvidia-smi --query-gpu=name --format=csv,noheader | sed 's/^/ = gpu: /'
|
||||
|
||||
tmp=/etc/sudoers.d/.quantus-miner_gitea_ci.tmp
|
||||
cat > "$tmp" <<'SUDO'
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /usr/local/bin/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/systemd/system/quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/sysusers.d/quantus-miner.conf
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner.env
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-auth-token
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/quantus-miner/miner-tls-cert-sha256
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/rsync * /etc/firewalld/services/quantus-miner-metrics.xml
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --get-default-zone
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --permanent --zone\=* --add-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --add-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/firewall-cmd --zone\=* --query-rich-rule\=*
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemd-sysusers
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o root -g quantus-miner -m 0750 /etc/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -d -o quantus-miner -g quantus-miner -m 0750 /var/lib/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/sbin/restorecon -R /usr/local/bin/quantus-miner /etc/quantus-miner /var/lib/quantus-miner
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl daemon-reload
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl enable quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl restart quantus-miner.service
|
||||
# stop/start (not just restart) so the benchmark harness (bench.yaml) can pause
|
||||
# mining for a measurement window and resume it afterwards.
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl stop quantus-miner.service
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/systemctl start quantus-miner.service
|
||||
# Rollback: the deploy keeps the previous binary as .prev (cp) and restores it
|
||||
# with install when validate fails; cp back would hit "Text file busy" on the
|
||||
# running binary, install unlinks the destination first. Deploying from main means a bad commit reaches production;
|
||||
# this is what makes that survivable.
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/cp -p /usr/local/bin/quantus-miner /usr/local/bin/quantus-miner.prev
|
||||
gitea_ci ALL=(root) NOPASSWD: /usr/bin/install -m 0755 /usr/local/bin/quantus-miner.prev /usr/local/bin/quantus-miner
|
||||
SUDO
|
||||
chmod 0440 "$tmp"
|
||||
visudo -cf "$tmp"
|
||||
mv "$tmp" /etc/sudoers.d/quantus-miner_gitea_ci
|
||||
echo " = sudoers quantus-miner_gitea_ci installed and visudo-verified"
|
||||
REMOTE
|
||||
}
|
||||
|
||||
rc=0
|
||||
for h in $MINER_HOSTS; do
|
||||
provision_miner "$h" || rc=1
|
||||
done
|
||||
echo
|
||||
echo "done. remaining operator steps:"
|
||||
echo " 1. confirm RSYNC_SSH_KEY is set in the repo's Actions secrets"
|
||||
echo " 2. push to main, or run the deploy workflow manually (mode: validate first)"
|
||||
exit $rc
|
||||
6
deploy/miner.env.tmpl
Normal file
6
deploy/miner.env.tmpl
Normal file
@@ -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}}
|
||||
9
deploy/quantus-miner-metrics.xml
Normal file
9
deploy/quantus-miner-metrics.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<service>
|
||||
<short>quantus-miner-metrics</short>
|
||||
<description>Quantus miner Prometheus exporter. The miner binds this on
|
||||
0.0.0.0 unconditionally (crates/metrics: SocketAddr from [0,0,0,0]) — there is
|
||||
no loopback option — so the firewalld rich rule scoped to the scrape host is
|
||||
the only thing bounding who can reach it.</description>
|
||||
<port protocol="tcp" port="9900"/>
|
||||
</service>
|
||||
64
deploy/quantus-miner.service
Normal file
64
deploy/quantus-miner.service
Normal file
@@ -0,0 +1,64 @@
|
||||
# Quantus external miner. Runs on the GPU host and connects OUT to the node's
|
||||
# QUIC control channel; it listens on nothing but its loopback metrics port, so
|
||||
# it ships no firewalld service of its own.
|
||||
#
|
||||
# Hardened per ~/git/architecture/generic.md §8, with one deliberate relaxation:
|
||||
#
|
||||
# PrivateDevices=false
|
||||
# The miner needs /dev/nvidia*; PrivateDevices=true hides them and wgpu
|
||||
# finds no adapter, silently falling back to nothing.
|
||||
#
|
||||
# Poseidon2-over-Goldilocks is compute-bound in registers, not memory-bound, so
|
||||
# there is no tuning knob here worth more than the GPU power limit — which is a
|
||||
# host-level concern (nvidia-smi -pl), not a unit-file one.
|
||||
#
|
||||
# --cpu-workers 0 is deliberate: a current-gen desktop CPU contributes ~4 MH/s
|
||||
# next to a 4090's ~183 MH/s, for a couple hundred watts. Measured, not assumed.
|
||||
|
||||
[Unit]
|
||||
Description=Quantus external miner (GPU)
|
||||
Documentation=https://github.com/Quantus-Network/quantus-miner
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=quantus-miner
|
||||
Group=quantus-miner
|
||||
Environment=RUST_LOG=info
|
||||
WorkingDirectory=/var/lib/quantus-miner
|
||||
|
||||
ExecStart=/usr/local/bin/quantus-miner serve \
|
||||
--node-addr ${QUANTUS_NODE_ADDR} \
|
||||
--auth-token-file /etc/quantus-miner/miner-auth-token \
|
||||
--tls-cert-sha256-file /etc/quantus-miner/miner-tls-cert-sha256 \
|
||||
--gpu-devices ${QUANTUS_GPU_DEVICES} \
|
||||
--cpu-workers 0 \
|
||||
--metrics-port 9900
|
||||
EnvironmentFile=/etc/quantus-miner/miner.env
|
||||
|
||||
# The node may be down, still syncing, or mid-redeploy; reconnecting is normal
|
||||
# operation, not an error condition.
|
||||
Restart=always
|
||||
RestartSec=15s
|
||||
LimitNOFILE=65536
|
||||
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
PrivateTmp=true
|
||||
PrivateDevices=false
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectControlGroups=true
|
||||
RestrictRealtime=true
|
||||
RestrictSUIDSGID=true
|
||||
LockPersonality=true
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=false
|
||||
|
||||
ReadWritePaths=/var/lib/quantus-miner
|
||||
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
4
deploy/quantus-miner.sysusers.conf
Normal file
4
deploy/quantus-miner.sysusers.conf
Normal file
@@ -0,0 +1,4 @@
|
||||
#Type Name ID GECOS Home directory Shell
|
||||
u quantus-miner - "Quantus miner service account" /var/lib/quantus-miner /usr/sbin/nologin
|
||||
m quantus-miner video
|
||||
m quantus-miner render
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
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 (default), cpu-chain-manipulator
|
||||
- gpu for high-performance GPU mining
|
||||
- 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,4 +1,4 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
channel = "1.93.0"
|
||||
components = ["clippy", "rustfmt"]
|
||||
profile = "minimal"
|
||||
|
||||
Reference in New Issue
Block a user