cumo 0.9.0 → 0.10.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 (153) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +404 -13
  4. data/bench/cumo_probe.rb +6 -2
  5. data/bench/cumo_sync_probe.rb +1 -1
  6. data/cumo.gemspec +1 -1
  7. data/ext/cumo/cuda/cublas.c +6 -1
  8. data/ext/cumo/cuda/cudnn.c +5 -1
  9. data/ext/cumo/cuda/cudnn_impl.cpp +29 -3
  10. data/ext/cumo/cuda/driver.c +158 -1
  11. data/ext/cumo/cuda/memory_pool.cpp +3 -7
  12. data/ext/cumo/cuda/nvrtc.c +42 -0
  13. data/ext/cumo/cuda/runtime.c +627 -1
  14. data/ext/cumo/cumo.c +43 -12
  15. data/ext/cumo/include/cumo/bit_reduce_kernel.h +8 -8
  16. data/ext/cumo/include/cumo/cuda/cudnn.h +14 -5
  17. data/ext/cumo/include/cumo/cuda/runtime.h +77 -3
  18. data/ext/cumo/include/cumo/intern.h +8 -1
  19. data/ext/cumo/include/cumo/ndloop.h +21 -0
  20. data/ext/cumo/include/cumo/reduce_kernel.h +14 -14
  21. data/ext/cumo/include/cumo/row_kernel.h +2 -2
  22. data/ext/cumo/include/cumo/template_kernel.h +4 -1
  23. data/ext/cumo/include/cumo/types/f16_macro.h +23 -1
  24. data/ext/cumo/include/cumo/types/f16_macro_kernel.h +23 -1
  25. data/ext/cumo/include/cumo/types/float_macro.h +23 -1
  26. data/ext/cumo/include/cumo/types/float_macro_kernel.h +23 -1
  27. data/ext/cumo/include/cumo.h +9 -2
  28. data/ext/cumo/narray/data.c +25 -2
  29. data/ext/cumo/narray/data_kernel.cu +23 -23
  30. data/ext/cumo/narray/gen/def/bfloat.rb +0 -2
  31. data/ext/cumo/narray/gen/def/bit.rb +0 -3
  32. data/ext/cumo/narray/gen/def/dcomplex.rb +1 -3
  33. data/ext/cumo/narray/gen/def/dfloat.rb +1 -3
  34. data/ext/cumo/narray/gen/def/hfloat.rb +0 -2
  35. data/ext/cumo/narray/gen/def/int16.rb +0 -3
  36. data/ext/cumo/narray/gen/def/int32.rb +0 -3
  37. data/ext/cumo/narray/gen/def/int64.rb +0 -3
  38. data/ext/cumo/narray/gen/def/int8.rb +0 -3
  39. data/ext/cumo/narray/gen/def/robject.rb +0 -3
  40. data/ext/cumo/narray/gen/def/scomplex.rb +1 -3
  41. data/ext/cumo/narray/gen/def/sfloat.rb +1 -3
  42. data/ext/cumo/narray/gen/def/uint16.rb +0 -3
  43. data/ext/cumo/narray/gen/def/uint32.rb +0 -3
  44. data/ext/cumo/narray/gen/def/uint64.rb +0 -3
  45. data/ext/cumo/narray/gen/def/uint8.rb +0 -3
  46. data/ext/cumo/narray/gen/narray_def.rb +7 -0
  47. data/ext/cumo/narray/gen/spec.rb +2 -0
  48. data/ext/cumo/narray/gen/tmpl/aref_cpu.c +8 -0
  49. data/ext/cumo/narray/gen/tmpl/binary2_kernel.cu +2 -2
  50. data/ext/cumo/narray/gen/tmpl/binary_kernel.cu +4 -4
  51. data/ext/cumo/narray/gen/tmpl/binary_s_kernel.cu +2 -2
  52. data/ext/cumo/narray/gen/tmpl/bincount_kernel.cu +4 -4
  53. data/ext/cumo/narray/gen/tmpl/clip_kernel.cu +6 -6
  54. data/ext/cumo/narray/gen/tmpl/cond_binary_kernel.cu +2 -2
  55. data/ext/cumo/narray/gen/tmpl/cond_unary_kernel.cu +2 -2
  56. data/ext/cumo/narray/gen/tmpl/conv.c +3 -2
  57. data/ext/cumo/narray/gen/tmpl/conv_grad_w.c +3 -2
  58. data/ext/cumo/narray/gen/tmpl/conv_transpose.c +3 -2
  59. data/ext/cumo/narray/gen/tmpl/cum.c +88 -10
  60. data/ext/cumo/narray/gen/tmpl/cum_kernel.cu +179 -17
  61. data/ext/cumo/narray/gen/tmpl/each.c +4 -3
  62. data/ext/cumo/narray/gen/tmpl/each_with_index.c +4 -3
  63. data/ext/cumo/narray/gen/tmpl/ewcomp_kernel.cu +2 -2
  64. data/ext/cumo/narray/gen/tmpl/extract_cpu.c +8 -0
  65. data/ext/cumo/narray/gen/tmpl/extract_data.c +8 -0
  66. data/ext/cumo/narray/gen/tmpl/eye_kernel.cu +1 -1
  67. data/ext/cumo/narray/gen/tmpl/fill_kernel.cu +2 -2
  68. data/ext/cumo/narray/gen/tmpl/format.c +2 -2
  69. data/ext/cumo/narray/gen/tmpl/format_to_a.c +2 -2
  70. data/ext/cumo/narray/gen/tmpl/frexp_kernel.cu +2 -2
  71. data/ext/cumo/narray/gen/tmpl/gemm.c +21 -28
  72. data/ext/cumo/narray/gen/tmpl/inspect.c +0 -1
  73. data/ext/cumo/narray/gen/tmpl/logseq_kernel.cu +2 -2
  74. data/ext/cumo/narray/gen/tmpl/map_with_index.c +5 -1
  75. data/ext/cumo/narray/gen/tmpl/median.c +9 -4
  76. data/ext/cumo/narray/gen/tmpl/new_dim0_kernel.cu +1 -1
  77. data/ext/cumo/narray/gen/tmpl/poly_kernel.cu +3 -3
  78. data/ext/cumo/narray/gen/tmpl/pow_kernel.cu +4 -4
  79. data/ext/cumo/narray/gen/tmpl/rand.c +16 -15
  80. data/ext/cumo/narray/gen/tmpl/rand_kernel.cu +19 -24
  81. data/ext/cumo/narray/gen/tmpl/rand_norm.c +7 -25
  82. data/ext/cumo/narray/gen/tmpl/rand_norm_kernel.cu +19 -24
  83. data/ext/cumo/narray/gen/tmpl/seq_kernel.cu +2 -2
  84. data/ext/cumo/narray/gen/tmpl/set2_kernel.cu +2 -2
  85. data/ext/cumo/narray/gen/tmpl/softmax_kernel.cu +3 -3
  86. data/ext/cumo/narray/gen/tmpl/sort.c +7 -4
  87. data/ext/cumo/narray/gen/tmpl/store.c +8 -0
  88. data/ext/cumo/narray/gen/tmpl/store_array.c +3 -2
  89. data/ext/cumo/narray/gen/tmpl/store_array_kernel.cu +4 -4
  90. data/ext/cumo/narray/gen/tmpl/store_bit_kernel.cu +2 -2
  91. data/ext/cumo/narray/gen/tmpl/store_from_kernel.cu +4 -4
  92. data/ext/cumo/narray/gen/tmpl/to_a.c +1 -2
  93. data/ext/cumo/narray/gen/tmpl/unary2_kernel.cu +2 -2
  94. data/ext/cumo/narray/gen/tmpl/unary_kernel.cu +2 -2
  95. data/ext/cumo/narray/gen/tmpl/unary_ret2_kernel.cu +2 -2
  96. data/ext/cumo/narray/gen/tmpl/unary_s_kernel.cu +2 -2
  97. data/ext/cumo/narray/gen/tmpl_bit/aref_cpu.c +3 -2
  98. data/ext/cumo/narray/gen/tmpl_bit/binary_kernel.cu +4 -4
  99. data/ext/cumo/narray/gen/tmpl_bit/bit_count.c +6 -5
  100. data/ext/cumo/narray/gen/tmpl_bit/bit_reduce.c +23 -21
  101. data/ext/cumo/narray/gen/tmpl_bit/each.c +4 -3
  102. data/ext/cumo/narray/gen/tmpl_bit/each_with_index.c +4 -3
  103. data/ext/cumo/narray/gen/tmpl_bit/extract_cpu.c +1 -2
  104. data/ext/cumo/narray/gen/tmpl_bit/fill_kernel.cu +3 -3
  105. data/ext/cumo/narray/gen/tmpl_bit/format.c +2 -3
  106. data/ext/cumo/narray/gen/tmpl_bit/format_to_a.c +2 -3
  107. data/ext/cumo/narray/gen/tmpl_bit/inspect.c +1 -2
  108. data/ext/cumo/narray/gen/tmpl_bit/mask_kernel.cu +3 -3
  109. data/ext/cumo/narray/gen/tmpl_bit/store_array.c +3 -2
  110. data/ext/cumo/narray/gen/tmpl_bit/store_array_kernel.cu +5 -5
  111. data/ext/cumo/narray/gen/tmpl_bit/store_bit_kernel.cu +4 -4
  112. data/ext/cumo/narray/gen/tmpl_bit/store_from_kernel.cu +2 -2
  113. data/ext/cumo/narray/gen/tmpl_bit/to_a.c +2 -3
  114. data/ext/cumo/narray/gen/tmpl_bit/unary.c +6 -0
  115. data/ext/cumo/narray/gen/tmpl_bit/unary_kernel.cu +4 -4
  116. data/ext/cumo/narray/gen/tmpl_bit/where.c +2 -2
  117. data/ext/cumo/narray/gen/tmpl_bit/where_kernel.cu +4 -4
  118. data/ext/cumo/narray/index.c +3 -2
  119. data/ext/cumo/narray/index_kernel.cu +13 -13
  120. data/ext/cumo/narray/narray.c +130 -41
  121. data/ext/cumo/narray/ndloop.c +322 -23
  122. data/ext/cumo/narray/ndloop_kernel.cu +18 -18
  123. data/ext/cumo/narray/sort_kernel.cu +88 -40
  124. data/lib/cumo/cuda/compiler.rb +83 -30
  125. data/lib/cumo/cuda/device.rb +8 -1
  126. data/lib/cumo/cuda/elementwise_kernel.rb +131 -0
  127. data/lib/cumo/cuda/event.rb +68 -0
  128. data/lib/cumo/cuda/function.rb +45 -0
  129. data/lib/cumo/cuda/module.rb +30 -3
  130. data/lib/cumo/cuda/nvrtc_program.rb +16 -1
  131. data/lib/cumo/cuda/pinned_memory.rb +93 -0
  132. data/lib/cumo/cuda/reduction_kernel.rb +279 -0
  133. data/lib/cumo/cuda/stream.rb +114 -0
  134. data/lib/cumo/cuda/user_kernel.rb +169 -0
  135. data/lib/cumo/cuda.rb +6 -0
  136. data/lib/cumo/narray/extra.rb +30 -1
  137. data/test/bit_test.rb +233 -7
  138. data/test/cuda/compiler_test.rb +84 -0
  139. data/test/cuda/device_test.rb +42 -0
  140. data/test/cuda/elementwise_kernel_test.rb +184 -0
  141. data/test/cuda/function_test.rb +162 -0
  142. data/test/cuda/module_test.rb +15 -0
  143. data/test/cuda/name_expression_test.rb +172 -0
  144. data/test/cuda/pinned_memory_test.rb +138 -0
  145. data/test/cuda/reduction_kernel_test.rb +189 -0
  146. data/test/cuda/runtime_test.rb +21 -0
  147. data/test/cuda/stream_test.rb +246 -0
  148. data/test/cudnn_test.rb +54 -0
  149. data/test/cumo_test.rb +47 -0
  150. data/test/math_test.rb +163 -1
  151. data/test/narray_alt_coverage_test.rb +117 -1
  152. data/test/narray_test.rb +577 -0
  153. metadata +22 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f27ebdaa195fa967c144d6178ad3f9fbeca551162f8edacac205d5edd5d0fb6
4
- data.tar.gz: 3d87faa55ab7c4c0e52bee3afdb0116d9d463fa778c408a46e200bff0cabdf81
3
+ metadata.gz: 78444d61470bd2cf534dfadba1facef1d92785cf85db498ca14d893242532591
4
+ data.tar.gz: 7bb662f78bd6e130c5a93db7fe6ac44fc2dad14f58f3788de48c04f7830197b4
5
5
  SHA512:
6
- metadata.gz: 4b498a760e59725c15aa3e651ecbada01e52517833fd3132a860dbfe459e354f4afba47207cc33f2c94963f58f19a8a1d7e52ce722e51065876d637c8ddfc94b
7
- data.tar.gz: bbda42b2ef7ce7ea882982a015bec06ff058ad42a2b72e4c8953e75facfcb64657c8da4b69d63666cf4c06d3c265dc7e0acf54c431450f181b39cbeebed9560e
6
+ metadata.gz: 46d001da3c8c82e4f835fe6643cec1a1f5a4b28864283286a52f98887d975673914ee475ecd90d97042e5c16e45c525060d595ecd8301a427fed5060a8efc7b0
7
+ data.tar.gz: ae0052bea91487469a2dd6052c08f9bb8c74799447ea165d189babc88ccd94b7c686e50d1ff69f1df75ec2d20986c7113ff2d3b949205c4ac72132f7b8d468af
data/CHANGELOG.md CHANGED
@@ -1,3 +1,54 @@
1
+ # 0.10.0 (2026/09/22)
2
+
3
+ Breaking changes:
4
+
5
+ * `CUMO_COMPATIBLE_MODE`, `CUMO_SHOW_WARNING`, `CUMO_SHOW_WARNING_ONCE` and `CUMO_MEMORY_POOL` read `off`, `false` and `no` as a no, in any case, where anything but `OFF`, `0` and `NO` used to turn the flag on without a word. A value they do not recognise keeps the flag's default and warns, and an empty value reads as unset (PR #514)
6
+ * An empty array keeps its shape through everything that goes through ndloop: `Cumo::Int32.new(0, 3).copy` and `(a + 1)` answer `[0, 3]` where they answered `[0, 0]`. `to_a` nests down to the first zero and no further, the way numpy's `tolist` does, so `(3, 0)` is `[[], [], []]` and `(0, 3, 4)` is `[]` (PR #513)
7
+ * `Marshal.dump` of a subclass of `Cumo::RObject` writes its objects, where it wrote the raw addresses the buffer holds. Data the old dump wrote for such a subclass no longer loads, and what it held was addresses from a process that has since exited (PR #509)
8
+
9
+ Fixes:
10
+
11
+ * Fix `all?(axis: [])` answering a zero-dimensional `Cumo::Bit`, which is truthy whatever it holds, for an array it is false for. Whether an axis was named is now read off the reduction rather than off the list (PR #512)
12
+ * Fix `all?`, `any?` and `none?` answering a zero-dimensional `Cumo::Bit` when a default keyword such as `axis: nil` or `keepdims: false` is written out, so `Cumo::Bit[1, 1, 0].all?(axis: nil)` read as true (PR #510)
13
+ * Fix an axis reduction of an empty `Cumo::Bit` answering an empty array: `Cumo::Bit.new(0, 3).all?(axis: 0)` answers `[1, 1, 1]` and `count_true(axis: 0)` answers `[0, 0, 0]` (PR #508)
14
+ * Fix the five `Cumo::Bit` reductions answering false, or an Integer, for an empty array before reading their arguments. `Cumo::Bit[].all?` is true and `any?` false, as Ruby's are, `count_true` answers a `UInt64`, and an axis that is not there raises `DimensionError` (PR #507)
15
+ * Fix `CastError` for a subclass of a dtype, which broke `store`, arithmetic and the reductions, and put an element of one into an RObject array as one object repeated (PR #506)
16
+ * Fix `Device#with` running its block on the device it was already on, whichever device it was given (PR #499)
17
+ * Fix `reshape` writing the new shape over whatever the receiver's `dup` answered, which could leave an array claiming a size its buffer cannot hold. The answer `dup` gives has to be the same class, own its data and not be frozen (PR #494)
18
+ * Fix `gemm` walking a copy of a non-contiguous operand with the original's dimensions when `dup` answered something else (PR #493)
19
+ * Fix `marshal_dump` and `to_binary` handing out memory past the end of the array when a view's `dup` answered another shape, class or layout (PR #492)
20
+ * Fix `median` and `sort` ignoring the axes a view marked with `:sum`, so `f[:sum, true].median` answered the whole array. `median` keeps its receiver rather than standing its copy in its place, and no longer copies an operand it only reads, so the pool holds less afterwards (PR #489, PR #488, PR #487)
21
+ * Fix `Cumo::Bit#copy` answering its own receiver when the receiver is inplace, which let a write to the copy reach the original and gave every reduction that copies an index-backed view first the wrong rows (PR #486)
22
+ * Fix the interpreter abort when `all?` or `any?` reduces a `[:sum, true]` view (PR #485)
23
+
24
+ Changes:
25
+
26
+ * `compile_with_cache` answers the same module for the same source, options and device for the rest of the process, so it can be called wherever the kernel is launched: a repeated call costs 0.0009 ms where it read the cubin back and loaded a new module for 0.033 ms, nine times the launch itself. A module nobody holds any more is unloaded when Ruby collects it, and `Cumo::CUDA::Compiler.clear_modules` makes the next call read the disk cache or compile again (PR #531)
27
+ * The ports of the CuPy examples live in `examples/`, one Ruby file per original, with `backend.rb` running the CPU side on Numo and the GPU side on Cumo under `GPU=1`. The gem leaves them out (PR #530)
28
+ * Drop a dead first definition of `Compiler#save_cache` that `ruby -W` warned about (PR #529)
29
+ * Reading a result back from the device copies the range it needs into pinned host memory rather than reading managed memory in place, which faulted the page over and back once per read where a small block shares its page with others: `Float(r.sum)` takes 0.012 ms where it took 0.885, `to_a` of eight elements 0.016 ms where it took 0.97, and `to_binary` of 8 MB 2.3 ms where it took 7.6. `to_a`, `each`, `each_with_index`, `map_with_index`, `inspect`, `format`, `extract_cpu`, `aref_cpu`, `to_binary` and the RObject marshal all take the copy, and a block that writes the array it is walking still sees what it wrote (PR #528)
30
+ * `rand` and `rand_norm` fill an array whose last axis has length one in one kernel launch rather than one per row: a `[1048576, 1]` column takes 0.018 ms where it took 1799. A seed produces the same values as before whatever the layout (PR #527)
31
+ * A module answers a template kernel such as `kernel<float>` by its name expression: `NVRTCProgram.new` and `compile_with_cache` take `name_expressions:`, the mangled names are cached beside the cubin, and `Module#get_function` looks an expression up before asking the driver. Arrays of structs are arrays whose trailing axes are the fields, and a struct by value is a packed String. The cubin cache had been writing empty files where `/tmp` and the cache directory are different file systems, so every `compile_with_cache` compiled again; it now writes next to its place (PR #526)
32
+ * Add `Cumo::CUDA::PinnedMemory`, a page-locked host buffer read and written as bytes, and `NArray#set` and `NArray#get`, which copy through it asynchronously on the current stream or on `stream:` the way `cupy.ndarray.set` and `get` do. A copy in flight keeps both ends until it is done (PR #525)
33
+ * Add `Cumo::CUDA::Stream` and `Cumo::CUDA::Event`, after `cupy.cuda`: `Stream.new`, `with`, `use`, `synchronize`, `record`, `wait_event`, `Stream.null`, `Stream.current`, `Event.new`, `record`, `synchronize`, `done?` and `Cumo::CUDA.get_elapsed_time`, and `Function#launch` takes `stream:`. `Stream#with` orders the block after what the previous stream had queued and waits for the stream on the way out (PR #524)
34
+ * Every kernel, copy, cuBLAS and cuDNN call, thrust and CUB call goes to the current stream of the thread, which is the null stream until a `Stream` is used, so a stream a caller picks reaches all of it. `Runtime.cudaStreamCreateWithFlags`, `cudaStreamDestroy`, `cudaStreamSynchronize` and `current_stream=` are bound (PR #523)
35
+ * Add `Runtime.cudaDeviceCanAccessPeer` and `Device#can_access_peer?`. A CUDA error raised for a device that is not there no longer stays as the runtime's last error for the next kernel launch to read as its own (PR #521)
36
+ * The CI ccache ceiling is 2 GB, since one build no longer fit in 1 GB and the CUDA 12 jobs compiled again what had been evicted (PR #522)
37
+ * Add `Cumo::CUDA::ReductionKernel`, a reduction written as a map, a reduce and a post expression with an identity, called with `axis:` and `keepdims:`, after CuPy's. This is the last half of #106 (PR #520)
38
+ * Add `Cumo::CUDA::ElementwiseKernel`, a kernel written as one expression over a parameter list, with one-letter types resolved from the arguments and broadcasting, compiled once per set of types, after CuPy's. Bit, half precision and complex arguments are refused for now (PR #519)
39
+ * A kernel compiled with NVRTC can be launched: `Module#get_function` answers a `Cumo::CUDA::Function` whose `launch` takes the arguments, the grid, the block and the dynamic shared memory. An NArray hands over its device pointer, an Integer goes as a `long long`, a Float as a `double`, and a String as its bytes. On CUDA 12.4 or later the count and the sizes of the arguments are checked against the kernel before the launch (PR #518, PR #500)
40
+ * `CUMO_ALLOW_TF32` puts single precision `gemm` on the tensor cores as well as convolutions: a `[4096, 4096]` SFloat gemm goes from 17.8 to 28.4 TFLOP/s, and its answer moves from 4e-07 to 3e-04 of a double precision reference. Off, which is the default, answers bit for bit what it did. The flag is read in `cumo.c` as `Cumo.allow_tf32?` rather than in `cudnn.c`, so a build without cuDNN has it too (PR #517, PR #516)
41
+ * Every dtype's `gemm` goes through `cublasGemmStridedBatchedEx`, the entry point that can name a compute type. The answers are the same bytes, and no change in speed could be told apart from the noise (PR #515)
42
+ * `all?`, `any?` and `none?` on an empty `Cumo::Bit` with no axis named answer the identity without reserving anything or waiting on the device: 0.045 us a call where it took 12.3 and 512 bytes (PR #511)
43
+ * `sort_index`, and `sort` and `median` over a view, address by rank rather than through the run-time accessor: `sort_index` over a 4096 by 1024 SFloat takes 2.2 ms where it took 5.6, and an inplace `sort` over a column slice 2.0 ms where it took 6.5 (PR #505)
44
+ * `cumsum` and `cumprod` along an axis send every row to one scan keyed on its row rather than starting a scan per row, and a view no single stride reaches is gathered by a kernel that addresses by rank: over a `[512, 2048]` SFloat a column slice takes 25.7 us where 0.9.0 took 50.7, and a transposed view 45.2 us where it took 308.3. A reversed view is read through its stride and costs what a flat one does (PR #504, PR #503, PR #498, PR #496)
45
+ * The default cuDNN workspace ceiling is 128 MB rather than 8 MB, which reaches the faster convolution algorithms: the twenty convolutions of a ResNet-18 forward pass at batch 16 take 5.23 ms where they took 9.80, and a half precision convolution reaches its tensor core algorithms. The search reserves the whole ceiling while it runs, so the peak grows with it and goes back to the pool afterwards (PR #502)
46
+ * A single precision convolution stays off the tensor cores unless `CUMO_ALLOW_TF32` says yes, where cuDNN's default let the algorithm search move it there and round the operands to a 10 bit significand (PR #501)
47
+ * Say in the README that `contiguous?` answers true for a view whose length-one axis Numo counts as breaking contiguity, that copying a transposed operand beats handing it to cuBLAS as it is for most shapes, what the memory numbers of the pool and of `nvidia-smi` mean, where a bfloat16 integer index goes past 256, and that `quantize_symmetric` breaks ties away from zero where numpy and torch round to even (PR #497, PR #495, PR #491, PR #484)
48
+ * Say what routing `silu` through `sigmoid` would change, rather than a fraction that no dtype matches (PR #483)
49
+ * Add `Cumo::NMath.sigmoid`, which keeps a value where `1 / (1 + exp(-x))` written out gives out: single reaches zero at -104 rather than -89, double at -745 rather than -710 (PR #482)
50
+ * Add `Cumo::NMath.softplus`, which does not overflow where `log(1 + exp(x))` written out does, and answers `x` to the last bit past 11.09 in half and 88.7 in single (PR #481)
51
+
1
52
  # 0.9.0 (2026/09/19)
2
53
 
3
54
  Breaking changes:
data/README.md CHANGED
@@ -121,7 +121,7 @@ a[0] < 1.0 #=> Cumo::Bit#shape=[] holding 0
121
121
  `assert_operator(a[0], :<, 1.0)` passes for the same reason, so a test suite written for Numo can stay green against Cumo while asserting nothing.
122
122
  Read the value back to the host before branching on it, or run under `compatible_mode`.
123
123
 
124
- Set the `CUMO_COMPATIBLE_MODE` environment variable to `ON` to force Numo NArray compatibility (for worse performance).
124
+ Set the `CUMO_COMPATIBLE_MODE` environment variable to `ON` to force Numo NArray compatibility (for worse performance). Every such flag takes `1`, `on`, `yes` or `true` for a yes and `0`, `off`, `no` or `false` for a no, in any case; anything else keeps the default and warns.
125
125
  Running a Numo test suite that way keeps its assertions meaningful.
126
126
 
127
127
  You may enable or disable `compatible_mode` as:
@@ -149,6 +149,36 @@ Float(a.sum) #=> 7.0 in either mode
149
149
  They are methods on an NArray, so chaining one onto a result that `compatible_mode` has already turned into a Ruby object, as in `a.sum.extract_cpu`, raises `NoMethodError` while the mode is on.
150
150
  `Kernel#Float` and `Kernel#Integer` read either representation, and read Numo's too, so they are what code that runs against both libraries wants.
151
151
 
152
+ #### A Length-One Axis Does Not Break Contiguity
153
+
154
+ `contiguous?` answers true for some views Numo calls false.
155
+ An axis of length one is only ever indexed at zero, so whatever stride it carries is multiplied by zero and never moves the pointer.
156
+ Cumo leaves such an axis out of the chain it walks; Numo does not, and calls the view strided because of an axis that cannot stride.
157
+
158
+ ```ruby
159
+ a = Cumo::DFloat.new(1, 4).seq
160
+ a[true, 0...2].contiguous? #=> true, where Numo gives false
161
+ a[true, 0...2].to_a #=> [[0.0, 1.0]], the same either way
162
+
163
+ Cumo::DFloat.new(2, 4).seq[true, 0...2].contiguous? #=> false in both
164
+ ```
165
+
166
+ The elements the view holds are the same in either library.
167
+ What changes is who is willing to read them where they lie:
168
+
169
+ * `reshape!` is accepted on these views, where Numo raises
170
+ * `dot`, `gemm`, `conv` and the rest take them as they are, instead of copying them into a contiguous array first
171
+
172
+ A three-dimensional slice behaves the same way, and so does the transpose of a single row:
173
+
174
+ ```ruby
175
+ Cumo::DFloat.new(1, 1, 4).seq[true, true, 0...2].contiguous? #=> true
176
+ Cumo::DFloat.new(1, 3).seq.transpose.contiguous? #=> true
177
+ ```
178
+
179
+ Code that runs against both libraries should not read `contiguous?` and expect the same answer.
180
+ Where it wants a contiguous array it can ask for one, since `dup` answers one in either library.
181
+
152
182
  ### Keeping Scalars On The Device
153
183
 
154
184
  The 0-dimensional return is what lets an iterative loop stay on the GPU.
@@ -178,6 +208,10 @@ x += p_dir * alpha # and consumed there, without crossing the bus
178
208
 
179
209
  Read the value back once the loop is done, or every k iterations if it has to test something.
180
210
 
211
+ A read costs the wait and a copy of the block it needs into pinned host memory, a few microseconds for a scalar.
212
+ Reading managed memory from the host directly would fault its page over instead, and a small block shares a page with other live blocks that the next kernel touches, so a fresh scalar cost 0.8 ms that way on the machine above, forty times what the copy costs.
213
+ The reads that answer values, from `Float(x)` and `to_a` to `each` and `inspect`, take the copy.
214
+
181
215
  ### Reshape Copies, Reshape! Does Not
182
216
 
183
217
  `reshape` answers a copy of the whole array, never a view.
@@ -260,6 +294,39 @@ Cumo::SFloat::Math.atan2(a, 2.0) #=> Cumo::SFloat
260
294
 
261
295
  The 0-dimensional form has no effect under Numo, where `[]` returns a Ruby Float.
262
296
 
297
+ ### A Transposed Operand Goes To cuBLAS As It Is
298
+
299
+ `dot` and `gemm` hand a transposed operand to cuBLAS with its transpose flag set, rather than copying it into the layout cuBLAS reads fastest.
300
+ That saves the copy and the memory it needs.
301
+ What it costs is the kernel cuBLAS then picks, which for most shapes is slower than the one it picks for an operand already laid out its way.
302
+
303
+ RTX 5070 Ti Laptop, `Cumo::SFloat`, `q[M,K].dot(k[N,K].transpose)`, medians of nine rounds:
304
+
305
+ ```
306
+ M K N M*K as it is copied first the copy
307
+ 1 64 1500 64 5.6 us 11.4 us 2.8 us
308
+ 512 64 512 32,768 8.5 11.2 2.9
309
+ 1500 64 1500 96,000 33.0 29.3 3.0
310
+ 512 256 512 131,072 21.8 20.7 2.8
311
+ 4096 64 4096 262,144 229.1 179.3 4.2
312
+ 512 768 512 393,216 54.0 39.2 3.3
313
+ 256 3072 768 786,432 131.1 103.0 12.5
314
+ ```
315
+
316
+ The copy weighs `N * K`, and the faster kernel it buys is worth `M * N * K`, so `M` is what decides.
317
+ A matrix-vector product, where `M` is one, is the clearest case against copying: it takes twice as long that way.
318
+ Past an `M * K` of roughly fifty thousand on this card the copy starts paying for itself, and past a few hundred thousand it is worth a fifth of the time.
319
+
320
+ Where a profile says one of these multiplications matters, hand it an operand that is already contiguous:
321
+
322
+ ```ruby
323
+ kt = k.transpose.dup # or build k transposed in the first place
324
+ q.dot(kt)
325
+ ```
326
+
327
+ The table above is two-dimensional, where cuBLAS is given one matrix.
328
+ A batched multiplication takes another path through the same flag, and these numbers do not cover it.
329
+
263
330
  ### Fused Operations
264
331
 
265
332
  `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.
@@ -274,6 +341,16 @@ xq, scale = x.quantize_symmetric # scale = max(|x|) / 127, xq = round(
274
341
 
275
342
  `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
343
 
344
+ The rounding takes a tie away from zero, which is the rule `round` follows here and the rule Ruby's `Float#round` follows. numpy and torch take a tie to the nearest even value instead, so code ported from either answers differently wherever the quotient lands exactly halfway.
345
+
346
+ ```
347
+ x / scale -2.5 -1.5 -0.5 0.5 1.5 2.5
348
+ quantize_symmetric -3 -2 -1 1 2 3
349
+ numpy, torch -2 -2 0 0 2 2
350
+ ```
351
+
352
+ `rint` is the other rule, at the cost of writing the quantization out: `(x / scale[false, :new]).rint.clip(-127, 127)` answers what numpy answers. A tie needs `x` to be an exact odd multiple of half the scale, so whether one ever comes up is a property of the data rather than of the arithmetic. Over a million random single-precision elements the two spellings disagreed four times, and every disagreement was a tie.
353
+
277
354
  On an RTX 5070 Ti Laptop, against the same arithmetic spelled with operators, in microseconds:
278
355
 
279
356
  ```
@@ -363,7 +440,7 @@ runs out of significant digits and answers zero, and `-Float::INFINITY` answers
363
440
 
364
441
  ### SiLU
365
442
 
366
- `Cumo::NMath.silu` is `x * sigmoid(x)`, the activation Llama and the models after it use where GPT-2 uses `gelu_tanh`.
443
+ `Cumo::NMath.silu` is `x / (1 + exp(-x))`, the activation Llama and the models after it use where GPT-2 uses `gelu_tanh`. It is the curve `x * sigmoid(x)` names, written as one division, and the two spellings do not answer alike: see Sigmoid below.
367
444
  It is also called Swish.
368
445
 
369
446
  ```ruby
@@ -389,6 +466,86 @@ Where that zero starts depends on the type, and not on `exp` alone.
389
466
  Single and bfloat16 reach it at -89, where `exp(-x)` passes what a float holds, and double never does.
390
467
  Half reaches it at -21, because the round back to half gets there first.
391
468
 
469
+ ### Sigmoid
470
+
471
+ `Cumo::NMath.sigmoid` is `1 / (1 + exp(-x))`, the logistic curve.
472
+
473
+ ```ruby
474
+ Cumo::NMath.sigmoid(x) # in one kernel rather than four
475
+ ```
476
+
477
+ Written out of the operators it costs four launches, and one kernel runs 1.2x to 4.8x faster on an RTX 5070 Ti Laptop, in microseconds:
478
+
479
+ ```
480
+ sigmoid SFloat DFloat HFloat
481
+ elements fused written fused written fused written
482
+ 768 2.9 9.0 2.5 8.8 2.0 9.6
483
+ 786432 5.6 23.0 94.6 111.3 7.3 23.6
484
+ 16777216 343.5 1234.7 1978.5 2941.1 210.5 798.1
485
+ ```
486
+
487
+ The kernel keeps the exponent's argument negative, which the plain quotient does not: writing `1 / (1 + exp(-x))` out asks `exp` for a value it cannot hold once `x` is negative enough, and the quotient then answers a zero where the curve is still a number the type carries.
488
+
489
+ ```ruby
490
+ Cumo::NMath.sigmoid(Cumo::SFloat[-100.0]).to_a.first #=> 3.783506e-44
491
+ (1.0 / (1.0 + Cumo::NMath.exp(Cumo::SFloat[100.0]))).to_a.first #=> 0.0
492
+ ```
493
+
494
+ Where each type reaches zero, written the one way and the other:
495
+
496
+ ```
497
+ sigmoid written out
498
+ Cumo::HFloat -17.5 -11.5
499
+ Cumo::BFloat -93.0 -89.0
500
+ Cumo::SFloat -104.0 -89.0
501
+ Cumo::DFloat -745.0 -710.0
502
+ ```
503
+
504
+ A non-negative `x` takes the plain quotient unchanged, bit for bit, so only the negative half moves. It moves toward the true value more often than away, but not by much and not always: over the 360,000 single points between -88 and 0 the guarded spelling is closer at 90,423, further at 72,593 and the same at the rest, and both spellings pass one unit in the last place, the guarded one at 2,289 points and the plain one at 4,489, neither worse than three.
505
+
506
+ `silu` is `x / (1 + exp(-x))` rather than `x * sigmoid(x)`, one division rather than a division and a multiply. The second spelling rounds once more and moves a third of the answers, and it moves them in kind at the bottom: `silu` answers the signed zero torch answers below -89, where `x * sigmoid(x)` still carries a number. The two are written apart for that reason.
507
+
508
+ ### Softplus
509
+
510
+ `Cumo::NMath.softplus` is `log(1 + exp(x))`, the smooth positive part, and the function a selective state space model puts its step size through.
511
+
512
+ ```ruby
513
+ Cumo::NMath.softplus(x) # log(1 + exp(x)), in one kernel rather than three
514
+ ```
515
+
516
+ Written out of the operators it costs three launches, and one kernel runs 1.0x to 3.1x faster on an RTX 5070 Ti Laptop, in microseconds:
517
+
518
+ ```
519
+ softplus SFloat DFloat HFloat
520
+ elements fused written fused written fused written
521
+ 768 2.0 6.1 2.3 6.2 2.1 6.0
522
+ 786432 5.9 18.3 166.6 171.8 5.7 15.0
523
+ 16777216 344.2 939.5 3503.7 3938.2 206.2 591.2
524
+ ```
525
+
526
+ Double barely moves at the two larger sizes, the arithmetic rather than the launches being what it pays for there.
527
+
528
+ Writing it out also gives out earlier than the kernel does, because the intermediate is an array of the receiver's type:
529
+
530
+ ```ruby
531
+ x = Cumo::HFloat[22.26]
532
+ Cumo::NMath.log(1.0 + Cumo::NMath.exp(x)) #=> Infinity
533
+ Cumo::NMath.softplus(x) #=> 22.265625
534
+ ```
535
+
536
+ Half holds `exp(x)` only to 11.09 and single to 88.7, where softplus is `x` to the last bit. The kernel takes the single-precision `exp` whatever the type, and hands back `x` where even that has no value to give, so nothing overflows at either width.
537
+
538
+ It is not `log1p(exp(x))`, which is a different number: the sum is taken before the logarithm, as the definition reads and as the implementations this follows compute. `torch.nn.functional.softplus` takes the other spelling and switches to `x` above 20, so the two differ by about an ulp where both are finite.
539
+
540
+ Taking the sum first costs the other end. Once `exp(x)` falls under the type's epsilon the sum drops it, so softplus reaches zero while the true value is still a number:
541
+
542
+ ```ruby
543
+ Cumo::NMath.softplus(Cumo::SFloat[-17.0]) #=> 0.0, where the value is 4.1e-08
544
+ Cumo::NMath.softplus(Cumo::DFloat[-37.0]) #=> 0.0, where the value is 8.5e-17
545
+ ```
546
+
547
+ Single and both sixteen-bit types reach that zero at -17 and double at -37, and the error grows before it: 5.9% at -16 in single and 4.3% at -36 in double. `log1p` is what to reach for where a small negative `x` has to keep its digits.
548
+
392
549
  ### Half Precision
393
550
 
394
551
  `Cumo::HFloat`, also reachable as `Cumo::Float16`, holds IEEE binary16: one sign bit, five of exponent and ten of mantissa.
@@ -472,16 +629,19 @@ A 1x768 by 768x50257 gemv takes 0.211 ms with that odd 50257 and 0.205 ms with 5
472
629
  Pad the inner dimensions of a real matrix product; leave a gemv alone.
473
630
 
474
631
  `conv` is a different story, and worth reading before reaching for half in a network.
475
- cuDNN chooses its algorithm from the ones that fit in a scratch buffer, and the half algorithms that use the tensor cores ask for more than the default 8MB ceiling allows.
476
- Left at the default, a half convolution is no faster than a single-precision one.
632
+ cuDNN chooses its algorithm from the ones that fit in a scratch buffer, and the half algorithms that use the tensor cores ask for a lot of it.
633
+ The default ceiling reaches them; the 8MB one Cumo used to ship does not.
477
634
  N=32, C=K=64, 56x56, 3x3:
478
635
 
479
636
  ```
480
- HFloat SFloat
481
- CUMO_CUDNN_MAX_WORKSPACE_SIZE unset 1.13 ms 1.03 ms
482
- CUMO_CUDNN_MAX_WORKSPACE_SIZE=268435456 0.56 ms 0.68 ms
637
+ HFloat BFloat SFloat
638
+ CUMO_CUDNN_MAX_WORKSPACE_SIZE=8388608 1.27 ms 1.26 ms 1.19 ms
639
+ unset (128MB) 0.54 ms 0.60 ms 1.19 ms
640
+ CUMO_CUDNN_MAX_WORKSPACE_SIZE=268435456 0.52 ms 0.59 ms 0.59 ms
483
641
  ```
484
642
 
643
+ This shape's single-precision algorithm wants more than the default, which is the other half of the reason to look at the ceiling for a network that spends its time in `conv`.
644
+
485
645
  Tensor cores also want the channel counts to be multiples of eight, which the first layer of a network never satisfies.
486
646
  That layer is still faster in half, but for the other reason:
487
647
 
@@ -535,7 +695,7 @@ The two do not contain each other, so an expression mixing them promotes to `Cum
535
695
 
536
696
  Everything else promotes as `Cumo::SFloat` does, so an integer array or a Ruby Float mixed in stays bfloat16 while anything wider takes over.
537
697
 
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`.
698
+ `layer_norm`, `rms_norm`, `softmax`, `silu`, `softplus`, `sigmoid` and both spellings of `gelu` take it, and so do the reductions, `sort`, `median`, `cumsum`, `rand` and `dot`.
539
699
  The cuDNN methods take it too: `conv`, `conv_transpose`, `conv_grad_w`, `max_pool`, `avg_pool` and the three batch norm entries.
540
700
 
541
701
  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:
@@ -584,9 +744,169 @@ A `dot` on a pre-Ampere card is the one to expect trouble from.
584
744
  cuDNN reaches bfloat16 as `CUDNN_DATA_BFLOAT16`, and its own bfloat16 kernels want Ampere for the same reason cuBLAS does.
585
745
  A convolution is given `CUDNN_DATA_FLOAT` to accumulate in, so it passes 256 the way a reduction does.
586
746
  The batch norm parameters are `Cumo::SFloat`, the same as they are for `Cumo::HFloat`.
587
- The workspace ceiling matters here as much as it does for half: left at the default 8MB, a bfloat16 convolution is no faster than a single-precision one.
747
+ The workspace ceiling matters here as much as it does for half, and for the same reason.
588
748
  See [Raise the cuDNN workspace ceiling](#raise-the-cudnn-workspace-ceiling).
589
749
 
750
+ #### An index rounds here rather than saturating
751
+
752
+ [Half Precision](#half-precision) says an index has to be built in a wider type and cast afterwards. That holds here at 256 rather than 2048, and it goes wrong more quietly:
753
+
754
+ ```ruby
755
+ Cumo::BFloat[79800] #=> 79872.0
756
+ Cumo::HFloat[79800] #=> Infinity
757
+ ```
758
+
759
+ A binary16 hands back an infinity, which the next operation carries somewhere visible. A bfloat16 hands back a plausible integer, and whatever reads it goes on. Where the index is an angle, the answer comes back with the wrong sign:
760
+
761
+ ```ruby
762
+ Cumo::NMath.cos(Cumo::SFloat[79800]) #=> -0.9190999865531921
763
+ Cumo::NMath.cos(Cumo::SFloat[79872]) #=> 0.989012598991394
764
+ ```
765
+
766
+ Not every integer past 256 is lost, which is what makes this one hard to catch by sampling: `Cumo::BFloat[1000]` is exact, since 1000 is a multiple of 8.
767
+ What ends at 256 is that consecutive integers stay distinct.
768
+
769
+ ### Launching Your Own Kernel
770
+
771
+ A kernel written in CUDA C can be compiled with NVRTC and launched on the arrays.
772
+ It runs on the stream Cumo's own kernels use, so it sees the results of the operations issued before it and the operations after it see its.
773
+
774
+ ```ruby
775
+ source = <<~CUDA
776
+ extern "C" __global__ void axpy(float* y, const float* x, float a, int n) {
777
+ for (int i = blockIdx.x * blockDim.x + threadIdx.x; i < n; i += gridDim.x * blockDim.x) {
778
+ y[i] = a * x[i] + y[i];
779
+ }
780
+ }
781
+ CUDA
782
+ mod = Cumo::CUDA::Compiler.new.compile_with_cache(source)
783
+ axpy = mod.get_function("axpy")
784
+
785
+ x = Cumo::SFloat.new(1000).seq
786
+ y = Cumo::SFloat.ones(1000)
787
+ axpy.launch([y, x, [2.5].pack("f"), [1000].pack("l")], grid: 4, block: 256)
788
+ ```
789
+
790
+ `compile_with_cache` compiles once and keeps the cubin on disk, and within a process the same source and options answer the same module, so it can be called wherever the kernel is launched.
791
+ That module is shared by everything in the process that compiles the source, so unload it only when nothing else uses it; a module nobody holds any more is unloaded when Ruby collects it.
792
+ `Cumo::CUDA::Compiler.clear_modules` makes the next call read the disk cache or compile again.
793
+ An NArray argument hands over its device pointer, so it has to be contiguous.
794
+ An Integer is passed as a `long long` and a Float as a `double`.
795
+ Anything narrower, and a struct passed by value, goes as the packed bytes of a String: `[n].pack("l")` is an `int` and `[x].pack("f")` a `float`.
796
+ `grid` and `block` take one to three sizes each, and `shared_mem:` is the dynamic shared memory in bytes.
797
+ The kernel is asked for by the name in the source, so it is declared inside `extern "C"`, or asked for by its mangled name.
798
+ On CUDA 12.4 or later the count and the size of the arguments are checked against the kernel before the launch, so an Integer handed to an `int` is refused rather than read wrong.
799
+ A kernel may write any NArray it is handed, so a frozen one is refused, and one that has not been allocated yet is allocated on the way and holds whatever was there.
800
+
801
+ ### Streams And Events
802
+
803
+ Every kernel and copy Cumo issues goes to the current stream of the thread, which is the null stream until a `Cumo::CUDA::Stream` is used.
804
+ `Stream#with` runs a block on a stream of its own, waits for everything the block queued, and puts the previous stream back, so what the block produced is complete when it returns.
805
+ The block's work is ordered after what the previous stream had queued, so an input still being computed when the block starts is read complete.
806
+
807
+ ```ruby
808
+ s = Cumo::CUDA::Stream.new(non_blocking: true)
809
+ c = s.with { a.gemm(b) } # queued on s, and finished when with returns
810
+ ```
811
+
812
+ `Stream#use` makes a stream current without a block, `Stream.current` answers the current one and `Stream.null` the null stream.
813
+ `Stream#record` records an `Event` after everything queued so far, and `Stream#wait_event` makes what is queued after it wait for one, which is how two streams are ordered against each other.
814
+ `Event#synchronize` waits for an event on the host, and `Cumo::CUDA.get_elapsed_time(start, stop)` answers the milliseconds between two recorded events, which is how a kernel is timed without a device-wide wait.
815
+
816
+ ```ruby
817
+ start = Cumo::CUDA::Event.new.record
818
+ c = a.gemm(b)
819
+ stop = Cumo::CUDA::Event.new.record
820
+ stop.synchronize
821
+ Cumo::CUDA.get_elapsed_time(start, stop) # => milliseconds
822
+ ```
823
+
824
+ Under a stream of the caller's, a host read such as `to_a` or `each` waits for the whole device rather than for that stream, since what it reads may have been written on another one.
825
+ The current stream is per thread, and fibers of one thread share it.
826
+ A `Function#launch` takes `stream:` to launch on a stream other than the current one.
827
+
828
+ ### Pinned Host Memory
829
+
830
+ A copy between the host and the device is asynchronous only when the host side is page-locked.
831
+ `Cumo::CUDA::PinnedMemory` allocates such a buffer, and `NArray#set` and `NArray#get` copy through it on the current stream, or on `stream:`.
832
+
833
+ ```ruby
834
+ pinned = Cumo::CUDA::PinnedMemory.new(a.byte_size)
835
+ s = Cumo::CUDA::Stream.new(non_blocking: true)
836
+ s.with { a.get(pinned) } # queued on s, complete when with returns
837
+ b = Cumo::SFloat.from_binary(pinned.read, a.shape)
838
+
839
+ src = Cumo::CUDA::PinnedMemory.from_binary(bytes)
840
+ c.set(src, stream: s) # the host bytes reach c once s gets there
841
+ ```
842
+
843
+ `set` with a String and `get` with nothing to copy into are the synchronous `store_binary` and `to_binary`.
844
+ A pinned buffer is read and written as bytes with `read` and `write`, and the array has to be contiguous and of the buffer's size.
845
+ A copy in flight keeps its array alive and is waited for by `read`, `write`, `free` and the next copy, so the bytes read are the copy's.
846
+ Allocating a pinned buffer synchronizes the device, so a buffer is allocated once and reused rather than made for every transfer.
847
+
848
+ ### Structs And Template Kernels
849
+
850
+ An array of structs is an array whose trailing axes are the struct's fields, so a kernel that takes `const double3*` is handed a `Cumo::DFloat` of shape `[n, 3]`, and one that takes `const Matrix<float>*` an `SFloat` of shape `[n, 4, 4]`.
851
+ `SComplex` and `DComplex` are `float2` and `double2` to a kernel; `Bit`, which packs its elements, and `RObject` cannot be handed to one.
852
+ A struct passed by value is the packed bytes of its fields, and where the struct has padding, `pack`'s `@` places each field at the offset the device reports.
853
+
854
+ ```ruby
855
+ rhs = [x, y, z].pack("d3") # a double3 by value
856
+ sum_kernel.launch([lhs, rhs, out], grid: 1, block: n)
857
+ ```
858
+
859
+ A template kernel has no `extern "C"` name.
860
+ `compile_with_cache` takes `name_expressions:`, and the module then answers `get_function` for each expression by the mangled name NVRTC reports.
861
+
862
+ ```ruby
863
+ mod = Cumo::CUDA::Compiler.new.compile_with_cache(source, name_expressions: ["kernel<float>", "kernel<double>"])
864
+ mod.get_function("kernel<float>").launch([a, b, c.to_a.flatten.pack("f*"), out], grid: 1, block: n)
865
+ ```
866
+
867
+ ### Writing An Elementwise Kernel
868
+
869
+ `Cumo::CUDA::ElementwiseKernel` takes one piece of CUDA C and applies it to every element, the way CuPy's `ElementwiseKernel` does.
870
+ The kernel is compiled once for each set of dtypes, once more when a different argument is a number or is broadcast, and kept for the next call.
871
+
872
+ ```ruby
873
+ squared_diff = Cumo::CUDA::ElementwiseKernel.new(
874
+ "T x, T y", "T z", "z = (x - y) * (x - y)", "squared_diff")
875
+
876
+ x = Cumo::SFloat.new(2, 5).seq
877
+ y = Cumo::SFloat.new(5).seq
878
+ squared_diff.call(x, y) # => the (2, 5) array of squared differences
879
+ squared_diff.call(x, 5) # => the same against a scalar
880
+ ```
881
+
882
+ A type is one of `float64`, `float32`, `int64`, `int32`, `int16`, `int8`, `uint64`, `uint32`, `uint16` and `uint8`, or a single letter that stands for whichever dtype the argument has.
883
+ Outputs decide a letter before inputs do, and a letter that only a Ruby number reaches becomes `int64` or `float64`.
884
+ Array arguments are broadcast against each other, and an output may be given after the inputs, or is allocated.
885
+ An argument marked `raw T y` is handed over as a pointer for the operation to index itself, with `i` the element index and `_ind.size()` the element count, and when every argument is raw or a number, `size:` says how many elements there are.
886
+ Inputs are `const`, so an operation that writes one does not compile, and a number handed to an integer type has to be an Integer that fits.
887
+ `preamble:` is placed before the kernel, after the typedefs of the letters, so a device function can be written in terms of `T`.
888
+ `Cumo::Bit`, `Cumo::HFloat`, `Cumo::BFloat`, `Cumo::SComplex`, `Cumo::DComplex` and `Cumo::RObject` cannot be handed to one of these kernels yet.
889
+
890
+ ### Writing A Reduction Kernel
891
+
892
+ `Cumo::CUDA::ReductionKernel` reduces along axes with three expressions, the way CuPy's `ReductionKernel` does: a map applied to every element, a reduce between two mapped values `a` and `b`, and a post map that writes the reduced value `a` to the output.
893
+ The identity starts every reduction.
894
+
895
+ ```ruby
896
+ l2norm = Cumo::CUDA::ReductionKernel.new(
897
+ "T x", "T y", "x * x", "a + b", "y = sqrt(a)", "0", "l2norm")
898
+
899
+ x = Cumo::SFloat.new(2, 5).seq
900
+ l2norm.call(x, axis: 1) # => [5.477, 15.969]
901
+ l2norm.call(x) # => the 0-dimensional norm of everything
902
+ ```
903
+
904
+ The parameters, the types and the broadcasting follow `ElementwiseKernel`, and `axis:` and `keepdims:` follow `sum`.
905
+ `reduce_type:` names the type the values are accumulated in, as a C type, a type name or one of the letters, and is the output's type unless said otherwise.
906
+ The post map may be several statements, so a kernel can write several outputs.
907
+ A `raw` parameter is not taken, since a reduction indexes every argument itself.
908
+ A long axis reduced to a few outputs is split across blocks and folded in a second pass, so it runs as fast as `sum`.
909
+
590
910
  ### Select a GPU device ID
591
911
 
592
912
  Set the `CUDA_VISIBLE_DEVICES=id` environment variable, or
@@ -607,6 +927,37 @@ require 'cumo'
607
927
  Cumo::CUDA::MemoryPool.disable
608
928
  ```
609
929
 
930
+ ### Reading The Memory Numbers
931
+
932
+ cumo allocates with `cudaMallocManaged`, so a page can sit on the host, and `nvidia-smi` counts only what the card is holding at that moment.
933
+ It reports less than cumo has taken, and the gap is not a fixed one.
934
+ A library that allocates with `cudaMalloc` reports everything it took, so the two numbers do not belong side by side in a comparison.
935
+
936
+ From inside, the pool answers two different questions:
937
+
938
+ ```ruby
939
+ Cumo::CUDA::MemoryPool.total_bytes # what the pool has taken from the card
940
+ Cumo::CUDA::MemoryPool.used_bytes # what it has handed out and not taken back
941
+ ```
942
+
943
+ `used_bytes` still counts blocks whose last reference is gone but which Ruby's garbage collector has not reached, so read it after `GC.start` when you mean the arrays that are alive:
944
+
945
+ ```ruby
946
+ a = Cumo::SFloat.new(1024, 1024).seq
947
+ t = a + 1
948
+ t = nil
949
+ Cumo::CUDA::MemoryPool.used_bytes #=> 8388608
950
+ GC.start
951
+ Cumo::CUDA::MemoryPool.used_bytes #=> 4194304
952
+ ```
953
+
954
+ `total_bytes` does not move with the collector and counts the free blocks the pool is keeping, so it answers how much this took rather than how much is alive.
955
+
956
+ ## Examples
957
+
958
+ `examples/` holds ports of the examples in the CuPy repository, one Ruby file per original, with a `backend.rb` that runs the CPU side on Numo and the GPU side on Cumo under `GPU=1`.
959
+ `examples/README.md` lists what is ported and how to run them.
960
+
610
961
  ## Documentation
611
962
 
612
963
  See https://github.com/ruby-numo/numo-narray#documentation, replacing Numo with Cumo.
@@ -730,17 +1081,57 @@ export CUMO_SHOW_WARNING_ONCE=OFF
730
1081
 
731
1082
  ### Raise the cuDNN workspace ceiling
732
1083
 
733
- cuDNN picks a convolution algorithm by benchmarking the ones that fit in a scratch buffer, and the ceiling on that buffer is 8MB.
734
- The fastest half precision algorithms, the ones that reach the tensor cores, ask for more than that and are left out of the search.
735
- To let them in:
1084
+ cuDNN picks a convolution algorithm by benchmarking the ones that fit in a scratch buffer, and the ceiling on that buffer is 128MB.
1085
+ The search reserves the whole ceiling whatever the convolution's size and hands it back to the pool afterwards, so the ceiling costs a peak rather than a residency.
1086
+
1087
+ Some shapes want more than the default. The twenty convolutions of a ResNet-18 forward pass at batch 16, and the single-precision case from [Half Precision](#half-precision):
1088
+
1089
+ ```
1090
+ ResNet-18 N=32, C=K=64, 56x56, 3x3
1091
+ CUMO_CUDNN_MAX_WORKSPACE_SIZE=8388608 9.80 ms 1.19 ms
1092
+ unset (128MB) 5.23 ms 1.19 ms
1093
+ CUMO_CUDNN_MAX_WORKSPACE_SIZE=268435456 5.24 ms 0.59 ms
1094
+ ```
1095
+
1096
+ To raise it:
736
1097
 
737
1098
  ```
738
- export CUMO_CUDNN_MAX_WORKSPACE_SIZE=67108864
1099
+ export CUMO_CUDNN_MAX_WORKSPACE_SIZE=268435456
739
1100
  ```
740
1101
 
741
1102
  The value is in bytes and only bounds the search; each convolution reserves what its chosen algorithm actually needs.
742
1103
  `Cumo::CUDA::CUDNN.max_workspace_size` reads back the value in force.
743
1104
 
1105
+ ### Single precision stays off the tensor cores
1106
+
1107
+ cuDNN reads its default math mode as "tensor cores are allowed", so a single precision convolution moves onto them as soon as the algorithm search reaches an algorithm that has them.
1108
+ The operands are rounded to a 10 bit significand on the way, and nothing in the call said to do that.
1109
+ Cumo asks cuDNN to keep single precision off the tensor cores, so raising the workspace ceiling buys speed at single precision accuracy.
1110
+ A different algorithm rounds differently, so the answer still moves; what it does not do is drop two digits.
1111
+ The half types name the tensor cores themselves and are not affected.
1112
+
1113
+ To trade the accuracy for the speed:
1114
+
1115
+ ```
1116
+ export CUMO_ALLOW_TF32=1
1117
+ ```
1118
+
1119
+ Measured over the convolutions of a ResNet-18 forward pass at batch 16, with the ceiling raised to 256MB, each layer against a double precision reference:
1120
+
1121
+ ```
1122
+ pass worst layer
1123
+ tensor cores off (default) 5.24 ms 1.4e-05
1124
+ tensor cores on 4.31 ms 2.4e-04
1125
+ ```
1126
+
1127
+ The same flag puts `SFloat` and `SComplex` `gemm` on the tensor cores as TF32, and `dot` where it goes through `gemm`.
1128
+ Off, the answer is the one cuBLAS gives at single precision, bit for bit.
1129
+ On, a `[4096, 4096]` `SFloat` `gemm` goes from 17.8 to 28.4 TFLOP/s on an RTX 5070 Ti Laptop, 1.60 times over six interleaved rounds.
1130
+ Its answer is then about 3e-04 from a double precision reference, where it was 4e-07.
1131
+ The double types are not affected either way.
1132
+
1133
+ `Cumo.allow_tf32?` reads back the value in force.
1134
+
744
1135
  ## Contributing
745
1136
 
746
1137
  Bug reports and pull requests are welcome on GitHub at https://github.com/sonots/cumo.
data/bench/cumo_probe.rb CHANGED
@@ -77,7 +77,7 @@ DTYPES = (ENV['DTYPES'] == 'all' ? DTYPE_SETS['all'] : (ENV['DTYPES']&.split(','
77
77
 
78
78
  LAYOUT_SETS = {
79
79
  'default' => %i[contig colslice],
80
- 'all' => %i[contig colslice transpose step2 index]
80
+ 'all' => %i[contig colslice transpose step2 index reverse]
81
81
  }.freeze
82
82
  LAYOUTS = (ENV['LAYOUTS'] == 'all' ? LAYOUT_SETS['all'] : (ENV['LAYOUTS']&.split(',')&.map(&:to_sym) || LAYOUT_SETS['default']))
83
83
 
@@ -156,6 +156,9 @@ def operand(klass, rows, cols, layout)
156
156
  when :transpose then base(klass, cols, rows).transpose
157
157
  when :step2 then base(klass, rows, cols * 2)[true, (0...(cols * 2)).step(2)]
158
158
  when :index then base(klass, rows, cols)[true, (0...cols).to_a.rotate(7)]
159
+ # walks one stride like contig does, but backwards, which is the shape a
160
+ # kernel that only knows +elmsz hands to a buffer instead
161
+ when :reverse then base(klass, rows, cols).reverse(1)
159
162
  else raise ArgumentError, "unknown layout #{layout}"
160
163
  end
161
164
  end
@@ -312,7 +315,8 @@ op(:scalar, 'scalar_left') { |c| c.scalar - c.sc.inplace }
312
315
 
313
316
  # NMath
314
317
  %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 silu].each do |f|
318
+ sinh cosh tanh asinh acosh atanh cbrt erf erfc sinc gelu gelu_tanh silu
319
+ softplus sigmoid].each do |f|
316
320
  op(:math, f, :float) { |c| XM::NMath.send(f, c.usc.inplace) }
317
321
  end
318
322
  op(:math, 'atan2', :float) { |c| XM::NMath.atan2(c.sc.inplace, c.b) }
@@ -342,7 +342,7 @@ if ARGV.include?('--list')
342
342
  exit
343
343
  end
344
344
 
345
- warn_on = ENV['CUMO_SHOW_WARNING'].to_s.upcase == 'ON'
345
+ warn_on = %w[1 on yes true].include?(ENV['CUMO_SHOW_WARNING'].to_s.downcase)
346
346
 
347
347
  puts "ruby : #{RUBY_VERSION} (#{RUBY_PLATFORM})"
348
348
  puts "cumo : #{begin
data/cumo.gemspec CHANGED
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
25
25
  spec.required_ruby_version = ">= 3.0.0"
26
26
 
27
27
  spec.files = `git ls-files -z`.split("\x0").reject do |f|
28
- f.match(%r{^(.github|test|spec|features|docker)/})
28
+ f.match(%r{^(.github|test|spec|features|docker|examples)/})
29
29
  end
30
30
  spec.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
31
31
  spec.bindir = "exe"