Soporte para instrucciones específicas de hardware en .NET Core (ahora no solo SIMD)

Introduccion


Hace unos años, decidimos que era hora de admitir el código SIMD en .NET . Introdujimos el espacio de nombres System.Numerics con los tipos Vector2 , Vector3 , Vector4 y Vector<T> . Estos tipos representan una API de propósito general para crear, acceder y manipular instrucciones vectoriales siempre que sea posible. También proporcionan compatibilidad de software para aquellos casos en los que el hardware no admite instrucciones adecuadas. Esto permitió, con una refactorización mínima, vectorizar varios algoritmos. Sea como fuere, la generalidad de este enfoque hace que sea difícil de aplicar para aprovechar al máximo todas las instrucciones vectoriales disponibles en el hardware moderno. Además, el hardware moderno proporciona una serie de instrucciones especializadas, no vectoriales, que pueden mejorar significativamente el rendimiento. En este artículo, hablaré sobre cómo eludimos estas limitaciones en .NET Core 3.0.



Nota: Todavía no hay un término establecido para la traducción de Intrisics . Al final del artículo hay un voto para la opción de traducción. Si elegimos una buena opción, cambiaremos el artículo.


¿Cuáles son las funciones integradas?


En .NET Core 3.0, agregamos una nueva funcionalidad llamada funciones integradas específicas de hardware (WF lejano). Esta funcionalidad proporciona acceso a muchas instrucciones de hardware específicas que no pueden representarse simplemente mediante mecanismos de propósito más general. Se diferencian de las instrucciones SIMD existentes en que no tienen un propósito general (los nuevos WF no son multiplataforma y su arquitectura no proporciona compatibilidad de software). En cambio, proporcionan directamente la plataforma y la funcionalidad específica del hardware para los desarrolladores de .NET. Las funciones SIMD existentes, por ejemplo, multiplataforma, proporcionan compatibilidad de software, y se abstraen ligeramente del hardware subyacente. Esta abstracción puede ser costosa, además, puede evitar la divulgación de alguna funcionalidad (cuando, por ejemplo, la funcionalidad no existe o es difícil de emular en todas las plataformas de destino).


Las nuevas funciones integradas y los tipos compatibles se encuentran en el System.Runtime.Intrinsics . Para .NET Core 3.0, por el momento, hay un System.Runtime.Intrinsics.X86 . Estamos trabajando para admitir funciones integradas para otras plataformas como System.Runtime.Intrinsics.Arm .


Bajo espacios de nombres específicos de la plataforma, los WF se agrupan en clases que representan grupos de instrucciones de hardware lógicamente integradas (a menudo llamadas arquitectura de conjunto de instrucciones (ISA)). Cada clase proporciona una propiedad IsSupported indica si el hardware en el que se ejecuta el código admite este conjunto de instrucciones. Además, cada clase contiene un conjunto de métodos asignados a un conjunto correspondiente de instrucciones. A veces hay una subclase adicional que corresponde a una parte del mismo conjunto de instrucciones, que puede estar limitada (admitida) por hardware específico. Por ejemplo, la clase Lzcnt proporciona acceso a instrucciones para contar los ceros a la izquierda . Tiene una subclase llamada X64 , que contiene la forma de estas instrucciones utilizadas solo en máquinas con arquitectura de 64 bits.


Algunas de estas clases son naturalmente de naturaleza jerárquica. Por ejemplo, si Lzcnt.X64.IsSupported devuelve verdadero, entonces Lzcnt.IsSupported también debería devolver verdadero, ya que esta es una subclase explícita. O, por ejemplo, si Sse2.IsSupported devuelve verdadero, entonces Sse.IsSupported debería devolver verdadero, porque Sse2 hereda explícitamente de Sse . Sin embargo, vale la pena señalar que la similitud de los nombres de clase no es un indicador de su pertenencia a la misma jerarquía de herencia. Por ejemplo, Bmi2 no se hereda de Bmi1 , por lo que los valores devueltos por IsSupported para estos dos conjuntos de instrucciones serán diferentes. El principio fundamental en el desarrollo de estas clases fue la presentación explícita de las especificaciones ISA. SSE2 requiere soporte para SSE1, por lo que las clases que los representan están relacionadas por herencia. Al mismo tiempo, BMI2 no requiere soporte para BMI1, por lo que no utilizamos la herencia. El siguiente es un ejemplo de la API anterior.


 namespace System.Runtime.Intrinsics.X86 { public abstract class Sse { public static bool IsSupported { get; } public static Vector128<float> Add(Vector128<float> left, Vector128<float> right); // Additional APIs public abstract class X64 { public static bool IsSupported { get; } public static long ConvertToInt64(Vector128<float> value); // Additional APIs } } public abstract class Sse2 : Sse { public static new bool IsSupported { get; } public static Vector128<byte> Add(Vector128<byte> left, Vector128<byte> right); // Additional APIs public new abstract class X64 : Sse.X64 { public static bool IsSupported { get; } public static long ConvertToInt64(Vector128<double> value); // Additional APIs } } } 

Puede ver más en el código fuente en los siguientes enlaces source.dot.net o dotnet / coreclr en GitHub


IsSupported compilador JIT procesa las comprobaciones IsSupported como constantes de tiempo de ejecución (cuando la optimización está habilitada), por lo que no necesita compilación cruzada para admitir múltiples ISA, plataformas o arquitecturas. En cambio, solo necesita escribir el código usando expresiones if , como resultado de las ramas de código no utilizadas (es decir, aquellas ramas a las que no se puede acceder debido al valor de la variable en la declaración condicional) se descartarán cuando se genere el código nativo.


Es importante que la verificación del IsSupported correspondiente preceda al uso de los comandos de hardware integrados. Si no hay tal verificación, entonces el código que usa comandos específicos de plataforma que se ejecutan en plataformas / arquitecturas donde estos comandos no son compatibles generará una excepción de tiempo de ejecución PlatformNotSupportedException .


¿Qué beneficios brindan?


Por supuesto, las funciones integradas específicas del hardware no son para todos, pero pueden usarse para mejorar el rendimiento en operaciones cargadas de cálculos. CoreFX y ML.NET utilizan estos métodos para acelerar operaciones como copiar en la memoria, buscar el índice de un elemento en una matriz o cadena, cambiar el tamaño de una imagen o trabajar con vectores / matrices / tensores. La vectorización manual de algún código que resultó ser un cuello de botella también puede ser más simple de lo que parece. La vectorización del código, de hecho, es realizar varias operaciones a la vez, en general, utilizando instrucciones SIMD (un flujo de instrucciones, flujo de datos múltiples).


Antes de decidirse a vectorizar algún código, debe llevar a cabo la creación de perfiles para asegurarse de que este código sea realmente parte del "punto caliente" (y, por lo tanto, su optimización dará un aumento significativo en el rendimiento). También es importante llevar a cabo la creación de perfiles en cada etapa de la vectorización, ya que la vectorización de no todo el código conduce a una mayor productividad.


Vectorización de un algoritmo simple


Para ilustrar el uso de funciones incorporadas, tomamos el algoritmo para sumar todos los elementos de una matriz o rango. Este tipo de código es un candidato ideal para la vectorización, porque en cada iteración, se realiza la misma operación trivial.


Un ejemplo de implementación de dicho algoritmo puede tener el siguiente aspecto:


 public int Sum(ReadOnlySpan<int> source) { int result = 0; for (int i = 0; i < source.Length; i++) { result += source[i]; } return result; } 

Este código es bastante simple y directo, pero al mismo tiempo lo suficientemente lento para datos de entrada grandes, como realiza solo una operación trivial por iteración.


 BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362 AMD Ryzen 7 1800X, 1 CPU, 16 logical and 8 physical cores .NET Core SDK=3.0.100-preview9-013775 [Host] : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT [AttachedDebugger] DefaultJob : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT 

MétodoCuentaMediaErrorStddev
Suma12.477 ns0,0192 ns0,0179 ns
Suma22.164 ns0.0265 ns0.0235 ns
Suma4 43.224 ns0,0302 ns0,0267 ns
Suma84.347 ns0.0665 ns0.0622 ns
Suma168.444 ns0.2042 ns0.3734 ns
Suma3213.963 ns0.2182 ns0.2041 ns
Suma6450.374 ns0.2955 ns0.2620 ns
Suma12860,139 ns0.3890 ns0.3639 ns
Suma256106.416 ns0.6404 ns0.5990 ns
Suma512291.450 ns3.5148 ns3.2878 ns
Suma1024574.243 ns9.5851 ns8.4970 ns
Suma20481 137.819 ns5.9363 ns5.5529 ns
Suma40962 228.341 ns22.8882 ns21.4097 ns
Suma81922 973.040 ns14.2863 ns12.6644 ns
Suma163845 883.504 ns15.9619 ns14.9308 ns
Suma3276811 699,237 ns104.0970 ns97,3724 ns

Aumente la productividad a través de los ciclos de implementación


Los procesadores modernos tienen varias opciones para mejorar el rendimiento del código. Para aplicaciones de subproceso único, una de esas opciones es realizar varias operaciones primitivas en un solo ciclo de procesador.


La mayoría de los procesadores modernos pueden realizar cuatro operaciones de adición en un ciclo de reloj (en condiciones óptimas), como resultado de lo cual, con el "diseño" correcto del código, a veces puede mejorar el rendimiento, incluso en una implementación de subproceso único.


Aunque JIT puede realizar el desenrollado de bucles por sí solo, JIT es conservador al tomar este tipo de decisión, debido al tamaño del código generado. Por lo tanto, puede ser ventajoso implementar un bucle, en código, manualmente.


Puede expandir el bucle en el código anterior de la siguiente manera:


 public unsafe int SumUnrolled(ReadOnlySpan<int> source) { int result = 0; int i = 0; int lastBlockIndex = source.Length - (source.Length % 4); // Pin source so we can elide the bounds checks fixed (int* pSource = source) { while (i < lastBlockIndex) { result += pSource[i + 0]; result += pSource[i + 1]; result += pSource[i + 2]; result += pSource[i + 3]; i += 4; } while (i < source.Length) { result += pSource[i]; i += 1; } } return result; } 

Este código es un poco más complicado, pero hace un mejor uso de las características del hardware.


Para bucles realmente pequeños, este código se ejecuta un poco más lento. Pero esta tendencia ya está cambiando para los datos de entrada de ocho elementos, después de lo cual la velocidad de ejecución comienza a aumentar (el tiempo de ejecución del código optimizado, para 32 mil elementos, es un 26% menor que el tiempo de la versión original). Vale la pena señalar que dicha optimización no siempre aumenta la productividad. Por ejemplo, cuando se trabaja con colecciones con elementos de tipo float versión "implementada" del algoritmo tiene casi la misma velocidad que la original. Por lo tanto, es muy importante llevar a cabo la creación de perfiles.


MétodoCuentaMediaErrorStddev
Sumunrolled12.922 ns0.0651 ns0,0609 ns
Sumunrolled23.576 ns0,0116 ns0.0109 ns
Sumunrolled4 43.708 ns0,0157 ns0,0139 ns
Sumunrolled84.832 ns0,0486 ns0,0454 ns
Sumunrolled167.490 ns0.1131 ns0.1058 ns
Sumunrolled3211.277 ns0.0910 ns0.0851 ns
Sumunrolled6419.761 ns0.2016 ns0.1885 ns
Sumunrolled12836,639 ns0.3043 ns0.2847 ns
Sumunrolled25677,969 ns0.8409 ns0.7866 ns
Sumunrolled512146.357 ns1.3209 ns1.2356 ns
Sumunrolled1024287.354 ns0.9223 ns0.8627 ns
Sumunrolled2048566,405 ns4.0155 ns3.5596 ns
Sumunrolled40961 131.016 ns7.3601 ns6.5246 ns
Sumunrolled81922 259.836 ns8.6539 ns8.0949 ns
Sumunrolled163844 501.295 ns6.4186 ns6.0040 ns
Sumunrolled327688 979.690 ns19.5265 ns18.2651 ns


Aumente la productividad a través de la vectorización de bucle


Sea como fuere, pero aún podemos optimizar ligeramente este código. Las instrucciones SIMD son otra opción proporcionada por los procesadores modernos para mejorar el rendimiento. Usando una sola instrucción, le permiten realizar varias operaciones en un solo ciclo de reloj. Esto puede ser mejor que el despliegue de bucle directo, porque, de hecho, se hace lo mismo, pero con una menor cantidad de código generado.


Para aclarar, cada operación de adición, en un ciclo desplegado, toma 4 bytes. Por lo tanto, necesitamos 16 bytes para 4 operaciones de suma en forma expandida. Al mismo tiempo, la instrucción de adición SIMD también realiza 4 operaciones de adición, pero solo toma 4 bytes. Esto significa que tenemos menos instrucciones para la CPU. Además de esto, en el caso de una instrucción SIMD, la CPU puede hacer suposiciones y realizar optimizaciones, pero esto está más allá del alcance de este artículo. Lo que es aún mejor es que los procesadores modernos pueden ejecutar más de una instrucción SIMD a la vez, es decir, en algunos casos, puede aplicar una estrategia mixta, al mismo tiempo realizar una exploración de ciclo parcial y vectorización.


En general, debe comenzar mirando la clase de propósito general de Vector<T> para sus tareas. Él, como los nuevos WF , incorporará instrucciones SIMD, pero al mismo tiempo, dada la versatilidad de esta clase, puede reducir la cantidad de codificación "manual".


El código podría verse así:


 public int SumVectorT(ReadOnlySpan<int> source) { int result = 0; Vector<int> vresult = Vector<int>.Zero; int i = 0; int lastBlockIndex = source.Length - (source.Length % Vector<int>.Count); while (i < lastBlockIndex) { vresult += new Vector<int>(source.Slice(i)); i += Vector<int>.Count; } for (int n = 0; n < Vector<int>.Count; n++) { result += vresult[n]; } while (i < source.Length) { result += source[i]; i += 1; } return result; } 

Este código funciona más rápido, pero nos vemos obligados a referirnos a cada elemento por separado al calcular la cantidad final. Además, Vector<T> no tiene un tamaño definido con precisión y puede variar, dependiendo del equipo en el que se ejecuta el código. Las funciones integradas específicas del hardware proporcionan una funcionalidad adicional que puede mejorar ligeramente este código y hacerlo un poco más rápido (a costa de la complejidad del código adicional y los requisitos de mantenimiento).


MétodoCuentaMediaErrorStddev
SumVectorT14.517 ns0,0752 ns0,0703 ns
SumVectorT24.853 ns0,0609 ns0.0570 ns
SumVectorT4 45.047 ns0,0909 ns0.0850 ns
SumVectorT85.671 ns0,0251 ns0,0223 ns
SumVectorT166.579 ns0.0330 ns0,0276 ns
SumVectorT3210.460 ns0,0241 ns0,0226 ns
SumVectorT6417.148 ns0,0407 ns0,0381 ns
SumVectorT12823.239 ns0.0853 ns0,0756 ns
SumVectorT25662,146 ns0.8319 ns0.7782 ns
SumVectorT512114.863 ns0.4175 ns0.3906 ns
SumVectorT1024172.129 ns1.8673 ns1.7467 ns
SumVectorT2048429.722 ns1.0461 ns0.9786 ns
SumVectorT4096654.209 ns3.6215 ns3.0241 ns
SumVectorT81921 675.046 ns14.5231 ns13.5849 ns
SumVectorT163842 514.778 ns5.3369 ns4.9921 ns
SumVectorT327686.689.829 ns13.9947 ns13.0906 ns


NOTA Para este artículo, COMPlus_SIMD16ByteOnly=1 tamaño del Vector<T> igual a 16 bytes usando el parámetro de configuración interna ( COMPlus_SIMD16ByteOnly=1 ). Este ajuste normalizó los resultados al comparar SumVectorT con SumVectorizedSse , y nos permitió mantener el código simple. En particular, evitó escribir un salto condicional if (Avx2.IsSupported) { } . Este código es casi idéntico al código para Sse2 , pero trata con Vector256<T> (32 bytes) y procesa aún más elementos en una iteración del bucle.


Por lo tanto, utilizando las nuevas funciones integradas , el código puede reescribirse de la siguiente manera:


 public int SumVectorized(ReadOnlySpan<int> source) { if (Sse2.IsSupported) { return SumVectorizedSse2(source); } else { return SumVectorT(source); } } public unsafe int SumVectorizedSse2(ReadOnlySpan<int> source) { int result; fixed (int* pSource = source) { Vector128<int> vresult = Vector128<int>.Zero; int i = 0; int lastBlockIndex = source.Length - (source.Length % 4); while (i < lastBlockIndex) { vresult = Sse2.Add(vresult, Sse2.LoadVector128(pSource + i)); i += 4; } if (Ssse3.IsSupported) { vresult = Ssse3.HorizontalAdd(vresult, vresult); vresult = Ssse3.HorizontalAdd(vresult, vresult); } else { vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0x4E)); vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0xB1)); } result = vresult.ToScalar(); while (i < source.Length) { result += pSource[i]; i += 1; } } return result; } 

Este código, una vez más, es un poco más complicado, pero es significativamente más rápido para todos, excepto para los conjuntos de entrada más pequeños. Para 32 mil elementos, este código se ejecuta un 75% más rápido que el ciclo expandido y un 81% más rápido que el código fuente del ejemplo.


Notó que escribimos algunos controles IsSupported . El primero verifica si el hardware actual es compatible con el conjunto requerido de funciones incorporadas ; de lo contrario, la optimización se realiza mediante una combinación de barrido y Vector<T> . La última opción se seleccionará para plataformas como ARM / ARM64 que no admiten el conjunto de instrucciones requerido, o si el conjunto se ha deshabilitado para la plataforma. La segunda prueba IsSupported , en el método SumVectorizedSse2 , se utiliza para una optimización adicional si el hardware admite el Ssse3 instrucciones Ssse3 .


De lo contrario, la mayor parte de la lógica es esencialmente la misma que para el bucle expandido. Vector128<T> es un tipo de 128 bits que contiene elementos Vector128<T>.Count . En este caso, uint , que en sí mismo es de 32 bits, puede tener 4 elementos (128/32), así es como lanzamos el bucle.


MétodoCuentaMediaErrorStddev
Sumvectorizado14.555 ns0,0192 ns0,0179 ns
Sumvectorizado24.848 ns0,0147 ns0,0137 ns
Sumvectorizado4 45.381 ns0.0210 ns0,0186 ns
Sumvectorizado84.838 ns0.0209 ns0,0186 ns
Sumvectorizado165.107 ns0,0175 ns0,0146 ns
Sumvectorizado325.646 ns0.0230 ns0,0204 ns
Sumvectorizado646.763 ns0,0338 ns0.0316 ns
Sumvectorizado1289.308 ns0.1041 ns0.0870 ns
Sumvectorizado25615.634 ns0.0927 ns0.0821 ns
Sumvectorizado51234,706 ns0.2851 ns0.2381 ns
Sumvectorizado102468.110 ns0.4016 ns0.3756 ns
Sumvectorizado2048136.533 ns1.3104 ns1.2257 ns
Sumvectorizado4096277.930 ns0.5913 ns0.5531 ns
Sumvectorizado8192554.720 ns3.5133 ns3.2864 ns
Sumvectorizado163841 110.730 ns3.3043 ns3.0909 ns
Sumvectorizado327682 200.996 ns21.0538 ns19,6938 ns


Conclusión


Las nuevas funciones integradas le brindan la oportunidad de aprovechar la funcionalidad específica del hardware de la máquina en la que ejecuta el código. Hay aproximadamente 1,500 API para X86 y X64 distribuidas en 15 conjuntos, hay demasiadas para describir en un artículo. Al perfilar el código para identificar cuellos de botella, puede determinar la parte del código que se beneficia de la vectorización y observar un aumento de rendimiento bastante bueno. Hay muchos escenarios en los que se puede aplicar la vectorización y el despliegue de bucles es solo el comienzo.


Cualquiera que quiera ver más ejemplos puede buscar el uso de funciones integradas en el marco (ver dotnet y aspnet ), o en otros artículos de la comunidad. Y aunque los WF actuales son vastos, todavía hay mucha funcionalidad que debe introducirse. Si tiene la funcionalidad que desea presentar, no dude en registrar su solicitud de API a través de dotnet / corefx en GitHub . El proceso de revisión de API se describe aquí y hay un buen ejemplo de una plantilla de solicitud de API especificada en el paso 1.


Agradecimiento especial


Me gustaría expresar un agradecimiento especial a los miembros de nuestra comunidad Fei Peng (@fiigii) y Jacek Blaszczynski (@ 4creators) por su ayuda en la implementación del WF , así como a todos los miembros de la comunidad por sus valiosos comentarios sobre el desarrollo, implementación y facilidad de uso de esta funcionalidad.




