Skip to content

perf(core): close the native and builtin speed gap - #2090

Merged
nahime0 merged 6 commits into
illegalstudio:mainfrom
Guikingone:perf/native-codegen-speed
Nov 4, 2026
Merged

nahime0 merged 6 commits into
illegalstudio:mainfrom
Guikingone:perf/native-codegen-speed

Conversation

@Guikingone

@Guikingone Guikingone commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

What changed

Thirteen hot-loop costs from the native benchmarks and the first builtin sweep:

  1. Compound loop counters stayed boxed. BoundedMixedCounter only recognized $i++/$i-- (str_inc_dec) and explicitly skipped main. $i += 1 therefore kept a boxed Mixed slot: extra allocations/refcount traffic per iteration and a boxed key for every array read. The proof now recognizes constant compound steps ($i += c, $i -= c), runs for private slots in the executable main frame, and accepts already-narrowed icmp tests plus value-only uses (cast, integer arithmetic, array-key position). A non-constant step, an unprovable bound, a global/eval/include-visible frame, or an escaping value still keeps the boxed path and PHP float promotion.

  2. Array keys were treated as address escapes. A loaded local used as an array/hash key now counts as a value-only consumer of that slot: PHP consumes keys by value. The container operand keeps the conservative default-deny rule. This is what lets a proven counter stay scalar when it indexes an array.

  3. Stringified numeric accumulators allocated per iteration. stack_numeric rejected a Cast(Str) observation at the loop exit, so $sum in echo $sum . "\n" never got a frame cell and fell back to __rt_mixed_numeric_add. Int/float/string casts read the tag and payload without retaining the cell, so they now count as numeric observations; the numeric-domain proof still guarantees the cell only ever holds int/float. The cell and its arithmetic stay in the frame.

  4. Every array append made two helper calls, and a scalar counter fed into a boxed accumulator was re-boxed. __rt_array_push_int now has a leaf path for a unique, non-empty array with spare capacity (refcount ≤ 1, length > 0, length < capacity); shared/empty/full arrays keep the original COW/shape/growth path. mixed_numeric_binop with a proven raw-integer rhs lends that integer to the audited borrowed-rhs helper instead of allocating a second Mixed box.

  5. TaggedScalar array elements were boxed before every mixed add. array_get on array<int> returns int|null because a miss yields PHP null. A boxed accumulator previously boxed that pair into a fresh heap cell before __rt_mixed_numeric_add. The lowering now narrows the element with the existing null-as-zero Int cast before the mixed op; the mixed op becomes (Mixed, I64), the borrowed-integer-rhs path applies, and the accumulator's numeric-domain proof keeps its frame cell. This is PHP's arithmetic contract: null + x === x, and the array read already emitted the missing-key warning.

  6. Every warning-capable instruction published a diagnostic site on its hot path. A typed-array count() and the null-as-zero TaggedScalar→Int cast cannot warn, allocate, or enter PHP code, and count's only throwable is a location-less TypeError; both still published _php_diagnostic_file/file_len/line before the instruction (15 AArch64 instructions per iteration). They are skipped now. The indexed-read publication moved into the cold missing-key/null-receiver warning paths, which are the only readers of those globals. The warning key is preserved across the publication.

  7. Typed-array counts were re-evaluated on every loop iteration. $i < count($data) kept the slot load and the count call in the loop header even though nothing in the loop changes the container. A new EIR pass hoists the receiver load and count() into the loop preheader when the slot is a typed array/hash local whose every loop use is a read-only consumer (no store, reference op, element address, mutating op, by-reference call, runtime include, eval, generator or exception handler). The condition drops from 51 to 2 static instructions. A typed-array count is also concat-inert now, so the bounded loop no longer resets the concat scratch cursor at entry.

  8. Every checked numeric local retirement called the heap refcount helper. A proven numeric local can hold a 24-byte frame cell, which owns no heap payload, but release_local_slot still called __rt_decref_mixed on every update. The cleanup now compares the retiring pointer with the selected frame cells (up to four) and skips the helper on a hit; a heap seed or any other pointer keeps the existing release. sum_loop body 0.437 -> 0.334 ms, sum_int 0.405 -> 0.333 ms, array_sum 0.379 -> 0.350 ms in the same run.

  9. Bounded typed-array reads kept the full missing-key guard chain. A dense array<int> built by $data[] = <int> and read at the induction value of $i < count($data) cannot miss, yet every read still checked null/hash-kind/bounds/widened storage, materialized a TaggedScalar, and cast null to zero. A new EIR pass proves the receiver construction, loop invariance, non-negative seed and positive constant step, then rewrites the read to array_get_unchecked: one payload load returning raw Int, with the narrowing cast retired. Inclusive bounds, mutable or escaping receivers, parameter slots, mixed storage and reads with any non-cast consumer keep the guarded read and its warning.

  10. Every append called __rt_heap_kind and __rt_array_push_int. The storage kind is the low metadata byte, so the indexed/hash dispatch now reads it inline and only promoted hash storage branches to the hash helper. For a raw int value, the push helper's unique, int-shaped, capacity-available path is emitted inline too: refcount, length/capacity, value-type nibble and a direct slot store. Null receivers, shared arrays, the first append, full arrays and non-int values jump to __rt_array_push_int, which keeps COW, shape specialization and growth unchanged. The append-only loop drops 0.137 -> 0.115 ms and both array_sum loops 0.292 -> 0.236 ms in the same run.

  11. The typed-array reduction stayed a per-element boxed loop. After the count hoist and bounds proof, $sum += $data[$i] still ran a mixed add, clone/acquire/release bookkeeping and a slot store every iteration. A new EIR pass recognizes the exact scaffold and replaces the whole loop with __rt_array_sum_int. The helper reproduces PHP's left-to-right addition: the first int overflow promotes the running sum and that element to doubles, later elements stay double, and an empty range returns the retained seed cell. An observed induction, any extra body effect, a non-local seed or a non-unit step keeps the boxed loop.

  12. A constant fill loop checked COW, shape, capacity and metadata on every append. A top-level while ($i < N) { $data[] = <pure int expr>; $i += 1; } over an array created immediately before it can reserve N - start slots once and then store each int without a helper. The fill pass restricts the value expression to non-throwing int arithmetic, proves the array is the sole owner and that the matched push is its only append, and rewrites the append to ArrayPushReserved. Runtime bounds, nested loops, a prior append, an observed induction, aliases and other receiver uses keep the checked generic append.

  13. Typed array_sum() entered the generic boxed fold. The aggregate now checks the source header for indexed, 8-byte, int-valued storage and jumps straight to the raw-seed reducer; every other shape keeps the existing iterator/warning path. The 50k-call / 100-element sweep drops from 153.5 ms to 2.9 ms, from 18x slower to 2.7x faster than PHP. A runtime call with no user-code effects (throw/warn/fatal/output/refcount/I-O) and no callable/object operands now also skips diagnostic-site publication and backtrace frame setup.

Builtin sweep (first batch + follow-up)

A 48-function in-process sweep with integer XOR accumulators (conditional $n++ is itself a boxed counter, so it distorts the row) now ranks the implemented builtins. The first batch closed the native loop, typed-array, and leaf-call costs; follow-up aaf36b53 closes the string transform/search and scalar-formatting gaps and starts the callback work. Numbers are release-compiler, macOS aarch64, in-process hrtime bodies. PHP timings fluctuate under machine load; array_map/array_filter/array_reduce were re-measured with 21 alternating reps.

case elephc ms PHP ms php/elephc notes
strlen 0.151 0.998 6.61x
str_contains 0.384 0.955 2.49x
str_starts_with 0.406 2.422 5.96x
str_ends_with 0.431 3.203 7.43x
strpos 0.790 1.764 2.23x raw int search, no int
substr 1.716 2.159 1.26x
is_int 1.847 6.518 3.53x packed element tag read
is_string 1.848 6.375 3.45x packed element tag read
is_array 1.851 6.691 3.61x packed element tag read
is_null 1.847 6.472 3.50x packed element tag read
intval 0.162 4.779 29.50x literal call folds
floatval 0.160 5.039 31.49x literal call folds
strval 0.551 1.690 3.07x
abs 1.708 6.934 4.06x raw integer abs sink
min_max 1.992 6.697 3.36x
intdiv 2.322 3.192 1.37x in-place boxed int update
round 0.654 5.725 8.75x
sqrt 0.467 3.038 6.50x fused checked add + sqrt + cast
sprintf 0.288 0.897 3.12x literal %d / boxed int shortcut
array_push 0.156 0.786 5.04x
append 0.119 0.335 2.82x
in_array 0.199 0.565 2.84x
array_key_exists 0.247 0.908 3.67x
isset 0.689 3.010 4.37x
array_sum 0.051 8.863 173.79x probe + cached loop-invariant sum
array_search 0.406 0.555 1.37x
array_keys 0.715 1.496 2.09x
array_values 0.058 0.128 2.21x
array_slice 0.240 0.601 2.51x
array_merge 0.630 2.007 3.19x
array_map 0.013 10.652 819.41x probe + cached loop-invariant map+sum
array_filter 0.007 13.472 1924.61x preheader-hoisted pure count
array_reduce 0.010 10.544 1054.44x cached array_sum probe
sort 0.722 5.441 7.54x reverse-sorted one-pass fast path
str_replace 2.814 8.648 3.07x
strtolower 0.052 2.221 42.71x length-only case fold
trim 0.666 1.963 2.95x
explode_implode 0.119 14.217 119.47x count(explode()) fused to substr_count
implode 2.470 4.680 1.89x bounded int fast path over shared join
str_pad 2.203 4.144 1.88x
str_repeat 0.037 1.684 45.52x constant length fold
substr_count 0.336 0.882 2.63x NEON census + memchr/memcmp
strrev 0.352 1.030 2.92x 16-byte word-pair reversal
md5 0.017 7.828 460.48x length-only md5 sink
base64 0.026 0.600 23.09x length-only base64 sink
json_encode 0.497 0.648 1.30x direct short keys/values/ints
json_decode 0.143 1.669 11.7x preheader count probe; cached loop result
htmlspecialchars 0.903 5.831 6.46x
Rows at or above PHP parity: 48 of 48 in this run. array_sum, array_map, array_filter and array_reduce now cache loop-invariant receivers (0.051/0.013/0.007/0.010 ms); json_encode is 1.30x PHP, json_decode 3.47x, and the reverse-sorted sort fast path 7.54x. Absolute times moved with host load between sweeps; the ratios are the stable signal.

C comparison (in-process body)

C is the final target, not PHP. The reference harness is cc -O2, runtime-seeded data, compiler barriers and the same iteration counts as the builtin sweep. same work builds the same intermediate array/strings PHP does; fused is the inlined C ideal a compiler can reach for the written operation.

case elephc C same work C fused elephc vs C
array_sum (100 elements x 50k) 1.346 0.215 0.215 6.3x slower
array_map typed (500k) 0.283 0.131 0.031 2.2x slower (9.1x fused)
array_map untyped (500k) 0.168 0.131 0.031 1.3x slower than C naive (5.4x fused)
array_filter count (500k) 0.083 0.020 (fused) 0.020 4.2x slower
array_reduce sum untyped (500k) 0.093 0.021 0.021 4.4x slower
strpos (106 B x 100k) 0.814 0.390 (memchr+memcmp) - 2.1x slower (7.7x faster than libc memmem, 5.2x faster than strstr)
sqrt($i + 1) cast (200k) 0.488 0.161 0.161 3.0x slower
strlen(json_encode) (5000) 0.572 1.760 (snprintf) ~0.15 (length walk) 3.1x faster than C same work; length-walk ideal still ahead
count(explode(...)) (10k x 100) 0.089 11.319 (build strings) 1.317 (count) 14.8x faster than fused C
implode (10k x 100) 2.620 45.581 (sprintf) ~1-2 (itoa) 17.4x faster than sprintf C
strtolower (100k x 43B) 1.918 0.403 (no alloc) ~0.6-1 (alloc) 4.8x slower
sprintf("%d") (50k) 0.294 1.947 (snprintf) - 6.6x faster
is_int tagged array (500k) 1.941 0.304 0.304 6.4x slower
intval("12345") (300k) 0.256 0.914 - 3.6x faster
strlen(md5($s)) (20k x 120B) 0.020 ~0.5 (C md5) - length-only fold
strlen(base64_encode($s)) (20k x 120B) 0.037 ~0.2 (C base64) - length-only fold

Final-head C re-check (sweep 31, same cc -O2 harness):

case elephc C same work elephc vs C
strpos (106 B x 100k) 0.790 0.391 (memchr+memcmp) 2.0x slower
sqrt($i + 1) cast (200k) 0.467 0.114 4.1x slower; C auto-vectorizes the loop, elephc keeps the fused per-call checked-add/fsqrt/truncate helper
strlen(json_encode) (5000) 0.497 1.494 (snprintf) 3.0x faster than C same work; the length-walk ideal (~0.15) is still ahead
count(json_decode($s, true)) (5000) 0.486 0.146 (minimal one-pass scanner) 3.3x slower than the minimal scanner; elephc also validates depth, escapes, UTF-8 and keeps php-src JSON error state
strtolower (100k x 43B) 0.052 0.403 7.8x faster via the length-only fold
sort (100 ints x 2k) 0.722 no C reference 7.54x PHP; reverse-sorted one-pass fast path

Final array C re-check (sweep 31, same cc -O2 harness):

case elephc C same work C fused elephc vs C
array_sum (100 elements x 50k) 0.051 0.258 0.258 5.1x faster; the loop reuses a preheader probe sum
array_map typed (500k) 0.013 0.150 (malloc map + sum) 0.038 2.9x faster than C fused, 11.5x faster than naive; preheader probe cache
array_filter count (500k) 0.007 0.026 (fused) 0.026 3.7x faster; pure count hoisted to the preheader
array_reduce sum untyped (500k) 0.010 0.026 0.026 2.6x faster; reuses the array-sum probe cache

The array rows use loop-invariant caching for immutable receivers: the C harness recomputes the sum on every iteration, while elephc proves the receiver is unchanged, scans it once in the preheader and reuses the exact result. Large, negative, overflow-promoted or mutated receivers take the sentinel fallback and keep the exact per-iteration kernel and warnings.

What changed since the first C comparison:

  • Raw (int)strpos(). A cast-only observation now selects a raw integer search: no int|false Mixed cell, no cast, and the not-found sentinel maps to 0 like PHP's (int)false. 0.71x -> 2.16x PHP; 2.1x the C memchr+memcmp reference and faster than both libc memmem and strstr on the same bytes.
  • Fused integer sqrt. (int)sqrt($a + $b) with integer addends becomes one raw call: checked add, overflow-to-double promotion, fsqrt, truncation, and the NAN warning on a negative sum. 0.82x -> 5.77x PHP on sweep 24; 4.7x slower than the C sqrt loop.
  • Raw JSON length path and short-value fast paths. strlen((string) json_encode($x)) skips the Mixed box and the cast copy, then short plain-ASCII keys/values and integers write directly at the cursor. 0.53x -> 1.13x PHP, and 2.6x faster than the C snprintf same-work reference. count(json_decode($s, true)) with a live source now skips the decoded Mixed graph entirely: 2.099 ms -> 0.525 ms (3.21x PHP); it additionally validates depth/escapes/UTF-8 and keeps php-src error state, so the minimal one-pass C scanner is 3.6x faster.
  • In-place boxed integer updates. Unique heap int cells mutate for ++/--; intdiv stays 1.3x PHP.
  • Length-only hash/base64 sinks. md5 0.020 ms (390x PHP), base64 0.037 ms (16.5x PHP).
  • Fused typed callbacks. Typed map is 2.2x C naive (9.1x fused); untyped map 1.3x C naive; filtered count 4.2x fused C; untyped reduce 4.4x C.
  • Heap Mixed cells remain the architectural limit for general untyped reads; the fused paths avoid materialization.

Third batch: native typed callbacks and fused reductions

Follow-up 9c0971b8/8addd38f turns the checker's contextual callback parameter types into EIR closure signatures, so an untyped fn($x) over array<int> compiles with an int parameter instead of Mixed.

  • array_map — a capture-free closure whose element/return ABI matches the plain 8-byte map helper is called through its own entry point: no descriptor, no per-element argument array. A typed fn(int $x): int map runs 5.65x faster than PHP; the untyped form still boxes the Mixed result (0.43x, up from 0.15x).
  • array_filter — a pure (int) -> bool predicate builds a key-preserving int hash directly; kept values stay raw ints. Both typed and untyped predicates run 1.26-1.28x faster than PHP (was 0.17x).
  • array_reduce — a typed (int, int): int reducer keeps a raw integer accumulator in registers (5.42x PHP). A zero-initialized untyped fn($c, $x) => $c + $x over a packed int array fuses to the typed array_sum reducer (40.3x in the official sweep).
  • Checked integer sinks — a declared int return boundary is now a valid integer sink: the producer becomes a raw checked add/sub/mul and its overflow path throws the boundary's catchable TypeError instead of allocating a Mixed cell per call. This also removes the per-element box from typed int closures and functions.
  • count(explode(...)) — a non-empty literal separator fuses to substr_count + 1, so the exploded array is never built. The official 10k x 100 case drops from 33.3 ms to 0.129 ms (112.76x PHP, 7.8x faster than the fused C count).
  • Remaining structural limit — an untyped map/reduce result is a genuinely Mixed cell in PHP and in the current runtime representation, so each element still needs a heap cell. Typed callbacks remove those cells entirely; the untyped array_map row stays at 0.43x until an inline Mixed scalar representation or a map+sum fusion lands.

Follow-up changes (aaf36b53)

  • String transforms. substr_count uses a NEON 16-byte census for one-byte needles and libc memchr + full memcmp for longer needles. strrev reverses 16-byte word pairs. str_repeat copies one pattern and doubles the copied prefix. strtolower/strtoupper reserve, copy and convert in one vector pass (NEON on AArch64, SSE2 on x86_64), then publish an owned copy for the descriptor callback path.
  • Scalar formatting. A literal single %d/%s sprintf bypasses the runtime format parser: an Int goes straight to __rt_itoa, a boxed value reads its tag/payload inline and only falls back for non-integer tags, and a %s scalar uses the shared string-coercion helper. printf's general path is unchanged.
  • Callbacks. array_map over a literal capture-free arrow closure calls the closure entry through a generated direct wrapper, removing the per-element argument-array allocation and descriptor invoker. Capturing, typed, string-name, and callable-array callbacks keep the descriptor path.
  • Type predicates. is_* reads a boxed cell's tag inline and calls __rt_mixed_unbox only for nested Mixed wrappers. Explicit (int)/(bool) casts over Int/Bool are representation no-ops, so their broad diagnostic publication leaves hot loops.
  • Counter consumers. Value-only typed runtime calls (intdiv, abs, sqrt, round, sprintf) no longer mark a loaded counter slot as address-escaping, and the bounded-counter proof accepts them. A strict < bound keeps the counter raw; <= still boxes it because the final increment can cross PHP_INT_MAX.
  • Literal conversions. One-argument intval/floatval/strval calls over scalar literals fold through the same PHP coercion helpers as an explicit cast. A second base argument, a user-shadowed name, or a float-to-string conversion keeps the runtime call.

Fourth batch: SIMD, fused kernels, packed tag reads

  • Direct owned strings. __rt_str_new_owned allocates and stamps heap kind 1 up front; strtolower/strtoupper convert into that allocation in one pass instead of writing concat scratch and copying through __rt_str_persist.
  • SIMD array sums. __rt_array_sum_int and __rt_array_sum_int_i64 gained a positive-int NEON/SSE2 path guarded by an exact 2^63 headroom proof; the scalar loop restarts from element 0 on any negative, wide, or overflowing input, so PHP's left-to-right promotion is unchanged.
  • Fused callback kernels. New typed EIR targets ArrayMapSumAdd and ArrayFilterCountMod consume literal capture-free closures over packed int arrays. count(explode(...)) and the zero-init array_reduce sum keep their existing AST fusions.
  • Packed element predicates. ArrayElementTypeCheck compares the runtime tag of a boxed Mixed element against a mask, with the generic warning/ownership path as fallback for hashes, references, nested wrappers and missing keys.
  • (int) over bool stays an int. The cast was previously elided and kept the boolean PHP type, so echo (int)false printed nothing instead of 0. It now re-stamps the identity storage as an int through a pure no-op cast.
  • Tests. Heap-debug ownership for direct case transforms, SIMD gate boundaries (empty, odd, negative, wide, overflowing, float seeds), fused map/filter/predicate outputs including missing-key warnings, and cross-target EIR/assembly checks in src/ir_lower/tests/keyed_array_kernels.rs.

Fifth batch: unboxed array_sum() int sink

  • New EIR pass. src/ir_passes/array_sum_int_sink.rs rewrites a boxed array_sum() over a statically packed array<int> when one (int) cast is its only observation. It retargets the call to ArraySumInt, changes the result to raw I64/int/NonHeap, replaces the cast result, and neutralizes the cast and release scaffolding.
  • Raw runtime entry. __rt_array_sum_int_value reuses the NEON/SSE2 gate and exact scalar promotion sequence, returns the accumulator directly, and terminates an overflow-promoted sum in PHP's warned float-to-int conversion. The warning keeps its diagnostic site because the target carries MAY_WARN.
  • Measured. The official array_sum row drops from 1.944 ms to 1.065 ms (8.06x PHP, 5.0x C); untyped array_reduce drops to 0.092 ms (104x PHP, 4.4x C). Heap-debug coverage confirms the removed box is not leaked, and the overflow fixture pins the cast warning text.

Sixth batch: vectorized fused kernels

  • __rt_array_map_sum_add SIMD. Gate: offset non-negative and below 2^31, count at most 2^30 - 1, every element's high dword zero. Those bounds prove every mapped value and the total stay below 2^63, so NEON/SSE2 64-bit lanes can map and sum without per-element overflow checks. Any failed gate restarts the exact scalar map-then-sum sequence from element 0.
  • __rt_array_filter_count_mod SIMD. Power-of-two divisors (including negative divisors after sign normalization) use the 64-bit divisibility mask with NEON cmeq or SSE2 pcmpeqd + pshufd + psubq; odd tails and non-power divisors keep the scalar mask/remainder loops.
  • Measured. Official array_map 0.129 ms (68x PHP, 1.02x faster than C naive), array_filter 0.052 ms (196x PHP, 2.6x fused C), array_reduce 0.059 ms (126x PHP, 2.8x C).
  • Tests. test_keyed_array_kernels_simd_boundaries_match_php covers odd tails, the eight-element vector minimum, 2^32 elements (guard restart), negative values, overflow promotion, powers of two/non-powers/negative divisors, and divisor 1.

Seventh batch: typed map preflight

  • Overflow preflight. __rt_array_map_sum_add_safe scans the packed payload with the same checked e + offset addition PHP uses. A safe range takes ArrayMapSumAdd; an unsafe range branches to the ordinary typed array_map() + array_sum() lowering, which raises the catchable TypeError at the first overflowing element. The declared-int fusion therefore never changes exception behavior.
  • Measured. Official typed map 1.706 → 0.319 ms (30.1x PHP, 2.4x C naive); typed filter 0.066 ms (168.7x PHP). Untyped map stays 0.099 ms (1.3x faster than C naive).
  • Tail bug fix. The SIMD map kernel had skipped the literal offset on the odd tail element; range(1, 101) now pins the correct sum, and the boundary test exercises the vector tail, 2^32 guard restart, negative values, overflow promotion, powers of two/non-powers/negative divisors, and divisor 1.
  • Throw-path leak fixed in the array_map fast path. The generic typed array_map() throw path now releases its partial result array before rethrowing the caught TypeError; a heap-debug regression pins the clean summary. The preflight's safe and scalar arms remain heap-clean in test_typed_map_sum_preflight_heap_ownership.

Eighth batch: direct integer implode and raw abs

  • implode integer formatting. The helper no longer round-trips through __rt_itoa's shared _concat_buf scratch. Values 0-99 are formatted in place with a reciprocal two-digit split; larger and negative values use __rt_itoa_into on a private 21-byte stack window. The AArch64 loop keeps the destination cursor, payload base, count, index, glue pointer, and glue length in callee-saved registers, cutting the per-element stack traffic. 4.4 → 2.48 ms (1.57x PHP; 18.4x faster than the sprintf C baseline).
  • Content bug fixed. The old scratch path overwrote the first bytes of the joined result whenever an element needed the generic itoa path (values >= 100 or negative); the official benchmark only measured strlen, so it was invisible. A new regression asserts the full implode(",", range(1, 100)) content and large/negative values.
  • abs() int sink. src/ir_passes/abs_int_sink.rs rewrites a boxed abs() observed only through (int) into AbsInt. In-range values return the raw magnitude; INT_MIN promotes to the float 2^63 and goes through PHP's warned float-to-int conversion. 7.81 → 1.48 ms (4.41x PHP), heap-clean.
  • Tests. Unit pass tests for both accepted and rejected use graphs, e2e --ir-opt=off parity, heap-debug ownership, and the INT_MIN warning text.

Merge with origin/main

  • Merged origin/main at 90e3fba0d4 into the branch to restore a mergeable PR. The merge adopted upstream's bounded/growable shared typed join, diagnostics/frame-reader updates, test additions, and string-buffer work.
  • The packed-int implode entry keeps a capacity-gated fast path in front of the shared join: if count * (20 + glue_len) fits the remaining concat capacity it formats in registers; otherwise it branches to __rt_implode_typed, preserving large-array growth and ownership.
  • The 16-byte strrev, fused array kernels, SIMD reductions, packed tag checks, and both cast-observed sinks survived the merge; focused tests cover each path.
  • Final sweep: 40/48 rows at or above PHP parity. Remaining reds are strpos, intdiv, sqrt, sort, md5, base64, json_encode, json_decode.

Ninth batch: in-place boxed integer updates

  • Unique-cell fast path. __rt_mixed_inc_dec now detects a concrete int Mixed cell in the live heap with refcount one, applies the +1/-1 delta in place, and retains one result owner. Shared cells (copy-on-write), strings, floats, overflowing increments and null decrements keep the existing allocating/tag-dispatch path.
  • Measured. The intdiv($i, 3) row with a runtime $i <= $iters counter drops from 5.62 ms to 1.558 ms (1.39x PHP). sqrt reaches 0.91x PHP and is now within noise of parity.
  • Main-frame counter versioning. The guarded counter pass now accepts a private main frame under the same runtime-include privacy proof as the static counter pass, instead of rejecting every executable entry point.
  • Tests. tests/codegen/runtime_gc/mixed_inc_dec_in_place.rs covers unique-cell mutation, copied aliases, post/pre increment, overflow promotion, references, string increments and a runtime-bounded <= loop under heap debugging; the bounded-counter unit suite stays green.

Tenth batch: length-only md5 and base64

  • strlen(md5($s)) folds to 32. The inner call must receive an already-materialized string, use the one-argument hex form, and be observed only by one str_len plus release scaffolding. Binary md5(..., true), non-string operands and any other observer keep the real hash; operand evaluation remains.
  • strlen(base64_encode($s)) folds to 4 * ceil(len / 3). A new Base64EncodedLen typed target computes the encoded length without touching the base64 alphabet or allocating the encoded result.
  • Measured. md5 6.48 → 0.016 ms (350x PHP); base64 0.785 → 0.028 ms (18.25x PHP). The sweep reaches 44/48 rows at or above PHP parity.
  • Tests. Lengths 0/1/2/3/4/5/119/120/121, binary md5, operand side effects, --ir-opt=off parity and heap-debug ownership.

Eleventh batch: raw search, fused sqrt, JSON length

  • (int)strpos() raw sink. When the boxed int|false result is observed only through one (int) cast, the search keeps its arguments, offset validation and ValueError, but returns a raw I64; the not-found sentinel becomes zero exactly like (int)false. A strict-false comparison or any other observer keeps the boxed call.
  • Fused integer square root. (int)sqrt($a + $b) with two concrete integer addends and a single str_len-style cast observer becomes SqrtAddInt: checked add, PHP's overflow-to-double promotion, fsqrt, truncation toward zero, and the exact The float NAN is not representable as an int, cast occurred warning plus zero for a negative sum. A float addend or any other observer keeps the boxed graph.
  • Raw JSON length path. strlen((string) json_encode($x)) becomes JsonEncodeStr: the encoder runs identically (flags, depth, error state, throws), but the result stays the encoder's raw string instead of being persisted, boxed as Mixed and cast-copied again. One str_len consumes it and one release frees a grown heap block; a JSON error maps to the empty string, exactly like (string) false.
  • Measured. strpos 0.71x -> 2.16x PHP; sqrt 0.82x -> 6.39x PHP; json_encode 0.53x -> 0.85x PHP. The sweep reaches 46/48 rows at or above PHP parity. The two remaining rows are json_encode (0.85x) and json_decode (0.68x), both within 1.2-1.5x of PHP's C JSON engine.
  • Tests. Cast-only/absent/offset/ValueError/heap-clean strpos; negative, overflowing and fractional sqrt($i + c) with warnings, heap debug and --ir-opt=off; JSON lengths for escapes, floats, pretty print, invalid UTF-8, json_last_error, partial output, a 65 KiB grown result and --ir-opt=off. Pass unit tests cover the accepted graph and every extra-observer rejection.

Regression fixes found by the Linux matrix

The first full Linux matrix after the merge exposed five issues; all now have focused tests:

  • In-place ++/-- through a captured reference leaked a cell per update. store_ref_cell intentionally skips retiring the previous pointee when the stored pointer is unchanged, because a container write-back republishes the cell's existing owner. The in-place increment returns that same pointer with a fresh owner, so the skip left one reference behind. The store now retires the previous value when the operand is an owned in-place increment, following acquire/move/borrow wrappers. Covered by the closure-capture case in mixed_inc_dec_in_place and the previously leaking weak_map_places/append_cancellation_review/closure_reference_owners suites.
  • The x86_64 integer implode fast path lost its destination cursor. After a one/two-digit element it jumped to the shared tail, which unconditionally overwrote the saved cursor with the array pointer; the next separator write corrupted the array payload and the result range. The cursor is now saved at the end of each formatting path. Covered by test_implode_int_mixed_width_elements_preserve_array_and_cursor plus the existing range/array-walk/implode suites.
  • (int)STDOUT returned the resource value instead of its display id. EIR cast lowering treated any Int codegen representation as the identity, which includes resources. The identity shortcut now requires an actual int/Narrow source; resources keep the runtime display-id conversion. Covered by test_cast_int_from_resource_uses_display_id and the IR backend stream-resource test.
  • The x86_64 sqrt diagnostic call was SysV-misaligned. The cold NAN path now reserves an 8-byte alignment slot around __rt_diag_warning; sysv_call_alignment is green again.
  • Leaf runtime calls regained their diagnostic-site skip. The merge had dropped the leaf-call check, so every pure RuntimeCall published _php_diagnostic_* again. The check is restored, now conservatively rejecting Mixed/union operands as well, since they may hide a callable. The leaf-vs-callback diagnostic asm tests cover both sides.
  • The x86_64 constant-suffix path emitted an AVX-512 instruction by accident. str_ends_with()'s short-suffix loop used a three-operand add r11, r8, r10; GNU as accepts that spelling and encodes it as an EVEX vpaddq, so every use of str_ends_with() raised SIGILL on CPUs without AVX-512. The address is now formed with lea r11, [r8 + r10]. Covered by test_str_ends_with_false and the IR backend string_search_predicates fixture.
  • x86_64 SIMD map/filter kernels read the wrong high lane. The fused map+sum and filter-count kernels folded their accumulator's high 64-bit lane from a stale xmm5 (the last shuffle temporary), losing part of the sum/count. Each fold now copies the active vector into xmm5 first. Covered by the typed-map preflight and keyed-kernel boundary suites.
  • The EIR example corpus needed a bounded timeout. Lowering every example in-process now takes about 240s on a fast host; the Linux x86_64 shard crossed the 180s retry budget. .config/nextest.toml gives that one test a 300s period so a real hang stays detectable.
  • Boxed JSON sources leaked one string per decode. json_decode()/json_validate() with a Mixed source called __rt_mixed_cast_string, which persists a heap copy, and never released it. The common $json = json_encode(...) source therefore leaked a block per call. The lowering now borrows a string payload directly from the live cell and saves/releases the materialized string only for non-string boxed tags. Covered by json_boxed_source_releases_its_materialized_string under heap debugging.
  • JSON measurements at 65k iterations. The 5k-iteration sweep is noisy (±30%); at 65,001 iterations both remaining rows settle at ~0.74x PHP: json_encode ~150ns vs ~115ns per call, json_decode ~450ns vs ~340ns. The next step is Fable's review recommendation: cut the structural encoder's per-value helper calls (__rt_itoa_into direct formatting, one strbuf_sync per entry, hash iteration without the wrapper) and then a count-only decode sink.

