Conteúdo

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

  1. Obter memória na stack com stackalloc custa 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.
  2. O que você paga não é alocar, é inicializar. O C# emite .locals init por 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 o stackalloc.
  3. 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 a CORINFO_HELP_MEMZERO. Com 256 B literal, oito stores SIMD desenrolados. Trocar stackalloc double[n] por um teto literal muda o codegen, não só a segurança.
  4. 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.
  5. 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.

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:

newton

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:

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:#fff

O 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 trata localloc de tamanho desconhecido e localloc em laço como observações fatais para inlining. As duas estão em inline.def como LOCALLOC_SIZE_UNKNOWN e LOCALLOC_IN_LOOP, marcadas FATAL. 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 stackalloc nenhum. 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:

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.

MethodItemsMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
HeapOnly18.089 ns0.1709 ns0.2505 ns1.000.040.001040 B1.00
StackOnly13.808 ns0.0857 ns0.1283 ns0.470.020.00
PoolOnly114.065 ns0.3130 ns0.4685 ns1.740.080.00
HeapOnlyControl18.623 ns0.2313 ns0.3242 ns1.070.050.001040 B1.00
StackOnlyControl15.028 ns0.5701 ns0.8533 ns0.620.110.00
FixedCapOnly145.742 ns1.5013 ns2.1531 ns5.660.310.00
FixedCapOnlyNoInit13.041 ns0.3756 ns0.5622 ns0.380.070.00
HeapOnly412.456 ns0.2992 ns0.4479 ns1.000.050.002288 B1.00
StackOnly45.895 ns0.1375 ns0.2058 ns0.470.020.00
PoolOnly413.646 ns0.2146 ns0.3146 ns1.100.050.00
HeapOnlyControl415.049 ns0.8972 ns1.3429 ns1.210.110.002288 B1.00
StackOnlyControl45.527 ns0.5287 ns0.7749 ns0.440.060.00
FixedCapOnly436.988 ns2.5236 ns3.7773 ns2.970.320.00
FixedCapOnlyNoInit42.730 ns0.1020 ns0.1462 ns0.220.010.00
HeapOnly819.391 ns1.3332 ns1.9954 ns1.010.140.0038152 B1.00
StackOnly89.443 ns0.6830 ns1.0223 ns0.490.070.00
PoolOnly820.602 ns1.6256 ns2.3829 ns1.070.160.00
HeapOnlyControl823.004 ns1.1737 ns1.7567 ns1.200.150.0038152 B1.00
StackOnlyControl88.808 ns0.6152 ns0.9209 ns0.460.070.00
FixedCapOnly841.494 ns3.7944 ns5.5618 ns2.160.360.00
FixedCapOnlyNoInit83.241 ns0.0777 ns0.1164 ns0.170.020.00
HeapOnly1630.455 ns1.6075 ns2.4060 ns1.010.110.0069280 B1.00
StackOnly1612.852 ns1.1979 ns1.7559 ns0.420.070.00
PoolOnly1616.635 ns1.5370 ns2.2529 ns0.550.080.00
HeapOnlyControl1634.195 ns1.4803 ns2.2156 ns1.130.110.0069280 B1.00
StackOnlyControl1611.852 ns0.9108 ns1.3062 ns0.390.050.00
FixedCapOnly1637.509 ns2.0028 ns2.9977 ns1.240.130.00
FixedCapOnlyNoInit163.000 ns0.2678 ns0.3926 ns0.100.010.00
HeapOnly3254.628 ns2.3110 ns3.3144 ns1.000.080.0133536 B1.00
StackOnly3223.372 ns1.5258 ns2.2837 ns0.430.050.00
PoolOnly3216.307 ns1.0958 ns1.6401 ns0.300.030.00
HeapOnlyControl3259.558 ns2.3348 ns3.4946 ns1.090.090.0132536 B1.00
StackOnlyControl3223.040 ns1.6738 ns2.4535 ns0.420.050.00
FixedCapOnly3235.640 ns2.0492 ns3.0037 ns0.650.070.00
FixedCapOnlyNoInit323.141 ns0.2405 ns0.3372 ns0.060.010.00
HeapOnly64106.418 ns3.5043 ns5.1366 ns1.000.070.02591048 B1.00
StackOnly6442.072 ns2.5317 ns3.7893 ns0.400.040.00
PoolOnly6415.902 ns0.9929 ns1.4861 ns0.150.020.00
HeapOnlyControl64108.354 ns3.4504 ns5.1644 ns1.020.070.02591048 B1.00
StackOnlyControl6440.060 ns2.4419 ns3.5021 ns0.380.040.00
FixedCapOnly6435.414 ns2.0443 ns3.0598 ns0.330.030.00
FixedCapOnlyNoInit644.400 ns0.3150 ns0.4617 ns0.040.000.00
HeapOnly128208.858 ns7.3121 ns10.9445 ns1.000.070.05152072 B1.00
StackOnly12883.826 ns8.0776 ns12.0902 ns0.400.060.00
PoolOnly12815.583 ns1.0028 ns1.4699 ns0.070.010.00
HeapOnlyControl128211.451 ns8.7648 ns12.5702 ns1.020.080.05152072 B1.00
StackOnlyControl12882.470 ns5.6236 ns8.4171 ns0.400.040.00
FixedCapOnly12835.963 ns1.9916 ns2.9809 ns0.170.020.00
FixedCapOnlyNoInit1283.450 ns0.2706 ns0.3881 ns0.020.000.00
HeapOnly512826.127 ns29.7903 ns44.5887 ns1.000.070.20228216 B1.00
StackOnly512306.500 ns17.1665 ns25.6941 ns0.370.040.00
PoolOnly51215.964 ns0.9910 ns1.4833 ns0.020.000.00
HeapOnlyControl512805.359 ns20.6783 ns29.6562 ns0.980.060.20228216 B1.00
StackOnlyControl512306.669 ns17.0402 ns25.5049 ns0.370.040.00
FixedCapOnly512NANANA??NANA?
FixedCapOnlyNoInit512NANANA??NANA?

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:

ItensBytesnew double[n]stackallocRuído do controleRazão
1168,356 ns4,418 ns1,220 ns (27,6%)0,53
46413,752 ns5,711 ns0,368 ns (6,4%)0,42
812821,197 ns9,125 ns0,635 ns (7,0%)0,43
1625632,325 ns12,352 ns1,000 ns (8,1%)0,38
3251257,093 ns23,206 ns0,332 ns (1,4%)0,41
641024107,386 ns41,066 ns2,012 ns (4,9%)0,38
1282048210,154 ns83,148 ns1,356 ns (1,6%)0,40
5128192815,743 ns306,584 ns0,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:

ItensFixedCapOnlynew double[n]Resultado
145,742 ns8,356 ns5,5x pior
436,988 ns13,752 ns2,7x pior
841,494 ns21,197 ns2,0x pior
1637,509 ns32,325 ns1,2x pior
3235,640 ns57,093 ns1,6x melhor
6435,414 ns107,386 ns3,0x melhor
12835,963 ns210,154 ns5,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:

BlocoItensnew double[n]stackalloc variávelArrayPool
256 B1632,325 ns12,352 ns16,635 ns
2 KiB128210,154 ns83,148 ns15,583 ns
8 KiB512815,743 ns306,584 ns15,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 PoolOnly devolve com clearArray: false e 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 Rent só é 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 StackOnly zera. 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:

  1. stackalloc literal sem zeragem: 4,9 ns
  2. stackalloc variável sem zeragem: 6,0 ns
  3. ArrayPool: 16,0 ns
  4. stackalloc literal com zeragem: 97,6 ns
  5. stackalloc variável com zeragem: 306,6 ns
  6. 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.

EixoEm 8 KiBFator
Ligar a zeragem, tamanho literal4,9 → 97,6 ns19,8x
Ligar a zeragem, tamanho variável6,0 → 303,6 nscerca de 50x
Literal → variável, sem zeragem4,9 → 6,0 ns1,2x, não resolvível
Literal → variável, com zeragem97,6 → 303,6 ns3,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 SP em 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 init que o C# emite por padrão, contra [SkipLocalsInit].
  • Quatro tamanhos. Const2/Var2 são 16 B, Const32/Var32 são 256 B, Const256/Var256 são 2 KiB e Const1024/Var1024 sã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. Floor e FloorTwin fazem o mesmo Touch sobre um buffer que já existe, sem stackalloc nenhum.
MethodMeanErrorStdDevRatioRatioSDAllocatedAlloc Ratio
Const2Zero3.036 ns0.3216 ns0.4814 ns1.070.23NA
Const32Zero5.059 ns0.3572 ns0.5346 ns1.790.31NA
Var2Zero4.219 ns0.3049 ns0.4469 ns1.490.26NA
Const256Zero36.336 ns2.3937 ns3.5827 ns12.832.19NA
Const1024Zero98.557 ns8.0625 ns12.0675 ns34.816.45NA
Const32NoInit3.421 ns0.2955 ns0.4423 ns1.210.23NA
Var2NoInit5.558 ns0.4488 ns0.6717 ns1.960.36NA
Const2NoInit3.233 ns0.3176 ns0.4555 ns1.140.23NA
Var1024Zero304.390 ns16.5254 ns24.7344 ns107.5017.37NA
Floor2.891 ns0.2990 ns0.4382 ns1.020.21NA
Const1024NoInit4.931 ns0.3578 ns0.5355 ns1.740.31NA
Var256Zero80.915 ns4.7856 ns7.1628 ns28.584.72NA
Const256NoInit3.290 ns0.2944 ns0.4315 ns1.160.22NA
Var32Zero12.052 ns0.7128 ns1.0669 ns4.260.70NA
Var256NoInit4.860 ns0.3146 ns0.4709 ns1.720.29NA
Var32NoInit4.754 ns0.3140 ns0.4699 ns1.680.29NA
Var1024NoInit5.522 ns0.3625 ns0.5314 ns1.950.33NA
Const2ZeroTwin3.113 ns0.3201 ns0.4693 ns1.100.23NA
Const32ZeroTwin5.562 ns0.3970 ns0.5694 ns1.960.34NA
Var2ZeroTwin4.203 ns0.3184 ns0.4667 ns1.480.26NA
Const256ZeroTwin35.821 ns2.5866 ns3.7914 ns12.652.21NA
Const1024ZeroTwin96.555 ns5.2487 ns7.6935 ns34.105.48NA
Const32NoInitTwin3.352 ns0.2676 ns0.3838 ns1.180.21NA
Var2NoInitTwin4.790 ns0.3597 ns0.5384 ns1.690.30NA
Const2NoInitTwin3.182 ns0.2889 ns0.4324 ns1.120.22NA
Var1024ZeroTwin302.897 ns17.6873 ns25.3665 ns106.9817.41NA
FloorTwin2.950 ns0.3222 ns0.4822 ns1.040.22NA
Const1024NoInitTwin4.930 ns0.3718 ns0.5450 ns1.740.31NA
Var256ZeroTwin80.174 ns4.3215 ns6.3344 ns28.324.54NA
Const256NoInitTwin3.190 ns0.2671 ns0.3830 ns1.130.21NA
Var32ZeroTwin12.242 ns0.8761 ns1.3113 ns4.320.76NA
Var256NoInitTwin4.744 ns0.3661 ns0.5366 ns1.680.30NA
Var32NoInitTwin4.912 ns0.3708 ns0.5550 ns1.730.31NA
Var1024NoInitTwin6.542 ns0.4634 ns0.6647 ns2.310.40NA

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:

ValorMétodo
Menor delta absoluto0,001 nsConst1024NoInit
Mediana dos deltas absolutos0,158 nsVar32NoInit
Maior delta absoluto2,002 nsConst1024Zero (2,1% relativo)
Mediana dos deltas relativos2,0%n/d
Maior delta relativo16,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.

  • Floor lê o campo _existing do heap e passa pelos testes de faixa do AsSpan. Os métodos de stackalloc formam o Span direto de RSP, sem carga nenhuma. Logo Floor faz mais trabalho, e a subtração subestima o custo de obter o bloco.
  • Floor fatia sempre 256 doubles, mas é subtraído de métodos de 2, 32, 256 e 1024. Como Touch escreve 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.
  • Floor não tem localloc, 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 do localloc isolado.

Com isso declarado, a subtração:

BlocoLiteralVariávelDiferença
16 B0,287 ns2,253 ns1,966 ns
256 B0,466 ns1,913 ns1,447 ns
2 KiB0,320 ns1,881 ns1,562 ns
8 KiB2,010 ns3,111 ns1,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.

BlocoZeragem, tamanho literalZeragem, tamanho variávelFator
16 Babaixo do pisoabaixo do pison/d
256 B1,92 ns7,31 nsnão resolvível
2 KiB32,84 ns75,74 ns2,31x
8 KiB92,63 ns297,61 ns3,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:

BlocoCaminhoInstruções
16 B literalpromovido a local de quadroum vmovdqu ymmword no prólogo, sem ajuste de RSP dedicado ao bloco e sem sondagem
256 B literallocalloc, sondagem única, zeragem desenroladatest dword ptr [rsp], esp, sub rsp, 256 e oito vmovdqu ymmword de 32 bytes
2 KiB literallocalloc, sondagem única, rotina auxiliartest, sub rsp, 0x800 e call CORINFO_HELP_MEMZERO
8 KiB literallocalloc, sondagem em laço, rotina auxiliarlaço test / sub rsp, 0x1000 / cmp rsp, rcx / jae, uma página por volta, e depois call CORINFO_HELP_MEMZERO
256 B de campolocalloc dinâmicoadd rax, 15 / shr rax, 4 e push 0 / push 0 / dec rax / jne, 16 bytes por iteração
8 KiB de campolocalloc dinâmicoo 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 literalCaminho emitido
16 Bpromovido a local de quadro
32 Bpromovido a local de quadro
64 Blocalloc, com sondagem
256 Blocalloc, 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 B2 KiB8 KiB
Taxa com tamanho literalnão resolvível62,4 B/ns88,4 B/ns
Taxa com tamanho variável35,0 B/ns27,0 B/ns27,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 dumpantes 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.

MethodItemsMeanErrorStdDevRatioRatioSDGen0AllocatedAlloc Ratio
Heap120.77 ns1.260 ns1.847 ns1.010.120.001040 B1.00
Stack115.98 ns1.074 ns1.574 ns0.770.100.00
Pool126.69 ns1.824 ns2.730 ns1.290.170.00
HeapControl119.93 ns1.266 ns1.855 ns0.970.120.001040 B1.00
StackControl115.94 ns0.905 ns1.326 ns0.770.090.00
Hybrid145.94 ns3.534 ns5.068 ns2.230.310.00
StackControl2116.10 ns0.926 ns1.385 ns0.780.090.00
HybridNoInit115.26 ns1.286 ns1.925 ns0.740.110.00
HeapControl2120.16 ns1.148 ns1.718 ns0.980.120.001040 B1.00
Heap460.01 ns3.167 ns4.740 ns1.010.110.002188 B1.00
Stack451.57 ns2.723 ns4.076 ns0.860.090.00
Pool458.49 ns3.262 ns4.782 ns0.980.110.00
HeapControl459.20 ns3.121 ns4.575 ns0.990.110.002188 B1.00
StackControl466.06 ns5.021 ns7.201 ns1.110.150.00
Hybrid478.78 ns5.336 ns7.480 ns1.320.160.00
StackControl2453.21 ns2.642 ns3.954 ns0.890.090.00
HybridNoInit450.17 ns2.688 ns4.023 ns0.840.090.00
HeapControl2460.66 ns3.123 ns4.674 ns1.020.110.002188 B1.00
Heap8114.02 ns5.711 ns8.371 ns1.000.100.0037152 B1.00
Stack896.00 ns5.544 ns8.298 ns0.850.090.00
Pool8109.71 ns6.534 ns9.780 ns0.970.110.00
HeapControl8113.55 ns7.260 ns10.866 ns1.000.120.0036152 B1.00
StackControl896.36 ns5.037 ns7.383 ns0.850.090.00
Hybrid8125.41 ns8.181 ns12.245 ns1.110.130.00
StackControl2896.27 ns5.148 ns7.706 ns0.850.090.00
HybridNoInit893.29 ns5.328 ns7.810 ns0.820.090.00
HeapControl28114.13 ns5.095 ns7.626 ns1.010.100.0037152 B1.00
Heap16244.18 ns11.669 ns17.104 ns1.000.100.0067280 B1.00
Stack16204.42 ns11.310 ns16.928 ns0.840.090.00
Pool16205.47 ns10.490 ns15.044 ns0.850.080.00
HeapControl16217.17 ns11.350 ns16.637 ns0.890.090.0067280 B1.00
StackControl16205.38 ns10.353 ns15.496 ns0.840.080.00
Hybrid16218.86 ns10.991 ns16.451 ns0.900.090.00
StackControl216215.44 ns19.689 ns29.469 ns0.890.130.00
HybridNoInit16201.68 ns11.267 ns16.516 ns0.830.090.00
HeapControl216246.69 ns13.770 ns20.610 ns1.010.110.0067280 B1.00
Heap32495.46 ns23.254 ns34.085 ns1.000.090.0129536 B1.00
Stack32490.45 ns25.035 ns37.470 ns0.990.100.00
Pool32465.75 ns27.585 ns41.287 ns0.940.100.00
HeapControl32531.64 ns22.048 ns29.434 ns1.080.090.0124536 B1.00
StackControl32442.56 ns21.381 ns31.340 ns0.900.090.00
Hybrid32454.08 ns24.176 ns36.186 ns0.920.090.00
StackControl232474.01 ns21.583 ns32.305 ns0.960.090.00
HybridNoInit32435.51 ns21.685 ns32.458 ns0.880.090.00
HeapControl232494.11 ns23.256 ns33.353 ns1.000.090.0124536 B1.00
Heap641,264.29 ns70.236 ns105.126 ns1.010.110.02481048 B1.00
Stack641,158.55 ns63.926 ns95.682 ns0.920.100.00
Pool641,158.22 ns65.539 ns98.096 ns0.920.100.00
HeapControl641,232.80 ns63.591 ns95.180 ns0.980.110.02481048 B1.00
StackControl641,129.69 ns55.597 ns81.494 ns0.900.090.00
Hybrid641,128.67 ns57.356 ns85.848 ns0.900.100.00
StackControl2641,150.79 ns62.856 ns94.079 ns0.920.100.00
HybridNoInit641,176.62 ns63.692 ns95.331 ns0.940.100.00
HeapControl2641,264.60 ns67.222 ns100.615 ns1.010.110.02481048 B1.00
Heap1283,576.66 ns194.400 ns290.968 ns1.010.110.04962072 B1.00
Stack1283,634.82 ns216.607 ns317.500 ns1.020.120.00
Pool1283,704.54 ns232.554 ns348.076 ns1.040.130.00
HeapControl1283,472.18 ns138.202 ns198.204 ns0.980.090.04582072 B1.00
StackControl1283,435.22 ns179.758 ns269.054 ns0.970.110.00
Hybrid1283,398.80 ns182.321 ns272.890 ns0.960.110.00
StackControl21283,439.17 ns184.366 ns275.951 ns0.970.110.00
HybridNoInit1283,270.63 ns179.614 ns263.276 ns0.920.100.00
HeapControl21283,703.12 ns197.003 ns294.865 ns1.040.110.04962072 B1.00
Heap51234,386.11 ns2,751.481 ns4,033.085 ns1.010.160.18318216 B1.00
Stack51232,236.91 ns1,618.676 ns2,422.758 ns0.950.120.00
Pool51231,249.87 ns1,817.288 ns2,720.031 ns0.920.120.00
HeapControl51232,516.37 ns1,987.319 ns2,974.525 ns0.960.130.18318216 B1.00
StackControl51233,403.80 ns1,901.003 ns2,845.332 ns0.980.130.00
Hybrid51231,937.15 ns1,640.867 ns2,353.283 ns0.940.120.00
StackControl251232,930.48 ns1,999.010 ns2,992.025 ns0.970.130.00
HybridNoInit51231,320.29 ns1,678.209 ns2,459.898 ns0.920.120.00
HeapControl251232,972.87 ns1,882.675 ns2,759.601 ns0.970.130.18318216 B1.00

A dispersão dentro de cada grupo de três métodos byte a byte idênticos:

ItensGrupo newGrupo stackalloc
14,2%1,0%
42,5%28,1%
80,5%0,4%
1613,6%5,4%
327,6%10,8%
642,6%2,6%
1286,7%5,8%
5125,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:

newton newton

Resultado nesta máquina, por duplicação e depois busca binária:

ElementosBytes pedidosResultado
131.0721.048.576sobreviveu
163.8401.310.720sobreviveu
180.2241.441.792sobreviveu
187.3921.499.136sobreviveu
188.4161.507.328STACK OVERFLOW (0xC00000FD)
196.6081.572.864STACK OVERFLOW (0xC00000FD)
262.1442.097.152STACK 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

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çõesTotal pedidoResultado
64512 KiBsobreviveu
1281024 KiBsobreviveu
1601280 KiBsobreviveu
1801440 KiBsobreviveu
1841472 KiBSTACK OVERFLOW (0xC00000FD)
2001600 KiBSTACK OVERFLOW (0xC00000FD)
4003200 KiBSTACK 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:

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

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 do ArrayPool.Shared, organizados em potências de dois, é leitura minha do código, não contrato público. Quem itera por array.Length em 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 Span depois do Return é use-after-free lógico: o buffer já pertence a outro chamador.
  • clearArray: false entrega 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:

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:

newton

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:

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 projeto Codegen/, 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 memzero converteria a interpolação em previsão testada, e eu não rodei esse ponto.
  • Nenhum número de tempo do stackalloc transfere 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 coluna Allocated é 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 Allocated prova 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 com dotnet-dump e dotnet-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 TieredCompilationQuickJitForLoops e 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 stosb rápido, os números literais mudam. E ele não vale para span.Clear(), Array.Clear nem Unsafe.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 de push.
  • 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.Shared foi medido sem concorrência. O cache por thread e o descarte sob pressão de memória são da implementação em dotnet/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.

Compartilhe:

Tiago Tartari

Tiago Tartari

Eu ajudo e capacito pessoas e organizações a transformar problemas complexos em soluções práticas usando a tecnologia para atingir resultados extraordinários.

Qual é o desafio
que você tem hoje?