Otimização e performance extrema em .NET com o uso do stackalloc para diminuir alocações no Gargage Coletor e evitar pausas na aplicação.
Resumo do Peso Cubado e stackalloc
Peso cubado é o cálculo que decide quanto você paga de frete. Além disso, ele roda a cada renderização de carrinho e a cada cotação.
Este artigo parte da consolidação de um pedido em caixas, uma operação que exige um buffer de trabalho.
Ela serve para responder uma pergunta comum. Quase todo artigo sobre stackalloc responde errado: o que exatamente você economiza ao alocar na stack.
Insights
- Obter memória na stack com
stackalloccusta quase nada, e o custo quase não acompanha o tamanho. Um bloco de 2 KiB com tamanho literal custa 3,24 ns para reservar, quando o compilador não precisa preenchê-lo com zeros. Esse número já inclui duas chamadas não-inlinadas e duas escritas. Entre 256 B e 8 KiB (32 vezes de tamanho), esse custo sobe apenas 1,54 ns, comparando medições da mesma família, sem depender do método de referência. - O que você paga não é alocar, é inicializar. O C# emite
.locals initpor padrão, e essa marca obriga o runtime a gravar zero em cada byte do bloco antes do seu código encostar nele. Num bloco de 2 KiB são 2048 bytes escritos que você não pediu. Chamo isso de zeragem no artigo inteiro. Medi dos dois jeitos, sem descontar o custo do instrumento. Um bloco de 8 KiB literal custa 4,93 ns sem zeragem e 97,56 ns com ela. São vinte vezes mais. Na mesma base, o mesmo bloco com tamanho variável vai de 6,03 para 303,64 ns, cinquenta vezes. Quem cobra é o.locals init, não ostackalloc. - Tamanho literal e tamanho variável zeram por caminhos diferentes de codegen, e a diferença é de 2,3x a 3,2x. Com uma ressalva: no caminho variável, o mesmo laço zera e sonda ao mesmo tempo. Ali “zeragem” é rótulo aproximado. Li o código gerado em vez de supor. Com tamanho desconhecido, o RyuJIT emite um laço de
push, 16 bytes por iteração. Com 2 KiB literal, emite uma chamada aCORINFO_HELP_MEMZERO. Com 256 B literal, oito stores SIMD desenrolados. Trocarstackalloc double[n]por um teto literal muda o codegen, não só a segurança. - A alocação vai a zero e esse número não tem ruído. Onde o newton cobra de 40 a 8.216 bytes por chamada, os três benchmarks não registram um único byte na coluna
Allocated. É o único resultado que não depende de resolução temporal, e o único que transfere para a sua máquina. - O discriminante é zerar ou não zerar, e eu já errei esse eixo uma vez. Em 8 KiB: ligar a zeragem move o custo de 4,9 para 97,6 ns, vinte vezes. Trocar literal por variável, sem zeragem, move de 4,9 para 6,0 ns. São 1,2 vez, e nem isso o benchmark resolve. O tamanho ser literal só pesa se você zera, e aí vale de 2,3x a 3,2x. É a zeragem que decide; o resto é segunda ordem.
Análise de desempenho
São três benchmarks em .NET 10, com código e artefatos reproduzíveis. O primeiro compara stackalloc, newton e ArrayPool com o trabalho reduzido ao mínimo. Ele entrega a razão de 0,415. Também apresenta o tamanho a partir do qual o pool vence. Além disso, essa razão mede justamente a forma de alocação que não recomendo.
O segundo é um fatorial 2×2 sobre quatro tamanhos. Cada um dos dezessete métodos medidos tem uma cópia byte a byte, e um par de referência isola o custo do próprio instrumento. É ele que responde a pergunta.
Um bloco de 2 KiB com tamanho literal custa 3,24 ns sem zeragem e 36,08 ns com ela. A zeragem de tamanho variável é de 2,3 a 3,2 vezes mais lenta que a de tamanho literal. O motivo são quatro caminhos de codegen diferentes no RyuJIT, que eu li em vez de supor. O terceiro usa a operação completa e entrega o contrapeso: ali o ganho se dilui e três cópias byte a byte do mesmo método discordam em 28,1%.
O artigo traz ainda mais cinco coisas. O ponto exato em que a stack quebra e mata o processo sem log de aplicação. Por que stackalloc dentro de laço acumula. Quanto custa o teto fixo do padrão de produção. Quanta memória cada thread viva mantém comprometida por causa da stack, que é o custo que aparece na conta do contêiner. E uma lista do que a medição não permite concluir.
O cálculo que roda milhares de vezes por minuto
Transportadora que aplica cubagem não cobra pelo peso da balança. Cobra pelo peso faturado, que é o maior valor entre o peso real e o peso cubado. Peso cubado é o peso que aquele volume ocuparia no caminhão se tivesse densidade padrão.
|
1 2 |
peso cubado = (comprimento × largura × altura) ÷ fator de cubagem peso faturado = máximo(peso real, peso cubado) |
O fator de cubagem costuma ser tratado como constante universal. Não é. O valor 6000 vem da convenção de carga aérea: a IATA divide o volume em centímetros cúbicos por 6000 para obter o peso volumétrico. No transporte terrestre, pelo que eu vejo nos contratos que passam pela minha mesa, ele aparece na cláusula contratual da transportadora em vez de vir de norma. Essa segunda metade é observação minha, não afirmação com fonte.
Por ser contratual, o fator entra no código como parâmetro:
|
1 |
public readonly record struct CubageRule(double Factor, bool ApplyMinimumCubage)<br>{<br> /// <summary>Eixo abaixo do qual a cubagem minima passa a valer, em centimetros.</summary><br> public const double MinimumAxisCm = 10d;<br><br> /// <summary>Comprimento da cubagem minima, em centimetros.</summary><br> public const double MinimumLengthCm = 13d;<br><br> /// <summary>Largura da cubagem minima, em centimetros.</summary><br> public const double MinimumWidthCm = 8d;<br><br> /// <summary>Altura da cubagem minima, em centimetros.</summary><br> public const double MinimumHeightCm = 0.4d;<br><br> /// <summary><br> /// Fator aereo de 6000 com cubagem minima aplicada.<br> /// O fator vem da convencao de carga aerea e chega ao transporte terrestre como clausula<br> /// de contrato; a cubagem minima espelha o tratamento do cubometro na triagem dos Correios.<br> /// Sao duas regras de origens diferentes, combinadas aqui por conveniencia do exemplo.<br> /// </summary><br> public static CubageRule AirFactorWithMinimumCubage => |
|
1 |
(6000d, ApplyMinimumCubage: true);<br>} |
A cubagem mínima é a segunda regra, e é menos conhecida. A cláusula 11.1.6.2 do Termo de Condições Comerciais dos Correios trata da encomenda postada de forma automatizada. Se pelo menos um dos eixos fica abaixo de 10 cm, o cubômetro substitui as dimensões dela pela medida fixa de 13 x 8 x 0,4 cm. Não é piso por eixo, e sim substituição do conjunto inteiro.
Um detalhe da mesma cláusula costuma escapar. O objeto é inicialmente tarifado pelo peso real. Depois, o preço pode ser corrigido pela cubagem aferida, conforme o subitem 11.1.6. Quem modela isso como decisão final erra a conciliação da fatura.
O núcleo cabe em um método:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
public static double BillableWeightKg( double lengthCm, double widthCm, double heightCm, double actualWeightKg, in CubageRule rule) { if (rule.ApplyMinimumCubage && (lengthCm < CubageRule.MinimumAxisCm || widthCm < CubageRule.MinimumAxisCm || heightCm < CubageRule.MinimumAxisCm)) { lengthCm = CubageRule.MinimumLengthCm; widthCm = CubageRule.MinimumWidthCm; heightCm = CubageRule.MinimumHeightCm; } double cubedWeightKg = lengthCm * widthCm * heightCm / rule.Factor; return Math.Max(actualWeightKg, cubedWeightKg); } |
Duas multiplicações, uma divisão, uma comparação. É trivial, e é por isso que é interessante. Código trivial em volume alto não aparece como hotspot de CPU no perfil; aparece como pressão de alocação, que é exatamente a pressão que o stackalloc remove.
A operação que exige um buffer de trabalho
O peso de cada item isolado não decide nada: o que pesa na conta é volume despachado, ou seja, em quantas caixas o pedido cabe. O algoritmo clássico é o first-fit-decreasing, que ordena os itens do mais pesado para o mais leve e coloca cada um na primeira caixa que ainda o comporta.
Uma ressalva antes de seguir, porque o público aqui trabalha com isso. Tudo o que vem depois mede o custo de obter esse buffer de trabalho com stackalloc, e não o cálculo em si. O consolidador deste sample soma pesos faturados contra uma capacidade de caixa. Ele não é um modelo fiel de tarifação: a cubagem real de uma caixa vem das dimensões externas dela, não da soma das cubagens dos itens dentro. Escolhi essa carga por ser plausível e determinística, e por duas razões de método. Ela exige o buffer, porque ordenar obriga a materializar e não existe versão de uma passada. E ela devolve um escalar, então não há array de saída fora da medição.
E uma segunda ressalva, mais dura que a primeira, porque delimita o que o artigo pode afirmar. Dos três benchmarks, só a terceira executa este domínio. As duas primeiras reduzem o trabalho a escrever e ler duas posições do buffer. Não calculam cubagem, não empacotam caixa, não tocam em peso. Isso é deliberado: é a única forma de isolar o custo do buffer do custo do trabalho. Mas a consequência precisa ser dita. As conclusões quantitativas deste artigo são sobre a operação de obter um bloco de memória, não sobre cálculo de frete. O domínio está aqui porque é onde eu encontrei o problema e porque ele determina o formato e o tamanho do buffer. E qualquer carga que exija um scratch de 2N posições produziria as mesmas tabelas.
flowchart LR
A["Pedido<br/>N itens"] --> B["Peso faturado<br/>de cada item"]
B --> C[("Buffer de trabalho<br/>2N valores")]
C --> D["Ordenação<br/>decrescente"]
D --> E["First-fit<br/>por caixa"]
E --> F["Quantidade<br/>de caixas"]
style C fill:#1f3a5f,stroke:#4a90d9,color:#fffO buffer é um Span<T> único, com 2N posições: a primeira metade guarda os pesos faturados, a segunda guarda a capacidade livre de cada caixa aberta. Uma única aquisição por chamada, e é ela que os benchmarks comparam.
O desenho dos benchmarks
Versões anteriores deste sample foram derrubadas em revisão, sempre pelo mesmo motivo: eu comparava métodos que diferiam em mais de uma coisa. O desenho atual segue sete regras, e cada uma fecha uma objeção específica que derrubou uma versão anterior.
Antes das regras, duas palavras que aparecem daqui até o fim. Gêmeo é uma cópia byte a byte de um método medido, com outro nome, que roda na mesma execução. As duas fazem exatamente a mesma coisa. Então qualquer diferença entre elas não pode ser efeito de nada: é erro do instrumento. Piso de ruído é essa diferença. Ele é o critério de corte deste artigo: nenhum número menor que o piso do próprio ponto vira conclusão.
- Um único corpo de trabalho. Todos os métodos medidos chamam o mesmo corpo de trabalho, marcado com
[MethodImpl(MethodImplOptions.NoInlining)]. O trabalho executa a partir de um único método compilado, com as mesmas instruções. - Inlinabilidade igualada. Todos os métodos de benchmark também carregam
NoInlining. Sem isso o desenho tem um fator escondido que confunde a comparação, e ele é assimétrico. O RyuJIT tratalocallocde tamanho desconhecido elocallocem laço como observações fatais para inlining. As duas estão eminline.defcomoLOCALLOC_SIZE_UNKNOWNeLOCALLOC_IN_LOOP, marcadasFATAL. Sem a marcação, os métodos de tamanho variável jamais seriam inlinados. Os de tamanho literal e os que usam newton não têm essa barreira. Aí o inlining viraria uma segunda variável escondida dentro da comparação. Marcando todos, nenhum é. O mecanismo de inlining do RyuJIT está detalhado no artigo sobre a análise do CA1859 com IL e Assembly. - Todo método tem gêmeo byte a byte idêntico. Não um par de controle por benchmark, e sim um por método. A dispersão dentro de cada par é o piso de ruído daquele ponto, medido na mesma execução e no mesmo relógio. O piso deixa de ser argumento e vira linha da tabela.
- Método de referência. Ele faz exatamente o mesmo trabalho sobre um buffer que já existe, sem
stackallocnenhum. Sem ele não há como separar o custo de obter o bloco do custo do próprio instrumento de medição. Duas chamadas não-inlinadas e duas escritas em memória já custam alguma coisa sozinhas. - Ordem de declaração embaralhada, com distância constante entre gêmeos. A ordem dos métodos originais não acompanha tamanho nem variante. O gêmeo de cada método fica 17 posições adiante do original, sempre. Se houver deriva de posição, ela aparece com o mesmo sinal nos dezessete pares, e aí a diferença entre gêmeos mede deriva, não ruído aleatório. Os dois casos são distinguíveis no resultado, e é isso que se quer saber.
- Retorno escalar. Nenhum dos três benchmarks tem buffer de saída fora da medição.
- Piso medido depois do descarte padrão. O BenchmarkDotNet remove outliers pela configuração padrão do job, e é sobre a série já filtrada que eu calculo a discordância entre gêmeos. Isso torna o piso publicado otimista: ele mede a dispersão do que sobrou, não do que aconteceu.
- Sem aritmética de ciclos. Nanossegundos e bytes. A frequência efetiva deste processador móvel não foi medida, e conta em bytes por ciclo sem frequência medida é chute apresentado como física.
Some-se a isso um job fixo e o DATAS desligado:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
public sealed class FixedJobDatasOff : ManualConfig { public FixedJobDatasOff() { AddJob(Job.Default .WithGcServer(true) .WithGcConcurrent(true) .WithWarmupCount(10) .WithIterationCount(30) .WithEnvironmentVariable("DOTNET_GCDynamicAdaptationMode", "0")); } } |
O DATAS (Dynamic Adaptação To Aplicação Sizes) vem ligado por padrão desde o .NET 9 e afeta o Server GC. Ele parte de um heap e ajusta o orçamento de gen0 conforme o tamanho do dado de vida longa. Com isso, a leitura das colunas de coleta passa a depender do histórico da execução. Desligar é decisão de medição, não recomendação de produção. A escolha entre Server GC e Workstation GC muda a frequência de gen0 e, com ela, o custo amortizado do método que usa newton. É mais uma razão para a razão medida aqui não transferir direto para o seu ambiente.
Benchmark 1 mede quanto custa chegar a um buffer utilizável
O primeiro benchmark compara as três formas de conseguir um buffer de N doubles: newton, stackalloc e ArrayPool. O trabalho é reduzido ao mínimo: um método Touch que escreve e lê duas posições. Assim a diferença entre eles é o custo de chegar a um buffer utilizável, e não o custo de usá-lo.
Repare no nome da seção, porque ele importa. Isto não mede aquisição pura. Mede aquisição mais a zeragem. Zeragem é como eu chamo o seguinte. O C# emite .locals init por padrão. Essa marca obriga o runtime a gravar zero em cada byte do bloco, antes do seu código encostar nele. Num bloco de 2 KiB, são 2048 bytes escritos que você não pediu. As duas parcelas vieram juntas aqui porque é isso que acontece quando você escreve stackalloc double[n] num projeto normal. Separar as duas parcelas é trabalho do benchmark 2.
|
1 2 3 4 5 6 7 8 |
BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9168/25H2/2025Update/HudsonValley2) Intel Core i7-8665U CPU 1.90GHz (Max: 2.11GHz) (Coffee Lake), 1 CPU, 8 logical and 4 physical cores .NET SDK 10.0.101 [Host] : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v3 Job-GPKRFX : .NET 10.0.1 (10.0.1, 10.0.125.57005), X64 RyuJIT x86-64-v3 EnvironmentVariables=DOTNET_GCDynamicAdaptationMode=0 Concurrent=True Server=True IterationCount=30 WarmupCount=10 |
| Method | Items | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| HeapOnly | 1 | 8.089 ns | 0.1709 ns | 0.2505 ns | 1.00 | 0.04 | 0.0010 | 40 B | 1.00 |
| StackOnly | 1 | 3.808 ns | 0.0857 ns | 0.1283 ns | 0.47 | 0.02 | – | – | 0.00 |
| PoolOnly | 1 | 14.065 ns | 0.3130 ns | 0.4685 ns | 1.74 | 0.08 | – | – | 0.00 |
| HeapOnlyControl | 1 | 8.623 ns | 0.2313 ns | 0.3242 ns | 1.07 | 0.05 | 0.0010 | 40 B | 1.00 |
| StackOnlyControl | 1 | 5.028 ns | 0.5701 ns | 0.8533 ns | 0.62 | 0.11 | – | – | 0.00 |
| FixedCapOnly | 1 | 45.742 ns | 1.5013 ns | 2.1531 ns | 5.66 | 0.31 | – | – | 0.00 |
| FixedCapOnlyNoInit | 1 | 3.041 ns | 0.3756 ns | 0.5622 ns | 0.38 | 0.07 | – | – | 0.00 |
| HeapOnly | 4 | 12.456 ns | 0.2992 ns | 0.4479 ns | 1.00 | 0.05 | 0.0022 | 88 B | 1.00 |
| StackOnly | 4 | 5.895 ns | 0.1375 ns | 0.2058 ns | 0.47 | 0.02 | – | – | 0.00 |
| PoolOnly | 4 | 13.646 ns | 0.2146 ns | 0.3146 ns | 1.10 | 0.05 | – | – | 0.00 |
| HeapOnlyControl | 4 | 15.049 ns | 0.8972 ns | 1.3429 ns | 1.21 | 0.11 | 0.0022 | 88 B | 1.00 |
| StackOnlyControl | 4 | 5.527 ns | 0.5287 ns | 0.7749 ns | 0.44 | 0.06 | – | – | 0.00 |
| FixedCapOnly | 4 | 36.988 ns | 2.5236 ns | 3.7773 ns | 2.97 | 0.32 | – | – | 0.00 |
| FixedCapOnlyNoInit | 4 | 2.730 ns | 0.1020 ns | 0.1462 ns | 0.22 | 0.01 | – | – | 0.00 |
| HeapOnly | 8 | 19.391 ns | 1.3332 ns | 1.9954 ns | 1.01 | 0.14 | 0.0038 | 152 B | 1.00 |
| StackOnly | 8 | 9.443 ns | 0.6830 ns | 1.0223 ns | 0.49 | 0.07 | – | – | 0.00 |
| PoolOnly | 8 | 20.602 ns | 1.6256 ns | 2.3829 ns | 1.07 | 0.16 | – | – | 0.00 |
| HeapOnlyControl | 8 | 23.004 ns | 1.1737 ns | 1.7567 ns | 1.20 | 0.15 | 0.0038 | 152 B | 1.00 |
| StackOnlyControl | 8 | 8.808 ns | 0.6152 ns | 0.9209 ns | 0.46 | 0.07 | – | – | 0.00 |
| FixedCapOnly | 8 | 41.494 ns | 3.7944 ns | 5.5618 ns | 2.16 | 0.36 | – | – | 0.00 |
| FixedCapOnlyNoInit | 8 | 3.241 ns | 0.0777 ns | 0.1164 ns | 0.17 | 0.02 | – | – | 0.00 |
| HeapOnly | 16 | 30.455 ns | 1.6075 ns | 2.4060 ns | 1.01 | 0.11 | 0.0069 | 280 B | 1.00 |
| StackOnly | 16 | 12.852 ns | 1.1979 ns | 1.7559 ns | 0.42 | 0.07 | – | – | 0.00 |
| PoolOnly | 16 | 16.635 ns | 1.5370 ns | 2.2529 ns | 0.55 | 0.08 | – | – | 0.00 |
| HeapOnlyControl | 16 | 34.195 ns | 1.4803 ns | 2.2156 ns | 1.13 | 0.11 | 0.0069 | 280 B | 1.00 |
| StackOnlyControl | 16 | 11.852 ns | 0.9108 ns | 1.3062 ns | 0.39 | 0.05 | – | – | 0.00 |
| FixedCapOnly | 16 | 37.509 ns | 2.0028 ns | 2.9977 ns | 1.24 | 0.13 | – | – | 0.00 |
| FixedCapOnlyNoInit | 16 | 3.000 ns | 0.2678 ns | 0.3926 ns | 0.10 | 0.01 | – | – | 0.00 |
| HeapOnly | 32 | 54.628 ns | 2.3110 ns | 3.3144 ns | 1.00 | 0.08 | 0.0133 | 536 B | 1.00 |
| StackOnly | 32 | 23.372 ns | 1.5258 ns | 2.2837 ns | 0.43 | 0.05 | – | – | 0.00 |
| PoolOnly | 32 | 16.307 ns | 1.0958 ns | 1.6401 ns | 0.30 | 0.03 | – | – | 0.00 |
| HeapOnlyControl | 32 | 59.558 ns | 2.3348 ns | 3.4946 ns | 1.09 | 0.09 | 0.0132 | 536 B | 1.00 |
| StackOnlyControl | 32 | 23.040 ns | 1.6738 ns | 2.4535 ns | 0.42 | 0.05 | – | – | 0.00 |
| FixedCapOnly | 32 | 35.640 ns | 2.0492 ns | 3.0037 ns | 0.65 | 0.07 | – | – | 0.00 |
| FixedCapOnlyNoInit | 32 | 3.141 ns | 0.2405 ns | 0.3372 ns | 0.06 | 0.01 | – | – | 0.00 |
| HeapOnly | 64 | 106.418 ns | 3.5043 ns | 5.1366 ns | 1.00 | 0.07 | 0.0259 | 1048 B | 1.00 |
| StackOnly | 64 | 42.072 ns | 2.5317 ns | 3.7893 ns | 0.40 | 0.04 | – | – | 0.00 |
| PoolOnly | 64 | 15.902 ns | 0.9929 ns | 1.4861 ns | 0.15 | 0.02 | – | – | 0.00 |
| HeapOnlyControl | 64 | 108.354 ns | 3.4504 ns | 5.1644 ns | 1.02 | 0.07 | 0.0259 | 1048 B | 1.00 |
| StackOnlyControl | 64 | 40.060 ns | 2.4419 ns | 3.5021 ns | 0.38 | 0.04 | – | – | 0.00 |
| FixedCapOnly | 64 | 35.414 ns | 2.0443 ns | 3.0598 ns | 0.33 | 0.03 | – | – | 0.00 |
| FixedCapOnlyNoInit | 64 | 4.400 ns | 0.3150 ns | 0.4617 ns | 0.04 | 0.00 | – | – | 0.00 |
| HeapOnly | 128 | 208.858 ns | 7.3121 ns | 10.9445 ns | 1.00 | 0.07 | 0.0515 | 2072 B | 1.00 |
| StackOnly | 128 | 83.826 ns | 8.0776 ns | 12.0902 ns | 0.40 | 0.06 | – | – | 0.00 |
| PoolOnly | 128 | 15.583 ns | 1.0028 ns | 1.4699 ns | 0.07 | 0.01 | – | – | 0.00 |
| HeapOnlyControl | 128 | 211.451 ns | 8.7648 ns | 12.5702 ns | 1.02 | 0.08 | 0.0515 | 2072 B | 1.00 |
| StackOnlyControl | 128 | 82.470 ns | 5.6236 ns | 8.4171 ns | 0.40 | 0.04 | – | – | 0.00 |
| FixedCapOnly | 128 | 35.963 ns | 1.9916 ns | 2.9809 ns | 0.17 | 0.02 | – | – | 0.00 |
| FixedCapOnlyNoInit | 128 | 3.450 ns | 0.2706 ns | 0.3881 ns | 0.02 | 0.00 | – | – | 0.00 |
| HeapOnly | 512 | 826.127 ns | 29.7903 ns | 44.5887 ns | 1.00 | 0.07 | 0.2022 | 8216 B | 1.00 |
| StackOnly | 512 | 306.500 ns | 17.1665 ns | 25.6941 ns | 0.37 | 0.04 | – | – | 0.00 |
| PoolOnly | 512 | 15.964 ns | 0.9910 ns | 1.4833 ns | 0.02 | 0.00 | – | – | 0.00 |
| HeapOnlyControl | 512 | 805.359 ns | 20.6783 ns | 29.6562 ns | 0.98 | 0.06 | 0.2022 | 8216 B | 1.00 |
| StackOnlyControl | 512 | 306.669 ns | 17.0402 ns | 25.5049 ns | 0.37 | 0.04 | – | – | 0.00 |
| FixedCapOnly | 512 | NA | NA | NA | ? | ? | NA | NA | ? |
| FixedCapOnlyNoInit | 512 | NA | NA | NA | ? | ? | NA | NA | ? |
Dois métodos dessa tabela precisam de aviso na própria linha. FixedCapOnly e FixedCapOnlyNoInit alocam sempre 2 KiB, independentemente de Items, e o parâmetro só muda o comprimento do slice. Essa é a forma que eu chamo de teto no resto do artigo. Em vez de pedir o tamanho que a entrada mandar, você pede sempre um máximo fixo, escrito como literal no código. E usa só o pedaço de que precisa. As sete linhas deles não são sete tamanhos, são sete réplicas da mesma alocação. E elas se movem mais do que deveriam: de 35,414 a 45,742 ns, 29% de amplitude. Esse número volta adiante.
Com uma ressalva que eu preciso dar, porque ela enfraquece o uso que faço dele. A série na ordem de execução é 45,742 → 36,988 → 41,494 → 37,509 → 35,640 → 35,414 → 35,963. Ela decai e estabiliza. Essa é a forma de aquecimento ou de deriva, não a de ruído branco. Se parte dos 29% for sistemática, o número é amplitude de um efeito não identificado sendo usado como limiar de resolução. Não separei as duas coisas: o teste de sinal que apliquei aos dezessete pares do benchmark 2 não tem equivalente aqui, porque estes métodos não têm gêmeo. Eles estão aqui para outra coisa, que aparece adiante. As duas linhas NA em 512 itens são o teto estourando: 1024 doubles passam dos 256 do MaxDoublesOnStack, e o slice sai da faixa.
A razão é o resultado. newton contra stackalloc, mesmo tamanho de buffer, mesmo trabalho:
| Itens | Bytes | new double[n] | stackalloc | Ruído do controle | Razão |
|---|---|---|---|---|---|
| 1 | 16 | 8,356 ns | 4,418 ns | 1,220 ns (27,6%) | 0,53 |
| 4 | 64 | 13,752 ns | 5,711 ns | 0,368 ns (6,4%) | 0,42 |
| 8 | 128 | 21,197 ns | 9,125 ns | 0,635 ns (7,0%) | 0,43 |
| 16 | 256 | 32,325 ns | 12,352 ns | 1,000 ns (8,1%) | 0,38 |
| 32 | 512 | 57,093 ns | 23,206 ns | 0,332 ns (1,4%) | 0,41 |
| 64 | 1024 | 107,386 ns | 41,066 ns | 2,012 ns (4,9%) | 0,38 |
| 128 | 2048 | 210,154 ns | 83,148 ns | 1,356 ns (1,6%) | 0,40 |
| 512 | 8192 | 815,743 ns | 306,584 ns | 0,169 ns (0,1%) | 0,38 |
Cada coluna de tempo é a média dos dois métodos idênticos daquele grupo, e a coluna de ruído é a diferença entre eles.
A razão fica entre 0,38 e 0,53, com média 0,415. Chegar a um buffer zerado com stackalloc custa cerca de 2,4 vezes menos que com newton. Essa proporção se mantém de 16 bytes a 8 KiB, ou quinhentas vezes de tamanho.
Agora a ressalva que muda como você deve ler esse número, e que eu preciso dar antes de qualquer outra coisa. O método StackOnly usa stackalloc double[_bufferSize], ou seja, tamanho variável, que é exatamente a forma que eu desaconselho no fim deste artigo. A versão que eu recomendo, com teto literal, está na mesma tabela e dá números bem diferentes. Em 128 itens, FixedCapOnly custa 35,963 ns contra os 210,154 ns do newton. São 5,8 vezes menos por esta linha, ou 5,5 vezes pela média agrupada que adoto adiante. E FixedCapOnlyNoInit, sem a zeragem, custa 3,450 ns. Esse método se espalha ainda mais que o irmão: 2,730 a 4,400 ns nas sete réplicas, 61% de variação. Então trate o fator de 60,9 vezes como ordem de grandeza, não como medida.
E a técnica recomendada tem um ponto de virada que eu preciso publicar, porque a mesma tabela o mostra e ele contraria a leitura fácil. FixedCapOnly contra newton, nos sete tamanhos em que o teto cabe:
| Itens | FixedCapOnly | new double[n] | Resultado |
|---|---|---|---|
| 1 | 45,742 ns | 8,356 ns | 5,5x pior |
| 4 | 36,988 ns | 13,752 ns | 2,7x pior |
| 8 | 41,494 ns | 21,197 ns | 2,0x pior |
| 16 | 37,509 ns | 32,325 ns | 1,2x pior |
| 32 | 35,640 ns | 57,093 ns | 1,6x melhor |
| 64 | 35,414 ns | 107,386 ns | 3,0x melhor |
| 128 | 35,963 ns | 210,154 ns | 5,8x melhor |
Duas ressalvas de base, e a segunda muda a leitura da tabela. FixedCapOnly e FixedCapOnlyNoInit são métodos únicos no benchmark 1, sem gêmeo. E as sete linhas deles medem a mesma operação, sempre 2 KiB, variando de 35,414 a 45,742 ns: 29% de amplitude sobre algo que deveria ser constante. Essa amplitude é o piso de ruído daquele método, e a régua que eu apliquei ao benchmark 2 tem de valer aqui também.
Aplicada, ela diz o seguinte. Uso a média agrupada das sete réplicas, 38,39 ns, que é o estimador correto quando as sete são a mesma operação. Por ela, o teto literal perde com folga em 1, 4 e 8 itens, por 4,6x, 2,8x e 1,8x. Em 16 itens perde por 1,2x, que cai dentro dos 29% e portanto não é resultado. E ganha com folga de 32 itens em diante.
Em três dos sete tamanhos o padrão que este artigo recomenda é mais lento que newton double[n] com margem que sobrevive ao ruído; num quarto, empata dentro dele. O motivo é direto: o teto literal paga a zeragem de 2 KiB em toda chamada, mesmo quando o pedido usa 16 bytes. O ponto de virada fica entre 16 e 32 itens, com essa incerteza.
E há um resultado mais duro ainda, que eu preferia não ter medido. Comparo o mesmo FixedCapOnly com o PoolOnly da mesma tabela. Com a zeragem ligada, o ArrayPool ganha do teto literal nos sete tamanhos, sem exceção: 38,4 ns de média agrupada contra 13,6 a 20,6 ns. O pool não zera nada; o teto zera 2 KiB toda vez.
Isso diz uma coisa dura sobre o padrão de produção que vem adiante. Do jeito que ele está escrito, com a zeragem ligada, o teto literal perde para o ArrayPool nos sete tamanhos, e para o newton em três deles.
Duas ressalvas sobre essa frase. A primeira é que FixedCapOnly é proxy do bloco impresso, não o bloco: ele não tem try/finally, nem ternário, nem o ramo de fallback. A segunda é que existe um método que mede o bloco de verdade: o Hybrid do benchmark 3. Ele não concorda com esta frase em 32, 64 e 128 itens, onde empata com o pool. Aquele benchmark espalha 28% entre cópias idênticas, e por isso nenhum tempo dele entra como resultado. Mas seria desonesto usar o proxy sem dizer que a medição direta discorda onde ela existe.
Ele continua tendo razão de existir, e a razão não é de medição nenhuma, e sim de modo de falha. O stackalloc não exige disciplina de Return: nada de devolução dupla, uso após devolução, ou lixo do locatário anterior. Em troca, ele cobra memória comprometida por thread, que é justamente o custo que o pool não tem. A escolha é entre dois riscos diferentes, não entre rápido e lento.
E quem copiar aquele bloco esperando performance precisa ligar [SkipLocalsInit]. Aí a conta inverte: FixedCapOnlyNoInit custa cerca de 3 ns e ganha de tudo.
Isso não invalida a recomendação, mas põe condição. O teto literal compensa quando a distribuição real dos seus pedidos fica acima do ponto de virada, ou quando você usa [SkipLocalsInit]. Com ele, FixedCapOnlyNoInit custa cerca de 3 ns e ganha em todos os sete tamanhos.
Uma segunda ressalva, sobre o que a razão significa. newton double[n] também zera o bloco, por garantia da linguagem e não por cortesia do alocador. Então 0,415 não compara “alocar na stack” contra “alocar no heap“. Compara zerar um quadro de pilha (stack frame) quente contra zerar memória de gen0 fria e alocar o cabeçalho do array. Isso continua sendo uma diferença real que aparece no seu perfil, mas o mecanismo não é o que o senso comum diz.
Onde o stackalloc perde, e perde feio
A tabela bruta do benchmark 1 tem uma linha que a tabela de razão não mostra, e ela muda a recomendação para buffer grande: a do PoolOnly. Reorganizada por tamanho de bloco:
| Bloco | Itens | new double[n] | stackalloc variável | ArrayPool |
|---|---|---|---|---|
| 256 B | 16 | 32,325 ns | 12,352 ns | 16,635 ns |
| 2 KiB | 128 | 210,154 ns | 83,148 ns | 15,583 ns |
| 8 KiB | 512 | 815,743 ns | 306,584 ns | 15,964 ns |
Desempenho de alocação entre ArrayPool e stackalloc
O ArrayPool não acompanha o tamanho do bloco. Fica entre 13,6 e 20,6 ns nos oito tamanhos, porque devolve um array que já existe e já foi usado. A amplitude de 51% entre esses dois extremos é grande demais para eu chamar de constante.
Além disso, em 256 B ele perde para o stackalloc. Em 2 KiB já ganha por 5,3 vezes. Em 8 KiB, por 19,2 vezes.
Três ressalvas de honestidade sobre essa coluna, e a terceira é a que mais importa.
- O
PoolOnlydevolve comclearArray: falsee recebe de volta o mesmo array em toda iteração, sempre quente em cache. Já o método de heap percorre a gen0 em memória fria. Os dois não estão em pé de igualdade de cache. - O
Rentsó é barato assim porque o bucket nunca esvazia num benchmark de uma thread só. Sob contenção real o custo sobe. - A comparação varia em duas coisas ao mesmo tempo. O pool não zera; o
StackOnlyzera. Isso é exatamente o confundidor que as sete regras de desenho declaram ter eliminado, e aqui ele está.
O par honesto é stackalloc sem zeragem contra o pool, e ele inverte o resultado. Em 8 KiB, o Const1024NoInit do benchmark 2 custa 4,9 ns contra os 16,0 ns do pool: o stackalloc ganha por 3,2 vezes.
O ranking em 8 KiB, do mais barato ao mais caro, com os seis métodos que a medição tem. Os itens 1, 2 e 4 vêm do benchmark 2; os itens 3, 5 e 6, do benchmark 1. Misturar as duas se apoia na concordância de 1,0% na única operação que ambas medem. E essa concordância veio de uma operação de 303 ns, onde 1% são 3 ns. Nas linhas de 4,9 e 16,0 ns desta lista, três nanossegundos de diferença de instrumento valeriam de 20% a 60%. Trate a ordenação como confiável e os fatores entre linhas vizinhas como ordem de grandeza. De onde sai cada linha:
stackallocliteral sem zeragem: 4,9 nsstackallocvariável sem zeragem: 6,0 nsArrayPool: 16,0 nsstackallocliteral com zeragem: 97,6 nsstackallocvariável com zeragem: 306,6 ns- newton
double[1024]: 815,7 ns
Repare no que essa lista faz com a leitura fácil. Os dois métodos sem zeragem ocupam o topo, um deles de tamanho variável. Os dois métodos com zeragem estão no fim. O eixo que separa a lista em duas metades não é literal contra variável, e sim zerar contra não zerar.
| Eixo | Em 8 KiB | Fator |
|---|---|---|
| Ligar a zeragem, tamanho literal | 4,9 → 97,6 ns | 19,8x |
| Ligar a zeragem, tamanho variável | 6,0 → 303,6 ns | cerca de 50x |
| Literal → variável, sem zeragem | 4,9 → 6,0 ns | 1,2x, não resolvível |
| Literal → variável, com zeragem | 97,6 → 303,6 ns | 3,1x |
A linha do meio precisa de aviso, e é o mesmo que eu dou em outros pontos. O 6,0 ns sai de Var1024NoInit. Esse é o pior par de gêmeos de todo o benchmark: os 5,522 contra 6,542 que produzem o “maior” da tabela de piso. O efeito é de 1,1 ns e a discordância do próprio operando é 1,02 ns. Conforme o gêmeo escolhido, o eixo se move de 1,12x a 1,33x. Não é resultado, é indicação, e a indicação é de que o efeito é pequeno, que é o que a linha precisa mostrar.
As duas linhas de zeragem, essas sim, excedem com folga a discordância dos métodos que as compõem.
> O discriminante é zerar ou não zerar. O tamanho ser literal pesa em segunda ordem: pouco ou nada se você não zera, e de 2,3x a 3,2x se você zera.
E há o custo que nenhuma dessas colunas mostra. A stack de uma thread é comprometida sob demanda, e não é devolvida enquanto a thread existir. Então as páginas que o seu teto de fato toca ficam comprometidas (commit charge) por todo o tempo de vida da thread. Multiplique isso pelo tamanho do pool. O ArrayPool não tem esse problema. A magnitude importa antes de virar argumento, e ela tem duas sutilezas. O comprometimento é por página de 4 KiB, não por byte. E ele acompanha só o pico de profundidade da thread: o seu stackalloc só soma se passar do que os outros quadros já comprometeram. Com o teto de 2 KiB que este artigo recomenda e um pool de duzentas threads, a ordem de grandeza fica nas centenas de KiB. Não decide pod nenhum. O que decide é teto grande vezes pool grande, e é aí que o ArrayPool, que não cobra por thread, se paga.
Benchmark 2 é o fatorial que explica o mecanismo
O benchmark 1 diz quanto custa. Ela não diz por quê, e duas versões deste artigo já erraram ao tentar responder isso.
A primeira usava um método que alocava sempre 2 KiB e só mudava o comprimento do slice. Eram réplicas de uma alocação só, publicadas como se fossem tamanhos diferentes. A segunda corrigiu isso com um fatorial de verdade, mas tinha um único par de controle, e ele discordava 18,8%. Várias conclusões que eu publiquei eram menores que essa discordância, incluindo um “custo de sondagem de página de 0,92 ns” que não sobrevive a nenhum critério honesto. Sondagem, aqui e no resto do artigo, é uma escrita que o código gerado emite só para encostar em cada página nova da pilha. Uma página de cada vez, para que o sistema operacional estenda a pilha em ordem em vez de estourar. E nenhuma das duas tinha piso: toda medição chama um método não-inlinado a partir de outro método não-inlinado, e isso custa alguma coisa sozinho.
Esta versão é um fatorial. É o desenho em que você não testa uma coisa de cada vez: mede toda combinação das dimensões de interesse. Assim dá para ver se uma delas muda de efeito conforme a outra. São duas dimensões sobre quatro tamanhos, com todos os métodos replicados e o piso medido.
- Tamanho literal contra variável. Com o valor conhecido em compilação, o RyuJIT calcula o ajuste de
SPem compilação. Com o valor vindo de um campo, precisa do caminho dinâmico, com cálculo e alinhamento em tempo de execução. - Com zeragem contra sem. O
.locals initque o C# emite por padrão, contra[SkipLocalsInit]. - Quatro tamanhos.
Const2/Var2são 16 B,Const32/Var32são 256 B,Const256/Var256são 2 KiB eConst1024/Var1024são 8 KiB. O número no nome é a contagem de doubles, e cada double são 8 bytes. - Dezessete gêmeos. Cada método tem uma cópia byte a byte, declarada exatamente 17 posições adiante. O piso de ruído passa a ser medido por método.
- Um par de referência.
FlooreFloorTwinfazem o mesmoTouchsobre um buffer que já existe, semstackallocnenhum.
| Method | Mean | Error | StdDev | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
| Const2Zero | 3.036 ns | 0.3216 ns | 0.4814 ns | 1.07 | 0.23 | – | NA |
| Const32Zero | 5.059 ns | 0.3572 ns | 0.5346 ns | 1.79 | 0.31 | – | NA |
| Var2Zero | 4.219 ns | 0.3049 ns | 0.4469 ns | 1.49 | 0.26 | – | NA |
| Const256Zero | 36.336 ns | 2.3937 ns | 3.5827 ns | 12.83 | 2.19 | – | NA |
| Const1024Zero | 98.557 ns | 8.0625 ns | 12.0675 ns | 34.81 | 6.45 | – | NA |
| Const32NoInit | 3.421 ns | 0.2955 ns | 0.4423 ns | 1.21 | 0.23 | – | NA |
| Var2NoInit | 5.558 ns | 0.4488 ns | 0.6717 ns | 1.96 | 0.36 | – | NA |
| Const2NoInit | 3.233 ns | 0.3176 ns | 0.4555 ns | 1.14 | 0.23 | – | NA |
| Var1024Zero | 304.390 ns | 16.5254 ns | 24.7344 ns | 107.50 | 17.37 | – | NA |
| Floor | 2.891 ns | 0.2990 ns | 0.4382 ns | 1.02 | 0.21 | – | NA |
| Const1024NoInit | 4.931 ns | 0.3578 ns | 0.5355 ns | 1.74 | 0.31 | – | NA |
| Var256Zero | 80.915 ns | 4.7856 ns | 7.1628 ns | 28.58 | 4.72 | – | NA |
| Const256NoInit | 3.290 ns | 0.2944 ns | 0.4315 ns | 1.16 | 0.22 | – | NA |
| Var32Zero | 12.052 ns | 0.7128 ns | 1.0669 ns | 4.26 | 0.70 | – | NA |
| Var256NoInit | 4.860 ns | 0.3146 ns | 0.4709 ns | 1.72 | 0.29 | – | NA |
| Var32NoInit | 4.754 ns | 0.3140 ns | 0.4699 ns | 1.68 | 0.29 | – | NA |
| Var1024NoInit | 5.522 ns | 0.3625 ns | 0.5314 ns | 1.95 | 0.33 | – | NA |
| Const2ZeroTwin | 3.113 ns | 0.3201 ns | 0.4693 ns | 1.10 | 0.23 | – | NA |
| Const32ZeroTwin | 5.562 ns | 0.3970 ns | 0.5694 ns | 1.96 | 0.34 | – | NA |
| Var2ZeroTwin | 4.203 ns | 0.3184 ns | 0.4667 ns | 1.48 | 0.26 | – | NA |
| Const256ZeroTwin | 35.821 ns | 2.5866 ns | 3.7914 ns | 12.65 | 2.21 | – | NA |
| Const1024ZeroTwin | 96.555 ns | 5.2487 ns | 7.6935 ns | 34.10 | 5.48 | – | NA |
| Const32NoInitTwin | 3.352 ns | 0.2676 ns | 0.3838 ns | 1.18 | 0.21 | – | NA |
| Var2NoInitTwin | 4.790 ns | 0.3597 ns | 0.5384 ns | 1.69 | 0.30 | – | NA |
| Const2NoInitTwin | 3.182 ns | 0.2889 ns | 0.4324 ns | 1.12 | 0.22 | – | NA |
| Var1024ZeroTwin | 302.897 ns | 17.6873 ns | 25.3665 ns | 106.98 | 17.41 | – | NA |
| FloorTwin | 2.950 ns | 0.3222 ns | 0.4822 ns | 1.04 | 0.22 | – | NA |
| Const1024NoInitTwin | 4.930 ns | 0.3718 ns | 0.5450 ns | 1.74 | 0.31 | – | NA |
| Var256ZeroTwin | 80.174 ns | 4.3215 ns | 6.3344 ns | 28.32 | 4.54 | – | NA |
| Const256NoInitTwin | 3.190 ns | 0.2671 ns | 0.3830 ns | 1.13 | 0.21 | – | NA |
| Var32ZeroTwin | 12.242 ns | 0.8761 ns | 1.3113 ns | 4.32 | 0.76 | – | NA |
| Var256NoInitTwin | 4.744 ns | 0.3661 ns | 0.5366 ns | 1.68 | 0.30 | – | NA |
| Var32NoInitTwin | 4.912 ns | 0.3708 ns | 0.5550 ns | 1.73 | 0.31 | – | NA |
| Var1024NoInitTwin | 6.542 ns | 0.4634 ns | 0.6647 ns | 2.31 | 0.40 | – | NA |
O piso de ruído, medido dezessete vezes
Antes de qualquer conclusão sobre o custo do stackalloc, o número que autoriza tirá-las. A diferença entre cada método e seu gêmeo:
| Valor | Método | |
|---|---|---|
| Menor delta absoluto | 0,001 ns | Const1024NoInit |
| Mediana dos deltas absolutos | 0,158 ns | Var32NoInit |
| Maior delta absoluto | 2,002 ns | Const1024Zero (2,1% relativo) |
| Mediana dos deltas relativos | 2,0% | n/d |
| Maior delta relativo | 16,9% | Var1024NoInit (1,020 ns absoluto) |
As linhas de máximo nomeiam métodos diferentes de propósito. O par que mais discorda em nanossegundos não é o que mais discorda em proporção, e juntar os dois numa linha só esconderia isso.
Em 11 dos 17 pares o gêmeo saiu mais rápido que o original. Isso importa: se houvesse deriva sistemática de posição, os dezessete apareceriam com o mesmo sinal, já que a distância entre gêmeos é constante. Não aparecem. O 18,8% da versão anterior era um par azarado, não uma tendência, e foi preciso replicar tudo para descobrir isso.
Há uma segunda estimativa de erro, e ela é independente de tudo o que os gêmeos compartilham. Mas é um único ponto, e um ponto não é distribuição. A operação “8 KiB de tamanho variável, com zeragem” foi medida duas vezes, em benchmarks e processos diferentes: 303,644 ns aqui e 306,584 ns no benchmark 1. Concordam em 1,0%.
Daqui para frente, nenhum número abaixo de 0,158 ns entra como conclusão. Essa é a forma curta da regra, e adiante ela precisa de uma segunda metade: a mediana global é ponto de partida, não critério final.
Obter o bloco é barato, e o piso mostra o quanto
O par de referência mede 2,921 ns. Esse é o custo de duas chamadas não-inlinadas mais duas escritas em memória, sem stackalloc nenhum. Ele responde de vez a objeção que derrubou a versão anterior deste artigo. Os “2,9 ns de custo de aquisição” que eu havia publicado eram o custo do instrumento de medição, quase inteiros.
Antes de subtrair, três assimetrias que o piso tem e que eu preciso declarar, porque todas empurram o resultado na direção que favorece a minha tese.
Floorlê o campo_existingdo heap e passa pelos testes de faixa doAsSpan. Os métodos destackallocformam oSpandireto deRSP, sem carga nenhuma. LogoFloorfaz mais trabalho, e a subtração subestima o custo de obter o bloco.Floorfatia sempre 256 doubles, mas é subtraído de métodos de 2, 32, 256 e 1024. ComoTouchescreve na primeira e na última posição, o piso toca duas linhas de cache. É o mesmo que o método de 2 KiB, mas mais que os métodos de 16 B e 256 B.Floornão temlocalloc, então tem forma de quadro diferente. Incluir isso no delta é defensável. Mas aí o número vira “custo de obter o bloco incluindo a mudança de forma de quadro”, não o custo dolocallocisolado.
Com isso declarado, a subtração:
| Bloco | Literal | Variável | Diferença |
|---|---|---|---|
| 16 B | 0,287 ns | 2,253 ns | 1,966 ns |
| 256 B | 0,466 ns | 1,913 ns | 1,447 ns |
| 2 KiB | 0,320 ns | 1,881 ns | 1,562 ns |
| 8 KiB | 2,010 ns | 3,111 ns | 1,101 ns |
E aqui eu preciso ser mais duro comigo do que fui. Os valores da coluna literal são menores que a barra de erro dos próprios operandos. Const256NoInit mede 3,240 ns com erro de ±0,29, e Floor mede 2,921 ns com erro de ±0,30. Uma diferença de 0,32 ns entre dois números com essa incerteza é pequena demais para este benchmark separar. Publicá-la como fato seco seria o mesmo erro das versões anteriores, uma casa decimal adiante.
A formulação honesta usa o número bruto, que não depende de subtração nenhuma:
> Obter um bloco de 2 KiB com tamanho literal, incluindo duas chamadas não-inlinadas e duas escritas, custa 3,24 ns no total. O que sobra depois de descontar o instrumento é positivo e pequeno, e este benchmark não resolve o quanto.
Há ainda uma estimativa livre do método de referência, e ela é a mais robusta da seção porque compara métodos da mesma família.
Ela precisa de um cuidado que a primeira versão desta seção não teve. O método de 16 B não faz localloc nenhum: a tabela de codegen mostra que ele é promovido a local de quadro. Usá-lo como base mediria “não alocar contra alocar”, não “alocar pouco contra alocar muito”. Então a comparação certa é entre os dois extremos que de fato têm localloc. De 256 B a 8 KiB, 32 vezes de tamanho, o custo de obter sobe 1,54 ns.
Nenhuma objeção ao Floor toca esse número, e ele basta sozinho para a tese: o custo de obter quase não acompanha o tamanho do bloco. E vale registrar que dentro desse intervalo ele nem sobe sempre: 256 B mede 3,39 ns e 2 KiB mede 3,24 ns. Essa diferença o benchmark também não resolve.
O irmão dele, com tamanho variável, não sobrevive ao próprio critério e por isso não entra. Ele sairia de Var1024NoInit menos Var2NoInit, e esses dois são o pior e o segundo pior par de gêmeos do benchmark: discordam 1,020 e 0,768 ns. O primeiro é maior que o efeito de 0,86 ns que eu tentaria medir, e o segundo é da mesma ordem. Pelos métodos originais o resultado é negativo; pelos gêmeos, +1,75 ns. Um número que muda de sinal conforme o gêmeo escolhido não é um resultado.
E isso obriga a enunciar a regra de corte como ela é de fato aplicada. São duas, e o artigo estava usando as duas sem nomear a segunda:
- Para publicar uma diferença, ela precisa exceder a discordância de gêmeos de todo método que a compõe, não a mediana global de 0,158 ns.
- Para publicar uma razão, o valor precisa ainda ser estável sob troca de gêmeo. Uma diferença pode passar folgada na primeira regra e mesmo assim gerar um fator que se move demais para significar alguma coisa.
É a segunda que reprova a célula de 256 B. Os 1,92 ns passam na primeira com folga de quase 4x, mas o fator derivado vai de 3,3 a 4,5 conforme o gêmeo. E é a segunda que eu preciso aplicar de volta num lugar onde não apliquei. O 50,3x da tabela de quatro eixos usa Var1024NoInit como denominador, e sob troca de gêmeo ele vai de 46 a 55. Leia aquele número como “cerca de cinquenta”, não como 50,3.
Quanto à diferença entre literal e variável nessa mesma tabela, ela fica entre 1,1 e 2,0 ns, e o mecanismo não é o que eu disse antes. Se a causa fosse sondagem de página, o custo cresceria com o número de páginas tocadas. Os quatro deltas são planos dentro do piso: não crescem com o tamanho, e a ordenação entre eles não é resolvível. Isso já basta para descartar a sondagem como parcela dominante. O que o caminho dinâmico cobra é custo fixo: carregar o tamanho, arredondar, testar zero, manipular RSP. E o dump fecha o argumento por um caminho que eu não esperava. O caminho literal emite sondagem em todo localloc que eu li, um test dword ptr [rsp], esp, visível já em 256 B. O método de 16 B não conta aqui: ele não tem localloc. O que depende de o quadro cruzar páginas é a sondagem em laço: em 8 KiB ela aparece como sub rsp, 0x1000 repetido, uma página por volta. Sondagem, portanto, não é o que separa os dois caminhos: ela existe nos dois.
O que cresce é a zeragem, e é ela que decide
Esta é a tabela mais sólida do artigo, e vale dizer por quê. Cada célula é a diferença entre dois métodos do mesmo tamanho, com o mesmo instrumento e o mesmo comprimento de span. O piso se cancela inteiro na subtração, e nenhuma das três ressalvas sobre o Floor toca um só destes números.
As duas colunas, porém, não medem a mesma coisa, e isso eu só descobri lendo o código gerado das variantes sem zeragem. Na coluna literal a subtração é limpa. Com e sem zeragem, o JIT emite a mesma sondagem e o mesmo ajuste de RSP. A única diferença é a chamada à rotina auxiliar, então a coluna mede zeragem isolada. Na coluna variável não: com zeragem o JIT emite um laço de push 0, e sem zeragem emite um laço de sondagem com sub rsp, 0x1000 por volta. O push faz as duas coisas ao mesmo tempo, zerar e tocar página. Logo a coluna variável mede laço de push menos laço de sondagem, não zeragem isolada.
Isso não muda a direção nem a ordem de grandeza de nenhum número da tabela, e não muda a conclusão. Mas muda o que a palavra “zeragem” significa naquela coluna. Este artigo já errou o mecanismo em duas versões, por não ler o codegen. Não vai errar isso por suposição.
| Bloco | Zeragem, tamanho literal | Zeragem, tamanho variável | Fator |
|---|---|---|---|
| 16 B | abaixo do piso | abaixo do piso | n/d |
| 256 B | 1,92 ns | 7,31 ns | não resolvível |
| 2 KiB | 32,84 ns | 75,74 ns | 2,31x |
| 8 KiB | 92,63 ns | 297,61 ns | 3,21x |
Em 16 B a subtração dá negativo nos dois casos. A zeragem é pequena demais para o benchmark separar nesse tamanho, e a linha fica vazia em vez de receber um número inventado.
Em 256 B há número, mas o fator não sobrevive ao próprio critério. Ele nasce de Const32Zero, cujo par de gêmeos discorda 0,503 ns, ou 9,5%, o terceiro pior par relativo do benchmark, atrás de Var1024NoInit (16,9%) e Var2NoInit (14,8%). Escolhendo o outro gêmeo, o fator vai de 3,80 para 3,31; pelo gêmeo rápido, sobe para 4,46. Um número que se move de 3,3 a 4,5 conforme o gêmeo escolhido não é um resultado. Por isso a célula diz “não resolvível” em vez de 3,80x.
Justiça com o número retratado, porque honestidade vale nos dois sentidos: o mecanismo previa mesmo algo perto de 3,8 ali. Em 256 B o caminho literal é SIMD desenrolado e o variável é o laço de push, e a razão entre as duas taxas cai perto de 3,8. O valor era mecanicamente esperado; o que o benchmark não tem é resolução para afirmá-lo.
Sobram 2 KiB e 8 KiB, onde os pares discordam 1,4% e 2,1%. Nos dois tamanhos que o benchmark resolve, o fator entre zeragem literal e variável fica entre 2,3x e 3,2x, e é essa a faixa que o artigo publica.
Uma ressalva sobre o que essa faixa não é: ela não é teto. Pelo próprio mecanismo, o fator cresce com o tamanho dentro do regime de memzero. O custo fixo da chamada se dilui e a taxa literal sobe em direção ao seu teto, enquanto a variável fica plana. Acima de 8 KiB é de esperar que o fator continue subindo. Não medi acima de 8 KiB.
O contraste com a tabela anterior é o artigo inteiro em duas linhas. Obter um bloco de 8 KiB literal custa alguma coisa abaixo de 5 ns. Zerá-lo custa 92,6 ns.
> O que você paga em um stackalloc não é alocar. É inicializar.
Uma nota acompanha todo uso da coluna variável daqui para frente, e vale relembrar sempre que o fator de 2,3x a 3,2x aparecer. Aquela coluna não mede zeragem isolada. Ela mede laço de push menos laço de sondagem, pelo motivo explicado adiante na seção de codegen. A direção e a ordem de grandeza se sustentam; o rótulo “zeragem” é aproximação.
Quatro caminhos de codegen, lidos no código gerado
A versão anterior deste artigo afirmava que, com tamanho constante, “o bloco vira local de quadro e a zeragem sai no prólogo”. Isso está errado para três dos quatro tamanhos medidos, e duas revisões independentes apontaram. Em vez de argumentar, eu li o código gerado. O projeto Codegen/ do sample reproduz o dump com DOTNET_JitDisasm.
O que o RyuJIT emite nesta máquina, neste runtime:
| Bloco | Caminho | Instruções |
|---|---|---|
| 16 B literal | promovido a local de quadro | um vmovdqu ymmword no prólogo, sem ajuste de RSP dedicado ao bloco e sem sondagem |
| 256 B literal | localloc, sondagem única, zeragem desenrolada | test dword ptr [rsp], esp, sub rsp, 256 e oito vmovdqu ymmword de 32 bytes |
| 2 KiB literal | localloc, sondagem única, rotina auxiliar | test, sub rsp, 0x800 e call CORINFO_HELP_MEMZERO |
| 8 KiB literal | localloc, sondagem em laço, rotina auxiliar | laço test / sub rsp, 0x1000 / cmp rsp, rcx / jae, uma página por volta, e depois call CORINFO_HELP_MEMZERO |
| 256 B de campo | localloc dinâmico | add rax, 15 / shr rax, 4 e push 0 / push 0 / dec rax / jne, 16 bytes por iteração |
| 8 KiB de campo | localloc dinâmico | o mesmo laço de push, sem mudança; o tamanho não altera o caminho |
A distinção da primeira linha merece meia frase, porque é exatamente a que foi bloqueada antes. O quadro do método cresce uma vez no prólogo, como em qualquer método. E não há um segundo ajuste de RSP dedicado ao bloco, que é o que os outros casos têm.
E as duas últimas linhas fecham a generalização: o laço de push é idêntico em 256 B e em 8 KiB. O tamanho não muda o caminho dinâmico, o que é esperado, porque o JIT não conhece o tamanho e não pode escolher em função dele. É por isso que a taxa de zeragem variável é plana de 2 KiB para cima e a literal não é.
Ou seja: o mecanismo que eu havia descrito existe, mas só nos blocos menores. O limiar é controlado pelo JitStackAllocToLocalSize do RyuJIT, e em vez de citar o valor padrão eu o medi, acrescentando dois métodos que cercam a fronteira:
| Bloco literal | Caminho emitido |
|---|---|
| 16 B | promovido a local de quadro |
| 32 B | promovido a local de quadro |
| 64 B | localloc, com sondagem |
| 256 B | localloc, com sondagem |
A fronteira está entre 32 e 64 bytes, e o corte de 32 é inclusivo. Dos tamanhos que sustentam conclusões neste artigo, nenhum passa por esse caminho. É por isso que o método de 16 B fica de fora da estimativa de escala da seção anterior.
E os três caminhos que sobram, os que os tamanhos deste artigo de fato usam, explicam as taxas medidas:
| 256 B | 2 KiB | 8 KiB | |
|---|---|---|---|
| Taxa com tamanho literal | não resolvível | 62,4 B/ns | 88,4 B/ns |
| Taxa com tamanho variável | 35,0 B/ns | 27,0 B/ns | 27,5 B/ns |
Antes de ler a tabela, uma ressalva de ordem física que eu devia ter dado antes. 62,4 e 88,4 B/ns são 62 e 88 GB/s, e a largura de banda de memória deste processador fica bem abaixo disso. Ou seja: essas taxas medem zeragem de região residente em cache, que é o regime que o benchmark constrói ao repetir a mesma operação trinta vezes. Em quadro frio (primeira chamada de uma thread nova, pilha profunda, pod recém-escalado) o custo é maior. Dito na unidade certa, porque a inversão aqui é fácil: as taxas desta tabela são teto, e os tempos da tabela anterior são piso. Nos dois casos, o que o benchmark mede é o melhor caso. Dei essa mesma ressalva para o método do pool algumas seções atrás e não tinha dado para o meu próprio operando central.
A célula de 256 B literal fica vazia pelo mesmo motivo da tabela anterior. Ela seria 256 ÷ 1,92 ns = 133 B/ns, e o 1,92 é a zeragem que eu acabei de declarar não resolvível. Trocando os gêmeos nas quatro combinações possíveis, a taxa vai de 116 a 156 B/ns. Publicá-la aqui depois de recusá-la ali seria aplicar a régua em um lugar e não no outro, e essa foi a crítica que derrubou duas versões deste artigo.
A taxa variável é plana de 2 KiB para cima, e o motivo está no dump. O laço de push é idêntico em 256 B e em 8 KiB, 16 bytes por iteração. Os 35,0 B/ns de 256 B ficam 30% acima dos outros dois, e essa diferença sobrevive à troca de gêmeo. O laço sozinho não a explica, e eu não tenho explicação medida para ela.
A taxa literal não é plana, e a versão anterior deste artigo declarava não ter explicação para isso. Agora tem, e ela sai do próprio mecanismo. Em 256 B a zeragem é desenrolada em oito stores SIMD, sem chamada nenhuma. Em 2 KiB e 8 KiB ela vira chamada à rotina auxiliar de memzero, que tem custo fixo de partida.
O que o dump dá antes de qualquer conta é a direção: existe call em 2 KiB e 8 KiB, e não existe em 256 B. Um caminho com chamada tem custo de partida; um caminho desenrolado não tem. Logo a taxa por byte fica baixa perto do limiar da chamada, e sobe conforme o custo fixo se dilui. É o que 62,4 → 88,4 mostra, com separação real dentro do erro dos pares.
O ponto de 256 B seria o mais dramático dos três, e é justamente o que não sobrevive ao critério de corte. O mecanismo prevê que ele esteja bem acima dos outros dois; o benchmark não tem resolução para dizer quanto. Fico com a direção, que o dump garante, e não com o número.
Dá para ir além e estimar as duas parcelas, e aqui eu preciso ser honesto sobre o que isso é. Resolvendo custo fixo e taxa assintótica para os dois pontos de memzero, sai cerca de 13 ns de partida e cerca de 100 B/ns. Mas são dois parâmetros ajustados a dois pontos: resíduo zero por construção, nenhum grau de liberdade, nenhuma chance de o ajuste falhar. Isso é interpolação, não previsão, e propagando a incerteza dos gêmeos a taxa fica em algo como 90 a 120 B/ns. Publico os dois números como ordem de grandeza compatível com o mecanismo, não como propriedade medida da rotina auxiliar.
Para virar previsão de verdade bastaria um terceiro tamanho no regime de memzero, 4 KiB por exemplo, e testar o ajuste fora da amostra. Não rodei esse ponto, e por isso a frase acima diz “compatível com” e não “previsto por”.
Duas consequências práticas, e a segunda evita um erro que os números acima convidam a cometer.
A primeira: trocar stackalloc double[n] por stackalloc double[TETO] com um literal não é só mais seguro contra estouro de pilha. Troca o laço de push por stores desenrolados ou por memzero.
A segunda: isto vale para stackalloc em RyuJIT x64, e não se generaliza. span.Clear(), Array.Clear e Unsafe.InitBlock usam escrita desenrolada ou memset nativo para tipos sem referências gerenciadas. Nenhum deles usa o laço de push, que é o que importa aqui. Em ARM64 o codegen é outro. Em Ice Lake ou Zen, com rep stosb rápido, os números literais mudam. A faixa de 2,3x a 3,2x é observação desta microarquitetura, não propriedade da linguagem.
E a zeragem é removível com [SkipLocalsInit], com três ressalvas que precisam vir juntas. A documentação classifica o atributo como inseguro, porque pode revelar memória não inicializada. O escopo dele não é o bloco. Aplicado a um método, vale para o método inteiro e para todas as funções locais e lambdas aninhadas. Aplicado a um tipo ou módulo, propaga para tudo dentro. E ele só tem efeito com <AllowUnsafeBlocks>true</AllowUnsafeBlocks> no projeto. Sem isso, você acha que removeu a zeragem e não removeu.
Benchmark 3 repete a comparação com a operação completa
Aqui está o contrapeso, e ele é essencial. O terceiro benchmark troca o Touch pelo consolidador de caixas inteiro. Ele tem dois grupos de três métodos de controle: três idênticos para o heap e três para a stack, declarados de forma intercalada.
| Method | Items | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|---|---|
| Heap | 1 | 20.77 ns | 1.260 ns | 1.847 ns | 1.01 | 0.12 | 0.0010 | 40 B | 1.00 |
| Stack | 1 | 15.98 ns | 1.074 ns | 1.574 ns | 0.77 | 0.10 | – | – | 0.00 |
| Pool | 1 | 26.69 ns | 1.824 ns | 2.730 ns | 1.29 | 0.17 | – | – | 0.00 |
| HeapControl | 1 | 19.93 ns | 1.266 ns | 1.855 ns | 0.97 | 0.12 | 0.0010 | 40 B | 1.00 |
| StackControl | 1 | 15.94 ns | 0.905 ns | 1.326 ns | 0.77 | 0.09 | – | – | 0.00 |
| Hybrid | 1 | 45.94 ns | 3.534 ns | 5.068 ns | 2.23 | 0.31 | – | – | 0.00 |
| StackControl2 | 1 | 16.10 ns | 0.926 ns | 1.385 ns | 0.78 | 0.09 | – | – | 0.00 |
| HybridNoInit | 1 | 15.26 ns | 1.286 ns | 1.925 ns | 0.74 | 0.11 | – | – | 0.00 |
| HeapControl2 | 1 | 20.16 ns | 1.148 ns | 1.718 ns | 0.98 | 0.12 | 0.0010 | 40 B | 1.00 |
| Heap | 4 | 60.01 ns | 3.167 ns | 4.740 ns | 1.01 | 0.11 | 0.0021 | 88 B | 1.00 |
| Stack | 4 | 51.57 ns | 2.723 ns | 4.076 ns | 0.86 | 0.09 | – | – | 0.00 |
| Pool | 4 | 58.49 ns | 3.262 ns | 4.782 ns | 0.98 | 0.11 | – | – | 0.00 |
| HeapControl | 4 | 59.20 ns | 3.121 ns | 4.575 ns | 0.99 | 0.11 | 0.0021 | 88 B | 1.00 |
| StackControl | 4 | 66.06 ns | 5.021 ns | 7.201 ns | 1.11 | 0.15 | – | – | 0.00 |
| Hybrid | 4 | 78.78 ns | 5.336 ns | 7.480 ns | 1.32 | 0.16 | – | – | 0.00 |
| StackControl2 | 4 | 53.21 ns | 2.642 ns | 3.954 ns | 0.89 | 0.09 | – | – | 0.00 |
| HybridNoInit | 4 | 50.17 ns | 2.688 ns | 4.023 ns | 0.84 | 0.09 | – | – | 0.00 |
| HeapControl2 | 4 | 60.66 ns | 3.123 ns | 4.674 ns | 1.02 | 0.11 | 0.0021 | 88 B | 1.00 |
| Heap | 8 | 114.02 ns | 5.711 ns | 8.371 ns | 1.00 | 0.10 | 0.0037 | 152 B | 1.00 |
| Stack | 8 | 96.00 ns | 5.544 ns | 8.298 ns | 0.85 | 0.09 | – | – | 0.00 |
| Pool | 8 | 109.71 ns | 6.534 ns | 9.780 ns | 0.97 | 0.11 | – | – | 0.00 |
| HeapControl | 8 | 113.55 ns | 7.260 ns | 10.866 ns | 1.00 | 0.12 | 0.0036 | 152 B | 1.00 |
| StackControl | 8 | 96.36 ns | 5.037 ns | 7.383 ns | 0.85 | 0.09 | – | – | 0.00 |
| Hybrid | 8 | 125.41 ns | 8.181 ns | 12.245 ns | 1.11 | 0.13 | – | – | 0.00 |
| StackControl2 | 8 | 96.27 ns | 5.148 ns | 7.706 ns | 0.85 | 0.09 | – | – | 0.00 |
| HybridNoInit | 8 | 93.29 ns | 5.328 ns | 7.810 ns | 0.82 | 0.09 | – | – | 0.00 |
| HeapControl2 | 8 | 114.13 ns | 5.095 ns | 7.626 ns | 1.01 | 0.10 | 0.0037 | 152 B | 1.00 |
| Heap | 16 | 244.18 ns | 11.669 ns | 17.104 ns | 1.00 | 0.10 | 0.0067 | 280 B | 1.00 |
| Stack | 16 | 204.42 ns | 11.310 ns | 16.928 ns | 0.84 | 0.09 | – | – | 0.00 |
| Pool | 16 | 205.47 ns | 10.490 ns | 15.044 ns | 0.85 | 0.08 | – | – | 0.00 |
| HeapControl | 16 | 217.17 ns | 11.350 ns | 16.637 ns | 0.89 | 0.09 | 0.0067 | 280 B | 1.00 |
| StackControl | 16 | 205.38 ns | 10.353 ns | 15.496 ns | 0.84 | 0.08 | – | – | 0.00 |
| Hybrid | 16 | 218.86 ns | 10.991 ns | 16.451 ns | 0.90 | 0.09 | – | – | 0.00 |
| StackControl2 | 16 | 215.44 ns | 19.689 ns | 29.469 ns | 0.89 | 0.13 | – | – | 0.00 |
| HybridNoInit | 16 | 201.68 ns | 11.267 ns | 16.516 ns | 0.83 | 0.09 | – | – | 0.00 |
| HeapControl2 | 16 | 246.69 ns | 13.770 ns | 20.610 ns | 1.01 | 0.11 | 0.0067 | 280 B | 1.00 |
| Heap | 32 | 495.46 ns | 23.254 ns | 34.085 ns | 1.00 | 0.09 | 0.0129 | 536 B | 1.00 |
| Stack | 32 | 490.45 ns | 25.035 ns | 37.470 ns | 0.99 | 0.10 | – | – | 0.00 |
| Pool | 32 | 465.75 ns | 27.585 ns | 41.287 ns | 0.94 | 0.10 | – | – | 0.00 |
| HeapControl | 32 | 531.64 ns | 22.048 ns | 29.434 ns | 1.08 | 0.09 | 0.0124 | 536 B | 1.00 |
| StackControl | 32 | 442.56 ns | 21.381 ns | 31.340 ns | 0.90 | 0.09 | – | – | 0.00 |
| Hybrid | 32 | 454.08 ns | 24.176 ns | 36.186 ns | 0.92 | 0.09 | – | – | 0.00 |
| StackControl2 | 32 | 474.01 ns | 21.583 ns | 32.305 ns | 0.96 | 0.09 | – | – | 0.00 |
| HybridNoInit | 32 | 435.51 ns | 21.685 ns | 32.458 ns | 0.88 | 0.09 | – | – | 0.00 |
| HeapControl2 | 32 | 494.11 ns | 23.256 ns | 33.353 ns | 1.00 | 0.09 | 0.0124 | 536 B | 1.00 |
| Heap | 64 | 1,264.29 ns | 70.236 ns | 105.126 ns | 1.01 | 0.11 | 0.0248 | 1048 B | 1.00 |
| Stack | 64 | 1,158.55 ns | 63.926 ns | 95.682 ns | 0.92 | 0.10 | – | – | 0.00 |
| Pool | 64 | 1,158.22 ns | 65.539 ns | 98.096 ns | 0.92 | 0.10 | – | – | 0.00 |
| HeapControl | 64 | 1,232.80 ns | 63.591 ns | 95.180 ns | 0.98 | 0.11 | 0.0248 | 1048 B | 1.00 |
| StackControl | 64 | 1,129.69 ns | 55.597 ns | 81.494 ns | 0.90 | 0.09 | – | – | 0.00 |
| Hybrid | 64 | 1,128.67 ns | 57.356 ns | 85.848 ns | 0.90 | 0.10 | – | – | 0.00 |
| StackControl2 | 64 | 1,150.79 ns | 62.856 ns | 94.079 ns | 0.92 | 0.10 | – | – | 0.00 |
| HybridNoInit | 64 | 1,176.62 ns | 63.692 ns | 95.331 ns | 0.94 | 0.10 | – | – | 0.00 |
| HeapControl2 | 64 | 1,264.60 ns | 67.222 ns | 100.615 ns | 1.01 | 0.11 | 0.0248 | 1048 B | 1.00 |
| Heap | 128 | 3,576.66 ns | 194.400 ns | 290.968 ns | 1.01 | 0.11 | 0.0496 | 2072 B | 1.00 |
| Stack | 128 | 3,634.82 ns | 216.607 ns | 317.500 ns | 1.02 | 0.12 | – | – | 0.00 |
| Pool | 128 | 3,704.54 ns | 232.554 ns | 348.076 ns | 1.04 | 0.13 | – | – | 0.00 |
| HeapControl | 128 | 3,472.18 ns | 138.202 ns | 198.204 ns | 0.98 | 0.09 | 0.0458 | 2072 B | 1.00 |
| StackControl | 128 | 3,435.22 ns | 179.758 ns | 269.054 ns | 0.97 | 0.11 | – | – | 0.00 |
| Hybrid | 128 | 3,398.80 ns | 182.321 ns | 272.890 ns | 0.96 | 0.11 | – | – | 0.00 |
| StackControl2 | 128 | 3,439.17 ns | 184.366 ns | 275.951 ns | 0.97 | 0.11 | – | – | 0.00 |
| HybridNoInit | 128 | 3,270.63 ns | 179.614 ns | 263.276 ns | 0.92 | 0.10 | – | – | 0.00 |
| HeapControl2 | 128 | 3,703.12 ns | 197.003 ns | 294.865 ns | 1.04 | 0.11 | 0.0496 | 2072 B | 1.00 |
| Heap | 512 | 34,386.11 ns | 2,751.481 ns | 4,033.085 ns | 1.01 | 0.16 | 0.1831 | 8216 B | 1.00 |
| Stack | 512 | 32,236.91 ns | 1,618.676 ns | 2,422.758 ns | 0.95 | 0.12 | – | – | 0.00 |
| Pool | 512 | 31,249.87 ns | 1,817.288 ns | 2,720.031 ns | 0.92 | 0.12 | – | – | 0.00 |
| HeapControl | 512 | 32,516.37 ns | 1,987.319 ns | 2,974.525 ns | 0.96 | 0.13 | 0.1831 | 8216 B | 1.00 |
| StackControl | 512 | 33,403.80 ns | 1,901.003 ns | 2,845.332 ns | 0.98 | 0.13 | – | – | 0.00 |
| Hybrid | 512 | 31,937.15 ns | 1,640.867 ns | 2,353.283 ns | 0.94 | 0.12 | – | – | 0.00 |
| StackControl2 | 512 | 32,930.48 ns | 1,999.010 ns | 2,992.025 ns | 0.97 | 0.13 | – | – | 0.00 |
| HybridNoInit | 512 | 31,320.29 ns | 1,678.209 ns | 2,459.898 ns | 0.92 | 0.12 | – | – | 0.00 |
| HeapControl2 | 512 | 32,972.87 ns | 1,882.675 ns | 2,759.601 ns | 0.97 | 0.13 | 0.1831 | 8216 B | 1.00 |
A dispersão dentro de cada grupo de três métodos byte a byte idênticos:
| Itens | Grupo new | Grupo stackalloc |
|---|---|---|
| 1 | 4,2% | 1,0% |
| 4 | 2,5% | 28,1% |
| 8 | 0,5% | 0,4% |
| 16 | 13,6% | 5,4% |
| 32 | 7,6% | 10,8% |
| 64 | 2,6% | 2,6% |
| 128 | 6,7% | 5,8% |
| 512 | 5,8% | 3,6% |
Em 4 itens, três cópias byte a byte do mesmo método discordaram entre si em 28,1%. Mesmo código-fonte, mesmo corpo compartilhado, mesma execução. Não há nada para explicar ali além de ruído de medição.
Comparar isso com o benchmark 2 exige cuidado, e a versão anterior deste artigo não teve. Os 28,1% são amplitude entre três cópias; os 2,0% do benchmark 2 são mediana de deltas entre duas. Amplitude de três amostras é sistematicamente maior que delta de duas, antes de qualquer diferença de carga, e mediana esconde cauda. O par comparável é máximo contra máximo: 16,9% no benchmark 2 contra 28,1% aqui. É uma razão de 1,7x, sugestiva e fraca demais para eu afirmar causa.
O que a diferença de ruído tem de origem continua na lista do que a medição não permite concluir. O que este benchmark autoriza é uma conclusão de ordem de grandeza, e só ela. A razão contra newton sai de 0,42 no benchmark 1 para uma média de 0,90 aqui, chegando a 1,02 no pior ponto. É uma mudança de 2,3x, que sobrevive folgada a um piso de 28%. O ganho medido nas outros dois benchmarks se dilui quando a operação fica pesada.
E vale dizer que essa diluição não é descoberta, é aritmética. Somar um mesmo trabalho aos dois métodos força a razão a tender a 1. Com 34 µs de trabalho em cima de deltas de dezenas de nanossegundos, o resultado é obrigatório. O que o benchmark faz é quantificar onde o ganho deixa de aparecer no seu perfil, não revelar um efeito novo. Fora essa razão de ordem de grandeza, nenhum número de tempo desta tabela entra no artigo.
O que este benchmark mostra sem ambiguidade é a coluna Allocated, e ela não depende de resolução temporal nenhuma.
O que os três benchmarks dizem juntos
O benchmark 1 mede a operação como ela aparece no seu código: stackalloc double[n], com a zeragem que vem junto. Razão de 0,415 contra newton, estável em oito tamanhos.
O benchmark 2 abre essa razão em duas parcelas e mostra que a conta está quase toda numa delas. Obter um bloco de 2 KiB literal: 3,24 ns, instrumento incluído. Zerar: 32,84 ns. O newton também zera, então a razão do benchmark 1 é, no fundo, zerar quadro de pilha quente contra zerar gen0 fria mais alocar o cabeçalho do array.
O benchmark 3 pega a mesma comparação com o trabalho de verdade em cima e mostra que o ganho se dilui. Três cópias idênticas do mesmo método discordaram em 28,1% ali, contra 2,0% de mediana no benchmark 2. Dali sai uma única conclusão de tempo, e ela é de ordem de grandeza.
Juntas, elas dizem uma coisa só, e é a recomendação inteira do artigo:
> Alocar na stack é quase de graça. O que custa é a zeragem que vem junto, e você controla o quanto ela custa escolhendo entre um teto literal e um tamanho variável.
A alocação, que é o número sem ruído
Tudo acima é tempo, e tempo nesta máquina tem ruído. A coluna Allocated não tem.
A versão com newton cobra 40, 88, 152, 280, 536, 1.048, 2.072 e 8.216 bytes por chamada, contando o array mais o cabeçalho de objeto. As versões com stackalloc registram traço em todas as linhas dos três benchmarks. Traço é como o BenchmarkDotNet escreve zero byte alocado. Zero, não “pouco”.
Essa diferença é estrutural, não estatística. Não depende de relógio, de temperatura nem de ordem de execução, e é a única coisa neste artigo que transfere direto para a sua máquina. A magnitude depende do tamanho, e vale dizer qual. A mil chamadas por segundo, o pedido de um item deixa de alocar 40 KB/s, e o de 512 itens deixa de alocar 8,2 MB/s. É a cauda que paga, não a moda.
O ArrayPool também mostra traço, mas por outro motivo: ali o zero é amortizado, não estrutural. O array existe, foi alocado uma vez e é reaproveitado. E arrays grandes o bastante teriam ido parar no Large Object Heap, cuja fragmentação eu diagnostiquei em detalhe no artigo sobre fragmentação de memória no .NET com WinDbg.
O limite do stackalloc e por que ele mata o processo
A documentação do stackalloc é explícita: a memória disponível na stack é limitada, alocar demais lança StackOverflowException, e o limite depende do ambiente.
O que dói é a parte seguinte. A documentação de StackOverflowException afirma que a exceção não pode ser capturada com try/catch e que o processo é encerrado por padrão. Essa ressalva do “por padrão” é herança do .NET Framework, onde o host podia descarregar o AppDomain via ICLRPolicyManager. No .NET 10 não existe esse caminho de sobrevivência: a documentação de AppDomain.Unload diz que criar e descarregar domínios não é suportado e lança exceção.
Por isso o sample mede esse limite em processo filho, com o pai apenas observando o código de saída:
|
1 |
static bool Survived(string executable, int n, out int exitCode)<br>{<br> var info = |
|
1 |
ProcessStartInfo(executable)<br> {<br> RedirectStandardOutput = true,<br> RedirectStandardError = true,<br> UseShellExecute = false,<br> };<br> info.ArgumentList.Add("filho");<br> info.ArgumentList.Add(n.ToString(CultureInfo.InvariantCulture));<br><br> using Process process = Process.Start(info)<br> ?? throw |
|
1 |
InvalidOperationException("Nao foi possivel iniciar o processo filho.");<br><br> process.StandardOutput.ReadToEnd();<br> process.StandardError.ReadToEnd();<br> process.WaitForExit();<br><br> exitCode = process.ExitCode;<br> return exitCode == 0;<br>} |
Resultado nesta máquina, por duplicação e depois busca binária:
| Elementos | Bytes pedidos | Resultado |
|---|---|---|
| 131.072 | 1.048.576 | sobreviveu |
| 163.840 | 1.310.720 | sobreviveu |
| 180.224 | 1.441.792 | sobreviveu |
| 187.392 | 1.499.136 | sobreviveu |
| 188.416 | 1.507.328 | STACK OVERFLOW (0xC00000FD) |
| 196.608 | 1.572.864 | STACK OVERFLOW (0xC00000FD) |
| 262.144 | 2.097.152 | STACK OVERFLOW (0xC00000FD) |
1464 KiB sobrevive. 1472 KiB mata o processo. O código 0xC00000FD é o STATUS_STACK_OVERFLOW, documentado pela Microsoft.
Uma distinção que importa: esse número não é o tamanho da stack. É o maior stackalloc que sobreviveu naquele quadro. A documentação do Windows registra 1 MB como reserva padrão do linker. E diz que o tamanho efetivo vem do cabeçalho do executável e de como a thread foi criada. É por isso que os 1464 KiB medidos aqui passam de 1 MB. O tamanho padrão de stack da thread principal no .NET x64 é de 1,5 MB, conforme registrado pela equipe de runtime. Da reserva ainda se desconta uma página de guarda.
E há um custo que a medição de nanossegundos não mostra: a stack de uma thread é comprometida sob demanda e não é devolvida enquanto a thread viver. As páginas que o seu teto toca ficam comprometidas pelo tempo de vida da thread, multiplicadas pelo tamanho do pool. A documentação garante o comprometimento e a liberação na saída da thread; que elas fiquem residentes em memória física é observação, não contrato. Se você dimensiona pod por memória, o teto do stackalloc entra nessa conta. E vale notar que [SkipLocalsInit] não muda isso: ele remove a zeragem, não o comprometimento das páginas.
E o ponto que importa mais que o número: ele não serve como parâmetro de projeto. A medição foi feita na thread principal de um aplicativo de console, em Windows. Não transfira o valor para uma thread do pool nem para um contêiner Linux.
> Nunca deixe o tamanho de um stackalloc depender de entrada que você não controla.
O stackalloc dentro de laço é a mesma bomba com pavio mais longo
|
1 2 3 4 5 |
foreach (var item in items) { Span<double> buffer = stackalloc double[1024]; // ERRADO Process(item, buffer); } |
A stack só é devolvida quando o método retorna, não quando a iteração termina. A documentação de OpCodes.Localloc é literal: o bloco de memória local só fica disponível para reuso quando o método executa o Ret. Cada volta empilha mais 8 KiB.
| Iterações | Total pedido | Resultado |
|---|---|---|
| 64 | 512 KiB | sobreviveu |
| 128 | 1024 KiB | sobreviveu |
| 160 | 1280 KiB | sobreviveu |
| 180 | 1440 KiB | sobreviveu |
| 184 | 1472 KiB | STACK OVERFLOW (0xC00000FD) |
| 200 | 1600 KiB | STACK OVERFLOW (0xC00000FD) |
| 400 | 3200 KiB | STACK OVERFLOW (0xC00000FD) |
184 iterações de 8 KiB somam 1472 KiB. Os dois experimentos param no mesmo ponto dentro da resolução de 32 KiB desta tabela. É corroboração, não prova, já que a semântica do localloc garante o acúmulo sem precisar medir.
O analisador CA2014 sinaliza esse padrão, e no .NET 10 ele já vem ligado por padrão como aviso. Elevar para erro no .editorconfig fecha essa porta:
|
1 2 |
[*.cs] dotnet_diagnostic.CA2014.severity = error |
Fecha essa porta, não todas. A documentação da regra descreve a causa como stackalloc dentro de laço, e não menciona recursão. A leitura de que ela é intraprocedural e cega para acúmulo por recursão é minha, e vem de como a regra está escrita. O resultado é o mesmo StackOverflowException não capturável.
O padrão que vai para produção, e o que ele custa
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
double[]? rented = null; try { Span<double> work = _bufferSize <= MaxDoublesOnStack ? stackalloc double[MaxDoublesOnStack] : (rented = ArrayPool<double>.Shared.Rent(_bufferSize)); return BoxConsolidation.CountBoxes(_items, work[.._bufferSize], BoxCapacityKg, Rule); } finally { if (rented is not null) { ArrayPool<double>.Shared.Return(rented, clearArray: false); } } |
O stackalloc usa uma constante de compilação, não _bufferSize, para manter o quadro de pilha independente da entrada. E agora há número para dimensionar o teto. Você paga a zeragem dele em toda chamada. São 32,84 ns para 2 KiB pelo fatorial do benchmark 2, mesmo quando o pedido tem um item e usa 16 bytes. Se a distribuição real dos seus pedidos é de um ou dois itens, um teto de 2 KiB é caro; dimensione pelo percentil, não pelo máximo. E é aqui que o [SkipLocalsInit] deixa de ser curiosidade. Medindo dos dois jeitos, sem descontar o custo do instrumento, o teto de 2 KiB custa 3,24 ns com o atributo e 36,08 ns sem ele.
Dimensione o teto pela distribuição real dos seus pedidos, não pelo maior caso imaginável. E se o perfil apontar essa zeragem como custo relevante, [SkipLocalsInit] a remove, ao preço de você garantir que toda posição é escrita antes de ser lida.
Aqui vale um aviso que eu mesmo não tinha dado, e que torna o conselho acionável em vez de decorativo. Um teste comum não detecta esse erro. A stack do processo de teste costuma estar zerada nas primeiras chamadas. Então o teste passa verde e você ganha confiança falsa, bem onde o modo de falha é silencioso. E neste domínio ele é o pior tipo possível. A segunda metade do buffer guarda a capacidade livre de cada caixa. Ler lixo dali não lança nada: devolve um número de caixas errado, ou seja, valor de frete errado, sem exceção e sem rastro.
O teste que fecha o buraco precisa sujar o quadro de propósito antes de exercitar o caminho. Primeiro um método NoInlining que aloca um stackalloc do mesmo tamanho, preenche com NaN e retorna. Depois a chamada sob teste. As duas partem do mesmo chamador e na mesma profundidade de pilha, para que o segundo quadro caia sobre o primeiro. Sem isso, “garanta com teste” é um teste que passa por acidente. E mesmo com isso, o teste eleva a probabilidade de detectar, não prova ausência: continua sendo um teste sobre layout de pilha, que nenhum contrato do runtime garante.
O ArrayPool escala para qualquer tamanho, mas cobra disciplina. São quatro os erros que aparecem em produção:
Rent(n)devolve um array com pelo menos n posições, frequentemente maior. A documentação garante só o “pelo menos”. Que o excedente venha dos buckets doArrayPool.Shared, organizados em potências de dois, é leitura minha do código, não contrato público. Quem itera porarray.Lengthem vez de fatiar processa lixo do locatário anterior.- Devolver o mesmo array duas vezes corrompe o pool, e como o exemplo usa o
ArrayPool<T>.Shared, o estrago vale para o processo inteiro. A documentação classifica isso como problema de segurança de alta severidade. - Usar o
Spandepois doReturné use-after-free lógico: o buffer já pertence a outro chamador. clearArray: falseentrega o conteúdo do locatário anterior, o que importa quando o buffer carrega dado pessoal ou segredo.
Vale lembrar que arrays grandes o bastante caem no Large Object Heap, e é justamente essa a pressão que o pool evita. Escrevi sobre o diagnóstico dela em fragmentação de memória no .NET com WinDbg.
E a restrição que morde primeiro quem copia o padrão: Span<T> e stackalloc não atravessam await. O motivo tem nome: Span<T> é um ref struct. Antes do C# 13 nem se podia declarar um local de ref struct em método async. O C# 13 relaxou a regra e passou a permitir, desde que a variável não seja acessada através de um await. Este método precisa ser um auxiliar síncrono chamado de dentro do handler assíncrono. Quando o buffer precisa sobreviver a um await, o caminho é Memory<T> com IMemoryOwner<T>.
O que o .NET 10 já resolve sozinho
O .NET 10 ampliou o escape analysis do JIT. Segundo a documentação do runtime, o compilador passou a alocar na stack quatro coisas. Arrays pequenos de tipos de valor sem ponteiros de GC. Arrays pequenos de tipos de referência. Objetos referenciados por campos de struct locais. E delegates que não escapam do método.
O exemplo abaixo é reprodução literal dessa documentação e não faz parte do sample deste artigo:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
static void Sum() { int[] numbers = {1, 2, 3}; int sum = 0; for (int i = 0; i < numbers.Length; i++) { sum += numbers[i]; } Console.WriteLine(sum); } |
Em código de tier-1, esse newton int[3] não vai para o heap no .NET 10. O JIT prova que ele não sobrevive ao método e o coloca na stack sem que ninguém peça.
A condição está em uma palavra fácil de pular na leitura: fixed-sized. A documentação diz “small, fixed-sized arrays” e explica que o JIT sabe em tempo de compilação que o array tem apenas três inteiros.
O buffer deste artigo não se qualifica, por duas razões independentes. O tamanho vem do pedido. E o buffer é passado a um método marcado com NoInlining, sendo que a própria documentação diz que um objeto escapa quando é passado a método que o JIT não inlina.
> Onde o tamanho é fixo e pequeno, deixe o JIT do .NET 10 trabalhar e escreva o código mais legível. Onde o tamanho depende da entrada, a otimização automática não alcança, e é ali que o stackalloc paga.
Como o benchmark se protege de si mesmo
O sample traz um projeto Codegen/ que existe só para uma coisa: ler o código que o RyuJIT gera para cada forma de stackalloc, com DOTNET_JitDisasm. Ele é a fonte da tabela de três caminhos do benchmark 2. Existe porque a versão anterior deste artigo afirmou um mecanismo de codegen sem ter lido o codegen.
São 50 testes. O principal roda as três estratégias de buffer (heap, pool e stackalloc) sobre a mesma entrada e verifica que devolvem o mesmo número de caixas:
|
1 |
[Theory]<br>[InlineData(0)]<br>[InlineData(1)]<br>[InlineData(4)]<br>[InlineData(8)]<br>[InlineData(127)]<br>[InlineData(128)]<br>[InlineData(129)]<br>[InlineData(512)]<br>public void AllBufferStrategiesAgree(int count)<br>{<br> Package[] items = SampleCart.Generate(count, seed: 20260819);<br> int size = BoxConsolidation.BufferSize(count);<br><br> double[] heap = |
|
1 |
double[size];<br> int fromHeap = BoxConsolidation.CountBoxes(items, heap, BoxCapacityKg, Rule);<br><br> double[] rented = ArrayPool<double>.Shared.Rent(size);<br> int fromPool;<br> try<br> {<br> fromPool = BoxConsolidation.CountBoxes(items, rented.AsSpan(0, size), BoxCapacityKg, Rule);<br> }<br> finally<br> {<br> ArrayPool<double>.Shared.Return(rented, clearArray: false);<br> }<br><br> Assert.Equal(fromHeap, fromPool);<br><br> // O braco de stackalloc so cabe enquanto o buffer nao passa do teto de 256 doubles<br> // usado pelo padrao hibrido. Acima disso a estrategia real e o pool, ja coberto acima.<br> if (size > 0 && size <= 256)<br> {<br> int fromStack = CountBoxesOnStack(items, size);<br> Assert.Equal(fromHeap, fromStack);<br> }<br>} |
Os valores 127, 128 e 129 existem por causa do teto: são as fronteiras onde a estratégia troca, e bug de “um a mais” mora exatamente ali.
Outro teste guarda a distribuição de entrada. A primeira versão deste sample sorteava todos os eixos acima de 10 cm, e o limiar da cubagem mínima é 10 cm. O ramo que este artigo passa uma seção descrevendo nunca era executado em nenhum caso medido. Agora um em cada quatro itens é achatado, com teste guardando contra a regressão:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
[Fact] public void GeneratorActuallyTriggersMinimumCubage() { // Guarda contra a regressao que o revisor apontou: se a distribuicao voltar a produzir // todos os eixos acima de 10 cm, a regra de cubagem minima vira codigo morto no // benchmark inteiro e o resultado deixa de representar o cenario descrito. var items = SampleCart.Generate(128, seed: 20260819); int flat = items.Count(p => p.LengthCm < CubageRule.MinimumAxisCm || p.WidthCm < CubageRule.MinimumAxisCm || p.HeightCm < CubageRule.MinimumAxisCm); Assert.Equal(128 / SampleCart.OneFlatEvery, flat); // ... e a segunda metade verifica que o ramo muda o resultado de fato. } |
O que a medição não permite concluir
- A diferença de tempo na operação completa. Três cópias byte a byte do mesmo método discordaram em 28,1% no benchmark 3. Nada ali é conclusivo sobre tempo, e nada dali entrou como resultado.
- A causa da diferença de ruído entre os benchmarks. O do fatorial tem mediana de 2,0% entre gêmeos; o de carga completa chega a 28,1%. A explicação provável é que medições mais curtas sofrem menos deriva dentro de cada iteração, mas não medi isso; é hipótese.
- A causa da discordância entre métodos idênticos. Layout de código e alinhamento são a explicação mais provável, e o runtime do .NET 10 documenta que layout afeta desempenho. Aqui eu não li o código gerado: li o do
stackalloc, que está no projetoCodegen/, e não o dos métodos do benchmark 3. Então é hipótese, não causa provada. - A separação entre sondagem de página e as demais parcelas do caminho dinâmico. O fatorial mede o custo fixo do caminho dinâmico como um todo, de 1,1 a 2,0 ns. Ele não isola a sondagem, e o formato dos deltas (maior em 16 B, menor em 8 KiB) sugere que a sondagem não é a parcela dominante.
- A magnitude exata das duas parcelas da rotina auxiliar de memzero. A leitura do codegen explica a direção da curva de taxa literal (baixa em 2 KiB, mais alta em 8 KiB) e essa parte se sustenta. Mas os ~13 ns de custo fixo e os ~100 B/ns de taxa assintótica saem de dois parâmetros ajustados a dois pontos, sem grau de liberdade. Um terceiro tamanho no regime de
memzeroconverteria a interpolação em previsão testada, e eu não rodei esse ponto. - Nenhum número de tempo do
stackalloctransfere para outra máquina. A razão de 0,415 é o resultado temporal mais estável do artigo, e mesmo ela vale para este processador, este runtime e este alocador. A colunaAllocatedé a exceção, e é por isso que ela é tratada em seção própria: bytes alocados não dependem de relógio. - O benefício da alocação zero não foi medido em carga. A coluna
Allocatedprova que os bytes deixam de vir do heap. Ela não prova que isso muda pausa de coletor, throughput ou latência de percentil no seu sistema. Para saber se compensa, meça a sua taxa de alocação e a sua frequência de gen0. Escrevi sobre como ler as métricas de runtime do .NET e sobre profiling de memória comdotnet-dumpedotnet-gcdump. Se você não tem esse número, a resposta certa é não mexer.
Cinco limites adicionais:
- Os números valem para um Core i7-8665U móvel, x64, Server GC concorrente com DATAS desligado, em tier-1. Na primeira requisição de um pod o quadro é outro: o código roda em tier-0 até o contador de chamadas disparar a recompilação. Desde o .NET 7 isso vale também para métodos com laço: o
TieredCompilationQuickJitForLoopse o On-Stack Replacement foram ligados por padrão em x64 e Arm64. Vale a ressalva de que a página de configuração do runtime ainda descreve o padrão antigo; quem manda é o pull request que mudou o valor. Nada neste benchmark mede esse regime. Se esse é o seu problema, escrevi sobre tier-0, tier-1, OSR e PGO em detalhe. - O fator entre zeragem literal e variável é observação de codegen x64. Em ARM64 o caminho é outro; em Ice Lake ou Zen, com
rep stosbrápido, os números literais mudam. E ele não vale paraspan.Clear(),Array.ClearnemUnsafe.InitBlock. Para tipos sem referências gerenciadas eles usam escrita desenrolada ou memset nativo. Para tipos com referências, um laço de escritas do tamanho do ponteiro. Nenhum usa o laço depush. - A configuração do benchmark não é a configuração padrão de produção. Com DATAS ligado, que é o padrão desde o .NET 9, e com limite de CPU de contêiner, a linha de base do método newton é outra. E a razão de 0,415 não se reproduz.
- O
ArrayPool.Sharedfoi medido sem concorrência. O cache por thread e o descarte sob pressão de memória são da implementação emdotnet/runtime, não da documentação pública. Trate como leitura minha do código, não como contrato. - A faixa de entrada para em 512 itens, e os dois primeiros benchmarks não executam o domínio de cubagem.
Nota sobre o .NET 11
O .NET 11 está em preview 7, com lançamento previsto para novembro de 2026. Nem o resumo da versão nem a página de runtime anunciam ampliação do escape analysis nessa preview: o requisito de tamanho fixo continua de pé.
A documentação do .NET 10 informa que a equipe de runtime pretende estender a alocação em stack para closures em versão futura. E o C# 15 traz memory safety em preview. Ele permite converter uma expressão stackalloc em ponteiro fora de contexto unsafe, embora o acesso pelo ponteiro continue exigindo unsafe.
O que levar disso
Alocar na stack é quase de graça, e quase não depende do tamanho. Um bloco de 2 KiB literal sem zeragem custa 3,24 ns com o instrumento incluído, e entre 256 B e 8 KiB esse custo sobe apenas 1,54 ns. A reserva sempre foi barata. Se você achava que o ganho do stackalloc vinha de “não passar pelo alocador”, o ganho é real, mas o mecanismo é outro.
O que custa é a zeragem, e você controla quanto. O mesmo bloco de 2 KiB com zeragem custa 36,08 ns, ou onze vezes o custo sem ela, na mesma base de medição. Um teto literal zera por um caminho de codegen. Um tamanho variável zera por outro, de 2,3 a 3,2 vezes mais lento nos tamanhos em que o benchmark resolve. Essa é a decisão que muda o número.
Use teto literal. Por segurança, sempre; por performance, com condição. A parte de segurança não tem ressalva. Um stackalloc cujo tamanho vem da entrada é um StackOverflowException esperando o pedido certo. E ele mata o processo sem catch, sem finally e sem log de aplicação.
A parte de performance tem. Com a zeragem ligada, o teto literal de 2 KiB perde para newton em três dos sete tamanhos medidos. No pedido de um item ele é 4,6 vezes pior, pela média agrupada das sete réplicas. Num quarto tamanho, empata dentro do piso de 29% daquele método. O ponto de virada fica entre 16 e 32 itens. Se a sua distribuição real fica abaixo disso, ou você liga [SkipLocalsInit], ou o teto literal é decisão de segurança que custa performance.
Acima do teto, ArrayPool. O pool não acompanha o tamanho do bloco, ficando entre 13,6 e 20,6 ns, e ganha do stackalloc variável com zeragem por 19 vezes em 8 KiB. E ele não cobra memória comprometida por thread, que é o custo que a stack cobra e nenhuma tabela de nanossegundos mostra. Com o teto de 2 KiB deste artigo esse custo é irrelevante: centenas de KiB num pool de duzentas threads. Ele só decide alguma coisa com teto grande vezes pool grande, e é aí que a coluna passa a pesar.
O que a medição entrega sem ambiguidade é a alocação, que vai a zero de forma estrutural e é verificável por contador. Se isso compensa no seu sistema é outra pergunta, e ela exige o número de gen0 que a seção anterior pede.
E meça o seu. Este artigo publica o piso de ruído de cada benchmark em tabela justamente porque duas versões anteriores dele publicaram conclusões menores que o próprio ruído. Se a sua medição não tem método de controle e método de referência, ela não sabe o que está medindo. Eu não sabia.
FAQ – Perguntas Frequentes
1. O que é stackalloc no C#?
stackalloc é uma expressão que aloca um bloco de memória na stack do método em vez do heap. O bloco é descartado quando o método retorna, não passa pelo garbage collector e não precisa ser fixado com fixed. Atribuindo o resultado a um Span<T>, não é preciso contexto unsafe.
2. O stackalloc é mais rápido que newton no .NET 10?
Para chegar a um buffer utilizável, sim: 2,4 vezes mais barato, com razão entre 0,38 e 0,53 em oito tamanhos. Com uma ressalva que muda a leitura: essa medição usa stackalloc de tamanho variável, que é a forma que este artigo desaconselha. A forma recomendada, com teto literal, dá números bem diferentes, melhores acima de 32 itens e piores abaixo. Mas o mecanismo não é o que parece: newton também zera o bloco, então a comparação é entre zerar quadro de pilha quente e zerar gen0 fria. E o ganho se dilui quando a operação em cima do buffer fica pesada.
3. Quanto custa de fato o stackalloc?
Um bloco de 2 KiB com tamanho literal custa 3,24 ns sem zeragem e 36,08 ns com ela. É onze vezes mais caro com a zeragem do que sem ela, na mesma base de medição, com o instrumento incluído nos dois lados. Em 8 KiB a proporção é de vinte vezes. Com tamanho variável a zeragem custa de 2,3 a 3,2 vezes isso, porque o RyuJIT usa outro caminho de codegen.
4. Qual é o limite de memória do stackalloc?
Não existe limite fixo. Depende da plataforma, do host, do tipo de thread e da profundidade da pilha na hora da chamada. Nesta máquina, 1464 KiB sobreviveu e 1472 KiB derrubou o processo. Por isso o conselho não é calcular o limite, e sim jamais deixar o tamanho depender da entrada.
5. É possível capturar StackOverflowException no .NET?
Não. Essa exceção não pode ser capturada por código de usuário e o processo é encerrado. Como o processo morre, o finally também não roda. O runtime imprime Stack overflow. e o callstack no console, que em contêiner vai para o log do pod. Mas não há log de aplicação nem rastro no APM.
6. Quando usar ArrayPool em vez de stackalloc?
Quando o buffer pode ser grande, quando o tamanho vem da entrada, ou quando você não pode ligar [SkipLocalsInit]. Este último caso surpreende, e a medição é clara. Com a zeragem ligada, o pool ganha do teto literal nos sete tamanhos medidos. O teto paga zeragem de 2 KiB em toda chamada, e o pool não paga nenhuma. A inversão só acontece sem zeragem, e aí o stackalloc ganha por 3,2 vezes em 8 KiB. Pesam também dois fatores que não aparecem em nanossegundos: o pool não cobra memória residente por thread, e não impõe teto de tamanho. Em troca, cobra disciplina de uso, detalhada na seção do padrão de produção.
7. Que tipos posso usar em stackalloc?
Apenas unmanaged types, ou seja, tipos que não contêm referências gerenciadas. double, int, Guid e structs compostos só de campos desse tipo funcionam; qualquer struct com um string ou um array dentro não compila. Para coleções de tipos de referência, o caminho é ArrayPool ou ObjectPool.
8. Por que stackalloc dentro de laço é perigoso?
Porque a stack só é liberada quando o método retorna, não ao fim de cada iteração. As alocações se acumulam até estourar. Na medição, 184 iterações de 8 KiB somaram 1472 KiB e derrubaram o processo. O analisador CA2014 já sinaliza o padrão por padrão no .NET 10, mas, pela minha leitura de como a regra é enunciada, não enxerga acúmulo por recursão.
9. Por que dois métodos idênticos deram tempos diferentes na medição?
Layout de código e alinhamento são a explicação mais provável, e o runtime documenta que layout afeta desempenho. Também é candidata a variação de frequência. O cabeçalho do benchmark registra 1,90 GHz de base e 2,11 GHz de máximo reportado pelo sistema. Esse não é o teto de turbo: a especificação da Intel dá até 4,80 GHz para este processador. A faixa real de variação é bem maior que a do cabeçalho, e cobre com folga as dispersões observadas. Não separei as duas causas, então trato como ruído do benchmark. É justamente para expor isso que os benchmarks têm métodos byte a byte idênticos: nenhuma conclusão publicada se apoia em diferença menor que a discordância entre eles.