Una pequeña descripción de SIMD en .NET / C #

Su atención es invitada a una pequeña descripción de las capacidades de vectorización de algoritmos en .NET Framework y .NETCORE. El propósito del artículo es presentar estas técnicas a aquellos que no las conocían en absoluto y mostrar que .NET no va muy por detrás de los lenguajes "reales, compilados" para los nativos.
desarrollo


Estoy empezando a aprender técnicas de vectorización, por lo que si alguien de la comunidad me señala un canto explícito o sugiere versiones mejoradas de los algoritmos que se describen a continuación, estaré muy feliz.


Un poco de historia


En .NET, SIMD apareció por primera vez en 2015 con el lanzamiento de .NET Framework 4.6. Luego se agregaron los tipos Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 y Vector4, lo que permitió la construcción de cálculos vectorizados. Más tarde, se agregó el tipo Vector <T>, que proporcionó más oportunidades para vectorizar algoritmos. Pero muchos programadores todavía estaban descontentos porque los tipos anteriores limitaron el flujo de pensamientos del programador y no permitieron el uso completo de las instrucciones SIMD de los procesadores modernos. Ya en nuestro tiempo, en .NET Core 3.0 Preview, ha aparecido el espacio de nombres System.Runtime.Intrinsics, que proporciona mucha más libertad para elegir instrucciones. Para obtener los mejores resultados en velocidad, debe usar RyuJit y construir en x64 o desactivar Prefer 32-bit y construir en AnyCPU. Todos los puntos de referencia que ejecuté en una computadora con un procesador Intel Core i7-6700 3.40GHz (Skylake).


Resumir los elementos de la matriz.


Decidí comenzar con el problema clásico, que a menudo se escribe primero cuando se trata de vectorización. Esta es la tarea de encontrar la suma de los elementos de la matriz. Escribiremos cuatro implementaciones de esta tarea, resumiremos los elementos de la matriz Array:


Más obvio


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 vectores de 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 código del espacio 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; } 

Lancé un punto de referencia sobre estos 4 métodos en mi computadora y obtuve este resultado:


MétodoItemsCountMediana
Ingenuo1075,12 ns
LINQ101 186.85 ns
Vectores1060.09 ns
Intrínseca10255,40 ns
Ingenuo100360.56 ns
LINQ1002 719,24 ns
Vectores10060.09 ns
Intrínseca100345,54 ns
Ingenuo10001 847,88 ns
LINQ100012 033,78 ns
Vectores1000240,38 ns
Intrínseca1000630.98 ns
Ingenuo10,00018 403,72 ns
LINQ10,000102 489,96 ns
Vectores10,0007 316,42 ns
Intrínseca10,0003 365.25 ns
Ingenuo100,000176 630,67 ns
LINQ100,000975 998,24 ns
Vectores100,00078 828,03 ns
Intrínseca100,00041 269,41 ns

Se puede ver que las soluciones con vectores e intrínsecos son mucho más rápidas que la solución obvia y con LINQ. Ahora tenemos que descubrir qué sucede en estos dos métodos.


Considere el método de Vectores con más detalle:


Vectores
 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 = Vector <int> .Count; - esta es la cantidad de números de 4 bytes que podemos poner en un vector. Si se usa la aceleración de hardware, este valor muestra cuántos números de 4 bytes se pueden colocar en un registro SIMD. De hecho, muestra cuántos elementos de este tipo puede realizar operaciones en paralelo;
  • accVector: un vector en el que se acumulará el resultado de la función;
    var v = nuevo Vector <int> (matriz, i); - los datos se cargan en un nuevo vector v, desde la matriz, comenzando desde el índice i. Se cargarán exactamente los datos de vectorSize.
  • accVector = Vector.Add (accVector, v); - Se añaden dos vectores.
    Por ejemplo, en la matriz se almacenan 8 números: {0, 1, 2, 3, 4, 5, 6, 7} y vectorSize == 4, luego:
    En la primera iteración del bucle accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3}, después de la adición en accVector será: {0, 0, 0, 0} + {0, 1, 2 , 3} = {0, 1, 2, 3}.
    En la segunda iteración, v = {4, 5, 6, 7} y después de la suma accVector = {0, 1, 2, 3} + {4, 5, 6, 7} = {4, 6, 8, 10}.
  • Solo queda obtener de alguna manera la suma de todos los elementos del vector, para esto podemos aplicar la multiplicación escalar por un vector lleno de unidades: int result = Vector.Dot (accVector, Vector <int> .One);
    Entonces resulta: {4, 6, 8, 10} {1, 1, 1, 1} = 4 1 + 6 1 + 8 1 + 10 * 1 = 28.
  • Al final, si es necesario, se suman los números que no caben en el último vector.

