Мой друг 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 за то, что мы сделали с его красивой моделью.)
Автор: PatientZero