]> git.hungrycats.org Git - bees/commitdiff
hash: resize into the real mapping instead of a heap buffer
authorZygo Blaxell <bees@furryterror.org>
Sun, 23 Aug 2026 18:15:02 +0000 (14:15 -0400)
committerZygo Blaxell <bees@furryterror.org>
Sat, 5 Sep 2026 04:03:58 +0000 (00:03 -0400)
resize_file() built the new table in a vector<uint8_t> and reinvented
the union aliasing over it, reinterpret_casting the buffer to Bucket and
Extent the way m_bucket_ptr and m_extent_ptr already alias the mmap.
That buffer was ordinary heap: no MADV_HUGEPAGE, no MADV_DONTFORK, no
MADV_DONTDUMP, and no mlock, because prepare() did not map or lock
anything until after the resize had finished.

So the most memory-intensive phase of startup ran in the one region that
was not protected.  Redistributing cells scatters writes across the
whole new table while the old file streams through the page cache, and
with multiple gigabytes of unlocked anonymous memory competing with that
stream, the host swaps.

Map first, then fill.  prepare() now decides whether this startup is a
resize, sets m_size to the requested size, calls mmap_and_size_metadata()
and the new mlock_mapping() helper, and only then hands off to
resize_into_mapping(), which writes through m_bucket_ptr and reads back
through m_extent_ptr.  The redistribution runs in memory that is locked,
huge-page advised, and excluded from fork and core dumps, and there is no
second copy of the table anywhere.

resize_file(new_size) becomes resize_into_mapping(old_size): the new size
is already m_size by the time it runs, so the only thing it needs told is
where to read from.  It no longer sets m_size, and checks the reopened
file against it instead.

Fold the per-extent histogram seeding into the write-out loop, which
touches each extent anyway, so the resize path does not need a second
sequential walk of the mapping.  Retitle the mmap log line, which
reported the mapping size under "opened hash table filename" while the
file on disk was still the old size.

Verified against the pre-change binary by resizing one populated table
and comparing beeshash.dat: identical for 2x, 3x and 1.5x grows and for
2x and 1.5x shrinks, covering both the one-pass and merging paths and
the resize-down case where the mapping is smaller than the file read.
Startup log order is now mapping, mlock, resize, write, where it was
resize, write, mapping, mlock.

Assisted-by: Claude-Code:claude-opus-5
Signed-off-by: Zygo Blaxell <bees@furryterror.org>
src/bees-hash.cc
src/bees.h

index 4784bcbcb133d1c0a66b23f6697a030de0e99ff3..d1910f6d9acbcfb2bb0a63bbd1cd128942aaae0f 100644 (file)
@@ -693,7 +693,9 @@ BeesHashTable::mmap_and_size_metadata()
        BEESTRACE("hash table bucket size " << BLOCK_SIZE_HASHTAB_BUCKET);
        BEESTRACE("hash table extent size " << BLOCK_SIZE_HASHTAB_EXTENT);
 
-       BEESLOGINFO("opened hash table filename '" << m_filename << "' length " << m_size);
+       // m_size is the size the table will have when prepare() is done, which
+       // during a resize is the requested size, not the current file's.
+       BEESLOGINFO("mapping hash table '" << m_filename << "' length " << m_size << " (" << pretty(m_size) << ")");
        m_buckets = m_size / BLOCK_SIZE_HASHTAB_BUCKET;
        m_cells = m_buckets * c_cells_per_bucket;
        m_extents = (m_size + BLOCK_SIZE_HASHTAB_EXTENT - 1) / BLOCK_SIZE_HASHTAB_EXTENT;
@@ -744,14 +746,18 @@ BeesHashTable::prepare()
                return;
        }
 