Si nos fijamos en el código del método 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; } 

Puede ver que es muy similar a Vectores con algunas excepciones:


  • vectorSize está dado por constante. Esto se debe a que las instrucciones Avx2 que operan en registros de 256 bits se usan explícitamente en este método. En una aplicación real, debería verificarse si el procesador Avx2 actual admite instrucciones y, si no, llamar a otro código. Se parece a esto:
     if (Avx2.IsSupported) { DoThingsForAvx2(); } else if (Avx.IsSupported) { DoThingsForAvx(); } ... else if (Sse2.IsSupported) { DoThingsForSse2(); } ... 
  • var accVector = Vector256 <int> .Zero; accVector se declara como un vector de 256 bits lleno de ceros.
  • fixed (int * ptr = Array): se ingresa un puntero a una matriz en ptr.
  • Luego, las mismas operaciones que en Vectores: cargar datos en un vector y agregar dos vectores.
  • Para resumir los elementos del vector se aplicó el siguiente método:
    • se crea una matriz en la pila: var temp = stackalloc int [vectorSize];
    • el vector se carga en esta matriz: Avx2.Store (temp, accVector);
    • en un bucle se suman los elementos de la matriz.
  • entonces se suman los elementos de la matriz que no se colocan en el último vector

Compara dos matrices


Es necesario comparar dos matrices de bytes. En realidad, este es el problema por el cual comencé a aprender SIMD en .NET. Nuevamente, escribiremos varios métodos para el punto de referencia, compararemos dos matrices: ArrayA y ArrayB:


La solución más obvia:


 public bool Naive() { for (int i = 0; i < ArrayA.Length; i++) { if (ArrayA[i] != ArrayB[i]) return false; } return true; } 

Solución a través de LINQ:


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

Solución a través de la función 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 vectores de 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 intrínsecos:


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

El resultado del benchmark en mi computadora:


MétodoItemsCountMediana
Ingenuo10,00066 719,1 ns
LINQ10,00071 211,1 ns
Vectores10,0003 695.8 ns
Memcmp10,000600,9 ns
Intrínseca10,0001 607,5 ns
Ingenuo100,000588 633,7 ns
LINQ100,000651 191.3 ns
Vectores100,00034 659,1 ns
Memcmp100,0005 513,6 ns
Intrínseca100,00012,078.9 ns
Ingenuo1,000,0005 637 293,1 ns
LINQ1,000,0006 622 666,0 ns
Vectores1,000,000777 974,2 ns
Memcmp1,000,000361 704,5 ns
Intrínseca1,000,000434 252,7 ns

Creo que todo el código para estos métodos es comprensible, con la excepción de dos líneas en intrínsecos:


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

En el primero, se comparan dos vectores para igualdad y el resultado se almacena en el vector areEqual, en el que todos los bits se establecen en 1 en un elemento en una posición específica si los elementos correspondientes en va y vb son iguales. Resulta que si los vectores de los bytes va y vb son completamente iguales, entonces en areEquals todos los elementos deberían ser iguales a 255 (11111111b). Porque Avx2.CompareEqual es un contenedor sobre _mm256_cmpeq_epi8, luego en el sitio de Intel puede ver el pseudocódigo de esta operación:
El método MoveMask de un vector crea un número de 32 bits. Los valores de bits son los bits altos de cada uno de los 32 elementos de un solo byte del vector. El pseudocódigo se puede encontrar aquí .


Por lo tanto, si algunos bytes en va y vb no coinciden, en areEqual los bytes correspondientes serán 0, por lo tanto, los bits más significativos de estos bytes también serán 0, lo que significa que los bits correspondientes en la respuesta Avx2.MoveMask también serán 0 y la comparación con equalsMask no funcionará.


Analicemos un pequeño ejemplo, suponiendo que la longitud del vector es de 8 bytes (para escribirlo fue menor):


  • Sea va = {100, 10, 20, 30, 100, 40, 50, 100}, y vb = {100, 20, 10, 30, 100, 40, 80, 90};
  • Entonces areEqual será igual a {255, 0, 0, 255, 255, 255, 0, 0};
  • El método MoveMask devolverá 10011100b, que deberá compararse con la máscara 11111111b, porque Como estas máscaras son desiguales, resulta que los vectores va y vb no son iguales.

Cuente cuántas veces ocurre un elemento en la colección


A veces es necesario calcular cuántas veces se encuentra un elemento en particular en una colección, por ejemplo, ints, este algoritmo también se puede acelerar. Escribamos algunos métodos para comparar, buscaremos el elemento Item en la matriz de Array.


Lo más obvio:


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

utilizando vectores 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 intrínsecos:


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

El resultado del benchmark en mi computadora:


MétodoItemsCountMediana
Ingenuo10002 824,41 ns
LINQ100012 138.95 ns
Vectores1000961.50 ns
Intrínseca1000691.08 ns
Ingenuo10,00027 072.25 ns
LINQ10,000113 967,87 ns
Vectores10,0007 571,82 ns
Intrínseca10,0004.296,71 ns
Ingenuo100,000361 028,46 ns
LINQ100,0001.091.994,28 ns
Vectores100,00082 839,29 ns
Intrínseca100,00040 307,91 ns
Ingenuo1,000,0001 634 175,46 ns
LINQ1,000,0006 194 257,38 ns
Vectores1,000,000583 901,29 ns
Intrínseca1,000,000413 520,38 ns

Los métodos de Vectores e Intrínsecos son completamente idénticos en lógica, las diferencias están solo en la implementación de operaciones específicas. La idea en su conjunto es:


  • se crea un vector de máscara en el que se almacena el número requerido en cada elemento;
  • La parte de la matriz se carga en el vector v y se compara con la máscara, luego todos los bits se establecerán en elementos iguales en areEqual, porque areEqual es un vector de ints, entonces si configura todos los bits de un elemento, obtenemos -1 en este elemento ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
  • el vector areEqual se resta de accVector y luego accVector será la suma de cuántas veces se produjo el elemento elemento en todos los vectores v para cada posición (menos min da un plus).

Todo el código del artículo se puede encontrar en GitHub


Conclusión


Examiné solo una parte muy pequeña de las posibilidades que ofrece .NET para los cálculos de vectorización. Para obtener una lista completa y actualizada de intrínsecos disponibles en .NETCORE bajo x86, puede consultar el código fuente . Es conveniente que en los archivos C # en el resumen de cada intrínseco haya su propio nombre del mundo de C, lo que simplifica la comprensión del propósito de este intrínseco y la traducción de los algoritmos C ++ / C existentes a .NET. La documentación de System.Numerics.Vector está disponible en msdn .


En mi opinión, .NET tiene una gran ventaja sobre C ++, porque La compilación JIT ya tiene lugar en la máquina del cliente, el compilador puede optimizar el código para un procesador de cliente específico, proporcionando el máximo rendimiento. Al mismo tiempo, un programador para escribir código rápido puede permanecer dentro del marco de un lenguaje y tecnología.


UPD (15/09/2019):


Había una jamba en los puntos de referencia

En los puntos de referencia, usé IterationSetup, que, como resultó, puede afectar en gran medida el rendimiento de los puntos de referencia que funcionan en menos de 100 ms. Si lo rehace en GlobalSetup, los resultados serán así.


Suma de elementos de matriz:


