18 - Optimisations mémoire
Ce que tu vas apprendre
- Le pooling d'objets : réutiliser au lieu de créer et collecter
- Pourquoi les arrays plats battent les arrays d'objets en mémoire et en vitesse
- Comment les generators evitent les allocations intermediaires
- Le pattern Flyweight et les buffers pre-alloues
- Quand l'optimisation est prematuree (et quand elle ne l'est pas)
Prerequisites
- Connaitre les bases de V8 (V8 en profondeur)
- Savoir utiliser le profiler (Chrome DevTools)
Le jeu qui laggait a 60 fps
Un collegue bossait sur un jeu en canvas. 60 fps, des dizaines de particules a l'ecran, chaque frame créé de nouveaux objets { x, y, vx, vy, life } pour les particules. Ca tournait bien... pendant 30 secondes. Puis le GC se declenchait, gelait le rendu pendant 20ms, et le jeu saccadait.
Le problème n'etait pas la quantité de mémoire. C'etait le rythme de création et destruction. 1000 objets créés par seconde = 1000 objets a collecter par seconde = des pauses GC frequentes.
Object pooling
L'idee : au lieu de créer un nouvel objet et laisser le GC le collecter, tu reutilises des objets existants.
typescriptclass ParticlePool {
private pool: Particle[] = [];
private active: Particle[] = [];
acquire(): Particle {
const particle = this.pool.pop() || new Particle();
particle.reset();
this.active.push(particle);
return particle;
}
release(particle: Particle): void {
const index = this.active.indexOf(particle);
if (index !== -1) {
this.active.splice(index, 1);
this.pool.push(particle);
}
}
}
class Particle {
x = 0;
y = 0;
vx = 0;
vy = 0;
life = 0;
reset(): void {
this.x = 0;
this.y = 0;
this.vx = 0;
this.vy = 0;
this.life = 100;
}
}
Avec un pool, le nombre d'allocations tombe a zero pendant le jeu (apres le warmup initial). Plus de pauses GC. Le collegue est passe de 20ms de freeze toutes les 2 secondes a zero freeze.
Mais attention : le pooling ajoute de la complexité. Tu dois gerer le cycle de vie manuellement. Un objet pas remis dans le pool, c'est une fuite. Un objet remis trop tot et réutilisé ailleurs, c'est un bug subtil. Utilise ca uniquement dans les boucles chaudes (game loop, traitement de flux).
String interning : V8 le fait pour toi
typescriptconst a = "hello";
const b = "hello";
// V8 ne cree qu'une seule string en memoire. a et b pointent au meme endroit.
const c = "hel" + "lo";
// V8 internalise aussi le resultat si c'est une constante a la compilation.
V8 internalise automatiquement les petites chaînes et les chaînes identiques. Tu n'as pas besoin de gerer ca toi-meme. Par contre, les chaînes construites dynamiquement (par concatenation avec des variables) ne sont pas toujours internalisees.
Si tu as 100 000 objets avec un champ status qui vaut "active" ou "inactive", V8 ne stocke ces deux chaînes qu'une fois. Les 100 000 objets pointent vers les deux memes strings.
Arrays plats vs arrays d'objets
typescript// Version objets : chaque element est un objet sur le heap
const points = [
{ x: 1, y: 2 },
{ x: 3, y: 4 },
{ x: 5, y: 6 },
];
// 3 objets + 1 tableau = 4 allocations
// Version plate : un seul tableau
const flatPoints = new Float64Array([1, 2, 3, 4, 5, 6]);
// 1 allocation, donnees contigues en memoire
// flatPoints[i*2] = x, flatPoints[i*2+1] = y
Pour 100 000 points, la version objets créé 100 001 allocations (100 000 objets + le tableau). Chaque objet a un overhead de 32 a 64 octets pour les metadonnees V8. Total : environ 5 Mo.
La version plate : une seule allocation de 1.6 Mo (100 000 * 2 * 8 octets). Et comme les donnees sont contigues en mémoire, le cache CPU peut les prefetcher. L'itération est 2 a 5 fois plus rapide.
J'utilise ce pattern sur paltemps.fr pour les donnees de temperature horaire. 8760 heures par an, 5 stations, ca fait 43 800 nombres. Un Float32Array de 175 Ko au lieu de 43 800 objets a 2 Mo.
Éviter les intermediaires inutiles
typescriptconst data = getHugeArray(); // 1 million d'elements
// Mauvais : cree 2 tableaux intermediaires
const result = data
.map((item) => item.value * 2) // Tableau 1 : 1 million d'elements
.filter((val) => val > 100); // Tableau 2 : N elements
// Mieux : une seule passe avec reduce
const result2 = data.reduce<number[]>((acc, item) => {
const val = item.value * 2;
if (val > 100) acc.push(val);
return acc;
}, []);
// Encore mieux : un simple for
const result3: number[] = [];
for (const item of data) {
const val = item.value * 2;
if (val > 100) result3.push(val);
}
Avec map().filter(), tu alloues un tableau intermediaire de 1 million d'éléments qui est immédiatement jete. C'est 8 Mo d'allocation pour rien. Le for ne créé que le tableau final.
Est-ce que ca compte pour un tableau de 50 éléments ? Non. Le gain est negligeable. Mais pour des dizaines de milliers d'éléments, ca fait une différence mesurable.
Generators pour l'itération paresseuse
typescriptfunction* filterMap(
items: Iterable<DataItem>,
predicate: (item: DataItem) => boolean,
transform: (item: DataItem) => number
): Generator<number> {
for (const item of items) {
if (predicate(item)) {
yield transform(item);
}
}
}
// Traite 1 element a la fois, pas de tableau intermediaire
for (const value of filterMap(hugeArray, (i) => i.active, (i) => i.score)) {
process(value);
}
Un generator ne materialise jamais la liste complète en mémoire. Il produit les valeurs une par une. Si tu lis un fichier de 10 Go ligne par ligne avec un generator, tu as une seule ligne en mémoire a la fois.
Le pattern Flyweight
Le Flyweight séparé les donnees partagees (intrinseques) des donnees uniques (extrinseques).
typescript// Sans Flyweight : chaque arbre stocke toute sa configuration
class Tree {
constructor(
public x: number,
public y: number,
public type: string, // "oak" repete 5000 fois
public texture: ImageData, // 2 Mo repete 5000 fois
public color: string,
) {}
}
// 5000 arbres * 2 Mo = 10 Go
// Avec Flyweight : les donnees partagees sont factorisees
class TreeType {
constructor(
public type: string,
public texture: ImageData,
public color: string,
) {}
}
class TreeInstance {
constructor(
public x: number,
public y: number,
public treeType: TreeType,
) {}
}
const oakType = new TreeType("oak", oakTexture, "#4a3");
// 5000 arbres partagent la meme reference
// 1 texture de 2 Mo au lieu de 5000
Buffers pre-alloues
typescript// Mauvais : on cree un nouveau Buffer a chaque lecture
function processChunk(data: Buffer): Buffer {
const output = Buffer.alloc(data.length * 2);
// ... transformation
return output;
}
// Mieux : un buffer reutilisable
const outputBuffer = Buffer.alloc(64 * 1024); // 64 Ko pre-alloue
function processChunkReuse(data: Buffer): Buffer {
// Ecrit dans le buffer existant
data.copy(outputBuffer);
// ... transformation dans outputBuffer
return outputBuffer.subarray(0, data.length * 2);
}
Meme principe que le pooling, mais pour les buffers. Utile quand tu traites des flux binaires a haut debit.
Quand l'optimisation est prematuree
Tout ce que je viens de decrire a un coût en lisibilité et en complexité. Le pooling rend le code plus fragile. Les arrays plats sont moins lisibles que les objets. Les generators changent le modèle mental.
Ma regle :
- Ecris du code lisible d'abord
- Mesure avec le profiler
- Identifie le goulot réel (pas celui que tu imagines)
- Optimise uniquement le goulot
- Mesure a nouveau pour vérifier le gain
Si ton appli consomme 50 Mo et que tu n'as pas de problème de performance, ne touche a rien. Optimiser du code qui n'a pas besoin de l'etre, c'est perdre du temps et rendre le code plus dur a maintenir.
Résumé
- Le pooling évité les allocations repetitives dans les boucles chaudes.
- V8 internalise automatiquement les strings courtes et identiques.
- Les arrays plats (Float64Array) sont 3 a 5x plus compacts que les arrays d'objets.
- map().filter() créé des tableaux intermediaires : utilise un for pour les gros volumes.
- Mesure avant d'optimiser. Le profiler te dira ou est le vrai problème.
Precedent : Mémoire et Docker Suivant : Comparaison avec d'autres langages