Repository navigation
Description
Problem
We consider the problem of dynamically generating Wasm code while running on a Wasm engine.
Harvard architecture, no JIT
Core WebAssembly disallows using runtime data as code or generating new code at runtime. This important property of Wasm allows efficient validation of all code in a module and establishes other important security properties. Further, this restriction allows static analysis against the state declared in a module but not exported, admitting sound closed-world optimizations, which is common in tooling today.
Guest runtimes want to JIT new code
However, guest runtimes running on top of Wasm, such as language runtimes like Python or Lua, and hardware emulators, such as QEMU, can benefit tremendously by generating specialized code at runtime. The performance benefit of dynamic code generation can be extreme; as much as 10 to 100 times faster than running in interpreted mode. Today, these runtimes can run only in interpreted mode and are prohibitively slow.
Inconsistent host support for new modules
To date, generating new Wasm code has been an optional host capability provided by the embedder. On the web, this is done through JavaScript APIs for creating new modules directly from bytes. The wasm-c-api allows embedding a Wasm engine in a C/C++ program and creating new modules dynamically. Other host platforms may offer the ability to create new modules from bytes. Generally, the granularity of generation is Wasm modules. Some experiments have been done with function-at-a-time mechanisms, but no standard mechanism has been proposed and nothing is portable across platforms.
Today, guest runtimes that generate new code are few, and they make a number of compromises, such as batching generated functions, in order to workaround limitations and cost of new modules on host platforms.
Proposing a lightweight core-Wasm mechanism
To address this problem, we propose to add a new mechanism to core Wasm. Doing so exposes the mechanism to security features/mitigations, makes behavior explicit and documented, allows sound toolchain analysis and transformations, and exposes it to engine optimizations.
A new instruction: func.new
The main component of this idea is a new core Wasm bytecode, func.new, which creates a new function at runtime from bytecode stored in Wasm memory.
func.new $mt $ft $scope: [at at] -> [(ref $ft)]
where:
- $mt = memory code at limits
- $ft = func [t1*] -> [t2*]
- $scope = export*
This bytecode has immediates:
- a memory index, indicating the memory which contains the code for the new function
- a function type, indicating the signature of the new function
- a scope, indicating a list of functions, tables, globals, tags, and memories that new code may legally access
At runtime, this instruction takes operands:
- start, an integer indicating the start offset within the memory of the code
- length, an integer indicating the length of the code
The memory index and function type are straightforward. The $scope immediate is an explicit enumeration of the module contents the new code may reference. In particular, $scope produces a new index space for types, globals, functions, memories, etc. For compactness of the bytecode, and to reuse the same scope for multiple different func.new instructions, $scope will be factored out to its own section rather than inline as immediates.
To execute the instruction, the engine first copies the bytes of code from the Wasm memory, then validates the bytecode under a module context corresponding to $scope and a function context corresponding to the expected function signature $ft. Upon out of bounds memory access or validation error, the instruction traps. If validation of the code is successful, the engine creates an internal representation of a new function and pushes a non-null reference to it onto the operand stack. The function's store is derived from the store under which the instruction was executed; i.e. the new function's instance is (a subset of) the caller's instance.
A new section: JIT scope
A scope defines what declarations are accessible to new code passed to a func.new instruction. We introduce a new "scope" section to factor out the list of accessible declarations so that func.new may refer to a scope by index rather than listing declarations individually. A scope section allows declaring multiple scopes to allow different func.new instructions different accessibility. A scope consists of a list of declarations (similar to export declarations, but without names). The order of declarations in a scope creates new index spaces, i.e. it renumbers the declarations from the surrounding module, starting over from 0.
A new memory flag: code
Generating new code into a module is a powerful feature. It's important that modules have mechanisms to limit and control access to the feature. With the proposed scoping mechanism, a module can limit the interface that new code has access to by explicit enumeration of allowable declarations. This makes sense for modules with security requirements, e.g. where a threat model consider dynamically-generated code potentially compromised or even malicious. Another possible threat is that dynamically-generated code could be corrupted while in Wasm memory, either intentionally or unintentionally, before being supplied to a func.new invocation.
Supporting dynamically-generated code also requires significant runtime support, including a validator and at least one execution tier. Some host platforms may simply be unable to support dynamically-generated code because of memory constraints, lack of execution tiers, or security controls.
For many reasons, we might want to consider the feature "opt-in". We propose a new flag for memories, the code flag, which is part of a memory declaration. Like the shared flag, it explicitly marks a memory that has the capability to be used with func.new. For many of the same reasons as shared memory, this capability thus requires that declarer of the memory and the user of the memory both agree it can be used for code.
An example
Putting the pieces together, we can write an example that uses a code memory, a scope, and creates new functions at runtime.
(module
(type $t1 (func)) ;; the type for new functions
(func $f1 ...)
(func $f2 ...)
(func $f3 ...)
(memory $m1 code 1 1) ;; the memory used to temporarily store code for func.new
(memory $m2 1 1) ;; a memory accessible to new code
(scope $s1 ;; the scope a new function may use
(func $f1 $f2) ;; expose $f1 and $f2 to new code
(memory $m2)) ;; expose only $m2 to new code
(func $gen
(local $n (ref $t1)) ;; a variable to hold the new funcref
...
(local.set $n
(func.new $m1 $t1 $s1 ;; code lives in $m1, result sig is $t1, scope is $s1
(i32.const 1024) (i32.const 10))) ;; code is stored at address 1024 and is 10 bytes long
...
(call_ref $t1 (local.get $n)) ;; call the new function!!
)
)
Sound static analysis
Sound static analysis of Wasm code is vital for module-level optimizations. This proposal preserves reasoning about a module's internal behavior by making its interactions with runtime-generated code explicit. Since runtime generated code can only access the declarations explicitly provided by its scope, analysis can soundly treat declarations mentioned in scopes similarly to exported functions. However, unlike exported functions, scopes cannot be accessed outside a module. Thus a module's public interface is not polluted with its internal use of dynamically-generated code.
Toolchain transformations
Sound static analysis implies that toolchains making closed-world assumptions for optimization (e.g. dead-code elimination) can account for all potential future uses by dynamically-generated code by simply considering scopes. Reorganization of modules resulting from DCE is sound, as scopes can be rewritten for updated indices. Since dynamically-generated code must use scoped indices, it is not affected by the renumbering of the containing module. Thus DCE and other aggressive transformations can be made sound and not affect dynamically-generated code.
Engine optimizations
The possibility of dynamically-generated code implies that an engine has runtime capability to parse function bodies and perform code validation. However, the func.new bytecode doesn't require parsing and validating sections, so the runtime system doesn't need a fully-featured Wasm module parser.
New code implies the need for at least one execution tier, such as interpreter or compiler. This implies AOT scenarios need to either disable the feature, e.g. reject code memories, trap on func.new, or integrate a new tier. Note that since a scope defines a module context, the execution tier only needs to support runtime features that could be legally used under that context--basically, no memories implies no load/store instructions, no GC types implies no GC instructions.
In the future, we could consider additional restrictions, such as an explicit list of allowable bytecodes for new functions. That could allow a module to limit language features for new functions and greatly reduce the runtime system requirement. Since this is a restriction a module imposes on itself, it need not be standardized. As an example of the usefulness of this, a module could limit its func.new capabilities to only allow i32 arithmetic and control flow. An AOT implementation could then provide a runtime execution tier that only supported that restricted set, keeping it both simple and optimized for the use case.
Activity
rossberg commentedon Sep 24, 2025
This looks good! I'm curious to see how far this would take us and what people could do with it.
I have one question and two naming nits:
exportkeyword and name).titzer commentedon Sep 24, 2025
Yeah, agree that "environment" is a better name, since there isn't nesting or even embedding. I was also thinking the section would go best just before the code section.
conrad-watt commentedon Sep 24, 2025
Can the concept of scope/environment be unified with the concept of "nested modules" that showed up in earlier drafts of interface types/the component model? Is an environment essentially a nested module made up of just imports (plus the JIT'd function)?
kmiller68 commentedon Sep 24, 2025
Some random thoughts in no particular order:
Does it really make sense to have a different memory flag? If a producer can't control the code enough to trust a particular memory index is isolated they presumably couldn't guard against malicious use of the flag where it wouldn't be safe. It also wouldn't protect the host wasm VM in any way.
Is there any reason it only takes a linear memory rather than a GC array of bytes? It seems like that would be just as efficient (if not more so, especially if immutable) in most GC environments and possibly more convenient.
Can the scope/environment contain tables/globals? If so then this seems likely to cause memory leaks without some cycle breaking. GC runtimes would get this for free but other environments might not. Maybe this is already an issue though? If not, is that overly limiting for producers?
titzer commentedon Sep 24, 2025
@conrad-watt Yes, I think conceptually they are very limited nested modules. I didn't want to unduly incorporate the generality of nested modules in this discussion, but that would probably be an elegant treatment in specification language and formalism.
titzer commentedon Sep 24, 2025
@kmiller68 The choice of linear memory was only for simplicity and discussion. An additional instruction that takes a GC byte array would also be fine with me.
As for the flag, my primary thinking was to exclude accidental use of a memory for creating new code, so that modules have to be new and know about dynamic code generation in order to use the feature. The declaration of the memory and the use of the memory must agree. For example, importing a memory to be used for code generation would require the flag, and the provider of that memory would have to also have the flag, like the
sharedattribute.The environment can contain globals and tables. But I don't think this creates any cycles, as the instance for the new function is just a subset of the instance that invokes
func.new.cfallin commentedon Sep 25, 2025
Would it make sense to fit the notion of asynchronous/background compilation into this design?
Engines today often have mechanisms to kick off a compilation, and allow the background thread to fill in a function pointer asynchronously. For a multitude of reasons, a JIT inside a Wasm guest may wish to preserve that asynchrony. Perhaps there could be a variant (
func.new_asyncor similar) that places a funcref in a designated table index (of compatible nullable funcref type) when complete?Separately, this would enable a deferred mechanism/workflow, similar to how we process function additions in weval today: one could recover a pseudo-AOT workflow by collecting
func.news and batching the function additions into a post-processing step.kg commentedon Sep 25, 2025
We currently ship a WASM JIT in production and would love to adopt an opcode like this. It might be helpful to provide some context to inform your thinking here, so I'll try and summarize what we built:
Now for the actual JIT. It has a few modes but the most important one is described below.
call_indirect.func.newoperating on a single function is great.Promise.then) the tiered up WASM code until after the loop finished, making it worthless.func.new_asynchad a mechanism to allow receiving a compiled fn before an event loop turn, though, it would be a fantastic improvement for us. We have the ability to safely do this kind of transition, because we pre-fill a big block of function pointers with a safe 'dummy' function with the appropriate signature that does nothing. (This is necessary for thread safety, since function pointer tables are per-thread.)func.newwill allow sharing tables, so it would just be important to make sure table sharing isn't expensive.types for a function. I can imagine cases where you'd want to be able to do this though - a full blown JIT for arbitrary methods can't know every possible signature in advance, so it would be ideal if there were a way to define functions of constructed signatures, even if only otherfunc.new'd functions are able to invoke them. This is totally optional though, I can just imagine it being a barrier to use offunc.newin a production JIT, especially if you want to expose JITed functions to JS.func.newcould be made thread safe somehow - perhaps some mechanism where it patches the table in all threads at once - that would be really big for making thread-safe JITs. The need to ensure that a function pointer is 'safe' in all threads before exposing it creates really difficult synchronization problems, and the workarounds for it are expensive (pre-filling the fn pointer table with safe dummies takes multiple milliseconds at startup.)This comment got longer than I wanted so I'll end it here. Feel free to ask questions. Really happy to see this proposal.
yuri91 commentedon Sep 29, 2025
A few points from the perspective of another production Wasm JIT engine (https://webvm.io):
titzer commentedon Sep 29, 2025
Ok, summarizing some above points for further discussion:
callinstruction (e.g. importing them)?For 2) I am curious if the need for asynchrony is to avoid a large pause from the engine compiling a Wasm function to machine code, or if the latency of validation is already prohibitively long. I think in any case the engine should make a synchronous copy of the bytes to avoid racing (early on, we had a security bug in V8 caused by racing on a SAB during module compilation using the synchronous JS API). In my experience validation is fast (hundreds of megabytes per second), so that makes me wonder if engines could copy+validate the bytecode synchronously and provide a valid
funcrefas a return value, but then block if the function is invoked before machine code is available.fgmccabe commentedon Sep 29, 2025
Async compilation sounds like a classic scenario where JSPI is the right approach (on the web at least).
Barring that, a pure Wasm solution for async compilation should wait for stack switching to land IMO. However, there are additional issues in using stack switching for this: in particular, at the moment, there is no intra-WebAssembly event model.
This seems to lead to a model similar to that for JS Strings: realizing jit compilation via special imports rather than adding new instructions.
titzer commentedon Sep 29, 2025
Also, for reference, Wizard implements a function-at-a-time compilation as a host API. The API produces a new function in the calling instance, similar to
func.new. As a lower bound, for a simple function containingnop, with synchronous copy+validate, Wizard can generate 3.5 million new functions per second.cfallin commentedon Sep 29, 2025
One use-case that this doesn't cover, at least, is the desire in the guest to continue using some lower tier (e.g., an interpreter) until the background compilation finishes. The two paths I see to provide that are the asynchronous write to a table slot, as I suggested above, or another opcode (something like
func.ready) with signaturefuncref -> i32. Either way the end-result is the moral equivalent of what lazily compiling native JITs do: dynamically check whether a function pointer is non-null and call it if so.titzer commentedon Sep 29, 2025
Interesting idea @cfallin !
func.readycould even be spec'd to have more than one return value, so that0= not ready,1= ready,2= validation error. Then validation could even be made asynchronous if the engine chooses to do so.cfallin commentedon Sep 29, 2025
Indeed, and to state it explicitly, before the function is ready, calling the funcref could still have the blocking semantics you describe. That behavior actually makes it possible to do away with the
func.new/func.new_asyncdistinction, which is nice as well.Async validation is interesting: in that case what are the semantics of calling the funcref? A trap? (For the record I don't see async validation as being as important as async compilation, so if there is any pushback here we should separate them; but it's nice to provide the possibility in any case.) A value of
2to indicate a validation error feels like a little bit of a hack (we should either expose validation programmatically, which would include a synchronous/blocking query, or maintain it as a compiles-to-a-trapping-function semantics) but at the very least it's non-zero so a branch-and-call would succeed and hit that trap.titzer commentedon Sep 29, 2025
Agree on the blocking semantics. Somehow the engine doing an asynchronous write to a table feels dangerous and error-prone. E.g. if/when there are shared tables, what if two different threads execute
func.new_asyncinto the same table slot; the engine would need to have atomic write semantics and one of them might get lost to the sands of time...jakobkummerow commentedon Sep 29, 2025
FWIW, V8's current default behavior is to lazy-compile functions the first time they're called. Since this strategy is often hugely beneficial on the Web, I would assume that (1) it'll remain that way for a long time and (2) other engines will also gravitate towards this if they haven't already. Also, we first compile with our fast baseline single-pass compiler, and only spend the cost of (async!) optimized compilation when a function is hot.
So for engines employing such strategies, an "async compilation" feature for
func.newonly makes sense if there's also a way to request immediate compilation and/or optimization. That could be implicit infunc.newjust behaving differently from regular module instantiation; but perhaps there are use cases for more explicit control: while some modules might JIT-create hot functions one at a time for performance reasons, others might mass-create lots of them for adaptiveness reasons or something, and prefer them being as lightweight as possible when they're not actually used yet. The Compilation Hints proposal is going in this direction, but is (currently?) specified as only providing optional hints, and since it's based on a Custom Section, it wouldn't be available tofunc.newby default; there'd have to be some mechanism to get it or something like it.Perhaps it would be viable to simply rely on engines' ability to compile/optimize lazily? An uncompiled
funcrefthat will be lazily compiled on its first call is very quick to create, so doing that synchronously isn't going to add a lot of latency.cfallin commentedon Sep 29, 2025
That still doesn't address the use-case above though, which is that the guest wants to have an in-guest fallback behavior if compilation isn't complete yet. It need not be "slower implementation of the same logic" (e.g. an interpreter) either: for example, perhaps the guest has another funcref for a (compiled) generic version of a function, and is JIT'ing a type-specialized version. That needs the underlying compilation status to somehow be exposed to the guest so it can fall back to calling another funcref instead.
yuri91 commentedon Sep 29, 2025
@jakobkummerow this default V8 behavior is bad for our use case, because we always want the most optimized version possible when we request to (re)compile a function. As @cfallin mentioned, we might have already a compiled version of the code available, and switching to a better but baseline-compiled function can make things slower instead of faster.
And we are looking forward to the standardization of the compilation hints proposal to improve the situation.
It would be a shame if the new API was a step back on this front.
fitzgen commentedon Sep 29, 2025
One thing that has not been brought up yet is fallibility in the context of resource exhaustion (no compiler available or OOM, rather than because of OOB memory access or invalid code). It might be nice for
func.newto return a nullable reference (or whatever async equivalent) to signal this case, rather than raise a trap. This would allow for engines built without a compiler at runtime, and which can only run AOT-compiled modules, to return null references and for the Wasm program to continue, albeit running slower than it otherwise might if a compiler was available, rather than trap and abort the whole computation. It also allows engines to look up an AOT-compiled function for a givenfunc.newcall in a cache, even if it will never actually compile any code, and still sometimes provide a non-nullfuncrefresult. This could be useful for Wizer-style phased compilation/initialization, as well as for PGO-style things (that is, doing the JIT stuff a "level up" in the overall system).eqrion commentedon Oct 2, 2025
Thanks for this proposal, good to see a concrete idea in this space finally.
Unordered thoughts:
func.new. Such as bounds checking the bytecode range to a partitioned part of the heap where JIT code must be created by the VM.func.newin it? The instruction would have to point at a pre-existing scope and function type, so it's probably fine.func.can_newinstruction which returnsi32.const 1when a JIT is present at runtime, and specfunc.newto always validate but trap (or return null) if called when!func.can_new. This will eventually simplify the build matrix that users need to support. I think returningnullfor resource exhaustion would also be good.rossberg commentedon Oct 3, 2025
Rather, I think this feature is a clear example of something that needs profile support. Not sure why it would justify an ad-hoc feature detection mechanism more than any other problematic feature we already have.
tschneidereit commentedon Oct 6, 2025
I agree that this would make
func.newviable in environments where otherwise it wouldn't be. To really make it work well though would require being able to signal three different outcomes:The third one would presumably be used in OOM situations, but also is the one that'd allow for more involved PGO-style scenarios: you might not have a compiler where you execute code, but you might be able to store the bytecode / send it somewhere and do delayed AOT + re-deploy. If the guest gives up on the whole notion of JIT-compilation after the first failure, that kind of approach isn't viable.
cfallin commentedon Oct 6, 2025
The
func.readyapproach could kind of work for that, too; though there is a distinction between "pause because compiler is churning on a background thread" and "pause until you return from your initialization entry point" aka deadlock. To your point of disambiguating directly in the semantics, maybe we'd want to spec the "call the funcref early" to be allowed to trap as well for your case 2 ("functions will not be available until the next phase boundary"). Then a guest that wants to be maximally compatible with such environments would always usefunc.readybefore calling; this allows seamless upgrade once frozen/thawed across the phase boundary; and a guest that avoids any async machinery and naively doesfunc.newthen calls the funcref immediately would just work in a true JIT environment (albeit with compiler pause jitter) and properly trap in a deferred-JIT environment.titzer commentedon Oct 6, 2025
I wonder if there's enough use case diversity to consider making space for hints directly as an immediate. For example, if we reserve a zero byte for future flags, the default value of
0could be defined to mean the most ergonomic (but potentially lowest performing) option, such as block until the function is ready to run. Then we could add flags one by one allowing more asynchrony and failures, such as "I am OK with async compilation, I'll usefunc.readyto poll later" and "I'm OK with OOM for now, just give me null", etc.eqrion commentedon Oct 6, 2025
@rossberg
I'm fine with there being a profile for this too. However a 'full' profile that is targeted by the web ecosystem would still benefit from a feature test. The capability to generate code on the Web is governed by CSP and is dynamic. It doesn't necessarily need to be a
func.can_new, but I think there should be some recoverable way to do it outside of just trapping or failing to instantiate your module.titzer commentedon Oct 7, 2025
@eqrion That's a good point about CSP. I was thinking that the engine rejecting a module with the "code memory" flag set would be sufficient...but would mean that, e.g. a guest language runtime (a potentially big thing) that has a code memory would not validate at all if CSP disallows it. The page would have to first check whether code memory was allowed (via a test module) and load one of two different modules, one with code memory, one without, depending. I am not sure if that's the status quo on the web, however. Are other feature tests are commonly like that?
eqrion commentedon Oct 7, 2025
@titzer Yeah, that is the status quo for wasm feature tests on the web. The unfortunate thing is that the build combinations users need to support grows really quick. You might need to support
MVP,MVP+EH,MVP+EH+SIMD,MVP+SIMD, etc.One mitigating factor has been that eventually browsers get enough support that some build combinations can be removed and a feature's support assumed (e.g. exception-handling). But that doesn't work for features like SIMD which may never be fully supported on all architectures (Firefox doesn't support it on ARM32 and doesn't plan to), and so the build matrix will always need to support it.
So for JIT support, a separate build configuration will probably be required initially as browsers gain support. But it would be nice if eventually a single build could be shipped that uses runtime checks for CSP issues.
titzer commentedon Oct 31, 2025
A new proposal has been created for this!