[C++]トリビアルってトリビアル?

別のことを調べていたらなぜか出来上がっていたメモです・・・

ABIとtriviality

型(の特殊メンバ関数)がトリビアルであることは、ABIにとって重要です。

例えば、型Ttrivially default constructibleであればT t;のような変数宣言時に初期化処理を省略することができ、trivially destructibleであればtの破棄時(スコープ外になる時)にデストラクタ呼び出しを省略できます。この2つのトリビアル性はstd::vectorなどコンテナに格納した際にも活用されます。そして、型Tのコピー/ムーブコンストラクタがトリビアルであれば、Tのコピーはmemcpy相当の簡易な方法によってコピーすることができ、それは代入演算子でも同様です。

もしそれらがトリビアルでは無い時、コンパイラはそれらの呼び出しが必要になる所で必ずユーザー定義の関数を呼び出すようにしておく必要があります。それが実質的にトリビアル相当のことをしていたとしても、トリビアルでない限りは何らかの関数呼び出しが必要になります。もっともそのような場合、インライン展開をはじめとする最適化によってそのような呼び出しは実質的に省略されるでしょう。

より重要なのは(あるいは問題となるのは)、トリビアルでない型のオブジェクトが関数引数として値渡しされる時、あるいは戻り値として直接返される時、静かなオーバーヘッドを埋め込んでしまうことです。

どういうことかというと、Tのオブジェクトを値渡しした時に、Tトリビアル型であればレジスタに配置されて渡される(可能性がある)のに対し、Tが非トリビアル型であるとスタック上に配置したオブジェクトのポインタ渡しになり、戻り値型についてもほぼ同様のことが起こります。これはC++コード上からは観測できず、出力されたアセンブラを確認して初めて観測できます。

// トリビアルな型
struct trivial {
  int n;
};

// 非トリビアルな型
struct non_trivial {
  int n;

  ~non_trivial() {}
};

// 引数渡し
int f(trivial t);
int f(non_trivial t);

// 戻り値で返す
trivial g1() {
  return {20};
}
non_trivial g2() {
  return {20};
}

void h(int);

int main() {
  int n1 = f(trivial{10});
  int n2 = f(non_trivial{10});
}

GCCのものをコピペすると、次のようなコードが生成されています。

g1():
        mov     eax, 20
        ret
g2():
        mov     DWORD PTR [rdi], 20
        mov     rax, rdi
        ret
main:
        sub     rsp, 24
        # f(trivial)の呼び出し
        mov     edi, 10
        call    f(trivial)
        # f(not_trivial)の呼び出し
        lea     rdi, [rsp+12]
        mov     DWORD PTR [rsp+12], 10
        call    f(non_trivial)
        # main()の終了
        xor     eax, eax
        add     rsp, 24
        ret

godbolt上で見ると対応がより分かりやすいかと思います。

f(trivial)の呼び出し時はediレジスタ(32bit)に即値10を配置して(trivial型を構築して)呼び出しているのに対し、f(not_trivial)の呼び出し時は、rdiレジスタ(64bit)にrsp(スタックポインタ)の値に12を足したアドレスをロードし、その領域に即値10を配置して(non_trivial型を構築して)から呼び出しを行なっています。
rdiレジスタはx64の呼び出し規約において整数/ポインタ引数に対して最初に使用されるレジスタであり、ediレジスタrdiの下位32bitの部分で役割は同様です。したがって、f(trivial)の呼び出しではtrivial型をレジスタに構築して渡しているのに対して、f(not_trivial)の呼び出し時はnon_trivial型をスタック上に配置してそのポインタを渡しています。

今度は、g1(), g2()の定義について生成されたコードを見てみると、trivial型を返すg1()eaxレジスタ(32bit)に即値20を配置して(trivial型を構築して)returnしているのに対し、non_trivial型を返すg2()rdiレジスタ(64bit)の値をポインタとして読みその領域に即値20を配置し(non_trivial型を構築し)、raxレジスタ(64bit)にrdiの値をコピーしてからreturnしています。
raxレジスタはx64の呼び出し規約において戻り値を返すのに使用されるレジスタであり、eaxはその下位32bit部分で役割は同様です。したがって、g1()returnではtrivial型をレジスタに構築して返しているのに対して、g2()returnではnon_trivial型をスタック上に配置してそのポインタを渡しています。

MSVCはf()の呼び出しがどちらも同じコードを生成していますが、g1(), g2()GCC/clangと同じことをしているのが分かります。

このトリビアル型と非トリビアル型の扱いの差異は、ABIによって規定され要求されている事です(MSVCとGCC/clangの差異も使用しているABIの違いによります)。そしておそらく、C++における各種トリビアル性はこうしたABIからの要請によって生まれた規定でしょう。

