Skip to content

Cut large-app build time: fewer inference passes, smaller IR, cheaper split and clang - #585

Merged
blacktrs merged 53 commits into
mainfrom
perf/build-speed
Nov 9, 2026
Merged

blacktrs merged 53 commits into
mainfrom
perf/build-speed

Conversation

@blacktrs

@blacktrs blacktrs commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

Compiling a large PHP application spent most of its time re-analysing unchanged code, then handing clang hundreds of megabytes of IR that repeated the same tables and switches. This branch cuts a full cold -O2 build of the Symfony demo from 36 to 14 minutes and of Laravel from 28 to 14, and the heaviest clang part of the demo from 6.8 to 2.4 GB.

Results

Full cold build, -O2, fresh object cache, macOS arm64, one run each on a loaded machine:

Before After
symfony-demo, wall 2161 s 856 s
symfony-demo, clang wall 1202 s 487 s
symfony-demo, clang CPU summed over parts 5119 s 2434 s
symfony-demo, staged IR 3226 MB 2178 MB
symfony-demo, largest part 6.8 GB, 128 s in clang 67 MB, 2.4 GB, 65 s
Laravel, wall 1688 s 852 s
Laravel, front end to the end of NarrowReturns ~440 s ~270 s
Laravel, clang CPU summed over parts 4027 s 2582 s
Laravel, peak RSS of the build tree 13.7 GB 13.7 GB

The demo rows come from these patches merged onto examples/symfony-demo, with one change from a separate branch (perf/prop-deny-shared: the $o->$name visibility table, about 280 MB of the IR saving) because that code exists only there. The per-part clang numbers are each part compiled on its own, 8 at a time. The Laravel rows predate the last IR changes.

What changed

Inference

  • Monomorphize no longer clones names shadowed by a codegen builtin (array_key_exists, in_array, array_keys, array_values, array_is_list). A clone missed the builtin and scanned the whole array; this was ~35% of the demo build.
  • Monomorphize rounds after the first re-infer only the new clones and their callers. Module scans re-infer what they touched (ported from laravel). A body whose signature held still wakes only its callees; a settled element-as-key body is not re-inferred.
  • The late NarrowReturns pass stops only when its last full inference moved no types. MANTICORE_DEPS_SHADOW=1 checks that the module ends at a fixpoint.

Emit and IR size

  • An erased invoke ($callback(...) on a value of unknown type) runs through one helper per argument shape instead of splicing every candidate arm (function names, callable arrays, __invoke, closures) at each site: 388 to 67 MB of the demo. A variable argument is passed by address, as a &$x parameter receives it, so a by-reference callee still writes the caller's variable; the call site's trace line is passed in. MANTICORE_ERINV_TRACE=1 names the sites that stay inline and why.
  • Dynamic-method spread fallbacks, erased spread calls (typed packs included, one helper per pack type) and new $class(...$pack) share one helper per method name or one constructor table instead of a copy per site. The erased-receiver member-call Error is one cold helper per method name.
  • An unknown-receiver property read calls the shared per-name reader instead of inlining its switch; readers and writers ask one module predicate whether a class overloads __get/__set, and are linkonce_odr under the module token, so no split part has to copy all of them.
  • Reflection and dynamic-method tables share one string global per distinct name instead of one per row.
  • class_uses() resolves the class through the crc32 tree instead of a switch over every declaration.

Split and clang

  • With the object cache on, definitions are hashed into 4096 buckets and the buckets are packed into parts by weight, instead of hashing straight to a part: the demo's largest part went from 127 to 67 MB. An edit still only moves a bucket at a cut: after a one-function edit on symfony-app 27 of 65 parts hit the cache, against 15 before.
  • A file-local table of 4 KB or more gets one definition per module instead of a copy per part.
  • A definition of 1 MB or more is emitted noinline optnone (MANTICORE_OPTNONE_KB, 0 turns it off; not applied at -O3 unless set). Clang spent about 30% of its CPU on a handful of such functions.
  • Each global is classified once and parts close over a worklist.

Object cache after an edit

  • A one-function edit on symfony-app now recompiles one split part: 64 of 65 come from the object cache, against 15 before. Emitted code no longer depends on what was emitted before it: literal tables, shared helpers, resolve caches and row/scope tables are named by their content or key instead of a module-wide running number; helper bodies number their own registers; a virtual-dispatch key spells types instead of interning ids.
  • Shared helpers take their call site's line and file as arguments, so a trace frame or an Error raised through one names the caller instead of whichever function first asked for the helper.
  • emitMixedMagicCall marked the calls it wrapped by spl_object_id, an id handed out again once the node was freed: a later, unrelated call could skip its own __call check and call a method its class does not have. It keeps the nodes alive now.
  • MANTICORE_EMIT_REVERSE=1 emits functions in reverse; diffing the staged module against a normal build lists every body that still depends on emission order.

Compiler memory

  • The compiler no longer holds about 3 GB while clang runs. The object-cache key hashes 8 MB chunks instead of reading each part whole, the splitter is freed before clang, big bodies go straight to the staged file, and the extra body tables are written piece by piece. The cache key format changed, so the first build after this misses the cache once.

Yardsticks

  • MANTICORE_STATS / MANTICORE_PHASE_TRACE print a precision: census of erased types in the final MIR.
  • docs/design/inference-fixpoint.md covers the measurements and a plan for an inference fixpoint with recorded dependencies (Epic: incremental compilation #498).

Generated code

Emit, split and memory changes keep staged IR byte-identical on symfony-app; the IR-size changes are rewrites with the same results per class. The inference changes move staged IR by a few KB out of 745 MB on symfony-app, where one more inference after NarrowReturns moves nothing, as on main.

Bugs found on the way, with repros in tests/aot/repro/wrong: #595, #596, #597, #598.

Test plan

  • tests/aot/run.sh -k subsets, no failures: array, string, class, static, prop, foreach, closure, ref_, byref, dyn, generic, reified, narrow, mono, magic, generator, reflection, reflect, split, cache, escape, borrow, spread, erased, overload, property_hook, hook, enum, readonly, invoke, callable, callback, trace, exception, dyn_method, method_exists, array_map, array_walk, usort
  • New cases magic_shared_default_arm, erased_spread_method_typed_pack, erased_invoke_shared_helper
  • bin/build self-host, two generations; merged with main at 8c95ce87
  • Full AOT suite on Linux and macOS (CI)
  • Fixpoint and difftest: not run
  • Runtime cost of optnone on large functions: not measured

…Returns round, no builtin clones, MIR precision census under MANTICORE_STATS
…ody whose signature held still wakes only its callees
…tionary, zlib 1.4 changed it (Alpine now ships 1.4)
…tionary, zlib 1.4 changed it (Alpine now ships 1.4)
…ng early left properties narrower than their stores
…changes, and that the module reaches a fixpoint
…ng early left properties narrower than their stores
…moved no types, a scan can change facts after its walk
…ne name chain re-emitted the receiver per candidate (3M IR lines in one Doctrine method)
… erased spread method calls: one dispatch+unpack body per method name instead of one copy per shape/site
…odule constructor table instead of one inline arm per class; MANTICORE_DYNM_TRACE names the sites that still do not
…tor table too, the owned temp given back after; the test carries a dynamic method call so the table is exercised
…f a switch over every declaration (an Eloquent model calls it several times per attribute; /api/notes 6 to 17 rps)
…r method name instead of ~570 IR lines per site
…stead of a copy per part (part text -21%, objects -21%)
…, clang spends 30% of its CPU on a handful of them
…over 8 MB chunks instead of reading each part whole, free the splitter before clang, write big bodies straight to the staged file (Laravel tree peak 16.3 -> 13.7 GB)
…d of joining them, and release each table once written; staged IR byte-identical
… per distinct name instead of one per row (symfony-demo: 360k strings, 37k distinct, 64 MB of IR)
…a class overloads __get/__set instead of listing every overloading class in each helper (~250 cases in each of ~8 800 helpers on symfony-demo)
# Conflicts:
#	src/Compile/Mir/Passes/NarrowReturns.php
@blacktrs blacktrs changed the title Cut large-app compile time: fewer inference passes, no builtin clones, cheaper emit, split and escape analysis Cut large-app build time: fewer inference passes, smaller IR, cheaper split and clang Nov 8, 2026
…and names the constructor C::::construct() in ArgumentCountError (#596)
… typed spread pack takes the shared arm helper (one per pack type; Monomorphize clones forwarders per pack shape, mysqli_result::__mcFetchObject was 13 MB), and an unknown-receiver property read calls the shared per-name reader (symfony-demo closures of 25 MB of __get arms)
… dispatch helper per argument shape instead of splicing every candidate arm at each site; the call-site trace line is passed in, a variable goes over as its slot so a by-reference arm still writes it (MANTICORE_ERINV_TRACE names the sites that stay inline)
…ound by reference corrupts the closure result (#597)
# Conflicts:
#	src/Compile/Mir/Passes/EmitLlvmCalls.php
#	src/Compile/Mir/Passes/EmitLlvmObjects.php
#	src/Compile/Mir/Passes/InferTypes.php
#	src/Compile/Mir/Passes/Monomorphize.php
… parameter receives it: the caller passes its address, so ref-cell, global and by-ref-param variables share the helper too (symfony-demo: inline dispatch 349 -> 67 MB, staged IR -12%, clang CPU -11%)
…ight instead of hashing straight to a part; an edit still only moves a bucket at a cut (symfony-app, one-function edit: 27/65 parts hit the cache, 15/65 before; symfony-demo largest part 127 -> 67 MB)
… token instead of internal: the property tables name every one, and the split part holding them copied all 8 800 (51 MB on symfony-demo); clang peak per part 3.8 -> 2.4 GB
… cache key instead of a module-wide running number, and a virtual-dispatch thunk numbers its own registers: one edit renumbered every later table, helper and caller, so most split parts missed the object cache
# Conflicts:
#	tests/aot/cases/stdlib_zlib_inflate_stream.php
…r takes the call site file as well as its line, and other shared helpers no longer push a trace line relative to whichever frame asked first; MANTICORE_EMIT_REVERSE=1 emits functions in reverse to find bodies that depend on emission order
… 64/65 parts from the object cache, 15 before). Shared helpers take their call site line and file as arguments (dispatch arms, the member-call Error, which also stores it in the Error), resolve caches and row/scope tables are named by their key, a virtual-dispatch key spells types instead of interning ids, a key always maps to one name, and emitMixedMagicCall keeps the nodes it wrapped alive: their spl_object_id was handed to later nodes, which then skipped their own __call check
…-scope wrapper for what that scope may reach, instead of a copy of the whole table per scope (symfony-demo: 67 x 4.5 MB of IR)
…plicing is_object/method_exists/get_class at every site: with the wrapped nodes no longer mistaken for one another, every erased fluent call took it (symfony-demo staged IR 2691 -> 2509 MB)
…that builds the argument array itself, instead of an erased __call dispatch and its checks at every site (symfony-demo staged IR 2509 -> 2445 MB, one Configuration builder 3.1 -> 0.9 MB)
…w B][$i] leaked both objects

Only for an element read; hoisting a literal out of a write or by-ref chain miscompiled the compiler's own second generation.

Fixes #591
…ort on an element: $a['q'] mutated in place changed every copy of $a and every reuse of a literal

Through the element's slot, as a & binding takes it, for an outer array whose elements are statically arrays (raw slot, never a reference box); other shapes keep the old path.

Fixes #601
@blacktrs
blacktrs merged commit 5e4a0f7 into main Nov 9, 2026
55 checks passed
@blacktrs
blacktrs deleted the perf/build-speed branch November 9, 2026 11:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant