⚠️ Important: Before introducing new languages into your project, ensure that your existing Elixir code has been optimized first. In many cases, proper optimization can significantly improve application performance and sometimes yield better results than adding another programming language.
Elixir and Erlang when are excellent languages for developing scalable and fault-tolerant systems. However, there may come times when you need to extract maximum performance or utilize libraries available only in other languages. If you're facing such situations or simply want to explore how to combine two favorite programming languages, this article is for you!
🔍 Search When You Need Native Code Integration
Before diving into technical details, it's important to understand when native code integration should be considered:
1. Compute-intensive Tasks
The BEAM virtual machine excels at concurrency but isn't designed for heavy computations. Consider integrating native code if your bottleneck involves CPU-bound operations:
- 🧮 Mathematical Calculations—Linear algebra or matrix operations
- 🔐 Cryptography—Encryption/decryption of large datasets
- 🎨 Media Processing—Images, videos, audio
- 🧠 Machine Learning—Model inference, vector operations
2. Hardware Interaction
When low-level access to hardware is required:
- 📟 Embedded Systems—Raspberry Pi, microcontrollers (Note: While Nerves exists in Elixir, it might fall short)
- 🎮 Specific Drivers—Unconventional devices
- 📊 GPU Computing—CUDA, OpenCL
3. Reusing Existing Codebases
- 🏛️ Proven Libraries on C/C++
- 📚 Ecosystem Advantages from other languages (e.g., Python for ML)
- 🔧 Avoid Reinventing the Wheel
4. Profiling and Bottlenecks
Typical signs indicating it's time to consider native code:
# Before Optimization - 1000ms
defmodule SlowModule do
def process_data(data) do
# Potential bottleneck candidate for native optimization
Enum.reduce(data, 0, fn x, acc -> complex_calculation(x) + acc end)
end
defp complex_calculation(x) do
# Imagine CPU-heavy calculations here
# That don't scale well within BEAM
end
end
# Exhausted all possible optimizations in Elixir and still slow?
# Perhaps it's time to introduce native code!
Native extensions transform Elixir into an all-purpose tool where BEAM handles everything except critical computations. This opens doors to ML, hardware, video processing, and more speed-critical tasks.
Balance Comparison of Integration Methods
Choosing the right method of integration is crucial for project success. Here's a comparative table of methods with their pros and cons:
Method | Speed | BEAM Safety | Implementation Complexity | Language Support | Communication Overhead | Asynchronicity | Ideal Use Cases использования |
---|---|---|---|---|---|---|---|
NIF | ⚡⚡⚡⚡⚡ | ❌ Dangerous | 🔧🔧🔧 Medium | C, C++ | None (direct call) | ❌ Blocks scheduler | Microservices, lengthy CPU-bound tasks |
Dirty NIF | ⚡⚡⚡⚡ | ⚠️ Conditionally Safe | 🔧🔧🔧 Medium | C, C++ | None (direct call) | ✅ Doesn't block primary scheduler | Long computational tasks (>1ms) |
Port | ⚡⚡ | ✅✅ Completely Safe | 🔧 Simple | Any | High (IPC) | ✅ Process isolation | Python, Go, Bash-scripts |
Port Driver | ⚡⚡⚡⚡ | ✅ Safe | 🔧🔧🔧🔧 Complex | C, C++ | Low | ✅ Dedicated thread | Video/audio processing |
gRPC | ⚡⚡ | ✅✅ Completely Safe | 🔧🔧 Medium | Any with gRPC support | Moderate (network) | ✅ Separate service | Microservice architecture |
Rustler | ⚡⚡⚡⚡ | ✅ Safe | 🔧🔧 Medium | Rust | None (direct call) | ✅ Supports asynchronous APIs | Alternative to C NIF |
Zigler | ⚡⚡⚡⚡⚡ | ✅ Safe | 🔧🔧 Medium | Zig | None (direct call) | ✅ Safer than standard NIFs | Alternative to C in NIF |
Visual Comparison by Key Metrics
Speed: Safety: Simplicity:
NIF █████ Port █████ Port █████
Dirty NIF ████ Dirty NIF ███ gRPC ████
Rustler ████ Rustler ████ Rustler ████
Zigler █████ Zigler ████ Zigler ████
Port Driver ████ Port Driver ████ NIF ███
Port ██ gRPC █████ Port Driver █
gRPC ██ NIF █ Dirty NIF ███
Mechanisms Behind Integration
ㅤ
🧠 NIFs — Maximal Performance
Native Implemented Functions (NIFs) offer the fastest way to integrate native code, albeit being the riskiest option. They execute directly in the BEAM scheduler's thread, ensuring lightning-fast execution due to zero overhead.
How Do NIFs Work?
- Compilation: Native code (C/Rust/Zig) compiles into a dynamic library (.so, .dll)
- Loading: BEAM loads the library during module startup via :erlang.load_nif/2
- Direct Execution: Functions run in the same thread as the calling Elixir code
✅ Benefits
- Blazing Speed: Calls take approximately 0.1–1 µs (~100–1000× faster than Ports)
- Access to BEAM API: Direct interaction with Erlang terms
- No Serialization: No encoding/decoding costs
- Distribution: Library ships alongside the OTP app
❌ Drawbacks
- Risk of VM Crash: Errors in NIF terminate the entire BEAM instance
- Scheduler Blocking: Long-running NIFs freeze multithreading
- Debugging Difficulty: Memory leaks hard to detect
- Platform Dependency: Requires compilation per target architecture
Dirty NIFs — Safer Alternative
With Erlang/OTP 20+, we have Dirty NIFs, which execute in dedicated thread pools, allowing longer computations without blocking the BEAM scheduler.
// Defining a Dirty NIF
static ErlNifFunc nif_funcs[] = {
{"long_computation", 1, long_computation_nif, ERL_NIF_DIRTY_CPU}
};
Tip: Use ERL_NIF_DIRTY_CPU for CPU-bound ops and ERL_NIF_DIRTY_IO for I/O-bound ones
🔌 Ports — Complete Isolation
Ports provide a safe mechanism for interacting with external programs through standard I/O streams (stdin/stdout). It's the safest form of integration since the external program runs in its own OS process.
How Do Ports Work?
- Launch: Elixir spawns an external program as a separate OS process
- Communication: Data exchange occurs over stdin/stdout
- Isolation: Failure of the external program doesn't affect BEAM
✅ Benefits
- Full Safety: Isolation ensures BEAM stability
- Language Agnosticism: Works with any programming language
- Ease of Debugging: External program can be tested independently
- Dependency-Free: No specific BEAM-compatible libraries needed
❌ Downsides
- High Overheads: Approximately 100–500 µs per invocation
- Serialization Required: Needs conversion of data (usually to JSON)
- Blocking Calls: By default blocks the caller process
🚗 Port Drivers — Goldilocks Zone
Port Drivers are high-performance alternatives to Ports, though they require greater complexity. These drivers are written in C and embedded directly into BEAM’s address space, running in separate threads.
How Do Port Drivers Work?
- Load: BEAM loads the C library into its memory space
- Thread Allocation: Each driver operates in its own thread
- Asynchronous Operation: Data exchanges occur via message queues
✅ Benefits
- Performance: Significantly faster than regular ports
- Safety: Lower risk compared to NIFs
- Async Capability: Non-blocking operation supported
- Convenience: No need to spawn additional processes
❌ Disadvantages
- Complexity: Requires knowledge of Erlang C API
- Limited Scope: Only works with C/C++
- Less Documentation: Few examples and guides available
🐊 Zigler — Wrapper Around NIFs for Zig
Zigler is a library enabling writing native extensions using the Zig programming language. It integrates the Zig compiler directly into the Elixir compilation cycle, allowing direct coding in Zig inside Elixir modules.
✅ Benefits
- Minimal overhead for native calls
- Memory safety without garbage collection
- Direct access to low-level operations
- Simpler compilation compared to other native extensions
- Hot reloading capability
❌ Disadvantages
- Zig is relatively young with a smaller community
- Higher entry barrier for Elixir developers
- Limited ecosystem of libraries compared to Rust
🦀 Rustler — Wrapping NIFs for Rust
Rustler is a library for creating native extensions in Erlang/Elixir using Rust. It provides a safe binding between Rust and Erlang/Elixir, facilitating writing NIFs in Rust.
✅ Benefits
- Memory safety enforced at compile-time
- High performance for computationally intensive tasks
- Rich ecosystem of Rust packages (crates)
- Protection against crashes in native code (protects BEAM from crashing)
- Parallel execution without blocking the BEAM scheduler
❌ Disadvantages
- Complicated build process and dependency management
- Requires familiarity with two different programming paradigms
- Possible compatibility issues across versions
- Potential bottlenecks at runtime boundaries
🌐 Global gRPC and Similar Protocols
For more advanced scenarios involving external services, especially in microservice architectures, protocols like gRPC provide structured and scalable ways of integration.
✅ gRPC Benefits for Elixir
- Contract Schema: Strong typing via Protocol Buffers
- Bidirectional Streaming: Supports streaming both ways
- Cross-platform: Supports multiple languages
- Performance: More efficient than REST/JSON
❗️ An example using gRPC was omitted due to its extensive nature. For detailed info about gRPC in Elixir, see elixir-grpc.
🧪 Practical Examples of Integration
📚 The goal of this article is to give an overview of available native integration methods. Deep implementations and edge cases are omitted to maintain balance between theory and practical application.
Exploring Criteria for Choosing Integration Methods
Before moving onto examples, let's define key factors influencing our choice:
- Performance: How fast does the call execute?
- Safety: Risk level for BEAM stability
- Implementation Complexity: Ease of implementation
- Language Support: Suitability of the chosen language with BEAM
⚙️ C — Maximum Performance Using NIF and Port Drivers
Optimal Methods:
- NIF — for instantaneous operations (<1ms)
- Port Drivers — for long-lasting or async tasks
Why Choose This Approach?
C offers seamless compatibility with BEAM, making it ideal for:
- Critically performant tasks
- Low-level operations (hardware, GPU)
- Integrating pre-existing C libraries
Example 1: Fast NIF for Hashing (Best Suited for C)
// hash_nif.c
#include <erl_nif.h>
#include <openssl/sha.h>
static ERL_NIF_TERM sha256_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
ErlNifBinary input;
if (!enif_inspect_binary(env, argv[0], &input)) {
return enif_make_badarg(env);
}
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256(input.data, input.size, hash);
ErlNifBinary output;
enif_alloc_binary(SHA256_DIGEST_LENGTH, &output);
memcpy(output.data, hash, SHA256_DIGEST_LENGTH);
return enif_make_binary(env, &output);
}
static ErlNifFunc nif_funcs[] = {
{"sha256", 1, sha256_nif}
};
ERL_NIF_INIT(Elixir.CryptoNif, nif_funcs, NULL, NULL, NULL, NULL)
Elixir Part:
defmodule CryptoNif do
@on_load :load_nif
def load_nif do
:erlang.load_nif(Path.expand("./hash_nif"), 0)
end
def sha256(_data), do: raise "NIF not loaded!"
end
Compiling C Source:
You'll need the OpenSSL library installed:
# For Ubuntu/Debian
sudo apt-get install libssl-dev
# For CentOS/RHEL
sudo yum install openssl-devel
# For macOS (with Homebrew)
brew install openssl
Compile the C source:
gcc -shared -fPIC -o hash_nif.so hash_nif.c \
-I /usr/lib/erlang/erts-15.2.7/include \ # Path depends on your system
-I /usr/include/openssl \
-L /usr/lib/openssl \
-lssl -lcrypto
Result:
iex(1)> CryptoNif.sha256("test")
<<159, 134, 208, 129, 136, 76, 125, 101, 154, 47, 234, 160, 197, 90, 208, 21, 163, 191, 79, 27, 43, 11, 130, 44, 209, 93, 108, 21, 176, 240, 10, 8>>
Example 2: Port Driver for String Reversal
// reverse_driver.c
#include "erl_driver.h"
#include <string.h>
typedef struct {
ErlDrvPort port;
} DriverData;
static void reverse_and_send(ErlDrvData drv_data, char* buf, ErlDrvSizeT len) {
DriverData* d = (DriverData*)drv_data;
for (ErlDrvSizeT i = 0; i < len / 2; i++) {
char temp = buf[i];
buf[i] = buf[len - 1 - i];
buf[len - 1 - i] = temp;
}
driver_output(d->port, buf, len);
}
static ErlDrvData driver_start(ErlDrvPort port, char* command) {
DriverData* d = (DriverData*)driver_alloc(sizeof(DriverData));
d->port = port;
return (ErlDrvData)d;
}
static void driver_stop(ErlDrvData drv_data) {
DriverData* d = (DriverData*)drv_data;
driver_free(d);
}
static ErlDrvEntry driver_entry = {
.init = NULL,
.start = driver_start,
.stop = driver_stop,
.output = reverse_and_send,
.ready_input = NULL,
.ready_output = NULL,
.driver_name = "reverse_driver",
.finish = NULL,
.handle = NULL,
.control = NULL,
.timeout = NULL,
.outputv = NULL,
.ready_async = NULL,
.flush = NULL,
.call = NULL,
.extended_marker = ERL_DRV_EXTENDED_MARKER,
.major_version = ERL_DRV_EXTENDED_MAJOR_VERSION,
.minor_version = ERL_DRV_EXTENDED_MINOR_VERSION,
.driver_flags = 0,
.handle2 = NULL,
.process_exit = NULL,
.stop_select = NULL
};
DRIVER_INIT(reverse_driver) {
return &driver_entry;
}
Elixir Part:
defmodule ReverseString do
def start() do
:ok = :erl_ddll.load_driver('./', 'reverse_driver')
Port.open({:spawn, 'reverse_driver'}, [:binary])
end
def reverse(port, string) when is_binary(string) do
true = Port.command(port, string)
receive do
{^port, {:data, result}} -> result
after
1000 -> {:error, :timeout}
end
end
def stop(port) do
Port.close(port)
end
end
Compiling C Source:
gcc -std=gnu99 -shared -fPIC -o reverse_driver.so reverse_driver.c \
-I/usr/lib/erlang/erts-15.2.7/include/ # Path depends on your system
Result:
iex(1)> port = ReverseString.start
#Port<0.6>
iex(2)> ReverseString.reverse(port, "hello")
"olleh"
🦀 Rustler — Security and Performance with Rustler
Optimal Method: Rustler (specialized wrapper for NIF)
Why Rustler?
- Full memory safety
- Convenient macros for working with BEAM terms
- Automatic error handling
- Async task support
Example: Parallel Data Processing
Add Rustler
to mix.exs (Current version is 0.36.1):
defp deps do
[
{:rustler, "~> 0.36.1"}
]
end
Create Rust Project Inside Elixir:
mix deps.get
mix rustler.new
This is the name of the Elixir module the NIF module will be registered to.
Module name > RustUtils
This is the name used for the generated Rust crate. The default is most likely fine.
Library name (rustutils) > (Enter)
// lib.rs
use rayon::prelude::*;
#[rustler::nif]
fn parallel_double(input: Vec<i64>) -> Vec<i64> {
input.par_iter().map(|&x| x * 2).collect()
}
rustler::init!("Elixir.RustUtils");
Elixir Part:
defmodule RustUtils do
use Rustler, otp_app: :rust_utils, crate: "rustutils"
def parallel_double(_list), do: :erlang.nif_error(:not_loaded)
end
Result:
RustUtils.parallel_double([1, 2, 3])
# => [2, 4, 6]
3. 🐍 Python — Effortless Integration Through Ports
Optimal Method: Ports
Why Ports?
- Total process isolation
- Easy debugging
- Access to Python ecosystem
- Handles long operations (like ML models)
Example: Creating Your Own Model and Integrating TensorFlow
Build the Model:
# create_model.py
import tensorflow as tf
import numpy as np
model = tf.keras.Sequential([
tf.keras.layers.Dense(1, input_shape=(3,), use_bias=False)
])
model.set_weights([np.array([[1.0], [1.0], [1.0]])])
model.save("my_model.h5")
print("✅ Model saved to my_model.h5")
#tensorflow_port.py
import sys
import json
import numpy as np
import tensorflow as tf
import os
os.environ['TF_CPP_MIN_LOG_LEVEL'] = '3'
tf.get_logger().setLevel('ERROR')
model = tf.keras.models.load_model('my_model.h5')
def predict(data):
try:
if isinstance(data, str):
data = json.loads(data)
input_array = np.array(data, dtype=np.float32).reshape(1, 3)
prediction = model.predict(input_array)
return json.dumps({"status": "success", "result": prediction.tolist()})
except Exception as e:
return json.dumps({"status": "error", "message": str(e)})
if __name__ == "__main__":
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
response = predict(line)
sys.stdout.write(response + "\n")
sys.stdout.flush()
except Exception as e:
error = json.dumps({"status": "error", "message": str(e)})
sys.stdout.write(error + "\n")
sys.stdout.flush()
Elixir Part:
defmodule TensorflowPort do
@timeout 5_000
def start do
Port.open(
{:spawn, "python3 tensorflow_port.py"},
[:binary, :use_stdio, :exit_status, :stderr_to_stdout, {:line, 1024}]
)
end
def predict(port, input_data) do
input_data
|> Jason.encode!()
|> then(&Port.command(port, &1 <> "\n"))
wait_for_response(port)
end
defp wait_for_response(port) do
receive do
{^port, {:data, {:eol, line}}} ->
case Jason.decode(line) do
{:ok, %{"status" => "success", "result" => result}} -> {:ok, result}
{:ok, %{"status" => "error", "message" => msg}} -> {:error, msg}
_ -> wait_for_response(port)
end
{^port, {:exit_status, status}} ->
{:error, "Python process exited with status #{status}"}
after
@timeout -> {:error, :timeout}
end
end
end
Result:
To run the script, enter the Python environment and launch iex -S mix
.
(venv)
iex(1)> port = TensorflowPort.start
#Port<0.10>
iex(2)> TensorflowPort.predict(port, [1,2,3])
{:ok, [[6.0]]}
4. 🦫 Go — Efficiency Through CGO or gRPC
Optimal Methods:
- CGO wrappers for NIF — best for max performance
- gRPC — suitable for complex interactions and microservices
CGO Wrapper for Go Code (Suitable for Performance)
// fib.go
package main
import "C"
func GoFib(n C.int) C.int {
a, b := 0, 1
for i := 0; i < int(n); i++ {
a, b = b, a+b
}
return C.int(a)
}
func main() {}
C Wrapper:
// c_src/go_nif.c
#include <erl_nif.h>
#include "libfib.h"
static ERL_NIF_TERM go_fib_nif(ErlNifEnv* env, int argc, const ERL_NIF_TERM argv[]) {
int n;
if (!enif_get_int(env, argv[0], &n)) {
return enif_make_badarg(env);
}
return enif_make_int(env, GoFib(n));
}
static ErlNifFunc nif_funcs[] = {
{"go_fib", 1, go_fib_nif}
};
ERL_NIF_INIT(Elixir.NifGo, nif_funcs, NULL, NULL, NULL, NULL)
Elixir part:
defmodule NifGo do
@on_load :load_nif
def load_nif do
nif_path = Path.expand("priv/go_nif", File.cwd!())
# Preload Go library
:erlang.load_nif(Path.expand("priv/libfib", File.cwd!()), 0)
# Load main NIF library
:erlang.load_nif(nif_path, 0)
end
def go_fib(_n), do: raise("NIF not loaded!")
end
Compile:
go build -buildmode=c-shared -o priv/libfib.so fib.go
gcc -shared -fPIC \
-I/usr/lib/erlang/erts-15.2.7/include/ \ # Path varies based on your system
-I./priv \
-o priv/go_nif.so \
c_src/go_nif.c \
./priv/libfib.so \
-Wl,-rpath,'$ORIGIN'
LD_LIBRARY_PATH=./priv iex -S mix
Run:
iex(1)> NifGo.go_fib(3)
2
⚠️ If you'd rather avoid dealing with CGO, opt for gRPC or Ports instead. They're safer, although slower.
5. 🐊 Zig — Integration Via Zigler
Optimal Method: Zigler (wrapper for Zig-based NIFs)
Why Zigler?
- Minimum overhead for native calls
- Memory safety without garbage collection
- Direct access to low-level operations
- Simplified compilation relative to other native extensions
- Hot-reloading capabilities
Example: Quick Binary Data Handling
Add Zigler
to mix.exs:
def deps do
[
{:zigler, "~> 0.13.2", runtime: false}
]
end
lib/nif_zig.ex:
defmodule NifZig do
use Zig, otp_app: :zigler
~Z"""
pub fn string_count(string: []u8) i64 {
return @intCast(string.len);
}
pub fn list_sum(array: []f64) f64 {
var sum: f64 = 0.0;
for(array) | item | {
sum += item;
}
return sum;
}
"""
end
Result:
iex(3)> NifZig.string_count("hello")
5
iex(4)> NifZig.list_sum([1.2,2.3,3.4,4.5])
11.4
Language | Optimal Method | Alternatives | When To Use |
---|---|---|---|
C | NIF, Port Drivers | Ports | Maximum performance |
Rust | Rustler (NIF) | - | Safety + performance |
Python | Ports | gRPC | Integration with ML/scientific tools |
Go | C-обёртки (NIF), gRPC | Ports | Leveraging Go ecosystem |
Zig | Zigler (NIF) | - | Modern alternative to C |
Final Thoughts
Integrating Elixir with native code unlocks new possibilities for developers, combining BEAM's strengths (scalability, reliability) with the performance and libraries of other languages. We've explored various integration techniques: NIF, Dirty NIF, Ports, Port Drivers, Rustler, Zigler, and gRPC, along with their optimal usage scenarios.
Remember, choosing an integration method should align with your project's requirements regarding performance, security, and ease of development.
Author's Notes
Thank you for reading this article! I hope you've found it insightful learning about ways to integrate other languages into Elixir. Among these options, I found Port Drivers particularly challenging, as crafting one turned out to be quite an adventure, requiring hours of trial-and-error to find the correct approach. Overall, however, integrating other languages into Elixir proved neither difficult nor boring—it was actually fun!
If you spot inaccuracies or have interesting additions, please share them in the comments section. Constructive feedback is always appreciated 😎
Top comments (0)