Woodstock Backup - Optimiser la consommation mémoire de Node.js avec Rust
Posté le 10. May 2023 dans Programmation
Introduction
Node.js est un environnement d'exécution JavaScript côté serveur qui repose sur le moteur JavaScript V8 de Google. Il est utilisé pour développer des applications serveur en back-end d'une application web, des outils en ligne de commande et des applications desktop. Cependant, la consommation de mémoire peut être un problème pour certaines applications Node.js, en particulier celles qui manipulent de grandes quantités de données ou des données volumineuses.
Dans cet article, nous allons voir comment optimiser la consommation de mémoire d'une application Node.js en le couplant avec Rust. Rust est un langage de programmation système qui offre des performances similaires à celles du C++, tout en offrant une sécurité de mémoire à la compilation. Rust peut être utilisé pour écrire des bibliothèques C/C++ natives pour Node.js.
Problématique
J'ai développé un logiciel de sauvegarde appelé Woodstock Backup, écrit en TypeScript. Lors du lancement des sauvegardes, il crée une représentation du système de fichier en mémoire et nécessite une grande quantité de mémoire. Pour illustrer cela, nous avons reproduit notre cas avec le code suivant :
const filesize = require("filesize.js");
const fs = require("fs");
// Utilisation des méthodes de sérialisation et de désérialisation du moteur V8
const { serialize, deserialize } = require("v8");
// Méthode pour générer une chaîne de caractère contenant des caractères aléatoires
function randomString(size) {
const buffer = Buffer.alloc(size);
for (let i = 0; i < size; i++) {
buffer[i] = Math.floor(Math.random() * 256);
}
return buffer;
}
// Méthode pour générer un nombre aléatoire de la taille d'un nombre de 53 bits
function randomNumber() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
}
// Création d'un objet de test en javascript contenant que des données aléatoires
const testObject = () => ({
path: randomString(100),
stats: {
ownerId: { low: randomNumber(), high: randomNumber(), unsigned: true },
groupId: { low: randomNumber(), high: randomNumber(), unsigned: true },
size: { low: randomNumber(), high: randomNumber(), unsigned: true },
compressedSize: {
low: randomNumber(),
high: randomNumber(),
unsigned: true,
},
lastRead: { low: randomNumber(), high: randomNumber(), unsigned: true },
lastModified: { low: randomNumber(), high: randomNumber(), unsigned: true },
created: { low: randomNumber(), high: randomNumber(), unsigned: true },
mode: { low: randomNumber(), high: randomNumber(), unsigned: true },
dev: { low: randomNumber(), high: randomNumber(), unsigned: true },
rdev: { low: randomNumber(), high: randomNumber(), unsigned: true },
ino: { low: randomNumber(), high: randomNumber(), unsigned: true },
nlink: { low: randomNumber(), high: randomNumber(), unsigned: true },
},
chunks: [randomString(32), randomString(32), randomString(32)],
sha256: randomString(32),
});
// Lancement du GC pour s'assurer que la mémoire utilisé ne contient que les objets de test
global.gc();
// On recupère la mémoire utilisé avant le test
const memoryBefore = process.memoryUsage().heapUsed;
// On recupère le temps avant le test (pour mesurer le temps de traitement)
const time = Date.now();
// Création des objets. J'ai du lancer plusieurs fois le script pour trouver une valeur qui ne causé pas de crash de
// Node.JS pour cause de manque de mémoire
const nbObjects = 1_300_000;
const testArray = new Array(nbObjects);
for (let i = 0; i < nbObjects; i++) {
testArray[i] = testObject();
}
// Combien de temps à pris la création des objets
console.log("Creation time: ", Date.now() - time);
// Lancement du GC pour s'assurer que nous n'avons pas d'autres reliquats
global.gc();
// Récupération de la mémoire après le test
const memoryAfter = process.memoryUsage().heapUsed;
console.log("Memory consumption: ", filesize.default(memoryAfter - memoryBefore));
console.log("Memory consumption by objects: ", filesize.default((memoryAfter - memoryBefore) / nbObjects));
// Dans la suite on va écrire un fichier contenant le contenu de la mémoire. Cela a été fait initiallement pour
// s'assurer que le GC ne supprime pas mes objets car non utilisés.
const time2 = Date.now();
// Remove test file if exist
try {
fs.unlinkSync("test");
} catch (e) {}
const stream = fs.createWriteStream("test");
// C'est moche, mais c'est pour tester
stream.on("close", () => {
console.log("Write to file time: ", Date.now() - time2);
// Size of file test on disk
const stats = fs.statSync("test");
console.log("Size of file on disk: ", filesize.default(stats.size));
console.log(
"Size of object in the file",
filesize.default(stats.size / nbObjects)
);
});
for (const obj of testArray) {
stream.write(serialize(obj));
}
stream.end();
En estimant rapidement la mémoire que la taille de l'objet aurait dû prendre, je l'estime à environ 432 octets (12 nombres de 2*64 bits + 1 octet de boolean + 128 caractères pour les chunks et 100 caractères pour le nom).
Le code n'est pas le plus propre, mais le but est ici d'illustrer rapidement la problématique. Voici le résultat de ce test :
Nombre d'objets créés | 1 300 000 |
Temps de création | 15 secondes |
Consommation mémoire | 2,8 Go |
Consommation moyenne par objet | 2,2 Ko |
Temps d'écriture dans le fichier | 91 secondes |
Taille du fichier sur le disque | 1,1 Go |
Taille moyenne d'un objet dans le fichier | 903 octets |
Avec ce premier exemple, on peut déjà constater que la consommation mémoire est très importante. En effet, on est à 2,2Ko par objet au lieu des 432 estimés (soit 5 fois plus). La taille des objets dans le fichier est déjà un peu plus raisonnable.
La seule explication au fait que la consommation mémoire soit aussi importante est, je pense, liée au fait que dans V8, les structures sont toutes des objets avec des méthodes par défaut et une structure minimaliste (les Buffers également).
Test avec un BigInt
La question que l'on peut se poser est : pourquoi utiliser la structure { low: Number, high: Number, unsigned: Bool }
?
J'utilise cette structure car pour la persistance, j'utilise la librairie protobuf.js qui ne supporte pas les BigInt, mais qui utilise à la place la librairie long.js qui utilise cette structure.
J'ai donc effectué un test en remplaçant chaque objet complexe par le nouveau type BigInt de Node.js. Voici le code :
Pour générer le nombre aléatoire, je me base sur un entier de 2*53 bits.
function randomNumber() {
return Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
}
function randomBigInt() {
return (BigInt(randomNumber()) << 53n) + BigInt(randomNumber());
}
Le résultat est le suivant:
Nombre d'objets créés | 1 300 000 |
Temps de création | 14 secondes |
Consommation mémoire | 2,1 Go |
Consommation moyenne par objet | 1,7 Ko |
Temps d'écriture dans le fichier | 40 secondes |
Taille du fichier sur le disque | 747,8 Mo |
Taille moyenne d'un objet dans le fichier | 603 octets |
L'utilisation de BigInt
permet de réduire la consommation mémoire de 25% et la taille des objets dans le fichier de
33%. La taille des objets sur le disque est acceptable. En revanche, la consommation mémoire de Node.js reste trop
importante.
Ecriture du même code en Rust
Il est possible d'optimiser certaines parties de l'application grâce à la notion de node_modules natifs. Habituellement, ces modules sont écrits en C++ en utilisant N-API. Il existe des bindings pour Rust, ce qui permet d'écrire une partie de l'application en Rust et de l'utiliser dans Node.JS.
Pour comparer les performances de Rust et de Node.js, j'ai écrit le même code en Rust. Pour les nombres, je me suis basé
sur des entiers de 64 bits et pour le nom de fichier, sur un Vec<u8>
. Je me suis basé sur une table de 32 caractères
pour le Sha256
.
use humansize::{format_size, make_format, DECIMAL};
use procinfo::pid;
use rand::{rngs::ThreadRng, Rng};
use serde::{Deserialize, Serialize};
use std::io::BufWriter;
#[derive(Serialize, Deserialize, Debug)]
struct Stats {
owner_id: u64,
group_id: u64,
size: u64,
compressed_size: u64,
last_read: u64,
last_modified: u64,
created: u64,
mode: u64,
dev: u64,
rdev: u64,
ino: u64,
nlink: u64,
}
#[derive(Serialize, Deserialize, Debug)]
struct Sha256 {
data: [u8; 32],
}
#[derive(Serialize, Deserialize, Debug)]
struct TestObject {
#[serde(with = "serde_bytes")]
path: Vec<u8>,
stats: Stats,
chunks: Vec<Sha256>,
sha256: Sha256,
}
fn generate_random_number(rng: &mut ThreadRng) -> u64 {
rng.gen()
}
fn generate_random_vect(rng: &mut ThreadRng, size: usize) -> Vec<u8> {
let mut vect: Vec<u8> = Vec::with_capacity(size);
for _ in 0..size {
vect.push(rng.gen());
}
vect
}
fn generate_random_sha256(rng: &mut ThreadRng) -> Sha256 {
let mut sha256 = Sha256 { data: [0; 32] };
for i in 0..32 {
sha256.data[i] = rng.gen();
}
sha256
}
fn main() {
let formatter = make_format(DECIMAL);
let mut rng = rand::thread_rng();
// Get the memory consumption of the current process
let memory = pid::statm_self().unwrap();
// Get the current time in ms
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
// Create a vector with 1_300_000 elements of type TestObject
let nb_object = 1_300_000;
let mut test_objects: Vec<TestObject> = Vec::with_capacity(nb_object);
for _ in 0..nb_object {
test_objects.push(TestObject {
path: generate_random_vect(&mut rng, 100),
stats: Stats {
owner_id: generate_random_number(&mut rng),
group_id: generate_random_number(&mut rng),
size: generate_random_number(&mut rng),
compressed_size: generate_random_number(&mut rng),
last_read: generate_random_number(&mut rng),
last_modified: generate_random_number(&mut rng),
created: generate_random_number(&mut rng),
mode: generate_random_number(&mut rng),
dev: generate_random_number(&mut rng),
rdev: generate_random_number(&mut rng),
ino: generate_random_number(&mut rng),
nlink: generate_random_number(&mut rng),
},
chunks: vec![
generate_random_sha256(&mut rng),
generate_random_sha256(&mut rng),
generate_random_sha256(&mut rng),
],
sha256: generate_random_sha256(&mut rng),
});
}
let now_after_creation = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
// Calculate the creation time
println!("Creation time: {} ms", now_after_creation - now);
// Get the memory consumption of the current process
let memory_after_creation = pid::statm_self().unwrap();
// Show memory consumption
println!(
"Memory consumption after creation: {}",
formatter((memory_after_creation.size - memory.size) * 4096)
);
// Show average memory consumption per object
println!(
"Average memory consumption per object: {}",
formatter(((memory_after_creation.size - memory.size) * 4096) / nb_object)
);
// Remove the file test.bin if it exists
match std::fs::remove_file("test.bin") {
Ok(_) => {}
Err(_) => {}
}
// Serialize all the objects in a file
let file = std::fs::File::create("test.bin").unwrap();
let mut buffile = BufWriter::new(file);
for test_object in test_objects.iter() {
bincode::serialize_into(&mut buffile, test_object).unwrap();
}
// Show time to write
let now_after_serialization = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
println!(
"Write to file time: {} ms",
now_after_serialization - now_after_creation
);
// Get the file size and the average object size in file
let metadata = std::fs::metadata("test.bin").unwrap();
println!(
"Size of file on disk: {}",
format_size(metadata.len(), DECIMAL)
);
println!(
"Size of object in the file: {}",
format_size(metadata.len() / nb_object as u64, DECIMAL)
);
}
Ce qui donne le résultat suivant :
Nombre d'objets créés | 1 300 000 |
Temps de création | 2 secondes |
Consommation mémoire | 519,92 Mo |
Consommation moyenne par objet | 399 octets |
Temps d'écriture dans le fichier | 1 seconde |
Taille du fichier sur le disque | 441,99 Mb |
Taille moyenne d'un objet dans le fichier | 339 octets |
En comparant les résultats obtenus avec Node.js et Rust, on peut conclure que Rust est, sans contestation possible, beaucoup plus performant que Node.js en termes de temps d'exécution et de consommation de mémoire pour la création de 1,3 million d'objets. En effet, Rust a créé tous les objets en seulement 2 secondes et consommé 519,92 Mo de mémoire, tandis que Node.js a pris 15 secondes et consommé 2,8 Go de mémoire.
La consommation moyenne de mémoire par objet est du coup nettement plus faible avec Rust (399 octets) qu'avec Node.js (2,2 Ko).
En somme, cette première partie montre clairement que Rust est une alternative intéressante pour les applications nécessitant une manipulation intensive de données, en particulier lorsque la performance et la consommation de mémoire sont des critères importants.
Optimisation de notre application
Pour optimiser notre application en TypeScript, nous allons développer un module natif en Node.js avec Rust. Pour cela, nous allons utiliser NAPI.RS.
N-API est une interface de programmation d'applications (API) qui permet aux modules natifs d'être facilement utilisés dans Node.js. Cela permet aux développeurs de créer des modules en C++ et de les utiliser dans des projets Node.js sans avoir à se soucier de la compatibilité entre les versions de Node.js.
N-API est une API stable et évolutive qui est maintenue par l'équipe Node.js. N-API fournit une interface de programmation d'applications indépendante du moteur JavaScript utilisé par Node.js. Cela signifie que les modules compilés avec N-API fonctionnent de manière cohérente, quel que soit le moteur JavaScript utilisé par Node.js.
napi.rs est une bibliothèque Rust qui fournit une API pour écrire des modules Node.js en Rust. Elle simplifie la création de modules Node.js en fournissant des abstractions de niveau supérieur pour les fonctionnalités N-API. Avec napi.rs, les développeurs Rust peuvent facilement écrire des modules Node.js sans avoir à se soucier des détails techniques de N-API.
Nous allons débuter le développement de la partie Rust de notre application. Je ne vais pas détailler la création d'un module Rust avec NAPI.RS. La documentation est assez bien faite pour cette partie : https://napi.rs/docs/introduction/simple-package.
Le module que nous allons écrire doit conserver les objets qu'il crée en mémoire. En effet, si les objets créés côté Rust étaient ensuite transférés dans la partie Node.js, la consommation de mémoire serait la même que si nous avions créé les objets directement en Node.js.
L'exemple ci-dessous ne fait pas grand-chose, mais c'est un point à prendre en considération lorsque je vais développer le module pour mon application. Les objets transférés à la partie JavaScript ne doivent que transiter.
Voici la partie Rust :
#![deny(clippy::all)]
use humansize::{format_size, DECIMAL};
use procinfo::pid;
use rand::{rngs::ThreadRng, Rng};
#[macro_use]
extern crate napi_derive;
struct Stats {
owner_id: u64,
group_id: u64,
size: u64,
compressed_size: u64,
last_read: u64,
last_modified: u64,
created: u64,
mode: u64,
dev: u64,
rdev: u64,
ino: u64,
nlink: u64,
}
struct Sha256 {
data: [u8; 32],
}
struct TestObject {
path: Vec<u8>,
stats: Stats,
chunks: Vec<Sha256>,
sha256: Sha256,
}
fn generate_random_number(rng: &mut ThreadRng) -> u64 {
rng.gen()
}
fn generate_random_vect(rng: &mut ThreadRng, size: usize) -> Vec<u8> {
let mut vect: Vec<u8> = Vec::with_capacity(size);
for _ in 0..size {
vect.push(rng.gen());
}
vect
}
fn generate_random_sha256(rng: &mut ThreadRng) -> Sha256 {
let mut sha256 = Sha256 { data: [0; 32] };
for i in 0..32 {
sha256.data[i] = rng.gen();
}
sha256
}
pub struct TestObjectWrapper {
test_objects: Vec<TestObject>,
}
#[napi(js_name = "TestObjectWrapper")]
pub struct JsTestObjectWrapper {
test_object_wrapper: TestObjectWrapper,
}
#[napi]
impl JsTestObjectWrapper {
#[napi(constructor)]
pub fn new() -> Self {
Self {
test_object_wrapper: TestObjectWrapper {
test_objects: Vec::new(),
},
}
}
#[napi]
pub fn fill(&mut self, nb_object: i32) {
let mut rng = rand::thread_rng();
// Get the memory consumption of the current process
let memory = pid::statm_self().unwrap();
// Get the current time in ms
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
self.test_object_wrapper.test_objects = Vec::with_capacity(nb_object.try_into().unwrap());
for _ in 0..nb_object {
self.test_object_wrapper.test_objects.push(TestObject {
path: generate_random_vect(&mut rng, 100),
stats: Stats {
owner_id: generate_random_number(&mut rng),
group_id: generate_random_number(&mut rng),
size: generate_random_number(&mut rng),
compressed_size: generate_random_number(&mut rng),
last_read: generate_random_number(&mut rng),
last_modified: generate_random_number(&mut rng),
created: generate_random_number(&mut rng),
mode: generate_random_number(&mut rng),
dev: generate_random_number(&mut rng),
rdev: generate_random_number(&mut rng),
ino: generate_random_number(&mut rng),
nlink: generate_random_number(&mut rng),
},
chunks: vec![
generate_random_sha256(&mut rng),
generate_random_sha256(&mut rng),
generate_random_sha256(&mut rng),
],
sha256: generate_random_sha256(&mut rng),
});
}
let now_after_creation = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis();
// Calculate the creation time
println!("Creation time: {} ms", now_after_creation - now);
// Get the memory consumption of the current process
let memory_after_creation = pid::statm_self().unwrap();
// Show memory consumption
println!(
"Memory consumption after creation: {}",
format_size((memory_after_creation.size - memory.size) * 4096, DECIMAL)
);
// Show average memory consumption per object
println!(
"Average memory consumption per object: {}",
format_size(
((memory_after_creation.size - memory.size) * 4096) / nb_object as usize,
DECIMAL
)
);
}
#[napi(js_name = "toString")]
pub fn to_string(&self) -> String {
format!(
"TestObjectWrapper {{ test_objects: {} }}",
self.test_object_wrapper.test_objects.len()
)
}
}
Pour la parte Node.JS :
const test = require("./index.js");
const filesize = require("filesize.js");
const fs = require("fs");
const { serialize, deserialize } = require("v8");
// Start by consuming memory with big object
// Run GC
global.gc();
// Get memory consumption before
const memoryBefore = process.memoryUsage().heapUsed;
const time = Date.now();
// Create objects
const nbObjects = 1_300_000;
const testArray = new test.TestObjectWrapper();
testArray.fill(nbObjects);
// Bench creation
console.log("Creation time: ", Date.now() - time);
// Run GC
// Get memory consumption after
const memoryAfter = process.memoryUsage().heapUsed;
// Print memory consumption
console.log(
"Memory consumption in JS: ",
filesize.default(memoryAfter - memoryBefore)
);
console.log(testArray.toString());
Voici le résultat:
Nombre d'objets créés | 1 300 000 |
Temps de création | 1 seconde |
Consommation mémoire | 520,31 Mo |
Consommation moyenne par objet | 400 octets |
Parfait, le module écrit en Rust va nous permettre de réduire la consommation mémoire de notre application et améliorer ses performances.
Conclusion - Wasm
Une alternative possible à l'écriture d'un module natif en Rust est l'utilisation de WebAssembly. WebAssembly est un langage de bas niveau qui permet d'écrire des modules qui seront exécutés dans un environnement sécurisé. Il est possible d'écrire des modules en Rust qui seront compilés en WebAssembly.
Pour compiler notre module en WebAssembly, nous allons utiliser le compilateur wasm-pack.
La partie Rust du module n'est pas très différente de la version native. La seule différence est que nous devons utiliser le crate wasm-bindgen pour pouvoir utiliser notre module dans Node.JS.
Là encore, la documentation est très bien faite (https://rustwasm.github.io/docs/book/). Je ne vais pas détailler ici la création de ce module.
mod utils;
use rand::{rngs::ThreadRng, Rng};
use wasm_bindgen::prelude::*;
// When the `wee_alloc` feature is enabled, use `wee_alloc` as the global
// allocator.
#[cfg(feature = "wee_alloc")]
#[global_allocator]
static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT;
#[wasm_bindgen]
struct Stats {
owner_id: u64,
group_id: u64,
size: u64,
compressed_size: u64,
last_read: u64,
last_modified: u64,
created: u64,
mode: u64,
dev: u64,
rdev: u64,
ino: u64,
nlink: u64,
}
#[wasm_bindgen]
struct Sha256 {
data: [u8; 32],
}
#[wasm_bindgen]
struct TestObject {
path: Vec<u8>,
stats: Stats,
chunks: Vec<Sha256>,
sha256: Sha256,
}
fn generate_random_number(rng: &mut ThreadRng) -> u64 {
rng.gen()
}
fn generate_random_vect(rng: &mut ThreadRng, size: usize) -> Vec<u8> {
let mut vect: Vec<u8> = Vec::with_capacity(size);
for _ in 0..size {
vect.push(rng.gen());
}
vect
}
fn generate_random_sha256(rng: &mut ThreadRng) -> Sha256 {
let mut sha256 = Sha256 { data: [0; 32] };
for i in 0..32 {
sha256.data[i] = rng.gen();
}
sha256
}
#[wasm_bindgen]
pub struct TestObjectWrapper {
test_objects: Vec<TestObject>,
}
#[wasm_bindgen]
impl TestObjectWrapper {
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Self {
test_objects: Vec::new(),
}
}
#[wasm_bindgen]
pub fn fill(&mut self, nb_object: i32) {
let mut rng = rand::thread_rng();
// Get the current time in ms
self.test_objects = Vec::with_capacity(nb_object as usize);
for _ in 0..nb_object {
self.test_objects.push(TestObject {
path: generate_random_vect(&mut rng, 100),
stats: Stats {
owner_id: generate_random_number(&mut rng),
group_id: generate_random_number(&mut rng),
size: generate_random_number(&mut rng),
compressed_size: generate_random_number(&mut rng),
last_read: generate_random_number(&mut rng),
last_modified: generate_random_number(&mut rng),
created: generate_random_number(&mut rng),
mode: generate_random_number(&mut rng),
dev: generate_random_number(&mut rng),
rdev: generate_random_number(&mut rng),
ino: generate_random_number(&mut rng),
nlink: generate_random_number(&mut rng),
},
chunks: vec![
generate_random_sha256(&mut rng),
generate_random_sha256(&mut rng),
generate_random_sha256(&mut rng),
],
sha256: generate_random_sha256(&mut rng),
});
}
}
#[wasm_bindgen(js_name = toString)]
pub fn to_string(&self) -> String {
format!(
"TestObjectWrapper {{ test_objects: {} }}",
self.test_objects.len()
)
}
}
Pour utiliser notre module dans Node.JS, nous devons importer le module wasm-bindgen
.
const { webcrypto } = require('node:crypto')
global.crypto = webcrypto
const wasm = require('../pkg/test_rust_was.js');
const filesize = require("filesize.js");
global.gc();
const memoryBefore = process.memoryUsage().rss;
const time = Date.now();
const nbObjects = 1300000;
const testArray = new wasm.TestObjectWrapper();
testArray.fill(nbObjects);
console.log("Creation time: ", Date.now() - time);
global.gc();
const memoryAfter = process.memoryUsage().rss;
console.log("Memory consumption in JS: ", filesize.default(memoryAfter - memoryBefore));
console.log("Memory consumption calculated by head: ", filesize.default(432));
console.log("Memory consumption by objects: ", filesize.default((memoryAfter - memoryBefore) / nbObjects));
console.log(testArray.toString());
Nous n'oublions pas de compiler notre module en mode release et avec la target nodejs.
Le résultat est alors le suivant :
Nombre d'objets créés | 1 300 000 |
Temps de création | 42 secondes |
Consommation mémoire | 450,6 Mo |
Consommation moyenne par objet | 363.5 octets |
Wasm permet d'améliorer la consommation mémoire. Sur mon exemple, le temps de création n'est pas très impressionnant mais est, je crois, lié à la génération des nombres aléatoires.
L'utilisation de WASM pourrait être une bonne alternative.
Quand utiliser WASM et quand faire un module node ? L'avantage de faire du Wasm, c'est que le module peut tourner également côté navigateur. L'autre avantage est que le module n'a pas besoin d'être recompilé en cas de changement de plateforme (Linux, Windows, ... ; x86, ARM, ...).
D'un autre côté, un module node natif a l'avantage d'être plus simple (moins de bindings) et sera sûrement plus rapide à l'exécution.
Conclusion
Pour améliorer les performances de notre application, nous allons donc écrire un module natif en Rust avec N-API.
Vous pouvez retrouver le code source de cet article sur mon dépôt Gitea.