diff options
| author | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-02 15:23:02 +0000 |
|---|---|---|
| committer | bitflash-maintainer <noreply@bitflash.invalid> | 2026-09-02 15:23:02 +0000 |
| commit | 07051b2378734d62fb94a4a62819d8150452b661 (patch) | |
| tree | b20b912b18d5da1f524209bedda395d155fe11ef /src/rpc.cpp | |
| parent | f5def7625e6369d117cc8f02b7c0244b70d6f779 (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.cpp | 194 |
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. |
| 2503 | static const double HASHRATE_IDLE_MISSES = 4.0; // grace, in expected share intervals | 2503 | static const double HASHRATE_IDLE_MISSES = 4.0; // grace, in expected share intervals |
| 2504 | static const double HASHRATE_MIN_INTERVAL = 1.0; // seconds; floor for the expected interval | 2504 | static const double HASHRATE_MIN_INTERVAL = 1.0; // seconds; floor for the expected interval |
| 2505 | static const double HASHRATE_MAX_INTERVAL = 3600.0; // seconds; ceiling for the expected interval | 2505 | static 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. | ||
| 2510 | static const double HASHRATE_DECAY_CUTOFF = 64.0; | ||
| 2506 | 2511 | ||
| 2507 | struct Miner { | 2512 | struct 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. | ||
| 2587 | static 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 | |||
| 2549 | static std::vector<Miner*> gMiners; | 2613 | static std::vector<Miner*> gMiners; |
| 2550 | 2614 | ||
| 2551 | struct FoundBlock { int height; int64 ts; std::string addr; }; | 2615 | struct 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 |