Skip to content

Optimize AOT by default (3.5x→1.3x vs Zend), fix exposed miscompiles, php-src exact pow/numeric strings, -146k lines - #37724

Merged
PurHur merged 3 commits into
masterfrom
claude/opt-by-default-and-compat
Oct 24, 2026
Merged

PurHur merged 3 commits into
masterfrom
claude/opt-by-default-and-compat

Conversation

@PurHur

@PurHur PurHur commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Three commits (see each message for detail):

  1. Perf: run the LLVM optimizer by default on standalone AOT builds — OPT_LEVEL>=1 crashed (php-llvm signature), default path skipped IR opts + OptNone codegen. Internalize + DCE unreachable runtime, O2 by default, inliner hygiene, delref fast/slow split, per-class TypeError chains → one runtime-name raise (ClassNameTable), compile-cache key now covers all lib/ext sources.
  2. AOT: fix miscompiles exposed by default optimization — keep peer-defined symbols external (muldefs interposition), peer-only functions optnone, zero-init pointer allocas (undef delref), sealed dangling blocks abort instead of bare unreachable.
  3. Compat — php-src exact int ** int (PowLongLlvm, replaces ~146k lines of per-exponent tables with wrong fallback), numeric strings in boxed arithmetic (NumericStringLlvm, "2"+"3" is int), runtime type names in operand TypeErrors, sound no-throw call analysis (callee TypeErrors were caught one statement late), -nan → NAN.

Measured (lab, scaled benchmarks, Zend 8.2 pinned image, best of 3, outputs identical)

Geomean AOT/Zend over 10 programs: 3.5× slower → 1.3× slower. untyped loop 2.22s→0.54s (Zend 0.48), nbody 1.29s→0.41s (Zend 0.41), spectral-norm 0.65× Zend, call-heavy 0.23× Zend. Not a committed table (benchmarks/v2 programs run ~10 ms and measure startup; regenerating tables is follow-up).

Gates run

  • make check (dev-verify-fast) OK on final tree.
  • AOT differential sweep (all 386 default cases, stdout+rc, 3 runs each; run in parallel with an xargs harness equivalent to script/differential-sweep.sh --aot --repeat 3): master 372 ok / 10 fail → this branch 374 ok / 8 fail, no case regressed; fixed: discarded_pathinfo_parse_url_elision_36386, discarded_str_pad_split_elision_36386.
  • Not run: compliance suites (VMTest/JITTest), north-star5-verify --strict.

🤖 Generated with Claude Code

PurHur and others added 3 commits October 24, 2026 18:48
)

Generated code had never been optimized in a default `phpc build`:
OPT_LEVEL>=1 died with a PHP fatal in vendored php-llvm
(PassManagerBuilder::populate*PassManager narrowed the interface's
parameter type), and the default path skipped IR passes and used OptNone
codegen. All `alwaysinline` value accessors stayed out-of-line calls.

- patches/php-llvm-pass-manager-builder-core-type.patch: fix the signature.
- Internalize every script-module function that peer objects (helper-runtime
  units, vendor prelink) do not reference (their `nm -u`), then GlobalDCE.
  ~80% of a small script's IR is unreachable runtime library; only plain
  standalone links (not chunk/partial/object-only/bitcode emits).
- Internalized standalone builds now default to the O2 module pipeline and
  Default codegen; PHP_COMPILER_OPT_LEVEL=0/none still opts out.
- Inliner hygiene: drop `alwaysinline` from helpers > 80 instructions,
  `minsize` on __init__/__shutdown__/__superglobals__*, `optsize` on
  functions > 4000 instructions; split __ref__delref into an inline fast
  path (decrement, still live, not a GC-root candidate) and a cold
  out-of-line slow path (Zend zval_ptr_dtor shape).
- Error-path bloat: the object-operand TypeError for arithmetic and the
  string-builtin object-arg TypeError emitted one raise arm per known class
  at every site (~80% of a typical function's IR). Now one raise with the
  class name from a module-level class-id table (ClassNameTable), message
  built at runtime; ExceptionBridge/TypeErrorRaise/TryCatchHelper gained
  runtime-message variants.
- CompileCache key now covers every lib/ and ext/ source (path/size/mtime)
  and the opt-level env knobs; it used to hash a hand-picked handful of
  files, so most codegen edits restored stale binaries. The cache stamp
  (module.bc) is saved before internalize/optimize.
- aot-smoke IR size gate measured a stale /tmp/phpc-last.ll whenever the
  echo build was a cache hit; delete it first and bypass the cache.

Measured (scaled-up benchmarks, Zend 8.2 in the pinned image, best of 3,
output verified identical; these are lab numbers, not a committed table):
geomean AOT/Zend over 10 programs 3.5x slower -> 1.3x slower; untyped loop
2.22s -> 0.54s (Zend 0.48s); nbody 1.29s -> 0.41s (Zend 0.41s);
spectral-norm 0.65x of Zend; call-heavy 0.23x. Compile time at O2:
nbody 404s (unfixed O2) -> ~14s; MiniWebApp 210s -> 38s.

Gates run: make check (dev-verify-fast) OK; targeted Zend-vs-AOT checks of
the object-operand and string-arg TypeError paths identical to master.
AOT differential sweep: running at commit time (see follow-up).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-up to the optimize-by-default change; the parallel AOT differential
sweep found 5 runtime regressions, all fixed here:

- Internalize keep-set now includes every global symbol peer objects
  *define*, not only what they leave undefined. With -z muldefs the
  script's definition wins for all references, including a helper unit's
  calls to its own copy; internalizing it rebound those calls to the
  (stale) unit copy -> crashes in InfoJitHelper/__string__separate
  (discarded_* cases).
- Peer-only functions (kept external for helpers, unreachable from main)
  get optnone+noinline, so the O2 pipeline is spent on the user program;
  compile time stays near the unsound-internalize numbers.
- Zero-initialize pointer-typed entry allocas. Some lowerings delref a
  pointer local on paths where it was never assigned: at O0 that read stack
  garbage, at O2 the undef read became a trap (j06_json_roundtrip).
- BasicBlockHelper::sealOpenBlock emits abort() before `unreachable`: a
  dangling block is a lowering gap, not a proof the path is dead, and a bare
  `unreachable` lets the optimizer delete the paths into it.

Gates: make check OK. Parallel AOT differential sweep (stdout+rc, 3 runs per
case, 386 cases) vs pristine master: 2 cases fixed
(discarded_pathinfo_parse_url_elision_36386, discarded_str_pad_split_elision_36386),
remaining regressions are only the 9 pow_hundred*_smul cases whose ~20k-line
tables crash LLVM at O2 — removed by the next commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…o-throw analysis

pow / ** (php-src Zend/zend_operators.c pow_function_base):
- New PowLongLlvm (__phpc_pow_long): square-and-multiply with overflow
  checks and php-src's exact double continuation on overflow; negative
  exponent -> double. Replaces ~146k lines of per-exponent chained-multiply
  tables (exponents 0..111, eleven files plus a fold trait) whose fallback
  for every other exponent was (int)pow((double)b,(double)e): wrong above
  2^53 and on overflow (2**64, 3**40, 7**23 ... returned garbage ints where
  php-src returns floats). The tables also crashed LLVM at O2.
- JitValueNumeric::powValueOperands (boxed operands) uses it too.
- VmPowNumericOperandGuard: the numeric-string check misclassified "2" as
  non-numeric and its continue block was left unterminated (sealed
  `unreachable`), so any string operand of ** was UB; the TypeError was a
  raw abort(), now the catchable path.

Numeric strings in boxed arithmetic (+ - * / % **):
- New NumericStringLlvm (__phpc_numeric_str) implementing
  _is_numeric_string_ex: whitespace, sign, digits, fraction, exponent,
  trailing whitespace; integer strings that fit zend_long are IS_LONG.
- "2" + "3" is int(5) (was float(5)); "5 apples" + 1 warns "A non-numeric
  value encountered"; "abc" + 1 throws "Unsupported operand types: string +
  int" with runtime type names on both sides.

TypeError messages: object-operand errors name the other operand's runtime
type ("Foo + int", was "Foo + mixed").

No-throw call elision (#36386 perf work) was a deny-list (throw/new/
include/calls only), so f($a,$b){return $a+$b;} counted as non-throwing and
the caller skipped its pending-exception check: a TypeError from f()
surfaced only after the caller's next statement (var_dump(f(...)) printed
NULL first). Now an allow-list with operand-type conditions (arithmetic only
on inferred numeric operands, / % only with a non-zero literal divisor,
shifts only with a non-negative literal count). Typed recursion (fibo,
call-heavy) keeps the elision — timings unchanged.

var_dump/echo of a negative NaN printed "-nan" (glibc) — now NAN via fcmp uno.

Bookkeeping: spine smoke list, size budgets, bootstrap inventory/profile,
OWNERSHIP regenerated for the removed/added files (spine 8588/8588).

Gates: make check OK; all 108 pow_*_smul differential cases match Zend
(3 runs each); targeted Zend-vs-AOT scripts for pow (24 int cases incl.
overflow), numeric strings, cross-function TypeErrors all match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@PurHur
PurHur merged commit 07395d1 into master Oct 24, 2026
0 of 2 checks passed
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