Skip to content

feat: implement PartialEq/Eq/Hash for Func - #14128

Queued
smarcd wants to merge 6 commits into
bytecodealliance:mainfrom
smarcd:codex/debug-function-index
Queued

feat: implement PartialEq/Eq/Hash for Func#14128
smarcd wants to merge 6 commits into
bytecodealliance:mainfrom
smarcd:codex/debug-function-index

Conversation

@smarcd

@smarcd smarcd commented Aug 12, 2026

Copy link
Copy Markdown

Adds a host-only inverse to Instance::debug_function for debugging tools that need to serialize a same-instance funcref as a Wasm function index. The lookup never exposes VM pointers and returns None when guest debugging is disabled or the function is not part of the instance.\n\nTests cover private functions, imports, an unrelated host function, and disabled guest debugging.

@smarcd
smarcd requested review from a team as code owners August 12, 2026 17:14
@smarcd
smarcd requested review from alexcrichton and removed request for a team August 12, 2026 17:14
@github-actions github-actions Bot added cranelift Issues related to the Cranelift code generator cranelift:meta Everything related to the meta-language. cranelift:module isle Related to the ISLE domain-specific language wasmtime:c-api Issues pertaining to the C API. wasmtime:docs Issues related to Wasmtime's documentation labels Aug 12, 2026
@github-actions

Copy link
Copy Markdown

Subscribe to Label Action

cc @cfallin, @fitzgen

Details This issue or pull request has been labeled: "cranelift", "cranelift:meta", "cranelift:module", "isle", "wasmtime:c-api", "wasmtime:docs"

Thus the following users have been cc'd because of the following labels:

  • cfallin: isle
  • fitzgen: isle

To subscribe or unsubscribe from this label, edit the .github/subscribe-to-label.json configuration file.

Learn more.

@pchickey

Copy link
Copy Markdown
Contributor

It looks like a bunch of commits from the 47 release branch got included in your PR branch for some reason - can you please rebase this cleanly on main?

@smarcd
smarcd force-pushed the codex/debug-function-index branch from 12199ef to 7fe093a Compare August 13, 2026 05:43
@github-actions github-actions Bot added the wasmtime:api Related to the API of the `wasmtime` crate itself label Aug 13, 2026
@alexcrichton

Copy link
Copy Markdown
Member

Could you detail your use case a bit more here? This is a pretty powerful debugging capability which also sort of inherently can't be efficient (e.g. the linear search here) and may also not hold up in future possible refactorings. Given the cost of supporting such an API I'd like to better understand the intended use case.

@smarcd

smarcd commented Aug 13, 2026

Copy link
Copy Markdown
Author

This is for a host-side debugging workflow. The debugger captures an instance’s private mutable state at a breakpoint/checkpoint, then materializes that state into an isolated Store so it can inspect or continue the snapshot without mutating the original execution.

This is deliberately not production runtime functionality. The path is only enabled with guest debugging, runs at debug snapshot boundaries rather than during normal execution, and is allowed to trade efficiency for a small, well-contained API.

The specific need for debug_function_index is that function references are store-local. To restore a captured table/global funcref in the isolated debugging store, the debugger needs to serialize the same-module function identity as an index and resolve it through debug_function in the destination instance.

@cfallin

cfallin commented Aug 13, 2026

Copy link
Copy Markdown
Member

The specific need for debug_function_index is that function references are store-local. To restore a captured table/global funcref in the isolated debugging store, the debugger needs to serialize the same-module function identity as an index and resolve it through debug_function in the destination instance.

But given that the implementation here is linear in the number of functions, your whole-store snapshot is going to run in quadratic time overall, which does not seem workable for anything semi-large. I'll second Alex's point that "linear search for this function" is not something we want to support.

I could see a Func::eq implementation making sense (because the primitive is harder to argue against -- it may be independently useful); if we also had Func::hash, then you could build a hashtable of funcrefs to defining instance and index within that instance in a single linear pass, then rename through that hashtable -- asymptotically better. I am not sure if I'm missing anything that would prevent us from providing Func::eq though (@alexcrichton ?).

@smarcd
smarcd force-pushed the codex/debug-function-index branch from 7fe093a to 0cc8442 Compare August 13, 2026 15:47
@smarcd

smarcd commented Aug 13, 2026

Copy link
Copy Markdown
Author

Fair point, and I think it's addressable without needing Func::eq/Func::hash — the actual complaint is the O(n) work per lookup (and thus the O(n²) whole-store cost), not the shape of the API. I pushed a rewrite that makes debug_function_index itself O(1) instead of scanning every function.

Both an instance's imported-function table and its defined-function funcref table are contiguous, fixed-stride arrays at statically known VMContext offsets, so a VMFuncRef pointer's position in either array is computable directly via pointer arithmetic instead of a linear scan:

  • Imported functions are indexed by FuncIndex directly, so that's a direct offset computation.
  • Defined ("escaped") functions are indexed by a compact FuncRefIndex. That index is not assigned in FuncIndex order (slots are handed out in the order functions are discovered to escape during translation — e.g. export declaration order — not declaration order), so resolving it back to a FuncIndex needs an explicit reverse table rather than arithmetic or a binary search.

That reverse table only depends on compiled module metadata, not on any particular Instance, so it's built once, lazily, and cached on Module — shared by every Instance and every debug snapshot of that module, rather than rebuilt per lookup or per instance. So a whole-store snapshot doing this once per captured funcref is now O(n) total (amortized), not O(n²).

Added a regression test (debug_function_index_with_non_monotonic_escape_order) that deliberately scrambles escape order via a table elem segment, to pin down that the reverse table is actually used correctly rather than assuming escape order tracks function-index order.

Also took the opportunity to rebase cleanly on main per @pchickey's comment above.

@cfallin

cfallin commented Aug 13, 2026

Copy link
Copy Markdown
Member

I think that is still the sort of complexity that we would rather not take on if we don't have to: it makes the code more entangled (we now have a dependency on the vmctx layout; if the scheme ever needs to change we are now more restricted because we need to provide this property), and it's just a lot of delicate logic.

For reference, the "debug" variants of accessors have geneally been simple O(1) holes in the encapsulation, where under-the-hood Wasmtime already has the appropriate private accessors. This is a whole lot of new functionality instead for a niche use-case.

Is there a reason that Func::eq / Func::hash and then an external implementation of your snapshot/clone algorithm couldn't work?

Per review on the original debug_function_index approach: rather than
adding a new Instance lookup coupled to VMContext's internal layout
(imported-function array / func_refs array offsets, FuncRefIndex
assignment order, etc.), expose plain identity equality and hashing on
Func instead.

Func now implements PartialEq/Eq/Hash as pointer-identity equality (same
store, same underlying VMFuncRef). This lets a caller build a Func -> id
map itself, e.g. by walking an instance's function index space once with
the existing Instance::debug_function and inserting into a HashMap, which
is exactly what a debug-snapshot tool needs to invert a captured funcref
back to a serializable index. This keeps wasmtime's own surface small and
avoids depending on unstable internal invariants of the funcref layout.

Adds a regression test that walks a module whose functions are placed
into a table out of index order (so FuncRefIndex assignment during
translation doesn't track FuncIndex order), builds a Func -> index map via
debug_function + the new Eq/Hash impls, and confirms every function
round-trips through it.
@smarcd
smarcd force-pushed the codex/debug-function-index branch from 0cc8442 to 50650fa Compare August 13, 2026 16:09
@smarcd

smarcd commented Aug 13, 2026

Copy link
Copy Markdown
Author

That's a fair concern — coupling to VMContext's internal layout is more entanglement than this is worth. I dropped debug_function_index and pushed Func::eq/Func::hash instead, per your suggestion.

Func now implements PartialEq/Eq/Hash as pointer-identity equality (same store, same underlying VMFuncRef). Comparing/hashing only reads the StoreId and the raw pointer bits — no dereference, no dependency on VMContext offsets, no assumptions about FuncRefIndex assignment order.

With that, our debugger can build the Func -> index map itself: walk the instance's function index space once with the existing Instance::debug_function, insert each (Func, index) pair into a HashMap, and invert a captured funcref through that map at snapshot time. Same result as debug_function_index, but the O(n) work (and any layout assumptions) live in our code instead of wasmtime's.

Added a regression test (debug_function_identity_round_trips_through_a_caller_built_map) that builds exactly that map over a module whose functions are placed into a table out of index order, to make sure the identity is real pointer/store identity and not something that only happens to work when escape order matches function-index order.

Much smaller diff now — just the two trait impls and a test.

@cfallin cfallin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- a few comments below.

Please feel free to change the title of this PR as well -- you're really adding Eq and Hash to Func, rather than adding any debug-specific mechanisms at all.

Comment thread crates/wasmtime/src/runtime/func.rs Outdated
// a `Func` came from within its instance) without wasmtime needing to expose
// such an inverse lookup itself: collect `(Func, id)` pairs by walking the
// forward direction once, and use `Func`'s `Eq`/`Hash` impls to build a
// `HashMap` from that.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for this narrative paragraph -- we generally don't have comments describing very specific single use-cases like this, nor a comparison to an alternative that used to (or in this case, never did) exist in the code.

The bit about shallow equality is also self-evident and probably not needed (a reasonable user would not expect f1 == f2 to, say, prove equivalence of two different algorithms).

The paragraph below about only comparing raw pointer bits is fine (a pseudo "safety comment" even though there's no literal unsafe block) I think.

Comment thread tests/all/debug.rs Outdated
// index order, so a naive "assume escape order tracks function index order"
// approach would not happen to work by coincidence.
#[test]
fn debug_function_identity_round_trips_through_a_caller_built_map() -> wasmtime::Result<()> {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This unit test is fairly excessive -- again the narrative comment is unnecessary, and the test itself is testing the actual Wasmtime functionality in a fairly baroque way. All we really need to test is that f1 == f2 works when functions are fetched in different ways and/or carried through imports/exports between modules in the store, etc (and likewise for the hash value). Can you do that instead, and put it alongside other tests of the Func-related APIs rather than in debug?

@smarcd smarcd changed the title feat: add debug function index lookup feat: implement PartialEq/Eq/Hash for Func Aug 13, 2026
- Drop the narrative/use-case paragraph on the Func PartialEq/Eq/Hash
  impls; keep only the note that comparison only reads raw pointer bits
  without dereferencing.
- Replace the debug.rs-specific test (which built a Func -> index map over
  a deliberately-scrambled table) with a smaller test living alongside the
  other Func tests, checking that the same function fetched multiple ways
  (by export name, via exports(), through a table) compares equal and
  hashes equal, and that a different function does not.
@smarcd

smarcd commented Aug 13, 2026

Copy link
Copy Markdown
Author

Done: title renamed, doc comment trimmed, test moved to tests/all/func.rs and simplified to fetch-multiple-ways equality (name, exports(), table).

One flag: Eq doesn't hold across an import/export boundary between two instances (each instance copies its own VMFunctionImport record, so the pointer differs even though it calls the same code). Left the test scoped to one instance. Let me know if cross-instance identity was actually expected (can compare through to the underlying code/vmctx pointer instead if so).

@cfallin

cfallin commented Aug 13, 2026

Copy link
Copy Markdown
Member

Hmm, that's actually somewhat surprising API behavior, IMHO -- equality (and hashing) should hold when a Func refers to the same function within a store, regardless how it's reached.

I think this means we need to take a Store borrow so we can safely reach inside the raw pointers. That means we can't implement the Eq and Hash traits literally -- we need to provide separate methods on the Func for this. I'd imagine something like Func::is_same(&store, &f1, &f2) or similar. cc @alexcrichton for thoughts on this as well since it's a fairly conspicuous API surface...

cfallin pointed out that plain PartialEq/Eq/Hash on Func was surprising:
equality didn't hold when the same function was reached through a second
instance (imported, then re-exported), because each instance holds its
own copy of an imported function's VMFunctionImport record at a distinct
address, even though it calls the same underlying code.

Replace those trait impls with Func::is_same(store, a, b) and
Func::identity_key(store), which take a store borrow so they can safely
dereference into the VMFuncRef's own identity (its vmctx and calling
convention entry points) instead of comparing the wrapper pointer's own
address. That identity is preserved across the import/export copy
boundary, so these correctly recognize the same function regardless of
how it was reached.

Extends the regression test to cover exactly that case: a function passed
as an import to a second instance and re-exported from there.
@smarcd

smarcd commented Aug 13, 2026

Copy link
Copy Markdown
Author

Agreed, and that tracks with what I found — pushed Func::is_same(store, a, b) and Func::identity_key(store) in place of the trait impls.

Both take a store borrow and dereference into the VMFuncRef's own identity (vmctx, wasm_call, array_call) rather than comparing the wrapper pointer's own address, so they see through the import/export copy: the underlying entry points and vmctx are identical across instances even though each instance holds its own VMFunctionImport record at a different address. identity_key returns an opaque Hash + Eq value for HashMap use, consistent with is_same.

Extended the test to cover the case that broke last time: a function imported into a second instance and re-exported from there now correctly reports is_same and equal identity_key.

@alexcrichton

Copy link
Copy Markdown
Member

This seems reasonable to me, yeah, thanks! I'd probably go ahead and throw the type index into the hashed key as well to be safe, although it's probably not strictly necessary either

@cfallin cfallin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @smarcd -- another nit below, and see Alex's point. If you can address those and get the CI green, I'm happy to approve and merge.

Comment thread crates/wasmtime/src/runtime/func.rs Outdated
// `store`, which we're borrowing for the duration of this call, so
// dereferencing it here is sound.
let func_ref = unsafe { self.vm_func_ref(store).as_ref() };
(func_ref.vmctx, func_ref.wasm_call, func_ref.array_call)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably sufficient to return just the wasm_call field here: each exported function will have the two entry points but either one will uniquely identify the function.

cfallin suggested shrinking the identity key to just wasm_call, and Alex
suggested folding in type_index for extra safety. Checked wasm_call
empirically first: for a host function created via Func::wrap, wasm_call
starts out None and is filled in lazily, in place, the first time that
Func is paired with a module that has a matching trampoline (confirmed
via a throwaway probe test). That means:

- Two distinct never-imported host functions both read as wasm_call=None,
  so a key built from wasm_call alone would collide them.
- Even for a single Func, the key would change over the store's lifetime
  as wasm_call flips from None to Some, which breaks HashMap usability
  outright rather than merely causing an occasional collision.

array_call has neither problem (always present, stable), so the key is
now (vmctx, array_call, type_index) instead of (vmctx, wasm_call,
array_call).

Also fixes the accompanying regression test, which previously wrapped two
*different* closures (`|| {}` twice) to check that distinct host
functions aren't `is_same` -- but two `|| {}` expressions are two
distinct Rust types, so the assertion was passing for the wrong reason
(different array_call, not different vmctx). Changed it to wrap the same
closure value twice, so array_call is shared and only vmctx can
discriminate, plus a regression test asserting identity_key is stable
across the wasm_call None -> Some transition.
@smarcd

smarcd commented Aug 14, 2026

Copy link
Copy Markdown
Author

Checked wasm_call before committing to it: it's None for a host Func::wrap until that Func is later paired with a module that has a matching trampoline, and it's filled in in place at that point (confirmed with a throwaway probe: None before instantiation, Some(..) after).

That's two problems for using it alone as the key:

  • Two never-imported host functions both read wasm_call = None, so they'd collide.
  • Even a single Func's key would change over the store's lifetime as it flips None → Some, which breaks HashMap use outright, not just an occasional collision.

array_call doesn't have either issue (always present, never mutates), so I kept (vmctx, array_call) and folded in type_index per Alex - dropped wasm_call instead of keeping only it.

Also fixed the regression test along the way: it wrapped two different || {} closures to check that distinct host functions aren't is_same, but two separate closure expressions are two distinct Rust types, so that assertion happened to pass for the wrong reason (different array_call, not different vmctx). Now wraps the same closure value twice, so array_call is shared and only vmctx discriminates, plus a new test asserting identity_key is stable across the wasm_call transition.

VMArrayCallFunction is only re-exported from crate::runtime::vm under
the component-model feature (crates/wasmtime/src/runtime/vm.rs), but
wasmtime-c-api builds wasmtime with just ['runtime', 'std']. Naming the
type directly in identity_key_raw's signature broke that build.

VmPtr::addr() doesn't require naming the pointee type at the call site,
so use that (a NonZero<usize>) instead of the typed VmPtr<T> for the
array_call field -- same identity, no cfg-gated import.

@cfallin cfallin left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks -- just one nit then I'm happy to merge.

Comment thread crates/wasmtime/src/runtime/func.rs Outdated
let func_ref = unsafe { self.vm_func_ref(store).as_ref() };
// `array_call`'s type (`VMArrayCallFunction`) is only re-exported
// under the `component-model` feature, so use its address rather
// than naming the type here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need for the narrative comment here (which bits are exported, etc) -- that's also something we can easily change if needed.

Better to explain why we choose these keys. We include vmctx to disambiguate statically-same functions from different instances of its module; we include the array-call pointer because it's always present and there is one per separate function; and we include the type-index as insurance to ensure that we disambiguate functions based on their Wasm-level signatures.

@smarcd

smarcd commented Aug 14, 2026

Copy link
Copy Markdown
Author

Done.

@cfallin
cfallin enabled auto-merge August 14, 2026 18:38
@cfallin
cfallin added this pull request to the merge queue Aug 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cranelift:meta Everything related to the meta-language. cranelift:module cranelift Issues related to the Cranelift code generator isle Related to the ISLE domain-specific language wasmtime:api Related to the API of the `wasmtime` crate itself wasmtime:c-api Issues pertaining to the C API. wasmtime:docs Issues related to Wasmtime's documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants