PersistentMap/PersistentMap/Readme.org

18 KiB

PersistentMap

A high-performance, persistent (Copy-on-Write) B+ Tree implemented in C#.

It is designed for zero-overhead reads, SIMD-accelerated key routing, and allocation-free range queries. It supports both fully immutable usage and "Transient" mode for high-throughput bulk mutations.

Features

  • Copy-on-Write Semantics: Thread-safe, immutable tree states. Modifying the tree yields a new version while sharing unmodified nodes.
  • Transient Mode: Perform bulk mutations in-place with standard mutable performance, then freeze it into a PersistentMap in $O(1)$ time.
  • SIMD Prefix Scanning: Uses AVX2/AVX512 to vectorize B+ tree routing and binary searches via long key-prefixes.
  • Linear Time Set Operations: Sort-merge based Intersect, Except, and SymmetricExcept execute in $O(N+M)$ time using lazy evaluation.

When should I use this?

Never, probably. This was just a fun little project. If you want a really fast immutable sorted map you should consider it. Despite this map being faster than LanguageExt.HashMap for some key types, you should definitely use that if you don't need a sorted collection. It is well tested and does not have any problems key collisions, which will slow this map down by a lot.

The general version of this, using StandardStrategy<K> does not benefit from the prefix optimization.

Quick Start

1. Basic Immutable Usage

By default, the map is immutable. Every write operation returns a new, updated version of the map.

// Create a map with a specific key strategy (e.g., Int, Unicode, Double)
var map1 = BaseOrderedMap<int, string, IntStrategy>.Create(new IntStrategy());

// Set returns a new tree instance. map1 remains empty.
var map2 = map1.Set(1, "Apple")
               .Set(2, "Banana")
               .Set(3, "Cherry");

if (map2.TryGetValue(2, out var value)) 
{
    Console.WriteLine(value); // "Banana"
}

2. Transient Mode (Bulk Mutations)

If you need to insert thousands of elements, creating a new persistent tree on every insert is too slow. Use a TransientMap to mutate the tree in-place, then lock it into a persistent snapshot.

var transientMap = BaseOrderedMap<int, string, IntStrategy>.CreateTransient(new IntStrategy());

// Mutates in-place. No allocations for unchanged tree paths.
for (int i = 0; i < 10_000; i++)
{
    transientMap.Set(i, $"Value_{i}");
}

// O(1) freeze. Returns a thread-safe immutable PersistentMap.
var persistentSnapshot = transientMap.ToPersistent();

3. Range Queries and Iteration

Because it is a B+ tree, leaf nodes are linked. Range queries require zero allocations and simply walk the leaves.

var map = GetPopulatedMap();

// Iterate exact bounds
foreach (var kvp in map.Range(min: 10, max: 50))
{
    Console.WriteLine($"{kvp.Key}: {kvp.Value}");
}

// Open-ended queries
var greaterThan100 = map.From(100);
var lessThan50 = map.Until(50);
var allElements = map.AsEnumerable();

4. Tree Navigation

Find bounds and adjacent elements instantly. Missing keys will correctly resolve to the mathematical lower/upper bound.

// Get extremes
map.TryGetMin(out int minKey, out string minVal);
map.TryGetMax(out int maxKey, out string maxVal);

// Get the immediate next/previous element (works even if '42' doesn't exist)
if (map.TryGetSuccessor(42, out int nextKey, out string nextVal))
{
    Console.WriteLine($"The key immediately after 42 is {nextKey}");
}

if (map.TryGetPredecessor(42, out int prevKey, out string prevVal))
{
    Console.WriteLine($"The key immediately before 42 is {prevKey}");
}

5. Set Operations

Set operations take advantage of the tree's underlying sorted linked-list structure to merge trees in linear $O(N+M)$ time.

var mapA = CreateMap(1, 2, 3, 4);
var mapB = CreateMap(3, 4, 5, 6);

// Returns { 3, 4 }
var common = mapA.Intersect(mapB); 

