carray-jit 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.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +88 -0
  6. data/bin/carray-jit +194 -0
  7. data/carray-jit.gemspec +41 -0
  8. data/docs/00_Introduction.md +40 -0
  9. data/docs/01_GettingStarted.md +80 -0
  10. data/docs/02_KernelShapes.md +397 -0
  11. data/docs/03_SupportedFeatures.md +595 -0
  12. data/docs/04_Compiling.md +234 -0
  13. data/docs/05_DesignNotes.md +136 -0
  14. data/docs/06_Cheatsheet.md +177 -0
  15. data/examples/README.md +56 -0
  16. data/examples/applications/game_of_life.rb +161 -0
  17. data/examples/applications/heat_equation.rb +117 -0
  18. data/examples/applications/kepler.rb +178 -0
  19. data/examples/applications/mandelbrot.rb +151 -0
  20. data/examples/applications/moving_average.rb +124 -0
  21. data/examples/applications/partial_sums.rb +141 -0
  22. data/examples/applications/point_cloud.rb +110 -0
  23. data/examples/applications/quicksort.rb +118 -0
  24. data/examples/applications/recursion.rb +121 -0
  25. data/examples/applications/relaxation.rb +115 -0
  26. data/examples/applications/sensor_gaps.rb +118 -0
  27. data/examples/applications/sieve.rb +95 -0
  28. data/examples/applications/sobel_edges.rb +80 -0
  29. data/examples/features/01_element_wise.rb +69 -0
  30. data/examples/features/02_stencil.rb +40 -0
  31. data/examples/features/03_recurrence.rb +50 -0
  32. data/examples/features/04_thomas.rb +81 -0
  33. data/examples/features/05_reduction.rb +90 -0
  34. data/examples/features/06_jit_contract.rb +58 -0
  35. data/examples/features/07_masks.rb +55 -0
  36. data/examples/features/08_views.rb +46 -0
  37. data/examples/features/09_inspecting.rb +55 -0
  38. data/examples/features/10_complex.rb +107 -0
  39. data/examples/features/11_c_functions.rb +260 -0
  40. data/examples/features/12_sweep.rb +139 -0
  41. data/examples/features/13_cscalar.rb +80 -0
  42. data/examples/features/14_stencil_window.rb +106 -0
  43. data/examples/features/15_loops.rb +148 -0
  44. data/examples/features/16_raising.rb +69 -0
  45. data/ext/carray_jit_access/carray_jit_access.c +460 -0
  46. data/ext/carray_jit_access/extconf.rb +8 -0
  47. data/lib/carray/jit/analyzer.rb +1847 -0
  48. data/lib/carray/jit/block_reader.rb +139 -0
  49. data/lib/carray/jit/c_function.rb +777 -0
  50. data/lib/carray/jit/c_generator.rb +2305 -0
  51. data/lib/carray/jit/compiler.rb +468 -0
  52. data/lib/carray/jit/errors.rb +37 -0
  53. data/lib/carray/jit/expression.rb +202 -0
  54. data/lib/carray/jit/kernel.rb +509 -0
  55. data/lib/carray/jit/node.rb +573 -0
  56. data/lib/carray/jit/sweep.rb +97 -0
  57. data/lib/carray/jit/type_assignment.rb +811 -0
  58. data/lib/carray/jit/version.rb +5 -0
  59. data/lib/carray/jit.rb +1210 -0
  60. metadata +139 -0
@@ -0,0 +1,234 @@
1
+ # Compiling, caching and inspecting
2
+
3
+ ## What compiling costs, and where kernels are kept
4
+
5
+ ### Requirements on the array
6
+
7
+ Contiguity is not one of them. What is:
8
+
9
+ - **not an object array** -- `CA_OBJECT` holds Ruby values, not numbers
10
+ - **not a size-reinterpreting view carrying a mask** -- see [Masks](03_SupportedFeatures.md#masks)
11
+ - **writable**, when the kernel writes to it
12
+
13
+ ### What compiling costs
14
+
15
+ Compiling is not free, and the shape of the trade is worth stating. `benchmark/break_even.rb` measures it where you are; what follows is the shape to expect, not a figure to hold this to.
16
+
17
+ Only the first time is expensive. Compiling a kernel never seen before costs a couple of hundred milliseconds, and most of that is the operating system checking a freshly written binary rather than the compiler working -- the same C through `cc -O3 -fPIC -shared` by hand takes a fraction of it. After that the object is on disk, and a new process pays single-digit milliseconds to load it.
18
+
19
+ The third level is not the compiler at all. A call that finds everything already loaded still costs tens of microseconds, and that figure does not move with the size of the array: it is reading the block's source, splitting the captures, broadcasting the shapes and packing the buffers. So a kernel called in a loop does not pay for itself at one element, and where it starts to depends on what it is being weighed against -- which is two different questions with two different answers.
20
+
21
+ Against **a Ruby loop** -- the reader who came here from `jit_for`, writing a recurrence or a stencil that has no array form -- the crossing is early, at tens of elements for a wide expression and around a hundred for one as narrow as `a * 2 + 1`.
22
+
23
+ Against **the array expression** -- the reader choosing `jit_each` over `a + b * c`, which already runs in C -- it is thousands, and the narrower the expression the later it comes: what is being saved is the passes over the data, and a narrow expression has fewer of them to save.
24
+
25
+ Below those, write the expression. The compiled kernel is for the sizes where the passes cost more than the call does.
26
+
27
+ Which is why the cache is on by default. Turning it off with `CARRAY_JIT_NO_CACHE` makes every run pay the compile again.
28
+
29
+ ### Caching
30
+
31
+ A kernel is compiled once, and every later call takes a fast path. Three levels, outermost first:
32
+
33
+ | Level | Key | Skips |
34
+ | --- | --- | --- |
35
+ | Block source | the block's instruction sequence | reading and parsing the file |
36
+ | Kernel | source, dtype, capture types | analysis, type assignment, code generation, `dlopen` |
37
+ | Shared object | SHA-256 of the C source and flags | the C compiler, across processes |
38
+
39
+ Measured, on a kernel called repeatedly:
40
+
41
+ ```
42
+ first call (compiles) 2090 us
43
+ repeat call, block 9.2 us
44
+ repeat call, source: 6.4 us
45
+ calling the kernel directly 3.1 us
46
+ ```
47
+
48
+ Nothing on that path is free enough to repeat: parsing the file a block lives in costs about 110 us, and analysis and type assignment about 80 us together, against roughly 3 us to call a compiled kernel. So all of it is memoized.
49
+
50
+ The block cache is keyed on the instruction sequence because CRuby hands back the same one for every Proc made from a single block literal -- a free identity for the block, with no source comparison needed.
51
+
52
+ What is *not* cached is the value of a captured variable: it is read from the binding on every call, so a kernel keeps working when the value changes. Only its class is part of the kernel key, since only the class changes the C signature.
53
+
54
+ The remaining ~9 us is per call, not per element, so it disappears into any array worth compiling for: at a million elements it is 0.1% of the run.
55
+
56
+ ### Where compiled kernels are kept
57
+
58
+ In `~/.cache/carray-jit` (honouring `XDG_CACHE_HOME`), one directory per version, CArray version and architecture, holding a pair of files per kernel named by the SHA-256 of the C source, the compiler and its flags:
59
+
60
+ ```
61
+ ~/.cache/carray-jit/
62
+ ├── 0.1.0-carray3.0.1-arm64-darwin24/
63
+ │ ├── 014f77ae....c the generated C, kept so an object can be identified
64
+ │ └── 014f77ae....bundle the compiled kernel, about 17 KB
65
+ └── 0.1.0-carray3.0.1-x86_64-linux/
66
+ ```
67
+
68
+ A kernel is only good for the version that generated it and the architecture it was built for, so those get their own directories. CArray's version is there for the same reason and one of its own: a kernel is handed CArray's memory, on layouts CArray decides, so one compiled against one version and run against the next would answer wrongly rather than fail to load.
69
+
70
+ The two numbers say different things and move on their own clocks: the first is the version that generated the kernel, the second the version it was generated against. This gem is not versioned with CArray, and one release of it meets more than one -- the dependency is `>= 3.0.1, < 3.1`, so 3.0.1 and 3.0.4 both satisfy it -- and a layout the kernel reaches into can move between them. A new release does not spend its cache budget on entries nothing can reach any more, and a home directory shared between machines -- over NFS, or between Rosetta and native -- does not have one architecture evicting the other's kernels. A directory whose newest entry has not been touched in 30 days (`CARRAY_JIT_CACHE_MAX_AGE_DAYS`) is removed.
71
+
72
+ ```ruby
73
+ CArray::JIT.cache_root #=> "/home/you/.cache/carray-jit"
74
+ CArray::JIT.cache_directory #=> ".../0.1.0-carray3.0.1-arm64-darwin24"
75
+ CArray::JIT.cache_entry_count #=> 12
76
+ CArray::JIT.cache_byte_size #=> 208_320
77
+ CArray::JIT.stale_cache_environments #=> [".../0.0.9-carray3.0.1-arm64-darwin24"]
78
+ CArray::JIT.clear_cache #=> 12 (kernels already loaded keep working)
79
+ CArray::JIT.clear_cache(everything: true)
80
+ ```
81
+
82
+ It survives the process on purpose. Rebuilding a kernel costs about 60 ms to compile, plus roughly **180 ms on macOS the first time a freshly written binary is loaded** -- Gatekeeper checking it, not anything Ruby does. Another process loading the same file pays 0.2 ms:
83
+
84
+ ```
85
+ process A, just compiled dlopen 177.8 ms
86
+ process B, existing file dlopen 0.2 ms
87
+ process C, existing file dlopen 0.2 ms
88
+ ```
89
+
90
+ Set `CARRAY_JIT_NO_CACHE=1` (or `CARRAY_JIT_CACHE=none`) to put the cache in a temporary directory that is removed at exit, at that cost per kernel per run.
91
+
92
+ The cache is **bounded**: past `CARRAY_JIT_CACHE_LIMIT` kernels per environment (512 by default, so about 9 MB) the least recently used are evicted, source and object together. Reuse updates an entry's timestamp, so what a program actually runs stays. This is the one place carray-jit deliberately parts with RubyInline, whose `~/.ruby_inline` has no eviction at all and grows for the life of the account.
93
+
94
+ Removing a cached object never breaks a kernel already in use: unlinking a loaded shared object leaves its mapping intact.
95
+
96
+ Because the cache is shared across processes and across time, the key covers the C source, the compiler *binary* (its path, size and mtime -- a stat rather than the ~50 ms of asking it for `--version`), the flags and the architecture. A toolchain upgrade therefore invalidates entries rather than reusing what the old compiler produced.
97
+
98
+ An entry that will not load -- a truncated write, an OS or toolchain change -- is deleted and rebuilt rather than raised. A cache that outlives the process must not be able to turn one bad write into a permanent failure of every future run. A staging file left behind by a process killed mid-compile is swept once it is old enough to be certain nothing is still writing it.
99
+
100
+ The directory is created 0700, and a cache directory other users can write to is refused rather than used -- everything in it gets `dlopen`ed, so a shared writable cache would be a way to run code as you.
101
+
102
+ ## Inspecting a kernel
103
+
104
+ ### Seeing the generated C
105
+
106
+ ```
107
+ CARRAY_JIT_DUMP=1 ruby your_script.rb
108
+ ```
109
+
110
+ prints the source before it is compiled. It opens by saying where it came from -- the file and line the block was written at, and the block itself -- because a generated file that says only what it does is hard to place months later:
111
+
112
+ ```c
113
+ /*
114
+ * Generated by carray-jit 0.1.0.
115
+ *
116
+ * /home/you/work/legendre.rb:12
117
+ *
118
+ * { |i|
119
+ * w = x * legendre[i-1]
120
+ * wy = w - legendre[i-2]
121
+ * legendre[i] = wy + w - wy / i
122
+ * }
123
+ */
124
+ ```
125
+
126
+ That header is kept out of the hash the cache is keyed by, so two call sites that generate the same kernel share one compiled object, and editing the lines above a kernel does not throw its object away. The file then names the first site that compiled it. Every kernel carries two loops and decides between them once, outside the loop -- the contiguous form indexes a typed pointer, which the compiler can vectorise, and the strided form is what lets a view run without being copied first:
127
+
128
+ ```c
129
+ static void
130
+ carray_jit_contiguous (char **pointers, int64_t *strides, int64_t *bounds, ...)
131
+ {
132
+ char *const p_legendre = pointers[0];
133
+ const int64_t legendre_s0 = strides[0];
134
+ const double x = reals[0];
135
+
136
+ for (int64_t i = bounds[0]; i < bounds[1]; i++) {
137
+ double w = x * ((double *)(p_legendre))[i - 1];
138
+ double wy = w - ((double *)(p_legendre))[i - 2];
139
+ ((double *)(p_legendre))[i] = wy + w - wy / (double)i;
140
+ }
141
+ }
142
+ ```
143
+
144
+ Every kernel has that same signature, which is what lets one Fiddle::Function shape serve all of them; the per-kernel detail arrives in the buffers and is unpacked into named locals at the top, where it also reads better. Abridged above are the ones this kernel barely uses: `reals` and `integers` for the captured scalars, `functions` and `data` for the address of each C function the block called and of each array it handed to one whole, `mask_pointers` and `mask_strides` for the masks, and `error` for the one thing a cell can raise. `bounds` carries a start, a limit and a step per axis -- which is also what a chunk looks like, and is why CArray's sweep can call this kernel directly.
145
+
146
+ ### The carray-jit command
147
+
148
+ Installing the gem provides a small command for looking after the cache. It loads only the compiler and its cache, so it works whether or not CArray and the compiled extension can be loaded -- the CArray version in the environment name is the one RubyGems says a `require` would activate, which is the one a running program would have reported itself.
149
+
150
+ ```
151
+ $ carray-jit
152
+ root /home/you/.cache/carray-jit
153
+ environment 0.1.0-carray3.0.1-arm64-darwin24
154
+ kernels 3
155
+ size 50.1 KB
156
+ limit 512 kernels
157
+
158
+ other environments:
159
+ 0.0.9-carray3.0.1-arm64-darwin24 1.2 MB last used 2026-01-01
160
+ ```
161
+
162
+ ```
163
+ $ carray-jit list
164
+ 5a29d9e696a2 16.5 KB 2026-09-01 13:31 /home/you/work/smooth.rb:42
165
+ fff2e2d74333 16.5 KB 2026-09-01 13:31 /home/you/work/solve.rb:17
166
+ 5070c61b1a43 16.5 KB 2026-09-01 13:31 /home/you/work/solve.rb:23
167
+ ```
168
+
169
+ A hash says nothing about which kernel it names, so `list` shows where the kernel was written, and `show` prints the source -- which begins by saying the same thing, and quotes the block it was generated from:
170
+
171
+ ```
172
+ $ carray-jit show 5a29d9e6
173
+ /*
174
+ * Generated by carray-jit 0.1.0.
175
+ *
176
+ * /home/you/work/smooth.rb:42
177
+ *
178
+ * { |i|
179
+ * values[i] = alpha * price[i] + (1.0 - alpha) * values[i-1]
180
+ * }
181
+ */
182
+
183
+ #include <stdint.h>
184
+ #include <math.h>
185
+
186
+ static void
187
+ carray_jit_contiguous (char **pointers, int64_t *strides, int64_t *bounds, ...)
188
+ {
189
+ char *const p_values = pointers[0];
190
+ ...
191
+ ```
192
+
193
+ | Command | |
194
+ | --- | --- |
195
+ | `carray-jit` / `carray-jit status` | where the cache is and how big it is |
196
+ | `carray-jit list` | the kernels cached for this environment, and where each was written |
197
+ | `carray-jit show <prefix>` | the C source of one cached kernel, block and all |
198
+ | `carray-jit clear` | remove this environment's kernels |
199
+ | `carray-jit clear --all` | remove every environment's kernels |
200
+
201
+ ### Environment variables
202
+
203
+ | Variable | Effect |
204
+ | --- | --- |
205
+ | `CARRAY_JIT_DUMP` | Print generated C to stderr before compiling |
206
+ | `CARRAY_JIT_CACHE` | Cache directory (default `~/.cache/carray-jit`), or `none` |
207
+ | `CARRAY_JIT_NO_CACHE` | Keep the cache in a temporary directory, removed on exit |
208
+ | `CARRAY_JIT_CACHE_LIMIT` | Kernels retained on disk, per environment (default 512) |
209
+ | `CARRAY_JIT_CACHE_MAX_AGE_DAYS` | Days an unused environment's directory is kept (default 30) |
210
+ | `CARRAY_JIT_CC` | C compiler (default `RbConfig::CONFIG["CC"]`) |
211
+ | `CARRAY_JIT_REASSOCIATE` | `0` makes the serial accumulator the default for the process |
212
+
213
+ ## Testing
214
+
215
+ ```
216
+ rake test
217
+ rake benchmark
218
+ ```
219
+
220
+ Every kernel in the suite is checked against the same computation written as an ordinary Ruby loop -- except the masked ones, which are checked against CArray's operators -- and the float comparisons are exact rather than within a tolerance -- a tolerance would hide the two bugs most worth catching, FMA contraction and computing in the wrong precision. A test whose kernel reduces says `reassociate: false`, which is what makes its answer comparable to a Ruby loop's at all.
221
+
222
+ The tridiagonal solver in `test/test_thomas.rb` is the case the design had to be able to express: two sweeps in opposite directions, one of them writing two arrays that share a denominator. It is checked bit for bit against a plain Ruby solver, and by the residual of `A x - d`.
223
+
224
+ `benchmark/thomas.rb` times it against a Ruby loop and against LAPACK. Run it rather than reading a figure off this page: what it prints depends on the machine it is run on, and what is worth reading is the ratio between the rows.
225
+
226
+ The Ruby loop is the comparison this gem is about. The LAPACK row is **not** a claim that this is faster than LAPACK, and three things stand between it and any such reading:
227
+
228
+ - `?gtsv` does LU with partial pivoting and solves systems that are not diagonally dominant. This solves the ones that are. Skipping the pivot is most of the difference.
229
+ - What is timed is `CArray::Linalg.solve_tridiagonal`, not `?gtsv` itself: the diagonals are passed as views, so there are contiguity copies, plus validation and output allocation, inside that number.
230
+ - The four arguments `?gtsv` overwrites have to be copied first, and that is excluded from the figure, because it is not part of solving.
231
+
232
+ What the comparison is good for is the other direction. `?gtsv` exists, so this kernel can be checked against it, and it agrees to within rounding. The algorithms `jit_for` is actually for, a periodic tridiagonal solve or a domain-specific recurrence, have no LAPACK entry point to be checked against at all.
233
+
234
+ `test/test_views.rb` covers the access tiers: a contiguous row, a strided column, a reversal, a slice of a slice, and a gather view whose region is transferred and written back.
@@ -0,0 +1,136 @@
1
+ # Design notes
2
+
3
+ Decisions that were not obvious, and why.
4
+
5
+ ## The type is C's, and so is the arithmetic where the width is real
6
+
7
+ The storage type is CArray's, mapped to C exactly -- `int64_t`, `uint8_t`, `float` -- and everything the type itself decides follows from that: the width, the wrap on store, the bit patterns, what a shift does past the width. There a kernel agrees with CArray, because both are the same C.
8
+
9
+ The arithmetic follows the same rule where the width makes a difference to the answer, and Ruby's where it does not. That splits the types in two.
10
+
11
+ **Floating point computes in its own width.** A `float32` cell is read as a `float`, worked on as a `float`, and stored as one; the literal it meets takes the same width, since one `double` in a C expression takes the whole expression with it. So a kernel over float32 arrays gives what CArray's own operators give:
12
+
13
+ ```ruby
14
+ one = CArray.float32(1); one[0] = 1.0
15
+ tiny = CArray.float32(1); tiny[0] = 1.0e-8
16
+
17
+ ((one + tiny) - one)[0] #=> 0.0, computed in float
18
+ CArray.jit_for(1) { |i| out[i] = (one[i] + tiny[i]) - one[i] }
19
+ out[0] #=> 0.0, the same
20
+ ```
21
+
22
+ Ruby's answer for that loop is 9.99e-09, because Ruby has no float32 arithmetic to give: every cell it reads becomes a Float, which is a double. Where Ruby has no width to have an opinion about, the opinion followed is CArray's.
23
+
24
+ **The integers compute in `int64_t`,** as they always have. Reading an `int32` cell gets an Integer that does not stop at 2^31, and that is what a kernel gives it -- `int32 + int32` is worked out in 64 bits and truncated at the store.
25
+
26
+ Narrowing them was tried and dropped, because it would change the answer and buy nothing. Truncation commutes with add, subtract and multiply, so a compiler can prove the wide computation equals the narrow one and emits the narrow vectors by itself: `int16` multiplication comes out as `mul.8h` on arm64 and `pmullw` on x86 whether the C says `int16_t` or `int64_t`, with no widening instruction anywhere. Floating point has no such property -- each step rounds -- which is exactly why it has to be narrow to be narrow, and why it is about twice as fast now that it is.
27
+
28
+ `uint64` is neither: it is the width that cannot fold into `int64_t` at all, since the values it holds above 2^63 are the ones `int64_t` cannot carry, so it computes in a `uint64_t` of its own. See [Types](03_SupportedFeatures.md#unsigned-64-bit).
29
+
30
+ **A value with no data type follows Ruby.** A literal and a captured Numeric have no width of their own, so `2.0` is a double and `2` an Integer -- until they meet an array of their own kind, which lends them its width: `f32 * 2.0` is float32. What an array can lend is a width, never a kind, so `i32 * 2.0` is a float64 and `f32 * 1i` a cmplx128. A local that wants a particular type is seeded from a `CScalar`, which is a value with a data type. See [Locals](03_SupportedFeatures.md#locals-types-and-postfix-math).
31
+
32
+ `CArray.float` is float32; `CArray.double` is float64.
33
+
34
+ ## `-ffp-contract=off` is mandatory
35
+
36
+ Without it, clang and gcc fuse `a*b + c` into a single FMA instruction -- **at `-O0` as well as `-O2` on arm64** -- which changes the last bit of the result. Measured on a two-term recurrence: 13 of 24 cells disagree with the Ruby loop with contraction on, and all 24 agree with it off.
37
+
38
+ The Legendre kernel happens to be immune, because its product is bound to a variable used twice and so never forms the fusable pattern. That makes this a bug a narrow test suite would miss entirely.
39
+
40
+ Fusing is not forbidden the way reassociating a reduction is not forbidden -- one rounding in place of two is if anything the more accurate -- so licensing it would be the same kind of move. It has not been made because it does not pay: measured here, contraction is worth about 1.2x on a coefficient-heavy stencil and makes the 300-cube matrix multiply about 1.3x *slower*, the vectoriser deciding differently. Off is also what keeps `a*b - a*b` and the error-free transformations meaning what they say.
41
+
42
+ ## A sine beside a cosine is `sincos`, and is allowed to be
43
+
44
+ A kernel that asks for `Math.sin(x)` and `Math.cos(x)` of the same argument in the same pass does not get two library calls. The generated C says `sin(x)` and `cos(x)` on two lines and clang answers both with one call to `sincos`, whose sine is not always the one `sin` returns in the last bit. Ruby calls `sin`. So this is the one place where a kernel over `double` disagrees with the Ruby loop it replaces without the block having asked for anything unusual: measured over 200,000 arguments, a sine on its own agrees with Ruby's everywhere, and the same sine written beside a cosine of the same argument disagrees for 1748 of them, by a bit. [kepler.rb](../examples/applications/kepler.rb) prints both counts.
45
+
46
+ It is the same family of thing as fusing `a*b + c`, and the reason it is treated differently is that the flag is not the same shape. `-ffp-contract=off` stops one transformation and costs nothing. The two flags that stop this one cost, measured here on arm64 with Apple clang -- three processes, cache off, against the default flags:
47
+
48
+ ```
49
+ default -ffp-model=strict -fno-builtin
50
+ element-wise a + b*c (4M) 1.91 ms 2.71 ms (1.42x) 2.10 ms (1.10x)
51
+ five-point stencil (2000x2000) 1.92 ms 4.09 ms (2.14x) 2.08 ms (1.09x)
52
+ row sums (2000x2000) 0.65 ms 0.68 ms (1.06x) 0.65 ms (1.01x)
53
+ recurrence (4M) 19.43 ms 27.02 ms (1.39x) 18.68 ms (0.96x)
54
+ Kepler by Newton (200k) 11.74 ms 12.79 ms (1.09x) 12.42 ms (1.06x)
55
+ exp and sqrt (4M) 7.25 ms 8.05 ms (1.11x) 9.47 ms (1.31x)
56
+ ```
57
+
58
+ `-ffp-model=strict` brings `-frounding-math` and strict exception behaviour with it, so it does not stop one transformation -- it stops the vectoriser reasoning about floating point at all, which is where the stencil's 2.14x comes from. `-fno-builtin` is cheaper and better aimed, but it aims at the same place from the other side: it stops `sin` being recognised as `sin`, which also stops `sqrt` and `exp` being the instructions they have on this target. `-fno-builtin-sincos` does not stop the merge here at all.
59
+
60
+ So the merge stays, and is written down instead. A kernel wanting the last bit Ruby has can take the two lines apart -- a sine in one kernel and a cosine in another agree with Ruby everywhere -- and a kernel comparing itself against a Ruby loop should compare within a tolerance if it computes both.
61
+
62
+ ## Integer division is floored, not truncated
63
+
64
+ Ruby floors integer division and gives the remainder the sign of the divisor; C truncates toward zero. `-7 / 2` is `-4` in Ruby and `-3` in C. CArray's own kernels were changed to floor and agree with Ruby, so a JIT specialized for CArray has to agree as well -- lowering `/` to C's operator would quietly produce different numbers for negative operands.
65
+
66
+ The generated helper mirrors `ext/mkkernel.rb` in CArray. Dividing by a positive power of two skips the helper: an arithmetic shift already floors, and is cheaper than the truncating divide C would emit.
67
+
68
+ For the same reason `%` is **not** lowered to `fmod`, which truncates. Ruby and CArray floor it. `%` is not in the subset yet.
69
+
70
+ ## Integer division by zero
71
+
72
+ C has no exception to raise, and dividing by zero in the kernel would trap. The helper reports it through an `int32_t *error` out-parameter, which the Ruby side checks after the call and turns into `ZeroDivisionError`. The branch costs nothing measurable because the flooring correction needs a comparison anyway.
73
+
74
+ That slot carries `raise` too. 1 is the divisor that was not there and 2 the subscript that ran off its array; a `raise` in the block takes a code from 3 up, and the kernel keeps the message behind each -- as does a compiled function, for the `raise`s in its own body. The code is taken from the message rather than counted off as messages are met: a kernel is cached on disk under the C it generated, and a number that meant one string when the object was built and another when it was loaded would raise the wrong message out of a cache hit, quietly. From the message, one string is one code in every process.
75
+
76
+ ## Reaching the array
77
+
78
+ A generated kernel addresses cells itself -- it reads `a[i-1]` and writes `a[i]` -- so what it needs from CArray is not element delivery but an **addressing basis**: a pointer, an offset, and one byte stride per axis.
79
+
80
+ That rules out the two surfaces that look like the obvious homes for it. The kernel iterator (`guides/devel/11`) delivers cells per slab, with mask gather and outer-axis walk, and has no N-ary form -- the Thomas algorithm touches six arrays at once. The sweep ELEMENT family (`guides/devel/13`) flattens the array and cannot recover the axis structure a stencil needs. Neither is wrong; they answer a different question.
81
+
82
+ The basis comes from three public predicates, checked in order:
83
+
84
+ | | Test | How the kernel reaches the array | Copy |
85
+ | --- | --- | --- | --- |
86
+ | 1 | `ca_is_entity` | the buffer is the basis | none |
87
+ | 2 | `ca_is_stride_family` | `ca_stride_compose_to_root` folds the whole view chain to `root + base + strides` | none |
88
+ | 3 | otherwise | `ca_xfer_stride` moves the box the loop touches | that box |
89
+
90
+ Tier 2 is what makes a transpose, a column slice, a reversal, or a slice of a slice run **in place**, with no gather and no scatter -- a write through the folded stride lands in the parent's memory, because it *is* the parent's memory. Measured on a two-million-element recurrence:
91
+
92
+ ```
93
+ entity tier 1 stride 8 2.17 ns/element
94
+ contiguous row of a matrix tier 2 stride 8 2.13 ns/element
95
+ strided column tier 2 stride 16 4.85 ns/element
96
+ reversed tier 2 stride -8 4.86 ns/element
97
+ ```
98
+
99
+ A contiguous view costs what an entity costs. A strided one costs about twice that, because the loop cannot be vectorised. Copying it out, running the contiguous loop and copying it back measured about 25% faster than folding it in place (7.4 ms against 9.8 ms) -- so folding is not chosen for speed. It is chosen because the alternative is the library allocating a second array behind the caller's back. Anyone who wants that trade writes `column.copy`, and can see what it costs.
100
+
101
+ The fold has to land on an array that owns its memory. It stops at the first thing it cannot fold through, and that need not be an entity -- a `CARefer` over a gather view (`whole[whole >= 0].reshape(4, 4)`) folds one step and lands on the `CASelect`. Attaching a root like that materialises a temporary, and detaching it would throw the kernel's writes away, so a fold that does not reach an entity is not the stride tier at all: it goes to the box transfer, which moves only the cells the kernel asked for and puts them back.
102
+
103
+ The fold is done **once**, before compiling, and its result is passed to the kernel. That matters more than it looks: composing the chain per access costs 10 ns for one hop and 37 ns for two, while a hoisted basis is flat at 3.5 ns regardless of depth.
104
+
105
+ ## Why tier 3 never materialises the whole view
106
+
107
+ The box is computed per array and per axis: two arrays in one kernel need not be read at the same offsets, and one array need not be read at the same offsets on each of its axes.
108
+
109
+ `ca_attach` on a view gathers all of it, whatever the kernel intends to touch. On a four-million-element gather view that was 2.7 ms whether the loop covered a hundred cells or a million. `ca_xfer_stride` moves only the requested box:
110
+
111
+ ```
112
+ loop covers ca_attach ca_xfer_stride
113
+ 100 cells 2.97 ms 0.001 ms
114
+ 10,000 cells 2.69 ms 0.022 ms
115
+ 1,000,000 cells 3.86 ms 2.305 ms
116
+ ```
117
+
118
+ Most kernels cover the whole array, so in practice this rarely changes the number. It changes what the library promises. A cost that does not scale with the work is one the caller cannot reason about, and a JIT that hides one has given up the thing it exists for.
119
+
120
+ The same reasoning is why nothing here materialises on the caller's behalf beyond that box. CArray's own rule is that a materialised copy happens when the user writes `copy` (`guides/devel/10`, principle 3); a kernel that silently gathered 32 MB would be breaking it.
121
+
122
+ ## What was tried and rejected
123
+
124
+ The first version of this gem read the pointer through Ruby's MemoryView C API, to stay independent of `carray.h`. It asked for `RUBY_MEMORY_VIEW_SIMPLE`, which demands a contiguous buffer, concluded that views could not be reached, and rejected every one of them. Both halves were wrong: `RUBY_MEMORY_VIEW_STRIDES` exports the whole CAStride family zero-copy, and CArray's own predicates say more than the buffer protocol can. Refusing views to keep the gem decoupled was trading away the composability that views exist for.
125
+
126
+ Per-cell `ca_xfer_addrs` was measured as a tier-3 alternative and is not used. At `n = 1` it re-folds the view on every call and adds about 28 ns of fixed cost on top; batched over 1024 cells it is as fast as anything, but a recurrence cannot batch -- each write has to land before the next read.
127
+
128
+ ## Recovering a block's source
129
+
130
+ Ruby will not hand back a Proc's source, but it does say exactly where the block sits. `RubyVM::InstructionSequence.of(block).to_a[4][:code_location]` gives `[first_line, first_column, last_line, last_column]`; the file (or `script_lines`) is parsed with Prism, and the block node at that position is the kernel.
131
+
132
+ The columns are the part that matters. `Proc#source_location` reports only a line, which cannot tell two blocks on one line apart -- so it is not enough on its own.
133
+
134
+ `RubyVM::AbstractSyntaxTree.of` looks like the obvious route and does not work: since Ruby 3.4 the default parser is Prism, and it refuses with "cannot get AST for ISEQ compiled by prism".
135
+
136
+ Two cases fall back to `source:`. A block defined in `eval` or in a console has no file to read -- setting `RubyVM.keep_script_lines = true` before it is defined makes `script_lines` available and handles that. And a file edited since it was loaded no longer holds the same text at that position, which is reported rather than compiled.
@@ -0,0 +1,177 @@
1
+ # Cheatsheet
2
+
3
+ Eight entry points: the seven `jit_` methods this gem puts on `CArray`, and
4
+ `CArray.fuse`, which is CArray's own and gets the compiler from this gem being
5
+ installed. Every example here runs as written.
6
+
7
+ ## Which one
8
+
9
+ | What you are doing | Write |
10
+ |---|---|
11
+ | An expression over whole arrays | `CArray.fuse` |
12
+ | The same, in one pass with no intermediates | `CArray.jit_each` |
13
+ | The same, and you want the result back | `CArray.jit_map` |
14
+ | A cell reads its neighbours, or the one computed before it | `CArray.jit_for` |
15
+ | A cell reads a window, and the edge needs a rule | `CArray.jit_stencil` |
16
+ | An index appears twice and is summed | `CArray.jit_contract` |
17
+ | Call a C function someone else compiled | `CArray.jit_extern` |
18
+ | Compile a C function of your own | `CArray.jit_function` |
19
+
20
+ The dividing line among the first four is **what reaches what**. Element-wise
21
+ work reaches no neighbour, so it names no index and needs no extent. A cell
22
+ that reaches another cell has to say which one, which is what an index is for.
23
+
24
+ ---
25
+
26
+ ## Whole arrays
27
+
28
+ ```ruby
29
+ out[] = CArray.fuse { a + b * 2 }
30
+ ```
31
+
32
+ Needs no compiler. Without this gem CArray walks the expression; with it, the
33
+ expression is compiled instead, without being asked and without changing the
34
+ answer. An expression the compiler cannot address goes back to CArray. This is
35
+ the only entry point here that is not `jit_`-prefixed, and the only one that
36
+ works with no compiler on the machine.
37
+
38
+ ## Cell by cell
39
+
40
+ ```ruby
41
+ CArray.jit_each { out = a + b * 2 } # writes; returns the kernel
42
+ larger = CArray.jit_map { a > b ? a : b } # returns a new array
43
+ ```
44
+
45
+ Arrays are the names the block closes over -- nothing is named twice. Every
46
+ name is a *cell*: `out = ...` writes the cell of the array `out` names outside.
47
+ A name that is not an array out there is an ordinary local.
48
+
49
+ `jit_each` writes and hands back the kernel; `jit_map` allocates the result,
50
+ typed from the block's last value, and hands that back. Neither takes block
51
+ parameters.
52
+
53
+ ## Naming the index
54
+
55
+ ```ruby
56
+ CArray.jit_for(1...6) { |i| x[i] = x[i-1] * 2 }
57
+ ```
58
+
59
+ The parameters are the loop indices and the arguments are their extents, one
60
+ each, an Integer `n` standing for `0...n`. Naming an index is what lets a cell
61
+ reach `x[i-1]`, and reaching a cell the kernel will later write is what fixes
62
+ the direction the axis runs -- derived from the dependencies, not chosen.
63
+
64
+ `reassociate:` says whether a reduction's accumulator may be split into partial
65
+ sums. Default is `CArray::JIT.reassociate` (`true`). Pass `false` for the
66
+ serial order -- a compensated summation, or checking against the loop.
67
+
68
+ ## Windows
69
+
70
+ ```ruby
71
+ smoothed = CArray.jit_stencil(image) { |a|
72
+ 0.25 * (a[-1, 0] + a[1, 0] + a[0, -1] + a[0, 1])
73
+ }
74
+ ```
75
+
76
+ Arrays are **given**, not closed over, and the parameters are windows onto them
77
+ in that order: `a[0, 0]` is the cell, `a[-1, 1]` a neighbour. The block's value
78
+ is what the cell gets.
79
+
80
+ | Keyword | Meaning |
81
+ |---|---|
82
+ | `border: :mask` | a cell whose window falls off is `UNDEF` -- not computed (default) |
83
+ | `border: :skip` | that cell is left as it was found |
84
+ | `type:` | the data type to collect into; without it, the block's value's |
85
+ | `into:` | write into an array of yours, which then decides the type |
86
+
87
+ `type:` and `into:` together are refused.
88
+
89
+ ## Contraction over a repeated index
90
+
91
+ ```ruby
92
+ c = CArray.jit_contract { |i, j, k| a[i,k] * b[k,j] } # a matrix product
93
+ CArray.jit_contract { |i, j, k| c[i,j] = a[i,k] * b[k,j] }
94
+ ```
95
+
96
+ **An index that appears twice is summed.** One that appears once is free and
97
+ becomes an axis of the result, in the order the block named them -- so
98
+ `{ |j, i, k| ... }` is the transpose. No extent is given: each index's extent
99
+ comes from the axes it addresses.
100
+
101
+ Assigning into an array of yours says where to put it and in what order its
102
+ axes lie. It does **not** decide what is summed -- so `total[i] = a[i,k]` is
103
+ refused, because nothing in `a[i,k]` stands in for a sigma. That is `sum(axis:)`.
104
+
105
+ ## C functions
106
+
107
+ ```ruby
108
+ j0 = CArray.jit_extern("double j0(double)", from: "libgsl")
109
+ sq = CArray.jit_function("double (*)(double)") { |t| t * t }
110
+
111
+ CArray.jit_each { out = j0.call(x) }
112
+ ```
113
+
114
+ `jit_extern` compiles nothing -- Fiddle finds the address, and the kernel calls
115
+ it directly rather than reaching it per cell through Fiddle. `from:` names the
116
+ library; `nil` searches the process. `jit_function` compiles a body of your
117
+ own, callable from a kernel, from Ruby, and by a C library that knows nothing
118
+ about either.
119
+
120
+ ---
121
+
122
+ ## What comes back
123
+
124
+ | Method | Returns |
125
+ |---|---|
126
+ | `fuse` | a lazy expression; assign it to materialise |
127
+ | `jit_each` | `CompiledKernel` -- the value is in the arrays it wrote |
128
+ | `jit_map` | a new `CArray`, typed from the block's value |
129
+ | `jit_for` | `CompiledKernel` |
130
+ | `jit_stencil` | `into:` when given, otherwise a new `CArray` |
131
+ | `jit_contract` | a new `CArray`, or the `CompiledKernel` when the block assigns |
132
+ | `jit_extern` | `CFunction` |
133
+ | `jit_function` | `CFunction` |
134
+
135
+ Every `CompiledKernel` answers `#c_source` with the C that ran.
136
+
137
+ ## Compiler required
138
+
139
+ | | Without a C compiler |
140
+ |---|---|
141
+ | `fuse` | works -- CArray walks it, same answer |
142
+ | the seven `jit_` methods | `CArray::JIT::Unsupported` |
143
+
144
+ That is what the prefix says. A block outside the subset is refused by name and
145
+ line rather than run as a Ruby loop: nobody reaches for a compiler except to
146
+ make something fast, so quietly doing the slow thing would answer a question
147
+ that was not asked.
148
+
149
+ ## What gets refused
150
+
151
+ | | |
152
+ |---|---|
153
+ | `jit_for` with a block naming no index | element-wise -- that is `jit_each` |
154
+ | `jit_each` / `jit_map` with block parameters | an index means `jit_for` |
155
+ | `jit_stencil` with no array given | the arrays are arguments, not closures |
156
+ | `jit_stencil` with both `type:` and `into:` | `into:` already decides the type |
157
+ | a contraction summing an index that appears once | not the convention; `sum(axis:)` |
158
+ | an index appearing more than twice | there is no pair to sum |
159
+ | an index whose axes disagree in extent | the shape check a contraction exists to do |
160
+ | an array both written and read in a contraction | a recurrence -- write it with `jit_for` |
161
+ | a block naming a construct outside the subset | refused by name and line |
162
+
163
+ ## Knobs
164
+
165
+ ```ruby
166
+ CArray::JIT.reassociate #=> true, the default for jit_for reductions
167
+ CArray::JIT.reassociate = false # serial accumulation everywhere
168
+
169
+ CArray::JIT.cache_directory #=> ~/.cache/carray-jit/<version>
170
+ CArray::JIT.cache_entry_count
171
+ CArray::JIT.cache_byte_size
172
+ CArray::JIT.clear_cache
173
+ ```
174
+
175
+ A kernel is compiled once: the shared object is cached on disk, keyed by the
176
+ generated C and the compiler that built it, so a later process finds it there.
177
+ See [Compiling, caching and inspecting](04_Compiling.md).
@@ -0,0 +1,56 @@
1
+ Examples
2
+ ========
3
+
4
+ Two kinds. [applications/](applications) are small programs that do something
5
+ -- read one to see how this gets used. [features/](features) is a tour of the
6
+ subset, one file per feature, closer to documentation with the answers checked.
7
+
8
+ ```
9
+ ruby examples/applications/game_of_life.rb
10
+ rake examples # all of them
11
+ ```
12
+
13
+ Applications
14
+ ------------
15
+
16
+ | | |
17
+ | --- | --- |
18
+ | [game_of_life.rb](applications/game_of_life.rb) | Conway's rules as a stencil, twice: with the indices named, and as a window on a torus |
19
+ | [moving_average.rb](applications/moving_average.rb) | a price series: exponential smoothing, running peak, drawdown |
20
+ | [heat_equation.rb](applications/heat_equation.rb) | implicit diffusion in a rod -- a tridiagonal solve every step |
21
+ | [sobel_edges.rb](applications/sobel_edges.rb) | edge detection on an image, printed as ASCII |
22
+ | [sensor_gaps.rb](applications/sensor_gaps.rb) | quality control on a record with holes in it |
23
+ | [point_cloud.rb](applications/point_cloud.rb) | rotating points, their covariance, projecting onto a basis |
24
+ | [mandelbrot.rb](applications/mandelbrot.rb) | escape time per cell -- a loop whose length no cell shares, and `z = z*z + c` as a Complex |
25
+ | [relaxation.rb](applications/relaxation.rb) | steady heat on a plate: a stencil sweep with the boundary held |
26
+ | [partial_sums.rb](applications/partial_sums.rb) | nine series in one pass, and where the order of a sum is a choice |
27
+ | [sieve.rb](applications/sieve.rb) | Eratosthenes: an inner loop the data decides the length of |
28
+ | [recursion.rb](applications/recursion.rb) | fib, tak, tarai and ackermann as `jit_function`s that call themselves |
29
+ | [quicksort.rb](applications/quicksort.rb) | the textbook partition as a compiled C function, recursing through a pointer |
30
+ | [kepler.rb](applications/kepler.rb) | Newton's method where the passes are the cell's business, not the program's |
31
+
32
+ Each says what you would otherwise have written -- a Ruby loop, or an
33
+ expression over whole arrays -- and measures against it. The numbers vary with
34
+ the machine; the ratios are what to read.
35
+
36
+ The tour
37
+ --------
38
+
39
+ | | |
40
+ | --- | --- |
41
+ | [01_element_wise.rb](features/01_element_wise.rb) | `jit_each { out = a + b * c }` and `jit_map` in one pass, and broadcasting |
42
+ | [02_stencil.rb](features/02_stencil.rb) | each cell from its neighbours; extents, offsets, a step of two |
43
+ | [03_recurrence.rb](features/03_recurrence.rb) | Legendre polynomials; upward sweeps, Ruby's division, a refused range |
44
+ | [04_thomas.rb](features/04_thomas.rb) | a tridiagonal solver; the downward sweep and why it is `step(0, -1)` |
45
+ | [05_reduction.rb](features/05_reduction.rb) | sum, maximum, count and a matrix multiply, all as inner loops |
46
+ | [06_jit_contract.rb](features/06_jit_contract.rb) | Contraction over a repeated index; matmul, trace, outer product, the shape check |
47
+ | [07_masks.rb](features/07_masks.rb) | `a[i] == UNDEF`, filling holes, and implicit propagation |
48
+ | [08_views.rb](features/08_views.rb) | a column, a slice of a slice, a transpose, written in place |
49
+ | [09_inspecting.rb](features/09_inspecting.rb) | the generated C, the cost of compiling, and what is refused |
50
+ | [10_complex.rb](features/10_complex.rb) | complex arrays, the way in and out of them, and Ruby's signed zeros |
51
+ | [11_c_functions.rb](features/11_c_functions.rb) | `jit_extern` for a C function already compiled, `jit_function` for one written in Ruby |
52
+ | [12_sweep.rb](features/12_sweep.rb) | letting CArray drive the element-wise loop; what that bounds, and what keeps it here |
53
+ | [13_cscalar.rb](features/13_cscalar.rb) | a `CScalar` in an expression, in a loop, and as the one cell every iteration writes |
54
+ | [14_stencil_window.rb](features/14_stencil_window.rb) | `jit_stencil`: windows instead of indices, and the five answers `border:` gives |
55
+ | [15_loops.rb](features/15_loops.rb) | an inner loop with a `break`, `while` where no bound is known, `next` |
56
+ | [16_raising.rb](features/16_raising.rb) | `raise "..."` in a kernel: what comes back, and what was written before it |