Skip to content

Commit a361d8f

Browse files
authored
perf: keep shared-memory locking out of line (#72)
Since the threads proposal landed, `with_memory!` takes a shared memory's lock inline and holds the guard until the access ends, and this actually dropped tinywasm's leaderboard placement in my WasmBench app on multiple devices. With the inline lock, load and store handler contain the `lock` and `unlock` calls, even though `core::hint::cold_path()` marks that branch cold. To keep values alive across those calls, each handler saves extra callee-saved registers on every access, whether or not the memory is shared. This runs the shared-memory body in a `#[cold]`, `#[inline(never)]` helper that takes the lock. (There is a `core::hint::cold_path()` in there, but when I looked at the disassembled code for the Release build, it didn't affect codegen and Rust docs verify that its not necessarily meant to influence codegen.) The ordinary-memory path stays inline, which is seemingly more common in the wild, but if you have some specific workload you're optimizing for and need both paths to be equally fast, I can go back to the drawing board. Everything measured with XCode 27's PMU template, the same approach I used for measuring and optimizing other projects (WAMR, wgpu-native, femtovg, BabylonNative, xmrsplayer, etc) on this set of hardware. On aarch64 (iPhone XS A12 codegen, `nightly-tail-calls`): | handler | before | after | |---|---|---| | `I32Store` | 188 instructions, 5 saved register pairs, 17 calls | 137 instructions, 3 pairs, 8 calls | | `I32Load` | 186 instructions, 5 pairs, 16 calls | 142 instructions, 3 pairs, 8 calls | Three memory.copy / data-segment bodies used `?` inside the macro for a unit result. They now return the `Result` and apply `?` outside the macro, so a body means the same thing on both paths. **iPhone 12 (A14) efficiency cores.** Per-call medians of the kernel's retired-instruction and cycle counters, 5 interleaved runs of each build: | row | instructions | cycles | |---|---:|---:| | byte-hash loop over one memory | −3.6% | −4.2% | | convolution 256×256 (scalar) | −5.7% | −3.3% | | sieve (scalar) | −4.2% | −7.8% | | crc32 64 KB (scalar) | −4.0% | −6.3% | | xmrsplayer | −2.7% | −1.1% | | graphql-validation (AS) | −3.4% | −2.7% | | shared memory: plain load/store | −4.2% | −4.1% | | shared memory: atomic RMW | −6.3% | −10.7% | | ordinary memory: plain load/store | −5.8% | −4.4% | | fib(30), no memory ops | −0.1% | −0.0% | **Apple Watch Series 10.**Per-call medians of the kernel's retired-instruction and cycle counters: | row | instructions | cycles | |---|---:|---:| | byte-hash loop over one memory | −4.6% | −8.6% | | convolution 256×256 (scalar) | −5.8% | −4.8% | | sieve (scalar) | −4.2% | −8.3% | | crc32 64 KB (scalar) | −4.0% | −6.2% | | xmrsplayer | −2.9% | −3.6% | | graphql-validation (AS) | −3.3% | −2.8% | | fib(30), no memory ops | −0.2% | −0.0% | ![WasmBench on Apple Watch Series 10, tinywasm with this change](https://github.com/rebeckerspecialties/wasm-benchmark/blob/app-store-ui/docs/tinywasm-watch-2026-09-26/watch-cold-shared-memory.png?raw=true) The screenshot is one 1-second run. Full data: [tinywasm on the Apple Watch Series 10](https://github.com/rebeckerspecialties/wasm-benchmark/blob/app-store-ui/docs/tinywasm-watch-2026-09-26.md).
1 parent 693d590 commit a361d8f

3 files changed

Lines changed: 57 additions & 12 deletions

File tree

‎crates/tinywasm/src/interpreter/executor/mod.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,7 +1432,7 @@ impl<'store> Executor<'store> {
14321432
let memory = arg.resolve(&self.func.data);
14331433
let mem_addr = self.mem_addr(memory.memory());
14341434
let width = if op == AtomicWaitOp::Wait64 { 8 } else { 4 };
1435-
let addr = crate::store::with_memory!(self.store.state, mem_addr, |mem, kind| {
1435+
let addr = crate::store::with_memory!(@lock_inline self.store.state, mem_addr, |mem, kind| {
14361436
let base = self.store.value_stack.pop_memory_operand(kind.arch())?;
14371437
let addr = cold_err!(mem.effective_addr::<1>(base, memory.offset()))?;
14381438
if addr % width != 0 {
@@ -1484,7 +1484,7 @@ impl<'store> Executor<'store> {
14841484

14851485
let memory = arg.memory.resolve(&self.func.data);
14861486
let mem_addr = self.mem_addr(memory.memory());
1487-
crate::store::with_memory!(self.store.state, mem_addr, |mem, kind| {
1487+
crate::store::with_memory!(@lock_inline self.store.state, mem_addr, |mem, kind| {
14881488
let base = self.store.value_stack.pop_memory_operand(kind.arch())?;
14891489
let addr = cold_err!(mem.effective_addr::<N>(base, memory.offset()))?;
14901490
if addr % N != 0 {

‎crates/tinywasm/src/store/mod.rs‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ pub(crate) use memory::{MemValue, MemoryInstance};
3434
pub use memory::{MemoryShared, MemorySharedGuard};
3535
pub(crate) use state::State;
3636
pub(crate) use state::with_memory;
37+
#[cfg(feature = "std")]
38+
pub(crate) use state::with_shared_memory;
3739
pub(crate) use types::{canonicalize_ref_type, canonicalize_value_type};
3840
pub(crate) use {data::*, element::*, function::*, global::*, table::*, tag::*};
3941

@@ -822,9 +824,8 @@ impl Store {
822824
};
823825
let offset = usize::try_from(offset).unwrap_or(usize::MAX);
824826
with_memory!(self.state, *mem_addr, |mem, kind| {
825-
mem.write_all(offset, &data.data)
826-
.ok_or_else(|| memory::memory_oob(offset, data.data.len(), mem.len()))?;
827-
});
827+
mem.write_all(offset, &data.data).ok_or_else(|| memory::memory_oob(offset, data.data.len(), mem.len()))
828+
})?;
828829
self.state.data[data_addrs[i] as usize].drop();
829830
}
830831
tinywasm_types::DataKind::Passive => {}

‎crates/tinywasm/src/store/state.rs‎

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,19 @@ pub(crate) struct State {
2626
pub(crate) roots: gc::Roots,
2727
}
2828

29-
// Dispatch once per operation, keeping ordinary memory on the direct-access path
29+
// Dispatch once per operation, keeping ordinary memory on the direct-access path.
30+
// A shared memory runs the body out of line, under its lock, so the handlers of
31+
// ordinary memory accesses contain no lock/unlock calls and spill no registers for them.
32+
// Atomic operations, whose memory is usually shared, keep the lock inline with
33+
// `with_memory!(@lock_inline ...)`.
3034
macro_rules! with_memory {
31-
($state:expr, $addr:expr, |$memory:ident, $kind:ident| $body:block) => {{
35+
(@lock_inline $state:expr, $addr:expr, |$memory:ident, $kind:ident| $body:block) => {{
3236
let state = &mut $state;
3337
let addr = $addr;
3438
#[cfg(feature = "std")]
3539
let mut guard;
3640
#[cfg(feature = "std")]
3741
let (kind, bytes) = if addr & $crate::store::SHARED_MEM_BIT != 0 {
38-
core::hint::cold_path();
3942
guard = state.shared_memories[(addr & !$crate::store::SHARED_MEM_BIT) as usize].lock();
4043
(guard.kind, &mut *guard.inner)
4144
} else {
@@ -52,9 +55,50 @@ macro_rules! with_memory {
5255
let $memory = bytes;
5356
$body
5457
}};
58+
($state:expr, $addr:expr, |$memory:ident, $kind:ident| $body:block) => {{
59+
let state = &mut $state;
60+
let addr = $addr;
61+
#[cfg(feature = "std")]
62+
let result = if addr & $crate::store::SHARED_MEM_BIT != 0 {
63+
$crate::store::with_shared_memory(state, addr, |$memory, kind| {
64+
#[allow(unused_variables)]
65+
let $kind = kind;
66+
$body
67+
})
68+
} else {
69+
let ordinary = &mut state.memories[addr as usize];
70+
#[allow(unused_variables)]
71+
let $kind = ordinary.kind;
72+
let $memory = &mut ordinary.inner;
73+
$body
74+
};
75+
#[cfg(not(feature = "std"))]
76+
let result = {
77+
let ordinary = state.get_mem_mut(addr);
78+
#[allow(unused_variables)]
79+
let $kind = ordinary.kind;
80+
let $memory = &mut ordinary.inner;
81+
$body
82+
};
83+
result
84+
}};
5585
}
5686
pub(crate) use with_memory;
5787

88+
/// Runs `body` on a shared memory's bytes while holding its lock.
89+
#[cfg(feature = "std")]
90+
#[cold]
91+
#[inline(never)]
92+
pub(crate) fn with_shared_memory<R>(
93+
state: &mut State,
94+
addr: MemAddr,
95+
body: impl FnOnce(&mut super::memory::MemoryStorage, MemoryType) -> R,
96+
) -> R {
97+
let mut guard = state.shared_memories[(addr & !SHARED_MEM_BIT) as usize].lock();
98+
let kind = guard.kind;
99+
body(&mut guard.inner, kind)
100+
}
101+
58102
impl State {
59103
/// Returns the immutable memory type without taking a shared-memory lock.
60104
pub(crate) fn memory_type(&self, addr: MemAddr) -> MemoryType {
@@ -120,11 +164,11 @@ impl State {
120164
// Never hold two backing locks, since another store may copy in the opposite direction.
121165
// Growth cannot invalidate these ranges. Check both before changing the destination.
122166
with_memory!(*self, src_addr, |source, kind| {
123-
source.checked_range(src, size).ok_or_else(|| memory_oob(src, size, source.len()))?;
124-
});
167+
source.checked_range(src, size).ok_or_else(|| memory_oob(src, size, source.len()))
168+
})?;
125169
with_memory!(*self, dst_addr, |destination, kind| {
126-
destination.checked_range(dst, size).ok_or_else(|| memory_oob(dst, size, destination.len()))?;
127-
});
170+
destination.checked_range(dst, size).ok_or_else(|| memory_oob(dst, size, destination.len()))
171+
})?;
128172
let mut bytes = [0u8; 4096];
129173
for offset in (0..size).step_by(bytes.len()) {
130174
let chunk = bytes.len().min(size - offset);

0 commit comments

Comments
 (0)