Code Stub Assembler: How V8 Writes Machine Code by Hand

The Code Stub Assembler is the portable, typed layer V8 uses to emit machine code: it builds a TurboFan graph, not bytes, so the same source becomes x64, arm64 or ia32 code.

TL;DR: The Code Stub Assembler is not an assembler. CodeStubAssembler (src/codegen/code-stub-assembler.h) does not emit a single byte - it builds a TurboFan graph, and TurboFan’s per-architecture backend turns that graph into machine code. That is the entire reason the same builtin source compiles to x64, arm64 and ia32 unchanged. Underneath it sit three more layers: the byte-level Assembler (one class per architecture), the MacroAssembler on top of it, and the compiler::RawMachineAssembler, which is the graph builder that CSA actually talks to.

The mistake everybody makes first

The name is the problem. “Code Stub Assembler” sounds like movq(rax, rbx) with a type annotation, and it is not. Compare the two layers:

cpp
// src/codegen/x64/assembler-x64.h:639
class V8_EXPORT_PRIVATE Assembler : public AssemblerBase {
 private:
  // We check before assembling an instruction that there is sufficient
  // space to write an instruction and its relocation information.

That is the byte layer: one method per x86 instruction, one file per architecture. src/codegen/x64/assembler-x64.h, src/codegen/arm64/assembler-arm64.h and src/codegen/ia32/assembler-ia32.h each declare their own class Assembler : public AssemblerBase.

The Code Stub Assembler is somewhere else entirely:

cpp
// src/codegen/code-stub-assembler.h:66
// Provides JavaScript-specific "macro-assembler" functionality on top of the
// CodeAssembler. By factoring the JavaScript-isms out of the CodeAssembler,
// it's possible to add JavaScript-specific useful CodeAssembler "macros"
// without modifying files in the compiler directory (and requiring a review
// from a compiler directory OWNER).
class V8_EXPORT_PRIVATE CodeStubAssembler
    : public compiler::CodeAssembler,
      public TorqueGeneratedExportedMacrosAssembler {

compiler::CodeAssembler is the graph builder, and it says so:

cpp
// src/compiler/code-assembler.h:387
// A "public" interface used by components outside of compiler directory to
// create code objects with TurboFan's backend. This class is mostly a thin
// shim around the RawMachineAssembler, and its primary job is to ensure that
// the innards of the RawMachineAssembler and other compiler implementation
// details don't leak outside of the compiler directory.

And the thing underneath that is a graph too:

cpp
// src/compiler/raw-machine-assembler.h:36
// The RawMachineAssembler produces a low-level IR graph. All nodes are wired
// into a graph and also placed into a schedule immediately, hence subsequent
// code generation can happen without the need for scheduling.
//
// In order to create a schedule on-the-fly, the assembler keeps track of basic
// blocks by having one current basic block being populated and by referencing
// other basic blocks through the use of labels.

So the real stack, bottom to top, is:

text
  TurboFan backend (x64 / arm64 / ia32 / ...)   -> machine bytes
  compiler::RawMachineAssembler                 -> low-level IR graph
  compiler::CodeAssembler                       -> public, typed graph API
  CodeStubAssembler (CSA)                       -> JS-flavoured graph API
  Torque                                        -> generates C++ that calls CSA

The byte-level Assembler and MacroAssembler are a different branch of the tree. They are used by hand-written assembly builtins, by Sparkplug, by Maglev, and by the runtime’s own stubs. They never see a TurboFan graph. If you are reading a builtin that is listed as ASM(...) in src/builtins/builtins-definitions.h, it is in that branch, and it exists precisely because it needs to manipulate the stack in ways that TurboFan’s fixed frame layout cannot express.

What CSA adds on top

Two things.

First, JavaScript types. TNode<Smi>, TNode<JSArray>, TNode<Context>, TNode<NativeContext> are C++ types, so the compiler catches Smi + String at compile time. TNode<AnyTaggedT> is the escape hatch.

Second, JavaScript semantics: allocating a HeapObject, loading a field off a Map, checking an elements kind, reading a Context slot. None of that lives in compiler::CodeAssembler, which is organised around machine types rather than around V8’s object model.

The dividing line between “graph API” and “JavaScript API” is visible in where the Call helpers live. compiler::CodeAssembler has the generic ones:

cpp
// src/compiler/code-assembler.h:1399
  // Calls
  template <class T = Object, class... TArgs>
  TNode<T> CallRuntime(Runtime::FunctionId function, TNode<Object> context,
                       TArgs... args) {
    return UncheckedCast<T>(CallRuntimeImpl(
        function, context, {implicit_cast<TNode<Object>>(args)...}));
  }

and CodeStubAssembler adds the JavaScript-specific ones on top: CallBuiltin, CallJS, CallFunction, Allocate, TaggedEqual, LoadMap, and so on.

One source, three architectures

The portability is not magic and it is not in CSA at all. It comes from the fact that CSA is a graph API, so TurboFan’s per-architecture instruction selector and code generator do the final lowering. V8 ships those backends under src/compiler/backend/, one directory per architecture: x64/, arm64/, ia32/, arm/, ppc/, riscv/, s390/, mips64/, loong64/.

What CSA does own is the set of decisions that have to look the same on every architecture. Two of them are interesting.

The first is CPU feature detection. Some instructions are not available everywhere, so CSA emits a branch on a unique constant - a constant the graph optimiser cannot fold - and provides a software fallback:

cpp
// src/codegen/code-stub-assembler.cc:440
TNode<Float64T> CodeStubAssembler::Float64Ceil(TNode<Float64T> x) {
  TVARIABLE(Float64T, var_x, x);
  Label round_op_supported(this), round_op_fallback(this), return_x(this);
  // Use UniqueInt32Constant instead of BoolConstant here in order to ensure
  // that the graph structure does not depend on the value of the predicate
  // (BoolConstant uses cached nodes).
  Branch(UniqueInt32Constant(IsFloat64RoundUpSupported()), &round_op_supported,
         &round_op_fallback);

  BIND(&round_op_supported);
  {
    // This optional operation is used behind a static check and we rely
    // on the dead code elimination to remove this unused unsupported
    // instruction. We generate builtins this way in order to ensure that
    // builtins PGO profiles are interchangeable between architectures.
    var_x = Float64RoundUp(x);
    Goto(&return_x);
  }

That comment is the whole portability story in miniature: the shape of the graph must not depend on the machine, or the same builtin would have a different PGO profile on x64 and on arm64 and the profiles could not be shipped in a snapshot.

The second is data representation. Smi might be 31 or 32 bits depending on the pointer-compression configuration; a tagged pointer might be 4 or 8 bytes. CSA never asks. It asks for SmiAdd(a, b) and the answer is correct because the graph node has a MachineRepresentation that was chosen at build time.

TaggedEqual: the cost of comparing two objects

This is the smallest interesting function in the file:

cpp
// src/codegen/code-stub-assembler.h:299
  TNode<BoolT> TaggedEqual(TNode<AnyTaggedT> a, TNode<AnyTaggedT> b,
                           SourceLocation loc = SourceLocation::Current()) {
#if defined(V8_EXTERNAL_CODE_SPACE) || defined(V8_ENABLE_SANDBOX)
    if (v8_flags.enable_slow_asserts) {
      CheckObjectComparisonAllowed(a, b, loc);
    }
#endif
    if (COMPRESS_POINTERS_BOOL) {
      return Word32Equal(ReinterpretCast<Word32T>(a),
                         ReinterpretCast<Word32T>(b));
    } else {
      return WordEqual(ReinterpretCast<WordT>(a), ReinterpretCast<WordT>(b));
    }
  }

Two things to notice. First, it is a word comparison, not a call to Object::Equals: comparing two references for identity is a machine instruction, and Torque’s TaggedEqual (used, for example, to test whether an IC slot holds the megamorphic symbol) inherits that. Second, it is the place where the sandbox’s rules bite: under V8_ENABLE_SANDBOX, comparing two objects that live in different pointer compression cages is illegal, so there is a separate SafeEqual for objects that may legitimately live in different cages:

cpp
// src/codegen/code-stub-assembler.h:319
  // A variant of TaggedEqual which allows comparing objects in different
  // pointer compression cages. In particular, this should be used when
  // comparing objects in trusted- or code space with objects in the main
  // pointer compression cage. Currently only defined for HeapObject, this
  // type can be expanded if needed.
  TNode<BoolT> SafeEqual(TNode<HeapObject> a, TNode<HeapObject> b) {

The distinction between TaggedEqual and SafeEqual is the kind of detail that produces a sandbox bug report months later.

The helpers that matter

CallRuntime - call a C++ runtime function. The implementation shows the whole trampoline: the runtime call goes through the RuntimeCEntry builtin, with the runtime function’s address passed as an external constant:

cpp
// src/compiler/code-assembler.cc:1298
  Builtin centry =
      Builtins::RuntimeCEntry(result_size, switch_to_the_central_stack);
  TNode<Code> centry_code =
      HeapConstantNoHole(isolate()->builtins()->code_handle(centry));
  constexpr size_t kMaxNumArgs = 7;
  DCHECK_GE(kMaxNumArgs, args.size());
  int argc = static_cast<int>(args.size());
  auto call_descriptor = Linkage::GetRuntimeCallDescriptor(
      zone(), function, argc, Operator::kNoProperties,
      Runtime::MayAllocate(function) ? CallDescriptor::kNoFlags
                                     : CallDescriptor::kNoAllocate);

  TNode<ExternalReference> ref =
      ExternalConstant(ExternalReference::Create(function));
  TNode<Int32T> arity = Int32Constant(argc);

  NodeArray<kMaxNumArgs + 4> inputs;
  inputs.Add(centry_code);
  for (const auto& arg : args) inputs.Add(arg);
  inputs.Add(ref);
  inputs.Add(arity);
  inputs.Add(context);

  CallPrologue();
  Node* return_value =
      raw_assembler()->CallN(call_descriptor, inputs.size(), inputs.data());
  HandleException(return_value);
  CallEpilogue();
  return return_value;

Runtime::MayAllocate(function) picks the call descriptor flags, which tells the garbage collector whether this call site can trigger a GC. That is the kind of metadata that is easy to get wrong by hand and free to get right in one place.

TailCallRuntime is the same thing without a return: it is a jump, so the frame does not grow.

CallCFunction is for calling a raw C++ function pointer, without the runtime-call machinery:

cpp
// src/compiler/code-assembler.cc:1590
Node* CodeAssembler::CallCFunction(
    Node* function, std::optional<MachineType> return_type,
    std::initializer_list<CodeAssembler::CFunctionArg> args) {
  return raw_assembler()->CallCFunction(function, return_type, args);
}

CallBuiltin is how one builtin calls another. It is worth reading because the call descriptor is not a constant - it is looked up from the builtin id:

cpp
// src/compiler/code-assembler.h:1439
  template <typename T = Object, class... TArgs>
  TNode<T> CallBuiltin(Builtin id, TNode<Object> context, TArgs... args) {
    DCHECK_WITH_MSG(!Builtins::HasJSLinkage(id), "Use CallJSBuiltin instead");
    TNode<RawPtrT> old_sp;
#if V8_ENABLE_WEBASSEMBLY
    bool maybe_needs_switch = wasm::BuiltinLookup::IsWasmBuiltinId(builtin()) &&
                              !wasm::BuiltinLookup::IsWasmBuiltinId(id);
    if (maybe_needs_switch) {
      old_sp = SwitchToTheCentralStackIfNeeded();
    }
#endif
    Callable callable = Builtins::CallableFor(isolate(), id);
    TNode<Code> target = HeapConstantNoHole(callable.code());
    TNode<T> call =
        CallStub<T>(callable.descriptor(), target, context, args...);

The DCHECK_WITH_MSG is a real constraint, not decoration: a JS-linkage builtin has a different calling convention (an argument count, a dispatch handle, a new target) and must be called with CallJSBuiltin instead.

Allocate is the bump-pointer allocator with a runtime escape hatch. This is the single most instructive function in CSA:

cpp
// src/codegen/code-stub-assembler.cc:1656
    TNode<Smi> runtime_flags = SmiConstant(
        Smi::FromInt(needs_double_alignment ? kDoubleAligned : kTaggedAligned));
    result =
        CallRuntime(Runtime::kAllocateInYoungGeneration, NoContextConstant(),
                    SmiTag(size_in_bytes), runtime_flags);
    Goto(&out);

    BIND(&next);
  }

  TVARIABLE(IntPtrT, adjusted_size, size_in_bytes);

and then the fast path:

cpp
// src/codegen/code-stub-assembler.cc:1679
  TNode<IntPtrT> new_top =
      IntPtrAdd(UncheckedCast<IntPtrT>(top), adjusted_size.value());

  Branch(UintPtrGreaterThanOrEqual(new_top, limit), &runtime_call,
         &no_runtime_call);

So: load the current top and the limit from the heap, add the (aligned) size, and if the new top would cross the limit, take the slow path and ask the runtime to allocate. The size is checked to be a positive Smi first, which is why the slow path only ever sees sane sizes.

What a CSA builtin looks like

The TF_BUILTIN macro is the harness. It builds a small assembler class whose Descriptor is the generated interface descriptor, and it wires the builtin into Builtins::Generate_<Name>:

cpp
// src/builtins/builtins-utils-gen.h:31
#define TF_BUILTIN(Name, AssemblerBase)                                     \
  class Name##Assembler : public AssemblerBase {                            \
   public:                                                                  \
    using Descriptor = Builtin_##Name##_InterfaceDescriptor;                \
                                                                            \
    explicit Name##Assembler(compiler::CodeAssemblerState* state)           \
        : AssemblerBase(state) {}                                           \
    void Generate##Name##Impl();                                            \
                                                                            \
    template <class T>                                                      \
    TNode<T> Parameter(                                                     \
        Descriptor::ParameterIndices index,                                 \
        cppgc::SourceLocation loc = cppgc::SourceLocation::Current()) {     \
      return CodeAssembler::Parameter<T>(static_cast<int>(index), loc);     \
    }                                                                       \
                                                                            \
    template <class T>                                                      \
    TNode<T> UncheckedParameter(Descriptor::ParameterIndices index) {       \
      return CodeAssembler::UncheckedParameter<T>(static_cast<int>(index)); \
    }                                                                       \
  };                                                                        \
  void Builtins::Generate_##Name(compiler::CodeAssemblerState* state) {     \
    Name##Assembler assembler(state);                                       \
    state->SetInitialDebugInformation(#Name, __FILE__, __LINE__);           \
    if (Builtins::KindOf(Builtin::k##Name) == Builtins::TFJ) {              \
      assembler.PerformStackCheck(assembler.GetJSContextParameter());       \
    }                                                                       \
    assembler.Generate##Name##Impl();                                       \
  }                                                                         \
  void Name##Assembler::Generate##Name##Impl()

Two details in there are worth more than the whole macro. The parameter index comes from Descriptor::ParameterIndices, i.e. from the generated call descriptor, so the implementation cannot disagree with the calling convention. And the stack check is inserted automatically for JS-linkage builtins (TFJ), because a JS builtin can recurse and must check the stack limit on entry.

A complete CSA builtin is then tiny:

cpp
// src/builtins/builtins-handler-gen.cc:46
TF_BUILTIN(LoadIC_StringLength, CodeStubAssembler) {
  auto string = Parameter<String>(Descriptor::kReceiver);
  Return(LoadStringLengthAsSmi(string));
}

TF_BUILTIN(LoadIC_StringWrapperLength, CodeStubAssembler) {
  auto value = Parameter<JSPrimitiveWrapper>(Descriptor::kReceiver);
  TNode<String> string = CAST(LoadJSPrimitiveWrapperValue(value));
  Return(LoadStringLengthAsSmi(string));
}

CAST is a compile-time checked cast, not a runtime branch. The CheckedNode template static_asserts that the cast is legal at all (“Incompatible types: this cast can never succeed.”) and, in debug builds only, calls ExternalReference::check_object_type() to verify the instance type. If you want a runtime check, you write the GotoIfNot yourself; UncheckedCast skips even the static_assert.

From CSA source to a Code object

The builtin table is generated by the same macro machinery that generates the Torque builtin list, and there are three kinds of builtin that matter here:

cpp
// src/builtins/builtins.h:643
#define DECLARE_ASM(Name, ...) \
  static void Generate_##Name(MacroAssembler* masm);
#define DECLARE_TF(Name, ...) \
  static void Generate_##Name(compiler::CodeAssemblerState* state);
#define DECLARE_TS(Name, ...)                                           \
  static void Generate_##Name(compiler::turboshaft::PipelineData* data, \
                              Isolate* isolate,                         \
                              compiler::turboshaft::Graph& graph, Zone* zone);

DECLARE_TF is CSA. DECLARE_ASM is the byte-level branch. DECLARE_TS is the new Turboshaft branch, which is where V8 is heading.

At start-up, src/builtins/setup-builtins-internal.cc walks the whole list. CSA builtins are handed to TurboFan as compilation jobs:

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);

while the assembly ones are built directly into a buffer:

cpp
// src/builtins/setup-builtins-internal.cc:564
#define BUILD_ASM_WITHOUT_JOB(Name, InterfaceDescriptor)            \
  code = BuildWithMacroAssembler(isolate, Builtin::k##Name,         \
                                 Builtins::Generate_##Name, #Name); \
  generated_builtins[Builtins::ToInt(Builtin::k##Name)] =           \
      handle(code, isolate);                                        \
  builtins_built_without_job_count++;

The two paths converge on the same thing: a Code object with a builtin id, installed into the isolate’s table.

cpp
// src/builtins/setup-builtins-internal.cc:604
  // Add the generated builtins to the isolate.
  for (Builtin builtin = Builtins::kFirst; builtin <= Builtins::kLast;
       ++builtin) {
    AddBuiltin(builtins, builtin,
               *generated_builtins[Builtins::ToInt(builtin)]);
  }

The scale of this is worth a number. The checked-in generated builtin list for one configuration (src/gen/intl-wasm/builtin-definitions.h) contains 358 TFJ entries and 395 TFC entries - roughly 750 builtins whose calling conventions are all derived from a single signature each.

The bytes of that code live in the code space, which is a heap space with its own allocator and its own GC rules:

cpp
// src/heap/factory.cc:281
    result =
        heap->heap()->allocator()->AllocateRawWith<HeapAllocator::kRetryOrFail>(
            object_size, AllocationType::kCode, AllocationOrigin::kRuntime);

AllocationType::kCode is the important bit. Code is not in the young generation, it is not moved by the scavenger, and it is written and then made immutable.

Keeping the debugger honest

A CSA builtin is machine code that nobody wrote by hand, so the debugger needs a way to map an instruction pointer back to something a human wrote. Two mechanisms do that.

The first is a source position attached to the graph nodes. The API is deliberately explicit about whose source positions these are:

cpp
// src/compiler/code-assembler.h:830
  // The following methods refer to source positions in CSA or Torque code
  // compiled during mksnapshot, not JS compiled at runtime.
  void SetSourcePosition(const char* file, int line);

The Torque backend fills those in, one line per Torque statement, and converts from Torque’s zero-based lines to the one-based lines the rest of V8 uses:

cpp
// src/torque/csa-generator.cc:92
void CSAGenerator::EmitSourcePosition(SourcePosition pos, bool always_emit) {
  const std::string& file = SourceFileMap::AbsolutePath(pos.source);
  if (always_emit || !previous_position_.CompareStartIgnoreColumn(pos)) {
    // Lines in Torque SourcePositions are zero-based, while the
    // CodeStubAssembler and downwind systems are one-based.
    out() << "    ca_.SetSourcePosition(\"" << file << "\", "
          << (pos.start.line + 1) << ");\n";
    previous_position_ = pos;
  }
}

The previous_position_ check is an optimisation, and a nice one: the position is only re-emitted when the line changes, not when the column does, so a long basic block does not pay for a source position per statement.

The second is the initial debug information, which is what shows up in a stack trace when a builtin throws:

cpp
// src/builtins/builtins-utils-gen.h:52
  void Builtins::Generate_##Name(compiler::CodeAssemblerState* state) {     \
    Name##Assembler assembler(state);                                       \
    state->SetInitialDebugInformation(#Name, __FILE__, __LINE__);           \

__FILE__ and __LINE__ here are the generated file and line, not the Torque source. The Torque source position is what the graph nodes carry; this is the fallback for the frames that have no graph node to point at.

The inline cache handlers are CSA too

The property-access ICs are builtins, and their implementations are CSA. src/builtins/builtins-definitions.h lists them under a TFH (“handlers in Turbofan, with CodeStub linkage”):

cpp
// src/builtins/builtins-definitions.h:869
  TFH(LoadIC, LoadWithVector)                                                  \
  TFH(LoadIC_Megamorphic, LoadWithVector)                                      \
  TFH(LoadIC_Noninlined, LoadWithVector)                                       \
  TFH(LoadICTrampoline, Load)                                                  \

and each one is a Builtins::Generate_* function that constructs a CSA subclass:

cpp
// src/builtins/builtins-ic-gen.cc:17
void Builtins::Generate_LoadIC(compiler::CodeAssemblerState* state) {
  AccessorAssembler assembler(state);
  assembler.GenerateLoadIC();
}
void Builtins::Generate_LoadIC_Megamorphic(
    compiler::CodeAssemblerState* state) {
  AccessorAssembler assembler(state);
  assembler.GenerateLoadIC_Megamorphic();
}

AccessorAssembler (src/ic/accessor-assembler.h) is a CodeStubAssembler subclass, and it is where the megamorphic stub-cache probe lives:

cpp
// src/ic/accessor-assembler.cc:4840
void AccessorAssembler::GenerateLoadIC_Megamorphic() {
  using Descriptor = LoadWithVectorDescriptor;

  auto receiver = Parameter<JSAny>(Descriptor::kReceiver);
  auto name = Parameter<Object>(Descriptor::kName);
  auto slot = Parameter<TaggedIndex>(Descriptor::kSlot);
  auto vector = Parameter<HeapObject>(Descriptor::kVector);
  auto context = Parameter<Context>(Descriptor::kContext);

  ExitPoint direct_exit(this);
  TVARIABLE(MaybeObject, var_handler);
  Label if_handler(this, &var_handler), miss(this, Label::kDeferred);

and the miss goes to a runtime:

cpp
// src/ic/accessor-assembler.cc:4882
  BIND(&miss);
  direct_exit.ReturnCallRuntime(Runtime::kLoadIC_Miss, context, receiver, name,
                                slot, vector);

So the inline cache story has two halves: the interpreter and TurboFan patch the site, and the handler that runs on a miss is a CSA builtin. The LoadIC_Miss runtime then re-runs the lookup in C++ and installs a new handler.

Two different “handler tables”

Worth disambiguating, because the phrase means two things in V8.

The IC handler is what we just described: a Code object selected by the feedback vector. There is no table; the feedback slot holds a Smi that encodes the handler.

The handler table (src/codegen/handler-table.h) is something else entirely - it is the exception-handler table of a code object:

cpp
// src/codegen/handler-table.h:25
// HandlerTable is a byte array containing entries for exception handlers in
// the code object it is associated with. The tables come in two flavors:
// 1) Based on ranges: Used for unoptimized code. Stored in a
//   {TrustedByteArray} that is attached to each {BytecodeArray}. Contains one
//   entry per exception handler and a range representing the try-block covered
//   by that handler. Layout looks as follows:
//      [ range-start , range-end , handler-offset , handler-data ]
// 2) Based on return addresses: Used for turbofanned code. Stored directly in
//    the instruction stream of the {InstructionStream} object. Contains one
//    entry per call-site that could throw an exception. Layout looks as
//    follows:
//      [ return-address-offset , handler-offset ]

The second flavour is the interesting one: for TurboFan-generated code there is no try/catch structure, so V8 reconstructs one at unwind time by scanning backwards from the return address. Every call site that could throw has an entry.

Trade-offs

What CSA buys.

  • One source per builtin, nine architectures. The x64, arm64 and ia32 backends exist to lower the graph; the builtin source does not change.
  • A real type system. TNode<Smi> and TNode<String> cannot be mixed.
  • The TurboFan optimiser for free. Because CSA produces a graph, dead code elimination, register allocation and instruction scheduling all apply to builtins. A hand-written assembly builtin gets none of that.
  • Cheap cross-language calls. CallCFunction lets a builtin call into C++ without going through the full runtime-call protocol.

What CSA costs.

  • It is not an assembler. You cannot see the instructions. When something is slow there is no objdump to look at, and the only way to reason about the output is to dump the TurboFan graph.
  • It is a graph, so it is slower to generate. A builtin that is generated at every isolate start-up pays graph construction cost. That is why V8 embeds builtins in the binary, and why there is a “short builtin calls” mechanism that remaps the embedded blob into the isolate’s code range so generated code can reach it with a short PC-relative call.
  • Two layers of the same idea. Assembler/MacroAssembler and CodeAssembler/CodeStubAssembler both exist, and the choice between them is per builtin. The comment in docs/builtins/architecture.md is blunt about it: architecture-specific builtins “must be used for any operations that need to manipulate the stack … because TurboFan code assumes a fixed stack layout”.
  • A third backend is coming. DECLARE_TS in the same macro block is the Turboshaft assembler, and CSA is on the way out.

The bottom line

Think of CSA as a typed, JavaScript-aware, architecture-neutral intermediate language that happens to be written in C++. The API is deliberately small: build a graph of loads, stores, branches and calls; let TurboFan lower it; let the per-architecture backend emit bytes.

The mental model to keep is that a CSA builtin is not “assembly that happens to be portable”. It is a program in a graph language, and the portability is a consequence of never touching bytes yourself.

The thing people get wrong

The common misreading is that CSA is an abstraction over machine instructions, so that a CSA builtin compiles to roughly the same instructions everywhere. It does not. CSA is an abstraction over a TurboFan graph, so a CSA builtin is optimised. Concretely: a CSA builtin gets dead-code elimination, constant folding, register allocation and instruction scheduling; a MacroAssembler builtin gets none of those, because it is already bytes by the time anybody can look at it.

The second misreading is that CSA and the MacroAssembler are two spellings of the same thing. They are two different branches of the tree with different performance characteristics and different rules about what you may do - most importantly, only the MacroAssembler branch can manipulate the stack, because TurboFan-generated code assumes a fixed frame layout.

For the language that generates most of V8’s builtins, see Torque: The DSL V8 Uses to Write Its Own Builtins.

V8InternalsCompilerCodegenBuiltins