メインコンテンツまでスキップ

SIMDRegisterクラスを使用した最適化

📚 Source Page

プロセッサの並列性を利用して、単一命令複数データ計算を実行。並行処理を導入することなく、オーディオ・アプリケーションを最適化できます。

レベル:上級

プラットフォーム:Windows, macOS, Linux

クラス: dsp::SIMDRegister, dsp::IIR, dsp::ProcessorDuplicator AudioDataConverters, dsp::AudioBlock, HeapBlock

スタート

このチュートリアルのデモ・プロジェクトのダウンロードはこちらから:PIP|ZIP.プロジェクトを解凍し、最初のヘッダーファイルをProjucerで開く。

このステップでヘルプが必要な場合は、以下を参照してください。Tutorial: Projucer Part 1: Getting started with the Projucer.

デモ・プロジェクト

このデモ・プロジェクトでは、読み込んだオーディオ・ファイルをIIRフィルターを通して再生し、試聴時に処理・変更することができます。この最適化の目的は、同じIIRフィルターにSIMD命令セットを使用することで、CPUパワーをどれだけ軽減できるかを確認することです。

デモプロジェクトのウィンドウ
デモプロジェクトのウィンドウ
注記

ここで紹介するコードは、大まかに以下のものと似ている。SIMDRegisterDemoDSPデモより

SIMD命令

SIMDとは "Single Instruction Multiple Data"(単一命令複数データ)の略で、最新のCPUが複数のレジスタに数値をロードして同じ計算を一度に実行することで、1つの命令をデータセットに適用できる方法を指す。デジタル信号処理の世界では、このタイプの並列処理がMIMD(Multiple Instruction Multiple Data)のような他のタイプよりも好まれます。オーディオスレッドが他のスレッドとデータの取り合いにならないようにすることが最も重要であり、オーディオ処理ではほとんどの場合、命令の順序は同じに保たれるべきである。

SIMDは個々のデータではなく、データストリームのベクトル上で動作するため、オーディオバッファからデータブロックを受け取ることに慣れているオーディオ処理にさらに適している。SIMDはまた、DSPアルゴリズムで非常に一般的な、複数のデータポイントに対して同じスカラー演算を適用する必要がある場合にも威力を発揮する。

一般的なコードを最適化するプロセスは、現在ではコンパイラが自動的に行うのが普通だが、DSPアルゴリズムのベクトル化は必ずしも簡単ではない。コンパイラーは、正しく最適化するためにアルゴリズムが何をしようとしているのか、必ずしも人間的に理解できるとは限りません。したがって、この作業は通常手動で行われ、SIMDRegisterクラスはJUCEでこれを行うための便利なツールです。

SIMDRegisterクラスが便利なのは、さまざまなタイプのプロセッサに対応できるからです。CPUによって、レジスタのサイズや数が異なることがあり、すべてのCPUベンダーを考慮するのはすぐに難しくなります。これはすべてSIMDRegisterクラスが処理してくれるので、あとはアルゴリズムでベクトル化したい命令セットを指定するだけです。

SIMDRegisterクラスの使い方は比較的簡単で、基本的にプリミティブ型の置き換えとして機能します。次のような簡単なコード例を見てみましょう:

float calculateDSPEffect (float x,
float y)
{
auto z = x + (y * 2.0f);

return z;
}
xfloat xDefinition juce_UnityPluginInterface.h:200
yfloat float yDefinition juce_UnityPluginInterface.h:200

プリミティブ型をSIMDRegisterクラスでラップするだけで、簡単にベクトル化できます:

SIMDRegister calculateDSPEffect (SIMDRegister x,
SIMDRegister y)
{
auto z = x + (y * 2.0f);

return z;
}

DSPコードでは、条件文は非常に遅く、一般に分岐はできるだけ避けるべきである。したがって、次の例はSIMD最適化の良い候補です:

float calculateDSPEffect (float x,
float y)
{
auto z = (x > y ? x + (y * 2.0f) : y);

return z;
}

幸いなことに、SIMDRegisterクラスは、以下のように正しい結果を選択できるビットマスクを提供してくれる:

SIMDRegister calculateDSPEffect (SIMDRegister x,
SIMDRegister y)
{
auto mask = SIMDRegister::greaterThan (x, y);
auto z = ((x + (y * 2.0f)) & mask) + (y & (~mask));

return z;
}

このチュートリアルでは、SIMDを使ってIIRフィルターを最適化します。

IIRフィルター

の中でSIMDTutorialFilterクラスでは、まずここに示すように、フィルタのパラメータなどのメンバ変数を定義する:

    dsp::ProcessorDuplicator, dsp::IIR::Coefficients> iir;

ChoiceParameter typeParam { { "Low-pass", "High-pass", "Band-pass" }, 1, "Type" };
SliderParameter cutoffParam { { 20.0, 20000.0 }, 0.5, 440.0f, "Cutoff", "Hz" };
SliderParameter qParam { { 0.3, 20.0 }, 0.5, 0.7, "Q" };

std::vector parameters { &typeParam, &cutoffParam, &qParam };
double sampleRate = 0.0;
};

ProcessorDuplicator内にIIRフィルター・オブジェクトを定義することで、各チャンネルのprepare()、process()、reset()関数を個別に呼び出す心配がなくなり、モノラル・プロセッサーを自動的にマルチチャンネル・プロセッサーに変換することができる。また、通過フィルタのタイプ、カットオフ周波数、シャープネスQなど、フィルタのパラメータも定義します。

updateParameters()関数では、画面上のコントロールが変更されたときにフィルターのパラメーターが更新されるようにしています:

    void updateParameters()
{
if (sampleRate != 0.0)
{
auto cutoff = static_cast (cutoffParam.getCurrentValue());
auto qVal = static_cast (qParam.getCurrentValue());

switch (typeParam.getCurrentSelectedID())
{
case 1: *iir.state = *dsp::IIR::Coefficients::makeLowPass (sampleRate, cutoff, qVal); break;
case 2: *iir.state = *dsp::IIR::Coefficients::makeHighPass (sampleRate, cutoff, qVal); break;
case 3: *iir.state = *dsp::IIR::Coefficients::makeBandPass (sampleRate, cutoff, qVal); break;
default: break;
}
}
}

DSPモジュールは、makeLowPass()、makeHighPass()、makeBandPass()関数をそれぞれ使用することで、3つのフィルタータイプの便利な係数を提供してくれる。

prepare()関数では、ProcessSpecオブジェクトからサンプル・レートを設定し、ローパス・フィルタのデフォルトの場合のIIRフィルタ係数を設定し、処理コンテキストに関する情報を使用してprepare()関数を使用してフィルタを準備する:

    void prepare (const dsp::ProcessSpec& spec)
{
sampleRate = spec.sampleRate;

iir.state = dsp::IIR::Coefficients::makeLowPass (sampleRate, 440.0);
iir.prepare (spec);
}

process()関数の中で、1つのブロックが入力と出力の両方に使われるコンテキストで、フィルター上のprocess()関数を呼び出す:

    void process (const dsp::ProcessContextReplacing& context)
{
iir.process (context);
}

最後に、reset()関数でフィルターにresetを呼び出して、フィルターをリセットする:

    void reset()
{
iir.reset();
}

では、このIIRフィルターの最適化を始めよう。

SIMD最適化IIRフィルター

IIRフィルターのコードを最適化する前に、SIMDがシステムで利用可能であることを確認する必要があります。そのためにはJUCE_USE_SIMDマクロを使って、フィルター実装全体をこのようにラップすることで、SIMDマシンで開発しているかどうかをチェックできる:

#if JUCE_USE_SIMD

//==============================================================================
template
static T* toBasePointer (dsp::SIMDRegister* r) noexcept
{
return reinterpret_cast (r);
}

constexpr auto registerSize = dsp::SIMDRegister::size();

struct SIMDTutorialFilter
{
};

#endif

まず、IIRフィルターのメンバー変数と、AudioBlockとHeapBlockの下にある処理を容易にするためのオブジェクトを使用する。SIMDTutorialFilterクラスである:

    dsp::IIR::Coefficients::Ptr iirCoefficients;                 // [1]
std::unique_ptr>> iir;

dsp::AudioBlock> interleaved; // [2]
dsp::AudioBlock zero;

juce::HeapBlock interleavedBlockData, zeroData; // [3]

ChoiceParameter typeParam { { "Low-pass", "High-pass", "Band-pass" }, 1, "Type" };
SliderParameter cutoffParam { { 20.0, 20000.0 }, 0.5, 440.0f, "Cutoff", "Hz" };
SliderParameter qParam { { 0.3, 20.0 }, 0.5, 0.7, "Q" };

std::vector parameters { &typeParam, &cutoffParam, &qParam };
double sampleRate = 0.0;

IIR係数をポインタとして定義し、SIMDRegisterクラスを使ってフィルタを一意なポインタとして定義し、サンプル・タイプをラップします。[1].サンプル・タイプをラップするためにSIMDRegisterクラスを使用して、インターリーブされたデータを格納するAudioBlockを作成し、後で出力ブロックを格納するために使用するゼロ・データ用に別のAudioBlockを作成します。[2].割り当てHeapBlock対応するAudioBlockオブジェクトと、SIMDRegisterベクトルの要素数を持つチャンネルポインタを保持するためのオブジェクト[3].

prepare()関数で、前と同じようにサンプル・レートを設定し、フィルタのデフォルト係数を計算します。[4].サンプル・タイプと先に定義した係数をSIMDRegisterラッパーで包んだ新しいIIRフィルタをインスタンス化して、フィルタをリセットします。[5]以下の通りである:

    void prepare (const dsp::ProcessSpec& spec)
{
sampleRate = spec.sampleRate; // [4]

iirCoefficients = dsp::IIR::Coefficients::makeLowPass (sampleRate, 440.0f);
iir.reset (new dsp::IIR::Filter> (iirCoefficients)); // [5]

interleaved = dsp::AudioBlock> (interleavedBlockData, 1, spec.maximumBlockSize);
zero = dsp::AudioBlock (zeroData, dsp::SIMDRegister::size(), spec.maximumBlockSize); // [6]

zero.clear();

auto monoSpec = spec;
monoSpec.numChannels = 1;
iir->prepare (monoSpec); // [7]
}

インターリーブデータとゼロデータ用のAudioBlockオブジェクトを作成する。HeapBlock先に定義したオブジェクト[6].インターリーブされたデータブロックは1つのチャネルしか必要とせず、最大ブロックサイズはコンテキスト情報から取得される。ゼロ・データ・ブロックはSIMDRegisterベクトルのサイズを取り、処理前にクリアされる。フィルタは、現在のコンテキスト情報に基づいてチャネル数をモノラルに減らすことで準備される。[7]マルチチャンネルサンプルは後でインターリーブされ、1つのチャンネルとして処理されるからである。

最後に、process()関数の中で、最適化された処理のために、以下のようにサンプルをインターリーブする:

    void process (const dsp::ProcessContextReplacing& context)
{
jassert (context.getInputBlock().getNumSamples() == context.getOutputBlock().getNumSamples());
jassert (context.getInputBlock().getNumChannels() == context.getOutputBlock().getNumChannels());

const auto& input = context.getInputBlock(); // [9]
const auto numSamples = (int) input.getNumSamples();

auto inChannels = prepareChannelPointers (input); // [10]

using Format = juce::AudioData::Format;

juce::AudioData::interleaveSamples (juce::AudioData::NonInterleavedSource { inChannels.data(), registerSize, },
juce::AudioData::InterleavedDest { toBasePointer (interleaved.getChannelPointer (0)), registerSize },
numSamples); // [11]

iir->process (dsp::ProcessContextReplacing> (interleaved)); // [12]

auto outChannels = prepareChannelPointers (context.getOutputBlock()); // [13]

juce::AudioData::deinterleaveSamples (juce::AudioData::InterleavedSource { toBasePointer (interleaved.getChannelPointer (0)), registerSize },
juce::AudioData::NonInterleavedDest { outChannels.data(), registerSize },
numSamples); // [14]
}
  • [8]まず、入力ブロックと出力ブロックのサンプル数とチャンネル数が同じであることを確認してください。
  • [9]次に、入力ブロックと処理するサンプル数を取得する。
  • [10]SIMDRegisterの各チャンネルについて、そのチャンネルが入力チャンネルであるかどうかをチェックし、チャンネルポインタを対応するHeapBlock.そうでない場合は、出力チャネルであることを意味し、ゼロ・データ・チャネル・ポインタをコピーする。
  • [11]チャンネル・ポインターをコピーして、異なるチャンネルのすべてのサンプルをインターリーブする。HeapBlockをインターリーブしたAudioBlockに挿入し、サンプル数とチャンネル数をSIMDRegisterのサイズとして指定します。
  • [12]サンプル・タイプのSIMDRegisterラッパーを使用したシングル・ブロック・コンテキストで、インターリーブされたデータを使用して、フィルターでオーディオを処理します。
  • [13]次に、すべての入力チャンネルについて、出力ブロックのチャンネルポインタを、対応するHeapBlock.
  • [14]最後に、インターリーブされたAudioBlockからチャンネルポインタにコピーすることで、異なるチャンネルのすべてのサンプルをデインターリーブします。HeapBlockで、サンプル数とチャンネル数を SIMDRegister のサイズとして指定します。

フィルタのreset()関数はどちらの場合も同じままであり、最適化は完了した。

updateParameters()関数を次のように更新して、新しい係数ポインタを考慮すればよい:

    void updateParameters()
{
if (sampleRate != 0.0)
{
auto cutoff = static_cast (cutoffParam.getCurrentValue());
auto qVal = static_cast (qParam.getCurrentValue());

switch (typeParam.getCurrentSelectedID())
{
case 1: *iirCoefficients = *dsp::IIR::Coefficients::makeLowPass (sampleRate, cutoff, qVal); break;
case 2: *iirCoefficients = *dsp::IIR::Coefficients::makeHighPass (sampleRate, cutoff, qVal); break;
case 3: *iirCoefficients = *dsp::IIR::Coefficients::makeBandPass (sampleRate, cutoff, qVal); break;
default: break;
}
}
}
注記

この修正版のソースコードはSIMDRegisterTutorial_02.hファイルにある。

概要

このチュートリアルでは、SIMDRegisterクラスを使ってDSPコードを最適化する方法を学びました。特に

  • SIMD命令の利点を学ぶ
  • サウンドファイルをIIRフィルターで処理
  • SIMDRegisterクラスを使ってIIRフィルタを最適化

参照