有名な所では、std::unique_ptrトリビアル型ではないために生ポインタと比較した時にこの種のオーバーヘッドを発生させてしまっています。このことによるオーバーヘッドは微々たるものですが、例えばそれがヘビーループの中で起こっていると問題となるかもしれません。しかもこの事は非常に認識しづらく、よく知られてはいません。このため、std::optional/std::variantに見られるように、近年(C++17以降くらい)の標準ライブラリのクラス型はトリビアル性に注意を払って設計されるようになりました。

とはいえ、MSVC ABIにおけるstd::spanのように(std::spanは常にトリビアル型)、ABIの別の事情によってこの種のオーバーヘッドが発生してしまっていたりと、ABIにまつわる問題は複雑で把握しづらいものがあります・・・

各種ABIでのトリビアル性と引数渡し、戻り値返し

Itanium C++ ABI

1.1 Definitions non-trivial for the purposes of callsで定義されています。

A type is considered non-trivial for the purposes of calls if:

  • it has a non-trivial copy constructor, move constructor, or destructor, or
  • all of its copy and move constructors are deleted.

これはItanium C++ ABIの定める非トリビアルな型の定義で、以下のどちらかの時にクラス型は非トリビアルとして扱われます

  • コピー/ムーブコンストラクタおよびデストラクタのいずれか一つでも非トリビアルである
  • 全てのコピー/ムーブコンストラクタがdeleteされている

さらにすぐ下にはこう書かれています。

This definition, as applied to class types, is intended to be the complement of the definition in [class.temporary]p3 of types for which an extra temporary is allowed when passing or returning a type. A type which is trivial for the purposes of the ABI will be passed and returned according to the rules of the base C ABI, e.g. in registers; often this has the effect of performing a trivial copy of the type.

この定義に該当する非トリビアルな型は、引数として渡すときと戻り値として返す時に一時オブジェクトを作成して返すことが許容され、そうでない型はレジスタ等で受け渡される、みたいな事を言っています。これがまさに先ほどの生成コードに現れている静かなオーバーヘッドの正体であり根拠です。
「non-trivial for the purposes of calls」という用語からもトリビアルという性質がABI(特に関数呼び出しの都合)からきている事が窺えます。

そして、この定義を用いて、関数呼び出し時の非トリビアル型引数について次のように規定されています(3.1.2.3 Non-Trivial Parameters

If a parameter type is a class type that is non-trivial for the purposes of calls, the caller must allocate space for a temporary and pass that temporary by reference. Specifically:

  • Space is allocated by the caller in the usual manner for a temporary, typically on the stack.
  • The caller evaluates the argument in the space provided.
  • The function is called, passing the address of the temporary as the appropriate argument. In the callee, the address passed is used as the address of the parameter variable.
  • If the type has a non-trivial destructor, the caller calls that destructor after control returns to it (including when the caller throws an exception).
  • If necessary (e.g. if the temporary was allocated on the heap), the caller deallocates space after return and destruction.

意訳

トリビアルな型のオブジェクトを関数引数として渡す時、呼び出し元が一時オブジェクトを作成しその参照を渡さなければならない。具体的には

  • 呼び出し元は、一時オブジェクトを作成する通常の方法で、一般的にはスタック上に領域を確保し構築する
  • 呼び出された側(関数内)は、その提供された領域で引数を評価する
  • 関数は、その一時オブジェクトのアドレスを適正な引数として受け取って呼び出される。呼び出された側では渡されたアドレスが引数変数のアドレスとして使用される
  • 型が非トリビアルデストラクタを持つ場合、呼び出し側は関数がリターンした後(制御を戻した後)にデストラクタを呼び出す(関数が例外を投げた場合も同様)
  • 関数のリターンとデストラクタ呼び出しの後、呼び出し側は必要に応じて一時オブジェクトに割り当てられていた領域を解放する(一時オブジェクトがヒープに構築されていた場合など)

戻り値の非トリビアル型引数について次のように規定されています(3.1.3.1 Non-trivial Return Values

If the return type is a class type that is non-trivial for the purposes of calls, the caller passes an address as an implicit parameter. The callee then constructs the return value into this address. If the return type has a non-trivial destructor, the caller is responsible for destroying the temporary when control is returned to it normally. If an exception is thrown out of the callee after the return value is constructed but before control returns to the caller, e.g. by a throwing destructor, it is the callee's responsibility to destroy the return value before propagating the exception to the caller. Thus, in general, the caller is responsible for destroying the return value after, and only after, the callee returns control to the caller normally.

The address passed need not be of temporary memory; copy elision may cause it to point anywhere, including to global or heap-allocated memory.

意訳

戻り値の型が非トリビアル型である場合、呼び出し側は暗黙のパラメータとしてアドレスを渡す。呼び出された側は、そのアドレスに戻り値を構築する。戻り値型が非トリビアルデストラクタを持つ場合、呼び出し側には制御が戻った後でこの一時オブジェクトを破棄する責任が発生する。
戻り値が構築された後呼び出し元に制御が戻る前に、呼び出された関数から例外が送出された場合(ローカル変数のデストラクタからの例外送出など)、呼び出し元に例外を送出する前に戻り値オブジェクトを破棄する(デストラクタを呼び出す)のは呼び出された側(関数内)の責任である。
したがって、一般的には、呼び出し側は呼び出した関数が正常にリターンした場合にのみ戻り値を破棄する責任を負う。

この暗黙に渡される戻り値格納用領域のアドレスは、スタックなどの一時領域のものである必要はなく、コピー省略などによってグローバル領域やヒープ領域のアドレスなど、どこを指していても構わない。

先ほどのサンプルコードを改めて見てみると、まさにこのあたりに書かれている通りになっている事がわかります。

ところで、非トリビアル型戻り値に関する規定の最後の一文は少し驚きです。

// Tは何かしら非トリビアル型とする
T f();

int main() {
  T* p = new T(f());
}

C++17以降コピー省略が保証されているため、このような場合にf()の戻り値はpの領域に直接構築されることになり、先ほどの規定によると、new式によるメモリの確保->f()の評価->Tの構築、のような順番で処理が実行されることが示唆されます。すなわち、new式が行なう2つのこと(メモリの確保とオブジェクトの構築)の間にf()の評価が挟まる事になり、この評価順序はかなり非自明です。

System V AMD64 ABI

System V AMD64 ABIはItanium C++ ABIを参照しており、「non-trivial for the purposes of calls」という言葉とその定義をそのまま使用しています。したがって、System V AMD64 ABIにおけるトリビアルな型とは先ほどのItanium C++ ABIにおけるそれと同様という事になります。

その扱いについて、「3.2.3 Parameter Passing」のクラス型の引数渡しについての欄外に次のようにあります

An object whose type is non-trivial for the purpose of calls cannot be passed by value because such objects must have the same address in the caller and the callee. Similar issues apply when returning an object from a function. See C++17 [class.temporary] paragraph 3.

トリビアルな型のオブジェクトは、呼び出し元と呼び出された側で同じアドレスを持っている必要があるため、値で渡す事ができず、関数からオブジェクトを返す場合も同様の問題がある。のように書かれています。

この一文は非トリビアル型がなぜ特別扱いされるのか?という疑問の回答となるものです。非トリビアル型のオブジェクトが関数の呼び出し元と呼び出された側で同じアドレスを持っている必要がある、というのは非トリビアルなコピー/ムーブコンストラクタおよび非トリビアルデストラクタの呼び出しを避けるためでしょう。関数の呼び出しに伴って実装の予測できないユーザー定義の関数(コピー/ムーブコンストラクタ等)を何度も呼び出す可能性(レジスタとスタックやメモリとの間のコピー)が生じるというのはとてつもないオーバーヘッドになります。あるいは、コピーやムーブがトリビアルでない型のオブジェクトのビット表現(バイト列)をただコピーすることには意味が無いか有害ですらある可能性があります。その様な事を避けるために、レジスタの外、関数の呼び出し前後で消えたりしない領域に一時オブジェクトを作成してその領域を共有していると考えられます。

逆に、トリビアルな型ではコピー/ムーブコンストラクタはmemcpy相当(CPUにとっては普通のコピー)、トリビアルデストラクタは省略可能であるので、レジスタにコピーして渡したり、レジスタからコピーして受け取ったりと言ったことを何の問題もなく行う事ができます。

また、System V AMD64 ABIの規定として、トリビアル型であっても64 [byte]を超える場合はスタック領域上のポインタ渡し(非トリビアル型と同様)となるようです。

ARM64 C++ ABI

ARM64のC++ABIはItanium C++ ABIを参照しており、トリビアルの定義もそのまま使用しています。したがって、ARM64 C++ ABIにおけるトリビアルな型とはItanium C++ ABIにおけるそれと同様という事になります。

ただし、関数の戻り値型で非トリビアルな型を返すときの指定が変更されています。4.1 Summary of differences from and additions to the generic C++ ABI より

When a return value has a non-trivial copy constructor or destructor, the address of the caller-allocated temporary is passed in the Indirect Result Location Register (r8) rather than as an implicit first parameter before the this parameter and user parameters.

意訳

戻り値型に非トリビアルなコピーコンストラクタかデストラクタがある場合、呼び出し側が割り当てた戻り値用一時領域へのアドレスは、暗黙の第一引数ではなくIndirect Result Location Register(x8)に渡される。

トリビアル型を返す関数を呼び出すときに呼び出し側が用意する領域へのアドレスを特定のレジスタを経由して渡す、という事を言っているだけでやることは変化していません。用は、ARM64では非トリビアル型を返すときに専用のレジスタを使用するわけです。

呼び出し時のことについては特に変更がないため、Itanium C++ ABIの時と同様となります。

Procedure Call Standard for the Arm 64-bit Architecture6.4.2 Parameter Passing Rulesには関数呼び出し時の引数の渡し方について規定されており、それによると、トリビアルな型であっても16 [byte]を超える型はレジスタ渡しではなくスタック領域へのポインタ渡しとなるようです。

Windows x64 呼び出し規約 (Windwos x64 ABI)

Windwos x64 ABIは当然?ながらItanium C++ ABIを参照せず、独自定義しています。

引数渡しでは、特にトリビアル型とそうではないクラス型の区別がなく、次のように規定されています

サイズが 8、16、32、または 64 ビットの構造体と共用体、および __m64 型は、同じサイズの整数であるかのように渡されます。他のサイズの構造体または共用体は、呼び出し元によって割り当てられたメモリへのポインターとして渡されます。

つまりWindows x64 ABIでは、関数引数に渡す分にはクラス型のサイズが8 [byte]以下であることしか求められていません。先ほどのサンプルのgodboltの出力も確かにそうなっていました。

なお、この規定のせいで、std::spanstd:string_view(どちらもポインタ1つとstd::size_t1つの16 [byte])と言った軽量なはずのクラスオブジェクトはレジスタ渡しされません。System V AMD64 ABIでは64 [byte]が閾値だったので、できないわけでは無いはずですが・・・

戻り値ではクラス型について要件が次のように課されています

ユーザー定義型は、グローバル関数や静的メンバー関数からの値で返すことができます。 RAX の値によってユーザー定義型を返すには、その長さが 1、2、4、8、16、32、または 64 ビットである必要があります。 また、ユーザー定義のコンストラクター、デストラクター、またはコピー代入演算子は含まれません。 プライベートまたは保護された非静的データ メンバー、および参照型の非静的データ メンバーは含まれません。 基底クラスまたは仮想関数は含まれません。 これらの要件を満たすデータ メンバーのみが含まれます。 (この定義は、実質的には C++03 POD 型と同じです。 C++11 標準ではこの定義は変更されているため、このテストに std::is_pod を使うことはお勧めしません。)それ以外の場合、呼び出し元で、戻り値のメモリを割り当て、最初の引数として、その戻り値用のポインターを渡す必要があります。 残りの引数は、引数 1 つ分だけ右にシフトされます。 RAX 内の呼び出し先は同じポインターを返す必要があります。

まず引数の時と同様に8 [byte]のサイズ要件があり、トリビアルに近い事が要求されます。要件を抜き出して並べると次のようになります

  • ユーザー定義コンストラクタを持たない
  • ユーザー定義コピー代入演算子を持たない
  • ユーザー定義デストラクタを持たない
  • public以外の非静的メンバ変数を持たない
  • 参照型の非静的メンバ変数を持たない
  • 基底クラスを持たない
  • 仮想関数を持たない
  • メンバ変数型もこれらの要件を満たす

これは明らかにItanium C++ ABIのトリビアルよりも厳しい要件です。また、これはC++03のPOD型の要件であるようですが、C++11 std::is_podとは異なるようです。

しかし、この要件に沿わない型は呼び出し元が用意した領域へのポインタを受け取り、そこに構築して返される、というところはItanium C++ ABIと共通しています。

Windows ARM64 ABI

ARM64 ABI 規則の概要には次のようにあるので、x64の時と異なりWindows ARM64 ABIは先ほど見たARM64 ABIに準ずるようです。

Windows 用の基本的なアプリケーション バイナリ インターフェイス (ABI) を、ARM プロセッサ上で 64 ビット モード (ARMv8 以降のアーキテクチャ) でコンパイルして実行するとき、ほとんどの部分は ARM の標準 AArch64 EABI に従います。

ただ、大元のARM64 ABIではC++ ABIについてはItanium C++ ABIを参照していましたが、ここ(Windows ARM64 ABI)ではItanium C++ ABIを参照しておらず、独自に規定しているようです。

引数渡しでは、C++のクラス型は複合型(Composite Type)として次のように規定されています

引数の型が 16 バイトより大きい複合型である場合、引数は呼び出し元によって割り当てられたメモリにコピーされ、その引数がそのコピーへのポインターによって置き換えられます。

クラス型のサイズが16 [byte]以下であれば、レジスタ渡しになります。特にトリビアルであるかどうかは指定がありません。Windowsは引数渡しに関してはサイズしか気にしないようです。

戻り値では戻り値型について規定していますが、型の分類によって分岐し少し複雑です(以下適宜英語版を参照し訳を修正しています)

4 つ以下の要素を持つ HFA と HVA の値が、s0-s3、d0-d3、または v0-v3 で適宜返されます。

HFA/HVAとは次の要件を満たすクラス型です

HFA型はfloatdouble型のメンバで構成された集成体のような型の事で、HVA型はショートベクタ型のメンバで構成された集成体のような型の事です。そして、4要素以下のHFA/HVA型の場合はレジスタ返しになるようです。

それ以外のクラス型については次のようにあります

値で返される型は、特定のプロパティがあるかどうか、および関数が静的でないメンバー関数であるかどうかによって、異なる方法で処理されます。 型に次のプロパティがすべて含まれる場合:

トリビアルという言葉が出てきましたが、C++14集成体型であることを指定されているため、x64の時と同じくらいに厳しいものです。

そして、この要件を満たし16 [byte]以下の型の場合はレジスタ返しされます。16 [byte]を超える型については

16 バイトを超える型の場合、呼び出し元は十分なサイズのメモリ ブロックと、結果を保持するためのアラインメントを予約する必要があります。 メモリ ブロックのアドレスは、x8 内の関数に追加の引数として渡す必要があります。 呼び出し先は、サブルーチンの実行中の任意の時点で、結果のメモリ ブロックを変更する場合があります。 呼び出し先は、x8 に格納されている値を保持する必要はありません。

とあり、それ以外の(そもそも要件を満たさない)型については

呼び出し元は、十分なサイズのメモリ ブロックと、結果を保持するためのアラインメントを予約する必要があります。 メモリ ブロックのアドレスは、x0 内の関数に追加の引数として渡す必要があります。また、$thisx0 に渡される場合は、x1 に渡す必要があります。 呼び出し先は、サブルーチンの実行中の任意の時点で、結果のメモリ ブロックを変更する場合があります。 呼び出し先は、x0 内のメモリ ブロックのアドレスを返します。

結局はどちらも、呼び出し側が戻り値用領域を確保して暗黙の引数としてそのアドレスを渡し、呼び出された側はそこに戻り値を構築してリターンする、という何度か見たことをしています。しかし、その際に使用されるレジスタが異なり(x8x0orx1)そのレジスタの値を保持すべきかも逆で、戻り値としてそのアドレスを返すかが異なっています。なして・・・?

引数が多い時

全てのABIで共通することですが、引数が多くあり後ろの方の引数がレジスタに配置できない場合、本来レジスタ渡しできる型の値であってもスタック上に配置して渡されます。

C++ ABI?

Itanium C++ ABIは元々intelのItanium系CPUの上で動作するC++プログラムのために定められたABIでしたが、その後一般化され、より広範な64bitプロセッサのためのC++ABIへと進化しました。その結果、Windows環境以外を対象とする多くのC++コンパイラで採用され、実質的なデファクトスタンダードとなっています。

Itanium C++ ABIはその名の通りC++のためのABIです。名前空間やテンプレートのマングリングや例外、オーバーロード、クラス、(仮想)メンバ関数などC++特有の事情をどうやってハードウェアにマップするかなどを定めたものです。そのため特にプロセッサに依存するより基礎的な部分のABIについては指定されておらず、あくまでC++の部分のABIについてのみ規定したものです。そのため、特定のプロセッサに依存せずに書かれており、それによって多くのコンパイラで採用されるに至りました(多分)。

Itanium C++ ABIではより基礎的な部分のABIのことをC ABIと呼んでいます。Itanium C++ ABIは任意のC ABIの上に重ねることによって特定のプラットフォームにおけるC++ABIとして機能します。例えばx86-64ならばSystem V AMD64 ABIが、ARM64ならばApplication Binary Interface for the Arm® ArchitectureがC ABIに該当しています。

前述のように、x86-64もARM64もC++ABIについてはItanium C++ ABIを参照しているため、64bit(非Windows)環境のC++ABIとはイコールItanium C++ ABIの事です。ただし、プラットフォーム固有のABIの指定する所によって、変更されている部分はあります。

参考文献

この記事のMarkdownソース