Skip to content

Commit 8f91450

Browse files
committed
Add FileOpenMode option
1 parent f66b91f commit 8f91450

5 files changed

Lines changed: 125 additions & 53 deletions

File tree

README.md

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -86,31 +86,6 @@ The `ReadOnlyMemory<byte>` returned from `ReadLineAsync` or `TryReadLine` is onl
8686

8787
This design is similar to [System.IO.Pipelines](https://learn.microsoft.com/en-us/dotnet/standard/io/pipelines).
8888

89-
## Optimizing FileStream
90-
91-
Similar to `StreamReader`, `Utf8StreamReader` has the ability to open a `FileStream` by accepting a `string path`.
92-
93-
```csharp
94-
public Utf8StreamReader(string path)
95-
public Utf8StreamReader(string path, int bufferSize)
96-
public Utf8StreamReader(string path, FileStreamOptions options)
97-
public Utf8StreamReader(string path, FileStreamOptions options, int bufferSize)
98-
```
99-
100-
Unfortunately, the `FileStream` used by `StreamReader` is not optimized for modern .NET. For example, when using `FileStream` with asynchronous methods, it should be opened with `useAsync: true` for optimal performance. However, since `StreamReader` has both synchronous and asynchronous methods in its API, false is specified. Additionally, although `StreamReader` itself has a buffer and `FileStream` does not require a buffer, the buffer of `FileStream` is still being utilized.
101-
102-
Strictly speaking, [FileStream underwent a major overhaul in .NET 6](https://github.com/dotnet/runtime/issues/40359). The behavior is controlled by an internal `FileStreamStrategy`. For instance, on Windows, `SyncWindowsFileStreamStrategy` is used when useAsync is false, and `AsyncWindowsFileStreamStrategy` is used when useAsync is true. Moreover, if bufferSize is set to 1, the `FileStreamStrategy` is used directly, and it writes directly to the buffer passed via `ReadAsync(Memory<byte>)`. If any other value is specified, it is wrapped in a `BufferedFileStreamStrategy`.
103-
104-
Based on these observations of the internal behavior, `Utf8StreamReader` generates a `FileStream` with the following options:
105-
106-
```csharp
107-
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, useAsync: true)
108-
```
109-
110-
Furthermore, by devising how to call Stream as a whole, we have succeeded in making it function as a thin wrapper for [RandomAccess.ReadAsync](https://learn.microsoft.com/en-us/dotnet/api/system.io.randomaccess.readasync), which is the fastest way to call it.
111-
112-
For overloads that accept `FileStreamOptions`, the above settings are not reflected, so please adjust them manually.
113-
11489
## Read as `ReadOnlyMemory<char>`
11590

11691
You can convert it to a `Utf8TextReader` that extracts `ReadOnlyMemory<char>` or `string`. Although there is a conversion cost, it is still fast and low allocation, so it can be used as an alternative to `StreamReader`.
@@ -137,6 +112,58 @@ You can perform text processing without allocation, such as splitting `ReadOnlyS
137112

138113
When a string is needed, you can convert `ReadOnlyMemory<char>` to a string using `ToString()`. Even with the added string conversion, the performance is higher than `StreamReader`, so it can be used as a better alternative.
139114

115+
## Optimizing FileStream
116+
117+
Similar to `StreamReader`, `Utf8StreamReader` has the ability to open a `FileStream` by accepting a `string path`.
118+
119+
```csharp
120+
public Utf8StreamReader(string path, FileOpenMode fileOpenMode = FileOpenMode.Scalability)
121+
public Utf8StreamReader(string path, int bufferSize, FileOpenMode fileOpenMode = FileOpenMode.Scalability)
122+
public Utf8StreamReader(string path, FileStreamOptions options)
123+
public Utf8StreamReader(string path, FileStreamOptions options, int bufferSize)
124+
```
125+
126+
Unfortunately, the `FileStream` used by `StreamReader` is not optimized for modern .NET. For example, when using `FileStream` with asynchronous methods, it should be opened with `useAsync: true` for optimal performance. However, since `StreamReader` has both synchronous and asynchronous methods in its API, false is specified. Additionally, although `StreamReader` itself has a buffer and `FileStream` does not require a buffer, the buffer of `FileStream` is still being utilized.
127+
128+
It is difficult to handle `FileStream` correctly with high performance. By specifying a `string path`, the stream is opened with options optimized for `Utf8StreamReader`, so it is recommended to use this overload rather than opening `FileStream` yourself. The following is a benchmark of `FileStream`.
129+
130+
![image](https://github.com/Cysharp/Utf8StreamReader/assets/46207/83936827-2380-414a-9778-f53252689eb7)
131+
132+
`Utf8StreamReader` opens `FileStream` with the following settings:
133+
134+
```csharp
135+
var useAsync = (fileOpenMode == FileOpenMode.Scalability);
136+
new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, useAsync: useAsync)
137+
```
138+
139+
Due to historical reasons, the options for `FileStream` are odd, but by setting `bufferSize` to 1, you can avoid the use of internal buffers. `FileStream` has been significantly revamped in .NET 6, and by controlling the setting of this option and the way `Utf8StreamReader` is called as a whole, it can function as a thin wrapper around the fast [RandomAccess.ReadAsync](https://learn.microsoft.com/en-us/dotnet/api/system.io.randomaccess.readasync), allowing you to avoid most of the overhead of FileStream.
140+
141+
`FileOpenMode` is a proprietary option of `Utf8StreamReader`.
142+
143+
144+
```csharp
145+
public enum FileOpenMode
146+
{
147+
Scalability,
148+
Throughput
149+
}
150+
```
151+
152+
In a Windows environment, the table in the [IO section of the Performance Improvements in .NET 6 blog](https://devblogs.microsoft.com/dotnet/performance-improvements-in-net-6/#io) shows that throughput decreases when `useAsync: true` is used.
153+
154+
| Method | Runtime | IsAsync | BufferSize | Mean |
155+
| - | - | - | - | - |
156+
| ReadAsync | .NET 6.0 | True | 1 | 119.573 ms |
157+
| ReadAsync | .NET 6.0 | False | 1 | 36.018 ms |
158+
159+
By setting `Utf8StreamReader` to `FileOpenMode.Scalability`, true async I/O is enabled and scalability is prioritized. If set to `FileOpenMode.Throughput`, it internally becomes sync-over-async and consumes the ThreadPool, but reduces the overhead of asynchronous I/O and improves throughput.
160+
161+
If frequently executed within a server application, setting it to `Scalability`, and for batch applications, setting it to `Throughput` will likely yield the best performance characteristics. The default is `Scalability`.
162+
163+
In `Utf8StreamReader`, by carefully adjusting the buffer size on the `Utf8StreamReader` side, the performance difference is minimized. Please refer to the above benchmark results image for specific values.
164+
165+
For overloads that accept `FileStreamOptions`, the above settings are not reflected, so please adjust them manually.
166+
140167
## Reset
141168

142169
`Utf8StreamReader` is a class that supports reuse. By calling `Reset()`, the Stream and internal state are released. Using `Reset(Stream)`, it can be reused with a new `Stream`.
@@ -145,7 +172,7 @@ When a string is needed, you can convert `ReadOnlyMemory<char>` to a string usin
145172

146173
The constructor accepts `int bufferSize` and `bool leaveOpen` as parameters.
147174

148-
`int bufferSize` defaults to 4096, but if the data per line is large, changing the buffer size may improve performance. When the buffer size and the size per line are close, frequent buffer copy operations occur, leading to performance degradation.
175+
`int bufferSize` defaults to 65536 and the buffer is rented from `ArrayPool<byte>`. If the data per line is large, changing the buffer size may improve performance. When the buffer size and the size per line are close, frequent buffer copy operations occur, leading to performance degradation.
149176

150177
`bool leaveOpen` determines whether the internal Stream is also disposed when the object is disposed. The default is `false`, which means the Stream is disposed.
151178

sandbox/Benchmark/FromFile.cs

Lines changed: 53 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
using System.Text;
1+
using BenchmarkDotNet.Attributes;
2+
using Cysharp.IO;
23
using System.Text.Encodings.Web;
34
using System.Text.Json;
45
using System.Text.Unicode;
5-
using BenchmarkDotNet.Attributes;
6-
using Cysharp.IO;
76

87
namespace Benchmark;
98

@@ -43,52 +42,90 @@ public void GlobalCleanup()
4342
}
4443

4544
[Benchmark]
46-
public async Task StreamReader()
45+
public async Task StreamReaderFileStream()
4746
{
48-
using var fs = File.OpenRead(filePath);
49-
using var sr = new System.IO.StreamReader(fs);
47+
using var sr = new System.IO.StreamReader(filePath);
5048
string? line;
5149
while ((line = await sr.ReadLineAsync()) != null)
5250
{
53-
// Console.WriteLine(line);
51+
// ...
52+
}
53+
}
54+
55+
[Benchmark]
56+
public async Task Utf8StreamReaderFileStreamScalability()
57+
{
58+
using var sr = new Cysharp.IO.Utf8StreamReader(filePath, fileOpenMode: FileOpenMode.Scalability);
59+
while (await sr.LoadIntoBufferAsync())
60+
{
61+
while (sr.TryReadLine(out var line))
62+
{
63+
// ...
64+
}
65+
}
66+
}
67+
68+
[Benchmark]
69+
public async Task Utf8StreamReaderFileStreamThroughput()
70+
{
71+
using var sr = new Cysharp.IO.Utf8StreamReader(filePath, fileOpenMode: FileOpenMode.Throughput);
72+
while (await sr.LoadIntoBufferAsync())
73+
{
74+
while (sr.TryReadLine(out var line))
75+
{
76+
// ...
77+
}
78+
}
79+
}
80+
81+
[Benchmark]
82+
public async Task Utf8TextReaderFileStreamScalability()
83+
{
84+
using var sr = new Cysharp.IO.Utf8StreamReader(filePath, fileOpenMode: FileOpenMode.Scalability).AsTextReader();
85+
while (await sr.LoadIntoBufferAsync())
86+
{
87+
while (sr.TryReadLine(out var line))
88+
{
89+
// ...
90+
}
5491
}
5592
}
5693

5794
[Benchmark]
58-
public async Task Utf8StreamReader()
95+
public async Task Utf8TextReaderFileStreamThroughput()
5996
{
60-
using var sr = new Cysharp.IO.Utf8StreamReader(filePath);
97+
using var sr = new Cysharp.IO.Utf8StreamReader(filePath, fileOpenMode: FileOpenMode.Throughput).AsTextReader();
6198
while (await sr.LoadIntoBufferAsync())
6299
{
63100
while (sr.TryReadLine(out var line))
64101
{
65-
// Console.WriteLine(Encoding.UTF8.GetString( line.Span));
102+
// ...
66103
}
67104
}
68105
}
69106

70107
[Benchmark]
71-
public async Task Utf8TextReader()
108+
public async Task Utf8TextReaderToStringFileStreamScalability()
72109
{
73-
using var sr = new Cysharp.IO.Utf8StreamReader(filePath).AsTextReader();
110+
using var sr = new Cysharp.IO.Utf8StreamReader(filePath, fileOpenMode: FileOpenMode.Scalability).AsTextReader();
74111
while (await sr.LoadIntoBufferAsync())
75112
{
76113
while (sr.TryReadLine(out var line))
77114
{
78-
// Console.WriteLine(line);
115+
_ = line.ToString();
79116
}
80117
}
81118
}
82119

83120
[Benchmark]
84-
public async Task Utf8TextReaderToString()
121+
public async Task Utf8TextReaderToStringFileStreamThroughput()
85122
{
86-
using var sr = new Cysharp.IO.Utf8StreamReader(filePath).AsTextReader();
123+
using var sr = new Cysharp.IO.Utf8StreamReader(filePath, fileOpenMode: FileOpenMode.Throughput).AsTextReader();
87124
while (await sr.LoadIntoBufferAsync())
88125
{
89126
while (sr.TryReadLine(out var line))
90127
{
91-
// Console.WriteLine(line);
128+
_ = line.ToString();
92129
}
93130
}
94131
}

sandbox/Benchmark/FromMemory.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
2-
using System.Buffers;
1+
using System.Buffers;
32
using System.IO.Pipelines;
43
using System.Text;
54
using System.Text.Encodings.Web;

src/Utf8StreamReader/Utf8StreamReader.cs

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,18 @@
44

55
namespace Cysharp.IO;
66

7+
public enum FileOpenMode
8+
{
9+
Scalability,
10+
Throughput
11+
}
12+
713
public sealed class Utf8StreamReader : IAsyncDisposable, IDisposable
814
{
915
// NetStandard2.1 does not have Array.MaxLength so use constant.
1016
const int ArrayMaxLength = 0X7FFFFFC7;
1117

12-
const int DefaultBufferSize = 4096;
18+
const int DefaultBufferSize = 65536;
1319
const int MinBufferSize = 1024;
1420

1521
Stream stream;
@@ -56,21 +62,24 @@ public Utf8StreamReader(Stream stream, int bufferSize, bool leaveOpen)
5662
this.leaveOpen = leaveOpen;
5763
}
5864

59-
public Utf8StreamReader(string path)
60-
: this(path, DefaultBufferSize)
65+
public Utf8StreamReader(string path, FileOpenMode fileOpenMode = FileOpenMode.Scalability)
66+
: this(path, DefaultBufferSize, fileOpenMode)
6167
{
6268
}
6369

64-
public Utf8StreamReader(string path, int bufferSize)
65-
: this(OpenPath(path), bufferSize, leaveOpen: false)
70+
public Utf8StreamReader(string path, int bufferSize, FileOpenMode fileOpenMode = FileOpenMode.Scalability)
71+
: this(OpenPath(path, fileOpenMode), bufferSize, leaveOpen: false)
6672
{
6773
}
6874

69-
static FileStream OpenPath(string path)
75+
static FileStream OpenPath(string path, FileOpenMode fileOpenMode = FileOpenMode.Scalability)
7076
{
7177
// useAsync:1 + bufferSize 1 chooses internal FileStreamStrategy to AsyncWindowsFileStreamStrategy(in windows)
7278
// but bufferSize larger than 1, wrapped strategy with BufferedFileStreamStrategy, it is unnecessary in ReadLine.
73-
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, useAsync: true);
79+
var fileOptions = (fileOpenMode == FileOpenMode.Scalability)
80+
? (FileOptions.SequentialScan | FileOptions.Asynchronous)
81+
: FileOptions.SequentialScan;
82+
return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 1, options: fileOptions);
7483
}
7584

7685
#if !NETSTANDARD

src/Utf8StreamReader/Utf8TextReader.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ namespace Cysharp.IO;
66

77
public sealed class Utf8TextReader : IDisposable, IAsyncDisposable
88
{
9-
const int DefaultCharBufferSize = 4096;
10-
const int MinBufferSize = 1024;
9+
const int DefaultCharBufferSize = 1024; // buffer per line.
10+
const int MinBufferSize = 128;
1111

1212
readonly Utf8StreamReader reader;
1313
readonly int bufferSize;

0 commit comments

Comments
 (0)