はじめに
プログラムを速くする――と聞くと、「アルゴリズムを大改造する」「マルチスレッド化する」といった大がかりな施策を思い浮かべがちです。
しかし実際には、日常的に書いている ちょっとしたコードの選択 が、そのままアプリ全体の体感速度を左右しているケースも少なくありません。
同じ結果を出す 2 つのコード、実は書き方ひとつで 10 倍以上 の差が開くことも!?
今回は、アプリでよく目にする “当たり前” の実装を例に取り、「どちらを選ぶとどれくらい速いのか?」 を BenchmarkDotNet
で可視化しながら検証していきます。
ここでの「効率的なアプローチ」は、あくまでパフォーマンスの比較においてであって「非効率なアプローチ」が悪いというわけでは決してありまん。 最適化のゴールは 「闇雲に速くする」 ことではなく、「可読性・保守性とのバランスを取りながらベストプラクティスを選ぶ」ことだということは忘れないでおきましょう。
目次
ベンチマーク共通環境
今回の測定に利用したコードもおいておきますので(details-折りたたみになっているので要展開)、実装の判断材料としてぜひ活用してください。
項目 | 値 |
---|---|
CPU | Intel(R) Core(TM) Ultra 7 155H 3.80 GHz |
RAM | 16.0 GB |
OS | Windows 11 Home 24H2 |
.NET | SDK 8.0.403 |
BenchmarkDotNet | v0.15.1 |
以降すべてのセクションで共通。
Release(x64) ビルド & Ctrl+F5
実行 で測定しています。
1. 文字列操作の最適化
❌ 非効率なアプローチ(+=演算子)
文字列の+=
演算子を使用したクエリ文字列の構築
C#
public string BuildQueryString_Inefficient(Dictionarystring, string> parameters)
{
string result = "";
foreach (var param in parameters)
{
result += $"{param.Key}={param.Value}&";
}
return result.TrimEnd('&');
}
✅ 効率的なアプローチ(StringBuilder)
StringBuilder
を使用して効率的に文字列を構築
C#
public string BuildQueryString_Efficient(Dictionarystring, string> parameters)
{
var sb = new StringBuilder(parameters.Count * 20); // 適切な初期容量
foreach (var param in parameters)
{
sb.Append(param.Key).Append('=').Append(param.Value).Append('&');
}
if (sb.Length > 0)
sb.Length--; // 最後の&を削除
return sb.ToString();
}
【 パフォーマンス差(予想) 】
100個のパラメータで約10倍の性能差
【 理由 】
文字列の+=
演算子は毎回新しい文字列オブジェクトを作成するため、O(n²)の時間計算量になります。StringBuilder
を使用することで、内部バッファの再利用によりO(n)に改善されます。
ベンチマーク結果(+=演算子 vs StringBuilder)
実際の測定に利用したコード
C#
using System;
using System.Collections.Generic;
using System.Text;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
public class QueryStringBench
{
private const int DictSize = 100;
private readonly Dictionarystring,string> _params;
public QueryStringBench()
=> _params = GenerateParams(DictSize, seed: 1234);
// █ ベースライン(+= 連結)
[Benchmark(Baseline = true)]
public string Inefficient() => BuildQueryString_Inefficient(_params);
// █ 改善版(StringBuilder)
[Benchmark]
public string Efficient() => BuildQueryString_Efficient(_params);
//──────────────── テスト対象コード ────────────────
private static string BuildQueryString_Inefficient(Dictionarystring,string> p)
{
string result = string.Empty;
foreach (var kv in p) result += $"{kv.Key}={kv.Value}&";
return result.TrimEnd('&');
}
private static string BuildQueryString_Efficient(Dictionarystring,string> p)
{
var sb = new StringBuilder(p.Count * 20);
foreach (var (k,v) in p) sb.Append(k).Append('=').Append(v).Append('&');
if (sb.Length > 0) sb.Length--;
return sb.ToString();
}
private static Dictionarystring,string> GenerateParams(int n, int seed)
{
var rnd = new Random(seed);
var d = new Dictionarystring,string>(n);
for (int i = 0; i n; i++) d[$"key{i}"] = rnd.Next(0, 10_000).ToString();
return d;
}
}
public class Program
{
public static void Main(string[] args)
=> BenchmarkRunner.RunQueryStringBench>();
}
Method | Mean (µs) | Alloc (KB) | Ratio |
---|---|---|---|
Inefficient | 6.58 | 116.8 | 1.00 |
Efficient | 0.81 | 6.1 | 0.12 |
【結果】StringBuilder
版は 8 倍高速 / 19 倍省メモリ。
Gen0 GC 回数も 9.5 → 0.5 に激減し、スループット向上だけでなく予測可能性も高まりました。
【補足】
定数+少数の変数を 1 式で +(または $””)→ string.Concat 1 回になるので十分速い
ループなどで何度も追記する場合のみ StringBuilder が効果的—この 2 択で使い分ければ OK です。
2. コレクション操作の最適化
❌ 非効率なアプローチ
LINQチェーンを使用したフィルタリングと変換(複数回の反復処理)
C#
public ListUser> FilterAndTransform_Inefficient(ListUser> users, int minAge)
{
var filtered = users.Where(u => u.Age >= minAge).ToList();
var transformed = filtered.Select(u => new User
{
Id = u.Id,
Name = u.Name.ToUpper(),
Age = u.Age
}).ToList();
return transformed;
}
✅ 効率的なアプローチ
単一ループでフィルタリングと変換を同時実行
C#
public ListUser> FilterAndTransform_Efficient(ListUser> users, int minAge)
{
var result = new ListUser>(users.Count); // 適切な初期容量
foreach (var user in users)
{
if (user.Age >= minAge)
{
result.Add(new User
{
Id = user.Id,
Name = user.Name.ToUpper(),
Age = user.Age
});
}
}
return result;
}
【 パフォーマンス差(予想) 】
10,000件のデータで約3倍の性能差
【 理由 】
LINQ チェーンは中間結果を作成し、複数回の反復処理が発生します。単一ループにまとめることで、メモリ使用量と処理時間の両方を削減できます。
ベンチマーク結果(10,000 件, Age ≥ 55)
実際の測定に利用したコード
C#
using System;
using System.Collections.Generic;
using System.Linq;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class CollectionBench
{
private const int ItemCount = 10_000;
private const int MinAge = 58;
private ListUser> _users = default!;
[GlobalSetup]
public void Setup()
{
var rnd = new Random(1234);
_users = Enumerable.Range(0, ItemCount)
.Select(i => new User(i, $"user{i}", rnd.Next(10, 60)))
.ToList();
}
[Benchmark(Baseline = true)]
public ListUser> Inefficient()
=> FilterAndTransform_Inefficient(_users, MinAge);
[Benchmark]
public ListUser> Efficient()
=> FilterAndTransform_Efficient(_users, MinAge);
// --- Inefficient ---
private static ListUser> FilterAndTransform_Inefficient(
ListUser> users, int minAge)
{
var filtered = users.Where(u => u.Age >= minAge).ToList();
return filtered.Select(u =>
new User(u.Id, u.Name.ToUpperInvariant(), u.Age))
.ToList();
}
// --- Efficient ---
private static ListUser> FilterAndTransform_Efficient(
ListUser> users, int minAge)
{
var result = new ListUser>(users.Count / 16); // ざっくり 6〜7 % を見積もって予約
foreach (var u in users)
{
if (u.Age >= minAge)
result.Add(new User(u.Id, u.Name.ToUpperInvariant(), u.Age));
}
return result;
}
}
Method | Mean (µs) | Alloc (KB) | Ratio |
---|---|---|---|
Inefficient | 25.9 | 38.3 | 1.00 |
Efficient | 16.2 | 31.8 | 0.63 |
【結果】
- 単一ループ版は 1.6 倍高速/メモリ 17 % 削減
- Gen0 GC 回数も 3.1 → 2.6 と減少
- ヒット率が 10 % 程度以下 のケースでは、この差がアプリ全体のスループットに効いてくる。
【補足】
- 抽出率が高い(例: Age ≥ 30)場合は差が 1 割前後まで縮小。
- 可読性を優先して LINQ を採用する余地がある一方、
- 低ヒット率 × 大量データ では単一ループのメリットが顕著になる。
3. 非同期処理の最適化
❌ 非効率なアプローチ
foreach
での順次await
処理(直列実行)
C#
public async TaskListstring>> ProcessUrls_Inefficient(Liststring> urls)
{
var results = new Liststring>();
using var client = new HttpClient();
foreach (var url in urls)
{
var response = await client.GetStringAsync(url); // 順次処理
results.Add(ProcessResponse(response));
}
return results;
}
✅ 効率的なアプローチ
Task.WhenAll
を使用した並列処理
C#
public async TaskListstring>> ProcessUrls_Efficient(Liststring> urls)
{
using var client = new HttpClient();
var tasks = urls.Select(async url =>
{
var response = await client.GetStringAsync(url);
return ProcessResponse(response);
});
return (await Task.WhenAll(tasks)).ToList(); // 並列処理
}
【 パフォーマンス差(予想) 】
10個のAPIコールで約8倍の性能差(ネットワーク遅延による)
【 理由 】Task.WhenAll
を使用することで、複数のHTTPリクエストを並列実行できます。I/Oバウンドな処理では、待機時間を有効活用することで劇的な性能向上が可能です。
ベンチマーク結果(10 本 × 100 ms ダミー API)
実際の測定に利用したコード
C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class AsyncBench
{
// ------------------------------------------------------------
// ベンチマークの前提
//
// 非同期 I/O (HTTP) を正確に比較するには、外部ネットワークの
// 揺らぎ(回線状況・サーバ処理時間など)を除く必要があります。
// そこで今回は「実通信の代わりに 100 ms 待機するだけ」の
// 疑似 HTTP ハンドラを用意し、純粋に
// ・逐次 await
// ・Task.WhenAll(…) による並列 await
// がどれだけ差を生むかを検証します。
// ------------------------------------------------------------
private const int UrlCount = 10; // 10 本の疑似 API
private readonly Liststring> _urls;
public AsyncBench()
{
// ダミー URL を作っておくだけ(実際は使わない)
_urls = Enumerable.Range(1, UrlCount)
.Select(i => $"https://example.com/api/{i}")
.ToList();
}
// ---------------- 疑似 HTTP クライアント ----------------
private static readonly HttpClient FakeClient =
new HttpClient(new FakeDelayHandler(TimeSpan.FromMilliseconds(100)))
{
BaseAddress = new Uri("https://example.com")
};
// ---------------- ベンチ対象 ----------------------------
[Benchmark(Baseline = true)]
public async TaskListstring>> Inefficient()
=> await ProcessUrls_Inefficient(_urls);
[Benchmark]
public async TaskListstring>> Efficient()
=> await ProcessUrls_Efficient(_urls);
// ------ 非効率(逐次)-----------------------------------
private static async TaskListstring>> ProcessUrls_Inefficient(
Liststring> urls)
{
var results = new Liststring>();
foreach (var url in urls)
{
var response = await FakeClient.GetStringAsync(url);
results.Add(ProcessResponse(response));
}
return results;
}
// ------ 効率(並列)-------------------------------------
private static async TaskListstring>> ProcessUrls_Efficient(
Liststring> urls)
{
var tasks = urls.Select(async url =>
{
var response = await FakeClient.GetStringAsync(url);
return ProcessResponse(response);
});
return (await Task.WhenAll(tasks)).ToList();
}
// 疑似レスポンス加工
private static string ProcessResponse(string s) => s.ToUpperInvariant();
// ------ 100 ms 待機だけを行うダミー Handler --------------
private sealed class FakeDelayHandler : HttpMessageHandler
{
private readonly TimeSpan _delay;
public FakeDelayHandler(TimeSpan delay) => _delay = delay;
protected override async TaskHttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken cancellationToken)
{
await Task.Delay(_delay, cancellationToken);
return new HttpResponseMessage()
{
Content = new StringContent("dummy")
};
}
}
}
Method | Mean (ms) | Ratio |
---|---|---|
Inefficient | 1 093 | 1.00 |
Efficient | 109 | 0.10 |
【結果】
-
Task.WhenAll
で 10 本を並列化すると 約 10 倍のスループット。 - CPU 使用率はほぼ変わらず、待機時間を“重ねて潰す”ことで劇的に短縮できる。
【測定条件】
- 実ネットワークの揺らぎを排除するため、
HttpMessageHandler
で 100 ms のTask.Delay
を入れた疑似 API を使用。 - リクエスト本数:10 本 / ペイロード:文字列
"dummy"
/ .NET 8.0.11 / BenchmarkDotNet 0.15.1
<参考記事>
【C#】非同期プログラミングの正しい理解と実践
4. 条件分岐の最適化
❌ 非効率なアプローチ
ネストしたif-else
文による条件分岐
C#
public decimal CalculateDiscount_Inefficient(CustomerType type, decimal amount)
{
if (type == CustomerType.Regular)
{
if (amount > 10000) return amount * 0.05m;
else if (amount > 5000) return amount * 0.03m;
else return 0;
}
else if (type == CustomerType.Premium)
{
if (amount > 10000) return amount * 0.15m;
else if (amount > 5000) return amount * 0.10m;
else return amount * 0.05m;
}
else if (type == CustomerType.VIP)
{
if (amount > 10000) return amount * 0.25m;
else if (amount > 5000) return amount * 0.20m;
else return amount * 0.15m;
}
return 0;
}
✅ 効率的なアプローチ
辞書とパターンマッチング(switch式)の組み合わせ
C#
private static readonly DictionaryCustomerType, (decimal high, decimal mid, decimal low)>
DiscountRates = new()
{
[CustomerType.Regular] = (0.05m, 0.03m, 0.00m),
[CustomerType.Premium] = (0.15m, 0.10m, 0.05m),
[CustomerType.VIP] = (0.25m, 0.20m, 0.15m)
};
public decimal CalculateDiscount_Efficient(CustomerType type, decimal amount)
{
if (!DiscountRates.TryGetValue(type, out var rates))
return 0;
return amount switch
{
> 10000 => amount * rates.high,
> 5000 => amount * rates.mid,
_ => amount * rates.low
};
}
【 パフォーマンス差(予想)】
** 約2倍**の性能差(複雑な条件ほど差が拡大)
【 理由 】
パターンマッチングと辞書を活用することで、条件分岐の回数を削減し、コードの可読性も向上します。静的な辞書により、ルックアップコストも最小化されます。
ベンチマーク結果(ネスト if-else vs 辞書 + switch 式)
実際の測定に利用したコード
C#
using System;
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
public enum CustomerType { Regular, Premium, VIP }
[MemoryDiagnoser]
public class ConditionBench
{
// 1 回のベンチで計算する件数(100k / 500k / 2M)
[Params(100_000, 500_000, 2_000_000)]
public int Iterations { get; set; }
private readonly CustomerType[] _types = { CustomerType.Regular, CustomerType.Premium, CustomerType.VIP };
private readonly Random _rnd = new(1234);
// ───────────────────────────────
// 非効率:多段 if-else
// ───────────────────────────────
[Benchmark(Baseline = true)]
public decimal Inefficient()
{
decimal sum = 0;
for (int i = 0; i Iterations; i++)
{
var type = _types[_rnd.Next(_types.Length)];
var amount = _rnd.Next(0, 20_000);
sum += CalculateDiscount_Inefficient(type, amount);
}
return sum;
}
// ───────────────────────────────
// 効率:辞書 + switch 式
// ───────────────────────────────
[Benchmark]
public decimal Efficient()
{
decimal sum = 0;
for (int i = 0; i Iterations; i++)
{
var type = _types[_rnd.Next(_types.Length)];
var amount = _rnd.Next(0, 20_000);
sum += CalculateDiscount_Efficient(type, amount);
}
return sum;
}
// ----------- 対象メソッド -------------
private static decimal CalculateDiscount_Inefficient(CustomerType type, decimal amount)
{
if (type == CustomerType.Regular)
{
if (amount > 10_000) return amount * 0.05m;
else if (amount > 5_000) return amount * 0.03m;
else return 0;
}
else if (type == CustomerType.Premium)
{
if (amount > 10_000) return amount * 0.15m;
else if (amount > 5_000) return amount * 0.10m;
else return amount * 0.05m;
}
else if (type == CustomerType.VIP)
{
if (amount > 10_000) return amount * 0.25m;
else if (amount > 5_000) return amount * 0.20m;
else return amount * 0.15m;
}
return 0;
}
private static readonly DictionaryCustomerType, (decimal high, decimal mid, decimal low)> DiscountRates = new()
{
[CustomerType.Regular] = (0.05m, 0.03m, 0.00m),
[CustomerType.Premium] = (0.15m, 0.10m, 0.05m),
[CustomerType.VIP] = (0.25m, 0.20m, 0.15m)
};
private static decimal CalculateDiscount_Efficient(CustomerType type, decimal amount)
{
if (!DiscountRates.TryGetValue(type, out var rates))
return 0;
return amount switch
{
> 10_000 => amount * rates.high,
> 5_000 => amount * rates.mid,
_ => amount * rates.low
};
}
}
Iterations | Method | Mean (ms) | Ratio |
---|---|---|---|
100 k | Inefficient | 3.88 | 1.00 |
Efficient | 3.71 | 0.96 | |
500 k | Inefficient | 18.83 | 1.00 |
Efficient | 18.14 | 0.96 | |
2 M | Inefficient | 74.10 | 1.00 |
Efficient | 72.67 | 0.98 |
【結果】
辞書+switch
式はネスト if-else
に対し、最大で約 4 % 高速、メモリ割当も 15 % 前後削減。
今回のベンチでは差は 約 4 % でしたが、これは「タイプ 3 種・金額 3 段」の小さな条件木だったためです。実システムでは 商品カテゴリ × 会員ランク × キャンペーンフラグ … と分岐が増えることが多く、テーブル駆動型(辞書/配列)のメリットが一気に大きくなります。上表のシナリオを参考に、“増えそうな分岐は最初からテーブル設計” を意識してみてください。
<参考記事>
【C#】パターンマッチングで条件分岐を簡潔に書く
5. メモリアロケーションの最適化
❌ 非効率なアプローチ
ピクセル毎にヒープに配列を作成する画像処理
C#
public byte[] ProcessImageData_Inefficient(byte[] imageData, int width, int height)
{
var result = new byte[imageData.Length];
for (int i = 0; i imageData.Length; i += 4) // RGBA
{
var pixel = new byte[] { imageData[i], imageData[i+1], imageData[i+2], imageData[i+3] };
var processed = ApplyFilter(pixel); // 毎回新しい配列を作成
Array.Copy(processed, 0, result, i, 4);
}
return result;
}
✅ 効率的なアプローチ
stackalloc
とunsafeコードによる直接メモリ操作
C#
public unsafe byte[] ProcessImageData_Efficient(byte[] imageData, int width, int height)
{
var result = new byte[imageData.Length];
Spanbyte> pixel = stackalloc byte[4]; // スタック上に確保
fixed (byte* sourcePtr = imageData, resultPtr = result)
{
for (int i = 0; i imageData.Length; i += 4)
{
// 直接メモリ操作でコピー
*(uint*)(pixel.GetPinnableReference()) = *(uint*)(sourcePtr + i);
ApplyFilterInPlace(pixel); // インプレース処理
*(uint*)(resultPtr + i) = *(uint*)(pixel.GetPinnableReference());
}
}
return result;
}
【 パフォーマンス差(予想)】
大きな画像(4K)で約5倍の性能差
【 理由 】stackalloc
によりヒープアロケーションを回避し、unsafeコードで直接メモリ操作を行うことで、GCプレッシャーを大幅に削減できます。
ベンチマーク結果(4K 画像 1 枚)
実際の測定に利用したコード
C#
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class ImageBench
{
private const int Width = 3840;
private const int Height = 2160;
private byte[] _image = default!;
[GlobalSetup]
public void Setup()
{
_image = new byte[Width * Height * 4];
new Random(1234).NextBytes(_image);
}
// ───── 基準 (ヒープ 4B × ピクセル) ─────
[Benchmark(Baseline = true)]
public byte[] Inefficient() =>
ProcessImageData_Inefficient(_image);
// ───── 改善版 ─────
[Benchmark]
public byte[] Efficient() =>
ProcessImageData_Efficient(_image);
// --------------------------------------------------
private static byte[] ProcessImageData_Inefficient(byte[] src)
{
var dst = new byte[src.Length];
for (int i = 0; i src.Length; i += 4)
{
var pixel = new byte[]
{
src[i], src[i + 1], src[i + 2], src[i + 3]
};
var p = ApplyFilter(pixel); // 毎回ヒープ 4B
Array.Copy(p, 0, dst, i, 4);
}
return dst;
}
// ============= ここを安全に書き直し =============
private static unsafe byte[] ProcessImageData_Efficient(byte[] src)
{
var dst = GC.AllocateUninitializedArraybyte>(src.Length);
fixed (byte* pSrc = src, pDst = dst)
{
for (int i = 0; i src.Length; i += 4)
{
// アンアライン読取
uint pixel = Unsafe.ReadUnaligneduint>(pSrc + i);
// 8bit * 3 チャネル反転 (ARGB little-endian)
uint processed =
(pixel & 0xFF000000) | // A
((0xFFu - ((pixel >> 16) & 0xFF)) 16) |// R
((0xFFu - ((pixel >> 8) & 0xFF)) 8) |// G
(0xFFu - (pixel & 0xFF)); // B
// アンアライン書込
Unsafe.WriteUnaligned(pDst + i, processed);
}
}
return dst;
}
// 元のフィルタ (参照のみ)
private static byte[] ApplyFilter(byte[] p) => new byte[]
{
(byte)(255 - p[0]), (byte)(255 - p[1]), (byte)(255 - p[2]), p[3]
};
}
Method | Mean (ms) | Alloc (MB) | Ratio |
---|---|---|---|
Inefficient | 86.89 | 537.9 | 1.00 |
Efficient | 9.60 | 31.64 | 0.11 |
【結果】
-
stackalloc
+ 直接メモリ操作版は 約 9 倍高速 - ヒープ割当は 538 MB → 32 MB(約 17 分の 1) に削減
- GC も Gen0 42 500 回 → 938 回 と大幅に減り、GC ストップ・ザ・ワールド時間をほぼ解消できた
【測定条件】
- 画像サイズ:3840 × 2160 × RGBA (≈ 32 MB)
- フィルタ:全チャネル反転(疑似処理)
- .NET 8.0 Release/BenchmarkDotNet 0.15.1
-
を有効化true
6. 数値計算の最適化
❌ 非効率なアプローチ
Math.Pow(x, 2)
を使用した距離計算
C#
public double CalculateDistance_Inefficient(Point[] points)
{
double totalDistance = 0;
for (int i = 0; i points.Length - 1; i++)
{
var dx = points[i+1].X - points[i].X;
var dy = points[i+1].Y - points[i].Y;
totalDistance += Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2)); // 重い計算
}
return totalDistance;
}
✅ 効率的なアプローチ
単純な乗算x * x
とReadOnlySpan
の使用
C#
public double CalculateDistance_Efficient(ReadOnlySpanPoint> points)
{
double totalDistance = 0;
for (int i = 0; i points.Length - 1; i++)
{
var dx = points[i+1].X - points[i].X;
var dy = points[i+1].Y - points[i].Y;
totalDistance += Math.Sqrt(dx * dx + dy * dy); // 乗算の方が高速
}
return totalDistance;
}
【 パフォーマンス差(予想) 】
約 30% の性能差
【 理由 】Math.Pow(x, 2)
は汎用的な累乗関数のため、単純なx * x
より重い処理になります。また、ReadOnlySpan
を使用することで、境界チェックの最適化も期待できます。
ベンチマーク結果(総距離計算)
実際の測定に利用したコード
C#
using System;
using BenchmarkDotNet.Attributes;
using System.Runtime.InteropServices;
[MemoryDiagnoser]
public class DistanceBench
{
// 配列サイズを 3 段階で計測
[Params(10_000, 100_000, 1_000_000)]
public int PointCount { get; set; }
private Point[] _points = default!;
[GlobalSetup]
public void Setup()
{
var rnd = new Random(1234);
_points = new Point[PointCount];
for (int i = 0; i PointCount; i++)
_points[i] = new Point(rnd.NextDouble() * 1000,
rnd.NextDouble() * 1000);
}
// ───── ベースライン:Math.Pow(x,2) ─────
[Benchmark(Baseline = true)]
public double Inefficient() => CalculateDistance_Inefficient(_points);
// ───── 改善版:x*x & ReadOnlySpan ─────
[Benchmark]
public double Efficient() => CalculateDistance_Efficient(_points);
// ────────────────────────────────
private static double CalculateDistance_Inefficient(Point[] pts)
{
double sum = 0;
for (int i = 0; i pts.Length - 1; i++)
{
var dx = pts[i + 1].X - pts[i].X;
var dy = pts[i + 1].Y - pts[i].Y;
sum += Math.Sqrt(Math.Pow(dx, 2) + Math.Pow(dy, 2));
}
return sum;
}
private static double CalculateDistance_Efficient(Point[] pts)
{
ReadOnlySpanPoint> s = pts;
double sum = 0;
for (int i = 0; i s.Length - 1; i++)
{
var dx = s[i + 1].X - s[i].X;
var dy = s[i + 1].Y - s[i].Y;
sum += Math.Sqrt(dx * dx + dy * dy);
}
return sum;
}
}
// シンプルな Point 構造体
[StructLayout(LayoutKind.Sequential)]
public readonly struct Point
{
public readonly double X;
public readonly double Y;
public Point(double x, double y) { X = x; Y = y; }
}
}
PointCount | Method | Mean (µs) | Ratio |
---|---|---|---|
10 000 | Inefficient | 264.1 | 1.00 |
Efficient | 13.5 | 0.05 | |
100 000 | Inefficient | 2 625.8 | 1.00 |
Efficient | 136.4 | 0.05 | |
1 000 000 | Inefficient | 26 673.1 | 1.00 |
Efficient | 1 553.2 | 0.06 |
【結果】
-
dx*dx + dy*dy
方式は 約 18〜20 倍高速 -
Math.Pow(dx, 2)
は汎用累乗関数のため、専用の掛け算に比べ極端に遅い -
ReadOnlySpan
採用により境界チェックも最適化され、追加のメモリ割当はゼロ(Alloc 列 0 B)
7. キャッシュ効率の最適化
❌ 非効率なアプローチ
列優先(column-major)での行列アクセス
C#
public int SumMatrix_Inefficient(int[,] matrix)
{
int sum = 0;
int rows = matrix.GetLength(0);
int cols = matrix.GetLength(1);
for (int col = 0; col cols; col++) // 列優先アクセス
{
for (int row = 0; row rows; row++)
{
sum += matrix[row, col];
}
}
return sum;
}
✅ 効率的なアプローチ
行優先(row-major)での行列アクセス
C#
public int SumMatrix_Efficient(int[,] matrix)
{
int sum = 0;
int rows = matrix.GetLength(0);
int cols = matrix.GetLength(1);
for (int row = 0; row rows; row++) // 行優先アクセス
{
for (int col = 0; col cols; col++)
{
sum += matrix[row, col];
}
}
return sum;
}
【 パフォーマンス差(予想)】
大きな行列(1000×1000)で約3倍の性能差
【 理由 】
C#の多次元配列は行優先(row-major)でメモリに配置されるため、行優先でアクセスすることでCPUキャッシュヒット率が向上します。
ベンチマーク結果(1000 × 1000 int[,])
実際の測定に利用したコード
C#
using System;
using BenchmarkDotNet.Attributes;
[MemoryDiagnoser]
public class MatrixBench
{
private const int Size = 1_000; // 1000 × 1000 行列
private int[,] _matrix = default!;
[GlobalSetup]
public void Setup()
{
_matrix = new int[Size, Size];
var rnd = new Random(1234);
for (int r = 0; r Size; r++)
for (int c = 0; c Size; c++)
_matrix[r, c] = rnd.Next(0, 100);
}
// ───── 列優先(キャッシュ効率 ×)─────
[Benchmark(Baseline = true)]
public int Inefficient() => SumMatrix_Inefficient(_matrix);
// ───── 行優先(キャッシュ効率 ◎)─────
[Benchmark]
public int Efficient() => SumMatrix_Efficient(_matrix);
// ----------- 対象メソッド ---------------
private static int SumMatrix_Inefficient(int[,] m)
{
int sum = 0;
int rows = m.GetLength(0);
int cols = m.GetLength(1);
for (int col = 0; col cols; col++) // 列優先アクセス
for (int row = 0; row rows; row++)
sum += m[row, col];
return sum;
}
private static int SumMatrix_Efficient(int[,] m)
{
int sum = 0;
int rows = m.GetLength(0);
int cols = m.GetLength(1);
for (int row = 0; row rows; row++) // 行優先アクセス
for (int col = 0; col cols; col++)
sum += m[row, col];
return sum;
}
}
Method | Mean (µs) | Ratio |
---|---|---|
Inefficient | 832.9 | 1.00 |
Efficient | 555.6 | 0.67 |
【結果】
- 行優先アクセスは 約 1.5 倍高速
まとめ
さて、この測定結果はいかがでしたでしょうか。
思ったとおり? 意外だった!? いろいろな思いがあると思いますが、私個人としては、なかなか実際に測定してみるなんて機会なかったので、「あれ? 思ったほど差がない・・」とか「あ、本当にこんなに早いのか」とか面白かったです。
パフォーマンス最適化は、アルゴリズムの理解、メモリ使用パターンの把握、そしてC#/.NETの内部動作に関する知識がとても大切です。
重要なポイントは以下の通り!
【 最適化の優先順位 】
- アルゴリズムの複雑さ – O(n²)からO(n)への改善が最も効果的
- メモリアロケーション – GCプレッシャーの削減
- キャッシュ効率 – メモリアクセスパターンの最適化
- 並列化 – I/Oバウンドな処理での並列実行
【 測定とプロファイリング 】
最適化を行う際は、必ずBenchmarkDotNetなどのツールで実際の性能を測定し、プロファイラでボトルネックを特定することが重要です。
C#
[Benchmark]
public void TestMethod_Baseline() => MethodA();
[Benchmark]
public void TestMethod_Optimized() => MethodB();
【 業務での適用指針 】
- 可読性とのバランス – 過度な最適化は保守性を損なう
- 適用箇所の選択 – ホットパスに集中する
- 継続的な改善 – パフォーマンステストの自動化
これらのテクニックを適切に活用することで、アプリケーションの性能を大幅に改善できます!! ただし、最適化は常に測定に基づいて行い、可読性とのバランスを保つこようにしてください。
Views: 0