MétodoItemsCountMediaErrorStddevRatio
Ingenuo103.531 ns0.0336 ns0.0314 ns1.00
LINQ1076,925 ns0.4166 ns0.3897 ns21,79
Vectores102.750 ns0.0210 ns0,0196 ns0,78
Intrínseca106.513 ns0.0623 ns0,0582 ns1,84
Ingenuo10047,982 ns0.3975 ns0.3524 ns1.00
LINQ100590.414 ns3.8808 ns3.4402 ns12,31
Vectores10010.122 ns0.0747 ns0.0699 ns0,21
Intrínseca10014,277 ns0.0566 ns0.0529 ns0,30
Ingenuo1000569.910 ns2.8297 ns2.6469 ns1.00
LINQ10005,658.570 ns31.7465 ns29.6957 ns9,93
Vectores100079.598 ns0.3498 ns0.3272 ns0,14
Intrínseca100066,970 ns0.3937 ns0.3682 ns0,12
Ingenuo10,0005.637.571 ns37.5050 ns29.2814 ns1.00
LINQ10,00056.498.987 ns294.8776 ns275.8287 ns10.02
Vectores10,000772.900 ns2.6802 ns2.5070 ns0,14
Intrínseca10,000579.152 ns2.8371 ns2.6538 ns0,10
Ingenuo100,00056,352.865 ns230.7916 ns215.8826 ns1.00
LINQ100,000562,610.571 ns3.775,7631 ns3.152,9332 ns9,98
Vectores100,0008.389.647 ns165.9590 ns227.1666 ns0,15
Intrínseca100,0007.261.334 ns89.6468 ns69.9903 ns0,13

Comparando dos matrices:


MétodoItemsCountMediaErrorStddevRatio
Ingenuo10,0007.033,8 ns50.636 ns47.365 ns1.00
LINQ10,00064.841,4 ns289.157 ns270.478 ns9.22
Vectores10,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
Ingenuo100,00085,214.4 ns903.868 ns845.478 ns1.00
LINQ100,000702,279.4 ns2,846.609 ns2,662.720 ns8.24
Vectores100,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
Ingenuo1,000,000844,006.1 ns3,552.478 ns3.322,990 ns1.00
LINQ1,000,0006.483.079,3 ns42,641.040 ns39,886.455 ns7.68
Vectores1,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

El número de ocurrencias de un elemento en una matriz:


MétodoItemsCountMediaErrorStddevRatio
Ingenuo108.844 ns0,0772 ns0.0603 ns1.00
LINQ1087.456 ns0.9496 ns0.8883 ns9,89
Vectores103.140 ns0,0406 ns0.0380 ns0,36
Intrínseca1013.813 ns0.0825 ns0,0772 ns1,56
Ingenuo100107,310 ns0,6975 ns0.6183 ns1.00
LINQ100626.285 ns5.7677 ns5.3951 ns5.83
Vectores10011.844 ns0.2113 ns0.1873 ns0,11
Intrínseca10019,616 ns0.1018 ns0,0903 ns0,18
Ingenuo10001,032.466 ns6.3799 ns5.6556 ns1.00
LINQ10006.266,605 ns42.6585 ns39.9028 ns6.07
Vectores100083,417 ns0.5393 ns0.4780 ns0,08
Intrínseca100088.358 ns0.4921 ns0.4603 ns0,09
Ingenuo10,0009,942.503 ns47,9732 ns40.0598 ns1.00
LINQ10,00062,305.598 ns643.8775 ns502.6972 ns6.27
Vectores10,000914.967 ns7.2959 ns6.8246 ns0,09
Intrínseca10,000931.698 ns6.3444 ns5.9346 ns0,09
Ingenuo100,00094,834.804 ns793.8585 ns703.7349 ns1.00
LINQ100,000626,620.968 ns4,696.9221 ns4,393.5038 ns6.61
Vectores100,0009,000.827 ns179.5351 ns192.1005 ns0,09
Intrínseca100,0008.690.771 ns101.7078 ns95.1376 ns0,09
Ingenuo1,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
Vectores1,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/es435840/


All Articles