“Los Baños de Doña María de Padilla” por aalmada

Cómo usar Span y Memoria

(Actualizado a la versión de lanzamiento oficial de .NET Core 2.1)

Introducción

Span y Memory son nuevas características en .NET Core 2.1 que permiten una administración fuertemente tipada de memoria contigua, independientemente de cómo se asignó. Esto permite un mantenimiento más fácil del código y mejora enormemente el rendimiento de las aplicaciones al reducir la cantidad de asignaciones de memoria y copias requeridas.

Por razones que otros pueden explicar mucho mejor, Span solo puede existir en la pila (en lugar de existir en el montón). Esto significa que no puede ser un campo en una clase o en una estructura "encuadrada" (convertible a un tipo de referencia). Span aprovecha la estructura de ref, una nueva característica en C # 7.2, que hace que el compilador aplique esta regla.

A continuación, voy a crear algunos escenarios de uso para que pueda comprender mejor cuándo y cómo usar cada una de estas nuevas funciones.

Variables locales

Imaginemos que tenemos un servicio que devuelve una colección de objetos de tipo Foo. La colección proviene de una ubicación remota, por lo que llega a través de una secuencia de bytes. Esto significa que necesitamos obtener una porción de bytes en un búfer local, convertirlos en nuestros objetos y repetir este proceso hasta el final de la colección.

El siguiente ejemplo recorre toda la colección y calcula la suma de los valores en el campo Entero de cada elemento.

Observe en la línea 5 que el búfer se asigna como una matriz pero se almacena como una variable local de tipo Span . El marco incluye operadores implícitos que permiten esta conversión.

Las variables locales en métodos regulares (que no usan asíncrono, rendimiento o lambdas) se asignan en la pila. Esto significa que no hay problema al usar Span . La variable persistirá mientras su propio alcance, en este caso, la función misma.

Si verifica la firma del método Stream.Read (), notará que acepta un argumento de tipo Span . Esto generalmente significa que tendríamos que copiar memoria. No con Span . Mientras T sea un tipo de valor, como es el caso, puede usar el método MemoryMarshal.Cast () que enmascara el búfer como otro tipo sin requerir ninguna copia. Pase el Span a Stream.Read () (líneas 8 y 15) pero lea su contenido como una colección de Foo usando el Span (línea 13). Puede acceder al Span con el operador de corchetes o con un bucle foreach.

Debido a que, al final de la enumeración, el número de elementos leídos puede ser menor que el tamaño del búfer, iteramos en un segmento del búfer original. Slice () es un método que devuelve otro Span para el mismo búfer pero con límites diferentes.

Tenga en cuenta que, además de Stream.Read (), no hay copias de memoria. Solo enmascaramientos del mismo búfer. Esto da como resultado importantes mejoras de rendimiento en relación con los administradores de memoria que teníamos antes. Todo esto con seguridad tipo y código fácil de mantener.

stackalloc

Observe en el ejemplo anterior que el Span tiene que residir en la pila pero no su contenido. La matriz se asigna en el montón.

Debido a que, en este caso, el búfer no tiene que sobrevivir a la función y es relativamente pequeño, podemos asignar el búfer en la pila usando la palabra clave stackalloc.

Además de la asignación del búfer, todo el código permanece sin cambios. Esta es una ventaja más de usar Span . Resume cómo se asignó la memoria contigua.

estructura de ref

Estos ejemplos anteriores funcionan bien, pero ¿qué sucede si desea realizar varias operaciones en la colección? Tendría que replicar este código en muchos otros lugares, creando una pesadilla de mantenimiento. ¿Qué pasaría si pudiéramos usar un bucle foreach? Solo necesitamos crear una estructura, que se asigna en la pila, que implementa IEnumerator .

Desafortunadamente, cualquier tipo de valor que implemente una interfaz es "box-able", lo que significa que puede convertirse en un tipo de referencia.

Afortunadamente, foreach realmente no requiere la implementación de interfaces. Solo requiere la implementación de un método GetEnumerator () que devuelve una instancia de un objeto que implementa una propiedad de solo lectura Current y un método MoveNext (). Así es como se implementa la enumeración de Span . Podemos hacer lo mismo para nuestra colección.

Observe en las líneas 17 y 18 que los tramos no son variables locales sino campos de la estructura Enumerator. Para asegurarse de que este objeto solo se crea en la pila, observe en la línea 12 que se declara como una estructura de referencia. Sin esto, el compilador mostraría un error.

La creación de los tramos ahora está en el constructor Enumerator pero es muy similar al primer ejemplo (que yo sepa, no es posible usar stackalloc en este caso). La enumeración ahora se divide en Current y MoveNext (). foreach llama al método MoveNext () para pasar al siguiente elemento de las colecciones y luego llama a Current para obtenerlo.

Tenga en cuenta que Current devuelve una referencia de solo lectura de tipo Foo. Esto significa que accede al elemento sin copiarlo. Esta también es una característica de C # 7.2 que se puede utilizar para mejorar considerablemente el rendimiento de las aplicaciones.

Tipos de valor

El código anterior permite el uso de foreach, pero si también desea permitir el uso de LINQ, no hay escapatoria para implementar interfaces.

Observe en la línea 19 que el búfer todavía está almacenado como un campo pero ahora del tipo Memoria .

La memoria es una fábrica de Span que puede residir en el montón. Tiene una propiedad Span que crea una nueva instancia de Span válida en el ámbito que se llama. Se usa en las líneas 33 y 45.

El enumerador no puede ser una estructura de referencia ahora ya que implementa una interfaz. Lo dejo como una estructura, ya que funciona mejor en este caso. Llamar a un método de interfaz tiene una penalización de rendimiento porque es una llamada virtual. Las estructuras no permiten la herencia, por lo que .NET puede optimizar estas llamadas haciéndolas un poco más rápidas.

La propiedad Current ahora devuelve Foo en lugar de una referencia, lo que significa que hay una copia de memoria. Puede agregar una sobrecarga que devuelva una referencia pero, cualquier llamada que use IEnumerator , usará explícitamente la otra.

Actuación

¿Cómo funcionan estos ejemplos? BenchmarkDotNet hace que sea muy simple comparar el rendimiento de todos estos escenarios.

El código para estos puntos de referencia se puede encontrar en https://github.com/aalmada/SpanSample/blob/master/SpanSample/EnumerationBenchmarks.cs

Para los puntos de referencia, amplié el primer ejemplo en 3 opciones de iteración en el búfer Span <>: usando un foreach, usando GetEnumerator () y usando un bucle for con el operador indexador. Es interesante ver que el foreach tiene el mismo rendimiento pero tiene el uso de GetEnumerator () el doble de lento.

Usar el bucle for con el buffer asignado usando stackalloc es el más eficiente. Lleva el menor tiempo (1,6 ms) y no asigna memoria en el montón. Se establece como el punto de referencia de referencia para facilitar la comparación.

El uso de un enumerador de estructura de referencia es más lento con 4.2 ms (2.67 veces más lento que la enumeración de stackalloc sin procesar) pero con código reutilizable y más fácil de mantener. Esta es la penalización por dividir la lógica de enumeración en dos funciones.

El uso de IEnumerator lo hace sustancialmente más lento con 24.0 ms (15.11 veces más lento que la enumeración de stackalloc sin procesar). Este caso tiene la misma penalización que la anterior, además, el uso de interfaces, no devuelve el valor por referencia y no tiene un solo Span <> para toda la enumeración.

Aunque no se muestra aquí, cualquiera de estas soluciones es mucho más rápida que sin el uso de Span . Estos valores muestran que debe considerar varios escenarios de enumeración en sus aplicaciones, dependiendo de si favorece la flexibilidad o el rendimiento. Si usted es un desarrollador de API, debe exponer todo esto para que el usuario pueda hacer su propia elección.

Conclusión

Span y Memory son nuevas características que pueden reducir drásticamente las copias de memoria en aplicaciones .NET, permitiendo mejoras de rendimiento sin sacrificar la seguridad del tipo y la legibilidad del código.

Puede descargar el código fuente de este artículo y ejecutar los puntos de referencia en su propio sistema.

Más información

Planeo escribir algunos artículos más sobre este tema, pero puede encontrar mucha más información en estos enlaces:

  • Bienvenido a C # 7.2 y Span por Mads Torgersen
  • C # - Todo sobre Span: Explorando un nuevo .NET Mainstay por Stephen Toub
  • Span por Adam Sitnik
  • C # 7.2: Comprensión del alcance por Jared Parsons
  • Especificaciones de Span por Krzysztof Cwalina et al.
  • Agregue API iniciales basadas en Span / Buffer en corefx por Stephen Toub et al.