clang -fexperimental-new-constant-interpreter でコンパイルすると、コード中の全ての constexpr は Clang の古典的な再帰下降の定数評価器(clang/lib/AST/ExprConstant.cpp)を経由しなくなり、代わりにスタックベースのバイトコード仮想マシン上で実行されます。その VM は 2019 年から木に入っており、それ以来静かに ExprConstant.cpp の完全な置き換えへと育ち続けています。
この記事はその VM を徹底的に歩く内容です。ExprConstant.cpp からのディスパッチ、TableGen 駆動のオペコード集合、Compiler テンプレートによる AST ノードのバイトコード化、二系統のエミッタバックエンド(バッファするものと即時実行するもの)、インタプリタがオペコードをどうディスパッチするか(switch と、[[clang::preserve_none]] の下で musttail を使うスレッデッドインタプリタ)、オペランドスタック、constexpr の lvalue を支える Block/Pointer モデル、そして最後に全体を結ぶ Add オペコードの実例を扱います。ここに JIT はありません — VM はエンドツーエンドで解釈実行されます — が、設計は木ウォーカよりも、CPython のような小さなスタックマシンに近いものです。
特記しない限りパスは全て clang/lib/AST/ByteCode/ からの相対パスで、行番号は現在の main のものです。
なぜ新しいインタプリタなのか?
ExprConstant.cpp の古典的な評価器は木ウォーカです。各 Evaluate* 関数は AST を再帰降下し、部分結果を C++ スタック上に置いた LValue / APValue オブジェクトで持ち回します。これは非常に正しい(長年の互換性バグでハードニングされてきた)ですが、実用上 2 つの問題があります:
- ループ。 N 回反復する
constexprのforループを評価するということは、同じ AST 部分木を N 回歩き、毎反復で一時的なAPValueを確保するということです。キャッシュはありません。 - 関数。
constexpr関数への呼び出しは、ホスト C++ スタックに新しい評価器状態を積み、呼び出し先の AST を再ウォークします。再利用可能なコンパイル済み表現はありません。
新インタプリタはこの両方を解決します。各関数は一度だけバイトコードにコンパイルされ、その後インタプリタループがそのバイトコードを走らせます。バイトコードは密(std::byte[])、オペランドスタックは型付きスラブアロケータ、ディスパッチャはタイトなキャッシュ局所性のために musttail を使います。ユーザの目線からは、変わるのは -fexperimental-new-constant-interpreter フラグだけです。
clang/docs/ConstantInterpreter.rst(元の RFC、軽くメンテされている)はこう言っています:
The constexpr interpreter aims to replace the existing tree evaluator in clang, improving performance on constructs which are executed inefficiently by the evaluator.
フラグの場所
ドライバフラグは clang/include/clang/Options/Options.td:2162-2165 で宣言されています:
def fexperimental_new_constant_interpreter : Flag<["-"], "fexperimental-new-constant-interpreter">, Group<f_Group>,
HelpText<"Enable the experimental new constant interpreter">,
Visibility<[ClangOption, CC1Option]>,
MarshallingInfoFlag<LangOpts<"EnableNewConstInterp">>;
MarshallingInfoFlag が値を LangOptions::EnableNewConstInterp(clang/include/clang/Basic/LangOptions.def:388 で宣言)に渡し、古典的な評価器が全てのエントリポイントでこれをチェックします。ExprConstant.cpp:21123-21128 より:
if (Info.EnableNewConstInterp) {
if (!Info.Ctx.getInterpContext().evaluateAsRValue(Info, E, Result))
return false;
return CheckConstantExpression(Info, E->getExprLoc(), E->getType(), Result,
ConstantExprKind::Normal);
}
つまりフラグは「Evaluate-as-X」公開エントリ毎のルーティング判断です。セットされていれば、制御は VM を所有する interp::Context に移ります。::Evaluate(木ウォーカ)へのフォールバックパスはそのまま残されています。
3 つのレイヤ
インタプリタは 3 つのレイヤに分かれています:
- Compiler(
Compiler.h、Compiler.cpp) —Stmt/Exprノードを歩いてオペコードを発行する AST ビジタ。Emitter型でテンプレート化されているので、同じコンパイルロジックで 2 つのバックエンドを駆動できます。 - Emitter —
ByteCodeEmitter(後で実行するためにオペコードをSmallVector<std::byte>にバッファする)か、EvalEmitter(バッファなしで、発行された瞬間にオペコードを解釈する)のいずれか。 - Interpreter(
Interp.h、Interp.cpp) — オペコード実装とディスパッチループ。ByteCodeEmitterのRun()から使われます。EvalEmitterは個別のオペコード関数を再利用しますが、自前の制御フローループを C++ で動かします。
Compiler<Emitter> テンプレートのおかげで、EvalEmitter と ByteCodeEmitter は AST ハンドリングコード全体を共有します。違うのは底にある emitOp がする事だけです — バイトを追加する か 即座にオペコードを呼ぶ か。
なぜ 2 つのエミッタがあるのか?
ConstantInterpreter.rst:21-30 を引用:
The compiler has two different backends: one to generate bytecode for functions (
ByteCodeEmitter) and one to directly evaluate expressions as they are compiled, without generating bytecode (EvalEmitter). All functions are compiled to bytecode, while toplevel expressions used in constant contexts are directly evaluated since the bytecode would never be reused.
具体的には:
static constexpr int x = f(g(h()));— トップレベル評価は単発です。SmallVector<std::byte>を確保し、オペコードを詰めて、それを 1 回だけ走らせるのに意味はありません。EvalEmitterは発行直後に各オペコードを実物のInterpStateに対して実行します。constexpr int f(int n) { ... }—fは多数の場所から呼ばれ得ます。バイトコードにコンパイルすると、コード生成コストが全呼び出しサイトで償却されます。ByteCodeEmitterはCodeが連続バイトコードバッファであるFunctionオブジェクトを生成します。
Context.cpp:73-101 で evaluateAsRValue が EvalEmitter を選んでいるのが見えます:
bool Context::evaluateAsRValue(State &Parent, const Expr *E, APValue &Result) {
++EvalID;
// ...
Compiler<EvalEmitter> C(*this, *P, Parent, Stk);
auto Res = C.interpretExpr(E, /*ConvertResultToRValue=*/E->isGLValue());
// ...
Result = Res.stealAPValue();
return true;
}
一方 Context::isPotentialConstantExpr(Sema が「この本体は C++14 の constexpr として使えるか?」をチェックするのに使う)は Compiler<ByteCodeEmitter> をインスタンス化し、できた Function を Context::Run に流します:
Compiler<ByteCodeEmitter>(*this, *P).compileFunc(FD, const_cast<Function *>(Func));
// ...
return Run(Parent, Func);
オペコード: TableGen 生成、型ごとにモノモーファイズ
オペコード集合は Opcodes.td に住んでいます。そこには大体 250 のオペコード「テンプレート」がリストされています。TableGen 展開後の実インタプリタにはもっと多く存在します — なぜなら大半のオペコードは 型付き だからです。例えば Opcodes.td:599:
def Add : AluOpcode;
AluOpcode は Opcode を Types = [AluTypeClass] と HasGroup = 1 で拡張し、AluTypeClass は Sint8, Uint8, Sint16, Uint16, Sint32, Uint32, Sint64, Uint64, IntAP, IntAPS, Bool, FixedPoint をリストします。TableGen 展開後、OP_AddSint8、OP_AddUint8、…、OP_AddIntAPS、OP_AddBool、OP_AddFixedPoint という個別の enum エントリが得られます — それぞれ完全に型特化されたオペコードで、専用のディスパッチャと専用の Add<PT_Sint8>(...) テンプレートインスタンスを持ちます。
その展開を駆動するのが clang/utils/TableGen/ClangOpcodesEmitter.cpp です。鍵となるヘルパは Enumerate(ClangOpcodesEmitter.cpp:58-82) — オペコードの型リスト上のデカルト積を再帰的に歩くものです:
void Enumerate(const Record *R, StringRef N,
std::function<void(ArrayRef<const Record *>, Twine)> &&F) {
// R.Types の全ての型の組み合わせを歩き、
// "AddSint8"、"AddUint8"、… のような合成名で F を呼ぶ。
}
各列挙ステップは以下を生成します:
Opcodeenum のエントリ:OP_AddSint8、…- 「ディスパッチャ」
static bool Interp_AddSint8(InterpState &S, CodePtr &PC)— バイトコードストリームからオペコードの引数を読み、Add<PT_Sint8>(S, OpPC, ...)に末尾呼び出しします。 - エミッタ上の
emitAddSint8(...)メンバ — バッファにオペコード + 引数を書きます。 - 逆アセンブラの
case OP_AddSint8:エントリ。 HasGroup = 1のオペコードでは、ランタイムのPrimTypeでスイッチしてemitAddSint8/emitAddUint8/ … に転送する グループ ディスパッチャemitAdd(PrimType T0, ...)。
最後の点がコンパイラを使いやすくしています: Compiler::VisitBinaryOperator が結果型は 32-bit 符号付き整数(*T == PT_Sint32)と分かっているとき、this->emitAdd(*T, E) と書けて、グループディスパッチャが emitAddSint32 にルーティングしてくれます。
このオペコードレベルでの 型モノモーファイズ は性能上重要です。単型の Add<PT_Sint32> はこうなります:
template <PrimType Name, class T = typename PrimConv<Name>::T>
bool Add(InterpState &S, CodePtr OpPC) {
const T &RHS = S.Stk.pop<T>();
const T &LHS = S.Stk.pop<T>();
// ...
}
T = Integral<32, true> が代入された状態で。コンパイラはタイトでモノモーフィックでインライン化可能な関数を見ます — switch (Type) { case Int32: ... case Int64: ... } を毎ループ反復で叩く梯子ではありません。
プリミティブ型
PrimType.h:34-50:
enum PrimType : uint8_t {
PT_Sint8 = 0, PT_Uint8 = 1, PT_Sint16 = 2, PT_Uint16 = 3,
PT_Sint32 = 4, PT_Uint32 = 5, PT_Sint64 = 6, PT_Uint64 = 7,
PT_IntAP = 8, PT_IntAPS = 9, PT_Bool = 10,
PT_FixedPoint = 11, PT_Float = 12,
PT_Ptr = 13, PT_MemberPtr = 14,
};
15 のプリミティブ型が VM の格納する値の全空間をカバーします。IntAP{S} は APInt で支えられた任意-だが-固定精度の整数で、ホストがネイティブに扱えないターゲット整数型用です。Floating は APFloat をラップします。Pointer はもっと手の込んだもの(後述)で、MemberPointer は C++ のメンバへのポインタを扱います。
PrimConv(PrimType.h:150-195)は各 PrimType enum 値を C++ 型に対応付ける小さなトレイトです:
template <> struct PrimConv<PT_Sint32> { using T = Integral<32, true>; };
template <> struct PrimConv<PT_Float> { using T = Floating; };
template <> struct PrimConv<PT_Ptr> { using T = Pointer; };
// ...
見える全テンプレートオペコード — Add<PT_Sint32>、Ret<PT_Ptr>、Cast<PT_Sint32, PT_Bool> — は PrimConv を通じてモノモーファイズされます。同じヘッダの TYPE_SWITCH / INT_TYPE_SWITCH マクロは ランタイム の PrimType 値をコンパイル時の T = PrimConv<PT>::T ブロックにディスパッチさせるもので、バイトコード命令ストリームが PrimType バイトを運ぶ箇所で使われます。
バイトコードレイアウトと CodePtr
関数のバイトコードは SmallVector<std::byte>(Function::Code)です。各オペコードは ポインタアラインメントにパディングされた 16-bit Opcode enum としてエンコードされ、その後にこれもパディングされた引数が続きます。
リーダは Source.h:30-71 の CodePtr です:
class CodePtr final {
public:
CodePtr &operator+=(int32_t Offset) { Ptr += Offset; return *this; }
template <typename T> std::enable_if_t<!std::is_pointer<T>::value, T> read() {
assert(aligned(Ptr));
using namespace llvm::support;
T Value = endian::read<T, llvm::endianness::native>(Ptr);
Ptr += align(sizeof(T));
return Value;
}
private:
const std::byte *Ptr = nullptr;
};
各読み取りは align(sizeof(T)) 進みます(align は alignof(void*) に切り上げ)。これはオペコードあたり数バイトを浪費しますが、各読み取りが自然にアライン済み、各オペコード境界が void*-align 済み、ということを意味します — assert(aligned(Ptr)) がそれを強制します。
バイトコード中のポインタ(例えば const FunctionDecl * 引数)は代わりに 32-bit ID を得ます。Program::getOrCreateNativePointer がホストポインタをサイドテーブルに intern し、printArg<T*>(Disasm.cpp:36-43)が逆を行います。よってオンディスクサイズは sizeof(void*) から独立しており、これは LabelOffsets と LabelRelocs が int32_t を使うため重要です。ByteCodeEmitter::emit(ByteCodeEmitter.cpp:134-161)は関数が numeric_limits<unsigned>::max() バイトを超えそうになった瞬間に bail out します(Success = false)。
ジャンプは PC 相対 int32_t オフセットです。エミッタは getOffset(ByteCodeEmitter.cpp:117-130)でこれを計算します:
int32_t ByteCodeEmitter::getOffset(LabelTy Label) {
const int64_t Position =
Code.size() + align(sizeof(Opcode)) + align(sizeof(int32_t));
// ターゲットが既知なら、ジャンプオフセットを計算。
if (auto It = LabelOffsets.find(Label); It != LabelOffsets.end())
return It->second - Position;
// それ以外なら relocation を記録し、ダミーオフセットを返す。
LabelRelocs[Label].push_back(Position);
return 0ull;
}
前方ジャンプはプレースホルダ 0 で発行され、ターゲットのオフセットが分かった時点で emitLabel でパッチされます。古典的な「2 パスのつもりで実は 1 パス」アセンブラのトリックです。
AST のコンパイル: visitIfStmt
AST ビジタは Compiler.cpp に住んでいます。機械的ですが教育的です。visitIfStmt(Compiler.cpp:6128-6206)は構造化制御フローの低位化の教科書例:
template <class Emitter> bool Compiler<Emitter>::visitIfStmt(const IfStmt *IS) {
// ... init / 条件変数 / consteval を処理 ...
if (std::optional<bool> BoolValue = getBoolValue(IS->getCond())) {
if (*BoolValue) return visitChildStmt(IS->getThen());
if (const Stmt *Else = IS->getElse())
return visitChildStmt(Else);
return true;
}
// 条件をコンパイルし、Bool をスタックに残す。
if (!this->visitBool(IS->getCond()))
return false;
// ...
if (const Stmt *Else = IS->getElse()) {
LabelTy LabelElse = this->getLabel();
LabelTy LabelEnd = this->getLabel();
if (!this->jumpFalse(LabelElse, IS)) return false;
if (!visitChildStmt(IS->getThen())) return false;
if (!this->jump(LabelEnd, IS)) return false;
this->emitLabel(LabelElse);
if (!visitChildStmt(Else)) return false;
this->emitLabel(LabelEnd);
} else {
LabelTy LabelEnd = this->getLabel();
if (!this->jumpFalse(LabelEnd, IS)) return false;
if (!visitChildStmt(IS->getThen())) return false;
this->emitLabel(LabelEnd);
}
return true;
}
冒頭の 静的分岐除去 は小さいですが嬉しい最適化です: 条件が既知の結果を持つ ConstantExpr なら、デッド分岐のコード生成を完全にスキップします。新インタプリタはこのやり方で自前のドッグフードを食べているとも言えます — getBoolValue は Sema が既に評価した ConstantExpr だけをチェックしますが、実用上ほとんどの if (some_constexpr_var) はこのパスに乗ります。
式は似ていますが型付きです。VisitBinaryOperator(Compiler.cpp:1064)は働き者で — ポインタ算術から複素数乗算、<=> スペースシップ演算子まで、200 行ほどあらゆることを覆います。素直な整数算術のコアは底のスイッチです:
switch (E->getOpcode()) {
case BO_Add:
if (E->getType()->isFloatingType())
return Discard(this->emitAddf(getFPOptions(E), E));
return Discard(this->emitAdd(*T, E));
// ...
}
*T はランタイムの PrimType(PT_Sint32、PT_Uint64、…)— そして emitAdd は TableGen が生成した グループエミッタ です。一度 *T でスイッチし、型特化された emitAddSint32 か emitAddUint64 を選び、適切なオペコードバイトと引数をストリームに書きます。バイトコードが発行された後、ディスパッチャは同じ 1 バイトを読み戻して、対応する Add<PT_Sint32> インスタンスに末尾呼び出しします。
ディスパッチループ
Interp.cpp:2803-2825:
bool Interpret(InterpState &S) {
assert(!S.Current->isRoot());
CodePtr PC = S.Current->getPC();
#if USE_TAILCALLS
return InterpNext(S, PC);
#else
while (true) {
auto Op = PC.read<Opcode>();
auto Fn = InterpFunctions[Op];
if (!Fn(S, PC)) return false;
if (OpReturns(Op)) break;
}
return true;
#endif
}
ディスパッチ戦略は 2 つあり、コンパイル時に選ばれます。フォールバックは古典的な switch スタイルのループです: オペコードを読み、関数ポインタテーブルを引き、呼び、繰り返す。高速パスは末尾呼び出しループ:
PRESERVE_NONE static bool InterpNext(InterpState &S, CodePtr &PC) {
auto Op = PC.read<Opcode>();
auto Fn = InterpFunctions[Op];
MUSTTAIL return Fn(S, PC);
}
各オペコードディスパッチャは MUSTTAIL return InterpNext(S, PC); で終わります。これでインタプリタは末尾呼び出しの連鎖になります — 各オペコードハンドラはスタックを巻き戻すことなく直接次に飛びます。[[clang::preserve_none]](Interp.h:44-50)— callee-saved レジスタを保存する必要がないと知らせる属性 — と組み合わさって、ディスパッチャには非常にタイトで予測可能なコードパスができます。TableGen 生成のディスパッチャ(ClangOpcodesEmitter.cpp:113-197)が実際に InterpNext を全オペコードに繋ぎ込んでいます:
PRESERVE_NONE
static bool Interp_AddSint32(InterpState &S, CodePtr &PC) {
CodePtr OpPC = PC;
if (!Add<PT_Sint32>(S, OpPC))
return false;
#if USE_TAILCALLS
MUSTTAIL return InterpNext(S, PC);
#else
return true;
#endif
}
USE_TAILCALLS マクロは Interp.cpp:43-50 でプラットフォーム毎に設定されます:
#if defined(_MSC_VER) || defined(__powerpc__) || !defined(MUSTTAIL) || \
defined(__i386__) || defined(__sparc__)
#undef MUSTTAIL
#define MUSTTAIL
#define USE_TAILCALLS 0
#else
#define USE_TAILCALLS 1
#endif
PPC、MSVC、i386、SPARC は switch ディスパッチャにフォールバックします。switch パスは正しいですが遅い — 各オペコードハンドラは通常の関数 return を行い、ディスパッチャループはオペコードを再読、テーブルを再インデックスします。
switch ループ底の OpReturns チェックが要るのは、switch モードではディスパッチャが単純に「止まる」ことができないからです — 現オペコードが RetX だった事を検出してループを抜けねばなりません。OpReturns は手書きで(Interp.cpp:2766-2774)、これが最適でないことを認めるコメントもあります:
// FIXME: Would be nice to generate this instead of hardcoding it here.
constexpr bool OpReturns(Opcode Op) {
return Op == OP_RetVoid || Op == OP_RetValue || Op == OP_NoRet ||
Op == OP_RetSint8 || Op == OP_RetUint8 || ...
}
TableGen レコードは return 形のオペコードを CanReturn = 1 でマークし、末尾呼び出しディスパッチャはそれを使って末尾の MUSTTAIL return InterpNext(...) をスキップします。switch モードのループは同じ情報をランタイムに再導出するわけです。
オペランドスタック
InterpStack.h:25-208。「スタック」という名前にもかかわらず、これは固定配列では ありません。1 MiB チャンク(ChunkSize = 1024 * 1024)の連結リストです:
struct StackChunk {
StackChunk *Next;
StackChunk *Prev;
uint32_t Size;
// ... メモリ上にデータが続く ...
};
push は grow() を通ります:
template <size_t Size> void *grow() {
if (LLVM_UNLIKELY(!Chunk)) {
Chunk = new (std::malloc(ChunkSize)) StackChunk(Chunk);
} else if (LLVM_UNLIKELY(Chunk->size() >
ChunkSize - sizeof(StackChunk) - Size)) {
if (Chunk->Next) {
Chunk = Chunk->Next;
} else {
StackChunk *Next = new (std::malloc(ChunkSize)) StackChunk(Chunk);
Chunk->Next = Next;
Chunk = Next;
}
}
// Chunk->Size を進めてスロットを返す
}
注目すべき設計選択が 2 つ。チャンクは縮小時に保持されます(peekData / shrink は必要なら以前のチャンクに戻ります)。だから pop / push の連続でアロケータを叩くことはなく — 先行チャンクも空になったチャンクだけが解放されます。スロットは alignof(void*) にアラインされます: 各 push はオブジェクトサイズをポインタアラインメントに切り上げるので、異種型が体操なしに隣り合えます。
もう一つ変わっているのが ItemTypes:
/// SmallVector recording the type of data we pushed into the stack.
/// We don't usually need this during normal code interpretation but
/// when aborting, we need type information to call the destructors
/// for what's left on the stack.
llvm::SmallVector<PrimType> ItemTypes;
ホットパスの push/pop は ItemTypes を 読みません — バイトコード自身がトップに何の PrimType があるかをエンコードしているので、pop<Pointer>() や pop<Integral<32, true>>() は静的に何を期待するか分かります。しかし中断された評価では、スタックには周囲コンテクストが型を忘れた任意の残値があり得ます。ItemTypes のおかげで clearTo() はチャンクを下に歩きながら正しいデストラクタを呼べます — これは Pointer、Floating、MemberPointer が trivially destructible でないので重要です。
Block / Pointer モデル
ここがこの設計の最も特徴的な部分 — そしてこれを「constexpr VM」たらしめる、汎用的なおもちゃインタプリタではなくしている部分です。
Block(InterpBlock.h:44)は単一のアロケーションを支える「VM メモリ」の連続したチャンクです: ローカル変数、グローバル、ヒープ確保、または一時オブジェクト。各ブロックはその型、アラインメント、レイアウト、ライフタイムを記述する Descriptor* を持ちます。ブロックレイアウト(InterpBlock.h:30-43):
Block* rawData() data()
│ │ │
▼ ▼ ▼
┌───────────────┬─────────────────────────┬─────────────────┐
│ Block │ Metadata │ Data │
│ sizeof(Block) │ Desc->getMetadataSize() │ Desc->getSize() │
└───────────────┴─────────────────────────┴─────────────────┘
Block オブジェクトは型ディスクリプタ、EvalID(「このブロックは前の評価で確保されたもので、この評価には持ち越すべきでない」を検出するため)、それを指している全生存ポインタのチェーン(ブロックが死ぬときに無効化するため)、そしてアクセスフラグ(extern/dead/weak/dummy)を運びます。実データは同じアロケーション内で Block のメタデータの後に居ます — data() がそれを返します。
Pointer(Pointer.h:97)は constexpr における lvalue の対応物です。これは単なる void* では ありません。Pointer.h:84-96 より:
Pointee Offset
│ │
▼ ▼
┌───────┬────────────┬─────────┬────────────────────────────┐
│ Block │ InlineDesc │ InitMap │ Actual Data │
└───────┴────────────┴─────────┴────────────────────────────┘
▲
│
Base
ポインタが運ぶもの:
- Pointee: ポインタが根を張る
Block*。 - Base: ブロック内のオフセット(バイト単位)で、現在の sub-field が始まる位置。「私は
BS.PointeeのPointの.yメンバ」を追跡するのがこれです。 - Offset: その sub-field 内のオフセット。プリミティブなら 0 か 1(one-past-end)、配列なら要素インデックス × 要素サイズです。
- Storage タグ: ブロックだけが唯一のポインタの種類ではありません。Pointer は
IntPointer(ポインタにキャストされた整数)、FunctionPointer、TypeidPointerでもあり得ます。Storageenum(Pointer.h:65)がそれらを切り替えます。
Base と Offset の分離が、生アドレス算術を行わずに「このポインタは one-past-the-end か?」「このアクセスは flexible array member 内か?」のような質問にインタプリタが答えられる理由で、narrow()/expand() が存在する理由でもあります — sub-object 境界でポインタを再ルートしたり、その外側の配列に戻したりします。
各複合配列要素 / 構造体フィールドの前に埋め込まれた InlineDescriptor(Descriptor.h:62-119)が、「このフィールドは初期化されているか?」「これがこの union のアクティブメンバか?」「これは基底クラスの sub-object か?」「このオブジェクトのライフタイムは始まっているか?」を実際に追跡します。InlineDescriptor は sub-object あたり ~24 バイト のメタデータ — 絶対値では高価ですが、C++ の constexpr ルールを強制するのにちょうど必要なメタデータです:
struct InlineDescriptor {
unsigned Offset;
unsigned IsConst : 1;
unsigned IsInitialized : 1;
unsigned IsBase : 1;
unsigned IsActive : 1; // active union member
unsigned InUnion : 1;
unsigned IsFieldMutable : 1;
// ...
Lifetime LifeState; // Started/NotStarted/Destroyed/Ended
const Descriptor *Desc;
};
プリミティブ 配列(例えば int[10])では、要素ごとに 1 つの InlineDescriptor の代わりに InitMapPtr が来ます — どの配列要素が初期化されているかを追跡する単一のビットフィールド(InitMap.h:22)です。全要素が初期化されると、InitMap は解放されてセンチネル値 AllInitializedValue(InitMap.h:84)に置き換えられ、完全に初期化された配列でビットマップを持ち回るコストを避けます。
関数フレームと呼び出し
InterpFrame(InterpFrame.h:27)は VM の呼び出しフレームで、ホスト C++ スタックに置かれます。メモリ上のレイアウト:
+-- InterpFrame --+--- locals ---+--- args ---+
| fields, etc. | (frame | (argument |
| | slots) | slots) |
+-----------------+--------------+------------+
Context::Run(Context.cpp:500-516)が底フレームの作成を見せます:
bool Context::Run(State &Parent, const Function *Func) {
InterpState State(Parent, *P, Stk, *this, Func);
auto Memory = std::make_unique<char[]>(InterpFrame::allocSize(Func));
InterpFrame *Frame = new (Memory.get()) InterpFrame(
State, Func, /*Caller=*/nullptr, CodePtr(), Func->getArgSize());
State.Current = Frame;
if (Interpret(State)) {
assert(Stk.empty());
return true;
}
// ...
}
引数渡しはオペランドスタック経由です。Function::ParamDescriptor は各パラメタについて、呼び出し元のスタック領域内のどのオフセットから呼び出し先がフェッチすべきかを記録します。Function.h:91-98 の図:
この Function を呼ぶときの ─────┐
スタック位置 │
▼
┌─────┬──────┬────────┬────────┬─────┬────────────────────┐
│ RVO │ This │ Param1 │ Param2 │ ... │ │
└─────┴──────┴────────┴────────┴─────┴────────────────────┘
先頭のオプションの RVO スロットは戻り値最適化用です: constexpr 関数が非プリミティブ(構造体、配列)を返すとき、呼び出し元は結果用のスペースを事前確保し、暗黙の第 1 引数として Pointer を渡します。関数はスタック経由で値を返さず、そのポインタに対して構築します。これは Itanium ABI が trivially-copyable でない return を扱う仕方のミラーで、VM スタックに構造体値を保持させる必要を避けます。
Call(Interp.cpp:1747-1837)が実装です:
bool Call(InterpState &S, CodePtr OpPC, const Function *Func, uint32_t VarArgSize) {
// ... 安全性 / 妥当性チェック ...
if (!Func->isFullyCompiled())
compileFunction(S, Func);
// ... さらにチェック ...
auto Memory = new char[InterpFrame::allocSize(Func)];
auto NewFrame = new (Memory) InterpFrame(S, Func, OpPC, VarArgSize);
InterpFrame *FrameBefore = S.Current;
S.Current = NewFrame;
bool Success = Interpret(S);
// ...
return true;
}
ここで興味深い手が 2 つ: compileFunction は最初の使用時に遅延的に呼ばれ(Func->isFullyCompiled() がゲート)、Interpret(S) が Call 内で再帰的に呼ばれます。よってホスト C++ スタックは VM 呼び出しスタックと 1:1 で対応します — VM は自前のスケジューラを持ちません。N 段の constexpr 再帰はホストスタックフレームを N 個食い、ヒープ上に N 個の InterpFrame を確保し、各レベルで実行中のバイトコードもあります。フレーム確保の直前に呼ばれる CheckCallDepth がこれを LangOptions::ConstexprCallDepth に bound します。
Add: 実例オペコード
部品をまとめます。コンパイラが 2 つの int の a + b を見たとき:
Compiler::VisitBinaryOperator(Compiler.cpp:1064)は両オペランドをPT_Sint32と分類し、LHS を visit し(オペランドスタックにIntegral<32, true>を残す)、RHS を visit し、this->emitAdd(PT_Sint32, E)を呼びます。- TableGen 生成のグループエミッタは
PT_Sint32でスイッチしてemitAddSint32にルーティングし、それはOP_AddSint32のバイトをバイトコードバッファに書きます(引数なし —Addには即値オペランドはありません)。 - 解釈時、
InterpNextはOP_AddSint32を読み、InterpFunctions[OP_AddSint32]をインデックスしてInterp_AddSint32を見つけ、それに末尾呼び出しします。 Interp_AddSint32はAdd<PT_Sint32>(S, OpPC)を呼びます。
Add テンプレートは Interp.h:380-396 にあります:
template <PrimType Name, class T = typename PrimConv<Name>::T>
bool Add(InterpState &S, CodePtr OpPC) {
const T &RHS = S.Stk.pop<T>();
const T &LHS = S.Stk.pop<T>();
const unsigned Bits = RHS.bitWidth() + 1;
if constexpr (isIntegralOrPointer<T>()) {
if (LHS.isNumber() != RHS.isNumber())
return AddSubNonNumber<T, std::plus>(S, OpPC, LHS, RHS);
else if (LHS.isNumber() && RHS.isNumber())
; // ちゃんとした加算へフォールスルー。
else
return false;
}
return AddSubMulHelper<T, T::add, std::plus>(S, OpPC, Bits, LHS, RHS);
}
興味深いケースは Integral 形でかつ ポインタ風 かもしれない型です(整数とポインタからキャストされた整数は表現を共有するので)。実物の整数では制御は AddSubMulHelper(Interp.h:303-352)にフォールスルーします:
template <typename T, bool (*OpFW)(T, T, unsigned, T *),
template <typename U> class OpAP>
bool AddSubMulHelper(InterpState &S, CodePtr OpPC, unsigned Bits, const T &LHS,
const T &RHS) {
// 高速パス - 数を固定幅で加算。
T Result;
if (!OpFW(LHS, RHS, Bits, &Result)) {
S.Stk.push<T>(Result);
return true;
}
// ここに来たら、固定幅加算がオーバーフロー。
S.Stk.push<T>(Result);
// ...
if (S.Current->getExpr(OpPC)->getType().isWrapType())
return true;
// 低速パス - 1 ビット余分の精度で結果を計算。
APSInt Value = OpAP<APSInt>()(LHS.toAPSInt(Bits), RHS.toAPSInt(Bits));
// ... Expr のソース位置経由でオーバーフロー診断を出す ...
if (!handleOverflow(S, OpPC, Value)) {
S.Stk.pop<T>();
return false;
}
return true;
}
高速パスは T::add です — Integral<32, true> ではこれはオーバーフロー検出付きの 2 つのネイティブ 32-bit 加算です。それが「オーバーフローなし」を返せば、結果はスタックに戻り終わりです。オーバーフローしたら、ヘルパは低速パスに落ちます: 1 ビット余分の精度で APSInt で再計算し、handleOverflow に「言語はこの式でこのオーバーフローを許すか?」(C++ の符号付き算術? UB; -fwrapv で? 定義済み; __builtin_add_overflow 内? 呼び出し元はラップを欲しい)と問い、S.Current->getExpr(OpPC)(オペコードが発行された AST ノード)経由で診断を出すか、ラップされた値を受け入れるか、を決めます。
注意: AST はランタイムでも まだ絵の中にあります — S.Current->getExpr(OpPC) は Function::SrcMap(Source.h:98、エミッタが各 emitOp で埋める)経由で現 PC の元の Expr* を引きます。これが新インタプリタに優れた診断を与えます: 各オペコードはどの AST ノードがそれを生んだかを知っているので、UB 診断は元のソース位置と式テキストを運びます。
2 つの繊細さ: 投機実行とステップ計数
ステップ計数。 Constexpr 評価にはステップ制限があります(デフォルト 1,048,576、-fconstexpr-steps=N で設定可能)。インタプリタは InterpState::noteStep(InterpState.cpp:160-169)でステップを課金します:
bool InterpState::noteStep(CodePtr OpPC) {
if (InfiniteSteps) return true;
--StepsLeft;
if (StepsLeft != 0) return true;
FFDiag(Current->getSource(OpPC), diag::note_constexpr_step_limit_exceeded);
return false;
}
noteStep はジャンプでだけ呼ばれます — Jmp、Jt、Jf(Interp.cpp:60-77)。つまり、オペコードあたり 1 ステップではなく、後方分岐や分岐あたり 1 ステップ です。線形シーケンスは無料、ループは妥当なコストになります。古典的な評価器はステップを別の仕方で(全文ごとに)数えるので、同じコードでも 2 つの評価器で別の地点で制限に当たるかもしれません — 比較するときには気に留めておきたい点です。
__builtin_constant_p / 投機実行。 __builtin_constant_p(x) は「x の評価は constexpr コンテクストで成功するか?」を、コミットせずに、しかも 失敗パスが出すであろう診断を一切出さずに 問う必要があります。新インタプリタはこれを BCP オペコード(Interp.cpp:2837-2914)を中心とした投機実行機構で扱います:
PRESERVE_NONE static bool BCP(InterpState &S, CodePtr &RealPC, int32_t Offset,
PrimType PT) {
size_t StackSizeBefore = S.Stk.size();
CodePtr PC = RealPC;
auto SpeculativeInterp = [&S, &PC]() -> bool {
PushIgnoreDiags(S, PC);
auto _ = llvm::scope_exit([&]() { PopIgnoreDiags(S, PC); });
// ... 投機実行を走らせる ...
};
if (SpeculativeInterp()) {
// 結果を pop して 1 を push。
S.Stk.push<Integral<32, true>>(Integral<32, true>::from(1));
} else {
EndSpeculation(S, RealPC);
if (!S.inConstantContext())
return Invalid(S, RealPC);
S.Stk.clearTo(StackSizeBefore);
S.Stk.push<Integral<32, true>>(Integral<32, true>::from(0));
}
// ...
RealPC += Offset - ParamSize;
return true;
}
BCP は「投機実行結果に基づく分岐」 — 診断を抑止して埋め込まれたサブプログラムを実行(PushIgnoreDiags/PopIgnoreDiags オペコードは文字通り InterpState 上のカウンタを切り替えます)、失敗時はスタックを投機実行前の高さにスナップしてから投機ブロックを越えて続行します。Offset はエミッタが計算した、__builtin_constant_p 後の継続までのバイトコード距離です。
なぜまだ「experimental」なのか?
理由は 2 つ:
- カバレッジの隙間。 C++ 定数評価のいくつかの隅のケースがまだ実装されていません(一部のアトミック演算、特定のベクタ組み込み、特定の MSVC constexpr 拡張)。新インタプリタがそれに当たると、優雅にフォールバックするかエラーを出します —
Unsupportedオペコード(Opcodes.td:837)はまさに「コンパイル時に降参」のマーカです。 - 診断の同等性。 古典的な評価器は診断に対する 10 年分のユーザバグレポートを浴びてきました。新しい方は大体そこに辿り着いていますが完全ではなく — 各リリースで差を少しずつ縮めています。
clang/test/AST/ByteCode/下のテストは対応するclang/test/SemaCXX/ケースをミラーし、2 つのパスの出力を比較します。
フラグは今のところ opt-in で、最終目標は元の RFC に書かれた通り: ExprConstant.cpp の評価器を丸ごと置き換えることです。
次に読むもの
clang/docs/ConstantInterpreter.rst— 元の(少し古い)RFC。高レベルの動機と型システムの概観に良いです。Opcodes.td— オペコード集合全体がここに。上から下まで読むのが VM が何をできるかを見る最速の方法です。Interp.h— 全オペコードの実装。見えるbool OpName(InterpState &, CodePtr OpPC, ...)は全て、実物のバイトコードバイトから到達可能な実物のハンドラです。clang/test/AST/ByteCode/— 小さな C++ スニペットと FileCheck アサーションのペア。テストは各オペコードグループの隅のケースを刺激します。「仮想基底クラス持ちのクラスでこれは 本当に 何をする?」を理解するにはこれを読むのが手です。
新インタプリタは、もっと大きなコードベースに埋め込まれた「小さな VM」の良い例です — 強く型付けされ、必要な所だけメタデータが疎で(プリミティブ配列の InitMap vs 複合配列のフィールド毎の InlineDescriptor)、エンドツーエンドで C++ 定数評価が表現可能でなければならない事に形作られています。ここまで読んだなら、次にビルドログで -fexperimental-new-constant-interpreter を見たとき、作業がどのディレクトリで起きているかを正確に知っていることでしょう。