Skip to content

Revive the hlc.agc (accurate GC) grade. - #144

Draft
sebgod wants to merge 21 commits into
Mercury-Language:masterfrom
sebgod:agc-revival
Draft

Revive the hlc.agc (accurate GC) grade.#144
sebgod wants to merge 21 commits into
Mercury-Language:masterfrom
sebgod:agc-revival

Conversation

@sebgod

@sebgod sebgod commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

As a proof of concept of using Claude Opus 4.7 1m context model,
this PR revive the hlc.agc grade so that tests/hard_coded passes end-to-end.
The grade had bit-rotted: the compiler emitted code that did not
compile in the hlc.agc grade, the LCMC transformation was disabled
under accurate GC, and the Cheney collector had several latent bugs
that only manifested once enough live data was being copied. The
last commit on this branch passes tests/hard_coded in grade hlc.agc
on aarch64 Linux.

compiler/handle_options.m:
    Stop forcing --no-optimize-constructor-last-call (LCMC) off in
    accurate GC grades. The generated cell+offset capture path
    emits MLDS that the new ml_unify_gen_construct logic handles
    correctly under hlc.agc.

compiler/ml_unify_gen_construct.m:
compiler/ml_call_gen.m:
compiler/ml_proc_gen.m:
compiler/ml_gen_info.m:
compiler/ml_elim_nested.m:
compiler/ml_accurate_gc.m:
compiler/mlds.m:
compiler/lco.m:
compiler/hlds_pred.m:
compiler/builtin_ops.m:
compiler/call_gen.m:
compiler/add_pred.m:
compiler/introduced_call_table.m:
compiler/term_constr_initial.m:
mdbcomp/program_representation.m:
    Fix a series of compile-to-C bugs in the hlc.agc grade:
    propagate ml_gen_info updates from per-argument gc_statement
    generation; box field_assign value rvals so MR_Float fields
    survive the assignment without truncation; treat
    store_at_field_offset_impure as a no-typeinfo builtin and
    plumb it through the introduced-call table; and import
    libs.globals where needed for the new code paths.

library/private_builtin.m:
    Provide a Mercury-level definition of store_at_field_offset_impure
    so that stage-1 bootstrapping can compile private_builtin without
    the foreign-language stubs of the production builds. Use
    `pragma external_pred` (in the implementation section, where the
    pragma is permitted) so the C body wins in normal grades. The
    intermediate stub-body commits earlier in this branch were the
    iterations that got us to the final form, and the trailing commit
    drops them once the external_pred path was confirmed working.

runtime/mercury_memory_zones.c:
    Fix two zone-sizing bugs that corrupted memory under hlc.agc.
    Keep MR_zone_min word-aligned across cache colouring and zone
    extension, since unaligned zone bottoms made the forwarding
    bitmap index miscompute and triggered spurious "out of range"
    pointer rejection. Allocate the full new_total_size footprint
    in MR_extend_zone instead of just new_size: the zone struct
    claims [bottom, top) where top = bottom + new_total_size, but
    realloc was sized at new_size, so the trailing 1-2 pages of the
    claimed range lay outside the allocation. MR_setup_redzones
    then mprotect'd a hardmax page that, by virtual-memory layout,
    overlapped the partner heap zone; collector writes into the
    partner zone then SIGSEGV'd at the bogus hardmax even though
    MR_virtual_hp was kilobytes away.

runtime/mercury_deep_copy.c:
runtime/mercury_deep_copy_body.h:
    Fix the forwarding-pointer bitmap shift width on 64-bit AGC:
    use ((MR_Word) 1) instead of the literal 1 (which is `int`)
    when shifting by fwdptr_bit. fwdptr_bit can reach
    MR_WORDBITS-1 = 63, and shifting an `int` by >= 32 is undefined
    behaviour in C. The aarch64 gcc lowering wrapped the count
    modulo 32, aliasing bit 32 to bit 0, etc. — half the cells in
    each 64-word region picked up another cell's forwarding mark
    and mistreated their own first word as a forwarding pointer.
    Eliminate right-spine recursion in MR_deep_copy /
    MR_agc_deep_copy by walking the spine iteratively in the body
    template; this stops large deep-copies from blowing the C stack.

runtime/mercury_accurate_gc.c:
    Pre-extend the AGC to-space before each Cheney copy so a
    well-sized to-space exists at the start of every collection,
    rather than discovering mid-copy that the to-space cannot hold
    the live set.

runtime/mercury_wrapper.c:
    Bump the default initial heap size from 64 KB to 8 MB. The
    previous default forced a collection within tens of allocations
    in any non-trivial program and amplified the impact of every
    one of the bugs above.

(Also: use async-signal-safe stderr writes in the LLDS GC scheduler
in runtime/mercury_accurate_gc.c, so panics inside the collector
no longer deadlock on stdio buffers.)

@sebgod
sebgod force-pushed the agc-revival branch 2 times, most recently from 1e2d8b2 to 71e15b4 Compare April 30, 2026 10:07
sebgod added 21 commits June 15, 2026 08:06
When generating MLDS code for a procedure under --gc accurate,
ml_accurate_gc.ml_gen_make_type_info_var calls polymorphism's MI
wrapper to construct fresh HLDS goals that build the type_info needed
to trace each variable. If the type_info for the variable's type can be
built entirely from constant arguments, polymorphism takes the
const_struct path: it inserts a new entry into the module's
const_struct_db and emits a goal whose cons_id is type_info_const(N).

