Spanned es una biblioteca .NET de alto rendimiento y asignación cero que introduce alternativas compatibles con el tramo a los tipos de BCL populares y proporciona soluciones vectorizadas para operaciones comunes en los tramos.
Para comenzar, primero agregue el paquete abarcado a su proyecto. Puede hacer esto ejecutando el siguiente comando:
dotnet add package SpannedAlternativamente, puede instalarlo a través de la consola de Administrador de paquetes con este comando:
Install-Package SpannedTenga en cuenta que .NET ha acumulado numerosas rutas de optimización a lo largo del tiempo, tanto en el marco como en el tiempo de ejecución. En consecuencia, se ha vuelto extremadamente desafiante para el código altamente optimizado vecino para diferentes marcos en una sola base de código. Por lo tanto, tomé una decisión difícil de apoyar solo un marco por versión de esta biblioteca. Puede encontrar una lista de versiones de la biblioteca y las versiones marco compatibles correspondientes en la tabla a continuación:
| .NET Standard 2.0 | .NET Standard 2.1 | .NET 8+ | |
|---|---|---|---|
Si bien puede ser tentador usar v0.0.1 para todas sus necesidades, dado su soporte para .NET Standard 2.0 y la adopción generalizada de .NET Standard 2.0 hoy en día, recomiendo que no lo haga. Prácticamente no hay optimizaciones que uno puede realizar utilizando este marco heredado. Incluso nuestro amado Span , accesible en .NET Standard 2.0 a través del paquete System.Memory Nuget, se conoce como "Slip Span", porque ese Span no es más que un ArraySegment reinventado, que carece de soporte adecuado en el lado de tiempo de ejecución/JIT. Por lo tanto, elija la mejor versión de paquete para su entorno, no la que parezca adaptarse a todos.
Antes de entrar en los detalles, discutamos algunos casos de borde común que pueda encontrar mientras usa esta biblioteca y respondemos la pregunta: "¿Por qué X no es parte de .NET?" En resumen, todo lo que puede encontrar aquí es fácil de usar y fácil de usar mal.
Comencemos con un punto obvio. Esta biblioteca está diseñada específicamente para escenarios en los que luchas con los dientes y las uñas para cada byte asignado y cada nanosegundo del tiempo de ejecución en caminos altamente críticos. No está destinado a ser una solución única para todos para toda su base de código. Incorporar tipos de esta biblioteca en cada escenario concebible puede degradar el rendimiento general de su aplicación, en lugar de mejorarla.
Recuerde, no se sumerja primero en el océano de nano-optimización hasta que esté seguro de que es necesario.
Un error común que debe evitar es pasar cualquiera de los tipos proporcionados por esta biblioteca por valor (sí, esto solo debería ayudarlo a comprender por qué algo como esto no debería y nunca será parte del BCL) . Por ejemplo, si bien el siguiente código puede aparecer bien, en realidad es desastroso:
ValueStringBuilder sb = new ValueStringBuilder ( 16 ) ;
sb . AppendUserName ( user ) ;
// ...
public static void AppendUserName ( this ValueStringBuilder sb , User user )
{
sb . Append ( user . FirstName ) ;
sb . Append ( ' ' ) ;
sb . Append ( user . LastName ) ;
} Agregar a un constructor de cadenas puede ampliar su búfer interno. Sin embargo, desde que pasamos nuestro ValueStringBuilder por valor (es decir, copiamos) , el ValueStringBuilder original no lo sabrá y continuará usando un búfer ya dispuesto.
Si bien este enfoque puede parecer funcionar con entradas desinfectadas durante las pruebas, ocasionalmente fallará, rompiendo no solo su código sino también algunas partes aleatorias del tiempo de ejecución de su aplicación al interferir con un búfer que una copia de ValueStringBuilder ya ha regresado al grupo, por lo que puede ser reutilizado por algo más.
Puede intentar ser inteligente al respecto y abordar el problema reescribiendo el método de extensión problemática de la siguiente manera:
public static void AppendUserName ( this in ValueStringBuilder sb , User user )
{
sb . Append ( user . FirstName ) ;
sb . Append ( ' ' ) ;
sb . Append ( user . LastName ) ;
} Ahora, ValueStringBuilder se pasa por referencia, por lo que no debería haber problemas, ¿verdad? Bueno, no. El modificador in existe para reducir los costos de copiar la totalidad de una instancia de tipo de valor pasando su referencia a un método al tiempo que preserva la semántica como si fuera pasada por valor. Esto significa que las modificaciones estatales del ValueStringBuilder proporcionado no se propagarán a la instancia original. Entonces, todavía tenemos el mismo problema en nuestras manos. La única forma correcta de implementar un método que puede modificar el estado interno de un tipo de valor de la instancia es aprobarlo realmente por referencia:
ValueStringBuilder sb = new ValueStringBuilder ( 16 ) ;
AppendUserName ( ref sb , user ) ;
// ...
public static void AppendUserName ( ref ValueStringBuilder sb , User user )
{
sb . Append ( user . FirstName ) ;
sb . Append ( ' ' ) ;
sb . Append ( user . LastName ) ;
}Si bien no es tan elegante como a algunos les gustaría que fuera, esta solución tiene el beneficio de trabajar realmente.
La mayoría de los tipos proporcionados por esta biblioteca definen un método Dispose() , permitiendo su uso con la palabra clave using , como se puede ver a continuación:
using ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
Foo ( ref sb ) ;
return sb . ToString ( ) ; Sin embargo, esto no significa que deban usarse con la palabra clave using . Es bastante importante recordar cómo se reduce el código anterior:
ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
try
{
Foo ( ref sb ) ;
return sb . ToString ( ) ;
}
finally
{
sb . Dispose ( ) ;
} Crear y administrar regiones protegidas no es gratuita. Teniendo en cuenta que nuestro enfoque en las nano-optimizaciones, el impacto aquí es notable. Por lo tanto, es preferible llamar manualmente Dispose() ::
ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
Foo ( ref sb ) ;
string result = sb . ToString ( ) ;
sb . Dispose ( ) ;
return result ;Alternativamente, verifique si el último método que llama en un tipo dado tiene una sobrecarga que realiza una limpieza automáticamente:
ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
Foo ( ref sb ) ;
return sb . ToString ( dispose : true ) ;En el moderno .NET, es común encontrar el siguiente patrón:
O, expresando el mismo concepto en el código:
const int StackAllocByteLimit = 1024 ;
T [ ] ? spanSource ;
scoped Span < T > span ;
if ( sizeof ( T ) * length > StackAllocByteLimit )
{
spanSource = ArrayPool < T > . Shared . Rent ( length ) ;
span = spanSource . AsSpan ( 0 , length ) ;
}
else
{
spanSource = null ;
span = stackalloc T [ length ] ;
}
DoSomeWorkWithSpan ( span ) ;
if ( spanSource is not null )
{
ArrayPool < T > . Shared . Return ( spanSource ) ;
} No es la pieza de horario más bonita, ¿verdad? La lógica real a menudo termina enterrada por él, que está lejos de ser ideal. Este es el problema exacto que SpanOwner suvopere resolver. Aquí está la misma lógica, pero toda la caldera se ha ocultado detrás del SpanOwner :
SpanOwner < T > owner = SpanOwner < T > . ShouldRent ( length ) ? SpanOwner < T > . Rent ( length ) : stackalloc T [ length ] ;
Span < T > span = owner . Span ;
DoSomeWorkWithSpan ( span ) ;
owner . Dispose ( ) ; Mucho más fácil de escribir, mucho más fácil de leer y, lo más importante, este enfoque proporciona exactamente el mismo rendimiento porque SpanOwner está diseñado para ser completamente inlinable. Puede ser eliminado por completo de su código por JIT:
| Método | Significar | Stddev | Relación | Tamaño del código |
|---|---|---|---|---|
| Sin_spanowner_int32 | 5.134 ns | 0.0425 ns | 1.00 | 315 B |
| With_spanowner_int32 | 4.908 ns | 0.0168 ns | 0.96 | 310 B |
ValueStringBuilder es una reimplementación de StringBuilder diseñada para admitir tampones alocados con pila. Es capaz de utilizar un grupo de matriz compartido para expandir su amortiguador interno cuando sea necesario. ValueStringBuilder es perfecto para construir cuerdas compactas que pueden caber en la pila; Sin embargo, no debe usarse para nada más, porque las operaciones en cadenas más grandes pueden y degradarán el rendimiento general de su aplicación. El verdadero brillantez de ValueStringBuilder emerge cuando necesita crear una secuencia de caracteres corta que no sea necesario materializarse como una cadena en absoluto.
ValueStringBuilder refleja todas las características de StringBuilder y pasa con éxito el mismo conjunto de pruebas unitarias, lo que le permite servir sin problemas como un reemplazo de entrega en la mayoría de los escenarios.
// Note that providing a capacity instead of a buffer will force
// the builder to rent an array from `ArrayPool<char>.Shared`.
ValueStringBuilder sb = new ( stackalloc char [ 256 ] ) ;
// `ValueStringBuilder` provides a custom interpolated string handler,
// ensuring such operations do not allocate any new strings.
sb . Append ( $ "Hello, { user . Name } ! Your ID is: { user . id } " ) ;
// Unlike `StringBuilder`, `ValueStringBuilder` can be represented
// as a readonly span. Thus, you don't need to actually materialize
// the string you've built in lots of cases.
DisplayWelcome ( ( ReadOnlySpan < char > ) sb ) ;
// Remember to dispose of the builder to return
// a rented buffer, if any, back to the pool.
sb . Dispose ( ) ; ValueList<T> es una reimplementación de List<T> diseñada para admitir buffers alocados con pila. Es capaz de utilizar un grupo de matriz compartido para expandir su amortiguador interno cuando sea necesario. ValueList<T> es perfecto para procesar pequeñas cantidades de datos que pueden caber en la pila; Sin embargo, no debe usarse para nada más, porque las operaciones en conjuntos de datos más grandes pueden y degradarán el rendimiento general de su aplicación.
ValueList<T> refleja todas las características de List<T> y pasa con éxito el mismo conjunto de pruebas unitarias, lo que le permite servir sin problemas como un reemplazo de entrega en la mayoría de los escenarios.
// Note that providing a capacity instead of a buffer will force
// the list to rent an array from `ArrayPool<T>.Shared`.
ValueList < int > list = new ( stackalloc int [ 10 ] ) ;
list . Add ( 0 ) ;
list . Add ( 1 ) ;
list . Add ( 2 ) ;
DoSomethingWithIntegers ( ( ReadOnlySpan < int > ) list ) ;
// Remember to dispose of the list to return
// a rented buffer, if any, back to the pool.
list . Dispose ( ) ; ValueStack<T> es una reimplementación de Stack<T> diseñada para admitir tampones alocados con pila. Es capaz de utilizar un grupo de matriz compartido para expandir su amortiguador interno cuando sea necesario. ValueStack<T> es perfecto para procesar pequeñas cantidades de datos que pueden caber en la pila; Sin embargo, no debe usarse para nada más, porque las operaciones en conjuntos de datos más grandes pueden y degradarán el rendimiento general de su aplicación.
ValueStack<T> refleja todas las características de Stack<T> y pasa con éxito el mismo conjunto de pruebas unitarias, lo que le permite servir sin problemas como un reemplazo de entrega en la mayoría de los escenarios.
// Note that providing a capacity instead of a buffer will force
// the stack to rent an array from `ArrayPool<T>.Shared`.
ValueStack < int > stack = new ( stackalloc int [ 10 ] ) ;
stack . Push ( 0 ) ;
stack . Push ( 1 ) ;
stack . Push ( 2 ) ;
DoSomethingWithIntegers ( ( ReadOnlySpan < int > ) stack ) ;
// Remember to dispose of the stack to return
// a rented buffer, if any, back to the pool.
stack . Dispose ( ) ; ValueQueue<T> es una reimplementación de Queue<T> diseñada para admitir tampones alocados con pila. Es capaz de utilizar un grupo de matriz compartido para expandir su amortiguador interno cuando sea necesario. ValueQueue<T> es perfecto para procesar pequeñas cantidades de datos que pueden caber en la pila; Sin embargo, no debe usarse para nada más, porque las operaciones en conjuntos de datos más grandes pueden y degradarán el rendimiento general de su aplicación.
ValueQueue<T> refleja todas las características de Queue<T> y pasa con éxito el mismo conjunto de pruebas unitarias, lo que le permite servir sin problemas como un reemplazo de entrega en la mayoría de los escenarios.
// Note that providing a capacity instead of a buffer will force
// the queue to rent an array from `ArrayPool<T>.Shared`.
ValueQueue < int > queue = new ( stackalloc int [ 10 ] ) ;
queue . Enqueue ( 0 ) ;
queue . Enqueue ( 1 ) ;
queue . Enqueue ( 2 ) ;
DoSomethingWithIntegers ( ( ReadOnlySpan < int > ) queue ) ;
// Remember to dispose of the queue to return
// a rented buffer, if any, back to the pool.
queue . Dispose ( ) ; ValueSet<T> es una reimplementación de HashSet<T> diseñada para admitir tampones alocados con pila. Es capaz de utilizar un grupo de matriz compartido para expandir su amortiguador interno cuando sea necesario. ValueSet<T> es perfecto para procesar pequeñas cantidades de datos que pueden caber en la pila; Sin embargo, no debe usarse para nada más, porque las operaciones en conjuntos de datos más grandes pueden y degradarán el rendimiento general de su aplicación.
ValueSet<T> refleja todas las características del HashSet<T> y pasa con éxito el mismo conjunto de pruebas unitarias, lo que le permite servir a la perfección como reemplazo de entrega en la mayoría de los escenarios.
// Note that providing a capacity instead of a buffer will force
// the set to rent an array from `ArrayPool<T>.Shared`.
ValueSet < int > set = new ( stackalloc int [ 10 ] ) ;
set . Add ( 0 ) ;
set . Add ( 1 ) ;
set . Add ( 2 ) ;
DoSomethingWithIntegers ( ( ReadOnlySpan < int > ) set ) ;
// Remember to dispose of the set to return
// a rented buffer, if any, back to the pool.
set . Dispose ( ) ; ValueDictionary<TKey, TValue> es una reimplementación de Dictionary<TKey, TValue> diseñado para admitir buffers alocados con pila. Es capaz de utilizar un grupo de matriz compartido para expandir su amortiguador interno cuando sea necesario. ValueDictionary<TKey, TValue> es perfecto para procesar pequeñas cantidades de datos que pueden caber en la pila; Sin embargo, no debe usarse para nada más, porque las operaciones en conjuntos de datos más grandes pueden y degradarán el rendimiento general de su aplicación.
ValueDictionary<TKey, TValue> refleja todas las características del Dictionary<TKey, TValue> y pasa con éxito el mismo conjunto de pruebas unitarias, lo que le permite servir sin problemas como un reemplazo de entrega en la mayoría de los escenarios.
// Note that providing a capacity instead of a buffer will force
// the dictionary to rent an array from `ArrayPool<T>.Shared`.
ValueDictionary < int , string > dictionary = new ( 10 ) ;
dictionary . Add ( 0 , "zero" ) ;
dictionary . Add ( 1 , "one" ) ;
dictionary . Add ( 2 , "two" ) ;
DoSomethingWithPairs ( ( ReadOnlySpan < KeyValuePair < int , string > > ) dictionary ) ;
// Remember to dispose of the dictionary to return
// a rented buffer, if any, back to the pool.
dictionary . Dispose ( ) ; .Min() y .Max() son métodos de extensión que pueden ayudarlo a encontrar el valor mínimo/máximo en un tramo. Están vectorizados para todos los tipos compatibles, a diferencia de Enumerable.Min() y Enumerable.Max() , que no proporciona optimización para los números de punto flotante.
Sin embargo, hay un pequeño problema con los números de punto flotante (es decir, float y double ), y el nombre de este problema es NaN . Como sabrán, NaN no es más grande ni menor que en ningún número, y no es igual a ningún número, ni siquiera a sí mismo. Por lo tanto, si una NaN está presente en la secuencia proporcionada, puede interrumpir una implementación ingenua que se basa únicamente en el resultado de operaciones de comparación regulares. Por lo tanto, si una NaN está presente en la secuencia proporcionada, puede interrumpir una implementación ingenua que se basa únicamente en el resultado de operaciones de comparación regulares. Por lo tanto, no tener en cuenta esta ruina de las comparaciones de puntos flotantes no es una opción.
Spanned se las arregla para emplear todos los controles relacionados con NaN de una manera altamente eficiente, proporcionando un aumento significativo de rendimiento sobre las soluciones no optimizadas. Sin embargo, el rendimiento podría ser aún mejor si no tuviéramos que dar cuenta de NaN S. Es por eso que existen .UnsafeMin() y .UnsafeMax() . Estos métodos son específicos para los tramos que contienen números de punto flotante, y realizan operaciones de comparación sin reconocer la existencia de NaN , eliminando todos los controles relacionados. Entonces, si está absolutamente seguro de que se desinfecta un tramo de números de punto flotante y no puede contener ningún NaN s, puede exprimir aún más el rendimiento de las operaciones .Min() y .Max() .
Mientras que la diferencia entre .Min() y .UnsafeMin() puede no ser muy notable:
| Método | Significar | Stddev | Relación | Tamaño del código |
|---|---|---|---|---|
| Min_loop_single | 3,919.5 ns | 15.75 ns | 1.00 | 207 b |
| Min_linq_single | 4,030.3 ns | 37.38 ns | 1.03 | 570 b |
| Min_span_single | 611.1 ns | 8.55 ns | 0.16 | 534 b |
| Unsfemin_span_single | 569.0 ns | 1.82 ns | 0.15 | 319 b |
La brecha de rendimiento se vuelve bastante sustancial entre .Max() y .UnsafeMax() :
| Método | Significar | Stddev | Relación | Tamaño del código |
|---|---|---|---|---|
| Max_loop_single | 3,849.2 ns | 36.97 ns | 1.00 | 215 B |
| Max_linq_single | 3,936.4 ns | 53.51 ns | 1.02 | 643 b |
| Max_span_single | 901.7 ns | 7.12 ns | 0.23 | 606 B |
| Unsafemax_span_single | 551.8 ns | 3.06 ns | 0.14 | 321 b |
.Sum() es un método de extensión que puede ayudarlo a calcular la suma de todos los valores en un tramo. Está vectorizado para todos los tipos compatibles, a diferencia de Enumerable.Sum() , que no solo carece de vectorización, sino que no proporciona sobrecargas para la mayoría de los tipos numéricos de la caja.
Similar a .Min() y .Max() , .Sum() tiene el gemelo malvado que se llama .UnsafeSum() . El método base lanzará una OverflowException si el cálculo de suma da como resultado un desbordamiento/subflujo entero. Los guardias de desbordamiento, por supuesto, tienen un costo, y no es insignificante. Por lo tanto, si su aporte se desinfecta y no puede causar un desbordamiento, o si el desbordamiento entero es el comportamiento esperado en su contexto de trabajo, no dude en usar .UnsafeSum() . Es dos veces más rápido que .Sum() , 34 veces más rápido que calcular la suma dentro de un bucle, e informal 130 veces más rápido que calcular la suma a través de Linq:
| Método | Significar | Stddev | Relación | Tamaño del código |
|---|---|---|---|---|
| Sum_loop_int16 | 3,820.0 ns | 7.04 ns | 1.00 | 128 B |
| Sum_linq_int16 | 14,472.6 ns | 281.83 ns | 3.80 | 732 b |
| Sum_span_int16 | 214.6 ns | 2.43 ns | 0.06 | 413 B |
| Unsafesum_span_int16 | 111.8 ns | 1.00 ns | 0.03 | 200 B |
.LongSum() es un método de extensión que puede ayudarlo a calcular la suma de todos los valores en un tramo utilizando un acumulador de 64 bits (es decir, long enteros firmados, ulong para integers sin firmar y double para float ) , capaz de almacenar un resultado más grande que el valor máximo/mínimo del tipo original (eg, no se puede almacenar el resultado matemático correcto de int.MaxValue + int.MaxValue escriba int ) . Está vectorizado para todos los tipos compatibles y no tiene alternativas adecuadas en LINQ (por lo tanto, el punto de referencia a continuación es un poco injusto) .
.LongSum() no tiene una contraparte "insegura", porque incluso el mayor tramo posible que almacena los elementos int.MaxValue de tipo int no pueden causar un desbordamiento de un acumulador de 64 bits ( (long)int.MaxValue * (long)int.MaxValue < long.MaxValue ).
| Método | Significar | Stddev | Relación | Tamaño del código |
|---|---|---|---|---|
| Longsum_loop_int16 | 2,537.1 ns | 21.30 ns | 1.00 | 98 B |
| Longsum_linq_int16 | 14,372.0 ns | 130.00 ns | 5.67 | 734 b |
| Longsum_span_int16 | 251.0 ns | 2.38 ns | 0.10 | 390 b |
.Average() es un método de extensión que puede ayudarlo a calcular el promedio de todos los valores en un tramo. Está vectorizado para todos los tipos compatibles, a diferencia de Enumerable.Average() , que solo proporciona cierto nivel de optimización para enteros firmados de 32 bits (es decir, int s).
Debajo del capó, .Average() usa .LongSum() para calcular la suma de todos los elementos mientras evita los desbordamientos enteros. Sin embargo, si su entrada se desinfecta y no puede causar una, puede cambiar a .UnsafeAverage() , que usa .UnsafeSum() y no pasa el precioso tiempo de ejecución en los guardias de desbordamiento.
| Método | Significar | Stddev | Relación | Tamaño del código |
|---|---|---|---|---|
| Promedio_loop_int16 | 2,482.1 ns | 20.04 ns | 1.00 | 241 B |
| Promedio_linq_int16 | 13,198.2 ns | 97.67 ns | 5.31 | 1.016 b |
| Promedio_span_int16 | 257.8 ns | 3.61 ns | 0.10 | 593 b |
| Unsafeaverage_span_int16 | 116.7 ns | 1.27 ns | 0.05 | 128 B |
.FillSequential() es un método de extensión que puede ayudarlo a llenar un tramo dado con valores numéricos secuenciales. Está vectorizado para todos los tipos compatibles y no tiene alternativas en LINQ.
| Método | Significar | Stddev | Relación | Tamaño del código |
|---|---|---|---|---|
| Relleno_loop_int16 | 2,499.4 ns | 28.47 ns | 1.00 | 118 B |
| Relleno_span_int16 | 169.2 ns | 0.18 ns | 0.07 | 660 b |
.IndexOf() , .LastIndexOf() y .Contains() pueden parecerle familiares, porque estos métodos son proporcionados por MemoryExtensions . Sin embargo, hay dos problemas con ellos:
IEquatable<T> , lo que los hace inaccesibles en el contexto genérico no unido.IEqualityComparer<T> .Las implementaciones de estos métodos proporcionados por esta biblioteca abordan ambos problemas.
Licenciado bajo los términos de la licencia MIT.