Rust: ponto flutuante para conversão inteira pode causar comportamento indefinido

Criado em 31 out. 2013  ·  234Comentários  ·  Fonte: rust-lang/rust

Status em 18-04-2020

Pretendemos estabilizar o comportamento de saturating-float-casts para as e estabilizar as funções de biblioteca não seguras que tratam do comportamento anterior. Veja # 71269 para a discussão mais recente sobre esse processo de estabilização.

Status em 05/11/2018

Um sinalizador foi implementado no compilador, -Zsaturating-float-casts , que fará com que todos os float para inteiros tenham um comportamento "saturante", onde se estiver fora dos limites é fixado no limite mais próximo. Uma chamada para benchmarking desta mudança saiu há algum tempo. Os resultados, embora positivos em muitos projetos, são bastante negativos para alguns projetos e indicam que não terminamos aqui.

As próximas etapas são descobrir como recuperar o desempenho para estes casos:

  • Uma opção é pegar o comportamento atual de as cast (que é UB em alguns casos) e adicionar unsafe funções para os tipos relevantes e outros.
  • Outra é esperar que o LLVM adicione um conceito freeze que significa que obtemos um padrão de bit de lixo, mas pelo menos não é UB
  • Outra é implementar casts via assembly embutido no LLVM IR, visto que o codegen atual não é altamente otimizado.

Status antigo

ATUALIZAÇÃO (por @nikomatsakis): Após muita discussão, obtivemos os rudimentos de um plano de como resolver este problema. Mas precisamos de ajuda para realmente investigar o impacto no desempenho e definir os detalhes finais!


O PROBLEMA ORIGINAL SEGUE:

Se o valor não couber em ty2, os resultados são indefinidos.

1.04E+17 as u8
A-LLVM C-bug I-unsound 💥 P-medium T-lang

Comentários muito úteis

Comecei alguns trabalhos para implementar intrínsecos para saturar float para int casts no LLVM: https://reviews.llvm.org/D54749

Se isso for para algum lugar, fornecerá uma maneira de sobrecarga relativamente baixa de obter a semântica saturante.

Todos 234 comentários

Nomeando

aceito para P-alto, mesmo raciocínio que # 10183

Eu não acho que isso seja incompatível com versões anteriores em um nível de linguagem. Isso não fará com que o código que estava funcionando bem pare de funcionar. Nomeando.

mudando para P-alto, mesmo raciocínio que # 10183

Como nos propomos a resolver isso e # 10185? Visto que se o comportamento é definido ou não depende do valor dinâmico do número sendo lançado, parece que a única solução é inserir verificações dinâmicas. Parece que concordamos que não queremos fazer isso para estouro aritmético, estamos felizes em fazer isso para estouro de elenco?

Poderíamos adicionar um intrínseco ao LLVM que realiza uma "conversão segura". @zwarich pode ter outras idéias.

AFAIK, a única solução no momento é usar os intrínsecos específicos do alvo. Isso é o que JavaScriptCore faz, pelo menos de acordo com alguém a quem perguntei.

Oh, isso é bastante fácil então.

ping @pnkfelix isso é coberto pelo novo material de verificação de estouro?

Essas conversões não são verificadas pelo rustc com declarações de depuração.

Estou feliz em lidar com isso, mas preciso de uma solução concreta. Pessoalmente, acho que isso deve ser verificado junto com o estouro da aritmética de inteiros, pois é um problema muito semelhante. Eu realmente não me importo com o que fazemos.

Observe que esse problema está causando um ICE quando usado em certas expressões constantes.

Isso permite violar a segurança da memória em caso de ferrugem segura, exemplo desta postagem do fórum :

Undefs, hein? Undefs são divertidos. Eles tendem a se propagar. Depois de alguns minutos de discussão ..

#[inline(never)]
pub fn f(ary: &[u8; 5]) -> &[u8] {
    let idx = 1e100f64 as usize;
    &ary[idx..]
}

fn main() {
    println!("{}", f(&[1; 5])[0xdeadbeef]);
}

segfaults no meu sistema (mais tarde à noite) com -O.

Marcação com I-unsound devido à violação da segurança da memória em caso de ferrugem segura.

@bluss , isso não segfualt para mim, só dá um erro de asserção. incontestável, já que fui eu que o adicionei

Suspiro, esqueci o -O, remarcação.

renomeando para P-high. Aparentemente, isso foi em algum ponto alto, mas diminuiu com o tempo. Isso parece muito importante para correção.

EDIT: não reage ao comentário de triagem, adicionando rótulo manualmente.

Parece que o precedente do estouro de coisas (por exemplo, para mudança) é apenas se estabelecer em algum comportamento. Java parece produzir o resultado módulo do intervalo, o que parece razoável; Não tenho certeza de que tipo de código LLVM precisamos para lidar com isso.

De acordo com https://docs.oracle.com/javase/specs/jls/se7/html/jls-5.html#jls -5.1.3 Java também garante que NaN valores são mapeados para 0 e infinitos para o número inteiro representável mínimo / máximo. Além disso, a regra Java para a conversão é mais complexa do que apenas embrulhar, pode ser uma combinação de saturação (para a conversão para int ou long ) e embrulhar (para a conversão para tipos integrais menores , se necessário). A replicação de todo o algoritmo de conversão do Java certamente é possível, mas exigiria uma boa quantidade de operações para cada conversão. Em particular, para garantir que o resultado de uma operação fpto[us]i no LLVM não exiba um comportamento indefinido, uma verificação de intervalo seria necessária.

Como alternativa, eu sugeriria que float-> int casts são garantidos como válidos apenas se o truncamento do valor original pode ser representado como um valor do tipo de destino (ou talvez como [iu]size ?) E para têm afirmações sobre compilações de depuração que geram pânico quando o valor não foi representado fielmente.

As principais vantagens da abordagem Java são que a função de conversão é total, mas isso também significa que um comportamento inesperado pode surgir: impediria um comportamento indefinido, mas seria fácil ser enganado e não verificar se o elenco realmente fazia algum sentido (infelizmente isso também é verdade para os outros elencos: preocupado:).

A outra abordagem corresponde à usada atualmente para operações aritméticas: implementação simples e eficiente na versão, pânico acionado pela verificação de intervalo na depuração. Infelizmente, ao contrário de outros as casts, isso tornaria essa conversão verificada, o que pode ser surpreendente para o usuário (embora talvez a analogia com as operações aritméticas possa ajudar aqui). Isso também quebraria algum código, mas AFAICT só deve acontecer para o código que atualmente depende de um comportamento indefinido (ou seja, substituiria o comportamento indefinido "vamos retornar qualquer inteiro, obviamente você não se importa com qual" com um pânico).

O problema não é "vamos retornar qualquer inteiro, você obviamente não se importa com qual", é que ele causa um undef que não é um valor aleatório, mas sim um valor de demônio nasal e o LLVM pode assumir que o undef nunca ocorre permitindo otimizações que fazem coisas terrivelmente incorretas. Se fosse um valor aleatório, mas crucialmente não undef, então isso seria o suficiente para corrigir os problemas de solidez. Não precisamos definir como os valores não representáveis ​​são representados, apenas precisamos prevenir undef.

Discutido na reunião @rust-lang / compiler. O curso de ação mais consistente permanece:

  1. quando as verificações de estouro estão habilitadas, verifique se há conversões ilegais e entre em pânico.
  2. caso contrário, precisamos de um comportamento de fallback, deve ser algo que tenha custo de tempo de execução mínimo (idealmente, zero) para valores válidos, mas o comportamento preciso não é tão importante, contanto que não seja um undef LLVM.

O principal problema é que precisamos de uma sugestão concreta para a opção 2.

triagem: P-médio

@nikomatsakis Atualmente as entra em pânico em compilações de depuração? Do contrário, por consistência e previsibilidade, parece preferível mantê-lo assim. (Eu acho que _deve ter_, assim como a aritmética, mas isso é um debate separado e passado.)

caso contrário, precisamos de um comportamento de fallback, deve ser algo que tenha custo de tempo de execução mínimo (idealmente, zero) para valores válidos, mas o comportamento preciso não é tão importante, contanto que não seja um undef LLVM.

Sugestão concreta: extraia dígitos e expoente como u64 e dígitos de deslocamento de bits por expoente.

fn f64_as_u64(f: f64) -> u64 {
    let (mantissa, exponent, _sign) = f.integer_decode();
    mantissa >> ((-exponent) & 63)
}

Sim, não é custo zero, mas é algo otimizável (seria melhor se marcássemos integer_decode inline ) e pelo menos determinístico. Um futuro MIR-pass que expande um float-> int cast provavelmente poderia analisar se o float tem garantia de estar ok para lançar e pular esta conversão pesada.

O LLVM não possui intrínsecos de plataforma para as funções de conversão?

EDITAR : @zwarich disse (há muito tempo):

AFAIK, a única solução no momento é usar os intrínsecos específicos do alvo. Isso é o que JavaScriptCore faz, pelo menos de acordo com alguém a quem perguntei.

Por que se incomodar em entrar em pânico? AFAIK, @glaebhoerl está correto, as deve truncar / estender, _não_ verificar os operandos.

No sábado, 05 de março de 2016 às 03:47:55 AM -0800, Gábor Lehel escreveu:

@nikomatsakis Atualmente as entra em pânico em compilações de depuração? Do contrário, por consistência e previsibilidade, parece preferível mantê-lo assim. (Eu acho que _deve ter_, assim como a aritmética, mas isso é um debate separado e passado.)

Verdadeiro. Acho isso persuasivo.

Na quarta-feira, 09 de março de 2016 às 02:31:05 AM -0800, Eduard-Mihai Burtescu escreveu:

O LLVM não possui intrínsecos de plataforma para as funções de conversão?

EDITAR :

AFAIK, a única solução no momento é usar os intrínsecos específicos do alvo. Isso é o que JavaScriptCore faz, pelo menos de acordo com alguém a quem perguntei.

Por que se incomodar em entrar em pânico? AFAIK, @glaebhoerl está correto, as deve truncar / estender, _não_ verificar os operandos.

Sim, acho que me enganei antes. as é o "truncamento não verificado"
operador, para melhor ou para pior, e parece melhor permanecer consistente
com essa filosofia. Usar intrínsecos específicos do alvo pode ser um perfeito
boa solução embora?

@nikomatsakis : parece que o comportamento ainda não foi definido? Você pode dar uma atualização sobre o planejamento a respeito disso?

Acabei de encontrar isso com números muito menores

    let x: f64 = -1.0;
    x as u8

Resultados em 0, 16, etc. dependendo das otimizações, eu esperava que fosse definido como 255, então não tenho que escrever x as i16 as u8 .

@gmorenz Você tentou !0u8 ?

Em um contexto que não faria sentido, eu estava obtendo f64 de uma transformação nos dados enviados pela rede, com um intervalo de [-255, 255]. Eu esperava que fosse embrulhado bem (da mesma forma que <i32> as u8 envolve).

Aqui está uma proposta recente do LLVM para "matar undef" http://lists.llvm.org/pipermail/llvm-dev/2016-October/106182.html , embora eu dificilmente tenha conhecimento suficiente para saber se isso resolveria automaticamente ou não esse assunto.

Eles estão substituindo undef por poison, a semântica sendo um pouco diferente. Isso não fará com que int -> float casts defina um comportamento.

Provavelmente deveríamos fornecer alguma maneira explícita de fazer um elenco saturante? Eu queria exatamente esse comportamento agora.

Parece que isso deve ser marcado como I-crash, dado https://github.com/rust-lang/rust/issues/10184#issuecomment -139858153.

Tivemos uma pergunta sobre isso em #rust-beginners hoje, alguém encontrou isso na selva.

O livro que estou escrevendo com @jimblandy , _Programming Rust_, menciona esse bug.

Vários tipos de elencos são permitidos.

  • Os números podem ser convertidos de qualquer um dos tipos numéricos integrados para qualquer outro.

    (...)

    No entanto, no momento em que este livro foi escrito, converter um grande valor de ponto flutuante em um tipo inteiro pequeno demais para representá-lo pode levar a um comportamento indefinido. Isso pode causar falhas, mesmo em Rust seguro. É um bug no compilador, github.com/rust-lang/rust/issues/10184 .

Nosso prazo para este capítulo é 19 de maio. Adoraria excluir o último parágrafo, mas acho que deveríamos ter pelo menos algum tipo de plano aqui primeiro.

Aparentemente, o JavaScriptCore atual usa um hack interessante no x86. Eles usam a instrução CVTTSD2SI e, em seguida, recorrem a um C ++ complicado se o valor estiver fora do intervalo. Como os valores fora do intervalo atualmente explodem, usar essa instrução (sem fallback!) Seria uma melhoria em relação ao que temos agora, embora apenas para uma arquitetura.

Honestamente, acho que devemos descontinuar os casts numéricos com as e usar From e TryFrom ou algo como o conv crate.

Pode ser, mas isso me parece ortogonal.

OK, acabei de reler toda a conversa. Acho que todos concordam que esta operação não deve entrar em pânico (para consistência geral com as ). Existem dois candidatos principais para qual deve ser o comportamento:

  • Algum tipo de resultado definido

    • Pro: Isso eu acho que é maximamente consistente com nossa filosofia geral até agora.

    • Contra: Não parece haver uma maneira verdadeiramente portátil de produzir qualquer resultado definido específico neste caso. Isso implica que estaríamos usando intrínsecos específicos da plataforma com algum tipo de fallback para valores fora do intervalo (por exemplo, retroceder para a saturação , esta função que @oli-obk propôs , para a definição de Java ou para qualquer "C ++ cabeludo" JSC usa .

    • Na pior das hipóteses, podemos apenas inserir alguns ifs para os casos "fora do intervalo".

  • Um valor indefinido (comportamento não indefinido)

    • Pro: isso nos permite usar apenas os intrínsecos específicos da plataforma que estão disponíveis em cada plataforma.

    • Con: é um risco de portabilidade. Em geral, sinto que não usamos resultados indefinidos com muita frequência, pelo menos na linguagem (tenho certeza de que fazemos nas bibliotecas em vários lugares).

Não está claro para mim se há um precedente claro para qual deve ser o resultado no primeiro caso?

Depois de escrever isso, minha preferência seria manter um resultado determinístico. Sinto que cada lugar em que podemos limitar o determinismo é uma vitória. Não tenho certeza de qual deve ser o resultado.

Gosto da saturação porque posso entendê-la e parece útil, mas parece de alguma forma incongruente com a maneira u64 as u32 faz o truncamento. Então, talvez algum tipo de resultado baseado em truncamento faça sentido, o que eu acho que é provavelmente o que @ oli-obk propôs - eu não entendo totalmente o que esse código pretende fazer. =)

Meu código fornece o valor correto para coisas no intervalo 0..2 ^ 64 e valores determinísticos, mas falsos, para todo o resto.

flutuantes são representados por mantissa ^ expoente, por exemplo, 1.0 é (2 << 52) ^ -52 e uma vez que bitshifts e expoentes são a mesma coisa em binário, podemos apenas reverter o deslocamento (portanto, a negação do expoente e da direita mudança).

+1 para determinismo.

Vejo duas semânticas que fazem sentido para os humanos e acho que devemos escolher a que for mais rápida para os valores que estão dentro do intervalo, quando o compilador não pode otimizar nenhum cálculo . (Quando o compilador sabe que um valor está dentro do intervalo, ambas as opções fornecem os mesmos resultados, portanto, são igualmente otimizáveis.)

  • Saturação (os valores fora do intervalo tornam-se IntType::max_value() / min_value() )
  • Módulo (valores fora do intervalo são tratados como se fossem convertidos para um bigint primeiro e, em seguida, truncados)

A tabela abaixo destina-se a especificar ambas as opções totalmente. T é qualquer tipo de máquina inteira. Tmin e Tmax são T::min_value() e T::max_value() . RTZ (v) significa pegar o valor matemático de v e arredondar para zero para obter um número inteiro matemático.

v | v as T (saturação) | v as T (módulo)
---- | ---- | ----
no intervalo (Tmin <= v <= Tmax) | RTZ (v) | RTZ (v)
zero negativo | 0 | 0
NaN | 0 | 0
Infinity | Tmax | 0
-Infinity | Tmin | 0
v> Tmax | Tmax | RTZ (v) truncado para caber em T
v <Tmin | Tmin | RTZ (v) truncado para caber em T

O padrão ECMAScript especifica as operações ToInt32 , ToUint32, ToInt16, ToUint16, ToInt8, ToUint8 e minha intenção com a opção "módulo" acima é corresponder a essas operações em todos os casos.

ECMAScript também especifica ToInt8Clamp que não corresponde a nenhum dos casos acima: ele "arredonda a metade para igual" arredondando os valores fracionários em vez de "arredondar para zero".

A sugestão de @oli-obk é uma terceira forma, vale a pena considerar se é mais rápido de calcular, para valores que estão no intervalo.

@oli-obk E quanto aos tipos inteiros com sinal?

Jogando outra proposta na mistura: marque o u128 como inseguro e force as pessoas a escolher explicitamente uma maneira de lidar com isso. u128 é muito raro atualmente.

@Manishearth , espero que

Para float → saturação inteira será mais rápido AFAICT (resultando em uma sequência de and , teste + salto, comparação de float e salto, tudo para 0,66 ou 0,5 2-3 ciclos em arcos modernos). Eu, pessoalmente, não poderia me importar menos com o comportamento exato que decidimos, desde que os valores dentro do intervalo sejam os mais rápidos que poderiam ser.

Não faria sentido fazê-lo se comportar como um estouro? Portanto, em um build de depuração, entraria em pânico se você fizesse uma conversão com comportamento indefinido. Então você poderia ter métodos para especificar o comportamento de fundição como 1.04E+17.saturating_cast::<u8>() , unsafe { 1.04E+17.unsafe_cast::<u8>() } e potencialmente outros.

Oh, pensei que o problema era apenas para u128, e podemos tornar isso inseguro dos dois modos.

@cryze UB não deve existir mesmo no modo de liberação no código seguro. O estouro ainda é um comportamento definido.

Dito isso, pânico na depuração eno lançamento seria ótimo.

Isso afeta:

  • f32 -> u8, u16, u32, u64, u128, usize ( -1f32 as _ para todos, f32::MAX as _ para todos, exceto u128)
  • f32 -> i8, i16, i32, i64, i128, isize ( f32::MAX as _ para todos)
  • f64 -> todos os ints ( f64::MAX as _ para todos)

f32::INFINITY as u128 também é UB

@CryZe

Não faria sentido fazê-lo se comportar como um estouro? Portanto, em um build de depuração, entraria em pânico se você fizesse uma conversão com comportamento indefinido.

Isso foi o que pensei inicialmente, mas fui lembrado de que as conversões nunca entram em pânico no momento (não fazemos verificação de estouro com as , para melhor ou pior). Então, a coisa mais análoga é "fazer algo definido".

FWIW, a coisa "kill undef" iria, de fato, fornecer uma maneira de corrigir a insegurança da memória, mas deixando o resultado não determinístico. Um dos componentes principais é:

3) Crie uma nova instrução, '% y = freeze% x', que interrompe a propagação de
Poção. Se a entrada for venenosa, então ele retorna um arbitrário, mas fixo,
valor. (como o antigo undef, mas cada uso obtém o mesmo valor), caso contrário,
apenas retorna seu valor de entrada.

A razão pela qual undefs podem ser usados ​​para violar a segurança da memória hoje é que eles podem mudar magicamente os valores entre os usos: em particular, entre uma verificação de limites e a aritmética de ponteiro subsequente. Se rustc adicionasse um congelamento após cada lançamento perigoso, você apenas obteria um valor desconhecido, mas bem comportado. Em termos de desempenho, o congelamento é basicamente gratuito aqui, pois é claro que a instrução da máquina correspondente ao elenco produz um único valor, não flutuante; mesmo se o otimizador desejar duplicar a instrução de conversão por algum motivo, deve ser seguro fazê-lo porque o resultado para entradas fora do intervalo geralmente é determinístico em uma determinada arquitetura.

... Mas não determinístico entre arquiteturas, se alguém estiver se perguntando. x86 retorna 0x80000000 para todas as entradas incorretas; ARM satura para entradas fora do intervalo e (se estou lendo este pseudocódigo à direita) retorna 0 para NaN. Portanto, se o objetivo é produzir um resultado determinístico e independente da plataforma , não é suficiente apenas usar o intrínseco fp-to-int da plataforma; pelo menos no ARM, você também precisa verificar o registro de status para uma exceção. Isso pode ter alguma sobrecarga em si e certamente impede a autovectorização no caso improvável de que o uso do intrínseco ainda não o fizesse. Como alternativa, acho que você poderia testar explicitamente os valores dentro do intervalo usando operações de comparação regulares e, em seguida, usar um float para int regular. Isso soa muito melhor no otimizador ...

as conversões nunca entram em pânico no momento

Em algum ponto, mudamos + para pânico (no modo de depuração). Eu não ficaria chocado em ver as entrar em pânico em casos que anteriormente eram UB.

Se nos preocupamos com a verificação (o que devemos), devemos descontinuar as (há algum caso de uso em que seja a única boa opção?) Ou, pelo menos, desaconselhar o uso, e mover as pessoas para coisas como TryFrom e TryInto vez disso, que é o que dissemos que estávamos planejando fazer quando foi decidido deixar as como estão. Não creio que os casos em discussão sejam qualitativamente diferentes, em abstrato , dos casos em que as já está definido para não fazer verificações. A diferença é apenas que na prática a implementação para esses casos atualmente está incompleta e possui UB. Um mundo onde você não pode contar com as fazendo verificações (porque para a maioria dos tipos, isso não acontece), e você não pode contar com ele para não entrar em pânico (porque para alguns tipos, isso aconteceria), e não é consistente, e ainda não descontinuamos, parece o pior de todos eles para mim.

Então, acho que neste ponto @jorendorff basicamente enumerou o que me parece ser o melhor plano :

  • as terá algum comportamento determinístico;
  • vamos escolher um comportamento com base em uma combinação de quão sensato é e quão eficiente é

Ele enumerou três possibilidades. Acho que o trabalho restante é investigar essas possibilidades - ou pelo menos investigar uma delas. Isto é, realmente implemente-o e tente sentir como ele é "lento" ou "rápido".

Existe alguém por aí que se sente motivado a tentar fazer isso? Vou marcar isso como E-help-wanted na esperança de atrair alguém. (@ oli-obk?)

Uh, eu prefiro não pagar o preço pela consistência de plataforma cruzada : / É lixo, não me importo com o lixo que sai (no entanto, uma declaração de depuração seria muito útil).

Atualmente todas as funções de arredondamento / truncamento no Rust são muito lentas (chamadas de função com implementações meticulosamente precisas), então as é meu último recurso para arredondamento de flutuação rápida.

Se você vai fazer as qualquer coisa mais do que cvttss2si , por favor, adicione também uma alternativa estável que seja exatamente isso.

@pornel, isso não é apenas UB do tipo teórico onde as coisas estão bem se você ignorar que é ub, tem implicações no mundo real. Extraí # 41799 de um exemplo de código do mundo real.

@ est31 Concordo que deixar como UB é errado, mas vi freeze proposto como uma solução para UB. AFAIK que o torna um valor determinístico definido, você simplesmente não pode dizer qual. Esse comportamento está bom para mim.

Então, eu ficaria bem se, por exemplo, u128::MAX as f32 produzisse 17.5 em x86 e 999.0 em x86-64 e -555 em ARM.

freeze não produziria um valor definido, determinístico e não especificado. Seu resultado ainda é "qualquer padrão de bits que o compilador goste" e é consistente apenas nos usos da mesma operação. Isso pode contornar os exemplos produtores de UB que as pessoas coletaram acima, mas não daria isso:

u128 :: MAX as f32 produziu deterministicamente 17,5 em x86 e 999,0 em x86-64 e -555 em ARM.

Por exemplo, se o LLVM notar que u128::MAX as f32 estourou e o substituiu por freeze poison , uma redução válida de fn foo() -> f32 { u128::MAX as f32 } em x86_64 pode ser esta:

foo:
  ret

(ou seja, basta retornar o que foi armazenado por último no registro de retorno)

Eu vejo. Isso ainda é aceitável para meus usos (para casos em que espero valores fora do intervalo, faço a fixação de antemão. Onde espero valores dentro do intervalo, mas não são, então não vou obter um resultado correto, não importa o que aconteça) .

Não tenho nenhum problema com conversão de float fora do intervalo retornando valores arbitrários, desde que os valores sejam congelados para que não possam causar mais comportamento indefinido.

Algo como freeze disponível no LLVM? Achei que era uma construção puramente teórica.

@nikomatsakis Nunca o vi usado assim (ao contrário de poison ) - é uma reformulação planejada de poison / undef.

freeze não existe no LLVM hoje. Ele apenas foi proposto ( este artigo PLDI é uma versão independente, mas também foi muito discutido na lista de discussão). A proposta parece ter aceitação considerável, mas é claro que isso não é garantia de que será adotada, muito menos adotada em tempo hábil. (A remoção dos tipos de ponta dos tipos de ponta é aceita há anos e ainda não é feita.)

Queremos abrir uma RFC para obter uma discussão mais ampla sobre as mudanças propostas aqui? IMO, qualquer coisa que potencialmente afete o desempenho de as será controverso, mas será duplamente controverso se não dermos às pessoas a chance de fazerem sua voz ser ouvida.

Sou um desenvolvedor Julia e venho acompanhando esse problema há algum tempo, pois compartilhamos o mesmo back-end LLVM e, portanto, temos problemas semelhantes. Caso seja de interesse, aqui está o que decidimos (com tempos aproximados para uma única função em minha máquina):

  • unsafe_trunc(Int64, x) mapeia diretamente para o LLVM intrínseco correspondente fptosi (1,5 ns)
  • trunc(Int64, x) lança uma exceção para valores fora do intervalo (3 ns)
  • convert(Int64, x) lança uma exceção para valores fora do intervalo ou não inteiros (6 ns)

Além disso, perguntei na lista de discussão sobre como tornar o comportamento indefinido um pouco mais definido, mas não recebi uma resposta muito promissora.

@bstrie Estou bem com um RFC, mas acho que definitivamente seria útil ter dados! O comentário de @simonbyrne é muito útil nesse aspecto, entretanto.

Eu brinquei com a semântica JS (o módulo @jorendorff mencionado) e a semântica Java que parece ser a coluna "saturação". Caso esses links expirem, é JS e Java .

Também criei uma implementação rápida de saturação em Rust, que acho (?) Correta. E também obteve alguns números de referência . Curiosamente, estou vendo que a implementação de saturação é 2-3x mais lenta do que a intrínseca, o que é diferente do que @simonbyrne encontrou com apenas 2x mais lento.

Não tenho certeza de como implementar a semântica "mod" no Rust ...

Para mim, entretanto, parece claro que precisaremos de uma série de métodos f32::as_u32_unchecked() e outros semelhantes para aqueles que precisam do desempenho.

parece claro que precisaremos de uma série de f32::as_u32_unchecked() métodos e outros para aqueles que precisam do desempenho.

Isso é uma chatice - ou você quer dizer uma variante segura, mas definida pela implementação?

Não há opção para um padrão rápido definido pela implementação?

@eddyb Eu estava pensando que teríamos apenas unsafe fn as_u32_unchecked(self) -> u32 em f32 e tal que é um análogo direto do que as é hoje.

Certamente não vou afirmar que a implementação do Rust que escrevi é ótima, mas fiquei com a impressão de que, ao ler este thread, o determinismo e a segurança eram mais importantes do que a velocidade neste contexto na maioria das vezes. A escotilha de fuga unsafe é para aqueles do outro lado da cerca.

Portanto, não há uma variante dependente de plataforma barata? Eu quero algo que seja rápido, forneça um valor não especificado quando fora dos limites e seja seguro. Não quero UB para algumas entradas e acho que é muito perigoso para uso comum, se pudermos fazer melhor.

Pelo que eu sei, na maioria, senão em todas as plataformas, a maneira canônica de implementar essa conversão faz algo para entradas fora do intervalo que não é UB. Mas o LLVM não parece ter nenhuma maneira de escolher essa opção (seja ela qual for) em vez do UB. Se pudéssemos convencer os desenvolvedores de LLVM a introduzir um intrínseco que produzisse um resultado "não especificado, mas não undef / poison " em entradas fora do intervalo, poderíamos usar isso.

Mas eu estimaria que alguém neste tópico teria que escrever um RFC convincente (na lista llvm-dev), obter aceitação e implementá-lo (nos back-ends que nos interessam, e com uma implementação de fallback para outros alvos). Provavelmente mais fácil do que convencer o llvm-dev a tornar os casts existentes não-UB (porque evita questões como "isso tornará os programas C e C ++ mais lentos"), mas ainda não muito fácil.

Apenas no caso de você escolher entre estes:

Saturação (valores fora do intervalo tornam-se IntType :: max_value () / min_value ())
Módulo (valores fora do intervalo são tratados como se fossem convertidos para um bigint primeiro e, em seguida, truncados)

IMO apenas saturação faria sentido aqui, porque a precisão absoluta do ponto flutuante cai rapidamente conforme os valores aumentam, então em algum ponto o módulo seria algo inútil como todos os zeros.

Eu marquei isso como E-needs-mentor e marquei com WG-compiler-middle pois parece que o período impl pode ser um ótimo momento para investigar isso mais a fundo! Minhas notas existentes

@nikomatsakis

O IIRC LLVM está planejando implementar freeze , o que deve nos permitir lidar com o UB fazendo um freeze .

Meus resultados até agora: https://gist.github.com/s3bk/4bdfbe2acca30fcf587006ebb4811744

As variantes _array executam um loop de 1024 valores.
_cast: x as i32
_clip: x.min (MAX) .max (MIN) como i32
_panic: entra em pânico se x estiver fora dos limites
_zero: define o resultado como zero se fora dos limites

test bench_array_cast       ... bench:       1,840 ns/iter (+/- 37)
test bench_array_cast_clip  ... bench:       2,657 ns/iter (+/- 13)
test bench_array_cast_panic ... bench:       2,397 ns/iter (+/- 20)
test bench_array_cast_zero  ... bench:       2,671 ns/iter (+/- 19)
test bench_cast             ... bench:           2 ns/iter (+/- 0)
test bench_cast_clip        ... bench:           2 ns/iter (+/- 0)
test bench_cast_panic       ... bench:           2 ns/iter (+/- 0)
test bench_cast_zero        ... bench:           2 ns/iter (+/- 0)

Talvez você não precise arredondar os resultados para inteiros para operações individuais. É claro que deve haver alguma diferença por trás desses 2 ns / iter. Ou é realmente assim, _exatamente_ 2 ns para todas as 4 variantes?

@ sp-1234 Eu me pergunto se ele está parcialmente otimizado.

@ sp-1234 É muito rápido para medir. Os benchmarks sem array são basicamente inúteis.
Se você forçar as funções de valor único a serem funções por meio de #[inline(never)] , obterá 2 ns contra 3 ns.

@ arielb1
Tenho algumas reservas em relação a freeze . Se bem entendi, um undef congelado ainda pode conter qualquer valor arbitrário, apenas não muda entre os usos. Na prática, o compilador provavelmente reutilizará um registro ou slot de pilha.

No entanto, isso significa que agora podemos ler a memória não inicializada do código seguro. Isso pode levar ao vazamento de dados secretos, como o Heartbleed. É discutível se isso é realmente considerado UB do ponto de vista de Rust, mas parece claramente indesejável.

Eu executei o benchmark @ s3bk localmente. Posso confirmar que as versões escalares são totalmente otimizadas e o conjunto para as variantes de array também parece suspeitamente bem otimizado: por exemplo, os loops são vetorizados, o que é bom, mas torna difícil extrapolar o desempenho para o código escalar.

Infelizmente, enviar spam black_box não parece ajudar. Eu vejo o conjunto fazendo um trabalho útil, mas executar o benchmark ainda dá consistentemente 0ns para os benchmarks escalares (exceto cast_zero , que mostra 1ns). Vejo que @alexcrichton realizou a comparação 100 vezes em seus benchmarks, então adotei o mesmo hack. Agora estou vendo estes números ( código-fonte ):

test bench_cast             ... bench:          53 ns/iter (+/- 0)
test bench_cast_clip        ... bench:         164 ns/iter (+/- 1)
test bench_cast_panic       ... bench:         172 ns/iter (+/- 2)
test bench_cast_zero        ... bench:         100 ns/iter (+/- 0)

Os benchmarks do array variam muito para que eu possa confiar neles. Bem, para dizer a verdade, sou cético quanto à infraestrutura de benchmarking de test qualquer maneira, especialmente depois de ver os números acima em comparação com os 0ns fixos que obtive anteriormente. Além disso, mesmo apenas 100 iterações de black_box(x); (como linha de base) leva 34 ns, o que torna ainda mais difícil interpretar esses números de forma confiável.

Dois pontos dignos de nota:

  • Apesar de não tratar NaN especificamente (ele retorna -inf em vez de 0?), A implementação de cast_clip parece ser mais lenta do que o elenco de saturação de @alexcrichton (observe que sua execução e a minha têm aproximadamente o mesmo tempo para as casts, 53-54 ns).
  • Ao contrário dos resultados do array de @ s3bk , estou vendo cast_panic sendo mais lento do que os outros casts verificados. Também vejo uma desaceleração ainda maior nos benchmarks de array. Talvez essas coisas sejam altamente dependentes de detalhes da microarquitetura e / ou do humor do otimizador?

Para registro, eu medi com rustc 1.21.0-nightly (d692a91fa 2017-08-04), -C opt-level=3 , em um i7-6700K sob carga leve.


Concluindo, concluo que não temos dados confiáveis ​​até agora e que obter dados mais confiáveis ​​parece difícil. Além disso, duvido muito que qualquer aplicativo real gaste até 1% do seu tempo de parede nesta operação. Portanto, eu sugeriria avançar implementando casts saturantes de as em rustc , atrás de um -Z e, em seguida, executando alguns benchmarks não artificiais com e sem este sinalizador para determinar o impacto em formulários.

Edit: Eu também recomendaria executar tais benchmarks em uma variedade de arquiteturas (por exemplo, incluindo ARM) e microarquitetura, se possível.

Admito que não estou familiarizado com ferrugem, mas acho que esta linha está sutilmente incorreta: std::i32::MAX (2 ^ 31-1) não é exatamente representável como Float32, então std::i32::MAX as f32 será arredondado para o valor representável mais próximo (2 ^ 31). Se este valor for usado como argumento x , o resultado é tecnicamente indefinido. Substituir por uma desigualdade estrita deve resolver esse caso.

Sim, tivemos exatamente esse problema no Servo antes. A solução final foi lançar em f64 e, em seguida, prender.

Existem outras soluções, mas elas são bem complicadas e a ferrugem não expõe APIs legais para lidar bem com isso.

usando 0x7FFF_FF80i32 como limite superior e -0x8000_0000i32 deve resolver isso sem converter para f64.
editar: use o valor correto.

Acho que você quer dizer 0x7fff_ff80 , mas simplesmente usar uma desigualdade estrita provavelmente tornaria a intenção do código mais clara.

como em x < 0x8000_0000u32 as f32 ? Isso provavelmente seria uma boa ideia.

Penso em todas as opções determinísticas sugeridas, a fixação é a mais útil em termos genealógicos, porque acho que é feita com frequência de qualquer maneira. Se o tipo de conversão fosse realmente documentado para saturação, a fixação manual se tornaria desnecessária.

Estou apenas um pouco preocupado com a implementação sugerida porque ela não se traduz corretamente em instruções de máquina e depende muito de ramificações. A ramificação torna o desempenho dependente de padrões de dados específicos. Nos casos de teste fornecidos acima, tudo parece (comparativamente) rápido porque o mesmo ramo é sempre obtido e o processador tem bons dados de previsão de ramo de muitas iterações de loop anteriores. O mundo real provavelmente não será assim. Além disso, a ramificação prejudica a capacidade do compilador de vetorizar o código. Não concordo com a opinião de @rkruppe , de que a operação também não deve ser testada em combinação com a vetorização. A vetorização é importante em código de alto desempenho e ser capaz de vetorizar conversões simples em arquiteturas comuns deve ser um requisito crucial.

Pelas razões apresentadas acima, eu brinquei com uma versão alternativa sem ramificações e orientada para fluxo de dados do elenco de @simonbyrne . Eu implementei para u16 , i16 e i32, pois todos eles têm que cobrir casos ligeiramente diferentes que resultam em desempenho variável.

Os resultados:

test i16_bench_array_cast       ... bench:          99 ns/iter (+/- 2)
test i16_bench_array_cast_clip  ... bench:         197 ns/iter (+/- 3)
test i16_bench_array_cast_clip2 ... bench:         113 ns/iter (+/- 3)
test i16_bench_cast             ... bench:          76 ns/iter (+/- 1)
test i16_bench_cast_clip        ... bench:         218 ns/iter (+/- 25)
test i16_bench_cast_clip2       ... bench:         148 ns/iter (+/- 4)
test i16_bench_rng_cast         ... bench:       1,181 ns/iter (+/- 17)
test i16_bench_rng_cast_clip    ... bench:       1,952 ns/iter (+/- 27)
test i16_bench_rng_cast_clip2   ... bench:       1,287 ns/iter (+/- 19)

test i32_bench_array_cast       ... bench:         114 ns/iter (+/- 1)
test i32_bench_array_cast_clip  ... bench:         200 ns/iter (+/- 3)
test i32_bench_array_cast_clip2 ... bench:         128 ns/iter (+/- 3)
test i32_bench_cast             ... bench:          74 ns/iter (+/- 1)
test i32_bench_cast_clip        ... bench:         168 ns/iter (+/- 3)
test i32_bench_cast_clip2       ... bench:         189 ns/iter (+/- 3)
test i32_bench_rng_cast         ... bench:       1,184 ns/iter (+/- 13)
test i32_bench_rng_cast_clip    ... bench:       2,398 ns/iter (+/- 41)
test i32_bench_rng_cast_clip2   ... bench:       1,349 ns/iter (+/- 19)

test u16_bench_array_cast       ... bench:          99 ns/iter (+/- 1)
test u16_bench_array_cast_clip  ... bench:         136 ns/iter (+/- 3)
test u16_bench_array_cast_clip2 ... bench:         105 ns/iter (+/- 3)
test u16_bench_cast             ... bench:          76 ns/iter (+/- 2)
test u16_bench_cast_clip        ... bench:         184 ns/iter (+/- 7)
test u16_bench_cast_clip2       ... bench:         110 ns/iter (+/- 0)
test u16_bench_rng_cast         ... bench:       1,178 ns/iter (+/- 22)
test u16_bench_rng_cast_clip    ... bench:       1,336 ns/iter (+/- 26)
test u16_bench_rng_cast_clip2   ... bench:       1,207 ns/iter (+/- 21)

O teste foi executado em uma CPU Intel Haswell i5-4570 e Rust 1.22.0-nightly.
clip2 é a nova implementação sem ramificação. Ele concorda com clip em todos os 2 ^ 32 valores de entrada f32 possíveis.

Para os benchmarks de rng , são usados ​​valores de entrada aleatórios que costumam atingir diferentes casos. Isso revela o custo de desempenho _extremo_ (cerca de 10 vezes o custo normal !!!) que ocorre se a previsão de ramificação falhar. Acho que é _muito_ importante considerar isso. Não é o desempenho médio do mundo real, mas ainda é um caso possível e alguns aplicativos irão acertar isso. As pessoas esperam que um elenco F32 tenha um desempenho consistente.

Comparação do conjunto em x86: https://godbolt.org/g/AhdF71
A versão sem ramificação mapeia muito bem para as instruções minss / maxss.

Infelizmente, não consegui fazer o godbolt gerar o assembly ARM do Rust, mas aqui está uma comparação dos métodos do ARM com o Clang: https://godbolt.org/g/s7ronw
Sem ser capaz de testar o código e saber muito sobre ARM: O tamanho do código também parece menor e o LLVM gera principalmente vmax / vmin, o que parece promissor. Talvez o LLVM pudesse ser ensinado eventualmente a dobrar a maior parte do código em uma única instrução?

@ActuallyaDeviloper O conjunto e os resultados do benchmark parecem muito bons! Além disso, código sem ramificação como o seu é provavelmente mais fácil de gerar em rustc que as condicionais aninhadas de outras soluções (para registro, estou assumindo que queremos gerar IR embutido em vez de chamar uma função de item lang). Muito obrigado por escrever isso.

Eu tenho uma pergunta sobre u16_cast_clip2 : ele não parece lidar com NaN ?! Há um comentário falando sobre NaN, mas acredito que a função passará NaN sem modificações e tentará convertê-lo em f32 (e mesmo que não o fizesse, produziria um dos valores de limite em vez de 0 )

PS: Para ser claro, eu não estava tentando dar a entender que não é importante se o elenco pode ser vetorizado. É claramente importante se o código ao redor for vetorizável. Mas o desempenho escalar também é importante, pois a vetorização geralmente não é aplicável e os benchmarks que eu estava comentando não faziam qualquer declaração sobre o desempenho escalar. Sem interesse, você verificou o conjunto dos *array* benchmarks para ver se eles ainda são vetorizados com sua implementação?

@rkruppe Você está certo, eu acidentalmente troquei os lados do if e esqueci disso. f32 as u16 aconteceu de fazer a coisa certa truncando o 0x8000 superior, então os testes também não o detectaram. Corrigi o problema agora trocando os ramos novamente e testando todos os métodos com if (y.is_nan()) { panic!("NaN"); } desta vez.

Eu atualizei meu post anterior. O código x86 não mudou significativamente, mas infelizmente a mudança impede o LLVM de gerar vmax no caso u16 ARM por algum motivo. Presumo que isso tenha a ver com alguns detalhes sobre o manuseio do NaN daquela instrução ARM ou talvez seja uma limitação do LLVM.

Por que funciona, observe que o valor do limite inferior é, na verdade, 0 para valores sem sinal. Portanto, NaN e o limite inferior podem ser capturados ao mesmo tempo.

As versões da matriz são vetorizadas.
Godbolt: https://godbolt.org/g/HnmsSV

Re: o conjunto ARM , acredito que a razão de vmax não ser mais usado é que ele retorna NaN se um dos operandos for NaN . O código ainda não tem ramificações, porém, ele apenas usa movimentos predicados ( vmovgt , referindo-se ao resultado do vcmp com 0).

Por que funciona, observe que o valor do limite inferior é, na verdade, 0 para valores sem sinal. Portanto, NaN e o limite inferior podem ser capturados ao mesmo tempo.

Ohhh, certo. Agradável.

Eu sugeriria seguir em frente implementando saturação como moldes em rustc, atrás de uma bandeira -Z

Implementei isso e apresentarei um PR assim que consertei o # 41799 e tenho muitos mais testes.

45134 apontou um caminho de código que perdi (geração de expressões constantes LLVM - isso é separado da avaliação constante do próprio rustc). Vou lançar uma correção para isso no mesmo PR, mas vai demorar um pouco mais.

@rkruppe Você deve coordenar com @oli-obk para que miri termine com as mesmas alterações.

A solicitação pull está ativa: # 45205

45205 foi mesclado, então qualquer um pode agora (bem, começando com a próxima noite) medir o impacto da saturação no desempenho passando -Z saturating-float-casts por RUSTFLAGS . [1] Essas medições seriam muito valiosas para decidir como proceder com este problema.

[1] Estritamente falando, isso não afetará as partes não genéricas e não #[inline] da biblioteca padrão, portanto, para ser 100% preciso, você gostaria de criar um padrão local com o Xargo. No entanto, não espero que haja muitos códigos afetados por isso (os vários impls de características de conversão são #[inline] , por exemplo).

@rkruppe , sugiro iniciar uma página interna / de usuários para coletar dados, na mesma linha que https://internals.rust-lang.org/t/help-us-benchmark-incremental-compilation/6153/ (então também podemos vincular as pessoas a isso, em vez de alguns comentários aleatórios em nosso rastreador de problemas)

@rkruppe você deve criar um problema de rastreamento. Esta discussão já está dividida em duas questões. Isso não é bom!

@Gankro Sim, concordo, mas pode demorar alguns dias até eu encontrar tempo para escrever essa postagem corretamente, então resolvi solicitar feedback das pessoas inscritas neste problema nesse meio tempo.

