What are we trying to achieve
Let's say you have a JavaScript application that runs in bun and you've identified some bottleneck that you'd like to optimize.
Rewriting it in a more performant language may just be the solution you need.
As a modern JS runtime, Bun supports Foreign Function Interface (FFI) to call libraries written in other languages that support exposing C ABIs, like C, C++, Rust and Zig.
In this post, we'll go over how one may use it, and conclude whether one can benefit from it.
How to link the library to JavaScript
This example is using Rust. Creating a shared library with C bindings looks differently in other languages but the idea remains the same.
From JS side
Bun exposes its FFI API through . Other languages that manage memory will have to do something similar.
Memory management
As we can see, it's possible to allocate memory in both JS and Rust, and neither can safely manage others memory.
Let's choose where you should allocate your memory and how.
Allocate in Rust
There are 3 methods of delegating memory cleanup to Rust from JS and all have their pros and cons.
Use FinalizationRegistry
Use
function readStringU16(
create_string_u16: (ptr: Uint16Array, len: number) => void,
): string {
const length = 12;
const buffer = new Uint16Array(length);
create_string_u16(buffer, length)!;
const decoder = new TextDecoder("utf-16");
const copy = decoder.decode(buffer);
return copy;
}
function readStringC(create_string_c: () => CString): string {
const cstr = create_string_c();
const copy = cstr.toString();
return copy;
}
const readU16 = readStringU16.bind(null, create_string_u16);
const readC = readStringC.bind(null, create_string_c);
summary(() => {
bench("read u16", readU16);
bench("read c", readC);
});
await run({ colors: false });
const STRING: &str = "Hello, World";
#[no_mangle]
pub unsafe extern "C" fn create_string_u16(ptr: *mut u16, capacity: usize) {
let buffer: &mut [u16] = unsafe { slice::from_raw_parts_mut(ptr, capacity) };
let src: Vec<u16> = STRING.encode_utf16().collect::<Vec<u16>>();
buffer.copy_from_slice(&src);
}
#[no_mangle]
pub extern "C" fn create_string_c() -> *const c_char {
ManuallyDrop::new(CString::new(STRING).unwrap()).as_ptr()
}
benchmark avg (min … max) p75 p99 (min … top 1%)
-------------------------------------- -------------------------------
read u16 1.12 µs/iter 1.21 µs █▂
(905.96 ns … 1.83 µs) 1.79 µs ██▆▅▆▇▇▇▃▂▂▄▃▃▂▂▁▂▂▁▁
read c 727.82 ns/iter 809.01 ns █▂
(590.53 ns … 1.03 µs) 969.92 ns ▆███▆▆▇▃▆▄▄▆▃▄▆▆▃▂▃▂▂
summary
read c
1.54x faster than read u16
What about WebAssembly?
It's time to address the elephant in the room that is WebAssembly.
Should you choose nice existing WASM bindings over dealing with C ABI?
The answer is probably neither.
Is it actually worth it?
Introducing another language to your codebase will require more than just a single bottleneck to be worth it DX-wise and performance-wise.
Here is a benchmark for a simple range function in JS, WASM and Rust.
// rs.rs
#[no_mangle]
pub unsafe extern "C" fn rs_range(ptr: *mut i32, start: i32, end: i32) {
let len: usize = usize::try_from(end - start).unwrap();
let buffer: &mut [i32] = unsafe { slice::from_raw_parts_mut(ptr, len) };
let src: Vec<i32> = (start..end).collect();
buffer.copy_from_slice(&src);
}
// wasm.rs
#[wasm_bindgen]
pub fn wa_range(start: i32, end: i32) -> Vec<i32> {
(start..end).collect()
}
function tsRange(start: number, end: number): number[] {
return [...Array(end - start).keys()].map((x) => x + start);
}
function wrapWa(
wa_range: (start: number, end: number) => Int32Array,
): (start: number, end: number) => number[] {
return (start, end) => {
return Array.from(wa_range(start, end));
};
}
function wrapRs(
rs_range: (ptr: Int32Array, start: number, end: number) => void,
): (start: number, end: number) => number[] {
return (start, end) => {
const buffer = new Int32Array(end - start);
rs_range(buffer, start, end);
return Array.from(buffer);
};
}
await init();
const waRange = wrapWa(wa_range);
const rsRange = wrapRs(rs_range);
summary(() => {
bench("ts", () => tsRange(100, 50000));
bench("wa", () => waRange(100, 50000));
bench("rs", () => rsRange(100, 50000));
});
await run({ colors: false });
benchmark avg (min … max) p75 p99 (min … top 1%)
-------------------------------------- -------------------------------
ts 1.33 ms/iter 1.25 ms █
(802.90 µs … 5.29 ms) 4.22 ms ▂██▅▂▁▁▁▁▁▁▁▁▁▂▁▁▁▁▁▁
wa 1.58 ms/iter 1.72 ms █
(1.17 ms … 4.36 ms) 4.09 ms ██▄▃▄▃▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁
rs 1.47 ms/iter 1.43 ms ▂█
(1.17 ms … 4.11 ms) 3.84 ms ██▃▂▂▄▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁
summary
ts
1.1x faster than rs
1.18x faster than wa
Native library barely beats out WASM and consistently loses to the pure TypeScript implementation.
And that's it for this tutorial for/exploration of bun:ffi module. Hopefully we all have walked away from this a little bit more educated.
Feel free to share thoughts and questions in the comments
SOCIAL SHARE CARD GENERATOR