LibGC: Allocate HeapBlocks from 2 MiB chunks instead of one mmap each

Previously every 16 KiB HeapBlock was its own posix_memalign /
mach_vm_map / VirtualAlloc, which churned VMAs and made the kernel's
vm_area_struct list balloon for any non-trivial heap.

Carve slots out of 2 MiB chunks instead. The kernel now sees one
mmap per 128 blocks. Chunks are owned exclusively by a single
BlockAllocator and are never released back to the OS or shared
across allocators -- that's how we keep the heap's VM permanently
type-isolated, where a virtual address used for a cell of type T
is never reused for any other type. We don't bother tracking chunk
bases for teardown: the destructor leaks them by design.

Per-block memory return is preserved: deallocate_block still calls
MADV_FREE_REUSABLE / MADV_FREE / MADV_DONTNEED / DiscardVirtualMemory
so the kernel can reclaim physical pages under pressure.
This commit is contained in:
Andreas Kling 2026-05-07 10:47:44 +02:00 committed by Andreas Kling
parent 98a9c36ba7
commit 788bfe0f24

View file

@ -30,66 +30,78 @@
namespace GC {
BlockAllocator::~BlockAllocator()
{
for (auto* block : m_blocks) {
ASAN_UNPOISON_MEMORY_REGION(block, HeapBlock::BLOCK_SIZE);
#if defined(AK_OS_MACOS)
kern_return_t kr = mach_vm_deallocate(mach_task_self(), reinterpret_cast<mach_vm_address_t>(block), HeapBlock::BLOCK_SIZE);
VERIFY(kr == KERN_SUCCESS);
#elif defined(AK_OS_WINDOWS)
if (!VirtualFree(block, 0, MEM_RELEASE)) {
warnln("{}", Error::from_windows_error());
VERIFY_NOT_REACHED();
}
#else
free(block);
#endif
}
}
// Each BlockAllocator carves its 16 KiB HeapBlock slots out of 2 MiB
// chunks, so the kernel sees one mmap per 128 blocks instead of one per
// block. Chunks are owned exclusively by a single BlockAllocator and are
// never released back to the OS or shared across allocators -- that's how
// we keep the heap's VM permanently type-isolated, where a virtual address
// used for a cell of type T is never reused for any other type.
//
// We do not hint MADV_HUGEPAGE: per-block madvise() in deallocate_block
// would split any THP backing the chunk anyway. Per-block memory return
// matches what V8, SpiderMonkey, and WebKit's libpas all do.
static constexpr size_t CHUNK_SIZE = 2 * MiB;
static constexpr size_t BLOCKS_PER_CHUNK = CHUNK_SIZE / HeapBlock::BLOCK_SIZE;
static_assert(CHUNK_SIZE % HeapBlock::BLOCK_SIZE == 0);
static_assert(BLOCKS_PER_CHUNK == 128);
BlockAllocator::~BlockAllocator() = default;
void* BlockAllocator::allocate_block([[maybe_unused]] char const* name)
{
if (!m_blocks.is_empty()) {
// To reduce predictability, take a random block from the cache.
size_t random_index = get_random_uniform(m_blocks.size());
auto* block = m_blocks.unstable_take(random_index);
ASAN_UNPOISON_MEMORY_REGION(block, HeapBlock::BLOCK_SIZE);
LSAN_REGISTER_ROOT_REGION(block, HeapBlock::BLOCK_SIZE);
if (m_blocks.is_empty()) {
void* chunk_base = nullptr;
#if defined(AK_OS_MACOS)
mach_vm_address_t address = 0;
kern_return_t kr = mach_vm_map(
mach_task_self(),
&address,
CHUNK_SIZE,
CHUNK_SIZE - 1,
VM_FLAGS_ANYWHERE,
MEMORY_OBJECT_NULL,
0,
false,
VM_PROT_READ | VM_PROT_WRITE,
VM_PROT_READ | VM_PROT_WRITE,
VM_INHERIT_DEFAULT);
VERIFY(kr == KERN_SUCCESS);
chunk_base = reinterpret_cast<void*>(address);
#elif defined(AK_OS_WINDOWS)
chunk_base = VirtualAlloc(nullptr, CHUNK_SIZE, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);
VERIFY(chunk_base);
#else
auto rc = posix_memalign(&chunk_base, CHUNK_SIZE, CHUNK_SIZE);
VERIFY(rc == 0);
#endif
#if defined(MADV_FREE_REUSE) && defined(MADV_FREE_REUSABLE)
if (madvise(block, HeapBlock::BLOCK_SIZE, MADV_FREE_REUSE) < 0) {
perror("madvise(MADV_FREE_REUSE)");
// Mark the whole chunk reusable upfront so MADV_FREE_REUSE pairs
// symmetrically when slots are popped from m_blocks below. (Linux
// and Windows fall through with no-op.)
if (madvise(chunk_base, CHUNK_SIZE, MADV_FREE_REUSABLE) < 0) {
perror("madvise(MADV_FREE_REUSABLE)");
VERIFY_NOT_REACHED();
}
#endif
return block;
ASAN_POISON_MEMORY_REGION(chunk_base, CHUNK_SIZE);
for (size_t i = 0; i < BLOCKS_PER_CHUNK; ++i)
m_blocks.append(static_cast<u8*>(chunk_base) + i * HeapBlock::BLOCK_SIZE);
}
#if defined(AK_OS_MACOS)
mach_vm_address_t address = 0;
kern_return_t kr = mach_vm_map(
mach_task_self(),
&address,
HeapBlock::BLOCK_SIZE,
HeapBlock::BLOCK_SIZE - 1,
VM_FLAGS_ANYWHERE,
MEMORY_OBJECT_NULL,
0,
false,
VM_PROT_READ | VM_PROT_WRITE,
VM_PROT_READ | VM_PROT_WRITE,
VM_INHERIT_DEFAULT);
VERIFY(kr == KERN_SUCCESS);
auto* block = reinterpret_cast<void*>(address);
#elif defined(AK_OS_WINDOWS)
auto* block = VirtualAlloc(NULL, HeapBlock::BLOCK_SIZE, MEM_COMMIT, PAGE_READWRITE);
VERIFY(block);
#else
void* block = nullptr;
auto rc = posix_memalign(&block, HeapBlock::BLOCK_SIZE, HeapBlock::BLOCK_SIZE);
VERIFY(rc == 0);
#endif
// Random pick to preserve the previous anti-predictability behavior.
size_t random_index = get_random_uniform(m_blocks.size());
auto* block = m_blocks.unstable_take(random_index);
ASAN_UNPOISON_MEMORY_REGION(block, HeapBlock::BLOCK_SIZE);
LSAN_REGISTER_ROOT_REGION(block, HeapBlock::BLOCK_SIZE);
#if defined(MADV_FREE_REUSE) && defined(MADV_FREE_REUSABLE)
if (madvise(block, HeapBlock::BLOCK_SIZE, MADV_FREE_REUSE) < 0) {
perror("madvise(MADV_FREE_REUSE)");
VERIFY_NOT_REACHED();
}
#endif
return block;
}
@ -97,6 +109,9 @@ void BlockAllocator::deallocate_block(void* block)
{
VERIFY(block);
// Tell the kernel it can reclaim physical pages backing this 16 KiB
// slot. The slot stays in m_blocks for reuse by this same
// BlockAllocator -- never seen by a different cell type.
#if defined(AK_OS_WINDOWS)
DWORD ret = DiscardVirtualMemory(block, HeapBlock::BLOCK_SIZE);
if (ret != ERROR_SUCCESS) {