J'ai étudié la dégradation des performances et l'ai suivie pour ralentir les HashSets.
J'ai des structures avec des valeurs nullables qui sont utilisées comme clé primaire. Par exemple:
public struct NullableLongWrapper
{
private readonly long? _value;
public NullableLongWrapper(long? value)
{
_value = value;
}
}
J'ai remarqué que la création d'un HashSet<NullableLongWrapper>
est exceptionnellement lent.
Voici un exemple utilisant BenchmarkDotNet : (Install-Package BenchmarkDotNet
)
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Running;
public class Program
{
static void Main()
{
BenchmarkRunner.Run<HashSets>();
}
}
public class Config : ManualConfig
{
public Config()
{
Add(Job.Dry.WithWarmupCount(1).WithLaunchCount(3).WithTargetCount(20));
}
}
public struct NullableLongWrapper
{
private readonly long? _value;
public NullableLongWrapper(long? value)
{
_value = value;
}
public long? Value => _value;
}
public struct LongWrapper
{
private readonly long _value;
public LongWrapper(long value)
{
_value = value;
}
public long Value => _value;
}
[Config(typeof (Config))]
public class HashSets
{
private const int ListSize = 1000;
private readonly List<long?> _nullables;
private readonly List<long> _longs;
private readonly List<NullableLongWrapper> _nullableWrappers;
private readonly List<LongWrapper> _wrappers;
public HashSets()
{
_nullables = Enumerable.Range(1, ListSize).Select(i => (long?) i).ToList();
_longs = Enumerable.Range(1, ListSize).Select(i => (long) i).ToList();
_nullableWrappers = Enumerable.Range(1, ListSize).Select(i => new NullableLongWrapper(i)).ToList();
_wrappers = Enumerable.Range(1, ListSize).Select(i => new LongWrapper(i)).ToList();
}
[Benchmark]
public void Longs() => new HashSet<long>(_longs);
[Benchmark]
public void NullableLongs() => new HashSet<long?>(_nullables);
[Benchmark(Baseline = true)]
public void Wrappers() => new HashSet<LongWrapper>(_wrappers);
[Benchmark]
public void NullableWrappers() => new HashSet<NullableLongWrapper>(_nullableWrappers);
}
Résultat:
Méthode | Médiane | Mise à l'échelle ----------------- | ---------------- | --------- Longs | 22.8682 us | 0,42 NullableLongs | 39.0337 us | 0,62 Emballages | 62.8877 us | 1,00 NullableWrappers | 231 993,7278 us | 3 540,34
Utiliser une structure avec un Nullable<long>
comparé à une structure avec un long
est 3540 fois plus lent!
Dans mon cas, cela faisait la différence entre 800 ms et <1 ms.
Voici les informations d'environnement de BenchmarkDotNet:
OS = Microsoft Windows NT 6.1.7601 Service Pack 1
Processeur = Intel (R) Core (TM) i7-5600U CPU 2,60 GHz, ProcessorCount = 4
Fréquence = 2536269 ticks, Résolution = 394,2799 ns, Minuterie = TSC
CLR = MS.NET 4.0.30319.42000, Arch = 64-bit RELEASE [RyuJIT]
GC = poste de travail simultané
JitModules = clrjit-v4.6.1076.0
Quelle est la raison pour laquelle les performances sont si médiocres?
Cela se produit car chacun des éléments de _nullableWrappers
a le même code de hachage renvoyé par GetHashCode()
, ce qui entraîne le hachage dégénérant en O(N) accès plutôt que O (1).
Vous pouvez le vérifier en imprimant tous les codes de hachage.
Si vous modifiez votre structure comme suit:
public struct NullableLongWrapper
{
private readonly long? _value;
public NullableLongWrapper(long? value)
{
_value = value;
}
public override int GetHashCode()
{
return _value.GetHashCode();
}
public long? Value => _value;
}
cela fonctionne beaucoup plus rapidement.
Maintenant, la question évidente est POURQUOI le code de hachage de chaque NullableLongWrapper
est le même.
La réponse à cela est discutée dans ce fil . Cependant, cela ne répond pas tout à fait à la question, car la réponse de Hans tourne autour de la structure ayant DEUX champs parmi lesquels choisir lors du calcul du code de hachage - mais dans ce code, il n'y a qu'un seul champ à choisir - et c'est un type de valeur (un struct
).
Cependant, la morale de cette histoire est la suivante: Ne vous fiez jamais à la valeur par défaut GetHashCode()
pour les types de valeur!
Addendum
Je pensais que ce qui se passait était peut-être lié à la réponse de Hans dans le fil que j'avais lié - peut-être que cela prenait la valeur du premier champ (le bool) dans la structure Nullable<T>
), et mes expériences indiquent que cela peut être lié - mais c'est compliqué:
Considérez ce code et sa sortie:
using System;
public class Program
{
static void Main()
{
var a = new Test {A = 0, B = 0};
var b = new Test {A = 1, B = 0};
var c = new Test {A = 0, B = 1};
var d = new Test {A = 0, B = 2};
var e = new Test {A = 0, B = 3};
Console.WriteLine(a.GetHashCode());
Console.WriteLine(b.GetHashCode());
Console.WriteLine(c.GetHashCode());
Console.WriteLine(d.GetHashCode());
Console.WriteLine(e.GetHashCode());
}
}
public struct Test
{
public int A;
public int B;
}
Output:
346948956
346948957
346948957
346948958
346948959
Notez comment les deuxième et troisième codes de hachage (pour 1/0 et 0/1) sont identiques, mais les autres sont tous différents. Je trouve cela étrange parce que changer clairement A change le code de hachage, tout comme changer B, mais étant donné deux valeurs X et Y, le même code de hachage est généré pour A = X, B = Y et A = Y, B = X.
(Cela ressemble à quelque chose de XOR qui se passe dans les coulisses, mais c'est une supposition.)
Soit dit en passant, ce comportement où les DEUX champs peuvent être affichés pour contribuer au code de hachage prouve que le commentaire dans la source de référence pour ValueType.GetHashType()
est inexact ou incorrect:
Action: Notre algorithme pour retourner le hashcode est un peu complexe. Nous recherchons le premier champ non statique et obtenons son code de hachage. Si le type n'a pas de champs non statiques, nous renvoyons le code de hachage du type. Nous ne pouvons pas prendre le code de hachage d'un membre statique car si ce membre est du même type que le type d'origine, nous nous retrouverons dans une boucle infinie.
Si ce commentaire était vrai, alors quatre des cinq codes de hachage dans l'exemple ci-dessus seraient les mêmes, puisque A
a la même valeur, 0, pour tous ceux-là. (Cela suppose que A
est le premier champ, mais vous obtenez les mêmes résultats si vous échangez les valeurs autour: les deux champs contribuent clairement au code de hachage.)
J'ai ensuite essayé de changer le premier champ pour qu'il soit booléen:
using System;
public class Program
{
static void Main()
{
var a = new Test {A = false, B = 0};
var b = new Test {A = true, B = 0};
var c = new Test {A = false, B = 1};
var d = new Test {A = false, B = 2};
var e = new Test {A = false, B = 3};
Console.WriteLine(a.GetHashCode());
Console.WriteLine(b.GetHashCode());
Console.WriteLine(c.GetHashCode());
Console.WriteLine(d.GetHashCode());
Console.WriteLine(e.GetHashCode());
}
}
public struct Test
{
public bool A;
public int B;
}
Output
346948956
346948956
346948956
346948956
346948956
Hou la la! Donc, faire du premier champ un booléen fait que tous les codes de hachage sortent de la même manière, indépendamment des valeurs de N'IMPORTE QUEL des champs!
Cela ressemble toujours à une sorte de bogue pour moi.
Le bogue a été corrigé dans .NET 4, mais uniquement pour Nullable. Les types personnalisés produisent toujours le mauvais comportement. source
Cela est dû au comportement de struct GetHashCode (). S'il trouve des types de référence - il essaie d'obtenir le hachage du premier champ de type non référence. Dans votre cas, il a été trouvé, et Nullable <> est également struct, donc il vient de s'afficher sa valeur booléenne privée (4 octets)