1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

.NET Framework のArray.Sort,よく分かんないけどなんか分かった!

Last updated at Posted at 2025-12-07

はじめに

筆者の下記の記事では,.NET FrameworkArray.Sort の謎の動作に悩まされた。

.NET FrameworkArray.Sort では,差がゼロとなることが分かり切っているにも関わらず,同じ要素同士の比較を行う場合があるのだ。これが .NET Framework の欠陥なのか,あるいはこのほうが効率良いからなのかは分からなかった。

.NET Framework 4.8 の Array.Sort のソースコード

ソースコードは下記のサイトで公開されているので誰でも勉強できる。

Array.cs (.NET Framework 4.8) - microsoft.com

だがしかし,長過ぎて筆者の読解力を超えている。

.NET Framework のアルゴリズムを探る

ということで,ソースコードを読まずに外部入力に対する応答から凡そのアルゴリズムを探ってみたところ,.NET Framework 3.5 時代の Array.Sort は純粋なクイックソートのみで構成され,比較的簡素なつくり●●●をしているものと推察された。

.NET Framework 3.5 の Array.Sort の意訳版を作る

ということで試行錯誤すること数カ月・・・ようやく .NET Framework 3.5 時代の Array.Sort と同じ動きをするものをC言語で作ることができた。非常にコンパクトにまとまっているのでコード全文を紹介しよう。

.NET Framework 3.5 の Array.Sort をC言語に意訳したもの
/* 001 */ typedef int     (*COMPARE)(int, int);
/* 002 */ static  void    swap(int *p, int *q) {
/* 003 */     int temp = *p;
/* 004 */     *p = *q;
/* 005 */     *q = temp;
/* 006 */ }
/* 007 */ static  void    sort(int *a, int top, int end, COMPARE compare) {
/* 008 */     do {
/* 009 */         int mid = top + (end - top) / 2;
/* 010 */         if(top != mid && compare(a[top], a[mid]) > 0) swap(&a[top], &a[mid]);
/* 011 */         if(top != end && compare(a[top], a[end]) > 0) swap(&a[top], &a[end]);
/* 012 */         if(mid != end && compare(a[mid], a[end]) > 0) swap(&a[mid], &a[end]);
/* 013 */         int pivot = a[mid];
/* 014 */         int t = top;
/* 015 */         int e = end;
/* 016 */         do {
/* 017 */             while(compare(a[t], pivot) < 0) t++;
/* 018 */             while(compare(pivot, a[e]) < 0) e--;
/* 019 */             if(t > e) break;
/* 020 */             if(t < e) swap(&a[t], &a[e]);
/* 021 */             t++;
/* 022 */             e--;
/* 023 */         } while(t <= e);
/* 024 */         if(e - top <= end - t) {
/* 025 */             if(top < e) sort(a, top, e, compare);
/* 026 */             top = t;
/* 027 */         } else {
/* 028 */             if(t < end) sort(a, t, end, compare);
/* 029 */             end = e;
/* 030 */         }
/* 031 */     } while(top < end);
/* 032 */ }
/* 033 */ void    qsort_net(int *a, int len, COMPARE compare) {
/* 034 */     if(len < 2) return;
/* 035 */     sort(a, 0, len - 1, compare);
/* 036 */ }

アルゴリズム解説

それでは意訳コードを頭から解説していきたい。同じクイックソートである uCRT 版と比べれば実装の違いがよく分かると思う。

uCRT の qsort のソースを読む - Qiita

1. 交換関数

まずは交換関数 swap() だが,uCRT 版と全く同じである。

2. ソート関数冒頭部

ソート関数本体 qsort_net() である。引数は uCRT 版に合わせた。配列の要素数 len が2個未満のときは何もしないでリターンするところも同じである。

ただし,uCRT 版では再帰呼び出しのオーバーヘッドを嫌い,内部スタックを用意して goto 文によるループ処理で済ませていたが,.NET Framework 3.5 版では普通に再帰関数 sort() を呼び出すように作った。

3. 小型ソート

uCRT 版では分割された区間の要素数が8個以下になるとアルゴリズムを切り換えて選択ソートを実行するが,.NET Framework 3.5 版では切り換えない。

4. ピボット選択

クイックソートではピボットの選択が重要である。たまたま選んだピボット値が最大値または最小値であった場合,クイックソートの効率は最悪になってしまうからだ。これを避けるため先頭と末尾と中間の三点でサンプリングして,その中で最大値でも最小値でもない中央値をピボットに選ぶようにしている。

uCRT 版と .NET Framework 3.5 版では,この中間サンプリングの位置が微妙に異なっている。uCRT 版の場合 n = end - top + 1 であるから,n が偶数のとき,中間サンプリングの位置が一つずれることになる。ただし,性能には全く影響しないだろう。

また,uCRT 版の場合,要素数 n が8個より大きいので,中間サンプリングの位置 mid は先頭 top とも末尾 end とも重なることはないが,.NET Framework 3.5 版の場合,要素数が2個になると重なってしまう。このため同値と比較しないように工夫しているものと考える。

4. ピボットを基準とした交換処理

ピボットを基準として,これより小さな値は配列の先頭へ,大きな値は配列の末尾のほうへ集めていく。クイックソートは外部メモリを必要としない内部ソートであり,配列の先頭からピボットより小さな値を検索する一方,配列の末尾からピボットより大きな検索し,これらを交換することで実現している。ここで uCRT 版と .NET Framework 3.5 版で微妙な差がある。

  • uCRT 版では配列の先頭から検索する t はピボットの位置 mid をスキップするようにループが二分割されている。これは同値とわざわざ比較しないような配慮である。一方 .NET Framework 3.5 版ではこのような配慮を行っていないことから,同値と比較してしまうことが避けられない。つまり .NET Framework 3.5 版では比較関数の無駄な呼び出しを行っているが,その分プログラムの構造は少しだけシンプルになっており,これがパフォーマンスにどのような影響を与えるのか興味深いところである。

  • uCRT 版では配列の先頭から検索する変数 t は前置インクリメント演算子があるので top + 1 から検索を開始する。同様に配列の末尾から検索する eend - 1 から検索を開始する。これは配列の先頭の値 a[top] および末尾の値 a[end] はピボット選択処理においてピボットの値 a[mid] と既に比較が終わっており,比較を省略可能だからだ。一方,.NET Framework 3.5 版では律儀に t は top から,eend から検索する。

  • .NET Framework 3.5 版ではピボットの値を変数 pivot にセットすることで,繰り返しピボット値を参照するアクセスを減らしている。そもそも標準ライブラリ qsort() の仕様では,比較関数に比較対象となる二つの要素のポインタを与えるため,このようなことは出来ないのだ。逆にこのような実装を可能とするため,今回の一連の記事では比較関数の引数をポインタではなく整数値そのものとした。ここだけは .NET Framework 3.5 版が uCRT 版に対して明らかにパフォーマンス上有利な点である。

6. 三分割処理

三分割処理とは正式な名称ではなく,筆者が独自に呼んでいるものである。uCRT 版ではピボットと隣接して同値が連続している場合,その区間を再帰処理の対象から外す。この結果,同値が多いデータ(極端な例だと全て同じ値)の場合,uCRT 版では極めて高速にソートを完了することができる。.NET Framework 3.5 版には該当する処理はない。
※詳しくは配列のデータがすべてゼロの結果を見て欲しい。

7. 再帰処理

ピボットを基準として [top, e][t, end] の区間に分割したが,このうち狭いほうを優先して再帰処理に回す。これは広いほうを優先するとスタックが溢れてしまう危険があるからだ。なお,等号が成立したときの挙動が uCRT 版と異なっている。

uCRT 版では再帰関数の呼び出しを行わず,内部スタックに積んでいる。内部スタックに積むのは後回しにするという意味である。

比較回数・転送回数の比較

それでは uCRT 版と .NET Framework 3.5 版で比較回数・転送回数の比較を行ってみよう。試験条件は下記の通りである。

  • 200,000,000 個の 32bit 整数を昇順ソートする。
  • データの順序は以下の四種類である。
    • ゼロ(すべて同値)
    • 昇順:0199,999,999
    • 降順:199,999,9990
    • 乱数:0~199,999,999 の範囲で一様乱数(重複あり)
  • 乱数発生器は XorShift である。

試験結果は下記の通りである。

表1 比較回数の比較(単位は回)
順序 uCRT .NET
ゼロ 399,999,999 5,675,718,141
昇順 5,363,792,412 5,730,237,980
降順 5,363,792,412 5,730,238,009
乱数 6,400,427,154 6,916,900,788
表2 転送回数の比較(単位は回)
順序 uCRT .NET
ゼロ 0 5,208,609,280
昇順 265,782,274 0
降順 465,782,278 200,000,004
乱数 2,736,238,624 2,770,781,196

こういうのはグラフにすると一目瞭然である。比較関数の呼び出し回数は uCRT 版のほうが全般的に少ないことが分かる。一方,.NET Framework 3.5 版ではゼロのときのデータ転送回数が乱数のときの約2倍になっている。

uCRT,.NET

実行時間の比較

試験条件を以下に示す。

  • Cコンパイラは下記の三種類とし,かつターゲットを 32bit (x86)64bit (x64) の二種類の組み合わせで合計六種類とする。
    • Microsoft Visual C++ 2022
    • LLVM clang 17.0.3
    • intel C++ compiler:oneAPI 2024.2, Compiler Release 2024.2.1
  • 評価マシンのスペックは下記の通り
    Windows10 2022H2,Core i7-13700 5.1GHZ,32GB

試験結果を以下に示す。単位は秒である。

コンパイラ 順序 uCRT .NET
Visual C++
32bit版
ゼロ 0.090 1.891
昇順 1.433 1.262
降順 1.475 1.275
乱数 15.505 14.986
LLVM clang-cl
32bit版
ゼロ 0.098 2.129
昇順 1.627 1.425
降順 1.688 1.461
乱数 15.636 14.389
intel icx-cl
32bit版
ゼロ 0.070 2.255
昇順 1.592 1.435
降順 1.691 1.464
乱数 17.120 14.694
コンパイラ 順序 uCRT .NET
Visual C++
64bit版
ゼロ 0.093 2.187
昇順 1.582 1.589
降順 1.617 1.607
乱数 15.500 15.562
LLVM clang-cl
64bit版
ゼロ 0.089 2.334
昇順 1.460 1.338
降順 1.523 1.381
乱数 15.710 14.703
intel icx-cl
64bit版
ゼロ 0.064 2.388
昇順 1.366 1.536
降順 1.491 1.580
乱数 16.584 15.106

グラフにすると分かり易い。

uCRT,.NET

結論

データが全てゼロのような特殊ケースを除き(これは uCRT 版では専用の処理があるためで仕方ないところであるが)ほぼ全てのコンパイラにおいて .NET Framework 3.5 版は uCRT 版に勝っているのだ。

ということで,筆者の記事「uCRT の qsort のソースを読む - Qiita」において,uCRT 版の qsort() を評して

よく出来ている。芸術品である・・・と,この記事を書いた当時はそう思っていた。

と述べていたが,ようやく今回の記事で伏線を回収できる。uCRT 版の qsort() はあれこれと技巧を凝らしたコードのように見えても,それは開発当時には効果があったのかもしれないが,現代のプロセッサおよびコンパイラにおいてはあまり効果がなくなっていたのだ。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?