Últimamente he estado investigando e implementando un Sistema de Entidades para mi marco. Creo que leí la mayoría de los artículos, reddits y preguntas al respecto que pude encontrar, y hasta ahora creo que estoy captando la idea lo suficientemente bien.
Sin embargo, planteó algunas preguntas sobre el comportamiento general de C ++, el lenguaje en el que implemento el sistema de entidades, así como algunos problemas de usabilidad.
Por lo tanto, un enfoque sería almacenar una matriz de componentes en la entidad directamente, lo cual no hice porque arruina la localidad de la caché al recorrer los datos. Debido a esto, decidí tener una matriz por tipo de componente, por lo que todos los componentes del mismo tipo están contiguos en la memoria, lo que debería ser la solución óptima para una iteración rápida.
Pero, cuando debo iterar arreglos de componentes para hacer algo con ellos desde un sistema en una implementación de juego real, noto que casi siempre estoy trabajando con dos o más tipos de componentes a la vez. Por ejemplo, el sistema de procesamiento usa el componente Transformar y el Modelo juntos para hacer una llamada de procesamiento. Mi pregunta es, dado que no estoy iterando linealmente una matriz contigua a la vez en estos casos, ¿estoy sacrificando de inmediato las ganancias de rendimiento de la asignación de componentes de esta manera? ¿Es un problema cuando itero, en C ++, dos matrices contiguas diferentes y uso datos de ambas en cada ciclo?
Otra cosa sobre la que quería preguntar es cómo se deben mantener referencias a componentes o entidades, ya que la naturaleza misma de cómo se colocan los componentes en la memoria, pueden cambiar fácilmente las posiciones en la matriz o la matriz podría reasignarse para expandirse o encogiéndose, dejando mis punteros o manejadores de componentes inválidos. ¿Cómo recomienda manejar estos casos, ya que a menudo me encuentro con ganas de operar con transformaciones y otros componentes en cada cuadro y si mis identificadores o punteros no son válidos, es bastante complicado hacer búsquedas en cada cuadro.
fuente
Respuestas:
Primero, no diría que en este caso está optimizando demasiado pronto, dependiendo de su caso de uso. Sin embargo, en cualquier caso, ha hecho una pregunta interesante y, como tengo experiencia con esto, voy a opinar. Trataré de explicar cómo terminé haciendo las cosas y lo que encontré en el camino.
Cabe señalar que no, no siempre podrá atravesar un grupo de componentes y hacer lo ideal y limpio. Hay, como has dicho, vínculos ineludibles entre componentes, en los que realmente necesitas procesar cosas de una entidad a la vez.
Sin embargo, hay casos (como he encontrado) en los que, de hecho, puede escribir literalmente un bucle for para un tipo de componente en particular y hacer un gran uso de las líneas de caché de la CPU. Para aquellos que desconocen o desean saber más, eche un vistazo a https://en.wikipedia.org/wiki/Locality_of_reference . En la misma nota, cuando sea posible, intente mantener el tamaño de su componente menor o igual que el tamaño de la línea de caché de la CPU. El tamaño de mi línea era de 64 bytes, lo que creo que es común.
En mi caso, valió la pena hacer el esfuerzo de implementar el sistema. Vi ganancias de rendimiento visibles (perfiladas, por supuesto). Tendrás que decidir por ti mismo si es una buena idea. Las mayores ganancias en rendimiento que vi en más de 1000 entidades.
También resolví este problema personalmente. Terminé teniendo un sistema donde:
* Descubrí que tratar de desreferenciar siempre los identificadores de componentes en tiempo de ejecución en ciertas secciones del código de alto uso con la cantidad de entidades con las que estaba tratando era un problema de rendimiento. Debido a eso, ahora mantengo algunos punteros T sin procesar en las partes críticas del rendimiento de mi proyecto, pero de lo contrario uso los identificadores de componentes genéricos, que deberían usarse siempre que sea posible. Los mantengo válidos como se mencionó anteriormente, con el sistema de devolución de llamada. Es posible que no necesite ir tan lejos como eso.
Pero sobre todo, solo prueba las cosas. Hasta que obtenga un escenario del mundo real, todo lo que alguien diga aquí es solo una forma de hacer las cosas, que puede no ser apropiado para usted.
¿Eso ayuda? Trataré de aclarar cualquier cosa que no esté clara. También se agradecen las correcciones.
fuente
Para responder solo esto:
No (al menos no necesariamente). El controlador de caché debería, en la mayoría de los casos, ser capaz de manejar la lectura de más de una matriz contigua de manera eficiente. La parte importante es intentar, cuando sea posible, acceder a cada matriz linealmente.
Para demostrar esto, escribí un pequeño punto de referencia (se aplican las advertencias habituales de referencia).
Comenzando con una estructura vectorial simple:
Encontré que un bucle que suma cada elemento de dos matrices separadas y almacena el resultado en un tercero realizó exactamente lo mismo que una versión donde los datos de origen se entrelazaron en una sola matriz y el resultado se almacenó en un tercero. Sin embargo, descubrí que si intercalaba el resultado con la fuente, el rendimiento sufría (alrededor de un factor de 2).
Si accedí a los datos al azar, el rendimiento se vio afectado por un factor entre 10 y 20.
Tiempos (10,000,000 elementos)
acceso lineal
acceso aleatorio (descomentar random_shuffle)
Fuente (compilada con Visual Studio 2013):
fuente
Respuesta corta: Perfil y luego optimizar.
Respuesta larga:
C ++ no es responsable de las fallas de caché, ya que se aplica a cualquier lenguaje de programación. Esto tiene que ver con cómo funciona la arquitectura moderna de la CPU.
Su problema podría ser un buen ejemplo de lo que podría llamarse optimización pre-madura .
En mi opinión, usted optimizó demasiado pronto para la localidad de caché sin mirar los patrones de acceso a la memoria del programa. Pero la pregunta más importante es si realmente necesita este tipo de optimización (localidad de referencia).
Agner's Fog sugiere que no debe optimizar antes de perfilar su aplicación y / o saber con seguridad dónde están los cuellos de botella. (Todo esto se menciona en su excelente guía. Enlace a continuación)
Desafortunadamente, lo que hizo fue asumir que asignar un tipo de componente por matriz le dará un mejor rendimiento, mientras que en realidad podría haber causado más errores de caché o incluso contención de caché.
Definitivamente deberías mirar su excelente guía de optimización de C ++ .
Personalmente, asignaré la mayoría de los componentes usados juntos en un solo bloque de memoria, para que tengan direcciones "cercanas". Por ejemplo, una matriz se verá así:
[{ID0 Transform Model PhysicsComp }{ID10 Transform Model PhysicsComp }{ID2 Transform Model PhysicsComp }..]
y luego comenzar a optimizar desde allí si el rendimiento no fue "lo suficientemente bueno".fuente
Lo más probable es que obtenga menos pérdidas de caché en general con matrices "verticales" separadas por tipo de componente que intercalar los componentes unidos a una entidad en un bloque de tamaño variable "horizontal", por así decirlo.
La razón es porque, primero, la representación "vertical" tenderá a usar menos memoria. No tiene que preocuparse por la alineación de matrices homogéneas asignadas contiguamente. Con los tipos no homogéneos asignados a un grupo de memoria, debe preocuparse por la alineación, ya que el primer elemento de la matriz podría tener un tamaño y requisitos de alineación totalmente diferentes del segundo. Como resultado, a menudo necesitará agregar relleno, como un simple ejemplo:
Digamos que queremos intercalar
Foo
yBar
almacenarlos uno al lado del otro en la memoria:Ahora, en lugar de tomar 18 bytes para almacenar Foo y Bar en regiones de memoria separadas, se necesitan 24 bytes para fusionarlos. No importa si cambias el orden:
Si toma más memoria en un contexto de acceso secuencial sin mejorar significativamente los patrones de acceso, generalmente incurrirá en más errores de caché. Además de eso, el paso para pasar de una entidad a la siguiente aumenta y a un tamaño variable, lo que hace que tenga que dar saltos de tamaño variable en la memoria para pasar de una entidad a la siguiente solo para ver cuáles tienen los componentes que usted ' Estás interesado en
Por lo tanto, usar una representación "vertical" como lo hace para almacenar tipos de componentes es en realidad más probable que sea óptima que las alternativas "horizontales". Dicho esto, el problema con los errores de caché con la representación vertical se puede ejemplificar aquí:
Donde las flechas simplemente indican que la entidad "posee" un componente. Podemos ver que si tratamos de acceder a todos los componentes de movimiento y renderizado de entidades que tienen ambos, terminaríamos saltando por todos lados en la memoria. Ese tipo de patrón de acceso esporádico puede hacer que cargue datos en una línea de caché para acceder, por ejemplo, a un componente de movimiento, luego acceder a más componentes y desalojar esos datos anteriores, solo para cargar nuevamente la misma región de memoria que ya fue desalojada por otro movimiento componente. Por lo tanto, puede ser un desperdicio cargar exactamente las mismas regiones de memoria más de una vez en una línea de caché solo para recorrer y acceder a una lista de componentes.
Vamos a limpiar ese desastre un poco para que podamos ver más claramente:
Tenga en cuenta que si encuentra este tipo de escenario, generalmente es mucho después de que el juego ha comenzado a ejecutarse, después de que se hayan agregado y eliminado muchos componentes y entidades. En general, cuando comienza el juego, puede agregar todas las entidades y componentes relevantes juntos, en ese momento pueden tener un patrón de acceso secuencial muy ordenado con buena ubicación espacial. Sin embargo, después de muchas extracciones e inserciones, es posible que termines obteniendo algo como el desastre anterior.
Una manera muy fácil de mejorar esa situación es simplemente ordenar por radix sus componentes en función del ID / índice de la entidad que los posee. En ese momento obtienes algo como esto:
Y ese es un patrón de acceso mucho más amigable con la caché. No es perfecto ya que podemos ver que tenemos que omitir algunos componentes de renderizado y movimiento aquí y allá ya que nuestro sistema solo está interesado en entidades que tienen ambos , y algunas entidades solo tienen un componente de movimiento y algunas solo tienen un componente de renderizado. , pero al menos puede procesar algunos componentes contiguos (más en la práctica, por lo general, ya que a menudo adjuntará componentes relevantes de interés, como quizás más entidades en su sistema que tienen un componente de movimiento tendrán un componente de representación que no).
Lo más importante, una vez que los haya ordenado, no cargará datos en una región de memoria en una línea de caché solo para luego volver a cargarlos en un solo bucle.
Y esto no requiere un diseño extremadamente complejo, solo un pase de clasificación de radix de tiempo lineal de vez en cuando, tal vez después de que haya insertado y eliminado un grupo de componentes para un tipo de componente en particular, en ese momento puede marcarlo como necesita ser ordenado Una clasificación de radix implementada razonablemente (incluso puede paralelizarla, lo que hago) puede ordenar un millón de elementos en aproximadamente 6 ms en mi i7 quad-core, como se ejemplifica aquí:
Lo anterior es ordenar un millón de elementos 32 veces (incluido el tiempo de
memcpy
resultados antes y después de la clasificación). Y supongo que la mayoría de las veces en realidad no tendrá más de un millón de componentes para clasificar, por lo que debería poder colarse fácilmente aquí y allá sin causar ningún tartamudeo notable de la velocidad de fotogramas.fuente