00:00:00 Introduction à la conférence sur le débordement sur disque
00:00:34 Traitement des données des détaillants et limitations de la mémoire
00:02:13 Solution de stockage persistant et comparaison des coûts
00:04:07 Comparaison de la vitesse du disque et de la mémoire
00:05:10 Limitations des techniques de partitionnement et de streaming
00:06:16 Importance des données ordonnées et taille de lecture optimale
00:07:40 Pire scénario pour la lecture de données
00:08:57 Impact de la mémoire de la machine sur l’exécution du programme
00:10:49 Techniques de débordement sur disque et utilisation de la mémoire
00:12:59 Explication de la section de code et mise en œuvre .NET
00:15:06 Contrôle de l’allocation de mémoire et conséquences
00:16:18 Page mappée en mémoire et fichiers de mappage de mémoire
00:18:24 Cartes mémoire en lecture-écriture et outils de performance système
00:20:04 Utilisation de la mémoire virtuelle et des pages mappées en mémoire
00:22:08 Gestion des grands fichiers et des pointeurs 64 bits
00:24:00 Utilisation de span pour charger à partir de la mémoire mappée
00:26:03 Copie de données et utilisation de la structure pour lire des entiers
00:28:06 Création d’un span à partir d’un pointeur et gestionnaire de mémoire
00:30:27 Création d’une instance de gestionnaire de mémoire
00:31:05 Mise en œuvre du programme de débordement sur disque et mappage de la mémoire
00:33:34 Version mappée en mémoire préférable pour la performance
00:35:22 Stratégie de mise en mémoire tampon du flux de fichiers et limitations
00:37:03 Stratégie de mappage d’un grand fichier
00:39:30 Répartition de la mémoire sur plusieurs grands fichiers
00:40:21 Conclusion et invitation à poser des questions

Résumé

Afin de traiter plus de données que ne peut contenir la mémoire, les programmes peuvent déborder une partie de ces données vers un stockage plus lent mais plus grand, comme les disques NVMe. Grâce à une combinaison de deux fonctionnalités .NET plutôt obscures (fichiers mappés en mémoire et gestionnaires de mémoire), cela peut être fait à partir de C# avec peu ou pas de surcharge de performance. Cette conférence donnée lors des Journées IT de Varsovie 2023 plonge dans les détails profonds de son fonctionnement, et discute de la manière dont le open source package NuGet Lokad.ScratchSpace cache la plupart de ces détails aux développeurs.

Résumé étendu

Dans une conférence exhaustive, Victor Nicolet, le CTO de Lokad, se penche sur les subtilités du débordement sur disque en .NET, une technique qui permet le traitement de grands ensembles de données qui dépassent la capacité de mémoire d’un ordinateur typique. Nicolet s’appuie sur sa vaste expérience dans le traitement de jeux de données complexes dans le domaine de l’optimisation de la supply chain quantitative chez Lokad, en fournissant un exemple pratique d’un détaillant avec cent mille produits répartis sur 100 sites. Cela résulte en un ensemble de données de 10 milliards d’entrées lorsqu’on considère des points de données quotidiens sur trois ans, ce qui nécessiterait 37 gigaoctets de mémoire pour stocker une valeur à virgule flottante pour chaque entrée, dépassant largement la capacité d’un ordinateur de bureau typique.

Nicolet suggère l’utilisation d’un stockage persistant, comme le stockage SSD NVMe, comme une alternative rentable à la mémoire. Il compare le coût de la mémoire et du stockage SSD, notant que pour le coût de 18 gigaoctets de mémoire, on pourrait acheter un téraoctet de stockage SSD. Il discute également du compromis en termes de performance, notant que la lecture à partir du disque est six fois plus lente que la lecture à partir de la mémoire.

Il introduit le partitionnement et le streaming comme des techniques pour utiliser l’espace disque comme une alternative à la mémoire. Le partitionnement permet de traiter des ensembles de données en plus petites pièces qui tiennent en mémoire, mais il ne permet pas de communication entre les partitions. Le streaming, en revanche, permet de maintenir un certain état entre le traitement de différentes parties, mais il nécessite que les données sur disque soient ordonnées ou alignées correctement pour une performance optimale.

Nicolet introduit ensuite les techniques de débordement sur disque comme une solution aux limitations de l’approche “fits in memory”. Ces techniques répartissent les données entre la mémoire et le stockage persistant de manière dynamique, en utilisant plus de mémoire lorsque c’est possible pour fonctionner plus rapidement, et en ralentissant pour utiliser moins de mémoire lorsque moins est disponible. Il explique que les techniques de débordement sur disque utilisent autant de mémoire que possible et ne commencent à déverser des données sur le disque que lorsqu’elles manquent de mémoire. Cela les rend meilleures pour réagir à la présence de plus ou moins de mémoire que prévu initialement.

Il explique en outre que les techniques de débordement sur disque divisent l’ensemble de données en deux sections : la section chaude, qui est toujours en mémoire, et la section froide, qui peut déverser des parties de son contenu sur le stockage persistant à tout moment. Le programme utilise des transferts chaud-froid, qui impliquent généralement de grands lots pour maximiser l’utilisation de la bande passante NVMe. La section froide permet à ces algorithmes d’utiliser autant de mémoire que possible.

Nicolet discute ensuite de la manière de mettre cela en œuvre en .NET. Pour la section chaude, des objets .NET normaux sont utilisés, tandis que pour la section froide, une classe de référence est utilisée. Cette classe conserve une référence à la valeur qui est mise en stockage froid et cette valeur peut être mise à null lorsqu’elle n’est plus en mémoire. Un système central dans le programme garde la trace de toutes les références froides, et chaque fois qu’une nouvelle référence froide est créée, il détermine si elle provoque un débordement de la mémoire et invoquera la fonction de débordement d’une ou plusieurs des références froides déjà dans le système pour rester dans le budget de mémoire disponible pour le stockage froid.

Il introduit ensuite le concept de mémoire virtuelle, où le programme n’a pas un accès direct aux pages de mémoire physique, mais a plutôt accès à des pages de mémoire virtuelle. Il est possible de créer une page mappée en mémoire, qui est une façon courante de mettre en œuvre la communication entre les programmes et les fichiers mappés en mémoire. Le but principal du mappage en mémoire est d’éviter que chaque programme ait sa propre copie de la DLL en mémoire car toutes ces copies sont identiques.

Nicolet discute ensuite de l’outil Performance Tool du système, qui montre l’utilisation actuelle de la mémoire physique. En vert se trouve la mémoire qui a été directement attribuée à un processus, en bleu se trouve le cache de pages, et les pages modifiées au milieu sont celles qui devraient être une copie exacte du disque mais contiennent des modifications en mémoire.

Il discute ensuite de la deuxième tentative en utilisant la mémoire virtuelle, où la section froide sera entièrement composée de pages mappées en mémoire. Si le système d’exploitation a soudainement besoin de mémoire, il sait quelles pages sont mappées en mémoire et peuvent être éliminées en toute sécurité.

Nicolet explique ensuite les étapes de base pour créer un fichier mappé en mémoire en .NET, qui sont d’abord de créer un fichier mappé en mémoire à partir d’un fichier sur le disque, puis de créer un accesseur de vue. Les deux sont gardés séparés parce que .NET doit gérer le cas d’un processus 32 bits. Dans le cas d’un processus 64 bits, un accesseur de vue qui charge le fichier entier peut être créé.

Nicolet discute ensuite de l’introduction de la mémoire et de l’étendue il y a cinq ans, qui sont des types utilisés pour représenter une plage de mémoire de manière plus sûre que de simples pointeurs. L’idée générale derrière l’étendue et la mémoire est que, étant donné un pointeur et un nombre d’octets, une nouvelle étendue qui représente cette plage de mémoire peut être créée. Une fois une étendue créée, elle peut être lue en toute sécurité n’importe où dans l’étendue en sachant que si une tentative est faite pour lire au-delà des limites, le runtime l’attrapera et une exception sera lancée au lieu de simplement terminer le processus.

Nicolet discute ensuite de comment utiliser l’étendue pour charger de la mémoire mappée en mémoire gérée par .NET. Par exemple, s’il y a une chaîne qui doit être lue, de nombreuses API centrées autour des étendues peuvent être utilisées. Nicolet explique l’utilisation des API centrées autour des étendues, comme MemoryMarshal.Read, qui peut lire un entier au début de l’étendue. Il mentionne également la fonction Encoding.GetString, qui peut charger à partir d’une étendue d’octets dans une chaîne.

Il explique en outre que ces opérations sont effectuées sur des étendues, qui représentent une section de données qui pourrait être sur le disque au lieu d’être en mémoire. Le système d’exploitation gère le chargement des données en mémoire lorsqu’elles sont d’abord accédées. Nicolet donne un exemple d’une séquence de valeurs à virgule flottante qui doivent être chargées dans un tableau de flottants. Il explique l’utilisation de MemoryMarshal.Read pour lire la taille, l’allocation d’un tableau de valeurs à virgule flottante de cette taille, et l’utilisation de MemoryMarshal.Cast pour transformer l’étendue d’octets en une étendue de valeurs à virgule flottante.

Il discute également de l’utilisation de la fonction CopyTo des étendues, qui effectue une copie haute performance des données du fichier mappé en mémoire dans le tableau. Il note que ce processus peut être un peu gaspilleur car il implique de créer une toute nouvelle copie. Nicolet suggère de créer une structure qui représente l’en-tête avec deux valeurs entières à l’intérieur, qui peuvent être lues par MemoryMarshal. Il discute également de l’utilisation d’une bibliothèque de compression pour décompresser les données.

Nicolet discute de l’utilisation d’un type différent, Memory, pour représenter une plage de données plus longue. Il mentionne le manque de documentation sur comment créer une mémoire à partir d’un pointeur et recommande un gist sur GitHub comme la meilleure ressource disponible. Il explique la nécessité de créer un MemoryManager, qui est utilisé en interne par un Memory chaque fois qu’il a besoin de faire quelque chose de plus complexe que simplement pointer sur une section d’un tableau.

Nicolet discute de l’utilisation de la cartographie de la mémoire par rapport à FileStream, notant que FileStream est le choix évident lors de l’accès à des données qui sont sur le disque et son utilisation est bien documentée. Il note que l’approche FileStream n’est pas sûre pour les threads et nécessite un verrou autour de l’opération, empêchant la lecture à partir de plusieurs emplacements en parallèle. Nicolet mentionne également que l’approche FileStream introduit un certain surcoût qui n’est pas présent avec la version mappée en mémoire.

Il explique que la version mappée en mémoire devrait être utilisée à la place, car elle est capable d’utiliser autant de mémoire que possible et, lorsqu’elle manque de mémoire, renverra des parties des ensembles de données sur le disque. Nicolet soulève la question de combien de fichiers allouer, de quelle taille ils devraient être, et comment faire défiler ces fichiers au fur et à mesure que la mémoire est allouée et désallouée.

Il suggère de diviser la mémoire sur plusieurs gros fichiers, de ne jamais écrire deux fois dans la même mémoire, et de supprimer les fichiers dès que possible. Nicolet conclut en partageant qu’en production chez Lokad, ils utilisent l’espace de travail Lokad avec des paramètres spécifiques : les fichiers ont chacun 16 gigaoctets, il y a 100 fichiers sur chaque disque, et chaque L32VM a quatre disques, représentant un peu plus de 6 téraoctets d’espace de débordement pour chaque VM.

Transcription complète

Slide 1

Victor Nicolet: Bonjour et bienvenue à cette conférence sur le débordement sur disque en .NET.

Le débordement sur disque est une technique pour traiter des ensembles de données qui ne tiennent pas en mémoire en gardant les parties de l’ensemble de données qui ne sont pas utilisées sur un stockage persistant à la place.

Cette conférence est basée sur mon expérience de travail pour Lokad. Nous faisons de l’optimisation de la supply chain quantitative.

La partie quantitative signifie que nous travaillons avec de grands ensembles de données et la partie supply chain, eh bien, elle fait partie du monde réel donc ils sont désordonnés, surprenants, et pleins de cas limites dans les cas limites.

Donc, nous faisons beaucoup de traitements plutôt complexes.

Slide 4

Regardons un exemple typique. Un détaillant aurait de l’ordre de cent mille produits.

Ces produits sont présents dans jusqu’à 100 emplacements. Ce peuvent être des magasins, ils peuvent être des entrepôts, ils peuvent même être des sections d’entrepôts qui sont dédiées au e-commerce.

Et si nous voulons faire une véritable analyse à ce sujet, nous devons regarder le comportement passé, ce qui arrive à ces produits et ces emplacements.

En supposant que nous ne gardons qu’un seul point de données pour chaque jour et que nous ne regardons que trois ans en arrière, cela signifie environ 1000 jours. Multipliez tout cela ensemble et notre ensemble de données aura 10 milliards d’entrées.

Si nous ne gardons qu’une seule valeur flottante pour chaque entrée, l’ensemble de données prend déjà 37 gigaoctets d’empreinte mémoire. C’est plus que ce qu’un ordinateur de bureau typique aurait.

Slide 10

Et une seule valeur flottante n’est pas suffisante pour faire une quelconque analyse.

Un meilleur chiffre serait 20 et même alors nous faisons de très bons efforts pour garder l’empreinte petite. Même alors, nous regardons environ 745 gigaoctets d’utilisation de la mémoire.

Cela tient dans les machines cloud si elles sont assez grandes, environ sept mille dollars par mois. Donc, c’est plutôt abordable mais c’est aussi plutôt gaspilleur.

Slide 11

Comme vous l’avez peut-être deviné à partir du titre de cette conférence, la solution est d’utiliser un stockage persistant à la place, qui est plus lent mais moins cher que la mémoire.

De nos jours, vous pouvez acheter du stockage SSD NVMe pour environ 5 centimes par gigaoctet. Un SSD NVMe est à peu près le type de stockage persistant le plus rapide que vous pouvez obtenir facilement de nos jours.

En comparaison, un gigaoctet de RAM coûte 275 dollars. C’est environ 55 fois plus.

Slide 14

Une autre façon de voir cela est que pour le budget qu’il faut pour acheter 18 gigaoctets de mémoire, vous auriez assez pour payer un téraoctet de stockage SSD.

Slide 15

Qu’en est-il des offres Cloud ? Eh bien, en prenant le cloud Microsoft comme exemple, à gauche se trouve le L32s, faisant partie d’une série de machines virtuelles optimisées pour le stockage.

Pour environ deux mille dollars par mois, vous obtenez près de 8 téraoctets de stockage persistant.

À droite se trouve le M32ms, faisant partie d’une série optimisée pour la mémoire et pour plus de deux fois et demie le coût, vous obtenez seulement 875 gigaoctets de RAM.

Si mon programme tourne sur la machine à gauche et prend deux fois plus de temps pour se terminer, je suis toujours gagnant en termes de coût.

Slide 16

Qu’en est-il des performances ? Eh bien, la lecture à partir de la mémoire fonctionne à environ 21 gigaoctets par seconde. La lecture à partir d’un SSD NVMe fonctionne à environ 3,5 gigaoctets par seconde.

Ce n’est pas un véritable benchmark. J’ai simplement créé une machine virtuelle et exécuté ces deux commandes et il y a de nombreuses façons d’augmenter et de diminuer ces chiffres.

La partie importante ici est simplement l’ordre de grandeur de la différence entre les deux. La lecture à partir du disque est six fois plus lente que la lecture à partir de la mémoire.

Donc, le disque est à la fois décevant en termes de vitesse, vous ne voulez pas lire tout le temps à partir du disque avec des motifs d’accès aléatoires. Mais d’un autre côté, il est aussi étonnamment rapide. Si votre traitement est principalement limité par le CPU, vous pourriez ne même pas remarquer que vous lisez à partir du disque au lieu de lire à partir de la mémoire.

Slide 19

Une technique assez bien connue pour utiliser l’espace disque comme alternative à la mémoire est la partitionnement.

L’idée derrière le partitionnement est de sélectionner l’une des dimensions de l’ensemble de données et de découper l’ensemble de données en plus petits morceaux. Chaque morceau doit être assez petit pour tenir en mémoire.

Le traitement charge ensuite chaque morceau individuellement, effectue son traitement, et sauvegarde ce morceau sur le disque avant de charger le morceau suivant.

Dans notre exemple, si nous devions découper les ensembles de données selon les emplacements et traiter les emplacements un par un, alors chaque emplacement ne prendrait que 7,5 gigaoctets de mémoire. C’est bien dans la portée de ce qu’un ordinateur de bureau peut faire.

Slide 21

Cependant, avec le partitionnement, il n’y a pas de communication entre les partitions. Donc, si nous avons besoin de traiter des données à travers les emplacements, nous ne pouvons plus utiliser cette technique.

Une autre technique est le streaming. Le streaming est assez similaire au partitionnement en ce sens que nous ne chargeons que de petits morceaux de données en mémoire à un moment donné.

Contrairement au partitionnement, nous sommes autorisés à conserver un certain état entre le traitement de différentes parties. Ainsi, lors du traitement du premier emplacement, nous mettrions en place l’état initial, et ensuite lors du traitement du deuxième emplacement, nous sommes autorisés à utiliser ce qui était présent dans l’état à ce moment-là pour créer un nouvel état à la fin du traitement du deuxième emplacement.

Contrairement au partitionnement, le streaming ne se prête pas à l’exécution en parallèle. Mais il résout le problème du calcul de quelque chose sur toutes les données de l’ensemble de données au lieu d’être cloisonné dans chaque pièce séparément.

Le streaming a cependant sa propre limitation. Pour qu’il soit performant, les données sur disque doivent être ordonnées ou alignées correctement.

Slide 26

Pour comprendre ces exigences, vous devez savoir que le NVMe lit et écrit des données en secteurs d’un demi-kilo-octet et que les valeurs de performance précédentes, comme 3,5 gigaoctets par seconde, supposent que les secteurs sont lus et utilisés dans leur intégralité.

Si nous n’utilisons qu’une partie du secteur mais que le secteur entier doit être lu, alors nous gaspillons de la bande passante et divisons notre performance par un grand facteur.

Slide 28

Et donc, il est optimal que les données que nous lisons soient un multiple d’un demi-kilo-octet et soient alignées sur les limites des secteurs.

Nous n’utilisons plus de disques tournants maintenant, donc sauter en avant et ne pas lire le secteur se fait sans coût.

Slide 30

Si il n’est pas possible d’aligner les données sur les limites des secteurs, cependant, une autre façon est de les charger dans l’ordre séquentiel.

