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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for map comprehensions
- Added USB CDC port drivers for ESP32, RP2, and STM32 platforms
- Added a Linux `gpio` driver for the generic_unix port (in `avm_unix`) using sysfs
- Added generational garbage collection: minor collections only copy recently allocated data and
promote long-lived terms to a per-process old generation. Tune with the `fullsweep_after`
`spawn_opt/2,3,4,5` option or `process_flag/2` flag (default 65535 like BEAM, `0` forces a full
sweep on every collection), inspect with `process_info/2`

### Changed
- Updated network type db() to dbm() to reflect the actual representation of the type
Expand Down Expand Up @@ -70,6 +74,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Fixed several underallocation issues that could trigger data corruption on `binary:replace`, `zlib:compress` and bsd socket recv code.
- Fixed a bug where `catch` would raise on regular atom results
- Fixed ESP32 socket driver holding the global socket-list lock across blocking TCP connects, leaking the port on connect failure, losing concurrent `accept` waiters, leaking `netbuf` on receive error paths, and a recycled-`netconn` race between socket close and the event handler
- Fixed `put_map_exact` writing the new key into a keys tuple shared with other maps
- Fixed a process that exited before ever being scheduled staying in the scheduler's process list

## [0.7.0-alpha.1] - 2026-04-06

Expand Down
23 changes: 22 additions & 1 deletion code-queries/allocations-exceeding-ensure-free.ql
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,20 @@ predicate consumesContextBudget(FunctionCall efCall, FunctionCall consumer) {
ensureFreeContextVar(efCall)
}

/**
* Holds if `f` tears down a context (e.g. `context_destroy`): it frees the
* context rather than re-establishing a usable heap budget on it. Such a
* function may reach a memory_ensure_free variant incidentally while draining
* signals during teardown, but that does not make a preceding reservation
* redundant -- there is no surviving budget for a caller to use. Excluded from
* the superseding-reset notion to avoid flagging a deliberate
* `ensure_free(ctx, N); ...; context_destroy(ctx);` (e.g. a test that forces a
* GC then destroys the context).
*/
predicate isContextTeardown(Function f) {
f.hasName("context_destroy")
}

