Tests de performance - 01 - Benchmark CPU et mémoire avec Bun

Mesurer les performances d'une fonction avec Bun. performance.now(), process.memoryUsage() et comparaison d'implementations.

01 - Benchmark CPU et mémoire avec Bun

Ce que tu vas apprendre

  • Mesurer le temps d'exécution d'une fonction avec performance.now()
  • Suivre la consommation mémoire avec process.memoryUsage()
  • Écrire une boucle de benchmark avec warmup et stats
  • Comparer des implementations pour choisir la plus rapide

Prerequisites

Avoir Bun installe. Connaitre les bases de TypeScript. Avoir lu l'introduction de la serie.


Pourquoi micro-benchmarker

Quand je bossais sur la page de recherche de paltemps.fr, j'avais deux implementations possibles pour filtrer les résultats : un Array.filter() enchaîné avec un Array.map(), ou une boucle for qui fait tout en un seul passage. Mon instinct me disait que la boucle for serait plus rapide. Mais de combien ? Et est-ce que ca vaut le coup de perdre en lisibilité ?

C'est exactement la question a laquelle répond un micro-benchmark. Pas "quelle est la bonne approche en theorie", mais "combien de nanosecondes de différence sur MES donnees, avec MON runtime".

performance.now() : la base

La méthode performance.now() retourne un timestamp en millisecondes avec une precision a la microseconde. C'est la brique élémentaire :

typescriptconst start = performance.now();
maFonction();
const elapsed = performance.now() - start;
console.log(`${elapsed.toFixed(3)}ms`);

Le problème, c'est qu'une seule mesure ne veut rien dire. Le garbage collector peut se déclencher pile a ce moment. Le CPU peut etre occupe par un autre process. Il faut mesurer plusieurs fois et faire des stats.

Mesurer la mémoire

process.memoryUsage() te donne quatre valeurs :

typescriptconst mem = process.memoryUsage();
console.log({
  rss: (mem.rss / 1024 / 1024).toFixed(1) + "MB",         // memoire totale du process
  heapTotal: (mem.heapTotal / 1024 / 1024).toFixed(1) + "MB", // heap allouee
  heapUsed: (mem.heapUsed / 1024 / 1024).toFixed(1) + "MB",  // heap utilisee
  external: (mem.external / 1024 / 1024).toFixed(1) + "MB",   // buffers C++ lies a des objets JS
});

RSS (Resident Set Size) : la mémoire totale que ton process occupe en RAM. C'est ce que tu vois dans htop. heapUsed : la partie du heap JavaScript réellement utilisee. C'est cette valeur qui te dit si ta fonction alloue trop.

Pour mesurer la mémoire d'une fonction spécifique, prends un snapshot avant et apres :

typescriptfunction measureMemory(fn: () => void) {
  global.gc?.(); // forcer le GC si dispo (bun --gc)
  const before = process.memoryUsage().heapUsed;
  fn();
  const after = process.memoryUsage().heapUsed;
  return (after - before) / 1024; // en KB
}

La fonction benchmark complète

Voici la fonction que j'utilise pour mes benchmarks. Elle gere le warmup, les itérations, et calcule les stats utiles :

typescriptfunction benchmark(fn: () => void, iterations = 1000, warmup = 100) {
  for (let i = 0; i < warmup; i++) fn();

  const times: number[] = [];
  for (let i = 0; i < iterations; i++) {
    const start = performance.now();
    fn();
    times.push(performance.now() - start);
  }

  times.sort((a, b) => a - b);
  return {
    min: times[0],
    max: times[times.length - 1],
    median: times[Math.floor(times.length / 2)],
    p95: times[Math.floor(times.length * 0.95)],
    avg: times.reduce((s, t) => s + t, 0) / times.length,
  };
}

Pourquoi le warmup ? Bun (comme V8 et JavaScriptCore) optimise le code a la volee. Les premières executions d'une fonction sont interpretees. Apres un certain nombre d'appels, le JIT compiler généré du code machine optimise. Si tu mesures sans warmup, tu melanges le temps d'interprétation et le temps réel d'exécution. 100 itérations de warmup suffisent pour que le JIT fasse son travail.

Comparer deux implementations

Prenons un cas concret. Tu as un tableau de 10 000 objets et tu veux extraire les noms :

typescriptinterface User {
  id: number;
  name: string;
  email: string;
  active: boolean;
}

const users: User[] = Array.from({ length: 10_000 }, (_, i) => ({
  id: i,
  name: `User ${i}`,
  email: `user${i}@test.com`,
  active: i % 3 !== 0,
}));

// Implementation 1 : filter + map (deux passes)
function filterMapChain() {
  return users.filter((u) => u.active).map((u) => u.name);
}

// Implementation 2 : boucle for (une passe)
function forLoop() {
  const result: string[] = [];
  for (let i = 0; i < users.length; i++) {
    if (users[i].active) result.push(users[i].name);
  }
  return result;
}

// Implementation 3 : reduce (une passe, mais overhead)
function reduceApproach() {
  return users.reduce<string[]>((acc, u) => {
    if (u.active) acc.push(u.name);
    return acc;
  }, []);
}

const results = {
  "filter+map": benchmark(filterMapChain),
  "for loop": benchmark(forLoop),
  reduce: benchmark(reduceApproach),
};

for (const [name, stats] of Object.entries(results)) {
  console.log(
    `${name.padEnd(12)} | avg: ${stats.avg.toFixed(3)}ms | median: ${stats.median.toFixed(3)}ms | p95: ${stats.p95.toFixed(3)}ms`
  );
}

Lance avec bun run bench.ts et regarde les résultats. Sur ma machine (VPS 2 vCPU, 4 GB RAM), la boucle for est environ 30% plus rapide que filter+map sur 10 000 éléments. Le reduce est entre les deux.

Est-ce que ca vaut le coup ? Sur 10 000 éléments, on parle d'une différence de 0.1ms. Probablement pas. Sur 1 million d'éléments en hot path, oui.

L'ecart-type, le signal d'alerte

Ajoute le calcul de l'ecart-type a ta fonction de benchmark :

typescriptfunction stddev(times: number[], avg: number): number {
  const squareDiffs = times.map((t) => (t - avg) ** 2);
  return Math.sqrt(squareDiffs.reduce((s, d) => s + d, 0) / times.length);
}

Si l'ecart-type est supérieur a 20% de la moyenne, tes mesures sont instables. Ca peut venir du GC, d'un autre process sur la machine, ou d'un warmup insuffisant. Augmente le nombre d'itérations ou isole mieux ton environnement.

benchmark.paltemps.fr

J'ai construit benchmark.paltemps.fr pour faire exactement ca : comparer des snippets TypeScript/JavaScript cote a cote. L'outil gere automatiquement le warmup, lance des milliers d'itérations, et affiche les stats avec les percentiles. Si tu veux tester rapidement deux approches sans écrire tout le boilerplate, c'est fait pour ca.

Les pièges classiques

Ne benchmark pas du code mort. Si le compilateur détecté que le résultat d'une fonction n'est jamais utilise, il peut l'eliminer. Assigne toujours le retour a une variable et utilise-la (ou retourne-la).

Ne compare pas des pommes et des oranges. Si une implementation retourne un nouveau tableau et l'autre modifie le tableau en place, la deuxieme sera toujours "plus rapide" parce qu'elle n'alloue pas de mémoire. Mais elle a un effet de bord.

Ne benchmark pas sur ton laptop en mode batterie. Le CPU throttle. Utilise un environnement stable : un serveur, un VPS, ou au minimum un desktop branche sur secteur avec les autres applications fermees.


Article précédent : 00 - Introduction Article suivant : 02 - k6 : ton premier test de charge HTTP

Sources

Réservez un audit gratuit de 30 minutes. Je vous montre concrètement ce qu'on peut automatiser.