Considere a seguinte valarray
classe -like:
#include <stdlib.h>
struct va
{
void add1(const va& other);
void add2(const va& other);
size_t* data;
size_t size;
};
void va::add1(const va& other) {
for (size_t i = 0; i < size; ++i) {
data[i] += other.data[i];
}
}
void va::add2(const va& other){
for (size_t i = 0, s = size; i < s; ++i) {
data[i] += other.data[i];
}
}
A add2
função é vetorizada para diferentes compiladores (MSVC, Clang, GCC, ICC), enquanto add1
não é. Veja https://godbolt.org/z/c61qvrrbv
Isso é explicado pelo possível aliasing: os compiladores não podem provar que um dos elementos apontados por data
não é o size
próprio.
No entanto, também há sobreposição potencial de elementos apontados por data
e other.data
. Para o MSVC, existe o potencial de aliasing desses elementos e ponteiros, pois não tira proveito da Strict Aliasing Rule. Isso se aplica a ambos add1
e add2
.
Os compiladores estão realizando verificações para todos os possíveis aliasing que suspeitam e executam operações vetorizadas para arquivos add2
.
Por que eles não estão adicionando mais verificações e tendo essa otimização para add1
?
Parece que os compiladores não conseguem perceber (e não adicionam código para verificar) que
data[i]
nunca aponta parathis->size
. Isso tornaria a contagem de viagens de loop não calculável antes da primeira iteração, algo que nenhum compilador convencional, exceto o ICC, pode manipular.Espero que os compiladores possam aprender a verificar possíveis sobreposições antes de aplicar sua lógica de vetorização, como
(.data > this) || (.data+.size) < this
, espero que haja uma maneira eficiente de fazer isso. Eles já inventaram código para verificar a sobreposição entre dois arrays emadd2
.(Quanto mais código de verificação for necessário na inicialização, mais lucrativa será a vetorização para se pagar; os elementos escalares de 64 bits não são tão ruins com a linha de base x86-64 em comparação com vetores de 128 bits, especialmente quando o compilador não sei pelo PGO que o tamanho geralmente não é pequeno e que o loop é quente. Tentei
gcc -march=icelake-client
não-march=znver4
apenas habilitar o AVX2, mas também definir heurísticas de ajuste para CPUs com taxa de transferência de vetor muito boa e largura de banda de cache/memória. Mas ainda sem alegria, então isso é possível aliasing é provavelmente um obstáculo completo, não uma decisão heurística.)Asm evidências de que os compiladores estão preocupados com o aliasing
this->size
Observe como a ramificação do loop do GCC é
cmp rax, QWORD PTR [rdi+8]
, onderax
detémi
e[rdi+8]
éthis->size
(convenção de chamada x86-64 SysV), portanto, é recarregada a cada iteração. Se compilarmos com-O3 -fno-tree-vectorize
, veremos que o GCCadd2
carrega o tamanho em um registro antes do loop, comparando com o que está dentro do loop. ou seja, içar a carga. O fato de o GCC não fazer issoadd1
é um sinal bastante claro de que ele pensa quedata[i] += ...
pode modificar arquivosthis->size
.Além disso, alterar o tipo para
unsigned *data;
ou qualquer coisa que não possa apontar parasize_t
permite que GCC, Clang e ICC sejam vetorizados automaticamente deadd1
. O uso-fno-strict-aliasing
desativa a vetorização novamente. (E torna os compiladores mais "paranóicos", recarregandothis->data
eother.data
a cada iteração, como o MSVC sempre fazia. Também quebrando a vetorizaçãoadd2
desses compiladores.)Alterar o tipo de ponteiro não ajuda o MSVC porque não faz análise de alias baseada em tipo; sempre age como
gcc -fno-strict-aliasing
. O MSVCadd2
já verifica mais do que apenas a sobreposição dos arrays apontados, eu acho; provavelmente algum desse cmp/jcc extra está verificando quethis->data[i] += ...
não mudará o.data
ponteiro emthis
ouother
.std::vector
não temsize_t
membros, geralmente evitando issostd::vector<size_t>
não teria esse problema (pelo menos em compiladores não-MSVC) porque o aliasing baseado em tipo sabe quesize_t *
não pode apontar para outro ponteiro.std::vector
normalmente armazena três ponteiros:.begin
,.end
, eend_of_capacity
, portanto, as informações de tamanho sãoend-begin
, não um membro que armazena o tamanho diretamente.Para iterar sobre uma matriz, normalmente é pelo menos tão eficiente incrementar um ponteiro quanto
for (... ; ptr < endp ; ptr++) *ptr
para não usar modos de endereçamento indexados. Presumivelmente, é por isso questd::vector
normalmente é definido dessa maneira, em vez de um ponteiro e doissize_t
membros.Algumas máquinas RISC nem sequer têm modos de endereçamento indexados de dois registradores. Para iterar duas matrizes, algumas CPUs se sairão melhor com menos instruções, apenas incrementando um índice em vez de dois incrementos de ponteiro, mas isso depende da microarquitetura, por exemplo, algumas CPUs x86 não laminam
add reg, [reg + reg]
em 2 uops no back-end, não o mantendo micro -fundido, especialmente com instruções AVX de 3 operandos.Uma maneira eficiente (no asm) de fazer um loop em duas matrizes em CPUs com endereçamento indexado é endereçar uma matriz em relação à outra. É UB fazer isso em C ++ e ofuscaria seu código, então é algo que os compiladores devem fazer por você.
sub rsi, rdi
fora do loop (subtrair ponteiros), então o corpo do loop pode sermov eax, [rsi + rdi]
(segundo array = diferença + primeiro) /add [rdi], eax
(primeiro array) /add rdi, 8
(incrementar o ponteiro que também é o índice para o outro array).O MSVC realmente fará essa otimização que outros compiladores ainda não pegaram. ( Godbolt )
Infelizmente, o MSVC retrocedeu e está usando o modo de endereçamento de dois registros como o operando de fonte de memória para
vpaddq
. Ele será deslaminado em questão/renomeado para o ROB na família Intel Sandybridge, incluindo pelo menos Skylake, provavelmente um pouco mais tarde. Masvpaddd ymm1, ymm1, [rdx]
seria 1 up. A carga puravmovdqu
seria sempre 1 up mesmo com um modo de endereçamento indexado.As lojas indexadas também não são ideais (o endereço da loja uop não pode ser executado na porta 7 em Haswell / Skylake), e o MSVC evita isso. Mas ele poderia obter o melhor dos dois mundos fazendo a carga pura com
b[]
um modo de endereçamento indexado e, em seguida, fonte de memóriavpadd
+ armazenamento com o modo de endereçamento simples, como[rdx+32]
.Portanto, o MSVC economizou algum tamanho de código e está obtendo algum benefício na taxa de transferência de back-end, precisando de apenas um incremento de sobrecarga de loop e na contenção de porta AGU, para que possa ser executado próximo a 1 vetor por clock com acessos de cache L1d para permitir fazer 2 cargas + 1 armazenar a cada ciclo. (O manual de otimização da Intel sugere que o Skylake não pode sustentar totalmente isso para carregamento/armazenamento de 32 bytes, por algum motivo desconhecido.)
Com um modo de endereçamento indexado para a loja, como uso de GCC e clang, ele só poderia ser executado em 1 vetor por 1,5 ciclos em Haswell / Skylake. (Ice Lake tem duas AGUs de carga e duas AGUs de armazenamento separadas, evitando esse gargalo, mas não sei se a deslaminação dos modos de endereçamento indexado ainda é uma coisa em Ice Lake ou Alder Lake.)
IDK o que há com o MSVC preferindo
lea
incrementar um ponteiro. Ou por preferirsub ecx/jne
em vez de comparar com um ponteiro finallea
antes do loop em vez demov
. Então o fim do loop poderia sercmp rax, r8
/jne
ou algo assim, que se fundiria em um único uop na AMD, não apenas na Intel.