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

Aquí hay un vistazo rápido a las capacidades de vectorización de algoritmos en .NET Framework y .NET Core. Este artículo es para aquellos que no saben nada sobre estas técnicas. También mostraré que .NET no se queda atrás de los lenguajes "compilados reales" para el desarrollo nativo.


Estoy empezando a aprender técnicas de vectorización. Por lo tanto, agradeceré si los miembros de la comunidad encuentran errores claros o sugieren mejoras en los algoritmos descritos.


Algo de historia


SIMD apareció en .NET Framework 4.6 en 2015. Fue entonces cuando se agregaron los tipos Matrix3x2, Matrix4x4, Plane, Quaternion, Vector2, Vector3 y Vector4. Permitieron cálculos vectorizados. El siguiente fue el tipo Vector <T> que dio más oportunidades para vectorizar algoritmos. Sin embargo, muchos programadores todavía estaban insatisfechos ya que estos tipos restringían los flujos de ideas de los codificadores y no permitían usar toda la capacidad de las instrucciones SIMD en los procesadores modernos. Ahora en .NET Core 3.0 Preview, tenemos el espacio de nombres System.Runtime.Intrinsics que da mucha libertad en la elección de instrucciones. Para obtener la máxima velocidad, debe usar RyuJit y recurrir al ensamblaje x64 o desactivar Prefer 32-bit y elegir el ensamblaje AnyCPU. Ejecuté todos los puntos de referencia en la computadora con CPU Intel Core i7-6700 3.40 GHz (Skylake).


Suma de elementos de matriz


Decidí comenzar con una tarea clásica que generalmente viene primero si hay una vectorización involucrada. Se trata de encontrar la suma de los elementos de la matriz. Escribamos cuatro implementaciones de esta tarea para sumar los elementos de Array.


La implementación más obvia:


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

Implementación basada en LINQ:


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

La implementación basada en 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; } 

La implementación basada en el código del espacio de nombres 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; } 

Comparé esos 4 métodos en mi computadora y obtuve los siguientes resultados:


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

Está claro que las soluciones con vectores e intrínsecos son mucho más rápidas que las soluciones obvias y basadas en LINQ. Ahora tenemos que descubrir qué sucede en estos dos métodos.


Consideremos el método de vectores más de cerca:


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; - la cantidad de números de 4 bytes que podemos colocar en un vector. Si se usa la aceleración de hardware, este valor muestra cuántos números de 4 bytes podemos poner en un registro SIMD. De hecho, muestra cuántos elementos de este tipo se pueden manejar simultáneamente;
  • accVector es un vector que acumula el resultado de la función;
  • var v = nuevo Vector <int> (matriz, i); - los datos de la matriz se cargan en un nuevo vector v, comenzando desde el índice i. El vectorSize de datos se cargará exactamente;
  • accVector = Vector.Add (accVector, v); - se suman dos vectores.
    Por ejemplo, hay 8 números en la matriz: {0, 1, 2, 3, 4, 5, 6, 7} y vectorSize == 4.
    Luego, durante la iteración del primer ciclo accVector = {0, 0, 0, 0}, v = {0, 1, 2, 3} y después de la adición accVector mantendrá: {0, 0, 0, 0} + {0, 1 , 2, 3} = {0, 1, 2, 3}.
    Durante la segunda iteración v = {4, 5, 6, 7} y después de la adición accVector = {0, 1, 2, 3} + {4, 5, 6, 7} = {4, 6, 8, 10}.
  • Ahora solo necesitamos obtener la suma de todos los elementos vectoriales. Para hacer esto, podemos usar la multiplicación escalar por un vector lleno de unos: int result = Vector.Dot (accVector, Vector <int> .One);
    Luego obtenemos: {4, 6, 8, 10} * {1, 1, 1, 1} = 4 * 1 + 6 * 1 + 8 * 1 + 10 * 1 = 28.
  • Si es necesario, los números que no se ajustan al último vector se sumarán al final.

Veamos el 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 es como Vectores con una excepción:


  • vectorSize se especifica mediante una constante. Esto se debe a que este método utiliza explícitamente las instrucciones de Avx2 que funcionan con registros de 256 bits. Una aplicación real debe incluir una comprobación de si un procesador actual es compatible con Avx2. Si no, se debe llamar a otro código. Se ve así:
     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): el puntero a la matriz se coloca en ptr.
  • Las siguientes son las mismas operaciones que en Vectores: cargar datos en un vector y agregar dos vectores.
  • La suma de elementos vectoriales utiliza el siguiente método:
    • crear una matriz en la pila: var temp = stackalloc int [vectorSize];
    • cargar un vector en esta matriz: Avx2.Store (temp, accVector);
    • Elementos de la matriz de suma durante el ciclo.
  • A continuación, se resumen los elementos que no se ajustan al último vector.

Comparar dos matrices


Necesitamos comparar dos matrices de bytes. Es exactamente esta tarea la que me hizo estudiar SIMD en .NET. Nuevamente, escribamos varios métodos para la evaluación comparativa y comparemos 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 basada en LINQ:


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

La solución basada en 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; 

La solución basada en 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; } 

Solución basada en 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; } } 

Los resultados de ejecutar el benchmark en mi computadora:


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

Supongo que el código de estos métodos es claro, excepto por dos líneas en Intrinsics:


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

En la primera línea, se comparan dos vectores para la igualdad y el resultado se guarda en un vector areEqual en el que todos los bits del elemento en una posición particular se establecen en 1 si los elementos correspondientes en va y vb son iguales. Entonces, resulta que si los vectores de bytes va y vb son iguales, todos los elementos en areEquals deberían ser iguales a 255 (11111111b). Como Avx2.CompareEqual es un contenedor sobre _mm256_cmpeq_epi8, podemos ir al sitio web de Intel y ver el pseudocódigo de esta operación:
El método MoveMask crea un número de 32 bits a partir de un vector. Los bits superiores de cada 32 elementos de un byte en un vector son los valores de bits en el resultado de MoveMask. El pseudocódigo está disponible aquí .


Por lo tanto, si algunos bytes en va y vb no coinciden, los bytes correspondientes en areEqual serán 0. Por lo tanto, los bits superiores de estos bytes también serán 0. Esto significa que los bits correspondientes en la respuesta Avx2.MoveMask también serán 0 y areEqual no será igual a equalsMask.


Veamos un ejemplo suponiendo que la longitud del vector es de 8 bytes (para escribir menos):


  • Deje va = {100, 10, 20, 30, 100, 40, 50, 100} y vb = {100, 20, 10, 30, 100, 40, 80, 90}.
  • Entonces areEquals será {255, 0, 0, 255, 255, 255, 0, 0}.
  • El método MoveMask devolverá 10011100b que se debe comparar con la máscara 11111111b. Como estas máscaras no son iguales, los vectores va y vb tampoco lo son.

Contando las veces que ocurre un elemento en una colección.


Algunas veces necesita contar las ocurrencias de un elemento en particular, por ejemplo, enteros, en una colección. También podemos acelerar este algoritmo. Para comparar, escribamos varios métodos para buscar el elemento Item en Array.


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

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

Los resultados de ejecutar el benchmark en mi computadora:


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

Los vectores y los métodos intrínsecos coinciden completamente en lógica pero difieren en la implementación de operaciones particulares. La idea es la siguiente:


  • crear un vector de máscara en el que se almacena un número requerido en cada elemento;
  • cargar la parte de una matriz en v vector y comparar esta parte con una máscara. Como resultado, todos los bits se establecerán en elementos iguales de areEqual. Como areEqual es una matriz de enteros, si establecemos todos los bits de un elemento, obtendremos -1 en este elemento ((int) (1111_1111_1111_1111_1111_1111_1111_1111b) == -1);
  • reste el vector areEqual del accVector. Luego, accVector mantendrá el recuento de cuántas veces se produjo el elemento elemento en todos los vectores v para cada posición (menos por menos es un más).

Todo el código del artículo está en GitHub .


Conclusión


Describí solo una pequeña parte de las capacidades de .NET para la vectorización de cómputo. Para ver la lista actualizada completa de todos los intrínsecos disponibles en .NET Core en x86, consulte el código fuente . Es conveniente que el resumen de cada intrínseco en archivos C # contenga su nombre en C world. Esto ayuda a comprender el propósito de este intrínseco y la transferencia de algoritmos C ++ / C existentes a .NET. La documentación de System.Numerics.Vector está disponible en msdn .


Creo que .NET tiene una gran ventaja sobre C ++. Como la compilación JIT ya se produce en una máquina cliente, un compilador puede optimizar el código para un procesador de cliente en particular, dando el máximo rendimiento. Al mismo tiempo, un programador puede permanecer dentro de un idioma y las mismas tecnologías para escribir código rápido.

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


All Articles