disjoint_interval_tree 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CITATION.cff +67 -0
- data/README.md +340 -0
- data/Rakefile +232 -0
- data/disjoint_interval_tree.gemspec +42 -0
- data/ext/disjoint_interval_tree/dit.c +850 -0
- data/ext/disjoint_interval_tree/dit.h +244 -0
- data/ext/disjoint_interval_tree/dit_ruby.c +648 -0
- data/ext/disjoint_interval_tree/extconf.rb +17 -0
- data/lib/disjoint_interval_tree/version.rb +5 -0
- data/lib/disjoint_interval_tree.rb +52 -0
- data/test/c/Makefile +58 -0
- data/test/c/test_dit.c +1072 -0
- data/test/test_disjoint_interval_tree.rb +649 -0
- metadata +65 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 2489f3da4496b1c3abb350eb582d52a40298e61fd9132f52f3ca80e69aeda976
|
|
4
|
+
data.tar.gz: a3a35b0752910b4ae0e5f2c5aa021bf2175b4ae3545041670ee1fb2f7fe20c96
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 8937708e32708573ed399b91722a9ccc1ff8c9d338f6e6655c089a996e4228b0685d653d6b4dd2729e121f384a0a622a0f9cba585475128304633bf80c1830b0
|
|
7
|
+
data.tar.gz: af0540688e96cc9aa9eebfa5951d4b04aafc04e137b7d7113e959fef4d2621eb81d1a63f7fcbfbbd81d8716cf7fba2d3507e4fc20669265f94fbb2821a7dc163
|
data/CITATION.cff
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
cff-version: 1.2.0
|
|
2
|
+
message: >-
|
|
3
|
+
If you use this software in academic work, please cite the papers that
|
|
4
|
+
introduced the structures it derives from, listed under `references` below.
|
|
5
|
+
title: disjoint_interval_tree
|
|
6
|
+
abstract: >-
|
|
7
|
+
A set of pairwise disjoint half-open intervals, backed by an augmented AVL
|
|
8
|
+
tree written in C, with Ruby bindings. Each node caches the hull of the
|
|
9
|
+
subtree it roots, so intersection queries prune whole subtrees in constant
|
|
10
|
+
time.
|
|
11
|
+
type: software
|
|
12
|
+
license: CECILL-C
|
|
13
|
+
repository-code: "https://github.com/anlsys/ruby-disjoint-interval-tree"
|
|
14
|
+
authors:
|
|
15
|
+
- family-names: Pereira
|
|
16
|
+
given-names: Romain
|
|
17
|
+
email: rpereira@anl.gov
|
|
18
|
+
affiliation: Argonne National Laboratory
|
|
19
|
+
orcid: "https://orcid.org/0000-0003-1856-2172"
|
|
20
|
+
keywords:
|
|
21
|
+
- interval tree
|
|
22
|
+
- AVL tree
|
|
23
|
+
- augmented tree
|
|
24
|
+
- ruby
|
|
25
|
+
- c
|
|
26
|
+
|
|
27
|
+
references:
|
|
28
|
+
- type: conference-paper
|
|
29
|
+
title: >-
|
|
30
|
+
Taskgrind: Heavyweight Dynamic Binary Instrumentation for Parallel
|
|
31
|
+
Programs Analysis
|
|
32
|
+
authors:
|
|
33
|
+
- family-names: Pereira
|
|
34
|
+
given-names: Romain
|
|
35
|
+
- family-names: Stelle
|
|
36
|
+
given-names: George
|
|
37
|
+
- family-names: Carribault
|
|
38
|
+
given-names: Patrick
|
|
39
|
+
collection-title: >-
|
|
40
|
+
SC24-W: Workshops of the International Conference for High Performance
|
|
41
|
+
Computing, Networking, Storage and Analysis
|
|
42
|
+
year: 2024
|
|
43
|
+
month: 11
|
|
44
|
+
start: 214
|
|
45
|
+
end: 221
|
|
46
|
+
location:
|
|
47
|
+
name: Atlanta, GA, USA
|
|
48
|
+
doi: 10.1109/SCW63240.2024.00033
|
|
49
|
+
notes: Introduced the SPMT, the disjoint interval tree this software rewrites.
|
|
50
|
+
|
|
51
|
+
- type: conference-paper
|
|
52
|
+
title: Multi-GPU Memory Coherence for BLAS Matrices
|
|
53
|
+
authors:
|
|
54
|
+
- family-names: Pereira
|
|
55
|
+
given-names: Romain
|
|
56
|
+
- family-names: Polet
|
|
57
|
+
given-names: Pierre-Etienne
|
|
58
|
+
- family-names: Gautier
|
|
59
|
+
given-names: Thierry
|
|
60
|
+
- family-names: Perarnau
|
|
61
|
+
given-names: Swann
|
|
62
|
+
collection-title: >-
|
|
63
|
+
IPDPS-W HIPS: 31st International Workshop on High-level Parallel
|
|
64
|
+
Programming Models and Supportive Environments
|
|
65
|
+
year: 2026
|
|
66
|
+
month: 5
|
|
67
|
+
notes: Introduced the LP-Tree, whose subtree hull augment this software reuses.
|
data/README.md
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# disjoint\_interval\_tree
|
|
2
|
+
|
|
3
|
+
A set of **pairwise disjoint half-open intervals `[a..b[`**, implemented in C
|
|
4
|
+
and exposed to Ruby.
|
|
5
|
+
|
|
6
|
+
It is a self-balancing (AVL) binary search tree, augmented - as the LP-Tree it
|
|
7
|
+
is inspired from, see [References](#references) - with the *hull* of the
|
|
8
|
+
subtree each node roots, so that intersection queries prune whole subtrees in
|
|
9
|
+
`O(1)`.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
gem install disjoint_interval_tree
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
or, in a `Gemfile`:
|
|
18
|
+
|
|
19
|
+
```ruby
|
|
20
|
+
gem 'disjoint_interval_tree'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Ruby >= 2.7 and a C compiler are required: the extension is built at install
|
|
24
|
+
time.
|
|
25
|
+
|
|
26
|
+
## Synopsis
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
require 'disjoint_interval_tree'
|
|
30
|
+
|
|
31
|
+
tree = DisjointIntervalTree.new
|
|
32
|
+
tree.insert(10, 20)
|
|
33
|
+
tree.insert(30, 40)
|
|
34
|
+
|
|
35
|
+
tree.intersect(15, 35) do |a, b|
|
|
36
|
+
puts "[#{a}..#{b}["
|
|
37
|
+
end
|
|
38
|
+
# => [10..20[
|
|
39
|
+
# => [30..40[
|
|
40
|
+
|
|
41
|
+
tree.remove(15, 35) # => 2
|
|
42
|
+
tree.to_a # => []
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Semantics
|
|
46
|
+
|
|
47
|
+
* Intervals are **half-open**: `[a..b[` contains `a` but not `b`. `[0..10[` and
|
|
48
|
+
`[10..20[` are adjacent, they do **not** intersect.
|
|
49
|
+
* Bounds are **unsigned 64 bits integers**, so intervals live in
|
|
50
|
+
`[0 .. DisjointIntervalTree::MAX[`. Negative or non-integer bounds raise.
|
|
51
|
+
* Stored intervals are **pairwise disjoint**. It is a usage contract that the
|
|
52
|
+
caller never inserts an interval overlapping an already inserted one.
|
|
53
|
+
Breaking it raises `DisjointIntervalTree::OverlapError` and leaves the tree
|
|
54
|
+
unchanged - intervals are never merged nor split implicitly.
|
|
55
|
+
|
|
56
|
+
## Operations
|
|
57
|
+
|
|
58
|
+
With `n` intervals stored, `k` intervals reported by the call, and `m`
|
|
59
|
+
intervals handed to the constructor:
|
|
60
|
+
|
|
61
|
+
| operation | description | complexity |
|
|
62
|
+
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------- |
|
|
63
|
+
| `DisjointIntervalTree.new(intervals = nil)` | Build an empty tree, or one filled with the given `[a, b]` pairs. | `O(m.log m)` |
|
|
64
|
+
| `insert(a, b)` | Add `[a..b[`, raising `OverlapError` if it intersects an already stored interval. | `O(log n)` |
|
|
65
|
+
| `insert?(a, b)` | Same as `insert`, but returns `false` instead of raising when it would overlap. | `O(log n)` |
|
|
66
|
+
| `intersect(a, b, &blk)` | Yield every stored interval intersecting `[a..b[` in increasing order, or return them as an array when given no block. | `O(k + log n)` |
|
|
67
|
+
| `intersect?(a, b)` | Whether at least one stored interval intersects `[a..b[`. | `O(log n)` |
|
|
68
|
+
| `remove(a, b, &blk)` | Remove every stored interval intersecting `[a..b[` - whole, never split - and return how many went. | `O(k.log n)` |
|
|
69
|
+
| `at(x)` | The stored interval containing the point `x`, or `nil`. | `O(log n)` |
|
|
70
|
+
| `cover?(x)` | Whether the point `x` falls inside a stored interval. | `O(log n)` |
|
|
71
|
+
| `hull` | The smallest interval enclosing every stored one, read straight off the root augment. | `O(1)` |
|
|
72
|
+
| `size`, `length` | How many intervals are stored. | `O(1)` |
|
|
73
|
+
| `empty?` | Whether the tree holds no interval at all. | `O(1)` |
|
|
74
|
+
| `height` | Height of the underlying AVL tree, for tests and diagnostics. | `O(1)` |
|
|
75
|
+
| `coverage` | Total length covered, that is the sum of the lengths of the stored intervals. | `O(n)` |
|
|
76
|
+
| `each(&blk)` | Yield every stored interval in increasing order, or return an `Enumerator` when given no block. | `O(n)` |
|
|
77
|
+
| `to_a`, `entries` | Every stored interval as an array of `[a, b]` pairs, in increasing order. | `O(n)` |
|
|
78
|
+
| `map`, `select`, ... | Anything `Enumerable` provides, built on `each`. | `O(n)` |
|
|
79
|
+
| `clear` | Drop every interval, leaving the tree usable. | `O(n)` |
|
|
80
|
+
| `dup` | A deep copy, sharing no node with the original. | `O(n.log n)` |
|
|
81
|
+
| `==` | Whether two trees hold exactly the same intervals. | `O(n)` |
|
|
82
|
+
| `check!` | Re-derive every structural invariant, raising `CorruptedError` if one is broken. | `O(n)` |
|
|
83
|
+
| `inspect`, `to_s` | A short `#<DisjointIntervalTree size=... height=...>` summary. | `O(1)` |
|
|
84
|
+
|
|
85
|
+
## Ruby API
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
tree = DisjointIntervalTree.new # empty
|
|
89
|
+
tree = DisjointIntervalTree.new([[0, 10], [20, 30]]) # pre-filled
|
|
90
|
+
|
|
91
|
+
# --- the three core operations -------------------------------------------
|
|
92
|
+
|
|
93
|
+
tree.insert(a, b) # -> self, raises OverlapError if it overlaps
|
|
94
|
+
tree.insert?(a, b) # -> true / false instead of raising
|
|
95
|
+
|
|
96
|
+
tree.intersect(a, b) { |x, y| ... } # -> self, yields in increasing order
|
|
97
|
+
tree.intersect(a, b) # -> [[x, y], ...] when no block given
|
|
98
|
+
|
|
99
|
+
tree.remove(a, b) # -> number of intervals removed
|
|
100
|
+
tree.remove(a, b) { |x, y| ... } # ... and yields each removed interval
|
|
101
|
+
|
|
102
|
+
# --- queries --------------------------------------------------------------
|
|
103
|
+
|
|
104
|
+
tree.intersect?(a, b) # -> true if any stored interval intersect [a..b[
|
|
105
|
+
tree.at(x) # -> [a, b] containing the point x, or nil
|
|
106
|
+
tree.cover?(x) # -> true if x is covered by a stored interval
|
|
107
|
+
tree.hull # -> [a, b] spanning every interval, or nil
|
|
108
|
+
tree.size # -> number of stored intervals
|
|
109
|
+
tree.empty?
|
|
110
|
+
tree.height # -> height of the underlying AVL tree
|
|
111
|
+
|
|
112
|
+
# --- iteration (DisjointIntervalTree includes Enumerable) -----------------
|
|
113
|
+
|
|
114
|
+
tree.each { |a, b| ... }
|
|
115
|
+
tree.to_a # -> [[a, b], ...], in increasing order
|
|
116
|
+
tree.map { |a, b| b - a }
|
|
117
|
+
tree.coverage # -> total length covered
|
|
118
|
+
|
|
119
|
+
# --- misc -----------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
tree.clear # -> self
|
|
122
|
+
tree.dup # -> deep copy
|
|
123
|
+
tree == other
|
|
124
|
+
tree.check! # -> self, raises CorruptedError if an invariant
|
|
125
|
+
# of the underlying tree is broken
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Aliases: `each_intersecting` (`intersect`), `overlaps?` (`intersect?`),
|
|
129
|
+
`length` (`size`), `entries` (`to_a`).
|
|
130
|
+
|
|
131
|
+
The tree **must not be modified from within `each` or `intersect`**: doing so
|
|
132
|
+
raises `DisjointIntervalTree::Error` rather than corrupting the structure. The
|
|
133
|
+
block given to `remove` is called after the removal, on a snapshot, so it may
|
|
134
|
+
modify the tree.
|
|
135
|
+
|
|
136
|
+
## C API
|
|
137
|
+
|
|
138
|
+
The Ruby extension is a thin binding over a standalone, dependency-free C
|
|
139
|
+
library made of `ext/disjoint_interval_tree/dit.{c,h}`. It can be vendored as
|
|
140
|
+
is into a C or C++ project.
|
|
141
|
+
|
|
142
|
+
```c
|
|
143
|
+
#include "dit.h"
|
|
144
|
+
|
|
145
|
+
static int print_cb(dit_value_t a, dit_value_t b, void * user)
|
|
146
|
+
{
|
|
147
|
+
(void) user;
|
|
148
|
+
printf("[%lu..%lu[\n", a, b);
|
|
149
|
+
return 0; /* non-zero stops the traversal */
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
dit_t tree;
|
|
153
|
+
dit_init(&tree);
|
|
154
|
+
|
|
155
|
+
if (dit_insert(&tree, 0, 10) != DIT_OK)
|
|
156
|
+
{ /* DIT_EMPTY, DIT_OVERLAP or DIT_NOMEM */ }
|
|
157
|
+
|
|
158
|
+
dit_intersect(&tree, 5, 25, print_cb, NULL);
|
|
159
|
+
dit_remove(&tree, 5, 25);
|
|
160
|
+
|
|
161
|
+
dit_destroy(&tree);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### Node augments
|
|
165
|
+
|
|
166
|
+
Each node stores its own interval, its children, and - in a single `augment`
|
|
167
|
+
field - everything it caches about the subtree it roots:
|
|
168
|
+
|
|
169
|
+
```c
|
|
170
|
+
typedef struct dit_augment_s
|
|
171
|
+
{
|
|
172
|
+
/* the englobing interval of the subtree, i.e. the smallest interval
|
|
173
|
+
* including every interval stored in that subtree */
|
|
174
|
+
struct { dit_value_t a, b; } hull;
|
|
175
|
+
|
|
176
|
+
/* height of the subtree, a leaf has 1 */
|
|
177
|
+
int32_t height;
|
|
178
|
+
|
|
179
|
+
/* number of nodes in the subtree */
|
|
180
|
+
uint32_t size;
|
|
181
|
+
} dit_augment_t;
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
Augments are derived from the subtree alone and recomputed bottom-up after
|
|
185
|
+
every structural change, so a rotation only has to refresh the two nodes it
|
|
186
|
+
moves. `hull` is what lets `dit_intersect()` discard a whole subtree with a
|
|
187
|
+
single comparison; the root one is readable in O(1) through `dit_hull()`.
|
|
188
|
+
|
|
189
|
+
Customization points, to define before including `dit.h`:
|
|
190
|
+
|
|
191
|
+
| macro | default | purpose |
|
|
192
|
+
| --------------- | ---------------------- | ----------------------------------------------- |
|
|
193
|
+
| `DIT_VALUE_T` | `uint64_t` | interval bound type |
|
|
194
|
+
| `DIT_ASSERT` | `assert` | internal consistency assertions |
|
|
195
|
+
| `DIT_MALLOC` | `malloc` / `free` | node allocation |
|
|
196
|
+
| `DIT_PARANOID` | `0` | run `dit_check()` after every mutation |
|
|
197
|
+
|
|
198
|
+
### Invariants
|
|
199
|
+
|
|
200
|
+
`dit_check()` validates, without ever aborting, that:
|
|
201
|
+
|
|
202
|
+
1. every stored interval is non-empty (`a < b`),
|
|
203
|
+
2. the binary search tree ordering holds, which - the bounds narrowing at each
|
|
204
|
+
level - also proves the intervals are pairwise disjoint,
|
|
205
|
+
3. an in-order traversal yields increasing, non-overlapping intervals,
|
|
206
|
+
4. the `height` augment of every node is correct,
|
|
207
|
+
5. every node is AVL-balanced,
|
|
208
|
+
6. the `size` augment of every node is correct,
|
|
209
|
+
7. the `hull` augment of every node englobes its subtree exactly,
|
|
210
|
+
8. that hull spans from the leftmost to the rightmost descendant,
|
|
211
|
+
9. the cached cardinality matches the number of nodes,
|
|
212
|
+
10. the tree depth is logarithmic in the number of nodes,
|
|
213
|
+
11. no traversal is leaking.
|
|
214
|
+
|
|
215
|
+
On top of that, the implementation is littered with `DIT_ASSERT`s on the
|
|
216
|
+
invariants it relies on locally (rotations, augment refresh, deletion cases,
|
|
217
|
+
insertion contract, ...).
|
|
218
|
+
|
|
219
|
+
## Building and testing
|
|
220
|
+
|
|
221
|
+
Building the extension needs the ruby development headers (`ruby-dev` /
|
|
222
|
+
`ruby-devel`, or any ruby built from source).
|
|
223
|
+
|
|
224
|
+
```sh
|
|
225
|
+
rake compile # build the extension into lib/
|
|
226
|
+
rake recompile # force a full rebuild, use this when in doubt
|
|
227
|
+
rake test # ruby test suite
|
|
228
|
+
rake test:c # C test suite: debug, asan+ubsan, release
|
|
229
|
+
rake test:valgrind # C test suite under valgrind
|
|
230
|
+
rake test:paranoid # ruby test suite against a DIT_PARANOID build
|
|
231
|
+
rake test:gem # build, install into a sandbox, test the installed gem
|
|
232
|
+
rake test:files # every file the gem ships is present and tracked by git
|
|
233
|
+
rake # default: test:c + test
|
|
234
|
+
rake verify # everything above, plus a randomized seed sweep
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
What each configuration actually proves:
|
|
238
|
+
|
|
239
|
+
| configuration | proves |
|
|
240
|
+
| -------------------------------------- | --------------------------------------------------------------------------------------------- |
|
|
241
|
+
| `test:c` debug (`-O0 -DDIT_PARANOID=1`) | all the invariants below are re-validated after *every* mutation, plus the inline `DIT_ASSERT`s |
|
|
242
|
+
| `test:c` sanitize | no undefined behaviour, no out of bounds access, no leak, still paranoid |
|
|
243
|
+
| `test:c` release (`-DNDEBUG`) | the behaviour is identical with every assertion compiled out |
|
|
244
|
+
| `test:valgrind` | no invalid access and no leak, independently of the sanitizers |
|
|
245
|
+
| `test` / `test:paranoid` | the ruby bindings, against the regular and the paranoid extension |
|
|
246
|
+
| `test:gem` | the *packaged* gem installs and works, which `test` cannot tell |
|
|
247
|
+
| `test:files` | the gem and the repository ship the same files, which neither of the above can tell |
|
|
248
|
+
|
|
249
|
+
The C test suite can also be driven directly:
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
make -C test/c # debug, sanitized and release builds
|
|
253
|
+
make -C test/c debug SEED=42
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Both suites cross-check the tree against a naive reference implementation on
|
|
257
|
+
randomized workloads, and validate every structural invariant after each
|
|
258
|
+
operation. `rake verify SEEDS=100` widens the randomized sweep.
|
|
259
|
+
|
|
260
|
+
## Releasing
|
|
261
|
+
|
|
262
|
+
Releases are cut from `main`, with CI green.
|
|
263
|
+
|
|
264
|
+
**1. Bump the version.**
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
# lib/disjoint_interval_tree/version.rb
|
|
268
|
+
VERSION = '0.2.0'
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
**2. Commit, push, and wait for CI.** `rake release` re-runs everything
|
|
272
|
+
locally, but a release should not be cut from a commit CI has not seen.
|
|
273
|
+
|
|
274
|
+
**3. Publish.**
|
|
275
|
+
|
|
276
|
+
```sh
|
|
277
|
+
rake release
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
It refuses to do anything unless the working tree is clean, the branch is
|
|
281
|
+
`main`, and the tag is still free. It then runs `rake verify`, tags `vX.Y.Z`,
|
|
282
|
+
pushes the tag, and `gem push`es the gem it just built.
|
|
283
|
+
|
|
284
|
+
**4. Attach the gem to a GitHub release.** `rake release` prints this command
|
|
285
|
+
with the version filled in:
|
|
286
|
+
|
|
287
|
+
```sh
|
|
288
|
+
gh release create vX.Y.Z pkg/disjoint_interval_tree-X.Y.Z.gem \
|
|
289
|
+
--title vX.Y.Z --generate-notes
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
**5. Update the downstream packagers.** `rake release` prints the sha256 of
|
|
293
|
+
what it published, as a ready to paste spack `version(...)` line. For
|
|
294
|
+
[THAPI-spack](https://github.com/argonne-lcf/THAPI-spack), that is
|
|
295
|
+
`packages/ruby-disjoint-interval-tree/package.py`. The published artifact can
|
|
296
|
+
also be checksummed directly, which is the authoritative source:
|
|
297
|
+
|
|
298
|
+
```sh
|
|
299
|
+
spack checksum ruby-disjoint-interval-tree X.Y.Z
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## References
|
|
303
|
+
|
|
304
|
+
`dit` is a C rewrite of two structures published by the author:
|
|
305
|
+
|
|
306
|
+
* the **SPMT**, a red-black tree of disjoint memory intervals that merges
|
|
307
|
+
adjacent ranges, used to track memory accesses in Taskgrind:
|
|
308
|
+
|
|
309
|
+
> R. Pereira, G. Stelle and P. Carribault, *"Taskgrind: Heavyweight Dynamic
|
|
310
|
+
> Binary Instrumentation for Parallel Programs Analysis"*, SC24-W: Workshops
|
|
311
|
+
> of the International Conference for High Performance Computing, Networking,
|
|
312
|
+
> Storage and Analysis, Atlanta, GA, USA, 2024, pp. 214-221,
|
|
313
|
+
> doi: [10.1109/SCW63240.2024.00033](https://doi.org/10.1109/SCW63240.2024.00033).
|
|
314
|
+
|
|
315
|
+
* the **LP-Tree**, a k-dimensional interval tree where each node caches the
|
|
316
|
+
hyperrectangle hull of its subtree, used to track matrix tile coherence
|
|
317
|
+
across GPUs:
|
|
318
|
+
|
|
319
|
+
> R. Pereira, P.-E. Polet, T. Gautier and S. Perarnau, *"Multi-GPU Memory
|
|
320
|
+
> Coherence for BLAS Matrices"*, IPDPS-W HIPS: 31st International Workshop on
|
|
321
|
+
> High-level Parallel Programming Models and Supportive Environments, 2026.
|
|
322
|
+
|
|
323
|
+
What this library takes from each, and where it departs from them:
|
|
324
|
+
|
|
325
|
+
* from the **LP-Tree**, the `includes` augment - the hull of the subtree cached
|
|
326
|
+
in every node, here `augment.hull` - and the case analysis of the insertion
|
|
327
|
+
descent. `dit` is the `K = 1` case, so the hyperrectangle collapses to an
|
|
328
|
+
interval, and `includes.hyperrect[0]` to `augment.hull`;
|
|
329
|
+
* from the **SPMT**, the flat C style, the callback-based traversals and the
|
|
330
|
+
coherency-check approach (`dit_check()`);
|
|
331
|
+
* unlike both, `dit` never merges nor splits intervals. Keeping them disjoint
|
|
332
|
+
is a *caller contract*, checked at no extra cost during the insertion
|
|
333
|
+
descent, which is what lets a stored interval keep its identity;
|
|
334
|
+
* unlike the SPMT, the tree is an AVL rather than a red-black tree: rebalancing
|
|
335
|
+
is bottom-up and recursive, which makes the augment refresh and the deletion
|
|
336
|
+
paths markedly harder to get wrong, at the cost of slightly more rotations.
|
|
337
|
+
|
|
338
|
+
## License
|
|
339
|
+
|
|
340
|
+
CeCILL-C, see the headers of the source files.
|
data/Rakefile
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'etc'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'rake/clean'
|
|
6
|
+
require 'rbconfig'
|
|
7
|
+
|
|
8
|
+
require_relative 'lib/disjoint_interval_tree/version'
|
|
9
|
+
|
|
10
|
+
EXT_NAME = 'disjoint_interval_tree'
|
|
11
|
+
EXT_DIR = File.expand_path("ext/#{EXT_NAME}", __dir__)
|
|
12
|
+
LIB_DIR = File.expand_path("lib/#{EXT_NAME}", __dir__)
|
|
13
|
+
BUILD_DIR = File.expand_path("tmp/#{RUBY_PLATFORM}/#{EXT_NAME}/#{RUBY_VERSION}", __dir__)
|
|
14
|
+
GEM_DIR = File.expand_path('tmp/gemtest', __dir__)
|
|
15
|
+
DLEXT = RbConfig::CONFIG['DLEXT']
|
|
16
|
+
SO_NAME = "#{EXT_NAME}.#{DLEXT}"
|
|
17
|
+
SO_PATH = File.join(LIB_DIR, SO_NAME)
|
|
18
|
+
RUBY_BIN = RbConfig.ruby
|
|
19
|
+
NPROC = Etc.respond_to?(:nprocessors) ? Etc.nprocessors : 4
|
|
20
|
+
VERSION = DisjointIntervalTree::VERSION
|
|
21
|
+
GEM_FILE = File.expand_path("pkg/#{EXT_NAME}-#{VERSION}.gem", __dir__)
|
|
22
|
+
|
|
23
|
+
SOURCES = FileList["#{EXT_DIR}/*.c", "#{EXT_DIR}/*.h", "#{EXT_DIR}/extconf.rb"]
|
|
24
|
+
|
|
25
|
+
CLEAN.include('tmp')
|
|
26
|
+
CLOBBER.include(SO_PATH, 'pkg')
|
|
27
|
+
|
|
28
|
+
# Number of distinct RNG seeds the randomized tests are replayed with by
|
|
29
|
+
# `rake verify`. Both suites take a seed, and those randomized cross-checks
|
|
30
|
+
# against a naive model are the strongest correctness signal there is here.
|
|
31
|
+
SEEDS = Integer(ENV.fetch('SEEDS', 20))
|
|
32
|
+
|
|
33
|
+
# `rake compile EXTOPTS=--enable-paranoid` to build an extension running the
|
|
34
|
+
# full coherency check after every mutation
|
|
35
|
+
EXTOPTS = (ENV['EXTOPTS'] || '').split
|
|
36
|
+
|
|
37
|
+
directory BUILD_DIR
|
|
38
|
+
directory LIB_DIR
|
|
39
|
+
|
|
40
|
+
file SO_PATH => SOURCES + [BUILD_DIR, LIB_DIR] do
|
|
41
|
+
Dir.chdir(BUILD_DIR) do
|
|
42
|
+
sh(RUBY_BIN, File.join(EXT_DIR, 'extconf.rb'), *EXTOPTS)
|
|
43
|
+
sh('make', "-j#{NPROC}")
|
|
44
|
+
end
|
|
45
|
+
FileUtils.cp(File.join(BUILD_DIR, SO_NAME), SO_PATH, verbose: true)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc 'Build the C extension'
|
|
49
|
+
task compile: SO_PATH
|
|
50
|
+
|
|
51
|
+
# `clean` alone would not be enough: rake never considers a file task out of
|
|
52
|
+
# date because of a directory prerequisite, so the stale .so has to go
|
|
53
|
+
desc 'Rebuild the C extension from scratch'
|
|
54
|
+
task :recompile do
|
|
55
|
+
Rake::Task[:clean].invoke
|
|
56
|
+
FileUtils.rm_f(SO_PATH)
|
|
57
|
+
[BUILD_DIR, LIB_DIR, SO_PATH].each { |t| Rake::Task[t].reenable }
|
|
58
|
+
Rake::Task[SO_PATH].invoke
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
desc 'Run the ruby test suite'
|
|
62
|
+
task test: :compile do
|
|
63
|
+
sh(RUBY_BIN, '-Ilib', '-Itest', 'test/test_disjoint_interval_tree.rb')
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
namespace :test do
|
|
67
|
+
desc 'Run the C test suite (debug, sanitizers and release builds)'
|
|
68
|
+
task :c do
|
|
69
|
+
sh('make', '-C', 'test/c', 'all')
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
desc 'Run the C test suite under valgrind'
|
|
73
|
+
task :valgrind do
|
|
74
|
+
sh('make', '-C', 'test/c', 'valgrind')
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# `EXTOPTS` is read when the Rakefile is loaded, hence the sub-invocation.
|
|
78
|
+
# The regular extension is then rebuilt, so that a paranoid - and much
|
|
79
|
+
# slower - build is never left behind in lib/
|
|
80
|
+
desc 'Run the ruby test suite against a paranoid build of the extension'
|
|
81
|
+
task :paranoid do
|
|
82
|
+
sh({ 'EXTOPTS' => '--enable-paranoid' }, RUBY_BIN, $PROGRAM_NAME, 'recompile', 'test')
|
|
83
|
+
ensure
|
|
84
|
+
sh(RUBY_BIN, $PROGRAM_NAME, 'recompile')
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# This is the only task exercising what users actually get: it runs the suite
|
|
88
|
+
# without `-Ilib`, so `require 'disjoint_interval_tree'` resolves through the
|
|
89
|
+
# installed gem. A file missing from `spec.files`, a broken `extconf.rb` or a
|
|
90
|
+
# wrong `require_paths` fails here and nowhere else.
|
|
91
|
+
desc 'Install the built gem in a sandbox and run the ruby test suite against it'
|
|
92
|
+
task gem: :build do
|
|
93
|
+
FileUtils.rm_rf(GEM_DIR)
|
|
94
|
+
FileUtils.mkdir_p(GEM_DIR)
|
|
95
|
+
|
|
96
|
+
# the sandbox is the install target, but the default gem dirs stay on the
|
|
97
|
+
# path so that the test suite can still find minitest
|
|
98
|
+
env = {
|
|
99
|
+
'GEM_HOME' => GEM_DIR,
|
|
100
|
+
'GEM_PATH' => ([GEM_DIR] + Gem.path).uniq.join(File::PATH_SEPARATOR)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
sh(env, 'gem', 'install', '--norc', '--no-document', '--local', GEM_FILE)
|
|
104
|
+
|
|
105
|
+
# the gem must come from the sandbox, not from a copy installed elsewhere
|
|
106
|
+
sh(env, RUBY_BIN, '-e', <<~RUBY)
|
|
107
|
+
gem 'disjoint_interval_tree', '= #{VERSION}'
|
|
108
|
+
require 'disjoint_interval_tree'
|
|
109
|
+
|
|
110
|
+
path = Gem.loaded_specs['disjoint_interval_tree'].full_gem_path
|
|
111
|
+
raise "loaded \#{path}, expected it under #{GEM_DIR}" unless path.start_with?('#{GEM_DIR}')
|
|
112
|
+
|
|
113
|
+
tree = DisjointIntervalTree.new([[0, 10], [20, 30]])
|
|
114
|
+
raise 'unexpected content' unless tree.intersect(5, 25) == [[0, 10], [20, 30]]
|
|
115
|
+
raise 'unexpected removal' unless tree.remove(5, 25) == 2
|
|
116
|
+
tree.check!
|
|
117
|
+
|
|
118
|
+
puts "installed gem \#{DisjointIntervalTree::VERSION} loads from \#{path}"
|
|
119
|
+
RUBY
|
|
120
|
+
|
|
121
|
+
sh(env, RUBY_BIN, '-Itest', 'test/test_disjoint_interval_tree.rb')
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
desc 'Replay the randomized tests over SEEDS distinct seeds (default 20)'
|
|
125
|
+
task seeds: :compile do
|
|
126
|
+
(1..SEEDS).each do |seed|
|
|
127
|
+
sh('make', '-C', 'test/c', 'debug', "SEED=#{seed}")
|
|
128
|
+
sh({ 'SEED' => seed.to_s }, RUBY_BIN, '-Ilib', '-Itest',
|
|
129
|
+
'test/test_disjoint_interval_tree.rb')
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# `spec.files` is a `Dir[]` glob over the working tree, so a file that is
|
|
134
|
+
# gitignored, or simply never added, still ends up in the gem while being
|
|
135
|
+
# absent from the repository. That asymmetry is silent, and it is how
|
|
136
|
+
# `test/c/Makefile` - matched by a bare `Makefile` ignore rule - shipped in
|
|
137
|
+
# the gem but broke every CI job.
|
|
138
|
+
desc 'Check that every file the gem ships is present and tracked by git'
|
|
139
|
+
task :files do
|
|
140
|
+
spec = Gem::Specification.load("#{EXT_NAME}.gemspec")
|
|
141
|
+
problems = []
|
|
142
|
+
|
|
143
|
+
spec.files.sort.each do |path|
|
|
144
|
+
problems << "#{path}: listed by the gemspec but missing on disk" unless File.exist?(path)
|
|
145
|
+
|
|
146
|
+
ignored = !`git check-ignore -- #{path}`.empty?
|
|
147
|
+
problems << "#{path}: shipped in the gem but matched by .gitignore" if ignored
|
|
148
|
+
|
|
149
|
+
tracked = system("git ls-files --error-unmatch -- #{path} > /dev/null 2>&1")
|
|
150
|
+
problems << "#{path}: shipped in the gem but not tracked by git" if !tracked && !ignored
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# the repository needs these too, even though the gem does not ship them
|
|
154
|
+
['.gitignore', 'Rakefile', '.github/workflows/ci.yml'].each do |path|
|
|
155
|
+
next if `git check-ignore -- #{path}`.empty?
|
|
156
|
+
|
|
157
|
+
problems << "#{path}: matched by .gitignore"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
unless problems.empty?
|
|
161
|
+
problems.each { |p| warn " #{p}" }
|
|
162
|
+
abort "test:files: #{problems.size} problem(s)"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
puts "test:files: the #{spec.files.size} files the gem ships are all present and tracked"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
desc 'Run both the C and the ruby test suites'
|
|
170
|
+
task default: ['test:c', :test]
|
|
171
|
+
|
|
172
|
+
desc 'Run every test suite: C, sanitizers, valgrind, ruby, paranoid, packaged gem, seed sweep'
|
|
173
|
+
task verify: ['test:files', 'test:c', 'test:valgrind', :test, 'test:paranoid', 'test:gem',
|
|
174
|
+
'test:seeds'] do
|
|
175
|
+
puts
|
|
176
|
+
puts "#{EXT_NAME} #{VERSION}: everything passed"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# `gem build` stamps the gem with the current date, which makes it a different
|
|
180
|
+
# file on every run. Pinning SOURCE_DATE_EPOCH to the last commit makes the
|
|
181
|
+
# build byte-reproducible, so the sha256 a downstream packager - spack, nix,
|
|
182
|
+
# a distro - records for a release can be recomputed and checked.
|
|
183
|
+
def source_date_epoch
|
|
184
|
+
ENV['SOURCE_DATE_EPOCH'] || begin
|
|
185
|
+
epoch = `git log -1 --format=%ct 2> /dev/null`.strip
|
|
186
|
+
epoch.empty? ? Time.now.to_i.to_s : epoch
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
desc 'Build the gem into pkg/'
|
|
191
|
+
task :build do
|
|
192
|
+
require 'digest'
|
|
193
|
+
|
|
194
|
+
FileUtils.mkdir_p('pkg')
|
|
195
|
+
sh({ 'SOURCE_DATE_EPOCH' => source_date_epoch }, 'gem', 'build', '--norc', "#{EXT_NAME}.gemspec")
|
|
196
|
+
FileUtils.mv(FileList["#{EXT_NAME}-*.gem"].to_a, 'pkg', verbose: true)
|
|
197
|
+
|
|
198
|
+
puts
|
|
199
|
+
puts "sha256(#{File.basename(GEM_FILE)}) = #{Digest::SHA256.file(GEM_FILE).hexdigest}"
|
|
200
|
+
puts 'spack recipe line:'
|
|
201
|
+
puts %( version("#{VERSION}", sha256="#{Digest::SHA256.file(GEM_FILE).hexdigest}", expand=False))
|
|
202
|
+
puts
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
task gem: :build
|
|
206
|
+
|
|
207
|
+
desc "Verify, tag and publish v#{DisjointIntervalTree::VERSION} to rubygems.org"
|
|
208
|
+
task :release do
|
|
209
|
+
abort 'release: the working tree has uncommitted changes' unless `git status --porcelain`.empty?
|
|
210
|
+
|
|
211
|
+
branch = `git rev-parse --abbrev-ref HEAD`.strip
|
|
212
|
+
abort "release: on branch #{branch}, expected main" unless branch == 'main'
|
|
213
|
+
|
|
214
|
+
tag = "v#{VERSION}"
|
|
215
|
+
abort "release: #{tag} already exists" if system("git rev-parse -q --verify refs/tags/#{tag} > /dev/null")
|
|
216
|
+
|
|
217
|
+
Rake::Task[:verify].invoke
|
|
218
|
+
|
|
219
|
+
sh('git', 'tag', '-a', tag, '-m', "#{EXT_NAME} #{VERSION}")
|
|
220
|
+
sh('git', 'push', 'origin', 'main', '--follow-tags')
|
|
221
|
+
sh('gem', 'push', GEM_FILE)
|
|
222
|
+
|
|
223
|
+
require 'digest'
|
|
224
|
+
puts
|
|
225
|
+
puts "published #{tag}."
|
|
226
|
+
puts
|
|
227
|
+
puts 'attach the gem to the github release with:'
|
|
228
|
+
puts " gh release create #{tag} #{GEM_FILE} --title #{tag} --generate-notes"
|
|
229
|
+
puts
|
|
230
|
+
puts 'and record this in the spack recipe:'
|
|
231
|
+
puts %( version("#{VERSION}", sha256="#{Digest::SHA256.file(GEM_FILE).hexdigest}", expand=False))
|
|
232
|
+
end
|