PARTIE I : Fondations système

Chapitre 2 : Gestion mémoire, du noyau au moteur

2.1 Introduction : pourquoi la mémoire est le premier goulot d’étranglement

La gestion de la mémoire, c’est ce qui sépare un moteur qui tient un framerate stable à 90 Hz (le minimum pour la réalité virtuelle) d’un moteur qui part en micro-saccades (hitches), celles qui cassent l’immersion et donnent la nausée.

Le problème de fond, le voici : l’allocation dynamique via malloc() (ou new en C++) a un temps d’exécution imprévisible. Selon l’état de fragmentation du tas (heap), un appel à malloc() peut prendre quelques nanosecondes… ou plusieurs millisecondes, s’il doit parcourir des listes chaînées de blocs libres, fusionner des fragments adjacents, ou réclamer de la mémoire au système d’exploitation via mmap(). Dans un budget de frame de 11 ms (90 Hz), une allocation de 2 ms est catastrophique.1

Ce chapitre explore la gestion mémoire sur trois niveaux de profondeur : le niveau noyau OS (comment le système d’exploitation gère la mémoire physique), le niveau moteur (les allocateurs personnalisés utilisés en espace utilisateur), et le niveau matériel (alignement cache, NUMA, mémoire épinglée pour le GPU).

2.2 Niveau noyau : les allocateurs du système d’exploitation

2.2.1 L’allocateur de pages : le Buddy System

Au niveau le plus bas, le noyau OS gère la mémoire physique par pages, des blocs de taille fixe (typiquement 4 Ko sur x86). Lorsque le noyau doit allouer une page physique (par exemple, lors d’un défaut de page dans le sous-système de mémoire virtuelle), il utilise un allocateur de cadres de pages (page-frame allocator).

Le mécanisme dominant depuis des décennies est le Buddy System (système des copains), inventé par Knowlton en 1965 et popularisé par Knuth. Son principe est élégant :

  1. La mémoire physique est divisée en blocs dont la taille est une puissance de 2 (4 Ko, 8 Ko, 16 Ko, …, jusqu’à plusieurs mégaoctets).
  2. Chaque taille est gérée par une liste chaînée de blocs libres.
  3. Pour allouer une page de taille 2k2^k : si un bloc de taille 2k2^k est disponible, le retourner. Sinon, prendre un bloc de taille 2k+12^{k+1} et le diviser en deux « copains » de taille 2k2^k.
  4. À la libération, si le « copain » d’un bloc est également libre, les deux sont fusionnés (coalesced) en un bloc de taille supérieure.2

L’avantage : la fusion rapide limite la fragmentation externe, avec une complexité en O(logn)O(\log n) dans le pire cas.

L’inconvénient, c’est la fragmentation interne, élevée : une demande de 5 Ko force l’allocation d’un bloc de 8 Ko, et 3 Ko partent en pure perte. Les blocs physiquement contigus de grande taille deviennent eux aussi difficiles à obtenir dès que la mémoire se fragmente, un problème critique pour le DMA (Direct Memory Access) des périphériques GPU et BCI.

Implémentation LplKernel : PMM à double stratégie

LplKernel implémente un PMM (Physical Memory Manager) à double stratégie, sélectionnée à la compilation par le flag REALTIME_MODE :

  • Client (REALTIME_MODE=1) : Une pile LIFO de pages libres (free-list) offrant un alloc/free en O(1)O(1) déterministe absolu. Le PMM client couvre les pages de 1 Mo à 16 Mo (boot mapping), extensibles via physical_memory_manager_extend_mapping(). Aucune opération de fusion (coalescing) n’est effectuée : la prédictibilité temporelle prime sur l’optimisation de la fragmentation.
  • Serveur (REALTIME_MODE=0) : Un Buddy Allocator complet avec API d’ordre (allocate_order(n), free_order(addr, n)) pour allouer des blocs de 2n2^n pages physiquement contigus. La fusion automatique des copains est active, instrumentée par un histogramme d’ordres libres (o0..o18), des watermarks haut/bas, un ratio de fragmentation, et des compteurs de garde (rejected-free, double-free).

Les deux chemins partagent une couche de détection UAF (Use-After-Free) : le compteur pmm_uaf_detection_count est incrémenté lorsqu’un motif empoisonné (0xDEADBEEF / 0xFEEDFACE) est détecté dans une page censée être libre, signalant un accès post-libération. L’ensemble est validé par 8 smoke tests (coalescing, stress, order, watermark, fragmentation, UAF).

2.2.2 L’allocateur d’objets : SLAB, SLUB, SLOB

