(Russian translation from English by Maxim Voloshin)

Новый компилятор Unity 2019.1 для Job System имеет две опции для увеличения производительности еще сильнее: FloatPrecision и FloatMode. Жертвуя некоторой точностью вычислений, мы имеем возможность увеличить скорость выполнения. Сегодняшняя статья об исследовании и проверке результатов использования этих опций.

Использовать FloatPrecision и FloatMode с Burst очень просто. Достаточно заменить это:

[BurstCompile]
struct MyJob : IJob

На это:

[BurstCompile(FloatPrecision.High, FloatMode.Strict)]
struct MyJob : IJob

Вот как код этих двух перечислений выглядят в Burst 1.0.0:

/// <summary>
/// Представляет собой точность вычислений с плавающей точкой для определенных встроенных операций, синус/косинус и т.д.
/// </summary>
public enum FloatPrecision
{
    /// <summary>
    /// Использует точность по умолчанию, смотри FloatPrecision.Medium
    /// </summary>
    Standard = 0,
    /// <summary>
    /// Вычисления с точностью до 1 ULP - очень точные, но медленнее выполняются, не должны использоваться для большинства целей
    /// </summary>
    High = 1,
    /// <summary>
    /// Вычисления с точностью 3.5 ULP - считаются приемлемыми для большинства задач.
    /// </summary>
    Medium = 2,
    /// <summary>
    /// Зарезервировано на будущее
    /// </summary>
    Low = 3,
}
 
/// <summary>
/// Представляет собой режим оптимизаций компилятора для вычислений с плавающей точкой
/// </summary>
public enum FloatMode
{
    /// <summary>
    /// Использует режим по умолчанию, смотри FloatMode.Strict.
    /// </summary>
    Default = 0,
    /// <summary>
    /// Не выполняется никаких оптимизаций
    /// </summary>
    Strict = 1,
    /// <summary>
    /// Зарезервировано на будущее
    /// </summary>
    Deterministic = 2,
    /// <summary>
    /// Допускает алгебраический эквивалент оптимизации (который может поменять результат вычислений), это подразумевает, что:
    /// <para/> оптимизации могут предполагать, что результаты и аргументы не содержат NaN или +/- бесконечность и не учитывают знак нуля.
    /// <para/> оптимизации могут использовать обратные операции - 1/x * y, вместо y/x.
    /// <para/> оптимизации могут использовать совмещенные инструкции, например, madd.
    /// </summary>
    Fast = 3,
}

Обратите внимание, что каждое перечисление содержит четыре константы, но только две из них имеют какие-то значения. FloatPrecision содержит High и Medium. FloatMode содержит Strict и Fast. Одна из двух других это псевдоним Default и константа зарезервированная на будущее.

Теперь давайте попробуем использовать эти настройки и посмотреть как много производительности мы можем получить от Medium и Fast опций в сравнение с High и Strict. Чтобы сделать это, мы создадим задачу, которая складывает float4 векторы из двух NativeArray и помещает результат в третий. Затем, мы создадим задачу, которая, вместо сложения, находит скалярное произведение. Для каждой из них, мы создадим четыре версии: High и Strict, High и Fast, Medium и Strict, Medium и Fast. Вот как выглядит тестовый скрипт:

using System;
using System.Diagnostics;
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;
using Unity.Mathematics;
using UnityEngine;
 
class TestScript : MonoBehaviour
{
    [BurstCompile(FloatPrecision.High, FloatMode.Strict)]
    struct AddHighStrictJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = A[i] + B[i];
            }
        }
    }
 
    [BurstCompile(FloatPrecision.High, FloatMode.Fast)]
    struct AddHighFastJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = A[i] + B[i];
            }
        }
    }
 
    [BurstCompile(FloatPrecision.Medium, FloatMode.Strict)]
    struct AddMedStrictJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = A[i] + B[i];
            }
        }
    }
 
    [BurstCompile(FloatPrecision.Medium, FloatMode.Fast)]
    struct AddMedFastJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = A[i] + B[i];
            }
        }
    }
 
    [BurstCompile(FloatPrecision.High, FloatMode.Strict)]
    struct DotHighStrictJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = math.dot(A[i], B[i]);
            }
        }
    }
 
    [BurstCompile(FloatPrecision.High, FloatMode.Fast)]
    struct DotHighFastJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = math.dot(A[i], B[i]);
            }
        }
    }
 
    [BurstCompile(FloatPrecision.Medium, FloatMode.Strict)]
    struct DotMedStrictJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = math.dot(A[i], B[i]);
            }
        }
    }
 
    [BurstCompile(FloatPrecision.Medium, FloatMode.Fast)]
    struct DotMedFastJob : IJob
    {
        public NativeArray<float4> A;
        public NativeArray<float4> B;
        public NativeArray<float4> C;
 
        public void Execute()
        {
            for (int i = 0; i < A.Length; ++i)
            {
                C[i] = math.dot(A[i], B[i]);
            }
        }
    }
 
    void Start()
    {
        const int size = 1000000;
        const Allocator alloc = Allocator.TempJob;
        NativeArray<float4> a = new NativeArray<float4>(size, alloc);
        NativeArray<float4> b = new NativeArray<float4>(size, alloc);
        NativeArray<float4> c = new NativeArray<float4>(size, alloc);
        for (int i = 0; i < size; ++i)
        {
            a[i] = float4.zero;
            b[i] = float4.zero;
            c[i] = float4.zero;
        }
 
        AddHighStrictJob ahsj = new AddHighStrictJob { A = a, B = b, C = c };
        AddHighFastJob ahfj = new AddHighFastJob { A = a, B = b, C = c };
        AddMedStrictJob amsj = new AddMedStrictJob { A = a, B = b, C = c };
        AddMedFastJob amfj = new AddMedFastJob { A = a, B = b, C = c };
        DotHighStrictJob dhsj = new DotHighStrictJob { A = a, B = b, C = c };
        DotHighFastJob dhfj = new DotHighFastJob { A = a, B = b, C = c };
        DotMedStrictJob dmsj = new DotMedStrictJob { A = a, B = b, C = c };
        DotMedFastJob dmfj = new DotMedFastJob { A = a, B = b, C = c };
 
        const int reps = 100;
        long[] ahst = new long[reps];
        long[] ahft = new long[reps];
        long[] amst = new long[reps];
        long[] amft = new long[reps];
        long[] dhst = new long[reps];
        long[] dhft = new long[reps];
        long[] dmst = new long[reps];
        long[] dmft = new long[reps];
        Stopwatch sw = new Stopwatch();
        for (int i = 0; i < reps; ++i)
        {
            sw.Restart();
            ahsj.Run();
            ahst[i] = sw.ElapsedTicks;
 
            sw.Restart();
            ahfj.Run();
            ahft[i] = sw.ElapsedTicks;
 
            sw.Restart();
            amsj.Run();
            amst[i] = sw.ElapsedTicks;
 
            sw.Restart();
            amfj.Run();
            amft[i] = sw.ElapsedTicks;
 
            sw.Restart();
            dhsj.Run();
            dhst[i] = sw.ElapsedTicks;
 
            sw.Restart();
            dhfj.Run();
            dhft[i] = sw.ElapsedTicks;
 
            sw.Restart();
            dmsj.Run();
            dmst[i] = sw.ElapsedTicks;
 
            sw.Restart();
            dmfj.Run();
            dmft[i] = sw.ElapsedTicks;
        }
 
        print(
            "Operation,High-Strict,High-Fast,Medium-Strict,Medium-Fastn" +
            "Add,"
                + Median(ahst) + ","
                + Median(ahft) + ","
                + Median(amst) + ","
                + Median(amft) + "n" +
            "Dot," + Median(dhst)
                + "," + Median(dhft)
                + "," + Median(dmst)
                + "," + Median(dmft));
 
        a.Dispose();
        b.Dispose();
        c.Dispose();
 
        Application.Quit();
    }
 
    static long Median(long[] values)
    {
        Array.Sort(values);
        return values[values.Length / 2];
    }
}

Теперь давайте попробуем запустить тестовый скрипт и посмотреть какую производительность мы получим. Я запустил его вот в таком окружении:

  • 2.7 Ghz Intel Core i7-6820HQ
  • macOS 10.14.4
  • Unity 2019.1.0f2
  • macOS Standalone
  • .NET 4.x scripting runtime version и API compatibility level
  • IL2CPP
  • Non-development
  • 640×480, Fastest, Windowed

И вот результаты, которые я получил:

Operation High-Strict High-Fast Medium-Strict Medium-Fast
Add 26920 26790 26520 26510
Dot 28810 29020 29080 29150

График производительности FloatPrecision и FloatMode

Сразу видно, что при использовании разных настроек FloatPrecision и FloatMode ни один из результатов не совпал. График показывает небольшой уклон для сложения и небольшой подъем для скалярного произведения. Если сравнивать самое медленное с самым быстрым, то мы получим выигрыш в производительности 1.5% для сложения и снижение производительности в 1.2% для скалярного произведения.

На данный момент мы можем сделать выводы, что эти настройки могут только незначительно повлиять на результат, который, к тому же, отличается для разных операций: что-то быстрее, а что-то медленнее. Однако, эти выводы неверны. Почему? Давайте посмотрим в Burst Inspector для того, чтобы увидеть ассемблерный код, в который были скомпилированы наши задачи. Вот этот код:

; High-Strict
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
addps    xmm1, xmm0
movups   xmmword ptr [rsi + rdi], xmm1
 
; High-Fast
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
addps    xmm1, xmm0
movups   xmmword ptr [rsi + rdi], xmm1
 
; Medium-Strict
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
addps    xmm1, xmm0
movups   xmmword ptr [rsi + rdi], xmm1
 
; Medium-Fast
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
addps    xmm1, xmm0
movups   xmmword ptr [rsi + rdi], xmm1

И вот как было скомпилировано скалярное произведение:

; High-Strict
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
mulps    xmm1, xmm0
movshdup xmm0, xmm1
addps    xmm1, xmm0
movhlps  xmm0, xmm1
addps    xmm0, xmm1
shufps   xmm0, xmm0, 0
movups   xmmword ptr [rsi + rdi], xmm0
 
; High-Fast
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
mulps    xmm1, xmm0
movshdup xmm0, xmm1
addps    xmm1, xmm0
movhlps  xmm0, xmm1
addps    xmm0, xmm1
shufps   xmm0, xmm0, 0
movups   xmmword ptr [rsi + rdi], xmm0
 
; Medium-Strict
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
mulps    xmm1, xmm0
movshdup xmm0, xmm1
addps    xmm1, xmm0
movhlps  xmm0, xmm1
addps    xmm0, xmm1
shufps   xmm0, xmm0, 0
movups   xmmword ptr [rsi + rdi], xmm0
 
; Medium-Fast
movups   xmm0, xmmword ptr [rcx + rdi]
movups   xmm1, xmmword ptr [rdx + rdi]
mulps    xmm1, xmm0
movshdup xmm0, xmm1
addps    xmm1, xmm0
movhlps  xmm0, xmm1
addps    xmm0, xmm1
shufps   xmm0, xmm0, 0
movups   xmmword ptr [rsi + rdi], xmm0

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

Из всего этого нужно вынести пару уроков. Во-первых, просто посмотреть на результат замеров производительности не достаточно, чтобы окончательно сделать вывод о том, как что-то работает. Числа и график показывают, что производительность увеличивается и уменьшается, но это еще не все. Нам нужно получить одинаковую информацию из нескольких источников, что бы по-настоящему понять как что-то работает. В данном случае, Burst Inspector раскрыл нам глаза и показал, что все, что мы видели в замере производительности есть погрешность измерений.

Во-вторых, FloatPrecision и FloatMode совершенно ни на что не влияют. Я не проверял все 1840 методов класса math и тысячи перегрузок операторов всех типов в Unity.Mathematics, и, возможно, иногда эти настройки будут на что-то влиять. Но для этих конкретных операций float4+float4 и dot(float4, float4) нет никакой разницы. Лучше всего прислушаться к совету выше в коде Вашей собственной игры. Использование Burst Inspector в комбинации с замерами производительности даст более полную картину того, как работает именно ваш код и поможет принять лучшее техническое решение.