Skip to content

FrozenDictionary is incorrectly case sensitive in some cases #93974

Closed

Description

@andrewjsaid

Description

When optimizing a FrozenDictionary in KeyAnalyzer we look for the possibility to use case sensitive string comparison on a substring which is ascii and does not have letters (canSwitchIgnoreCaseToCaseSensitive).

The problem is that since analysis.IgnoreCase is false, we choose certain classes like OrdinalStringFrozenDictionary_LeftJustifiedSubstringCaseInsensitive, OrdinalStringFrozenDictionary_LeftJustifiedSingleChar etc.

These classes implement Equals as follows:

private protected override bool Equals(string? x, string? y) => string.Equals(x, y);

The above implementation ignores the original comparer and assumes case sensitivity.

When TryGetValue is called, it uses Equals which thus uses case sensitive comparison as described above.

The 'naive' solution is obvious - change Equals to be

private protected override bool Equals(string? x, string? y) => Comparer.Equals(x, y);

However (and this is where I'm guessing) - this has a pretty detrimental performance problem in that Equals will no longer be aggressively inlined into SequenceEquals. If (and it's a big IF) this is shown to be correct, then we are left with the following options which I can think of:

  1. Create another class OrdinalStringFrozenDictionary_FullCaseSensitiveHashButCaseInsensitiveEquals
  2. Stop calculating canSwitchIgnoreCaseToCaseSensitive.

As .NET 8 is about to launch and the cutoff for new development has long passed, I would suggest (2) as the immediate change. I am happy to verify my assumptions about using comparer and submit a PR with tests.
I am also happy to implement the immediate fix, however I'd need to know which branch since I assume we want this to be patched into .NET 8.

As for. NET 9, I (or anybody else) can find time to implement (1) it shouldn't be that hard without the time pressure.

Reproduction Steps

using System.Collections.Frozen;
using static System.Console;

var dictionary1 = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
    {"1xxx", 1},
    {"2xxx", 2},
    {"3xxx", 3},
    {"4xxx", 4},
    {"5xxx", 5},
    {"6xxx", 6},
    {"7xxx", 7},
    {"8xxx", 8},
    {"9xxx", 9}
};

var frozen1 = dictionary1.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);

WriteLine("Dictionary1 Upper: " + dictionary1.TryGetValue("1XXX", out _));
WriteLine("Dictionary1 Lower: " + dictionary1.TryGetValue("1xxx", out _));


WriteLine("FrozenDictionary1 Upper: " + frozen1.TryGetValue("1XXX", out _));
WriteLine("FrozenDictionary1 Lower: " + frozen1.TryGetValue("1xxx", out _));
WriteLine("FrozenDictionary1 Type: " + frozen1.GetType());

Expected behavior

FrozenDictionary1 Upper: True

Actual behavior

Results:

Dictionary1 Upper: True
Dictionary1 Lower: True
FrozenDictionary1 Upper: False
FrozenDictionary1 Lower: True
FrozenDictionary1 Type: System.Collections.Frozen.OrdinalStringFrozenDictionary_LeftJustifiedSingleChar`1[System.Int32]

Regression?

No - this is a new feature and we are probably in time to fix.

Known Workarounds

No response

Configuration

8.0.0-rc.1.23419.4

Looking at source code 1 week old tells me the bug is still there.

Other information

I haven't looked at the code for FrozenSet but a quick test shows me it's got the same bug.

Also side note API design complaint... It's very misleading that the following intentionally returns a case sensitive dictionary but I'm guessing there was a good reason to make that decision.

var dictionary = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase)
{
    {"1xxx", 1},
    {"2xxx", 2}
};
var frozen = dictionary.ToFrozenDictionary();

Activity

  1. ghost added
    untriagedNew issue has not been triaged by the area owner
    on Oct 25, 2023
  2. andrewjsaid commented on Oct 25, 2023

    @andrewjsaid
    ContributorAuthor

    On my branch I attempted to do the naive change:

    // before
            private protected override bool Equals(string? x, string? y) => string.Equals(x, y);
    
    // after
            private protected override bool Equals(string? x, string? y) => Comparer.Equals(x, y);

    And ended up with the following. Not nice.

    Editor's note: turns out I forgot to copy all benchmarks and confirm which table this is for, but based on the fact that SubstringFrozenDictionary comes last I assume it's that one for the below. We should probably double check if this is important.

    // * Summary *
    
    BenchmarkDotNet v0.13.7-nightly.20230717.35, Windows 11 (10.0.22621.2428/22H2/2022Update/SunValley2)
    AMD Ryzen 9 5950X, 1 CPU, 32 logical and 16 physical cores
    .NET SDK 8.0.100-rc.1.23463.5
      [Host]     : .NET 8.0.0 (8.0.23.41904), X64 RyuJIT AVX2
      Job-FLEQES : .NET 9.0.0 (42.42.42.42424), X64 RyuJIT AVX2
      Job-HUAWEC : .NET 9.0.0 (42.42.42.42424), X64 RyuJIT AVX2
    
    PowerPlanMode=00000000-0000-0000-0000-000000000000  Arguments=/p:EnableUnsafeBinaryFormatterSerialization=true  IterationTime=250.0000 ms
    MaxIterationCount=20  MinIterationCount=15  WarmupCount=1
    
    |                             Method |        Job |                       ...   Toolchain | Count |         Mean |        Error |       StdDev |       Median |          Min |          Max | Ratio | RatioSD | Allocated | Alloc Ratio |
    |----------------------------------- |----------- |-----------------------...------------ |------ |-------------:|-------------:|-------------:|-------------:|-------------:|-------------:|------:|--------:|----------:|------------:|
    |  TryGetValue_True_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe |    10 |     41.44 ns |     0.301 ns |     0.282 ns |     41.31 ns |     41.10 ns |     41.86 ns |  1.04 |    0.01 |         - |          NA |
    |  TryGetValue_True_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe |    10 |     39.81 ns |     0.416 ns |     0.389 ns |     39.92 ns |     39.08 ns |     40.64 ns |  1.00 |    0.00 |         - |          NA |
    |                                    |            |                       ...             |       |              |              |              |              |              |              |       |         |           |             |
    | TryGetValue_False_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe |    10 |     34.33 ns |     0.308 ns |     0.288 ns |     34.16 ns |     34.04 ns |     34.87 ns |  0.96 |    0.04 |         - |          NA |
    | TryGetValue_False_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe |    10 |     35.77 ns |     1.171 ns |     1.348 ns |     35.43 ns |     34.17 ns |     38.19 ns |  1.00 |    0.00 |         - |          NA |
    |                                    |            |                       ...             |       |              |              |              |              |              |              |       |         |           |             |
    |  TryGetValue_True_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe |   100 |    419.62 ns |     5.247 ns |     4.651 ns |    418.53 ns |    414.23 ns |    429.21 ns |  1.09 |    0.02 |         - |          NA |
    |  TryGetValue_True_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe |   100 |    386.50 ns |     3.452 ns |     3.229 ns |    387.37 ns |    381.21 ns |    391.93 ns |  1.00 |    0.00 |         - |          NA |
    |                                    |            |                       ...             |       |              |              |              |              |              |              |       |         |           |             |
    | TryGetValue_False_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe |   100 |    343.34 ns |     6.452 ns |     6.036 ns |    342.13 ns |    334.67 ns |    354.65 ns |  1.00 |    0.03 |         - |          NA |
    | TryGetValue_False_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe |   100 |    343.19 ns |     6.852 ns |     6.074 ns |    341.49 ns |    337.08 ns |    358.06 ns |  1.00 |    0.00 |         - |          NA |
    |                                    |            |                       ...             |       |              |              |              |              |              |              |       |         |           |             |
    |  TryGetValue_True_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe |  1000 |  4,588.38 ns |    51.584 ns |    45.728 ns |  4,569.67 ns |  4,540.82 ns |  4,686.33 ns |  1.10 |    0.02 |         - |          NA |
    |  TryGetValue_True_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe |  1000 |  4,190.14 ns |    65.276 ns |    57.866 ns |  4,175.76 ns |  4,111.92 ns |  4,318.38 ns |  1.00 |    0.00 |         - |          NA |
    |                                    |            |                       ...             |       |              |              |              |              |              |              |       |         |           |             |
    | TryGetValue_False_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe |  1000 |  3,726.07 ns |    15.695 ns |    13.106 ns |  3,729.16 ns |  3,697.16 ns |  3,744.70 ns |  1.01 |    0.00 |         - |          NA |
    | TryGetValue_False_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe |  1000 |  3,677.24 ns |    17.303 ns |    16.185 ns |  3,668.32 ns |  3,662.17 ns |  3,707.33 ns |  1.00 |    0.00 |         - |          NA |
    |                                    |            |                       ...             |       |              |              |              |              |              |              |       |         |           |             |
    |  TryGetValue_True_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe | 10000 | 69,912.78 ns | 1,306.068 ns | 1,221.697 ns | 70,145.52 ns | 68,111.90 ns | 72,411.09 ns |  1.18 |    0.02 |         - |          NA |
    |  TryGetValue_True_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe | 10000 | 58,998.67 ns |   579.996 ns |   484.323 ns | 58,814.32 ns | 58,472.57 ns | 59,865.90 ns |  1.00 |    0.00 |         - |          NA |
    |                                    |            |                       ...             |       |              |              |              |              |              |              |       |         |           |             |
    | TryGetValue_False_FrozenDictionary | Job-FLEQES | \dotnet-runtime-93974\...\corerun.exe | 10000 | 46,891.95 ns |   170.173 ns |   150.854 ns | 46,877.13 ns | 46,593.58 ns | 47,185.55 ns |  0.98 |    0.02 |         - |          NA |
    | TryGetValue_False_FrozenDictionary | Job-HUAWEC |  \dotnet-runtime-main\...\corerun.exe | 10000 | 47,906.88 ns |   836.252 ns |   782.231 ns | 47,799.26 ns | 46,492.75 ns | 49,421.45 ns |  1.00 |    0.00 |         - |          NA |
    
    
  3. added and removed
    untriagedNew issue has not been triaged by the area owner
    on Oct 26, 2023
  4. added this to the 8.0.1 milestone on Oct 26, 2023
  5. eiriktsarpalis commented on Oct 26, 2023

    @eiriktsarpalis
    Member

    Good catch, we should try to service a fix in .NET 8. FYI @adamsitnik @stephentoub

  6. added a commit that references this issue on Oct 26, 2023
    a94513c
  7. ghost added
    in-prThere is an active PR which will close this issue when it is merged
    on Nov 13, 2023
  8. added a commit that references this issue on Nov 13, 2023
    70c4113
  9. ghost removed
    in-prThere is an active PR which will close this issue when it is merged
    on Nov 13, 2023
  10. locked and limited conversation to collaborators on Dec 14, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions