spinel_native
Compile a single Ruby method to native code with the Spinel AOT compiler, from inside a running CRuby program. Mark the hot method, keep everything else on CRuby.
require "spinel/native"
module Physics
extend Spinel::Native
native def dot(a, b)
s = 0.0
i = 0
while i < a.length
s += a[i] * b[i]
i += 1
end
s
end
end
Physics.dot(xs, ys) # first call: compile + rebind (about 1s, cached on disk)
Physics.dot(xs, ys) # nativeThat is the whole interface: native def. The method stays plain Ruby until
its first call. The argument types of that call seed Spinel's whole-program
type inference, the kernel is compiled into a CRuby extension, and the method
is rebound to the compiled entry. If anything goes wrong the Ruby definition
stays in place and a warning says why.
Interface
Mark a method
native def is the whole DSL. Types are sampled from the first call.
module Physics
extend Spinel::Native
native def dot(a, b)
s = 0.0
i = 0
while i < a.length
s += a[i] * b[i]
i += 1
end
s
end
end
Physics.dot([1.0, 2.0], [3.0, 4.0]) # => 11.0, compiled on this callIn a module the method is also callable as Physics.dot, since a native
method never uses self. Singleton and instance forms work the same way:
module Text
extend Spinel::Native
native def self.count_vowels(s)
n = 0
s.each_char { |c| n += 1 if "aeiou".include?(c) }
n
end
end
class Sieve
extend Spinel::Native
native def count_primes(n)
flags = Array.new(n + 1, true)
count = 0
i = 2
while i <= n
if flags[i]
count += 1
j = i * i
while j <= n
flags[j] = false
j += i
end
end
i += 1
end
count
end
end
Text.count_vowels("spinel") # => 2
Sieve.new.count_primes(100) # => 25Native methods may call each other; everything reachable must be native
too, because the kernel is exactly the set of marked methods:
module Fractal
extend Spinel::Native
native def mandel(cr, ci, limit)
zr = zi = 0.0
n = 0
while n < limit && zr * zr + zi * zi < 4.0
zr, zi = zr * zr - zi * zi + cr, 2.0 * zr * zi + ci
n += 1
end
n
end
native def row(ci, width, limit)
(0...width).map { |x| mandel(-2.0 + 3.0 * x / width, ci, limit) }
end
endDeclare the types
A signature on the line before the def replaces sampling. The return
type is inferred by Spinel and only checked for shape.
module Stats
extend Spinel::Native
native "(Array[Float]) -> Float"
def mean(xs)
xs.sum / xs.length
end
endDeclared entries can be compiled at boot, so a compile error surfaces before the first request rather than in the middle of one:
Spinel::Native.compile!(Stats)Keep state between calls
native_state gives the module state that lives inside the compiled kernel,
so a large input can be copied in once and queried many times:
module Index
extend Spinel::Native
native_state do
@docs = []
end
native "(Array[String]) -> Integer"
def load(docs)
@docs = docs
docs.length
end
native "(String) -> Array[Integer]"
def find(word)
hits = []
i = 0
while i < @docs.length
hits << i if @docs[i].include?(word)
i += 1
end
hits
end
end
Index.load(corpus) # copied across once
Index.find("spinel") # no copy, just the answerA stateful module is one kernel with one state, so it is compiled as a
whole the moment its body ends; there is nothing to call first. Every native
method in it therefore needs a declared signature, and a missing one is
reported when the module closes. The block also runs on the module itself,
so the Ruby definitions (used by off, verify, and the fallback) start
from the same state. State is per process, not per object: a stateful
module is a singleton, and a forked worker gets its own copy.
Extra kernel source
Only native methods are pulled out of the source file. Constants, Struct
definitions, and helpers the kernel needs but that are not entries go in a
native_prelude:
module Raster
extend Spinel::Native
native_prelude <<~RUBY
WIDTH = 320
Point = Struct.new(:x, :y)
def self.clamp(v, lo, hi)
v < lo ? lo : (v > hi ? hi : v)
end
RUBY
native def pixel(x, y)
clamp(y, 0, 239) * WIDTH + clamp(x, 0, WIDTH - 1)
end
endChoose the entry methods
A stateful module is compiled as one kernel, and by default every native method
is an entry that Ruby can call -- so every one needs a boundary-crossable
signature. native_entries names the few methods actually called from Ruby; the
rest stay internal to the kernel, reachable only from other native methods.
Internal methods need no signature and their parameters and return value need
not be boundary types (they may be poly and get boxed) -- which is what lets a
real, mutually-recursive kernel expose a small typed surface:
module Renderer
extend Spinel::Native
native_state { @fb = Array.new(76800, 0) }
native_entries :render # the only method CRuby calls
native "(Integer, Integer, Float) -> Array[Integer]"
def render(px, py, angle)
draw_walls(px, py, angle) # internal; no signature required
@fb
end
native def draw_walls(px, py, angle) # stays inside the kernel
# ...
0
end
endInternal methods are still defined on the Ruby module, but calling one from
Ruby runs its Ruby definition, against the module's Ruby-side ivars, not
the kernel's state. Renderer.draw_walls(...) would draw into the Ruby
@fb, which the compiled render never sees. Treat internal methods as
private to the kernel; make a method an entry if Ruby needs its result.
Modes
The Ruby definition is always kept. Which path runs is a process-wide switch, settable from code or from the environment:
Spinel::Native.mode = :on # default: compile on first call, fall back on failure
Spinel::Native.mode = :off # never compile, run the Ruby definitions
Spinel::Native.mode = :verify # run both, raise Spinel::Native::Mismatch if they differ
Spinel::Native.mode = :strict # a compile or type failure raises instead of falling backSPINEL_NATIVE=verify ruby app.rb # the Ruby definition is the oracle
SPINEL_NATIVE=off ruby app.rb # e.g. in a debuggerEnvironment
SPINEL=/path/to/spinel/bin/spinel # the compiler; otherwise `spinel` on PATH
SPINEL_HDR_DIR=/path/to/lib # runtime headers, if not next to the binary
SPINEL_NATIVE_CACHE=~/.cache/spinel-native # where compiled kernels live
SPINEL_NATIVE_VERBOSE=1 # print the commands and timingsRules for a native method
- Parameters and the return value cross the boundary by copy. Supported
types:
Integer(64-bit),Float,String,bool, andArrayof those. Mutating a parameter is refused at compile time; return the result instead. - The body must not touch
selfor anything outside the module'snativemethods, itsnative_prelude, and itsnative_stateivars. - A
raiseinside the kernel arrives in Ruby as the same exception class and message. Integer overflow raisesRangeErrorwhere CRuby would promote to a Bignum, and a Bignum argument is aRangeErrorat the boundary. - Kernels run one call at a time per module, with the GVL held (see the
0.3.1 changelog for why), so
native_stateneeds no locking of its own. - In a stateful module with
native_entries, only the entries reach the kernel's state. A non-entry method called from Ruby runs as Ruby, on the module's own ivars.
How it works
-
Method#source_locationplus Prism pull thedefback out of its file. - The marked methods become
def self.methods of a synthetic module, with aif __FILE__ == $0driver that calls each exported entry once with a literal of its parameter types ([0.0],0,"x"). Spinel infers from that call site and never runs the driver. -
spinel kernel.rb -c --ext cruby --ext-init ... --ext-entry Mod.a,Mod.bemits the kernel C, a header contract, and a CRuby shim that convertsVALUEs, releases the GVL, and re-raises kernel exceptions. - The C compiler from
RbConfiglinks those with the Spinel runtime into a shared object, keyed in the cache by the kernel source, the entries, the Spinel binary and the Ruby ABI. The runtime itself is compiled once per Spinel build with-fPIC(a couple of seconds), since the archive Spinel ships is meant for executables. On Linux the object exports only itsInit_*symbol and binds the rest internally, because CRuby loads extensionsRTLD_GLOBALand two kernels would otherwise share onesp_raise_cls.requireloads the object; the method is redefined to forward to the extension.
Install
The gem is on RubyGems:
gem install spinel_nativeor in a Gemfile:
gem "spinel_native"The gem needs the Spinel compiler at run time, and Spinel is not on RubyGems.
Build it from source once and point the gem at the binary, either through
SPINEL or by putting spinel on PATH:
git clone https://github.com/matz/spinel && cd spinel && make deps && make
export SPINEL=$PWD/bin/spinelThe runtime headers and sources are found next to the binary. If Spinel
was installed elsewhere, SPINEL_HDR_DIR names the directory with
spinel_rt.h.
Running the example
git clone https://github.com/khasinski/spinel_native && cd spinel_native
ruby -I lib examples/demo.rb
bundle exec rake testOn an M-series Mac with CRuby master the demo prints roughly:
dot(2M floats) ruby 0.136s native 0.016s x8.4
mandel_row(4000) ruby 0.517s native 0.013s x39.6
count_primes(5M) ruby 0.645s native 0.072s x9.0
Status
A proof of concept. Not yet done: shipping the compiled kernel inside a gem
(Spinel's own spin ext covers that path), zero-copy numeric buffers, keyword
and block parameters, Hash parameters, and --int-overflow=promote parity.