diff --git a/Cargo.lock b/Cargo.lock index cb6377884fc..dfd0fdee61a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -453,6 +453,7 @@ name = "boa_gc" version = "0.20.0" dependencies = [ "boa_macros", + "boa_mempool", "boa_string", "either", "hashbrown 0.16.0", @@ -527,6 +528,10 @@ dependencies = [ "trybuild", ] +[[package]] +name = "boa_mempool" +version = "0.20.0" + [[package]] name = "boa_parser" version = "0.20.0" diff --git a/Cargo.toml b/Cargo.toml index f753e9d3085..9c8ca77c1ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,8 +18,8 @@ members = [ exclude = [ "tests/fuzz", # Does weird things on Windows tests - "tests/src", # Just a hack to have fuzz inside tests - "tests/wpt", # Should not run WPT by default. + "tests/src", # Just a hack to have fuzz inside tests + "tests/wpt", # Should not run WPT by default. ] [workspace.package] @@ -40,6 +40,7 @@ boa_gc = { version = "~0.20.0", path = "core/gc" } boa_icu_provider = { version = "~0.20.0", path = "core/icu_provider" } boa_interner = { version = "~0.20.0", path = "core/interner" } boa_macros = { version = "~0.20.0", path = "core/macros" } +boa_mempool = { version = "~0.20.0", path = "core/mempool" } boa_parser = { version = "~0.20.0", path = "core/parser" } boa_runtime = { version = "~0.20.0", path = "core/runtime" } boa_string = { version = "~0.20.0", path = "core/string" } diff --git a/core/gc/Cargo.toml b/core/gc/Cargo.toml index ab2fe161942..64c8ffdfe1d 100644 --- a/core/gc/Cargo.toml +++ b/core/gc/Cargo.toml @@ -22,6 +22,7 @@ either = ["dep:either"] [dependencies] boa_macros.workspace = true +boa_mempool.workspace = true hashbrown.workspace = true boa_string = { workspace = true, optional = true } @@ -29,6 +30,7 @@ either = { workspace = true, optional = true } thin-vec = { workspace = true, optional = true } icu_locale_core = { workspace = true, optional = true } + [lints] workspace = true diff --git a/core/gc/src/internals/vtable.rs b/core/gc/src/internals/vtable.rs index 9ecda8109a0..eb580198107 100644 --- a/core/gc/src/internals/vtable.rs +++ b/core/gc/src/internals/vtable.rs @@ -1,7 +1,7 @@ +use crate::{GcBox, GcErasedPointer, MEM_POOL_ELEMENT_SIZE_THRESHOLD, Trace, Tracer}; +use boa_mempool::MemPoolAllocator; use std::any::TypeId; -use crate::{GcBox, GcErasedPointer, Trace, Tracer}; - // Workaround: https://users.rust-lang.org/t/custom-vtables-with-integers/78508 pub(crate) const fn vtable_of() -> &'static VTable { trait HasVTable: Trace + Sized + 'static { @@ -35,12 +35,22 @@ pub(crate) const fn vtable_of() -> &'static VTable { } // SAFETY: The caller must ensure that the passed erased pointer is `GcBox`. - unsafe fn drop_fn(this: GcErasedPointer) { + unsafe fn drop_fn( + this: GcErasedPointer, + pool: &MemPoolAllocator<[u8; MEM_POOL_ELEMENT_SIZE_THRESHOLD]>, + ) { // SAFETY: The caller must ensure that the passed erased pointer is `GcBox`. let this = this.cast::>(); - // SAFETY: The caller must ensure the erased pointer is not droped or deallocated. - let _value = unsafe { Box::from_raw(this.as_ptr()) }; + if pool.contains(this.cast()) { + // SAFETY: The caller must ensure the erased pointer is not dropped or deallocated. + unsafe { + drop(this.read()); + pool.dealloc_no_drop(this.cast()); + } + } else { + drop(unsafe { Box::from_raw(this.as_ptr()) }); + } } fn type_id_fn() -> TypeId { @@ -67,7 +77,10 @@ pub(crate) const fn vtable_of() -> &'static VTable { pub(crate) type TraceFn = unsafe fn(this: GcErasedPointer, tracer: &mut Tracer); pub(crate) type TraceNonRootsFn = unsafe fn(this: GcErasedPointer); pub(crate) type RunFinalizerFn = unsafe fn(this: GcErasedPointer); -pub(crate) type DropFn = unsafe fn(this: GcErasedPointer); +pub(crate) type DropFn = unsafe fn( + this: GcErasedPointer, + pool: &MemPoolAllocator<[u8; MEM_POOL_ELEMENT_SIZE_THRESHOLD]>, +); pub(crate) type TypeIdFn = fn() -> TypeId; #[derive(Debug)] diff --git a/core/gc/src/lib.rs b/core/gc/src/lib.rs index b5430f94844..ca7a3ca546a 100644 --- a/core/gc/src/lib.rs +++ b/core/gc/src/lib.rs @@ -23,31 +23,36 @@ mod trace; pub(crate) mod internals; +pub use crate::trace::{Finalize, Trace, Tracer}; +pub use boa_macros::{Finalize, Trace}; +use boa_mempool::MemPoolAllocator; +pub use cell::{GcRef, GcRefCell, GcRefMut}; +pub use internals::GcBox; use internals::{EphemeronBox, ErasedEphemeronBox, ErasedWeakMapBox, WeakMapBox}; +pub use pointers::{Ephemeron, Gc, GcErased, WeakGc, WeakMap}; use pointers::{NonTraceable, RawWeakMap}; +use std::collections::BTreeMap; use std::{ cell::{Cell, RefCell}, mem, ptr::NonNull, }; -pub use crate::trace::{Finalize, Trace, Tracer}; -pub use boa_macros::{Finalize, Trace}; -pub use cell::{GcRef, GcRefCell, GcRefMut}; -pub use internals::GcBox; -pub use pointers::{Ephemeron, Gc, GcErased, WeakGc, WeakMap}; +const MEM_POOL_ELEMENT_SIZE_THRESHOLD: usize = 256; type GcErasedPointer = NonNull>; type EphemeronPointer = NonNull; type ErasedWeakMapBoxPointer = NonNull; thread_local!(static GC_DROPPING: Cell = const { Cell::new(false) }); -thread_local!(static BOA_GC: RefCell = RefCell::new( BoaGc { +thread_local!(static BOA_GC: RefCell = RefCell::new(BoaGc { config: GcConfig::default(), runtime: GcRuntimeData::default(), strongs: Vec::default(), weaks: Vec::default(), weak_maps: Vec::default(), + pool: MemPoolAllocator::with_capacity(102_400), + stats: Default::default(), })); #[derive(Debug, Clone, Copy)] @@ -84,6 +89,8 @@ struct BoaGc { strongs: Vec, weaks: Vec, weak_maps: Vec, + pool: MemPoolAllocator<[u8; MEM_POOL_ELEMENT_SIZE_THRESHOLD]>, + stats: BTreeMap, } impl Drop for BoaGc { @@ -134,8 +141,21 @@ impl Allocator { let mut gc = st.borrow_mut(); Self::manage_state(&mut gc); - // Safety: value cannot be a null pointer, since `Box` cannot return null pointers. - let ptr = unsafe { NonNull::new_unchecked(Box::into_raw(Box::new(value))) }; + // Safety: value cannot be a null pointer, since `MemPool` cannot return null pointers. + let ptr = unsafe { + gc.stats + .entry(element_size) + .and_modify(|v| *v += 1) + .or_insert(1); + + if size_of::>() <= MEM_POOL_ELEMENT_SIZE_THRESHOLD { + let ptr = gc.pool.alloc_unitialized().cast(); + ptr.write(value); + ptr + } else { + NonNull::new_unchecked(Box::into_raw(Box::new(value))) + } + }; let erased: NonNull> = ptr.cast(); gc.strongs.push(erased); @@ -250,6 +270,7 @@ impl Collector { &mut gc.strongs, &mut gc.weaks, &mut gc.runtime.bytes_allocated, + &gc.pool, ); } @@ -266,7 +287,11 @@ impl Collector { // SAFETY: // The `Allocator` must always ensure its start node is a valid, non-null pointer that // was allocated by `Box::from_raw(Box::new(..))`. - let _unmarked_node = unsafe { Box::from_raw(w.as_ptr()) }; + unsafe { + if !gc.pool.dealloc(NonNull::new_unchecked(w.as_ptr().cast())) { + drop(Box::from_raw(w.as_ptr())); + } + } false } @@ -447,6 +472,7 @@ impl Collector { strong: &mut Vec, weak: &mut Vec, total_allocated: &mut usize, + pool: &MemPoolAllocator<[u8; MEM_POOL_ELEMENT_SIZE_THRESHOLD]>, ) { let _guard = DropGuard::new(); @@ -467,7 +493,7 @@ impl Collector { // SAFETY: The function pointer is appropriate for this node type because we extract it from it's VTable. unsafe { - drop_fn(*node); + drop_fn(*node, pool); } false @@ -497,6 +523,10 @@ impl Collector { // Clean up the heap when BoaGc is dropped fn dump(gc: &mut BoaGc) { + eprintln!("GC Stats:"); + eprintln!("{:#?}", gc.stats); + eprintln!("------------------------------------------------------------\n"); + // Weak maps have to be dropped first, since the process dereferences GcBoxes. // This can be done without initializing a dropguard since no GcBox's are being dropped. for node in mem::take(&mut gc.weak_maps) { @@ -517,7 +547,7 @@ impl Collector { // SAFETY: The function pointer is appropriate for this node type because we extract it from it's VTable. unsafe { - drop_fn(node); + drop_fn(node, &gc.pool); } } diff --git a/core/mempool/ABOUT.md b/core/mempool/ABOUT.md new file mode 100644 index 00000000000..c45c8a107c8 --- /dev/null +++ b/core/mempool/ABOUT.md @@ -0,0 +1,40 @@ +# About Boa + +Boa is an open-source, experimental ECMAScript Engine written in Rust for +lexing, parsing and executing ECMAScript/JavaScript. Currently, Boa supports some +of the [language][boa-conformance]. More information can be viewed at [Boa's +website][boa-web]. + +Try out the most recent release with Boa's live demo +[playground][boa-playground]. + +## Boa Crates + +- [**`boa_cli`**][cli] - Boa's CLI && REPL implementation +- [**`boa_ast`**][ast] - Boa's ECMAScript Abstract Syntax Tree. +- [**`boa_engine`**][engine] - Boa's implementation of ECMAScript builtin objects and execution. +- [**`boa_gc`**][gc] - Boa's garbage collector. +- [**`boa_icu_provider`**][icu] - Boa's ICU4X data provider. +- [**`boa_interner`**][interner] - Boa's string interner. +- [**`boa_macros`**][macros] - Boa's macros. +- [**`boa_parser`**][parser] - Boa's lexer and parser. +- [**`boa_runtime`**][runtime] - Boa's `WebAPI` features. +- [**`boa_string`**][string] - Boa's ECMAScript string implementation. +- [**`tag_ptr`**][tag_ptr] - Utility library that enables a pointer to be associated with a tag of type `usize`. +- [**`small_btree`**][small_btree] - Utility library that adds the `SmallBTreeMap` data structure. + +[boa-conformance]: https://boajs.dev/conformance +[boa-web]: https://boajs.dev/ +[boa-playground]: https://boajs.dev/playground +[ast]: https://docs.rs/boa_ast/latest/boa_ast/index.html +[engine]: https://docs.rs/boa_engine/latest/boa_engine/index.html +[gc]: https://docs.rs/boa_gc/latest/boa_gc/index.html +[interner]: https://docs.rs/boa_interner/latest/boa_interner/index.html +[parser]: https://docs.rs/boa_parser/latest/boa_parser/index.html +[icu]: https://docs.rs/boa_icu_provider/latest/boa_icu_provider/index.html +[runtime]: https://docs.rs/boa_runtime/latest/boa_runtime/index.html +[string]: https://docs.rs/boa_string/latest/boa_string/index.html +[tag_ptr]: https://docs.rs/tag_ptr/latest/tag_ptr/index.html +[small_btree]: https://docs.rs/small_btree/latest/small_btree/index.html +[macros]: https://docs.rs/boa_macros/latest/boa_macros/index.html +[cli]: https://crates.io/crates/boa_cli diff --git a/core/mempool/Cargo.toml b/core/mempool/Cargo.toml new file mode 100644 index 00000000000..7f348e3cefb --- /dev/null +++ b/core/mempool/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "boa_mempool" +description = "Pool Allocator for the Boa JavaScript engine." +keywords = ["javascript", "js", "alloc", "memory"] +categories = ["memory-management"] +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[features] + +[dependencies] + +[lints] +workspace = true + +[package.metadata.docs.rs] +all-features = true diff --git a/core/mempool/src/lib.rs b/core/mempool/src/lib.rs new file mode 100644 index 00000000000..7c0b9ab44d4 --- /dev/null +++ b/core/mempool/src/lib.rs @@ -0,0 +1,303 @@ +//! Boa's **`boa_mempool`** crate implements a simple memory pool allocator. +//! +//! # Crate Overview +//! More stuff to be explained later. +#![doc = include_str!("../ABOUT.md")] +#![doc( + html_logo_url = "https://raw.githubusercontent.com/boa-dev/boa/main/assets/logo_black.svg", + html_favicon_url = "https://raw.githubusercontent.com/boa-dev/boa/main/assets/logo_black.svg" +)] +#![cfg_attr(not(test), forbid(clippy::unwrap_used))] + +use std::alloc::{Layout, alloc, dealloc}; +use std::cell::RefCell; +use std::fmt::Debug; +use std::ptr::NonNull; + +#[cfg(test)] +mod tests; + +/// TODO: Make this related to cache size or something. +const THRESHOLD: usize = 5120; + +const BASE_CAPACITY: usize = 1024; + +/// An empty slot is a reference (indices within the same pool) to the next free item +/// after this one. +type EmptySlot = usize; + +/// A single pool allocated. +struct Chunk { + layout: Layout, + total: usize, + available: usize, + next: usize, + slots: *mut T, +} + +impl Chunk { + /// Create a new pool without checking that `count * size_of::()` is valid. + #[must_use] + fn new_unchecked(count: usize) -> Self { + // Statically assert that the size of a unit is bigger than the size expected + // for the empty slot. + let _: () = const { + assert!(size_of::() >= size_of::()); + }; + + let layout = Layout::array::(count) + .and_then(|l| l.align_to(align_of::())) + .expect("Could not allocate this pool.") + .pad_to_align(); + + // SAFETY: This will panic if memory or count is not right, which is safe. + let slots: *mut T = unsafe { alloc(layout).cast() }; + + // The first slot should always be pointing to itself as an `EmptySlot`. + // SAFETY: We statically validated that `size_of::() > size_of::()`. + unsafe { + *slots.cast::() = 0; + } + + Self { + layout, + total: count, + available: count, + next: 0, + slots, + } + } + + /// Allocate a new block. + #[inline] + #[must_use] + fn alloc(&mut self) -> Option> { + if self.available == 0 { + return None; + } + + // Reduce availability. + self.available -= 1; + + // Get an empty slot. + // SAFETY: If `self.availability > 0`, `self.next` points to within our slots. + let ptr = unsafe { self.slots.add(self.next) }; + // SAFETY: We statically ensure `size_of:: > size_of::`. + let next: EmptySlot = unsafe { *ptr.cast::() }; + + // Move next to the next one. + // If `next` is itself, we know that we haven't allocated past this, + // so we move to the next slot and update it to be itself as well. + // If `next` is different, we just set next to next. + if next == self.next { + self.next += 1; + // Unless there's no available in this case, `self.next` points to + // past the pool at this point. + if self.available > 0 { + unsafe { + self.slots + .add(self.next) + .cast::() + .write(self.next); + } + } + } else { + self.next = next; + } + + // SAFETY: We know `ptr` to be within the bounds of our memory. + Some(unsafe { NonNull::new_unchecked(ptr) }) + } + + #[inline] + fn find_slot(&self, ptr: NonNull) -> Option { + if ptr.addr().get() < self.slots.addr() { + return None; + } + let slot_index = (ptr.addr().get() - self.slots.addr()) / size_of::(); + if slot_index >= self.total { + return None; + } + Some(slot_index) + } + + /// Free the memory and set its `EmptySlot` value properly. + /// Returns false if the pointer is not contained in this pool. + #[inline] + fn dealloc_no_drop(&mut self, ptr: NonNull) -> bool { + let Some(slot_index) = self.find_slot(ptr) else { + return false; + }; + + // SAFETY: We know by now that slot_index is between 0 and `self.total`. + unsafe { + ptr.cast::().write(self.next); + } + + self.next = slot_index; + self.available += 1; + true + } + + /// Free the memory, call `T::drop` and set its `EmptySlot` value properly. + /// Returns false if the pointer is not contained in this pool. + #[inline] + fn dealloc(&mut self, ptr: NonNull) -> bool { + let Some(slot_index) = self.find_slot(ptr) else { + return false; + }; + + // Call `drop(...)` on the type and cleanup. + // SAFETY: We know by now that slot_index is between 0 and `self.total`. + unsafe { + let ptr = self.slots.add(slot_index); + let _unused = ptr.read(); + ptr.cast::().write(self.next); + } + + self.next = slot_index; + self.available += 1; + true + } +} + +impl Drop for Chunk { + fn drop(&mut self) { + // SAFETY: We use the same layout, so this is sure to work. + unsafe { + dealloc(self.slots.cast(), self.layout); + } + } +} + +/// A simple Pool-based memory allocator. This is not thread safe. `T` must +/// have a size larger than `usize`. +/// +/// ```compile_fail +/// let pool = boa_mempool::MemPoolAllocator::::new(); +/// ``` +pub struct MemPoolAllocator { + pools: RefCell>>, +} + +impl Debug for MemPoolAllocator { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("MemPoolAllocator") + .field("allocated", &self.allocated()) + .field("available", &self.available()) + .finish() + } +} + +impl Default for MemPoolAllocator { + fn default() -> Self { + Self::new() + } +} + +impl MemPoolAllocator { + /// Create a new empty allocator. Capacity will grow with allocations. + #[must_use] + pub fn new() -> Self { + Self::with_capacity(BASE_CAPACITY) + } + + /// Create an allocator with `capacity` amount of `T`s. That is, the total + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + // Double-check that the total capacity doesn't exceed `usize`. + debug_assert!(capacity.checked_mul(size_of::()).is_some()); + + Self { + pools: RefCell::new(vec![Chunk::::new_unchecked(capacity)]), + } + } + + /// Allocate a new slot and return a pointer to it. + /// + /// # Panics + /// If allocating a new pool region fails, this will panic. Otherwise, it can't. + /// + /// # Safety + /// It is the responsibility of the caller to initialize this memory. + #[must_use] + pub unsafe fn alloc_unitialized(&self) -> NonNull { + let mut pools = self.pools.borrow_mut(); + // Find the first pool with an unused slot. Use reverse because + // the last pool is the most likely one to have availability. + if let Some(p) = pools.iter_mut().find_map(Chunk::alloc) { + p + } else { + // Allocate twice the last allocation if smaller than THRESHOLD, or 20% more otherwise. + let last_total = pools + .last() + .expect("There should always be at least one pool.") + .total; + let mut new_pool = Chunk::::new_unchecked(if last_total < THRESHOLD { + last_total * 2 + } else { + last_total + last_total / 20 + }); + let ptr = new_pool.alloc().expect("Could not allocate memory."); + pools.push(new_pool); + ptr + } + } + + /// Allocate the memory and write the value in it. + #[inline] + #[must_use] + pub fn alloc(&self, value: T) -> NonNull { + // Safety: We'll initialize, don't worry. + unsafe { + let ptr = self.alloc_unitialized(); + ptr.write(value); + ptr + } + } + + /// Returns true if the pointer is contained within this pool. + pub fn contains(&self, ptr: NonNull) -> bool { + self.pools + .borrow() + .iter() + .any(|p| p.find_slot(ptr).is_some()) + } + + /// Deallocate an existing slot without dropping its contained value. + /// If the pointer is not within our pool, this will do nothing and + /// return `false`. + /// + /// # Safety + /// It is the responsibility of the caller to make sure this value is + /// dropped or does not implement the `Drop` trait. + pub unsafe fn dealloc_no_drop(&self, ptr: NonNull) -> bool { + self.pools + .borrow_mut() + .iter_mut() + .any(|p| p.dealloc_no_drop(ptr)) + } + + /// Deallocate an existing slot, calling `T::Drop` on its contained value. + /// If the pointer is not within our pool, this will do nothing and return + /// `false`. + pub fn dealloc(&self, ptr: NonNull) -> bool { + self.pools.borrow_mut().iter_mut().any(|p| p.dealloc(ptr)) + } + + /// Return the total capacity of the pool. + pub fn allocated(&self) -> usize { + self.pools + .borrow() + .iter() + .fold(0usize, |acc, p| acc + p.total) + } + + /// Return the total number of objects allocated. + pub fn available(&self) -> usize { + self.pools + .borrow() + .iter() + .fold(0usize, |acc, p| acc + p.available) + } +} diff --git a/core/mempool/src/tests.rs b/core/mempool/src/tests.rs new file mode 100644 index 00000000000..d5c1a900bc2 --- /dev/null +++ b/core/mempool/src/tests.rs @@ -0,0 +1,128 @@ +//! Tests for `boa_mempool`. +//! These are better run within Miri. + +use crate::MemPoolAllocator; +use std::rc::Rc; +use std::sync::atomic::AtomicBool; + +#[test] +fn small_in_order() { + let pool = MemPoolAllocator::::new(); + let mut objs = vec![]; + + for i in 0..100 { + objs.push(pool.alloc(i)); + } + + let total = pool.allocated(); + assert_eq!(pool.available(), total - 100); + + for p in objs { + pool.dealloc(p); + } + + assert_eq!(pool.available(), pool.allocated()); + // Deallocating should not change the amount of memory used. + assert_eq!(pool.allocated(), total); +} + +#[test] +fn realloc_loops() { + let pool = MemPoolAllocator::::new(); + + for i in 0..32 { + let mut objs = vec![]; + + for j in 0..(i * 16) { + let ptr = pool.alloc(i * j); + objs.push(ptr); + } + + let total = pool.allocated(); + assert_eq!(pool.available(), total - objs.len()); + + for p in objs { + pool.dealloc(p); + } + + assert_eq!(pool.available(), pool.allocated()); + // Deallocating should not change the amount of memory used. + assert_eq!(pool.allocated(), total); + } +} + +#[test] +fn simple() { + let pool = MemPoolAllocator::::new(); + let a = pool.alloc(2000); + unsafe { + a.write(1000); + }; + let b = pool.alloc(2001); + unsafe { + b.write(1001); + }; + let c = pool.alloc(2002); + unsafe { + c.write(1002); + }; + + pool.dealloc(c); + pool.dealloc(b); + pool.dealloc(a); +} + +#[test] +fn array() { + unsafe { + let pool = MemPoolAllocator::<[u8; 128]>::new(); + let a = pool.alloc([0xFFu8; 128]); + + let b = pool.alloc_unitialized(); + a.write([0xFEu8; 128]); + b.write([0xFDu8; 128]); + let c = pool.alloc_unitialized(); + pool.dealloc(b); + pool.dealloc(a); + let b = pool.alloc_unitialized(); + let a = pool.alloc_unitialized(); + a.write([0xFCu8; 128]); + b.write([0xFBu8; 128]); + c.write([0xFAu8; 128]); + let d = pool.alloc_unitialized(); + a.write([0xF9u8; 128]); + b.write([0xF8u8; 128]); + c.write([0xF7u8; 128]); + d.write([0xF6u8; 128]); + + let x = pool.alloc_unitialized(); + pool.dealloc(a); + pool.dealloc(d); + pool.dealloc_no_drop(x); + pool.dealloc(c); + pool.dealloc(b); + } +} + +#[test] +fn drop() { + struct MyS { + dropped: Rc, + } + + impl Drop for MyS { + fn drop(&mut self) { + self.dropped + .store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + let pool = MemPoolAllocator::::new(); + let dropped = Rc::new(AtomicBool::new(false)); + let a = pool.alloc(MyS { + dropped: dropped.clone(), + }); + + pool.dealloc(a); + assert!(dropped.load(std::sync::atomic::Ordering::SeqCst)); +}