This tutorial walks you through using RustCall.jl to call Rust code from Julia step by step.
- Getting Started
- Basic Usage
- Understanding the Type System
- String Handling
- Error Handling
- Using Ownership Types
- LLVM IR Integration (Advanced)
- Performance Optimization
using Pkg
Pkg.add("RustCall")- Julia 1.12 or later
- Rust toolchain (
rustcandcargo) installed and available in PATH
To install Rust, visit rustup.rs.
To use ownership types (Box, Rc, Arc), you need to build the Rust helpers library:
using Pkg
Pkg.build("RustCall")using RustCall
The simplest way to define Rust functions is using the #[julia] attribute:
rust"""
#[julia]
fn add(a: i32, b: i32) -> i32 {
a + b
}
"""
# Call directly - no @rust macro needed!
result = add(10, 20)
println(result) # => 30
The #[julia] attribute automatically:
- Converts to
#[no_mangle] pub extern "C" - Generates a Julia wrapper function with proper type conversions
For more control, you can use the traditional FFI approach:
rust"""
#[no_mangle]
pub extern "C" fn multiply(a: i32, b: i32) -> i32 {
a * b
}
"""
# Use @rust macro with explicit types
result = @rust multiply(Int32(5), Int32(7))::Int32
println(result) # => 35
Use the rust"" string literal to define and compile Rust code:
rust"""
#[no_mangle]
pub extern "C" fn subtract(a: i32, b: i32) -> i32 {
a - b
}
"""
This code is automatically compiled and loaded as a shared library.
Use the @rust macro to call functions:
# With type inference
result = @rust subtract(Int32(100), Int32(30))::Int32
println(result) # => 70
You can define multiple functions in the same rust"" block:
rust"""
#[no_mangle]
pub extern "C" fn multiply(x: f64, y: f64) -> f64 {
x * y
}
#[no_mangle]
pub extern "C" fn subtract(a: i64, b: i64) -> i64 {
a - b
}
"""
# Usage
product = @rust multiply(3.0, 4.0)::Float64 # => 12.0
difference = @rust subtract(100, 30)::Int64 # => 70
RustCall.jl automatically maps Rust types to Julia types:
| Rust Type | Julia Type | Example |
|---|---|---|
i8 |
Int8 |
10i8 |
i16 |
Int16 |
100i16 |
i32 |
Int32 |
1000i32 |
i64 |
Int64 |
10000i64 |
u8 |
UInt8 |
10u8 |
u32 |
UInt32 |
1000u32 |
u64 |
UInt64 |
10000u64 |
f32 |
Float32 |
3.14f0 |
f64 |
Float64 |
3.14159 |
bool |
Bool |
true |
usize |
UInt |
100u |
isize |
Int |
100 |
() |
Cvoid |
- |
RustCall.jl tries to infer return types from argument types, but explicit specification is recommended:
# Not recommended - relies on inference (works but not recommended)
result = @rust add(Int32(10), Int32(20))
# Recommended - explicit type specification
result = @rust add(Int32(10), Int32(20))::Int32rust"""
#[no_mangle]
pub extern "C" fn is_positive(x: i32) -> bool {
x > 0
}
"""
@rust is_positive(Int32(5))::Bool # => true
@rust is_positive(Int32(-5))::Bool # => false
When Rust functions expect *const u8 (C strings), you can pass Julia String directly:
rust"""
#[no_mangle]
pub extern "C" fn string_length(s: *const u8) -> u32 {
let c_str = unsafe { std::ffi::CStr::from_ptr(s as *const i8) };
c_str.to_bytes().len() as u32
}
"""
# Julia String is automatically converted to Cstring
len = @rust string_length("hello")::UInt32 # => 5
len = @rust string_length("世界")::UInt32 # => 6 (UTF-8 bytes)
rust"""
#[no_mangle]
pub extern "C" fn count_chars(s: *const u8) -> u32 {
let c_str = unsafe { std::ffi::CStr::from_ptr(s as *const i8) };
let utf8_str = std::str::from_utf8(c_str.to_bytes()).unwrap();
utf8_str.chars().count() as u32
}
"""
# Count UTF-8 characters
count = @rust count_chars("hello")::UInt32 # => 5
count = @rust count_chars("世界")::UInt32 # => 2 (characters, not bytes)
Rust's Result<T, E> type is represented as RustResult{T, E} in Julia:
rust"""
#[no_mangle]
pub extern "C" fn divide(a: i32, b: i32) -> i32 {
if b == 0 {
return -1; // Return -1 as error code
}
a / b
}
"""
# Error checking
result = @rust divide(Int32(10), Int32(2))::Int32
if result == -1
println("Division by zero!")
end
For a more Rust-like approach, you can define functions that return Result types:
# Create RustResult manually
ok_result = RustCall.RustResult{Int32, String}(true, Int32(42))
RustCall.is_ok(ok_result) # => true
RustCall.unwrap(ok_result) # => 42
err_result = RustCall.RustResult{Int32, String}(false, "error message")
RustCall.is_err(err_result) # => true
RustCall.unwrap_or(err_result, Int32(0)) # => 0Use result_to_exception to convert Result to Julia exceptions:
err_result = RustCall.RustResult{Int32, String}(false, "division by zero")
try
value = RustCall.result_to_exception(err_result)
catch e
if e isa RustCall.RustError
println("Rust error: $(e.message)")
end
endRustBox<T> is a heap-allocated value with single ownership:
# Rust helpers library required
if RustCall.is_rust_helpers_available()
# Create Box (usually returned from Rust functions)
# Here as an example, actual usage is from Rust function return values
box = RustCall.RustBox{Int32}(ptr) # ptr obtained from Rust function
# Explicitly drop after use
RustCall.drop!(box)
endif RustCall.is_rust_helpers_available()
# Create Rc
rc1 = RustCall.RustRc{Int32}(ptr)
# Clone to increment reference count
rc2 = RustCall.clone(rc1)
# Dropping one keeps the other valid
RustCall.drop!(rc1)
@assert RustCall.is_valid(rc2) # Still valid
# Drop last reference
RustCall.drop!(rc2)
endif RustCall.is_rust_helpers_available()
# Create Arc
arc1 = RustCall.RustArc{Int32}(ptr)
# Thread-safe clone
arc2 = RustCall.clone(arc1)
# Can be used from different tasks
@sync begin
@async begin
# Use arc2
end
end
RustCall.drop!(arc1)
RustCall.drop!(arc2)
endThe @rust_llvm macro enables optimized calls via LLVM IR integration (experimental):
rust"""
#[no_mangle]
pub extern "C" fn fast_add(a: i32, b: i32) -> i32 {
a + b
}
"""
# Register function
info = RustCall.compile_and_register_rust_function("""
#[no_mangle]
pub extern "C" fn fast_add(a: i32, b: i32) -> i32 { a + b }
""", "fast_add")
# Call with @rust_llvm (potentially optimized)
result = @rust_llvm fast_add(Int32(10), Int32(20)) # => 30using RustCall
# Create optimization configuration
config = RustCall.OptimizationConfig(
level=3, # Optimization level 0-3
enable_vectorization=true,
inline_threshold=300
)
# Optimize module
# RustCall.optimize_module!(module, config)
# Convenience functions
# RustCall.optimize_for_speed!(module) # Level 3, aggressive optimization
# RustCall.optimize_for_size!(module) # Level 2, size optimizationRustCall.jl automatically caches compilation results. No need to recompile the same code:
# First compilation (takes time)
rust"""
#[no_mangle]
pub extern "C" fn compute(x: i32) -> i32 {
x * 2
}
"""
# Same code again (fast load from cache)
rust"""
#[no_mangle]
pub extern "C" fn compute(x: i32) -> i32 {
x * 2
}
"""# Check cache size
size = RustCall.get_cache_size()
println("Cache size: $size bytes")
# List cached libraries
libs = RustCall.list_cached_libraries()
println("Cached libraries: $libs")
# Cleanup old cache (older than 30 days)
RustCall.cleanup_old_cache(30)
# Clear all cache
RustCall.clear_cache()To measure performance:
julia --project benchmark/benchmarks.jlThis compares performance of Julia native, @rust, and @rust_llvm.
# Recommended
result = @rust add(Int32(10), Int32(20))::Int32
# Not recommended (relies on type inference)
result = @rust add(Int32(10), Int32(20))# Use Result type
result = some_rust_function()
if RustCall.is_err(result)
# Handle error
return
end
value = RustCall.unwrap(result)When using ownership types, always call drop! appropriately:
box = RustCall.RustBox{Int32}(ptr)
try
# Use box
finally
RustCall.drop!(box) # Always cleanup
endWhen using the same Rust code multiple times, caching is automatically leveraged.
If issues occur, try clearing the cache and recompiling:
RustCall.clear_cache()- See Examples for more advanced usage examples
- Check Troubleshooting to solve problems
- Review API Reference for all features