Skip to content

Commit ea822ae

Browse files
committed
Add benchmarks and read loop tests for the binary snapshot compare
StreamComparerBenchmarks measures the previous commit against the implementation it replaced, kept in LegacyStreamComparer with the buffer size and read overload lifted to parameters. The four rungs isolate each part of the change: 8KB sequential byte[] reads, then 64KB, then the Memory overload, then the shipped overlapped version. Both sides are real async FileStreams opened the way IoHelpers.OpenRead opens them, and each size carries its own baseline so the ratios compare like for like. The 1MB compare drops to 0.13 of the old time and a ninth of the allocation, and 64KB to 0.26. At 2KB the buffer size does nothing, one read either way, so the whole 0.77 there is the overlapping. A mismatch that exits on the first chunk is 0.51. The buffered category is a MemoryStream against a FileStream, not a MemoryStream against another one. InnerCompare always opens the verified side with IoHelpers.OpenRead, so a pair of MemoryStreams never reaches the comparer, and measuring that shape reported a regression that cannot occur. Against the real shape it is 0.36. MixedEqualSpanningMultipleBuffers covers that buffered shape, which had only not-equal coverage. EqualWithShortReads covers the accumulation loop in ReadBufferAsync: a local FileStream returns the full buffer every time, so replacing that loop with a single read leaves every other test passing. A stream that always reads short keeps both sides chunk aligned under test, where a desync would surface as a spurious NotEqual on a network share.
1 parent 1a80b22 commit ea822ae

3 files changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
using System.Buffers;
2+
3+
// StreamComparer as it stood before the current branch, with the two things the branch
4+
// changed inside ReadBufferAsync lifted to parameters, so each step can be measured on
5+
// its own:
6+
// * bufferSize: 1024 * sizeof(long) (8K) was the old constant, 64K is the new one
7+
// * useMemoryOverload: the byte[] ReadAsync overload wraps every call in a Task on a
8+
// FileStream opened for async IO, the Memory<byte> overload does not
9+
// The reads still run one after the other, which is the part the branch replaced with
10+
// an overlapped pair.
11+
static class LegacyStreamComparer
12+
{
13+
public static async Task<CompareResult> AreEqual(Stream stream1, Stream stream2, int bufferSize, bool useMemoryOverload)
14+
{
15+
var buffer1 = ArrayPool<byte>.Shared.Rent(bufferSize);
16+
var buffer2 = ArrayPool<byte>.Shared.Rent(bufferSize);
17+
try
18+
{
19+
while (true)
20+
{
21+
var count1 = await ReadBufferAsync(stream1, buffer1, bufferSize, useMemoryOverload);
22+
var count2 = await ReadBufferAsync(stream2, buffer2, bufferSize, useMemoryOverload);
23+
24+
if (count1 != count2)
25+
{
26+
return CompareResult.NotEqual();
27+
}
28+
29+
if (count1 == 0)
30+
{
31+
return CompareResult.Equal;
32+
}
33+
34+
if (!buffer1.AsSpan(0, count1).SequenceEqual(buffer2.AsSpan(0, count1)))
35+
{
36+
return CompareResult.NotEqual();
37+
}
38+
}
39+
}
40+
finally
41+
{
42+
ArrayPool<byte>.Shared.Return(buffer1);
43+
ArrayPool<byte>.Shared.Return(buffer2);
44+
}
45+
}
46+
47+
static async Task<int> ReadBufferAsync(Stream stream, byte[] buffer, int bufferSize, bool useMemoryOverload)
48+
{
49+
var bytesRead = 0;
50+
while (bytesRead < bufferSize)
51+
{
52+
int read;
53+
if (useMemoryOverload)
54+
{
55+
read = await stream.ReadAsync(buffer.AsMemory(bytesRead, bufferSize - bytesRead));
56+
}
57+
else
58+
{
59+
read = await stream.ReadAsync(buffer, bytesRead, bufferSize - bytesRead);
60+
}
61+
62+
if (read == 0)
63+
{
64+
// Reached end of stream.
65+
return bytesRead;
66+
}
67+
68+
bytesRead += read;
69+
}
70+
71+
return bytesRead;
72+
}
73+
}
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
using BenchmarkDotNet.Configs;
2+
3+
// StreamComparer is the default comparer for every binary snapshot, and on a passing run
4+
// both files are read end to end, so the read path is the whole cost. The branch changed
5+
// three things, and the ladder below isolates each one:
6+
// * Legacy_* 8K buffer, byte[] overload, sequential reads. The implementation as
7+
// it stood on main.
8+
// * Legacy64K_* only the buffer size changed, so a snapshot of any usual size is a
9+
// couple of reads instead of dozens.
10+
// * LegacyMemory_* 64K plus the Memory<byte> ReadAsync overload, which drops the Task
11+
// the byte[] overload allocates per call on an async FileStream.
12+
// * Current_* the shipped implementation: the above plus the two reads overlapped.
13+
//
14+
// The verified side is always an async FileStream, because InnerCompare opens it with
15+
// IoHelpers.OpenRead. The received side is a FileStream in the usual case, and a
16+
// MemoryStream when FileComparer had to buffer a non seekable one, which is the Buffered
17+
// category. Both sides are opened once and rewound per invocation, matching FileComparer,
18+
// which reads both streams from position 0.
19+
//
20+
// The OS file cache is warm after the first iteration, so these measure the async read
21+
// machinery rather than the disk. Overlapping is worth more than this shows when the
22+
// reads reach storage.
23+
//
24+
// Grouped by category so each file size carries its own baseline. A ratio against one
25+
// shared baseline would be comparing a 2K compare to a 1M one.
26+
[MemoryDiagnoser]
27+
[SimpleJob(iterationCount: 10, warmupCount: 3)]
28+
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
29+
[CategoriesColumn]
30+
public class StreamComparerBenchmarks
31+
{
32+
const int oldBufferSize = 1024 * sizeof(long);
33+
const int newBufferSize = 64 * 1024;
34+
35+
// Sized to the snapshots actually seen: a text snapshot is a couple of KB, a small
36+
// image or serialized document lands around the new buffer size, and a large binary
37+
// snapshot runs to megabytes.
38+
const int smallSize = 2 * 1024;
39+
const int mediumSize = 64 * 1024;
40+
const int largeSize = 1024 * 1024;
41+
42+
string directory = null!;
43+
44+
Pair small = null!;
45+
Pair medium = null!;
46+
Pair large = null!;
47+
Pair mismatch = null!;
48+
49+
MemoryStream buffered = null!;
50+
51+
[GlobalSetup]
52+
public void Setup()
53+
{
54+
directory = Path.Combine(Path.GetTempPath(), "VerifyStreamComparerBenchmarks");
55+
if (Directory.Exists(directory))
56+
{
57+
Directory.Delete(directory, true);
58+
}
59+
60+
Directory.CreateDirectory(directory);
61+
62+
small = BuildPair("small", smallSize, differAtStart: false);
63+
medium = BuildPair("medium", mediumSize, differAtStart: false);
64+
large = BuildPair("large", largeSize, differAtStart: false);
65+
66+
// Same length, first byte differs. FileComparer short circuits on a length
67+
// difference, so a mismatch that reaches here is usually an equal length one.
68+
mismatch = BuildPair("mismatch", largeSize, differAtStart: true);
69+
70+
// FileComparer buffers a non seekable received stream into a MemoryStream, then
71+
// compares it against the verified file. So the received side reads synchronously
72+
// while the verified side is still async file IO. A MemoryStream on both sides
73+
// never reaches StreamComparer: InnerCompare always opens the verified side with
74+
// IoHelpers.OpenRead. Same content as the medium pair, so this compares equal
75+
// against that pair's verified file.
76+
buffered = new(BuildContent(mediumSize, seed: mediumSize));
77+
}
78+
79+
[GlobalCleanup]
80+
public void Cleanup()
81+
{
82+
small.Dispose();
83+
medium.Dispose();
84+
large.Dispose();
85+
mismatch.Dispose();
86+
buffered.Dispose();
87+
Directory.Delete(directory, true);
88+
}
89+
90+
[BenchmarkCategory("Small")]
91+
[Benchmark(Baseline = true)]
92+
public async Task<bool> Legacy_Small() =>
93+
(await LegacyStreamComparer.AreEqual(small.Rewind(), small.Verified, oldBufferSize, useMemoryOverload: false)).IsEqual;
94+
95+
[BenchmarkCategory("Small")]
96+
[Benchmark]
97+
public async Task<bool> Legacy64K_Small() =>
98+
(await LegacyStreamComparer.AreEqual(small.Rewind(), small.Verified, newBufferSize, useMemoryOverload: false)).IsEqual;
99+
100+
[BenchmarkCategory("Small")]
101+
[Benchmark]
102+
public async Task<bool> LegacyMemory_Small() =>
103+
(await LegacyStreamComparer.AreEqual(small.Rewind(), small.Verified, newBufferSize, useMemoryOverload: true)).IsEqual;
104+
105+
[BenchmarkCategory("Small")]
106+
[Benchmark]
107+
public async Task<bool> Current_Small() =>
108+
(await StreamComparer.AreEqual(small.Rewind(), small.Verified)).IsEqual;
109+
110+
[BenchmarkCategory("Medium")]
111+
[Benchmark(Baseline = true)]
112+
public async Task<bool> Legacy_Medium() =>
113+
(await LegacyStreamComparer.AreEqual(medium.Rewind(), medium.Verified, oldBufferSize, useMemoryOverload: false)).IsEqual;
114+
115+
[BenchmarkCategory("Medium")]
116+
[Benchmark]
117+
public async Task<bool> Legacy64K_Medium() =>
118+
(await LegacyStreamComparer.AreEqual(medium.Rewind(), medium.Verified, newBufferSize, useMemoryOverload: false)).IsEqual;
119+
120+
[BenchmarkCategory("Medium")]
121+
[Benchmark]
122+
public async Task<bool> LegacyMemory_Medium() =>
123+
(await LegacyStreamComparer.AreEqual(medium.Rewind(), medium.Verified, newBufferSize, useMemoryOverload: true)).IsEqual;
124+
125+
[BenchmarkCategory("Medium")]
126+
[Benchmark]
127+
public async Task<bool> Current_Medium() =>
128+
(await StreamComparer.AreEqual(medium.Rewind(), medium.Verified)).IsEqual;
129+
130+
[BenchmarkCategory("Large")]
131+
[Benchmark(Baseline = true)]
132+
public async Task<bool> Legacy_Large() =>
133+
(await LegacyStreamComparer.AreEqual(large.Rewind(), large.Verified, oldBufferSize, useMemoryOverload: false)).IsEqual;
134+
135+
[BenchmarkCategory("Large")]
136+
[Benchmark]
137+
public async Task<bool> Legacy64K_Large() =>
138+
(await LegacyStreamComparer.AreEqual(large.Rewind(), large.Verified, newBufferSize, useMemoryOverload: false)).IsEqual;
139+
140+
[BenchmarkCategory("Large")]
141+
[Benchmark]
142+
public async Task<bool> LegacyMemory_Large() =>
143+
(await LegacyStreamComparer.AreEqual(large.Rewind(), large.Verified, newBufferSize, useMemoryOverload: true)).IsEqual;
144+
145+
[BenchmarkCategory("Large")]
146+
[Benchmark]
147+
public async Task<bool> Current_Large() =>
148+
(await StreamComparer.AreEqual(large.Rewind(), large.Verified)).IsEqual;
149+
150+
// A failing run: both sides are read once and the compare stops at the first chunk.
151+
[BenchmarkCategory("NotEqual")]
152+
[Benchmark(Baseline = true)]
153+
public async Task<bool> Legacy_Large_NotEqual() =>
154+
(await LegacyStreamComparer.AreEqual(mismatch.Rewind(), mismatch.Verified, oldBufferSize, useMemoryOverload: false)).IsEqual;
155+
156+
[BenchmarkCategory("NotEqual")]
157+
[Benchmark]
158+
public async Task<bool> Current_Large_NotEqual() =>
159+
(await StreamComparer.AreEqual(mismatch.Rewind(), mismatch.Verified)).IsEqual;
160+
161+
// The received side reads synchronously, so its read completes inline before the
162+
// verified read is even issued. The overlapping has nothing to hide behind here.
163+
[BenchmarkCategory("Buffered")]
164+
[Benchmark(Baseline = true)]
165+
public async Task<bool> Legacy_Medium_Buffered() =>
166+
(await LegacyStreamComparer.AreEqual(RewindBuffered(), medium.Verified, oldBufferSize, useMemoryOverload: false)).IsEqual;
167+
168+
[BenchmarkCategory("Buffered")]
169+
[Benchmark]
170+
public async Task<bool> Legacy64K_Medium_Buffered() =>
171+
(await LegacyStreamComparer.AreEqual(RewindBuffered(), medium.Verified, newBufferSize, useMemoryOverload: false)).IsEqual;
172+
173+
[BenchmarkCategory("Buffered")]
174+
[Benchmark]
175+
public async Task<bool> LegacyMemory_Medium_Buffered() =>
176+
(await LegacyStreamComparer.AreEqual(RewindBuffered(), medium.Verified, newBufferSize, useMemoryOverload: true)).IsEqual;
177+
178+
[BenchmarkCategory("Buffered")]
179+
[Benchmark]
180+
public async Task<bool> Current_Medium_Buffered() =>
181+
(await StreamComparer.AreEqual(RewindBuffered(), medium.Verified)).IsEqual;
182+
183+
MemoryStream RewindBuffered()
184+
{
185+
buffered.Position = 0;
186+
medium.Verified.Position = 0;
187+
return buffered;
188+
}
189+
190+
Pair BuildPair(string name, int size, bool differAtStart)
191+
{
192+
var content = BuildContent(size, seed: size);
193+
var receivedPath = Path.Combine(directory, name + ".received.bin");
194+
var verifiedPath = Path.Combine(directory, name + ".verified.bin");
195+
File.WriteAllBytes(verifiedPath, content);
196+
197+
if (differAtStart)
198+
{
199+
var copy = (byte[]) content.Clone();
200+
copy[0] ^= 0xFF;
201+
File.WriteAllBytes(receivedPath, copy);
202+
}
203+
else
204+
{
205+
File.WriteAllBytes(receivedPath, content);
206+
}
207+
208+
return new(Open(receivedPath), Open(verifiedPath));
209+
}
210+
211+
// Matches IoHelpers.OpenRead, which is how the verified side is opened in production.
212+
static FileStream Open(string path) =>
213+
new(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
214+
215+
static byte[] BuildContent(int size, int seed)
216+
{
217+
var content = new byte[size];
218+
new Random(seed).NextBytes(content);
219+
return content;
220+
}
221+
222+
sealed class Pair(FileStream received, FileStream verified) :
223+
IDisposable
224+
{
225+
public FileStream Verified { get; } = verified;
226+
227+
// StreamComparer requires both streams at position 0. Rewinding costs the same
228+
// for every variant, so it does not skew the comparison.
229+
public FileStream Rewind()
230+
{
231+
received.Position = 0;
232+
Verified.Position = 0;
233+
return received;
234+
}
235+
236+
public void Dispose()
237+
{
238+
received.Dispose();
239+
Verified.Dispose();
240+
}
241+
}
242+
}

src/Verify.Tests/StreamComparerTests.cs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,60 @@ public async Task EqualWithLengthNotMultipleOfEight()
2828
Assert.True(result.IsEqual);
2929
}
3030

31+
[Fact]
32+
public async Task MixedEqualSpanningMultipleBuffers()
33+
{
34+
// The shape FileComparer produces for a non-seekable received stream: it is
35+
// buffered into a MemoryStream and compared against the verified file, which is
36+
// always opened for async IO. So one side completes every read inline and the
37+
// other does not. Large enough to span several buffers, and deliberately not a
38+
// multiple of the buffer size, so the final block is partial.
39+
var bytes = new byte[256 * 1024 + 13];
40+
new Random(1).NextBytes(bytes);
41+
42+
var path = Path.Combine(Path.GetTempPath(), $"StreamComparerTests_{Guid.NewGuid():N}.bin");
43+
File.WriteAllBytes(path, bytes);
44+
try
45+
{
46+
using var received = new MemoryStream((byte[]) bytes.Clone());
47+
// ReSharper disable once UseAwaitUsing
48+
using var verified = IoHelpers.OpenRead(path);
49+
var result = await StreamComparer.AreEqual(received, verified);
50+
Assert.True(result.IsEqual);
51+
}
52+
finally
53+
{
54+
File.Delete(path);
55+
}
56+
}
57+
58+
[Fact]
59+
public async Task EqualWithShortReads()
60+
{
61+
// A stream is free to return fewer bytes than asked for, and ReadBufferAsync
62+
// accumulates until the buffer is full so both sides stay chunk aligned. A real
63+
// FileStream rarely short reads, so it takes a stream that always does to cover
64+
// that loop. Both sides are equal, so a mis-aligned chunk would surface as a
65+
// spurious NotEqual.
66+
var bytes = new byte[256 * 1024 + 13];
67+
new Random(2).NextBytes(bytes);
68+
69+
using var received = new ShortReadStream(bytes, maxRead: 1023);
70+
using var verified = new ShortReadStream((byte[]) bytes.Clone(), maxRead: 337);
71+
var result = await StreamComparer.AreEqual(received, verified);
72+
Assert.True(result.IsEqual);
73+
}
74+
75+
// Returns at most maxRead bytes per call, regardless of how many are asked for. Only
76+
// the byte[] overload needs overriding: because this is a derived type, MemoryStream
77+
// routes the span and Memory reads back through it rather than using its fast path.
78+
class ShortReadStream(byte[] bytes, int maxRead) :
79+
MemoryStream(bytes)
80+
{
81+
public override int Read(byte[] buffer, int offset, int count) =>
82+
base.Read(buffer, offset, Math.Min(count, maxRead));
83+
}
84+
3185
[Fact]
3286
public async Task NotEqualInPartialFinalBlock()
3387
{

0 commit comments

Comments
 (0)