Quais são os perigos ao criar um encadeamento com um tamanho de pilha de 50x o padrão?

228

Atualmente, estou trabalhando em um programa muito crítico de desempenho e um caminho que decidi explorar que pode ajudar a reduzir o consumo de recursos estava aumentando o tamanho da pilha dos threads de trabalho, para que eu pudesse mover a maioria dos dados float[]que acessarei a pilha (usando stackalloc).

Eu li que o tamanho da pilha padrão para um thread é 1 MB, portanto, para mover todos os meus float[]s, eu teria que expandir a pilha aproximadamente 50 vezes (para 50 MB ~).

Entendo que isso geralmente é considerado "inseguro" e não é recomendado, mas depois de comparar meu código atual com esse método, descobri um aumento de 530% na velocidade de processamento! Portanto, não posso simplesmente passar por essa opção sem mais investigações, o que me leva à minha pergunta; Quais são os perigos associados ao aumento da pilha para um tamanho tão grande (o que pode dar errado) e que precauções devo tomar para minimizar esses perigos?

Meu código de teste

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
fonte
98
+1. Seriamente. Você pergunta o que parece uma pergunta idiota fora da norma e, em seguida, argumenta MUITO bem que, em seu cenário específico, é uma coisa sensata a considerar, porque você fez sua lição de casa e mediu o resultado. Isso é MUITO bom - sinto falta disso com muitas perguntas. Muito bom - bom você considerar algo assim, infelizmente muitos programadores de C # não estão cientes dessas oportunidades de otimização. Sim, muitas vezes não é necessário - mas às vezes é crítico e faz muita diferença.
TomTom
5
Estou interessado em ver os dois códigos que têm 530% de diferença na velocidade de processamento, apenas por mover a matriz para a pilha. Isso simplesmente não parece certo.
Dialecticus
13
Antes de seguir esse caminho: você tentou usar Marshal.AllocHGlobal(não esqueça FreeHGlobaltambém) de alocar os dados fora da memória gerenciada? Em seguida, converta o ponteiro para a float*e você deve ser classificado.
Marc Gravell
2
Parece certo se você fizer muitas alocações. O Stackalloc ignora todos os problemas do GC, que também podem criar / criar uma localidade muito forte no nível do processador. Esta é uma das coisas olhar chapéu como micro otimizações - a menos que você escrever um programa matemático de alto desempenho e estão tendo exatamente esse comportamento e fazer a diferença;)
TomTom
6
Minha suspeita: um desses métodos aciona a verificação de limites em cada iteração de loop enquanto o outro não, ou é otimizado.
precisa saber é

Respostas:

45

Ao comparar o código de teste com Sam, concluí que ambos estamos certos!
No entanto, sobre coisas diferentes:

  • O acesso à memória (leitura e gravação) é tão rápido onde quer que esteja - pilha, global ou pilha.
  • Alocá- lo, no entanto, é mais rápido na pilha e mais lento na pilha.

É assim: stack< global< heap. (tempo de alocação)
Tecnicamente, a alocação de pilha não é realmente uma alocação, o tempo de execução apenas garante que uma parte da pilha (quadro?) seja reservada para a matriz.

Eu recomendo fortemente ter cuidado com isso, no entanto.
Eu recomendo o seguinte:

  1. Quando você precisa criar matrizes com freqüência que nunca deixam a função (por exemplo, passando sua referência), o uso da pilha será uma enorme melhoria.
  2. Se você pode reciclar uma matriz, faça-o sempre que puder! A pilha é o melhor local para armazenamento de objetos a longo prazo. (poluir a memória global não é bom; os quadros de pilha podem desaparecer)

( Nota : 1. aplica-se apenas a tipos de valor; os tipos de referência serão alocados na pilha e o benefício será reduzido para 0)

Para responder à pergunta em si: Não encontrei nenhum problema em nenhum teste de pilha grande.
Acredito que os únicos problemas possíveis são um estouro de pilha, se você não tomar cuidado com suas chamadas de função e ficar sem memória ao criar seu (s) encadeamento (s) se o sistema estiver com pouca carga.

A seção abaixo é a minha resposta inicial. É errado e os testes não estão corretos. É mantido apenas para referência.


Meu teste indica que a memória alocada à pilha e a memória global são pelo menos 15% mais lentas do que (leva 120% do tempo) a memória alocada à pilha para uso em matrizes!

Este é o meu código de teste e é uma amostra de saída:

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.

Testei no Windows 8.1 Pro (com Atualização 1), usando um i7 4700 MQ, no .NET 4.5.1
. Testei com x86 e x64 e os resultados são idênticos.

Edit : aumentei o tamanho da pilha de todos os threads em 201 MB, o tamanho da amostra para 50 milhões e diminuímos as iterações para 5.
Os resultados são os mesmos que acima :

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.

No entanto, parece que a pilha está realmente ficando mais lenta .

Vercas
fonte
Eu teria que discordar, de acordo com os resultados do meu benchmark (veja o comentário na parte inferior da página para obter resultados) mostra que a pilha é marginalmente mais rápida que a global e muito mais rápida que a pilha; e, para ter certeza absoluta de que meus resultados são precisos, executei o teste 20 vezes e cada método foi chamado 100 vezes por iteração de teste. Você definitivamente está executando seu benchmark corretamente?
Sam
Estou obtendo resultados muito inconsistentes. Com confiança total, x64, release config, sem depurador, todos são igualmente rápidos (menos de 1% de diferença; flutuante), enquanto o seu é realmente muito mais rápido com uma pilha. Eu preciso testar mais! Edit : Seu DEVE lançar uma exceção de estouro de pilha. Você apenas aloca o suficiente para a matriz. O_o
Vercas
Sim, eu sei, está perto. Você precisa repetir os benchmarks algumas vezes, como eu fiz, talvez tente fazer uma média acima de 5 corridas.
Sam
1
@Voo A primeira corrida levou tanto tempo quanto a 100ª corrida de qualquer teste para mim. Pela minha experiência, essa coisa do Java JIT não se aplica ao .NET. O único "aquecimento" que o .NET faz é carregar classes e assemblies quando usado pela primeira vez.
Vercas
2
@ Teste o meu benchmark e o da essência que ele adicionou em um comentário a esta resposta. Monte os códigos juntos e execute algumas centenas de testes. Então volte e relate sua conclusão. Eu fiz meus testes muito bem e sei muito bem do que estou falando quando digo que o .NET não interpreta nenhum bytecode como o Java, mas o faz instantaneamente.
Vercas
28

Eu descobri um aumento de 530% na velocidade de processamento!

Esse é de longe o maior perigo que eu diria. Há algo seriamente errado com o seu benchmark, o código que se comporta de maneira imprevisível geralmente tem um bug desagradável escondido em algum lugar.

É muito, muito difícil consumir muito espaço de pilha em um programa .NET, exceto por recursão excessiva. O tamanho do quadro de pilha dos métodos gerenciados é definido em pedra. Simplesmente a soma dos argumentos do método e as variáveis ​​locais em um método. Menos os que podem ser armazenados em um registro da CPU, você pode ignorá-lo, pois existem muito poucos.

Aumentar o tamanho da pilha não faz nada, você apenas reserva um monte de espaço de endereço que nunca será usado. Não há mecanismo que possa explicar um aumento de desempenho por não usar a memória, é claro.

Isso é diferente de um programa nativo, especialmente um escrito em C, mas também pode reservar espaço para matrizes no quadro da pilha. O vetor básico de ataque de malware por trás dos estouros do buffer da pilha. Possível também em C #, você teria que usar a stackallocpalavra - chave. Se você estiver fazendo isso, o perigo óbvio é ter que escrever código inseguro que esteja sujeito a esses ataques, além de corrupção aleatória no quadro da pilha. Muito difícil de diagnosticar bugs. Há uma contramedida contra isso em instâncias posteriores, acho que a partir do .NET 4.0, onde a instabilidade gera código para colocar um "cookie" no quadro da pilha e verifica se ainda está intacta quando o método retorna. Falha instantânea na área de trabalho sem nenhuma maneira de interceptar ou relatar o acidente, se isso acontecer. Isso é ... perigoso para o estado mental do usuário.

O segmento principal do seu programa, o iniciado pelo sistema operacional, terá uma pilha de 1 MB por padrão, 4 MB quando você compilar o programa visando x64. Aumentar isso requer a execução de Editbin.exe com a opção / STACK em um evento pós-compilação. Normalmente, você pode solicitar até 500 MB antes que o programa tenha problemas para iniciar a execução no modo de 32 bits. Os encadeamentos também podem, muito mais fácil, é claro, a zona de perigo normalmente fica em torno de 90 MB para um programa de 32 bits. Disparado quando o programa está em execução há muito tempo e o espaço de endereço foi fragmentado das alocações anteriores. O uso total do espaço de endereço já deve estar alto, durante um show, para obter esse modo de falha.

Verifique seu código três vezes, há algo muito errado. Você não pode obter uma aceleração x5 com uma pilha maior, a menos que escreva explicitamente seu código para tirar proveito dele. O que sempre exige código não seguro. O uso de ponteiros em C # sempre tem um talento especial para criar código mais rápido, não sendo sujeito às verificações de limites da matriz.

Hans Passant
fonte
21
A aceleração de 5x relatada foi de passar de float[]para float*. A pilha grande era simplesmente como isso foi realizado. Uma aceleração de x5 em alguns cenários é totalmente razoável para essa alteração.
Marc Gravell
3
Ok, eu ainda não tinha o snippet de código quando comecei a responder à pergunta. Ainda perto o suficiente.
Hans Passant
22

Eu teria uma reserva lá que simplesmente não saberia como prever - permissões, GC (que precisa varrer a pilha) etc. - tudo poderia ser afetado. Eu ficaria muito tentado a usar memória não gerenciada:

var ptr = Marshal.AllocHGlobal(sizeBytes);
try
{
    float* x = (float*)ptr;
    DoWork(x);
}
finally
{
    Marshal.FreeHGlobal(ptr);
}
Marc Gravell
fonte
1
Pergunta secundária: Por que o GC precisaria digitalizar a pilha? A memória alocada por stackallocnão está sujeita à coleta de lixo.
22414 dcastro
6
@dcastro, ele precisa varrer a pilha para verificar as referências que existem apenas na pilha. Simplesmente não sei o que vai fazer quando chegar a um tamanho tão grande stackalloc- é meio que necessário pular, e você esperaria que isso acontecesse sem esforço - mas o ponto que estou tentando fazer é que ele introduz complicações / preocupações desnecessárias . O IMO stackallocé ótimo como um buffer de arranhão, mas para um espaço de trabalho dedicado, é mais provável que aloque um pedaço de memória em algum lugar, em vez de abusar / confundir a pilha,
Marc Gravell
8

Uma coisa que pode dar errado é que você pode não ter permissão para fazê-lo. A menos que seja executado no modo de confiança total, o Framework apenas ignorará a solicitação de um tamanho de pilha maior (consulte o MSDN Thread Constructor (ParameterizedThreadStart, Int32))

Em vez de aumentar o tamanho da pilha do sistema para números tão grandes, sugiro reescrever seu código para que ele use a Iteração e uma implementação manual da pilha no heap.

PMF
fonte
1
Boa idéia, eu vou percorrer. Além disso, meu código está sendo executado no modo de confiança total. Há outras coisas que devo procurar?
Sam
6

As matrizes de alto desempenho podem estar acessíveis da mesma maneira que uma C # normal, mas isso pode ser o começo de um problema: Considere o seguinte código:

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

Você espera uma exceção fora do limite e isso faz todo o sentido, porque você está tentando acessar o elemento 200, mas o valor máximo permitido é 99. Se você for para a rota stackalloc, não haverá nenhum objeto envolvido em sua matriz para verificar o limite e o valor a seguir não mostrará nenhuma exceção:

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

Acima, você está alocando memória suficiente para armazenar 100 carros alegóricos e está definindo o local da memória sizeof (float) que inicia no local iniciado dessa memória + 200 * sizeof (float) para manter o valor de flutuação 10. Sem surpresa, essa memória está fora do memória alocada para os carros alegóricos e ninguém saberia o que poderia ser armazenado nesse endereço. Se você tiver sorte, poderá ter usado alguma memória atualmente não utilizada, mas, ao mesmo tempo, é provável que você substitua algum local usado para armazenar outras variáveis. Para resumir: Comportamento imprevisível em tempo de execução.

MHOOS
fonte
Factualmente errado. Os testes de tempo de execução e compilador ainda estão lá.
TomTom
9
@ TomTom erm, não; a resposta tem mérito; a pergunta fala stackalloc, caso em que estamos falando float*etc - que não tem as mesmas verificações. É chamado unsafepor uma boa razão. Pessoalmente, estou perfeitamente feliz em usar unsafequando há uma boa razão, mas Sócrates faz alguns pontos razoáveis.
Marc Gravell
@ Marc Para o código mostrado (após a execução do JIT), não há mais verificações de limites, pois é trivial para o compilador concluir que todos os acessos são dentro dos limites. Em geral, isso certamente pode fazer a diferença.
Voo
6

Linguagens de microbenchmarking com JIT e GC, como Java ou C #, podem ser um pouco complicadas, por isso geralmente é uma boa ideia usar uma estrutura existente - Java oferece mhf ou Caliper que são excelentes, infelizmente, pelo que sei, o C # não oferece qualquer coisa se aproximando deles Jon Skeet escreveu isso aqui, que assumirei cegamente que cuida das coisas mais importantes (Jon sabe o que está fazendo nessa área; também sim, não se preocupe, eu realmente verifiquei). Ajustei um pouco o tempo, porque 30 segundos por teste após o aquecimento eram demais para minha paciência (5 segundos deveriam fazer).

Então, primeiro os resultados, .NET 4.5.1 no Windows 7 x64 - os números indicam as iterações que poderiam ser executadas em 5 segundos, para que quanto maior, melhor.

JIT x64:

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 (sim, isso ainda é meio 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)

Isso proporciona uma aceleração muito mais razoável de no máximo 14% (e a maior parte da sobrecarga se deve ao fato de o GC ter que ser executado, considere o pior cenário realista). Os resultados do x86 são interessantes - ainda não está claro o que está acontecendo lá.

e aqui está o 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
fonte
Uma observação interessante, vou ter que verificar meus benchmarks novamente. Embora isso ainda não responda à minha pergunta, " ... quais são os perigos associados ao aumento da pilha para um tamanho tão grande ... ". Mesmo que meus resultados estejam incorretos, a pergunta ainda é válida; Agradeço o esforço, no entanto.
Sam
1
@ Sam Ao usar 12500000como tamanho, recebo uma exceção de stackoverflow. Mas, principalmente, era sobre rejeitar a premissa subjacente de que o uso de código alocado por pilha é várias ordens de magnitudes mais rapidamente. Estamos fazendo praticamente a menor quantidade de trabalho possível aqui, caso contrário, e a diferença já é de apenas 10 a 15% - na prática, será ainda menor .. isso, na minha opinião, definitivamente muda toda a discussão.
Voo
5

Como a diferença de desempenho é muito grande, o problema mal está relacionado à alocação. Provavelmente, é causado pelo acesso à matriz.

Desmontei o corpo do loop das funções:

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 o uso das instruções e, mais importante, a exceção que elas lançam nas especificações da ECMA :

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

Exceções que lança:

System.NullReferenceException

E

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

Exceção que lança:

System.NullReferenceException
System.IndexOutOfRangeException
System.ArrayTypeMismatchException

Como você pode ver, stelemtrabalha mais na verificação de intervalo de matriz e verificação de tipo. Como o corpo do loop faz pouca coisa (apenas atribui valor), a sobrecarga da verificação domina o tempo de computação. É por isso que o desempenho difere em 530%.

E isso também responde às suas perguntas: o perigo é a ausência de verificação de alcance e tipo de matriz. Isso não é seguro (como mencionado na declaração da função; D).

HKTonyLee
fonte
4

EDIT: (pequena alteração no código e na medição produz uma grande alteração no resultado)

Em primeiro lugar, executei o código otimizado no depurador (F5), mas isso estava errado. Ele deve ser executado sem o depurador (Ctrl + F5). Segundo, o código pode ser completamente otimizado, portanto, devemos complicá-lo para que o otimizador não mexa com a nossa medição. Eu fiz todos os métodos retornarem um último item na matriz, e a matriz é preenchida de maneira diferente. Também há um zero extra nos OPs, TestMethod2que sempre o tornam dez vezes mais lento.

Eu tentei alguns outros métodos, além dos dois que você forneceu. O método 3 tem o mesmo código que o método 2, mas a função é declarada unsafe. O método 4 está usando o acesso do ponteiro à matriz criada regularmente. O método 5 está usando o acesso do ponteiro à memória não gerenciada, conforme descrito por Marc Gravell. Todos os cinco métodos são executados em tempos muito semelhantes. M5 é o mais rápido (e M1 fica em segundo). A diferença entre o mais rápido e o mais lento é de cerca de 5%, o que não é 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);
        }
    }
Dialecticus
fonte
Então M3 é o mesmo que M2 marcado apenas com "inseguro"? Suspeito de que seria mais rápido ... você tem certeza?
Roman Starkov 13/06
@romkyns Acabei de executar uma referência (M2 vs M3), e surpreendentemente o M3 é na verdade 2,14% mais rápido que o M2.
Sam
" A conclusão é que não é necessário usar a pilha " . Ao alocar blocos grandes, como citei no meu post, concordo, mas, depois de ter completado mais alguns benchmarks M1 vs M2 (usando a idéia de PFM para ambos os métodos), certamente tem que discordar, já que o M1 agora é 135% mais rápido que o M2.
Sam
1
@ Sam Mas você ainda está comparando o acesso do ponteiro ao acesso da matriz! QUE é primarly o que o torna mais rápido. TestMethod4vs TestMethod1é uma comparação muito melhor para stackalloc.
Roman Starkov 13/06
@romkyns Ah sim, bom ponto, eu esqueci disso; Reparei os benchmarks , agora há apenas uma diferença de 8% (o M1 é o mais rápido dos dois).
Sam