C’est parce qu’une fois qu’un secteur a été chargé en mémoire, lire la deuxième partie du secteur ne nécessite pas un autre chargement depuis le disque. Au lieu de cela, le système d’exploitation sera juste capable de vous donner les octets restants qui n’ont pas encore été utilisés.

Et donc, si les données sont chargées consécutivement, il n’y a pas de bande passante gaspillée et vous obtenez toujours la pleine performance.

Slide 31

Le pire cas est lorsque vous ne lisez qu’un ou quelques octets de chaque secteur. Par exemple, si vous lisez une valeur à virgule flottante de chaque secteur, vous divisez votre performance par 128.

Slide 32

Ce qui est pire, c’est qu’il y a une autre unité de regroupement de données au-dessus des secteurs, qui est la page du système d’exploitation, et le système d’exploitation charge généralement des pages entières d’environ 4 kilo-octets dans leur intégralité.

Donc maintenant, si vous lisez une valeur à virgule flottante de chaque page, vous avez divisé votre performance par 1024.

Pour cette raison, il est vraiment important de s’assurer que les lectures de données à partir du stockage persistant sont lues en grands lots consécutifs.

Slide 33

En utilisant ces techniques, il est possible de faire en sorte que le programme s’adapte à une plus petite quantité de mémoire. Maintenant, ces techniques traiteront la mémoire et le disque comme deux espaces de stockage séparés, indépendants l’un de l’autre.

Et donc, la répartition de l’ensemble de données entre la mémoire et le disque est entièrement déterminée par ce qu’est l’algorithme et quelle est la structure de l’ensemble de données.

Donc, si nous exécutons le programme sur une machine qui a exactement la bonne quantité de mémoire, le programme s’adaptera parfaitement et il pourra fonctionner.

Si nous fournissons une machine qui a moins de mémoire que nécessaire, le programme ne pourra pas s’adapter en mémoire et il ne pourra pas fonctionner.

Enfin, si nous fournissons une machine qui a plus de mémoire que nécessaire, le programme fera ce que les programmes font habituellement, il n’utilisera pas la mémoire supplémentaire et il fonctionnera toujours à la même vitesse.

Slide 38

Si nous devions tracer un graphique du temps d’exécution en fonction de la mémoire disponible, il ressemblerait à ceci. En dessous de l’empreinte mémoire, il n’y a pas d’exécution, donc il n’y a pas de temps de traitement. Au-dessus de l’empreinte, le temps de traitement est constant car le programme n’est pas capable d’utiliser la mémoire supplémentaire pour fonctionner plus rapidement.

Slide 39

Et aussi, que se passe-t-il si l’ensemble de données grandit ? Eh bien, selon la dimension, si l’ensemble de données grandit de manière à augmenter le nombre de partitions, alors l’empreinte mémoire restera la même, il y aura juste plus de partitions.

Slide 41

D’autre part, si les partitions individuelles grandissent, alors l’empreinte mémoire grandira également, ce qui augmentera la quantité minimale de mémoire dont le programme a besoin pour fonctionner.

Slide 42

En d’autres termes, si j’ai un ensemble de données plus grand que je dois traiter, non seulement cela prendra plus de temps, mais cela aura également une empreinte plus grande.

Cela crée une situation désagréable où je devrai ajouter plus de mémoire pour pouvoir adapter de grands ensembles de données lorsqu’ils apparaissent, mais ajouter plus de mémoire n’améliore rien pour les petits ensembles de données.

Slide 43

C’est une limitation de l’approche “fits in memory” où la répartition de l’ensemble de données entre la mémoire et le stockage persistant est entièrement déterminée par la structure de l’ensemble de données et l’algorithme lui-même.

Elle ne prend pas en compte la quantité réelle de mémoire disponible. Ce que font les techniques de débordement sur disque, c’est qu’elles font cette répartition de manière dynamique. Donc, s’il y a plus de mémoire disponible, elles utiliseront plus de mémoire pour fonctionner plus rapidement.

Slide 46

Et à l’inverse, s’il y a moins de mémoire disponible, alors jusqu’à un certain point, elles seront capables de ralentir pour utiliser moins de mémoire. Les courbes semblent beaucoup mieux dans ce cas. L’empreinte minimale est plus petite et est la même pour les deux ensembles de données.

Slide 47

Les performances augmentent à mesure que plus de mémoire est ajoutée dans tous les cas. Les techniques de “fit to memory” vont préemptivement décharger certaines données sur le disque afin de réduire l’empreinte mémoire. En revanche, les techniques de débordement sur disque utiliseront autant de mémoire que possible et ce n’est que lorsqu’elles seront à court de mémoire qu’elles commenceront à décharger certaines données sur le disque pour faire de la place.

Cela les rend beaucoup plus réactives face à une quantité de mémoire plus ou moins importante que prévu initialement. Les techniques de débordement sur disque vont diviser l’ensemble de données en deux sections. La section chaude est supposée être toujours en mémoire et il est donc toujours sûr en termes de performance d’y accéder avec des motifs d’accès aléatoires. Elle aura bien sûr un budget maximum, peut-être quelque chose comme 8 gigaoctets par CPU sur une machine Cloud typique.

D’autre part, la section froide est autorisée à tout moment à déverser une partie de son contenu sur le stockage persistant. Il n’y a pas de budget maximum sauf ce qui est disponible. Et bien sûr, il n’est pas possible en toute sécurité en termes de performance de lire à partir de la section froide.

Ainsi, le programme utilisera des transferts chaud-froid. Ceux-ci impliqueront généralement de grands lots afin de maximiser l’utilisation de la bande passante NVMe. Et comme les lots sont assez grands, ils seront également effectués à une fréquence assez faible. Et donc, c’est la section froide qui permet à ces algorithmes d’utiliser autant de mémoire que possible.

Slide 50

Parce que la section froide remplira autant de RAM que disponible et déversera le reste sur le stockage persistant. Alors, comment pouvons-nous faire fonctionner cela en .NET ? Comme j’appelle cela la première tentative, vous pouvez deviner que cela ne fonctionnera pas. Alors, essayez de découvrir à l’avance quel sera le problème.

slide 51

Pour la section chaude, j’utiliserai des objets .NET normaux et le problème que nous examinerons dans un programme .NET normal. Pour la section froide, j’utiliserai ce qu’on appelle une classe de référence. Cette classe conserve une référence à la valeur qui est mise en stockage froid et cette valeur peut être mise à null lorsqu’elle n’est plus en mémoire. Elle a une fonction de déversement qui prend la valeur de la mémoire et l’écrit sur le stockage, puis met la référence à null, ce qui permettra au ramasse-miettes .NET de récupérer cette mémoire lorsqu’il ressent une certaine pression.

Et enfin, elle a une propriété de valeur. Cette propriété, lorsqu’elle est accédée, renverra la valeur de la mémoire si elle est présente et sinon, nous la rechargeons à partir du disque en mémoire avant de la renvoyer. Maintenant, si je mets en place un système central dans mon programme qui garde une trace de toutes les références froides, alors chaque fois qu’une nouvelle référence froide est créée, je peux déterminer si elle provoque un débordement de la mémoire et invoquer la fonction de déversement d’une ou plusieurs des références froides déjà présentes dans le système juste pour rester dans le budget mémoire disponible pour le stockage froid.

Slide 53

Alors, quel va être le problème ? Eh bien, si je regarde le contenu de la mémoire d’une machine qui exécute notre programme, dans le cas idéal, cela ressemblera à ceci. D’abord à gauche se trouve la mémoire du système d’exploitation qu’il utilise pour ses propres besoins. Ensuite, il y a la mémoire interne utilisée par .NET pour des choses comme les assemblages chargés ou le surcoût du ramasse-miettes, etc. Ensuite, il y a la mémoire de la section chaude et enfin, prenant tout le reste, la mémoire allouée à la section froide.

Avec quelques efforts, nous sommes capables de contrôler tout ce qui est à droite car c’est ce que nous allouons et choisissons de libérer pour que le ramasse-miettes puisse le collecter. Cependant, ce qui est à gauche est hors de notre contrôle. Et que se passe-t-il si soudainement le système d’exploitation a besoin de mémoire supplémentaire et découvre que tout est occupé par ce que le processus .NET a créé ?

Slide 56

Eh bien, la réaction typique du noyau Linux dans ce cas sera de tuer le programme qui utilise le plus de mémoire et il n’y a aucun moyen de réagir assez rapidement pour libérer de la mémoire au noyau afin qu’il ne nous tue pas. Alors, quelle est la solution ?

Slide 57

Les systèmes d’exploitation modernes ont le concept de mémoire virtuelle. Le programme n’a pas d’accès direct aux pages de mémoire physique. Au lieu de cela, il a accès à des pages de mémoire virtuelle et il y a une correspondance entre ces pages et les pages réelles en mémoire physique. Si un autre programme est en cours d’exécution sur le même ordinateur, il ne pourra pas accéder de lui-même aux pages du premier programme. Il existe cependant des moyens de partager.

Il est possible de créer une page mappée en mémoire. Dans ce cas, tout ce que le premier programme écrit sur la page partagée sera immédiatement visible par l’autre partie. C’est une façon courante de mettre en œuvre la communication entre les programmes, mais son principal objectif est le mappage de fichiers en mémoire. Ici, le système d’exploitation sait que cette page est une copie exacte d’une page sur un stockage persistant, généralement des parties d’un fichier de bibliothèque partagée.

Le but principal ici est d’éviter que chaque programme ait sa propre copie de la DLL en mémoire car toutes ces copies sont identiques, il n’y a donc aucune raison de gaspiller de la mémoire pour stocker ces copies. Ici, par exemple, nous avons deux programmes totalisant quatre pages de mémoire alors que la mémoire physique n’a de place que pour trois. Maintenant, que se passe-t-il si nous voulons allouer une page supplémentaire dans le premier programme ? Il n’y a pas de place disponible mais le système d’exploitation du noyau sait que la page mappée en mémoire peut être supprimée temporairement et, si nécessaire, elle pourra être rechargée à partir du stockage persistant de manière identique.

Slide 63

Alors, il fera exactement cela. Les deux pages partagées pointeront maintenant vers le disque au lieu de la mémoire. La mémoire est effacée, mise à zéro par le système d’exploitation, puis donnée au premier programme pour être utilisée pour sa troisième page logique. Maintenant, la mémoire est complètement pleine et si l’un ou l’autre des programmes tente d’accéder à la page partagée, il n’y aura pas de place pour qu’elle soit chargée à nouveau en mémoire car les pages qui sont données aux programmes ne peuvent pas être récupérées par le système d’exploitation.

Slide 66

Alors, ce qui se passera ici est une erreur de manque de mémoire. L’un des programmes va mourir, la mémoire sera libérée, et elle sera ensuite réaffectée pour charger le fichier mappé en mémoire à nouveau en mémoire. De plus, bien que la plupart des cartes mémoire soient en lecture seule, il est également possible d’en créer certaines qui sont en lecture-écriture.

Slide 70

Si un programme apporte un changement à la mémoire dans la page mappée, alors le système d’exploitation sauvegardera à un moment donné dans le futur le contenu de cette page sur le disque. Et bien sûr, il est possible de demander que cela se produise à un moment précis en utilisant des fonctions comme flush sur Windows. L’outil Performance System a cette belle fenêtre qui montre l’utilisation actuelle de la mémoire physique.

En vert se trouve la mémoire qui a été directement attribuée à un processus. Elle ne peut pas être récupérée sans tuer le processus. En bleu se trouve le cache de pages. Ce sont des pages qui sont connues pour être des copies identiques d’une page sur le disque et donc chaque fois qu’un processus a besoin de lire à partir du disque une page qui est déjà dans le cache, alors aucune lecture de disque ne se produira et la valeur sera renvoyée directement à partir de la mémoire.

Slide 71

Enfin, les pages modifiées au milieu sont celles qui devraient être une copie exacte du disque mais contiennent des modifications en mémoire. Ces modifications n’ont pas encore été appliquées au disque mais elles le seront dans un délai assez court. Sur Linux, l’outil h-stop affiche un graphique similaire. À gauche se trouvent les pages qui ont été directement attribuées aux processus et ne peuvent pas être récupérées sans les tuer et à droite en jaune se trouve le cache de pages.

Slide 73

Si vous êtes intéressé, il existe une excellente ressource de Vyacheslav Biryukov sur ce qui se passe dans le cache de pages Linux. En utilisant la mémoire virtuelle, faisons notre deuxième tentative. Est-ce que ça va marcher cette fois-ci ? Maintenant, nous décidons que la section froide sera entièrement composée de pages mappées en mémoire. Donc, toutes sont censées être présentes sur le disque en premier.

Le programme n’a plus aucun contrôle sur les pages qui seront en mémoire et celles qui ne seront présentes que sur le disque. Le système d’exploitation fait cela de manière transparente. Donc, si le programme essaie d’accéder, disons, à la troisième page de la section froide, le système d’exploitation détectera qu’elle n’est pas présente en mémoire, déchargera une des pages existantes, disons la deuxième, puis chargera la troisième page en mémoire.

Slide 76

Du point de vue du processus lui-même, c’était complètement transparent. L’attente pour la lecture à partir de la mémoire était juste un peu plus longue que d’habitude. Et que se passe-t-il si le système d’exploitation a soudainement besoin de mémoire pour faire ses propres choses ? Eh bien, il sait quelles pages sont mappées en mémoire et peuvent être jetées en toute sécurité. Donc, il va simplement supprimer une des pages, l’utiliser pour ses propres besoins, puis la libérer quand il a terminé.

Slide 77

Toutes ces techniques s’appliquent à .NET et sont présentes dans le projet open source Lokad Scratch Space. Et la plupart du code qui suit est basé sur la façon dont ce package NuGet fait les choses.

Slide 78

Tout d’abord, comment créerions-nous un fichier mappé en mémoire en .NET ? Le mappage de mémoire existe depuis .NET Framework 4, soit environ 13 ans. Il est assez bien documenté sur internet et le code source est entièrement disponible sur GitHub.

Slide 80

Les étapes de base sont d’abord de créer un fichier mappé en mémoire à partir d’un fichier sur le disque, puis de créer un accesseur de vue. Ces deux types sont gardés séparés car ils ont des significations différentes. Le fichier mappé en mémoire indique simplement au système d’exploitation que certaines sections de ce fichier seront mappées à la mémoire du processus. L’accesseur de vue représente ces mappages.

Les deux sont gardés séparés car .NET doit gérer le cas d’un processus 32 bits. Un très grand fichier, un qui est plus grand que quatre gigaoctets, ne peut pas être mappé à l’espace mémoire d’un processus 32 bits. Il est trop grand. Maintenant, le point n’est pas assez grand pour le représenter. Il est donc possible de ne mapper que de petites sections du fichier une à la fois de manière à ce qu’elles s’adaptent.

Dans notre cas, nous travaillerons avec des pointeurs 64 bits. Nous pouvons donc simplement créer un accesseur de vue qui charge le fichier entier. Et maintenant, j’utilise AcquirePointer pour obtenir le pointeur vers les premiers octets de cette plage de mémoire mappée. Lorsque j’ai fini de travailler avec le pointeur, je peux simplement le libérer. Travailler avec des pointeurs en .NET est dangereux. Il nécessite l’ajout du mot-clé unsafe partout et cela peut exploser si vous essayez d’accéder à la mémoire au-delà des limites de la plage autorisée.

Slide 81

Heureusement, il existe un moyen de contourner cela. Il y a cinq ans, .NET a introduit memory et span. Ce sont des types utilisés pour représenter une plage de mémoire de manière plus sûre que les simples pointeurs. C’est assez bien documenté et la plupart du code peut être trouvé à cet endroit sur GitHub.

Slide 83

L’idée générale derrière span et memory est que, étant donné un pointeur et un nombre d’octets, vous pouvez créer un nouveau span qui représente cette plage de mémoire.

Une fois que vous avez ce span, vous pouvez lire en toute sécurité n’importe où dans le span en sachant que si vous essayez de lire au-delà des limites, le runtime l’attrapera pour vous et vous obtiendrez une exception au lieu de simplement terminer le processus.

Slide 84

Voyons comment nous pouvons utiliser span pour charger de la mémoire mappée en mémoire gérée par .NET. N’oubliez pas, nous ne voulons pas accéder directement à la section froide pour des raisons de performance. Au lieu de cela, nous voulons faire des transferts de froid à chaud qui chargent beaucoup de données en même temps.

Par exemple, disons que nous avons une chaîne que nous voulons lire. Elle sera disposée dans le fichier mappé en mémoire sous la forme d’une taille suivie d’une charge utile de bytes encodée en UTF-8, et nous voulons charger une chaîne .NET à partir de cela.

Slide 86

Eh bien, il existe de nombreuses API centrées autour des spans que nous pouvons utiliser. Par exemple, MemoryMarshal.Read peut lire un entier au début du span. Ensuite, en utilisant cette taille, je peux demander à la fonction Encoding.GetString de charger à partir d’un span de bytes dans une chaîne.

Tous ces éléments fonctionnent sur des spans et même si le span représente une section de données qui est peut-être présente sur le disque au lieu d’être en mémoire, le système d’exploitation se charge de charger transparentement les données en mémoire lorsqu’elles sont d’abord accédées.

Slide 87

Un autre exemple serait une séquence de valeurs flottantes que nous voulons charger dans un tableau de flottants.

Slide 88

Encore une fois, nous utilisons MemoryMarshal.Read pour lire la taille. Nous allouons un tableau de valeurs flottantes de cette taille et ensuite nous utilisons MemoryMarshal.Cast pour transformer le span de bytes en un span de valeurs flottantes. Cela réinterprète simplement les données présentes dans le span en tant que valeurs flottantes au lieu de simples bytes.

Enfin, nous utilisons la fonction CopyTo des spans qui effectuera une copie haute performance des données du fichier mappé en mémoire dans le tableau lui-même. C’est en quelque sorte un peu gaspilleur, nous faisons une toute nouvelle copie.

Slide 89

Peut-être pourrions-nous éviter cela. Eh bien, généralement ce que nous stockerons sur le disque ne seront pas les valeurs flottantes brutes. Au lieu de cela, nous sauvegarderons une version compressée de celles-ci. Ici, nous stockons la taille compressée, qui nous indique combien de bytes nous devons lire. Nous stockons la taille de destination ou la taille décompressée. Cela nous indique combien de valeurs flottantes nous devons allouer en mémoire gérée. Et enfin, nous stockons la charge utile compressée elle-même.

Slide 90

Pour charger cela, il serait préférable que, au lieu de lire deux entiers, nous créions une structure qui représente cet en-tête avec deux valeurs entières à l’intérieur.

Slide 91

MemoryMarshal sera capable de lire une instance de cette structure, chargeant les deux champs en même temps. Nous allouons un tableau de valeurs flottantes et ensuite notre bibliothèque de compression a presque certainement une variante d’une fonction de décompression qui prend un span de bytes en lecture seule en entrée et un span de bytes en sortie. Nous pouvons à nouveau utiliser MemoryMarshal.Cast, cette fois en transformant le tableau de valeurs flottantes en un span de bytes à utiliser comme destination.

Maintenant, aucune copie n’est impliquée. Au lieu de cela, l’algorithme de compression lit directement à partir du disque, généralement via le cache de pages, dans le tableau de destination de flottants.

Slide 92

Span a une limitation majeure, c’est qu’il ne peut pas être utilisé comme membre de classe et par extension, il ne peut pas non plus être utilisé comme variable locale dans une méthode asynchrone.

Heureusement, il existe un type différent, Memory, qui devrait être utilisé pour représenter une plage de données plus longue.

Slide 94

Malheureusement, il y a décevant peu de documentation sur comment faire cela. Créer un span à partir d’un pointeur est facile, créer une mémoire à partir d’un pointeur n’est pas documenté au point que la meilleure documentation disponible est un gist sur GitHub, que je vous recommande vraiment de lire.

Slide 95

En bref, ce que nous devons faire est de créer un MemoryManager. Le MemoryManager est utilisé en interne par un Memory chaque fois qu’il a besoin de faire quelque chose de plus complexe que de simplement pointer vers une section d’un tableau.

Dans notre cas, nous devons faire référence à l’accessoire de vue mappé en mémoire dans lequel nous regardons. Nous devons connaître la longueur que nous sommes autorisés à regarder et enfin, nous aurons besoin d’un décalage. C’est parce qu’une Memory de bytes ne peut représenter plus de deux gigaoctets par conception, et le fichier lui-même sera probablement plus long que deux gigaoctets. Ainsi, le décalage nous donne l’emplacement où la mémoire commence dans l’accessoire de vue plus large.

Slide 97

Le constructeur de la classe est assez simple.

Slide 98

Nous devons juste ajouter une référence à la poignée sûre qui représente la région de mémoire et cette référence sera libérée dans la fonction de disposition.

Slide 99

Ensuite, nous avons une propriété d’adresse qui n’est pas une autre balade, c’est juste quelque chose qui est utile pour nous d’avoir. Nous utilisons DangerousGetHandle pour obtenir un pointeur et nous ajoutons le décalage de sorte que l’adresse pointe vers les premiers bytes dans la région que nous voulons que notre mémoire représente.

Slide 100

Nous surchargeons la fonction GetSpan qui fait toute la magie. Elle crée simplement un span en utilisant l’adresse et la longueur.

Slide 101

Il y a deux autres méthodes qui doivent être implémentées sur le MemoryManager. L’une d’elles est Pin. Elle est utilisée par le runtime dans un cas où la mémoire doit être maintenue au même endroit pour une courte durée. Nous ajoutons une référence et nous renvoyons un MemoryHandle qui pointe vers l’emplacement correct et qui référence également l’objet actuel comme le pinnable.

Slide 102

Cela permettra au runtime de savoir que lorsque la mémoire sera dépinglée, alors il appellera la méthode Unpin de cet objet, ce qui provoque la libération de la poignée sûre à nouveau.

Slide 103

Une fois cette classe créée, il suffit de créer une instance de celle-ci et d’accéder à sa propriété Memory qui renverra une Memory de bytes qui référence en interne le MemoryManager que nous venons de créer. Et voilà, maintenant vous avez un morceau de mémoire. Lorsque vous écrivez dessus, il sera automatiquement déchargé sur le disque lorsque l’espace est nécessaire et lorsqu’il est accédé, il sera chargé de manière transparente à partir du disque chaque fois que vous en avez besoin.

Slide 104

C’est donc suffisant pour mettre en œuvre notre programme de débordement sur disque. Il y a une autre question, pourquoi utiliser la cartographie mémoire lorsque nous pourrions utiliser FileStream à la place ? Après tout, FileStream est le choix évident lors de l’accès à des données qui sont sur le disque et son utilisation est assez bien documentée. Pour lire un tableau de valeurs en virgule flottante, par exemple, vous avez besoin d’un FileStream et d’un BinaryReader enveloppé autour du FileStream. Vous définissez la position à l’endroit où les données sont présentes, vous lisez un Int32 avec le lecteur, allouez le tableau de points flottants et ensuite MemoryMarshal.Cast à une étendue de bytes.

Slide 106

FileStream.Read a maintenant une surcharge qui prend une étendue de bytes comme destination. Cela utilise en fait également le cache de pages. Au lieu de mapper ces pages dans l’espace d’adressage de votre processus, le système d’exploitation les garde simplement et pour lire les valeurs, il chargera simplement du disque en mémoire puis copiera de cette page dans l’étendue de destination que vous avez fournie. Donc, cela est équivalent en termes de performance et de comportement à ce qui s’est passé dans la version mappée en mémoire.

Il y a cependant deux différences majeures. Premièrement, ce n’est pas sûr pour les threads. Vous définissez la position dans une ligne et ensuite dans une autre instruction, vous comptez sur le fait que cette position est toujours la même. Cela signifie que vous avez besoin d’un verrou autour de cette opération et donc vous ne pouvez pas lire à plusieurs endroits en parallèle, même si cela est possible avec les fichiers mappés en mémoire.

Un autre problème est que, selon la stratégie utilisée par le FileStream, vous faites deux lectures, une pour l’Int32 et une pour la lecture à l’étendue. Une possibilité est que chacun d’eux fera un appel système. Il appellera le système d’exploitation et le système d’exploitation copiera certaines données de sa propre mémoire dans la mémoire du processus. Cela a un certain surcoût. L’autre possibilité est que le flux est tamponné. Dans ce cas, la lecture de quatre bytes initialement créera une copie d’une page, probablement. Et cette copie se produit en plus de la copie réelle qui est faite par la fonction de lecture plus tard. Donc, cela introduit un certain surcoût qui n’est tout simplement pas présent avec la version mappée en mémoire.

Pour cette raison, l’utilisation de la version mappée en mémoire est préférable en termes de performance. Après tout, le FileStream est le choix évident pour accéder aux données présentes sur le disque et son utilisation est très bien documentée. Par exemple, pour lire un tableau de valeurs à virgule flottante, vous avez besoin d’un FileStream, d’un BinaryReader. Vous définissez la position du FileStream à l’endroit où les données sont présentes dans le fichier, vous lisez un Int32 pour obtenir la taille, allouez le tableau à virgule flottante, le transformez en une étendue de bytes en utilisant MemoryMarshal.Cast et le passez à la surcharge de FileStream.Read qui veut une étendue de bytes comme destination pour la lecture. Et cela utilise également le cache de pages. Au lieu que les pages soient associées au processus, elles sont conservées par le système d’exploitation lui-même et il se contentera de charger depuis le disque dans le cache de pages et de copier depuis le cache de pages dans la mémoire du processus, tout comme nous l’avons fait avec la version mappée en mémoire.

L’approche FileStream a cependant deux inconvénients majeurs. Le premier est que ce code n’est pas sûr pour une utilisation multi-thread. Après tout, la position est définie dans une instruction et ensuite utilisée dans les instructions suivantes. Nous avons donc besoin d’un verrou autour de ces opérations de lecture. La version mappée en mémoire n’a pas besoin de verrous et est en fait capable de charger à partir de plusieurs endroits sur le disque en parallèle. Pour les SSD, cela augmente la profondeur de la file d’attente, ce qui augmente les performances et est donc généralement souhaitable. L’autre problème est que le FileStream doit faire deux lectures.

Selon la stratégie utilisée en interne par le flux, cela peut entraîner deux appels système qui doivent réveiller le système d’exploitation. Il copiera certaines données de sa propre mémoire dans la mémoire du processus, puis il devra tout effacer et rendre le contrôle au processus. Cela a un certain surcoût. L’autre stratégie possible est que le FileStream soit tamponné. Dans ce cas, un seul appel système serait effectué, mais il impliquerait une copie de la mémoire du système d’exploitation vers le tampon interne du FileStream, puis l’instruction de lecture devra copier à nouveau du tampon interne du FileStream vers le tableau à virgule flottante. Cela crée donc une copie inutile qui n’est pas présente avec la version mappée en mémoire.

Le flux de fichiers, bien qu’un peu plus facile à utiliser, a certaines limitations. La version mappée en mémoire devrait être utilisée à la place. Nous avons donc fini par avoir un système capable d’utiliser autant de mémoire que possible et, lorsqu’il manque de mémoire, renvoie une partie des ensembles de données sur le disque. Ce processus est complètement transparent et coopère avec le système d’exploitation. Il fonctionne à la performance maximale car les pièces de l’ensemble de données qui sont fréquemment accédées sont toujours conservées en mémoire.

Cependant, il reste une dernière question à laquelle nous devons répondre. Après tout, lorsque vous mappez des choses en mémoire, vous ne mappez pas le disque, vous mappez des fichiers sur le disque. Maintenant, la question est, combien de fichiers allons-nous allouer ? Quelle sera leur taille ? Et comment allons-nous faire défiler ces fichiers lorsque nous allouons et désallouons de la mémoire ?

Le choix évident est de simplement mapper un grand fichier, de le faire au démarrage du programme, et de continuer à le faire fonctionner. Lorsqu’une partie n’est plus utilisée, il suffit de l’écrire par-dessus. C’est évident et donc c’est faux.

Slide 111

Le premier problème avec cette approche est que l’écriture sur une page de mémoire nécessite un algorithme discret.

L’algorithme est le suivant : d’abord, vous chargez la page en mémoire immédiatement. Ensuite, vous changez le contenu de la page en mémoire. Le système d’exploitation n’a aucun moyen de savoir qu’à l’étape deux, vous allez tout effacer et le remplacer, donc il doit toujours charger la page pour que les pièces que vous ne changez pas restent les mêmes. Enfin, vous programmez la page pour qu’elle soit réécrite sur le disque à un moment donné dans le futur.

Maintenant, la première fois que vous écrivez sur une page donnée dans un nouveau fichier, il n’y a pas de données à charger. Le système d’exploitation sait que toutes les pages sont à zéro, donc le chargement est gratuit. Il prend simplement une page zéro et l’utilise. Mais lorsque la page a déjà été modifiée et n’est plus en mémoire, le système d’exploitation doit la recharger à partir du disque.

Slide 113

Un deuxième problème est que les pages du cache de pages sont évincées sur la base du moins récemment utilisé, et le système d’exploitation n’est pas conscient qu’une section morte de votre mémoire, qui ne sera plus jamais utilisée, doit être supprimée. Il pourrait donc finir par garder en mémoire certaines parties de l’ensemble de données qui ne sont pas nécessaires et évincer certaines parties qui le sont. Il n’y a aucun moyen de dire au système d’exploitation qu’il devrait simplement ignorer les sections mortes.

Slide 114

Un troisième problème est également lié, c’est que l’écriture des données sur le disque est toujours en retard par rapport à l’écriture des données en mémoire. Et si vous savez qu’une page n’est plus nécessaire et qu’elle n’a pas encore été écrite sur le disque, eh bien, le système d’exploitation ne le sait pas. Il passe donc encore du temps à écrire ces octets qui ne seront plus jamais utilisés sur le disque, ralentissant tout.

Slide 115

Au lieu de cela, nous devrions diviser la mémoire sur plusieurs grands fichiers. Nous n’écrivons jamais deux fois sur la même mémoire. Cela garantit que chaque écriture touche une page qui est connue du système d’exploitation pour être entièrement à zéro et n’implique pas de chargement à partir du disque. Et nous supprimons les fichiers dès que possible. Cela indique au système d’exploitation que cela n’est plus nécessaire, il peut être supprimé du cache de pages, il n’a pas besoin d’être écrit sur le disque s’il ne l’a pas déjà été.

Slide 116

En production chez Lokad, sur une VM de production typique, nous utilisons l’espace de travail temporaire de Lokad avec les paramètres suivants : les fichiers ont chacun 16 gigaoctets, il y a 100 fichiers sur chaque disque, et chaque VM a quatre disques. Au total, cela représente un peu plus de 6 téraoctets d’espace de débordement pour chaque VM.

Slide 117

C’est tout pour aujourd’hui. N’hésitez pas à nous contacter si vous avez des questions ou des commentaires, et merci de nous avoir regardé.