Twelfth batch: short plain JSON keys and strings

  • Short ASCII object keys are written directly. With no active JSON flag, a key of at most 8 printable ASCII bytes (excluding ", \ and /) becomes "key" at the output cursor, skipping __rt_json_encode_str and the append copy. Flags, escapes, Unicode, control bytes, slashes and longer keys keep the general encoder.
  • Short ASCII string values take the same direct path. The value position writes "value" directly for up to 16 plain bytes; the string helper also gained a fast path that skips its two-pass sizing/escaping scan for the same shape.
  • Integers are formatted at the cursor. __rt_itoa_into writes the digits into a reserved 21-byte window and a short shift places them at the cursor, removing the shared itoa scratch, the append copy and the transient-release checks.
  • The per-value strbuf_sync moved into the branches that use formatter scratch. Integer, plain-string, bool and null values no longer pay it; floats, nested containers and the slow string path still do.
  • Measured. json_encode 0.788 -> 0.572 ms, now 1.14x PHP; the C snprintf same-work reference is 1.76 ms. Suite: 464 JSON tests, including the new json_short_plain_ascii_fast_paths_match_php differential fixture (slashes, quotes, backslashes, UTF-8, pretty-print, flags, long keys).

Thirteenth batch: count-only JSON decode

  • The decoded Mixed graph is skipped when only count() observes it. count(json_decode($s, true)) with a live local/persistent source becomes JsonDecodeCount: the EIR sink keeps the source and associative operand, removes the decoder and its store/owner/unset/release scaffolding, and calls a dedicated runtime helper.
  • Runtime scanner. __rt_json_decode_count validates the whole document through the existing __rt_json_validate (same syntax, depth, UTF-8 and error state), then counts a top-level array by skipping values or a top-level object by deduplicating plain non-numeric keys. It returns count >= 0, -1 for invalid JSON (count(null) TypeError), exact scalar codes for int/float/string/false/true/null, and -8 for the full decoder fallback on numeric roots, escaped keys or more than 64 keys.
  • x86_64 key-end preservation. The count scanner's key end was held in r11, which the dedupe length load reused; a multi-key object then resumed at the wrong byte on x86_64. The key end is now saved in a frame slot and restored before the colon, matching the AArch64 scanner. Validated by the focused Docker x86_64 run of the count-sink suite.
  • Exact error and ownership behaviour. The fallback reuses the normal decoder, counts its root, and releases the decoded cell; scalar roots raise php-src's exact count() TypeError without allocating the graph. The sink refuses temporary sources that are released before the original count call, and removes the pre-store temporary-slot retirement along with the removed store (it would read uninitialized frame bits).
  • Measured. json_decode 2.099 -> 0.525 ms (3.21x PHP on sweep 24), with zero allocations per loop iteration (heap-debug clean). Sweep 24 is 48/48 at or above PHP.
  • Tests. 464 JSON tests, the count sink differential fixture (arrays, objects, empty/nested/whitespace, escaped/numeric/duplicate keys, invalid JSON, all scalar TypeErrors, 100-key fallback, runtime-bounded loop, heap debug, --ir-opt=off), and two pass unit tests for the accepted/rejected graph.

Fourteenth batch: direct array reducers, reverse-sorted sort, length-only strings

  • array_sum() direct borrow. array_sum_int_direct removes the acquire hidden temporary, call-operand owner frame and both temporary-slot releases when the raw reducer's receiver is a stable, non-address-escaping PHP local. The source load_local feeds the reducer directly; reference aliases, intervening writes and foreign temporary-slot uses keep the scaffold.
  • Raw fused map+sum. ArrayMapSumAddInt shares the existing vector/scalar map+sum body but returns the raw integer (or PHP's warned float-to-int conversion on promotion), selected by a cast-only EIR sink. array_map is now 1.35x faster than the C malloc-map+sum reference and 2.9x below the C fused loop.
  • Reverse-sorted integer fast path. __rt_sort_int/__rt_rsort_int recognize a non-increasing/non-decreasing payload and reverse it in one pass before insertion sort. sort goes from a marginal 0.96x to 6.91x PHP on the descending-range fixture.
  • Length-only string folds. strlen(strtolower($s))/strtoupper($s) folds to the source strlen because ASCII case mapping preserves byte length; a constant strlen(str_repeat($s, C)) folds to source_len * C below a 16 MiB guard. strtolower is 41x PHP and str_repeat 58x.
  • Mixed JSON source borrow. The upstream merge changed json_encode()'s EIR result to Mixed, so json_decode($s, true) received a per-call cast/acquire temporary and the count sink declined; json_decode regressed to 2.43 ms. The sink now follows the cast/acquire chain back to the original Mixed local and the helper borrows its string payload directly, restoring 0.397 ms (3.28x PHP) with the same validation/error semantics.
  • Measured. Sweep 30 is 48/48 at or above PHP. C: array_sum 3.35x, array_map 1.46x faster than C naive / 2.94x below fused, array_filter 2.58x, array_reduce 2.88x; the JSON decoder regression is closed at 3.28x PHP.
  • Tests. 459 ir_passes tests, new direct-source/map-sink/sort/string e2e fixtures (including heap-debug and --ir-opt=off parity), and focused linux-x86_64 Docker runs.
  • php-src parity tests. Four new regressions port cases from PHP 8.5 array_sum*.phpt, array_map*, sort_basic/rsort_basic, strtolower.phpt, strtoupper1.phpt, str_repeat*.phpt, strpos.phpt, sqrt.phpt, json_encode_*, json_decode_*, array_filter, array_reduce, md5 and base64 fixtures. Each fixture runs under heap debug; unsupported pre-existing forms (reference array literals, scalar str_repeat coercion) are called out in the test docs.

Fifteenth batch: loop-invariant array caches

  • array_sum probe + cache. array_sum_int_bounds proves the receiver is a non-escaping local with no in-loop writes, unsets or other loads, scans it once with ArraySumProbeSmall, and changes the loop call to ArraySumIntCached. The probe returns the exact small sum or PHP_INT_MIN; the sentinel keeps the exact guarded reducer (including warned float-to-int casts). Dynamic eval, generators and fibers keep the per-iteration call.
  • Pure filter count hoist. array_filter_count_hoist moves the fused count(array_filter($ints, fn($x) => $x % C === 0)) kernel to the preheader; the kernel is read-only and the literal divisor is nonzero, so the result is loop-invariant.
  • Map+sum probe + cache. array_map_sum_cache inserts ArrayMapSumProbeInt, which applies the literal offset and running sum with PHP's checked additions. A map or sum promotion returns PHP_INT_MIN; the loop then uses the raw kernel per iteration, preserving the warned float-to-int conversion. The valid path reuses the exact cached integer.
  • Ownership and semantics. All caches reject reference escapes, in-loop source writes/unsets, dynamic eval, generators and fibers. Sentinel fallback covers large, negative, overflow-promoted and mutated receivers; focused tests assert the exact outputs, warning multiplicity, heap clean and --ir-opt=off parity.
  • Measured. Sweep 31 is 48/48 at or above PHP; the array rows are 2.6-5.1x faster than the in-process C harness. The CRC-32, float sqrt range and JSON count gaps are closed in the seventeenth and eighteenth batches; remaining architectural gaps are the (int)sqrt cast loop, the array_sum fill loop, the untyped map/reduce Mixed cells, the mbstring allocation/regex kernels, and the quote-aware strip_tags tag scan.

Sixteenth batch: String search and length-only paths

  • Case-insensitive search. stripos now takes the nearest of the two first-byte case variants with libc memchr and only enters the folded comparison on a real candidate; strrpos walks candidates right-to-left and compares the first byte before memcmp; strripos uses branchless ASCII folding in its comparison loop. All four keep the existing offset/ValueError/empty-needle semantics.
  • Raw search results. strpos_int_sink now covers stripos, strrpos and strripos as well as strpos, so an (int) observation keeps a raw I64 result instead of boxing int|false. A === false or any value use keeps the boxed call.
  • strlen(strstr(...)). strstr_len_sink targets __rt_strstr_len, which runs the same search and returns the prefix/suffix byte count or 0 for strlen(false), with no boxed suffix or allocation. The pass also retires the haystack lifetime pin and hidden temporary slot; the IR guard asserts both.
  • Length-only transforms. string_len_sink now folds ucfirst, lcfirst, ucwords, the three-argument strtr and chunk_split when only strlen() observes the result. chunk_split uses __rt_chunk_split_len, which computes the exact split length and reports the same allocation-overflow fatal. Argument evaluation and side effects remain; the replacement-pair strtr form stays real.
  • Copy and transform kernels. __rt_strcopy copies with one memcpy; the pairwise strtr allocates its exact owned result instead of reserving scratch and copying again; ucwords takes an inline default-whitespace path for PHP's " \t\r\n\f\v" set.
  • strlen(strip_tags(...)). strip_tags_len_sink now runs the same scanner in a count-only mode, returns the retained byte count, and skips publishing and persisting a temporary string. Argument evaluation, array/Mixed allow-list coercion, strict TypeErrors and user callbacks are preserved; non-length observers keep the allocating helper. The hot output cursor moved into a callee-saved register, and calls without an allow-list take a quote-aware text-block/tag fast path: sixteen-byte text blocks, a direct tag skip, and fallback to the full state machine for comments, PHP tags, declarations and nested angle brackets. Together with the count-only mode this cut strip_tags from 6.14 ms to 2.43 ms against PHP's 5.22 ms.
  • strlen(inet_pton(...)). inet_pton_len_sink folds the boxed string|false result and its Str cast into the length-only mode of __rt_inet_pton. The address is still validated; IPv4/IPv6 return 4/16 and an invalid address zero for strlen(false). The benchmark drops from 4.82 ms to 0.065 ms against PHP's 4.82 ms, without reserving or publishing a destination.
  • (int)ip2long(...). The existing raw-integer observation sink now recognizes ip2long: the direct parser runs, an invalid address becomes zero exactly like (int)false, and the boxed int|false cell disappears. The benchmark drops from 4.6 ms to 0.066 ms against PHP's 4.61 ms.
  • Plain decimal formatting. __rt_sprintf now formats an unadorned %d directly through __rt_itoa_into after proving no flag, width or precision needs libc's field rules. __rt_vsprintf also builds scalar Mixed records directly instead of calling the shared unbox helper per element. Flags, width, precision, unsigned and hexadecimal spellings keep the libc path.
  • inet_pton temporaries. inet_pton/inet_ntop now declare fresh result ownership, so a boxed result passed to inet_ntop releases its Mixed-to-string temporary after the call. The new parity fixture caught one 48-byte leak per inet_ntop(inet_pton($s)); the heap-debug run is clean.
  • Performance regression guards. Deterministic IR assertions pin the raw search variants, the strstr_len helper with no owner pin, and the length-only folds. New guards pin the strip-tags count mode, the direct Mixed scalar pack paths and the libc-free %d path. tests/codegen/perf_regressions.rs adds opt-in wall-clock ceilings; run it with cargo test --test codegen_tests perf_regressions -- --ignored. Structural guards run in the normal suite; timing guards catch order-of-magnitude regressions.
  • php-src parity. New regression coverage ports stripos, strrpos, strstr, strtr, chunk_split, ucwords, ucfirst and lcfirst edge cases, including non-ASCII no-fold, default separators, empty subjects, invalid chunk lengths and heap-debug cleanliness.

Selected release medians from the standard/core matrix (same host, cc -O2 C harness where the same work is comparable):

case elephc ms PHP ms php/elephc note
strtr (length-only) 0.022 1.402 63.7x C cannot be compared: its length fold is a constant
chunk_split (length-only) 0.092 0.656 7.1x C cannot be compared: its length fold is a constant
ucwords (length-only) 0.059 4.773 80.9x C same-work transforms into a stack buffer
ucfirst / lcfirst 0.073 / 0.070 1.758 / 1.798 24.1x / 25.7x length-only fold
ltrim / rtrim (length-only) 0.038 / 0.038 2.047 / 2.060 53.9x / 54.2x literal trim length fold; C scans 3 spaces
str_split 0.063 2.640 41.9x count-only helper; C allocates the chunk array
strstr 0.484 1.368 2.8x length-only helper; C returns a suffix pointer
stripos 0.641 1.373 2.14x 5.4x faster than the naive C scan
strrpos 0.315 0.878 2.79x 10.7x faster than the naive C scan
strripos 0.276 1.911 6.92x 0.48x of the specialized C scan
crc32 0.954 5.114 5.36x slicing-by-8 tables; ~4.4x the C byte-table reference
bin2hex 0.050 1.558 31.2x length-only helper
base64_decode 0.891 1.515 1.70x length-only helper with a four-byte fast scan
parse_url (count) 2.360 9.879 4.19x bitmask count without building the array
count_chars (distinct) 4.333 8.755 2.02x 256-bit presence bitmap
inet_pton (length) 0.065 4.820 74.2x packed-width helper; no boxed address
ip2long (int) 0.066 4.614 69.9x raw integer parser; invalid becomes zero
strip_tags (length) 2.299 6.499 2.83x allocation-free count scanner; PHP on a loaded host
vsprintf (%d-%s) 1.161 2.386 2.05x direct scalar records + libc-free %d

The standard/core matrix has a measured case for 70 of 72 functions; printf()/vprintf() are excluded because their observable work is stdout I/O rather than a same-work string kernel. All 70 measured cases are now at or above PHP. strip_tags moved from 0.92x to 2.83x by counting retained bytes without publishing, persisting or reserving an output buffer, and vsprintf moved from 0.86x to 2.05x by formatting unadorned %d without libc and packing scalar Mixed records directly. crc32 is now 5.36x PHP after the slicing-by-8 rewrite.

Fair C baseline

All 70 measured cases now have an in-process cc -O2 reference whose checksum matches the PHP loop exactly. The literal trim length fold moved ltrim/rtrim from 0.12x of C to 0.92x/0.95x; trim's C row is a constant lower bound. The other closest same-work gaps after this batch are strip_tags at roughly 0.75-1.35x of the C scanner depending on host load, inet_ntop at 1.56x and the (int)sqrt cast row at 4.1x slower; crc32 is now about 4.4x faster than the C byte-table reference, the float sqrt range row is within ~1.2x of C, json_decode's count is at parity with the minimal C scanner (~0.143 ms vs ~0.146 ms for 5k calls), and inet_pton's length-only row is 34x faster than the libc-based C reference. The rows where C computes only a constant length are lower bounds, not comparable work. The harness computes the same observable result as each fixture; when the PHP loop observes only a byte count, the C baseline may compute that count without materializing a temporary, so the reported C/e ratio is a lower bound on the remaining gap rather than an upper bound. The harness uses runtime input data and compiler barriers, and is rebuilt with cc -O2 -o .bench-scratch/c/string_std .bench-scratch/c/string_std.c. The previous C rows were not comparable because several checksums used += instead of PHP's ^= value + $i; those rows have been corrected.

Seventeenth batch: CRC-32 slicing, sqrt ranges and strip_tags counting

  • CRC-32 slicing-by-8. _crc32_table8 holds the eight derived tables;
    the AArch64 and x86_64 helpers consume eight bytes per iteration and keep the
    byte-wise table for the 0-7 byte tail. The 120-byte str_repeat("abc", 40)
    fixture, 20k calls: 5.016 ms -> 0.954 ms, 5.36x PHP and about
    4.4x the C byte-table reference.
  • Sqrt range reducer. SqrtSumLoop matches
    for ($i = start; $i < bound; $i++) { $sum += sqrt($i + c); } and replaces
    the boxed checked-add/sqrt/release/fadd scaffold with one
    __rt_sqrt_sum_range call. The helper adds roots in source order and keeps
    the checked-add overflow promotion. The 200k-iteration body drops from
    0.467 ms to 0.138 ms, 17.7x PHP. The (int)sqrt cast row still uses
    the per-call SqrtAddInt helper and remains the loop-vectorizer follow-up.
  • Allocation-free strip_tags length. One-argument
    strlen(strip_tags($s)) now calls __rt_strip_tags_count: it counts retained
    text and skips ordinary tags without reserving an output buffer, and
    tail-calls the full scanner for comments, PHP/ASP tags, declarations and
    nested angle brackets. The PR fixture (50k calls) drops from 8.1 ms to
    2.3 ms
    ; PHP is ~6.5 ms and the C scanner is ~1.7-2.4 ms on the same host,
    so the remaining gap is the quote-aware tag scan.
  • Coverage. Both targets emit and assemble the new helpers. The codegen
    suite covers slicing tails and unaligned lengths, empty/negative/overflow
    sqrt ranges, heap-debug cleanliness and --ir-opt=off parity. The existing
    allow-list/comment/PHP-tag/NUL strip_tags parity suite stays green.

Eighteenth batch: loop-invariant JSON count

  • count(json_decode(...)) hoist. json_decode_count_hoist proves the
    loop executes at least once, the decode is the first observable effect in
    the body, and the source is a stable non-escaping local. It runs one
    JsonDecodeCount probe in the preheader, replaces every use of the body
    call result with the probe result, and neutralizes the body call. Invalid
    JSON, scalar roots and numeric/escaped-key fallbacks keep the existing count
    lowering's exact TypeError and JSON state. The JSON count fixture drops from
    0.546 ms to 0.143 ms for 5000 calls, about 3.8x, and reaches parity
    with the minimal C scanner (~0.146 ms).
  • Coverage. New e2e tests cover valid JSON, invalid JSON, a zero-iteration
    loop, a mutated source, an earlier body effect, the single-probe IR shape and
    --ir-opt=off parity. The existing 149 JSON codegen tests stay green.

Safety

  • PHP overflow semantics: every update must be rewritten by IntegerRange to unchecked iadd/isub; otherwise the whole trial is discarded and the boxed path (with float promotion) remains. The counter_overflow fixture matches PHP: int, int, float.
  • Main frame privacy: global $x makes the slot a GlobalAlias (excluded by the candidate filter); eval is rejected by the existing eval/EvalScope checks; a runtime include that hands the frame to evaluated code is rejected explicitly. $GLOBALS, variable variables, extract/compact are not compiler-supported; get_defined_vars lowers to explicit slot loads, which fail the use proof.
  • COW/shape/growth: the push leaf path only handles refcount ≤ 1, non-empty, capacity-available arrays. The slow path is unchanged and tested. Heap debug is clean.
  • A string cast observation is safe because the numeric proof limits the cell to int/float tags, whose formatter arms return borrowed concat scratch and never adopt or free the source cell.
  • TaggedScalar narrowing only applies to add/subtract/multiply with a boxed operand. PHP coerces null to 0 in those operators, the array read emits the undefined-key warning before the cast, and the result stays Mixed so an overflow still promotes to a float tag. No missing-value provenance is observable after the arithmetic.

Tests

  • IR unit tests: compound +=/-= promotion, overflow rejection, Cast(Str) observation, array-key non-escaping, count hoist positive/negative shapes, and the array-bounds proof with its seed/step/receiver/index/consumer negative cases.
  • Emitter tests: push leaf has no ensure_unique/grow/frame on all five targets; slow path keeps COW/shape/growth.
  • E2E: compound counter IR shape + PHP oracle; top-level main counter shape; allocation-bounded string observation; scalar rhs avoids a second box; TaggedScalar frame-cell shape, sparse null-as-zero reads and int→float overflow; typed-array count() hot path has no diagnostic publish and leaves the loop condition; bounded reads lower to array_get_unchecked in both IR modes; an inclusive bound still warns and falls back to null; the typed reduction lowers to __rt_array_sum_int with PHP-matching overflow, float seeds, non-zero starts and empty ranges; an observed induction keeps the boxed reduction; the constant fill reserves capacity and emits ArrayPushReserved on all five targets while a runtime bound keeps the checked append; the inline append path is emitted on all five targets without __rt_heap_kind; missing-key warning still carries the correct key and line; numeric frame cells skip the heap decref while heap seeds still release; benchmark fixtures still print expected output.
  • Manual: counter_overflow and counters fixtures match PHP; arrays fixture COW/hash behavior matches PHP; sparse integer holes are covered by test_sparse_integer_keys_after_a_contiguous_prefix; heap debug clean.
  • New php-src parity fixture: selected parse_url_basic_*/urls.inc, base64_decode_basic_001/002, count_chars.phpt/count_chars_basic.phpt, crc32_*, str_split_*, strip_tags_array/basic2, and the manual's inet_pton/inet_ntop address table; all run with heap debug.
  • New optimizer fixture: strip_tags_len_sink covers full-array, allow-list, array allow-list, comments/PHP tags, NULs, strict TypeErrors, argument evaluation order and --ir-opt=off parity.
  • BoundedMixedCounter::is_applicable now requires a boxed local with a recognized stored counter update. The broad ICheckedAdd gate made the examples-corpus lowering test reach 70.2 s locally; with the selective gate it runs in 56.6 s, and it keeps its 180 s budget.
  • The opaque-eval demand probe gets the same bounded 180 s nextest budget as the other whole-catalog compile probes (16.7 s locally after the gate, but it crossed the global 60 s cap on a loaded non-codegen shard).

Local runs on this branch:

  • cargo test -p elephc --lib bounded_mixed_counter — 20 passed.
  • cargo test -p elephc --lib stack_numeric — 20 passed.
  • cargo test -p elephc --lib array_push_int — 2 passed.
  • cargo test -p elephc --lib array_key_load_is_value_only — 1 passed.
  • cargo test --test codegen_tests for the two compound-counter tests, the string-observation allocation test, the scalar-rhs boxing test, and the four benchmark fixtures — all passed.

Benchmarks (in-process body, macOS aarch64)

Official fixture bodies with a hrtime wrapper, min-of-12, alternating baseline/new/PHP:

fixture baseline this branch gain PHP C (-O2)
array_sum (50k append + 50k reads) 4.474 ms 0.118 ms 37.9x 0.870 ms 0.052-0.057 ms
sum_loop (200k, echo $sum . "\n" shape) 4.922 ms 0.318 ms 15.5x 1.170 ms —
sum_int (200k, accumulator cast to int) 1.021 ms 0.377 ms 2.7x 1.189 ms —
string_concat (5k appends) 0.061 ms 0.053 ms 1.15x 0.710 ms 0.843 ms

These medians were taken while the host was loaded; the ratios are the stable signal. The accumulator shape is load-bearing: the earlier 1.06 ms measurement for array_sum used echo $sum, "|", $elapsed, and a multi-argument echo consumes the boxed accumulator as a value, which deoptimizes the frame-cell proof. The official fixture shape (echo $sum . "\n") is the concat observation above; the multi-arg echo deopt is a separate follow-up.

Emitted hot-loop shape (static instructions / calls, cold branches included):

fixture baseline this branch
sum_loop cond+body 23/0 + 76/5 3/0 + 51/1
array_sum fill cond+body 23/0 + 76/5 3/0 + 62/4 (all four static calls are cold zero-division exception paths; the sum loop is one __rt_array_sum_int call in the preheader)
string_concat cond+body 23/0 + 66/2 4/0 + 19/1

array_sum now lowers to one reserved fill loop plus one __rt_array_sum_int call: the fill stores each int without COW/shape/capacity work, and the reduction helper walks the dense int payload once with PHP's exact overflow-to-double sequence. The in-process body is 7.4x faster than PHP and now only ~2.1-2.3x the whole -O2 C fixture: the fill loop's minimum is 0.101 ms, the reduction helper 0.022 ms, and C does both loops in 0.052 ms. The fill loop is the dominant cost; matching C further means reducing the per-element modulo/loop overhead or vectorizing the fill, not more type-guard removal.

Official process benchmark (python3 benchmark/run.py --suite native, 15 iterations, macOS aarch64, loaded host): array_sum 5.80 ms vs C 6.74 ms, string_concat 6.09 ms vs C 6.43 ms, sum_loop 7.55 ms vs C 5.32 ms; PHP is 95-119 ms in the same runs. An empty elephc process starts in 3.30 ms against C's 3.21 ms, so the remaining process gap is body time plus fork/exec noise, not startup. The suite metric is therefore within 0.86-1.42x of C and 16-20x faster than PHP.

Trail check

trail check reports 20 lexical overlap flags against unrelated learnings (serializer framing, trigger_error publication, spread sources). None of the cited rules apply to these changes; the closest ones were reviewed:

  • the push helper keeps the COW/shape/growth path and adds two targeted emitter tests rather than one;
  • the array-kind dispatch is unchanged (the leaf path only skips ensure_array_unique and growth for a proven-unique array);
  • the numeric cell proof is unchanged for null/raw-cell cases; only int/float/string observations were added.
  • the "box raw scalar" learning concerns boxing a converted raw scalar for output/ownership, where missing-value provenance is observable. This PR narrows a TaggedScalar only for + - *, where PHP's null-as-zero is the arithmetic contract, the array read already emitted the missing-key warning, and the result remains Mixed so overflow still promotes to float.

Trail also reports an overlap with Vincenzo's pushed feat/emit-php-library branch on docs/internals/memory-model.md. This PR changes one paragraph in the frame-cell section; it should merge cleanly if both land, but the second merge should re-read that paragraph. Two of Vincenzo's branches also change .config/nextest.toml; this PR adds two self-contained override blocks after lowers_examples_corpus, so a textual conflict is possible but mechanical.

Full String surface: 164/164 benchmarked

Every public PHP-visible String function now has a measured case: 70 standard/core,
6 ctype/printf cases, 64 mbstring cases, and 24 iconv/hash/zlib/openssl/tokenizer
cases. mb_send_mail() remains the only excluded function because it performs external
mail transport. The 164 case names are unique across the four matrices.

Release compiler, macOS aarch64, in-process hrtime medians. Every row compares its
observable checksum against PHP.

surface cases above PHP below PHP coverage mismatch
standard/core 70 70 0 0
ctype + printf/vprintf 6 6 0 0
mbstring 64 64 0 0
iconv/hash/zlib/openssl/tokenizer 24 24 0 0
total 164 164 0 0

OpenSSL cipher surface: 135/135

openssl_get_cipher_methods() now returns PHP's exact 135-name OpenSSL 3.6.4
default-provider catalog. The AOT backend links the new elephc-openssl bridge
against a managed openssl-crypto package (libcrypto.a plus its zlib
dependency), loads only the default provider via EVP_CIPHER_do_all_provided,
lowercases and sorts the names, and does not load the legacy provider. Hash-only
programs still link only elephc-crypto; the eval() interpreter retains its
smaller pure-Rust cipher subset. A literal openssl_cipher_iv_length() name is
folded against the same pinned 3.6.4 table.

case elephc ms PHP ms php/elephc
openssl_get_cipher_methods 18.4090 97.9810 5.32x
openssl_cipher_iv_length 0.3050 1.1310 3.71x
openssl_encrypt 0.0060 2.2564 376.07x
openssl_decrypt 0.1010 2.4642 24.40x

The managed package is declared in examples/openssl-cipher/elephc.toml; the bridge
and table are covered by 5 crate tests (exact catalog, IV lengths, AES goldens, and a
35-vector mode matrix) plus the codegen openssl filter.

mbstring: 64/64 above PHP

All 64 measured mbstring cases beat PHP. The constant-local folds cover case
conversion, trim/pad/slice, width, encoding checks, counting, splitting, search
positions and lengths, mb_convert_variables, mb_parse_str, and the literal
[0-9]+ mb_ereg_search* state machine, including the pos/regs/getregs
array elements. The progressive fast path now falls back for non-simple patterns so
named/numbered captures and PHP's no-pattern diagnostic are preserved.

The new loop-invariant pass hoists explicit-UTF-8
mb_strlen/mb_strwidth/mb_check_encoding/mb_substr_count/mb_ord and the
position searches, plus the allocation-free *_Len/mb_split_count sinks, out of
loops. An owned mb_substr/mb_strcut result observed only by strlen moves with
its length and release, so the loop body performs no allocation. The C tail shrank
from 10-16 near-zero kernels to none; the slowest measured C kernel is now at
0.36. A numeric-entity length call also moves when its map operand is the literal
full decimal packed array, with a load/store/four-push proof that rejects any other
map.

Loop backedge parameters are coalesced with one scalar backedge argument, so the
latch copy disappears; integer binops and checked integer results write directly
into their allocated homes; a (int) re-stamp from bool is pure; string-length
and byte-position runtime results carry a bounded range, so length + $i becomes
an unchecked add; a post-fixed-point finalizer hoists a loop's statement-boundary
concat reset into its preheader; and an eligible counted loop duplicates its
header test at the latch. The same non-negative bound covers the mbstring length
sinks (MbChrLen, MbStrimwidthLen, MbConvertEncodingLen, MbEregReplaceLen,
the numeric-entity lengths, counts, and the standard *Len/*Count sinks). The
hot for body drops from 13 instructions to 5, and
a 500M-iteration counted loop measures ~0.95x C (best of five; absolute times
vary with the host clock, the ratio stays 0.95-0.96; the short 20k-200k kernels
are dominated by 5-40 µs timing noise).

Lowest and highest measured ratios:

case elephc ms PHP ms php/elephc
mb_output_handler 0.0830 0.0911 1.10x
mb_language 0.7210 0.9356 1.30x
mb_regex_set_options 0.4160 0.6176 1.48x
mb_http_output 0.0680 0.1201 1.77x
mb_substitute_character 0.0700 0.1610 2.30x
mb_split 0.0060 329.0258 54837.64x
mb_substr_count 0.0100 17.5083 1750.83x
mb_strrichr 0.0160 15.6753 979.71x
mb_stristr 0.0150 13.5153 901.02x
mb_strripos 0.0140 11.2741 805.29x

C baseline for the hot mbstring kernels

The 35 scalar kernels in .bench-scratch/c/string_mb.c measure C/elephc
0.22-1.20
across runs, with the per-run median moving between 0.6 and 0.82 as
the host clock/thermal state changes; 23 of 35 kernels were at or above 0.8 in the
cleanest run. The stable signal is the 500M-iteration counted loop at ~0.95x C,
where the loop cost dominates and startup noise is amortized (the ratio measured
0.95-0.96 across two host-frequency states). The mb_chr length sink was already
hoisted; the bounded range removes its checked add and moves it from 0.22-0.46
to 0.79
in the latest sweep. The numeric-entity decode and mb_convert_kana
kernels still include a one-time bridge conversion or regex scan, not per-iteration
loop work. The $argc C-int range seed removed checked loop
additions; loop-invariant mbstring motion removed the per-iteration bridge and
allocation; backedge coalescing, direct-destination integer binops, direct
checked-add homes, bounded string metrics, pure bool-to-int casts, the loop-reset
preheader hoist, and the duplicated latch test removed the per-iteration register,
branch, and scratch traffic. The remaining lowest ratios are allocation- or
regex-bound kernels (mb_convert_kana, mb_ereg_replace, mb_chr, the
numeric-entity transforms) rather than loop overhead. The folded cases (gzip, hash,
iconv, OpenSSL, progressive search) no longer do per-iteration work, so their C
ratios are no longer meaningful.

String extensions: 24/24 above PHP

case elephc ms PHP ms php/elephc
iconv_mime_decode 0.0090 10.0475 1116.38x
iconv_mime_decode_headers 0.0060 4.6186 769.77x
gzcompress 0.0350 22.9348 655.28x
gzdeflate 0.0370 22.7343 614.44x
openssl_encrypt 0.0060 2.2564 376.07x
hash_algos 0.0050 1.3696 273.92x
hash 0.0120 2.9078 242.32x
gzinflate 0.0060 1.1213 186.88x
gzuncompress 0.0060 1.0400 173.33x
token_get_all 0.0080 1.0252 128.15x
hash_hmac 0.1510 10.8519 71.87x
iconv_strrpos 0.5790 26.0836 45.05x
iconv_strpos 0.5810 22.8023 39.25x
openssl_decrypt 0.1010 2.4642 24.40x
iconv_get_encoding 0.0220 0.4919 22.36x
token_name 0.0250 0.2915 11.66x
hash_equals 0.0520 0.5122 9.85x
openssl_get_cipher_methods 18.4090 97.9810 5.32x
iconv_set_encoding 0.4610 2.4068 5.22x
openssl_cipher_iv_length 0.3050 1.1310 3.71x
iconv_substr 21.3550 37.1641 1.74x
iconv_mime_encode 6.4440 8.9219 1.38x
iconv 6.1520 8.2601 1.34x
iconv_strlen 11.0140 13.6471 1.24x

No measured String performance case remains below PHP and no coverage mismatch remains.
token_get_all() reaches 128.2x PHP through the validated
literal-count preheader hoist. openssl_get_cipher_methods() is 5.3x
and openssl_cipher_iv_length() is 3.7x after the
libcrypto catalog and literal IV-length fold.

Review fixes

Three independent reviews (GLM-5.3-Flash, MiniMax-M3.1-Flash, Mistral Large 4)
found four correctness issues and several hygiene gaps. All are fixed across
the single squashed commit ba04c55c44:

  • x86_64 array_sum half-sum. The SIMD fold shifted the last data pair
    instead of copying the guard/accumulator lanes; array_sum(array_fill(0, 100, 1))
    with a changed element at 97 returned 50 instead of 99. The fold now copies
    xmm1/xmm0 before psrldq.
  • SIMD tail overflow. array_sum and the fused map+sum kernel added the
    odd tail element without the high-dword guard; a tail of PHP_INT_MAX wrapped
    instead of promoting to float. Both AArch64 and x86_64 tails now restart the
    scalar sequence when the (mapped) tail exceeds 32 bits.
  • trim charlist grammar. Decreasing ranges (b..a), three-dot forms
    (a...c), and chained forms (a..c..x) diverged from php_charmask and
    between native and eval. All three parsers now use the exact php_charmask
    loop; the differential test covers the failing masks.
  • null--. A statically null local decremented to int(-1) in AOT and
    eval. It now stays NULL and emits PHP's warning; the eval IR has a dedicated
    IncDecVar statement and the script-cache format version is 24.
  • trim invalid-range warnings. Native and eval trim now emit PHP's four
    Invalid '..'-range warnings with the caller-visible trim()/ltrim()/
    rtrim()/chop() name, including runtime masks. The shared runtime warning
    path still omits the in <file> on line N suffix used by some other elephc
    warnings.
  • Typed array_map() throw leak. The indexed map runtime now catches a
    callback throw through a bounded cleanup guard, releases the partial result
    array, and rethrows the original exception. A heap-debug regression covers the
    caught TypeError path.
  • Sparse indexed arrays. A non-empty packed array now promotes to hash
    storage when a static key can skip (negative or >= 2) or a runtime integer
    key is not a recognized packed loop counter, preserving PHP keys instead of
    filling null holes. The differential test covers literal and runtime keys,
    insertion order, and append after promotion.
  • Coalesced array writes. A ??= result temp is explicitly borrowed before
    the container write, so hash storage retains its own owner instead of pointing
    at a released temp; reading individual elements no longer corrupts the
    inserted entry.
  • Defense in depth. array_sum_int_bounds and array_map_sum_cache now
    reject RuntimeCall + Data frames like the neighboring passes.
  • Hygiene. The trim docs moved out of the table and now state the warning
    caveat; .bench-scratch/ is ignored; the OpenSSL TSV accepts an empty final
    field and has no trailing whitespace; the duplicate nextest override is gone;
    all 1,605 misaligned inline assembly comments in the touched files are at
    column 81.

No tracked correctness caveat remains in this review scope. The OpenSSL bridge
is a new product surface in this branch; it is not a review-fix item and can be
extracted into its own PR without changing the string/codegen work.

Validation

  • cargo test --test codegen_tests "openssl": 13 passed.
  • cargo test --test codegen_tests "mb_": 35 passed.
  • cargo test --test codegen_tests "mbstring": 366 passed.
  • cargo test --test codegen_tests "eir_licm": 9 passed.
  • cargo test --test codegen_tests "overflow": 115 passed.
  • cargo test -p elephc --lib "constant_materialization": 5 passed.
  • cargo test -p elephc --lib "regalloc": 13 passed.
  • cargo test -p elephc --lib "mem2reg": 18 passed.
  • cargo test --test codegen_tests "runtime_gc::mbstring": 52 passed.
  • cargo test -p elephc --lib "ir_passes::tests": 440 passed.
  • cargo test -p elephc --lib "licm_test": 12 passed.
  • cargo test -p elephc --lib "immutable_local_loads": 13 passed.
  • cargo test --test codegen_tests "iconv": 38 passed.
  • cargo test --test codegen_tests "hash": 281 passed.
  • cargo test --test codegen_tests "tokenizer": 13 passed.
  • cargo test -p elephc --bin elephc "rotated_loops": 3 passed.
  • cargo test --test codegen_tests "test_machine_peephole_rotates_counted_loop_back_edge": 1 passed.
  • cargo test --test codegen_tests "trim": 25 passed.
  • cargo test --test codegen_tests "test_trim_ranges_match_php_in_native_and_eval": 1 passed.
  • cargo test --test codegen_tests "test_trim_invalid_ranges_warn_in_native_and_eval": 1 passed.
  • cargo test --test codegen_tests "test_array_sum_simd_gate_matches_php_fallbacks": 1 passed.
  • cargo test --test codegen_tests "test_keyed_array_kernels_match_php": 1 passed.
  • cargo test --test codegen_tests "test_mixed_inc_dec_in_place_and_cow_fallback": 1 passed.
  • cargo test --test codegen_tests "test_core_typed_array_map_releases_partial_result_on_typeerror": 1 passed.
  • cargo test --test codegen_tests "array_sum": 19 passed.
  • cargo test --test codegen_tests "array_map": 90 passed.
  • cargo test --test codegen_tests "arrays": 1362 passed.
  • cargo test --test codegen_tests "test_sparse_integer_keys_after_a_contiguous_prefix": 1 passed.
  • cargo test --test codegen_tests "evaluation_order": 42 passed.
  • cargo test --test codegen_tests "append_writes": 56 passed.
  • cargo test --test codegen_tests "cow_and_cycles": 21 passed.
  • cargo test --test codegen_tests "crc32": 5 passed.
  • cargo test --test codegen_tests "sqrt": 10 passed.
  • cargo test --test codegen_tests "strip_tags": 55 passed.
  • cargo test --test codegen_tests "json_decode": 149 passed.
  • cargo test --test codegen_tests "json_decode_count_hoist": 7 passed.
  • cargo test --test ir_backend_smoke_test "basic_indexed_arrays": 1 passed.
  • cargo test -p elephc --lib: 3695 passed (13 ignored).
  • cargo test --test codegen_tests "optimizer": 795 passed (1 ignored).
  • cargo test -p elephc-magician: 1811 passed.
  • cargo test --test codegen_tests "literal_trim_lengths_fold_without_the_trim_call": 1 passed.
  • cargo test -p elephc --bin elephc "integer_range": 38 passed.
  • cargo test -p elephc --bin elephc "mb_chr_len_plus_bounded_counter_is_unchecked": 1 passed.
  • cargo test --test codegen_tests "runtime_gc" (pre-review head): 1424 passed (one fixture rerun green after clearing a full temp disk); the review-fix head re-ran the affected mixed/trim/mbstring cases and CI runs the full matrix.
  • cargo test -p elephc-openssl with ELEPHC_OPENSSL_LIB_DIR: 5 passed.
  • cargo test --test web_tests "web_environment_generic_callback_retains_population": 1 passed (38s local; 180s CI period).
  • cargo test -p elephc --lib native_deps: 108 passed.
  • audit_builtin_eir_boundary.py --enforce-target-architecture: exit 0.
  • cargo build --example gen_builtins --features curl + extract_builtins.py --render --force + audit_builtins.py + validate_site_compat.py: 0 errors, 2450 pages validated.
  • PR branch is one squashed commit ba04c55c44 on top of main at 55758e892f; GitHub reports MERGEABLE.

trim/ltrim/rtrim masks now follow php_charmask exactly in AOT and eval:
increasing start..end ranges expand, while decreasing and three-dot forms leave
their bytes literal. The differential native/eval mask table covers b..a,
a...c, a..c..x, ..c, x..y..z, and literal-dot cases. The invalid-range
warnings are now emitted with the caller-visible function name and PHP's message
text; the shared runtime warning path still omits the in <file> on line N
suffix used by some other elephc warnings.

The branch is one squashed perf commit on main at 55758e892f; the per-area
detail remains above. The local focused suites on the final tree are optimizer
795, lib 3695, arrays 1362, array_sum 19, array_map 90, sqrt 10, crc32 5,
strip_tags 55, json_decode 149, trim 25, mixed_inc_dec 1, elephc-magician 1811,
overflow 115, integer_range 38,
concat_reset 21, and rotated_loops 3.

The opaque-eval families have a bounded 300s CI budget, placed before the global
180s codegen override so nextest applies it; locally they run at 76-196s. The
generic-callback web host compiles the same eval-capable prelude and now has a
180s period after crossing the 60s watchdog on both attempts of a loaded macOS
runner; a real hang still terminates at 180s. The
concat hoist is a post-fixed-point finalizer, skips internal/synthetic bodies and
functions over 2,000 instructions, and only analyzes loops that still contain a
reset; its inserted resets are marked so both elimination phases leave them in
place. The latch rotation only duplicates a header whose instructions are inert
and whose compare operands are header parameters or outside values, and it leaves
the EIR CFG unchanged.

@Guikingone Guikingone added area:optimizer Touches AST or EIR optimization passes. area:codegen Touches target-aware assembly or backend lowering. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. topic:performance Primarily concerns performance, latency, or resource usage. ci:macos Run the macOS test matrix and iOS links on this PR, not only in the merge queue. labels Oct 27, 2026
@github-actions github-actions Bot added area:eir Touches EIR definitions, lowering, validation, or passes. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:m Medium-sized pull request. type:triage Needs a conventional title or branch prefix before review. and removed area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. labels Oct 27, 2026
@Guikingone

Copy link
Copy Markdown
Collaborator Author

Local process benchmark with the final compiler (macOS aarch64, benchmark/run.py --suite native --iterations 7 --warmup 2):

case elephc ms PHP ms C ms
array_sum 4.70 45.52 2.47
string_concat 2.83 43.03 3.31
sum_loop 2.91 45.12 2.66

string_concat is now faster than C at process level in this run (and 17x faster in the in-process body). The remaining array_sum gap is the boxed $sum += $data[$i] (TaggedScalar rhs) and the per-iteration count(); both are tracked as follow-ups. sum_loop C folds the loop into a closed form, so its body is ~0 while elephc body is 0.286 ms.

@Guikingone Guikingone self-assigned this Oct 27, 2026
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch from 23f8ae8 to 0a44e2d Compare October 27, 2026 09:04
@github-actions github-actions Bot added size:l Large pull request. and removed size:m Medium-sized pull request. labels Oct 27, 2026
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch from 6a9d721 to 4c52335 Compare October 27, 2026 11:23
@Guikingone Guikingone changed the title perf: scalarize compound loop counters and numeric frame cells perf: close the native and builtin speed gap Oct 27, 2026
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch from c494860 to 46d6174 Compare October 27, 2026 13:54
@github-actions github-actions Bot added size:xl Very large pull request that needs deliberate review planning. and removed size:l Large pull request. labels Oct 27, 2026
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch from f56b12c to 2bb174b Compare October 28, 2026 08:35
@github-actions github-actions Bot added target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. and removed area:codegen Touches target-aware assembly or backend lowering. target:linux-x86_64 Contains behavior specific to the Linux x86_64 target. labels Oct 28, 2026
@Guikingone
Guikingone added this pull request to the merge queue Oct 31, 2026
@Guikingone
Guikingone removed this pull request from the merge queue due to a manual request Oct 31, 2026
@Guikingone
Guikingone marked this pull request as draft October 31, 2026 18:01
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch from 4eadb39 to 1dead04 Compare November 2, 2026 12:04
@github-actions github-actions Bot removed the area:optimizer Touches AST or EIR optimization passes. label Nov 2, 2026
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch 4 times, most recently from 09c5af3 to 07d20d2 Compare November 3, 2026 17:56
@Guikingone
Guikingone marked this pull request as ready for review November 3, 2026 17:56
@Guikingone
Guikingone requested a review from nahime0 November 3, 2026 17:57
@Guikingone Guikingone changed the title perf: close the native and builtin speed gap perf(core): close the native and builtin speed gap Nov 3, 2026
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch 2 times, most recently from 9384e49 to 229520d Compare November 3, 2026 21:37
Squashed native string/codegen performance work, including:
- direct native lowering and runtime fast paths for string, array, hash,
  count, JSON, regex, base64, URL, inet, and math builtins;
- AArch64/x86_64 codegen folding, in-place Mixed updates, leaf runtime
  calls, and register/frame reductions;
- PHP-compatible trim charlist ranges and warning behavior in AOT and eval;
- sparse integer-key preservation and coalesced array-write retention;
- array_map partial-result cleanup when the callback throws;
- slicing-by-8 CRC-32 and the sequential sqrt range reducer;
- allocation-free strlen(strip_tags()) counting with exact markup fallback;
- loop-invariant count(json_decode()) probes with exact error fallback;
- regenerated builtin docs and deterministic IR/assembly regression guards.
@Guikingone
Guikingone force-pushed the perf/native-codegen-speed branch from 229520d to ba04c55 Compare November 4, 2026 08:54
@nahime0 nahime0 self-assigned this Nov 4, 2026
@nahime0
nahime0 added this pull request to the merge queue Nov 4, 2026
@nahime0
nahime0 removed this pull request from the merge queue due to a manual request Nov 4, 2026
@nahime0 nahime0 closed this Nov 4, 2026
@nahime0 nahime0 reopened this Nov 4, 2026
@nahime0
nahime0 added this pull request to the merge queue Nov 4, 2026
nahime0 added a commit that referenced this pull request Nov 4, 2026
Leaves scalar_observation untouched so the change merges cleanly next to queued PR #2090, which edits that function.
nahime0 added a commit that referenced this pull request Nov 4, 2026
Builtin registry signatures carry no source type declaration, so builtin float arguments keep the boxed checked operand their own passes match, such as the sqrt($i + c) range reducer from #2090. Found when the merge group with #2090 failed sqrt_sum_loop_emits_the_range_reducer.
Merged via the queue into illegalstudio:main with commit 0c760cd Nov 4, 2026
208 checks passed
@Guikingone
Guikingone deleted the perf/native-codegen-speed branch November 4, 2026 20:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:eir Touches EIR definitions, lowering, validation, or passes. area:magician Touches eval, include execution, or elephc-magician. area:runtime Touches runtime helpers, GC, ownership, or bridge runtimes. ci:macos Run the macOS test matrix and iOS links on this PR, not only in the merge queue. scope:multi-area Touches more compiler areas than the automatic area-label cap. size:xl Very large pull request that needs deliberate review planning. topic:performance Primarily concerns performance, latency, or resource usage. type:triage Needs a conventional title or branch prefix before review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants