(Russian translation from English by Maxim Voloshin)

Недавно, читатель спросил какой самый быстрый способ получить модуль числа. И я обнаружил, что существует много способов сделать это в Unity! Сегодня мы попробуем их все и узнаем какой самый быстрый.

Варианты

Сколько всего способов получить модуль числа с плавающей точкой? Давайте посчитаем:

  1. a = System.Math.Math.Abs(f);
  2. a = UnityEngine.Mathf.Abs(f);
  3. a = Unity.Mathematics.math.abs(f);
  4. if (f >= 0) a = f; else a = -f;
  5. 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

Absolute Value Performance Graph

Производительность оказалась неожиданно разной! Ранее мы видели, что четыре варианта из пяти имеют идентичный ассемблерный код. Однако, мы видим, что только 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.