38 Commits
Author SHA1 Message Date
Quantus Network CIandilluzen a60d95c825 bump version to v3.0.0 (#51)
Co-authored-by: illuzen <illuzen@users.noreply.github.com>
2026-04-02 14:22:41 +08:00
illuzenandNikolaus Heger 417a3efd86 Single hash, new poseidon (#50)
* single hash, new poseidon

* fix build

* new poseidon api

* fmt

* fix race condition with stale jobs being reported

* fmt

* fix deps

* format toml

remove stale comments

---------

Co-authored-by: Nikolaus Heger <nheger@gmail.com>
2026-04-02 14:06:54 +08:00
illuzen 443f0f9f89 QUIC miner (#49)
* implement quic

* simpler names

* simplify worker logic

* simplify gpu configuration logic

* miner initiates and picks random start nonce

* remove mining service

* clean up logs and remove unused engines

* clean up metrics

* metrics fix

* shared worker pool, cpu driven cancellation

just 10x the gpu thru-put, no more batch-size on gpu

* fmt

* non-local git

* taplo derp

* fix the benches

* handle stale results correctly in gpu

* more accurate gpu hashrate

* fmt

* remove unused

* handle duplicate solutions, cli controls gpu cancel frequency

* put plonky2 back

* demote noisy log
2026-02-02 16:38:21 +08:00
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
26 changed files with 2349 additions and 4645 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
-24
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-15-intel
target: x86_64-apple-darwin
binary_name: quantus-miner
asset_name: quantus-miner-macos-x86_64
features: ""
- os: macos-15-intel
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
+555 -614
View File
File diff suppressed because it is too large Load Diff
+4 -7
View File
@@ -15,7 +15,7 @@ resolver = "2"
edition = "2021"
authors = ["Quantus Network"]
description = "Quantus External Miner Workspace"
version = "2.0.2"
version = "3.0.0"
[workspace.dependencies]
anyhow = "1"
@@ -29,18 +29,15 @@ num-bigint = { version = "0.4", features = ["rand"] }
num-traits = "0.2"
num_cpus = "1.16"
primitive-types = { version = "0.13.1", default-features = false }
qp-poseidon-core = { version = "1.0.1", default-features = false }
qpow-math = { git = "https://github.com/Quantus-Network/chain.git", package = "qpow-math", default-features = false }
quantus-miner-api = { version = "0.0.3", default-features = false }
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 }
serde = { version = "1.0", features = ["derive"] }
serde_json = { version = "1.0.132", default-features = false }
thiserror = "1"
tokio = { version = "1.36", features = ["full"] }
warp = "0.3"
# pow-core now delegates to qpow-math for core functionality while providing optimized mining scaffolding.
# GPU backend dependencies are declared in individual engine crates (no optional deps in workspace)
[profile.release]
opt-level = 3
+216 -237
View File
@@ -1,256 +1,235 @@
# External Miner Protocol Specification
This document defines the JSON-based HTTP protocol for communication between the Resonance Network node and an external QPoW miner service.
This document defines the QUIC-based protocol for communication between the Quantus Network node and external QPoW miner services.
## Overview
The node delegates the mining task (finding a valid nonce) to an external service. The node provides the necessary parameters (header hash, difficulty, nonce range) and the external miner searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. The miner returns the result, including the winning nonce, when found.
The node delegates the mining task (finding a valid nonce) to external miner services over persistent QUIC connections. The node provides the necessary parameters (header hash, difficulty) and each external miner independently searches for a valid nonce according to the QPoW rules defined in the `qpow-math` crate. Miners push results back when found.
### Key Benefits of QUIC
- **Lower latency**: Results are pushed immediately when found (no polling)
- **Connection resilience**: Built-in connection migration and recovery
- **Multiplexed streams**: Multiple operations on single connection
- **Built-in TLS**: Encrypted by default
## Architecture
### Connection Model
```
┌─────────────────────────────────┐
│ Node │
│ (QUIC Server on port 9833) │
│ │
┌──────────┐ │ Broadcasts: NewJob │
│ Miner 1 │ ──connect───► │ Receives: JobResult │
└──────────┘ │ │
│ Supports multiple miners │
┌──────────┐ │ First valid result wins │
│ Miner 2 │ ──connect───► │ │
└──────────┘ └─────────────────────────────────┘
┌──────────┐
│ Miner 3 │ ──connect───►
└──────────┘
```
- **Node** acts as the QUIC server, listening on port 9833 (default)
- **Miners** act as QUIC clients, connecting to the node
- Single bidirectional stream per miner connection
- Connection persists across multiple mining jobs
- Multiple miners can connect simultaneously
### Multi-Miner Operation
When multiple miners are connected:
1. Node broadcasts the same `NewJob` to all connected miners
2. Each miner independently selects a random starting nonce
3. First miner to find a valid solution sends `JobResult`
4. Node uses the first valid result, ignores subsequent results for same job
5. New job broadcast implicitly cancels work on all miners
### Message Types
The protocol uses only **two message types**:
| Direction | Message | Description |
|-----------|---------|-------------|
| Node → Miner | `NewJob` | Submit a mining job (implicitly cancels any previous job) |
| Miner → Node | `JobResult` | Mining result (completed, failed, or cancelled) |
### Wire Format
Messages are length-prefixed JSON:
```
┌─────────────────┬─────────────────────────────────┐
│ Length (4 bytes)│ JSON payload (MinerMessage) │
│ big-endian u32 │ │
└─────────────────┴─────────────────────────────────┘
```
Maximum message size: 16 MB
## Data Types
See the `resonance-miner-api` crate for the canonical Rust definitions of these structures.
See the `quantus-miner-api` crate for the canonical Rust definitions.
- `job_id`: String (UUID recommended) - Unique identifier for a specific mining task, generated by the node.
- `mining_hash`: String (64 hex chars, no 0x) - The header hash for which to find a nonce.
- `difficulty`: String (u64 as string) - The target difficulty for the mining job.
- `nonce_start`: String (128 hex chars, no 0x) - The starting nonce value (inclusive) for the search range.
- `nonce_end`: String (128 hex chars, no 0x) - The ending nonce value (inclusive) for the search range.
- `status`: Enum (`ApiResponseStatus`) - Indicates the state or result of an API call.
- `message`: String (optional) - Provides details for `Error` status responses.
- `nonce`: String (Hex, no 0x) - Represents the `U512` value of the current or winning nonce.
- `work`: String (128 hex chars, no 0x) - Represents the winning nonce as `[u8; 64]`. This is the value the node needs for verification.
- `hash_count`: Number (u64) - Number of nonces checked by the miner for the job.
- `elapsed_time`: Number (f64) - Time in seconds the miner spent on the job.
### MinerMessage (Enum)
## Endpoints
```rust
pub enum MinerMessage {
NewJob(MiningRequest),
JobResult(MiningResult),
}
```
### 1. Submit Mining Job
### MiningRequest
- **Endpoint:** `POST /mine`
- **Description:** The node requests the external miner to start searching for a valid nonce.
- **Request Body (`MiningRequest`):**
```json
{
"job_id": "...",
"mining_hash": "...",
"difficulty": "...",
"nonce_start": "...",
"nonce_end": "..."
}
```
- **Response Body (`MiningResponse`):**
- Success (200 OK):
```json
{
"status": "accepted",
"job_id": "..."
}
```
- Error (400 Bad Request - Invalid Input / 409 Conflict - Duplicate Job ID):
```json
{
"status": "error",
"job_id": "...",
"message": "..." // e.g., "Job already exists", "Invalid mining_hash (...)"
}
```
| Field | Type | Description |
|-------|------|-------------|
| `job_id` | String | Unique identifier (UUID recommended) |
| `mining_hash` | String | Header hash (64 hex chars, no 0x prefix) |
| `distance_threshold` | String | Difficulty (U512 as decimal string) |
### 2. Get Job Result
Note: Nonce range is not specified - each miner independently selects a random starting point.
- **Endpoint:** `GET /result/{job_id}`
- **Description:** The node polls the external miner to check the status and retrieve the result.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to query.
- **Response Body (`MiningResult`):**
- Job Completed (200 OK):
```json
{
"status": "completed",
"job_id": "...",
"nonce": "...", // U512 hex value of winning nonce
"work": "...", // [u8; 64] hex value of winning nonce
"hash_count": ..., // u64
"elapsed_time": ... // f64 seconds
}
```
- Job Still Running (200 OK):
```json
{
"status": "running",
"job_id": "...",
"nonce": "...", // Current nonce being checked (U512 hex)
"work": null,
"hash_count": ..., // u64
"elapsed_time": ... // f64 seconds
}
```
- Job Failed (e.g., nonce range exhausted) (200 OK):
```json
{
"status": "failed",
"job_id": "...",
"nonce": "...", // Final nonce checked (U512 hex)
"work": null,
"hash_count": ..., // u64
"elapsed_time": ... // f64 seconds
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "...",
"nonce": null,
"work": null,
"hash_count": 0,
"elapsed_time": 0.0
}
```
### MiningResult
### 3. Cancel Mining Job
| Field | Type | Description |
|-------|------|-------------|
| `status` | ApiResponseStatus | Result status (see below) |
| `job_id` | String | Job identifier |
| `nonce` | Option<String> | Winning nonce (U512 hex, no 0x prefix) |
| `work` | Option<String> | Winning nonce as bytes (128 hex chars) |
| `hash_count` | u64 | Number of nonces checked |
| `elapsed_time` | f64 | Time spent mining (seconds) |
- **Endpoint:** `POST /cancel/{job_id}`
- **Description:** The node requests the external miner to stop working on a specific job.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to cancel.
- **Request Body:** (Empty)
- **Response Body (`MiningResponse`):
- Success (200 OK):
```json
{
"status": "cancelled",
"job_id": "..."
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "..."
}
```
### ApiResponseStatus (Enum)
| Value | Description |
|-------|-------------|
| `completed` | Valid nonce found |
| `failed` | Nonce range exhausted without finding solution |
| `cancelled` | Job was cancelled (new job received) |
| `running` | Job still in progress (not typically sent) |
## Protocol Flow
### Normal Mining Flow
```
Miner Node
│ │
│──── QUIC Connect ─────────────────────────►│
│◄─── Connection Established ────────────────│
│ │
│◄─── NewJob { job_id: "abc", ... } ─────────│
│ │
│ (picks random nonce, starts mining) │
│ │
│──── JobResult { job_id: "abc", ... } ─────►│ (found solution!)
│ │
│ (node submits block, gets new work) │
│ │
│◄─── NewJob { job_id: "def", ... } ─────────│
│ │
```
### Job Cancellation (Implicit)
When a new block arrives before the miner finds a solution, the node simply sends a new `NewJob`. The miner automatically cancels the previous job:
```
Miner Node
│ │
│◄─── NewJob { job_id: "abc", ... } ─────────│
│ │
│ (mining "abc") │
│ │
│ (new block arrives at node!) │
│ │
│◄─── NewJob { job_id: "def", ... } ─────────│
│ │
│ (cancels "abc", starts "def") │
│ │
│──── JobResult { job_id: "def", ... } ─────►│
```
### Miner Connect During Active Job
When a miner connects while a job is active, it immediately receives the current job:
```
Miner (new) Node
│ │ (already mining job "abc")
│──── QUIC Connect ─────────────────────────►│
│◄─── Connection Established ────────────────│
│◄─── NewJob { job_id: "abc", ... } ─────────│ (current job sent immediately)
│ │
│ (joins mining effort) │
```
### Stale Result Handling
If a result arrives for an old job, the node discards it:
```
Miner Node
│ │
│◄─── NewJob { job_id: "abc", ... } ─────────│
│ │
│◄─── NewJob { job_id: "def", ... } ─────────│ (almost simultaneous)
│ │
│──── JobResult { job_id: "abc", ... } ─────►│ (stale, node ignores)
│ │
│──── JobResult { job_id: "def", ... } ─────►│ (current, node uses)
```
## Configuration
### Node
```bash
# Listen for external miner connections on port 9833
quantus-node --miner-listen-port 9833
```
### Miner
```bash
# Connect to node
quantus-miner serve --node-addr 127.0.0.1:9833
```
## TLS Configuration
The node generates a self-signed TLS certificate at startup. The miner skips certificate verification by default (insecure mode). For production deployments, consider:
1. **Certificate pinning**: Configure the miner to accept only specific certificate fingerprints
2. **Proper CA**: Use certificates signed by a trusted CA
3. **Network isolation**: Run node and miner on a private network
## Error Handling
### Connection Loss
The miner automatically reconnects with exponential backoff:
- Initial delay: 1 second
- Maximum delay: 30 seconds
The node continues operating with remaining connected miners.
### Validation Errors
If the miner receives an invalid `MiningRequest`, it sends a `JobResult` with status `failed`.
## Notes
- All hex values (`mining_hash`, `nonce_start`, `nonce_end`, `nonce`, `work`) should be sent **without** the `0x` prefix.
- The miner must implement the validation logic defined in `qpow_math::is_valid_nonce`.
- The node relies primarily on the `work` field in the `MiningResult` (when status is `completed`) for constructing the `QPoWSeal`.
# External Miner Protocol Specification
This document defines the JSON-based HTTP protocol for communication between the node and an external QPoW miner.
## Overview
The node delegates the mining task to an external service. The node provides the necessary parameters (mining hash, difficulty, and a nonce range) and the external miner searches for a valid nonce within that range. The miner returns the nonce and the resulting work hash when a solution is found.
## Data Types
- `job_id`: String (UUID recommended) - Identifier for a specific mining task.
- `mining_hash`: String (Hex-encoded, 32-byte hash, H256) - The hash derived from the block header data that the miner needs to solve.
- `difficulty`: String (Decimal representation of u64) - The target difficulty for the block.
- `nonce_start`: String (Hex-encoded, 64-byte value, U512) - The starting nonce value (inclusive).
- `nonce_end`: String (Hex-encoded, 64-byte value, U512) - The ending nonce value (inclusive).
- `nonce`: String (Hex-encoded, 64-byte value, U512) - The solution found by the miner.
- `work`: String (Hex-encoded, 32-byte hash, H256) - The hash resulting from the combination of `mining_hash` and `nonce`, meeting the difficulty requirement.
- `status`: String Enum - Indicates the state or result of an API call.
## Endpoints
### 1. Start Mining Job
- **Endpoint:** `POST /mine`
- **Description:** The node requests the external miner to start searching for a valid nonce within the specified range for the given parameters.
- **Request Body (application/json):**
```json
{
"job_id": "...", // String (UUID), generated by the node
"mining_hash": "...", // Hex String (H256)
"difficulty": "...", // String (u64 decimal)
"nonce_start": "...", // Hex String (U512 hex)
"nonce_end": "..." // Hex String (U512 hex)
}
```
- **Response Body (application/json):**
- Success (200 OK):
```json
{
"status": "accepted",
"job_id": "..." // String (UUID), confirming the job ID received
}
```
- Error (e.g., 400 Bad Request, 500 Internal Server Error):
```json
{
"status": "rejected",
"reason": "..." // String (Description of error)
}
```
### 2. Get Job Result
- **Endpoint:** `GET /result/{job_id}`
- **Description:** The node polls the external miner to check the status and retrieve the result of a previously submitted job.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to query.
- **Response Body (application/json):**
- Solution Found (200 OK):
```json
{
"status": "found",
"job_id": "...", // String (UUID)
"nonce": "...", // Hex String (U512 hex)
"work": "CAFEBABE01.." // Hex String (H256 hex)
}
```
- Still Working (200 OK):
```json
{
"status": "working",
"job_id": "..." // String (UUID)
}
```
- Job Stale/Cancelled (200 OK): Indicates the job is no longer valid (e.g., the node requested cancellation or submitted work for a newer block).
```json
{
"status": "stale",
"job_id": "..." // String (UUID)
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "..." // String (UUID)
}
```
### 3. Cancel Mining Job
- **Endpoint:** `POST /cancel/{job_id}`
- **Description:** The node requests the external miner to stop working on a specific job. This is typically used when the node receives a new block or its mining parameters change, making the old job obsolete.
- **Path Parameter:**
- `job_id`: String (UUID) - The ID of the job to cancel.
- **Request Body:** (Empty)
- **Response Body (application/json):**
- Success (200 OK):
```json
{
"status": "cancelled",
"job_id": "..." // String (UUID)
}
```
- Job Not Found (404 Not Found):
```json
{
"status": "not_found",
"job_id": "..." // String (UUID)
}
```
## Notes
- The external miner should iterate from `nonce_start` up to and including `nonce_end` when searching for a valid nonce.
- The miner should return the `nonce` and the calculated `work` hash when a solution is found.
- The node uses the returned `nonce` and `work` (along with the fetched `difficulty`) to construct the `QPoWSeal` and submit it.
- The external miner should not need to know anything about the runtime or the code; it only needs to perform the nonce search and return the results.
- All hex values should be sent **without** the `0x` prefix
- The miner implements validation logic from `qpow_math::is_valid_nonce`
- The node uses the `work` field from `MiningResult` to construct `QPoWSeal`
- ALPN protocol identifier: `quantus-miner`
- Each miner independently generates a random nonce starting point using cryptographically secure randomness
- With a 512-bit nonce space, collision between miners is statistically impossible
+25 -23
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,8 +35,8 @@ 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 |
| `--port <PORT>` | `MINER_PORT` | HTTP API port | 9833 |
| `--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 Mining
@@ -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,32 +69,34 @@ 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
```
## API Endpoints
## Protocol
- `POST /mine`: Submit mining job
- `GET /result/{job_id}`: Get job status/result
- `POST /cancel/{job_id}`: Cancel job
The miner uses a QUIC-based protocol for communication with the node:
Full API specification: `api/openapi.yaml`
- **Transport**: QUIC with TLS 1.3 (self-signed certificates)
- **Port**: 9833 (default)
- **Messages**: `NewJob` (from node) and `JobResult` (from miner)
For full protocol specification, see `EXTERNAL_MINER_PROTOCOL.md`.
## Docker
@@ -107,7 +109,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 +119,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
```
-242
View File
@@ -1,242 +0,0 @@
openapi: 3.0.0
info:
title: Quantus External Miner API
version: 0.1.0
description: API for managing QPoW mining jobs for the Resonance Network external miner service.
paths:
/mine:
post:
summary: Submit a new mining job
description: Accepts a mining job request and adds it to the queue if valid.
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/MiningRequest'
responses:
'200':
description: Job accepted successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseAccepted'
'400':
description: Invalid request (e.g., missing fields, invalid format).
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseError'
'409':
description: Conflict (e.g., duplicate job ID).
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseError'
/result/{job_id}:
get:
summary: Get mining job status and result
description: Retrieves the current status and potentially the found nonce for a given job ID.
parameters:
- name: job_id
in: path
required: true
description: The ID of the mining job to retrieve.
schema:
type: string
format: uuid
responses:
'200':
description: Job status retrieved successfully (could be running, completed, or failed).
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResult'
'404':
description: Job ID not found.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResultNotFound'
/cancel/{job_id}:
post:
summary: Cancel a mining job
description: Attempts to cancel an ongoing mining job.
parameters:
- name: job_id
in: path
required: true
description: The ID of the mining job to cancel.
schema:
type: string
format: uuid
responses:
'200':
description: Job cancelled successfully.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseCancelled'
'404':
description: Job ID not found.
content:
application/json:
schema:
$ref: '#/components/schemas/MiningResponseNotFound'
components:
schemas:
ApiResponseStatus:
type: string
description: Status code indicating the result of an API call or the state of a mining job.
enum:
- accepted
- running
- completed
- failed
- cancelled
- not_found
- error
example: running
MiningRequest:
type: object
required:
- job_id
- mining_hash
- difficulty
- nonce_start
- nonce_end
properties:
job_id:
type: string
format: uuid
description: Unique identifier for the mining job (UUID format recommended).
example: "123e4567-e89b-12d3-a456-426614174000"
mining_hash:
type: string
description: The header hash to mine on (hex encoded, 64 characters, no 0x).
example: "a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0"
pattern: '^[a-fA-F0-9]{64}$'
difficulty:
type: string
description: Target difficulty for the mining job (string representation of a u64).
example: "10000000000"
nonce_start:
type: string
description: Starting nonce value (hex encoded, 128 characters, no 0x).
example: "00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000"
pattern: '^[a-fA-F0-9]{128}$'
nonce_end:
type: string
description: Ending nonce value (hex encoded, 128 characters, no 0x).
example: "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
pattern: '^[a-fA-F0-9]{128}$'
MiningResponseBase:
type: object
required:
- status
- job_id
properties:
status:
$ref: '#/components/schemas/ApiResponseStatus'
job_id:
type: string
format: uuid
description: The ID of the job associated with the response.
example: "123e4567-e89b-12d3-a456-426614174000"
MiningResponseAccepted:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: accepted
MiningResponseCancelled:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: cancelled
MiningResponseError:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: error
message:
type: string
description: Details about the error that occurred.
example: "Job already exists"
MiningResponseNotFound:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: not_found
MiningResult:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
required:
- hash_count
- elapsed_time
properties:
nonce:
type: string
nullable: true
description: The current or winning nonce (U512 hex, no 0x).
example: "1a2b3c"
pattern: '^[a-fA-F0-9]+$'
work:
type: string
nullable: true
description: The winning nonce ([u8; 64] hex, 128 chars, no 0x). Only present if status is 'completed'.
example: "000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001a2b3c"
pattern: '^[a-fA-F0-9]{128}$'
hash_count:
type: integer
format: uint64
description: Number of nonces checked so far for this job.
example: 123456
elapsed_time:
type: number
format: double
description: Time elapsed (in seconds) since the job started.
example: 15.732
MiningResultNotFound:
allOf:
- $ref: '#/components/schemas/MiningResponseBase'
- type: object
properties:
status:
example: not_found
nonce:
type: string
nullable: true
example: null
work:
type: string
nullable: true
example: null
hash_count:
type: integer
format: uint64
example: 0
elapsed_time:
type: number
format: double
example: 0.0
-3
View File
@@ -20,9 +20,6 @@ simd-poseidon2 = ["pow-core/simd-poseidon2"]
[dependencies]
pow-core = { path = "../pow-core" }
primitive-types = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
hex = { workspace = true }
+24 -265
View File
@@ -1,23 +1,14 @@
#![deny(rust_2018_idioms)]
#![forbid(unsafe_code)]
//! CPU mining engine scaffolding and trait definition.
//! CPU mining engine for Quantus.
//!
//! This crate defines a minimal `MinerEngine` trait so the service layer can
//! orchestrate mining without knowing about specific math/device details,
//! plus a baseline CPU implementation that uses the reference path in `pow-core`.
//!
//! The baseline engine performs a straightforward linear scan across an inclusive
//! nonce range, computing distance per nonce using the context (header-derived)
//! constants. It is intended as a correctness reference and may be replaced at
//! runtime by faster engines (e.g., incremental CPU path, CUDA/OpenCL engines).
//! This crate defines the `MinerEngine` trait for mining orchestration,
//! plus a fast CPU implementation using optimized paths from `pow-core`.
use core::cmp::Ordering;
use pow_core::{is_valid_nonce, JobContext};
use pow_core::JobContext;
use primitive_types::U512;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
use std::time::Duration;
use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
/// An inclusive nonce range to search.
#[derive(Clone, Debug)]
@@ -44,13 +35,6 @@ pub enum FoundOrigin {
}
/// Status from an engine search.
///
/// For synchronous `search_range` calls, the final outcome will be one of:
/// - `Found`
/// - `Exhausted`
/// - `Cancelled`
///
/// `Running` is included for potential future async/streaming engines.
#[derive(Clone, Debug)]
pub enum EngineStatus {
Running {
@@ -72,106 +56,22 @@ pub enum EngineStatus {
/// Abstract mining engine interface.
///
/// The service layer depends only on this trait to manage jobs.
/// Different engines (baseline CPU, optimized CPU, CUDA, OpenCL) can implement
/// this trait and be selected at runtime via configuration.
/// Different engines (CPU, CUDA, OpenCL) can implement this trait.
pub trait MinerEngine: Send + Sync {
/// Human-readable engine name (for logs/metrics).
/// Human-readable engine name (for logs).
fn name(&self) -> &'static str;
/// Prepare a precomputed context for a job (header + difficulty).
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext;
/// Search an inclusive nonce range with cancellation support.
///
/// Implementations should:
/// - Respect `cancel` promptly to minimize wasted work after a solution is found.
/// - Return `Found` with a `Candidate` on success.
/// - Return `Exhausted` if the range is fully searched without a solution.
/// - Return `Cancelled` if `cancel` was observed during the search.
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus;
/// Enable downcasting to concrete engine types.
fn as_any(&self) -> &dyn std::any::Any;
}
/// Baseline CPU engine.
///
/// This implementation uses the reference path from `pow-core` for distance
/// computation and scans the range linearly, one nonce at a time.
#[derive(Default)]
pub struct BaselineCpuEngine;
impl BaselineCpuEngine {
pub fn new() -> Self {
Self
}
}
impl MinerEngine for BaselineCpuEngine {
fn name(&self) -> &'static str {
"cpu-baseline"
}
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
JobContext::new(header_hash, difficulty)
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
// Ensure start <= end (inclusive range). If not, treat as exhausted.
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
let mut current = range.start;
let mut hash_count: u64 = 0;
loop {
// Cancellation check
if cancel.load(AtomicOrdering::Relaxed) {
return EngineStatus::Cancelled { hash_count };
}
// Compute hash for this nonce using Bitcoin-style double Poseidon2
let (is_valid, hash) =
is_valid_nonce(ctx.header, current.to_big_endian(), ctx.difficulty);
hash_count = hash_count.saturating_add(1);
// Check if it meets difficulty target
if is_valid {
let work = current.to_big_endian();
let candidate = Candidate {
nonce: current,
work,
hash,
};
return EngineStatus::Found {
candidate,
hash_count,
origin: FoundOrigin::Cpu,
};
}
// Advance or finish
match current.cmp(&range.end) {
Ordering::Less => {
current = current.saturating_add(U512::one());
}
_ => {
// End of inclusive range reached
break EngineStatus::Exhausted { hash_count };
}
}
}
}
}
// Re-export commonly used items for convenience by consumers.
// Fast CPU engine using incremental pow-core helpers (init_worker_y0 + step_mul)
/// Fast CPU engine using optimized pow-core helpers.
#[derive(Default)]
pub struct FastCpuEngine;
@@ -197,7 +97,6 @@ impl MinerEngine for FastCpuEngine {
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
use pow_core::{hash_from_nonce, is_valid_hash, step_nonce};
// Ensure start <= end (inclusive range). If not, treat as exhausted.
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
@@ -206,12 +105,10 @@ impl MinerEngine for FastCpuEngine {
let mut hash_count: u64 = 0;
loop {
// Cancellation check
if cancel.load(AtomicOrdering::Relaxed) {
return EngineStatus::Cancelled { hash_count };
}
// Compute hash using Bitcoin-style double Poseidon2
let hash = hash_from_nonce(ctx, current);
hash_count = hash_count.saturating_add(1);
@@ -232,7 +129,6 @@ impl MinerEngine for FastCpuEngine {
break;
}
// Advance to next nonce
current = step_nonce(current);
}
@@ -240,140 +136,8 @@ impl MinerEngine for FastCpuEngine {
}
}
#[derive(Default)]
pub struct ChainManipulatorEngine {
/// Base sleep per batch in nanoseconds; actual sleep increases linearly with job count.
pub base_delay_ns: u64,
/// Number of nonce attempts between sleeps.
pub step_batch: u64,
/// Optional cap for solved-block throttling (sleep index will not exceed this).
pub throttle_cap: Option<u64>,
/// Monotonically increasing solved-block counter used to scale throttling.
/// Public so the service can initialize from CLI (pick up where we left off).
pub job_index: AtomicU64,
}
impl ChainManipulatorEngine {
pub fn new() -> Self {
// Start fast: first block has no throttle (job_index = 0 -> 0ns sleep),
// then 0.5ms per batch at block 1, 1.0ms at block 2, etc.
Self {
base_delay_ns: 500_000, // 0.5 ms
step_batch: 10_000, // sleep every 10k nonce checks
throttle_cap: None, // unlimited by default
job_index: AtomicU64::new(0),
}
}
}
impl MinerEngine for ChainManipulatorEngine {
fn name(&self) -> &'static str {
"cpu-chain-manipulator"
}
fn prepare_context(&self, header_hash: [u8; 32], difficulty: U512) -> JobContext {
// Per-block throttling: do NOT increment here. We increment on Found (i.e., when a block is solved).
let ctx = JobContext::new(header_hash, difficulty);
// Debug: log current throttle state at job start
log::debug!(
target: "miner",
"manipulator throttle start: solved_blocks={}, sleep_ns_per_batch={}, step_batch={}",
self.job_index.load(AtomicOrdering::Relaxed),
self.base_delay_ns.saturating_mul(self.job_index.load(AtomicOrdering::Relaxed)),
self.step_batch
);
ctx
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn search_range(&self, ctx: &JobContext, range: Range, cancel: &AtomicBool) -> EngineStatus {
use pow_core::{hash_from_nonce, is_valid_hash, step_nonce};
if range.start > range.end {
return EngineStatus::Exhausted { hash_count: 0 };
}
// Bitcoin-style hashing path
let mut current = range.start;
let mut hash_count: u64 = 0;
let mut batch_counter: u64 = 0;
// Per-block throttling: derived from blocks solved so far (apply cap if configured).
let mut solved_blocks = self.job_index.load(AtomicOrdering::Relaxed);
if let Some(cap) = self.throttle_cap {
if solved_blocks > cap {
solved_blocks = cap;
}
}
let sleep_ns = self.base_delay_ns.saturating_mul(solved_blocks);
let do_sleep = sleep_ns > 0;
loop {
if cancel.load(AtomicOrdering::Relaxed) {
return EngineStatus::Cancelled { hash_count };
}
// Compute hash using Bitcoin-style double Poseidon2
let hash = hash_from_nonce(ctx, current);
hash_count = hash_count.saturating_add(1);
batch_counter = batch_counter.saturating_add(1);
// Optional detailed debug of throttle progression within a job
#[allow(unused_variables)]
let _dbg_batch = batch_counter;
if is_valid_hash(ctx, hash) {
let work = current.to_big_endian();
// Increment solved-block counter so the NEXT block throttles more.
let _new_idx = self.job_index.fetch_add(1, AtomicOrdering::Relaxed) + 1;
{
let capped = if let Some(cap) = self.throttle_cap {
std::cmp::min(_new_idx, cap)
} else {
_new_idx
};
log::debug!(
target: "miner",
"manipulator throttle increment: solved_blocks={} (next sleep_ns_per_batch={}, cap={:?})",
_new_idx,
self.base_delay_ns.saturating_mul(capped),
self.throttle_cap
);
}
return EngineStatus::Found {
candidate: Candidate {
nonce: current,
work,
hash,
},
hash_count,
origin: FoundOrigin::Cpu,
};
}
// Throttle after each batch to artificially slow down based on blocks solved so far.
if do_sleep && batch_counter >= self.step_batch {
std::thread::sleep(Duration::from_nanos(sleep_ns));
batch_counter = 0;
}
// Advance
if current < range.end {
current = step_nonce(current);
} else {
break EngineStatus::Exhausted { hash_count };
}
}
}
}
pub use {
BaselineCpuEngine as DefaultEngine, Candidate as EngineCandidate,
ChainManipulatorEngine as ChainEngine, FastCpuEngine as FastEngine, Range as EngineRange,
};
// Re-exports for convenience
pub use {Candidate as EngineCandidate, Range as EngineRange};
#[cfg(test)]
mod tests {
@@ -381,33 +145,25 @@ mod tests {
use primitive_types::U512;
use std::sync::atomic::AtomicBool;
fn make_ctx() -> JobContext {
let header = [1u8; 32];
let difficulty = U512::from(1u64); // easy difficulty for "found" parity test
JobContext::new(header, difficulty)
}
#[test]
fn engine_returns_exhausted_when_no_solution_in_range() {
// Use a very hard difficulty to make solutions effectively impossible in a tiny range.
let header = [2u8; 32];
let difficulty = U512::MAX;
let difficulty = U512::MAX; // impossible difficulty
let ctx = JobContext::new(header, difficulty);
let range = Range {
start: U512::from(1u64),
end: U512::from(1000u64), // small range; probability of accidental match is negligible
end: U512::from(1000u64),
};
let cancel = AtomicBool::new(false);
let baseline = BaselineCpuEngine::new();
let engine = FastCpuEngine::new();
let status = baseline.search_range(&ctx, range.clone(), &cancel);
let status = engine.search_range(&ctx, range.clone(), &cancel);
match status {
EngineStatus::Exhausted { hash_count } => {
// Inclusive range length = end - start + 1
let expected = (range.end - range.start + U512::one()).as_u64();
assert_eq!(hash_count, expected, "hash_count should equal range length");
assert_eq!(hash_count, expected);
}
other => panic!("expected Exhausted, got {other:?}"),
}
@@ -415,19 +171,22 @@ mod tests {
#[test]
fn engine_respects_immediate_cancellation() {
let ctx = make_ctx();
let header = [1u8; 32];
let difficulty = U512::from(1u64);
let ctx = JobContext::new(header, difficulty);
let range = Range {
start: U512::from(0u64),
end: U512::from(1_000_000u64),
};
let cancel = AtomicBool::new(true); // cancelled before starting
let baseline = BaselineCpuEngine::new();
let status = baseline.search_range(&ctx, range, &cancel);
let cancel = AtomicBool::new(true); // pre-cancelled
let engine = FastCpuEngine::new();
let status = engine.search_range(&ctx, range, &cancel);
match status {
EngineStatus::Cancelled { hash_count } => {
// Cancellation was pre-set; allow zero or near-zero work depending on timing.
assert_eq!(hash_count, 0, "expected no work when cancelled immediately");
assert_eq!(hash_count, 0);
}
other => panic!("expected Cancelled, got {other:?}"),
}
+6 -9
View File
@@ -11,26 +11,23 @@ description = "GPU mining engine implementation for the Quantus External Miner"
[features]
# Default to the baseline/reference implementation; enable others as needed.
default = ["baseline", "metrics"]
default = ["baseline"]
# Map engine features (pow-core dependency removed for now)
baseline = []
simd-poseidon2 = []
metrics = ["dep:metrics"]
[dependencies]
engine-cpu = { path = "../engine-cpu" }
pow-core = { path = "../pow-core" }
metrics = { path = "../metrics", optional = true }
primitive-types = { workspace = true }
log = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
qp-poseidon-core = { version = "1.0.2", features = ["p2"] }
qp-poseidon-constants = { version = "1.0.2" }
qp-plonky2 = { version = "1.1.1" }
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
+2 -2
View File
@@ -1,4 +1,4 @@
use engine_cpu::{BaselineCpuEngine, EngineStatus, MinerEngine, Range};
use engine_cpu::{EngineStatus, FastCpuEngine, MinerEngine, Range};
use engine_gpu::GpuEngine;
use primitive_types::U512;
use std::sync::atomic::AtomicBool;
@@ -16,7 +16,7 @@ fn main() {
// Use a fixed header and easy difficulty (1) so any nonce is valid
let header = [1u8; 32];
let difficulty = U512::from(u64::MAX); // High difficulty - no solutions expected
let cpu_engine = BaselineCpuEngine::new();
let cpu_engine = FastCpuEngine::new();
let ctx = cpu_engine.prepare_context(header, difficulty);
log::info!("Context prepared. Difficulty: {}", difficulty);
+35 -8
View File
@@ -82,8 +82,8 @@ pub async fn test_end_to_end_mining(
let zeros = vec![0u8; results_size];
queue.write_buffer(&results_buffer, 0, &zeros);
// Dispatch config buffer: [total_threads, nonces_per_thread, work_per_batch, threads_per_workgroup]
let dispatch_config_data: [u32; 4] = [256, 1, 1, 256];
// Dispatch config buffer: [total_threads, nonces_per_thread, total_nonces, cancel_check_interval]
let dispatch_config_data: [u32; 4] = [256, 1, 256, 10000];
let dispatch_config_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Dispatch Config Buffer"),
size: 16,
@@ -96,6 +96,20 @@ pub async fn test_end_to_end_mining(
bytemuck::cast_slice(&dispatch_config_data),
);
// Cancel flag buffer: 0 = running, 1 = cancel requested
let cancel_flag_data: [u32; 1] = [0];
let cancel_flag_buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Cancel Flag Buffer"),
size: 4,
usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
queue.write_buffer(
&cancel_flag_buffer,
0,
bytemuck::cast_slice(&cancel_flag_data),
);
// Load Shader
let shader_source = include_str!("mining.wgsl");
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
@@ -137,6 +151,10 @@ pub async fn test_end_to_end_mining(
binding: 4,
resource: dispatch_config_buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: cancel_flag_buffer.as_entire_binding(),
},
],
});
@@ -193,18 +211,27 @@ pub async fn test_end_to_end_mining(
let found_nonce = U512::from_little_endian(&nonce_bytes);
println!("GPU Nonce: {}", found_nonce);
assert_eq!(found_nonce, nonce_val, "Nonce mismatch!");
// Parse hash
// Parse hash from GPU result
let mut hash_bytes = [0u8; 64];
for i in 0..16 {
let bytes = result_u32s[17 + i].to_le_bytes();
hash_bytes[i * 4..(i + 1) * 4].copy_from_slice(&bytes);
}
let found_hash = U512::from_little_endian(&hash_bytes);
println!("GPU Hash: {:x}", found_hash);
let gpu_hash = U512::from_little_endian(&hash_bytes);
println!("GPU Hash: {:x}", gpu_hash);
// Verify: compute hash on CPU for the found nonce and compare
let cpu_verified_hash = hash_from_nonce(&ctx, found_nonce);
println!("CPU verified hash: {:x}", cpu_verified_hash);
assert_eq!(
gpu_hash, cpu_verified_hash,
"GPU hash doesn't match CPU verification for the found nonce!"
);
// Verify hash is below target (should always pass with difficulty 1)
assert!(gpu_hash <= ctx.target, "Hash is not below target!");
assert_eq!(found_hash, expected_hash, "Hash mismatch!");
println!("✅ End-to-End Test Passed!");
} else {
println!("❌ GPU did not find solution (should have passed with MAX target)");
+332 -231
View File
@@ -3,7 +3,7 @@
use engine_cpu::{Candidate, EngineStatus, FoundOrigin, MinerEngine, Range};
use futures::executor::block_on;
use pow_core::JobContext;
use pow_core::{format_hashrate, format_u512, JobContext};
use primitive_types::U512;
use std::cell::RefCell;
use std::sync::{
@@ -11,32 +11,147 @@ use std::sync::{
Arc,
};
/// Default interval for checking cancel flag in shader (in nonces)
const DEFAULT_CANCEL_CHECK_INTERVAL: u32 = 10_000;
/// 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
}
#[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,
cancel_buffer: wgpu::Buffer,
staging_buffer: wgpu::Buffer,
bind_group: wgpu::BindGroup,
}
pub struct GpuEngine {
contexts: Vec<Arc<GpuContext>>,
device_counter: AtomicUsize,
cancel_check_interval: u32,
}
// 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, total_nonces, cancel_check_interval] = 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,
});
// Cancel flag: single u32 (0 = running, 1 = cancel requested)
let cancel_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Cancel Buffer"),
size: 4,
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(),
},
wgpu::BindGroupEntry {
binding: 5,
resource: cancel_buffer.as_entire_binding(),
},
],
});
GpuResources {
header_buffer,
target_buffer,
start_nonce_buffer,
results_buffer,
dispatch_config_buffer,
cancel_buffer,
staging_buffer,
bind_group,
}
}
}
impl Default for GpuEngine {
@@ -47,10 +162,28 @@ impl Default for GpuEngine {
impl GpuEngine {
pub fn new() -> Self {
block_on(Self::init()).expect("Failed to initialize GPU engine")
block_on(Self::init(DEFAULT_CANCEL_CHECK_INTERVAL))
.expect("Failed to initialize GPU engine")
}
async fn init() -> Result<Self, Box<dyn std::error::Error>> {
/// Create a new GPU engine with a custom cancel check interval.
pub fn with_cancel_interval(cancel_check_interval: u32) -> Self {
block_on(Self::init(cancel_check_interval)).expect("Failed to initialize GPU engine")
}
/// Try to initialize the GPU engine, returning an error if initialization fails.
pub fn try_new() -> Result<Self, Box<dyn std::error::Error>> {
block_on(Self::init(DEFAULT_CANCEL_CHECK_INTERVAL))
}
/// Try to initialize the GPU engine with a custom cancel check interval.
pub fn try_with_cancel_interval(
cancel_check_interval: u32,
) -> Result<Self, Box<dyn std::error::Error>> {
block_on(Self::init(cancel_check_interval))
}
async fn init(cancel_check_interval: u32) -> Result<Self, Box<dyn std::error::Error>> {
log::info!("Initializing WGPU...");
let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
backends: wgpu::Backends::PRIMARY,
@@ -123,87 +256,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,58 +265,20 @@ impl GpuEngine {
device,
queue,
pipeline,
bind_group,
optimal_workgroups,
header_buffer,
target_buffer,
start_nonce_buffer,
results_buffer,
dispatch_config_buffer,
staging_buffer,
}));
}
log::info!("GPU engine initialized with {} devices", contexts.len());
// Set engine backend info for metrics
#[cfg(feature = "metrics")]
{
metrics::set_gpu_device_count(contexts.len() as i64);
for (i, adapter_info) in adapter_infos.iter().enumerate() {
let device_id = format!("gpu-{}", i);
let backend_str = format!("{:?}", adapter_info.backend);
let vendor_str = format!("{}", adapter_info.vendor);
let device_type_str = format!("{:?}", adapter_info.device_type);
let clean_name = adapter_info.name.replace(" ", "_").replace(",", "");
// Set general engine backend info
metrics::set_engine_backend(&device_id, &backend_str);
// Set detailed GPU device info
metrics::set_gpu_device_info(
&device_id,
&clean_name,
&backend_str,
&vendor_str,
&device_type_str,
);
// Log GPU device info for monitoring
log::info!(
"📊 GPU Device {}: {} | Backend: {:?} | Vendor: {} | Device Type: {:?}",
i,
adapter_info.name,
adapter_info.backend,
adapter_info.vendor,
adapter_info.device_type
);
}
}
log::info!(
"GPU engine initialized with {} devices (cancel check interval: {} nonces)",
contexts.len(),
cancel_check_interval
);
Ok(Self {
contexts,
device_counter: AtomicUsize::new(0),
cancel_check_interval,
})
}
@@ -271,6 +286,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 {
@@ -297,14 +320,17 @@ impl MinerEngine for GpuEngine {
return EngineStatus::Exhausted { hash_count: 0 };
}
// Check for pre-cancellation
if cancel.load(Ordering::Relaxed) {
return EngineStatus::Cancelled { hash_count: 0 };
}
// Use thread-local assignment for consistent worker-to-GPU mapping
let device_index = ASSIGNED_GPU_DEVICE.with(|assigned| {
let mut assigned_ref = assigned.borrow_mut();
if let Some(index) = *assigned_ref {
// This thread already has a GPU assigned
index
} else {
// First time this thread is calling search_range, assign a GPU device
let index = if self.contexts.len() == 1 {
0
} else {
@@ -321,21 +347,47 @@ impl MinerEngine for GpuEngine {
});
let gpu_ctx = &self.contexts[device_index];
log::debug!(
"GPU device {} processing range {}..={} (inclusive)",
// Calculate range size (capped at u32::MAX for dispatch config)
let range_size_u512 = range
.end
.saturating_sub(range.start)
.saturating_add(U512::one());
let range_size = if range_size_u512 > U512::from(u32::MAX) {
u32::MAX as u64
} else {
range_size_u512.as_u64()
};
log::info!(
target: "gpu_engine",
"GPU {} search started: range {}..{}, nonces: {}, cancel check interval: {}",
device_index,
range.start,
range.end
format_u512(range.start),
format_u512(range.end),
range_size,
self.cancel_check_interval
);
// Pre-convert header and target once (not per range)
// 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());
}
});
let resources = WORKER_RESOURCES
.with(|resources_cell| resources_cell.borrow().as_ref().unwrap().clone());
// Pre-convert header and target
let mut header_u32s = [0u32; 8];
for (i, item) in header_u32s.iter_mut().enumerate() {
let chunk = &ctx.header[i * 4..(i + 1) * 4];
*item = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
}
gpu_ctx.queue.write_buffer(
&gpu_ctx.header_buffer,
&resources.header_buffer,
0,
bytemuck::cast_slice(&header_u32s),
);
@@ -347,103 +399,69 @@ 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 };
}
// Thread configuration for a single dispatch over this range.
let threads_per_workgroup = 256u32; // Must match shader @workgroup_size(256)
// Calculate dispatch configuration
let threads_per_workgroup = 256u32;
let limits = gpu_ctx.device.limits();
let max_workgroups = limits.max_compute_workgroups_per_dimension;
// 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;
// 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 logical_threads = range_size.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 = (range_size.div_ceil(total_threads)).max(1) as u32;
// 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!(
log::debug!(
target: "gpu_engine",
"GPU dispatch configuration: total_range={} nonces, workgroups={}, threads={}, nonces_per_thread={}",
total_range_size,
"GPU {} dispatch config: {} workgroups × {} threads, {} nonces/thread",
device_index,
num_workgroups,
total_threads_u32,
threads_per_workgroup,
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;
// Dispatch config: [total_threads, nonces_per_thread, total_nonces, cancel_check_interval]
let dispatch_config = [
total_threads_u32,
total_threads as u32,
nonces_per_thread,
total_nonces_u32,
threads_per_workgroup,
range_size as u32,
self.cancel_check_interval,
];
// Write dispatch config
gpu_ctx.queue.write_buffer(
&gpu_ctx.dispatch_config_buffer,
&resources.dispatch_config_buffer,
0,
bytemuck::cast_slice(&dispatch_config),
);
// Starting nonce for this range.
// Write start nonce
let start_nonce_bytes = range.start.to_little_endian();
gpu_ctx
.queue
.write_buffer(&gpu_ctx.start_nonce_buffer, 0, &start_nonce_bytes);
.write_buffer(&resources.start_nonce_buffer, 0, &start_nonce_bytes);
// Reset results buffer to detect solutions from this dispatch.
// Reset results buffer
const RESULTS_SIZE: usize = (1 + 16 + 16) * 4;
const ZEROS: [u8; RESULTS_SIZE] = [0; RESULTS_SIZE];
gpu_ctx
.queue
.write_buffer(&gpu_ctx.results_buffer, 0, &ZEROS);
.write_buffer(&resources.results_buffer, 0, &ZEROS);
let total_start = std::time::Instant::now();
// Reset cancel buffer to 0 (not cancelled)
gpu_ctx
.queue
.write_buffer(&resources.cancel_buffer, 0, &[0u8; 4]);
// Create command buffer for this range
let search_start = std::time::Instant::now();
// Create and submit command buffer
let mut encoder = gpu_ctx
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
@@ -453,73 +471,156 @@ impl MinerEngine for GpuEngine {
timestamp_writes: None,
});
cpass.set_pipeline(&gpu_ctx.pipeline);
cpass.set_bind_group(0, &gpu_ctx.bind_group, &[]);
cpass.set_bind_group(0, &resources.bind_group, &[]);
cpass.dispatch_workgroups(num_workgroups, 1, 1);
}
encoder.copy_buffer_to_buffer(
&gpu_ctx.results_buffer,
&resources.results_buffer,
0,
&gpu_ctx.staging_buffer,
&resources.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,
// Poll GPU with periodic cancel checks
// We use a shared flag to know when the mapping is complete
let buffer_slice = resources.staging_buffer.slice(..);
let mapped = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let mapped_clone = mapped.clone();
buffer_slice.map_async(wgpu::MapMode::Read, move |result| {
if result.is_ok() {
mapped_clone.store(true, Ordering::Release);
}
});
loop {
// Check if cancelled
if cancel.load(Ordering::Relaxed) {
// Write cancel flag to GPU buffer
gpu_ctx
.queue
.write_buffer(&resources.cancel_buffer, 0, &1u32.to_le_bytes());
log::debug!(target: "gpu_engine", "GPU {} cancel flag propagated to GPU buffer", device_index);
}
// Poll with short timeout
let _ = gpu_ctx.device.poll(wgpu::PollType::Wait {
submission_index: None,
timeout: Some(std::time::Duration::from_millis(10)),
});
// Check if buffer mapping is complete
if mapped.load(Ordering::Acquire) {
break;
}
}
let search_elapsed = search_start.elapsed();
// Read results
let data = buffer_slice.get_mapped_range();
let result_u32s: &[u32] = bytemuck::cast_slice(&data);
let was_cancelled = cancel.load(Ordering::Relaxed);
// Calculate the actual number of nonces dispatched to the GPU
// This is total_threads * nonces_per_thread, capped at range_size
let dispatched_nonces = (total_threads * nonces_per_thread as u64).min(range_size);
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}",
device_index,
nonce,
hash
);
// Calculate hashes computed based on GPU parallel execution model.
//
// GPU threads process nonces in parallel, not sequentially:
// - Thread T processes nonces: start + T*nonces_per_thread + 0, +1, +2, ...
// - All threads run approximately in lockstep (SIMT execution)
//
// When thread T finds a solution at its iteration J:
// - logical_index = nonce - start = T * nonces_per_thread + J
// - winning_iteration = logical_index % nonces_per_thread = J
// - All threads have progressed to approximately iteration J
// - Total hashes ≈ total_threads * (J + 1)
//
// This gives a consistent hash rate regardless of which thread finds the solution.
let hashes_computed = if nonce >= range.start {
let logical_index = (nonce - range.start).as_u64();
let winning_iteration = logical_index % (nonces_per_thread as u64);
// All threads processed approximately (winning_iteration + 1) nonces each
(total_threads * (winning_iteration + 1)).min(dispatched_nonces)
} else {
// Shouldn't happen, but fall back to dispatched count
dispatched_nonces
};
drop(data);
gpu_ctx.staging_buffer.unmap();
resources.staging_buffer.unmap();
let hash_rate = hashes_computed as f64 / search_elapsed.as_secs_f64();
log::debug!(
target: "gpu_engine",
"GPU {} found solution! Nonce: {}, Hash: {} ({} hashes in {:.2}s, {})",
device_index,
format_u512(nonce),
format_u512(hash),
hashes_computed,
search_elapsed.as_secs_f64(),
format_hashrate(hash_rate)
);
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,
hash_count: hashes_computed,
origin: FoundOrigin::GpuG1,
};
}
drop(data);
gpu_ctx.staging_buffer.unmap();
resources.staging_buffer.unmap();
let total_time = total_start.elapsed();
if was_cancelled {
// For cancelled jobs, estimate hashes based on elapsed time and dispatched work.
// The shader checks cancel flag periodically, so we estimate based on how much
// of the dispatch likely completed. This is approximate but better than 0.
// We use the ratio of elapsed time to expected completion time.
// As a simple heuristic, if the GPU was running, it was doing work.
// We report dispatched_nonces as upper bound since the dispatch was submitted.
// In practice, cancellation happens quickly so this is often close to 0 useful hashes.
let estimated_hashes = dispatched_nonces;
log::info!(
target: "gpu_engine",
"GPU {} search cancelled after {:.2}s (~{} hashes dispatched)",
device_index,
search_elapsed.as_secs_f64(),
estimated_hashes
);
return EngineStatus::Cancelled {
hash_count: estimated_hashes,
};
}
// Range exhausted without finding solution - all dispatched nonces were processed
let hash_rate = dispatched_nonces as f64 / search_elapsed.as_secs_f64();
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 exhausted: {} hashes in {:.2}s ({})",
device_index,
dispatched_nonces,
search_elapsed.as_secs_f64(),
format_hashrate(hash_rate)
);
log::debug!(target: "gpu_engine", "GPU {} finished range with no solution.", device_index);
EngineStatus::Exhausted { hash_count }
EngineStatus::Exhausted {
hash_count: dispatched_nonces,
}
}
}
+2 -2
View File
@@ -93,8 +93,8 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
eprintln!("❌ Poseidon2 squeeze-twice tests failed: {}", e);
}
if let Err(e) = tests::test_double_hash(&device, &queue).await {
eprintln!("❌ Double hash tests failed: {}", e);
if let Err(e) = tests::test_hash_squeeze_twice(&device, &queue).await {
eprintln!("❌ Hash squeeze twice tests failed: {}", e);
}
if let Err(e) = end_to_end_tests::test_end_to_end_mining(&device, &queue).await {
+31 -20
View File
@@ -9,7 +9,7 @@ const GOLDILOCKS_PRIME_HIGH: u32 = 4294967295u; // High 32 bits (2^32 - 1)
// Poseidon2 constants
const WIDTH: u32 = 12u;
const RATE: u32 = 4u;
const RATE: u32 = 8u;
const EXTERNAL_ROUNDS: u32 = 4u;
const INTERNAL_ROUNDS: u32 = 22u;
@@ -194,7 +194,8 @@ const MDS_MATRIX_DIAG_12: array<array<u32, 2>, 12> = array<array<u32, 2>, 12>(
@group(0) @binding(1) var<storage, read> header: array<u32, 8>; // 32 bytes
@group(0) @binding(2) var<storage, read> start_nonce: array<u32, 16>; // 64 bytes
@group(0) @binding(3) var<storage, read> difficulty_target: array<u32, 16>; // 64 bytes (U512 target)
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 4>; // [total_threads (logical), nonces_per_thread, total_nonces (logical nonces in this dispatch), threads_per_workgroup]
@group(0) @binding(4) var<storage, read> dispatch_config: array<u32, 4>; // [total_threads, nonces_per_thread, total_nonces, cancel_check_interval]
@group(0) @binding(5) var<storage, read> cancel_flag: array<u32, 1>; // 0 = running, 1 = cancel requested
// Goldilocks field element represented as [limb0, limb1]
// where the value is limb0 + limb1*2^32
@@ -676,17 +677,17 @@ fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
state[i] = gf_zero();
}
// Convert input to field elements (25 total)
// Convert input to field elements (25 total: 24 from input + 1 terminator)
let input_felts = bytes_to_field_elements(input);
// Sponge construction matching CPU reference exactly:
// CPU processes field elements using push_to_buf() which absorbs in chunks of RATE=4
// CPU processes field elements using push_to_buf() which absorbs in chunks of RATE=8
// Process first 24 elements (6 complete chunks of 4)
for (var chunk = 0u; chunk < 6u; chunk++) {
// Absorb 4 elements for this chunk
for (var i = 0u; i < 4u; i++) {
let felt_idx = chunk * 4u + i;
// Process first 24 elements (3 complete chunks of 8)
for (var chunk = 0u; chunk < 3u; chunk++) {
// Absorb 8 elements for this chunk
for (var i = 0u; i < 8u; i++) {
let felt_idx = chunk * 8u + i;
state[i] = gf_add(state[i], input_felts[felt_idx]);
}
// Permute after each complete chunk
@@ -694,17 +695,16 @@ fn poseidon2_hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
}
// Process remaining 1 element (partial chunk)
// Now we have element 24 (the padding marker = 1) remaining
// This simulates CPU's finalize_twice adding ONE to buffer position 0
state[0] = gf_add(state[0], input_felts[24u]); // Add the padding marker (should be 1)
// Now we have element 24 (the terminator = 1) remaining
state[0] = gf_add(state[0], input_felts[24u]); // Add the terminator (value 1)
// Add sponge padding marker (ONE) to the next position
state[1] = gf_add(state[1], gf_one());
// Final permutation (CPU calls permute after completing the block)
// Final permutation
poseidon2_permute(&state);
// First squeeze - get first 4 field elements
// First squeeze - get first 4 field elements (POSEIDON2_OUTPUT=4 -> 32 bytes)
let first_output = field_elements_to_bytes(array<GoldilocksField, 4>(
state[0], state[1], state[2], state[3]
));
@@ -783,10 +783,9 @@ fn poseidon2_hash_squeeze_twice_64(input: array<u32, 16>) -> array<u32, 16> {
return result;
}
// Double Poseidon2 hash (like Bitcoin's double SHA256)
fn double_hash(input: array<u32, 24>) -> array<u32, 16> {
let first_hash = poseidon2_hash_squeeze_twice(input);
return poseidon2_hash_squeeze_twice_64(first_hash);
// Single Poseidon2 hash with double squeeze (512-bit output)
fn hash_squeeze_twice(input: array<u32, 24>) -> array<u32, 16> {
return poseidon2_hash_squeeze_twice(input);
}
// Check if hash < target (U512 comparison)
@@ -805,16 +804,20 @@ fn is_below_target(hash: array<u32, 16>, difficulty_tgt: array<u32, 16>) -> bool
// Main mining kernel
@compute @workgroup_size(256)
fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
// If solution already found, exit early
// If solution already found or cancelled, exit early
if (atomicLoad(&results[0]) != 0u) {
return;
}
if (cancel_flag[0] != 0u) {
return;
}
let thread_id = global_id.x;
// Read dispatch configuration from buffer
let total_threads = dispatch_config[0]; // Total logical threads in this dispatch
let nonces_per_thread = dispatch_config[1]; // Nonces processed by each thread
let total_nonces = dispatch_config[2]; // Total logical nonces this dispatch should cover
let cancel_check_interval = dispatch_config[3]; // How often to check cancel flag
// Guard against threads beyond configured total_threads
if (thread_id >= total_threads) {
@@ -831,6 +834,14 @@ fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
break;
}
// Periodic checks for early exit
// Check cancel flag every cancel_check_interval iterations
if (cancel_check_interval > 0u && (j % cancel_check_interval) == 0u) {
if (cancel_flag[0] != 0u) {
return;
}
}
// Check if solution already found (early exit for entire dispatch)
if (atomicLoad(&results[0]) != 0u) {
return;
@@ -873,7 +884,7 @@ fn mining_main(@builtin(global_invocation_id) global_id: vec3<u32>) {
}
// Hash (Big Endian)
let hash_be = double_hash(input);
let hash_be = hash_squeeze_twice(input);
// Convert to Little Endian for difficulty check and storage
var hash_le: array<u32, 16>;
+24 -34
View File
@@ -1,8 +1,8 @@
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::serialization::{p2_backend::GF as P2Goldilocks, GoldiCompat};
use rand::{Rng, SeedableRng};
use wgpu::util::DeviceExt;
@@ -888,13 +888,17 @@ 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> {
felts
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.as_canonical_u64()))
.collect()
}
// Test 1: All zeros
let zero_input = [0u8; 96];
let zero_expected =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&zero_input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let zero_expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&zero_input));
vectors.push(BytesToFieldTestCase {
input_bytes: zero_input,
expected_felts: zero_expected,
@@ -902,11 +906,7 @@ fn generate_bytes_to_field_test_vectors() -> Vec<BytesToFieldTestCase> {
// Test 2: All 0xFF
let ff_input = [0xFFu8; 96];
let ff_expected =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&ff_input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let ff_expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&ff_input));
vectors.push(BytesToFieldTestCase {
input_bytes: ff_input,
expected_felts: ff_expected,
@@ -917,11 +917,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 =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&seq_input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let seq_expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&seq_input));
vectors.push(BytesToFieldTestCase {
input_bytes: seq_input,
expected_felts: seq_expected,
@@ -931,11 +927,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 =
qp_poseidon_core::serialization::injective_bytes_to_felts::<P2Goldilocks>(&input)
.into_iter()
.map(|g| GoldilocksField::from_noncanonical_u64(g.to_u64()))
.collect();
let expected = p3_to_p2(qp_poseidon_core::serialization::bytes_to_felts(&input));
vectors.push(BytesToFieldTestCase {
input_bytes: input,
expected_felts: expected,
@@ -1040,20 +1032,18 @@ fn print_test_failure_hex(
}
}
fn generate_double_hash_test_vectors() -> Vec<DoubleHashTestCase> {
fn generate_hash_squeeze_twice_test_vectors() -> Vec<DoubleHashTestCase> {
let mut vectors = Vec::new();
let mut rng = rand_chacha::ChaCha8Rng::seed_from_u64(0xDEADBEEF);
// Test 1-250: Random header and nonce combinations (25 tests)
// Test 1-250: Random header and nonce combinations
for i in 0..250 {
let mut input = [0u8; 96];
rng.fill(&mut input[..]);
let expected = {
let first_hash = qp_poseidon_core::hash_squeeze_twice(&input);
qp_poseidon_core::hash_squeeze_twice(&first_hash)
};
// Single hash with double squeeze (no longer double hash)
let expected = qp_poseidon_core::hash_squeeze_twice(&input);
vectors.push(DoubleHashTestCase {
input_96_bytes: input,
@@ -4344,11 +4334,11 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
results.print_summary()
}
pub async fn test_double_hash(
pub async fn test_hash_squeeze_twice(
device: &wgpu::Device,
queue: &wgpu::Queue,
) -> Result<(), Box<dyn std::error::Error>> {
let test_vectors = generate_double_hash_test_vectors();
let test_vectors = generate_hash_squeeze_twice_test_vectors();
let total_tests = test_vectors.len();
let mut passed_tests = 0;
let mut failed_tests = Vec::new();
@@ -4372,8 +4362,8 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
input_array[i] = input_data[input_offset + i];
}}
// Hash the input using double hash (96 bytes -> 64 bytes)
let hash_result = double_hash(input_array);
// Hash the input using single hash with double squeeze (96 bytes -> 64 bytes)
let hash_result = hash_squeeze_twice(input_array);
// Write full 64 bytes (16 u32s) of result as the hash
let output_offset = test_id * 16u;
@@ -4386,12 +4376,12 @@ fn main(@builtin(global_invocation_id) global_id: vec3<u32>) {{
);
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Double Hash Test Shader"),
label: Some("Hash Squeeze Twice Test Shader"),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("Double Hash Test Pipeline"),
label: Some("Hash Squeeze Twice Test Pipeline"),
layout: None,
module: &shader,
entry_point: Some("main"),
+2 -6
View File
@@ -6,11 +6,9 @@ publish = false
description = "Metrics and Prometheus exporter utilities for the Quantus External Miner"
[features]
# Provide a Registry by default; HTTP exporter is opt-in.
default = ["registry"]
registry = []
default = []
# Enable a Warp-based HTTP exporter (used when --metrics-port is provided).
http-exporter = ["dep:serde", "dep:serde_json", "dep:tokio", "dep:warp"]
http-exporter = ["dep:tokio", "dep:warp"]
[dependencies]
anyhow = { workspace = true }
@@ -21,5 +19,3 @@ prometheus = { version = "0.13", default-features = false }
# Optional HTTP exporter stack (enabled via `http-exporter` feature)
warp = { workspace = true, optional = true }
tokio = { workspace = true, features = ["full"], optional = true }
serde = { workspace = true, features = ["derive"], optional = true }
serde_json = { workspace = true, optional = true }
+213 -674
View File
@@ -1,55 +1,91 @@
#![deny(rust_2018_idioms)]
#![forbid(unsafe_code)]
//! Minimal metrics scaffolding for the Quantus External Miner.
//! Minimal metrics for the Quantus External Miner.
//!
//! - Provides a global Prometheus registry and a handful of default metrics.
//! - Exposes helper functions to update metrics from the service layer.
//! - Optionally runs a Warp-based HTTP endpoint (/metrics) when the
//! `http-exporter` feature is enabled. This is gated at runtime by
//! the presence of a metrics port in the CLI (`--metrics-port`).
//! Exposes a small set of Prometheus metrics for monitoring mining performance:
//! - `miner_hash_rate`: Total hash rate (CPU + GPU combined)
//! - `miner_cpu_hash_rate`: CPU-only hash rate
//! - `miner_gpu_hash_rate`: GPU-only hash rate
//! - `miner_hashes_total`: Total hashes computed (all time)
//! - `miner_active_jobs`: Currently running jobs (0 or 1)
//! - `miner_workers`: Total worker count
//! - `miner_cpu_workers`: Number of CPU workers
//! - `miner_gpu_devices`: Number of GPU devices
//! - `miner_effective_cpus`: Detected CPU cores
//!
//! Default metrics:
//! - miner_jobs_total{status} : number of jobs by terminal state
//! - miner_hashes_total : total nonces tested
//! - miner_hash_rate : current estimated hash rate (nonces/sec)
//! - miner_http_requests_total{code,endpoint} : HTTP request counts for miner API
//!
//! Notes:
//! - The HTTP exporter is spawned as a background task and does not block.
//! - If the `http-exporter` feature is disabled, `start_http_exporter` becomes
//! a no-op that returns immediately.
//! - When jobs/threads end, prefer removing gauge label children (series) rather than
//! writing a zero; this avoids scrape-timing artifacts and produces cleaner rollups.
//! - Service-level observability: expose an `active_jobs` gauge and a `mine_requests_total`
//! counter (labeled by result) to disambiguate "idle because no jobs" vs "actively mining".
//! Optionally runs a Warp-based HTTP endpoint (`/metrics`) when the
//! `http-exporter` feature is enabled.
use once_cell::sync::Lazy;
use prometheus::{
opts, Encoder, Gauge, GaugeVec, IntCounter, IntCounterVec, IntGauge, IntGaugeVec, Registry,
TextEncoder,
};
use prometheus::{IntCounter, IntGauge, Registry};
use std::sync::Mutex;
use std::time::Instant;
#[cfg(feature = "http-exporter")]
use {anyhow::Result, std::net::SocketAddr, warp::Filter};
use {
anyhow::Result,
prometheus::{Encoder, TextEncoder},
std::net::SocketAddr,
warp::Filter,
};
#[cfg(not(feature = "http-exporter"))]
use anyhow::Result;
use std::collections::{HashMap, HashSet};
use std::sync::Mutex;
use std::thread;
use std::time::{Duration, Instant};
// -------------------------------------------------------------------------------------
// Global Registry and Default Metrics
// -------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Global Registry
// ---------------------------------------------------------------------------
static REGISTRY: Lazy<Registry> = Lazy::new(Registry::new);
// -------------------------------------------------------------------------------------
// High-level service metrics
// -------------------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Hash Rate Metrics
// ---------------------------------------------------------------------------
static HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_hash_rate", "Total hash rate in hashes per second")
.expect("create miner_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_hash_rate");
g
});
static CPU_HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_cpu_hash_rate", "CPU hash rate in hashes per second")
.expect("create miner_cpu_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_cpu_hash_rate");
g
});
static GPU_HASH_RATE: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_gpu_hash_rate", "GPU hash rate in hashes per second")
.expect("create miner_gpu_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_hash_rate");
g
});
// ---------------------------------------------------------------------------
// Counter Metrics
// ---------------------------------------------------------------------------
static HASHES_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
let c = IntCounter::new("miner_hashes_total", "Total hashes computed")
.expect("create miner_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_hashes_total");
c
});
// ---------------------------------------------------------------------------
// Gauge Metrics
// ---------------------------------------------------------------------------
static ACTIVE_JOBS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new(
@@ -63,25 +99,37 @@ static ACTIVE_JOBS: Lazy<IntGauge> = Lazy::new(|| {
g
});
static ENGINE_BACKEND_INFO: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_engine_backend",
"Engine backend info (label-only gauge set to 1). Labels: engine, backend"
),
&["engine", "backend"],
)
.expect("create miner_engine_backend");
static WORKERS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_workers", "Total number of worker threads")
.expect("create miner_workers");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_engine_backend");
.expect("register miner_workers");
g
});
static CPU_WORKERS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_cpu_workers", "Number of CPU worker threads")
.expect("create miner_cpu_workers");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_cpu_workers");
g
});
static GPU_DEVICES: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new("miner_gpu_devices", "Number of GPU devices")
.expect("create miner_gpu_devices");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_devices");
g
});
static EFFECTIVE_CPUS: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new(
"miner_effective_cpus",
"Detected logical CPU capacity available to this process (cpuset-aware)",
"Detected logical CPU cores available to this process",
)
.expect("create miner_effective_cpus");
REGISTRY
@@ -90,676 +138,167 @@ static EFFECTIVE_CPUS: Lazy<IntGauge> = Lazy::new(|| {
g
});
static MINE_REQUESTS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_mine_requests_total",
"Count of /mine requests by result"
),
&["result"], // accepted, duplicate, invalid, error
)
.expect("create miner_mine_requests_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_mine_requests_total");
c
});
// ---------------------------------------------------------------------------
// Hash Rate Tracking
// ---------------------------------------------------------------------------
static JOBS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!("miner_jobs_total", "Number of jobs by status"),
&["status"],
)
.expect("create miner_jobs_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_jobs_total");
c
});
static HASHES_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
let c = IntCounter::new("miner_hashes_total", "Total nonces tested")
.expect("create miner_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_hashes_total");
c
});
static HASH_RATE: Lazy<Gauge> = Lazy::new(|| {
let g =
Gauge::new("miner_hash_rate", "Estimated hash rate (nonces per second)").expect("create");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_hash_rate");
g
});
static HTTP_REQUESTS_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_http_requests_total",
"HTTP requests count by endpoint and status code"
),
&["endpoint", "code"],
)
.expect("create miner_http_requests_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_http_requests_total");
c
});
/// Access the global Prometheus registry used by this crate.
// Engine-aware, per-job, and per-thread labeled metrics
pub fn set_effective_cpus(n: i64) {
EFFECTIVE_CPUS.set(n);
/// Tracks cumulative hashes to compute rolling hash rates.
///
/// The hash rate is computed as total_hashes / elapsed_time since mining started.
/// Call `reset()` when a new mining session begins to get accurate rates.
struct HashRateTracker {
/// Cumulative CPU hashes since last reset
cpu_total: u64,
/// Cumulative GPU hashes since last reset
gpu_total: u64,
/// When tracking started - set via reset() when mining begins
start_time: Option<Instant>,
}
static JOB_HASHES_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_job_hashes_total",
"Total nonces tested per job and engine"
),
&["engine", "job_id"],
)
.expect("create miner_job_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_job_hashes_total");
c
});
impl HashRateTracker {
fn new() -> Self {
Self {
cpu_total: 0,
gpu_total: 0,
start_time: None,
}
}
static JOB_HASH_RATE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_job_hash_rate",
"Estimated hash rate (nonces per second) per job and engine"
),
&["engine", "job_id"],
)
.expect("create miner_job_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_hash_rate");
g
});
/// Reset the tracker - call this when a new mining session starts.
fn reset(&mut self) {
self.cpu_total = 0;
self.gpu_total = 0;
self.start_time = Some(Instant::now());
}
static JOB_ESTIMATED_RATE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_job_estimated_rate",
"Estimated work rate (nonces per second) per engine and backend"
),
&["engine", "backend"],
)
.expect("create miner_job_estimated_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_estimated_rate");
g
});
fn record_cpu(&mut self, hashes: u64) {
self.cpu_total += hashes;
self.update_rates();
}
static CANDIDATES_FOUND_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_candidates_found_total",
"Total candidates found per job and engine"
),
&["engine", "job_id"],
)
.expect("create miner_candidates_found_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_candidates_found_total");
c
});
fn record_gpu(&mut self, hashes: u64) {
self.gpu_total += hashes;
self.update_rates();
}
static CANDIDATES_FALSE_POSITIVE_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_candidates_false_positive_total",
"Total false-positive candidates rejected by host re-verification per engine"
),
&["engine"],
)
.expect("create miner_candidates_false_positive_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_candidates_false_positive_total");
c
});
static SAMPLE_MISMATCH_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_sample_mismatch_total",
"Total decision parity mismatches between engine and host per engine"
),
&["engine"],
)
.expect("create miner_sample_mismatch_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_sample_mismatch_total");
c
});
static MISSED_WINNER_TOTAL: Lazy<IntCounter> = Lazy::new(|| {
let c = IntCounter::new(
"miner_gpu_g2_missed_winner_total",
"Host detected a winner in G2 batch sample but device did not flag early-exit",
)
.expect("create miner_gpu_g2_missed_winner_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_gpu_g2_missed_winner_total");
c
});
static FOUND_BY_ORIGIN_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_found_by_origin_total",
"Count of candidates found by origin per engine"
),
&["engine", "origin"],
)
.expect("create miner_found_by_origin_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_found_by_origin_total");
c
});
static THREAD_HASHES_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_thread_hashes_total",
"Total nonces tested per thread, job, and engine"
),
&["engine", "job_id", "thread_id"],
)
.expect("create miner_thread_hashes_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_thread_hashes_total");
c
});
static THREAD_HASH_RATE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_thread_hash_rate",
"Estimated hash rate (nonces per second) per thread, job, and engine"
),
&["engine", "job_id", "thread_id"],
)
.expect("create miner_thread_hash_rate");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_thread_hash_rate");
g
});
static JOBS_BY_ENGINE_TOTAL: Lazy<IntCounterVec> = Lazy::new(|| {
let c = IntCounterVec::new(
opts!(
"miner_jobs_by_engine_total",
"Number of jobs by engine and terminal status"
),
&["engine", "status"],
)
.expect("create miner_jobs_by_engine_total");
REGISTRY
.register(Box::new(c.clone()))
.expect("register miner_jobs_by_engine_total");
c
});
static JOB_STATUS_GAUGE: Lazy<IntGaugeVec> = Lazy::new(|| {
let g = IntGaugeVec::new(
opts!(
"miner_job_status",
"Job status gauge (set to 1 for current status)"
),
&["engine", "job_id", "status"],
)
.expect("create miner_job_status");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_status");
g
});
static JOB_FOUND_ORIGIN: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_job_found_origin",
"Per-job found candidate origin (0=unknown, 1=cpu, 2=gpu-g1, 3=gpu-g2)"
),
&["engine", "job_id", "origin"],
)
.expect("create miner_job_found_origin");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_job_found_origin");
g
});
// -------------------------------------------------------------------------------------
// GPU-specific metrics
// -------------------------------------------------------------------------------------
static GPU_DEVICE_COUNT: Lazy<IntGauge> = Lazy::new(|| {
let g = IntGauge::new(
"miner_gpu_devices_total",
"Number of GPU devices available for mining",
)
.expect("create miner_gpu_devices_total");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_devices_total");
g
});
static GPU_DEVICE_INFO: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_gpu_device_info",
"GPU device information (label-only gauge set to 1). Labels: device_id, name, backend, vendor, device_type"
),
&["device_id", "name", "backend", "vendor", "device_type"],
)
.expect("create miner_gpu_device_info");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_device_info");
g
});
static GPU_BATCH_SIZE: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_gpu_batch_size",
"Current GPU batch size (hashes per batch) per device"
),
&["device_id"],
)
.expect("create miner_gpu_batch_size");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_batch_size");
g
});
static GPU_WORKGROUPS: Lazy<GaugeVec> = Lazy::new(|| {
let g = GaugeVec::new(
opts!(
"miner_gpu_workgroups",
"Number of GPU workgroups dispatched per device"
),
&["device_id"],
)
.expect("create miner_gpu_workgroups");
REGISTRY
.register(Box::new(g.clone()))
.expect("register miner_gpu_workgroups");
g
});
pub fn default_registry() -> &'static Registry {
&REGISTRY
fn update_rates(&self) {
if let Some(start) = self.start_time {
let elapsed = start.elapsed().as_secs_f64();
// Require at least 0.1 seconds of data for meaningful rate
if elapsed >= 0.1 {
let cpu_rate = (self.cpu_total as f64 / elapsed) as i64;
let gpu_rate = (self.gpu_total as f64 / elapsed) as i64;
CPU_HASH_RATE.set(cpu_rate);
GPU_HASH_RATE.set(gpu_rate);
HASH_RATE.set(cpu_rate + gpu_rate);
}
}
}
}
// -------------------------------------------------------------------------------------
// Update Helpers (to be called from the service layer)
// -------------------------------------------------------------------------------------
static HASH_TRACKER: Lazy<Mutex<HashRateTracker>> =
Lazy::new(|| Mutex::new(HashRateTracker::new()));
/// Increment the total hashes counter by `n` (number of nonces tested).
// Labeled helpers for engine/job/thread metrics
//
// Service-level helpers
pub fn set_active_jobs(n: i64) {
ACTIVE_JOBS.set(n);
// ---------------------------------------------------------------------------
// Public API - Hash Recording
// ---------------------------------------------------------------------------
/// Reset the hash rate tracker - call this when mining starts.
/// This resets the cumulative hash counts and starts a fresh timing window.
pub fn reset_hash_tracker() {
if let Ok(mut tracker) = HASH_TRACKER.lock() {
tracker.reset();
}
}
pub fn set_engine_backend(engine: &str, backend: &str) {
ENGINE_BACKEND_INFO
.with_label_values(&[engine, backend])
.set(1.0);
/// Record CPU hashes and update hash rate metrics.
pub fn record_cpu_hashes(n: u64) {
HASHES_TOTAL.inc_by(n);
if let Ok(mut tracker) = HASH_TRACKER.lock() {
tracker.record_cpu(n);
}
}
pub fn inc_mine_requests(result: &str) {
MINE_REQUESTS_TOTAL.with_label_values(&[result]).inc();
/// Record GPU hashes and update hash rate metrics.
pub fn record_gpu_hashes(n: u64) {
HASHES_TOTAL.inc_by(n);
if let Ok(mut tracker) = HASH_TRACKER.lock() {
tracker.record_gpu(n);
}
}
pub fn inc_job_hashes(engine: &str, job_id: &str, n: u64) {
touch_job(engine, job_id);
JOB_HASHES_TOTAL
.with_label_values(&[engine, job_id])
.inc_by(n);
// ---------------------------------------------------------------------------
// Public API - Hash Rates (for direct setting, kept for compatibility)
// ---------------------------------------------------------------------------
/// Set the total hash rate (CPU + GPU combined).
pub fn set_hash_rate(rate: i64) {
HASH_RATE.set(rate);
}
pub fn set_job_hash_rate(engine: &str, job_id: &str, rate: f64) {
touch_job(engine, job_id);
JOB_HASH_RATE.with_label_values(&[engine, job_id]).set(rate);
/// Set the CPU-only hash rate.
pub fn set_cpu_hash_rate(rate: i64) {
CPU_HASH_RATE.set(rate);
}
pub fn set_job_estimated_rate_backend(engine: &str, backend: &str, rate: f64) {
JOB_ESTIMATED_RATE
.with_label_values(&[engine, backend])
.set(rate);
/// Set the GPU-only hash rate.
pub fn set_gpu_hash_rate(rate: i64) {
GPU_HASH_RATE.set(rate);
}
pub fn set_job_estimated_rate(engine: &str, _job_id: &str, rate: f64) {
// Compatibility shim: map per-job series to backend="unknown" to avoid cardinality growth
JOB_ESTIMATED_RATE
.with_label_values(&[engine, "unknown"])
.set(rate);
}
pub fn inc_candidates_found(engine: &str, job_id: &str) {
touch_job(engine, job_id);
CANDIDATES_FOUND_TOTAL
.with_label_values(&[engine, job_id])
.inc();
}
pub fn inc_candidates_false_positive(engine: &str) {
CANDIDATES_FALSE_POSITIVE_TOTAL
.with_label_values(&[engine])
.inc();
}
pub fn inc_sample_mismatch(engine: &str) {
SAMPLE_MISMATCH_TOTAL.with_label_values(&[engine]).inc();
}
pub fn inc_found_by_origin(engine: &str, origin: &str) {
FOUND_BY_ORIGIN_TOTAL
.with_label_values(&[engine, origin])
.inc();
}
pub fn inc_gpu_g2_missed_winner() {
MISSED_WINNER_TOTAL.inc();
}
pub fn set_job_found_origin(engine: &str, job_id: &str, origin: &str) {
touch_job(engine, job_id);
JOB_FOUND_ORIGIN
.with_label_values(&[engine, job_id, origin])
.set(1.0);
}
pub fn job_estimated_rate_backend(engine: &str, backend: &str, rate: f64) {
JOB_ESTIMATED_RATE
.with_label_values(&[engine, backend])
.set(rate);
}
pub fn job_estimated_rate(engine: &str, _job_id: &str, rate: f64) {
// Compatibility shim: prefer new API using backend; fallback to backend="unknown"
JOB_ESTIMATED_RATE
.with_label_values(&[engine, "unknown"])
.set(rate);
}
pub fn inc_thread_hashes(engine: &str, job_id: &str, thread_id: &str, n: u64) {
THREAD_HASHES_TOTAL
.with_label_values(&[engine, job_id, thread_id])
.inc_by(n);
}
pub fn set_thread_hash_rate(engine: &str, job_id: &str, thread_id: &str, rate: f64) {
touch_thread(engine, job_id, thread_id);
THREAD_HASH_RATE
.with_label_values(&[engine, job_id, thread_id])
.set(rate);
}
pub fn inc_jobs_by_engine(engine: &str, status: &str) {
JOBS_BY_ENGINE_TOTAL
.with_label_values(&[engine, status])
.inc();
}
pub fn set_job_status_gauge(engine: &str, job_id: &str, status: &str, value: i64) {
touch_job(engine, job_id);
JOB_STATUS_GAUGE
.with_label_values(&[engine, job_id, status])
.set(value);
}
// ---------------------------------------------------------------------------
// Public API - Counters (kept for compatibility)
// ---------------------------------------------------------------------------
/// Increment the total hashes counter (without updating rates).
/// Prefer `record_cpu_hashes` or `record_gpu_hashes` instead.
pub fn inc_hashes(n: u64) {
HASHES_TOTAL.inc_by(n);
}
/// Set the current estimated hash rate (nonces per second).
pub fn set_hash_rate(rate: f64) {
HASH_RATE.set(rate);
// ---------------------------------------------------------------------------
// Public API - Gauges
// ---------------------------------------------------------------------------
/// Set the number of active jobs (0 or 1).
pub fn set_active_jobs(n: i64) {
ACTIVE_JOBS.set(n);
}
/// Increment the jobs counter for a terminal status: completed | failed | cancelled.
pub fn inc_job_status(status: &str) {
JOBS_TOTAL.with_label_values(&[status]).inc();
/// Set the total number of workers.
pub fn set_workers(n: i64) {
WORKERS.set(n);
}
/// Increment HTTP request counters for an endpoint with a status code.
pub fn inc_http_request(endpoint: &str, code: u16) {
HTTP_REQUESTS_TOTAL
.with_label_values(&[endpoint, &code.to_string()])
.inc();
/// Set the number of CPU workers.
pub fn set_cpu_workers(n: i64) {
CPU_WORKERS.set(n);
}
// -------------------------------------------------------------------------------------
// Removal helpers for end-of-life series
// -------------------------------------------------------------------------------------
const METRICS_TTL_SECS: u64 = 300;
const JANITOR_INTERVAL_SECS: u64 = 60;
type JobKey = (String, String); // (engine, job_id)
type ThreadKey = (String, String, String); // (engine, job_id, thread_id)
static JOB_KEYS: Lazy<Mutex<HashSet<JobKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
static JOB_LAST: Lazy<Mutex<HashMap<JobKey, Instant>>> = Lazy::new(|| Mutex::new(HashMap::new()));
static THREAD_KEYS: Lazy<Mutex<HashSet<ThreadKey>>> = Lazy::new(|| Mutex::new(HashSet::new()));
static THREAD_LAST: Lazy<Mutex<HashMap<ThreadKey, Instant>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static JANITOR_INIT: Lazy<()> = Lazy::new(|| {
thread::spawn(|| loop {
thread::sleep(Duration::from_secs(JANITOR_INTERVAL_SECS));
prune_stale();
});
});
fn prune_stale() {
let now = Instant::now();
// Jobs
let mut to_remove_jobs: Vec<JobKey> = Vec::new();
{
let last = JOB_LAST.lock().unwrap();
for (k, ts) in last.iter() {
if now.duration_since(*ts).as_secs() > METRICS_TTL_SECS {
to_remove_jobs.push(k.clone());
}
}
}
for (engine, job_id) in to_remove_jobs {
let _ = JOB_HASHES_TOTAL.remove_label_values(&[&engine, &job_id]);
let _ = JOB_HASH_RATE.remove_label_values(&[&engine, &job_id]);
let _ = CANDIDATES_FOUND_TOTAL.remove_label_values(&[&engine, &job_id]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "completed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "failed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[&engine, &job_id, "cancelled"]);
// Remove per-job origin gauge variants (known origins)
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "cpu"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "gpu-g1"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[&engine, &job_id, "gpu-g2"]);
JOB_KEYS
.lock()
.unwrap()
.remove(&(engine.clone(), job_id.clone()));
JOB_LAST.lock().unwrap().remove(&(engine, job_id));
}
// Threads
let mut to_remove_threads: Vec<ThreadKey> = Vec::new();
{
let last = THREAD_LAST.lock().unwrap();
for (k, ts) in last.iter() {
if now.duration_since(*ts).as_secs() > METRICS_TTL_SECS {
to_remove_threads.push(k.clone());
}
}
}
for (engine, job_id, thread_id) in to_remove_threads {
let _ = THREAD_HASHES_TOTAL.remove_label_values(&[&engine, &job_id, &thread_id]);
let _ = THREAD_HASH_RATE.remove_label_values(&[&engine, &job_id, &thread_id]);
THREAD_KEYS
.lock()
.unwrap()
.remove(&(engine.clone(), job_id.clone(), thread_id.clone()));
THREAD_LAST
.lock()
.unwrap()
.remove(&(engine, job_id, thread_id));
}
/// Set the number of GPU devices.
pub fn set_gpu_devices(n: i64) {
GPU_DEVICES.set(n);
}
fn touch_job(engine: &str, job_id: &str) {
*JANITOR_INIT; // ensure janitor starts
let key = (engine.to_string(), job_id.to_string());
JOB_KEYS.lock().unwrap().insert(key.clone());
JOB_LAST.lock().unwrap().insert(key, Instant::now());
/// Set the detected CPU core count.
pub fn set_effective_cpus(n: i64) {
EFFECTIVE_CPUS.set(n);
}
fn touch_thread(engine: &str, job_id: &str, thread_id: &str) {
*JANITOR_INIT; // ensure janitor starts
let key = (
engine.to_string(),
job_id.to_string(),
thread_id.to_string(),
);
THREAD_KEYS.lock().unwrap().insert(key.clone());
THREAD_LAST.lock().unwrap().insert(key, Instant::now());
}
// ---------------------------------------------------------------------------
// HTTP Exporter
// ---------------------------------------------------------------------------
/// Explicitly remove all job-scoped metrics for a job (call on job completion)
pub fn remove_job_metrics(engine: &str, job_id: &str) {
let _ = JOB_HASHES_TOTAL.remove_label_values(&[engine, job_id]);
let _ = JOB_HASH_RATE.remove_label_values(&[engine, job_id]);
let _ = CANDIDATES_FOUND_TOTAL.remove_label_values(&[engine, job_id]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "completed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "failed"]);
let _ = JOB_STATUS_GAUGE.remove_label_values(&[engine, job_id, "cancelled"]);
// Remove per-job origin gauge variants (known origins)
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "cpu"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "gpu-g1"]);
let _ = JOB_FOUND_ORIGIN.remove_label_values(&[engine, job_id, "gpu-g2"]);
JOB_KEYS
.lock()
.unwrap()
.remove(&(engine.to_string(), job_id.to_string()));
JOB_LAST
.lock()
.unwrap()
.remove(&(engine.to_string(), job_id.to_string()));
}
/// Explicitly remove all thread-scoped metrics for a job
pub fn remove_thread_metrics_for_job(engine: &str, job_id: &str) {
// Collect matching thread keys
let keys: Vec<ThreadKey> = {
let set = THREAD_KEYS.lock().unwrap();
set.iter()
.filter(|(e, j, _)| e == engine && j == job_id)
.cloned()
.collect()
};
for (e, j, t) in keys {
let _ = THREAD_HASHES_TOTAL.remove_label_values(&[&e, &j, &t]);
let _ = THREAD_HASH_RATE.remove_label_values(&[&e, &j, &t]);
THREAD_KEYS
.lock()
.unwrap()
.remove(&(e.clone(), j.clone(), t.clone()));
THREAD_LAST
.lock()
.unwrap()
.remove(&(e.clone(), j.clone(), t.clone()));
}
}
/// Remove the per-job hash rate series for a finished job.
pub fn remove_job_hash_rate(engine: &str, job_id: &str) {
let _ = JOB_HASH_RATE.remove_label_values(&[engine, job_id]);
}
/// Remove the per-thread hash rate series for a finished thread.
pub fn remove_thread_hash_rate(engine: &str, job_id: &str, thread_id: &str) {
let _ = THREAD_HASH_RATE.remove_label_values(&[engine, job_id, thread_id]);
}
// -------------------------------------------------------------------------------------
// GPU-specific metric helpers
// -------------------------------------------------------------------------------------
/// Set the total number of GPU devices available for mining
pub fn set_gpu_device_count(count: i64) {
GPU_DEVICE_COUNT.set(count);
}
/// Set GPU device information (call once per device during initialization)
pub fn set_gpu_device_info(
device_id: &str,
name: &str,
backend: &str,
vendor: &str,
device_type: &str,
) {
GPU_DEVICE_INFO
.with_label_values(&[device_id, name, backend, vendor, device_type])
.set(1.0);
}
/// Set GPU batch size for a specific device
pub fn set_gpu_batch_size(device_id: &str, batch_size: f64) {
GPU_BATCH_SIZE
.with_label_values(&[device_id])
.set(batch_size);
}
/// Set GPU workgroup count for a specific device
pub fn set_gpu_workgroups(device_id: &str, workgroups: f64) {
GPU_WORKGROUPS
.with_label_values(&[device_id])
.set(workgroups);
}
// -------------------------------------------------------------------------------------
// HTTP Exporter (feature: http-exporter)
// -------------------------------------------------------------------------------------
/// Start the Prometheus HTTP exporter on 0.0.0.0:`port`.
/// Start the Prometheus HTTP exporter on `0.0.0.0:port`.
///
/// Behavior:
/// - Spawns the exporter as a background task and returns immediately.
/// - Serves plaintext metrics at GET /metrics.
/// - If called multiple times, multiple servers may be created (call once).
/// Spawns the exporter as a background task and returns immediately.
/// Serves plaintext metrics at `GET /metrics`.
#[cfg(feature = "http-exporter")]
pub async fn start_http_exporter(port: u16) -> Result<()> {
// Encoder is created inside the handler to avoid capturing non-Clone state
// Use REGISTRY.gather() directly in the handler
// GET /metrics -> plaintext Prometheus format
let metrics_route = warp::path("metrics").and(warp::get()).map(|| {
let encoder = TextEncoder::new();
let metric_families = REGISTRY.gather();
let mut buffer = Vec::with_capacity(16 * 1024);
let mut buffer = Vec::with_capacity(4096);
encoder
.encode(&metric_families, &mut buffer)
.unwrap_or_default();
@@ -777,7 +316,7 @@ pub async fn start_http_exporter(port: u16) -> Result<()> {
Ok(())
}
// If the exporter feature is not enabled, expose a no-op to keep call sites simple.
/// No-op when HTTP exporter feature is disabled.
#[cfg(not(feature = "http-exporter"))]
pub async fn start_http_exporter(_port: u16) -> Result<()> {
log::warn!(
+1 -4
View File
@@ -5,12 +5,9 @@ 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" }
metrics = { path = "../metrics", features = ["http-exporter"] }
clap = { workspace = true, features = ["derive", "env"] }
tokio = { workspace = true, features = ["full"] }
env_logger = { workspace = true }
+138 -369
View File
@@ -9,115 +9,53 @@ use std::thread;
use std::time::{Duration, Instant};
#[derive(Subcommand, Debug)]
#[allow(clippy::large_enum_variant)]
enum Command {
/// Run the mining service (default behavior)
/// Run the mining service
Serve {
/// Port number to listen on for the miner HTTP API
#[arg(short, long, env = "MINER_PORT", default_value_t = 9833)]
port: u16,
/// Address of the node to connect to (e.g., "127.0.0.1:9833")
#[arg(long, env = "MINER_NODE_ADDR")]
node_addr: std::net::SocketAddr,
/// Number of CPU worker threads to use for mining (None = auto-detect)
/// Number of CPU worker threads to use for mining (default: auto-detect)
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
cpu_workers: Option<usize>,
/// Number of GPU 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 (default: 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")]
metrics_port: Option<u16>,
/// GPU cancel check interval in nonces (default: 10000)
#[arg(long = "gpu-cancel-interval", env = "MINER_GPU_CANCEL_INTERVAL")]
gpu_cancel_interval: Option<u32>,
/// Enable verbose logging (shows debug info, progress details, etc.)
/// Port for Prometheus metrics HTTP endpoint (default: 9900)
#[arg(
long = "metrics-port",
env = "MINER_METRICS_PORT",
default_value_t = 9900
)]
metrics_port: u16,
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
/// How often to report mining progress (in milliseconds).
/// Smaller values give more frequent updates but slightly reduce performance.
#[arg(long = "progress-interval-ms", env = "MINER_PROGRESS_INTERVAL_MS")]
progress_interval_ms: Option<u64>,
/// Size of work chunks to process before reporting progress (number of hashes).
/// If omitted, uses engine-specific defaults (200K for CPU, 100M for GPU).
#[arg(long = "chunk-size", env = "MINER_CHUNK_SIZE")]
chunk_size: Option<u64>,
/// For cpu-chain-manipulator: start throttle index at this many solved blocks
/// to "pick up where we left off" after restarts.
#[arg(long = "manip-solved-blocks", env = "MINER_MANIP_SOLVED_BLOCKS")]
manip_solved_blocks: Option<u64>,
/// For cpu-chain-manipulator: base sleep per batch in nanoseconds (default 500_000 ns)
#[arg(long = "manip-base-delay-ns", env = "MINER_MANIP_BASE_DELAY_NS")]
manip_base_delay_ns: Option<u64>,
/// For cpu-chain-manipulator: number of nonce attempts between sleeps (default 10_000)
#[arg(long = "manip-step-batch", env = "MINER_MANIP_STEP_BATCH")]
manip_step_batch: Option<u64>,
/// For cpu-chain-manipulator: optional cap on solved-blocks throttle index
#[arg(long = "manip-throttle-cap", env = "MINER_MANIP_THROTTLE_CAP")]
manip_throttle_cap: Option<u64>,
/// Telemetry endpoints (repeat --telemetry-endpoint or comma-separated)
#[arg(long = "telemetry-endpoint", env = "MINER_TELEMETRY_ENDPOINTS", value_delimiter = ',', num_args = 0.., value_name = "URL")]
telemetry_endpoints: Option<Vec<String>>,
/// Enable or disable telemetry explicitly
#[arg(long = "telemetry-enabled", env = "MINER_TELEMETRY_ENABLED")]
telemetry_enabled: Option<bool>,
/// Telemetry verbosity level (0..=4 typical)
#[arg(long = "telemetry-verbosity", env = "MINER_TELEMETRY_VERBOSITY")]
telemetry_verbosity: Option<u8>,
/// Interval seconds for system.interval messages
#[arg(
long = "telemetry-interval-secs",
env = "MINER_TELEMETRY_INTERVAL_SECS"
)]
telemetry_interval_secs: Option<u64>,
/// Default association: chain name
#[arg(long = "telemetry-chain", env = "MINER_TELEMETRY_CHAIN")]
telemetry_chain: Option<String>,
/// Default association: genesis hash (hex)
#[arg(long = "telemetry-genesis", env = "MINER_TELEMETRY_GENESIS")]
telemetry_genesis: Option<String>,
/// Default association: node telemetry id
#[arg(long = "telemetry-node-id", env = "MINER_TELEMETRY_NODE_ID")]
telemetry_node_id: Option<String>,
/// Default association: node libp2p peer id
#[arg(long = "telemetry-node-peer-id", env = "MINER_TELEMETRY_NODE_PEER_ID")]
telemetry_node_peer_id: Option<String>,
/// Default association: node name
#[arg(long = "telemetry-node-name", env = "MINER_TELEMETRY_NODE_NAME")]
telemetry_node_name: Option<String>,
/// Default association: node version
#[arg(long = "telemetry-node-version", env = "MINER_TELEMETRY_NODE_VERSION")]
telemetry_node_version: Option<String>,
},
/// Run a quick benchmark of the mining engines
Benchmark {
/// Number of CPU workers to use for benchmark
#[arg(long = "cpu-workers", env = "MINER_CPU_WORKERS")]
cpu_workers: Option<usize>,
/// Number of GPU 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)]
duration: u64,
/// Enable verbose logging during benchmark
/// Enable verbose logging
#[arg(short, long, env = "MINER_VERBOSE")]
verbose: bool,
},
@@ -131,119 +69,65 @@ struct Args {
command: Option<Command>,
}
#[allow(clippy::enum_variant_names)]
#[tokio::main]
async fn main() {
let args = Args::parse();
match args.command.unwrap_or(Command::Serve {
port: 9833,
cpu_workers: None,
gpu_workers: None,
metrics_port: None,
verbose: false,
progress_interval_ms: None,
chunk_size: None,
manip_solved_blocks: None,
manip_base_delay_ns: None,
manip_step_batch: None,
manip_throttle_cap: None,
telemetry_endpoints: None,
telemetry_enabled: None,
telemetry_verbosity: None,
telemetry_interval_secs: None,
telemetry_chain: None,
telemetry_genesis: None,
telemetry_node_id: None,
telemetry_node_peer_id: None,
telemetry_node_name: None,
telemetry_node_version: None,
}) {
let Some(command) = args.command else {
eprintln!("Error: No command provided. Use 'serve --node-addr <ADDRESS>' to start mining.");
eprintln!("Example: quantus-miner serve --node-addr 127.0.0.1:9833");
std::process::exit(1);
};
match command {
Command::Serve {
port,
node_addr,
cpu_workers,
gpu_workers,
gpu_devices,
gpu_cancel_interval,
metrics_port,
verbose,
progress_interval_ms,
chunk_size,
manip_solved_blocks,
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
telemetry_endpoints,
telemetry_enabled,
telemetry_verbosity,
telemetry_interval_secs,
telemetry_chain,
telemetry_genesis,
telemetry_node_id,
telemetry_node_peer_id,
telemetry_node_name,
telemetry_node_version,
} => {
run_serve_command(
port,
init_logger(verbose);
log::info!("Starting external miner service...");
// Start metrics HTTP server
if let Err(e) = metrics::start_http_exporter(metrics_port).await {
log::error!("Failed to start metrics exporter: {e:?}");
std::process::exit(1);
}
log::info!(
"Metrics available at http://0.0.0.0:{}/metrics",
metrics_port
);
let config = ServiceConfig {
node_addr,
cpu_workers,
gpu_workers,
metrics_port,
verbose,
progress_interval_ms,
chunk_size,
manip_solved_blocks,
manip_base_delay_ns,
manip_step_batch,
manip_throttle_cap,
telemetry_endpoints,
telemetry_enabled,
telemetry_verbosity,
telemetry_interval_secs,
telemetry_chain,
telemetry_genesis,
telemetry_node_id,
telemetry_node_peer_id,
telemetry_node_name,
telemetry_node_version,
)
.await;
gpu_devices,
gpu_cancel_interval,
};
if let Err(e) = run(config).await {
log::error!("Miner service terminated with error: {e:?}");
std::process::exit(1);
}
}
Command::Benchmark {
cpu_workers,
gpu_workers,
gpu_devices,
duration,
verbose,
} => {
run_benchmark_command(cpu_workers, gpu_workers, duration, verbose).await;
init_logger(verbose);
run_benchmark(cpu_workers, gpu_devices, duration).await;
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_serve_command(
port: u16,
cpu_workers: Option<usize>,
gpu_workers: Option<usize>,
metrics_port: Option<u16>,
verbose: bool,
progress_interval_ms: Option<u64>,
chunk_size: Option<u64>,
manip_solved_blocks: Option<u64>,
manip_base_delay_ns: Option<u64>,
manip_step_batch: Option<u64>,
manip_throttle_cap: Option<u64>,
telemetry_endpoints: Option<Vec<String>>,
telemetry_enabled: Option<bool>,
telemetry_verbosity: Option<u8>,
telemetry_interval_secs: Option<u64>,
telemetry_chain: Option<String>,
telemetry_genesis: Option<String>,
telemetry_node_id: Option<String>,
telemetry_node_peer_id: Option<String>,
telemetry_node_name: Option<String>,
telemetry_node_version: Option<String>,
) {
// Initialize logger early to capture startup messages.
// If RUST_LOG is not set, default to appropriate level based on verbose flag
fn init_logger(verbose: bool) {
if std::env::var("RUST_LOG").is_err() {
let log_level = if verbose {
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug"
@@ -253,208 +137,116 @@ async fn run_serve_command(
std::env::set_var("RUST_LOG", log_level);
}
env_logger::init();
// Telemetry CLI passthrough to env for miner-service bootstrap
if let Some(eps) = telemetry_endpoints.as_ref() {
if !eps.is_empty() {
std::env::set_var("MINER_TELEMETRY_ENDPOINTS", eps.join(","));
}
}
if let Some(v) = telemetry_enabled {
std::env::set_var("MINER_TELEMETRY_ENABLED", if v { "1" } else { "0" });
}
if let Some(v) = telemetry_verbosity {
std::env::set_var("MINER_TELEMETRY_VERBOSITY", v.to_string());
}
if let Some(v) = telemetry_interval_secs {
std::env::set_var("MINER_TELEMETRY_INTERVAL_SECS", v.to_string());
}
if let Some(v) = telemetry_chain.as_ref() {
std::env::set_var("MINER_TELEMETRY_CHAIN", v);
}
if let Some(v) = telemetry_genesis.as_ref() {
std::env::set_var("MINER_TELEMETRY_GENESIS", v);
}
if let Some(v) = telemetry_node_id.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_ID", v);
}
if let Some(v) = telemetry_node_peer_id.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_PEER_ID", v);
}
if let Some(v) = telemetry_node_name.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_NAME", v);
}
if let Some(v) = telemetry_node_version.as_ref() {
std::env::set_var("MINER_TELEMETRY_NODE_VERSION", v);
}
// Log effective configuration (concise; see ServiceConfig Display)
log::info!("Starting external miner service...");
let config = ServiceConfig {
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,
};
log::info!("Effective config: {config}");
if let Err(e) = run(config).await {
log::error!("Miner service terminated with error: {e:?}");
std::process::exit(1);
}
}
async fn run_benchmark_command(
cpu_workers: Option<usize>,
gpu_workers: Option<usize>,
duration: u64,
verbose: bool,
) {
// Initialize logger early to capture startup messages.
if std::env::var("RUST_LOG").is_err() {
let log_level = if verbose {
"debug,miner=debug,gpu_engine=debug,engine_cpu=debug"
} else {
"info,miner=info,gpu_engine=warn,engine_cpu=info"
};
std::env::set_var("RUST_LOG", log_level);
}
env_logger::init();
async fn run_benchmark(cpu_workers: Option<usize>, gpu_devices: Option<usize>, duration: u64) {
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
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);
std::process::exit(1);
}
};
let total_workers = effective_cpu_workers + effective_gpu_devices;
println!("🚀 Quantus Miner Benchmark");
println!("==========================");
println!(
"Configuration: {} CPU workers, {} GPU workers",
effective_cpu_workers, effective_gpu_workers
"CPU Workers: {} (Available: {})",
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 {
Some(Arc::new(engine_cpu::FastCpuEngine::new()) as Arc<dyn MinerEngine>)
} else {
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");
println!();
if total_workers == 0 {
eprintln!("Error: No workers specified");
std::process::exit(1);
}
println!("Workers: {}", total_workers);
println!();
// Run benchmark
// Create CPU engine
let cpu_engine: Option<Arc<dyn MinerEngine>> = if effective_cpu_workers > 0 {
Some(Arc::new(engine_cpu::FastCpuEngine::new()))
} else {
None
};
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
};
// Random header hash for benchmark
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);
let ref_engine = cpu_engine.as_ref().or(gpu_engine.as_ref()).unwrap();
let ctx = ref_engine.prepare_context(header, difficulty);
println!("⛏️ Starting benchmark...");
// Spawn worker threads
let mut handles = Vec::new();
let total_hashes_arc = Arc::new(std::sync::Mutex::new(0u64));
let total_hashes = 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
};
let cpu_chunk = 10_000u64;
let gpu_chunk = 1_000_000u64;
for worker_id in 0..total_workers {
let engine = engine.clone();
let (engine, nonces_per_batch) = if worker_id < effective_cpu_workers {
(cpu_engine.as_ref().unwrap().clone(), cpu_chunk)
} else {
(gpu_engine.as_ref().unwrap().clone(), gpu_chunk)
};
let ctx = ctx.clone();
let cancel_flag = cancel_flag.clone();
let total_hashes = total_hashes_arc.clone();
let cancel = cancel_flag.clone();
let hashes = total_hashes.clone();
let start = benchmark_start;
let handle = thread::spawn(move || {
let stride = U512::from(1_000_000_000_000u64);
let worker_start = 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_batch))
.saturating_sub(U512::from(1u64)),
};
let mut worker_hashes = 0u64;
loop {
if cancel_flag.load(std::sync::atomic::Ordering::Relaxed) {
if cancel.load(std::sync::atomic::Ordering::Relaxed) {
break;
}
let result = engine.search_range(&ctx, worker_range.clone(), &cancel_flag);
let result = engine.search_range(&ctx, worker_range.clone(), &cancel);
match result {
engine_cpu::EngineStatus::Found { hash_count, .. }
| engine_cpu::EngineStatus::Exhausted { hash_count }
| engine_cpu::EngineStatus::Cancelled { hash_count } => {
worker_hashes += hash_count;
*total_hashes.lock().unwrap() += hash_count;
*hashes.lock().unwrap() += hash_count;
}
engine_cpu::EngineStatus::Running { .. } => {}
}
// Check if we've exceeded the time limit
if benchmark_start.elapsed() >= Duration::from_secs(duration) {
if start.elapsed() >= Duration::from_secs(duration) {
break;
}
}
worker_hashes
engine_gpu::GpuEngine::clear_worker_resources();
});
handles.push(handle);
}
// Wait for duration or interrupt
// Progress updates
let mut last_update = Instant::now();
loop {
@@ -465,70 +257,47 @@ async fn run_benchmark_command(
break;
}
// Update progress every second
if last_update.elapsed() >= Duration::from_secs(1) {
let current_hashes = *total_hashes_arc.lock().unwrap();
let current = *total_hashes.lock().unwrap();
let elapsed = benchmark_start.elapsed().as_secs_f64();
if current_hashes > 0 {
let hash_rate = current_hashes as f64 / elapsed;
let hash_rate_str = if hash_rate >= 1_000_000.0 {
format!("{:.1}M", hash_rate / 1_000_000.0)
} else if hash_rate >= 1_000.0 {
format!("{:.1}K", hash_rate / 1_000.0)
} else {
format!("{:.0}", hash_rate)
};
println!("⏱️ {:.1}s - {} H/s", elapsed, hash_rate_str);
} else if effective_gpu_workers > 0 {
println!("⏱️ {:.1}s - processing...", elapsed);
} else {
println!("⏱️ {:.1}s - starting...", elapsed);
if current > 0 {
let rate = current as f64 / elapsed;
println!("⏱️ {:.1}s - {} H/s", elapsed, format_hash_rate(rate));
}
last_update = Instant::now();
}
}
// Wait for all threads to finish
// Wait for threads
for handle in handles {
let _ = handle.join();
}
let total_elapsed = benchmark_start.elapsed();
let final_hashes = *total_hashes_arc.lock().unwrap();
let avg_hash_rate = final_hashes as f64 / total_elapsed.as_secs_f64();
let final_hashes = *total_hashes.lock().unwrap();
let avg_rate = final_hashes as f64 / total_elapsed.as_secs_f64();
println!();
println!("📊 Benchmark Results");
println!("===================");
println!("Total time: {:.2} seconds", total_elapsed.as_secs_f64());
println!("Total time: {:.2}s", total_elapsed.as_secs_f64());
println!("Total hashes: {}", final_hashes);
let hash_rate_str = if avg_hash_rate >= 1_000_000.0 {
format!("{:.2}M H/s", avg_hash_rate / 1_000_000.0)
} else if avg_hash_rate >= 1_000.0 {
format!("{:.2}K H/s", avg_hash_rate / 1_000.0)
} else {
format!("{:.0} H/s", avg_hash_rate)
};
println!("Average hash rate: {}", hash_rate_str);
println!("Average rate: {} H/s", format_hash_rate(avg_rate));
if total_workers > 1 {
let per_worker_rate = avg_hash_rate / total_workers as f64;
let per_worker_str = if per_worker_rate >= 1_000_000.0 {
format!("{:.2}M H/s", per_worker_rate / 1_000_000.0)
} else if per_worker_rate >= 1_000.0 {
format!("{:.2}K H/s", per_worker_rate / 1_000.0)
} else {
format!("{:.0} H/s", per_worker_rate)
};
println!(
"Per-worker rate: {} (across {} workers)",
per_worker_str, total_workers
);
let per_worker = avg_rate / total_workers as f64;
println!("Per-worker: {} H/s", format_hash_rate(per_worker));
}
println!("✅ Benchmark completed successfully!");
println!("✅ Benchmark completed!");
}
fn format_hash_rate(rate: f64) -> String {
if rate >= 1_000_000.0 {
format!("{:.2}M", rate / 1_000_000.0)
} else if rate >= 1_000.0 {
format!("{:.2}K", rate / 1_000.0)
} else {
format!("{:.0}", rate)
}
}
+8 -19
View File
@@ -3,43 +3,32 @@ name = "miner-service"
version.workspace = true
edition.workspace = true
publish = false
description = "Service layer: HTTP API, job orchestration, and engine abstraction for the Quantus External Miner"
description = "QUIC-based mining service for the Quantus External Miner"
[features]
default = ["cpu", "metrics"]
# Enable CPU engine by default.
default = ["cpu"]
cpu = ["engine-cpu"]
# Enable GPU engine.
gpu = ["engine-gpu"]
# Optional metrics/observability (Prometheus endpoint).
metrics = [
"dep:metrics",
"metrics/http-exporter",
]
[dependencies]
# Workspace-shared deps
warp = { workspace = true }
tokio = { workspace = true, features = ["full"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
hex = { workspace = true }
primitive-types = { workspace = true }
log = { workspace = true }
crossbeam-channel = { workspace = true }
num_cpus = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
reqwest = { version = "0.11", features = ["json"] }
# QUIC transport
quinn = "0.10"
rustls = { version = "0.21", default-features = false, features = ["dangerous_configuration", "quic"] }
getrandom = "0.2"
# Protocol types from the node repo (keep API compatibility)
# Protocol types from the node repo
quantus-miner-api = { workspace = true }
# Local crates
pow-core = { path = "../pow-core" }
engine-cpu = { path = "../engine-cpu", optional = true }
engine-gpu = { path = "../engine-gpu", optional = true }
metrics = { path = "../metrics", optional = true }
miner-telemetry = { path = "../miner-telemetry" }
engine-gpu = { path = "../engine-gpu" }
metrics = { path = "../metrics" }
File diff suppressed because it is too large Load Diff
+324
View File
@@ -0,0 +1,324 @@
//! QUIC client for connecting to blockchain nodes.
//!
//! This module provides a QUIC client that connects to a blockchain node
//! and handles bidirectional streaming for receiving mining jobs and
//! sending results.
use std::net::SocketAddr;
use std::sync::Arc;
use std::time::{Duration, Instant};
use engine_cpu::MinerEngine;
use primitive_types::U512;
use quinn::{ClientConfig, Endpoint};
use rustls::client::ServerCertVerified;
use quantus_miner_api::{
read_message, write_message, ApiResponseStatus, MinerMessage, MiningResult,
};
use crate::{EngineType, WorkerPool};
use pow_core::format_hashrate;
/// Connect to a node and start mining.
///
/// This function connects to the node, receives mining jobs, and sends results.
/// It automatically reconnects if the connection is lost.
///
/// Uses a persistent worker pool to avoid thread creation overhead between jobs.
pub async fn connect_and_mine(
node_addr: SocketAddr,
cpu_engine: Option<Arc<dyn MinerEngine>>,
gpu_engine: Option<Arc<dyn MinerEngine>>,
cpu_workers: usize,
gpu_devices: usize,
) -> anyhow::Result<()> {
// Create persistent worker pool once - it lives for the entire miner lifetime
let worker_pool = WorkerPool::new(cpu_engine, gpu_engine, cpu_workers, gpu_devices);
let mut reconnect_delay = Duration::from_secs(1);
const MAX_RECONNECT_DELAY: Duration = Duration::from_secs(30);
loop {
log::info!("⛏️ Connecting to node at {}...", node_addr);
match establish_connection(node_addr).await {
Ok((connection, send, recv)) => {
log::info!("⛏️ Connected to node at {}", node_addr);
reconnect_delay = Duration::from_secs(1);
if let Err(e) = handle_connection(connection, send, recv, &worker_pool).await {
log::info!("⛏️ Connection lost: {}", e);
// Cancel any running job when connection drops
worker_pool.cancel();
}
}
Err(e) => {
log::warn!("⛏️ Failed to connect to node: {}", e);
}
}
log::info!("⛏️ Reconnecting in {:?}...", reconnect_delay);
tokio::time::sleep(reconnect_delay).await;
reconnect_delay = (reconnect_delay * 2).min(MAX_RECONNECT_DELAY);
}
}
/// Establish a QUIC connection to the node.
async fn establish_connection(
addr: SocketAddr,
) -> anyhow::Result<(quinn::Connection, quinn::SendStream, quinn::RecvStream)> {
let mut crypto = rustls::ClientConfig::builder()
.with_safe_defaults()
.with_custom_certificate_verifier(Arc::new(InsecureCertVerifier))
.with_no_client_auth();
crypto.alpn_protocols = vec![b"quantus-miner".to_vec()];
let mut client_config = ClientConfig::new(Arc::new(crypto));
let mut transport_config = quinn::TransportConfig::default();
transport_config.keep_alive_interval(Some(Duration::from_secs(5)));
transport_config.max_idle_timeout(Some(Duration::from_secs(15).try_into().unwrap()));
client_config.transport_config(Arc::new(transport_config));
let mut endpoint = Endpoint::client("0.0.0.0:0".parse().unwrap())?;
endpoint.set_default_client_config(client_config);
let connection = endpoint.connect(addr, "localhost")?.await?;
log::info!("⛏️ QUIC connection established to {}", addr);
log::info!("⛏️ Opening bidirectional stream to node...");
let (mut send, recv) = connection.open_bi().await?;
// Send Ready message to establish the stream
write_message(&mut send, &MinerMessage::Ready).await?;
log::info!("⛏️ Bidirectional stream established");
Ok((connection, send, recv))
}
/// Helper to send a message while monitoring connection health.
async fn send_message_checked(
connection: &quinn::Connection,
send: &mut quinn::SendStream,
msg: &MinerMessage,
) -> anyhow::Result<()> {
tokio::select! {
biased;
reason = connection.closed() => {
Err(anyhow::anyhow!("Connection closed: {}", reason))
}
result = write_message(send, msg) => {
result.map_err(|e| anyhow::anyhow!("Failed to send message: {}", e))
}
}
}
/// Handle an established connection, receiving jobs and sending results.
async fn handle_connection(
connection: quinn::Connection,
mut send: quinn::SendStream,
mut recv: quinn::RecvStream,
worker_pool: &WorkerPool,
) -> anyhow::Result<()> {
use crossbeam_channel::RecvTimeoutError;
// Set static metrics once per connection
metrics::set_effective_cpus(num_cpus::get() as i64);
metrics::set_workers(worker_pool.worker_count() as i64);
metrics::set_cpu_workers(worker_pool.cpu_worker_count() as i64);
metrics::set_gpu_devices(worker_pool.gpu_worker_count() as i64);
metrics::reset_hash_tracker();
// Current job state
// - node_job_id: The string ID from the node (e.g., "27") - used in protocol messages
// - internal_job_id: Our internal numeric ID from WorkerPool - used to detect stale results
let mut node_job_id: Option<String> = None;
let mut internal_job_id: u64 = 0;
let mut job_start_time: Option<Instant> = None;
let mut cpu_hashes: u64 = 0;
let mut gpu_hashes: u64 = 0;
let mut result_sent_for_current_job = false;
log::info!("⛏️ Waiting for mining jobs from node...");
loop {
// Poll for worker results (non-blocking via spawn_blocking)
let poll_result = if node_job_id.is_some() && !result_sent_for_current_job {
let rx = worker_pool.result_receiver().clone();
tokio::task::spawn_blocking(move || rx.recv_timeout(Duration::from_millis(10)))
.await
.unwrap_or(Err(RecvTimeoutError::Disconnected))
} else {
Err(RecvTimeoutError::Timeout)
};
// Handle worker result if any
if let Ok(worker_result) = poll_result {
// Track hashes by engine type (always, even for stale results)
match worker_result.engine_type {
EngineType::Cpu => {
cpu_hashes += worker_result.hash_count;
metrics::record_cpu_hashes(worker_result.hash_count);
}
EngineType::Gpu => {
gpu_hashes += worker_result.hash_count;
metrics::record_gpu_hashes(worker_result.hash_count);
}
}
// Check if this result is for the current job (not stale)
if worker_result.job_id != internal_job_id {
log::debug!(
"⏰ Discarding stale result from worker {} (result job_id {} != current {})",
worker_result.thread_id,
worker_result.job_id,
internal_job_id
);
continue;
}
// Only send result for the FIRST solution found for THIS job
if let Some(candidate) = worker_result.candidate {
if !result_sent_for_current_job {
if let Some(ref job_id) = node_job_id {
let total_hashes = cpu_hashes + gpu_hashes;
let elapsed = job_start_time
.map(|t| t.elapsed().as_secs_f64())
.unwrap_or(0.0);
log::info!(
"⛏️ Job {job_id} completed: {total_hashes} hashes in {elapsed:.2}s ({})",
format_hashrate(total_hashes as f64 / elapsed.max(0.001))
);
// Mark as sent BEFORE sending to prevent duplicates
result_sent_for_current_job = true;
worker_pool.cancel();
metrics::set_active_jobs(0);
let result = MiningResult {
status: ApiResponseStatus::Completed,
job_id: job_id.clone(),
nonce: Some(format!("{:x}", candidate.nonce)),
work: Some(hex::encode(candidate.work)),
hash_count: total_hashes,
elapsed_time: elapsed,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
}
}
}
}
// Check for incoming messages and connection health
tokio::select! {
biased;
reason = connection.closed() => {
return Err(anyhow::anyhow!("Connection closed: {}", reason));
}
msg_result = read_message(&mut recv) => {
match msg_result {
Ok(MinerMessage::NewJob(request)) => {
log::info!(
"⛏️ Received job: id={}, hash={}...",
request.job_id,
&request.mining_hash[..8]
);
// Parse header hash
let header_hash: [u8; 32] = match hex::decode(&request.mining_hash) {
Ok(bytes) if bytes.len() == 32 => bytes.try_into().unwrap(),
_ => {
log::warn!("Invalid mining_hash in request");
let result = MiningResult {
status: ApiResponseStatus::Failed,
job_id: request.job_id,
nonce: None,
work: None,
hash_count: 0,
elapsed_time: 0.0,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
continue;
}
};
// Parse difficulty
let difficulty = match U512::from_dec_str(&request.distance_threshold) {
Ok(d) => d,
Err(_) => {
log::warn!("Invalid difficulty in request");
let result = MiningResult {
status: ApiResponseStatus::Failed,
job_id: request.job_id,
nonce: None,
work: None,
hash_count: 0,
elapsed_time: 0.0,
miner_id: None,
};
let msg = MinerMessage::JobResult(result);
send_message_checked(&connection, &mut send, &msg).await?;
continue;
}
};
// Reset state for new job
cpu_hashes = 0;
gpu_hashes = 0;
job_start_time = Some(Instant::now());
node_job_id = Some(request.job_id.clone());
result_sent_for_current_job = false;
log::debug!("Starting job {}", request.job_id);
metrics::set_active_jobs(1);
// start_job returns the internal job ID used to detect stale results
internal_job_id = worker_pool.start_job(header_hash, difficulty);
}
Ok(MinerMessage::JobResult(_)) => {
log::warn!("Received unexpected JobResult from node");
}
Ok(MinerMessage::Ready) => {
log::warn!("Received unexpected Ready from node");
}
Err(e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
return Err(anyhow::anyhow!("Node disconnected"));
}
Err(e) => {
return Err(anyhow::anyhow!("Read error: {}", e));
}
}
}
// Short sleep to yield when no messages
_ = tokio::time::sleep(Duration::from_millis(1)) => {}
}
}
}
/// Certificate verifier that accepts any certificate (for self-signed certs).
struct InsecureCertVerifier;
impl rustls::client::ServerCertVerifier for InsecureCertVerifier {
fn verify_server_cert(
&self,
_end_entity: &rustls::Certificate,
_intermediates: &[rustls::Certificate],
_server_name: &rustls::ServerName,
_scts: &mut dyn Iterator<Item = &[u8]>,
_ocsp_response: &[u8],
_now: std::time::SystemTime,
) -> Result<ServerCertVerified, rustls::Error> {
Ok(ServerCertVerified::assertion())
}
}
-1
View File
@@ -21,5 +21,4 @@ serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tokio-tungstenite = { version = "0.23", features = ["rustls-tls-webpki-roots"] }
url = "2"
uuid = { version = "1", features = ["fast-rng", "v4"] }
-7
View File
@@ -18,7 +18,6 @@ default = ["baseline", "std"]
# std feature wires through to dependencies that have separate std/no_std builds.
std = [
"primitive-types/std",
"qp-poseidon-core/std",
]
# Baseline/reference implementation using BigUint-backed modular exponentiation.
@@ -34,14 +33,8 @@ simd-poseidon2 = []
gpu-interop = []
[dependencies]
log = { workspace = true }
primitive-types = { workspace = true }
num-bigint = { workspace = true }
num-traits = { workspace = true }
qp-poseidon-core = { workspace = true }
qpow-math = { workspace = true }
thiserror = { workspace = true }
anyhow = { workspace = true }
[dev-dependencies]
hex = { workspace = true }
+29 -2
View File
@@ -2,7 +2,34 @@ use primitive_types::U512;
pub use qpow_math::{get_nonce_hash, is_valid_nonce, mine_range};
/// Job context for Bitcoin-style PoW mining with double Poseidon2 hashing
/// Format a U512 in a human-readable way (scientific notation for large numbers).
pub fn format_u512(n: U512) -> String {
if n.is_zero() {
return "0".to_string();
}
let s = format!("{}", n);
let len = s.len();
if len <= 12 {
s
} else {
format!("{}e{}", &s[..4], len - 1)
}
}
/// Format a hash rate with appropriate units (H/s, KH/s, MH/s, GH/s).
pub fn format_hashrate(hashes_per_sec: f64) -> String {
if hashes_per_sec >= 1_000_000_000.0 {
format!("{:.2} GH/s", hashes_per_sec / 1_000_000_000.0)
} else if hashes_per_sec >= 1_000_000.0 {
format!("{:.2} MH/s", hashes_per_sec / 1_000_000.0)
} else if hashes_per_sec >= 1_000.0 {
format!("{:.2} KH/s", hashes_per_sec / 1_000.0)
} else {
format!("{:.2} H/s", hashes_per_sec)
}
}
/// Job context for PoW mining with Poseidon2 hash_squeeze_twice
#[derive(Debug, Clone)]
pub struct JobContext {
pub header: [u8; 32],
@@ -35,7 +62,7 @@ pub fn step_nonce(nonce: U512) -> U512 {
nonce.saturating_add(U512::from(1u64))
}
/// Compute hash for the current nonce using Bitcoin-style double Poseidon2
/// Compute hash for the current nonce using Poseidon2 hash_squeeze_twice
pub fn hash_from_nonce(ctx: &JobContext, nonce: U512) -> U512 {
let nonce_bytes = nonce.to_big_endian();
qpow_math::get_nonce_hash(ctx.header, nonce_bytes)