feat(stores): Replace Map backend with O(1) sparse set#136
feat(stores): Replace Map backend with O(1) sparse set#136jtnuttall wants to merge 5 commits intojonascarpay:masterfrom
Conversation
|
Thanks for an exemplary PR. I'm a little pressed for time right now, but I want to leave some initial thoughts.
|
|
Thanks for the thoughtful feedback! This was more of an RFC than a merge request, so I'm happy to close it.
This is a totally fair point. Your suggestion of a standalone package seems like a great compromise. Would the standalone package be something you'd envision living in the
That's right. This is the main draw for this PR and the primary reason I would not recommend merging it as-is. Specifically, the overhead is on the order of It is fixable, but it's a non-trivial change. The typical mitigation strategy (roughly how Bevy handles it) involves altering the
I'd speculate that a dedicated unboxed/storable Just as a final bit of context, my original motivation for this work also included benefits I didn't detail in the PR, like the great cache locality of sparse sets on intersections and faster parent/child queries, which can make 3D scene graphs feasible within the ECS itself. |
Probably just your own account, but I'm thinking we add a "third-party packages" section to the readme where we link to it
Interesting, I wonder if we could do something like that. Having generational entities seems nice. |
|
I like entity ids being transparent so I can attach components to random external stuff like AST nodes or ProcessIDs. |
|
Yeah, we're definitely not changing the entity logic in the core apecs library |
|
Alright, thanks for taking the time to review this! I'll let you know if/when I get the time to create a spinoff package. I'll close this out now. |
Hey folks,
This PR refactors the Map store's backend. It swaps out the existing
IntMap-based implementation for one using sparse sets.The tl;dr is that this seems to offer a really nice performance boost, making the default
Mapas fast as the oldCache(IntMap)setup out-of-the-box, with even better performance forUnboxed andStorablecomponents via new specialized map types.I was curious how this sparse-set approach would perform, and the initial numbers looked promising enough that I thought it was worth sharing. I know it's a big architectural change, so I'm keen to get feedback on the trade-offs before taking it any further.
The Motivation
I've been hacking on a small rendering engine in Haskell in my spare time and wrote heph-sparse-set as part of that project. I was happy with how it turned out and ended up publishing it as its own little library.
Since I was already using
apecsas a testbed while developing it, and the benchmark numbers looked pretty good, I thought I'd clean up the integration work and submit it for real.New Features & API Changes
UMapandSMapfor components that haveUnboxorStorableinstances.MapWith,UMapWith, andSMapWithflavors. These take a type-levelNatto hint at the initial capacity, which helps avoid reallocations in hot loops.Cachedoesn't seem to provide any real benefit with the new stores. I've left theCachableinstance for backward compatibility, but I've marked it as obsolete in the docs.Breaking Changes / Downsides
vectorlower bound is now 0.12.3.0.heph-sparse-setwas published yesterday, so it's not in any stackage snapshots.Mapis now a type synonym aroundMapWith 16, so its semantics change quite a bit. This could be fixed with a newtype wrapper, but that seemed overengineered.Performance Results
To get a clearer picture, I updated the benchmark suite to be a bit more rigorous (100k entities, 1k simulation steps). Of course, real-world performance will vary, but the results from the benchmark suite are below.
1. Comparison Against the Old
IntMapBaselineThe benchmarks suggest a significant speedup compared to the original default Map.
stepTime (per step)MapIntMap(Baseline)2,299 µsCache(IntMap)91.5 µsMap(Sparse Set)83.7 µsUMap(Unbox)59.0 µsSMap(Storable)51.0 µs2. Comparison Against the Old
Cache(IntMap)BaselineThe new default Map appears to match the old cached performance without any particular capacity tuning. There are further gains from the specialized maps. The difference is likely to be even more stark if we were to look at cases where we overflow the cache.
stepTime (per step)Cache(Map)Cache(IntMap)91.5 µsMap(Sparse Set)83.7 µsUMap(Unbox)59.0 µsSMap(Storable)51.0 µsRaw Criterion reports are on my fork if you want to dig in: before and after.
Discussion Points
This change is definitely a trade-off: it prioritizes CPU performance at the cost of memory efficiency in some cases.
MapWithcapacity: Right now, the implementation usesnas the dense capacity andn * 2as the sparse capacity to reduce API complexity. This is a very fuzzy heuristic, and probably an incorrect one in most cases. Providing both parameters asNats is the superior choice here.heph-sparse-setmaintains its own internal invariants around minimum capacity, so an absurd capacity (0 or capacity(dense) > capacity(sparse)) shouldn't present issues beyond degraded performance.apecsuses a simple, monotonically increasingEntityCounter, this could lead to higher memory usage in long-running apps with a lot of entity churn.heph-sparse-setlibrary has acompactoperation that can mitigate this memory growth. The open question for this PR is how we should handle that.apecsdoesn't currently have a mechanism to trigger compaction, and its ability to constrain resources would be limited without a mechanism to constrain entity ID growth over time, so I'm interested in hearing any thoughts on what a good API for that might look like.