Если в современных реалиях у разработчика возникает необходимость собрать надежное решение для обработки G‑кода, он обычно смотрит в сторону готовых вариантов (GRBL/FluidNC/Linux‑CNC), либо пишет свой кастомный парсер на С/С++ или Rust и для таких проектов вариант реализации на.NET даже не рассматривается ввиду того, что платформа является управляемой и потому недостаточно надежной (заслуженно или нет — разберем далее).
Почему вообще C# и.NET?
У вас может возникнуть закономерный вопрос: зачем вообще изобретать велосипед, если традиционно для RT систем выбирают C или C++?
Для меня аргументов в пользу C# и.NET было несколько:
-
Современный.NET — это уже далеко не тот инструмент, который был лет 10 назад, новейшие оптимизации и инструменты позволяют писать высокопроизводительный код из коробки при должной дисциплине разработки.
-
Выбор связки C# и.NET автоматически открывает доступ гигантской экосистеме: Avalonia UI, ASP.NET, Entity Framework, Microsoft.Extensions. Все это доступно и позволяет без сборки своих велосипедов строить высококачественные решения.
-
С# намного безопаснее С и С++ по умолчанию, возможностей выстрелить себе в колено намного меньше и большинство строго контролируется компилятором.
Почему связка C# и.NET при наивном использовании является заведомо проигрышной для станков и 3D принтеров?
Стоит начать с того, что самое главное для станков и потоковой обработки команд? Ответ достаточно банален — это предсказуемость поведения, он же детерминизм, то есть выполнение кода за константное время. Станок работает по своим внутренним циклам и поток импульсов для двигателей (либо поток точек траектории) должен быть непрерывен, иначе это гарантированно приведет к рваному движению станка и потенциально может привести к выходу шаговых двигателей из строя от удара, а также к порче детали и потере драгоценного материала и времени, которое уже было потрачено на обработку.
Почему же классический подход C# и.NET с абстракциями, полиморфизмом, виртуальными вызовами и созданиями объектов в куче не позволяет достичь той самой предсказуемости и выполнения за константное время?
На самом деле причина далеко не одна:
-
Искажение локальности данных.
-
Прыжки по таблицам виртуальных вызовов.
-
Сборка мусора.
-
Фрагментация памяти.
Давайте кратко разберем каждую.
Искажение локальности данных происходит из‑за того, что объекты в куче выделяются не последовательно, а там где сейчас под них есть место и когда мы создаем, например, массив или список элементы в коллекциях являются не самими объектами, а ссылками на них, отчего при итерации по коллекции процессор вынужден прыгать от адреса к адресу и накапливает промахи кэша (cache misses), из‑за чего вынужден простаивать, пока поступят новые порции данных из RAM.
Прыжки по таблицам виртуальных вызовов обусловлены тем, что процессор чисто физически не может прыгнуть на адрес в памяти, так как не знает точную реализацию, ко всему этому добавляется то, что JIT не может подставить код вместо вызова метода, если не удастся выполнить девиртуализацию (компиляция под NativeAOT решает эту проблему, но это тема для отдельного обсуждения), а процессор не может предсказать переходы, из‑за чего конвейер инструкций сбрасывается, вызывая задержки. Задержки не очень велики (обычно это наносекунды), но они вносят свою лепту в непредсказуемость поведения программы.
Сборка мусора и фрагментация памяти связаны друг с другом, потому рассмотрим их вместе. Сборка мусора происходит когда во время исполнения программы заканчивается память в поколениях GC, особенно в Gen0 и Gen1, то есть в областях с короткоживущими объектами. Чтобы выполнить сборку мусора, GC необходимо полностью на время заморозить поток приложения (так называемый Stop‑The‑World), что приводит к задержкам выполнения и как следствие к нарушению потока данных на станок, причем победить это алгоритмически — невозможно, время паузы зависит исключительно от количества объектов живущих в куче, при масштабировании системы это количество будет только расти. Фрагментация данных же это последствие работы GC. Удаление мертвых объектов сборщиком приводит к тому что последовательный блок живых объектов в куче превращается в решето, отчего процессор пытаясь прочитать объекты последовательно будет ловить промахи кэша (GC имеет механизм компактности, который решает эту проблему уплотнением кучи, но это дорогая операция, к тому же если по какой‑то причине ваши объекты живут в Large Object Heap — там этот механизм не применяется вовсе).
Исходя из всего сказанного, можно сделать вывод, что.NET и C# не позволяют создать ядро для RT системы? Или могут?..
Забегая немного вперед — могут, результаты бенчмарков говорят сами за себя:
|
Количество строк |
Среднее время (Mean) |
Выделенная память (Allocated) |
|
10 |
8.12 μs |
0 B |
|
100 |
147.38 μs |
0 B |
|
1 000 |
1.81 ms |
0 B |
|
10 000 |
24.88 ms |
0 B |
|
100 000 |
251.55 ms |
0 B |
Конфигурация стенда: Intel Core i5-10400F,.NET 10.0, X64 RyuJIT AVX2, BenchmarkDotNet v0.15.2.
В тесте измерялось время обработки конвейером из 7 слоев 10, 100, 1000, 10 000 и 100 000 строк G‑кода (линейное перемещение, круговое перемещение и рапид). Как вы можете видеть, за 1 секунду можно обработать около 400 000 строк G‑кода с 0 байт аллокаций на чистом C# и.NET 10.0 и превратить их в плотный поток байт‑кода с точками траектории для отправки на микроконтроллер.
Почему при правильном подходе C# и.NET 10.0 позволяют реализовать модуль реального времени?
Времена, когда C# был «языком для формочек и веба», давно прошли. В последних версиях команда.NET активно работает над тем, чтобы предоставить разработчикам новые инструменты для оптимизаций кодовой базы под жесткие требования железа и активно развивает NativeAOT.
Благодаря использованию инструментов по типу ReadOnlyMemory<char>, ReadOnlySpan<char>, ref struct, allows ref struct, NativeMemory, StructLayout — можно строить высокопроизводительную архитектуру без единой аллокации в куче (а значит и без GC пауз) и упаковывать данные так, чтобы они идеально ложились в кэш процессора, обеспечивая при этом максимально возможную локальность данных.
ReadOnlyMemory<T> и ReadOnlySpan<T> позволяют работать с данными без аллокаций новых объектов и копирования, ref struct гарантирует, что данные никогда не утекут в кучу и запрещает боксинг, allow ref struct позволяет строить полиморфное поведение без внедрения виртуальных методов (код компилируется для вызова с каждым типом индивидуально, что особенно эффективно при использовании NativeAOT), NativeMemory дает возможность строить невидимую для GC арену памяти с идеальной локальностью данных, StructLayout позволяет упаковывать данные идеально под линии кэша процессора.
Давайте же рассмотрим архитектуру библиотеки, которая способна преобразовывать 400 000 строк G‑кода в секунду во фреймы симуляции либо траекторию для станка с нулевым давлением на GC и детерминированным выполнением каждого цикла.
Архитектура NG.Velox
Конвейер NG.Velox представляет собой классический компиляторный конвейер из 7 слоев:
-
Preprocessor<TInput> — удаляет комментарии, пробелы, N‑метки (номера строк G‑кода).
-
Lexer — разбивает обработанный текст на токены.
-
Parser — преобразует токены в ноды.
-
Interpreter — интерпретирует полученные ноды во фреймы станка.
-
Planner — планирует траекторию и рассчитывает оптимальные ускорения.
-
Interpolator — интерполирует траекторию между блоками.
-
Postprocessor<TOutput> — преобразует траекторию в требуемый формат (массив байт для стриминга на станок, либо массив фреймов для визуализации в симуляторе).
Между слоями передаются контекст выполнения и DiagnosticBag для сбора диагностики.
Ниже приведена схема конвейера обработки:
Библиотека строится на четырех основных принципах:
-
Zero Heap Allocation в Hot Path (все промежуточные данные необходимые для работы пайплайна живут только на стеке).
-
Data Oriented Design (весь конвейер завязан на конкретные структуры данных, данные важнее абстракций).
-
Unsafe Within, Safe Without (внутри конвейера используются сырые указатели, но снаружи пользователь получает безопасный API и выходные данные обернутые в ReadOnlySpan<T>).
-
Чистота от внешних зависимостей (библиотека написана целиком на чистом.NET без зависимостей от внешних пакетов и полностью кросс‑платформенна).
Ключевые архитектурные решения
Unsafe код
Внутри библиотеки активно используется unsafe код для того, чтобы убрать проверки выхода за границы массива при работе со Span<T>/ReadOnlySpan<T> и массивами через указатели. Расходы на это мизерные (а иногда и вообще нулевые, если JIT сможет предсказать, что выходы за границы массива физически невозможны в данном участке кода), но когда проходов десятки или сотни тысяч — накладные расходы становятся заметны.
[MethodImpl(MethodImplOptions.AggressiveInlining)]public static unsafe void SkipWhitespaces(ref char* pCurrent, char* pEnd){ char* ptr = pCurrent; const CharMask whitespaceMask = CharMask.Whitespace | CharMask.NewLine; while (ptr < pEnd && (*ptr).Is(whitespaceMask)) { ptr++; } pCurrent = ptr;}
Единая непрерывная область памяти (Memory Arena)
MemoryArena использует NativeMemory.Alloc для выделения невидимого для GC участка памяти, все данные внутри пайплайна лежат внутри этой арены. Почему не ArrayPool<T>? Потому что пулы массивов увеличивают давление на GC, подвержены внутренней фрагментации и требуют дисциплины возврата в пул, а паттерн MemoryArena подразумевает выделение за O(1) после инициализации памяти за счет сдвига указателя и очищается за один такт процессора за счет сброса офсета.
public unsafe struct MemoryArena : IDisposable{ private readonly byte* _memory; private readonly int _capacity; private int _offset; private bool _disposed; public MemoryArena(int capacity) { ArgumentOutOfRangeException.ThrowIfNegativeOrZero(capacity); _capacity = capacity; _offset = 0; _memory = (byte*)NativeMemory.Alloc((nuint)capacity); } public readonly int Capacity { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _capacity; } public readonly int AllocatedBytes { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _offset; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public byte* Allocate(int size, int alignment) { ObjectDisposedException.ThrowIf(_disposed, typeof(MemoryArena)); byte* currentPtr = _memory + _offset; byte* alignedPtr = (byte*)(((ulong)currentPtr + (ulong)alignment - 1) & ~(ulong)(alignment - 1)); int realAllocatedSize = (int)(alignedPtr - currentPtr) + size; if (_offset + realAllocatedSize > _capacity) { ThrowOutOfMemoryException(size, _capacity - _offset); } _offset += realAllocatedSize; return alignedPtr; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Reset() { _offset = 0; } public void Dispose() { if (_disposed) return; if (_memory is not null) { NativeMemory.Free(_memory); } _disposed = true; } [MethodImpl(MethodImplOptions.NoInlining)] private static void ThrowOutOfMemoryException(int size, int left) => throw new OutOfMemoryException($"Not enough memory in arena. Requested {size} bytes, left: {left} bytes.");}
Выравнивание памяти (Arena Allocator)
ArenaAllocator автоматически выделяет на арене блок байт под запрошенный тип и добавляет выравнивание, чтобы данные лучше легли в кэш процессора. Для вычисления выравнивания использует хелпер‑структуру с типом выравнивания StructLayout.Sequential, этот трюк хорошо знаком тем, кто пишет на С++ под именем alignof. Он позволяет непосредственно в рантайме узнать требование к выравниванию типа в байтах, основываясь на поведении JIT. В чем суть такого подхода? Так как StructLayout.Sequential обязывает JIT разместить Value строго за Target (который является byte) по правилам архитектуры процессора компилятор должен добавить пустые байты отступа, чтобы выровнять Value по адресу, кратному его собственному требованию к выравниванию. После этого остается через метод Unsafe.ByteOffset взять расстояние от указателя на начало поля Target до указателя на начало поля Value, это и будет требуемое выравнивание.
internal static unsafe class ArenaAllocator{ [MethodImpl(MethodImplOptions.AggressiveInlining)] public static TEntity* Allocate<TEntity>(ref MemoryArena arena, int count) where TEntity : unmanaged { if (count == 0) return null; int size = checked(sizeof(TEntity) * count); int alignment = AlignOf<TEntity>(); return (TEntity*)arena.Allocate(size, alignment); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static int AlignOf<T>() where T : unmanaged { var layout = default(AlignmentHelper<T>); ref byte start = ref Unsafe.As<AlignmentHelper<T>, byte>(ref layout); ref byte field = ref Unsafe.As<T, byte>(ref layout.Value); return (int)Unsafe.ByteOffset(ref start, ref field); } [StructLayout(LayoutKind.Sequential)] private struct AlignmentHelper<T> where T : unmanaged { public byte Target; public T Value; }}
Упаковка данных для изоляции слоев (Context)
Context хранит метаданные, необходимые для работы конвейера. Использует новую фичу C# 13 — обобщенное ограничение allows ref struct для generic методов. До версии 13 полиморфизм на ref struct и передача ее как параметра обобщенного типа запрещал сам компилятор из‑за риска утечки ref struct в кучу из‑за упаковки в managed обертку. После версии 13 модификатор allows ref struct позволяет обойти ограничение на полиморфизм с ref struct и интерфейсами, благодаря компиляции индивидуального метода под вызов с каждым типом generic параметра.
internal ref struct VeloxContext<TInput, TOutput> : IPreprocessingContext<TInput>, ILexingContext, IParsingContext, IInterpretationContext, IPlanningContext, IInterpolationContext, IPostprocessingContext<TOutput> where TInput : notnull where TOutput : unmanaged{ private readonly ref TInput _input; private readonly ref MemoryArena _arena; private PreprocessingResult _preprocessingResult; private LexingResult _lexingResult; private ParsingResult _parsingResult; private InterpretationResult _interpretationResult; private PlanningResult _planningResult; private InterpolationResult _interpolationResult; private PostprocessingResult<TOutput> _postprocessingResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] public VeloxContext( ref TInput input, ref MemoryArena arena) { _input = ref input; _arena = ref arena; _preprocessingResult = default; _lexingResult = default; _parsingResult = default; _interpretationResult = default; _planningResult = default; _interpolationResult = default; _postprocessingResult = default; } public readonly ref MemoryArena Arena { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => ref _arena; } public readonly TInput Input { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _input; } public PreprocessingResult PreprocessingResult { [MethodImpl(MethodImplOptions.AggressiveInlining)] readonly get => _preprocessingResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] set => _preprocessingResult = value; } public LexingResult LexingResult { [MethodImpl(MethodImplOptions.AggressiveInlining)] readonly get => _lexingResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] set => _lexingResult = value; } public ParsingResult ParsingResult { [MethodImpl(MethodImplOptions.AggressiveInlining)] readonly get => _parsingResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] set => _parsingResult = value; } public InterpretationResult InterpretationResult { [MethodImpl(MethodImplOptions.AggressiveInlining)] readonly get => _interpretationResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] set => _interpretationResult = value; } public PlanningResult PlanningResult { [MethodImpl(MethodImplOptions.AggressiveInlining)] readonly get => _planningResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] set => _planningResult = value; } public InterpolationResult InterpolationResult { [MethodImpl(MethodImplOptions.AggressiveInlining)] readonly get => _interpolationResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] set => _interpolationResult = value; } public PostprocessingResult<TOutput> PostprocessingResult { [MethodImpl(MethodImplOptions.AggressiveInlining)] readonly get => _postprocessingResult; [MethodImpl(MethodImplOptions.AggressiveInlining)] set => _postprocessingResult = value; }}// Фича C# 13void Process<TContext>(ref TContext context, ref DiagnosticBag diagnosticBag) where TContext : struct, IPreprocessingContext<TInput>, allows ref struct;void Process<TContext>(ref TContext context, ref DiagnosticBag diagnosticBag) where TContext : struct, ILexingContext, allows ref struct;void Process<TContext>(ref TContext context, ref DiagnosticBag diagnosticBag) where TContext : struct, IParsingContext, allows ref struct;void Process<TContext>(ref TContext context, ref DiagnosticBag diagnosticBag) where TContext : struct, IInterpretationContext, allows ref struct;void Process<TContext>(ref TContext context, ref DiagnosticBag diagnosticBag) where TContext : struct, IPlanningContext, allows ref struct;void Process<TContext>(ref TContext context, ref DiagnosticBag diagnosticBag) where TContext : struct, IInterpolationContext, allows ref struct;void Process<TContext>(ref TContext context, ref DiagnosticBag diagnosticBag) where TContext : struct, IPostprocessingContext<TOutput>, allows ref struct;
Сбор диагностики (DiagnosticBag)
DiagnosticBag — классическая «сумка диагностики» из Roslyn, использует ArrayPool<T>.Shared, такой подход выбран, так как ошибки в Happy Path редки, а сама сумка является частью Cold Path, это позволяет разгрузить арену и уложить туда данные, необходимые для работы конвейера максимально плотно, не фрагментируя их редкими ошибками.
public struct DiagnosticBag : IDisposable{ private const int INITIAL_CAPACITY = 256; private int _errorsCount; private int _warningsCount; private Diagnostic[]? _buffer; private int _totalCount; private bool _disposed; public readonly bool HasErrors => _errorsCount > 0; public readonly bool HasWarnings => _warningsCount > 0; public readonly ReadOnlySpan<Diagnostic> Diagnostics => _buffer is null ? ReadOnlySpan<Diagnostic>.Empty : _buffer.AsSpan(0, _totalCount); [MethodImpl(MethodImplOptions.AggressiveInlining)] public void EnsureCapacity(int capacity) { if (_buffer is null) Init(capacity); else if (_buffer.Length < capacity) Resize(capacity); } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Add(in Diagnostic diagnostic) { if (_buffer is null) Init(INITIAL_CAPACITY); else if (_totalCount >= _buffer.Length) Resize(_totalCount * 2); if (diagnostic.Severity == Severity.Error) _errorsCount++; else if (diagnostic.Severity == Severity.Warning) _warningsCount++; _buffer![_totalCount++] = diagnostic; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Clear() { _totalCount = 0; _errorsCount = 0; _warningsCount = 0; } [MethodImpl(MethodImplOptions.AggressiveInlining)] private void Init(int capacity) => _buffer = ArrayPool<Diagnostic>.Shared.Rent(capacity); [MethodImpl(MethodImplOptions.NoInlining)] private void Resize(int capacity) { var oldBuffer = _buffer!; var newBuffer = ArrayPool<Diagnostic>.Shared.Rent(capacity); oldBuffer.AsSpan(0, _totalCount).CopyTo(newBuffer); ArrayPool<Diagnostic>.Shared.Return(oldBuffer, clearArray: false); _buffer = newBuffer; } public void Dispose() { if (_disposed) return; if (_buffer is not null) { ArrayPool<Diagnostic>.Shared.Return(_buffer, clearArray: false); _buffer = null; } _disposed = true; }}
Отказ от ветвления (CharRegistry)
CharRegistry использует битовые маски для branchless классификации символов. В G‑кода все символы являются символами ASCII, поэтому, благодаря использованию битовых масок, процессор может провести классификацию за один такт и идеально может предсказывать дальнейший сценарий выполнения. Такой подход позволяет значительно ускорить процесс лексинга за счет избавления от if‑else веток и инструкций switch.
[Flags]internal enum CharMask : ushort{ None = 0, Coordinate = 1 << 0, Command = 1 << 1, Parameter = 1 << 2, Digit = 1 << 3, Exponent = 1 << 4, OpenBracket = 1 << 5, CloseBracket = 1 << 6, Semicolon = 1 << 7, Whitespace = 1 << 8, NewLine = 1 << 9, Label = 1 << 10,}internal static class CharRegistry{ private static readonly ushort[] Table = new ushort[128]; static CharRegistry() { Table['X'] = Table['Y'] = Table['Z'] = (ushort)CharMask.Coordinate; Table['G'] = Table['M'] = (ushort)CharMask.Command; Table['F'] = Table['P'] = Table['I'] = Table['J'] = Table['K'] = Table['R'] = (ushort)CharMask.Parameter; Table['N'] = (ushort)CharMask.Label; for (int i = '0'; i <= '9'; i++) { Table[i] = (ushort)CharMask.Digit; } Table['.'] = Table['-'] = Table['+'] = (ushort)CharMask.Digit; Table['e'] = Table['E'] = (ushort)CharMask.Exponent; Table[';'] = (ushort)CharMask.Semicolon; Table['('] = (ushort)CharMask.OpenBracket; Table[')'] = (ushort)CharMask.CloseBracket; Table['\t'] = Table['\r'] = Table[' '] = (ushort)CharMask.Whitespace; Table['\n'] = (ushort)CharMask.NewLine; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static bool Is(this char c, CharMask mask) { // Быстрая проверка на ASCII (0..127). if (c > 127) return false; // Прямое чтение по адресу. return (Table[c] & (ushort)mask) != 0; } [MethodImpl(MethodImplOptions.AggressiveInlining)] public static CharMask GetMask(this char c) { return c <= 127 ? (CharMask)Table[c] : CharMask.None; }}
Управляемая упаковка данных (StructLayout)
StructLayout.Explicit и StructLayout.Sequential — используются для управления упаковкой данных внутри структур. StructLayout.Explicit дает возможность без раздувания структуры в универсальную мега‑структуру, содержащую все поля явно, упаковать в нее больше полезных данных за счет использования union‑полей. Union‑поля по сути позволяют наложить данные одного поля на данные другого и получаемое значение будет зависеть от того, какое именно поле мы пытаемся прочитать из структуры. StructLayout.Sequential позволяет физически обеспечить безопасность pointer‑арифметики в unmanaged секции, а также явно указать JIT, что поля внутри структуры должны быть упакованы именно так, как они заданы в коде (JIT иногда может менять поля внутри структуры местами для более компактной упаковки). Это гарантирует, что каждый раз в рантайме порядок полей будет одинаковым, что критично, например, для DTO.
[StructLayout(LayoutKind.Explicit, Size = 16)]internal readonly struct Node{ [FieldOffset(0)] public readonly NodeKind Kind; [FieldOffset(1)] public readonly ParameterKind ParameterKind; // union-поле [FieldOffset(1)] public readonly CommandKind CommandKind; // union-поле [FieldOffset(1)] public readonly CoordinateKind CoordinateKind; // union-поле [FieldOffset(2)] public readonly ushort Length; [FieldOffset(4)] public readonly int Start; [FieldOffset(8)] public readonly double Value; public Node(NodeKind kind, CoordinateKind coordinateKind, int start, ushort length, double value) { Kind = kind; CoordinateKind = coordinateKind; Start = start; Length = length; Value = value; } public Node(NodeKind kind, CommandKind commandKind, int start, ushort length, double value) { Kind = kind; CommandKind = commandKind; Start = start; Length = length; Value = value; } public Node(NodeKind kind, ParameterKind parameterKind, int start, ushort length, double value) { Kind = kind; ParameterKind = parameterKind; Start = start; Length = length; Value = value; }}[StructLayout(LayoutKind.Sequential)] // поля идут строго последовательноpublic readonly unsafe struct SimulationFrame{ public readonly MachineFrame MachineFrame; public readonly TrajectoryPoint* Points; public readonly int PointsCount; public readonly byte MotionMode; [MethodImpl(MethodImplOptions.AggressiveInlining)] public SimulationFrame(MachineFrame machineFrame, TrajectoryPoint* points, int pointsCount, byte motionMode) { MachineFrame = machineFrame; Points = points; PointsCount = pointsCount; MotionMode = motionMode; } public readonly ReadOnlySpan<TrajectoryPoint> FramePoints { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => new(Points, PointsCount); }}
Управляемый инлайнинг (MethodImplOptions)
MethodImplOptions.AggressiveInlining и MethodImplOptions.NoInlining — ручное управление инлайнингом и явное разделение Happy Path и Sad Path. Обычный доступ к данным и методы Hot Path встраиваются компилятором явно напрямую в вызывающий код, в то время как вызов генерации тяжелых исключений выносится в отдельный блок, не забивая кэш процессора.
public readonly ref T this[int index]{ [MethodImpl(MethodImplOptions.AggressiveInlining)] get { if ((uint)index >= (uint)_count) { ThrowIndexOutOfRangeException(); } return ref _items[index]; }}[MethodImpl(MethodImplOptions.NoInlining)]private static void ThrowIndexOutOfRangeException() => throw new IndexOutOfRangeException("Index was out of range of the ArenaList.");
Кому будет полезен NG.Velox?
Изначально я задумывал этот проект просто как что‑то интересное лично мне с точки зрения изучения новых инструментов C# и.NET в сфере высокопроизводительного кода, однако на данный момент вижу в библиотеке потенциал для развития и применения в учебных или DIY станках, 3D принтерах, небольших CAD/CAM системах. Библиотека не требует мощного железа и будет работать на практически любой современной системе с поддержкой.NET — от Raspberry PI до промышленных ПК (при должной конфигурации). Также, думаю, студентам, изучающим системы ЧПУ, робототехнику, высокопроизводительное программирование или встраиваемые системы и энтузиастам Zero‑Allocation кода будет интересно изучить подходы и архитектурные решения, которые были использованы при разработке библиотеки. Исходный код открыт и лицензирован под MIT, так что может свободно использоваться и как материал для курсов либо учебных заведений.
Итоги и выводы
На мой субъективный взгляд библиотека вышла довольно крепко сбитая и органичная, использование новейших инструментов оптимизации, подходов из неуправляемых языков и сырых указателей позволили достичь производительности, приближающейся к нативным решениям на С/C++ при 0 байт аллокаций и абсолютном детерминизме. Это доказывает, что на управляемом языке можно построить достойное ядро для системы реального времени, не жертвуя при этом безопасностью и читаемостью, и не отказываясь от ООП и абстракций.
Также стоит упомянуть о компромиссах, на которые пришлось пойти. На самом деле сырые указатели везде — это даже оверкилл для такой системы. Можно было бы использовать обертки Span<T> и ReadOnlySpan<T> более активно и работать с ними, опуская факт проверки выхода за границу массива, но мне было интересно выжать максимум из самой платформы, поэтому пришлось пожертвовать простотой поддержки, читаемостью и безопасностью в угоду производительности и ручному контролю.
Что касается производительности — обработка 400 000 строк в секунду полностью покрывает потребности для учебных и гаражных ЧПУ и 3D принтеров, а дальнейшее развитие библиотеки и внедрение стримингового конвейера позволит обрабатывать бесконечные потоки G‑кода с двойной буферизацией, что позволит использовать библиотеку в полупромышленных либо даже промышленных решениях.
Небольшое послесловие
Статья и так получилась довольно объемной и нагруженной, поэтому я не стал включать в нее подробное описание каждого слоя конвейера. Если будет интерес со стороны сообщества, я подготовлю статью по каждому этапу конвейера и разберу оптимизации, подходы и компромиссы каждого из них.
Если вам интересно разобраться самостоятельно в устройстве конвейера, его отдельных слоев, либо вы заинтересованы помочь в развитии библиотеки — прилагаю ссылку на GitHub проекта, проект лицензирован под MIT, делайте fork, создавайте issue, предлагайте, тестируйте, критикуйте, задавайте вопросы, я всегда открыт к сотрудничеству, в споре рождается истина.
ссылка на оригинал статьи https://habr.com/ru/articles/1067914/