Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve dictionary lookup perf for OrdinalIgnoreCase #36252

Merged
merged 15 commits into from
Aug 6, 2020

Conversation

GrabYourPitchforks
Copy link
Member

string.GetHashCode (and all built-in StringComparer.GetHashCode routines) have built-in randomness to help prevent hash collision attacks. This means that collection types like Dictionary<string, ...>, HashSet<string>, and others (where TKey = string) get these protections for free automatically.

However, hash code randomization does incur some perf penalty, so Dictionary<string, ...> special-cases two specific code paths. If you instantiate a dictionary instance via new Dictionary<string, ...>(comparer: null) or new Dictionary<string, ...>(EqualityComparer<string>.Default), the dictionary instance will swap out the comparer and use a faster non-randomized comparer, falling back to the randomized comparer only when the dictionary's internal buckets become unbalanced.

This PR also special-cases new Dictionary<string, ...>(StringComparer.Ordinal) and new Dictionary<string, ...>(StringComparer.OrdinalIgnoreCase) to get the same perf boost. No other comparers (such as the linguistic StringComparer singletons) are special-cased.

Perf results are below. Note that StringComparison.Ordinal and StringComparison.OrdinalIgnoreCase see a significant perf boost. Other comparers remain within the range of noise.

Method Toolchain Comparer key Mean Error StdDev Ratio RatioSD
ContainsKey master Ordinal key-not-found 17.95 ns 0.180 ns 0.159 ns 1.00 0.00
ContainsKey proto Ordinal key-not-found 13.15 ns 0.245 ns 0.229 ns 0.73 0.01
ContainsKey master Ordinal some-sample-key 17.10 ns 0.298 ns 0.249 ns 1.00 0.00
ContainsKey proto Ordinal some-sample-key 12.19 ns 0.159 ns 0.149 ns 0.71 0.01
ContainsKey master OrdinalIgnoreCase key-not-found 21.85 ns 0.351 ns 0.328 ns 1.00 0.00
ContainsKey proto OrdinalIgnoreCase key-not-found 12.52 ns 0.235 ns 0.220 ns 0.57 0.01
ContainsKey master OrdinalIgnoreCase some-sample-key 44.22 ns 0.648 ns 0.575 ns 1.00 0.00
ContainsKey proto OrdinalIgnoreCase some-sample-key 32.31 ns 0.654 ns 0.850 ns 0.73 0.02
ContainsKey master EQ<string>.Default key-not-found 12.95 ns 0.295 ns 0.500 ns 1.00 0.00
ContainsKey proto EQ<string>.Default key-not-found 12.57 ns 0.288 ns 0.561 ns 0.97 0.06
ContainsKey master EQ<string>.Default some-sample-key 12.10 ns 0.270 ns 0.361 ns 1.00 0.00
ContainsKey proto EQ<string>.Default some-sample-key 11.13 ns 0.245 ns 0.229 ns 0.92 0.04
ContainsKey master Invariant key-not-found 468.96 ns 6.625 ns 5.873 ns 1.00 0.00
ContainsKey proto Invariant key-not-found 473.38 ns 7.590 ns 9.869 ns 1.01 0.03
ContainsKey master Invariant some-sample-key 524.40 ns 9.684 ns 8.584 ns 1.00 0.00
ContainsKey proto Invariant some-sample-key 517.89 ns 10.022 ns 9.842 ns 0.99 0.02
ContainsKey master InvariantIgnoreCase key-not-found 425.31 ns 8.104 ns 6.767 ns 1.00 0.00
ContainsKey proto InvariantIgnoreCase key-not-found 425.18 ns 5.988 ns 5.601 ns 1.00 0.02
ContainsKey master InvariantIgnoreCase some-sample-key 718.50 ns 10.493 ns 9.815 ns 1.00 0.00
ContainsKey proto InvariantIgnoreCase some-sample-key 726.69 ns 14.357 ns 15.958 ns 1.01 0.03

@GrabYourPitchforks GrabYourPitchforks added enhancement Product code improvement that does NOT require public API changes/additions area-System.Collections tenet-performance Performance related issue labels May 11, 2020
@ghost
Copy link

ghost commented May 11, 2020

Tagging subscribers to this area: @eiriktsarpalis
Notify danmosemsft if you want to be subscribed.

@GrabYourPitchforks
Copy link
Member Author

Also, the above chart should clearly demonstrate that Ordinal[IgnoreCase] is always preferred over InvariantCulture[IgnoreCase] in high-throughput scenarios unless you really, really need linguistic string processing capabilities. :)

[Fact]
public void Dictionary_Comparer_NonRandomizedStringComparers()
{
RunTest(null);
Copy link
Member

@danmoseley danmoseley May 11, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

would it be better to use MemberData in order to get separate pass/fail results for each of these comparers?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried that initially, but it was a bit cryptic to match the failure back to the argument instance. Maybe there's some secret sauce to getting a friendlier error message in that case?

Right now you get a line number as part of the exception if there's a failure. And while it's not ideal, at least it's a straightforward mapping.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough. Possibly it would require some trick such as passing a 2nd parameter that was the comparer name.

private NonRandomizedStringEqualityComparer() { }
// Flag indicates whether this instance wraps EqualityComparer<string>.Default
// or StringComparer.Ordinal.
private readonly bool _wrapsOrdinalComparer;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was going to guess this makes the dictionaries one pointer bigger. But I learned something new to me, it seems in at least some cases the CLR will pack boolean fields into the object header, apparently:

using System;
class Program
{
    static void Main(string[] args)
    {
        int size = 50000;
        X[] array = new X[size];
        long before = GC.GetTotalMemory(true);
        for (int i = 0; i < array.Length; i++)
        {
            array[i] = new X();
        }
        long after = GC.GetTotalMemory(true);

        Console.WriteLine((after - before)/ size);

        GC.KeepAlive(array);
    }
}

class X // comment out all bools, and it prints 24 (on 64 bit)
{
    bool a;
    bool b;
    bool c;
    bool d;
    bool e;
    bool f;
    bool g;
    bool h; // with bools a-h it still shows 24
    // bool i; // uncomment this and it shows 32
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jkotas do I understand that right or am I misreading what I did?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The minimum object size with CoreCLR GC is 12 bytes on 32-bit platforms and 24 bytes on 64-bit platforms. If the payload is less, the object size is padded to the minimum size.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah - padding, of course. Thank you.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's on the singleton comparer instance, not the dictionary instance. That's why there are two singletons for the non-randomized equality comparer: one that wraps EqualityComparer<string>.Default, and one that wraps StringComparer.Ordinal. Their runtime behavior is identical. The only difference is that for compat I need to make sure that when you call the Dictionary<...>.Comparer property getter I give you back the same comparer instance you used to instantiate the dictionary. So this information is smuggled via the comparer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To answer your other question re: how bools are stored, all objects in the CLR are at least 3 pointers (24 bytes on x64) in size. The first two pointers are collectively referred to as the "object header". The object's member data always begins just after these two pointers.

Due to how the GC handles variable-length objects (string, Array, Utf8String), the length field must always be the first thing directly after the object header. When walking objects, the GC always attempts to dereference this field, even for normal fixed-length objects which don't have a length field. It'll eventually get ignored for these types of objects. But the interesting consequence to this is that for all objects there must be a pointer's worth of dereferenceable data immediately after the object header. It can be junk, but it must be dereferenceable. For normal fixed-size objects, the VM starts putting the member data at where this length field would have been.

That's why the object size doesn't change when you have between 0 - 8 bools, because they take up the space that was already carved out for this object's member data. But as soon as you have a 9th bool it spills over.

Copy link
Contributor

@Suchiman Suchiman May 12, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GrabYourPitchforks but that wouldn't require each object to to be padded to at least 12/24 bytes, only the current gc segment would need to be at least 4/8 byte longer to ensure that there's valid memory after the last object on the gc segment to dereference there. The primary reason i've read for why it is that way is (plug is a continous memory range of surviving objects and gap is a continous range of dead objects / free space)
image
(from Pro .NET Memory Management)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Suchiman Sure, I suppose you could do this as an optimization, but why bother? The number of zero-length objects (basically, System.Object instances and nothing else) allocated is vanishingly small compared to the number of "useful" object instances allocated. Doesn't seem worthwhile for the GC to change from its existing logic due since we'd expect no real-world gains.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@GrabYourPitchforks this doesn't just affect zero-length objects but all objects that are smaller than 4/8 bytes, e.g. a few bools, but the point i was trying to make, was that this is unlikely to be the cause but rather an effect (if the objects are always that large, one can save on the check) of the objects being padded. The likely primary cause is that the GC needs the additional space for the plan phase of the GC (as seen in the previous excerpt from the book).

@mjsabby
Copy link
Contributor

mjsabby commented May 11, 2020

BTW DictionarySlim beats even the newer numbers you have posted here for OrdinalIgnoreCase. I'm now replacing many dictionaries with DictionarySlim ... so any improvements we could make there would be great too.

{
internal static new IEqualityComparer<string?> Default { get; } = new NonRandomizedStringEqualityComparer();
internal static readonly IEqualityComparer<string?> AroundDefaultComparer = new NonRandomizedStringEqualityComparer(wrapsOrdinalComparer: false);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Around in the name looks odd to me. Should the names match StringComparer - Ordinal and OrdinalIgnoreCase ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, I see what this means now.

I would not describe the relationship between NonRandomized and regular comparer as one wrapping the other. There is no pointer from the NonRandomized to the regular one.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, Around confused me as well.

@GrabYourPitchforks
Copy link
Member Author

@mjsabby You mean the prototype DictionarySlim<TKey, TValue> from corefxlab? AFAIK that type doesn't allow using a custom comparer at all (such as a case-insensitive comparer) and relies solely on the implementation of TKey.GetHashCode and TKey.Equals.

{
internal interface INonRandomizedEqualityComparer<in T> : IEqualityComparer<T>
{
IEqualityComparer<T> GetComparerForSerialization() => GetRandomizedComparer();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we have two different methods? I would expect one should be enough.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future-proofing, mainly. Check out the discussion over at #4761 (comment).

Summary: you may have types whose default hash code calculation is non randomized (e.g., int, long). If the dictionary starts to become unbalanced, you'll want to fall back to a randomized hash code calculation routine. But the default comparer is the non-randomized one, not the randomized one, so GetComparerForSerialization would return the non-randomized comparer.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should add these abstractions only once they are required.


namespace System.Collections.Generic
{
internal interface INonRandomizedEqualityComparer<in T> : IEqualityComparer<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this interface necessary? Can we just make NonRandomizedStringEqualityComparer unsealed?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At the moment it's only for string. But this also provides the infrastructure to have it work for other types as well, such as Utf8String. (I didn't do that as part of this PR, but it was in one of the early prototypes back in the coreclr repo.)

You recommend deleting it for now and crossing that bridge later?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Future proofing like this works against our goals around framework linkability.

@danmoseley
Copy link
Member

@mjsabby are you intending to use string keys in your DictionarySlim<TKey, TValue> ? It was a conscious decision to avoid storing a comparer (and calling through a comparer). I could imagine customized version that could work with string comparers and still be worthwhile.

@mjsabby
Copy link
Contributor

mjsabby commented May 12, 2020

@danmosemsft @GrabYourPitchforks sorry, yes we copied the code and wrote a version that is doing case insensitive comparisons. We also freeze them to disk so we also had to rip out the seed. So my comments are not valid for this PR.


namespace System.Collections.Generic
{
internal interface INonRandomizedEqualityComparer<in T> : IEqualityComparer<T>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Regardless of whether we keep this interface in the end, I'd say I find the name a bit confusing, in the sense that most IEqualityComparer instances out there are non-randomized but do not implement this subtype. Perhaps IRandomizableEqualityComparer might be more suitable?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe IFastEqualityComparer<in T>? The premise of the interface is "My GetHashCode routine is faster than a normal GetHashCode routine, but I'm not collision-resistant. I'm intended as an implementation detail for collections who want optimistically to assume the data isn't malicious, but I also provide a fallback in case things go bad."

@stephentoub
Copy link
Member

@GrabYourPitchforks, HashSet<T> has been updated to use the same pre-collision-non-randomness technique as Dictionary<TKey, TValue>. Can you update it accordingly in your PR as well to handle OrdinalIgnoreCase and friends?

@GrabYourPitchforks
Copy link
Member Author

@stephentoub Absolutely! I'll update this PR to incorporate the earlier feedback and also get to HashSet once I get some free cycles again.

@stephentoub
Copy link
Member

Great, thanks.

@GrabYourPitchforks
Copy link
Member Author

GrabYourPitchforks commented Jul 16, 2020

@jkotas I started looking at this again. Not seeing a way to work around having the interface or an abstract base class if we want to support both Ordinal and OrdinalIgnoreCase. Here's the implementation as proposed here:

internal interface INonRandomized : IEqualityComparer<string> { }
internal sealed class NonRandomizedStringOrdinal : INonRandomized { }
internal sealed class NonRandomizedStringOrdinalIgnoreCase : INonRandomized { }

Here, the dictionary has a reference to one of the two concrete implementations. The dictionary makes a virtual call to IEqualityComparer<string>.GetHashCode, and the concrete implementation makes a non-virtual call to string.GetNonRandomizedHashCode or string.GetNonRandomizedHashCodeOrdinaIIgnoreCase.

I could use a single concrete implementation internal sealed class NonRandomizedStringEqCmp : IEqCmp<string>, but now that concrete implementation has to maintain some internal state: either a bool _ignoreCase field or a delegate*<string, int> _hashCodeFn field. So that's introducing a branch or another indirection into what is presumably a hot path.

Would this be a sufficient reason for keeping the interface around? It would allow us to get away with only a single indirection in the hot path. It introduces some extra indirections in GetObjectData, but that seems fine by me.

@jkotas
Copy link
Member

jkotas commented Jul 16, 2020

Not seeing a way to work around having the interface or an abstract base class

Agree. I believe that NonRandomizedStringEqualityComparer should work as the base class (it does not need to be actually abstract):

public class NonRandomizedStringEqualityComparer : EqualityComparer<string?>, ISerializable
{
   // Used for EqualityComparer<string>.Default like today
}

internal sealed class NonRandomizedEqualityComparerForStringComparerOrdinal : NonRandomizedStringEqualityComparer
{
}

internal sealed class NonRandomizedEqualityComparerForStringComparerOrdinalIgnoreCase : NonRandomizedStringEqualityComparer
{
}

My issue with internal INonRandomizedEqualityComparer<T> is that it is not pay for play. Every Dictionary instantiation will pay for it, but only the instantiations on string can benefit from it.

@danmoseley
Copy link
Member

This would be a significant win, it would be nice to get this committed before we fork for 5.0.

@GrabYourPitchforks
Copy link
Member Author

GrabYourPitchforks commented Aug 5, 2020

Major changes since last iteration:

  • Added unit tests
  • Updated TestData.resources directly rather than updating the test generator project (see discussion at Fix resx test name mangling logic #39569, which was later reverted)
  • Added fast logic to HashSet<T> (when T = string)
  • Refactored the interface to make it as pay-for-play as possible, only used when TKey = string
  • Added per-instance hash code randomization to the Dictionary and HashSet types

Those last two points deserve a little more discussion. We're in the process of updating internal SDL guidance with regard to cryptographic and non-cryptographic hash algorithms, and this also served as a good opportunity to talk with the Crypto Board regarding whether we're following best practice with our usage of these hash algorithms. Per those discussions, the best hygiene is to use different seeds per instance rather than an app-global seed where possible. So, once the Dictionary or HashSet type sees 100 collisions, it will instantiate a brand new equality comparer with a brand new seed. This does incur some overhead, but only after 100 collisions are seen, which means that the dictionary is already in its "be safe rather than fast" fallback behavior.

Since this involves the creation of new non-randomized and randomized string equality comparer types, there's now the issue of what to do when serializing dictionary instances. Rather than complicate the serialization logic to special-case all of these components, I changed the GetObjectData method to use the existing public Comparer property. That property now calls into a helper function to unwrap the underlying "public" equality comparer from whichever internal equality comparer we're using as a proxy. Hence the introduction of the IInternalStringEqualityComparer interface. But again, it should be as pay-for-play as possible.

IEqualityComparer<string?> GetUnderlyingEqualityComparer();
}

internal static class InternalStringEqualityComparer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The static method can be in IInternalStringEqualityComparer. No need to have a separate type with just a single method.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have other examples of where we've introduced static methods in interfaces?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do not have much, if any. It got enabled in C# not too long ago. C# allows it now so we should be free to use it. Using it as internal implementation detail is low risk - we can undo it if necessary.

@danmoseley
Copy link
Member

Would it be possible to gather updated perf numbers for Dictionary and HashSet now? Just to check.

@GrabYourPitchforks
Copy link
Member Author

@danmosemsft Here are the updated perf numbers. In this table, -1 means calling the Dictionary<string, ...> or HashSet<string> parameterless ctor.

Linguistic (InvariantCulture[IgnoreCase] and CurrentCulture[IgnoreCase]) code paths are unchanged by this PR. Only default ctor / Ordinal / OrdinalIgnoreCase are changed. In the first few rows, there's a +/- 1ns jitter on my box that's throwing off some of the numbers.

Method Toolchain Comparison key Mean Error StdDev Median Ratio RatioSD
DictContainsKey master -1 key-not-found 8.735 ns 0.0628 ns 0.0557 ns 8.737 ns 1.00 0.00
DictContainsKey proto -1 key-not-found 9.915 ns 0.0415 ns 0.0368 ns 9.921 ns 1.14 0.01
HashSetContainsKey master -1 key-not-found 9.542 ns 0.1340 ns 0.1187 ns 9.577 ns 1.00 0.00
HashSetContainsKey proto -1 key-not-found 8.777 ns 0.0453 ns 0.0378 ns 8.765 ns 0.92 0.01
DictContainsKey master -1 some-sample-key 8.542 ns 0.0544 ns 0.0482 ns 8.560 ns 1.00 0.00
DictContainsKey proto -1 some-sample-key 8.467 ns 0.1966 ns 0.3175 ns 8.267 ns 1.03 0.02
HashSetContainsKey master -1 some-sample-key 9.356 ns 0.1260 ns 0.1178 ns 9.385 ns 1.00 0.00
HashSetContainsKey proto -1 some-sample-key 8.432 ns 0.0827 ns 0.0774 ns 8.382 ns 0.90 0.02
DictContainsKey master InvariantCulture key-not-found 309.988 ns 3.5880 ns 3.3563 ns 309.693 ns 1.00 0.00
DictContainsKey proto InvariantCulture key-not-found 309.144 ns 1.7566 ns 1.4669 ns 309.032 ns 1.00 0.01
HashSetContainsKey master InvariantCulture key-not-found 311.741 ns 2.7918 ns 2.6115 ns 311.543 ns 1.00 0.00
HashSetContainsKey proto InvariantCulture key-not-found 318.378 ns 5.9965 ns 5.3157 ns 316.811 ns 1.02 0.02
DictContainsKey master InvariantCulture some-sample-key 332.346 ns 1.9034 ns 1.6873 ns 332.058 ns 1.00 0.00
DictContainsKey proto InvariantCulture some-sample-key 332.720 ns 1.8593 ns 1.6482 ns 332.704 ns 1.00 0.01
HashSetContainsKey master InvariantCulture some-sample-key 332.339 ns 1.1907 ns 0.9296 ns 332.604 ns 1.00 0.00
HashSetContainsKey proto InvariantCulture some-sample-key 330.537 ns 3.4120 ns 2.8491 ns 330.342 ns 0.99 0.01
DictContainsKey master InvariantIgnoreCase key-not-found 292.041 ns 3.7606 ns 3.5177 ns 292.047 ns 1.00 0.00
DictContainsKey proto InvariantIgnoreCase key-not-found 294.231 ns 2.2934 ns 2.1453 ns 293.944 ns 1.01 0.02
HashSetContainsKey master InvariantIgnoreCase key-not-found 291.706 ns 2.0379 ns 1.5911 ns 292.133 ns 1.00 0.00
HashSetContainsKey proto InvariantIgnoreCase key-not-found 292.365 ns 2.1903 ns 2.0488 ns 291.339 ns 1.00 0.01
DictContainsKey master InvariantIgnoreCase some-sample-key 476.904 ns 4.0664 ns 3.8037 ns 476.863 ns 1.00 0.00
DictContainsKey proto InvariantIgnoreCase some-sample-key 480.673 ns 4.5449 ns 4.2513 ns 479.887 ns 1.01 0.01
HashSetContainsKey master InvariantIgnoreCase some-sample-key 475.590 ns 3.1486 ns 2.6292 ns 475.183 ns 1.00 0.00
HashSetContainsKey proto InvariantIgnoreCase some-sample-key 484.064 ns 3.0195 ns 2.5215 ns 483.797 ns 1.02 0.01
DictContainsKey master Ordinal key-not-found 12.424 ns 0.0410 ns 0.0383 ns 12.417 ns 1.00 0.00
DictContainsKey proto Ordinal key-not-found 8.744 ns 0.0314 ns 0.0294 ns 8.743 ns 0.70 0.00
HashSetContainsKey master Ordinal key-not-found 12.272 ns 0.0880 ns 0.0735 ns 12.276 ns 1.00 0.00
HashSetContainsKey proto Ordinal key-not-found 8.572 ns 0.1064 ns 0.0943 ns 8.572 ns 0.70 0.01
DictContainsKey master Ordinal some-sample-key 13.435 ns 0.0544 ns 0.0482 ns 13.430 ns 1.00 0.00
DictContainsKey proto Ordinal some-sample-key 8.301 ns 0.0555 ns 0.0519 ns 8.295 ns 0.62 0.00
HashSetContainsKey master Ordinal some-sample-key 14.642 ns 0.0436 ns 0.0387 ns 14.649 ns 1.00 0.00
HashSetContainsKey proto Ordinal some-sample-key 8.562 ns 0.0523 ns 0.0464 ns 8.571 ns 0.58 0.00
DictContainsKey master OrdinalIgnoreCase key-not-found 17.440 ns 0.1035 ns 0.0968 ns 17.436 ns 1.00 0.00
DictContainsKey proto OrdinalIgnoreCase key-not-found 8.596 ns 0.1082 ns 0.0959 ns 8.606 ns 0.49 0.00
HashSetContainsKey master OrdinalIgnoreCase key-not-found 14.991 ns 0.0821 ns 0.0768 ns 14.994 ns 1.00 0.00
HashSetContainsKey proto OrdinalIgnoreCase key-not-found 8.835 ns 0.0393 ns 0.0367 ns 8.835 ns 0.59 0.00
DictContainsKey master OrdinalIgnoreCase some-sample-key 31.169 ns 0.0933 ns 0.0827 ns 31.177 ns 1.00 0.00
DictContainsKey proto OrdinalIgnoreCase some-sample-key 22.979 ns 0.2831 ns 0.2364 ns 22.913 ns 0.74 0.01
HashSetContainsKey master OrdinalIgnoreCase some-sample-key 31.781 ns 0.6414 ns 0.6586 ns 31.563 ns 1.00 0.00
HashSetContainsKey proto OrdinalIgnoreCase some-sample-key 23.083 ns 0.0741 ns 0.0657 ns 23.064 ns 0.73 0.02

@danmoseley
Copy link
Member

Those numbers look great to me.

@GrabYourPitchforks
Copy link
Member Author

Failing test is tracked at #40416.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Collections enhancement Product code improvement that does NOT require public API changes/additions tenet-performance Performance related issue
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants