Backend Translation¶
A backend is a translation of MLIR: one pass pipeline turns every serialisation plan into a
serialise and a deserialise function of dialect operations, and a backend spells those functions
in its language. DESIGN.md
states this as the backend contract; ctest -L backend-contract accepts a backend against it, and
nothing else does. Every backend meets the contract, and this page is the record of the work.
Bodies were not derived from the IR¶
Before this work, every emitter called collectLoweredFactsFromMlir, and for five of them that
was the whole of their MLIR consumption. The LoweredFactsMap it returns holds, per field, a step
index and the names of helper symbols; per section, a capacity-check helper name, the union tag
width and the alias flag. It holds no operations. The serialise and deserialise bodies of C++,
Rust and Go came from SerDesStatementPlan and NativeEmitterTraversal; those of TypeScript and
Python from RuntimeLoweredPlan and ScriptedOperationPlan — planners in lib/CodeGen that
walked the semantic module and decided the control flow themselves. C and obj translated the
output of build-dsdl-plan-bodies: through EmitC for C source and through the LLVM dialect for
objects.
The gates measure exactly this. Perturb a dsdl.io operation's width with the semantic module
held constant and a planned body does not change; perturb the semantic module's cast mode with
the operations held constant and it does. Three times the architecture was asked for and
delivered in that shape, each time passing as the real thing because every emitter consumed the
dialect. Consuming lowered facts is not translating lowered operations.
The pipeline is one¶
lower-dsdl-bodies is lower-dsdl-exec, dsdl-verify-alias-layout and
build-dsdl-plan-bodies, defined once in lib/Transforms as addLowerDSDLBodiesPipeline and
registered with dsdl-opt under that name. dsdlc runs it once, over the module every backend
receives; each plan yields three bodies, serialise, deserialise and initialise; --optimize-lowered-serdes canonicalises the bodies and their helpers after
build-dsdl-plan-bodies, so it acts on every backend alike. The C lane takes each definition's schema, and the functions built for it, from that
module; stamps C names on the schema; and converts them.
The body IR is target-neutral¶
A body spells nothing the way C does:
| in the body | the C conversions spell it as |
|---|---|
!dsdl.ptr<!dsdl.object<"vendor.Msg.1.0">>, !dsdl.ptr<!dsdl.byte>, !dsdl.ptr<!dsdl.size>, const carried on the pointer |
struct vendor__Msg*, uint8_t*, size_t* |
dsdl.load_member %obj "field", and the store, address, element and length forms, each carrying the DSDL member name |
the member's c_name from the schema |
dsdl.array_length, dsdl.set_array_length |
.count |
dsdl.element_addr, dsdl.load_element, dsdl.store_element, carrying the element's storage category and width |
.elements[i], or .bitpacked for a bool array |
dsdl.union_tag, dsdl.set_union_tag |
._tag_ |
dsdl.call_serdes @vendor_Inner_1_0__serialize_ir_, carrying the member and the direction |
vendor__Inner__serialize_ |
dsdl.call_initialize @vendor_Inner_1_0__initialize_ir_, carrying the member |
vendor__Inner__initialize_ |
dsdl.store_view, dsdl.clear_view, dsdl.load_view, carrying the member held as a view and, in an array of views, the element's index |
.bytes and .size_bytes of the member's dsdl_runtime_view_t, or of the element's; dsdl_runtime_clear_views over a fixed array |
dsdl.copy_bytes, carrying the field's width |
dsdl_runtime_copy_bytes |
convert-dsdl-to-emitc and convert-dsdl-to-llvm take the C spelling from the stamped schema
when they run. test/lit/lower-dsdl-bodies-neutral.txt holds that a module after
lower-dsdl-bodies carries none of it, and the gate fixture has a variable array, a union and a
nested composite, with a row for each.
The union operations are designed against Rust's enum, the object model furthest from a C struct, and validated on C, where the answer is known.
One translator, one spelling per language¶
The body vocabulary is fixed: the dsdl operations above, of which ten map onto runtime
primitives every language runtime already provides (set_uxx, get_u8 to get_u64, set_f16,
copy_bits and their kin); dsdl.index_holds, which answers whether the target's index type
holds a count and so belongs to the target rather than to a cast a pass may fold; a dozen arith
operations; scf.if, scf.while and scf.for; func.call and func.return; and the helper
functions lowering synthesises, which translate like any other function.
An array length read off the wire is validated by its helper before anything is sized by it:
past the declared capacity, or not held by the target's index, and the message is rejected with
the array untouched. dsdl.set_array_length therefore receives a validated count, and a spelling
narrows it without bounding it.
translateFunction, in lib/CodeGen/BodyTranslator.cpp,
has the shape of mlir::emitc::translateToCpp: it walks a function, names its values, spells
scf.if, scf.while and scf.for as the language's structured statements, and dispatches each
operation through a BodySpelling — types and literals, operators with width and sign, runtime
primitive calls, member, element, array and union access, signatures. One skeleton; one spelling
per language. It takes the function and nothing else: no SemanticModule.
A value is named by the operation that defines it¶
A dsdl operation states what its result is, and several name the DSDL member it belongs to:
dsdl.member_addr and dsdl.element_addr carry a member, dsdl.call_serdes carries the member
it calls, dsdl.array_length the array it counts, and lowering marks each helper it synthesises
with what that helper answers. The translator reads a ValueRole from the operation alone, so it
is the same role in every language, and asks the spelling for an identifier through
BodySpelling::valueName. snakeValueName and camelValueName render the two styles the
backends use: C++, Rust and Python take the first, Go and TypeScript the second.
A name is claimed only where the translator declares a value. An operation a spelling answers
spellsInline for is spelled at each use instead and claims nothing, which is why a row below
reaches one language and not another: C++ forms every address inline and declares its null tests,
the others form a member and an element address inline and declare the buffer's, and TypeScript
declares a deserialiser's member address because that is where the storage is created.
| the operation, where its value is declared | the value is spelled |
|---|---|
dsdl.member_addr %obj "frequency" |
frequency_addr, frequencyAddr |
dsdl.call_serdes @… {member = "frequency"} |
frequency_err, frequencyErr |
dsdl.local, and the dsdl.load_scalar that reads it back |
frequency_size, frequencySize |
dsdl.array_length %obj "name" |
name_count, nameCount |
dsdl.buffer_at, named by the nested call it addresses for |
frequency_buf, frequencyBuf |
dsdl.load_view %obj "pose", its bytes and their count |
pose_buf and pose_size, poseBuf and poseSize |
dsdl.is_null, dsdl.union_tag, dsdl.buffer_or_empty |
is_null, tag, buf |
dsdl.index_holds, in an array length's validation helper |
index_holds, indexHolds |
| a helper call, by the marker lowering left on the helper | err, value, count, tag |
An scf result takes the role the values yielded into it share, which is how the error a plan
threads through its fields keeps the name at every step. Two arms agreeing on the role but not on
the member give the role alone, and two stating different roles give none. An arm whose value no
operation describes states nothing rather than states no role, so it neither carries the answer
nor takes it away. An operation that states nothing about its result takes the
translator's own v<N>, and so does an arith result a body declares; a constant is spelled where
it is used and claims no name at all.
A plan's own structure states what no operation in it does. The bit offset threaded from step to
step, and the error carried beside it, reach a body as scf results, and the operations that build
those results say nothing about either -- an error has a role above only where an operation answers
with one. guarded, the union arm, the element loops and the epilogue each build the pair knowing
which is which.
build-dsdl-plan-bodies writes that down as llvmdsdl.result_roles, one name per result:
%10:2 = scf.if %9 -> (i64, i8) {
...
} {llvmdsdl.result_roles = ["offset", "error"]}
test/lit/plan-cursor-result-roles.txt holds the stamp on the guard, the loops and the epilogue.
The translator takes a stamp only while its length still equals the operation's result count:
canonicalisation drops a result nothing reads and carries the attribute onto the operation it
rebuilds, so a stamp that has outlived its results names none of them, and the yielded values are
asked instead.
A name is asked for from ordinal zero upwards until the function has not used the answer, so a
repeat is distinguished in the spelling's own style — err_2 or err2. The ordinal counts
candidates rather than values: a role whose plain form a parameter or a reserved local has claimed
takes its first value from an ordinal above zero. The joined identifier collapses
its underscore runs: a member that already trails one, which is how a target escapes a reserved
word, would otherwise reach C++ as the __ it reserves.
What a local must not capture¶
The pool a name is claimed from holds the function's parameters and BodySpelling::reservedLocals
— the identifiers a spelling's bodies use without qualifying them. Go's bodies convert with the
predeclared type names and measure with len; Python's call len, min and memoryview;
TypeScript's construct through BigInt, Number and the typed arrays. A local of any of those
names captures the call, and the capture is legal code that means something else: Go stops
compiling, and Python makes the name local for the whole function so the builtin is gone from its
first line. Rust and C++ answer with nothing — Rust resolves a conversion in the type namespace
and reaches the runtime by path, C++ qualifies every standard-library call and prefixes every
runtime one.
Stropping does not cover this. codegenIsKeyword holds keywords, and len is not one in either
language; shadowing a predeclared identifier is permitted, so there is no illegal spelling for an
escape to fix. It is a question about the scope, which is where it is answered.
A role word is therefore chosen for how it reads. count rather than len is still the better
word — len is claimed, so every array count would come back as len2.
Declarations, module layout, manifests, constants, deprecation notices and runtime embedding stay
in each emitter and keep reading the semantic module; a declaration takes the alias verdict and a
union's tag width from the plan operation itself, through
include/llvmdsdl/CodeGen/SchemaLookup.h.
The body half of an emitter — its
function-body emitters, helper-binding spellings, alignment, padding and composite renderers — is
replaced by a call into the translator.
Each backend, in order¶
Each step is one change. A backend's gate turns green and it joins
LLVMDSDL_BACKEND_CONTRACT_ENFORCED in that same change; its body renderers are deleted in that
same change.
C++ went first because its object model is C's. CppSpelling, in
lib/CodeGen/emitter/Cpp.cpp,
names members from the scope the struct declaration names them in, built from the schema's own
fields and constants; names a nested type from the schema its callee belongs to; and emits the
helpers as inline functions ahead of the sections that call them. The plan's i64 is spelled
unsigned, which is what the wire arithmetic and the runtime primitives take, and the few signed
comparisons cast for the comparison alone. A fixed bool array is packed bytes and copies as a
run; a variable-length one is the profile's container of bool and copies an element at a time.
A variable-length array is sized within its capacity before the plan validates the count, so a
malformed count never sizes a container past it. The std, pmr and autosar profiles differ
in how those containers are spelled and, under pmr, in the memory resource a nested call is
handed. The C↔C++ parity lanes and the generation lane accept it.
Rust followed. RustSpelling, in
lib/CodeGen/emitter/Rust.cpp,
names members from the scope the struct declaration names them in, and never names a nested type:
a nested call is a method call on the member. The plan's i64 is u64 with wrapping arithmetic,
which is the plan's arithmetic and cannot panic; the size a plan is handed by pointer is a local
usize, and a nested call's answer is written back to it. A buffer is a slice, and a pointer
into it is a sub-slice clamped to the buffer's end. A fixed-length array is [T; N], which is the
object the plan addresses without a count; it was a growable container with a length check the
plan does not state. A variable-length array is sized within its capacity, under the section's
memory contract, before the plan validates the count. The helpers are functions of the module.
Both profiles, both runtime specialisations and both memory modes are one spelling. The C↔Rust
parity lanes and their variants, the cargo-check lanes and the generation lane accept it.
Go followed. GoSpelling, in
lib/CodeGen/emitter/Go.cpp,
names members from the scope the struct declaration names them in, and never names a nested type:
a nested call is a method call on the member, and its two answers, the code and the size it used,
make it a statement rather than a value. The output is gofmt-clean by construction: each operation
is one statement, so no operator sits inside a call argument or an index, and a conditional value
is an if that assigns a declared variable. Go's fixed-width arithmetic wraps, which is the
plan's arithmetic. A negative literal is spelled in the signed type it is compared in, since a
constant conversion that overflows is a compile error. A bool member read as an integer goes
through the runtime's BoolToUint64; a variable-length array is sized within its capacity by the
runtime's Resize, which names no element type. A buffer is a slice, and a pointer into it is a
sub-slice clamped to the buffer's end. The helpers are functions of the package. The C↔Go parity
lanes, the decoder fuzz and forward-compatibility lanes, the go-build lane and the generation
lane, which runs gofmt, accept it.
TypeScript followed. TsSpelling, in
lib/CodeGen/emitter/Ts.cpp,
spells the plan's i64 as bigint, so a 64-bit field and the plan's arithmetic on it are exact;
an index and an error code are number. A constant is the value it stands for, since a bigint's
bitwise operators read a negative value as its two's complement, and an unsigned comparison
against a negative literal reads both sides as unsigned 64-bit values. A body is an exported
function of the object and the buffer that answers the size it used or the error code, and the
serialize and deserialize entry points a consumer calls wrap it: the one with a buffer of the
type's largest size, the other with an empty object, and each throws on a code. A deserialise body
is handed that empty object, so the storage a member address names is created where it is first
addressed; a union is one of its option objects, reached through the object cast to the option's
shape. A buffer is a Uint8Array, and a pointer into it is a subarray clamped to the buffer's
end. The runtime's write functions answer the plan's code. Both runtime specialisations are one
spelling. The C↔TypeScript parity lanes and their variants, the decoder fuzz lane, the runtime
smoke lanes, the type-check lanes and the generation lane accept it.
Python followed. PythonSpelling, in
lib/CodeGen/emitter/Python.cpp,
spells every integer of the plan as a Python int, which holds the value it stands for, so a
constant is that value and the bitwise operators read a negative one as its two's complement. A
body is a method of the dataclass that answers the size it used or the error code, and the
serialize and deserialize methods wrap it: the one with a buffer of the type's largest size,
the other with a default-constructed object, and each raises ValueError with the code's text. A
buffer is a memoryview, and a pointer into it is a slice of the view, which the three runtimes
read and write in place. A fixed-length array holds its elements from construction, so a
deserialised object has the storage the plan addresses; a union's option is created when the
plan sets the tag. Python has no empty block, so a block that spelled no statement closes with
pass. The C↔Python parity lanes and their variants, the malformed-input and decode-fuzz lanes,
the runtime parity and smoke lanes, and the generation lane accept it.
Acceptance¶
ctest -L backend-contract runs
test/integration/BackendContractTool.cpp
once per backend over
test/integration/backend_contract.
| gate | perturbed | held | bodies must |
|---|---|---|---|
| operation reflection | one dsdl.io class per row — unsigned, signed and float widths, alignment, an array element's width, a union option's width |
the semantic module | change |
| model independence | the semantic module's cast mode; its field widths | the MLIR module | be identical |
| determinism | nothing; the baseline is generated twice | everything | be identical |
A backend in LLVMDSDL_BACKEND_CONTRACT_ENFORCED fails its test on a gap; the others report
it. A gate the
current tree passes proves nothing about a change: each gate above was shown to fail the five
backends before any of them was written.