Skip to content

Commit 5a6d2aa

Browse files
committed
btc(stratum): memoize per-job tx_data hex (H5 work_source.cpp:634 churn fix)
build_connection_coinbase rebuilt the per-job tx-hex vector on every call, even when the mempool tx set was unchanged -- the dominant churn/leak site in the 2026-06-02 heaptrack (work_source.cpp:634, ~768MB across 1.2M calls). Factor the rebuild into a standalone, unit-testable seam (btc/stratum/tx_data_memo.hpp) that memoizes the shared_ptr in a single slot keyed to a fingerprint over the merkle leaf set (wd->m_hashes, in leaf order). A repeat call against the same tx set returns the cached shared_ptr (a refcount bump) instead of re-serializing the whole mempool. The key is the exact leaf set that built this job merkle, so a hit is atomic-with-merkle by construction (no stale-tx-vs-fresh-merkle mismatch on submit). Single slot => bounded memory (one tx set retained), self-evicting on tx-set roll. Rewire build_connection_coinbase to call the seam, guarding the two new memo members (tx_data_fp_, tx_data_memo_) with template_mutex_ since the builder runs on connection threads. Adds a standalone acceptance harness (tx_data_memo_test.cpp) proving pointer-identity reuse, correct invalidation on tx-set change, content correctness, and O(1) per-call cost on the unchanged path. BTC-only; no behavioral change to other coins.
1 parent 532de64 commit 5a6d2aa

4 files changed

Lines changed: 199 additions & 52 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
#pragma once
2+
// H5 leak-fix seam (acceptance-gate testable unit).
3+
//
4+
// build_connection_coinbase rebuilt the per-job tx hex vector on every call
5+
// even when the mempool tx set was unchanged -- the dominant churn/leak site
6+
// in the 2026-06-02 heaptrack (work_source.cpp:634, 768MB / 1.2M calls, ~78%
7+
// of leaked bytes). The fix memoizes the shared_ptr in a single slot keyed to
8+
// a fingerprint over the merkle leaf set (wd->m_hashes, in merkle-leaf order):
9+
// a repeat call against the same tx set returns the cached shared_ptr (a
10+
// refcount bump) instead of re-serializing the whole mempool. The key is the
11+
// exact leaf set that built this job merkle, so a hit is atomic-with-merkle by
12+
// construction (no stale-tx-vs-fresh-merkle mismatch on submit). Single slot =>
13+
// bounded memory (one tx set retained), self-evicting on tx-set roll.
14+
//
15+
// Factored out of build_connection_coinbase so the invariant can be proven
16+
// deterministically by a standalone harness (pointer-identity + O(1) repeat
17+
// call) without standing up a full BTCWorkSource. The call site holds
18+
// template_mutex_ across the call; this helper itself does no locking.
19+
20+
#include <memory>
21+
#include <span>
22+
#include <string>
23+
#include <vector>
24+
25+
#include <nlohmann/json.hpp>
26+
27+
#include <btclibs/hash.h> // CHash256
28+
#include <core/uint256.hpp> // uint256 (c2pool canonical)
29+
30+
namespace btc::stratum::detail {
31+
32+
// Fingerprint the template tx set over its merkle leaf hashes, in leaf order.
33+
inline uint256 tx_data_fingerprint(const std::vector<uint256>& leaf_hashes)
34+
{
35+
legacy::CHash256 fp_hasher;
36+
for (const auto& h : leaf_hashes)
37+
fp_hasher.Write(std::span<const unsigned char>(h.data(), 32));
38+
uint256 fp;
39+
fp_hasher.Finalize(std::span<unsigned char>(fp.data(), 32));
40+
return fp;
41+
}
42+
43+
// Single-slot memo: return the cached tx-hex vector when the leaf set matches
44+
// the fingerprint of the prior call; otherwise rebuild from txs_field and
45+
// overwrite the slot (the prior shared_ptr refcount drops). The two memo
46+
// fields are owned by the caller (BTCWorkSource members, guarded by
47+
// template_mutex_); this function mutates them but takes no lock itself.
48+
inline std::shared_ptr<std::vector<std::string>> tx_data_memo_get_or_build(
49+
const std::vector<uint256>& leaf_hashes,
50+
const nlohmann::json& txs_field,
51+
uint256& memo_fp,
52+
std::shared_ptr<std::vector<std::string>>& memo_slot)
53+
{
54+
const uint256 fp = tx_data_fingerprint(leaf_hashes);
55+
if (memo_slot && memo_fp == fp)
56+
return memo_slot; // unchanged tx set -> refcount bump, no rebuild
57+
58+
auto txd = std::make_shared<std::vector<std::string>>();
59+
txd->reserve(txs_field.size());
60+
for (const auto& t : txs_field) {
61+
if (t.is_object() && t.contains("data") && t["data"].is_string())
62+
txd->push_back(t["data"].get<std::string>());
63+
}
64+
memo_fp = fp; // single slot: overwrite, prior set refcount drops
65+
memo_slot = txd;
66+
return txd;
67+
}
68+
69+
} // namespace btc::stratum::detail

src/impl/btc/stratum/work_source.cpp

Lines changed: 15 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
// implementing the substantive logic.
1212

1313
#include <impl/btc/stratum/work_source.hpp>
14+
#include <impl/btc/stratum/tx_data_memo.hpp> // H5 tx_data memo seam (work_source.cpp:634 churn fix)
1415
#include <memory>
1516

1617
#include <impl/btc/coin/header_chain.hpp>
@@ -630,44 +631,20 @@ core::stratum::CoinbaseResult BTCWorkSource::build_connection_coinbase(
630631
}
631632
auto txs_field = wd->m_data.find("transactions");
632633
if (txs_field != wd->m_data.end() && txs_field->is_array()) {
633-
// a1 (shared_ptr + lazy materialize): build the per-tx hex vector ONCE
634-
// and hand the snapshot a shared_ptr to it, so the copies made along
635-
// CoinbaseResult -> JobSnapshot -> JobEntry become refcount bumps, not
636-
// deep copies of the full mempool tx hex.
637-
//
638-
// H5 (memoize across calls): a1 collapsed the per-job copies, but the
639-
// vector itself was still rebuilt from scratch on every call to this
640-
// method even when the mempool tx set had not changed -- the dominant
641-
// leak/churn site (work_source.cpp:634, 768MB / 1.2M calls). Fingerprint
642-
// the template tx set over wd->m_hashes (the merkle leaf set, in
643-
// merkle-leaf order) and reuse the memoized shared_ptr on a match.
644-
// The fingerprint keys on the SAME leaves that built this job's merkle,
645-
// so a hit is atomic with the witness_commitment/merkle captured for
646-
// the job (no stale-tx-vs-fresh-merkle mismatch on submit). Single slot
647-
// -> bounded memory (one tx set retained), self-evicting on tx-set roll.
648-
CHash256 fp_hasher;
649-
for (const auto& h : wd->m_hashes)
650-
fp_hasher.Write(std::span<const unsigned char>(h.data(), 32));
651-
uint256 fp;
652-
fp_hasher.Finalize(std::span<unsigned char>(fp.data(), 32));
653-
654-
std::shared_ptr<std::vector<std::string>> txd;
655-
{
656-
std::lock_guard<std::mutex> lk(template_mutex_);
657-
if (tx_data_memo_ && tx_data_fp_ == fp) {
658-
txd = tx_data_memo_; // unchanged tx set -> refcount bump, no rebuild
659-
} else {
660-
txd = std::make_shared<std::vector<std::string>>();
661-
txd->reserve(txs_field->size());
662-
for (const auto& t : *txs_field) {
663-
if (t.is_object() && t.contains("data") && t["data"].is_string())
664-
txd->push_back(t["data"].get<std::string>());
665-
}
666-
tx_data_fp_ = fp; // single slot: overwrite, prior set refcount drops
667-
tx_data_memo_ = txd;
668-
}
669-
}
670-
snap.tx_data = std::move(txd);
634+
// a1 + H5 memo: build the per-tx hex vector ONCE and hand the snapshot a
635+
// shared_ptr to it (copies along CoinbaseResult -> JobSnapshot -> JobEntry
636+
// become refcount bumps, not deep copies of the full mempool tx hex).
637+
// The memo seam goes one further: it keys a single-slot cache to a
638+
// fingerprint over the merkle leaf set (wd->m_hashes), so a repeat call
639+
// against an UNCHANGED tx set reuses the cached shared_ptr instead of
640+
// re-serializing the whole mempool -- the dominant churn site in the
641+
// 2026-06-02 heaptrack (768MB / 1.2M calls). The key is the exact leaf
642+
// set that built this job merkle, so a hit is atomic-with-merkle by
643+
// construction. The seam does no locking; build_connection_coinbase runs
644+
// on connection threads, so guard the memo members with template_mutex_.
645+
std::lock_guard<std::mutex> lk(template_mutex_);
646+
snap.tx_data = btc::stratum::detail::tx_data_memo_get_or_build(
647+
wd->m_hashes, *txs_field, tx_data_fp_, tx_data_memo_);
671648
}
672649
snap.merkle_branches = std::move(branches);
673650

src/impl/btc/stratum/work_source.hpp

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -266,20 +266,12 @@ class BTCWorkSource : public core::stratum::IWorkSource
266266
// Template cache (filled lazily; invalidated when work_generation_ bumps)
267267
// Stage 4c populates these.
268268
mutable std::mutex template_mutex_;
269-
// H5 fix: single-slot tx_data memo. build_connection_coinbase rebuilt
270-
// the per-tx hex vector on every call; for an unchanged mempool tx set
271-
// that is pure churn (768MB / 1.2M calls in the 2026-06-02 trace, ~78%
272-
// of total leaked bytes). Memoize the shared_ptr keyed to a fingerprint
273-
// over wd->m_hashes (the merkle leaf set, in merkle-leaf order) so a
274-
// repeat call against the same tx set returns a refcount bump instead
275-
// of a fresh deep copy. Single slot: only the latest tx set is ever
276-
// needed, so the memo is O(1) and self-evicting (a new fingerprint
277-
// overwrites the prior entry, dropping its refcount). Guarded by
278-
// template_mutex_. Atomic-with-merkle by construction: the key is the
279-
// exact leaf set that produced this job, so a memo hit implies an
280-
// identical merkle (no stale-tx-vs-fresh-merkle submit mismatch).
281-
mutable uint256 tx_data_fp_;
282-
mutable std::shared_ptr<std::vector<std::string>> tx_data_memo_;
269+
// H5 tx_data memo (single slot, guarded by template_mutex_): the per-job
270+
// tx-hex vector and a fingerprint over its merkle leaf set, so repeat
271+
// build_connection_coinbase calls against an unchanged tx set reuse the
272+
// shared_ptr instead of re-serializing the mempool. See tx_data_memo.hpp.
273+
mutable uint256 tx_data_fp_;
274+
mutable std::shared_ptr<std::vector<std::string>> tx_data_memo_;
283275
};
284276

