cumo 0.8.0 → 0.9.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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/README.md +113 -8
  4. data/bench/cumo_probe.rb +1 -1
  5. data/cumo.gemspec +6 -0
  6. data/ext/cumo/cuda/memory_pool_impl.cpp +32 -22
  7. data/ext/cumo/cuda/memory_pool_impl.hpp +3 -1
  8. data/ext/cumo/cuda/memory_pool_impl_test.cpp +19 -25
  9. data/ext/cumo/include/cumo/check.h +11 -3
  10. data/ext/cumo/include/cumo/cuda/cudnn.h +3 -6
  11. data/ext/cumo/include/cumo/indexer.h +16 -0
  12. data/ext/cumo/include/cumo/intern.h +2 -0
  13. data/ext/cumo/include/cumo/reduce_kernel.h +253 -28
  14. data/ext/cumo/include/cumo/row_kernel.h +49 -17
  15. data/ext/cumo/include/cumo/row_method.h +101 -0
  16. data/ext/cumo/include/cumo/types/bf16_macro.h +6 -178
  17. data/ext/cumo/include/cumo/types/bf16_macro_kernel.h +6 -200
  18. data/ext/cumo/include/cumo/types/f16_macro.h +191 -0
  19. data/ext/cumo/include/cumo/types/f16_macro_kernel.h +214 -0
  20. data/ext/cumo/include/cumo/types/float_macro.h +7 -0
  21. data/ext/cumo/include/cumo/types/float_macro_kernel.h +7 -0
  22. data/ext/cumo/include/cumo/types/half_macro.h +4 -176
  23. data/ext/cumo/include/cumo/types/half_macro_kernel.h +4 -198
  24. data/ext/cumo/include/cumo.h +2 -2
  25. data/ext/cumo/narray/data.c +15 -6
  26. data/ext/cumo/narray/data_kernel.cu +110 -0
  27. data/ext/cumo/narray/gen/def/bfloat.rb +2 -1
  28. data/ext/cumo/narray/gen/def/bit.rb +1 -0
  29. data/ext/cumo/narray/gen/def/dcomplex.rb +1 -0
  30. data/ext/cumo/narray/gen/def/dfloat.rb +1 -0
  31. data/ext/cumo/narray/gen/def/hfloat.rb +2 -1
  32. data/ext/cumo/narray/gen/def/int16.rb +1 -0
  33. data/ext/cumo/narray/gen/def/int32.rb +1 -0
  34. data/ext/cumo/narray/gen/def/int64.rb +1 -0
  35. data/ext/cumo/narray/gen/def/int8.rb +1 -0
  36. data/ext/cumo/narray/gen/def/robject.rb +1 -0
  37. data/ext/cumo/narray/gen/def/scomplex.rb +1 -0
  38. data/ext/cumo/narray/gen/def/sfloat.rb +1 -0
  39. data/ext/cumo/narray/gen/def/uint16.rb +1 -0
  40. data/ext/cumo/narray/gen/def/uint32.rb +1 -0
  41. data/ext/cumo/narray/gen/def/uint64.rb +1 -0
  42. data/ext/cumo/narray/gen/def/uint8.rb +1 -0
  43. data/ext/cumo/narray/gen/narray_def.rb +35 -1
  44. data/ext/cumo/narray/gen/spec.rb +3 -0
  45. data/ext/cumo/narray/gen/tmpl/accum_binary.c +63 -5
  46. data/ext/cumo/narray/gen/tmpl/accum_binary_kernel.cu +21 -5
  47. data/ext/cumo/narray/gen/tmpl/batch_norm.c +1 -1
  48. data/ext/cumo/narray/gen/tmpl/batch_norm_backward.c +2 -2
  49. data/ext/cumo/narray/gen/tmpl/binary.c +3 -9
  50. data/ext/cumo/narray/gen/tmpl/conv.c +2 -2
  51. data/ext/cumo/narray/gen/tmpl/conv_grad_w.c +2 -2
  52. data/ext/cumo/narray/gen/tmpl/conv_transpose.c +2 -2
  53. data/ext/cumo/narray/gen/tmpl/fixed_batch_norm.c +1 -1
  54. data/ext/cumo/narray/gen/tmpl/gemm.c +0 -6
  55. data/ext/cumo/narray/gen/tmpl/layer_norm.c +8 -61
  56. data/ext/cumo/narray/gen/tmpl/pooling_backward.c +1 -1
  57. data/ext/cumo/narray/gen/tmpl/pooling_forward.c +1 -1
  58. data/ext/cumo/narray/gen/tmpl/quantize_symmetric.c +74 -0
  59. data/ext/cumo/narray/gen/tmpl/quantize_symmetric_kernel.cu +67 -0
  60. data/ext/cumo/narray/gen/tmpl/rms_norm.c +38 -0
  61. data/ext/cumo/narray/gen/tmpl/rms_norm_kernel.cu +57 -0
  62. data/ext/cumo/narray/gen/tmpl/softmax.c +4 -39
  63. data/ext/cumo/narray/gen/tmpl/softmax_kernel.cu +2 -2
  64. data/ext/cumo/narray/gen/tmpl/store_from.c +1 -16
  65. data/ext/cumo/narray/index.c +33 -24
  66. data/ext/cumo/narray/index_kernel.cu +27 -0
  67. data/ext/cumo/narray/math.c +38 -8
  68. data/ext/cumo/narray/narray.c +58 -14
  69. data/ext/cumo/narray/ndloop.c +137 -1
  70. data/test/bit_test.rb +52 -14
  71. data/test/fused_test.rb +300 -24
  72. data/test/math_test.rb +105 -0
  73. data/test/narray_test.rb +274 -12
  74. metadata +13 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 695bd66c93bd2511f905d74cf242a7d2d7d0ac4aea8f5915fb722e013e658e9a
4
- data.tar.gz: fa51db7ef5f01745ded6d71834bde62fa238fb92cba9bb358374293a18b7577b
3
+ metadata.gz: 0f27ebdaa195fa967c144d6178ad3f9fbeca551162f8edacac205d5edd5d0fb6
4
+ data.tar.gz: 3d87faa55ab7c4c0e52bee3afdb0116d9d463fa778c408a46e200bff0cabdf81
5
5
  SHA512:
6
- metadata.gz: fbad56229183188dc36b2341105a68a5ca670de3a86ac9d50887544bebf4200e80c0f571783aefe24657c609aa86bc3f6af0da8d5943b56c0c91031ea7c22244
7
- data.tar.gz: a5a3e9cfa2e99873b3e7f555fec3e397ed8c9814fbd0255ccc2e0cd52ac9d337fd654741a00412f1b0eb46a777ed346fa46ae98c7e4bf45ce0d7eb8de964fa75
6
+ metadata.gz: 4b498a760e59725c15aa3e651ecbada01e52517833fd3132a860dbfe459e354f4afba47207cc33f2c94963f58f19a8a1d7e52ce722e51065876d637c8ddfc94b
7
+ data.tar.gz: bbda42b2ef7ce7ea882982a015bec06ff058ad42a2b72e4c8953e75facfcb64657c8da4b69d63666cf4c06d3c265dc7e0acf54c431450f181b39cbeebed9560e
data/CHANGELOG.md CHANGED
@@ -1,3 +1,37 @@
1
+ # 0.9.0 (2026/09/19)
2
+
3
+ Breaking changes:
4
+
5
+ * An integer array divided by a zero Ruby number raises `ZeroDivisionError` before the kernel runs rather than after it, so `a.inplace / 0` leaves `a` alone where it wrote over it first. The divisor was on the host all along, so the flag the device raised and the `cudaDeviceSynchronize` that read it back are gone: `Cumo::Int32.new(1, 768)` costs 2.34us for `a / 2` where it cost 9.27, and 2.20 for `a % 2` where it cost 7.88. `2 / a` and an array by an array keep the flag, the divisor really being on the device there (PR #466)
6
+
7
+ Fixes:
8
+
9
+ * Fix `mulsum` reducing everything where the receiver is a view that marked its axes with `:sum`, `:reduce` or `:+` and the operands have different dtypes. The marking lives on the array the method is asked of, and `mulsum` answers another class by putting a different array in the receiver's place, so `Cumo::Int8.new(4, 6)[:sum, true].mulsum(a_float)` answered a scalar where the same call with matching dtypes answers a row of six. numo answers the same wrong thing (PR #480)
10
+ * Fix an elementwise operation whose inplace receiver shares memory with an operand that walks it differently, `a.inplace + a.transpose` answering something new on every run: the element a thread reads is one another thread is writing, and nothing orders the two on a device. An operand sitting exactly where the output does is left alone, which is the common `a.inplace + a` (PR #473)
11
+ * Fix `to_binary` handing out whatever lay past the end of a `Cumo::Bit` array whose size is not a multiple of eight. `Cumo::Bit.new(24).fill(1)[0..3].to_binary` answered `[255]` where four bits belong to it, and a view copied first answered the memory pool's contents, so the same expression could answer differently from one run to the next. `marshal_dump` goes through here too (PR #471)
12
+ * Fix an inplace view built from an index array reducing from the wrong rows. The copy that makes such a view contiguous carried `CUMO_NDF_INPLACE`, so for an inplace receiver it answered self and the reduction walked the index-backed view it meant to have copied. `median` and `max_index` answered their own wrong rows the same way, and inplace survives an operation, so `(v.inplace * 2).sum(axis: 2)` reached it too (PR #461)
13
+ * Hold `layer_norm`, `rms_norm` and `softmax` to the same checks. `softmax` measured the result by its element count where the other two compare the shape, so an `allocate` that reshaped either side to the same count left every check true: reshaping the result answered the right values under the wrong shape, and reshaping the receiver normalized along the wrong axis (PR #459)
14
+
15
+ Changes:
16
+
17
+ * Add `rms_norm`, which normalizes a row in one kernel rather than the six it is written out of. This is `layer_norm` without the centring, the layer Llama and the models after it normalize with, so it takes no beta. On SFloat `[1, 768]` it goes from 14.2 to 2.9us and `[4096, 768]` from 122.5 to 38.5. No shape is slower (PR #458)
18
+ * Add `silu` to `Cumo::NMath`, which takes one kernel rather than the five it is written out of: 768 elements go from 13.4 to 3.6us. It follows `torch.nn.functional.silu`, answering within one unit in the last place of the true value at the nine double points measured, and matching it at the edges (PR #460)
19
+ * Add `quantize_symmetric`, which quantizes a row to `Cumo::Int8` in one kernel rather than the six it is written out of and hands the scale back. A launch costs about two microseconds whatever it is handed, so a short row pays for those rather than for its bytes (PR #476)
20
+ * Speed up `mulsum`. Operands of different dtypes are read where they already are and converted one element at a time rather than cast into an array of the wider type first, so `SFloat#mulsum(Int8)` at `[4096, 4096]` goes from 384 to 53us. A broadcast operand no longer falls off the fast path, `1000x12x64` against `1000x12x1` over axis 0 going from 13.5 to 4.9us, and the thread layout takes the shorter step of the two operands rather than the first one's, so `DFloat[64,1].mulsum(DFloat[64,1024], axis: 1)` goes from 16.26 to 5.68 whichever order it is written in (PR #479, PR #477, PR #475)
21
+ * Fix every allocation slowing down as the memory pool fragments, the arena having been rebuilt whenever an allocation emptied a bin. GPT-2 124M generating 256 tokens goes from 11.9 to 3.2 seconds in fp16, where the pool ends up with 4035 free chunks against fp32's 71 (PR #474)
22
+ * Range check an NArray index on the device rather than copying the indices to the host and back. `Cumo::SFloat.new(50257, 768)`, `a[idx, true]`: 4096 indices go from 58.2 to 22.2us and 64 from 20.8 to 13.8. Nothing reads managed memory from the host on that path any more, which is what breaks under Ractor (sonots/cumo#180) (PR #470)
23
+ * Copy a transposed view through the shared-memory tile the typed store has used since 0.7.0. `Cumo::SFloat.new(4096, 1024)`: `a.transpose.copy` goes from 338 to 97us, level with `a.transpose.dup` at 102, and `a.transpose.max_index(axis: 1)` from 362 to 107. Every reduction that needs a contiguous operand goes through this copy (PR #468)
24
+ * Reduce a view built from an index array without staging it in a buffer first. Copying a `[6250, 64]` view of a `[100000, 64]` array goes from 13.9 to 7.2us and reducing it from 25.1 to 18.3 (PR #462)
25
+ * Let `store_binary` reach a view it could only read back from. `to_binary` reads any view, so `v.store_binary(v.to_binary)` raised for the same `v` that had just produced the string. A view the bytes cannot move into directly now takes them through an array of its own class and shape (PR #472)
26
+ * Answer `respond_to?` for the methods `Cumo::NMath` dispatches. Every one of them arrives through `method_missing`, so `respond_to?` answered false for `sqrt` and `exp` alike, which code that checks for a feature before calling it reads as absence (PR #463)
27
+ * Take one Ruby method dispatch out of every ndloop call, the argument list always being a plain Array where `rb_obj_dup` took the general path: about 800 fewer retired instructions per operation, 3.4% of the 23,200 an operation takes (PR #465)
28
+ * Tell which array a type check rejected and what it wanted. It said "invalid NArray type (class)", naming neither, so two checks side by side read exactly alike; it now says "gamma must be Cumo::SFloat, not Cumo::HFloat" (PR #478)
29
+ * Say in the README that `reshape` answers a copy where slicing answers a view, priced across four shapes: on a GPU it is an allocation and a copy kernel every call, 58.1us and 12MB for a `4096x768` SFloat against 0.22us and nothing for `reshape!` (PR #457)
30
+ * Show the changelog, source and issue links on the gem page, and require multi-factor authentication to push the gem (PR #456)
31
+ * Share one body between the half and bfloat16 macros so a fix reaches both, 399 duplicated lines where it would have reached one. The compiled code is unchanged: the device SASS is identical (PR #464)
32
+ * Keep the index-array test in one place so copy and store cannot drift apart, 172 generated copies of it becoming one (PR #467)
33
+ * Drop the `Cumo::NMath::DISPATCH` rows no argument can reach. A bare Ruby numeric takes an NArray row, so the `Integer`, `Float` and `Complex` ones were never read: 944 calls over 16 kinds of argument reached them zero times (PR #469)
34
+
1
35
  # 0.8.0 (2026/09/16)
2
36
 
3
37
  Breaking changes:
data/README.md CHANGED
@@ -178,6 +178,44 @@ x += p_dir * alpha # and consumed there, without crossing the bus
178
178
 
179
179
  Read the value back once the loop is done, or every k iterations if it has to test something.
180
180
 
181
+ ### Reshape Copies, Reshape! Does Not
182
+
183
+ `reshape` answers a copy of the whole array, never a view.
184
+ Slicing answers a view, so the two look alike and are not:
185
+
186
+ ```ruby
187
+ a = Cumo::SFloat.new(4, 6).seq
188
+ a[0..1, true][0, 0] = 77.0 # a view, so a changes
189
+ a.reshape(2, 12)[0, 0] = 99.0 # a copy, so a does not
190
+ ```
191
+
192
+ This is what Numo does too, and numpy is where the expectation comes from: there `reshape` answers a view whenever the strides allow one.
193
+ On a GPU the difference is an allocation and a copy kernel, paid every call.
194
+ `reshape!` changes the receiver in place and costs neither.
195
+ RTX 5070 Ti Laptop, `Cumo::SFloat`, 200 calls a measurement:
196
+
197
+ ```
198
+ shape reshape reshape! the copy allocates
199
+ 1x768 5.1 us 0.04 us 3 KB
200
+ 1024x768 39.1 us 0.18 us 3 MB
201
+ 4096x768 58.1 us 0.22 us 12 MB
202
+ 8192x768 142.7 us 0.43 us 24 MB
203
+ ```
204
+
205
+ `reshape!` is host-side bookkeeping, so it stays under a microsecond whatever the array weighs.
206
+ The `reshape` column is the copy, and it grows with the bytes.
207
+
208
+ The catch is that `reshape!` changes the array everything else is holding.
209
+ It fits a temporary the calling expression owns, and not an argument, an ivar, or anything a cache still points at:
210
+
211
+ ```ruby
212
+ x = a * b # a temporary nothing else holds
213
+ x.reshape!(t, n, h) # free
214
+ ```
215
+
216
+ Where the array is not yours to change, the copy is the price of the shape.
217
+ Reach for `reshape!` when a profile says the copies are worth removing, not by default.
218
+
181
219
  ### Ruby Floats In NMath Promote To Double
182
220
 
183
221
  `Cumo::NMath` picks the module it dispatches to from every argument it is given, and a Ruby `Float` counts as a `DFloat` there.
@@ -224,14 +262,18 @@ The 0-dimensional form has no effect under Numo, where `[]` returns a Ruby Float
224
262
 
225
263
  ### Fused Operations
226
264
 
227
- `layer_norm` and `softmax` normalize along the last axis in one kernel each.
228
- Written out of the operators they take nine launches and five, and a launch costs about two microseconds whatever it is handed, so a short row pays for the launches rather than for its bytes.
265
+ `layer_norm`, `rms_norm` and `softmax` normalize along the last axis in one kernel each, and `quantize_symmetric` takes it to 8-bit integers in one more.
266
+ Written out of the operators they take nine launches, six, five and six, and a launch costs about two microseconds whatever it is handed, so a short row pays for the launches rather than for its bytes.
229
267
 
230
268
  ```ruby
231
269
  y = x.layer_norm(gamma, beta, eps: 1e-5) # (x - mean) / sqrt(var + eps) * gamma + beta
270
+ z = x.rms_norm(gamma, eps: 1e-5) # x / sqrt(mean(x * x) + eps) * gamma
232
271
  probs = scores.softmax # exp(x - max) / sum(exp(x - max))
272
+ xq, scale = x.quantize_symmetric # scale = max(|x|) / 127, xq = round(x / scale)
233
273
  ```
234
274
 
275
+ `quantize_symmetric` answers a `Cumo::Int8` shaped like self and the scale of every row, which is self's shape without its last axis. `xq * scale[false, :new]` is what the row stood for. A row of zeros has no scale to divide by and answers zero for both, and a row holding an infinity or a NaN answers that in its scale and zeros in the row, since neither is a value 8 bits could carry. The scale comes back in the class the reduction accumulates in, which is `Cumo::SFloat` for `Cumo::HFloat` and `Cumo::BFloat` and self's own otherwise.
276
+
235
277
  On an RTX 5070 Ti Laptop, against the same arithmetic spelled with operators, in microseconds:
236
278
 
237
279
  ```
@@ -248,23 +290,58 @@ shape fused written fused written fused written
248
290
  256 x 768 8.8 13.9 23.4 35.1 6.8 18.5
249
291
  4096 x 768 49.2 122.7 318.3 549.0 28.2 109.8
250
292
  1 x 1000000 27.8 44.0 108.6 148.5 32.1 53.8
293
+
294
+ rms_norm SFloat DFloat HFloat
295
+ shape fused written fused written fused written
296
+ 1 x 768 2.9 14.2 5.8 14.1 3.1 15.1
297
+ 256 x 768 4.6 16.7 6.9 20.2 5.2 15.5
298
+ 4096 x 768 38.5 122.5 130.8 367.0 19.2 80.8
299
+ 1 x 1000000 12.5 35.8 23.7 102.2 12.0 33.2
251
300
  ```
252
301
 
253
- Both pay off in every precision, by the most where the row is short enough that the launches were all it was doing, and by the least in double, where the reduction itself costs more than the launches ever did.
302
+ ```
303
+ quantize_symmetric SFloat DFloat HFloat
304
+ shape fused written fused written fused written
305
+ 1 x 768 3.7 20.2 6.0 15.7 2.3 11.9
306
+ 256 x 768 4.7 15.0 20.2 21.3 4.7 13.0
307
+ 4096 x 768 24.4 105.4 289.5 333.3 23.8 80.3
308
+ 1 x 1000000 18.8 40.8 97.1 81.6 15.7 37.5
309
+ ```
310
+
311
+ The tables were taken in separate sessions, so read each row against the row beside it and not across the tables.
312
+
313
+ `quantize_symmetric` in `Cumo::DFloat` is worth less than the others and loses outright on a million elements, whatever shape they are in. The division it does per element is what costs: on this card one row of a million takes 44.0 us to divide in double against 10.8 in single, where the reduction over the same row takes 25.3 and 15.4. Six kernels give that division a kernel of its own to fill the device with, and one kernel leaves it behind the reduction.
314
+
315
+ All three pay off in every precision, by the most where the row is short enough that the launches were all it was doing, and by the least in double, where the reduction itself costs more than the launches ever did.
254
316
  The memory clock on this card steps between 9001 and 11001 MHz under a benchmark this short, and the absolute figures move with it.
255
317
  The ratios hold across the steps.
256
318
 
257
- Both methods normalize along the last axis only.
319
+ All three normalize along the last axis only.
258
320
  Other axes are reachable through `transpose`.
259
- `layer_norm` divides the variance by the row length and not by one less, which is how the layer is defined and unlike `var`.
260
- Both answer a new array and ignore `inplace!`, and a non-contiguous receiver is copied once rather than walked.
321
+ `layer_norm` divides the variance by the row length and not by one less, which is how the layer is defined and unlike `var`; `rms_norm` divides its mean square the same way.
322
+ All three answer a new array and ignore `inplace!`, and a non-contiguous receiver is copied once rather than walked.
261
323
  `gamma` and `beta` must be one-dimensional, as long as the last axis, and of the same class as the receiver.
262
324
 
325
+ `rms_norm` is `layer_norm` without the centring, and takes no `beta`, which is the layer Llama and the models after it normalize with.
326
+ The two agree on a row whose mean is already zero and differ on every other row, so they are not interchangeable:
327
+
328
+ ```ruby
329
+ x = Cumo::SFloat[[1.0, 2.0, 3.0, 4.0]]
330
+ ones = Cumo::SFloat.ones(4)
331
+ zeros = Cumo::SFloat.zeros(4)
332
+ x.rms_norm(ones) #=> [[0.365, 0.730, 1.095, 1.461]]
333
+ x.layer_norm(ones, zeros) #=> [[-1.342, -0.447, 0.447, 1.342]]
334
+ ```
335
+
336
+ All three accumulate in single precision, so a half row that squares past the 65504 half holds is still answered the way double answers it.
337
+ bfloat16 carries a float's exponent rather than a half's, so a bfloat16 row that squares past what a float holds overflows the accumulator too: `Cumo::BFloat[[-3e20, 3e20, 3e20, -3e20]].rms_norm(ones)` answers zeros where double answers ones.
338
+
263
339
  `softmax` subtracts the row maximum before exponentiating, so a row masked with `-Float::INFINITY` answers zeros rather than `NaN`.
264
340
  A row that is entirely `-Float::INFINITY` answers `NaN`, as the written-out form does.
265
341
 
266
- Those two and `gelu_tanh` from the section below are where a transformer spends most of its launches.
342
+ `layer_norm` and `softmax` above, with `gelu_tanh` from the section below, are where a GPT-2 style transformer spends most of its launches.
267
343
  Decoding one token of GPT-2 124M written against Numo's API takes 640 of them, and calling these three instead removes 332: 200 for the layer norms, 84 for the activations and 48 for the softmaxes.
344
+ A Llama style one spends them on `rms_norm` and `silu` instead, which is why they are here.
268
345
 
269
346
  ### Two Spellings Of gelu
270
347
 
@@ -284,6 +361,34 @@ Both follow PyTorch at the edges rather than the limit: a large negative `x`
284
361
  runs out of significant digits and answers zero, and `-Float::INFINITY` answers
285
362
  `NaN`.
286
363
 
364
+ ### SiLU
365
+
366
+ `Cumo::NMath.silu` is `x * sigmoid(x)`, the activation Llama and the models after it use where GPT-2 uses `gelu_tanh`.
367
+ It is also called Swish.
368
+
369
+ ```ruby
370
+ Cumo::NMath.silu(x) # x / (1 + exp(-x)), in one kernel rather than five
371
+ ```
372
+
373
+ Written out of the operators it costs five launches, and one kernel runs 1.1x to 4.3x faster on an RTX 5070 Ti Laptop, in microseconds:
374
+
375
+ ```
376
+ silu SFloat DFloat HFloat
377
+ elements fused written fused written fused written
378
+ 768 3.6 13.4 3.6 9.5 3.4 14.3
379
+ 786432 7.0 25.9 90.5 102.4 5.2 19.2
380
+ 16777216 353.2 1312.9 1984.7 3258.8 199.6 851.8
381
+ ```
382
+
383
+ The three rows are three regimes rather than one curve: 768 elements pay for the launches, 786432 fit in L2 and move faster than this card reads from memory, and 16777216 are what it costs from DRAM.
384
+
385
+ It follows `torch.nn.functional.silu`, answering within one unit in the last place of the true value at the nine double points measured, as torch does.
386
+ At the edges it matches torch exactly: `-Float::INFINITY` answers `NaN`, since that is what infinity times zero is, and a large negative `x` answers a signed zero.
387
+
388
+ Where that zero starts depends on the type, and not on `exp` alone.
389
+ Single and bfloat16 reach it at -89, where `exp(-x)` passes what a float holds, and double never does.
390
+ Half reaches it at -21, because the round back to half gets there first.
391
+
287
392
  ### Half Precision
288
393
 
289
394
  `Cumo::HFloat`, also reachable as `Cumo::Float16`, holds IEEE binary16: one sign bit, five of exponent and ten of mantissa.
@@ -430,7 +535,7 @@ The two do not contain each other, so an expression mixing them promotes to `Cum
430
535
 
431
536
  Everything else promotes as `Cumo::SFloat` does, so an integer array or a Ruby Float mixed in stays bfloat16 while anything wider takes over.
432
537
 
433
- `layer_norm`, `softmax` and both spellings of `gelu` take it, and so do the reductions, `sort`, `median`, `cumsum`, `rand` and `dot`.
538
+ `layer_norm`, `rms_norm`, `softmax`, `silu` and both spellings of `gelu` take it, and so do the reductions, `sort`, `median`, `cumsum`, `rand` and `dot`.
434
539
  The cuDNN methods take it too: `conv`, `conv_transpose`, `conv_grad_w`, `max_pool`, `avg_pool` and the three batch norm entries.
435
540
 
436
541
  Reductions accumulate in single precision and round once at the end, exactly as they do for half, so a sum passes 256 without stopping there:
data/bench/cumo_probe.rb CHANGED
@@ -312,7 +312,7 @@ op(:scalar, 'scalar_left') { |c| c.scalar - c.sc.inplace }
312
312
 
313
313
  # NMath
314
314
  %w[sqrt exp exp2 exp10 expm1 log log2 log10 log1p sin cos tan asin acos atan
315
- sinh cosh tanh asinh acosh atanh cbrt erf erfc sinc gelu gelu_tanh].each do |f|
315
+ sinh cosh tanh asinh acosh atanh cbrt erf erfc sinc gelu gelu_tanh silu].each do |f|
316
316
  op(:math, f, :float) { |c| XM::NMath.send(f, c.usc.inplace) }
317
317
  end
318
318
  op(:math, 'atan2', :float) { |c| XM::NMath.atan2(c.sc.inplace, c.b) }
data/cumo.gemspec CHANGED
@@ -16,6 +16,12 @@ Gem::Specification.new do |spec|
16
16
  spec.homepage = "https://github.com/sonots/cumo"
17
17
  spec.license = "MIT"
18
18
 
19
+ spec.metadata["homepage_uri"] = spec.homepage
20
+ spec.metadata["source_code_uri"] = spec.homepage
21
+ spec.metadata["changelog_uri"] = "https://github.com/sonots/cumo/blob/master/CHANGELOG.md"
22
+ spec.metadata["bug_tracker_uri"] = "https://github.com/sonots/cumo/issues"
23
+ spec.metadata["rubygems_mfa_required"] = "true"
24
+
19
25
  spec.required_ruby_version = ">= 3.0.0"
20
26
 
21
27
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
@@ -108,7 +108,16 @@ bool SingleDeviceMemoryPool::RemoveFromFreeList(size_t size, std::shared_ptr<Chu
108
108
  }
109
109
  assert(arena.size() > arena_index);
110
110
  FreeList& free_list = arena[arena_index];
111
- return EraseFromFreeList(free_list, chunk);
111
+ if (!EraseFromFreeList(free_list, chunk)) {
112
+ return false;
113
+ }
114
+ // A bin left empty here would be walked by every later search, and the
115
+ // merge path below Free empties one whenever it takes a neighbour out.
116
+ if (free_list.empty()) {
117
+ arena.erase(arena.begin() + arena_index);
118
+ arena_index_map.erase(arena_index_map.begin() + arena_index);
119
+ }
120
+ return true;
112
121
  }
113
122
 
114
123
  intptr_t SingleDeviceMemoryPool::Malloc(size_t size, cudaStream_t stream_ptr) {
@@ -132,6 +141,7 @@ intptr_t SingleDeviceMemoryPool::Malloc(size_t size, cudaStream_t stream_ptr) {
132
141
 
133
142
  // find best-fit, or a smallest larger allocation
134
143
  Arena& arena = GetArena(stream_ptr);
144
+ ArenaIndexMap& arena_index_map = GetArenaIndexMap(stream_ptr);
135
145
  size_t arena_index = GetArenaIndex(size, stream_ptr);
136
146
  size_t arena_length = arena.size();
137
147
  for (size_t i = arena_index; i < arena_length; ++i) {
@@ -141,10 +151,15 @@ intptr_t SingleDeviceMemoryPool::Malloc(size_t size, cudaStream_t stream_ptr) {
141
151
  }
142
152
  chunk = PopFromFreeList(free_list);
143
153
  if (free_list.empty()) {
144
- // An emptied bin stays in the arena otherwise, and every later
145
- // search walks it. Dropping it here invalidates arena and
146
- // free_list, so nothing below this loop may touch them.
147
- CompactIndex(stream_ptr, false);
154
+ // Drop just this bin. Rebuilding the whole arena instead
155
+ // copied every other bin's free list, and a free list holds
156
+ // shared pointers, so a fragmented pool touched the reference
157
+ // count of every chunk it held on every allocation.
158
+ // This leaves free_list, the elements of arena_index_map and
159
+ // every iterator from i on dangling, so nothing below may
160
+ // touch them.
161
+ arena.erase(arena.begin() + i);
162
+ arena_index_map.erase(arena_index_map.begin() + i);
148
163
  }
149
164
  break;
150
165
  }
@@ -252,7 +267,7 @@ bool SingleDeviceMemoryPool::Free(intptr_t ptr) {
252
267
  return true;
253
268
  }
254
269
 
255
- void SingleDeviceMemoryPool::CompactIndex(cudaStream_t stream_ptr, bool free) {
270
+ void SingleDeviceMemoryPool::CompactIndex(cudaStream_t stream_ptr) {
256
271
  // need lock ouside this function
257
272
  if (!HasArena(stream_ptr)) return;
258
273
 
@@ -266,22 +281,17 @@ void SingleDeviceMemoryPool::CompactIndex(cudaStream_t stream_ptr, bool free) {
266
281
  if (free_list.empty()) {
267
282
  continue;
268
283
  }
269
- if (free) {
270
- FreeList keep_list;
271
- for (auto chunk : free_list) {
272
- if (chunk->prev() != nullptr || chunk->next() != nullptr) {
273
- keep_list.emplace_back(chunk);
274
- }
275
- }
276
- if (keep_list.size() == 0) {
277
- continue;
284
+ FreeList keep_list;
285
+ for (auto chunk : free_list) {
286
+ if (chunk->prev() != nullptr || chunk->next() != nullptr) {
287
+ keep_list.emplace_back(chunk);
278
288
  }
279
- new_arena_index_map.emplace_back(arena_index_map[arena_index]);
280
- new_arena.emplace_back(keep_list);
281
- } else {
282
- new_arena_index_map.emplace_back(arena_index_map[arena_index]);
283
- new_arena.emplace_back(free_list);
284
289
  }
290
+ if (keep_list.size() == 0) {
291
+ continue;
292
+ }
293
+ new_arena_index_map.emplace_back(arena_index_map[arena_index]);
294
+ new_arena.emplace_back(keep_list);
285
295
  }
286
296
  if (new_arena.empty()) {
287
297
  index_.erase(stream_ptr);
@@ -299,7 +309,7 @@ void SingleDeviceMemoryPool::FreeAllBlocks() {
299
309
  std::vector<cudaStream_t> keys(free_.size());
300
310
  transform(free_.begin(), free_.end(), keys.begin(), [](auto pair) { return pair.first; });
301
311
  for (cudaStream_t stream_ptr : keys) {
302
- CompactIndex(stream_ptr, true);
312
+ CompactIndex(stream_ptr);
303
313
  }
304
314
  }
305
315
 
@@ -307,7 +317,7 @@ void SingleDeviceMemoryPool::FreeAllBlocks() {
307
317
  void SingleDeviceMemoryPool::FreeAllBlocks(cudaStream_t stream_ptr) {
308
318
  std::lock_guard<std::recursive_mutex> lock{mutex_};
309
319
 
310
- CompactIndex(stream_ptr, true);
320
+ CompactIndex(stream_ptr);
311
321
  }
312
322
 
313
323
  size_t SingleDeviceMemoryPool::GetNumFreeBlocks() {
@@ -284,7 +284,9 @@ public:
284
284
  // be found in the free list as the chunk is allocated.)
285
285
  bool RemoveFromFreeList(size_t size, std::shared_ptr<Chunk>& chunk, cudaStream_t stream_ptr);
286
286
 
287
- void CompactIndex(cudaStream_t stream_ptr, bool free);
287
+ // Drops every chunk that was never split from a larger one, and every
288
+ // bin left empty by that.
289
+ void CompactIndex(cudaStream_t stream_ptr);
288
290
  };
289
291
 
290
292
  // Memory pool for all GPU devices on the host.
@@ -337,10 +337,10 @@ public:
337
337
  assert(pool_->GetFreeBytes() == kRoundSize * 8);
338
338
  }
339
339
 
340
- // RemoveFromFreeList leaves an emptied bin in the arena -- it erases the
341
- // chunk and returns, where Malloc drops one through CompactIndex. The
342
- // assertions below spell that out, so they describe the arena as it is
343
- // rather than wait on a compaction this path is not getting.
340
+ // RemoveFromFreeList drops a bin its erase leaves empty, the same as
341
+ // Malloc. The merge path below Free empties one whenever it takes a
342
+ // neighbour out, so a bin left behind here would be walked by every later
343
+ // search.
344
344
  void TestRemoveFromFreeList() {
345
345
  Arena& arena = pool_->GetArena(stream_ptr_);
346
346
  ArenaIndexMap& arena_index_map = pool_->GetArenaIndexMap(stream_ptr_);
@@ -372,36 +372,30 @@ public:
372
372
  assert(arena_index_map[1] == 3);
373
373
  assert(arena_index_map[2] == 4);
374
374
 
375
- // remove two from two
375
+ // remove two from two, which empties that bin
376
376
  pool_->RemoveFromFreeList(chunk2->size(), chunk2, stream_ptr_);
377
- assert(arena.size() == 3);
377
+ assert(arena.size() == 2);
378
378
  assert(arena[0].size() == 1);
379
- assert(arena[1].size() == 0);
380
- assert(arena[2].size() == 1);
381
- assert(arena_index_map.size() == 3);
379
+ assert(arena[1].size() == 1);
380
+ assert(arena_index_map.size() == 2);
382
381
  assert(arena_index_map[0] == 2);
383
- assert(arena_index_map[1] == 3);
384
- assert(arena_index_map[2] == 4);
382
+ assert(arena_index_map[1] == 4);
385
383
 
386
384
  pool_->RemoveFromFreeList(chunk3->size(), chunk3, stream_ptr_);
387
- assert(arena.size() == 3);
385
+ assert(arena.size() == 1);
388
386
  assert(arena[0].size() == 1);
389
- assert(arena[1].size() == 0);
390
- assert(arena[2].size() == 0);
391
- assert(arena_index_map.size() == 3);
387
+ assert(arena_index_map.size() == 1);
392
388
  assert(arena_index_map[0] == 2);
393
- assert(arena_index_map[1] == 3);
394
- assert(arena_index_map[2] == 4);
395
389
 
396
390
  pool_->RemoveFromFreeList(chunk4->size(), chunk4, stream_ptr_);
397
- assert(arena.size() == 3);
398
- assert(arena[0].size() == 0);
399
- assert(arena[1].size() == 0);
400
- assert(arena[2].size() == 0);
401
- assert(arena_index_map.size() == 3);
402
- assert(arena_index_map[0] == 2);
403
- assert(arena_index_map[1] == 3);
404
- assert(arena_index_map[2] == 4);
391
+ assert(arena.size() == 0);
392
+ assert(arena_index_map.size() == 0);
393
+
394
+ // A chunk that is not in the free list leaves the arena alone.
395
+ pool_->AppendToFreeList(chunk1->size(), chunk1, stream_ptr_);
396
+ assert(!pool_->RemoveFromFreeList(chunk2->size(), chunk2, stream_ptr_));
397
+ assert(arena.size() == 1);
398
+ assert(arena[0].size() == 1);
405
399
  }
406
400
 
407
401
  void TestMalloc() {
@@ -11,10 +11,18 @@ extern "C" {
11
11
  #endif
12
12
  #endif
13
13
 
14
- #define CUMO_CHECK_NARRAY_TYPE(x,t) \
15
- if (rb_obj_class(x)!=(t)) { \
16
- rb_raise(rb_eTypeError,"invalid NArray type (class)"); \
14
+ // Two of these often sit side by side, so the message has to say which array
15
+ // was turned down as well as what it is.
16
+ static inline void
17
+ cumo_na_check_narray_type(VALUE x, VALUE type, const char *name)
18
+ {
19
+ if (rb_obj_class(x) != type) {
20
+ rb_raise(rb_eTypeError, "%s must be %" PRIsVALUE ", not %" PRIsVALUE,
21
+ name, type, rb_obj_class(x));
17
22
  }
23
+ }
24
+
25
+ #define CUMO_CHECK_NARRAY_TYPE(x,t,name) cumo_na_check_narray_type((x),(t),(name))
18
26
 
19
27
  #define CUMO_CHECK_SIZE_EQ(sz1,sz2) \
20
28
  if ((sz1) != (sz2)) { \
@@ -38,7 +38,7 @@ cumo_cuda_cudnn_check_output(VALUE out, VALUE type, size_t ndim, size_t *shape)
38
38
  {
39
39
  cumo_narray_t *na;
40
40
 
41
- CUMO_CHECK_NARRAY_TYPE(out, type);
41
+ CUMO_CHECK_NARRAY_TYPE(out, type, "output");
42
42
  CumoGetNArray(out, na);
43
43
  CUMO_CHECK_DIM_EQ((size_t)(na->ndim), ndim);
44
44
  for (size_t idim = 0; idim < ndim; ++idim) {
@@ -75,7 +75,7 @@ cumo_cuda_cudnn_check_input(VALUE in, VALUE type, size_t ndim, size_t *shape)
75
75
  {
76
76
  cumo_narray_t *na;
77
77
 
78
- CUMO_CHECK_NARRAY_TYPE(in, type);
78
+ CUMO_CHECK_NARRAY_TYPE(in, type, "input");
79
79
  CumoGetNArray(in, na);
80
80
  CUMO_CHECK_DIM_EQ((size_t)(na->ndim), ndim);
81
81
  for (size_t idim = 0; idim < ndim; ++idim) {
@@ -88,10 +88,7 @@ cumo_cuda_cudnn_check_input(VALUE in, VALUE type, size_t ndim, size_t *shape)
88
88
  static inline void
89
89
  cumo_cuda_cudnn_check_param_type(VALUE param, VALUE type, const char* name)
90
90
  {
91
- if (rb_obj_class(param) != type) {
92
- rb_raise(rb_eTypeError, "%s must be %s, not %s",
93
- name, rb_class2name(type), rb_obj_classname(param));
94
- }
91
+ CUMO_CHECK_NARRAY_TYPE(param, type, name);
95
92
  }
96
93
 
97
94
  void
@@ -176,6 +176,22 @@ cumo_na_make_iarray_stridx(cumo_na_loop_args_t* arg)
176
176
  return iarray;
177
177
  }
178
178
 
179
+ // Whether any argument of the loop is backed by an index array. With
180
+ // INDEX_LOOP an operand can arrive carrying one, and cumo_na_iarray_t holds
181
+ // byte steps only: an indexed dimension has a step of zero there, so a loop
182
+ // that answers yes has to address its operands through the stridx form above.
183
+ static int
184
+ cumo_na_loop_has_index(cumo_na_loop_t *const lp)
185
+ {
186
+ int j, i;
187
+ for (j = 0; j < lp->narg; ++j) {
188
+ for (i = 0; i < lp->args[j].ndim; ++i) {
189
+ if (lp->args[j].iter[i].idx) return 1;
190
+ }
191
+ }
192
+ return 0;
193
+ }
194
+
179
195
  static cumo_na_bit_iarray_stridx_t
180
196
  cumo_na_make_bit_iarray_stridx(cumo_na_loop_args_t* arg)
181
197
  {
@@ -54,6 +54,8 @@ void cumo_na_release_lock(VALUE); // currently do nothing
54
54
  // used in reduce methods
55
55
  VALUE cumo_na_reduce_dimension(int argc, VALUE *argv, int naryc, VALUE *naryv,
56
56
  cumo_ndfunc_t *ndf, cumo_na_iter_func_t nan_iter);
57
+ VALUE cumo_na_get_reduce(VALUE v);
58
+ void cumo_na_set_reduce(VALUE v, VALUE reduce);
57
59
 
58
60
  VALUE cumo_na_reduce_options(VALUE axes, VALUE *opts, int naryc, VALUE *naryv,
59
61
  cumo_ndfunc_t *ndf);