Maison > Article > interface Web > Comment et devriez-vous utiliser Bun FFI
Disons que vous disposez d'une application JavaScript qui s'exécute en bun et que vous avez identifié un goulot d'étranglement que vous souhaitez optimiser.
Le réécrire dans un langage plus performant pourrait bien être la solution dont vous avez besoin.
En tant que runtime JS moderne, Bun prend en charge l'interface de fonction étrangère (FFI) pour appeler des bibliothèques écrites dans d'autres langages prenant en charge l'exposition des ABI C, comme C, C, Rust et Zig.
Dans cet article, nous verrons comment l'utiliser et conclurons si l'on peut en bénéficier.
Cet exemple utilise Rust. La création d'une bibliothèque partagée avec des liaisons C est différente dans d'autres langages mais l'idée reste la même.
Bun expose son API FFI via le module bun:ffi.
Le point d'entrée est une fonction dlopen. Il prend un chemin qui est soit absolu, soit relatif au répertoire de travail actuel vers le fichier de bibliothèque (la sortie de construction avec une extension .so pour Linux, .dylib pour macOS ou .dll pour Windows) et un objet avec les signatures des fonctions que vous souhaitez importer.
Il renvoie un objet avec une méthode close que vous pouvez utiliser pour fermer la bibliothèque une fois qu'elle n'est plus nécessaire et une propriété symbol qui est un objet contenant les fonctions que vous avez choisies.
import { dlopen, FFIType, read, suffix, toArrayBuffer, type Pointer, } from "bun:ffi"; // Both your script and your library don't typically change their locations // Use `import.meta.dirname` to make your script independent from the cwd const DLL_PATH = import.meta.dirname + `/../../rust-lib/target/release/library.${suffix}`; function main() { // Deconstruct object to get functions // but collect `close` method into object // to avoid using `this` in a wrong scope const { symbols: { do_work }, ...dll } = dlopen(DLL_PATH, { do_work: { args: [FFIType.ptr, FFIType.ptr, "usize", "usize"], returns: FFIType.void, }, }); /* ... */ // It is unclear whether it is required or recommended to call `close` // an example says `JSCallback` instances specifically need to be closed // Note that using `symbols` after calling `close` is undefined behaviour dll.close(); } main();
Comme vous le remarquerez peut-être, les types pris en charge que Bun accepte via FFI sont limités aux nombres, y compris les pointeurs.
Notamment size_t ou usize est absent de la liste des types pris en charge, même si le code correspondant existe à partir de la version 1.1.34 de Bun.
Bun n'offre aucune aide pour transmettre des données plus complexes qu'une chaîne C. Cela signifie que vous devrez travailler vous-même avec les pointeurs.
Voyons comment passer un pointeur de JavaScript vers Rust...
{ reconstruct_slice: { args: [FFIType.ptr, "usize"], returns: FFIType.void, }, } const array = new BigInt64Array([0, 1, 3]); // Bun automatically converts `TypedArray`s into pointers reconstruct_slice(array, array.length);
/// Reconstruct a `slice` that was initialized in JavaScript unsafe fn reconstruct_slice( array_ptr: *const i64, length: libc::size_t, ) -> &[i64] { // Even though here it's not null, it's good practice to check assert!(!array_ptr.is_null()); // Unaligned pointer can lead to undefined behaviour assert!(array_ptr.is_aligned()); // Check that the array doesn't "wrap around" the address space assert!(length < usize::MAX / 4); let _: &[i64] = unsafe { slice::from_raw_parts(array_ptr, length) }; }
... et comment renvoyer un pointeur de Rust vers JavaScript.
{ allocate_buffer: { args: [], returns: FFIType.ptr, }, as_pointer: { args: ["usize"], returns: FFIType.ptr, }, } // Hardcoding this value for 64-bit systems const BYTES_IN_PTR = 8; const box: Pointer = allocate_buffer()!; const ptr: number = read.ptr(box); // Reading the value next to `ptr` const length: number = read.ptr(box, BYTES_IN_PTR); // Hardcoding `byteOffset` to be 0 because Rust guarantees that // Buffer holds `i32` values which take 4 bytes // Note how we need to call a no-op function `as_pointer` because // `toArrayBuffer` takes a `Pointer` but `read.ptr` returns a `number` const _buffer = toArrayBuffer(as_pointer(ptr)!, 0, length * 4);
#[no_mangle] pub extern "C" fn allocate_buffer() -> Box<[usize; 2]> { let buffer: Vec<i32> = vec![0; 10]; let memory: ManuallyDrop<Vec<i32>> = ManuallyDrop::new(buffer); let ptr: *const i32 = memory.as_ptr(); let length: usize = memory.len(); // Unlike a `Vec`, `Box` is FFI compatible and will not drop // its data when crossing the FFI // Additionally, a `Box<T>` where `T` is `Sized` will be a thin pointer Box::new([ptr as usize, length]) } #[no_mangle] pub const extern "C" fn as_pointer(ptr: usize) -> usize { ptr }
Rust ne sait pas que JS s'approprie les données de l'autre côté, vous devez donc lui dire explicitement de ne pas désallouer les données sur le tas à l'aide de ManuallyDrop. Les autres langages qui gèrent la mémoire devront faire quelque chose de similaire.
Comme nous pouvons le voir, il est possible d'allouer de la mémoire dans JS et Rust, et aucun des deux ne peut en toute sécurité gérer la mémoire des autres.
Choisissons où vous devez allouer votre mémoire et comment.
Il existe 3 méthodes pour déléguer le nettoyage de la mémoire à Rust depuis JS et toutes ont leurs avantages et leurs inconvénients.
Utilisez FinalizationRegistry pour demander un rappel de nettoyage pendant le garbage collection en suivant l'objet en JavaScript.
import { dlopen, FFIType, read, suffix, toArrayBuffer, type Pointer, } from "bun:ffi"; // Both your script and your library don't typically change their locations // Use `import.meta.dirname` to make your script independent from the cwd const DLL_PATH = import.meta.dirname + `/../../rust-lib/target/release/library.${suffix}`; function main() { // Deconstruct object to get functions // but collect `close` method into object // to avoid using `this` in a wrong scope const { symbols: { do_work }, ...dll } = dlopen(DLL_PATH, { do_work: { args: [FFIType.ptr, FFIType.ptr, "usize", "usize"], returns: FFIType.void, }, }); /* ... */ // It is unclear whether it is required or recommended to call `close` // an example says `JSCallback` instances specifically need to be closed // Note that using `symbols` after calling `close` is undefined behaviour dll.close(); } main();
{ reconstruct_slice: { args: [FFIType.ptr, "usize"], returns: FFIType.void, }, } const array = new BigInt64Array([0, 1, 3]); // Bun automatically converts `TypedArray`s into pointers reconstruct_slice(array, array.length);
Déléguez le suivi de la collecte des déchets à Bun pour appeler un rappel de nettoyage.
Lors du passage de 4 paramètres à toArrayBuffer, le 4ème doit être une fonction C pour être appelé lors du nettoyage.
Cependant, lors du passage de 5 paramètres, le 5ème paramètre est la fonction et le 4ème paramètre doit être un pointeur de contexte qui lui est transmis.
/// Reconstruct a `slice` that was initialized in JavaScript unsafe fn reconstruct_slice( array_ptr: *const i64, length: libc::size_t, ) -> &[i64] { // Even though here it's not null, it's good practice to check assert!(!array_ptr.is_null()); // Unaligned pointer can lead to undefined behaviour assert!(array_ptr.is_aligned()); // Check that the array doesn't "wrap around" the address space assert!(length < usize::MAX / 4); let _: &[i64] = unsafe { slice::from_raw_parts(array_ptr, length) }; }
{ allocate_buffer: { args: [], returns: FFIType.ptr, }, as_pointer: { args: ["usize"], returns: FFIType.ptr, }, } // Hardcoding this value for 64-bit systems const BYTES_IN_PTR = 8; const box: Pointer = allocate_buffer()!; const ptr: number = read.ptr(box); // Reading the value next to `ptr` const length: number = read.ptr(box, BYTES_IN_PTR); // Hardcoding `byteOffset` to be 0 because Rust guarantees that // Buffer holds `i32` values which take 4 bytes // Note how we need to call a no-op function `as_pointer` because // `toArrayBuffer` takes a `Pointer` but `read.ptr` returns a `number` const _buffer = toArrayBuffer(as_pointer(ptr)!, 0, length * 4);
Déposez simplement la mémoire vous-même une fois que vous n'en avez plus besoin.
Heureusement, TypeScript a une interface jetable très utile pour cela et pour le mot-clé using.
C'est un équivalent de Python avec ou de C# utilisant des mots-clés.
Voir la documentation pour cela
#[no_mangle] pub extern "C" fn allocate_buffer() -> Box<[usize; 2]> { let buffer: Vec<i32> = vec![0; 10]; let memory: ManuallyDrop<Vec<i32>> = ManuallyDrop::new(buffer); let ptr: *const i32 = memory.as_ptr(); let length: usize = memory.len(); // Unlike a `Vec`, `Box` is FFI compatible and will not drop // its data when crossing the FFI // Additionally, a `Box<T>` where `T` is `Sized` will be a thin pointer Box::new([ptr as usize, length]) } #[no_mangle] pub const extern "C" fn as_pointer(ptr: usize) -> usize { ptr }
{ drop_buffer: { args: [FFIType.ptr], returns: FFIType.void, }, } const registry = new FinalizationRegistry((box: Pointer): void => { drop_buffer(box); }); registry.register(buffer, box);
C'est beaucoup plus simple et plus sûr car la désallocation est gérée pour vous.
Cependant, il existe un inconvénient important.
Comme vous ne pouvez pas gérer la mémoire JavaScript dans Rust, vous ne pouvez pas dépasser la capacité du tampon car cela entraînerait une désallocation. Cela signifie que vous devez connaître la taille du tampon avant de le transmettre à Rust.
Ne pas savoir à l'avance le nombre de tampons dont vous avez besoin entraînera également beaucoup de frais généraux, car vous devrez faire des allers-retours via FFI juste pour les allouer.
/// # Safety /// /// This call assumes neither the box nor the buffer have been mutated in JS #[no_mangle] pub unsafe extern "C" fn drop_buffer(raw: *mut [usize; 2]) { let box_: Box<[usize; 2]> = unsafe { Box::from_raw(raw) }; let ptr: *mut i32 = box_[0] as *mut i32; let length: usize = box_[1]; let buffer: Vec<i32> = unsafe { Vec::from_raw_parts(ptr, length, length) }; drop(buffer); }
{ box_value: { args: ["usize"], returns: FFIType.ptr, }, drop_box: { args: [FFIType.ptr], returns: FFIType.void, }, drop_buffer: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void, }, } // Bun expects the context to specifically be a pointer const finalizationCtx: Pointer = box_value(length)!; // Note that despite the presence of these extra parameters in the docs, // they're absent from `@types/bun` //@ts-expect-error see above const buffer = toArrayBuffer( as_pointer(ptr)!, 0, length * 4, //@ts-expect-error see above finalizationCtx, drop_buffer, ); // Don't leak the box used to pass buffer through FFI drop_box(box);
Si le résultat que vous attendez de la bibliothèque est une chaîne, vous avez peut-être envisagé la micro-optimisation consistant à renvoyer un vecteur de u16 plutôt qu'une chaîne, car les moteurs JavaScript utilisent généralement UTF-16 sous le capot.
Cependant, ce serait une erreur car transformer votre chaîne en chaîne C et utiliser le type cstring de bun sera légèrement plus rapide.
Voici un benchmark réalisé à l'aide d'une jolie bibliothèque de benchmark mitata
import { dlopen, FFIType, read, suffix, toArrayBuffer, type Pointer, } from "bun:ffi"; // Both your script and your library don't typically change their locations // Use `import.meta.dirname` to make your script independent from the cwd const DLL_PATH = import.meta.dirname + `/../../rust-lib/target/release/library.${suffix}`; function main() { // Deconstruct object to get functions // but collect `close` method into object // to avoid using `this` in a wrong scope const { symbols: { do_work }, ...dll } = dlopen(DLL_PATH, { do_work: { args: [FFIType.ptr, FFIType.ptr, "usize", "usize"], returns: FFIType.void, }, }); /* ... */ // It is unclear whether it is required or recommended to call `close` // an example says `JSCallback` instances specifically need to be closed // Note that using `symbols` after calling `close` is undefined behaviour dll.close(); } main();
{ reconstruct_slice: { args: [FFIType.ptr, "usize"], returns: FFIType.void, }, } const array = new BigInt64Array([0, 1, 3]); // Bun automatically converts `TypedArray`s into pointers reconstruct_slice(array, array.length);
/// Reconstruct a `slice` that was initialized in JavaScript unsafe fn reconstruct_slice( array_ptr: *const i64, length: libc::size_t, ) -> &[i64] { // Even though here it's not null, it's good practice to check assert!(!array_ptr.is_null()); // Unaligned pointer can lead to undefined behaviour assert!(array_ptr.is_aligned()); // Check that the array doesn't "wrap around" the address space assert!(length < usize::MAX / 4); let _: &[i64] = unsafe { slice::from_raw_parts(array_ptr, length) }; }
Il est temps d'aborder l'éléphant dans la pièce qu'est WebAssembly.
Devriez-vous choisir de belles liaisons WASM existantes plutôt que de gérer C ABI ?
La réponse est probablement ni l'un ni l'autre.
L'introduction d'un autre langage dans votre base de code nécessitera plus qu'un simple goulot d'étranglement pour en valoir la peine en termes de DX et de performances.
Voici un benchmark pour une fonction de plage simple en JS, WASM et Rust.
{ allocate_buffer: { args: [], returns: FFIType.ptr, }, as_pointer: { args: ["usize"], returns: FFIType.ptr, }, } // Hardcoding this value for 64-bit systems const BYTES_IN_PTR = 8; const box: Pointer = allocate_buffer()!; const ptr: number = read.ptr(box); // Reading the value next to `ptr` const length: number = read.ptr(box, BYTES_IN_PTR); // Hardcoding `byteOffset` to be 0 because Rust guarantees that // Buffer holds `i32` values which take 4 bytes // Note how we need to call a no-op function `as_pointer` because // `toArrayBuffer` takes a `Pointer` but `read.ptr` returns a `number` const _buffer = toArrayBuffer(as_pointer(ptr)!, 0, length * 4);
#[no_mangle] pub extern "C" fn allocate_buffer() -> Box<[usize; 2]> { let buffer: Vec<i32> = vec![0; 10]; let memory: ManuallyDrop<Vec<i32>> = ManuallyDrop::new(buffer); let ptr: *const i32 = memory.as_ptr(); let length: usize = memory.len(); // Unlike a `Vec`, `Box` is FFI compatible and will not drop // its data when crossing the FFI // Additionally, a `Box<T>` where `T` is `Sized` will be a thin pointer Box::new([ptr as usize, length]) } #[no_mangle] pub const extern "C" fn as_pointer(ptr: usize) -> usize { ptr }
{ drop_buffer: { args: [FFIType.ptr], returns: FFIType.void, }, } const registry = new FinalizationRegistry((box: Pointer): void => { drop_buffer(box); }); registry.register(buffer, box);
La bibliothèque native bat à peine WASM et perd systématiquement face à l'implémentation pure de TypeScript.
Et c'est tout pour ce tutoriel d'exploration du module bun:ffi. Espérons que nous en soyons tous sortis un peu plus instruits.
N'hésitez pas à partager vos réflexions et vos questions dans les commentaires
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!