Async Rust nunca saiu do estado de MVP
(tweedegolf.nl)- Async Rust permite executar código independente de executor tanto em servidores quanto em microcontroladores, mas a máquina de estados gerada pelo compilador faz o tamanho do binário crescer de forma especialmente visível em sistemas embarcados
- Mesmo um exemplo simples como
bar(), com 2 pontos deawait, gera 360 linhas de MIR e os estadosUnresumed,Returned,Panicked,Suspend0,Suspend1, enquanto a versão síncrona precisa de apenas 23 linhas - Se, ao fazer poll de um future já concluído novamente, ele passar a retornar
Poll::Pendingem vez depanic, é possível cumprir o contrato sem comportamento unsafe, e em experimentos isso reduziu o tamanho do binário em 2% a 5% em firmware embarcado - Mesmo
async { 5 }, semawait, hoje ainda gera uma máquina de estados com 3 estados básicos, mas otimizar isso para sempre retornarPoll::Ready(5)reduziu o tamanho de binários embarcados em 0,2% - O Project Goal proposto quer avançar no compilador com a remoção do panic após conclusão em modo release, eliminação da máquina de estados em blocos async sem await, inline de futures com um único await e fusão de estados idênticos
O problema de inchaço no nível do compilador em Async Rust
- Async Rust permite executar código independente de executor ao mesmo tempo em servidores e microcontroladores, mas em microcontroladores pequenos o aumento no tamanho do binário é especialmente perceptível
- O blog do Rust apresentou async/await como uma abstração de custo zero, mas na prática async gera bastante inchaço, e o mesmo problema também existe em desktop e servidor, só aparece menos por causa da abundância de memória e recursos de computação
- Após as soluções alternativas para evitar inchaço ao escrever código async, foi submetido um Project Goal para resolver o problema no compilador
- O problema de futures maiores do que o necessário e com cópias excessivas está fora do escopo
- Esse problema já é conhecido, e há um PR aberto tratando parte disso: https://github.com/rust-lang/rust/pull/135527
Estrutura do future gerado
- O código de exemplo faz
foo()retornarasync { 5 }, ebar()executafoo().await + foo().await- Exemplo no Godbolt: godbolt
- Como
bartem 2 pontos de await, a máquina de estados precisa de pelo menos 2 estados, mas na prática vários estados extras são gerados - O compilador Rust pode despejar MIR em várias passagens, e a passagem
coroutine_resumeé a última passagem de MIR específica para async- Async ainda existe no MIR, mas não no LLVM IR, então a transformação de async em máquina de estados acontece na fase de MIR
- A função
bargera 360 linhas de MIR, enquanto a versão síncrona usa apenas 23 linhas - O
CoroutineLayoutemitido pelo compilador é, na prática, um conjunto de estados em forma de enumUnresumed: estado inicialReturned: estado concluídoPanicked: estado após panicSuspend0: primeiro ponto de await, armazenando o future defooSuspend1: segundo ponto de await, armazenando o primeiro resultado e o segundo future defoo
Future::pollé uma função segura, então chamá-la novamente depois que o future já terminou não pode causar UB- Hoje, após
Suspend1, ele retornaReadye muda o future para o estadoReturned - Se houver novo poll nesse estado, ocorre panic
- Hoje, após
- O estado
Panickedparece existir para impedir novo poll de um future depois que uma função async entrou em panic e isso foi capturado comcatch_unwind- Depois de um panic, o future pode ficar em estado incompleto, então fazer poll novamente poderia levar a UB
- Esse mecanismo é muito parecido com poisoning de mutex
- Essa interpretação do estado
Panickedtem cerca de 90% de confiança, já que é difícil encontrar documentação definitiva
É realmente necessário dar panic ao fazer poll após a conclusão?
- Hoje, um future no estado
Returnedentra em panic, mas isso não é estritamente necessário- A única exigência é não causar UB
- Panic é relativamente caro e adiciona um caminho com efeitos colaterais difíceis de remover por otimização
- Se um future já concluído passar a retornar
Poll::Pendingquando receber novo poll, ainda será possível cumprir o contrato do tipoFuturesem comportamento unsafe - Ao modificar o compilador para testar essa abordagem, foi observada uma redução de 2% a 5% no tamanho do binário em firmwares embarcados com async
- Foi proposto oferecer esse comportamento como uma opção, de forma parecida com
overflow-checks = falsepara overflow de inteiros- Em builds de debug, o panic continuaria para expor imediatamente o comportamento incorreto
- Em builds de release, seria possível obter futures menores
- Ao usar
panic=abort, pode haver margem para remover o próprio estadoPanicked, mas o impacto disso ainda precisa de análise adicional
Mesmo sem await, uma máquina de estados sempre é gerada
foo()retorna apenasasync { 5 }, então a forma otimizada manualmente seria um future sem estado que sempre retornaPoll::Ready(5)- Porém, no MIR gerado pelo compilador, ainda existem os 3 estados básicos:
Unresumed,ReturnedePanicked- Ao fazer poll, o discriminante do estado atual é verificado e ocorre um desvio condicional
- Se houver novo poll após a conclusão, acontece um panic com
`async fn` resumed after completion
- Nesse caso, seria possível otimizar para não criar máquina de estados e simplesmente retornar
Poll::Ready(5)toda vez - Ao aplicar isso experimentalmente no compilador, o tamanho do binário embarcado caiu 0,2%
- A redução não é grande, mas por ser uma otimização simples, talvez ainda valha a pena
- Essa otimização muda ligeiramente o comportamento, mas só afeta executores que violam o contrato
- Hoje o compilador entra em panic em polls posteriores
- Após a otimização, o future sempre retorna
Ready
Só o LLVM não basta
- Mesmo que a saída de MIR seja ineficiente, em alguns casos o LLVM consegue limpar tudo, mas as condições são limitadas
- O future precisa ser simples o suficiente
- É preciso usar
opt-level=3
- Quando o future fica mais complexo, o LLVM não consegue eliminar tudo, e em código async idiomático em Rust os futures tendem a ser profundamente aninhados, então a complexidade cresce rápido
- Em ambientes como embarcados ou wasm, onde otimização por tamanho é comum, o LLVM não consegue resolver isso sozinho
- Exemplo no Godbolt: https://godbolt.org/z/58ahb3nne
- No assembly gerado, o LLVM sabe que
fooretorna 5, mas não consegue otimizar a resposta debarpara 10 - A chamada da função
polldefootambém permanece - O motivo são caminhos potenciais de panic que o compilador não consegue descartar completamente
- O LLVM não sabe que
foona prática só será chamado uma vez e não entrará em panic
- No assembly gerado, o LLVM sabe que
- Se os ramos de panic forem comentados no IR, a otimização melhora: https://godbolt.org/z/38KqjsY8E
- Em vez de esperar uma otimização posterior do LLVM, o compilador precisa fornecer uma entrada melhor para ele
O inline de futures não está funcionando bem
- Inline é importante porque habilita passagens posteriores de otimização, mas os futures gerados em Rust hoje não são inlinados nessa fase inicial
- Depois que cada future ganha sua implementação, LLVM e linker até têm chance de fazer inline, mas por causa dos problemas anteriores esse momento já é tarde demais
- A oportunidade de inline mais direta é quando
bar()apenas faz algo comofoo(blah).await- Esse padrão aparece com frequência ao criar abstrações com traits
- Hoje o compilador cria uma máquina de estados para
bare chama a máquina de estados defoodentro dela - De forma mais eficiente,
barpoderia ser o próprio future defoo
- Quando há preâmbulo e pós-processamento, o caso fica mais complexo
- Exemplo:
bar(input)criablahcominput > 10, depois fazfoo(blah).awaite aplica* 2ao resultado - Isso é comum ao transformar funções async para outra assinatura, especialmente em implementações de trait
- Exemplo:
- Mesmo nessa forma,
barnão precisa de estado async próprio- Não há dados preservados além do único ponto de await, exceto o valor capturado por
foo - Ainda assim,
barnão pode simplesmente se tornar o própriofoo, e pode depender da maior parte do estado defoo
- Não há dados preservados além do único ponto de await, exceto o valor capturado por
- Numa implementação manual,
BarFutpoderia ter os estadosUnresumed { input }eInlined { foo: FooFut }- No primeiro poll, ele executaria o preâmbulo, criaria
foo(blah)e mudaria paraInlined - Depois disso, aplicaria o pós-processamento ao resultado de
foo.poll(cx)
- No primeiro poll, ele executaria o preâmbulo, criaria
- Se fosse possível executar código antecipadamente até o primeiro ponto de await, também daria para remover o estado
Unresumed, mas isso não pode mudar porque há a garantia de que o future não faz nada antes de receber poll - Se fosse possível consultar propriedades do future durante o poll, mais otimizações de inline poderiam acontecer
- Por exemplo, se fosse sabido que o future sempre retorna ready no primeiro poll, o future chamador não precisaria criar estado para aquele ponto de await
- Aplicar esse tipo de otimização recursivamente permitiria fundir muitos futures em máquinas de estados bem mais simples
- Pela estrutura atual do
rustc, cada bloco async parece ser transformado isoladamente e os dados relevantes não são preservados depois, o que impede esse tipo de consulta - O inline de futures ainda não foi testado experimentalmente, mas há expectativa de grande benefício para tamanho de binário e desempenho
Fusão de estados idênticos
- Cada ponto de await em um bloco async adiciona um estado extra à máquina de estados
- O código abaixo é natural, mas como os dois ramos fazem await da mesma função async, surgem 2 estados idênticos
CommandId::A => send_response(123).awaitCommandId::B => send_response(456).await
- Nesse caso, o
CoroutineLayoutpassa a ter_s0e_s1, ambos armazenando o mesmo tipo de coroutine desend_response, além dos estadosSuspend0eSuspend1 - O MIR dessa função tem 456 linhas, e muitos blocos básicos são essencialmente duplicados
- Se o código for refatorado manualmente para primeiro calcular apenas o valor da resposta e depois chamar uma única vez
send_response(response).await, os estados duplicados desaparecemCommandId::Avira123CommandId::Bvira456- Depois disso,
send_response(response).await
- Após a refatoração, o
CoroutineLayoutpassa a ter apenas um future armazenado e sobra só o estadoSuspend0 - O comprimento total do MIR cai para 302 linhas, eliminando a duplicação
- Portanto, parece útil ter uma passagem de otimização que detecte caminhos de código e estados equivalentes para fundi-los
- Essa otimização provavelmente combina bem com uma passagem de inline de futures
Links dos experimentos e benchmarks adicionais
- Aplicando os dois experimentos juntos, houve cerca de 3% de ganho de desempenho em um benchmark sintético x86 usando o executor
smol - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
Pedido de apoio ao Project Goal
- Esse trabalho foi submetido como Project Goal para ser realizado no compilador: https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
- Sem financiamento, é difícil avançar muito, então é necessário apoio parcial ou integral de empresas ou organizações que possam se beneficiar desse trabalho
- O contato é
dion@tweedegolf.com - O escopo do trabalho e o volume de financiamento necessário são flexíveis, mas a estimativa é que €30k permitiriam concluir tudo ou uma parte substancial
1 comentários
Opiniões no Lobste.rs
Foi um texto muito mais construtivo do que eu esperava só pelo título
Espero que quem queira trabalhar nisso receba o apoio necessário
É bom ver esse problema sendo tratado. Já vi algumas vezes gente dizendo que hoje o rustc passa código demais para o LLVM e espera que o otimizador resolva tudo, e este texto em especial também está pedindo financiamento para esse trabalho
Meu Deus, eu fui burro
Sempre pensei que async fosse inerentemente “inchado”, já que de qualquer forma precisa de runtime, rastreamento de tarefas e polling para verificar conclusão. Então esse overhead nunca é zero
Eu entendia que a “abstração sem custo” aqui era sobre o recurso da linguagem, separada do runtime adicional
Nunca nem me ocorreu olhar o que o rustc emite antes de passar para o LLVM
Para quem não está familiarizado com async Rust:
Isso é realmente verdade. Até uma árvore de chamadas async aninhadas, quando recebe otimização máxima, acaba consolidada em uma única struct com máquinas de estado dentro. É uma abordagem realmente engenhosa
Em build de release, chegar a esse caso gera algum tipo de deadlock? Ou pode haver vazamento por causa de tarefas esperando trabalhos que ficam sempre em
Pending?Não dá para fazer polling incorreto usando
.awaitAlgumas ideias me ocorreram:
panic=unwind. Tirando alguns test harnesses, quase nunca vi vantagens sobrepanic=abortque compensem o custo. Mesmo para test harnesses, no Linux parece que daria para aplicar uma escolha parecida, usandoclonede forma meio obscura para fazerwaitna thread executora em vez depthread_join. Posso estar errado nessa parteO link morreu agora há pouco para mais alguém?
Edit: o post do blog aparece por meio segundo e depois vai para uma página 404
Edit 2: entrei na lista de posts do blog, cliquei em várias coisas, e até abrindo aquele post a partir da lista vai para uma página 404. Como alguém consegue quebrar assim um blog estático, ou pelo menos que deveria ser estático?
Para constar, acho que segui os mesmos passos de reprodução e não vi nenhum 404. Testei no celular e no desktop, com JavaScript ligado e desligado. Então o que você encontrou talvez fosse mais complexo do que parecia