世界を動かす技術を、日本語で。

C#における安全なゼロコピー操作

概要

  • C# は多用途な言語であり、低レベルから高レベルまで幅広い開発が可能
  • 配列アクセスの境界チェック がパフォーマンスに影響を与える場合がある
  • unsafeコード やポインタ演算で高速化できるが、安全性に課題
  • Span<T>やReadOnlySpan<T> の導入で、安全かつ高速なゼロコピー操作が実現
  • .NETランタイム でもSpanを活用した新しいAPIが増加

C#の多様性と低レベル最適化

  • C#モバイルアプリデスクトップアプリゲームWebサイトサービス/API の開発に対応
  • Java風 の抽象化豊富な設計も可能だが、 unsafeコードポインタ による低レベル制御もサポート
  • パフォーマンス向上Cライブラリとの連携 には GCを介さない生ポインタ 利用が有効

配列アクセスと境界チェック

  • C#の配列アクセス は安全性のため 境界チェック が自動で行われる
  • forループ でインデックスが明確な場合、 コンパイラが境界チェックを省略 できる最適化
  • 動的なインデックス や外部から渡された範囲の場合、 境界チェックが残りパフォーマンス低下

unsafeコードのメリット・デメリット

  • unsafe関数ポインタ演算 で境界チェックを完全排除し、 高速なコード生成 が可能
  • 不正なlength値 で例外ではなく クラッシュや誤動作バッファオーバーフロー脆弱性 のリスク
  • unsafe関数の呼び出し元もunsafe指定 が必要で、安全性確保が困難

Span<T>による安全かつ高速な配列操作

  • 配列の部分範囲と実体の整合性 を保つために Span<T> を導入
    • readonly struct Span<T>
      • T*ポインタ長さ情報 を保持するイミュータブル型
  • Span<T>ref struct として宣言され、 スタック上のみ存続 しヒープに逃げない
    • ガーベジコレクション による解放リスクを排除
    • 他のref struct 内でのみ保持可能、 ボックス化不可
  • Span<T> を使った関数は 境界チェック不要 で高速化、部分配列も安全に受け渡し可能

ReadOnlySpan<T>の利点

  • ReadOnlySpan<T> で受け取れば 不変保証、内容の変更をコンパイラが防止
  • readonly配列 では参照自体しか守れないが、 ReadOnlySpan<T> は内容自体も保護

ゼロコピーAPIと実用例

Quicksortの比較

  • 従来のQuicksort配列と範囲インデックス で部分配列を表現し、 境界チェックやオーバーフロー のリスク
  • Span<T>を使うと範囲演算子(..) で部分配列を安全かつ直感的に表現
    • (low + high) / 2 のようなオーバーフローも発生しにくい
    • 境界チェック不要 でunsafe並みのパフォーマンス
    • コードの可読性・表現力向上

.NETランタイムのゼロコピーAPI

  • .NET では Span<T>ReadOnlySpan<T> 対応のAPIが増加
  • String.Split のようなAPIでも 新たなバッファ生成を避けて メモリ効率向上
    • 例: ReadOnlySpan<char> でCSV行を分割・処理することでGC負荷減少

まとめ

  • C#高レベル抽象化 から 低レベル最適化 まで柔軟に対応
  • unsafeコード はパフォーマンスに優れるが、安全性に難
  • Span<T>ReadOnlySpan<T> の導入で、安全かつ高速な ゼロコピー操作 が容易に
  • .NETランタイム もSpanベースのAPIを拡充し、現代的なパフォーマンスと安全性を両立

Hackerたちの意見

スパンやスライスのような構造は、現代のプログラミング言語における安全なメモリ操作の未来だ。受け入れていこう。俺はスパンをめっちゃ使ってるけど、同じ物理メモリに対して論理的なビューが必要な場合に大きな違いが出る。可能な限り、俺のコードは配列の代わりにスパンを使うように書き直した。ToArray()や、それを使うことを暗示するコードを見落としがちだけど、大きなコードベースでは特に注意が必要だよ。小さな、たまにあるアロケーションでも、作業セットを快適な場所から移動させてGCを動かす原因になることがある。パフォーマンスの違いは、場合によっては理不尽なこともある。こんなこともできる: var arena = stackalloc byte[1024]; var segment0 = arena.Slice(10); var segment1 = arena.Slice(10, 200); ... 上記のコードは、GCの圧力やアクティビティを全く引き起こさない。すべてスタック上で処理される。

こういう記事は本当にありがたい。俺はUmbraco CMSを使っていて、全体のシステムを動かすために推奨要件よりも低いコードを書いている。まだスパンを使う必要性は感じていないけど、膨大なコンテンツを持つウェブサイトには役立つかもしれないと思ってる。今は、ビュー用に作成するモデルに「public readonly record struct」を使うことを検討中。もちろん、標準クラスのreadonlyプロパティと比べてパフォーマンスをプロファイリングする必要があるけど、俺のコードのほとんどはCMSからクラスを水分補給するために短命だから、どれくらいのメリットがあるかはわからない。幸運なことに、主要なプロジェクトの合間にパフォーマンスを最大限に引き出す作業ができる立場にいる。誰か、.NET CMSでスパンや「public readonly record struct」を使って真剣なパフォーマンス向上を見つけた人いる?ページが通常「ファイア&フォゲット」だから、パフォーマンスを引き出すのが難しい。2013年からずっと、コードからパフォーマンスを引き出すために努力してきたけど、いくつかの小さなビジネスと一緒に仕事をしていて、チームの他のメンバーもWixやSquarespaceを検討し始めている。サイトを立ち上げるのに「俺」が関与しなくてもいいからね。俺の知る限り、セキュリティ侵害には遭ったことがないし、ログを読んだりコードを常に見直したりしているのが好きなんだ(少なくともUmbraco CMSの範囲内では、他にも知識があるけど)。2013年以前はPHPとCodeIgniterで働いていて(その後、PHPから.NETに移行する際に少しKohanaも使った)。C#が好きで、かなりのパフォーマンスを引き出せると感じているけど、もっと価値を生み出す方法についてアイデアがあれば、めっちゃ興味ある。

.NET CMSでスパンや「public readonly record struct」を使って真剣なパフォーマンス向上を見つけた人いる? この返答は「.NET CMS」の部分には直接答えてないよ。最適化について考えるべきタイミングを伝えたかっただけ。こういうマイクロ最適化は、特定のパフォーマンス問題を解決しようとしているときに考えるのがベストだよ。特に、あまりアクセスがないサイトを扱っているときはね。俺は、各ページの読み込みに5秒かかる小規模ビジネスのeコマースサイトを使ったことがあって、何かを買うのを諦めたことがある。その場合、サイトをプロファイリングして問題を特定するのは非常に価値がある。アクセスが多いサイトでは、こういうパフォーマンス最適化がコストを節約するのに役立つことがある。もしサービスを運営するのに100台のサーバーが必要で、パフォーマンスを調整して75台に減らせるなら、それはエンジニアリングの努力に見合うかもしれない。俺のおすすめは、何らかのプロファイラーを使うこと。アプリケーション全体でホットスポットを特定して、特定のパフォーマンス問題の原因を探るのがいい。ホットスポットを特定したら、BenchmarkDotNetで問題のマイクロベンチマークを作成して、スパンのようなツールを使って問題を解決してみて。

CMSや似たような状況では、スパンよりも高レベルの変更から大きなパフォーマンス向上が得られるよ。HTTPキャッシュコントロールヘッダーを正しく使ってCDNと組み合わせることで、桁違いの改善が得られる。より効率的なレイアウトテンプレートを使ってHTML/CSS/JSを減らすだけでも、サイト全体に乗数効果をもたらすことができる。俺の経験では、最大の成果はブラウザのF12ツールのネットワークタブを使うことで得られた。次に大きかったのは、プロダクションで動作しているAzure Application Insightsプロファイラー。最もコストのかかるデータベースクエリのトップ10を見て、それを徹底的に調整するのが重要。スパンのようなものは、共有ライブラリの作者にとっては重要だけど、「エンドユーザー」がウェブアプリを書く場合にはそれほどでもない。そういえば、NuGetパッケージのバージョンや.NETフレームワークのバージョンを9や10に更新するだけで、その使用を増やせるよ。これで、ほんの少しの努力で何千ものマイクロ最適化が得られる!

CMSの場合、主要なボトルネックはDBクエリにあることが多いと思うよ。特にC#みたいにデフォルトで結構速い言語の場合はね。こういう低レベルの最適化に行く前に、ちゃんと測定する必要があるよ。多分、この場合はフレームワークやCMSにオーバーヘッドがあって、どう使うかを理解することで一番改善できると思う。スパンは、低レベルのライブラリコードを書くときに注意すべき最適化だね。

普通、CMSのパフォーマンス問題はデータベースやレンダリングコンポーネントの使い方、キャッシュの仕方に関連してることが多いよ。私が経験した2つの.NET CMS、SitecoreとOptimizelyでは、スパンみたいなものはほとんど改善には繋がらないと思う。むしろ、ORMの使い方を見直したり、直接SQLを使ったり、レンダリングを別の方法でキャッシュしたり、CMSのAPIが正しく使われているかを確認する方が重要だよ。

他の人も言ってるけど、CMSのようなプロジェクトではボトルネックはデータベースやキャッシュにあることが多いよ。スパンやスタックアロック、値構造体は、画像処理やゲーム、"AI"/ベクトルクエリ、データベースエンジンの実装みたいな重いデータや数値を扱うシナリオを書くときに重要になるね(昨日のC++を使うって発表した人たちの話も見てみて、RustやGo、Erlang、Java、C#との比較がされてたよ)。私はCMSのワークロードを思い出させるアプリケーションを書くことが多いけど、構造体を使うこともあるけど、過去6年間で低レベルの最適化スキルを本格的に使ったのは数回だけで、95%の時間はDBの使い方が悪いことが原因だね。

誰か、.NETのCMSでSpanや「public readonly record struct」を使って、真剣なパフォーマンス向上を見つけた人いる?ページは通常、ファイアアンドフォゲットだから。Spanの利点は、.NETのアップグレードに合わせて得られるものが多いよ。Spanは低レベルの最適化で、ASP.NETの内部にはユーザーコードよりもずっと効果がある。Spanが追加されて以来、各バージョンの.NETはその使い方を改善してきたし。さらにC#では、コンパイラは意味があるときにSpanのオーバーロードを優先するから、最新の.NETに再ビルドするだけでその利点を享受できる。これが「真剣な」利点かどうかは好みの問題でもあるし、あなたのコードが常に低レベルのことをしているわけではないってことを思い出させてくれる。例えば、データベースのクエリ時間は一般的にもっと大きな影響を与えるしね。 「public readonly record struct」については、注意が必要だよ。デフォルトでその冗長なバージョンを使い始めるコードベースをいくつか見たけど、期待以上にひどいパフォーマンスの落ち込みを招いてしまった。なぜ「record」がデフォルトでクラスになっているのか、構造体の動作を選択する必要があるのかには、たくさんの理由がある。クラスの方が理解しやすいし、パフォーマンスのトレードオフも把握しやすい。GCは友達であって敵じゃない、特に短命のデータに対してはね。(Gen0のコレクションはしばしば非常に速い。ナースリーはファイアアンドフォゲットのデータの回転のために設計されている。世代型ガベージコレクターの仕事は、回転と安定を分け、回転を素早く処理して安定を保つことだから。)構造体は値渡しだから、同じスタックに留まる「ラッキーパス」を出ると、どんどんコピーされちゃう。モデルに他の構造体がたくさん含まれていると、メモリをもっと頻繁にコピーすることになる。構造体が特定のスタックフレームの制限を超えると、結局GCヒープにボックス化されて、ヒープのアロケーションを節約できない。クラスは参照渡し。構造体を使って不変データモデルを作るために「readonly」を使っているなら、すべてのコピーが不変データの変更から積み重なって新しい構造体を作ることになる。一方で、「通常の」不変レコード(クラス)は、互いに参照を共有できるから、変更しない部分は同じ参照を使って同じメモリを共有できる。モデルが整数が数個以上で、何らかのネストや複雑な関係を持っているなら、「public readonly record struct」は早すぎる最適化で、実際にはパフォーマンスを損なうことになる。すべてのデータをスタックに移せるわけじゃないし、移すべきでもない。トレードオフがあることを忘れないで、健全なパフォーマンスの.NETアプリケーションは、スタックとGCの賢い組み合わせを使うことが一般的だから、どちらも重要なツールなんだ。言った通り、「public record」がデフォルトで「クラス」になっている理由や、「public readonly record struct」が冗長なオプトインである理由を覚えておくのは役に立つよ。

C#の開発者として、スパンが大好き。Rustとは同じ領域ではないのは理解してるけど、Rustが提供するパワーとどれくらい比較できるのかな?

これは好みの問題だね。C#はより一般的なパターンを許可するけど、Rustはもっと多くのことを許可する。例えば、RustではSpan(Rustではスライスと呼ばれる)をどこにでも保存することができる(ヒープ上でも、ただしこれは稀)。

主な違いはこれだと思う。C#はGCがあって、システムは使用中のメモリを保護するけど、スパンやメモリなどを許可する一方で、ライフタイムに関する考慮が雑なためにいくつかの制約がある。でも一般的には「簡単」に使えるから、ライフタイムを気にしなくていい。Rustはライフタイムのセマンティクスが言語のコアに組み込まれていて、コンパイラは安全なものについてずっとよく知っているけど、証明できないけど安全なものからは早めに禁止される(でも経験に基づいてチェックを改善している)。その知識のおかげで、アロケーションをより正確に扱えるようになるんだ。個人的にはアセンブリやC、C++などのバックグラウンドがあるから、Rustの魅力は理解できるし、パフォーマンスが必要な経験の浅い開発者がクリティカルなコンポーネントにRustを選ぶのはプラスだと思う。自分もRustでプロジェクトをやってみようと思ってるけど、今のところRustのわずかなパフォーマンス向上が、C#が許す「ちょっと雑な」接続ができることから得られる「生産性」の向上を上回るプロジェクトは見たことがないな。

これが役に立つかも: 「Rustの借用チェッカーとC#の比較」 https://em-tg.github.io/csborrow/

多くのプロジェクトでは、GCは全く問題にならないし、考えることもないよ。でも、あるプロジェクトでは大きな問題で、常にそのゴーストのことを考えなきゃいけない。ゴーストに悩まされないように工夫する必要があるんだ。もしいつもそんな風に考えなきゃいけないなら、非GC言語を使った方がいいかもね。

配列要素をバウンドチェックなしで扱うための、現代のポインタの代替手段がこれだよ。「unsafe」キーワードやGCのためのオブジェクトピンニングなしで:MemoryMarshal.GetArrayDataReference(T[])。これはまだ完全にunsafeだけど、「現代的に安全なunsafe」で、refと連携してSystem.Runtime.CompilerServices.Unsafeと仲良くなる。面白い点は、このメソッドとSRCS.Unsafeの冗長さが、潜在的にポインタよりも遅く見えるけど、C#でナイフを使うのと同じくらい速いってこと。fixedキーワードは主にデータの高速一時ピンニング用。fixedからの生ポインタは、AVXで作業するときのアライメントなどに便利だけど、これもrefを使って、ピン留めされたオブジェクトヒープやネイティブメモリから参照できる。ほとんどのAPIはrefを受け入れて、GCは基盤となるオブジェクトを追跡し続ける。配列データポインタを取得するためのfixedの一般的な誤用についての微妙な違いを見てみて:https://sharplab.io/#v2:C4LghgzgtgPgAgJgIwFgBQcDMACR2DC2A3ut... スパンは素晴らしいけど、時には生のrefがタスクにより適していることもある。パフォーマンスの最後の一歩を引き出すためにね。

C/C++でパフォーマンスが重要なものを書くのが、全体的に見て少ない労力で済むんじゃないかって、最近よく考えるんだよね。パフォーマンス重視のゼロアロケーションのC#とC/C++は、裏技みたいなもので、言語としてのサポートが全然違う。ボクシンググローブと外科用手袋みたいな感じ。C#でもできるけど、いろんな抽象化があってさ。特別なパフォーマンスAPI、C#、IL、アセンブリ。結果は言語のバージョンやランタイムのバージョン、プラットフォーム、IL2CPP/Burst/Mono/dotnetによって変わるけど、C/C++はコンパイラという一つの抽象化レイヤーだけで、コンパイルしたらそれが固定される。できるだけ正確に、一貫して、シンプルな方法でやりたいんだよね! .cppと.csを一緒にコンパイルするビルド環境があれば、すごく助かるな。 ---- 抽象化についての例として、こんなのがある。 void addBatch(int *a, int *b, int count) { for(int i=0; i < count; i++) { a[i] += b[i]; } } 対して、 [MethodImpl(MethodImplOptions.AggressiveOptimization)] public static void AddBatch(int[] a, int[] b, int count) { ref int ra = ref MemoryMarshal.GetArrayDataReference(a); ref int rb = ref MemoryMarshal.GetArrayDataReference(b); for (nint i = 0, n = (nint)count; i < n; i++) { ra[i] += rb[i]; } } (これは明らかに作り話の例だけど、抽象化の種類を示したかったんだ。)

「モデレートは安全でない」。実際、ほとんどの場合その逆なんだよね。void*はGCを含まないことが多いから、むしろ安全だよ。https://learn.microsoft.com/en-us/dotnet/standard/unsafe-cod...を読むことをおすすめするよ。

Cでも動くよ:https://uecker.codeberg.page/2025-07-02.html

それは記事で説明されている内容と完全に1対1の対応ではないと思う。C#のスパンも君のスパン型も型と境界が安全だけど、前者はrefキーワードのおかげで使用に追加の制約があって、ランタイムを介さずにライフタイムエラーがないことが保証されているんだ。

これはプログラミング言語が提供するツールの使い方を学ぶのに良い例だね。ただ「プログラミング言語にはGCがあるから悪い」って言っても、実際に何ができるかを理解していなければ意味がないよね。

「スパンやスライスのような構造は、現代のプログラミング言語における安全なメモリ操作の未来だ。」 技術が主流になるまでに時間がかかるのは悲しいね。オベロンでは、パーティションに相当する宣言はこうなるんだ。 「PROCEDURE partition(span: ARRAY OF INTEGER): INTEGER」 もし型が特別なケースの「ARRAY OF BYTE」なら(それにはSYSTEMをインポートする必要があるけど)、どんな型の表現もバイトのスパンにマッピングできるんだ。セダーやモジュラ-2+、モジュラ-3などでも似たような機能が見つかるよ。現代の安全なメモリ言語は、ようやく90年代の研究に追いついてきたけど、クールなアイデアが受け入れられるまでにいつもこんなに時間がかかるのは残念だね。そう言いつつも、今の.NETには昔モジュラ-3が好きだった理由の機能が全部揃ってる気がする。ちょっと複雑な部分もあるけど、構造体のインライン配列みたいな。

逆に、直接サポートのない言語にGCを導入すると、基本的にいつも劣ったGCを実行していることになるよ。なぜなら、リード/ライトバリアによって可能になった多くの進歩が、言語のサポートなしでは本当に利用できないから。そうすると、人々は「GCは悪い」って叫ぶけど、実際には最悪のものを使わざるを得なかった言語で使ったからなんだよね。

OPにとって、素晴らしい記事だね。ちょっと質問なんだけど、君のswap(array, i, pivotIndex)関数の定義はどうなってるの?何か見落としてる?それとも、標準的に「tempをaに、aをbに、bをtempに」って仮定してるのかな?

ありがとう!記事を書いてる間にコードサンプルを何度も見直したから、swap()はただの名残だったんだ。そう、正解!タプルのスワップに置き換えられる予定だったんだよね:「(a,b)=(b,a)」。今、完了したよ。 :)

これが、Virgilが言語内で範囲をサポートしている理由だね。スライスよりも優れてる。範囲は大きな配列の一部を表す値型なんだ。オフヒープもできるから、Rangeがメモリマップされたバッファを安全に参照できるんだよ。 https://github.com/titzer/virgil/blob/master/doc/tutorial/Ra...

Spanって、まさに同じ概念じゃない?

C#では配列の要素アクセスは安全のために境界チェックされる。でも、そのせいでパフォーマンスに影響が出るんだよね… なんでまだこれにハードウェアサポートがないの?(つまり、境界を意識したCPU命令とか?)編集: あるのかな? https://stackoverflow.com/questions/40752436/do-any-cpus-hav...

GoとC#を行ったり来たりしてるよ。Goでゼロアロケーションのパッケージを書いて、それをC#に移植したら、アロケーションが爆発した!C#のサブストリングがアロケーションすることを忘れてたか、もしくは最初から気づかなかったんだ。解決策はSpansだった。特に、Goには最初から「スパン」が設計されてることに気づかされたよ。 https://github.com/clipperhouse/uax29

いや、GoのスライスはArraySegmentに似てるけど、サイズ変更や追加時のコピーがあるんだ。 .NETがサポートしているような、任意のメモリ(GC所有のものも含めて)を統一的に参照できるbyrefメカニズムはないよ。

C#の文字列は不変なんだ。文字列を扱うにはStringBuilderを使うべきだよ。