Rust possui números inteiros de 128 bits, que são indicados com o tipo de dados i128
(e u128
para entradas não assinadas):
let a: i128 = 170141183460469231731687303715884105727;
Como o Rust faz com que esses i128
valores funcionem em um sistema de 64 bits; por exemplo, como é que faz aritmética sobre estes?
Como, até onde eu sei, o valor não pode caber em um registro de uma CPU x86-64, o compilador de alguma forma usa 2 registros para um i128
valor? Ou eles estão usando algum tipo de estrutura de número inteiro grande para representá-los?
Respostas:
Todos os tipos inteiros do Rust são compilados para números inteiros LLVM . A máquina abstrata LLVM permite números inteiros com qualquer largura de bit de 1 a 2 ^ 23 - 1. * As instruções LLVM normalmente funcionam com números inteiros de qualquer tamanho.
Obviamente, não existem muitas arquiteturas de 8388607 bits por aí; portanto, quando o código é compilado no código de máquina nativo, o LLVM precisa decidir como implementá-lo. A semântica de uma instrução abstrata como
add
é definida pelo próprio LLVM. Normalmente, instruções abstratas que possuem uma única instrução equivalente no código nativo serão compiladas para essa instrução nativa, enquanto as que não tiverem serão emuladas, possivelmente com várias instruções nativas. A resposta de mcarton demonstra como o LLVM compila instruções nativas e emuladas.(Isso não se aplica apenas aos números inteiros maiores do que a máquina nativa pode suportar, mas também aos menores. Por exemplo, arquiteturas modernas podem não suportar a aritmética nativa de 8 bits, portanto, uma
add
instrução em doisi8
s pode ser emulada com uma instrução mais ampla, os bits extras são descartados.)No nível do LLVM IR, a resposta é nenhuma:
i128
cabe em um único registro, como qualquer outro tipo de valor único . Por outro lado, uma vez traduzido para o código de máquina, não há realmente nenhuma diferença entre os dois, porque as estruturas podem ser decompostas em registros, assim como números inteiros. Ao fazer aritmética, no entanto, é uma aposta bastante segura que o LLVM carregue tudo em dois registros.* No entanto, nem todos os back-end LLVM são criados iguais. Esta resposta está relacionada ao x86-64. Entendo que o suporte de back-end para tamanhos maiores que 128 e não potências de dois é irregular (o que pode explicar em parte porque o Rust apenas expõe números inteiros de 8, 16, 32, 64 e 128 bits). De acordo com est31 no Reddit , rustc implementa números inteiros de 128 bits no software ao direcionar um back-end que não os suporta nativamente.
fonte
Type
classe, isso significa que existem 8 bits para armazenar que tipo de tipo (função, bloco, número inteiro, ...) e 24 bits para os dados da subclasse. AIntegerType
classe usa esses 24 bits para armazenar o tamanho, permitindo que as instâncias se encaixem perfeitamente em 32 bits!O compilador irá armazená-los em vários registros e usar várias instruções para fazer aritmética nesses valores, se necessário. A maioria dos ISAs possui instruções add-with-carry como x86, o
adc
que o torna bastante eficiente para adicionar / sub números inteiros de precisão estendida.Por exemplo, dado
o compilador gera o seguinte ao compilar para x86-64 sem otimização:
(comentários adicionados por @PeterCordes)
onde você pode ver que o valor
42
está armazenado emrax
ercx
.(nota do editor: as convenções de chamada x86-64 C retornam números inteiros de 128 bits em RDX: RAX. Mas isso
main
não retorna um valor. Toda a cópia redundante é puramente por desabilitar a otimização e o Rust realmente verifica o excesso na depuração modo.)Para comparação, eis o exemplo para números inteiros Rust de 64 bits no x86-64, em que não é necessário adicionar com transporte, apenas um único registro ou slot de pilha para cada valor.
O setb / test ainda é totalmente redundante:
jc
(pule se CF = 1) funcionaria perfeitamente.Com a otimização ativada, o compilador Rust não verifica o estouro, assim
+
funciona.wrapping_add()
.fonte
u128
argumentos e retornasse um valor (como este godbolt.org/z/6JBza0 ), em vez de desativar a otimização para impedir que o compilador o faça propagação constante em argumentos de constante de tempo de compilação.Sim, da mesma maneira que números inteiros de 64 bits em máquinas de 32 bits foram manipulados, números inteiros de 32 bits em máquinas de 16 bits ou mesmo números inteiros de 16 e 32 bits em máquinas de 8 bits (ainda aplicáveis a microcontroladores! ) Sim, você armazena o número em dois registradores, ou locais de memória, ou o que for (isso realmente não importa). Adição e subtração são triviais, seguindo duas instruções e usando o sinalizador de transporte. A multiplicação requer três multiplicações e algumas adições (é comum que os chips de 64 bits já tenham uma operação de multiplicação de 64x64-> 128 que gera dois registros). Divisão ... requer uma sub-rotina e é bastante lenta (exceto em alguns casos em que a divisão por uma constante pode ser transformada em um turno ou uma multiplicação), mas ainda funciona. Bit a bit e / ou / xor apenas precisam ser feitos nas metades superior e inferior separadamente. Os turnos podem ser realizados com rotação e mascaramento. E isso praticamente cobre as coisas.
fonte
Para fornecer talvez um exemplo mais claro, em x86_64, compilado com o
-O
sinalizador, a funçãocompila para
(Minha postagem original tinha
u128
mais do quei128
você perguntou. A função compila o mesmo código de qualquer maneira, uma boa demonstração de que adição assinada e não assinada é a mesma em uma CPU moderna.)A outra listagem produziu código não otimizado. É seguro avançar em um depurador, porque garante que você possa colocar um ponto de interrupção em qualquer lugar e inspecionar o estado de qualquer variável em qualquer linha do programa. É mais lento e mais difícil de ler. A versão otimizada está muito mais próxima do código que realmente será executado na produção.
O parâmetro
a
dessa função é passado em um par de registradores de 64 bits, rsi: rdi. O resultado é retornado em outro par de registros, rdx: rax. As duas primeiras linhas de código inicializam a soma paraa
.A terceira linha adiciona 1337 à palavra baixa da entrada. Se isso exceder, ele carrega o 1 no sinalizador de transporte da CPU. A quarta linha adiciona zero à palavra mais alta da entrada - mais 1 se for carregada.
Você pode pensar nisso como simples adição de um número de um dígito a um número de dois dígitos
mas na base 18.446.744.073.709.551.616. Você ainda está adicionando o "dígito" mais baixo primeiro, possivelmente carregando um 1 para a próxima coluna e, em seguida, adicionando o próximo dígito mais o transporte. Subtração é muito semelhante.
A multiplicação deve usar a identidade (2⁶⁴a + b) (2⁶⁴c + d) = 2¹²⁸ac + 2⁶⁴ (ad + bc) + bd, em que cada uma dessas multiplicações retorna a metade superior do produto em um registro e a metade inferior do produto em outro. Alguns desses termos serão descartados, porque os bits acima do 128º não cabem no a
u128
e são descartados. Mesmo assim, são necessárias várias instruções da máquina. A divisão também dá vários passos. Para um valor assinado, multiplicação e divisão precisariam adicionalmente converter os sinais dos operandos e o resultado. Essas operações não são muito eficientes.Em outras arquiteturas, fica mais fácil ou mais difícil. O RISC-V define uma extensão de conjunto de instruções de 128 bits, embora eu saiba que ninguém a implementou em silício. Sem essa extensão, o manual de arquitetura do RISC-V recomenda uma ramificação condicional:
addi t0, t1, +imm; blt t0, t1, overflow
O SPARC possui códigos de controle como os sinalizadores de controle do x86, mas você precisa usar uma instrução especial
add,cc
, para defini-los. O MIPS, por outro lado, exige que você verifique se a soma de dois números inteiros não assinados é estritamente menor que um dos operandos. Nesse caso, a adição estourou. Pelo menos você pode definir outro registro para o valor do bit de transporte sem uma ramificação condicional.fonte
sub
resultado, é necessário umn+1
sub resultado resultante para asn
entradas de bits. ou seja, você precisa observar a execução, não o bit de sinal do resultado da mesma largura. É por isso que as condições de ramificação não assinadas x86 são baseadas em CF (bit 64 ou 32 do resultado lógico completo), não em SF (bit 63 ou 31).x - (a*b)
, calculando o restante do dividendo, quociente e divisor. (Isso é útil mesmo para divisores constantes usando um inverso multiplicativo para a parte de divisão). Eu não tinha lido sobre ISAs que fundem instruções div + mod em uma única operação divmod; isso é legal.mul r64
são 2 uops, com o segundo escrevendo a metade alta do RDX).adc
,sbb
ecmov
2 UOPs cada. (Haswell introduzido UOPs 3-entrada para FMA, Broadwell estendida que para inteiro.)