6
\$\begingroup\$

First C++ project: write a hash set for a specific scenario where I only need inserts and containment checks.

This is not AI slop, everything except for the avalanche function is hand written. Please ruthlessly correct everything you dislike; idioms, naming, formatting, performance, safety, structure, references vs pointers vs copies. None of this should require actually thinking about hash sets, but if you're so inclined, I'd also appreciate algorithmic performance insights, like what hash function I could use instead (although it's already 3x faster than the built-in on large sets, so I'm happy on that front).

The hash set is supposed to work well for both small (int / double) types, large structs with dozens of fields, and container types (which I'd store as pointers and for which I'd add comparer argument), and for all sizes.

First, the hash set code, insert_only_hash_set.tpp:

#pragma once
#include <vector>
#include <cassert>
#include <cstring>

static size_t avalanche(size_t h) {
    if constexpr (sizeof(size_t) == 8) {
        // 64-bit MurmurHash3 finalizer
        h ^= h >> 33;
        h *= 0xff51afd7ed558ccdULL;
        h ^= h >> 33;
        h *= 0xc4ceb9fe1a85ec53ULL;
        h ^= h >> 33;
    } else {
        // 32-bit MurmurHash3 finalizer
        h ^= h >> 16;
        h *= 0x85ebca6bU;
        h ^= h >> 13;
        h *= 0xc2b2ae35U;
        h ^= h >> 16;
    }
    return h;
}

template <typename T, std::size_t K, std::size_t M>
class InsertOnlyHashSet {
/* Allocates M * capacity buckets. 
Stores K entries per bucket "inline", stores the rest in a linear allocator 
(which gets compacted as needed) */ 
public:
    explicit InsertOnlyHashSet(const size_t capacity)
     : numElements(0),
       capacity(capacity),
       spillHead(0)
    {
        static_assert(M > 0, "Template parameter M must positive");
        assert(capacity > 0);
        spill = std::vector<T>(capacity);
        buckets = std::vector<BucketEntry>(M * capacity);
    }

    static size_t myHash(const T t) {
        return avalanche(std::hash<std::remove_cv_t<T>>{}(t));
    }

    bool contains(const T& item) const {
        auto thisHash = myHash(item);
        size_t bucketIndex = thisHash % nBuckets();
        auto& bucket = buckets[bucketIndex];
        auto bucketSize = bucket.size;
        auto& bucketValues = bucket.values;
        if (bucketSize == 0) {
            return false;
        }
        if (bucketSize < K) {
            return std::find(bucketValues.begin(), bucketValues.begin() + bucketSize, item) != bucketValues.begin() + bucketSize;
        }
        if (std::find(bucketValues.begin(), bucketValues.begin() + K, item) != bucketValues.begin() + K) {
            return true;
        }
        auto spillStartIndex = bucket.spillIndex;
        for (size_t offset = 0; offset < bucketSize - K; offset++) {
            if (item == spill[spillStartIndex + offset]) {
                return true;
            }
        }
        return false;
    }

    bool insert(const T& item) {
        if (numElements + 1 > capacity) {
            reserve(capacity == 0 ? 1 : capacity * 2);
        }
        auto thisHash = myHash(item);
        size_t index = thisHash % nBuckets();
        auto& bucket = buckets[index];
        auto bucketSize = bucket.size;
        auto& values = bucket.values;
        if (bucketSize < K) {
            for (size_t i = 0; i < bucketSize; i++) {
                if (item == values[i]) {
                    return false;
                }
            }
            values[bucketSize] = item;
        }
        else {
            for (size_t i = 0; i < K; i++) {
                if (item == values[i]) {
                    return false;
                }
            }
            auto spillStartIndex = bucket.spillIndex;
            for (size_t offset = 0; K + offset < bucketSize; offset++) {
                if (item == spill[spillStartIndex + offset]) {
                    return false;
                }
            }
            reserveBucketSpill(index, bucketSize + 1);
            spill[bucket.spillIndex + bucketSize - K] = item;
        }
        bucket.size = bucketSize + 1;
        numElements++;
        return true;
    }

    void reserve(size_t newCapacity) {
        auto newHashSet = InsertOnlyHashSet<T, K, M>(newCapacity);
        for (size_t j = 0; j < nBuckets(); j++){
            auto& bucket = buckets[j];
            auto bucketSize = bucket.size;
            auto& bucketValues = bucket.values;
            if (bucketSize < K) {
                for (size_t i = 0; i < bucketSize; i++) {
                    newHashSet.insert(std::move(bucketValues[i]));
                }
            }
            else {
                for (size_t i = 0; i < K; i++) {
                    newHashSet.insert(std::move(bucketValues[i]));
                }
                auto spillStartIndex = bucket.spillIndex;
                for (size_t offset = 0; K + offset < bucketSize; offset++) {
                    newHashSet.insert(std::move(spill[spillStartIndex + offset]));
                }
            }
        }
        buckets = std::move(newHashSet.buckets);
        spill = std::move(newHashSet.spill);
        spillHead = newHashSet.spillHead;
        capacity = newHashSet.capacity;
    }
    size_t numElements;
    size_t capacity;

private:
    struct BucketEntry {
        size_t size;
        size_t spillIndex;
        std::array<T, K> values;
        BucketEntry() : size(0), spillIndex(0), values{} {}
    };

    std::vector<BucketEntry> buckets;
    std::vector<T> spill;
    size_t nBuckets() const { return buckets.size(); };
    size_t nTotalSpill() const { return spill.size(); };
    size_t spillHead;

    void reserveBucketSpill(size_t bucketIndex, size_t newSize) {
        auto& bucket = buckets[bucketIndex];
        assert(newSize > bucket.size);
        assert(bucket.size >= K);
        if (bucket.spillIndex + bucket.size - K == spillHead && spillHead + (newSize - bucket.size) <= nTotalSpill()) {
            // spill of this bucket is already at the end, and we can just extend it
            spillHead += newSize - bucket.size;
        }
        else if (spillHead + (newSize - K) <= nTotalSpill()){
            // spill of this bucket gets appended to the end and the old spill is abandoned
            if constexpr (std::is_trivially_copyable_v<T>) {
                std::memcpy(&spill[spillHead], &spill[bucket.spillIndex], (bucket.size - K) * sizeof(T));
            } else {
                for (size_t offset = 0; K + offset < bucket.size; offset++) {
                    spill[spillHead + offset] = std::move(spill[bucket.spillIndex + offset]);
                }
            }
            bucket.spillIndex = spillHead;
            spillHead += newSize - K;
        }
        else{
            // we first compact the existing spill for all buckets but this one,
            // then write the newly extended spill for this bucket to the end
            auto newSpill = std::vector<T>(nTotalSpill());
            auto newHead = 0;
            // move all other spills
            for (size_t i = 0; i < nBuckets(); i++)
            {
                if (i != bucketIndex) {
                    if (auto& otherBucket = buckets[i]; otherBucket.size > K) {
                        if constexpr (std::is_trivially_copyable_v<T>) {
                            std::memcpy(&newSpill[newHead], &spill[otherBucket.spillIndex], (otherBucket.size - K) * sizeof(T));
                        } else {
                            for (size_t offset = 0; K + offset < otherBucket.size; offset++) {
                                newSpill[newHead + offset] = std::move(spill[otherBucket.spillIndex + offset]);
                            }
                        }
                        otherBucket.spillIndex = newHead;
                        newHead += otherBucket.size - K;
                    }
                }
            }
            // append this spill
            for (size_t offset = 0; K + offset < bucket.size; offset++) {
                newSpill[newHead + offset] = std::move(spill[bucket.spillIndex + offset]);
            }
            if constexpr (std::is_trivially_copyable_v<T>) {
                std::memcpy(&newSpill[newHead], &spill[bucket.spillIndex], (bucket.size - K) * sizeof(T));
            } else {
                for (size_t offset = 0; K + offset < bucket.size; offset++) {
                    newSpill[newHead + offset] = std::move(spill[bucket.spillIndex + offset]);
                }
            }
            bucket.spillIndex = newHead;
            spillHead = newHead + (newSize - K); // newSize instead of bucket.size because we reserve for this bucket
            spill = std::move(newSpill);
        }
        assert(spillHead <= nTotalSpill());
        // should hold by design as long as we only ever request one more and as long as capacity() >= numberOfinsertCalls
        // (which can be larger than numElements once we add drops)
    }
};

Next, the benchmarks: main.cpp:

#include <random>
#include <unordered_set>
#include <iostream>
#include <chrono>
#include "insert_only_hash_set.tpp"

inline void print() {
    std::cout << "\n";
}

template <typename T, typename... Args>
void print(const T& first, const Args&... rest) {
    std::cout << first << " ";
    print(rest...);
}

template <typename F>
double timeFunction(F func) {
    const auto start = std::chrono::high_resolution_clock::now();
    func();
    const auto end = std::chrono::high_resolution_clock::now();
    return std::chrono::duration<double>(end - start).count();
}

template <typename SetType>
void test(size_t n, size_t init) {
    std::default_random_engine generator1;
    std::default_random_engine generator1Clone;
    std::default_random_engine generator2(13);
    std::uniform_real_distribution<> distribution(0, 1);
    for (auto repeat = 0; repeat < (10'000'000 / n) + 1; repeat++) {
        SetType numbers(init);
        double rand;
        for (auto i = 0; i < n; i++) {
            rand = distribution(generator1);
            numbers.insert(rand);
        }
        for (auto i = 0; i < n; i++) {
            rand = distribution(generator1Clone);
            if (!numbers.contains(rand)){
                throw std::runtime_error("should be contained");
            }
            rand = distribution(generator2);
            if (numbers.contains(rand)){
                throw std::runtime_error("should not be contained");
            }
        }
    }
}

int main() {
    // ReSharper disable once CppTooWideScope
    for (auto i = 0; i < 2; i++) {
        for (auto n : {10, 100, 1000, 100'000, 1'000'000}) {
            for (auto init : {1, n}) {
                print(i, n, init);
                print("InsertOnlyHashSet", timeFunction([n, init]{test<InsertOnlyHashSet<double, 1, 2>>(n, init);}));
                print("std::unordered_set", timeFunction([n, init]{test<std::unordered_set<double>>(n, init);}));
            }
        }
    }
}
0 10 1 
InsertOnlyHashSet 1.46363 
std::unordered_set 1.46558 
0 10 10 
InsertOnlyHashSet 0.86682 
std::unordered_set 1.19881 
0 100 1 
InsertOnlyHashSet 1.26 
std::unordered_set 1.42473 
0 100 100 
InsertOnlyHashSet 0.928371 
std::unordered_set 1.30072 
0 1000 1 
InsertOnlyHashSet 1.21812 
std::unordered_set 1.6374 
0 1000 1000 
InsertOnlyHashSet 0.845891 
std::unordered_set 1.40202 
0 100000 1 
InsertOnlyHashSet 2.43145 
std::unordered_set 2.4168 
0 100000 100000 
InsertOnlyHashSet 1.23032 
std::unordered_set 2.04783 
0 1000000 1 
InsertOnlyHashSet 3.32925 
std::unordered_set 9.74492 
0 1000000 1000000 
InsertOnlyHashSet 2.74033 
std::unordered_set 7.56649 
1 10 1 
InsertOnlyHashSet 1.47043 
std::unordered_set 1.43015 
1 10 10 
InsertOnlyHashSet 0.890216 
std::unordered_set 1.19057 
1 100 1 
InsertOnlyHashSet 1.25313 
std::unordered_set 1.34393 
1 100 100 
InsertOnlyHashSet 0.849175 
std::unordered_set 1.22462 
1 1000 1 
InsertOnlyHashSet 1.19655 
std::unordered_set 1.53719 
1 1000 1000 
InsertOnlyHashSet 0.863851 
std::unordered_set 1.40414 
1 100000 1 
InsertOnlyHashSet 2.11273 
std::unordered_set 2.52363 
1 100000 100000 
InsertOnlyHashSet 1.38269 
std::unordered_set 2.08268 
1 1000000 1 
InsertOnlyHashSet 3.27826 
std::unordered_set 9.46702 
1 1000000 1000000 
InsertOnlyHashSet 2.80797 
std::unordered_set 7.20734 
\$\endgroup\$
2
  • \$\begingroup\$ (I left a tabulated rendition of the test output as a "trailing half-comment") \$\endgroup\$ Commented Nov 9 at 22:45
  • 2
    \$\begingroup\$ Try testing the type with a “barker” class that just prints on each fundamental operation. See what happens. How many T objects should be in a set that is constructed with K as 1, M as 2, and an initial capacity of 10… but with zero T objects actually inserted? Should it be 30? \$\endgroup\$ Commented Nov 10 at 3:40

4 Answers 4

7
\$\begingroup\$

Short-circuiting behavior

You have a number of places where you run a loop over some collection of data, checking for a condition on each iteration, and return false or true, short-circuiting the loop.

E.g.

            for (size_t i = 0; i < K; i++) {
                if (item == values[i]) {
                    return false;
                }
            }
            auto spillStartIndex = bucket.spillIndex;
            for (size_t offset = 0; K + offset < bucketSize; offset++) {
                if (item == spill[spillStartIndex + offset]) {
                    return false;
                }
            }

These are opportunities to use something like std::any_of or std::all_of, or since you're using a range of integers, you may wish to use std::views::iota and std::views::take or std::views::take_while with std::ranges::all_of.

            if (std::ranges::any_of(
                    std::views::iota(0) | 
                    std::views::take(K), 
                    [](auto i) { return item == values[i]; }
                )
            ) {
               return false;
            }

            auto spillStartIndex = bucket.spillIndex;

            if (std::ranges::any_of(
                    std::views::iota(0) |
                    std::views::take_while([](auto offset) { return offset + K < bucketSize; }),
                    [](auto offset) { return item == spill[spillStartIndex + offset]; }
               )
            ) {
                return false;
            }

I note also:

  • The reasoning for these loops and checks is not documented in comments.
  • That they may be better factored out into private member functions.

This opportunity for range-based iteration can also be seen in a simple loop like:

                for (size_t i = 0; i < bucketSize; i++) {
                    newHashSet.insert(std::move(bucketValues[i]));
                }

Which might be:

                for (auto &bucket : buckets) {
                    newHashSet.insert(std::move(bucket));
                }

Indentation style

In the following you have a lot of indentation going on.

            // move all other spills
            for (size_t i = 0; i < nBuckets(); i++)
            {
                if (i != bucketIndex) {
                    if (auto& otherBucket = buckets[i]; otherBucket.size > K) {
                        if constexpr (std::is_trivially_copyable_v<T>) {
                            std::memcpy(&newSpill[newHead], &spill[otherBucket.spillIndex], (otherBucket.size - K) * sizeof(T));
                        } else {
                            for (size_t offset = 0; K + offset < otherBucket.size; offset++) {
                                newSpill[newHead + offset] = std::move(spill[otherBucket.spillIndex + offset]);
                            }
                        }
                        otherBucket.spillIndex = newHead;
                        newHead += otherBucket.size - K;
                    }
                }
            }

We can reduce this using continue.

            // move all other spills
            for (size_t i = 0; i < nBuckets(); i++)
            {
                if (i == bucketIndex) {
                    continue;
                }

                if (auto& otherBucket = buckets[i]; otherBucket.size > K) {
                    if constexpr (std::is_trivially_copyable_v<T>) {
                        std::memcpy(&newSpill[newHead], &spill[otherBucket.spillIndex], (otherBucket.size - K) * sizeof(T));
                    } else {
                        for (size_t offset = 0; K + offset < otherBucket.size; offset++) {
                            newSpill[newHead + offset] = std::move(spill[otherBucket.spillIndex + offset]);
                        }
                    }
                    otherBucket.spillIndex = newHead;
                    newHead += otherBucket.size - K;
                }
                
            }

Further to this piece of code, the if (auto& otherBucket = buckets[i]; otherBucket.size > K) feels a bit excessively clever, given there is no need for otherBucket to be limited in scope to the conditional, since nothing else follows.

            // move all other spills
            for (size_t i = 0; i < nBuckets(); i++)
            {
                if (i == bucketIndex) {
                    continue;
                }

                auto& otherBucket = buckets[i]; 

                if (otherBucket.size > K) {
                    if constexpr (std::is_trivially_copyable_v<T>) {
                        std::memcpy(&newSpill[newHead], &spill[otherBucket.spillIndex], (otherBucket.size - K) * sizeof(T));
                    } else {
                        for (size_t offset = 0; K + offset < otherBucket.size; offset++) {
                            newSpill[newHead + offset] = std::move(spill[otherBucket.spillIndex + offset]);
                        }
                    }
                    otherBucket.spillIndex = newHead;
                    newHead += otherBucket.size - K;
                }
                
            }

We might further reduce indentation levels with:

            // move all other spills
            for (size_t i = 0; i < nBuckets(); i++)
            {
                if (i == bucketIndex) {
                    continue;
                }

                auto& otherBucket = buckets[i]; 

                if (otherBucket.size <= K) {
                    continue;
                }

                if constexpr (std::is_trivially_copyable_v<T>) {
                    std::memcpy(&newSpill[newHead], &spill[otherBucket.spillIndex], (otherBucket.size - K) * sizeof(T));
                } else {
                    for (size_t offset = 0; K + offset < otherBucket.size; offset++) {
                        newSpill[newHead + offset] = std::move(spill[otherBucket.spillIndex + offset]);
                    }
                }
                otherBucket.spillIndex = newHead;
                newHead += otherBucket.size - K;
            }
\$\endgroup\$
14
  • \$\begingroup\$ Thanks for the review! If I may ask one follow up on your first code block in "Short circuiting behavior". Do you mean "opportunity to use any / all" in the sense of "learning opportunity" or does your functional-style code actually typically perform better (in C#, the opposite would be the case) or do you think it's cleaner about the intent and therefore easier to extend without introducing bugs (I might come around to that eventually, but as a beginner it currently looks like it needs a lot of words and concepts to do something simple). Fully agree with everything else said. \$\endgroup\$ Commented Nov 9 at 9:42
  • \$\begingroup\$ Is it customary here to edit the question to incorporate suggestions, so that the next answerer can focus on new areas of improvement? \$\endgroup\$ Commented Nov 9 at 9:43
  • 3
    \$\begingroup\$ @Bananach it's customary here to leave the question as-is (at least when it comes to code that may be commented on) once it has at least one reply \$\endgroup\$ Commented Nov 9 at 10:04
  • 2
    \$\begingroup\$ Both of those complex any_of() sure look like they would be much simpler as contains(). For example, the first just seems to be std::ranges::contains(values, item). The fact that it’s not obvious that the loop is just doing a simple contains() makes a good case for why using named algorithms makes code simpler and clearer. (And to answer the performance concerns: on any modern compiler, with even the most basic optimizations turned on, the algorithms will be just as as efficient as a manual loop, if not more. See for yourself.) \$\endgroup\$ Commented Nov 10 at 3:01
  • 1
    \$\begingroup\$ @BЈовић You’re looking at 20 year old C guidelines (MISRA-C:2004). The C++ guidelines, first published in 2008, did not forbid continue (MISRA-C++:2008). Since 2012, neither do the C guidelines (MISRA-C:2012). \$\endgroup\$ Commented Nov 11 at 20:52
5
\$\begingroup\$
size_t index = thisHash % nBuckets();

Since you apply an avalanche function to the hash, it is safe to force the number of buckets to be a power of two and then you can use a bitwise truncation here (eg bitwise AND, or right shift if you prefer, doesn't really matter hashes that have gone through an avalanche function) instead of a more expensive remainder. Some hash sets & maps do this without applying an avalanche function to the hash, which can cause problems if the hashes have poorly distributed lower bits (if using bitwise AND) or poorly distributed upper bits (if using right shift), but after putting the raw hash through the MurmurHash3 finalizer it should be fine to take some subset of its bits as the index. When you can get away with it, skipping the avalanche function but still using a bitwise AND for the range reduction can be the fastest option (that's why some hash sets & maps do it), this only gets slow due to not being able to get away with it and generating too many collisions.

Or if you don't want to force the number of buckets to be a power of two, you could use multiply-high (at the assembly level it's as easy as a multiplication, unfortunately it takes some effort to express it in C++, hopefully multiply-high makes it into the standard library at some point), essentially that treats the avalanced hash as a fixed-point number in the range [0 .. 1) and multiplies that by the number of buckets, giving a number in the range [0 .. nBuckets). This relies on the hashes being properly distributed throughout the whole 64-bit range, which they should be after going through avalanche function, but otherwise for example if the hashes tend to be low values then the resulting indices similarly bunch up near zero.

How much avoiding a remainder operation matters depends on the CPU you test the code on. For example on Intel x64 processors like Kaby Lake and older a 64-bit remainder takes dozens of cycles to compute, while on more recent processors it may only take around a single dozen of cycles, which is still not great but noticably less than it used to be.

Given the results from various hash map benchmarks (usually it's maps, not sets, but they're pretty similar..), such as https://martin.ankerl.com/2022/08/27/hashmap-bench-01/ , it may be worth comparing not just to std::unordered_set but to some alternatives as well.

\$\endgroup\$
1
  • \$\begingroup\$ multiply-high is a great suggestion, I'd have never thought that to optimize a modulo operation. I think i'll stick with powers of 2 and AND until multiply-high is in stdlib though. comparison with other implementations wasn't my main goal. I'm under no illusion that my benchmark is particularly good anyway, I just wanted a project to learn C++ and needed any performance comparison. \$\endgroup\$ Commented Nov 9 at 13:25
1
\$\begingroup\$

A pity there is no well documented abstract class e.g. std::unordered_set is patterned after.
But given the lack of intersection and union there, I don't see how to do without iteration.

With hindsight, I think it would have merit to proceed in stages:
a) special case K == 1, no spills (just grow table & rehash) - private?
b) special case K == 1
c) general K (see question)
d) elaborate "free" management for spill? May well prove not worth it

To have spelled out in an answer what indy's comment highlights:
Keeping Ts instead of (shared(?)) pointers(/references?) uses a lot of space, especially where items are significantly bigger than their keys. And it initialises a lot of Ts.
Having table size exceed nominal capacity is conventional, their quotient designated load factor. M looks the latter's reciprocal, but even 2 is a lot: conventionally, a floating point value is used here. Likewise, a spill area the size of nominal capacity is anything but shy.


There are six sections of source code searching for an item:
two functions times (two places to search + one for when the bucket didn't spill).
The + one is easy to consolidate: search up to the min of K and bucket size.

thisHash is unused but for computing bucketIndex. Depending on the cost of establishing item equivalence, it may be useful to keep the hash values and use them to establish not equivalent cheaply.

To further reduce searching code, I shy from consolidating bucket & spill.
So how about a consolidated function?
I tried to code it (bottom of post - I find myself way out of my depth with C++, more so with modern C++.)

In reserveBucketSpill(), there are three instances of "std::is_trivially_copyable_v-optimised copy": factor out a procedure.
Calling that, it seems "this spill" gets copied twice?!


// just hacked in to eyeball feasibility
    /// search for an item.
    /// return is_contained when dont_insert;
    /// otherwise false if is_contained,
    /// else insert & return true
    bool checkInsertIf(const T& item, bool dont_insert) {
        auto thisHash = myHash(item);
        size_t index = thisHash % nBuckets();
        auto& bucket = buckets[index];
        auto bucketSize = bucket.size;
        auto& values = bucket.values;
        auto range = std::min(bucketSize, K);
        auto begin = values.begin(),
             beyond = begin + range,
             found = std::find(begin, beyond, item);
        
        if (found != beyond)  // item found in bucket
            return dont_insert;

        if (bucketSize <= K) {
            if (!dont_insert && bucketSize < K)
                values[bucketSize] = item;
        }
        else /* (K < bucketSize): handle spilled */ {
            begin = spill.begin() + bucket.spillIndex;
            range = bucketSize - K;
            beyond = begin + range;  // given references this calls for factoring out
            found = std::find(begin, beyond, item);
            if (found != beyond)  // item found in spill
                return dont_insert;
        }
        // not found anywhere
        if (dont_insert)
            return false;
        if (K <= bucketSize) {
            reserveBucketSpill(index, bucketSize + 1);
            spill[bucket.spillIndex + bucketSize - K] = item;
        }
        bucket.size = bucketSize + 1;
        numElements++;
        return true;
    }

    bool contains(const T& item) const {
        return checkInsertIf(item, true);
    }
    
    bool insert(const T& item) {
        if (capacity <= numElements) {
            reserve(capacity <= 1 ? 2 : (capacity * 5) / 3);
        }
        return checkInsertIf(item, false);
    }
\$\endgroup\$
1
  • \$\begingroup\$ The C++ standard library is into templates not inheritance, so it's no wonder there is no relevant abstract base. \$\endgroup\$ Commented Nov 12 at 15:41
1
\$\begingroup\$
  1. Don't put static asserts in constructor, since it limits of the whole class. Instead:
template <typename T, std::size_t K, std::size_t M>
class InsertOnlyHashSet {
        static_assert(M > 0, "Template parameter M must positive");
  1. initialize member variables in the initializer's list:
explicit InsertOnlyHashSet(const size_t capacity)
     : numElements(0),
       capacity(capacity),
       spillHead(0),
       buckets( M*capacity ),
       spill(capacity),
       spillHead( 0 )
    {
        assert(capacity > 0);
    }
  1. instead of using indexing, use iterators. For example, in the reserve() method, instead of this:
                for (size_t i = 0; i < K; i++) {
                    newHashSet.insert(std::move(bucketValues[i]));
                }

do this:

                auto it = std::begin( bucketValues );
                while( std::end( bucketValues) != it ) {
                    newHashSet.insert(std::move( *it++ ) );
                }

Once you make such changes to all places, you should see a huge improvement.

  1. don't mix methods and variables
...
    size_t nBuckets() const { return buckets.size(); };
    size_t nTotalSpill() const { return spill.size(); };


    std::vector<BucketEntry> buckets;
    std::vector<T> spill;
    size_t spillHead;

  1. a function (method) should do one thing and it should do it good. Also, it should fit the screen, because if you read long functions, when you get to the end, you forgot what was at the beginning. Your method reserveBucketSpill() goes over pages and is too long.
\$\endgroup\$
2
  • 1
    \$\begingroup\$ move isn't a micro optimisation. It is there for correctness. How else would you have a hashset of unique pointers? \$\endgroup\$ Commented Nov 12 at 7:32
  • \$\begingroup\$ @Caleth ops I guess I didn't think thoroughly. \$\endgroup\$ Commented Nov 14 at 10:41

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.