10 Commits
Author SHA1 Message Date
illuzen 8359892406 bump version to v3.3.0 (#71) 2026-06-30 13:47:10 +08:00
Nikolaus Hegerandilluzen d99be29a64 Select GPU adapters by backend to fix multi-backend OOM (#67)
On Windows wgpu enumerates each physical GPU once per backend (Vulkan +
DX12) plus a CPU-emulated fallback ("Microsoft Basic Render Driver").
Building a mining context for every entry causes VRAM contention and
OOMs the process during benchmark/serve startup (#61).

Instead of deduplicating by (vendor, device) PCI IDs - which would
collapse rigs with multiple identical cards into a single context - drop
CPU-emulated adapters and keep all adapters from the highest-ranked
backend present (Vulkan/Metal, then DX12). Within a single backend each
physical GPU appears exactly once, so identical cards are preserved by
construction and no physical-ID matching is needed.

Selected adapters are ordered discrete-first so `--gpu-devices 1` picks
the discrete card on hybrid laptops. Skipped adapters are logged at info
level; if nothing usable remains, init fails with an explicit error.

Selection logic is a pure index-based function unit-tested against the
exact enumeration reported in #61, identical multi-GPU rigs, DX12-only
machines, and software-only environments.

Co-authored-by: illuzen <illuzen@users.noreply.github.com>
2026-06-30 13:13:45 +08:00
illuzen 7296c003bb Improve Windows gpu support (#70)
* support AMD GPUs on windows

* add support for more GPUs

* remove shader dump from logs

* handle gpu initialization failure better

* fmt

* re-order matching to avoid mobile / desktop false positives

* harden gpu matching

* add tests to engine-gpu module

* handle dead device and failed mapping correctly

* handle timeout properly with tokio

* nits

* fix benchmarks

* fmt
2026-06-30 12:55:35 +08:00
I Dewa Gede Bisma Mahendra d37f1dc0e5 Merge pull request #69 from Quantus-Network/beast/add-use-main-in-workflow
feat: make dockerfile use main an option
2026-06-24 17:43:08 +08:00
Beast 4d53fff54b feat: make dockerfile use main an option 2026-06-24 17:16:00 +08:00
I Dewa Gede Bisma Mahendra 18e8539fa3 Merge pull request #68 from Quantus-Network/beast/fix-docker-build
fix: docker build
2026-06-24 17:00:40 +08:00
Beast 4ab6914331 fix: docker build 2026-06-24 16:44:49 +08:00
illuzen 16fb76c5e8 bump version to v3.2.0 (#66) 2026-06-12 10:56:36 +08:00
illuzen 157aa6fb5c Address v12 issues (#65)
* don't accept zero difficulty jobs

* bound job queue

* synchronous emit_payload

* cleaner gpu_batch_size type

* remove build script

* distance_threshold -> difficulty

* enable pq-cert checking with miner

* clearer logs

* Revert "enable pq-cert checking with miner"

This reverts commit 7a7f5394352b60d8c946f547f77a057504b29c44.

* update versions of relevant deps, including rand
2026-06-12 10:48:01 +08:00
illuzen 5ce62b3e12 add gpu-throttle arg (#64)
* add gpu-throttle arg

* smarter sleep logic

* document cli args
2026-05-22 23:31:54 +08:00
21 changed files with 1322 additions and 1126 deletions
+8 -14
View File
@@ -7,6 +7,11 @@ on:
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
@@ -96,19 +101,8 @@ jobs:
with:
ref: ${{ env.TARGET_VERSION_WITH_V }}
- name: Check if Dockerfile exists in tag
id: check_dockerfile
run: |
if [ ! -f Dockerfile ]; then
echo "::warning::Dockerfile not found in tag ${{ env.TARGET_VERSION_WITH_V }}, falling back to main branch Dockerfile"
echo "use_main=true" >> $GITHUB_OUTPUT
else
echo "Dockerfile found in tag"
echo "use_main=false" >> $GITHUB_OUTPUT
fi
- name: Checkout main branch for Dockerfile (if needed)
if: steps.check_dockerfile.outputs.use_main == 'true'
if: github.event.inputs.use_main_dockerfile == 'true'
uses: actions/checkout@v4
with:
ref: main
@@ -119,13 +113,13 @@ jobs:
path: dockerfile-source
- name: Copy Dockerfile from main (if needed)
if: steps.check_dockerfile.outputs.use_main == 'true'
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@v5
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
Generated
+41 -27
View File
@@ -571,18 +571,18 @@ dependencies = [
[[package]]
name = "engine-cpu"
version = "3.1.0"
version = "3.3.0"
dependencies = [
"criterion",
"hex",
"pow-core",
"primitive-types 0.13.1",
"rand 0.8.5",
"rand 0.9.2",
]
[[package]]
name = "engine-gpu"
version = "3.1.0"
version = "3.3.0"
dependencies = [
"bytemuck",
"criterion",
@@ -591,16 +591,16 @@ dependencies = [
"futures",
"hex",
"log",
"p3-field",
"p3-goldilocks",
"pow-core",
"primitive-types 0.13.1",
"qp-plonky2",
"qp-plonky2-field",
"qp-poseidon-constants",
"qp-poseidon-core",
"rand 0.8.5",
"rand_chacha 0.3.1",
"rand 0.9.2",
"rand_chacha 0.9.0",
"regex",
"tokio",
"wgpu",
]
@@ -831,6 +831,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi 5.3.0",
"wasip2",
]
[[package]]
name = "getrandom"
version = "0.4.2"
@@ -839,7 +851,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"r-efi 6.0.0",
"rand_core 0.10.0",
"wasip2",
"wasip3",
@@ -1458,7 +1470,7 @@ dependencies = [
[[package]]
name = "metrics"
version = "3.1.0"
version = "3.3.0"
dependencies = [
"anyhow",
"log",
@@ -1486,7 +1498,7 @@ dependencies = [
[[package]]
name = "miner-cli"
version = "3.1.0"
version = "3.3.0"
dependencies = [
"clap",
"engine-cpu",
@@ -1497,13 +1509,13 @@ dependencies = [
"miner-service",
"num_cpus",
"primitive-types 0.13.1",
"rand 0.8.5",
"rand 0.9.2",
"tokio",
]
[[package]]
name = "miner-service"
version = "3.1.0"
version = "3.3.0"
dependencies = [
"anyhow",
"crossbeam-channel",
@@ -1524,7 +1536,7 @@ dependencies = [
[[package]]
name = "miner-telemetry"
version = "3.1.0"
version = "3.3.0"
dependencies = [
"anyhow",
"futures",
@@ -2008,7 +2020,7 @@ dependencies = [
[[package]]
name = "pow-core"
version = "3.1.0"
version = "3.3.0"
dependencies = [
"hex",
"primitive-types 0.13.1",
@@ -2215,22 +2227,14 @@ dependencies = [
[[package]]
name = "qp-poseidon-core"
version = "1.4.0"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51774769a44a1c65f9ad3959ff1497a9c315a16f5718151873721b3c5631413"
dependencies = [
"p3-field",
"p3-goldilocks",
"p3-poseidon2",
"p3-symmetric",
"qp-poseidon-constants",
"rand_chacha 0.9.0",
]
checksum = "78172860cd960773c72e97fba07ead9e5a1c13086c5746283744933e2e4a577b"
[[package]]
name = "qpow-math"
version = "0.1.0"
source = "git+https://github.com/Quantus-Network/chain.git?tag=v0.4.11-star-dust#89f0260cdba4eb71533cb060215cca3d166108d1"
source = "git+https://github.com/Quantus-Network/chain.git?tag=v0.7.1-q-day-2#5ea94cb1e5347b12d091b6fa9f9234ceb897ec42"
dependencies = [
"hex",
"log",
@@ -2240,8 +2244,8 @@ dependencies = [
[[package]]
name = "quantus-miner-api"
version = "0.0.3"
source = "git+https://github.com/Quantus-Network/chain.git?tag=v0.4.11-star-dust#89f0260cdba4eb71533cb060215cca3d166108d1"
version = "0.2.0"
source = "git+https://github.com/Quantus-Network/chain.git?tag=v0.7.1-q-day-2#5ea94cb1e5347b12d091b6fa9f9234ceb897ec42"
dependencies = [
"serde",
"serde_json",
@@ -2305,6 +2309,12 @@ dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "r-efi"
version = "6.0.0"
@@ -2334,6 +2344,7 @@ version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
]
@@ -2382,6 +2393,9 @@ name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "rand_core"
+5 -5
View File
@@ -15,7 +15,7 @@ resolver = "2"
edition = "2021"
authors = ["Quantus Network"]
description = "Quantus External Miner Workspace"
version = "3.1.0"
version = "3.3.0"
[workspace.dependencies]
anyhow = "1"
@@ -29,10 +29,10 @@ num-bigint = { version = "0.4", features = ["rand"] }
num-traits = "0.2"
num_cpus = "1.16"
primitive-types = { version = "0.13.1", default-features = false }
qp-poseidon-core = { version = "1.4.0", default-features = false }
qpow-math = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.4.11-star-dust", package = "qpow-math", default-features = false }
quantus-miner-api = { git = "https://github.com/Quantus-Network/chain.git", tag = "v0.4.11-star-dust" }
rand = { version = "0.8.5", default-features = false }
qp-poseidon-core = { version = "2.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" }
rand = { version = "0.9", default-features = false, features = ["std", "std_rng", "thread_rng"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0.132", default-features = false }
thiserror = "1"
-1
View File
@@ -18,7 +18,6 @@ WORKDIR /build
# Copy workspace files
COPY Cargo.toml Cargo.lock rust-toolchain taplo.toml ./
COPY crates ./crates
COPY tests ./tests
# Build the miner-cli in release mode
RUN cargo build --release -p miner-cli --locked
+9 -3
View File
@@ -34,10 +34,13 @@ 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` |
| `--cpu-workers <N>` | `MINER_CPU_WORKERS` | Number of CPU worker threads | Auto-detect |
| `--gpu-devices <N>` | `MINER_GPU_DEVICES` | Number of GPU devices | 0 |
| `--port <PORT>` | `MINER_PORT` | QUIC server port | 9833 |
| `--metrics-port <PORT>` | `MINER_METRICS_PORT` | Prometheus metrics port | Disabled |
| `--gpu-devices <N>` | `MINER_GPU_DEVICES` | Number of GPU devices | Auto-detect |
| `--gpu-batch-size <N>` | `MINER_GPU_BATCH_SIZE` | GPU batch size in nonces | 1000000 |
| `--cpu-batch-size <N>` | `MINER_CPU_BATCH_SIZE` | CPU batch size in hashes | 10000 |
| `--gpu-throttle-ms <MS>` | `MINER_GPU_THROTTLE_MS` | Sleep duration (ms) between GPU batches | 0 |
| `--metrics-port <PORT>` | `MINER_METRICS_PORT` | Prometheus metrics port | 9900 |
## GPU Mining
@@ -74,6 +77,9 @@ cargo build -p miner-cli --release
# Pure GPU mining
./target/release/quantus-miner serve --gpu-devices 1
# GPU mining with throttle (reduce GPU utilization)
./target/release/quantus-miner serve --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
@@ -19,7 +19,7 @@ fn bench_cpu_fast_engine(c: &mut Criterion) {
c.bench_function("cpu_fast_large_range", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(10_000_000u64);
let ctx = JobContext::new(header, difficulty);
@@ -36,7 +36,7 @@ fn bench_cpu_fast_engine(c: &mut Criterion) {
fn bench_hash_from_nonce(c: &mut Criterion) {
// Create a test job context
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(1000u64);
let ctx = JobContext::new(header, difficulty);
+7 -6
View File
@@ -26,20 +26,21 @@ qp-poseidon-core = { workspace = true }
qp-poseidon-constants = "1.1"
qp-plonky2-field = { version = "1.1.1" }
plonky2 = { package = "qp-plonky2", version = "1.1.3" }
p3-goldilocks = "0.3"
p3-field = "0.3"
wgpu = { version = "27.0.1" } # GPU compute library
futures = "0.3" # For async executor
bytemuck = "1.16" # For buffer mapping
wgpu = { version = "27.0.1" } # GPU compute library
tokio = { workspace = true, features = ["rt", "rt-multi-thread", "time"] } # Async runtime with timeout support
futures = "0.3" # For async oneshot channels in test modules
bytemuck = "1.16" # For buffer mapping
rand = { workspace = true, features = ["std", "std_rng"] }
rand_chacha = "0.3" # For deterministic random test generation
rand_chacha = "0.9" # For deterministic random test generation
regex = "1" # For GPU name pattern matching
[dev-dependencies]
hex = { workspace = true }
criterion = "0.5"
rand = { workspace = true, features = ["std", "std_rng"] }
env_logger.workspace = true
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time"] } # Add macros for #[tokio::main]
[[bench]]
name = "gpu_engine_bench"
+19 -19
View File
@@ -8,7 +8,7 @@ use std::sync::atomic::AtomicBool;
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).expect("Failed to init GPU");
let gpu_engine = GpuEngine::try_new(10_000_000, 0).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
@@ -25,7 +25,7 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
group.bench_function("cpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -41,7 +41,7 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
group.bench_function("gpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -59,7 +59,7 @@ fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000).expect("Failed to init GPU");
let gpu_engine = GpuEngine::try_new(10_000_000, 0).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
@@ -76,7 +76,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
group.bench_function("cpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -92,7 +92,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
group.bench_function("gpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -110,7 +110,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000).expect("Failed to init GPU");
let gpu_engine = GpuEngine::try_new(10_000_000, 0).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
@@ -127,7 +127,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
group.bench_function("cpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -143,7 +143,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
group.bench_function("gpu", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -161,7 +161,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
fn bench_solution_finding(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000).expect("Failed to init GPU");
let gpu_engine = GpuEngine::try_new(10_000_000, 0).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
@@ -178,7 +178,7 @@ fn bench_solution_finding(c: &mut Criterion) {
group.bench_function("cpu_find_solution", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(10_000u64); // Easy difficulty - should find solution
let ctx = JobContext::new(header, difficulty);
@@ -194,7 +194,7 @@ fn bench_solution_finding(c: &mut Criterion) {
group.bench_function("gpu_find_solution", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(10_000u64); // Easy difficulty - should find solution
let ctx = JobContext::new(header, difficulty);
@@ -212,7 +212,7 @@ fn bench_solution_finding(c: &mut Criterion) {
fn bench_throughput_per_second(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new(10_000);
let gpu_engine = GpuEngine::try_new(10_000_000).expect("Failed to init GPU");
let gpu_engine = GpuEngine::try_new(10_000_000, 0).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
@@ -229,7 +229,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
group.bench_function("cpu_throughput", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -245,7 +245,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
group.bench_function("gpu_throughput", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let ctx = JobContext::new(header, difficulty);
@@ -262,7 +262,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
}
fn bench_gpu_batch_efficiency(c: &mut Criterion) {
let gpu_engine = GpuEngine::try_new(10_000_000).expect("Failed to init GPU");
let gpu_engine = GpuEngine::try_new(10_000_000, 0).expect("Failed to init GPU");
let cancel_flag = AtomicBool::new(false);
let cancel_check = AtomicBoolCancelCheck(&cancel_flag);
@@ -289,7 +289,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
group.bench_function("gpu_1k_batch", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX);
let ctx = JobContext::new(header, difficulty);
@@ -305,7 +305,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
group.bench_function("gpu_50k_batch", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX);
let ctx = JobContext::new(header, difficulty);
@@ -321,7 +321,7 @@ fn bench_gpu_batch_efficiency(c: &mut Criterion) {
group.bench_function("gpu_500k_batch", |b| {
b.iter(|| {
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::from(u64::MAX);
let ctx = JobContext::new(header, difficulty);
+1 -1
View File
@@ -26,7 +26,7 @@ fn main() {
// 3. Verify with GPU engine
log::info!("Initializing GPU engine...");
let gpu_engine = GpuEngine::try_new(10_000_000).expect("Failed to init GPU");
let gpu_engine = GpuEngine::try_new(10_000_000, 0).expect("Failed to init GPU");
// Search a small range around the valid nonce
let gpu_range = Range {
+723
View File
@@ -0,0 +1,723 @@
//! GPU tier detection using regex patterns
//!
//! This module provides a table-driven approach to GPU detection,
//! using regex patterns with word boundaries to avoid substring matching issues.
use regex::Regex;
use std::sync::LazyLock;
/// GPU tier configuration
struct GpuTier {
/// Regex pattern to match GPU name (case-insensitive)
pattern: &'static str,
/// Human-readable tier name
name: &'static str,
/// Divisor for max_workgroups (higher = more conservative)
workgroup_divisor: u32,
/// Minimum workgroups to use
min_workgroups: u32,
}
/// Compiled GPU tier with regex
struct CompiledGpuTier {
regex: Regex,
name: &'static str,
workgroup_divisor: u32,
min_workgroups: u32,
}
impl CompiledGpuTier {
fn from_tier(tier: &GpuTier) -> Self {
Self {
regex: Regex::new(&format!("(?i){}", tier.pattern)).expect("Invalid GPU tier regex"),
name: tier.name,
workgroup_divisor: tier.workgroup_divisor,
min_workgroups: tier.min_workgroups,
}
}
}
// GPU tiers are checked in order - first match wins
// Use word boundaries (\b) to avoid substring issues like "550" matching "5500"
const NVIDIA_TIERS: &[GpuTier] = &[
// Blackwell (RTX 50 series)
GpuTier {
pattern: r"\b50[89]0\b",
name: "NVIDIA RTX 50 Flagship (Blackwell)",
workgroup_divisor: 6,
min_workgroups: 5120,
},
GpuTier {
pattern: r"\b50[67]0\b|rtx 50",
name: "NVIDIA RTX 50 (Blackwell)",
workgroup_divisor: 7,
min_workgroups: 4608,
},
// Ada Lovelace (RTX 40 series)
GpuTier {
pattern: r"\b40[89]0\b",
name: "NVIDIA RTX 40 Flagship (Ada)",
workgroup_divisor: 8,
min_workgroups: 4096,
},
GpuTier {
pattern: r"\b40[67]0\b|rtx 40",
name: "NVIDIA RTX 40 (Ada)",
workgroup_divisor: 10,
min_workgroups: 3072,
},
// Ampere/Turing (RTX 30/20 series)
GpuTier {
pattern: r"\b30[5-9]0\b|\b20[6-8]0\b|rtx 30|rtx 20",
name: "NVIDIA RTX 30/20 (Ampere/Turing)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
// Turing/Pascal (GTX 16/10 series)
GpuTier {
pattern: r"\b16[56]0\b|\b10[3-8]0\b|gtx 16|gtx 10",
name: "NVIDIA GTX 16/10 (Turing/Pascal)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// Maxwell (GTX 900 series)
GpuTier {
pattern: r"\b9[5-8]0\b|gtx 9",
name: "NVIDIA GTX 900 (Maxwell)",
workgroup_divisor: 18,
min_workgroups: 768,
},
// Kepler/Maxwell (GTX 700 series)
GpuTier {
pattern: r"\b7[5-8]0\b|gtx 7",
name: "NVIDIA GTX 700 (Kepler/Maxwell)",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Legacy GTX
GpuTier {
pattern: r"gtx [456]",
name: "NVIDIA GTX Legacy (Fermi/Kepler)",
workgroup_divisor: 24,
min_workgroups: 384,
},
GpuTier {
pattern: r"\bgtx\b",
name: "NVIDIA GTX (Unknown)",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Mobile
GpuTier {
pattern: r"\bmx[1-5]\d0|geforce mx",
name: "NVIDIA MX (Mobile)",
workgroup_divisor: 24,
min_workgroups: 384,
},
GpuTier {
pattern: r"geforce gt\b|\bgt \d{3}\b",
name: "NVIDIA GT (Entry-Level)",
workgroup_divisor: 28,
min_workgroups: 256,
},
// Professional
GpuTier {
pattern: r"quadro|rtx a\d|tesla|\ba100\b|\bh100\b|\bl4\b",
name: "NVIDIA Quadro/Professional",
workgroup_divisor: 10,
min_workgroups: 2560,
},
];
const AMD_TIERS: &[GpuTier] = &[
// RDNA 4 (RX 9000 series)
GpuTier {
pattern: r"rx 9|\b90[78]0\b",
name: "AMD RX 9000 (RDNA 4)",
workgroup_divisor: 8,
min_workgroups: 4096,
},
// RDNA 3 Discrete
GpuTier {
pattern: r"\b7900\b",
name: "AMD RX 7900 (RDNA 3 Flagship)",
workgroup_divisor: 9,
min_workgroups: 3584,
},
GpuTier {
pattern: r"rx 7|\b7[6-8]00\b",
name: "AMD RX 7000 (RDNA 3)",
workgroup_divisor: 10,
min_workgroups: 3072,
},
// RDNA 3 APUs - check before discrete to avoid substring match
GpuTier {
pattern: r"\b780m\b|radeon 780m",
name: "AMD Radeon 780M (RDNA 3 APU)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
GpuTier {
pattern: r"\b7[46]0m\b|radeon 7[46]0m",
name: "AMD Radeon 7x0M (RDNA 3 APU)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// RDNA 2 Discrete
GpuTier {
pattern: r"\b6[89][05]0\b",
name: "AMD RX 6900/6800 (RDNA 2 Flagship)",
workgroup_divisor: 12,
min_workgroups: 2560,
},
GpuTier {
pattern: r"\b6[67][05]0\b",
name: "AMD RX 6700/6600 (RDNA 2)",
workgroup_divisor: 14,
min_workgroups: 2048,
},
GpuTier {
pattern: r"\b6[45]00\b",
name: "AMD RX 6500/6400 (RDNA 2 Entry)",
workgroup_divisor: 22,
min_workgroups: 512,
},
GpuTier {
pattern: r"rx 6\d{3}",
name: "AMD RX 6000 (RDNA 2)",
workgroup_divisor: 14,
min_workgroups: 2048,
},
// RDNA 2 APUs - check before discrete
GpuTier {
pattern: r"\b680m\b|radeon 680m",
name: "AMD Radeon 680M (RDNA 2 APU)",
workgroup_divisor: 16,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\b6[16]0m\b|radeon 6[16]0m",
name: "AMD Radeon 6x0M (RDNA 2 APU)",
workgroup_divisor: 22,
min_workgroups: 768,
},
// RDNA 1 (RX 5000 series) - 4-digit patterns to avoid matching Polaris 3-digit
GpuTier {
pattern: r"\b5700\b",
name: "AMD RX 5700 (RDNA 1)",
workgroup_divisor: 16,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\b5[56]00\b|rx 5\d{3}",
name: "AMD RX 5000 (RDNA 1)",
workgroup_divisor: 18,
min_workgroups: 1024,
},
// Polaris (RX 400/500 series) - 3-digit models with boundaries
GpuTier {
pattern: r"rx [45]\d0\b|\b[45][6-9]0\b|\b590\b|rx 5.0x",
name: "AMD RX 500/400 (Polaris)",
workgroup_divisor: 20,
min_workgroups: 768,
},
// Vega
GpuTier {
pattern: r"radeon vii\b",
name: "AMD Radeon VII (Vega 20)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
GpuTier {
pattern: r"vega\s*64",
name: "AMD Vega 64 (Discrete)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
GpuTier {
pattern: r"vega\s*56",
name: "AMD Vega 56 (Discrete)",
workgroup_divisor: 16,
min_workgroups: 1280,
},
GpuTier {
pattern: r"vega",
name: "AMD Vega (APU)",
workgroup_divisor: 28,
min_workgroups: 384,
},
// GCN
GpuTier {
pattern: r"fury|nano",
name: "AMD R9 Fury/Nano (Fiji)",
workgroup_divisor: 16,
min_workgroups: 1280,
},
GpuTier {
pattern: r"r9.*(3[89]0|2[89]0)|\b[23][89]0x?\b",
name: "AMD R9 (GCN)",
workgroup_divisor: 20,
min_workgroups: 768,
},
GpuTier {
pattern: r"r7.*(3[67]0|2[67]0)|\b[23][67]0x?\b",
name: "AMD R7 (GCN)",
workgroup_divisor: 22,
min_workgroups: 512,
},
// Professional
GpuTier {
pattern: r"radeon pro|instinct mi|mi[123]\d0|firepro|w[567]\d{3}",
name: "AMD Radeon Pro/Instinct",
workgroup_divisor: 10,
min_workgroups: 2560,
},
// OEM/APU fallbacks
GpuTier {
pattern: r"radeon\s*\(tm\)\s*[67]\d{2}\b|radeon [67]\d{2}\b",
name: "AMD Radeon OEM (Polaris Rebrand)",
workgroup_divisor: 24,
min_workgroups: 512,
},
GpuTier {
pattern: r"radeon\s*(\(tm\)\s*)?graphics",
name: "AMD Radeon Graphics (APU)",
workgroup_divisor: 26,
min_workgroups: 384,
},
];
const INTEL_TIERS: &[GpuTier] = &[
// Battlemage (Arc B-Series)
GpuTier {
pattern: r"arc b|\bb5[78]0\b",
name: "Intel Arc B-Series (Battlemage)",
workgroup_divisor: 10,
min_workgroups: 2560,
},
// Alchemist Mobile - check BEFORE desktop (a770m contains a770)
GpuTier {
pattern: r"\ba7[37]0m\b",
name: "Intel Arc A7 Mobile (Alchemist)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\ba5[57]0m\b",
name: "Intel Arc A5 Mobile (Alchemist)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
GpuTier {
pattern: r"\ba3[57]0m\b",
name: "Intel Arc A3 Mobile (Alchemist)",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Alchemist Desktop
GpuTier {
pattern: r"\ba7[57]0\b",
name: "Intel Arc A7 (Alchemist)",
workgroup_divisor: 12,
min_workgroups: 2048,
},
GpuTier {
pattern: r"\ba580\b",
name: "Intel Arc A5 (Alchemist)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
GpuTier {
pattern: r"\ba3[18]0\b",
name: "Intel Arc A3 (Alchemist)",
workgroup_divisor: 18,
min_workgroups: 768,
},
GpuTier {
pattern: r"arc a|\barc\b",
name: "Intel Arc (Unknown)",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// Integrated
GpuTier {
pattern: r"iris xe max",
name: "Intel Iris Xe Max (Discrete)",
workgroup_divisor: 20,
min_workgroups: 512,
},
GpuTier {
pattern: r"iris xe",
name: "Intel Iris Xe (Integrated)",
workgroup_divisor: 24,
min_workgroups: 384,
},
GpuTier {
pattern: r"iris pro",
name: "Intel Iris Pro (Integrated)",
workgroup_divisor: 26,
min_workgroups: 256,
},
GpuTier {
pattern: r"iris plus|iris\b",
name: "Intel Iris Plus (Integrated)",
workgroup_divisor: 26,
min_workgroups: 320,
},
GpuTier {
pattern: r"uhd.*(7\d{2}|graphics 7)",
name: "Intel UHD 700 (Integrated)",
workgroup_divisor: 26,
min_workgroups: 320,
},
GpuTier {
pattern: r"uhd.*(6\d{2}|graphics 6)",
name: "Intel UHD 600 (Integrated)",
workgroup_divisor: 28,
min_workgroups: 256,
},
GpuTier {
pattern: r"uhd",
name: "Intel UHD Graphics (Integrated)",
workgroup_divisor: 28,
min_workgroups: 256,
},
GpuTier {
pattern: r"hd graphics|hd [456]\d{2}",
name: "Intel HD Graphics (Integrated)",
workgroup_divisor: 30,
min_workgroups: 192,
},
];
const QUALCOMM_TIERS: &[GpuTier] = &[
// Snapdragon X (Adreno X1)
GpuTier {
pattern: r"x elite|x plus|adreno x|x1-[89]",
name: "Qualcomm Adreno X1 (Snapdragon X)",
workgroup_divisor: 14,
min_workgroups: 1536,
},
// Adreno 700 series (730, 740, 750, etc.)
GpuTier {
pattern: r"adreno\s*7[0-9]{2}\b|\b7[345]0\b",
name: "Qualcomm Adreno 700 Series",
workgroup_divisor: 16,
min_workgroups: 1024,
},
// Adreno 600 series (610, 612, 615, 618, 619, 620, 630, 640, 650, 660, etc.)
GpuTier {
pattern: r"adreno\s*6[0-9]{2}\b|\b6[1-6][0-9]\b",
name: "Qualcomm Adreno 600 Series",
workgroup_divisor: 20,
min_workgroups: 512,
},
// Adreno 500 series (505, 506, 508, 509, 510, 512, 530, 540)
GpuTier {
pattern: r"adreno\s*5[0-4][0-9]\b|\b5[0-4][0-9]\b",
name: "Qualcomm Adreno 500 Series",
workgroup_divisor: 24,
min_workgroups: 384,
},
];
const APPLE_TIERS: &[GpuTier] = &[
// M4 series
GpuTier {
pattern: r"m4 ultra",
name: "Apple M4 Ultra",
workgroup_divisor: 4,
min_workgroups: 1600,
},
GpuTier {
pattern: r"m4 max",
name: "Apple M4 Max",
workgroup_divisor: 4,
min_workgroups: 800,
},
GpuTier {
pattern: r"m4 pro",
name: "Apple M4 Pro",
workgroup_divisor: 4,
min_workgroups: 400,
},
GpuTier {
pattern: r"\bm4\b",
name: "Apple M4",
workgroup_divisor: 4,
min_workgroups: 200,
},
// M3 series
GpuTier {
pattern: r"m3 ultra",
name: "Apple M3 Ultra",
workgroup_divisor: 4,
min_workgroups: 1520,
},
GpuTier {
pattern: r"m3 max",
name: "Apple M3 Max",
workgroup_divisor: 4,
min_workgroups: 800,
},
GpuTier {
pattern: r"m3 pro",
name: "Apple M3 Pro",
workgroup_divisor: 4,
min_workgroups: 360,
},
GpuTier {
pattern: r"\bm3\b",
name: "Apple M3",
workgroup_divisor: 4,
min_workgroups: 200,
},
// M2 series
GpuTier {
pattern: r"m2 ultra",
name: "Apple M2 Ultra",
workgroup_divisor: 4,
min_workgroups: 1520,
},
GpuTier {
pattern: r"m2 max",
name: "Apple M2 Max",
workgroup_divisor: 4,
min_workgroups: 760,
},
GpuTier {
pattern: r"m2 pro",
name: "Apple M2 Pro",
workgroup_divisor: 4,
min_workgroups: 380,
},
GpuTier {
pattern: r"\bm2\b",
name: "Apple M2",
workgroup_divisor: 4,
min_workgroups: 200,
},
// M1 series
GpuTier {
pattern: r"m1 ultra",
name: "Apple M1 Ultra",
workgroup_divisor: 4,
min_workgroups: 1280,
},
GpuTier {
pattern: r"m1 max",
name: "Apple M1 Max",
workgroup_divisor: 4,
min_workgroups: 640,
},
GpuTier {
pattern: r"m1 pro",
name: "Apple M1 Pro",
workgroup_divisor: 4,
min_workgroups: 320,
},
GpuTier {
pattern: r"\bm1\b",
name: "Apple M1",
workgroup_divisor: 4,
min_workgroups: 160,
},
];
/// Compiled GPU tier tables (lazily initialized)
struct GpuTierTables {
nvidia: Vec<CompiledGpuTier>,
amd: Vec<CompiledGpuTier>,
intel: Vec<CompiledGpuTier>,
qualcomm: Vec<CompiledGpuTier>,
apple: Vec<CompiledGpuTier>,
}
static GPU_TIERS: LazyLock<GpuTierTables> = LazyLock::new(|| GpuTierTables {
nvidia: NVIDIA_TIERS
.iter()
.map(CompiledGpuTier::from_tier)
.collect(),
amd: AMD_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
intel: INTEL_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
qualcomm: QUALCOMM_TIERS
.iter()
.map(CompiledGpuTier::from_tier)
.collect(),
apple: APPLE_TIERS.iter().map(CompiledGpuTier::from_tier).collect(),
});
/// Result of GPU tier matching
pub struct GpuTierMatch {
pub name: &'static str,
pub workgroup_divisor: u32,
pub min_workgroups: u32,
pub is_fallback: bool,
}
/// Find matching tier from a list of compiled tiers
fn find_matching_tier(name: &str, tiers: &[CompiledGpuTier]) -> Option<GpuTierMatch> {
tiers
.iter()
.find(|tier| tier.regex.is_match(name))
.map(|tier| GpuTierMatch {
name: tier.name,
workgroup_divisor: tier.workgroup_divisor,
min_workgroups: tier.min_workgroups,
is_fallback: false,
})
}
/// Detect GPU tier based on adapter info
///
/// Returns (tier_name, workgroup_divisor, min_workgroups, is_fallback)
pub fn detect_gpu_tier(vendor_name: &str, vendor_id: u32, is_metal_backend: bool) -> GpuTierMatch {
let name_lower = vendor_name.to_lowercase();
// Determine vendor and find matching tier
if name_lower.contains("nvidia") || vendor_id == 0x10DE {
find_matching_tier(&name_lower, &GPU_TIERS.nvidia).unwrap_or(GpuTierMatch {
name: "NVIDIA Unknown",
workgroup_divisor: 20,
min_workgroups: 512,
is_fallback: true,
})
} else if name_lower.contains("amd") || name_lower.contains("radeon") || vendor_id == 0x1002 {
find_matching_tier(&name_lower, &GPU_TIERS.amd).unwrap_or(GpuTierMatch {
name: "AMD Unknown",
workgroup_divisor: 24,
min_workgroups: 512,
is_fallback: true,
})
} else if name_lower.contains("intel") || vendor_id == 0x8086 {
find_matching_tier(&name_lower, &GPU_TIERS.intel).unwrap_or(GpuTierMatch {
name: "Intel Unknown",
workgroup_divisor: 24,
min_workgroups: 256,
is_fallback: true,
})
} else if name_lower.contains("qualcomm")
|| name_lower.contains("adreno")
|| vendor_id == 0x5143
{
find_matching_tier(&name_lower, &GPU_TIERS.qualcomm).unwrap_or(GpuTierMatch {
name: "Qualcomm Adreno (Unknown)",
workgroup_divisor: 24,
min_workgroups: 384,
is_fallback: true,
})
} else if is_metal_backend {
// Apple Silicon - detected by Metal backend
find_matching_tier(&name_lower, &GPU_TIERS.apple).unwrap_or(GpuTierMatch {
name: "Apple Silicon Unknown",
workgroup_divisor: 4,
min_workgroups: 160,
is_fallback: true,
})
} else {
GpuTierMatch {
name: "Unknown GPU",
workgroup_divisor: 16,
min_workgroups: 512,
is_fallback: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_nvidia_detection() {
// RTX 40 series
let tier = detect_gpu_tier("NVIDIA GeForce RTX 4090", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA RTX 40 Flagship (Ada)");
assert!(!tier.is_fallback);
let tier = detect_gpu_tier("NVIDIA GeForce RTX 4070 Ti", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA RTX 40 (Ada)");
// RTX 30 series
let tier = detect_gpu_tier("NVIDIA GeForce RTX 3080", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA RTX 30/20 (Ampere/Turing)");
// GTX 10 series
let tier = detect_gpu_tier("NVIDIA GeForce GTX 1080 Ti", 0x10DE, false);
assert_eq!(tier.name, "NVIDIA GTX 16/10 (Turing/Pascal)");
}
#[test]
fn test_amd_rdna_vs_polaris() {
// RDNA 1 - should NOT match Polaris
let tier = detect_gpu_tier("AMD Radeon RX 5500 XT", 0x1002, false);
assert_eq!(tier.name, "AMD RX 5000 (RDNA 1)");
assert!(!tier.is_fallback);
let tier = detect_gpu_tier("AMD Radeon RX 5600 XT", 0x1002, false);
assert_eq!(tier.name, "AMD RX 5000 (RDNA 1)");
let tier = detect_gpu_tier("AMD Radeon RX 5700 XT", 0x1002, false);
assert_eq!(tier.name, "AMD RX 5700 (RDNA 1)");
// Polaris - should match Polaris
let tier = detect_gpu_tier("AMD Radeon RX 580", 0x1002, false);
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
let tier = detect_gpu_tier("AMD Radeon RX 560X", 0x1002, false);
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
let tier = detect_gpu_tier("AMD Radeon RX 550", 0x1002, false);
assert_eq!(tier.name, "AMD RX 500/400 (Polaris)");
}
#[test]
fn test_amd_vega_apu() {
let tier = detect_gpu_tier("AMD Radeon(TM) Vega 8 Graphics", 0x1002, false);
assert_eq!(tier.name, "AMD Vega (APU)");
assert!(!tier.is_fallback);
}
#[test]
fn test_intel_arc_mobile_vs_desktop() {
// Mobile should match mobile tier
let tier = detect_gpu_tier("Intel Arc A770M", 0x8086, false);
assert_eq!(tier.name, "Intel Arc A7 Mobile (Alchemist)");
assert!(!tier.is_fallback);
// Desktop should match desktop tier
let tier = detect_gpu_tier("Intel Arc A770", 0x8086, false);
assert_eq!(tier.name, "Intel Arc A7 (Alchemist)");
assert!(!tier.is_fallback);
}
#[test]
fn test_apple_silicon() {
let tier = detect_gpu_tier("Apple M1 Pro", 0, true);
assert_eq!(tier.name, "Apple M1 Pro");
assert!(!tier.is_fallback);
let tier = detect_gpu_tier("Apple M3 Max", 0, true);
assert_eq!(tier.name, "Apple M3 Max");
assert!(!tier.is_fallback);
}
#[test]
fn test_qualcomm_adreno_series() {
// Adreno 627 should match 600 series, NOT 700 series
let tier = detect_gpu_tier("Qualcomm Adreno 627", 0x5143, false);
assert_eq!(tier.name, "Qualcomm Adreno 600 Series");
assert!(!tier.is_fallback);
// Adreno 730 should match 700 series
let tier = detect_gpu_tier("Qualcomm Adreno 730", 0x5143, false);
assert_eq!(tier.name, "Qualcomm Adreno 700 Series");
assert!(!tier.is_fallback);
// Adreno 540 should match 500 series
let tier = detect_gpu_tier("Qualcomm Adreno 540", 0x5143, false);
assert_eq!(tier.name, "Qualcomm Adreno 500 Series");
assert!(!tier.is_fallback);
}
}
+352 -301
View File
@@ -1,8 +1,12 @@
#![deny(rust_2018_idioms)]
#![forbid(unsafe_code)]
mod gpu_tiers;
pub mod end_to_end_tests;
pub mod tests;
use engine_cpu::{CancelCheck, Candidate, EngineStatus, FoundOrigin, MinerEngine, Range};
use futures::executor::block_on;
use pow_core::{format_hashrate, format_u512, JobContext};
use primitive_types::U512;
use std::cell::RefCell;
@@ -35,7 +39,8 @@ struct GpuResources {
pub struct GpuEngine {
contexts: Vec<Arc<GpuContext>>,
device_counter: AtomicUsize,
batch_size: u64,
batch_size: u32,
throttle_ms: u64,
}
// Thread-local storage for consistent GPU device assignment per worker thread
@@ -137,13 +142,96 @@ impl GpuContext {
}
}
/// Rank backends for mining: native compute APIs first.
fn backend_rank(backend: wgpu::Backend) -> u8 {
match backend {
wgpu::Backend::Vulkan | wgpu::Backend::Metal => 0,
wgpu::Backend::Dx12 => 1,
_ => 2,
}
}
/// Select which adapters to mine on, returning indices into `infos` ordered
/// discrete-first.
///
/// wgpu can enumerate the same physical GPU once per backend (Vulkan + DX12 on
/// Windows) plus CPU-emulated fallbacks such as "Microsoft Basic Render Driver".
/// Mining on every entry causes VRAM contention and OOM (issue #61), so CPU
/// adapters are dropped and only the best-ranked backend present is kept.
/// Within a single backend each physical GPU appears exactly once, so rigs with
/// multiple identical cards keep every card.
fn select_adapters(infos: &[wgpu::AdapterInfo]) -> Vec<usize> {
let usable: Vec<usize> = (0..infos.len())
.filter(|&i| {
if infos[i].device_type == wgpu::DeviceType::Cpu {
log::info!(
target: "gpu_engine",
"Skipping CPU-emulated adapter: {} ({:?})",
infos[i].name,
infos[i].backend
);
return false;
}
true
})
.collect();
let Some(best) = usable.iter().map(|&i| backend_rank(infos[i].backend)).min() else {
return Vec::new();
};
let mut selected: Vec<usize> = usable
.into_iter()
.filter(|&i| {
if backend_rank(infos[i].backend) != best {
log::info!(
target: "gpu_engine",
"Skipping adapter on lower-priority backend: {} ({:?})",
infos[i].name,
infos[i].backend
);
return false;
}
true
})
.collect();
selected.sort_by_key(|&i| match infos[i].device_type {
wgpu::DeviceType::DiscreteGpu => 0,
wgpu::DeviceType::IntegratedGpu => 1,
_ => 2,
});
selected
}
impl GpuEngine {
/// Try to initialize the GPU engine with the given batch size.
pub fn try_new(batch_size: u64) -> Result<Self, Box<dyn std::error::Error>> {
block_on(Self::init(batch_size))
/// Try to initialize the GPU engine with the given batch size and throttle (ms between batches).
///
/// # Errors
///
/// Returns an error if:
/// - `batch_size` is zero (no work would be performed)
/// - No usable GPU adapters are found
pub fn try_new(batch_size: u32, throttle_ms: u64) -> Result<Self, Box<dyn std::error::Error>> {
if batch_size == 0 {
return Err("batch_size must be non-zero".into());
}
// Handle both cases: called from within a tokio runtime or from outside
match tokio::runtime::Handle::try_current() {
Ok(handle) => {
// We're inside a tokio runtime - use block_in_place to allow blocking
tokio::task::block_in_place(|| handle.block_on(Self::init(batch_size, throttle_ms)))
}
Err(_) => {
// No runtime exists - create a temporary one
let rt = tokio::runtime::Runtime::new()?;
rt.block_on(Self::init(batch_size, throttle_ms))
}
}
}
async fn init(batch_size: u64) -> Result<Self, Box<dyn std::error::Error>> {
async fn init(batch_size: u32, throttle_ms: u64) -> Result<Self, Box<dyn std::error::Error>> {
log::info!(target: "gpu_engine", "Initializing WGPU...");
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
@@ -151,36 +239,73 @@ impl GpuEngine {
});
let adapters = instance.enumerate_adapters(wgpu::Backends::PRIMARY);
let infos: Vec<wgpu::AdapterInfo> = adapters.iter().map(|a| a.get_info()).collect();
// Collect adapters to a vector to check count and iterate with index
let adapters: Vec<_> = adapters.into_iter().collect();
if adapters.is_empty() {
log::error!(target: "gpu_engine", "No suitable GPU adapters found.");
return Err("No suitable GPU adapters found".into());
let selected = select_adapters(&infos);
if selected.is_empty() {
log::error!(
target: "gpu_engine",
"No usable GPU adapters found ({} enumerated). Use --gpu-devices 0 to disable GPU mining.",
infos.len()
);
return Err("No usable GPU adapters found".into());
}
let mut adapters: Vec<Option<wgpu::Adapter>> = adapters.into_iter().map(Some).collect();
let mut contexts = Vec::new();
let mut adapter_infos = Vec::new();
for (i, adapter) in adapters.into_iter().enumerate() {
let info = adapter.get_info();
log::debug!(target: "gpu_engine", "Adapter {} raw info: {:?}", i, info);
adapter_infos.push(info.clone());
let (device, queue) = adapter
.request_device(&wgpu::DeviceDescriptor {
label: Some("Mining Device"),
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
memory_hints: Default::default(),
..Default::default()
})
.await?;
// Timeout for initializing each adapter (30 seconds should be plenty)
let init_timeout = std::time::Duration::from_secs(30);
for (i, idx) in selected.into_iter().enumerate() {
let adapter = adapters[idx].take().expect("adapter selected exactly once");
let info = &infos[idx];
log::info!(
target: "gpu_engine",
"Initializing GPU device {i}: {} ({:?}, {:?})",
info.name,
info.device_type,
info.backend
);
log::debug!(target: "gpu_engine", "Adapter {i} raw info: {info:?}");
// 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_limits: wgpu::Limits::default(),
memory_hints: Default::default(),
..Default::default()
});
let device_result = match tokio::time::timeout(init_timeout, device_future).await {
Ok(result) => result,
Err(_) => {
log::warn!(
target: "gpu_engine",
"GPU device {i} ({}) timed out after {}s during initialization, skipping",
info.name, init_timeout.as_secs()
);
continue;
}
};
let (device, queue) = match device_result {
Ok(dq) => dq,
Err(e) => {
log::warn!(
target: "gpu_engine",
"Failed to initialize GPU device {i} ({}): {}. Skipping.",
info.name, e
);
continue;
}
};
// Log device limits at debug level
let limits = device.limits();
log::debug!(target: "gpu_engine", "Adapter {} limits: max_workgroups={}, max_workgroup_size={}x{}x{}, max_buffer={}",
i,
log::debug!(target: "gpu_engine", "GPU device {i} limits: max_workgroups={}, max_workgroup_size={}x{}x{}, max_buffer={}",
limits.max_compute_workgroups_per_dimension,
limits.max_compute_workgroup_size_x,
limits.max_compute_workgroup_size_y,
@@ -188,6 +313,9 @@ impl GpuEngine {
limits.max_buffer_size
);
// 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"),
@@ -203,10 +331,15 @@ impl GpuEngine {
cache: None,
});
log::debug!(target: "gpu_engine", "Pipeline initialized for adapter {}", i);
let pipeline_elapsed = pipeline_start.elapsed();
log::info!(
target: "gpu_engine",
"GPU device {i} ({}) initialized successfully (pipeline compiled in {:.1}s)",
info.name, pipeline_elapsed.as_secs_f64()
);
// Calculate vendor-specific configuration once during initialization
let optimal_workgroups = get_vendor_specific_dispatch(&info, &device);
let optimal_workgroups = get_vendor_specific_dispatch(info, &device);
contexts.push(Arc::new(GpuContext {
device,
@@ -216,17 +349,24 @@ impl GpuEngine {
}));
}
if contexts.is_empty() {
log::error!(target: "gpu_engine", "No GPU adapters could be initialized successfully.");
return Err("No GPU adapters could be initialized".into());
}
log::info!(
target: "gpu_engine",
"GPU engine initialized with {} devices (batch size: {} nonces)",
"GPU engine initialized with {} devices (batch size: {} nonces, throttle: {}ms)",
contexts.len(),
batch_size
batch_size,
throttle_ms
);
Ok(Self {
contexts,
device_counter: AtomicUsize::new(0),
batch_size,
throttle_ms,
})
}
@@ -377,10 +517,11 @@ impl MinerEngine for GpuEngine {
.saturating_sub(current_start)
.saturating_add(U512::one());
let batch_size_u512 = U512::from(self.batch_size);
let this_batch_size = if remaining > batch_size_u512 {
let this_batch_size: u32 = if remaining > batch_size_u512 {
self.batch_size
} else {
remaining.as_u64()
// remaining fits in u32 since it's <= batch_size which is u32
remaining.low_u32()
};
// Run single batch
@@ -417,12 +558,43 @@ impl MinerEngine for GpuEngine {
BatchResult::NotFound { hash_count } => {
total_hashes += hash_count;
}
BatchResult::DeviceLost => {
// GPU device is lost/unresponsive - log loudly and return cancelled
// This prevents spinning at 0 H/s indefinitely on a dead device
log::error!(
target: "gpu_engine",
"GPU {} device lost or unresponsive - stopping worker. \
This GPU will not process further batches.",
device_index
);
return EngineStatus::Cancelled {
hash_count: total_hashes,
};
}
}
// Move to next batch
current_start = current_start.saturating_add(U512::from(this_batch_size));
batch_num += 1;
// Apply throttle delay between batches (if configured and more batches remain)
// Sleep in small increments to remain responsive to cancellation
if self.throttle_ms > 0 && current_start <= range.end {
let sleep_interval =
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 sleep_time = remaining.min(sleep_interval);
std::thread::sleep(sleep_time);
remaining = remaining.saturating_sub(sleep_time);
}
}
// Log progress periodically (every 10 batches)
if batch_num.is_multiple_of(10) {
let elapsed = search_start.elapsed();
@@ -467,6 +639,8 @@ enum BatchResult {
NotFound {
hash_count: u64,
},
/// GPU device is lost or unresponsive - caller should stop using this device
DeviceLost,
}
/// Run a single batch of GPU computation
@@ -474,7 +648,7 @@ fn run_single_batch(
gpu_ctx: &GpuContext,
resources: &GpuResources,
batch_start: U512,
batch_size: u64,
batch_size: u32,
) -> BatchResult {
// Calculate dispatch configuration for this batch
let threads_per_workgroup = 256u32;
@@ -484,13 +658,13 @@ fn run_single_batch(
let hinted_workgroups = gpu_ctx.optimal_workgroups.max(1).min(max_workgroups);
let hinted_threads = hinted_workgroups as u64 * threads_per_workgroup as u64;
let logical_threads = batch_size.min(hinted_threads).max(1);
let logical_threads = (batch_size as u64).min(hinted_threads).max(1);
let num_workgroups = ((logical_threads as u32).div_ceil(threads_per_workgroup)).max(1);
let total_threads = (num_workgroups * threads_per_workgroup) as u64;
let nonces_per_thread = (batch_size.div_ceil(total_threads)).max(1) as u32;
let nonces_per_thread = ((batch_size as u64).div_ceil(total_threads)).max(1) as u32;
// Dispatch config: [total_threads, nonces_per_thread, total_nonces]
let dispatch_config = [total_threads as u32, nonces_per_thread, batch_size as u32];
let dispatch_config = [total_threads as u32, nonces_per_thread, batch_size];
// Write dispatch config
gpu_ctx.queue.write_buffer(
@@ -537,32 +711,56 @@ fn run_single_batch(
// Wait for GPU to complete (blocking)
let buffer_slice = resources.staging_buffer.slice(..);
let mapped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mapped_clone = mapped.clone();
// Use atomic to track completion: 0 = pending, 1 = success, 2 = error
let map_status = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0));
let map_status_clone = map_status.clone();
buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
if result.is_ok() {
mapped_clone.store(true, Ordering::Release);
}
map_status_clone.store(if result.is_ok() { 1 } else { 2 }, Ordering::Release);
});
// Poll until complete
loop {
// Poll until complete, error, or timeout (30 seconds max to prevent infinite hang)
let poll_start = std::time::Instant::now();
let max_poll_duration = std::time::Duration::from_secs(30);
let final_status = loop {
let _ = gpu_ctx.device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: Some(std::time::Duration::from_millis(10)),
});
if mapped.load(Ordering::Acquire) {
break;
match map_status.load(Ordering::Acquire) {
1 => break 1, // Success - buffer is mapped
2 => {
// Mapping failed - buffer was never successfully mapped, don't unmap
log::error!(
target: "gpu_engine",
"GPU buffer mapping failed - possible device lost or resource error"
);
return BatchResult::DeviceLost;
}
_ => {
// Still pending - check timeout
if poll_start.elapsed() > max_poll_duration {
log::error!(
target: "gpu_engine",
"GPU buffer mapping timed out after {}s - GPU may be unresponsive",
max_poll_duration.as_secs()
);
// Timeout: map_async callback never fired, buffer not mapped, don't unmap
return BatchResult::DeviceLost;
}
}
}
}
};
// Only reach here if final_status == 1 (success), buffer is mapped
debug_assert_eq!(final_status, 1);
// Read results
let data = buffer_slice.get_mapped_range();
let result_u32s: &[u32] = bytemuck::cast_slice(&data);
// Calculate the actual number of nonces dispatched
let dispatched_nonces = (total_threads * nonces_per_thread as u64).min(batch_size);
let dispatched_nonces = (total_threads * nonces_per_thread as u64).min(batch_size as u64);
if result_u32s[0] != 0 {
// Solution found!
@@ -603,269 +801,22 @@ fn get_vendor_specific_dispatch(adapter_info: &wgpu::AdapterInfo, device: &wgpu:
let limits = device.limits();
let max_workgroups = limits.max_compute_workgroups_per_dimension.min(65535);
// Parse vendor from adapter info
let vendor_name = adapter_info.name.to_lowercase();
let _device_name = adapter_info.device.to_string().to_lowercase();
let is_metal = adapter_info.backend == wgpu::Backend::Metal;
let tier = gpu_tiers::detect_gpu_tier(&adapter_info.name, adapter_info.vendor, is_metal);
// Vendor-specific heuristics based on architecture knowledge
// Returns (workgroups, tier_name, is_fallback)
let (optimal_workgroups, tier, is_fallback) =
if vendor_name.contains("nvidia") || adapter_info.vendor == 4318 {
// NVIDIA GPUs (vendor ID 0x10DE = 4318)
if vendor_name.contains("5090") || vendor_name.contains("5080") {
(
(max_workgroups / 6).max(5120),
"NVIDIA RTX 50 Flagship (Blackwell)",
false,
)
} else if vendor_name.contains("5070")
|| vendor_name.contains("5060")
|| vendor_name.contains("rtx 50")
{
(
(max_workgroups / 7).max(4608),
"NVIDIA RTX 50 (Blackwell)",
false,
)
} else if vendor_name.contains("4090") || vendor_name.contains("4080") {
(
(max_workgroups / 8).max(4096),
"NVIDIA RTX 40 Flagship (Ada)",
false,
)
} else if vendor_name.contains("rtx 40")
|| vendor_name.contains("4070")
|| vendor_name.contains("4060")
{
(
(max_workgroups / 10).max(3072),
"NVIDIA RTX 40 (Ada)",
false,
)
} else if vendor_name.contains("rtx 30")
|| vendor_name.contains("rtx 20")
|| vendor_name.contains("3090")
|| vendor_name.contains("3080")
|| vendor_name.contains("3070")
|| vendor_name.contains("2080")
|| vendor_name.contains("2070")
|| vendor_name.contains("2060")
{
(
(max_workgroups / 12).max(2048),
"NVIDIA RTX 30/20 (Ampere/Turing)",
false,
)
} else if vendor_name.contains("gtx 16")
|| vendor_name.contains("gtx 10")
|| vendor_name.contains("1660")
|| vendor_name.contains("1650")
|| vendor_name.contains("1080")
|| vendor_name.contains("1070")
|| vendor_name.contains("1060")
{
(
(max_workgroups / 16).max(1024),
"NVIDIA GTX 16/10 (Turing/Pascal)",
false,
)
} else if vendor_name.contains("gtx") {
((max_workgroups / 18).max(768), "NVIDIA GTX (Legacy)", false)
} else if vendor_name.contains("quadro")
|| vendor_name.contains("rtx a")
|| vendor_name.contains("tesla")
{
(
(max_workgroups / 10).max(2560),
"NVIDIA Quadro/Professional",
false,
)
} else {
((max_workgroups / 20).max(512), "NVIDIA Unknown", true)
}
} else if vendor_name.contains("amd")
|| vendor_name.contains("radeon")
|| adapter_info.vendor == 4098
{
// AMD GPUs (vendor ID 0x1002 = 4098)
if vendor_name.contains("rx 9")
|| vendor_name.contains("9070")
|| vendor_name.contains("9080")
{
(
(max_workgroups / 8).max(4096),
"AMD RX 9000 (RDNA 4)",
false,
)
} else if vendor_name.contains("7900") {
(
(max_workgroups / 9).max(3584),
"AMD RX 7900 (RDNA 3 Flagship)",
false,
)
} else if vendor_name.contains("rx 7")
|| vendor_name.contains("7800")
|| vendor_name.contains("7700")
|| vendor_name.contains("7600")
{
(
(max_workgroups / 10).max(3072),
"AMD RX 7000 (RDNA 3)",
false,
)
} else if vendor_name.contains("6900") || vendor_name.contains("6800") {
(
(max_workgroups / 12).max(2560),
"AMD RX 6900/6800 (RDNA 2 Flagship)",
false,
)
} else if vendor_name.contains("rx 6")
|| vendor_name.contains("6700")
|| vendor_name.contains("6600")
{
(
(max_workgroups / 14).max(2048),
"AMD RX 6000 (RDNA 2)",
false,
)
} else if vendor_name.contains("5700") {
(
(max_workgroups / 16).max(1536),
"AMD RX 5700 (RDNA 1)",
false,
)
} else if vendor_name.contains("rx 5")
|| vendor_name.contains("5600")
|| vendor_name.contains("5500")
{
(
(max_workgroups / 18).max(1024),
"AMD RX 5000 (RDNA 1)",
false,
)
} else if vendor_name.contains("rx 4")
|| vendor_name.contains("580")
|| vendor_name.contains("570")
{
(
(max_workgroups / 20).max(768),
"AMD RX 500/400 (Polaris)",
false,
)
} else if vendor_name.contains("radeon pro")
|| vendor_name.contains("instinct")
|| vendor_name.contains("mi")
{
(
(max_workgroups / 10).max(2560),
"AMD Radeon Pro/Instinct",
false,
)
} else {
((max_workgroups / 24).max(512), "AMD Unknown", true)
}
} else if vendor_name.contains("intel") || adapter_info.vendor == 32902 {
// Intel GPUs (vendor ID 0x8086 = 32902)
if vendor_name.contains("arc b")
|| vendor_name.contains("b580")
|| vendor_name.contains("b570")
{
(
(max_workgroups / 10).max(2560),
"Intel Arc B-Series (Battlemage)",
false,
)
} else if vendor_name.contains("a770") || vendor_name.contains("a750") {
(
(max_workgroups / 12).max(2048),
"Intel Arc A7 (Alchemist)",
false,
)
} else if vendor_name.contains("a580")
|| vendor_name.contains("a380")
|| vendor_name.contains("arc a5")
|| vendor_name.contains("arc a3")
{
(
(max_workgroups / 16).max(1024),
"Intel Arc A5/A3 (Alchemist)",
false,
)
} else if vendor_name.contains("a310") {
((max_workgroups / 20).max(512), "Intel Arc A3 Entry", false)
} else if vendor_name.contains("iris xe") || vendor_name.contains("iris plus") {
(
(max_workgroups / 24).max(384),
"Intel Iris Xe/Plus (Integrated)",
false,
)
} else if vendor_name.contains("uhd") || vendor_name.contains("hd graphics") {
(
(max_workgroups / 28).max(256),
"Intel UHD/HD Graphics (Integrated)",
false,
)
} else {
((max_workgroups / 24).max(256), "Intel Unknown", true)
}
} else if adapter_info.backend == wgpu::Backend::Metal {
// Apple GPUs (detected by Metal backend)
let (gpu_cores, workgroups, tier) = if vendor_name.contains("m4 ultra") {
(80, 1600, "Apple M4 Ultra")
} else if vendor_name.contains("m4 max") {
(40, 800, "Apple M4 Max")
} else if vendor_name.contains("m4 pro") {
(20, 400, "Apple M4 Pro")
} else if vendor_name.contains("m4") {
(10, 200, "Apple M4")
} else if vendor_name.contains("m3 ultra") {
(76, 1520, "Apple M3 Ultra")
} else if vendor_name.contains("m3 max") {
(40, 800, "Apple M3 Max")
} else if vendor_name.contains("m3 pro") {
(18, 360, "Apple M3 Pro")
} else if vendor_name.contains("m3") {
(10, 200, "Apple M3")
} else if vendor_name.contains("m2 ultra") {
(76, 1520, "Apple M2 Ultra")
} else if vendor_name.contains("m2 max") {
(38, 760, "Apple M2 Max")
} else if vendor_name.contains("m2 pro") {
(19, 380, "Apple M2 Pro")
} else if vendor_name.contains("m2") {
(10, 200, "Apple M2")
} else if vendor_name.contains("m1 ultra") {
(64, 1280, "Apple M1 Ultra")
} else if vendor_name.contains("m1 max") {
(32, 640, "Apple M1 Max")
} else if vendor_name.contains("m1 pro") {
(16, 320, "Apple M1 Pro")
} else if vendor_name.contains("m1") {
(8, 160, "Apple M1")
} else {
(8, 160, "Apple Silicon Unknown")
};
let clamped_workgroups = workgroups.min(max_workgroups / 4).max(64);
let _ = gpu_cores; // gpu_cores currently unused but kept for potential future tuning
let is_fallback = tier == "Apple Silicon Unknown";
(clamped_workgroups, tier, is_fallback)
} else {
// Unknown/Generic GPU - use conservative defaults
((max_workgroups / 16).max(512), "Unknown GPU", true)
};
let optimal_workgroups = (max_workgroups / tier.workgroup_divisor).max(tier.min_workgroups);
// Log GPU detection result
log::info!(
target: "gpu_engine",
"GPU detected: {} | tier: {} | workgroups: {} (max: {})",
adapter_info.name,
tier,
tier.name,
optimal_workgroups,
max_workgroups
);
if is_fallback {
if tier.is_fallback {
log::warn!(
target: "gpu_engine",
"GPU not recognized, using fallback config. Please report: name='{}', vendor=0x{:04X}, device={}",
@@ -878,3 +829,103 @@ fn get_vendor_specific_dispatch(adapter_info: &wgpu::AdapterInfo, device: &wgpu:
optimal_workgroups
}
#[cfg(test)]
mod adapter_selection_tests {
use super::*;
fn info(
name: &str,
device_type: wgpu::DeviceType,
backend: wgpu::Backend,
) -> wgpu::AdapterInfo {
wgpu::AdapterInfo {
name: name.into(),
vendor: 0,
device: 0,
device_type,
driver: String::new(),
driver_info: String::new(),
backend,
}
}
#[test]
fn windows_multi_backend_keeps_one_context_per_physical_gpu() {
// The exact enumeration from issue #61
let infos = [
info(
"AMD Radeon(TM) Graphics",
wgpu::DeviceType::IntegratedGpu,
wgpu::Backend::Vulkan,
),
info(
"NVIDIA GeForce RTX 3070",
wgpu::DeviceType::DiscreteGpu,
wgpu::Backend::Vulkan,
),
info(
"AMD Radeon(TM) Graphics",
wgpu::DeviceType::IntegratedGpu,
wgpu::Backend::Dx12,
),
info(
"NVIDIA GeForce RTX 3070",
wgpu::DeviceType::DiscreteGpu,
wgpu::Backend::Dx12,
),
info(
"Microsoft Basic Render Driver",
wgpu::DeviceType::Cpu,
wgpu::Backend::Dx12,
),
];
assert_eq!(select_adapters(&infos), vec![1, 0]);
}
#[test]
fn identical_multi_gpu_rig_keeps_every_card() {
let infos = [
info(
"RTX 3090",
wgpu::DeviceType::DiscreteGpu,
wgpu::Backend::Vulkan,
),
info(
"RTX 3090",
wgpu::DeviceType::DiscreteGpu,
wgpu::Backend::Vulkan,
),
info(
"RTX 3090",
wgpu::DeviceType::DiscreteGpu,
wgpu::Backend::Vulkan,
),
];
assert_eq!(select_adapters(&infos), vec![0, 1, 2]);
}
#[test]
fn dx12_only_machine_keeps_all_dx12_adapters() {
let infos = [
info("iGPU", wgpu::DeviceType::IntegratedGpu, wgpu::Backend::Dx12),
info("dGPU", wgpu::DeviceType::DiscreteGpu, wgpu::Backend::Dx12),
];
assert_eq!(select_adapters(&infos), vec![1, 0]);
}
#[test]
fn software_only_environment_selects_nothing() {
let infos = [info(
"llvmpipe",
wgpu::DeviceType::Cpu,
wgpu::Backend::Vulkan,
)];
assert!(select_adapters(&infos).is_empty());
}
#[test]
fn empty_enumeration_selects_nothing() {
assert!(select_adapters(&[]).is_empty());
}
}
+3 -4
View File
@@ -1,10 +1,9 @@
use futures::executor::block_on;
mod end_to_end_tests;
mod tests;
fn main() {
block_on(run()).unwrap();
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
run().await
}
async fn run() -> Result<(), Box<dyn std::error::Error>> {
+43 -44
View File
@@ -1,8 +1,7 @@
use p3_field::PrimeField64 as P3PrimeField64;
use p3_goldilocks::Goldilocks as P3Goldilocks;
use plonky2::hash::poseidon2::P2Permuter;
use qp_plonky2_field::goldilocks_field::GoldilocksField;
use qp_plonky2_field::types::{Field, Field64, PrimeField64};
use qp_poseidon_core::Goldilocks as QpGoldilocks;
use rand::{Rng, SeedableRng};
use wgpu::util::DeviceExt;
@@ -204,8 +203,8 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random small values (both operands < 2^32)
for _ in 0..50 {
let a = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let a = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
vectors.push(GfMulTestCase {
a,
b,
@@ -215,8 +214,8 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random mixed cases (one small, one large)
for _ in 0..80 {
let a = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>());
let a = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>());
vectors.push(GfMulTestCase {
a,
b,
@@ -226,8 +225,8 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random large values (both operands >= 2^32)
for _ in 0..100 {
let a = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let a = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
vectors.push(GfMulTestCase {
a,
b,
@@ -237,9 +236,9 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Random values near the field modulus
for _ in 0..30 {
let offset = rng.gen::<u32>() as u64;
let offset = rng.random::<u32>() as u64;
let a = GoldilocksField::from_canonical_u64(GoldilocksField::ORDER - offset);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>());
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>());
vectors.push(GfMulTestCase {
a,
b,
@@ -250,7 +249,7 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Add more specific high-limb test cases to detect patterns
for i in 20..40 {
let val1 = 1u64 << i; // Powers of 2 from 2^20 to 2^39
let val2 = rng.gen::<u32>() as u64 | (1u64 << 32); // Random value with high bit set
let val2 = rng.random::<u32>() as u64 | (1u64 << 32); // Random value with high bit set
let a = GoldilocksField::from_canonical_u64(val1);
let b = GoldilocksField::from_canonical_u64(val2);
vectors.push(GfMulTestCase {
@@ -262,9 +261,9 @@ fn generate_gf_mul_test_vectors() -> Vec<GfMulTestCase> {
// Test values with specific limb patterns
for _ in 0..20 {
let high_val = (rng.gen::<u32>() as u64) << 32;
let high_val = (rng.random::<u32>() as u64) << 32;
let a = GoldilocksField::from_canonical_u64(high_val);
let b = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let b = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
vectors.push(GfMulTestCase {
a,
b,
@@ -331,21 +330,21 @@ fn generate_sbox_test_vectors() -> Vec<SboxTestCase> {
// Random small values (< 2^32)
for _ in 0..20 {
let input = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
let input = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
let expected = input.exp_u64(7);
vectors.push(SboxTestCase { input, expected });
}
// Random large values (>= 2^32)
for _ in 0..30 {
let input = GoldilocksField::from_canonical_u64(rng.gen::<u64>() | 0x100000000u64);
let input = GoldilocksField::from_canonical_u64(rng.random::<u64>() | 0x100000000u64);
let expected = input.exp_u64(7);
vectors.push(SboxTestCase { input, expected });
}
// Values near the field modulus
for _ in 0..10 {
let offset = rng.gen::<u32>() as u64;
let offset = rng.random::<u32>() as u64;
let input = GoldilocksField::from_canonical_u64(GoldilocksField::ORDER - offset);
let expected = input.exp_u64(7);
vectors.push(SboxTestCase { input, expected });
@@ -387,7 +386,7 @@ fn generate_external_linear_layer_test_vectors() -> Vec<ExternalLinearLayerTestC
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u16>() as u64;
let val = rng.random::<u16>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = apply_full_external_linear_layer_to_state(&input);
@@ -398,7 +397,7 @@ fn generate_external_linear_layer_test_vectors() -> Vec<ExternalLinearLayerTestC
for _ in 0..10 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u32>() as u64;
let val = rng.random::<u32>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = apply_full_external_linear_layer_to_state(&input);
@@ -432,7 +431,7 @@ fn generate_internal_linear_layer_test_vectors() -> Vec<InternalLinearLayerTestC
for _ in 0..10 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen_range(0..100));
*item = GoldilocksField::from_canonical_u64(rng.random_range(0..100));
}
let expected = apply_internal_linear_layer_to_state(&input);
vectors.push(InternalLinearLayerTestCase { input, expected });
@@ -442,7 +441,7 @@ fn generate_internal_linear_layer_test_vectors() -> Vec<InternalLinearLayerTestC
for _ in 0..20 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
*item = GoldilocksField::from_noncanonical_u64(rng.gen());
*item = GoldilocksField::from_noncanonical_u64(rng.random());
}
let expected = apply_internal_linear_layer_to_state(&input);
vectors.push(InternalLinearLayerTestCase { input, expected });
@@ -487,7 +486,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..50 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen::<u16>() as u64);
*item = GoldilocksField::from_canonical_u64(rng.random::<u16>() as u64);
}
let expected = apply_external_linear_layer_to_chunk(&input);
vectors.push(MdsTestCase { input, expected });
@@ -497,7 +496,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..30 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen::<u32>() as u64);
*item = GoldilocksField::from_canonical_u64(rng.random::<u32>() as u64);
}
let expected = apply_external_linear_layer_to_chunk(&input);
vectors.push(MdsTestCase { input, expected });
@@ -507,7 +506,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..30 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
*item = GoldilocksField::from_canonical_u64(rng.gen::<u64>());
*item = GoldilocksField::from_canonical_u64(rng.random::<u64>());
}
let expected = apply_external_linear_layer_to_chunk(&input);
vectors.push(MdsTestCase { input, expected });
@@ -517,7 +516,7 @@ fn generate_mds_test_vectors() -> Vec<MdsTestCase> {
for _ in 0..10 {
let mut input = [GoldilocksField::ZERO; 4];
for item in &mut input {
let offset = rng.gen::<u32>() as u64;
let offset = rng.random::<u32>() as u64;
*item = GoldilocksField::from_canonical_u64(GoldilocksField::ORDER - offset);
}
let expected = apply_external_linear_layer_to_chunk(&input);
@@ -710,7 +709,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u16>() as u64;
let val = rng.random::<u16>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -721,7 +720,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..20 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u16>() as u64;
let val = rng.random::<u16>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -732,7 +731,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen::<u32>() as u64;
let val = rng.random::<u32>() as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -744,7 +743,7 @@ fn generate_poseidon2_test_vectors() -> Vec<Poseidon2TestCase> {
for _ in 0..15 {
let mut input = [GoldilocksField::ZERO; 12];
for item in &mut input {
let val = rng.gen_range(65536..u32::MAX) as u64;
let val = rng.random_range(65536..u32::MAX) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let expected = <GoldilocksField as P2Permuter>::permute(input);
@@ -802,10 +801,10 @@ fn generate_field_to_bytes_test_vectors() -> Vec<FieldToBytesTestCase> {
// Test 4: Random values
for _ in 0..10 {
let input = [
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.gen::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
GoldilocksField::from_canonical_u64(rng.random::<u64>() % GoldilocksField::ORDER),
];
let mut expected = [0u8; 32];
for (i, &felt) in input.iter().enumerate() {
@@ -888,8 +887,8 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
let mut vectors = Vec::new();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0x98765432);
// Helper to convert P3 Goldilocks to P2 (Plonky2) GoldilocksField
fn p3_to_p2(felts: Vec<P3Goldilocks>) -> Vec<GoldilocksField> {
// Helper to convert qp_poseidon_core::Goldilocks to Plonky2 GoldilocksField
fn qp_to_p2(felts: Vec<QpGoldilocks>) -> Vec<GoldilocksField> {
felts
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.as_canonical_u64()))
@@ -898,7 +897,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
// Test 1: All zeros
let zero_input = [0u8; 96];
let zero_expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&zero_input));
let zero_expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&zero_input));
vectors.push(BytesToFieldTestCase {
input_bytes: zero_input,
expected_felts: zero_expected,
@@ -906,7 +905,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
// Test 2: All 0xFF
let ff_input = [0xFFu8; 96];
let ff_expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&ff_input));
let ff_expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&ff_input));
vectors.push(BytesToFieldTestCase {
input_bytes: ff_input,
expected_felts: ff_expected,
@@ -917,7 +916,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
for (i, byte) in seq_input.iter_mut().enumerate() {
*byte = (i % 256) as u8;
}
let seq_expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&seq_input));
let seq_expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&seq_input));
vectors.push(BytesToFieldTestCase {
input_bytes: seq_input,
expected_felts: seq_expected,
@@ -927,7 +926,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
for _ in 0..10 {
let mut input = [0u8; 96];
rng.fill(&mut input[..]);
let expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&input));
let expected = qp_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&input));
vectors.push(BytesToFieldTestCase {
input_bytes: input,
expected_felts: expected,
@@ -1135,7 +1134,7 @@ fn generate_true_internal_only_test_vectors() -> Vec<InternalRoundsTestCase> {
for _ in 0..15 {
let mut random_input = [GoldilocksField::ZERO; 12];
for item in &mut random_input {
let val = (rng.gen::<u8>()) as u64;
let val = (rng.random::<u8>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let after_initial_external = apply_initial_external(random_input);
@@ -1150,7 +1149,7 @@ fn generate_true_internal_only_test_vectors() -> Vec<InternalRoundsTestCase> {
for _ in 0..10 {
let mut random_input = [GoldilocksField::ZERO; 12];
for item in &mut random_input {
let val = (rng.gen::<u32>()) as u64;
let val = (rng.random::<u32>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
let after_initial_external = apply_initial_external(random_input);
@@ -1461,7 +1460,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..15 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u32>()) as u64;
let val = (rng.random::<u32>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
@@ -1471,7 +1470,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..10 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u16>()) as u64;
let val = (rng.random::<u16>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
@@ -1720,7 +1719,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..15 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u8>()) as u64;
let val = (rng.random::<u8>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
@@ -1730,7 +1729,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
for _ in 0..10 {
let mut state = [GoldilocksField::ZERO; 12];
for item in &mut state {
let val = (rng.gen::<u16>()) as u64;
let val = (rng.random::<u16>()) as u64;
*item = GoldilocksField::from_canonical_u64(val);
}
test_cases.push(state);
-134
View File
@@ -1,134 +0,0 @@
use std::env;
use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
fn main() {
// Always re-run if version env or git state might change
println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION");
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
println!("cargo:rerun-if-changed=.git/packed-refs");
let pkg_version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.0.0".to_string());
let short_sha = match env::var("GITHUB_SHA") {
Ok(s) => s.get(0..8).map(|t| t.to_string()),
Err(_) => discover_short_git_sha().ok(),
};
let version = match short_sha {
Some(sha) if !sha.is_empty() => format!("{pkg_version}+{sha}"),
_ => pkg_version.clone(),
};
// Expose to the crate as an env! binding
println!("cargo:rustc-env=MINER_VERSION={version}");
// Optional extra info for diagnostics
if let Ok(sha) = env::var("GITHUB_SHA") {
println!("cargo:rustc-env=MINER_BUILD_SHA={}", sha);
}
// Log the computed version for debugging
println!("miner-cli: computed MINER_VERSION={version}");
}
/// Attempts to determine the short git SHA (first 8 hex chars) without requiring the `git` binary.
/// Handles both normal repositories and worktrees where `.git` is a file pointing to the real gitdir.
fn discover_short_git_sha() -> io::Result<String> {
let git_dir = find_git_dir()?;
let head_path = git_dir.join("HEAD");
let head = fs::read_to_string(&head_path)?.trim().to_string();
// HEAD can be either a direct SHA or a ref: refs/heads/branch
if let Some(ref_line) = head.strip_prefix("ref: ") {
let ref_path = git_dir.join(ref_line.trim());
if ref_path.is_file() {
let mut contents = String::new();
fs::File::open(ref_path)?.read_to_string(&mut contents)?;
let sha = contents.trim();
return Ok(shorten_sha(sha));
}
// Not a loose ref; try packed-refs
let packed = git_dir.join("packed-refs");
if packed.is_file() {
let contents = fs::read_to_string(packed)?;
for line in contents.lines() {
// Skip comments and peeled lines
if line.starts_with('#') || line.starts_with('^') || line.trim().is_empty() {
continue;
}
// Format: <sha> <ref>
if let Some((sha, r)) = line.split_once(' ') {
if r.trim() == ref_line.trim() {
return Ok(shorten_sha(sha.trim()));
}
}
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
format!(
"Could not resolve ref '{}' in {:?}",
ref_line.trim(),
git_dir
),
))
} else {
// HEAD contained a SHA directly (e.g., detached HEAD)
Ok(shorten_sha(head.trim()))
}
}
/// Finds the actual .git directory, handling both:
/// - Standard repo: a ".git" directory exists, or
/// - Worktree/submodule: ".git" is a file containing "gitdir: <path>"
fn find_git_dir() -> io::Result<PathBuf> {
let mut dir = env::current_dir()?;
loop {
let dot_git = dir.join(".git");
if dot_git.is_dir() {
return Ok(dot_git);
} else if dot_git.is_file() {
// Read the pointer file
let contents = fs::read_to_string(&dot_git)?;
if let Some(p) = contents.strip_prefix("gitdir:") {
let p = p.trim();
let path = Path::new(p);
// If relative, it's relative to the location of the .git file's parent
let resolved = if path.is_absolute() {
path.to_path_buf()
} else {
dir.join(path)
};
if resolved.is_dir() {
return Ok(resolved);
} else {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("Resolved gitdir does not exist: {}", resolved.display()),
));
}
}
}
// Ascend until root
if !dir.pop() {
break;
}
}
Err(io::Error::new(
io::ErrorKind::NotFound,
"Could not locate .git directory",
))
}
fn shorten_sha(full: &str) -> String {
let s = full.trim();
if s.len() >= 8 {
s[..8].to_string()
} else {
s.to_string()
}
}
+21 -10
View File
@@ -9,7 +9,7 @@ use std::thread;
use std::time::{Duration, Instant};
// CLI defaults
const DEFAULT_GPU_BATCH_SIZE: u64 = 1_000_000;
const DEFAULT_GPU_BATCH_SIZE: u32 = 1_000_000;
const DEFAULT_CPU_BATCH_SIZE: u64 = 10_000;
#[derive(Subcommand, Debug)]
@@ -30,7 +30,7 @@ enum Command {
/// 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)]
gpu_batch_size: u64,
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)]
@@ -44,6 +44,14 @@ enum Command {
)]
metrics_port: u16,
/// GPU throttle delay in milliseconds between batches (0 = no throttle)
#[arg(
long = "gpu-throttle-ms",
env = "MINER_GPU_THROTTLE_MS",
default_value_t = 0
)]
gpu_throttle_ms: u64,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
@@ -61,7 +69,7 @@ enum Command {
/// 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)]
gpu_batch_size: u64,
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)]
@@ -79,7 +87,7 @@ enum Command {
/// Quantus External Miner CLI
#[derive(Parser, Debug)]
#[command(author, version = option_env!("MINER_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")), about, long_about = None)]
#[command(author, version, about, long_about = None)]
struct Args {
#[command(subcommand)]
command: Option<Command>,
@@ -102,6 +110,7 @@ async fn main() {
gpu_devices,
gpu_batch_size,
cpu_batch_size,
gpu_throttle_ms,
metrics_port,
verbose,
} => {
@@ -125,6 +134,7 @@ async fn main() {
gpu_devices,
gpu_batch_size,
cpu_batch_size,
gpu_throttle_ms,
};
if let Err(e) = run(config).await {
@@ -156,10 +166,11 @@ async fn main() {
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"
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug,wgpu=warn,wgpu_core=warn,wgpu_hal=warn,naga=warn"
} else {
"info,miner=info,gpu_engine=info"
"info,miner=info,gpu_engine=info,wgpu=error,wgpu_core=error,wgpu_hal=error,naga=error"
};
std::env::set_var("RUST_LOG", log_level);
}
@@ -169,15 +180,15 @@ fn init_logger(verbose: bool) {
async fn run_benchmark(
cpu_workers: Option<usize>,
gpu_devices: Option<usize>,
gpu_batch_size: u64,
gpu_batch_size: u32,
cpu_batch_size: u64,
duration: u64,
) {
let effective_cpu_workers = cpu_workers.unwrap_or_else(num_cpus::get);
// Initialize GPU engine
// Initialize GPU engine (no throttle for benchmark)
let (gpu_engine, effective_gpu_devices) =
match miner_service::resolve_gpu_configuration(gpu_devices, gpu_batch_size) {
match miner_service::resolve_gpu_configuration(gpu_devices, gpu_batch_size, 0) {
Ok((engine, count)) => (engine, count),
Err(e) => {
eprintln!("❌ ERROR: {}", e);
@@ -215,7 +226,7 @@ async fn run_benchmark(
// Random header hash for benchmark
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
rand::rng().fill_bytes(&mut header);
let difficulty = U512::MAX; // High difficulty - no solutions expected
let ref_engine = cpu_engine.as_ref().or(gpu_engine.as_ref()).unwrap();
+41 -16
View File
@@ -10,7 +10,7 @@
pub mod quic;
use crossbeam_channel::{bounded, unbounded, Receiver, Sender};
use crossbeam_channel::{bounded, Receiver, Sender};
use engine_cpu::{EngineCandidate, EngineRange, JobIdCancelCheck, MinerEngine};
use pow_core::{format_hashrate, format_u512};
use primitive_types::U512;
@@ -27,10 +27,12 @@ pub struct ServiceConfig {
pub cpu_workers: Option<usize>,
/// Number of GPU devices to use for mining (None = auto-detect)
pub gpu_devices: Option<usize>,
/// GPU batch size in nonces
pub gpu_batch_size: u64,
/// GPU batch size in nonces (u32 since GPU dispatch protocol uses 32-bit counts)
pub gpu_batch_size: u32,
/// CPU batch size in hashes
pub cpu_batch_size: u64,
/// GPU throttle delay in milliseconds between batches (0 = no throttle)
pub gpu_throttle_ms: u64,
}
/// Engine type for tracking metrics per compute type.
@@ -129,7 +131,11 @@ impl WorkerPool {
if cpu_workers > 0 {
if let Some(ref engine) = cpu_engine {
for _ in 0..cpu_workers {
let (job_tx, job_rx) = unbounded::<MiningJob>();
// Use bounded channel to prevent unbounded queue growth.
// Capacity of 16 allows sender to queue jobs without realistic
// risk of drops during normal operation. Worker drains to get
// the latest job.
let (job_tx, job_rx) = bounded::<MiningJob>(16);
job_senders.push(job_tx);
let eng = engine.clone();
@@ -150,7 +156,11 @@ impl WorkerPool {
if gpu_devices > 0 {
if let Some(ref engine) = gpu_engine {
for _ in 0..gpu_devices {
let (job_tx, job_rx) = unbounded::<MiningJob>();
// Use bounded channel to prevent unbounded queue growth.
// Capacity of 16 allows sender to queue jobs without realistic
// risk of drops during normal operation. Worker drains to get
// the latest job.
let (job_tx, job_rx) = bounded::<MiningJob>(16);
job_senders.push(job_tx);
let eng = engine.clone();
@@ -194,10 +204,15 @@ impl WorkerPool {
job_id: new_job_id,
};
// Dispatch job to all workers (unbounded channels - always succeeds unless worker died)
for tx in &self.job_senders {
// Send will only fail if receiver is dropped (worker thread died)
let _ = tx.send(job.clone());
// Dispatch job to all workers using bounded channels (capacity 16).
// Workers drain to get the latest job, so we just need room to queue.
for (i, tx) in self.job_senders.iter().enumerate() {
if let Err(e) = tx.try_send(job.clone()) {
log::error!(
"Failed to send job {new_job_id} to worker {i}: {e}. \
Channel full - worker may be stuck or jobs arriving too fast."
);
}
}
let worker_count = self.job_senders.len();
@@ -342,7 +357,7 @@ fn worker_loop(
log_worker_completion(
type_str,
thread_id,
"cancelled (stale)",
"interrupted by new block",
hash_count,
search_elapsed,
);
@@ -365,7 +380,7 @@ fn worker_loop(
..
} => {
log::info!(
"🎉 {type_str} worker {thread_id} found solution! Nonce: {}, Hash: {} (job {job_id})",
"🎯 {type_str} worker {thread_id} found solution! Nonce: {}, Hash: {} (job {job_id})",
format_u512(nonce),
format_u512(hash),
);
@@ -382,7 +397,7 @@ fn worker_loop(
(None, hash_count)
}
engine_cpu::EngineStatus::Cancelled { hash_count } => {
log_worker_completion(type_str, thread_id, "cancelled", hash_count, search_elapsed);
log_worker_completion(type_str, thread_id, "new block", hash_count, search_elapsed);
(None, hash_count)
}
engine_cpu::EngineStatus::Running { .. } => {
@@ -413,7 +428,8 @@ fn worker_loop(
/// Resolve GPU configuration and initialize the engine.
pub fn resolve_gpu_configuration(
requested_devices: Option<usize>,
batch_size: u64,
batch_size: u32,
throttle_ms: u64,
) -> anyhow::Result<(Option<Arc<dyn MinerEngine>>, usize)> {
// Explicit 0 means no GPU
if requested_devices == Some(0) {
@@ -421,7 +437,7 @@ pub fn resolve_gpu_configuration(
}
// Try to initialize GPU engine
let engine = engine_gpu::GpuEngine::try_new(batch_size);
let engine = engine_gpu::GpuEngine::try_new(batch_size, throttle_ms);
let engine = match engine {
Ok(e) => e,
Err(e) => {
@@ -462,8 +478,11 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
let effective_cpus = num_cpus::get().max(1);
// Resolve GPU configuration
let (gpu_engine, gpu_devices) =
resolve_gpu_configuration(config.gpu_devices, config.gpu_batch_size)?;
let (gpu_engine, gpu_devices) = resolve_gpu_configuration(
config.gpu_devices,
config.gpu_batch_size,
config.gpu_throttle_ms,
)?;
// Resolve CPU workers
let cpu_workers = config.cpu_workers.unwrap_or_else(|| {
@@ -504,6 +523,12 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
if let Some(ref engine) = gpu_engine {
let name = engine.name();
log::info!("🎮 GPU engine: {name}");
if config.gpu_throttle_ms > 0 {
log::info!(
"⏳ GPU throttle: {}ms between batches",
config.gpu_throttle_ms
);
}
}
let total_workers = cpu_workers + gpu_devices;
+19 -2
View File
@@ -253,10 +253,10 @@ async fn handle_connection(
};
// Parse difficulty
let difficulty = match U512::from_dec_str(&request.distance_threshold) {
let difficulty = match U512::from_dec_str(&request.difficulty) {
Ok(d) => d,
Err(_) => {
log::warn!("Invalid difficulty in request");
log::warn!("Invalid difficulty in request: parse error");
let result = MiningResult {
status: ApiResponseStatus::Failed,
job_id: request.job_id,
@@ -272,6 +272,23 @@ async fn handle_connection(
}
};
// Reject zero difficulty to prevent division-by-zero panic in JobContext::new
if difficulty.is_zero() {
log::warn!("Invalid difficulty in request: zero is not allowed");
let result = MiningResult {
status: ApiResponseStatus::Failed,
job_id: request.job_id,
nonce: None,
work: None,
hash_count: 0,
elapsed_time: 0.0,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
continue;
}
// Reset state for new job
cpu_hashes = 0;
gpu_hashes = 0;
+12 -19
View File
@@ -106,33 +106,28 @@ impl TelemetryHandle {
}
/// Emit a fully custom payload (must include `"msg"`).
pub async fn emit_payload(&self, payload: Value, verbosity: Option<u8>) {
///
/// This is intentionally lossy: if the internal channel is full, the message is dropped
/// silently to avoid blocking mining paths or causing unbounded memory growth.
pub fn emit_payload(&self, payload: Value, verbosity: Option<u8>) {
if self.senders.is_empty() {
return;
}
let msg = EnvelopeMsg { payload, verbosity };
for tx in self.senders.iter() {
// Drop on full; do not block mining paths. This is intentionally lossy under backpressure.
if tx.try_send(msg.clone()).is_err() {
// Best-effort: if try_send failed due to full buffer, attempt bounded send with small timeout.
// Avoid blocking indefinitely.
let tx = tx.clone();
let msg = msg.clone();
tokio::spawn(async move {
let _ = tx.send(msg).await;
});
}
// Drop on full; do not block mining paths.
let _ = tx.try_send(msg.clone());
}
}
/// Emit a `system.connected` message using configuration defaults and an optional link.
pub async fn emit_system_connected(&self, link: Option<&TelemetryNodeLink>) {
pub fn emit_system_connected(&self, link: Option<&TelemetryNodeLink>) {
let payload = build_system_connected_payload(&self.config, link);
self.emit_payload(payload, Some(self.verbosity)).await;
self.emit_payload(payload, Some(self.verbosity));
}
/// Emit a `system.interval` message with lightweight health stats and an optional link hint.
pub async fn emit_system_interval(&self, interval: &SystemInterval, link_hint: Option<&str>) {
pub fn emit_system_interval(&self, interval: &SystemInterval, link_hint: Option<&str>) {
let mut payload = json!({
"msg": "system.interval",
"uptime_ms": interval.uptime_ms,
@@ -169,7 +164,7 @@ impl TelemetryHandle {
.insert("linked_node_hint".to_string(), json!(hint));
}
self.emit_payload(payload, Some(self.verbosity)).await;
self.emit_payload(payload, Some(self.verbosity));
}
}
@@ -463,9 +458,7 @@ mod tests {
let cfg = TelemetryConfig::default();
let handle = start(cfg);
assert!(!handle.is_enabled());
handle
.emit_system_interval(&SystemInterval::default(), None)
.await;
handle.emit_system_connected(None).await;
handle.emit_system_interval(&SystemInterval::default(), None);
handle.emit_system_connected(None);
}
}
+16 -1
View File
@@ -38,7 +38,12 @@ pub struct JobContext {
}
impl JobContext {
/// Build a new context from header and difficulty
/// Build a new context from header and difficulty.
///
/// # Panics
///
/// Panics if `difficulty` is zero (division by zero in target calculation).
/// Callers must validate that difficulty is non-zero before calling this function.
pub fn new(header: [u8; 32], difficulty: U512) -> Self {
// In Bitcoin-style PoW, target = max_target / difficulty
let max_target = U512::MAX;
@@ -216,4 +221,14 @@ mod tests {
// With impossible difficulty, should not find solution
assert!(result.is_none());
}
#[test]
#[should_panic(expected = "division by zero")]
fn test_zero_difficulty_panics() {
// Zero difficulty causes division by zero in target calculation.
// Callers MUST validate difficulty > 0 before calling JobContext::new.
let header = [7u8; 32];
let zero_difficulty = U512::zero();
let _ctx = JobContext::new(header, zero_difficulty);
}
}
-207
View File
@@ -1,207 +0,0 @@
use primitive_types::U512;
use quantus_miner::*;
use resonance_miner_api::*;
use warp::test::request;
use warp::Filter; // Import shared API types
#[tokio::test]
async fn test_mine_endpoint() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
let mine_route = warp::post()
.and(warp::path("mine"))
.and(warp::body::json())
.and(state_filter.clone())
.and_then(handle_mine_request);
// Test valid request
let valid_request = MiningRequest {
job_id: "test".to_string(),
mining_hash: "a".repeat(64),
distance_threshold: "1000".to_string(),
nonce_start: "0".repeat(128),
nonce_end: "1".repeat(128),
};
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_request)
.reply(&mine_route)
.await;
assert_eq!(resp.status(), 200);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Accepted);
assert_eq!(body.job_id, "test");
// Test duplicate job ID
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_request)
.reply(&mine_route)
.await;
assert_eq!(resp.status(), 409);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Error);
assert!(body.message.is_some());
assert!(body.message.unwrap().contains("Job already exists"));
// Test invalid request
let invalid_request = MiningRequest {
job_id: "".to_string(), // Empty job ID
mining_hash: "a".repeat(64),
distance_threshold: "1000".to_string(),
nonce_start: "0".repeat(128),
nonce_end: "1".repeat(128),
};
let resp = request()
.method("POST")
.path("/mine")
.json(&invalid_request)
.reply(&mine_route)
.await;
assert_eq!(resp.status(), 400);
}
#[tokio::test]
async fn test_result_endpoint() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
// First create a job
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job("test".to_string(), job).await.unwrap();
let result_route = warp::get()
.and(warp::path("result"))
.and(warp::path::param())
.and(state_filter.clone())
.and_then(handle_result_request);
// Test existing job
let resp = request()
.method("GET")
.path("/result/test")
.reply(&result_route)
.await;
assert_eq!(resp.status(), 200);
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Running);
assert_eq!(body.job_id, "test");
// Test non-existent job
let resp = request()
.method("GET")
.path("/result/nonexistent")
.reply(&result_route)
.await;
assert_eq!(resp.status(), 404);
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::NotFound);
}
#[tokio::test]
async fn test_cancel_endpoint() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
// First create a job
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job("test".to_string(), job).await.unwrap();
let cancel_route = warp::post()
.and(warp::path("cancel"))
.and(warp::path::param())
.and(state_filter.clone())
.and_then(handle_cancel_request);
// Test cancel existing job
let resp = request()
.method("POST")
.path("/cancel/test")
.reply(&cancel_route)
.await;
assert_eq!(resp.status(), 200);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Cancelled);
assert_eq!(body.job_id, "test");
// Test cancel non-existent job
let resp = request()
.method("POST")
.path("/cancel/nonexistent")
.reply(&cancel_route)
.await;
assert_eq!(resp.status(), 404);
let body: MiningResponse = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::NotFound);
}
#[tokio::test]
async fn test_concurrent_access() {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
// Create multiple jobs concurrently
let mut handles = vec![];
for i in 0..10 {
let state = state.clone();
let handle = tokio::spawn(async move {
let job = MiningJob::new([0; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job(format!("test{}", i), job).await
});
handles.push(handle);
}
// Wait for all jobs to be created
for handle in handles {
assert!(handle.await.unwrap().is_ok());
}
// Verify all jobs exist
for i in 0..10 {
assert!(state.get_job(&format!("test{}", i)).await.is_some());
}
// Test concurrent result checks
let result_route = warp::get()
.and(warp::path("result"))
.and(warp::path::param())
.and(state_filter.clone())
.and_then(handle_result_request);
let mut result_handles = vec![];
for i in 0..10 {
let route = result_route.clone();
let handle = tokio::spawn(async move {
request()
.method("GET")
.path(&format!("/result/test{}", i))
.reply(&route)
.await
});
result_handles.push(handle);
}
// Verify all result checks succeed
for handle in result_handles {
let resp = handle.await.unwrap();
assert_eq!(resp.status(), 200);
let body: MiningResult = serde_json::from_slice(resp.body()).unwrap();
assert_eq!(body.status, ApiResponseStatus::Running);
}
}
-310
View File
@@ -1,310 +0,0 @@
// Verifying that our API spec is in sync with the implementation
// Manually generated from the OpenAPI spec but it should still flag when we get out of sync and need to update.
use primitive_types::U512;
use quantus_miner::*;
use resonance_miner_api::*; // Use the shared API types
use serde_json::json;
use warp::test::request;
use warp::Filter; // For sending custom/invalid JSON
// Helper function to setup the routes with a fresh state for each test
fn setup_routes() -> (
MiningState,
impl Filter<Extract = impl warp::Reply, Error = warp::Rejection> + Clone,
) {
let state = MiningState::new();
let state_clone = state.clone();
let state_filter = warp::any().map(move || state_clone.clone());
let mine_route = warp::post()
.and(warp::path("mine"))
.and(warp::body::json::<MiningRequest>()) // Expect valid MiningRequest
.and(state_filter.clone())
.and_then(handle_mine_request);
let result_route = warp::get()
.and(warp::path("result"))
.and(warp::path::param::<String>())
.and(state_filter.clone())
.and_then(handle_result_request);
let cancel_route = warp::post()
.and(warp::path("cancel"))
.and(warp::path::param::<String>())
.and(state_filter.clone())
.and_then(handle_cancel_request);
let routes = mine_route.or(result_route).or(cancel_route);
(state, routes)
}
// --- /mine Endpoint Tests ---
#[tokio::test]
async fn spec_mine_valid_request() {
let (_state, routes) = setup_routes();
let valid_req = MiningRequest {
job_id: "job-valid-1".to_string(),
mining_hash: "a".repeat(64),
distance_threshold: "12345".to_string(),
nonce_start: "0".repeat(128),
nonce_end: "f".repeat(128),
};
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_req)
.reply(&routes)
.await;
// Spec: 200 OK, response matches MiningResponseAccepted
assert_eq!(
resp.status(),
200,
"Spec requires 200 OK for valid /mine request"
);
let body: MiningResponse = serde_json::from_slice(resp.body())
.expect("Valid response should deserialize to MiningResponse");
assert_eq!(
body.status,
ApiResponseStatus::Accepted,
"Spec requires status 'accepted'"
);
assert_eq!(body.job_id, valid_req.job_id);
assert!(
body.message.is_none(),
"Spec shows no message field for success"
);
}
#[tokio::test]
async fn spec_mine_duplicate_job_id() {
let (state, routes) = setup_routes();
let valid_req = MiningRequest {
job_id: "job-duplicate-1".to_string(),
mining_hash: "b".repeat(64),
distance_threshold: "54321".to_string(),
nonce_start: "1".repeat(128),
nonce_end: "e".repeat(128),
};
// Add the job first
let job = MiningJob::new(
hex::decode(valid_req.mining_hash.clone())
.unwrap()
.try_into()
.unwrap(),
U512::from_dec_str(&valid_req.distance_threshold).unwrap(),
U512::from_str_radix(&valid_req.nonce_start, 16).unwrap(),
U512::from_str_radix(&valid_req.nonce_end, 16).unwrap(),
);
state.add_job(valid_req.job_id.clone(), job).await.unwrap();
// Try to submit again
let resp = request()
.method("POST")
.path("/mine")
.json(&valid_req)
.reply(&routes)
.await;
// Spec: 409 Conflict, response matches MiningResponseError
assert_eq!(
resp.status(),
409,
"Spec requires 409 Conflict for duplicate job ID"
);
let body: MiningResponse = serde_json::from_slice(resp.body())
.expect("Valid error response should deserialize to MiningResponse");
assert_eq!(
body.status,
ApiResponseStatus::Error,
"Spec requires status 'error'"
);
assert!(
body.message.is_some(),
"Spec requires message field for error"
);
assert!(body.message.unwrap().contains("Job already exists"));
assert_eq!(body.job_id, valid_req.job_id);
}
// Test cases for invalid formats (hash length, nonce length, difficulty non-numeric)
// These should ideally result in 400 Bad Request
#[tokio::test]
async fn spec_mine_invalid_hash_format() {
let (_state, routes) = setup_routes();
let invalid_req = json!({
"job_id": "job-invalid-hash",
"mining_hash": "a".repeat(63), // Too short
"distance_threshold": "1000",
"nonce_start": "0".repeat(128),
"nonce_end": "f".repeat(128)
});
let resp = request()
.method("POST")
.path("/mine")
.json(&invalid_req)
.reply(&routes)
.await;
// Spec: 400 Bad Request, response matches MiningResponseError
assert_eq!(
resp.status(),
400,
"Spec requires 400 Bad Request for invalid hash format"
);
let body: MiningResponse =
serde_json::from_slice(resp.body()).expect("Error response should deserialize");
assert_eq!(body.status, ApiResponseStatus::Error);
assert!(body.message.is_some());
assert!(body
.message
.unwrap()
.contains("mining_hash must be 64 hex characters"));
}
// Add similar tests for invalid nonce_start, nonce_end, difficulty format...
#[tokio::test]
async fn spec_mine_missing_required_field() {
let (_state, routes) = setup_routes();
// Missing 'difficulty'
let invalid_req = json!({
"job_id": "job-missing-field",
"mining_hash": "c".repeat(64),
"nonce_start": "0".repeat(128),
"nonce_end": "f".repeat(128)
});
let resp = request()
.method("POST")
.path("/mine")
.json(&invalid_req)
.reply(&routes)
.await;
// Warp's default JSON body deserialization handles missing fields
// It should return 400 Bad Request based on warp::body::json()
assert_eq!(
resp.status(),
400,
"Spec requires 400 Bad Request for missing required field"
);
// The body might be a plain text error from Warp, not necessarily our JSON structure
// We can check if the body indicates a deserialization error
let body_bytes = resp.body();
let body_string = String::from_utf8_lossy(body_bytes);
assert!(
body_string.contains("Failed to deserialize") || body_string.contains("missing field"),
"Response body should indicate deserialization failure"
);
}
// --- /result/{job_id} Endpoint Tests ---
#[tokio::test]
async fn spec_result_job_running() {
let (state, routes) = setup_routes();
let job_id = "job-running-1".to_string();
// Add a job
let job = MiningJob::new([1u8; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job(job_id.clone(), job).await.unwrap();
let resp = request()
.method("GET")
.path(&format!("/result/{}", job_id))
.reply(&routes)
.await;
// Spec: 200 OK, response matches MiningResult with status 'running'
assert_eq!(resp.status(), 200);
let body: MiningResult =
serde_json::from_slice(resp.body()).expect("Should deserialize to MiningResult");
assert_eq!(body.status, ApiResponseStatus::Running);
assert_eq!(body.job_id, job_id);
assert!(body.nonce.is_none()); // Should be none initially
assert!(body.work.is_none()); // No work yet
// hash_count and elapsed_time should be present
// assert_eq!(body.hash_count, 0); // Initial state
// assert!(body.elapsed_time >= 0.0);
}
// Add tests for spec_result_job_completed, spec_result_job_failed (need to manipulate state)
// Need to carefully simulate a completed/failed state or run miner loop briefly
#[tokio::test]
async fn spec_result_job_not_found() {
let (_state, routes) = setup_routes();
let job_id = "job-not-found-1";
let resp = request()
.method("GET")
.path(&format!("/result/{}", job_id))
.reply(&routes)
.await;
// Spec: 404 Not Found, response matches MiningResultNotFound
assert_eq!(resp.status(), 404);
let body: MiningResult = serde_json::from_slice(resp.body())
.expect("Should deserialize to MiningResult (NotFound variant)");
assert_eq!(body.status, ApiResponseStatus::NotFound);
assert_eq!(body.job_id, job_id);
assert!(body.nonce.is_none());
assert!(body.work.is_none());
assert_eq!(body.hash_count, 0);
assert_eq!(body.elapsed_time, 0.0);
}
// --- /cancel/{job_id} Endpoint Tests ---
#[tokio::test]
async fn spec_cancel_existing_job() {
let (state, routes) = setup_routes();
let job_id = "job-cancel-1".to_string();
let job = MiningJob::new([2u8; 32], U512::from(1000), U512::from(0), U512::from(1000));
state.add_job(job_id.clone(), job).await.unwrap();
let resp = request()
.method("POST")
.path(&format!("/cancel/{}", job_id))
.reply(&routes)
.await;
// Spec: 200 OK, response matches MiningResponseCancelled
assert_eq!(resp.status(), 200);
let body: MiningResponse =
serde_json::from_slice(resp.body()).expect("Should deserialize to MiningResponse");
assert_eq!(body.status, ApiResponseStatus::Cancelled);
assert_eq!(body.job_id, job_id);
assert!(body.message.is_none());
// Verify job is actually cancelled
let job = state.get_job(&job_id).await.unwrap();
assert_eq!(job.status, JobStatus::Cancelled);
}
#[tokio::test]
async fn spec_cancel_non_existent_job() {
let (_state, routes) = setup_routes();
let job_id = "job-cancel-not-found-1";
let resp = request()
.method("POST")
.path(&format!("/cancel/{}", job_id))
.reply(&routes)
.await;
// Spec: 404 Not Found, response matches MiningResponseNotFound
assert_eq!(resp.status(), 404);
let body: MiningResponse = serde_json::from_slice(resp.body())
.expect("Should deserialize to MiningResponse (NotFound variant)");
assert_eq!(body.status, ApiResponseStatus::NotFound);
assert_eq!(body.job_id, job_id);
assert!(body.message.is_some());
}
// Removed placeholder test