¿Cuántos hilos debo tener y para qué?


81

¿Debería tener hilos separados para renderizado y lógica, o incluso más?

Soy consciente de la inmensa caída de rendimiento causada por la sincronización de datos (y mucho menos cualquier bloqueo de mutex).

He estado pensando en llevar esto al extremo y hacer hilos para todos los subsistemas concebibles. Pero me preocupa que eso también pueda retrasar las cosas. (Por ejemplo, ¿es sensato separar el hilo de entrada de los hilos de renderizado o de lógica del juego?) ¿La sincronización de datos requerida lo haría inútil o incluso más lento?


66
¿cual plataforma? PC, consola NextGen, teléfonos inteligentes?
Ellis

Hay una cosa en la que puedo pensar que requeriría múltiples subprocesos; redes.
Jabonoso

Salga de las exageraciones, no hay desaceleración "inmensa" cuando hay cerraduras involucradas. Esta es una leyenda urbana y un prejuicio.
v.oddou

Respuestas:


61

El enfoque común para aprovechar múltiples núcleos es, francamente, simplemente equivocado. La separación de sus subsistemas en diferentes subprocesos dividirá parte del trabajo en varios núcleos, pero tiene algunos problemas importantes. Primero, es muy difícil trabajar con él. ¿Quién quiere jugar con las cerraduras y la sincronización y la comunicación y esas cosas cuando podrían simplemente escribir código de representación o física en su lugar? En segundo lugar, el enfoque en realidad no se amplía. En el mejor de los casos, esto le permitirá aprovechar quizás tres o cuatro núcleos, y eso si realmente sabe lo que está haciendo. Hay solo unos pocos subsistemas en un juego, y de esos hay incluso menos que ocupan grandes cantidades de tiempo de CPU. Hay un par de buenas alternativas que conozco.

Una es tener un subproceso principal junto con un subproceso de trabajo para cada CPU adicional. Independientemente del subsistema, el hilo principal delega tareas aisladas a los hilos de trabajo a través de algún tipo de cola (s); Estas tareas pueden crear otras tareas también. El único propósito de los subprocesos de trabajo es tomar cada tarea de la cola de una en una y realizarlas. Sin embargo, lo más importante es que tan pronto como un subproceso necesita el resultado de una tarea, si la tarea se completa, puede obtener el resultado, y si no, puede eliminar la tarea de la cola de manera segura y seguir adelante y realizar eso tarea en sí. Es decir, no todas las tareas terminarán siendo programadas en paralelo entre sí. Tener más tareas de las que se pueden ejecutar en paralelo es una buena opción.cosa en este caso; significa que es probable que se amplíe a medida que agrega más núcleos. Una desventaja de esto es que requiere mucho trabajo por adelantado para diseñar una cola decente y un bucle de trabajo a menos que tenga acceso a una biblioteca o tiempo de ejecución de idioma que ya lo proporciona. La parte más difícil es asegurarse de que sus tareas estén verdaderamente aisladas y seguras para los hilos, y asegurarse de que sus tareas estén en un punto medio feliz entre los granos gruesos y los granos finos.

Otra alternativa a los subprocesos del subsistema es paralelizar cada subsistema de forma aislada. Es decir, en lugar de ejecutar renderizado y física en sus propios hilos, escriba el subsistema de física para usar todos sus núcleos a la vez, escriba el subsistema de renderizado para usar todos sus núcleos a la vez, luego haga que los dos sistemas simplemente se ejecuten secuencialmente (o intercalados, dependiendo de otros aspectos de la arquitectura de tu juego). Por ejemplo, en el subsistema de física, podrías tomar todas las masas de puntos en el juego, dividirlas entre tus núcleos y luego hacer que todos los núcleos los actualicen a la vez. Cada núcleo puede trabajar en sus datos en ciclos cerrados con buena localidad. Este estilo de paralelismo de paso de bloqueo es similar a lo que hace una GPU. La parte más difícil aquí es asegurarse de que está dividiendo su trabajo en trozos de grano fino, de modo que se divida de manera uniformeen realidad da como resultado una cantidad igual de trabajo en todos los procesadores.

Sin embargo, a veces es más fácil, debido a la política, el código existente u otras circunstancias frustrantes, darle un hilo a cada subsistema. En ese caso, es mejor evitar hacer más subprocesos del sistema operativo que núcleos para cargas de trabajo pesadas de la CPU (si tiene un tiempo de ejecución con subprocesos livianos que simplemente equilibran sus núcleos, esto no es un gran problema). Además, evite la comunicación excesiva. Un buen truco es intentar canalizar; cada subsistema principal puede estar trabajando en un estado de juego diferente a la vez. La canalización reduce la cantidad de comunicación necesaria entre sus subsistemas, ya que no todos necesitan acceder a los mismos datos al mismo tiempo, y también puede anular algunos de los daños causados ​​por los cuellos de botella. Por ejemplo, si su subsistema de física tiende a tardar mucho tiempo en completarse y su subsistema de representación termina siempre esperándolo, su velocidad de fotogramas absoluta podría ser mayor si ejecuta el subsistema de física para el siguiente fotograma mientras el subsistema de representación todavía funciona en el anterior cuadro. De hecho, si tiene tales cuellos de botella y no puede eliminarlos de otra manera, la canalización puede ser la razón más legítima para molestarse con los subprocesos del subsistema.


"tan pronto como un subproceso necesita el resultado de una tarea, si la tarea se completa, puede obtener el resultado, y si no, puede eliminar la tarea de la cola de forma segura y continuar y realizar esa tarea por sí mismo". ¿Estás hablando de una tarea generada por el mismo hilo? Si es así, ¿no tendría más sentido si esa tarea es ejecutada por el hilo que generó la tarea misma?
jmp97

es decir, el hilo podría, sin programar la tarea, ejecutar esa tarea de inmediato.
jmp97

3
El punto es que el hilo no necesariamente sabe por adelantado si sería mejor ejecutar la tarea en paralelo o no. La idea es provocar especulativamente el trabajo que eventualmente necesitará hacer, y si otro hilo se encuentra inactivo, entonces puede continuar y hacer este trabajo por usted. Si esto no sucede cuando necesita el resultado, puede extraer la tarea de la cola usted mismo. Este esquema es para equilibrar dinámicamente una carga de trabajo en múltiples núcleos en lugar de estáticamente.
Jake McArthur

Perdón por tardar tanto en volver a este hilo. No estoy prestando atención a gamedev últimamente. Esta es probablemente la mejor respuesta, contundente pero al grano y extensa.
j riv

1
Tiene razón en el sentido de que me olvidé de hablar sobre las cargas de trabajo pesadas de E / S. Mi interpretación de la pregunta fue que se trataba solo de cargas de trabajo pesadas de CPU.
Jake McArthur

30

Hay un par de cosas a considerar. La ruta de subproceso por subsistema es fácil de pensar ya que la separación del código es bastante evidente desde el principio. Sin embargo, dependiendo de la cantidad de intercomunicación que sus subsistemas necesiten, la comunicación entre subprocesos realmente podría matar su rendimiento. Además, esto solo se escala a N núcleos, donde N es el número de subsistemas que abstrae en subprocesos.

Si solo está buscando multiprocesar un juego existente, este es probablemente el camino de menor resistencia. Sin embargo, si está trabajando en algunos sistemas de motor de bajo nivel que podrían compartirse entre varios juegos o proyectos, consideraría otro enfoque.

Puede tomar un poco de torsión mental, pero si puede dividir las cosas como una cola de trabajo con un conjunto de hilos de trabajo, se escalará mucho mejor a largo plazo. A medida que las últimas y mejores fichas salgan con miles de millones de núcleos, el rendimiento de su juego aumentará junto con él, solo disparará más hilos de trabajadores.

Básicamente, si está buscando obtener cierto paralelismo con un proyecto existente, paralelizaría en todos los subsistemas. Si está construyendo un nuevo motor desde cero con una escalabilidad paralela en mente, buscaría una cola de trabajo.


El sistema que menciona es muy similar a un sistema de programación mencionado en la respuesta dada por el Otro James, sigue siendo un buen detalle en esa área, por lo que +1 se suma a la discusión.
James

3
Sería bueno tener un wiki de la comunidad sobre cómo configurar una cola de trabajo y subprocesos de trabajo.
bot_bot

23

Esa pregunta no tiene la mejor respuesta, ya que depende de lo que intente lograr.

El xbox tiene tres núcleos y puede manejar algunos subprocesos antes de que la sobrecarga de cambio de contexto se convierta en un problema. La PC puede manejar bastantes más.

Por lo general, muchos juegos tienen un solo subproceso para facilitar la programación. Esto está bien para la mayoría de los juegos personales. Lo único para lo que probablemente tenga que tener otro hilo es Redes y Audio.

Unreal tiene un hilo de juego, un hilo de renderizado, un hilo de red y un hilo de audio (si no recuerdo mal). Esto es bastante estándar para muchos motores de generación actual, aunque ser capaz de soportar un hilo de renderizado separado puede ser una molestia e implica mucho trabajo preliminar.

El motor idTech5 que se está desarrollando para Rage en realidad usa cualquier cantidad de hilos, y lo hace dividiendo las tareas del juego en 'trabajos' que se procesan con un sistema de tareas. Su objetivo explícito es hacer que su motor de juego escale bien cuando salta el número de núcleos en el sistema de juego promedio.

La tecnología que uso (y he escrito) tiene un hilo separado para Redes, Entrada, Audio, Renderizado y Programación. Luego tiene cualquier cantidad de hilos que se pueden usar para realizar tareas del juego, y esto es administrado por el hilo de programación. Se trabajó mucho para que todos los hilos funcionen bien entre sí, pero parece estar funcionando bien y está utilizando muy bien los sistemas multinúcleo, por lo que tal vez sea una misión cumplida (por ahora; podría romper el audio / las redes / trabajo de entrada en solo 'tareas' que los hilos de trabajo pueden actualizar).

Realmente depende de tu objetivo final.


+1 por la mención de un sistema de programación ... generalmente es un buen lugar para centrar la comunicación hilo / sistema :)
James

¿Por qué el voto negativo, votante negativo?
jcora

12

Un subproceso por subsistema es el camino equivocado. De repente, su aplicación no escalará porque algunos subsistemas demandan mucho más que otros. Este era el enfoque de subprocesos adoptado por Supreme Commander y no se escalaba más allá de dos núcleos porque solo tenían dos subsistemas que ocupaban una cantidad sustancial de procesamiento de CPU y lógica de física / juego, a pesar de que tenían 16 subprocesos, los otros subprocesos apenas ascendió a algún trabajo y, como resultado, el juego solo se ajustó a dos núcleos.

Lo que debe hacer es usar algo llamado grupo de subprocesos. Esto refleja un poco el enfoque adoptado en las GPU, es decir, publica el trabajo, y cualquier subproceso disponible simplemente aparece y lo hace, y luego vuelve a esperar el trabajo, piense en él como un buffer de anillo, de subprocesos. Este enfoque tiene la ventaja de escalar N-core y es muy bueno para escalar tanto para conteos bajos como altos. La desventaja es que es bastante difícil trabajar la propiedad del hilo para este enfoque, ya que es imposible saber qué hilo está haciendo qué funciona en un momento dado, por lo que debe tener los problemas de propiedad bloqueados muy estrictamente. También hace que sea muy difícil usar tecnologías como Direct3D9 que no admiten múltiples hilos.

Los grupos de subprocesos son muy difíciles de usar, pero ofrecen los mejores resultados posibles. Si necesita un escalado extremadamente bueno, o tiene mucho tiempo para trabajar en él, use un grupo de subprocesos. Si está intentando introducir paralelismo en un proyecto existente con problemas de dependencia desconocidos y tecnologías de subproceso único, esta no es la solución para usted.


Para ser un poco más precisos: las GPU no usan grupos de subprocesos, sino que el planificador de subprocesos se implementa en hardware, lo que hace que sea muy barato crear nuevos subprocesos y cambiarlos, a diferencia de las CPU donde la creación de subprocesos y los cambios de contexto son caros. Consulte la Guía del programador de Nvidias CUDA, por ejemplo.
Nils

2
+1: La mejor respuesta aquí. Incluso usaría construcciones más abstractas que grupos de subprocesos (por ejemplo, colas de trabajo y trabajadores) si su marco lo permite. Es mucho más fácil pensar / programar en estos términos que en hilos / bloqueos / etc. Además: dividir tu juego en renderizado, lógica, etc. no tiene sentido, ya que el renderizado tiene que esperar a que termine la lógica. En su lugar, cree trabajos que realmente se puedan ejecutar en paralelo (por ejemplo: Calcule la IA para un npc para el siguiente cuadro).
Dave O.

@DaveO. Su punto "más" es muy, muy cierto.
Ingeniero

11

Tiene razón en que la parte más crítica es evitar la sincronización siempre que sea posible. Hay algunas formas de lograr esto.

  1. Conozca sus datos y guárdelos en la memoria de acuerdo con sus necesidades de procesamiento. Esto le permite planificar cálculos paralelos sin necesidad de sincronización. Desafortunadamente, esto es la mayoría de las veces bastante difícil de lograr ya que a menudo se accede a los datos desde diferentes sistemas en momentos impredecibles.

  2. Defina tiempos de acceso claros para los datos. Podrías separar tu tic principal en x fases. Si está seguro de que el subproceso X lee los datos solo en una fase específica, también sabe que otros subprocesos pueden modificar estos datos en una fase diferente.

  3. Doble memoria intermedia de sus datos. Ese es el enfoque más simple, pero aumenta la latencia, ya que Thread X está trabajando con los datos del último fotograma, mientras que Thread Y está preparando los datos para el siguiente fotograma.

Mi experiencia personal muestra que los cálculos de grano fino son la forma más efectiva, ya que estos pueden escalar mucho mejor que las soluciones basadas en un subsistema. Si conecta sus subsistemas, el tiempo de trama estará vinculado al subsistema más caro. Esto puede conducir a todos los subprocesos, pero a uno en inactivo hasta que el costoso subsistema finalmente haya terminado su trabajo. Si puede separar grandes partes de su juego en pequeñas tareas, estas tareas se pueden programar en consecuencia para evitar núcleos inactivos. Pero esto es algo difícil de lograr si ya tienes una gran base de código.

