|
|
|
|
|
|
|
// run with: |
|
// dotnet run -c Release --framework net10.0 -- --filter * |
|
|
|
|
|
|
|
|
|
// * Summary * |
|
// |
|
// BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8037/25H2/2025Update/HudsonValley2) |
|
// 12th Gen Intel Core i7-12800H 2.40GHz, 1 CPU, 20 logical and 14 physical cores |
|
// |
|
// .NET SDK 10.0.200 |
|
// [Host] : .NET 10.0.4 (10.0.4, 10.0.426.12010), X64 RyuJIT x86-64-v3 |
|
// ShortRun : .NET 10.0.4 (10.0.4, 10.0.426.12010), X64 RyuJIT x86-64-v3 |
|
|
|
//Job=ShortRun IterationCount=3 LaunchCount=1 |
|
//WarmupCount=1 |
|
|
|
//| Method | Runtime | Source | ComparisonType | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Allocated | Alloc Ratio | |
|
//|----------------------------------------------------- |------------------- |--------------------- |------------------ |-----------:|------------:|-----------:|-------------:|--------:|-------:|----------:|------------:| |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET 10.0 | ABCDE(...)^&*() [75] | Ordinal | 28.290 ns | 10.6876 ns | 0.5858 ns | baseline | | 0.0140 | 176 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET 10.0 | ABCDE(...)^&*() [75] | Ordinal | 7.742 ns | 5.4336 ns | 0.2978 ns | 3.66x faster | 0.14x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET 10.0 | ABCDE(...)^&*() [75] | Ordinal | 6.521 ns | 1.4463 ns | 0.0793 ns | 4.34x faster | 0.09x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET Framework 4.8 | ABCDE(...)^&*() [75] | Ordinal | 254.465 ns | 15.4581 ns | 0.8473 ns | baseline | | 0.0343 | 217 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET Framework 4.8 | ABCDE(...)^&*() [75] | Ordinal | 50.547 ns | 6.8980 ns | 0.3781 ns | 5.03x faster | 0.04x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET Framework 4.8 | ABCDE(...)^&*() [75] | Ordinal | 38.318 ns | 7.4586 ns | 0.4088 ns | 6.64x faster | 0.06x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET 10.0 | ABCDE(...)^&*() [75] | OrdinalIgnoreCase | 30.575 ns | 13.2449 ns | 0.7260 ns | baseline | | 0.0140 | 176 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET 10.0 | ABCDE(...)^&*() [75] | OrdinalIgnoreCase | 8.099 ns | 6.2832 ns | 0.3444 ns | 3.78x faster | 0.16x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET 10.0 | ABCDE(...)^&*() [75] | OrdinalIgnoreCase | 6.427 ns | 6.9473 ns | 0.3808 ns | 4.77x faster | 0.26x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET Framework 4.8 | ABCDE(...)^&*() [75] | OrdinalIgnoreCase | 269.232 ns | 121.7263 ns | 6.6722 ns | baseline | | 0.0343 | 217 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET Framework 4.8 | ABCDE(...)^&*() [75] | OrdinalIgnoreCase | 239.134 ns | 24.5035 ns | 1.3431 ns | 1.13x faster | 0.02x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET Framework 4.8 | ABCDE(...)^&*() [75] | OrdinalIgnoreCase | 260.472 ns | 43.7581 ns | 2.3985 ns | 1.03x faster | 0.02x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET 10.0 | Lore(...)ris. [191] | Ordinal | 44.765 ns | 8.6767 ns | 0.4756 ns | baseline | | 0.0325 | 408 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET 10.0 | Lore(...)ris. [191] | Ordinal | 13.633 ns | 6.2573 ns | 0.3430 ns | 3.28x faster | 0.08x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET 10.0 | Lore(...)ris. [191] | Ordinal | 11.595 ns | 2.6312 ns | 0.1442 ns | 3.86x faster | 0.05x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET Framework 4.8 | Lore(...)ris. [191] | Ordinal | 511.982 ns | 66.4035 ns | 3.6398 ns | baseline | | 0.0706 | 449 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET Framework 4.8 | Lore(...)ris. [191] | Ordinal | 109.135 ns | 21.9243 ns | 1.2017 ns | 4.69x faster | 0.05x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET Framework 4.8 | Lore(...)ris. [191] | Ordinal | 94.081 ns | 99.0786 ns | 5.4308 ns | 5.45x faster | 0.28x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET 10.0 | Lore(...)ris. [191] | OrdinalIgnoreCase | 47.911 ns | 31.2295 ns | 1.7118 ns | baseline | | 0.0325 | 408 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET 10.0 | Lore(...)ris. [191] | OrdinalIgnoreCase | 13.196 ns | 1.5312 ns | 0.0839 ns | 3.63x faster | 0.11x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET 10.0 | Lore(...)ris. [191] | OrdinalIgnoreCase | 12.717 ns | 7.0340 ns | 0.3856 ns | 3.77x faster | 0.15x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET Framework 4.8 | Lore(...)ris. [191] | OrdinalIgnoreCase | 506.374 ns | 388.7720 ns | 21.3099 ns | baseline | | 0.0706 | 449 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET Framework 4.8 | Lore(...)ris. [191] | OrdinalIgnoreCase | 513.464 ns | 53.5487 ns | 2.9352 ns | 1.02x slower | 0.04x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET Framework 4.8 | Lore(...)ris. [191] | OrdinalIgnoreCase | 497.864 ns | 36.3594 ns | 1.9930 ns | 1.02x faster | 0.04x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET 10.0 | The q(...)y dog [43] | Ordinal | 24.595 ns | 13.9633 ns | 0.7654 ns | baseline | | 0.0089 | 112 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET 10.0 | The q(...)y dog [43] | Ordinal | 7.513 ns | 0.4483 ns | 0.0246 ns | 3.27x faster | 0.09x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET 10.0 | The q(...)y dog [43] | Ordinal | 5.240 ns | 0.7950 ns | 0.0436 ns | 4.69x faster | 0.13x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET Framework 4.8 | The q(...)y dog [43] | Ordinal | 204.573 ns | 64.9947 ns | 3.5626 ns | baseline | | 0.0241 | 152 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET Framework 4.8 | The q(...)y dog [43] | Ordinal | 43.608 ns | 8.8645 ns | 0.4859 ns | 4.69x faster | 0.08x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET Framework 4.8 | The q(...)y dog [43] | Ordinal | 29.372 ns | 2.4230 ns | 0.1328 ns | 6.97x faster | 0.11x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET 10.0 | The q(...)y dog [43] | OrdinalIgnoreCase | 22.419 ns | 5.4077 ns | 0.2964 ns | baseline | | 0.0089 | 112 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET 10.0 | The q(...)y dog [43] | OrdinalIgnoreCase | 7.537 ns | 2.8075 ns | 0.1539 ns | 2.98x faster | 0.06x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET 10.0 | The q(...)y dog [43] | OrdinalIgnoreCase | 6.158 ns | 1.6943 ns | 0.0929 ns | 3.64x faster | 0.06x | - | - | NA | |
|
//| | | | | | | | | | | | | |
|
//| 'ToLower + Contains (with ToLower) - 2x allocations' | .NET Framework 4.8 | The q(...)y dog [43] | OrdinalIgnoreCase | 206.237 ns | 26.9969 ns | 1.4798 ns | baseline | | 0.0241 | 152 B | | |
|
//| 'string.IndexOf (no alloc)' | .NET Framework 4.8 | The q(...)y dog [43] | OrdinalIgnoreCase | 128.457 ns | 12.2357 ns | 0.6707 ns | 1.61x faster | 0.01x | - | - | NA | |
|
//| 'Span.IndexOf (no alloc, managed path)' | .NET Framework 4.8 | The q(...)y dog [43] | OrdinalIgnoreCase | 145.212 ns | 10.7029 ns | 0.5867 ns | 1.42x faster | 0.01x | - | - | NA | |
|
// |
|
|
|
|
|
// --------------------------------------------------------------------------- |
|
// LINQPad-compatible configuration |
|
// --------------------------------------------------------------------------- |
|
// BDN's default CsProj toolchain spawns a child process and re-compiles the |
|
// benchmark project from source. It locates the .csproj by matching the |
|
// output exe name — which breaks under LINQPad because LINQPad compiles |
|
// queries into a temp assembly named "LINQPadQuery" with no .csproj present. |
|
// |
|
// InProcessEmitToolchain avoids this entirely: it runs benchmarks in the |
|
// same process that LINQPad already started, using Reflection.Emit to |
|
// generate the benchmark harness at runtime. No child process, no .csproj |
|
// lookup, no net48 re-compilation. |
|
// |
|
// Trade-off: because everything runs in one process you cannot benchmark |
|
// startup costs or cross-runtime differences (net48 vs net10) in a single |
|
// run. Run once per LINQPad target runtime to compare. |
|
using BenchmarkDotNet.Attributes; |
|
using BenchmarkDotNet.Columns; |
|
using BenchmarkDotNet.Configs; |
|
using BenchmarkDotNet.Engines; |
|
using BenchmarkDotNet.Environments; |
|
using BenchmarkDotNet.Jobs; |
|
using BenchmarkDotNet.Reports; |
|
using BenchmarkDotNet.Running; |
|
using System; |
|
|
|
|
|
internal sealed class BenchmarkConfig : ManualConfig |
|
{ |
|
public BenchmarkConfig() |
|
{ |
|
foreach (var logger in DefaultConfig.Instance.GetLoggers()) AddLogger(logger); |
|
foreach (var exporter in DefaultConfig.Instance.GetExporters()) AddExporter(exporter); |
|
foreach (var validator in DefaultConfig.Instance.GetValidators()) AddValidator(validator); |
|
foreach (var analyser in DefaultConfig.Instance.GetAnalysers()) AddAnalyser(analyser); |
|
foreach (var diagnoser in DefaultConfig.Instance.GetDiagnosers()) AddDiagnoser(diagnoser); |
|
foreach (var provider in DefaultConfig.Instance.GetColumnProviders()) AddColumnProvider(provider); |
|
|
|
SummaryStyle = SummaryStyle.Default |
|
.WithRatioStyle(RatioStyle.Trend); |
|
|
|
// Both runtimes defined here with iteration counts pinned explicitly. |
|
// No [SimpleJob] attributes on the class — this is the single source |
|
// of truth for job definitions, which prevents duplicate job rows. |
|
AddJob(Job.ShortRun |
|
.WithRuntime(ClrRuntime.Net48) |
|
.WithWarmupCount(1) |
|
.WithIterationCount(3)); |
|
|
|
AddJob(Job.ShortRun |
|
.WithRuntime(CoreRuntime.Core10_0) |
|
.WithWarmupCount(1) |
|
.WithIterationCount(3)); |
|
} |
|
} |
|
|
|
// --------------------------------------------------------------------------- |
|
// Benchmark class |
|
// --------------------------------------------------------------------------- |
|
|
|
/// <summary> |
|
/// Compares three Contains implementations across runtimes: |
|
/// |
|
/// 1. StringIndexOf — string.IndexOf(string, StringComparison) |
|
/// On net48: may route through Windows NLS / P/Invoke |
|
/// for OrdinalIgnoreCase and culture-aware comparisons. |
|
/// On net10.0: reimplemented in managed code; gap narrows. |
|
/// |
|
/// 2. SpanIndexOf — source.AsSpan().IndexOf(toCheck.AsSpan(), comparison) |
|
/// On net48 (System.Memory NuGet): fully managed path, |
|
/// no P/Invoke, JIT-inlineable, eligible for SIMD |
|
/// vectorization on supported hardware. |
|
/// On net10.0: same managed path as string.IndexOf; |
|
/// numbers should be essentially identical. |
|
/// |
|
/// 3. ToLowerContains — the naïve approach included as a worst-case baseline |
|
/// to anchor the "why we don't do this" discussion. |
|
/// Allocates two new strings on every call regardless |
|
/// of runtime. |
|
/// |
|
/// The Consumer field is written to after each benchmark body so the JIT |
|
/// cannot dead-code-eliminate the return value (a common source of |
|
/// artificially fast micro-benchmark numbers). |
|
/// </summary> |
|
[Config(typeof(BenchmarkConfig))] |
|
[MemoryDiagnoser] |
|
public class ContainsBenchmarks |
|
{ |
|
// --------------------------------------------------------------------------- |
|
// Test data — varied lengths exercise both the short-string fast path and |
|
// the longer SIMD-eligible path in MemoryExtensions. |
|
// --------------------------------------------------------------------------- |
|
|
|
[Params( |
|
"The quick brown fox jumps over the lazy dog", |
|
"ABCDEFGHIJKLMNOPQRSTUVWXYZ abcdefghijklmnopqrstuvwxyz 0123456789 !@#$%^&*()", |
|
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.")] |
|
public string Source { get; set; } = string.Empty; |
|
|
|
[Params(StringComparison.OrdinalIgnoreCase, StringComparison.Ordinal)] |
|
public StringComparison ComparisonType { get; set; } |
|
|
|
// Search term is always present so we measure a successful search, |
|
// which exercises the full comparison path rather than an early exit. |
|
private const string ToCheck = "lazy"; |
|
|
|
// BenchmarkDotNet's Consumer is a black-hole sink: calling Consume() |
|
// writes the value into a volatile field, which is enough to prevent |
|
// the JIT from treating the result as dead code and eliminating the call. |
|
private readonly Consumer _consumer = new Consumer(); |
|
|
|
// --------------------------------------------------------------------------- |
|
// Benchmarks |
|
// --------------------------------------------------------------------------- |
|
|
|
/// <summary> |
|
/// Baseline: the naïve ToLower approach. |
|
/// |
|
/// Allocates two strings per call (source.ToLower() + toCheck.ToLower()). |
|
/// Shown here so the Ratio column in the results makes the cost of |
|
/// allocations viscerally visible — especially on net48 where GC pressure |
|
/// from boxed values and string allocations is already a concern. |
|
/// </summary> |
|
[Benchmark(Baseline = true, Description = "ToLower + Contains (with ToLower) - 2x allocations")] |
|
public void ToLowerContains() |
|
{ |
|
// Allocates: source.ToLower() → new string |
|
// toCheck.ToLower() → new string |
|
// Every. Single. Call. |
|
bool result = Source.ToLower().Contains(ToCheck.ToLower()); |
|
_consumer.Consume(result); |
|
} |
|
|
|
/// <summary> |
|
/// string.IndexOf(string, StringComparison). |
|
/// |
|
/// Zero allocation — no new strings created. |
|
/// On net48, OrdinalIgnoreCase may still route through a Windows NLS |
|
/// helper (CompareInfo.IndexOf) which involves additional dispatch |
|
/// overhead compared to the fully-managed Span path. |
|
/// On net10.0 this was reimplemented to use the same managed searcher |
|
/// as MemoryExtensions, so the advantage over SpanIndexOf largely |
|
/// disappears. |
|
/// </summary> |
|
[Benchmark(Description = "string.IndexOf (no alloc)")] |
|
public void StringIndexOf() |
|
{ |
|
bool result = Source.IndexOf(ToCheck, ComparisonType) != -1; |
|
_consumer.Consume(result); |
|
} |
|
|
|
/// <summary> |
|
/// source.AsSpan().IndexOf(toCheck.AsSpan(), StringComparison). |
|
/// |
|
/// Zero allocation — AsSpan() returns a view over the existing string's |
|
/// memory; no copy, no heap object. |
|
/// |
|
/// On net48 (System.Memory NuGet): MemoryExtensions.IndexOf is a |
|
/// fully-managed implementation that: |
|
/// a) avoids the P/Invoke / NLS dispatch that string.IndexOf may use, |
|
/// b) is directly inlineable by the JIT because it has no |
|
/// MarshalByRefObject or COM interop callsites in the call graph, |
|
/// c) on hardware with SSE2/AVX2, the inner loop is SIMD-vectorized, |
|
/// processing 16–32 chars per CPU instruction instead of 1. |
|
/// |
|
/// On net10.0: both string.IndexOf and Span.IndexOf share the same |
|
/// underlying managed searcher, so numbers will be essentially identical. |
|
/// The real story is the net48 column — that's where the gap is visible. |
|
/// </summary> |
|
[Benchmark(Description = "Span.IndexOf (no alloc, managed path)")] |
|
public void SpanIndexOf() |
|
{ |
|
// AsSpan() — O(1), zero allocation, just a pointer + length struct |
|
bool result = Source.AsSpan().IndexOf(ToCheck.AsSpan(), ComparisonType) != -1; |
|
_consumer.Consume(result); |
|
} |
|
|
|
internal static class Program |
|
{ |
|
private static void Main(string[] args) |
|
{ |
|
BenchmarkSwitcher |
|
.FromAssembly(typeof(Program).Assembly) |
|
.Run(args, new BenchmarkConfig()); |
|
} |
|
} |
|
} |