You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 6ee6df9
Browse filesBrowse the repository at this point in the historyBrowse files
Copy file name to clipboardExpand all lines: content/english/hpc/data-structures/s-tree.md
+38-14Lines changed: 38 additions & 14 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -312,7 +312,7 @@ To address these problems, we need to change the layout a little bit.
312
312
313
313
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:
314
314
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.
316
316
-*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.
317
317
318
318
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
328
328
329
329
### Implicit B+ Tree
330
330
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]`.
332
332
333
-
We will explain the constexpr functions because this time it is important:
333
+
To implement all that, we need slightly more `constexpr` functions:
334
334
335
335
```c++
336
336
// number of B-element blocks in a layer with n keys
@@ -348,7 +348,7 @@ constexpr int height(int n) {
348
348
return (n <= B ? 1 : height(prev_keys(n)) + 1);
349
349
}
350
350
351
-
// where each layer starts
351
+
// where the layer h starts (0 is the largest)
352
352
constexpr int offset(int h) {
353
353
int k = 0, n = N;
354
354
while (h--) {
@@ -358,13 +358,18 @@ constexpr int offset(int h) {
358
358
return k;
359
359
}
360
360
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;
362
366
```
363
367
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.
365
369
370
+
### Construction
366
371
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:
368
373
369
374
```c++
370
375
memcpy(btree, a, 4 * N);
@@ -373,27 +378,37 @@ for (int i = N; i < S; i++)
373
378
btree[i] = INT_MAX;
374
379
```
375
380
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
+
376
383
```c++
377
384
for (int h = 1; h < H; h++) {
378
385
for (int i = 0; i < offset(h + 1) - offset(h); i++) {
386
+
// i = k * B + j
379
387
int k = i / B,
380
388
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
382
390
// and then always to the left
383
391
for (int l = 0; l < h - 1; l++)
384
392
k *= (B + 1);
393
+
// pad the rest with infinities if the key doesn't exist
385
394
btree[offset(h) + i] = (k * B < N ? btree[k * B] : INT_MAX);
386
395
}
387
396
}
388
397
```
389
398
399
+
And just the finishing touch — we need to permute keys in internal nodes to search them faster:
400
+
390
401
```c++
391
402
for (int i = offset(1); i < S; i += B)
392
403
permute(btree + i);
393
404
```
394
405
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
+
395
408
### Searching
396
409
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
+
397
412
```c++
398
413
int lower_bound(int _x) {
399
414
unsigned k = 0;
@@ -407,15 +422,23 @@ int lower_bound(int _x) {
407
422
}
408
423
```
409
424
410
-
425
+
It is 1.5-3x faster:
411
426
412
427

413
428
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.
415
430
416
-

431
+
64 * 2 = 128MB
417
432
418
-
### Measuring Actual Latency
433
+
### Comparison with `std::lower_bound`
434
+
435
+
We've come a long way from binary search:
436
+
437
+

438
+
439
+
On these scales, it makes more sense to look at the relative speedup:
440
+
441
+

419
442
420
443
One huge asterisk we didn't disclosed.
421
444
@@ -437,6 +460,9 @@ for (int i = 0; i < m; i++) {
437
460
438
461

439
462
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.
440
466
441
467
### Modifications
442
468
@@ -497,8 +523,6 @@ However, they perform better:
497
523
498
524
## Conclusions
499
525
500
-

501
-
502
526
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.
0 commit comments