Errores de caché y usabilidad en Entity Systems

18

Ú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.

Grimshaw
fuente
44
No me molestaría en colocar los componentes en una memoria continua, sino simplemente asignar memoria para cada componente dinámicamente. La memoria contigua es poco probable que le brinde ganancias de rendimiento de caché porque es probable que tenga acceso a los componentes en un orden bastante aleatorio de todos modos.
JarkkoL
@Grimshaw Aquí hay un artículo interesante para leer: harm.cat-v.org/software/OO_programming/_pdf/…
Raxvan
@JarkkoL -10 puntos. Realmente perjudica el rendimiento si construye un caché del sistema amigable y accede a él de manera aleatoria , es estúpido solo por su sonido. El punto para acceder a él de manera lineal . El arte del ECS y la ganancia de rendimiento se trata de escribir C / S accedido de forma lineal.
wondra
@Grimshaw no olvides que el caché es más grande que un entero. Tienes varios KB de caché L1 disponibles (y MB de otro), si no haces nada monstruoso, debería estar bien acceder a pocos sistemas a la vez y al mismo tiempo ser amigable con el caché.
wondra
2
@wondra ¿Cómo garantizaría el acceso lineal a los componentes? Digamos si reúno componentes para renderizar y quiero entidades procesadas en orden descendente desde la cámara. No se podrá acceder linealmente a los componentes de representación para estas entidades en la memoria. Si bien lo que dices es bueno en teoría, no lo veo funcionando en la práctica, pero me alegra que me demuestres que estoy equivocado (:
JarkkoL

Respuestas:

13

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.

  • Cada entidad tiene un vector de identificadores de componentes genéricos que pueden representar cualquier tipo.
  • Cada identificador de componente se puede desreferenciar para producir un puntero T * sin procesar. *Vea abajo.
  • Cada tipo de componente tiene su propio grupo, un bloque continuo de memoria (tamaño fijo en mi caso).

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.

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.

También resolví este problema personalmente. Terminé teniendo un sistema donde:

  • Cada identificador de componente contiene una referencia a un índice de grupo
  • Cuando un componente se 'elimina' o 'elimina' de un grupo, el último componente dentro de ese grupo se mueve (literalmente con std :: move) a la ubicación ahora libre, o ninguno si acaba de eliminar el último componente.
  • Cuando ocurre un 'intercambio', tengo una devolución de llamada que notifica a los oyentes, para que puedan actualizar cualquier puntero concreto (por ejemplo, T *).

* 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.

parar
fuente
Votado, esta fue una muy buena respuesta, y aunque podría no ser una bala de plata, todavía es bueno ver que alguien tiene ideas de diseño similares. También tengo algunos de tus trucos implementados en mi ES, y parecen prácticos. ¡Muchas gracias! Siéntase libre de comentar más ideas si surgen.
Grimshaw
5

Para responder solo esto:

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?

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:

struct float3 { float x, y, z; };

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

  • matrices separadas 0.21s
  • fuente intercalada 0.21s
  • fuente intercalada y resultado 0.48s

acceso aleatorio (descomentar random_shuffle)

  • matrices separadas 2.42s
  • fuente intercalada 4.43s
  • fuente intercalada y resultado 4.00s

Fuente (compilada con Visual Studio 2013):

#include <Windows.h>
#include <vector>
#include <algorithm>
#include <iostream>

struct float3 { float x, y, z; };

float3 operator+( float3 const &a, float3 const &b )
{
    return float3{ a.x + b.x, a.y + b.y, a.z + b.z };
}

struct Both { float3 a, b; };

struct All { float3 a, b, res; };


// A version without any indirection
void sum( float3 *a, float3 *b, float3 *res, int n )
{
    for( int i = 0; i < n; ++i )
        *res++ = *a++ + *b++;
}

void sum( float3 *a, float3 *b, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = a[*index] + b[*index];
}

void sum( Both *both, float3 *res, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        res[*index] = both[*index].a + both[*index].b;
}

void sum( All *all, int *index, int n )
{
    for( int i = 0; i < n; ++i, ++index )
        all[*index].res = all[*index].a + all[*index].b;
}

class PerformanceTimer
{
public:
    PerformanceTimer() { QueryPerformanceCounter( &start ); }
    double time()
    {
        LARGE_INTEGER now, freq;
        QueryPerformanceCounter( &now );
        QueryPerformanceFrequency( &freq );
        return double( now.QuadPart - start.QuadPart ) / double( freq.QuadPart );
    }
private:
    LARGE_INTEGER start;
};

int main( int argc, char* argv[] )
{
    const int count = 10000000;

    std::vector< float3 > a( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > b( count, float3{ 1.f, 2.f, 3.f } );
    std::vector< float3 > res( count );

    std::vector< All > all( count, All{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );
    std::vector< Both > both( count, Both{ { 1.f, 2.f, 3.f }, { 1.f, 2.f, 3.f } } );

    std::vector< int > index( count );
    int n = 0;
    std::generate( index.begin(), index.end(), [&]{ return n++; } );
    //std::random_shuffle( index.begin(), index.end() );

    PerformanceTimer timer;
    // uncomment version to test
    //sum( &a[0], &b[0], &res[0], &index[0], count );
    //sum( &both[0], &res[0], &index[0], count );
    //sum( &all[0], &index[0], count );
    std::cout << timer.time();
    return 0;
}
GuyRT
fuente
1
Esto ayuda mucho con mis dudas sobre la localidad de caché, ¡gracias!
Grimshaw
Respuesta simple pero interesante que también encuentro tranquilizadora :) Me interesaría ver cómo estos resultados varían para diferentes recuentos de elementos (es decir, ¿1000 en lugar de 10,000,000?) O si tuviera más matrices de valores (es decir, sumando elementos de 3 -5 matrices separadas y almacenar el valor en otra matriz separada).
Awesomania
2

Respuesta corta: Perfil y luego optimizar.

Respuesta larga:

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.

¿Es un problema cuando itero, en C ++, dos matrices contiguas diferentes y uso datos de ambas en cada ciclo?

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)

Es útil saber cómo se organiza un caché si está creando programas que tienen estructuras de grandes datos con acceso no secuencial y desea evitar la contención del caché. Puede omitir esta sección si está satisfecho con más pautas heurísticas.

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 ++ .

Otra cosa sobre la que quería preguntar es cómo se deben mantener las referencias a componentes o entidades, ya que la naturaleza misma de cómo se colocan los componentes en la memoria.

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".

concepto3d
fuente
Mi pregunta era sobre las implicaciones que mi arquitectura podría tener en el rendimiento, el punto no era optimizar sino elegir una forma de organizar las cosas internamente. Independientemente de la forma en que sucede dentro, quiero que mi código de juego interactúe con él de manera homogénea en caso de que quiera cambiar más tarde. Su respuesta fue buena incluso si pudiera proporcionar sugerencias adicionales sobre cómo almacenar los datos. Votado
Grimshaw
Por lo que veo, hay tres formas principales de almacenar componentes, todos acoplados en una sola matriz por entidad, todos unidos por tipo en matrices individuales, y si entendí correctamente, sugieres almacenar diferentes Entidades contiguamente en una gran matriz, y por entidad, ¿tienen todos sus componentes juntos?
Grimshaw
@Grimshaw Como mencioné en la respuesta, no se garantiza que su arquitectura dé mejores resultados que el patrón de asignación normal. Dado que realmente no conoce el patrón de acceso de sus aplicaciones. Dichas optimizaciones generalmente se realizan después de algún estudio / evidencia. Con respecto a mi sugerencia, almacene los componentes relacionados juntos en la misma memoria y otros componentes en diferentes ubicaciones. Este es un término medio entre todo o nada. Sin embargo, sigo suponiendo que es difícil predecir cómo afectará su arquitectura al resultado dada la cantidad de condiciones que entran en juego.
concept3d
El downvoter quiere explicar? Simplemente señale el problema en mi respuesta. Mejor aún, da una mejor respuesta.
concept3d
1

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?

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:

// Assuming 8-bit chars and 64-bit doubles.
struct Foo
{
    // 1 byte
    char a;

    // 1 byte
    char b;
};

struct Bar
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

Digamos que queremos intercalar Foo y Baralmacenarlos uno al lado del otro en la memoria:

// Assuming 8-bit chars and 64-bit doubles.
struct FooBar
{
    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'

    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;
};

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:

// Assuming 8-bit chars and 64-bit doubles.
struct BarFoo
{
    // 8 bytes
    double opacity;

    // 8 bytes
    double radius;

    // 1 byte
    char a;

    // 1 byte
    char b;

    // 6 bytes padding for 64-bit alignment of 'opacity'
};

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í:

ingrese la descripción de la imagen 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:

ingrese la descripción de la imagen aquí

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:

ingrese la descripción de la imagen aquí

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í:

Sorting 1000000 elements 32 times...
mt_sort_int: {0.203000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_sort: {1.248000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
mt_radix_sort: {0.202000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
std::sort: {1.810000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]
qsort: {2.777000 secs}
-- small result: [ 22 48 59 77 79 80 84 84 93 98 ]

Lo anterior es ordenar un millón de elementos 32 veces (incluido el tiempo de memcpyresultados 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