かきかけ
はじめに
「クソパフォーマンスコードだな、直しちゃろ」と思ってクソパフォーマンスコードを書かないために。
知識は日々劣化していくものです。常に最新のドキュメントを確認して、本当に正しいのかを日々確認して、パフォーマンスチューニングに努めましょう。
本記事の展開
本記事は、以下の流れで展開されます。
- 元コード
- 直したクソパフォーマンスコード
- 何故↑のクソパフォーマンスコードがダメなのかの説明
- 最適なコード
- 計測結果
開発環境
特に明示されていない場合は、以下の環境です。
- 言語: C# 10
- フレームワーク: .NET 6.0
- OS: Windows 10
本題
???「StringBuilder
つかおうよー」
元コード
string Original()
{
string str =
"いい感じの文字列1"
+ "いい感じの文字列2"
+ "いい感じの文字列3"
+ "いい感じの文字列4"
+ "いい感じの文字列5"
+ "いい感じの文字列6"
+ "いい感じの文字列7"
+ "いい感じの文字列8"
+ "いい感じの文字列9"
+ "いい感じの文字列10"
+ "いい感じの文字列11"
+ "いい感じの文字列12"
+ "いい感じの文字列13";
return str;
}
直したクソパフォーマンスコード
using System.Text;
string FuckingFix()
{
StringBuilder builder = new StringBuilder()
.Append("いい感じの文字列1")
.Append("いい感じの文字列2")
.Append("いい感じの文字列3")
.Append("いい感じの文字列4")
.Append("いい感じの文字列5")
.Append("いい感じの文字列6")
.Append("いい感じの文字列7")
.Append("いい感じの文字列8")
.Append("いい感じの文字列9")
.Append("いい感じの文字列10")
.Append("いい感じの文字列11")
.Append("いい感じの文字列12")
.Append("いい感じの文字列13");
return builder.ToString();
}
何故↑のクソパフォーマンスコードがダメなのかの説明
元コードはコンパイル最適化により、以下のように最適化されます。
string Original()
{
return "いい感じの文字列1いい感じの文字列2いい感じの文字列3いい感じの文字列4いい感じの文字列5いい感じの文字列6いい感じの文字列7いい感じの文字列8いい感じの文字列9いい感じの文字列10いい感じの文字列11いい感じの文字列12いい感じの文字列13";
}
つまり、+
演算子分の string.Concat
の呼び出しがなくなり、ldstr
(文字列のロード) 分だけの実行になります。
一方、StringBuilder
は最適化されないので、島風ちゃんに「おっそーい」と言われるくらい遅くなります。
最適なコード
元コードでも十分適切ですが、もっと最適化するならこうなります。
// static field 使うので、一応ちゃんとクラス宣言書く
class Program
{
static string s_str =
"いい感じの文字列1"
+ "いい感じの文字列2"
+ "いい感じの文字列3"
+ "いい感じの文字列4"
+ "いい感じの文字列5"
+ "いい感じの文字列6"
+ "いい感じの文字列7"
+ "いい感じの文字列8"
+ "いい感じの文字列9"
+ "いい感じの文字列10"
+ "いい感じの文字列11"
+ "いい感じの文字列12"
+ "いい感じの文字列13";
string BestFix()
{
return s_str;
}
}
これは ldfld
(フィールドのロード) のみになり、2421 bytes → 4 or 8 bytes (sizeof(nint)
) の読み込みになるため、非常に高速になる。
島風もびっくり。
計測結果
| Method | Mean | Error | StdDev | Median |
|----------- |------------:|----------:|----------:|------------:|
| Original | 0.0066 ns | 0.0160 ns | 0.0142 ns | 0.0000 ns |
| FuckingFix | 131.3555 ns | 1.8521 ns | 1.6419 ns | 131.2134 ns |
| BestFix | 0.0000 ns | 0.0000 ns | 0.0000 ns | 0.0000 ns |
???「StringBuilder
つかおうよー」その2
元コード
public int n;
public string Original()
{
string str = "";
for (int i = 0; i < n; i++)
{
str = "いい感じの文字列" + i;
}
return str;
}
直したクソパフォーマンスコード
using System.Text;
public int n;
public string FuckingFix()
{
StringBuilder builder = new();
for (int i = 0; i < n; i++)
{
builder.Append("いい感じの文字列" + i);
}
return builder.ToString();
}
何故↑のクソパフォーマンスコードがダメなのかの説明
なんで結合を避けるために StringBuilder.Append
使っているのに、"いい感じの文字列" + i
で結合しているんですか?
最適なコード
public int n;
public string BestFix()
{
StringBuilder builder = new();
for (int i = 0; i < n; i++)
{
builder.Append("いい感じの文字列");
builder.Append(i);
}
return builder.ToString();
}
// C# 10.0 以降なら、文字列補間 ($始まり) で書いた文字列は
// StringBuilder.AppendInterpolatedStringHandler に暗黙的に変換されるので、こっちで書いた方がパフォーマンスと可読性を両立できるのでおすすめ
public string BestFix2()
{
StringBuilder builder = new();
for (int i = 0; i < n; i++)
{
builder.Append($"いい感じの文字列{i}");
}
return builder.ToString();
}
計測結果
| Method | n | Mean | Error | StdDev |
|----------- |------ |--------------:|--------------:|--------------:|
| Original | 100 | 4.914 us | 0.0270 us | 0.0253 us |
| FuckingFix | 100 | 1.933 us | 0.0266 us | 0.0249 us |
| BestFix | 100 | 1.067 us | 0.0162 us | 0.0151 us |
| BestFix2 | 100 | 1.180 us | 0.0093 us | 0.0078 us |
| Original | 10000 | 80,812.917 us | 1,609.4959 us | 4,211.7703 us |
| FuckingFix | 10000 | 288.914 us | 4.8126 us | 4.5017 us |
| BestFix | 10000 | 110.361 us | 0.6834 us | 0.6393 us |
| BestFix2 | 10000 | 125.578 us | 0.4204 us | 0.3726 us |
???「二分探索の方が速いよ!」
元コード
string[] _items;
public bool Original()
{
string[] items = _items;
foreach (string str in items)
{
if (str == "abc")
{
return true;
}
}
return false;
}
直したクソパフォーマンスコード
using System.Linq;
string[] _items;
public bool FuckingFix()
{
var sortedList = _items.ToList();
sortedList.Sort();
return sortedList.BinarySearch("abc") >= 0;
}
何故↑のクソパフォーマンスコードがダメなのかの説明
二分探索は高速ですが、ソートしてまでやるほどではありません
最適なコード
Original
で十分です。(string.Length
を確認すれば、場合によっては少し高速になりますが、特筆するほど高速になる訳ではありません。)
個人的には、可読性を優先して、_items.Contains()
でも良いと思います。
計測結果
| Method | itemCount | maxLength | Mean | Error | StdDev |
|----------- |---------- |---------- |----------------:|--------------:|--------------:|
| Original | 100 | 100 | 50.49 ns | 0.507 ns | 0.474 ns |
| FuckingFix | 100 | 100 | 25,761.58 ns | 311.147 ns | 275.823 ns |
| Original | 100 | 10000 | 59.19 ns | 0.839 ns | 0.743 ns |
| FuckingFix | 100 | 10000 | 27,000.79 ns | 269.980 ns | 252.539 ns |
| Original | 10000 | 100 | 11,226.87 ns | 37.011 ns | 30.906 ns |
| FuckingFix | 10000 | 100 | 7,121,160.37 ns | 34,551.173 ns | 28,851.794 ns |
| Original | 10000 | 10000 | 16,586.33 ns | 71.628 ns | 59.813 ns |
| FuckingFix | 10000 | 10000 | 7,457,502.29 ns | 58,746.371 ns | 52,077.128 ns |
計測に用いたソース
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System;
using System.Linq;
using System.Text;
public class Test
{
[Params(100, 10000)]
public int itemCount;
[Params(100, 10000)]
public int maxLength;
private string[] _items = null!;
[GlobalSetup]
public void Setup()
{
Random rand = new(42);
string GetString()
{
byte[] buffer = new byte[rand.Next(maxLength)];
rand.NextBytes(buffer);
unsafe
{
fixed (sbyte* p = (sbyte[])(object)buffer)
{
return new string(p, 0, buffer.Length, Encoding.ASCII);
}
}
}
_items = Enumerable.Repeat(0, itemCount)
.Select(s => GetString())
.ToArray();
}
[Benchmark]
public bool Original()
{
string[] items = _items;
foreach (string str in items)
{
if (str == "abc")
{
return true;
}
}
return false;
}
[Benchmark]
public bool FuckingFix()
{
var sortedList = _items.ToList();
sortedList.Sort();
return sortedList.BinarySearch("abc") >= 0;
}
static void Main(string[] args)
{
BenchmarkRunner.Run<Test>();
}
}