Uma pequena visão geral do SIMD no .NET / C #

Aqui está uma rápida olhada nos recursos de vetorização de algoritmos no .NET Framework e .NET Core. Este artigo é para aqueles que não sabem nada sobre essas técnicas. Também mostrarei que o .NET não fica para trás das linguagens "reais compiladas" para o desenvolvimento nativo.


Estou apenas começando a aprender técnicas de vetorização. Portanto, aprecio se os membros da comunidade encontrarem erros claros ou sugerirem melhorias nos algoritmos descritos.


Alguma história


O SIMD apareceu no .NET Framework 4.6 em 2015. Foi quando os tipos Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 e Vector4 foram adicionados. Eles permitiram cálculos vetorizados. Em seguida, foi o tipo Vector <T> que deu mais oportunidades para vetorizar algoritmos. No entanto, muitos programadores ainda estavam insatisfeitos, pois esses tipos restringiam o fluxo de idéias dos codificadores e não deixavam usar toda a capacidade das instruções SIMD nos processadores modernos. Agora, no .NET Core 3.0 Preview, temos o espaço para nome System.Runtime.Intrinsics, que oferece muita liberdade na escolha das instruções. Para obter o máximo de velocidade, você precisa usar o RyuJit e recorrer à montagem x64 ou desativar o Prefer 32 bits e escolher AnyCPU assembly. Eu executei todos os benchmarks no computador com CPU Intel Core i7-6700 3.40 GHz (Skylake).


Somando elementos da matriz


Decidi começar com uma tarefa clássica que geralmente vem primeiro se houver vetorização envolvida. Ele trata de encontrar a soma dos elementos da matriz. Vamos escrever quatro implementações desta tarefa para somar os elementos da matriz.


A implementação mais óbvia:


public int Naive() { int result = 0; foreach (int i in Array) { result += i; } return result; } 

Implementação baseada em LINQ:


 public long LINQ() => Array.Aggregate<int, long>(0, (current, i) => current + i); 

A implementação baseada em 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; } 

A implementação baseada no código do espaço para nome 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; } 

Comparei esses quatro métodos no meu computador e obtive os seguintes resultados:


MétodoItemsCountMeanErroStddevRatio
Ingênuo103.531 ns0,0336 ns0,0314 ns1,00
LINQ1076.925 ns0,4166 ns0,3897 ns21,79
Vetores102.750 ns0,0210 ns0,0196 ns0,78
Intrínseca106.513 ns0,0623 ns0,0582 ns1,84
Ingênuo10047.982 ns0,3975 ns0,3524 ns1,00
LINQ100590.414 ns3.8808 ns3.4402 ns12,31
Vetores10010,122 ns0,0747 ns0,0699 ns0,21
Intrínseca10014.277 ns0,0566 ns0,0529 ns0,30
Ingênuo1000569.910 ns2.8297 ns2.6469 ns1,00
LINQ10005.658.570 ns31.7465 ns29.6957 ns9,93
Vetores100079.598 ns0,3498 ns0,3272 ns0,14
Intrínseca100066.970 ns0,3937 ns0,3692 ns0,12
Ingênuo10.0005.637.571 ns37.5050 ns29.2814 ns1,00
LINQ10.00056,498.987 ns294.8776 ns275,8287 ns10,02
Vetores10.000772.900 ns2.6802 ns2.5070 ns0,14
Intrínseca10.000579.152 ns2.8371 ns2,6538 ns0,10
Ingênuo100.00056.352.865 ns230.7916 ns215,8826 ns1,00
LINQ100.000562.610.571 ns3.775,7631 ns3.152,9332 ns9,98
Vetores100.0008.389.647 ns165.9590 ns227.1666 ns0,15
Intrínseca100.0007.261.334 ns89.6468 ns69.9903 ns0,13

É claro que as soluções com Vectors e Intrinsics são muito mais rápidas que as soluções óbvias e baseadas em LINQ. Agora precisamos descobrir o que acontece nesses dois métodos.


Vamos considerar o método Vectors mais de perto:


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; - a quantidade de números de 4 bytes que podemos colocar em um vetor. Se a aceleração de hardware for usada, esse valor mostra quantos números de 4 bytes podemos colocar em um registro SIMD. De fato, mostra quantos elementos desse tipo podem ser manipulados simultaneamente;
  • accVector é um vetor que acumula o resultado da função;
  • var v = novo vetor <int> (matriz, i); - os dados da matriz são carregados em um novo vetor v, iniciando no índice i. O vetor Tamanho dos dados será carregado exatamente;
  • accVector = Vector.Add (accVector, v); - dois vetores são somados.
    Por exemplo, existem 8 números na matriz: {0, 1, 2, 3, 4, 5, 6, 7} e vectorSize == 4.
    Então, durante a iteração do primeiro ciclo accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3} e após a adição o accVector manterá: {0, 0, 0, 0} + {0, 1 , 2, 3} = {0, 1, 2, 3}.
    Durante a 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}.
  • Agora só precisamos obter a soma de todos os elementos do vetor. Para fazer isso, podemos usar a multiplicação escalar por um vetor preenchido por esses: int result = Vector.Dot (accVector, Vector <int> .One);
    Então temos: {4, 6, 8, 10} * {1, 1, 1, 1} = 4 * 1 + 6 * 1 + 8 * 1 + 10 * 1 = 28.
  • Se necessário, os números que não se encaixam no último vetor serão somados no final.

