C vs C#: VLCのガウシアンブラーを競う
C vs. C#: racing VLC's Gaussian blur (martinfinkel.com)
要約
本記事は、VLCのガウシアンブラーフィルタにおけるC言語版とC#版のパフォーマンスを比較します。当初、C#版はコード生成の違いによりC版より遅かったものの、最適化とコンパイラへのヒントにより、C#版はC版に非常に近い、あるいは一部ではそれを上回る性能を達成しました。CPU負荷の高いVLCプラグインにおいてC#は現実的な選択肢であると結論付けています。
全文翻訳
C vs C#: VLCのガウシアンブラーを競う
2026年10月12日
更新、10月12日: 2つの小さなソース変更により、C#の行ごとのパフォーマンスがCの8%以内に収まりました。詳細はラウンド1の末尾にあります。
私の前回の投稿では、.NET Native AOTのおかげで、ネイティブVLC 4プラグインが完全にC#で記述できることを示しました。次に当然浮かぶ疑問は「どのように」ではなく「どれだけ速く」です。VLCはCで書かれており、ビデオフィルタはすべてのフレームのすべてのピクセルに触れます。そしてC#にはガベージコレクタがあります。それは確かにコストがかかるのではないでしょうか?
推測する代わりに、私は実際のVLCフィルタを選び、それをC#に行ごとに移植し、同じVLC内で、同じフレームで、バイト単位で同一の出力を生成させながら、両者を競わせました。その後、両方を最適化し、もう一度競わせました。2回です。
歴史のあるブラー
ガウシアンブラーは、各ピクセルをその近傍の加重平均に置き換えます。最も近いピクセルが最も重み付けされ、その重みは1809年にカール・フリードリヒ・ガウスが測定誤差を説明するために使用したベルカーブに沿って減衰します。その滑らかな減衰が、ボックス状のぼやけではなく、ピンボケしたレンズのように見える理由です。それを制御する1つの数値はシグマです。VLCは最大3 xシグマピクセル離れた近傍を見ます。したがって、シグマ4は25ピクセルのウィンドウを意味します。
[画像: 同じフレーム、オリジナルとVLCのフィルタでシグマ2、4、8でぼかしたもの]
これはデジタルイメージングにおける最も古いエフェクトの1つです。Photoshop 1.0は1990年にこれを搭載し、GIMP、FFmpeg、GStreamerもそれぞれ独自のものを備えています。VLCは2007年からTools > Effects and Filters > Video Effects > Advancedで「Spatial blur」(空間ぼかし)としてこれを搭載しています。
[画像: VLCのAdjustments and Effectsダイアログ、Video Effectsタブ、AdvancedタブでSpatial blurを有効にし、そのシグマスライダーを表示]
VLCでの実装方法
ガウシアンブラーは分離可能です。つまり、すべての行をぼかしてからすべての列をぼかすと、完全な2Dウィンドウと同じ結果が得られます。したがって、25x25のウィンドウ(ピクセルあたり625回の乗算)は、25回の2パスになります。VLCのgaussianblur.cはまさにこれを整数で行っています。まず水平パスをバッファに、次にバッファ上で垂直パスを実行し、最後に重みの合計で割ります。以下は水平パスです。
for( int i_line = 0; i_line < i_visible_lines; i_line++ )
{
for( int i_col = 0; i_col < i_visible_pitch; i_col++ )
{
type_t t_value = 0;
const int c = i_line*i_in_pitch+i_col;
for( int x = __MAX( -i_dim, -i_col*(x_factor+1) );
x <= __MIN( i_dim, (i_visible_pitch - i_col)*(x_factor+1) + 1 );
x++ )
{
t_value += pt_distribution[x+i_dim] *
p_in[c+(x>>x_factor)];
}
pt_buffer[c] = t_value;
}
}
C#での同じコード
C#バージョンは意図的に行ごとに移植されています。同じループ、同じ境界、同じ整数演算、Cがポインタを使用する箇所では生のポインタを使用しています。
for (int line = 0; line < visibleLines; line++)
{
for (int col = 0; col < visiblePitch; col++)
{
int value = 0;
int c = line * inPitch + col;
for (int x = Math.Max(-dim, -col * (xFactor + 1));
x <= Math.Min(dim, (visiblePitch - col) * (xFactor + 1) + 1);
x++)
{
value += distribution[x + dim] * input[c + (x >> xFactor)];
}
buffer[c] = value;
}
}
これはNative AOTでコンパイルされたVLCLRを通じてVLCプラグインとして実行されます。これはVLCが他のプラグインと同様にロードするネイティブDLLです。このフィルタはフレームごとにマネージドヒープに何も割り当てないため、実行中にガベージコレクタが収集するものがありません。
公平を期すために、以下のすべてのバージョンは同じVLC 4リリースビルドで、そのビルド自身の変更されていないフィルタに対して実行され、バイト単位で同一のフレームを生成します。各フィルタ呼び出しは、1080pクリップで、11世代Intel Core i7(Tiger Lake、4コア)を搭載したラップトップ上で同じようにタイム計測されます。各ラウンドは、3つのぼかし強度での.NET 10でのミリ秒/フレームを示し、シグマ4でのフレーム/秒も併記します。.NET 8と11の結果は数パーセントの範囲内です。
ラウンド1: 行ごと
1080pフレームあたりのミリ秒(.NET 10上のC#)、少ない方が良い。
| シグマ 2 | シグマ 4 | シグマ 8 | |
|---|---|---|---|
| 現在のVLC | 62.0 | 125.9 | 252.9 |
| C#ポート | 78.9 | 155.2 | 298.1 |
[画像: シグマ4でのフレーム/秒: 現在のVLC 7.9、C#ポート 6.4]
予想通りCが勝ちました。C#ポートはフレームあたり約23%長くなります。大惨事ではありませんが、明確な差です。
マシンコードを見るとその理由がわかります。上記の内部ループでは、clangは実行中の合計をレジスタに保持し、ループ境界を一度だけ計算します。.NETのコードジェネレータであるRyuJITは、約2倍の命令を発行します。これは、ステップごとにMath.Min(...)を再計算し、合計をメモリに保持します。同じ算術演算ですが、その周りのブックキーピングが多いです。
更新、10月12日。 公開後、このギャップのどれだけがコードジェネレータによるもので、どれだけがC#の書き方によるものかを確認しました。2つの小さな編集でそのほとんどが解消されました。ループ境界をループの前にローカル変数に計算させることで、RyuJITがステップごとにMath.Min(...)の再評価を停止するようにし、インデックスにnintを使用して、メモリアクセスごとに32ビットから64ビットへの変換が不要になるようにしました。Clangはこれらをすべて自動で行います。同じループ、同じ算術演算、バイト単位で同一の出力です。
- int c = line * inPitch + col;
- for (int x = Math.Max(-dim, -col * (xFactor + 1));
- x <= Math.Min(dim, (visiblePitch - col) * (xFactor + 1) + 1);
- x++)
+ nint c = (nint)line * inPitch + col;
+ nint xStart = Math.Max(-dim, -col * (xFactor + 1));
+ nint xEnd = Math.Min(dim, (visiblePitch - col) * (xFactor + 1) + 1);
+ for (nint x = xStart; x <= xEnd; x++)
1080pフレームあたりのミリ秒(.NET 10上のC#)、少ない方が良い。新しい実行なので、最初の2行は上の表とわずかに異なります。
| シグマ 2 | シグマ 4 | シグマ 8 | |
|---|---|---|---|
| 現在のVLC | 62.6 | 137.9 | 256.7 |
| C#ポート | 78.9 | 169.0 | 303.6 |
| C#ポート、境界ホイスト | 67.9 | 126.4 | 263.5 |
これらの2つの編集により、行ごとのC#はCの8%以内に収まり、シグマ4では上回りました。ギャップの一部はコンパイラによるもので、一部はコンパイラが少し助けを必要としたコードによるものでした。
ラウンド2: 同じアルゴリズム、両方最適化
SIMDに手を出す前に、アルゴリズム自体から多くの改善が得られます。SIMDやスレッドを使わない、まだプレーンなスカラーコードで、両方に同じ4つの変更を適用しました。
- 除算を乗算に置き換える。境界から離れた場所では除数は変化しないため、事前に計算された「マジック」な逆数とシフトにより、ループ内で最も遅い命令なしでまったく同じ結果が得られます。
- メモリを格納順に読み取る。垂直パスは列を下っていき、ステップごとに1行全体をジャンプしていました。行全体を累積することで、シーケンシャルにメモリを読み取ります。
- ベルカーブの対称性を利用する。
w*a + w*bはw*(a+b)になります。乗算が半分になります。 - 境界を個別に処理する。これにより、内部は境界計算の必要がない、よりシンプルなループで実行されます。
1080pフレームあたりのミリ秒(.NET 10上のC#)、少ない方が良い。
| シグマ 2 | シグマ 4 | シグマ 8 | |
|---|---|---|---|
| 現在のVLC | 62.0 | 125.9 | 252.9 |
| 最適化されたC | 23.3 | 40.7 | 75.4 |
| 最適化されたC# | 28.1 | 50.6 | 98.9 |
[画像: シグマ4でのフレーム/秒: 現在のVLC 7.9、最適化されたC 25、最適化されたC# 20]
まだプレーンなスカラーコードであるC#バージョンは、現在のVLCコードよりも2.5倍高速です。しかし、Cは依然として先行しています。C#はラウンド1と同じ理由で約24%遅くなります。Cのもう1つの利点: clangはこれらのシンプルなループを自動的にSIMDコードに変換します。比較のためにコードは同じにしましたが、有効にすると、CはソースにSIMDコードを一行も書かずに34 fpsに達します。
ラウンド3: 極限までチューニング
最後のラウンドでは、両者がすべてを利用できるようにします。AVX2 SIMDを単一スレッドで使用し、同じバイト単位で同一の出力を生成します。Cはimmintrin.hのイントリンシックを、C#はSystem.Runtime.Intrinsicsを使用します。両者の設計は同じです。水平パスで命令あたり16ピクセル、色平面のマージされたタップ、浮動小数点演算で正確に行われる除算。水平パスの内部ループです。
__m256i sum = _mm256_mullo_epi16(
_mm256_cvtepu8_epi16( _mm_loadu_si128( (const __m128i *)p ) ), vw0 );
for( int i = 0; i < pair_count; i++ )
{
const int o = pair_offsets[i];
const __m256i both = _mm256_add_epi16(
_mm256_cvtepu8_epi16( _mm_loadu_si128( (const __m128i *)(p - o) ) ),
_mm256_cvtepu8_epi16( _mm_loadu_si128( (const __m128i *)(p + o) ) ) );
sum = _mm256_add_epi16( sum,
_mm256_mullo_epi16( both, _mm256_set1_epi16( (short)pair_weights[i] ) ) );
}
Vector256<ushort> sum = Avx2.MultiplyLow(
Avx2.ConvertToVector256Int16(p).AsUInt16(), vw0);
for (int i = 0; i < pairCount; i++)
{
int o = pairOffsets[i];
Vector256<ushort> both = Avx2.Add(
Avx2.ConvertToVector256Int16(p - o).AsUInt16(),
Avx2.ConvertToVector256Int16(p + o).AsUInt16());
sum = Avx2.Add(sum,
Avx2.MultiplyLow(both, Vector256.Create((ushort)pairWeights[i])));
}
むしろ、C#の方が読みやすいです。型が各レジスタに含まれるものを明示していますが、Cではすべてに対して1つの__m256iを使用しています。
最初のSIMDバージョンでは、C#は14%遅く、最大のぼかしでは30%遅くなりました。同じベクトル命令ですが、clangはその周りのループを展開し、レジスタにポインタを保持していました。そのため、両側でループを広げ、乗算あたり2タップ(vpmaddwd)、ベクトル化された境界、スカラーテールなしで続行しました。C#側では、.NETがclangがデフォルトで生成するものと同じものを生成するまでコードを再構成しました。ホットループは独自のメソッドにあり、重みはメモリから直接ブロードキャストされます。
1080pフレームあたりのミリ秒(.NET 10上のC#)、少ない方が良い。
| シグマ 2 | シグマ 4 | シグマ 8 | |
|---|---|---|---|
| 現在のVLC | 62.5 | 128.9 | 253.8 |
| チューニングされたC | 3.08 | 4.17 | 6.62 |
| チューニングされたC# | 3.05 | 4.23 | 7.22 |
[画像: シグマ4でのフレーム/秒: 現在のVLC 7.8、チューニングされたC 240、チューニングされたC# 236]
これでほぼ同等になりました。シグマ2と4では、両者は測定ノイズの範囲内で同等であり、C#は最大のぼかしで9%遅くなります。どちらも現在のVLCコードより30倍高速です。
私が学んだこと
[画像: 左がオリジナル、右がVLCのガウシアンブラー(シグマ8)]
| シグマ4、1080p | C | C# |
|---|---|---|
| 現在のVLCコード/行ごとポート | 8 fps | 6 fps |
| 同じアルゴリズム、最適化 | 25 fps | 20 fps |
| 極限までチューニング(SIMD) | 240 fps | 236 fps |
- 行ごとに比較すると、C#ポートは約23%遅くなります。ガベージコレクタ(ここでは収集するものがない)や、生のポインタが回避する境界チェックが原因ではありません。コードジェネレータが原因です。LLVMはこれらのループにおいてRyuJITよりも優れた仕事をします。更新: ループと算術演算を維持する2つの小さな編集により、Cの8%以内に収まります(ラウンド1を参照)。
- 作業を行うと、ギャップはほぼ閉じます。その作業の一部は、.NETがclangがデフォルトで生成するものと同じものを生成するようにC#を書き直すことでした。これがここでC#を選択する真のコストです。コンパイラがループをどのように処理するかを知ることです。
- C#は、CPUで作業を行うVLCプラグインの現実的な選択肢です。C#フィルタは、1コアあたりでシグマ8でも、1080pビデオを約140フレーム/秒でぼかします。
その過程で、速度とは無関係のVLCのバグを2つ発見しました。ハードウェアデコードでは、CPUフィルタが有効になると画像の半分が緑色になり、ブラーは各行の右端を2バイト超えて読み取っていました。両方についてマージリクエストを開きました。
AVX-512や、作業を複数のコアに分散させるなど、両方のバージョンをさらに高速化する方法はありますが、ここでは停止しました。
行ごとのギャップのどれだけが本当にコードジェネレータによるものなのかを知りたいです。.NETには、LLVMでC#をコンパイルする実験的なNativeAOT-LLVMブランチがあります。これは現在WebAssemblyのみをターゲットとしているため、ここで使用されているデスクトップVLCではなく、VLCのWebAssemblyバージョン用のVLCプラグインをビルドできます。
これは同じCPUコードで2つの言語を比較するための実験であり、ビデオをぼかす最良の方法ではありません。このようなフィルタはGPUに属しており、いくつかのVLCフィルタのGPUバージョンが開発中です。
最後の10%が重要なら、依然としてCが答えです。それ以外のすべてについては、あなたが最も生産性の高い言語と優れたアルゴリズムがあれば、より遠くまで到達できます。
自分で試してみる
このブラーは現在VLCLRのVLCLR.Filtersライブラリに含まれています。GaussianBlurはVLCピクチャ上でこれらのバージョンのすべてを実行します。GaussianBlurサンプルは、これを約70行の完全なVLCフィルタプラグインにラップします。これは、シグマオプションの読み取りから出力ピクチャの割り当てまでです。
前回の投稿と同様に、実験とルールは私が設計し、Claudeがコードとベンチマークハーネスの大部分を書きました。