// Returns { 1, 2 }
var onlyInA = mapA.Except(mapB); 

// Returns { 1, 2, 5, 6 }
var symmetricDiff = mapA.SymmetricExcept(mapB);

Benchmarks

This is going to be all over the place, but here is a small comparison to other immutable sequences. Due to how the prefix optimization works, this persistent map will be the absolutely most performant when there is high entropy in the first 8 bytes of the key. The following is pretty much the best scenario we can have since we probably only look at the first 8 characters (this is for reading a value).

| Method          | CollectionSize | KeySize  |          Mean |       Gen0 | Allocated |
|-----------------|----------------|----------|--------------:|-----------:|----------:|
| PersistentMap   | **1024**       | **10**   |  **25.61 ns** | **0.0043** |  **72 B** |
| Sys.Sorted      | 1024           | 10       |     153.18 ns |          - |         - |
| LangExt.HashMap | 1024           | 10       |      24.80 ns |          - |         - |
| LangExtSorted   | 1024           | 10       |     176.90 ns |          - |         - |
| PersistentMap   | **1024**       | **100**  |  **26.43 ns** | **0.0043** |  **72 B** |
| SysSorted       | 1024           | 100      |     154.77 ns |          - |         - |
| LangExt.HashMap | 1024           | 100      |      66.30 ns |          - |         - |
| LangExtSorted   | 1024           | 100      |     177.28 ns |          - |         - |
| PersistentMap   | **1024**       | **1000** |  **26.17 ns** | **0.0043** |  **72 B** |
| SysSorted       | 1024           | 1000     |     155.68 ns |          - |         - |
| LangExt.HashMap | 1024           | 1000     |     491.97 ns |          - |         - |
| LangExtSorted   | 1024           | 1000     |     181.58 ns |          - |         - |
| PersistentMap   | **131072**     | **10**   | **109.34 ns** | **0.0072** | **120 B** |
| SysSorted       | 131072         | 10       |     460.22 ns |          - |         - |
| LangExt.HashMap | 131072         | 10       |      60.35 ns |          - |         - |
| LangExtSorted   | 131072         | 10       |     555.17 ns |          - |         - |
| PersistentMap   | **131072**     | **100**  | **147.30 ns** | **0.0072** | **120 B** |
| SysSorted       | 131072         | 100      |     556.39 ns |          - |         - |
| LangExt.HashMap | 131072         | 100      |     162.81 ns |          - |         - |
| LangExtSorted   | 131072         | 100      |     605.15 ns |          - |         - |
| PersistentMap   | **131072**     | **1000** | **170.16 ns** | **0.0072** | **120 B** |
| SysSorted       | 131072         | 1000     |     625.78 ns |          - |         - |
| LangExt.HashMap | 131072         | 1000     |     763.75 ns |          - |         - |
| LangExtSorted   | 131072         | 1000     |     692.92 ns |          - |         - |

To look at pure overhead, here is a benchmark using integers as keys. This is also a good fit for this BTree, since it can utilize a key strategy that compares integers using AVX. The hash based alternatives are going to have a huge advantage here. As you can see, reading a single value isn't great, and setting a single value after building the btree is also pretty awful (setting many should probably be done using transients). Iterating a building (using transients. The only valid comparison here is probably to MS SortedDict) is however plenty fast.

| Method                              | N      |             Mean |      Gen0 |      Gen1 |    Gen2 |   Allocated |
|-------------------------------------|--------|-----------------:|----------:|----------:|--------:|------------:|
| 'Build: PersistentMap (Transient)'  | 100    |      3,764.63 ns |    0.3929 |    0.0038 |       - |      6632 B |
| 'Build: MS Sorted (Builder)'        | 100    |      3,096.11 ns |    0.2899 |    0.0038 |       - |      4864 B |
| 'Build: LanguageExt Map (AVL)'      | 100    |      6,967.02 ns |    2.2736 |    0.0229 |       - |     38144 B |
| 'Build: LanguageExt HashMap'        | 100    |      4,594.07 ns |    1.9684 |    0.0076 |       - |     33024 B |
| 'Read: PersistentMap'               | 100    |      1,596.68 ns |    0.4292 |         - |       - |      7200 B |
| 'Read: MS Sorted'                   | 100    |        474.54 ns |         - |         - |       - |           - |
| 'Read: LanguageExt Map'             | 100    |      1,311.31 ns |         - |         - |       - |           - |
| 'Read: LanguageExt HashMap'         | 100    |        641.22 ns |         - |         - |       - |           - |
| 'Iterate: PersistentMap'            | 100    |        135.41 ns |         - |         - |       - |           - |
| 'Iterate: MS Sorted'                | 100    |        372.31 ns |         - |         - |       - |           - |
| 'Iterate: LanguageExt Map'          | 100    |        287.33 ns |    0.0019 |         - |       - |        32 B |
| 'Iterate: LanguageExt HashMap'      | 100    |        781.56 ns |    0.0648 |         - |       - |      1088 B |
| 'Set: PersistentMap'                | 100    |         85.68 ns |    0.1142 |    0.0007 |       - |      1912 B |
| 'Set: MS Sorted'                    | 100    |         66.44 ns |    0.0229 |         - |       - |       384 B |
| 'Set: LanguageExt Map'              | 100    |         60.04 ns |    0.0219 |         - |       - |       368 B |
| 'Set: LanguageExt HashMap'          | 100    |         36.62 ns |    0.0206 |         - |       - |       344 B |
| 'Build: PersistentMap (Transient)'  | 1000   |     49,445.56 ns |    3.1738 |    0.2441 |       - |     53096 B |
| 'Build: MS Sorted (Builder)'        | 1000   |     50,163.19 ns |    2.8687 |    0.4272 |       - |     48064 B |
| 'Build: LanguageExt Map (AVL)'      | 1000   |    103,877.98 ns |   34.6680 |    3.1738 |       - |    580688 B |
| 'Build: LanguageExt HashMap'        | 1000   |    124,339.17 ns |   45.4102 |    3.2959 |       - |    760096 B |
| 'Read: PersistentMap'               | 1000   |     17,671.71 ns |    4.3030 |         - |       - |     72000 B |
| 'Read: MS Sorted'                   | 1000   |      7,911.72 ns |         - |         - |       - |           - |
| 'Read: LanguageExt Map'             | 1000   |     20,187.52 ns |         - |         - |       - |           - |
| 'Read: LanguageExt HashMap'         | 1000   |      9,740.28 ns |         - |         - |       - |           - |
| 'Iterate: PersistentMap'            | 1000   |      1,217.47 ns |         - |         - |       - |           - |
| 'Iterate: MS Sorted'                | 1000   |      3,875.47 ns |         - |         - |       - |           - |
| 'Iterate: LanguageExt Map'          | 1000   |      2,862.82 ns |         - |         - |       - |        32 B |
| 'Iterate: LanguageExt HashMap'      | 1000   |     11,974.93 ns |    1.9226 |         - |       - |     32320 B |
| 'Set: PersistentMap'                | 1000   |        121.01 ns |    0.1142 |    0.0007 |       - |      1912 B |
| 'Set: MS Sorted'                    | 1000   |         91.62 ns |    0.0315 |         - |       - |       528 B |
| 'Set: LanguageExt Map'              | 1000   |         82.26 ns |    0.0305 |         - |       - |       512 B |
| 'Set: LanguageExt HashMap'          | 1000   |         57.02 ns |    0.0367 |         - |       - |       616 B |
| 'Build: PersistentMap (Transient)'  | 100000 | 10,808,233.62 ns |  296.8750 |  218.7500 |       - |   5185832 B |
| 'Build: MS Sorted (Builder)'        | 100000 | 16,655,882.43 ns |  281.2500 |  250.0000 |       - |   4800064 B |
| 'Build: LanguageExt Map (AVL)'      | 100000 | 39,932,734.83 ns | 5333.3333 | 3333.3333 |       - |  89959040 B |
| 'Build: LanguageExt HashMap'        | 100000 | 21,220,179.10 ns | 5781.2500 | 2968.7500 | 31.2500 |  96555422 B |
| 'Read: PersistentMap'               | 100000 |  7,359,807.97 ns |  710.9375 |         - |       - |  12000000 B |
| 'Read: MS Sorted'                   | 100000 |  8,428,009.48 ns |         - |         - |       - |           - |
| 'Read: LanguageExt Map'             | 100000 | 10,268,884.43 ns |         - |         - |       - |           - |
| 'Read: LanguageExt HashMap'         | 100000 |  1,936,555.07 ns |         - |         - |       - |           - |
| 'Iterate: PersistentMap'            | 100000 |    151,028.79 ns |         - |         - |       - |           - |
| 'Iterate: MS Sorted'                | 100000 |  1,068,072.16 ns |         - |         - |       - |           - |
| 'Iterate: LanguageExt Map'          | 100000 |    837,677.39 ns |         - |         - |       - |        32 B |
| 'Iterate: LanguageExt HashMap'      | 100000 |  1,226,773.82 ns |   64.4531 |         - |       - |   1082432 B |
| 'Set: PersistentMap'                | 100000 |        208.61 ns |    0.1984 |    0.0024 |       - |      3320 B |
| 'Set: MS Sorted'                    | 100000 |        138.82 ns |    0.0458 |         - |       - |       768 B |
| 'Set: LanguageExt Map'              | 100000 |        128.28 ns |    0.0448 |         - |       - |       752 B |
| 'Set: LanguageExt HashMap'          | 100000 |         84.33 ns |    0.0583 |         - |       - |       976 B |

Lastly, here is a comparison of how things look compared to itself for when the prefixes are turned off for strings. This relies on regular linear string searches. This is however STILL a pretty good benchmark for all ordered dicts, since the strings are random, meaning the string comparison can stop almost immediately. For real world keys, all hash based dicts will be better, with everything regarding getting or setting a single key.

| Method                     | N     | KeyLength | Mean            | Gen0     | Gen1     | Allocated |
|--------------------------- |------ |---------- |----------------:|---------:|---------:|----------:|
| 'Build: NiceBTree'         | 10000 | 10        | 2,037,851.45 ns |  35.1563 |  15.6250 |  644600 B |
| 'Build: MS HashDict'       | 10000 | 10        | 1,647,876.61 ns |  37.1094 |  15.6250 |  640096 B |
| 'Build: MS SortedDict'     | 10000 | 10        | 3,853,709.48 ns |  31.2500 |  11.7188 |  560112 B |
| 'Build: LangExt HashMap'   | 10000 | 10        | 1,612,117.07 ns | 472.6563 | 154.2969 | 7919328 B |
| 'Build: LangExt Map'       | 10000 | 10        | 5,363,298.26 ns | 507.8125 | 203.1250 | 8594784 B |
| 'Read: NiceBTree'          | 10000 | 10        |        36.30 ns |        - |        - |         - |
| 'Read: MS HashDict'        | 10000 | 10        |        12.66 ns |        - |        - |         - |
| 'Read: MS SortedDict'      | 10000 | 10        |       233.59 ns |        - |        - |         - |
| 'Read: LangExt HashMap'    | 10000 | 10        |        28.61 ns |        - |        - |         - |
| 'Read: LangExt Map'        | 10000 | 10        |       268.13 ns |        - |        - |         - |
| 'Iterate: NiceBTree'       | 10000 | 10        |    12,630.95 ns |        - |        - |         - |
| 'Iterate: MS HashDict'     | 10000 | 10        |   151,314.44 ns |        - |        - |         - |
| 'Iterate: MS SortedDict'   | 10000 | 10        |    57,402.20 ns |        - |        - |         - |
| 'Iterate: LangExt HashMap' | 10000 | 10        |   148,980.47 ns |  10.0098 |        - |  170712 B |
| 'Iterate: LangExt Map'     | 10000 | 10        |    34,428.07 ns |        - |        - |      32 B |
| 'Update: NiceBTree'        | 10000 | 10        |       303.01 ns |   0.2027 |   0.0024 |    3392 B |
| 'Update: MS HashDict'      | 10000 | 10        |        48.36 ns |   0.0100 |        - |     168 B |
| 'Update: MS SortedDict'    | 10000 | 10        |       137.47 ns |   0.0196 |        - |     328 B |
| 'Update: LangExt HashMap'  | 10000 | 10        |       102.57 ns |   0.0502 |   0.0001 |     840 B |
| 'Update: LangExt Map'      | 10000 | 10        |       122.54 ns |   0.0186 |        - |     312 B |
| 'Build: NiceBTree'         | 10000 | 50        | 2,020,984.87 ns |  35.1563 |  11.7188 |  624248 B |
| 'Build: MS HashDict'       | 10000 | 50        | 1,811,186.24 ns |  37.1094 |  15.6250 |  640096 B |
| 'Build: MS SortedDict'     | 10000 | 50        | 3,883,214.25 ns |  31.2500 |  15.6250 |  560112 B |
| 'Build: LangExt HashMap'   | 10000 | 50        | 1,784,616.64 ns | 472.6563 | 154.2969 | 7926712 B |
| 'Build: LangExt Map'       | 10000 | 50        | 5,248,030.22 ns | 507.8125 | 203.1250 | 8544720 B |
| 'Read: NiceBTree'          | 10000 | 50        |        40.64 ns |        - |        - |         - |
| 'Read: MS HashDict'        | 10000 | 50        |        29.91 ns |        - |        - |         - |
| 'Read: MS SortedDict'      | 10000 | 50        |       255.55 ns |        - |        - |         - |
| 'Read: LangExt HashMap'    | 10000 | 50        |        47.61 ns |        - |        - |         - |
| 'Read: LangExt Map'        | 10000 | 50        |       255.68 ns |        - |        - |         - |
| 'Iterate: NiceBTree'       | 10000 | 50        |    12,718.71 ns |        - |        - |         - |
| 'Iterate: MS HashDict'     | 10000 | 50        |   170,815.59 ns |        - |        - |         - |
| 'Iterate: MS SortedDict'   | 10000 | 50        |    68,982.58 ns |        - |        - |         - |
| 'Iterate: LangExt HashMap' | 10000 | 50        |   144,442.27 ns |   9.7656 |        - |  165600 B |
| 'Iterate: LangExt Map'     | 10000 | 50        |    35,082.49 ns |        - |        - |      32 B |
| 'Update: NiceBTree'        | 10000 | 50        |       393.56 ns |   0.2027 |   0.0024 |    3392 B |
| 'Update: MS HashDict'      | 10000 | 50        |       114.57 ns |   0.0215 |        - |     360 B |
| 'Update: MS SortedDict'    | 10000 | 50        |        65.51 ns |   0.0129 |        - |     216 B |
| 'Update: LangExt HashMap'  | 10000 | 50        |       103.28 ns |   0.0535 |        - |     896 B |
| 'Update: LangExt Map'      | 10000 | 50        |        67.62 ns |   0.0119 |        - |     200 B |

Architecture Notes: Key Strategies

NiceBtree uses IKeyStrategy<K> to map generic keys (like string or double) into sortable long prefixes. This achieves two things:

  1. Enables AVX512/AVX2 vector instructions to search internal nodes simultaneously.
  2. Avoids expensive IComparable<T> interface calls or string.Compare during the initial descent of the tree, only falling back to exact comparisons when refining the search within a leaf.

This means that it will be fast for integers and anything you can pack in 8 bytes. If you use stings with high prefix entropy, this will be very performant. If you don't, it is just another b+tree.