.NET 9 に対するパフォーマンス改善の貢献 - 小さな改善が大きな価値を生む

Takaaki Suzuki Takaaki Suzuki
.NET 9 に対するパフォーマンス改善の貢献 - 小さな改善が大きな価値を生む

はじめに

Open Source プロジェクトへの貢献は、ソフトウェア開発コミュニティ全体にとって価値ある行為です。今回、私 (@xin9le) は Microsoft が開発を主導する .NET Runtime に対してパフォーマンス改善に関する Pull-Request を送り、それが承認され取り込まれるという貴重な経験をしました。

特筆すべきは、この貢献が Microsoft .NET Team による年次の大規模なパフォーマンス改善レポート「Performance Improvements in .NET」に取り上げられたことです。このブログ記事は、.NET の最新版リリース前に公開され、フレームワークの主要な性能向上について詳細に解説しています。私の貢献が .NET 9 のパフォーマンス改善の一部として認識されたことは非常に光栄であり、その重要性を裏付けるものです。

Performance Improvements in .NET

本記事では、私の貢献内容とそれに至った経緯について共有し、Open Source コミュニティへの参加がいかに価値あるものであるかを皆さんにお伝えできればと思います。

貢献内容の概要

今回の改善は Enumerable.ToDictionary<TKey, TValue>() メソッドに関するものです。該当の Pull-Request は下記の URL を参照ください。

この Pull-Request では主に以下の 2 点の最適化を行いました。

  1. 辞書の初期容量の最適化

    • ICollection または Iterator<T> を実装しているコレクションに対して “も” 適切な初期容量を設定
    • これにより辞書が内部で保持しているバッファの拡張を抑制し、パフォーマンスを向上
  2. 配列 (T[]) と List<T> の処理を共通化

    • Span<T> を使用して T[]List<T> の実装を共通化しコード量を削減
    • List<T> に対する列挙速度を向上

改善の恩恵を受けるケース

この最適化は主に以下のシナリオで効果を発揮し、メモリ使用量の削減と実行速度の向上が期待できます。

  • 要素数が確定しているコレクションを辞書に変換する場合
var dic
    = Enumerable.Range(0, 10000)  // .Range() は要素数が確定している
    .Select(static x => (value: x, power: x * x))  // .Select() では要素数が変わらない
    .ToDictionary(static x => x.value);  // 要素数が確定した状態で呼び出す場合に最適化がかかる

上記の例では、よくあるパターンのひとつとして .Select() を挙げましたが、他にも .OrderBy().ToArray() などもメソッドチェーンの前後で要素数に変化がないので最適化の対象になります。一方で .Where().Distinct() のようにメソッドチェーンの前後で要素数に変化が出てしまうケースは最適化の対象とならず、これまでと同様の速度とメモリ使用量で動作します。

技術的な詳細

1. 初期容量の設定

コレクションには要素を格納しておくための内部バッファが必ず存在します。特に List<T>Dictionary<T> のような内部に配列を抱えるタイプのものの場合、初期容量を設定することが非常に重要になってきます。具体的には、要素を .Add() メソッドでひとつずつ追加していく場合どこかで確保しておいた内部バッファのサイズを超えてしまうことがあり、その際にメモリの再確保と要素の全件コピーが発生してしまいます。これが非常にパフォーマンスインパクトが大きいです。内部バッファの拡張処理は概ね以下のように動作します。

  1. 既定では初期バッファとして 4 要素分のメモリ領域 (A) を確保
  2. バッファサイズの上限 (= 4 件) まで登録する
  3. 5 要素目を登録
    • このとき内部バッファとして 8 要素分 (= 元々の 2 倍) のメモリ領域 (B) を A とは別に確保
    • A に格納されている全要素を B にコピー
    • B の末尾に 5 要素目を登録
    • A のメモリ領域を破棄
  4. 2 に戻る

この動的配列の仕組みについては下記の動画を参考にしていただくと分かり易いので、お時間ある方はご参照ください。

コレクションに格納すべき要素数が確定している場合、内部バッファのサイズを事前に過不足なく適切に設定することができ、メモリの再確保と要素の全件コピーを完全に抑制することができます。今回の Pull-Request では、ここで説明した内部バッファの初期設定を行っています。

では、これまで初期容量の設定が一切行われていなかったのかというと、そんなことは決してありません。実は Pull-Request を送る前にも「指定されたコレクションが ICollection<T> である場合」にのみ最適化が行われていました。私の Pull-Request では、これを .NET 6 から追加された Enumerable.TryGetNonEnumeratedCount<T>() メソッドを利用して「ICollectionIterator<T> に対しても適用するように拡張」しました。

.TryGetNonEnumeratedCount<T>()IEnumerable<T> の要素数を「列挙なしに」取得するためのメソッドです。また Iterator<T> は LINQ を最適化するために .NET Runtime 内部でだけ利用されている型です。特にこの Iterator<T> が重要で、LINQ メソッドから返される IEnumerable<T> のインスタンスがこれを継承する形になっています。先に挙げた .Select().OrderBy() などから返される IEnumerable<T> インスタンスは要素数を適切に管理にしており、.TryGetNonEnumeratedCount<T>() でその要素数を返すことができるため今回の最適化の対象となります。

if (source.TryGetNonEnumeratedCount(out int capacity))
{
    if (capacity is 0)
        return new Dictionary<TKey, TSource>(comparer);

    // 省略...
}

var d = new Dictionary<TKey, TSource>(capacity, comparer);  // 初期容量を設定
foreach (TSource element in source)
    d.Add(keySelector(element), element);

2. 配列 (T[]) と List<T> の処理を共通化

元実装では配列と List<T> に対してそれぞれ特殊処理がされていました。実装はほぼ同じで、以下の 2 点しか違いがありません。

  • 要素数を取得するプロパティが .Length.Count
  • ループの仕方が forforeach
if (collection is TSource[] array)
    return ToDictionary(array, keySelector, elementSelector, comparer);

if (collection is List<TSource> list)
    return ToDictionary(list, keySelector, elementSelector, comparer);

// 配列
private static Dictionary<TKey, TElement> ToDictionary<TSource, TKey, TElement>(TSource[] source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector, IEqualityComparer<TKey>? comparer)
    where TKey : notnull
{
    var d = new Dictionary<TKey, TElement>(source.Length, comparer);
    for (int i = 0; i < source.Length; i++)
        d.Add(keySelector(source[i]), elementSelector(source[i]));
    return d;
}

// List<T>
private static Dictionary<TKey, TElement> ToDictionary<TSource, TKey, TElement>(List<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector, IEqualityComparer<TKey>? comparer)
    where TKey : notnull
{
    var d = new Dictionary<TKey, TElement>(source.Count, comparer);
    foreach (TSource element in source)
        d.Add(keySelector(element), elementSelector(element));
    return d;
}

この実装は共通化ができそうです。共通化によるコード量の削減ができれば .NET Runtime のバイナリサイズに低減に寄与することになります。簡単に思い浮かぶのは IReadOnlyCollection<T> を使った方法ですが、それではインターフェースを介した多態動作になってしまう (= 仮想関数テーブルを挟んだメソッド呼び出しになる) ので若干のオーバーヘッドがあります。現代の .NET であれば Span<T> を使った共通化ができます。

if (source is TSource[] array)
    return SpanToDictionary(array, keySelector, elementSelector, comparer);

if (source is List<TSource> list)
{
    var span = CollectionsMarshal.AsSpan(list);
    return SpanToDictionary(span, keySelector, elementSelector, comparer);
}

// ReadOnlySpan<T> で共通化
private static Dictionary<TKey, TElement> SpanToDictionary<TSource, TKey, TElement>(ReadOnlySpan<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TElement> elementSelector, IEqualityComparer<TKey>? comparer)
    where TKey : notnull
{
    var d = new Dictionary<TKey, TElement>(source.Length, comparer);
    foreach (TSource element in source)
        d.Add(keySelector(element), elementSelector(element));
    return d;
}

CollectionsMarshal.AsSpan(List<T>? list) は馴染みがないかもしれませんが、List<T> が内部バッファとして抱えている配列をダイレクトに Span<T> としてアクセスできるようにする、所謂 unsafe なメソッドです。今回は内部バッファを取り出して悪さをしたいわけではなく、単純に Span<T> として共通化する目的で利用しています。

また、List<T> 自体を foreach ループするよりも Span<T> でループする方が若干速度が出ます。これは List<T>.Enumerator を利用しないことに加えて Span<T> への特殊最適化で境界値チェックが外れるためです。単純にコード量を減らすだけでなく、ループ速度に対する最適化も併せて行っています。

なぜこの改善 PR を提供するに至ったか

ひとことで言えば「複合的な要因が重なった」に集約されるのですが、特に大きかったポイントを 3 つピックアップしてお伝えします。

1. 日常的に行っている .NET の内部実装の追跡

私は日頃から .NET のソースコードを注意深く追いかけるようにしています。これは単なる興味からだけでなく、フレームワークの動作原理を深く理解し、自分のコードの品質向上に繋げるためです。Visual Studio を利用していれば F12 キーを押すだけ簡単に .NET の内部実装を追いかけることができます。是非覗いてみてください。

また、Visual Studio を利用していなくても .NET Source Browser からも閲覧できます。インクリメンタル検索はもちろんのこと、型や関数をクリックして定義に飛ぶこともできます。Visual Studio に慣れ親しんだ方は特に簡単に受け入れられるでしょう。

ちなみに先の .NET Source Browser は .NET (CoreFx) 向けのものなので注意が必要です。もし .NET Framework のソースコードを確認したい場合は Reference Source から参照してください。

2. 想定外の挙動の発見

.NET 開発者であれば Enumerable.ToDictionary() メソッドは日常的に利用するものかと思います。今回の Pull-Request を送る前、私は業務コードの一部をこの .ToDictionary() を利用してパフォーマンス向上ができないかを検証していました。そのときは「念のためと興味本位」で内部実装を閲覧したのですが、そのときは「すでに初期容量を設定する最適化がされているはずだ」という想定がありました。というのも、同様の変換メソッドである .ToArray().ToList() にその最適化が掛かっていることをずっと以前から知っていたためです。しかし、実際に .ToDictionary() のコードを確認するとそうなってはいませんでした。

3. 簡単に実装でき、心理的抵抗感が少なかった

この実装の違いから「改善の余地」があることが明確になりましたが、特に Pull-Request を送る敷居を下げたのが「非常に簡単に実装できる」ものであったことです。そもそも Open Source Project に対して Issue や Pull-Request を送るにあたっては「開発チームごとの規約」や「やり取りの御作法」があるもので、外部の開発者にはその重要性や温度感のすべてを察することができません。修正ポイントが分かったとしても、そういったルールの煩雑さが億劫で見送ることもあるでしょう。

しかし、今回は既存の挙動を守りつつ (= 新たなテストコードを書く必要がなく) たったひとつのメソッドに手を入れるだけで済みます。修正すべきコード量が非常に少ないためにコードレビューを何往復もする必要がないだろうと事前に予測が立っていました。この心理的抵抗感の低さもまた、Pull-Request を送ってみようという気持ちを後押ししました。

おわりに

Open Source プロジェクトへの貢献は、時として小さな改善でも大きな価値を生み出すことがあります。今回の経験を通じて、日々のコード読解や技術研鑽が思わぬ形で実を結ぶことを実感しました。さらに、Microsoft .NET Team の公式ブログに取り上げられたことで、この貢献が .NET コミュニティ全体にとって意義あるものだったことが再確認できました。

皆さんも、日々の業務や学習の中で気づいた改善点があれば、是非 Open Source コミュニティに貢献してみてください。それがソフトウェア開発エコシステム全体の進化に繋がると信じています。もしあなたの貢献内容が .NET Runtime のパフォーマンス改善に関するものであれば、次の「Performance Improvements in .NET」ブログに掲載されるかもしれません!

お問い合わせはこちらから

問い合わせる

関連記事

TOP