Repository navigation
defk-c1e: release a dead copy before the kernel returns - #82
Open
DanielLuning wants to merge 1 commit into
Open
DanielLuning wants to merge 1 commit into
DanielLuning wants to merge 1 commit into
Conversation
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."
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 keepsone 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/2buffers,bitstring accumulators and aligned input copies live until return, so Elixir
mode's budget still raises at the same
alloc/2as 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 thebudget) and puts the slot on a free list threaded through the empty slots
(
defk_ctx.free1), whichdefk_own_rawfills before taking a new slot;defk_make_arrputs a handed-over slot on the same list and skips theempty ones.
lib/defk/copies.ex: two facts beside the alias classes:origin(thebuffers a copy made) and
phys(buffers that are physically one: a loopaccumulator 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/4names the block's own bindings thatdie after a statement holding a copy no live holder may share;
take_over/3gives the shadowed outer binding's pairs to the accumulator binding; the
scope carries
read_after(the names read after the statement, before thenames 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/2marks the copy; theaccumulator keeps the one-ref invariant (
Copies.same/3) and assumes itsvalues 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 copyassumption dropped apart from the length facts). The report notes each
release (
:owned,:released); a copy noted as kept per iteration isre-noted when the dying binding holds it or the copy it was made from.
docs/language.md(Copies, the budget),docs/guides/runtime-contract.md,CHANGELOG.md, ADR-0012,docs/performance.md6.47.Two calls beyond "copies only", both in ADR-0012, for you to object to:
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.
x = (a; b)) releases nothing: its bindings liveon in the block around it.
Results
test/owned_budget_test.exs: 1000 iterations of a 1 KiB copy under an8 KiB budget succeed natively and in Elixir mode, in the
for, reduce, armand inlined-callee shapes, under forced yields and as a property (40 runs
over lengths and loop counts);
copy_kept(the copy held through anifvalue) still raises at the same buffer;
alloc_loopis unchanged.local.ctx.free1 = 0in each entry (dump of all 113 modules on bothsides, diffed). Nine test modules gain
defk_disowncalls and valuetemporaries;
Defk.ArrayAccumulatorTest.Kkeeps its proven store (afirst version dropped it with the copy assumption, fixed).
6.47):
copy_loopper iteration, 16 elements 92.3 -> 41.7 ns, 1024elements 864 -> 130 ns. The freed slot and binary are reused, so the table
no longer grows.
DEFK_SANITIZE=address) over all nine test modules whose Cchanged, plus alloc_unzeroed, early_exit and array_branch (two runs, 433
and 107 passed): no report.
How it was checked
mix cion 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_keptreplacescopy_loopin the raise tests) andtest/copies_test.exs("early release":dead/4,may_same?/3,take_over/3).CHANGELOG.mdentry, 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.test/stability/? A frozen file is never edited; a release adds a directory (docs/agents/stability.md). Not touched.