Para tener en cuenta algunas restricciones de hardware, debe intentar no suscribirse en exceso a su hardware. Con la suscripción excesiva, me refiero a tener más hilos de software que los hilos de hardware de su plataforma. Especialmente en arquitecturas PPC (Xbox360, PS3) un cambio de tareas es realmente costoso. Por supuesto, está perfectamente bien si se han suscrito algunos subprocesos que solo se activan por un período de tiempo reducido (una vez por cuadro, por ejemplo). Si se dirige a la PC, debe tener en cuenta que la cantidad de núcleos (o mejor HW -Threads) está en constante crecimiento, por lo que querrá encontrar una solución escalable, que aproveche la potencia de CPU adicional. Por lo tanto, en esta área, debe intentar diseñar su código lo más basado en tareas posible.


3

Regla general para enhebrar una aplicación: 1 hilo por CPU Core. En una PC de cuatro núcleos, eso significa 4. Como se señaló, la XBox 360 tiene 3 núcleos pero 2 hilos de hardware cada uno, por lo que 6 hilos en este caso. En un sistema como la PS3 ... buena suerte en eso :) La gente todavía está tratando de resolverlo.

Sugeriría diseñar cada sistema como un módulo autónomo que podría enhebrar si lo desea. Esto generalmente significa tener vías de comunicación muy claramente definidas entre el módulo y el resto del motor. En particular, me gustan los procesos de solo lectura, como el renderizado y el audio, así como los procesos de 'estamos allí todavía', como leer la entrada del reproductor para que las cosas se desenrosquen. Para tocar la respuesta dada por AttackingHobo, cuando estás procesando 30-60 fps, si tus datos están desactualizados 1/30/1/60 de segundo, realmente no va a restar valor a la sensación receptiva de tu juego. Recuerde siempre que la principal diferencia entre el software de aplicación y los videojuegos es hacer todo 30-60 veces por segundo. En esa misma nota, sin embargo,

Si diseñas los sistemas de tu motor lo suficientemente bien, cualquiera de ellos se puede mover de hilo a hilo para equilibrar la carga de tu motor de manera más apropiada por juego y similares. En teoría, también podría usar su motor en un sistema distribuido si fuera necesario, donde sistemas informáticos completamente separados ejecutan cada componente.


2
La Xbox360 tiene 2 hilos de hardware por núcleo, por lo que el número óptimo de hilos es 6.
DarthCoder

Ah, +1 :) Siempre estuve restringido a las áreas de redes de 360 ​​y ps3, jeje :)
James

0

Creo un subproceso por núcleo lógico (menos uno, para tener en cuenta el subproceso principal, que incidentalmente es responsable de la representación, pero también actúa como un subproceso de trabajo).

Recopilo eventos de dispositivos de entrada en tiempo real a lo largo de un marco, pero no los aplico hasta el final del marco: tendrán efecto en el siguiente marco. Y uso una lógica similar para renderizar (estado anterior) versus actualizar (estado nuevo).

Utilizo eventos atómicos para diferir operaciones inseguras hasta más adelante en el mismo marco, y uso más de una cola de eventos (cola de trabajos) para implementar una barrera de memoria que ofrece una garantía de hierro con respecto al orden de las operaciones, sin bloquear ni esperar (bloquear colas concurrentes libres en orden de prioridad de trabajo).

Cabe mencionar que cualquier trabajo puede emitir subjobs (que son más finos y se acercan a la atomicidad) a la misma cola de prioridad o una que es más alta (se sirve más adelante en el marco).

Dado que tengo tres colas de este tipo, todos los hilos, excepto uno, pueden detenerse exactamente tres veces por cuadro (mientras espero que otros hilos completen todos los trabajos pendientes emitidos en el nivel de prioridad actual).

¡Esto parece un nivel aceptable de inactividad del hilo!


Mi marco comienza con PRINCIPAL que representa el ESTADO ANTIGUO del pase de actualización del marco anterior, mientras que todos los otros subprocesos comienzan inmediatamente a calcular el estado del marco SIGUIENTE, solo estoy usando Eventos para duplicar los cambios de estado del búfer hasta un punto en el marco donde ya nadie está leyendo .
Homero

0

Usualmente uso un hilo principal (obviamente) y agregaré un hilo cada vez que note una caída de rendimiento de aproximadamente 10 a 20 por ciento. Para ubicar tal caída utilizo las herramientas de rendimiento de Visual Studio. Los eventos comunes son (des) cargar algunas áreas del mapa o hacer algunos cálculos pesados.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.