-       // Apply state.hash.resize before mmap so the mapping is the size
-       // the operator requested, not the current on-disk size.  When a
-       // resize is in progress this is what keeps the host from having to
-       // reserve the OLD size of virtual address space (or commit it via
-       // mlock) on a machine that may have been downsized.
+       // Decide whether this startup is a resize before anything is mapped.
+       // open_file() left m_size at the on-disk size; m_desired_size is what
+       // [state.hash] size asked for.
+       const uint64_t old_size = m_size;
+       bool do_resize = false;
        if (m_persistent && m_size != m_desired_size) {
                if (m_resize) {
-                       resize_file(ranged_cast<off_t>(m_desired_size));
+                       do_resize = true;
+                       // Map at the size the operator requested, not the on-disk
+                       // size.  On a host that was downsized the old size may not
+                       // be mappable at all, and it is never what we want resident.
+                       m_size = m_desired_size;
                } else {
                        BEESLOGWARN("hash table on-disk size " << pretty(m_size)
                                << " differs from configured state.hash.size " << pretty(m_desired_size)
@@ -759,49 +765,67 @@ BeesHashTable::prepare()
                }
        }
 
-       // mmap + size metadata at the final size.
+       // Map and commit the final-size table before doing anything with it.
+       // A resize then fills this mapping in place, so it inherits
+       // MADV_HUGEPAGE and the mlock.  Building the new table in a heap
+       // buffer instead left multiple gigabytes unlocked and un-advised while
+       // the resize scattered writes across all of it and streamed the old
+       // file through the page cache, and the host swapped heavily.
        mmap_and_size_metadata();
+       mlock_mapping();
 
-       // Load the whole table into the (anonymous) mapping up front, in the
-       // main thread, before any worker can look up a hash.  This replaces
-       // the old per-extent demand-load: random worker faults turned the
-       // read into scattered I/O, so a single sequential pass here is both
-       // faster to steady state and lets the lookup/insert paths assume the
-       // table is always resident.  Memory-only mode skips this —
-       // the pages are already zero from MAP_ANONYMOUS.
-       BeesProgressLogger load_progress("Loading hash table '" + m_filename + "'");
-       for (uint64_t ext = 0; ext < m_extents; ++ext) {
+       if (do_resize) {
+               // Fills the mapping from the old file and persists it.  No load
+               // pass afterwards: the mapping already holds the new table, and
+               // the file on disk was written from it.
+               resize_into_mapping(old_size);
+       } else {
+               // Load the whole table into the (anonymous) mapping up front, in
+               // the main thread, before any worker can look up a hash.  This
+               // replaces the old per-extent demand-load: random worker faults
+               // turned the read into scattered I/O, so a single sequential pass
+               // here is both faster to steady state and lets the lookup/insert
+               // paths assume the table is always resident.  Memory-only mode
+               // skips the read — the pages are already zero from MAP_ANONYMOUS.
+               BeesProgressLogger load_progress("Loading hash table '" + m_filename + "'");
+               for (uint64_t ext = 0; ext < m_extents; ++ext) {
+                       if (m_persistent) {
+                               uint8_t *const extent_begin = m_extent_ptr[ext    ].p_byte;
+                               uint8_t *const extent_end   = m_extent_ptr[ext + 1].p_byte;
+                               pread_or_die(m_fd, extent_begin, extent_end - extent_begin, extent_begin - m_byte_ptr);
+                               BEESCOUNT(hash_extent_in);
+                               load_progress.update(ext + 1, m_extents);
+                       }
+                       // Seed the occupancy fragment from the just-loaded (or, in
+                       // memory-only mode, zeroed) extent so the survey has data
+                       // before the first writeback.
+                       recompute_extent_fill_histogram_locked(ext);
+               }
                if (m_persistent) {
-                       uint8_t *const extent_begin = m_extent_ptr[ext    ].p_byte;
-                       uint8_t *const extent_end   = m_extent_ptr[ext + 1].p_byte;
-                       pread_or_die(m_fd, extent_begin, extent_end - extent_begin, extent_begin - m_byte_ptr);
-                       BEESCOUNT(hash_extent_in);
-                       load_progress.update(ext + 1, m_extents);
+                       load_progress.finish();
                }
-               // Seed the occupancy fragment from the just-loaded (or, in
-               // memory-only mode, zeroed) extent so the survey has data before
-               // the first writeback.
-               recompute_extent_fill_histogram_locked(ext);
-       }
-       if (m_persistent) {
-               load_progress.finish();
        }
+}
 
-       // Pre-fault and lock now, while we know which size the host has to
-       // back.  prefetch_loop() used to do this on its first analyze pass,
-       // but by then the mmap had already been allocated unconditionally
-       // at the on-disk size — wrong for resize-down scenarios.  Doing
-       // allocation + lock together here means a host that cannot back
-       // the requested size fails fast and visibly.  catch_all to support
-       // users who don't want to use mlock().
-       if (m_cell_ptr) {
-               BEESLOGINFO("mlock(" << pretty(m_size) << ")...");
-               Timer lock_time;
-               catch_all([&]() {
-                       DIE_IF_NON_ZERO(mlock(m_byte_ptr, m_size));
-               });
-               BEESLOGINFO("mlock(" << pretty(m_size) << ") done in " << lock_time << " sec");
+void
+BeesHashTable::mlock_mapping()
+{
+       // Commit the pages now, while we know which size the host has to back.
+       // prefetch_loop() used to do this on its first analyze pass, but by
+       // then the mmap had already been allocated unconditionally at the
+       // on-disk size — wrong for resize-down scenarios.  Doing allocation +
+       // lock together here means a host that cannot back the requested size
+       // fails fast and visibly.  catch_all to support users who don't want
+       // to use mlock().
+       if (!m_cell_ptr) {
+               return;
        }
+       BEESLOGINFO("mlock(" << pretty(m_size) << ")...");
+       Timer lock_time;
+       catch_all([&]() {
+               DIE_IF_NON_ZERO(mlock(m_byte_ptr, m_size));
+       });
+       BEESLOGINFO("mlock(" << pretty(m_size) << ") done in " << lock_time << " sec");
 }
 
 void
@@ -938,42 +962,33 @@ BeesHashTable::set_randomize_lru(bool v)
 }
 
 void
-BeesHashTable::resize_file(off_t new_size_signed)
+BeesHashTable::resize_into_mapping(const uint64_t old_size)
 {
        THROW_CHECK1(runtime_error, m_persistent, m_persistent);
-       const uint64_t new_size = ranged_cast<uint64_t>(new_size_signed);
-       if (m_size == new_size) {
-               return;
-       }
+       // prepare() has already mapped, advised, and locked the mapping at the
+       // new size, so the destination is m_size and the union aliases over it
+       // (m_bucket_ptr, m_extent_ptr) are the ones to write through.  There
+       // is no second copy of the table anywhere.
+       const uint64_t new_size = m_size;
+       THROW_CHECK2(invalid_argument, old_size, new_size, old_size != new_size);
+       THROW_CHECK1(runtime_error, m_cell_ptr, m_cell_ptr != nullptr);
 
-       const uint64_t old_size = m_size;
        const uint64_t old_extents = old_size / BLOCK_SIZE_HASHTAB_EXTENT;
-       const uint64_t new_buckets = new_size / BLOCK_SIZE_HASHTAB_BUCKET;
+       const uint64_t new_buckets = m_buckets;
+       const uint64_t new_extents = m_extents;
        const uint64_t buckets_per_extent = BLOCK_SIZE_HASHTAB_EXTENT / BLOCK_SIZE_HASHTAB_BUCKET;
 
        BEESLOGINFO("Resizing hash table from " << pretty(old_size) << " (" << old_extents << " extents) to " << pretty(new_size));
 
-       // One full new-table copy in RAM is acceptable here — the OOM case
-       // is having OLD and NEW resident at the same time, which we avoid by
-       // reading the old file one extent at a time below.  When the old
-       // table fits in the page cache the rereads land in cache; when it
-       // does not the resize is slow but does not OOM.
-       //
        // open_file() guarantees the table size is an exact multiple of one
-       // Extent, so both views below tile the buffer exactly and the write
+       // Extent, so the Extent view tiles the mapping exactly and the write
        // loop has no partial tail extent to special-case.
        THROW_CHECK2(invalid_argument, new_size, BLOCK_SIZE_HASHTAB_EXTENT, (new_size % BLOCK_SIZE_HASHTAB_EXTENT) == 0);
-       const uint64_t new_extents = new_size / BLOCK_SIZE_HASHTAB_EXTENT;
-       Timer alloc_timer;
-       // Raw bytes, not vector<Extent>: Cell has no default constructor by
-       // design, so Bucket and Extent have none either.  Both are union
-       // aliases over the same bytes, exactly as the mmap'd table is viewed
-       // through m_bucket_ptr and m_extent_ptr.
-       vector<uint8_t> new_table(new_size, 0);
-       Bucket *const new_bucket_ptr = reinterpret_cast<Bucket *>(new_table.data());
-       const Extent *const new_extent_ptr = reinterpret_cast<const Extent *>(new_table.data());
-       BEESLOGDEBUG("Allocated " << pretty(new_size) << " (" << new_extents << " extents)"
-               " for the resized hash table in " << alloc_timer << " sec");
+
+       // The mapping is MAP_ANONYMOUS, so it starts zeroed; the old contents
+       // are read one extent at a time into a scratch buffer below, which is
+       // the only extra memory this uses.
+       Bucket *const new_bucket_ptr = m_bucket_ptr;
 
        // Can two old buckets land in the same new bucket?  A cell is placed
        // at new_bucket = hash % new_buckets, and lived at
@@ -1079,11 +1094,17 @@ BeesHashTable::resize_file(off_t new_size_signed)
        // progress is reportable in — writing 16 GiB to a slow device is
        // minutes of otherwise silent time.  Durability comes from the fsync
        // and rename below, not from the size of any one write.
+       //
+       // Seed each extent's occupancy histogram in the same pass, while the
+       // extent is the one we just touched.  prepare() does this inside its
+       // load loop for the non-resize path; doing it here saves a second
+       // sequential walk of the whole mapping.
        BeesProgressLogger write_progress("Writing resized hash table to '" + tmp_filename + "'");
        for (uint64_t ext = 0; ext < new_extents; ++ext) {
-               const Extent &extent = new_extent_ptr[ext];
+               const Extent &extent = m_extent_ptr[ext];
                pwrite_or_die(tmp_fd, extent.p_byte, sizeof(extent.p_byte),
                        ranged_cast<off_t>(ext * sizeof(Extent)));
+               recompute_extent_fill_histogram_locked(ext);
                write_progress.update(ext + 1, new_extents);
        }
        write_progress.finish();
@@ -1101,9 +1122,12 @@ BeesHashTable::resize_file(off_t new_size_signed)
        tmp_fd = Fd();
        renameat_or_die(m_ctx->home_fd(), tmp_filename, m_ctx->home_fd(), m_filename);
 
-       // Reopen the renamed file and update m_size.
+       // Reopen the renamed file.  m_size was already set to the new size by
+       // prepare() before the mapping was created, so there is nothing to
+       // update here; check that the file we just wrote agrees.
        m_fd = openat_or_die(m_ctx->home_fd(), m_filename, FLAGS_OPEN_FILE_RW, 0700);
-       m_size = new_size;
+       const off_t reopened_size = Stat(m_fd).st_size;
+       THROW_CHECK2(runtime_error, reopened_size, new_size, ranged_cast<uint64_t>(reopened_size) == new_size);
        BEESLOGINFO("Hash table resize complete: " << pretty(old_size) << " -> " << pretty(new_size));
 }
 
index 8c067be0f0ae7e92f795a47624762b71c760ef73..cb50d162e5973819b9025e7affd6f4c7880911ed 100644 (file)
@@ -890,6 +890,11 @@ private:
        /// size is known.
        void mmap_and_size_metadata();
 
+       /// mlock the mapping created by mmap_and_size_metadata(), committing
+       /// the pages so neither startup nor steady-state work can swap them
+       /// out.  catch_all: a host that declines to lock still runs.
+       void mlock_mapping();
+
 public:
        /// Apply state.hash.resize (if requested), mmap the in-memory hash
        /// table at the final size, mlock it.  Idempotent: subsequent calls
@@ -913,11 +918,11 @@ private:
        /// been mutated to move @p mv to the front).
        ///
        /// This is the lock-free, fetch-free core of push_front_hash_addr.
-       /// resize_file() calls it directly to populate a freshly allocated
-       /// new-size buffer without dragging in the live mmap's lazy-fetch
-       /// path (which would read from the *old* m_fd into the *new* buffer
-       /// and corrupt it) or its per-extent locking (single-threaded at
-       /// startup, no locking required).
+       /// resize_into_mapping() calls it directly to populate the new-size
+       /// mapping without dragging in the live mmap's lazy-fetch path (which
+       /// would read from the *old* m_fd into the *new* mapping and corrupt
+       /// it) or its per-extent locking (single-threaded at startup, no
+       /// locking required).
        static bool push_front_in_range(Cell *begin, Cell *end, const Cell &mv);
 
        void writeback_loop();
@@ -928,7 +933,20 @@ private:
        pair<uint8_t *, uint8_t *> get_extent_range(HashType hash);
        void set_extent_dirty_locked(uint64_t extent_index);
        size_t flush_dirty_extents(bool slowly);
-       void resize_file(off_t new_size);
+
+       /// Redistribute the cells of the @p old_size hash table file into the
+       /// mapping already created at the new m_size, then persist that
+       /// mapping over the old file (temp file, fsync, atomic rename) and
+       /// reopen m_fd on the result.
+       ///
+       /// Takes the OLD size, not the new one: prepare() has already set
+       /// m_size to the requested size and mapped and locked it, so this only
+       /// needs to know where it is reading from.  Filling the real mapping
+       /// is what gives the resize MADV_HUGEPAGE and the mlock; building the
+       /// new table in a heap buffer instead left it swappable, and scattering
+       /// writes across an unlocked multi-gigabyte buffer while streaming the
+       /// old file through the page cache made the host swap heavily.
+       void resize_into_mapping(uint64_t old_size);
 
        size_t                  hash_to_extent_index(HashType ht);
        unique_lock<mutex>      lock_extent_by_hash(HashType ht);