std::complexの積和の高速化(C/C++)

はじめに

既に気付いている人も多いかもしれないが、std::complexの積和の計算を約20%高速化する書き方に気付いたのでメモしておく。今担当している組込リアルタイムDSPの時間制約がキツく、僅かでも高速化したいと思っていた。この方法は手軽さの割に効果が大きい。

arg maxarg min\providecommandrecterf\providecommand\providecommand\providecommandPr

問題と解決法

std::complexの積和(例えばベクトルの内積)を計算するとき、普通は次のように書くだろう(x,y等の変数は適当に定義されているものとする)。

std::complex<float> sum = 0;
for (int i=0; i<N; ++i) {
    sum += x[i]*y[i];
}
C++

これだとx[i]*y[i]の結果が一時オブジェクトとして生成され、それがsumに加算されるという形をとり、一時オブジェクトの生成でstd::complexのコンストラクタが走るのでコストが掛かる。

しかし、std::complex実部と虚部がメモリ上で隣接して配置されるという規格を利用し、浮動小数点数の配列(長さ2)として中身を直接扱うことで一時オブジェクトの生成を省略できる。

std::complex<float> sum = 0;
for (int i=0; i<N_sim; ++i) {
    float *const sum_ptr = reinterpret_cast<float *>(sum);
    const float *const x_ptr = reinterpret_cast<const float *>(&x[i]);
    const float *const y_ptr = reinterpret_cast<const float *>(&y[i]);
    sum[0] += x_ptr[0]*y_ptr[0] - x_ptr[1]*y_ptr[1]; // real part
    sum[1] += x_ptr[0]*y_ptr[1] + x_ptr[1]*y_ptr[0]; // imaginary part
}
C++

ループ中でのポインタ定義の実行時コストは次に挙げる理由で無視できる。

  • reinterpret_castはコンパイル段階で完結する処理なので実行時コストはない。
  • float *const sum_ptr = reinterpret_cast<float *>(sum): sumのアドレスがループ中で不変であることをコンパイラは知っているからsum_ptrはループに入る前に解決され、ループ中での更新はない。sum_ptr自体のアドレスをコード中で使用していないのでRAM上に配置する必要性が無く、おそらくレジスタに割り当てられる。
  • x_ptr, y_ptrはトリップ毎に変わるが、それ以外はsum_ptrと同じ条件が成り立つのでおそらくレジスタに割り当てられる。

関数化

積和の計算の度に煩雑なコードを書くのは生産性・保守性において非効率極まりないので、強制的にインライン展開される関数として定義するのがよい。処理時間計測機能付きのサンプルコードを次に示す。

#include <chrono>
#include <complex>
#include <cstdlib>
#include <iostream>
#include <random>
#include <thread>
#include <type_traits>

/**
 * @brief Add the product of two complex numbers "x1" and "x2" to "y"
 * This function is expected to work about 20% faster than normal operation such as "y += x1*x2".
 *
 * @tparam T the number type of real and complex parts
 * @param[in] x1 "x1"
 * @param[in] x2 "x2"
 * @param[inout] y "y"
 */
template <typename T>
inline static void __attribute__((always_inline)) addProd(const std::complex<T> &x1, const std::complex<T> &x2, std::complex<T> &y) {
    const T *const x1_vec = reinterpret_cast<const T *>(&x1);
    const T *const x2_vec = reinterpret_cast<const T *>(&x2);
    T *const y_vec = reinterpret_cast<T *>(&y);
    y_vec[0] += x1_vec[0]*x2_vec[0] - x1_vec[1]*x2_vec[1];
    y_vec[1] += x1_vec[0]*x2_vec[1] + x1_vec[1]*x2_vec[0];
}

/**
 * @brief Add the product of two non-complex numbers "x1" and "x2" to "y".
 * Calling this function is completely equivalent to just writing "y += x1*x2".
 *
 * @tparam T the number type
 * @param[in] x1 "x1"
 * @param[in] x2 "x2"
 * @param[inout] y "y"
 */
template <typename T>
inline static void __attribute__((always_inline)) addProd(const T &x1, const T &x2, T &y) {
    y += x1*x2;
}

int main() {
    /* Prepares sample points */
    constexpr int N_sim = 1e8;
    std::random_device seed_gen;
    std::default_random_engine rand_engine(seed_gen());
    std::uniform_real_distribution<float> dist(-1, 1);
    std::vector<std::complex<float>> samplePoints(N_sim);
    for (int i=0; i<N_sim+1; ++i) {
        samplePoints[i].real(dist(rand_engine));
        samplePoints[i].imag(dist(rand_engine));
    }
    std::complex<float> sum;

    std::this_thread::sleep_for(std::chrono::milliseconds(1000)); // short rest

    /* normal multiplication */
    sum = 0;
    const auto t0_normal_mul = std::chrono::system_clock::now();
    for (int i=0; i<N_sim; ++i) {
        sum += samplePoints[i]*samplePoints[i+1];
    }
    const auto t1_normal_mul = std::chrono::system_clock::now();
    std::cout << "sum" << sum << std::endl; //avoid optimizing out `sum`

    std::this_thread::sleep_for(std::chrono::milliseconds(1000)); // short rest

    /* special method */
    sum = 0;
    const auto t0_specialMethod = std::chrono::system_clock::now();
    for (int i=0; i<N_sim; ++i) {
        addProd(samplePoints[i], samplePoints[i+1], sum);
    }
    const auto t1_specialMethod = std::chrono::system_clock::now();
    std::cout << "sum" << sum << std::endl; // avoid optimizing out `sum`

    /* Show result. */
    const auto dt_normal_mul = t1_normal_mul - t0_normal_mul;
    const auto dt_specialMethod = t1_specialMethod - t0_specialMethod;
    const auto dt_ms_normal_mul = std::chrono::duration_cast<std::chrono::milliseconds>(dt_normal_mul).count();
    const auto dt_ms_specialMethod = std::chrono::duration_cast<std::chrono::milliseconds>(dt_specialMethod).count();
    std::cout << "dt_ms_normal_mul: " << dt_ms_normal_mul << " ms\n";
    std::cout << "dt_ms_specialMethod: " << dt_ms_specialMethod << "ms\n";

    // CPU: Core 2 Quad Q9650, RAM: DDR2 800MHz 8GiB
    // N_sim = 1e8
    // dt_normal_mul: 411 ms
    // dt_specialMethod: 330 ms
}
C++

性能比較

次の表に示すのは、前掲のコードで処理時間を測定した結果である。繰り返し回数は結果を安定させるのに十分な値とし、10回以上実行した結果を平均した。最適化レベルは最高に設定している。

表の3行目は今使っている組込DSPであり、恐ろしいほどに効果が認められる。このプロセッサにはHWによるループ支援機能があるため、ループ内での一時オブジェクトの生成処理を取り去ったことで極めて高効率な機械語が生成されたものと考えられる。

HWOSコンパイラ繰り返し回数 処理時間 [ms]処理時間削減量[%]
CPU: Core 2 Quad Q9650
RAM: DDR2 800MHz 8GiB
Windows 10 64bit clang 11.0.1-2108normal method: 411
special method: 330
19.7
CPU: Core-i5 1035G7
RAM: DDR4 8GiB
Windows 10 64bit clang 11.0.1-2 108 normal method: 161
special method: 127
21.0
CPU: TMS320C6748
RAM: DDR2 256MiB
TI-RTOS 6.83.0.18 C6000 CGT v8.3.11 106 normal method: 763
special method: 191
75.0
処理時間計測結果

投稿者: motchy

DSP and FPGA engineer working on measuring instrument.

コメントを残す