#include <stdio.h>
void assembly(float *array);
int main(void)
{
float array[4];
assembly(array);
printf("%f, %f, %f, %f\n", array[0], array[1], array[2], array[3]);
return 0;
}
Listando algumas instruções de movimentação de dados do SSE.
As instruções MOVAPS e MOVUPS fazem a mesma coisa: Movem 4 valores float empacotados entre registradores XMM ou de/para memória principal. MOVAPD e MOVUPD porém lida com 2 valores double.
A diferença é que a instrução MOVAPS/MOVAPD espera que o endereço do valor na memória esteja alinhado a um valor de 16 bytes, caso não esteja a instrução dispara uma exceção #GP (General Protection ou "segmentation fault" como é conhecido no Linux). O motivo dessa instrução exigir isso é que acessar o endereço alinhado é muito mais performático.
Já a instrução MOVUPS/MOVUPD pode acessar um endereço de memória desalinhado (unaligned) sem ocorrer nenhum erro, porém ela é menos performática.
Um exemplo de uso da MOVAPS na nossa PoC:
Sem entrar em detalhes ainda sobre a convenção de chamada, o ponteiro recebido como argumento pela função assembly() está no registrador RDI.
Sobre o atributo align=16 usado na seção .rodata ele serve para fazer exatamente o que o nome sugere: Alinhar o endereço inicial da seção em um múltiplo de 16, que é uma exigência da instrução MOVAPS.
Um detalhe interessante que vale citar é que apesar da instrução ter sido feita para lidar com um determinado tipo de dado nada impede de nós carregarmos outros dados nos registradores XMM. No exemplo abaixo usei a instrução MOVUPS para mover uma string de 16 bytes com apenas duas instruções:
Move um único float/double entre registradores XMM, onde o valor estaria contido na double word (4 bytes) ou quadword (8 bytes) menos significativo do registrador. E também é possível mover de/para memória principal.
A instrução MOVLPS instrução é semelhante à MOVUPS porém carrega/escreve apenas dois floats. No registrador os dois floats ficam armazenados no quadword (8 bytes) menos significativo. O quadword mais significativo do registrador não é alterado.
Já MOVLPD faz a mesma operação porém com um double contido no quadword menos significativo.
Semelhante a instrução acima porém armazena/ler o valor do registrador XMM no quadword mais significativo. O quadword menos significativo do registrador não é alterado.
Move o quadword (8 bytes) menos significativo do registrador fonte (a direita) para o quadword mais significativo do registrador destino. O quadword menos significativo do registrador destino não é alterado.
Move o quadword (8 bytes) mais significativo do registrador fonte (a direita) para o quadword menos significativo do registrador destino. O quadword mais significativo do registrador destino não é alterado.
MOVMSKPS move os bits mais significativos (MSB) de cada um dos quatro valores float contido no registrador XMM para os 4 bits menos significativo do registrador de propósito geral. Os outros bits do registrador são zerados.
Já MOVMSKPD faz a mesma coisa porém com os 2 valores doubles contidos no registrador, assim definindo os 2 bits menos significativos do registrador de propósito geral.
Essa instrução pode ser usada com o intuito de verificar o sinal de cada um dos valores float/double, tendo em vista que o bit mais significativo é usado para indicar o sinal do número (0 caso positivo e 1 caso negativo).
Calcula a média da soma de todos os valores dos dois operandos somados. PAVGB calcula a média somando 16 bytes em cada operando, enquanto PAVGW soma 8 words em cada um.
Copia uma das 8 words contidas no registrador XMM e armazena no registrador de propósito geral de 32 ou 64 bits. O valor é movido para os 16 bits menos significativos do registrador e todos os outros bits são zerados. Também é possível armazenar a word diretamente na memória principal.
O operando imediato é um valor entre 0 e 7 que indica o índice da word no registrador XMM. Apenas os 3 bits menos significativos do valor são considerados, os demais são ignorados.
Copia uma word dos 16 bits menos significativos do registrador de propósito geral no segundo operando e armazena em uma das words no registrador XMM. Também é possível ler a word da memória principal.
Assim como no caso do PEXTRW o operando imediato serve para identificar o índice da word no registrador XMM.
Compara os bytes/words não-sinalizados dos dois operandos packed e armazena o maior deles em cada uma das comparações no operando destino (o primeiro).
Faz o mesmo que a instrução anterior porém armazenando o menor número de cada comparação.
Faz o mesmo que PMAXUB/PMAXUW porém considerando o valor como sinalizado. Também há a instrução PMAXSD que compara quatro double words (4 bytes) empacotados.
Faz o mesmo que PMAXSB/PMAXSW porém retornando o menor valor de cada comparação.
Armazena nos 16 bits menos significativos do registrador de propósito geral cada um dos bits mais significativos (MSB) de todos os bytes contidos no registrador XMM.
Multiplica cada uma das words dos operandos onde o resultado temporário da multiplicação é de 32 bits de tamanho. Porém armazena no operando destino somente a word mais significativa do resultado da multiplicação.
PMULHW faz uma multiplicação sinalizada, enquanto PMULHUW faz uma multiplicação não-sinalizada.
Calcula a diferença absoluta dos bytes dos dois operandos e armazena a soma de todas as diferenças.
A diferença dos 8 bytes menos significativos é somada e armazenada na word menos significativa do operando destino. Já a diferença dos 8 bytes mais significativos é somada e armazenada na quinta word (índice 4, bits [79:64]) do operando destino. Todas as outras words do registrador XMM são zeradas.
Move dois quadwords (8 bytes) entre registradores XMM ou de/para memória RAM. O endereço na memória precisa estar alinhado a 16 se não uma exceção #GP será disparada.
Faz o mesmo que a instrução anterior porém o alinhamento da memória não é necessário, porém essa instrução é menos performática caso acesse um endereço desalinhado.
Soma os bytes, words, double words ou quadwords dos operandos e armazena no operando destino.
Faz o mesmo que a instrução PADDQ porém com uma operação de subtração.
Multiplica o primeiro (índice 0) e o terceiro (índice 2) doublewords dos operandos e armazena o resultado como quadwords no operando destino. O resultado da multiplicação entre os primeiros doublewords é armazenado no quadword menos signfiicativo do operando destino, enquanto a multiplicação entre os terceiros doublewords é armazenada no quadword mais significativo.
Exemplo:
RDI é o primeiro ponteiro recebido como argumento e RSI o segundo.
Faz uma operação de logical shift left com os dois quadwords do registrador XMM. O número de vezes que o shift deve ser feito é especificado pelo operando imediato de 8 bits. Os bits menos significativos são zerados.
Faz o mesmo que a instrução anterior porém com um shift right. Os bits mais significativos são zerados.
Instruções de operação aritmética do SSE.
Soma 4 números float (ou 2 números double) de uma única vez no registrador destino com os quatro números float (ou 2 números double) do registrador/memória fonte. Exemplo:
Funciona da mesma forma que a instrução anterior porém faz uma operação de subtração nos valores.
ADDSS faz a adição do float contido no double word (4 bytes) menos significativo do registrador XMM. Já ADDSD faz a adição do double contido na quadword (8 bytes) menos significativa do registrador.
Conforme exemplo abaixo:
Funciona da mesma forma que a instrução anterior porém subtraindo os valores.
Funciona como ADDPS/ADDPD porém multiplicando os números ao invés de somá-los.
Funciona como ADDSS/ADDSD porém multiplicando os números ao invés de somá-los.
Funciona como ADDPS/ADDPD porém dividindo os números ao invés de somá-los.
Funciona como ADDSS/ADDSD porém dividindo os números ao invés de somá-los.
Calcula o valor aproximado do inverso multiplicativo dos floats no operando fonte (a direita) e armazena os valores no operando destino.
Calcula o valor aproximado do inverso multiplicativo do float no operando fonte (a direita) e armazena o resultado na double word (4 bytes) menos significativa do operando destino.
Calcula as raízes quadradas dos números floats/doubles no operando fonte e armazena os resultados no operando destino.
Calcula a raiz quadrada do número escalar no operando fonte e armazena o resultado no float/double menos significativo do operando destino. Exemplo:
Calcula o inverso multiplicativo das raízes quadradas dos floats no operando fonte, armazenando os resultados no operando destino. Essa instrução é equivalente ao uso de SQRTPS seguido de RCPPS.
Calcula o inverso multiplicativo da raiz quadrada do número escalar no operando fonte e armazena o resultado no double word menos significativo do operando destino.
Compara cada um dos valores contidos nos dois operandos e retorna o maior valor entre os dois.
Compara os dois valores escalares e armazena o maior deles no float/double menos significativo do operando destino.
Funciona da mesma forma que MAXPS/MAXPD porém retornando o menor valor entre cada comparação.
Funciona da mesma forma que MAXSS/MAXSD porém retornando o menor valor entre os dois.
Convertendo valores entre float, double e inteiro.
Essas instruções servem para conversão de tipos entre float, double e inteiro.
Converte dois valores float do operando fonte (segundo) em dois valores double no operando destino (primeiro).
Converte dois valores double do operando fonte (segundo) em dois valores float no operando destino (primeiro).
Converte um valor float do operando fonte (segundo) em um valor double no operando destino (primeiro).
Converte um valor double do operando fonte (segundo) em um valor float no operando destino (primeiro).
Converte os dois doubles no operando fonte para dois inteiros sinalizados de 32-bit no operando destino. A instrução CVTPD2DQ faz o arredondamento normal do valor enquanto CVTTPD2DQ trunca ele.
Converte os dois inteiros sinalizados de 32-bit no operando fonte para dois doubles no operando destino.
CVTSD2SI converte o valor double no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerá extensão de sinal (sign extension).
CVTTSD2SI faz a mesma coisa porém truncando o valor.
Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um double no operando destino.
Converte quatro floats do operando fonte em quatro inteiros sinalizados de 32-bit no operando destino. A instrução CVTPS2DQ faz o arredondamento normal dos valores enquanto CVTTPS2DQ trunca eles.
Converte quatro inteiros sinalizados de 32-bit no operando fonte para quatro floats no operando destino.
CVTSS2SI converte o valor float no operando fonte em inteiro de 32-bit sinalizado, e armazena o valor no registrador de propósito geral do operando destino. O registrador destino também pode ser um registrador de 64-bit onde nesse caso o valor sofrerá extensão de sinal (sign extension).
A instrução CVTTSS2SI faz a mesma coisa porém truncando o valor.
Converte o valor inteiro sinalizado de 32 ou 64 bits do operando fonte e armazena como um float no operando destino.
Faz uma operação E bit a bit (bitwise AND) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
Faz uma operação NAND bit a bit em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
Faz uma operação OU bit a bit (bitwise OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
Faz uma operação OU Exclusivo bit a bit (bitwise eXclusive OR) em cada um dos valores float/double contidos no operando fonte e armazena o resultado no operando destino.
As instruções de comparação do SSE recebem um terceiro operando imediato de 8 bits que serve como identificador para indicar qual comparação deve ser efetuada com os valores, onde os valores válidos são de 0 até 7. Na tabela abaixo é indicado cada valor e qual operação de comparação ele representa:
Felizmente para facilitar nossa vida os assemblers, incluindo o NASM, adicionam pseudo-instruções que removem o operando imediato e, ao invés disso, usa os mnemônicos apresentados na tabela como conditional code para a instrução. Como é demonstrado no exemplo abaixo:
Essa instrução compara cada um dos valores float/double contido nos dois operandos e armazena o resultado da comparação no operando fonte (o primeiro). O valor imediato passado como terceiro operando é um código numérico para identificar qual operação de comparação deve ser executada em cada um dos valores.
O resultado é armazenado como todos os bits ligados (1) caso a comparação seja verdadeira, se não todos os bits estarão desligados (0) indicando que a comparação foi falsa. Cada número float/double tem um resultado distinto no registrador destino.
Funciona da mesma forma que a instrução anterior porém comparando um único valor escalar. O resultado é armazenado no float/double menos significativo do operando fonte.
As quatro instruções comparam os dois operandos escalares e definem as status flags em EFLAGS de acordo com a comparação sem modificar os operandos. Comportamento semelhante ao da instrução CMP.
Quando uma operação aritmética com números floats resulta em NaN existem dois tipos diferentes:
quiet NaN (QNaN): O valor é apenas definido para NaN sem qualquer indicação de problema.
signaling NaN (SNaN): O valor é definido para NaN e uma exceção floating-point invalid-operation (#I
) é disparada caso você execute alguma operação com o valor.
A diferença entre COMISS/COMISD e UCOMISS/UCOMISD é que COMISS/COMISD irá disparar a exceção #I
se o primeiro operando for QNaN ou SNaN. Já UCOMISS/UCOMISD apenas dispara a exceção se o primeiro operando for SNaN.
Aprendendo sobre SIMD, SSE e registradores XMM.
Na computação existe um conceito de instrução chamado SIMD (Single Instruction, Multiple Data) que é basicamente uma instrução que processa múltiplos dados de uma única vez. Todas as instruções que vimos até agora processavam meramente um dado por vez, porém instruções SIMD podem processar diversos dados paralelamente. O principal objetivo das instruções SIMD é ganhar performance se aproveitando dos múltiplos núcleos do processador, a maioria das instruções SIMD foram implementadas com o intuito de otimizar cálculos comuns em áreas como processamento gráfico, inteligência artificial, criptografia, matemática etc.
A Intel criou a primeira versão do SSE (streaming SIMD extensions) ainda no IA-32 com o Pentium III, e de lá para cá já ganhou diversas novas versões que estendem a tecnologia adicionando novas instruções. Atualmente nos processadores mais modernos há as seguintes extensões: SSE, SSE2, SSE3, SSSE3 e SSE4.
Processadores da arquitetura x86 têm diversas tecnologias SIMD, a primeira delas foi o MMX nos processadores Intel antes mesmo do SSE. Além de haver diversas outras como AVX, AVX-512, FMA, 3DNow! (da AMD) etc.
Na arquitetura x86 existem literalmente milhares de instruções SIMD. Esteja ciente que esse tópico está longe de cobrir todo o assunto e serve meramente como conteúdo introdutório.
A tecnologia SSE adiciona novos registradores independentes de 128 bits de tamanho cada. Em todos os modos de operação são adicionados oito novos registradores XMM0 até XMM7, e em 64-bit também há mais oito registradores XMM8 até XMM15 que podem ser acessados usando o prefixo REX. Isso dá um total de 16 registradores em 64-bit e 8 registradores nos outros modos de operação.
Esses registradores podem armazenar vários dados diferentes de mesmo tipo/tamanho, conforme demonstra tabela abaixo:
Esses são os tipos empacotados (packed), onde em um único registrador há vários valores de um mesmo tipo. Existem instruções SIMD específicas que executam operações packed onde elas trabalham com os vários dados armazenados no registrador ao mesmo tempo. Em contraste existem também as operações escalares (scalar) que operam com um único dado (unpacked) no registrador, onde esse dado estaria armazenado na parte menos significativa do registrador.
Na convenção de chamada para x86-64 da linguagem C os primeiros argumentos float/double passados para uma função vão nos registradores XMM0, XMM1 etc. como valores escalares. E o retorno do tipo float/double fica no registrador XMM0 também como um valor escalar.
Na lista de instruções haverá códigos de exemplo disso.
As instruções adicionadas pela tecnologia SSE podem ser divididas em quatro grupos:
Instruções packed e scalar que lidam com números float.
Instruções SIMD com inteiros de 64 bits.
Instruções de gerenciamento de estado.
Instruções de controle de cache e prefetch.
A tabela abaixo lista a nomenclatura que irei utilizar para descrever as instruções SSE.
Para facilitar o entendimento irei usar o termo float para se referir aos números de ponto flutuante de precisão única, ou seja, 32 bits de tamanho e 23 bits de precisão. Já o termo double será utilizado para se referir aos números de ponto flutuante de dupla precisão, ou seja, de 64 bits de tamanho e 52 bits de precisão. Esses são os mesmos nomes usados como tipos na linguagem C.
As instruções SSE terminam com um sufixo de duas letras onde a penúltima indica se ela lida com dados packed ou scalar, e a última letra indica o tipo do dado sendo manipulado. Por exemplo a instrução MOVAPS onde o P indica que a instrução manipula dados packed, enquanto o S indica o tipo do dado como single-precision floating-point, ou seja, float de 32 bits de tamanho.
Já o D de MOVAPD indica que a instrução lida com valores do tipo double-precision floating-point (64 bits). Eis a lista de sufixos e seus respectivos tipos:
Todas as instruções SSE que lidam com valores na memória exigem que o valor esteja em um endereço alinhado em 16 bytes, exceto as instruções que explicitamente dizem lidar com dados desalinhados (unaligned).
Caso uma instrução SSE seja executada com um dado desalinhado uma exceção #GP será disparada.
Nomenclatura
Descrição
xmm(n)
Indica qualquer um dos registradores XMM.
float(n)
Indica N números floats em sequência na memória RAM. Exemplo: float(4) seriam 4 números float totalizando 128 bits de tamanho.
double(n)
Indica N números double na memória RAM. Exemplo: double(2) que totaliza 128 bits.
ubyte(n)
Indica N bytes não-sinalizados na memória RAM. Exemplo: ubyte(16) que totaliza 128 bits.
byte(n)
Indica N bytes sinalizados na memória RAM.
uword(n)
Indica N words (2 bytes) não-sinalizados na memória RAM. Exemplo: uword(8) que totaliza 128 bits.
word(n)
Indica N words sinalizadas na memória RAM.
dword(n)
Indica N double words (4 bytes) na memória RAM.
qword(n)
Indica N quadwords (8 bytes) na memória RAM.
reg32/64
Registrador de propósito geral de 32 ou 64 bits.
imm8
Operando imediato de 8 bits de tamanho.
Sufixo
Tipo
S
Single-precision float. Equivalente ao tipo float em C.
D
Double-precision float. Equivalente ao tipo double em C.
Ou inteiro doubleword (4 bytes) que seria um inteiro de 32 bits.
B
Inteiro de um byte (8 bits).
W
Inteiro word (2 bytes | 16 bits).
Q
Inteiro quadword (8 bytes | 64 bits).
Valor
Mnemônico
Descrição
0
EQ
Verifica se os valores são iguais.
1
LT
Verifica se o primeiro operando é menor que o segundo.
2
LE
Verifica se o primeiro operando é menor ou igual ao segundo.
3
UNORD
Verifica se os valores não estão ordenados.
4
NEQ
Verifica se os valores não são iguais.
5
NLT
Verifica se o primeiro operando não é menor que o segundo (ou seja, se é igual ou maior).
6
NLE
Verifica se o primeiro operando não é menor ou igual que o segundo (ou seja, se é maior).
7
ORD
Verifica se os valores estão ordenados.