wasm_memory (wasm v0.1.0)
View SourceLinear memory backed by chunked atomics arrays.
You reach this through wasm:read_memory/3 and wasm:write_memory/3; read the
module itself when you want to know what a store costs you. The representation
is the one the benchmarks chose. Per store, on this machine:
atomics, raw 64-bit word 6.1 ns
atomics, masked i32 store 22.1 ns
ETS, one row per word 41.6 ns
ETS, 4 KB chunk rebuild 278 ns
binary rebuild, 64 KiB 1201 nsBinaries are immutable, so every store rebuilds the whole page. At 1.2 us per
store they are unusable as mutable memory however good they are at reads.
atomics is mutable in place, and it is an OTP built-in resource rather than
a NIF this project ships, so linear memory needs no native code of its own to
be viable. That is the single biggest reason a pure-Erlang runtime is
practical at all.
Chunking. Memory is a tuple of atomics arrays, one per 1 MiB, so
memory.grow appends a chunk instead of reallocating and copying. A single
flat array would make growth O(size), and growing to 64 MiB one page at a
time would copy about a thousand times more than it allocates.
Alignment. atomics granularity is 64 bits, so an aligned i64 store
is one put while an i32 store is a read-modify-write. Accesses that span
two words take a slower path again. The value is assembled little-endian
because that is what WebAssembly specifies, independent of host byte order.
Accounting. atomics memory is off-heap and invisible to max_heap_size,
so every page is reserved before it is allocated. Without that, a module could
exhaust your node without its owning process's heap ever growing. Reserving,
growing and releasing are wasm_keeper transactions, which is also what records
who holds the memory and releases it when they are all gone.
Summary
Functions
Add a holder to this memory, for an instance importing it.
Replace the value at Addr only if it is Expected, answering what was there.
Atomic access.
Apply Fun to the value at Addr indivisibly, answering the old value.
Copy between two distinct memories.
Create a memory, saying who holds it and whether its size is observable.
Where each field of a memory handle lives, by index.
Release this handle's claim on the memory.
Grow by Delta pages, returning the previous size.
A stable identity for this memory, for keying the wait queue.
memory.init: copy from a passive data segment.
Whether a term is a memory handle.
Whether this memory was declared shared, which wait requires.
Load Nbytes at Addr as an unsigned little-endian integer.
Read a byte range as a binary, for host functions and WASI.
The all-ones mask for an access of N bytes.
A standalone memory, held by the calling process.
A memory whose size is visible to other instances.
Remove a named holder, whatever this handle's own token is.
This memory's registry identity.
Whole-memory snapshot. Diagnostics and tests only: it materialises the entire memory as a binary.
Types
Functions
-spec acquire(mem(), wasm_keeper:token(), pid() | none) -> ok | {error, gone | instance_limit | keeper_unavailable}.
Add a holder to this memory, for an instance importing it.
{error, gone} means the last holder released it first, which has to be a link
failure rather than a memory the importer goes on to use.
-spec atomic_cmpxchg(mem(), non_neg_integer(), 1..8, integer(), integer()) -> non_neg_integer().
Replace the value at Addr only if it is Expected, answering what was there.
The answer is the old value either way, so a caller compares it with what it expected to find out whether the exchange happened. That is the specified interface, and it is why this cannot be built from a load and a store.
-spec atomic_load(mem(), non_neg_integer(), 1..8) -> non_neg_integer().
Atomic access.
An atomic load or store is naturally aligned, which validation enforces
exactly, so it never straddles two of the 64-bit words the memory is made of.
That makes a load one atomics:get and an aligned eight-byte store one
atomics:put.
Anything narrower is a part of a word, so writing it means reading the word,
replacing a field and writing it back. That is three operations and another
process can write the same word between them, so it goes through
atomics:compare_exchange and retries. A read-modify-write is the same loop
with the arithmetic inside it, which is what makes i32.atomic.rmw.add a
single indivisible step rather than a load and a store that usually work.
-spec atomic_rmw(mem(), non_neg_integer(), 1..8, atom(), integer()) -> non_neg_integer().
Apply Fun to the value at Addr indivisibly, answering the old value.
-spec atomic_store(mem(), non_neg_integer(), 1..8, integer()) -> ok.
-spec copy(mem(), non_neg_integer(), non_neg_integer(), non_neg_integer()) -> ok.
-spec copy(mem(), non_neg_integer(), mem(), non_neg_integer(), non_neg_integer()) -> ok.
Copy between two distinct memories.
There is no overlap to worry about, so the source range is read out as one
binary and written back as one binary. Both ranges are bounds-checked before
anything is written, which is what the specification requires: a memory.copy
that traps must leave the destination untouched.
A zero-length copy is checked too. Returning early on Len = 0 looks
harmless and is not: the specification admits an address exactly at the end of
a memory and requires a trap one byte beyond it, whatever the length. The
same-memory copy/4 above already checks before it looks at the length, and
memory_copy1.wast asserts both directions.
-spec create(#limits{min :: non_neg_integer(), max :: undefined | non_neg_integer(), shared :: boolean(), index_type :: i32 | i64}, map()) -> {ok, mem()} | {error, term()}.
Create a memory, saying who holds it and whether its size is observable.
observable is the linking sense: another instance can see this memory grow, so
its size and chunk tuple are published rather than kept in the handle. holder
is a {Token, Owner} pair naming the registry entry to create and the process
whose death removes it; leave it out and the memory belongs to the calling
process, or to nobody at all if it is thread-shared.
-spec field_indices() -> #{atom() => pos_integer()}.
Where each field of a memory handle lives, by index.
Generated code reads a handle directly rather than calling in for every access,
so it needs these, and a header of literals that silently disagreed with the
record would corrupt memory rather than fail. wasm_core_SUITE asserts this
answer equals include/wasm_memory.hrl, so adding a field breaks a test.
-spec fill(mem(), non_neg_integer(), byte(), non_neg_integer()) -> ok.
-spec free(mem()) -> ok.
Release this handle's claim on the memory.
Idempotent, and safe to call on a handle that holds no claim: a memory an
instance created or imported is released by destroying that instance, so
free/1 on a handle you got from wasm:extern/2 does nothing rather than
pulling the memory out from under it.
The pages that go back are the size the memory is now, taken from the registry, not the size this handle was made at. A grown memory used to return only its original pages and leave the rest charged for the life of the node.
-spec grow(mem(), non_neg_integer()) -> {ok, non_neg_integer(), mem()} | {error, term()}.
Grow by Delta pages, returning the previous size.
Returns {error, _} rather than trapping: memory.grow is specified to
push -1 on failure, so refusal is a value the module observes, not a fault.
Growth is serialised per memory, in two stages. The keeper validates the request against the authoritative size, reserves the budget and hands out the right to grow; the caller allocates the chunks, which is the expensive half and so must not happen inside the serialised callback; the keeper then publishes the chunk tuple and the new size together.
Two agents growing one shared memory used to each build a tuple from its own view and publish both cells independently, so one could publish a size backed by the other's shorter tuple. A single compare-and-exchange cannot fix that, because there are two cells to move.
A stable identity for this memory, for keying the wait queue.
Two handles on the same shared memory must agree, and a private memory must not collide with anyone. The registry identity is both: minted once per memory and copied into every handle on it.
-spec init(mem(), non_neg_integer(), binary(), non_neg_integer(), non_neg_integer()) -> ok.
memory.init: copy from a passive data segment.
Whether a term is a memory handle.
Needed because an embedder hands over bare terms: a module importing a memory
may be given a table, a function or a number, and that has to come back as a
link error rather than as a function_clause from inside the runtime.
-spec limits(mem()) -> #limits{min :: non_neg_integer(), max :: undefined | non_neg_integer(), shared :: boolean(), index_type :: i32 | i64}.
-spec load(mem(), non_neg_integer(), 1..8) -> non_neg_integer().
Load Nbytes at Addr as an unsigned little-endian integer.
Bounds are checked against the whole access, and the addition is done in Erlang's arbitrary-precision integers, so an offset near 2^32 cannot wrap around into a valid address the way it would in C.
-spec load_bytes(mem(), non_neg_integer(), non_neg_integer()) -> binary().
Read a byte range as a binary, for host functions and WASI.
Costs roughly a nanosecond per byte because the bytes have to be assembled from atomic words. This is the operation that would most benefit from the optional native backend, where it becomes a single memcpy.
-spec mask(pos_integer()) -> non_neg_integer().
The all-ones mask for an access of N bytes.
Exported because wasm_core needs it at generation time to build the same
mask into compiled code, and one table of widths is the point: see
wasm_exec:load_spec/1, which is read the same way and for the same reason.
-spec new(non_neg_integer() | #limits{min :: non_neg_integer(), max :: undefined | non_neg_integer(), shared :: boolean(), index_type :: i32 | i64}) -> {ok, mem()} | {error, term()}.
A standalone memory, held by the calling process.
Observable, because a standalone memory exists to be handed to instances: two of them importing it must see each other grow it, and a handle is an immutable record that cannot learn a new size on its own. Only a memory an instance defined and neither imports nor exports keeps its size in the handle, which is the case the fast path was measured for.
-spec new(non_neg_integer(), undefined | non_neg_integer()) -> {ok, mem()} | {error, term()}.
-spec release(mem(), wasm_keeper:token()) -> ok.
Remove a named holder, whatever this handle's own token is.
-spec resource(mem()) -> wasm_keeper:resource().
This memory's registry identity.
-spec size_bytes(mem()) -> non_neg_integer().
-spec size_pages(mem()) -> non_neg_integer().
-spec store(mem(), non_neg_integer(), 1..8, integer()) -> ok.
-spec store_bytes(mem(), non_neg_integer(), binary()) -> ok.
Whole-memory snapshot. Diagnostics and tests only: it materialises the entire memory as a binary.