Dada a seguinte função
void foo(float* result, int size, float y, float delta) {
for (int t = 0; t < size; ++t) {
result[t] = y + delta * t;
}
}
O Clang -O2
gera o seguinte assembly x86-64 :
.LCPI0_0:
.long 0
.long 1
.long 2
.long 3
.LCPI0_1:
.long 4
.long 4
.long 4
.long 4
.LCPI0_2:
.long 65535
.long 65535
.long 65535
.long 65535
.LCPI0_3:
.long 1258291200
.long 1258291200
.long 1258291200
.long 1258291200
.LCPI0_4:
.long 1392508928
.long 1392508928
.long 1392508928
.long 1392508928
.LCPI0_5:
.long 0x53000080
.long 0x53000080
.long 0x53000080
.long 0x53000080
.LCPI0_6:
.long 8
.long 8
.long 8
.long 8
foo(float*, int, float, float):
test esi, esi
jle .LBB0_7
mov eax, esi
cmp esi, 7
ja .LBB0_3
xor ecx, ecx
jmp .LBB0_6
.LBB0_3:
mov ecx, eax
and ecx, 2147483640
movaps xmm2, xmm1
shufps xmm2, xmm1, 0
movaps xmm3, xmm0
shufps xmm3, xmm0, 0
mov edx, eax
shr edx, 3
and edx, 268435455
shl rdx, 5
movdqa xmm4, xmmword ptr [rip + .LCPI0_0]
xor esi, esi
movdqa xmm5, xmmword ptr [rip + .LCPI0_1]
movdqa xmm6, xmmword ptr [rip + .LCPI0_2]
movdqa xmm7, xmmword ptr [rip + .LCPI0_3]
movdqa xmm8, xmmword ptr [rip + .LCPI0_4]
movaps xmm9, xmmword ptr [rip + .LCPI0_5]
movdqa xmm10, xmmword ptr [rip + .LCPI0_6]
.LBB0_4:
movdqa xmm11, xmm4
paddd xmm11, xmm5
movdqa xmm12, xmm4
pand xmm12, xmm6
por xmm12, xmm7
movdqa xmm13, xmm4
psrld xmm13, 16
por xmm13, xmm8
subps xmm13, xmm9
addps xmm13, xmm12
movdqa xmm12, xmm11
pand xmm12, xmm6
por xmm12, xmm7
psrld xmm11, 16
por xmm11, xmm8
subps xmm11, xmm9
addps xmm11, xmm12
mulps xmm13, xmm2
addps xmm13, xmm3
mulps xmm11, xmm2
addps xmm11, xmm3
movups xmmword ptr [rdi + rsi], xmm13
movups xmmword ptr [rdi + rsi + 16], xmm11
paddd xmm4, xmm10
add rsi, 32
cmp rdx, rsi
jne .LBB0_4
cmp ecx, eax
je .LBB0_7
.LBB0_6:
xorps xmm2, xmm2
cvtsi2ss xmm2, ecx
mulss xmm2, xmm1
addss xmm2, xmm0
movss dword ptr [rdi + 4*rcx], xmm2
inc rcx
cmp rax, rcx
jne .LBB0_6
.LBB0_7:
ret
Estou tentando entender o que está acontecendo aqui. Parece que .LBB0_4
é um loop que cobre 8 iterações do loop original para cada iteração (há 2 mulps
instruções e cada instrução cobre 4 float
s e rsi
é incrementada em 32). O código no final provavelmente está lá para cobrir o caso em que size
não é divisível por 8. O que estou tendo problemas é o resto do código. O que todas essas outras instruções dentro do .LBB0_4
loop e as constantes no início estão fazendo? Existe uma ferramenta ou um argumento do compilador que pode me ajudar a entender o resultado da vetorização SIMD? Talvez algo que transforme isso de volta em C++ com intrínsecos SIMD?
Também se eu mudar o código para isso
void foo(float* result, int size, float y, float delta) {
for (int t = 0; t < size; ++t) {
result[t] = y;
y += delta;
}
}
O Clang gera uma montagem muito menor e faz um loop em 16 valores de uma só vez .
Edição: Acabei de perceber que esta versão não é vetorizada e, portanto, é menor e provavelmente mais lenta.
Qual é a maneira mais rápida de escrever este código?
Como @user555045 apontou, esta é uma regressão no Clang 19. O Clang 18 e versões anteriores autovetorizam da maneira óbvia, com o loop principal usando
cvtdq2ps
para converter 4int32_t
em 4float
.Se observarmos a saída do Clang 19 para outros ISAs SIMD, como AArch64 e AVX-512 ( Godbolt ), em ambos os casos ele é usado
unsigned
parafloat
conversões, como AArch64ucvtf
ou AVX-512vcvtudq2ps
.O lance do bithack é como o clang vetoriza o u32 para float para ISAs como SSE2 e AVX2, que fornecem apenas int assinados de/para float.
Então, ele deu um tiro no próprio pé (para AVX2 e versões anteriores) ao provar que ele
int t
sempre foi não negativo e substituí-lo por um temporário não assinado, esquecendo que ele também pode funcionar como assinado.Este é um bug que você deve relatar em https://github.com/llvm/llvm-project/issues/. Sinta-se à vontade para citar e/ou linkar para esta resposta. Sua fonte C++ é um bom caso de teste MCVE para um relatório de bug. Não vejo uma duplicata existente pesquisando
vectorization unsigned float
e coisas semelhantes.O loop executaria zero iterações se
size
fosse assinado negativo, e é uma<
comparação, então ele sempre sai do loop sem encontrar o estouro de sinal UB. Que é UB de qualquer forma, então o compilador pode simplesmente assumir que isso não acontece mesmo com umat <= size
condição ou algo assim. É isso que permite promoverint
contadores de loop para largura de ponteiro para que eles possam ser usados como índices de array sem refazer a extensão de sinal toda vez.Veja Por que esse código é executado mais lentamente após multiplicações de redução de força para adições transportadas por loop? - evitar a conversão e multiplicação (ou FMA) a cada iteração pode ser uma vitória, mas somente se você desenrolar o suficiente para esconder a latência de FP com múltiplos acumuladores de vetor. Como
float y[UNROLL] = ...;
y[0] += UNROLL * delta;
y[1] += UNROLL * delta;
... etc.Com desenrolamento suficiente para que o compilador use 6, 8 ou 10 vetores para elementos de
y[]
, isso é apenas o suficiente para o produto latência x throughput devaddps
no x86 moderno, tipicamente latência de 3 a 4 ciclos com throughput de 2/clock, então 6 a 8 em voo ao mesmo tempo. Alguma folga permite agendamento imperfeito.No Ice Lake ou Intel posterior, e talvez Zen 3 ou AMD posterior, isso poderia atingir 2x 256 bits
vaddps
e 2x 256 bits de armazenamento vetorial por clock. (Skylake e versões anteriores só podem fazer um armazenamento por clock. https://uops.info/ . O Q&A vinculado era sobre um polinômio de segunda ordem, então levou 2 adições por armazenamento com o método das diferenças. Então meu Skylake foi capaz de fazer 2 adições e 1 armazenamento por clock com meu código. O Ice Lake teria largura de banda de armazenamento de sobra com ele. Claro, assumindo que o destino estava ativo no cache L1d, caso contrário, você provavelmente só tem um gargalo de largura de banda de memória/cache, mesmo para L2.)Em núcleos Intel E (SIMD de 128 bits de largura), a taxa de transferência de adição para armazenamento também é de 1:1, então, novamente, você vai querer a versão não processada que apenas faz adições de SIMD FP.
Adicionar, converter e FMA inteiros são apenas 3 uops ALU de vetor SIMD por clock, então poderia realmente rodar em 1 resultado de vetor por clock, o que é suficiente para acompanhar o rendimento do armazenamento no Skylake sem redução de força para apenas adicionar. (Se você compilar com,
-march=x86-64-v3
é claro, para permitir FMA). Mas então o front-end é um gargalo, já que tem apenas 4 uops de largura no SKL e anteriores. Então, a sobrecarga do loop (pelo menos um add ou sub/jcc com fusão de macro, provavelmente também um incremento de ponteiro) consome o rendimento e não deixa o exec fora de ordem avançar.