В прошлом месяце я зарелизил ZLinq v1 — революционную LINQ-библиотеку, которая достигает zero allocation на структурах и дженериках. Она может похвастаться такими расширениями, как LINQ to Span, LINQ to SIMD, LINQ to Tree (FileSystem, JSON, GameObject и т.д.), drop-in replacement Source Generator для произвольных типов, поддержкой нескольких платформ, включая .NET Standard 2.0, Unity и Godot и на данный момент ZLinq имеет более 2000 звезд на GitHub.
Сам по себе LINQ на основе структур не является чем-то необычным, и многие реализации пытались использовать этот подход на протяжении многих лет. Однако до сих пор ни одна из них не стала по-настоящему практичной. Как правило, они страдали от слишком большого размера сборки, недостаточного охвата операторов или проблем с производительностью из-за недостаточной оптимизации, так и не продвинувшись дальше экспериментального статуса. С ZLinq мы стремились создать нечто практичное. Мы реализовали 100% охват всех методов и перегрузок в .NET 10 (включая новые, такие как Shuffle, RightJoin и leftJoin), обеспечив 99%-ную совместимость, и внедрили оптимизации, которые выходят за рамки одного лишь сокращения аллокации, включая поддержку SIMD, что значительно повышает эффективность в большинстве сценариев.
Это стало возможным благодаря моему большому опыту в области внедрения LINQ. В апреле 2009 года я выпустил linq.js — библиотеку LINQ to Objects для JavaScript (что удивительно, так это то, что эта библиотека до сих пор поддерживается кем-то, кто ее форкнул!). Я также реализовал широко используемую библиотеку реактивных расширений UniRx для Unity и недавно зарелизил ее эволюцию, R3. Я создал такие вариации, как LINQ to GameObject, LINQ to BigQuery, и SimdLinq. Объединив этот опыт со знаниями zero-allocation библиотек (ZString, ZLogger) и высокопроизводительных сериализаторов (MessagePack-CSharp, MemoryPack), мы смогли достичь амбициозной цели — создать превосходную альтернативу стандартной библиотеке.

Этот простой бенчмарк наглядно демонстрирует, что в то время как для обычного LINQ выделение памяти растет по мере объединения большего количества методов (Where, Where.Take, Where.Take.Select), для ZLinq этот показатель остается равным нулю.
Производительность зависит от множества факторов: источника, количества элементов, их типа и цепочки методов. Чтобы продемонстрировать, что в большинстве случаев ZLinq работает эффективнее, мы подготовили разнообразные сценарии бенчмарков, которые можно запустить на GitHub Actions: ZLinq/actions/Benchmark. Хотя в некоторых случаях ZLinq структурно не может показать превосходство, он доминирует в большинстве практических сценариев.
Чтобы увидеть значительные различия в бенчмарках, попробуйте несколько раз вызвать функцию Select. Ни System.LINQ, ни ZLinq не применяют в этом случае специальных оптимизаций, но ZLinq демонстрирует значительное преимущество в производительности:

(1B приходится на погрешность BenchmarkDotNet MemoryDiagnoser. В документации четко указано, что MemoryDiagnoser обладает точностью 99,5%, что означает, что могут наблюдаться незначительные погрешности в измерениях.)
В простых случаях операции, которые требуют промежуточных буферов, такие как Distinct или OrderBy, демонстрируют значительные различия, поскольку агрессивный пуллинг значительно сокращает количество аллокаций (ZLinq использует в некоторой степени агрессивный пуллинг, поскольку он по большей части основан на ref struct
, ожидаемо имеющей короткое время жизни):

LINQ применяет специальные оптимизации, основанные на паттернах вызовов методов, поэтому одного лишь сокращения аллокаций недостаточно, чтобы всегда превосходить его. Например, для оптимизации цепочки операторов, представленной в .NET 9 и описанной в разделе Улучшения производительности в .NET 9, ZLinq реализует все эти оптимизации для достижения еще более высокой производительности.

