c# thread - ¿Cuáles son los peligros al crear un hilo con un tamaño de pila de 50 veces el valor predeterminado?




sistemas creacion (8)

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

He read que el tamaño de pila predeterminado para un hilo es de 1 MB, por lo que para mover todo mi float[] s 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! Así que no puedo simplemente pasar por 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;
    }
}

Answers

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 volver a escribir su código para que use Iteración y una implementación manual de la pila en el montón.


Los arreglos de alto rendimiento pueden ser accesibles de la misma manera que uno normal de C #, pero ese 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á tratando de 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 la verificación del límite y la Lo siguiente no mostrará ninguna excepción:

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

Arriba, está asignando suficiente memoria para contener 100 flotadores y está configurando la ubicación de memoria sizeof (float) que comienza en el lugar donde comenzó esta memoria + 200 * sizeof (float) para mantener su valor flotante 10. Como era de esperar, esta memoria está fuera del memoria asignada para los flotadores y nadie sabría qué 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 sobrescriba alguna ubicación que se utilizó para almacenar otras variables. Para resumir: comportamiento impredecible en tiempo de ejecución.


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

En primer lugar 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 altere nuestras mediciones. Hice que todos los métodos devuelvan un último elemento de la matriz, y la matriz se rellena de forma diferente. También hay un cero adicional en el TestMethod2 de OP que 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 la función se declara unsafe . El método 4 está usando el acceso de puntero a la matriz creada regularmente. El método 5 utiliza el acceso de 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 está cerca en segundo lugar). La diferencia entre el más rápido y el más lento es alrededor del 5%, lo cual 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);
        }
    }

Tendría una reserva allí que simplemente no sabría cómo predecir (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);
}

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 incluyen en las especificaciones de ECMA :

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

Excepciones que lanza:

System.NullReferenceException

Y

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

Excepción que lanza:

System.NullReferenceException
System.IndexOutOfRangeException
System.ArrayTypeMismatchException

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

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


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

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

Funciona 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 simplemente se asegura de que una parte de la pila (frame?) Esté reservada para la matriz.

Sin embargo, recomiendo encarecidamente 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), el uso de la pila será una mejora enorme.
  2. Si puedes reciclar una matriz, ¡hazlo siempre que puedas! El montón es el mejor lugar para el almacenamiento de objetos a largo plazo. (La memoria global contaminante no es agradable; los cuadros 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 pila grande.
Creo que los únicos problemas posibles son un desbordamiento de pila, si no tiene cuidado con sus llamadas de función y se está quedando 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 mantiene 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 120% el 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, bajo .NET 4.5.1
He probado ambos con x86 y x64 y los resultados son idénticos.

Edición : aumenté el tamaño de pila de todos los subprocesos 201 MB, el tamaño de la muestra a 50 millones y reduje 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 .


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

Ese es, con mucho, el mayor peligro que diría. Hay algo seriamente mal con su punto de referencia, el código que se comporta de forma impredecible generalmente tiene un error desagradable oculto en algún lugar.

Es muy, muy difícil consumir una gran cantidad de espacio de pila en un programa .NET, aparte de una recursión excesiva. El tamaño del marco de pila de los métodos gestionados 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 arreglos en el marco de la pila. El vector básico de ataque de malware detrás de la pila se desborda. Posible también en C #, tendría que usar la palabra clave stackalloc . Si está haciendo eso, entonces el peligro obvio es tener que escribir código inseguro que esté sujeto a tales ataques, así como daños aleatorios en el marco de la pila. Muy difícil de diagnosticar errores. Hay una contramedida contra esto en temores posteriores, creo que comenzando en .NET 4.0, donde el temblor genera un 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 reportar el percance si eso sucede. Eso es ... peligroso para el estado mental del usuario.

El subproceso principal de su programa, el iniciado por el sistema operativo, tendrá una pila de 1 MB por defecto, 4 MB cuando compile su programa dirigido a x64. Aumentar eso 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 hilos también pueden, mucho más fácil, la zona de peligro suele rondar los 90 MB para un programa de 32 bits. Se activó cuando su programa se ha estado ejecutando durante mucho tiempo y el espacio de direcciones se fragmentó de las asignaciones anteriores. El uso total del espacio de direcciones ya debe ser alto, durante un concierto, para obtener este modo de falla.

Revisa tres veces tu código, hay algo muy mal. No puedes obtener una aceleración x5 con una pila más grande a menos que escribas explícitamente tu código para aprovecharla. Que siempre requiere código inseguro. El uso de punteros en C # siempre tiene la habilidad de crear un código más rápido, no está sujeto a las comprobaciones de los límites de la matriz.


Si solo desea saber cuánta memoria se está utilizando en su JVM y cuánta es gratuita, puede probar algo como esto:

// Get current size of heap in bytes
long heapSize = Runtime.getRuntime().totalMemory();

// Get maximum size of heap in bytes. The heap cannot grow beyond this size.
// Any attempt will result in an OutOfMemoryException.
long heapMaxSize = Runtime.getRuntime().maxMemory();

// Get amount of free memory within the heap in bytes. This size will increase
// after garbage collection and decrease as new objects are created.
long heapFreeSize = Runtime.getRuntime().freeMemory();

Edición: pensé que esto podría ser útil ya que la pregunta del autor también dijo que le gustaría tener una lógica que maneje "leer tantas filas como sea posible hasta que haya usado 32 MB de memoria".





c# .net memory stack-memory