Separar eficientemente los pasos de lectura / cálculo / escritura para el procesamiento concurrente de entidades en sistemas de entidades / componentes

11

Preparar

Tengo una arquitectura de entidad-componente donde las entidades pueden tener un conjunto de atributos (que son datos puros sin comportamiento) y existen sistemas que ejecutan la lógica de la entidad que actúa sobre esos datos. Esencialmente, en algo pseudocódigo:

Entity
{
    id;
    map<id_type, Attribute> attributes;
}

System
{
    update();
    vector<Entity> entities;
}

Un sistema que simplemente se mueve a lo largo de todas las entidades a una velocidad constante podría ser

MovementSystem extends System
{
   update()
   {
      for each entity in entities
        position = entity.attributes["position"];
        position += vec3(1,1,1);
   }
}

Esencialmente, estoy tratando de paralelizar update () de la manera más eficiente posible. Esto se puede hacer ejecutando sistemas completos en paralelo, o dando a cada actualización () de un sistema un par de componentes para que diferentes subprocesos puedan ejecutar la actualización del mismo sistema, pero para un subconjunto diferente de entidades registradas con ese sistema.

Problema

En el caso del Movimiento mostrado, la paralelización es trivial. Como las entidades no dependen unas de otras y no modifican los datos compartidos, podríamos mover todas las entidades en paralelo.

Sin embargo, estos sistemas a veces requieren que las entidades interactúen (lean / escriban datos de / a) entre sí, a veces dentro del mismo sistema, pero a menudo entre diferentes sistemas que dependen unos de otros.

Por ejemplo, en un sistema de física a veces las entidades pueden interactuar entre sí. Dos objetos colisionan, sus posiciones, velocidades y otros atributos se leen, se actualizan y luego los atributos actualizados se vuelven a escribir en ambas entidades.

Y antes de que el sistema de representación en el motor pueda comenzar a representar entidades, debe esperar a que otros sistemas completen la ejecución para garantizar que todos los atributos relevantes sean lo que necesitan ser.

Si intentamos paralelizar ciegamente esto, dará lugar a condiciones de carrera clásicas donde diferentes sistemas pueden leer y modificar datos al mismo tiempo.

Idealmente, existiría una solución en la que todos los sistemas puedan leer datos de las entidades que deseen, sin tener que preocuparse de que otros sistemas modifiquen esos mismos datos al mismo tiempo, y sin que el programador se preocupe por ordenar correctamente la ejecución y la paralelización de estos sistemas de forma manual (que a veces puede que ni siquiera sea posible).

En una implementación básica, esto podría lograrse simplemente colocando todas las lecturas y escrituras de datos en secciones críticas (protegiéndolas con mutexes). Pero esto induce una gran cantidad de sobrecarga en tiempo de ejecución y probablemente no sea adecuado para aplicaciones sensibles al rendimiento.

¿Solución?

En mi opinión, una posible solución sería un sistema en el que la lectura / actualización y la escritura de datos estén separadas, de modo que en una fase costosa, los sistemas solo lean datos y calculen lo que necesitan para computar, de alguna manera guarden en caché los resultados y luego escriban todos los datos modificados vuelven a las entidades de destino en un pase de escritura separado. Todos los sistemas actuarían sobre los datos en el estado en que se encontraban al principio del marco, y luego antes del final del marco, cuando todos los sistemas hayan terminado de actualizarse, se produce un pase de escritura serializado donde el caché resulta de todos los diferentes Los sistemas se repiten y se vuelven a escribir en las entidades de destino.

Esto se basa en la idea (¿quizás errónea?) De que la ganancia de paralelización fácil podría ser lo suficientemente grande como para superar el costo (tanto en términos de rendimiento de tiempo de ejecución como de sobrecarga de código) del almacenamiento en caché de resultados y el pase de escritura.

La pregunta

¿Cómo podría implementarse un sistema de este tipo para lograr un rendimiento óptimo? ¿Cuáles son los detalles de implementación de dicho sistema y cuáles son los requisitos previos para un sistema de entidad-componente que quiere usar esta solución?

TravisG
fuente

Respuestas:

1

----- (basado en la pregunta revisada)

Primer punto: como no menciona haber perfilado su tiempo de ejecución de compilación de lanzamiento y encontrado una necesidad específica, le sugiero que lo haga lo antes posible. ¿Cómo se ve su perfil? ¿Está agotando los cachés con un diseño de memoria defectuoso? Es un núcleo vinculado al 100%, cuánto tiempo relativo se dedica a procesar su ECS frente al resto de su motor, etc.

