Je tourne certaines des parties les plus centrales de ma base de code (un moteur ECS) autour du type de structure de données que vous avez décrit, bien qu'il utilise des blocs contigus plus petits (plus comme 4 kilo-octets au lieu de 4 mégaoctets).

Il utilise une double liste gratuite pour réaliser des insertions et des suppressions à temps constant avec une liste gratuite pour les blocs libres qui sont prêts à être insérés dans (blocs qui ne sont pas pleins) et une liste sous-libre à l'intérieur du bloc pour les indices dans ce bloc prêt à être récupéré lors de l'insertion.
Je couvrirai les avantages et les inconvénients de cette structure. Commençons par quelques inconvénients car il y en a plusieurs:
Les inconvénients
- Il faut environ 4 fois plus de temps pour insérer quelques centaines de millions d'éléments dans cette structure
std::vector
(une structure purement contiguë). Et je suis assez décent pour les micro-optimisations, mais il y a juste plus de travail conceptuellement à faire car le cas commun doit d'abord inspecter le bloc libre en haut de la liste des blocs libres, puis accéder au bloc et faire apparaître un index gratuit à partir du bloc. liste libre, écrivez l'élément à la position libre, puis vérifiez si le bloc est plein et faites-le sortir de la liste des blocs libres si c'est le cas. C'est toujours une opération à temps constant, mais avec une constante beaucoup plus grande que de repousser std::vector
.
- Cela prend environ deux fois plus de temps lors de l'accès aux éléments à l'aide d'un modèle d'accès aléatoire étant donné l'arithmétique supplémentaire pour l'indexation et la couche supplémentaire d'indirection.
- L'accès séquentiel ne correspond pas efficacement à une conception d'itérateur car l'itérateur doit effectuer une ramification supplémentaire chaque fois qu'il est incrémenté.
- Il a un peu de surcharge de mémoire, généralement autour de 1 bit par élément. 1 bit par élément peut ne pas sembler beaucoup, mais si vous l'utilisez pour stocker un million d'entiers 16 bits, cela représente 6,25% de mémoire en plus qu'un tableau parfaitement compact. Cependant, dans la pratique, cela a tendance à utiliser moins de mémoire qu'à
std::vector
moins que vous ne compactiez le vector
pour éliminer la capacité excédentaire qu'il réserve. De plus, je ne l'utilise généralement pas pour stocker de tels éléments minuscules.
Avantages
- L'accès séquentiel à l'aide d'une
for_each
fonction qui prend en charge un traitement de rappel des plages d'éléments dans un bloc rivalise presque avec la vitesse d'accès séquentiel avec std::vector
(seulement comme un diff de 10%), il n'est donc pas beaucoup moins efficace dans les cas d'utilisation les plus critiques pour moi ( la plupart du temps passé dans un moteur ECS est en accès séquentiel).
- Il permet des suppressions à temps constant du milieu avec la structure désallouant les blocs lorsqu'ils deviennent complètement vides. En conséquence, il est généralement assez décent de s'assurer que la structure de données n'utilise jamais beaucoup plus de mémoire que nécessaire.
- Il n'invalide pas les index des éléments qui ne sont pas directement supprimés du conteneur, car il laisse simplement des trous derrière en utilisant une approche de liste libre pour récupérer ces trous lors de l'insertion ultérieure.
- Vous n'avez pas à vous soucier autant de manquer de mémoire même si cette structure contient un nombre épique d'éléments, car elle ne demande que de petits blocs contigus qui ne posent pas de défi au système d'exploitation pour trouver un grand nombre de contigus inutilisés pages.
- Il se prête bien à la concurrence et à la sécurité des threads sans verrouiller toute la structure, car les opérations sont généralement localisées sur des blocs individuels.
Maintenant, l'un des plus grands avantages pour moi était qu'il devient trivial de créer une version immuable de cette structure de données, comme ceci:

