This post describes the lowering of caml_modify to native ARM64 through the non-Flambda code generation path.
It follows a pointer field update from the representation decision made before closure conversion through Cmm and Mach until the ARM64 emitter produces an ordinary call to the runtime barrier.
The path is narrow, but it exposes how collector invariants, calling convention details and memory ordering survive a sequence of intermediate representations that otherwise resemble routine instruction selection.
1. The barrier contract
caml_modify receives the address of an OCaml field and its replacement value before satisfying three separate obligations around the store.
For incremental major marking it applies a deletion barrier to the old major heap value so overwriting the last unscanned edge can't hide an object from the collector's logical snapshot.
For the generational collector it records a pointer from the major heap into the minor heap within the domain's remembered set, which lets the next minor collection find young objects through fields it won't otherwise scan.
For the OCaml memory model it upgrades earlier relaxed reads with an acquire fence and publishes the replacement through a release store.
This is a deletion barrier rather than an insertion barrier from a black object to a white object because the runtime darkens the value being removed instead of the value being installed.
caml_darken changes an UNMARKED header to MARKED and pushes scannable blocks onto the current domain's mark stack.
A replacement value is inspected separately for the generational case, where Is_block_and_young(new_val) decides whether the field address must enter major_ref for the next minor collection.
Describing this as a black object storing a white target reverses the protected edge since the marking barrier preserves the old value while the remembered set handles the new young value independently.
The typed front end can avoid caml_modify when the assigned value is definitely immediate and this makes the representation annotation on Psetfield the first useful split in the path.
A field whose right-hand side may be boxed carries Pointer, whereas an integer field carries Immediate and doesn't require the collector to preserve either kind of heap edge.
This decision is made from the typed expression before closure conversion so the later backend doesn't have to recover representation information from an untyped machine value.
The two assignments below therefore reach different Cmm operations even though both are ordinary mutable record updates and neither source expression mentions a collector primitive:
type cell = { mutable payload : string }
type counter = { mutable count : int }
let overwrite cell value =
cell.payload <- value
let overwrite_immediate counter value =
counter.count <- value
overwrite may install a heap pointer and therefore requests caml_modify because its representation annotation can't rule out an edge into either managed generation.
overwrite_immediate stores a tagged integer directly because an immediate can neither become a major heap marking edge nor a reference from the major heap into the minor heap.
maybe_pointer determines the primitive's representation annotation before closure conversion begins.
The annotation describes the stored value rather than the record itself because both examples update mutable fields in ordinary heap blocks while asking for different barrier behavior.
Despite bypassing the runtime routine the direct assigning store still carries the memory ordering required for mutable OCaml fields.
2. Lambda to Cmm
A mutable record update enters Lambda as Psetfield(index, Pointer, Assignment) when the right-hand side may be boxed and closure conversion retains that primitive while producing Clambda.
During Clambda to Cmm translation the assignment classifier maps Assignment, Pointer to Caml_modify and constructs a Cextcall with alloc = false.
Its result is the tagged unit value and the nonallocating flag tells later stages that the call won't invoke the OCaml allocator even though the runtime operation remains more than an ordinary store.
Compiling the previous sample through the non-Flambda path and dumping Cmm exposes both lowerings directly, with the pointer field becoming an external call while the immediate field becomes a normal assigning store:
(function camlSample$overwrite
(cell: val value: val)
(extcall "caml_modify" cell value -> unit)
1)
(function camlSample$overwrite_immediate
(counter: val value: int)
(store val counter value)
1)
The generic instruction selector maps any remaining Cextcall to Iextcall and preserves the function name plus allocation flag for the target emitter.
The ARM64 selector only inlines square root and byte swap operations in its inline_ops set so caml_modify doesn't acquire a hidden target expansion during this stage.
Neither the Cmm operation nor the selected Mach instruction contains a destination generation predicate, which rules out an intervening young heap fast path before the emitter receives the operation.
The selected Mach form therefore remains an external call with its callee identity and nonallocating property intact at the handoff to target emission:
extcall "caml_modify" field_address new_value (noalloc)
3. The ARM64 call
caml_modify carries a nonallocating call annotation and the ARM64 emitter therefore selects the ordinary nonallocating C branch rather than the allocating runtime call sequence.
The branch must still move from the OCaml stack to the domain's system stack before entering C because the native and C execution contexts use separate stack disciplines.
The emitter saves the OCaml stack pointer in x19 then loads Domain_c_stack through the domain state register before calling the external symbol and restoring the original stack.
At this point the emitter only needs to perform the stack transition and branch with link because arguments and results already occupy their external calling convention locations.
Reduced to its nonallocating case the external call branch is:
| Lop(Iextcall {func; alloc; stack_ofs}) ->
(* allocating and stack argument cases omitted *)
if not alloc then begin
` mov x19, sp\n`;
let offset =
Domainstate.(idx_of_field Domain_c_stack) * 8 in
` ldr {emit_reg reg_tmp1},
[{emit_reg reg_domain_state_ptr}, {emit_int offset}]\n`;
` mov sp, {emit_reg reg_tmp1}\n`;
` bl {emit_symbol func}\n`;
` mov sp, x19\n`
end
At the generated call site the field address occupies the first platform C argument location chosen by emit_extcall_args, while the replacement value occupies the second location.
There is no tag test or young heap range comparison and no conditional call in this emitted sequence because those decisions belong to the separately compiled runtime function.
An inline cmp and conditional branch therefore aren't part of this non-Flambda emitter path because the generation test remains inside the runtime function.
Their eventual coexistence in the native object doesn't make the external call site and runtime routine a single sequence generated by the compiler.
4. The runtime barrier
caml_modify first calls write_barrier with the field address as the destination object alongside the old field contents and requested replacement.
Passing the interior field address is sufficient for Is_young because minor heap membership is a range property, while the field offset remains zero relative to that address for remembered set insertion.
The helper performs collector bookkeeping only for a destination outside the minor heap and then returns to caml_modify so the actual field update remains common to every control flow path.
The essential branch structure is:
if (!Is_young(obj)) {
if (Is_block(old_val)) {
if (Is_young(old_val)) return;
caml_darken(Caml_state, old_val, 0);
}
if (Is_block_and_young(new_val)) {
Ref_table_add(&Caml_state->minor_tables->major_ref,
Op_val(obj) + field);
}
}
atomic_thread_fence(memory_order_acquire);
atomic_store_release(&Op_atomic_val((value)fp)[0], val);
An old field already containing a young block returns from write_barrier early because that address is already in the remembered set.
caml_modify still executes the fence and release store afterwards.
An old major value passes to caml_darken before the edge disappears and a new young value adds the address unless the previous young value established the remembered set case.
A young destination skips all collector bookkeeping but doesn't skip the ordered store so this routine isn't a conditional slow path around a plain str.
The early return belongs to write_barrier and therefore rejoins the unconditional acquire fence and release store in caml_modify.
5. AArch64 memory ordering
The tail of caml_modify expresses two separate C11 operations rather than an assembly template for AArch64 embedded in the runtime source.
Removing the surrounding collector branches leaves the acquire fence and release store as the complete ordering kernel for target instruction selection.
ARM64 GCC at -O2 lowers that pair to:
dmb ishld
stlr x1, [x0]
dmb ishld implements the delayed acquire fence which orders earlier relaxed reads before the following memory access and stlr provides release semantics for the field publication itself.
The barrier ordered before relation for DMB LD supplies the read before later read or write case required by this sequence within the inner shareable domain.
DMB ST only constrains earlier writes before later writes and therefore can't implement the acquire fence expressed by dmb ishld, whereas a full dmb ish would be stronger than the C source requests.
The release store also isn't interchangeable with a plain str since it orders the object state which precedes publication before another domain can observe the replacement pointer through the field.
This ordering pair belongs to the runtime's C compilation rather than the OCaml ARM64 emitter, which has its own implementation for direct assigning stores that never became caml_modify calls.
On AArch64 targets other than macOS the direct assigning store path emits dmb ishld followed by str; macOS uses a release store helper because of its memory model fix for that platform.
The direct assigning store sequence belongs to the Istore path and isn't part of the external caml_modify call sequence.
Immediate field mutation can therefore avoid every collector predicate while retaining the ordering contract attached to an assigning store.
6. x86_64
The same C11 acquire fence and release store pair compiles to a single ordinary mov with x86_64 GCC because total store order already supplies the required hardware ordering.
That doesn't remove either collector barrier since the deletion barrier and remembered set checks still run inside the same C function before the target store sequence.
x86_64 TSO permits a buffered store to be observed after a later load from a different address on the same processor.
Ordinary x86_64 loads and stores still realize the acquire and release ordering requested by these C11 operations without an explicit hardware fence.
7. Flambda and Flambda2
The Flambda and non-Flambda pipelines share the same field assignment lowering so any surviving heap pointer assignment still becomes Cextcall "caml_modify" before target instruction selection.
Flambda may eliminate an allocation or mutation and can improve representation knowledge, but the ARM64 selector doesn't contain a destination age specialization which turns this call into an inline minor heap check.
Flambda2 is a different backend and its assignment classifier makes the distinction explicit through Modify_heap and Modify_maybe_stack.
Heap pointer writes call caml_modify; potentially local writes call caml_modify_local so the runtime can use a plain store for a NOT_MARKABLE stack object and fall back to caml_modify otherwise.
8. Failure modes
Omitting the deletion barrier and weakening the memory ordering create different failures which can both remain hidden on an x86_64 development machine before the program reaches another architecture.
Losing caml_darken(old_val) can remove the collector's final visible path to an unmarked major object, while losing remembered set insertion can make a young object unreachable from the minor collector's scanned roots.
Replacing the ordered store with a plain AArch64 str instead attacks the language memory model by allowing publication and preceding accesses to become visible in an order that the OCaml semantics exclude.
None of these cases requires a torn write of a 64-bit pointer because the failures arise from lost reachability or insufficient interdomain ordering.