Summary
Allow customizing a Wasm linear memory's page size in its static type definition.
Motivation
-
Allow Wasm to better target resource-constrained embedded environments, including those with less than 64KiB memory available.
-
Allow Wasm to have finer-grained control over its resource consumption, e.g. if a Wasm module only requires a small amount of additional working memory, it doesn't need to reserve a full 64KiB. Consider, for example, compiling automata and other state machines down into Wasm modules: there will be some state tables in memory, but depending on the size and complexity of the state machine in question these tables can be quite small and may not need a full 64KiB.
-
My understanding is that the number of concurrent Wasm instances a single Web app can have is currently limited by Web browsers, and this limit is often because of bottlenecks related to virtual memory and guard pages. This proposal can help raise these limits, allowing the Web app to tell the Web browser that guard pages and large virtual memory reservations aren't necessary for a particular memory. Similar benefits exist for multi-tenant function-as-a-service platforms, which can also exhaust virtual address space.
Proposal
Memory types currently have the following structure:
where limits is defined in terms of pages, which are always 64KiB.1
This proposal would extend the memory type structure with a page size:
memtype ::= mempagesize limits
mempagesize ::= u32
(Note that the above is defining structure and not binary encoding, which is why the mempagesize is always present. Even though the mempagesize would be optional in the binary encoding, it would have a default value of 64KiB if omitted, for backwards compatibility, and is therefore always present in the structure.)
The memory type's limits would still be defined in terms of pages, but the final memory size in bytes would be determined both by the limits and the configured page size. For example, given a memory type defined with a page size of 1024, a minimum limit of 4, and a maximum limit of 8, memory instances of that type would have a minimum byte size of 4096, a maximum byte size of 8192, and a size that is always a multiple of 1024.
Memory type matching would require that both memory types define the exact same page size. We would not define any subtyping relationship between page sizes.
The memory.grow and memory.size instructions will continue to give results in page counts, and can generally remain unmodified. Because the <memidx> these instructions operate upon is known statically, we know their memory's type statically, and therefore we also know the memory's associated page size statically.
Customizing a memory's page size does not affect its index type; it has the same i32 or i642 index it would otherwise have.
A single-byte page size, combined with the potential non-multiple-of-the-OS-page-size Wasm memory sizes it implies, makes emitting explicit bounds checks (as opposed to omitting bounds checks and relying on guard pages to catch out-of-bounds accesses) a practical necessity. Even if, in practice, a Wasm application dynamically keeps its memory sized in multiples of 64KiB, a single-byte page size can be used to tell the Wasm engine to avoid large virtual memory reservations for a particular memory.
Example
Here is a short example using strawperson WAT syntax:
(module
;; Import a memory with a page size of 512 bytes and a minimum size of
;; 2 pages, aka 1024 bytes. No maximum is specified.
(import "env" "memory" (memory $imported (page_size 512) 2))
;; Define a memory with a page size of 1; a minimum size of 13 pages, aka
;; 13 bytes; and a maximum size of 42 pages, aka 42 bytes.
(memory $defined (page_size 1) 13 42)
;; Export a function to get the imported memory's size, in bytes.
(func (export "get_imported_memory_size_in_bytes") (result i32)
memory.size $imported
i32.const 512
i32.mul
)
;; And a similar function for the defined memory. In this case we can avoid
;; the multiplication by page size, since we statically know the page size is
;; 1.
(func (export "get_defined_memory_size_in_bytes") (result i32)
memory.size $defined
)
)
Rationale and Alternatives
-
Instead of defining page sizes statically in the memory type, we could allow engines to choose page sizes based on the environment they are running in. This page size could be determined either at a whole-store or per-memory granularity. This effectively makes the page size a dynamic property, which necessitates a memory.page_size <memidx>: [] -> [u32] instruction, so that malloc implementations can determine how much additional memory they have available to parcel out to the application after they execute a memory.grow instruction, for example. Additionally, existing libraries and applications targeting Wasm often assume a 64KiB page size, and changing that out from under their feet will result in breakage. Finally, this doesn't solve the use case of hinting to the Wasm engine that guard pages aren't necessary for a particular memory.
In contrast, by making the page size part of the static memory type, we avoid the need for any new memory.page_size instruction or a similar mechanism and we avoid breaking existing code, since new code must opt into alternative page sizes. Additionally, because the Wasm engine statically knows the page size, and therefore statically knows whether it can elide bounds checks via guard pages or not, it has the option to leverage that information in an ahead-of-time manner (for example, to eagerly emit or omit explicit bounds checks) rather than being forced to delay that decision until when the page size is dynamically chosen at runtime. Similarly, static page sizes enable more constant propagation and folding by the Wasm engine's compiler; dynamic page sizes defeat such optimizations.
-
We could avoid changing Wasm core semantics and instead encourage a wink-and-nudge collaboration between Wasm engines and toolchains (possibly with the help of a Wasm-to-Wasm rewriting tool), such that generated Wasm modules themselves mask/wrap/bounds-check every memory access they perform, resulting in all actual memory accesses fitting within the desired memory size of N < 64KiB. This communicates to engines, statically, that there are never any memory accesses beyond N and engines can therefore avoid allocating a full 64KiB page as an optimization while still fully conforming to standard Wasm semantics.
This approach, however inelegant, does address the narrow embedded use case of smaller-than-64KiB memories, but not the use cases where memory is still larger than a 64KiB Wasm page but at a finer granularity than 64KiB, nor the use case of hinting that guard pages are unnecessary for a particular memory. It additionally requires that the memory is not exported (and therefore this approach isn't composable and doesn't support merging/splitting applications across modules). It also requires full-program static analysis on the part of the Wasm engine.
-
Do nothing and ignore these use cases.
However, if we do nothing, then the Wasm engines that cater to embedded use cases (for example) are incentivized to satisfy their users' use cases by abandoning standard Wasm semantics and adding ad-hoc, non-standard, proprietary support for non-multiples-of-64KiB memory sizes. This will result in uninteroperability, ecosystem splits, and eventually pressure on standards-conforming Wasm implementations to support these non-standard extensions. Furthermore, because such non-standard extensions would likely be designed with the needs of only a single Wasm engine in mind, there is no guarantee they would be compatible with other engines' internal architectures and constraints.
Properly addressing these use cases with standard, well-designed, and interoperable solutions is a much preferable outcome.
Open Questions
-
Should we support any arbitrary page size, eg 12345? Or just powers of two? Or perhaps only the exact values 1 and 65536?
Allowing only 1 and 65536 allows us to avoid the grey area of page sizes like 4096 which may or may not support eliding bounds checks with virtual memory guards pages depending on the target, and is therefore a minor portability concern.
-
I think we could, if we were really motivated, loosen memory type matching to allow subtyping of page sizes where the supertype page size is a multiple of the subtype's page size, e.g. 1024 <: 4096. This would allow importing memories defined with larger page sizes than the importer declared. But then when the importer did a memory.grow n, we would need to round n up such that the memory delta was a multiple of the supertype's page size and somehow communicate that this happened to the caller. We'd also need to update the semantics of the memory.{grow,size} instructions to translate between memory-definition pages and memory-import pages before returning memory sizes expressed in page counts. Ultimately I can't think of any concrete benefits or use cases this unlocks, and it seems like a big hassle with many subtle gotchas, so I'd rather not open this can of worms.
Meta
I'd like to start discussion on this proposal in this design issue, gather feedback and more motivation and use cases, and then present this idea more formally at an upcoming CG meeting. If the CG's reception is positive, I'd like to turn this into an actual phase 0 proposal and continue from there.
So if you have additional use cases that this proposal could potentially solve, or one of the use cases mentioned above, please leave a comment and share your particular details. Thanks!
Summary
Allow customizing a Wasm linear memory's page size in its static type definition.
Motivation
Allow Wasm to better target resource-constrained embedded environments, including those with less than 64KiB memory available.
Allow Wasm to have finer-grained control over its resource consumption, e.g. if a Wasm module only requires a small amount of additional working memory, it doesn't need to reserve a full 64KiB. Consider, for example, compiling automata and other state machines down into Wasm modules: there will be some state tables in memory, but depending on the size and complexity of the state machine in question these tables can be quite small and may not need a full 64KiB.
My understanding is that the number of concurrent Wasm instances a single Web app can have is currently limited by Web browsers, and this limit is often because of bottlenecks related to virtual memory and guard pages. This proposal can help raise these limits, allowing the Web app to tell the Web browser that guard pages and large virtual memory reservations aren't necessary for a particular memory. Similar benefits exist for multi-tenant function-as-a-service platforms, which can also exhaust virtual address space.
Proposal
Memory types currently have the following structure:
where
limitsis defined in terms of pages, which are always 64KiB.1This proposal would extend the memory type structure with a page size:
(Note that the above is defining structure and not binary encoding, which is why the
mempagesizeis always present. Even though themempagesizewould be optional in the binary encoding, it would have a default value of 64KiB if omitted, for backwards compatibility, and is therefore always present in the structure.)The memory type's limits would still be defined in terms of pages, but the final memory size in bytes would be determined both by the limits and the configured page size. For example, given a memory type defined with a page size of 1024, a minimum limit of 4, and a maximum limit of 8, memory instances of that type would have a minimum byte size of 4096, a maximum byte size of 8192, and a size that is always a multiple of 1024.
Memory type matching would require that both memory types define the exact same page size. We would not define any subtyping relationship between page sizes.
The
memory.growandmemory.sizeinstructions will continue to give results in page counts, and can generally remain unmodified. Because the<memidx>these instructions operate upon is known statically, we know their memory's type statically, and therefore we also know the memory's associated page size statically.Customizing a memory's page size does not affect its index type; it has the same
i32ori642 index it would otherwise have.A single-byte page size, combined with the potential non-multiple-of-the-OS-page-size Wasm memory sizes it implies, makes emitting explicit bounds checks (as opposed to omitting bounds checks and relying on guard pages to catch out-of-bounds accesses) a practical necessity. Even if, in practice, a Wasm application dynamically keeps its memory sized in multiples of 64KiB, a single-byte page size can be used to tell the Wasm engine to avoid large virtual memory reservations for a particular memory.
Example
Here is a short example using strawperson WAT syntax:
Rationale and Alternatives
Instead of defining page sizes statically in the memory type, we could allow engines to choose page sizes based on the environment they are running in. This page size could be determined either at a whole-store or per-memory granularity. This effectively makes the page size a dynamic property, which necessitates a
memory.page_size <memidx>: [] -> [u32]instruction, so thatmallocimplementations can determine how much additional memory they have available to parcel out to the application after they execute amemory.growinstruction, for example. Additionally, existing libraries and applications targeting Wasm often assume a 64KiB page size, and changing that out from under their feet will result in breakage. Finally, this doesn't solve the use case of hinting to the Wasm engine that guard pages aren't necessary for a particular memory.In contrast, by making the page size part of the static memory type, we avoid the need for any new
memory.page_sizeinstruction or a similar mechanism and we avoid breaking existing code, since new code must opt into alternative page sizes. Additionally, because the Wasm engine statically knows the page size, and therefore statically knows whether it can elide bounds checks via guard pages or not, it has the option to leverage that information in an ahead-of-time manner (for example, to eagerly emit or omit explicit bounds checks) rather than being forced to delay that decision until when the page size is dynamically chosen at runtime. Similarly, static page sizes enable more constant propagation and folding by the Wasm engine's compiler; dynamic page sizes defeat such optimizations.We could avoid changing Wasm core semantics and instead encourage a wink-and-nudge collaboration between Wasm engines and toolchains (possibly with the help of a Wasm-to-Wasm rewriting tool), such that generated Wasm modules themselves mask/wrap/bounds-check every memory access they perform, resulting in all actual memory accesses fitting within the desired memory size of
N < 64KiB. This communicates to engines, statically, that there are never any memory accesses beyondNand engines can therefore avoid allocating a full 64KiB page as an optimization while still fully conforming to standard Wasm semantics.This approach, however inelegant, does address the narrow embedded use case of smaller-than-64KiB memories, but not the use cases where memory is still larger than a 64KiB Wasm page but at a finer granularity than 64KiB, nor the use case of hinting that guard pages are unnecessary for a particular memory. It additionally requires that the memory is not exported (and therefore this approach isn't composable and doesn't support merging/splitting applications across modules). It also requires full-program static analysis on the part of the Wasm engine.
Do nothing and ignore these use cases.
However, if we do nothing, then the Wasm engines that cater to embedded use cases (for example) are incentivized to satisfy their users' use cases by abandoning standard Wasm semantics and adding ad-hoc, non-standard, proprietary support for non-multiples-of-64KiB memory sizes. This will result in uninteroperability, ecosystem splits, and eventually pressure on standards-conforming Wasm implementations to support these non-standard extensions. Furthermore, because such non-standard extensions would likely be designed with the needs of only a single Wasm engine in mind, there is no guarantee they would be compatible with other engines' internal architectures and constraints.
Properly addressing these use cases with standard, well-designed, and interoperable solutions is a much preferable outcome.
Open Questions
Should we support any arbitrary page size, eg
12345? Or just powers of two? Or perhaps only the exact values1and65536?Allowing only
1and65536allows us to avoid the grey area of page sizes like4096which may or may not support eliding bounds checks with virtual memory guards pages depending on the target, and is therefore a minor portability concern.I think we could, if we were really motivated, loosen memory type matching to allow subtyping of page sizes where the supertype page size is a multiple of the subtype's page size, e.g.
1024 <: 4096. This would allow importing memories defined with larger page sizes than the importer declared. But then when the importer did amemory.grow n, we would need to roundnup such that the memory delta was a multiple of the supertype's page size and somehow communicate that this happened to the caller. We'd also need to update the semantics of thememory.{grow,size}instructions to translate between memory-definition pages and memory-import pages before returning memory sizes expressed in page counts. Ultimately I can't think of any concrete benefits or use cases this unlocks, and it seems like a big hassle with many subtle gotchas, so I'd rather not open this can of worms.Meta
I'd like to start discussion on this proposal in this design issue, gather feedback and more motivation and use cases, and then present this idea more formally at an upcoming CG meeting. If the CG's reception is positive, I'd like to turn this into an actual phase 0 proposal and continue from there.
So if you have additional use cases that this proposal could potentially solve, or one of the use cases mentioned above, please leave a comment and share your particular details. Thanks!
Footnotes
The
memory64proposal adds an index type to the memory type, and parameterizes the limits on the index type, but the limits are still defined in terms of 64KiB pages. ↩If the
memory64proposal is enabled and this memory is a 64-bit memory. ↩