285277
} // namespace btc::stratum
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// H5 acceptance gate: deterministic pointer-identity + O(1) proof for the
2+
// tx_data memo (the work_source.cpp:634 churn/leak fix). Standalone harness
3+
// in the style of template_parity_test.cpp -- no btc/test CMake plumbing
4+
// required.
5+
//
6+
// Build (from repo root):
7+
// g++ -std=gnu++20 -I src -I src/btclibs \
8+
// src/impl/btc/test/tx_data_memo_test.cpp src/btclibs/crypto/sha256.cpp \
9+
// -o /tmp/tx_data_memo_test
10+
// Run:
11+
// /tmp/tx_data_memo_test
12+
//
13+
// Proves the invariant the fix establishes:
14+
// (1) same merkle leaf set on repeat calls -> SAME shared_ptr (no rebuild)
15+
// (2) a changed leaf set -> a FRESH shared_ptr (correct invalidation)
16+
// (3) content is correct (the tx "data" hex, in order)
17+
// (4) N repeat calls do NOT grow allocations: exactly ONE vector ever built
18+
// for an unchanged tx set, and the per-call cost is O(1) (fingerprint
19+
// only), not O(mempool) -- this is the flatten of the :634 curve.
20+
21+
#include <impl/btc/stratum/tx_data_memo.hpp>
22+
23+
#include <chrono>
24+
#include <cstdio>
25+
#include <cstdint>
26+
27+
using btc::stratum::detail::tx_data_memo_get_or_build;
28+
29+
static int g_fails = 0;
30+
static void check(bool ok, const char* label) {
31+
std::printf(" %s %s\n", ok ? "PASS" : "FAIL", label);
32+
if (!ok) ++g_fails;
33+
}
34+
35+
// Build a synthetic merkle leaf set (deterministic, no real PoW needed).
36+
static std::vector<uint256> make_leaves(unsigned n, unsigned char salt) {
37+
std::vector<uint256> v(n);
38+
for (unsigned i = 0; i < n; ++i) {
39+
unsigned char* d = v[i].data();
40+
for (int b = 0; b < 32; ++b) d[b] = (unsigned char)((i * 31 + b * 7 + salt) & 0xff);
41+
}
42+
return v;
43+
}
44+
45+
// Build a synthetic getblocktemplate-style "transactions" array.
46+
static nlohmann::json make_txs(unsigned n) {
47+
nlohmann::json arr = nlohmann::json::array();
48+
for (unsigned i = 0; i < n; ++i)
49+
arr.push_back({{"data", std::string("deadbeef") + std::to_string(i)}});
50+
return arr;
51+
}
52+
53+
int main() {
54+
std::printf("== tx_data memo: pointer-identity + O(1) acceptance gate ==\n");
55+
56+
const unsigned NTX = 3000; // representative full mempool
57+
auto leaves_a = make_leaves(NTX, 0x00);
58+
auto leaves_b = make_leaves(NTX, 0x01); // one differing tx set
59+
auto txs_a = make_txs(NTX);
60+
auto txs_b = make_txs(NTX);
61+
62+
uint256 memo_fp;
63+
std::shared_ptr<std::vector<std::string>> memo_slot;
64+
65+
// (1) first call builds; second call against same leaves reuses the ptr.
66+
auto p1 = tx_data_memo_get_or_build(leaves_a, txs_a, memo_fp, memo_slot);
67+
auto p2 = tx_data_memo_get_or_build(leaves_a, txs_a, memo_fp, memo_slot);
68+
check(p1.get() == p2.get(), "same leaf set -> identical shared_ptr (no rebuild)");
69+
check(p1.use_count() >= 3, "memoized ptr is shared, not re-materialized");
70+
71+
// (3) content correctness on the built vector.
72+
bool content_ok = p1->size() == NTX && (*p1)[0] == "deadbeef0"
73+
&& (*p1)[NTX - 1] == ("deadbeef" + std::to_string(NTX - 1));
74+
check(content_ok, "tx hex content correct and in order");
75+
76+
// (2) a changed leaf set invalidates and yields a fresh ptr.
77+
auto p3 = tx_data_memo_get_or_build(leaves_b, txs_b, memo_fp, memo_slot);
78+
check(p3.get() != p1.get(), "changed leaf set -> fresh shared_ptr (invalidated)");
79+
80+
// rolling back to leaves_a is now a MISS (single slot holds leaves_b).
81+
auto p4 = tx_data_memo_get_or_build(leaves_a, txs_a, memo_fp, memo_slot);
82+
check(p4.get() != p1.get() && p4.get() != p3.get(),
83+
"single-slot: rolled-back set rebuilds (bounded memory, one slot)");
84+
85+
// (4) O(1) proof: hammer the SAME leaf set N times. The original :634 code
86+
// re-serialized the whole mempool every call (O(N*NTX)); the memo makes
87+
// every repeat a fingerprint-only refcount bump. Assert exactly ONE vector
88+
// identity is returned across all N calls, and report per-call timing.
89+
const long N = 1000000;
90+
// reset slot to a known single set
91+
memo_slot.reset(); memo_fp = uint256();
92+
auto base = tx_data_memo_get_or_build(leaves_a, txs_a, memo_fp, memo_slot);
93+
void* base_id = base.get();
94+
bool all_same = true;
95+
auto t0 = std::chrono::steady_clock::now();
96+
for (long i = 0; i < N; ++i) {
97+
auto pp = tx_data_memo_get_or_build(leaves_a, txs_a, memo_fp, memo_slot);
98+
if (pp.get() != base_id) { all_same = false; break; }
99+
}
100+
auto t1 = std::chrono::steady_clock::now();
101+
double ns_per_call =
102+
std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count() / double(N);
103+
check(all_same, "N repeat calls return the ONE memoized vector (no rebuild)");
104+
std::printf(" curve: %ld unchanged-tx calls @ NTX=%u -> %.1f ns/call "
105+
"(O(1) fingerprint, was O(NTX) re-serialize)\n", N, NTX, ns_per_call);
106+
107+
std::printf("\n%s (%d failures)\n", g_fails ? "FAILED" : "ALL PASS", g_fails);
108+
return g_fails ? 1 : 0;
109+
}

0 commit comments

Comments
 (0)