Instrução única, múltiplos dados (SIMD) é uma forma de paralelismo em nível de hardware. O mesmo cálculo é realizado pela CPU sobre vários elementos ao mesmo tempo.
Considere, por exemplo, a soma de dois arrays de quatro números de ponto flutuante de 32 bits, elemento por elemento.
Uma implementação escalar emitiria quatro instruções addss separadas, uma por elemento.
Uma implementação SIMD usa um único addps para fazer as quatro adições em paralelo.
Isso significa quatro vezes mais trabalho por instrução.
Em assembly x86-64, as instruções SIMD normalmente são executadas usando registradores xmm.
Algumas dessas instruções operam sobre inteiros.
Outras operam sobre números de ponto flutuante.
Esses inteiros ou números de ponto flutuante são chamados de empacotados. Cada um ocupa um espaço, chamado de lane, dentro do mesmo registrador. As instruções SIMD normalmente operam em paralelo sobre valores na mesma posição de lane em dois operandos de origem. O resultado de cada operação é armazenado no lane na mesma posição do operando de destino.
Um lane não é um limite rígido. É uma interpretação que cada instrução dá a uma sequência de bytes. Um operando de 128 bits contém 16 bytes. Dependendo da instrução, esses bytes podem ser interpretados como 16 lanes de 1 byte, 8 lanes de 2 bytes, 4 lanes de 4 bytes, 2 lanes de 8 bytes, ou até um único lane de 16 bytes.
Por exemplo, a instrução addps soma valores de ponto flutuante empacotados de precisão simples (4 bytes).
Para essa instrução, cada bloco de 4 bytes em um operando é um lane diferente.
Em um operando de 128 bits de largura (16 bytes):
| operando | 4º lane | 3º lane | 2º lane | 1º lane |
|---|---|---|---|---|
| origem 1 | 1.5 | 2.1 | 6.2 | 3.4 |
| + | + | + | + | + |
| origem 2 | 0.5 | 8.26 | -13.4 | 22.9 |
| = | = | = | = | = |
| resultado | 2.0 | 10.36 | -7.2 | 26.3 |
Por outro lado, um operando de 128 bits seria interpretado como tendo 2 lanes no caso de mulpd.
Isso porque essa instrução realiza multiplicação empacotada sobre valores de precisão dupla (8 bytes):
| operando | 2º lane | 1º lane |
|---|---|---|
| origem 1 | 4.2 | 2.1 |
| * | * | * |
| origem 2 | 1.0 | 92.4 |
| = | = | = |
| resultado | 4.2 | 194.04 |
Neste conceito, vamos nos concentrar em uma pequena seleção de instruções SIMD.
Elas operam sobre valores de ponto flutuante empacotados usando registradores xmm.
Como regra geral, as instruções SIMD que operam sobre valores de ponto flutuante têm a forma de dois operandos. O nome delas segue uma certa estrutura:
add, mul, etc.).p para indicar que a instrução opera sobre dados empacotados, na mesma posição antes ocupada por um s (para dados escalares).s para precisão simples e d para precisão dupla).O nome empacotado é, portanto, obtido a partir do nome escalar substituindo o s (escalar) no meio do mnemônico por um p (empacotado).
O nome da operação e o sufixo de tamanho permanecem inalterados:
| escalar | empacotado | significado |
|---|---|---|
addss |
addps |
soma, precisão simples (lanes de 32 bits) |
addsd |
addpd |
soma, precisão dupla (lanes de 64 bits) |
mulss |
mulps |
multiplicação, precisão simples |
mulsd |
mulpd |
multiplicação, precisão dupla |
As instruções SIMD aritméticas seguem a regra acima. Alguns exemplos:
addps xmm0, xmm1 ; xmm0 = xmm0 + xmm1 (each 32-bit float at the same position)
subpd xmm2, xmm3 ; xmm2 = xmm2 - xmm3 (each 64-bit float at the same position)
mulps xmm4, xmm5 ; xmm4 = xmm4 * xmm5 (each 32-bit float at the same position)
divpd xmm6, xmm7 ; xmm6 = xmm6 / xmm7 (each 64-bit float at the same position)
Também existem instruções SIMD para converter entre valores de ponto flutuante empacotados de precisão simples e de precisão dupla.
Elas seguem a mesma regra de nomenclatura, com cada s de escalar substituído por um p de empacotado:
| escalar | empacotado | significado |
|---|---|---|
cvtss2sd |
cvtps2pd |
converte precisão simples em precisão dupla |
cvtsd2ss |
cvtpd2ps |
converte precisão dupla em precisão simples |
Essas instruções ilustram o ponto anterior: um lane é uma interpretação, não um limite rígido. Os operandos de origem e de destino têm tamanhos de lane diferentes, então têm contagens de lanes diferentes.
Para cvtps2pd, a origem (ps) tem 4 lanes de precisão simples.
O destino (pd) tem 2 lanes de precisão dupla.
Apenas os 2 lanes inferiores da origem são lidos.
Os 2 superiores são ignorados:
| operando | 4º lane | 3º lane | 2º lane | 1º lane |
|---|---|---|---|---|
origem como ps
|
ignorado | ignorado | 2.5 | 1.5 |
destino como pd
|
2.5 | 1.5 |
Para cvtpd2ps, a origem (pd) tem 2 lanes de precisão dupla.
O destino (ps) tem 4 lanes de precisão simples.
Os dois lanes da origem são convertidos e escritos nos 2 lanes inferiores do destino.
Os 2 lanes superiores do destino são definidos como zero:
| operando | 4º lane | 3º lane | 2º lane | 1º lane |
|---|---|---|---|---|
origem como pd
|
4.0 | 3.0 | ||
destino como ps
|
0 | 0 | 4.0 | 3.0 |
Alguns exemplos:
cvtps2pd xmm0, xmm1 ; xmm0 = xmm1 as 2 doubles (low 2 lanes of xmm1 are read)
cvtpd2ps xmm0, xmm1 ; xmm0 = xmm1 as 2 floats (in low 2 lanes; upper 2 zeroed)
As instruções SIMD acima foram todas mostradas com operandos de registrador para registrador. Elas também podem receber um operando de memória como origem (mas não como destino).
O número de bytes lidos da memória é determinado pela instrução.
Por exemplo, addps lê 16 bytes (4 números de ponto flutuante de precisão simples).
cvtps2pd, em contrapartida, lê apenas 8 bytes (2 números de ponto flutuante de precisão simples), porque é tudo o que ele precisa como entrada.
addps xmm0, [rsi] ; xmm0 = xmm0 + 4 floats at [rsi]
mulpd xmm1, [rsi + 16] ; xmm1 = xmm1 * 2 doubles at [rsi + 16]
cvtps2pd xmm2, [rsi + 8] ; xmm2 = (2 floats at [rsi + 8]) converted to 2 doubles
Quando o operando de memória tem 16 bytes de largura, ele deve estar alinhado em 16 bytes. Um local de memória alinhado é aquele cujo endereço é um múltiplo do tamanho da operação (16 bytes neste caso).
Para declarar memória alinhada, você pode usar a diretiva align na seção .data e .rodata, ou alignb, na seção .bss:
section .rodata
align 16
mem1: dd 4.0, 3.5, 2.0, -1.0
section .data
align 16
mem2: dq 6.0, 2.1
section .bss
alignb 16
mem3: resb 16
section .text
fn:
movaps xmm0, oword [rel mem1] ; loads 4 packed 32-bit floats in xmm0
movapd xmm1, oword [rel mem2] ; loads 2 packed 64-bit floats in xmm1
movaps oword [rel mem3], xmm0 ; stores 4 packed 32-bit floats in mem3
ret
Operandos de memória menores que 16 bytes, como a origem de 8 bytes de cvtps2pd, não têm exigência de alinhamento.
Existem quatro instruções que copiam 128 bits de valores de ponto flutuante empacotados.
Elas operam entre dois registradores xmm, ou entre um registrador xmm e um local de memória:
| instrução | descrição |
|---|---|
movaps |
copia dados empacotados de precisão simples de, ou para, um local de memória alinhado |
movups |
copia dados empacotados de precisão simples de, ou para, um local de memória não alinhado |
movapd |
copia dados empacotados de precisão dupla de, ou para, um local de memória alinhado |
movupd |
copia dados empacotados de precisão dupla de, ou para, um local de memória não alinhado |
Todas essas instruções podem ser usadas para copiar valores entre dois registradores xmm.
Se a operação envolver um local de memória, movaps e movapd exigem que ele esteja alinhado.
movaps e movapd geram uma falha se receberem um endereço desalinhado.
Elas também servem como uma verificação de segurança de que o layout dos seus dados é o que você imagina.
Por outro lado, movups e movupd podem ser usadas com qualquer local de memória, alinhado ou não.
O prefixo de tamanho para um valor de 16 bytes no NASM é oword.
O SIMD tem sido alvo de desenvolvimento ativo no ecossistema x86-64 há décadas. Cada extensão da arquitetura traz novas instruções ou novos usos para instruções antigas.
Em particular, as extensões AVX e AVX2 introduzem algumas mudanças relevantes.
Primeiro, suporte a registradores SIMD de 256 bits, chamados de ymm.
Eles estendem os registradores xmm correspondentes, assim como rax contém eax.
Segundo, elas incluem suporte a variantes de 3 operandos para a maioria das instruções que operam sobre registradores xmm (e ymm).
Isso inclui não apenas instruções SIMD, mas também as que operam sobre valores de ponto flutuante escalares.
Essas variantes seguem a mesma notação da sua equivalente SSE, com um v adicionado antes:
vaddsd xmm0, xmm1, xmm2 ; xmm0 = xmm1 + xmm2 (scalar sum of 64-bit floats)
vmulps xmm1, xmm3, xmm4 ; xmm1 = xmm3 * xmm4 (packed multiplication of 32-bit floats)
vmovupd ymm5, yword [mem] ; this copies 32 bytes from the location at mem to ymm5
Terceiro, o alinhamento exigido dos operandos de memória SIMD é relaxado.
Apenas movimentos explicitamente alinhados, como vmovaps, exigem esse alinhamento.
Nesta trilha, nos limitamos a instruções e registradores até as extensões SSE anteriores. Isso é o suficiente para ensinar os fundamentos e garante compatibilidade com uma ampla variedade de CPUs.
No entanto, o test-runner tem suporte completo a AVX e AVX2, e até mesmo às extensões mais novas que fazem parte do AVX-512. Fique à vontade para usá-las.
É uma boa prática ser consistente no uso das variantes SIMD. Procure não misturar as variantes SSE e AVX para as mesmas instruções.
Se a sua base de código misturar essas variantes, é uma boa prática usar vzeroupper depois de terminar o trabalho com AVX e antes de usar SSE.
Isso quebra cadeias de dependência e evita uma pausa custosa quando a CPU alterna entre os estados AVX e SSE.
Você está escrevendo código para o sistema de dados do chão de fábrica de uma fábrica de peças automotivas. Componentes de motor e chassi passam por uma série de estações de usinagem. Cada estação produz medições que precisam ser combinadas, comparadas, calibradas ou normalizadas antes que a próxima etapa rode. As funções que você implementa aqui são uma para cada estação.
As computações neste exercício devem ser feitas usando instruções SIMD, não operações escalares.
O chão de fábrica opera duas linhas de montagem paralelas. No fim de cada turno, as duas linhas reportam seus rendimentos para 4 caixas de peças como números de precisão simples. O painel do supervisor mostra o rendimento combinado por caixa, então os dois relatórios são somados caixa a caixa. Os três buffers são gerenciados pelo sistema de relatórios e estão sempre alinhados em 16 bytes.
Defina uma função sum_yields que, dados os rendimentos por caixa de line_a e line_b, escreve a soma caixa a caixa em result.
Os argumentos, em ordem, são:
line_a: endereço de memória alinhado em 16 bytes que armazena 4 números de ponto flutuante de 32 bitsline_b: endereço de memória alinhado em 16 bytes que armazena 4 números de ponto flutuante de 32 bitsresult: endereço de memória alinhado em 16 bytes onde a função escreverá 4 números de ponto flutuante de 32 bitsEsta função não tem valor de retorno; em vez disso, o resultado é armazenado no buffer passado (result):
line_a = {10.0, 20.0, 30.0, 40.0};
line_b = { 5.0, 15.0, 25.0, 35.0};
sum_yields(line_a, line_b, result);
// => result == {15.0, 35.0, 55.0, 75.0};
Entre as operações, uma estação de amostragem mede peças usinadas e as compara com as dimensões alvo. Os valores medidos chegam em fluxo de um medidor de precisão para um buffer que está sempre alinhado em 16 bytes. Os valores alvo vêm do pacote de ordem de serviço, que não tem garantia de alinhamento. O desvio é multiplicado por um fator de sensibilidade por eixo antes de ser passado para o verificador de tolerância a jusante. Para esta etapa, o verificador a jusante lê o desvio diretamente de um registrador SIMD, e não da memória.
Defina uma função scaled_deviation que, dados measured e target e uma sensitivity, escreve o desvio escalado em result.
Os argumentos, em ordem, são:
measured: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bitstarget: endereço de memória que armazena 2 números de ponto flutuante de 64 bits (alinhamento não garantido)sensitivity: endereço de memória que armazena 2 números de ponto flutuante de 64 bits (alinhamento não garantido)result: endereço de memória para um buffer onde a função escreverá 2 números de ponto flutuante de 64 bits (alinhamento não garantido)O desvio escalado é (measured - target) * sensitivity, lane a lane.
Esta função não tem valor de retorno; em vez disso, o resultado é armazenado no buffer passado (result) como um par de números de ponto flutuante de 64 bits.
measured = {10.5, 20.5};
target = {10.0, 20.0};
sensitivity = { 2.0, 4.0};
scaled_deviation(measured, target, sensitivity, result);
// => result == {1.0, 2.0};
Antes que as peças entrem na baía de inspeção final, as leituras dimensionais de um lote de 4 peças são reescaladas para precisão dupla e corrigidas quanto à deriva de temperatura conhecida.
As 4 leituras brutas chegam como números de precisão simples.
Para cada linha de 2 leituras, a calibração calcula reference / (raw - offset) e depois multiplica esse resultado por um fator de conversão constante de 0.5.
Repare que a referência e o offset são os mesmos para as duas linhas do lote. O fator de conversão é o mesmo em toda calibração, então, em vez de recebê-lo como argumento, você deve declará-lo como uma constante de 16 bytes empacotada na memória.
Defina uma função calibrate_batch que, dados um lote raw, uma reference, um offset e um buffer result, escreve os valores calibrados em result.
Os argumentos, em ordem, são:
raw: endereço de memória que armazena 4 números de ponto flutuante de 32 bits (alinhamento não garantido)reference: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bitsoffset: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bitsresult: endereço de memória alinhado em 16 bytes onde a função escreverá 4 números de ponto flutuante de 64 bits (32 bytes no total)A função deve, para cada uma das duas linhas do lote:
offset do par convertido.reference pelo resultado.0.5.result.Esta função não tem valor de retorno; em vez disso, o resultado é armazenado no buffer passado (result):
raw = {10.0, 20.0, 25.0, 50.0};
reference = {100.0, 100.0};
offset = { 5.0, 10.0};
calibrate_batch(raw, reference, offset, result);
// => result == {10.0, 5.0, 2.5, 1.25};
No fim da linha, cada peça tem uma pontuação de qualidade que precisa ser normalizada antes de ser reportada. Cada pontuação é multiplicada pelo seu próprio ganho por peça e depois dividida por uma escala de produção por eixo. O buffer de ganhos contém um ganho por peça.
A escala de produção é a mesma para toda a execução e é passada em um endereço de memória que armazena dois números de ponto flutuante de 64 bits. As pontuações são atualizadas no próprio local: o mesmo buffer que continha as pontuações brutas agora contém as pontuações normalizadas.
Defina uma função normalize_scores que, dados um array scores, um array gains, uma scale e uma contagem n, normaliza as pontuações no próprio local.
Os argumentos, em ordem, são:
scores: endereço de memória alinhado em 16 bytes que armazena n números de ponto flutuante de 64 bits (modificado no próprio local)gains: endereço de memória alinhado em 16 bytes que armazena n números de ponto flutuante de 64 bitsscale: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bitsn: um inteiro positivo e par de 64 bits (mínimo 2), o número de pontuações a normalizarOs dois lanes da escala se aplicam aos dois elementos de cada par.
O primeiro lane escala o primeiro elemento de cada par (lanes em posições pares), o segundo lane escala o segundo (lanes em posições ímpares).
Cada pontuação é multiplicada pelo ganho correspondente e depois dividida pelo lane de escala apropriado: score * gain / scale_lane.
Esta função não tem valor de retorno; em vez disso, o resultado é armazenado no buffer de entrada (scores), na mesma posição da score correspondente que foi normalizada:
n = 4
scores = {10.0, 20.0, 30.0, 40.0};
gains = { 2.0, 1.5, 1.0, 0.5};
scale = { 2.0, 4.0};
normalize_scores(scores, gains, scale, n);
// => scores == {10.0, 7.5, 15.0, 5.0};
Crie sua conta no Exercism para aprender e dominar x86-64 Assembly com 22 conceitos130 exercícios e mentoria humana de verdade, tudo de graça.