feat: implement PartialEq/Eq/Hash for Func - #14128
Conversation
Subscribe to Label ActionDetailsThis 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:
To subscribe or unsubscribe from this label, edit the |
|
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? |
12199ef to
7fe093a
Compare
|
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. |
|
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. |
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 |
7fe093a to
0cc8442
Compare
|
Fair point, and I think it's addressable without needing Both an instance's imported-function table and its defined-function funcref table are contiguous, fixed-stride arrays at statically known
That reverse table only depends on compiled module metadata, not on any particular Added a regression test ( Also took the opportunity to rebase cleanly on main per @pchickey's comment above. |
|
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 |
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.
0cc8442 to
50650fa
Compare
|
That's a fair concern — coupling to
With that, our debugger can build the Added a regression test ( Much smaller diff now — just the two trait impls and a test. |
cfallin
left a comment
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.
| // 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<()> { |
There was a problem hiding this comment.
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?
- 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.
|
Done: title renamed, doc comment trimmed, test moved to One flag: |
|
Hmm, that's actually somewhat surprising API behavior, IMHO -- equality (and hashing) should hold when a I think this means we need to take a |
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.
|
Agreed, and that tracks with what I found — pushed Both take a store borrow and dereference into the 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 |
|
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 |
| // `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) |
There was a problem hiding this comment.
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.
|
Checked That's two problems for using it alone as the key:
Also fixed the regression test along the way: it wrapped two different |
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
left a comment
There was a problem hiding this comment.
Thanks -- just one nit then I'm happy to merge.
| 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. |
There was a problem hiding this comment.
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.
|
Done. |
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.