Single instruction, multiple data (SIMD) é uma forma de paralelismo ao nível do hardware. A mesma computação é realizada pela CPU sobre vários elementos ao mesmo tempo.
Considera, por exemplo, a soma elemento a elemento de dois arrays de quatro números de vírgula flutuante de 32 bits.
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 dá quatro vezes mais trabalho por instrução.
Em assembly x86-64, as instruções SIMD são normalmente executadas com registos xmm.
Algumas dessas instruções operam sobre inteiros.
Outras operam sobre números de vírgula flutuante.
Esses inteiros ou números de vírgula flutuante chamam-se empacotados. Cada um ocupa um espaço, a que se chama lane, dentro do mesmo registo. As instruções SIMD operam tipicamente em paralelo sobre valores que estão na mesma posição de lane em dois operandos de origem. O resultado de cada operação é guardado no lane que ocupa a mesma posição no 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. Consoante a 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 vírgula flutuante empacotados de precisão simples (4 bytes).
Para esta instrução, cada bloco de 4 bytes de um operando é um lane diferente.
Num 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.
Isto porque esta instrução realiza uma 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 focar-nos numa pequena seleção de instruções SIMD.
Estas operam sobre valores de vírgula flutuante empacotados, usando registos xmm.
Como regra geral, as instruções SIMD que operam sobre valores de vírgula flutuante assumem uma forma com 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 que antes era ocupada por um s (para dados escalares).s para precisão simples e d para precisão dupla).O nome empacotado obtém-se, por isso, a partir do nome escalar, substituindo o s (escalar) no meio da mnemónica por um p (empacotado).
O nome da operação e o sufixo de tamanho mantêm-se inalterados:
| escalar | empacotado | significado |
|---|---|---|
addss |
addps |
somar, precisão simples (lanes de 32 bits) |
addsd |
addpd |
somar, precisão dupla (lanes de 64 bits) |
mulss |
mulps |
multiplicar, precisão simples |
mulsd |
mulpd |
multiplicar, 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)
Existem também instruções SIMD para converter entre valores de vírgula flutuante empacotados de precisão simples e de precisão dupla.
Seguem a mesma regra de nomenclatura, com cada s de escalar substituído por um p de empacotado:
| escalar | empacotado | significado |
|---|---|---|
cvtss2sd |
cvtps2pd |
converter de precisão simples para precisão dupla |
cvtsd2ss |
cvtpd2ps |
converter de precisão dupla para precisão simples |
Estas instruções ilustram o que foi dito antes: um lane é uma interpretação, não um limite rígido. Os operandos de origem e de destino têm tamanhos de lane diferentes, por isso têm um número diferente de lanes.
Em 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 |
Em 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 postos a 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 apresentadas com operandos de registo para registo. Também podem ter 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 vírgula flutuante de precisão simples).
cvtps2pd, em contrapartida, lê apenas 8 bytes (2 números de vírgula flutuante de precisão simples), porque é só isso que precisa de ler.
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, tem de estar alinhado a 16 bytes. Uma posição de memória alinhada é aquela cujo endereço é um múltiplo do tamanho da operação (16 bytes, neste caso).
Para declarar memória alinhada, podes usar a diretiva align na section .data e .rodata, ou alignb, na section .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
Os operandos de memória com menos de 16 bytes, como a origem de 8 bytes de cvtps2pd, não têm qualquer requisito de alinhamento.
Há quatro instruções que copiam 128 bits de valores de vírgula flutuante empacotados.
Operam entre dois registos xmm, ou entre um registo xmm e uma posição de memória:
| instrução | descrição |
|---|---|
movaps |
copia dados empacotados de precisão simples de, ou para, uma posição de memória alinhada |
movups |
copia dados empacotados de precisão simples de, ou para, uma posição de memória não alinhada |
movapd |
copia dados empacotados de precisão dupla de, ou para, uma posição de memória alinhada |
movupd |
copia dados empacotados de precisão dupla de, ou para, uma posição de memória não alinhada |
Todas estas instruções podem ser usadas para copiar valores entre dois registos xmm.
Se a operação envolver uma posição de memória, movaps e movapd exigem que esta esteja alinhada.
movaps e movapd provocam uma falha se receberem um endereço desalinhado.
Servem também de verificação de segurança de que a disposição dos teus dados é aquela que pensas que é.
Por outro lado, movups e movupd podem ser usadas com qualquer posição de memória, alinhada ou não.
O prefixo de tamanho para um valor de 16 bytes em NASM é oword.
O SIMD tem sido alvo de desenvolvimento ativo no ecossistema x86-64 há décadas. Cada extensão à arquitetura traz novas instruções ou novos usos para instruções antigas.
Em particular, as extensões AVX e AVX2 introduzem algumas alterações relevantes.
Primeiro, o suporte para registos SIMD de 256 bits, chamados ymm.
Estes ampliam os registos xmm correspondentes, tal como rax contém eax.
Segundo, incluem suporte para variantes de 3 operandos na maioria das instruções que operam sobre registos xmm (e ymm).
Isto inclui não só as instruções SIMD, mas também as que operam sobre valores de vírgula flutuante escalares.
Essas variantes seguem a mesma notação da sua equivalente SSE, com um v acrescentado à frente:
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 aos operandos de memória SIMD é mais flexível.
Apenas os movimentos explicitamente alinhados, como vmovaps, exigem este alinhamento.
Neste track, limitámo-nos às instruções e aos registos das extensões SSE anteriores. Isto é suficiente para ensinar os fundamentos e garante compatibilidade com uma grande variedade de CPUs.
No entanto, o test-runner suporta totalmente AVX e AVX2, e até as extensões mais recentes que fazem parte do AVX-512. Podes usá-las à vontade.
É boa prática seres consistente na utilização das variantes SIMD. Tenta não misturar variantes SSE e AVX para as mesmas instruções.
Se a tua base de código misturar essas variantes, é boa prática usar vzeroupper depois de teres terminado o trabalho com AVX e antes de usares SSE.
Isto quebra cadeias de dependências e evita uma paragem dispendiosa quando a CPU alterna entre o estado AVX e o estado SSE.
Estás a escrever código para o sistema de dados no chão de fábrica de uma fábrica de componentes automóveis. Os componentes do motor e do chassis passam por uma série de estações de maquinagem. Cada estação produz medições que têm de ser combinadas, comparadas, calibradas ou normalizadas antes de a etapa seguinte avançar. As funções que implementas aqui são uma para cada estação.
Os cálculos deste exercício devem ser feitos com instruções SIMD, não com operações escalares.
No chão de fábrica funcionam duas linhas de montagem paralelas. No fim de cada turno, as duas linhas comunicam os 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, por isso os dois relatórios são somados caixa a caixa. Os três buffers são geridos pelo sistema de relatórios e estão sempre alinhados a 16 bytes.
Define 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, por ordem, são:
line_a: endereço de memória alinhado a 16 bytes que contém 4 números de vírgula flutuante de 32 bitsline_b: endereço de memória alinhado a 16 bytes que contém 4 números de vírgula flutuante de 32 bitsresult: endereço de memória alinhado a 16 bytes onde a função vai escrever 4 números de vírgula flutuante de 32 bitsEsta função não tem valor devolvido; o resultado é guardado no buffer recebido (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 operações, uma estação de amostragem mede peças maquinadas e compara-as com as dimensões pretendidas. Os valores medidos chegam de um medidor de precisão para um buffer que está sempre alinhado a 16 bytes. Os valores pretendidos vêm do pacote da ordem de trabalho, que não tem garantia de alinhamento. O desvio é multiplicado por um fator de sensibilidade por eixo antes de ser passado ao verificador de tolerâncias a jusante. Nesta etapa, o verificador a jusante lê o desvio diretamente de um registo SIMD, e não da memória.
Define uma função scaled_deviation que, dados measured e target e uma sensitivity, escreve o desvio escalado em result.
Os argumentos, por ordem, são:
measured: endereço de memória alinhado a 16 bytes que contém 2 números de vírgula flutuante de 64 bitstarget: endereço de memória que contém 2 números de vírgula flutuante de 64 bits (sem garantia de alinhamento)sensitivity: endereço de memória que contém 2 números de vírgula flutuante de 64 bits (sem garantia de alinhamento)result: endereço de memória para um buffer onde a função vai escrever 2 números de vírgula flutuante de 64 bits (sem garantia de alinhamento)O desvio escalado é (measured - target) * sensitivity, lane a lane.
Esta função não tem valor devolvido; o resultado é guardado no buffer recebido (result) como um par de números de vírgula 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 de as peças entrarem na baía de inspeção final, as leituras dimensionais de um lote de 4 peças são reescaladas para dupla precisão e corrigidas quanto à deriva de temperatura conhecida.
As 4 leituras em bruto chegam como números de precisão simples.
Para cada linha de 2 leituras, a calibração calcula reference / (raw - offset) e multiplica depois esse resultado por um fator de conversão constante de 0.5.
Repara que a referência e o offset são iguais para as duas linhas do lote. O fator de conversão é o mesmo em todas as calibrações; por isso, em vez de o receberes como argumento, deves declará-lo como uma constante de 16 bytes empacotada na memória.
Define uma função calibrate_batch que, dado um lote raw, uma reference, um offset e um buffer result, escreve os valores calibrados em result.
Os argumentos, por ordem, são:
raw: endereço de memória que contém 4 números de vírgula flutuante de 32 bits (sem garantia de alinhamento)reference: endereço de memória alinhado a 16 bytes que contém 2 números de vírgula flutuante de 64 bitsoffset: endereço de memória alinhado a 16 bytes que contém 2 números de vírgula flutuante de 64 bitsresult: endereço de memória alinhado a 16 bytes onde a função vai escrever 4 números de vírgula flutuante de 64 bits (32 bytes no total)A função deve, para cada uma das duas linhas do lote:
offset ao par convertido.reference pelo resultado.0.5.result.Esta função não tem valor devolvido; o resultado é guardado no buffer recebido (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 tem de ser normalizada antes de ser comunicada. 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 dos ganhos contém um ganho por peça.
A escala de produção é a mesma para toda a execução e é passada num endereço de memória que contém dois números de vírgula flutuante de 64 bits. As pontuações são atualizadas no local: o mesmo buffer que continha as pontuações em bruto passa a conter as pontuações normalizadas.
Define uma função normalize_scores que, dados um array scores, um array gains, uma scale e uma contagem n, normaliza as pontuações no local.
Os argumentos, por ordem, são:
scores: endereço de memória alinhado a 16 bytes que contém n números de vírgula flutuante de 64 bits (modificado no local)gains: endereço de memória alinhado a 16 bytes que contém n números de vírgula flutuante de 64 bitsscale: endereço de memória alinhado a 16 bytes que contém 2 números de vírgula flutuante de 64 bitsn: um número inteiro positivo par de 64 bits (mínimo 2), o número de pontuações a normalizarAs duas lanes da escala aplicam-se aos dois elementos de cada par.
A primeira lane escala o primeiro elemento de cada par (lanes em posições pares); a segunda lane escala o segundo (lanes em posições ímpares).
Cada pontuação é multiplicada pelo ganho correspondente e depois dividida pela lane de escala adequada: score * gain / scale_lane.
Esta função não tem valor devolvido; o resultado é guardado 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};
Inscreve-te no Exercism para aprenderes e dominares x86-64 Assembly com 22 conceitos130 exercícios, e mentoria humana real, tudo grátis.