[Перевод] Как мы удвоили скорость работы с Float в Mono

49ac69c73ac3a6c124f04b468dfb90d5.png


Мой друг 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


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

407ecf385de6261087b4395928076e02.png


Просто применив 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 оказывает незначительное влияние.
В процессе этого исследования мы обнаружили, что хотя в компиляторе 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 **. И камера, и сцена находятся рядом с точкой начала координат, и всё выглядит отлично.

4f1702725634faa3ba94aa930230fb6d.png


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

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

8424b065878f52bcd83b5d2de19321e5.png


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

49ac69c73ac3a6c124f04b468dfb90d5.png


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

© Habrahabr.ru