Если в современных реалиях у разработчика возникает необходимость собрать надежное решение для обработки G‑кода, он обычно смотрит в сторону готовых вариантов (GRBL/FluidNC/Linux‑CNC), либо пишет свой кастомный парсер на С/С++ или Rust и для таких проектов вариант реализации на.NET даже не рассматривается ввиду того, что платформа является управляемой и потому недостаточно надежной (заслуженно или нет — разберем далее).
Почему вообще C# и.NET?
У вас может возникнуть закономерный вопрос: зачем вообще изобретать велосипед, если традиционно для высоконагруженных парсеров и детерминированных систем выбирают C или C++?
Для меня аргументов в пользу C# и.NET было несколько:
Современный.NET — это уже далеко не тот инструмент, который был лет 10 назад, новейшие оптимизации и инструменты позволяют писать высокопроизводительный код "из коробки" и выжимать из железа максимум при должной дисциплине разработки.
Выбор связки C# и.NET автоматически открывает доступ гигантской экосистеме: Avalonia UI, ASP.NET, Entity Framework, Microsoft.Extensions. Все это доступно и позволяет без сборки своих велосипедов и подключения сторонних .dll строить высококачественные решения в самой экосистеме.
С# намного безопаснее С и С++ по умолчанию, хотя современный C++ стал намного более дружелюбным и гибким, благодаря RAII и умным указателям, возможностей выстрелить себе в колено в C# меньше и большинство из них строго контролируется компилятором.
Почему связка C# и.NET при наивном использовании является заведомо проигрышной для станков и 3D принтеров?
Стоит начать с того, что самое главное для станков и потоковой обработки команд? Ответ достаточно банален — это предсказуемость поведения, он же детерминизм, то есть выполнение кода за константное время. Станок работает по своим внутренним циклам и поток импульсов для двигателей (либо поток точек траектории) должен быть непрерывен, иначе это гарантированно приведет к рваному движению станка и потенциально может привести к выходу шаговых двигателей из строя от удара, а также к порче детали и потере драгоценного материала и времени, которое уже было потрачено на обработку.
Почему же классический подход C# и.NET с абстракциями, полиморфизмом, виртуальными вызовами и созданиями объектов в куче не позволяет достичь той самой предсказуемости и выполнения за константное время?
На самом деле причина далеко не одна:
Искажение локальности данных.
Прыжки по таблицам виртуальных вызовов.
Сборка мусора.
Фрагментация памяти.
Давайте кратко разберем каждую.
Искажение локальности данных происходит из‑за того, что объекты в куче выделяются не последовательно, а там где сейчас под них есть место и когда мы создаем, например, массив или список элементы в коллекциях являются не самими объектами, а ссылками на них, отчего при итерации по коллекции процессор вынужден прыгать от адреса к адресу и накапливает промахи кэша (cache misses), из‑за чего вынужден простаивать, пока поступят новые порции данных из RAM.
Прыжки по таблицам виртуальных вызовов обусловлены тем, что процессор чисто физически не может прыгнуть на адрес в памяти, так как не знает точную реализацию, ко всему этому добавляется то, что JIT не может подставить код вместо вызова метода, если не удастся выполнить девиртуализацию (компиляция под NativeAOT решает эту проблему, но это тема для отдельного обсуждения), а процессор не может предсказать переходы, из‑за чего конвейер инструкций сбрасывается, вызывая задержки. Задержки не очень велики (обычно это наносекунды), но они вносят свою лепту в непредсказуемость поведения программы.
Сборка мусора и фрагментация памяти связаны друг с другом, потому рассмотрим их вместе. Сборка мусора происходит когда во время исполнения программы заканчивается память в поколениях GC, особенно в Gen0 и Gen1, то есть в областях с короткоживущими объектами. Чтобы выполнить сборку мусора, GC необходимо полностью на время заморозить поток приложения (так называемый Stop‑The‑World), что приводит к задержкам выполнения и как следствие к нарушению потока данных на станок, причем победить это алгоритмически — невозможно, время паузы зависит исключительно от количества объектов живущих в куче, при масштабировании системы это количество будет только расти. Фрагментация данных же это последствие работы GC. Удаление мертвых объектов сборщиком приводит к тому что последовательный блок живых объектов в куче превращается в решето, отчего процессор пытаясь прочитать объекты последовательно будет ловить промахи кэша (GC имеет механизм компактности, который решает эту проблему уплотнением кучи, но это дорогая операция, к тому же если по какой‑то причине ваши объекты живут в Large Object Heap — там этот механизм не применяется вовсе).
Исходя из всего сказанного, можно сделать вывод, что.NET и C# не позволяют создать детерминированное ядро для системы обработки G-кода? Или могут?..
Забегая немного вперед — могут, результаты бенчмарков говорят сами за себя:
Количество строк |
Среднее время (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 позволяют достичь детерминированного поведения?
Стоит сразу оговориться, что если мы рассматриваем вариант разворачивания модуля обработки внутри какой-либо ОС, которая не является RTOS, всегда остается риск того, что система переключит контекст выполнения на другое приложение и из-за этого предсказуемость поведения нарушится, это стоит учитывать.
Однако времена, когда 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# 13 void 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 байт аллокаций и максимально возможном детерминизме, доступном вне RTOS. Это доказывает, что на управляемом языке можно построить достойное ядро для системы обработки G-кода, не жертвуя при этом безопасностью и читаемостью, и не отказываясь от ООП и абстракций.
Также стоит упомянуть о компромиссах, на которые пришлось пойти. На самом деле сырые указатели везде — это даже оверкилл для такой системы. Можно было бы использовать обертки Span<T> и ReadOnlySpan<T> более активно и работать с ними, опуская факт проверки выхода за границу массива, но мне было интересно выжать максимум из самой платформы, поэтому пришлось пожертвовать простотой поддержки, читаемостью и безопасностью в угоду производительности и ручному контролю.
Что касается производительности, обработка 400 000 строк в секунду для учебных и гаражных ЧПУ и 3D принтеров - это абсолютный оверкилл, ни один станок столько просто не переварит физически, но для построения симуляторов и CAD/CAM - это может быть полезно, обработка больших объемов кода со сбором диагностики позволит оперативно сигнализировать пользователю об ошибках, не создавая UI-лаг.
На данный момент библиотека представляет собой неплохой методологический пример построения высокопроизводительных, оптимизированных систем обработки G-кода, но для реальных задач и интеграции с железом требует важной модернизации - внедрения стримингового конвейера и адаптеров для популярных прошивок. Это позволит обрабатывать бесконечные потоки G‑кода с двойной буферизацией и интегрировать библиотеку с проверенными решениями как middleware без лишних манипуляций.
Небольшое послесловие
Статья и так получилась довольно объемной и нагруженной, поэтому я не стал включать в нее подробное описание каждого слоя конвейера. Если будет интерес со стороны сообщества, я подготовлю статью по каждому этапу конвейера и разберу оптимизации, подходы и компромиссы каждого из них.
Если вам интересно разобраться самостоятельно в устройстве конвейера, его отдельных слоев, либо вы заинтересованы помочь в развитии библиотеки — прилагаю ссылку на GitHub проекта, проект лицензирован под MIT, делайте fork, создавайте issue, предлагайте, тестируйте, критикуйте, задавайте вопросы, я всегда открыт к сотрудничеству, в споре рождается истина.
Комментарии (16)

Siemargl
07.08.2026 13:341.Современный.NET — это уже далеко не тот инструмент, который был лет 10 назад, новейшие оптимизации и инструменты позволяют писать высокопроизводительный код из коробки при должной дисциплине разработки.
Улучшили, но все еще отстает. AOT хуже своего же JIT, и хуже современных компиляторов C и основанных на LLVM.
2.Выбор связки C# и.NET автоматически открывает доступ гигантской экосистеме: Avalonia UI, ASP.NET, Entity Framework, Microsoft.Extensions. Все это доступно и позволяет без сборки своих велосипедов строить высококачественные решения.
Эти все библиотеки совсем не для эмбеддед.
3.С# намного безопаснее С и С++ по умолчанию, возможностей выстрелить себе в колено намного меньше и большинство строго контролируется компилятором.
Немного безопаснее чем С++, за счет GC и отказа от сырых указателей. Но поскольку в статье наоборот, свой аллокатор, оптимизации, unsafe указатели и прочие костыли вместо использования встроенных средств языка, аргумент ничтожен.
Итого, проигрывает языкам, заточенным под эмбеддед - C++, Rust, Zig, D.
Ну и результат - библиотека, пригодная только для C# - окружения. Я не представляю, как ее можно вызвать из другого ЯП без побочек.
А в эмбеддед мире .Net MF если еще и жив, то просто так пахнет =)

MachineGate Автор
07.08.2026 13:34Приветствую
Благодарю за развёрнутый фидбэк по статье, и здоровый скепсис)
Но давайте проясним несколько моментов:
Я не предлагаю писать прошивки для микроконтроллеров на .Net, это очевидно очень сомнительное решение, C/C++ прекрасно работают. Ядро обработки предназначено для работы на машине, которая управляет станком, его задача это только парсинг G-кода и получение байткода для станка, который уже в последствии стримится на микроконтроллер.
Про то что Avalonia и т.д. не предназначены для Embeded - очевидно это так, но .Net не существует в вакууме, вы можете использовать это ядро + Avalonia для построения CAD/CAM, сборку под Web Assembly, для онлайн-визуализатора, продвинутое логирование, ASP.NET для веб-интерфейса управления. Главное преимущество в том, что не придётся связывать несколько модулей на разных ЯП и придумывать им интерфейсы для связи.
Про безопасность и костыли - я явно указываю, что сырые указатели и NativeMemory это осознанный выбор, инкапсулированый внутри библиотеки для достижения максимума предсказуемости поведения и контроля, наружный API безопасен, а в С/С++ весь код потенциально опасен. То есть я предлагаю осознанно инкапсулировать небезопасность внутри библиотеки, сохранив остальной код под защитой рантайма.
Про вызов из другого ЯП - что мешает использовать [UnmanagedCallerOnly]? Если будет стоять задача вызвать библиотеку из другого ЯП её можно спокойно адаптировать и скомпилировать в нативную .dll или .so с экспортом функций по стандарту C ABI.

