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;
}
}
fonte
Marshal.AllocHGlobal
(não esqueçaFreeHGlobal
também) de alocar os dados fora da memória gerenciada? Em seguida, converta o ponteiro para afloat*
e você deve ser classificado.Respostas:
Ao comparar o código de teste com Sam, concluí que ambos estamos certos!
No entanto, sobre coisas diferentes:
É 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:
( 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:
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 :
No entanto, parece que a pilha está realmente ficando mais lenta .
fonte
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
stackalloc
palavra - 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.
fonte
float[]
parafloat*
. 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.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:
fonte
stackalloc
não está sujeita à coleta de lixo.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 IMOstackalloc
é ó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,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.
fonte
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:
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:
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.
fonte
stackalloc
, caso em que estamos falandofloat*
etc - que não tem as mesmas verificações. É chamadounsafe
por uma boa razão. Pessoalmente, estou perfeitamente feliz em usarunsafe
quando há uma boa razão, mas Sócrates faz alguns pontos razoáveis.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:
x86 JIT (sim, isso ainda é meio triste):
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:
fonte
12500000
como 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.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:
TestMethod2:
Podemos verificar o uso das instruções e, mais importante, a exceção que elas lançam nas especificações da ECMA :
Exceções que lança:
E
Exceção que lança:
Como você pode ver,
stelem
trabalha 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).
fonte
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,
TestMethod2
que 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.fonte
TestMethod4
vsTestMethod1
é uma comparação muito melhor parastackalloc
.