Constant Interpreter¶
Introduction¶
The constexpr interpreter aims to replace the existing tree evaluator in clang, improving performance on constructs which are executed inefficiently by the evaluator. The interpreter is activated using the following flags:
-fexperimental-new-constant-interpreter
enables the interpreter, emitting an error if an unsupported feature is encountered
Bytecode Compilation¶
Bytecode compilation is handled in ByteCodeStmtGen.h
for statements
and ByteCodeExprGen.h
for expressions. 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. This mechanism aims to
pave the way towards replacing the evaluator, improving its performance on
functions and loops, while being just as fast on single-use toplevel
expressions.
The interpreter relies on stack-based, strongly-typed opcodes. The glue
logic between the code generator, along with the enumeration and
description of opcodes, can be found in Opcodes.td
. The opcodes are
implemented as generic template methods in Interp.h
and instantiated
with the relevant primitive types by the interpreter loop or by the
evaluating emitter.
Primitive Types¶
PT_{U|S}int{8|16|32|64}
Signed or unsigned integers of a specific bit width, implemented using the
`Integral`
type.PT_{U|S}intFP
Signed or unsigned integers of an arbitrary, but fixed width used to implement integral types which are required by the target, but are not supported by the host. Under the hood, they rely on APValue. The
Integral
specialisation for these types is required by opcodes to share an implementation with fixed integrals.PT_Bool
Representation for boolean types, essentially a 1-bit unsigned
Integral
.PT_RealFP
Arbitrary, but fixed precision floating point numbers. Could be specialised in the future similarly to integers in order to improve floating point performance.
PT_Ptr
Pointer type, defined in
"Pointer.h"
. A pointer can be either null, reference interpreter-allocated memory (BlockPointer
) or point to an address which can be derived, but not accessed (ExternPointer
).PT_FnPtr
Function pointer type, can also be a null function pointer. Defined in
"FnPointer.h"
.PT_MemPtr
Member pointer type, can also be a null member pointer. Defined in
"MemberPointer.h"
PT_VoidPtr
Void pointer type, can be used for rount-trip casts. Represented as the union of all pointers which can be cast to void. Defined in
"VoidPointer.h"
.PT_ObjCBlockPtr
Pointer type for ObjC blocks. Defined in
"ObjCBlockPointer.h"
.
Composite types¶
The interpreter distinguishes two kinds of composite types: arrays and
records (structs and classes). Unions are represented as records, except
at most a single field can be marked as active. The contents of inactive
fields are kept until they are reactivated and overwritten.
Complex numbers (_Complex
) and vectors
(__attribute((vector_size(16)))
) are treated as arrays.
Bytecode Execution¶
Bytecode is executed using a stack-based interpreter. The execution
context consists of an InterpStack
, along with a chain of
InterpFrame
objects storing the call frames. Frames are built by
call instructions and destroyed by return instructions. They perform
one allocation to reserve space for all locals in a single block.
These objects store all the required information to emit stack traces
whenever evaluation fails.
Memory Organisation¶
Memory management in the interpreter relies on 3 data structures: Block
objects which store the data and associated inline metadata, Pointer
objects which refer to or into blocks, and Descriptor
structures which
describe blocks and subobjects nested inside blocks.
Blocks¶
Blocks contain data interleaved with metadata. They are allocated either statically in the code generator (globals, static members, dummy parameter values etc.) or dynamically in the interpreter, when creating the frame containing the local variables of a function. Blocks are associated with a descriptor that characterises the entire allocation, along with a few additional attributes:
IsStatic
indicates whether the block has static duration in the interpreter, i.e. it is not a local in a frame.DeclID
identifies each global declaration (it is set to an invalid and irrelevant value for locals) in order to prevent illegal writes and reads involving globals and temporaries with static storage duration.
Static blocks are never deallocated, but local ones might be deallocated
even when there are live pointers to them. Pointers are only valid as
long as the blocks they point to are valid, so a block with pointers to
it whose lifetime ends is kept alive until all pointers to it go out of
scope. Since the frame is destroyed on function exit, such blocks are
turned into a DeadBlock
and copied to storage managed by the
interpreter itself, not the frame. Reads and writes to these blocks are
illegal and cause an appropriate diagnostic to be emitted. When the last
pointer goes out of scope, dead blocks are also deallocated.
The lifetime of blocks is managed through 3 methods stored in the descriptor of the block:
CtorFn: initializes the metadata which is store in the block, alongside actual data. Invokes the default constructors of objects which are not trivial (
Pointer
,RealFP
, etc.)DtorFn: invokes the destructors of non-trivial objects.
MoveFn: moves a block to dead storage.
Non-static blocks track all the pointers into them through an intrusive doubly-linked list, required to adjust and invalidate all pointers when transforming a block into a dead block. If the lifetime of an object ends, all pointers to it are invalidated, emitting the appropriate diagnostics when dereferenced.
The interpreter distinguishes 3 different kinds of blocks:
Primitives
A block containing a single primitive with no additional metadata.
Arrays of primitives
An array of primitives contains a pointer to an
InitMap
storage as its first field: the initialisation map is a bit map indicating all elements of the array which were initialised. If the pointer is null, no elements were initialised, while a value of(InitMap*)-1
indicates that the object was fully initialised. When all fields are initialised, the map is deallocated and replaced with that token.Array elements are stored sequentially, without padding, after the pointer to the map.
Arrays of composites and records
Each element in an array of composites is preceded by an
InlineDescriptor
which stores the attributes specific to the field and not the whole allocation site. Descriptors and elements are stored sequentially in the block. Records are laid out identically to arrays of composites: each field and base class is preceded by an inline descriptor. TheInlineDescriptor
has the following fields:Offset: byte offset into the array or record, used to step back to the parent array or record.
IsConst: flag indicating if the field is const-qualified.
IsInitialized: flag indicating whether the field or element was initialized. For non-primitive fields, this is only relevant to determine the dynamic type of objects during construction.
IsBase: flag indicating whether the record is a base class. In that case, the offset can be used to identify the derived class.
IsActive: indicates if the field is the active field of a union.
IsMutable: indicates if the field is marked as mutable.
Inline descriptors are filled in by the CtorFn of blocks, which leaves storage in an uninitialised, but valid state.
Descriptors¶
Descriptors are generated at bytecode compilation time and contain information required to determine if a particular memory access is allowed in constexpr. They also carry all the information required to emit a diagnostic involving a memory access, such as the declaration which originates the block. Currently there is a single kind of descriptor encoding information for all block types.
Pointers¶
Pointers, implemented in Pointer.h
are represented as a tagged union.
Some of these may not yet be available in upstream clang
.
BlockPointer: used to reference memory allocated and managed by the interpreter, being the only pointer kind which allows dereferencing in the interpreter
ExternPointer: points to memory which can be addressed, but not read by the interpreter. It is equivalent to APValue, tracking a declaration and a path of fields and indices into that allocation.
TargetPointer: represents a target address derived from a base address through pointer arithmetic, such as
((int *)0x100)[20]
. Null pointers are target pointers with a zero offset.TypeInfoPointer: tracks information for the opaque type returned by
typeid
InvalidPointer: is dummy pointer created by an invalid operation which allows the interpreter to continue execution. Does not allow pointer arithmetic or dereferencing.
Besides the previously mentioned union, a number of other pointer-like types have their own type:
ObjCBlockPointer tracks Objective-C blocks
FnPointer tracks functions and lazily caches their compiled version
MemberPointer tracks C++ object members
Void pointers, which can be built by casting any of the aforementioned
pointers, are implemented as a union of all pointer types. The BitCast
opcode is responsible for performing all legal conversions between these
types and primitive integers.
BlockPointer¶
Block pointers track a Pointee
, the block to which they point, along
with a Base
and an Offset
. The base identifies the innermost field,
while the offset points to an array element relative to the base (including
one-past-end pointers). The offset identifies the array element or field
which is referenced, while the base points to the outer object or array which
contains the field. These two fields allow all pointers to be uniquely
identified, disambiguated and characterised.
As an example, consider the following structure:
struct A {
struct B {
int x;
int y;
} b;
struct C {
int a;
int b;
} c[2];
int z;
};
constexpr A a;
On the target, &a
and &a.b.x
are equal. So are &a.c[0]
and
&a.c[0].a
. In the interpreter, all these pointers must be
distinguished since the are all allowed to address distinct range of
memory.
In the interpreter, the object would require 240 bytes of storage and would have its field interleaved with metadata. The pointers which can be derived to the object are illustrated in the following diagram:
0 16 32 40 56 64 80 96 112 120 136 144 160 176 184 200 208 224 240
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
+ B | D | D | x | D | y | D | D | D | a | D | b | D | D | a | D | b | D | z |
+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+---+
^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^
| | | | | | | &a.c[0].b | | &a.c[1].b |
a |&a.b.x &a.y &a.c |&a.c[0].a |&a.c[1].a |
&a.b &a.c[0] &a.c[1] &a.z
The Base
offset of all pointers points to the start of a field or
an array and is preceded by an inline descriptor (unless Base
is
zero, pointing to the root). All the relevant attributes can be read
from either the inline descriptor or the descriptor of the block.
Array elements are identified by the Offset
field of pointers,
pointing to past the inline descriptors for composites and before
the actual data in the case of primitive arrays. The Offset
points to the offset where primitives can be read from. As an example,
a.c + 1
would have the same base as a.c
since it is an element
of a.c
, but its offset would point to &a.c[1]
. The
array-to-pointer decay operation adjusts a pointer to an array (where
the offset is equal to the base) to a pointer to the first element.
ExternPointer¶
Extern pointers can be derived, pointing into symbols which are not
readable from constexpr. An external pointer consists of a base
declaration, along with a path designating a subobject, similar to
the LValuePath
of an APValue. Extern pointers can be converted
to block pointers if the underlying variable is defined after the
pointer is created, as is the case in the following example:
extern const int a;
constexpr const int *p = &a;
const int a = 5;
static_assert(*p == 5, "x");
TargetPointer¶
While null pointer arithmetic or integer-to-pointer conversion is
banned in constexpr, some expressions on target offsets must be folded,
replicating the behaviour of the offsetof
builtin. Target pointers
are characterised by 3 offsets: a field offset, an array offset and a
base offset, along with a descriptor specifying the type the pointer is
supposed to refer to. Array indexing adjusts the array offset, while the
field offset is adjusted when a pointer to a member is created. Casting
an integer to a pointer sets the value of the base offset. As a special
case, null pointers are target pointers with all offsets set to 0.
TypeInfoPointer¶
TypeInfoPointer
tracks two types: the type assigned to
std::type_info
and the type which was passed to typeinfo
.
InvalidPointer¶
Such pointers are built by operations which cannot generate valid pointers, allowing the interpreter to continue execution after emitting a warning. Inspecting such a pointer stops execution.
TODO¶
Missing Language Features¶
Changing the active field of unions
volatile
__builtin_constant_p
dynamic_cast
new
anddelete
Fixed Point numbers and arithmetic on Complex numbers
Several builtin methods, including string operations and
__builtin_bit_cast
Continue-after-failure: a form of exception handling at the bytecode level should be implemented to allow execution to resume. As an example, argument evaluation should resume after the computation of an argument fails.
Pointer-to-Integer conversions
Lazy descriptors: the interpreter creates a
Record
andDescriptor
when it encounters a type: ones which are not yet defined should be lazily created when required
Known Bugs¶
If execution fails, memory storing APInts and APFloats is leaked when the stack is cleared