Skip to content

Fastest way to read a text file in C#: net10.0, async reads, benchmark re-run - #2172

Open
vladimir-pecanac-main wants to merge 1 commit into
CodeMazeBlog:mainfrom
vladimir-pecanac-main:seo/96144-read-text-file
Open

Fastest way to read a text file in C#: net10.0, async reads, benchmark re-run#2172
vladimir-pecanac-main wants to merge 1 commit into
CodeMazeBlog:mainfrom
vladimir-pecanac-main:seo/96144-read-text-file

Conversation

@vladimir-pecanac-main

Copy link
Copy Markdown
Collaborator

This retargets the fastest way to read a text file sample to net10.0, adds the asynchronous reads the article never covered, and re-runs the benchmark so the article's numbers describe the runtime it says it was tested on. The published numbers were measured on net7.0 with BenchmarkDotNet 0.13.7 before September 2023.

What changed

  • Both projects net7.0 -> net10.0, and <Nullable> enabled on the sample project (the test project already had it).
  • BenchmarkDotNet 0.13.7 -> 0.15.8.
  • Test packages to current: Microsoft.NET.Test.Sdk 18.9.0, xunit 2.9.3, xunit.runner.visualstudio 4.0.0, coverlet.collector 10.0.1. The sample stays on the xunit v2 line; only the runner moves, and the 13 tests pass on it.
  • NU1903 is cleared by those bumps, and that is worth saying out loud rather than leaving as a happy accident. The Tests project was pulling Newtonsoft.Json 9.0.1 transitively through the 2022-era Microsoft.NET.Test.Sdk 17.3.2 pin, and that version carries a known high-severity advisory (GHSA-5crp-9r3c-p9vr). The test SDK bump drops the transitive reference.
  • Primary constructor on WaysToReadATextFileInCsharp. The _sampleFilePath field is kept so every snippet the article prints is still byte-identical to the sample.
  • UseBufferedStreamObjectWithNoFileStreamBuffer() moves to the FileStreamOptions overload. The section teaches that a buffer size of 0 disables buffering, which is what FileStreamOptions.BufferSize documents ("0 or 1 means that buffering should be disabled"), while the five-argument FileStream constructor the sample was using documents its bufferSize as "greater than 0" and lists ArgumentOutOfRangeException when it "is negative or zero". Both forms run without throwing on .NET 10, so this changes no behaviour; it moves the section onto the API whose documentation states what the section teaches.
  • Three asynchronous reads, with matching benchmarks and tests: UseFileReadAllTextAsync(), UseFileReadLinesAsync(), UseStreamReaderReadToEndAsync().
    • The StreamReader one is built over a FileStream carrying FileOptions.Asynchronous, not over the path-taking StreamReader constructor. dotnet/runtime is blunt about why, in System/IO/File.cs: "If we use the path-taking constructors we will not have FileOptions.Asynchronous set and we will have asynchronous file access faked by the thread pool. We want the real thing."
  • The benchmark file is now found with AppContext.BaseDirectory and copied to the output directory, replacing a six-level Directory.GetParent(...).Parent.Parent.Parent.Parent.Parent.Parent walk tied to one build layout. With Nullable enabled that walk was also seven CS8602 warnings.

Verification

dotnet build -c Release   ->  0 Errors, 0 Warnings
dotnet test  -c Release   ->  Failed: 0, Passed: 13, Skipped: 0

Benchmark, re-run on net10.0

The benchmark file is the folder's own BenchmarkTextFile.txt, roughly 98 KB. Every method except File.ReadAllText / StreamReader.ReadToEnd and their asynchronous forms reads the file line by line and then reassembles one string through a StringBuilder, so the numbers measure "read the file and rebuild it as a string", not "read the file".

Run 1, verbatim:

BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update)
AMD Ryzen 5 3600 3.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.302
  [Host]     : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3


| Method                               | Mean     | Error    | StdDev   | Allocated |
|------------------------------------- |---------:|---------:|---------:|----------:|
| FileReadAllText                      | 187.3 us |  1.40 us |  1.09 us | 410.83 KB |
| StreamReaderReadToEnd                | 189.4 us |  3.74 us |  4.60 us | 410.83 KB |
| StreamReaderReadBlockWithSpan        | 193.8 us |  1.41 us |  1.32 us | 418.71 KB |
| StreamReaderReadBlockWithArrayPool   | 208.8 us |  0.86 us |  0.76 us | 609.89 KB |
| StreamReaderReadBlock                | 216.2 us |  3.58 us |  2.99 us | 617.92 KB |
| BufferedStreamWithNoFileStreamBuffer | 237.4 us |  3.09 us |  2.89 us | 626.83 KB |
| StreamReaderReadLine                 | 237.8 us |  3.26 us |  2.89 us | 626.78 KB |
| BufferedStreamObject                 | 240.2 us |  4.77 us |  5.30 us | 626.85 KB |
| FileReadLines                        | 248.7 us |  4.82 us |  4.95 us | 626.84 KB |
| FileReadAllLines                     | 248.9 us |  3.79 us |  4.37 us | 650.89 KB |
| StreamReaderReadToEndAsync           | 539.7 us | 10.65 us | 13.47 us | 415.68 KB |
| FileReadAllTextAsync                 | 551.5 us | 10.91 us | 12.56 us | 420.12 KB |
| FileReadLinesAsync                   | 707.5 us | 13.58 us | 12.70 us | 704.59 KB |

// * Hints *
HideColumnsAnalyser
  Summary -> Hidden columns: Gen0, Gen1, Gen2
Outliers
  WaysToReadATextFileInCsharpBenchmark.FileReadAllText: Default                    -> 3 outliers were removed (193.46 us..209.26 us)
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadToEnd: Default              -> 1 outlier  was  removed (204.76 us)
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadBlockWithArrayPool: Default -> 1 outlier  was  removed (216.74 us)
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadBlock: Default              -> 2 outliers were removed (231.90 us, 233.50 us)
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadLine: Default               -> 1 outlier  was  removed (246.98 us)
  WaysToReadATextFileInCsharpBenchmark.FileReadLines: Default                      -> 1 outlier  was  removed (265.29 us)
  WaysToReadATextFileInCsharpBenchmark.FileReadAllLines: Default                   -> 5 outliers were removed (261.76 us..272.55 us)

// * Legends *
  Mean      : Arithmetic mean of all measurements
  Error     : Half of 99.9% confidence interval
  StdDev    : Standard deviation of all measurements
  Allocated : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
  1 us      : 1 Microsecond (0.000001 sec)

Run 2, verbatim, on the same machine and the same binaries:

BenchmarkDotNet v0.15.8, Windows 10 (10.0.19045.6466/22H2/2022Update)
AMD Ryzen 5 3600 3.60GHz, 1 CPU, 12 logical and 6 physical cores
.NET SDK 10.0.302
  [Host]     : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3
  DefaultJob : .NET 10.0.10 (10.0.10, 10.0.1026.32716), X64 RyuJIT x86-64-v3


| Method                               | Mean     | Error    | StdDev   | Allocated |
|------------------------------------- |---------:|---------:|---------:|----------:|
| StreamReaderReadToEnd                | 191.8 us |  3.81 us |  4.54 us | 410.83 KB |
| FileReadAllText                      | 192.3 us |  3.81 us |  4.53 us | 410.83 KB |
| StreamReaderReadBlockWithSpan        | 199.0 us |  3.79 us |  3.54 us | 418.71 KB |
| StreamReaderReadBlockWithArrayPool   | 216.5 us |  3.51 us |  3.28 us | 609.89 KB |
| StreamReaderReadBlock                | 217.3 us |  3.72 us |  3.30 us | 617.92 KB |
| BufferedStreamObject                 | 243.6 us |  4.85 us |  4.98 us | 626.85 KB |
| StreamReaderReadLine                 | 245.6 us |  4.04 us |  3.58 us | 626.78 KB |
| BufferedStreamWithNoFileStreamBuffer | 245.9 us |  4.32 us |  3.83 us | 626.83 KB |
| FileReadLines                        | 252.3 us |  5.04 us | 11.59 us | 626.84 KB |
| FileReadAllLines                     | 258.9 us |  4.84 us |  9.10 us | 650.89 KB |
| StreamReaderReadToEndAsync           | 547.7 us | 10.65 us | 14.58 us | 415.67 KB |
| FileReadAllTextAsync                 | 569.9 us |  9.15 us |  8.11 us | 420.11 KB |
| FileReadLinesAsync                   | 709.4 us |  9.83 us |  9.20 us | 704.59 KB |

// * Hints *
HideColumnsAnalyser
  Summary -> Hidden columns: Gen0, Gen1, Gen2
Outliers
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadToEnd: Default                -> 1 outlier  was  removed (205.58 us)
  WaysToReadATextFileInCsharpBenchmark.FileReadAllText: Default                      -> 2 outliers were removed (208.09 us, 208.45 us)
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadBlock: Default                -> 1 outlier  was  removed (234.21 us)
  WaysToReadATextFileInCsharpBenchmark.BufferedStreamObject: Default                 -> 1 outlier  was  removed (273.15 us)
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadLine: Default                 -> 3 outliers were removed (256.22 us..281.00 us)
  WaysToReadATextFileInCsharpBenchmark.BufferedStreamWithNoFileStreamBuffer: Default -> 1 outlier  was  removed (260.59 us)
  WaysToReadATextFileInCsharpBenchmark.FileReadLines: Default                        -> 1 outlier  was  removed (316.49 us)
  WaysToReadATextFileInCsharpBenchmark.FileReadAllLines: Default                     -> 2 outliers were removed (284.52 us, 286.61 us)
  WaysToReadATextFileInCsharpBenchmark.StreamReaderReadToEndAsync: Default           -> 2 outliers were removed (627.91 us, 700.24 us)
  WaysToReadATextFileInCsharpBenchmark.FileReadAllTextAsync: Default                 -> 1 outlier  was  removed (622.51 us)

// * Legends *
  Mean      : Arithmetic mean of all measurements
  Error     : Half of 99.9% confidence interval
  StdDev    : Standard deviation of all measurements
  Allocated : Allocated memory per single operation (managed only, inclusive, 1KB = 1024B)
  1 us      : 1 Microsecond (0.000001 sec)

What the two runs say

The old winner has not been beaten, but it can no longer be named on its own. File.ReadAllText() and StreamReader.ReadToEnd() finish within half a microsecond to two microseconds of each other, well inside their own error bars, and the order between them flips between runs: run 1 puts File.ReadAllText() first by 2.1 us, run 2 puts StreamReader.ReadToEnd() first by 0.5 us. They also allocate the same 410.83 KB in both runs. The honest reading is a tie between the two methods that read the file whole in one pass, and the article now says that instead of naming one.

Two of the published article's other claims move with the re-run:

  • Still true: the three ReadBlock variants come next, and the Span one is both the fastest of the three and the lightest (418.71 KB against 609.89 KB and 617.92 KB).
  • No longer true: the published article says StreamReader.ReadLine() and File.ReadLines() are the two slowest. On .NET 10 StreamReader.ReadLine() sits mid-table, and the bottom of the synchronous ten is File.ReadAllLines() and File.ReadLines().
  • Newly measured: the asynchronous reads are roughly three times slower on this file, not "slightly" slower. On a warm 98 KB file the awaits are pure overhead, and what asynchronous reads buy is a free thread, not a shorter wait.

Timings are from one desktop and are there to rank the methods, not to be quoted as absolute figures.

…e-run benchmark

- Both projects net7.0 -> net10.0; Nullable enabled on the sample project.
- BenchmarkDotNet 0.13.7 -> 0.15.8.
- Test packages to current: Microsoft.NET.Test.Sdk 18.9.0, xunit 2.9.3,
  xunit.runner.visualstudio 4.0.0, coverlet.collector 10.0.1. This clears
  NU1903 (Newtonsoft.Json 9.0.1 came in transitively through the old test SDK).
- Primary constructor on WaysToReadATextFileInCsharp.
- UseBufferedStreamObjectWithNoFileStreamBuffer moves to the FileStreamOptions
  overload. FileStreamOptions.BufferSize documents "0 or 1 means that buffering
  should be disabled"; the five-argument FileStream constructor documents its
  bufferSize as greater than zero and throws on zero. Same behaviour, but the
  section now stands on the API whose docs state what it teaches.
- Three async reads with matching benchmarks and tests:
  UseFileReadAllTextAsync, UseFileReadLinesAsync, UseStreamReaderReadToEndAsync.
  The StreamReader one is built over a FileStream with FileOptions.Asynchronous
  rather than the path-taking constructor, which does not set that flag and so
  fakes async file access on the thread pool.
- Benchmark file resolved with AppContext.BaseDirectory and copied to the output
  directory, replacing a six-level Directory.GetParent walk tied to one layout.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant