Prescott New Instructions (SSE3): обзор новых SIMD расширений с точки зрения разработки и оптимизации программного обеспечения


С выпуском нового 90-нм процессора Pentium 4 под кодовым названием Prescott, анонсированного Intel 2 февраля 2004 года архитектура Intel NetBurst приобрела ряд нововведений и дополнений, позволяющих по праву назвать процессор Prescott новым поколением процессоров семейства IA-32. Ключевыми моментами улучшенной архитектуры NetBurst, согласно Intel, являются следующие:

  • Поддержка технологии Hyper-Threading (которая, судя по всему, не претерпела каких-либо изменений);
  • Углубленный конвейер, позволяющий достичь еще более высоких частот;
  • Высокая частота системной шины (800 MHz, появившаяся еще со времен последних моделей Northwood);
  • Увеличенный вдвое объем первого и второго уровней кэша, а также большее количество буферов сохранения (store) и объединения записи (write-combining);
  • Поддержка новых SIMD расширений под кодовым названием Prescott New Instructions (официально — SSE3).

Ряд изменений коснулся информации, выдаваемой инструкцией CPUID (в то время как поведение самой инструкции не изменилось, за исключением большего количества выдаваемых значений). Специфичными для Prescott являются следующие значения CPUID:

  • Prescott New Instructions (SSE3): Extended Features, бит 0;
  • Debug Trace Store Qualification: Extended Features, бит 4;
  • Улучшенная технология Intel SpeedStep (использующая MSR процессора): Extended Features, бит 7;
  • Поддержка инструкций MONITOR/MWAIT: Extended Features, бит 3.

Ключевым элементом новой, улучшенной архитектуры Prescott является, конечно же, поддержка ряда новых инструкций, получивших кодовое название Prescott New Instructions (PNI) и, далее, официальное — SSE3. Это набор из 13 новых инструкций, призванных улучшить производительность процессора в ряде операций потоковой обработки данных. Дополнения коснулись набора команд SSE (Streaming SIMD Extensions), работающих с четырехкомпонентными векторами с одинарной точностью, SSE2 (Streaming SIMD Extensions 2), работающих с двумерными векторами чисел двойной точности, а также x87 FPU. Новая технология является полностью совместимой с существующим программным обеспечением (ПО), разработанным под архитектуру IA-32. В связи с этим гарантируется, что имеющееся ПО будет работать корректно без необходимости какой-либо модификации и на новых процессорах, поддерживающих расширения SSE3. Более того, использование расширений SSE3 не требует какой-либо дополнительной поддержки со стороны операционной системы, связанной с сохранением и восстановлением состояния процессора при переключении контекста, за исключением той, которая является необходимой (и уже имеется) для поддержки потоковых расширений SSE/SSE2.

Краткий экскурс в SSE3

Приступим к рассмотрению этого нового набора из 13 инструкций. Его можно разбить на следующие подгруппы:

1. Преобразование чисел с плавающей точкой (x87) в целые числа

В эту подгруппу входит одна-единственная инструкция из всего набора Prescott New Instructions (SSE3), которая работает на уровне x87 FPU.

FISTTP (сохранение целочисленного значения с освобождением элемента стека x87-FP с округлением в сторону нуля). Ее поведение аналогично поведению стандартной IA-32 инструкции FISTP, но важным отличием является использование округления в сторону нуля (известного как truncate или chop) вне зависимости от того, какой способ округления выбран в данный момент в контрольном слове FPU.

2. Дублирование данных

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

MOVSLDUP — загрузка 128-битного значения из исходного операнда (памяти или XMM-регистра) в операнд назначения с дублированием первого и третьего 32-битных элементов:

Операнд A (128 бит, 4 элемента): a3 | a2 | a1 | a0
Операнд B (128 бит, 4 элемента): b3 | b2 | b1 | b0

MOVSLDUP A, B
Результат (операнд A): b2 | b2 | b0 | b0

MOVSHDUP — загрузка 128-битного значения из исходного операнда в операнд назначения с дублированием второго и четвертого 32-битных элементов:

Операнд A (128 бит, 4 элемента): a3 | a2 | a1 | a0
Операнд B (128 бит, 4 элемента): b3 | b2 | b1 | b0

MOVSHDUP A, B
Результат (операнд A): b3 | b3 | b1 | b1

MOVDDUP — загрузка 64-битного значения из памяти или исходного регистра (биты [63-0]) с дублированием в обоих нижней и верхней частях регистра назначения:

Операнд A (128 бит, 2 элемента): a1 | a0
Операнд B (64 бита, 1 элемент): b0

MOVDDUP A, B
Результат (операнд A): b0 | b0

3. Загрузка невыровненных переменных

Данную подгруппу представляет инструкция LDDQU. Это операция особой загрузки невыровненного 128-битного значения из памяти, исключающая возможность «разрыва» (пересечения границы) строки кэша. В случае, если адрес загружаемого элемента выровнен по 16-байтной границе, LDDQU осуществляет обычную загрузку запрашиваемого 16-байтного значения (т.е., по сути, ведет себя аналогично инструкциям MOVAPS/MOVAPD/MOVDQA из стандартного набора SSE/SSE2). В противном случае LDDQU загружает целых 32 байта, начиная с выровненного адреса (ниже запрашиваемого) с последующим извлечением требуемых 16 байт. Использование этой инструкции позволяет достичь значительного увеличения производительности при загрузке невыровненных 128-битных значений из памяти, по сравнению со стандартными инструкциями MOVUPS/MOVUPD/MOVDQU SIMD-расширений SSE/SSE2.

4. Одновременное сложение/вычитание

