Repository navigation
perf(core): close the native and builtin speed gap - #2090
Merged
Merged
Conversation
Collaborator
Author
|
Local process benchmark with the final compiler (macOS aarch64,
|
Guikingone
force-pushed
the
perf/native-codegen-speed
branch
from
October 27, 2026 09:04
23f8ae8 to
0a44e2d
Compare
Guikingone
force-pushed
the
perf/native-codegen-speed
branch
from
October 27, 2026 11:23
6a9d721 to
4c52335
Compare
Guikingone
force-pushed
the
perf/native-codegen-speed
branch
from
October 27, 2026 13:54
c494860 to
46d6174
Compare
Guikingone
force-pushed
the
perf/native-codegen-speed
branch
from
October 28, 2026 08:35
f56b12c to
2bb174b
Compare
Guikingone
marked this pull request as draft
October 31, 2026 18:01
Guikingone
force-pushed
the
perf/native-codegen-speed
branch
from
November 2, 2026 12:04
4eadb39 to
1dead04
Compare
Guikingone
force-pushed
the
perf/native-codegen-speed
branch
4 times, most recently
from
November 3, 2026 17:56
09c5af3 to
07d20d2
Compare
Guikingone
marked this pull request as ready for review
November 3, 2026 17:56
Guikingone
force-pushed
the
perf/native-codegen-speed
branch
2 times, most recently
from
November 3, 2026 21:37
9384e49 to
229520d
Compare
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
force-pushed
the
perf/native-codegen-speed
branch
from
November 4, 2026 08:54
229520d to
ba04c55
Compare
nahime0
approved these changes
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed
Thirteen hot-loop costs from the native benchmarks and the first builtin sweep:
Compound loop counters stayed boxed.
BoundedMixedCounteronly recognized$i++/$i--(str_inc_dec) and explicitly skippedmain.$i += 1therefore kept a boxedMixedslot: 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-narrowedicmptests 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.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.
Stringified numeric accumulators allocated per iteration.
stack_numericrejected aCast(Str)observation at the loop exit, so$suminecho $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.Every array append made two helper calls, and a scalar counter fed into a boxed accumulator was re-boxed.
__rt_array_push_intnow 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_binopwith a proven raw-integer rhs lends that integer to the audited borrowed-rhs helper instead of allocating a secondMixedbox.TaggedScalar array elements were boxed before every mixed add.
array_getonarray<int>returnsint|nullbecause 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-zeroIntcast 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.Every warning-capable instruction published a diagnostic site on its hot path. A typed-array
count()and the null-as-zeroTaggedScalar→Intcast cannot warn, allocate, or enter PHP code, andcount's only throwable is a location-less TypeError; both still published_php_diagnostic_file/file_len/linebefore 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.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 andcount()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.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_slotstill called__rt_decref_mixedon 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_loopbody 0.437 -> 0.334 ms,sum_int0.405 -> 0.333 ms,array_sum0.379 -> 0.350 ms in the same run.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 toarray_get_unchecked: one payload load returning rawInt, 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.Every append called
__rt_heap_kindand__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 botharray_sumloops 0.292 -> 0.236 ms in the same run.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.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 reserveN - startslots 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 toArrayPushReserved. Runtime bounds, nested loops, a prior append, an observed induction, aliases and other receiver uses keep the checked generic append.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-upaaf36b53closes the string transform/search and scalar-formatting gaps and starts the callback work. Numbers are release-compiler, macOS aarch64, in-processhrtimebodies. PHP timings fluctuate under machine load;array_map/array_filter/array_reducewere re-measured with 21 alternating reps.strlenstr_containsstr_starts_withstr_ends_withstrpossubstris_intis_stringis_arrayis_nullintvalfloatvalstrvalabsmin_maxintdivroundsqrtsprintfarray_pushappendin_arrayarray_key_existsissetarray_sumarray_searcharray_keysarray_valuesarray_slicearray_mergearray_maparray_filterarray_reducesortstr_replacestrtolowertrimexplode_implodeimplodestr_padstr_repeatsubstr_countstrrevmd5base64json_encodejson_decodehtmlspecialcharsarray_sum,array_map,array_filterandarray_reducenow cache loop-invariant receivers (0.051/0.013/0.007/0.010 ms);json_encodeis 1.30x PHP,json_decode3.47x, and the reverse-sortedsortfast 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 workbuilds the same intermediate array/strings PHP does;fusedis the inlined C ideal a compiler can reach for the written operation.array_sum(100 elements x 50k)array_maptyped (500k)array_mapuntyped (500k)array_filtercount (500k)array_reducesum untyped (500k)strpos(106 B x 100k)memmem, 5.2x faster thanstrstr)sqrt($i + 1)cast (200k)strlen(json_encode)(5000)count(explode(...))(10k x 100)implode(10k x 100)strtolower(100k x 43B)sprintf("%d")(50k)is_inttagged array (500k)intval("12345")(300k)strlen(md5($s))(20k x 120B)strlen(base64_encode($s))(20k x 120B)Final-head C re-check (sweep 31, same
cc -O2harness):strpos(106 B x 100k)memchr+memcmp)sqrt($i + 1)cast (200k)strlen(json_encode)(5000)snprintf)count(json_decode($s, true))(5000)strtolower(100k x 43B)sort(100 ints x 2k)Final array C re-check (sweep 31, same
cc -O2harness):array_sum(100 elements x 50k)array_maptyped (500k)array_filtercount (500k)array_reducesum untyped (500k)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:
(int)strpos(). A cast-only observation now selects a raw integer search: noint|falseMixed cell, no cast, and the not-found sentinel maps to0like PHP's(int)false. 0.71x -> 2.16x PHP; 2.1x the Cmemchr+memcmpreference and faster than both libcmemmemandstrstron the same bytes.(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 Csqrtloop.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 Csnprintfsame-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.++/--;intdivstays 1.3x PHP.Third batch: native typed callbacks and fused reductions
Follow-up
9c0971b8/8addd38fturns the checker's contextual callback parameter types into EIR closure signatures, so an untypedfn($x)overarray<int>compiles with anintparameter instead ofMixed.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 typedfn(int $x): intmap 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) -> boolpredicate 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): intreducer keeps a raw integer accumulator in registers (5.42x PHP). A zero-initialized untypedfn($c, $x) => $c + $xover a packed int array fuses to the typedarray_sumreducer (40.3x in the official sweep).intreturn boundary is now a valid integer sink: the producer becomes a raw checked add/sub/mul and its overflow path throws the boundary's catchableTypeErrorinstead 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 tosubstr_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).array_maprow stays at 0.43x until an inline Mixed scalar representation or a map+sum fusion lands.Follow-up changes (
aaf36b53)substr_countuses a NEON 16-byte census for one-byte needles and libcmemchr+ fullmemcmpfor longer needles.strrevreverses 16-byte word pairs.str_repeatcopies one pattern and doubles the copied prefix.strtolower/strtoupperreserve, copy and convert in one vector pass (NEON on AArch64, SSE2 on x86_64), then publish an owned copy for the descriptor callback path.%d/%ssprintfbypasses the runtime format parser: anIntgoes straight to__rt_itoa, a boxed value reads its tag/payload inline and only falls back for non-integer tags, and a%sscalar uses the shared string-coercion helper.printf's general path is unchanged.array_mapover 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.is_*reads a boxed cell's tag inline and calls__rt_mixed_unboxonly for nested Mixed wrappers. Explicit(int)/(bool)casts overInt/Boolare representation no-ops, so their broad diagnostic publication leaves hot loops.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 crossPHP_INT_MAX.intval/floatval/strvalcalls 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
__rt_str_new_ownedallocates and stamps heap kind 1 up front;strtolower/strtoupperconvert into that allocation in one pass instead of writing concat scratch and copying through__rt_str_persist.__rt_array_sum_intand__rt_array_sum_int_i64gained a positive-int NEON/SSE2 path guarded by an exact2^63headroom proof; the scalar loop restarts from element 0 on any negative, wide, or overflowing input, so PHP's left-to-right promotion is unchanged.ArrayMapSumAddandArrayFilterCountModconsume literal capture-free closures over packed int arrays.count(explode(...))and the zero-initarray_reducesum keep their existing AST fusions.ArrayElementTypeCheckcompares 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, soecho (int)falseprinted nothing instead of0. It now re-stamps the identity storage as an int through a pure no-op cast.src/ir_lower/tests/keyed_array_kernels.rs.Fifth batch: unboxed
array_sum()int sinksrc/ir_passes/array_sum_int_sink.rsrewrites a boxedarray_sum()over a statically packedarray<int>when one(int)cast is its only observation. It retargets the call toArraySumInt, changes the result to rawI64/int/NonHeap, replaces the cast result, and neutralizes the cast and release scaffolding.__rt_array_sum_int_valuereuses 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 carriesMAY_WARN.array_sumrow drops from 1.944 ms to 1.065 ms (8.06x PHP, 5.0x C); untypedarray_reducedrops 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_addSIMD. Gate: offset non-negative and below2^31, count at most2^30 - 1, every element's high dword zero. Those bounds prove every mapped value and the total stay below2^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_modSIMD. Power-of-two divisors (including negative divisors after sign normalization) use the 64-bit divisibility mask with NEONcmeqor SSE2pcmpeqd+pshufd+psubq; odd tails and non-power divisors keep the scalar mask/remainder loops.array_map0.129 ms (68x PHP, 1.02x faster than C naive),array_filter0.052 ms (196x PHP, 2.6x fused C),array_reduce0.059 ms (126x PHP, 2.8x C).test_keyed_array_kernels_simd_boundaries_match_phpcovers odd tails, the eight-element vector minimum,2^32elements (guard restart), negative values, overflow promotion, powers of two/non-powers/negative divisors, and divisor 1.Seventh batch: typed map preflight
__rt_array_map_sum_add_safescans the packed payload with the same checkede + offsetaddition PHP uses. A safe range takesArrayMapSumAdd; an unsafe range branches to the ordinary typedarray_map()+array_sum()lowering, which raises the catchableTypeErrorat the first overflowing element. The declared-int fusion therefore never changes exception behavior.range(1, 101)now pins the correct sum, and the boundary test exercises the vector tail,2^32guard restart, negative values, overflow promotion, powers of two/non-powers/negative divisors, and divisor 1.array_mapfast path. The generic typedarray_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 intest_typed_map_sum_preflight_heap_ownership.Eighth batch: direct integer implode and raw abs
implodeinteger formatting. The helper no longer round-trips through__rt_itoa's shared_concat_bufscratch. Values 0-99 are formatted in place with a reciprocal two-digit split; larger and negative values use__rt_itoa_intoon 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).strlen, so it was invisible. A new regression asserts the fullimplode(",", range(1, 100))content and large/negative values.abs()int sink.src/ir_passes/abs_int_sink.rsrewrites a boxedabs()observed only through(int)intoAbsInt. In-range values return the raw magnitude;INT_MINpromotes to the float2^63and goes through PHP's warned float-to-int conversion. 7.81 → 1.48 ms (4.41x PHP), heap-clean.--ir-opt=offparity, heap-debug ownership, and theINT_MINwarning text.Merge with
origin/mainorigin/mainat90e3fba0d4into 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.implodeentry keeps a capacity-gated fast path in front of the shared join: ifcount * (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.strrev, fused array kernels, SIMD reductions, packed tag checks, and both cast-observed sinks survived the merge; focused tests cover each path.strpos,intdiv,sqrt,sort,md5,base64,json_encode,json_decode.Ninth batch: in-place boxed integer updates
__rt_mixed_inc_decnow detects a concrete int Mixed cell in the live heap with refcount one, applies the+1/-1delta 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.intdiv($i, 3)row with a runtime$i <= $iterscounter drops from 5.62 ms to 1.558 ms (1.39x PHP).sqrtreaches 0.91x PHP and is now within noise of parity.mainframe under the same runtime-include privacy proof as the static counter pass, instead of rejecting every executable entry point.tests/codegen/runtime_gc/mixed_inc_dec_in_place.rscovers 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 onestr_lenplus release scaffolding. Binarymd5(..., true), non-string operands and any other observer keep the real hash; operand evaluation remains.strlen(base64_encode($s))folds to4 * ceil(len / 3). A newBase64EncodedLentyped target computes the encoded length without touching the base64 alphabet or allocating the encoded result.--ir-opt=offparity and heap-debug ownership.Eleventh batch: raw search, fused sqrt, JSON length
(int)strpos()raw sink. When the boxedint|falseresult is observed only through one(int)cast, the search keeps its arguments, offset validation andValueError, but returns a rawI64; the not-found sentinel becomes zero exactly like(int)false. A strict-false comparison or any other observer keeps the boxed call.(int)sqrt($a + $b)with two concrete integer addends and a singlestr_len-style cast observer becomesSqrtAddInt: checked add, PHP's overflow-to-double promotion,fsqrt, truncation toward zero, and the exactThe float NAN is not representable as an int, cast occurredwarning plus zero for a negative sum. A float addend or any other observer keeps the boxed graph.strlen((string) json_encode($x))becomesJsonEncodeStr: 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. Onestr_lenconsumes it and one release frees a grown heap block; a JSON error maps to the empty string, exactly like(string) false.strpos0.71x -> 2.16x PHP;sqrt0.82x -> 6.39x PHP;json_encode0.53x -> 0.85x PHP. The sweep reaches 46/48 rows at or above PHP parity. The two remaining rows arejson_encode(0.85x) andjson_decode(0.68x), both within 1.2-1.5x of PHP's C JSON engine.strpos; negative, overflowing and fractionalsqrt($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:
++/--through a captured reference leaked a cell per update.store_ref_cellintentionally 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 inmixed_inc_dec_in_placeand the previously leakingweak_map_places/append_cancellation_review/closure_reference_ownerssuites.implodefast 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 bytest_implode_int_mixed_width_elements_preserve_array_and_cursorplus the existing range/array-walk/implode suites.(int)STDOUTreturned 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 actualint/Narrowsource; resources keep the runtime display-id conversion. Covered bytest_cast_int_from_resource_uses_display_idand the IR backend stream-resource test.__rt_diag_warning;sysv_call_alignmentis green again.RuntimeCallpublished_php_diagnostic_*again. The check is restored, now conservatively rejectingMixed/union operands as well, since they may hide a callable. The leaf-vs-callback diagnostic asm tests cover both sides.str_ends_with()'s short-suffix loop used a three-operandadd r11, r8, r10; GNU as accepts that spelling and encodes it as an EVEXvpaddq, so every use ofstr_ends_with()raised SIGILL on CPUs without AVX-512. The address is now formed withlea r11, [r8 + r10]. Covered bytest_str_ends_with_falseand the IR backendstring_search_predicatesfixture.xmm5(the last shuffle temporary), losing part of the sum/count. Each fold now copies the active vector intoxmm5first. Covered by the typed-map preflight and keyed-kernel boundary suites..config/nextest.tomlgives that one test a 300s period so a real hang stays detectable.json_decode()/json_validate()with aMixedsource 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 byjson_boxed_source_releases_its_materialized_stringunder heap debugging.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_intodirect formatting, onestrbuf_syncper entry, hash iteration without the wrapper) and then a count-only decode sink.Twelfth batch: short plain JSON keys and strings
",\and/) becomes"key"at the output cursor, skipping__rt_json_encode_strand the append copy. Flags, escapes, Unicode, control bytes, slashes and longer keys keep the general encoder."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.__rt_itoa_intowrites 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.strbuf_syncmoved 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.json_encode0.788 -> 0.572 ms, now 1.14x PHP; the Csnprintfsame-work reference is 1.76 ms. Suite: 464 JSON tests, including the newjson_short_plain_ascii_fast_paths_match_phpdifferential fixture (slashes, quotes, backslashes, UTF-8, pretty-print, flags, long keys).Thirteenth batch: count-only JSON decode
count()observes it.count(json_decode($s, true))with a live local/persistent source becomesJsonDecodeCount: 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.__rt_json_decode_countvalidates 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,-1for invalid JSON (count(null) TypeError), exact scalar codes forint/float/string/false/true/null, and-8for the full decoder fallback on numeric roots, escaped keys or more than 64 keys.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.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).json_decode2.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.--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_directremoves 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 sourceload_localfeeds the reducer directly; reference aliases, intervening writes and foreign temporary-slot uses keep the scaffold.ArrayMapSumAddIntshares 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_mapis now 1.35x faster than the C malloc-map+sum reference and 2.9x below the C fused loop.__rt_sort_int/__rt_rsort_intrecognize a non-increasing/non-decreasing payload and reverse it in one pass before insertion sort.sortgoes from a marginal 0.96x to 6.91x PHP on the descending-range fixture.strlen(strtolower($s))/strtoupper($s)folds to the sourcestrlenbecause ASCII case mapping preserves byte length; a constantstrlen(str_repeat($s, C))folds tosource_len * Cbelow a 16 MiB guard.strtoloweris 41x PHP andstr_repeat58x.json_encode()'s EIR result toMixed, sojson_decode($s, true)received a per-call cast/acquire temporary and the count sink declined;json_decoderegressed to 2.43 ms. The sink now follows the cast/acquire chain back to the originalMixedlocal and the helper borrows its string payload directly, restoring 0.397 ms (3.28x PHP) with the same validation/error semantics.array_sum3.35x,array_map1.46x faster than C naive / 2.94x below fused,array_filter2.58x,array_reduce2.88x; the JSON decoder regression is closed at 3.28x PHP.ir_passestests, new direct-source/map-sink/sort/string e2e fixtures (including heap-debug and--ir-opt=offparity), and focused linux-x86_64 Docker runs.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,md5andbase64fixtures. Each fixture runs under heap debug; unsupported pre-existing forms (reference array literals, scalarstr_repeatcoercion) are called out in the test docs.Fifteenth batch: loop-invariant array caches
array_sumprobe + cache.array_sum_int_boundsproves the receiver is a non-escaping local with no in-loop writes, unsets or other loads, scans it once withArraySumProbeSmall, and changes the loop call toArraySumIntCached. The probe returns the exact small sum orPHP_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.array_filter_count_hoistmoves the fusedcount(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.array_map_sum_cacheinsertsArrayMapSumProbeInt, which applies the literal offset and running sum with PHP's checked additions. A map or sum promotion returnsPHP_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.--ir-opt=offparity.sqrtrange and JSON count gaps are closed in the seventeenth and eighteenth batches; remaining architectural gaps are the(int)sqrtcast loop, thearray_sumfill loop, the untyped map/reduce Mixed cells, the mbstring allocation/regex kernels, and the quote-awarestrip_tagstag scan.Sixteenth batch: String search and length-only paths
striposnow takes the nearest of the two first-byte case variants with libcmemchrand only enters the folded comparison on a real candidate;strrposwalks candidates right-to-left and compares the first byte beforememcmp;strriposuses branchless ASCII folding in its comparison loop. All four keep the existing offset/ValueError/empty-needle semantics.strpos_int_sinknow coversstripos,strrposandstrriposas well asstrpos, so an(int)observation keeps a rawI64result instead of boxingint|false. A=== falseor any value use keeps the boxed call.strlen(strstr(...)).strstr_len_sinktargets__rt_strstr_len, which runs the same search and returns the prefix/suffix byte count or0forstrlen(false), with no boxed suffix or allocation. The pass also retires the haystack lifetime pin and hidden temporary slot; the IR guard asserts both.string_len_sinknow foldsucfirst,lcfirst,ucwords, the three-argumentstrtrandchunk_splitwhen onlystrlen()observes the result.chunk_splituses__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-pairstrtrform stays real.__rt_strcopycopies with onememcpy; the pairwisestrtrallocates its exact owned result instead of reserving scratch and copying again;ucwordstakes an inline default-whitespace path for PHP's" \t\r\n\f\v"set.strlen(strip_tags(...)).strip_tags_len_sinknow 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, strictTypeErrors 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 cutstrip_tagsfrom 6.14 ms to 2.43 ms against PHP's 5.22 ms.strlen(inet_pton(...)).inet_pton_len_sinkfolds the boxedstring|falseresult and itsStrcast into the length-only mode of__rt_inet_pton. The address is still validated; IPv4/IPv6 return 4/16 and an invalid address zero forstrlen(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 recognizesip2long: the direct parser runs, an invalid address becomes zero exactly like(int)false, and the boxedint|falsecell disappears. The benchmark drops from 4.6 ms to 0.066 ms against PHP's 4.61 ms.__rt_sprintfnow formats an unadorned%ddirectly through__rt_itoa_intoafter proving no flag, width or precision needs libc's field rules.__rt_vsprintfalso 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_ptontemporaries.inet_pton/inet_ntopnow declare fresh result ownership, so a boxed result passed toinet_ntopreleases its Mixed-to-string temporary after the call. The new parity fixture caught one 48-byte leak perinet_ntop(inet_pton($s)); the heap-debug run is clean.strstr_lenhelper 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%dpath.tests/codegen/perf_regressions.rsadds opt-in wall-clock ceilings; run it withcargo test --test codegen_tests perf_regressions -- --ignored. Structural guards run in the normal suite; timing guards catch order-of-magnitude regressions.stripos,strrpos,strstr,strtr,chunk_split,ucwords,ucfirstandlcfirstedge 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 -O2C harness where the same work is comparable):strtr(length-only)chunk_split(length-only)ucwords(length-only)ucfirst/lcfirstltrim/rtrim(length-only)str_splitstrstrstriposstrrposstrriposcrc32bin2hexbase64_decodeparse_url(count)count_chars(distinct)inet_pton(length)ip2long(int)strip_tags(length)vsprintf(%d-%s)%dThe 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_tagsmoved from 0.92x to 2.83x by counting retained bytes without publishing, persisting or reserving an output buffer, andvsprintfmoved from 0.86x to 2.05x by formatting unadorned%dwithout libc and packing scalar Mixed records directly.crc32is now 5.36x PHP after the slicing-by-8 rewrite.Fair C baseline
All 70 measured cases now have an in-process
cc -O2reference whose checksum matches the PHP loop exactly. The literal trim length fold movedltrim/rtrimfrom 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 arestrip_tagsat roughly 0.75-1.35x of the C scanner depending on host load,inet_ntopat 1.56x and the(int)sqrtcast row at 4.1x slower;crc32is now about 4.4x faster than the C byte-table reference, the floatsqrtrange 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), andinet_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 reportedC/eratio 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 withcc -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
_crc32_table8holds 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.
SqrtSumLoopmatchesfor ($i = start; $i < bound; $i++) { $sum += sqrt($i + c); }and replacesthe boxed checked-add/sqrt/release/fadd scaffold with one
__rt_sqrt_sum_rangecall. The helper adds roots in source order and keepsthe checked-add overflow promotion. The 200k-iteration body drops from
0.467 ms to 0.138 ms, 17.7x PHP. The
(int)sqrtcast row still usesthe per-call
SqrtAddInthelper and remains the loop-vectorizer follow-up.strlen(strip_tags($s))now calls__rt_strip_tags_count: it counts retainedtext 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.
suite covers slicing tails and unaligned lengths, empty/negative/overflow
sqrt ranges, heap-debug cleanliness and
--ir-opt=offparity. The existingallow-list/comment/PHP-tag/NUL strip_tags parity suite stays green.
Eighteenth batch: loop-invariant JSON count
count(json_decode(...))hoist.json_decode_count_hoistproves theloop 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
JsonDecodeCountprobe in the preheader, replaces every use of the bodycall 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).
loop, a mutated source, an earlier body effect, the single-probe IR shape and
--ir-opt=offparity. The existing 149 JSON codegen tests stay green.Safety
IntegerRangeto uncheckediadd/isub; otherwise the whole trial is discarded and the boxed path (with float promotion) remains. Thecounter_overflowfixture matches PHP:int,int,float.global $xmakes the slot aGlobalAlias(excluded by the candidate filter); eval is rejected by the existing eval/EvalScope checks; a runtimeincludethat hands the frame to evaluated code is rejected explicitly.$GLOBALS, variable variables,extract/compactare not compiler-supported;get_defined_varslowers to explicit slot loads, which fail the use proof.Mixedso an overflow still promotes to a float tag. No missing-value provenance is observable after the arithmetic.Tests
+=/-=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.ensure_unique/grow/frame on all five targets; slow path keeps COW/shape/growth.count()hot path has no diagnostic publish and leaves the loop condition; bounded reads lower toarray_get_uncheckedin both IR modes; an inclusive bound still warns and falls back to null; the typed reduction lowers to__rt_array_sum_intwith PHP-matching overflow, float seeds, non-zero starts and empty ranges; an observed induction keeps the boxed reduction; the constant fill reserves capacity and emitsArrayPushReservedon 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.counter_overflowandcountersfixtures match PHP;arraysfixture COW/hash behavior matches PHP; sparse integer holes are covered bytest_sparse_integer_keys_after_a_contiguous_prefix; heap debug clean.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'sinet_pton/inet_ntopaddress table; all run with heap debug.strip_tags_len_sinkcovers full-array, allow-list, array allow-list, comments/PHP tags, NULs, strictTypeErrors, argument evaluation order and--ir-opt=offparity.BoundedMixedCounter::is_applicablenow requires a boxed local with a recognized stored counter update. The broadICheckedAddgate 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.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_testsfor 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
hrtimewrapper, min-of-12, alternating baseline/new/PHP:array_sum(50k append + 50k reads)sum_loop(200k,echo $sum . "\n"shape)sum_int(200k, accumulator cast to int)string_concat(5k appends)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 msmeasurement forarray_sumusedecho $sum, "|", $elapsed, and a multi-argumentechoconsumes 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):
sum_loopcond+bodyarray_sumfill cond+body__rt_array_sum_intcall in the preheader)string_concatcond+bodyarray_sumnow lowers to one reserved fill loop plus one__rt_array_sum_intcall: 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-O2C 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_sum5.80 ms vs C 6.74 ms,string_concat6.09 ms vs C 6.43 ms,sum_loop7.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 checkreports 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:ensure_array_uniqueand growth for a proven-unique array);+ - *, where PHP's null-as-zero is the arithmetic contract, the array read already emitted the missing-key warning, and the result remainsMixedso overflow still promotes to float.Trail also reports an overlap with Vincenzo's pushed
feat/emit-php-librarybranch ondocs/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 afterlowers_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/printfcases, 64 mbstring cases, and 24 iconv/hash/zlib/openssl/tokenizercases.
mb_send_mail()remains the only excluded function because it performs externalmail transport. The 164 case names are unique across the four matrices.
Release compiler, macOS aarch64, in-process
hrtimemedians. Every row compares itsobservable checksum against PHP.
OpenSSL cipher surface: 135/135
openssl_get_cipher_methods()now returns PHP's exact 135-name OpenSSL 3.6.4default-provider catalog. The AOT backend links the new
elephc-opensslbridgeagainst a managed
openssl-cryptopackage (libcrypto.aplus itszlibdependency), 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; theeval()interpreter retains itssmaller pure-Rust cipher subset. A literal
openssl_cipher_iv_length()name isfolded against the same pinned 3.6.4 table.
openssl_get_cipher_methodsopenssl_cipher_iv_lengthopenssl_encryptopenssl_decryptThe managed package is declared in
examples/openssl-cipher/elephc.toml; the bridgeand table are covered by 5 crate tests (exact catalog, IV lengths, AES goldens, and a
35-vector mode matrix) plus the codegen
opensslfilter.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 thepos/regs/getregsarray 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_ordand theposition searches, plus the allocation-free
*_Len/mb_split_countsinks, out ofloops. An owned
mb_substr/mb_strcutresult observed only bystrlenmoves withits 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-lengthand byte-position runtime results carry a bounded range, so
length + $ibecomesan 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/*Countsinks). Thehot
forbody drops from 13 instructions to 5, anda 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:
mb_output_handlermb_languagemb_regex_set_optionsmb_http_outputmb_substitute_charactermb_splitmb_substr_countmb_strrichrmb_stristrmb_strriposC baseline for the hot mbstring kernels
The 35 scalar kernels in
.bench-scratch/c/string_mb.cmeasure C/elephc0.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_chrlength sink was alreadyhoisted; 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_kanakernels still include a one-time bridge conversion or regex scan, not per-iteration
loop work. The
$argcC-intrange seed removed checked loopadditions; 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, thenumeric-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
iconv_mime_decodeiconv_mime_decode_headersgzcompressgzdeflateopenssl_encrypthash_algoshashgzinflategzuncompresstoken_get_allhash_hmaciconv_strrposiconv_strposopenssl_decrypticonv_get_encodingtoken_namehash_equalsopenssl_get_cipher_methodsiconv_set_encodingopenssl_cipher_iv_lengthiconv_substriconv_mime_encodeiconviconv_strlenNo measured String performance case remains below PHP and no coverage mismatch remains.
token_get_all()reaches 128.2x PHP through the validatedliteral-count preheader hoist.
openssl_get_cipher_methods()is 5.3xand
openssl_cipher_iv_length()is 3.7x after thelibcrypto 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:array_sumhalf-sum. The SIMD fold shifted the last data pairinstead 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/xmm0beforepsrldq.array_sumand the fused map+sum kernel added theodd tail element without the high-dword guard; a tail of
PHP_INT_MAXwrappedinstead of promoting to float. Both AArch64 and x86_64 tails now restart the
scalar sequence when the (mapped) tail exceeds 32 bits.
trimcharlist grammar. Decreasing ranges (b..a), three-dot forms(
a...c), and chained forms (a..c..x) diverged fromphp_charmaskandbetween native and eval. All three parsers now use the exact
php_charmaskloop; the differential test covers the failing masks.
null--. A statically null local decremented toint(-1)in AOT andeval. It now stays
NULLand emits PHP's warning; the eval IR has a dedicatedIncDecVarstatement and the script-cache format version is 24.triminvalid-range warnings. Native and eval trim now emit PHP's fourInvalid '..'-rangewarnings with the caller-visibletrim()/ltrim()/rtrim()/chop()name, including runtime masks. The shared runtime warningpath still omits the
in <file> on line Nsuffix used by some other elephcwarnings.
array_map()throw leak. The indexed map runtime now catches acallback throw through a bounded cleanup guard, releases the partial result
array, and rethrows the original exception. A heap-debug regression covers the
caught
TypeErrorpath.storage when a static key can skip (negative or
>= 2) or a runtime integerkey 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.
??=result temp is explicitly borrowed beforethe 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.
array_sum_int_boundsandarray_map_sum_cachenowreject
RuntimeCall + Dataframes like the neighboring passes.caveat;
.bench-scratch/is ignored; the OpenSSL TSV accepts an empty finalfield 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-opensslwithELEPHC_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.ba04c55c44on top ofmainat55758e892f; GitHub reports MERGEABLE.trim/ltrim/rtrimmasks now followphp_charmaskexactly in AOT and eval:increasing
start..endranges expand, while decreasing and three-dot forms leavetheir 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-rangewarnings 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 Nsuffix used by some other elephc warnings.
The branch is one squashed perf commit on
mainat55758e892f; the per-areadetail 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.