From bba328f79f224824815ef7e25bbc0cf615e3123e Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Mon, 26 Oct 2026 18:32:03 -0400 Subject: [PATCH 1/4] Fix SHOW_MEMORY crashes on valid input `Node::getCapacity()` asserted `!releaseDeferred`, but releasing a deferred node requires its capacity, so any node replacement aborted an assertion- enabled `SHOW_MEMORY` build. `partialKeyCapacity` is not repurposed by `deferRelease`, so there is no reason to reject deferred nodes. Drop the assertion. `eraseTree` released a node before visiting its children, so the `parent` pointers walked by `removeKey` pointed to freed memory (and `deferRelease` repurposes `parent` as a list link). Carry the search path length of each node in the worklist, computed while its parent is still alive, and account for it with the new `removeKey` overload. Add a test covering the SHOW_MEMORY instrumentation, which the rest of the test suite doesn't compile with. --- CMakeLists.txt | 18 ++++++++++ ConflictSet.cpp | 56 ++++++++++++++++++++++++----- Internal.h | 7 ++++ ShowMemoryTest.cpp | 89 ++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 9 deletions(-) create mode 100644 ShowMemoryTest.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 733818c..736483c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -390,6 +390,24 @@ if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR AND BUILD_TESTING) PROPERTIES CXX_STANDARD_REQUIRED ON) add_test(NAME conflict_set_cxx_api_test COMMAND conflict_set_cxx_api_test) + # SHOW_MEMORY instrumentation, which the rest of the test suite doesn't + # compile with + add_executable(conflict_set_show_memory_test ConflictSet.cpp + ShowMemoryTest.cpp) + target_compile_options(conflict_set_show_memory_test PRIVATE ${TEST_FLAGS}) + target_compile_definitions(conflict_set_show_memory_test + PRIVATE SHOW_MEMORY=1) + target_include_directories(conflict_set_show_memory_test + PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/include) + if(NOT CMAKE_CROSSCOMPILING) + target_compile_options(conflict_set_show_memory_test + PRIVATE -fsanitize=address,undefined) + target_link_options(conflict_set_show_memory_test PRIVATE + -fsanitize=address,undefined) + endif() + add_test(NAME conflict_set_show_memory_test + COMMAND conflict_set_show_memory_test) + # symbol visibility tests if(NOT CMAKE_BUILD_TYPE STREQUAL Debug) if(APPLE) diff --git a/ConflictSet.cpp b/ConflictSet.cpp index 553fb11..414728e 100644 --- a/ConflictSet.cpp +++ b/ConflictSet.cpp @@ -85,11 +85,18 @@ void addNode(struct Node *); void removeNode(struct Node *); void addKey(struct Node *); void removeKey(struct Node *); +// Like `removeKey`, but with a caller-provided search path length. Used when +// the node's ancestors may already have been released, which makes walking +// `parent` pointers invalid. +void removeKey(struct Node *, int64_t searchPathLength); +int64_t getSearchPathLength(struct Node *); #else constexpr void addNode(struct Node *) {} constexpr void removeNode(struct Node *) {} constexpr void addKey(struct Node *) {} constexpr void removeKey(struct Node *) {} +constexpr void removeKey(struct Node *, int64_t) {} +constexpr int64_t getSearchPathLength(struct Node *) { return 0; } #endif // ==================== BEGIN IMPLEMENTATION ==================== @@ -307,7 +314,9 @@ struct Node { uint8_t *partialKey(); Type getType() const { return type; } int32_t getCapacity() const { - assert(!releaseDeferred); + // `partialKeyCapacity` is not repurposed by `deferRelease`, so this is also + // valid for nodes with `releaseDeferred` set. It's needed to account for + // the capacity of a deferred node when it's finally released. return partialKeyCapacity; } @@ -2754,17 +2763,30 @@ checkMaxBetweenExclusiveImpl(Node256 *n, int begin, int end, } void eraseTree(Node *root, WriteContext *writeContext) { + // A node is released before its children are visited, so the `parent` + // pointers of queued nodes can't be relied upon. Carry the search path length + // of each node along with it instead. + struct ToFree { + Node *n; + // Length of the key leading to `n`. See `getSearchPathLength`. + int64_t searchPathLength; + }; Arena arena; - auto toFree = vector(arena); - toFree.push_back(root); + auto toFree = vector(arena); + toFree.push_back({root, getSearchPathLength(root)}); while (toFree.size() > 0) { - auto *n = toFree.back(); + const auto item = toFree.back(); + auto *n = item.n; toFree.pop_back(); writeContext->accum.entries_erased += n->entryPresent; ++writeContext->accum.nodes_released; - removeKey(n); + removeKey(n, item.searchPathLength); + + // Search path length of the children of `n`. Must be read before `n` is + // released. + const int64_t childSearchPathLengthBase = item.searchPathLength + 1; switch (n->getType()) { case Type_Node0: { @@ -2774,28 +2796,37 @@ void eraseTree(Node *root, WriteContext *writeContext) { case Type_Node3: { auto *n3 = static_cast(n); for (int i = 0; i < n3->numChildren; ++i) { - toFree.push_back(n3->children[i]); + Node *child = n3->children[i]; + toFree.push_back( + {child, childSearchPathLengthBase + child->partialKeyLen}); } writeContext->release(n3); } break; case Type_Node16: { auto *n16 = static_cast(n); for (int i = 0; i < n16->numChildren; ++i) { - toFree.push_back(n16->children[i]); + Node *child = n16->children[i]; + toFree.push_back( + {child, childSearchPathLengthBase + child->partialKeyLen}); } writeContext->release(n16); } break; case Type_Node48: { auto *n48 = static_cast(n); for (int i = 0; i < n48->numChildren; ++i) { - toFree.push_back(n48->children[i]); + Node *child = n48->children[i]; + toFree.push_back( + {child, childSearchPathLengthBase + child->partialKeyLen}); } writeContext->release(n48); } break; case Type_Node256: { auto *n256 = static_cast(n); auto *out = toFree.unsafePrepareAppend(n256->numChildren).data(); - n256->bitSet.forEachSet([&](int i) { *out++ = n256->children[i]; }); + n256->bitSet.forEachSet([&](int i) { + Node *child = n256->children[i]; + *out++ = {child, childSearchPathLengthBase + child->partialKeyLen}; + }); assert(out == toFree.end()); writeContext->release(n256); } break; @@ -6018,6 +6049,13 @@ void removeKey(Node *n) { } } +void removeKey(Node *n, int64_t searchPathLength) { + if (n->entryPresent) { + --totalKeys; + keyBytes -= searchPathLength; + } +} + struct __attribute__((visibility("default"))) PeakPrinter { ~PeakPrinter() { printf("--- radix_tree ---\n"); diff --git a/Internal.h b/Internal.h index 7525e0c..f2b2ea8 100644 --- a/Internal.h +++ b/Internal.h @@ -23,7 +23,9 @@ using namespace weaselab; #include #define DEBUG_VERBOSE 0 +#ifndef SHOW_MEMORY #define SHOW_MEMORY 0 +#endif // std::span is not trivially constructible. We want a span that leaves its // members uninitialized for performance reasons. @@ -96,6 +98,11 @@ static_assert(std::is_trivial_v); #if SHOW_MEMORY inline int64_t mallocBytes = 0; inline int64_t peakMallocBytes = 0; +// Defined in ConflictSet.cpp +extern int64_t nodeBytes; +extern int64_t partialCapacityBytes; +extern int64_t totalKeys; +extern int64_t keyBytes; #endif inline thread_local int64_t mallocBytesDelta = 0; diff --git a/ShowMemoryTest.cpp b/ShowMemoryTest.cpp new file mode 100644 index 0000000..5d3d078 --- /dev/null +++ b/ShowMemoryTest.cpp @@ -0,0 +1,89 @@ +// Exercises the SHOW_MEMORY instrumentation, which the other tests don't +// compile with (this target passes -DSHOW_MEMORY=1). The instrumentation has +// its own consistency checks, which abort on failure, and asserts are enabled +// by TEST_FLAGS. Only one ConflictSet may exist at a time, since the +// accounting is process-wide. +#include "ConflictSet.h" +#include "Internal.h" + +#include +#include +#include + +using namespace weaselab; + +namespace { + +void writePoint(ConflictSet &cs, const std::string &key, int64_t writeVersion) { + ConflictSet::WriteRange w{{(const uint8_t *)key.data(), int(key.size())}, + {nullptr, 0}}; + cs.addWrites(&w, 1, writeVersion); +} + +ConflictSet::Result checkPoint(const ConflictSet &cs, const std::string &key, + int64_t readVersion) { + ConflictSet::ReadRange r{{(const uint8_t *)key.data(), int(key.size())}, + {nullptr, 0}, + readVersion}; + ConflictSet::Result result; + cs.check(&r, &result, 1); + return result; +} + +// Everything a ConflictSet allocated must have been accounted for and released +// by the time it's destroyed. +void checkFullyReleased() { + assert(nodeBytes == 0); + assert(partialCapacityBytes == 0); + assert(totalKeys == 0); + assert(keyBytes == 0); + assert(mallocBytes == 0); +} + +} // namespace + +int main() { + // Splitting a node replaces it, which defers the release of the old node. + // Releasing a deferred node must account for its capacity, and the key it + // held must be accounted for without walking `parent` pointers of nodes that + // have already been released. + { + ConflictSet cs{0}; + writePoint(cs, "a", 1); + writePoint(cs, "ab", 2); + assert(checkPoint(cs, "a", 0) == ConflictSet::Conflict); + assert(checkPoint(cs, "ab", 0) == ConflictSet::Conflict); + assert(checkPoint(cs, "b", 0) == ConflictSet::Commit); + assert(nodeBytes > 0); + assert(keyBytes > 0); + } + checkFullyReleased(); + + // Exceeding the version window erases the tree mid-workload. + { + ConflictSet cs{0}; + writePoint(cs, "abc", 1); + const int64_t version = int64_t(1) << 40; + writePoint(cs, "abc", version); + assert(checkPoint(cs, "abc", 0) == ConflictSet::TooOld); + assert(checkPoint(cs, "abc", version) == ConflictSet::Commit); + } + checkFullyReleased(); + + // Erase a tree with many nodes, both mid-workload and at destruction. + { + ConflictSet cs{0}; + for (int i = 0; i < 1000; ++i) { + writePoint(cs, "prefix/" + std::to_string(i), i + 1); + } + const int64_t version = int64_t(1) << 40; + writePoint(cs, "prefix/1", version); + assert(checkPoint(cs, "prefix/1", version) == ConflictSet::Commit); + + for (int i = 0; i < 1000; ++i) { + writePoint(cs, "other/" + std::to_string(i), version + i + 1); + } + assert(checkPoint(cs, "other/999", version) == ConflictSet::Conflict); + } + checkFullyReleased(); +} From df986810ff563401db71315937575394ada0741e Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Mon, 26 Oct 2026 20:34:45 -0400 Subject: [PATCH 2/4] Give the SHOW_MEMORY instrumentation a single definition per function The coverage job compiles ConflictSet.cpp twice: once without SHOW_MEMORY, like the rest of the build, and once with it for the SHOW_MEMORY test. Each config previously saw a *different* definition of the accounting helpers: no-op `constexpr` stubs near the top of the file when SHOW_MEMORY was off, and the real definitions at the bottom when it was on. gcovr reads the .gcda files for a source file and merges them, and its default (strict) function merge mode asserts that a function name always maps to one line. Since `addNode`, `removeNode`, `addKey`, both `removeKey` overloads and `getSearchPathLength` were reported on two different lines, report generation aborted with AssertionError: Got function getSearchPathLength(Node*) on multiple lines: 99, 6002. Define those functions in exactly one place instead, and compile the accounting out from within the bodies. Then every configuration reports each function on the same line and the coverage data can be merged. The empty calls are inlined away: the Release shared library contains no calls to them. With the merge fixed, the accounting lines that only exist in the SHOW_MEMORY build show up in the report, so the coverage job's 100% line requirement covers them. Extend the SHOW_MEMORY test to grow a node through every node type (so `getNodeSize` covers every case) and to erase entries one at a time via `setOldestVersion` (so `removeKey` and the `internal_setOldestVersion` cross-check are covered). The remaining `abort()`s in the accounting are defensive checks that can never be taken on a passing run; mark them GCOVR_EXCL_LINE like the rest of the unreachable code in this file. --- ConflictSet.cpp | 61 ++++++++++++++++++++++++++++++++++++---------- ShowMemoryTest.cpp | 26 ++++++++++++++++++++ 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/ConflictSet.cpp b/ConflictSet.cpp index 414728e..193b9f0 100644 --- a/ConflictSet.cpp +++ b/ConflictSet.cpp @@ -80,7 +80,10 @@ using namespace weaselab; #define assume assert #endif -#if SHOW_MEMORY +// The SHOW_MEMORY instrumentation. Declared unconditionally so that each of +// these has a single definition in every configuration (see the definitions at +// the end of this file), rather than a no-op stub standing in for the real +// thing. void addNode(struct Node *); void removeNode(struct Node *); void addKey(struct Node *); @@ -90,14 +93,6 @@ void removeKey(struct Node *); // `parent` pointers invalid. void removeKey(struct Node *, int64_t searchPathLength); int64_t getSearchPathLength(struct Node *); -#else -constexpr void addNode(struct Node *) {} -constexpr void removeNode(struct Node *) {} -constexpr void addKey(struct Node *) {} -constexpr void removeKey(struct Node *) {} -constexpr void removeKey(struct Node *, int64_t) {} -constexpr int64_t getSearchPathLength(struct Node *) { return 0; } -#endif // ==================== BEGIN IMPLEMENTATION ==================== @@ -5492,8 +5487,9 @@ void internal_addWrites(ConflictSet::Impl *impl, impl->totalBytes += mallocBytesDelta; impl->memory_bytes.set(impl->totalBytes); #if SHOW_MEMORY + // Testing-only cross-check of the accounting. if (impl->totalBytes != mallocBytes) { - abort(); + abort(); // GCOVR_EXCL_LINE } #endif } @@ -5504,8 +5500,9 @@ void internal_setOldestVersion(ConflictSet::Impl *impl, int64_t oldestVersion) { impl->totalBytes += mallocBytesDelta; impl->memory_bytes.set(impl->totalBytes); #if SHOW_MEMORY + // Testing-only cross-check of the accounting. if (impl->totalBytes != mallocBytes) { - abort(); + abort(); // GCOVR_EXCL_LINE } #endif } @@ -5971,6 +5968,14 @@ checkMaxVersion(Node *root, Node *node, InternalVersionT oldestVersion, } // namespace +// The SHOW_MEMORY instrumentation. Each of these functions is defined exactly +// once, with the accounting compiled out when SHOW_MEMORY is disabled. Defining +// them once (rather than defining no-op stubs when SHOW_MEMORY is disabled) +// keeps every configuration of this file reporting each function on the same +// line, which merging coverage from objects compiled with different options +// requires: gcovr, merging the SHOW_MEMORY test with the rest of the build, +// otherwise aborts with "Got function ... on multiple lines". The optimizer +// removes the resulting empty calls. #if SHOW_MEMORY int64_t nodeBytes = 0; @@ -5994,12 +5999,15 @@ int64_t getNodeSize(struct Node *n) { return sizeof(Node48); case Type_Node256: return sizeof(Node256); - default: - abort(); + default: // GCOVR_EXCL_LINE + abort(); // GCOVR_EXCL_LINE } } +#endif + int64_t getSearchPathLength(Node *n) { +#if SHOW_MEMORY assert(n != nullptr); int64_t result = 0; for (;;) { @@ -6011,9 +6019,14 @@ int64_t getSearchPathLength(Node *n) { n = n->parent; } return result; +#else + (void)n; + return 0; +#endif } void addNode(Node *n) { +#if SHOW_MEMORY nodeBytes += getNodeSize(n); partialCapacityBytes += n->getCapacity(); if (nodeBytes > peakNodeBytes) { @@ -6022,14 +6035,22 @@ void addNode(Node *n) { if (partialCapacityBytes > peakPartialCapacityBytes) { peakPartialCapacityBytes = partialCapacityBytes; } +#else + (void)n; +#endif } void removeNode(Node *n) { +#if SHOW_MEMORY nodeBytes -= getNodeSize(n); partialCapacityBytes -= n->getCapacity(); +#else + (void)n; +#endif } void addKey(Node *n) { +#if SHOW_MEMORY if (!n->entryPresent) { ++totalKeys; keyBytes += getSearchPathLength(n); @@ -6040,22 +6061,36 @@ void addKey(Node *n) { peakKeyBytes = keyBytes; } } +#else + (void)n; +#endif } void removeKey(Node *n) { +#if SHOW_MEMORY if (n->entryPresent) { --totalKeys; keyBytes -= getSearchPathLength(n); } +#else + (void)n; +#endif } void removeKey(Node *n, int64_t searchPathLength) { +#if SHOW_MEMORY if (n->entryPresent) { --totalKeys; keyBytes -= searchPathLength; } +#else + (void)n; + (void)searchPathLength; +#endif } +#if SHOW_MEMORY + struct __attribute__((visibility("default"))) PeakPrinter { ~PeakPrinter() { printf("--- radix_tree ---\n"); diff --git a/ShowMemoryTest.cpp b/ShowMemoryTest.cpp index 5d3d078..440cf7f 100644 --- a/ShowMemoryTest.cpp +++ b/ShowMemoryTest.cpp @@ -86,4 +86,30 @@ int main() { assert(checkPoint(cs, "other/999", version) == ConflictSet::Conflict); } checkFullyReleased(); + + // Grow a node through every node type, so that the accounting for each node + // type's size is exercised, then erase the entries one at a time so that + // nodes are released individually rather than as part of a whole tree. + { + ConflictSet cs{0}; + std::string key(1, '\0'); + for (int i = 0; i < 256; ++i) { + key[0] = char(i); + writePoint(cs, key, i + 1); + } + for (int i = 0; i < 256; ++i) { + key[0] = char(i); + assert(checkPoint(cs, key, i) == ConflictSet::Conflict); + } + + // Garbage collection erases entries that can only fail with TooOld anyway. + // Doing it one version at a time walks the tree, erasing, downsizing and + // releasing nodes as it goes. + for (int i = 0; i < 256; ++i) { + cs.setOldestVersion(i + 1); + } + key[0] = '\0'; + assert(checkPoint(cs, key, 0) == ConflictSet::TooOld); + } + checkFullyReleased(); } From c0bd10effac9b6021eb3613707548c95e86fa046 Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Mon, 26 Oct 2026 21:47:41 -0400 Subject: [PATCH 3/4] Drop redundant coverage exclusion markers in the excluded tail region `getNodeSize`'s unreachable `default:` sits inside ConflictSet.cpp's `GCOVR_EXCL_START`/`GCOVR_EXCL_STOP` block, so the `GCOVR_EXCL_LINE` markers on those two lines are redundant: they cannot change the report, but they make gcovr print (WARNING) GCOVR_EXCL_LINE found on line 6002 in excluded region started on line 5535 once per line for each of the four reports the coverage job writes. Drop them, matching the rest of the excluded tail, which carries no exclusion markers. Context: the previous coverage job for this branch did not fail in any of its steps. Build, Test (5754/5754 passed), Coverage report (`gcovr --fail-under-line 100` reported 100% for ConflictSet.cpp, LongestCommonPrefix.h and Metrics.h), the upload and the test summary all reported success; the job was marked failed while tearing the job container down. The post-actions (actions/cache, actions/checkout) could no longer `docker exec` into the container and the runner's rootless podman socket was gone ("Cannot connect to the Docker daemon at unix:///run/user/992/podman/podman.sock", "Error while stop job container"); the actions/cache service on 192.168.0.110:41379 had been refusing connections for the whole run. Verified locally on this commit: Debug, gcc and `--coverage` builds of the coverage job's configuration; 5754/5754 tests pass; merged line coverage (union over the library, SHOW_MEMORY and fuzz-driver objects, honouring the GCOVR_EXCL_START/STOP regions) is 100% for all three measured files, and no GCOVR_EXCL_LINE markers remain inside an excluded region. --- ConflictSet.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ConflictSet.cpp b/ConflictSet.cpp index 193b9f0..1b5911b 100644 --- a/ConflictSet.cpp +++ b/ConflictSet.cpp @@ -5999,8 +5999,8 @@ int64_t getNodeSize(struct Node *n) { return sizeof(Node48); case Type_Node256: return sizeof(Node256); - default: // GCOVR_EXCL_LINE - abort(); // GCOVR_EXCL_LINE + default: + abort(); } } From 80f084cc43abc3533be0db4a55ff95bd8f29650d Mon Sep 17 00:00:00 2001 From: Weaselbot Date: Tue, 27 Oct 2026 12:56:55 -0400 Subject: [PATCH 4/4] Compile eraseTree's search path length bookkeeping out of non-SHOW_MEMORY builds `eraseTree` only carries the search path lengths of queued nodes around so that the SHOW_MEMORY key accounting doesn't have to walk `parent` pointers of nodes that have already been released. Put the lengths on a parallel stack that only exists when SHOW_MEMORY is enabled, so that other builds keep the upstream worklist of bare `Node *` and don't compute anything extra. --- ConflictSet.cpp | 78 ++++++++++++++++++++++++++++++++----------------- 1 file changed, 51 insertions(+), 27 deletions(-) diff --git a/ConflictSet.cpp b/ConflictSet.cpp index 1b5911b..326a365 100644 --- a/ConflictSet.cpp +++ b/ConflictSet.cpp @@ -2757,31 +2757,53 @@ checkMaxBetweenExclusiveImpl(Node256 *n, int begin, int end, return self; } +#if SHOW_MEMORY +// Length of the key leading to `child`, given the length of the key leading to +// its parent. Consistent with `getSearchPathLength`. +int64_t childSearchPathLength(int64_t parentSearchPathLength, Node *child) { + return parentSearchPathLength + 1 + child->partialKeyLen; +} +#endif + void eraseTree(Node *root, WriteContext *writeContext) { - // A node is released before its children are visited, so the `parent` - // pointers of queued nodes can't be relied upon. Carry the search path length - // of each node along with it instead. - struct ToFree { - Node *n; - // Length of the key leading to `n`. See `getSearchPathLength`. - int64_t searchPathLength; - }; Arena arena; - auto toFree = vector(arena); - toFree.push_back({root, getSearchPathLength(root)}); + auto toFree = vector(arena); + toFree.push_back(root); + // Length of the key leading to each node in `toFree`, on a parallel stack. + // A node is released before its children are visited, so the `parent` + // pointers that `getSearchPathLength` walks are dangling by the time a queued + // node is popped. Carry the lengths along instead. Only the SHOW_MEMORY + // accounting needs them. +#if SHOW_MEMORY + auto searchPathLengths = vector(arena); + searchPathLengths.push_back(getSearchPathLength(root)); +#endif + + // Queue `child` for release, where `parentSearchPathLength` is the length of + // the key leading to its parent. + auto queueChild = [&](Node *child, int64_t parentSearchPathLength) { + toFree.push_back(child); +#if SHOW_MEMORY + searchPathLengths.push_back( + childSearchPathLength(parentSearchPathLength, child)); +#else + (void)parentSearchPathLength; +#endif + }; while (toFree.size() > 0) { - const auto item = toFree.back(); - auto *n = item.n; + auto *n = toFree.back(); toFree.pop_back(); writeContext->accum.entries_erased += n->entryPresent; ++writeContext->accum.nodes_released; - removeKey(n, item.searchPathLength); - - // Search path length of the children of `n`. Must be read before `n` is - // released. - const int64_t childSearchPathLengthBase = item.searchPathLength + 1; +#if SHOW_MEMORY + const int64_t searchPathLength = searchPathLengths.back(); + searchPathLengths.pop_back(); +#else + const int64_t searchPathLength = 0; +#endif + removeKey(n, searchPathLength); switch (n->getType()) { case Type_Node0: { @@ -2791,36 +2813,38 @@ void eraseTree(Node *root, WriteContext *writeContext) { case Type_Node3: { auto *n3 = static_cast(n); for (int i = 0; i < n3->numChildren; ++i) { - Node *child = n3->children[i]; - toFree.push_back( - {child, childSearchPathLengthBase + child->partialKeyLen}); + queueChild(n3->children[i], searchPathLength); } writeContext->release(n3); } break; case Type_Node16: { auto *n16 = static_cast(n); for (int i = 0; i < n16->numChildren; ++i) { - Node *child = n16->children[i]; - toFree.push_back( - {child, childSearchPathLengthBase + child->partialKeyLen}); + queueChild(n16->children[i], searchPathLength); } writeContext->release(n16); } break; case Type_Node48: { auto *n48 = static_cast(n); for (int i = 0; i < n48->numChildren; ++i) { - Node *child = n48->children[i]; - toFree.push_back( - {child, childSearchPathLengthBase + child->partialKeyLen}); + queueChild(n48->children[i], searchPathLength); } writeContext->release(n48); } break; case Type_Node256: { auto *n256 = static_cast(n); auto *out = toFree.unsafePrepareAppend(n256->numChildren).data(); +#if SHOW_MEMORY + auto *outSearchPathLengths = + searchPathLengths.unsafePrepareAppend(n256->numChildren).data(); +#endif n256->bitSet.forEachSet([&](int i) { Node *child = n256->children[i]; - *out++ = {child, childSearchPathLengthBase + child->partialKeyLen}; + *out++ = child; +#if SHOW_MEMORY + *outSearchPathLengths++ = + childSearchPathLength(searchPathLength, child); +#endif }); assert(out == toFree.end()); writeContext->release(n256);