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-levelAssembler(one class per architecture), theMacroAssembleron top of it, and thecompiler::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:
// 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:
// 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:
// 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:
// 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:
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 CSAThe 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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>:
// 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:
// 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:
// 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:
// 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:
// 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.
// 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:
// 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:
// 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:
// 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:
// 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”):
// 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:
// 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:
// 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:
// 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:
// 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>andTNode<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.
CallCFunctionlets 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
objdumpto 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/MacroAssemblerandCodeAssembler/CodeStubAssemblerboth exist, and the choice between them is per builtin. The comment indocs/builtins/architecture.mdis 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_TSin 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.