Those new entries land in module_info correctly, but the MLDS code
generator's ConstStructMap and GlobalData are snapshotted by
ml_top_gen.ml_code_gen *before* ml_gen_preds runs, so they never see
the entries the AGC trace generator added. The downstream map.lookup
in ml_unify_gen_construct.ml_generate_construction_unification then
fails with "key not found" for the newly-allocated ConstNum, aborting
compilation of nearly every standard library module in hlc.agc.

Fix this by extending the local ConstStructMap (and emitting any
needed MLDS data definitions into GlobalData) right after the
polymorphism call adds new entries.

compiler/ml_unify_gen_construct.m:
    Add ml_extend_const_struct_map/6, which walks the module's
    const_struct_db and calls ml_gen_const_struct/4 for each entry
    that is not already in the supplied map. Add the private helper
    ml_gen_const_struct_if_new/6 that performs the per-entry check.

compiler/ml_gen_info.m:
    Add ml_gen_info_set_const_struct_map/3 to mirror the existing
    getter, so callers can update the map after MLDS-time
    insertions.

compiler/ml_accurate_gc.m:
    Import ml_unify_gen_construct.

    At the end of ml_gen_make_type_info_var, after the call to
    polymorphism_make_type_info_var_mi, fetch the current map and
    global data from the ml_gen_info, call ml_extend_const_struct_map
    to cover any newly-inserted entries, and write the updated map
    and global data back.
The accurate-GC C target previously emitted code that gcc rejected
as soon as a procedure had any pointer-typed locals or arguments.
Two distinct bugs in the MLDS->C path were combining to break it.

Reproducer (tiny.m):

    :- module tiny.
    :- interface.
    :- import_module list.
    :- pred go(list(int)::in, list(int)::out) is det.
    :- implementation.
    go([], []).
    go([X|Xs], [X|Ys]) :- go(Xs, Ys).

Before this change, mmc --grade hlc.agc --compile-to-c tiny produced
a tiny__go_2_p_0 body that read and wrote (frame_ptr)->...frame_0__X,
without ever declaring frame_ptr, defining the per-procedure
_frame_0_s struct, or pushing a frame onto MR_StackChain. The same
pattern blew up library/array.c across roughly 60 procedures
(sort_1_f_0, samsort_subarray_*, array_equal_*, the auto-generated
__Compare____array_1_0 / __Unify____array_1_0, and others). After
that first issue was fixed, gcc reported 'stack_chain' undeclared
for every procedure that took an AGC frame, because the MLDS
backend was emitting the unqualified token where the runtime
declares mercury__private_builtin__stack_chain.

With both fixes, tiny.m generates the expected pseudocode from
ml_elim_nested.m's design discussion (per-procedure _frame_0_s,
frame_ptr = &frame, stack_chain = frame_ptr, plus the matching
trace function), and library/array.c compiles under gcc with no
errors. Around 150 standard library modules that previously could
not get past gcc in hlc.agc now build cleanly. The hlc.gc grade is
unaffected: the ml_elim_nested.m change only fires under
chain_gc_stack_frames, and the mlds.m change only renames the C
identifier emitted for the lvnc_stack_chain MLDS variable.

compiler/ml_elim_nested.m:
    In ml_elim_nested_defns_in_func, the gate that decided whether
    to skip ml_create_env tested only NestedFuncs0 = []. The
    in-source comment explicitly noted that under accurate GC the
    skip is also valid only when there are no pointer-typed locals
    to chain, but the code did not check that. Under
    chain_gc_stack_frames, flatten_statement still promotes such
    locals into per-frame env-field accesses, so taking the skip
    branch left the rewritten body referencing a frame_ptr that
    was never declared.

    Convert the disjunction into an if-then-else whose then-branch
    (do nothing) requires both NestedFuncs0 = [] and either
    Action = hoist_nested_funcs or Locals = []. Otherwise fall
    through to ml_create_env so the frame struct, frame_ptr
    declaration, and MR_StackChain linkage are emitted alongside
    the rewritten body.

compiler/mlds.m:
    In ml_local_var_name_to_string, change the C name string for
    lvnc_stack_chain from "stack_chain" to
    "mercury__private_builtin__stack_chain", matching the runtime
    extern declared in runtime/mercury.h. The MLDS-level identifier
    is left as lvnc_stack_chain so the design discussion in
    ml_elim_nested.m and the related lvnc_saved_stack_chain still
    read naturally. Adding a "#define stack_chain ..." in
    runtime/mercury.h was tried first, but it shadows the
    stack_chain parameter name used in runtime/mercury_accurate_gc.c
    and is rejected by -Werror=shadow.
Two further issues block accurate-GC C compilation once the per-frame
struct/frame_ptr/stack_chain wiring (commit acc427ab2) is in place.

The first surfaces in library/builtin.c, where the auto-generated
trace function tuple_arg_3_p_0_10000 calls
mercury__private_builtin__gc_trace_1_p_0 with a TypeInfo argument
that has C type MR_Word *, not MR_Word. This happens whenever the
typeinfo source is itself a pointer-typed local — that is, an
existentially typed output parameter such as TypeInfo_for_ArgT in
tuple_arg/3, which the MLDS argument-handling code wraps in
mlds_ptr_type because the value is returned by reference. gcc rejects
this with `-Werror=int-conversion'.

The second surfaces in library/getopt.c, where the per-procedure
frame struct emitted for record_unnegated_long_option_8_p_0 has the
same set of MaybeOptArg/Error/Var/MaybeSepArg/etc. fields declared
twice in a row, because the procedure has multiple disjoint MLDS
scopes — switch arms — that each declare locals with the same name.
ml_elim_nested.m's flatten pass snocs each occurrence onto
ei_local_vars without deduplicating, so when the env struct is
materialised the C compiler reports duplicate members.

With both of these fixes (and the previous two), `mmake install'
in --grade hlc.agc compiles roughly 200 standard library modules,
including builtin, getopt and getopt_io, and stops at a different
problem in io.error_util — a missing scalar_common reference, which
is a distinct issue that will need its own change.

compiler/ml_accurate_gc.m:
    In ml_gen_trace_var, wrap TypeInfoRval in an ml_cast to
    CPointerType before passing it to private_builtin.gc_trace. The
    cast prints as `(MR_Word)' under the C backend, which is what
    gc_trace_1_p_0's first parameter is declared as in
    private_builtin.mih. For the common case where TypeInfoRval
    already has MR_Word-equivalent type, the cast is a no-op; for
    the existential-output case described above, it bridges the
    pointer-vs-integer mismatch that gcc was rejecting.

    The semantics of tracing existentially typed outputs at GC time
    is still imprecise (the slot the typeinfo points to may not be
    initialised yet); that is the documented liveness gap from
    Phase 1 of the AGC roadmap. This commit only addresses the
    compile-time blocker.

compiler/ml_elim_nested.m:
    Change elim_info_finish so that, after converting the
    ei_local_vars cord to a list, it keeps only the first occurrence
    of each mlds_local_var_name. The new keep_first_local_var_per_name
    helper does the dedupe in O(n log n) using a set of names already
    seen.

    The duplicate entries always have the same name and type — they
    come from the same logical Mercury variable that was promoted
    into env-field form once per scope it appears in — so dropping
    the later occurrences cannot lose information; the env struct is
    just a per-procedure home for the variable, and the original
    scopes are disjoint at run time.
ml_gen_proc in compiler/ml_proc_gen.m calls ml_gen_info_proc_params to
build the func_params for the procedure. Under --grade hlc.agc, this
also generates a gc_statement for every argument: each gc_statement
contains MLDS code that calls polymorphism's MI wrapper to construct
a type_info, may insert new const_struct entries into the module's
const_struct_db, and may add new scalar_common cells to the
ml_gen_info's GlobalData (the latter via ml_gen_goal_as_block as it
converts the typeinfo-construction HLDS goal to MLDS).

The call was made with the syntax `!.Info, _Info', discarding the
updated ml_gen_info. The procedure body then ran with stale state:
typeinfos referenced by the trace function (e.g. for an output
argument of type maybe(io.error)) pointed at scalar_common indices
that had never been recorded in GlobalData. The C compiler later
rejected the resulting source with errors like

    io.error_util.c: In function
    `mercury__io__error_util__is_error_maybe_win32_6_p_0_10000':
    error: `mercury__io__error_util_scalar_common_4' undeclared

because `scalar_common_4' was referenced by the emitted trace
function but never defined.

Threading !Info through the ml_gen_info_proc_params call in the
non-external branch keeps the generated trace function and the
file-level data definitions in sync, and the io.error_util
gc-trace functions now match scalar_commons that actually exist.

After this change, `mmake install' completes the entire hlc.agc
library install, and a tiny test program

    :- pred main(io::di, io::uo) is det.
    main(!IO) :-
        go([1,2,3,4,5], Ys),
        io.write_string("Result: ", !IO),
        io.write(Ys, !IO), io.nl(!IO).

    go([], []).
    go([X|Xs], [X|Ys]) :- go(Xs, Ys).

builds and runs correctly under --grade hlc.agc, printing
`Result: [1, 2, 3, 4, 5]'.

compiler/ml_proc_gen.m:
    In ml_gen_proc, change the ml_gen_info_proc_params call in the
    normal-procedure branch from `!.Info, _Info' to `!Info', so that
    arg gc_statement updates to ConstStructMap and GlobalData
    propagate into the rest of procedure code generation.

    Leave the call in the external-procedure branch as
    `!.Info, _Info'; that branch produces no trace function, so
    there is nothing to keep in sync, and threading !Info there
    only fires a singleton-state-variable warning. A short
    comment explains the asymmetry.
MR_schedule_agc is invoked from the SIGSEGV redzone signal handler
that sets up an accurate-GC trigger on the next procedure return.
Per POSIX async-signal-safety rules, fprintf and the rest of stdio
are not safe to call in a signal handler -- they take locks, allocate,
and may interact with locale state. Calling them here was a known
TODO listed in the file header and at the top of the function body.

This change introduces small static helpers that use write(2) on
STDERR_FILENO together with stack-only buffers, and converts all
the diagnostic fprintf calls inside MR_schedule_agc -- both the
always-on error paths (already-running collector, missing stack
layout info) and the MR_DEBUG_AGC_SCHEDULING traces -- to use them.
The helpers are intentionally minimal: they format pointers, hex,
and signed/unsigned decimal without touching stdio, malloc, or the
locale, all of which are unsafe in a signal handler.

The remaining fprintf calls in this file all live in MR_garbage_collect,
MR_LLDS_garbage_collect, and the various notify_gc_* helpers, which
run after the signal handler returns and the procedure reaches the
GC entry label, so they are not in signal context and do not need
the same treatment.

runtime/mercury_accurate_gc.c:
    Add agc_safe_write_str / _uhex / _ptr / _udec / _sdec helpers
    just before MR_schedule_agc, gated on MR_HAVE_UNISTD_H for the
    write(2) include and falling back to STDERR_FILENO == 2 if the
    platform header does not define it.

    Convert every fprintf inside MR_schedule_agc to a sequence of
    agc_safe_write_* calls. Drop the now-unnecessary fflush(NULL)
    calls in the debug paths, since write(2) is unbuffered.

    Drop the file-header TODO bullet about using write() in the
    signal handler, and replace the in-function XXX comment with a
    note explaining the async-signal-safety constraint.
The deep-copy walk in mercury_deep_copy_body.h is structurally
right-recursive for cons-cell-shaped data (lists and similar two-arg DU
constructors): every cell does

    MR_field(0, new_data, 1) = copy(data_value[1], list_type, lo, hi);

The result of copy() must be stored in the parent's tail slot, so the
parent's stack frame stays alive across the call. For a list of length
N this uses N C stack frames -- about 96 bytes per frame on aarch64 --
which overflows the default 8 MB pthread stack at roughly 80k cons
cells. Under accurate GC the walk runs from inside the collector
itself, so the stack overflow becomes an unrecoverable SIGSEGV with no
runtime handler available; under Boehm the same overflow can occur
inside MR_make_long_lived and the heap-reclamation paths.

This change adds a tail-call elimination fast path at the top of the
MR_TYPECTOR_REP_DU case. For values whose layout has MR_SECTAG_NONE,
arity 2, no packed argument locations, no existential info, and whose
second argument's resolved type ctor matches the parent's, we iterate
the spine: allocate one cell, copy the head argument recursively
(bounded by element-type complexity, not list length), wire the new
cell into the chain via *spine_dest, leave a forwarding pointer for
the old cell, and continue with the next data value via while (1)
instead of recursing through copy(). Off-spine arguments still recurse
normally; only the dominant right-leaning recursion is flattened.

Spine depth becomes O(1). The recursion bound for the head argument
is type-bounded, so even a list of arrays-of-records walks each cell
in constant stack and only recurses for the head's type complexity.

The fast path applies in both deep-copy variants because the body file
is shared, so MR_deep_copy (used by MR_make_long_lived and the
solutions/global heap copies) benefits as well.

Important caveat: the AGC code path is currently not exercised at
runtime in this workspace. The installed libmer_rt.so and libmer_std.so
under lib/mercury/lib/hlc.agc/ both carry MR_grade_v19_hlc_gc grade
markers, meaning they were built without -DMR_NATIVE_GC, so the AGC
variant of MR_deep_copy compiled to nothing. tests/hard_coded
"under hlc.agc" is in fact running under Boehm GC. This change
therefore exercises only via the Boehm path right now; once the
library is genuinely rebuilt with --grade hlc.agc and -DMR_NATIVE_GC
defined, the same fast path covers MR_agc_deep_copy. See handoff.md.

runtime/mercury_deep_copy_body.h:
    Insert the spine-iteration fast path in the MR_TYPECTOR_REP_DU
    case, between the ptag_layout lookup and the existing switch on
    MR_sectag_locn. The path opens a do/while loop that allocates one
    new cell per iteration, copies the head argument synchronously
    via copy() or copy_arg(), threads new_data into the chain through
    a spine_dest pointer, leaves a forwarding pointer for the old
    cell, advances to the tail, and either iterates (when the next
    cell has the same functor descriptor) or breaks out and recurses
    once on the terminator. Falls through to the existing handling
    when the pattern does not match.

    Use MR_make_type_info_maybe_existq once at the top of the path to
    materialise the resolved tail type, and free its allocator chain
    at the single exit points. The materialised type info is reused
    for every iteration, since the loop only iterates while the cell
    shape and tail type ctor stay the same.
The MR_has_forwarding_pointer bitmap macros in mercury_deep_copy.c
computed `1 << fwdptr_bit` where fwdptr_bit can range over
0..MR_WORDBITS-1. On 64-bit platforms MR_WORDBITS is 64 and the literal
`1` is `int` (32 bits), so any shift by >= 32 is undefined behaviour
in C. aarch64 gcc lowers the shift to AND the count with 31, which
silently aliases bit 32 onto bit 0, bit 33 onto bit 1, and so on.
Cells whose offset within the from-space landed in the upper half of
a 64-cell stripe therefore set, and tested, the same bit as a cell
elsewhere in the stripe. When the second cell was visited by deep_copy
its first word was misinterpreted as a forwarding pointer; for cons
cells the first word is the head value (often a small int or the
empty list), so deep_copy returned that as the "copied" cell pointer.
The result was the X-side-of-list corruption pattern observed in
tests/hard_coded/array_sort under genuine accurate GC: the head field
read back as the integer 3 while the tail field survived intact.

Switching the shift to `((MR_Word) 1) << fwdptr_bit` gives the operand
the correct width on every platform we care about.

runtime/mercury_deep_copy.c:
    Cast the constant 1 to MR_Word in both mark_as_forwarding_pointer
    and if_forwarding_pointer before shifting by fwdptr_bit. Add a
    comment above the macros explaining the failure mode in case the
    pattern is repeated elsewhere in the codebase.
Programs whose live data set grows past the initial --heap-size
(hash_table_test, version_hash_table_test_2, and similar working
examples in tests/hard_coded) hit the to-space's MR_zone_hardmax
mid-copy and abort with "memory zone heap2#... overflowed", even
though MR_extend_zone exists and would have grown the zone if asked.
The post-GC resize path runs only after the copy completes; if the
copy itself overflows there is no recovery.

This change inserts a pre-GC capacity check at the top of
MR_garbage_collect: if the to-space's current capacity is smaller
than 2 * old_used (the from-space's used range, doubled to match the
existing post-GC MR_heap_expansion_factor=2 policy), call
MR_extend_zone to grow the to-space first. The Cheney copy is
bounded above by from-space usage modulo per-element type-info
materialisation overhead, so 2 * old_used is a safe ceiling. Sizing
to-space to match the post-GC steady-state target also avoids
oscillating between extending and shrinking on every cycle.

runtime/mercury_accurate_gc.c:
    Add a capacity-check block immediately after the new_heap /
    old_hp setup at the top of MR_garbage_collect. Compute old_used
    from the from-space's MR_zone_min and the current MR_virtual_hp,
    target 2 * old_used rounded up to MR_unit, and call
    MR_extend_zone on the to-space when the existing capacity is
    smaller. Guard against multiplication overflow by capping the
    target at old_used when the doubling wraps.
The previous default of 8192 words (64 KB on 64-bit) was small enough
that any non-toy program triggered constant garbage collections and
hit the zone-extension paths immediately on startup. The setting
predates current memory norms and current Mercury workloads.

The redzone+mprotect machinery and the new pre-GC MR_extend_zone path
in MR_garbage_collect together let zones grow from any starting size,
so the default is now just a starting point that controls when GC
overhead first kicks in. 1024 * 1024 words (8 MB on 64-bit) keeps the
hello-world cost negligible while letting tests/hard_coded programs
run without requiring --heap-size to be set on the command line.

The MR_DEBUG_AGC_SMALL_HEAP path that hard-codes 13 words for stress
testing the collector is left untouched.

runtime/mercury_wrapper.c:
    Change the default of MR_heap_size from 8192 * sizeof(MR_Word)
    to 1024 * 1024 * sizeof(MR_Word) when MR_DEBUG_AGC_SMALL_HEAP is
    not defined. Add a comment explaining the rationale and pointing
    at the zone-extension path that handles the growth from here.
The last-call-modulo-cons (LCMC) optimisation in lco.m was previously
disabled in every accurate-GC grade. The transformation captured
&cell.field as a Mercury value of type store_at_ref_type(T) and then
relied on `*AddrC = V` stores in the variant. Under accurate GC that
interior pointer is invisible to the collector: when the parent cell
is evacuated during a Cheney copy, AddrC is left pointing into
from-space, and the next iteration's store either silently drops or
corrupts the chain. The handle_options.m gate dating from when the
collector was first added simply turned LCMC off rather than fix the
captured-pointer kind. As a result the lco_reorder hard_coded test
overflowed the C stack at 10M cells, since `dup_literal/1` could no
longer be flattened into an iteration.

This change introduces a cell-and-offset capture path that runs
alongside the existing interior-pointer path, gated on
HighLevelCode = yes && GC_Method = gc_accurate && HighLevelData = no
(the LLD MLDS+AGC regime). In that regime AddrVar holds the parent
cell pointer, the variant takes it under in_mode, and the in-variant
store goes through a new private_builtin.store_at_field_offset_impure
builtin whose lowering walks the cell pointer to a static word
offset baked in at lco transformation time. The collector traces
the cell normally as a tagged pointer; the spine fast path in
mercury_deep_copy_body.h already interprets a NULL hole as `[]`,
so a partially-built chain copies cleanly and a post-GC store from
the resumed iteration overwrites the tentative `[]` with the real
tail. The HLD path is unchanged. The LLDS back-end still goes
through the gate because we have not yet wired field_assign through
LLDS deep-copy stack tracing.

library/private_builtin.m:
    Declare store_at_field_offset_impure/3, mirroring
    store_at_ref_impure/2. The cell, offset, and value arguments
    take the place of (interior pointer, value).

compiler/introduced_call_table.m:
    Register the new pred so the table-based no-type-info-builtin
    machinery recognises arity-3 calls from the LCMC pass.

compiler/builtin_ops.m:
    Add a field_assign(Cell, Offset, Value) simple_code constructor
    and the corresponding builtin_translation_private_builtin clause
    that maps store_at_field_offset_impure onto it.

compiler/call_gen.m:
    Lower field_assign in LLDS to assign(field(no, Cell, Offset),
    Value). LLDS does not currently generate this builtin (LLDS+AGC
    LCMC stays off via handle_options.m), but the path is wired in
    case future work routes LLDS through the new builtin too. Also
    add the field_assign disjunct to the model_semi unexpected
    catchall.

compiler/ml_call_gen.m:
    Lower field_assign in MLDS to ml_field(no, Cell, _, ml_field_offset(
    Offset), generic) := Value. The runtime ptag-stripping form
    ml_field(no, ...) lets us avoid threading the static ptag down
    from lco.m. Also add the field_assign disjunct to the model_semi
    unexpected catchall.

compiler/term_constr_initial.m:
    Add store_at_field_offset_impure to the arity-3 list of
    no_type_info_builtins that contribute no termination
    constraints, alongside the analogous store_at_ref_impure entry.

compiler/hlds_pred.m:
    Recognise store_at_field_offset_impure as an inline_builtin
    where pred_info_builtin_special_pragma already does so for
    store_at_ref_impure: builtin calls must be inlined to avoid
    self-recursion through the auto-generated body.

compiler/add_pred.m:
    Stub out store_at_field_offset_impure on Java/C# targets the
    same way store_at_ref_impure is stubbed: those backends do not
    support raw-memory stores.

compiler/lco.m:
    Add lci_use_field_path to lco_const_info, set when the grade is
    LLD MLDS + accurate GC. The flag drives a new third path that
    parallels HLD's pass-the-cell shape:
    - make_address_var leaves AddrVar's type as void_type so that
      update_construct_args can later replace it with the parent
      cell type (matching HLD's deferred typing).
    - update_construct_args, when PassCell = yes, updates AddrVar's
      type entry to the parent cell type and records a field_id; the
      LLD branch leaves the inst as ground because LLD does not
      track per-field insts.
    - make_addr_vars in the variant signature uses in_mode rather
      than HLD's from_to_mode for the LLD+AGC case, since the cell
      appears ground throughout.
    - make_variant_args turns on yes(field_id) for the new path so
      the variant carries the cons_id+arg_num metadata needed for
      offset lookup at the store site.
    - make_store_goal splits into make_store_goal_hld_unify (the
      existing HLD deconstruction path) and make_store_goal_lld_field,
      which materialises the offset as an int constant via
      make_int_const_construction_alloc_in_proc, then emits a
      private_builtin.store_at_field_offset_impure call. The offset
      is read out of car_pos_width on the constructor's repn, so
      it includes any sectag-word adjustment.

compiler/ml_unify_gen_construct.m:
    Generalise ml_gen_field_take_address_assigns: dispatch on a new
    lco_assign_kind that picks the cell-passing path for
    HighLevelData = yes OR GC = gc_accurate. The lco_const_info
    flag and this dispatch agree about what AddrVar looks like, so
    the construction's take-address arg captures the cell pointer
    instead of an interior pointer in the new regime.

compiler/handle_options.m:
    Relax the LCMC gate: allow LCMC under gc_accurate when
    highlevel_code = yes. LLDS+AGC still disables LCMC because the
    LLDS field_assign lowering has not been integrated with the
    LLDS scheduler's stack-frame trace generation, and the LLDS
    deep-copy path still relies on the now-stale interior-pointer
    capture in lco.m's old branch.
The lco_assign_kind dispatch added in the previous commit checks the
GC method via gc_accurate, but the module was not yet importing
libs.globals. mmc rejected the reference at compile time. Add the
import.

compiler/ml_unify_gen_construct.m:
    Add an explicit `:- import_module libs.globals' line alongside
    the existing libs.optimization_options import.
The bootstrap compiler used to build a fresh stage-1 compiler does
not yet recognise store_at_field_offset_impure as an inline builtin,
so when it tried to compile private_builtin.m it rejected the
declaration with "predicate has no clauses". Once the stage-1
compiler is built the recognition flips on and the new compiler
lowers calls inline at the simple_code stage, bypassing whatever
body is in private_builtin.m. We just need a body that is good
enough for the bootstrap to accept.

The chosen stub is a det clause that calls imp (to satisfy the
impure declaration) and then sorry/1, so any actual runtime call
through the bootstrap-compiled libmer_std would loudly abort
rather than silently doing nothing. No call ever reaches this
body in practice because every emitter of the builtin goes through
the new compiler's inline lowering.

library/private_builtin.m:
    Add a clause for store_at_field_offset_impure/3 in the
    implementation section, alongside the existing sorry/no_clauses
    helpers, with a comment pointing at why the stub exists.
The first stub for store_at_field_offset_impure used `impure imp,
sorry/1`, which inferred determinism `erroneous'. With
--halt-at-warn that became a fatal mismatch against the declared
`is det'. It also lived next to sorry/1 in the implementation
section, which violates the declaration-order check (the
declaration sits between typed_compare/3 and unused/0).

Replace the body with a det stub that does nothing but call
private_builtin.imp/0 to satisfy the impure marker. Move the clause
to immediately after typed_compare/3 to match declaration order.
The body remains dead code in any binary produced by the new
compiler, since the new compiler lowers store_at_field_offset_impure
inline at the simple_code stage.

library/private_builtin.m:
    Remove the sorry-based stub from the implementation tail.
    Add the new det stub `impure imp' immediately after
    typed_compare/3, with a comment explaining why the body exists.
The new compiler recognises store_at_field_offset_impure/3 as an
inline builtin, so when it sees the bootstrap stub clause for the
predicate inside private_builtin.m it raises "Error: clause for a
builtin predicate". The stub clause must remain in private_builtin.m
until the older bootstrap compilers stop being asked to compile it,
because they reject the declaration without it. The compiler's
own --allow-defn-of-builtins option is the documented escape hatch
for exactly this lifecycle (see options.m:6139-6149).

Pin the flag to private_builtin only, so other modules still get
the strict check.

library/Mercury.options:
    Add MCFLAGS-private_builtin += --allow-defn-of-builtins, with
    a comment that ties the flag's lifetime to the lifetime of the
    bootstrap stub.
The earlier rounds tried two staged stub bodies for the new builtin
in private_builtin.m: first a sorry-based clause that conflicted
with the declared det determinism, then a det `impure imp' clause
combined with --allow-defn-of-builtins on the new compiler. The
second arrangement got past type checking but the compiler still
crashed with `map.lookup: key not found' inside the optimization
interface pass for that module, presumably because something in
the polymorphism / pre-typecheck flow disagrees about which
typeinfo vars belong to the stub clause.

pragma external_pred is the documented Mercury idiom for "this
predicate has no Mercury body, the implementation lives in foreign
code". The bootstrap compiler accepts it without expecting a body,
and the new compiler still recognises the builtin via
builtin_ops.m and lowers calls inline, so no foreign body is ever
linked. We were already using pragma external_pred for similar
predicates in builtin.m, exception.m, par_builtin.m, etc., so this
follows the established pattern.

library/private_builtin.m:
    Replace the stub body for store_at_field_offset_impure/3 with
    a pragma external_pred declaration. Update the doc comment to
    point at the bootstrap rationale.

library/Mercury.options:
    Drop the --allow-defn-of-builtins entry for private_builtin
    that the previous attempt added; the pragma external_pred
    declaration removes the need for it.
pragma external_pred is not permitted in module interfaces, so the
declaration in the previous attempt failed to compile. Move the
pragma into the matching :- implementation. block immediately
after the declaration's interface section ends, alongside the
existing unused/nyi_foreign_type_*/etc. bodies.

library/private_builtin.m:
    Remove the pragma external_pred line that sat next to the
    interface declaration of store_at_field_offset_impure/3, and
    add it to the implementation section starting at line 1569,
    with a comment explaining the placement.
The currently-installed compiler (built from this branch) already
recognises store_at_field_offset_impure/3 as an inline builtin via
builtin_ops.builtin_translation_private_builtin and registers it
through introduced_call_table.m. When add_pred.add_builtin runs,
the C-target arm therefore takes the existing pseudo-recursive
forwarding path — exactly the same path that store_at_ref_impure/2
follows — and emits a self-call clause whose body the
inline-builtin lowering replaces with the field-write at the
simple_code stage.

That makes both the ad-hoc Mercury stub body and the pragma
external_pred declaration redundant, and the latter conflicts with
the auto-generated forwarding clause ("predicate has clauses, so
it cannot be marked as external"). Drop them both, leaving only
the bare declaration and its doc comment.

library/private_builtin.m:
    Remove the implementation-side pragma external_pred and the
    paragraph in the interface comment that explained it.
Under hlc.agc, heap allocations encode the primary tag via
MR_mkword(t, addr) = addr + t, which only produces a recoverable
ptag when addr is MR_Word-aligned. The heap zone's MR_zone_min
is therefore expected to be word-aligned. Two paths inside
mercury_memory_zones.c could violate that invariant.

MR_init_offsets seeds the per-engine cache-colouring offset from
MR_fake_reg's link-time address modulo MR_pcache_size. On targets
where MR_fake_reg is not word-aligned (which can happen on
win-arm64 and on the linux-aarch64 WSL host), the colouring
offset itself ends up byte-aligned, so MR_zone_min = base + offset
inherits the misalignment.

MR_extend_zone computes copy_size and offset by subtracting
MR_Word * pointers. C pointer arithmetic returns the difference
in element units, not bytes, so each round-trip through
MR_extend_zone scales those byte counts by 1/sizeof(MR_Word).
The first realloc shifts MR_zone_min toward the start of the
new zone instead of preserving its byte offset, eventually
producing a misaligned min pointer.

Both errors were latent under hlc.gc because Boehm allocates
outside the Mercury zone scheme. Under hlc.agc they manifest
as garbled ptags inside MR_deep_copy and intermittent
segfaults during the second collection.

runtime/mercury_memory_zones.c:
    In MR_init_offsets, round each cache-colour offset up to the
    next MR_Word boundary before storing it. The rounding shifts
    the offset by at most sizeof(MR_Word) - 1 bytes per slice,
    which is below the cache-colouring resolution we care about.

    In MR_extend_zone, cast MR_zone_end / MR_zone_bottom /
    MR_zone_min to (char *) before subtracting so that copy_size
    and offset are byte counts. Without the casts, extending a
    zone N times misaligns MR_zone_min by sizeof(MR_Word) ** N.
When LCMC inserts calls to store_at_field_offset_impure/3, the
addr-of-field var, offset, and ground-result share an arity-3 call
with no extra typeinfo arguments at the HLDS level. The polymorphism
pass walks the predicate's argument list and, by default, prepends
typeinfo args for any var whose type is polymorphic, growing the
call site to five arguments. recompute_instmap_deltas later reads
the proc's headvar list (3 entries) against that five-arg call and
aborts with "compute_inst_var_sub: length mismatch" inside the
tree234 module on any program built with --optimize-constructor-
last-call under hlc.agc.

no_type_info_builtin/3 is the canonical hook for "this private
builtin is lowered inline by the back-end and must not be wrapped
in typeinfo args by polymorphism". The companion arm in
term_constr_initial.process_no_type_info_builtin already lists the
new builtin; this patch closes the gap on the
mdbcomp/program_representation side, which the polymorphism pass
consults.

The comment block above no_type_info_builtin/3 in this file warns
that adding an entry only takes effect for code built by a
compiler linked with the rebuilt mdbcomp library, which matches
the bootstrap flow used for the AGC revival.

mdbcomp/program_representation.m:
    Add store_at_field_offset_impure/3 to the no_type_info_builtin
    private_builtin block, alongside store_at_ref_impure/2 and the
    other LCMC store primitives.
The MLDS lowering of the field_assign(Cell, Offset, Value)
primitive in ml_gen_builtin assigned ml_lval(ValueLval) directly
into a slot whose declared type is mlds_generic_type (= MR_Box,
= void *). The C back-end therefore emitted

    MR_hl_mask_field((MR_Word) Cell, Offset) = ValueRval;

with no explicit conversion of ValueRval. For ValueLval of type
MR_Word the implicit conversion to MR_Box is at most an
int-to-pointer warning; under -Werror=int-conversion (which
bootcheck imposes on hlc.agc) the warning becomes a fatal error.
For ValueLval of type MR_Float — produced by LCMC patterns that
pack a float into a generic-typed field, see
tests/hard_coded/lco_pack_args_3 — gcc rejects the conversion
outright with "cannot convert to a pointer type", because there
is no implicit float-to-pointer coercion in ISO C.

The MLDS already provides ml_box(Type, SubRval) for exactly this
case: the C back-end's mlds_output_boxed_rval_float emits
MR_box_float(...) for floats, mlds_output_boxed_rval_int64 emits
MR_box_int64 for 64-bit ints, and the default arm emits a plain
"((MR_Box) (...))" cast for word-sized values. Switching from a
bare assign (or, equivalently, from ml_cast(mlds_generic_type,
...)) to ml_box(ValueType, ...) lets the back-end pick the right
boxing routine per value type.

Extracting ValueType requires that ValueLval be ml_local_var; the
existing ref_assign branch already relies on the same shape.
Strengthen the if-then-else guard to demand both Cell and Value
are local-var lvals so the type is always available.

With this change tests/hard_coded passes cleanly under hlc.agc on
linux-aarch64 (WSL Ubuntu 24.04) with --optimize-constructor-
last-call enabled — including lco_pack_args_3 and lco_reorder.

compiler/ml_call_gen.m:
    In the model_det field_assign arm of ml_gen_builtin, lift
    ValueLval's type out of its ml_local_var wrapper and wrap the
    rval in ml_box(ValueType, ...) before passing it to
    ml_gen_assign. Update the surrounding comment to describe the
    box choice and why a bare cast would mis-handle floats.
The MR_extend_zone helper, used by MR_garbage_collect's pre-extend
to grow the AGC to-space, was passing new_size to
MR_realloc_zone_memory while updating the zone's MR_zone_top to
new_base + new_total_size, where new_total_size = new_size +
2 * MR_unit (one page for the hardzone, one extra in the
MR_PROTECTPAGE case). The trailing one or two pages were claimed
by the zone struct but never actually allocated.

MR_setup_redzones then ran mprotect(zone->MR_zone_hardmax,
MR_page_size, REDZONE_PROT) on a page that lives outside our
allocation. By sheer virtual-memory layout, that page can sit
inside a neighbouring live allocation -- typically the partner
heap zone allocated by the same engine -- and the mprotect call
silently turns a chunk of someone else's data read-only.

Once that happens, the next write the collector performs into the
neighbour's data area (e.g. setting a forwarding pointer in a
from-space cell) faults inside the bogus protected page. The
SIGSEGV handler reports `memory zone heap#1 overflowed` even
though MR_virtual_hp has only advanced a few kilobytes into the
to-space, because the fault is recorded against whichever zone
nominally owns the protected page, not against the zone whose
allocation logic actually triggered the fault.

The symptom was lco_reorder under hlc.agc with --heap-size 1024
through 131072 KB: GC #2 always crashed inside the Cheney copy.
After the fix the test passes cleanly across that whole range.
hard_coded under hlc.agc continues to pass with no regressions.

runtime/mercury_memory_zones.c:
    Pass new_total_size, not new_size, to MR_realloc_zone_memory
    in MR_extend_zone, so the realloc covers every byte of
    [MR_zone_bottom, MR_zone_top). Add a comment explaining why,
    so future readers do not collapse the size back to new_size.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant