elijah.run/blog/linear-search
AI Disclosure LLMs were used to generate benchmark test suites for this project.

is linear search faster than HashMap/HashSet for small sets?

published 2026-07-24 | tags: #gamedev #bevy #rust

Table of Contents

I've heard from multiple sources that when you are operating on small sets, performance matters, like in the hot-path in a game that runs every frame, it is often better to use a Vec or some sort of array and to linearly search through the data through the data instead of using a HashSet or HashMap. The rationale is that while hash-based fetches and inserts are constant time, that may be a high enough constant time that just ripping through the array is faster. At some [small] size it must be faster to just iterate through the array instead of hashing a value...

So is it?

To test this out I made simple VecSet and VecMap that implement a limited subset of the HashSet and HashMap API, namely insert, remove, get, and iter. My implementation also went the route of storing Option<T> and Option<(K,V)> for Set and Map respectively, so I added a compact() method to remove None values from the storage; I could have done a swap_remove instead of Option, since order doesn't matter, but I don't think it will really matter; our tests don't do much get and set so we don't generate many empty holes.

Aside: Why are we using Maps and Sets in a game?

Both Hash and Vec Map/Sets tend to allocate memory on the heap which I also hear is bad for games. Especially if you are allocating new memory every frame, the time to allocate memory (it is said) can dominate your frame time, drastically lowering your FPS.

In Bevy it's much more idiomatic to just make everything a Query, and do a for (a, b, c) in query.iter() or for (mut a, b, c) in query.iter_mut().

So why do we need a Map or a Set? Basically: memoization.

I often want to ask questions like "What entities are near this entity?" which falls into the category of "You can derive this from existing components, but there isn't a component to make this a Query'able directly."

fn my_system(
  query: Query<Entity, With<???>>,
  // ...
) {
  // What would this look like??
  // ...
}

so instead we build a little cache every time the system executes:

fn my_system(
  foos: Query<(Entity, &Coordinates), With<Foo>>,
  bars: Query<(Entity, &Coordinates), With<Foo>>,
  mut near_map: Local<HashMap<Entity, usize>>,
) {
  // Build the HashMap of Entity -> Number of neighbors
  // (Yes I prefer `query.iter().chain().things(|foo| ...)` over `for thing in query`)
  near_map = foos.iter().map(|(e_foo, c_foo)| {
    // Filter to just bar coordinates near foo's coordinates
    // Sum the resulting set of 1s
    let near_foo = bars
      .iter()
      .filter_map(|(e_bar, c_bar)| do_check(c_foo, c_bar).then_some(0))
      .sum();
    // Return a tuple of (Entity, usize)
    ( e_foo, near_foo )
  }).collect();

  // ... use near_map ...
}

This both gives me a nice computed lookup table, and using Bevy's Local<T> ensures I don't re-allocate memory every frame, assuming I don't you know... overlook something.

Benchmarking

VecSet vs HashSet

Winner is the fast implementation.

OperationImpl481632641282565121024Winner
iterateVecSet3.56.111.221.642.389.3172.2337.9670.2Vec, every N (~2×)
HashSet6.010.620.740.981.2161.5322.8652.31289.3
build (collect)VecSet13.918.122.733.559.8157.6262.2467.7853.1Vec, every N (~4–6×)
HashSet55.388.0139.7232.3454.1738.51346.02767.85281.8
contains — hitVecSet2.63.76.010.719.938.281.5155.1302.3Vec ≲8, else Hash
HashSet3.63.53.53.93.93.53.63.63.5
contains — missVecSet3.25.510.119.337.779.7153.4301.0595.0Hash, every N
HashSet2.72.92.92.72.92.92.72.72.7
remove oneVecSet24.738.744.160.8112.6219.6376.6685.51273.5Vec ≲8, else Hash
HashSet25.935.638.445.842.945.446.345.850.1
build (checked insert)VecSet26.0136.9244.4596.01859.35916.52108579255308808Vec ≲48, else Hash (O(n²))
HashSet101.3212.4404.8694.01305.32418.74608.58849.017437.6

VecMap vs HashMap

OperationImpl481632641282565121024Winner
build (collect)VecMap15.117.625.942.8129.7201.8353.1651.11240.7Vec, every N (~4×)
HashMap51.488.1137.5298.1468.5794.61428.02874.35613.2
get — hitVecMap2.64.36.210.826.845.381.8155.4302.7Vec ≲8, else Hash
HashMap4.24.54.24.24.34.24.24.24.2
get — missVecMap4.35.710.125.237.780.7155.7301.5596.9Hash, every N
HashMap3.53.53.53.53.53.53.53.53.5

jUsT uSe RaYoN

After the initial results I thought "surely we can parallelize the linear search right? Rayon makes things fast right?!" and did a side-quest adding a par_* version of the VecSet/VecMap types using Rayon as the underlying engine. Here are those results:

OperationImpl641024163842621441048576
contains — missVecSet seq57892141522909131281114
VecSet par1133017063412963085841169997
HashSet2.72.72.72.92.7
remove oneVecSet seq7278711928266622
VecSet par124851805232334244978
HashSet3449112928

As it turns out, Rayon has hella overhead so for small sets like this the startup cost way overshadows the compute time. Rayon took at least 11-12 µs which makes it much slower at these small sizes.

Takeway: Rayon is great for processing lots of data, like in batch jobs.

wHaT aBoUt MiCroPoOl

I heard about a cool library that's basically "Rayon for Games" called micropool. The main selling point is that it avoids some of the pitfalls of Rayon.

The results were interesting!

contains — miss (ns):

Impl641024163842621441048576
VecSet seq57932148343068331320048
VecSet par (rayon)1133017063412963085841169997
VecSet par (micropool)519102881411730091131319
HashSet2.72.82.83.02.8

remove one (ns):

Impl64102416384262144
VecSet seq7880212041283219
VecSet par (rayon)124851805232334244978
VecSet par (micropool)608145512005248263
HashSet345798948

Micropool was better than Rayon but still not nearly good enough to beat an optimized build for either Hash nor Vec implementation.

Takeaways

Notes

A few admissions:

  1. I didn't test this against Bevy's Query nor did I test it against Rust's stdlib DefaultHasher, which I expect to perform worse but that would just move the line not change the story.

  2. We could probably improve search performance in the Vec* implementations to get better performance, like sorting elements or... something. That wasn't the point of this exploration though. The bit is to see if we are leaving an obvoius naive optimization on the table.

  3. It's tempting to turn VecSet and VecMap into a library, if it isn't already, but I promise it was a very quick type to implement, and even after building it out I'm not using it in my game because the use-case for it is niche enough that I haven't actually needed it.

Conclusion

So in conclusion... wait -- now that I think about it... a set that you always want to iterate over is... a Vec. So this is basically nothing! This is just Vec with more steps!

Happy hacking~