Tree Borrows: um modelo de regras de aliasing para código unsafe em Rust
(plf.inf.ethz.ch)- Para que o compilador Rust use as garantias de aliasing de ponteiros em otimizações, é preciso definir com clareza onde o código unsafe viola as regras
- O modelo existente Stacked Borrows apresentou esse critério, mas não consegue acomodar suficientemente padrões comuns em código unsafe Rust real nem recursos recentes do borrow checker
- Tree Borrows altera a estrutura central do Stacked Borrows de uma pilha para uma árvore, permitindo representar mais padrões válidos
- Em uma avaliação com 30.000 dos crates Rust mais usados, ele rejeitou 54% menos casos de teste do que o Stacked Borrows
- Provas em Rocq confirmaram que é possível manter a maior parte das otimizações existentes e também habilitar novas otimizações, como reordenação read-read
Regras de aliasing necessárias em Rust unsafe
- Rust oferece garantias fortes, como segurança de memória e prevenção de data races, por meio de um sistema de tipos baseado em ownership
- Porém, em áreas de código unsafe, a segurança não é garantida automaticamente, e são necessárias regras separadas que o programador deve seguir
- O compilador busca fortalecer otimizações dentro de funções usando as garantias do sistema de tipos, especialmente informações relacionadas a aliasing de ponteiros
- Código unsafe escrito incorretamente pode quebrar essas otimizações, por isso é importante ter um critério claro para determinar que código deve ser considerado “badly behaved”
- O trabalho anterior Stacked Borrows definiu esse critério, mas tem limitações
- Rejeita vários padrões comuns em código unsafe Rust real
- Não reflete recursos avançados introduzidos recentemente no borrow checker do Rust
Abordagem e resultados da avaliação do Tree Borrows
- Tree Borrows é definido substituindo a pilha, estrutura central do Stacked Borrows, por uma árvore
- Essa mudança estrutural relaxa as limitações do modelo anterior
- Em uma avaliação com 30.000 dos crates Rust mais usados, o número de casos de teste rejeitados caiu 54% em comparação com o Stacked Borrows
- Provas em Rocq também confirmaram propriedades relacionadas a otimizações
- Mantém a maior parte das otimizações permitidas pelo Stacked Borrows
- Também permite uma nova otimização importante: read-read reorderings
- Tree Borrows recebeu o PLDI'25 Distinguished Paper Award
- Materiais relacionados
1 comentários
Opiniões no Hacker News
Como bônus, há também uma apresentação recente do grupo de Ralf Jung sobre a tentativa de especificar com precisão, em forma executável, a semântica de execução do Rust em um dialeto de Rust: https://youtube.com/watch?v=yoeuW_dSe0o
Torvalds há muito argumenta que as regras estritas de aliasing do C causam mais prejuízo do que benefício, e isso soa convincente. Um exemplo está aqui: https://lore.kernel.org/all/CAHk-=wgq1DvgNVoodk7JKc6BuU1m9Un... Se você se interessa pelo tema, vale ler a thread inteira
Pela minha experiência limitada, não parece que Rust seja fundamentalmente diferente. Pelo menos quando unsafe entra na história, ainda menos
Acho que elas são mais úteis para o compilador e menos pesadas para o programador. Além disso, há de fato uma forma de escapar dentro da linguagem: basta usar ponteiros brutos. E também há ferramentas para verificar o código
No fim das contas, como tudo em design de linguagens, é uma questão de compromisso, e talvez o Rust tenha encontrado um novo sweet spot para esse tipo de otimização. O tempo dirá se isso está certo
Em C existe aquele dispositivo meio “bomba nuclear” chamado
restrict, que, pela minha experiência, só produziu algum efeito no clang e no gcc quando aplicado a argumentos de função. Análise de aliasing baseada em tipos é difícil de usar em geral, e não dá para criar infinitas cópias do tipoint64_t, nem você provavelmente gostaria disso. Também é irritante que seja obrigatório usarmemcpypara reinterpretar como outro tipoJá as referências em Rust têm tempo de vida, escopo e mutabilidade delimitados de forma refinada, e não se importam tanto com o tipo “físico” em si. Por isso, é possível reinterpretar e alternar a mesma memória entre
&mut i32/&i32e&mut i64/&i64. Desde que uma abstração unsafe não forneça referências&mutsimultaneamente sobrepostas, ou divida um único&mutem vários&mutque não se sobrepõem, é possível ler e escrever metade de um valor, ou vários valores, com leituras e escritas normais de Rust seguroAnálise de aliasing é muito importante para obter bom desempenho hoje em dia. Mas também é preciso lembrar que os maiores ganhos vêm das heurísticas mais simples. Por exemplo, duas cargas que usam o mesmo valor SSA como ponteiro necessariamente são aliases uma da outra
Do ponto de vista do LLVM, é o BasicAA que cumpre esse papel. É um conjunto de heurísticas simples, próximo de “se for possível rastrear o ponto de alocação do objeto, resolva a consulta de aliasing de forma conclusiva; caso contrário, não sei”
A verdadeira pergunta é qual é o valor da análise de aliasing além das verificações básicas e óbvias. Quando as consultas de aliasing deixam de ser resolvidas trivialmente, o que dá para fazer com o resultado também costuma diminuir bastante, ficando quase só em encontrar riscos de movimentação de código. O ganho disso é muito menor
Um experimento que eu gostaria de fazer é medir o ganho total de velocidade que uma análise de aliasing teoricamente perfeita proporcionaria. Meu palpite é que, mesmo em código não HPC como o kernel Linux, seria algo em torno de 20%
[1] Isso não inclui otimizações heroicas, como transformações de layout de dados, que não seriam tentadas sem uma análise de aliasing de alta qualidade. Como já sabemos que essa análise de aliasing não existe na prática, essas otimizações também não seriam tentadas, e acho que não vale incluí-las no ganho de velocidade esperado
O aliasing do C se baseia apenas em tipos, e por isso outro nome para ele é análise de aliasing baseada em tipos, ou TBAA
Encontrei uma afirmação de que
noaliascontribui com cerca de 5% de ganho de desempenho em tempo de execução, mas o material claramente é bem antigohttps://github.com/rust-lang/rust/issues/54878#issuecomment-...
https://news.ycombinator.com/item?id=22281205
https://news.ycombinator.com/item?id=17715399
Parecia que a explicação era que, ao criar um
*mut i32a partir de um&mute usar*x = 10em vez dewrite(x), não se usa o empréstimo implícito em duas fases, então o compilador deveria rejeitar; mas, na prática, ele aceita*x = 10;reporta erro, enquanto a versão comwrite(x);nãoO erro é do tipo “Undefined Behavior: attempting a write access using [...] but that tag does not exist in the borrow stack for this location”
o próprio rustc não tem motivo para rejeitar nenhum dos dois.
yé*mute, do ponto de vista do sistema de tipos em tempo de compilação, não tem relação de empréstimo ou tempo de vida comx, que é&mutO verificador de empréstimos atual usa uma análise mais restritiva e não consegue detectar esse conflito específico entre um ponteiro bruto e uma referência mutável
Na minha experiência prática [1] [2], ele também permitiu código razoável que seria ilegal no Stacked Borrows
[1] https://github.com/Voultapher/sort-research-rs/blob/main/wri... coluna Miri
[2] https://github.com/rust-lang/rust/blob/6b3ae3f6e45a33c2d95fa...
Ele passou do verificador de empréstimos baseado em escopo para o verificador com tempos de vida não lexicais, e a próxima implementação experimental, Polonius, também existe como opção. Mas, quando uma nova implementação fica pronta para produção, a antiga é descartada. Não há motivo para escolher a antiga
A verificação de empréstimos é rápida, e a nova implementação aceita estritamente mais programas corretos
Além disso, existem os tipos
RceRefCell, que permitem obter mais flexibilidade pagando o custo de verificações em tempo de execuçãoTodos têm custos e capacidades diferentes em implementação, desempenho e experiência do desenvolvedor
E o que a maior parte do mundo fora de Rust realmente busca é a produtividade do gerenciamento automático de recursos. Usa-se gerenciamento automático de recursos, seja qual for o método, e combina-se um desses sistemas de tipos apenas nos caminhos em que desempenho é importante
Nesse contexto, “Rust” pode ser visto apenas como “os invariantes que as pessoas normalmente querem” e “um conjunto de otimizações que assume esses invariantes comuns, nem mais nem menos”
A maior parte do tempo de compilação é gasta com resolução de traits, monomorfização, passes de otimização do LLVM e linking
Talvez seja uma pergunta idiota, mas fico me perguntando se não daria para rodar várias implementações em threads paralelas e deixar vencer a que der primeiro um resultado positivo
Usar ponteiros para fazer várias referências mutáveis à mesma variável existirem ao mesmo tempo é comportamento indefinido. A menos que eu tenha entendido errado a intenção do artigo, é isso que parece
O código acima é aceito pelo compilador Rust, mas viola regras. A questão é: quais regras ele viola?
Essencialmente, aquilo que o verificador de empréstimos aceita é legal; unsafe pode expressar coisas ilegais ou comportamento indefinido; e existe um conjunto de regras mais amplo do que o que o verificador de empréstimos consegue checar, mas que ainda é legal e tem comportamento definido
O objetivo desta pesquisa é especificar esse conjunto de regras com precisão. Em linhas gerais, ele se aproxima de “ponteiros graváveis não podem ter aliases”, mas detalhes como ponteiros internos, invalidação de iteradores e se o problema é criar um ponteiro ruim ou usá-lo são muito difíceis
O artigo anterior sobre Stacked Borrows era mais simples, mas também mais restritivo, então código unsafe real frequentemente não passava nas regras. Tree Borrows é mais amplo e permite mais código, ao mesmo tempo que continua sendo comprovadamente seguro
Tree Borrows propõe justamente esse tipo de definição
Aqui, “o código pode fazer isso” significa “é possível escrever, compilar e executar esse código, e, sem algo como Tree Borrows, não há base para afirmar que há um problema nele”
Você já aceitou que devemos dizer que esse código é comportamento indefinido, ou seja, que algo como Tree Borrows é necessário. Esta parte do artigo é a argumentação de por que algo assim é necessário
https://play.rust-lang.org/?version=stable&mode=debug&editio...
Ele diz que, como os desenvolvedores do compilador Rust claramente querem dar suporte a otimizações baseadas em aliasing, é necessário ter uma forma de “excluir” contraexemplos como o de cima da consideração
unsafeé destinado a casos em que é difícil provar a validade do código pela análise de lifetimes do Rust, mas pode ser abusado para fazer muito mais do que isso