Siemargl
07.08.2026 13:34его задача это только парсинг G-кода и получение байткода для станка
Тогда зачем все эти приседания в сторону реального времени?
в С/С++ весь код потенциально опасен
Для С чаще верно, для С++ чаще нет.
Например, для С придется работать с небезопасными строками в виде char* и массивами в виде int*, в то время как в С++ можно обходиться безопасными string<> и vector<>. В общем случае обобщать С/С++ неверно.
Про вызов из другого ЯП - что мешает использовать [UnmanagedCallerOnly]? Если будет стоять задача вызвать библиотеку из другого ЯП её можно спокойно адаптировать и скомпилировать в нативную .dll или .so с экспортом функций по стандарту C ABI.
Тут нужно учесть, что эта библиотека потянет с собой - GC, или JIT целиком, какие части рантайма (особенно связанные с многопоточностью) и как это будет конкурировать с соответствующими частями вызывающей программы - хозяина.

MachineGate Автор
07.08.2026 13:34Стоит оговориться, что при запуске рантайма внутри ОС это априори будет Soft RT, я об этом не упомянул, каюсь. Но то, зачем нужно RT и детерминизм выполнения в самой статье затрагивается - это необходимо, если мы используем библиотеку как часть middleware на ПК, чтобы при обработке буфер траектории/команд на микроконтроллере внезапно не опустошился из-за GC пауз или аллокаций, поток должен быть предсказуемо плотный.
На счёт С и С++ я и правда немного обобщил, современный С++ намного безопаснее С, но проблема string и vector в том, что они алоцируют память в куче, чтобы избежать фрагментации и промахов по кэшу все равно придётся писать кастомные арены, аллокаторы или использовать что-то по типу паттерна Object Pool. Мой посыл был в другом, в .NET компилятор и рантайм сами пресекают большинство потенциально опасных вещей по типу выхода за границы массива, плюс им можно полностью делегировать управление памятью в Cold Path, а в С++ придётся следить за этим самостоятельно на всех уровнях.
Я не спорю, что вызов из другого ЯП остаётся болью, таскать за собой полноценный Core CLR и JIT это даже не стрельба из пушки по воробьям, это уже артобстрел муравейника, можно попытаться использовать NativeAOT и скомпилировать .dll или .so, как я уже говорил выше, посмотреть что он за собой потянет, но главный вопрос в том: а зачем? Основной тейк моей статьи как раз в том, чтобы не мучиться со связыванием модулей на разных языках и писать на .NET в экосистеме .NET. Если остальная экосистема написана на Rust или C/C++ - пишите на них, моя библиотека вам нужна как собаке пятая нога, это будет архитектурным излишеством.

Siemargl
07.08.2026 13:34априори будет Soft RT
Хоть какое то RT появится, когда все вызываемые алгоритмы будут иметь сложность O(1).
проблема string и vector в том, что они алоцируют память в куче, чтобы избежать фрагментации и промахов по кэшу все равно придётся писать кастомные арены, аллокаторы
О чем и речь, что для C++/Rust/Zig кастомизация аллокаторов для стандартных алгоритмов предусмотрена изначально (в Rust недавно появилась), в C# же такой возможности не было, если я ничего не пропустил.
Ну и если динамическая память смущает недетерминированностью, то опять же, работа со стеком в C# уступает остальным.
Итого из всего полезного для RT в С# есть только GC.TryStartNoGCRegion()
в .NET компилятор и рантайм сами пресекают большинство потенциально опасных вещей по типу выхода за границы массива, плюс им можно полностью делегировать управление памятью в Cold Path, а в С++ придётся следить за этим самостоятельно на всех уровнях
контроль границ и RAII для памяти в С++ тоже есть

MachineGate Автор
07.08.2026 13:34В целом вы правы, детерминизм без алгоритмических гарантий это фикция, но в данном случае масштабируемость от данных близка к линейной (на больших объёмах идёт упор в скорость RAM при копировании данных в слое постпроцессинга), в остальном общий цикл детерминирован и линеен. Для стриминга на МК этого достаточно.
Про кастомные аллокаторы - да, подменить можно, но не везде и не всегда, разные контейнеры могут быть несовместимы с разными аллокаторами. В С# такой возможности и правда нет, но это больше деталь архитектуры, здесь для этого как раз и существуют примитивы низкого уровня типа ArrayPool<T>, Span<T>, stackalloc и т.д. Просто другой подход, легковесные обертки, вместо кастомизации.
Про то что работа со стэком уступает остальным - я бы попросил конкретики, чем именно уступает? В C# есть ref структуры, stackalloc и т.д., которые ещё и жёстко контролируются для безопасности. Если речь о размере стэка или гибкости управления - насколько мне известно, в С++ стэк тоже ограничен и переполнение так же является проблемой.
Я не большой специалист в С++ и конкретно по работе с RAII, но, насколько мне известно, он требует такой же высокой дисциплины в работе как и любой объект с ручной очисткой в .NET, не готов вступать в полемику по этому вопросу, не зная устройства этой системы под капотом. Поправьте меня, если я ошибаюсь.

Siemargl
07.08.2026 13:34на больших объёмах идёт упор в скорость RAM при копировании данных в слое постпроцессинга
Какие еще большие объемы, у вас километры G-кода?
конкретики, чем именно уступает
Ограниченный функционал. Произвольные объекты на стеке в С# создавать нельзя, только ограниченный набор примитивов.
Я не большой специалист в С++ и конкретно по работе с RAII, но, насколько мне известно, он требует такой же высокой дисциплины в работе как и любой объект с ручной очисткой в .NET
Так может пора начинать хотя бы знакомиться! Полностью учить необязательно, но знать концепции очень полезно. Чтобы брать готовые велосипеды, как минимум.
Простые примеры:
При выходе из области видимости сразу автоматически вызывается деструктор твоего объекта в котором очистка (освобождение памяти, закрытие файла итп). Как в блоке c# using().
make_unique<Type> + make_shared<T> реализует ту же семантику только для кучи с подсчетом ссылок.

MachineGate Автор
07.08.2026 13:34На самом деле, вы очень сильно недооцениваете сложные программы обработки. Там могут быть миллионы строк и очень плотный поток точек. Так что речь не о километрах, а о вполне реальных объемах.
Произвольные объекты на стеке в C# создать нельзя, это так, но это осознанное ограничение языка. Для поставленной задачи примитивов вроде ref struct, Span<T> и stackalloc более чем достаточно. В C++ доступно больше возможностей при работе со стеком, но это сопряжено с рисками.
Что касается совета "начать разбираться в C++" - я специализируюсь на C#, и статья написана именно про C#, а точнее про то, что на нем можно эффективно решить задачу, которую классически решают на C/C++. Для микроконтроллеров, где я действительно использую C++, нужны совсем другие, более приземленные подходы. Знать концепции C++ полезно, я не спорю, но требовать от автора статьи по C# глубокого понимания C++ не совсем корректно.
Janycz
Прочитав материал, у меня возникло несколько вопросов. 1) sizeof для unmanaged типов это сейчас нормально? Просто раньше в руководствах писали, что надо использовать Marshal.SizeOf<T>. 2) Как я понимаю, вынести во время компиляции генерацию
Tableнельзя? Разве что использоватьTable = new ushort [] { ... }?MachineGate Автор
Приветствую
Раньше действительно нужно было использовать Marshal.SizeOf<T>, так как его можно было вызвать в safe контексте, а sizeof нет, сейчас можно использовать sizeof. Ещё зависит от задачи: sizeof возвращает размер в оперативной памяти с выравнивание, Marshal.SizeOf<T> возвращает размер после маршалинга, как она будет передаваться в P/Invoke, то есть для внешнего кода и учитывает атрибуты маршалинга.
По поводу таблицы символов - да, вы правы, либо new { ... } и ручками забивать страдая, либо попробовать SourceGenerators, но слабо представляю как это тут применить.