Vamos analisar o código intrínseco:


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

Podemos ver que é como Vetores com uma exceção:


  • vectorSize é especificado por uma constante. Isso ocorre porque esse método usa explicitamente as instruções Avx2 que operam com registros de 256 bits. Um aplicativo real deve incluir uma verificação se um processador atual suporta Avx2. Caso contrário, outro código deve ser chamado. É assim:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero; accVector é declarado como vetor de 256 bits preenchido com zeros.
  • fixo (int * ptr = matriz) - o ponteiro para a matriz é colocado em ptr.
  • A seguir, são as mesmas operações de Vetores: carregamento de dados em um vetor e adição de dois vetores.
  • A soma dos elementos vetoriais usa o seguinte método:
    • crie uma matriz na pilha: var temp = stackalloc int [vectorSize];
    • carrega um vetor nessa matriz: Avx2.Store (temp, accVector);
    • soma elementos da matriz durante o ciclo.
  • Em seguida, os elementos que não se encaixam no último vetor são resumidos.

Comparando duas matrizes


Precisamos comparar duas matrizes de bytes. Foi exatamente essa tarefa que me levou a estudar o SIMD no .NET. Novamente, vamos escrever vários métodos para fazer benchmarking e comparar 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 baseada em LINQ:


 public bool LINQ() => ArrayA.SequenceEqual(ArrayB); 

A solução baseada na 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; 

A solução baseada em 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; } 

Solução baseada em intrínseca:


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

Os resultados da execução do benchmark no meu computador:


MétodoItemsCountMeanErroStddevRatio
Ingênuo10.0007.033,8 ns50.636 ns47.365 ns1,00
LINQ10.00064.841,4 ns289.157 ns270.478 ns9,22
Vetores10.000504,0 ns2,406 ns2,251 ns0,07
Memcmp10.000368,1 ns2.637 ns2.466 ns0,05
Intrínseca10.000283,6 ns1.135 ns1.061 ns0,04
Ingênuo100.00085.214,4 ns903.868 ns845.478 ns1,00
LINQ100.000702.279,4 ns2.846,609 ns2.662.720 ns8.24
Vetores100.0005.179,2 ns45.337 ns42.409 ns0,06
Memcmp100.0004.510,5 ns24.292 ns22.723 ns0,05
Intrínseca100.0002.957,0 ns11.452 ns10.712 ns0,03
Ingênuo1.000.000844.006,1 ns3.552.478 ns3,322.990 ns1,00
LINQ1.000.0006.483.079,3 ns42.641.040 ns39.886,445 ns7.68
Vetores1.000.00054.180,1 ns357.258 ns334.180 ns0,06
Memcmp1.000.00049.480,1 ns515.675 ns457.133 ns0,06
Intrínseca1.000.00036.633,9 ns680.525 ns636.564 ns0,04

Eu acho que o código desses métodos é claro, exceto por duas linhas no Intrinsics:


 var areEqual = Avx2.CompareEqual(va, vb); if (Avx2.MoveMask(areEqual) != equalsMask) { return false; } 

Na primeira linha, dois vetores são comparados em termos de igualdade e o resultado é salvo no vetor areEqual, no qual todos os bits do elemento em uma posição específica são definidos como 1 se os elementos correspondentes em va e vb forem iguais. Portanto, se os vetores de bytes va e vb forem iguais, todos os elementos em areEquals devem ser 255 (11111111b). Como o Avx2.CompareEqual é um invólucro sobre _mm256_cmpeq_epi8, podemos acessar o site da Intel e ver o pseudocódigo desta operação:
O método MoveMask cria um número de 32 bits a partir de um vetor. Os bits superiores de cada elemento de 32 bytes em um vetor são os valores dos bits no resultado MoveMask. O pseudocódigo está disponível aqui .


Portanto, se alguns bytes em va e vb não corresponderem, os bytes correspondentes em areEqual serão 0. Portanto, os bits superiores desses bytes também serão 0. Isso significa que os bits correspondentes na resposta Avx2.MoveMask também serão 0 e areEqual não será igual a equalsMask.


Vejamos um exemplo, assumindo que o comprimento do vetor seja 8 bytes (para escrever menos):


  • Seja va = {100, 10, 20, 30, 100, 40, 50, 100} e vb = {100, 20, 10, 30, 100, 40, 80, 90}.
  • Então areEquals serão {255, 0, 0, 255, 255, 255, 0, 0}.
  • O método MoveMask retornará 10011100b que deve ser comparado à máscara 11111111b. Como essas máscaras não são iguais, os vetores va e vb também não são iguais.

Contando as vezes que um elemento ocorre em uma coleção.


Às vezes, você precisa contar as ocorrências de um elemento específico, por exemplo, números inteiros, em uma coleção. Também podemos acelerar esse algoritmo. Para comparação, vamos escrever vários métodos para pesquisar o elemento Item na matriz.


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; var temp = stackalloc int[vectorSize]; for (int j = 0; j < vectorSize; j++) { temp[j] = Item; } var mask = Avx2.LoadVector256(temp); 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); var areEqual = Avx2.CompareEqual(v, mask); accVector = Avx2.Subtract(accVector, areEqual); } } int result = 0; Avx2.Store(temp, accVector); for(int j = 0; j < vectorSize; j++) { result += temp[j]; } for(; i < array.Length; i++) { if (array[i] == Item) { result++; } } return result; } 

Os resultados da execução do benchmark no meu computador:


MétodoItemsCountMeanErroStddevRatio
Ingênuo108,844 ns0,0772 ns0,0603 ns1,00
LINQ1087,456 ns0,9496 ns0,8883 ns9,89
Vetores103.140 ns0,0406 ns0,0380 ns0,36
Intrínseca1013,813 ns0,0825 ns0,0772 ns1,56
Ingênuo100107.310 ns0,6975 ns0,6183 ns1,00
LINQ100626,285 ns5.7677 ns5.3951 ns5,83
Vetores10011.844 ns0,2113 ns0,1873 ns0,11
Intrínseca10019.616 ns0,1018 ns0,0903 ns0,18
Ingênuo10001.032.466 ns6.3799 ns5.6556 ns1,00
LINQ10006.266,605 ns42.6585 ns39.9028 ns6.07
Vetores100083.417 ns0,5393 ns0,4780 ns0,08
Intrínseca100088.358 ns0,4921 ns0,4603 ns0,09
Ingênuo10.0009.942.503 ns47.9732 ns40.0598 ns1,00
LINQ10.00062.305.598 ns643,8775 ns502.6972 ns6.27
Vetores10.000914.967 ns7.2959 ns6,8246 ns0,09
Intrínseca10.000931.698 ns6.3444 ns5.9346 ns0,09
Ingênuo100.00094.834,804 ns793,8585 ns703.7349 ns1,00
LINQ100.000626.620.968 ns4.696,9221 ns4.393.5038 ns6.61
Vetores100.0009.000.827 ns179.5351 ns192.1005 ns0,09
Intrínseca100.0008.690.771 ns101.7078 ns95.1376 ns0,09
Ingênuo1.000.000959.302.249 ns4.268,2488 ns3.783,6914 ns1,00
LINQ1.000.0006.218.681.888 ns31.321.9277 ns29.298.5506 ns6,48
Vetores1.000.00099.778.488 ns1.975.6001 ns4.252,6877 ns0,10
Intrínseca1.000.00096.449.350 ns1.171.8067 ns978.5116 ns0,10

Os métodos de vetores e intrínsecos coincidem completamente na lógica, mas diferem na implementação de operações específicas. A ideia é a seguinte:


  • criar vetor de máscara no qual um número necessário é armazenado em cada elemento;
  • carregue a parte de uma matriz no vetor v e compare essa parte com uma máscara. Como resultado, todos os bits serão definidos em elementos iguais de areEqual. Como areEqual é uma matriz de números inteiros, se definirmos todos os bits de um elemento, obteremos -1 nesse elemento ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
  • subtrair areEqual vector de accVector. Então, accVector manterá a contagem de quantas vezes o elemento do item ocorreu em todos os v vetores para cada posição (menos por menos é um mais).

Todo o código do artigo está no GitHub .


Conclusão


Descrevi apenas uma pequena parte dos recursos do .NET para vetorização de computação. Para ver a lista atualizada completa de todas as intrínsecas disponíveis no .NET Core em x86, vá para o código-fonte . É conveniente que o resumo de cada intrínseco nos arquivos C # contenha seu nome no mundo C. Isso ajuda a entender o objetivo deste intrínseco e a transferência dos algoritmos C ++ / C existentes para o .NET. A documentação do System.Numerics.Vector está disponível no msdn .


Eu acho que o .NET tem uma grande vantagem sobre o C ++. Como a compilação JIT já ocorre em uma máquina cliente, um compilador pode otimizar o código para um processador de cliente específico, oferecendo desempenho máximo. Ao mesmo tempo, um programador pode permanecer em um idioma e nas mesmas tecnologias para escrever código rápido.

Source: https://habr.com/ru/post/pt467689/


All Articles