2009-09-20 5 views
8

He encontrado un comportamiento extraño en una aplicación .NET que realiza un procesamiento altamente paralelo en un conjunto de datos en memoria.Escalado no lineal de operaciones .NET en máquinas multi-core

Cuando se ejecuta en un procesador multi-core (IntelCore2 Quad Q6600 2.4GHz) exhibe una escala no lineal ya que varios hilos se inician para procesar los datos.

Cuando se ejecuta como un bucle no multiproceso en un solo núcleo, el proceso puede completar aproximadamente 2,4 millones de cálculos por segundo. Cuando se ejecuta como cuatro hilos esperaría cuatro veces más rendimiento, en algún lugar en la vecindad de 9 millones de cálculos por segundo, pero ¡ay !, no. En la práctica, solo completa unos 4.1 millones por segundo ... bastante corto del rendimiento esperado.

Además, el comportamiento se produce independientemente de que use PLINQ, un grupo de subprocesos o cuatro subprocesos creados explícitamente. Muy extraño ...

Nada más se está ejecutando en la máquina usando el tiempo de CPU, ni hay bloqueos u otros objetos de sincronización involucrados en el cálculo ... simplemente debería avanzar a través de los datos. Lo he confirmado (en la medida de lo posible) mirando los datos perfmon mientras se ejecuta el proceso ... y no hay conflictos de hilos informados ni actividad de recolección de basura.

mis teorías en la actualidad

  1. la sobrecarga de todas las técnicas (contexto hilo interruptores, etc) se abrumadora los cálculos
  2. Los hilos no se están asignados a cada uno de los cuatro núcleos y pase algún tiempo esperando en el mismo núcleo del procesador ... no estoy seguro de cómo probar esta teoría ...
  3. .NET Los subprocesos CLR no se ejecutan con la prioridad esperada o tienen algún gasto interno oculto.

A continuación se muestra un extracto representativo del código que debe exhibir el mismo comportamiento:

var evaluator = new LookupBasedEvaluator(); 

    // find all ten-vertex polygons that are a subset of the set of points 
    var ssg = new SubsetGenerator<PolygonData>(Points.All, 10); 

    const int TEST_SIZE = 10000000; // evaluate the first 10 million records 

    // materialize the data into memory... 
    var polygons = ssg.AsParallel() 
         .Take(TEST_SIZE) 
         .Cast<PolygonData>() 
         .ToArray(); 

    var sw1 = Stopwatch.StartNew(); 
    // for loop completes in about 4.02 seconds... ~ 2.483 million/sec 
    foreach(var polygon in polygons) 
     evaluator.Evaluate(polygon); 
    s1.Stop(); 
    Console.WriteLine("Linear, single core loop: {0}", s1.ElapsedMilliseconds); 

    // now attempt the same thing in parallel using Parallel.ForEach... 
    // MS documentation indicates this internally uses a worker thread pool 
    // completes in 2.61 seconds ... or ~ 3.831 million/sec 
    var sw2 = Stopwatch.StartNew(); 
    Parallel.ForEach(polygons, p => evaluator.Evaluate(p)); 
    sw2.Stop(); 
    Console.WriteLine("Parallel.ForEach() loop: {0}", s2.ElapsedMilliseconds); 

    // now using PLINQ, er get slightly better results, but not by much 
    // completes in 2.21 seconds ... or ~ 4.524 million/second 
    var sw3 = Stopwatch.StartNew(); 
    polygons.AsParallel(Environment.ProcessorCount) 
      .AsUnordered() // no sure this is necessary... 
      .ForAll(h => evalautor.Evaluate(h)); 
    sw3.Stop(); 
    Console.WriteLine("PLINQ.AsParallel.ForAll: {0}", s3.EllapsedMilliseconds); 

    // now using four explicit threads: 
    // best, still short of expectations at 1.99 seconds = ~ 5 million/sec 
    ParameterizedThreadStart tsd = delegate(object pset) { foreach (var p in (IEnumerable<Card[]>) pset) evaluator.Evaluate(p); }; 
    var t1 = new Thread(tsd); 
    var t2 = new Thread(tsd); 
    var t3 = new Thread(tsd); 
    var t4 = new Thread(tsd); 

    var sw4 = Stopwatch.StartNew(); 
    t1.Start(hands); 
    t2.Start(hands); 
    t3.Start(hands); 
    t4.Start(hands); 
    t1.Join(); 
    t2.Join(); 
    t3.Join(); 
    t4.Join(); 
    sw.Stop(); 
    Console.WriteLine("Four Explicit Threads: {0}", s4.EllapsedMilliseconds); 

Respuesta

5

Así que finalmente descubrí cuál era el problema, y ​​creo que sería útil compartirlo con la comunidad SO.

El número entero con un rendimiento no lineal fue el resultado de una sola línea dentro del método Evaluate():

var coordMatrix = new long[100]; 

Desde Evaluate() se invoca millones de veces, esta asignación de memoria se producen millones de veces. Como ocurre, el CLR realiza internamente alguna sincronización entre subprocesos al asignar memoria; de lo contrario, la asignación en varios subprocesos podría solaparse inadvertidamente. Cambiar el conjunto de una instancia local de método a una instancia de clase que solo se asigna una vez (pero luego inicializar en un bucle local de método) eliminó el problema de escalabilidad.

Normalmente, es un antipatrón crear un miembro de nivel de clase para una variable que solo se usa (y tiene sentido) dentro del alcance de un único método. Pero en este caso, dado que necesito la mayor escalabilidad posible, viviré con (y documentaré) esta optimización.

Epílogo: Después de realizar este cambio, el proceso simultáneo pudo lograr 12,2 millones de cálculos/seg.

P.S. Felicitaciones a Igor Ostrovsky por su enlace relacionado con los blogs de MSDN que me ayudó a identificar y diagnosticar el problema.

+0

Esto podría haberse resuelto usando un grupo de recursos. Esta pregunta me ayudó a entender por qué las agrupaciones pueden ser importantes cuando se intenta realizar operaciones paralelas masivas. – Will

+1

Sp Su implementación original no paralela se ejecuta a 2.4Mop/s, su última versión paralela en 4 núcleos se ejecuta a 12.2Mop/s. Eso es escalar súper lineal, que es notable y digno de investigación. Vuelve a probar la ejecución del código de núcleo único una vez que realizó el cambio, ¿verdad? –

+0

Al cambiar la asignación de memoria se mejoró el rendimiento de un solo núcleo a 3,2 Mops, por lo que los resultados de 12,2 núcleos son razonables. – LBushkin

0

Desde luego, no se esperaría una relación lineal, pero yo habría pensado que habría visto una ganancia más grande que ese. Estoy asumiendo que el uso de la CPU está al máximo en todos los núcleos. Solo un par de pensamientos fuera de mi cabeza.

  • ¿Está utilizando alguna estructura de datos compartida (explícita o implícitamente) que requiera sincronización?
  • ¿Ha intentado perfilar o registrar contadores de rendimiento para determinar dónde está el cuello de botella? ¿Puedes dar más pistas?

Editar: Lo siento, me he dado cuenta de que ya ha abordado mis dos puntos.

+0

Una idea interesante @spender, lo que los contadores de rendimiento podría examinar para determinar si estoy de hecho el gasto excesivo rendimiento de memoria? – LBushkin

+0

Si está maximizando el rendimiento de la memoria sin maximizar el uso de la CPU, la forma más fácil de saber es que el uso de la CPU no sería del 100% ... – configurator

3

El escalado no lineal es de esperar con un algoritmo paralelo en comparación con un algoritmo secuencial, ya que hay una sobrecarga inherente en la paralelización. (Lo ideal es, por supuesto, que desee acercarse lo más posible).

Además, generalmente habrá ciertas cosas que debe tener en cuenta en un algoritmo paralelo que no necesita en un algoritmo secuencial .Más allá de la sincronización (que realmente puede empantanar su trabajo), hay otras cosas que pueden suceder:

  • La CPU y el sistema operativo no pueden dedicar todo su tiempo a su aplicación. Por lo tanto, debe hacer el cambio de contexto de vez en cuando para permitir que otros procesos realicen algún trabajo. Cuando solo está utilizando un único núcleo, es menos probable que su proceso esté desactivado, ya que tiene otros tres núcleos para elegir. Tenga en cuenta que aunque piense que no se está ejecutando nada más, el sistema operativo o algunos servicios aún podrían estar realizando algún trabajo en segundo plano.
  • Si cada uno de sus subprocesos tiene acceso a una gran cantidad de datos, y estos datos no son comunes entre los subprocesos, lo más probable es que no pueda almacenar todo esto en la memoria caché de la CPU. Eso significa que se requiere mucho más acceso a la memoria, que es (relativamente) lento.

Por lo que puedo decir, su enfoque explícito actual utiliza un iterador compartido entre los hilos. Esa es una buena solución si el procesamiento varía enormemente en toda la matriz, pero es probable que haya una sobrecarga de sincronización para evitar que se salte un elemento (recuperar el elemento actual y mover el puntero interno al siguiente elemento debe ser una operación atómica para evitar omitiendo un elemento).

Por lo tanto, podría ser una mejor idea dividir la matriz, suponiendo que se espera que el tiempo de procesamiento de cada elemento sea aproximadamente igual independientemente de la posición del elemento. Dado que tiene 10 millones de registros, eso significa que el hilo 1 debe trabajar en los elementos 0 a 2,499,999, el hilo 2 funciona en los elementos 2,500,000 a 4,999,999, etc. Puede asignar a cada hilo una ID y usar esto para calcular el rango real.

Otra pequeña mejora sería dejar que el hilo principal actúe como uno de los hilos que se calcula. Sin embargo, si recuerdo correctamente, eso es muy cosa menor.

5

Tome un vistazo a este artículo: http://blogs.msdn.com/pfxteam/archive/2008/08/12/8849984.aspx

Específicamente, las asignaciones de memoria límite de la región en paralelo, y es importante verificar las escrituras para asegurarse de que no se producen cerca de las posiciones de memoria que otros hilos leen o escriben.

+0

y también debería poder crear un perfil y ver qué sucede en el vistas de concurrencia del perfilador VS 2010. Aquí está el blog de sus equipos http://blogs.msdn.com/visualizeparallel/default.aspx – Rick

Cuestiones relacionadas