Skip to content

Fix StreamReader EOF handling and improve perf - #69888

Merged
stephentoub merged 3 commits into
dotnet:mainfrom
GrabYourPitchforks:streamreader
Nov 7, 2022
Merged

Fix StreamReader EOF handling and improve perf#69888
stephentoub merged 3 commits into
dotnet:mainfrom
GrabYourPitchforks:streamreader

Conversation

@GrabYourPitchforks

Copy link
Copy Markdown
Member

This fixes a bug in StreamReader where it doesn't properly pass flush: true to the backing Decoder instance when EOF is reached. This could result in cases where partial data in the decoder buffer is never processed.

For a concrete example, consider the below sample.

usingSystem.IO;usingSystem.Text;varreader=newStreamReader(newMemoryStream(newbyte[]{0xF0}),newUTF8Encoding(encoderShouldEmitUTF8Identifier:false,throwOnInvalidBytes:true));Console.WriteLine(reader.ReadToEnd());

This prints an empty string to the console, even though it should throw DecoderFallbackException since the caller explicitly requested that they want to reject invalid data.

I also took this opportunity to tighten the ReadLine and ReadLineAsync methods, including using StringBuilderCache. This shouldn't blow the cache because we expect lines to be 80 - 100 chars at the high end, which is well within what StringBuilderCache can handle.

This results in an approx. 50% throughput increase in ReadLine and ReadLineAsync, as shown in the below benchmarks. The benchmarks also show a decrease in overall StringBuilder allocations.

MethodJobToolchainMeanErrorStdDevRatioRatioSDGen 0Allocated
GetLineCountJob-QTEUMBmain404.5 μs4.27 μs4.19 μs1.000.00105.4688431 KB
GetLineCountJob-OSITYFsr263.5 μs5.15 μs7.70 μs0.660.0293.7500384 KB
GetLineCountAsyncJob-QTEUMBmain645.5 μs12.58 μs11.15 μs1.000.00169.9219694 KB
GetLineCountAsyncJob-OSITYFsr421.6 μs8.35 μs12.24 μs0.660.02158.2031647 KB
// In the below benchmarks, _ms is a MemoryStream whose contents have been initialized from:// https://www.gutenberg.org/files/11/11-0.txt[Benchmark]publicintGetLineCount(){_ms.Position=0;StreamReaderreader=newStreamReader(_ms);intlineCount=0;while(reader.ReadLine()!=null){lineCount++;}returnlineCount;}[Benchmark]publicintGetLineCountAsync(){_ms.Position=0;StreamReaderreader=newStreamReader(_ms);intlineCount=0;while(reader.ReadLineAsync().GetAwaiter().GetResult()!=null){lineCount++;}returnlineCount;}

/cc @dotnet/area-system-io

@ghost

Copy link
Copy Markdown

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

Issue Details

This fixes a bug in StreamReader where it doesn't properly pass flush: true to the backing Decoder instance when EOF is reached. This could result in cases where partial data in the decoder buffer is never processed.

For a concrete example, consider the below sample.

usingSystem.IO;usingSystem.Text;varreader=newStreamReader(newMemoryStream(newbyte[]{0xF0}),newUTF8Encoding(encoderShouldEmitUTF8Identifier:false,throwOnInvalidBytes:true));Console.WriteLine(reader.ReadToEnd());

This prints an empty string to the console, even though it should throw DecoderFallbackException since the caller explicitly requested that they want to reject invalid data.

I also took this opportunity to tighten the ReadLine and ReadLineAsync methods, including using StringBuilderCache. This shouldn't blow the cache because we expect lines to be 80 - 100 chars at the high end, which is well within what StringBuilderCache can handle.

This results in an approx. 50% throughput increase in ReadLine and ReadLineAsync, as shown in the below benchmarks. The benchmarks also show a decrease in overall StringBuilder allocations.

MethodJobToolchainMeanErrorStdDevRatioRatioSDGen 0Allocated
GetLineCountJob-QTEUMBmain404.5 μs4.27 μs4.19 μs1.000.00105.4688431 KB
GetLineCountJob-OSITYFsr263.5 μs5.15 μs7.70 μs0.660.0293.7500384 KB
GetLineCountAsyncJob-QTEUMBmain645.5 μs12.58 μs11.15 μs1.000.00169.9219694 KB
GetLineCountAsyncJob-OSITYFsr421.6 μs8.35 μs12.24 μs0.660.02158.2031647 KB
// In the below benchmarks, _ms is a MemoryStream whose contents have been initialized from:// https://www.gutenberg.org/files/11/11-0.txt[Benchmark]publicintGetLineCount(){_ms.Position=0;StreamReaderreader=newStreamReader(_ms);intlineCount=0;while(reader.ReadLine()!=null){lineCount++;}returnlineCount;}[Benchmark]publicintGetLineCountAsync(){_ms.Position=0;StreamReaderreader=newStreamReader(_ms);intlineCount=0;while(reader.ReadLineAsync().GetAwaiter().GetResult()!=null){lineCount++;}returnlineCount;}

/cc @dotnet/area-system-io

Author:GrabYourPitchforks
Assignees:-
Labels:

area-System.IO

Milestone:7.0.0

Comment threadsrc/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs Outdated
Comment threadsrc/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs Outdated
Comment threadsrc/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs Outdated
Comment threadsrc/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs Outdated
Comment threadsrc/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs Outdated
@danmoseley

danmoseley commented Jul 25, 2022

Copy link
Copy Markdown
Contributor

@GrabYourPitchforks do you expect to have time to continue this PR? I see that #62552 by @Trapov. Is much work remaining?

@stephentoub how important is it that we get these PR's into .NET 7?

@stephentoub

Copy link
Copy Markdown
Member

It'd be valuable to get this PR for .NET 7, but we wouldn't block the release on it 😄

@jozkeejozkee left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, I will close-reopen it to retrigger CI.

@jozkeejozkee closed this Aug 29, 2022
@jozkeejozkee reopened this Aug 29, 2022
@jozkee

Copy link
Copy Markdown
Member

CI issues are related:

...
Discovering: System.IO.Tests (method display = ClassAndMethod, method display options = None)
Discovered: System.IO.Tests (found 719 of 727 test cases)
Starting: System.IO.Tests (parallel test collections = on, max threads = 4)
Process terminated. Assertion failed.
at System.IO.StreamReader.ReadAsyncInternal(Memory`1 buffer, CancellationToken cancellationToken) in /_/src/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs:line 1215
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine) in /_/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs:line 38
at System.IO.StreamReader.ReadAsyncInternal(Memory`1 buffer, CancellationToken cancellationToken)
at System.IO.StreamReader.ReadAsync(Memory`1 buffer, CancellationToken cancellationToken) in /_/src/libraries/System.Private.CoreLib/src/System/IO/StreamReader.cs:line 1080
at System.IO.Tests.StreamReaderTests.ReadAsync_LongStreamIntoShortBuffer_PerformsFinalFlushCorrectly() in /_/src/libraries/System.IO/tests/StreamReader/StreamReaderTests.cs:line 819
at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine) in /_/src/libraries/System.Private.CoreLib/src/System/Runtime/CompilerServices/AsyncMethodBuilderCore.cs:line 38
at System.IO.Tests.StreamReaderTests.ReadAsync_LongStreamIntoShortBuffer_PerformsFinalFlushCorrectly()
at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor)
...

@stephentoub

Copy link
Copy Markdown
Member

@GrabYourPitchforks, I'm planning to take over this PR. Let me know if you'd prefer I not. Thanks.

@adamsitnikadamsitnik modified the milestones: 7.0.0, 8.0.0Nov 7, 2022

@adamsitnikadamsitnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM, thank you both @GrabYourPitchforks and @stephentoub !

@stephentoub is there any chance you could run these benchmarks and share the results before we hit the merge button?

Buffer.BlockCopy(_byteBuffer, n, _byteBuffer, 0, _byteLen - n);
byte[] byteBuffer = _byteBuffer;
_ = byteBuffer.Length; // allow JIT to prove object is not null
new ReadOnlySpan<byte>(byteBuffer, n, _byteLen - n).CopyTo(byteBuffer);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

not related to the changes you have made: I can see that once the encoding is detected, this method is being called:

// Big Endian Unicode
_encoding=Encoding.BigEndianUnicode;
CompressBuffer(2);

So for the default buffer size and Unicode:

privateconstintDefaultBufferSize=1024;// Byte buffer size

we copy 1022 bytes in place.

Why don't we just update _byteLen and _bytePos? I know it would require changing some other parts of the code that rely on the assumption that _bytePos == 0 after the read:

_charLen=_decoder.GetChars(_byteBuffer,0,_byteLen,_charBuffer,0,flush:false);

I am asking this question because this PR improves the performance of DetectEncoding by moving rarely called code out of hot path and it seems like another perf improvement we could make here while we are at it.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@stephentoub just checking you saw this comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I did. It was preexisting this PR and my goal was just to land this PR. Anyone is welcome to follow up on the comment.

@stephentoub

Copy link
Copy Markdown
Member

is there any chance you could run these benchmarks and share the results before we hit the merge button?

MethodJobToolchainLineLengthRangeMeanErrorStdDevMedianMinMaxRatioRatioSDGen0Gen1AllocatedAlloc Ratio
ReadLineJob-ICUQWW\main\corerun.exe[ 0, 0]54.007 us0.5088 us0.4510 us53.872 us53.521 us55.055 us1.000.000.4281-3.27 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[ 0, 0]116.675 us2.2660 us2.3270 us115.754 us113.742 us121.385 us2.170.040.4596-3.27 KB1.00
ReadLineJob-ICUQWW\main\corerun.exe[ 0, 1024]25.894 us0.1901 us0.1484 us25.897 us25.663 us26.143 us1.000.009.93380.103561.3 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[ 0, 1024]5.566 us0.2926 us0.3252 us5.510 us5.135 us6.442 us0.220.015.87090.065035.98 KB0.59
ReadLineJob-ICUQWW\main\corerun.exe[ 1, 1]75.675 us1.5807 us1.7570 us75.390 us73.353 us79.385 us1.000.0021.6121-132.69 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[ 1, 1]112.312 us1.4151 us1.2545 us112.014 us110.407 us114.133 us1.480.0421.2766-131.28 KB0.99
ReadLineJob-ICUQWW\main\corerun.exe[ 1, 8]70.865 us1.3965 us1.4341 us71.037 us68.668 us73.844 us1.000.0015.3460-94.88 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[ 1, 8]72.269 us1.1682 us1.0928 us72.799 us70.873 us73.868 us1.020.0314.9212-92.05 KB0.97
ReadLineJob-ICUQWW\main\corerun.exe[ 9, 32]36.804 us0.8704 us0.8938 us36.765 us35.670 us39.108 us1.000.008.7243-54.08 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[ 9, 32]22.755 us0.3234 us0.3025 us22.701 us22.397 us23.244 us0.620.028.20140.094350.4 KB0.93
ReadLineJob-ICUQWW\main\corerun.exe[ 33, 128]28.526 us0.5235 us0.4641 us28.618 us27.673 us29.073 us1.000.007.2415-44.88 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[ 33, 128]8.806 us0.2594 us0.2987 us8.739 us8.367 us9.461 us0.310.016.42820.069939.43 KB0.88
ReadLineJob-ICUQWW\main\corerun.exe[ 129, 1024]27.006 us0.6048 us0.6723 us26.859 us26.141 us28.397 us1.000.009.93380.103561.08 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[ 129, 1024]5.799 us0.3215 us0.3702 us5.786 us5.207 us6.538 us0.220.015.96950.067336.68 KB0.60
ReadLineJob-ICUQWW\main\corerun.exe[1025, 2048]29.657 us0.7368 us0.8486 us29.414 us28.271 us31.111 us1.000.0014.76650.457990.71 KB1.00
ReadLineJob-TBQMKP\pr\corerun.exe[1025, 2048]5.721 us0.1653 us0.1838 us5.682 us5.476 us6.057 us0.190.016.06260.069437.24 KB0.41
ReadLineAsyncJob-ICUQWW\main\corerun.exe[ 0, 0]280.831 us5.0815 us4.5046 us283.446 us274.084 us285.435 us1.000.0094.31821.1364579.27 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[ 0, 0]323.730 us3.8763 us3.6259 us325.144 us316.879 us328.661 us1.150.0393.7500-579.27 KB1.00
ReadLineAsyncJob-ICUQWW\main\corerun.exe[ 0, 1024]16.556 us0.3721 us0.3982 us16.486 us16.131 us17.416 us1.000.0010.32610.203863.63 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[ 0, 1024]7.596 us0.2903 us0.3343 us7.596 us7.129 us8.213 us0.460.026.22940.117538.3 KB0.60
ReadLineAsyncJob-ICUQWW\main\corerun.exe[ 1, 1]221.409 us3.1384 us2.6207 us220.859 us217.106 us226.193 us1.000.0083.90410.8562516.73 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[ 1, 1]248.871 us2.3755 us2.2220 us248.263 us245.998 us253.694 us1.120.0283.33330.9921515.33 KB1.00
ReadLineAsyncJob-ICUQWW\main\corerun.exe[ 1, 8]139.495 us3.7694 us4.1897 us137.139 us136.879 us148.902 us1.000.0047.01330.5531288.66 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[ 1, 8]150.359 us0.5854 us0.5190 us150.338 us149.494 us151.258 us1.080.0346.42860.5952285.83 KB0.99
ReadLineAsyncJob-ICUQWW\main\corerun.exe[ 9, 32]47.786 us0.7042 us0.6243 us47.467 us47.298 us49.212 us1.000.0017.09730.1900105.55 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[ 9, 32]43.736 us0.6887 us0.7370 us43.547 us42.842 us44.999 us0.920.0216.61850.1806101.87 KB0.97
ReadLineAsyncJob-ICUQWW\main\corerun.exe[ 33, 128]24.341 us0.4823 us0.4953 us24.217 us23.778 us25.317 us1.000.009.64510.096559.15 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[ 33, 128]16.727 us0.1437 us0.1274 us16.687 us16.596 us17.023 us0.690.028.71620.067653.7 KB0.91
ReadLineAsyncJob-ICUQWW\main\corerun.exe[ 129, 1024]16.183 us0.2827 us0.2903 us16.156 us15.764 us16.829 us1.000.0010.21700.189262.98 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[ 129, 1024]7.070 us0.0953 us0.0891 us7.040 us6.949 us7.232 us0.440.016.28380.084538.58 KB0.61
ReadLineAsyncJob-ICUQWW\main\corerun.exe[1025, 2048]17.619 us0.3329 us0.2951 us17.617 us17.201 us18.306 us1.000.0014.89420.556891.48 KB1.00
ReadLineAsyncJob-TBQMKP\pr\corerun.exe[1025, 2048]7.043 us0.1396 us0.1371 us7.045 us6.737 us7.275 us0.400.016.20450.113838.02 KB0.42

@EgorBo

EgorBo commented Nov 10, 2022

Copy link
Copy Markdown
Member

@kunalspathak

kunalspathak commented Nov 12, 2022

Copy link
Copy Markdown
Contributor

Windows arm64 improvements: dotnet/perf-autofiling-issues#9689

Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants

@GrabYourPitchforks@danmoseley@stephentoub@jozkee@EgorBo@kunalspathak@gfoidl@adamsitnik