upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/rpc.cpp
diff options
context:
space:
mode:
authorbitflash-maintainer <noreply@bitflash.invalid>2026-09-02 15:23:02 +0000
committerbitflash-maintainer <noreply@bitflash.invalid>2026-09-02 15:23:02 +0000
commit07051b2378734d62fb94a4a62819d8150452b661 (patch)
treeb20b912b18d5da1f524209bedda395d155fe11ef /src/rpc.cpp
parentf5def7625e6369d117cc8f02b7c0244b70d6f779 (diff)
pool: compute a miner's faded hashrate instead of decaying it in place
2bca62a decayed m->hashRate from the event loop's miner sweep, which turned a figure that is only ever displayed into an accumulator, and got the curve wrong at both ends. Each pass recomputed `expected` -- and so `grace` -- from the estimate it had just decayed. Every decay raised `expected`, which raised the grace that gates the next decay, so `grace` chased `silent` upward and the reported rate settled into a 1/t crawl instead of the half-life the commit's own comment documents: 291x too high at ten half-lives past the grace, and still announcing 0.4% of baseline four hours into a silence. #7 is about the pool over-reporting its hashrate, and this under-corrected it for hours. The far end was worse. Once `expected` reached HASHRATE_MAX_INTERVAL it could not rise any further, the feedback that had been gating each pass was gone, and the decay did begin to compound -- once a second, against the total elapsed silence, because lastShareTime does not advance while a miner is quiet and nothing recorded when the decay had last run. A miner four hours silent fell from 0.4% of its baseline to 1e-11 of it over the next hundred seconds, with nothing in the code or the announced number to explain the cliff. Neither behaviour is worth tuning, so drop the sweep. hashRate is read in exactly one place -- RefreshStats, building the per-worker rows and the total that the pool status and the nostr announcement publish -- and vardiff retargets from share timing alone and never reads it. So leave the stored value as the sample it always was and have RefreshStats ask MinerReportedHashRate() what to publish: a pure function of that sample and the absolute silence since the last share. With no stored decay state there is nothing for a tick rate, a missed pass or a repeated pass to perturb, and the answer is bit-identical from 1/60 Hz to 1000 Hz because nothing accumulates, not because the arithmetic was written carefully. It also keeps the fade out of the estimator: the EWMA on the share path still blends against the value it last computed rather than a faded copy of it, so a miner that resumes recovers exactly as it did before any of this, and there is no decay timestamp for a newly accepted or newly authorised connection to be caught holding unset. RefreshStats takes one GetTime() for the whole sweep, so the rows and the total they are summed into describe a single instant rather than a smear across it. The clamp is written !(expected > MIN) so a NaN lands on the floor instead of sailing through; a negative `silent` from a clock stepping backwards reports the sample unchanged rather than scaling it up; and past HASHRATE_DECAY_CUTOFF half-lives the answer is a flat zero rather than pow() walking down into the denormals. This also drops the std::max(m->hashRate, 1e-9) the old pass carried, which the `hashRate <= 0.0` guard immediately above it had already made dead. pool-stratum gains ten checks over the policy: the full estimate inside the grace, a thousand samples at one instant returning one value and leaving the estimate untouched, a few percent lost one second past the grace rather than the whole estimate, exactly half at one expected interval past it, zero after an hour of silence, no inflation from a backwards clock, zero for a miner that has no rate sample yet, and nothing non-finite anywhere across a long silence.
Diffstat (limited to 'src/rpc.cpp')
-rw-r--r--src/rpc.cpp194
1 files changed, 160 insertions, 34 deletions
diff --git a/src/rpc.cpp b/src/rpc.cpp
index df7ca05..9d98a4d 100644
--- a/src/rpc.cpp
+++ b/src/rpc.cpp
@@ -2488,21 +2488,26 @@ static const double VARDIFF_STALL_CUT = 4.0; // divide difficulty by thi
2488// that stops hashing keeps its last estimate forever and RefreshStats sums 2488// that stops hashing keeps its last estimate forever and RefreshStats sums
2489// it into the announced pool total for as long as the connection sits open 2489// it into the announced pool total for as long as the connection sits open
2490// -- the published pool hashrate ran several times over ground truth 2490// -- the published pool hashrate ran several times over ground truth
2491// because of exactly this. The decay pass in PoolEventLoop applies this 2491// because of exactly this. MinerReportedHashRate() below applies the
2492// policy: a miner gets a grace of HASHRATE_IDLE_MISSES expected share 2492// policy: a miner gets a grace of HASHRATE_IDLE_MISSES expected share
2493// intervals (expected interval = expected hashes per share at its current 2493// intervals (expected interval = expected hashes per share at its
2494// difficulty / its last rate estimate) with no share, then its estimate 2494// difficulty, divided by the estimate itself) with no share, after which
2495// decays with a half-life of one more such interval, computed from 2495// the figure we report falls off with a half-life of one more such
2496// absolute time so the result does not depend on how often the loop ticks. 2496// interval. The interval is clamped to [HASHRATE_MIN_INTERVAL,
2497// The interval is clamped to [HASHRATE_MIN_INTERVAL, HASHRATE_MAX_INTERVAL] 2497// HASHRATE_MAX_INTERVAL] so a fast miner at the vardiff floor (expected
2498// so a fast miner at the vardiff floor (expected interval of milliseconds) 2498// interval of milliseconds) is not punished for ordinary share-delivery
2499// is not punished for ordinary share-delivery jitter, and a very slow 2499// jitter, and a very slow miner's grace does not stretch past the point
2500// miner's grace does not stretch past the point where its estimate means 2500// where its estimate means anything. At the vardiff target of one share
2501// anything. At the vardiff target of one share per 15s, a stopped miner's 2501// per 15s that is a minute of grace, half the rate reported 75s in, and
2502// announced rate is gone within minutes. 2502// nothing reported after about seventeen minutes of silence.
2503static const double HASHRATE_IDLE_MISSES = 4.0; // grace, in expected share intervals 2503static const double HASHRATE_IDLE_MISSES = 4.0; // grace, in expected share intervals
2504static const double HASHRATE_MIN_INTERVAL = 1.0; // seconds; floor for the expected interval 2504static const double HASHRATE_MIN_INTERVAL = 1.0; // seconds; floor for the expected interval
2505static const double HASHRATE_MAX_INTERVAL = 3600.0; // seconds; ceiling for the expected interval 2505static const double HASHRATE_MAX_INTERVAL = 3600.0; // seconds; ceiling for the expected interval
2506// Half-lives past the grace beyond which we report a flat zero. 2^-64 of the
2507// original estimate is not a number anyone needs, and stopping here keeps
2508// pow() from walking down into the denormals for a miner that has been quiet
2509// for hours.
2510static const double HASHRATE_DECAY_CUTOFF = 64.0;
2506 2511
2507struct Miner { 2512struct Miner {
2508 btf_socket_t fd; 2513 btf_socket_t fd;
@@ -2546,6 +2551,65 @@ struct Miner {
2546 extranonce2(gNextExtranonce2.fetch_add(1)) {} 2551 extranonce2(gNextExtranonce2.fetch_add(1)) {}
2547}; 2552};
2548 2553
2554// What we publish as a miner's H/s: the last EWMA sample, faded out if shares
2555// have stopped arriving (#7, policy at HASHRATE_IDLE_MISSES).
2556//
2557// This reads m->hashRate and never writes it. That is the whole design. The
2558// first attempt decayed the stored estimate in place from the event loop's
2559// sweep, which put a running accumulator behind a figure that is only ever
2560// displayed, and the accumulator misbehaved in two ways:
2561//
2562// - `expected` was recomputed from the already-decayed estimate on every
2563// pass, so each decay raised `expected`, which raised `grace`, which is
2564// the very thing gating the next decay. `grace` simply chased `silent`
2565// upward and the reported rate settled into a 1/t crawl instead of the
2566// half-life curve the comment above promises -- 291x too high at ten
2567// half-lives past the grace. Issue #7 is about the pool over-reporting;
2568// that under-corrected it for hours.
2569// - Once `expected` reached HASHRATE_MAX_INTERVAL it could not rise any
2570// further, the feedback that had been gating each pass was gone, and the
2571// decay began compounding once a second against the total elapsed
2572// silence -- taking a miner from 0.4% of its baseline to 1e-11 of it in
2573// the next hundred seconds, four hours in, for no reason a reader of
2574// either the code or the announced number could see.
2575//
2576// Computing the value instead of accumulating it removes the failure mode
2577// rather than tuning it: with no stored decay state there is nothing for a
2578// tick rate, a missed pass or a duplicated pass to perturb, and the answer
2579// depends only on how long the miner has actually been quiet. It also keeps
2580// the fade out of the estimator -- the EWMA on the share path still blends
2581// against the value it last computed, not against a faded copy of it, so a
2582// miner that resumes recovers on exactly the curve it would have without any
2583// of this.
2584//
2585// `now` is a parameter rather than a GetTime() call so that one refresh fades
2586// every miner to the same instant, and so the policy is directly testable.
2587static double MinerReportedHashRate(const Miner* m, int64 now)
2588{
2589 // Nothing sampled yet: a fresh connection, or one that has found a single
2590 // share and so has no interval to have been timed over. Nothing to fade,
2591 // and nothing to divide by below.
2592 if (m->hashRate <= 0.0 || m->lastShareTime <= 0) return m->hashRate;
2593
2594 double expected = ExpectedHashesForDifficulty(m->difficulty) / m->hashRate;
2595 // Written so that a NaN would land on the floor rather than sail through:
2596 // the clamp is what guarantees `expected` is finite and strictly positive
2597 // below, whatever the division produced.
2598 if (!(expected > HASHRATE_MIN_INTERVAL)) expected = HASHRATE_MIN_INTERVAL;
2599 if (expected > HASHRATE_MAX_INTERVAL) expected = HASHRATE_MAX_INTERVAL;
2600
2601 double silent = (double)(now - m->lastShareTime);
2602 double grace = HASHRATE_IDLE_MISSES * expected;
2603 // grace is at least HASHRATE_IDLE_MISSES seconds, so a clock that has
2604 // stepped backwards gives a negative `silent`, takes this branch, and
2605 // reports the sample unchanged. Nothing here can scale an estimate up.
2606 if (!(silent > grace)) return m->hashRate;
2607
2608 double halflives = (silent - grace) / expected;
2609 if (halflives >= HASHRATE_DECAY_CUTOFF) return 0.0;
2610 return m->hashRate * pow(0.5, halflives);
2611}
2612
2549static std::vector<Miner*> gMiners; 2613static std::vector<Miner*> gMiners;
2550 2614
2551struct FoundBlock { int height; int64 ts; std::string addr; }; 2615struct FoundBlock { int height; int64 ts; std::string addr; };
@@ -4672,6 +4736,82 @@ int RunPoolStratumSelfTest()
4672 strPoolRoundsFile = savedRoundsFile; 4736 strPoolRoundsFile = savedRoundsFile;
4673 gPoolRounds = savedRounds; 4737 gPoolRounds = savedRounds;
4674 4738
4739 // -- hashrate decay (#7) --------------------------------------------------
4740 // The first version of this decayed m->hashRate in place from the event
4741 // loop's sweep, which made the reported figure a function of how the loop
4742 // happened to run rather than of how long the miner had been quiet. These
4743 // pin the properties that fixes: sampling changes nothing, and the answer
4744 // depends only on elapsed silence.
4745 {
4746 // A miner exactly on the vardiff target: 4096 hashes per share at
4747 // difficulty 1.0, one share per 15s, so 4096/15 H/s. Its expected
4748 // interval is 15s and its grace is 60s.
4749 const double base = 4096.0 / 15.0;
4750 const int64 t0 = 1766000000; // any plausible non-zero wall clock
4751 Miner decayed(INVALID_SOCKET);
4752 decayed.difficulty = 1.0;
4753 decayed.hashRate = base;
4754 decayed.lastShareTime = t0;
4755
4756 check(MinerReportedHashRate(&decayed, t0 + 60) == base,
4757 "hashrate decay: a miner inside its grace period reports its full estimate");
4758
4759 // Sampling must not consume the estimate. Twenty passes is one second
4760 // of the event loop's old sweep; a thousand is nearly a minute of it.
4761 double once = MinerReportedHashRate(&decayed, t0 + 61);
4762 bool idempotent = (decayed.hashRate == base);
4763 for (int i = 0; i < 1000 && idempotent; i++)
4764 idempotent = (MinerReportedHashRate(&decayed, t0 + 61) == once) &&
4765 (decayed.hashRate == base);
4766 check(idempotent,
4767 "hashrate decay: repeated sampling returns one value and leaves the estimate alone");
4768
4769 // One second past a 60s grace is 1/15th of a half-life: ~4.5% off, not
4770 // the estimate gone. A miner at the vardiff target overshoots its grace
4771 // roughly 1.8% of the time (e^-4) purely by share-timing variance, and
4772 // must not be zeroed for it.
4773 check(once > 0.94 * base && once < 0.96 * base,
4774 "hashrate decay: one second past grace costs a few percent, not the estimate");
4775 double three = MinerReportedHashRate(&decayed, t0 + 63);
4776 check(three > 0.86 * base && three < 0.88 * base,
4777 "hashrate decay: three seconds past grace costs ~13%");
4778
4779 // One half-life past the grace is exactly half.
4780 double half = MinerReportedHashRate(&decayed, t0 + 75);
4781 check(half > 0.499 * base && half < 0.501 * base,
4782 "hashrate decay: one expected interval past grace halves the estimate");
4783
4784 // A miner that has genuinely stopped ends up reported as nothing --
4785 // the whole point of #7. The old in-place version could not get here:
4786 // its decay fed back into its own grace and settled into a 1/t crawl.
4787 check(MinerReportedHashRate(&decayed, t0 + 3600) == 0.0,
4788 "hashrate decay: a miner silent for an hour is reported as zero");
4789
4790 // A clock that steps backwards must never inflate what we announce.
4791 check(MinerReportedHashRate(&decayed, t0 - 3600) == base,
4792 "hashrate decay: a backwards clock reports the estimate unchanged, never higher");
4793
4794 // Fresh connection, and a connection with one share and so no interval
4795 // to have timed: both must report nothing rather than divide by zero.
4796 Miner fresh(INVALID_SOCKET);
4797 check(MinerReportedHashRate(&fresh, t0) == 0.0,
4798 "hashrate decay: a miner that never submitted a share reports zero");
4799 Miner single(INVALID_SOCKET);
4800 single.lastShareTime = t0;
4801 check(MinerReportedHashRate(&single, t0 + 86400) == 0.0,
4802 "hashrate decay: a miner with no rate sample yet reports zero");
4803
4804 // Nothing in the swept range may produce a NaN, an infinity, a negative
4805 // rate, or a rate above the estimate it started from.
4806 bool finite = true;
4807 for (int64 s = -100; s < 200000 && finite; s += 37) {
4808 double v = MinerReportedHashRate(&decayed, t0 + s);
4809 finite = !std::isnan(v) && !std::isinf(v) && v >= 0.0 && v <= base;
4810 }
4811 check(finite,
4812 "hashrate decay: stays finite, non-negative and non-amplifying across a long silence");
4813 }
4814
4675 gCurrentJob = savedJob; 4815 gCurrentJob = savedJob;
4676 gHaveJob = savedHaveJob; 4816 gHaveJob = savedHaveJob;
4677 4817
@@ -4699,6 +4839,10 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal)
4699 if (m->authorised) gStats.authorizedMiners++; 4839 if (m->authorised) gStats.authorizedMiners++;
4700 4840
4701 gWorkerStats.clear(); 4841 gWorkerStats.clear();
4842 // One timestamp for the whole sweep (#7): every miner is faded to the
4843 // same instant, so the per-worker rows and the total they are summed
4844 // into describe one moment rather than a smear across the loop.
4845 int64 nowStats = GetTime();
4702 for (Miner* m : gMiners) { 4846 for (Miner* m : gMiners) {
4703 if (!m->authorised || m->lastSeen <= 0) continue; 4847 if (!m->authorised || m->lastSeen <= 0) continue;
4704 PoolWorkerStatView v; 4848 PoolWorkerStatView v;
@@ -4707,9 +4851,11 @@ static void RefreshStats(int blocksFoundThisSession, uint64 roundShareTotal)
4707 v.roundShares = m->roundShares; 4851 v.roundShares = m->roundShares;
4708 v.totalShares = m->totalShares; 4852 v.totalShares = m->totalShares;
4709 v.lastSeen = m->lastSeen; 4853 v.lastSeen = m->lastSeen;
4710 v.hashRate = m->hashRate; 4854 // Not m->hashRate: a miner that has stopped submitting shares must
4855 // not keep contributing its last sample to the announced total.
4856 v.hashRate = MinerReportedHashRate(m, nowStats);
4711 gWorkerStats.push_back(v); 4857 gWorkerStats.push_back(v);
4712 gStats.totalHashRate += m->hashRate; 4858 gStats.totalHashRate += v.hashRate;
4713 } 4859 }
4714 std::sort(gWorkerStats.begin(), gWorkerStats.end(), 4860 std::sort(gWorkerStats.begin(), gWorkerStats.end(),
4715 [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) { 4861 [](const PoolWorkerStatView& a, const PoolWorkerStatView& b) {
@@ -4942,27 +5088,7 @@ static void PoolEventLoop()
4942 } 5088 }
4943 } 5089 }
4944 5090
4945 // 3.6. Hashrate decay (#7) -- see the policy comment at 5091 // 3.6. Idle reap (#6). Mirror the clean-disconnect path exactly --
4946 // HASHRATE_IDLE_MISSES. The EWMA is only re-sampled on share arrival,
4947 // so without this pass a miner that stops hashing keeps its last
4948 // estimate forever and the summed pool hashrate RefreshStats (and
4949 // the nostr announcement built from it) publishes is fiction.
4950 for (Miner* m : gMiners) {
4951 if (m->hashRate <= 0.0 || m->lastShareTime <= 0) continue;
4952 double expected = ExpectedHashesForDifficulty(m->difficulty) /
4953 std::max(m->hashRate, 1e-9);
4954 if (expected < HASHRATE_MIN_INTERVAL) expected = HASHRATE_MIN_INTERVAL;
4955 if (expected > HASHRATE_MAX_INTERVAL) expected = HASHRATE_MAX_INTERVAL;
4956 double silent = (double)(GetTime() - m->lastShareTime);
4957 double grace = HASHRATE_IDLE_MISSES * expected;
4958 if (silent <= grace) continue;
4959 // Half-life of one expected interval past the grace period.
4960 // Written off absolute time, not per-tick, so the decay curve is
4961 // the same however often this loop runs.
4962 m->hashRate *= pow(0.5, (silent - grace) / expected);
4963 }
4964
4965 // 3.7. Idle reap (#6). Mirror the clean-disconnect path exactly --
4966 // log, close, erase from gMiners via std::find, delete -- just with 5092 // log, close, erase from gMiners via std::find, delete -- just with
4967 // a different reason. Shares the miner already earned stay in 5093 // a different reason. Shares the miner already earned stay in
4968 // roundShareCount, exactly as they do when a miner cleanly 5094 // roundShareCount, exactly as they do when a miner cleanly