[optimize] fn:deep-equal: streaming fast-path for persistent-DOM trees (closes #4050)#6337
[optimize] fn:deep-equal: streaming fast-path for persistent-DOM trees (closes #4050)#6337joewiz wants to merge 4 commits into
Conversation
… per-type helpers
Decompose deepCompare(Item, Item, Collator, DBBroker) from NPath 8,847,362
into focused per-type helpers, each well under the 200 NPath threshold:
- compareArrayItems — array-vs-array, with size short-circuits
- compareMapItems — map-vs-map, with size and key short-circuits
- compareAtomicItems — atomic-vs-atomic, NaN/numeric handling
- compareNodeItems — node-vs-node dispatcher (Java 21 switch
expression over Type.DOCUMENT / ELEMENT /
ATTRIBUTE / PI|NAMESPACE / TEXT|COMMENT)
- compareDocumentItems — document-node compare with streaming fast-path
- compareElementItems — element-node compare with streaming fast-path
- compareAttributeItems — attribute name + value compare
- comparePiOrNamespaceItems — PI/namespace name + string value compare
- tryStreamingCompare — extracts the eXist-dbGH-4050 streaming fast-path
guard + fallback that was duplicated across
the DOCUMENT and ELEMENT switch branches
Pure refactor; no behaviour change. The streaming fast-path's Macbeth.xml
result (6-9ms median, 800x speedup) is preserved. Resolves reinhapa's
NPath complexity review comment on PR eXist-db#6337.
Verification:
- FunDeepEqual JUnit set: 78/78 pass
- XQuery3Tests XQSuite gate: 986/986 pass (1 pre-existing skip)
- Codacy PMD: NPath violation on deepCompare cleared; no new findings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
[This response was co-authored with Claude Code. -Joe] Decomposed |
Per reinhapa's review on PR eXist-db#6337 (3 of the 4 Codacy findings; the remaining FunDeepEqual.deepCompare NPath 8.8M issue was already decomposed in commit 53db259 on this same PR): - walk() decomposed into per-event-type helpers (compareStartElements / compareEndElements / walkStep) with a small WalkState record-holder so depth/rootSeen propagate without static fields. Sentinel WalkState.CONTINUE distinguishes "step succeeded, keep walking" from a non-EQUAL return. The CHARACTERS / CDATA / SPACE cases were folded into one yield branch (they were identical). NPath 721 -> well under 200. - nextRelevantEvent: rewrite to flag-controlled loop so neither `continue` nor `return ev` is the final statement of the loop body (was line 233). - FunDeepEqualPerformanceTest: move STORED_EQUAL_TREES, LARGE_EQUAL_TREES, LARGE_TREES_DIFFER_AT_LEAF, LARGE_TREES_DIFFER_AT_ROOT to the field-declaration block at the top of the class. `mvn test -pl exist-core -Dtest='*DeepEqual*'` - 78/78 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
[This response was co-authored with Claude Code. -Joe] Addressed all four Codacy findings on this PR — note that the fourth (the
|
|
@joewiz can you rebase please. There is no conflict, but I want to be safe, having just spent hours on NodeProxy |
Closes eXist-db#4050 PieterLamers' TEST.zip reproducer (Macbeth.xml vs Macbeth2.xml, ~3,550 elements each, byte-identical) was 5,170 ms median on develop tip b917e1a, ~24x slower than xmldiff:compare's 228 ms. With this change the same comparison runs in 5-11 ms median -- within an order of magnitude of the storage-layer floor (a single serialize() walk of both docs takes ~15 ms) and ~20-45x faster than xmldiff:compare. xmldiff:compare is not a correctness- equivalent shortcut (it overlooks attribute changes, per PieterLamers' 2026-05-08 follow-up), so closing the gap had to preserve fn:deep-equal's full attribute / namespace / collation semantics. The diagnosis from the 2026-05-08 paused investigation pointed at "per-node persistent-DOM accessor cost" (getNamespaceURI, getLocalName, getAttributes). The actual hot path is more specific: StoredNode.getNextSibling() (and ElementImpl.getFirstChild()) acquire a fresh broker per call AND open a new XMLStreamReader on the parent that walks until it finds the requested sibling -- an O(siblings) walk per step. compareContents calls these per node, making the recursion effectively quadratic in sibling count for a wide stored DOM. Fix: dispatch to a new FunDeepEqualStreamingComparator when both arguments are persistent-DOM DOCUMENT or ELEMENT NodeProxy instances. The comparator opens two IEmbeddedXMLStreamReader instances via DBBroker.newXMLStreamReader (which iterates the dom.dbx node stream directly, byte-by-byte) and walks them in lockstep, comparing element names, attribute sets, character data, and skipping comments / processing instructions per the fn:deep-equal spec. Attributes are gathered, xmlns:* declarations filtered, and the remainder sorted by (namespace URI, local name) before positional value comparison -- order-insensitive, matching FunDeepEqual.compareAttributes. The new path is additive: legacy compareElements / compareContents remain in place and handle memtree, atomic, attribute-as-top-level, text, map, array, and any persistent-DOM case where the streaming reader fails (caught XMLStreamException / IOException / RuntimeException falls through to legacy with a debug log). Out of scope: - Schema-aware typed-value comparison (untyped only). Documented; the reporter's reproducer is untyped. - In-memory tree caching of stored documents (broader optimisation affecting many functions, larger architectural piece). - Documents with leading comments / PIs before the root element (the streaming path's documentRoot helper requires the first stored child to be an element; non-element first child triggers legacy fallback). Test plan: - 78 existing *DeepEqual* JUnit tests pass (DeepEqualTest 63, FunDeepEqualPerformanceTest 14, FunDeepEqual4050ReproTest 1). - 1,230-test sweep across XPathQueryTest, XQuery3Tests, FunSortTest, GroupByTest, *MapType*, *Serialization* all green. - Full mvn test -pl exist-core: 6,607 tests, 0 failures, 0 errors, 106 skipped (pre-existing). Reproducer measurements (Macbeth.xml, JDK Zulu 21.38.21, develop tip): | Variant | Median | Notes | |-------------------------------|-----------:|--------------------------------| | current develop (no fix) | 5,170 ms | reporter's TEST.zip | | xmldiff:compare (per AR 2022) | 228 ms | not correctness-equivalent | | this fix | 6 ms | within ~2-3x of serialize() floor | Synthetic 10k-element stored corpus (FunDeepEqualPerformanceTest): | Variant | Median | Notes | |-------------------------------|-----------:|--------------------------------| | current develop | ~2,521 ms | from 2026-05-08 measurement | | this fix | 124 ms | ~20x speedup | Memtree case unchanged (~46 ms on the same corpus); the streaming path does not apply to in-memory nodes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… per-type helpers
Decompose deepCompare(Item, Item, Collator, DBBroker) from NPath 8,847,362
into focused per-type helpers, each well under the 200 NPath threshold:
- compareArrayItems — array-vs-array, with size short-circuits
- compareMapItems — map-vs-map, with size and key short-circuits
- compareAtomicItems — atomic-vs-atomic, NaN/numeric handling
- compareNodeItems — node-vs-node dispatcher (Java 21 switch
expression over Type.DOCUMENT / ELEMENT /
ATTRIBUTE / PI|NAMESPACE / TEXT|COMMENT)
- compareDocumentItems — document-node compare with streaming fast-path
- compareElementItems — element-node compare with streaming fast-path
- compareAttributeItems — attribute name + value compare
- comparePiOrNamespaceItems — PI/namespace name + string value compare
- tryStreamingCompare — extracts the eXist-dbGH-4050 streaming fast-path
guard + fallback that was duplicated across
the DOCUMENT and ELEMENT switch branches
Pure refactor; no behaviour change. The streaming fast-path's Macbeth.xml
result (6-9ms median, 800x speedup) is preserved. Resolves reinhapa's
NPath complexity review comment on PR eXist-db#6337.
Verification:
- FunDeepEqual JUnit set: 78/78 pass
- XQuery3Tests XQSuite gate: 986/986 pass (1 pre-existing skip)
- Codacy PMD: NPath violation on deepCompare cleared; no new findings
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per reinhapa's review on PR eXist-db#6337 (3 of the 4 Codacy findings; the remaining FunDeepEqual.deepCompare NPath 8.8M issue was already decomposed in commit 53db259 on this same PR): - walk() decomposed into per-event-type helpers (compareStartElements / compareEndElements / walkStep) with a small WalkState record-holder so depth/rootSeen propagate without static fields. Sentinel WalkState.CONTINUE distinguishes "step succeeded, keep walking" from a non-EQUAL return. The CHARACTERS / CDATA / SPACE cases were folded into one yield branch (they were identical). NPath 721 -> well under 200. - nextRelevantEvent: rewrite to flag-controlled loop so neither `continue` nor `return ev` is the final statement of the loop body (was line 233). - FunDeepEqualPerformanceTest: move STORED_EQUAL_TREES, LARGE_EQUAL_TREES, LARGE_TREES_DIFFER_AT_LEAF, LARGE_TREES_DIFFER_AT_ROOT to the field-declaration block at the top of the class. `mvn test -pl exist-core -Dtest='*DeepEqual*'` - 78/78 green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7b82bb4 to
c9f12fb
Compare
|
[This response was co-authored with Claude Code. -Joe] Rebased onto current develop tip — clean, no conflicts across 309 commits. New tip: Local smoke check post-rebase: FunDeepEqual JUnit + streaming comparator + performance test all green (14/14), GH-4050 fast-path numbers preserved (96ms / 55ms / 154ms on the equal/mismatched 10k-element trees). Force-pushed; CI should run on the new tip. |
|
|
||
| private static int compareNullable(@Nullable final String a, @Nullable final String b) { | ||
| // NOTE: intentional reference equality short-circuit (mirrors safeCompare). | ||
| if (a == b) { |
There was a problem hiding this comment.
I believe using caps would prevent the codacy warning A == B and keep the intention intact
There was a problem hiding this comment.
Capitalizing only helps if codacy's check is dumb enough to take those for constants
There was a problem hiding this comment.
But there should be a way to signal the intent to the linter in the comment
There was a problem hiding this comment.
There is a null capable Comparator.nullsFirst() / Comparator.nullsLast() JDK alternative to handle null cases "correctly" also should identity comparisons be avoided going forward as this will be hidden traps for future value types.
…Nullable returns Addresses duncdrum's review on PR eXist-db#6337. Three return values in compareNullable now use Constants.EQUAL / INFERIOR / SUPERIOR, matching the sibling safeCompare method exactly. The intentional reference-equality short-circuit (a == b) is preserved; the constants make the comparison's intent explicit and align with the project-wide compare-method idiom. Full-module gate: Tests run: 6747, Failures: 0, Errors: 0, Skipped: 97, BUILD SUCCESS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
[This response was co-authored with Claude Code. -Joe] Done in `badc60ba91`. Switched `compareNullable`'s three return values to `Constants.EQUAL` / `INFERIOR` / `SUPERIOR` so it matches the sibling `safeCompare` method byte-for-byte on the compare-result idiom. The intentional reference-equality short-circuit (`a == b`) is preserved, with the existing comment explaining it. Full-module gate green: `Tests run: 6747, Failures: 0, Errors: 0, Skipped: 97`. |
|
|
||
| private static int compareNullable(@Nullable final String a, @Nullable final String b) { | ||
| // NOTE: intentional reference equality short-circuit (mirrors safeCompare). | ||
| if (a == b) { |
There was a problem hiding this comment.
There is a null capable Comparator.nullsFirst() / Comparator.nullsLast() JDK alternative to handle null cases "correctly" also should identity comparisons be avoided going forward as this will be hidden traps for future value types.
Summary
fn:deep-equalon persistent-DOM trees was ~24× slower thanxmldiff:compareon PieterLamers' TEST.zip reproducer (Macbeth.xml vs Macbeth2.xml, ~3,550 elements each, byte-identical) — 5,170 ms vs 228 ms on develop tipb917e1ab1d. After this change the same comparison runs in 5–11 ms median while preserving fullfn:deep-equalsemantics.xmldiff:compareis not a correctness-equivalent shortcut (it overlooks attribute changes, per PieterLamers' 2026-05-08 follow-up), so closing the gap had to keep attribute / namespace / collation comparison rules intact.Closes #4050
Diagnosis
The 2026-05-08 paused investigation pointed at "per-node persistent-DOM accessor cost" (
getNamespaceURI,getLocalName,getAttributes). The hot path is more specific than that.StoredNode.getNextSibling()(andElementImpl.getFirstChild()) acquire a fresh broker per call and open a newXMLStreamReaderon the parent that walks until it finds the requested sibling — an O(siblings) scan per step.FunDeepEqual.compareContentscalls these per node, so the recursion is effectively quadratic in sibling count for a wide stored DOM.Diagnostic XQuery on the same Macbeth corpus, on develop tip:
count(//*) + count(//*)(both docs)count(distinct-values(//*/local-name()))string-length(serialize(doc))(forces full walk)fn:deep-equal(doc, doc)(legacy)The storage-layer floor for walking both documents is ≤ 24 ms. The legacy
fn:deep-equalpath is paying ~5 seconds ingetFirstChild/getNextSiblingoverhead, not in the comparison work.What changed
A new
FunDeepEqualStreamingComparatorwalks both subtrees viaIEmbeddedXMLStreamReader(the persistent-DOM BTree stream reader) and compares events in lockstep:xmlns:*declarations filtered, the remainder sorted by (namespace URI, local name) and compared positionally — matchesFunDeepEqual.compareAttributes's order-insensitive semantics. Attribute values use the supplied collator.CHARACTERS/CDATA/SPACEevents are compared in document order with the supplied collator.COMMENTandPROCESSING_INSTRUCTIONevents are skipped per the W3C XPath F&O 3.1 §15.3.1 fn:deep-equal spec.Dispatch lives in
FunDeepEqual.deepCompare'sDOCUMENTandELEMENTswitch branches and only fires when both arguments are persistentNodeProxyinstances withgetImplementationType() == PERSISTENT_NODE. Memtree, atomic, attribute-as-top-level, text, map, and array shapes fall through to the legacy recursive path. AnyXMLStreamException/IOException/RuntimeExceptionfrom the streaming path is caught and logged at DEBUG; the legacy path runs, so correctness is preserved.The
FunDeepEqualstatic API gains broker-aware overloads (deepCompareSeq(..., DBBroker),deepCompare(..., DBBroker),deepEqualsSeq(..., DBBroker)); the existing 3-arg signatures delegate withbroker = null, leaving callers likeSwitchExpression,GroupByClause,AbstractMapType, andFunSortuntouched.Reproducer
PieterLamers' TEST.zip (Macbeth.xml + Macbeth2.xml, ~3,550 elements each), JDK Zulu 21.38.21, eXist 7.0.0-SNAPSHOT, 5-trial median:
xmldiff:compare(per @adamretter, 2022)Synthetic 10k-element stored corpus (
FunDeepEqualPerformanceTest.deepEqualOnStoredEqualDocsIsFast):Memtree case (
deepEqualOnLargeEqualTreesIsFast) unchanged at ~46 ms; the streaming path does not apply to in-memory nodes.Correctness
*DeepEqual*JUnit tests pass:DeepEqualTest(63),FunDeepEqualPerformanceTest(14),FunDeepEqual4050ReproTest(1).XPathQueryTest,XQuery3Tests,FunSortTest,GroupByTest,*MapType*,*Serialization*— all green.mvn test -pl exist-core: 6,607 tests, 0 failures, 0 errors, 106 skipped (pre-existing).XQTS conformance has not been re-run for this branch —
fn:deep-equalis exercised pervasively in QT4 / XQ 3.1, and CI's runner job will report any conformance delta. I'll re-run locally before merge if a reviewer wants confirmation in advance.Out of scope
xs:untypedAtomic(string equality). The reporter's reproducer is untyped; if a typed-value edge case surfaces in the test gate it'll fall back to legacy. (None has so far across 6,607 tests.)fn:deep-equal. Worth a separate design pass.documentRoothelper requires the document's first stored child to be an element; non-element first child triggers legacy fallback. (Macbeth.xml and the synthetic corpus both have a single root element.)Test plan
mvn test -pl exist-core -Dtest='*DeepEqual*'— 78 tests pass.mvn test -pl exist-core(full module via shared lock script) — 6,607 tests, 0 failures.deepComparemethod's structure; not introduced here).Spec / context references
fn:deep-equal: https://www.w3.org/TR/xpath-functions-31/#func-deep-equalIEmbeddedXMLStreamReader(the BTree stream reader this fix builds on):exist-core/src/main/java/org/exist/stax/IEmbeddedXMLStreamReader.java