Add AMD OpenCL kernel, runtime-loaded CUDA, mixed backend, portability

AMD GPU backend:
- Add the GCN-tuned equihash192_7.cl kernel (clearCounter/blake/round1..7/
  combine pipeline) and its host driver src/gpu_amd.rs. GpuSolver now dispatches
  AMD-vendor OpenCL devices to it and other devices to the existing kernel
  (force with ZCL_OPENCL_KERNEL=amd|legacy). Validated on an RX 9060 XT: GPU
  solutions match the CPU reference 1/1.
- Expose BatchHasher::midstate() for the kernel's ulong8 hashState arg.

Runtime-loaded GPU drivers (minimum host deps):
- dlopen libcuda / libnvidia-ml via libloading instead of linking them
  (src/dylib.rs macro; cuda.rs, nvml.rs, gpu_probe.rs). The binary now builds
  and starts on hosts without an NVIDIA driver and reports no CUDA devices
  gracefully; remove build.rs (its only job was linking those libs).
- Add Dockerfile.portable + build-portable.sh: build against Debian bullseye's
  glibc 2.31 for a binary that runs on older distros and drives both AMD
  (OpenCL) and NVIDIA (CUDA) cards. Document the build matrix in the README.

Mixed backend (default):
- Add --backend mixed (now the default): each card on its native backend
  (NVIDIA->CUDA, AMD/Intel->OpenCL), deduped so no card is mined twice.
  --devices indexes the unified list shown by --list-devices.

Misc:
- Stale-work timeout (--job-timeout) default 300s -> 600s (10 minutes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jackpotincorporated
2026-06-06 01:15:41 -04:00
parent f3ca6a1ee4
commit 4b5f84959c
18 changed files with 2949 additions and 109 deletions
+12
View File
@@ -166,6 +166,18 @@ impl BatchHasher {
Self { mid, tail }
}
/// The BLAKE2b chaining state after compressing the shared first 128-byte
/// header block (the eight 64-bit midstate words). This is exactly the
/// `hashState` (`ulong8`) the OpenCL `equihash192_7.cl` round-0 kernel
/// consumes: it injects the final 16-byte block (message word `m[1] =
/// (index << 32) | nonce_low`, `m[0] = 0`) itself, which requires the
/// header's bytes [128..136] to be zero (the same `cuda_compatible` rule the
/// CUDA backend relies on). Only used by the OpenCL (AMD) backend.
#[cfg_attr(not(feature = "gpu"), allow(dead_code))]
pub fn midstate(&self) -> [u64; 8] {
self.mid
}
/// Assemble the zero-padded final block for index `g`.
#[inline]
fn final_block(&self, g: u32) -> [u8; 128] {
+19 -2
View File
@@ -108,7 +108,16 @@ const CU_LAUNCH_PARAM_END: usize = 0x00;
const CU_LAUNCH_PARAM_BUFFER_POINTER: usize = 0x01;
const CU_LAUNCH_PARAM_BUFFER_SIZE: usize = 0x02;
extern "C" {
// The CUDA driver API, loaded at runtime via dlopen (see `crate::dylib`) rather
// than linked at build time: the SONAME `libcuda.so.1` ships with the NVIDIA
// driver (`nvcuda.dll` on Windows) and is absent on driver-less / AMD-only
// hosts. `cuda_lib()` returns `None` when it can't be opened; the public entry
// points below turn that into a clear error / empty device list, so the binary
// still builds and starts everywhere.
crate::dylib::dynamic_library! {
lib_struct: CudaLib,
loader: cuda_lib,
names: ["libcuda.so.1", "libcuda.so", "nvcuda.dll"],
fn cuInit(flags: c_uint) -> CUresult;
fn cuDeviceGetCount(count: *mut c_int) -> CUresult;
fn cuDeviceGet(device: *mut CUdevice, ordinal: c_int) -> CUresult;
@@ -148,6 +157,11 @@ extern "C" {
fn cuGetErrorName(error: CUresult, str: *mut *const c_char) -> CUresult;
}
/// Error returned when the CUDA driver library isn't present on the host.
fn cuda_unavailable() -> anyhow::Error {
anyhow!("CUDA driver library (libcuda.so.1) not found — is the NVIDIA driver installed?")
}
/// Turn a non-success `CUresult` into an error with the driver's symbolic name.
fn check(code: CUresult, what: &str) -> Result<()> {
if code == CUDA_SUCCESS {
@@ -164,8 +178,10 @@ fn check(code: CUresult, what: &str) -> Result<()> {
Err(anyhow!("{what} failed: {name}"))
}
/// Number of CUDA devices (initialises the driver as a side effect).
/// Number of CUDA devices (initialises the driver as a side effect). Returns an
/// error if the CUDA driver library isn't installed.
pub fn device_count() -> Result<usize> {
cuda_lib().ok_or_else(cuda_unavailable)?;
unsafe {
check(cuInit(0), "cuInit")?;
let mut n: c_int = 0;
@@ -579,6 +595,7 @@ impl CudaSolver {
/// fatbin, select the config that fits free VRAM, allocate its buffers, and
/// rebase the recorded launch sequence.
pub fn new(device_index: usize) -> Result<Self> {
cuda_lib().ok_or_else(cuda_unavailable)?;
unsafe {
check(cuInit(0), "cuInit")?;
let mut dev: CUdevice = 0;
+85
View File
@@ -0,0 +1,85 @@
//! Tiny runtime dynamic-library loader for the optional GPU vendor libraries.
//!
//! The CUDA driver (`libcuda`) and NVML (`libnvidia-ml`) are vendor components
//! that ship with the NVIDIA driver — they are not installable as ordinary
//! build dependencies and are absent on AMD-only / driver-less hosts. Linking
//! them at build time would (a) make the build fail without the NVIDIA libs
//! present and (b) make the resulting binary refuse to start anywhere they are
//! missing. Instead we `dlopen` them on first use: the binary has no build-time
//! or load-time dependency on them, and the CUDA backend simply reports "no
//! devices" when the driver isn't installed.
//!
//! [`dynamic_library!`] generates, for one such library, a function-pointer
//! table plus same-named wrapper `fn`s, so the call sites in [`crate::cuda`] /
//! [`crate::nvml`] are unchanged — only the `extern "C"` block is replaced.
/// Open the first of `names` that loads (e.g. the versioned SONAME first, then
/// the unversioned dev symlink). Returns the last error if none load.
pub fn load_first(names: &[&str]) -> Result<libloading::Library, libloading::Error> {
let mut last_err = None;
for name in names {
match unsafe { libloading::Library::new(name) } {
Ok(lib) => return Ok(lib),
Err(e) => last_err = Some(e),
}
}
Err(last_err.expect("load_first called with an empty name list"))
}
/// Generate a runtime-loaded binding for one shared library.
///
/// Produces a hidden fn-pointer struct, a `OnceLock`-cached loader (`$loader()`
/// returns `Option<&'static _>`, `None` when the library can't be loaded), and a
/// same-named `unsafe fn` wrapper for each declared function that dispatches
/// through the table. Public entry points must check `$loader().is_some()` (or
/// `?` on the `Option`) before invoking any wrapper; the wrappers themselves
/// panic if called with the library unloaded, which the entry-point guards
/// prevent.
macro_rules! dynamic_library {
(
lib_struct: $Lib:ident,
loader: $loader:ident,
names: [$($lname:expr),+ $(,)?],
$( fn $fname:ident($($an:ident: $at:ty),* $(,)?) -> $ret:ty; )*
) => {
#[allow(non_snake_case)]
struct $Lib {
$( $fname: unsafe extern "C" fn($($at),*) -> $ret, )*
// Keep the library mapped for the process lifetime; the fn pointers
// above point into it.
#[allow(dead_code)]
handle: libloading::Library,
}
impl $Lib {
#[allow(non_snake_case)]
unsafe fn load() -> std::result::Result<Self, libloading::Error> {
let handle = $crate::dylib::load_first(&[$($lname),+])?;
$(
let $fname: unsafe extern "C" fn($($at),*) -> $ret =
*handle.get(concat!(stringify!($fname), "\0").as_bytes())?;
)*
Ok(Self { $($fname,)* handle })
}
}
static __DYLIB: std::sync::OnceLock<Option<$Lib>> = std::sync::OnceLock::new();
/// The loaded library, or `None` if it could not be opened.
fn $loader() -> Option<&'static $Lib> {
__DYLIB.get_or_init(|| unsafe { $Lib::load().ok() }).as_ref()
}
$(
#[inline]
#[allow(non_snake_case)]
unsafe fn $fname($($an: $at),*) -> $ret {
($loader()
.expect(concat!(stringify!($fname), ": ", stringify!($Lib), " not loaded"))
.$fname)($($an),*)
}
)*
};
}
pub(crate) use dynamic_library;
+119 -5
View File
@@ -151,8 +151,9 @@ fn kernel_source(geom: &Geom) -> String {
)
}
/// A persistent OpenCL solver bound to one device.
pub struct GpuSolver {
/// The default (project-native) OpenCL solver, bound to one device. Wrapped by
/// [`GpuSolver`], which selects it for non-AMD devices.
struct LegacySolver {
pq: ProQue,
header: Buffer<u8>,
/// Per-table back-reference arrays (1 u32/slot), kept resident for recovery.
@@ -167,15 +168,14 @@ pub struct GpuSolver {
nr_rows: usize,
}
impl GpuSolver {
impl LegacySolver {
/// This device's product name (e.g. "NVIDIA GeForce RTX 5080"), if available.
pub fn device_name(&self) -> Option<String> {
self.pq.device().name().ok()
}
/// Initialise the solver and allocate all device buffers.
pub fn new(device_index: usize) -> Result<Self> {
let (platform, device) = pick_device(device_index)?;
pub fn new(platform: ocl::Platform, device: ocl::Device) -> Result<Self> {
let geom = pick_geom(&device);
// The device's platform must be set explicitly: ProQue otherwise builds
// the context against `Platform::default()` (the first platform), which
@@ -406,6 +406,101 @@ impl GpuSolver {
}
}
/// OpenCL solver for one device. Dispatches to the AMD-tuned kernel
/// (`equihash192_7.cl`) on AMD-vendor devices and the default project kernel
/// (`equihash.cl`) everywhere else. Forceable with `ZCL_OPENCL_KERNEL=amd|legacy`.
pub struct GpuSolver {
inner: SolverInner,
}
enum SolverInner {
Legacy(LegacySolver),
Amd(crate::gpu_amd::AmdSolver),
}
impl GpuSolver {
/// Initialise the solver for a flat device index, choosing the kernel by
/// device vendor (AMD → `equihash192_7.cl`).
pub fn new(device_index: usize) -> Result<Self> {
let (platform, device) = pick_device(device_index)?;
let inner = if use_amd_kernel(&device) {
log::info!("OpenCL: AMD device — using the equihash192_7 kernel");
SolverInner::Amd(crate::gpu_amd::AmdSolver::new(platform, device)?)
} else {
SolverInner::Legacy(LegacySolver::new(platform, device)?)
};
Ok(Self { inner })
}
/// This device's product name, if available.
pub fn device_name(&self) -> Option<String> {
match &self.inner {
SolverInner::Legacy(s) => s.device_name(),
SolverInner::Amd(s) => s.device_name(),
}
}
/// Solve the puzzle for `header` (140 bytes).
pub fn solve(&self, header: &[u8]) -> Result<Vec<Vec<u32>>> {
match &self.inner {
SolverInner::Legacy(s) => s.solve(header),
SolverInner::Amd(s) => s.solve(header),
}
}
/// Solve and also return the raw GPU candidate count (for diagnostics).
pub fn solve_with_stats(&self, header: &[u8]) -> Result<(usize, Vec<Vec<u32>>)> {
match &self.inner {
SolverInner::Legacy(s) => s.solve_with_stats(header),
SolverInner::Amd(s) => s.solve_with_stats(header),
}
}
/// Time each GPU stage individually.
pub fn profile(&self, header: &[u8]) -> Result<()> {
match &self.inner {
SolverInner::Legacy(s) => s.profile(header),
SolverInner::Amd(s) => s.profile(header),
}
}
/// Whether the per-index BLAKE2b probe ([`Self::hash_all`]) is available.
/// Only the default kernel exposes a linear digest layout; the AMD kernel
/// buckets in round 0, so the self-test skips the probe there.
pub fn supports_blake_probe(&self) -> bool {
matches!(self.inner, SolverInner::Legacy(_))
}
/// Compute every first-round BLAKE2b output (default kernel only).
pub fn hash_all(&self, header: &[u8]) -> Result<Vec<u8>> {
match &self.inner {
SolverInner::Legacy(s) => s.hash_all(header),
SolverInner::Amd(_) => {
Err(anyhow!("hash_all is not supported by the AMD kernel"))
}
}
}
}
/// Decide whether to drive `device` with the AMD `equihash192_7.cl` kernel.
/// `ZCL_OPENCL_KERNEL` forces the choice (`amd` or `legacy`); otherwise it's by
/// device vendor.
fn use_amd_kernel(device: &ocl::Device) -> bool {
use ocl::enums::{DeviceInfo, DeviceInfoResult};
match std::env::var("ZCL_OPENCL_KERNEL").ok().as_deref() {
Some(v) if v.eq_ignore_ascii_case("amd") => return true,
Some(v) if v.eq_ignore_ascii_case("legacy") => return false,
_ => {}
}
match device.info(DeviceInfo::Vendor) {
Ok(DeviceInfoResult::Vendor(v)) => {
let v = v.to_ascii_lowercase();
v.contains("advanced micro devices") || v.contains("amd")
}
_ => false,
}
}
/// List `(platform, device)` names so the user can choose `--device`.
pub fn list_devices() -> Result<Vec<String>> {
use ocl::{Device, Platform};
@@ -422,6 +517,25 @@ pub fn list_devices() -> Result<Vec<String>> {
Ok(names)
}
/// For each OpenCL device — in the same flat order as [`list_devices`] and
/// `--devices` — whether its vendor is NVIDIA. The mixed backend uses this to
/// hand NVIDIA cards to CUDA (and mine only the non-NVIDIA OpenCL devices).
pub fn device_is_nvidia() -> Vec<bool> {
use ocl::enums::{DeviceInfo, DeviceInfoResult};
use ocl::{Device, Platform};
let mut out = Vec::new();
for platform in Platform::list() {
for device in Device::list_all(platform).unwrap_or_default() {
let is_nv = matches!(
device.info(DeviceInfo::Vendor),
Ok(DeviceInfoResult::Vendor(v)) if v.to_ascii_lowercase().contains("nvidia")
);
out.push(is_nv);
}
}
out
}
/// The flat OpenCL device index of the first CPU-type device (e.g. PoCL), if any.
/// Lets CPU mining run through the OpenCL backend on the CPU. The index matches
/// [`list_devices`] / `--devices`.
+286
View File
@@ -0,0 +1,286 @@
//! AMD OpenCL Equihash 192,7 solver (`kernels/equihash192_7.cl`).
//!
//! A second OpenCL backend, selected for AMD-vendor devices by
//! [`crate::gpu::GpuSolver`]. Where the default [`crate::gpu`] driver runs the
//! project's own `gen`/`round_collide`/`recover` kernel, this one drives a
//! self-contained, GCN-tuned kernel with a fixed table geometry and a different
//! host ABI: a `clearCounter` → `blake` → `round1..round7` → `combine`
//! pipeline.
//!
//! ## Geometry (hard-coded in the kernel, mirrored here)
//!
//! 2^25 initial entries are bucketed into `NR_ROWS = 8192` rows. Round 0 and the
//! early rounds keep `SLOTS_R0 = 4592` `uint8` slots per row (`buffer0`/
//! `buffer1` ping-pong, ~1.2 GB each); rounds 45 widen to `SLOTS_R45 = 8688`
//! `uint4` slots in `buffer2` (~1.1 GB). `buffer1` is additionally reinterpreted
//! as `uint4`/`uint2` for the late-round R46/R57 outputs at fixed offsets. A
//! flat counter array of 8 banks × 16384 tracks per-row occupancy; the
//! round-7/R5 survivor count lives at index `R5_COUNTER_IDX = 114688` and sizes
//! the `combine` launch.
//!
//! ## Hashing ABI
//!
//! `blake` takes the BLAKE2b first-block midstate as a by-value `ulong8`
//! (`hashState`, from [`BatchHasher::midstate`]) plus a `nonce` whose low 32
//! bits become message word `m[1]`'s low half (= header bytes [136..140]); the
//! kernel hard-codes `m[0] = 0`, so the header's bytes [128..136] must be zero
//! (the same `cuda_compatible` rule the CUDA backend uses). Each work item hashes
//! index `tId`, emitting the two leaf entries `2*tId` and `2*tId+1` — exactly the
//! canonical leaf-index/sub-block split [`crate::equihash`] verifies against.
//!
//! `combine` writes recovered solutions to the `output0`/`res` buffer:
//! `output0[0].s0` is the solution count and each solution is 32 `uint4`
//! (`SOLUTION_INDICES = 128` pre-sorted 25-bit leaf indices) at
//! `output0[1 + 32*i ..]`. Those flatten straight into
//! [`equihash::filter_candidates`], which canonicalises, verifies and
//! de-duplicates them — the same contract as the default driver.
use anyhow::{anyhow, Result};
use ocl::prm::Ulong8;
use ocl::{Buffer, MemFlags, ProQue};
use crate::blake::BatchHasher;
use crate::equihash;
use crate::params::{BLAKE_CALLS, HEADER_LEN, SOLUTION_INDICES};
/// Buckets ("rows") the 2^25 entries are hashed into (kernel: `& 0x1FFF`).
const NR_ROWS: usize = 8192;
/// `uint8` slots per row in the round-0/early `buffer0`/`buffer1` tables.
const SLOTS_R0: usize = 4592;
/// `uint4` slots per row in the round-4/5 `buffer2` table.
const SLOTS_R45: usize = 8688;
/// `uint8` entries in `buffer0`/`buffer1` (the kernel's `37617664` bound).
const BUF01_U8ENTRIES: usize = NR_ROWS * SLOTS_R0;
/// `uint4` entries in `buffer2`.
const BUF2_U4ENTRIES: usize = NR_ROWS * SLOTS_R45;
/// Flat counter array: 8 banks × 16384 (one bank consumed per round).
const COUNTERS_U32: usize = 8 * 16384;
/// Counter index holding the round-7 / R5 survivor count (sizes `combine`).
const R5_COUNTER_IDX: usize = 114688;
/// `combine` only emits solutions for `addr < this` (matches the kernel cap);
/// far above the ~2 solutions a 192,7 nonce yields. Reads are capped to match.
const MAX_WRITTEN_SOLS: usize = 16;
/// Solution buffer capacity in solutions (`output0` = 1 + 32*cap `uint4`).
const SOL_CAP: usize = 16;
/// `reqd_work_group_size` of `blake`/`combine`.
const WG_BLAKE: usize = 64;
/// `reqd_work_group_size` of `round1..round7`.
const WG_ROUND: usize = 256;
/// Input rows (buckets) each collision round reads. The table is keyed on 13
/// bits (8192) through round 4, then narrows to 12 bits (4096) — a round that
/// reads `b` rows must launch exactly `b * 4` work-groups (kernel:
/// `bucket = grp >> 2`), or it processes uninitialised rows and explodes.
const ROUND_BUCKETS: [usize; 7] = [8192, 8192, 8192, 8192, 4096, 4096, 4096];
/// Extra rows of slack appended to each big table. The kernel's per-row
/// `atomic_inc` writes are uncapped, so a row that overflows its slot count
/// spills into the next row and the top row spills past the nominal table end;
/// this slack absorbs that (mean occupancy ~4096 sits well under the 4592/8688
/// slot counts, so realistic overflow is a few rows at most).
const ROW_SLACK: usize = 64;
const KERNEL_SRC: &str = include_str!("../kernels/equihash192_7.cl");
/// A persistent AMD OpenCL solver bound to one device.
pub struct AmdSolver {
pq: ProQue,
/// Round-0/early ping-pong tables (`uint8`), reinterpreted at narrower
/// widths in late rounds.
buffer0: Buffer<u32>,
buffer1: Buffer<u32>,
/// Round-4/5 wide table (`uint4`).
buffer2: Buffer<u32>,
/// Per-row occupancy counters (8 banks).
counters: Buffer<u32>,
/// `res` / `output0`: `[count, then 32 uint4 per solution]`.
sols: Buffer<u32>,
}
unsafe impl Send for AmdSolver {}
impl AmdSolver {
/// This device's product name, if available.
pub fn device_name(&self) -> Option<String> {
self.pq.device().name().ok()
}
/// Build the solver on `(platform, device)` and allocate all device buffers
/// (~3.5 GB total).
pub fn new(platform: ocl::Platform, device: ocl::Device) -> Result<Self> {
let pq = ProQue::builder()
.src(KERNEL_SRC)
.platform(platform)
.device(device)
.dims(1) // placeholder; every launch sets its own work size
.build()
.map_err(|e| anyhow!("AMD OpenCL build failed: {e}"))?;
let alloc = |len: usize| -> Result<Buffer<u32>> {
Ok(Buffer::<u32>::builder()
.queue(pq.queue().clone())
.flags(MemFlags::new().read_write())
.len(len)
.build()?)
};
// uint8 entries → 8 u32 each; uint4 entries → 4 u32 each. Each table gets
// ROW_SLACK extra rows of write headroom (see ROW_SLACK). buffer1's
// nominal uint8 size (75.2M uint4) already covers the late-round R46/R57
// regions at fixed offsets 48496640 / 67305472.
let buffer0 = alloc((BUF01_U8ENTRIES + ROW_SLACK * SLOTS_R0) * 8)?;
let buffer1 = alloc((BUF01_U8ENTRIES + ROW_SLACK * SLOTS_R0) * 8)?;
let buffer2 = alloc((BUF2_U4ENTRIES + ROW_SLACK * SLOTS_R45) * 4)?;
let counters = alloc(COUNTERS_U32)?;
let sols = alloc((1 + 32 * SOL_CAP) * 4)?;
Ok(Self { pq, buffer0, buffer1, buffer2, counters, sols })
}
/// Set the eight by-value args shared by every kernel in the pipeline:
/// `(buffer0, buffer1, buffer2, counters, res, extra, hashState, nonce)`.
fn build_kernel(&self, name: &str, hash_state: Ulong8, nonce: u64) -> Result<ocl::Kernel> {
Ok(self
.pq
.kernel_builder(name)
.arg(&self.buffer0)
.arg(&self.buffer1)
.arg(&self.buffer2)
.arg(&self.counters)
.arg(&self.sols)
.arg(0u32) // extra (unused by the pipeline)
.arg(hash_state)
.arg(nonce)
.build()?)
}
/// Run the full pipeline for `header` and return the flat recovered leaf
/// indices (`n * SOLUTION_INDICES`), ready for [`equihash::filter_candidates`].
fn run_pipeline(&self, header: &[u8]) -> Result<Vec<u32>> {
let mid = BatchHasher::new(header).midstate();
let hash_state = Ulong8::new(
mid[0], mid[1], mid[2], mid[3], mid[4], mid[5], mid[6], mid[7],
);
// Kernel's gId = nonce & 0xFFFFFFFF = message word m[1] low = header[136..140].
let nonce = u32::from_le_bytes(header[136..140].try_into().unwrap()) as u64;
// Clear counters + solution header (global = counter uint4 count).
let clear = self.build_kernel("clearCounter", hash_state, nonce)?;
unsafe {
clear.cmd().global_work_size(COUNTERS_U32 / 4).enq()?;
}
// Round 0: BLAKE2b + bucket. One work item per blake call (2^24); each
// emits two leaf entries.
let blake = self.build_kernel("blake", hash_state, nonce)?;
unsafe {
blake
.cmd()
.global_work_size(BLAKE_CALLS)
.local_work_size(WG_BLAKE)
.enq()?;
}
// Collision rounds 1..7 (4 groups per input row, 256 work items each).
for r in 1..=7 {
let k = self.build_kernel(&format!("round{r}"), hash_state, nonce)?;
unsafe {
k.cmd()
.global_work_size(ROUND_BUCKETS[r - 1] * 4 * WG_ROUND)
.local_work_size(WG_ROUND)
.enq()?;
}
}
// Size `combine` from the round-7 survivor count (one group per candidate).
let mut r5 = [0u32; 1];
self.counters
.read(&mut r5[..])
.offset(R5_COUNTER_IDX)
.enq()?;
let groups = r5[0] as usize;
if groups == 0 {
return Ok(Vec::new());
}
let combine = self.build_kernel("combine", hash_state, nonce)?;
unsafe {
combine
.cmd()
.global_work_size(groups * WG_BLAKE)
.local_work_size(WG_BLAKE)
.enq()?;
}
// output0[0].s0 = solution count; each solution is 128 u32 (32 uint4)
// starting at uint4 index 1 (= u32 offset 4).
let mut head = [0u32; 1];
self.sols.read(&mut head[..]).enq()?;
let nsols = (head[0] as usize).min(MAX_WRITTEN_SOLS);
if nsols == 0 {
return Ok(Vec::new());
}
let mut data = vec![0u32; nsols * SOLUTION_INDICES];
self.sols.read(&mut data[..]).offset(4).enq()?;
Ok(data)
}
/// Solve for `header` (140 bytes): returns valid, canonical, de-duplicated
/// solutions as leaf-index lists.
pub fn solve(&self, header: &[u8]) -> Result<Vec<Vec<u32>>> {
assert_eq!(header.len(), HEADER_LEN);
let base = crate::blake::base_state(header);
let out = self.run_pipeline(header)?;
Ok(equihash::filter_candidates(&base, &out))
}
/// Solve and also return the raw recovered-candidate count (for diagnostics).
pub fn solve_with_stats(&self, header: &[u8]) -> Result<(usize, Vec<Vec<u32>>)> {
assert_eq!(header.len(), HEADER_LEN);
let base = crate::blake::base_state(header);
let out = self.run_pipeline(header)?;
let raw = out.len() / SOLUTION_INDICES;
Ok((raw, equihash::filter_candidates(&base, &out)))
}
/// Time each pipeline stage individually (forces a sync between stages).
pub fn profile(&self, header: &[u8]) -> Result<()> {
use log::info;
use std::time::Instant;
let mid = BatchHasher::new(header).midstate();
let hash_state = Ulong8::new(
mid[0], mid[1], mid[2], mid[3], mid[4], mid[5], mid[6], mid[7],
);
let nonce = u32::from_le_bytes(header[136..140].try_into().unwrap()) as u64;
let q = self.pq.queue();
let stage = |label: &str, t: Instant| -> Result<()> {
q.finish().map_err(|e| anyhow!("{label} failed: {e}"))?;
info!(" {label:14} {:6.1} ms", t.elapsed().as_secs_f64() * 1000.0);
Ok(())
};
let t = Instant::now();
let clear = self.build_kernel("clearCounter", hash_state, nonce)?;
unsafe {
clear.cmd().global_work_size(COUNTERS_U32 / 4).enq()?;
}
stage("clear", t)?;
let t = Instant::now();
let blake = self.build_kernel("blake", hash_state, nonce)?;
unsafe {
blake.cmd().global_work_size(BLAKE_CALLS).local_work_size(WG_BLAKE).enq()?;
}
stage("blake", t)?;
for r in 1..=7 {
let t = Instant::now();
let k = self.build_kernel(&format!("round{r}"), hash_state, nonce)?;
unsafe {
k.cmd()
.global_work_size(ROUND_BUCKETS[r - 1] * 4 * WG_ROUND)
.local_work_size(WG_ROUND)
.enq()?;
}
stage(&format!("round {r}"), t)?;
}
Ok(())
}
}
+41 -17
View File
@@ -1,11 +1,12 @@
//! GPU device probing for the config tool (`jackpotminer-config` only — this is
//! not compiled into the miner, so there is no duplicate FFI).
//!
//! With the `gpu`/`cuda` features the OpenCL/CUDA SDKs are linked in (build.rs
//! links `cuda`/`nvml`; the `ocl` crate links `OpenCL`), and the tool enumerates
//! devices directly — handy on Windows where you may not want to shell out to
//! the miner. Without those features the functions return empty lists and the
//! tool falls back to spawning `jackpotminer --devices-json`.
//! With the `gpu`/`cuda` features the tool enumerates devices directly — handy
//! on Windows where you may not want to shell out to the miner. OpenCL goes
//! through the `ocl` crate; CUDA is `dlopen`'d at runtime (so this binary, like
//! the miner, has no build- or load-time dependency on libcuda). Without those
//! features the functions return empty lists and the tool falls back to spawning
//! `jackpotminer --devices-json`.
/// True when at least one GPU SDK is compiled in, so direct probing works.
pub const HAS_SDK: bool = cfg!(feature = "gpu") || cfg!(feature = "cuda");
@@ -34,34 +35,57 @@ pub fn opencl() -> Vec<String> {
}
/// CUDA devices as `"[i] <name>"` via the driver API (empty without the SDK, no
/// driver, or any error). Uses a tiny self-contained FFI subset.
/// driver, or any error). The CUDA driver is `dlopen`'d at runtime — a tiny
/// self-contained subset of the FFI in `src/cuda.rs` — so this binary needs no
/// link- or load-time libcuda.
#[cfg(feature = "cuda")]
pub fn cuda() -> Vec<String> {
use std::ffi::CStr;
use std::os::raw::{c_char, c_int, c_uint};
// Linked via build.rs (`cuda`), matching src/cuda.rs's declarations.
extern "C" {
fn cuInit(flags: c_uint) -> c_int;
fn cuDeviceGetCount(count: *mut c_int) -> c_int;
fn cuDeviceGet(device: *mut c_int, ordinal: c_int) -> c_int;
fn cuDeviceGetName(name: *mut c_char, len: c_int, dev: c_int) -> c_int;
}
type CuInit = unsafe extern "C" fn(c_uint) -> c_int;
type CuCount = unsafe extern "C" fn(*mut c_int) -> c_int;
type CuGet = unsafe extern "C" fn(*mut c_int, c_int) -> c_int;
type CuName = unsafe extern "C" fn(*mut c_char, c_int, c_int) -> c_int;
let mut out = Vec::new();
unsafe {
if cuInit(0) != 0 {
// libcuda.so.1 ships with the NVIDIA driver; absent on AMD-only hosts.
let lib = match ["libcuda.so.1", "libcuda.so", "nvcuda.dll"]
.iter()
.find_map(|n| libloading::Library::new(n).ok())
{
Some(l) => l,
None => return out,
};
let sym = |name: &[u8]| -> Option<*mut std::ffi::c_void> {
lib.get::<*mut std::ffi::c_void>(name).ok().map(|s| *s)
};
let (Some(init), Some(count), Some(get), Some(getname)) = (
sym(b"cuInit\0"),
sym(b"cuDeviceGetCount\0"),
sym(b"cuDeviceGet\0"),
sym(b"cuDeviceGetName\0"),
) else {
return out;
};
let cu_init: CuInit = std::mem::transmute(init);
let cu_count: CuCount = std::mem::transmute(count);
let cu_get: CuGet = std::mem::transmute(get);
let cu_name: CuName = std::mem::transmute(getname);
if cu_init(0) != 0 {
return out;
}
let mut n: c_int = 0;
if cuDeviceGetCount(&mut n) != 0 {
if cu_count(&mut n) != 0 {
return out;
}
for i in 0..n {
let mut dev: c_int = 0;
let name = if cuDeviceGet(&mut dev, i) == 0 {
let name = if cu_get(&mut dev, i) == 0 {
let mut buf = [0i8; 128];
if cuDeviceGetName(buf.as_mut_ptr() as *mut c_char, 128, dev) == 0 {
if cu_name(buf.as_mut_ptr() as *mut c_char, 128, dev) == 0 {
CStr::from_ptr(buf.as_ptr() as *const c_char).to_string_lossy().into_owned()
} else {
format!("CUDA device {i}")
+125 -15
View File
@@ -14,6 +14,14 @@ mod tui;
#[cfg(feature = "gpu")]
mod gpu;
// AMD-tuned OpenCL kernel driver (selected by GpuSolver for AMD-vendor devices).
#[cfg(feature = "gpu")]
mod gpu_amd;
// Runtime dynamic-library loader (dlopen) for the CUDA driver + NVML.
#[cfg(feature = "cuda")]
mod dylib;
#[cfg(feature = "cuda")]
mod cuda;
@@ -79,8 +87,9 @@ struct Args {
jackpot: Option<u32>,
/// Pause mining if no new job arrives within this many seconds (stale work
/// guard); resumes automatically when fresh work arrives. 0 disables.
#[arg(long, value_name = "SECS", default_value_t = 300)]
/// guard); resumes automatically when fresh work arrives. Default 600 (10
/// minutes). 0 disables.
#[arg(long, value_name = "SECS", default_value_t = 600)]
job_timeout: u64,
/// Open a local control server on 127.0.0.1:<PORT> so the GUI config tool can
@@ -139,8 +148,11 @@ struct Args {
#[arg(long, default_value = "all")]
devices: String,
/// GPU backend: "opencl" or "cuda" (for nvidia cards).
#[arg(long, default_value = "cuda")]
/// GPU backend: "mixed" (default — each card on its native backend: NVIDIA
/// on CUDA, AMD/Intel on OpenCL), "opencl" (every card via OpenCL), or
/// "cuda" (NVIDIA only). In mixed mode `--devices` indexes the combined list
/// shown by --list-devices.
#[arg(long, default_value = "mixed")]
backend: String,
/// Force the OpenCL backend, disabling CUDA (overrides --backend).
@@ -610,6 +622,9 @@ fn main() -> Result<()> {
/// Which GPU backend the user selected.
enum BackendKind {
Cpu,
/// Each physical card on its native backend (NVIDIA→CUDA, others→OpenCL).
#[cfg(any(feature = "gpu", feature = "cuda"))]
Mixed,
#[cfg(feature = "gpu")]
OpenCl,
#[cfg(feature = "cuda")]
@@ -633,6 +648,16 @@ fn backend_kind(args: &Args) -> Result<BackendKind> {
}
}
match args.backend.to_ascii_lowercase().as_str() {
"mixed" => {
// Each card on its native backend; falls back to whatever single GPU
// backend is compiled, or to CPU when none is.
#[cfg(any(feature = "gpu", feature = "cuda"))]
{
Ok(BackendKind::Mixed)
}
#[cfg(not(any(feature = "gpu", feature = "cuda")))]
Ok(BackendKind::Cpu)
}
"cuda" => {
#[cfg(feature = "cuda")]
{
@@ -649,7 +674,7 @@ fn backend_kind(args: &Args) -> Result<BackendKind> {
#[cfg(not(feature = "gpu"))]
Ok(BackendKind::Cpu)
}
other => Err(anyhow!("unknown --backend '{other}' (expected opencl or cuda)")),
other => Err(anyhow!("unknown --backend '{other}' (expected mixed, opencl, or cuda)")),
}
}
@@ -707,6 +732,8 @@ fn backend_specs(args: &Args, gpu_devices: &[GpuDeviceCfg]) -> Result<Vec<Backen
let clamp = (args.cpu_clamp != 0).then_some(args.cpu_clamp);
return Ok(vec![BackendSpec::Cpu(clamp)]);
}
// Mixed builds its own unified list (each card on its native backend).
BackendKind::Mixed => return mixed_specs(args),
#[cfg(feature = "cuda")]
BackendKind::Cuda => (cuda::device_count()?, true),
#[cfg(feature = "gpu")]
@@ -735,6 +762,71 @@ fn backend_specs(args: &Args, gpu_devices: &[GpuDeviceCfg]) -> Result<Vec<Backen
}
}
/// The unified device list for the `mixed` backend, as `(label, spec)`: each
/// physical GPU on its native backend, with no card mined twice. NVIDIA cards go
/// to CUDA (listed first); the remaining OpenCL devices (AMD/Intel, plus NVIDIA
/// when CUDA is unavailable) go to OpenCL. Shared by [`mixed_specs`] and
/// [`list_devices`]; `--devices` indexes into this list.
#[cfg(any(feature = "gpu", feature = "cuda"))]
fn mixed_plan() -> Vec<(String, BackendSpec)> {
/// Drop a leading `"[<n>] "` index prefix from a backend's device label, so
/// the mixed list shows its own single index instead of two.
fn strip_index(label: &str) -> &str {
label
.strip_prefix('[')
.and_then(|s| s.split_once("] "))
.map(|(_, rest)| rest)
.unwrap_or(label)
}
#[allow(unused_mut)]
let mut plan: Vec<(String, BackendSpec)> = Vec::new();
// NVIDIA cards via CUDA, when the backend is compiled and the driver loads.
#[cfg(feature = "cuda")]
let cuda_has_nvidia = {
let names = cuda::list_devices().unwrap_or_default();
for (i, label) in names.iter().enumerate() {
plan.push((format!("{} (CUDA)", strip_index(label)), BackendSpec::Cuda(i)));
}
!names.is_empty()
};
#[cfg(not(feature = "cuda"))]
let cuda_has_nvidia = false;
// Remaining OpenCL cards via OpenCL; skip NVIDIA ones already on CUDA.
#[cfg(feature = "gpu")]
{
let names = gpu::list_devices().unwrap_or_default();
let nvidia = gpu::device_is_nvidia();
for (j, label) in names.iter().enumerate() {
if nvidia.get(j).copied().unwrap_or(false) && cuda_has_nvidia {
continue;
}
plan.push((format!("{} (OpenCL)", strip_index(label)), BackendSpec::Gpu(j)));
}
}
// `cuda_has_nvidia` is only consumed by the OpenCL branch above.
#[cfg(not(feature = "gpu"))]
let _ = cuda_has_nvidia;
plan
}
/// Build the worker list for `--backend mixed`: each card on its native backend.
/// `--devices` selects into [`mixed_plan`]'s unified list.
#[cfg(any(feature = "gpu", feature = "cuda"))]
fn mixed_specs(args: &Args) -> Result<Vec<BackendSpec>> {
let plan = mixed_plan();
if plan.is_empty() {
return Err(anyhow!(
"no GPUs found for the mixed backend — none detected via CUDA or OpenCL"
));
}
let selected = parse_devices(&args.devices, plan.len())?;
Ok(selected.into_iter().map(|i| plan[i].1).collect())
}
/// Build a single GPU worker spec for `idx`, choosing CUDA or OpenCL, erroring if
/// the requested backend wasn't compiled in.
#[cfg(any(feature = "gpu", feature = "cuda"))]
@@ -821,6 +913,18 @@ fn list_devices() {
Ok(_) => println!("no CUDA devices found"),
Err(e) => println!("error listing CUDA devices: {e}"),
}
// What the default `mixed` backend will mine, and the indices `--devices`
// selects from in that mode.
#[cfg(any(feature = "gpu", feature = "cuda"))]
{
let plan = mixed_plan();
if !plan.is_empty() {
println!("\nMixed backend (--backend mixed, the default) — `--devices` indexes this list:");
for (i, (label, _)) in plan.iter().enumerate() {
println!(" [{i}] {label}");
}
}
}
#[cfg(not(any(feature = "gpu", feature = "cuda")))]
println!("built without GPU support (rebuild with the `gpu` or `cuda` feature)");
}
@@ -886,18 +990,24 @@ fn selftest(gpu_device: usize) -> Result<()> {
let solver = gpu::GpuSolver::new(gpu_device)
.with_context(|| format!("init OpenCL device {gpu_device}"))?;
// Spot-check the BLAKE2b kernel against the CPU reference.
let outputs = solver.hash_all(&header)?;
let step = params::BLAKE_CALLS / 64;
for k in 0..64 {
let g = (k * step) as u32;
let cpu = blake::generate_hash(&base, g);
let off = g as usize * params::HASH_OUTPUT;
if cpu != outputs[off..off + params::HASH_OUTPUT] {
return Err(anyhow!("GPU BLAKE2b mismatch at g={g}"));
// Spot-check the BLAKE2b kernel against the CPU reference. The AMD kernel
// buckets its round-0 output instead of exposing per-index digests, so
// the probe is skipped there (the solve-vs-CPU check below still runs).
if solver.supports_blake_probe() {
let outputs = solver.hash_all(&header)?;
let step = params::BLAKE_CALLS / 64;
for k in 0..64 {
let g = (k * step) as u32;
let cpu = blake::generate_hash(&base, g);
let off = g as usize * params::HASH_OUTPUT;
if cpu != outputs[off..off + params::HASH_OUTPUT] {
return Err(anyhow!("GPU BLAKE2b mismatch at g={g}"));
}
}
info!("GPU BLAKE2b kernel matches CPU");
} else {
info!("skipping BLAKE2b kernel probe (AMD kernel buckets round-0 output)");
}
info!("GPU BLAKE2b kernel matches CPU");
let gpu_solutions = solver.solve(&header)?;
info!("GPU found {} valid solution(s)", gpu_solutions.len());
+10 -1
View File
@@ -33,7 +33,15 @@ const NVML_CLOCK_MEM: c_int = 2;
// nvmlTemperatureSensors_t
const NVML_TEMPERATURE_GPU: c_int = 0;
extern "C" {
// NVML, loaded at runtime via dlopen (see `crate::dylib`) rather than linked at
// build time — it ships with the NVIDIA driver (`libnvidia-ml.so.1`;
// `nvml.dll` on Windows) and is absent on driver-less / AMD-only hosts.
// `nvml_lib()` is `None` when it can't be opened; `open()` checks it first and
// returns `None` (no tuning) so the rest of the program is unaffected.
crate::dylib::dynamic_library! {
lib_struct: NvmlLib,
loader: nvml_lib,
names: ["libnvidia-ml.so.1", "libnvidia-ml.so", "nvml.dll"],
fn nvmlInit_v2() -> nvmlReturn_t;
fn nvmlShutdown() -> nvmlReturn_t;
fn nvmlDeviceGetName(device: nvmlDevice_t, name: *mut c_char, length: c_uint) -> nvmlReturn_t;
@@ -69,6 +77,7 @@ unsafe impl Send for NvmlTuner {}
/// Open an NVML control handle for the GPU at `pci_bus_id` (e.g. "0000:01:00.0").
pub fn open(pci_bus_id: &str) -> Option<Box<dyn GpuTuner>> {
nvml_lib()?; // NVML not installed → no tuning
let cstr = CString::new(pci_bus_id).ok()?;
unsafe {
if nvmlInit_v2() != NVML_SUCCESS {