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

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:


MétodoItemsCountMediana
IngĂȘnuo1075.12 ns
LINQ101 186,85 ns
Vetores1060,09 ns
IntrĂ­nseca10255,40 ns
IngĂȘnuo100360.56 ns
LINQ1002 719,24 ns
Vetores10060,09 ns
IntrĂ­nseca100345,54 ns
IngĂȘnuo10001 847,88 ns
LINQ100012 033,78 ns
Vetores1000240,38 ns
IntrĂ­nseca1000630,98 ns
IngĂȘnuo10.00018 403,72 ns
LINQ10.000102 489,96 ns
Vetores10.0007 316,42 ns
IntrĂ­nseca10.0003 365,25 ns
IngĂȘnuo100.000176 630,67 ns
LINQ100.000975 998,24 ns
Vetores100.00078 828,03 ns
IntrĂ­nseca100.00041 269,41 ns

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:


  • vectorSize Ă© dado por constante. Isso ocorre porque as instruçÔes Avx2 que operam em registros de 256 bits sĂŁo explicitamente usadas nesse mĂ©todo. Em um aplicativo real, deve-se verificar se o processador Avx2 atual suporta instruçÔes e, se nĂŁo, chamar outro cĂłdigo. Parece algo como isto:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero; accVector Ă© declarado como um vetor de 256 bits preenchido com zeros.
  • fixo (int * ptr = matriz) - um ponteiro para uma matriz Ă© inserido em ptr.
  • Em seguida, as mesmas operaçÔes que em Vetores: carregando dados em um vetor e adicionando dois vetores.
  • Para resumir os elementos do vetor, foi aplicado o seguinte mĂ©todo:
    • uma matriz Ă© criada na pilha: var temp = stackalloc int [vectorSize];
    • o vetor Ă© carregado nessa matriz: Avx2.Store (temp, accVector);
    • em um loop, os elementos da matriz sĂŁo somados.
  • os elementos da matriz que nĂŁo sĂŁo colocados no Ășltimo vetor sĂŁo somados

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:


MétodoItemsCountMediana
IngĂȘnuo10.00066 719,1 ns
LINQ10.00071 211,1 ns
Vetores10.0003 695,8 ns
Memcmp10.000600,9 ns
IntrĂ­nseca10.0001 607,5 ns
IngĂȘnuo100.000588 633,7 ns
LINQ100.000651 191,3 ns
Vetores100.00034 659,1 ns
Memcmp100.0005 513,6 ns
IntrĂ­nseca100.00012.078,9 ns
IngĂȘnuo1.000.0005 637 293,1 ns
LINQ1.000.0006 622 666,0 ns
Vetores1.000.000777 974,2 ns
Memcmp1.000.000361 704,5 ns
IntrĂ­nseca1.000.000434 252,7 ns

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; //var mask = Avx2.SetAllVector256(Item); //var mask = Avx2.SetVector256(Item, Item, Item, Item, Item, Item, Item, Item); 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; } 

O resultado da referĂȘncia no meu computador:


MétodoItemsCountMediana
IngĂȘnuo10002 824,41 ns
LINQ100012 138,95 ns
Vetores1000961,50 ns
IntrĂ­nseca1000691,08 ns
IngĂȘnuo10.00027 072,25 ns
LINQ10.000113 967,87 ns
Vetores10.0007 571,82 ns
IntrĂ­nseca10.0004.296,71 ns
IngĂȘnuo100.000361 028,46 ns
LINQ100.0001.091.994,28 ns
Vetores100.00082 839,29 ns
IntrĂ­nseca100.00040 307,91 ns
IngĂȘnuo1.000.0001 634 175,46 ns
LINQ1.000.0006 194 257,38 ns
Vetores1.000.000583 901,29 ns
IntrĂ­nseca1.000.000413 520,38 ns

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 benchmarks

Nos 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:


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

Comparando duas matrizes:


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

NĂșmero de ocorrĂȘncias de um elemento em uma matriz:


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

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


All Articles