Skip to content

Commit 23116b1

Browse files
committed
feat: add LinearMemory implementation
This implementation of linear memory is capable of being shared between multiple, concurrently running interpreter instances. All access to the actual data goes through raw pointers, derived from `UnsafeCell`s. While this allows race-conditions, it avoids the other kinds of UB that may be caused by code that violates the guarantees attached to shared and mutable references. Signed-off-by: wucke13 <wucke13+github@gmail.com>
1 parent 52a8efa commit 23116b1

File tree

2 files changed

+319
-0
lines changed

2 files changed

+319
-0
lines changed

src/execution/linear_memory.rs

Lines changed: 318 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,318 @@
1+
use core::{cell::UnsafeCell, mem};
2+
3+
use alloc::vec::Vec;
4+
5+
use crate::{core::little_endian::LittleEndianBytes, spinlock::RwSpinLock, RuntimeError};
6+
7+
/// Implementation of the linear memory suitable for concurrent access
8+
///
9+
/// This linear memory implementation internally relies on a `Vec<UnsafeCell<u8>>`. Thus, the atomic unit of information for it is a byte (`u8`). All access to the linear memory internally occurs through pointers, avoiding the creation of shared and mut refs to the internal data completely. This avoids undefined behavior, except for the race-condition inherent to concurrent writes. Because of this, the [`LinearMemory::store`] function does not require `&mut self` -- `&self` suffices.
10+
///
11+
/// # Notes on locking
12+
///
13+
/// The internal data vector of the [`LinearMemory`] is wrapped in a [`RwLock`]. Despite the
14+
/// name, writes to the linear memory do not require an acquisition of a write lock. Writes are
15+
/// implemented through a shared ref to the internal vector, with an `UnsafeCell` to achieve
16+
/// interior mutability.
17+
///
18+
/// However, linear memory can grow. As the linear memory is implemented via a [`Vec`], a grow can
19+
/// result in the vector's internal data buffer to be copied over to a bigger, fresh allocation.
20+
/// The old buffer is then freed. Combined with concurrent mutable access, this can cause
21+
/// use-after-free. To avoid this, a grow operation of the linear memory acquires a write lock,
22+
/// blocking all read/write to the linear memory inbetween.
23+
///
24+
/// # Unsafe Note
25+
///
26+
/// Raw pointer access it required, because concurent mutation of the linear memory might happen
27+
/// (consider the threading proposal for WASM, where mutliple WASM threads access the same linear
28+
/// memory at the same time). The inherent race condition results in UB w/r/t the state of the `u8`s
29+
/// in the inner data. However, this is tolerable, e.g. avoiding race conditions on the state of the
30+
/// linear memory can not be the task of the interpreter, but has to be fulfilled by the interpreted
31+
/// bytecode itself.
32+
// TODO if a memmap like operation is available, the linear memory implementation can be optimized brutally. Out-of-bound access can be mapped to userspace handled page-faults, e.g. the MMU takes over that responsibility of catching out of bounds. Grow can happen without copying of data, by mapping new pages consecutively after the current final page of the linear memory.
33+
pub struct LinearMemory<const PAGE_SIZE: usize = { 1 << 16 }> {
34+
inner_data: RwSpinLock<Vec<UnsafeCell<u8>>>,
35+
}
36+
37+
impl<const PAGE_SIZE: usize> LinearMemory<PAGE_SIZE> {
38+
const PAGE_SIZE: usize = PAGE_SIZE;
39+
40+
/// Create a new, empty [`LinearMemory`]
41+
pub fn new() -> Self {
42+
Self {
43+
inner_data: RwSpinLock::new(Vec::new()),
44+
}
45+
}
46+
47+
/// Create a new, empty [`LinearMemory`]
48+
pub fn new_with_capacity(pages: u16) -> Self {
49+
let size_bytes = Self::PAGE_SIZE * pages as usize;
50+
let mut data = Vec::with_capacity(size_bytes);
51+
data.resize_with(size_bytes, || UnsafeCell::new(0));
52+
53+
Self {
54+
inner_data: RwSpinLock::new(data),
55+
}
56+
}
57+
58+
/// Grow the [`LinearMemory`] by a number of pages
59+
pub fn grow(&self, pages_to_add: i16) {
60+
let mut lock_guard = self.inner_data.write();
61+
let prior_length_bytes = lock_guard.len();
62+
let new_length_bytes = prior_length_bytes + Self::PAGE_SIZE * pages_to_add as usize;
63+
lock_guard.resize_with(new_length_bytes, || UnsafeCell::new(0));
64+
}
65+
66+
/// Get the number of pages currently allocated to this [`LinearMemory`]
67+
pub fn pages(&self) -> u16 {
68+
u16::try_from(self.inner_data.read().len() / PAGE_SIZE).unwrap()
69+
}
70+
71+
/// At a given index, store a datum in the [`LinearMemory`]
72+
pub fn store<const N: usize, T: LittleEndianBytes<N>>(
73+
&self,
74+
index: i32,
75+
value: T,
76+
) -> Result<(), RuntimeError> {
77+
let value_size = mem::size_of::<T>();
78+
79+
let lock_guard = self.inner_data.read();
80+
let ptr = lock_guard.get(index as usize).unwrap().get();
81+
let bytes = value.to_le_bytes(); //
82+
83+
// A value must fit into the linear memory
84+
assert!(
85+
value_size <= lock_guard.len(),
86+
"value does not fit into linear memory"
87+
);
88+
89+
// The following statement must be true
90+
// `index + value_size <= lock_guard.len()`
91+
// This check verifies it, while avoiding the possible overflow. The subtraction can not
92+
// underflow because of the previous assert.
93+
assert!(
94+
(index as usize) <= lock_guard.len() - value_size,
95+
"value write would extend beyond the end of the linear_memory"
96+
);
97+
98+
// Safety argument:
99+
//
100+
// - nonoverlapping is guaranteed, because `src` is a pointer to a stack allocated array,
101+
// while the destination is heap allocated Vec
102+
// - the first assert above guarantee that `src` fits into the destination
103+
// - the second assert above guarantees that even with the offset in `index`, `src` does not
104+
// extend beyond the destinations last `UnsafeCell<u8>`
105+
// - the use of `UnsafeCell` avoids any `&` or `&mut` to ever be created on any of the `u8`s
106+
// contained in the `UnsafeCell`s, so no UB is created through the existence of unsound
107+
// references
108+
unsafe { ptr.copy_from_nonoverlapping(bytes.as_ref().as_ptr(), value_size) }
109+
110+
Ok(())
111+
}
112+
113+
/// From a given index, load a datum in the [`LinearMemory`]
114+
pub fn load<const N: usize, T: LittleEndianBytes<N>>(
115+
&self,
116+
index: i32,
117+
) -> Result<T, RuntimeError> {
118+
let value_size = mem::size_of::<T>();
119+
assert_eq!(value_size, N, "value size must match const generic N");
120+
121+
let lock_guard = self.inner_data.read();
122+
let ptr = lock_guard.get(index as usize).unwrap().get();
123+
let mut bytes = [0; N];
124+
125+
// A value must fit into the linear memory
126+
assert!(
127+
value_size <= lock_guard.len(),
128+
"value does not fit into linear memory"
129+
);
130+
131+
// The following statement must be true
132+
// `index + value_size <= lock_guard.len()`
133+
// This check verifies it, while avoiding the possible overflow. The subtraction can not
134+
// underflow because of the previous assert.
135+
assert!(
136+
(index as usize) <= lock_guard.len() - value_size,
137+
"value read would extend beyond the end of the linear_memory"
138+
);
139+
140+
// Safety argument:
141+
//
142+
// - nonoverlapping is guaranteed, because `dest` is a pointer to a stack allocated array,
143+
// while the source is heap allocated Vec
144+
// - the first assert above guarantee that source is bigger than `dest`
145+
// - the second assert above guarantees that even with the offset in `index`, `dest` does
146+
// not extend beyond the destinations last `UnsafeCell<u8>` in source
147+
// - the use of `UnsafeCell` avoids any `&` or `&mut` to ever be created on any of the `u8`s
148+
// contained in the `UnsafeCell`s, so no UB is created through the existence of unsound
149+
// references
150+
unsafe { ptr.copy_to_nonoverlapping(bytes.as_mut_ptr(), bytes.len()) };
151+
Ok(T::from_le_bytes(bytes))
152+
}
153+
}
154+
155+
impl<const PAGE_SIZE: usize> core::fmt::Debug for LinearMemory<PAGE_SIZE> {
156+
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
157+
write!(f, "LinearMemory {{ inner_data: [ ")?;
158+
let lock_guard = self.inner_data.read();
159+
let mut iter = lock_guard.iter();
160+
161+
if let Some(first_byte_uc) = iter.next() {
162+
write!(f, "{}", unsafe { *first_byte_uc.get() })?;
163+
}
164+
165+
for uc in iter {
166+
// Safety argument:
167+
//
168+
// TODO
169+
let byte = unsafe { *uc.get() };
170+
171+
write!(f, ", {byte}")?;
172+
}
173+
write!(f, " ] }}")
174+
}
175+
}
176+
177+
impl<const PAGE_SIZE: usize> Default for LinearMemory<PAGE_SIZE> {
178+
fn default() -> Self {
179+
Self::new()
180+
}
181+
}
182+
183+
#[cfg(test)]
184+
mod test {
185+
use alloc::format;
186+
187+
use super::*;
188+
189+
const PAGE_SIZE: usize = 1 << 8;
190+
const PAGES: u16 = 2;
191+
192+
#[test]
193+
fn new_constructor() {
194+
let lin_mem = LinearMemory::<PAGE_SIZE>::new();
195+
assert_eq!(lin_mem.pages(), 0);
196+
}
197+
198+
#[test]
199+
fn new_grow() {
200+
let lin_mem = LinearMemory::<PAGE_SIZE>::new();
201+
lin_mem.grow(1);
202+
assert_eq!(lin_mem.pages(), 1);
203+
}
204+
205+
#[test]
206+
fn debug_print() {
207+
let lin_mem = LinearMemory::<PAGE_SIZE>::new_with_capacity(1);
208+
assert_eq!(lin_mem.pages(), 1);
209+
210+
let expected_length = "LinearMemory { inner_data: [ ] }".len() + PAGE_SIZE * "0, ".len();
211+
let tol = 2;
212+
213+
let debug_repr = format!("{lin_mem:?}");
214+
let lower_bound = expected_length - tol;
215+
let upper_bound = expected_length + tol;
216+
assert!((lower_bound..upper_bound).contains(&debug_repr.len()));
217+
}
218+
219+
#[test]
220+
fn roundtrip_normal_range_i8_neg127() {
221+
let x: i8 = -127;
222+
let highest_legal_offset = PAGE_SIZE - mem::size_of::<i8>();
223+
for offset in 0..i32::try_from(highest_legal_offset).unwrap() {
224+
let lin_mem = LinearMemory::<PAGE_SIZE>::new_with_capacity(PAGES);
225+
226+
lin_mem.store(offset, x).unwrap();
227+
228+
assert_eq!(
229+
lin_mem
230+
.load::<{ core::mem::size_of::<i8>() }, i8>(offset)
231+
.unwrap(),
232+
x,
233+
"load store roundtrip for {x:?} failed!"
234+
);
235+
}
236+
}
237+
238+
#[test]
239+
fn roundtrip_normal_range_f32_13() {
240+
let x: f32 = 13.0;
241+
let highest_legal_offset = PAGE_SIZE - mem::size_of::<f32>();
242+
for offset in 0..i32::try_from(highest_legal_offset).unwrap() {
243+
let lin_mem = LinearMemory::<PAGE_SIZE>::new_with_capacity(PAGES);
244+
245+
lin_mem.store(offset, x).unwrap();
246+
247+
assert_eq!(
248+
lin_mem
249+
.load::<{ core::mem::size_of::<f32>() }, f32>(offset)
250+
.unwrap(),
251+
x,
252+
"load store roundtrip for {x:?} failed!"
253+
);
254+
}
255+
}
256+
257+
#[test]
258+
fn roundtrip_normal_range_f64_min() {
259+
let x: f64 = f64::MIN;
260+
let highest_legal_offset = PAGE_SIZE - mem::size_of::<f64>();
261+
for offset in 0..i32::try_from(highest_legal_offset).unwrap() {
262+
let lin_mem = LinearMemory::<PAGE_SIZE>::new_with_capacity(PAGES);
263+
264+
lin_mem.store(offset, x).unwrap();
265+
266+
assert_eq!(
267+
lin_mem
268+
.load::<{ core::mem::size_of::<f64>() }, f64>(offset)
269+
.unwrap(),
270+
x,
271+
"load store roundtrip for {x:?} failed!"
272+
);
273+
}
274+
}
275+
276+
#[test]
277+
fn roundtrip_normal_range_f64_nan() {
278+
let x: f64 = f64::NAN;
279+
let highest_legal_offset = PAGE_SIZE - mem::size_of::<f64>();
280+
for offset in 0..i32::try_from(highest_legal_offset).unwrap() {
281+
let lin_mem = LinearMemory::<PAGE_SIZE>::new_with_capacity(PAGES);
282+
283+
lin_mem.store(offset, x).unwrap();
284+
285+
assert!(
286+
lin_mem
287+
.load::<{ core::mem::size_of::<f64>() }, f64>(offset)
288+
.unwrap()
289+
.is_nan(),
290+
"load store roundtrip for {x:?} failed!"
291+
);
292+
}
293+
}
294+
295+
#[test]
296+
#[should_panic(expected = "value write would extend beyond the end of the linear_memory")]
297+
fn store_out_of_range_u128_max() {
298+
let x: u128 = u128::MAX;
299+
let pages = 1;
300+
let lowest_illegal_offset = PAGE_SIZE - mem::size_of::<u128>() + 1;
301+
let lowest_illegal_offset = i32::try_from(lowest_illegal_offset).unwrap();
302+
let lin_mem = LinearMemory::<PAGE_SIZE>::new_with_capacity(pages);
303+
304+
lin_mem.store(lowest_illegal_offset, x).unwrap();
305+
}
306+
307+
#[test]
308+
#[should_panic(expected = "called `Option::unwrap()` on a `None` value")]
309+
fn store_empty_lineaer_memory_u8() {
310+
let x: u8 = u8::MAX;
311+
let pages = 0;
312+
let lowest_illegal_offset = PAGE_SIZE - mem::size_of::<u8>() + 1;
313+
let lowest_illegal_offset = i32::try_from(lowest_illegal_offset).unwrap();
314+
let lin_mem = LinearMemory::<PAGE_SIZE>::new_with_capacity(pages);
315+
316+
lin_mem.store(lowest_illegal_offset, x).unwrap();
317+
}
318+
}

src/execution/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub(crate) mod execution_info;
3333
pub mod function_ref;
3434
pub mod hooks;
3535
mod interpreter_loop;
36+
pub(crate) mod linear_memory;
3637
pub(crate) mod locals;
3738
pub(crate) mod lut;
3839
pub(crate) mod store;

0 commit comments

Comments
 (0)