Мой друг Aras недавно написал один и тот же трассировщик лучей на разных языках, в том числе на C++, C# и компиляторе Unity Burst. Разумеется, естественно ожидать, что C# будет медленнее, чем C++, но мне показалось интересным, что Mono настолько медленнее .NET Core.

Опубликованные им показатели были плохими:

  • C# (.NET Core): Mac 17.5 Mray/s,
  • C# (Unity, Mono): Mac 4.6 Mray/s,
  • C# (Unity, IL2CPP): Mac 17.1 Mray/s

Я решил посмотреть, что происходит, и задокументировать места, которые можно улучшить.

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

  • Во-первых, необходимо улучшить параметры Mono по умолчанию, потому что пользователи обычно не настраивают параметры у себя
  • Во-вторых, нам нужно активнее знакомить мир с бекэндом оптимизации кода LLVM в Mono
  • В-третьих, мы улучшили настройку некоторых параметров Mono.

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

Результаты на моём домашнем iMac для Mono и .NET Core были следующими:

Рабочая среда Результаты, MRay/sec
.NET Core 2.1.4, отладочная сборка dotnet run 3.6
.NET Core 2.1.4, релизная сборка dotnet run -c Release 21.7
Ванильный Mono, mono Maths.exe 6.6
Ванильный Mono с LLVM и float32 15.5

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

Рабочая среда Результаты, MRay/sec
Mono с LLVM и float32 15.5
Усовершенствованный Mono с LLVM, float32 и fixed inline 29.6

Общая картина:


Просто применив LLVM и float32, можно почти в 2,3 раза увеличить производительность кода с плавающей запятой. А после настройки, которую мы добавили к Mono в результате этих опытов, можно повысить производительность в 4,4 раза по сравнению со стандартным Mono — эти параметры в будущих версиях Mono станут параметрами по умолчанию.

В этой статье я объясню наши находки.

32-битные и 64-битные Float


Aras использует для основной части вычислений 32-битные числа с плавающей запятой (тип float в C# или System.Single в .NET). В Mono мы давным давно совершили ошибку — все 32-битные вычисления с плавающей запятой выполнялись как 64-битные, а данные всё равно хранились в 32-битных областях.

Сегодня моя память не так остра, как раньше, и я не могу точно вспомнить, почему мы приняли такое решение.

Могу только предположить, что на него повлияли тенденции и идеи того времени.

Тогда вокруг float-вычислений с повышенной точностью витала положительная аура. Например, в процессорах Intel x87 для вычислений с плавающей запятой использовалась 80-битная точность, даже когда операнды были double, что обеспечивало пользователям более точные результаты.

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

На начальных этапах развития Mono большинство математических операций, выполняемых на всех платформах, могло получать на входе только double. В C99, Posix и ISO были добавлены 32-битные версии, но в те дни они не были широко доступны для всей отрасли (например, sinf — это float-версия sin, fabsf — версия fabs, и так далее).

Если говорить вкратце, то начало 2000-х было временем оптимизма.

Приложения платили большую цену за увеличение времени вычислений, но Mono в основном использовался для десктопных приложений Linux, обслуживающих HTTP-страницы и некоторые серверные процессы, поэтому скорость вычислений с плавающей точкой не была той проблемой, с которой мы сталкивались ежедневно. Она становилась заметной только в некоторых научных бенчмарках, а 2003 году они редко разрабатывались на .NET.

Сегодня игры, трёхмерные приложения, обработка изображений, VR, AR и машинное обучение сделали операции с плавающей запятой более распространённым типом данных. Беда не приходит одна, и здесь нет исключений. Float больше не были дружелюбным типом данных, которые использовались в коде всего в паре мест. Они превратились в лавину, от которой никуда не спрятаться. Их стало очень много и их распространение нельзя остановить.

Флаг float32 рабочей среды


Поэтому пару лет назад мы решили добавить поддержку выполнения 32-битных float-операций с помощью 32-битных операций, как и во всех других случаях. Мы назвали эту функцию рабочей среды «float32». В Mono она включается добавлением в рабочей среде опции --O=float32, а в приложениях Xamarin этот параметр изменяется в настройках проекта.

Этот новый флаг хорошо восприняли наши мобильные пользователи, потому что в основном мобильные устройства до сих пор не слишком мощны, и им предпочтительнее обрабатывать данные быстрее, чем иметь повышенную точность. Мы рекомендовали мобильным пользователям одновременно включить оптимизирующий компилятор LLVM и флаг float32.

Хоть этот флаг и реализован уже несколько лет, мы не делали его применяемым по умолчанию, чтобы избежать неприятных сюрпризов для пользователей. Однако мы начали сталкиваться со случаям, в которых сюрпризы возникают из-за стандартного 64-битного поведения, см. этот bug report, отправленный пользователем Unity.

Теперь мы по умолчанию будем использовать в Mono float32, прогресс можно отслеживать здесь: https://github.com/mono/mono/issues/6985.

Тем временем я вернулся к проекту своего друга Aras. Он использовал новые API, которые были добавлены в .NET Core. Хотя .NET Core всегда выполнял 32-битные float-операции как 32-битные float, в процессе своей работы API System.Math всё равно выполняет преобразования из float в double. Например, если вам нужно вычислить функцию синуса для float-значения, то единственным вариантом является вызов Math.Sin (double), при этом придётся выполнить преобразование из float в double.

Чтобы это исправить, в .NET Core был добавлен новый тип System.MathF, в котором содержатся математические операции с плавающей запятой одинарной точности, и сейчас мы только что перенесли этот [System.MathF] в Mono.

Переход от 64-битных к 32-битным float заметно повышает производительность, что можно увидеть из данной таблицы:

Рабочая среда и опции Mrays/second
Mono с System.Math 6.6
Mono с System.Math и -O=float32 8.1
Mono с System.MathF 6.5
Mono с System.MathF и -O=float32 8.2

То есть применение float32 в этом тесте действительно улучшает производительность, а MathF оказывает незначительное влияние.

Настройка LLVM


В процессе этого исследования мы обнаружили, что хотя в компиляторе Fast JIT Mono есть поддержка float32, мы не добавили эту поддержку в бекэнд LLVM. Это означало, что Mono с LLVM по-прежнему выполнял затратные преобразования из float в double.

Поэтому Золтан добавил в движок генерации кода LLVM поддержку float32.

Потом он заметил, что наш встраиватель кода (inliner) использует для Fast JIT те же эвристики, которые использовались для LLVM. При работе с Fast JIT необходимо соблюдать баланс между скоростью JIT и скоростью выполнения, поэтому мы ограничили количество встраиваемого кода, чтобы снизить объём работы движка JIT.

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

Вот как выглядят результаты с изменёнными настройками LLVM:

Рабочая среда и опции Mrays/seconds
Mono с System.Math --llvm -O=float32 16.0
Mono с System.Math --llvm -O=float32, постоянные эвристики 29.1
Mono с System.MathF --llvm -O=float32, постоянные эвристики 29.6

Следующие шаги


Для внесения всех этих усовершенствований достаточно было незначительных усилий. К этим изменениям привели периодические обсуждения в Slack. Мне даже удалось выкроить несколько часов одним вечером, чтобы перенести System.MathF в Mono.

Код трассировщика лучей Aras стал идеальным объектом для изучения, потому что он был самодостаточным, являлся реальным приложением, а не синтетическим бенчмарком. Мы хотим найти другое подобное ПО, которое можно использовать для изучения генерируемого нами двоичного кода, и убедиться, что мы передаём LLVM наилучшие данные для оптимального выполнения его работы.

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

Отдельное примечание


Дополнительная точность имеет приятные побочные эффекты. Например, читая пул-реквесты движка Godot, я увидел, что там ведётся активное обсуждение того, делать ли точность операций с плавающей запятой настраиваемой во время компиляции (https://github.com/godotengine/godot/pull/17134).

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

Хуан объяснил, что в общем случае float работают замечательно, но если вы «отойдёте» от центра, допустим, переместитесь на 100 километров от центра игры, то начинает накапливаться ошибка вычислений, что в результате может привести к интересным графическим глитчам. Можно использовать разные стратегии, чтобы снизить влияние этой проблемы, и одна из них — работа с повышенной точностью, за которую приходится расплачиваться производительностью.

Вскоре после нашего разговора в своей ленте Twitter я увидел пост, демонстрирующий эту проблему: http://pharr.org/matt/blog/2018/03/02/rendering-in-camera-space.html

Проблема показана на изображениях ниже. Здесь мы видим модель спортивного автомобиля из пакета pbrt-v3-scenes **. И камера, и сцена находятся рядом с точкой начала координат, и всё выглядит отлично.


** (Автор модели автомобиля Yasutoshi Mori.)

Потом мы перемещаем камеру и сцену на 200 000 единиц по xx, yy и zz от точки начала координат. Видно, что модель машины стала довольно фрагментарной; это происходит исключительно из-за нехватки точности чисел с плавающей запятой.


Если мы переместимся ещё дальше в 5?5?5 раз, на 1 миллион единиц от точки начала координат, то модель начинает распадаться; машина превращается в чрезвычайно грубую воксельную аппроксимацию самой себя, одновременно интересную и ужасающую. (Keanu задал вопрос: Minecraft такой кубический просто потому, что всё рендерится очень далеко от начала координат?)


** (Приношу извинения Yasutoshi Mori за то, что мы сделали с его красивой моделью.)

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


  1. Nomad1
    06.12.2018 11:30
    -1

    System.MathF, взятый из оупен-сорс проект .Net Core и портированный для Mono, по-честному, надо выкладывать на Github для блага сообщества. А лучше еще и с бенчмарками.


    1. m08pvv
      06.12.2018 11:54

      Вроде всё есть на гитхабе у моно. И в репозитории corefx тоже всё есть.


      1. Nomad1
        06.12.2018 12:03
        -1

        Если присмотреться, то это все заглушки или обертки. Ни один из файлов не содержит, например, 32-битного вычисления тригонометрии (упомянутого Math.Sin, например). В похожей ситуации для проприетарного проекта я решил не подглядывать на код из .Net Core, а писал свою тригонометрию по документам NVidia и другим, чтобы не было конфликта с OSS принципами.


        1. m08pvv
          06.12.2018 12:30

          Вот, например, реализация метода ves_icall_System_MathF_Sin, вполне ожидаемо состоит из вызова sinf из math.h


          1. Nomad1
            06.12.2018 12:34

            см. чуть ниже. Вышла глупая ситуации — код был портирован из .Net Core, а затем заменен на нативные вызовы, где надо и включен в Mono. В статье были упущены ссылки на Pull-реквесты, но они есть в оригинале и значит моя реакция была беспочвенной. Посыпаю голову пеплом, но радуюсь, что можно будет выкинуть доморощенные классы.


            1. Nagg
              07.12.2018 14:37

              .NET Core сам все эти математические функции так же реализует нативно через пинвоки в либс, мы сделали 100% перенос кода из .NET Core Math и MathF в моно


    1. Nomad1
      06.12.2018 12:29

      Как обычно, я не заметил, что это переводная статья, а заодно не проверил оригинал.
      Статья-то от самого Miguel De Icaza, автора Mono. И в оригинале есть ссылка:

      and we have just brought this [System.MathF](https://github.com/mono/mono/pull/7941) to Mono

      Shame on me. (


      1. PatientZero Автор
        06.12.2018 13:12

        В переводе тоже есть эта ссылка.


        1. Nomad1
          06.12.2018 13:13

          Double Shame ;)


      1. hdfan2
        06.12.2018 15:43

        Скорее, shame on Habr, который придумал максимально невразумительный и незаметный способ пометить статью как перевод.