@ est31 Hmm. Embora o sinalizador -Z cubra ambas as direções do elenco (o que pode ter sido um erro, em retrospecto), parece improvável que vamos ligar o interruptor de ambos ao mesmo tempo, e há pouca sobreposição entre os dois em termos do que deve ser discutido (por exemplo, este problema depende do desempenho da saturação, enquanto no # 41799 é acordado qual é a solução certa).
É um pouco bobo que benchmarks destinada, sobretudo, esta questão também medir o impacto da correção para # 41799, mas que pode, no máximo, levar a sobreregisto de regressões de desempenho, então eu sou uma espécie de bem com isso. (Mas se alguém estiver motivado para dividir a bandeira -Z em duas, vá em frente.)

Considerei um problema de rastreamento para a tarefa de remover o sinalizador uma vez que ela tenha deixado de ser útil, mas não vejo a necessidade de mesclar as discussões que ocorrem aqui e em # 41799.

Elaborei uma postagem interna: https://gist.github.com/Gankro/feab9fb0c42881984caf93c7ad494ebd

Sinta-se à vontade para copiar, ou apenas me dê notas para que eu possa postá-lo. (note que estou um pouco confuso sobre o comportamento de const fn )

Um detalhe adicional é que o custo das conversões float-> int é específico para a implementação atual, ao invés de ser fundamental. No x86, cvtss2si cvttss2si retorna 0x80000000 nos casos muito baixo, muito alto e nan, portanto, pode-se implementar -Zsaturating-float-casts com um cvtss2si cvttss2si seguido por um código especial no caso 0x80000000, então pode ser apenas um único ramo de comparação e previsibilidade no caso comum. No ARM, vcvt.s32.f32 já tem a semântica -Zsaturating-float-casts . O LLVM atualmente não otimiza as verificações extras em nenhum dos casos.

@Gankro

Incrível, muito obrigado! Deixei algumas notas sobre a essência. Depois de ler isso, eu gostaria de tentar separar u128-> f32 casts do sinalizador -Z. Apenas para se livrar da advertência perturbadora sobre a bandeira cobrindo duas características ortogonais.

(Eu solicitei # 45900 para refocar o sinalizador -Z de forma que ele cubra apenas o problema float-> int)

Seria bom se pudéssemos obter implementações específicas da plataforma a la @sunfishcode (pelo menos para x86) antes de pedir um benchmarking em massa. Não deve ser muito difícil.

O problema é que o LLVM atualmente não fornece uma maneira de fazer isso, até onde eu sei, exceto talvez com conjunto embutido que eu não recomendaria necessariamente para um lançamento.

Eu atualizei o rascunho para refletir a discussão (basicamente retirando qualquer menção embutida de u128 -> f32 para uma seção extra no final).

@sunfishcode Tem certeza? Não é o llvm.x86.sse.cvttss2si intrínseco o que você está procurando?

Aqui está um link de playground que o usa:

https://play.rust-lang.org/?gist=33cf9e0871df2eb2475b845af4f1b574&version=nightly

No modo de liberação, float_to_int_with_intrinsic e float_to_int_with_as compilam em uma única instrução. (No modo de depuração, float_to_int_with_intrinsic desperdiça algumas instruções colocando zero no máximo, mas não é tão ruim.)

Ele até parece fazer dobra constante corretamente. Por exemplo,

float_to_int_with_intrinsic(42.0)

torna-se

movl    $42, %eax

Mas um valor fora do intervalo,

float_to_int_with_intrinsic(42.0e33)

não é dobrado:

cvttss2si   .LCPI2_0(%rip), %eax

(O ideal seria dobrar para constante 0x80000000, mas isso não é grande coisa. O importante é que não produz undef.)

Oh fixe. Parece que funcionaria!

É legal saber que, afinal de contas, temos uma maneira de construir em cvttss2si . No entanto, não concordo que seja claramente melhor alterar a implementação para usá-la antes de solicitarmos os benchmarks:

A maioria das pessoas fará o benchmark em x86, então se formos um caso especial de x86, obteremos muito menos dados sobre a implementação geral, que ainda será usada na maioria dos outros destinos. Reconhecidamente, já é difícil inferir qualquer coisa sobre outras arquiteturas, mas uma implementação totalmente diferente torna completamente impossível.

Em segundo lugar, se coletarmos benchmarks agora, com a solução "simples", e descobrirmos que não há regressões de desempenho no código real (e tbh é isso que eu espero), então não precisamos nem mesmo ter o trabalho de tentar otimizar ainda mais esse caminho de código.

Finalmente, nem tenho certeza se construir em cvttss2si será mais rápido do que o que temos agora (embora no ARM, apenas usar a instrução apropriada seja claramente melhor):

  • Você precisa de uma comparação para perceber que a conversão retorna 0x80000000 e, se for esse o caso, você ainda precisa de outra comparação (do valor de entrada) para saber se deve retornar int :: MIN ou int :: MAX. E se for um tipo inteiro assinado, não vejo como evitar uma terceira comparação para distinguir NaN. Portanto, no pior caso:

    • você não salva no número de comparações / seleções

    • você está trocando uma comparação de flutuação por uma comparação interna, o que pode ser bom para núcleos OoO (se você tiver um gargalo em FUs que podem fazer comparações, que parece um se relativamente grande), mas essa comparação também depende da flutuação -> comparação interna, embora as comparações na implementação atual sejam todas independentes, portanto, está longe de ser óbvio que isso é uma vitória.

  • A vetorização provavelmente se torna mais difícil ou impossível. Não espero que o vetorizador de loop trate de forma alguma esse intrínseco.
  • Também é importante notar que (AFAIK) esta estratégia só se aplica a alguns tipos de número inteiro. f32 -> u8, por exemplo, precisará de ajustes adicionais do resultado, o que torna essa estratégia claramente não lucrativa. Não tenho certeza de quais tipos são afetados por isso (por exemplo, não sei se há uma instrução para f32 -> u32), mas um aplicativo que usa apenas esses tipos não terá nenhum benefício.
  • Você poderia fazer uma solução de ramificação com apenas uma comparação no caminho feliz (em oposição a duas ou três comparações e, portanto, ramificações, como as soluções anteriores). No entanto, como @ActuallyaDeviloper argumentou anteriormente, a ramificação pode não ser desejável: o desempenho agora se torna ainda mais dependente da carga de trabalho e da previsão de ramificação.

É seguro presumir que precisaremos de uma grande quantidade de unsafe fn as_u32_unchecked(self) -> u32 e amigos, independentemente do que o benchmarking mostre? O outro recurso potencial que alguém iria ter se eles se acabam observando uma desaceleração?

@bstrie Eu acho que faria mais sentido, em um caso como esse, fazer algo como estender a sintaxe para as <type> [unchecked] e exigir que unchecked só esteja presente em unsafe contextos.

A meu ver, uma floresta de _unchecked funciona como variantes de as fundida seria uma verruga, tanto no que diz respeito à intuição quanto quando se trata de gerar documentação limpa e utilizável.

@ssokolow Adicionar sintaxe deve ser sempre o último recurso, especialmente se tudo isso puder ser foo.as_unchecked::<u32>() genérico seria preferível às mudanças sintáticas (e a interminável bicicleta concomitante), especialmente porque deveríamos estar reduzindo, não aumentando, o número de coisas que unsafe desbloqueia.

Ponto. O turbofish escapou da minha mente ao considerar as opções e, em retrospectiva, não estou exatamente disparando em todos os cilindros esta noite, então eu deveria ter sido mais cauteloso ao comentar sobre as decisões de design.

Dito isso, parece errado assar o tipo de destino no nome da função ... deselegante e um fardo potencial na evolução futura da linguagem. O turbofish parece uma opção melhor.

Um método genérico poderia ser suportado por um novo conjunto de UncheckedFrom / UncheckedInto traits com métodos unsafe fn , juntando os From / Into e TryFrom / TryInto coleção.

@bstrie Uma solução alternativa para pessoas cujo código ficou mais lento poderia ser usar um intrínseco (por exemplo, via stdsimd) para acessar a instrução de hardware subjacente. Argumentei anteriormente que isso tem desvantagens para o otimizador - a autovetorização provavelmente sofre, e o LLVM não pode explorá-la retornando undef em entradas fora do intervalo - mas oferece uma maneira de fazer o elenco sem qualquer trabalho extra em tempo de execução. Não consigo decidir se isso é bom o suficiente, mas parece pelo menos plausível que possa ser.

Algumas notas sobre conversões no conjunto de instruções x86:

O SSE2 é, na verdade, relativamente limitado nas operações de conversão que ele oferece. Você tem:

  • Família CVTTSS2SI com registro de 32 bits: converte flutuação única em i32
  • Família CVTTSS2SI com registro de 64 bits: converte flutuação única em i64 (x86-64 apenas)
  • Família CVTTPS2PI: converte dois flutuadores em dois i32s

Cada um deles tem variantes para f32 e f64 (assim como variantes que se arredondam em vez de truncar, mas isso é inútil aqui).

Mas não há nada para inteiros sem sinal, nada para tamanhos menores que 32 e, se você estiver no x86 de 32 bits, nada para 64 bits. As extensões do conjunto de instruções posteriores adicionam mais funcionalidade, mas parece que quase ninguém compila para elas.

Como resultado, o comportamento existente ('inseguro'):

  • Para converter em u32, os compiladores convertem em i64 e truncam o inteiro resultante. (Isso produz um comportamento estranho para valores fora do intervalo, mas isso é UB, quem se importa.)
  • Para converter para qualquer coisa de 16 ou 8 bits, os compiladores convertem para i64 ou i32 e truncam o inteiro resultante.
  • Para converter para u64, os compiladores geram um monte de instruções. Para f32 a u64, GCC e LLVM gere um equivalente a:
fn f32_to_u64(f: f32) -> u64 {
    const CUTOFF: f32 = 0x8000000000000000 as f32; // 2^63 exactly
    if !(f >= CUTOFF) { // less, or NaN
        // just use the signed conversion
        f as i64 as u64
    } else {
        0x8000000000000000u64 + ((f - CUTOFF) as i64 as u64)
    }
}

Curiosidade não relacionada: a geração de código "Converter do que truncar" é o que causa a falha dos " universos paralelos " no Super Mario 64. O código de detecção de colisão primeiro a instrução MIPS para converter as coordenadas f32 em i32, depois trunca em i16; portanto, as coordenadas que cabem em i16, mas não em i32 'envolvem', por exemplo, ir para a coordenada 65536.0 permite a detecção de colisão de 0,0.

Enfim, conclusões:

  • "Teste para 0x80000000 e tenha manipulador especial" só funciona para conversões para i32 e i64.
  • Para conversões para u32, u / i16 e u / i8, entretanto, "testar se a saída truncada / com sinal estendido difere do original" é um equivalente. (Isso coletaria os dois inteiros que estavam dentro do intervalo para a conversão original, mas fora do intervalo para o tipo final, e 0x8000000000000000, o indicador de que o flutuante era NaN ou fora do intervalo para a conversão original.)
  • Mas o custo de um branch e um monte de código extra para esse caso é provavelmente um exagero. Pode não haver problemas se os ramos puderem ser evitados.
  • A abordagem baseada em minss / maxss de @ActuallyaDeviloper não é tão ruim! A forma mínima,
minss %xmm2, %xmm1
maxss %xmm3, %xmm1
cvttss2si %rax, %xmm1

é apenas três instruções (que têm tamanho de código decente e taxa de transferência / latência) e sem ramificações.

Contudo:

  • A versão pure-Rust precisa de um teste extra para NaN. Para conversões para 32 bits ou menor, isso pode ser evitado usando intrínseco, usando cvttss2si de 64 bits e truncando o resultado. Se a entrada não foi NaN, o mín. / Máx. Garantem que o número inteiro não seja alterado por truncamento. Se a entrada foi NaN, o número inteiro é 0x8000000000000000 que trunca para 0.
  • Não incluí o custo de carregamento de 2147483647.0 e -2148473648.0 nos registros, normalmente um mov da memória cada.
  • Para f32, 2147483647.0 não pode ser representado exatamente, então isso não funciona: você precisa de outra verificação. Isso torna as coisas muito piores. Idem para f64 a u / i64, mas f64 a u / i32 não tem esse problema.

Eu sugiro um meio-termo entre as duas abordagens:

  • Para f32 / f64 a u / i16 e u / i8 e f64 a u / i32, vá com mín / máx + truncamento, como acima, por exemplo:
    let f = if f > 32767.0 { 32767.0 } else { f };
    let f = if f < -32768.0 { -32768.0 } else { f };
    cvttss2si(f) as i16

(Para u / i16 e u / i8, a conversão original pode ser para i32; para f64 para u / i32, deve ser para i64.)

  • Para f32 / 64 a u32,
    let r = cvttss2si64(f) as u32;
    if f >= 4294967296.0 { 4294967295 } else { r }

são apenas algumas instruções e nenhum branch:

    cvttss2si   %xmm0, %rcx
    ucomiss .LCPI0_0(%rip), %xmm0
    movl    $-1, %eax
    cmovbl  %ecx, %eax
  • Para f32 / 64 a i64, talvez
    let r = cvttss2si64(f);
    if f >= 9223372036854775808. {
        9223372036854775807 
    } else if f != f {
        0
    } else {
        r
    }

Isso produz uma sequência mais longa (ainda sem ramificações):

    cvttss2si   %xmm0, %rax
    xorl    %ecx, %ecx
    ucomiss %xmm0, %xmm0
    cmovnpq %rax, %rcx
    ucomiss .LCPI0_0(%rip), %xmm0
    movabsq $9223372036854775807, %rax
    cmovbq  %rcx, %rax

… Mas pelo menos salvamos uma comparação em comparação com a abordagem ingênua, como se f fosse muito pequeno, 0x8000000000000000 já é a resposta correta (ou seja, i64 :: MIN).

  • Para f32 em i32, não tenho certeza se seria preferível fazer o mesmo que o anterior ou apenas converter para f64 primeiro e, em seguida, fazer a coisa mínima / máxima mais curta.

  • u64 é uma bagunça na qual não estou com vontade de pensar. : p

A chamada para comparativos de mercado está aberta: https://internals.rust-lang.org/t/help-us-benchmark-saturating-float-casts/6231

Em https://internals.rust-lang.org/t/help-us-benchmark-saturating-float-casts/6231/14, alguém relatou uma lentidão mensurável e significativa na codificação JPEG com a caixa de imagem. Minimizei o programa para que seja independente e focado principalmente nas partes relacionadas à desaceleração: https://gist.github.com/rkruppe/4e7972a209f74654ebd872eb4bc57722 (este programa mostra desaceleração de ~ 15% para mim com saturação cast).

Observe que os casts são f32-> u8 ( rgb_to_ycbcr ) e f32-> i32 ( encode_rgb , loop de "Quantização") em proporções iguais. Também parece que as entradas estão todas dentro do intervalo, ou seja, a saturação nunca é ativada, mas no caso de f32-> u8 isso só pode ser verificado calculando o mínimo e o máximo de um polinômio e levando em consideração o erro de arredondamento, que é pedir muito. Os casts f32-> i32 estão mais obviamente no intervalo de i32, mas apenas porque os elementos de self.tables são diferentes de zero, o que (aparentemente?) Não é tão fácil para o otimizador mostrar, especialmente no programa original. tl; dr: As verificações de saturação existem para ficar, a única esperança é torná-las mais rápidas.

Eu também dei uma olhada no LLVM IR - parece que literalmente a única diferença são as comparações e seleções dos cast de saturação. Uma olhada rápida indica que o conjunto tem instruções correspondentes e, claro, mais valores ativos (que levam a mais vazamentos).

@comex Você acha que os casts de f32-> u8 e f32-> i32 podem ser feitos de forma mensurável mais rápido com CVTTSS2SI?

Pequena atualização, a partir de rustc 1.28.0-nightly (952f344cd 2018-05-18) , a sinalização -Zsaturating-float-casts ainda faz com que o código em https://github.com/rust-lang/rust/issues/10184#issuecomment -345479698 seja ~ 20 % mais lento em x86_64. O que significa que o LLVM 6 não mudou nada.

| Bandeiras | Timing |
| ------- | -------: |
| -Copt-level = 3 -Ctarget-cpu = nativo | 325.699 ns / iter (+/- 7.607) |
| -Copt-level = 3 -Ctarget-cpu = nativo -Zsaturating-float-casts | 386.962 ns / iter (+/- 11.601)
(19% mais lento) |
| -Copt-level = 3 | 331.521 ns / iter (+/- 14.096) |
| -Copt-level = 3 -Zsaturating-float-casts | 413.572 ns / iter (+/- 19.183)
(25% mais lento) |

@kennytm Esperávamos que o LLVM 6 mudasse alguma coisa? Eles estão discutindo um aprimoramento específico que beneficiaria este caso de uso? Se sim, qual é o número do bilhete?

@insanitybit Ele ... parece ainda estar aberto ...?

image

Bem, não tenho ideia do que eu estava olhando. Obrigado!

@rkruppe , não garantimos que float para int casts não sejam mais UB no LLVM
(alterando documentos)?

Em 20 de julho de 2018 4:31 AM, "Colin" [email protected] escreveu:

Bem, não tenho ideia do que eu estava olhando.

-
Você está recebendo isto porque está inscrito neste tópico.
Responda a este e-mail diretamente, visualize-o no GitHub
https://github.com/rust-lang/rust/issues/10184#issuecomment-406462053 ,
ou mudo
o segmento
https://github.com/notifications/unsubscribe-auth/AApc0v3rJHhZMD7Kv7RC8xkGOiIhkGB1ks5uITMHgaJpZM4BJ45C
.

@nagisa Talvez você esteja pensando em f32::from_bits(v: u32) -> f32 (e da mesma forma em f64 )? Costumava fazer alguma normalização de NaNs, mas agora é apenas transmute .

Este problema é sobre as conversões que tentam aproximar o valor numérico.

@nagisa Você pode estar pensando em float-> float casts, consulte # 15536 ​​e https://github.com/rust-lang-nursery/nomicon/pull/65.

Ah, sim, isso era flutuar para flutuar.

Na sexta-feira, 20 de julho de 2018, 12h24, Robin Kruppe [email protected] escreveu:

@nagisa https://github.com/nagisa Você pode estar pensando em float-> float
cast, consulte # 15536 https://github.com/rust-lang/rust/issues/15536 e
rust-lang-Nursery / nomicon # 65
https://github.com/rust-lang-nursery/nomicon/pull/65 .

-
Você está recebendo isso porque foi mencionado.
Responda a este e-mail diretamente, visualize-o no GitHub
https://github.com/rust-lang/rust/issues/10184#issuecomment-406542903 ,
ou silenciar o tópico
https://github.com/notifications/unsubscribe-auth/AApc0gA24Hz8ndnYhRXCyacd3HdUSZjYks5uIaHegaJpZM4BJ45C
.

As notas de lançamento do LLVM 7 mencionam algo:

A otimização de conversões de ponto flutuante foi aprimorada. Isso pode causar resultados surpreendentes para código que depende do comportamento indefinido de transbordamento de conversões. A otimização pode ser desabilitada especificando um atributo de função: "strict-float-cast-overflow" = "false". Este atributo pode ser criado pela opção clang -fno-strict-float-cast-overflow. Os sanitizadores de código podem ser usados ​​para detectar padrões afetados. A opção do clang para detectar esse problema sozinho é -fsanitize = float-cast-overflow:

Isso tem alguma relação com este problema?

Não devemos nos preocupar com o que o LLVM faz para transbordar de conversões, contanto que não seja um comportamento indefinido inseguro. O resultado pode ser lixo, desde que não cause um comportamento doentio.

Isso tem alguma relação com este problema?

Na verdade não. O UB não mudou, o LLVM ficou ainda mais agressivo ao explorá-lo, o que torna mais fácil ser afetado por ele na prática, mas o problema de solidez não mudou. Em particular, o novo atributo não remove o UB ou afeta quaisquer otimizações que existiam antes do LLVM 7.

@rkruppe por curiosidade, esse tipo de coisa caiu no esquecimento? Parece que https://internals.rust-lang.org/t/help-us-benchmark-saturating-float-casts/6231/14 correu bem o suficiente e a implementação não teve muitos bugs. Parece que uma ligeira regressão de desempenho sempre foi esperada, mas compilar corretamente parece uma troca que vale a pena.

Isso está apenas esperando para ser empurrado para a linha de chegada? Ou existem outros bloqueadores conhecidos?

Quase sempre estive distraído / ocupado com outras coisas, mas uma regressão x0.82 na codificação RBG JPEG parece mais do que "leve", uma pílula bastante difícil de engolir (embora seja reconfortante que outros tipos de carga de trabalho não pareçam afetados) . Não é grave o suficiente para que eu me oponha a ativar a saturação por padrão, mas o suficiente para que eu mesmo hesite em forçar isso antes de tentarmos o "também fornece uma função de conversão que é mais rápida do que a saturação, mas pode gerar lixo (seguro) "opção discutida antes. Não cheguei a isso e, aparentemente, ninguém mais também, então isso foi deixado de lado.

Ok legal, obrigado pela atualização @rkruppe! Estou curioso para saber se há realmente uma implementação da opção de lixo seguro. Eu poderia nos imaginar fornecendo algo como unsafe fn i32::unchecked_from_f32(...) e algo assim, mas parece que você está pensando que essa deve ser uma função segura . Isso é possível com o LLVM hoje?

Não há freeze ainda, mas é possível usar o assembly embutido para acessar a instrução da arquitetura de destino para converter flutuantes em inteiros (com um fallback para, por exemplo, saturar as ). Embora isso possa inibir algumas otimizações, pode ser bom o suficiente para corrigir a regressão em alguns benchmarks.

Uma função unsafe que mantém o UB de que trata este problema (e é codegen'd da mesma forma que as é hoje) é outra opção, mas muito menos atraente. Eu prefiro uma função segura se ela puder realizar o trabalho.

Também há espaço significativo para melhorias na sequência de flutuação para interior de saturação segura . O LLVM hoje não tem nada especificamente para isso, mas se as soluções de conjunto em linha estiverem na mesa, não seria difícil fazer algo assim:

     cvttsd2si %xmm0, %eax   # x86's cvttsd2si returns 0x80000000 on overflow and invalid cases
     cmp $1, %eax            # a compact way to test whether %eax is equal to 0x80000000
     jno ok
     ...  # slow path: check for and handle overflow and invalid cases
ok:

que deve ser significativamente mais rápido do que o rustc faz atualmente .

Ok, eu só queria ter certeza de esclarecer, obrigado! Percebi que as soluções de asm em linha não funcionam como padrão, pois inibiriam demais outras otimizações, mas não tentei sozinho. Eu pessoalmente prefiro que fechemos esse buraco doentio definindo algum comportamento razoável (exatamente como os modelos de saturação de hoje). Se necessário, podemos sempre preservar a implementação rápida / inadequada de hoje como uma função insegura e, no limite de tempo, dados recursos infinitos, podemos até melhorar drasticamente o padrão e / ou adicionar outras funções de conversão especializadas (como uma conversão segura onde fora dos limites não é não é UB, mas apenas um padrão de bits de lixo)

Outros se oporiam a tal estratégia? Achamos que isso não é importante o suficiente para consertar enquanto isso?

Acho que o assembly inline deve ser tolerado por cvttsd2si (ou instruções semelhantes) especificamente porque esse conjunto inline não acessaria a memória ou teria efeitos colaterais, então é apenas uma caixa preta opaca que pode ser removida se não for usada e não inibir muito as otimizações em torno dele, o LLVM simplesmente não consegue raciocinar sobre os valores internos e do resultado do conjunto em linha. Essa última parte é por que eu ficaria cético sobre, por exemplo, usar asm inline para a sequência de código @sunfishcode sugere para saturação: as verificações introduzidas para saturação podem ocasionalmente ser removidas hoje se forem redundantes, mas os ramos em um bloco asm inline podem ' t ser simplificado.

Outros se oporiam a tal estratégia? Achamos que isso não é importante o suficiente para consertar enquanto isso?

Eu não me oponho a sacudir a saturação agora e possivelmente adicionar alternativas mais tarde, eu só não quero ser aquele que tem que reunir o consenso para isso e justificá-lo para os usuários cujo código ficou mais lento 😅

Comecei alguns trabalhos para implementar intrínsecos para saturar float para int casts no LLVM: https://reviews.llvm.org/D54749

Se isso for para algum lugar, fornecerá uma maneira de sobrecarga relativamente baixa de obter a semântica saturante.

Como reproduzir esse comportamento indefinido? Tentei o exemplo do comentário, mas o resultado foi 255 , o que me parece bom:

println!("{}", 1.04E+17 as u8);

O comportamento indefinido não pode ser observado de forma confiável dessa maneira; às vezes, dá a você o que você espera, mas em situações mais complexas falha.

Em suma, o mecanismo de geração de código (LLVM) que usamos pode assumir que isso não acontece e, portanto, pode gerar um código incorreto se confiar nessa suposição.

@ AaronM04 um exemplo de comportamento indefinido reproduzível foi postado no reddit hoje:

fn main() {
    let a = 360.0f32;
    println!("{}", a as u8);

    let a = 360.0f32 as u8;
    println!("{}", a);

    println!("{}", 360.0f32 as u8);
}

(ver playground )

Presumo que o último comentário foi feito para @ AaronM04 , em referência ao comentário anterior .

"Oh, isso é bastante fácil então."

  • @pcwalton , 2014

Desculpe, li com muito cuidado toda essa história de boas intenções de 6 anos. Mas, falando sério, 6 longos anos em 10 !!! Se fosse um fórum político, seria de se esperar alguma sabotagem por aqui.

Então, por favor, alguém pode explicar, em palavras simples, o que torna o processo de busca de uma solução mais interessante do que a própria solução?

Porque é mais difícil do que parecia inicialmente e precisa de mudanças no LLVM.

Ok, mas não foi Deus quem fez este LLVM em sua segunda semana, e indo na mesma direção pode levar mais 15 anos para resolver este problema fundamental.

Sério, eu não presto atenção em machucar alguém e sou novo na infraestrutura do Rust para ajudar de repente, mas quando soube desse caso, fiquei pasmo.

Este rastreador de problemas é para discutir como resolver esse problema, e declarar o óbvio não faz nenhum progresso nessa direção. Portanto, se você quiser ajudar a resolver o problema ou tiver alguma informação nova para contribuir, por favor, faça, mas, caso contrário, seus comentários não farão magicamente a correção aparecer. :)

Acho que a suposição de que isso requer mudanças no LLVM é prematura.

Acho que podemos fazer isso na linguagem com custo de desempenho mínimo. Seria uma mudança significativa * * sim, mas poderia ser feito e deveria ser feito.

Minha solução seria definir float para int casts como unsafe e fornecer algumas funções auxiliares na biblioteca padrão para fornecer resultados vinculados a Result Tipos.

É uma correção nada sexy e uma mudança significativa, mas, em última análise, é o que todo desenvolvedor precisa codificar para contornar o UB existente. Esta é a abordagem correta da ferrugem.

Obrigado, @RalfJung , por me fazer entender. Não tinha intenção de insultar ninguém ou intervir com desdém no processo produtivo de brainstorming. Sendo novo em ferrugem, é verdade, não há tanto que eu possa fazer. No entanto, ajuda a mim e talvez a outros, que tentam entrar na ferrugem, aprender mais sobre suas falhas não resolvidas e fazer a saída relevante: vale a pena aprofundar ou é melhor escolher outra coisa por enquanto. Mas já estou feliz que a remoção de "meus comentários inúteis" será muito mais fácil.

Conforme observado anteriormente no tópico, isso está lenta, mas certamente, sendo corrigido da maneira certa, corrigindo llvm para oferecer suporte à semântica necessária, como as equipes relevantes há muito concordaram.

Nada mais pode realmente ser adicionado a esta discussão.

https://reviews.llvm.org/D54749

@nikic Parece que o progresso do lado do LLVM estagnou, você poderia dar uma breve atualização, se possível? Obrigado.

O elenco de saturação pode ser implementado como uma função de biblioteca na qual os usuários podem optar, se estiverem dispostos a fazer alguma regressão prévia para obter som? Estou lendo a implementação do compilador, mas parece bastante sutil:

https://github.com/rust-lang/rust/blob/625451e376bb2e5283fc4741caa0a3e8a2ca4d54/src/librustc_codegen_ssa/mir/rvalue.rs#L774 -L901

Poderíamos expor um intrínseco que gera o LLVM IR para saturação (seja o IR de código aberto atual ou llvm.fpto[su]i.sat no futuro) independente da bandeira -Z . Isso não é nada difícil de fazer.

No entanto, estou preocupado se é o melhor curso de ação. Quando (se?) A saturação se torna a semântica padrão de as casts, tal API torna-se redundante. Também não parece ótimo dizer aos usuários que eles devem escolher por si próprios se desejam solidez ou desempenho, mesmo que seja apenas temporário.

Ao mesmo tempo, a situação atual é claramente ainda pior. Se estamos pensando em adicionar APIs de biblioteca, estou alertando cada vez mais para apenas ativar a saturação por padrão e oferecer um intrínseco unsafe que tem UB em NaN e números fora do intervalo (e diminui para um simples fpto[su]i ). Isso ainda ofereceria basicamente a mesma escolha, mas padronizando para solidez, e a nova API provavelmente não se tornaria redundante no futuro.

Mudar para som por padrão parece bom. Acho que podemos oferecer o intrínseco preguiçosamente mediante solicitação, e não desde o início. Além disso, const eval fará a saturação também neste caso? (cc @RalfJung @eddyb @ oli-obk)

Const nos avalia já fazendo saturação e tem feito isso por muito tempo, acho que antes mesmo de miri (lembro-me distintamente de mudar no antigo avaliador baseado em llvm::Constant ).

@rkruppe Incrível! Já que você está familiarizado com o código em questão, gostaria de liderar a troca dos padrões?

@rkruppe

Poderíamos expor um intrínseco que gera o LLVM IR para saturação

Pode ser necessário ter 10 ou 12 intrínsecos separados, para cada combinação de tipo de origem e destino.

@Centril

Mudar para som por padrão parece bom. Acho que podemos oferecer o intrínseco preguiçosamente mediante solicitação, e não desde o início.

Presumo que, ao contrário de outros comentários, “o intrínseco” em seu comentário significa algo que teria menos regressão pref quando as faz a saturação.

Não acho que seja uma boa abordagem para lidar com regressões significativas conhecidas . Para alguns usuários, a perda de desempenho pode ser um problema real, enquanto seu algoritmo garante que a entrada esteja sempre dentro do alcance. Se eles não estiverem inscritos neste tópico, eles só perceberão que serão afetados quando a mudança chegar ao canal Estável. Nesse ponto, eles podem ficar presos por 6 a 12 semanas, mesmo se conseguirmos uma API não segura imediatamente após a solicitação.

Eu prefiro seguir o padrão já estabelecido para avisos de depreciação: apenas faça a troca em Nightly depois que a alternativa estiver disponível em Stable por algum tempo.

Pode ser necessário ter 10 ou 12 intrínsecos separados, para cada combinação de tipo de origem e destino.

Tudo bem, você me pegou, mas não vejo como isso é relevante. Sejam 30 intrínsecos, ainda é trivial adicioná-los. Mas, na realidade, é ainda mais fácil ter um único intrínseco genérico usado por N wrappers finos. O número também não muda se escolhermos "fazer as som e introduzir uma opção de unsafe cast API".

Não acho que seja uma boa abordagem para lidar com regressões significativas _conhecidas_. Para alguns usuários, a perda de desempenho pode ser um problema real, enquanto seu algoritmo garante que a entrada esteja sempre dentro do alcance. Se eles não estiverem inscritos neste tópico, eles só perceberão que serão afetados quando a mudança chegar ao canal Estável. Nesse ponto, eles podem ficar presos por 6 a 12 semanas, mesmo se conseguirmos uma API não segura imediatamente após a solicitação.

+1

Não tenho certeza se o procedimento para avisos de descontinuação (descontinuar apenas todas as noites quando a substituição estiver estável) é necessário, pois parece menos importante permanecer sem regressão de desempenho em todos os canais de lançamento do que permanecer sem avisos em todos os canais de lançamento , mas, novamente, esperar mais 12 semanas é basicamente um erro de arredondamento com relação a há quanto tempo esse problema existe.

Também podemos deixar -Zsaturating-float-casts perto (apenas mudando o padrão), o que significa que qualquer usuário noturno ainda pode optar por sair do canal por um tempo.

(Sim, o número de intrínsecos é apenas um detalhe de implementação e não foi concebido como um argumento a favor ou contra nada.)

@rkruppe Não posso afirmar ter digerido todos os comentários aqui, mas eu estou sob a impressão de que LLVM agora tem uma instrução de congelamento, que foi o item bloqueando o "caminho mais curto" para eliminar UB aqui, certo?

Embora eu ache que freeze seja tão novo que pode não estar disponível em nossa própria versão do LLVM, certo? Ainda assim, parece algo em que deveríamos explorar o desenvolvimento, talvez durante o primeiro semestre de 2020?

Nomeação para discussão na reunião do compilador T, para tentar obter um consenso aproximado sobre nosso caminho desejado neste momento.

Usar freeze ainda é problemático por todas as razões mencionadas aqui . Não tenho certeza de quão realistas são essas preocupações com o uso de congelamento para esses moldes, mas, em princípio, elas se aplicam. Basicamente, espere que freeze retorne lixo aleatório ou sua chave secreta, o que for pior. (Eu li isso online em algum lugar e realmente gosto que tenha um resumo.: D)

E, de qualquer maneira, mesmo devolver lixo aleatório parece bastante ruim para um elenco de as . Faz sentido ter operações mais rápidas para velocidade onde necessário, semelhante a unchecked_add , mas tornar isso o padrão parece fortemente contra o espírito de Rust.

@SimonSapin, você propôs a abordagem oposta primeiro (o padrão é semântica

@pnkfelix

Tenho a impressão de que o LLVM agora tem uma instrução de congelamento, que era o item bloqueando o "caminho mais curto" para eliminar o UB aqui, certo?

Existem algumas ressalvas. Mais importante ainda, mesmo se tudo o que nos interessa é nos livrarmos do UB e atualizarmos nosso LLVM empacotado para incluir freeze (o que poderíamos fazer a qualquer momento), oferecemos suporte a várias versões mais antigas (de volta ao LLVM 6 no momento) e precisaríamos de alguma implementação de fallback para que realmente se livrem do UB para todos os usuários.

Em segundo lugar, é claro, está a questão de saber se "apenas não UB" é tudo que nos preocupa enquanto estamos nisso. Em particular, quero destacar novamente que freeze(fptosi %x) se comporta de forma extremamente contra-intuitiva: é não determinístico e pode retornar um resultado diferente (mesmo que seja retirado da memória sensível como @RalfJung disse) toda vez que for executado. Não quero discutir isso novamente agora, mas vale a pena considerar na reunião se preferimos trabalhar um pouco mais para tornar a saturação o padrão e conversões desmarcadas (inseguras ou freeze -utilizando) a opção não padrão.

@RalfJung Minha posição é que as deve ser evitado totalmente, independentemente desse problema, porque ele pode ter semânticas totalmente diferentes (truncamento, saturação, arredondamento ...) dependendo do tipo de entrada e saída, e nem sempre óbvio ao ler o código. (Mesmo o último pode ser inferido com foo as _ .) Portanto, tenho um rascunho de pré-RFC para propor vários métodos de conversão nomeados explicitamente que cobrem os casos que as faz hoje (e talvez mais) .

Acho que as definitivamente não deveria ter UB, uma vez que pode ser usado fora de unsafe . Devolver o lixo também não soa bem. Mas provavelmente deveríamos ter algum tipo de mitigação / transição / alternativa para casos conhecidos de regressão de desempenho causada por elenco de saturação. Eu apenas perguntei sobre uma implementação de biblioteca de elenco de saturação para não bloquear este rascunho de RFC nessa transição.

@SimonSapin

Minha posição é que é melhor evitar totalmente, independentemente deste problema, porque pode ter semânticas totalmente diferentes (truncamento, saturação, arredondamento, ...)

Acordado. Mas isso realmente não nos ajuda com esse problema.

(Além disso, estou feliz que você esteja trabalhando para tornar as desnecessário. Ansioso por isso.: D)

Acho que definitivamente não deveria ter UB, já que pode ser usado fora de inseguro. Devolver o lixo também não soa bem. Mas provavelmente deveríamos ter algum tipo de mitigação / transição / alternativa para casos conhecidos de regressão de desempenho causada por elenco de saturação. Eu apenas perguntei sobre uma implementação de biblioteca de elenco de saturação para não bloquear este rascunho de RFC nessa transição.

Portanto, parece que concordamos que o estado final deve ser que float-to-int as satura? Estou feliz com qualquer plano de transição, desde que esse seja o objetivo final para o qual estamos caminhando.

Esse objetivo final parece bom para mim.

Não acho que seja uma boa abordagem para lidar com regressões significativas _conhecidas_. Para alguns usuários, a perda de desempenho pode ser um problema real, enquanto seu algoritmo garante que a entrada esteja sempre dentro do alcance. Se eles não estiverem inscritos neste tópico, eles só perceberão que serão afetados quando a mudança chegar ao canal Estável. Nesse ponto, eles podem ficar presos por 6 a 12 semanas, mesmo se conseguirmos uma API não segura imediatamente após a solicitação.

Na minha opinião, não seria o fim do mundo se esses usuários esperassem com a atualização de seu rustc por 6-12 semanas - eles podem não precisar de nada dos próximos lançamentos em ambos os casos, ou suas bibliotecas podem ter restrições de MSRV para defender.

Enquanto isso, os usuários, que também não estão inscritos no segmento, podem estar recebendo erros de compilação da mesma forma que podem estar tendo perdas de desempenho. O que devemos priorizar? Damos garantias sobre estabilidade e damos garantias sobre segurança - mas, que eu saiba, essas garantias não são dadas sobre desempenho (por exemplo, RFC 1122 não menciona desempenho de todo).

Eu prefiro seguir o padrão já estabelecido para avisos de depreciação: apenas faça a troca em Nightly depois que a alternativa estiver disponível em Stable por algum tempo.

No caso de avisos de depreciação, a consequência de esperar com a depreciação até que haja uma alternativa estável não é, pelo menos que eu saiba, falhas de segurança durante o período de espera. (Além disso, embora os intrínsecos possam ser fornecidos aqui, no caso geral, podemos não ser capazes de oferecer alternativas razoáveis ​​ao consertar orifícios de solidez. Portanto, não acho que ter alternativas no estável possa ser um requisito difícil.)

Tudo bem, você me pegou, mas não vejo como isso é relevante. Sejam 30 intrínsecos, ainda é trivial adicioná-los. Mas, na realidade, é ainda mais fácil ter um único intrínseco genérico usado por N wrappers finos. O número também não muda se escolhermos "fazer as som e introduzir uma opção de unsafe cast API".

Aquele único intrínseco genérico não requer implementações separadas no compilador para aquelas instanciações monomórficas específicas de 12/30?

Pode ser trivial adicionar intrínsecos ao compilador, porque o LLVM já fez a maior parte do trabalho, mas isso também está longe do custo total. Além disso, há a implementação em Miri, Cranelift, bem como o eventual trabalho necessário em uma especificação. Portanto, não acho que devemos adicionar intrínsecos na chance de alguém precisar deles.

No entanto, não me oponho a expor mais intrínsecos, mas se alguém precisar deles, eles devem fazer uma proposta (por exemplo, como um RP com alguma descrição elaborada) e justificar a adição com alguns números de benchmarking ou algo parecido.

Também podemos deixar -Zsaturating-float-casts perto (apenas mudando o padrão), o que significa que qualquer usuário noturno ainda pode optar por sair do canal por um tempo.

Isso parece bom para mim, mas eu sugeriria renomear o sinalizador para -Zunsaturating-float-casts para evitar alterar a semântica para inadequação para aqueles que já usam esse sinalizador.

@Centril

Aquele único intrínseco genérico não requer implementações separadas no compilador para aquelas instanciações monomórficas específicas de 12/30?

Não, a maior parte da implementação pode ser e já é compartilhada pela parametrização das larguras dos bits de origem e destino. Apenas alguns bits precisam de distinções de caso. O mesmo se aplica à implementação no miri e, muito provavelmente, também a outras implementações e especificações.

(Editar: para ser claro, esse compartilhamento pode acontecer mesmo se houver N intrínsecos distintos, mas um único intrínseco genérico reduz o clichê necessário por intrínseco.)

Portanto, não acho que devemos adicionar intrínsecos na chance de alguém precisar deles.

No entanto, não me oponho a expor mais intrínsecos, mas se alguém precisar deles, eles devem fazer uma proposta (por exemplo, como um RP com alguma descrição elaborada) e justificar a adição com alguns números de benchmarking ou algo parecido. Não acho que isso deva bloquear a fixação do orifício de segurança enquanto isso.

Já temos alguns números de benchmarking. Sabemos da chamada para benchmarks há muito tempo que a codificação JPEG fica significativamente mais lenta em x86_64 com conversões de saturação. Alguém poderia executá-los novamente, mas tenho certeza de que isso não mudou (embora, é claro, os números específicos não sejam idênticos) e não vejo nenhuma razão para que futuras alterações em como a saturação seja implementada (como mudar para asm em linha ou o Os intrínsecos do LLVM em que @nikic trabalhou) mudariam isso fundamentalmente. Embora seja difícil ter certeza sobre o futuro, meu palpite é que a única maneira plausível de obter esse desempenho de volta é usar algo que gere código sem verificações de intervalo, como uma conversão de unsafe ou algo usando freeze .

OK, então a partir dos números de benchmarking existentes, parece que há um desejo ativo por esses intrínsecos. Nesse caso, eu proporia o seguinte plano de ação:

  1. Simultaneamente:

    • Apresente os intrínsecos expostos todas as noites por meio das funções #[unstable(...)] .

    • Remova -Zsaturating-float-casts e introduza -Zunsaturating-float-casts .

    • Mude o padrão para o que -Zsaturating-float-casts faz.

  2. Nós estabilizamos os intrínsecos depois de algum tempo; podemos acelerar um pouco.
  3. Remova -Zunsaturating-float-casts depois de um tempo.

Parece bom. Exceto que os intrínsecos são detalhes de implementação de alguma API pública, provavelmente métodos em f32 e f64 . Eles podem ser:

  • Métodos de uma característica genérica (com um parâmetro para o tipo de retorno inteiro da conversão), opcionalmente no prelúdio
  • Métodos inerentes com um traço de suporte (semelhante a str::parse e FromStr ) para oferecer suporte a diferentes tipos de retorno
  • Vários métodos inerentes não genéricos com o tipo de destino no nome

Sim, eu quis dizer expor os intrínsecos por meio de métodos ou algo assim.

Vários métodos inerentes não genéricos com o tipo de destino no nome

Parece a coisa normal que fazemos - alguma objeção a essa opção?

É mesmo? Eu sinto que quando tem o nome de um tipo (da assinatura) como parte do nome de um método são conversões ad-hoc “únicas” (como Vec::as_slice e [T]::to_vec ) , ou uma série de conversões em que a diferença não é um tipo (como to_ne_bytes , to_be_bytes , to_le_bytes ). Mas parte da motivação para as características de std::convert era evitar dezenas de métodos separados, como u8::to_u16 , u8::to_u32 , u8::to_u64 , etc.

Minha dúvida é se isso seria naturalmente generalizável para uma característica, visto que os métodos precisam ser unsafe fn . Se adicionarmos métodos inerentes, então você sempre pode delegar para aqueles em implementações de características e outros enfeites.

Parece estranho para mim adicionar características para conversões inseguras, mas acho que provavelmente Simon está pensando no fato de que possivelmente precisaríamos de um método diferente para cada combinação de ponto flutuante e tipo inteiro (por exemplo, f32::to_u8_unsaturated , f32::to_u16_unsaturated , etc).

Não quero me preocupar com uma longa discussão que não li em total ignorância, mas isso é desejado ou é suficiente ter, por exemplo, f32::to_integer_unsaturated que se converte em u32 ou algo assim? Existe uma escolha óbvia para o tipo de destino para a conversão insegura?

Fornecer conversões não seguras apenas para i32 / u32 (por exemplo) exclui completamente todos os tipos de inteiros cujo intervalo de valores não é estritamente menor, e isso às vezes é definitivamente necessário. Diminuir o tamanho (até u8, como na codificação JPEG) também é frequentemente necessário, mas pode ser emulado convertendo para um tipo inteiro mais amplo e truncando com as (que é barato, embora geralmente não seja gratuito).

Mas não podemos fornecer apenas a conversão para o maior tamanho inteiro. Esses nem sempre são nativamente suportados (portanto, são lentos) e as otimizações não podem consertar isso: não é recomendável otimizar "converter para grande int, depois truncar" para "converter para menor int diretamente" porque o último tem UB (em LLVM IR) / resultados diferentes (no nível do código de máquina, na maioria das arquiteturas) nos casos em que o resultado da conversão original teria quebrado ao ser truncado.

Observe que mesmo excluir pragmaticamente inteiros de 128 bits e focar em inteiros de 64 bits ainda será ruim para destinos comuns de 32 bits.

Sou novo nesta conversa, mas não em programação. Estou curioso para saber por que as pessoas pensam que as conversões saturantes e a conversão de NaN para zero são comportamentos padrão razoáveis. Eu entendo que o Java faz isso (embora o agrupamento pareça muito mais comum), mas não há nenhum valor inteiro para o qual NaN possa realmente ser considerado uma conversão correta. Da mesma forma, converter 1000000,0 em 65535 (u16), por exemplo, parece errado. Simplesmente não há u16 que é claramente a resposta certa. Pelo menos, não vejo isso como sendo melhor do que o comportamento atual de convertê-lo para 16960, que é pelo menos um comportamento compartilhado com C / C ++, C #, go e outros e, portanto, pelo menos não é surpreendente.

Várias pessoas comentaram sobre a semelhança com a verificação de estouro e eu concordo com eles. Também é semelhante à divisão inteira por zero. Acho que as conversões inválidas devem entrar em pânico, assim como a aritmética inválida. Depender de NaN -> 0 e 1000000.0 -> 65535 (ou 16960) parece tão sujeito a erros quanto confiar em um estouro de inteiro ou um hipotético n / 0 == 0. É o tipo de coisa que deve produzir um erro por padrão. (Em compilações de versão, a ferrugem pode elidir a verificação de erro, assim como faz com a aritmética de inteiros.) E nos raros casos em que você _deseja_ converter NaN para zero ou tenha saturação de ponto flutuante, deverá optar por isso, assim como você tem que optar por estouro de inteiro.

Quanto ao desempenho, parece que o desempenho geral mais alto viria de fazer uma conversão simples e confiar em falhas de hardware. Tanto o x86 quanto o ARM, por exemplo, geram exceções de hardware quando uma conversão de ponto flutuante para inteiro não pode ser representada corretamente (incluindo casos NaN e fora do intervalo). Esta solução tem custo zero, exceto para conversões inválidas, exceto ao converter diretamente de ponto flutuante para pequenos tipos inteiros em compilações de depuração - um caso raro - onde ainda deveria ser comparativamente barato. (Em hardware teórico que não oferece suporte a essas exceções, ele pode ser emulado em software, mas novamente apenas em compilações de depuração.) Imagino que as exceções de hardware são exatamente como a detecção de divisão inteira por zero é implementada hoje. Vi muito falar sobre LLVM, então talvez você esteja limitado aqui, mas seria lamentável ter emulação de software em cada conversão de ponto flutuante, mesmo em compilações de lançamento, para fornecer comportamentos alternativos duvidosos para conversões inerentemente inválidas.

@admilazz Somos limitados pelo que o LLVM pode fazer e, atualmente, o LLVM não expõe um método para converter floats em inteiros de maneira eficiente sem o risco de comportamento indefinido.

A saturação ocorre porque a linguagem define as casts para sempre ter sucesso e, portanto, não podemos mudar o operador para entrar em pânico.

Da mesma forma, converter 1000000,0 em 65535 (u16), por exemplo, parece errado. Simplesmente não há u16 que é claramente a resposta certa. Pelo menos, não vejo como sendo melhor do que o comportamento atual de convertê-lo para 16960,

Não era óbvio para mim, então acho que vale a pena ressaltar: 16960 é o resultado da conversão de 1000000.0 em um número inteiro suficientemente largo e, em seguida, truncado para manter os 16 bits mais baixos.

Esta ~ não é uma opção que foi sugerida antes neste tópico, e é ~ (Editar: eu estava errado aqui, desculpe não ter encontrado) também não é o comportamento atual. O comportamento atual no Rust é que a conversão de float para inteiro fora do intervalo é o comportamento indefinido. Na prática, isso geralmente leva a um valor de lixo; em princípio, poderia causar erros de compilação e vulnerabilidades. Este tópico é sobre consertar isso. Quando executo o programa abaixo no Rust 1.39.0, obtenho um valor diferente a cada vez:

fn main() {
    dbg!(1000000.0 as u16);
}

Playground . Exemplo de saída:

[src/main.rs:2] 1000000.0 as u16 = 49072

Eu pessoalmente acho que o truncamento do tipo inteiro não é melhor nem pior do que a saturação, ambos são numericamente errados para valores fora do intervalo. Uma conversão infalível tem o seu lugar, desde que seja determinística e não UB. Você já deve saber pelo seu algoritmo que os valores estão dentro do intervalo ou pode não se importar com esses casos.

Acho que também devemos adicionar APIs de conversão falíveis que retornam Result , mas ainda preciso terminar de escrever esse rascunho pré-RFC :)

O "convertido para inteiro matemática, em seguida, truncar a largura alvo" ou semântica "envolvente" foram sugeridos antes neste segmento (https://github.com/rust-lang/rust/issues/10184#issuecomment-299229143). Eu particularmente não gosto disso:

  • Acho que é um pouco menos sensato do que a saturação. A saturação geralmente não fornece resultados razoáveis ​​para números muito fora do intervalo, mas:

    • ele se comporta de forma mais sensata do que o contorno quando os números estão ligeiramente fora do intervalo (por exemplo, devido a erro de arredondamento acumulado). Em contraste, um elenco que envolve pode amplificar um erro de arredondamento leve no cálculo de float para o erro máximo possível no domínio inteiro.

    • é algo comumente usado em processamento de sinal digital, portanto, há pelo menos algumas aplicações onde é realmente desejado. Em contraste, não conheço um único algoritmo que se beneficie da semântica de wraparound.

  • AFAIK a única razão para preferir a semântica envolvente é a eficiência da emulação de software, mas isso parece uma suposição não comprovada para mim. Eu ficaria feliz em ser provado que estou errado, mas em um relance superficial, o contorno parece exigir uma cadeia tão longa de instruções ALU (mais ramificações para lidar com infinitos e NaNs separadamente) que não sinto que seja claro que um será claramente melhor para desempenho do que o outro.
  • Enquanto a questão do que fazer para NaN é um problema feio para qualquer conversão para inteiro, a saturação pelo menos não requer nenhuma caixa especial (nem na semântica nem na maioria das implementações) para o infinito. Mas, para encerrar, qual é o equivalente inteiro que +/- infinito deveria ser? JavaScript diz que é 0, e suponho que se fizermos as entrar em pânico no NaN, ele também poderá entrar em pânico no infinito, mas, de qualquer forma, isso tornará o enrolamento mais difícil de fazer rápido do que olhar para números normais e denormal sozinho sugeriria.

Suspeito que a maior parte do código regredido pela semântica de saturação para conversão seria melhor usando SIMD. Portanto, embora infeliz, essa mudança não impedirá que o código de alto desempenho seja escrito (especialmente se intrínseco com semânticas diferentes forem fornecidas), e pode até mesmo levar alguns projetos a uma implementação mais rápida (se menos portátil).

Nesse caso, algumas pequenas regressões de desempenho não devem ser usadas como justificativa para evitar o fechamento de um orifício de integridade.

https://github.com/rust-lang/rust/pull/66841 adiciona métodos unsafe fn que convertem com fptoui e fptosi do LLVM, para aqueles casos onde os valores são conhecidos estar dentro do alcance e saturar é uma regressão de desempenho mensurável.

Depois disso, acho que não há problema em mudar o padrão para as (e talvez adicionar outro sinalizador -Z para cancelar?), Embora essa provavelmente deva ser uma decisão formal da equipe Lang.

Depois disso, acho que não há problema em mudar o padrão para as (e talvez adicionar outro sinalizador -Z para cancelar?), Embora isso provavelmente deva ser uma decisão formal da equipe Lang.

Então nós (equipe de linguagem, com as pessoas que estavam lá, pelo menos) discutimos isso em https://github.com/rust-lang/lang-team/blob/master/minutes/2019-11-21.md e pensamos adicionar novos intrínsecos + adicionar -Zunsaturated-float-casts seriam bons primeiros passos.

Acho que seria bom mudar o padrão como parte disso ou logo depois, possivelmente com o FCP, se necessário.

Presumo que por novos intrínsecos você quer dizer algo como https://github.com/rust-lang/rust/pull/66841

O que significa adicionar -Z unsaturated-float-casts sem alterar o padrão? Aceitar como autônomo em vez de emitir "erro: opção de depuração desconhecida"

Presumo que por novos intrínsecos você quer dizer algo como # 66841

Sim 👍 - obrigado por liderar isso.

O que significa adicionar -Z unsaturated-float-casts sem alterar o padrão? Aceitar como autônomo em vez de emitir "erro: opção de depuração desconhecida"

Sim, basicamente. Alternativamente, removemos -Z saturated-float-casts em favor de -Z unsaturated-float-casts e mudamos o padrão diretamente, mas isso deve levar ao mesmo resultado em menos PRs.

Eu realmente não entendo a sugestão "insaturada". Se o objetivo for apenas fornecer um botão para cancelar o novo padrão, é mais fácil apenas alterar o padrão do sinalizador existente e nada mais. Se o objetivo é escolher um novo nome que seja mais claro sobre a compensação (insalubridade), então "insaturante" é péssimo - em vez disso, sugiro um nome que inclua "inseguro" ou "UB" ou algo semelhante palavra assustadora, por exemplo -Z fix-float-cast-ub .

unchecked é o termo com algum precedente em nomes de API.

@admilazz Somos limitados pelo que o LLVM pode fazer e, atualmente, o LLVM não expõe um método para converter floats em inteiros de maneira eficiente sem o risco de comportamento indefinido.

Mas presumivelmente você pode adicionar verificações de tempo de execução apenas em compilações de depuração, como você faz para estouro de inteiro.

AFAIK a única razão para preferir a semântica envolvente é a eficiência da emulação de software

Não acho que devamos preferir o wraparound ou a saturação, já que ambos estão errados, mas o wraparound pelo menos tem a vantagem de ser o método usado por muitas linguagens semelhantes à ferrugem: C / C ++, C #, go, provavelmente D, e certamente mais, e de ser também o comportamento atual da ferrugem (pelo menos às vezes). Dito isso, acho que "pânico em conversões inválidas (possivelmente apenas em compilações de depuração)" é ideal, assim como fazemos para estouro de inteiros e aritmética inválida, como divisão por zero.

(Curiosamente, eu consegui 16960 no playground . Mas eu vejo por outros exemplos postados que às vezes a ferrugem faz isso de forma diferente ...)

A saturação ocorre porque a linguagem define como conversões sempre bem-sucedidas e, portanto, não podemos mudar o operador para entrar em pânico.

Mudar o que a operação avalia já é uma mudança radical, na medida em que nos preocupamos com os resultados das pessoas que já estão fazendo isso. Esse comportamento sem pânico também pode mudar.

Suponho que se fizéssemos pânico em NaN, então também poderia entrar em pânico no infinito, mas de qualquer forma, isso tornará o envolvimento mais difícil de fazer rápido

Se for verificado apenas em compilações de depuração, como ocorre com o estouro de inteiro, então acho que podemos obter o melhor dos dois mundos: as conversões são garantidas para serem corretas (em compilações de depuração), os erros do usuário têm mais probabilidade de serem detectados, você pode optar a comportamentos estranhos como wraparound e / ou saturação, se quiser, e o desempenho é o melhor que pode ser.

Além disso, parece estranho controlar essas coisas por meio de uma opção de linha de comando. É um grande martelo. Certamente, o comportamento desejado de uma conversão fora do intervalo depende das especificações do algoritmo, portanto, é algo que deve ser controlado por conversão. Eu sugeriria f.to_u16_sat () e f.to_u16_wrap () ou semelhante aos opt-ins, e não ter nenhuma opção de linha de comando que altere a semântica do código. Isso tornaria difícil misturar e combinar diferentes partes do código, e você não consegue entender o que algo faz ao lê-lo ...

E, se for realmente inaceitável tornar "pânico se inválido" o comportamento padrão, seria bom ter um método intrínseco que o implementasse, mas apenas executasse a verificação de validade em compilações de depuração para que possamos garantir que nossas conversões estejam corretas no (vasto maioria dos?) casos em que esperamos obter o mesmo número após a conversão, mas sem pagar qualquer penalidade nas compilações de lançamento.

Curiosamente, consegui 16960 no playground.

É assim que o Undefined Behavior funciona: dependendo da formulação exata do programa e da versão exata do compilador e dos sinalizadores de compilação exatos, você pode obter um comportamento determinístico ou um valor de lixo que muda a cada execução, ou compilações incorretas. O compilador tem permissão para fazer qualquer coisa.

wraparound pelo menos tem a vantagem de ser o método usado por muitas linguagens semelhantes à ferrugem: C / C ++, C #, go, provavelmente D, e certamente mais,

Realmente? Pelo menos não em C e C ++, eles têm o mesmo comportamento indefinido que Rust. Isso não é uma coincidência, nós usamos o LLVM, que é principalmente construído para clang implementar C e C ++. Tem certeza sobre o C # e pronto?

C11 padrão https://port70.net/~nsz/c/c11/n1570.html#6.3.1.4

Quando um valor finito de tipo flutuante real é convertido em um tipo inteiro diferente de _Bool, a parte fracionária é descartada (ou seja, o valor é truncado para zero). Se o valor da parte integral não pode ser representado pelo tipo inteiro, o comportamento é indefinido.

A operação de remaindering executada quando um valor do tipo inteiro é convertido para o tipo sem sinal não precisa ser executada quando um valor do tipo flutuante real é convertido para o tipo sem sinal. Assim, o intervalo de valores flutuantes reais portáteis é (-1, Utype_MAX + 1).

C ++ 17 padrão http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2017/n4659.pdf#section.7.10

Um prvalue de um tipo de ponto flutuante pode ser convertido em um prvalue de um tipo inteiro. A conversão trunca, ou seja, a parte fracionária é descartada. O comportamento é indefinido se o valor truncado não puder ser representado no tipo de destino.

Referência C # https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/numeric-conversions

Quando você converte um valor duplo ou flutuante em um tipo integral, esse valor é arredondado para zero para o valor integral mais próximo. Se o valor integral resultante estiver fora do intervalo do tipo de destino, o resultado dependerá do contexto de verificação de estouro. Em um contexto verificado, uma OverflowException é lançada, enquanto em um contexto não verificado, o resultado é um valor não especificado do tipo de destino.

Portanto, não é UB, apenas um "valor não especificado".

@admilazz Há uma grande diferença entre isso e o estouro de inteiro: o estouro de inteiro é indesejável, mas bem definido . As conversões de vírgula flutuante são comportamentos indefinidos .

O que você está pedindo é semelhante a desligar a verificação de limites Vec no modo de liberação, mas isso seria errado porque permitiria um comportamento indefinido.

Permitir um comportamento indefinido em código seguro não é aceitável, mesmo que isso aconteça apenas no modo de liberação. Portanto, qualquer correção deve ser aplicada ao modo de liberação e depuração.

Claro que é possível ter uma correção mais restritiva no modo de depuração, mas a correção para o modo de lançamento ainda deve estar bem definida.

@admilazz Há uma grande diferença entre isso e o estouro de inteiro: o estouro de inteiro é indesejável, mas bem definido. As conversões de vírgula flutuante são comportamentos indefinidos.

Claro, mas este tópico é sobre como definir o comportamento. Se ele foi definido como produzindo "um valor não especificado do tipo de destino", como na especificação C # que Amanieu fez referência acima, então ele não seria mais indefinido (de qualquer forma perigosa). Você não pode usar facilmente a natureza bem definida do estouro de inteiros em programas práticos porque ele ainda entrará em pânico em compilações de depuração. Da mesma forma, o valor produzido por um elenco inválido em compilações de lançamento não precisa ser previsível ou particularmente útil porque os programas não poderiam praticamente fazer uso dele de qualquer maneira se ele entrasse em pânico nas compilações de depuração. Na verdade, isso dá o máximo de escopo ao compilador para otimizações, ao passo que escolher um comportamento como a saturação restringe o compilador e pode ser significativamente mais lento no hardware sem instruções de conversão de saturação nativa. (E não é como se a saturação estivesse claramente correta.)

O que você está pedindo é semelhante a desligar a verificação de limites Vec no modo de liberação, mas isso seria errado porque permitiria um comportamento indefinido. Permitir comportamento indefinido em código seguro não é aceitável ...

Nem todo comportamento indefinido é igual. Comportamento indefinido significa apenas que cabe ao implementador do compilador decidir o que acontece. Contanto que não haja como violar as garantias de segurança da ferrugem lançando um float em um int, então não acho que seja semelhante a permitir que as pessoas gravem em locais de memória arbitrários. No entanto, é claro que concordo que deve ser definido no sentido de ser garantido como seguro, mesmo que não seja necessariamente previsível.

Realmente? Pelo menos não em C e C ++, eles têm o mesmo comportamento indefinido que Rust ... Você tem certeza sobre C # e pronto?

Justo. Não li todas as especificações; Acabei de testar vários compiladores. Você está certo ao dizer que "todos os compiladores que tentei fazer dessa maneira" é diferente de dizer "as especificações da linguagem definem que seja assim". Mas eu não estou argumentando a favor do estouro de qualquer maneira, apenas apontando que parece ser o mais comum. Estou realmente argumentando a favor de uma conversão que 1) proteja contra resultados "errados" como 1000000.0 tornando-se 65535 ou 16960 pelo mesmo motivo que protegemos contra estouro de inteiros - é provavelmente um bug, então os usuários devem optar por ele e 2) permite desempenho máximo em compilações de lançamento.

Nem todo comportamento indefinido é igual. Comportamento indefinido significa apenas que cabe ao implementador do compilador decidir o que acontece. Contanto que não haja como violar as garantias de segurança da ferrugem lançando um float em um int, então não acho que seja semelhante a permitir que as pessoas gravem em locais de memória arbitrários. No entanto, é claro que concordo que deve ser definido: definido, mas não necessariamente previsível.

Comportamento indefinido significa que os otimizadores (que são fornecidos por desenvolvedores LLVM focados em C e C ++) são livres para assumir que isso nunca pode acontecer e transformar o código com base nessa suposição, incluindo a exclusão de pedaços de código que só são acessíveis passando pelo elenco indefinido ou, como mostra este exemplo , supondo que uma atribuição deva ter sido chamada, embora na verdade não tenha sido, porque invocar o código que é chamado sem primeiro chamá-lo seria um comportamento indefinido.

Mesmo se fosse razoável provar que compor as diferentes etapas de otimização não produz comportamentos emergentes perigosos, os desenvolvedores do LLVM não farão nenhum esforço consciente para preservá-los.

Eu diria que todo comportamento indefinido é semelhante nessa base.

Mesmo se fosse razoável provar que compor as diferentes etapas de otimização não produz comportamentos emergentes perigosos, os desenvolvedores do LLVM não farão nenhum esforço consciente para preservá-los.

Bem, é uma pena que o LLVM interfere no design da ferrugem dessa maneira, mas eu acabei de ler algumas das referências de instrução do LLVM e mencionam a operação de "congelamento" mencionada acima ("... outra é esperar que o LLVM adicione um congelamento conceito… ") que evitaria um comportamento indefinido no nível do LLVM. A ferrugem está ligada a uma versão antiga do LLVM? Se não, poderíamos usá-lo. Sua documentação não é clara sobre o comportamento exato, no entanto.

Se o argumento for undef ou poison, 'freeze' retorna um valor arbitrário, mas fixo, do tipo 'ty'. Caso contrário, esta instrução é autônoma e retorna o argumento de entrada. Todos os usos de um valor retornado pela mesma instrução de 'congelamento' têm a garantia de sempre observar o mesmo valor, enquanto diferentes instruções de 'congelamento' podem produzir valores diferentes.

Não sei o que eles querem dizer com "valor fixo" ou "a mesma instrução de 'congelamento'". Acho que o ideal seria compilar para um no-op e fornecer um número inteiro imprevisível, mas parece que pode fazer algo caro. Alguém já tentou esta operação de congelamento?

Bem, é uma pena que o LLVM interfere no design da ferrugem desta forma

Não é só que os desenvolvedores LLVM escrevem os otimizadores. É que, mesmo que os desenvolvedores rustc escrevam os otimizadores, flertar com a indefinição é inerentemente uma grande arma de fogo por causa das propriedades emergentes de encadear otimizadores. O cérebro humano simplesmente não evoluiu para "intuir a magnitude potencial do erro de arredondamento" quando o arredondamento em questão é um comportamento emergente construído por passagens de otimização em cadeia.

Eu não vou discordar de você aí. :-) Espero que esta instrução de "congelamento" do LLVM forneça uma maneira de custo zero para evitar esse comportamento indefinido.

Isso foi discutido acima e a conclusão foi que, embora fundir e depois congelar seja um comportamento definido , não é um comportamento as .

IMO, tal semântica seria um design de linguagem ruim que preferiríamos evitar.

Minha posição é que as deve ser evitado inteiramente independentemente deste problema, porque ele pode ter semânticas totalmente diferentes (truncamento, saturação, arredondamento, ...) dependendo do tipo de entrada e saída, e esses nem sempre são óbvios quando código de leitura. (Mesmo o último pode ser inferido com foo as _ .) Portanto, tenho um rascunho de pré-RFC para propor vários métodos de conversão nomeados explicitamente que cobrem os casos que as faz hoje (e talvez mais) .

Eu terminei esse rascunho! https://internals.rust-lang.org/t/pre-rfc-add-explicitly-named-numeric-conversion-apis/11395

Qualquer feedback é muito bem-vindo, mas por favor, dê-o nos tópicos internos em vez de aqui.

No modo de liberação, tais conversões retornariam resultados arbitrários para entradas fora do limite (em código totalmente seguro). Essa não é uma boa semântica para algo tão inocente quanto.

Desculpe repetir, mas acho que o mesmo argumento se aplica ao estouro de inteiro. Se você multiplicar alguns números e o resultado estourar, você obterá um resultado totalmente errado que quase certamente invalidará o cálculo que estava tentando realizar, mas entra em pânico em compilações de depuração e, portanto, o bug provavelmente será detectado. Eu diria que uma conversão numérica que dá resultados totalmente errados também deve causar pânico, porque há uma grande chance de representar um bug no código do usuário. (O caso de imprecisão típica de ponto flutuante já foi tratado. Se um cálculo produzir 65535,3 já é válido convertê-lo para u16. Para obter uma conversão fora dos limites, você geralmente precisa de um bug em seu código, e se eu tiver um bug eu quero ser notificado para que possa corrigi-lo.)

A capacidade de compilações de lançamento para fornecer resultados arbitrários, mas definidos para conversões inválidas, também permite desempenho máximo, o que é importante, na minha opinião, para algo tão fundamental quanto conversões numéricas. Sempre saturar tem um impacto significativo no desempenho, esconde bugs e raramente faz um cálculo que encontra inesperadamente o resultado correto.

Desculpe repetir, mas acho que o mesmo argumento se aplica ao estouro de inteiro. Se você multiplicar alguns números e o resultado estourar, você obterá um resultado totalmente errado que quase certamente invalidará o cálculo que você estava tentando realizar

Não estamos falando de multiplicação, estamos falando de moldes. E sim, o mesmo se aplica ao estouro de inteiro: as conversões de int para int nunca entram em pânico, mesmo quando estouram. Isso ocorre porque as , por design, nunca entra em pânico, nem mesmo em compilações de depuração. Desviar disso para conversões de ponto flutuante é surpreendente na melhor das hipóteses e perigoso na pior, já que a correção e a segurança em código inseguro podem depender de certas operações não entrarem em pânico.

Se você quiser argumentar que o design de as é falho porque fornece uma conversão infalível entre tipos em que a conversão adequada nem sempre é possível, acho que a maioria de nós concordará. Mas isso está totalmente fora do escopo deste tópico, que trata da correção de conversões float para int dentro da estrutura existente de as casts . Eles devem ser infalíveis, não devem entrar em pânico, nem mesmo em compilações de depuração. Portanto, proponha alguma semântica razoável (não envolvendo freeze ) e sem pânico para conversões float-to-int, ou então tente iniciar uma nova discussão sobre redesenhar as para permitir o pânico quando o elenco tem perdas (e faça isso de forma consistente para conversões int-to-int e float-to-int) - mas o último está fora do tópico neste problema, então abra um novo tópico (estilo pré-RFC) por isso.

Que tal começarmos implementando agora a semântica freeze para consertar o UB, e então podemos ter todo o tempo do mundo para concordar sobre a semântica que realmente queremos, já que qualquer semântica que escolhermos será compatível com freeze semântica.

Que tal começarmos implementando apenas freeze semantics _now_ para consertar o UB, e então podemos ter todo o tempo do mundo para concordar sobre a semântica que realmente queremos, já que qualquer semântica que escolhermos será compatível com freeze semântica.

  1. O pânico não é compatível com o congelamento, portanto, precisaríamos rejeitar pelo menos todas as propostas que envolvam pânico. Mover-se do UB para o pânico é menos obviamente incompatível, embora, conforme discutido acima, haja alguns outros motivos para não fazer as entrar em pânico.
  2. Como escrevi antes ,
    > oferecemos suporte a várias versões mais antigas (de volta ao LLVM 6 no momento) e precisaríamos de alguma implementação de fallback para que realmente se livrassem do UB para todos os usuários.

Eu concordo com @RalfJung que fazer apenas alguns as casts entrar em pânico é altamente indesejável, mas tirando isso, não acho que este ponto que

(O caso de imprecisão típica de ponto flutuante já foi tratado. Se um cálculo produzir 65535,3 já é válido convertê-lo para u16. Para obter uma conversão fora dos limites, você geralmente precisa de um bug em seu código, e se eu tiver um bug eu quero ser notificado para que possa corrigi-lo.)

Para f32-> u16, pode ser verdade que você precise de um erro de arredondamento extraordinariamente grande para sair do intervalo u16 apenas por causa do erro de arredondamento, mas para conversões de f32 para inteiros de 32 bits isso não é tão obviamente verdadeiro. i32::MAX não é representável exatamente em f32, o número representável mais próximo é 47 fora de i32::MAX . Portanto, se você tiver um cálculo que deve resultar matematicamente em um número até i32::MAX , qualquer erro> = 1 ULP longe de zero o colocará fora dos limites. E fica muito pior quando consideramos flutuadores de precisão mais baixa (IEEE 754 binary16 ou o bfloat16 não padrão).

Não estamos falando de multiplicação, estamos falando de moldes

Bem, as conversões de ponto flutuante para inteiro são usadas quase exclusivamente no mesmo contexto da multiplicação: cálculos numéricos, e acho que há um paralelo útil com o comportamento do estouro de inteiro.

E sim, o mesmo se aplica ao estouro de inteiro: as conversões de int para int nunca entram em pânico, mesmo quando estouram ... Desviar disso para conversões de ponto flutuante é surpreendente na melhor das hipóteses e perigoso na pior, pois a correção e segurança em código pode depender de certas operações para não entrar em pânico.

Eu argumentaria que a inconsistência aqui é justificada pela prática comum e não seria tão surpreendente. Truncar e dividir inteiros com deslocamentos, máscaras e conversões - usando efetivamente as conversões como uma forma de AND bit a bit mais uma mudança de tamanho - é muito comum e tem uma longa história na programação de sistemas. É algo que faço várias vezes por semana, pelo menos. Mas, nos últimos 30 anos ou mais, não consigo me lembrar de alguma vez esperar obter um resultado razoável convertendo NaN, Infinity ou um valor de ponto flutuante fora do intervalo em um inteiro. (Cada instância de que me lembro foi um bug no cálculo que produziu o valor.) Portanto, não acho que o caso de inteiro -> conversão de inteiro e ponto flutuante -> conversão de inteiro deve ser tratado de forma idêntica. Dito isso, posso entender que algumas decisões já foram gravadas em pedra.

por favor ... proponha alguma semântica razoável (não envolvendo congelamento) e sem pânico para conversões float-to-int

Bem, minha proposta é:

  1. Não use opções de compilação global que afetam mudanças significativas na semântica. (Presumo que -Zsaturating-float-casts seja um parâmetro de linha de comando ou semelhante.) O código que depende do comportamento de saturação, digamos, seria quebrado se compilado sem ele. Presumivelmente, o código com expectativas diferentes não pode ser misturado no mesmo projeto. Deve haver alguma forma local de cálculo para especificar a semântica desejada, provavelmente algo como este pré-RFC .
  2. Faça as casts terem desempenho máximo por padrão, como seria de esperar de um elenco.

    • Acho que isso deve ser feito via congelamento nas versões do LLVM que o suportam e qualquer outra semântica de conversão nas versões do LLVM que não o suportam (por exemplo, truncamento, saturação, etc). Espero que a alegação de 'congelamento pode vazar valores da memória sensível' seja puramente hipotética. (Ou, se y = freeze(fptosi(x)) simplesmente deixar y inalterado, perdendo memória não inicializada, isso pode ser corrigido limpando y primeiro.)

    • Se as for relativamente lento por padrão (por exemplo, porque satura), forneça uma maneira de obter o desempenho máximo (por exemplo, um método - inseguro se necessário - que usa congelamento).

  1. Não use opções de compilação global que afetam mudanças significativas na semântica. (Presumo que -Zsaturating-float-casts seja um parâmetro de linha de comando ou semelhante.)

Para ser claro, não acho que alguém discorde. Este sinalizador só foi proposto como uma ferramenta de curto prazo para medir e contornar com mais facilidade as regressões de desempenho enquanto as bibliotecas são atualizadas para corrigir essas regressões.

Para f32-> u16, pode ser verdade que você precise de um erro de arredondamento extraordinariamente grande para sair do intervalo u16 apenas por causa do erro de arredondamento, mas para conversões de f32 para inteiros de 32 bits isso não é tão obviamente verdadeiro. i32 :: MAX não é representável exatamente em f32, o número representável mais próximo é 47 fora de i32 :: MAX. Então, se você tiver um cálculo que deve resultar matematicamente em um número até i32 :: MAX, qualquer erro> = 1 ULP longe de zero irá colocá-lo fora dos limites

Isso está ficando um pouco fora do assunto, mas digamos que você tenha este algoritmo hipotético que deve produzir matematicamente f32s até 2 ^ 31-1 (mas _não_ deve produzir 2 ^ 31 ou mais, exceto possivelmente devido a erro de arredondamento). Já parece estar com falhas.

  1. Eu acho que o i32 representável mais próximo é na verdade 127 abaixo de i32 :: MAX, então mesmo em um mundo perfeito sem imprecisão de ponto flutuante, o algoritmo que você espera estar produzindo valores de até 2 ^ 31-1 pode de fato apenas produzir (legal ) valores até 2 ^ 31-128. Talvez já seja um bug. Não tenho certeza se faz sentido falar sobre o erro medido de 2 ^ 31-1 quando esse número não é possível representar. Você teria que estar 64 desviado do número representável mais próximo (considerando o arredondamento) para sair dos limites. Certo, isso não é muito em termos de porcentagem quando você está perto de 2 ^ 32.
  2. Você não deve esperar discriminação de valores separados por 1 (ou seja, 2 ^ 31-1, mas não 2 ^ 31) quando os valores representáveis ​​mais próximos estão separados por 128. Além disso, apenas 3,5% de i32s são representáveis ​​como f32 (e <2% de u32s). Você não pode obter esse tipo de alcance ao mesmo tempo que tem esse tipo de precisão com um f32. O algoritmo parece estar usando a ferramenta errada para o trabalho.

Suponho que qualquer algoritmo prático que faça o que você descreve estará intimamente ligado a inteiros de alguma forma. Por exemplo, se você converter um i32 aleatório em f32 e vice-versa, ele poderá falhar se estiver acima de i32 :: MAX-64. Mas isso degrada muito a sua precisão e não sei por que você faria uma coisa dessas. Praticamente qualquer cálculo i32 -> f32 -> i32 que produza toda a gama i32 pode ser expresso mais rápido e com mais precisão com matemática inteira, e se não houver f64.

De qualquer forma, embora eu tenha certeza de que é possível encontrar alguns casos em que algoritmos que realizam conversões fora dos limites seriam corrigidos por saturação, acho que eles são raros - raros o suficiente para que não devamos desacelerar _todas_ as conversões para acomodá-los . E eu diria que esses algoritmos provavelmente ainda apresentam falhas e devem ser corrigidos. E se um algoritmo não pode ser corrigido, ele sempre pode fazer uma verificação de limites antes da conversão possivelmente fora dos limites (ou chamar uma função de conversão de saturação). Dessa forma, a despesa de delimitar o resultado é paga apenas quando necessário.

PS Tardio feliz Ação de Graças a todos

Para ser claro, não acho que alguém discorde. Esta bandeira só foi proposta como uma ferramenta de curto prazo ...

Eu estava me referindo principalmente à proposta de substituir -Zsaturated-float-casts com -Zinsaturated-float-casts. Mesmo se a saturação se tornar o padrão, sinalizadores como -Zunsaturated-float-casts parecem ruins para compatibilidade, mas se também se destina a ser temporário, então tudo bem, deixa para lá. :-)

De qualquer forma, tenho certeza de que todos esperam que eu tenha falado o suficiente sobre esse assunto - inclusive eu. Sei que a equipe Rust tradicionalmente busca fornecer várias maneiras de fazer as coisas, para que as pessoas possam fazer suas próprias escolhas entre desempenho e segurança. Eu compartilhei minha perspectiva e confio que vocês encontrarão uma boa solução no final. Cuidar!

Presumi que -Zunsaturated-float-casts existiria apenas temporariamente e seria removido em algum momento. Que é uma opção -Z (disponível apenas no Nightly) ao invés de -C sugere, pelo menos.

Por que vale a pena, saturação e UB não são as únicas opções. Outra possibilidade é alterar o LLVM para adicionar uma variante de fptosi que usa o comportamento de estouro nativo da CPU - ou seja, o comportamento de estouro não seria portátil entre as arquiteturas, mas seria bem definido em qualquer arquitetura ( por exemplo, retornar 0x80000000 em x86), e nunca retornaria veneno ou memória não inicializada. Mesmo se o padrão se tornar saturado, seria bom ter isso como uma opção. Afinal, enquanto os casts de saturação têm sobrecarga inerente em arquiteturas onde não são o comportamento padrão, "faça o que a CPU faz" só tem sobrecarga se inibir alguma otimização específica do compilador. Não tenho certeza, mas suspeito que quaisquer otimizações habilitadas pelo tratamento do estouro de float-para-int como UB são de nicho e não se aplicam à maioria dos códigos.

Dito isso, um problema pode ser se uma arquitetura tem várias instruções float-to-int que retornam valores diferentes em overflow. Nesse caso, o compilador escolhendo um ou outro afetaria o comportamento observável, o que não é um problema por si só, mas pode se tornar um se um único fptosi for duplicado e as duas cópias terminarem se comportando de maneira diferente. Mas não tenho certeza se esse tipo de divergência realmente existe em alguma arquitetura popular. E o mesmo problema se aplica a outras otimizações de ponto flutuante , incluindo

const fn (miri) já escolheu o comportamento de conversão saturada desde Rust 1.26 (assumindo que queremos que os resultados de CTFE e RTFE sejam consistentes) (antes de 1.26, a conversão transbordante em tempo de compilação retorna 0)

const fn g(a: f32) -> i32 {
    a as i32
}

const Q: i32 = g(1e+12);

fn main() {
    println!("{}", Q); // always 2147483647
    println!("{}", g(1e+12)); // unspecified value, but always 2147483647 in miri
}

Miri / CTFE usa os métodos to_u128 / to_i128 do apfloat para fazer a conversão. Mas não tenho certeza se isso é uma garantia estável - dado em particular que parece ter mudado antes (que não tínhamos conhecimento ao implementar essas coisas no Miri).

Acho que podemos ajustar isso para qualquer codegen que acabar pegando. Mas o fato de que o apfloat do LLVM (do qual a versão Rust é uma porta direta) usa saturação é um bom indicador de que este é algum tipo de "padrão razoável".

Uma solução para o comportamento observável poderia ser escolher aleatoriamente um dos métodos disponíveis no momento da construção do compilador ou do binário resultante.
Em seguida, tenha funções como a.saturating_cast::<i32>() para usuários que requerem um comportamento específico.

@ dns2utf8

A palavra "aleatoriamente" iria contra o esforço de obter compilações reproduzíveis e, se for previsível em uma versão do compilador, você sabe que alguém decidirá se ela não mudará.

IMO, o que @comex descreveu (não é novidade para este tópico IIRC, tudo que é antigo é novo novamente) esta é a próxima melhor opção se não quisermos saturação. Observe que nem mesmo precisamos de nenhuma alteração no LLVM para testar isso, podemos usar o conjunto embutido (em arquiteturas onde tais instruções existem).

Dito isso, um problema pode ser se uma arquitetura tem várias instruções float-to-int que retornam valores diferentes em overflow. Nesse caso, o compilador escolhendo um ou outro afetaria o comportamento observável, o que não é um problema por si só, mas pode se tornar um se um único fptosi for duplicado e as duas cópias terminarem se comportando de maneira diferente.

IMO, tal não-determinismo abriria mão de quase todas as vantagens práticas em comparação com freeze . Se fizermos isso, devemos escolher uma instrução por arquitetura e segui-la, tanto para o determinismo quanto para que os programas possam realmente confiar no comportamento da instrução quando fizer sentido para eles. Se isso não for possível em alguma arquitetura, poderíamos recorrer a uma implementação de software (mas, como você diz, isso é inteiramente hipotético).

Isso é mais fácil se não delegarmos essa decisão ao LLVM, mas implementarmos a operação com conjunto embutido. O que, incidencialmente, também seria muito mais fácil do que alterar o LLVM para adicionar novos intrínsecos e diminuí-los em cada backend.

@rkruppe

[...] O que acidentalmente também seria muito mais fácil do que alterar o LLVM para adicionar novos intrínsecos e diminuir para eles em cada backend.

Além disso, o LLVM não está exatamente satisfeito com intrínsecos com semântica dependente do alvo:

No entanto, se você deseja que os elencos sejam bem definidos, defina seu comportamento. "Faça alguma coisa rápida" não é realmente uma definição, e não acredito que devamos dar a construções independentes de alvo comportamento dependente de alvo.

https://groups.google.com/forum/m/#!msg/llvm -dev / cgDFaBmCnDQ / CZAIMj4IBAA

Vou retag # 10184 como apenas T-lang: acho que os problemas a serem resolvidos são escolhas semânticas sobre o que float as int significa

(ou seja, se estamos dispostos a permitir que tenha uma semântica de pânico ou não, se estamos dispostos a permitir que tenha uma subespecificação baseada em freeze ou não, etc)

essas são perguntas mais direcionadas à equipe T-lang, não ao compilador T, pelo menos para a discussão inicial, IMO

Apenas encontrei este problema produzindo resultados que são _irreproduzíveis entre execuções_ mesmo sem recompilar. O operador as parece buscar algum lixo da memória em tais casos.

Eu sugiro apenas proibir completamente o uso de as para "float as int" e confiar em métodos de arredondamento específicos. Raciocínio: as não causa perdas para outros tipos.

Raciocínio: como não é com perdas para outros tipos.

É isso?

Com base no Rust Book, posso assumir que não há perdas apenas em certos casos (nomeadamente nos casos em que From<X> é definido para um tipo Y), ou seja, você pode lançar u8 para u32 usando From , mas não o contrário.

Por "sem perdas", quero dizer fundição de valores que são pequenos o suficiente para caber. Exemplo: 1_u64 as u8 não tem perdas, portanto u8 as u64 as u8 não tem perdas. Para flutuadores, não existe uma definição simples de "ajustes", pois 20000000000000000000000000000_u128 as f32 não tem perdas enquanto 20000001_u32 as f32 sim, portanto, nem float as int nem int as float têm perdas.

256u64 as u8 entanto,

Mas <anything>_u8 as u64 as u8 não é.

Eu acho que a perda é normal e esperada com gesso, e não um problema. Truncar inteiros com casts (por exemplo, u32 as u8 ) é uma operação comum com um significado bem compreendido que é consistente em todas as linguagens do tipo C que eu conheço (pelo menos em arquiteturas que usam representações inteiras complementares de dois, que é basicamente todos eles hoje em dia). As conversões de ponto flutuante válidas (ou seja, onde a parte integrante se encaixa no destino) também têm uma semântica bem compreendida e aceita. 1.6 as u32 tem perdas, mas todas as linguagens semelhantes a C que conheço concordam que o resultado deve ser 1. Ambos os casos resultam do consenso entre os fabricantes de hardware sobre como essas conversões devem funcionar e a convenção em C -como as linguagens que casts devem ser tipos de operadores de alto desempenho, "Eu sei o que estou fazendo".

Portanto, não acho que devemos considerá-los problemáticos da mesma forma que as conversões de ponto flutuante inválidas, uma vez que elas não têm nenhuma semântica acordada em linguagens como C ou em hardware (mas geralmente resultam em estados de erro ou exceções de hardware) e quase sempre indicam bugs (na minha experiência) e, portanto, geralmente não existem no código correto.

Apenas encontrei esse problema produzindo resultados irreproduzíveis entre as execuções, mesmo sem recompilar. O operador as parece buscar algum lixo da memória em tais casos.

Pessoalmente, acho que está tudo bem, desde que só aconteça quando a conversão for inválida e não tenha nenhum efeito colateral além de produzir um valor de lixo. Se você realmente precisa de uma conversão de outra forma inválida em um pedaço de código, você mesmo pode lidar com o caso inválido com qualquer semântica que você acha que deveria ter.

e não tem efeitos colaterais além de produzir um valor de lixo

O efeito colateral é que o valor do lixo se origina em algum lugar na memória e revela alguns dados (possivelmente sensíveis). Retornar um valor "aleatório" calculado apenas a partir do próprio float seria bom, mas o comportamento atual não é.

As conversões de ponto flutuante válidas (ou seja, onde a parte integrante se encaixa no destino) também têm uma semântica bem compreendida e aceita.

Há algum caso de uso de conversões float para int não acompanhadas por trunc() , round() , floor() ou ceil() explícitos? A estratégia de arredondamento atual de as é "indefinida", tornando as dificilmente utilizável para números não arredondados. Acredito que na maioria dos casos quem escreve x as u32 realmente deseja x.round() as u32 .

Eu acho que a perda é normal e esperada com gesso, e não um problema.

Eu concordo, mas apenas se a perda for facilmente previsível. Para inteiros, as condições de conversão com perdas são óbvias. Para carros alegóricos, eles são obscuros. Eles não têm perdas para alguns números muito grandes, mas têm perdas para alguns números menores, mesmo se forem redondos. Minha preferência pessoal é ter dois operadores diferentes para conversões com e sem perdas para evitar a introdução de conversões com perdas por engano, mas também estou bem com apenas um operador, desde que eu possa dizer se é ou não com perdas.

O efeito colateral é que o valor do lixo se origina em algum lugar na memória e revela alguns dados (possivelmente sensíveis).

Eu esperaria que ele simplesmente deixasse o destino inalterado ou algo assim, mas se isso for realmente um problema, ele poderia ser zerado primeiro.

Há algum caso de uso de conversões float para int não acompanhadas por trunc (), round (), floor () ou ceil () explícito? A estratégia de arredondamento atual de as é "indefinida", tornando-se dificilmente utilizável para números não arredondados.

Se a estratégia de arredondamento for realmente indefinida, isso seria uma surpresa para mim, e eu concordaria que o operador quase não é útil, a menos que você já esteja fornecendo um número inteiro. Eu esperava que fosse truncar em direção a zero.

Eu acredito que na maioria dos casos quem escreve x as u32 realmente quer x.round() as u32 .

Acho que depende do domínio, mas espero que x.trunc() as u32 também seja bastante desejado.

Eu concordo, mas apenas se a perda for facilmente previsível.

Eu definitivamente concordo. Se 1.6 as u32 se torna 1 ou 2, não deve ser indefinido, por exemplo.

https://doc.rust-lang.org/nightly/reference/expressions/operator-expr.html#type -cast-expression

A conversão de um float para um inteiro arredondará o float para zero
NOTA: atualmente, isso causará comportamento indefinido se o valor arredondado não puder ser representado pelo tipo de inteiro alvo. Isso inclui Inf e NaN. Este é um bug e será corrigido.

Os links da nota aqui.

O arredondamento de valores que “se encaixam” é bem definido, não é disso que trata este problema. Este tópico já é longo, seria bom não especulá-lo sobre fatos que já estão estabelecidos e documentados. Obrigado.

Resta decidir como definir f as $Int nos seguintes casos:

  • f.trunc() > $Int::MAX (incluindo infinito positivo)
  • f.trunc() < $Int::MIN (incluindo infinito negativo)
  • f.is_nan()

Uma opção que já está implementada e disponível no Nightly com o sinalizador do compilador -Z saturating-casts é defini-los para retornar respectivamente: $Int::MAX , $Int::MIN e zero. Mas ainda é possível escolher outro comportamento.

Minha opinião é que o comportamento deve ser definitivamente determinístico e retornar algum valor inteiro (em vez de pânico, por exemplo), mas o valor exato não é muito importante e os usuários que se preocupam com esses casos devem usar métodos de conversão que estou propondo separadamente. adicionar: https://internals.rust-lang.org/t/pre-rfc-add-explicitly-named-numeric-conversion-apis/11395

Acho que depende do domínio, mas espero que x.trunc() as u32 também seja bastante desejado.

Corrigir. Em geral, x.anything() as u32 , provavelmente round() , mas também pode ser trunc() , floor() , ceil() . Apenas x as u32 sem especificar o procedimento de arredondamento concreto é provavelmente um erro.

Minha opinião é que o comportamento deve ser definitivamente determinístico e retornar algum valor inteiro (ao invés de pânico, por exemplo), mas o valor exato não é muito importante

Pessoalmente, estou bem mesmo com o valor "indefinido", desde que ele não dependa de nada além do próprio float e, o mais importante, não exponha nenhum registro não relacionado e conteúdo de memória.

Uma opção que já está implementada e disponível no Nightly com o sinalizador do compilador -Z saturating-casts é defini-los para retornar respectivamente: $Int::MAX , $Int::MIN e zero. Mas ainda é possível escolher outro comportamento.

O comportamento que eu esperaria obter para f.trunc() > $Int::MAX e f.trunc() < $Int::MIN é o mesmo de quando o número de ponto flutuante imaginário é convertido em um número inteiro de tamanho infinito e, em seguida, os bits mais baixos significativos são retornados ( como na conversão de tipos inteiros). Tecnicamente, isso seria alguns bits do significativo deslocados para a esquerda dependendo do expoente (para números positivos, os números negativos precisam de inversão de acordo com o complemento de dois).

Por exemplo, eu esperaria que números realmente grandes se convertessem em 0 .

Parece ser mais difícil / mais arbitrário definir em que se converte o infinito e o NaN.

@CryZe então se eu li corretamente, isso corresponde a -Z saturating-casts (e o que Miri já implementa)?

@RalfJung Correto.

Impressionante, copiarei https://github.com/WebAssembly/testsuite/blob/master/conversions.wast (com as armadilhas substituídas pelos resultados especificados) para o conjunto de testes da Miri. :)

@RalfJung Atualize para a versão mais recente de conversões.wast, que acabou de ser atualizada para incluir testes para os novos operadores de conversão de saturação. Os novos operadores têm "_sat" em seus nomes e não têm trapping, portanto, não é necessário substituir nada.

@sunfishcode obrigado por atualizar! Tenho que traduzir os testes para Rust de qualquer maneira, então ainda tenho que substituir muitas coisas. ;)

Os testes _sat diferentes em termos dos valores testados? (EDITAR: há um comentário lá dizendo que os valores são os mesmos.) Para as projeções de saturação de Rust, peguei muitos desses valores e os adicionei em https://github.com/rust-lang/miri/pull/1321. Tive preguiça de fazer isso por todos eles ... mas acho que isso significa que não há nada a ser alterado agora com o arquivo atualizado.

Para o UB intrínseco, as armadilhas do lado do wasm devem se tornar testes de falha de compilação em Miri, eu acho.

Os valores de entrada são todos iguais, a única diferença é que _sat operadores têm valores de saída esperados em entradas onde os operadores de trapping esperaram armadilhas.

Testes para Miri (e, portanto, também para o motor Rust CTFE) foram adicionados em https://github.com/rust-lang/miri/pull/1321. Eu verifiquei localmente se rustc -Zmir-opt-level=0 -Zsaturating-float-casts também passa nos testes desse arquivo.
Agora também implementei o intrínseco não verificado em Miri, consulte https://github.com/rust-lang/miri/pull/1325.

Eu postei https://github.com/rust-lang/rust/pull/71269#issuecomment -615537137 que documenta o estado atual como eu o entendi e que o PR também se move para estabilizar o comportamento da bandeira -Z saturante.

Dada a extensão deste tópico, acho que se as pessoas sentirem que perdi algo naquele comentário, eu direcionaria os comentários para o PR ou, se for menor, sinta-se à vontade para me enviar um ping no Zulip ou Discord (simulacro) e eu posso conserte as coisas para evitar ruídos desnecessários no tópico de RP.

Espero que alguém da equipe de idiomas provavelmente comece uma proposta FCP nesse PR em breve, e mesclá-la automaticamente encerrará este problema :)

Existem planos para conversões verificadas? Algo como fn i32::checked_from(f64) -> Result<i32, DoesntFit> ?

Você precisará considerar o que i32::checked_from(4.5) retornar.

Esta página foi útil?
0 / 5 - 0 avaliações