Epílogo de la traducción


Me gusta observar el desarrollo de la plataforma .NET y, en particular, el lenguaje C #. Viniendo del mundo de C ++, y con poca experiencia en desarrollo en Delphi y Java, me sentí muy cómodo comenzando a escribir programas en C #. En 2006, este lenguaje de programación (el lenguaje en sí) me pareció más conciso y práctico que Java en el mundo de la recolección de basura administrada y multiplataforma. Por lo tanto, mi elección recayó en C #, y no me arrepentí. La primera etapa en la evolución de un lenguaje fue simplemente su apariencia. Para 2006, C # absorbió todo lo mejor que había en ese momento en los mejores lenguajes y plataformas: C ++ / Java / Delphi. En 2010, F # se hizo público. Era una plataforma experimental para estudiar el paradigma funcional con el objetivo de introducirlo en el mundo de .NET. El resultado de los experimentos fue la siguiente etapa en la evolución de C #: la expansión de sus capacidades hacia el FP, a través de la introducción de funciones anónimas, expresiones lambda y, en última instancia, LINQ. Esta extensión del lenguaje convirtió a C # en el lenguaje de propósito general más avanzado, desde mi punto de vista. El siguiente paso evolutivo se relacionó con el apoyo a la concurrencia y la asincronía. Tarea / Tarea <T>, todo el concepto de TPL, el desarrollo de LINQ - PLINQ y, por último, asíncrono / espera. , - , .NET C# — . Span<T> Memory<T>, ValueTask/ValueTask<T>, IAsyncDispose, ref readonly struct in, foreach, IO.Streams. GC . , — . , .NET C#, , . ( ) .

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


All Articles