¿Cuáles son los peligros al crear un hilo con un tamaño de pila de 50x el predeterminado?

228

Actualmente estoy trabajando en un programa muy crítico para el rendimiento y una ruta que decidí explorar que puede ayudar a reducir el consumo de recursos fue aumentar el tamaño de la pila de mis hilos de trabajo para poder mover la mayoría de los datos a float[]los que accederé la pila (usando stackalloc).

He leído que el tamaño de pila predeterminado para un subproceso es de 1 MB, por lo que para mover todos mis float[]mensajes tendría que expandir la pila aproximadamente 50 veces (a 50 MB ~).

Entiendo que esto generalmente se considera "inseguro" y no se recomienda, pero después de comparar mi código actual con este método, ¡descubrí un aumento del 530% en la velocidad de procesamiento! Por lo tanto, no puedo pasar por alto esta opción sin más investigación, lo que me lleva a mi pregunta; ¿Cuáles son los peligros asociados con aumentar la pila a un tamaño tan grande (qué podría salir mal) y qué precauciones debo tomar para minimizar tales peligros?

Mi código de prueba

public static unsafe void TestMethod1()
{
    float* samples = stackalloc float[12500000];

    for (var ii = 0; ii < 12500000; ii++)
    {
        samples[ii] = 32768;
    }
}

public static void TestMethod2()
{
    var samples = new float[12500000];

    for (var i = 0; i < 12500000; i++)
    {
        samples[i] = 32768;
    }
}
Sam
fuente
98
+1. Seriamente. Preguntas lo que PARECE como una pregunta idiota fuera de la norma y luego haces un MUY buen caso de que en tu escenario particular es algo sensato a considerar porque hiciste tu tarea y midiste el resultado. Esto es MUY bueno, lo extraño con muchas preguntas. Muy bien, bueno, considera algo como esto, lamentablemente muchos programadores de C # no son conscientes de esas oportunidades de optimización. Sí, a menudo no es necesario, pero a veces es crítico y hace una gran diferencia.
TomTom
55
Estoy interesado en ver los dos códigos que tienen una diferencia de 530% en la velocidad de procesamiento, únicamente debido a que mueve la matriz a la pila. Eso simplemente no se siente bien.
Dialecticus
13
Antes de saltar por ese camino: ¿ha intentado usar Marshal.AllocHGlobal(no se olvide FreeHGlobaltambién) para asignar los datos fuera de la memoria administrada? Luego lanza el puntero a a float*, y deberías ser ordenado.
Marc Gravell
2
Se siente bien si haces muchas asignaciones. Stackalloc evita todos los problemas de GC que también pueden crear / crea una localidad muy fuerte a nivel de procesador. Esta es una de las cosas que parecen micro optimizaciones, a menos que escriba un programa matemático de alto rendimiento y tenga exactamente este comportamiento y marque la diferencia;)
TomTom
66
Mi sospecha: uno de estos métodos desencadena la verificación de límites en cada iteración de bucle mientras que el otro no, o está optimizado.
pjc50

Respuestas:

45

Al comparar el código de prueba con Sam, ¡determiné que ambos tenemos razón!
Sin embargo, sobre cosas diferentes:

  • Acceder a la memoria (lectura y escritura) es igual de rápido donde sea que esté: pila, global o montón.
  • Sin embargo, su asignación es más rápida en la pila y más lenta en el montón.

Dice así: stack< global< heap. (tiempo de asignación)
Técnicamente, la asignación de la pila no es realmente una asignación, el tiempo de ejecución solo se asegura de que una parte de la pila (¿marco?) esté reservada para la matriz.

Sin embargo, recomiendo tener cuidado con esto.
Recomiendo lo siguiente:

  1. Cuando necesite crear matrices con frecuencia que nunca abandonen la función (por ejemplo, pasando su referencia), usar la pila será una mejora enorme.
  2. Si puede reciclar una matriz, ¡hágalo siempre que pueda! El montón es el mejor lugar para el almacenamiento de objetos a largo plazo. (contaminar la memoria global no es bueno; los marcos de pila pueden desaparecer)

( Nota : 1. solo se aplica a los tipos de valor; los tipos de referencia se asignarán en el montón y el beneficio se reducirá a 0)

Para responder a la pregunta en sí: no he encontrado ningún problema con ninguna prueba de gran tamaño.
Creo que los únicos problemas posibles son un desbordamiento de pila, si no tiene cuidado con sus llamadas a funciones y se queda sin memoria al crear su (s) hilo (s) si el sistema se está agotando.

La siguiente sección es mi respuesta inicial. Es incorrecto y las pruebas no son correctas. Se guarda solo como referencia.


¡Mi prueba indica que la memoria asignada a la pila y la memoria global son al menos un 15% más lentas que (toma el 120% del tiempo de) la memoria asignada en el montón para su uso en matrices!

Este es mi código de prueba , y esta es una salida de muestra:

Stack-allocated array time: 00:00:00.2224429
Globally-allocated array time: 00:00:00.2206767
Heap-allocated array time: 00:00:00.1842670
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 100.80 %| 120.72 %|
--+---------+---------+---------+
G |  99.21 %|    -    | 119.76 %|
--+---------+---------+---------+
H |  82.84 %|  83.50 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

Probé en Windows 8.1 Pro (con la Actualización 1), usando un i7 4700 MQ, en .NET 4.5.1 Probé
con x86 y x64 y los resultados son idénticos.

Editar : Aumenté el tamaño de la pila de todos los hilos 201 MB, el tamaño de la muestra a 50 millones y disminuí las iteraciones a 5.
Los resultados son los mismos que los anteriores :

Stack-allocated array time: 00:00:00.4504903
Globally-allocated array time: 00:00:00.4020328
Heap-allocated array time: 00:00:00.3439016
------------------------------------------
Fastest: Heap.

  |    S    |    G    |    H    |
--+---------+---------+---------+
S |    -    | 112.05 %| 130.99 %|
--+---------+---------+---------+
G |  89.24 %|    -    | 116.90 %|
--+---------+---------+---------+
H |  76.34 %|  85.54 %|    -    |
--+---------+---------+---------+
Rates are calculated by dividing the row's value to the column's.

Sin embargo, parece que la pila se está volviendo más lenta .

Vercas
fuente
Tendría que estar en desacuerdo, de acuerdo con los resultados de mi punto de referencia (ver el comentario en la parte inferior de la página para ver los resultados) muestra que la pila es marginalmente más rápida que la global, y mucho más rápida que el montón; y para estar definitivamente seguro de que mis resultados son precisos, ejecuté la prueba 20 veces, y cada método se llamó 100 veces por iteración de prueba. ¿Definitivamente estás ejecutando tu punto de referencia correctamente?
Sam
Estoy obteniendo resultados muy inconsistentes. Con plena confianza, x64, configuración de lanzamiento, sin depurador, todos son igualmente rápidos (menos del 1% de diferencia; fluctuantes) mientras que el tuyo es mucho más rápido con una pila. ¡Necesito probar más! Editar : la suya DEBE lanzar una excepción de desbordamiento de pila. Simplemente asigna suficiente para la matriz. O_o
Vercas
Sí, lo sé, está cerca. Debe repetir los puntos de referencia varias veces, como lo hice, tal vez intente tomar un promedio de más de 5 carreras más o menos.
Sam
1
@Voo La primera ejecución me llevó tanto tiempo como la centésima de cualquier prueba. Desde mi experiencia, esto de Java JIT no se aplica a .NET en absoluto. El único "calentamiento" que hace .NET es cargar clases y ensamblajes cuando se usa por primera vez.
Vercas
2
@Voo Pruebe mi punto de referencia y el de la esencia que agregó en un comentario a esta respuesta. Reúna los códigos y ejecute unos cientos de pruebas. Luego regrese e informe su conclusión. He hecho mis pruebas muy a fondo, y sé muy bien de qué estoy hablando cuando digo que .NET no interpreta ningún código de bytes como lo hace Java, lo JIT instantáneamente.
Vercas
28

¡He descubierto un aumento del 530% en la velocidad de procesamiento!

Ese es, con mucho, el mayor peligro que diría. Hay algo muy mal con su punto de referencia, el código que se comporta de manera impredecible generalmente tiene un error desagradable oculto en alguna parte.

Es muy, muy difícil consumir mucho espacio de pila en un programa .NET, excepto por una recursión excesiva. El tamaño del marco de la pila de métodos administrados se establece en piedra. Simplemente la suma de los argumentos del método y las variables locales en un método. Menos los que se pueden almacenar en un registro de CPU, puede ignorar eso ya que hay muy pocos de ellos.

Aumentar el tamaño de la pila no logra nada, solo reservará un montón de espacio de direcciones que nunca se utilizará. No hay ningún mecanismo que pueda explicar un aumento de rendimiento por no usar memoria, por supuesto.

Esto es diferente a un programa nativo, particularmente uno escrito en C, también puede reservar espacio para matrices en el marco de la pila. El vector de ataque de malware básico detrás de los desbordamientos del búfer de pila. También es posible en C #, tendría que usar la stackallocpalabra clave. Si está haciendo eso, entonces el peligro obvio es tener que escribir código inseguro que esté sujeto a tales ataques, así como la corrupción aleatoria del marco de la pila. Muy difícil de diagnosticar errores. Hay una contramedida contra esto en jitters posteriores, creo que a partir de .NET 4.0, donde el jitter genera código para poner una "cookie" en el marco de la pila y comprueba si todavía está intacto cuando el método regresa. Accidente instantáneo en el escritorio sin ninguna forma de interceptar o informar el error si eso sucede. Eso es ... peligroso para el estado mental del usuario.

El hilo principal de su programa, el iniciado por el sistema operativo, tendrá una pila de 1 MB de forma predeterminada, 4 MB cuando compile su programa dirigido a x64. Un aumento que requiere ejecutar Editbin.exe con la opción / STACK en un evento posterior a la compilación. Por lo general, puede solicitar hasta 500 MB antes de que su programa tenga problemas para comenzar cuando se ejecuta en modo de 32 bits. Los subprocesos también pueden, por supuesto, mucho más fácil, la zona de peligro suele rondar los 90 MB para un programa de 32 bits. Se activa cuando su programa se ha estado ejecutando durante mucho tiempo y el espacio de direcciones se fragmentó a partir de asignaciones anteriores. El uso total del espacio de direcciones ya debe ser alto, en un concierto, para obtener este modo de falla.

Verifique tres veces su código, hay algo muy mal. No puede obtener una aceleración x5 con una pila más grande a menos que escriba explícitamente su código para aprovecharlo. Que siempre requiere un código inseguro. El uso de punteros en C # siempre tiene un don para crear código más rápido, no está sujeto a las verificaciones de los límites de la matriz.

Hans Passant
fuente
21
La aceleración 5x reportada fue de pasar de float[]a float*. La gran pila fue simplemente cómo se logró eso. Una aceleración x5 en algunos escenarios es completamente razonable para ese cambio.
Marc Gravell
3
De acuerdo, todavía no tenía el fragmento de código cuando comencé a responder la pregunta. Aún lo suficientemente cerca.
Hans Passant
22

Tendría una reserva allí que simplemente no sabría cómo predecirla: permisos, GC (que necesita escanear la pila), etc., todo podría verse afectado. Estaría muy tentado a usar memoria no administrada en su lugar:

var ptr = Marshal.AllocHGlobal(sizeBytes);
try
{
    float* x = (float*)ptr;
    DoWork(x);
}
finally
{
    Marshal.FreeHGlobal(ptr);
}
Marc Gravell
fuente
1
Pregunta secundaria: ¿Por qué el GC necesitaría escanear la pila? La memoria asignada por stackallocno está sujeta a recolección de basura.
dcastro
66
@dcastro necesita escanear la pila para verificar las referencias que solo existen en la pila. Simplemente no sé qué va a hacer cuando llegue a ser tan grande stackalloc, necesita saltar, y es de esperar que lo haga sin esfuerzo, pero el punto que estoy tratando de hacer es que introduce complicaciones / preocupaciones innecesarias OMI, stackalloces excelente como un búfer de memoria virtual, pero para un espacio de trabajo dedicado, se espera que asigne un fragmento de memoria en algún lugar, en lugar de abusar / confundir la pila,
Marc Gravell
8

Una cosa que puede salir mal es que es posible que no obtenga el permiso para hacerlo. A menos que se ejecute en modo de plena confianza, el Framework simplemente ignorará la solicitud de un tamaño de pila más grande (consulte MSDN en Thread Constructor (ParameterizedThreadStart, Int32))

En lugar de aumentar el tamaño de la pila del sistema a números tan grandes, sugeriría reescribir su código para que use Iteration y una implementación de pila manual en el montón.

PMF
fuente
1
Buena idea, iteraré en su lugar. Además de eso, mi código se ejecuta en modo de plena confianza, ¿hay otras cosas que debería tener en cuenta?
Sam
6

Las matrices de alto rendimiento podrían ser accesibles de la misma manera que un C # uno normal, pero eso podría ser el comienzo de un problema: considere el siguiente código:

float[] someArray = new float[100]
someArray[200] = 10.0;

Espera una excepción fuera de límite y esto tiene mucho sentido porque está intentando acceder al elemento 200 pero el valor máximo permitido es 99. Si va a la ruta stackalloc, no habrá ningún objeto envuelto alrededor de su matriz para verificar y Lo siguiente no mostrará ninguna excepción:

Float* pFloat =  stackalloc float[100];
fFloat[200]= 10.0;

Arriba está asignando suficiente memoria para contener 100 flotantes y está configurando la ubicación de memoria sizeof (float) que comienza en la ubicación iniciada de esta memoria + 200 * sizeof (float) para mantener su valor flotante 10. Como era de esperar, esta memoria está fuera del asignada memoria para los flotadores y nadie sabría lo que podría almacenarse en esa dirección. Si tiene suerte, es posible que haya utilizado alguna memoria no utilizada actualmente, pero al mismo tiempo es probable que pueda sobrescribir alguna ubicación que se utilizó para almacenar otras variables. Para resumir: comportamiento de tiempo de ejecución impredecible.

MHOOS
fuente
De hecho mal. El tiempo de ejecución y las pruebas del compilador siguen ahí.
TomTom
99
@TomTom erm, no; la respuesta tiene mérito; la pregunta habla stackalloc, en cuyo caso estamos hablando, float*etc., que no tiene los mismos controles. Se llama unsafepor una muy buena razón. Personalmente, estoy perfectamente feliz de usar unsafecuando hay una buena razón, pero Sócrates hace algunos puntos razonables.
Marc Gravell
@Marc Para el código que se muestra (después de ejecutar el JIT) no hay más comprobaciones de límites porque es trivial que el compilador razone que todos los accesos están dentro de los límites. En general, esto puede marcar la diferencia.
Voo
6

Los lenguajes de microbenchmarking con JIT y GC como Java o C # pueden ser un poco complicados, por lo que generalmente es una buena idea usar un marco existente: Java ofrece mhf o Caliper, que son excelentes, lamentablemente a mi entender C # no ofrece cualquier cosa que se aproxime a esos. Jon Skeet escribió esto aquí, que asumiré ciegamente que se ocupa de las cosas más importantes (Jon sabe lo que está haciendo en esa área; también sí, no te preocupes, en realidad lo comprobé). Ajusté un poco el tiempo porque 30 segundos por prueba después del calentamiento era demasiado para mi paciencia (5 segundos deberían hacerlo).

Entonces, primero los resultados, .NET 4.5.1 en Windows 7 x64: los números denotan las iteraciones que podría ejecutar en 5 segundos, por lo que cuanto más alto, mejor.

x64 JIT:

Standard       10,589.00  (1.00)
UnsafeStandard 10,612.00  (1.00)
Stackalloc     12,088.00  (1.14)
FixedStandard  10,715.00  (1.01)
GlobalAlloc    12,547.00  (1.18)

x86 JIT (sí, eso todavía es un poco triste):

Standard       14,787.00   (1.02)
UnsafeStandard 14,549.00   (1.00)
Stackalloc     15,830.00   (1.09)
FixedStandard  14,824.00   (1.02)
GlobalAlloc    18,744.00   (1.29)

Esto proporciona una aceleración mucho más razonable de a lo sumo 14% (y la mayor parte de la sobrecarga se debe a que el GC tiene que ejecutarse, considérelo como el peor de los casos de manera realista). Sin embargo, los resultados x86 son interesantes, no del todo claro lo que está sucediendo allí.

y aquí está el código:

public static float Standard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float UnsafeStandard(int size) {
    float[] samples = new float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float Stackalloc(int size) {
    float* samples = stackalloc float[size];
    for (var ii = 0; ii < size; ii++) {
        samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
    }
    return samples[size - 1];
}

public static unsafe float FixedStandard(int size) {
    float[] prev = new float[size];
    fixed (float* samples = &prev[0]) {
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    }
}

public static unsafe float GlobalAlloc(int size) {
    var ptr = Marshal.AllocHGlobal(size * sizeof(float));
    try {
        float* samples = (float*)ptr;
        for (var ii = 0; ii < size; ii++) {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }
        return samples[size - 1];
    } finally {
        Marshal.FreeHGlobal(ptr);
    }
}

static void Main(string[] args) {
    int inputSize = 100000;
    var results = TestSuite.Create("Tests", inputSize, Standard(inputSize)).
        Add(Standard).
        Add(UnsafeStandard).
        Add(Stackalloc).
        Add(FixedStandard).
        Add(GlobalAlloc).
        RunTests();
    results.Display(ResultColumns.NameAndIterations);
}
Voo
fuente
Una observación interesante, tendré que revisar mis puntos de referencia nuevamente. Aunque esto realmente no responde a mi pregunta, " ... cuáles son los peligros asociados con aumentar la pila a un tamaño tan grande ... ". Incluso si mis resultados son incorrectos, la pregunta sigue siendo válida; Sin embargo, aprecio el esfuerzo.
Sam
1
@Sam Cuando uso 12500000como tamaño, en realidad obtengo una excepción stackoverflow. Pero principalmente se trataba de rechazar la premisa subyacente de que usar código asignado por pila es más rápido en varios órdenes de magnitud. De lo contrario, estamos haciendo la menor cantidad de trabajo posible aquí, y la diferencia ya es solo del 10 al 15%, en la práctica será aún más baja ... esto, en mi opinión, definitivamente cambia toda la discusión.
Voo
5

Dado que la diferencia de rendimiento es demasiado grande, el problema apenas está relacionado con la asignación. Es probable que sea causado por el acceso a la matriz.

Desmonté el cuerpo del bucle de las funciones:

TestMethod1:

IL_0011:  ldloc.0 
IL_0012:  ldloc.1 
IL_0013:  ldc.i4.4 
IL_0014:  mul 
IL_0015:  add 
IL_0016:  ldc.r4 32768.
IL_001b:  stind.r4 // <----------- This one
IL_001c:  ldloc.1 
IL_001d:  ldc.i4.1 
IL_001e:  add 
IL_001f:  stloc.1 
IL_0020:  ldloc.1 
IL_0021:  ldc.i4 12500000
IL_0026:  blt IL_0011

TestMethod2:

IL_0012:  ldloc.0 
IL_0013:  ldloc.1 
IL_0014:  ldc.r4 32768.
IL_0019:  stelem.r4 // <----------- This one
IL_001a:  ldloc.1 
IL_001b:  ldc.i4.1 
IL_001c:  add 
IL_001d:  stloc.1 
IL_001e:  ldloc.1 
IL_001f:  ldc.i4 12500000
IL_0024:  blt IL_0012

Podemos verificar el uso de la instrucción y, lo que es más importante, la excepción que arrojan en la especificación ECMA :

stind.r4: Store value of type float32 into memory at address

Excepciones que arroja:

System.NullReferenceException

Y

stelem.r4: Replace array element at index with the float32 value on the stack.

Excepción que arroja:

System.NullReferenceException
System.IndexOutOfRangeException
System.ArrayTypeMismatchException

Como puede ver, stelemhace más trabajo en la verificación de rango de matriz y la verificación de tipo. Como el cuerpo del bucle hace poca cosa (solo asigna un valor), la sobrecarga de la verificación domina el tiempo de cálculo. Por eso, el rendimiento difiere en un 530%.

Y esto también responde a sus preguntas: el peligro es la ausencia de verificación de rango y tipo de matriz. Esto no es seguro (como se menciona en la declaración de función; D).

HKTonyLee
fuente
4

EDITAR: (un pequeño cambio en el código y en la medición produce un gran cambio en el resultado)

Primero ejecuté el código optimizado en el depurador (F5) pero eso estaba mal. Debe ejecutarse sin el depurador (Ctrl + F5). En segundo lugar, el código puede estar completamente optimizado, por lo que debemos complicarlo para que el optimizador no interfiera con nuestra medición. Hice que todos los métodos devolvieran un último elemento en la matriz, y la matriz se llena de manera diferente. También hay un cero adicional en los OP TestMethod2que siempre lo hace diez veces más lento.

Intenté algunos otros métodos, además de los dos que proporcionaste. El método 3 tiene el mismo código que el método 2, pero se declara la función unsafe. El método 4 está utilizando el acceso del puntero a la matriz creada regularmente. El método 5 está utilizando el acceso del puntero a la memoria no administrada, como lo describe Marc Gravell. Los cinco métodos se ejecutan en tiempos muy similares. M5 es el más rápido (y M1 es el segundo más cercano). La diferencia entre el más rápido y el más lento es de alrededor del 5%, que no es algo que me importe.

    public static unsafe float TestMethod3()
    {
        float[] samples = new float[5000000];

        for (var ii = 0; ii < 5000000; ii++)
        {
            samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
        }

        return samples[5000000 - 1];
    }

    public static unsafe float TestMethod4()
    {
        float[] prev = new float[5000000];
        fixed (float* samples = &prev[0])
        {
            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
    }

    public static unsafe float TestMethod5()
    {
        var ptr = Marshal.AllocHGlobal(5000000 * sizeof(float));
        try
        {
            float* samples = (float*)ptr;

            for (var ii = 0; ii < 5000000; ii++)
            {
                samples[ii] = 32768 + (ii != 0 ? samples[ii - 1] : 0);
            }

            return samples[5000000 - 1];
        }
        finally
        {
            Marshal.FreeHGlobal(ptr);
        }
    }
Dialéctico
fuente
¿Entonces M3 es lo mismo que M2 solo marcado con "inseguro"? Más bien sospechoso de que sería más rápido ... ¿estás seguro?
Roman Starkov
@romkyns Acabo de ejecutar un punto de referencia (M2 vs M3), y sorprendentemente M3 es en realidad 2.14% más rápido que M2.
Sam
" La conclusión es que no es necesario usar la pila " . Al asignar bloques grandes como los que di en mi publicación, estoy de acuerdo, pero, después de haber completado algunos puntos de referencia más M1 vs M2 (usando la idea de PFM para ambos métodos), ciertamente No estoy de acuerdo, ya que M1 es ahora un 135% más rápido que M2.
Sam
1
@Sam ¡Pero todavía está comparando el acceso del puntero al acceso a la matriz! ESO es principalmente lo que lo hace más rápido. TestMethod4vs TestMethod1es una comparación mucho mejor para stackalloc.
Roman Starkov
@romkyns Ah, sí, buen punto, me olvidé de eso; He vuelto a ejecutar los puntos de referencia , ahora solo hay una diferencia del 8% (M1 es el más rápido de los dos).
Sam