¿Leer de una entidad y calcular algo ... y conservar los resultados en algún lugar de un área de almacenamiento intermedio hasta más tarde? No creo que pueda separar leer + calcular + almacenar de la forma en que piensa y esperar que esta tienda intermedia sea todo menos pura sobrecarga.

Además, dado que está realizando un procesamiento continuo, la regla principal que desea seguir es tener un subproceso por núcleo de CPU. Creo que está viendo esto en la capa incorrecta , intente mirar sistemas completos y no entidades individuales.

Cree un gráfico de dependencia entre sus sistemas, un árbol de lo que el sistema necesita resultados del trabajo de un sistema anterior. Una vez que tenga ese árbol de dependencias, puede enviar fácilmente sistemas completos llenos de entidades para procesar en un hilo.

Entonces, digamos que su árbol de dependencia está lleno de zarzas y trampas para osos, un problema de diseño, pero tenemos que trabajar con lo que tenemos. El mejor caso aquí es que dentro de cada sistema cada entidad no depende de ningún otro resultado dentro de ese sistema. Aquí puede subdividir fácilmente el procesamiento en subprocesos, 0-99 y 100-199 en dos subprocesos para un ejemplo con dos núcleos y 200 entidades que posee este sistema.

En cualquier caso, en cada etapa debe esperar los resultados de los que depende la siguiente. Pero esto está bien porque esperar los resultados de diez grandes bloques de datos que se procesan en masa es muy superior a la sincronización de miles de bloques pequeños.

La idea detrás de construir un gráfico de dependencia era trivializar la tarea aparentemente imposible de "Encontrar y ensamblar otros sistemas para que se ejecuten en paralelo" mediante la automatización. Si dicho gráfico muestra signos de estar bloqueado por la espera constante de resultados previos, la creación de una lectura + modificación y escritura retardada solo mueve el bloqueo y no elimina la naturaleza en serie del procesamiento.

Y el procesamiento en serie solo se puede convertir en paralelo entre cada punto de secuencia, pero no en general. Pero te das cuenta de esto porque es el núcleo de tu problema. Incluso si almacena en caché las lecturas de datos que aún no se han escrito, aún debe esperar en ese caché para estar disponible.

Si crear arquitecturas paralelas fuera fácil o incluso posible con este tipo de restricciones, la informática no habría tenido problemas con el problema desde Bletchley Park.

La única solución real sería minimizar todas estas dependencias para hacer que los puntos de secuencia sean tan raramente necesarios como sea posible. Esto puede implicar subdividir los sistemas en pasos de procesamiento secuenciales donde, dentro de cada subsistema, ir en paralelo con los hilos se vuelve trivial.

Lo mejor que obtuve para este problema es que no es más que recomendar que si te duele golpear la cabeza contra una pared de ladrillos y luego dividirlo en paredes de ladrillo más pequeñas para que solo te golpees las espinillas.

Patrick Hughes
fuente
Lamento decírtelo, pero esta respuesta parece poco productiva. Solo me estás diciendo que lo que estoy buscando no existe, lo que parece lógicamente incorrecto (al menos en principio) y también porque he visto personas aludir a ese sistema en varios lugares antes (nadie nunca da lo suficiente detalles, sin embargo, que es la principal motivación para hacer esta pregunta). Sin embargo, es posible que no haya sido lo suficientemente detallado en mi pregunta original, por eso lo actualicé extensamente (y lo seguiré actualizando si mi mente tropieza con algo).
TravisG
Tampoco se pretende ofender: P
TravisG
@TravisG A menudo hay sistemas que dependen de otros sistemas como señaló Patrick. Para evitar retrasos de trama o evitar múltiples pases de actualización como parte de un paso lógico, la solución aceptada es serializar la fase de actualización, ejecutar subsistemas en paralelo siempre que sea posible, serializar subsistemas con dependencias mientras se procesan lotes de pases de actualización más pequeños dentro de cada uno subsistema utilizando un concepto parallel_for (). Es ideal para cualquier combinación de necesidades de pases de actualización del subsistema y la más flexible.
Naros
0

He oído hablar de una solución interesante a este problema: la idea es que habría 2 copias de los datos de la entidad (desperdicio, lo sé). Una copia sería la copia actual, y la otra sería la copia pasada. La copia actual es estrictamente de solo escritura, y la copia pasada es estrictamente de solo lectura. Supongo que los sistemas no quieren escribir en los mismos elementos de datos, pero si ese no es el caso, esos sistemas deberían estar en el mismo hilo. Cada subproceso tendría acceso de escritura a las copias actuales de secciones mutuamente excluyentes de los datos, y cada subproceso tiene acceso de lectura a todas las copias anteriores de los datos y, por lo tanto, puede actualizar las copias actuales utilizando datos de las copias anteriores sin cierre. Entre cada fotograma, la copia actual se convierte en la copia pasada, sin embargo, desea manejar el intercambio de roles.

Este método también elimina las condiciones de carrera porque todos los sistemas funcionarán con un estado obsoleto que no cambiará antes / después de que el sistema lo haya procesado.

John McDonald
fuente
Ese es el truco de la copia del montón de John Carmack, ¿no? Me lo he preguntado, pero todavía tiene el mismo problema que varios hilos podrían escribir en la misma ubicación de salida. Probablemente sea una buena solución si mantiene todo "de un solo paso", pero no estoy seguro de lo factible que sea.
TravisG
La latencia de la entrada a la pantalla aumentaría en 1 fotograma, incluida la reactividad de la GUI. Lo que puede importar para juegos de acción / sincronización o manipulaciones pesadas de GUI como RTS. Sin embargo, me gusta como idea creativa.
Patrick Hughes
Me enteré de esto por un amigo, y no sabía que era un truco de Carmack. Dependiendo de cómo se realice la representación, la representación de los componentes puede estar un cuadro atrás. Puede usar esto para la fase de Actualización, luego renderizar desde la copia actual una vez que todo esté actualizado.
John McDonald
0

Conozco 3 diseños de software que manejan el procesamiento paralelo de datos:

  1. Procese los datos secuencialmente : esto puede sonar extraño ya que queremos procesar los datos usando múltiples hilos. Sin embargo, la mayoría de los escenarios requieren múltiples subprocesos solo para que el trabajo se complete mientras que otros subprocesos esperan o realizan operaciones de larga duración. El uso más común son los subprocesos de la interfaz de usuario que actualizan la interfaz de usuario en un solo subproceso, mientras que otros subprocesos pueden ejecutarse en segundo plano, pero no se les permite acceder directamente a los elementos de la interfaz de usuario. Para pasar los resultados de los subprocesos en segundo plano, se utilizan colas de trabajos que serán procesadas por el único subproceso en la próxima oportunidad razonable.
  2. Sincronice el acceso a los datos: esta es la forma más común de manejar múltiples hilos que acceden a los mismos datos. La mayoría de los lenguajes de programación tienen clases y herramientas integradas para bloquear secciones donde los datos son leídos y / o escritos por múltiples hilos simultáneamente. Sin embargo, se debe tener precaución para no bloquear las operaciones. Por otro lado, este enfoque cuesta muchos gastos generales en aplicaciones en tiempo real.
  3. Maneje las modificaciones concurrentes solo cuando sucedan: este enfoque optimista se puede hacer si raramente ocurren colisiones. Los datos se leerán y modificarán si no hubo acceso múltiple, pero existe un mecanismo que detecta cuándo se actualizaron los datos simultáneamente. Si eso sucede, el cálculo único se ejecutará nuevamente hasta el éxito.

Aquí hay algunos ejemplos para cada enfoque que pueden usarse en un sistema de entidad:

  1. Pensemos en un CollisionSystemque lee Positiony RigidBodycomponentes y debería actualizar a Velocity. En lugar de manipular Velocitydirectamente, la CollisionSystemvoluntad colocará un CollisionEventen la cola de trabajo de un EventSystem. Este evento se procesará secuencialmente con otras actualizaciones del Velocity.
  2. Un EntitySystemdefine un conjunto de componentes que necesita leer y escribir. Para cada uno Entity, obtendrá un bloqueo de lectura para cada componente que desee leer y un bloqueo de escritura para cada componente que desee actualizar. De esta manera, todos EntitySystempodrán leer componentes simultáneamente mientras las operaciones de actualización están sincronizadas.
  3. Tomando el ejemplo de MovementSystem, el Positioncomponente es inmutable y contiene un número de revisión . El MovementSystemlee la Savely Positiony Velocitycomponentes y calcula la nueva Position, incrementando la lectura de revisión número e intenta actualizar el Positioncomponente. En caso de una modificación concurrente, el marco indica esto en la actualización y se Entityvolverá a colocar en la lista de entidades que el MovementSystem.

Dependiendo de los sistemas, entidades e intervalos de actualización, cada enfoque puede ser bueno o malo. Un marco de sistema de entidad podría permitir al usuario elegir entre esas opciones para ajustar el rendimiento.

Espero poder agregar algunas ideas a la discusión y por favor avíseme si hay alguna noticia al respecto.

benez
fuente