Инициализация DSP-модуля
После того, как мы смогли корректно идентифицировать необходимые нам расширения процессора, возникает необходимость в инициализации всего DSP-модуля. Частично мы уже выполнили большую часть работы, написав код идентификации и поиска поддерживаемых расширений процессора. Теперь осталось открыть завесу над тайной, зачем всё это нам нужно. Давайте представим себе ситуацию, когда нам необходимо выполнить какую-то функцию, осуществляющую достаточно нетривиальные и интенсивные математические вычисления. Допустим, она будет иметь следующий прототип:
Очевидно, что для разных архитектур такая функция может иметь различные варианты реализации. Однако неизменным окажется то, что в любом случае потребуется написать нативную реализацию этой функции в случае, если возникнет необходимость скомпилировать код под «неоптимизированную» архитектуру. Поэтому первым делом пишем нативную реализацию этой функции в файле include/dsp/native/example.h:Код (C):
void example_function();
При этом, реализацию этой функции организуем в отдельном пространстве имён dsp::native, а сам заголовочный файл включим внутри src/dsp/native.cpp:Код (C):
#ifndef DSP_NATIVE_IMPL #error "This header should not be included directly" #endif /* DSP_NATIVE_IMPL */ void example_function() { printf("Native implementation of example function called\n"); }
Разумеется, мы предполагаем, что у нас будет аналогичная оптимизированная функция для SSE и AVX, поэтому создаём файл include/dsp/x86/sse/example.h, где помещаем свою реализацию:Код (C):
#include <dsp.h> #include <stdio.h> #define DSP_NATIVE_IMPL namespace dsp { namespace native { #include <dsp/native/example.h> } } #undef DSP_NATIVE_IMPL
И аналогично в файле include/dsp/x86/avx/example.h:Код (C):
#ifndef X86_SSE_IMPL #error "This heades should not be included directly" #endif /* X86_SSE_IMPL */ void example_function() { printf("SSE-optimized implementation of example function called\n"); }
Аналогичным образом включаем эти файлы в src/dsp/sse.cpp:Код (C):
#ifndef DSP_X86_AVX_IMPL #error "This header should not be included directly" #endif /* DSP_X86_AVX_IMPL */ void example_function() { printf("AVX-optimized implementation of example function called\n"); }
И в src/dsp/avx.cpp:Код (C):
#define X86_SSE_IMPL namespace dsp { namespace sse { #include <dsp/x86/sse/example.h> } } /* namespace dsp::sse */ #undef X86_SSE_IMPL
Однако теперь возникает закономерный вопрос – как приложение, использующее нашу библиотеку DSP, поймёт, какую функцию надо вызывать? Ведь в зависимости от архитектуры и конкретной модели процессора, одна реализация будет вести себя оптимально, другая – нет, а третья в принципе не может быть использована. Решить эту задачу можно, динамически формируя адрес оптимальной реализации функции. То есть, в области глобальных переменных мы объявим переменную-указатель на функцию, а вызывающий код будет читать этот адрес и передавать управление соответствующей реализации. Для этого в заголовочном файле include/dsp/dsp/example.h объявим соответствующий прототип переменной:Код (C):
#define DSP_X86_AVX_IMPL namespace dsp { namespace x86 { #include <dsp/x86/avx/xcr.h> } } namespace dsp { namespace avx { #include <dsp/x86/avx/example.h> } } #undef DSP_X86_AVX_IMPL
А саму переменную расположим, например, в src/dsp.cpp:Код (C):
#ifndef DSP_H_IMPL #error "This header should not be included directly" #endif /* DSP_H_IMPL */ /** This is an example function * */ extern void (* example_function)();
Однако указатель на функцию у нас изначально записан как NULL, и прежде чем иметь возможность вызвать оптимальную реализацию, необходимо этот указатель проинициализировать. Для этого тут же мы реализуем функцию инициализации DSP-модуля:Код (C):
namespace dsp { // Function prototypes void (* example_function)() = NULL; }
Как видно, нативная архитектура инициализируется в любом случае, а архитектура x86 – только в том случае, если исходный код компилируется под неё. Разумеется, файл src/dsp.cpp ничего не знает как о первой функции, так и о второй. Поэтому мы там же предварительно объявляем прототипы этих функций:Код (C):
namespace dsp { // Initialization void init() { native::init(); #ifdef ARCH_X86 x86::init(); #endif /* ARCH_X86 */ } }
Теперь объявленные прототипы обязывают нас написать реализацию native::init в src/dsp/native.cpp:Код (C):
namespace dsp { namespace native { void init(); } #ifdef ARCH_X86 namespace x86 { void init(); } #endif /* ARCH_X86 */ }
Ну а x86::init у нас уже частично реализована в src/dsp/x86.cpp, осталось только в самом конце вызвать функции инициализации расширений:Код (C):
namespace dsp { namespace native { void init() { printf("Initializing native DSP functions\n"); dsp::example_function = native::example_function; } } }
Аналогичным образом чуть ранее объявляем прототипы для функций инициализации SSE и AVX:Код (C):
namespace dsp { namespace x86 { void init() { // Detect CPU options cpu_features_t f; detect_options(&f); // Extension initialization sse::init(&options); avx::init(&options); } } }
После этого в файле src/dsp/sse.cpp пишем реализацию sse::init:Код (C):
namespace dsp { namespace sse { void init(x86::cpu_features_t *options); } namespace avx { void init(x86::cpu_features_t *options); } }
И в файле src/dsp/avx.cpp – реализацию avx::init:Код (C):
namespace dsp { namespace sse { using namespace x86; void init(cpu_features_t *options) { if (!(options->features & CPU_OPTION_SSE)) return; printf("Initializing SSE-optimized DSP functions\n"); dsp::example_function = sse::example_function; } } }
После всей проделанной работы нам необходимо где-то вызвать основную функцию инициализации – dsp::init. Открываем main.cpp и пишем в main:Код (C):
namespace dsp { namespace avx { using namespace x86; void init(cpu_features_t *options) { if (!(options->features & CPU_OPTION_AVX)) return; printf("Initializing AVX-optimized DSP functions\n"); dsp::example_function = avx::example_function; } } }
В итоге мы получили одну-единственную функцию иницализации, которую необходимо вызвать один раз, чтобы впоследствии использовать весь имеющийся набор DSP-функций, что вполне удобно. При этом, порядок инициализации самого DSP-модуля будет зависеть от целевой архитектуры и поддерживаемых ею расширений. Учитывая то, что каждую реализацию функции мы, по сути, объявили в отдельном файле трансляции, в будущем серьёзно облегчит написание файла сборки. Недостатком, с другой стороны, будет увеличение времени компиляции отдельных файлов.Код (C):
#include <dsp.h> int main() { dsp::init(); return 0; }
Машинный контекст
Процессоры Intel в расширениях SSE и AVX поддерживают работу с числами с плавающей точкой стандарта IEEE 754. При этом, сами числа с плавающей точкой можно классифицировать на следующие виды:
- нули со знаком (Signed Zeros) – +0 и -0;
- обычные нормализованные числа;
- денормализованные числа;
- знаковые бесконечности – +Inf и -Inf;
- не числа (NaN, Not a Number) двух видов – SNaN и QNaN.
Особенность реализации вычислений над числами с плавающей точкой в архитектуре x86 заключается в том, что вычисления с участием денормализованных чисел приводит к большим скоростным регрессиям. Приведу пример из личного опыта: когда я тестировал только что разработанный мною эквалайзер, он замечательно и практически не загружая CPU справлялся со своей задачей при большом количестве одновременно включённых фильтров. Но стоило только мне убрать аудиосигнал, поступающий на вход, как эквалайзер начинал поедать ресурс CPU на все 100%. «Что же могло произойти?» – задумался я. На деле оказалось всё достаточно банально: отсутствие сигнала на входе эквалайзера равносильно подаче последовательности отсчётов с нулевой амплитудой, поэтому накапливаемые вещественные числа, хранящиеся в ячейках памяти цифровых фильтров, потихоньку начинают уменьшаться, и спустя весьма короткий промежуток времени нормализованные вещественные числа становятся денормализованными. Учитывая особенности реализация вещественной математики в процессорах семейства x86, имеем вполне закономерное последствие: производительность математических операций резко падает, а утилизация ресурсов центрального процессора подскакивает до 100%. Поэтому в нашем случае следует придерживаться одного жизненно необходимого правила: в критичных ко времени выполнения мультимедиа-приложениях следует избегать денормализованных чисел в расчётах.
В этом нам помогут специальные флаги FTZ и DAZ специального 32-разрядного регистра MXCSR, позволяющего контролировать и настраивать режим работы математики в SSE и AVX. Распишем значения его битов:
- бит 0 – IE (Invalid Operation Exception) – была выполнена неверная операция;
- бит 1 – DE (Denormal Exception) – была выполнена операция с денормализованным операндом;
- бит 2 – ZE (Divide-by-Zero Exception) – попытка деления на ноль;
- бит 3 – OE (Overflow Exception) – переполнение результата;
- бит 4 – UE (Underflow Exception) – антипереполнение результата;
- бит 5 – PE (Precision Exception) – потеря точности результата;
- бит 6 – DAZ (Denormals Are Zeros) – денормализованные операнды при расчёте приравниваются к нулям со знаком;
- бит 7 – IM (Invalid Operation Mask) – маскирование исключения IE;
- бит 8 – DM (Denormal Flag Mask) – маскирование исключения DE;
- бит 9 – ZM (Divide-by-Zero Mask) – маскирование исключения ZE;
- бит 10 – OM (Overflow Mask) – маскирование исключения OE;
- бит 11 – UM (Underflow Mask) – маскирование исключения UE;
- бит 12 – PM (Precision Mask) – маскирование исключения PE;
- биты 13 и 14 – RC (Rounding Control) – способ округления результата:
- 00 – округление в сторону ближайшего, приоритет отдаётся чётному числу;
- 01 – округление в сторону отрицательной бесконечности;
- 10 – округление в сторону положительной бесконечности;
- 11 – округление в сторону нуля;
- бит 15 – FTZ (Flush To Zero) – в случае, если установлен флаг OM и произошло антипереполнение результата, результат вычисления сбрасывается в ноль с сохранением знака, и устанавливаются флаги PE и UE.
Как видим, все биты регистра MXCSR управляют режимом работы вещественной арифметики, при этом использование битов FTZ и DAZ позволяет избегать денормализованных чисел за счёт частичной потери точности вычислений. Мы готовы этим пожертвовать, так как итоговая погрешность вычислений будет меньше пиковой амплитуды шума квантования 24-битного АЦП.
Однако далеко не все процессоры поддерживают эти флаги, и предварительно необходимо убедиться, что мы действительно можем их установить. Для этого следует получить маску регистра MXCSR, которая позволяет избежать установки не поддерживаемых процессором битов.
Согласно документации от Intel, по умолчанию для маски следует использовать значение 0xffbf, что, как раз, исключает оба бита FTZ и DAZ. Для того, чтобы получить актуальную маску для целевого процессора, необходимо воспользоваться специальной инструкцией сохранения контекста SSE, MMX и FPU – FXSAVE. Эта команда поддерживается только в том случае, если функция 1 инструкции CPUID возвращает в регистр ECX значение с установленным 26-м битом. Ранее, написав код детекции расширений процессора, мы учли это, и у нас есть отдельный бит индикации поддержки этой инструкции – CPU_OPTION_FXSAVE.
Теперь коротко о том, что делает FXSAVE: инструкция сохраняет регистры данных и управляющие регистры FPU, MMX и SSE в виде определённым образом структурированных данных в участке памяти размером 512 байт, при этом адрес участка памяти должен быть выровнен по границе 16 байт. Одним из элементов такой структуры, располагающимся по смещению 0x1c относительно начала и имеющим размер 4 байта, как раз, и является актуальная маска регистра MXCSR. Однако и тут кроются определённые подводные камни: дело в том, что далеко не все процессоры поддерживают запись маски MXCSR при выполнении FXSAVE, и значение поля следует дополнительно проверять. Если окажется, что оно равно нулю, то для MXCSR следует использовать маску по умолчанию.
Теперь мы знаем всё, что нам необходимо, и заводим дополнительный файл include/dsp/x86/sse/mxcsr.h, в котором реализуем функцию чтения маски MXCSR:
Как видно из текста функции, в стеке выделяется структура fxsave, выровненная по границе 16 байт. После этого содержимое структуры заполняется нулями, после чего выполняется операция FXSAVE. Далее анализируется результат, сохранённый в структуре FXSAVE по смещению 0x1c, и если он равен нулю, то мы принимаем маску MXCSR равной MXCSR_DEFAULT (0xffbf). В противном случае мы сохраняем прочитанную из структуры FXSAVE маску в глобальной переменной mxcsr_mask.Код (C):
#ifndef CORE_X86_SSE_IMPL #error "This header should not be included directly" #endif /* CORE_X86_SSE_IMPL */ #define MXCSR_IE (1 << 0) #define MXCSR_DE (1 << 1) #define MXCSR_ZE (1 << 2) #define MXCSR_OE (1 << 3) #define MXCSR_UE (1 << 4) #define MXCSR_PE (1 << 5) #define MXCSR_DAZ (1 << 6) /* Denormals are zeros flag */ #define MXCSR_IM (1 << 7) #define MXCSR_DM (1 << 8) #define MXCSR_ZM (1 << 9) #define MXCSR_OM (1 << 10) #define MXCSR_UM (1 << 11) #define MXCSR_PM (1 << 12) #define MXCSR_ALL_MASK (MXCSR_IM | MXCSR_DM | MXCSR_ZM | MXCSR_OM | MXCSR_UM | MXCSR_PM) #define MXCSR_RC_MASK (3 << 13) #define MXCSR_RC_NEAREST (0 << 13) #define MXCSR_RC_N_INF (1 << 13) #define MXCSR_RC_P_INF (2 << 13) #define MXCSR_RC_ZERO (3 << 13) #define MXCSR_FZ (1 << 15) /* Flush to zero flag */ #define MXCSR_DEFAULT 0xffbf static uint32_t mxcsr_mask = MXCSR_DEFAULT; inline void init_mxcsr_mask() { uint8_t fxsave[512] __lsp_aligned16; uint8_t *ptr = fxsave; __asm__ __volatile__ ( // Clear FXSAVE structure __ASM_EMIT("xor %%eax, %%eax") __ASM_EMIT("mov $0x80, %%ecx") __ASM_EMIT("rep stosl") // Issue fxsave __ASM_EMIT("fxsave (%[fxsave])") // Get mask __ASM_EMIT("mov 0x1c(%[fxsave]), %%eax") __ASM_EMIT("test %%eax, %%eax") // Old processors issue zero MXCSR mask __ASM_EMIT("jnz 1f") __ASM_EMIT("mov %[mxcsr_dfl], %%eax") __ASM_EMIT("1:") // Store MXCSR mask __ASM_EMIT("mov %%eax, %[mask]") : "+D" (ptr), [fxsave] "+S" (ptr), [mask] "+m" (mxcsr_mask) : [mxcsr_dfl] "i" (MXCSR_DEFAULT) : "cc", "memory", "%eax", "%ecx" ); }
Для того, чтобы иметь возможность устанавливать значение битов FTZ и DAZ в регистре MXCSR, нам следует написать функции чтения и записи регистра MXCSR:
Отлично, теперь мы можем подключить функции управления регистром в файл src/dsp/sse.cpp:Код (C):
inline uint32_t read_mxcsr() { uint32_t result = 0; __asm__ __volatile__ ( __ASM_EMIT("stmxcsr %[result]") : [result] "+m" (result) : : "memory" ); return result; } inline void write_mxcsr(uint32_t value) { __asm__ __volatile__ ( // Clear FXSAVE structure __ASM_EMIT("and %[mask], %[value]") __ASM_EMIT("ldmxcsr %[value]") : [value] "+m" (value) : [mask] "r" (mxcsr_mask) : "cc", "memory" ); }
Разумеется, хорошим тоном будет, если в случае необходимости выполнения математических расчётов мы будем по возможности устанавливать флаги DAZ и FTZ в регистре MXCSR, а после выполнения расчётов – восстанавливать исходное состояние регистра MXCSR. Для этого введём понятие машинного контекста в файле include/dsp.h:Код (C):
#define X86_SSE_IMPL namespace dsp { namespace sse { #include <dsp/x86/sse/mxcsr.h> } } /* namespace dsp::sse */ #undef X86_SSE_IMPL
Как видно, это самая обычная структура, которая работает по принципу стека, в которую будут сохраняться все необходимые значения регистров, которые потом нужно будет восстановить. Соответственно, для работы с этой структурой объявим необходимые нам функции сохранения данных в контекст и восстановления:Код (C):
namespace dsp { #pragma pack(push, 1) typedef struct context_t { uint32_t top; uint32_t data[15]; } context_t; #pragma pack(pop) }
Как видно, start и stop – указатели на функции, которые должны быть инициализированы в ходе выполнения функции dsp::init. Делаем реализацию этих функций для нативной архитектуры:Код (C):
namespace dsp { // Start and finish types typedef void (* start_t)(context_t *ctx); typedef void (* finish_t)(context_t *ctx); /** Start DSP processing, save machine context * * @param ctx structure to save context */ extern void (* start)(context_t *ctx); /** Finish DSP processing, restore machine context * * @param ctx structure to restore context */ extern void (* finish)(context_t *ctx); }
Как видно из реализации, функция start инициализирует вершину стека, а finish ничего не делает ввиду того, что восстанавливать, особо, ничего и не надо.Код (C):
namespace dsp { namespace native { void start(context_t *ctx) { ctx->top = 0; } void finish(context_t *ctx) { } void init() { printf("Initializing native DSP functions\n"); dsp::start = native::start; dsp::finish = native::finish; dsp::example_function = native::example_function; } } }
В случае использования SSE нам потребуется сохранить адреса этих функций и переназначить на функции, которые будут изменять значение регистра MXCSR. Сами же переназначенные функции должны будут вызвать сохранённые указатели в прологе и эпилоге соответственно:
Теперь, для того, чтобы вызвать нашу example_function, мы должны следовать определённой конвенции: вызывать функцию dsp::start вначале и dsp::finish в конце:Код (C):
namespace dsp { namespace sse { using namespace x86; static dsp::start_t dsp_start = NULL; static dsp::finish_t dsp_finish = NULL; static void start(context_t *ctx) { dsp_start(ctx); uint32_t mxcsr = read_mxcsr(); ctx->data[ctx->top++] = mxcsr; write_mxcsr(mxcsr | MXCSR_FZ | MXCSR_DAZ); } static void finish(context_t *ctx) { write_mxcsr(ctx->data[--ctx->top]); dsp_finish(ctx); } void init(cpu_features_t *options) { if (!(options->features & CPU_OPTION_SSE)) return; printf("Initializing SSE-optimized DSP functions\n"); dsp_start = dsp::start; dsp_finish = dsp::finish; dsp::start = sse::start; dsp::finish = sse::finish; dsp::example_function = sse::example_function; } } }
Сборка проектаКод (C):
void do_math() { dsp::context_t ctx; dsp::start(&ctx); dsp::example_function(); dsp::finish(&ctx); } int main() { dsp::init(); do_math(); return 0; }
Осталось собрать проект. В данном случае пишем обычный Makefile, который для сборки бинарников использует отдельный каталог. Отдельное внимание уделим мейкфайлу в каталоге src/dsp:
Как видно, отдельные файлы компилируются с отдельными ключами, включающими те или иные расширенные наборы инструкций. Если мы в GCC попытаемся скомпилировать SSE-код с ключом -mavx, то компилятор автоматически заменит все SSE-инструкции на их AVX-аналоги, что, в итоге, может привести к некорректно работающему коду ввиду того, что детали реализации отдельных инструкций в AVX отличаются от деталей реализации аналогичных им SSE-инструкций. Поэтому код, использующий SSE должен компилироваться с ключами для SSE, а код, использующий AVX – с ключами для AVX.Код (Text):
FILE = $(@:$(OBJDIR)/%.o=%.cpp) ALL_IMPL = $(OBJDIR)/dsp.o NATIVE_IMPL = $(OBJDIR)/native.o X86_IMPL = $(OBJDIR)/x86.o SSE_IMPL = $(OBJDIR)/sse.o AVX_IMPL = $(OBJDIR)/avx.o SSE_INSTR_FLAGS = -mmmx -m3dnow -msse AVX_INSTR_FLAGS = -mavx # Form set of object to compile and link LINK_OBJECTS = $(NATIVE_IMPL) ifeq ($(CPU_ARCH), i586) LINK_OBJECTS += $(X86_IMPL) $(SSE_IMPL) $(AVX_IMPL) endif ifeq ($(CPU_ARCH), x86_64) LINK_OBJECTS += $(X86_IMPL) $(SSE_IMPL) $(AVX_IMPL) endif .PHONY: all all: $(ALL_IMPL) $(ALL_IMPL): $(LINK_OBJECTS) @echo " $(LD) $(notdir $(ALL_IMPL))" @$(LD) -r $(LDFLAGS) -o $(ALL_IMPL) $(LINK_OBJECTS) $(NATIVE_IMPL) $(X86_IMPL): @echo " $(CC) $(FILE)" @$(CC) -c $(CPPFLAGS) $(CFLAGS) $(INCLUDE) $(FILE) -o $(@) $(SSE_IMPL): @echo " $(CC) $(FILE)" @$(CC) -c $(CPPFLAGS) $(CFLAGS) $(SSE_INSTR_FLAGS) $(INCLUDE) $(FILE) -o $(@) $(AVX_IMPL): @echo " $(CC) $(FILE)" @$(CC) -c $(CPPFLAGS) $(CFLAGS) $(AVX_INSTR_FLAGS) $(INCLUDE) $(FILE) -o $(@)
В итоге на выходе мы получаем каталог .build, в котором располагается целевой файл dsp-utils. Запустим его:
Как видно, наш код инициализации обнаружил расширение AVX на целевой архитектуре и выбрал в качестве оптимального решения AVX-реализацию example_function.Код (Text):
.build/dsp-utils Initializing native DSP functions Initializing SSE-optimized DSP functions Initializing AVX-optimized DSP functions AVX-optimized implementation of example function called
Давайте посмотрим, как поведёт себя код на процессоре, не поддерживающем AVX:
Как видно, мы получили вполне себе ожидаемый результат.Код (Text):
.build/dsp-utils Initializing native DSP functions Initializing SSE-optimized DSP functions SSE-optimized implementation of example function called
К сожалению, компьютер, не поддерживающий SSE, сейчас найти трудно, так что будем полагать, что он корректно работает и в случае с отсутствием поддержки SSE.
Заключение
Как видно, для того, чтобы работать с SIMD, необходимо выполнить ряд важных действий, начиная с детекции расширений, поддерживаемых процессором, и заканчивая настройкой работы блока вещественной арифметики с целью оптимизации вычислений.
Я думал, как лучше всего организовать хранение примеров к данному циклу статей и пришёл к выводу, что лучше всего с этим справится система контроля версий. Поэтому я завёл отдельный Git-репозиторий для хранения исходных кодов:
https://github.com/sadko4u/simd-utils
При этом, чтобы не возникало путаницы, я решил тэговать определённый коммит к моменту выхода определённой статьи. Поэтому актуальный для данной статьи исходный код доступен по следующему адресу:
https://github.com/sadko4u/simd-utils/tree/SIMD-003
Если же вы вытащили master-ветку, то переключиться на соответствующий тег не составит большой проблемы:
Код (Text):
git checkout tags/SIMD-003
Прикладной SIMD 003: прежде, чем начать (часть 2)
Дата публикации 13 мар 2018
| Редактировалось 1 апр 2020