carray-jit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.yardopts +10 -0
  3. data/CHANGELOG.md +84 -0
  4. data/LICENSE +21 -0
  5. data/README.md +88 -0
  6. data/bin/carray-jit +194 -0
  7. data/carray-jit.gemspec +41 -0
  8. data/docs/00_Introduction.md +40 -0
  9. data/docs/01_GettingStarted.md +80 -0
  10. data/docs/02_KernelShapes.md +397 -0
  11. data/docs/03_SupportedFeatures.md +595 -0
  12. data/docs/04_Compiling.md +234 -0
  13. data/docs/05_DesignNotes.md +136 -0
  14. data/docs/06_Cheatsheet.md +177 -0
  15. data/examples/README.md +56 -0
  16. data/examples/applications/game_of_life.rb +161 -0
  17. data/examples/applications/heat_equation.rb +117 -0
  18. data/examples/applications/kepler.rb +178 -0
  19. data/examples/applications/mandelbrot.rb +151 -0
  20. data/examples/applications/moving_average.rb +124 -0
  21. data/examples/applications/partial_sums.rb +141 -0
  22. data/examples/applications/point_cloud.rb +110 -0
  23. data/examples/applications/quicksort.rb +118 -0
  24. data/examples/applications/recursion.rb +121 -0
  25. data/examples/applications/relaxation.rb +115 -0
  26. data/examples/applications/sensor_gaps.rb +118 -0
  27. data/examples/applications/sieve.rb +95 -0
  28. data/examples/applications/sobel_edges.rb +80 -0
  29. data/examples/features/01_element_wise.rb +69 -0
  30. data/examples/features/02_stencil.rb +40 -0
  31. data/examples/features/03_recurrence.rb +50 -0
  32. data/examples/features/04_thomas.rb +81 -0
  33. data/examples/features/05_reduction.rb +90 -0
  34. data/examples/features/06_jit_contract.rb +58 -0
  35. data/examples/features/07_masks.rb +55 -0
  36. data/examples/features/08_views.rb +46 -0
  37. data/examples/features/09_inspecting.rb +55 -0
  38. data/examples/features/10_complex.rb +107 -0
  39. data/examples/features/11_c_functions.rb +260 -0
  40. data/examples/features/12_sweep.rb +139 -0
  41. data/examples/features/13_cscalar.rb +80 -0
  42. data/examples/features/14_stencil_window.rb +106 -0
  43. data/examples/features/15_loops.rb +148 -0
  44. data/examples/features/16_raising.rb +69 -0
  45. data/ext/carray_jit_access/carray_jit_access.c +460 -0
  46. data/ext/carray_jit_access/extconf.rb +8 -0
  47. data/lib/carray/jit/analyzer.rb +1847 -0
  48. data/lib/carray/jit/block_reader.rb +139 -0
  49. data/lib/carray/jit/c_function.rb +777 -0
  50. data/lib/carray/jit/c_generator.rb +2305 -0
  51. data/lib/carray/jit/compiler.rb +468 -0
  52. data/lib/carray/jit/errors.rb +37 -0
  53. data/lib/carray/jit/expression.rb +202 -0
  54. data/lib/carray/jit/kernel.rb +509 -0
  55. data/lib/carray/jit/node.rb +573 -0
  56. data/lib/carray/jit/sweep.rb +97 -0
  57. data/lib/carray/jit/type_assignment.rb +811 -0
  58. data/lib/carray/jit/version.rb +5 -0
  59. data/lib/carray/jit.rb +1210 -0
  60. metadata +139 -0
@@ -0,0 +1,468 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "rbconfig"
4
+ require "tmpdir"
5
+ require "fiddle"
6
+
7
+ class CArray
8
+ module JIT
9
+
10
+ # Compiles generated C into a shared object and hands back a loaded
11
+ # function pointer, caching by the hash of the source and the flags used
12
+ # to build it.
13
+ #
14
+ # The cache is on disk and outlives the process because rebuilding is not
15
+ # cheap: about 60 ms to compile, plus roughly 180 ms on macOS the first
16
+ # time a freshly written binary is loaded, which is Gatekeeper checking it
17
+ # rather than anything Ruby does. A second process loading the same file
18
+ # pays 0.2 ms. It is bounded, though -- see #prune -- so it cannot grow
19
+ # without limit the way RubyInline's ~/.ruby_inline does.
20
+ class Compiler
21
+
22
+ # -ffp-contract=off is not optional. Without it clang and gcc fuse
23
+ # `a*b + c` into a single FMA -- even at -O0 on arm64 -- which changes
24
+ # the last bit of the result and breaks agreement with the Ruby
25
+ # evaluator. Measured: a two-term recurrence diverges in 13 of 24
26
+ # cells with contraction on, and matches bit for bit with it off.
27
+ #
28
+ # -O3 rather than -O2, which is what this was until it was measured.
29
+ # The level is what decides whether the loop is vectorised at all, and
30
+ # that is not a clang-shaped question: clang vectorises at -O2 already,
31
+ # so on arm64 the change is worth 1.33x on an element-wise pass and
32
+ # nothing on the rest (seven benchmarks, min of three, none slower).
33
+ # gcc does not vectorise at -O2 at all -- measured 1.6x to 1.7x on the
34
+ # same kernels -- so at -O2 this gem was leaving that on the table
35
+ # wherever gcc is the compiler. Compiling costs the same either way:
36
+ # 57 ms against 58 ms on a generated kernel, min of seven.
37
+ #
38
+ # It is also what CArray builds itself with, and what this gem's own
39
+ # fuse path already inherits from CArray::BUILD_FLAGS. The kernel path
40
+ # being lower was not a decision -- the flag list is the one the first
41
+ # milestone was scaffolded with, and only the contraction flag beside
42
+ # it was ever argued for.
43
+ FLAGS = ["-O3", "-fPIC", "-shared", "-ffp-contract=off"].freeze
44
+
45
+ # Kernels retained on disk. Each costs about 17 KB, so the default is
46
+ # roughly 9 MB -- far more than any real program compiles, but a bound
47
+ # all the same.
48
+ DEFAULT_ENTRY_LIMIT = 512
49
+
50
+ class << self
51
+
52
+ def compiler_command
53
+ ENV["CARRAY_JIT_CC"] || RbConfig::CONFIG["CC"] || "cc"
54
+ end
55
+
56
+ # True when the cache lives in a temporary directory that goes away
57
+ # with the process.
58
+ def ephemeral?
59
+ !ENV["CARRAY_JIT_NO_CACHE"].nil? || ENV["CARRAY_JIT_CACHE"] == "none"
60
+ end
61
+
62
+ # The root holds one directory per environment; kernels live in the
63
+ # directory for this one.
64
+ def cache_root
65
+ return ephemeral_directory if ephemeral?
66
+ ENV["CARRAY_JIT_CACHE"] ||
67
+ File.join(ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache"),
68
+ "carray-jit")
69
+ end
70
+
71
+ def cache_directory
72
+ File.join(cache_root, environment_tag)
73
+ end
74
+
75
+ # Kernels are only good for the version that generated them and the
76
+ # architecture they were built for. Keeping those apart means a new
77
+ # release does not spend its cache budget on entries nothing can
78
+ # reach any more, and a home directory shared between machines --
79
+ # over NFS, or between Rosetta and native -- does not have one
80
+ # architecture evicting the other's kernels.
81
+ #
82
+ # CArray's version is in it because a kernel is not only generated for
83
+ # this library: it is handed CArray's memory, on layouts CArray
84
+ # decides, and reaches CArray's own C by address. A kernel that
85
+ # compiles against one version and runs against the next is a silent
86
+ # wrong answer rather than a load error, so the two versions travel
87
+ # together.
88
+ def environment_tag
89
+ "#{JIT::VERSION}-carray#{carray_version}-#{RbConfig::CONFIG['arch']}"
90
+ end
91
+
92
+ # CArray's own version is defined by its extension, and this may be
93
+ # running without it: the command that looks after the cache loads the
94
+ # compiler and nothing else, on purpose, so that a cache can be
95
+ # inspected or cleared when CArray itself will not load. RubyGems
96
+ # knows which version a `require` would activate without activating
97
+ # it, and that is the same one -- except where CArray is on the load
98
+ # path from a checkout, which nothing outside the process could have
99
+ # known either way.
100
+ def carray_version
101
+ return CArray::VERSION if defined?(CArray::VERSION)
102
+ return @carray_version if defined?(@carray_version)
103
+ @carray_version =
104
+ if defined?(Gem::Specification)
105
+ begin
106
+ Gem::Specification.find_by_name("carray").version.to_s
107
+ rescue StandardError
108
+ "none"
109
+ end
110
+ else
111
+ "none"
112
+ end
113
+ end
114
+
115
+ # Directories for versions and architectures no longer in use, so
116
+ # they can be reported and removed as a unit.
117
+ def stale_environments
118
+ root = cache_root
119
+ return [] unless File.directory?(root)
120
+ current = cache_directory
121
+ Dir[File.join(root, "*")].select { |path|
122
+ File.directory?(path) && path != current
123
+ }
124
+ end
125
+
126
+ def entry_limit
127
+ limit = ENV["CARRAY_JIT_CACHE_LIMIT"]
128
+ limit ? limit.to_i : DEFAULT_ENTRY_LIMIT
129
+ end
130
+
131
+ def entries
132
+ Dir[File.join(cache_directory, "*#{shared_object_suffix}")]
133
+ end
134
+
135
+ def entry_count
136
+ entries.size
137
+ end
138
+
139
+ def byte_size
140
+ Dir[File.join(cache_directory, "*")].sum do |path|
141
+ File.file?(path) ? File.size(path) : 0
142
+ end
143
+ end
144
+
145
+ # Safe even while a kernel from the cache is in use: unlinking a
146
+ # loaded shared object leaves the mapping intact.
147
+ #
148
+ # Clears this environment's kernels; pass everything: true to remove
149
+ # the directories other versions and architectures left behind too.
150
+ def clear (everything: false)
151
+ removed = clear_directory(cache_directory)
152
+ if everything
153
+ stale_environments.each do |path|
154
+ removed += Dir[File.join(path, "*#{shared_object_suffix}")].size
155
+ begin
156
+ FileUtils.remove_entry(path)
157
+ rescue StandardError
158
+ nil
159
+ end
160
+ end
161
+ end
162
+ removed
163
+ end
164
+
165
+ # Returns [Fiddle handle, whether it was compiled rather than reused].
166
+ #
167
+ # `header` says where the kernel was written. It is written into the
168
+ # .c file but kept out of the digest: two call sites that produce the
169
+ # same kernel are one cached object, and editing the lines above a
170
+ # kernel does not throw its object away. The file then names the
171
+ # first site that compiled it, which is a place the code was written
172
+ # rather than the only one.
173
+ def build (source, function_name, header: nil, flags: FLAGS)
174
+ dump(header.to_s + source)
175
+
176
+ directory = prepare(cache_directory)
177
+ key = digest(source, flags)
178
+ object_path = File.join(directory, "#{key}#{shared_object_suffix}")
179
+
180
+ if File.exist?(object_path)
181
+ handle = load_shared_object(object_path)
182
+ if handle
183
+ touch(object_path)
184
+ return [handle, false]
185
+ end
186
+ # The cached object is unusable -- a truncated write, a toolchain
187
+ # or OS change. Since the cache outlives the process, failing
188
+ # here would fail every future run as well, so drop it and build
189
+ # again.
190
+ remove_entry(object_path)
191
+ end
192
+
193
+ source_path = File.join(directory, "#{key}.c")
194
+ File.write(source_path, header.to_s + source)
195
+ # Build to a unique path and rename, so a concurrent process never
196
+ # loads a half-written object.
197
+ staging_path = "#{object_path}.#{Process.pid}"
198
+ compile(source_path, staging_path, flags)
199
+ File.rename(staging_path, object_path)
200
+ sweep_staging(directory)
201
+ sweep_environments
202
+ prune(directory, object_path)
203
+
204
+ [load_new_object(object_path), true]
205
+ end
206
+
207
+ # The flags are part of the key: the same C built two ways is two
208
+ # objects, and need not compute the same last bit.
209
+ def digest (source, flags = FLAGS)
210
+ Digest::SHA256.hexdigest([source,
211
+ compiler_identity,
212
+ flags.join(" "),
213
+ RbConfig::CONFIG["arch"],
214
+ target_identity(flags)].join("\0"))
215
+ end
216
+
217
+ # Flags that mean "this machine" rather than a named target. What
218
+ # they select is not in the flag's text, so it is not in the key
219
+ # either unless it is asked for.
220
+ NATIVE_FLAGS = /\A-m(?:arch|cpu|tune)=native\z/
221
+
222
+ # What `-march=native` actually chose, for the key.
223
+ #
224
+ # Without this, two machines can agree on every other component --
225
+ # the same source, the same `arch` (`x86_64-linux` spans Nehalem to
226
+ # Sapphire Rapids), the same flag *text*, and the same compiler,
227
+ # since #compiler_identity is a stat of a binary that a shared /usr
228
+ # or a common image makes identical -- and disagree only on which
229
+ # instructions exist. The one with the wider CPU writes an object
230
+ # the other then loads and executes. The cache directory is split
231
+ # by `arch` with a shared home in mind (see #environment_tag); this
232
+ # is the same care one level down.
233
+ #
234
+ # Asked of the compiler rather than the CPU, because what matters is
235
+ # what the compiler will emit, and it is the only thing that knows.
236
+ # The predefined macros carry the target triple and the instruction
237
+ # sets together, so one probe answers all of it.
238
+ #
239
+ # Costs a compiler run, so it is only paid when a flag actually says
240
+ # `native` -- the kernel path names no such flag and pays nothing --
241
+ # and then once per process for each set of flags.
242
+ def target_identity (flags)
243
+ native = flags.select { |flag| flag =~ NATIVE_FLAGS }
244
+ return "" if native.empty?
245
+
246
+ command = compiler_command
247
+ key = [command, native]
248
+ cached = @target_identity
249
+ return cached[1] if cached && cached[0] == key
250
+
251
+ macros = probe_macros(command, native)
252
+ identity = macros ? Digest::SHA256.hexdigest(macros)[0, 16] : "unprobed"
253
+ @target_identity = [key, identity]
254
+ identity
255
+ end
256
+
257
+ # The macros the compiler predefines for this target, sorted so that
258
+ # the order it prints them in cannot make two identical targets look
259
+ # different. A compiler that will not answer leaves the key saying
260
+ # so, which is worse than a real answer and better than a key that
261
+ # silently claims two machines are one.
262
+ def probe_macros (command, native)
263
+ argv = [*command.split, *native, "-E", "-dM", "-x", "c", "/dev/null"]
264
+ output = IO.popen(argv, err: File::NULL) { |io| io.read }
265
+ return nil unless $?&.success?
266
+ output.lines.sort.join
267
+ rescue SystemCallError, IOError
268
+ nil
269
+ end
270
+
271
+ # The compiler's name is not enough to key on: a cache shared with a
272
+ # toolchain upgrade would keep handing back objects the old compiler
273
+ # produced. Its size and mtime stand in for a version, and cost a
274
+ # stat rather than the ~50 ms of asking it with --version.
275
+ def compiler_identity
276
+ command = compiler_command
277
+ cached = @compiler_identity
278
+ return cached[1] if cached && cached[0] == command
279
+
280
+ path = resolve_compiler(command)
281
+ identity =
282
+ if path
283
+ stat = File.stat(path)
284
+ "#{path}:#{stat.size}:#{stat.mtime.to_i}"
285
+ else
286
+ command
287
+ end
288
+ @compiler_identity = [command, identity]
289
+ identity
290
+ end
291
+
292
+ def resolve_compiler (command)
293
+ program = command.split.first
294
+ return nil unless program
295
+ if program.include?(File::SEPARATOR)
296
+ return File.executable?(program) ? program : nil
297
+ end
298
+ ENV.fetch("PATH", "").split(File::PATH_SEPARATOR).each do |directory|
299
+ candidate = File.join(directory, program)
300
+ return candidate if File.file?(candidate) && File.executable?(candidate)
301
+ end
302
+ nil
303
+ end
304
+
305
+ private
306
+
307
+ def clear_directory (directory)
308
+ return 0 unless File.directory?(directory)
309
+ removed = Dir[File.join(directory, "*#{shared_object_suffix}")].size
310
+ Dir[File.join(directory, "*")].each do |path|
311
+ File.unlink(path) if File.file?(path)
312
+ end
313
+ removed
314
+ end
315
+
316
+ def ephemeral_directory
317
+ @ephemeral_directory ||= begin
318
+ directory = Dir.mktmpdir("carray-jit-")
319
+ at_exit do
320
+ begin
321
+ FileUtils.remove_entry(directory)
322
+ rescue StandardError
323
+ nil
324
+ end
325
+ end
326
+ directory
327
+ end
328
+ end
329
+
330
+ # 0700, and refuse a directory others can write to: everything in here
331
+ # gets dlopen'd, so a shared writable cache is a way to run code as
332
+ # this user.
333
+ def prepare (directory)
334
+ FileUtils.mkdir_p(directory, :mode => 0700) unless File.directory?(directory)
335
+ mode = File.stat(directory).mode
336
+ if (mode & 0022) != 0
337
+ raise CompilationError,
338
+ "#{directory} is writable by other users; " \
339
+ "carray-jit loads shared objects from it. " \
340
+ "Fix its permissions or set CARRAY_JIT_CACHE elsewhere."
341
+ end
342
+ directory
343
+ end
344
+
345
+ # Least-recently-used eviction, by modification time, which #touch
346
+ # keeps current on every reuse.
347
+ def prune (directory, keep)
348
+ limit = entry_limit
349
+ return if limit <= 0
350
+ objects = Dir[File.join(directory, "*#{shared_object_suffix}")]
351
+ return if objects.size <= limit
352
+
353
+ ordered = objects.sort_by { |path| File.mtime(path) rescue Time.at(0) }
354
+ (ordered - [keep]).first(objects.size - limit).each do |path|
355
+ remove_entry(path)
356
+ end
357
+ end
358
+
359
+ # A process killed mid-compile leaves `<key>.bundle.<pid>` behind.
360
+ # Those match neither the entry glob nor the prune glob, so without
361
+ # this they would sit in the cache for good. Only swept on a miss,
362
+ # to keep it off the reuse path.
363
+ STAGING_MAXIMUM_AGE = 300
364
+
365
+ def sweep_staging (directory)
366
+ now = Time.now
367
+ Dir[File.join(directory, "*#{shared_object_suffix}.*")].each do |path|
368
+ next unless File.file?(path)
369
+ age = now - File.mtime(path) rescue 0
370
+ next if age < STAGING_MAXIMUM_AGE
371
+ begin
372
+ File.unlink(path)
373
+ rescue StandardError
374
+ nil
375
+ end
376
+ end
377
+ end
378
+
379
+ # How long an unused environment's directory is kept. A release or an
380
+ # architecture that stops being used would otherwise sit in the cache
381
+ # for good, since nothing in it is ever reached again.
382
+ DEFAULT_ENVIRONMENT_MAXIMUM_AGE = 30 * 24 * 60 * 60
383
+
384
+ def environment_maximum_age
385
+ days = ENV["CARRAY_JIT_CACHE_MAX_AGE_DAYS"]
386
+ days ? days.to_i * 24 * 60 * 60 : DEFAULT_ENVIRONMENT_MAXIMUM_AGE
387
+ end
388
+
389
+ def sweep_environments
390
+ age = environment_maximum_age
391
+ return if age <= 0
392
+ now = Time.now
393
+ stale_environments.each do |path|
394
+ # A directory's own mtime only moves when entries are added or
395
+ # removed, so age it by its newest file instead: one that is
396
+ # being reused, and therefore touched, stays.
397
+ newest = Dir[File.join(path, "*")].map { |entry|
398
+ File.mtime(entry) rescue Time.at(0)
399
+ }.max
400
+ next if newest && (now - newest) < age
401
+ begin
402
+ FileUtils.remove_entry(path)
403
+ rescue StandardError
404
+ nil
405
+ end
406
+ end
407
+ end
408
+
409
+ def remove_entry (object_path)
410
+ source_path = object_path.sub(/#{Regexp.escape(shared_object_suffix)}\z/, ".c")
411
+ [object_path, source_path].each do |path|
412
+ begin
413
+ File.unlink(path) if File.exist?(path)
414
+ rescue StandardError
415
+ nil
416
+ end
417
+ end
418
+ end
419
+
420
+ def touch (path)
421
+ now = Time.now
422
+ File.utime(now, now, path)
423
+ rescue StandardError
424
+ nil
425
+ end
426
+
427
+ def dump (source)
428
+ return unless ENV["CARRAY_JIT_DUMP"]
429
+ warn("--- carray-jit generated source ---")
430
+ warn(source)
431
+ warn("-----------------------------------")
432
+ end
433
+
434
+ def shared_object_suffix
435
+ RbConfig::CONFIG["DLEXT"] ? ".#{RbConfig::CONFIG['DLEXT']}" : ".so"
436
+ end
437
+
438
+ def compile (source_path, object_path, flags = FLAGS)
439
+ command = [compiler_command, *flags, source_path, "-o", object_path, "-lm"]
440
+ output = IO.popen(command, err: [:child, :out]) { |io| io.read }
441
+ unless $?.success?
442
+ raise CompilationError, "#{command.join(' ')}\n#{output}"
443
+ end
444
+ end
445
+
446
+ # Returns nil rather than raising, so a bad cache entry can be
447
+ # rebuilt. A freshly compiled object that will not load is a real
448
+ # failure and is raised by #load_new_object.
449
+ def load_shared_object (path)
450
+ Fiddle.dlopen(path)
451
+ rescue Fiddle::DLError
452
+ nil
453
+ end
454
+
455
+ def load_new_object (path)
456
+ handle = load_shared_object(path)
457
+ unless handle
458
+ raise CompilationError, "could not load freshly compiled #{path}"
459
+ end
460
+ handle
461
+ end
462
+
463
+ end
464
+
465
+ end
466
+
467
+ end
468
+ end
@@ -0,0 +1,37 @@
1
+ class CArray
2
+ module JIT
3
+
4
+ # The base of every error this gem raises.
5
+ class Error < StandardError
6
+ end
7
+
8
+ # Raised when a kernel falls outside the recognized subset. Callers that
9
+ # want the Ruby evaluator instead of a hard failure rescue this.
10
+ class Unsupported < Error
11
+
12
+ # @return [Prism::Location, nil] where in the block the construct is, or
13
+ # `nil` when the message names no place.
14
+ attr_reader :location
15
+
16
+ # @param message [String] what was refused.
17
+ # @param location [Prism::Location, nil] where it is in the block; when
18
+ # given, the line and column are appended to the message.
19
+ def initialize (message, location = nil)
20
+ @location = location
21
+ if location
22
+ super("#{message} (at line #{location.start_line}, column #{location.start_column})")
23
+ else
24
+ super(message)
25
+ end
26
+ end
27
+
28
+ end
29
+
30
+ # Raised when the C compiler rejects generated source, or the shared
31
+ # object cannot be loaded. Never a fallback condition -- it means the
32
+ # generator emitted something wrong.
33
+ class CompilationError < Error
34
+ end
35
+
36
+ end
37
+ end