Доступен новый JIT: теперь с поддержкой SIMD

в 19:17, , рубрики: Без рубрики

От переводчика

Лично я просто невероятно обрадовался новой возможности. Как раз не так давно одолел Pro .Net Perfomance, в которой одна из глав была посвящена параллельности, и векторизации в частности. Вывод, сделанный авторами: «К сожалению, использование векторизации возможно исключительно на С++, выполнение кода на видеокарте — возможно и средствами .Net, однако C++ AMP оставляет любые управляемые библиотеки GPGPU далеко позади, поэтому, к сожалению, в данных задачах рекомендуем использовать подключаемые C++ сборки.» Поэтому рад сообщить, что по крайней мере одна проблема решена. Что ж, приступим!

Вступление

Быстродействие процессоров более не подчиняется закону Мура. Поэтому для увеличения производительности приложений, все важнее использовать параллелизацию. Или, как говорил Герб Саттер, «бесплатного супа больше не будет» (The free lunch is over, отсылка к статье The Free Lunch Is Over: A Fundamental Turn Toward Concurrency in Software, перевод на хабре)

Можно подумать, что использование задачеориентированного программирования (например, в случае .Net — TPL, прим. пер.) или обычных потоков уже решает эту проблему. В то время как многопоточность, безусловно, является важной задачей, нужно понимать, что по-прежнему важно оптимизировать код, выполняющийся на каждом отдельном ядре. SIMD это технология, которая использует распараллеливание данных на уровне процессора. Многопоточность и SIMD дополняют друг друга: многопоточность позволяет распараллеливать работу на несколько ядер процессора, в то время как SIMD позволяет распараллеливать работу в пределах одного ядра.

Сегодня мы рады объявить о новой превью-версии RyuJIT, которая обеспечивает функциональность SIMD. SIMD API доступен через новый пакет NuGet, Microsoft.Bcl.Simd, который также выпущен в виде превью.

Вот пример, как вы можете его использовать:

// Initalize some vectors

Vector<float> values = GetValues();
Vector<float> increment = GetIncrement();

// The next line will leverage SIMD to perform the
// addition of multiple elements in parallel:

Vector<float> result = values + increment;

Что такое SIMD и зачем он мне?

SIMD на данный момент является самой востребованной фичей, которую просят добавить в платформу (около 2000 голосов пользователей)

image

Он настолько популярен, потому что, за исключением некоторых видов приложений, SIMD существенно ускоряет работу кода. Например, производительность рендеринга множества Мандельброта можно ощутимо улучшить с помощью SIMD: в 2-3 раза (если процессор поддерживает SSE2), и в 4-5 раз (если процессор поддерживает AVX).

image

image

Введение в SIMD

SIMD расшифровывается как «одна команда, множество данных» (отечественный вариант — ОКМД, прим. пер). Это набор инструкций процессора, которые позволяют работать над векторами вместо скаляров. За счет этого возможно параллельное выполнение математических операций над множеством данных.

SIMD позволяет распараллеливать данные на уровне процессора, используя высокоуровневые средства. Например, представьте, что у вас есть множество чисел, и к каждому нужно прибавить какое-то значение. Обычно, для решения этой задачи пишется цикл, который выполняет эту операцию последовательно для каждого элемента:

float[] values = GetValues();
float increment = GetIncrement();

// Perform increment operation as manual loop:
for (int i = 0; i < values.Length; i++)
{
    values[i] += increment;
}

SIMD же позволяет прибавлять несколько значений одновременно, используя специфические инструкции CPU. Обычно это выглядит, как операция над вектором:

Vector<float> values = GetValues();
Vector<float> increment = GetIncrement();

// Perform addition as a vector operation:
Vector<float> result = values + increment;

Интересно отметить, что существует не одна спецификация SIMD. Скорее, каждый процессор имеет свою собственную реализацию SIMD. Они отличаются числом элементов, над которыми операция может выполняться одновременно, а также набором доступных инструкций. Наиболее распространенной реализацией SIMD на процессорах Intel / AMD является SSE2.

Вот упрощенная модель того, как работает SIMD на уровне процессора:

  1. В процессоре есть специальные SIMD регистры. Они имеют фиксированный размер. Например, для SSE2, размер 128 бит.
  2. Процессор также имеет специальные SIMD-инструкции, зависящие от размера операнда. С точки зрения процессора, данные в SIMD регистре являются просто набором бит. Однако, разработчик хочет интерпретировать эти биты как, ну скажем, набор 32-битных целых чисел. Для этой цели в процессоре есть инструкции, специфичные для выполняемой операции (например, сложения), и типа операнда (например, 32-битного целого)

Одна из многих областей, где SIMD является весьма полезным — это графика и игры, так как:

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

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

Проектирование SIMD для. NET

Большинство .NET разработчиков не должно писать CPU-зависимый код. Вместо этого CLR абстрагирует аппаратное обеспечение, предоставляя виртуальную машину, которая переводит свой ​​код в машинные команды либо во время выполнения (JIT), либо во время установки (NGEN). Оставляя генерацию кода CLR, вы можете использовать один и тот же MSIL код на разных компьютерах с разными процессорами, не отказываясь от оптимизаций, специфических для данного конкретного CPU.

Это разделение является тем, что позволяет использовать библиотечную экосистему, потому что это чрезвычайно упрощает повторное использование кода. Мы считаем, что библиотечная экосистема является главной причиной того, что .NET является такой продуктивной средой.

Для того чтобы сохранить это разделение, мы должны были придумать такую модель программирования для SIMD, которая бы позволила выразить векторные операции без привязки к конкретной реализации процессора, например SSE2. Мы придумали модель, которая обеспечивает две категории векторных типов:

image

Эти две категории мы называем JIT-«встроенными типами». Это означает, что JIT знает об этих типах и трактует их особым образом при генерации машинного кода. Тем не менее, все типы также разработаны для безупречной работы в тех случаях, когда оборудование не поддерживает SIMD (что встречается сегодня довольно редко) или приложение не использует эту версию RyuJIT. Наша цель заключается в обеспечении приемлимой производительности даже в тех случаях, когда она примерно такая же, как и для последовательного написанный кода. К сожалению, в этом превью мы пока этого не достигли.

Векторы с фиксированным размером

Давайте сперва поговорим о векторах фиксированного размера. Есть много приложений, которые уже определяют свои собственные векторные типы, особенно графические приложения, такие как игры или рэйтрэйсеры (ray tracer). В большинстве случаев эти приложения используют значения с плавающей запятой и одинарной точностью.

Ключевым аспектом является то, что эти векторы имеют определенное количество элементов, как правило, два, три или четыре. Двухэлементные векторы часто используется для представления точки или подобных объектов, таких как комплексные числа. Векторы с тремя и четырьмя элементами, как правило, используется для 3D (4-й элемент используется, чтобы сделать математическую работу [Очевидно, речь идет о том, что в 3D-моделировании преобразования используют четырехмерные матрицы XYZ1, прим. пер]). Суть в том, что эти задачи используют векторы с определенным количеством элементов.

Чтобы получить представление о том, как эти типы выглядят, посмотрим на упрощенную версию Vector3f:

public struct Vector3f
{
    public Vector3f(float value);
    public Vector3f(float x, float y, float z);
    public float X { get; }
    public float Y { get; }
    public float Z { get; }
    public static bool operator ==(Vector3f left, Vector3f right);
    public static bool operator !=(Vector3f left, Vector3f right);
    // With SIMD, these element wise operations are done in parallel:
    public static Vector3f operator +(Vector3f left, Vector3f right);
    public static Vector3f operator -(Vector3f left, Vector3f right);
    public static Vector3f operator -(Vector3f value);
    public static Vector3f operator *(Vector3f left, Vector3f right);
    public static Vector3f operator *(Vector3f left, float right);
    public static Vector3f operator *(float left, Vector3f right);
    public static Vector3f operator /(Vector3f left, Vector3f right);
}

Я хотел бы подчеркнуть следующие аспекты:

  • Мы разработали векторы фиксированного размера, так что они могут легко заменить те, которые определены приложениях.
  • По соображениям производительности, мы определили эти типы, как неизменяемые типы значений.
  • Идея состоит в том, что после замены вашего вектора нашим, ваше приложение будет вести себя так же, за исключением того, что оно будет работает быстрее. Для получения более подробной информации, посмотрите на наш пример приложения трассировки лучей.

Векторы с аппаратно-зависимым размером

В то время векторные типы фиксированного размера удобно использовать, их максимальная степень параллелизации ограничена количеством компонентов. Например, приложение, которое использует Vector2f получит ускорение, самое большее, в два раза, даже если CPU будет способен выполнять операции над восемью элементами одновременно.

Для того, чтобы приложение масштабировалось с аппаратными возможностями, разработчик должен векторизовать алгоритм. Векторизация алгоритма означает то, что разработчик должен разбить входные параметры на множество векторов, размер которых зависит от оборудования. На машине с SSE2, это означает, что приложение может работать над векторами из четырех 32-битных чисел с плавающей запятой. На машине с AVX, то же приложение может работать над векторами из восьми таких чисел.

Чтобы понять для разницу, ниже представлена упрощенная версия Vector<T>:

public struct Vector<T> where T : struct {
    public Vector(T value);
    public Vector(T[] values);
    public Vector(T[] values, int index);
    public static int Length { get; }
    public T this[int index] { get; }
    public static bool operator ==(Vector<T> left, Vector<T> right);
    public static bool operator !=(Vector<T> left, Vector<T> right); 
    // With SIMD, these element wise operations are done in parallel:
    public static Vector<T> operator +(Vector<T> left, Vector<T> right);
    public static Vector<T> operator &(Vector<T> left, Vector<T> right);
    public static Vector<T> operator |(Vector<T> left, Vector<T> right);
    public static Vector<T> operator /(Vector<T> left, Vector<T> right);
    public static Vector<T> operator ^(Vector<T> left, Vector<T> right);
    public static Vector<T> operator *(Vector<T> left, Vector<T> right);
    public static Vector<T> operator *(Vector<T> left, T right);
    public static Vector<T> operator *(T left, Vector<T> right);
    public static Vector<T> operator ~(Vector<T> value);
    public static Vector<T> operator -(Vector<T> left, Vector<T> right);
    public static Vector<T> operator -(Vector<T> value);
}

Ключевые аспекты этого типа включают в себя следующее:

  • Он обобщенный. Для повышения гибкости и во избежание комбинаторного взрыва типов, мы определили аппаратно-зависимый вектор как обобщенный тип, Vector<T>. Исходя из практических соображений, Т может быть только примитивным числовым типом. В этом превью мы поддерживаем только int, long, float и double. Окончательный вариант будет также включать в себя поддержку всех других целочисленных типов, в том числе и их беззнаковых аналогов.
  • Длина статична. Так как длина зависит от оборудования, но фиксирована, то доступ к ней предоставляется через статическое свойство Length. Размер же определяется как sizeof(SIMD-register) / sizeof(T). Другими словами, два вектора Vector<T1> и Vector<T2> имеют одинаковую длину, если T1 и T2 имеют одинаковый размер. Это позволяет нам соотнести элементы в векторах с элементами разных типов, что является очень полезным свойством для векторизованного кода.

Векторизация — это сложная тема и, таким образом, она выходит за рамки этого блога. Тем не менее, позвольте мне дать вам общий обзор того, что это будет означать для конкретного приложения. Давайте посмотрим на средства визуализации множества Мандельброта. Концептуально, оно работает над комплексными числами, которые могут быть представлены в виде векторов с двумя элементами. Основываясь на математическом алгоритме, эти комплексные числа имеют цветовую маркировку и отображается в виде одной точки получаемого изображения.

При наивном использовании SIMD, можно было бы векторизовать алгоритм, представляя комплексные числа как Vector2f. Более сложный алгоритм будет векторизовать точки для визуализации (число которых является неограниченным) вместо размера (который фиксирован). Один из способов сделать это заключается в представлении действительной и мнимой компонент, как векторов. Другими словами, можно было бы векторизовать один и тот же компонент над несколькими точками.

Для получения более подробной информации, посмотрите на наш пример. В частности, сравните скалярную и векторизованную версии.

Использование превью-версии SIMD

В предпросмотре мы предоставляем следующие две части:

  1. Новый релиз RyuJIT, который обеспечивает поддержку SIMD
  2. Новая библиотека NuGet, который предоставляет поддержку SIMD

Библиотека NuGet была специально предназначена для работы без поддержки SIMD со стороны аппаратных средств / JIT. В этом случае все методы и операции реализованы в виде чистого IL. Тем не менее, очевидно, что вы можете получить максимальную производительность только при использовании этой библиотеки в сочетании с новой версией RyuJIT.

Для того чтобы использовать SIMD, вам необходимо выполнить следующие шаги:

  1. Скачать и установить последнюю превью RyuJIT с aka.ms/RyuJIT
  2. Установить некоторые переменные окружения для активизации новой JIT и SIMD для вашего процесса. Самый простой способ сделать это путем создания пакетного файла, который запускает ваше приложение:
    @echo off
    set COMPLUS_AltJit=*
    set COMPLUS_FeatureSIMD=1
    start myapp.exe
    

  3. Добавьте ссылку на NuGet-пакет Microsoft.Bcl.Simd. Вы можете сделать это, щелкнув правой кнопкой мыши на проекте и выбрав Manage NuGet References. В следующем диалоговом окне выберите вкладку под названием Online. Кроме того, необходимо выбрать Include Prelease в выпадающем списке на самом верху. Затем введите в текстовое поле в верхнем правом углу слово Microsoft.Bcl.Simd. Нажмите кнопку Install.

В данном превью существуют определенные ограничения, о которых, возможно, вам стоит знать:

  • SIMD доступна только для 64-битных процессов. Поэтому убедитесь, что ваше приложение либо ориентировано именно на x64, либо скомпилировано как Any CPU, а не отмечено как 32-битное
  • Тип Vector поддерживает только int, long, float и double. Создание экземпляров Vector<T> с любым другим типом вызовет исключение загрузки типа.
  • SIMD на данный момент поддерживает только SSE2. В связи с некоторыми ограничениями реализации, RyuJIT CTP не может автоматически изменять размер типа на основании местных возможностей аппаратного обеспечения. Полная поддержка AVX должна появиться с выпуском рантайма .NET, который будет включать в себя RyuJIT. Мы начали работу над этим, но она (поддержка) пока что не сочетается с нашей моделью развертывания CTP.

Заключение

Мы выпустили превью-версию, которая привносит силу SIMD в мир управляемого кода. Модель программирования представлена с помощью набора вектор типов, распространяемая как новый NuGet-пакет Microsoft.Bcl.Simd. Поддержка этих операций для процессора доступна в новой превью-версии RyuJIT.

Мы хотели бы получить ваши отзывы об обоих проектах. Нравится ли вам такая модель программирования? Чего не хватает? Какие улучшения производительности нужны для вашего приложения? Пожалуйста, используйте комментарии для обеспечения обратной связи или отправьте нам письмо по адресу ryujit(at)microsoft.com.

Автор: PsyHaSTe

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js