Maison  >  Article  >  interface Web  >  Comment et devriez-vous utiliser Bun FFI

Comment et devriez-vous utiliser Bun FFI

Linda Hamilton
Linda Hamiltonoriginal
2024-11-11 10:53:02770parcourir

How to and Should you use Bun FFI

Qu'essayons-nous de réaliser

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.

Comment lier la bibliothèque à JavaScript

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.

Du côté JS

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();

Transmission de données à travers la frontière FFI

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.

Gestion de la mémoire

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.

Allouer dans Rust

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.

Utiliser le registre de finalisation

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);
Avantages
  • C'est simple
Inconvénients
  • La collecte des déchets est spécifique au moteur et non déterministe
  • Il n'est pas du tout garanti que le rappel de nettoyage soit appelé

Utilisez le paramètre finalizationCallback de toArrayBuffer

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);
Avantages
  • Logique de délégation hors JavaScript
Inconvénients
  • Beaucoup de passe-partout et de risques de fuite de mémoire
  • Annotation de type manquante pour toArrayBuffer
  • La collecte des déchets est spécifique au moteur et non déterministe
  • Il n'est pas du tout garanti que le rappel de nettoyage soit appelé

Gérer la mémoire manuellement

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

  • Journal des modifications de TypeScript 5.2
  • Pull request pour l'utilisation
#[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);
Avantages
  • Le nettoyage est garanti
  • Vous contrôlez quand vous souhaitez supprimer la mémoire
Inconvénients
  • Objet passe-partout pour l'interface jetable
  • La suppression manuelle de la mémoire est plus lente que l'utilisation du garbage collector
  • Si vous souhaitez céder la propriété du tampon, vous devez en faire une copie et déposer l'original

Allouer dans JS

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);

Une note latérale sur les cordes

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) };
}

Qu’en est-il de WebAssembly ?

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.

Est-ce que ça vaut vraiment le coup ?

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!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn