From 90fd28cafdf6c9a5691e72b1642ea0f43f693e31 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 29 Jun 2026 14:10:49 +1000 Subject: [PATCH] Optimize StringBuilder.Equals(ReadOnlySpan) to compare by chunk Walk the StringBuilder's contiguous chunks and compare each with the vectorized ReadOnlySpan.SequenceEqual, instead of indexing per char (the indexer walks the chunk list on every access). Add multi-chunk and mismatch test coverage. --- assemblySize.include.md | 8 +++---- src/Polyfill/Polyfill_StringBuilder.cs | 11 +++++---- src/Split/net461/Polyfill_StringBuilder.cs | 8 +++---- src/Split/net462/Polyfill_StringBuilder.cs | 8 +++---- src/Split/net47/Polyfill_StringBuilder.cs | 8 +++---- src/Split/net471/Polyfill_StringBuilder.cs | 8 +++---- src/Split/net472/Polyfill_StringBuilder.cs | 8 +++---- src/Split/net48/Polyfill_StringBuilder.cs | 8 +++---- src/Split/net481/Polyfill_StringBuilder.cs | 8 +++---- .../netcoreapp2.0/Polyfill_StringBuilder.cs | 8 +++---- .../netstandard2.0/Polyfill_StringBuilder.cs | 8 +++---- src/Split/uap10.0/Polyfill_StringBuilder.cs | 8 +++---- src/Tests/PolyfillTests_Memory.cs | 23 +++++++++++++++++++ 13 files changed, 74 insertions(+), 48 deletions(-) diff --git a/assemblySize.include.md b/assemblySize.include.md index 4c85bdc0c..a9b57af0b 100644 --- a/assemblySize.include.md +++ b/assemblySize.include.md @@ -4,12 +4,12 @@ |----------------|----------------|---------------|-----------|-----------|--------------------|---------------------|-------------| | netstandard2.0 | 8.0KB | 351.5KB | +343.5KB | +9.5KB | +6.5KB | +9.5KB | +14.0KB | | netstandard2.1 | 8.5KB | 306.0KB | +297.5KB | +8.5KB | +6.0KB | +9.0KB | +13.5KB | -| net461 | 8.5KB | 350.5KB | +342.0KB | +9.0KB | +6.0KB | +9.0KB | +13.5KB | +| net461 | 8.5KB | 350.5KB | +342.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB | | net462 | 7.0KB | 354.0KB | +347.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB | | net47 | 7.0KB | 353.5KB | +346.5KB | +9.0KB | +6.5KB | +9.5KB | +13.5KB | | net471 | 8.5KB | 353.0KB | +344.5KB | +9.0KB | +6.0KB | +9.0KB | +13.5KB | | net472 | 8.5KB | 351.5KB | +343.0KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB | -| net48 | 8.5KB | 351.5KB | +343.0KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB | +| net48 | 8.5KB | 351.5KB | +343.0KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB | | net481 | 8.5KB | 351.5KB | +343.0KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB | | netcoreapp2.0 | 9.0KB | 327.5KB | +318.5KB | +9.0KB | +6.5KB | +9.0KB | +13.5KB | | netcoreapp2.1 | 9.0KB | 308.0KB | +299.0KB | +9.0KB | +6.5KB | +9.0KB | +14.0KB | @@ -31,12 +31,12 @@ |----------------|----------------|---------------|-----------|-----------|--------------------|---------------------|-------------| | netstandard2.0 | 8.0KB | 513.4KB | +505.4KB | +17.2KB | +8.2KB | +14.4KB | +19.4KB | | netstandard2.1 | 8.5KB | 441.7KB | +433.2KB | +16.2KB | +7.7KB | +13.9KB | +18.9KB | -| net461 | 8.5KB | 513.5KB | +505.0KB | +16.7KB | +7.7KB | +13.9KB | +18.9KB | +| net461 | 8.5KB | 513.5KB | +505.0KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB | | net462 | 7.0KB | 517.0KB | +510.0KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB | | net47 | 7.0KB | 516.2KB | +509.2KB | +16.7KB | +8.2KB | +14.4KB | +18.9KB | | net471 | 8.5KB | 515.4KB | +506.9KB | +16.7KB | +7.7KB | +13.9KB | +18.9KB | | net472 | 8.5KB | 512.8KB | +504.3KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB | -| net48 | 8.5KB | 512.8KB | +504.3KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB | +| net48 | 8.5KB | 512.8KB | +504.3KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB | | net481 | 8.5KB | 512.8KB | +504.3KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB | | netcoreapp2.0 | 9.0KB | 478.9KB | +469.9KB | +16.7KB | +8.2KB | +13.9KB | +18.9KB | | netcoreapp2.1 | 9.0KB | 447.4KB | +438.4KB | +16.7KB | +8.2KB | +13.9KB | +19.4KB | diff --git a/src/Polyfill/Polyfill_StringBuilder.cs b/src/Polyfill/Polyfill_StringBuilder.cs index b03f75cf3..55eb59b83 100644 --- a/src/Polyfill/Polyfill_StringBuilder.cs +++ b/src/Polyfill/Polyfill_StringBuilder.cs @@ -19,14 +19,17 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) return false; } - for (var index = 0; index < target.Length; index++) + // Walk the contiguous chunks and compare each with the vectorized SequenceEqual, + // rather than indexing the StringBuilder per char (which walks the chunk list on every access). + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + + span = span.Slice(chunkSpan.Length); } return true; diff --git a/src/Split/net461/Polyfill_StringBuilder.cs b/src/Split/net461/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/net461/Polyfill_StringBuilder.cs +++ b/src/Split/net461/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/net462/Polyfill_StringBuilder.cs b/src/Split/net462/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/net462/Polyfill_StringBuilder.cs +++ b/src/Split/net462/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/net47/Polyfill_StringBuilder.cs b/src/Split/net47/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/net47/Polyfill_StringBuilder.cs +++ b/src/Split/net47/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/net471/Polyfill_StringBuilder.cs b/src/Split/net471/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/net471/Polyfill_StringBuilder.cs +++ b/src/Split/net471/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/net472/Polyfill_StringBuilder.cs b/src/Split/net472/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/net472/Polyfill_StringBuilder.cs +++ b/src/Split/net472/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/net48/Polyfill_StringBuilder.cs b/src/Split/net48/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/net48/Polyfill_StringBuilder.cs +++ b/src/Split/net48/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/net481/Polyfill_StringBuilder.cs b/src/Split/net481/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/net481/Polyfill_StringBuilder.cs +++ b/src/Split/net481/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/netcoreapp2.0/Polyfill_StringBuilder.cs b/src/Split/netcoreapp2.0/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/netcoreapp2.0/Polyfill_StringBuilder.cs +++ b/src/Split/netcoreapp2.0/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/netstandard2.0/Polyfill_StringBuilder.cs b/src/Split/netstandard2.0/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/netstandard2.0/Polyfill_StringBuilder.cs +++ b/src/Split/netstandard2.0/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Split/uap10.0/Polyfill_StringBuilder.cs b/src/Split/uap10.0/Polyfill_StringBuilder.cs index 115993330..318263edd 100644 --- a/src/Split/uap10.0/Polyfill_StringBuilder.cs +++ b/src/Split/uap10.0/Polyfill_StringBuilder.cs @@ -16,14 +16,14 @@ public static bool Equals(this StringBuilder target, ReadOnlySpan span) { return false; } - for (var index = 0; index < target.Length; index++) + foreach (var chunk in target.GetChunks()) { - var ch1 = target[index]; - var ch2 = span[index]; - if (ch1 != ch2) + var chunkSpan = chunk.Span; + if (!chunkSpan.SequenceEqual(span.Slice(0, chunkSpan.Length))) { return false; } + span = span.Slice(chunkSpan.Length); } return true; } diff --git a/src/Tests/PolyfillTests_Memory.cs b/src/Tests/PolyfillTests_Memory.cs index 92a60d9f8..90cb0748f 100644 --- a/src/Tests/PolyfillTests_Memory.cs +++ b/src/Tests/PolyfillTests_Memory.cs @@ -527,5 +527,28 @@ public async Task StringEqualsSpan() { var builder = new StringBuilder("value"); await Assert.That(builder.Equals("value".AsSpan())).IsTrue(); + await Assert.That(builder.Equals("other".AsSpan())).IsFalse(); + await Assert.That(builder.Equals("val".AsSpan())).IsFalse(); + await Assert.That(builder.Equals("values".AsSpan())).IsFalse(); + await Assert.That(builder.Equals("".AsSpan())).IsFalse(); + await Assert.That(new StringBuilder().Equals("".AsSpan())).IsTrue(); + } + + [Test] + public async Task StringEqualsSpanMultipleChunks() + { + // A small initial capacity followed by appends past it forces the + // StringBuilder to span multiple chunks, exercising the chunk-walking path. + var builder = new StringBuilder("a", 1); + builder.Append("bb"); + builder.Append("ccc"); + + await Assert.That(builder.Equals("abbccc".AsSpan())).IsTrue(); + // mismatch in the first chunk + await Assert.That(builder.Equals("xbbccc".AsSpan())).IsFalse(); + // mismatch in a later chunk, after the span has advanced across a chunk boundary + await Assert.That(builder.Equals("abbcxc".AsSpan())).IsFalse(); + // matching prefix but wrong length + await Assert.That(builder.Equals("abbcc".AsSpan())).IsFalse(); } }