Fix potential quadratic complexity caused by `reserve(size + 1)` by kraldan · Pull Request #188 · martinus/unordered_dense · GitHub
Skip to content

Fix potential quadratic complexity caused by reserve(size + 1) - #188

Merged
martinus merged 2 commits into
martinus:mainfrom
kraldan:fix_reserve_size_plus_one
Aug 10, 2026
Merged

Fix potential quadratic complexity caused by reserve(size + 1)#188
martinus merged 2 commits into
martinus:mainfrom
kraldan:fix_reserve_size_plus_one

Conversation

@kraldan

@kraldan kraldan commented Aug 10, 2026

Copy link
Copy Markdown

Bug

I believe v4.9.0 introduced a performance regression while aiming for exception safety. The code introduced:

m_blocks.reserve(m_blocks.size() + 1);

which leads to m_blocks reallocation on every insert in a chain of inserts with libstdc++ and others, which is quadratic.

Sample program

The following program demonstrates this:

//   clang++ -O3 -DNDEBUG -std=c++17 -I include bench_segmented_growth.cpp -o bench_segmented_growth
//   ./bench_segmented_growth

#include <ankerl/unordered_dense.h>

#include <chrono>
#include <cstdio>
#include <string>

int main() {
    static constexpr std::size_t targets[] = {250'000, 500'000, 1'000'000, 2'000'000, 4'000'000, 8'000'000};

    for (auto target : targets) {
        ankerl::unordered_dense::segmented_map<std::string, int> map;

        auto const t0 = std::chrono::steady_clock::now();
        for (std::size_t i = 0; i < target; ++i) {
            map.try_emplace(std::to_string(i), 0);
        }
        auto const t1 = std::chrono::steady_clock::now();

        std::printf("%.4f s  (%zu entries)\n", std::chrono::duration<double>(t1 - t0).count(), target);
    }
}

when running on main I get the following output on machine:

0.0498 s  (250000 entries)
0.1105 s  (500000 entries)
0.3333 s  (1000000 entries)
1.3306 s  (2000000 entries)
5.3910 s  (4000000 entries)
21.8044 s  (8000000 entries)

when running with the fix:

0.0468 s  (250000 entries)
0.0929 s  (500000 entries)
0.2095 s  (1000000 entries)
0.4428 s  (2000000 entries)
1.0223 s  (4000000 entries)
2.3013 s  (8000000 entries)

Alternative fixes

Another option could be to remove the reserve all-together and wrap the push_back in a try-catch block, like:

pointer block = std::allocator_traits<Allocator>::allocate(ba, num_elements_in_block);

if constexpr (ANKERL_UNORDERED_DENSE_HAS_EXCEPTIONS()) {
    try {
        m_blocks.push_back(block);
    } catch (...) {
        std::allocator_traits<Allocator>::deallocate(ba, block, num_elements_in_block);
        throw;
    }
} else {
    m_blocks.push_back(block);
}

pros:

  • let's the specific std::vector implementation handle the scaling factor (in the PR diff, we hardcode 2x)

cons:

  • more complex code

@martinus

Copy link
Copy Markdown
Owner

@martinus
martinus merged commit 9cbc859 into martinus:main Aug 10, 2026
30 checks passed
martinus added a commit that referenced this pull request Aug 10, 2026
PR #188 fixed segmented_vector growing its block pointer array with
reserve(size + 1), which reallocated it for every new segment and made
a chain of inserts quadratic in the number of segments. Count the
allocations for a 256-segment fill: geometric growth needs one
allocation per segment plus a logarithmic handful for the index, while
the quadratic version lands near three per segment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants