Memory Management Survey Report

May 11, 2026 · View on GitHub

Scope: Lambda + Radiant codebase
Date: 2026-04-12


Table of Contents

  1. Architecture Overview
  2. rpmalloc Usage Review
  3. Pool Usage Inventory
  4. Arena Usage Inventory
  5. Pool → Arena Conversion Candidates
  6. Raw malloc/calloc Audit
  7. Implementation Bugs & Concerns
  8. Recommendations
  9. Temp Arena Design Proposal

1. Architecture Overview

The codebase uses a three-tier memory allocation strategy:

TierImplementationPurposeIndividual Free?
Poollib/mempool.c wrapping rpmalloc first-class heapsGeneral-purpose allocator with per-object free capabilityYes (rpmalloc mode) / No (mmap mode)
Arenalib/arena.c built on top of PoolBump-allocator for sequential, short-lived allocationsOptional free-list recycling
Raw mallocSystem malloc/calloc/freePool struct metadata, one-off utilitiesYes

Layering: Arena → Pool → rpmalloc → OS.
Arena chunks are allocated from Pool. Pool delegates to rpmalloc heaps (or mmap fallback).

Key Design Principle

  • Pool is appropriate when objects have varied lifetimes and need individual pool_free().
  • Arena is appropriate when objects are created together and discarded together (bulk reset/destroy). Arena's O(1) bump allocation is significantly faster than Pool's rpmalloc per-object tracking.

2. rpmalloc Usage Review

2.1 Configuration

  • Compiled with ENABLE_OVERRIDE=0 — does NOT replace system malloc
  • Compiled with RPMALLOC_FIRST_CLASS_HEAPS=1 — enables per-pool heap isolation
  • Library: librpmalloc_no_override.a (static link)

2.2 rpmalloc API Functions Used

FunctionLocationPurpose
rpmalloc_initialize(NULL)mempool.c:47One-time global init
rpmalloc_thread_initialize()mempool.c:59Per-thread TLS setup
rpmalloc_heap_acquire()mempool.c:75Per-pool heap creation
rpmalloc_heap_alloc()mempool.c:201Allocation
rpmalloc_heap_calloc()mempool.c:230Zero-init allocation
rpmalloc_heap_free()mempool.c:247Individual deallocation
rpmalloc_heap_realloc()mempool.c:265Resize
rpmalloc_heap_free_all()mempool.c:148,162,174Bulk free (destroy/drain/reset)
rpmalloc_heap_release()mempool.c:149,163Return heap to system

2.3 Assessment: Correct Usage ✅

The rpmalloc integration is well-designed:

  1. First-class heaps provide proper per-pool isolation — allocations from one pool cannot accidentally be freed via another.
  2. Lazy initialization with mutex guards is correct for multi-threaded startup.
  3. Thread-local rpmalloc_thread_initialize() is called via ensure_rpmalloc_initialized() before any pool creation.
  4. Bulk free via rpmalloc_heap_free_all() is the primary cleanup path, which is optimal for rpmalloc.
  5. ENABLE_OVERRIDE=0 correctly preserves system malloc for Pool struct metadata (malloc(sizeof(Pool))).

2.4 Minor Issues

IssueSeverityDetails
mempool_cleanup() never calledLowFIXED. Now called in main() exit path after runtime_cleanup(), before log_finish().
Heap pointer validation is weakLowmempool.c:200 checks (uintptr_t)pool->heap < 0x10000 — catches NULL-ish pointers but misses other corruption patterns.
No rpmalloc_thread_finalize() on thread exitMediumWorker threads that use pools should call rpmalloc_thread_finalize() at exit to release thread-local caches. mempool_cleanup() calls it at program exit; per-thread cleanup remains a concern for long-running worker threads.

3. Pool Usage Inventory

3.1 Complete List of Pool Usage Sites

Lambda Core

FilePool SourceWhat is AllocatedIndividual pool_free()?
lambda/input/input.cppinput->poolTypeMap, ShapeEntry, Map .data buffersYespool_free() on old .data buffer during resize (lines 91, 212, 305, 306, 353, 354)
lambda/lambda-data.cppinput->poolType, Array, Map, Element structsNo
lambda/mark_builder.cppinput->pool via MarkBuilderContainer construction (delegates to map_put)No (arena for structs, pool for metadata)
lambda/name_pool.cppparent poolNamePool struct, interned stringsNo
lambda/shape_pool.cppparent poolShapePool, CachedShape entriesNo
lambda/re2_wrapper.cpppool paramTypePattern, String, ShapeEntryNo
lambda/emit_sexpr.cpp:1966pool_create()Temp Input for S-expression serializationNo — bulk pool_destroy()
lambda/lambda-proc.cpp:395pool_create()Temp pool for procedure formattingNo — bulk pool_destroy()
lambda/validator/doc_validator.cpp:62pool_create()Transpiler, NamePool, validation structsNo
lambda/validator/ast_validate.cpp:213pool_create()Temp pool for AST validationNo
lambda/rb/rb_scope.cpp:165pool_create()tp->ast_pool backing ast_arena for Ruby AST nodesNo — ✅ Migrated to arena
lambda/rb/build_rb_ast.cpptp->ast_arenaRbAstNode allocationsNo — ✅ Migrated to arena
lambda/py/py_scope.cpp:180pool_create()tp->ast_pool backing ast_arena for Python AST nodesNo — ✅ Migrated to arena
lambda/py/build_py_ast.cpptp->ast_arenaPyAstNode allocationsNo — ✅ Migrated to arena
lambda/js/transpile_js_mir.cpppool_create()JS transpiler context, InputNo

Radiant

FilePool SourceWhat is AllocatedIndividual pool_free()?
radiant/view_pool.cpptree->poolDomElement, DomText, ViewBlock, ViewText, FontProp, layout propertiesYes — 10 pool_free() calls for selective view component cleanup (lines 143-173)
radiant/cmd_layout.cpppool_create() multipleViewTree, ViewBlock, ImageSurface, EmbedProp, CssStylesheet arraysYespool_realloc() for stylesheet arrays (lines 851, 980, 1050)
radiant/layout_block.cppview_tree->poolPseudo-elements (DomElement, DomText), text contentNo
radiant/layout_table.cppview_tree->poolAnonymous table cells (DomElement, FontProp)No
radiant/block_context.cpp:252ctx->poolFloatBoxNo
radiant/pdf/pdf_to_view.cpppool_create()ViewTree, ViewBlock, ViewText, EmbedProp, DomElementNo — bulk pool_destroy()
radiant/pdf/operators.cppstate->poolPDFStreamParser, String, PathSegment, PDFSavedState, PDFOperatorNo
radiant/pdf/fonts.cpppool paramGlyph map arrays, FontProp, PDFFontCache, PDFFontEntry, width arraysNo
radiant/pdf/pages.cpppool_create()Temp pools for page processing, PDFPageInfoNo — bulk pool_destroy()
radiant/render_dvi.cpppool_create() ×4Short-lived rendering poolsNo — bulk pool_destroy()
radiant/window.cpppool_create()Window rendering setupNo
radiant/event.cpppool_create() tempTemporary event processing poolsNo — bulk pool_destroy()
radiant/script_runner.cpp:354pool_create_mmap()Mmap-backed reuse pool for JS runtimeNo — mmap bump allocator

CSS System

FilePool SourceWhat is AllocatedIndividual pool_free()?
lambda/input/css/css_parser.cppengine poolCssValue, CssFunction, selectors, namesNo
lambda/input/css/css_engine.cpppool paramCssEngine, CssStylesheetNo
lambda/input/css/css_value_parser.cpppool paramCSS values and propertiesNo
radiant/resolve_css_style.cpppoolCssCustomPropNo

Font System

FilePool SourceWhat is AllocatedIndividual pool_free()?
lib/font/font_context.cpool paramFontContext, FreeType memory callbacks (pool_realloc)Via FreeType callbacks

4. Arena Usage Inventory

4.1 Complete List of Arena Usage Sites

Lambda Core

FileArena SourceWhat is AllocatedReset/Destroy
lambda/lambda-data.cppinput->arenaArray, Map, Element container structs (via *_arena() functions)Destroyed with Input
lambda/mark_builder.cppbuilder->arena()Container structs during parsingDestroyed with Input
lambda/input/input-latex-ts.cpparena paramLaTeX output buffersDestroyed with caller

Radiant

FileArena SourceWhat is AllocatedReset/Destroy
radiant/state_store.cppstate->arenaDocState snapshots, DragDropState, caret/selection/focus statesarena_reset() per frame
radiant/state_store.cppdirty_tracker.arenaDirtyRect linked listarena_reset() per frame
radiant/state_store.cppreflow_scheduler.arenaReflowRequest linked listarena_reset() per frame
radiant/layout.cpp:2156doc->arenaCounterContext for CSS countersDestroyed with document
radiant/layout_counters.cppctx->arenaCounterScope, counter arrays, name stringsDestroyed with document
radiant/layout_block.cpp:2596doc->arenaString struct for text contentDestroyed with document
radiant/layout_list.cppdoc->arenaMarker text copiesDestroyed with document
radiant/resolve_css_style.cpp:3029doc->arenaAttribute name copiesDestroyed with document
radiant/event.cpp:3193+temp arena_create_default()Short-lived text extraction buffersarena_destroy() immediately
radiant/render_dvi.cpparena_create_default()DVI rendering buffersarena_destroy() at function exit

Font System

FileArena SourceWhat is AllocatedReset/Destroy
lib/font/font_context.c:109arena_create_default()Font names, pathsDestroyed with FontContext
lib/font/font_context.c:133arena_create(256KB, 4MB)Glyph bitmaps (large)arena_reset() per frame
lib/font/font_database.cdb->arenaFont metadata strings (arena_strdup)Destroyed with FontDatabase
lib/font/font_config.carena paramSystem font paths, names, Unicode rangesDestroyed with database
lib/font/font_rasterize_ct.cglyph_arenaGlyphBitmap, raster buffersReset per frame
lib/font/font_glyph.cctx->glyph_arenaGlyph bitmap copies, raster buffersReset per frame
lib/font/font_decompress.cpparena paramWOFF1/WOFF2 decompression buffersCaller manages
lib/font/font_config.c:2107arena_create(64KB, 1MB)Global font arenaApplication lifetime

WebDriver

FileArena SourceWhat is AllocatedReset/Destroy
radiant/webdriver/webdriver_server.cpp:825arena_create(64KB, 256KB)WebDriverServer structServer lifetime
radiant/webdriver/webdriver_session.cpp:137arena_create(64KB, 256KB)Session, ElementRegistry, UI contextSession lifetime
radiant/webdriver/webdriver_locator.cppsession arenaText results, locator contextSession lifetime

PDF

FileArena SourceWhat is AllocatedReset/Destroy
radiant/pdf/pages.cpp:369arena_create_default()Page collection temp dataarena_destroy() at function exit
lib/pdf_writer.c:233arena_create_default()PDFDocument, page/object metadataDestroyed with document

5. Pool → Arena Conversion Candidates

These sites use Pool with zero individual pool_free() calls — they only rely on bulk pool_destroy(). Switching to Arena would give them faster O(1) bump allocation instead of rpmalloc per-object tracking overhead.

FileCurrent PatternRationaleStatus
lambda/rb/build_rb_ast.cpp + rb_scope.cpptp->ast_pool = pool_create() → many pool_alloc()pool_destroy()AST nodes are never individually freed. Pure bump-allocate pattern.Migratedast_arena added alongside ast_pool
lambda/py/build_py_ast.cpp + py_scope.cpptp->ast_pool = pool_create() → many pool_alloc()pool_destroy()Identical pattern to Ruby.Migrated — same arena pattern
radiant/pdf/pdf_to_view.cppPool* view_pool = pool_create() → ~25 pool_calloc() calls → pool_destroy()View tree construction never individually frees.Migrated — All 14 static functions changed from Pool* to Arena*. 23 pool_callocarena_calloc. 3 external API calls use arena_pool() accessor.
radiant/render_dvi.cpp4 separate pool_create() → allocations → pool_destroy() per functionShort-lived rendering pools with no individual frees.⏭️ Already optimal — uses Arena from Pool internally
radiant/event.cpp (temp pools)Pool* tp = pool_create() → temp processing → pool_destroy()Very short-lived event processing.⏭️ Already optimal — uses Arena from Pool internally
lambda/lambda-proc.cpp:395Pool* temp_pool = pool_create() → few allocations → pool_destroy()Very short-lived temp pool.⏭️ Blocked — Pool passed to format_*() APIs that require Pool
lambda/emit_sexpr.cpp:1966pool_create() → create Input → pool_destroy()Short-lived serialization context.⏭️ Blocked — Pool passed to Input::create()
lambda/validator/ast_validate.cpp:213Pool* pool = pool_create() → allocations → pool_destroy() on many exit pathsShort-lived validation context.⏭️ Skipped — only 1 pool_calloc, not worth the change
radiant/pdf/pages.cpp (temp pools)Pool* temp_pool = pool_create() → page processing → pool_destroy()Short-lived page collection.⏭️ Deferred — Low impact, few allocations

5.2 Not Candidates (Must Remain Pool)

FileReason
lambda/input/input.cpppool_free() called on old .data buffers during map resize. Needs individual free.
radiant/view_pool.cpp10 individual pool_free() calls for selective view component cleanup.
radiant/cmd_layout.cppUses pool_realloc() for stylesheet arrays.
lambda/input/css/*Long-lived CSS engine structures, pool lifetime matches engine lifetime. Could be arena but no clear win since they persist for entire document.
radiant/script_runner.cppAlready uses pool_create_mmap() — effectively a bump allocator like arena.
lib/font/font_context.cFreeType requires realloc callback, needs pool_realloc().

5.3 Borderline Cases

FileAssessment
radiant/pdf/operators.cppNo individual frees, but allocations come from an externally-owned pool. Converting would require the caller to pass an arena instead.
radiant/pdf/fonts.cppSame — allocations from external pool. Would require API change.
radiant/layout_block.cpp / layout_table.cppAllocate from view_tree->pool which does use individual frees elsewhere (view_pool.cpp). Cannot convert these independently.
radiant/block_context.cppFloatBox allocated from ctx->pool — shared pool, can't convert in isolation.

6. Raw malloc/calloc Audit

6.1 Properly Freed ✅

File : LineWhatFreed Where
lambda/validator/suggestions.cpp:43Levenshtein distance matrixfree() at line 68-70
lambda/network/enhanced_file_cache.cpp:59SHA256 hex bufferCaller free() at line 253
lambda/network/enhanced_file_cache.cpp:242Path bufferfree() at line 253
lambda/network/resource_loaders.cpp:38Content bufferCaller responsible (API contract)
radiant/render.cpp:174-176ClipMask + saved bufferfree_clip_mask() at lines 2197, 2283Migrated to ScratchArena (§9.8)
radiant/cmd_layout.cpp:2910MathInfoLoop free() at line 2997-2999
lambda/validator/ast_validate.cpp:348,351URL/format Stringfree() at lines 364-365

6.2 Potential Issues ⚠️ — ✅ Fixed

FileLineIssueResolution
lambda/validator/doc_validator.cpp:410malloc(sizeof(String) + len + 1) for error messageOnly when pool == NULL. Error merging path at line 501 used nullptr pool — copied error messages leaked.FIXED. Added Pool* pool parameter to merge_validation_results(). All call sites now pass the context pool. No more malloc fallback in the merge path.

6.3 Should Use Pool or Arena Instead

FileLineWhatRecommendation
radiant/render.cpp:174-176ClipMask + saved buffer via raw mallocMigrated to ScratchArenasave_clip_region() and parse_css_clip_shape() now use scratch_alloc/scratch_calloc from rdcon->scratch. free_clip_mask()/free_clip_shape() use scratch_free().✅ Done (§9.8)
radiant/cmd_layout.cpp:2910MathInfo structs in a loopShort-lived, freed in batch. Arena would be ideal.Use arena — allocate all MathInfo from temp arena, destroy arena at end.

7. Implementation Bugs & Concerns

7.1 Pool (mempool.c)

#SeverityLocationIssue
P1Highmempool.c:100-101 (mmap_pool_grow)FIXED. mmap failure now nulls cursor/limit; alloc/calloc check for NULL after mmap_pool_grow().
P2Mediummempool.c:281FIXED. mempool_cleanup() is now called in main() exit path (after runtime_cleanup(), before log_finish()). Calls rpmalloc_thread_finalize() + rpmalloc_finalize() for clean shutdown.
P3Mediummempool.c:270-279 (mmap realloc)FIXED. mmap bump allocations now embed a 16-byte size header (MMAP_SIZE_HEADER). pool_realloc reads old size from the header and safely memcpys data to the new allocation.
P4Lowmempool.c:200Heap pointer validation < 0x10000 is platform-specific and incomplete.

7.2 Arena (arena.c)

#SeverityLocationIssue
A1Mediumarena.c:arena_freeFIXED. arena_free() now promotes blocks < ARENA_MIN_FREE_BLOCK_SIZE to the minimum size, and arena_alloc_aligned() enforces a minimum allocation size. All blocks can participate in the free-list.
A2Mediumarena.c:arena_freeFIXED. arena_free() now calls arena_owns() to validate pointer ownership before adding to free-list. Foreign pointers are rejected with log_error().
A3Lowarena.c:_arena_alloc_from_freelistFIXED. Free-list search now checks both size and alignment before selecting a block, avoiding the remove-then-re-add pattern that lost size information.
A4Lowarena.c:arena_freeFIXED. Bump-back coalescing: when freed block is at the end of the current chunk, the bump pointer is decremented to reclaim space directly (O(1)). Also fixed arena_reset()/arena_clear() to clear stale free-list pointers.

7.3 Interaction Concerns

#Issue
I1Arena is not thread-safe — no synchronization. This is fine because each thread/context owns its own arena. ✅ Now documented in arena.h header with @warning ARENA_NOT_THREAD_SAFE.
I2pool_drain() invalidates the Pool (valid = 0) but Arena chunks allocated from that Pool are now backed by freed memory. Any Arena still referencing this Pool will corrupt memory on next chunk allocation. Ensure Arena is destroyed before Pool is drained.

8. Recommendations

8.1 High Priority — ✅ All Fixed

Fix P1: mmap failure causes out-of-bounds write — ✅ DONE

// mempool.c, mmap_pool_grow():
if (mem == MAP_FAILED) {
    log_error("mmap_pool_grow: mmap failed for %zu bytes", chunk_size);
+   pool->cursor = NULL;
+   pool->limit = NULL;
    return;
}

And in pool_alloc() / pool_calloc() mmap path:

if (pool->cursor + size > pool->limit) {
    mmap_pool_grow(pool, size);
+   if (!pool->cursor) return NULL;  // mmap failed
}

Fix P3: mmap realloc reads past old allocation — ✅ DONE

Originally removed the unsafe memcpy as an interim fix. Now fully resolved: mmap bump allocations embed a 16-byte size header (MMAP_SIZE_HEADER) before each allocation. pool_realloc() reads the old size from the header and safely copies data to the new buffer.

Fix A2: arena_free without ownership check — ✅ DONE

arena_free() now calls arena_owns() before adding to free-list:

void arena_free(Arena* arena, void* ptr) {
    if (!ptr || !arena) return;
+   if (!arena_owns(arena, ptr)) {
+       log_error("arena_free: ptr %p not owned by arena %p", ptr, arena);
+       return;
+   }
    // ... existing free-list logic
}

8.2 Medium Priority — Partially Done

Convert AST pools to arenas (strongest candidates) — ✅ DONE

Ruby and Python AST builders now use ast_arena (bump allocator) instead of ast_pool directly.

// Before:
tp->ast_pool = pool_create();
// ... pool_alloc(tp->ast_pool, ...) throughout AST building
pool_destroy(tp->ast_pool);

// After:
tp->ast_pool = pool_create();  // keep pool for arena's backing store
tp->ast_arena = arena_create_default(tp->ast_pool);
// ... arena_alloc(tp->ast_arena, ...) throughout AST building
arena_destroy(tp->ast_arena);
pool_destroy(tp->ast_pool);

This gives O(1) bump allocation for all AST nodes instead of rpmalloc per-object tracking.

Convert short-lived temp pools to arenas — ✅ DONE (3/9 migrated, 6 assessed)

Migrated to arena:

  • lambda/rb/build_rb_ast.cpp + rb_scope.cpp — AST arena
  • lambda/py/build_py_ast.cpp + py_scope.cpp — AST arena
  • radiant/pdf/pdf_to_view.cpp — ViewTree arena (23 pool_calloc → arena_calloc, arena_pool() accessor for 3 external API calls)

Already optimal (no change needed):

  • radiant/render_dvi.cpp — already uses Arena from Pool internally
  • radiant/event.cpp — already uses Arena from Pool internally

Not converted (assessed):

  • lambda/lambda-proc.cpp — Pool passed to format_*() APIs
  • lambda/emit_sexpr.cpp — Pool passed to Input::create()
  • lambda/validator/ast_validate.cpp — only 1 pool_calloc, not worth it
  • radiant/pdf/pages.cpp — low impact, deferred

Call rpmalloc_thread_finalize() on worker thread exit

Add thread-exit hooks or atexit-style cleanup in thread pool teardown to call rpmalloc_thread_finalize(), releasing per-thread caches.

8.3 Low Priority / Future Improvements — Items 1-8 ✅ Done

#SuggestionRationale
1Call mempool_cleanup() at program exitDONE. Added mempool_cleanup() call in main.cpp exit path, after runtime_cleanup() and before log_finish(). Ensures clean Valgrind/ASan shutdown.
2Add ARENA_NOT_THREAD_SAFE documentationDONE. Added @warning ARENA_NOT_THREAD_SAFE block to arena.h header docstring. Documents single-thread ownership requirement and external synchronization needed for concurrent access.
3Arena free-list coalescingDONE. Full adjacent-block coalescing implemented in arena_free(). On free, _arena_find_adjacent_block() scans all bins for blocks physically adjacent to the freed region, removes and merges them iteratively. After coalescing, checks if merged block reaches bump cursor for bump-back reclamation. Helper _arena_remove_free_block() handles bin removal.
4Pool allocation size tracking in mmap modeDONE. 16-byte MMAP_SIZE_HEADER embedded before each mmap bump allocation in pool_alloc() and pool_calloc(). pool_realloc() mmap path now reads old size from header and safely copies data via memcpy. Fully fixes P3.
5arena_calloc for view tree allocationsDONE. ViewTree now has a dedicated Arena* arena field. PDF view tree construction (pdf_to_view.cpp) uses arena_calloc() for all 23 allocation sites (ViewBlock, ViewText, TextRect, BoundaryProp, etc.). Layout code in layout_block.cpp/layout_table.cpp can optionally use view_tree->arena for permanent allocations in the future.
6Unified temp-arena patternSuperseded by ScratchArena (§9.8). ScratchArena provides a lightweight LIFO scratch allocator backed by Arena, with per-allocation free and mark/restore. Integrated into LayoutContext and RenderContext.
7doc_validator.cpp:410 potential leakFIXED. merge_validation_results() now takes a Pool* parameter; all call sites pass the context pool. The malloc fallback path in create_validation_error is no longer reached during error merging.
8render.cpp ClipMask raw mallocMigrated to ScratchArenasave_clip_region(), parse_css_clip_shape(), mix_blend_backdrop now use scratch_alloc/scratch_free from rdcon->scratch.
9Evaluate removing rpmalloc dependencyAfter converting the 9 strong arena candidates (§5.1), the remaining pool sites that need individual pool_free() are few (~3 files: input.cpp, view_pool.cpp, cmd_layout.cpp). These could be served by plain system malloc with a simple allocation-tracking list in the Pool struct. This would eliminate the rpmalloc build dependency, simplify the build (no custom static lib), remove TLS lifecycle issues (P2), and improve compatibility with sanitizers (ASan, Valgrind, Instruments). System malloc on macOS (libmalloc magazine zones) and Linux (glibc/jemalloc) is already performant for these patterns. Trade-off: lose rpmalloc_heap_free_all() convenience — replaced by iterating a tracking list on pool_destroy().

Appendix: Pool vs Arena Decision Matrix

Use this when deciding which allocator to use for new code:

Question→ Pool→ Arena
Need to free individual objects?
Need realloc to grow buffers?⚠️ (arena_realloc exists but limited)
All objects freed together at end?⚠️ (works but wasteful)
High allocation rate, small objects?❌ (overhead per alloc)✅ (O(1) bump)
Objects need to survive parent scope?
Frame-based reset pattern?✅ (arena_reset())
FreeType / external library callbacks?✅ (needs malloc-like API)

9. Temp Arena Design Proposal

9.1 Problem Statement

The Radiant layout, render, and event subsystems contain 25+ scoped malloc/free pairs — temporary buffers allocated at function entry (or mid-function) and freed before return. These use mem_alloc/mem_calloc/calloc/malloc paired with mem_free/free, going through the system allocator for every allocation.

Overhead of system malloc for these:

  • Lock contention on the global heap (layout and render are hot paths)
  • Per-object bookkeeping (malloc metadata headers)
  • Cache pollution from scattered heap addresses
  • No bulk-free — each free() is a separate syscall-level operation

9.2 Concrete Allocation Sites

High-value targets (large buffers, hot paths):

#FileFunctionWhatSizeStatus
1layout_table.cppTableMetadata ctor/dtor12 parallel arrays (grid_occupied, col_widths, row_heights, etc.)rows × cols up to cols+1 per array✅ Migrated
2render_background.cppbox_blur_region()Pixel scratch bufferrw × rh × 4 bytes✅ Migrated
3render.cpprender_block_view()mix_blend_backdrop pixel buffermbw × mbh × 4 bytes✅ Migrated
4render_filter.cppapply_drop_shadow_filter()Shadow pixel bufferew × eh × 4 bytes✅ Migrated
5render.cppsave_clip_region()ClipMask + saved pixel bufferw × h × 4 bytes✅ Migrated
6render.cppparse_css_clip_shape()ClipShape + polygon vx/vy arraysVariable✅ Migrated

Medium-value targets (smaller buffers):

#FileFunctionWhatStatus
7layout_table.cppperform_table_layout()explicit_col_widths, col_x_positions✅ Migrated
8grid_positioning.cppposition_grid_items()row_positions, column_positions✅ Migrated
9grid_utils.cppparse_grid_template_areas()3-level nested grid_cells + unique_names✅ Migrated (mark/restore)
10graph_dagre.cpp3 functions5 separate calloc/free pairs (int/float arrays)⏭️ Deferred
11grid_positioning.cppbaseline alignmentrow_max_baseline, row_max_below✅ Migrated
12intrinsic_sizing.cppgrid intrinsic widthscol_min, col_max✅ Migrated
13render_svg.cpp / render_pdf.cpptext renderingtext_content buffer⏭️ Deferred
14layout_counters.cppcollect_counter_values_all()temp int array⏭️ Deferred
15resolve_css_style.cppgrid-template-areascombined string buffer✅ Migrated

9.3 Implemented Design: LIFO Scratch Allocator

A lightweight bump allocator with per-allocation headers forming a backward-linked list. Optimized for LIFO free (which is the dominant pattern), with graceful handling of non-LIFO frees.

// ---- Data Structures ----

typedef struct ScratchHeader {
    struct ScratchHeader* prev;  // previous allocation (backward link)
    uint32_t size;               // allocation size (excluding header)
    uint32_t flags;              // bit 0: freed flag
} ScratchHeader;                 // 16 bytes

typedef struct ScratchArena {
    Arena* arena;                // backing arena for chunk allocation
    ScratchHeader* head;         // most recent allocation (top of stack)
} ScratchArena;

// ---- API ----

ScratchArena* scratch_create(Arena* backing_arena);
void*         scratch_alloc(ScratchArena* sa, size_t size);
void          scratch_free(ScratchArena* sa, void* ptr);
void          scratch_reset(ScratchArena* sa);  // free everything
void          scratch_destroy(ScratchArena* sa);

Alloc:

1. Bump-allocate (sizeof(ScratchHeader) + aligned_size) from backing arena
2. Fill header: prev = sa->head, size = user_size, flags = 0
3. sa->head = new_header
4. Return pointer past header (header + 1)

Free (LIFO fast path):

1. Get header from (ptr - sizeof(ScratchHeader))
2. If header == sa->head:          // LIFO case ← the common path
     a. sa->head = header->prev
     b. arena bump-back (arena_free on the header address)
     c. Walk backward: while sa->head && sa->head->flags & FREED:
          // coalesce consecutive freed holes
          arena_free(sa->arena, sa->head)
          sa->head = sa->head->prev
3. Else:                            // non-LIFO (rare)
     a. header->flags |= FREED      // mark as hole, reclaim lazily

Hole coalescing on LIFO free (step 2c) — critical detail:

When non-LIFO frees create holes in the middle, those holes must be reclaimed together when the tail block is freed:

Initial state (4 allocations):
  [A] ← [B] ← [C] ← [D]          sa->head = D
                                    ← = prev pointer

Step 1: scratch_free(B)             non-LIFO → mark as hole
  [A] ← [B̶] ← [C] ← [D]          sa->head = D (unchanged)

Step 2: scratch_free(C)             non-LIFO → mark as hole
  [A] ← [B̶] ← [C̶] ← [D]          sa->head = D (unchanged)

Step 3: scratch_free(D)             LIFO ✓ (D == sa->head)
  3a: sa->head = D->prev = C̶       arena bump-back reclaims D
  3c: C̶ is freed → reclaim         sa->head = C̶->prev = B̶
      B̶ is freed → reclaim         sa->head = B̶->prev = A
      A is NOT freed → stop

Result:
  [A]                               sa->head = A
                                    arena bump pointer rewound past D, C, B

All consecutive holes behind the freed tail block are reclaimed in one backward walk. The arena bump pointer rewinds past the entire freed region, making that memory available for future allocations. Without this walk, holes B and C would be permanently leaked until scratch_reset().

9.4 Design Assessment

Strengths:

PropertyBenefit
O(1) allocBump allocation, no malloc metadata, no lock contention
O(1) LIFO freePointer comparison + bump-back — zero overhead
Backward coalescingNon-LIFO frees are reclaimed lazily when the stack unwinds
Cache-friendlySequential memory layout, allocations adjacent in cache lines
Bulk resetscratch_reset() resets arena in O(1) — safety net if caller forgets individual frees
No fragmentation (LIFO)Perfect compaction when frees are strictly LIFO

Concerns and mitigations:

ConcernAssessment
16 bytes overhead per allocationFor the target sites, allocations are 64 bytes to 4MB pixel buffers. 16 bytes is negligible (<0.1% for pixel buffers, ~25% for a small 64-byte alloc). For very small allocations (<32 bytes), prefer the existing arena without headers.
Non-LIFO free creates holesIn practice this happens rarely. When it does, the hole is reclaimed on the next LIFO free via backward walk. Worst case: all holes reclaimed on scratch_reset().
Thread safetyNot needed — each layout/render pass owns its own scratch allocator. Same as existing arena design.
Large pixel buffersPixel buffers (render, blur, shadows) can be 10MB+. These should come from the same backing arena chunk system. Arena already handles large allocations via dedicated chunks.

9.5 Mark/Restore (Implemented as Secondary API)

Mark/restore is implemented alongside per-allocation free:

ScratchMark scratch_mark(ScratchArena* sa);            // save current head position
void        scratch_restore(ScratchArena* sa, ScratchMark mark);  // unwind to saved position

This is used for pure scoped patterns (allocate N things → free all at scope exit). In practice, parse_grid_template_areas()$ \text{uses} \text{mark}/\text{restore} \text{for} \text{its} 3-\text{level} \text{nested} \text{allocation} (16 \times 16 \times 32 = 8192+ \text{individual} \text{allocs}) — \text{bulk} \text{restore} \text{replaces} \text{nested} $mem_free loops.

For sites requiring individual mid-scope free (interleaved alloc/free patterns), the per-allocation LIFO free API is used instead:

  • render_block_view() — interleaved clip mask / blend backdrop alloc/free
  • render_filter.cpp — per-iteration shadow buffer alloc/free
  • TableMetadata — 12 arrays freed in LIFO order in destructor

9.6 Rollout Plan — ✅ Complete

Phase 1: Core implementation — ✅ DONE

  • lib/scratch_arena.h / lib/scratch_arena.c — full API: scratch_init, scratch_alloc, scratch_calloc, scratch_free, scratch_mark, scratch_restore, scratch_release, scratch_live_count
  • test/test_scratch_arena_gtest.cpp — 21 unit tests (LIFO, non-LIFO, large allocs, mixed sizes, backward coalescing, mark/restore). All passing.

Phase 2: Layout integration — ✅ DONE

  • layout.hpp — added ScratchArena scratch field to LayoutContext
  • layout.cpplayout_init() calls scratch_init(&lycon->scratch, doc->view_tree->arena), layout_cleanup() calls scratch_release(&lycon->scratch)
  • layout_table.cppTableMetadata constructor takes ScratchArena*, 12 mem_callocscratch_calloc, destructor frees in LIFO order. explicit_col_widths and col_x_positions migrated.
  • grid_positioning.cppposition_grid_items() takes ScratchArena* sa. 4 arrays migrated: row_positions, column_positions, row_max_baseline, row_max_below. align_grid_items() accesses via grid_layout->lycon->scratch.
  • grid_utils.cppparse_grid_template_areas() takes ScratchArena* sa. Uses scratch_mark/scratch_restore for 3-level nested allocation (grid_cells + unique_names).
  • intrinsic_sizing.cppcol_min/col_max arrays migrated.
  • resolve_css_style.cppcombined buffer for grid-template-areas list migrated. Callers of parse_grid_template_areas pass &lycon->scratch.

Phase 3: Render integration — ✅ DONE

  • render.hpp — added ScratchArena scratch field to RenderContext
  • render.cpprender_init() calls scratch_init(&rdcon->scratch, view_tree->arena) (after memset), render_clean_up() calls scratch_release(&rdcon->scratch). save_clip_region(), parse_css_clip_shape(), free_clip_mask(), free_clip_shape() take ScratchArena*. mix_blend_backdrop migrated.
  • render_background.cppbox_blur_region() takes ScratchArena*, temp pixel buffer migrated. Callers pass &rdcon->scratch.
  • render_filter.cppapply_css_filters() takes ScratchArena*, shadow_px buffer migrated. box_blur_region calls pass sa.

Phase 4: Deferred (not migrated)

  • graph_dagre.cpp — 5 scoped arrays in isolated subsystem with no Arena/LayoutContext access. Plumbing cost outweighs benefit.
  • render_svg.cpp / render_pdf.cpptext_content buffers. SvgRenderContext/PdfRenderContext have no Arena field. Would require adding Arena to these context structs.
  • layout_counters.cpptemp int array. Small, low frequency.

9.7 Resolved Design Questions

#QuestionResolution
1Should ScratchArena own its backing Arena, or receive one?Receive. scratch_init(sa, arena) takes the existing view_tree->arena. ScratchArena is a stack-embedded struct, not heap-allocated.
2Should pixel-sized buffers (10MB+) go through scratch?Yes. In practice, Arena handles large allocations via dedicated chunks. Pixel buffers (blur, shadow, clip, blend) all go through scratch without issue. No size threshold needed.
3Where to store the ScratchArena?LayoutContext.scratch for layout, RenderContext.scratch for render. Both are stack-embedded fields initialized in layout_init()/render_init() and released in layout_cleanup()/render_clean_up().
4Alignment?16-byte alignment confirmed. ScratchHeader is exactly 16 bytes (prev 8 + size 4 + flags 4), so payload is naturally 16-byte aligned.

9.8 Integration Summary

Implementation files:

  • lib/scratch_arena.h — API declarations
  • lib/scratch_arena.c — Implementation (LIFO bump-back, backward hole coalescing, mark/restore)
  • test/test_scratch_arena_gtest.cpp — 21 unit tests

Structural plumbing:

  • LayoutContext.scratch (layout.hpp) — initialized from doc->view_tree->arena in layout_init()
  • RenderContext.scratch (render.hpp) — initialized from view_tree->arena in render_init()

Migration scoreboard (§9.2 sites):

#SiteStatus
1TableMetadata 12 arrays✅ Migrated
2box_blur_region() pixel buffer✅ Migrated (ScratchArena* param)
3mix_blend_backdrop pixel buffer✅ Migrated
4apply_drop_shadow_filter() shadow buffer✅ Migrated (ScratchArena* param)
5save_clip_region() ClipMask + pixels✅ Migrated (ScratchArena* param)
6parse_css_clip_shape() ClipShape + polygon✅ Migrated (ScratchArena* param)
7explicit_col_widths, col_x_positions✅ Migrated
8row_positions, column_positions✅ Migrated
9parse_grid_template_areas() grid_cells + unique_names✅ Migrated (mark/restore)
10graph_dagre.cpp 5 scoped arrays⏭️ Deferred — isolated subsystem, no Arena access
11row_max_baseline, row_max_below✅ Migrated
12col_min, col_max✅ Migrated
13render_svg.cpp / render_pdf.cpp text_content⏭️ Deferred — context structs lack Arena field
14layout_counters.cpp temp array⏭️ Deferred — low impact
15resolve_css_style.cpp combined buffer✅ Migrated

Result: 12/15 sites migrated. 3 deferred (low-value or high plumbing cost).

Test verification (zero regressions):

  • Scratch arena unit tests: 21/21 passed
  • Arena unit tests: 90/90 passed
  • Radiant baseline: 4701/4710 (identical to pre-migration)
  • Lambda baseline: 565/566 (identical to pre-migration)

End of survey.