cumo 0.5.8 → 0.5.9

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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -0
  3. data/bench/cg_bench.rb +271 -0
  4. data/bench/crossover_bench.rb +374 -0
  5. data/bench/cumo_probe.rb +348 -0
  6. data/bench/cumo_sync_probe.rb +442 -0
  7. data/bench/ising_bench.rb +242 -0
  8. data/bench/kmeans_bench.rb +254 -0
  9. data/bench/particle_bench.rb +261 -0
  10. data/bench/transformer_bench.rb +264 -0
  11. data/ext/cumo/cuda/runtime.c +30 -1
  12. data/ext/cumo/extconf.rb +4 -0
  13. data/ext/cumo/include/cumo/cuda/cumo_thrust.hpp +16 -119
  14. data/ext/cumo/include/cumo/cuda/runtime.h +5 -0
  15. data/ext/cumo/include/cumo/indexer.h +152 -2
  16. data/ext/cumo/include/cumo/reduce_kernel.h +492 -107
  17. data/ext/cumo/include/cumo/template.h +26 -4
  18. data/ext/cumo/include/cumo/template_kernel.h +10 -1
  19. data/ext/cumo/include/cumo/types/bit_kernel.h +122 -0
  20. data/ext/cumo/include/cumo/types/complex_macro_kernel.h +1 -38
  21. data/ext/cumo/include/cumo/types/real_accum_kernel.h +0 -37
  22. data/ext/cumo/include/cumo/types/xint_macro_kernel.h +2 -2
  23. data/ext/cumo/include/cumo.h +2 -2
  24. data/ext/cumo/narray/data.c +21 -0
  25. data/ext/cumo/narray/data_kernel.cu +92 -0
  26. data/ext/cumo/narray/gen/tmpl/accum.c +9 -2
  27. data/ext/cumo/narray/gen/tmpl/accum_arg.c +16 -3
  28. data/ext/cumo/narray/gen/tmpl/accum_arg_kernel.cu +4 -2
  29. data/ext/cumo/narray/gen/tmpl/accum_binary_kernel.cu +67 -37
  30. data/ext/cumo/narray/gen/tmpl/accum_index.c +1 -1
  31. data/ext/cumo/narray/gen/tmpl/accum_index_kernel.cu +4 -2
  32. data/ext/cumo/narray/gen/tmpl/binary2.c +22 -12
  33. data/ext/cumo/narray/gen/tmpl/binary2_kernel.cu +24 -8
  34. data/ext/cumo/narray/gen/tmpl/bincount.c +34 -38
  35. data/ext/cumo/narray/gen/tmpl/bincount_kernel.cu +86 -0
  36. data/ext/cumo/narray/gen/tmpl/clip.c +57 -31
  37. data/ext/cumo/narray/gen/tmpl/clip_kernel.cu +61 -29
  38. data/ext/cumo/narray/gen/tmpl/complex_accum_kernel.cu +58 -86
  39. data/ext/cumo/narray/gen/tmpl/cond_binary.c +21 -11
  40. data/ext/cumo/narray/gen/tmpl/cond_binary_kernel.cu +21 -9
  41. data/ext/cumo/narray/gen/tmpl/cond_unary.c +47 -26
  42. data/ext/cumo/narray/gen/tmpl/cond_unary_kernel.cu +30 -0
  43. data/ext/cumo/narray/gen/tmpl/cum.c +13 -0
  44. data/ext/cumo/narray/gen/tmpl/cum_kernel.cu +72 -0
  45. data/ext/cumo/narray/gen/tmpl/ewcomp.c +20 -12
  46. data/ext/cumo/narray/gen/tmpl/ewcomp_kernel.cu +22 -7
  47. data/ext/cumo/narray/gen/tmpl/fill.c +32 -23
  48. data/ext/cumo/narray/gen/tmpl/fill_kernel.cu +18 -21
  49. data/ext/cumo/narray/gen/tmpl/float_accum_kernel.cu +56 -83
  50. data/ext/cumo/narray/gen/tmpl/frexp.c +9 -18
  51. data/ext/cumo/narray/gen/tmpl/frexp_kernel.cu +31 -0
  52. data/ext/cumo/narray/gen/tmpl/inspect.c +1 -0
  53. data/ext/cumo/narray/gen/tmpl/median.c +32 -0
  54. data/ext/cumo/narray/gen/tmpl/minmax.c +38 -12
  55. data/ext/cumo/narray/gen/tmpl/poly.c +27 -0
  56. data/ext/cumo/narray/gen/tmpl/poly_kernel.cu +46 -0
  57. data/ext/cumo/narray/gen/tmpl/pow.c +39 -18
  58. data/ext/cumo/narray/gen/tmpl/pow_kernel.cu +42 -14
  59. data/ext/cumo/narray/gen/tmpl/real_accum_kernel.cu +39 -5
  60. data/ext/cumo/narray/gen/tmpl/seq.c +17 -15
  61. data/ext/cumo/narray/gen/tmpl/seq_kernel.cu +18 -23
  62. data/ext/cumo/narray/gen/tmpl/set2.c +8 -45
  63. data/ext/cumo/narray/gen/tmpl/set2_kernel.cu +28 -0
  64. data/ext/cumo/narray/gen/tmpl/sort.c +41 -0
  65. data/ext/cumo/narray/gen/tmpl/sort_index.c +48 -0
  66. data/ext/cumo/narray/gen/tmpl/store_array.c +5 -0
  67. data/ext/cumo/narray/gen/tmpl/store_bit.c +17 -19
  68. data/ext/cumo/narray/gen/tmpl/store_bit_kernel.cu +21 -60
  69. data/ext/cumo/narray/gen/tmpl/store_from.c +48 -17
  70. data/ext/cumo/narray/gen/tmpl/store_from_kernel.cu +40 -48
  71. data/ext/cumo/narray/gen/tmpl/unary.c +12 -44
  72. data/ext/cumo/narray/gen/tmpl/unary2.c +61 -29
  73. data/ext/cumo/narray/gen/tmpl/unary2_kernel.cu +44 -0
  74. data/ext/cumo/narray/gen/tmpl/unary_kernel.cu +23 -73
  75. data/ext/cumo/narray/gen/tmpl/unary_ret2.c +9 -17
  76. data/ext/cumo/narray/gen/tmpl/unary_ret2_kernel.cu +32 -0
  77. data/ext/cumo/narray/gen/tmpl/unary_s.c +14 -19
  78. data/ext/cumo/narray/gen/tmpl/unary_s_kernel.cu +22 -54
  79. data/ext/cumo/narray/gen/tmpl_bit/binary.c +25 -110
  80. data/ext/cumo/narray/gen/tmpl_bit/binary_kernel.cu +52 -0
  81. data/ext/cumo/narray/gen/tmpl_bit/bit_reduce.c +18 -4
  82. data/ext/cumo/narray/gen/tmpl_bit/bit_reduce_kernel.cu +77 -0
  83. data/ext/cumo/narray/gen/tmpl_bit/extract.c +7 -1
  84. data/ext/cumo/narray/gen/tmpl_bit/extract_cpu.c +7 -1
  85. data/ext/cumo/narray/gen/tmpl_bit/fill.c +21 -37
  86. data/ext/cumo/narray/gen/tmpl_bit/fill_kernel.cu +42 -0
  87. data/ext/cumo/narray/gen/tmpl_bit/store_array.c +71 -38
  88. data/ext/cumo/narray/gen/tmpl_bit/store_array_kernel.cu +89 -0
  89. data/ext/cumo/narray/gen/tmpl_bit/store_bit.c +26 -73
  90. data/ext/cumo/narray/gen/tmpl_bit/store_bit_kernel.cu +47 -0
  91. data/ext/cumo/narray/gen/tmpl_bit/store_from.c +21 -1
  92. data/ext/cumo/narray/gen/tmpl_bit/store_from_kernel.cu +30 -0
  93. data/ext/cumo/narray/gen/tmpl_bit/unary.c +23 -78
  94. data/ext/cumo/narray/gen/tmpl_bit/unary_kernel.cu +48 -0
  95. data/ext/cumo/narray/gen/tmpl_bit/where.c +42 -5
  96. data/ext/cumo/narray/gen/tmpl_bit/where2.c +18 -5
  97. data/ext/cumo/narray/gen/tmpl_bit/where_kernel.cu +107 -0
  98. data/ext/cumo/narray/index.c +1 -0
  99. data/ext/cumo/narray/narray.c +6 -3
  100. data/ext/cumo/narray/ndloop.c +27 -0
  101. data/ext/cumo/narray/sort_kernel.cu +345 -0
  102. data/test/narray_test.rb +2070 -4
  103. metadata +26 -2
  104. data/ext/cumo/include/cumo/cuda/cumo_thrust_complex.hpp +0 -79
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 12027799f8203c1e08409de1fdec1b550e26511547bbaee2a938488ecd19e079
4
- data.tar.gz: 399daea2a183febf70ceeb1e6492479fd3a34ae29e994265c4da9f89644c3c4d
3
+ metadata.gz: f211eb1858b22b8418a8494efb431afeca60f023de409d74e6bdb1ae1fc3753a
4
+ data.tar.gz: '085be7ea42f37e235945e62783c51522c206c22eb539557b965812cc71ce422d'
5
5
  SHA512:
6
- metadata.gz: d12b0fa56578c8ffd309aa314ab40c98d3b983375d7ddd33b0909ac47092d52b48ea1b53173ea843701587a0fb50442a907b459bd558a8c92e4525eedd1531fd
7
- data.tar.gz: ba1b2bac6afb811d790c3f8d90906f3e7f571cbbdb8dc59e3ff6141fa54aa5b8f8a8d6e597ff3045e001c9ae5ff0cab6d9e6679ee6b8f5bbdb1642f203ba19b1
6
+ metadata.gz: 19a6ca3c65b190e2347624232fa33302f4485e84d90df7de6972d727d76353e20a15a00bf8a7c8c96d281a97bbd7f8084016b8c2961e6781e4d94b00b17287d1
7
+ data.tar.gz: 2df512d0a5e2b201cd82cc91d4dc258f4d3463097e2969250ab0aa81ba591abaaa89ea0c6d1e818d8b85f32d698a6b9cc529d961a8e432be540fb7f4144f2e70
data/CHANGELOG.md CHANGED
@@ -1,3 +1,39 @@
1
+ # 0.5.9 (2026/08/20)
2
+
3
+ Breaking changes:
4
+
5
+ * `sort_index` resolves ties to the lowest index, the GPU sort being stable, where the host quicksort left them in whatever order it produced (PR #261)
6
+
7
+ Fixes:
8
+
9
+ * Fix `a[[2, 1], true].store(array)` writing every row onto row 0, on every dtype: ndloop read the index array on the host before the kernel that fills it had run (PR #265)
10
+ * Fix a segmentation fault storing a Ruby Array into a reversed `Cumo::Bit` view that starts past the first word (PR #266)
11
+ * Fix `sort` in place of a view backed by an index array reading row 0 for every row (PR #262)
12
+ * Fix a `Cumo::RObject` store reading an index array before the kernel writes it, which answered zeroes from the second store onwards (PR #258)
13
+ * Fix a store of an Array of narrays into an indexed destination writing one element per row (PR #257)
14
+ * Fix the zero fill of a row stored from a shorter narray starting one element too far (PR #256)
15
+ * Fix grid-stride kernels hanging from about 2**32 elements, the step wrapping to zero (PR #240)
16
+ * Fix `Cumo::Bit` views with a negative step reading out of bounds (PR #238)
17
+ * Fix index reductions answering a later index on ties, where `Numo::NArray` answers the first (PR #236)
18
+ * Fix wrong values from reductions with `nan: true` (PR #234)
19
+
20
+ Changes:
21
+
22
+ * Run `sort`, `sort_index` and `median` on the GPU with `cub::DeviceSegmentedRadixSort`; 1M `SFloat` `sort` 115.7 -> 0.47 ms, `median` 107.8 -> 0.13 ms, `sort_index` 115.8 -> 1.69 ms (PR #259, PR #260, PR #261)
23
+ * Run `poly` on the GPU, which had called its iterator once per element behind a synchronization; 2^20 `DFloat` 163.8 -> 0.021 ms (PR #263)
24
+ * Run `bincount` on the GPU; 1M `Int32` 1.62 -> 0.05 ms (PR #255)
25
+ * Run `cumsum` and `cumprod` on the GPU (PR #242)
26
+ * Run `minmax`, `abs`, `isnan` and the rest of `cond_unary`, `modf` and `frexp` on the GPU (PR #230, PR #231, PR #232, PR #233)
27
+ * Run the `Cumo::Bit` operators, reductions, `where`, `where2`, `fill` and the stores on the GPU (PR #237, PR #239, PR #241, PR #252, PR #254, PR #264, PR #266)
28
+ * Run complex `real=` and `imag=` on the GPU; 2^22 `DComplex` 4.63 -> 0.45 ms (PR #267)
29
+ * Run the accumulating reductions and `mulsum` on more than one GPU thread, where they had been running device-side thrust in a single thread (PR #243, PR #244)
30
+ * Address a whole non-contiguous view in one kernel launch instead of one per row, in the elementwise templates, the stores, the copies and the Bit operands (PR #245, PR #246, PR #249, PR #251, PR #252)
31
+ * Read a flat reduction operand without the indexer, and reduce over an outer axis with host-side addressing; `sum(axis: 0)` on 4096x4096 2.01 -> 0.017 ms (PR #248, PR #253)
32
+ * Split a reduction with too few outputs across more blocks (PR #235)
33
+ * Build the C++ and CUDA sources with optimization: mkmf leaves `$(optflags)` out of `CXXFLAGS`, so every `.cpp` and the host half of every `.cu` had been built at `-O0`; `a + b` 2.86 -> 2.00 us (PR #250)
34
+ * Warn about the synchronization `a[idx]` and `inspect` perform (PR #247)
35
+ * Add benchmark scripts under `bench/` for a transformer block, k-means, a 2D Ising model, conjugate gradient, a particle simulation and the CPU/GPU crossover, and two probes for calls that synchronize or launch one kernel per row
36
+
1
37
  # 0.5.8 (2026/08/17)
2
38
 
3
39
  Breaking changes:
data/bench/cg_bench.rb ADDED
@@ -0,0 +1,271 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # Conjugate gradient, to price what a host readback costs.
6
+ #
7
+ # ruby cg_bench.rb
8
+ # GPU=1 ruby cg_bench.rb # the plain way: readbacks every iteration
9
+ # GPU=1 SCALAR=device ruby cg_bench.rb # keep alpha and beta on the device
10
+ # GPU=1 SCALAR=device CHECK=20 ruby cg_bench.rb # and test convergence rarely
11
+ # GPU=1 OP=dense N=4096 ruby cg_bench.rb # heavier arithmetic per iteration
12
+ #
13
+ # CG makes two scalars an iteration, alpha and beta, and multiplies arrays by
14
+ # them. Written plainly that reads them back to the host every iteration, and
15
+ # the convergence test reads a third, each one waiting for the queue to drain.
16
+ # An iteration is not much arithmetic, so the waiting is what shows.
17
+ #
18
+ # SCALAR=host alpha and beta become Ruby Floats, as one would write it
19
+ # SCALAR=device they stay as the 0-dimensional NArray sum answers, and are
20
+ # never read (Cumo returns 0-dim; on Numo sum is a Float
21
+ # already, so the setting makes no difference there)
22
+ # CHECK=k test convergence every k iterations, 0 to never test
23
+ #
24
+ # A readback does not cost a fixed amount. What it costs is however much work
25
+ # is queued when it happens, so it is better read as a cap on how deep the
26
+ # pipeline is allowed to get than as a price per call. Several shallow drains
27
+ # an iteration cost little each; the last one costs a lot, because until it is
28
+ # gone no iteration can overlap the next.
29
+ #
30
+ # On the host path the floor is two readbacks an iteration whatever CHECK is
31
+ # set to, since alpha needs pap and beta needs rs_new as Floats. Keeping the
32
+ # scalars on the device is what makes fewer than that possible, and thinning
33
+ # the convergence test only pays once they are there.
34
+ #
35
+ # Correctness is checked against a known solution: x_true is chosen first and
36
+ # b = A x_true built from it, so the answer has something to be compared to.
37
+ #
38
+ # Timings vary by a factor of a few between runs. Take the best of several.
39
+ # ---------------------------------------------------------------------------
40
+
41
+ GPU = !%w[0 false].include?(ENV['GPU'].to_s.downcase) && !ENV['GPU'].to_s.empty?
42
+
43
+ if GPU
44
+ require 'cumo/narray'
45
+ XM = Cumo
46
+ else
47
+ require 'numo/narray'
48
+ XM = Numo
49
+ end
50
+
51
+ OP = (ENV['OP'] || 'stencil') # stencil | dense
52
+ GRID = (ENV['GRID'] || 512).to_i # side of the grid, for stencil
53
+ N = (ENV['N'] || 2048).to_i # number of unknowns, for dense
54
+ MAXITER = (ENV['MAXITER'] || 200).to_i
55
+ SCALAR = (ENV['SCALAR'] || 'host') # host | device
56
+ CHECK = (ENV['CHECK'] || 1).to_i # test convergence every CHECK, 0 to never
57
+ TOL = (ENV['TOL'] || 1.0e-6).to_f
58
+ DOT = (ENV['DOT'] || 'sum') # sum | blas
59
+ DETAIL = ENV['DETAIL'] != '0'
60
+ REPEAT = (ENV['REPEAT'] || 3).to_i # solve this many times, keep the fastest
61
+ SEED = (ENV['SEED'] || 42).to_i
62
+
63
+ TIMES = Hash.new(0.0)
64
+ READBACKS = [0]
65
+
66
+ def sync
67
+ XM::CUDA::Runtime.cudaDeviceSynchronize if GPU
68
+ end
69
+
70
+ # A readback, which is where the host waits. Counted, since that is the point.
71
+ def scalar(v)
72
+ READBACKS[0] += 1
73
+ return v if v.is_a?(Numeric)
74
+
75
+ v.respond_to?(:extract_cpu) ? v.extract_cpu : v.extract
76
+ end
77
+
78
+ def timed(key)
79
+ return yield unless DETAIL
80
+
81
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ r = yield
83
+ sync
84
+ TIMES[key] += Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0
85
+ r
86
+ end
87
+
88
+ def dotp(a, b)
89
+ if DOT == 'blas'
90
+ a.reshape(a.size).dot(b.reshape(b.size))
91
+ else
92
+ (a * b).sum
93
+ end
94
+ end
95
+
96
+ # --- the operator ----------------------------------------------------------
97
+
98
+ # The five-point Laplacian, negated to be positive definite. The border stays
99
+ # zero and is never touched.
100
+ def apply_stencil(p)
101
+ out = XM::SFloat.zeros(GRID, GRID)
102
+ out[1..-2, 1..-2] =
103
+ 4.0 * p[1..-2, 1..-2] -
104
+ p[0..-3, 1..-2] - p[2..-1, 1..-2] -
105
+ p[1..-2, 0..-3] - p[1..-2, 2..-1]
106
+ out
107
+ end
108
+
109
+ # A diagonally dominant symmetric matrix. The identity is built with
110
+ # arithmetic rather than a fancy index.
111
+ def build_dense
112
+ r = XM::SFloat.new(N, N).rand * 2.0 - 1.0
113
+ sym = (r + r.transpose) * 0.5
114
+ i = XM::SFloat.new(N, 1).seq
115
+ j = XM::SFloat.new(1, N).seq
116
+ off = (XM::SFloat.zeros(N, N) + (i - j)).abs.clip(0.0, 1.0).ceil # 0 on the diagonal
117
+ sym + (1.0 - off) * (N * 1.0)
118
+ end
119
+
120
+ # --- setup -----------------------------------------------------------------
121
+
122
+ version = begin
123
+ XM::NArray::VERSION
124
+ rescue StandardError
125
+ 'unknown'
126
+ end
127
+
128
+ begin
129
+ XM::NArray.srand(SEED)
130
+ rescue StandardError
131
+ nil
132
+ end
133
+
134
+ if OP == 'dense'
135
+ amat = build_dense
136
+ x_true = XM::SFloat.new(N).rand * 2.0 - 1.0
137
+ apply = ->(p) { amat.dot(p) }
138
+ unknowns = N
139
+ else
140
+ x_true = XM::SFloat.zeros(GRID, GRID)
141
+ x_true[1..-2, 1..-2] = XM::SFloat.new(GRID - 2, GRID - 2).rand * 2.0 - 1.0
142
+ apply = ->(p) { apply_stencil(p) }
143
+ unknowns = (GRID - 2) * (GRID - 2)
144
+ end
145
+
146
+ b = apply.call(x_true)
147
+
148
+ puts "ruby : #{RUBY_VERSION} (#{RUBY_PLATFORM})"
149
+ puts "backend : #{XM} #{version}"
150
+ puts "problem : OP=#{OP} unknowns=#{unknowns} MAXITER=#{MAXITER}"
151
+ puts "scalar : SCALAR=#{SCALAR} CHECK=#{CHECK} DOT=#{DOT}"
152
+ puts
153
+
154
+ # Warm up
155
+ warm = XM::SFloat.new(64, 64).seq
156
+ warm_sum = (warm * warm).sum
157
+ warm_sum = warm_sum.extract if warm_sum.respond_to?(:extract)
158
+ sync
159
+ GC.start
160
+ READBACKS[0] = 0
161
+
162
+ # --- CG --------------------------------------------------------------------
163
+
164
+ # Each distinct scalar is read at most once an iteration. Reading rs_old in
165
+ # both alpha and beta, and rs_new in both the convergence test and beta, would
166
+ # wait twice for a value already in hand.
167
+ def solve(apply, b, rs0, shape)
168
+ x = XM::SFloat.zeros(*shape)
169
+ r = b + 0.0 # a copy
170
+ p_dir = r + 0.0
171
+ rs_old = timed(:dot) { dotp(r, r) }
172
+ rs_old_h = SCALAR == 'device' ? nil : rs0
173
+ iters = 0
174
+ converged = nil
175
+
176
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
177
+ MAXITER.times do |it|
178
+ iters = it + 1
179
+
180
+ ap = timed(:apply) { apply.call(p_dir) }
181
+ pap = timed(:dot) { dotp(p_dir, ap) }
182
+
183
+ alpha = timed(:alpha) do
184
+ SCALAR == 'device' ? rs_old / pap : rs_old_h / scalar(pap).to_f
185
+ end
186
+
187
+ timed(:axpy) do
188
+ x += p_dir * alpha
189
+ r -= ap * alpha
190
+ end
191
+
192
+ rs_new = timed(:dot) { dotp(r, r) }
193
+ rs_new_h = SCALAR == 'device' ? nil : scalar(rs_new).to_f
194
+
195
+ if CHECK.positive? && (iters % CHECK).zero?
196
+ rel = Math.sqrt((rs_new_h || scalar(rs_new).to_f) / rs0)
197
+ if rel < TOL
198
+ converged = rel
199
+ break
200
+ end
201
+ end
202
+
203
+ beta = timed(:beta) do
204
+ SCALAR == 'device' ? rs_new / rs_old : rs_new_h / rs_old_h
205
+ end
206
+
207
+ timed(:update_p) { p_dir = r + p_dir * beta }
208
+ rs_old = rs_new
209
+ rs_old_h = rs_new_h
210
+ end
211
+ sync
212
+ { x: x, iters: iters, converged: converged,
213
+ elapsed: Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 }
214
+ end
215
+
216
+ shape = OP == 'dense' ? [N] : [GRID, GRID]
217
+ rs0 = scalar(dotp(b, b)).to_f # the reference for the relative residual, read once
218
+
219
+ # Solve it a few times and keep the fastest. A single timing here varies by a
220
+ # factor of a few, which is enough to invent a trend that is not there.
221
+ best = nil
222
+ REPEAT.times do
223
+ TIMES.clear
224
+ READBACKS[0] = 0
225
+ run = solve(apply, b, rs0, shape)
226
+ run[:times] = TIMES.dup
227
+ run[:readbacks] = READBACKS[0]
228
+ best = run if best.nil? || run[:elapsed] < best[:elapsed]
229
+ end
230
+ x = best[:x]
231
+ iters = best[:iters]
232
+ elapsed = best[:elapsed]
233
+ TIMES.replace(best[:times])
234
+ READBACKS[0] = best[:readbacks]
235
+ puts format(' converged at iter %d, relative residual %.3e', iters, best[:converged]) if best[:converged]
236
+
237
+ # --- results ---------------------------------------------------------------
238
+
239
+ res = apply.call(x) - b
240
+ rel_res = Math.sqrt(scalar((res * res).sum).to_f / rs0)
241
+ err = x - x_true
242
+ rel_err = Math.sqrt(scalar((err * err).sum).to_f) /
243
+ Math.sqrt(scalar((x_true * x_true).sum).to_f)
244
+
245
+ puts format(' iterations : %d', iters)
246
+ puts format(' rel. residual : %.3e', rel_res)
247
+ puts format(' rel. error : %.3e (against the known x_true)', rel_err)
248
+ puts format(' time : %.4f s (%.1f us/iter, best of %d)',
249
+ elapsed, elapsed / iters * 1e6, REPEAT)
250
+ puts format(' readbacks : %d (%.2f per iteration)',
251
+ READBACKS[0], READBACKS[0].to_f / iters)
252
+
253
+ if DETAIL
254
+ puts
255
+ measured = TIMES.values.sum
256
+ puts 'breakdown (per iteration):'
257
+ TIMES.sort_by { |_, v| -v }.each do |key, v|
258
+ puts format(' %-10s %9.1f us %5.1f%%', key, v / iters * 1e6, 100.0 * v / measured)
259
+ end
260
+ puts ' (these sections synchronize, so they add up to more than us/iter above,'
261
+ puts ' and DETAIL=1 is much noisier than DETAIL=0 because of it)'
262
+ end
263
+
264
+ puts
265
+ puts ' What to compare:'
266
+ puts ' SCALAR=host CHECK=1 the plain way, several readbacks an iteration'
267
+ puts ' SCALAR=device CHECK=1 alpha and beta stay on the device, one readback'
268
+ puts ' SCALAR=device CHECK=20 the convergence test thinned out too'
269
+ puts ' SCALAR=device CHECK=0 no readback at all inside the loop'
270
+ puts ' Thinning the test on its own changes nothing: the host path still reads'
271
+ puts ' pap and rs_new every iteration. The two settings only pay together.'
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # ---------------------------------------------------------------------------
5
+ # Crossover sweep - the element count at which Cumo overtakes Numo, per kind of
6
+ # operation.
7
+ #
8
+ # ruby crossover_bench.rb # both in one process
9
+ # ONLY=cumo JSON=cumo.json ruby crossover_bench.rb
10
+ # ONLY=numo JSON=numo.json ruby crossover_bench.rb
11
+ # MERGE=numo.json,cumo.json ruby crossover_bench.rb # combine separate runs
12
+ #
13
+ # SIZES=1024,4096,16384 TIME_CAP=0.5 ruby crossover_bench.rb
14
+ #
15
+ # This asks "how small is too small", not "how many times faster on a big
16
+ # array". The element count that pays back Cumo's per-operation fixed cost
17
+ # (launch + Ruby + ndloop) differs by operation, and that point is the
18
+ # guidance a user actually needs.
19
+ #
20
+ # Every point is measured two ways:
21
+ # pipelined BATCH operations with a single synchronize at the end. This is
22
+ # the per-operation cost of a run of work, and what the crossover
23
+ # is computed from.
24
+ # per-op one synchronize after every operation, for code that reads the
25
+ # result straight back.
26
+ # Cumo cannot overlap the next launch with the previous operation once a
27
+ # synchronize sits between them, so the two differ by 2-4x on the same work.
28
+ #
29
+ # Timing is best-of-N, and the GPU is spun for a second first so that a
30
+ # laptop's clock ramp stays out of the measurement.
31
+ #
32
+ # Numo's times swing several-fold with the history of the process: above
33
+ # glibc's 128 KB mmap threshold every allocation takes page faults. Read the
34
+ # crossover as an order of magnitude, and split the processes with ONLY and
35
+ # MERGE when it matters.
36
+ # ---------------------------------------------------------------------------
37
+
38
+ require 'json'
39
+
40
+ ONLY = ENV['ONLY']
41
+ JSON_OUT = ENV['JSON']
42
+ MERGE = ENV['MERGE']
43
+ TIME_CAP = (ENV['TIME_CAP'] || 1.0).to_f # stop a sweep once one run exceeds this
44
+ WARMUP = (ENV['WARMUP'] || 1.0).to_f # seconds of idle work to raise the GPU clock
45
+ MIN_REPS = 3
46
+ MAX_REPS = 30
47
+ MIN_TOTAL = 0.15 # roughly how long to spend on a point
48
+ MAX_BATCH = (ENV['BATCH'] || 64).to_i # most operations to pipeline at once
49
+ # The outputs cannot be held on to while pipelining, or the pool has nothing to
50
+ # reuse, so each one is freed as it is produced. Allocation and free are part of
51
+ # the pipelined number for that reason.
52
+ BATCH_BYTES = (ENV['BATCH_BYTES'] || (64 << 20)).to_i
53
+
54
+ SIZES = (ENV['SIZES'] || '256,1024,4096,16384,65536,262144,1048576,4194304,16777216,67108864')
55
+ .split(',').map(&:to_i)
56
+
57
+ def clock
58
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
59
+ end
60
+
61
+ # --- backends --------------------------------------------------------------
62
+ # Numo and Cumo have separate namespaces, so both can live in one process.
63
+
64
+ BACKENDS = {}
65
+
66
+ unless ONLY == 'cumo'
67
+ begin
68
+ require 'numo/narray'
69
+ BACKENDS[:numo] = { mod: Numo, sync: -> {} }
70
+ rescue LoadError => e
71
+ warn "cannot load numo-narray: #{e.message}"
72
+ end
73
+ end
74
+
75
+ unless ONLY == 'numo'
76
+ begin
77
+ require 'cumo/narray'
78
+ BACKENDS[:cumo] = { mod: Cumo, sync: -> { Cumo::CUDA::Runtime.cudaDeviceSynchronize } }
79
+ rescue LoadError => e
80
+ warn "cannot load cumo: #{e.message}"
81
+ end
82
+ end
83
+
84
+ # --- timing ----------------------------------------------------------------
85
+
86
+ def best_of(&block)
87
+ best = Float::INFINITY
88
+ reps = 0
89
+ t_start = clock
90
+ loop do
91
+ t0 = clock
92
+ block.call
93
+ dt = clock - t0
94
+ best = dt if dt < best
95
+ reps += 1
96
+ break if reps >= MIN_REPS && (reps >= MAX_REPS || (clock - t_start) > MIN_TOTAL)
97
+ end
98
+ best
99
+ end
100
+
101
+ # Holding on to the outputs leaves the pool nothing to reuse and every
102
+ # allocation is a fresh one, so both ways of measuring free alike. The two
103
+ # columns then differ by the synchronize and nothing else.
104
+ def run_once(callable, frees)
105
+ r = callable.call
106
+ r.free if frees && r.respond_to?(:free)
107
+ end
108
+
109
+ # One synchronize per operation, for code that reads the result straight back.
110
+ def measure_sync(callable, sync, frees)
111
+ run_once(callable, frees)
112
+ sync.call
113
+ best_of do
114
+ run_once(callable, frees)
115
+ sync.call
116
+ end
117
+ end
118
+
119
+ # batch operations with a single synchronize at the end, reported per operation.
120
+ def measure_pipelined(callable, sync, batch, frees)
121
+ run_once(callable, frees)
122
+ sync.call
123
+ return measure_sync(callable, sync, frees) if batch <= 1
124
+
125
+ t = best_of do
126
+ batch.times { run_once(callable, frees) }
127
+ sync.call
128
+ end
129
+ t / batch
130
+ end
131
+
132
+ # Pipeline as many as fit in BATCH_BYTES of live output.
133
+ def batch_for(bytes)
134
+ return MAX_BATCH if bytes <= 0
135
+
136
+ (BATCH_BYTES / bytes).clamp(1, MAX_BATCH)
137
+ end
138
+
139
+ # --- operations ------------------------------------------------------------
140
+ # The two-dimensional ones use a square of side sqrt(n).
141
+
142
+ OPS = [
143
+ {
144
+ name: 'mul scalar', dims: 1, out_bytes: ->(n) { n * 4 },
145
+ setup: lambda { |xm, n|
146
+ a = xm::SFloat.new(n).seq
147
+ -> { a * 2.0 }
148
+ }
149
+ },
150
+ {
151
+ name: 'add arrays', dims: 1, out_bytes: ->(n) { n * 4 },
152
+ setup: lambda { |xm, n|
153
+ a = xm::SFloat.new(n).seq
154
+ b = xm::SFloat.new(n).seq
155
+ -> { a + b }
156
+ }
157
+ },
158
+ {
159
+ name: 'exp', dims: 1, out_bytes: ->(n) { n * 4 },
160
+ setup: lambda { |xm, n|
161
+ a = xm::SFloat.new(n).seq * 1.0e-6
162
+ -> { xm::NMath.exp(a) }
163
+ }
164
+ },
165
+ {
166
+ name: 'sum all', dims: 1, out_bytes: ->(_n) { 4 },
167
+ setup: lambda { |xm, n|
168
+ a = xm::SFloat.new(n).seq
169
+ -> { a.sum }
170
+ }
171
+ },
172
+ {
173
+ name: 'sum axis1', dims: 2, out_bytes: ->(n) { Integer(Math.sqrt(n)) * 4 },
174
+ setup: lambda { |xm, n|
175
+ s = Integer(Math.sqrt(n))
176
+ a = xm::SFloat.new(s, s).seq
177
+ -> { a.sum(axis: 1) }
178
+ }
179
+ },
180
+ {
181
+ # store returns the destination itself, which must not be freed
182
+ name: 'store colslice', dims: 2, out_bytes: ->(_n) { 0 }, frees: false,
183
+ setup: lambda { |xm, n|
184
+ s = Integer(Math.sqrt(n))
185
+ src = xm::SFloat.new(s, s * 2).seq
186
+ dst = xm::SFloat.zeros(s, s)
187
+ v = src[true, 0...s]
188
+ -> { dst.store(v) }
189
+ }
190
+ },
191
+ {
192
+ name: 'dot (GEMM)', dims: 2, out_bytes: ->(n) { n * 4 },
193
+ setup: lambda { |xm, n|
194
+ s = Integer(Math.sqrt(n))
195
+ a = xm::SFloat.new(s, s).seq * 0.001
196
+ b = xm::SFloat.new(s, s).seq * 0.001
197
+ -> { a.dot(b) }
198
+ }
199
+ }
200
+ ].freeze
201
+
202
+ # --- run -------------------------------------------------------------------
203
+
204
+ results = Hash.new { |h, k| h[k] = Hash.new { |h2, k2| h2[k2] = {} } }
205
+
206
+ if MERGE
207
+ MERGE.split(',').each do |path|
208
+ JSON.parse(File.read(path)).each do |backend, ops|
209
+ ops.each do |op, points|
210
+ points.each { |n, t| results[backend.to_sym][op][n.to_i] = t }
211
+ end
212
+ end
213
+ end
214
+ puts "merged : #{MERGE}"
215
+ else
216
+ puts "ruby : #{RUBY_VERSION} (#{RUBY_PLATFORM})"
217
+ BACKENDS.each do |key, be|
218
+ version = begin
219
+ be[:mod]::NArray::VERSION
220
+ rescue StandardError
221
+ 'unknown'
222
+ end
223
+ puts format('%-10s: %s %s', key, be[:mod], version)
224
+ end
225
+ puts "sizes : #{SIZES.first} .. #{SIZES.last} (#{SIZES.size} points)"
226
+ puts "params : TIME_CAP=#{TIME_CAP}s WARMUP=#{WARMUP}s"
227
+ puts
228
+
229
+ # raise the GPU clock before measuring anything
230
+ if BACKENDS[:cumo] && WARMUP.positive?
231
+ a = Cumo::SFloat.new(4_000_000).seq
232
+ t0 = clock
233
+ (a * 1.0001).free while clock - t0 < WARMUP
234
+ BACKENDS[:cumo][:sync].call
235
+ puts format(' warmed up for %.1f s', WARMUP)
236
+ puts
237
+ end
238
+
239
+ # Whichever operation is measured first carries the process's one-time setup,
240
+ # so every one of them gets a discarded pass first.
241
+ BACKENDS.each do |key, be|
242
+ OPS.each do |op|
243
+ call = op[:setup].call(be[:mod], SIZES.first)
244
+ 3.times { run_once(call, op.fetch(:frees, true)) }
245
+ be[:sync].call
246
+ rescue StandardError => e
247
+ warn format(' discarded pass %s / %s: %s', key, op[:name], e.class)
248
+ end
249
+ end
250
+
251
+ BACKENDS.each do |key, be|
252
+ OPS.each do |op|
253
+ frees = op.fetch(:frees, true)
254
+ SIZES.each do |n|
255
+ call = op[:setup].call(be[:mod], n)
256
+ t_sync = measure_sync(call, be[:sync], frees)
257
+ t_pipe = measure_pipelined(call, be[:sync], batch_for(op[:out_bytes].call(n)), frees)
258
+ results[key][op[:name]][n] = [t_sync, t_pipe]
259
+ GC.start
260
+ break if t_sync > TIME_CAP # anything larger takes too long to be useful
261
+ rescue StandardError, NoMemoryError => e
262
+ warn format(' %s / %s / n=%d: %s', key, op[:name], n, e.class)
263
+ break
264
+ end
265
+ end
266
+ end
267
+
268
+ if JSON_OUT
269
+ File.write(JSON_OUT, JSON.pretty_generate(results))
270
+ puts " -> #{JSON_OUT}"
271
+ end
272
+ end
273
+
274
+ # --- report ----------------------------------------------------------------
275
+
276
+ def fmt_time(t)
277
+ return ' -' unless t
278
+
279
+ if t < 1.0e-3
280
+ format('%7.2f us', t * 1e6)
281
+ else
282
+ format('%7.2f ms', t * 1e3)
283
+ end
284
+ end
285
+
286
+ # Interpolate the interval where the ratio crosses 1, in log space.
287
+ def crossover(points)
288
+ bracket = points.each_cons(2).find { |(_, r1), (_, r2)| r1 < 1.0 && r2 >= 1.0 }
289
+ return nil unless bracket
290
+
291
+ (n1, r1), (n2, r2) = bracket
292
+ f = Math.log(r1) / (Math.log(r1) - Math.log(r2))
293
+ Math.exp(Math.log(n1) + (f * (Math.log(n2) - Math.log(n1))))
294
+ end
295
+
296
+ # A point is [time with a synchronize per operation, time per operation pipelined]
297
+ def pipe(points, n)
298
+ v = points[n]
299
+ v.is_a?(Array) ? v[1] : v
300
+ end
301
+
302
+ def syncd(points, n)
303
+ v = points[n]
304
+ v.is_a?(Array) ? v[0] : nil
305
+ end
306
+
307
+ summary = []
308
+
309
+ OPS.each do |op|
310
+ numo = results[:numo][op[:name]]
311
+ cumo = results[:cumo][op[:name]]
312
+ next if numo.empty? && cumo.empty?
313
+
314
+ puts "[#{op[:name]}]"
315
+ puts format(' %12s %11s %11s %10s %13s', 'elements', 'numo', 'cumo', 'numo/cumo', 'cumo per-op')
316
+
317
+ ratios = []
318
+ marked = false
319
+ SIZES.each do |n|
320
+ tn = pipe(numo, n)
321
+ tc = pipe(cumo, n)
322
+ ratio = (tn && tc) ? tn / tc : nil
323
+ ratios << [n, ratio] if ratio
324
+ mark = if ratio && ratio >= 1.0 && !marked
325
+ marked = true
326
+ ' <== Cumo wins from here'
327
+ else
328
+ ''
329
+ end
330
+ next unless tn || tc
331
+
332
+ puts format(' %12d %11s %11s %10s %13s%s', n, fmt_time(tn), fmt_time(tc),
333
+ ratio ? format('%.2f', ratio) : '-', fmt_time(syncd(cumo, n)), mark)
334
+ end
335
+
336
+ cross = crossover(ratios)
337
+ small = cumo.keys.min
338
+ fixed = small ? pipe(cumo, small) : nil
339
+ fixed_sync = small ? syncd(cumo, small) : nil
340
+ best = ratios.map(&:last).compact.max
341
+ summary << [op[:name], cross, fixed, fixed_sync, best]
342
+ puts
343
+ end
344
+
345
+ puts '== summary'
346
+ puts format(' %-16s %14s %12s %12s %10s',
347
+ 'operation', 'crossover', 'cumo fixed', 'with sync', 'best ratio')
348
+ summary.each do |name, cross, fixed, fixed_sync, best|
349
+ puts format(' %-16s %14s %12s %12s %10s',
350
+ name,
351
+ cross ? format('~%d', cross.round) : '-',
352
+ fixed ? format('%.2f us', fixed * 1e6) : '-',
353
+ fixed_sync ? format('%.2f us', fixed_sync * 1e6) : '-',
354
+ best ? format('%.1fx', best) : '-')
355
+ end
356
+
357
+ puts
358
+ puts ' The cumo column and the crossover are the pipelined numbers.'
359
+ puts ' "cumo fixed" is the per-operation cost at the smallest size, which is'
360
+ puts ' the floor of launch + Ruby + ndloop. "with sync" waits for each'
361
+ puts ' operation to finish; the difference is not what a synchronize costs but'
362
+ puts ' what it prevents, namely overlapping the next launch with this one.'
363
+ puts ' The crossover is where the CPU takes longer than that fixed cost, so it'
364
+ puts ' moves left as the fixed cost falls or the work per operation grows.'
365
+ puts ' An operation with no crossover had one side ahead over the whole range.'
366
+ puts
367
+ if results[:numo].key?('dot (GEMM)') && !defined?(Numo::Linalg)
368
+ puts ' Note: without numo-linalg, dot runs against the built-in Numo'
369
+ puts ' implementation. With BLAS its crossover moves far to the right.'
370
+ end
371
+ puts ' Note: Numo swings several-fold with the history of the process, since'
372
+ puts ' above glibc\'s 128 KB mmap threshold every allocation takes page faults.'
373
+ puts ' Read the crossover as an order of magnitude, and split the processes'
374
+ puts ' with ONLY and MERGE when the number has to be tight.'