vm: parallelize BagOfCells::deserialize by dependency depth#2305
vm: parallelize BagOfCells::deserialize by dependency depth#2305Lordron wants to merge 1 commit into
Conversation
Replace the serial cell-construction loop in BagOfCells::deserialize with a three-phase pipeline: Phase 1a (parallel): parse each cell's header, store ref indices Phase 1b (sequential): compute dependency depth via table lookup Phase 2 (sequential): bucket cells by depth Phase 3 (parallel): construct DataCells per depth level Within a depth level no cell references another at the same depth, so per-level work is embarrassingly parallel. Arena allocation is enabled for the duration (existing DataCell::use_arena mechanism) for cache-local sequential allocation; this alone is a ~15% win by reducing allocator traffic. For BOCs under 64 MB the original serial path runs unchanged. Measured on an AMD EPYC-Rome 16-core host, Ubuntu 24.04, against mainnet masterchain state 60660942 (121,421,299 cells, 6839 MB): Serial original: 47,573 ms 2.55 M cells/s 143 MB/s This change: 21,008 ms 5.78 M cells/s 325 MB/s (2.26x) Correctness validated: root hash byte-equal between the serial and parallel paths (Merkle root covers every cell, so equal hash implies equal tree). Transient memory cost: ~20 bytes per cell in Phase 1a (~2.4 GB for a 121 M cell state), freed after Phase 1b. The hot path this accelerates is initial node sync and state rebuild, not steady-state block application which uses lazy-loaded cells via CellDb. Remaining ceiling: Phase 3 time is dominated by atomic Ref<Cell> refcount contention on shared children and per-cell SHA-256. Both are candidates for follow-up PRs.
|
Quick follow-up on the "atomic refcount contention" hypothesis I called out in the I prototyped a move-variant of A/B on the same host (EPYC-Rome, 16 cores, 30 GiB, arena on): parallel + arena (this PR): 21,008 ms Within noise — refcount traffic is not the bottleneck here. Leaving this here in case a reviewer was curious about the refcount angle — it looked |
|
Thanks! I will look into this in the near future. |
|
Thanks! Some follow-up findings and a couple of ready-to-go optimisations we'd propose on top of this PR. First, the low-hanging win: bump the arena batch + madvise huge pages
char* allocate_in_arena(size_t size) {
constexpr size_t batch_size = 16 * (1ULL << 20); // was 1 << 20
thread_local td::MutableSlice batch;
auto aligned_size = (size + 7) / 8 * 8;
if (batch.size() < aligned_size) {
char* ptr = new char[batch_size];
#if defined(__unix__) || defined(__APPLE__)
::madvise(ptr, batch_size, MADV_HUGEPAGE);
#endif
batch = td::MutableSlice(ptr, batch_size);
}
auto res = batch.begin();
batch.remove_prefix(aligned_size);
return res;
}Two things in one small diff:
No API change, ~5 lines. Why we think it's worth the changeWe ran Results (EPYC-Rome, 16-core, 30 GiB, Ubuntu 24.04): Zen2 tops out near 5 IPC, so 0.48 is ~10% of peak — CPU is heavily stalled, and the 35% cache miss + 34% dTLB miss point at memory as the dominant source. The 34% dTLB rate stands out. During a mainnet state deserialize the DataCell working set reaches ~18 GB; at 4 KB pages that's ~4.5M pages against a 2,048-entry L2 dTLB on Zen2 — working set vs. TLB capacity ratio ~2,200×. Effectively every third field access does a page-walk. Switching the arena backing to 2 MB pages collapses that page count by 512×. A further, more invasive idea — tentative, not recommended yetSeparately, we noticed that during parent-cell creation each child access straddles two cache lines: class CntObject {
virtual ~CntObject(); // vtable ptr @ [0..8]
std::atomic<int> cnt_; // refcount @ [8..12] HOT
};
class CellTraits : public CntObject {};
class Cell : public CellTraits {};
class DataCell final : public Cell {
unsigned bit_length_ : 11; // packed bitfield @ [16..20]
unsigned refs_cnt_ : 3;
unsigned type_ : 3;
unsigned level_ : 3;
unsigned level_mask_ : 3;
unsigned allocated_in_arena_ : 1;
unsigned virtualized_ : 1;
std::array<Ref<Cell>, 4> refs_; // @ [24..56]
alignas(detail::LevelInfo) char trailer_[];
// holds LevelInfo[level+1] (hash+depth) starting @ offset 56,
// then cell data bytes.
};
struct detail::LevelInfo {
CellHash hash; // 32 B
td::uint16 depth; // 2 B (padded to 40 B overall)
};
Caveat: we haven't confirmed this is worth the surgeryThe 120M-cell figure comes from cold-start masterchain-state deserialization. In the warm block-application loop, per-block cell counts are probably two orders of magnitude smaller — maybe ~1M cells at peak. At that scale the cache-line-straddle cost may be lost in noise and not justify adding a branch to every Before we'd open a PR for the layout change, we'd want to measure runtime The arena + |
|
I don't exactly like the idea to allocate cells inside of that global arena, these cells then stay in memory indefinitely IIRC. What I wanted to do for quite some time now is to have separate arenas for specific operations we do with BoCs (like "validation of candidate X" or, as you poined out "initial sync mc state deserialization") and also somehow track provenance of cells in trees. DB then becomes an ultimate way to clear provenance. If we treat cells this way, we can also stop counting their refcounts in most of the cases since the provenance lifetime becomes an ultimate test if the cell should be alive (and we spend lot of time on ARC). This should also fix some long-standing issues with memory leaks in the node. If you want, I can share with you my discussion with Claude about this, but such design change spanning the whole node might not be the best for first-time outside contribution. The insight about cache lines is.. unexpected (Which model do you use? When people tried to do perf work with models before what they got is an immense amounts of slop) If you decide to contribute it, I will ask for benchmarks. |
|
Thanks — that's really useful context, and the per-operation arena + provenance direction makes a lot of sense. Scoping lifetime to "validate candidate X" or "initial sync mc state" is a much cleaner invariant than the current global+ARC mix, and it also naturally kills the indefinite-retention problem with the current arena (which is exactly why we were hesitant to push the 16 MB bump harder — it's a tactical improvement on a design you're already planning to replace). I'd very much appreciate seeing your Claude discussion on this — even just to know which direction not to patch over. You're right that it's not a good first-time contribution though; we'd rather leave the arena structural work to you. On the model question — it's a team of two: me driving and Claude Opus 4.7 (1M context) doing the heavy lifting on layout analysis and profiling setup. We agree with your prior on AI perf slop; the way we tried to keep it honest was to never let the model's claims stand on their own — every proposal had to be backed by a number from the actual binary (perf stat counters, On benchmarks for the Either way, thanks again for the detailed response — it's rare to get this level of engagement on an outside contribution. |
https://gist.github.com/DanShaders/8e7a1c29ed69367e0510733068182fc4 I don't exactly agree with everything there but you can see general direction. |
|
Sorry for the long answer — I had all sorts of real-life difficulties and caught a common cold. I checked the cache hypothesis, and while it is a real problem during initial synchronization, it does not occur inside DataCell. I tried various tweaks and optimizations, but it accounted for only around 0.5–1% of all cache misses we had IIRC. The real cause of the misses was the tree-building process itself IIRC I asked my Claude to create a complete overview of our discussion to share with you. It’s much more detailed and technical: https://gist.github.com/Lordron/f6d4f06a48c2dfe3d3273905109e8e29 I hope I can get back to this problem in a couple of weeks once I have access to a 32/64 GB VPS. Does anyone know if that would be sufficient to run as a liteserver? I remember I was unable to get past the initial synchronization even with large swap on a 32 GB machine. |
What
vm::BagOfCells::deserializebuilds the cell tree on a single thread. On mainnet masterchain state (121 M cells, 6.8 GB serialized), this takes ~47.5 s on a 16-core host — 15 cores idle. This PR replaces the serial loop with a level-parallel pipeline, taking the same deserialize down to ~21 s.This path is hot on node cold start: initial sync, restart with wiped CellDB, state re-import from disk. It's not on the warm block-application path (that uses lazy cells via
CellDb), so the PR benefits sync catch-up, not steady-state throughput.How
Three phases, respecting BOC's
ref_idx > idxinvariant:CellSerializationInfo, store its ref indices in a flatstd::vector<CellRefs>. Pure read of BOC bytes, no ordering dependency.depth[idx] = 1 + max(depth[ref_idx])by table lookup (no re-parsing).create_data_cell.cell_listwrites target distinct indices, no locks.Arena allocation (existing
DataCell::use_arena) is enabled for the duration — 1 MB thread-local bump batches instead of 121 M individualoperator new. That alone is a 15 % win on the serial path and compounds with parallelism by reducing memory-controller contention. Arena cells are not individually freed, matching howInMemoryBagOfCellsDb::parallel_scan_cellsalready uses this path.Fallback: BOCs < 64 MB keep the original serial path (thread-spawn overhead not worth it).
Results
Test host: AMD EPYC-Rome, 16 cores, 30 GiB RAM, Ubuntu 24.04 LTS VPS. Build:
-O3 -march=native, jemalloc 5.3.Same input (mainnet state 60660942, 121,421,299 cells, 6,839 MB):
Phase breakdown of the 21 s run:
ph1a=6.5 s, ph1b=0.3 s, ph3=10.2 s(Phase 3 is the remaining bottleneck).Correctness
Same BOC deserialized serial and parallel — identical Merkle root hash
1202B7FB528AA618…. A Merkle root is an SHA-256 digest over every cell's contents and every link, so matching roots imply byte-equivalent trees.A commented-out
BOC_FORCE_SERIALenv-var switch is left in the code for reviewers to re-run this A/B test. Existingtest-cellsandtest-dbunit tests pass unchanged.Trade-offs
max-cell-countassertion; kept at 20 B to avoid an observable cap.Ref<Cell>copies for widely-shared children, plus per-cell SHA-256. Both are worth a follow-up PR —Ref<Cell>handoff could bump the child refcount once per parent instead of twice, and multi-buffer SHA-NI could amortize per-level. Out of scope here.Files
crypto/vm/boc.cpp: +286 / −11. No API changes;BagOfCells::deserializesignature unchanged; all callers unaffected.