1 pontos por GN⁺ 2 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • 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 de await, gera 360 linhas de MIR e os estados Unresumed, 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::Pending em vez de panic, é 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 }, sem await, hoje ainda gera uma máquina de estados com 3 estados básicos, mas otimizar isso para sempre retornar Poll::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

Estrutura do future gerado

  • O código de exemplo faz foo() retornar async { 5 }, e bar() executa foo().await + foo().await
  • Como bar tem 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 bar gera 360 linhas de MIR, enquanto a versão síncrona usa apenas 23 linhas
  • O CoroutineLayout emitido pelo compilador é, na prática, um conjunto de estados em forma de enum
    • Unresumed: estado inicial
    • Returned: estado concluído
    • Panicked: estado após panic
    • Suspend0: primeiro ponto de await, armazenando o future de foo
    • Suspend1: segundo ponto de await, armazenando o primeiro resultado e o segundo future de foo
  • 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 retorna Ready e muda o future para o estado Returned
    • Se houver novo poll nesse estado, ocorre panic
  • O estado Panicked parece existir para impedir novo poll de um future depois que uma função async entrou em panic e isso foi capturado com catch_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 Panicked tem 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 Returned entra 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::Pending quando receber novo poll, ainda será possível cumprir o contrato do tipo Future sem 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 = false para 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 estado Panicked, mas o impacto disso ainda precisa de análise adicional

Mesmo sem await, uma máquina de estados sempre é gerada

  • foo() retorna apenas async { 5 }, então a forma otimizada manualmente seria um future sem estado que sempre retorna Poll::Ready(5)
  • Porém, no MIR gerado pelo compilador, ainda existem os 3 estados básicos: Unresumed, Returned e Panicked
    • 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 foo retorna 5, mas não consegue otimizar a resposta de bar para 10
    • A chamada da função poll de foo também permanece
    • O motivo são caminhos potenciais de panic que o compilador não consegue descartar completamente
    • O LLVM não sabe que foo na prática só será chamado uma vez e não entrará em panic
  • 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 como foo(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 bar e chama a máquina de estados de foo dentro dela
    • De forma mais eficiente, bar poderia ser o próprio future de foo
  • Quando há preâmbulo e pós-processamento, o caso fica mais complexo
    • Exemplo: bar(input) cria blah com input > 10, depois faz foo(blah).await e aplica * 2 ao resultado
    • Isso é comum ao transformar funções async para outra assinatura, especialmente em implementações de trait
  • Mesmo nessa forma, bar nã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, bar não pode simplesmente se tornar o próprio foo, e pode depender da maior parte do estado de foo
  • Numa implementação manual, BarFut poderia ter os estados Unresumed { input } e Inlined { foo: FooFut }
    • No primeiro poll, ele executaria o preâmbulo, criaria foo(blah) e mudaria para Inlined
    • Depois disso, aplicaria o pós-processamento ao resultado de foo.poll(cx)
  • 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).await
    • CommandId::B => send_response(456).await
  • Nesse caso, o CoroutineLayout passa a ter _s0 e _s1, ambos armazenando o mesmo tipo de coroutine de send_response, além dos estados Suspend0 e Suspend1
  • 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 desaparecem
    • CommandId::A vira 123
    • CommandId::B vira 456
    • Depois disso, send_response(response).await
  • Após a refatoração, o CoroutineLayout passa a ter apenas um future armazenado e sobra só o estado Suspend0
  • 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

Pedido de apoio ao Project Goal

1 comentários

 
GN⁺ 2 시간 전
Opiniões no Lobste.rs
  • Foi um texto muito mais construtivo do que eu esperava só pelo título

    • Acho que está bem próximo dos fatos. Já se passaram 7 anos desde o lançamento do MVP, mas quase não houve avanço no design da linguagem nem na implementação do compilador, e as pessoas que em grande parte criaram o MVP reduziram sua atuação no projeto mais ou menos na mesma época, então o repasse adiante acabou parando
      Espero que quem queira trabalhar nisso receba o apoio necessário
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

    Stop generating statemachines that don’t have to be there
    Make the compiler’s job easier by removing panic paths and branches
    Make statemachines smaller

    É 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:

    It's amazing how we can write executor agnostic code that can run concurrently on huge servers and tiny microcontrollers.

    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?

    • Sim. Esses futures ficam em um estado travado e nunca terminam. Mas esse estado só pode ser alcançado por código async de baixo nível que já esteja com bug, e código que não consegue rastrear corretamente um future concluído provavelmente já está causando vazamentos e deadlocks
      Não dá para fazer polling incorreto usando .await
  • Algumas ideias me ocorreram:

    1. Este texto parece defender que mais lógica de otimização seja tirada de dentro do LLVM e movida para a camada MIR. Por exemplo, entendo por que o inlining de funções async seria mais fácil no MIR do que no LLVM. Se isso foi possível no MIR para async, fico pensando se daria para generalizar essa lógica também para funções síncronas e remover alguns passes de otimização do LLVM. Sei que é um trabalho grande, e isso é mais uma direção do que uma pergunta prática. Quando o compilador de frontend/middle-end atinge certo nível de complexidade, talvez faça mais sentido deslocar uma parte considerável das otimizações genéricas do LLVM para outro lugar
    2. Ainda não gosto de panic=unwind. Tirando alguns test harnesses, quase nunca vi vantagens sobre panic=abort que compensem o custo. Mesmo para test harnesses, no Linux parece que daria para aplicar uma escolha parecida, usando clone de forma meio obscura para fazer wait na thread executora em vez de pthread_join. Posso estar errado nessa parte
  • O 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?

    • O tom parece um pouco desnecessariamente rude e agressivo. Sites também podem ter bugs, e reportar isso é útil, mas este comentário soa um pouco mal-humorado
      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