Skip to content

defk-c1e: release a dead copy before the kernel returns - #82

Open
DanielLuning wants to merge 1 commit into
mainfrom
defk-c1e/free-dead-copies
Open

DanielLuning wants to merge 1 commit into
mainfrom
defk-c1e/free-dead-copies

Conversation

@DanielLuning

Copy link
Copy Markdown
Owner

What changed

Follow-up to defk-4b2 (the owned-buffer budget), epic defk-w8r. A
value-semantics copy is now released before the kernel returns, where the
last variable of the block that bound it dies and no other variable may
share the buffer. b = a; a[i] = x; ... b[0] in a loop body no longer keeps
one copy per iteration until return: the loop holds at most two. Only copies
are released (the owner's call on the ticket, 2026-10-22): alloc/2 buffers,
bitstring accumulators and aligned input copies live until return, so Elixir
mode's budget still raises at the same alloc/2 as native code.

  • priv/defk_runtime.h: defk_disown(ctx, data) releases a buffer early
    (backward scan of the owned table, enif_release_binary, the bytes off the
    budget) and puts the slot on a free list threaded through the empty slots
    (defk_ctx.free1), which defk_own_raw fills before taking a new slot;
    defk_make_arr puts a handed-over slot on the same list and skips the
    empty ones.
  • lib/defk/copies.ex: two facts beside the alias classes: origin (the
    buffers a copy made) and phys (buffers that are physically one: a loop
    accumulator and its source when no copy was made, the accumulator and its
    body binding and its result, a merged value and each arm's buffer), asked
    together by may_same?/3; dead/4 names the block's own bindings that
    die after a statement holding a copy no live holder may share; take_over/3
    gives the shadowed outer binding's pairs to the accumulator binding; the
    scope carries read_after (the names read after the statement, before the
    names it binds are taken out).
  • lib/defk/compiler.ex: the release points are in the statement list
    (release_after/1) and after a block's last statement (release_tail/2,
    which binds the value's scalar C to locals first), for every block whose
    bindings die with it (arms, loop bodies, callee bodies, the kernel's); a
    plain nested block releases nothing. copied/2 marks the copy; the
    accumulator keeps the one-ref invariant (Copies.same/3) and assumes its
    values are copies when its initial value was one, lowered again without the
    assumption when a new value is not (through with_acc_facts/2, the copy
    assumption dropped apart from the length facts). The report notes each
    release (:owned, :released); a copy noted as kept per iteration is
    re-noted when the dying binding holds it or the copy it was made from.
  • Docs: docs/language.md (Copies, the budget), docs/guides/runtime-contract.md,
    CHANGELOG.md, ADR-0012, docs/performance.md 6.47.

Two calls beyond "copies only", both in ADR-0012, for you to object to:

  • The release point is every block whose bindings die with it (arm, loop
    body, callee body), as the proposal recommended, and also the kernel's own
    body (the proposal said that needed nothing): a dead copy there is
    released before the next statement instead of at return. One rule.
  • A plain nested block (x = (a; b)) releases nothing: its bindings live
    on in the block around it.

Results

  • test/owned_budget_test.exs: 1000 iterations of a 1 KiB copy under an
    8 KiB budget succeed natively and in Elixir mode, in the for, reduce, arm
    and inlined-callee shapes, under forced yields and as a property (40 runs
    over lengths and loop counts); copy_kept (the copy held through an if
    value) still raises at the same buffer; alloc_loop is unchanged.
  • The generated C of every catalog kernel is unchanged but for
    local.ctx.free1 = 0 in each entry (dump of all 113 modules on both
    sides, diffed). Nine test modules gain defk_disown calls and value
    temporaries; Defk.ArrayAccumulatorTest.K keeps its proven store (a
    first version dropped it with the copy assumption, fixed).
  • Provisional (M-series Mac on battery, one session, docs/performance.md
    6.47): copy_loop per iteration, 16 elements 92.3 -> 41.7 ns, 1024
    elements 864 -> 130 ns. The freed slot and binary are reused, so the table
    no longer grows.
  • ASan (DEFK_SANITIZE=address) over all nine test modules whose C
    changed, plus alloc_unzeroed, early_exit and array_branch (two runs, 433
    and 107 passed): no report.

How it was checked

mix ci on the laptop (M-series Mac). New tests: test/owned_budget_test.exs
("early release": agreement, the C, forced yields, a property, the report
notes; copy_kept replaces copy_loop in the raise tests) and
test/copies_test.exs ("early release": dead/4, may_same?/3,
take_over/3).

  • Touches a promised surface (kernel subset, runtime contract, public API, error codes)? Then a CHANGELOG.md entry, and a deprecation for a rename or removal (docs/guides/stability.md). Entry added: the runtime contract's "arrays live until return" now excepts a dead copy; results and errors are unchanged.
  • Touches test/stability/? A frozen file is never edited; a release adds a directory (docs/agents/stability.md). Not touched.

A value-semantics copy is released (defk_disown) where the last variable
of the block that bound it dies and no other variable may share the
buffer, so a copy made every iteration of a loop holds at most two
buffers at a time instead of one per iteration until return. Only copies:
alloc/2 buffers live until return, so Elixir mode's budget still raises
at the same allocation (ADR-0012, the owner's call on the ticket).

Defk.Copies gains the buffers a copy made (origin), the buffers that are
physically one (phys, asked with the alias classes by may_same?/3),
dead/4 and take_over/3; the compiler releases after a statement and after
a block's last statement (the value's scalars bound to locals first), an
accumulator keeps the one-ref invariant and assumes its values are copies
when its initial value was one, dropped apart from the length facts when
a new value is not. The runtime reuses the freed slot through a free list
in the owned table. The report notes each release.

Measured (provisional): copy_loop 92 -> 42 ns per iteration at 16
elements, 864 -> 130 at 1024 (docs/performance.md 6.47). No catalog
kernel's C changes; ASan over the touched tests is clean.
DanielLuning pushed a commit that referenced this pull request Oct 22, 2026
… mix ci green, ASan clean over the nine test modules whose C changed, no catalog kernel's C changed. Measured (provisional, docs/performance.md 6.47): copy_loop 92 -> 42 ns per iteration at 16 elements, 864 -> 130 at 1024. Two calls beyond copies-only in ADR-0012: the release point is every block incl. the kernel's own body; a plain nested block releases nothing. Ticket stays in progress until the owner merges."
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