C++17の新機能を試す〜その1「3次元版hypot」
2018/02/14
  • このエントリーをはてなブックマークに追加


はじめに

カブクの甘いもの担当の高橋憲一です。

カブクでは3Dモデルデータの各種解析をしたり、プレビュー用画像のレンダリングをするエンジンを高速実行するためにC++で開発しています。私が2014年にカブクに入って3Dモデル解析エンジンの開発を始めた当時はまだC++11を使い始めたばかりでしたが、早いものでC++の規格はC++14、そしてC++17と進化してきました。

C++17は2017年に規格が制定されたものですが、かなりの部分の機能が使えるようになってきたので、今後書いていくコードにどのように取り入れていくか少しずつ見ていきたいと思います。

C++今昔

私が始めてC++を触ったのは最初の規格であるC++98が制定される前、


for (int i = 0; i < 10; i++)

この書き方すらできなかった頃です(正確にはgccは対応しているけど別のベンダー純正のコンパイラは対応していないとか、そんな状況でした)。
どういうことかというと、ループのカウンタである変数の int i を for のカッコの初期化のところで宣言し、for ループのスコープの中でのみ有効な変数とするということができなかったのです。今となっては信じられないほどですが...


int i;
for (i = 0; i < 10; i++)

というように書かなければなりませんでした。
(まだC++プログラマとして駆け出しの頃で、当時むさぼるように読んでいた書籍「Effective C++」のサンプルコードの文字列に"Hello World"の代わりにnuqneHとクリンゴン語で書いてあるのを見つけて喜んでいたことを思い出します。)

月日は経ち、C++11では


std::vector<int> hogehoge;
int sum = 0;
for (auto a: hogehoge) {
    sum += a;
}

というようなループも書けるようになったことには隔世の感がありました。

閑話休題。昔話はこれくらいにして...
綺麗なコード、良いコード、堅牢なコードを書いていくためには新しい機能もどんどん取り入れていきたいところです。


3次元hypot


https://ezoeryou.github.io/cpp17book/ 辺りを見ていると実にたくさんの追加機能がありますが、今回は3次元版hypotを見てみます。
これまでも`hypot(x, y)`関数は存在しましたが、C++17でx, y, zの3つの要素の引数を受け取る3次元版が追加されました。
いわゆる三平方の定理、

X2+Y2+Z2
を計算してくれるものです。カブクで開発、運用しているしている3Dモデル解析&レンダリングエンジンではこのような3次元ベクトルのX, Y, Zの各要素の合成成分、すなわちベクトルの長さを求めるような計算は至る所に出てきます。
もちろん、3次元ベクトルを保持、計算するクラスは独自に実装したものを使っているのですが、もしかしたら高度に最適化された実装が施されていて自前の実装より良いパフォーマンスを叩き出してくれるのではないかという淡い期待をもちつつ、これを使うことによるコードの変化と実行時のパフォーマンスを比較してみたいと思います。

現在のコードを抜粋すると下記のような感じになります。


#include <cmath>

class Vector3D {
public:
    Vector3D(double x, double y, double z);
    double length();

private:
    double x, y, z;
};

double Vector3D::length()
{
    return sqrt(x * x + y * y + z * z);
}

このlength()のメンバ関数の中をhypotを使って書くと


double Vector3D::length()
{
    return std::hypot(x, y, z);
}

となり、わずかではありますが見た目はシンプルになります。
実行時のパフォーマンス計測は、それぞれのstd::hypotを使った場合と、使わなかった場合をそれぞれ値を少しずつ変えながら、差を分かりやすくするために以下のようなコードで1億回実行した際の時間を計測しました。


double getSec()
{
    struct timeval tv;
    gettimeofday(&tv, NULL);
    return tv.tv_sec + tv.tv_usec * 1e-6;
}

int main()
{
    Vector3D vec(1.0, 2.0, 3.0);
    double s = getSec();
    const int loopNum = 100'000'000;
    double length;
    for (int i = 0; i < loopNum; i++) {
        vec.x = i;
        length = vec.length();
    }
    double e = getSec();
    std::cout << loopNum << " times hypot --- " << e - s << "[sec]" << std::endl;
}

ここでloopNumに代入している`100'100'000`という書き方ははC++14から導入されていたもので、区切り文字を入れることで数値の桁を見やすくしてくれます。実際の値には影響はありません。

hypot使用、不使用による実行時間の計測結果

実行時間(秒)
最適化あり (-O2)最適化なし
hypot使用0.3638324.04141
hypot不使用0.082931.44227


実行環境 MacBook Pro 15inch (2017)
- CPU Core i7 2.9GHz
- メモリ 16GB
- OS macOS Sierra上のDockerでUbuntu 17.04を実行
- コンパイラ gcc 7.0.1
- 最適化 (-O2) ありと最適化なしの場合に分けて計測

最適化ありの場合のコンパイルオプション

g++ -std=c++17 -O2

結果はhypotを使わない場合の方が速く、その差は約4倍という開きがあります。
なぜそのような結果になるのかgccでの実装を見てみると

https://gcc.gnu.org/git/ より)


template<typename _Tp>
    inline _Tp
    __hypot3(_Tp __x, _Tp __y, _Tp __z)
    {
    __x = std::abs(__x);
    __y = std::abs(__y);
    __z = std::abs(__z);
    if (_Tp __a = __x < __y ? __y < __z ? __z : __y : __x < __z ? __z : __x)
        return __a * std::sqrt((__x / __a) * (__x / __a)
                            + (__y / __a) * (__y / __a)
                            + (__z / __a) * (__z / __a));
    else
        return {};
    }

inline double
hypot(double __x, double __y, double __z)
{ return std::__hypot3<double>(__x, __y, __z); }

各要素を二乗してsqrtを取ることに変わりはないのですが、
まず、各要素の絶対値を求めて、最も大きな要素を係数aとして0除算を考慮しつつ各要素を割ってから2乗して平方根を取り、再びaを掛けて戻す、ということをしています。こうすることによって、絶対値が大きい値を2乗することで起こりうる浮動小数点の指数部の桁あふれを防ぐための対策がなされており、高パフォーマンスのためではなく、より精度の高い値をもとめるための実装だということが分かります。

例えば2の512乗という値は、それ自体はdoubleで扱える範囲の値ですが2乗するとinf(桁あふれ)を起こします。


double x = 0x1p512;
Vector vec(x, 2.0, 3.0);
std::cout << vec.length() << std::endl;
というコードを実行すると、
- hypotを使った場合 ... 2.3223e+154
- hypotを使わない場合 ... inf
という結果になります。
(ここで x に代入している 0x1p512 は 1 * 2の512乗を表しています。これはC++17で追加された16進数浮動小数点数リテラルで、16進で仮数部の値を書いた後、pに続いて10進で指数部の値を書きます)

まとめ

というわけで、

3次元版hypot ... 現時点ではエンジンの実装には使わない
- 精度の高い計算結果は魅力的だが、速度重視のポリシーを捨ててまで採用するほどではない
- 今後の実装がどう変化していくか引き続きチェックは続ける

という結論になりました。
何事も試してみないとわからないことがあります。

こんな風にいろいろ試行錯誤しながらC++で高速実行するためのコードをガリガリ書きたいというエンジニアの方がいましたら、ぜひお知らせください。