Что такое C#? Объектно-ориентированный эсперанто со сборщиком мусора, функциональными примочками и бесплатным массажем после обеда. Он позволяет писать Действительно Важные Вещи, скрывая от нас ненужные детали работы с памятью, процессором и прочее низкоуровневое программирование. Естественно, находятся люди с повышенным уровнем любопытства в крови, желающие знать как же .NET работает на самом деле (само собой, они изучают .NET исключительно ради повышения производительности разрабатываемого софта). Сегодня с нами разговаривают:
- Саша Гольдштейн. Регулярный спикер DotNext, автор “Pro .NET Performance” и много раз MVP. В последнее время ему мало производительности непосредственно языка, и он решил выжать максимум из железа.
- Карлен szKarlen Симонян. Автор atomics.net, начинающий WebKit коммиттер, а также специалист по Just-In-Time Compilation.
Саша Гольдштейн
Добрый день, Саша. Как ты добрался до hardware? Интерес или суровая необходимость?
Я консультант, работаю со многими клиентами. Иногда встречаются ситуации, когда необходимо оптимизировать алгоритм или кусок кода, который иными способами уже не оптимизируется. Ты можешь знать .NET на более чем высоком уровне, но вариантов кроме hardware – нет. При этом, если ты понимаешь, как работает процессор, у тебя в голове есть четкое понимание, как он устроен – ты можешь иначе организовать код: ориентироваться на иные инструкции процессора, оптимальнее использовать кэш. Некоторые примеры, вроде векторизации, я уже приводил на одной из DotNext, о некоторых буду рассказывать на ближайшей.
Так это инструмент последней надежды? Или его можно использовать на постоянной основе?
It depends. Для многих приложений сильная оптимизация не особо нужна, в некоторых ситуациях ты просто не можешь спуститься на нижний уровень. С другой стороны, если у тебя стоят машины с кучей герц на борту, недешевые машины, есть смысл выжимать из них максимум. В моей практике примерно 10 процентов клиентов попадали в ситуации, когда использование подобных оптимизаций было оправдано.
Почему C#? В .NET мире за производительностью ходят к плюсам.
С одной стороны, С++ язык более гибкий, его компиляторы сильнее заточены на оптимизацию железа. С другой стороны, есть цена поддержки С++ кода, ибо не каждый шарпист знает плюсы. Если ты обрабатываешь сигналы\изображения, и ты знаешь плюсы – вперед. В противном случае – увы. Как пример – ребята из stackoverflow будут показывать примеры, когда им нужно достичь максимальной производительности в далеко не самом тривиальном куске их кодовой базы, и вставка туда плюсов не оправдана: нет подходящих спецов, сложность поддержки удваивается. Вводить же в команду спеца С++ – опасно, фактор автобуса никто не отменял.
Каков типичный эффект низкоуровневых оптимизаций?
Само собой, это сильно зависит от ситуации. Векторизация может ускорить алгоритм до 8 раз. Кэш – до десяти раз. Несколько десятков процентов за счет выбора правильных инструкций. В сумме можно ускорить участок кода в сотню раз. Опять-таки, возможно, найдется только один цикл, который можно ускорить векторизацией\параллелизацией. А может, подобных мест будет несколько десятков. Само собой, суммарный эффект десяти оптимизаций будет заметнее (в абсолютных величинах).
Как искать хардварные боттлнеки? Опыт, интуиция, готовый инструмент?
Я не верю в оптимизацию наугад. Intel и AMD сейчас выпускают утилиты для отслеживания поведения железа: для Intel это Amplifier, для AMD – утилиты Catalyst. Ранее они были достаточно примитивными: определяли промахи в кэше, немного помогали с инструкциями, сейчас же они они дают самые разные подсказки, от оптимизации памяти до применения векторизации. От разработчика требуются знания, как воплотить эти подсказки в жизнь, но анализируют утилиты на отлично.
Значит, по гайду для Intel and AMD. Насколько они различаются? Придется ли еще изучать что-то еще?
Для десктопа и серверов в .NET мире достаточно этих двух, гайды почти одинаковые, как и наборы инструкции. Если кто-нибудь еще и выйдет на этот рынок, инструкции, скорее всего, будут практически те же.
Для мобильных же устройств есть ARM, который потребует дополнительного времени на изучение: иной набор инструкций, иная архитектура
Сейчас есть девайсы для майнинга BitCoin, есть CUDA, мелькали слухи про железо для нейросетей. Ждет ли нас эпоха «каждой задаче – своя плата»? Или разнообразие фреймворков останется лишь в JS?
Надеюсь, такого же разнообразия в hardware мы не увидим. Даже сейчас помимо специализированных плат тот же биткоин можно майнить на обычных видеокартах. Тренд, конечно, интересный, но вряд ли он получит сильное развитие. Слишком негибкое решение: производить плату для отдельной задачи. Intel пытается достичь той точки, когда последние процессоры совместимы по цене\качеству с видеокартами
А что насчет компиляторов? У джавистов их много, а у нас?
Ответ на этот вопрос состоит из двух частей. С одной стороны, сейчас у нас есть достаточно шустрый и легко расширяемый компилятор Roslyn, и его достаточно. С другой стороны, с JIT-компиляцией дела обстоят хуже. Взгляни на CoreCLR, очень много возможных оптимизаций не используются: экономится время. Впрочем, проект еще молодой, работа над ним продолжается. В любом случае: если новые компиляторы появятся и они будут способны компилировать код на любой машине, любой платформе – это благо. Если же начнется конкуренция стандартов – экосистема лишь пострадает.
На одной из предыдущих конференций ты упоминал про код, который по-разному выполняется на разных процессорах. Насколько остро эта проблема видна в hardware-оптимизациях? В частности, насколько она важна при работе в облаках?
Это вообще постоянная проблема, во многих областях: память, I/O. Ты тестируешь программу на SSD, в облаке она крутится на более медленном устройстве, и ты получаешь серьезную проблему с производительностью. Если говорить об облаках: ты заказываешь себе те процессоры, которые тебе нужны, которые устраивают тебя по производительности и цене. Возможно, при миграции и возникнут проблемы, но за несколько лет я с таким не сталкивался. В любом случае, вряд ли Microsoft решит поменять все процессоры одного типа за завтрашнее утро. Апдейт идет инкрементально, так что можно рассчитывать на несколько лет миграции, а этого достаточно для подготовки.
Что насчет сложности кода? Сколько времени занимают оптимизации такого уровня?
Если в команде только один человек способен на такое – лучше докупить оборудования. Но обычно подобные вещи можно локализуются в одном «черном ящике»: стандартное приложение отсылает запросы к базе\сервису, (де)сериализует JSON\XML, пишет логи, мест для серьезных оптимизаций немного. Учитывай также, что действительно сложные оптимизации используются редко, обычно применяется простая векторизация. Тут как с коллекциями: чаще всего применяют стандартные списки\массивы\словари, и гораздо реже рассматриваются иные варианты.
Насколько вероятна ситуация, когда вы создаете какое-то решение, прячете его в черный ящик, а потом коллега убивает его? Например, работаете с кэшем, а потом коллега вызывает по 10 потоков на процессор, вызывая многочисленные промахи.
Это возможно. Ваши коллеги должны понимать ограничения вашего черного ящика. Плюс нужны автотесты с регрессом.
Андрей DreamWalker Акиньшин рассказывал, что автотесты производительности – штука опасная и не вполне надежная. Windows обновится, или иной софт на машине с агентом, да и все агенты должны быть сконфигурированы абсолютно одинаково.
Да, это стандартная проблема автотестов с бенчмарками. Обычно можно проигнорировать первый-второй фейлы, но если идут 5 фейлов подряд – уже надо запускать ручной тест.
Насколько полезны знания железа при работе с другими языками (не .NET)?
В своем выступлении я буду рассматривать в основном C#, немного С++. В целом, ответ зависит от языка: чем проще доступ к памяти – тем больше пользы. Тот же C# позволяет работать с указателями, Java, насколько я помню, нет. А в JS подобными вещами заморачиваться вообще бессмысленно. Но в целом эти знания явно будут полезны при работе с серверными языками.
Карлен Симонян
Карлен, ты давно изучаешь внутренности .NET. Это интерес или практическая необходимость? Можешь привести парочку самых эффективных примеров JIT-оптимизации из своей практики?
Изначально это, конечно же, был интерес. Но уже последние 4 года я занимаюсь разработкой многопоточных и распределенных приложений на .NET, что требует знания конкретных возможностей платформы, ее структуры. Нельзя просто заниматься лишь изучением работы GC, например – все очень взаимосвязано. И API фреймворка, и JIT играют немаловажную роль. Кстати, последний напрямую отвечает за работоспособность нашего кода.
Мне кажется, что одним из самых эффективных оптимизаций/возможностей является кооперация самого рантайма с JIT’ом. Так в CLR очень интересно устроена диспетчеризация методов у интерфейсов. Один из блоков в моем докладе как раз и будет посвящен данной теме.
На DotNext 2016 Moscow ты сосредоточишься на RyuJit или расскажешь про JIT-теры в общем?
На конференции мы будем рассматривать и RyuJIT, и обновленный x86. Это обусловлено двумя факторами. Во-первых, рано или поздно 32-битные приложения должны будут уйти со сцены. Во-вторых, RyuJIT – только для 64-битных приложений, да и много сил брошено на его развитие, в том числе порт на ARM. Углубленное изучение поведения x86 компилятора (которое довольно предсказуемо) разумно, но лишь в краткосрочной перспективе. Есть еще legacy x64 JIT, но он менее эффективный, чем x86.
Надеюсь, информация из моего доклада пригодится не только в рамках .NET’a, но и позволит понять некоторые основные концепции в мире управляемых платформ. Акцент будет сделан на кооперации GC и JIT’a, устройстве методов и объектов, а также бенчмарках.
Выбор правильной коллекции или алгоритма позволяет ускорить программу на порядок. Насколько эффективны JIT-оптимизации? Их можно считать регулярным инструментом?
JIT-компилятор постоянно балансирует между эффективность кода и собственной производительностью, т.е. выдаваемый код должен оптимизированным, а сам процесс генерации – быстрым.
Так, многие оптимизации известны из теории и реализуются в C++ компиляторах, попутно перекочевав в RyuJIT. Некоторые могут показаться неожиданными, особенно техника copy-propagation, которая может поломать «плохой» код, работающий без побочных эффектов с x86 JIT’ом, но выявляющийся с legacy x64 и RyuJIT.
С приходом RyuJIT появилась достаточно востребованная фича SIMD. Именно здесь раскрывается один из плюсов Just-in-time compilation: компилятор на лету определяет архитектуру процессора, и код с использованием нового Vectors API преобразуется либо в SSE2, либо в AVX инструкции (или любой др. набор SIMD), в отличие от AOT-компиляции, где требуется указать минимальную архитектуру CPU. Есть варианты, например, «умных» C++ компиляторов, которые поддерживают условную компиляцию участков кода, но это уже «из другой оперы».
Основной императив разработки – управление сложностью. Насколько усложняется код, учитывающий особенности JIT? Насколько усложняется поддержка?
Главная цель любого компилятора – не менять поведение результирующего кода, используя различные оптимизации. И здесь выявляется главная проблема: даже неоптимизированный код может работать по-разному на разных архитектурах. Звучит в духе КО, но это так. Виной всему любовь некоторых CPU к чрезмерному out-of-order выполнению. Сейчас на десктопах, да и на серверах, доминирует архитектура x86-64, которая весьма консервативна в плане перестановок инструкций, когда как на мобильных системах повсюду ARM. Это стоит учитывать, т.к. C# уже обосновался на мобильных устройствах, а код мы пишем на x86-системах, где эмуляторы, соответственно, также потребляют x86-код.
Может показаться, что вся сложность ложится на наши плечи как разработчиков, отчасти это так, но и сам JIT старается выдавать код совместимый с x86-64.
Вообще, на вопросы допустимости перестановок, возможных оптимизаций и их сочетаемости должна отвечать модель памяти (Memory Model). К сожалению, в .NET’е (т.е. реализации CLI) это плохо описано. Вернее, стандарт ECMA335 дает нам определение модели памяти в главе «12.6 Memory model and optimizations», но оно описывает систему со слабой (weak) моделью, а x86 является архитектурой со строгой (strong) моделью, т.е. используется семантика acquire/release по-умолчанию. Начиная с CLR 2.0 модель сама стала ближе к x86, а сам JIT стал генерировать код для ARM и Itanium (который уже не поддерживается) совместимый с ним.
Как видно, данная тема весьма нетривиальна. Ответом на вопрос «Что делать»? является использование готовых примитивов и API, где решаются данные проблемы.
Обычно перед оптимизацией ищут «бутылочное горлышко». Какие подходы и инструменты позволяют искать его на нижних уровнях?
Главным инструментом является профилятор, будь то performance profiler, либо memory.
Я бы выделил два класса проблем, которые чаще всего возникают локально в приложении: неоптимальное использование кэша процессора + работа с невыровненными данными и слишком дорогая синхронизация.
Такое понятие как False Sharing известно давно в узких кругах, но лишь в последнее время набирает обороты. Для уменьшения таких побочных эффектов иногда приходится рефакторить структуры данных, что ведет к общему улучшению отзывчивости системы.
Решение проблемы дорогой синхронизации, на самом деле, является весьма нетривиальным: перейти к lock-free коду непросто. А верифицировать – еще труднее. Еще и закон Амдала никто не отменял.
Если беглый взгляд на код и профилятор не выявляют проблему, то следует опуститься на уровень ниже, т.е. CPU. Здесь на помощь приходят hardware performance counters, т.е. «железные» счетчики. Можно исследовать cache-miss и т.п. Их обычно много. Для чтения можно воспользоваться API операционных систем, но это значит самому создавать такой профилятор. Лучше всего пользоваться готовыми инструментами от самих производителей целевых процессоров.
Бенчмарки. Чем меньше время выполнения – тем больше граблей. Какие инструменты ты используешь для замеров?
Полностью соглашусь с озвученным тезисом. Но перед тем как измерять что-либо, первым делом необходимо определиться с метриками, т.е. что мы будем измерять: потребление памяти, эффективность использования CPU, какие участки можно изменять и т.д. Дело в том, что .NET – мир с GC, который значительно увеличивает энтропию системы. С одной стороны, бенчмарк может показать, что данный участок кода «быстрый». Но это не значит, что эффективный. Возможно потребление памяти весьма большое, что даст о себе знать потом при самой сборке мусора. Поэтому с определения вопроса: «Что нам надо?» – я и начинаю исследование.
Проблема бенчмарков заключается в том, что они измеряют не полную картину реального приложения.
Существует два подхода к построению бенчмарка: использовать встраивание «счетчиков» в код (а-ля подход, которым пользуется профилятор), либо тестировать отдельные куски приложения и/или делать микробенчмаркинг.
Первый – весьма комплексный подход, и лучше всего использовать Performance profiler вместе с Memory Profiler. Результаты могут быть несколько отличаться от реального кода (все-таки код переписывается), но зато показывает полную картину. Такого рода инструментов много – здесь стоит вопрос удобства/предпочтения при их выборе.
Второй путь – также непростой, требующий точного измерения, блока статистики и минимального влияния самого кода бенчмарка на результаты. Писать такой код трудно и муторно. С появлением замечательной библиотеки BenchmarkDotNet это дело стало проще. Ей я и пользуюсь.
В последние пару лет мир .NET стал богаче на компиляторы. Получится ли в этом плане догнать Java? Стоит ли это делать?
Конечно, JIT в HotSpot’e отличный. Но каждая платформа ориентируется на свои сценарии использования. Так в языке Java все методы по умолчанию – виртуальные. Это создает проблему: как оптимально реализовать вызовы методов? На помощь приходит техника динамической деоптимизации, т.е. при отсутствии переопределения метода он считается как non-virtual. Если появится наследник, то JIT перекомпилирует код. В .NET же все JIT компиляторы используют технику перекомпиляции некоторых участков кода (stubs). Очень интересно дело обстоит с интерфейсами, как я уже говорил выше.
Многие возможные оптимизации в HotSpot позволительны, на мой взгляд, из-за более строгого подхода к возможностям среды: .NET и C# более близки к железу и кооперации с нативным кодом, в то время как Java – нет. Например, оптимальным выравниванием полей класса занимаются обе среды, и JVM, и CLR, но последняя позволяет ручное управление, прямо как в C. Для Java появилась аннотация @Contended, но она не эквивалентна полностью StructLayout и FieldOffset атрибутам в .NET. Также более агрессивное использование регистров CPU и более агрессивный inliner, а также C2 (финальный оптимизирующий компилятор) дают JVM несомненный плюс, тогда как CLR JIT следует лишь fastcall соглашению вызовов и меньше инлайнит.
Зато у .NET есть unsafe. Кто-то использует его, а кто-то – нет. Если оптимизаций JIT’a не хватает, то берешь и оптимизируешь руками.
RyuJIT – еще молодой компилятор, но я замечаю в некоторых задачах повышение качества кода (по сравнению с legacy x86 и x64), что не может не радовать.
Насколько получаемый в результате оптимизаций код хрупок? Какова вероятность, что менее опытный коллега сломает его (из лучших побуждений)?
Мне кажется, что на таких оптимизированных участках кода надо оставлять комментарии вроде «Do not touch!». Оптимизации обычно затрагивают строение структур, расположение полей, очередность аргументов в методе и т.д. Что может приводить к плохо читаемому коду. При написании производительного кода, например, LINQ не должен встречаться вообще. Или же вместо него должны использоваться свои методы, которые не аллоцируют столько много памяти, либо везде должны использоваться простые конструкции а-ля циклы for. Новичкам это кажется «некрасивым», и они начинают править код.
Стандарт оптимизаций – алгоритмы\кеш\параллелизация. Как кеш и параллелизация влияют на JIT-оптимизации? Блокируют? Взаимодополняют?
Одна из главных оптимизаций, проводимых современными JIT компиляторами – loop unrolling. Трудно переоценить насколько это помогает branch predictor’у современных CPU. Правда, саму размотку умеет делать legacy x64 JIT, и только при выполнении ряда условий. RyuJIT подтягивается, x86 производит размотку совсем редко. Параллельное выполнение инструкций иногда может сильно ускорить код. Пускай JIT'ы в .NET не самые продвинутые в этом плане, но общая производительность генерируемого кода – на высоте.
Еще приятным бонусом является более агрессивный инлайнинг у RyuJIT, что не может не радовать.
Как JIT-оптимизации ограничивают миграции Win-Linux, Intel-Amd-ARM?
Сами оптимизации не ограничивают. CLR сильно завязан на Win32 API (и CoreCLR – не исключение). Да, у него есть Platform Abstraction Layer, но такие вещи как легковесные блокировки (критические секции), например, были портированы на другие ОС. Еще до недавнего времени Background GC был недоступен для CoreCLR на Linux, но усилиями сообщества патч исправил эту ситуацию.
Если говорить о Intel-Amd, то мне кажется, что больше ориентируются на особенности процессоров Intel. Сейчас активно разрабатывается порт RyuJIT на ARM. Этот процесс до сих пор идет – есть баги с кодогенерацией, но сообщество помогает с их закрытием. Если код однопоточный, то разницу в поведении на ARM’ах трудно заметить. Полностью out-of-order выполнение может дать о себе знать на многопоточном коде. Но и этот вопрос решаем.
Если вы прочитали эти интервью и вам мало — приходите на DotNext 2016 Moscow . Помимо Саши и Карлена про производительность там рассказывают:
- Marco Cecconi: Stack Overflow — It's all about performance!
- Егор Богатов: C++ через C#
- Игорь Чевдарь: Модификация кода .NET в рантайме