Atree: A simple and efficient pointer-free tree implementation
github.com
github.com
I am interested because I have some scientific software (astrodynamics - propagating orbits in space) that would really benefit from a cache-friendly tree, and this seemed promising.
Also, I write a lot of code that runs on a gpu, and the claim that this tree is somehow gpu friendly I find particularly dubious.
Big O isn't irrelevant, but it is not the full story either. There's a solid reason why hash tables are a thing in memory but aren't really a thing on disk.
The parent commenter writes a wonderful blog that covers their experience with building and optimizing a search engine, well worth a read.
When N is small, the asymptotic behavior is irrelevant and that's easy to show. Let's say we're comparing a O(N) algorithm to a O(N^2) algorithm, but each operation of the O(N) is 1000x more expensive. The O(N^2) algorithm is preferred as long as N < 1000. Choosing the O(N) algorithm will hurt performance in those situations. Real world examples like single-byte-writes causing full pages to be re-written on SSDs shows this isn't just a mathematical curiosity.
Without benchmarks, analysis of big-O behaviors, usage patterns, and known data size I'd (personally) avoid guessing the performance of Atree in an application.
Are you saying something different? It sounds like you have much more SIMD experience than I do and I'm always happy to learn something new.
How? Non-asymptotic N stays non-asymptotic no matter how you label it.
Big O tells you that there exists some number N such that for each number m larger than N, if O(f(m)) > O(g(m)) then f(m) > g(m). In practice, N may be 10, or it may be larger than the number of atoms in the universe. It's unrelated to the number of items in your collection, but a property of the algorithm itself.
The trick is that querying the children of N different nodes is usually still a single O(N) scan, so if you operate on it array-style (which APL heavily encourages anyway), it's amortized constant time. Of course that's not always viable, but APL programmers tend to be surprisingly good at making array operations out of things you wouldn't expect to use arrays for.
> cache hostile
If you additionally enforce that all parent indexes point to lower numbers, a preorder traversal is a linear scan forward, and a postorder traversal with child order reversed (which you can usually correct for one way or another) is a linear scan backward.
(This assumes you only need dependency ordering, ie the parent node uses or supplies data to/from its children; if you need a true sequential traversal, the array has to be sorted according to that traversal (but is still a valid Apter Tree).)
> the claim that this tree is somehow gpu friendly I find particularly dubious
Yeah, array programming is generally kind of hit-or-miss at that and this does look like a miss.
I mean, there are specific sizes that will fit in the L-(N) cache.
And consequently, sizes that don't suffer from the false-sharing problem in concurrent scenarios due to this.
https://en.cppreference.com/w/cpp/thread/hardware_destructiv...
There's an entire field of study devoted to cache-friendly/cache-oblivious data structures:
- https://www.microsoft.com/en-us/research/wp-content/uploads/...
But personally I’ve been working at higher levels of the stack the last few years, where these kinds of decisions seem less important.
And on another level, it seems like coders in general aren’t that interested in vector oriented languages and techniques which makes their study somewhat isolating.
I used a very similar setup, first time I needed to implement a tree. Now, I'm a fan of Eytzinger layout. (referenced in a previous comment in this thread)
Yeah, most coders in general don't seem to be as interested in this stuff, but it's still necessary. They'll want more performance.
I don't think programmers actually care about performance as much as they care for convenience. Every year the stack moves a bit higher, and everyone is okay with websites taking days to load on brand new phones with Gigabit wireless connections. There are companies that care about performance on the margin, like stock trading firms, but to get into one of them, you have to get pretty lucky, or come from a pretty special background. Even the banks are using Python more and more, these days.
People will always care about performance because they will always want more functionality.
I bet you care about the performance of your database or how fast your pages load. You want your streaming videos to be skip-free and your phone calls to sound lifelike. Performance will always matter. Because while people like me will be always trying to squeeze more, "the other side" will always be ready to use it for some new feature.
I'm not under the impression that people care at all, to be honest, outside of certain communities. If you're not in those communities talking about performance as a corner stone feels mostly like screaming into the void.
but then accumulated outcome of this is the slowness you see in web software!
I'm _also_ interested in scientific software, but that's more a hobby than a job. =)
For propagating a large number of orbits in space, I'm really curious what the Correct (tm) numerical algorithm is, mind sharing more? I love that space right at the intersection of lots of math + need to understand how computers really work. Once upon a time I implemented fast multipole method for the n-body problem, but I certainly don't claim to deeply understand it, anymore. :)
It does help (roughly ~5x vs. pointer-chasing trees, probably can be further optimized) for my workload, but at the same time quite some time was spent just making sure the tree is correct.
for i = 0, num_nodes do
if parents[i] == -1 then
world_xforms[i] = xforms[i]
else
world_xforms[i] = world_xforms[parents[i]] * xforms[i]Storing nodes in arrays and using indices for pointers is a must whenever you're implementing algorithms on trees. I typically prefer using an array of structs, putting the key and the parent index next to each other, instead of putting them in separate arrays. If you need to access the children of a node, then be sure to consider if you can save memory by having a separate structure for leaves - remember that over half of the nodes will be leaves, so using space to store a child pointer on both internal nodes and leaves can be wasteful use of memory.
To find the indices of all leaves: That's just L[].
To add a leaf i: Append i to L[], and set LP[i] to L.length - 1.
To remove a leaf i in constant time:
j = L[L.length - 1]
L[LP[i]] = j
LP[j] = i
LP[i] = -1.
DropLastElement(L[])There are technically correct nitpicks with some merit and there are trivial remarks like yours. It is perfectly clear what "pointer-free" was referring to in the post title.
The only real advantages of indices over pointers are serialisability and ease of debugging.
From the programmer's PoV, having no pointers gives little to no benefit. From the CPU's PoV, (in c/c++) indices become pointers anyway. Maybe the compiler can optimise more easily, but that's not obvious.
Cache coherence is the key goal, but you can do that with pointers easily enough.
Of course reading the values at each offset will require constructing a pointer address, but if the whole chunk can fit in the L2 data cache then reading those values at the calculated address will be very fast since the whole data structure is in cache.
The aim isn't a compiler optimization, but a runtime on CPU core optimization.
With a cache-friendly structure like array, the one dereference you may need for the array access has a high chance to be served from L2 or L1, saving you a lot of clocks, because RAM has huge latency.
A more useful, storage/serializable friendly, pointer perhaps.
You can also use the pool to bound the maximum size you’re able to allow.
At least, if someone asked me to explain what an arena allocator is/does, I would say essentially what they wrote -- likely without the "disappears automatically once all references disappear" part, which is IMHO just a clever nice-to-have.
It also helps to bound your memory usage. A request could block until memory was free.
https://www.youtube.com/watch?v=hzPd3umu78g https://www.youtube.com/watch?v=X5_5MtOYNos
Inserting a child would need a memmove in O(N), but if edits are rare after an initial build it wouldn't be that bad.
IMHO, compilers are about an order of magnitude slower than they could be, and this type of tree representation could be one key element for fixing that.
One interesting approach would be to combine egg[1] with this style of vectorised trees for efficient optimisation passes.
The fact that traditional tree implementation requires malloc is solely based on the wish to dynamically provide and remove of nodes. If the tree can have a limited size with no frequent delete operation, an array implementation is fine. Otherwise, expand an array can be a very costly or even an impossible operation in some circumstances.
And full scanning is not efficient.
This way of representing trees reminds me of two classic data structures: heaps [2] and disjoint-sets [3].
--
1: https://floooh.github.io/2018/06/17/handles-vs-pointers.html
2: https://en.wikipedia.org/wiki/Heap_(data_structure)
3: https://en.wikipedia.org/wiki/Disjoint-set_data_structure
But the parent indices are pointers. Not in the index-into-all-memory sense but in the offset-into-array sense. They’re smaller, type safe, inherently well packed, and can’t point outside the instance of the data structure in question if they’re bounds checked. But I would still think of them as a sort of pointer.
So this is just a tree, with only parent pointer, in SOA form, and iterable (in no particular order). Which is maybe useful if you want that specific data structure.
And it’s utterly useless if you want, say, a tree with children and need better-than-O(n) access to children. Which you probably do need. Of course, you can add child pointers in SOA form if you want.
(SOA is Struct Of Arrays)
The “no pointers” thing is cache efficient for three reasons:
1. Indices can be smaller than pointers.
2. The data is packed in memory. This means that cachelines will be full of relevant data, and adjacent cache lines (which are a bit faster than faraway lines) get used.
3. SOA form is more or less cache efficient depending on what you’re doing with it.
And that’s it.
If you need child pointers, use child pointers. If you want a cache-efficient tree, use a cache-efficient tree, e.g. a B-tree or an appropriate variant.
But those operations are horrible operations. Until you are log_2(cache line size / element size) steps from the leaves, you are hitting a different cache line each time, and you’re doing a branch such that the next cache line you want depends on the branch result.
So this is like log_2(n)-3 serial (cache miss, compare, branch, compute address operations). (Assume 8-byte entries — it’s much worse with bigger entries.) This not even close to optimal.
You will find that, for reasonably large data sets (maybe a few hundred elements or more?), any cache-optimized searchable data structure will beat binary search by a large factor. And for small arrays (or the last stage of an optimized large structure), you want a vectorized linear search.
Binary search is mostly nice because sorted arrays are elegant and binary search can be implemented in very little code. And it’s easy to explain and analyze.
But naive binary search can also be improved upon by dividing the searched space into 3 subranges instead of 2[2].
[1]: <https://www.pvk.ca/Blog/2012/07/03/binary-search-star-elimin...>
[2]: <https://www.pvk.ca/Blog/2012/07/30/binary-search-is-a-pathol...>
I made my own attempt at the same kind of idea at https://elmalabarista.com/blog/2022-flat-tree/.
Is pretty simple actually. What I have observed (and my tree exploit) is that most tree are "too much" and I only have cared by pre-order tree and "sequentially" scan is the major op.
So, the major takeaway is that if your case is simple, Go! Go! Vec!
But my point is that storing them in another array doesn't buy you a lot if you allow deallocations (you can then still have a use-after-free, or even read the wrong object) while if you don't deallocate you might as well just use pointers.
Just trying to understand the benefit of going to all this work. I can see the point in old FORTRAN code before FORTRAN 90
But I’ve worked with indexed classes (mixin class) to avoid pointers in the past and have never found them worth the effort. Since someone else is mentioning it here on HN I’m asking if there is an advantage over pointers that I don’t realise — if so I might be glad to take advantage of it myself.
https://www.dropbox.com/s/mmi46lxk8ipoci0/Principles%20of%20...
Arthur Whitney is the origin of much of that family of languages. Bryan Cantrill’s interview with him is good: https://dl.acm.org/doi/pdf/10.1145/1515964.1531242
K is the lower-level language and is a variant of APL. Q is the higher-level one, bringing in elements of SQL.
J is another APL-like programming language, unrelated to KDB, by the actual creator of APL.
All are easy to google by adding "programming language" to your query, and each has a wikipedia page.
https://opendatastructures.org/ods-cpp/10_1_Implicit_Binary_...
I dare say that no tree data structure beats Eytzinger's method in cache locality.
Cache locality is good toward the leaves and bad everywhere else. This is why binary search on a sorted array is actually quite slow.
ORC in Linux, for example, was first prototyped as a sorted array of little structures. It got much faster by adding an auxiliary index giving O(1) access to a credible starting point for a search.
1. That depends on the operation performed. I'd say cache-locality is near perfect for depth-traversal.
2. Whether the effective performance of the cache is good or bad depends on the alternative. If the alternative is adding two 64 bit pointer to every 32 bit value in the tree node. And each of those node may be spread through out the heap. Then this representation starts to look quite good.