Skip to content

Add Experimental stores - #160

Draft
axman6 wants to merge 23 commits into
jonascarpay:masterfrom
axman6:experimental-stores
Draft

Add Experimental stores#160
axman6 wants to merge 23 commits into
jonascarpay:masterfrom
axman6:experimental-stores

Conversation

@axman6

@axman6 axman6 commented Jul 2, 2026

Copy link
Copy Markdown

I was curious whether other storage mechanisms might be useful if the developer has a good idea about the access patterns of each component. The results are somewhat mixed but look promising, particularly where unboxed vectors are used.

If none of this gets merged, I'm fine with that, it was mostly an excuse for some discussion (and my first time using Claude to write code I'd be perfectly capable of writing myself, but faster). Marked draft as I'm sure there's plenty of changes that would be needed and a few cleanup steps needed.

Structures include (names to be bikesheded):

  • ArrayMap ~= (Vector Bool, Vector c) which grows like a C++ vector when entities are written past the end. Parameterised by the component vector type so you can use unboxed or primitive vectors, which has a noticeable performance impact

  • SparseMap -= (Vector Int, Vector c, Vector Entity), stores the index that each component is stored at in the first vector so the Vector c is dense, with the last vector doing the reverse mapping which allows for O(1) operations for add and delete. Uses the same growing behaviour. Mostly useful when the component is sparse to improve memory usage. Also parameterised by the vector. The Vector Entity (really Int) allows the explMembers action to just return that vector, but it means the order is essentially random, if that a problem? See https://youtu.be/L4xOCvELWlU for details of the structure.

  • ChunkMap ~= IntMap (Vector Bool, Vector c), like lots of smallish ArrayMaps, useful when you know a component will be created in contiguous runs that might be sparse. Performance isn’t as good as I’d hoped but thought it was an interesting idea.

At the moment regrowing the arrays can add delays as you’d expect, but I have an idea for how to push that to a thread to be handled concurrently which should be safe via the magic of atomicModifyIORef (but I’ll need to check that safety carefully)

Other changes

Adds benchmarks to try and compare the different structures, as well as refactoring the ecs-bench style benchmark to remove the init overhead from the step benchmark (I got very confused when the results didn't match what the paper showed).

Performance

I've included the outputs from criterion (html and CSV) which I think does a decent job of showing how each store behaves for different access patterns. If there's anything here worth merging, I'll remove those (and the CLAUDE.md, which I included because it had a decent high level summary of the new stores).

@dpwiz

dpwiz commented Jul 2, 2026

Copy link
Copy Markdown
Collaborator

Please ask Claude to use tasty-bench and compare against cached map store as a baseline. (apecs-stm bench does this)

@axman6

axman6 commented Jul 6, 2026

Copy link
Copy Markdown
Author

All the benchmarks I added do already compare against Cache n Map, with a few different n values; 2^16 and 2^10 when there is 40k elements. I can convert it to use tasty-bench though.

@axman6

axman6 commented Jul 14, 2026

Copy link
Copy Markdown
Author

I've updated the new benchmarks to use tasty-bench, with a new cabal flag to allow running using criterion instead (mostly because the HTML output it produces is the easiest to compare results with). The change ups the cabal-version to 2.0, I'm hoping that'll be ok, as far as I can tell the version of Cabal needed dates back to 2017. (Also, no claude involved in these changes, turns out I'm still faster than it is).

Here are the SVG results from the different categories of benchmark for store-bench - the B suffix mean boxed vectors, and U mean unboxed:

Linear read

linear-read

Linear write

linear-write

Chunked read

chunked-read

Chunked write

chunked-write

Random read

random-read

Random write

random-write

The memory usage isn't included here, but I did run the benchmarks with the appropriate flags - full output here:

Details
$ (set -x; 
for pattern in linear chunked random; do     
    for operation in read write; do
        cabal run apecs:store-bench -- \
            --pattern "All.$pattern.$operation" \
            --svg "bench-results/store-bench/$pattern-$operation.svg" \
            --csv "bench-results/store-bench/$pattern-$operation.csv" \
            +RTS -T
    done
done)
cabal run apecs:store-bench -- --pattern All.linear.read --svg bench-results/store-bench/linear-read.svg --csv bench-results/store-bench/linear-read.csv +RTS -T
Configuration is affected by the following files:
- cabal.project
Resolving dependencies...
Build profile: -w ghc-9.10.3 -O1
In order, the following will be built (use -v for more details):
 - apecs-0.10.0 (lib) (configuration changed)
 - apecs-0.10.0 (bench:store-bench) (configuration changed)
Configuring library for apecs-0.10.0...
Preprocessing library for apecs-0.10.0...
Building library for apecs-0.10.0...
Configuring benchmark 'store-bench' for apecs-0.10.0...
Preprocessing benchmark 'store-bench' for apecs-0.10.0...
Building benchmark 'store-bench' for apecs-0.10.0...
All
  linear
    read
      Map:          OK
        394  μs ±  37 μs, 930 KB allocated, 2.6 KB copied,  59 MB peak memory
      Cache-16384:  OK
        118  μs ±  10 μs, 788 KB allocated, 1.9 KB copied,  59 MB peak memory
      Cache-1024:   OK
        370  μs ±  35 μs, 922 KB allocated, 2.6 KB copied,  60 MB peak memory
      ArrayMapB:    OK
        113  μs ± 4.7 μs, 788 KB allocated, 1.8 KB copied,  60 MB peak memory
      ArrayMapU:    OK
        22.8 μs ± 2.1 μs,  16 B  allocated,   0 B  copied,  60 MB peak memory
      ChunkStoreB:  OK
        209  μs ±  13 μs, 788 KB allocated, 2.0 KB copied,  60 MB peak memory
      ChunkStoreU:  OK
        240  μs ±  16 μs, 788 KB allocated, 2.0 KB copied,  60 MB peak memory
      SparseStoreB: OK
        117  μs ± 8.7 μs, 788 KB allocated, 1.8 KB copied,  60 MB peak memory
      SparseStoreU: OK
        38.1 μs ± 963 ns,  16 B  allocated,   0 B  copied,  60 MB peak memory

All 9 tests passed (21.22s)
+/etc/profiles/per-user/axman/bin/zsh:68> operation=write
+/etc/profiles/per-user/axman/bin/zsh:69> cabal run apecs:store-bench -- --pattern All.linear.write --svg bench-results/store-bench/linear-write.svg --csv bench-results/store-bench/linear-write.csv +RTS -T
Configuration is affected by the following files:
- cabal.project
All
  linear
    write
      Map:          OK
        180  μs ±  16 μs, 2.7 MB allocated,  28 KB copied,  36 MB peak memory
      Cache-16384:  OK
        38.9 μs ± 3.7 μs, 256 KB allocated,   4 B  copied,  78 MB peak memory
      Cache-1024:   OK
        203  μs ±  18 μs, 2.4 MB allocated,  22 KB copied,  78 MB peak memory
      ArrayMapB:    OK
        69.9 μs ± 6.7 μs, 280 KB allocated,   6 B  copied,  78 MB peak memory
      ArrayMapU:    OK
        70.9 μs ± 3.8 μs, 280 KB allocated,   5 B  copied,  78 MB peak memory
      ChunkStoreB:  OK
        86.9 μs ± 7.6 μs, 143 KB allocated, 292 B  copied,  78 MB peak memory
      ChunkStoreU:  OK
        79.7 μs ± 7.7 μs, 140 KB allocated, 285 B  copied,  78 MB peak memory
      SparseStoreB: OK
        96.7 μs ± 6.6 μs, 914 KB allocated,  15 B  copied,  78 MB peak memory
      SparseStoreU: OK
        115  μs ± 7.6 μs, 913 KB allocated,  14 B  copied,  78 MB peak memory

All 9 tests passed (14.27s)
+/etc/profiles/per-user/axman/bin/zsh:67> pattern=chunked
+/etc/profiles/per-user/axman/bin/zsh:68> operation=read
+/etc/profiles/per-user/axman/bin/zsh:69> cabal run apecs:store-bench -- --pattern All.chunked.read --svg bench-results/store-bench/chunked-read.svg --csv bench-results/store-bench/chunked-read.csv +RTS -T
Configuration is affected by the following files:
- cabal.project
All
  chunked
    read
      Map:          OK
        396  μs ±  18 μs, 930 KB allocated, 2.6 KB copied,  59 MB peak memory
      Cache-16384:  OK
        250  μs ±  13 μs, 866 KB allocated, 2.3 KB copied,  59 MB peak memory
      Cache-1024:   OK
        393  μs ±  18 μs, 928 KB allocated, 2.7 KB copied,  61 MB peak memory
      ArrayMapB:    OK
        108  μs ± 5.5 μs, 788 KB allocated, 1.8 KB copied,  61 MB peak memory
      ArrayMapU:    OK
        23.9 μs ± 1.9 μs,  16 B  allocated,   0 B  copied,  61 MB peak memory
      ChunkStoreB:  OK
        214  μs ±  18 μs, 788 KB allocated, 2.0 KB copied,  61 MB peak memory
      ChunkStoreU:  OK
        239  μs ±  15 μs, 788 KB allocated, 2.0 KB copied,  61 MB peak memory
      SparseStoreB: OK
        118  μs ± 7.2 μs, 788 KB allocated, 1.8 KB copied,  62 MB peak memory
      SparseStoreU: OK
        38.3 μs ± 2.2 μs,  16 B  allocated,   0 B  copied,  62 MB peak memory

All 9 tests passed (23.60s)
+/etc/profiles/per-user/axman/bin/zsh:68> operation=write
+/etc/profiles/per-user/axman/bin/zsh:69> cabal run apecs:store-bench -- --pattern All.chunked.write --svg bench-results/store-bench/chunked-write.svg --csv bench-results/store-bench/chunked-write.csv +RTS -T
Configuration is affected by the following files:
- cabal.project
All
  chunked
    write
      Map:          OK
        176  μs ±  16 μs, 2.7 MB allocated,  28 KB copied,  36 MB peak memory
      Cache-16384:  OK
        144  μs ± 9.6 μs, 1.8 MB allocated, 9.2 KB copied,  44 MB peak memory
      Cache-1024:   OK
        211  μs ±  16 μs, 2.6 MB allocated,  26 KB copied,  44 MB peak memory
      ArrayMapB:    OK
        123  μs ± 9.0 μs, 1.1 MB allocated,  23 B  copied,  82 MB peak memory
      ArrayMapU:    OK
        79.8 μs ± 7.2 μs, 1.1 MB allocated,  22 B  copied,  82 MB peak memory
      ChunkStoreB:  OK
        87.7 μs ± 6.6 μs, 143 KB allocated, 290 B  copied,  82 MB peak memory
      ChunkStoreU:  OK
        80.7 μs ± 7.9 μs, 140 KB allocated, 281 B  copied,  82 MB peak memory
      SparseStoreB: OK
        110  μs ± 8.0 μs, 1.6 MB allocated,  31 B  copied,  82 MB peak memory
      SparseStoreU: OK
        125  μs ±  10 μs, 1.6 MB allocated,  29 B  copied,  82 MB peak memory

All 9 tests passed (15.81s)
+/etc/profiles/per-user/axman/bin/zsh:67> pattern=random
+/etc/profiles/per-user/axman/bin/zsh:68> operation=read
+/etc/profiles/per-user/axman/bin/zsh:69> cabal run apecs:store-bench -- --pattern All.random.read --svg bench-results/store-bench/random-read.svg --csv bench-results/store-bench/random-read.csv +RTS -T
Configuration is affected by the following files:
- cabal.project
All
  random
    read
      Map:          OK
        700  μs ±  40 μs, 930 KB allocated, 2.6 KB copied,  59 MB peak memory
      Cache-16384:  OK
        133  μs ± 6.8 μs, 788 KB allocated, 1.9 KB copied,  59 MB peak memory
      Cache-1024:   OK
        613  μs ±  39 μs, 922 KB allocated, 2.6 KB copied,  60 MB peak memory
      ArrayMapB:    OK
        122  μs ± 6.7 μs, 788 KB allocated, 1.8 KB copied,  60 MB peak memory
      ArrayMapU:    OK
        28.9 μs ± 2.3 μs,  16 B  allocated,   0 B  copied,  60 MB peak memory
      ChunkStoreB:  OK
        218  μs ±  17 μs, 788 KB allocated, 2.0 KB copied,  60 MB peak memory
      ChunkStoreU:  OK
        251  μs ±  10 μs, 788 KB allocated, 1.9 KB copied,  60 MB peak memory
      SparseStoreB: OK
        126  μs ± 6.6 μs, 788 KB allocated, 1.9 KB copied,  61 MB peak memory
      SparseStoreU: OK
        39.3 μs ± 2.8 μs,  16 B  allocated,   0 B  copied,  61 MB peak memory

All 9 tests passed (24.36s)
+/etc/profiles/per-user/axman/bin/zsh:68> operation=write
+/etc/profiles/per-user/axman/bin/zsh:69> cabal run apecs:store-bench -- --pattern All.random.write --svg bench-results/store-bench/random-write.svg --csv bench-results/store-bench/random-write.csv +RTS -T
Configuration is affected by the following files:
- cabal.project
All
  random
    write
      Map:          OK
        955  μs ±  33 μs, 5.0 MB allocated,  51 KB copied,  36 MB peak memory
      Cache-16384:  OK
        50.7 μs ± 4.4 μs, 256 KB allocated,   4 B  copied,  78 MB peak memory
      Cache-1024:   OK
        913  μs ±  36 μs, 4.4 MB allocated,  42 KB copied,  78 MB peak memory
      ArrayMapB:    OK
        67.4 μs ± 3.1 μs, 302 KB allocated,   6 B  copied,  78 MB peak memory
      ArrayMapU:    OK
        71.3 μs ± 3.4 μs, 302 KB allocated,   4 B  copied,  78 MB peak memory
      ChunkStoreB:  OK
        158  μs ±  13 μs, 159 KB allocated, 373 B  copied,  78 MB peak memory
      ChunkStoreU:  OK
        157  μs ±  14 μs, 156 KB allocated, 366 B  copied,  78 MB peak memory
      SparseStoreB: OK
        105  μs ± 3.8 μs, 933 KB allocated,  65 B  copied,  78 MB peak memory
      SparseStoreU: OK
        116  μs ± 8.2 μs, 933 KB allocated,  13 B  copied,  78 MB peak memory

All 9 tests passed (27.99s)

@dpwiz

dpwiz commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

At the moment regrowing the arrays can add delays as you’d expect, but I have an idea for how to push that to a thread to be handled concurrently

Concurrently with what?.. Would it mess up deterministic updates required for lockstep multiplayer? 🤔

@axman6

axman6 commented Jul 14, 2026

Copy link
Copy Markdown
Author

The idea would be to reference the store from another IORef which contains something like:

data Resizable s = Live s | Resizing (Map Entity (Maybe (Elem s)) s

when it’s Live, s is used directly, but on writes, s is checked to see if it needs to resize. If so, the Map stores the updates that are needed and a thread is forked to perform the resize. It then runs in a loop where it either applies the changed from the map if it’s non-empty, and if it is empty, swap back to Live with the newly resized s.

I haven’t worked through all the details yet but I’m hoping it should be able to offset the resize latency spiked.

@dpwiz

dpwiz commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator

U-versions clearly look very interesting. Absolutely worth for scalar types like Float/Int and tuples of them.
B-versions are... okay?
Can you add Storable-backed too? They will be quite useful for Stuff like geomancy's Mat4s (and the rest of them vecs actually) which is just a bunch of bytes, but would be ripped apart into 16 arrays by unboxing for no good reason, or having a boxed wrapper and paying an indirection which is also a waste.

@axman6

axman6 commented Jul 15, 2026

Copy link
Copy Markdown
Author

@dpwiz will do. Yeah the boxed versions only really make sense if there isn’t an efficient Vector to store them in or the objects will be shared (or avoiding excessive strictness is important). It’s mainly there because it works for all types without any extra instances; it’s an easy default to try but quite wasteful for memory.

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