Skip to content
bjoliPublic

About

A persistent map for c#

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

25 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Map, an immutable and unsafe champ that is also fast

Good afternoon. This is a really old project of mine that I never really got running in the first place. I was simple too dumb to get it to work. I had spent lots of time testing different things out, just to get a memory layout that was fast, but when it came down to me stitching it together I failed. Badly even.

As every hobby programmer, I dream about making my own language. It manifests in me looking around and seeing different things I like, and thinking “how sad it doesn’t use S-expression syntax”. Then one day I decided to try, and started not by actually designing a language, but by trying to get some old data structures to work. Being a lazy sod, I got gemini to help me debug the issue I was having. It wasn’t actually one issue, but three, and fixing either one actually made the other two worse, which was why I never got anywhere at the start. I also didn’t know what a stepping debugger was. Now that I get one in emacs thanks to LSP I have actually started using it.

However, when that issue was fixed, and one stupid inlining mishap was fixed it turns out this is actually very fast. So, I added an enumerator and a transient interface (holy code-duplication-batman!). AI helped me a bit along the way. Not that much, but still a bit.

How does it look?

Below is some general usage. Remember, this is an immutable data structure that does path copying. This means an update or addition usually only incurs a small copy penalty. It is of course often slower than a mutable dictionary, but still pretty fast. I can confidently say that operation for operation this implementation is faster than doing the same thing with clojure’s maps, and people are using those for serious business™.

using Map;

// --- 1. Basic Initialization & Persistence ---
var map1 = Map<string, int>.Empty.Add("Alice", 25).Add("Bob", 30);
var map2 = map1.Remove("Alice"); // Returns a new map without Alice

Console.WriteLine($"Map1 has Alice: {map1.ContainsKey("Alice")}"); // True
Console.WriteLine($"Map2 has Alice: {map2.ContainsKey("Alice")}"); // False

// --- 2. Retrieval & Properties ---
int bobAge = map1["Bob"]; // Throws KeyNotFoundException if missing
bool hasCharlie = map1.TryGetValue("Charlie", out int charlieAge);
Console.WriteLine($"Count: {map1.Count}, IsEmpty: {map1.IsEmpty}");

// --- 3. Enumeration ---
// Uses the struct-based MapEnumerator for zero-allocation iteration
foreach (var kvp in map1)
{
        Console.WriteLine($"{kvp.Key}: {kvp.Value}");
}

// --- 4. Raw Transient Interface ---
// Avoids allocating intermediate tree nodes for sequential updates
TransientMap<string, int> transientMap = map1.ToTransient();
transientMap.Add("Charlie", 35);
transientMap.Add("Dave", 40);
transientMap.Remove("Bob");

if (transientMap.TryGetValue("Charlie", out int val))
{
        // Retrieval works directly on the transient structure
}

// Commits changes and disowns the transient nodes
Map<string, int> map3 = transientMap.ToImmutable();

// --- 5. Bulk Construction via MapBuilder ---
// Uses highly optimized radix sort for fast initialization
var builder = new MapBuilder<string, int>(initialCapacity: 100);
for (int i = 0; i < 100; i++)
{
        builder.Add($"User{i}", i);
}
var map4 = builder.ToImmutable();

// --- 6. Functional Utilities & Searching ---
// MapCar: Transform keys and values into a new Map
var transformed = map1.MapCar((k, v) => (k.ToUpper(), v * 2));

// Filter: Retain elements matching a predicate
var filtered = transformed.Filter((k, v) => v > 50);

// FoldValues: Aggregate values sequentially
int sumOfAges = map1.FoldValues(0, (acc, v) => acc + v);

// Exists & FindKey
bool exists = map1.Exists((k, v) => v > 20);
string key = map1.FindKey(k => k.StartsWith("A"));

// Iter: Execute a delegate. Returning false aborts iteration early.
map1.Iter((k, v) => 
{
        if (k == "Bob") return false; // Abort early
            return true; 
});

// ForEach: Unconditional execution over all elements
map1.ForEach((k, v) => Console.WriteLine(k));

// --- 7. Merging Maps ---
// Resolves conflicts by explicitly preferring the right side's value
var map5 = map3.Merge(map1, conflictResolver: (k, left, right) => right);

Documentation

I wouldn’t call it good by any stretch of the imagination. I don’t know how docfx works: https://bjoli.github.io/api/Map.Map-2.html

How fast?

I would say fast. Not as much as I expected from my previous runs, but it is still very competitive. Here are some benchmarks for six different workloads. These are all <int, int> benchmarks, but things don’t change much for strings when everyone uses the same kind of hashing algorithm. Using other keys just adds noise, since nobody uses a novel string key storage anyway.

The transient map is a map that allows for mutating updates on nodes that has no other owner. This allows for somewhat faster building, but very fast bulk updates. The builder is a specialized builder that adds all keys to a list, radix sorts them by the hash and builds the tree directly. It is very fast.

Over all, when using all-persistent workloads (no transients or builders) it is around 2-10x faster than ImmDict (I didn’t benchmark the ImmutableDict builder, though), while being in the same ballpark with regards to memory usage. If you can use transients and builders for bulk updates, it stays within 3x of the regular mutable Dict. In some workloads it will actually be faster, which was surprising to me

The .md output from benchmarkdotnet can be found in the Benchmarks/Fullresults folder. That also compares against some other containers.

Building

Builds a map of N key-value pairs.

MethodNMeanStdDevMedianGen0Gen1Gen2Allocated
Build_Map1003,362.84 ns42.560 ns3,359.62 ns2.01420.0076-33728 B
Build_TransientMap1002,329.61 ns41.018 ns2,321.31 ns1.14060.0038-19112 B
Build_MapBuilder1001,953.42 ns10.720 ns1,948.06 ns0.3777--6360 B
Build_ImmDict1008,957.87 ns54.794 ns8,957.04 ns2.48720.0305-41688 B
Build_Dict100607.01 ns3.130 ns607.47 ns0.44160.0095-7392 B
Build_Map100044,389.03 ns973.064 ns43,895.81 ns29.41890.6104-492288 B
Build_TransientMap100021,505.54 ns169.379 ns21,496.56 ns10.46750.5188-175272 B
Build_MapBuilder100011,979.57 ns203.631 ns11,961.01 ns2.73130.0916-45936 B
Build_ImmDict1000145,834.04 ns383.463 ns145,858.84 ns35.64453.6621-598712 B
Build_Dict10005,864.75 ns37.385 ns5,850.49 ns4.36400.8698-73168 B
Build_Map10000634,241.37 ns7,368.893 ns632,125.18 ns413.085985.9375-6915072 B
Build_TransientMap10000241,740.35 ns1,182.753 ns241,425.54 ns72.753920.0195-1220264 B
Build_MapBuilder10000188,261.14 ns1,469.207 ns187,786.24 ns125.0000125.0000125.0000635972 B
Build_ImmDict100001,887,838.10 ns10,243.033 ns1,883,063.16 ns470.7031175.7813-7882552 B
Build_Dict10000181,170.48 ns885.224 ns181,009.45 ns124.7559124.7559124.7559673148 B
Build_Map10000013,352,245.40 ns675,587.686 ns13,180,942.52 ns5593.75001578.125062.500092621235 B
Build_TransientMap1000004,111,267.83 ns82,502.540 ns4,083,680.88 ns1179.6875695.3125-19845032 B
Build_MapBuilder1000002,861,721.92 ns59,803.892 ns2,864,021.21 ns414.0625363.2813281.25006507646 B
Build_ImmDict10000031,480,295.97 ns849,470.357 ns31,346,999.86 ns5875.00001000.000062.500097460067 B
Build_Dict1000001,070,066.17 ns63,782.635 ns1,057,968.13 ns220.7031207.0313207.03136038430 B

Retrieval

Retrieves N/10 elements

MethodNMeanErrorStdDevMedian
Retrieve_Map10024.50 ns0.188 ns0.176 ns24.44 ns
Retrieve_ImmDict10030.93 ns0.118 ns0.098 ns30.94 ns
Retrieve_Dict10016.36 ns0.050 ns0.044 ns16.36 ns
Retrieve_Map1000235.39 ns0.723 ns0.677 ns235.32 ns
Retrieve_ImmDict1000526.33 ns9.749 ns8.141 ns525.21 ns
Retrieve_Dict1000154.42 ns0.491 ns0.460 ns154.48 ns
Retrieve_Map100003,808.30 ns13.760 ns12.871 ns3,806.77 ns
Retrieve_ImmDict1000013,218.41 ns43.524 ns40.713 ns13,226.65 ns
Retrieve_Dict100001,729.55 ns5.281 ns4.682 ns1,727.68 ns
Retrieve_Map10000086,288.02 ns513.502 ns428.797 ns86,253.90 ns
Retrieve_ImmDict100000964,102.39 ns9,164.448 ns8,572.430 ns963,404.86 ns
Retrieve_Dict10000030,039.86 ns454.693 ns379.689 ns29,912.95 ns

Updating

Takes a map of N elements and updates N/10 of them

MethodNMeanStdDevMedianGen0Gen1Gen2Allocated
Update_Map100325.58 ns2.434 ns325.70 ns0.23560.0005-3944 B
Update_TransientMap100147.82 ns1.093 ns147.85 ns0.0515--864 B
Update_ImmDict100958.19 ns11.723 ns957.77 ns0.2308--3872 B
Update_Dict100197.13 ns1.190 ns197.23 ns0.1357--2272 B
Update_Map10004,266.67 ns43.275 ns4,262.69 ns3.69260.0839-61816 B
Update_TransientMap10001,205.30 ns4.732 ns1,205.17 ns0.52450.0153-8792 B
Update_ImmDict100013,507.24 ns76.703 ns13,521.37 ns3.28060.1068-54960 B
Update_Dict10001,888.26 ns23.484 ns1,877.85 ns1.32560.1011-22192 B
Update_Map1000063,366.19 ns666.225 ns63,229.88 ns44.31156.7139-742376 B
Update_TransientMap1000019,426.34 ns49.678 ns19,439.99 ns4.91330.9155-82224 B
Update_ImmDict10000215,919.62 ns2,168.950 ns215,785.17 ns43.457010.0098-730200 B
Update_Dict1000080,082.82 ns601.093 ns80,098.44 ns49.926849.926849.9268202226 B
Update_Map1000001,472,887.55 ns113,160.514 ns1,490,269.96 ns587.8906289.0625-9845952 B
Update_TransientMap100000431,578.90 ns13,798.491 ns428,840.83 ns48.828117.5781-821688 B
Update_ImmDict1000004,484,754.95 ns374,492.303 ns4,309,124.17 ns546.8750367.1875-9183488 B
Update_Dict100000440,942.80 ns26,864.498 ns440,516.53 ns57.128957.128957.12892173162 B

Update/set

Takes a map of N elements and updates or adds N/10 elements

MethodNMeanStdDevGen0Gen1Gen2Allocated
UpdateSet_Map100337.19 ns1.990 ns0.23700.0005-3968 B
UpdateSet_TransientMap100170.85 ns0.971 ns0.0563--944 B
UpdateSet_ImmDict100991.89 ns4.868 ns0.2613--4376 B
UpdateSet_Dict100203.63 ns0.708 ns0.1357--2272 B
UpdateSet_Map10004,920.42 ns49.807 ns3.89100.1678-65088 B
UpdateSet_TransientMap10001,975.69 ns11.449 ns1.26650.0648-21224 B
UpdateSet_ImmDict100014,409.79 ns51.564 ns3.52480.0916-59160 B
UpdateSet_Dict10001,909.26 ns17.035 ns1.32560.1011-22192 B
UpdateSet_Map1000072,783.26 ns373.260 ns44.55577.3242-746240 B
UpdateSet_TransientMap1000022,762.00 ns547.344 ns6.07301.2207-102064 B
UpdateSet_ImmDict10000217,369.98 ns1,699.372 ns46.14269.2773-775784 B
UpdateSet_Dict10000168,757.26 ns1,385.486 ns153.8086153.8086153.8086622807 B
UpdateSet_Map1000001,395,661.38 ns22,598.355 ns589.8438271.4844-9885480 B
UpdateSet_TransientMap100000474,293.63 ns3,575.335 ns52.246123.9258-881576 B
UpdateSet_ImmDict1000003,993,858.29 ns27,293.773 ns554.6875273.4375-9318896 B
UpdateSet_Dict100000473,906.43 ns46,198.356 ns58.593858.593858.59382173173 B

Iterate

Iterates through the map using a foreach loop. Despite the enumerator of Map being pretty fast, it is still very much not the best way to iterate through a tree. The internal iterator show this. The one I provide even manages to do unnecessary work (for this benchmark), yet is still the fastest by far.

The internal iterator is however only faster on .net10 and up, since the earlier versions do not manage to inline the delegate.

MethodNMeanErrorStdDevGen0Allocated
Iterate_Map100185.46 ns0.993 ns0.880 ns--
IterateInternal_Map10080.74 ns0.229 ns0.179 ns0.005288 B
Iterate_ImmDict1001,256.49 ns8.808 ns8.239 ns--
Iterate_Dict10047.83 ns0.183 ns0.171 ns--
Iterate_Map10001,283.39 ns11.798 ns11.036 ns--
IterateInternal_Map1000385.72 ns2.094 ns1.857 ns0.005288 B
Iterate_ImmDict100011,690.95 ns79.016 ns73.912 ns--
Iterate_Dict1000470.46 ns1.784 ns1.669 ns--
Iterate_Map1000016,480.00 ns44.802 ns37.412 ns--
IterateInternal_Map100004,972.67 ns31.673 ns28.077 ns-88 B
Iterate_ImmDict10000123,852.13 ns252.427 ns210.788 ns--
Iterate_Dict100004,765.20 ns36.991 ns32.791 ns--
Iterate_Map100000260,057.20 ns1,594.690 ns1,491.674 ns--
IterateInternal_Map100000125,658.47 ns600.162 ns561.392 ns-88 B
Iterate_ImmDict1000001,249,306.76 ns11,834.294 ns11,069.806 ns--
Iterate_Dict10000047,955.95 ns98.972 ns92.578 ns--

Removal

Takes a map of N elements and removes N/10 of the elements.

MethodNMeanStdDevGen0Gen1Gen2Allocated
Remove_Map100340.22 ns2.360 ns0.23030.0005-3856 B
Remove_TransientMap100168.17 ns0.928 ns0.0496--832 B
Remove_ImmDict100725.09 ns5.408 ns0.2213--3704 B
Remove_Dict100204.79 ns3.310 ns0.1357--2272 B
Remove_Map10004,894.29 ns30.186 ns3.57060.0763-59808 B
Remove_TransientMap10002,155.53 ns27.601 ns1.58310.0648-26544 B
Remove_ImmDict100011,790.80 ns154.933 ns3.21960.1068-54064 B
Remove_Dict10001,975.39 ns58.259 ns1.32370.0992-22192 B
Remove_Map1000078,635.99 ns2,387.785 ns43.57915.8594-730944 B
Remove_TransientMap1000028,698.14 ns289.587 ns6.50021.1902-108752 B
Remove_ImmDict10000200,503.76 ns3,084.968 ns43.21298.7891-726560 B
Remove_Dict1000080,866.75 ns358.208 ns49.926849.926849.9268202226 B
Remove_Map1000001,472,537.19 ns12,664.868 ns582.0313267.5781-9759264 B
Remove_TransientMap100000560,139.84 ns8,153.431 ns63.476625.3906-1065352 B
Remove_ImmDict1000004,042,468.20 ns29,579.164 ns546.8750351.5625-9149160 B
Remove_Dict100000446,216.83 ns24,179.405 ns65.918065.918065.91802173253 B

Who should use this?

Not a living soul. I remember feeling pretty confident it wasn’t going to eat my face back in the day, but looking it it now it is doing a lot of unsafe memory access. For the love of god, just use LanguageExt.HashMap. It is fast enough and high quality.

This is a work in progress that will be the default hash map for my own little programming language I am making. Any changes I make will reflect the usage of that. I might publish a nuget package, but even that is not sure. But: please, help yourself and have a look at how I did things.

Why is it fast?

The vast majority of nodes in a tree is going to be leaf nodes. In this code, I was inspired by the nightmare material that is scala’s vectors. Not the memory layout or any of the code, but using different classes for every level of the tree. At first I tried using 32 different kinds of nodes that all packad Slot<K,V,Node>, but in the end that ended up wasting too much memory to be useful. At small and very large maps it was very fast, but at N=1000 … 10000 it was slowish. I then repurposed the nodes to be leaf nodes that packed Slot<K,V>, and letting the internal nodes be regular internal nodes with the bitmaps and the two customary array types of a champ (data, and node).

I also packed a ulong that contained the node capacity (used only in leaf nodes), node flags (that are set at creation, meaning I can safely Unsafe.As based on the node flags) and a 48 bit owner id (that is used by transient maps).

Why did I pack this in one ulong? Possibly because I didn’t really trust the different Packing directives in dotnet. I had issues with reading protected memory, despite me being very diligent with how things were laid out. I might have been trying to avoid load instructions.

Anyway, there are 32 different kinds of exact-fit leaf nodes. They contain a ulong meta and N inline slots. This removes one memory indirection compared to using regular arrays. There are also 32 different kinds of internalnodes (the data array is just an array) where the child pointers are inline.

The really ugly parts

The transient functionality and persistent functionality are in two different places, despite merging them being pretty simple.

it relies on a lot unsafe memory access, while also being relatively untested.

No documentation at all.

Naming is all over the place.

What can be made faster?

I do think I am past the point where there are simple things that yield a large speedup, but there are still many things that can be done with regards to making it faster. With regards to leaf nodes, I am pretty sure they are pretty optimal except for the indirection when allocating.

What I really would like to do is to find a way to do dynamic inline arrays, where I can allocate a Node and tell the GC that there are N*NodePointers at the end. Right now I haven’t figured that out.

I think some of the methods could be rewritten to be iterative rather than recursive. This was a slight win for TryGetValue, but that method is also the most sensitive.

License

MPLv2.

About

A persistent map for c#

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages