ZLinqとは?C#のゼロアロケーションLINQ|.NET・Unityでの導入と性能・制約
ZLinqは、C#の標準LINQをアロケーションゼロで置き換えるCysharp製のライブラリです。既存のコードに AsValueEnumerable() を1行足すだけで、Where や Select をいくらチェーンしてもヒープ確保が増えません。ゲーム用と紹介されがちですが、対応は netstandard2.0 / 2.1 / net8 / net9 と Unity・Godotで、.NET Framework 4.8 のような古い環境でも .NET 10 相当のLINQ演算子が使えるのが実際の姿です。最新版は v1.5.6(2026年4月8日リリース、MITライセンス)。この記事では .NET とUnityそれぞれの導入手順、LINQ to SIMD の効き方、そして ref struct であるがゆえの制約と「使うべきでない場面」までを、公式リポジトリの一次情報にもとづいて整理します。
まとめ
- ZLinqは構造体ベースのLINQ。
ValueEnumerableによる実装で、メソッドチェーンを重ねてもアロケーションが増えない。 - 導入は1行。
dotnet add package ZLinqのうえ、対象コレクションにAsValueEnumerable()を挟むだけ。書き換えを避けたいならZLinq.DropInGeneratorで全LINQ呼び出しを自動置換できる。 - 互換性は実証済み。dotnet/runtime の System.Linq.Tests をドロップイン置換で走らせて9,000テストをパスし、.NET 10 のLINQと99%互換。
- Unityは2段階インストール。NuGetForUnityでZLinq本体、UPMのgit URLで
ZLinq.Unity(LINQ to GameObject)。ただしUnityではSIMDは使われない(netstandard2.1参照のため)。 - 万能ではない。.NET 9以降は
ref structなのでyield/awaitをまたげず、IEnumerable<T>を要求するAPIにも渡せない。小さいコレクション×長いチェーンでは標準LINQより遅くなることもある。
ZLinqの正体:チェーンしてもアロケーションが増えないLINQ
標準LINQでヒープ確保が起きる場所
標準LINQの Where や Select は、呼ぶたびにイテレータのクラスインスタンスを生成します。source.Where(...).Select(...).Take(...) と3段つなげば、列挙のたびにその段数ぶんのオブジェクトがヒープに載る。さらに foreach で GetEnumerator() を呼ぶ際、対象が IEnumerable<T> として宣言されていれば列挙子もボックス化されます。1回の呼び出しでは些細な量ですが、毎フレーム・毎リクエスト実行されるコードでは、このゴミがGCを呼び、GCがフレーム落ちやレイテンシのスパイクを呼びます。ゲーム開発で「Update内でLINQを使うな」という慣習が広まったのは、この連鎖が理由です。
LINQそのものの文法や遅延評価の考え方はLINQとは何か?その基本概念と特徴について詳しく解説で扱っています。ZLinqはその文法を変えずに、アロケーションの部分だけを差し替えるアプローチです。
ValueEnumerableとIValueEnumeratorという設計
ZLinqの中核は、次の2つの型です。
public readonly ref struct ValueEnumerable<TEnumerator, T>(TEnumerator enumerator)
where TEnumerator : struct, IValueEnumerator<T>, allows ref struct
{
public readonly TEnumerator Enumerator = enumerator;
}
public interface IValueEnumerator<T> : IDisposable
{
bool TryGetNext(out T current); // MoveNext + Current を1回に統合
// 最適化用のヘルパー
bool TryGetNonEnumeratedCount(out int count);
bool TryGetSpan(out ReadOnlySpan<T> span);
bool TryCopyTo(scoped Span<T> destination, Index offset);
}
列挙子が構造体なのでヒープに載らず、MoveNext と Current を TryGetNext ひとつに統合してイテレータ呼び出し回数を減らしています。さらに TryGetSpan が成立する経路(配列や List<T> が元ソースのケース)では、要素を1つずつ回さずSpanに対する直接処理へ切り替えられる。ToArray や ToList、Count、ElementAt が速いのはこの3つのヘルパーが効くからです。.NET 9 / C# 13 の allows ref struct により、Span<T> をそのままLINQのソースにできるのも同じ仕組みの延長にあります。
.NET 10相当の演算子が .NET Framework 4.8 でも使える
見落とされがちな価値がここです。ZLinqは .NET 10 のLINQと99%互換で、Shuffle、RightJoin、LeftJoin、Sequence、InfiniteSequence といった新しい演算子を含みます。そしてこれらは netstandard2.0(=.NET Framework 4.8)やUnity(netstandard2.1)でも動きます。ランタイムを上げられない業務システムに、最新のLINQ演算子と最適化だけを持ち込める。互換性の裏づけとして、dotnet/runtime の System.Linq.Tests をZLinqへドロップイン置換した状態で走らせ、9,000テストをパスしたことが公式に示されています。構造体LINQは長らく実験的な実装が多い領域でしたが、ZLinqは全メソッドを実装して本番の置き換えを狙う位置にいます。
.NETプロジェクトへの導入と2通りの書き方
AsValueEnumerable()を挟む(明示的な置き換え)
NuGetから入れて、using ZLinq; のうえでチェーンの先頭に1行足すだけです。
dotnet add package ZLinq
using ZLinq;
var source = new int[] { 1, 2, 3, 4, 5 };
// AsValueEnumerable を呼ぶとそこから先が ZLinq になる
var seq1 = source.AsValueEnumerable().Where(x => x % 2 == 0);
// Span も直接ソースにできる(allows ref struct をサポートする .NET 9 / C# 13 以降)
Span<int> span = stackalloc int[5] { 1, 2, 3, 4, 5 };
var seq2 = span.AsValueEnumerable().Select(x => x * x);
AsValueEnumerable() は任意の IEnumerable<T> に使えますが、ゼロアロケーションで変換できるのは T[]、List<T>、ArraySegment<T>、Memory<T>、Dictionary<TKey, TValue>、Queue<T>、Stack<T>、LinkedList<T>、HashSet<T>、ReadOnlyMemory<T>、ReadOnlySequence<T>、ImmutableArray<T>、Span<T>、ReadOnlySpan<T> という既知の型に限られます(ImmutableArray<T> は .NET 8 以上、Span<T> は .NET 9 以上)。変数が IEnumerable<T> 型で宣言されていても、実体が T[] や List<T> ならZLinqはゼロアロケーションで処理します。
ZLinq.DropInGeneratorで既存コードを書き換えずに置換する
既存プロジェクト全体に AsValueEnumerable() を足して回るのは現実的ではありません。そのためのSource Generatorが ZLinq.DropInGenerator です。導入後、アセンブリ属性で対象を指定すると、同名・同引数の拡張メソッドが優先的に選ばれ、LINQ呼び出しがZLinqに差し替わります。
dotnet add package ZLinq.DropInGenerator
using ZLinq;
// 第1引数は生成先の名前空間、第2引数は対象の型
[assembly: ZLinqDropInAttribute("MyApp", DropInGenerateTypes.Collection)]
対象は Array、Span、Memory、List、Enumerable から選ぶフラグ指定で、Collection(Array・Span・Memory・List)と Everything(+Enumerable)のプリセットがあります。公式も、慣れないうちは Everything ではなく Collection から始めることを推奨しています。
| DropInGenerateTypes | 対象 | 備考 |
|---|---|---|
| Array | T[] | 最小。まずここから |
| Span | Span / ReadOnlySpan | .NET 9以上で効果大 |
| Memory | Memory / ReadOnlyMemory | – |
| List | List<T> | – |
| Enumerable | IEnumerable<T> | 最広。要・名前空間設計 |
| Collection | Array + Span + Memory + List | プリセット |
| Everything | Collection + Enumerable | プリセット |
注意したいのは Enumerable を含めるときの名前空間です。生成先を空文字(グローバル)にすると全LINQ呼び出しがZLinqになり、標準LINQを呼べなくなります。アプリの既定名前空間を指定しておけば、using の切り替えで標準LINQに戻せるため、こちらが推奨されています。
Unityでの導入:NuGetForUnityとZLinq.Unityの2段構え
Unity導入の2ステップ:NuGetForUnityとUPMのgit URL
Unityでは、NuGetパッケージとUPMパッケージの両方が必要です。
- NuGetForUnity(Window → NuGet → Manage NuGet Packages)で「ZLinq」を検索してInstall。
- Package Manager の「Add package from git URL」で
ZLinq.Unityを参照する。
https://github.com/Cysharp/ZLinq.git?path=src/ZLinq.Unity/Assets/ZLinq.Unity
UPM側の package.json は対応Unityを 2021.3 と宣言しており、それ以降のバージョンで動きます。1だけでもZLinqの演算子は使えますが、後述するLINQ to GameObjectは2の ZLinq.Unity を入れないと使えません。
注意したいのが ZLinq.DropInGenerator をUnityで使う場合です。C# Incremental Source Generator(Compiler Version 4.3.0)が要るため、対応する最低Unityバージョンは 2022.3.12f1 に上がります。NuGetForUnityから追加したうえで、アセンブリ属性を asmdef ごとに置く必要もあります(asmdefごとに属性用の .cs を1つ配置する)。2021.3 系のプロジェクトで「DropInを入れたのにZLinqにならない」場合は、まずこの要件を疑ってください。Unityエディタ自体の設定(言語やバージョン差分)でつまずく場合はUnityの日本語化とUnity 6の新機能|エディタ言語設定の手順と主な変更点を参照してください。
LINQ to GameObjectでTransform階層とUI Toolkitを辿る
Unity版を入れると、GameObject/Transformの階層探索をLINQで書けるようになります(旧LINQ to GameObjectの後継にあたる機能です)。
using ZLinq;
public class SampleScript : MonoBehaviour
{
public Transform Origin;
void Start()
{
foreach (var item in Origin.Ancestors()) Debug.Log(item.name);
foreach (var item in Origin.Children()) Debug.Log(item.name);
foreach (var item in Origin.Descendants()) Debug.Log(item.name);
foreach (var item in Origin.BeforeSelf()) Debug.Log(item.name);
foreach (var item in Origin.AfterSelf()) Debug.Log(item.name);
}
}
軸(Ancestors / Children / Descendants / BeforeSelf / AfterSelf)で辿った結果に、そのままLINQのクエリを重ねられます。コンポーネント絞り込みには OfComponent<T> が用意されています。
// タグが foobar の子孫だけを取る
var foobars = root.Descendants().Where(x => x.tag == "foobar");
// 自分と子オブジェクトから FooScript を持つものを取る
var fooScripts = root.ChildrenAndSelf().OfComponent<FooScript>();
UI ToolkitのVisualElementも同じ軸で辿れるため、Document.rootVisualElement.Descendants().OfType<Button>() のように、UI階層に対する検索を書けます。毎フレーム呼ばれる階層探索は元々アロケーションの温床なので、ゼロアロケーションの恩恵が最も分かりやすい用途です。
UnityではSIMDが効かない(そしてref structでもない)
公式READMEは「Unityでは .NET Standard 2.1 を参照しているため、SIMDは利用されない」と明記しています。つまり、後述するLINQ to SIMDによる高速化はUnityでは得られず、Unityで効くのはアロケーション削減とイテレータ呼び出しの削減のほうです。GC由来のフレーム落ちを潰す目的なら十分に意味がありますが、「SIMDで数倍速くなる」という期待でUnityに入れると当てが外れます。
一方でUnityには有利な差もあります。.NET 9 以降の ValueEnumerable は ref struct ですが、Unityでは通常の struct として扱われるため、自前の拡張メソッドを1つ書けば IEnumerable<T> へ変換できます(.NET 9 側ではこれができません)。既存のコードが IEnumerable<T> を前提に組まれていても、Unityなら退路がある、ということです。
性能の実体:SIMDが効く演算子、効かない演算子
SIMDが自動適用される範囲
.NET 8以上では、TryGetSpan が成立する(=元ソースがSpanを取り出せる)場合にSIMDが自動適用されます。標準のSystem.Linqより適用範囲が広いのが特徴で、対象は次の演算子です。
Sum/SumUnchecked/Average:sbyte、short、int、long、byte、ushort、uint、ulong、doubleMax/Min:整数系(Int128、UInt128、nint、nuintを含む)Contains/SequenceEqual:整数系とbool、charRangeからのToArray、ToList、CopyToRepeatからのToArray、ToList、CopyTo(ただしunmanaged structかつサイズが2のべき乗の型に限る)
逆に言えば、Where や Select のようなデリゲートを伴う演算子は、この自動適用の対象外です。ZLinqの速さは「全部SIMDになるから」ではなく、アロケーションが増えないこと+集計系がSIMDに落ちることの合わせ技だと理解しておくと、期待値を外しません。
ただし「デリゲートを渡す処理はSIMDにできない」わけではありません。using ZLinq.Simd; を書いて T[] や Span<T> に AsVectorizable() を呼べば、LINQチェーンの状態と無関係に明示的なSIMD実行を指示できます。VectorizedUpdate にベクトル演算用の Func<Vector<T>, Vector<T>> と余り要素用の Func<T, T> を渡せば、任意の更新処理をSIMD幅で回せますし、Aggregate・All・Any・Count・Select・Zip も同じ形で書けます。なお Vector.IsHardwareAccelerated が false の環境では通常ループにフォールバックします。
公式ベンチマークの読み方(N=16384のSum)
公式READMEが掲載している Sum のベンチマークは次の値です。
| Method | N | Mean | Allocated |
|---|---|---|---|
| ForLoop | 16384 | 25,198.556 ns | – |
| SystemLinqSum | 16384 | 1,402.259 ns | – |
| ZLinqSum | 16384 | 1,351.449 ns | – |
| ZLinqSumUnchecked | 16384 | 721.832 ns | – |
ここで効くのは SumUnchecked です。Sum は checked(オーバーフロー検査あり)で、この検査がSIMD実行の足を引っ張ります。オーバーフローを気にしなくてよい値域だと分かっているなら SumUnchecked を選ぶと約1.9倍になる、というのがこの表の要点です。素の Sum 同士(1,402.259ns対1,351.449ns)の差はごくわずかで、単一の集計呼び出しを置き換えるだけではほとんど変わりません。ZLinqの投資対効果が出るのは、チェーンが長い・呼び出し回数が多い・アロケーションが積み上がるコードです。なお ForLoop が最も遅いのは、SIMDが効かない素朴なループだからと考えられます。少なくとも「手書きループが常に最速」ではない、というのはこの表から読み取れます。
ZLinqを採用すべきでない場面
互換性が高いだけに、制約は導入後に踏みます。公式が「Difference and Limitation」で挙げている項目のうち、設計判断に効くのは次の7つです。
yield/awaitをまたげない:.NET 9以降のValueEnumerable<T>はref structです。foreachの中でyield returnやawaitを書くとコンパイルエラーになります。非同期パイプラインの途中でZLinqを使うなら、ToArrayPool()で一度マテリアライズしてください。IEnumerable<T>を要求するAPIに渡せない:戻り値がZLinqの型のままだと、既存のメソッドシグネチャに渡せません。ToArray()で解決できますが、それではアロケーションを減らす目的が消えます。ToArrayPool()(ArrayPoolから借りてusingで返す)が用意されているものの、スコープ外へ漏らしてはいけない・フィールドに保持してはいけないという制約が付きます。String.Joinに渡すと結果が壊れる:String.JoinにはIEnumerable<string>とparams object[]の両オーバーロードがあり、ZLinqの値を直接渡すと後者が選ばれます。コンパイルは通るのに出力だけがおかしくなる型の事故なので、JoinToString(separator)を使ってください。- netstandard2.0 / 2.1 では
Sum・Averageが実行時に落ちうる:ZLinqはこの2つをINumber<T>全般へ拡張しています。.NET 8以上ではwhere制約でコンパイル時に弾かれますが、netstandard2.0 / 2.1(=.NET Framework 4.8 や Unity)では制約が付かず、非プリミティブ型で呼ぶとランタイムエラーになります。「古い環境でも .NET 10 相当が使える」ことの裏側です。 - 小さいコレクション×長いチェーンでは遅くなりうる:
ValueEnumerableは構造体で、メソッドを重ねるほどサイズが増え、コピーコストが積み上がります。数要素のリストに10段のチェーンを掛けるようなコードでは、標準LINQより遅くなることがあります。公式は「極端に長いチェーンと少ない反復回数でのみ起きるため実務上まれ」としていますが、ベンチマークを取らずに「速くなったはず」と決めつけないための一線です。 doubleの集計結果が標準LINQと一致しない:.NET 8以上のSum/Average(double)はSIMDで並列に加算するため、加算順序が変わり丸め誤差が変わります。既存の集計結果とビット単位で一致させる必要がある処理(金額計算のリグレッションテストなど)では、この差が事故になります。- 同じ変数への再代入ができない:チェーンの各段で型が変わるため、
forループの中でConcatを積み上げて同じ変数に代入し直す書き方は成立しません。
したがって、最初から DropInGenerateTypes.Everything をグローバル名前空間に入れて全LINQをZLinqにする構成は勧めません。標準LINQに戻す退路が塞がれ、上の制約に当たった箇所ごとに回避策を書くことになるからです。まずプロファイラでGCアロケーションが出ているホットパス(毎フレームのUpdate、毎リクエストのハンドラ、大きな配列の集計)を特定し、その関数に AsValueEnumerable() を足す。範囲を広げるのは効果を測ってからで十分で、DropInを使うにしても Collection から始めるのが公式の推奨です。Unityでこの「プロファイル→適用→再計測」を何度も回すなら、エディタのコンパイルとテスト実行をエージェント側から叩けるuLoopMCPとは?Unity開発をAIで自動化する仕組みと導入・他MCPとの違いのような仕組みを併用すると、1周あたりの手作業を減らせます。
よくある質問
ZLinqは標準LINQより常に速いですか?
いいえ。アロケーションは確実に減りますが、速度はケース次第です。公式ベンチでも Sum 単体(N=16384)では 1,402.259ns 対 1,351.449ns とほぼ横並びで、差が開くのはSIMDが効く SumUnchecked(721.832ns)や、チェーンが長くアロケーションが積み上がるコードです。逆に、小さなコレクションに長いチェーンを掛ける場合は構造体のコピーコストで遅くなることがあります。
ライセンスと対応プラットフォームを教えてください
MITライセンスです。対応は netstandard2.0 / netstandard2.1 / net8 / net9 と、Unity・Godot。UnityのUPMパッケージは対応バージョンを 2021.3 と宣言しています(ZLinq.DropInGenerator を併用する場合のみ 2022.3.12f1 以上)。最新版は v1.5.6(2026年4月8日)で、GitHubのスターは約5,200です。
UnityでLINQ to GameObjectを使うにはZLinqだけで足りますか?
足りません。NuGetForUnityで入れるZLinq本体に加えて、git URL参照で ZLinq.Unity パッケージを入れる必要があります。Descendants() や OfComponent<T> はこのUnity向けパッケージ側の機能です。
既存のLINQコードを全部書き換える必要がありますか?
いいえ。ZLinq.DropInGenerator を入れてアセンブリ属性を1行書けば、Source Generatorが優先度の高い拡張メソッドを生成し、既存のLINQ呼び出しがそのままZLinqで実行されます。ただし対象に Enumerable(IEnumerable<T>)を含める場合は、生成先の名前空間をグローバルにせずアプリの既定名前空間にしておくと、using の切り替えで標準LINQへ戻せます。
Sum と SumUnchecked はどう使い分けますか?
Sum はオーバーフローを検査(checked)します。SumUnchecked は検査を省いてSIMDの性能を出し切るもので、公式ベンチでは約1.9倍でした。値域が保証されていて、かつ集計がホットパスにある場合だけ SumUnchecked を選びます。対象は .NET 8以上、整数系と double、そしてソースからSpanが取れる(TryGetSpan が true)ことが条件です。