Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,18 @@

## Unreleased

- Changed (runtime contract, within the promise): a value-semantics copy is
released before the kernel returns when its last variable dies and no
other variable may share the buffer, instead of living until return. A
copy made every iteration of a loop (`b = a; a[i] = x; ... b[0]`) no
longer keeps one buffer per iteration under `max_owned_bytes`: the loop
holds at most two. `alloc/2` buffers, bitstring accumulators and aligned
input copies still live until return, so Elixir mode's budget still
raises at the same `alloc/2` as native code. The fast-path report notes
each release (`:owned`, `:released`), and the generated C shows them as
`defk_disown` calls. Results are unchanged; a kernel that raised "out of
memory" at the budget only because of such copies now runs to its result
(the budget's numbers are outside the promise, `docs/guides/stability.md`).
- Changed (runtime, no promise changes): on Apple platforms the clock that
measures a yielding kernel's slices reads `mach_absolute_time` instead of
the ERTS monotonic clock. A yielding kernel's call cost about 20 ns less
Expand Down
100 changes: 100 additions & 0 deletions docs/adr/0012-early-release-of-dead-copies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# ADR-0012: A dead copy is released early; an alloc lives until return

- Date: 2026-10-22
- Status: accepted (2026-10-22: the maintainer chose "copies only" from
the design proposal on the ticket)
- Source: ticket `defk-c1e` in epic `defk-w8r`, follow-up to `defk-4b2`
(the owned-buffer budget)

## Context

Every buffer a kernel call owns (`alloc/2`, a value-semantics copy, a
bitstring accumulator, an aligned copy of a misaligned input) is a slot in
`ctx->owned` and lives until the kernel returns. `defk-4b2` bounded the
total with `max_owned_bytes`, so a copy or an alloc inside a loop raises
"out of memory" instead of taking the node down, but the buffers still
accumulate: `b = a; a[i] = x; ... b[0]` in a loop body copies `a` every
iteration (docs/language.md, Copies), and each copy is kept until return
although only the latest two are ever read again. Elixir would
garbage-collect the rest.

Two facts of the compiler decide what can be released safely:

- The copy analysis (`Defk.Copies`) already tracks which bindings *may*
share a buffer: alias classes for the arrays an `if`, `cond` or `case`
gives, and `{id, buf}` pairs for what is live after a statement and after
a block. A buffer no live pair may share is dead.
- The analysis gives a loop accumulator a fresh buffer ref whether or not
its initial value was copied, so one physical buffer can carry two refs
(the dead outer binding's and the accumulator's). That is harmless for
the copy decision (the outer binding is dead, or a copy was made) but
unsound for a release: a buffer could be released through one ref while
the other is live.

Elixir mode enforces the same `max_owned_bytes` on the bytes `alloc/2`
asks for, and docs/language.md promises that native code's total is never
below Elixir mode's, so an `alloc/2` in a loop raises at the same iteration
in both modes. Releasing a dead `alloc/2` buffer natively would invert
that: native code would succeed where Elixir mode raises.

## Decision

1. **Only copies are released early.** A buffer is released where the last
binding of the block that holds it dies (after a statement nothing
later reads it, or after the block's last statement with the block's
value bound to locals first), when the buffer was made by a copy and no
live holder may be the same buffer. `alloc/2` buffers, bitstring
accumulators and aligned input copies live until return, as before, so
the budget agreement with Elixir mode stays as written.
2. **One physical buffer, one "may be the same" class.** `Defk.Copies`
records, beside the alias classes, which buffers are physically one
(`phys`: an 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) and which were made by a copy (`origin`). The release
test asks `may_same?/3` over both. An accumulator's buffer counts as a
copy only when its initial value was copied (or was a copy) and every
new value the body gives is a copy: the body is lowered under that
assumption and lowered again without it when a new value breaks it,
through the same backtracking as the accumulator facts.
3. **The runtime keeps the slot.** `defk_disown(ctx, data)` releases the
binary, subtracts its bytes from the budget and puts the slot on a free
list threaded through the empty slots, which `defk_own_raw` fills before
taking a new one, so a loop's table stays small. The array value stays
two words (data, length); the slot is found by a backward scan, since
the buffer that dies is usually a recent one. A slot index inside every
array value was rejected: it would touch every array operation.
4. **A stale pair does not hold a buffer.** A `{id, buf}` pair live after a
block whose binding was since copied in place (the same id with a
buffer that is not physically `buf`) names a buffer the binding no
longer holds. An accumulator binding stands in for the outer binding it
took its buffer from without a copy (`take_over/3`), so the loop
statement's reads of that name, resolved before the body bound it
again, do not keep the old buffer alive.

Two calls the implementation made beyond the owner's "copies only":

- The release point is every block whose bindings die with it (an arm, a
loop body, an inlined callee's body), as the proposal recommended, and
also the kernel's own body, which the proposal said needed nothing:
there a dead copy is released before the next statement instead of at
return (`copy_kept` in `test/owned_budget_test.exs` releases the hoisted
copy of its input after the loop). One rule, one code path.
- A plain nested block (`x = (a; b)`) releases nothing: its bindings live
on in the block around it.

## Consequences

- A per-iteration copy whose binding dies holds at most two buffers at a
time instead of one per iteration; `test/owned_budget_test.exs` runs
1000 iterations of a 1 KiB copy under an 8 KiB budget, natively and in
Elixir mode, also under forced yields and as a property.
- The release is static and conservative: a buffer an `if`/`cond`/`case`
value may hold, or one a loop accumulator may hold when not every value
is a copy, is never released early (`copy_kept` in the same test still
raises past the budget).
- A released binding's C local is stale at the next spill; a spill is a
store, never a dereference, and the binding is bound again before any
read. The generated C of a kernel without a dying copy is unchanged but
for `free1 = 0` in the entry.
- The report says where each copy is released; a copy in a loop that is
not released is still noted as kept per iteration.
6 changes: 4 additions & 2 deletions docs/guides/runtime-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,8 +113,10 @@ kernel is suspended, nothing leaks: `Mod.__defk_stats__().frames_live` goes
back down. `alloc/2` raises `ArgumentError` for a length over 2^48 bytes or
one the allocator cannot provide, instead of failing later.

A kernel's arrays live until it returns, so an `alloc` or a copy in a loop
body keeps one per iteration. On an operating system that overcommits
A kernel's arrays live until it returns, except a copy whose last variable
died, which is released then ([Copies](../language.md#copies)); so an
`alloc` in a loop body keeps one per iteration, and a copy whose variable
is read in the next iteration does too. On an operating system that overcommits
memory, the node would be killed long before the allocator failed, so each
call has a budget: an allocation that would take the bytes it holds past
`use Defk, max_owned_bytes: n` (by default half of physical memory) raises
Expand Down
41 changes: 30 additions & 11 deletions docs/language.md
Original file line number Diff line number Diff line change
Expand Up @@ -1601,20 +1601,36 @@ or clause head that chose it, which ran before it. It is conservative, so a
kernel may copy where Elixir would not have to (for example when `b` is read
earlier in the loop body than the write, which is not a read after it).
The fast-path report (`Defk.Report`) notes each copy, and
`Mod.__defk_c_source__/0` shows them as `defk_copy_*` calls. Like `alloc`,
every copy lives until the kernel returns, so a copy in a loop body keeps
one array per iteration (the report notes that too), counted against the
[owned-buffer budget](#the-owned-buffer-budget).
`Mod.__defk_c_source__/0` shows them as `defk_copy_*` calls.

A copy is released before the kernel returns when its last variable dies:
when the block that bound the variable (an `if`, `cond` or `case` arm, a
loop body, a called kernel's body, the kernel's own) reaches a statement
after which nothing reads it, and no other variable that may share the
buffer is read later, in the block or after it. So the copy that `b = a; a[i]
= x; ... b[0]` makes every iteration no longer keeps one buffer per
iteration: when `b` dies at the end of the iteration, the previous
iteration's copy goes with it, and the loop holds two at a time. The test
is static and conservative, like the copy decision: a buffer an `if`,
`cond` or `case` value may hold (`c = if p, do: b, else: a`) is never
released early, and nor is one a loop accumulator may hold unless every
value of the accumulator is a copy. Only copies are released early: an
`alloc/2` buffer lives until the kernel returns, so that both modes raise
at the same allocation under the
[owned-buffer budget](#the-owned-buffer-budget). The report notes each
release, and `__defk_c_source__/0` shows them as `defk_disown` calls; a
copy in a loop that is not released is noted as kept per iteration.

### The owned-buffer budget

Every array a call allocates (`alloc/2`), every copy above, the buffer of a
[bitstring accumulator](#the-bitstring-accumulator) and the aligned copy of
a misaligned input are the kernel's own until it returns. Elixir would
garbage-collect an array no variable sees any more; a kernel frees them all
at once when it returns, raises, or its process dies. So a call keeps a
running total of the bytes it holds, and an allocation that would take it
past the module's budget raises `ArgumentError`:
a misaligned input are the kernel's own until it returns, except a copy
whose last variable died (released then, see [Copies](#copies)). Elixir
would garbage-collect an array no variable sees any more; a kernel frees
the rest all at once when it returns, raises, or its process dies. So a
call keeps a running total of the bytes it holds, and an allocation that
would take it past the module's budget raises `ArgumentError`:

```
** (ArgumentError) out of memory (allocating 1024 bytes would take this call's buffers past max_owned_bytes, 8192 bytes)
Expand All @@ -1637,7 +1653,8 @@ fails, which would break the [runtime contract](guides/runtime-contract.md).
the host's memory). `Mod.__defk_stats__().max_owned_bytes` shows the budget.
- The count is per call: concurrent calls each have their own, a call
after a raise starts from zero, and a yield keeps it. An array a kernel
returns counts until it returns.
returns counts until it returns; a copy released early stops counting
when it is released.
- Elixir mode enforces the same budget on `alloc/2`: it adds up the bytes
every `alloc/2` of the call asks for (in called kernels too), against
the same `max_owned_bytes` (it reads `__defk_stats__().max_owned_bytes`), and raises
Expand All @@ -1647,7 +1664,9 @@ fails, which would break the [runtime contract](guides/runtime-contract.md).
copies, bitstring accumulator buffers or aligned input copies (it
garbage-collects those, as Elixir does), so a call whose copies pass the
budget raises in native code and succeeds in Elixir mode: its total is
never above native code's, so never the reverse.
never above native code's, so never the reverse. That is why only copies
are released early: releasing an `alloc/2` buffer natively would put
native code's total below Elixir mode's.
- A single allocation of 2^47 bytes or more, which no allocator serves,
raises the plain "out of memory (allocating N bytes)" of
[Allocating](#allocating) instead, the same in both modes.
Expand Down
39 changes: 39 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -3124,6 +3124,45 @@ the loop's exit path, once per block) beside the 16 MB time;
`DEFK_TICKS` in `lib/defk/compiler.ex` (`sp_macro/3`, `run_preamble/1`).
- **Status:** measured.

### 6.47 Releasing a dead copy early makes a per-iteration copy 2-7x cheaper: the slot and the binary are reused instead of growing the table

Since `defk-c1e` a copy is released where its last variable dies
(docs/language.md, Copies; ADR-0012), so `b = a; a[i] = x; ... b[0]` in a
loop body holds two copies at a time instead of one per iteration. The
release itself is one call (a backward scan of the owned table, usually
one step, and `enif_release_binary`), and it pays for itself at once: the
next copy takes the freed slot and, from the allocator, usually the freed
binary, so the table never grows and the copy stays in cache. Before, each
iteration took a new binary and, every power of two, a bigger table.

- **Numbers** (`copy_loop` of
[`test/owned_budget_test.exs`](https://github.com/DanielLuning/defk/blob/main/test/owned_budget_test.exs), median of 7 calls, ns per
iteration): 16 i64 elements, 1M iterations: `main` 92.3, with the
release 41.7 (2.2x); 1024 elements, 100k iterations: `main` 864, with
the release 130 (6.6x). The 6.25 figure (~50 ns per copy iteration at 32
bytes) was measured at 200k iterations, before the table and the
allocator's free lists had grown far.
- **Conditions:** M-series Mac on battery, one session, no quiet check;
provisional.
- **Source:** `defk-c1e`, a scratch script of `copy_loop` on both
checkouts; `defk_disown` and `defk_own_raw` in [`priv/defk_runtime.h`](https://github.com/DanielLuning/defk/blob/main/priv/defk_runtime.h),
`Copies.dead/4` in [`lib/defk/copies.ex`](https://github.com/DanielLuning/defk/blob/main/lib/defk/copies.ex).
- **Status:** provisional.

<!-- defk-example skip: kernel-subset code, the shape that is released -->
```elixir
# released: b dies at the end of the iteration, and the previous
# iteration's copy of a with it
for i <- 0..(n - 1)//1, reduce: 0 do
acc ->
b = a
a[0] = i
acc + b[0] + a[0]
end
# kept until return: c may be b or a, so neither buffer is released
c = if i > 2, do: b, else: a
```

---

## 7. When a hand-written or wrapper NIF is still the answer
Expand Down
Loading