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.map could be read like the ECMAScript spec instead of like a C++ template. A .tq file 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 of ArrayMapLoopContinuation is 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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.

cpp
// 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.

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

torque
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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__)
#endif

In 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:

torque
// 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:

torque
// 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:

c
// 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:

c
// 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:

cpp
// 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:

c
// 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:

cpp
// 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:

c
// 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:

cpp
// 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:

torque
// 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:

cpp
// 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:

cpp
// 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:

cpp
// 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.map can 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 L lowers to a generated DownCastForTorqueClass<T> that compares the object’s instance type against the class’s FIRST_/LAST_ range and branches to L on failure. The generator even emits the range form rather than integer literals, “because after the metagen cutover the IT values come from tools/metagen/, not from Torque”.
  • Portable output. The same .tq compiles 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 struct means.
  • 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.

V8InternalsBuiltinsTorqueCompiler