C++17: 動的メモリ確保とアライメント

背景

アライメントまわりのことを調べていたらC++17でaligned_allocnewの新しいオーバーロードが入っていたようで、少し規格書(N4659)と元になったP0035R4にあたってみることにした。

(最初C++2aのN4727を見て書いていたのでN4659を確認したが、内容に特に変化はない)

アライメントとは

ほとんどのアーキテクチャは、任意の位置からメモリの内容を読み込むことができない。これはアクセスできないメモリ領域があるということではなくて、メモリから取ってこれるデータの位置とサイズには限界があるということだ。例えば、一回のメモリアクセスでは16バイト分のデータを16の倍数のアドレスからしか読み込めない、という制限がある。それがユーザーのコードにどう影響するかはアーキテクチャによって異なるわけだが、一つの値を一回のメモリアクセスで取ってこれなかった場合、最善でもオーバーヘッドが生じる。

例えば、中身が{x, y, z, w}になっている8バイトのデータがあり、それをメモリから取ってきたいとする。もしこれがアクセス境界に並んでいなかった場合、アクセスは複数回生じるだろう(アーキテクチャに依るが、割り込みが発生する場合もある)。

一回目のアクセス
 v v v v
| | |x|y|z|w| | |
         ^ ^ ^ ^
    二回目のアクセス

だがこれをメモリアクセスの境界にちゃんと並べられたなら、アクセスは一回で済む。

一回目のアクセス
 v v v v
|x|y|z|w| | | | |

構造体の中にパディングが入る理由は、コンパイラがメモリを多少無駄遣いしても、構造体のメンバ、または構造体そのものへのアクセスを最適化しようと頑張るからだ。逆にメモリを全く無駄遣いできないときは、パディングを入れないようにコンパイラに伝えるか、最小限で済むようにメンバの配置を考える必要がある。

通常より大きなアライメントを指定したい状況

よくある例を挙げよう。多くのCPUがSIMD命令を持っている。これは複数の値に対して同じ命令を同時に適用できるというものだ。例えばIntel AVXは256bitの演算幅を提供しており、32bitのfloatなら8つ、doubleなら4つまでを同時に計算できる。これはイントリンシック関数を使うと

#include <immintrin.h>

__m256d v1 = _mm256_set_pd(3.0, 2.0, 1.0, 0.0);
__m256d v2 = _mm256_set1_pd(0.5);
__m256d v3 = _mm256_add_pd(v1, v2);

std::array<double, 4> result;
_mm256_storeu_pd(result.data(), v3);

// {0.5, 1.5, 2.5, 3.5}
std::cout << '{' << result[0] << ", " << result[1] << ", "
                 << result[2] << ", " << result[3] << "}\n";

のように書ける。ここで_mm256_add_pd(v1, v2)が、4つの浮動小数点数の足し算を1命令(vaddpd)で行う命令に変換される。

ただし、SIMDレジスタへのロード・ストアは、メモリ側のアドレスに制約を課す。ロード・ストアの対象は256bit (32byte)幅にアラインしている必要がある(アラインされていない場合は注意してそれなりの扱いをしなければならない)。多くのアーキテクチャは普通そんな幅のアライメントをデフォルトにしない。これはユーザーが保証しなければならないのだ。

実を言うと上の例で使用したstoreu_pdはストアする先が32byteにアラインされていない(unalignedな)場合にもデータを問題なく書き込んでくれるが、オーバーヘッドがかかる。アライメントされている前提で動くstore_pdが存在し、こちらの方が高速に動作する。速度が必要だからSIMDを書いているのであって、オーバーヘッドがかかっては本末転倒である。なのでstore_pdを使える方が望ましい。

SIMD以外にも、キャッシュラインに対応したアライメントに調整することでキャッシュヒット率を最適化するなどの用途があるようだ。

C++11 alignasの問題点

C++11にはalignasalignofstd::aligned_storagestd::alignが導入された。alignasはアライメントを(通常より大きな値に)指定できるものである。

struct alignas(32) d4 {
    double v[4];
};

このようにするとこの構造体は32byte境界にアラインされる――ただし、静的確保した場合のみ。 動的確保した領域は指定したとおりにアラインされている保証はないのだ。つまり、new d4std::vector<d4> vec;のそれぞれの要素が正しくアライメントされている保証はない。 newmallocはあらゆる型のデフォルトのアライメント要求を満たすような領域を確保するが、デフォルトより大きいアライメント要求には応える保証はない。

これは困る。これを回避するには、2つ方法があった。 1つめはメモリを必要量より大きめに確保しておいて、アライメントがあう部分を取り出してそこだけを使うという戦略で、std::alignはその用途にもってこいだ。だがこれだとメモリ管理が異様に複雑になってしまう。 2つめは_mm_malloc/_mm_freeposix_memalign_aligned_mallocなどのアライメントを保証する動的確保関数を使用することだ。だが、これらは非標準であるので、切り替えが面倒だ(それを言うなら_mm_add_psなどはアーキテクチャ依存もいいとこだが)。

C11にはstdlib.haligned_allocが入ったが、C++11には入らなかった。よってBoost.alignなどはコンパイラと環境を見て使用可能な関数をマクロで切り替えている。 言語レベルのアライメントサポートとして、これは片手落ちだ。

C++17におけるアライメントサポート

さて、C++17ではめでたく動的確保時にアライメントを保証できる機能が追加されることになった。C11に追従して<cstdlib>aligned_allocが追加され、またnewについてalign_val_tを取るオーバーロードが追加された(P0035, N4659 § 21.6)。

そのために、align_val_tがまず定義された。意図しない型変換を防ぐため、enum classになっている。ただ、これはopaque typedef相当の利用方法なので特に値が定義されているわけではない。

namespace std {
  enum class align_val_t : size_t {};
}

そして、以下のようなオーバーロードが追加された。

void* operator new(std::size_t, std::align_val_t);
void* operator new[](std::size_t, std::align_val_t);
void operator delete(void*, std::align_val_t);
void operator delete[](void*, std::align_val_t);
void operator delete(void*, std::size_t, std::align_val_t);
void operator delete[](void*, std::size_t, std::align_val_t);

align_val_tとして不適当な値を渡した場合、その動作は未定義である(§ 21.6.2)。

これらの呼ばれ方は少し変わっている。通常のnewの結果よりも大きなアライメントを要求する型についてnew Tを行った時、自動的にalign_val_t版が呼ばれ、そうでない場合は普通のバージョンが呼ばれるのである(§ 21.6.2.1 Effects、§ 21.6.2.2 Effects)。

「通常のnewの結果よりも大きなアライメントを要求する型(new-extended alignment)」は、__STDCPP_DEFAULT_NEW_ALIGNMENT__を超えるアライメント要求を持っているかどうかで判定される(§ 6.6.5)。このマクロはstd::size_t型の整数リテラルに展開され(§ 19.8)、それより大きいアライメント指定を持つ型にはnew(align_val_t(alignof(T)))が呼ばれる。定義済みマクロとして提供される理由は、標準ライブラリヘッダを何一つincludeしていなくてもこの値を知りたい可能性があるからだ。その値を知るためだけに<new>などをincludeする必要はないだろう。

要は、ユーザーはnewに関してあまり頭を悩ませる必要はなく、大きなアライメントを好きに選択すれば、あとは必要に応じて勝手に必要な方のnewが選択して解決されるのだ。素晴らしい。

pitfall

ところで、new/deleteはクラスでオーバーロードすることができる。もし再利用しようとしているC++17以前のコードで、クラスが専用newを持っていたとしたら、そしてそのクラスが通常のnewよりも大きなアライメントを要求するなら、どうなるべきだろうか。

ユーザー定義newは中でメモリ確保以外の副作用を持てるので、もしそれが呼ばれず標準定義align_val_t版が呼ばれた場合、知らぬ間にプログラムが壊れてしまう。これは恐怖である。このことを知らないプログラマは、自分のプログラムを完全に制御できていると思いながら、実際には呼ばれることのないnew演算子を定義しているかもしれないのだ。このような状況はできる限り避けねばならない。今回の場合、クラス特異的なユーザー定義newが提供されている場合はそれが呼ばれる必要がある。

呼び出し解決の順序は以下のようになる。

  1. クラス特異的でアライメント要求ありnew
  2. クラス特異的でアライメント要求無しnew
  3. グローバルでアライメント要求ありnew
  4. グローバルでアライメント要求なしnew

クラス特異的なnewは、アライメント要求に関わらずグローバルなnewより優先される。アライメント要求の有無に関しては、ある方がない方より優先される。