Eliminate deep copies: 20-6000x faster, linear memory - #1
Merged
Conversation
Benchmarking showed every hot path was dominated by deepCopy calls: whole-document copies in JsonPointer::get/has, per-traversal-step copies in childValue, and per-comparison copies in the equality helpers (the latter made diff quadratic in nesting depth for both time and memory — diffing two identical 800-level documents exhausted 128M). Arrays are PHP value types with engine copy-on-write, so sharing them is already copying. The new import() validates and clones only stdClass nodes (the sole reference-semantics JSON values), rebuilding just the object-bearing subtrees. Read paths (traversal, has, get, equality) now copy nothing. Token descent is index-based instead of array_shift, and diff builds pointer strings incrementally. Isolation contract is unchanged and now pinned by tests, including a memory bound on deep diffs that failed before this change.
jsonValuesAreEqual now tries strict equality first: it is sound as a positive-only check, and the engine compares identical copy-on-write hashtables by pointer, so subtrees shared between a document and a patched derivative of it compare in constant time. diffValue previously ran a full deep-equality check at every node before recursing, so each ancestor re-compared all of its descendants. It now recurses directly into same-type containers and only falls back to the equality helper for leaves and mixed representations (1 vs 1.0, assoc array vs stdClass), which keeps emitted operations byte-identical while dropping the O(size x depth) factor.
Values produced by json_decode cannot contain resources, closures, or non-finite floats, and no other code holds references into them. The JSON-string entry points (applyJson, diffJson, merge applyJson) now bypass the validating isolation pass and operate on the decoded values directly; results are encoded immediately, so nothing observable leaks.
Array-index tokens went through preg_match on every traversal step; a ctype_digit check with an explicit leading-zero guard accepts and rejects exactly the same token set. Escape handling now bails out before scanning when a token contains no escapable characters, and fromString unescapes in place instead of mapping through a closure. This targets the per-call constant of pointer-heavy workloads.
megasteve19
force-pushed
the
perf/eliminate-deep-copies
branch
from
July 24, 2026 09:15
a129be6 to
a924c35
Compare
diffObject now walks members in two passes instead of three: the target pass buffers add and member-diff operations separately and concatenates in the documented remove/add/replace order, saving a full pass and one membership lookup per common member. Array diff hoists count() out of loop conditions, and the three stdClass equality branches collapse into one objectsAreEqual entry that accepts both object representations. Emitted operations are unchanged.
The copy-on-write rework moved validation onto new entry paths that the suite no longer exercised: diff() input validation, has() target validation for objects and invalid values, merge imports of objects nested inside arrays, and diff's equal-leaf fallback for numerically equal values. New tests pin each of those, restoring the 100% coverage gate. Array-index tokens now use strspn() instead of ctype_digit() so the package keeps working when the ctype extension is disabled, without declaring an ext-ctype requirement.
Level 10 treats implicit mixed strictly. The src narrowing style already satisfies it; the gap was in tests, which accessed properties and offsets on mixed API results. Those sites now extract values and assert their structure first, which both narrows for analysis (through PHPUnit's own type assertions) and makes the tests assert the shapes they previously assumed.
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
Benchmarking showed every hot path was dominated by redundant
deepCopycalls: whole-document copies inJsonPointer::get/has, per-traversal-step copies inchildValue, and per-comparison copies in the equality helpers — the latter madediffquadratic in nesting depth for both time and memory (diffing two identical 800-level documents exhausted the default 128M limit).Four commits, no extensions, PHP engine features only:
stdClass(the sole reference-semantics JSON value) is cloned, and only in object-bearing subtrees. Read paths copy nothing. Index-based descent replacesarray_shift; diff builds pointer strings incrementally.===fast path + single-pass diff — strict equality is sound as a positive-only check, and the engine compares identical COW hashtables by pointer, so diffing a document against a patched derivative of it is near O(changed paths).diffValueno longer deep-compares at every ancestor.applyJson/diffJson/mergeapplyJsonskip validation and isolation for freshly decoded input (structurally valid by construction, referenced by nobody).ctype_digitwith a leading-zero guard replacespreg_matchper array token; escape handling bails early when no escapable characters exist.Numbers (PHP 8.4, medians)
JsonPointer::get, 1k-item stdClass docdiff, ~9.8k-node tree, 2 changesdiff, identical 800-deep chainsetat depth 400diff, identical 5k-item docsapplyJson, 1 op on 10k-item docContract
copyop never aliases inside the result) and a memory bound on deep diffs that fails onmaster.JsonPointer::get/hasvalidate only the subtree they traverse and return, not the whole document — a read no longer throws because an unrelated branch holds a non-JSON value. Write operations still validate the full document exactly as before.