image

Это краткое введение в наш новый дата-ориентированный технологический стек (DOTS). Мы поделимся некоторыми инсайтами, помогающими понять, как и почему Unity сегодня стала именно такой, а также расскажем, в каком направлении планируем развиваться. В дальнейшем мы планируем публиковать в блоге Unity новые статьи о DOTS.

Давайте поговорим о C++. Это язык, на котором написана современная Unity.
Одна из наиболее сложных проблем разработчика игр, с которой так или иначе приходится иметь дело, такова: программист должен предоставить исполняемый файл с инструкциями, понятными целевому процессору и, когда процессор выполнит эти инструкции, должна запуститься игра.

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

Мы считаем, что язык C++ не слишком хорош для этой задачи. Например, я хочу, чтобы мой цикл был векторизован, но может найтись миллион причин, по которым компилятору не удастся его векторизовать. Либо сегодня он векторизуется, а завтра – нет, из-за какого-то, казалось бы, пустякового изменения. Сложно даже убедиться, что все мои компиляторы C/C++ вообще станут векторизовывать мой код.

Мы решили разработать наш собственный «вполне удобный способ генерации машинного кода», который отвечал бы всем нашим пожеланиям. Можно было бы потратить массу времени, чтобы немножечко изогнуть всю последовательность проектирования на C++ в нужном нам направлении, но мы решили, что гораздо разумнее инвестировать силы в разработку тулчейна, который бы полностью решал все встающие перед нами проблемы проектирования. Мы же разработали бы его с учетом именно тех задач, которые приходится решать разработчику игр.

Каким же факторам мы уделяем первостепенное внимание?

  • Производительность = правильность. У меня должна быть возможность сказать: «если этот цикл по какой-то причине не векторизуется, то это, должно быть, ошибка компилятора, а не ситуация из разряда «ой, код стал работать всего в восемь раз медленнее, но по-прежнему выдает верные значения, делов-то!».
  • Кросс-платформенность. Входной код, который я пишу, должен оставаться совершенно одинаковым независимо от целевой платформы – будь то iOS или Xbox.
  • У нас должен быть аккуратный цикл итераций, в котором я легко могу просмотреть машинный код, генерируемый для любых архитектур по мере того, как я меняю мой исходный код. «Просмотрщик» машинного кода должен хорошо помогать с обучением/объяснением, когда требуется разобраться, что же делают все эти машинные инструкции.
  • Безопасность. Как правило, разработчики игр не ставят безопасность на высокие позиции в своем списке приоритетов, но мы считаем, что одна из самых классных черт Unity – в ней действительно очень сложно повредить память. Должен быть такой режим, в котором мы запускаем любой код – и однозначно фиксируем ошибку, по которой большими буквами выводится сообщение о том, что же здесь произошло: например, я вышел за границы при считывании/записи или попытался разыменовать нуль.

Итак, разобравшись, что же для нас важно, перейдем к следующему вопросу: на каком языке лучше писать программы, из которых затем будет генерироваться такой машинный код? Допустим, у нас есть следующие варианты:

  • Собственный язык
  • Некоторая адаптация/подмножество C или C++
  • Подмножество C#

Что-что, C#? Для наших внутренних циклов, производительность которых особенно критична? Да. C# — совершенно естественный выбор, с которым в контексте Unity связано множество очень приятных вещей:

  • Это язык, с которым уже сегодня работают наши пользователи
  • В нем отлично оснащена IDE, как для редактирования/рефакторинга, так и для отладки.
  • Уже существует компилятор, преобразующий C# в промежуточный IL (речь о компиляторе Roslyn для C# от Microsoft), и можно попросту воспользоваться им, а не писать собственный. У нас богатый опыт преобразования промежуточного языка в IL, поэтому нам просто выполнять генерацию кода и постпроцессинг конкретной программы.
  • C# лишен многих проблем C++ (ад с включением заголовков, паттерны PIMPL, продолжительное время компиляции)

Мне самому весьма нравится писать код на C#. Однако традиционный C# — не самый лучший язык с точки зрения производительности. Команда разработчиков C#, команды, отвечающие за стандартную библиотеку и среду исполнения за последние пару лет достигли огромного прогресса в этой области. Тем не менее, работая с C#, невозможно контролировать, где именно в памяти размещаются ваши данные. А именно эту проблему нам и необходимо решить для повышения производительности.

Вдобавок, стандартная библиотека этого языка организована вокруг «объектов в куче» и «объектов, имеющих ссылки-указатели на другие объекты».

При этом, работая с фрагментом кода, в котором критична производительность, можно почти полностью обойтись без стандартной библиотеки (прощайте Linq, StringFormatter, List, Dictionary), запретить операции выделения (=никаких классов, только структуры), рефлексию, отключить сборщик мусора и виртуальные вызовы, а также добавить несколько новых контейнеров, которые разрешено использовать (NativeArray и компания). В таком случае, оставшиеся элементы языка C# выглядят уже очень хорошо. Примеры посмотрите в блоге Араса, где он описывает кустарный проект трассировщика путей.

Такое подмножество поможет нам легко справляться со всеми задачами, актуальными при работе с горячими циклами. Поскольку это полноценное подмножество C#, работать с ним можно как с обычным C#. Мы можем получать ошибки, связанные с выходом за границу при попытке доступа, получим отличные сообщения об ошибках, у на будет поддерживаться отладчик, а скорость компиляции будет такая, о которой вы уж и позабыли, работая с C++. Мы часто называем такое подмножество «Высокопрозводительный C#» или HPC#.

Компилятор Burst: что на сегодняшний день?


Мы написали генератор/компилятор кода под названием Burst. Он доступен в версии Unity 2018.1 и выше как пакет в режиме «предпросмотра». С ним еще предстоит много работы, но мы довольны им уже сегодня.

Иногда у нас получается работать быстрее, чем на C++, зачастую – все еще медленнее, чем на C++. Ко второй категории относятся баги производительности, с которыми, убеждены, нам удастся справиться.

Однако простого сравнения производительности недостаточно. Не менее важно, что приходится сделать, чтобы достичь такой производительности. Пример: мы взяли код отсечения (culling) из нашего нынешнего рендерера C++ и портировали его в Burst. Производительность не изменилась, но в версии на C++ нам приходилось заниматься невероятной эквилибристикой, чтобы уговорить наши компиляторы C++ заняться векторизацией. Версия с Burst оказалась примерно вчетверо компактнее.

Честно говоря, вся эта история с «вам следует переписать на C# ваш код, критичный по части производительности» на первый взгляд никому не приглянулась и во внутренней команде Unity. Для большинства из нас это прозвучало как «поближе к железу!», когда работаешь с C++. Но теперь ситуация изменилась. Используя C#, мы полностью контролируем весь процесс от компиляции исходного кода вплоть до генерации машинного кода, а если какая-то деталь нам не нравится – мы просто берем и фиксим ее.

Мы собираемся медленно, но верно портировать весь код, критичный по производительности, с C++ на HPC#. На этом языке проще добиться нужной нам производительности, сложнее написать баг и легче работать.

Вот скриншот инспектора Burst, на котором можно без труда просмотреть, какие инструкции сборки были сгенерированы для ваших различных горячих циклов:

image

У Unity множество разных пользователей. Некоторые могут по памяти перечислить весь набор инструкций arm64, другие просто увлеченно творят, даже не имея PhD по информатике.
Все пользователи выигрывают, когда та ускоряется доля времени кадра, что тратится на выполнение кода движка (обычно это 90%+). Та доля, на которую приходится работа с исполняемым кодом пакета Asset Store, действительно ускоряется, так как авторы пакета Asset Store берут на вооружение HPC#.

Продвинутые пользователи дополнительно выиграют и от того, что сами смогут писать собственный высокопроизводительный код на HPC#.

Точечная оптимизация


В C++ очень сложно заставить компилятор принимать неодинаковые компромиссные решения по оптимизации кода в разных частях вашего проекта. Максимально детальная оптимизация, на которую можно рассчитывать – пофайловое указание уровня оптимизации.

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

Поскольку Burst оперирует лишь относительно небольшой частью программы, мы устанавливаем уровень оптимизации 11. Burst встраивает практически каждое место вызова. Удалите if-проверки, которые в противном случае удалены не будут, так как во встраиваемой форме мы получаем более полную информацию об аргументах функции.

Как это помогает решать распространенные проблемы с многопоточностью


C++ (равно как и C#) не особенно помогают разработчикам писать потокобезопасный код.

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

Гонки данных, недетерминированность и взаимные блокировки – вот основные вызовы, из-за которых так сложно писать многопоточный код. В данном контексте нам нужны фичи из разряда «убедиться, что эта функция и все, что она вызывает, никогда не станут читать или записывать глобальное состояние». Мы хотим, чтобы все нарушения этого правила давали ошибки компилятора, а не оставались «правилами, которых, надеемся, станут придерживаться все программисты». Burst выдает ошибку компиляции.

Мы настоятельно рекомендуем пользователям Unity (и придерживаемся того же в своем кругу) писать код так, чтобы все запланированные в нем преобразования данных подразделялись на задания. Каждое задание «функционально», и, в качестве побочного эффекта – свободно. В нем явно указываются буферы только для чтения и буферы для чтения/записи, с которыми ему приходится работать. Любая попытка обратиться к иным данным вызовет ошибку компиляции.
Планировщик заданий гарантирует, что никто не будет записывать в ваш буфер, предназначенный только для чтения, пока работает ваше задание. А мы гарантируем, что на период выполнения задания никто не будет считывать из вашего буфера, предназначенного для считывания и записи.

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

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

Осваиваем весь стек


Когда мы можем докопаться до всех этих компонентов, мы также можем обеспечить, чтобы им было известно друг о друге. Например, распространенная причина срыва векторизации такова: компилятор не может гарантировать, что два указателя не будут указывать в одну и ту же точку памяти (алиасинг). Мы знаем, что два NativeArray ни в коем случае не будут вот так накладываться друг на друга, поскольку написали библиотеку коллекций, и можем воспользоваться этими знаниями в Burst, поэтому не станем отказываться от оптимизации лишь из опасений, что два указателя могут быть направлены на один и тот же участок памяти.

Аналогично, мы написали математическую библиотеку Unity.Mathematics. Burst она известна «досконально» Burst (в будущем) сможет точечно отказываться от оптимизации в случаях наподобие math.sin(). Поскольку для Burst math.sin() – не просто рядовой метод C#, который нужно скомпилировать, он «поймет» и тригонометрические свойства sin(), поймет, что sin(x) == x для малых значений x (что Burst самостоятельно сможет доказать), поймет, что его можно заменить на разложение в ряд Тейлора, отчасти пожертвовав при этом точностью. В перспективе в Burst также планируется реализовать кроссплатформенность и спроектировать детерминизм с плавающей точкой – мы верим, что такие цели вполне достижимы.

Стираются различия между кодом игрового движка и кодом игры


Когда мы пишем код среды исполнения Unity на HPC#, игровой движок и игра как таковая оказываются написаны на одном и том же языке. Мы можем распределять в виде исходного кода системы времени исполнения, преобразованные нами в HPC#. Каждый сможет учиться на них, улучшать их, адаптировать их под себя. У нас будет игровое поле определенного уровня, и ничто не помешает нашим пользователям написать более качественную систему частиц, игровую физику или рендерер, чем написали мы. Сближая наши внутренние процессы разработки с пользовательскими процессами разработки, мы также сможем лучше почувствовать себя в шкуре пользователя, поэтому бросим все силы на выстраивание единого рабочего потока, а не двух разных.

Комментарии (21)


  1. Tutanhomon
    20.03.2019 18:48

    опыт преобразования промежуточного языка в IL
    шта?


    1. Nagg
      20.03.2019 18:54

      Думаю, небольшая ошибка перевода, в оригинале говорится о том, что у них большой опыт модификации IL.


  1. Goldseeker
    20.03.2019 21:47

    От C# в подходе DOTS остаётся только название. Да, он всё ещё компилируется так же быстро как нормальный С# (что почти сразу нивелируется во время билда: IL2CPP, который сначала конвертит IL код в с++ и делает это очень долго, а потом компилируется уже с++ тоже не быстро), но удобство написания кода страдает невероятно, писать на таком C# муторнее (не сложнее, просто ужасное количество бойлерплейта) чем на С++, я думаю на С++ или вообще С они добились бы такой же производительности при большем удобстве кодирования.


    1. Nagg
      20.03.2019 21:56

      Тем не менее это всё тот же C#, где работают те же IDE, рефакторинги.


      просто ужасное количество бойлерплейта) чем на С++

      У вас есть пример такого бойлерплейта?


      1. anz
        21.03.2019 11:19

        А в С++ IDE этого нет? Черт, ощущение что их отдел маркетинга просто сказал «ребята, если мы скажем что юнити теперь кодируется на С++, наши пользователи просто испугаются и свалят»


        1. Nagg
          21.03.2019 11:22

          ребята, если мы скажем что юнити теперь кодируется на С++, наши пользователи просто испугаются и свалят

          И действительно свалят в таком случае.


        1. Goldseeker
          21.03.2019 15:17

          Свалят, но они и от HPC# свалят, писать в Unity ECS очень трудоемко и неудобно, дебажить ECS очень сложно и так далее и тому подобное. Эта тема взлетит только среди профессиональных разработчиков с большим бюджетом именно на разработку.


          1. Nagg
            21.03.2019 15:37

            Безусловно, но перфоманс-ориентед ECS с вещами типа SoA/Архитипы нигде не будет удобнее и понятнее имхо, тем более в С++.


            1. anz
              21.03.2019 15:42

              Зато все остальное будет удобнее. И, как показывает практика, плюсовый код написанный левой пяткой по умолчанию работает в разы быстрее С#, чего вполне достаточно и без ECS в 90% случаев


              1. Goldseeker
                21.03.2019 18:13
                +1

                Не в этом случае, нет, в HPC# парадигме написать так чтобы тормозило из-за языка сложно и ограничений на то что и как ты пишешь достаточно, чтобы написать специальный компилятор со специфичными оптимизациями, компилятор общего назначения С++ не всегда сможет так соптимайзить.


              1. Nagg
                21.03.2019 18:20

                плюсовый код написанный левой пяткой по умолчанию работает в разы быстрее С#

                довольно спорное утверждение, особенно в контексте HPC#. Пример! :)


                1. anz
                  22.03.2019 15:42
                  -1

                  Не, я имею как раз таки без HPC#. Чисто C# vs C++. Суть в том, что если писать на плюсах обычный код, он будет работать с достаточной производительностью в большинстве случаев, и ECS там не нужен


                  1. Nagg
                    22.03.2019 16:18

                    Эээ не понимаю причем тут ECS. Если вы пишите игру, в которой у вас много объектов и затык в ЦПУ, то абсолютно не важно C# или C++ вам придется брать Data oriented подход и потратить много усилий, никакой компилятор С++ за вас ваш "простой код" не оптимизирует в SoA данные и т.п. А вообще вера в компиляторы плюсов слишком сильная у людей, попробуйте как-нибудь поупражняться на godbolt.org, чуть более сложный код (как в жизни ;-) и оптимизации идут лесом.


            1. Goldseeker
              21.03.2019 18:11

              в с++ за счет шаблонов, не спрятанных от разработчика указателей и ссылок, можно построить более удобный фреймворк, и даже памятью управлять не придется ведь по условию задачи мы не используем динамическую память.


              1. Nagg
                21.03.2019 18:18

                За счет них же и макросов можно и в ногу выстрелить :) ну и опять же — время компиляции отличается в разы. Ребята с юнити еще и дотюнили розлин (инкрементальная компиляция) что там вообще должно в теории на лету компилироваться всё (но просядет немного в llvm opt).


          1. anz
            21.03.2019 15:39

            Имхо тем самым профессиональным разработчикам с бюджетом проще иметь С++ API


  1. Inobelar
    20.03.2019 22:56

    Не смог удержаться )) Статья выглядит как наглядная иллюстрация: "что только не сделаешь, чтоб не писать на c++":


    … почти полностью обойтись без стандартной библиотеки (прощайте Linq, StringFormatter, List, Dictionary), запретить операции выделения (=никаких классов, только структуры), рефлексию, отключить сборщик мусора и виртуальные вызовы, а также добавить несколько новых контейнеров, которые разрешено использовать (NativeArray и компания).



    проверки на границы масивов в рантайме, и нарекания на скорость компиляции — улыбнули )) опять забывают — компиляция продукта производится единожды, но продук запускается "миллионы раз".


    Мы можем получать ошибки, связанные с выходом за границу при попытке доступа, получим отличные сообщения об ошибках, у на будет поддерживаться отладчик, а скорость компиляции будет такая, о которой вы уж и позабыли, работая с C++.

    резюмируя:


    Мне самому весьма нравится писать код на C#. Однако традиционный C# — не самый лучший язык с точки зрения производительности.


    1. Nagg
      20.03.2019 23:40

      компиляция продукта производится единожды, но продук запускается "миллионы раз".

      Правильнее сказать компиляция производится миллион раз, продукт запускается миллион раз. Почитайте https://aras-p.info/blog/2018/12/28/Modern-C-Lamentations/


      Хотя LLVM opt и llc/jit тоже не самые быстрые, но хотя бы убирается долгая фаза сбора плюсов для генерации IR. C# из коробки предоставляет их рантайму (читай llvm) хороший alias analysis для кода, и еще им был необходим контроль над оптимизациями грубо говоря по-методно. Вот этот метод максимально оптимизировать и векторизовать, а вот тут отключить все оптимизации, строгий расчет флотов и т.п.
      Безусловно всё можно было сделать в С++ (насколько понял, ребята даже свой opt pass для векторизации пишут для LLVM IR) но С# — это основной язык в среде Unity, разумнее развивать его, а не предлагать C# юзерам Unity отдельную подсистему писать на плюсах (jobs).


  1. anz
    21.03.2019 11:17

    ну теперь понятно, как это устроено… А то везде говорят «у них магия burst бла бла бла все будет работать быстро!!1».

    Оказывается все просто: компилятору явно говорится что данные не пересекаются и можно векторизовать со спокойной душой. Так ли это сложно в С++, предназначенном для производительности, в статье не описано.

    Мне кажется они пытаются решить неразрешимое противоречие: сделать простой и производительный инструментарий. Фишка в том, что простота всегда накладывает некие ограничения: ты можешь делать поверхностные операции, но не можешь уйти вглубь, иначе это становится сложно. А значит ты не можешь максимально эффективно распорядиться ресурсами, что значит невозможно достичь максимальной производительности.

    В итоге, им приходится делать фактически сложное решение (нет сборщика мусора, везде опасные массивы, нет удобных плюшек C#, черт посмотрите их примеры, они просто ужасают), под эгидой простого решения — "..but it's still C#!!". Честно говоря не верится, что типичное Unity3D сообщество перейдет на эту парадигму. Это сообщество как раз таки и выросло из простоты и удобства инструмента.


    1. Nagg
      21.03.2019 11:31

      Оказывается все просто: компилятору явно говорится что данные не пересекаются и можно векторизовать со спокойной душой. Так ли это сложно в С++, предназначенном для производительности, в статье не описано.

      Пользователь компилятору этого не говорит.


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

      Я не видел исходного кода Burst, но у ребята утверждают что у них полный контроль и предсказуемость над маппингом IL в LLVM IR. Понятно что можно так же держать полный контроль кодгена на С++, но как минимум нельзя сказать что они не могут "уйти вглубь".


      В итоге, им приходится делать фактически сложное решение (нет сборщика мусора, везде опасные массивы, нет удобных плюшек C#, черт посмотрите их примеры, они просто ужасают), под эгидой простого решения — "..but it's still C#!!". Честно говоря не верится, что типичное Unity3D сообщество перейдет на эту парадигму. Это сообщество как раз таки и выросло из простоты и удобства инструмента.

      Мне примеры не кажутся страшными — там вроде даже указатели не используются.
      То, что это C# позволит шарить между скриптинговой средой и высокопроизводительными джобами юзерский ECS код. И все не-гцшные фичи языка можно свободно использовать.


      1. Goldseeker
        21.03.2019 18:38

        Мне примеры не кажутся страшными — там вроде даже указатели не используются.
        То, что это C# позволит шарить между скриптинговой средой и высокопроизводительными джобами юзерский ECS код. И все не-гцшные фичи языка можно свободно использовать.


        А вы пробовали? Обычно хватает небольшого тестового проекта, чтобы осознать, что писать так как предлагается это страдание.