Skip to content

Laravel: build memory (no MachineOutliner), per-request leaks to zero, cycle collection through untyped properties, #599 regressions - #602

Merged
blacktrs merged 10 commits into
mainfrom
perf/laravel-build-mem-and-leaks
Nov 9, 2026
Merged

blacktrs merged 10 commits into
mainfrom
perf/laravel-build-mem-and-leaks

Conversation

@blacktrs

@blacktrs blacktrs commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Part of #600 (Laravel follow-up). This PR cuts build memory and per-request leaks, and fixes two Laravel regressions from #599.

Results (macOS arm64)

before after
clang codegen, one 330 MB Laravel part 13.7 GB RSS, 201 s 4.65 GB, 177 s (.o +1.5%)
/api/ping heap growth after gc ~3.9 KB/request (main 8c95ce8) 0 (82 568 nodes before and after ~14K requests)
/api/notes crashed on the first request (also on main) works; heap flat
Laravel boot Typed property Application::$appPath must not be accessed before initialization (main) boots

/hello still grows by ~5 nodes per request. That is legitimate: with SESSION_DRIVER=array every cookieless wrk request creates a new session and ArraySessionHandler stores it.

Changes

Build memory and IR size

  • -mllvm -enable-machine-outliner=never. On arm64 the MachineOutliner is a module pass, so it keeps every MachineFunction of a part alive until the end of codegen. That was the main source of 9–13 GB per clang.
  • One cold raise per guard site (emitColdRaise): pick the message with select instead of N separate throw blocks.
  • $o->$name on a known class dispatches through the reader table, not a strcmp chain with the full read path per name (Carbon::roundUnit was 348K IR lines).
  • Runtime helpers that cannot unwind are emitted as call, not invoke.
  • The cell_int_bad slow path moved out of line.

Ownership / leaks

  • Release the discarded result of a builtin that mints a value (json_decode('[]');).
  • An array + inside a conditional arm, and a cell + stored to a local, are owned, not borrowed.
  • A closure generator owns its env in the frame (env@56, GEN_HEADER 56 → 64). This makes it safe for an invoke to release the +1 it holds for a conditional or literal callee.
  • Cycle collector: an object in an untyped (cell) property is now walked as a child, and the other cell slots of a collected node are released. Before this, no cycle through an untyped property was ever collected; this is what kept Laravel's per-request Request/Route/closure graph alive.
  • is_a() with an erased string subject releases the strval() result it creates.
  • An owned call result used as a callee is released after the call, including a callable-typed word (classified at run time by its allocator header).
  • A closure stored in a reference box is tagged as an object cell, so the box's cell drop sees it; reads strip the tag.
  • (array) over an operand that mints a value passes its +1 through instead of retaining it again.

Correctness (regressions from #599)

  • The init map of a fresh instance is seeded with its untyped properties, so lazy-ghost support no longer breaks ordinary objects.
  • An inherited property keeps one representation across the hierarchy: once any class holds a bare cell, every class does (Model::$attributes was a cell in Model and vec[cell] in Note).

Tests

Validation

  • Topic subsets green on this head, rebased on 5e4a0f72: leak, closure, callable, invoke, gen, cast, own, ref, prop, gc, lazy, is_a.
  • Green before the rebase onto 5e4a0f72, not rerun since: array, class, inherit, async, fiber, str, json, tern, cell. xfail.sh before the rebase: 45 XFAIL, 0 XPASS.
  • Not run yet: the full AOT suite, difftest, and the Linux gate. All three are required here: this changes the cycle collector, the generator frame layout and ownership rules.

Still open

… Laravel part 13.7 GB -> 4.65 GB), one cold raise per guard site, table dispatch for $o->$name on a known class, nounwind runtime helpers called not invoked, cell_int_bad slow path out of line
…rded result, and a cell or array union read as borrowed in a conditional arm or a local
… a program that makes lazy ghosts every untyped read before a write was the uninitialized-property Error (Laravel Application::$appPath at boot)
…releases the +1 a conditional or literal callee holds: Laravel's call_user_func($cb ?? $default, ...) leaked the closure and all it captured per request
…ld and release a collected node's other cell slots: no cycle through an untyped property was ever collected, which kept Laravel's per-request Request/Route/closure graph and every string it held
… cases; spread_into_variadic_closure lands as _repro beside a different case of that name)

Fixes #541
Fixes #375
Fixes #517
Fixes #463
Fixes #476
Fixes #477
Fixes #565
Fixes #464
…the compares: the strval() an erased subject is unboxed by leaked one class name per Laravel container make()
…ed as a callee (call_user_func($this->getRouteResolver())), a closure stored raw in a reference box whose cell drop skipped it ($default in Router::substituteImplicitBindings), and (array) over a minting operand retained again (Router::resolveMiddleware)
…me representation: call_user_func($this->getRouteResolver()) with @return \Closure leaked Laravel's route resolver and its Route per request
…the slot holds a bare cell, every class of it does. Model::$attributes celled through the parent while Note kept vec[cell], so Note's drop released a tagged word as an array buffer and every Laravel /api/notes request crashed
@blacktrs
blacktrs merged commit e89ac72 into main Nov 9, 2026
49 checks passed
@blacktrs
blacktrs deleted the perf/laravel-build-mem-and-leaks branch November 9, 2026 13:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment