Trilhas
/
x86-64 Assembly
x86-64 Assembly
/
Exercícios
/
Linha de produção
Linha de produção

Linha de produção

Exercício de aprendizagem

Introdução

SIMD

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.

Instruções SIMD de ponto flutuante

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:

  1. A operação realizada, com o mesmo nome da sua equivalente não SIMD (também chamada de escalar) (por exemplo, add, mul, etc.).
  2. Um sufixo p para indicar que a instrução opera sobre dados empacotados, na mesma posição antes ocupada por um s (para dados escalares).
  3. Um sufixo para indicar o tamanho de cada lane, seguindo a mesma semântica da sua equivalente não SIMD (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
Aritmética

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)
Conversão entre precisões

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)
Operandos de memória

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.

Carregar e armazenar valores

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.

Extensões SIMD

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.

Note

É 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.

Instruções

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.

Note

As computações neste exercício devem ser feitas usando instruções SIMD, não operações escalares.

1. Combine os rendimentos de duas linhas

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:

  1. line_a: endereço de memória alinhado em 16 bytes que armazena 4 números de ponto flutuante de 32 bits
  2. line_b: endereço de memória alinhado em 16 bytes que armazena 4 números de ponto flutuante de 32 bits
  3. result: endereço de memória alinhado em 16 bytes onde a função escreverá 4 números de ponto flutuante de 32 bits

Esta 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};

2. Calcule o desvio escalado

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:

  1. measured: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bits
  2. target: endereço de memória que armazena 2 números de ponto flutuante de 64 bits (alinhamento não garantido)
  3. sensitivity: endereço de memória que armazena 2 números de ponto flutuante de 64 bits (alinhamento não garantido)
  4. 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};

3. Calibre um lote

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:

  1. raw: endereço de memória que armazena 4 números de ponto flutuante de 32 bits (alinhamento não garantido)
  2. reference: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bits
  3. offset: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bits
  4. result: 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:

  1. Converter os 2 valores brutos de precisão simples da linha para precisão dupla.
  2. Subtrair offset do par convertido.
  3. Dividir reference pelo resultado.
  4. Multiplicar lane a lane cada número de ponto flutuante de 64 bits pelo fator de conversão fixo de 0.5.
  5. Armazenar os 2 números de ponto flutuante de 64 bits nos 16 bytes correspondentes de 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};

4. Normalize as pontuações ao longo de uma linha

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:

  1. 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)
  2. gains: endereço de memória alinhado em 16 bytes que armazena n números de ponto flutuante de 64 bits
  3. scale: endereço de memória alinhado em 16 bytes que armazena 2 números de ponto flutuante de 64 bits
  4. n: um inteiro positivo e par de 64 bits (mínimo 2), o número de pontuações a normalizar

Os 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};
Editar via GitHub O link abre em uma nova janela ou aba
x86-64 Assembly Exercism

Tudo pronto para começar Linha de produção?

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.