Прикладной SIMD 001: введение (часть 3)

Дата публикации 16 фев 2018 | Редактировалось 19 фев 2018
Специфика программного кода

Учитывая то, что плагины в первую очередь разрабатываются для использования в некоторой DAW (Digital Audio Workstation, цифровая звуковая рабочая станция), фреймы принято делить на отдельные моно-каналы. Благодаря этому значительно упрощается их коммутация, обработка и микширование в DAW (далее будет использоваться более общий и употребимый термин — хост (host)). Иными словами, стереоканал — это просто совокупность из двух моно-каналов. Каждый канал ассоциируется с некоторым последовательным буфером в памяти, в котором хранятся сэмплы в хронологическом порядке их захвата (capturing). Когда необходимо вызвать процедуру обработки, плагину сообщаются адреса буферов всех входных каналов, или входов (inputs), и всех выходных каналов, или выходов (outputs). Помимо этого, плагину также передаётся количество сэмплов в каждом буфере, которое зачастую коррелирует с размером периода. В качестве примера приведём описание типа указателя на функцию обработки для VST 2.4 из официального SDK:

Код (C):
  1.  
  2. // ...
  3. typedef void (VSTCALLBACK *AEffectProcessProc) (AEffect* effect, float** inputs, float** outputs, VstInt32 sampleFrames);
  4. // ...
  5.  
В данном случае effect — это указатель на управляющие данные плагина в памяти, или инстанс (instance) плагина, inputs — массив указателей на буферы входов, outputs — массив указателей на буферы выходов, а sampleFrames — количество сэмплов в каждом буфере.

И, как альтернативу, рассмотрим функцию обработки стандарта LV2:

Код (C):
  1.  
  2. typedef struct _LV2_Descriptor {
  3.     // ...
  4.     void (*run)(LV2_Handle instance, uint32_t sample_count);
  5.     // ...
  6. } LV2_Descriptor;
  7.  
Как видно из этой функции, instanceхэндл (handle) плагина, а sample_count — количество семплов в каждом буфере. При этом, указатели на буферы передаются предварительным вызовом функции connect_port:

Код (C):
  1.  
  2. typedef struct _LV2_Descriptor {
  3.     // ...
  4.     void (*connect_port)(LV2_Handle instance,
  5.                          uint32_t   port,
  6.                          void *     data_location);
  7.     // ...
  8. } LV2_Descriptor;
  9.  
В этой функции, как и прежде, instance — хэндл плагина, port — номер порта (которым в том числе может быть вход или выход), а data_location — указатель на буфер, хранящий данные.

Как видно из примера, интерфейсы разные, но подход к решению задачи общий: для обработки звука предварительно подготавливаются все необходимые данные, после чего вызывается callback-функция, осуществляющая их обработку. Здесь следует сделать два очень важных замечания, которые необходимо учитывать в будущем:

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

Замечание второе. Хост может заниматься оптимизацией объёмов используемой памяти и, таким образом, передавать одинаковые указатели на буфер для соответствующих входа и выхода плагина. Иными словами, нельзя рассчитывать на то, что все переданные указатели на буферы входа и выхода будут различными.

Следует отметить, что callback-функции вызываются из потоков, зачастую имеющих приоритет реального времени и работающих в окружении реального или близком к реальному времени. Иными словами, любая задержка в вызове callback-функции приводит к рассинхронизации потоков и, как следствие, потерям или дублированию данных. Это проявляется в виде щелчков, треска, кратковременного пропадания сигнала или, наоборот, периодического зажёвывания аудиосигнала. Разумеется, это приводит к плохому результату на выходе, если изначально этот треск или зажёвывания не были задуманы как своеобразный художественный ход при создании фонограммы. Поэтому давайте посмотрим, сколько времени на обработку у нас есть в так называемом режиме записи и режиме микширования.

Допустим, мы осуществляем живое концертное выступление с частотой дискретизации 48 кГц и размером периода 64 сэмпла, а микширование осуществляем всё с той же частотой дискретизации, но размером периода 512 сэмплов:

[math]t_{rec}=64/48000=1.3(3)[/math] миллисекунды.

[math]t_{mix}=512/48000=10.6(6)[/math] миллисекунд.

Иными словами, в режиме микширования у нас восьмикратный запас по времени по сравнению с режимом живого концертного выступления. Также необходимо отметить, что при обработке звука есть ещё так называемый оффлайн-режим (offline mode), который подразумевает, что данные могут спокойно обрабатываться неограниченное количество времени, и только после их обработки они будут пригодны для дальнейшего использования.

Однако разумным будет проектировать плагин с расчётом на худший возможный случай, а именно когда у нас всего около миллисекунды времени на выполнение всех возможных вычислений. Учитывая то, что код должен выполняться быстро и исключать какие-либо задержки, сразу следует исключить в коде функции обработки использование:
  • Операций выделения и освобождения динамической памяти.
  • Операций чтения/записи данных в файл, пайп, сокет и т.д.
  • Многопоточности и сложных объектов синхронизации: мьютексов, семафоров, критических секций и т.п.
  • Иных функций и методов, которые могут приводить к неявным блокировкам или ожиданиям синхронизации с другими потоками.

При этом, вполне себе допустимо выделение памяти на стеке и использование атомарных операций с памятью (atomics), если они, опять же, не будут использованы в контексте цикла ожидания захвата блокировки. Иными словами, можно использовать любую функцию, у которой можно оценить максимальное время её выполнения.

Учитывая это, на инструментарий для разработки плагинов накладывается ряд жёстких ограничений. Да, можно написать плагин и на Java, и на Python, и на С#, однако ни один из этих инструментов не сможет полностью гарантировать строгого соблюдения режима реального времени прежде всего из-за активного (и зачастую неявного) использования динамической памяти и сборщика мусора (Garbage Collector, GC). Поэтому при разработке плагинов используют более низкоуровневые и более поддающиеся контролю средства с ручным механизмом управления памятью — это по большей части C, C++ и ассемблер.

И, если с языком C всё достаточно просто и понятно, так как он изначально разрабатывался так, чтобы любую его инструкцию можно было в уме перевести в машинный код, то касательно C++ в коде, работающем в реальном времени, придётся пойти на ряд существенных ограничений, а именно:
  • Не использовать какие-либо операции выделения памяти: new, delete, malloc, free, realloc запрещены.
  • Не использовать исключения. Оценить время обработки исключения или предугадать его генерацию сторонним кодом также затруднительно.
  • Минимизировать использование шаблонов. Зачастую попытки абстрагироваться от данных и реализовать какие-либо алгоритмы, работающие с абстрактными данными, приводят к достаточно посредственной по производительности кодогенерации. Этот вопрос я бы хотел детально рассмотреть в отдельной статье, посвящённой оптимизации кода.
  • Избегать перегрузки операторов. Перегрузка операторов скрывает реализацию операторов от нашего взора, и, как следствие, алгоритм выполнения той или иной операции становится неочевидным.
  • Не использовать STL. Реализация STL разнится от компилятора к компилятору, поэтому сложно оценить временные и ресурсные факторы, а именно сколько раз определённый метод будет осуществлять выделение памяти на куче и будет ли при этом использовать какие-либо примитивы синхронизации.
  • Избегать RAII и сторонних автоматических объектов на стеке, так как нет гарантии, что они не полезут в кучу.
    Таким образом, необходимо соблюдать определённые правила, чтобы получить совместимый с реальным временем код, работающий в достаточно жёстких временных рамках.

Переносимость
Теперь следует немного поговорить о переносимости (portability) кода. На данный момент нишу звуковой индустрии в сегменте персональных компьютеров в качестве платформы (platform) для приложений занимают два семейства операционных систем — производства Microsoft (Windows) и Apple (MacOS), работающих под архитектурой x86 (так далее я буду называть обе архитектуры i386 и x86_64). Также в этот сегмент в догоняющем темпе, но небезуспешно пытается вклиниться семейство дистрибутивов GNU/Linux. Поэтому конечный конкурентоспособный продукт должен быть рассчитан на то, что сможет работать по крайней мере под Windows и MacOS, ну а в совсем идеальном случае — и под GNU/Linux. Я же, как человек, начавший разработку именно из-под GNU/Linux, по крайней мере вынужден учитывать тот факт, что код должен быть написан так, чтобы потом его можно было с лёгкостью скомпилировать под Windows и MacOS.

Что касается архитекутры (architecture), то в нашем случае доминирует семейство архитектур x86, однако всё больше и больше завоёвывают рынок мобильные устройства, которые реализованы, например, на ARM или MIPS. Учитывая то, что гаджеты также становятся всё более популярными среди музыкантов, растёт рынок приложений для мобильных устройств. Поэтому помимо всего прочего код должен быть написан так, чтобы он был легко переносим на не только на другую платформу, но и на другую архитектуру.

Применимость SIMD
Как видим из всего ранее сказанного, наша область разработки достаточно специфичная. Будет ли применима технология SIMD в этой сфере?

Для этого следует сначала вспомнить, почему появился и для чего нужен SIMD.

Применительно к процессорам Intel первые SIMD-инструкции появились в процессорах Pentium MMX и были реализованы как набор MMX-команд (MultiMedia eXtension, мультимедийное расширение) в первой половине 1990-х годов. Мотивировано это было тем, что человеческие потребности в качестве мультимедиа-контента росли, но современные по тем меркам процессоры по скорости выполнения инструкций самостоятельно не справлялись с поставленными задачами. Это был набор команд, позволяющих одновременно выполнять однотипные арифметические операции над несколькими парами целых чисел примерно за то же время, что и выполнение «традиционных» арифметических операций, что позволило значительно повысить производительность обработки мультимедиа-контента.

В наше же время микроэлектроника уткнулась в другую существенную проблему: гонка тактовых частот процессорных ядер закончилась по вполне логичной причине: нельзя добиться стабильной работы транзисторов на более высоких частотах без усложнения технического процесса производства интегральных схем. Под усложнением техпроцесса подразумевается уменьшение размеров самого транзистора на кристалле, которое также имеет свой предел: современные затворы транзисторов высокотехнологичных процессоров уже имеют толщину в несколько атомов, и дальнейшее их уменьшение сильно сказывается на стабильности работы двоичных логических ячеек. Поэтому повышать производительность процессоров становится возможным только следующими путями:
  • Увеличение количества ядер процессора на одном кристалле и решение проблем синхронизации и кэширования памяти ядрами. Как следствие повышается производительность всей системы в целом.
  • Усложнение набора команд процессора путём введения команд, которые работают сразу с значительно большими объёмами данных — SIMD — и решение проблем разрастания элементной базы. Как следствие, повышается индивидуальная вычислительная производительность каждого ядра.

Иными словами, технология SIMD сейчас очень востребована, и введение таких расширений в архитектуру x86, как SSE, SSE2, SSE3, SSE4.x, AVX, FMA, AVX2, AVX512 и т.д. тому подтверждение.

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

Заключение

В данной статье была кратко освещена область, которую так или иначе будут затрагивать последующие статьи. Теперь мы имеем достаточное представление о том, что из себя представляет несжатый цифровой аудиопоток, какими характеристиками он обладает, и почему эти характеристики могут принимать именно такие значения. Далее в статьях мы ограничимся только семейством архитектур Intel x86 и платформой GNU/Linux, однако будем стараться писать максимально переносимый код на языке C++ с использованием ассемблерных вставок.

1 2.090
SadKo

SadKo
Владимир Садовников

Регистрация:
4 июн 2007
Публикаций:
8