Programación a bajo nivel con C# y .NET Core 3

por:

Programación a bajo nivel con C# y .NET Core 3

Programar a bajo nivel, quizás es una de esas cosas que aprendes en la carrera y que nunca vas a necesitar. Sin embargo, es muy curioso como algunos lenguajes de alto nivel, según van evolucionando, van añadiendo más características de bajo nivel.

¿Qué es programar a bajo nivel?

Para mí, programar a bajo nivel es programar lo más cerca posible al uso directo de la memoria del ordenador y al juego de instrucciones básicas del procesador. Esto evita usar abstracciones como clases, operadores propios del lenguaje, etc. acercándote lo máximo posible a programar en ensamblador.

¿Cuándo es necesario programar a bajo nivel?

En el día a día, seguro que el 99% de los desarrolladores no necesitamos programar a bajo nivel. Pero que no forme parte de nuestro día a día, no quiere decir que todavía no sea muy necesario en algunas áreas:

  • Programación de sistemas empotrados o embebidos.
  • Computación científica, con alta carga matemática.
  • Desarrollo de motores o sistemas que simulen sucesos.
  • Programación de sistemas criptográficos.

C#, la programación a bajo nivel y .NET Core 3

Teniendo claro los conceptos básicos anteriores, podemos hacer un pequeño repaso a algunas de las cosas que ya tenía C# antes de .NET Core 3 para programar a bajo nivel:

  • Gestión de memoria mediante punteros.
  • Uso de las funciones del sistema mediante PInvoke.
  • Uso de código que escape al runtime (unmanaged code)

Sin embargo, .NET Core 3 va a traer bajo el namespace System.Runtime.Intrinsics acceso al juego de instrucciones tanto de la arquitectura x86 como de ARM y sus sucesivas ampliaciones SSE2, SSE3…

Arquitectura x86, juego de instrucciones y algunos conceptos básicos

Para entender el ejemplo que voy a hacer utilizando la nueva API de C# que nos permite realizar llamadas a instrucciones concretas de nuestra CPU, primero creo interesante abordar algunos conceptos básicos.

Los ordenadores de sobremesa y portátiles utilizan hoy en día procesadores Intel o AMD, ambos utilizan las arquitecturas x86 y AMD64, para 32bits y 64bits respectivamente. En el caso de los móviles, lo más común es que usen arquitecturas ARM o ARM64, para 32bits y 64bits respectivamente. Por simplicidad me voy a centrar en x86.

La arquitectura x86 sigue un diseño CISC (Complex Instruction Set Computing), lo que implica que tienen muchas instrucciones para hacer operaciones complejas de manera óptima, que están diseñadas a nivel de hardware no por software.

Dentro de la arquitectura x86, un conjunto de instrucciones muy importante para agilizar en gran medida los cálculos son las SIMD (Single Instruction Multiple Data). Que en pocas palabras, permiten operar con una misma instrucción sobre múltiples datos al mismo tiempo.

Para profundizar en estos conceptos, puede visitar la siguiente explicación preparada por el Dr. Nicolás Wolovick de la Universidad Nacional de Córdoba, Argentina.

La instrucción PHMINPOSUW

La instrucción de bajo nivel que voy a utilizar es PHMINPOSUW, una instrucción que partiendo recibe un vector de 128bits formado por 8 elementos de tipo unsinged short, calcula el menor de los mismos. Para ello, según la documentación de Intel se utiliza el siguiente pseudocódigo.

index[2:0] := 0
min[15:0] := a[15:0]
FOR j := 0 to 7
    i := j*16
    IF a[i+15:i] < min[15:0]
        index[2:0] := j
        min[15:0] := a[i+15:i]
    FI
ENDFOR
dst[15:0] := min[15:0]
dst[18:16] := index[2:0]
dst[127:19] := 0

Aunque se ve más claro, utilizando la siguiente representación gráfica:

Programando a bajo nivel con la instrucción phminposuw en C# usando la nueva api de .NET Core 3

Fuente: Officedaytime

Usando la instrucción de bajo nivel desde C#

Con el siguiente código, podríamos computar el mínimo de 8 elementos unsigned short vía la instrucción PHMINPOSUW, desde C#:

using System;
using System.Runtime.Intrinsics.X86;
using System.Runtime.Intrinsics;
...
public ushort MinSIMD(ushort us0, ushort us1, ushort us2,
                ushort us3, ushort us4,ushort us5, ushort us6, ushort us7){
            
    var values = Vector128.Create(us0,us1,us2,us3,us4,us5,us6,us7);
    return Sse41.MinHorizontal(values).GetElement(0);
}

Ahora desgranemos cada una de las líneas:

  • En la línea 5 y 6 definimos un método que recibe los 8ushorts y devuelve el menor de los mismos.
  • En la línea 8 creamos el registro de 128bits, rellenándolo con los 8ushorts (1 ushort ocupa 16bits). Para ello hacemos uso del método estático Create el cuál posee múltiples sobrecargas.
  • En la línea 9 llamamos al método estático MinHorizontal del juego de instrucciones SSE4.1 vía su clase estática que lo representa y retornamos el resultado, que lo deja en la posición 0.

Ahora os preguntaréis, ¿cómo sabes que llamar a Sse41.MinHorizontal utiliza la instrucción PHMINPOSUW? A lo que os respondo, que es sencillo porque así lo indica la documentación:

//
// Summary:
//     __m128i _mm_minpos_epu16 (__m128i a) PHMINPOSUW xmm, xmm/m128
public static Vector128<ushort> MinHorizontal(Vector128<ushort> value);

Comparación de rendimiento

Desde el inicio del artículo, todo lo escrito defendía la tesis de que programar a bajo nivel nos da acceso a algunas características del procesador y de la memoria del ordenador, que permiten agilizar los cálculos, es hora de demostrarlo.

Lo primero que se necesita es ver cómo vamos a medir el rendimiento, para ello en C# la opción más sencilla es usar BenchmarkDotNet. Además, es la misma librería que utiliza Microsoft para medir el desempeño de .NET Core y las mejoras que van introduciendo.

Ahora solo nos falta ver contra qué lo vamos a comparar, para ello he creado dos métodos que buscan el mínimo de 8ushorts, uno mediante LINQ y otro iterando sobre el propio array de manera manual:

public ushort MinLINQ(ushort us0, ushort us1, ushort us2,
    ushort us3, ushort us4,ushort us5, ushort us6, ushort us7){
    
    return new ushort[8]{us0,us1,us2,us3,us4,us5,us6,us7}.Min();
}

public ushort MinLoop(ushort us0, ushort us1, ushort us2,
    ushort us3, ushort us4,ushort us5, ushort us6, ushort us7){
    
    ushort min = us0;
    foreach(var value in new ushort[7]{us1,us2,us3,us4,us5,us6,us7}){
        if(value<min)
            min=value;
    }
    return min;
}

Como ya vimos en el artículo, 3 maneras de optimizar tus programas en C#, la implementación vía LINQ es siempre más lenta, y el uso de arrays, es más óptimo que otras estructuras de datos para este fin.

Los resultados de los tiempos de ejecución de los 3 métodos son los siguientes:

MétodoMedia (ns)Error (ns)Desviación estándar (ns)
FindWithMinSIMD
3.6960.02250.0200
FindWithLINQ 182.5433.59254.2767
FindWithLoop29.4900.19200.1796

Con los resultados anteriores, se puede afirmar que el uso de esta instrucción mejora en casi 10 veces, la implementación mediante bucle y es entorno a 60 veces más rápida que la realizada con LINQ. El ejemplo completo se puede encontrar en mi GitHub

Espero que les haya gustado el artículo, si tienen algún comentario, no se olviden de dejarlo.

En el siguiente recurso, en inglés, puedes ver una amplicación de una optimización similar para el problema de la ordenación.

Iconos creados por Freepik para www.flaticon.com licenciados como CC 3.0 BY

The following two tabs change content below.

Jorge Durán

Entusiasta de la tecnología desde los 10 años, desarrollador y creador de varios proyectos de software y autodidacta por naturaleza. Ingeniero Informático por la USAL

3 comentarios en “Programación a bajo nivel con C# y .NET Core 3”

  1. Ian

    Jorge:

    ¿Sabes cómo sumar todos los elementos de un Vector256? Estoy viendo el ejemplo de Tanner Gooding en el blog de Microsoft, y veo que utiliza Ssse3.HorizontalAdd. Pero cuando hago el ejemplo con double y cambio Ssse3 por Avx, veo que cada llamada sólo suma el primero con el segundo, y el tercero con el cuarto. Dos llamadas consecutivas parecen no funcionar. Me imagino que la solución pasará por usar la alternativa con Shuffle del propio post, pero es un poco raro.

    Gracias de antemano.

    Responder
  2. Ian

    ¡Gracias, Jorge! Me ha contestado también Tanner Gooding. Resulta que HorizontalAdd se comporta diferente para Vector128 y Vector256. El primer HorizontalAdd está bien. Pero antes del segundo tienes que mover los registros. Queda algo parecido a esto:

    vresult = Avx.HorizontalAdd(vresult, vresult);
    vresult = Avx2.Permute4x64(vresult, 0b00_10_01_11);
    vresult = Avx.HorizontalAdd(vresult, vresult);
    result = vresult.ToScalar();

    Es parecido, de todos modos, al consejo de StackOverflow. La alternativa es extraer las partes inferiores y superiores como Vector128, y sumarlas entonces. Es más fácil de entender, pero tengo la impresión de que consume más código y tiempo de ejecución.

    Responder

Deja una Respuesta