Le Buddy System est trop grossier pour les petites allocations fréquentes du noyau (structures inode, tampons de socket, descripteurs de processus). C’est Jeff Bonwick qui introduisit, en 1994 pour SunOS, le concept de Slab Allocation : des caches d’objets pré-initialisés, triés par taille, qui éliminent le coût de l’initialisation répétée.3

Le noyau Linux a connu trois implémentations successives :

AllocateurPrincipePoints FortsLimitations
SLAB (original)Caches avec files d’attente per-CPU et per-nœud NUMARéutilisation d’objets efficaceComplexité excessive sur les systèmes multi-cœurs
SLUB (défaut depuis 2.6.23)Simplification : suppression des files, gestion directe par pageScalabilité supérieure, métadonnées réduitesLatence variable en « chemin lent » (slow path)
SLOBListe simple de blocs (style K&R)Empreinte mémoire minimaleFragmentation pathologique

Le SLUB est devenu l’allocateur par défaut grâce à sa scalabilité sur les architectures multi-cœurs modernes. Chaque CPU possède sa propre freelist (liste de blocs libres) locale, ce qui réduit la contention inter-processeurs.4

2.2.3 Évolution récente : les Sheaves (Linux 6.18)

Entre 2024 et 2026, le noyau Linux a introduit les Sheaves (« gerbes ») dans le mécanisme SLUB, fusionnées dans la version 6.18. L’objectif : supprimer les couches de cache redondantes que beaucoup de sous-systèmes (pile réseau, pilotes) empilaient par-dessus l’allocateur de slab pour compenser ses limites de performance.5

Une sheaf consiste en un tableau de taille fixe de pointeurs d’objets provenant de slabs existants. Chaque processeur se voit attribuer deux sheaves : une principale pour les allocations courantes, une de réserve pour éviter les interruptions au moment du remplissage ou du vidage. Les opérations d’allocation et de libération deviennent quasi-atomiques via la primitive local_trylock_t, ce qui minimise la contention globale.

Le barn (« grange ») est un cache par nœud NUMA qui sert de réservoir de second niveau pour les sheaves vides ou pleines : il permet de recycler les objets localement sans solliciter l’allocateur de pages. Les mesures montrent un gain d’allocation qui peut atteindre 20 % dans les environnements virtualisés et les piles réseau intensives.5

2.2.4 Les indicateurs GFP et les contextes d’allocation

Le comportement de l’allocateur noyau est guidé par les GFP flags (Get Free Pages), qui expriment les contraintes du contexte d’allocation :

  • GFP_KERNEL : Allocation standard. Le processus appelant autorise le noyau à le mettre en sommeil (sleep) si la mémoire est insuffisante, déclenchant une réclamation directe (direct reclaim) pour libérer des pages.
  • GFP_NOWAIT / GFP_ATOMIC : Allocation depuis un contexte atomique (gestionnaire d’interruption matérielle). Le sommeil est interdit : l’allocation échoue si aucun bloc n’est immédiatement disponible.
  • GFP_DMA : L’allocation doit provenir d’une zone de mémoire accessible par les contrôleurs DMA (typiquement les premiers 16 Mo sur les vieilles architectures x86).6

Cette distinction entre allocations « dormantes » et « atomiques » se retrouve, transformée, dans la gestion mémoire des moteurs temps réel : une allocation à latence variable passe pendant le chargement, jamais pendant la simulation.

2.2.5 Sécurité : KFENCE et allocateurs par buckets

La sécurité des allocateurs noyau est un champ de bataille permanent. Les techniques d’exploitation modernes (Use-After-Free, heap spraying, Slab-out-of-bounds) tirent parti de la prévisibilité de la disposition mémoire dans le tas noyau.7

Le noyau Linux 6.19 a introduit un allocateur de slab dédié par buckets (seaux) pour isoler les objets dans des compartiments spécifiques, réduisant la probabilité qu’un attaquant puisse manipuler la disposition mémoire. En complément, KFENCE (Kernel Electric-Fence) est un détecteur d’erreurs mémoire basé sur l’échantillonnage statistique : il insère des pages de garde (guard pages) autour d’un échantillon aléatoire d’objets alloués, détectant les accès hors limites et les use-after-free avec un surcoût négligeable (~1 %) en production.8

Implémentation LplKernel : KFENCE à coût nul

Pour sécuriser le tas du noyau sans introduire la latence d’un outil lourd comme KASAN, LplKernel s’inspire du modèle KFENCE en pré-allouant un pool fixe d’objets où chaque objet est entouré de pages de garde non mappées (via PROT_NONE). En alignant aléatoirement les allocations à l’extrême gauche ou à l’extrême droite de la page physique, tout dépassement de tampon (Buffer Overflow) déborde instantanément sur la page non mappée, déclenchant un Page Fault matériel (zéro instruction de vérification logicielle dans le chemin critique).

2.2.6 Implémentation LplKernel : kmalloc à double profil

Le noyau LplKernel implémente son propre allocateur kmalloc/kfree dont la stratégie diverge radicalement selon le profil de compilation :

Profil Client (REALTIME_MODE=1), stratégie slab+first-fit-client :

  • Pool de boot fixe : 8 pages physiques pré-allouées à l’initialisation du tas. Aucune croissance du pool via PMM en cours d’exécution.
  • Caches Slab déterministes (O(1)O(1)) : trois classes de taille (16 B, 64 B, 256 B), chacune protégée par un free-cookie anti-double-free (0x534C4131).
  • First-fit pour les tailles restantes, alimenté exclusivement par les pages de boot.
  • Règle de la hot loop : kmalloc est interdit pendant la boucle chaude de simulation. L’API d’enforcement (kernel_heap_hot_loop_enter() / kernel_heap_hot_loop_leave()) trace la profondeur d’imbrication et incrémente un compteur de violations si une allocation est tentée en zone interdite. Cette règle est validée par un smoke test dédié.

Profil Serveur (REALTIME_MODE=0), stratégie sizeclass+first-fit-server :

  • Buckets de taille (O(1)O(1)) : 7 free-lists par taille (8, 16, 32, 64, 128, 256, 512 B), avec compteurs de hit et de remplissage par classe.
  • First-fit fallback pour les allocations hors-bucket.
  • Allocations larges (>> PAGE_SIZE) redirigées vers l’allocateur buddy à ordre.
  • Domaines d’allocation : le serveur supporte un scaffold multi-domaines où le sélecteur de domaine est piloté par le slot logique CPU (APIC ID compacté en slot stable). Chaque domaine possède ses propres compteurs de refill, fallback, sonde distante et hit distant. Cela prépare le sharding per-CPU puis per-NUMA sans modifier le comportement actuel.

Gardes partagés (les deux profils) : validation magic du header sur chaque chemin, compteur de free rejeté, compteur de double-free, canary par objet sensible, patterns de poison (0xFEEDFACE) activables en build debug.

2.3 Niveau moteur : allocateurs personnalisés

Les allocateurs du noyau OS visent la généralité. Un moteur performant les contourne autant qu’il peut : il pré-alloue de vastes blocs de mémoire au démarrage, puis les subdivise avec ses propres allocateurs, au comportement entièrement prévisible.

2.3.1 L’allocateur linéaire (Arena Allocator)

L’allocateur le plus simple et le plus performant est l’Arena Allocator (ou Linear Allocator, Bump Allocator). Son principe est trivial :

  1. Au démarrage, réserver un bloc monolithique de mémoire (par exemple, 64 Mo via mmap()).
  2. Maintenir un pointeur current initialisé au début du bloc.
  3. Pour allouer nn octets : retourner current, puis incrémenter current de nn (aligné).
  4. Pour tout libérer : réinitialiser current au début du bloc.
class ArenaAllocator {
    uint8_t* m_buffer;
    size_t   m_capacity;
    size_t   m_offset;

public:
    explicit ArenaAllocator(size_t size)
        : m_buffer(static_cast<uint8_t*>(std::aligned_alloc(64, size)))
        , m_capacity(size)
        , m_offset(0) {}

    ~ArenaAllocator() { std::free(m_buffer); }

    void* Allocate(size_t size, size_t alignment = 16) {
        // Aligner l'offset
        size_t aligned = (m_offset + alignment - 1) & ~(alignment - 1);
        if (aligned + size > m_capacity) return nullptr; // Épuisé

        void* ptr = m_buffer + aligned;
        m_offset = aligned + size;
        return ptr;
    }

    void Reset() { m_offset = 0; } // Libération O(1)
};

La complexité d’allocation est O(1)O(1) : une addition et une comparaison. La libération est elle aussi O(1)O(1) : un reset du pointeur. Aucune fragmentation externe possible, aucune liste chaînée à parcourir, aucun verrou à acquérir.9

L’Arena est idéale pour les données éphémères, à durée de vie de frame : culling, résultats de raycast, commandes de rendu. À chaque début de frame, on réinitialise l’arène, ce qui libère d’un coup toute la mémoire de la frame précédente. Cette approche temporelle de la mémoire, où la validité d’une donnée tient à un cycle de temps (la frame) et non au cycle de vie d’un objet, est l’antithèse du new/delete C++ traditionnel.

2.3.2 Le Pool Allocator

Le Pool Allocator (allocateur de pool) gère des blocs de taille fixe. Il est taillé pour les entités qu’on crée et détruit sans arrêt (projectiles, particules, PNJ temporaires) :

  1. Au démarrage, pré-allouer un tableau de NN blocs de taille fixe SS.
  2. Maintenir une liste chaînée intrusive (intrusive linked list) des blocs libres : le pointeur next est stocké dans le bloc libre lui-même, sans aucune allocation de métadonnées.
  3. Pour allouer : détacher la tête de la liste (pop). O(1)O(1).
  4. Pour libérer : réattacher le bloc en tête de la liste (push). O(1)O(1).
class PoolAllocator {
    struct FreeNode { FreeNode* next; };

    uint8_t*  m_buffer;
    FreeNode* m_freeList;
    size_t    m_blockSize;

public:
    PoolAllocator(size_t blockSize, size_t blockCount)
        : m_blockSize(std::max(blockSize, sizeof(FreeNode)))
    {
        m_buffer = static_cast<uint8_t*>(
            std::aligned_alloc(64, m_blockSize * blockCount));

        // Construire la liste chaînée intrusive
        m_freeList = nullptr;
        for (size_t i = blockCount; i > 0; --i) {
            auto* node = reinterpret_cast<FreeNode*>(
                m_buffer + (i - 1) * m_blockSize);
            node->next = m_freeList;
            m_freeList = node;
        }
    }

    void* Allocate() {
        if (!m_freeList) return nullptr;
        FreeNode* node = m_freeList;
        m_freeList = m_freeList->next;
        return node;
    }

    void Free(void* ptr) {
        auto* node = static_cast<FreeNode*>(ptr);
        node->next = m_freeList;
        m_freeList = node;
    }
};

La liste chaînée intrusive est une astuce classique : en stockant le pointeur next directement dans la mémoire du bloc libre, le Pool se passe de toute structure externe. Le coût mémoire des métadonnées est nul : mémoire de métadonnées et mémoire de données se superposent dans le temps (les métadonnées existent quand le bloc est libre ; les données les écrasent quand le bloc est alloué).10

2.3.3 Le Stack Allocator

Le Stack Allocator est un Arena amélioré : il permet de libérer dans l’ordre inverse de l’allocation (LIFO, Last In, First Out). Idéal pour les données scopées :

class StackAllocator {
    uint8_t* m_buffer;
    size_t   m_offset;

public:
    using Marker = size_t;

    Marker GetMarker() const { return m_offset; }

    void* Allocate(size_t size, size_t alignment = 16) {
        size_t aligned = (m_offset + alignment - 1) & ~(alignment - 1);
        void* ptr = m_buffer + aligned;
        m_offset = aligned + size;
        return ptr;
    }

    void FreeToMarker(Marker marker) { m_offset = marker; }
};

Le pattern d’utilisation classique est le scope guard :

auto marker = stack.GetMarker();
// Allocations temporaires pour un calcul intermédiaire
auto* tempData = stack.Allocate(1024);
ProcessData(tempData);
// Libération automatique à la fin du scope
stack.FreeToMarker(marker);

2.3.4 Le Ring Buffer (tampon circulaire)

Le Ring Buffer est la structure de données utilisée pour la communication inter-threads, en particulier entre le thread d’acquisition BCI et le thread de traitement du signal. Sa mémoire est un tableau circulaire avec deux pointeurs : head (écriture) et tail (lecture).

template <typename T, size_t Capacity>
class RingBuffer {
    alignas(64) std::atomic<size_t> m_head{0}; // Ligne de cache séparée
    alignas(64) std::atomic<size_t> m_tail{0}; // Évite le false sharing
    T m_data[Capacity];

public:
    bool Push(const T& item) {
        size_t head = m_head.load(std::memory_order_relaxed);
        size_t next = (head + 1) % Capacity;
        if (next == m_tail.load(std::memory_order_acquire))
            return false; // Buffer plein
        m_data[head] = item;
        m_head.store(next, std::memory_order_release);
        return true;
    }

    bool Pop(T& item) {
        size_t tail = m_tail.load(std::memory_order_relaxed);
        if (tail == m_head.load(std::memory_order_acquire))
            return false; // Buffer vide
        item = m_data[tail];
        m_tail.store((tail + 1) % Capacity, std::memory_order_release);
        return true;
    }
};

std::atomic avec des ordres mémoire explicites (acquire/release) garantit l’absence de data races sans le moindre mutex. Le alignas(64) sur m_head et m_tail place chaque compteur sur sa propre ligne de cache (64 octets sur la plupart des architectures), ce qui évite le false sharing : deux variables atomiques indépendantes qui partagent la même ligne de cache et déclenchent des invalidations parasites entre les cœurs qui les modifient en même temps.11

Cette implémentation SPSC (Single-Producer, Single-Consumer) est plus performante qu’un boost::lockfree::spsc_queue dans les cas simples, et elle est la structure de choix pour le transfert de données EEG entre le thread d’acquisition et le thread de traitement.

2.4 Allocateurs temps réel déterministes

Les allocateurs ci-dessus (Arena, Pool, Stack) éliminent l’allocation dynamique pendant la simulation en la remplaçant par de la pré-allocation. Certains sous-systèmes réclament néanmoins des allocations de taille variable en cours d’exécution (par exemple des buffers réseau dont la taille dépend du nombre de joueurs). Pour ces cas-là, il faut des allocateurs déterministes, à complexité garantie.

2.4.1 TLSF : Two-Level Segregated Fit

L’allocateur TLSF (Two-Level Segregated Fit) est pensé pour les systèmes temps réel. Il repose sur des listes ségréguées à deux niveaux qui garantissent une complexité O(1)O(1) pour les opérations malloc et free :12

  • Premier niveau : sépare les blocs par classes de taille correspondant à des puissances de deux (24,25,26,2^4, 2^5, 2^6, \ldots).
  • Second niveau : divise chaque classe de puissance de deux en sous-classes linéaires (typiquement 16 ou 32 subdivisions), pour un ajustement précis à la taille demandée.

En utilisant des instructions matérielles de recherche de bit (ffs, clz : Find First Set, Count Leading Zeros), TLSF identifie le bloc libre optimal en 168 instructions au maximum sur architecture x86.12

MétriqueTLSFPool (blocs fixes)malloc (glibc)
Complexité temporelleO(1)O(1) constantO(1)O(1) constantVariable (O(n)O(n) pire cas)
Fragmentation interneFaible (ajustement fin)ÉlevéeFaible
Fragmentation externe< 25 % maximumNulleVariable
Tailles supportéesVariablesFixe uniqueVariables

2.4.2 O1Heap : l’allocateur Half-Fit pour l’embarqué

L’allocateur o1heap, fondé sur l’algorithme Half-Fit d’Ogasawara, offre des garanties comparables à TLSF dans une implémentation encore plus compacte, pensée pour les microcontrôleurs. Sur un Cortex-M4, une allocation ou une désallocation prend invariablement autour de 165 cycles d’horloge, à quelques cycles près.13

Ce contrôle déterministe du temps d’exécution, c’est le prérequis d’un moteur FullDive où chaque microseconde compte : si l’allocateur prend 2 ms au lieu de 2 µs, ce sont 18 % du budget de frame à 90 Hz qui s’évaporent.

2.5 Considérations matérielles

2.5.1 Alignement cache et false sharing

Les processeurs modernes accèdent à la mémoire par lignes de cache (typiquement 64 octets). Aligner les données sur ces lignes est critique, pour deux raisons :

  1. Accès non alignés : un accès qui chevauche deux lignes de cache demande deux lectures au lieu d’une, ce qui double la latence.
  2. False sharing : quand deux threads modifient des variables indépendantes qui résident sur la même ligne de cache, le protocole de cohérence (MESI) force des invalidations inutiles entre les cœurs, et les performances s’effondrent.14

Ce phénomène est particulièrement destructeur dans les structures Lock-Free inter-cœurs, comme les SPSC Ring Buffers (BipBuffer) utilisés pour router les événements matériels vers la simulation. Si le pointeur de lecture atomique (read_index) et le pointeur d’écriture atomique (write_index) résident sur la même ligne de 64 octets, le producteur invalide en continu le cache du consommateur, et la bande passante est divisée par dix.

// MAUVAIS : les deux atomiques partagent la même ligne de cache
struct SharedData {
    std::atomic<int> writerCounter;  // Offset 0
    std::atomic<int> readerCounter;  // Offset 4 — même ligne de cache !
};

// BON : chaque atomique sur sa propre ligne de cache
struct AlignedData {
    alignas(64) std::atomic<int> writerCounter;
    alignas(64) std::atomic<int> readerCounter;
};

2.5.2 Architectures NUMA

Sur les serveurs multi-socket, l’accès à la mémoire n’est pas uniforme (NUMA, Non-Uniform Memory Access) : chaque processeur a sa propre banque de mémoire locale, et accéder à celle d’un autre processeur impose une latence supplémentaire (typiquement 1.5× à 2× plus lent).

Pour un serveur de build LplKernel gérant des milliers de connexions, chaque instance de la simulation doit être liée (pinned) à un nœud NUMA spécifique via numactl --cpunodebind=0 --membind=0. Les accès mémoire les plus fréquents s’effectuent alors sur les banques locales.15

L’évolution récente des Sheaves SLUB intègre cette conscience NUMA via les barns (granges) par nœud : les objets libérés sur un nœud NUMA sont préférentiellement recyclés sur ce même nœud.

Implémentation LplKernel : SMP, topologie CPU et NUMA

Le serveur LplKernel implémente une infrastructure SMP progressive :

  1. Topologie CPU : Le sous-système cpu_topology découvre les cœurs via parsing MADT (ACPI), enregistre chaque APIC ID découvert, les compacte en slots logiques stables (supportant les APIC ID non contigus), et maintient un bitmap d’état en ligne (online) par slot avec un compteur de CPU actifs. Le BSP (Bootstrap Processor) est automatiquement marqué en ligne à l’initialisation.
  2. Démarrage AP (Application Processors) : Un trampoline en mémoire basse est installé au vecteur SIPI 0x08. Le BSP dispatch une séquence INIT/SIPI vers chaque AP découvert, validée par un marqueur d’acquittement (ack_word=0x4150) et un handoff en mode protégé/paginé vers le point d’entrée C de l’AP (application_processor_startup_initialize_cpu). La télémétrie enregistre les tentatives, retransmissions, et timeouts par AP.
  3. Domaines d’allocation : Chaque slot CPU est lié à un domaine d’allocation via cpu_topology_bind_slot_to_domain(). Le heap serveur route les allocations à travers cette table de binding, pour une politique de placement local-first. Des compteurs par domaine suivent les refills, fallbacks, sondes distantes et hits distants.
  4. IPI et TLB Shootdown : Un chemin IPI basique est présent pour la synchronisation des métadonnées mémoire et l’invalidation TLB inter-cœurs. Le mode x2APIC est supporté avec fallback xAPIC.
  5. Feuille de route NUMA : Extension des domaines d’allocation en domaines par nœud NUMA, politique de placement local-first explicite, chemin de fallback cross-node avec télémétrie local-vs-remote, et compteurs de hit rate par nœud.

2.5.3 Huge Pages

La traduction d’adresses virtuelles en adresses physiques s’effectue via les tables de pages, avec un cache matériel dédié : le TLB (Translation Lookaside Buffer). Avec des pages de 4 Ko, un TLB de 1024 entrées ne couvre que 4 Mo de mémoire, insuffisant pour un moteur qui manipule des gigaoctets de données de géométrie et de textures.16

Les Huge Pages (2 Mo ou 1 Go sur x86) augmentent considérablement la couverture du TLB :

ConfigurationCouverture TLB (1024 entrées)
Pages 4 Ko4 Mo
Huge Pages 2 Mo2 Go
Huge Pages 1 Go1 To
  • Transparent Huge Pages (THP) : gérées automatiquement par le noyau, mais peuvent introduire des latences imprévisibles lors de la défragmentation en arrière-plan par le thread khugepaged.
  • HugeTLBfs : allocation statique et pinning au démarrage. Pour un moteur FullDive, cette méthode est impérative pour les framebuffers et les données de géométrie : elle garantit l’absence de fautes de page pendant le rendu.

2.5.4 Mémoire épinglée (Pinned Memory) pour le GPU

Le transfert de données entre la RAM système et la VRAM du GPU, via le bus PCIe, exige que les pages source soient épinglées (pinned), c’est-à-dire non échangeables (non-swappable). Sans épinglage, le système d’exploitation pourrait déplacer une page en plein transfert DMA, et provoquer une corruption ou un crash.

Les API modernes (Vulkan, CUDA) fournissent des fonctions d’allocation de mémoire épinglée (vkAllocateMemory avec le flag VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, cudaMallocHost). Ces allocations sont coûteuses (elles verrouillent les pages physiques, réduisant la flexibilité du gestionnaire de mémoire virtuelle), et doivent donc être effectuées au démarrage, jamais pendant la simulation.17

2.5.5 CDMM : coordination GPU/CPU via NVIDIA

Pour le client FullDive, le mode CDMM (Coherent Driver-based Memory Management) de NVIDIA permet au pilote GPU de gérer directement la mémoire vidéo sans qu’elle soit exposée comme un nœud NUMA logiciel au noyau. Cela réduit les frais de migration de pages et facilite le partage direct des buffers entre les pipelines de simulation CPU et de rendu GPU, pour une latence motion-to-photon (MTP) ultra-basse.18

2.6 Blueprints architecturaux

2.6.1 Architecture serveur (Build Server)

CoucheChoix TechnologiqueJustification
Allocateur noyauSLUB + Sheaves (Linux 6.18+)Débit maximal par CPU, réduction de contention NUMA
Mémoire virtuelleHugeTLBfs, pages 2 MoCouverture TLB pour caches d’état des clients
ConcurrenceBoucles epoll liées aux nœuds NUMA + RCULectures massives sans verrou
Réseauio_uringMinimisation des transitions user/kernel

2.6.2 Architecture client (FullDive/Embarqué)

CoucheChoix TechnologiqueJustification
Allocateur de tasTLSFLatence O(1)O(1) pour toute allocation dynamique
Données de frameArena Allocator (reset par frame)Zéro fragmentation, zéro Free()
EntitésPool Allocator (intrusive freelist)Recyclage O(1)O(1) des projectiles, particules
Transfert BCIRing Buffer SPSC lock-freeZéro mutex entre acquisition et traitement
GPUMémoire épinglée via CDMMLatence MTP minimale

Règles mémoire client LplKernel : enforcement temps réel

La règle non négociable du client est : zéro allocation dynamique dans la hot loop. En dehors de la boucle chaude, seuls les caches slab client (16/64/256 B) et le small pool first-fit sur pages de boot fixes sont autorisés. En boucle chaude, seuls les allocateurs pré-alloués (frame arena, pool, ring buffer) sont permis : aucun site d’appel kmalloc/kfree.

L’enforcement runtime est assuré par :

  • La profondeur hot-loop doit revenir à 0 après chaque étape de frame.
  • Le compteur de violations hot-loop doit rester stable en boucle nominale.
  • Les compteurs de garde du heap ne doivent pas augmenter en boucle nominale.

Allocateurs spécialisés validés : Frame Arena (bump allocator, 2 KiB bootstrap, reset-per-frame, 5 smoke tests), Pool Allocator (64 B, 32 slots, free-list O(1)O(1), 2 smoke tests), Ring Buffer SPSC (32 B slots, 32 entrées, FIFO déterministe, 2 smoke tests), Stack Allocator (push/pop_all LIFO, 1 smoke test), TLSF (segregated fit O(1)O(1), WCET borné ≤ 168 instructions x86, 4 smoke tests). Au total, 47 smoke tests validés en QEMU couvrant les deux profils.

2.6.3 Paging runtime : de boot.s à l’API dynamique

L’implémentation du paging runtime dans LplKernel illustre un piège classique des noyaux higher-half et mérite un traitement détaillé en tant que leçon d’architecture.

Le problème initial : la première implémentation de paging_init() commettait plusieurs erreurs fondamentales :

  1. Structures non portables : Des unions imbriquées avec bitfields ne respectaient pas l’alignement strict 32-bit requis par le CPU. Les entrées de Page Directory et Page Table doivent faire exactement 4 octets.
  2. Écrasement du paging de boot : La fonction re-mappait toute la mémoire en identity mapping (1:1), détruisant le mapping higher-half (0xC0000000) configuré par boot.s.
  3. Confusion virt/phys : L’opérateur & en C retourne une adresse virtuelle (≥ 0xC0000000 dans un noyau higher-half), mais les entrées PDE/PTE exigent des adresses physiques. L’écriture directe de &entries[...] dans les entrées provoquait un triple fault immédiat.
  4. Rechargement CR3 inutile : Recharger CR3 après le boot invalide le TLB et écrase le page directory déjà actif.

La solution correcte :

// Types conformes Intel/OSDev — pas de bitfields, juste uint32_t + macros
typedef uint32_t PageDirectoryEntry_t;
typedef uint32_t PageTableEntry_t;

#define PAGE_PRESENT        0x001
#define PAGE_WRITE          0x002
#define PAGE_USER           0x004
#define PAGE_DIRECTORY_INDEX(v) (((uint32_t)(v) >> 22) & 0x3FF)
#define PAGE_TABLE_INDEX(v)    (((uint32_t)(v) >> 12) & 0x3FF)
#define PAGE_FRAME_ADDR(e)     ((e) & 0xFFFFF000)

// Initialisation : récupérer le PD de boot.s, ne PAS recharger CR3
void paging_init_runtime(void) {
    current_page_directory = boot_page_directory; // Symbole exporté par boot.s
}

// Conversion explicite virt→phys (fondamentale en higher-half)
static inline uint32_t virt_to_phys(uint32_t virt) {
    return (virt >= KERNEL_VIRTUAL_BASE) ? virt - KERNEL_VIRTUAL_BASE : virt;
}

L’API runtime (paging_map_page(), paging_unmap_page()) gère la création dynamique de page tables quand un PDE est absent, avec réclamation automatique des page tables vides lors du démappage. L’instruction invlpg invalide le TLB pour une seule page, évitant le coût d’un flush complet du TLB (mov cr3, cr3).

Concepts clés du higher-half :

  • L’entrée 768 du Page Directory (3 Go / 4 Mo = 768) mappe l’espace noyau.
  • Au boot, un identity mapping temporaire coexiste avec le mapping higher-half pour que le code de transition puisse s’exécuter. Il est retiré après le jmp vers l’espace higher-half.
  • Le TLB cache les traductions virt→phys. Toute modification d’une PTE doit être suivie d’un invlpg sous peine d’utiliser une traduction périmée.

2.7 Synthèse

La hiérarchie complète de la gestion mémoire dans LplKernel forme une pyramide :

graph TD
    A["Application
Arena / Pool / Stack
Engine allocators O(1)"] B["TLSF / O1Heap
Deterministic dynamic allocations"] C["SLUB + Sheaves
Kernel slab allocator"] D["Buddy System
Physical page allocator"] E["Hardware (NUMA, Cache, TLB, PCIe)
Physical constraints"] A --> B --> C --> D --> E

Le principe directeur est simple : aucune allocation dynamique non déterministe entre le premier et le dernier tick de simulation d’une frame. Toute la mémoire nécessaire est soit pré-allouée (Pool, Arena), soit obtenue avec des garanties O(1)O(1) (TLSF). Les allocations « lentes » (chargement d’assets, création de monde) restent confinées aux phases de chargement, à l’écart de la boucle de simulation.

Notes de bas de page du chapitre 2


Footnotes

  1. Un budget de frame de 11.11 ms (90 Hz) ou 16.67 ms (60 Hz) laisse typiquement 2-3 ms pour toute la logique gameplay. Une allocation malloc() qui prend 100 µs est déjà 3-5 % de ce budget.

  2. D. E. Knuth, The Art of Computer Programming, Volume 1: Fundamental Algorithms, 3e édition, section 2.5 « Dynamic Storage Allocation ».

  3. J. Bonwick, « The Slab Allocator: An Object-Caching Kernel Memory Allocator », Proceedings of the Summer 1994 USENIX Technical Conference.

  4. Oracle Linux Blog, « Linux SLUB Allocator Internals and Debugging », 2023.

  5. FOSDEM 2026, « Update on the SLUB allocator sheaves », présentation technique. 2

  6. Linux Kernel Documentation, « Memory Allocation Guide », docs.kernel.org/core-api/memory-allocation.html.

  7. sam4k.com, « Linternals: The Slab Allocator ». Analyse des techniques d’exploitation des allocateurs slab du noyau Linux.

  8. Linux Kernel Documentation, « Kernel Electric-Fence (KFENCE) », docs.kernel.org/dev-tools/kfence.html.

  9. Jennifer Chukwu, « Memory Management in Game Engines: What I’ve Learned (So Far) », jenniferchukwu.com.

  10. La liste chaînée intrusive est une technique où le pointeur next est stocké dans la mémoire de l’objet lui-même, et non dans un nœud de liste externe. Lorsque l’objet est « actif » (alloué), ses données écrasent le pointeur next, qui n’est plus nécessaire. Lorsqu’il est « libre », le pointeur next est restauré.

  11. Boost C++ Libraries, boost::lockfree::spsc_queue. File SPSC sans verrou utilisée comme référence pour les systèmes temps réel BCI.

  12. TLSF project page, gii.upv.es/tlsf/. Documents techniques de l’allocateur Two-Level Segregated Fit. 2

  13. Pavel Kirienko (OpenCyphal), o1heap. Allocateur déterministe à complexité constante pour systèmes embarqués. Reddit r/embedded.

  14. H. Sutter, « Eliminate False Sharing », Dr. Dobb’s Journal, 2008.

  15. Wikipedia, « Non-uniform memory access ». Description de l’architecture NUMA et de ses implications pour les serveurs multi-socket.

  16. Evan Jones, « Huge Pages are a Good Idea », evanjones.ca. Analyse de l’impact des Huge Pages sur les performances TLB.

  17. NVIDIA Developer Blog, « Understanding Memory Management on Hardware-Coherent Platforms ».

  18. NVIDIA Developer Blog, « Understanding Memory Management on Hardware-Coherent Platforms ». Présentation du mode CDMM.