Depuis lors, cela a ouvert toutes sortes de portes pour écrire plus de fonctions dépourvues d'effets secondaires, ce qui a rendu beaucoup plus facile la sécurité d'exception, la sécurité des threads, etc. cette structure de données avec le recul et par accident, mais sans doute l'un des plus beaux avantages qu'elle a fini par avoir car elle a facilité la maintenance de la base de code.
Les tableaux non contigus n'ont pas de localité de cache, ce qui entraîne de mauvaises performances. Cependant, à une taille de bloc de 4M, il semble qu'il y aurait suffisamment de localité pour une bonne mise en cache.
La localité de référence n'est pas quelque chose qui vous préoccupe avec des blocs de cette taille, encore moins des blocs de 4 kilo-octets. Une ligne de cache ne fait généralement que 64 octets. Si vous souhaitez réduire les erreurs de cache, concentrez-vous simplement sur l'alignement correct de ces blocs et privilégiez des modèles d'accès séquentiels lorsque cela est possible.
Un moyen très rapide de transformer un modèle de mémoire à accès aléatoire en modèle séquentiel consiste à utiliser un jeu de bits. Disons que vous avez une cargaison d'indices et qu'ils sont dans un ordre aléatoire. Vous pouvez simplement les parcourir et marquer des bits dans le jeu de bits. Ensuite, vous pouvez parcourir votre ensemble de bits et vérifier quels octets sont différents de zéro, en vérifiant, disons, 64 bits à la fois. Une fois que vous rencontrez un ensemble de 64 bits dont au moins un bit est défini, vous pouvez utiliser les instructions FFS pour déterminer rapidement quels bits sont définis. Les bits vous indiquent les indices auxquels vous devez accéder, sauf que vous obtenez maintenant les indices triés dans un ordre séquentiel.
Cela a des frais généraux mais peut être un échange utile dans certains cas, surtout si vous allez parcourir ces indices plusieurs fois.
Accéder à un élément n'est pas aussi simple, il y a un niveau supplémentaire d'indirection. Serait-ce optimisé loin? Cela causerait-il des problèmes de cache?
Non, il ne peut pas être optimisé. L'accès aléatoire, au moins, coûtera toujours plus cher avec cette structure. Souvent, cela n'augmentera pas beaucoup vos erreurs de cache, car vous aurez tendance à obtenir une localité temporelle élevée avec le tableau de pointeurs vers les blocs, surtout si vos chemins d'exécution de cas courants utilisent des modèles d'accès séquentiels.
Puisqu'il y a une croissance linéaire après que la limite de 4M est atteinte, vous pourriez avoir beaucoup plus d'allocations que vous ne le feriez normalement (disons, 250 allocations maximum pour 1 Go de mémoire). Aucune mémoire supplémentaire n'est copiée après 4 Mo, mais je ne sais pas si les allocations supplémentaires sont plus chères que la copie de gros morceaux de mémoire.
Dans la pratique, la copie est souvent plus rapide car il s'agit d'un cas rare, ne se produisant que comme le log(N)/log(2)
total des temps tout en simplifiant simultanément le cas courant bon marché où vous pouvez simplement écrire un élément dans le tableau plusieurs fois avant qu'il ne soit plein et doit être réaffecté à nouveau. Donc, généralement, vous n'obtiendrez pas des insertions plus rapides avec ce type de structure, car le travail de cas commun est plus cher, même s'il n'a pas à faire face à ce cas rare coûteux de réallocation d'énormes tableaux.
Le principal attrait de cette structure pour moi malgré tous les inconvénients est une utilisation réduite de la mémoire, ne pas avoir à se soucier du MOO, être capable de stocker des index et des pointeurs qui ne sont pas invalidés, la concurrence et l'immuabilité. C'est agréable d'avoir une structure de données où vous pouvez insérer et supprimer des choses en temps constant pendant qu'il se nettoie pour vous et n'invalide pas les pointeurs et les index dans la structure.