/**
* Holds if `efCall` is a redundant reserving ensure_free: no allocating call
* uses its budget, and `supersedingCall` is a subsequent call that resets
Expand All @@ -959,6 +973,11 @@ predicate consumesContextBudget(FunctionCall efCall, FunctionCall consumer) {
pragma[nomagic]
predicate isRedundantEnsureFree(FunctionCall efCall, FunctionCall supersedingCall) {
isReservingEnsureFreeCall(efCall) and
// Test code calls ensure_free for its GC side effects (forcing a minor or
// full collection, swapping in a fragment) with no intent to allocate:
// an unused budget there is deliberate, not a smell. The overflow rule
// still applies to tests.
not efCall.getFile().getRelativePath().matches("tests/%") and
// No allocating call uses this ensure_free's budget
not exists(FunctionCall a | allocToBudget(a, efCall)) and
// ...and no pointer-bumping consumer (memory_copy_term_tree) uses it either
Expand Down Expand Up @@ -986,8 +1005,10 @@ predicate isRedundantEnsureFree(FunctionCall efCall, FunctionCall supersedingCal
// A function that internally calls ensure_free on the caller's
// context (e.g., enif_make_resource), passed that same context as an
// argument. Uses the ensure_free-only notion: own-heap setup does not
// reset this context's budget.
// reset this context's budget. Context teardown (context_destroy) is
// excluded: it frees the context rather than re-establishing a budget.
not isEnsureFreeCall(supersedingCall) and
not isContextTeardown(supersedingCall.getTarget()) and
transitivelyCallsEnsureFreeOnly(supersedingCall.getTarget()) and
supersedingCall.getAnArgument().(VariableAccess).getTarget() = ctxVar
) and
Expand Down
40 changes: 40 additions & 0 deletions doc/src/memory-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -922,3 +922,43 @@ match binaries, as with the case of refc binaries on the process heap.
#### Deletion

Once all terms have been copied from the old heap to the new heap, and once the MSO list has been swept for unreachable references, the old heap is simply discarded via the `free` function.

### Generational Garbage Collection

The garbage collection described above is a *full sweep*: every live term is copied from the old heap to the new heap and the entire old heap is freed. While correct, this can be expensive for processes with large heaps, because long-lived data that has already survived previous collections must be copied again each time.

AtomVM implements *generational* (or *minor*) garbage collection to reduce this cost, using the same approach as BEAM. The key observation is that most terms die young: they are allocated, used briefly, and become garbage. Terms that have survived at least one collection are likely to survive many more. Generational GC exploits this by dividing the heap into two generations:

* **Young generation**: recently allocated terms, between the *high water mark* and the current heap pointer.
* **Old (mature) generation**: terms that have survived at least one minor collection, stored in a separate old heap.

#### High Water Mark

After each garbage collection, the heap pointer position is recorded as the *high water mark*. On the next collection, terms allocated below the high water mark (i.e., terms that existed at the time of the previous collection) are considered mature. Terms allocated above the high water mark are young.

#### Minor Collection

During a minor collection:

1. A new young heap is allocated.
2. Mature terms (below the high water mark) are *promoted*: copied to the old heap rather than the new young heap.
3. Young terms that are still reachable are copied to the new young heap.
4. Both the new young heap and the newly promoted old region are scanned, to a fixpoint: young terms freely reference mature data (terms only reference older terms), so scanning the young region keeps discovering mature terms to promote, and each promoted term's children are mature too and must be promoted in turn. Because terms are immutable, a promoted term can never reference a young term (see Why the Old Generation Never References the Young One below).
5. Only the young MSO list is swept; the old MSO list is preserved.
6. The previous heap is freed, but the old heap persists across minor collections.

Because the old heap is not scanned for garbage during a minor collection, the cost is proportional to the size of the young generation rather than the entire heap.

#### Why the Old Generation Never References the Young One

The old generation is not rescanned by minor collections, so it must never point at young terms a minor collection could free. This is guaranteed without any write barrier or remembered set. Because terms are immutable, mutator code cannot create such references: a mature term only references data that existed before the last collection, the compiler only emits destructive tuple updates (`set_tuple_element`) directly after the tuple's creation with no possible garbage collection in between, and `update_record` builds a new tuple. Promotion is decided by address (anything below the high water mark or in a chained fragment is promoted, whichever path reaches it first), so shared terms cannot end up young by copy order either. It follows that promotion is bounded by the mature region plus the chained fragments; the collector checks the old heap's free space against that bound before starting a minor collection (falling back to a full sweep otherwise) and asserts it during the copy. A future destructive `update_record` must preserve the invariant the way BEAM does: update in place only if the record is in the young region, copy otherwise.

#### When Full vs. Minor Collection Occurs

AtomVM keeps a counter (`gc_count`) of how many minor collections have occurred since the last full sweep. A full sweep is forced when:

* `gc_count` reaches the `fullsweep_after` threshold.
* The old heap does not have enough space to accommodate the expected promotion.
* A `MEMORY_FORCE_SHRINK` request is made (e.g., via `erlang:garbage_collect/0`).

The `fullsweep_after` value can be set per-process via [`spawn_opt`](./programmers-guide.md#spawning-processes) or [`erlang:process_flag/2`](./apidocs/erlang/estdlib/erlang.md#process_flag2). The default value is 65535, meaning full sweeps are infrequent under normal operation. Setting it to `0` disables generational collection entirely, forcing a full sweep on every garbage collection event.
1 change: 1 addition & 0 deletions doc/src/programmers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,7 @@ The [options](./apidocs/erlang/estdlib/erlang.md#spawn_option) argument is a pro
|-----|------------|---------------|-------------|
| `min_heap_size` | `non_neg_integer()` | none | Minimum heap size of the process. The heap will shrink no smaller than this size. |
| `max_heap_size` | `non_neg_integer()` | unbounded | Maximum heap size of the process. The heap will grow no larger than this size. |
| `fullsweep_after` | `non_neg_integer()` | 65535 | Maximum number of [minor garbage collections](./memory-management.md#generational-garbage-collection) before a full sweep is forced. Set to `0` to disable generational garbage collection. |
| `link` | `boolean()` | `false` | Whether to link the spawned process to the spawning process. |
| `monitor` | `boolean()` | `false` | Whether to link the spawning process should monitor the spawned process. |
| `atomvm_heap_growth` | `bounded_free \| minimum \| fibonacci` | `bounded_free` | [Strategy](./memory-management.md#heap-growth-strategies) to grow the heap of the process. |
Expand Down
13 changes: 11 additions & 2 deletions libs/estdlib/src/erlang.erl
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@
-type spawn_option() ::
{min_heap_size, pos_integer()}
| {max_heap_size, pos_integer()}
| {fullsweep_after, non_neg_integer()}
| {atomvm_heap_growth, atomvm_heap_growth_strategy()}
| link
| monitor.
Expand Down Expand Up @@ -320,6 +321,7 @@ send_after(Time, Dest, Msg) ->
%% <li><b>memory</b> the estimated total number of bytes in use by the process (integer)</li>
%% <li><b>links</b> the list of linked processes</li>
%% <li><b>monitored_by</b> the list of processes, NIF resources or ports that monitor the process</li>
%% <li><b>fullsweep_after</b> the maximum number of minor (generational) collections before a full-sweep garbage collection (integer)</li>
%% </ul>
%% Specifying an unsupported term or atom raises a bad_arg error.
%%
Expand All @@ -333,7 +335,8 @@ send_after(Time, Dest, Msg) ->
(Pid :: pid(), message_queue_len) -> {message_queue_len, non_neg_integer()};
(Pid :: pid(), memory) -> {memory, non_neg_integer()};
(Pid :: pid(), links) -> {links, [pid()]};
(Pid :: pid(), monitored_by) -> {monitored_by, [pid() | resource() | port()]}.
(Pid :: pid(), monitored_by) -> {monitored_by, [pid() | resource() | port()]};
(Pid :: pid(), fullsweep_after) -> {fullsweep_after, non_neg_integer()}.
process_info(_Pid, _Key) ->
erlang:nif_error(undefined).

Expand Down Expand Up @@ -1439,9 +1442,15 @@ group_leader(_Leader, _Pid) ->
%% '''
%% and the process does not exit if `Reason' is not `normal'.
%%
%% `fullsweep_after' sets the maximum number of minor (generational)
%% collections before the next garbage collection is a full sweep; `0'
%% disables generational collection (every collection is a full sweep).
%%
%% @end
%%-----------------------------------------------------------------------------
-spec process_flag(Flag :: trap_exit, Value :: boolean()) -> pid().
-spec process_flag
(trap_exit, boolean()) -> boolean();
(fullsweep_after, non_neg_integer()) -> non_neg_integer().
process_flag(_Flag, _Value) ->
erlang:nif_error(undefined).

Expand Down
26 changes: 13 additions & 13 deletions libs/jit/src/jit.erl
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,9 @@ first_pass(<<?OP_SET_TUPLE_ELEMENT, Rest0/binary>>, MMod, MSt0, State0) ->
{MSt2, Tuple, Rest2} = decode_compact_term(Rest1, MMod, MSt1, State0),
{Position, Rest3} = decode_literal(Rest2),
?TRACE("OP_SET_TUPLE_ELEMENT ~p, ~p, ~p\n", [NewElement, Tuple, Position]),
%% No write barrier needed: the compiler emits set_tuple_element only
%% right after the tuple's creation, with no possible GC in between, so
%% the tuple cannot be in the old generation.
{MSt3, Reg} = MMod:move_to_native_register(MSt2, Tuple),
{MSt4, Reg} = MMod:and_(MSt3, {free, Reg}, ?TERM_PRIMARY_CLEAR_MASK),
MSt5 = MMod:move_to_array_element(MSt4, NewElement, Reg, Position + 1),
Expand Down Expand Up @@ -1905,10 +1908,11 @@ first_pass(<<?OP_PUT_MAP_EXACT, Rest0/binary>>, MMod, MSt0, State0) ->
ctx, jit_state, offset, ?OUT_OF_MEMORY_ATOM
])
end),
ASt5 = term_set_map_assoc(
NewMapPtrReg, {free, PosReg}, {free, Key}, {free, Value}, MMod, ASt4
ASt5 = term_set_map_value(
NewMapPtrReg, {free, PosReg}, {free, Value}, MMod, ASt4
),
{ASt5, ARest2}
ASt6 = MMod:free_native_registers(ASt5, [Key]),
{ASt6, ARest2}
end,
{MSt10, Rest5},
lists:seq(1, NumElements)
Expand Down Expand Up @@ -5040,16 +5044,12 @@ term_binary_size(Src, MMod, MSt0) ->
MSt3 = MMod:move_array_element(MSt2, SrcReg, 1, SrcReg),
{MSt3, SrcReg}.

term_set_map_assoc(MapPtrReg, {free, PosReg}, {free, Key}, {free, Value}, MMod, MSt0) ->
{MSt1, MapKeysReg} = MMod:get_array_element(MSt0, MapPtrReg, 1),
MSt2 = term_put_tuple_element({free, MapKeysReg}, PosReg, {free, Key}, MMod, MSt1),
MSt3 = MMod:move_to_array_element(MSt2, Value, MapPtrReg, PosReg, 2),
MMod:free_native_registers(MSt3, [PosReg, Value]).

term_put_tuple_element({free, TupleReg}, PosReg, {free, Value}, MMod, MSt0) ->
{MSt1, TupleReg} = MMod:and_(MSt0, {free, TupleReg}, ?TERM_PRIMARY_CLEAR_MASK),
MSt2 = MMod:move_to_array_element(MSt1, Value, TupleReg, PosReg, 1),
MMod:free_native_registers(MSt2, [TupleReg, Value]).
%% The keys tuple is shared with the source map: only the value may be
%% updated. Writing the key operand (equal but not necessarily identical)
%% would mutate the source map's keys tuple in place.
term_set_map_value(MapPtrReg, {free, PosReg}, {free, Value}, MMod, MSt0) ->
MSt1 = MMod:move_to_array_element(MSt0, Value, MapPtrReg, PosReg, 2),
MMod:free_native_registers(MSt1, [PosReg, Value]).

%% @doc Get the stream module
%% @return The stream module for jit on this platform
Expand Down
24 changes: 16 additions & 8 deletions libs/jit/src/jit_aarch64.erl
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,8 @@
xor_/3
]).

-export([dwarf_x_reg_offset/0]).

-ifdef(JIT_DWARF).
-export([
dwarf_opcode/2,
Expand Down Expand Up @@ -186,25 +188,25 @@
| {maybe_free_aarch64_register(), '&', non_neg_integer(), '!=', integer()}
| {{free, aarch64_register()}, '==', {free, aarch64_register()}}.

% ctx->e is 0x28
% ctx->x is 0x30
% ctx->e is 0x50
% ctx->x is 0x58
-define(WORD_SIZE, 8).
-define(CTX_REG, r0).
-define(JITSTATE_REG, r1).
-define(NATIVE_INTERFACE_REG, r2).
-define(Y_REGS, {?CTX_REG, 16#28}).
-define(X_REG(N), {?CTX_REG, 16#30 + (N * ?WORD_SIZE)}).
-define(CP, {?CTX_REG, 16#B8}).
-define(FP_REGS, {?CTX_REG, 16#C0}).
-define(Y_REGS, {?CTX_REG, 16#50}).
-define(X_REG(N), {?CTX_REG, 16#58 + (N * ?WORD_SIZE)}).
-define(CP, {?CTX_REG, 16#E0}).
-define(FP_REGS, {?CTX_REG, 16#E8}).
-define(FP_REG_OFFSET(State, F),
(F *
case (State)#state.variant band ?JIT_VARIANT_FLOAT32 of
0 -> 8;
_ -> 4
end)
).
-define(BS, {?CTX_REG, 16#C8}).
-define(BS_OFFSET, {?CTX_REG, 16#D0}).
-define(BS, {?CTX_REG, 16#F0}).
-define(BS_OFFSET, {?CTX_REG, 16#F8}).
-define(JITSTATE_MODULE, {?JITSTATE_REG, 0}).
-define(JITSTATE_CONTINUATION, {?JITSTATE_REG, 16#8}).
-define(JITSTATE_REDUCTIONCOUNT, {?JITSTATE_REG, 16#10}).
Expand Down Expand Up @@ -3111,6 +3113,12 @@ add_label(
add_label(#state{labels = Labels} = State, Label, Offset) ->
State#state{labels = Labels#{Label => Offset}}.

%% @doc Byte offset of the `x' register array within the Context struct.
%% Derived from ?X_REG so it tracks the codegen offset.
-spec dwarf_x_reg_offset() -> non_neg_integer().
dwarf_x_reg_offset() ->
element(2, ?X_REG(0)).

-ifdef(JIT_DWARF).
%%-----------------------------------------------------------------------------
%% @doc Return the DWARF register number for the ctx parameter
Expand Down
24 changes: 16 additions & 8 deletions libs/jit/src/jit_arm32.erl
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
xor_/3
]).

-export([dwarf_x_reg_offset/0]).

-ifdef(JIT_DWARF).
-export([
dwarf_opcode/2,
Expand Down Expand Up @@ -175,16 +177,16 @@
| {maybe_free_arm32_register(), '&', non_neg_integer(), '!=', integer()}
| {{free, arm32_register()}, '==', {free, arm32_register()}}.

% ctx->e is 0x14
% ctx->x is 0x18
% ctx->e is 0x28
% ctx->x is 0x2C
-define(CTX_REG, r0).
-define(NATIVE_INTERFACE_REG, r2).
-define(Y_REGS, {?CTX_REG, 16#14}).
-define(X_REG(N), {?CTX_REG, 16#18 + (N * 4)}).
-define(CP, {?CTX_REG, 16#5C}).
-define(FP_REGS, {?CTX_REG, 16#60}).
-define(BS, {?CTX_REG, 16#64}).
-define(BS_OFFSET, {?CTX_REG, 16#68}).
-define(Y_REGS, {?CTX_REG, 16#28}).
-define(X_REG(N), {?CTX_REG, 16#2C + (N * 4)}).
-define(CP, {?CTX_REG, 16#70}).
-define(FP_REGS, {?CTX_REG, 16#74}).
-define(BS, {?CTX_REG, 16#78}).
-define(BS_OFFSET, {?CTX_REG, 16#7C}).
% JITSTATE is on stack, accessed via stack offset
% These macros now expect a register that contains the jit_state pointer
-define(JITSTATE_MODULE(Reg), {Reg, 0}).
Expand Down Expand Up @@ -3889,6 +3891,12 @@ value_to_contents(Value) -> jit_regs:value_to_contents(Value, ?MAX_REG).
%% Convert a VM register destination to a contents descriptor.
vm_dest_to_contents(Dest) -> jit_regs:vm_dest_to_contents(Dest, ?MAX_REG).

%% @doc Byte offset of the `x' register array within the Context struct.
%% Derived from ?X_REG so it tracks the codegen offset.
-spec dwarf_x_reg_offset() -> non_neg_integer().
dwarf_x_reg_offset() ->
element(2, ?X_REG(0)).

-ifdef(JIT_DWARF).
%%-----------------------------------------------------------------------------
%% @doc Return the DWARF register number for the ctx parameter
Expand Down
22 changes: 15 additions & 7 deletions libs/jit/src/jit_armv6m.erl
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@
xor_/3
]).

-export([dwarf_x_reg_offset/0]).

-ifdef(JIT_DWARF).
-export([
dwarf_opcode/2,
Expand Down Expand Up @@ -196,15 +198,15 @@
| {{free, armv6m_register()}, '==', {free, armv6m_register()}}.

% ctx->e is 0x28
% ctx->x is 0x30
% ctx->x is 0x2C
-define(CTX_REG, r0).
-define(NATIVE_INTERFACE_REG, r2).
-define(Y_REGS, {?CTX_REG, 16#14}).
-define(X_REG(N), {?CTX_REG, 16#18 + (N * 4)}).
-define(CP, {?CTX_REG, 16#5C}).
-define(FP_REGS, {?CTX_REG, 16#60}).
-define(BS, {?CTX_REG, 16#64}).
-define(BS_OFFSET, {?CTX_REG, 16#68}).
-define(Y_REGS, {?CTX_REG, 16#28}).
-define(X_REG(N), {?CTX_REG, 16#2C + (N * 4)}).
-define(CP, {?CTX_REG, 16#70}).
-define(FP_REGS, {?CTX_REG, 16#74}).
-define(BS, {?CTX_REG, 16#78}).
-define(BS_OFFSET, {?CTX_REG, 16#7C}).
% JITSTATE is on stack, accessed via stack offset
% These macros now expect a register that contains the jit_state pointer
-define(JITSTATE_MODULE(Reg), {Reg, 0}).
Expand Down Expand Up @@ -4381,6 +4383,12 @@ add_label(
add_label(#state{labels = Labels} = State, Label, Offset) ->
State#state{labels = Labels#{Label => Offset}}.

%% @doc Byte offset of the `x' register array within the Context struct.
%% Derived from ?X_REG so it tracks the codegen offset.
-spec dwarf_x_reg_offset() -> non_neg_integer().
dwarf_x_reg_offset() ->
element(2, ?X_REG(0)).

-ifdef(JIT_DWARF).
%%-----------------------------------------------------------------------------
%% @doc Return the DWARF register number for the ctx parameter
Expand Down
Loading
Loading