A while back I was working on an Implementation for HAMTs that uses StencilVectors, eventually I wanted to take a break from this and only continue working on it when I am actually using this data structure in other code and discover issues with it.
However I kept reading things in the forum that made me think, that using parts of this might be useful to people, so I decided to put the code into a package so that people can have a look at it / use it, or point out ways to improve it.
HAMTs are bitwise tries that have a compact memory layout, they are useful data structures that can be used as a key-value mapping, they also can be used as a persistent data structure, which means that old versions keep working and different versions can share nodes, this can be useful to implement things like undo, or wherever it is helpful to keep old versions of a value, without having to create full copies manually.
This implementation contains dynamic variants for runtime usage with both pointer and index support, imperative and persistent (old versions stay valid) behavior based on user configuration.
It also has simplified static implementations that can be modified at comptime without needing an allocator and then frozen and queried at runtime.
If you have ideas for improvements, feel free to open an issue.
The docs are here: hamt
I created this topic as a brainstorming topic, because I am not completely satisfied with the library design, I think it contains some interesting ideas and I want to use this topic to discuss those. Maybe we can come up with parts that are good and other parts that could be re-designed differently.
Pointers or Indicies or both?
The HAMTUnmanaged and StencilArray are written in such a way that they can be used with the normal std.mem.Allocator
interface and a custom allocator.
I like the idea of having data structures that support using either pointers or indices, but the way I am currently doing it may look more complex, than if the data structure only supported one or the other. So one option might be to instead always have a Pointer and an Index implementation.
The nice thing from the users side is that they can use an API that is agnostic about, whether the data structure uses Pointers or Indices, in some cases it might be nice to be able to switch around between the two without having to change the code that uses the data structure (Only having to change the config that creates the type).
Custom Allocator (Index/Pool Allocator)
I also think the custom allocator itself is interesting, it acts sort of like a memory pool and it returns an index from its alloc
function instead of a slice, it then has an additional lookup
function that converts the index to a slice.
I came up with this way of organizing it, because I found it to be annoying having to deal with pointer addresses and calculating indices from them.
(I thought that for allocators that can return indices, the index represents the “ground-truth” thing that identifies the thing that was allocated and having to deal with pointers as the more primary thing is annoying in that use-case)
When using indices, pointers are only needed when you are actually accessing the memory, but often you just need the index to store it somewhere else so it can be used later. Additionally going from index to pointer can be easier / more directly supported by slicing syntax, then going from some pointer to calculating an index for it based on some base-address.
variable sized slices of specific sizes
say that 3 times fast
The pool allocator actually uses the index as a handle like this:
const SlabIndex = packed struct {
slab: u16,
choice: u6,
index: u10,
};
This allows it to support 2^6 = 64
specific sizes, it then creates a Slab (chunk) of memory of 1024 slices of that specific size and address them.
I actually noticed right now that the code could be slightly restructured to make the slab
field specific to the choice
to give each choice a separate u16 possible slabs, which means each size could have a maximum of 2^26 = 67.108.864
instances.
This whole idea of having multiple sizes packed into one handle seems useful for data structures like the HAMT that have nodes with different sizes. SlabIndex
is just what I ended up using, there may be a lot of different ways to use the bits to differentiate between different sizes and chunks of allocation.
It is also possible that you could use different index sizes, but u32 seemed most practical for now.
StencilArray
Last but certainly not least: StencilArrays (originally named StencilVectors - Runtime and Compiler Support for HAMTs), are in principle the simple idea of combining a bitmask, with an array that has a size that corresponds to the number of bits that are set to 1 in the mask.
This idea appears in multiple places, but I think it is good to have a name for it, I like StencilArray more then Vector, but I am not quite sure what the best name is.
This for example allows you to create tree- or trie-nodes that have many branches and then only use a single 0 bit for branches that are null. This is used in the HAMT to create a trie that is able to store sparse data without wasting a lot of memory on null pointers.
The HAMT is also based on this paper Ideal Hash Trees (Bagwell, Phil 2001) but only implementing parts of it.
StencilVectors are how Racket implements its functional data-structure / immutable / persistent / hashed key-value mappings and that is where I learned about them and started to like using persistent data structures for some programming problems.
My StencilArray implementation is used like a handle (a struct that only contains a pointer/index) this handle then has methods to de-reference to get to the actual value and read / manipulate it, or create a functional update of it (create an altered copy, leaving the original unchanged).
If specific discussion topics are bigger than others we always can branch off and create dedicated topics.
I especially would be interested to hear if anybody has an interesting use case for these data structures. I think it might be possible to use the HAMT like a database-index, where you can have multi-version concurrency for readers and a single writer, that essentially only updates the index of the root node.