igneum/docs/analysis/hashrate-decay-2026-10-03.patch
igneum-josh 86fe1ec8b1 Hash-rate decay on the RTX 5090: diagnosis, Metal reproduction, proposed Seeder fix
No per-job growth in any worker or in the miner's memory. The STATUS rates are cumulative averages
(a fast first interval decays by construction), and the miner's Seeder walks the selected chain from
the sink to the epoch start on every memo miss (one getBlock per block, up to 3,600), a gap between
jobs that grew 0.10 s to 0.33 s across epoch 2 on the PC and reset at the epoch boundary while the
difficulty held. Reproduced on the Metal worker (churn on, off, on). One versus eight workers at two
fixed difficulties: 4% and 1% constant cost, no decay. Fix as a unified diff, not applied.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 23:31:23 +00:00

104 lines
5.1 KiB
Diff

--- a/igneum/miner/src/main.rs
+++ b/igneum/miner/src/main.rs
@@ -366,8 +366,15 @@
/// selected parent, which is the selected parent of any block built on the current template).
struct Seeder {
genesis: Hash,
- /// (epoch index, sink the walk started from) -> seed
- memo: HashMap<(u64, Hash), Hash>,
+ /// epoch index -> (selected-chain block -> that epoch's seed) for every block a walk has visited. A block's seed
+ /// is a function of the block alone (its selected parent chain is fixed), so a walk from a new sink stops at the
+ /// first block already in the map: a job costs as many `get_block` calls as the sink advanced, not the DAA
+ /// position in the epoch (the old `(epoch, sink)` key missed on every new block and walked back to the epoch's
+ /// first block, up to 3,600 calls, measured on the RTX 5090 on 3 October 2026 as a gap between jobs that grew
+ /// from 0.10 s to 0.33 s across epoch 2; docs/analysis/hashrate-decay-2026-10-03.md).
+ memo: HashMap<u64, HashMap<Hash, Hash>>,
+ /// `get_block` calls made by seed walks, for the STATUS line
+ walk_calls: u64,
}
impl Seeder {
@@ -392,7 +399,7 @@
if genesis != DEVNET_PARAMS.genesis.hash {
println!("{} node genesis {} differs from the shared devnet genesis: a private test network", now(), genesis);
}
- Self { genesis, memo: HashMap::new() }
+ Self { genesis, memo: HashMap::new(), walk_calls: 0 }
}
async fn seeds_for(&mut self, client: &GrpcClient, header: &Header) -> EpochSeeds {
@@ -402,25 +409,32 @@
return EpochSeeds { epoch: self.genesis, day };
}
let sink = client.get_block_dag_info().await.expect("dag info").sink;
- if let Some(seed) = self.memo.get(&(epoch, sink)) {
- return EpochSeeds { epoch: *seed, day };
- }
+ // This epoch's map and the previous one stay (a template can still land in the previous epoch at the
+ // boundary); older ones go. A map holds at most one entry per selected-chain block of its epoch (3,600).
+ self.memo.retain(|e, _| *e + 1 >= epoch);
+ let known = self.memo.entry(epoch).or_default();
let epoch_start = epoch * POW_EPOCH_BLOCKS;
let mut cur = sink;
+ let mut path = Vec::new();
let seed = loop {
+ if let Some(seed) = known.get(&cur) {
+ break *seed;
+ }
if cur == self.genesis {
break self.genesis;
}
let b = client.get_block(cur, false).await.expect("get block");
+ self.walk_calls += 1;
if b.header.daa_score < epoch_start {
break cur;
}
+ path.push(cur);
cur = b.verbose_data.expect("verbose data").selected_parent_hash;
};
- if self.memo.len() > 10_000 {
- self.memo.clear();
+ for h in path {
+ known.insert(h, seed);
}
- self.memo.insert((epoch, sink), seed);
+ known.insert(sink, seed);
EpochSeeds { epoch: seed, day }
}
}
@@ -707,6 +721,9 @@
let mut gpu_ms_total = 0.0f64;
let mut jobs = 0u64;
let mut last_report = Instant::now();
+ // The previous STATUS line's counters, for the per-interval rates (the cumulative ones decay by construction
+ // after a fast first interval, which read as a hash-rate decay on 3 October 2026)
+ let (mut last_hashes, mut last_gpu_ms, mut last_jobs, mut last_walk) = (0u64, 0.0f64, 0u64, 0u64);
let mut last_seeds: Option<EpochSeeds> = None;
let mut rng = rand::thread_rng();
while start.elapsed() < Duration::from_secs(o.secs) {
@@ -813,8 +830,9 @@
}
if last_report.elapsed() > Duration::from_secs(o.status_secs) {
let el = start.elapsed().as_secs_f64();
+ let (dt, dh, dg) = (last_report.elapsed().as_secs_f64(), (hashes_total - last_hashes) as f64, gpu_ms_total - last_gpu_ms);
println!(
- "{} STATUS '{}' [worker]: {:.0}s jobs={} accepted={} rejected={} mismatched={} extra={} rate={:.2} blocks/s hash={:.2} MH/s wall ({:.2} MH/s inside jobs) template_age={:.2}s synced={}",
+ "{} STATUS '{}' [worker]: {:.0}s jobs={} accepted={} rejected={} mismatched={} extra={} rate={:.2} blocks/s hash={:.2} MH/s wall ({:.2} MH/s inside jobs) now={:.2} MH/s wall ({:.2} MH/s inside jobs, {} jobs, seed walk {} calls) template_age={:.2}s synced={}",
now(),
o.vote_label,
el,
@@ -826,9 +844,14 @@
found as f64 / el,
hashes_total as f64 / el / 1e6,
if gpu_ms_total > 0.0 { hashes_total as f64 / gpu_ms_total / 1e3 } else { 0.0 },
+ if dt > 0.0 { dh / dt / 1e6 } else { 0.0 },
+ if dg > 0.0 { dh / dg / 1e3 } else { 0.0 },
+ jobs - last_jobs,
+ seeder.walk_calls - last_walk,
template_at.elapsed().as_secs_f64(),
synced
);
+ (last_hashes, last_gpu_ms, last_jobs, last_walk) = (hashes_total, gpu_ms_total, jobs, seeder.walk_calls);
last_report = Instant::now();
}
}