Repository navigation
Iteration/callback perf: JSLN key-reconstruction hypothesis refuted; the addressable cost is the per-element second lookup in *_HASH / *_ADAPTIVE #85
Description
Investigation of the standing roadmap item "C-level forEach()/filter()/map() performance tuning (vtable dispatch)". Measurement only — no code changed.
Short version: the hypothesis that JSLN's caller-supplied key buffer makes ordered string iteration expensive is refuted. JSLN's cost is flat in key length and flat in working-set size; the expensive part is JudySL's stateless per-call root descend plus successor search, and libJudy exposes no cheaper primitive. The one large addressable cost the decomposition did find is elsewhere: the four *_HASH / *_ADAPTIVE types perform a second full lookup (JHSG/JLG) per element during ordered traversal, worth 22 ns/elem at 16-byte keys and 98 ns/elem at 40-byte keys — up to 46% of forEach() on those types. The "vtable dispatch" framing in the roadmap line targets a few ns and should be dropped.
Method
Host honeycomb, 24-core x86_64, Ubuntu 22.04, idle. /proc/loadavg snapshotted before and after every run: 0.07–0.47 across all runs (contention threshold for this box is cores/2 = 12.0). PHP runs inside the existing php-judy-bench image: Debian 13, PHP 8.4.24, ext-judy 2.4.2. C benchmarks gcc -O2, same image, same libJudy.
Keys are user:%08d:f%d padded to a target length — 10 keys share each user: prefix, matching the shape the backend-evaluation POC used. All numbers are median of 5 runs, [min .. max] shown. Every PHP figure below was reproduced independently at n=500K and n=1M (deltas <3%). All numbers (measured) unless tagged otherwise.
C level: which part of JSLN is expensive
n=1M, median ns/key:
| keylen 16 | keylen 24 | keylen 40 | keylen 64 | |
|---|---|---|---|---|
JLF/JLN iterate (JudyL) |
10.6 | 10.7 | 10.6 | 10.8 |
JSLF/JSLN iterate |
70.2 | 78.1 | 77.7 | 77.8 |
JSLG, keys replayed in iteration order |
37.1 | 47.4 | 46.6 | 44.9 |
JSLG, random order |
184.5 | 190.4 | 192.8 | 197.8 |
JHSG, keys replayed in iteration order |
31.4 | 93.5 | 106.8 | 116.9 |
This reproduces the POC's ~67 ns/key for JudySL ordered iteration (70.2 here).
Three discriminators, all pointing the same way:
- Key length. 16 → 64 bytes is 4× the bytes to materialize;
JSLNmoves 70.5 → 77.8 (+10%). A cost dominated by writing the key into the caller buffer would scale with the key. - Working set. n=100K → 1M is 10× the tree;
JSLNmoves 66.4 → 70.2 (+6%). Not cache-miss-bound on tree size either. - Direct subtraction.
JSLGreplayed in iteration order does the same descend with the same locality and does not write a key back.JSLN − JSLG= 33.1 / 30.7 / 31.1 / 32.9 ns at keylen 16 / 24 / 40 / 64 — constant in key length. What "next" adds over a plain lookup is successor search (backtracking to the next populated branch), not key materialization.
So the 70 ns decomposes roughly as 37 ns descend (itself 3.5× a complete JudyL JLN step) + 32 ns successor search + ~1 ns key bytes.
This is not addressable from php-judy. The entire JudySL surface in Judy.h is JSLI JSLG JSLD JSLF JSLN JSLL JSLP JSLFA — there is no cursor/stateful-iteration primitive. The gap against libart's art_iter (2.7 ns/key in the POC) is stateful-DFS-with-a-path-stack vs stateless-successor-API; closing it means replacing the backend, not tuning the extension. That belongs to the backend-evaluation thread, not here.
PHP level: per-layer decomposition
n=500K, keylen 16 and 40, median ns/element:
| case | kl16 | kl40 |
|---|---|---|
floor: empty for loop |
1.8 | 1.8 |
floor: for loop + closure call |
11.0 | 10.9 |
native array (int keys) foreach k=>v |
4.5 | 4.5 |
native array (string keys) foreach k=>v |
8.7 | 6.5 |
native array_map(fn) |
29.0 | 29.5 |
INT_TO_INT keys()/values()/toArray() |
15.1 | 15.1 |
INT_TO_INT foreach k=>v |
23.1 | — |
INT_TO_INT forEach(noop) |
26.4 | 26.6 |
INT_TO_INT filter(false) / filter(true) / map(id) |
27.3 / 63.6 / 51.4 | — |
STRING_TO_INT keys() / values() / toArray() |
90.0 / 80.0 / 105.2 | 102.4 / 88.0 / 134.1 |
STRING_TO_INT foreach k=>v |
95.1 | 106.7 |
STRING_TO_INT forEach(noop) |
95.2 | 105.3 |
STRING_TO_INT filter(false) / filter(true) / map(id) |
97.6 / 275.4 / 238.9 | 110.0 / 289.9 / 252.2 |
STRING_TO_INT_HASH keys() / values() |
90.3 / 112.6 | 109.5 / 206.8 |
STRING_TO_INT_HASH foreach / forEach(noop) |
122.9 / 130.8 | 207.7 / 213.9 |
STRING_TO_INT_ADAPTIVE keys() |
89.0 | 108.9 |
STRING_TO_INT_ADAPTIVE foreach / forEach(noop) |
123.0 / 132.4 | 205.3 / 212.7 |
STRING_TO_MIXED_HASH keys() / values() |
89.4 / 111.3 | 110.1 / 208.6 |
STRING_TO_MIXED_HASH foreach / forEach(noop) |
123.5 / 131.6 | 208.3 / 215.9 |
Spreads were tight throughout (worst case ±1.5% around the median; the few keys() maxima are single first-run allocator warmups).
Attributing the layers:
forEach(noop) on STRING_TO_INT, keylen 16 = 95.2 ns/elem
JSLF/JSLNtraversal ≈ 70 (74%) — not addressable- userland dispatch ≈ 10 (11%) — at the PHP floor, not addressable
- extension glue (per-element
ZVAL_STRINGfor the key, fci setup, arg + retval dtor, type branches) ≈ 15 (16%)
forEach(noop) on INT_TO_INT = 26.4 ns/elem
JLF/JLN≈ 10.6 (40%)- dispatch ≈ 10 (38%)
- glue ≈ 6 (22%)
Dispatch is isolated two ways and both agree on ~10 ns: INT_TO_INT forEach(noop) 26.4 − values() 15.1 (same traversal, same zval build, no callback) = 11.3; and the pure-PHP floor of calling an empty closure in a for loop = 11.0 − 1.8 = 9.2. Judy::forEach() on INT_TO_INT (26.4) is already faster than array_map() over a native PHP array (29.0). There is nothing to win in dispatch.
The one large addressable cost. For *_HASH and *_ADAPTIVE, ordered traversal walks the JudySL key_index and then does a second, independent lookup (JHSG, or JLG on the SSO path) to fetch the value — in judy_iterator_move_forward, judy_iterator_rewind, judy_populate_array, and judy_callback_iterator. Measured directly as values() − keys() (identical traversal, one with the value fetch, one without):
| type | kl16 | kl40 |
|---|---|---|
STRING_TO_INT_HASH |
+22.3 | +97.3 |
STRING_TO_MIXED_HASH |
+21.9 | +98.5 |
STRING_TO_INT_ADAPTIVE |
+22 | +98 |
STRING_TO_MIXED_ADAPTIVE |
+22 | +98 |
Matching the C-level JHSG sorted-order figures (31.4 → 106.8 as keylen goes 16 → 40). At keylen 40 this is 46% of forEach() on those types and the reason STRING_TO_*_HASH iteration costs 2× STRING_TO_INT iteration despite walking the same JudySL.
filter() re-fetch. action_filter (php_judy.c:4149) calls judy_object_read_dimension_helper_zv() to recover the value, but judy_callback_iterator still has it in args[0] — it just isn't passed to the action. Isolated as filter(true) − map(identity) (the extra read filter does that map does not): +36.5 ns on STRING_TO_INT kl16, +12.2 ns on INT_TO_INT. Consistent with JSLG sorted (37.1) and JLG.
Ceiling
Addressable fraction, i.e. what is left after subtracting Judy traversal and userland dispatch:
| op / type | total (kl16) | addressable |
|---|---|---|
foreach / forEach on INT_TO_* |
23–26 | ≤ 20%, all of it arg marshalling; already beats array_map |
foreach / forEach on STRING_TO_INT/_MIXED |
95 | ≤ 16%, realistically ~10% |
foreach / forEach / values / toArray on the 4 *_HASH/*_ADAPTIVE types |
123–134 (kl16), 205–216 (kl40) | 18% at kl16, 46% at kl40 |
filter() |
275 (STR) / 64 (INT) | 13% / 19% |
Nobody should start this work expecting more than that. In particular, on the flagship STRING_TO_INT type, three quarters of forEach() is JSLN and another tenth is zend_call_function — the whole extension layer is ~16% and it will not all come out.
Plan
Step 1 — remove the per-element second lookup for *_HASH and *_ADAPTIVE.
Mechanism: the JudySL key_index value word is allocated but never written. Every JSLI(..., key_index, ...) site (php_judy.c:752, 795, 831, 848, 879, 903, 3057, 3107, 3185, 4732; judy_handlers.c:148, 181, 253) only checks the return for PJERR and stores nothing through it. Put the payload there — Word_t for the _INT variants, zval* for the _MIXED variants — so ordered traversal reads the value from the cursor it already holds and never calls JHSG/JLG.
Targets: 22 ns/elem at keylen 16, 98 ns/elem at keylen 40 (measured), across foreach, forEach(), filter(), map(), keys()/values()/toArray()/getAll(), clone, and the set ops, for 4 of the 10 types. Memory cost is zero — the word is already allocated.
Gating dependency (this is the real work, not the traversal edit): audit every path that mutates a value in place so the two stores cannot diverge — offsetSet on an existing key, increment(), putAll, mergeWith, map() writing into a result, __unserialize — and every delete path so a zval* is freed exactly once.
Rejected alternative: caching the PWord_t slot returned by JHSI instead of the value. libJudy can relocate JudyHS leaves on insert, so a cached slot pointer can dangle.
Gate: ≥15% improvement on foreach and values() at keylen 16 and ≥35% at keylen 40 for all four types; no regression on point-lookup benchmarks; .phpt covering mutation before/during/after iteration for both _INT and _MIXED; clean USE_ZEND_ALLOC=0 valgrind on the iteration and mutation tests.
Step 2 — drop the value re-fetch in filter(). Independent of Step 1, either order. Widen the judy_callback_action signature to pass args[0] so action_filter stops re-reading a value the iterator already has. Targets 36.5 ns per surviving element on STRING_TO_INT (13% of filter(true)), 12.2 ns on INT_TO_INT (19%). Small, isolated, no data-structure change.
Semantics: today, a predicate that mutates $this[$k] changes what filter() copies; after the change filter() copies the pre-callback value. Snapshot semantics are the more defensible contract, but this is a behavior change and needs a .phpt pinning whichever is chosen.
Step 3 (optional, lowest value, do last or not at all) — reuse one zend_string for the key across callback iterations. judy_callback_iterator does ZVAL_STRING(&args[1], key) + zval_ptr_dtor per element; judy_iterator_move_forward (judy_iterator.c:214-221) already implements the non-interned / refcount==1 / length-fits reuse. Projected ~8-12 ns/elem on string-keyed forEach/filter/map, ~10% (projected, not measured — measuring it requires building the change**)**. Hazard: a callback may retain the key ($keys[] = $k;), so the refcount guard is mandatory and getting it wrong corrupts a userland string. Bounded above by the whole 15 ns glue bucket. Weak supporting evidence that it is marginal: foreach (which already reuses) measures 95.1 vs forEach() (which does not) at 95.2.
Non-goals
- Callback dispatch optimization. Measured at ~10 ns/elem, equal to the pure-PHP closure-call floor.
forEach()already beatsarray_map()over a native array. - "vtable dispatch" as the roadmap line describes it. The per-element type branches live inside a glue bucket that is ~6 ns total on
INT_TO_INTand ~15 ns onSTRING_TO_INT; the branches themselves are a few ns of well-predicted work. Not worth a refactor. - Faster JudySL ordered traversal.
JSLNis 70 ns/key and libJudy exposes no cursor API. Any real improvement here is a backend change, not extension tuning. INT_TO_*traversal.JLNis 10.6 ns/key.- Chasing the libart
art_iter2.7 ns/key figure from inside php-judy. Different problem, different thread.
Roadmap
Replace C-level forEach()/filter()/map() performance tuning (vtable dispatch) in README.md:497 with the Step 1 item, which is the only part of that line the data supports.
Activity
poc(bench): inject a filter() regression to test issue #87 detection
Merge pull request #95 from orieg/perf/mirror-preconditions
orieg commentedon Aug 17, 2026
Status: B1 and B2 landed (#95). B3 is next, and B1 changed its shape.
Part B progress against the plan in this issue:
deleteRangedestructor orderingJUDY_DEBUG_MIRRORassertions + CI jobkey_index_MIXEDtypesThe
filter()half of this issue shipped separately in #86 (−12.5%STRING_TO_INT, −14.2%INT_TO_INT).What B1 changed about B3
The mutation surface collapsed. The audit in this thread counted 24 sites that would have to keep the two stores in sync.
judy_string_slot_acquire()now owns key registration, its rollback and the counter bump, so all six live-mutationkey_indexJSLIsites sit inside one function. What remains areslice()×3 andclone()×3, which build fresh arrays and never mutate a live object.increment()— previously the only in-place mutator bypassingwrite_dimension_helper, and the likeliest source of a silentincrement()-vs-foreachdisagreement — now goes through the same path.A cost this issue does not account for. Mirroring the payload means locating the
key_indexslot on the overwrite path, where today it is not touched at all. The gate stated in this issue is iteration-only and would not see it.examples/benchmarks/judy-bench-writepath.php(also in #95) exists for that: it isolatesoffsetSetinsert/overwrite across the six string-keyed types,increment(), and the ADAPTIVE short-string branch. It already caught a +3.0% regression on ADAPTIVE SSO overwrite during B1 that came from a call boundary, not from the branch restructure, and that had passed review.But B1 may make that cost zero. The existence probe is now a single site, so B3 can replace the
JHSGprobe with aJSLGonkey_index— which answers "does this key exist?" and returns the mirror slot — rather than adding a third lookup.Prerequisite before starting B3: the numbers in this issue compared
JHSGandJSLGreplayed in iteration order. Random-orderJSLGmeasures 184–198 ns there, and the random-order JudyHS figure was never taken. Writes are random-order, so the comparison that decides whether the swap is free is currently unmeasured. That measurement gates B3; it is not an afterthought.A limit worth stating before B3 claims coverage
JudyHS has no enumeration primitive. The API is
JHSI/JHSG/JHSD/JHSFAand nothing else, so the value store cannot be walked back tokey_indexfor the*_HASHtypes. B2's assertions checkkey_index→ value store (which is the direction payload divergence shows up in) and both directions for ADAPTIVE's short-key JudyL half. An orphaned JudyHS entry is reachable only through the population/counter check or as a valgrind leak. No audit can close that gap, so B3's gate should state it rather than imply coverage it cannot have.Recommended B3 scope
STRING_TO_INT_HASHandSTRING_TO_INT_ADAPTIVEonly. The payload is a plainWord_twith no ownership, which removes every zval-lifetime concern — no dangling alias, no destructor-ordering rule, noget_gcinteraction. The prize is barely smaller (the second lookup is 80.7 ns on INT_HASH against 82.7 ns on MIXED_HASH at 40-byte keys). The_MIXEDvariants follow in B5 once the_INTones have run in CI underJUDY_DEBUG_MIRROR.Merge pull request #100 from orieg/perf/key-index-mirror-optin
orieg commentedon Aug 17, 2026
Closing — everything in this issue has landed, and B5 is a deliberate no
What shipped
filter()second lookupSTRING_TO_INT, −14.2%INT_TO_INTdeleteRangeorderingincrement()no longer bypasses itJUDY_DEBUG_MIRROR+ CI_MIXEDtypesMeasured on the mirrored types (
STRING_TO_INT_HASH, long-keySTRING_TO_INT_ADAPTIVE),optimizeIteration: true:foreachvalues()offsetSetoverwriteOpt-in because that trade is not universally good:
increment()pays the same ~19% at short keys, andincrement()is a headline feature. Default-off measures as no separation frommain.B5: not doing
_MIXEDbuys a near-identical read win — the second lookup is 82.7 ns against 80.7 ns for_INTat 40-byte keys — for the same write cost plus zval lifetime rules: dangling aliases, destructor ordering,get_gcinteraction. Materially worse risk for materially the same prize. The_INTtypes have the clean property that made B3 tractable, namely aWord_tpayload with no ownership.Reopen if a workload appears where ordered traversal over
_MIXEDdominates and the write path does not.Two premises in the original issue that measurement overturned
JSLNkey-reconstruction hypothesis is refuted.JSLNis flat in key length (+10% for 4x the bytes) and flat in working-set size (+6% for 10x); the cost is JudySL's stateless per-call root descend.Judy.hexposes no cursor primitive, so php-judy cannot close that gap — it is a backend property, not an extension-tuning target. Harness kept atresearch/iteration-cost/.Judy::forEach()onINT_TO_INT(26.4 ns/element) already beatarray_map()(29.0). The addressable cost was the per-element second lookup, which is what this work removed.Limits worth stating rather than implying
JHSI/JHSG/JHSD/JHSFAand nothing else), so the value store cannot be walked back tokey_indexfor the*_HASHtypes.JUDY_DEBUG_MIRRORcheckskey_index→ value store, which is the direction payload divergence appears in, plus both directions for ADAPTIVE's short-key JudyL half. No audit can close that gap.str_int.filter_none+3.2%,str_mixed_adaptive.overwrite_sso+3-4%) were each chased with controls and attributed to code layout under-O3 -flto, not to the change. Recorded on perf(#85): opt-in key_index payload mirror — 24-47% faster ordered reads, per-instance #100 rather than smoothed over.Found along the way, fixed separately
deleteRange()destroyed values before removing tree entries: a userland-reachable use-after-free and double free, same class as the 2.4.2write-before-dtorfixes and missed by that sweep.next()/rewind()blanked a refcountediterator_keywith a bareZVAL_UNDEF, leaking onezend_stringper completed manual Iterator walk on every string-keyed type. 32 bytes per walk, scaling one-to-one. Surfaced only because this work's canary suite walks often enough to make it visible.Gates left behind
examples/benchmarks/judy-bench-readpath.phpandjudy-bench-writepath.php, plusoptimizeIterationcoverage in the CI release comparison including an intra-build ratio that flags if the win ever erodes. The write-path gate exists because this issue's stated gate was iteration-only and would not have seen the overwrite cost at all.