37 Commits
Author SHA1 Message Date
Nikolaus Heger 0a07bb2de5 Merge pull request #48 from Quantus-Network/release/v2.1.2
Release v2.1.2
2026-01-08 09:16:47 +08:00
n13 35df5a3554 bump version to v2.1.2 2026-01-08 01:14:06 +00:00
Nikolaus Heger bf8721f41d Merge pull request #46 from Quantus-Network/fix_hashrate_2
hash rate fix
2026-01-08 09:13:10 +08:00
NIK 590ce82440 fmt 2026-01-08 09:08:29 +08:00
Nikolaus Heger 689e5f9bfd hash rate fix 2026-01-06 15:17:00 +08:00
Nikolaus Heger 059fa8322c Merge pull request #45 from Quantus-Network/n13/fix_hashrate
fix hash rate across all threads and devices
2026-01-06 13:30:01 +08:00
Nikolaus Heger 8295d27d25 format 2026-01-06 12:35:36 +08:00
Nikolaus Heger e817c1a5e3 fix hash rate across all threads and devices 2026-01-05 19:27:40 +08:00
Nikolaus Heger a8ecc6f03c Merge pull request #44 from Quantus-Network/release/v2.1.1
Release v2.1.1
2026-01-05 18:42:55 +08:00
n13 91170c6aea bump version to v2.1.1 2026-01-05 10:35:00 +00:00
Nikolaus Heger 4131b60ebb Merge pull request #43 from Quantus-Network/fix_gpu_cancel_issue
Fix gpu cancel issue
2026-01-05 18:33:57 +08:00
Nikolaus Heger de3ffa9fb9 Merge branch 'fix_gpu_cancel_issue' of github.com:Quantus-Network/quantus-miner into fix_gpu_cancel_issue 2026-01-05 17:47:52 +08:00
Nikolaus Heger ad46f200c0 fmt 2026-01-05 17:47:47 +08:00
Nikolaus Heger d14ebdf65c fix tests 2026-01-05 17:47:38 +08:00
Nikolaus Heger 6834dd2dff format 2026-01-05 17:41:35 +08:00
Nikolaus Heger af5564796b dynamic batch size for GPU 2026-01-05 17:15:10 +08:00
Nikolaus Heger ffc5b8f83c GPU batch size automatically adjusts to target duration 2026-01-05 15:53:14 +08:00
Nikolaus Heger 3a694ecee1 Merge pull request #41 from Quantus-Network/release/v2.1.0
Release v2.1.0
2025-12-31 18:22:26 +08:00
n13 b9e080e20c bump version to v2.1.0 2025-12-31 10:19:01 +00:00
Nikolaus Heger 3705fa96e7 Merge pull request #40 from Quantus-Network/GPU_contention_fix
Gpu contention fix
2025-12-31 12:40:31 +08:00
Nikolaus Heger c3e7d70a75 fix readme 2025-12-27 11:57:12 +08:00
Nikolaus Heger 0b0731b869 fmt 2025-12-27 11:40:11 +08:00
Nikolaus Heger 3b5f759fbd refactored and unified gpu devices number logic
use all GPUs by default, handle the parameter, disallow more GPU devices than available
2025-12-27 11:40:03 +08:00
Nikolaus Heger 06933d5745 fix ci 2025-12-26 14:06:49 +08:00
Nikolaus Heger 96853cad23 restore serve command 2025-12-26 14:03:00 +08:00
Nikolaus Heger 010eff6e71 fix readme 2025-12-26 13:59:21 +08:00
Nikolaus Heger 4e7280cb0f fux ci 2025-12-26 13:58:27 +08:00
Nikolaus Heger c2773cb80d remove feature cpu 2025-12-26 13:50:48 +08:00
Nikolaus Heger b79a9368e0 fmt 2025-12-26 13:45:18 +08:00
Nikolaus Heger f0aa971326 remove gpu feature, rename to gpu-devices
rename gpu-workers flag to devices, also check user doesn't specify more than they have (degrades performance)
2025-12-26 13:44:59 +08:00
Nikolaus Heger 23c617bda9 remove some AI dust 2025-12-26 13:10:46 +08:00
Nikolaus Heger 2a36eeb3c3 Change workflows to have only GPU build 2025-12-26 13:02:43 +08:00
Nikolaus Heger a548f74f7d fmt 2025-12-26 12:47:42 +08:00
Nikolaus Heger 97ec1350de Fix for #39
Fix GPU worker crash, enable GPU default, and improve benchmark output
2025-12-26 12:46:57 +08:00
Nikolaus Heger ac87758996 Fix GPU contention, benchmarking 2025-12-26 10:53:00 +08:00
Quantus Network CIandczareko bdcd939bc9 bump version to v2.0.2 (#38)
Co-authored-by: czareko <czareko@users.noreply.github.com>
2025-12-23 06:30:16 +08:00
Cezary Olborski a51df35ca1 fix: Release - osx version (#37)
* fix: Release - OSX Version for Intel

* fix: OSX only for Intel
2025-12-23 06:25:18 +08:00
13 changed files with 1043 additions and 604 deletions
+5 -16
View File
@@ -45,28 +45,17 @@ jobs:
name: 🛠️ Build & Test
needs: fast-checks
runs-on: ubuntu-latest
strategy:
matrix:
features: ["", "gpu"]
steps:
- uses: actions/checkout@v4
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: compile (${{ matrix.features || 'cpu-only' }})
- name: compile (gpu)
run: |
if [ -n "${{ matrix.features }}" ]; then
cargo build --locked --workspace --features ${{ matrix.features }}
else
cargo build --locked --workspace
fi
cargo build --locked --workspace
timeout-minutes: 90
- name: test (${{ matrix.features || 'cpu-only' }})
- name: test (gpu)
run: |
if [ -n "${{ matrix.features }}" ]; then
cargo test --locked --workspace --features ${{ matrix.features }}
else
cargo test --locked --workspace
fi
cargo test --locked --workspace
timeout-minutes: 15
analysis:
@@ -95,7 +84,7 @@ jobs:
- name: setup rust
uses: actions-rust-lang/setup-rust-toolchain@v1
- name: build benchmark binary
run: cargo build -p miner-cli --release --features gpu
run: cargo build -p miner-cli --release
timeout-minutes: 60
- name: run cpu benchmark
run: ./target/release/quantus-miner benchmark --cpu-workers 2 --duration 5
+1 -25
View File
@@ -71,45 +71,21 @@ jobs:
target: x86_64-unknown-linux-gnu
binary_name: quantus-miner
asset_name: quantus-miner-linux-x86_64
features: ""
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
binary_name: quantus-miner
asset_name: quantus-miner-linux-x86_64-gpu
features: "gpu"
# Windows builds
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: quantus-miner.exe
asset_name: quantus-miner-windows-x86_64.exe
features: ""
- os: windows-latest
target: x86_64-pc-windows-msvc
binary_name: quantus-miner.exe
asset_name: quantus-miner-windows-x86_64-gpu.exe
features: "gpu"
# macOS builds (Intel)
- os: macos-13
- os: macos-15-intel
target: x86_64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-x86_64
features: ""
- os: macos-13
target: x86_64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-x86_64-gpu
features: "gpu"
# macOS builds (Apple Silicon)
- os: macos-latest
target: aarch64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-aarch64
features: ""
- os: macos-latest
target: aarch64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-aarch64-gpu
features: "gpu"
steps:
- uses: actions/checkout@v4
Generated
+7 -7
View File
@@ -557,7 +557,7 @@ dependencies = [
[[package]]
name = "engine-cpu"
version = "2.0.1"
version = "2.1.2"
dependencies = [
"anyhow",
"criterion",
@@ -571,7 +571,7 @@ dependencies = [
[[package]]
name = "engine-gpu"
version = "2.0.1"
version = "2.1.2"
dependencies = [
"anyhow",
"bytemuck",
@@ -1458,7 +1458,7 @@ dependencies = [
[[package]]
name = "metrics"
version = "2.0.1"
version = "2.1.2"
dependencies = [
"anyhow",
"log",
@@ -1488,7 +1488,7 @@ dependencies = [
[[package]]
name = "miner-cli"
version = "2.0.1"
version = "2.1.2"
dependencies = [
"clap",
"engine-cpu",
@@ -1504,7 +1504,7 @@ dependencies = [
[[package]]
name = "miner-service"
version = "2.0.1"
version = "2.1.2"
dependencies = [
"anyhow",
"crossbeam-channel",
@@ -1530,7 +1530,7 @@ dependencies = [
[[package]]
name = "miner-telemetry"
version = "2.0.1"
version = "2.1.2"
dependencies = [
"anyhow",
"futures",
@@ -2076,7 +2076,7 @@ dependencies = [
[[package]]
name = "pow-core"
version = "2.0.1"
version = "2.1.2"
dependencies = [
"anyhow",
"hex",
+1 -1
View File
@@ -15,7 +15,7 @@ resolver = "2"
edition = "2021"
authors = ["Quantus Network"]
description = "Quantus External Miner Workspace"
version = "2.0.1"
version = "2.1.2"
[workspace.dependencies]
anyhow = "1"
+17 -17
View File
@@ -9,7 +9,7 @@ High-performance external mining service for Quantus Network with support for CP
cargo build -p miner-cli --release
# With GPU support (recommended)
cargo build -p miner-cli --features gpu --release
cargo build -p miner-cli --release
```
The binary will be available at `target/release/quantus-miner`.
@@ -18,16 +18,16 @@ The binary will be available at `target/release/quantus-miner`.
```bash
# CPU-only mining (default: auto-detected CPU cores)
./target/release/quantus-miner --cpu-workers 4
./target/release/quantus-miner serve --cpu-workers 4
# GPU-only mining (requires --features gpu build)
./target/release/quantus-miner --gpu-workers 1
# GPU-only mining
./target/release/quantus-miner serve --gpu-devices 1
# Hybrid CPU+GPU mining
./target/release/quantus-miner --cpu-workers 4 --gpu-workers 1
./target/release/quantus-miner serve --cpu-workers 4 --gpu-devices 1
# Custom port and metrics
./target/release/quantus-miner --cpu-workers 2 --port 8000 --metrics-port 9900
./target/release/quantus-miner serve --cpu-workers 2 --port 8000 --metrics-port 9900
```
## Configuration
@@ -35,7 +35,7 @@ The binary will be available at `target/release/quantus-miner`.
| Argument | Environment Variable | Description | Default |
|----------|---------------------|-------------|---------|
| `--cpu-workers <N>` | `MINER_CPU_WORKERS` | Number of CPU worker threads | Auto-detect |
| `--gpu-workers <N>` | `MINER_GPU_WORKERS` | Number of GPU worker threads | 0 |
| `--gpu-devices <N>` | `MINER_GPU_DEVICES` | Number of GPU devices | 0 |
| `--port <PORT>` | `MINER_PORT` | HTTP API port | 9833 |
| `--metrics-port <PORT>` | `MINER_METRICS_PORT` | Prometheus metrics port | Disabled |
@@ -51,7 +51,7 @@ GPU support uses WGPU for cross-platform acceleration:
**Build with GPU support:**
```bash
cargo build -p miner-cli --features gpu --release
cargo build -p miner-cli --release
```
**Platform requirements:**
@@ -69,21 +69,21 @@ cargo build -p miner-cli --features gpu --release
```bash
# CPU mining with 8 workers
./target/release/quantus-miner --cpu-workers 8
./target/release/quantus-miner serve --cpu-workers 8
# Pure GPU mining
./target/release/quantus-miner --gpu-workers 1
./target/release/quantus-miner serve --gpu-devices 1
# Hybrid mining: 4 CPU + 1 GPU workers
./target/release/quantus-miner --cpu-workers 4 --gpu-workers 1
./target/release/quantus-miner serve --cpu-workers 4 --gpu-devices 1
# With verbose logging
RUST_LOG=debug ./target/release/quantus-miner --cpu-workers 2 --gpu-workers 1
RUST_LOG=debug ./target/release/quantus-miner serve --cpu-workers 2 --gpu-devices 1
# Production setup with metrics
./target/release/quantus-miner \
./target/release/quantus-miner serve \
--cpu-workers 6 \
--gpu-workers 1 \
--gpu-devices 1 \
--port 9833 \
--metrics-port 9900
```
@@ -107,7 +107,7 @@ docker run -d -p 9833:9833 -p 9900:9900 \
# Build from source
docker build -t quantus-miner .
docker run -d -p 9833:9833 quantus-miner --cpu-workers 4
docker run -d -p 9833:9833 quantus-miner serve --cpu-workers 4
```
## Benchmarking
@@ -117,8 +117,8 @@ docker run -d -p 9833:9833 quantus-miner --cpu-workers 4
./target/release/quantus-miner benchmark --cpu-workers 8 --duration 30
# Benchmark GPU performance
./target/release/quantus-miner benchmark --gpu-workers 1 --duration 30
./target/release/quantus-miner benchmark --gpu-devices 1 --duration 30
# Benchmark hybrid performance
./target/release/quantus-miner benchmark --cpu-workers 4 --gpu-workers 1 --duration 30
./target/release/quantus-miner benchmark --cpu-workers 4 --gpu-devices 1 --duration 30
```
@@ -5,10 +5,11 @@ use pow_core::JobContext;
use primitive_types::U512;
use rand::RngCore;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
fn bench_cpu_vs_gpu_small(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
let cancel_flag = AtomicBool::new(false);
// Small range: 10K nonces - reasonable for benchmarking
@@ -58,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();
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
let cancel_flag = AtomicBool::new(false);
// Medium range: 100K nonces
@@ -108,7 +109,7 @@ fn bench_cpu_vs_gpu_medium(c: &mut Criterion) {
fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
let cancel_flag = AtomicBool::new(false);
// Large range: 1M nonces - where GPU should really shine
@@ -158,7 +159,7 @@ fn bench_cpu_vs_gpu_large(c: &mut Criterion) {
fn bench_solution_finding(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
let cancel_flag = AtomicBool::new(false);
// Range where we expect to find solutions quickly
@@ -208,7 +209,7 @@ fn bench_solution_finding(c: &mut Criterion) {
fn bench_throughput_per_second(c: &mut Criterion) {
let cpu_engine = FastCpuEngine::new();
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
let cancel_flag = AtomicBool::new(false);
// Fixed time benchmark - see how many hashes we can do in 1 second
@@ -257,7 +258,7 @@ fn bench_throughput_per_second(c: &mut Criterion) {
}
fn bench_gpu_batch_efficiency(c: &mut Criterion) {
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
let cancel_flag = AtomicBool::new(false);
let mut group = c.benchmark_group("gpu_batch_sizes");
+2 -1
View File
@@ -2,6 +2,7 @@ use engine_cpu::{BaselineCpuEngine, EngineStatus, MinerEngine, Range};
use engine_gpu::GpuEngine;
use primitive_types::U512;
use std::sync::atomic::AtomicBool;
use std::time::Duration;
fn main() {
// Initialize logging
@@ -25,7 +26,7 @@ fn main() {
// 3. Verify with GPU engine
log::info!("Initializing GPU engine...");
let gpu_engine = GpuEngine::new();
let gpu_engine = GpuEngine::new(Duration::from_millis(3000));
// Search a small range around the valid nonce
let gpu_range = Range {
+370 -255
View File
@@ -7,50 +7,158 @@ use pow_core::JobContext;
use primitive_types::U512;
use std::cell::RefCell;
use std::sync::{
atomic::{AtomicBool, AtomicUsize, Ordering},
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
Arc,
};
use std::time::Duration;
/// Represents a single GPU device context.
struct GpuContext {
device: wgpu::Device,
queue: wgpu::Queue,
pipeline: wgpu::ComputePipeline,
bind_group: wgpu::BindGroup,
// Cached vendor configuration
optimal_workgroups: u32,
// Reusable buffers
// Dynamic batch size tuning
batch_size: AtomicU64,
}
#[derive(Clone)]
struct GpuResources {
header_buffer: wgpu::Buffer,
target_buffer: wgpu::Buffer,
start_nonce_buffer: wgpu::Buffer,
results_buffer: wgpu::Buffer,
dispatch_config_buffer: wgpu::Buffer,
staging_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup,
}
pub struct GpuEngine {
contexts: Vec<Arc<GpuContext>>,
device_counter: AtomicUsize,
target_batch_duration: Duration,
}
// Thread-local storage for consistent GPU device assignment per worker thread
thread_local! {
static ASSIGNED_GPU_DEVICE: RefCell<Option<usize>> = const { RefCell::new(None) };
static WORKER_RESOURCES: RefCell<Option<GpuResources>> = const { RefCell::new(None) };
}
impl GpuContext {
fn create_resources(&self) -> GpuResources {
let bind_group_layout = self.pipeline.get_bind_group_layout(0);
// Header: 8 u32s
let header_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Header Buffer"),
size: 32,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Target: 16 u32s
let target_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Target Buffer"),
size: 64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Start Nonce: 16 u32s
let start_nonce_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Start Nonce Buffer"),
size: 64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Results: [flag (1), nonce (16), hash (16)] = 33 u32s
let results_size = (1 + 16 + 16) * 4;
let results_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Results Buffer"),
size: results_size,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_SRC
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Dispatch config: [total_threads, nonces_per_thread, workgroups, threads_per_workgroup] = 4 u32s
let dispatch_config_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Dispatch Config Buffer"),
size: 16,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let staging_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Staging Buffer"),
size: results_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Mining Bind Group"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: results_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: header_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: start_nonce_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: target_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: dispatch_config_buffer.as_entire_binding(),
},
],
});
GpuResources {
header_buffer,
target_buffer,
start_nonce_buffer,
results_buffer,
dispatch_config_buffer,
staging_buffer,
bind_group,
}
}
}
impl Default for GpuEngine {
fn default() -> Self {
Self::new()
// Default to 3 seconds if not specified via new()
Self::new(Duration::from_secs(3))
}
}
impl GpuEngine {
pub fn new() -> Self {
block_on(Self::init()).expect("Failed to initialize GPU engine")
pub fn new(target_batch_duration: Duration) -> Self {
block_on(Self::init(target_batch_duration)).expect("Failed to initialize GPU engine")
}
async fn init() -> Result<Self, Box<dyn std::error::Error>> {
/// Try to initialize the GPU engine, returning an error if initialization fails.
pub fn try_new(target_batch_duration: Duration) -> Result<Self, Box<dyn std::error::Error>> {
block_on(Self::init(target_batch_duration))
}
async fn init(target_batch_duration: Duration) -> Result<Self, Box<dyn std::error::Error>> {
log::info!("Initializing WGPU...");
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
@@ -123,87 +231,7 @@ impl GpuEngine {
cache: None,
});
let bind_group_layout = pipeline.get_bind_group_layout(0);
// Pre-allocate buffers
// Header: 8 u32s
let header_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Header Buffer"),
size: 32,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Target: 16 u32s
let target_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Target Buffer"),
size: 64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Start Nonce: 16 u32s
let start_nonce_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Start Nonce Buffer"),
size: 64,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Results: [flag (1), nonce (16), hash (16)] = 33 u32s
let results_size = (1 + 16 + 16) * 4;
let results_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Results Buffer"),
size: results_size,
usage: wgpu::BufferUsages::STORAGE
| wgpu::BufferUsages::COPY_SRC
| wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
// Dispatch config: [total_threads, nonces_per_thread, workgroups, threads_per_workgroup] = 4 u32s
let dispatch_config_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Dispatch Config Buffer"),
size: 16,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let staging_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Staging Buffer"),
size: results_size,
usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Mining Bind Group"),
layout: &bind_group_layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: results_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: header_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 2,
resource: start_nonce_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 3,
resource: target_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 4,
resource: dispatch_config_buffer.as_entire_binding(),
},
],
});
log::debug!(target: "gpu_engine", "Buffers and bind group initialized for adapter {}", i);
log::debug!(target: "gpu_engine", "Pipeline initialized for adapter {}", i);
// Calculate vendor-specific configuration once during initialization
let optimal_workgroups = Self::get_vendor_specific_dispatch(&info, &device);
@@ -212,14 +240,8 @@ impl GpuEngine {
device,
queue,
pipeline,
bind_group,
optimal_workgroups,
header_buffer,
target_buffer,
start_nonce_buffer,
results_buffer,
dispatch_config_buffer,
staging_buffer,
batch_size: AtomicU64::new(1_000_000),
}));
}
@@ -264,6 +286,7 @@ impl GpuEngine {
Ok(Self {
contexts,
device_counter: AtomicUsize::new(0),
target_batch_duration,
})
}
@@ -271,6 +294,14 @@ impl GpuEngine {
pub fn device_count(&self) -> usize {
self.contexts.len()
}
/// Explicitly clear thread-local GPU resources.
/// Call this before thread exit to avoid TLS destruction order issues with wgpu.
pub fn clear_worker_resources() {
WORKER_RESOURCES.with(|resources| {
*resources.borrow_mut() = None;
});
}
}
impl MinerEngine for GpuEngine {
@@ -321,13 +352,28 @@ impl MinerEngine for GpuEngine {
});
let gpu_ctx = &self.contexts[device_index];
log::debug!(
"GPU device {} processing range {}..={} (inclusive)",
let initial_batch_size = gpu_ctx.batch_size.load(Ordering::Relaxed);
log::info!(
target: "gpu_engine",
"GPU {} search started: range {}..={} (inclusive), initial batch size: {}",
device_index,
range.start,
range.end
range.end,
initial_batch_size
);
// Ensure resources are initialized for this thread
WORKER_RESOURCES.with(|resources_cell| {
let mut resources = resources_cell.borrow_mut();
if resources.is_none() {
*resources = Some(gpu_ctx.create_resources());
}
});
// Clone resources to use outside the closure (they are cheap handles)
let resources = WORKER_RESOURCES
.with(|resources_cell| resources_cell.borrow().as_ref().unwrap().clone());
// Pre-convert header and target once (not per range)
let mut header_u32s = [0u32; 8];
for (i, item) in header_u32s.iter_mut().enumerate() {
@@ -335,7 +381,7 @@ impl MinerEngine for GpuEngine {
*item = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
gpu_ctx.queue.write_buffer(
&gpu_ctx.header_buffer,
&resources.header_buffer,
0,
bytemuck::cast_slice(&header_u32s),
);
@@ -347,179 +393,248 @@ impl MinerEngine for GpuEngine {
target_u32s[i] = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
gpu_ctx.queue.write_buffer(
&gpu_ctx.target_buffer,
&resources.target_buffer,
0,
bytemuck::cast_slice(&target_u32s),
);
// Length of the inclusive nonce range
let total_range_size = (range.end - range.start + 1).as_u64();
if total_range_size == 0 {
return EngineStatus::Exhausted { hash_count: 0 };
}
// --- Batch Processing Loop ---
let mut current_nonce = range.start;
let mut total_hashes_processed = 0u64;
// Thread configuration for a single dispatch over this range.
let threads_per_workgroup = 256u32; // Must match shader @workgroup_size(256)
let limits = gpu_ctx.device.limits();
let max_workgroups = limits.max_compute_workgroups_per_dimension;
// Loop until range is covered or cancelled
while current_nonce <= range.end {
// Check for cancellation between batches to ensure responsiveness
if cancel.load(Ordering::Relaxed) {
log::info!(
target: "gpu_engine",
"GPU {} search cancelled: processed {} hashes",
device_index,
total_hashes_processed
);
return EngineStatus::Cancelled {
hash_count: total_hashes_processed,
};
}
// Vendor hint, clamped by hardware limits.
let hinted_workgroups = gpu_ctx.optimal_workgroups.max(1).min(max_workgroups);
let hinted_threads = hinted_workgroups as u64 * threads_per_workgroup as u64;
// Determine dynamic batch size for this iteration
let batch_size = gpu_ctx.batch_size.load(Ordering::Relaxed);
// Choose a logical thread budget: enough threads to fill the GPU, but no more than
// the range length (spawning more threads than nonces is wasteful).
let mut logical_threads = total_range_size.min(hinted_threads);
if logical_threads == 0 {
logical_threads = 1;
}
// Round logical_threads up to a multiple of workgroup size so we have full workgroups.
let mut num_workgroups = (logical_threads as u32).div_ceil(threads_per_workgroup);
if num_workgroups == 0 {
num_workgroups = 1;
}
let total_threads = (num_workgroups * threads_per_workgroup) as u64;
// Derive how many nonces each logical thread should process so that the entire
// range is covered in a single dispatch.
let nonces_per_thread = total_range_size.div_ceil(total_threads).max(1) as u32;
let total_threads_u32 = total_threads as u32;
log::info!(
target: "gpu_engine",
"GPU dispatch configuration: total_range={} nonces, workgroups={}, threads={}, nonces_per_thread={}",
total_range_size,
num_workgroups,
total_threads_u32,
nonces_per_thread
);
// We'll process the full range in a single dispatch.
let hash_count = total_range_size;
// Eliminate intermediate syncs - only sync at end or when solution found
const RESULTS_SIZE: usize = (1 + 16 + 16) * 4;
const ZEROS: [u8; RESULTS_SIZE] = [0; RESULTS_SIZE];
#[cfg(feature = "metrics")]
{
let device_id = "gpu-0";
metrics::set_gpu_batch_size(device_id, total_range_size as f64);
metrics::set_gpu_workgroups(device_id, num_workgroups as f64);
}
if cancel.load(Ordering::Relaxed) {
log::debug!(target: "gpu_engine", "GPU {} cancelled before dispatch.", device_index);
return EngineStatus::Cancelled { hash_count: 0 };
}
// Dispatch configuration for this range:
// [total_threads, nonces_per_thread, total_nonces, threads_per_workgroup]
let total_nonces_u32 = total_range_size.min(u32::MAX as u64) as u32;
let dispatch_config = [
total_threads_u32,
nonces_per_thread,
total_nonces_u32,
threads_per_workgroup,
];
gpu_ctx.queue.write_buffer(
&gpu_ctx.dispatch_config_buffer,
0,
bytemuck::cast_slice(&dispatch_config),
);
// Starting nonce for this range.
let start_nonce_bytes = range.start.to_little_endian();
gpu_ctx
.queue
.write_buffer(&gpu_ctx.start_nonce_buffer, 0, &start_nonce_bytes);
// Reset results buffer to detect solutions from this dispatch.
gpu_ctx
.queue
.write_buffer(&gpu_ctx.results_buffer, 0, &ZEROS);
let total_start = std::time::Instant::now();
// Create command buffer for this range
let mut encoder = gpu_ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: None,
timestamp_writes: None,
});
cpass.set_pipeline(&gpu_ctx.pipeline);
cpass.set_bind_group(0, &gpu_ctx.bind_group, &[]);
cpass.dispatch_workgroups(num_workgroups, 1, 1);
}
encoder.copy_buffer_to_buffer(
&gpu_ctx.results_buffer,
0,
&gpu_ctx.staging_buffer,
0,
RESULTS_SIZE as u64,
);
// Submit and wait for completion
gpu_ctx.queue.submit(Some(encoder.finish()));
let buffer_slice = gpu_ctx.staging_buffer.slice(..);
buffer_slice.map_async(wgpu::MapMode::Read, |_| {});
let _ = gpu_ctx.device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let data = buffer_slice.get_mapped_range();
let result_u32s: &[u32] = bytemuck::cast_slice(&data);
if result_u32s[0] != 0 {
// Solution found!
let nonce_u32s = &result_u32s[1..17];
let hash_u32s = &result_u32s[17..33];
let nonce = U512::from_little_endian(bytemuck::cast_slice(nonce_u32s));
let hash = U512::from_little_endian(bytemuck::cast_slice(hash_u32s));
let work = nonce.to_big_endian();
log::info!(
"GPU {} found solution! Nonce: {}, Hash: {:x}",
log::debug!(
target: "gpu_engine",
"GPU {} starting batch: nonce {}..{}, batch size: {}",
device_index,
nonce,
hash
current_nonce,
current_nonce.saturating_add(U512::from(batch_size)).saturating_sub(U512::from(1u64)).min(range.end),
batch_size
);
drop(data);
gpu_ctx.staging_buffer.unmap();
// Calculate inclusive end for this batch
let batch_end = current_nonce
.saturating_add(U512::from(batch_size))
.saturating_sub(U512::from(1u64));
return EngineStatus::Found {
candidate: Candidate { nonce, work, hash },
// Approximate: we assume a uniform distribution within the dispatch
// and report that we processed the entire range.
hash_count,
origin: FoundOrigin::GpuG1,
let actual_end = if batch_end > range.end {
range.end
} else {
batch_end
};
// Range size for this specific batch
let range_size_u512 = actual_end
.saturating_sub(current_nonce)
.saturating_add(U512::from(1u64));
let range_size = range_size_u512.as_u64(); // batch_size is u64, so this is safe
if range_size == 0 {
break;
}
// --- Dispatch Logic (same as before but using range_size) ---
let threads_per_workgroup = 256u32; // Must match shader
let limits = gpu_ctx.device.limits();
let max_workgroups = limits.max_compute_workgroups_per_dimension;
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 mut logical_threads = range_size.min(hinted_threads);
if logical_threads == 0 {
logical_threads = 1;
}
let mut num_workgroups = (logical_threads as u32).div_ceil(threads_per_workgroup);
if num_workgroups == 0 {
num_workgroups = 1;
}
let total_threads = (num_workgroups * threads_per_workgroup) as u64;
let nonces_per_thread = range_size.div_ceil(total_threads).max(1) as u32;
let total_threads_u32 = total_threads as u32;
#[cfg(feature = "metrics")]
{
let device_id = "gpu-0"; // Simplified; ideally propagate device ID to metrics
metrics::set_gpu_batch_size(device_id, range_size as f64);
metrics::set_gpu_workgroups(device_id, num_workgroups as f64);
}
// Dispatch config: [total_threads, nonces_per_thread, total_nonces, threads_per_workgroup]
let total_nonces_u32 = range_size.min(u32::MAX as u64) as u32;
let dispatch_config = [
total_threads_u32,
nonces_per_thread,
total_nonces_u32,
threads_per_workgroup,
];
gpu_ctx.queue.write_buffer(
&resources.dispatch_config_buffer,
0,
bytemuck::cast_slice(&dispatch_config),
);
// Starting nonce for this batch
let start_nonce_bytes = current_nonce.to_little_endian();
gpu_ctx
.queue
.write_buffer(&resources.start_nonce_buffer, 0, &start_nonce_bytes);
// Reset results
const RESULTS_SIZE: usize = (1 + 16 + 16) * 4;
const ZEROS: [u8; RESULTS_SIZE] = [0; RESULTS_SIZE];
gpu_ctx
.queue
.write_buffer(&resources.results_buffer, 0, &ZEROS);
let batch_start_time = std::time::Instant::now();
// Encode and submit
let mut encoder = gpu_ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
{
let mut cpass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
label: None,
timestamp_writes: None,
});
cpass.set_pipeline(&gpu_ctx.pipeline);
cpass.set_bind_group(0, &resources.bind_group, &[]);
cpass.dispatch_workgroups(num_workgroups, 1, 1);
}
encoder.copy_buffer_to_buffer(
&resources.results_buffer,
0,
&resources.staging_buffer,
0,
RESULTS_SIZE as u64,
);
gpu_ctx.queue.submit(Some(encoder.finish()));
let buffer_slice = resources.staging_buffer.slice(..);
buffer_slice.map_async(wgpu::MapMode::Read, |_| {});
let _ = gpu_ctx.device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: None,
});
let data = buffer_slice.get_mapped_range();
let result_u32s: &[u32] = bytemuck::cast_slice(&data);
if result_u32s[0] != 0 {
// Solution found!
let nonce_u32s = &result_u32s[1..17];
let hash_u32s = &result_u32s[17..33];
let nonce = U512::from_little_endian(bytemuck::cast_slice(nonce_u32s));
let hash = U512::from_little_endian(bytemuck::cast_slice(hash_u32s));
let work = nonce.to_big_endian();
log::info!(
target: "gpu_engine",
"GPU {} found solution! Nonce: {}, Hash: {:x}",
device_index,
nonce,
hash
);
drop(data);
resources.staging_buffer.unmap();
log::info!(
target: "gpu_engine",
"GPU {} search ended (solution found): processed {} hashes",
device_index,
total_hashes_processed + range_size
);
// Even if found, return total hashes processed including this batch
return EngineStatus::Found {
candidate: Candidate { nonce, work, hash },
hash_count: total_hashes_processed + range_size,
origin: FoundOrigin::GpuG1,
};
}
drop(data);
resources.staging_buffer.unmap();
// --- Auto-tuning Logic ---
let batch_elapsed = batch_start_time.elapsed();
let batch_elapsed_secs = batch_elapsed.as_secs_f64();
// Update stats
total_hashes_processed += range_size;
if batch_elapsed_secs > 0.0 {
let target_secs = self.target_batch_duration.as_secs_f64();
// Calculate ideal batch size: (current_size / elapsed) * target
let hashrate = range_size as f64 / batch_elapsed_secs;
let ideal_batch_size = (hashrate * target_secs) as u64;
let old_size = gpu_ctx.batch_size.load(Ordering::Relaxed);
// Smooth update: 50% old, 50% new to avoid oscillation
// Also clamp to avoid drastic changes (0.5x to 2.0x)
let min_clamp = old_size / 2;
let max_clamp = old_size * 2;
// Ensure we don't go below a reasonable minimum (e.g. 100k) to avoid overhead dominance
let clamped_ideal = ideal_batch_size.clamp(min_clamp, max_clamp).max(100_000);
// Simple moving average
let new_size = (old_size + clamped_ideal) / 2;
if new_size != old_size {
gpu_ctx.batch_size.store(new_size, Ordering::Relaxed);
log::debug!(
target: "gpu_engine",
"GPU {} auto-tune: batch {} -> {} (elapsed: {:.3}s, target: {:.1}s, rate: {:.1} MH/s)",
device_index,
old_size,
new_size,
batch_elapsed_secs,
target_secs,
hashrate / 1_000_000.0
);
}
}
// Move to next batch
current_nonce = actual_end.saturating_add(U512::from(1u64));
}
drop(data);
gpu_ctx.staging_buffer.unmap();
let total_time = total_start.elapsed();
// Finished entire range without solution
log::info!(
target: "gpu_engine",
"GPU finished range. Total time: {:.2}ms, Batches: 1, Hashes: {}, Performance: {:.0} hashes/sec",
total_time.as_secs_f64() * 1000.0,
hash_count,
hash_count as f64 / total_time.as_secs_f64()
"GPU {} search completed: processed {} hashes, final batch size: {}",
device_index,
total_hashes_processed,
gpu_ctx.batch_size.load(Ordering::Relaxed)
);
log::debug!(target: "gpu_engine", "GPU {} finished range with no solution.", device_index);
EngineStatus::Exhausted { hash_count }
EngineStatus::Exhausted {
hash_count: total_hashes_processed,
}
}
}
+198 -6
View File
@@ -135,6 +135,45 @@ static HASH_RATE: Lazy<Gauge> = Lazy::new(|| {
g
});
type JobKey = (String, String); // (engine, job_id)
type ThreadKey = (String, String, String); // (engine, job_id, thread_id)
#[derive(Debug)]
struct ThreadState {
start: Instant,
hashes: u64,
}
#[derive(Debug)]
struct JobState {
start: Instant,
hashes: u64,
active_threads: usize,
}
#[derive(Debug)]
struct MiningSession {
start: Option<Instant>,
hashes: u64,
active_threads: usize,
threads: HashMap<ThreadKey, ThreadState>,
jobs: HashMap<JobKey, JobState>,
}
impl MiningSession {
fn new() -> Self {
Self {
start: None,
hashes: 0,
active_threads: 0,
threads: HashMap::new(),
jobs: HashMap::new(),
}
}
}
static SESSION: Lazy<Mutex<MiningSession>> = Lazy::new(|| Mutex::new(MiningSession::new()));
static HTTP_REQUESTS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
@@ -537,9 +576,165 @@ pub fn inc_hashes(n: u64) {
HASHES_TOTAL.inc_by(n);
}
/// Set the current estimated hash rate (nonces per second).
pub fn set_hash_rate(rate: f64) {
HASH_RATE.set(rate);
fn ensure_session(session: &mut MiningSession, now: Instant) {
if session.start.is_none() {
session.start = Some(now);
session.hashes = 0;
}
}
fn update_global_rate(session: &MiningSession, now: Instant) {
if let Some(start) = session.start {
let elapsed = now.saturating_duration_since(start).as_secs_f64();
if elapsed > 0.0 && session.hashes > 0 {
HASH_RATE.set(session.hashes as f64 / elapsed);
}
}
}
fn finish_thread_locked(session: &mut MiningSession, job_key: &JobKey, thread_key: &ThreadKey) {
if session.threads.remove(thread_key).is_some() {
session.active_threads = session.active_threads.saturating_sub(1);
}
if let Some(job) = session.jobs.get_mut(job_key) {
if job.active_threads > 0 {
job.active_threads -= 1;
}
}
if session.active_threads == 0 {
session.start = None;
session.hashes = 0;
HASH_RATE.set(0.0);
}
}
pub fn start_thread(engine: &str, job_id: &str, thread_id: usize) {
let now = Instant::now();
let engine_key = engine.to_string();
let job_key = job_id.to_string();
let thread_key = (engine_key.clone(), job_key.clone(), thread_id.to_string());
let job_key = (engine_key, job_key);
let mut session = SESSION.lock().unwrap();
ensure_session(&mut session, now);
if session.threads.contains_key(&thread_key) {
return;
}
session.active_threads = session.active_threads.saturating_add(1);
session.threads.insert(
thread_key,
ThreadState {
start: now,
hashes: 0,
},
);
let job = session.jobs.entry(job_key).or_insert(JobState {
start: now,
hashes: 0,
active_threads: 0,
});
if job.active_threads == 0 {
job.start = now;
job.hashes = 0;
}
job.active_threads = job.active_threads.saturating_add(1);
}
pub fn record_thread_progress(
engine: &str,
job_id: &str,
thread_id: usize,
hashes: u64,
completed: bool,
update_job_metrics: bool,
) {
let now = Instant::now();
let engine_key = engine.to_string();
let job_key = job_id.to_string();
let thread_id_str = thread_id.to_string();
let thread_key = (engine_key.clone(), job_key.clone(), thread_id_str.clone());
let job_key = (engine_key, job_key);
let mut session = SESSION.lock().unwrap();
ensure_session(&mut session, now);
let thread_was_present = session.threads.contains_key(&thread_key);
if !thread_was_present {
session.active_threads = session.active_threads.saturating_add(1);
session.threads.insert(
thread_key.clone(),
ThreadState {
start: now,
hashes: 0,
},
);
}
let (thread_start, thread_hashes_total) = {
let thread_state = session
.threads
.get_mut(&thread_key)
.expect("thread must exist");
thread_state.hashes = thread_state.hashes.saturating_add(hashes);
(thread_state.start, thread_state.hashes)
};
let (job_start, job_hashes_total) = {
let job_state = session.jobs.entry(job_key.clone()).or_insert(JobState {
start: thread_start,
hashes: 0,
active_threads: 0,
});
if job_state.active_threads == 0 {
job_state.start = thread_start;
job_state.hashes = 0;
}
if !thread_was_present {
job_state.active_threads = job_state.active_threads.saturating_add(1);
}
job_state.hashes = job_state.hashes.saturating_add(hashes);
(job_state.start, job_state.hashes)
};
session.hashes = session.hashes.saturating_add(hashes);
inc_hashes(hashes);
if update_job_metrics {
inc_job_hashes(engine, job_id, hashes);
inc_thread_hashes(engine, job_id, &thread_id_str, hashes);
}
let thread_elapsed = now.saturating_duration_since(thread_start).as_secs_f64();
if update_job_metrics && thread_elapsed > 0.0 && thread_hashes_total > 0 {
set_thread_hash_rate(
engine,
job_id,
&thread_id_str,
thread_hashes_total as f64 / thread_elapsed,
);
}
if update_job_metrics {
let job_elapsed = now.saturating_duration_since(job_start).as_secs_f64();
if job_elapsed > 0.0 && job_hashes_total > 0 {
set_job_hash_rate(engine, job_id, job_hashes_total as f64 / job_elapsed);
}
}
update_global_rate(&session, now);
if completed {
finish_thread_locked(&mut session, &job_key, &thread_key);
update_global_rate(&session, now);
}
}
/// Get the current global estimated hash rate.
pub fn get_hash_rate() -> f64 {
HASH_RATE.get()
}
/// Increment the jobs counter for a terminal status: completed | failed | cancelled.
@@ -561,9 +756,6 @@ pub fn inc_http_request(endpoint: &str, code: u16) {
const METRICS_TTL_SECS: u64 = 300;
const JANITOR_INTERVAL_SECS: u64 = 60;
type JobKey = (String, String); // (engine, job_id)
type ThreadKey = (String, String, String); // (engine, job_id, thread_id)
static JOB_KEYS: Lazy<Mutex<HashSet<JobKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
static JOB_LAST: Lazy<Mutex<HashMap<JobKey, Instant>>> = Lazy::new(|| Mutex::new(HashMap::new()));
static THREAD_KEYS: Lazy<Mutex<HashSet<ThreadKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
-4
View File
@@ -5,10 +5,6 @@ edition.workspace = true
publish = false
description = "CLI binary to run the Quantus External Miner service"
[features]
default = []
gpu = ["miner-service/gpu"]
[dependencies]
miner-service = { path = "../miner-service" }
clap = { workspace = true, features = ["derive", "env"] }
+78 -59
View File
@@ -21,9 +21,9 @@ enum Command {
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
cpu_workers: Option<usize>,
/// Number of GPU worker threads to use for mining (None = auto-detect)
#[arg(long = "gpu-workers", env = "MINER_GPU_WORKERS")]
gpu_workers: Option<usize>,
/// Number of GPU devices to use for mining (None = auto-detect)
#[arg(long = "gpu-devices", env = "MINER_GPU_DEVICES")]
gpu_devices: Option<usize>,
/// Optional Prometheus metrics exporter port; if omitted, metrics are disabled
#[arg(long, env = "MINER_METRICS_PORT")]
@@ -60,6 +60,10 @@ enum Command {
#[arg(long = "manip-throttle-cap", env = "MINER_MANIP_THROTTLE_CAP")]
manip_throttle_cap: Option<u64>,
/// Target duration for GPU mining batches in milliseconds (default: 3000)
#[arg(long = "gpu-batch-duration-ms", env = "MINER_GPU_BATCH_DURATION_MS")]
gpu_batch_duration_ms: Option<u64>,
/// Telemetry endpoints (repeat --telemetry-endpoint or comma-separated)
#[arg(long = "telemetry-endpoint", env = "MINER_TELEMETRY_ENDPOINTS", value_delimiter = ',', num_args = 0.., value_name = "URL")]
telemetry_endpoints: Option<Vec<String>>,
@@ -109,9 +113,9 @@ enum Command {
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
cpu_workers: Option<usize>,
/// Number of GPU workers to use for benchmark
#[arg(long = "gpu-workers", env = "MINER_GPU_WORKERS")]
gpu_workers: Option<usize>,
/// Number of GPU devices to use for benchmark
#[arg(long = "gpu-devices", env = "MINER_GPU_DEVICES")]
gpu_devices: Option<usize>,
/// Benchmark duration in seconds (default: 10)
#[arg(short, long, default_value_t = 10)]
@@ -139,7 +143,7 @@ async fn main() {
match args.command.unwrap_or(Command::Serve {
port: 9833,
cpu_workers: None,
gpu_workers: None,
gpu_devices: None,
metrics_port: None,
verbose: false,
progress_interval_ms: None,
@@ -148,6 +152,7 @@ async fn main() {
manip_base_delay_ns: None,
manip_step_batch: None,
manip_throttle_cap: None,
gpu_batch_duration_ms: None,
telemetry_endpoints: None,
telemetry_enabled: None,
telemetry_verbosity: None,
@@ -162,7 +167,7 @@ async fn main() {
Command::Serve {
port,
cpu_workers,
gpu_workers,
gpu_devices,
metrics_port,
verbose,
progress_interval_ms,
@@ -171,6 +176,7 @@ async fn main() {
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
gpu_batch_duration_ms,
telemetry_endpoints,
telemetry_enabled,
telemetry_verbosity,
@@ -185,7 +191,7 @@ async fn main() {
run_serve_command(
port,
cpu_workers,
gpu_workers,
gpu_devices,
metrics_port,
verbose,
progress_interval_ms,
@@ -194,6 +200,7 @@ async fn main() {
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
gpu_batch_duration_ms,
telemetry_endpoints,
telemetry_enabled,
telemetry_verbosity,
@@ -209,11 +216,11 @@ async fn main() {
}
Command::Benchmark {
cpu_workers,
gpu_workers,
gpu_devices,
duration,
verbose,
} => {
run_benchmark_command(cpu_workers, gpu_workers, duration, verbose).await;
run_benchmark_command(cpu_workers, gpu_devices, duration, verbose).await;
}
}
}
@@ -222,7 +229,7 @@ async fn main() {
async fn run_serve_command(
port: u16,
cpu_workers: Option<usize>,
gpu_workers: Option<usize>,
gpu_devices: Option<usize>,
metrics_port: Option<u16>,
verbose: bool,
progress_interval_ms: Option<u64>,
@@ -231,6 +238,7 @@ async fn run_serve_command(
manip_base_delay_ns: Option<u64>,
manip_step_batch: Option<u64>,
manip_throttle_cap: Option<u64>,
gpu_batch_duration_ms: Option<u64>,
telemetry_endpoints: Option<Vec<String>>,
telemetry_enabled: Option<bool>,
telemetry_verbosity: Option<u8>,
@@ -294,7 +302,7 @@ async fn run_serve_command(
let config = ServiceConfig {
port,
cpu_workers,
gpu_workers,
gpu_devices,
metrics_port,
progress_interval_ms,
chunk_size,
@@ -302,6 +310,7 @@ async fn run_serve_command(
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
gpu_batch_duration_ms,
};
log::info!("Effective config: {config}");
@@ -313,7 +322,7 @@ async fn run_serve_command(
async fn run_benchmark_command(
cpu_workers: Option<usize>,
gpu_workers: Option<usize>,
gpu_devices: Option<usize>,
duration: u64,
verbose: bool,
) {
@@ -329,18 +338,31 @@ async fn run_benchmark_command(
env_logger::init();
let effective_cpu_workers = cpu_workers.unwrap_or_else(num_cpus::get);
let effective_gpu_workers = gpu_workers.unwrap_or(0);
let total_workers = effective_cpu_workers + effective_gpu_workers;
// Initialize GPU engine and determine effective GPU devices
let (gpu_engine, effective_gpu_devices) =
match miner_service::resolve_gpu_configuration(gpu_devices, None) {
Ok((engine, count)) => (engine, count),
Err(e) => {
eprintln!("❌ ERROR: {}", e);
eprintln!(" Please check your --gpu-devices setting or GPU hardware.");
std::process::exit(1);
}
};
let total_workers = effective_cpu_workers + effective_gpu_devices;
println!("🚀 Quantus Miner Benchmark");
println!("==========================");
println!("Configuration:");
println!(
"Configuration: {} CPU workers, {} GPU workers",
effective_cpu_workers, effective_gpu_workers
" CPU Workers: {} (Available Cores: {})",
effective_cpu_workers,
num_cpus::get()
);
println!(" GPU Devices: {}", effective_gpu_devices);
println!("Duration: {} seconds", duration);
println!("Total Workers: {}", total_workers);
println!("Available CPUs: {}", num_cpus::get());
// Create engines based on configuration
let cpu_engine = if effective_cpu_workers > 0 {
@@ -349,46 +371,29 @@ async fn run_benchmark_command(
None
};
let gpu_engine = if effective_gpu_workers > 0 {
#[cfg(feature = "gpu")]
{
Some(Arc::new(engine_gpu::GpuEngine::new()) as Arc<dyn MinerEngine>)
}
#[cfg(not(feature = "gpu"))]
{
eprintln!("Error: GPU workers requested but this binary was built without GPU support");
std::process::exit(1);
}
} else {
None
};
// For benchmark, we'll use CPU engine as primary for simplicity
let engine = cpu_engine
.or(gpu_engine)
.expect("At least one engine must be available");
if total_workers == 0 {
eprintln!("Error: No workers specified");
std::process::exit(1);
}
println!("Workers: {}", total_workers);
println!();
// Run benchmark
let cancel_flag = Arc::new(AtomicBool::new(false));
let benchmark_start = Instant::now();
// Create a large range that should take the full duration
let benchmark_range = EngineRange {
start: U512::from(0u64),
end: U512::from(100_000_000u64), // 100M nonces - should be plenty
end: U512::from(100_000_000u64), // 100M nonces
};
let mut header = [0u8; 32];
rand::thread_rng().fill_bytes(&mut header);
let difficulty = U512::MAX; // High difficulty - no solutions expected
let ctx = engine.prepare_context(header, difficulty);
// We need a context from *some* engine. They should be compatible (same job).
// Just pick one.
let ref_engine = cpu_engine.as_ref().or(gpu_engine.as_ref()).unwrap();
let ctx = ref_engine.prepare_context(header, difficulty);
println!("⛏️ Starting benchmark...");
@@ -396,30 +401,42 @@ async fn run_benchmark_command(
let mut handles = Vec::new();
let total_hashes_arc = Arc::new(std::sync::Mutex::new(0u64));
// Use larger ranges for GPU (1M) vs CPU (10K)
let nonces_per_worker = if effective_gpu_workers > 0 {
1_000_000u64 // 1M nonces per GPU worker
} else {
10_000u64 // 10K nonces per CPU worker
};
// Chunk sizes
let cpu_chunk = 10_000u64;
// GPU chunk size from 1 - 10M is good
let gpu_chunk = 1_000_000u64;
for worker_id in 0..total_workers {
let engine = engine.clone();
// Determine type and engine
let (engine, nonces_per_worker) = if worker_id < effective_cpu_workers {
(
cpu_engine.as_ref().expect("CPU engine missing").clone(),
cpu_chunk,
)
} else {
(
gpu_engine.as_ref().expect("GPU engine missing").clone(),
gpu_chunk,
)
};
let ctx = ctx.clone();
let cancel_flag = cancel_flag.clone();
let total_hashes = total_hashes_arc.clone();
let handle = thread::spawn(move || {
// We'll just define a range based on worker ID and a large multiplier
// so they don't overlap easily, though it doesn't matter for pure hashrate.
// Use a large stride to separate workers.
let stride = U512::from(1_000_000_000_000u64);
let worker_start = benchmark_range
.start
.saturating_add(U512::from(worker_id as u64).saturating_mul(stride));
let worker_range = EngineRange {
start: benchmark_range.start.saturating_add(
U512::from(worker_id as u64).saturating_mul(U512::from(nonces_per_worker)),
),
end: benchmark_range
.start
.saturating_add(
U512::from((worker_id + 1) as u64)
.saturating_mul(U512::from(nonces_per_worker)),
)
start: worker_start,
end: worker_start
.saturating_add(U512::from(nonces_per_worker))
.saturating_sub(U512::from(1u64)),
};
@@ -448,6 +465,8 @@ async fn run_benchmark_command(
}
}
engine_gpu::GpuEngine::clear_worker_resources();
worker_hashes
});
@@ -480,7 +499,7 @@ async fn run_benchmark_command(
format!("{:.0}", hash_rate)
};
println!("⏱️ {:.1}s - {} H/s", elapsed, hash_rate_str);
} else if effective_gpu_workers > 0 {
} else if effective_gpu_devices > 0 {
println!("⏱️ {:.1}s - processing...", elapsed);
} else {
println!("⏱️ {:.1}s - starting...", elapsed);
+1 -3
View File
@@ -9,8 +9,6 @@ description = "Service layer: HTTP API, job orchestration, and engine abstractio
default = ["cpu", "metrics"]
# Enable CPU engine by default.
cpu = ["engine-cpu"]
# Enable GPU engine.
gpu = ["engine-gpu"]
# Optional metrics/observability (Prometheus endpoint).
metrics = [
"dep:metrics",
@@ -40,6 +38,6 @@ quantus-miner-api = { workspace = true }
# Local crates
pow-core = { path = "../pow-core" }
engine-cpu = { path = "../engine-cpu", optional = true }
engine-gpu = { path = "../engine-gpu", optional = true }
engine-gpu = { path = "../engine-gpu" }
metrics = { path = "../metrics", optional = true }
miner-telemetry = { path = "../miner-telemetry" }
+356 -204
View File
@@ -14,8 +14,6 @@ use std::time::Instant;
use tokio::sync::Mutex;
use warp::{Filter, Rejection, Reply};
const THREAD_RATE_EMA_ALPHA: f64 = 0.2;
/// Service runtime configuration provided by the CLI/binary.
#[derive(Clone, Debug)]
pub struct ServiceConfig {
@@ -23,8 +21,8 @@ pub struct ServiceConfig {
pub port: u16,
/// Number of CPU worker threads to use for mining (None = auto-detect)
pub cpu_workers: Option<usize>,
/// Number of GPU worker threads to use for mining (None = auto-detect)
pub gpu_workers: Option<usize>,
/// Number of GPU devices to use for mining (None = auto-detect)
pub gpu_devices: Option<usize>,
/// Optional metrics port. When Some, metrics endpoint starts; when None, metrics are disabled.
pub metrics_port: Option<u16>,
/// How often to report mining progress (in milliseconds). If None, defaults to 10000ms.
@@ -39,6 +37,8 @@ pub struct ServiceConfig {
pub manip_step_batch: Option<u64>,
/// Optional cap on solved-blocks throttle index for manipulator engine.
pub manip_throttle_cap: Option<u64>,
/// Optional target duration for GPU batches in milliseconds.
pub gpu_batch_duration_ms: Option<u64>,
}
impl Default for ServiceConfig {
@@ -46,7 +46,7 @@ impl Default for ServiceConfig {
Self {
port: 9833,
cpu_workers: None,
gpu_workers: None,
gpu_devices: None,
metrics_port: None,
progress_interval_ms: None,
chunk_size: None,
@@ -54,6 +54,7 @@ impl Default for ServiceConfig {
manip_base_delay_ns: None,
manip_step_batch: None,
manip_throttle_cap: None,
gpu_batch_duration_ms: None,
}
}
}
@@ -62,17 +63,18 @@ impl fmt::Display for ServiceConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"port={}, cpu_workers={:?}, gpu_workers={:?}, metrics_port={:?}, progress_interval_ms={:?}, chunk_size={:?}, manip_solved_blocks={:?}, manip_base_delay_ns={:?}, manip_step_batch={:?}, manip_throttle_cap={:?}",
"port={}, cpu_workers={:?}, gpu_devices={:?}, metrics_port={:?}, progress_interval_ms={:?}, chunk_size={:?}, manip_solved_blocks={:?}, manip_base_delay_ns={:?}, manip_step_batch={:?}, manip_throttle_cap={:?}, gpu_batch_duration_ms={:?}",
self.port,
self.cpu_workers,
self.gpu_workers,
self.gpu_devices,
self.metrics_port,
self.progress_interval_ms,
self.chunk_size,
self.manip_solved_blocks,
self.manip_base_delay_ns,
self.manip_step_batch,
self.manip_throttle_cap
self.manip_throttle_cap,
self.gpu_batch_duration_ms
)
}
}
@@ -82,7 +84,7 @@ impl fmt::Display for ServiceConfig {
pub struct MiningService {
pub jobs: Arc<Mutex<HashMap<String, MiningJob>>>,
pub cpu_workers: usize,
pub gpu_workers: usize,
pub gpu_devices: usize,
pub cpu_engine: Option<Arc<dyn MinerEngine>>,
pub gpu_engine: Option<Arc<dyn MinerEngine>>,
/// How often to report mining progress (in milliseconds).
@@ -96,7 +98,7 @@ pub struct MiningService {
impl MiningService {
fn new(
cpu_workers: usize,
gpu_workers: usize,
gpu_devices: usize,
cpu_engine: Option<Arc<dyn MinerEngine>>,
gpu_engine: Option<Arc<dyn MinerEngine>>,
progress_interval_ms: u64,
@@ -105,7 +107,7 @@ impl MiningService {
Self {
jobs: Arc::new(Mutex::new(HashMap::new())),
cpu_workers,
gpu_workers,
gpu_devices,
cpu_engine,
gpu_engine,
progress_interval_ms,
@@ -121,17 +123,13 @@ impl MiningService {
return Err("Job already exists".to_string());
}
log::info!(
"Adding mining job: {} with {} workers",
job_id,
self.cpu_workers + self.gpu_workers
);
log::info!("Adding mining job: {}", job_id,);
job.job_id = Some(job_id.clone());
#[cfg(feature = "metrics")]
{
let engine_name = if self.cpu_workers > 0 && self.gpu_workers > 0 {
let engine_name = if self.cpu_workers > 0 && self.gpu_devices > 0 {
"hybrid"
} else if self.gpu_workers > 0 {
} else if self.gpu_devices > 0 {
"gpu"
} else {
"cpu"
@@ -149,7 +147,7 @@ impl MiningService {
}
job.start_mining(
self.cpu_workers,
self.gpu_workers,
self.gpu_devices,
self.cpu_engine.clone(),
self.gpu_engine.clone(),
self.progress_interval_ms,
@@ -212,7 +210,12 @@ impl MiningService {
let mut jobs_guard = jobs.lock().await;
jobs_guard.retain(|job_id, job| {
if job.status == JobStatus::Running && job.update_from_results() {
let was_running = job.status == JobStatus::Running;
// Always update from results to drain thread completion messages
// regardless of job status (until we decide to drop the job)
let now_not_running = job.update_from_results();
if was_running && now_not_running {
log::info!(
"Mining job {} finished with status {:?}, hashes: {}, time: {:?}",
job_id,
@@ -234,52 +237,36 @@ impl MiningService {
#[cfg(feature = "metrics")]
{
// Aggregate and per-job hash-rate estimate across running jobs (nonces/sec)
// Aggregate hash-rate across running jobs from last recorded per-job rates
let mut total_rate = 0.0;
// Update active jobs gauge
let mut running_jobs = 0i64;
for (job_id, job) in jobs_guard.iter() {
for (_job_id, job) in jobs_guard.iter() {
if job.status == JobStatus::Running {
running_jobs += 1;
total_rate += job.last_hash_rate;
metrics::set_job_hash_rate(job.engine_name, job_id, job.last_hash_rate);
}
}
metrics::set_hash_rate(total_rate);
metrics::set_active_jobs(running_jobs);
}
let do_watchdog = last_watchdog.elapsed().as_secs() >= 30;
let (total, running, completed, failed, cancelled, total_hash_rate) = if do_watchdog
{
let (total, running, completed, failed, cancelled) = if do_watchdog {
let mut running = 0usize;
let mut completed = 0usize;
let mut cancelled = 0usize;
let mut failed = 0usize;
let mut total_hash_rate = 0.0;
let total = jobs_guard.len();
for (_id, job) in jobs_guard.iter() {
match job.status {
JobStatus::Running => {
running += 1;
total_hash_rate += job.last_hash_rate;
}
JobStatus::Running => running += 1,
JobStatus::Completed => completed += 1,
JobStatus::Cancelled => cancelled += 1,
JobStatus::Failed => failed += 1,
}
}
(
total,
running,
completed,
failed,
cancelled,
total_hash_rate,
)
(total, running, completed, failed, cancelled)
} else {
(0, 0, 0, 0, 0, 0.0)
(0, 0, 0, 0, 0)
};
drop(jobs_guard);
if do_watchdog {
let uptime = service_start.elapsed();
let uptime_str = if uptime.as_secs() < 60 {
@@ -294,6 +281,11 @@ impl MiningService {
)
};
#[cfg(feature = "metrics")]
let total_hash_rate = metrics::get_hash_rate();
#[cfg(not(feature = "metrics"))]
let total_hash_rate = 0.0;
if total == 0 {
log::info!(
"📊 Mining service healthy - uptime: {} - waiting for jobs",
@@ -356,7 +348,6 @@ pub struct MiningJob {
pub status: JobStatus,
pub start_time: Instant,
pub total_hash_count: u64,
pub last_hash_rate: f64,
pub best_result: Option<MiningJobResult>,
pub engine_name: &'static str,
@@ -364,8 +355,7 @@ pub struct MiningJob {
pub cancel_flag: Arc<AtomicBool>,
pub result_receiver: Option<Receiver<ThreadResult>>,
pub thread_handles: Vec<thread::JoinHandle<()>>,
pub thread_last_update: std::collections::HashMap<usize, std::time::Instant>,
pub thread_rate_ema: std::collections::HashMap<usize, f64>,
pub thread_total_hashes: std::collections::HashMap<usize, u64>,
completed_threads: usize,
pub result_served: bool,
}
@@ -381,7 +371,6 @@ impl Clone for MiningJob {
status: self.status.clone(),
start_time: self.start_time,
total_hash_count: self.total_hash_count,
last_hash_rate: self.last_hash_rate,
best_result: self.best_result.clone(),
engine_name: self.engine_name,
job_id: self.job_id.clone(),
@@ -390,8 +379,7 @@ impl Clone for MiningJob {
// Do not clone crossbeam receiver or thread handles; they are runtime artifacts.
result_receiver: None,
thread_handles: Vec::new(),
thread_last_update: self.thread_last_update.clone(),
thread_rate_ema: self.thread_rate_ema.clone(),
thread_total_hashes: self.thread_total_hashes.clone(),
completed_threads: self.completed_threads,
result_served: self.result_served,
}
@@ -422,15 +410,13 @@ impl MiningJob {
status: JobStatus::Running,
start_time: Instant::now(),
total_hash_count: 0,
last_hash_rate: 0.0,
best_result: None,
engine_name: "unknown",
job_id: None,
cancel_flag: Arc::new(AtomicBool::new(false)),
result_receiver: None,
thread_handles: Vec::new(),
thread_last_update: std::collections::HashMap::new(),
thread_rate_ema: std::collections::HashMap::new(),
thread_total_hashes: std::collections::HashMap::new(),
completed_threads: 0,
result_served: false,
}
@@ -439,13 +425,13 @@ impl MiningJob {
pub fn start_mining(
&mut self,
cpu_workers: usize,
gpu_workers: usize,
gpu_devices: usize,
cpu_engine: Option<Arc<dyn MinerEngine>>,
gpu_engine: Option<Arc<dyn MinerEngine>>,
progress_interval_ms: u64,
chunk_size: Option<u64>,
) {
let total_workers = cpu_workers + gpu_workers;
let total_workers = cpu_workers + gpu_devices;
let chan_capacity = std::env::var("MINER_RESULT_CHANNEL_CAP")
.ok()
.and_then(|v| v.parse::<usize>().ok())
@@ -454,9 +440,9 @@ impl MiningJob {
self.result_receiver = Some(receiver);
// Set engine name based on what's available
self.engine_name = if cpu_workers > 0 && gpu_workers > 0 {
self.engine_name = if cpu_workers > 0 && gpu_devices > 0 {
"hybrid"
} else if gpu_workers > 0 {
} else if gpu_devices > 0 {
"gpu"
} else {
"cpu"
@@ -473,7 +459,7 @@ impl MiningJob {
log::info!(
"Starting mining with {} CPU + {} GPU workers, total range: {}",
cpu_workers,
gpu_workers,
gpu_devices,
total_range
);
@@ -503,6 +489,9 @@ impl MiningJob {
let engine = cpu_engine.clone();
let job_id = self.job_id.clone().unwrap_or_else(|| "unknown".to_string());
#[cfg(feature = "metrics")]
metrics::start_thread(self.engine_name, &job_id, thread_id);
let handle = thread::spawn(move || {
mine_range_with_engine_typed(
thread_id,
@@ -524,10 +513,10 @@ impl MiningJob {
}
// Create GPU worker threads
if gpu_workers > 0 {
if gpu_devices > 0 {
if let Some(gpu_engine) = gpu_engine {
let ctx = gpu_engine.prepare_context(self.header_hash, self.difficulty);
let gpu_partitions = compute_partitions(current_start, self.nonce_end, gpu_workers);
let gpu_partitions = compute_partitions(current_start, self.nonce_end, gpu_devices);
for (start, end) in gpu_partitions.ranges.into_iter() {
let cancel_flag = self.cancel_flag.clone();
@@ -536,6 +525,9 @@ impl MiningJob {
let engine = gpu_engine.clone();
let job_id = self.job_id.clone().unwrap_or_else(|| "unknown".to_string());
#[cfg(feature = "metrics")]
metrics::start_thread(self.engine_name, &job_id, thread_id);
let handle = thread::spawn(move || {
mine_range_with_engine_typed(
thread_id,
@@ -573,22 +565,20 @@ impl MiningJob {
metrics::set_job_status_gauge(self.engine_name, job_id, "completed", 0);
metrics::set_job_status_gauge(self.engine_name, job_id, "failed", 0);
metrics::set_job_status_gauge(self.engine_name, job_id, "cancelled", 1);
// Remove job hash rate series on cancellation (avoid scrape-timing artifacts)
self.last_hash_rate = 0.0;
metrics::remove_job_hash_rate(self.engine_name, job_id);
metrics::remove_job_metrics(self.engine_name, job_id);
metrics::remove_thread_metrics_for_job(self.engine_name, job_id);
// Remove all per-thread hash rate series on cancellation and clear tracking
for (tid, _) in self.thread_rate_ema.iter() {
// Remove all per-thread hash rate series on cancellation
for (tid, _) in self.thread_total_hashes.iter() {
metrics::remove_thread_hash_rate(self.engine_name, job_id, &tid.to_string());
}
self.thread_last_update.clear();
self.thread_rate_ema.clear();
self.thread_total_hashes.clear();
}
}
while let Some(handle) = self.thread_handles.pop() {
let _ = handle.join();
// Do not wait for threads to finish; detach them so they can exit gracefully
// when they detect the cancellation flag. This prevents blocking the control loop.
drop(handle);
}
}
@@ -600,69 +590,40 @@ impl MiningJob {
while let Ok(thread_result) = receiver.try_recv() {
self.total_hash_count += thread_result.hash_count;
#[cfg(feature = "metrics")]
{
metrics::inc_hashes(thread_result.hash_count);
if let Some(job_id) = &self.job_id {
metrics::inc_job_hashes(self.engine_name, job_id, thread_result.hash_count);
metrics::inc_thread_hashes(
self.engine_name,
job_id,
&thread_result.thread_id.to_string(),
thread_result.hash_count,
);
// Compute per-thread delta hash rate since the last update for this thread
let now = std::time::Instant::now();
let last = self
.thread_last_update
.get(&thread_result.thread_id)
.copied()
.unwrap_or(self.start_time);
let dt = now.duration_since(last).as_secs_f64();
if dt > 0.0 && thread_result.hash_count > 0 {
let instant_rate = thread_result.hash_count as f64 / dt;
// Exponential Moving Average smoothing
let prev = self
.thread_rate_ema
.get(&thread_result.thread_id)
.copied()
.unwrap_or(instant_rate);
let ema = THREAD_RATE_EMA_ALPHA * instant_rate
+ (1.0 - THREAD_RATE_EMA_ALPHA) * prev;
metrics::set_thread_hash_rate(
self.engine_name,
job_id,
&thread_result.thread_id.to_string(),
ema,
);
// Store updated EMA for next delta
self.thread_rate_ema.insert(thread_result.thread_id, ema);
}
// Update last-seen timestamp for this thread
self.thread_last_update.insert(thread_result.thread_id, now);
*self
.thread_total_hashes
.entry(thread_result.thread_id)
.or_default() += thread_result.hash_count;
// Update per-job hash rate as the sum of per-thread EMAs and publish
let job_rate: f64 = self.thread_rate_ema.values().copied().sum();
self.last_hash_rate = job_rate;
metrics::set_job_hash_rate(self.engine_name, job_id, job_rate);
}
#[cfg(feature = "metrics")]
if let Some(job_id) = &self.job_id {
metrics::record_thread_progress(
self.engine_name,
job_id,
thread_result.thread_id,
thread_result.hash_count,
thread_result.completed,
self.status == JobStatus::Running,
);
}
if thread_result.completed {
self.completed_threads += 1;
#[cfg(feature = "metrics")]
{
if let Some(job_id) = &self.job_id {
// Remove this thread's hash rate series on completion
metrics::remove_thread_hash_rate(
self.engine_name,
job_id,
&thread_result.thread_id.to_string(),
);
}
// Cleanup per-thread tracking on completion
self.thread_last_update.remove(&thread_result.thread_id);
self.thread_rate_ema.remove(&thread_result.thread_id);
let thread_total = *self
.thread_total_hashes
.get(&thread_result.thread_id)
.unwrap_or(&0);
let elapsed = self.start_time.elapsed().as_secs_f64();
if elapsed > 0.0 && thread_total > 0 {
let thread_rate = thread_total as f64 / elapsed;
log::info!(
"Thread {} finished - Rate: {:.2} H/s ({} hashes in {:.2}s)",
thread_result.thread_id,
thread_rate,
thread_total,
elapsed
);
}
}
@@ -716,20 +677,15 @@ impl MiningJob {
metrics::set_job_status_gauge(self.engine_name, job_id, "completed", 1);
metrics::set_job_status_gauge(self.engine_name, job_id, "failed", 0);
metrics::set_job_status_gauge(self.engine_name, job_id, "cancelled", 0);
// Remove job hash rate on completion and clear per-thread series
self.last_hash_rate = 0.0;
metrics::remove_job_hash_rate(self.engine_name, job_id);
metrics::remove_job_metrics(self.engine_name, job_id);
metrics::remove_thread_metrics_for_job(self.engine_name, job_id);
for (tid, _) in self.thread_rate_ema.iter() {
for (tid, _) in self.thread_total_hashes.iter() {
metrics::remove_thread_hash_rate(
self.engine_name,
job_id,
&tid.to_string(),
);
}
self.thread_last_update.clear();
self.thread_rate_ema.clear();
}
}
} else if self.completed_threads >= self.thread_handles.len()
@@ -745,18 +701,13 @@ impl MiningJob {
metrics::set_job_status_gauge(self.engine_name, job_id, "completed", 0);
metrics::set_job_status_gauge(self.engine_name, job_id, "failed", 1);
metrics::set_job_status_gauge(self.engine_name, job_id, "cancelled", 0);
// Remove job hash rate on failure and clear per-thread series
self.last_hash_rate = 0.0;
metrics::remove_job_hash_rate(self.engine_name, job_id);
for (tid, _) in self.thread_rate_ema.iter() {
for (tid, _) in self.thread_total_hashes.iter() {
metrics::remove_thread_hash_rate(
self.engine_name,
job_id,
&tid.to_string(),
);
}
self.thread_last_update.clear();
self.thread_rate_ema.clear();
}
}
}
@@ -779,14 +730,26 @@ fn mine_range_with_engine_typed(
progress_interval_ms: u64,
chunk_size: Option<u64>,
) {
log::info!(
"⛏️ Job {} {} thread {} mining range {} to {} (inclusive)",
job_id,
engine_type,
thread_id,
range.start,
range.end
);
if engine_type == "CPU" {
log::info!(
target: "miner-service",
"CPU thread {} search started: Job {} range {} to {} (inclusive)",
thread_id,
job_id,
range.start,
range.end
);
} else {
log::debug!(
target: "miner-service",
"⛏️ Job {} {} thread {} mining range {} to {} (inclusive)",
job_id,
engine_type,
thread_id,
range.start,
range.end
);
}
// Chunk the range into subranges to emit periodic progress updates for metrics
let mut current_start = range.start;
@@ -796,19 +759,42 @@ fn mine_range_with_engine_typed(
let chunk_size_u64 = chunk_size.unwrap_or_else(|| {
// Use engine-specific defaults if not configured
if engine.name().contains("gpu") {
// GPU can handle much larger chunks efficiently
100_000_000 // 100M hashes per chunk
// GPU can handle much larger chunks efficiently.
// Increased to 4B to allow auto-tuned batches (e.g. 3s @ 1GH/s = 3B) to grow sufficiently.
4_000_000_000
} else if engine.name() == "hybrid" {
// Hybrid engines use GPU-sized chunks since they route to GPU workers
100_000_000 // 100M hashes per chunk for hybrid
4_000_000_000
} else {
// CPU uses time-based chunks
let est_ops_per_sec = 100_000u64; // 100K ops/sec for CPU
((est_ops_per_sec.saturating_mul(target_ms)) / 1000).max(5_000)
}
});
// Log the effective chunk size to assist in debugging/tuning
if engine_type == "GPU" {
log::info!(
target: "miner-service",
"Job {} GPU thread {} configured with chunk size: {}",
job_id,
thread_id,
chunk_size_u64
);
} else {
log::debug!(
target: "miner-service",
"Job {} {} thread {} configured with chunk size: {}",
job_id,
engine_type,
thread_id,
chunk_size_u64
);
}
let chunk_size = U512::from(chunk_size_u64);
let mut done = false;
let mut total_hashes_processed = 0u64;
while current_start <= end && !cancel_flag.load(Ordering::Relaxed) {
let mut current_end = current_start
@@ -824,14 +810,29 @@ fn mine_range_with_engine_typed(
};
log::debug!(
"Job {} {} thread {} processing subrange {}..{} (inclusive)",
"Job {} {} thread {} starting search on subrange {}..{} (inclusive)",
job_id,
engine_type,
thread_id,
sub_range.start,
sub_range.end
);
let start_time = Instant::now();
let status = engine.search_range(&ctx, sub_range.clone(), &cancel_flag);
let status_str = match status {
engine_cpu::EngineStatus::Found { .. } => "found",
engine_cpu::EngineStatus::Exhausted { .. } => "exhausted",
engine_cpu::EngineStatus::Cancelled { .. } => "cancelled",
engine_cpu::EngineStatus::Running { .. } => "running",
};
log::info!(
target: "miner-service",
"{} thread {} finished search, status: {} job: {}",
engine_type,
thread_id,
status_str,
job_id,
);
match status {
engine_cpu::EngineStatus::Found {
@@ -839,6 +840,7 @@ fn mine_range_with_engine_typed(
hash_count,
origin,
} => {
let _duration = start_time.elapsed();
// Send final result with found candidate and the hashes covered in this subrange
let final_result = ThreadResult {
thread_id,
@@ -866,6 +868,8 @@ fn mine_range_with_engine_typed(
break;
}
engine_cpu::EngineStatus::Exhausted { hash_count } => {
let _duration = start_time.elapsed();
total_hashes_processed += hash_count;
// Send intermediate progress update for this chunk
let update = ThreadResult {
thread_id,
@@ -894,17 +898,29 @@ fn mine_range_with_engine_typed(
}
}
engine_cpu::EngineStatus::Cancelled { hash_count } => {
let _duration = start_time.elapsed();
total_hashes_processed += hash_count;
// Send last progress update and stop
let update = ThreadResult {
thread_id,
result: None,
hash_count,
origin: None,
completed: false,
completed: true,
};
if sender.try_send(update).is_err() {
log::warn!(target: "miner", "Job {job_id} thread {thread_id} failed to send cancel update");
}
if engine_type == "CPU" {
log::info!(
target: "miner-service",
"CPU thread {} search cancelled: Job {} processed {} hashes (total: {})",
thread_id,
job_id,
hash_count,
total_hashes_processed
);
}
done = true;
break;
}
@@ -919,6 +935,20 @@ fn mine_range_with_engine_typed(
current_start = current_end.saturating_add(U512::from(1u64));
}
// Check if loop exited due to cancellation flag
if !done && cancel_flag.load(Ordering::Relaxed) {
if engine_type == "CPU" {
log::info!(
target: "miner-service",
"CPU thread {} search cancelled (flag set): Job {} processed {} hashes",
thread_id,
job_id,
total_hashes_processed
);
}
done = true;
}
if !done {
// Signal thread completion with no final candidate
let final_result = ThreadResult {
@@ -931,6 +961,20 @@ fn mine_range_with_engine_typed(
if sender.try_send(final_result).is_err() {
log::warn!(target: "miner", "Job {job_id} thread {thread_id} failed to send completion status after chunked search");
}
if engine_type == "CPU" {
log::info!(
target: "miner-service",
"CPU thread {} search completed: Job {} exhausted range, processed {} hashes",
thread_id,
job_id,
total_hashes_processed
);
}
}
// Explicitly clear thread-local GPU resources to avoid TLS order panic
if engine_type == "GPU" {
engine_gpu::GpuEngine::clear_worker_resources();
}
log::debug!(target: "miner", "Job {job_id} thread {thread_id} completed.");
@@ -977,7 +1021,7 @@ pub async fn handle_mine_request(
request: MiningRequest,
state: MiningService,
) -> Result<impl Reply, Rejection> {
log::debug!("Received mine request: {request:?}");
log::debug!(target: "miner-servce", "Mine request: {request:?}");
if let Err(e) = validate_mining_request(&request) {
log::warn!("Invalid mine request ({}): {}", request.job_id, e);
#[cfg(feature = "metrics")]
@@ -1006,7 +1050,7 @@ pub async fn handle_mine_request(
match state.add_job(request.job_id.clone(), job).await {
Ok(_) => {
log::debug!(target: "miner", "Accepted mine request for job ID: {}", request.job_id);
log::debug!(target: "miner-servce", "Accepted mine request for job ID: {}", request.job_id);
#[cfg(feature = "metrics")]
{
metrics::inc_mine_requests("accepted");
@@ -1048,7 +1092,7 @@ pub async fn handle_result_request(
job_id: String,
state: MiningService,
) -> Result<impl Reply, Rejection> {
log::debug!("Received result request for job: {job_id}");
log::debug!(target: "miner-servce", "Result request: {job_id}");
let job = match state.get_job(&job_id).await {
Some(job) => job,
@@ -1071,11 +1115,15 @@ pub async fn handle_result_request(
let elapsed = job.start_time.elapsed().as_secs_f64();
match job.status {
JobStatus::Running => {
#[cfg(feature = "metrics")]
let current_rate = metrics::get_hash_rate();
#[cfg(not(feature = "metrics"))]
let current_rate = 0.0;
log::debug!(
"🔍 Job {} still running - elapsed: {:.1}s - hash rate: {:.0} H/s",
job_id,
elapsed,
job.last_hash_rate
current_rate
);
}
JobStatus::Completed if !job.result_served => {
@@ -1158,7 +1206,7 @@ pub async fn handle_cancel_request(
job_id: String,
state: MiningService,
) -> Result<impl Reply, Rejection> {
log::debug!("Received cancel request for job: {job_id}");
log::info!(target: "miner-servce", "Cancel job: {job_id}");
if state.cancel_job(&job_id).await {
log::debug!(target: "miner", "Successfully cancelled job: {job_id}");
@@ -1245,6 +1293,54 @@ fn compute_partitions(start: U512, end: U512, workers: usize) -> Partitions {
Partitions { ranges }
}
pub fn resolve_gpu_configuration(
requested_devices: Option<usize>,
gpu_batch_duration_ms: Option<u64>,
) -> anyhow::Result<(Option<Arc<dyn MinerEngine>>, usize)> {
// Default to 3000ms if not specified
let duration = std::time::Duration::from_millis(gpu_batch_duration_ms.unwrap_or(3000));
if let Some(req_count) = requested_devices {
if req_count == 0 {
return Ok((None, 0));
}
// Explicit request > 0
let engine = engine_gpu::GpuEngine::try_new(duration)
.map_err(|e| anyhow::anyhow!("Failed to initialize GPU engine: {}", e))?;
let available = engine.device_count();
if req_count > available {
return Err(anyhow::anyhow!(
"Requested {} GPU devices but only {} device(s) are available.",
req_count,
available
));
}
Ok((Some(Arc::new(engine)), req_count))
} else {
// Auto-detect
match engine_gpu::GpuEngine::try_new(duration) {
Ok(engine) => {
let available = engine.device_count();
if available > 0 {
log::info!(
"Auto-detected {} GPU device(s). Using all available GPUs.",
available
);
Ok((Some(Arc::new(engine)), available))
} else {
log::info!("GPU auto-detection found 0 devices. Defaulting to CPU only.");
Ok((None, 0))
}
}
Err(e) => {
log::info!("GPU auto-detection failed (no suitable GPU found): {}. Defaulting to CPU only.", e);
Ok((None, 0))
}
}
}
}
/// Start the miner service with the given configuration.
/// - Spawns the mining loop.
/// - Optionally exposes a metrics endpoint if `metrics_port` is provided and the `metrics` feature is enabled.
@@ -1311,9 +1407,24 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
metrics::set_effective_cpus(effective_cpus as i64);
}
// Initialize GPU engine if requested or for auto-detection
let (gpu_engine, gpu_devices): (Option<Arc<dyn MinerEngine>>, usize) =
match resolve_gpu_configuration(config.gpu_devices, config.gpu_batch_duration_ms) {
Ok((engine, count)) => (engine, count),
Err(e) => {
log::error!("❌ ERROR: {}", e);
if config.gpu_devices.is_some() {
log::error!(" Please check your --gpu-devices setting or GPU hardware.");
std::process::exit(1);
} else {
(None, 0)
}
}
};
// Calculate CPU workers
let cpu_workers = config.cpu_workers.unwrap_or_else(|| {
if config.gpu_workers.is_none() || config.gpu_workers == Some(0) {
if gpu_devices == 0 {
// CPU-only mode: use default CPU allocation
let default_cpu_workers = effective_cpus
.saturating_sub(effective_cpus / 2)
@@ -1328,33 +1439,24 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
} else {
// Hybrid mode: default to half of effective CPUs for CPU
let default_cpu_workers = effective_cpus / 2;
log::info!("Hybrid mode: defaulting to {} CPU workers", default_cpu_workers);
log::info!(
"Hybrid mode: defaulting to {} CPU workers",
default_cpu_workers
);
default_cpu_workers
}
});
// Calculate GPU workers
let gpu_workers = config.gpu_workers.unwrap_or_else(|| {
if config.cpu_workers.is_some() && config.cpu_workers != Some(0) {
// Hybrid mode: default to 1 GPU worker
log::info!("Hybrid mode: defaulting to 1 GPU worker");
1
} else {
// CPU-only mode by default
0
}
});
let total_workers = cpu_workers + gpu_devices;
let total_workers = cpu_workers + gpu_workers;
let (cpu_workers, gpu_workers) = if total_workers == 0 {
let (cpu_workers, gpu_devices) = if total_workers == 0 {
log::warn!(
"No workers specified. Defaulting to CPU-only mode with {} workers.",
effective_cpus
);
(effective_cpus, 0)
} else {
(cpu_workers, gpu_workers)
(cpu_workers, gpu_devices)
};
// Create engines based on worker configuration
@@ -1364,27 +1466,11 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
None
};
let gpu_engine: Option<Arc<dyn MinerEngine>> = if gpu_workers > 0 {
#[cfg(feature = "gpu")]
{
Some(Arc::new(engine_gpu::GpuEngine::new()) as Arc<dyn MinerEngine>)
}
#[cfg(not(feature = "gpu"))]
{
log::error!("GPU workers requested but this binary was built without the 'gpu' feature. Rebuild with --features gpu.");
return Err(anyhow::anyhow!(
"GPU workers requested but 'gpu' feature not enabled"
));
}
} else {
None
};
// Log the mining configuration
log::info!(
"🚀 Mining configuration: {} CPU workers, {} GPU workers",
"🚀 Mining configuration: {} CPU workers, {} GPU devices",
cpu_workers,
gpu_workers
gpu_devices
);
if let Some(ref engine) = cpu_engine {
@@ -1400,7 +1486,7 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
let progress_interval_ms = config.progress_interval_ms.unwrap_or(10000);
let service = MiningService::new(
cpu_workers,
gpu_workers,
gpu_devices,
cpu_engine,
gpu_engine,
progress_interval_ms,
@@ -1500,9 +1586,9 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
let telemetry_handle = telemetry.clone();
let svc = service.clone();
let engine_name_str = if cpu_workers > 0 && gpu_workers > 0 {
let engine_name_str = if cpu_workers > 0 && gpu_devices > 0 {
"hybrid".to_string()
} else if gpu_workers > 0 {
} else if gpu_devices > 0 {
"gpu".to_string()
} else {
"cpu".to_string()
@@ -1515,25 +1601,24 @@ pub async fn run(config: ServiceConfig) -> anyhow::Result<()> {
tokio::spawn(async move {
loop {
let (active_jobs, total_rate) = {
let active_jobs = {
let jobs = svc.jobs.lock().await;
let mut running = 0i64;
let mut rate = 0.0;
for (_id, job) in jobs.iter() {
if job.status == JobStatus::Running {
running += 1;
rate += job.last_hash_rate;
}
}
(running, rate)
jobs.values()
.filter(|job| job.status == JobStatus::Running)
.count() as i64
};
#[cfg(feature = "metrics")]
let total_rate = metrics::get_hash_rate();
#[cfg(not(feature = "metrics"))]
let total_rate = 0.0;
let uptime_ms = start_instant.elapsed().as_millis() as u64;
let interval = miner_telemetry::SystemInterval {
uptime_ms,
engine: Some(engine_name_str.clone()),
workers: Some((svc.cpu_workers + svc.gpu_workers) as u32),
workers: Some((svc.cpu_workers + svc.gpu_devices) as u32),
hash_rate: Some(total_rate),
active_jobs: Some(active_jobs),
linked_node_hint: None,
@@ -1965,4 +2050,71 @@ mod tests {
"final completion message carries zero hash_count"
);
}
#[tokio::test]
async fn test_cancel_returns_immediately() {
use engine_cpu::{EngineRange, EngineStatus, MinerEngine};
use pow_core::JobContext;
use std::any::Any;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
struct SlowEngine;
impl MinerEngine for SlowEngine {
fn name(&self) -> &'static str {
"slow-engine"
}
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
JobContext::new(header_hash, difficulty)
}
fn search_range(
&self,
_ctx: &JobContext,
_range: EngineRange,
cancel: &AtomicBool,
) -> EngineStatus {
// Sleep to simulate a long-running batch on GPU
std::thread::sleep(Duration::from_secs(2));
if cancel.load(Ordering::Relaxed) {
EngineStatus::Cancelled { hash_count: 0 }
} else {
EngineStatus::Exhausted { hash_count: 0 }
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
let engine = Arc::new(SlowEngine);
let service = MiningService::new(1, 0, Some(engine), None, 10000, None);
let job = MiningJob::new([0u8; 32], U512::MAX, U512::zero(), U512::from(1000u64));
service
.add_job("slow-job".to_string(), job)
.await
.expect("Failed to add job");
// Give thread a moment to start and enter sleep
tokio::time::sleep(Duration::from_millis(100)).await;
let start = Instant::now();
service.cancel_job("slow-job").await;
let elapsed = start.elapsed();
// Cancel should be near-instant (<< 2 seconds)
assert!(
elapsed < Duration::from_millis(500),
"Cancel took too long: {:?} (expected < 500ms)",
elapsed
);
// Verify job is actually cancelled
let job = service.get_job("slow-job").await.unwrap();
assert_eq!(job.status, JobStatus::Cancelled);
}
}