En C #, lorsqu'un utilisateur crée un List<byte>
et y ajoute des octets, il y a une chance qu'il manque d'espace et ait besoin d'allouer plus d'espace. Il alloue le double (ou un autre multiplicateur) de la taille du tableau précédent, copie les octets et rejette la référence à l'ancien tableau. Je sais que la liste augmente de façon exponentielle car chaque allocation est coûteuse et cela la limite aux O(log n)
allocations, où le simple fait d'ajouter 10
des éléments supplémentaires à chaque fois entraînerait des O(n)
allocations.
Cependant, pour les grandes tailles de baie, il peut y avoir beaucoup d'espace perdu, peut-être près de la moitié de la baie. Pour réduire la mémoire que j'ai écrit une classe similaire NonContiguousArrayList
qui utilise List<byte>
comme un magasin de support s'il y avait moins de 4 Mo dans la liste, il attribuerait des tableaux d'octets de 4 Mo supplémentaires NonContiguousArrayList
ont augmenté en taille.
Contrairement à List<byte>
ces tableaux qui ne sont pas contigus, il n'y a donc pas de copie de données autour, juste une allocation supplémentaire de 4M. Lorsqu'un élément est recherché, l'index est divisé par 4M pour obtenir l'index du tableau contenant l'élément, puis modulo 4M pour obtenir l'index dans le tableau.
Pouvez-vous signaler des problèmes avec cette approche? Voici ma liste:
- 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.
- 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?
- 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.
TrimExcess
n'aiderait que lorsque la liste est déjà créée, et même alors, elle nécessite toujours suffisamment d'espace pour la copie.Réponses:
Aux échelles que vous avez mentionnées, les préoccupations sont totalement différentes de celles que vous avez mentionnées.
Localité de cache
Modèle d'accès aux éléments de données
YourList[k]
etYourList[k+1]
ont une forte probabilité d'être consécutifs (une chance sur quatre millions de ne pas l'être), ce fait n'aidera pas les performances si vous accédez à votre liste de manière complètement aléatoire, ou à grands pas imprévisibles, par exemplewhile { index += random.Next(1024); DoStuff(YourList[index]); }
Interaction avec le système GC
Frais généraux des calculs de décalage d'adresse
Pour illustrer pourquoi:
La dernière étape prend toujours la part du lion.
Suggestion personnelle
CopyRange
fonction, qui se comporterait comme uneArray.Copy
fonction mais fonctionnerait entre deux instances de votreNonContiguousByteArray
, ou entre une instance et une autre normalebyte[]
. ces fonctions peuvent utiliser le code SIMD (C ++ ou C #) pour maximiser l'utilisation de la bande passante mémoire, puis votre code C # peut fonctionner sur la plage copiée sans surcharge de déréférencement multiple ou de calcul d'adresse.Problèmes d'utilisation et d'interopérabilité
NonContiguousByteArray
avec des bibliothèques C #, C ++ ou en langue étrangère qui attendent des tableaux d'octets contigus ou des tableaux d'octets qui peuvent être épinglés.(3 * 1024 * 1024)
et se terminant par(5 * 1024 * 1024 - 1)
, cela signifie que l'accès s'étendra surchunk[0]
etchunk[1]
. Vous pouvez ensuite construire un tableau (taille 2) de tableaux d'octets (taille 4M), épingler ces adresses de blocs et les transmettre au code sous-jacent.IList<byte>
interface de manière efficace:Insert
etRemove
cela prendra trop de temps à traiter car cela prendra duO(N)
temps.IEnumerable<byte>
, c'est-à-dire qu'il peut être analysé séquentiellement et c'est tout.la source
Il convient de noter que C ++ a déjà une structure équivalente par Standard,
std::deque
. Actuellement, il est recommandé comme choix par défaut pour avoir besoin d'une séquence d'accès aléatoire.La réalité est que la mémoire contiguë est presque complètement inutile une fois que les données dépassent une certaine taille - une ligne de cache ne fait que 64 octets et une taille de page n'est que de 4 à 8 Ko (valeurs typiques actuellement). Une fois que vous commencez à parler de quelques Mo, cela devient vraiment une préoccupation. Il en va de même pour le coût d'allocation. Le prix du traitement de toutes ces données - même juste en les lisant - éclipse de toute façon le prix des allocations.
La seule autre raison de s'en inquiéter est l'interfaçage avec les API C. Mais vous ne pouvez pas obtenir un pointeur vers le tampon d'une liste de toute façon, il n'y a donc aucune préoccupation ici.
la source
deque
avait une implémentation similairestd::deque
est en fait fortement déconseillé, en partie parce que l'implémentation de la bibliothèque standard MS est si mauvaise.Lorsque des blocs de mémoire sont alloués à différents moments, comme dans les sous-matrices de votre structure de données, ils peuvent être situés loin les uns des autres en mémoire. Que ce soit un problème ou non dépend du CPU et est très difficile à prévoir plus longtemps. Vous devez le tester.
C'est une excellente idée, et c'est celle que j'ai utilisée dans le passé. Bien sûr, vous ne devez utiliser que des puissances de deux pour vos tailles de sous-réseau et le décalage de bits pour la division (cela peut se produire dans le cadre de l'optimisation). J'ai trouvé ce type de structure légèrement plus lent, dans la mesure où les compilateurs peuvent optimiser plus facilement une seule indirection de tableau. Vous devez tester, car ces types d'optimisations changent tout le temps.
Le principal avantage est que vous pouvez vous rapprocher de la limite supérieure de mémoire de votre système, tant que vous utilisez ces types de structures de manière cohérente. Tant que vous agrandissez vos structures de données et ne produisez pas de déchets, vous évitez les collectes de déchets supplémentaires qui se produiraient pour une liste ordinaire. Pour une liste géante, cela pourrait faire une énorme différence: la différence entre continuer à courir et manquer de mémoire.
Les allocations supplémentaires sont un problème uniquement si vos morceaux de sous-tableau sont petits, car il y a une surcharge de mémoire dans chaque allocation de tableau.
J'ai créé des structures similaires pour les dictionnaires (tables de hachage). Le dictionnaire fourni par le framework .net a le même problème que List. Les dictionnaires sont plus difficiles dans la mesure où vous devez également éviter de les ressasser.
la source
Avec une taille de bloc de 4M, même un seul bloc n'est pas garanti contigu dans la mémoire physique; il est plus grand qu'une taille de page VM standard. Localité non significative à cette échelle.
Vous devrez vous soucier de la fragmentation du tas: si les allocations se produisent de telle sorte que vos blocs sont en grande partie non contigus dans le tas, alors lorsqu'ils sont récupérés par le GC, vous vous retrouverez avec un tas qui peut être trop fragmenté pour s'adapter à un attribution ultérieure. C'est généralement une situation pire car des échecs se produiront dans des endroits indépendants et forceront éventuellement un redémarrage de l'application.
la source
List
.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
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 repousserstd::vector
.std::vector
moins que vous ne compactiez levector
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
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 avecstd::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).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.
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.
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.
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.
la source