Skip to content

Directory.EnumerateFiles regressed in .NET 8 #110754

Closed

Description

@Tjianke

Description

My team observed a regression for the performance of Directory.EnumerateFiles in .NET 8. We store a large number of database redo logs in a folder, named with a prefix and a log generation number (e.g., E10ABCD1234.log). Our goal is to determine the maximum log generation number efficiently.

To achieve this, we have developed a fast search algorithm that looks for the highest generation log files in a hierarchical manner, starting from E10F*******.log to E100*******.log and so on, until the last digit. We use Directory.EnumerateFiles(directory, filter) to detect matching files.

We compared the performance of our search algorithm against a direct enumeration of all files to get the maximum generation number.

  1. The direct enumeration takes approximately 0.5 seconds in both .Net Framework & .Net 8.
  2. Our search algorithm is significantly faster with a baseline of 4 milliseconds for 400,000 log files in .Net Framework, but it degrades a lot in .Net 8. Below are the detailed performance metrics.
Runtime # of Log Elapsed Time Enum Times
.NET Framework 4.8.9282.0 1000 00:00:00.0040757 99
.NET Framework 4.8.9282.0 10000 00:00:00.0051796 115
.NET Framework 4.8.9282.0 100000 00:00:00.0039643 101
.NET Framework 4.8.9282.0 400000 00:00:00.0042406 101
.NET 8.0.11 1000 00:00:00.0341829 99
.NET 8.0.11 10000 00:00:00.3642297 115
.NET 8.0.11 100000 00:00:02.7944484 101
.NET 8.0.11 400000 00:00:11.4074592 101

Previously in .Net framework it uses FindFirstFile which takes the search filter to find out the first / next file.

This API changed in .Net Core/6/8 implementation,

  1.  Directory.EnumerateFiles enumerates all files and see if current one match the prefix. So, this means FindHighestGenerationLogFileFastV2 scans the whole directory for many times.
    

runtime/src/libraries/System.Private.CoreLib/src/System/IO/Enumeration/FileSystemEnumerator.Windows.cs at 6362f242fc6e3065948b3bf922406509cb721a73 · dotnet/runtime

  1.  Directory.EnumerateFiles uses NtQueryDirectoryFile to find all file information, which probably could be slow (?).
    

Configuration

Regression?

Data

Analysis

Activity

  1. ghost added
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Dec 16, 2024
  2. added and removed
    needs-area-labelAn area label is needed to ensure this gets routed to the appropriate area owners
    on Dec 16, 2024
  3. dotnet-policy-service commented on Dec 16, 2024

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/area-system-io
    See info in area-owners.md if you want to be subscribed.

  4. stephentoub commented on Dec 16, 2024

    @stephentoub
    Member
  5. KalleOlaviNiemitalo commented on Dec 17, 2024

    @KalleOlaviNiemitalo

    There is previous discussion in #31214.

  6. jeffhandley commented on Jan 16, 2025

    @jeffhandley
    Member

    Assigned to @jozkee to finish triaging this.

  7. jozkee commented on Jan 17, 2025

    @jozkee
    Member

    May be worth exploring #31214 (comment).

  8. added this to the Future milestone on Jan 17, 2025
  9. removed their assignment
    on Jan 17, 2025
  10. jozkee commented on Feb 25, 2025

    @jozkee
    Member

    @Tjianke can you please share how can I repro this regression? .NET 9 seems to perform even better than .NET Framework 4.8.1 based on what I tried.

    Log generation:

    // write 400k log files with the following format E10ABCD1234.log, where ABCD is a 2 hex pair number and 1234 is a 4 digit decimal number
    int hexPair = 0xF000;  // Start from F000 for the hex pair (ABCD)
    int decimalNumber = 0; // Start from 0 for the decimal number (1234)
    
    for (int i = 0; i < 400_000; i++)
    {
        string hexPairFormatted = hexPair.ToString("X4"); // Format hex as 4 digits
        string decimalNumberFormatted = decimalNumber.ToString("D4"); // Format decimal as 4 digits
        string fileName = $"E10{hexPairFormatted}{decimalNumberFormatted}.log";
        File.Create(fileName).Dispose();
        Console.WriteLine("Created file: " + fileName);
    
        decimalNumber++;
        if (decimalNumber > 9999)
        {
            decimalNumber = 0;
            hexPair++;
        }
    }

    Benchmark:

    using System.Diagnostics;
    using System.Runtime.CompilerServices;
    
    Console.WriteLine("Hello, World!");
     
     var sw = new Stopwatch();
    sw.Start();
    int count = 0;
    foreach (string? file in Directory.EnumerateFiles(@"C:\Users\dacantu\consoleapps\110754-log-generation\bin\Debug\net9.0", "*", SearchOption.AllDirectories))
    {
        Use(file);
        count++;
    }
    
    sw.Stop();
    Console.WriteLine($"EnumerateFiles: {sw.ElapsedMilliseconds} ms");
    Console.WriteLine($"Count: {count}");
    
    
    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Use(string s) { }

    Results:

    9.0:

    C:\Users\dacantu\consoleapps\110754> dotnet run -c Release
    Hello, World!
    EnumerateFiles: 152 ms
    Count: 400005
    

    4.8.1:

    C:\Users\dacantu\consoleapps\110754-fx> dotnet run -c Release
    Hello, World!
    EnumerateFiles: 513 ms
    Count: 400005
    
  11. KalleOlaviNiemitalo commented on Feb 25, 2025

    @KalleOlaviNiemitalo

    @jozkee IIUC the regression is when you use a search pattern other than just *, like E10FF*.log. .NET Framework would pass the search pattern to Windows, which would pass it to the file system driver; but .NET Core enumerates all files in the directory and compares their names to the search pattern itself, so there is more copying of data from kernel space to user space and perhaps even more disk I/O.

  12. jozkee commented on Mar 12, 2025

    @jozkee
    Member

    @KalleOlaviNiemitalo I see, thanks. The more the searchPattern narrows it down, the larger the regression looks.
    In .NET Core, the interop overhead remains the same between * and e.g. E10F0000000.log, while in .NET FW, it decreases drastically because the filter is passed to the Windows API.

    Even increasing the buffer size so it reduces the number of calls to NtQueryDirectoryFile doesn't reduce that much the time spent in it, so I suspect the large chunk of time doesn't come from many jumps between managed and native but from filling the buffer.

  13. jeffhandley commented on Mar 3, 2026

    @jeffhandley
    Member

    This should be addressed by #122947 and #123695, included in .NET 11 Preview 1.

  14. locked and limited conversation to collaborators on Apr 3, 2026
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

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions