outline
Using C++ from elixir with nifs
In the previous post, we explored the possibility of running python code from Elixir by using the erlport library that is based on the ports facility available in Erlang(and Elixir).
Using ports requires launching an OS process that will host the Python code, we also saw how the erlport library makes it easy to send messages from and to the Python process.
In this post, we are going to see how to implement functions in C++ and how to use them from Elixir without launching any OS process by using the Nifs facility available in Erlang.
Full code sample containing the examples in this post is available on Github.
Native implemented functions
Native implemented functions is a feature available in Erlang that allows to call code implemented in C/C++ from Elixir, the native code (code in C/C++) is compiled into a shared library and is usually loaded in the Erlang VM when it starts up.
Functions defined in C++ cannot be called directly and some wrapping needs to take place; the erl_nif.h C library is used to define Elixir to C/C++ function mapping as well as to convert Elixir data structures to their corresponding representations in C/C++.
Nifs can be called without having to use OS processes but beware, a crash in the C/C++ code will cause the entire Erlang VM to crash; effectively bypassing any error handling mechanism.
In fact, C/C++ is notoriously unsafe especially when manipulating pointers. Unfortunately, to mitigate this unsafety you will have to code very defensively in your C/C++ code which is in some sense against the tao of let it crash.
Minimal example
Let’s take a look at a very basic example, we wish to implement an add
function in C++ and use it from Elixir.
C++ side
First, we have the simple add
C++ function:
int add(int a, int b)
{
return a + b;
}
Then, we need to use the <erl_nif.h>
library to convert the integers passed from elixir to plain C++ int
:
#include <erl_nif.h>
int add (int a, int b)
{
return a + b;
}
ERL_NIF_TERM add_nif(ErlNifEnv* env, int argc,
const ERL_NIF_TERM argv[])
{
int a = 0;
int b = 0;
if (!enif_get_int(env, argv[0], &a)) {
return enif_make_badarg(env);
}
if (!enif_get_int(env, argv[1], &b)) {
return enif_make_badarg(env);
}
int result = add(a, b);
return enif_make_int(env, result);
}
ErlNifFunc nif_funcs[] =
{
{"add", 2, add_nif},
};
ERL_NIF_INIT(Elixir.Nativly, nif_funcs, nullptr,
nullptr, nullptr, nullptr);
Let’s analyze this bottom-up, the ERL_NIF_INIT
initialization function accepts the name of the Erlang module that contains the nifs as well as the array containing nifs mapping; Elixir modules are just basically Erlang modules prefixed with Elixir.
The ERL_NIF_INIT
function simply wires things up.
The nif_funcs
two dimensional array specifies the Elixir function to C++ function mapping in the following form: {<elixir_fn_name>, <arity>, <cpp_fn_name>}
The add_nif
function has 3 main responsibilities:
- Converting Elixir data to C++ data, notice how we used the
enif_get_int
function defined in<erl_nif.h>
to convertERL_NIF_TERM
to anint
with error checking since any Elixir datatype can be passed - Executing the native add function
- Converting the result from C++ data to Elixir data and returning it
Finally, we need to compile the previous C++ file to a shared library which is going to be basically a single binary file.
We compile the C++ code by using the following command on a linux machine:
g++ -O3 -fpic -shared -o nativly.so nativly.cpp
Let’s mash a bit on the previous, here we used the g++ compiler to produce the nativly.so
shared library from the nativly.cpp
file.
The -o
flag allows us to specify the name of the shared library and the -shared
flag allows us to instruct g++ to output a shared library.
The -fpic
allows us to generate position independent code which is always recommended for shared libraries.
Last but not least, the -O3
flag instructs the compiler to perform very aggressive performance optimizations on the generated machine code. The idea behind using C++ is to have very fast and machine optimized CPU bound functions.
Elixir side
Before proceeding to the Elixir side, it is essential to make sure that the shared library has the same name as the Elixir module containing the nifs, in our example the name is nativly
.
On the Elixir side, we have to define the façade functions that are going to act as gateways to the native implemented functions and we need to load the shared library as well.
Consider the following:
defmodule Nativly do
@on_load :load_nifs
def load_nifs do
:erlang.load_nif('./nativly', 0)
end
def add(_a, _b) do
raise "NIF add/2 not implemented"
end
end
The Nativly.load_nifs
function is scheduled to be executed when the Natively
module loads with @on_load
and is responsible for loading the C++ shared library.
The Nativly.add
function will be mapped to the C++ function when the shared library is correctly loaded and from then on calls to add
will be calls to the C++ code.
When no mapping is found on the C++ side, calling add
will raise
an exception indicating that it is not implemented. This can occur when the mapping is simply not available or when the shared library binary is incompatible with the current architecture.
Hooking the C++ compilation to mix
Manually compiling the C++ code on each change is tedious but fortunately we can hook the native code compilation to mix which runs it as part of the Elixir compilation.
Consider this snippet from mix.exs
:
defmodule Mix.Tasks.Compile.Nativly do
def run(_args) do
{result, _errcode} = System.cmd("g++",
["--std=c++11",
"-O3",
"-fpic",
"-shared",
"-o", "nativly.so",
"native_lib/nativly.cpp"
], stderr_to_stdout: true)
IO.puts(result)
end
end
defmodule Nativly.Mixfile do
use Mix.Project
def project do
[
app: :nativly,
...
compilers: [:nativly] ++ Mix.compilers,
]
end
...
end
The Mix.Tasks.Compile.Nativly
module defines in its run
function how to compile the C++ code, then under the returned list from the project
function we add the compilers
entry which contains our defined compiler: :nativly
as well as the default compilers Mix.compilers
.
Running mix compile
or iex -S mix
will cause the C++ code to compile alongside the Elixir code.
Dot product example
Let’s now take a look at a slightly more interesting example, the dot product is a mathematical operation that takes two vectors and calculates the sum of the element-wise products.
Assuming that both vectors have the same size for the sake of simplicity, dot
can be implemented in Elixir as follows:
def dot(a, b) do
Enum.zip(a, b)
|> Enum.map(fn {ea, eb} -> ea * eb end)
|> Enum.reduce(&Kernel.+/2)
end
And in C++ in an imperative and procedural style as follows:
double dot(const vector<double> &a, const vector<double> &b)
{
double result = 0.0d;
for (int i = 0; i < a.size(); ++i)
{
result += a[i] * b[i];
}
return result;
}
The previous C++ function uses the std::vector
class which represents a dynamic list, note also how we used the const reference syntax to define the arguments of dot
to avoid using pointers as well as passing the vectors by value.
Now, we are going to see how to convert an Elixir list to a C++ std::vector
, consider the following:
bool enif_fill_vector(ErlNifEnv* env, ERL_NIF_TERM listTerm,
vector<double> &result)
{
unsigned int length = 0;
if (!enif_get_list_length(env, listTerm, &length))
{
return false;
}
double actualHead;
ERL_NIF_TERM head;
ERL_NIF_TERM tail;
ERL_NIF_TERM currentList = listTerm;
for (unsigned int i = 0; i < length; ++i)
{
if (!enif_get_list_cell(env, currentList, &head, &tail))
{
return false;
}
currentList = tail;
if (!enif_get_double(env, head, &actualHead))
{
return false;
}
result.push_back(actualHead);
}
return true;
}
ERL_NIF_TERM dot_nif(ErlNifEnv* env, int argc,
const ERL_NIF_TERM argv[])
{
vector<double> a;
vector<double> b;
if (!enif_fill_vector(env, argv[0], a))
{
return enif_make_badarg(env);
}
if (!enif_fill_vector(env, argv[1], b))
{
return enif_make_badarg(env);
}
double result = dot(a, b);
return enif_make_double(env, result);
}
As in the add
example we defined a dot_nif
function that is going to be mapped to its corresponding function in Elixir, here all of the complexity of list conversion has been pushed to the enif_fill_vector
function.
enif_fill_vector
uses the enif_get_list_length
in order to get the size of the list and uses the enif_get_list_cell
that allows to extract the head
and tail
of the list. Elements of the list are extracted iteratively from the beginning of the list and appended in the resulting vector
.
The list conversion has an N complexity which can be a considerable overhead, nonetheless the C++ version still performs an order of magnitude faster for N = 1'000'000
:
iex(1)> Nativly.benchmark
For N = 1000000:
=============
Elixir took 0.285704s
Native took 0.05946s
Closing thoughts
Using Nifs involves a lot of boilerplate, but despite the overhead that the boilerplate implies native code performs way better for CPU bound problems than Erlang/Elixir.
C/C++ is a language that needs to be deeply understood in order to leverage its benefits safely. I heard that Rust can be safer alternative and thanks to the Rustler library it is possible to write Nifs in Rust.
Finally, I would like to acknowledge that the Using C from Elixir with NIFs post has been very helpful in understanding nifs usage from Elixir. Definitely check it out.