RustlerElixirFun: Calling Elixir from Rust

RustlerElixirFun

With Rustler, it became a lot easier to create well-behaving Natively-Implemented-Functions (NIFs).
However, one question which arises from time to time (here, here, here, here, …), is how to call back into Elixir code from within native code: If you pass an anonymous function to a NIF, then how can we use this function inside the NIF?

The short answer: This is not supported.
The longer answer: … But if you get ready to jump through some hoops, you can still make it work!

The gist of it: (Thanks to Erlang Forums user @robashton and GitHub user @tessi’s explanations for inspiration!)

  1. In native code, create a manual ‘future’ (a mutex wrapping a potentially-empty value, and a condition variable).
  2. Wrap this ‘future’ into a reference which can be passed back to Elixir.
  3. Send {fun, params, future_ref} to a particular process (or pool of processes) which you’ve been running.
  4. Block the native code until the future is filled. (Or a timeout is triggered)
  5. In the elixir GenServer, run the function, (handle errors) and once the result is obtained, call a second NIF with the result and the future_ref.
  6. This NIF will ‘fill’ the future with the passed result and immediately return.
  7. Now the original native code will continue.

Or, in a diagram:


Until now, there were a couple of projects which did this manually, but it seemed to make sense to start working on a library to abstract this pattern once and for all.
This way, we can make sure it works well (no edge cases) and as efficient as it might be, with both a single GenServer you might run, as well as a full-fledged pool.

The project can be found at https://github.com/Qqwy/elixir-rustler_elixir_fun.
Work on RustlerElixirFun is still ongoing, but feedback would already be much appreciated.

7 Likes

A preliminary benchmark shows that calling a NIF which calls Elixir to run a trivial function which returns through the NIF back to the original caller takes roughly 85 times longer than calling a function directly in Elixir: 9.5µs vs 0.11µs.

I’m pretty sure that this overhead, while not neglegible, can still be considered ‘good enough’ for many projects, as the overhead of 9.5µs will often be overshadowed if there is some actual work (or communication with other processes or IO) going on inside the function.

$ MIX_ENV=bench mix run bench/call_overhead.ex 
Operating System: Linux
CPU Information: Intel(R) Core(TM) i7-6700HQ CPU @ 2.60GHz
Number of Available Cores: 8
Available memory: 31.18 GB
Elixir 1.12.0
Erlang 24.0.1

Benchmark suite executing with the following configuration:
warmup: 2 s
time: 5 s
memory time: 0 ns
reduction time: 0 ns
parallel: 1
inputs: 100, 100_000, 100_000_000
Estimated total run time: 1.40 min

Benchmarking apply(fun, param) with input 100 ...
Benchmarking apply(fun, param) with input 100_000 ...
Benchmarking apply(fun, param) with input 100_000_000 ...
Benchmarking apply_elixir_fun(Pool, fun, param) with input 100 ...
Benchmarking apply_elixir_fun(Pool, fun, param) with input 100_000 ...
Benchmarking apply_elixir_fun(Pool, fun, param) with input 100_000_000 ...
Benchmarking apply_elixir_fun(Server, fun, param) with input 100 ...
Benchmarking apply_elixir_fun(Server, fun, param) with input 100_000 ...
Benchmarking apply_elixir_fun(Server, fun, param) with input 100_000_000 ...
Benchmarking fun.(param) with input 100 ...
Benchmarking fun.(param) with input 100_000 ...
Benchmarking fun.(param) with input 100_000_000 ...

##### With input 100 #####
Name                                           ips        average  deviation         median         99th %
apply(fun, param)                           8.65 M       0.116 μs   ±144.37%       0.114 μs       0.178 μs
fun.(param)                                 8.64 M       0.116 μs   ±147.69%       0.114 μs       0.156 μs
apply_elixir_fun(Pool, fun, param)         0.104 M        9.63 μs   ±222.87%        8.54 μs       27.16 μs
apply_elixir_fun(Server, fun, param)       0.101 M        9.95 μs   ±233.02%        8.68 μs       27.38 μs

Comparison: 
apply(fun, param)                           8.65 M
fun.(param)                                 8.64 M - 1.00x slower +0.00003 μs
apply_elixir_fun(Pool, fun, param)         0.104 M - 83.27x slower +9.51 μs
apply_elixir_fun(Server, fun, param)       0.101 M - 86.01x slower +9.83 μs

##### With input 100_000 #####
Name                                           ips        average  deviation         median         99th %
apply(fun, param)                           8.55 M       0.117 μs   ±140.47%       0.115 μs       0.160 μs
fun.(param)                                 8.41 M       0.119 μs   ±131.56%       0.115 μs       0.180 μs
apply_elixir_fun(Server, fun, param)       0.104 M        9.59 μs   ±209.01%        8.53 μs       26.84 μs
apply_elixir_fun(Pool, fun, param)        0.0994 M       10.06 μs   ±179.97%        8.80 μs       27.30 μs

Comparison: 
apply(fun, param)                           8.55 M
fun.(param)                                 8.41 M - 1.02x slower +0.00198 μs
apply_elixir_fun(Server, fun, param)       0.104 M - 82.02x slower +9.47 μs
apply_elixir_fun(Pool, fun, param)        0.0994 M - 86.05x slower +9.94 μs

##### With input 100_000_000 #####
Name                                           ips        average  deviation         median         99th %
fun.(param)                                 8.42 M       0.119 μs   ±114.30%       0.115 μs       0.185 μs
apply(fun, param)                           8.27 M       0.121 μs   ±141.67%       0.118 μs        0.25 μs
apply_elixir_fun(Server, fun, param)       0.103 M        9.75 μs   ±219.29%        8.53 μs       27.54 μs
apply_elixir_fun(Pool, fun, param)        0.0961 M       10.40 μs   ±193.83%        8.94 μs       27.95 μs

Comparison: 
fun.(param)                                 8.42 M
apply(fun, param)                           8.27 M - 1.02x slower +0.00213 μs
apply_elixir_fun(Server, fun, param)       0.103 M - 82.08x slower +9.63 μs
apply_elixir_fun(Pool, fun, param)        0.0961 M - 87.54x slower +10.28 μs

2 Likes

Version 0.1.0 has been released, both on hex as well as on cargo. (You’ll need both the Elixir library and the Rust library to use it).

Version 0.3.0 has been released.
It now has been thoroughly battle-tested by using it from within ArraysRRBVector. :blush:

Usage has become slightly simpler and less error-prone; on the Rust side an enum is now returned with the various possible results of a function call:

pub enum ElixirFunCallResult {
    /// The happy path: The function completed successfully. In Elixir, this looks like `{:ok, value}`
    Success(StoredTerm),
    /// An exception was raised. In Elixir, this looks like `{:error, {:exception, some_exception}}`
    ExceptionRaised(StoredTerm),
    /// The code attempted to exit the process using a call to `exit(val)`. In Elixir, this looks like `{:error, {:exit, val}}`
    Exited(StoredTerm),
    /// A raw value was thrown using `throw(val)`. In Elixir, this looks like `{:error, {:thrown, val}}`
    ValueThrown(StoredTerm),
    /// The function took too long to complete. In Elixir, this looks like `{:error, :timeout}`
    TimedOut,
}

This way, we can be sure that all edge cases are considered when you use it in your code.

1 Like