Skip to content

[wasmparser] add atomic validator feature#2429

Open
keithw wants to merge 2 commits intobytecodealliance:mainfrom
keithw:atomic
Open

[wasmparser] add atomic validator feature#2429
keithw wants to merge 2 commits intobytecodealliance:mainfrom
keithw:atomic

Conversation

@keithw
Copy link
Contributor

@keithw keithw commented Jan 28, 2026

This PR adds an atomic feature to the validator, exposing a FuncValidator::atomic_op function that tries to validate an operator and leaves the validator unchanged if the operator is invalid.

The feature adds a "rollback log" to the OperatorValidator; the log keeps track of any information destroyed during validation of the operator, so that the validator can be rolled back to its initial state if the operator turns out to be invalid. I tried to make this basically performance-neutral for anybody who doesn't need this. The rollback log is only present if the atomic feature is enabled, and it's only used if atomic_op is being used (i.e. the visit functions and FuncValidator::op don't use it).

We're using this as part of a Wasm development/teaching environment where we'd like the validator to be in a predictable state after an invalid operator.

@keithw keithw requested a review from a team as a code owner January 28, 2026 18:44
@keithw keithw requested review from dicej and removed request for a team January 28, 2026 18:44
@alexcrichton
Copy link
Member

If performance is ok to compromise on a bit here, the Clone-ability of a FunctionValidator might be sufficient here because the validator could be cloned before the operation and then thrown away if validation succeeded. That'd likely have some quadratic performance in terms of stack depth, however, so I can see how that wouldn't be desirable for handling large functions.

@keithw
Copy link
Contributor Author

keithw commented Jan 30, 2026

Yeah, we are validating basically on every keystroke, so having to duplicate the operand/control/init stacks on every operator (valid or not) is probably too expensive for us.

@alexcrichton
Copy link
Member

Would you be up for doing some quick measurements with the scale of wasm files you're expecting to see what it's like? I'd naively expect keystrokes to be dozens-of-milliseconds apart which is probably more than enough time to clone the validator and such, so my hunch is that it's probably not that noticable but if you're dealing with clang.wasm it might still be noticable.

The reason I ask is that this is pretty invasive in the validator in terms of hooking into the critical points of dealing with all the internal validator state. It's not unreasonable per-se and I'd agree that a compile-time feature is the way to go here, but even with that this is something that may not be that easy to maintain over time. Given that I'd like to try to push on the clone-ability of the validator (which in contrast should be pretty easy to maintain) and see if that works. If it doesn't work though it doesn't work, and this seems reasonable enough.

FuncValidator::atomic_op validates an operator, leaving
the validator unchanged if the operator is invalid.
@keithw
Copy link
Contributor Author

keithw commented Feb 3, 2026

Sure, here's a (very) rough measurement... Let's say the largest file we'd plan to edit is around 100,000 lines. (The clang.wasm that used to be a w2c2 example is >10M lines.) The worst case is probably something like "a single function with 100,000 lines of i32.const 0". Running wasm-tools validate on an AMD Ryzen 7 PRO 4750U @ ~3.9 GHz:

  • As-is, this takes around 1-3 ms to validate and report the error at the end.

  • With an OperatorsReader and running FuncValidator::op on every Operator, this takes around 2-5 ms to validate and report the error.

  • With the atomic feature, using an OperatorsReader and running FuncValidator::atomic_op on every Operator, this takes around 3-7 ms to validate and report the error.

  • With an approach that clones the OperatorValidator on every operator, this is taking about 850-1000 ms.

(Edit to add: These numbers are for a release build running on native Linux x86-64; the real application is going to be running in Wasm in a browser which I think also makes us a little more concerned about possible performance gotchas.)

(Edit 2: made the numbers clearer and made the atomic benchmark actually use the new atomic_op function.)

Copy link
Member

@alexcrichton alexcrichton left a comment

Choose a reason for hiding this comment

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

Hm well yes to a certain extent I understand that cloning has quadratic behavior and that means it's quite feasible to construct a case that showcase the quadratic behavior and behaves poorly. I was instead curious about more real-world or examples-in-practice you might run into, for example do you expect that you'd be working with multi-thousand opcode functions?

Nevertheless I'll elaborate some more on my concerns about the code organization here as well. I've left a review about various parts below. They're all tractable to fix in my opinion without too too much refactoring, but this is along the lines of what I was trying to avoid by having a third system of a sort to track pushes/pops/etc to keep in sync with everything else.

Comment on lines 91 to 93
# A feature that allows validating an operator atomically, leaving the validator
# unchanged if the operator is invalid.
atomic = []
Copy link
Member

Choose a reason for hiding this comment

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

I might bikeshed the naming here a bit since "atomic" sort of sounds like a wasm proposal along the lines of "simd". Maybe rollback or try-op or something like that?

Copy link
Contributor Author

@keithw keithw Feb 5, 2026

Choose a reason for hiding this comment

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

Done (as try-op).

Comment on lines 125 to 136
// In a debug build, verify that `rollback` successfully returns the
// validator to its previous state after each (valid or invalid) operator.
#[cfg(all(debug_assertions, feature = "atomic"))]
{
let snapshot = self.validator.clone();
let op = reader.peek_operator(&self.visitor(reader.original_position()))?;
self.validator.begin_atomic_op();
let _ = self.op(reader.original_position(), &op);
self.validator.rollback();
self.validator.pop_push_log.clear();
assert!(self.validator == snapshot);
}
Copy link
Member

Choose a reason for hiding this comment

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

I'm a little worried about putting this here due to the quadradic behavior of clone/==. For the same reasons your tests are showing it's quite slow, this would drastically slow down development/testing when this feature is enabled.

That being said it's also a really good test to have. One option perhaps might be something like a custom #[cfg(foo)] specified in CI but otherwise not tested anywhere. That way CI would test this, where it's presumably not a massive slowdown, but external consumers wouldn't test it.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Happy to do that if you want -- do you want a custom cfg or is a custom feature okay? (I may need help on best practices for the former.)

Full disclosure, at least on my laptop the performance penalty of running this on cargo test seems to be in the noise. (E.g. cargo test vs. cargo test -F wasmparser/try-op.) Probably because the spec testsuite doesn't have many large functions so the cost of cloning is negligible compared with everything else, especially given that this only runs on a debug build and with the feature enabled. But if you think it's better avoided, happy to make a custom feature or cfg for it.

Comment on lines 219 to 231
/// Validates the next operator in a function, rolling back the validator
/// to its previous state if this is unsuccesful.
#[cfg(feature = "atomic")]
pub fn atomic_op(&mut self, offset: usize, operator: &Operator<'_>) -> Result<()> {
self.validator.begin_atomic_op();
let res = self.op(offset, operator);
if res.is_ok() {
self.validator.commit();
} else {
self.validator.rollback();
}
res
}
Copy link
Member

Choose a reason for hiding this comment

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

Bikeshedding this a bit, along with the atomic feature from above -- would it perhaps make sense to expose the transaction logic here rather than the singular "just do the one op" method? I'm a bit worried for the same reasons about "atomic_op" sounding like a wasm proposal thing, and this otherwise being more-or-less a duplicate of the op method. By exposing the transaction internals that'd make things a bit more clear here in terms of API contracts and what things are doing (and more composable with more than one op if desired).

Additionally, could you update the documentation of op to state that on failure the internal state of the validator is intentionally not defined in the sense that it can't be reused?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Unfortunately I don't think we can expose the begin/rollback methods externally because the rollback log is only rich enough to support rolling back one operator. To roll back arbitrarily, I think the log would have to keep track of the order of the various events (e.g. adding a control frame, pushing some operands, adding another control frame, marking the control frame unreachable, etc.) which it's not doing now.

On the name, I changed it to try_op to match the feature name, and updated the documentation for op.

/// When "atomic" validation of an operator is pending, this is a trace
/// of discarded info that can restore the OperatorValidator to its
/// pre-operator state if necessary.
#[cfg(feature = "atomic")]
Copy link
Member

Choose a reason for hiding this comment

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

For Wasmtime I wrote up some guidelines on conditional compilation, notably a small set of stylistic guidelines. One thing I'd recommend here is a change of style here to avoid the sprinkling of #[cfg(feature = "atomic")] throughout if possible.

For example there could be a transaction.rs and a transaction_disabled.rs where both have the same public API but everything in transaction_disabled.rs is a noop. That way this file wouldn't need #[cfg] sprinkled on both use and definition sites, there'd in theory be just one at the mod level.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done.

Comment on lines 796 to 797
#[cfg(feature = "atomic")]
self.transaction.map(|log| log.record_push());
Copy link
Member

Choose a reason for hiding this comment

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

Personally I find this a little confusing where there's a self.operands.push, a self.record_push method, and a third record_push on the transaction. Here they're all tightly paired together but below the self.record_pop() is much further below the self.transaction.map(...). Would it be possible to refactor this to avoid the duplication here? For example could the self.record_{push,pop} helper methods handle the transaction bits as well? Otherwise it feels a bit weird having up to three different systems to keep in sync with pops/pushes and it's not well centralized at all. The mutation of each one is independent and spread throughout this multi-thousand-line-file as opposed to being in one self-contained location.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done (it was a little tricky because of the case where a pop synthesizes a "Bottom" off an empty-but-polymorphic operand stack).

@keithw
Copy link
Contributor Author

keithw commented Feb 5, 2026

Hm well yes to a certain extent I understand that cloning has quadratic behavior and that means it's quite feasible to construct a case that showcase the quadratic behavior and behaves poorly. I was instead curious about more real-world or examples-in-practice you might run into, for example do you expect that you'd be working with multi-thousand opcode functions?

It's fair, but I guess the issue is that we're not building the tool for us -- it's an IDE for other people (especially beginners) to write code in, so I'm sort of thinking about the worst case text that some freshman could paste in that we'd want the tool to handle. :-/ I don't think we'll be encouraging people to write multi-thousand-opcode functions, but I also would like it to be hard for a user to paint themselves in a corner / drive over a performance cliff, hence how I got here...

Nevertheless I'll elaborate some more on my concerns about the code organization here as well. I've left a review about various parts below. They're all tractable to fix in my opinion without too too much refactoring, but this is along the lines of what I was trying to avoid by having a third system of a sort to track pushes/pops/etc to keep in sync with everything else.

Got it, and thank you. I appreciate the review and let me take a look at everything.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants