Skip to content

Commit 6ee6df9

Browse files
committed
s+ tree construction and searching
1 parent a3848f2 commit 6ee6df9

1 file changed

Lines changed: 38 additions & 14 deletions

File tree

‎content/english/hpc/data-structures/s-tree.md‎

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ To address these problems, we need to change the layout a little bit.
312312

313313
Most of the time people talk about B-trees they really mean *B+ trees*, which is a modification that distinguishes between the two types of nodes:
314314

315-
- *Internal nodes* store up to $B$ keys and $(B + 1)$ pointers to child nodes. The key number $i$ always equals the first key of of the $(i + 1)$-th child node.
315+
- *Internal nodes* store up to $B$ keys and $(B + 1)$ pointers to child nodes. The key number $i$ is always equal to the smallest key in the subtree of the $(i + 1)$-th child node.
316316
- *Data nodes* or *leaves* store up to $B$ keys, the pointer to the next leaf node, and, optionally, an associated value for each key, if the structure is used as a key-value map.
317317

318318
Advantages of this approach include faster search time as the internal nodes only store keys and the ability to quickly iterate over a range of entries by following next leaf node pointers, but this comes at the cost of some redundancy: we have to store copies of keys in the internal nodes.
@@ -328,9 +328,9 @@ The disadvantage is that this layout is not succinct: we need about some additio
328328

329329
### Implicit B+ Tree
330330

331-
B-tree layout
331+
To be more explicit with pointer arithmetic, we will store the entire tree in a single one-dimensional array. To minimize index computations during run-time, we will store each layer sequentially in this array and use compile-time computed offsets to address them: the keys of the node number `k` on layer `h` start with `btree[offset(h) + k * B]`, and its `i`-th child will at `btree[offset(h - 1) + (k * (B + 1) + i) * B]`.
332332

333-
We will explain the constexpr functions because this time it is important:
333+
To implement all that, we need slightly more `constexpr` functions:
334334

335335
```c++
336336
// number of B-element blocks in a layer with n keys
@@ -348,7 +348,7 @@ constexpr int height(int n) {
348348
return (n <= B ? 1 : height(prev_keys(n)) + 1);
349349
}
350350

351-
// where each layer starts
351+
// where the layer h starts (0 is the largest)
352352
constexpr int offset(int h) {
353353
int k = 0, n = N;
354354
while (h--) {
@@ -358,13 +358,18 @@ constexpr int offset(int h) {
358358
return k;
359359
}
360360

361-
const int H = height(N), S = offset(H);
361+
const int H = height(N);
362+
const int S = offset(H); // the tree size is the offset of the (non-existent) layer H
363+
364+
// the tree itself is stored in a single hugepage-aligned array of size S:
365+
int *btree;
362366
```
363367
364-
To be more explicit with pointer arithmetic, the tree is just a single huge-page aligned array `btree` of size `S`.
368+
Note that we store the layers in reverse order, but the nodes within a layer and data in them is still left-to-right, and also the layers are numbered bottom-up: the leaves form the zeroth layer and the root is the layer `H - 1`. These are just arbitrary decisions — it is just slightly easier to implement in code.
365369
370+
### Construction
366371
367-
We store in reverse order, but the nodes within a layer and data in them is still left-to-right. This is an arbitrary decision: you can do it the other way around, but it will be slightly harder to code.
372+
To construct the tree from a sorted array `a`, we first need to copy it into the zeroth layer and pad it with infinities:
368373
369374
```c++
370375
memcpy(btree, a, 4 * N);
@@ -373,27 +378,37 @@ for (int i = N; i < S; i++)
373378
btree[i] = INT_MAX;
374379
```
375380

381+
Now we build the internal nodes, layer by layer. For each key, we need to descend to the right of it in, always go left until we reach a leaf node, and then take its first key — it will be the smallest on the subtree:
382+
376383
```c++
377384
for (int h = 1; h < H; h++) {
378385
for (int i = 0; i < offset(h + 1) - offset(h); i++) {
386+
// i = k * B + j
379387
int k = i / B,
380388
j = i - k * B;
381-
k = k * (B + 1) + j + 1; // compare right
389+
k = k * (B + 1) + j + 1; // compare to the right of the key
382390
// and then always to the left
383391
for (int l = 0; l < h - 1; l++)
384392
k *= (B + 1);
393+
// pad the rest with infinities if the key doesn't exist
385394
btree[offset(h) + i] = (k * B < N ? btree[k * B] : INT_MAX);
386395
}
387396
}
388397
```
389398

399+
And just the finishing touch — we need to permute keys in internal nodes to search them faster:
400+
390401
```c++
391402
for (int i = offset(1); i < S; i += B)
392403
permute(btree + i);
393404
```
394405
406+
We start from `offset(1)`, and we specifically don't permute leaf nodes and leave the array in the original sorted order. The motivation is that we'd need to do this complex index translation we do in `update` if the keys were permuted, and it is on the critical path when this is the last operation, so just for this layer, we will switch to the original local lower bound procedure with mask-blending.
407+
395408
### Searching
396409
410+
The search procedure becomes simpler than for the B-tree layout: we don't need to do `update` and execute a constant number of iterations — although the last one with some special treatment. We slightly optimize the pointer arithmetic by storing `k` already multiplied by `B`:
411+
397412
```c++
398413
int lower_bound(int _x) {
399414
unsigned k = 0;
@@ -407,15 +422,23 @@ int lower_bound(int _x) {
407422
}
408423
```
409424

410-
425+
It is 1.5-3x faster:
411426

412427
![](../img/search-bplus.svg)
413428

414-
Makes more sense to look at it as a relative speedup:
429+
The spikes at the end is the L1 TLB: it has 64 entries for 2. The B+ layout hits it slightly faster because of the ~7% additional memory. Unfortunately, this CPU doesn't support 1G ones.
415430

416-
![](../img/search-relative.svg)
431+
64 * 2 = 128MB
417432

418-
### Measuring Actual Latency
433+
### Comparison with `std::lower_bound`
434+
435+
We've come a long way from binary search:
436+
437+
![](../img/search-all.svg)
438+
439+
On these scales, it makes more sense to look at the relative speedup:
440+
441+
![](../img/search-relative.svg)
419442

420443
One huge asterisk we didn't disclosed.
421444

@@ -437,6 +460,9 @@ for (int i = 0; i < m; i++) {
437460

438461
![](../img/search-relative-latency.svg)
439462

463+
A lot of the performance boost comes from removing branching and minimizing memory requests, which allows overlapping many queries — around 3 on average.
464+
465+
Although nobody except maybe the HFT people ever measure actual latency and uses it for benchmarking, this is still something to take into account.
440466

441467
### Modifications
442468

@@ -497,8 +523,6 @@ However, they perform better:
497523

498524
## Conclusions
499525

500-
![](../img/search-all.svg)
501-
502526
It may or may not be beneficial to reverse the order in which layers are stored. I only implemented right-to-left because that was easier to code.
503527

504528
### As a Dynamic Tree

0 commit comments

Comments
 (0)