Skip to content

Optimize the implementation of memory.fill#13367

Merged
alexcrichton merged 4 commits into
bytecodealliance:mainfrom
alexcrichton:optimize-memory-fill
May 14, 2026
Merged

Optimize the implementation of memory.fill#13367
alexcrichton merged 4 commits into
bytecodealliance:mainfrom
alexcrichton:optimize-memory-fill

Conversation

@alexcrichton

Copy link
Copy Markdown
Member

This commit is a refactoring and reimplementation of the memory.fill instruction in WebAssembly to be more optimal. Previously the implementation was a libcall with the raw arguments to the instruction which had various amounts of overhead in the host to implement the semantics of the instruction. The implementation now performs a bounds-check inline in wasm itself and while there's still an unconditional libcall it's a much simpler libcall.

The goal of this commit is to spearhead the shape of this optimization to pave the way for other libcalls to get modified as well. In isolation memory.fill isn't too particularly interesting here. Eventually though I'd like to have memory.copy and array.copy, for example, bottom out in the same libcall which is a simple memmove or similar. For now memory.fill was simple enough so I've started here.

This commit is a refactoring and reimplementation of the `memory.fill`
instruction in WebAssembly to be more optimal. Previously the
implementation was a libcall with the raw arguments to the instruction
which had various amounts of overhead in the host to implement the
semantics of the instruction. The implementation now performs a
bounds-check inline in wasm itself and while there's still an
unconditional libcall it's a much simpler libcall.

The goal of this commit is to spearhead the shape of this optimization
to pave the way for other libcalls to get modified as well. In isolation
`memory.fill` isn't too particularly interesting here. Eventually though
I'd like to have `memory.copy` and `array.copy`, for example, bottom out
in the same libcall which is a simple `memmove` or similar. For now
`memory.fill` was simple enough so I've started here.
@alexcrichton
alexcrichton requested review from a team as code owners May 14, 2026 16:55
@alexcrichton
alexcrichton requested review from cfallin and removed request for a team May 14, 2026 16:55

@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.

Looks reasonable to me overall -- thanks!

I'm curious if you've measured overheads here; naively, I'd expect that having a libcall at all is the main fixed cost, and moving logic from the libcall to compiled Wasm code is at best neutral (we have to do the same checks either way). In my mind the real win will come when we can avoid a trampoline and the usual runtime entry machinery, instead directly calling memmove/memcpy/memset (our local version or the real thing). And, as long as we're avoiding relocations in cwasms, the most efficient thing would be a direct call to a little local routine we put alongside the trampolines; on x86-64 a rep stosb / rep movsb (because these are optimized in microcode to do whole-cache-line-sized things, i.e. they're as good as it gets), on aarch64 and others whatever a reasonably good memcpy/memmove/memset loop would be. What do you think?

Comment thread crates/cranelift/src/func_environ.rs Outdated
///
/// Does not perform any in-bounds checks, so `val` must already be
/// validated to be in-bounds.
fn unchecked_cast_index_to_pointer(

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.

cast_index_to_pointer reads a little ambiguously to me -- maybe cast_wasm_addr_ty_to_native_ptr_ty?

@alexcrichton

Copy link
Copy Markdown
Member Author

Agreed yeah, and the main goal here isn't performance of memory.fill itself but rather paving the way for making the libcalls more raw. The main low-hanging-fruit I'd like to pick is array.copy which is an unconditional libcall which, for (array i8) will load/store each byte individually roundtripping through Wasmtime's high-level Val machinery. That ends up being extremely slow, and I'd also prefer to not have a whole bunch of *_copy builtins, so my hope is to use this as a plan to refactory memory.copy and then use that to refactor array.copy, shrinking our builtins and making things faster.

That being said, our safe VM machinery inside of Wasmtime isn't particularly optimized. A 1024-byte memory.fill benchmark in Criterion shows 10.1ns before this commit and 5.8ns after this commit. So this does shave off a good chunk of the overhead.

The profile before is:

  36.26%  wasmtime         libc.so.6                [.] __memset_avx2_unaligned_erms
  25.87%  wasmtime         wasmtime                 [.] wasmtime::runtime::vm::libcalls::memory_fill
  14.01%  wasmtime         wasmtime                 [.] wasmtime::runtime::vm::libcalls::raw::memory_fill
  10.81%  wasmtime         jitted-2068832-7728.so   [.] criterion::bencher::Bencher<M>::iter::h29a2389639587d69
  10.61%  wasmtime         jitted-2068832-11083.so  [.] wasmtime_builtin_memory_fill

and the profile after is:

  45.07%  wasmtime         libc.so.6                [.] __memset_avx2_unaligned_erms
  24.83%  wasmtime         jitted-2068506-7728.so   [.] criterion::bencher::Bencher<M>::iter::h29a2389639587d69
  10.56%  wasmtime         jitted-2068506-11083.so  [.] wasmtime_builtin_memory_fill
   4.58%  wasmtime         wasmtime                 [.] wasmtime::runtime::vm::libcalls::raw::memory_fill

where wasmtime_builtin_memory_fill and the raw::memory_fill functions are pure overhead, but it's still much leaner in terms of before.

@alexcrichton

Copy link
Copy Markdown
Member Author

Benchmark in question is this, in which I golf black_box until memory.fill shows up in a profile

@alexcrichton
alexcrichton added this pull request to the merge queue May 14, 2026
Merged via the queue into bytecodealliance:main with commit 40c9406 May 14, 2026
51 checks passed
@alexcrichton
alexcrichton deleted the optimize-memory-fill branch May 14, 2026 18:46
pull Bot pushed a commit to kokizzu/wasmtime that referenced this pull request May 14, 2026
This is a dual of bytecodealliance#13367 which updates the implementation of the
`memory.copy` intrinsic. The same shape is applied here -- perform
validation in compiled code and then delegate via a libcall to the
actual host implementation. The intention is to subsume the
`array_copy_non_gc_ref_elems` libcall into this one entirely in a
subsequent commit.
pull Bot pushed a commit to eduardomourar/wasmtime that referenced this pull request May 16, 2026
* Optimize `array.fill` and `array.copy`

This commit is a step towards optimizing the codegen and implementation
of the `array.fill` and `array.copy` GC instructions. This builds on the
intrinsics refactored in bytecodealliance#13367 and bytecodealliance#13368 to be used in the GC
implementation as well. For example `array.fill` and `array.new_default`
for `i8` arrays will now use `memset` to fill an array. The larger
change here is the reimplementation of `array.copy` to inline the loop
within generated CLIF.

Prior to this commit the `array.copy` function was exclusively built as
a libcall. There were two versions of this libcall -- one for GC
references and one for non GC references. Even with this minor
optimization, however, the benchmark in bytecodealliance#13279 is still quite slow. The
main reason for this is that the GC references version of the libcall
has an extremely high constant overhead per element copied. This is due
to the nature of the implementation using completely safe APIs which
engages a lot more dynamic type checks and GC heap tracking than
necessary.

The implementation of `array.copy` in this commit now looks like:

* First the bounds of the copy operation are all validated. Traps happen
  if either array is out of bounds.
* Next raw heap addresses are calculated for the start/end of the
  src/dst.
* For copyable types (e.g. `i8`, `i32`, etc), the implementation then
  delegates to the same intrinsic as `memory.copy`, a host-defined
  `memmove`.
* For GC types (e.g. `anyref`), the implementation instead has an inline
  loop which copies elements from the source to the destination. This
  copy is structured as two loops, either a forwards copy or a backwards
  copy, and only one is taken.

The result of this implementation is that the benchmark in bytecodealliance#13279 is
massively faster. What previously took minutes-to-hours now completes in
seconds, even with the DRC collector.

One caveat with this implementation is that when delegating to
`memory.copy` or `memory.fill` there's an
unnecessary-if-wasmtime-has-no-bugs bounds check double-check the host
will not fault. While wasm code will gracefully catch segfaults the host
will not, so this is necessary to ensure that in the face of heap
corruption the host never faults.

In the future it might make sense to split out functionality like this
into separate helper CLIF functions to keep the source relatively lean.
In the meantime though the massive performance benefits seem to
outweigh the size costs (to me at least).

* Fix conditional compile

* Review comments

* Specialize more bit patterns in `array.fill`

* Fix 32-bit test
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