Quelle est la valeur par défaut de max-old-space-size dans NodeJS ?

Posté le 14. June 2024 dans Programmation

Bonjour à tous,

Lors de nos développements en NodeJS, il arrive parfois que l'on se retrouve confronté à des erreurs. Certaines de ces erreurs ne se reproduisent pas en local, mais uniquement sur un environnement distant. C'est ce qui nous est arrivé récemment lors de l'exécution de la commande npm ci sur la chaîne de déploiement continue.

L'erreur que nous avons rencontrée est la suivante :

<--- Last few GCs --->

[14040:0x56930a0]    96150 ms: Mark-Compact 2012.5 (2093.0) -> 2011.8 (2092.0) MB, 902.72 / 17.13 ms  (average mu = 0.416, current mu = 0.217) allocation failure; scavenge might not succeed
[14040:0x56930a0]    97002 ms: Mark-Compact 2019.6 (2092.0) -> 2019.1 (2112.8) MB, 843.10 / 0.00 ms  (average mu = 0.251, current mu = 0.011) allocation failure; scavenge might not succeed

<--- JS stacktrace --->

FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory

Cette erreur se produit lorsque la mémoire allouée pour NodeJS est insuffisante (le garbage collector de NodeJS n'arrive pas à nettoyer la mémoire). Pour résoudre ce problème, l'une des possibilités est d'augmenter la mémoire allouée à NodeJS.

Une autre possibilité est de comprendre pourquoi le programme a besoin d'autant de mémoire et de le corriger, car le problème peut venir d'une fuite de mémoire. Dans notre cas, c'est la commande npm ci qui est en cause. Et la consommation de mémoire dépend du nombre de paquets que nous avons en dépendance (et qui eux-mêmes en ont).

Pour augmenter la mémoire allouée à NodeJS, il faut ajouter l'option --max-old-space-size à la commande node. On peut également modifier la variable d'environnement NODE_OPTIONS pour définir la taille de la mémoire allouée à NodeJS.

Lors de la modification de la mémoire allouée à NodeJS, il est important de prendre en compte la mémoire disponible sur la machine et la mémoire utilisée par les autres processus.

Voici un exemple pour augmenter la mémoire allouée à NodeJS à 4 Go :

export NODE_OPTIONS=--max-old-space-size=4096
npm ci

Mais avant d'augmenter la mémoire allouée à NodeJS, nous allons nous poser une question :

Pourquoi ai-je le problème sur la chaîne de déploiement et pas sur mon poste en local, malgré le fait que la version de NodeJS soit identique ?

Pour répondre à cette question, nous avons commencé par une petite recherche sur le grand réseau. Nous avons trouvé un article très intéressant sur le site Medium. Dans cet article, une expérimentation a été faite pour déterminer la mémoire allouée par défaut à NodeJS en fonction de la version de NodeJS.

Cela montre que dans la version de NodeJS 20 que nous utilisons, la limite devrait être de 4Go par défaut. Mais pourquoi avons-nous cette erreur, sachant que dans le message d'erreur, la mémoire allouée est de 2Go ?

Pour trouver plus d'informations, nous avons dû nous plonger dans le code source de NodeJS, qui embarque lui-même le code source du moteur V8. Nous allons nous concentrer sur la dernière version de NodeJS.

Pour trouver plus d'informations, nous avons essayé de rechercher dans le code les endroits qui parlent de max_old_space_size. Cette recherche m'a amené à la fonction MaxOldGenerationSize dans le fichier deps/v8/src/heap/heap.cc :

// deps/v8/src/heap/heap.cc

// line 362
size_t Heap::MaxOldGenerationSize(uint64_t physical_memory) {
  size_t max_size = V8HeapTrait::kMaxSize;
  // Increase the heap size from 2GB to 4GB for 64-bit systems with physical
  // memory at least 16GB. The theshold is set to 15GB to accomodate for some
  // memory being reserved by the hardware.
  constexpr bool x64_bit = Heap::kHeapLimitMultiplier >= 2;
  if (v8_flags.huge_max_old_generation_size && x64_bit &&
      (physical_memory / GB) >= 15) {
    DCHECK_EQ(max_size / GB, 2u);
    max_size *= 2;
  }
  return std::min(max_size, AllocatorLimitOnMaxOldGenerationSize());
}

Pour pouvoir rassembler les morceaux, nous devons donc connaître V8HeapTrait::kMaxSize et Heap::kHeapLimitMultiplier.

La valeur de V8HeapTrait::kMaxSize est définie dans le fichier deps/v8/src/heap/heap-controller.h :

// deps/v8/src/heap/heap-controller.h

// line 16
struct BaseControllerTrait {
  static constexpr size_t kMinSize = 128u * Heap::kHeapLimitMultiplier * MB;
  static constexpr size_t kMaxSize = 1024u * Heap::kHeapLimitMultiplier * MB;

  static constexpr double kMinGrowingFactor = 1.1;
  static constexpr double kMaxGrowingFactor = 4.0;
  static constexpr double kConservativeGrowingFactor = 1.3;
  static constexpr double kTargetMutatorUtilization = 0.97;
};

Enfin, la valeur de Heap::kHeapLimitMultiplier est définie dans le fichier deps/v8/src/heap/heap.h :

// deps/v8/src/heap/heap.h

// line 306
#if V8_OS_ANDROID
  // Don't apply pointer multiplier on Android since it has no swap space and
  // should instead adapt it's heap size based on available physical memory.
  static const int kPointerMultiplier = 1;
  static const int kHeapLimitMultiplier = 1;
#else
  static const int kPointerMultiplier = kTaggedSize / 4;
  // The heap limit needs to be computed based on the system pointer size
  // because we want a pointer-compressed heap to have larger limit than
  // an ordinary 32-bit which that is constrained by 2GB virtual address space.
  static const int kHeapLimitMultiplier = kSystemPointerSize / 4;
#endif

La valeur de kHeapLimitMultiplier dépend elle-même de kSystemPointerSize, qui n'est pas définie dans ce fichier. C'est dans le fichier deps/v8/src/base/platform/platform.h que nous allons trouver la valeur de kSystemPointerSize :

// deps/v8/src/base/platform/platform.h

// line 84
V8_INLINE intptr_t InternalGetExistingThreadLocal(intptr_t index) {
  const intptr_t kTibInlineTlsOffset = 0xE10;
  const intptr_t kTibExtraTlsOffset = 0xF94;
  const intptr_t kMaxInlineSlots = 64;
  const intptr_t kMaxSlots = kMaxInlineSlots + 1024;
  const intptr_t kSystemPointerSize = sizeof(void*);
  DCHECK(0 <= index && index < kMaxSlots);
  USE(kMaxSlots);
  if (index < kMaxInlineSlots) {
    return static_cast<intptr_t>(
        __readfsdword(kTibInlineTlsOffset + kSystemPointerSize * index));
  }
  intptr_t extra = static_cast<intptr_t>(__readfsdword(kTibExtraTlsOffset));
  if (!extra) return 0;
  return *reinterpret_cast<intptr_t*>(extra + kSystemPointerSize *
                                                  (index - kMaxInlineSlots));
}

Nous avons ainsi tous les éléments pour reconstituer le puzzle :

  • kSystemPointerSize permet de définir la taille d'un pointeur. Sur un système 64 bits, la taille d'un pointeur est de 8 octets, alors que sur un système 32 bits, la taille d'un pointeur est de 4 octets.
  • kHeapLimitMultiplier possède la valeur de 1 sur Android, mais pour les autres systèmes, la valeur est de kSystemPointerSize / 4, soit 2 sur un système 64 bits et 1 sur un système 32 bits.
  • kMaxSize vaut 1024 * kHeapLimitMultiplier * MB, soit 1024 MB sur un système 32 bits et 2048 MB sur un système 64 bits.

Enfin, en lisant le code de la fonction MaxOldGenerationSize, on comprend que la mémoire allouée à NodeJS est de 2Go par défaut.

Mais si le système est un système 64 bits (pointeur de 8 octets) et que la mémoire disponible sur la machine est supérieure à 15Go, alors la mémoire allouée est multipliée par 2, soit 4Go.

Cela explique pourquoi sur nos postes de développement surpuissants, nous n'avons pas l'erreur car la mémoire allouée est de 4Go par défaut. Mais sur la chaîne de déploiement, la mémoire allouée est de 2Go par défaut, car la machine ne possède que 8Go (ce qui est largement suffisant).

Voici un petit tableau récapitulatif:

Système Taille du pointeur Mémoire disponible Mémoire allouée par défaut
32 bits 4 octets OSEF 2Go
64 bits 8 octets < 15Go 2Go
64 bits 8 octets >= 15Go 4Go

J'espère que cet article vous a permis de faire un petit voyage dans le code source de NodeJS et de comprendre comment est calculée la valeur par défaut de max-old-space-size.