Torque: The DSL V8 Uses to Write Its Own Builtins
Torque is V8's strongly typed DSL for builtins. A .tq file becomes a typed IR, then C++ that calls the Code Stub Assembler, then a Code object with a matching call descriptor.
TL;DR: Torque is a small, strongly typed DSL that V8 wrote so that
Array.prototype.mapcould be read like the ECMAScript spec instead of like a C++ template. A.tqfile is parsed into an AST (src/torque/torque-parser.cc), type-checked and lowered into a control flow graph of Torque IR instructions (src/torque/instructions.h), and then translated into C++ that calls the Code Stub Assembler (src/torque/csa-generator.cc). The same run also emits the builtin table and the call descriptors, so the calling convention ofArrayMapLoopContinuationis derived from its Torque signature rather than written by hand twice.
Why V8 needed a second language
V8 has four ways to write a builtin, and each one is wrong in a different direction.
A C++ builtin is a plain function called through the runtime-call mechanism. It is easy to write and it is slow: every call crosses a C++ boundary, and the arguments have to be unpacked out of a stack frame.
An assembly builtin is written with the per-architecture
MacroAssembler. It is as fast as it can be, and it has to be written
once per architecture - V8 currently ships x64, arm64, ia32, arm, ppc, s390,
riscv, mips64 and loong64 backends.
A Code Stub Assembler builtin is portable, because CSA emits a TurboFan graph rather than bytes, but it is verbose and its type system is C++ templates rather than something the compiler can reason about.
Torque is the fourth, and it sits on top of CSA. It adds the thing CSA
was missing: a type
system that knows about Smi, JSArray, Context and NativeContext
as distinct types, that refuses to let you add a String to a Number,
and that can prove a Cast<T>(x) otherwise Bailout actually succeeded.
The payoff is visible in the source: a Torque builtin reads like the
specification, with the spec’s step numbers still in the comments.
The pipeline, in the order the compiler runs it
The whole driver is short enough to read in one sitting. CompileCurrentAst
in src/torque/torque-compiler.cc is the top of the pipeline:
// src/torque/torque-compiler.cc:82
// Two-step process of predeclaration + resolution allows to resolve type
// declarations independent of the order they are given.
PredeclarationVisitor::Predeclare(GlobalContext::ast());
PredeclarationVisitor::ResolvePredeclarations();
// Process other declarations.
DeclarationVisitor::Visit(GlobalContext::ast());
// A class types' fields are resolved here, which allows two class fields to
// mutually refer to each others.
TypeOracle::FinalizeAggregateTypes();Predeclaration exists so that type declarations can be resolved in any
order - the comment above it says the point is “to resolve type
declarations independent of the order they are given”, and the comment
below adds that “a class types’ fields are resolved here, which allows two
class fields to mutually refer to each others”. Then DeclarationVisitor
walks the AST once more and resolves macros, builtins and constants - and
this is where overload resolution happens, using the type oracle to answer
“is Smi a subtype of Number?”.
After that, the ImplementationVisitor turns declarations into
implementations and emits every artifact the rest of V8 needs:
// src/torque/torque-compiler.cc:112
ImplementationVisitor implementation_visitor;
implementation_visitor.SetDryRun(output_directory.empty());
implementation_visitor.GenerateInstanceTypes(output_directory);
implementation_visitor.BeginGeneratedFiles();
implementation_visitor.BeginDebugMacrosFile();
implementation_visitor.VisitAllDeclarables();
ReportAllUnusedMacros();
implementation_visitor.GenerateBuiltinDefinitionsAndInterfaceDescriptors(
output_directory);Note ReportAllUnusedMacros(): a Torque macro that nobody calls is
reported by the compiler (Lint("Macro '...' is never used.")), which is
how the language stays honest about its own dead code.
The output is three files per .tq input:
// src/torque/implementation-visitor.cc:1959
// TODO(torque-builder): Pass file directly.
WriteFile(base_filename + "-tq-csa.cc", std::move(csa_cc));
WriteFile(base_filename + "-tq-csa.h", streams.csa_headerfile.str());
WriteFile(base_filename + "-tq.cc", streams.class_definition_ccfile.str());-tq-csa.cc is the CSA implementation, -tq-csa.h the declarations other
generated files need, and -tq.cc is a completely different backend: it
emits C++ class definitions and debug readers for the structs that
Torque declares, so a Torque struct Vector has a real C++ mirror.
The IR: twenty-two instructions, all of them boring on purpose
Between the AST and the backend sits a tiny typed IR. The instruction set is listed in one place, which makes it easy to see how small the surface area is:
// src/torque/instructions.h:34
#define TORQUE_BACKEND_DEPENDENT_INSTRUCTION_LIST(V) \
V(PushUninitializedInstruction) \
V(PushBuiltinPointerInstruction) \
V(LoadReferenceInstruction) \
V(StoreReferenceInstruction) \
V(LoadBitFieldInstruction) \
V(StoreBitFieldInstruction) \
V(CallCsaMacroInstruction) \
V(CallIntrinsicInstruction) \
V(NamespaceConstantInstruction) \
V(CallCsaMacroAndBranchInstruction) \
V(CallBuiltinInstruction) \
V(CallRuntimeInstruction) \
V(CallBuiltinPointerInstruction) \
V(BranchInstruction) \
V(ConstexprBranchInstruction) \
V(GotoInstruction) \
V(GotoExternalInstruction) \
V(MakeLazyNodeInstruction) \
V(ReturnInstruction) \
V(PrintErrorInstruction) \
V(AbortInstruction) \
V(UnsafeCastInstruction)There is no arithmetic instruction, no comparison instruction, no loop
instruction. A Torque if becomes a BranchInstruction plus blocks; a
Torque + becomes a CallCsaMacroInstruction for the Add CSA macro.
Torque deliberately has no expression semantics of its own - it is a
control flow language whose leaves are CSA calls.
That is the key design decision, and it explains the rest of the architecture. Because every leaf is a CSA macro, the backends only have to know how to translate twenty-two instructions.
The keywords that actually matter
The grammar is an Earley parser built out of Rules. Six keywords carry
most of the meaning.
extern marks a declaration with no Torque body: the implementation lives
in hand-written C++ (usually CSA) and Torque only knows its signature.
// src/torque/torque-parser.cc:2928
Rule({annotations, Token("extern"), CheckIf(Token("transitioning")),
Optional<std::string>(
Sequence({Token("operator"), &externalString})),
Token("macro"),
Optional<std::string>(Sequence({&identifier, Token("::")})), &name,
TryOrDefault<GenericParameters>(&genericParameters),
&typeListMaybeVarArgs, &returnType, optionalLabelList, Token(";")},
MakeExternalMacro),
Rule({annotations, Token("extern"), CheckIf(Token("transitioning")),
CheckIf(Token("javascript")), Token("builtin"), &name,
TryOrDefault<GenericParameters>(&genericParameters),
&typeListMaybeVarArgs, &returnType, Token(";")},
MakeExternalBuiltin),transitioning is the interesting one. A transitioning callable may run
arbitrary JavaScript, allocate, or move the heap around underneath you. The
type system uses it to invalidate transient types - types such as
FastJSArray that are only valid as long as nothing has run in between.
Using a transient value across a transitioning call is a type error, and
the enforcement is explicit: after a transitioning call, every transient
type still on the value stack is replaced by a poison type that carries
the error message with it.
// src/torque/instructions.cc:138
void InstructionBase::InvalidateTransientTypes(
Stack<const Type*>* stack) const {
auto current = stack->begin();
while (current != stack->end()) {
if ((*current)->IsTransient()) {
std::stringstream stream;
stream << "type " << **current
<< " is made invalid by transitioning callable invocation at "
<< PositionAsString(pos);
*current = TypeOracle::GetTopType(stream.str(), *current);
}
++current;
}
}and it is called only when the callee is transitioning:
// src/torque/instructions.cc:166
if (intrinsic->IsTransitioning()) {
InvalidateTransientTypes(stack);
}
stack->PushMany(LowerType(intrinsic->signature().return_type));
}javascript marks a builtin with the JS calling convention, so it can be
called from JavaScript code.
builtin distinguishes a real builtin - which gets its own Code object
and its own entry in the builtin table - from a macro, which is inlined
into its caller at the Torque level. A macro is a compile-time function; a
builtin is a runtime function.
namespace groups declarations so that they are not visible outside the
namespace, and it maps straight onto a C++ namespace in the generated
file:
// src/torque/torque-parser.cc:2974
Rule({Token("namespace"), &identifier, Token("{"), &declarationList,
Token("}")},
AsSingletonVector<Declaration*, MakeNamespaceDeclaration>())};js-implicit context is the calling convention for a JS-linkage builtin.
The parser distinguishes implicit from js-implicit explicitly:
// src/torque/torque-parser.cc:529
if (implicit_params->kind->value == "implicit") {
result.implicit_kind = ImplicitKind::kImplicit;
} else {
DCHECK_EQ(implicit_params->kind->value, "js-implicit");
result.implicit_kind = ImplicitKind::kJSImplicit;
}The difference matters: a js-implicit builtin receives the context,
receiver, target and newTarget slots of the JavaScript calling
convention, and the context it receives is a NativeContext, not a
generic Context. V8 builtins always embed the native context in their
closures, so making the type NativeContext lets the compiler drop a
load.
A builtin, end to end
Here is the whole of ArrayMapLoopContinuation, which is the slow path of
Array.prototype.map:
// src/builtins/array-map.tq:62
transitioning builtin ArrayMapLoopContinuation(
implicit context: Context)(_receiver: JSReceiver, callbackfn: Callable,
thisArg: JSAny, array: JSReceiver, o: JSReceiver, initialK: Number,
length: Number): JSAny {
// 6. Let k be 0.
// 7. Repeat, while k < len
for (let k: Number = initialK; k < length; k++) {
// 7a. Let Pk be ! ToString(k).
// k is guaranteed to be a positive integer, hence ToString is
// side-effect free and HasProperty/GetProperty do the conversion inline.
// 7b. Let kPresent be ? HasProperty(O, Pk).
const kPresent: Boolean = HasProperty_Inline(o, k);
// 7c. If kPresent is true, then:
if (kPresent == True) {
// i. Let kValue be ? Get(O, Pk).
const kValue: JSAny = GetProperty(o, k);
// ii. Let mapped_value be ? Call(callbackfn, T, kValue, k, O).
const mappedValue: JSAny =
Call(context, callbackfn, thisArg, kValue, k, o);
// iii. Perform ? CreateDataPropertyOrThrow(A, Pk, mapped_value).
FastCreateDataProperty(array, k, mappedValue);
}
// 7d. Increase k by 1. (done by the loop).
}
// 8. Return A.
return array;
}Note Call(context, callbackfn, thisArg, kValue, k, o) - that is the
Call extern macro, and the call into user JavaScript happens right
there. We will come back to it.
Now the lowering. The ImplementationVisitor emits a TF_BUILTIN wrapper
for every builtin, and turns the implicit parameters into real CSA
parameter loads:
// src/torque/implementation-visitor.cc:666
csa_ccfile() << "TF_BUILTIN(";
size_t begin = static_cast<size_t>(csa_ccfile().tellp());
csa_ccfile() << name;
size_t end = static_cast<size_t>(csa_ccfile().tellp());
if (GlobalContext::has_kythe_inline_metadata() &&
CurrentFileStreams::HasScope()) {
const SourcePosition ident_pos = builtin->IdentifierPosition();
if (auto* streams = CurrentFileStreams::Get();
streams && ident_pos.IsValid()) {
streams->csa_cc_rules.push_back(
GlobalContext::KytheInlineMetadata::CallAnchor(
begin, end, static_cast<size_t>(ident_pos.start.offset),
static_cast<size_t>(ident_pos.end.offset),
KytheSourceFilePath(ident_pos.source)));
}
}
csa_ccfile() << ", CodeStubAssembler) {\n"
<< " compiler::CodeAssemblerState* state_ = state();"
<< " compiler::CodeAssembler ca_(state());\n";The same function then walks the signature and emits one
UncheckedParameter<T>(Descriptor::kX) per argument. The Descriptor::
constants come from the generated interface descriptor, so the parameter
indices in the generated C++ are the same numbers the descriptor uses -
there is no second, hand-maintained list.
For a varargs JS builtin the prologue is longer, because the arguments live on the stack and have to be walked:
// src/torque/implementation-visitor.cc:701
csa_ccfile() << " TNode<Word32T> argc = UncheckedParameter<Word32T>("
<< "Descriptor::kJSActualArgumentsCount);\n";
csa_ccfile() << " TNode<IntPtrT> "
"arguments_length(ChangeInt32ToIntPtr(UncheckedCast<"
"Int32T>(argc)));\n";
csa_ccfile() << " TNode<RawPtrT> arguments_frame = "
"UncheckedCast<RawPtrT>(LoadFramePointer());\n";The interesting bit is LoadFramePointer(): the builtin reads its
arguments off the stack through the frame pointer, with the frame’s own
argument count deciding how many there are. That is why the js-implicit
convention can have a variable number of arguments without allocating an
array.
How a macro call becomes a CSA call
The CSA backend turns each IR instruction into a string of C++. For a
Torque-defined macro the generated call looks like a normal C++ call; for
an extern macro it is a call on a freshly constructed CodeStubAssembler:
// src/torque/csa-generator.cc:351
if (ExternMacro* extern_macro = ExternMacro::DynamicCast(instruction.macro)) {
out() << extern_macro->external_assembler_name() << "(state_).";
} else {
args.insert(args.begin(), "state_");
}
out() << instruction.macro->ExternalName() << "(";
PrintCommaSeparatedList(out(), args);The assembler name defaults to CodeStubAssembler:
// src/torque/torque-parser.cc:621
result = {MakeNode<ExternalMacroDeclaration>(
transitioning,
external_assembler_name ? *external_assembler_name
: "CodeStubAssembler",
name, operator_name, args, return_type, std::move(labels))};So extern transitioning macro Call(Context, JSAny, JSAny): JSAny;
becomes, in the generated file, a call to
CodeStubAssembler(state_).Call(context, callable, receiver).
A call to a Torque builtin is different: it is not inlined, it is a real
call to the builtin’s Code object:
// src/torque/csa-generator.cc:591
if (result_types.empty()) {
out() << "ca_.CallBuiltinVoid(Builtin::k"
<< instruction.builtin->ExternalName();
} else {
out() << " " << lhs_name << " = ";
out() << "ca_.CallBuiltin<" << lhs_type << ">(Builtin::k"
<< instruction.builtin->ExternalName();
}And a runtime call goes through the runtime-function table:
// src/torque/csa-generator.cc:743
out() << " " << result_name << " = ";
if (generated_type != "Object") out() << "TORQUE_CAST(";
out() << "CodeStubAssembler(state_).CallRuntime(Runtime::k"
<< instruction.runtime_function->ExternalName() << ", ";And TORQUE_CAST is not a checked cast. It expands to a trusted cast,
and the source is candid about why:
// src/compiler/code-assembler.h:608
// Currently casts from torque are unconditionally trusted.
#ifdef DEBUG
#define STRINGIFY(x) #x
#define TO_STRING_LITERAL(x) STRINGIFY(x)
#define CAST(x) \
Cast(x, "CAST(" #x ") at " __FILE__ ":" TO_STRING_LITERAL(__LINE__))
#define TORQUE_CAST(...) \
/* TODO: Should we trust all torque types? */ \
ca_.TrustedCastImpl(__VA_ARGS__, "CAST(" #__VA_ARGS__ ") at " __FILE__ \
":" TO_STRING_LITERAL(__LINE__))
#else
#define CAST(x) Cast(x)
#define TORQUE_CAST(...) ca_.TrustedCastImpl(__VA_ARGS__)
#endifIn other words, the check that TORQUE_CAST skips is exactly the check
the Torque type system is supposed to have already proved. When you write
Cast<JSArray>(x) otherwise Bailout, the type checker makes sure the
bailout is reachable; the generated C++ then trusts the result. The
TODO: Should we trust all torque types? is the maintainers admitting
that this is a promise, not a proof.
Context slots: a typed layout
Torque describes the NativeContext layout in the same file that
describes the object, src/objects/contexts.tq, and it makes the slot
type part of the index:
// src/objects/contexts.tq:107
macro ContextSlot<ArgumentContext: type, AnnotatedContext: type, T: type>(
context: ArgumentContext, index: Slot<AnnotatedContext, T>):&T {
const context: AnnotatedContext = context;
return torque_internal::unsafe::ReferenceCast<T>(&context.elements[index]);
}
macro NativeContextSlot<T: type>(
context: NativeContext, index: Slot<NativeContext, T>):&T {
return ContextSlot(context, index);
}The index type is Slot<NativeContext, T>, so a slot that is declared to
hold a Map can only be read as a Map. The enum itself is in the same
file:
// src/objects/contexts.tq:126
extern enum ContextSlot extends intptr constexpr 'Context::Field' {
SCOPE_INFO_INDEX: Slot<Context, ScopeInfo>,
// Zero is used for the NativeContext, Undefined is used for synthetic
// function contexts.
PREVIOUS_INDEX: Slot<Context, Context|Zero|Undefined>,
AGGREGATE_ERROR_FUNCTION_INDEX: Slot<NativeContext, JSFunction>,
ARRAY_BUFFER_FUN_INDEX: Slot<NativeContext, Constructor>,constexpr 'Context::Field' names the C++ enum that supplies the
constants, so the Torque declaration and the C++ Context::Field enum
are the same values by construction rather than by convention. A
NativeContext slot and a Context slot are different types, so you
cannot accidentally read a native-context slot out of a function context.
From a builtin to a Code object and a JSFunction
Two separate things happen, and it is worth keeping them apart.
First, the compiler emits the table. The checked-in copy of the generated header is in the tree:
// src/gen/intl-wasm/builtin-definitions.h:60
TFJ(ArrayMapPreLoopLazyDeoptContinuation, JSParameterCount(4), kReceiver, kCallback, kThisArg, kLength, kResult) \
TFJ(ArrayMapLoopEagerDeoptContinuation, JSParameterCount(5), kReceiver, kCallback, kThisArg, kArray, kInitialK, kLength) \
TFJ(ArrayMapLoopLazyDeoptContinuation, JSParameterCount(6), kReceiver, kCallback, kThisArg, kArray, kInitialK, kLength, kResult) \
TFC(ArrayMapLoopContinuation, ArrayMapLoopContinuation) \
TFJ(ArrayMap, kDontAdaptArgumentsSentinel) \
TFJ is “Torque function with JS linkage”; TFC is “Torque function with
CodeStub linkage”. The parameter names in the TFJ line are not
documentation - they are the generated descriptor’s parameter names, and
they are exactly the names the Torque source used.
For a stub builtin the compiler also emits a call descriptor, into a
separate .inc file:
// src/gen/intl-wasm/interface-descriptors.inc:145
class ArrayMapLoopContinuationDescriptor : public StaticCallInterfaceDescriptor<ArrayMapLoopContinuationDescriptor> {
public:
INTERNAL_DESCRIPTOR()
SANDBOXING_MODE(kSandboxed)
DEFINE_RESULT_AND_PARAMETERS(1, kReceiver, kCallbackfn, kThisArg, kArray, kO, kInitialK, kLength)
DEFINE_RESULT_AND_PARAMETER_TYPES(MachineType::AnyTagged(), MachineType::TaggedPointer(), MachineType::TaggedPointer(), MachineType::AnyTagged(), MachineType::TaggedPointer(), MachineType::TaggedPointer(), MachineType::AnyTagged(), MachineType::AnyTagged())
DECLARE_DEFAULT_DESCRIPTOR(ArrayMapLoopContinuationDescriptor)
};The number of parameters, their names and their machine types all come from the Torque signature. Nobody writes the argument order down twice.
Second, each builtin becomes a Code object - either generated at
isolate start-up or deserialized from the embedded blob that V8 ships in
the binary. src/builtins/setup-builtins-internal.cc drives the whole
builtin list with one macro per kind:
// src/builtins/setup-builtins-internal.cc:518
#define BUILD_TFJ_WITH_JOB(Name, Argc, ...) \
CompileJSLinkageCodeStubBuiltin(isolate, Builtin::k##Name, \
&Builtins::Generate_##Name, \
install_generated_builtin, Argc, #Name, \
job_creation_order++, scheduler);Builtins::Generate_##Name is the C++ function that the Torque backend
emitted, and install_generated_builtin puts the resulting Code into
the isolate’s builtin table. The code object itself is a
CodeKind::BUILTIN:
// src/objects/code-kind.h:19
#define CODE_KIND_LIST(V) \
V(BYTECODE_HANDLER) \
V(FOR_TESTING) \
V(FOR_TESTING_JS) \
V(BUILTIN) \
V(REGEXP) \Third, the JavaScript-visible function object. A builtin does not become
a JSFunction by itself; the bootstrapper creates one and installs it on
a prototype:
// src/init/bootstrapper.cc:953
Handle<JSFunction> SimpleInstallFunction(Isolate* isolate,
DirectHandle<JSObject> base,
const char* name, Builtin call,
int len, AdaptArguments adapt,
PropertyAttributes attrs) {
// Although function name does not have to be internalized the property name
// will be internalized during property addition anyway, so do it here now.
DirectHandle<String> internalized_name =
isolate->factory()->InternalizeUtf8String(name);
Handle<JSFunction> fun =
SimpleCreateFunction(isolate, internalized_name, call, len, adapt);
JSObject::AddProperty(isolate, base, internalized_name, fun, attrs);
return fun;
}So Array.prototype.map is a JSFunction whose SharedFunctionInfo
points at Builtin::kArrayMap, and the builtin’s Code object is the
implementation. The two are joined by the builtin id, not by a pointer.
There is no Redirection builtin
Older write-ups of V8 mention a Redirection trampoline. Grep the current
tree and you will not find one: Redirection survives in V8 only as the
name of the simulator’s external-reference redirection
(ExternalReference::Redirect in src/codegen/external-reference.cc),
which rewrites a C++ function pointer so the simulator can trap it.
The role people remember it playing - “turn a generic call into the right kind of call” - is now played by the call trampolines, and they are hand-written assembly, not CSA. A JS builtin calls back into JS through one of three of them, chosen by what it knows about the receiver:
// src/builtins/builtins-inl.h:85
constexpr Builtin Builtins::Call(ConvertReceiverMode mode) {
switch (mode) {
case ConvertReceiverMode::kNullOrUndefined:
return Builtin::kCall_ReceiverIsNullOrUndefined;
case ConvertReceiverMode::kNotNullOrUndefined:
return Builtin::kCall_ReceiverIsNotNullOrUndefined;
case ConvertReceiverMode::kAny:
return Builtin::kCall_ReceiverIsAny;
}
UNREACHABLE();
}and the three are generated by the MacroAssembler, one per architecture:
// src/builtins/builtins-call-gen.cc:45
void Builtins::Generate_Call_ReceiverIsNullOrUndefined(MacroAssembler* masm) {
Generate_Call(masm, ConvertReceiverMode::kNullOrUndefined);
}
void Builtins::Generate_Call_ReceiverIsNotNullOrUndefined(
MacroAssembler* masm) {
Generate_Call(masm, ConvertReceiverMode::kNotNullOrUndefined);
}
void Builtins::Generate_Call_ReceiverIsAny(MacroAssembler* masm) {
Generate_Call(masm, ConvertReceiverMode::kAny);
}The split exists because f.call(undefined) and f.call(someObject) have
different receiver-conversion rules, and specialising the trampoline on
what the caller knows lets the callee skip the check.
Calling back into JavaScript
Call is an extern macro, so it lands on the CSA side:
// src/builtins/base.tq:1646
// Call(Context, Target, Receiver, ...Args)
// TODO(joshualitt): Assuming the context parameter is for throwing when Target
// is non-callable, then we should make it an implicit
// parameter.
extern transitioning macro Call(Context, JSAny, JSAny): JSAny;
extern transitioning macro Call(Context, JSAny, JSAny, JSAny): JSAny;
extern transitioning macro Call(Context, JSAny, JSAny, JSAny, JSAny): JSAny;The CSA implementation picks the trampoline:
// src/codegen/code-stub-assembler-inl.h:23
template <typename TCallable, class... TArgs>
TNode<JSAny> CodeStubAssembler::Call(TNode<Context> context,
TNode<TCallable> callable,
ConvertReceiverMode mode,
TNode<JSAny> receiver, TArgs... args) {
static_assert(is_subtype_v<TCallable, Object>);
static_assert(!is_subtype_v<TCallable, JSFunction>,
"Use CallFunction() when the callable is a JSFunction.");
if (IsUndefinedConstant(receiver) || IsNullConstant(receiver)) {
DCHECK_NE(mode, ConvertReceiverMode::kNotNullOrUndefined);
return CallJS(Builtins::Call(ConvertReceiverMode::kNullOrUndefined),
context, callable, receiver, args...);
}
DCheckReceiver(mode, receiver);
return CallJS(Builtins::Call(mode), context, callable, receiver, args...);
}Two details are worth stopping on. The static_assert refuses to call a
JSFunction through the generic Call - you have to use CallFunction,
which skips the callable-type dispatch. And the receiver test is a C++
if, evaluated while the graph is being built, not a branch in the
emitted code:
// src/compiler/code-assembler.cc:497
bool CodeAssembler::IsUndefinedConstant(TNode<Object> node) {
compiler::HeapObjectMatcher m(node);
return m.Is(isolate()->factory()->undefined_value());
}So if the Torque code hands Call the literal Undefined constant, the
kNullOrUndefined trampoline is selected at graph-construction time and
the other two never appear in the generated code at all.
transitioning is what makes this legal. Call can run arbitrary
JavaScript, which can reallocate the heap and invalidate a FastJSArray
witness, so the type system will not let a transient value survive the
call.
Tail calls
Torque has a TailCall statement, and it is restricted:
// src/torque/implementation-visitor.cc:2924
if (is_tailcall) {
if (Builtin* builtin = Builtin::DynamicCast(CurrentCallable::Get())) {
const Type* outer_return_type = builtin->signature().return_type;
if (!return_type->IsSubtypeOf(outer_return_type)) {
Error("Cannot tailcall, type of result is ", *return_type,
" but should be a subtype of ", *outer_return_type, ".");
}
} else {
Error("Tail calls are only allowed from builtins");
}
}Two rules, both about keeping the stack honest. A tail call can only happen from a builtin, because a tail call is the builtin - there is no frame to return to, so the callee has to accept the caller’s frame shape. And the result type has to be a subtype of the builtin’s declared return type, so the return value slot is still correct after the jump.
In the backend, a tail call becomes a TailCallBuiltin or a
TailCallJSBuiltin - a jump, not a call - which is why a chain of Torque
builtins does not grow the stack.
Trade-offs
What Torque buys.
- Spec fidelity. The source of
Array.prototype.mapcan be compared line by line with the ECMAScript text, because the step numbers are in the comments. - One calling convention, derived. The parameter count, the parameter names, the machine types and the descriptor all come from one signature. There is no way for the descriptor and the implementation to disagree.
- Checked casts.
Cast<T>(x) otherwise Llowers to a generatedDownCastForTorqueClass<T>that compares the object’s instance type against the class’sFIRST_/LAST_range and branches toLon failure. The generator even emits the range form rather than integer literals, “because after the metagen cutover the IT values come fromtools/metagen/, not from Torque”. - Portable output. The same
.tqcompiles to x64, arm64 and ia32 code without a line of architecture-specific source.
What Torque costs.
- A compiler in the compiler.
src/torque/is roughly 29 000 lines of C++ that have to be maintained, and every V8 contributor has to learn a second language to touch a builtin. - No higher-order macros, no closures. Torque is a control-flow language: there are no lambdas, no first-class functions, and no way to pass a macro as an argument. Some of the uglier Torque in the tree exists purely because the language has no closures.
- Everything is compiled twice. The CSA backend and the CC backend
(which generates C++ class definitions and debug readers) both have to
agree about what a Torque
structmeans. - A slow migration path. V8 is currently moving builtins from CSA to the Turboshaft assembler, and Torque has a third backend for that. Every new backend is another place for the type system to leak.
The bottom line
Torque is a typed macro language whose only job is to emit calls into another API. It has no arithmetic, no comparison and no loop of its own - just over twenty IR instructions and a type checker. Everything that looks like a language feature (context slots, calling conventions, cast checking, transient-type invalidation) is really a way of making the CSA call underneath it correct by construction.
The mental model to keep: a Torque builtin is a description, and the
compiler turns that description into three artefacts - a Code object, a
call descriptor, and a JSFunction wrapper - none of which the author
wrote by hand.
The thing people get wrong
Torque is not “C++ with nicer syntax”, and it is not a JavaScript
dialect. The most common misreading is that transitioning is a
documentation annotation - “this function might call back into JS”. It is
not: it is a type-system annotation. It is what makes it illegal to
hold a FastJSArray across a call that might reallocate the heap, and
the compiler enforces that at compile time.
The other common misreading is that a Torque builtin and a Torque
macro differ only in syntax. They differ in linking. A macro is
inlined into its caller by the Torque compiler and disappears. A builtin
becomes a separate Code object with its own descriptor and its own entry
in the builtin table, and calling it is a real call with a real argument
frame.
For the wider picture of where the generated code ends up, see Code Stub Assembler: How V8 Writes Machine Code by Hand.