Большим преимуществом ZLinq является то, что эти оптимизации эволюции LINQ становятся доступными для всех поколений .NET (включая .NET Framework), а не только для последних версий.
ZLinq очень прост в использовании — достаточно добавить вызов AsValueEnumerable(). Поскольку полностью поддерживаются все операторы, обновление существующего кода происходит без каких-либо сложностей:
using ZLinq;
var seq = source
.AsValueEnumerable() // достаточно добавить всего лишь одну эту строку
.Where(x => x % 2 == 0)
.Select(x => x * 3);
foreach (var item in seq) { }
Чтобы гарантировать совместимость, ZLinq портирует System.Linq.Tests из dotnet/runtime и регулярно запускает их в ZLinq/System.Linq.Tests.

9000 тестов гарантируют надежность работы (пропущенные тесты связаны с ограничениями ref struct, когда идентичный тестовый код не может быть запущен и т.д.).
Кроме того, ZLinq предоставляет drop-in replacement Source Generator, который, при необходимости, может даже устранить необходимость в использовании AsValueEnumerable()
:
[assembly: ZLinq.ZLinqDropInAttribute("", ZLinq.DropInGenerateTypes.Everything)]

Этот механизм дает вам возможность свободно контролировать охват drop-in replacement. ZLinq/System.Linq.Tests
также используют drop-in replacement для запуска существующего тестового кода с ZLinq без необходимости изменения самих тестов.
Архитектура и оптимизация ValueEnumerable
Для получения более детальной информации об использовании, пожалуйста, ознакомьтесь с ReadMe. Здесь же я хотел бы подробнее рассказать об оптимизации. Архитектурные отличия этой библиотеки не ограничиваются одной лишь реализацией отложенного выполнения последовательностей. Она предлагает множество инноваций по сравнению с библиотеками обработки коллекций на других языках.
Ключевым компонентом является ValueEnumerable<T>
, который служит основой для построения цепочек. Его определение выглядит следующим образом:
public readonly ref struct ValueEnumerable<TEnumerator,
T>(TEnumerator enumerator)
where TEnumerator : struct, IValueEnumerator<T>, allows ref struct // разрешает ref struct только в .NET 9 или более поздней версии
{
public readonly TEnumerator Enumerator = enumerator;
}
public interface IValueEnumerator<T> : IDisposable
{
bool TryGetNext(out T current); // как MoveNext + Current
// хелпер для оптимизации
bool TryGetNonEnumeratedCount(out int count);
bool TryGetSpan(out ReadOnlySpan<T> span);
bool TryCopyTo(scoped Span<T> destination, Index offset);
}
Исходя из этого, операторы типа Where могут создавать цепочку следующим образом:
public static ValueEnumerable<Where<TEnumerator, TSource>, TSource>
Where<TEnumerator, TSource>(this ValueEnumerable<TEnumerator,
TSource> source, Func<TSource, Boolean> predicate)
where TEnumerator : struct, IValueEnumerator<TSource>, allows ref struct
Мы выбрали этот подход вместо использования IValueEnumerable<T>
потому что с таким определением, как (this TEnumerable source) where TEnumerable : struct, IValueEnumerable<TSource>
, вывод типа для TSource
не удался бы. Это связано с ограничением языка C#, где выведение типа не работает из ограничений параметров типа (dotnet/csharplang#6930). Если бы мы все-таки задействовали в реализации это определение, нам пришлось бы определять экземплярные методы для огромного количества комбинаций. LinqAF пошел по этому пути, результатом чего стали более 100 000 методов и огромные размеры сборки, что уж точно не было идеальным решением.
В LINQ вся реализация сосредоточена в IValueEnumerator<T>
, и поскольку все Enumerator’ы являются структурами, я понял, что вместо использования GetEnumerator()
, мы могли бы просто скопировать и передать общий Enumerator
, позволяя каждому Enumerator’у работать в своем собственном независимом состоянии. В результате я пришел к финальной структуре, где IValueEnumerator<T>
оборачивается ValueEnumerable<TEnumerator, T>
. Таким образом, типы отображаются в объявлениях типов, а не в ограничениях, что позволяет избежать проблем с выведением типов.
TryGetNext
Давайте подробнее рассмотрим MoveNext, ядро итерации:
// Традиционный интерфейс
public interface IEnumerator<out T> : IDisposable
{
bool MoveNext();
T Current { get; }
}
// пример итерации
while (e.MoveNext())
{
var item = e.Current; // вызов get_Current()
}
// интерфейс ZLinq
public interface IValueEnumerator<T> : IDisposable
{
bool TryGetNext(out T current);
}
// пример итерации
while (e.TryGetNext(out var item))
{
}
В C# foreach разбивается на MoveNext() + Current
, что создает две проблемы. Во-первых, каждая итерация требует вызова двух методов: MoveNext и get_Current. Во-вторых, Current требует хранения переменной. Поэтому я объединил их в bool TryGetNext (out T current)
. Это сокращает количество вызовов методов до одного на итерацию, что значительно повышает производительность.
Этот подход с bool TryGetNext(out T current)
также используется в итераторе Rust:
pub trait Iterator {
type Item;
// Требуемый метод
fn next(&mut self) -> Option<Self::Item>;
}
Чтобы понять проблему с хранением переменной, давайте посмотрим на реализацию Select:
public sealed class LinqSelect<TSource,
TResult>(IEnumerator<TSource> source, Func<TSource, TResult>
selector) : IEnumerator<TResult>
{
// Три поля
IEnumerator<TSource> source = source;
Func<TSource, TResult> selector = selector;
TResult current = default!;
public TResult Current => current;
public bool MoveNext()
{
if (source.MoveNext())
{
current = selector(source.Current);
return true;
}
return false;
}
}
public ref struct ZLinqSelect<TEnumerator, TSource,
TResult>(TEnumerator source, Func<TSource, TResult> selector) :
IValueEnumerator<TResult>
where TEnumerator : struct, IValueEnumerator<TSource>, allows ref struct
{
// Два поля
TEnumerator source = source;
Func<TSource, TResult> selector = selector;
public bool TryGetNext(out TResult current)
{
if (source.TryGetNext(out var value))
{
current = selector(value);
return true;
}
current = default!;
return false;
}
}
IEnumerator<T>
требует поле current
, потому что он продвигается с помощью MoveNext()
и возвращается с Current
. Однако ZLinq выполняет вычисление и возвращает значения одновременно, устраняя необходимость хранения поля. Это ключевое отличие архитектуры ZLinq, основанной на структурах. Поскольку ZLinq охватывает структуру, в которой каждая цепочка методов полностью охватывает предыдущую структуру (TEnumerator
является структурой), размер структуры растет с каждым добавленным в цепочку методом. Хоть для цепочек методов вменяемой длины это и не так важно с точки зрения производительности, структуры меньшего размера означают более низкие затраты на копирование и более высокую производительность. Принятие TryGetNext
было необходимым решением для минимизации размера структуры.
Недостатком TryGetNext является то, что он не может поддерживать ковариантность и контравариантность. Однако я считаю, что итераторы и массивы должны полностью отказаться от поддержки ковариантности / контравариантности. Они несовместимы со Span<T>
, и это относит их к устаревшим концепциям, что сыграло важную роль при взвешивании всех "за" и "против". Например, преобразование массива в Span может вызвать ошибку во время выполнения, не будучи обнаруженным во время компиляции:
// Из-за вариантности дженериков Derived[] принимается Base[]
Base[] array = new Derived[] { new Derived(), new Derived() };
// В этом случае приведение к Span<T> или использование AsSpan() вызывает ошибку времени выполнения!
// System.ArrayTypeMismatchException: Attempted to access an element as a type incompatible with the array.
Span<Base> foo = array;
class Base;
class Derived : Base;
Это поведение существует, потому что эти функции были добавлены до Span<T>
, и в современном .NET, где Span широко используется, это становится проблемой, что делает функции, которые могут вызывать ошибки во время выполнения, практически непригодными для использования.
TryGetNonEnumeratedCount / TryGetSpan / TryCopyTo
Одного банального перечисления не достаточно, чтобы увеличить производительность. Например, при вызове toArray, если размер не меняется (например, array.Select
().ToArray())
, мы можем создать массив фиксированной длины с помощью new T[count]
. System.LINQ внутренне использует для этой оптимизации тип Iterator<T>
, но поскольку параметр является IEnumerable<T>
, то необходимо будет писать код наподобии if (source is Iterator<TSource> iterator
).
Поскольку ZLinq изначально разрабатывался специально для LINQ, мы учли эти оптимизации. Чтобы избежать увеличения размера сборки, мы тщательно отобрали только те определения, которые обеспечивают максимальный эффект. В результате были использованы эти три метода.
TryGetNonEnumeratedCount(out int count)
успешно работает, если исходный источник содержит конечное количество элементов и не вмешиваются никакие методы фильтрации (Where, Distinct и т.д., хотя Take и Skip поддаются пересчету). Это особенно полезно для ToArray и методов, которые требуют промежуточных буферов, таких как OrderBy и Shuffle.
TryGetSpan(out ReadOnlySpan<T> span)
может значительно повысить производительность, когда к источнику можно обращаться как к непрерывной памяти, позволяя выполнять операции SIMD или обработку циклов на основе Span для повышения производительности агрегирования.
TryCopyTo(scoped Span<T> destination, Index offset)
повышает производительность за счет внутренних итераторов. Чтобы понять разницу между внешними и внутренними итераторами, рассмотрим два подхода, предлагаемые List<T>
: foreach
и ForEach
:
// внешний итератор
foreach (var item in list) { Do(item); }
// внутренний итератор
list.ForEach(Do);
Они выглядят похоже, но работают по-разному. Вот их реализации:
// внешний итератор
List<T>.Enumerator e = list.GetEnumerator();
while (e.MoveNext())
{
var item = e.Current;
Do(item);
}
// внутренний итератор
for (int i = 0; i < _size; i++)
{
action(_items[i]);
}
В этом случае мы сталкиваемся с выбором между накладными расходами на вызов делегата (+ аллокация при создании делегата) и итератором MoveNext + вызовы Current. Сама скорость итерации выше у внутренних итераторов. В некоторых случаях вызовы делегатов могут быть легче, что делает внутренние итераторы потенциально более эффективными с точки зрения производительности.
Конечно, все зависит от конкретной ситуации. Я считаю, что, поскольку лямбда-захваты и обычный поток управления (continue, break, await и т.д.) недоступны, не следует использовать ForEach
или создавать пользовательские методы расширения, имитирующие его. Тем не менее, существуют структурные различия.
TryCopyTo(scoped Span<T> destination, Index offset)
предоставляет ограниченную внутреннюю итерацию, принимая Span
, а не делегат.
Возьмем, к примеру, Select. Для toArray, когда доступно значение Count, она передает Span для внутренней итерации:
public ref struct Select
{
public bool TryCopyTo(Span<TResult> destination, Index offset)
{
if (source.TryGetSpan(out var span))
{
if (EnumeratorHelper.TryGetSlice(span, offset, destination.Length, out var slice))
{
// встраивание цикла
for (var i = 0; i < slice.Length; i++)
{
destination[i] = selector(slice[i]);
}
return true;
}
}
return false;
}
}
// ------------------
// ToArray
if (enumerator.TryGetNonEnumeratedCount(out var count))
{
var array = GC.AllocateUninitializedArray<TSource>(count);
// пробуем внутренний итератор
if (enumerator.TryCopyTo(array.AsSpan(), 0))
{
return array;
}
// в противном случае используем внешний итератор
var i = 0;
while (enumerator.TryGetNext(out var item))
{
array[i] = item;
i++;
}
return array;
}
Таким образом, хоть Select и не может создать Span, если исходный источник может, то вариант с внутренним итератором значительно ускоряет обработку цикла.
TryCopyTo
отличается от обычного CopyTo
тем, что включает Index offse
t и позволяет цели быть меньше источника (стандартный CopyTo .NET выбрасывает ошибку, если цель меньше). Это позволяет отображать элемент, когда размер цели равен 1 — индекс 0 становится первым, ^ 1 — последним. Добавление First
, Last
, ElementAt
непосредственно в IValueEnumerator<T>
создало бы избыточность в определениях классов (влияющую на размер сборки), но объединение небольших целей с Index позволяет одним методом охватить больше вариантов оптимизации:
public static TSource ElementAt<TEnumerator, TSource>(this
ValueEnumerable<TEnumerator, TSource> source, Index index)
where TEnumerator : struct, IValueEnumerator<TSource>, allows ref struct
{
using var enumerator = source.Enumerator;
var value = default(TSource)!;
var span = new Span<T>(ref value); // создаем единственный span
if (enumerator.TryCopyTo(span, index))
{
return value;
}
// else...
}
LINQ to Span
В .NET 9 и выше ZLinq позволяет создавать цепочки LINQ-операторов для Span<T>
и ReadOnlySpan<T>
:
using ZLinq;
// Также может быть применено к Span (только в средах .NET 9/C# 13, которые поддерживают ref struct)
Span<int> span = stackalloc int[5] { 1, 2, 3, 4, 5 };
var seq1 = span.AsValueEnumerable().Select(x => x * x);
// Если включено Drop-in replacement, вы можете вызвать LINQ-оператор напрямую.
var seq2 = span.Select(x => x);
Хотя некоторые библиотеки заявляют о поддержке LINQ для Spans, на самом деле они обычно предоставляют методы расширения только для Span<T>
без какого-либо механизма обобщения. Из-за ограничений языка, которые ранее не позволяли получать Span<T>
в качестве дженерик-параметра, они предлагают ограниченный набор операторов. Универсальная обработка стала возможной с введением allows ref struct
в .NET 9.
В ZLinq нет различия между IEnumerable<T>
и Span<T>
— они обрабатываются одинаково.
Однако, поскольку allows ref struct
требует поддержки языка / среды выполнения, в то время как ZLinq поддерживает все версии .NET начиная с .NET Standard 2.0 и выше, поддержка Span ограничена версией .NET 9 и выше. Это означает, что в .NET 9+ все операторы являются ref struct
, что отличается от более ранних версий.
INQ to SIMD
System.Linq ускоряет некоторые методы агрегирования с помощью SIMD. Например, вызов Sum или Max непосредственно для массивов примитивных типов позволяет значительно сократить время обработки по сравнению с использованием циклов for. Однако, будучи основанными на IEnumerable<T>
, применимые типы ограничены. ZLinq является более универсальным благодаря IValueEnumerator.TryGetSpan
, нацеленный на коллекции, где можно получить Span<T>
(включая прямое применение Span<T>
).
Поддерживаемые методы включают:
Range к ToArray/ToList/CopyTo и т.д. …
Repeat для
unmanaged struct
иsize is power of 2
к ToArray/ToList/CopyTo/etc...Sum для
sbyte
,short
,int
,long
,byte
,ushort
,uint
,ulong
,double
SumUnchecked для
sbyte
,short
,int
,long
,byte
,ushort
,uint
,ulong
,double
Average для
sbyte
,short
,int
,long
,byte
,ushort
,uint
,ulong
,double
Max для
byte
,sbyte
,short
,ushort
,int
,uint
,long
,ulong
,nint
,nuint
,Int128
,UInt128
Min для
byte
,sbyte
,short
,ushort
,int
,uint
,long
,ulong
,nint
,nuint
,Int128
,UInt128
Contains для
byte
,sbyte
,short
,ushort
,int
,uint
,long
,ulong
,bool
,char
,nint
,nuint
SequenceEqual для
byte
,sbyte
,short
,ushort
,int
,uint
,long
,ulong
,bool
,char
,nint
,nuint
Sum
проверяет переполнение, что увеличивает накладные расходы. Мы добавили пользовательский метод SumUnchecked
, который работает быстрее:

