wasm_module_cache (wasm v0.3.0)
View SourceCompiled modules, cached node-wide and keyed by content hash.
You reach this through wasm:load/1 and wasm:load_file/1. Decoding and
validating is the expensive part of the pipeline, about 20 ms for a 100 KB Rust
binary against 15 us to instantiate one, and a plugin host or a
request-per-instance service compiles a small stable set of modules and
instantiates them constantly. So the compile belongs behind a cache and the
instantiate does not.
Why persistent_term
A compiled module is immutable and read on every instantiation, which is
exactly what persistent_term is for: reads do not copy, however large the
term. ETS would copy the whole intermediate representation into the reading
process every time, and that IR is megabytes for a real module.
The cost is on the other side: every put and erase triggers a global scan.
That is fine here precisely because loading is rare and instantiating is not,
but it does mean you should not call load/1 in a loop, and it makes anyone who
can drive repeated load and unload cycles a denial of service. Hence the rate
limit and the resident cap below, enforced here rather than left to you to
remember.
Identity is the content hash
Load the same bytes from two places and you get the same handle and share one
compiled artefact, with no coordination. It also means either of you can call
unload/1 safely: the module stays resident until the last holder drops it.
Summary
Types
An opaque handle to a cached module.
Functions
Claim a resident module on another process's behalf.
Fetch a cached module. It reads without copying, so you can call it on every instantiation.
Decode, validate and cache a module, returning a handle.
Give back a claim made by claim_for/2.
Drop your claim. The module stays resident until the last holder goes.
Types
-type annotated() :: {Height :: non_neg_integer(), instr()} | {Height :: non_neg_integer(), Base :: non_neg_integer(), instr()}.
-type externtype() :: {func, typeidx()} | {table, #tabletype{limits :: #limits{min :: non_neg_integer(), max :: undefined | non_neg_integer(), shared :: boolean(), index_type :: i32 | i64}, elemtype :: reftype(), init :: undefined | [instr()]}} | {mem, #memtype{limits :: #limits{min :: non_neg_integer(), max :: undefined | non_neg_integer(), shared :: boolean(), index_type :: i32 | i64}}} | {global, #globaltype{valtype :: valtype(), mut :: mut()}} | {tag, #tagtype{type :: typeidx()}}.
-type funcidx() :: non_neg_integer().
-nominal handle() :: {wasm_module, binary()}.
An opaque handle to a cached module.
-type heaptype() :: func | extern | exn | any | eq | i31 | struct | array | nofunc | noextern | noexn | none | {type, typeidx()}.
-type memidx() :: non_neg_integer().
-type mut() :: const | var.
-type numtype() :: i32 | i64 | f32 | f64.
-type reftype() :: {ref, null | nonull, heaptype()}.
-type tableidx() :: non_neg_integer().
-type typeidx() :: non_neg_integer().
-type vectype() :: v128.
Functions
Claim a resident module on another process's behalf.
Claims are per process and a process that dies has its claims dropped for it, which is the right lifetime for a caller and the wrong one for anything that must outlive the caller. An initialized runtime snapshot is exactly that: it holds a module's layout, and without a claim of its own it would be restoring over a module evicted underneath it.
So the claim is made for a process whose life matches the thing that needs it. The owner is monitored as any other holder is, so a claim never outlives the process it was made for, and an owner that dies invalidates the image rather than pinning a module for the life of the node.
-spec get(handle()) -> {ok, #module{identity :: undefined | {sha256, binary()} | reference(), types :: [#subtype{final :: boolean(), supers :: [typeidx()], body :: #functype{params :: [valtype()], results :: [valtype()]} | #structtype{fields :: [#fieldtype{type :: valtype() | i8 | i16, mut :: mut()}]} | #arraytype{field :: #fieldtype{type :: valtype() | i8 | i16, mut :: mut()}}}], rec_groups :: [{non_neg_integer(), non_neg_integer()}], imports :: [#import{module :: binary(), name :: binary(), desc :: externtype()}], funcs :: [#func{type :: typeidx(), locals :: [valtype()], body :: [instr()] | {validated, [annotated()]}}], tables :: [#tabletype{limits :: #limits{min :: non_neg_integer(), max :: undefined | non_neg_integer(), shared :: boolean(), index_type :: i32 | i64}, elemtype :: reftype(), init :: undefined | [instr()]}], mems :: [#memtype{limits :: #limits{min :: non_neg_integer(), max :: undefined | non_neg_integer(), shared :: boolean(), index_type :: i32 | i64}}], tags :: [#tagtype{type :: typeidx()}], globals :: [#global{type :: #globaltype{valtype :: valtype(), mut :: mut()}, init :: [instr()]}], exports :: [#export{name :: binary(), desc :: {func | table | mem | global | tag, non_neg_integer()}}], start :: undefined | funcidx(), elems :: [#elem{type :: reftype(), init :: [[instr()]], mode :: passive | declarative | {active, tableidx(), [instr()]}}], datas :: [#data{init :: binary(), mode :: passive | {active, memidx(), [instr()]}}], data_count :: undefined | non_neg_integer(), customs :: [{binary(), binary()}]}} | {error, not_loaded}.
Fetch a cached module. It reads without copying, so you can call it on every instantiation.
Decode, validate and cache a module, returning a handle.
Load the same bytes twice and the second call is cheap: it finds the compiled artefact already resident and only bumps its holder count.
-spec resident() -> non_neg_integer().
-spec stats() -> map().
Give back a claim made by claim_for/2.
-spec unload(handle()) -> ok.
Drop your claim. The module stays resident until the last holder goes.
Claims are per process: this drops one held by the calling process, and a process that dies without calling it has its claims dropped for it.