Giro algunas de las partes más centrales de mi base de código (un motor ECS) en torno al tipo de estructura de datos que describiste, aunque usa bloques contiguos más pequeños (más como 4 kilobytes en lugar de 4 megabytes).

Utiliza una lista doble gratuita para lograr inserciones y eliminaciones de tiempo constante con una lista libre para bloques libres que están listos para insertarse (bloques que no están completos) y una lista sublibre dentro del bloque para índices en ese bloque Listo para ser recuperado tras la inserción.
Cubriré los pros y los contras de esta estructura. Comencemos con algunos inconvenientes porque hay varios de ellos:
Contras
- Se tarda aproximadamente 4 veces más en insertar un par de cientos de millones de elementos en esta estructura que
std::vector(una estructura puramente contigua). Y soy bastante decente con las micro optimizaciones, pero conceptualmente solo hay más trabajo por hacer, ya que el caso común tiene que inspeccionar primero el bloque libre en la parte superior de la lista libre de bloques, luego acceder al bloque y extraer un índice libre de los bloques lista libre, escriba el elemento en la posición libre, y luego verifique si el bloque está lleno y saque el bloque de la lista libre de bloques si es así. Sigue siendo una operación de tiempo constante pero con una constante mucho mayor que presionar de nuevo std::vector.
- Se tarda aproximadamente el doble en acceder a elementos utilizando un patrón de acceso aleatorio, dada la aritmética adicional para la indexación y la capa adicional de indirección.
- El acceso secuencial no se asigna de manera eficiente a un diseño de iterador ya que el iterador tiene que realizar ramificaciones adicionales cada vez que se incrementa.
- Tiene un poco de sobrecarga de memoria, generalmente alrededor de 1 bit por elemento. 1 bit por elemento puede no parecer mucho, pero si está usando esto para almacenar un millón de enteros de 16 bits, entonces eso es 6.25% más de uso de memoria que una matriz perfectamente compacta. Sin embargo, en la práctica, esto tiende a usar menos memoria que a
std::vectormenos que esté compactando vectorpara eliminar el exceso de capacidad que reserva. Además, generalmente no lo uso para almacenar elementos tan pequeños.
Pros
- El acceso secuencial usando una
for_eachfunción que toma un rango de procesamiento de devolución de llamada de elementos dentro de un bloque casi rivaliza con la velocidad de acceso secuencial std::vector(solo como una diferencia del 10%), por lo que para mí no es mucho menos eficiente en los casos de uso más críticos para el rendimiento ( la mayor parte del tiempo pasado en un motor ECS es en acceso secuencial).
- Permite eliminaciones de tiempo constante desde el medio con la estructura desasignando bloques cuando se vuelven completamente vacíos. Como resultado, generalmente es bastante decente para asegurarse de que la estructura de datos nunca use significativamente más memoria de la necesaria.
- No invalida los índices de elementos que no se eliminan directamente del contenedor, ya que solo deja agujeros utilizando un enfoque de lista libre para recuperar esos agujeros en la inserción posterior.
- No tiene que preocuparse tanto por quedarse sin memoria, incluso si esta estructura contiene un número épico de elementos, ya que solo solicita pequeños bloques contiguos que no representan un desafío para el sistema operativo para encontrar una gran cantidad de elementos contiguos sin usar páginas
- Se presta bien a la concurrencia y seguridad de subprocesos sin bloquear toda la estructura, ya que las operaciones generalmente se localizan en bloques individuales.
Ahora, uno de los mayores pros para mí fue que se volvió trivial hacer una versión inmutable de esta estructura de datos, como esta:

Desde entonces, eso abrió todo tipo de puertas para escribir más funciones desprovistas de efectos secundarios que hicieron que fuera mucho más fácil lograr la seguridad de excepciones, seguridad de hilos, etc. La inmutabilidad fue algo que descubrí que podía lograr fácilmente con esta estructura de datos en retrospectiva y por accidente, pero podría decirse que es uno de los mejores beneficios que obtuvo, ya que hizo que el mantenimiento de la base de código fuera mucho más fácil.
Las matrices no contiguas no tienen localidad de caché, lo que resulta en un mal rendimiento. Sin embargo, con un tamaño de bloque de 4M, parece que habría suficiente localidad para un buen almacenamiento en caché.
La localidad de referencia no es algo de lo que deba preocuparse en bloques de ese tamaño, y mucho menos bloques de 4 kilobytes. Una línea de caché suele tener solo 64 bytes. Si desea reducir las pérdidas de caché, solo concéntrese en alinear esos bloques correctamente y favorezca más patrones de acceso secuencial cuando sea posible.
Una forma muy rápida de convertir un patrón de memoria de acceso aleatorio en uno secuencial es usar un conjunto de bits. Digamos que tiene una gran cantidad de índices y están en orden aleatorio. Simplemente puede abrirlos y marcar bits en el conjunto de bits. Luego puede iterar a través de su conjunto de bits y verificar qué bytes no son cero, verificando, digamos, 64 bits a la vez. Una vez que encuentre un conjunto de 64 bits, de los cuales al menos un bit está configurado, puede usar las instrucciones de FFS para determinar rápidamente qué bits están configurados. Los bits le dicen a qué índices debe acceder, excepto que ahora obtiene los índices ordenados en orden secuencial.
Esto tiene algunos gastos generales, pero puede ser un intercambio que valga la pena en algunos casos, especialmente si vas a recorrer estos índices muchas veces.
Acceder a un elemento no es tan simple, hay un nivel adicional de indirección. ¿Se optimizaría esto? ¿Causaría problemas de caché?
No, no se puede optimizar. El acceso aleatorio, al menos, siempre costará más con esta estructura. Sin embargo, a menudo no aumentará demasiado la pérdida de caché, ya que tenderá a obtener una alta localidad temporal con la matriz de punteros a bloques, especialmente si sus rutas de ejecución de casos comunes usan patrones de acceso secuenciales.
Dado que hay un crecimiento lineal después de alcanzar el límite de 4M, podría tener muchas más asignaciones de las que tendría normalmente (por ejemplo, un máximo de 250 asignaciones por 1 GB de memoria). No se copia memoria adicional después de 4M, sin embargo, no estoy seguro de si las asignaciones adicionales son más caras que copiar grandes porciones de memoria.
En la práctica, la copia es a menudo más rápida porque es un caso raro, solo ocurre algo así como el log(N)/log(2)tiempo total, mientras que al mismo tiempo simplifica el caso común muy barato donde simplemente puede escribir un elemento en la matriz muchas veces antes de que se llene y deba reasignarse nuevamente. Por lo general, no obtendrá inserciones más rápidas con este tipo de estructura porque el trabajo de caso común es más costoso incluso si no tiene que lidiar con ese costoso caso raro de reasignación de matrices enormes.
El principal atractivo de esta estructura para mí, a pesar de todos los inconvenientes, es el uso reducido de la memoria, no tener que preocuparme por OOM, poder almacenar índices y punteros que no se invalidan, la concurrencia y la inmutabilidad. Es bueno tener una estructura de datos donde pueda insertar y eliminar cosas en tiempo constante mientras se limpia por sí mismo y no invalida punteros e índices en la estructura.