[Перевод] Аппаратные «характеристики» в .NET Core (теперь не только SIMD)

Введение

Несколько лет назад, мы решили, что настало время поддержать SIMD код в .NET. Мы представили пространство имен System.Numerics с типами Vector2, Vector3, Vector4 и Vector. Эти типы представляют API общего назначения для создания, доступа и оперирования векторными инструкциями, когда это возможно. Они, так же, обеспечивают программную совместимость для тех случаев, где аппаратное обеспечение не поддерживает подходящих инструкций. Это позволило, с минимальным рефакторингом, векторизовать ряд алгоритмов. Как бы там ни было, общность такого подхода делает его сложным в применении с целью получения полного преимущество от всех доступных, на современном аппаратном обеспечении, векторных инструкций. В дополнении, современное аппаратное обеспечение предоставляет ряд специализированных, не векторных, инструкций, которые могут значительно улучшать производительность. В этой статье я расскажу, как мы обошли эти ограничения в .NET Core 3.0.

4dmxlt8xgnpgncvellsujvoe_rk.jpeg
Примечание: пока ещё нет устоявшегося термина для перевода Intrisics. В конце статьи есть голосовалка за вариант перевода. Если выберем хороший вариант, статью изменим


Что такое аппаратные характеристики

В .NET Core 3.0 мы добавили новую функциональность под названием аппаратные характеристики (Термин 'характеристики' был выбран в качестве перевода слова 'intrinsics' по причине того, что он наиболее точно передает смысл: 'Характеристика' — краткое, но верное описание главных отличительных признаков, свойств чего-либо. Т.к. набор аппаратно-зависимых функции отличает одну платформу от другой, то, мне кажется, логично использовать данный термин при описании наборов функций, посредством которых код получает доступ к инструкциям аппаратного обеспечения.). Эти характеристики обеспечивают доступ ко многим специфичным инструкциям аппаратного обеспечения, которые не могут быть просто представлены механизмами более общего назначения. Они отличаются от существующих SIMD-инструкций тем, что они не имеют общего назначения (новые характеристики не являются кросс-платформенными и их архитектура не обеспечивает программной совместимости). Вместо этого, они напрямую обеспечивают платформенно и аппаратно-зависимую функциональность для разработчиков .NET. Существующие SIMD-функции, к примеру, кросс-платформенные, обеспечивают программную совместимости, и они слегка абстрагированы от лежащего под ними аппаратного обеспечения. Эта абстракция может дорого стоить, к тому же, она может препятствовать раскрытию некоторой функциональности (когда, например, функциональность не существует, или сложно эмулируема на всех целевых платформах).

Новые характеристики, и поддерживаемые типы, расположены под пространством имен System.Runtime.Intrinsics. Для .NET Core 3.0, в настоящий момент, существует одно пространство имен System.Runtime.Intrinsics.X86. Мы работаем над поддержкой характеристик для других платформ, таких как System.Runtime.Intrinsics.Arm.

Под платформо-специфичными пространствами имён характеристики группируются в классы, которые представляют группы логически объединённых инструкций аппаратного обеспечения (часто называемые архитектурой набора команд (ISA)). Каждый класс предоставляет свойство IsSupported указывающее поддерживает ли аппаратное обеспечение, на котором выполняется код, этот набор инструкций. Далее, каждый такой класс содержит набор методов сопоставляемых с соответствующим набором инструкций. Иногда существует дополнительный подкласс, который соответствует части того же набора команд, которая может ограничиваться (поддерживаться) специфичным аппаратным обеспечением. Например, класс Lzcnt обеспечивает доступ к инструкциям подсчёта ведущих нулей. У него существует подкласс, именуемый X64, который содержит форму этих инструкций используемых только на машинах с 64-битной архитектурой.

Некоторые из этих классов имеют естественную иерархическую природу. Например, если Lzcnt.X64.IsSupported возвращает true, тогда Lzcnt.IsSupported также должны вернуть true, так как это явный подкласс. Или, например, если Sse2.IsSupported возвращает true, то и Sse.IsSupported должен вернуть true, потому что Sse2 явным образом наследуется от Sse. Однако, стоит отметить, что схожесть имён классов не является показателем принадлежности их к одной иерархии наследования. Например, Bmi2 не наследуются от Bmi1, поэтому значения возвращаемые IsSupported для этих двух наборов инструкций будут отличаться. Основополагающим принципом при разработке этих классов было явное представление ISA-спецификаций. SSE2 требует поддержки SSE1, поэтому классы, представляющие их, связаны наследованием. В тоже время, BMI2 не требует поддержки BMI1, поэтому мы не использовали наследования. Далее представлен пример описанного выше API.

namespace System.Runtime.Intrinsics.X86
{
    public abstract class Sse
    {
        public static bool IsSupported { get; }

        public static Vector128 Add(Vector128 left, Vector128 right);

        // Additional APIs

        public abstract class X64
        {
            public static bool IsSupported { get; }

            public static long ConvertToInt64(Vector128 value);

            // Additional APIs
        }
    }

    public abstract class Sse2 : Sse
    {
        public static new bool IsSupported { get; }

        public static Vector128 Add(Vector128 left, Vector128 right);

        // Additional APIs

        public new abstract class X64 : Sse.X64
        {
            public static bool IsSupported { get; }

            public static long ConvertToInt64(Vector128 value);

            // Additional APIs
        }
    }
}

Вы можете увидеть больше в исходном коде по следующим ссылкам source.dot.net or dotnet/coreclr on GitHub

Проверки IsSupported обрабатываются JIT-компилятором как константы времени выполнения (когда включена оптимизация), поэтому вам не нужна кросс-компиляция для поддержки нескольких ISA, платформ или архитектур. Вместо этого вам достаточно написать код используя if-выражения, в результате чего неиспользуемые ветки кода (т.е. те ветки, которые не достижимы, вследствие значения переменной в условном операторе) будут отброшены при генерации нативного кода.

Важно чтобы проверка соответствующего IsSupported предшествовала использованию встроенных команд аппаратного обеспечения. Если такой проверки нет, то код, использующий платформенно-зависимые команды, запущенный на платформах/архитектурах где эти команды не поддерживаются, сгенерирует исключение времени выполнения PlatformNotSupportedException.


Какие преимущества они дают?

Конечно аппаратные характеристики не для всех, но они могут быть использованы для повышения производительности в нагруженных вычислениями операциях. Фреймворки CoreFX и ML.NET используют эти методы для ускорения таких операций как копирование в памяти, поиск индекса элемента в массиве или строке, изменение размера изображения, или работа с векторами/матрицами/тензорами. Ручная векторизация некоторого кода, который оказался «бутылочным горлышком», также может быть проще чем кажется. Векторизация кода, в действительности, это выполнения нескольких операций за раз, в общем случае, используя SIMD-инструкции (один поток команд, множественный поток данных).

Перед тем как вы решите проводить векторизацию некоторого кода, необходимо провести профилирование чтобы убедиться, что этот код действительно является частью «горячей точки» (и, поэтому, ваша оптимизация даст существенный прирост производительности). Также важно проводить профилирование на каждом этапе векторизации, так как векторизация не всякого кода приводит к повышению производительности.


Векторизация простого алгоритма

Для иллюстрации использования характеристик возьмём алгоритм суммирования всех элементов массива или диапазона. Такого рода код — это идеальный кандидат на векторизацию, т.к. на каждой итерации выполняется одна и таже тривиальная операция.

Пример реализации такого алгоритма может выглядеть следующим образом:

public int Sum(ReadOnlySpan source)
{
    int result = 0;

    for (int i = 0; i < source.Length; i++)
    {
        result += source[i];
    }

    return result;
}

Этот код достаточно прост и понятен, но, в тоже время, достаточно медленный для входных данных большого размера, т.к. делает только одну тривиальную операцию за итерацию.

BenchmarkDotNet=v0.11.5, OS=Windows 10.0.18362
AMD Ryzen 7 1800X, 1 CPU, 16 logical and 8 physical cores
.NET Core SDK=3.0.100-preview9-013775
  [Host]     : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT  [AttachedDebugger]
  DefaultJob : .NET Core 3.0.0-preview9-19410-10 (CoreCLR 4.700.19.40902, CoreFX 4.700.19.40917), 64bit RyuJIT


Повышение производительности за счет развертывания циклов

Современные процессоры имеют различные варианты повышения производительности кода. Для однопоточных приложений, одним из таких вариантов выполнение нескольких примитивных операций за один такт процессора.

Большинство современных процессоров могут выполнять четыре операции сложения за один такт (при оптимальных условиях), в следствии чего, при правильной «раскладке» кода, вы можете, иногда, улучшить производительность, даже в однопоточной реализации.

Хотя JIT может выполнить развертку циклов самостоятельно, JIT консервативен при принятии такого рода решения, из-за размеров генерируемого кода. Поэтому, может быть выгодно развернуть цикл, в коде, вручную.

Вы можете развернуть цикл в показанном выше коде следующим образом:

public unsafe int SumUnrolled(ReadOnlySpan source)
{
    int result = 0;

    int i = 0;
    int lastBlockIndex = source.Length - (source.Length % 4);

    // Pin source so we can elide the bounds checks
    fixed (int* pSource = source)
    {
        while (i < lastBlockIndex)
        {
            result += pSource[i + 0];
            result += pSource[i + 1];
            result += pSource[i + 2];
            result += pSource[i + 3];

            i += 4;
        }

        while (i < source.Length)
        {
            result += pSource[i];
            i += 1;
        }
    }

    return result;
}

Этот код несколько сложнее, но он лучше использует возможности аппаратного обеспечения.

Для действительно маленьких циклов, этот код выполняется немного медленнее. Но эта тенденция меняется уже для входных данных из восьми элементов, после чего скорость выполнения начинает расти (время выполнения оптимизированного кода, для 32 тыс. элементов, на 26% меньше чем время исходного варианта). Стоит отметить, что такая оптимизация не всегда повышает производительность. К примеру, при работе с коллекциями с элементами типа float «развернутая» версия алгоритма имеет практически ту же скорость, что и исходная. Поэтому очень важно проводить профилирование.

4b71807758bdae4be2fba1eb6af8360b.png


Повышение производительности за счет векторизации циклов

Как бы то ни было, но мы можем еще чуть-чуть оптимизировать этот код. SIMD-инструкции -это еще одни вариант, который предоставляется со стороны современных процессоров для повышения производительности. Используя одну инструкцию, они позволяют вам выполнить несколько операций за один такт. Это может быть лучше прямого разворачивания циклов, потому что, по сути, делается тоже самое, но с меньшим объемом генерируемого кода.

Уточним, каждая операция сложения, в развернутом цикле, занимает 4 байта. Таким образом нам требуется 16 байт для 4-х операций сложения в развернутой форме. В то же время, SIMD-инструкция для сложения так же выполняет 4 операции сложения, но занимает лишь 4 байта. Это значит, что мы имеем меньшее количество инструкций для ЦП. В дополнении к этому, в случае SIMD-инструкции, ЦП может сделать предположения и выполнить оптимизацию, но это выходит за рамки данной статьи. Что еще лучше, так это то, что современные процессоры могут выполнить за раз более чем одну SIMD-инструкцию, т.е., в некоторых случаях, вы можете применить смешенную стратегию, одновременно выполнить частичную развертку цикла и векторизацию.

В общем случае, стартовать надо с рассмотрения класса общего назначения Vector под ваши задачи. Он, как и новые аппаратные характеристики, будет встраивать SIMD-инструкции, но при этом, учитывая универсальность этого класса, он позволяет сократить количество «ручного» кодирования.

Код может выглядеть так:

public int SumVectorT(ReadOnlySpan source)
{
    int result = 0;

    Vector vresult = Vector.Zero;

    int i = 0;
    int lastBlockIndex = source.Length - (source.Length % Vector.Count);

    while (i < lastBlockIndex)
    {
        vresult += new Vector(source.Slice(i));
        i += Vector.Count;
    }

    for (int n = 0; n < Vector.Count; n++)
    {
        result += vresult[n];
    }

    while (i < source.Length)
    {
        result += source[i];
        i += 1;
    }

    return result;
}

Этот код работает быстрее, но мы вынуждены обращаться к каждому элементу отдельно при расчете конечной суммы. Так же Vector не имеет точно определенного размера, и может варьироваться, в зависимости от оборудования, на котором выполняется код. Аппаратные характеристики предоставляют дополнительную функциональность, которая может слегка улучшить этот код и сделать немного быстрее (ценой будут дополнительная сложность кода и требования к обслуживанию).

00e0be5d469067af095f34beccf8c45c.png

ЗАМЕЧАНИЕ Для этой стати я принудительно сделал размер Vector равным 16 байтам, используя параметр внутренней конфигурации (COMPlus_SIMD16ByteOnly=1). Эта подстройка нормализовала результаты при сравнении SumVectorT с SumVectorizedSse, и позволила сохранить простоту кода последнего. В частности, позволило избежать написания условного перехода if (Avx2.IsSupported) { }. Этот код, почти идентичен коду для Sse2, но имеет дело с Vector256 (32-байтным) и обрабатывает еще больше элементов за одну итерацию цикла.

Таким образом, воспользовавшись новыми аппаратными характеристиками код можно переписать следующим образом:

public int SumVectorized(ReadOnlySpan source)
{
    if (Sse2.IsSupported)
    {
        return SumVectorizedSse2(source);
    }
    else
    {
        return SumVectorT(source);
    }
}

public unsafe int SumVectorizedSse2(ReadOnlySpan source)
{
    int result;

    fixed (int* pSource = source)
    {
        Vector128 vresult = Vector128.Zero;

        int i = 0;
        int lastBlockIndex = source.Length - (source.Length % 4);

        while (i < lastBlockIndex)
        {
            vresult = Sse2.Add(vresult, Sse2.LoadVector128(pSource + i));
            i += 4;
        }

        if (Ssse3.IsSupported)
        {
            vresult = Ssse3.HorizontalAdd(vresult, vresult);
            vresult = Ssse3.HorizontalAdd(vresult, vresult);
        }
        else
        {
            vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0x4E));
            vresult = Sse2.Add(vresult, Sse2.Shuffle(vresult, 0xB1));
        }
        result = vresult.ToScalar();

        while (i < source.Length)
        {
            result += pSource[i];
            i += 1;
        }
    }

    return result;
}

Этот код, опять таки, немного сложнее, но он значительно быстрее для все, кроме самых маленьких, входных наборов. Для 32 тыс. элементов, этот код выполняется на 75% быстрее чем развернутый цикл, и на 81% быстрее чем исходный код примера.

Вы заметили, что мы написали несколько проверок IsSupported. Первая проверяет, поддерживает ли текущее аппаратное обеспечение требуемый набор характеристик, если нет, то выполняется оптимизация через комбинацию развертки и Vector. Последний вариант будет выбран для таких платформ как ARM/ARM64, которые не поддерживают требуемого набора инструкций, или же если для платформы этот набор был отключен. Вторая проверка IsSupported, в методе SumVectorizedSse2, используется для дополнительной оптимизации, если аппаратное обеспечение поддерживает и набор инструкций Ssse3.

В остальном, большая часть логики, по сути, такая же как и для развернутого цикла. Vector128 — это 128-битный тип, содержащий Vector128.Count элементов. В данном случае, uint, который сам 32-битный, может иметь 4 (128 / 32) элемента, именно так мы развернули цикл.

311e7032050337fe946103244e749076.png


Заключение

Новые аппаратные характеристики дают вам возможность воспользоваться платформенно-специфичной функциональностью машины на который вы запускаете код. Существует примерно 1500 API для X86 и X64 распределенных по 15 наборам, их слишком много для того чтобы описать в одной статье. Профилируя код для определения узким мест вы можете определить ту часть кода, которая выиграет от векторизации и наблюдайте за довольно неплохим приростом производительности. Существует множество сценариев где векторизация может быть применена и развертка циклов это лишь начало.

Все кто хочет увидеть больше примеров может поискать использования характеристик в фреймворке (смотрите dotnet и aspnet), или в других статьях сообщества. И хотя существующие в настоящее время характеристики обширны, все еще есть много функционала, который должен быть представлен. Если у вас есть функциональность, которую вы хотите представить, не стесняйтесь зарегистрировать запрос на API через dotnet/corefx on GitHub. Процес рассмотрения API описан тут и есть хороший пример шаблона запроса на API указанного в шаге 1.


Особые благодарности

Хочу выразить особую благодарность членам нашего сообщества Fei Peng (@fiigii) и Jacek Blaszczynski (@4creators) за помощь в реализации аппаратных характеристик, а так же всем членам сообщества за ценную обратную связь в отношении разработки, реализации и удобства использования этой функциональности.



Послесловие к переводу

*Мне нравится наблюдать за развитием платформы .NET, и, в частности, языка C#. Придя из мира C++, и имея небольшой опыт разработки на Delphi и Java, мне было очень комфортно начать писать программы на C#. В 2006 году этот язык программирования (именно язык) мне показался более лаконичным и практичным, чем Java, в мире управляемой сборки мусора и кросплатформенности. Поэтому мой выбор пал на С#, и я не пожалел. Первый этап эволюции языка было просто его появление. К 2006 году С# впитал в себя все лучшее, что было на тот момент в лучших языках и платформах: C++/Java/Delphi. В 2010-м, в широкий доступ вышел F#. Он был экспериментальной площадкой для изучения функциональной парадигмы с целью внедрения ее в мир .NET. Результатом экспериментов стал следующий этап в эволюции C# — расширение его возможностей в сторону ФП, за счет введения анонимных функции, лямбда выражений, и, в конечном итоге, LINQ. Такое расширение языка сделало C# самым продвинутым, с моей точки зрения, языком общего назначения. Следующий шаг эволюции был связан с поддержкой параллелизма и асинхронности. Task/Task, вся концепция TPL, развитие LINQ — PLINQ, и, в конце концов, async/await. И вот, в последние два-три года, мы наблюдаем новый этап эволюции платформы .NET и языка C# — движение в сторону обеспечения высокопроизводительных вычислений. Сюда относится введение Span и Memory, ValueTask/ValueTask, IAsyncDispose, ref readonly struct и квалификатор in, асинхронынй foreach, IO.Streams. Все эти нововведения направлены на снижение нагрузки на GC и повышения производительности кода. В этой статье, представлен следующий шаг в этом направлении — предоставления разработчику доступа к функциям аппаратного обеспечения. Я рад, что в разработке платформы .NET и языка C#, в частности, участвую инженеры с такими широкими и разносторонними взглядами и интересами. Надеюсь увидеть (а может даже приложить свою руку к этому) еще много интересных поворотов в развитии платформы и языка.*

Habrahabr.ru прочитано 17367 раз