Поскольку эти методы неявно применяются при выполнении определенных условий, для эффективной работы с SIMD важно понимать внутренний конвейер. Для T[]
, Span<T>
или ReadOnlySpan<T>
мы предоставляем метод .AsVectorizable()
для явного вызова операций, применимых к SIMD, таких как Sum
, SumUnchecked
, Average
, Max
, Min
, Contains
и SequenceEqual
(однако, если Vector.IsHardwareAccelerated && Vector<T>.isSupported
имеет значение false, мы возвращаемся к стандартной обработке).
Для int[]
и Span<int>
доступен метод VectorizedFillRange
, который выполняет ту же операцию, что и ValueEunmerable.Range().CopyTo()
, заполняя массив последовательными числами с использованием SIMD-ускорения. В некоторых случаях это может быть гораздо быстрее, чем заполнение с помощью цикла for.

Векторизуемые Методы
Написание циклов SIMD-обработки требует практики и усилий. Для повседневного использования мы подготовили вспомогательные методы, которые принимают Func-аргументы. Хотя эти методы имеют некоторые накладные расходы на делегирование и работают хуже, чем встроенный код, они очень удобны для рядовой SIMD-обработки. Они принимают Func<Vector<T>, Vector<T>> vectorFunc
и Func<T, T> func
, обрабатывая с помощью Vector<T>
, где это возможно, а остальное с помощью Func<T>
.
T[]
и Span<T>
предлагают метод VectorizedUpdate
:
using ZLinq.Simd; // необходим using
int[] source = Enumerable.Range(0, 10000).ToArray();
[Benchmark]
public void For()
{
for (int i = 0; i < source.Length; i++)
{
source[i] = source[i] * 10;
}
}
[Benchmark]
public void VectorizedUpdate()
{
// arg1: Vector<int> => Vector<int>
// arg2: int => int
source.VectorizedUpdate(static x => x 10, static x => x 10);
}

Хотя производительность этого метода выше, чем у циклов for, она зависит от среды и формата машины, поэтому рекомендуется проверять ее для каждого конкретного случая.
AsVectorizable()
provides Aggregate
, All
, Any
, Count
, Select
, and Zip
:
AsVectorizable()
предоставляет Aggregate
, All
, Any
, Count
, Select
и Zip
:
source.AsVectorizable().Aggregate((x, y) => Vector.Min(x, y), (x, y) => Math.Min(x, y))
source.AsVectorizable().All(x => Vector.GreaterThanAll(x, new(5000)), x => x > 5000);
source.AsVectorizable().Any(x => Vector.LessThanAll(x, new(5000)), x => x < 5000);
source.AsVectorizable().Count(x => Vector.GreaterThan(x, new(5000)), x => x > 5000);
Производительность этих методов зависит от данных, но Count может показывать существенные различия:

Для Select
и Zip
используйте либо ToArray
, либо CopyTo
:
// Select
source.AsVectorizable().Select(x => x 3, x => x 3).ToArray();
source.AsVectorizable().Select(x => x 3, x => x 3).CopyTo(destination);
// Zip2
array1.AsVectorizable().Zip(array2, (x, y) => x + y, (x, y) => x + y).CopyTo(destination);
array1.AsVectorizable().Zip(array2, (x, y) => x + y, (x, y) => x + y).ToArray();
// Zip3
array1.AsVectorizable().Zip(array2, array3, (x, y, z) => x + y + z, (x, y, z) => x + y + z).CopyTo(destination);
array1.AsVectorizable().Zip(array2, array3, (x, y, z) => x + y + z, (x, y, z) => x + y + z).ToArray();
Zip может быть особенно эффективным и быстрым в некоторых случаях (например, для объединения двух Vec3):

LINQ to Tree
Вам доводилось использовать LINQ to XML? Когда в 2008 году появился LINQ, XML был самым популярным форматом, и удобство использования LINQ to XML поразило многих. Однако с появлением JSON LINQ to XML стал менее востребован.
Однако ценность LINQ to XML заключается в том, что он является эталонным дизайном для LINQ-операций с древовидными структурами. Он служит хорошим примером реализации совместимости древовидных структур с LINQ. Абстракции обхода дерева отлично работают с LINQ to Objects. Яркий пример — работа с SyntaxTree Roslyn, где такие методы, как Descendants, обычно используются в анализаторах и Source Generator’ах.
ZLinq расширяет эту концепцию, определяя интерфейс, который позволяет использовать Ancestors
, Children
, Descendants
, BeforeSelf
, и AfterSelf
для древовидных структур:

На этой диаграмме представлен обход GameObject Unity. Мы также включили стандартные реализации для файловой системы (DirectoryTree) и JSON (что позволяет выполнять операции в стиле LINQ to XML над JsonNode System.Text.Json). Конечно, вы можете реализовать интерфейс для пользовательских типов:
public interface ITraverser<TTraverser, T> : IDisposable
where TTraverser : struct, ITraverser<TTraverser, T> // self
{
T Origin { get; }
TTraverser ConvertToTraverser(T next); // для Descendants
bool TryGetHasChild(out bool hasChild); // необязательно: оптимизировать использование для Descendants
bool TryGetChildCount(out int count); // необязательно: оптимизировать использование для Children
bool TryGetParent(out T parent); // для Ancestors
bool TryGetNextChild(out T child); // для Children | Descendant
bool TryGetNextSibling(out T next); // для AfterSelf
bool TryGetPreviousSibling(out T previous); // BeforeSelf
}
Для JSON вы можете написать:
var json = JsonNode.Parse("""
// snip...
""");
// JsonNode
var origin = json!["nesting"]!["level1"]!["level2"]!;
// JsonNode axis, Children, Descendants, Anestors, BeforeSelf, AfterSelf и ***Self.
foreach (var item in origin.Descendants().Select(x => x.Node).OfType<JsonArray>())
{
// [true, false, true], ["fast", "accurate", "balanced"], [1, 1, 2, 3, 5, 8, 13]
Console.WriteLine(item.ToJsonString(JsonSerializerOptions.Web));
}
Мы включили стандартные реализации LINQ to Tree для GameObject
и Transform
, а также для Node
Godot. Поскольку производительность аллокации и обхода тщательно оптимизирована, они могут быть даже быстрее, чем ручные циклы.
Вопрос OSS
В последние месяцы произошло несколько инцидентов, связанных с.NET OSS, включая коммерциализацию известных проектов. У меня более 40 OSS-проектов на github/Cysharp, а также множество проектов под моим личным управлением и в других организациях, таких как MessagePack, с общим количеством более 50 000 звезд. Я считаю себя одним из крупнейших OSS-поставщиков в.NET экосистеме.
У меня нет планов на коммерциализацию, но из-за растущего масштаба проекта техническое обслуживание становится все более сложной задачей. В проектах с открытым исходным кодом, которые пытаются коммерциализироваться, несмотря на критику, на мейнтейнеров ложится огромная психологическая нагрузка, поскольку финансовая сторона далеко не всегда соответствует затраченным усилиям и времени. И я тоже испытываю это!
Отодвинув в сторону финансовые вопросы, я прошу вас быть готовыми к некоторым задержкам в поддержке. При разработке крупных библиотек, таких как ZLinq, мне требуется много времени, и это может означать, что Issue и PR’ы для других библиотек могут оставаться без ответа в течение нескольких месяцев. Я намеренно избегаю их, даже не читая заголовки (не пользуюсь панелями мониторинга и не отвечаю на уведомления). Такой, казалось бы, небрежный подход необходим для создания инновационных библиотек — это необходимая жертва!
Даже без этого огромное количество библиотек означает, что задержки с ротацией на месяцы неизбежны. Это происходит из-за острой нехватки человеческого ресурса, поэтому, пожалуйста, поймите и примите эти задержки. Не стоит заявлять, что “библиотека мертва” только потому, что ответы приходят медленно. Это меня очень расстраивает! Я стараюсь изо всех сил, но создание новых библиотек занимает много времени и приводит к еще большим задержкам, что отнимает у меня много сил.
Кроме того, фрустрация, связанная с Microsoft, может снизить мою мотивацию, что часто случается с мейнтейнерами для OSS C#. Несмотря на это, в долгосрочной перспективе я надеюсь продолжать работу.
Заключение
Структура ZLinq претерпела значительные изменения благодаря вашим отзывам о превью-релизе. @Akeit0 высказал множество ценных предложений по ключевым аспектам, критически важным для производительности, таким как определение ValueEnumerable<TEnumerator, T>
и добавление Index
к TryCopyTo
. @filzrev предложил обширную инфраструктуру для тестирования и бенчмарков. Без их вклада было бы невозможно обеспечить такую совместимость и повысить производительность, и я искренне благодарен им за это.
Хотя zero-allocation LINQ-библиотеки LINQ не являются чем-то новым, скрупулезность, с которой была разработана ZLinq, выделяет ее среди остальных. Обладая опытом и знаниями, а также руководствуясь исключительной решимостью, мы реализовали все методы, запустили все тестовые примеры для обеспечения полной совместимости и внедрили все оптимизации, включая SIMD. Это было поистине непростое задание!
И даже время было выбрано идеально, так как .NET 9 и C# 13 предоставили все необходимые языковые возможности для полноценной реализации. Одновременно было важно поддерживать Unity и .NET Standard 2.0.
Помимо того, что нам удалось сделать zero-allocation LINQ, LINQ to Tree является моей любимой функцией, и я надеюсь, что разработчики оценят ее!
Одним из узких мест производительности LINQ являются делегаты, и некоторые библиотеки используют подход ValueDelegate, создавая структуры для имитации функций. Мы намеренно избегали этого подхода, так как такие определения непрактичны из-за своей сложности. Лучше писать встроенный код, чем использовать LINQ с ValueDelegate структурами. Усложнять внутреннюю структуру и увеличивать размер сборки — расточительно, поэтому мы принимаем только System.Linq-совместимые решения.
R3 была амбициозной библиотекой, созданной для замены стандартной System.Reactive .NET. Однако замена System.Linq была бы гораздо более масштабным и, возможно, ненужным шагом, поэтому я ожидаю некоторого естественного сопротивления внедрению. Тем не менее, я считаю, что мы продемонстрировали достаточно преимуществ, чтобы оправдать такую замену. Поэтому я был бы очень рад, если бы вы дали шанс и попробовали эту новую библиотеку!
Если вы хотите не только понимать, как работают библиотеки и технологии, но и углубленно освоить C# и .NET, новый курс «C# углублённый» станет отличным проводником. На нём вы изучите важные аспекты работы с памятью, асинхронностью и многопоточностью, а также научитесь создавать высокопроизводительный код. Курс идеально подойдёт для разработчиков уровня Middle/Senior, желающих повысить свою квалификацию. Учеба начнётся в августе.
Также хотим напомнить про открытые уроки, которые проводят в онлайн формате преподаватели-практики OTUS. Эти темы помогут углубить знания в области оптимизации и производительности кода на C#:
Комментарии (4)
Nagg
05.06.2025 20:23Эта библиотека содержит 500+ unsafe хаков в разных местах, учитывая что автор так же известен библиотекой MemoryPack где из-за unsafe кода у него там есть набор потенциальных CVE, я бы был осторожен с этой либой. На мой взгляд Zero-Alloc LINQ можно было бы реализовать 100% безопасным кодом
supinepandora43
Уже готов был закричать: "ТЫ РУССКИЙ?!?!" А потом увидел, что это перевод :(
sdramare
Какая разница?