Math.Abs или Mathf.Abs или math.abs или что-то еще?
(Russian translation from English by Maxim Voloshin)
Недавно, читатель спросил какой самый быстрый способ получить модуль числа. И я обнаружил, что существует много способов сделать это в Unity! Сегодня мы попробуем их все и узнаем какой самый быстрый.
Варианты
Сколько всего способов получить модуль числа с плавающей точкой? Давайте посчитаем:
a = System.Math.Math.Abs(f);
a = UnityEngine.Mathf.Abs(f);
a = Unity.Mathematics.math.abs(f);
if (f >= 0) a = f; else a = -f;
a = f >= 0 ? f : -f;
Это довольно много! Варианты получения модуля вручную (#3 и #4) очевидны, поэтому, давайте углубимся в остальные варианты, чтобы увидеть, что именно они делают. Это декомпилированная версия Math.Abs
:
[MethodImpl(4096)] public static extern float Abs(float value);
Она реализована в нативном коде, и след C# кончается. Давайте перейдем к варианту #2 и посмотрим на Mathf.Abs
:
public static float Abs(float f) { return Math.Abs(f); }
Это всего лишь обертка над Math.Abs
! Двигаемся дальше, настала очередь math.abs
:
[MethodImpl(MethodImplOptions.AggressiveInlining)] public static float abs(float x) { return asfloat(asuint(x) & 0x7FFFFFFF); }
Как мы видим это реализовано конвертированием float
в uint
, путем установки бита знака (0x7FFFFFFF
это ноль
и 31 единица
) в ноль, и конвертированием обратно в float
. Чтобы разобрать конвертацию подробнее, посмотрим как работает asuint
:
[MethodImpl(MethodImplOptions.AggressiveInlining)] public static uint asuint(float x) { return (uint)asint(x); }
Просто обертка над asint
:
[MethodImpl(MethodImplOptions.AggressiveInlining)] public static int asint(float x) { IntFloatUnion u; u.intValue = 0; u.floatValue = x; return u.intValue; }
asint
использует объединение(union) чтобы обращаться к памяти через псевдонимы(поля объединения) без изменения битов данных. Мы видели этот прием ранее и даже знаем, что это работает с Burst. Вот как Unity это реализует:
[StructLayout(LayoutKind.Explicit)] internal struct IntFloatUnion { [FieldOffset(0)] public int intValue; [FieldOffset(0)] public float floatValue; }
Это такой же подход, как мы видели ранее: одна и та же память внутри структуры используется для int
и float
, таким образом тип может быть изменен без затрагивания памяти.
Теперь вернемся назад и посмотрим как uint
преобразуется обратно в float
:
[MethodImpl(MethodImplOptions.AggressiveInlining)] public static float asfloat(uint x) { return asfloat((int)x); }
И снова, это всего лишь перегрузка:
[MethodImpl(MethodImplOptions.AggressiveInlining)] public static float asfloat(int x) { IntFloatUnion u; u.floatValue = 0; u.intValue = x; return u.floatValue; }
Здесь происходят обратные преобразования с использованием того же объединения.
Jobs
Далее, напишем несколько задач Burst компилятора, с помощью которых мы сможем протестировать каждый подход, находя модуль числа первого элемента NativeArray
:
[BurstCompile] struct SingleSystemMathAbsJob : IJob { public NativeArray<float> Values; public void Execute() { Values[0] = Math.Abs(Values[0]); } } [BurstCompile] struct SingleUnityEngineMathfAbsJob : IJob { public NativeArray<float> Values; public void Execute() { Values[0] = Mathf.Abs(Values[0]); } } [BurstCompile] struct SingleUnityMathematicsMathAbsJob : IJob { public NativeArray<float> Values; public void Execute() { Values[0] = math.abs(Values[0]); } } [BurstCompile] struct SingleIfJob : IJob { public NativeArray<float> Values; public void Execute() { float val = Values[0]; if (val >= 0) { Values[0] = val; } else { Values[0] = -val; } } } [BurstCompile] struct SingleTernaryJob : IJob { public NativeArray<float> Values; public void Execute() { float val = Values[0]; Values[0] = val >= 0 ? val : -val; } }
Немного позже мы посмотрим на результаты теста производительности, а сейчас в ассемблерный код, который Burst скомпилировал для каждой задачи. Это покажет нам что в действительности выполняет процессор и прольет свет на некоторые детали, скрытые нативным кодом реализации Math.Abs
.
Для начала код, идентичный и для Math.Abs
, и для Mathf.Abs
:
mov rax, qword ptr [rdi] movss xmm0, dword ptr [rax] movabs rcx, offset .LCPI0_0 andps xmm0, xmmword ptr [rcx] movss dword ptr [rax], xmm0 ret
Ключевая строка здесь andps
которая выполняет & 0x7FFFFFFF
, как мы видели в math.abs
для очистки знакового бита.
Теперь посмотрим, что Burst скомпилировал для math.abs
:
mov rax, qword ptr [rdi] movss xmm0, dword ptr [rax] movabs rcx, offset .LCPI0_0 andps xmm0, xmmword ptr [rcx] movss dword ptr [rax], xmm0 ret
Как и ожидалось, здесь также используется andps
. Фактически этот код идентичен коду для Math.Abs
и Mathf.Abs
!
Версия вычисления модуля вручную, основанная на if
:
mov rax, qword ptr [rdi] movss xmm0, dword ptr [rax] xorps xmm1, xmm1 ucomiss xmm1, xmm0 jbe .LBB0_2 movabs rcx, offset .LCPI0_0 xorps xmm0, xmmword ptr [rcx] movss dword ptr [rax], xmm0 .LBB0_2: ret
В этой версии появляется ветвление: jbe
. Оно пропускает запись чего либо в массив в случае если значение положительное. Это улучшение, потому что в таком случае записывается меньше памяти, но ветвление может привести к неправильному прогнозу при загрузке команд процессора в кэш и, следовательно, неэффективному использованию ЦПУ.
И, наконец, ручное вычисление, основанное на тернарном операторе:
mov rax, qword ptr [rdi] movss xmm0, dword ptr [rax] movabs rcx, offset .LCPI0_0 andps xmm0, xmmword ptr [rcx] movss dword ptr [rax], xmm0 ret
Странно, но, эта версия не содержит ветвление как основанная на if
. Вместо этого, она идентична коду для Math.Abs
, Mathf.Abs
, и math.abs
! Каким то образом Burst смог проанализировать и радикально изменить код этой версии, но без использования if
.
Performance
Настало время протестировать все эти подходы, проанализировать производительность и узнать какой из них самый быстрый. Мы сделаем это вычисляя модуль числа для каждого элемента NativeArray
, заполненного случайными значениями:
using System; using System.Diagnostics; using Unity.Burst; using Unity.Collections; using Unity.Jobs; using Unity.Mathematics; using UnityEngine; [BurstCompile] struct SystemMathAbsJob : IJob { public NativeArray<float> Values; public void Execute() { for (int i = 0; i < Values.Length; ++i) { Values[i] = Math.Abs(Values[i]); } } } [BurstCompile] struct UnityEngineMathfAbsJob : IJob { public NativeArray<float> Values; public void Execute() { for (int i = 0; i < Values.Length; ++i) { Values[i] = Mathf.Abs(Values[i]); } } } [BurstCompile] struct UnityMathematicsMathAbsJob : IJob { public NativeArray<float> Values; public void Execute() { for (int i = 0; i < Values.Length; ++i) { Values[i] = math.abs(Values[i]); } } } [BurstCompile] struct IfJob : IJob { public NativeArray<float> Values; public void Execute() { for (int i = 0; i < Values.Length; ++i) { float val = Values[i]; if (val >= 0) { Values[i] = val; } else { Values[i] = -val; } } } } [BurstCompile] struct TernaryJob : IJob { public NativeArray<float> Values; public void Execute() { for (int i = 0; i < Values.Length; ++i) { float val = Values[i]; Values[i] = val >= 0 ? val : -val; } } } class TestScript : MonoBehaviour { void Start() { NativeArray<float> values = new NativeArray<float>( 100000, Allocator.TempJob); for (int i = 0; i < values.Length; ++i) { values[i] = UnityEngine.Random.Range(float.MinValue, float.MaxValue); } SystemMathAbsJob systemMathAbsJob = new SystemMathAbsJob { Values = values }; UnityEngineMathfAbsJob unityEngineMathfAbsJob = new UnityEngineMathfAbsJob { Values = values }; UnityMathematicsMathAbsJob unityMathematicsMathAbsJob = new UnityMathematicsMathAbsJob { Values = values }; IfJob ifJob = new IfJob { Values = values }; TernaryJob ternaryJob = new TernaryJob { Values = values }; // Прогрев job system systemMathAbsJob.Run(); unityEngineMathfAbsJob.Run(); unityMathematicsMathAbsJob.Run(); ifJob.Run(); ternaryJob.Run(); Stopwatch sw = Stopwatch.StartNew(); systemMathAbsJob.Run(); long systemMathAbsTicks = sw.ElapsedTicks; sw.Restart(); unityEngineMathfAbsJob.Run(); long unityEngineMathfAbsTicks = sw.ElapsedTicks; sw.Restart(); unityMathematicsMathAbsJob.Run(); long unityMathematicsMathAbsTicks = sw.ElapsedTicks; sw.Restart(); ifJob.Run(); long ifTicks = sw.ElapsedTicks; sw.Restart(); ternaryJob.Run(); long ternaryTicks = sw.ElapsedTicks; values.Dispose(); print( "Function,Ticksn" + "System.Math.Abs," + systemMathAbsTicks + "n" + "UnityEngine.Mathf.Abs," + unityEngineMathfAbsTicks + "n" + "Unity.Mathematics.Math.Abs," + unityMathematicsMathAbsTicks + "n" + "If," + ifTicks + "n" + "Ternary," + ternaryTicks ); } }
Тестовая машина на которой запускался тест:
- 2.7 Ghz Intel Core i7-6820HQ
- macOS 10.14.6
- Unity 2018.4.3f1
- macOS Standalone
- .NET 4.x scripting runtime version and API compatibility level
- IL2CPP
- Non-development
- 640×480, Fastest, Windowed
Функция | Тики |
---|---|
System.Math.Abs | 253 |
UnityEngine.Mathf.Abs | 198 |
Unity.Mathematics.Math.Abs | 195 |
If | 517 |
Ternary | 1447 |
Производительность оказалась неожиданно разной! Ранее мы видели, что четыре варианта из пяти имеют идентичный ассемблерный код. Однако, мы видим, что только Math.Abs
, Mathf.Abs
, и math.Abs
имеют примерно одинаковую производительность. Версия с тернарным операторам должна иметь такую же производительность, но это не так. Фактически, ее производительность в 10 раз хуже!
Для того чтобы понять, повлиял ли цикл, добавленный в тест, посмотрим ассемблерный код для этих задач в Burst инспекторе.
Math.Abs
, Mathf.Abs
, и math.abs
:
movsxd rax, dword ptr [rdi + 8] test rax, rax jle .LBB0_8 mov rcx, qword ptr [rdi] cmp eax, 8 jae .LBB0_3 xor edx, edx jmp .LBB0_6 .LBB0_3: mov rdx, rax and rdx, -8 lea rsi, [rcx + 16] movabs rdi, offset .LCPI0_0 movaps xmm0, xmmword ptr [rdi] mov rdi, rdx .p2align 4, 0x90 .LBB0_4: movups xmm1, xmmword ptr [rsi - 16] movups xmm2, xmmword ptr [rsi] andps xmm1, xmm0 andps xmm2, xmm0 movups xmmword ptr [rsi - 16], xmm1 movups xmmword ptr [rsi], xmm2 add rsi, 32 add rdi, -8 jne .LBB0_4 cmp rdx, rax je .LBB0_8 .LBB0_6: sub rax, rdx lea rcx, [rcx + 4*rdx] movabs rdx, offset .LCPI0_0 movaps xmm0, xmmword ptr [rdx] .p2align 4, 0x90 .LBB0_7: movss xmm1, dword ptr [rcx] andps xmm1, xmm0 movss dword ptr [rcx], xmm1 add rcx, 4 dec rax jne .LBB0_7 .LBB0_8: ret
If
:
movsxd rax, dword ptr [rdi + 8] test rax, rax jle .LBB0_26 mov r8, qword ptr [rdi] cmp eax, 8 jae .LBB0_3 xor edx, edx jmp .LBB0_22 .LBB0_3: mov rdx, rax and rdx, -8 xorps xmm0, xmm0 movabs rsi, offset .LCPI0_0 movaps xmm1, xmmword ptr [rsi] mov rsi, r8 mov rdi, rdx .p2align 4, 0x90 .LBB0_4: movups xmm3, xmmword ptr [rsi] movups xmm2, xmmword ptr [rsi + 16] movaps xmm4, xmm3 cmpltps xmm4, xmm0 pextrb ecx, xmm4, 0 test cl, 1 jne .LBB0_5 pextrb ecx, xmm4, 4 test cl, 1 jne .LBB0_7 .LBB0_8: pextrb ecx, xmm4, 8 test cl, 1 jne .LBB0_9 .LBB0_10: pextrb ecx, xmm4, 12 test cl, 1 je .LBB0_12 .LBB0_11: shufps xmm3, xmm3, 231 xorps xmm3, xmm1 movss dword ptr [rsi + 12], xmm3 .LBB0_12: movaps xmm3, xmm2 cmpltps xmm3, xmm0 pextrb ecx, xmm3, 0 test cl, 1 jne .LBB0_13 pextrb ecx, xmm3, 4 test cl, 1 jne .LBB0_15 .LBB0_16: pextrb ecx, xmm3, 8 test cl, 1 jne .LBB0_17 .LBB0_18: pextrb ecx, xmm3, 12 test cl, 1 jne .LBB0_19 .LBB0_20: add rsi, 32 add rdi, -8 jne .LBB0_4 jmp .LBB0_21 .p2align 4, 0x90 .LBB0_5: movaps xmm5, xmm3 xorps xmm5, xmm1 movss dword ptr [rsi], xmm5 pextrb ecx, xmm4, 4 test cl, 1 je .LBB0_8 .LBB0_7: movshdup xmm5, xmm3 xorps xmm5, xmm1 movss dword ptr [rsi + 4], xmm5 pextrb ecx, xmm4, 8 test cl, 1 je .LBB0_10 .LBB0_9: movaps xmm5, xmm3 movhlps xmm5, xmm5 xorps xmm5, xmm1 movss dword ptr [rsi + 8], xmm5 pextrb ecx, xmm4, 12 test cl, 1 jne .LBB0_11 jmp .LBB0_12 .p2align 4, 0x90 .LBB0_13: movaps xmm4, xmm2 xorps xmm4, xmm1 movss dword ptr [rsi + 16], xmm4 pextrb ecx, xmm3, 4 test cl, 1 je .LBB0_16 .LBB0_15: movshdup xmm4, xmm2 xorps xmm4, xmm1 movss dword ptr [rsi + 20], xmm4 pextrb ecx, xmm3, 8 test cl, 1 je .LBB0_18 .LBB0_17: movaps xmm4, xmm2 movhlps xmm4, xmm4 xorps xmm4, xmm1 movss dword ptr [rsi + 24], xmm4 pextrb ecx, xmm3, 12 test cl, 1 je .LBB0_20 .LBB0_19: shufps xmm2, xmm2, 231 xorps xmm2, xmm1 movss dword ptr [rsi + 28], xmm2 add rsi, 32 add rdi, -8 jne .LBB0_4 .LBB0_21: cmp rdx, rax je .LBB0_26 .LBB0_22: lea rcx, [r8 + 4*rdx] sub rax, rdx xorps xmm0, xmm0 movabs rdx, offset .LCPI0_0 movaps xmm1, xmmword ptr [rdx] .p2align 4, 0x90 .LBB0_25: movss xmm2, dword ptr [rcx] ucomiss xmm0, xmm2 jbe .LBB0_24 xorps xmm2, xmm1 movss dword ptr [rcx], xmm2 .LBB0_24: add rcx, 4 dec rax jne .LBB0_25 .LBB0_26: ret
Тернарный оператор:
xor eax, eax movabs rcx, offset .LCPI0_0 movaps xmm0, xmmword ptr [rcx] .p2align 4, 0x90 .LBB0_2: mov rcx, qword ptr [rdi] movss xmm1, dword ptr [rcx + 4*rax] andps xmm1, xmm0 movss dword ptr [rcx + 4*rax], xmm1 inc rax movsxd rcx, dword ptr [rdi + 8] cmp rax, rcx jl .LBB0_2 .LBB0_3: ret
Мы видим что код if
значительно длиннее и намного сложнее Math.Abs
, Mathf.Abs
, и math.abs
. Так что не удивительно, что они примерно вдвое быстрее.
Версия для тернарного оператора, с другой стороны, безусловно самая короткая из трех, приведенных выше. Это простой цикл, выполняющий наложение маски. Но почему же он такой медленный? Возможно, это происходит потому что задача выполняется последней, но перенос ее в начало теста не влияет на результаты. Как ни странно, проблема в том, что пропуск записи уже положительных значений, дает побочный эффект. Больше времени тратится на сам цикл и потенциальные промахи в предсказании команд из-за условного перехода и меньше времени на вычисление значений.
Заключение
Когда выполняется только одна операция, Burst компилирует идентичный код для Math.Abs
, Mathf.Abs
, math.abs
и даже тернарного оператора. Когда используется if
, Burst генерирует инструкции условного перехода. Так что в данном случае, все подходы равны кроме основанного на if
, который лучше избежать.
Когда модуль числа находится в цикле, Burst генерирует идентичный машинный код для трех самых быстрых подходов: Math.Abs
, Mathf.Abs
, и math.abs
. Использование if
примерно в 2 раза медленнее, а тернарный оператор в 10 раз. Оба лучше не использовать.
В итоге, лучше всего будет использовать, Math.Abs
, Mathf.Abs
, и math.abs
.