Inventory
What is inside a module and what it requires, read from the file without running it.
peakmark reads the inventory of every module it checks. It comes from the bytes of the artifact
alone: nothing is instantiated, no glue runs, and Node is not involved. check --details prints it
and check --json carries it under inventory for each module. The inventory alone never fails a check.
accept keeps a compact part of it in the baseline, check reports what differs from it, and only
an optional policy can turn the inventory into an error.
The artifact is validated first. A module that is invalid, uses a feature outside WebAssembly 3.0, or is a WebAssembly component stops the check with exit code 2 before an inventory exists; see Validation comes first. Components are not analysed recursively.
What it lists
| Part | JSON key | Content |
|---|---|---|
| Artifact | artifact |
kind (core_module) and the binary version |
| Counts | counts |
types, imported and defined functions, imports, exports, data and element segments |
| Imports, exports | imports, exports |
module and name, and the type: function signature, table, memory, global or tag |
| Entities | memories, tables, globals, tags |
every entity in index order, imported ones first, with imported_from, exported_as and its type |
| Limits | type of a memory |
minimum and maximum in pages (null: unbounded), shared, memory64, page_size in bytes |
| Features | features |
what the binary needs, see below |
| Sections | sections |
every section in file order with header and payload bytes |
| Custom sections | custom_sections |
name, kind and bytes |
| Composition | composition |
the bytes of the file by purpose, see Where the bytes are |
| Functions | functions |
count, body_bytes and the largest bodies with their raw size, name and exports |
| Data | data |
bytes and the largest data segments by size and location |
| Reach | reach |
why the largest functions are in the module, see Why a function is in the module |
| Names | names |
absent, present (module name, named functions) or unreadable |
| Metadata | producers, target_features |
what the toolchain wrote |
| Coverage | coverage |
what the inventory could not decide |
Function signatures list value types: (i32, i32) -> i32. Reference types keep their text form,
for example funcref or (ref null 3). A memory without a maximum is reported as unbounded; whether
that is acceptable is a decision for a policy, not a finding.
Names
A module with a name section reports how many of its functions are named. A stripped module says
absent: its functions are unnamed and the output states that instead of leaving the gap silent.
Imports and exports always have names, because the format requires them; they are listed whether the
module is stripped or not.
Section bytes
sections accounts for every byte of the file. The 8-byte preamble (magic number and version) is
PREAMBLE_BYTES in the library; each section adds a header (the section id and the LEB128 size
that precede its payload) and a payload. The preamble plus all headers and payloads equal the file
size. The payload of a custom section includes its own name. Headers are overhead: they are listed
apart so that an attribution of size can leave them out or show them.
Custom sections
Custom sections are classified by name: name (names), .debug_*, external_debug_info and
sourceMappingURL (debug), producers, target_features, and everything else (other, not
interpreted). Their presence is reported; it is not a finding. A name section or debug information
is not treated as waste or as a secret: whether to ship them is up to you.
producers and target_features are read when present. They are the toolchain’s claims. They are
listed next to the derived features and never turned into requirements.
Where the bytes are
composition divides the file into parts that add up to its size, exactly:
| Part | Bytes |
|---|---|
code |
the code section: function bodies, one size prefix each, and the count |
data |
the data section |
names |
the name section |
debug |
.debug_*, external_debug_info, sourceMappingURL |
custom |
every other custom section, producers and target_features included |
structure |
types, imports, functions, tables, memories, globals, exports, start, elements, data count, tags |
overhead |
the 8-byte preamble and the id and size field in front of every section |
functions lists the largest function bodies (20, biggest first). A size is shallow and raw: the
bytes of that body (locals, instructions, end) without the size prefix and without anything it
calls. A function is named by its name section entry; a stripped module has none, so its
exported_as names and its index in this file (func[12], imports first) identify it. The index
means nothing in another build. Raw bytes are not what a function costs after gzip or Brotli:
compression is a property of the whole resource, so the sizes of functions do not add up to a
compressed size and are never presented as a saving. functions.bodies in the library holds every
body for later parts; it is not serialized.
data lists the largest data segments with their size, the memory they are copied to and the
offset if it is a constant (null for a passive segment). The contents are never printed, and
peakmark does not say which asset a segment comes from.
On the first run this is all there is: the composition and the largest bodies and segments, shown
by check --details and in --json, with no delta and no statement about history.
Features are derived
features lists the core features the binary needs. Each entry has a feature, the number of
declarations that need it and the number of instructions. It is derived from the module, not
taken from advertised metadata:
- Declarations: a shared memory, a 64-bit memory or table, more than one memory, a tag, a
v128or GC reference type in a signature, a struct or array type, subtyping, a passive data or element segment, a second table or one that is notfuncref, a mutable global that is imported or exported, a function type with several results, imports fromwasm:js-string. - Instructions: every instruction in function bodies and constant expressions is classified by the proposal it comes from, using the list wasmparser keeps. SIMD, relaxed SIMD, atomics, bulk memory, reference types, function references, GC, exceptions (both the current and the legacy form), tail calls, sign extension, saturating float-to-int, wide arithmetic, memory control, stack switching and the other proposals have their own entry. Arithmetic in a constant expression is reported as extended constants; a block with a function type as multi-value.
Features appear under snake-case names such as simd, shared_memory, atomics, bulk_memory,
reference_types, exception_handling, tail_call and gc; the complete list is Feature in the
library.
This is a heuristic about the bytes. It can say that the module contains something that needs a feature; it cannot say that the code runs or that an engine supports it.
What is not detected
coverage states this, and check --details repeats it as not detectable:
- what the host must provide beyond the imports, for example cross-origin isolation for shared
memory in a browser (
host_environment); - whether the code that needs a feature is ever run (
reachability): a feature in dead code is listed; - the toolchain’s own claims in
target_featuresandproducers(toolchain_claims); - the meaning of custom sections other than names, debug, producers and target features
(
custom_section_semantics); - which engines and versions support a feature (
engine_support).
Two things lower the coverage further, and are reported as such, never as “not used”:
unclassified_instructions: instructions of a proposal this version of peakmark has no feature for, counted by proposal;unreadable_custom_sections: aname,producersortarget_featuressection that is there and cannot be read.
complete is true only when both are empty; the limits above apply anyway. A feature that
validation does not accept does not get this far: it stops the check with exit code 2 as
unsupported.
Against the baseline
peakmark accept stores a snapshot of the inventory with the entry of the module (inventory in
the baseline): the imports and exports with
their types, the memories and tables the module keeps to itself, and the names of the derived
features. It is sorted, so reordering a module changes nothing, and an entry accepted before it was
kept has none: check notes that (baseline/inventory-missing) and compares nothing, never an
empty inventory.
check compares the snapshot with the module as it is now and reports each difference as a
warning, with exit code 0, until the baseline is accepted again:
| Rule | Meaning |
|---|---|
baseline/interface-added |
an import, export, internal memory or table that was not accepted |
baseline/interface-removed |
an accepted one that is gone |
baseline/interface-changed |
the same declaration with another signature or other limits; the finding shows both |
baseline/feature-added |
the binary needs a feature it did not need when accepted |
baseline/feature-removed |
it no longer needs one |
baseline/size-sections |
the file changed between its parts (note) |
baseline/size-functions |
function bodies changed, with the confidence of the match (note) |
baseline/sizes-missing |
the accepted entry has no section or function sizes yet (note) |
Identity
A declaration is identified by import module (imports only), name and kind, never by the name
alone: env.f as a function and env.f as a memory are two imports, and a.f and b.f are not
the same import. Under an identity, the type is what can change. A signature change is one
interface-changed finding, not a removal and an addition; a change of kind or module is both. A
module that imports the same module, name and kind several times with different types is compared
as a set of types.
A memory or table that is imported or exported is an import or an export, with its limits as the
type, so a changed maximum is reported once. Only those the module keeps to itself are listed
apart, named by their position (#0, #1) among them. Limits are written min..max in pages for
a memory (1.. has no maximum, then shared, 64-bit and a non-default page-size) and
funcref 1..16 for a table.
Attributing a size change
When the file changed size or moved bytes between its parts, check reports a note
baseline/size-sections with the change of every part that differs (code, data, names, debug,
other custom, declarations, headers and preamble). The deltas add up to the change of the file
size. Its action names the first thing to look at, grounded in the part that grew most: a changed
function, the largest data segment and where it is, or, for name or debug information, that the
deployment artifact can be built without it. peakmark does not modify files and does not claim
what leaving something out would save.
baseline/size-functions explains the code change per function, but only as far as the match is
trustworthy. Two builds are matched by name (name section) or, for an unnamed function, by export
name, and only if that key occurs once. The index is never used: after stripping or reordering it
points at unrelated functions. The snapshot keeps the count, the sum and the 20 largest bodies, so
it stays small however large the module is.
| Field | Meaning |
|---|---|
changes |
functions found through their key: changed (both sizes), added, removed |
confidence |
high: every listed function has a key; partial: some do not; none: none does (a stripped module) |
unmatched_accepted, unmatched_now |
functions without a usable key, by local ID of their own build and with their size; they are not compared |
body_delta, attributed, unaccounted |
the change of all bodies, the part the changes explain, and the rest, which no listed function explains |
status |
exact if confidence is high, both lists name every function and unaccounted is 0; otherwise estimated |
If the module has more than 20 functions, a key only on one side may mean the function rose into
or fell out of the list instead of being new or gone; then it is entered_largest or
left_largest, and its size is not counted as attributed. Sizes are raw body bytes, not gzip or
Brotli savings. Mapping bytes to source is separate, later work. Why a large function is in the module is
answered by the reference graph.
Why a function is in the module
check --details explains the largest function bodies and --json carries the same under
reach: a short representative path from an export or the start function to the function, and its
direct callers. It comes from a reference graph of the core module, read from the bytes like the
rest of the inventory. It is an explanation of how a function is referred to, not a statement of
what removing it would save, and there is no separate command for it.
why dispatched (22 B) is in the module: reached only by indirect dispatch, which assumes every table entry is callable: export "run": entry -indirect-> table -element-> dispatched; 1 direct caller: table
why helper (14 B) is in the module: reached from export "run": entry -> helper; 1 direct caller: entry
The graph
Nodes are the functions plus one pseudo node, the table, which stands for every function reference
that does not sit in code: the entries of element segments and the ref.func of global and table
initializers.
| Edge | Meaning |
|---|---|
call |
call or return_call to a function |
reference |
ref.func in a function body |
dispatch |
the function contains call_indirect, call_ref, a table.* instruction or a global.get of a reference-typed global, so it may use any function reference |
elements |
the table to every function in an element segment or an initializer |
The roots are the exported functions and the start function. The table is a root as well if the
module imports or exports a table, or exports a global that holds a reference. Data segments add no
edge: they hold bytes, and a function pointer in linear memory is a table index that only
call_indirect can use. --json lists these assumptions under reach.assumptions.
What an answer says
reachability |
Meaning |
|---|---|
direct |
a path of calls and references exists from a root; the shortest is shown, ties go to the first export, then the start function, then the lower function index |
via_table |
every path goes through the table: the function is an element or initializer entry, and a reachable function can dispatch. The target of an indirect call is not known from the bytes, so every entry is assumed callable |
no_static_root |
the graph has no root for it |
unknown |
there is no path and the module contains operators the graph does not model (stack switching); reach.coverage says how many |
no_static_root is not “dead code” and not a saving. peakmark never reports a function as unused
or removable, and never gives a retained size: shared dependencies and a table that holds most of the
module would turn such a number into a claim it cannot support. Unknown targets are marked
via_table or unknown, never left out. A path never repeats a function, so recursion shows as a
caller (a function that calls itself is its own direct caller) and not as a loop.
callers lists the direct callers, at most five, with their total count; a caller is a function
(by call or reference) or the table. A function is shown by its name, its export or its index
in this file (func[12]); the index means nothing in another build, so compare paths by name.
What to expect
Modules built with wasm-bindgen dispatch a lot through the table. In two such modules (440 kB and
1.4 MB, 1,033 and 1,998 functions), 15 and 14 of the 20 largest functions were reachable only
through the table, and in the second the table is exported, so the path is just the table and the
entry. For those the answer is the hint, not a long path; the direct callers and the
direct paths of the rest (for example export "wasmchart_redo" -> func[89]) are what goes beyond the
size ranking.
Cost and limits
The graph is one pass over the code section plus two breadth-first searches. On a synthetic module
of 1.7 MB with 20 000 functions it takes about 7 ms in a release build. It runs for every check (about 1 to 2 percent of the measurement, see Explanations);
the library API does not need the rest of the check: peakmark::reach::Reach::from_bytes and
Reach::explain are documented with cargo doc.
Not modelled: function pointers a host stores outside the module, what an imported function does
with a reference it is given beyond the ref.func edge, and stack switching. The path of a function
is not stored in the baseline.
What a host-interface change means
A new import means the module asks the host for one more binding; a removed export means callers
lose one. That is all the inventory states. An import name does not show what the host does with it:
a fetch import may be wired to anything or to nothing, and the filesystem or network access of a
WASI module depends on the host implementation and the grants it is given. An empty import list is
not a safety statement either, and neither is the absence of a violation. peakmark makes no
security statement, and compatibility with a runtime or browser is unknown: it is only tied to what
the file contains, never to a list of engines.
Policy
A policy turns parts of the inventory into a gate. It is opt-in per module, in
[module.policy]:
[module.policy]
require_exports = [{ name = "add", signature = "(i32, i32) -> i32" }]
allow_imports = [{ module = "env", name = "log", signature = "(i32) -> ()" }]
allow_features = ["bulk_memory"]
A broken rule is an error: exit code 1, and accept refuses the module (exit code 2) until the
module or the policy is changed. Accepting a baseline does not waive a policy, just as it does not
waive an absolute size budget. Each finding names the declaration and the action to take, and
--json carries both under violations.
| Rule | Broken when |
|---|---|
policy/export-missing |
a required export is not there |
policy/export-signature |
it is there as another signature, or is not a function; the finding shows what it is |
policy/import-not-allowed |
allow_imports is set and the import is not listed (a listed name from another module does not count) |
policy/import-signature |
the import is listed, but not with this type |
policy/feature-not-allowed |
allow_features is set and the binary needs another feature |
policy/feature-unverifiable |
allow_features is set and instructions of a proposal peakmark has no feature for exist (unclassified_instructions), so it cannot show that all features are allowed |
Strict means: what is not listed is refused, and what peakmark cannot classify is not assumed to be allowed. An absent key gates nothing; a present key, even an empty one, is a complete list. A policy is about the bytes of the file. It does not say that the host grants an allowed import anything, that a required export works, or that the module is secure.
JSON schema
The document has a schema_version, currently 1. It is deterministic: lists keep the order of
the module and features keep the order of Feature. Adding a field keeps the version; renaming or
removing one raises it. The library exposes the same data as peakmark::inventory::Inventory
(Inventory::from_bytes reads one from bytes), so other parts of peakmark and other programs use
the model and not the text.