spinel_native 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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 341a37c7333aabb9a5ca73e8bfc1dfeea3949c9629fdc809c81c34ee83369c43
4
+ data.tar.gz: d5a28eafd41ffe17e21c5fee4af88807f1e7103209a163a8e78a88e23d25a6f7
5
+ SHA512:
6
+ metadata.gz: 79161e43d39d1d61384f14675c2b732c21c2b4d34f7ca0e38760ce929c339b04e51b0818d306c83bb74698e03b7bd447c42f1e4f257381a6e549090c5ac2b95c
7
+ data.tar.gz: cf96d9b21b54cb6a1adc910caceeced6aa8f14c766b2a70624ec9b13b1775918ba20e1551f154480ae066ba5ebc6ead092bba4d0bdd3d2ebfcffc7492cad9306
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ - `native def` marks a method; its first call samples the argument types,
6
+ compiles the kernel with `spinel --ext cruby`, and rebinds the method.
7
+ - `native "(T, ...) -> R"` declares the types instead; `Spinel::Native.compile!`
8
+ builds at boot.
9
+ - `SPINEL_NATIVE=off|verify|strict` modes; the Ruby definition stays as
10
+ fallback and oracle.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Hasiński
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # spinel_native
2
+
3
+ Compile a single Ruby method to native code with the
4
+ [Spinel](https://github.com/matz/spinel) AOT compiler, from inside a running
5
+ CRuby program. Mark the hot method, keep everything else on CRuby.
6
+
7
+ ```ruby
8
+ require "spinel/native"
9
+
10
+ module Physics
11
+ extend Spinel::Native
12
+
13
+ native def dot(a, b)
14
+ s = 0.0
15
+ i = 0
16
+ while i < a.length
17
+ s += a[i] * b[i]
18
+ i += 1
19
+ end
20
+ s
21
+ end
22
+ end
23
+
24
+ Physics.dot(xs, ys) # first call: compile + rebind (about 1s, cached on disk)
25
+ Physics.dot(xs, ys) # native
26
+ ```
27
+
28
+ That is the whole interface: `native def`. The method stays plain Ruby until
29
+ its first call. The argument types of that call seed Spinel's whole-program
30
+ type inference, the kernel is compiled into a CRuby extension, and the method
31
+ is rebound to the compiled entry. If anything goes wrong the Ruby definition
32
+ stays in place and a warning says why.
33
+
34
+ ## Interface
35
+
36
+ - `native def name(args)` marks a method. In a module it is also callable as
37
+ `Mod.name` (it can never use `self`, so the distinction is moot). `native
38
+ def self.name` works too, and so do instance methods of a class.
39
+ - `native "(Array[Float], Integer) -> Float"` on the line before a `def`
40
+ declares the parameter types instead of sampling them. The return type is
41
+ inferred by Spinel and only checked for shape.
42
+ - `Spinel::Native.compile!(Mod)` compiles every declared entry now, at boot,
43
+ so compile errors surface before the first request.
44
+ - `SPINEL_NATIVE=off` runs the Ruby definitions only. `verify` runs both paths
45
+ on every call and raises `Spinel::Native::Mismatch` when they disagree (the
46
+ Ruby definition is the oracle). `strict` turns the silent fallback into a
47
+ raised `CompileError` / `TypeError`. Same knob: `Spinel::Native.mode = :verify`.
48
+ - `SPINEL=/path/to/spinel` names the compiler (otherwise `spinel` on PATH),
49
+ `SPINEL_NATIVE_CACHE` the build cache (default `~/.cache/spinel-native`),
50
+ `SPINEL_NATIVE_VERBOSE=1` prints the commands and timings.
51
+
52
+ ## Rules for a native method
53
+
54
+ - Parameters and the return value cross the boundary **by copy**. Supported
55
+ types: `Integer` (64-bit), `Float`, `String`, `bool`, and `Array` of those.
56
+ Mutating a parameter is refused at compile time; return the result instead.
57
+ - The body must not touch `self`, instance variables, or anything outside the
58
+ module. Native methods may call each other; everything reachable must be
59
+ `native` too, because the kernel is exactly the set of marked methods.
60
+ - A `raise` inside the kernel arrives in Ruby as the same exception class and
61
+ message. Integer overflow raises `RangeError` where CRuby would promote to a
62
+ Bignum, and a Bignum argument is a `RangeError` at the boundary.
63
+ - The kernel runs without the GVL, one call at a time per module.
64
+
65
+ ## How it works
66
+
67
+ 1. `Method#source_location` plus Prism pull the `def` back out of its file.
68
+ 2. The marked methods become `def self.` methods of a synthetic module, with
69
+ a `if __FILE__ == $0` driver that calls each exported entry once with a
70
+ literal of its parameter types (`[0.0]`, `0`, `"x"`). Spinel infers from
71
+ that call site and never runs the driver.
72
+ 3. `spinel kernel.rb -c --ext cruby --ext-init ... --ext-entry Mod.a,Mod.b`
73
+ emits the kernel C, a header contract, and a CRuby shim that converts
74
+ `VALUE`s, releases the GVL, and re-raises kernel exceptions.
75
+ 4. The C compiler from `RbConfig` links those with the Spinel runtime into a
76
+ shared object, keyed in the cache by the kernel source, the entries, the
77
+ Spinel binary and the Ruby ABI. The runtime itself is compiled once per
78
+ Spinel build with `-fPIC` (a couple of seconds), since the archive Spinel
79
+ ships is meant for executables. On Linux the object exports only its
80
+ `Init_*` symbol and binds the rest internally, because CRuby loads
81
+ extensions `RTLD_GLOBAL` and two kernels would otherwise share one
82
+ `sp_raise_cls`. `require` loads the object; the method is redefined to
83
+ forward to the extension.
84
+
85
+ ## Install
86
+
87
+ Spinel is not on RubyGems; build it from source once and point the gem at it:
88
+
89
+ ```sh
90
+ git clone https://github.com/matz/spinel && cd spinel && make deps && make
91
+ export SPINEL=$PWD/bin/spinel
92
+ gem install spinel_native # or: gem "spinel_native" in the Gemfile
93
+ ```
94
+
95
+ ## Running the example
96
+
97
+ ```sh
98
+ git clone https://github.com/khasinski/spinel_native && cd spinel_native
99
+ ruby -I lib examples/demo.rb
100
+ bundle exec rake test
101
+ ```
102
+
103
+ On an M-series Mac with CRuby master the demo prints roughly:
104
+
105
+ ```
106
+ dot(2M floats) ruby 0.136s native 0.016s x8.4
107
+ mandel_row(4000) ruby 0.517s native 0.013s x39.6
108
+ count_primes(5M) ruby 0.645s native 0.072s x9.0
109
+ ```
110
+
111
+ ## Status
112
+
113
+ A proof of concept. Not yet done: shipping the compiled kernel inside a gem
114
+ (Spinel's own `spin ext` covers that path), zero-copy numeric buffers, keyword
115
+ and block parameters, Hash parameters, and `--int-overflow=promote` parity.
data/examples/demo.rb ADDED
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ruby -I lib examples/demo.rb
4
+ require "benchmark"
5
+ require "spinel/native"
6
+
7
+ module Physics
8
+ extend Spinel::Native
9
+
10
+ # Types are sampled from the first call.
11
+ native def dot(a, b)
12
+ s = 0.0
13
+ i = 0
14
+ while i < a.length
15
+ s += a[i] * b[i]
16
+ i += 1
17
+ end
18
+ s
19
+ end
20
+
21
+ # Or declared, so the kernel can be compiled before the first call.
22
+ native "(Float, Float, Integer) -> Integer"
23
+ def mandel(cr, ci, limit)
24
+ zr = 0.0
25
+ zi = 0.0
26
+ n = 0
27
+ while n < limit && zr * zr + zi * zi < 4.0
28
+ t = zr * zr - zi * zi + cr
29
+ zi = 2.0 * zr * zi + ci
30
+ zr = t
31
+ n += 1
32
+ end
33
+ n
34
+ end
35
+
36
+ # Native methods may call each other; everything reachable must be native too.
37
+ native def mandel_row(ci, width, limit)
38
+ out = []
39
+ x = 0
40
+ while x < width
41
+ out << mandel(-2.0 + 3.0 * x / width, ci, limit)
42
+ x += 1
43
+ end
44
+ out
45
+ end
46
+ end
47
+
48
+ class Sieve
49
+ extend Spinel::Native
50
+
51
+ # Instance methods work too; they must not touch ivars or self.
52
+ native def count_primes(n)
53
+ flags = Array.new(n + 1, true)
54
+ count = 0
55
+ i = 2
56
+ while i <= n
57
+ if flags[i]
58
+ count += 1
59
+ j = i * i
60
+ while j <= n
61
+ flags[j] = false
62
+ j += i
63
+ end
64
+ end
65
+ i += 1
66
+ end
67
+ count
68
+ end
69
+ end
70
+
71
+ a = Array.new(2_000_000) { |i| i * 0.5 }
72
+
73
+ Spinel::Native.mode = :off
74
+ t_ruby = Benchmark.realtime { 3.times { Physics.dot(a, a) } } / 3
75
+ p_ruby = Benchmark.realtime { Physics.mandel_row(0.1, 4000, 2000) }
76
+ s_ruby = Benchmark.realtime { Sieve.new.count_primes(5_000_000) }
77
+
78
+ Spinel::Native.mode = :on
79
+ Physics.dot(a, a) # first call compiles (or loads from cache)
80
+ Physics.mandel_row(0.1, 4, 10)
81
+ Sieve.new.count_primes(10)
82
+ t_nat = Benchmark.realtime { 3.times { Physics.dot(a, a) } } / 3
83
+ p_nat = Benchmark.realtime { Physics.mandel_row(0.1, 4000, 2000) }
84
+ s_nat = Benchmark.realtime { Sieve.new.count_primes(5_000_000) }
85
+
86
+ puts "dot(2M floats) ruby %.3fs native %.3fs x%.1f" % [t_ruby, t_nat, t_ruby / t_nat]
87
+ puts "mandel_row(4000) ruby %.3fs native %.3fs x%.1f" % [p_ruby, p_nat, p_ruby / p_nat]
88
+ puts "count_primes(5M) ruby %.3fs native %.3fs x%.1f" % [s_ruby, s_nat, s_ruby / s_nat]
89
+ Spinel::Native.mode = :off
90
+ ruby_dot = Physics.dot(a, a)
91
+ Spinel::Native.mode = :on
92
+ puts "results agree: #{Physics.dot(a, a) == ruby_dot && Sieve.new.count_primes(100) == 25 && Physics.mandel(0.0, 0.0, 50) == 50}"
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+ require "open3"
5
+ require "shellwords"
6
+
7
+ module Spinel
8
+ module Native
9
+ # Turns a kernel (a module of `def self.` methods plus a witness driver)
10
+ # into a loaded CRuby extension:
11
+ #
12
+ # spinel kernel.rb -c --ext cruby --ext-init ... --ext-entry Mod.a,Mod.b
13
+ # cc -bundle kernel.c kernel_ext.c libspinel_rt.a
14
+ # require kernel.bundle
15
+ #
16
+ # Builds are cached under SPINEL_NATIVE_CACHE (default ~/.cache/spinel-native)
17
+ # keyed by the kernel source, the exported entries, the spinel binary and
18
+ # the Ruby ABI, so a repeated run of the same program compiles nothing.
19
+ class Builder
20
+ Result = Struct.new(:module_name, :bundle, :dir, :cached, :seconds)
21
+
22
+ class << self
23
+ def spinel_bin
24
+ @spinel_bin ||= begin
25
+ env = ENV["SPINEL"].to_s
26
+ found = env.empty? ? which("spinel") : env
27
+ raise Error, "spinel compiler not found: set SPINEL=/path/to/spinel or put it on PATH" if found.to_s.empty?
28
+ File.realpath(found)
29
+ end
30
+ end
31
+
32
+ # The runtime headers and archive ship beside the compiler: <root>/bin/spinel, <root>/lib.
33
+ def runtime_dir
34
+ @runtime_dir ||= begin
35
+ env = ENV["SPINEL_HDR_DIR"].to_s
36
+ candidates = [env, File.expand_path("../lib", File.dirname(spinel_bin)),
37
+ File.expand_path("lib", File.dirname(spinel_bin))]
38
+ candidates.find { |d| !d.empty? && File.exist?(File.join(d, "spinel_rt.h")) } or
39
+ raise Error, "spinel runtime (spinel_rt.h) not found next to #{spinel_bin}; set SPINEL_HDR_DIR"
40
+ end
41
+ end
42
+
43
+ def cache_dir
44
+ ENV["SPINEL_NATIVE_CACHE"] || File.join(ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache"), "spinel-native")
45
+ end
46
+
47
+ def which(cmd)
48
+ ENV["PATH"].split(File::PATH_SEPARATOR).each do |d|
49
+ p = File.join(d, cmd)
50
+ return p if File.executable?(p) && !File.directory?(p)
51
+ end
52
+ nil
53
+ end
54
+
55
+ def fingerprint
56
+ @fingerprint ||= begin
57
+ st = File.stat(spinel_bin)
58
+ Digest::SHA256.hexdigest([st.size, st.mtime.to_i, RUBY_VERSION, RUBY_PLATFORM, RbConfig::CONFIG["CC"]].join("|"))
59
+ end
60
+ end
61
+
62
+ def cc
63
+ Shellwords.split(RbConfig::CONFIG["CC"] || "cc")
64
+ end
65
+
66
+ # The runtime archive Spinel ships is built for executables, not
67
+ # position-independent code, so a shared object cannot link it on
68
+ # Linux. Compile the runtime sources once per Spinel build into a
69
+ # -fPIC archive in the cache; the sources sit beside the headers.
70
+ def runtime_archive
71
+ @runtime_archive ||= begin
72
+ dir = File.join(cache_dir, "runtime_#{fingerprint[0, 12]}")
73
+ archive = File.join(dir, "libspinel_rt_pic.a")
74
+ build_runtime_archive(dir, archive) unless File.exist?(archive)
75
+ archive
76
+ end
77
+ end
78
+
79
+ def build_runtime_archive(dir, archive)
80
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
81
+ FileUtils.mkdir_p(dir)
82
+ sources = Dir[File.join(runtime_dir, "*.c")] + Dir[File.join(runtime_dir, "regexp", "*.c")]
83
+ raise Error, "no runtime sources in #{runtime_dir}" if sources.empty?
84
+ jobs = Etc.respond_to?(:nprocessors) ? Etc.nprocessors : 4
85
+ queue = Queue.new
86
+ sources.each { |src| queue << src }
87
+ failures = Queue.new
88
+ objects = sources.map { |src| File.join(dir, File.basename(src, ".c") + ".o") }
89
+ Array.new(jobs) do
90
+ Thread.new do
91
+ while (src = queue.pop(true) rescue nil)
92
+ obj = File.join(dir, File.basename(src, ".c") + ".o")
93
+ cmd = [*cc, "-c", "-fPIC", "-O2", "-w", "-ffunction-sections", "-fdata-sections",
94
+ "-I#{runtime_dir}", "-I#{File.join(runtime_dir, 'regexp')}", src, "-o", obj]
95
+ out, status = Open3.capture2e(*cmd)
96
+ failures << "#{cmd.join(' ')}\n#{out}" unless status.success?
97
+ end
98
+ end
99
+ end.each(&:join)
100
+ raise CompileError, "compiling the Spinel runtime failed:\n#{failures.pop}" unless failures.empty?
101
+ out, status = Open3.capture2e("ar", "rcs", archive, *objects)
102
+ raise CompileError, "ar failed:\n#{out}" unless status.success?
103
+ FileUtils.rm_f(objects)
104
+ Native.log("runtime compiled with -fPIC in #{(Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0).round(2)}s (#{archive})")
105
+ end
106
+ end
107
+
108
+ # +body+ is the module body (the `def self.` methods), +entries+ maps the
109
+ # exported names to their parameter types.
110
+ def initialize(body, entries)
111
+ @body = body
112
+ @entries = entries
113
+ digest = Digest::SHA256.hexdigest([self.class.fingerprint, body, entries.inspect].join("\0"))[0, 12]
114
+ @module_name = "SpinelKernel#{digest}"
115
+ @feature = "spinel_kernel_#{digest}"
116
+ @dir = File.join(self.class.cache_dir, @feature)
117
+ end
118
+
119
+ attr_reader :module_name, :feature, :dir
120
+
121
+ def kernel_source
122
+ witness = @entries.map do |name, types|
123
+ " #{@module_name}.#{name}(#{types.map { |t| Types.witness(t) }.join(', ')})"
124
+ end
125
+ <<~RUBY
126
+ module #{@module_name}
127
+ #{@body.gsub(/^/, " ")}
128
+ end
129
+
130
+ if __FILE__ == $0
131
+ #{witness.join("\n")}
132
+ end
133
+ RUBY
134
+ end
135
+
136
+ def bundle
137
+ File.join(@dir, "#{@feature}.#{RbConfig::CONFIG['DLEXT']}")
138
+ end
139
+
140
+ def build
141
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
142
+ cached = File.exist?(bundle)
143
+ unless cached
144
+ FileUtils.mkdir_p(@dir)
145
+ File.write(File.join(@dir, "kernel.rb"), kernel_source)
146
+ run_spinel
147
+ run_cc
148
+ end
149
+ require bundle
150
+ Result.new(@module_name, bundle, @dir, cached, Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0)
151
+ end
152
+
153
+ private
154
+
155
+ def run_spinel
156
+ entry_list = @entries.keys.map { |n| "#{@module_name}.#{n}" }.join(",")
157
+ cmd = [self.class.spinel_bin, File.join(@dir, "kernel.rb"), "-c", "--no-line-map",
158
+ "--ext", "cruby", "--ext-init", "spx_init_#{@feature}", "--ext-entry", entry_list,
159
+ "-o", File.join(@dir, "#{@feature}.c")]
160
+ sh(cmd, "spinel")
161
+ end
162
+
163
+ # Every kernel carries its own copy of the runtime and exports the same
164
+ # global symbols. CRuby dlopens extensions RTLD_GLOBAL, and an ELF
165
+ # shared object binds its calls to the first definition in the
166
+ # process, so a second kernel would raise through the first kernel's
167
+ # exception stack and die as "unhandled". Export only Init_* and bind
168
+ # everything else inside the object. (Mach-O two-level namespaces do
169
+ # this by default.)
170
+ def shared_flags
171
+ return %w[-bundle -Wl,-undefined,dynamic_lookup] if RUBY_PLATFORM.include?("darwin")
172
+ script = File.join(@dir, "exports.map")
173
+ File.write(script, "{ global: Init_*; local: *; };\n")
174
+ ["-shared", "-Wl,-Bsymbolic", "-Wl,--version-script=#{script}"]
175
+ end
176
+
177
+ def run_cc
178
+ cmd = [*self.class.cc, *shared_flags, "-fPIC", "-O2", "-w",
179
+ "-I#{RbConfig::CONFIG['rubyhdrdir']}", "-I#{RbConfig::CONFIG['rubyarchhdrdir']}",
180
+ "-I#{self.class.runtime_dir}", "-I#{@dir}",
181
+ File.join(@dir, "#{@feature}.c"), File.join(@dir, "#{@feature}_ext.c"),
182
+ self.class.runtime_archive, "-lm", "-o", bundle]
183
+ sh(cmd, "cc")
184
+ end
185
+
186
+ def sh(cmd, what)
187
+ Native.log(cmd.join(" "))
188
+ out, status = Open3.capture2e(*cmd)
189
+ File.write(File.join(@dir, "#{what}.log"), out)
190
+ return if status.success?
191
+ FileUtils.rm_f(bundle)
192
+ raise CompileError, "#{what} failed (exit #{status.exitstatus}) building #{@dir}:\n#{out}"
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spinel
4
+ module Native
5
+ # One per module that `extend Spinel::Native`. Tracks the methods marked
6
+ # `native`, keeps their pure-Ruby definitions, and swaps the compiled
7
+ # entries in once their parameter types are known.
8
+ class Registry
9
+ Entry = Struct.new(:name, :kind, :pure, :source, :types, :compiled, :module_function, keyword_init: true)
10
+
11
+ attr_accessor :pending_signature
12
+ attr_reader :entries
13
+
14
+ def initialize(owner)
15
+ @owner = owner
16
+ @entries = {}
17
+ @last_def = nil
18
+ @installing = false
19
+ end
20
+
21
+ # Called from the method_added hooks.
22
+ def defined(kind, name)
23
+ return if @installing
24
+ @last_def = [kind, name]
25
+ return unless @pending_signature
26
+ sig = @pending_signature
27
+ @pending_signature = nil
28
+ mark(name, sig)
29
+ end
30
+
31
+ # `native def name` (types nil: sampled from the first call) or a
32
+ # signature-declared entry.
33
+ def mark(name, types = nil)
34
+ kind = @last_def && @last_def[1] == name ? @last_def[0] : :instance
35
+ pure = kind == :singleton ? @owner.method(name) : @owner.instance_method(name)
36
+ entry = Entry.new(name: name, kind: kind, pure: pure, source: Source.of_method(pure), types: types,
37
+ # in a module, `native def x` is also callable as Mod.x (it never uses self)
38
+ module_function: kind == :instance && @owner.instance_of?(Module))
39
+ if types && pure.arity != types.length
40
+ raise TypeError, "#{name}: signature has #{types.length} parameter(s), the method takes #{pure.arity}"
41
+ end
42
+ @entries[name] = entry
43
+ install(entry) { |args, this| dispatch(entry, args, this) }
44
+ entry
45
+ end
46
+
47
+ def compile_known!
48
+ known = @entries.values.select { |e| e.types && !e.compiled }
49
+ compile(known) unless known.empty?
50
+ known
51
+ end
52
+
53
+ # The pure definition, bound like the original call.
54
+ def pure_call(entry, args, this)
55
+ entry.kind == :singleton ? entry.pure.call(*args) : entry.pure.bind_call(this, *args)
56
+ end
57
+
58
+ private
59
+
60
+ # First call of a not-yet-compiled entry: settle its types, compile the
61
+ # kernel, then either forward or (verify mode) keep comparing.
62
+ def dispatch(entry, args, this)
63
+ return pure_call(entry, args, this) if Native.mode == :off
64
+ unless entry.compiled
65
+ entry.types ||= args.map { |a| Types.of_value(a) }
66
+ compile(@entries.values.select { |e| e.types && !e.compiled })
67
+ end
68
+ return pure_call(entry, args, this) unless entry.compiled
69
+ native_call(entry, args, this)
70
+ rescue Native::TypeError, CompileError => e
71
+ raise if Native.mode == :strict
72
+ warn "[spinel-native] #{@owner}##{entry.name}: staying on Ruby (#{e.message.lines.first.strip})"
73
+ entry.types = nil
74
+ install(entry) { |a, t| pure_call(entry, a, t) }
75
+ pure_call(entry, args, this)
76
+ end
77
+
78
+ def native_call(entry, args, this)
79
+ got = entry.compiled.public_send(entry.name, *args)
80
+ if Native.mode == :verify
81
+ want = pure_call(entry, args, this)
82
+ unless want == got || (want.is_a?(Float) && got.is_a?(Float) && want.nan? && got.nan?)
83
+ raise Mismatch, "#{@owner}##{entry.name}(#{args.map(&:inspect).join(', ')}): ruby=#{want.inspect} native=#{got.inspect}"
84
+ end
85
+ end
86
+ got
87
+ end
88
+
89
+ # Every marked method goes into the kernel (they may call each other);
90
+ # the ones with known types are exported.
91
+ def compile(exports)
92
+ body = @entries.values.map(&:source).join("\n\n")
93
+ builder = Builder.new(body, exports.to_h { |e| [e.name, e.types] })
94
+ result = builder.build
95
+ mod = Object.const_get(result.module_name)
96
+ exports.each do |e|
97
+ e.compiled = mod
98
+ if Native.mode == :verify
99
+ install(e) { |args, this| native_call(e, args, this) }
100
+ else
101
+ install(e) { |args, _this| mod.public_send(e.name, *args) }
102
+ end
103
+ end
104
+ Native.log("#{@owner}: #{exports.map { |e| "#{e.name}(#{e.types.map { |t| Types.to_s(t) }.join(', ')})" }.join(', ')} " \
105
+ "#{result.cached ? 'loaded from cache' : 'compiled'} in #{result.seconds.round(2)}s (#{result.dir})")
106
+ end
107
+
108
+ def install(entry, &body)
109
+ @installing = true
110
+ target = entry.kind == :singleton ? @owner.singleton_class : @owner
111
+ redefine(target, entry.name) { |*args| body.call(args, self) }
112
+ redefine(@owner.singleton_class, entry.name) { |*args| body.call(args, self) } if entry.module_function
113
+ ensure
114
+ @installing = false
115
+ end
116
+
117
+ # define_method over an existing definition warns under -w; drop it first.
118
+ def redefine(target, name, &impl)
119
+ target.send(:remove_method, name) if target.method_defined?(name, false) || target.private_method_defined?(name, false)
120
+ target.send(:define_method, name, &impl)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spinel
4
+ module Native
5
+ # Pulls the source text of a method back out of its file with Prism, and
6
+ # rewrites its header to the `def self.name` form the kernel module uses.
7
+ module Source
8
+ module_function
9
+
10
+ def of_method(meth)
11
+ file, line = meth.source_location
12
+ raise Error, "#{meth.name}: no source location (defined in C or eval?)" unless file && File.exist?(file)
13
+ node = find_def(Prism.parse_file(file).value, meth.name, line)
14
+ raise Error, "#{meth.name}: no `def` found at #{file}:#{line}" unless node
15
+ as_module_function(node)
16
+ end
17
+
18
+ def find_def(node, name, line)
19
+ return node if node.is_a?(Prism::DefNode) && node.name == name && node.location.start_line == line
20
+ node.compact_child_nodes.each do |child|
21
+ found = find_def(child, name, line)
22
+ return found if found
23
+ end
24
+ nil
25
+ end
26
+
27
+ def as_module_function(node)
28
+ raise Error, "#{node.name}: block parameters cannot cross the boundary" if node.parameters&.block
29
+ text = node.slice
30
+ text.sub(/\Adef\s+(self\s*\.\s*)?#{Regexp.escape(node.name.to_s)}/, "def self.#{node.name}")
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spinel
4
+ module Native
5
+ # The types that cross the extension boundary (by copy), and how a Ruby
6
+ # value maps onto them. A type is one of:
7
+ # :int, :float, :str, :bool, [:array, :int|:float|:str]
8
+ module Types
9
+ module_function
10
+
11
+ NAMES = {
12
+ "Integer" => :int, "Float" => :float, "String" => :str,
13
+ "bool" => :bool, "true" => :bool, "false" => :bool,
14
+ }.freeze
15
+
16
+ # "(Array[Float], Integer) -> Float" => [[:array, :float], :int]
17
+ # The return type is inferred by Spinel and only checked for shape here.
18
+ def parse_signature(sig)
19
+ m = sig.strip.match(/\A\((.*)\)\s*->\s*(.+)\z/m)
20
+ raise TypeError, "bad signature #{sig.inspect}, want \"(T1, T2) -> R\"" unless m
21
+ params = split_top_level(m[1]).map { |t| parse_type(t) }
22
+ parse_type(m[2])
23
+ params
24
+ end
25
+
26
+ def parse_type(text)
27
+ t = text.strip
28
+ if (m = t.match(/\AArray\[(.+)\]\z/))
29
+ elem = parse_type(m[1])
30
+ raise TypeError, "nested arrays cannot cross the boundary: #{t}" if elem.is_a?(Array)
31
+ return [:array, elem]
32
+ end
33
+ NAMES.fetch(t) { raise TypeError, "unsupported type #{t.inspect} (supported: Integer, Float, String, bool, Array[...])" }
34
+ end
35
+
36
+ def split_top_level(text)
37
+ out, depth, cur = [], 0, +""
38
+ text.each_char do |ch|
39
+ case ch
40
+ when "[" then depth += 1; cur << ch
41
+ when "]" then depth -= 1; cur << ch
42
+ when "," then depth.zero? ? (out << cur; cur = +"") : cur << ch
43
+ else cur << ch
44
+ end
45
+ end
46
+ out << cur unless cur.strip.empty?
47
+ out.map(&:strip)
48
+ end
49
+
50
+ # The boundary type of a live Ruby value, or raise TypeError.
51
+ def of_value(v)
52
+ case v
53
+ when Integer then :int
54
+ when Float then :float
55
+ when String then :str
56
+ when true, false then :bool
57
+ when Array
58
+ raise TypeError, "cannot infer the element type of an empty array (declare a signature)" if v.empty?
59
+ elem = of_value(v.first)
60
+ raise TypeError, "nested arrays cannot cross the boundary" if elem.is_a?(Array)
61
+ unless v.all? { |e| of_value(e) == elem }
62
+ raise TypeError, "mixed-type array cannot cross the boundary"
63
+ end
64
+ [:array, elem]
65
+ else
66
+ raise TypeError, "#{v.class} cannot cross the boundary (Integer, Float, String, bool, Array of those)"
67
+ end
68
+ end
69
+
70
+ # A Ruby literal of the type, used as the witness call that seeds
71
+ # Spinel's whole-program inference for an exported entry.
72
+ def witness(type)
73
+ case type
74
+ in :int then "0"
75
+ in :float then "0.0"
76
+ in :str then '"x"'
77
+ in :bool then "true"
78
+ in [:array, elem] then "[#{witness(elem)}]"
79
+ end
80
+ end
81
+
82
+ def to_s(type)
83
+ case type
84
+ in :int then "Integer"
85
+ in :float then "Float"
86
+ in :str then "String"
87
+ in :bool then "bool"
88
+ in [:array, elem] then "Array[#{to_s(elem)}]"
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Spinel
4
+ module Native
5
+ VERSION = "0.1.0"
6
+ end
7
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Spinel::Native -- compile individual Ruby methods to a native extension with
4
+ # the Spinel AOT compiler, from inside a running CRuby program.
5
+ #
6
+ # module Physics
7
+ # extend Spinel::Native
8
+ #
9
+ # native def dot(a, b)
10
+ # s = 0.0
11
+ # i = 0
12
+ # while i < a.length
13
+ # s += a[i] * b[i]
14
+ # i += 1
15
+ # end
16
+ # s
17
+ # end
18
+ # end
19
+ #
20
+ # The method keeps running as plain Ruby until it is first called; the
21
+ # argument types of that first call seed Spinel's type inference, the kernel
22
+ # is compiled into a CRuby extension (cached on disk), and the method is
23
+ # re-bound to the compiled entry. If anything fails the pure-Ruby definition
24
+ # stays in place, so the program is never worse off than without the gem.
25
+ #
26
+ # SPINEL_NATIVE=off never compile, run the Ruby definitions
27
+ # SPINEL_NATIVE=verify run both and raise Spinel::Native::Mismatch on divergence
28
+ # SPINEL_NATIVE=strict a compile failure raises instead of falling back
29
+
30
+ require "rbconfig"
31
+ require "digest"
32
+ require "fileutils"
33
+ require "prism"
34
+
35
+ require_relative "native/version"
36
+ require_relative "native/types"
37
+ require_relative "native/source"
38
+ require_relative "native/builder"
39
+ require_relative "native/registry"
40
+
41
+ module Spinel
42
+ module Native
43
+ class Error < StandardError; end
44
+ class CompileError < Error; end
45
+ class TypeError < Error; end
46
+ class Mismatch < Error; end
47
+
48
+ class << self
49
+ # :on (default), :off, :verify, :strict
50
+ def mode
51
+ @mode ||= (ENV["SPINEL_NATIVE"] || "on").to_sym
52
+ end
53
+ attr_writer :mode
54
+
55
+ def verbose?
56
+ ENV["SPINEL_NATIVE_VERBOSE"] == "1"
57
+ end
58
+
59
+ def log(msg)
60
+ warn("[spinel-native] #{msg}") if verbose?
61
+ end
62
+
63
+ # Compile every native method of +mod+ whose parameter types are known
64
+ # (from a signature or an earlier call). Useful at boot to pay the
65
+ # compile cost up front and surface errors early.
66
+ def compile!(mod)
67
+ registry_of(mod).compile_known!
68
+ end
69
+
70
+ # The registry behind a module that has `extend Spinel::Native`.
71
+ def registry_of(mod)
72
+ mod.instance_variable_get(:@__spinel_native) or
73
+ raise Error, "#{mod} does not extend Spinel::Native"
74
+ end
75
+
76
+ def extended(base)
77
+ base.instance_variable_set(:@__spinel_native, Registry.new(base))
78
+ base.singleton_class.prepend(Hooks)
79
+ end
80
+ end
81
+
82
+ # Records which `def` ran last so `native def x` knows what it marked.
83
+ module Hooks
84
+ def method_added(name)
85
+ @__spinel_native&.defined(:instance, name)
86
+ super
87
+ end
88
+
89
+ def singleton_method_added(name)
90
+ @__spinel_native&.defined(:singleton, name)
91
+ super
92
+ end
93
+ end
94
+
95
+ # native def foo(a, b) ... end types sampled from the first call
96
+ # native "(Array[Float], Integer) -> Float"; def foo(a, b) ... end
97
+ # types declared, compiled on first call
98
+ def native(arg = nil)
99
+ registry = @__spinel_native
100
+ case arg
101
+ when Symbol then registry.mark(arg)
102
+ when String then registry.pending_signature = Types.parse_signature(arg)
103
+ when nil then raise ArgumentError, "native: expected `native def ...` or `native \"(...) -> ...\"`"
104
+ else raise ArgumentError, "native: unexpected #{arg.inspect}"
105
+ end
106
+ arg
107
+ end
108
+ end
109
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: spinel_native
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Chris Hasiński
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: prism
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Mark a hot method with `native def`; on its first call it is compiled
27
+ by Spinel into a CRuby extension and rebound, with the Ruby definition kept as fallback
28
+ and oracle.
29
+ email:
30
+ - krzysztof.hasinski@gmail.com
31
+ executables: []
32
+ extensions: []
33
+ extra_rdoc_files: []
34
+ files:
35
+ - CHANGELOG.md
36
+ - LICENSE
37
+ - README.md
38
+ - examples/demo.rb
39
+ - lib/spinel/native.rb
40
+ - lib/spinel/native/builder.rb
41
+ - lib/spinel/native/registry.rb
42
+ - lib/spinel/native/source.rb
43
+ - lib/spinel/native/types.rb
44
+ - lib/spinel/native/version.rb
45
+ homepage: https://github.com/khasinski/spinel_native
46
+ licenses:
47
+ - MIT
48
+ metadata:
49
+ source_code_uri: https://github.com/khasinski/spinel_native
50
+ changelog_uri: https://github.com/khasinski/spinel_native/blob/main/CHANGELOG.md
51
+ bug_tracker_uri: https://github.com/khasinski/spinel_native/issues
52
+ rubygems_mfa_required: 'true'
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.4'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 4.0.16
68
+ specification_version: 4
69
+ summary: Compile individual Ruby methods to native code with the Spinel AOT compiler
70
+ test_files: []