Skip to content

Iteration/callback perf: JSLN key-reconstruction hypothesis refuted; the addressable cost is the per-element second lookup in *_HASH / *_ADAPTIVE #85

Closed

Description

@orieg

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:

  1. Key length. 16 → 64 bytes is 4× the bytes to materialize; JSLN moves 70.5 → 77.8 (+10%). A cost dominated by writing the key into the caller buffer would scale with the key.
  2. Working set. n=100K → 1M is 10× the tree; JSLN moves 66.4 → 70.2 (+6%). Not cache-miss-bound on tree size either.
  3. Direct subtraction. JSLG replayed 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/JSLN traversal ≈ 70 (74%) — not addressable
  • userland dispatch ≈ 10 (11%) — at the PHP floor, not addressable
  • extension glue (per-element ZVAL_STRING for 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 beats array_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_INT and ~15 ns on STRING_TO_INT; the branches themselves are a few ns of well-predicted work. Not worth a refactor.
  • Faster JudySL ordered traversal. JSLN is 70 ns/key and libJudy exposes no cursor API. Any real improvement here is a backend change, not extension tuning.
  • INT_TO_* traversal. JLN is 10.6 ns/key.
  • Chasing the libart art_iter 2.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

  1. added 2 commits that reference this issue on Aug 17, 2026
  2. orieg commented on Aug 17, 2026

    @orieg
    OwnerAuthor

    Status: B1 and B2 landed (#95). B3 is next, and B1 changed its shape.

    Part B progress against the plan in this issue:

    Step State
    B0 — fix deleteRange destructor ordering done (#89) — it was a live use-after-free and double free, independent of this issue
    B1 — one slot-acquisition path for string-keyed writes done (#95)
    B2 — JUDY_DEBUG_MIRROR assertions + CI job done (#95)
    B3 — mirror the payload into key_index next
    B4 — switch the read sites after B3
    B5 — measure, decide on the _MIXED types last

    The 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-mutation key_index JSLI sites sit inside one function. What remains are slice() ×3 and clone() ×3, which build fresh arrays and never mutate a live object. increment() — previously the only in-place mutator bypassing write_dimension_helper, and the likeliest source of a silent increment()-vs-foreach disagreement — now goes through the same path.

    A cost this issue does not account for. Mirroring the payload means locating the key_index slot 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 isolates offsetSet insert/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 JHSG probe with a JSLG on key_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 JHSG and JSLG replayed in iteration order. Random-order JSLG measures 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 / JHSFA and nothing else, so the value store cannot be walked back to key_index for the *_HASH types. B2's assertions check key_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_HASH and STRING_TO_INT_ADAPTIVE only. The payload is a plain Word_t with no ownership, which removes every zval-lifetime concern — no dangling alias, no destructor-ordering rule, no get_gc interaction. 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 _MIXED variants follow in B5 once the _INT ones have run in CI under JUDY_DEBUG_MIRROR.

  3. added a commit that references this issue on Aug 17, 2026
  4. orieg commented on Aug 17, 2026

    @orieg
    OwnerAuthor

    Closing — everything in this issue has landed, and B5 is a deliberate no

    What shipped

    Step PR Outcome
    filter() second lookup #86 −12.5% STRING_TO_INT, −14.2% INT_TO_INT
    B0 deleteRange ordering #89 prerequisite — turned out to be a live use-after-free and double free
    B1 one write path #95 six write branches → one function; increment() no longer bypasses it
    B2 JUDY_DEBUG_MIRROR + CI #95 assertion build with negative controls
    B3+B4 mirror + read sites #100 opt-in per instance
    B5 _MIXED types — not doing, see below

    Measured on the mirrored types (STRING_TO_INT_HASH, long-key STRING_TO_INT_ADAPTIVE), optimizeIteration: true:

    kl16 kl40
    foreach −23.6% −38.1%
    values() −28.4% −46.5%
    offsetSet overwrite +19.2% +8.1%

    Opt-in because that trade is not universally good: increment() pays the same ~19% at short keys, and increment() is a headline feature. Default-off measures as no separation from main.

    B5: not doing

    _MIXED buys a near-identical read win — the second lookup is 82.7 ns against 80.7 ns for _INT at 40-byte keys — for the same write cost plus zval lifetime rules: dangling aliases, destructor ordering, get_gc interaction. Materially worse risk for materially the same prize. The _INT types have the clean property that made B3 tractable, namely a Word_t payload with no ownership.

    Reopen if a workload appears where ordered traversal over _MIXED dominates and the write path does not.

    Two premises in the original issue that measurement overturned

    • The JSLN key-reconstruction hypothesis is refuted. JSLN is 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.h exposes no cursor primitive, so php-judy cannot close that gap — it is a backend property, not an extension-tuning target. Harness kept at research/iteration-cost/.
    • The "vtable dispatch" framing was wrong. Userland callback dispatch is ~10 ns/element inside a 6-15 ns glue bucket, and Judy::forEach() on INT_TO_INT (26.4 ns/element) already beat array_map() (29.0). The addressable cost was the per-element second lookup, which is what this work removed.

    Limits worth stating rather than implying

    • JudyHS has no enumeration primitive (JHSI/JHSG/JHSD/JHSFA and nothing else), so the value store cannot be walked back to key_index for the *_HASH types. JUDY_DEBUG_MIRROR checks key_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.
    • Two small movements on untouched code paths (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.
    • None of this has had independent review beyond the checks described.

    Found along the way, fixed separately

    Gates left behind

    examples/benchmarks/judy-bench-readpath.php and judy-bench-writepath.php, plus optimizeIteration coverage 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions