.NET 8 から、ASCII文字に最適化された処理を提供するAscii
クラスが提供された。
文字列を扱う際、仕様上・あるいは事実上ASCII文字だけということは珍しくない。どういう感じか、触りつつベンチマークする。
なお、複数のAPIが、char
とbyte
(UTF8) をそれぞれあるいは両方を受けるシグニチャを備えている。そうした場合、ベンチマークではchar
を受けるものを扱う。
ベンチマーク
最初にベンチマークを示す。(BenchmarkDotNet)
ベンチマークコード折りたたみ
[ShortRunJob] [MemoryDiagnoser] [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] [HideColumns("Error", "StdDev", "Median", "RatioSD")] public class AsciiBenchmark { private readonly string target = $"The quick brown fox jumps over the lazy dog{'.'} {1234567890}"; // avoid ReferenceEquals private readonly string targetToBeTrimmed = $" {ascii} "; private const string ascii = "The quick brown fox jumps over the lazy dog. 1234567890"; private const string asciiIgnoreCase = "the quick brown FOX jumps over the lazy DOG. 1234567890"; private readonly byte[] utf8Target = "The quick brown fox jumps over the lazy dog. 1234567890"u8.ToArray(); [Benchmark(Baseline = true), BenchmarkCategory("Equals")] public bool Equals() => target.Equals(ascii); [Benchmark(Description = "(Ascii)"), BenchmarkCategory("Equals")] public bool EqualsAscii() => Ascii.Equals(target, ascii); [Benchmark(Baseline = true), BenchmarkCategory("EqualsIgnoreCase")] public bool EqualsIgnoreCase() => target.Equals(asciiIgnoreCase, StringComparison.OrdinalIgnoreCase); [Benchmark(Description = "(Ascii)"), BenchmarkCategory("EqualsIgnoreCase")] public bool EqualsIgnoreCaseAscii() => Ascii.EqualsIgnoreCase(target, asciiIgnoreCase); [Benchmark(Baseline = true), BenchmarkCategory("IsValid")] public bool IsValid() { foreach(var c in target.AsSpan()) { if (char.IsAscii(c) is false) return false; } return true; } [Benchmark(Description = "(Ascii)"), BenchmarkCategory("IsValid")] public bool IsValidAscii() => Ascii.IsValid(target); [Benchmark(Baseline = true), BenchmarkCategory("ToLower")] public string ToLower() => target.ToLowerInvariant(); [Benchmark(Description = "(Span)"), BenchmarkCategory("ToLower")] public string ToLowerSpan() => string.Create(target.Length, target, static (dest, target) => target.AsSpan().ToLowerInvariant(dest)); [Benchmark(Description = "(Ascii)"), BenchmarkCategory("ToLower")] public string ToLowerAscii() => string.Create(target.Length, target, static (dest, target) => Ascii.ToLower(target, dest, out int _)); [Benchmark(Baseline = true), BenchmarkCategory("Trim")] public string Trim() => targetToBeTrimmed.Trim(); [Benchmark(Description = "(Span)"), BenchmarkCategory("Trim")] public string TrimSpan() => targetToBeTrimmed.AsSpan().Trim().ToString(); [Benchmark(Description = "(Ascii)"), BenchmarkCategory("Trim")] public string TrimAscii() => targetToBeTrimmed.AsSpan()[Ascii.Trim(targetToBeTrimmed)].ToString(); [Benchmark(Baseline = true), BenchmarkCategory("FromUtf16")] public byte[] FromUtf16() => Encoding.UTF8.GetBytes(target); [Benchmark(Description = "(Utf8)"), BenchmarkCategory("FromUtf16")] public byte[] FromUtf16Utf8() { var dest = new byte[target.Length]; Utf8.FromUtf16(target, dest, out int _, out int _); return dest; } [Benchmark(Description = "(Ascii)"), BenchmarkCategory("FromUtf16")] public byte[] FromUtf16Ascii() { var dest = new byte[target.Length]; Ascii.FromUtf16(target, dest, out int _); return dest; } [Benchmark(Baseline = true), BenchmarkCategory("ToUtf16")] public string ToUtf16() => Encoding.UTF8.GetString(utf8Target); [Benchmark(Description = "(Utf8)"), BenchmarkCategory("ToUtf16")] public string ToUtf16Utf8() => string.Create(utf8Target.Length, utf8Target, static (span, target) => Utf8.ToUtf16(target, span, out int _, out int _)); [Benchmark(Description = "(Ascii)"), BenchmarkCategory("ToUtf16")] public string ToUtf16Ascii() => string.Create(utf8Target.Length, utf8Target, static (span, target) => Ascii.ToUtf16(target, span, out int _)); }
| Method | Mean | Ratio | Gen0 | Allocated | Alloc Ratio | |----------------- |----------:|------:|-------:|----------:|------------:| | Equals | 9.567 ns | 1.00 | - | - | NA | | (Ascii) | 12.373 ns | 1.29 | - | - | NA | | | | | | | | | EqualsIgnoreCase | 22.450 ns | 1.00 | - | - | NA | | (Ascii) | 33.296 ns | 1.48 | - | - | NA | | | | | | | | | IsValid | 35.909 ns | 1.00 | - | - | NA | | (Ascii) | 4.183 ns | 0.12 | - | - | NA | | | | | | | | | ToLower | 39.016 ns | 1.00 | 0.0433 | 136 B | 1.00 | | (Span) | 38.223 ns | 0.98 | 0.0433 | 136 B | 1.00 | | (Ascii) | 35.589 ns | 0.91 | 0.0433 | 136 B | 1.00 | | | | | | | | | Trim | 26.968 ns | 1.00 | 0.0433 | 136 B | 1.00 | | (Span) | 35.558 ns | 1.32 | 0.0433 | 136 B | 1.00 | | (Ascii) | 35.313 ns | 1.31 | 0.0433 | 136 B | 1.00 | | | | | | | | | FromUtf16 | 43.008 ns | 1.00 | 0.0255 | 80 B | 1.00 | | (Utf8) | 26.745 ns | 0.62 | 0.0255 | 80 B | 1.00 | | (Ascii) | 22.682 ns | 0.53 | 0.0255 | 80 B | 1.00 | | | | | | | | | ToUtf16 | 43.751 ns | 1.00 | 0.0433 | 136 B | 1.00 | | (Utf8) | 33.506 ns | 0.77 | 0.0433 | 136 B | 1.00 | | (Ascii) | 22.452 ns | 0.51 | 0.0433 | 136 B | 1.00 |
各項目の最初の行はstring
の対応メソッドなどによる方法、(Ascii) となっているのがAscii
クラスによるもの。(Utf8) や (Span) は参考として別の方法。
箇条書き。
- 基本的に効果がある
Equals
/EqualsIgnoreCase
とTrim
は今回の例では逆に悪くなった- ただし、
Trim
については文字列を得なくていい (Span
でよい) なら良くなる
- ただし、
個別
Equals
/ EqualsIgnoreCase
public bool Equals() => target.Equals(ascii); public bool EqualsAscii() => Ascii.Equals(target, ascii);
public bool EqualsIgnoreCase() => target.Equals(asciiIgnoreCase, StringComparison.OrdinalIgnoreCase); public bool EqualsIgnoreCaseAscii() => Ascii.EqualsIgnoreCase(target, asciiIgnoreCase);
Equals
、case-insensitive なEqualsIgnoreCase
が用意されているが、今回はむしろ遅くなった1。
環境や渡される内容によってはまた変わってくるとは思う。
また、ReadOnlySpan<char>
とReadOnlySpan<byte>
(UTF8) を直接比較できるシグニチャが存在する。
const string utf16 = "The quick brown fox jumps over the lazy dog. 1234567890"; ReadOnlySpan<byte> utf8 = "The quick brown fox jumps over the lazy dog. 1234567890"u8; Console.WriteLine(Ascii.Equals(utf16, utf8)); // True
IsValid
public bool IsValid() { foreach(var c in target.AsSpan()) { if (char.IsAscii(c) is false) return false; } return true; } public bool IsValidAscii() => Ascii.IsValid(target);
引数がASCII文字で構成されているかを得るメソッド。
最適化の恩恵が大きく、記載も簡潔。
ToLower
/ ToUpper
public string ToLower() => target.ToLowerInvariant(); public string ToLowerSpan() => string.Create(target.Length, target, static (dest, target) => target.AsSpan().ToLowerInvariant(dest)); public string ToLowerAscii() => string.Create(target.Length, target, static (dest, target) => Ascii.ToLower(target, dest, out int _));
効果はある。加えて、ベンチマークは条件を揃えるために結果を文字列としたが、結果がSpan
でよいなら明確に良くなる。
また、destination を用意せずインプレースに行うToLowerInPlace
もある。
他に、ReadOnlySpan<char>
とReadOnlySpan<byte>
の両方を受けるシグニチャもある。(char
を受けてbyte
に書き出す / byte
を受けてchar
に書き出す)
並びのToUpper
、そしてToUpperInPlace
もある。ベンチマーク省略。
Trim
public string Trim() => targetToBeTrimmed.Trim(); public string TrimSpan() => targetToBeTrimmed.AsSpan().Trim().ToString(); public string TrimAscii() => targetToBeTrimmed.AsSpan()[Ascii.Trim(targetToBeTrimmed)].ToString();
Ascii.Trim
はRange
を返す設計になっている。
結果の長さを不定と考えstring.Create
でなくToString
で文字列とした。それもあってか、今回はむしろ遅くなってしまった。もちろん、結果がSpan
でよい場合は良くなる。
TrimStart
, TrimEnd
もある。ベンチマーク省略。
FromUtf16
public byte[] FromUtf16() => Encoding.UTF8.GetBytes(target); public byte[] FromUtf16Utf8() { var dest = new byte[target.Length]; Utf8.FromUtf16(target, dest, out int _, out int _); return dest; } public byte[] FromUtf16Ascii() { var dest = new byte[target.Length]; Ascii.FromUtf16(target, dest, out int _); return dest; }
char
(UTF16) to byte
(UTF8)。
明確に速い。
ToUtf16
public string ToUtf16() => Encoding.UTF8.GetString(utf8Target); public string ToUtf16Utf8() => string.Create(utf8Target.Length, utf8Target, static (span, target) => Utf8.ToUtf16(target, span, out int _, out int _)); public string ToUtf16Ascii() => string.Create(utf8Target.Length, utf8Target, static (span, target) => Ascii.ToUtf16(target, span, out int _));
byte
(UTF8) to char
(UTF16)。
明確に速い。
-
冒頭 .NET Blog 記事では
EqualsIgnoreCase
のベンチマークを行っていたが、そこでは case-insensitive 処理をベタ書きしたものを比較対象としていた。StringComparison.OrdinalIgnoreCase
は比較対象として不適?(未調査)↩