В эту подгруппу входят две новые инструкции, первая из которых работает с четырехкомпонентными векторами чисел одинарной точности (SSE), вторая — с двухкомпонентными векторами чисел двойной точности (SSE2).

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

Операнд A (128 бит, 4 элемента): a3 | a2 | a1 | a0
Операнд B (128 бит, 4 элемента): b3 | b2 | b1 | b0

ADDSUBPS A, B
Результат (операнд A): a3+b3 | a2-b2 | a1+b1 | a0-b0

ADDSUBPD — ведет себя аналогично, но работает с числами двойной точности (двухэлементными операндами SSE2):

Операнд A (128 бит, 2 элемента): a1 | a0
Операнд B (128 бит, 2 элемента): b1 | b0

ADDSUBPD A, B
Результат (операнд A): a1+b1 | a0-b0

5. Горизонтальное сложение/вычитание

Пятая подгруппа представлена четырьмя командами, осуществляющими принципиально новые операции для SIMD расширений семейства SSE/SSE2. Первые две из них работают с четырехкомпонентными векторами с одинарной точностью, остальные — с двухкомпонентными векторами с двойной точностью.

HADDPS — осуществляет горизонтальное сложение элементов с одинарной точностью. Первый элемент, записываемый в операнд назначения, является суммой первого и второго элементов первого (исходного) операнда; второй элемент — суммой третьего и четвертого элементов первого операнда; третий элемент — суммой первого и второго элементов второго операнда (операнда назначения) и, наконец, четвертый элемент — суммой третьего и четвертого элементов второго операнда. Для наглядности изобразим это, как и прежде, в виде схемы:

Операнд A (128 бит, 4 элемента): a3 | a2 | a1 | a0
Операнд B (128 бит, 4 элемента): b3 | b2 | b1 | b0

HADDPS A, B
Результат (операнд A): b2+b3 | b0+b1 | a2+a3 | a0+a1

HSUBPS — осуществляет горизонтальное вычитание элементов с одинарной точностью. Ее поведение аналогично HADDPS, единственным отличием является использование операции вычитания вместо сложения:

Операнд A (128 бит, 4 элемента): a3 | a2 | a1 | a0
Операнд B (128 бит, 4 элемента): b3 | b2 | b1 | b0

HSUBPS A, B
Результат (операнд A): b2-b3 | b0-b1 | a2-a3 | a0-a1

HADDPD — осуществляет горизонтальное сложение элементов с двойной точностью. Первым (нижним) результирующим элементом является сумма нижней и верхней частей первого (исходного) операнда, вторым (верхним) — сумма нижней и верхней половин второго операнда (операнда назначения):

Операнд A (128 бит, 2 элемента): a1 | a0
Операнд B (128 бит, 2 элемента): b1 | b0

HADDPD A, B
Результат (операнд A): b0+b1 | a0+a1

HSUBPD — осуществляет горизонтальное вычитание элементов с двойной точностью. Эта инструкция аналогична HADDPS, но использует операцию вычитания вместо сложения:

Операнд A (128 бит, 2 элемента): a1 | a0
Операнд B (128 бит, 2 элемента): b1 | b0

HSUBPD A, B
Результат (операнд A): b0-b1 | a0-a1

6. Синхронизация потоков

В последнюю подгруппу можно включить две инструкции, нацеленные на использование в системном программировании с целью предоставления возможности более эффективной синхронизации потоков, в частности, при использовании технологии Hyper-Threading. Ожидается, что эти инструкции будут использоваться при разработке операционных систем и драйверов устройств с целью улучшения производительности процессора и снижения энергопотребления последнего, когда он находится в режиме «пустого» ожидания (по всей видимости, наряду с введенной в расширения SSE2 инструкцией PAUSE).

MONITOR — устанавливает диапазон адресов памяти (обычно используется одна строка кэша), по которому будет осуществляться отслеживание записей по стандартному протоколу write-back.

MWAIT — вводит логический процессор в оптимизированный режим (режим низкого энергопотребления) при ожидании записей по протоколу write-back по пространству адресов, заданных инструкцией MONITOR. С архитектурной точки зрения ее поведение идентично NOP. Выход из оптимизированного состояния осуществляется в случае записи по установленному пространству адресов, а также при срабатывании любого прерывания или исключения.

Использование SSE3 в разработке и оптимизации ПО

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

1. Расчетные задачи, использующие x87 FPU

В задачах такого типа (каковыми является подавляющее большинство профессионального расчетного ПО) может оказаться весьма полезной инструкция быстрого преобразования вещественных чисел в целые (FISTTP), единственная из всего набора SSE3, работающая на уровне x87 FPU (все остальные, как нетрудно видеть, задействуют исполнительные блоки SIMD — они работают с типами данных, присущими SSE или SSE2).

Известно, что правильным с точки зрения стандарта C/C++/Fortran способом преобразования переменных типа float (чисел с плавающей точкой) в переменные типа int (целые числа) в операциях вида:

float f;
int i = (int)f;

является округление в сторону нуля (truncate). В то же время, выбранным по умолчанию способом округления x87 FPU является округление в сторону ближайшего точного числа (round-to-nearest). Необходимость существования операции округления, вообще говоря, связана вовсе не с преобразованием вещественных чисел в целые, а с конечным способом представления бесконечного множества чисел с плавающей точкой (32 бита для чисел одинарной точности, 64 бита для чисел двойной точности, 80 бит для переменных с расширенной точностью, а также для внутреннего представления данных в x87 FPU). При этом наиболее точное конечное представление чисел с плавающей точкой достигается именно при таком способе округления.

В связи с этим, приведенный выше C-код в процессорах IA-32 нынешнего поколения будет преобразован любым компилятором, соблюдающим требования стандарта ANSI C, в ассемблерный код примерно следующего вида:

Код 1.1

DWORD cwOld, cwNew;
__asm
{
    fld         dword ptr[f]            // загрузка значения float
    fnstcw      word ptr[cwOld]         // сохранение FPUCW
    movzx       eax, word ptr[cwOld]
    or          eax, 0x0c00             // установка режима округления
                                        // в сторону нуля (truncate)
    mov         dword ptr[cwNew], eax
    fldcw       word ptr[cwNew]         // загрузка нового значения FPUCW
    fistp       dword ptr[i]            // сохранение значения int
    fldcw       word ptr[cwOld]         // восстановление FPUCW
}

Легко заметить, что такая процедура, довольно часто встречающаяся на практике, содержит в себе три явно лишние операции, связанных с сохранением, загрузкой и восстановлением значения контрольного слова x87 FPU. Заметим, что время исполнения каждой из последних измеряется как минимум несколькими тактами процессора (в ряде случаев — десятью и более, в зависимости от конкретной реализации микроархитектуры процессора). С точки зрения оптимизации кода, в рамках существующий архитектуры IA-32 можно наметить два выхода из этой ситуации.

Первый — это сохранить значение контрольного слова FPU, загрузить новое значение контрольного слова FPU с нужным способом округления, после чего совершить сразу целый ряд преобразований, и, наконец, восстановить исходное состояние FPU. Именно такой способ рекомендуется в ряде документации по оптимизации кода для процессоров x86 (в частности, для AMD Athlon). Трудность такого подхода заключается в том, что, во-первых, необходима реорганизация кода, позволяющая сгруппировать, по возможности, все преобразования значений float в int. Во-вторых, саму процедуру преобразования придется писать в виде ассемблерной вставки.

Второй подход — это использовать доступные SIMD-расширения, вроде SSE или 3DNow! В первом случае для этой цели подходят команды CVTSS2SI и CVTPS2PI (последняя позволяет осуществлять два преобразования одновременно, но использует MMX-регистр, что, в свою очередь, приводит к необходимости переключения режимов FPU/MMX, которое является относительно «бесплатным» далеко не для всех процессоров). Во втором случае преобразование пары вещественных значений в целочисленные можно осуществлять с помощью команды PF2ID. Здесь вновь присутствуют трудности, присущие, кстати, набору 3DNow! в целом — использование MMX-регистров, и, как следствие, необходимость переключения режимов работы процессора (либо очистки, либо сохранения/восстановления содержимого FPU-стека и MMX-регистров). В качестве наиболее простой процедуры преобразования можно придумать следующую, оформленную в виде ассемблерной вставки:

Код 1.2

__asm
{
    cvttss2si   eax, dword ptr[f]
    mov         dword ptr[i], eax
}

Подобный код вовсе не обязательно писать вручную, его вполне умеют генерировать компиляторы, понимающие SSE (при включении соответствующего ключа оптимизации, например, -QxK для Intel C++ Compiler 7.0). Таким образом, второй подход при наличии должной поддержки со стороны процессора, по идее, должен давать существенный выигрыш в производительности при преобразовании указанных типов данных. Более того, использование команд SSE, в отличие от MMX/3DNow!, не требует дополнительных затрат, связанных с переключением режима работы процессора.

Рассмотрим наконец, как можно осуществить рассматриваемое преобразование с использованием новых расширений SSE3. Решение окажется предельно простым:

Код 1.3

__asm
{
    fld         dword ptr[f] // загрузка значения float
    fisttp      dword ptr[i] // сохранение значения int в режиме truncate
}

К сожалению, по непонятным пока что причинам такой код не генерируется автоматически компилятором, понимающим SSE3 (каковым является Intel C++ Compiler 8.0 при использовании ключа оптимизации -QxP). Вместо этого создается аналог рассмотренного выше SSE-кода, использующего команду CVTTSS2SI. Тем не менее, время исполнения SSE3-кода должно быть вполне сопоставимым, если даже не меньшим, чем вышеупомянутого SSE-кода. Самое время проверить это утверждение на практике.

Тип кодаВремя исполнения, тактов*
1.1. Преобразование с помощью FPU26.0
1.2. Преобразование с помощью SSE3.80
1.3. Преобразование с помощью FPU/SSE32.50
* Здесь и далее замеры проводились на Pentium 4 Prescott 2.8A ГГц

Мы видим, что время исполнения первого примера (код 1.1) оказывается непозволительно большим для одной-единственной операции преобразования данных (26 тактов процессора), что позволяет говорить о серьезном недостатке стандартного набора команд x87 FPU архитектуры IA-32. Введение в последнюю расширений SSE значительно спасает положение — аналогичный SSE-код (1.2) отнимает всего 3.8 тактов процессора, что почти в 7 раз быстрее по сравнению с традиционным подходом. Тем не менее, новые расширения SSE3 оказываются еще более эффективными по отношению к этой задаче, и как нельзя лучше подходят для преобразования вещественных чисел в целые. Действительно, выполнение соответствующего кода (1.3) занимает всего 2.5 такта процессора, что более чем на порядок быстрее, чем традиционное решение, реализуемое в IA-32 x87 FPU.

Резюмируя, можно сказать, что использование новой инструкции FISTTP позволяет достичь значительного сокращения объема кода, необходимого для преобразования вещественных чисел в целые и, что еще более важно, времени исполнения последнего. В связи с этим оно является более чем оправданным. Остается только надеяться, что Intel позаботиться ввести ее автоматическое использование в последующие версии Intel C++ Compiler.

2. Вычисления с комплексными числами

Комплексная арифметика довольно часто встречается во всевозможных спектральных задачах, в частности — задачах обработки аудиоданных. К ним относятся дискретное/быстрое преобразование Фурье (DFT/FFT), частотная фильтрация и т.п. Среди новых расширений SSE3 можно выделить пять инструкций, позволяющих ускорить вычисления с комплексными числами. Сюда относятся инструкции одновременного сложения-вычитания ADDSUBPS и ADDSUBPD и инструкции дублирования данных MOVSLDUP, MOVSHDUP и MOVDDUP (исходный операнд которых может представлять собой адрес памяти). Первые позволяют исключить излишние операции смены знака у части элементов данных (обычно осуществляемые с помощью XORPS/XORPD), вторые — лишние операции распаковки данных, загружаемых из памяти (UNPCKLPS/UNPCKLPD, SHUFPS/SHUFPD).

Рассмотренные ниже примеры кода показывают, как можно реализовать умножение комплексных чисел, используя только SSE2 (код 2.1), или SSE2 и новые расширения SSE3 (код 2.2). mem_X представляет собой превый комплексный операнд, mem_Y — второй; результат умножения сохраняется в mem_Z. В регистре XMM7 хранится константа, используемая для смены знака одного из элемента данных.

Код 2.1. Комплексное умножение с помощью SSE2

struct __declspec(align(16)) DCOMPLEX
{
    double Re;
    double Im;
};

DCOMPLEX mem_X, mem_Y, mem_Z;

__asm
{
    movapd      xmm0, xmmword ptr[mem_X]
    movapd      xmm1, xmmword ptr[mem_Y]
    movapd      xmm2, xmmword ptr[mem_Y]
    unpcklpd    xmm1, xmm1
    unpckhpd    xmm2, xmm2
    mulpd       xmm1, xmm0
    mulpd       xmm2, xmm0
    xorpd       xmm2, xmm7
    shufpd      xmm2, xmm2, 0x1
    addpd       xmm2, xmm1
    movapd      xmmword ptr[mem_Z], xmm2
}

Код 2.2. Комплексное умножение с помощью SSE2/SSE3

struct __declspec(align(16)) DCOMPLEX
{
    double Re;
    double Im;
};

DCOMPLEX mem_X, mem_Y, mem_Z;

__asm
{
    movapd      xmm0, xmmword ptr[mem_X]
    movddup     xmm1, qword ptr[mem_Y]
    movddup     xmm2, qword ptr[mem_Y+8]
    mulpd       xmm1, xmm0
    mulpd       xmm2, xmm0
    shufpd      xmm2, xmm2, 0x1
    addsubpd    xmm2, xmm1
    movapd      xmmword ptr[mem_Z], xmm2
}

Сопоставим производительность первого примера, использующего только SSE2, со вторым, который использует в том числе и новые расширения SSE3.

Тип кодаВремя исполнения, тактов
2.1. Комплексное умножение, SSE210.5
2.2. Комплексное умножение, SSE2/SSE35.6

По результатам тестирования видно, что использование SSE3 в задачах комплексной арифметики позволяет достичь существенного выигрыша в скорости (время исполнения кода, использующего новые расширения, почти в два раза меньше, чем аналогичного SSE2-кода).

3. Кодирование видео

Наиболее затратной с точки зрения процессорного времени операцией в задачах кодирования видеоданных обычно является Motion Estimation (ME), в которой блоки текущего кадра сравниваются с блоками предыдущего кадра с целью нахождения наилучшего соответствия (критерием последнего обычно является сумма абсолютных разностей). Особенность алгоритма ME заключается в том, что адреса блоков текущего кадра является выровненными, в то время как блоки предыдущего кадра обычно не являются выровненными по 16-байтной границе. В существующей архитектуре IA-32 операции невыровненной загрузки SSE/SSE2 имеют два серьезных недостатка:

  • Отсутствие аппаратной поддержки;
  • Возможность «разрыва» (пересечения границы) строки кэша.

Так, в микроархитектуре NetBurst нет микрооперации, соответствующей загрузке невыровненного 128-битного значения (командами MOVUPS, MOVUPD или MOVDQU), в связи с чем последние эмулируются двумя 64-битными операциями загрузки с последующим объединением данных. Помимо эмуляции, могут возникать и дополнительные затраты в том случае, если загрузка сопровождается пересечением 64-байтной границы строки кэша процессора.

Введенная в набор SSE3 инструкция специализированной загрузки невыровненных 128-битных значений LDDQU призвана решить эту проблему. В то же время, поскольку эта команда загружает большее количество данных (32 байта, начиная с выровненного адреса), имеется ряд ограничений на ее использование. В частности, ее не рекомендуется использовать для некэшируемых (Uncached, UC) регионов, или регионов с объединением записи (Write-combining, USWC), а также в ситуациях, когда может ожидаться «загрузка после сохранения» (Store-to-load forwarding, STLF). В остальных случаях, каковыми является большинство, можно ожидать до 30% улучшения производительности кода, использующего операции невыровненной загрузки (учитывая, что пересечение границы строки кэша при загрузке невыровненных значений может проявляться в 25% случаев). Приведем фрагменты такого кода из алгоритма ME, использующие только SSE2 (код 3.1) и SSE2/SSE3 (код 3.2).

Код 3.1. Motion Estimation без SSE3

__asm
{
    movdqa      xmm0, xmmword ptr[current_block]
    movdqu      xmm1, xmmword ptr[previous_block]
    psadbw      xmm0, xmm1
    paddw       xmm2, xmm0
}

Код 3.2. Motion Estimation с использованием SSE3.
__asm
{
    movdqa      xmm0, xmmword ptr[current_block]
    lddqu       xmm1, xmmword ptr[previous_block]
    psadbw      xmm0, xmm1
    paddw       xmm2, xmm0
}

Попробуем оценить реальный выигрыш в производительности, который можно достичь при замене стандартной операции невыровненной загрузки MOVDQU на инструкцию специализированной загрузки невыровненных данных LDDQU. В нашем примере, для сведения влияния подсистемы памяти практически к нулю мы использовали блоки данных current_block и previous_block размером всего 64 байта, т.е. помещающиеся в одну строку кэша. При этом current_block располагался по адресу, выровненному по 16-байтной границе, а previous_block со смещением от этой границы на 4 байта вправо. Таким образом, ровно одна из четырех операций загрузки данных из previous_block приводила к пересечению границы строки кэша.

Тип кодаВремя исполнения, тактов
3.1. Motion Estimation, SSE27.0
3.2. Motion Estimation, SSE2/SSE35.0

Замена MOVDQU на LDDQU, по результатам тестирования, уменьшает время исполнения такого кода на два такта процессора. Выигрыш в скорости при использовании этой SSE3-инструкции в нашем простейшем случае составляет 40%, в связи с чем ее применение в тех случаях, когда данные не могут быть выровнены по 16-байтной границе, является вполне оправданным. При этом, правда, следует иметь в виду упомянутые выше ограничения на ее использование.

4. Векторные операции

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

Какие векторные операции чаще всего можно встретить в реальных 3D-приложениях? Прежде всего, это операции с трех- или четырехмерными векторами, вроде их сложения, вычитания и скалярного умножения (умножения вектора на число). Не менее важными являются операции скалярного произведения двух векторов, вычисления длины вектора и процедура нормирования вектора (деления каждого элемента вектора на длину вектора). С точки зрения разработки и оптимизации ПО, для осуществления большинства подобных операций как нельзя лучше подходят SIMD расширения SSE, введенные в архитектуру IA-32 с выходом первых процессоров Intel Pentium III. Операндами инструкций набора SSE являются 128-битные регистры XMM (или 128-битное значение в памяти, которое, как правило, должно быть выровнено по 16-байтной границе), содержимое которых можно рассматривать как четырехмерные вектора, построенные из чисел с одинарной точностью:

struct __declspec(align(16)) VECTOR4F
{
    float x;
    float y;
    float z;
    float w;
};

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

VECTOR4F A, B;
__asm
{
    movaps      xmm0, xmmword ptr[A]
    movaps      xmm1, xmmword ptr[B]
    addps       xmm0, xmm1
    movaps      xmmword ptr[A], xmm0
}

Из этого примера видно, что для осуществления собственно операции сложения необходима всего одна инструкция ADDPS (при условии, что как исходные вектора, так и результат сложения хранятся в XMM-регистрах, т.е. операции загрузки-выгрузки данных не требуются). Для осуществления подобной операции с помощью FPU потребовалось бы гораздо большее количество операций:

VECTOR4F A, B;
__asm
{
    fld         dword ptr[A]
    fadd        dword ptr[B]
    fld         dword ptr[A+4]
    fadd        dword ptr[B+4]
    fld         dword ptr[A+8]
    fadd        dword ptr[B+8]
    fxch        st(2)
    fstp        dword ptr[A]
    fstp        dword ptr[A+4]
    fstp        dword ptr[A+8]
}

Точно такой же выигрыш в скорости при использовании SSE можно получить и в любых других операциях с векторами, которые осуществляются поэлементно, иными словами, вертикально (т.е. когда одна и та же арифметическая операция применяется по отношению к каждому i-му элементу обоих векторов A и B). Тем не менее, в векторной алгебре известен и ряд других операций с векторами, осуществляемых с разными элементами одного и того же вектора, или горизонтально. Самый яркий тому пример — скалярное произведение двух векторов или вычисление длины (нормы) вектора (квадратного корня из скалярного произведения вектора самого на себя). Для наглядности приведем формулы для вычисления скалярного произведения трехкомпонентных векторов (A.B) и нормы вектора A:

VECTOR4F A, B;
float dot = A.x * B.x + A.y * B.y + A.z * B.z;
float norm = sqrtf(A.x * A.x + A.y * A.y + A.z * A.z);

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

Код 4.1. Скалярное произведение двух векторов, SSE

VECTOR4F A, B;
float dot;
// считаем, что четвертый элемент каждого вектора равен нулю
__asm
{
    movaps      xmm0, xmmword ptr[A]    // 0 | A.z | A.y | A.x
    movaps      xmm1, xmmword ptr[B]    // 0 | B.z | B.y | B.x
    mulps       xmm0, xmm1              // 0 | A.z*B.z | A.y*B.y | A.x*B.x
    movhlps     xmm1, xmm0      // ? | ? | 0 | A.z*B.z
    unpcklps    xmm0, xmm0      // A.y*B.y | A.y*B.y | A.x*B.x | A.x*B.x
    movhlps     xmm2, xmm0      // ? | ? | A.y*B.y | A.y*B.y
    addss       xmm0, xmm1      // ? | ? | ? | A.x*B.x+A.z*B.z
    addss       xmm0, xmm2      // ? | ? | ? | A.x*B.x+A.y*B.y+A.z*B.z
    movss       dword ptr[dot], xmm0
}

В рассмотренном примере вычисление скалярного произведения осуществляется при помощи одной операции умножения (MULPS), двух операций перемещения (MOVHLPS), одной операции распаковки (UNPCKLPS) и двух операций однокомпонентного (скалярного) сложения (ADDSS). Кроме того, что немаловажно, задействуется один дополнительный XMM-регистр.

Выходов из ситуации, в смысле, путей дальнейшей оптимизации кода, как всегда, можно придумать несколько. Первый из них — использовать для вычисления скалярных произведений не SSE, а FPU. Но это связано с дополнительными трудностями переноса данных из регистров SSE в стек FPU и обратно в том случае, если большая часть кода использует именно расширения SSE. В связи с этим мы не будем рассматривать его в нашем сравнительном тестировании производительности.

Второй способ — использовать однокомпонентные (скалярные) инструкции SSE. Получается что-то вроде аналога FPU-кода, но более удобного, поскольку мы используем все те же XMM-регистры:

Код 4.2. Скалярное произведение двух векторов, SSE scalar

VECTOR4F A, B;
float dot;
__asm
{
    movss       xmm0, dword ptr[A]
    movss       xmm1, dword ptr[A+4]
    movss       xmm2, dword ptr[A+8]
    mulss       xmm0, dword ptr[B]
    mulss       xmm1, dword ptr[B+4]
    mulss       xmm2, dword ptr[B+8]
    addss       xmm0, xmm1
    addss       xmm0, xmm2
    movss       dword ptr[dot], xmm0
}

Именно такой способ, по всей видимости, предпочитают оптимизирующие компиляторы вроде Intel C++ Compiler 7.0 (при задании соответствующего ключа оптимизации -QxK). В связи с этим мы включили его в наше сравнение, результаты которого представлены ниже.

Есть еще и третий способ, довольно фантастический. Он заключается в одновременном вычислении сразу четырех скалярных произведений восьми трехмерных векторов, но зато использует расширения SSE в полной мере. Для полноты картины приведем и его, но при этом отметим, что вряд ли кто-то действительно захочет возиться с подобной ручной оптимизацией.

Код 4.3. Четыре скалярных произведения, SSE

VECTOR4F A1, A2, A3, A4, B1, B2, B3, B4;
__declspec(align(16)) float dot[4];

__asm
{
    // загрузка векторов A
    movaps      xmm0, xmmword ptr[A1] // 0 | A1.z | A1.y | A1.x
    movaps      xmm1, xmmword ptr[A2] // 0 | A2.z | A2.y | A2.x
    movaps      xmm2, xmmword ptr[A3] // 0 | A3.z | A3.y | A3.x
    movaps      xmm3, xmmword ptr[A4] // 0 | A4.z | A4.y | A4.x
    // транспонирование
    movaps      xmm4, xmm0
    movlhps     xmm0, xmm1            // A2.y | A2.x | A1.y | A1.x
    movhlps     xmm1, xmm4            //    0 | A2.z |    0 | A1.z
    movaps      xmm5, xmm2
    movlhps     xmm2, xmm3            // A4.y | A4.x | A3.y | A3.x
    movhlps     xmm3, xmm5            //    0 | A4.z |    0 | A3.z
    movaps      xmm4, xmm0
    shufps      xmm0, xmm2, 0x88      // A4.x | A3.x | A2.x | A1.x
    shufps      xmm4, xmm2, 0xDD      // A4.y | A3.y | A2.y | A1.y
    shufps      xmm1, xmm3, 0x88      // A4.z | A3.z | A2.z | A1.z
    // загрузка векторов B
    movaps      xmm2, xmmword ptr[B1] // 0 | B1.z | B1.y | B1.x
    movaps      xmm3, xmmword ptr[B2] // 0 | B2.z | B2.y | B2.x
    movaps      xmm5, xmmword ptr[B3] // 0 | B3.z | B3.y | B3.x
    movaps      xmm6, xmmword ptr[B4] // 0 | B4.z | B4.y | B4.x
    // транспонирование
    movaps      xmm7, xmm2
    movlhps     xmm2, xmm3            // B2.y | B2.x | B1.y | B1.x
    movhlps     xmm3, xmm7            //    0 | B2.z |    0 | B1.z
    movaps      xmm7, xmm5
    movlhps     xmm5, xmm6            // B4.y | B4.x | B3.y | B3.x
    movhlps     xmm6, xmm7            //    0 | B4.z |    0 | B3.z
    movaps      xmm7, xmm2
    shufps      xmm2, xmm5, 0x88      // B4.x | B3.x | B2.x | B1.x
    shufps      xmm7, xmm5, 0xDD      // B4.y | B3.y | B2.y | B1.y
    shufps      xmm3, xmm6, 0x88      // B4.z | B3.z | B2.z | B1.z
    // скалярные произведения
    mulps       xmm0, xmm2            // A(i).x * B(i).x
    mulps       xmm4, xmm7            // A(i).y * B(i).y
    mulps       xmm1, xmm3            // A(i).z * B(i).z
    addps       xmm4, xmm1
    addps       xmm0, xmm4            // A(i).B(i)
    // сохранение
    movaps      xmmword ptr[dot], xmm0
}

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

Рассмотрим, наконец, что же позволяют нам сделать в плане оптимизации вычислений скалярных произведений грядущие расширения SSE3. А позволяют они многое. Для этого достаточно взглянуть на следующий код, относящийся к вычислению одного скалярного произведения.

Код 4.4. Скалярное произведение двух векторов, SSE/SSE3

VECTOR4F A, B;
float dot;
__asm
{
    movaps      xmm0, xmmword ptr[A]    // 0 | A.z | A.y | A.x
    movaps      xmm1, xmmword ptr[B]    // 0 | B.z | B.y | B.x
    mulps       xmm0, xmm1      // 0 | A.z*B.z | A.y*B.y | A.x*B.x
    movhlps     xmm1, xmm0      // ? | ? | 0 | A.z*B.z
    haddps      xmm0, xmm0      // A.z*B.z | A.y*B.y+A.x*B.x |
                                // A.z*B.z | A.y*B.y+A.x*B.x
    addss       xmm0, xmm1      // ? | ? | ? | A.B
    movss       dword ptr[dot], xmm0
}

По сравнению с первоначальным кодом (код 4.1) мы добились значительного сокращения количества операций, относящихся непосредственно к вычислению скалярного произведения, за счет использования новой инструкции из набора SSE3 — инструкции горизонтального сложения HADDPS. Более того, поскольку HADDPS умеет работать сразу с парой XMM-регистров, само собой напрашивается сделать код еще более оптимальным, вычисляя сразу два скалярных произведения:

Код 4.5. Два скалярных произведения, SSE/SSE3

VECTOR4F A1, A2, B1, B2;
__declspec(align(8)) float dot[2];
__asm
{
    movaps      xmm0, xmmword ptr[A1]   // 0 | A1.z | A1.y | A1.x
    movaps      xmm1, xmmword ptr[A2]   // 0 | A2.z | A2.y | A2.x
    movaps      xmm2, xmmword ptr[B1]   // 0 | B1.z | B1.y | B2.x
    movaps      xmm3, xmmword ptr[B2]   // 0 | B2.z | B2.y | B2.x
    mulps       xmm0, xmm2      // 0 | A1.z*B1.z | A1.y*B1.y | A1.x*B1.x
    mulps       xmm1, xmm3      // 0 | A2.z*B2.z | A2.y*B2.y | A2.x*B2.x
    haddps      xmm0, xmm1      // A2.z*B2.z | A2.y*B2.y+A2.x*B2.x |
                                // A1.z*B1.z | A1.y*B1.y+A1.x*B1.x
    haddps      xmm0, xmm0      // A2.B2 | A1.B1 | A2.B2 | A1.B1
    movlps      qword ptr[dot], xmm0
}

И все равно, складывается ощущение некоторой незавершенности оптимизации. Два скалярных произведения в результате вычисления как бы дублируются. Действительно, а почему бы не попытаться использовать SSE3 на всю мощь и попробовать посчитать сразу четыре скалярных произведения? Взгляните на следующий код — это достигается уже далеко не такой ценой, как при вычислении этих самых четырех скалярных произведений исключительно с помощью SSE-инструкций (код 4.3):

Код 4.6. Четыре скалярных произведения, SSE/SSE3

VECTOR4F A1, A2, A3, A4, B1, B2, B3, B4;
__declspec(align(16)) float dot[4];
__asm
{
    movaps      xmm0, xmmword ptr[A1]   // 0 | A1.z | A1.y | A1.x
    movaps      xmm1, xmmword ptr[A2]   // 0 | A2.z | A2.y | A2.x
    movaps      xmm2, xmmword ptr[A3]   // 0 | A3.z | A3.y | A3.x
    movaps      xmm3, xmmword ptr[A4]   // 0 | A4.z | A4.y | A4.x
    movaps      xmm4, xmmword ptr[B1]   // 0 | B1.z | B1.y | B2.x
    movaps      xmm5, xmmword ptr[B2]   // 0 | B2.z | B2.y | B2.x
    movaps      xmm6, xmmword ptr[B3]   // 0 | B3.z | B3.y | B3.x
    movaps      xmm7, xmmword ptr[B4]   // 0 | B4.z | B4.y | B4.x
    mulps       xmm0, xmm4      // 0 | A1.z*B1.z | A1.y*B1.y | A1.x*B1.x
    mulps       xmm1, xmm5      // 0 | A2.z*B2.z | A2.y*B2.y | A2.x*B2.x
    mulps       xmm2, xmm6      // 0 | A3.z*B3.z | A3.y*B3.y | A3.x*B3.x
    mulps       xmm3, xmm7      // 0 | A4.z*B4.z | A4.y*B4.y | A4.x*B4.x
    haddps      xmm0, xmm1      // A2.z*B2.z | A2.y*B2.y+A2.x*B2.x |
                                // A1.z*B1.z | A1.y*B1.y+A1.x*B1.x
    haddps      xmm2, xmm3      // A4.z*B4.z | A4.y*B4.y+A4.x*B4.x |
                                // A3.z*B3.z | A3.y*B3.y+A3.x*B3.x
    haddps      xmm0, xmm2      // A4.B4 | A3.B3 | A2.B2 | A1.B1
    movaps      xmmword ptr[dot], xmm0
}

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

Как всегда, самое время проверить все вышесказанное на практике. Для этого попробуем оценить, во сколько тактов процессора уложится вычисление одного скалярного произведения с использованием только SSE-команд (код 4.1, 4.2) и SSE вместе с SSE3 (код 4.4), а также сравним эффективность вычисления четырех скалярных произведений с помощью SSE-кода (код 4.3) и кода, в полной мере использующего новые процессорные расширения (код 4.6).

Тип кодаВремя исполнения, тактов
4.1. Одно скалярное произведение, SSE8.60
4.2. Одно скалярное произведение, SSE scalar8.75
4.4. Одно скалярное произведение, SSE/SSE39.00
4.3. Четыре скалярных произведения, SSE33.67
4.6. Четыре скалярных произведения, SSE/SSE318.33

Что же мы видим? Одно скалярное произведение, использующее расширения SSE/SSE3 далеко не на полную мощность, не то что не выигрывает, но даже несколько проигрывает при использовании новой инструкции HADDPS из набора SSE3. И это несмотря на то, что мы сократили объем кода и устранили необходимость использования дополнительных XMM-регистров. Тем не менее, поскольку увеличение времени исполнения такого кода (4.4) является сравнительно малым, использование SSE3 в этом случае (вычисление одного скалярного произведения трехкомпонентных векторов) можно считать оправданным, хотя бы по причине того, что в распоряжении компилятора, ну или непосредственно разработчика, остается большее количество доступных XMM-регистров.

Но давайте теперь посмотрим, что же получается в случае одновременного вычисления сразу четырех скалярных произведений? В этом случае выигрыш в скорости при использовании SSE3 оказывается весьма и весьма значительным, SSE3-код опережает свой SSE-аналог почти на 84%. Что еще раз доказывает, что использование сразу всех четырех элементов XMM-регистра является оптимальным режимом работы SIMD расширений Intel, и SSE3 здесь не является исключением. В этой связи ручная оптимизация под SSE3 может оказаться намного более эффективной, нежели поручение этой же работы оптимизирующему компилятору.

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

Тип задачиВыигрыш в скорости, раз, по сравнению с традиционным FPU/SIMD-кодом
Преобразование данных (float to int)10.4
Комплексное умножение1.88
Загрузка невыровненных значений1.40
Одно скалярное произведение векторов0.96
Четыре скалярных произведения векторов1.84

Сравнение с конкурентными решениями

С выпуском процессоров архитектуры AMD64, AMD наконец-то ввела полный набор SIMD-расширений в свои процессоры семейства K8 (Opteron, Athlon 64, Athlon 64 FX). SIMD-расширения AMD64 включают в себя как традиционно присущие процессорам AMD наборы 3DNow! (начиная с K6-2) и Extended 3DNow! (начиная с Athlon), так и расширения Intel SSE/SSE2. Тем не менее, с точки зрения оптимизации ПО такое решение не многим лучше реализованного в обычных Pentium 4 (только SSE/SSE2). А связано это с тем, что подавляющее большинство компиляторов по-прежнему не умеет автоматически генерировать код, использующий расширения 3DNow!/Extended 3DNow! Единственным известным нам примером компилятора, умеющего генерировать 3DNow! код, является VectorC{PC}, который, однако, до сих пор находится в стадии бета-тестирования и все еще не имеет полноценной поддержки C++ кода.

Большим минусом SIMD-расширений архитектуры AMD64 является отсутствие принципиально новых и весьма полезных инструкций, наподобие тех, что Intel ввела в свои новые процессоры Prescott. Возьмем, к примеру, операции горизонтального сложения/вычитания векторов. Давно известно, что процессоры AMD, фактически, умеют делать такие вычисления еще со времен K6-2 (инструкция PFACC). Горизонтальное вычитание, правда, появилось несколько позже — с момента введения новых расширений Extended 3DNow! в первых процессорах Athlon (инструкция PFNACC). Но 3DNow!, как известно, имеет ряд недостатков. О некоторых из них мы уже говорили выше — это необходимость использования MMX-регистров, что исключает возможность одновременного использования 3DNow! и FPU-кода, с одной стороны, и уменьшает количество свободных регистров при совместном использовании 3DNow! и MMX-кода, с другой. Вторым недостатком является низкая разрядность MMX-регистров (64 бита), так что в них влезает всего два значения с плавающей точкой одинарной точности. Т.е. для эффективных вычислений с трех- и четырехмерными векторами необходимо использовать по два регистра на каждый. В связи с этим, для AMD было бы весьма разумным расширить функциональность этих инструкций так, чтобы они умели работать с XMM-регистрами, наподобие того, как была расширена функциональность инструкций MMX с введением расширений SSE2 в процессоры семейства Pentium 4. И даже опередить тем самым Intel, поскольку получилось бы полное подобие расширений SSE3. Действительно, давайте посмотрим, для примера, как можно уже сейчас эмулировать инструкцию HADDPS XMM0, XMM1 с помощью SIMD-расширений AMD64 (будем считать, что в XMM0 уже загружен вектор A, а в XMM1 — вектор B):

__asm
{
    // Входные данные:
    // XMM0: A.w | A.z | A.y | A.x
    // XMM1: B.w | B.z | B.y | B.x
    movhlps     xmm2, xmm0  // ? | ? | A.w | A.z
    movhlps     xmm3, xmm1  // ? | ? | B.w | B.z
    movdq2q     mm0, xmm0   // A.y | A.x
    movdq2q     mm1, xmm2   // A.w | A.z
    movdq2q     mm2, xmm1   // B.y | B.x
    movdq2q     mm3, xmm3   // B.w | B.z
    pfacc       mm0, mm1    // A.w+A.z | A.y+A.x
    pfacc       mm2, mm3    // B.w+B.z | B.y+B.x
    movq2dq     xmm0, mm0   // ? | ? | A.w+A.z | A.y+A.x
    movq2dq     xmm2, mm2   // ? | ? | B.w+B.z | B.y+B.x
    movlhps     xmm0, xmm2  // A.w+A.z | A.y+A.x | B.w+B.z | B.y+B.x
    // Выходные данные:
    // XMM0: A.w+A.z | A.y+A.x | B.w+B.z | B.y+B.x
}

Нетрудно видеть, что в этом коде реальных вычислительных операций всего две — это те самые инструкции PFACC. Все остальные операции (целых 9 штук), по сути, являются лишними — они связаны с необходимостью перемещения данных из XMM- в MMX-регистры и обратно и, как следствие, требуют наличия дополнительных свободных регистров. Заключая, можно сказать, что предпосылки к введению новых расширений в процессоры архитектуры AMD64 явно имеются, и причем уже довольно давно. Теперь остается лишь дождаться, что AMD не будет сильно отставать в этом плане от Intel (а ведь могла бы и опередить!) и все же введет их в последующие модели своих 64-разрядных процессоров.




Дополнительно

iXBT BRAND 2016

«iXBT Brand 2016» — Выбор читателей в номинации «Процессоры (CPU)»:
Подробнее с условиями участия в розыгрыше можно ознакомиться здесь. Текущие результаты опроса доступны тут.

Нашли ошибку на сайте? Выделите текст и нажмите Shift+Enter

Код для блога бета

Выделите HTML-код в поле, скопируйте его в буфер и вставьте в свой блог.