Spanned est une bibliothèque .NET à allocation zéro performance haute performance qui introduit des alternatives compatibles Span aux types BCL populaires et fournit des solutions vectorielles pour des opérations communes sur des portées.
Pour commencer, ajoutez d'abord le package couvert à votre projet. Vous pouvez le faire en exécutant la commande suivante:
dotnet add package SpannedAlternativement, vous pouvez l'installer via la console du gestionnaire de package avec cette commande:
Install-Package SpannedNotez que .NET a accumulé de nombreux routes d'optimisation au fil du temps, dépendant du cadre et de l'exécution. Par conséquent, il est devenu extrêmement difficile pour le code très optimisé voisin pour différents cadres dans une seule base de code. Par conséquent, j'ai pris une décision difficile pour ne soutenir qu'un seul cadre par version de cette bibliothèque. Vous pouvez trouver une liste des versions de bibliothèque et les versions de framework pris en charge correspondantes dans le tableau ci-dessous:
| .NET Standard 2.0 | .NET Standard 2.1 | .Net 8+ | |
|---|---|---|---|
Bien qu'il puisse être tentant d'utiliser v0.0.1 pour tous vos besoins, étant donné son support pour .NET Standard 2.0 et l'adoption généralisée de .NET Standard 2.0 de nos jours, je recommande fortement de le faire. Il n'y a pratiquement pas d'optimisations que l'on peut effectuer en utilisant ce framework hérité. Même notre Span bien-aimée, accessible dans .NET Standard 2.0 via le package System.Memory NuGet, est connu sous le nom de "Slow Span", car cette Span n'est rien de plus qu'un réinventé ArraySegment , manquant de support approprié du côté d'exécution / jit. Par conséquent, veuillez choisir la meilleure version de package pour votre environnement, pas celle qui semble les adapter à tous.
Avant d'entrer dans les détails, discutons de certains cas de bord commun que vous pouvez rencontrer lors de l'utilisation de cette bibliothèque et répondons à la question: "Pourquoi ne fait pas partie de .NET?" En bref, tout ce que vous pouvez trouver ici est à la fois facile à utiliser et facile à abuser.
Commençons par un point évident. Cette bibliothèque est conçue spécifiquement pour les scénarios où vous combattez des dents et des ongles pour chaque octet alloué et chaque nanoseconde du temps d'exécution dans des chemins très critiques. Ce n'est pas censé être une solution unique pour toute votre base de code. L'incorporation de types de cette bibliothèque dans tous les scénarios imaginables peut dégrader les performances globales de votre application, plutôt que de l'améliorer.
N'oubliez pas, ne plongez pas la tête d'abord dans l'océan nano-optimisation jusqu'à ce que vous soyez sûr que cela soit nécessaire.
Une erreur courante à éviter est de passer l'un des types fournis par cette bibliothèque par valeur (oui, cela seul devrait vous aider à comprendre pourquoi quelque chose comme ça ne devrait pas et ne fera jamais partie du BCL) . Par exemple, bien que le code suivant puisse sembler bien, il est en fait désastreux:
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 ) ;
} L'ajout d'un constructeur de cordes peut agrandir son tampon interne. Cependant, puisque nous avons dépassé notre ValueStringBuilder par valeur (c'est-à-dire l'a copié) , le ValueStringBuilder d'origine n'en sera pas conscient et continuera à utiliser un tampon déjà disposé.
Bien que cette approche puisse sembler fonctionner avec des entrées désinfectées pendant les tests, il échouera parfois, cassant non seulement votre code, mais aussi certaines parties aléatoires de l'exécution de votre application en interférant avec un tampon qu'une copie de ValueStringBuilder est déjà retournée au pool, afin qu'elle puisse être réutilisée par quelque chose d'autre.
Vous pouvez essayer d'être intelligent à ce sujet et résoudre le problème en réécrivant la méthode d'extension problématique comme suit:
public static void AppendUserName ( this in ValueStringBuilder sb , User user )
{
sb . Append ( user . FirstName ) ;
sb . Append ( ' ' ) ;
sb . Append ( user . LastName ) ;
} Maintenant, ValueStringBuilder est passé par référence, donc il ne devrait y avoir aucun problème, non? Eh bien, non. Le modificateur in modificateur existe pour réduire les coûts de copie de l'intégralité d'une instance de type de valeur en transmettant sa référence à une méthode tout en préservant la sémantique comme si elle avait été transmise par valeur. Cela signifie que toutes les modifications d'état du ValueStringBuilder fourni ne seront pas propagées à l'instance d'origine. Donc, nous avons toujours le même problème entre nos mains. La seule façon correcte d'implémenter une méthode qui peut modifier l'état interne d'une instance de type de valeur consiste à la passer réellement par référence:
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 ) ;
}Bien que ce ne soit pas aussi sophistiqué que certains le souhaiteraient, cette solution a l'avantage de fonctionner réellement.
La plupart des types fournis par cette bibliothèque définissent une méthode Dispose() , permettant leur utilisation avec le mot-clé using , comme on peut le voir ci-dessous:
using ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
Foo ( ref sb ) ;
return sb . ToString ( ) ; Cependant, cela ne signifie pas qu'ils doivent être utilisés avec le mot-clé using . Il est assez important de se rappeler comment le code ci-dessus est réellement abaissé:
ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
try
{
Foo ( ref sb ) ;
return sb . ToString ( ) ;
}
finally
{
sb . Dispose ( ) ;
} La création et la gestion des régions protégées ne sont pas gratuites. Compte tenu de notre concentration sur les nano-optimisations, l'impact ici est perceptible. Par conséquent, il est préférable d'appeler manuellement Dispose() :
ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
Foo ( ref sb ) ;
string result = sb . ToString ( ) ;
sb . Dispose ( ) ;
return result ;Alternativement, vérifiez si la dernière méthode que vous appelez sur un type donné a une surcharge qui effectue automatiquement le nettoyage:
ValueStringBuilder sb = new ValueStringBuilder ( stackalloc char [ 16 ] ) ;
Foo ( ref sb ) ;
return sb . ToString ( dispose : true ) ;Dans .NET moderne, il est courant de rencontrer le modèle suivant:
Ou, exprimant le même concept dans le code:
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 ) ;
} Pas le plus joli morceau de passe-partout, n'est-ce pas? La logique réelle finit souvent par enterre par elle, ce qui est loin d'être idéal. Il s'agit du problème exact SpanOwner vise à résoudre. Voici la même logique, mais toute la baillue a été cachée derrière la SpanOwner :
SpanOwner < T > owner = SpanOwner < T > . ShouldRent ( length ) ? SpanOwner < T > . Rent ( length ) : stackalloc T [ length ] ;
Span < T > span = owner . Span ;
DoSomeWorkWithSpan ( span ) ;
owner . Dispose ( ) ; Beaucoup plus facile à écrire, beaucoup plus facile à lire et, surtout, cette approche offre exactement la même performance, car SpanOwner est conçu pour être entièrement inlinable. Il peut être complètement éliminé de votre code par JIT:
| Méthode | Signifier | Stddev | Rapport | Taille du code |
|---|---|---|---|---|
| Sans_spanowner_int32 | 5.134 ns | 0,0425 ns | 1,00 | 315 b |
| Avec_spanowner_int32 | 4.908 ns | 0,0168 ns | 0,96 | 310 b |
ValueStringBuilder est une réimplémentation de StringBuilder conçue pour prendre en charge les tampons alloués par la pile. Il est capable d'utiliser un pool de tableau partagé pour étendre son tampon interne si nécessaire. ValueStringBuilder est parfait pour construire des chaînes compactes qui peuvent s'adapter à la pile; Cependant, il ne devrait pas être utilisé pour autre chose, car les opérations sur des chaînes plus grandes peuvent dégrader les performances globales de votre application. La véritable brillance de ValueStringBuilder émerge lorsque vous devez créer une courte séquence de caractères qui n'a pas du tout besoin d'être matérialisée en tant que chaîne.
ValueStringBuilder reflète toutes les fonctionnalités de StringBuilder et passe avec succès le même ensemble de tests unitaires, ce qui lui permet de remplacer de manière transparente dans la plupart des scénarios.
// 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> est une réimplémentation de List<T> conçue pour prendre en charge les tampons alloués par la pile. Il est capable d'utiliser un pool de tableau partagé pour étendre son tampon interne si nécessaire. ValueList<T> est parfait pour traiter de petites quantités de données qui peuvent s'adapter à la pile; Cependant, il ne devrait pas être utilisé pour autre chose, car les opérations sur des ensembles de données plus importantes peuvent dégrader les performances globales de votre application.
ValueList<T> reflète toutes les fonctionnalités de List<T> et passe avec succès le même ensemble de tests unitaires, ce qui lui permet de remplacer de manière transparente dans la plupart des scénarios.
// 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> est une réimplémentation de Stack<T> conçue pour prendre en charge les tampons alloués par la pile. Il est capable d'utiliser un pool de tableau partagé pour étendre son tampon interne si nécessaire. ValueStack<T> est parfait pour le traitement de petites quantités de données qui peuvent s'adapter à la pile; Cependant, il ne devrait pas être utilisé pour autre chose, car les opérations sur des ensembles de données plus importantes peuvent dégrader les performances globales de votre application.
ValueStack<T> reflète toutes les fonctionnalités de Stack<T> et passe avec succès le même ensemble de tests unitaires, ce qui lui permet de remplacer de manière transparente dans la plupart des scénarios.
// 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> est une réimplémentation de Queue<T> conçue pour prendre en charge les tampons alloués par la pile. Il est capable d'utiliser un pool de tableau partagé pour étendre son tampon interne si nécessaire. ValueQueue<T> est parfait pour le traitement de petites quantités de données qui peuvent s'adapter à la pile; Cependant, il ne devrait pas être utilisé pour autre chose, car les opérations sur des ensembles de données plus importantes peuvent dégrader les performances globales de votre application.
ValueQueue<T> reflète toutes les fonctionnalités de Queue<T> et passe avec succès le même ensemble de tests unitaires, ce qui lui permet de remplacer de manière transparente dans la plupart des scénarios.
// 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> est une réimplémentation de HashSet<T> conçu pour prendre en charge les tampons alloués par la pile. Il est capable d'utiliser un pool de tableau partagé pour étendre son tampon interne si nécessaire. ValueSet<T> est parfait pour traiter de petites quantités de données qui peuvent s'adapter à la pile; Cependant, il ne devrait pas être utilisé pour autre chose, car les opérations sur des ensembles de données plus importantes peuvent dégrader les performances globales de votre application.
ValueSet<T> reflète toutes les fonctionnalités de HashSet<T> et passe avec succès le même ensemble de tests unitaires, ce qui lui permet de remplacer de manière transparente dans la plupart des scénarios.
// 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> est une réimplémentation de Dictionary<TKey, TValue> conçu pour prendre en charge les tampons alloués par la pile. Il est capable d'utiliser un pool de tableau partagé pour étendre son tampon interne si nécessaire. ValueDictionary<TKey, TValue> est parfait pour traiter de petites quantités de données qui peuvent s'adapter à la pile; Cependant, il ne devrait pas être utilisé pour autre chose, car les opérations sur des ensembles de données plus importantes peuvent dégrader les performances globales de votre application.
ValueDictionary<TKey, TValue> reflète toutes les fonctionnalités de Dictionary<TKey, TValue> et passe avec succès le même ensemble de tests unitaires, ce qui lui permet de remplacer de manière transparente dans la plupart des scénarios.
// 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() et .Max() sont des méthodes d'extension qui peuvent vous aider à trouver la valeur minimale / maximale en une durée. Ils sont vectorisés pour tous les types pris en charge, contrairement à Enumerable.Min() et Enumerable.Max() , qui ne fournissent aucune optimisation pour les nombres à virgule flottante.
Cependant, il y a un léger problème avec les numéros à virgule flottante (c'est-à-dire float et double ), et le nom de ce problème est NaN . Comme vous le savez peut-être, NaN n'est ni supérieur ni inférieur à un nombre, et il n'est pas égal à un nombre, même pour lui-même. Ainsi, si une NaN est présente dans la séquence fournie, elle peut perturber une implémentation naïve qui repose uniquement sur le résultat des opérations de comparaison régulières. Ainsi, si une NaN est présente dans la séquence fournie, elle peut perturber une implémentation naïve qui repose uniquement sur le résultat des opérations de comparaison régulières. Par conséquent, ne pas tenir compte de ce fléau de comparaisons à virgule flottante n'est pas une option.
Spanned parvient à utiliser tous les contrôles liés à NaN de manière très efficace, fournissant une augmentation significative des performances par rapport aux solutions non optimisées. Cependant, la performance pourrait être encore meilleure si nous n'avions pas besoin de tenir compte des NaN . C'est pourquoi .UnsafeMin() et .UnsafeMax() existent. Ces méthodes sont spécifiques aux portées contenant des nombres à virgule flottante, et ils effectuent des opérations de comparaison sans reconnaître l'existence de NaN , éliminant toutes les vérifications connexes. Donc, si vous êtes absolument sûr qu'une durée de numéros à virgule flottante est désinfectée et ne peut contenir aucune NaN , vous pouvez serrer encore plus de performances sur .Min() et .Max() .
Alors que la différence entre .Min() et .UnsafeMin() peut ne pas être très visible:
| Méthode | Signifier | Stddev | Rapport | Taille du code |
|---|---|---|---|---|
| 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 |
| Dangemin_span_single | 569.0 ns | 1,82 ns | 0,15 | 319 b |
L'écart de performance devient assez substantiel entre .Max() et .UnsafeMax() :
| Méthode | Signifier | Stddev | Rapport | Taille du code |
|---|---|---|---|---|
| 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 |
| Dangemax_span_single | 551,8 ns | 3.06 ns | 0,14 | 321 b |
.Sum() est une méthode d'extension qui peut vous aider à calculer la somme de toutes les valeurs en unproche. Il est vectorisé pour tous les types pris en charge, contrairement à Enumerable.Sum() , qui non seulement manque de vectorisation, mais ne fournit pas du tout des surcharges pour la plupart des types numériques.
Similaire à .Min() et .Max() , .Sum() a le jumeau maléfique qui porte le nom .UnsafeSum() . La méthode de base lancera une OverflowException si le calcul de somme entraîne un débordement / sous-flux entier. Les gardes de débordement, bien sûr, ont un coût, et ce n'est pas négligeable. Par conséquent, si votre entrée est désinfectée et ne peut pas provoquer de débordement, ou si le débordement entier est le comportement attendu dans votre contexte de travail, n'hésitez pas à utiliser .UnsafeSum() . Il est deux fois plus rapide que .Sum() , 34 fois plus rapide que de calculer la somme dans une boucle et de 130 fois plus rapide que de calculer la somme via LINQ:
| Méthode | Signifier | Stddev | Rapport | Taille du code |
|---|---|---|---|---|
| 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 |
| Dangesum_span_int16 | 111.8 ns | 1,00 ns | 0,03 | 200 b |
.LongSum() est une méthode d'extension qui peut vous aider à calculer la somme de toutes les valeurs dans une période à l'aide d'un accumulateur 64 bits (c'est-à-dire long pour les entiers signés, ulong pour les entiers non signés, et double pour float ) , capable de stocker un résultat plus grand que la valeur maximale / minimum du type d'origine int.MaxValue + int.MaxValue int exemple, vous ne pouvez pas stocker le résultat mathématiquement correct. ) . Il est vectorisé pour tous les types pris en charge et n'a pas d'alternatives appropriées dans LINQ (donc, la référence ci-dessous est un peu injuste) .
.LongSum() n'a pas de homologue "dangereux", car même la plus grande portée possible qui stocke int.MaxValue des éléments de type int ne peut pas provoquer un débordement d'un accumulateur 64 bits ( (long)int.MaxValue * (long)int.MaxValue < long.MaxValue ).
| Méthode | Signifier | Stddev | Rapport | Taille du code |
|---|---|---|---|---|
| Longsum_loop_int16 | 2 537,1 ns | 21h30 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() est une méthode d'extension qui peut vous aider à calculer la moyenne de toutes les valeurs en une durée. Il est vectorisé pour tous les types pris en charge, contrairement à Enumerable.Average() , qui ne fournit qu'un certain niveau d'optimisation pour les entiers signés 32 bits (c.-à int s).
Sous le capot, .Average() utilise .LongSum() pour calculer la somme de tous les éléments tout en évitant les débordements entiers. Cependant, si votre entrée est désinfectée et ne peut pas en provoquer une, vous pouvez passer à .UnsafeAverage() , qui utilise .UnsafeSum() et ne passe pas le temps d'exécution précieux sur les gardes de débordement.
| Méthode | Signifier | Stddev | Rapport | Taille du code |
|---|---|---|---|---|
| Moyen_loop_int16 | 2 482,1 ns | 20.04 ns | 1,00 | 241 b |
| Moyen_linq_int16 | 13,198,2 ns | 97,67 ns | 5.31 | 1 016 b |
| Moyen_span_int16 | 257,8 ns | 3.61 ns | 0.10 | 593 b |
| Dangetafeverage_span_int16 | 116.7 ns | 1,27 ns | 0,05 | 128 b |
.FillSequential() est une méthode d'extension qui peut vous aider à combler une portée donnée avec des valeurs numériques séquentielles. Il est vectorisé pour tous les types pris en charge et n'a pas d'alternatives dans LINQ.
| Méthode | Signifier | Stddev | Rapport | Taille du code |
|---|---|---|---|---|
| FILLESHELENTAL_LOOP_INT16 | 2 499,4 ns | 28,47 ns | 1,00 | 118 b |
| FILLESHELENTAL_SPAN_INT16 | 169.2 ns | 0,18 ns | 0,07 | 660 b |
.IndexOf() , .LastIndexOf() et .Contains() peuvent vous sembler familiers, car ces méthodes sont fournies par MemoryExtensions . Cependant, il y a deux problèmes avec eux:
IEquatable<T> , ce qui les rend inaccessibles dans le contexte générique non lié.IEqualityComparer<T> .Les implémentations de ces méthodes fournies par cette bibliothèque abordent ces deux questions.
Licencié selon les termes de la licence du MIT.