Rustler is a fantastic project built to make writing Rust NIFs a simple process; and the upcoming v0.22 release will provide a much cleaner syntax to do so. The library handles encoding and decoding Rust values into Erlang terms, catches Rust panics before they unwind to C and should make it impossible to crash the BEAM from a Rust NIF.
anchorGetting started with Rustler
One of my first forays into Rust-implemented NIFs was while building a micro-library providing Base64 encoding and decoding, creatively named base64. It's utterly pointless as that functionality comes built-in to Elixir but I wanted to start with something simple. On the plus side, this meant I could easily compare the performance of the NIF version to the Elixir implementation which can be found in the Base
module.
The library consists of two functions: encode/2
and decode/2
and it's using rust-base64 to do the heavy lifting in the NIFs. Let's walk through how this all works.
To get started, we need a new mix project with rustler installed as a dependency.
mix new base64
# add {:rustler, "~> 0.22-rc"} to mix.exs deps
mix deps.get
mix rustler.new
# follow rustler instructions
Let's explore the project's resulting structure (I've left out the usual Elixir files and directories and focused on lib
and native
):
.
├── lib
│ └── base64.ex
└── native
└── base64_nif
├── Cargo.lock
├── Cargo.toml
├── README.md
└── src
└── lib.rs
lib
will contain Elixir code (like any standard mix project).base64.ex
will contain the stubs to our NIFs. This is the Elixir module the NIF module will be registered to.native
will be home to the Rust code. In fact, a cargo package has been created within this directory (in this case namedbase64_nif
).lib.rs
will contain the NIFs.
The Rust NIFs are compiled and linked into a shared library loaded by Erlang code at runtime. Elixir (or Erlang) implementations of the functions are also necessary. These are usually minimal stubs defining the name and arity of the NIFs and serve as fallback implementations if the NIFs aren't loaded. Let's start with the Elixir stubs.
# lib/base64.ex
defmodule Base64 do
use Rustler, otp_app: :base64, crate: "base64_nif"
@spec decode(binary, atom) :: binary
def decode(_b64, _opt \\ :standard), do: error()
@spec encode(binary, atom) :: binary
def encode(_s, _opt \\ :standard), do: error()
defp error(), do: :erlang.nif_error(:nif_not_loaded)
end
The first line is configuration and lets Rustler know what Rust crate to compile for the Elixir module.
As mentioned above, decode/2
and encode/2
don't actually implement any decoding or encoding; they simply call error/0
if the NIFs can't be found. However, the names and the arguments must match in both the Rust and Elixir implementations. Both functions take in a binary
to be encoded or decoded and an atom
for configuration as different character sets that can be used (url-safe, without padding, etc...). The default is fittingly set to :standard
. The Rust NIFs are implemented as follows.
// native/base64_nif/src/lib.rs
use base64;
use rustler::Atom;
mod atoms {
rustler::atoms! {
crypt,
imap_map7,
standard,
standard_no_pad,
url_safe,
url_safe_no_pad,
}
}
#[rustler::nif]
pub fn decode(b64: String, opt: Atom) -> String {
let config: base64::Config = match_config(opt);
let bytes = base64::decode_config(b64, config).expect("decode failed: invalid b64");
String::from_utf8(bytes).unwrap()
}
#[rustler::nif]
pub fn encode(s: String, opt: Atom) -> String {
let config: base64::Config = match_config(opt);
base64::encode_config(s.as_bytes(), config)
}
fn match_config(option: Atom) -> base64::Config {
// omitted for brevity
}
rustler::init!("Elixir.Base64", [decode, encode]);
The last line is interesting: rustler::init
is a procedural macro that allows the use of #[rustler::nif]
to annotate functions to be wrapped as NIFs. It takes in the name of the Elixir module in which the stubs are defined (in this case "Elixir.Base64"
) and an array containing the names of the functions annotated as NIFs (in this case [decode, encode]
). In short, this links everything together.
The use statements at the top of the file are importing the rust-base64 crate (base64
) mentioned earlier, which we'll use for encoding and decoding, and the rustler::Atom
type which allows us to represent an Elixir/Erlang atom
in Rust. Both the rustler
and base64
crates have been added to the Cargo.toml
dependencies.
The rustler::atoms
macro defines Rust functions that return Erlang atoms; in this case, the possible options for the encode/2
and decode/2
functions.
Finally, we come to the NIF definitions. The functions take in a String
and a rustler::Atom
, and return a String
. This is consistent with the Elixir stubs, as are the names. In this case, the conversions between Rust values and Elixir terms are conveniently handled by Rustler. However, for more complex types, this may need to be implemented manually.
anchorHow does it compare to the Elixir implementation?
Rust is fast. Really fast. This was my set-up (using benchee):
Operating System: macOS
CPU Information: Intel(R) Core(TM) i5-4258U CPU @ 2.40GHz
Number of Available Cores: 4
Available memory: 16 GB
Elixir 1.10.2
Erlang 22.3.2
I used hello world as the short string and Sarah Kay’s poem B (If I Should Have a Daughter) as the longer string.
Decoding:
##### With input Bigger #####
Comparison: ips
Rust Nif decode 175.74 K
Elixir/Erlang decode 4.35 K - 40.37x slower +224.03 μs
##### With input Small #####
Comparison: ips
Rust Nif decode 953.17 K
Elixir/Erlang decode 555.63 K - 1.72x slower +0.75 μs
Encoding:
##### With input Bigger #####
Comparison: ips
Rust Nif encode 203.14 K
Elixir/Erlang encode 6.95 K - 29.23x slower +138.98 μs
##### With input Small #####
Comparison: ips
Rust Nif encode 941.14 K
Elixir/Erlang encode 615.62 K - 1.53x slower +0.56 μs
As the data to encode or decode becomes larger, the overhead of creating the NIFs becomes smaller and the gains in speed are impressive. These results were obtained with fairly small data and so the potential performance gains possible by leveraging Rust NIFs when dealing with CPU-intensive tasks are exciting.
I left out the memory usage comparisons but the Elixir/Erlang implementations used 3-5x more memory than the NIFs.
anchorTL;DR: Rustler makes it easy to implement NIFs
Other than the #[rustler::nif]
function annotations and the rustler::init
call, nothing more is required to implement Rust NIFs with Rustler. The boilerplate and the complexities of translating Rust values to Erlang terms being handled by the library, there's little resistance to leveraging the power of Rust in Elixir/Erlang.