Sua atenção é convidada a uma pequena visão geral dos recursos de vetorização de algoritmos no .NET Framework e no .NETCORE. O objetivo do artigo é apresentar essas técnicas para quem não as conhece e mostrar que o .NET não fica muito atrås dos idiomas "reais e compilados" para os nativos
desenvolvimento.
Estou apenas começando a aprender tĂ©cnicas de vetorização. Portanto, se alguĂ©m da comunidade me indicar um cant explĂcito ou oferecer versĂ”es aprimoradas dos algoritmos descritos abaixo, ficarei muito feliz.
Um pouco de histĂłria
No .NET, o SIMD apareceu pela primeira vez em 2015 com o lançamento do .NET Framework 4.6. Em seguida, foram adicionados os tipos Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 e Vector4, o que permitiu a construção de cĂĄlculos vetorizados. Posteriormente, foi adicionado o tipo Vector <T>, o que proporcionava mais oportunidades para algoritmos de vetorização. Mas muitos programadores ainda estavam infelizes porque os tipos acima limitavam o fluxo de pensamentos do programador e nĂŁo permitiam o uso total das instruçÔes SIMD dos processadores modernos. Atualmente, na visualização do .NET Core 3.0, o espaço para nome System.Runtime.Intrinsics apareceu, o que oferece muito mais liberdade na escolha das instruçÔes. Para obter os melhores resultados em velocidade, vocĂȘ precisa usar o RyuJit e construir no x64 ou desabilitar o Prefer 32 bits e construir no AnyCPU. Todos os benchmarks que eu executei em um computador com um processador Intel Core i7-6700 de 3.40GHz (Skylake).
Resuma os elementos da matriz
Decidi começar com o problema clåssico, que geralmente é escrito primeiro quando se trata de vetorização. Essa é a tarefa de encontrar a soma dos elementos da matriz. Escreveremos quatro implementaçÔes desta tarefa, resumiremos os elementos da matriz Array:
Mais Ăłbvio
public int Naive() { int result = 0; foreach (int i in Array) { result += i; } return result; }
Usando LINQ
public long LINQ() => Array.Aggregate<int, long>(0, (current, i) => current + i);
Usando vetores do System.Numerics:
public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; }
Usando o código do espaço System.Runtime.Intrinsics:
public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; }
Lancei uma referĂȘncia nesses quatro mĂ©todos no meu computador e obtive este resultado:
Pode-se observar que as soluçÔes com Vectors e Intrinsics são muito mais råpidas que a solução óbvia e com LINQ. Agora precisamos descobrir o que acontece nesses dois métodos.
Considere o método Vectors em mais detalhes:
Vetores public int Vectors() { int vectorSize = Vector<int>.Count; var accVector = Vector<int>.Zero; int i; var array = Array; for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); accVector = Vector.Add(accVector, v); } int result = Vector.Dot(accVector, Vector<int>.One); for (; i < array.Length; i++) { result += array[i]; } return result; }
- int vectorSize = Vetor <int> .Count; - Ă© quantos nĂșmeros de 4 bytes podemos colocar em um vetor. Se a aceleração de hardware for usada, esse valor mostra quantos nĂșmeros de 4 bytes podem ser colocados em um registro SIMD. De fato, mostra quantos elementos desse tipo vocĂȘ pode realizar operaçÔes em paralelo;
- accVector - um vetor no qual o resultado da função se acumularå;
var v = novo vetor <int> (matriz, i); - os dados sĂŁo carregados em um novo vetor v, da matriz, iniciando no Ăndice i. Os dados exatamente vectorSize serĂŁo carregados. - accVector = Vector.Add (accVector, v); - dois vetores sĂŁo adicionados.
Por exemplo, 8 nĂșmeros sĂŁo armazenados na matriz: {0, 1, 2, 3, 4, 5, 6, 7} e vectorSize == 4 e, em seguida:
Na primeira iteração do loop accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3}, após a adição no accVector serå: {0, 0, 0, 0} + {0, 1, 2 , 3} = {0, 1, 2, 3}.
Na segunda iteração, v = {4, 5, 6, 7} e após a adição accVector = {0, 1, 2, 3} + {4, 5, 6, 7} = {4, 6, 8, 10}. - Resta apenas obter de algum modo a soma de todos os elementos do vetor; para isso, podemos aplicar a multiplicação escalar por um vetor preenchido com unidades: int result = Vector.Dot (accVector, Vector <int> .One);
Acontece: {4, 6, 8, 10} {1, 1, 1, 1} = 4 1 + 6 1 + 8 1 + 10 * 1 = 28. - No final, se necessĂĄrio, sĂŁo adicionados nĂșmeros que nĂŁo se encaixam no Ășltimo vetor.
Se vocĂȘ olhar para o cĂłdigo do mĂ©todo Intrinsics:
IntrĂnseca public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4; var accVector = Vector256<int>.Zero; int i; var array = Array; fixed (int* ptr = array) { for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = Avx2.LoadVector256(ptr + i); accVector = Avx2.Add(accVector, v); } } int result = 0; var temp = stackalloc int[vectorSize]; Avx2.Store(temp, accVector); for (int j = 0; j < vectorSize; j++) { result += temp[j]; } for (; i < array.Length; i++) { result += array[i]; } return result; }
VocĂȘ pode ver que Ă© muito semelhante ao Vectors, com algumas exceçÔes:
Compare duas matrizes
à necessårio comparar duas matrizes de bytes. Na verdade, esse é o problema pelo qual comecei a aprender o SIMD no .NET. Novamente, escreveremos vårios métodos para o benchmark, compararemos duas matrizes: ArrayA e ArrayB:
A solução mais óbvia:
public bool Naive() { for (int i = 0; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; }
Solução via LINQ:
public bool LINQ() => ArrayA.SequenceEqual(ArrayB);
Solução via função MemCmp:
[DllImport("msvcrt.dll", CallingConvention = CallingConvention.Cdecl)] static extern int memcmp(byte[] b1, byte[] b2, long count); public bool MemCmp() => memcmp(ArrayA, ArrayB, ArrayA.Length) == 0;
Usando vetores do System.Numerics:
public bool Vectors() { int vectorSize = Vector<byte>.Count; int i = 0; for (; i < ArrayA.Length - vectorSize; i += vectorSize) { var va = new Vector<byte>(ArrayA, i); var vb = new Vector<byte>(ArrayB, i); if (!Vector.EqualsAll(va, vb)) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; }
Usando Intrinsics:
public unsafe bool Intrinsics() { int vectorSize = 256 / 8; int i = 0; const int equalsMask = unchecked((int) (0b1111_1111_1111_1111_1111_1111_1111_1111)); fixed (byte* ptrA = ArrayA) fixed (byte* ptrB = ArrayB) { for (; i < ArrayA.Length - vectorSize; i += vectorSize) { var va = Avx2.LoadVector256(ptrA + i); var vb = Avx2.LoadVector256(ptrB + i); var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } } for (; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } }
O resultado da referĂȘncia no meu computador:
Todo o cĂłdigo desses mĂ©todos, eu acho, Ă© compreensĂvel, com exceção de duas linhas no Intrinsics:
var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; }
No primeiro, dois vetores sĂŁo comparados quanto Ă igualdade e o resultado Ă© armazenado no vetor areEqual, no qual todos os bits sĂŁo definidos como 1 em um elemento em uma posição especĂfica se os elementos correspondentes em va e vb forem iguais. Acontece que se os vetores dos bytes va e vb forem completamente iguais, em areEquals todos os elementos deverĂŁo ser 255 (11111111b). Porque O Avx2.CompareEqual Ă© um invĂłlucro sobre _mm256_cmpeq_epi8; no site da Intel, vocĂȘ pode ver o pseudo-cĂłdigo desta operação:
O mĂ©todo MoveMask de um vetor cria um nĂșmero de 32 bits. Os valores de bits sĂŁo os bits altos de cada um dos 32 elementos de byte Ășnico do vetor. O pseudocĂłdigo pode ser encontrado aqui .
Portanto, se alguns bytes em va e vb não coincidirem, em areEqual os bytes correspondentes serão 0, portanto os bits mais significativos desses bytes também serão 0, o que significa que os bits correspondentes na resposta Avx2.MoveMask também serão 0 e a comparação com equalsMask não funcionarå.
Vamos analisar um pequeno exemplo, assumindo que o comprimento do vetor seja 8 bytes (para escrever era menor):
- Seja va = {100, 10, 20, 30, 100, 40, 50, 100} e vb = {100, 20, 10, 30, 100, 40, 80, 90};
- EntĂŁo areEqual serĂĄ igual a {255, 0, 0, 255, 255, 255, 0, 0};
- O método MoveMask retornarå 10011100b, que precisarå ser comparado com a måscara 11111111b, porque Como essas måscaras são desiguais, os vetores va e vb não são iguais.
Contar quantas vezes um elemento ocorre na coleção
Ăs vezes Ă© necessĂĄrio calcular quantas vezes um elemento especĂfico Ă© encontrado em uma coleção, por exemplo, ints, esse algoritmo tambĂ©m pode ser acelerado. Vamos escrever alguns mĂ©todos para comparação, procuraremos o elemento Item na matriz Array.
O mais Ăłbvio:
public int Naive() { int result = 0; foreach (int i in Array) { if (i == Item) { result++; } } return result; }
usando LINQ:
public int LINQ() => Array.Count(i => i == Item);
usando vetores de System.Numerics.Vectors:
public int Vectors() { var mask = new Vector<int>(Item); int vectorSize = Vector<int>.Count; var accResult = new Vector<int>(); int i; var array = Array; for (i = 0; i < array.Length - vectorSize; i += vectorSize) { var v = new Vector<int>(array, i); var areEqual = Vector.Equals(v, mask); accResult = Vector.Subtract(accResult, areEqual); } int result = 0; for (; i < array.Length; i++) { if (array[i] == Item) { result++; } } result += Vector.Dot(accResult, Vector<int>.One); return result; }
Usando Intrinsics:
public unsafe int Intrinsics() { int vectorSize = 256 / 8 / 4;
O resultado da referĂȘncia no meu computador:
Os mĂ©todos Vectors e Intrinsics sĂŁo completamente idĂȘnticos na lĂłgica, as diferenças estĂŁo apenas na implementação de operaçÔes especĂficas. A ideia como um todo Ă©:
- um vetor de mĂĄscara Ă© criado no qual o nĂșmero necessĂĄrio Ă© armazenado em cada elemento;
- A parte da matriz Ă© carregada no vetor ve comparada com a mĂĄscara; todos os bits serĂŁo definidos em elementos iguais em areEqual, porque areEqual Ă© um vetor de ints; se vocĂȘ definir todos os bits de um elemento, obteremos -1 nesse elemento ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
- o vetor areEqual Ă© subtraĂdo de accVector e, em seguida, o accVector serĂĄ a soma de quantas vezes o elemento do item ocorreu em todos os v vetores de cada posição (menos min dĂĄ um sinal de mais).
Todo o cĂłdigo do artigo pode ser encontrado no GitHub
ConclusĂŁo
Examinei apenas uma parte muito pequena das possibilidades que o .NET oferece para a vetorização de cĂĄlculos. Para obter uma lista completa e atualizada de intrĂnsecas disponĂveis no .NETCORE em x86, vocĂȘ pode consultar o cĂłdigo-fonte . Ă conveniente que nos arquivos C # no resumo de cada intrĂnseca exista seu prĂłprio nome no mundo C, o que simplifica o entendimento do objetivo desse intrĂnseco e a tradução dos algoritmos C ++ / C existentes para o .NET. A documentação do System.Numerics.Vector estĂĄ disponĂvel em msdn .
Na minha opiniĂŁo, o .NET tem uma grande vantagem sobre o C ++, porque A compilação JIT jĂĄ ocorre na mĂĄquina do cliente, o compilador pode otimizar o cĂłdigo para um processador de cliente especĂfico, fornecendo desempenho mĂĄximo. Ao mesmo tempo, um programador para escrever cĂłdigo rĂĄpido pode permanecer na estrutura de um idioma e tecnologia.
UPD (15/09/2019):
Houve um batente nos benchmarksNos benchmarks, usei o IterationSetup, que, como se viu, pode afetar bastante o desempenho dos benchmarks que funcionam em menos de 100ms. Se vocĂȘ o refazer no GlobalSetup, os resultados serĂŁo assim.
Soma dos elementos da matriz:
Comparando duas matrizes:
NĂșmero de ocorrĂȘncias de um elemento em uma matriz: