Обновить

Пишем Zero‑Allocation конвейер обработки G‑кода на C# (.NET 10): как выжать максимум из железа без GC‑пауз

Уровень сложностиСложный
Время на прочтение19 мин
Охват и читатели7.5K
Всего голосов 9: ↑9 и ↓0+12
Комментарии16

Комментарии 16

Прочитав материал, у меня возникло несколько вопросов. 1) sizeof для unmanaged типов это сейчас нормально? Просто раньше в руководствах писали, что надо использовать Marshal.SizeOf<T>. 2) Как я понимаю, вынести во время компиляции генерацию Tableнельзя? Разве что использовать Table = new ushort [] { ... } ?

Приветствую

Раньше действительно нужно было использовать Marshal.SizeOf<T>, так как его можно было вызвать в safe контексте, а sizeof нет, сейчас можно использовать sizeof. Ещё зависит от задачи: sizeof возвращает размер в оперативной памяти с выравнивание, Marshal.SizeOf<T> возвращает размер после маршалинга, как она будет передаваться в P/Invoke, то есть для внешнего кода и учитывает атрибуты маршалинга.

По поводу таблицы символов - да, вы правы, либо new { ... } и ручками забивать страдая, либо попробовать SourceGenerators, но слабо представляю как это тут применить.

1.Современный.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 если еще и жив, то просто так пахнет =)

Приветствую

Благодарю за развёрнутый фидбэк по статье, и здоровый скепсис)

Но давайте проясним несколько моментов:

Я не предлагаю писать прошивки для микроконтроллеров на .Net, это очевидно очень сомнительное решение, C/C++ прекрасно работают. Ядро обработки предназначено для работы на машине, которая управляет станком, его задача это только парсинг G-кода и получение байткода для станка, который уже в последствии стримится на микроконтроллер.

Про то что Avalonia и т.д. не предназначены для Embeded - очевидно это так, но .Net не существует в вакууме, вы можете использовать это ядро + Avalonia для построения CAD/CAM, сборку под Web Assembly, для онлайн-визуализатора, продвинутое логирование, ASP.NET для веб-интерфейса управления. Главное преимущество в том, что не придётся связывать несколько модулей на разных ЯП и придумывать им интерфейсы для связи.

Про безопасность и костыли - я явно указываю, что сырые указатели и NativeMemory это осознанный выбор, инкапсулированый внутри библиотеки для достижения максимума предсказуемости поведения и контроля, наружный API безопасен, а в С/С++ весь код потенциально опасен. То есть я предлагаю осознанно инкапсулировать небезопасность внутри библиотеки, сохранив остальной код под защитой рантайма.

Про вызов из другого ЯП - что мешает использовать [UnmanagedCallerOnly]? Если будет стоять задача вызвать библиотеку из другого ЯП её можно спокойно адаптировать и скомпилировать в нативную .dll или .so с экспортом функций по стандарту C ABI.

его задача это только парсинг G-кода и получение байткода для станка

Тогда зачем все эти приседания в сторону реального времени?

в С/С++ весь код потенциально опасен

Для С чаще верно, для С++ чаще нет.

Например, для С придется работать с небезопасными строками в виде char* и массивами в виде int*, в то время как в С++ можно обходиться безопасными string<> и vector<>. В общем случае обобщать С/С++ неверно.

Про вызов из другого ЯП - что мешает использовать [UnmanagedCallerOnly]? Если будет стоять задача вызвать библиотеку из другого ЯП её можно спокойно адаптировать и скомпилировать в нативную .dll или .so с экспортом функций по стандарту C ABI.

Тут нужно учесть, что эта библиотека потянет с собой - GC, или JIT целиком, какие части рантайма (особенно связанные с многопоточностью) и как это будет конкурировать с соответствующими частями вызывающей программы - хозяина.

Стоит оговориться, что при запуске рантайма внутри ОС это априори будет Soft RT, я об этом не упомянул, каюсь. Но то, зачем нужно RT и детерминизм выполнения в самой статье затрагивается - это необходимо, если мы используем библиотеку как часть middleware на ПК, чтобы при обработке буфер траектории/команд на микроконтроллере внезапно не опустошился из-за GC пауз или аллокаций, поток должен быть предсказуемо плотный.

На счёт С и С++ я и правда немного обобщил, современный С++ намного безопаснее С, но проблема string и vector в том, что они алоцируют память в куче, чтобы избежать фрагментации и промахов по кэшу все равно придётся писать кастомные арены, аллокаторы или использовать что-то по типу паттерна Object Pool. Мой посыл был в другом, в .NET компилятор и рантайм сами пресекают большинство потенциально опасных вещей по типу выхода за границы массива, плюс им можно полностью делегировать управление памятью в Cold Path, а в С++ придётся следить за этим самостоятельно на всех уровнях.

Я не спорю, что вызов из другого ЯП остаётся болью, таскать за собой полноценный Core CLR и JIT это даже не стрельба из пушки по воробьям, это уже артобстрел муравейника, можно попытаться использовать NativeAOT и скомпилировать .dll или .so, как я уже говорил выше, посмотреть что он за собой потянет, но главный вопрос в том: а зачем? Основной тейк моей статьи как раз в том, чтобы не мучиться со связыванием модулей на разных языках и писать на .NET в экосистеме .NET. Если остальная экосистема написана на Rust или C/C++ - пишите на них, моя библиотека вам нужна как собаке пятая нога, это будет архитектурным излишеством.

априори будет Soft RT

Хоть какое то RT появится, когда все вызываемые алгоритмы будут иметь сложность O(1).

проблема string и vector в том, что они алоцируют память в куче, чтобы избежать фрагментации и промахов по кэшу все равно придётся писать кастомные арены, аллокаторы

О чем и речь, что для C++/Rust/Zig кастомизация аллокаторов для стандартных алгоритмов предусмотрена изначально (в Rust недавно появилась), в C# же такой возможности не было, если я ничего не пропустил.

Ну и если динамическая память смущает недетерминированностью, то опять же, работа со стеком в C# уступает остальным.

Итого из всего полезного для RT в С# есть только GC.TryStartNoGCRegion()

в .NET компилятор и рантайм сами пресекают большинство потенциально опасных вещей по типу выхода за границы массива, плюс им можно полностью делегировать управление памятью в Cold Path, а в С++ придётся следить за этим самостоятельно на всех уровнях

контроль границ и RAII для памяти в С++ тоже есть

В целом вы правы, детерминизм без алгоритмических гарантий это фикция, но в данном случае масштабируемость от данных близка к линейной (на больших объёмах идёт упор в скорость RAM при копировании данных в слое постпроцессинга), в остальном общий цикл детерминирован и линеен. Для стриминга на МК этого достаточно.

Про кастомные аллокаторы - да, подменить можно, но не везде и не всегда, разные контейнеры могут быть несовместимы с разными аллокаторами. В С# такой возможности и правда нет, но это больше деталь архитектуры, здесь для этого как раз и существуют примитивы низкого уровня типа ArrayPool<T>, Span<T>, stackalloc и т.д. Просто другой подход, легковесные обертки, вместо кастомизации.

Про то что работа со стэком уступает остальным - я бы попросил конкретики, чем именно уступает? В C# есть ref структуры, stackalloc и т.д., которые ещё и жёстко контролируются для безопасности. Если речь о размере стэка или гибкости управления - насколько мне известно, в С++ стэк тоже ограничен и переполнение так же является проблемой.

Я не большой специалист в С++ и конкретно по работе с RAII, но, насколько мне известно, он требует такой же высокой дисциплины в работе как и любой объект с ручной очисткой в .NET, не готов вступать в полемику по этому вопросу, не зная устройства этой системы под капотом. Поправьте меня, если я ошибаюсь.

на больших объёмах идёт упор в скорость RAM при копировании данных в слое постпроцессинга

Какие еще большие объемы, у вас километры G-кода?

 конкретики, чем именно уступает

Ограниченный функционал. Произвольные объекты на стеке в С# создавать нельзя, только ограниченный набор примитивов.

Я не большой специалист в С++ и конкретно по работе с RAII, но, насколько мне известно, он требует такой же высокой дисциплины в работе как и любой объект с ручной очисткой в .NET

Так может пора начинать хотя бы знакомиться! Полностью учить необязательно, но знать концепции очень полезно. Чтобы брать готовые велосипеды, как минимум.

Простые примеры:

  1. При выходе из области видимости сразу автоматически вызывается деструктор твоего объекта в котором очистка (освобождение памяти, закрытие файла итп). Как в блоке c# using().

  2. make_unique<Type> + make_shared<T> реализует ту же семантику только для кучи с подсчетом ссылок.

На самом деле, вы очень сильно недооцениваете сложные программы обработки. Там могут быть миллионы строк и очень плотный поток точек. Так что речь не о километрах, а о вполне реальных объемах.

Произвольные объекты на стеке в C# создать нельзя, это так, но это осознанное ограничение языка. Для поставленной задачи примитивов вроде ref struct, Span<T> и stackalloc более чем достаточно. В C++ доступно больше возможностей при работе со стеком, но это сопряжено с рисками.

Что касается совета "начать разбираться в C++" - я специализируюсь на C#, и статья написана именно про C#, а точнее про то, что на нем можно эффективно решить задачу, которую классически решают на C/C++. Для микроконтроллеров, где я действительно использую C++, нужны совсем другие, более приземленные подходы. Знать концепции C++ полезно, я не спорю, но требовать от автора статьи по C# глубокого понимания C++ не совсем корректно.

Я же и не требую ничего.

Ветка началась, что я уточнил, что часть утверждений статьи не 100% верны и выбор C# не лучший для данного применения.

Но гораздо лучше Питона! =)

А вот какой-нибудь оптимизатор G-кода или корректор на шарпе писать было бы гораздо удобнее.

Благодарю вас за фидбэк еще раз, я сделал выводы и учту это при подготовке следующего материала, было интересно обсудить с вами детали 🤝

превратить их в плотный поток байт‑кода с точками траектории для отправки на микроконтроллер.

Для решения такой задачи непонятно, зачем вы вообще хотите сделать реалтайм на ПК. Для этого обычно отгружают реалтайм на микроконтроллер, а на ПК оставляют тяжёлые вычисления, интерфейс, и т.п.

Например, похожую задачу решает проект Klipper, где g-код для 3Д-принтеров обрабатывается на одноплатнике, а импульсы для шаговиков генерируются микроконтроллером. Этот стек используется одними из самых производительных принтеров на сегодняшний день. Дак вот, ПК-часть проекта написана на питоне! То есть при правильном разделении работ, задача успешно решается даже на языке, далеком от реалтайма настолько, насколько это вообще возможно.

Ещё вы делаете много разных оптимизаций, и хотелось бы измерений, как каждая из них влияет на результат, и не являются ли какие-то экономией на спичках (да и сравнение до/после тоже бы не помешало). И как эти выигрыши выглядят на фоне вклада ОС, которая может переключить контекст с вашего процесса на антивирус или учудить что-то подобное.

обработка 400 000 строк в секунду полностью покрывает потребности для учебных и гаражных ЧПУ и 3D принтеров

Звучит впечатляюще, но зачем вам вообще обрабатывать 400000 строк в секунду в реалтайме? Ни один станок или 3Д-принтер столько команд в секунду не выполнит.

Приветствую

По факту библиотека и занимается тем же, чем занимается часть на Python в Klipper: она парсит G-код, планирует траекторию, готовит байткод. Я уже писал выше, что упустил важный нюанс в самой статье - не отметил что под RT подразумевается именно Soft RT, то есть от ОС мы все таки зависим и она действительно может переключить контекст потока на антивирус или задержать выполнение, от этого мы не застрахованы, но сам поток данных должен быть предсказуем. Это то, что библиотека обеспечивает.

На счёт оптимизации - вы абсолютно правы, сравнительный анализ необходим, я обязательно подговлю материал на эту тему со сравнениями каждого подхода с наивной реализацией, где подробно разберу, что даёт лучший прирост производительности и в какой области. Материал достаточно объёмный, не хотелось нагружать им статью ещё больше.

400 000 строк в секунду - это абсолютный оверкилл для станка, вы правы, я упоминал, что слишком увлёкся оптимизациями в целях исследования возможностей платформы, так что это просто пропускная способность библиотеки (она точно не станет бутылочным горлышком). Для реальной задачи с железом идеально подходит алгоритм стримингового конвейера с двойной буферизацией, внедрением которого я сейчас занимаюсь. А вот для CAD/CAM обработка 400 000 строк в секунду как раз полезна, обрабатывать сразу большие объёмы предпочтительнее, чтобы собрать всю диагностику для пользователя одним батчем и показать список ошибок без UI-лага как можно быстрее.

Как вовремя! Я как раз начал писать парсер G-кода, чтобы программно выровнять кривой стол на 3D принтере – физически не получается. Хотел инжектить Z координату в G1 под настроенную коррекцию неровности стола. Думаю, Ваша библиотека идеально подойдет в проект. Спасибо!

Приветствую

Рад что вам моя работа пришлась на злобу дня, для вашего случая скорее нужен патчер G-кода, насколько я понял. Вы можете вывести формулу зависимости Z от XY, выбросить все слои после парсера, кроме постпроцессора (придется немного поменять API слоев, фабрику пайплайна и его конструктор с контекстом) и написать свой постпроцессор, чтобы он пересчитывал Z по вашей формуле, а потом сохранить выхлоп в файл. Если нужна будет помощь - вы можете написать мне по контактам в профиле, постараюсь ответить по возможности.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации