Skip to content

Stress-test DocxDiff on a heavyweight legal document, and stop it reading each file four times - #616

Merged
JSv4 merged 9 commits into
mainfrom
claude/docxdiff-perf-stress-test-kq2jev
Aug 30, 2026
Merged

Stress-test DocxDiff on a heavyweight legal document, and stop it reading each file four times#616
JSv4 merged 9 commits into
mainfrom
claude/docxdiff-perf-stress-test-kq2jev

Conversation

@JSv4

@JSv4 JSv4 commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Why

We had no way to answer "how fast is DocxDiff on a real legal document, and where does the time go?" — and no way to prove a performance change left the diff itself untouched. This adds both, then acts on what they showed.

The reference document is the NVCA Model Certificate of Incorporation (October 2025): 574 KB of word/document.xml holding 15,360 elements across 234 body paragraphs, plus 97 footnotes in a 227 KB part, 16 abstract numbering definitions, 4 sections, 8 headers and 10 footers. A DocxDiff.Compare of it against an edited copy took about 820 ms.

What the measurement showed

About 72% of a comparison was spent inside IrReader, reading the same two documents four times.

  1. DocxDiffComparison read both sides with RetainSources off to build the edit script.
  2. IrMarkupRenderer then re-read the same two documents with RetainSources on, to get the source w:p/w:tbl elements it clones from.
  3. And inside every one of those reads, settling the accepted-revision view opened a whole second package and parsed every story to scan it for revision markup — then threw that parse away and reopened the document for the walk.

None of it was necessary. RetainSources decides only whether IrProvenance pins the source XElement, and IrProvenance is equality-neutral by construction (it equals any other instance and hashes to zero, precisely so provenance never leaks into IR value equality). The two snapshots are therefore node-for-node value-equal, and the renderer can simply be handed the one the script was built over. And because GetXDocument caches per part, running the revision scan against the package the walk is about to use costs nothing.

The N-way path carried the same duplication multiplied by reviewer count: an N-reviewer Consolidate read 2*(N+1) packages to compare N+1 documents.

How it works now

  • One read per document per comparison. IrMarkupRenderer.Render and IrCompositeMarkupRenderer.Render take an optional pre-read snapshot; DocxDiffComparison and Consolidate read once with provenance on and hand it over.
  • One package open per read. IrReader.Read opens the package, decides the revision view against it, and walks it. Only a document that genuinely needs a RevisionProcessor round-trip reopens.
  • Both sides concurrently, where there are threads to do it on — see the guard section below.
  • GetRevisions on byte-identical packages returns empty immediately, the same shortcut ToRedline already had. The edit script keeps no such shortcut: its all-Equal operations are the answer the caller asked for.
  • Cheaper hashing walks. UnidHelper.ContentSignature walked every element's subtree three times building a string per walk; one fused walk now appends the same characters in the same order, and a per-call cache serves its repeated hash inputs (about seven in eight are duplicates). Canonical-XML hashing no longer materializes a byte array per call, and attribute canonicalization skips the sort for elements carrying none or one.

Results

Medians of nine timed iterations after four warm-ups, before and after in one run:

Case Before After
8 scattered word edits 821 ms 351 ms 2.3×
an edit in every fifth paragraph 868 ms 395 ms 2.2×
24 blocks relocated 792 ms 338 ms 2.3×
20 paragraphs deleted, 20 inserted 803 ms 334 ms 2.4×
half the footnote paragraphs edited 959 ms 510 ms 1.9×
every second text node edited 1062 ms 611 ms 1.7×
every paragraph rewritten 1279 ms 868 ms 1.5×
GetRevisions 395 ms 216 ms 1.8×
GetEditScriptJson 389 ms 216 ms 1.8×
all three products fused 852 ms 387 ms 2.2×
GetRevisions, identical packages 371 ms 0 ms
Consolidate, 4 reviewers 1870 ms 675 ms 2.8×

Allocation per comparison drops 528 → 276 MB; per four-reviewer consolidate 1337 → 710 MB.

How it was validated

4,161 of 4,161 .NET tests pass, warning count byte-for-byte identical to main (262, same distribution), WASM builds at 3689 KB wire size (budget 4096), browser diff specs green.

The evidence that matters for a change like this is that the diff itself did not move, and it is checkable:

dotnet run -c Release --project benchmarks/docxdiff-stress -- --corpus TestFiles --baseline main.json   # on main
dotnet run -c Release --project benchmarks/docxdiff-stress -- --corpus TestFiles --check main.json      # on this branch

678 documents → 8,136 digests → all identical. Each document contributes an edited variant under default settings, the document against itself (the byte-identical shortcuts), and the edited variant again under PreAcceptInputRevisions and PreserveInputRevisions — the only settings that reach the revision-transform path — times three products.

That corpus matters because the reference document has no tracked revisions, no tables and no drawings, which is to say it never exercised the path IrReader.Read was restructured around. TestFiles has 89 revision-bearing, 218 table-bearing, 95 drawing-bearing and 124 content-control-bearing documents.

Validated in both directions, because a check that cannot fail proves nothing:

main vs this branch all 8,136 identical
main vs itself all 8,136 identical
this branch vs itself all 8,136 identical
drop the w:t skip in UnidHelper.ContentSignature 3,905 mismatches

And its limit, stated rather than glossed: reintroducing the wp:docPr/@id stripping-order bug that IrHasherTests.Canonicalize_LoneDocPrId_StillStripped guards produces zero corpus mismatches — real Word documents emit docPr with both id and name, so the lone-attribute shape never occurs in these 678 files. Corpus parity and unit tests cover different things.

Found on the way: Compare is not byte-deterministic with media (#621)

The first corpus run reported 161 mismatches. They were not this branch's: main disagrees with itself on exactly the same 161. Media and diagram parts imported into a redline are named P + a fresh GUID, with relationship ids of R + a fresh GUID, so any document whose redline imports media produces different bytes every run — contradicting DocxDiffSettings.Deterministic. Filed as #621; the harness folds those names before digesting so real changes are not buried under naming churn.

One bug this PR's own review caught

Skipping the attribute sort for single-attribute elements was briefly wrong: canonicalization strips wp:docPr/@id by consulting attribute.Parent, so deciding an attribute's fate after detaching it changes the verdict. The LINQ chain it replaced got this right by accident (ToList() forces evaluation before RemoveAttributes()). Three tests now pin that path; the first fails against the wrong ordering.

The concurrency needed a guard, and it is not an optimization

wasm/DocxodusWasm/DocxodusWasm.csproj does not set WasmEnableThreads, so the browser runtime is single-threaded: Task.Run queues the delegate for the one thread that would then block on the result, and the runtime refuses rather than deadlocking —

PlatformNotSupportedException: Cannot wait on monitors on this runtime

Measured, not inferred: with the guard forced open, all ten npm/tests/docx-diff.spec.ts cases fail with exactly that exception; with it in place they pass (10 passed, 14.0s). Both call sites go through Docxodus.Internal.ParallelWork, compiled out under WASM_BUILD and additionally requiring Environment.ProcessorCount > 1. It preserves the sequential failure ordering, and a concurrent failure is always observed rather than left as an unobserved task exception.

The browser keeps most of the win regardless, because the larger half does not depend on threads: roughly 1.4–1.6× sequential against ~2.3× concurrent.

On the 10 comparisons/second question that prompted this

Not reached for a cold pairwise redline of a document this size. At ~350 ms this is just under 3/s; 10/s means 100 ms, and ~45 ms of that is package I/O no design avoids (13 ms to parse every story, 17 ms to re-serialize a package unchanged). The data products are much closer: with both documents already read, the diff itself is about 60 ms.

Three levers remain, filed rather than buried: #617 (reusable snapshot so bulk pipelines read a document once — the one that decides 10/s), #618 (UnidHelper hashes twice per element for elements the IR never reads back), #619 (MarkupCompatibilityNormalizer full-parses document.xml on every call to find nothing). A fourth — spend the remaining two cores — is deliberately not filed; FINDINGS.md says why.

Files

benchmarks/docxdiff-stress/ is a new standalone harness, outside Docxodus.sln so it never touches CI, packaging or the warning baselines. Its README covers the variants, the corpus differential, the rename-invariant digest and the measurement traps (force a collection between cases; warm past tiered JIT before timing — an early draft reported UnidHelper at 108 ms where the steady state is 51 ms). FINDINGS.md carries the full stage attribution.

docs/architecture/ir_diff_engine.md previously described the renderer's re-read as unconditional; it now documents the single-read design and why the fan-out is gated.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YQ7aHQV9e11SSt4idtyWaK

claude added 4 commits August 29, 2026 01:49
A DocxDiff.Compare on a heavyweight legal document (the NVCA model
certificate of incorporation: 574 KB of document.xml, 15,360 elements,
97 footnotes) spent about 72% of its wall clock inside IrReader, because
the same two documents were read four times over.

Two of those reads were pure duplication. DocxDiffComparison read both
sides with RetainSources off to build the edit script, and then
IrMarkupRenderer re-read the very same documents with RetainSources on to
get the source w:p/w:tbl elements it clones from. RetainSources only
controls whether IrProvenance pins the source XElement, and provenance is
equality-neutral by construction, so the two snapshots are node-for-node
value-equal. Render now takes an optional pre-read pair, and the
comparison reads once per side with provenance on and hands that snapshot
to the renderer.

The other two were inside IrReader itself: deciding the revision view
opened and parsed a whole second package, then threw the parse away and
opened the document again for the walk. The scan now runs against the
package the walk is about to use, and since GetXDocument caches per part,
the walk reuses the trees the scan already parsed. Only a document that
genuinely needs a RevisionProcessor round-trip reopens.

On top of that:

  * The left and right sides are independent pure reads, so pre-accept
    and the IR reads each run concurrently.
  * UnidHelper.ContentSignature walked every element's subtree three
    times (block-descendant test, w:t text, descendant names) and
    materialized a string per walk; one fused walk now feeds all three
    and appends the same characters in the same order.
  * SHA-256 dominated the Unid assignment, and roughly seven in eight of
    the strings it hashes are duplicates of one already seen, so a
    per-call cache keyed on the hashed string serves the repeats.
  * GetRevisions on byte-identical packages ran the whole pipeline to
    prove there was nothing to report; it now takes the same
    identical-bytes shortcut ToRedline already had. The edit script
    deliberately keeps no such shortcut - its all-Equal operations are
    the answer the caller asked for.

Measured on the reference document, median of seven timed iterations
after three warm-ups, comparing the baseline against eight generated
variants (scattered edits through whole-document rewrite):

  case         before    after
  light         807 ms   360 ms
  heavy         828 ms   384 ms
  reorder       801 ms   334 ms
  structural    787 ms   334 ms
  footnotes     934 ms   485 ms
  churn        1072 ms   583 ms
  rewrite      1257 ms   788 ms
  GetRevisions
   on identical 479 ms     0 ms

Allocation per comparison drops from 528 MB to 281 MB on the light case.

Output is unchanged: the new benchmarks/docxdiff-stress harness records
SHA-256 digests of the redline package, the rendered revision list and
the edit-script JSON for all eight variants, and all 32 digests are
identical to the pre-change baseline.
…the hashing walks

Follow-on to the two-way read sharing. Three changes, all output-preserving.

Consolidate had the same duplication the two-way path just lost, multiplied
by reviewer count: DocxDiff.Consolidate read the base and every reviewer to
build the merge script, and IrCompositeMarkupRenderer then re-read all of
them with provenance on for the elements it clones from — 2*(N+1) package
reads to compare N+1 documents. The renderer now takes an optional pre-read
set, Consolidate reads once with provenance and hands it over, and all four
consolidate entry points read the base and reviewers concurrently rather
than one after another.

Canonical-XML hashing runs once per paragraph, run and section of every
read, and it was allocating more than it hashed. CanonicalHash encoded its
canonical XML into a byte array purely to hand it to SHA-256; it now
encodes straight into a stack or pooled buffer. Clean allocated a removal
list for proofErr/noProof even on the overwhelming majority of subtrees
that contain neither. CleanAttributes ran a Where/OrderBy/ThenBy/Select
chain plus a ToList on every element, when in WordprocessingML almost every
element carries no attributes or exactly one — neither of which can be
reordered.

That last one has a trap worth naming: canonicalization strips
wp:docPr/@id by consulting attribute.Parent, so an attribute's fate has to
be decided while it is still attached. The LINQ chain got this right by
accident (ToList forces evaluation before RemoveAttributes); a hand-rolled
fast path has to do it on purpose. Three unit tests now pin the
single-attribute path — a lone docPr id is stripped, a lone kept attribute
survives with its value, a lone rsid/pt14 attribute is removed. The first
of them fails against the wrong ordering.

Measured on the NVCA model certificate of incorporation, medians of nine
timed iterations after four warm-ups, before and after in one run:

  four-reviewer Consolidate   1870 ms -> 675 ms   (alloc 1337 -> 710 MB)

The pairwise cases are dominated by the read sharing that landed
previously; the hashing work is worth a few percent on top of it.

Output is unchanged. benchmarks/docxdiff-stress now also digests the
consolidated document, its conflicts, its attributed revision list and its
script JSON, and all 36 digests across the eight edit shapes plus the
four-way consolidate are byte-identical to origin/main.

Also documents the single-read design in docs/architecture/ir_diff_engine.md
(it previously described the renderer's re-read as unconditional), and adds
the harness's README and FINDINGS — the latter carrying the full stage
attribution and an honest account of what still stands between the engine
and ten comparisons per second.
… have

The concurrent reads added with the read-sharing work used Task.Run plus a
blocking join. On a server that is a straightforward win. In the browser it
is a hang.

wasm/DocxodusWasm/DocxodusWasm.csproj does not set WasmEnableThreads, so the
WASM runtime is single-threaded. There Task.Run does not start a second
thread: it queues the delegate for the ONE thread, which is the very thread
about to block on the result. The queued read can never start, and the join
waits for it forever - the page stops rather than merely failing to be
faster. Every browser comparison goes through DocxDiffBridge.Compare into
DocxDiffOps and DocxDiffComparison, so this reached the whole npm surface.

Both call sites now go through Docxodus.Internal.ParallelWork, which is
compiled out under WASM_BUILD and additionally requires
Environment.ProcessorCount > 1 - a single-core host gains nothing from the
fan-out either. The sequential and concurrent paths compute the same values
in the same order, and the helper preserves the sequential failure ordering:
if the first unit of work throws, that is the exception a caller sees on
either path, and a concurrent failure is always observed rather than left as
an unobserved task exception.

Verified: the WASM target builds (wire size 3690 KB, unchanged, budget
4096 KB) and the browser diff specs pass - docx-diff, redline-reversibility
and edit-summary-and-diff, 21 tests, 28.5s. The .NET suite is 4,161 passing.
With the fan-out forced off, all 36 harness output digests still match
origin/main, so the schedule is genuinely not a semantic choice.

The browser keeps most of the improvement regardless, because the larger
half of it - reading each document once instead of twice - does not depend
on threads. Medians on the reference document: roughly 1.4-1.6x sequential
against ~2.3x concurrent.
The previous commit described the unguarded fan-out as hanging the browser.
It does not. Measured by building the WASM bundle with the guard forced open
and running the browser diff spec: all ten npm/tests/docx-diff.spec.ts cases
fail immediately with

  PlatformNotSupportedException: Cannot wait on monitors on this runtime

The single-threaded runtime refuses the blocking join rather than deadlocking
on it. The consequence is the same severity - every browser Compare,
GetRevisions, GetEditScriptJson and Consolidate call fails - but the symptom
a maintainer would see is an exception, not a stall, and the comment should
say so. Those ten specs are the guard's regression test; they pass with it in
place (10 passed, 14.0s).

The original suspicion came from a CI Playwright job that had run 51 minutes
without finishing, which was bad evidence: the job was cancelled by the next
push rather than timing out, and a defect that throws immediately would never
have produced a long run. FINDINGS.md records that as its own note - verify a
suspected failure mode, do not infer it from timing.

No behavior change; comments, CHANGELOG and design doc only.
…nk it

FINDINGS.md listed three levers left on the table with the reasoning for
declining each, which is the right analysis in the wrong place: a benchmark's
findings file is where follow-up work goes to be forgotten. The three that
are actual units of work are now issues, linked from the file:

  #617  reusable snapshot so bulk pipelines read a document once - the lever
        that decides whether the engine clears ten comparisons per second
  #618  UnidHelper hashes twice per element for all 15k elements of a read,
        and the IR reads a Unid back on almost none of them
  #619  MarkupCompatibilityNormalizer full-parses document.xml on every call
        because the part contains the literal "pPr", ~18ms per side to find
        nothing

#619 is new here rather than carried over: the gate never closes on a real
document, and the duplicate-w:pPr shape its repair targets does not occur in
the reference document at all (0 paragraphs), so the parse is pure overhead.

The fourth item - spend the remaining two cores - is deliberately NOT filed
and now says so. It is worth perhaps 15-20% against a real determinism risk,
it is unavailable in the browser at all, and "consider more threads" is not
something anyone can pick up. It stays a note.

Docs only.

JSv4 commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

CI note — the one red check on 2c9a0d5 is not this PR's.

Failing: test (Playwright) → tests/demo-arcade-freedoom.spec.ts:217autopilot navigates the real geometry and collects a Freedoom pickup spot. Everything else passed: 659 passed, 1 failed, 10 skipped.

Why it isn't this PR's:

What the failure looks like: the flight recorder dumped frames: 1401 against the test's 240 s budget — under 6 fps — with stuck: 15. That reads as a starved runner, not a navigation change. This is a recurrence of #492 (Flaky: Freedoom autopilot test times out waiting for the first sigil pickup), which is closed; the diagnostic dump quoted above was added by that issue's fix. I've reopened it with this occurrence.

No fix to port — nothing open addresses it, and making the autopilot's time budget robust is an arcade-demo change with no relationship to this diff, so widening this PR to carry it would be the wrong call.

Re-running the failed job once. If it goes green that confirms the diagnosis; if it fails again I'll come back to it rather than leave it.


Generated by Claude Code

JSv4 commented Aug 29, 2026

Copy link
Copy Markdown
Owner Author

Correction to the previous comment: I cannot re-run the job — rerun-failed-jobs returns 403 Resource not accessible by integration. The diagnosis above is unchanged, but the confirming re-run needs someone with write access, or any later push to this branch.

Everything else on 2c9a0d5 is green (659 passed). I'm keeping the PR watched and will report if the state changes.


Generated by Claude Code

…ne document

The parity evidence for the read-sharing work was eight generated variants of
a single reference document. That document carries no tracked revisions, no
tables and no drawings - which between them gate the revision-transform path
in IrReader.Read (the path this branch restructured most invasively), the
table differ, and every media import. So the strongest claim it supported was
"no regression on documents shaped like this one".

--corpus runs every .docx under a directory through the products and digests
the results. Against TestFiles that is 678 documents and 8,136 digests in
about four minutes: each document contributes an edited variant under default
settings, the document against itself for the byte-identical shortcuts, and
the edited variant again under PreAcceptInputRevisions and
PreserveInputRevisions - the only settings that reach the revision transform -
times redline, revision list and edit script. Exceptions are digested as
results, since a change in which exception a malformed fixture throws is also
a regression.

Running it turned up something the reference document had hidden: media and
diagram parts imported into a redline are named "P" + a fresh GUID, and their
relationships get ids of "R" + a fresh GUID, so DocxDiff.Compare is not
byte-deterministic on any document whose redline imports media - contradicting
DocxDiffSettings.Deterministic. That is pre-existing (origin/main disagrees
with itself on exactly the same 161 digests across 54 fixtures) and is filed
separately; here it only means the redline digest has to fold those generated
names to a placeholder, in entry names and in XML content, so real changes are
not buried under naming churn. Content still has to match exactly.

Validated in both directions rather than assumed:

  * origin/main vs this branch  ->  all 8,136 identical
  * origin/main vs itself       ->  all 8,136 identical (after the fold)
  * this branch vs itself       ->  all 8,136 identical
  * dropping the w:t skip in UnidHelper.ContentSignature -> 3,905 mismatches

The last one matters: a check that cannot fail proves nothing. So does its
counterexample - reintroducing the wp:docPr/@id stripping-order bug that
IrHasherTests.Canonicalize_LoneDocPrId_StillStripped guards produces ZERO
corpus mismatches, because real Word documents emit docPr with both id and
name and the lone-attribute shape never occurs in these 678 files. Corpus
parity and unit tests cover different things; the README says so.
claude added 2 commits August 29, 2026 14:32
…trol had left untouched

The corpus differential was demonstrated to be capable of failing by
reintroducing a real defect - dropping the w:t skip in
UnidHelper.ContentSignature's descendant-name walk - which moved 3,905 of
8,136 digests. Broken down by product, that control moved 82% of the revision
digests and 84% of the edit-script digests and exactly 0% of the redline
digests. So the redline column, which is the one an integrator actually ships,
had no demonstrated sensitivity at all.

The zero is correct rather than a bug: Unids feed block anchors, so corrupting
a content signature is visible throughout the script and the revision list,
but IrMarkupRenderer strips PtOpenXml.Unid on the way out and the rendered
package comes back byte-identical.

A second control closes it. Swapping the snapshots handed to
IrMarkupRenderer.Render - a plausible slip in the hand-off this branch added -
moves 1,725 redline digests (64%) and zero script or revision digests, since
those were computed before the render. The two controls partition cleanly
along the pipeline, which is itself evidence the digests measure what they
claim to.

README and FINDINGS now carry both, with the warning that one perturbation
validates one half of a multi-product check.

No library change; the control was reverted after measuring.

JSv4 commented Aug 30, 2026

Copy link
Copy Markdown
Owner Author

Adversarial review

I treated every claim here as unproven and re-derived it. Most of them hold. The fixes for the ones that do not are in #622, which targets this branch so this PR can land complete.

What I independently reproduced

  • The corpus evidence. Built the harness against main and against this branch, ran --corpus TestFiles on both: 678 documents → 8,136 digests, all identical. The number and the result are exactly as stated.
  • The single-read reasoning. IrProvenance.Equals returns true for any other instance and GetHashCode returns 0; RetainSources feeds nothing but ReadContext.Provenance() and IrDocument.Sources; nothing in IrEditScriptJson serialises provenance. Handing the renderer the script-side snapshot is sound.
  • The concurrency purity claim. Every static reachable from IrReader.Read is an initialize-once read-only collection; the only mutable statics in the library sit in WmlComparer (not on this path) and FontFamilyHelper (a ConcurrentDictionary and a Lazy). The reads really are independent.
  • The WASM guard's premise. DocxodusWasm.csproj references the library with AdditionalProperties="WASM_BUILD=true", so ParallelWork.CanFanOut compiles to a constant false; WasmEnableThreads appears nowhere in the repository. The library builds clean under -p:WASM_BUILD=true.

The one behaviour regression

GetRevisions' identical-bytes shortcut is not "the same guard ToRedline uses". ToRedline's identical-bytes path deliberately still runs the compatibility pre-flight. The new one does not.

// passes on main, fails on this branch
var doc = /* a document containing an oMath run */;
DocxDiff.GetRevisions(doc, new WmlDocument(doc),
    new DocxDiffSettings { ThrowOnCompatibilityWarning = true });   // throws on main; returns [] here

Same for OnCompatibilityWarning, which stops firing. DocxDiffComparison's own remarks promise the opposite: "Compatibility preflight … therefore fires on the first product call, exactly as it fires inside each stateless static." The pre-flight is a property of the inputs, not of whether they differ.

Why the validation could not have caught it

This is the part worth taking from the review. The defect has no output-digest signature — a product that stops warning still returns the right answer — so 4,161 tests and 8,136 output digests were both structurally incapable of seeing it. Two coverage gaps follow:

  1. Corpus mode never engages the pre-flight. OnCompatibilityWarning/ThrowOnCompatibilityWarning are never set in Corpus.Record, so a second observable output of every product goes undigested.
  2. Corpus mode never calls Consolidate. This PR restructured the N-way path — ReadReviewerSet, the IrCompositeMarkupRenderer hand-off, ParallelWork.Fan — and none of the three pairwise products touches any of it. The 678 documents → 8,136 digests headline covers zero consolidate calls; N-way parity rests entirely on the uncommitted reference document.

#622 closes both. The differential becomes 678 documents → 14,916 digests, still green.

Smaller findings

  • IrReader.Read leaks an open package on the error path. The OpenXmlMemoryStreamDocument is constructed and GetWordprocessingDocument() called before the try, so an .xlsx handed to the reader (GetDocumentType detects exactly that and throws) leaks what the previous using disposed.
  • ParallelWork.Fan's doc comment overstates the success path. The collect loop rethrows at the first faulted task, leaving later faulted tasks unobserved — the very thing the head-failure path takes care to avoid.
  • IrHash.ComputeUtf8 allocates 3× on large subtrees. GetMaxByteCount is 3n+3 and ArrayPool<byte>.Shared stops pooling above 1 MB, so for a large canonical subtree it allocates three times the string's UTF-8 length and drops it — worse than the exact-sized array it replaced.
  • Two stale documents. DocxDiffComparison's remarks still say the diff read does not retain sources (it does now, and the snapshots pin the parsed XML of every story for the instance's lifetime — a real retention increase worth stating). docs/architecture/ir_diff_engine.md is updated by this PR but the class's own XML docs were not.
  • The documented reproduction does not build. dotnet run … --corpus TestFiles --baseline main.json # on main fails: main has no InternalsVisibleTo Include="DocxDiffStress", so the harness dies with a wall of CS0122 that reads like a harness bug. I hit this producing the baseline above; adding that one line to the checkout being baselined is the fix, and the README should say so.
  • The warning count did move. Measured with --no-incremental on both branches: library 131 → 132, test project 774 → 775. Both from Docxodus/Internal/ParallelWork.csSA1633 fires once per file because no library file carries a StyleCop header, so any new .cs moves both by one. Not a defect; CLAUDE.md's stated baselines just need to move with it.
  • CHANGELOG says "all 36 digests", which was true of the eight-variant run but not of the corpus evidence this PR ended up resting on.

Two observations, not defects

  • The renderers' re-read fallback is exercised only by tests. Both Render overloads have one production call site each and both supply the snapshots, so the optional parameters are never null in the shipped library while the unit tests call the 4-argument form throughout. Worth knowing before deleting either path.
  • UnidHelper.ContentSignature's new walks recurse over subtree depth where element.Descendants() did not. AssignDescendantsDeterministic already recursed, so this is not a new class of failure — it roughly doubles the frame count at a given depth.

On the ContentSignature and CleanAttributes rewrites

I checked these character by character against the originals, since they are the changes most likely to silently move a hash. Both are faithful: the fused walk appends the same characters in the same order ((string)XElement is Value, Descendants and the new recursion agree on document order, the block-container bail-out reaches the same verdict), and the single-attribute path decides while the attribute is still attached, which is what the ToList() in the LINQ chain was doing by accident. The per-call signature cache is keyed on the exact hashed string, and a container's tag-name key can never collide with a non-container's input because the latter always contains |.


Generated by Claude Code

…ortcut, and cover the paths that hid it (#622)

Adversarial review of the DocxDiff single-read work, with the gaps it turned up resolved.

GetRevisions' new byte-identical shortcut was described as "the same guard ToRedline uses". It
was not: ToRedline's identical-bytes path deliberately still runs the compatibility pre-flight,
and the new one returned an empty list without it, so a caller who set OnCompatibilityWarning or
ThrowOnCompatibilityWarning and compared a document against an identical copy silently stopped
being warned. Both shortcuts now route through one RunGatedPreflight.

That defect has no output-digest signature, so neither the 678-document corpus differential nor
the unit suite could see it. Corpus mode now digests the compatibility report each pairwise
product produces, and runs an N-way consolidate per document with two disagreeing reviewers —
the consolidate path was restructured by this work and no pairwise product touches any of it.
The differential is now 678 documents to 14,916 digests, green.

Also: IrReader.Read leaked an open package when GetWordprocessingDocument threw; ParallelWork.Fan
left later task exceptions unobserved on its success path; IrHash.ComputeUtf8 rented 3x the
needed bytes above the ArrayPool ceiling. Stale docs corrected in DocxDiffComparison's remarks,
the harness README's un-followable reproduction, CLAUDE.md's warning baselines and the CHANGELOG.
@JSv4
JSv4 merged commit af4e63e into main Aug 30, 2026
14 checks passed
JSv4 pushed a commit that referenced this pull request Aug 30, 2026
Merging main brought in the DocxDiff read-amplification fix, which roughly halves
a two-way compare. Every performance figure this demo publishes was measured
against the engine before that, so they were all quietly wrong the moment the
merge landed.

Re-measured in the browser on the demo's own agreement, before -> after:

  docxDiffGetRevisions                159ms -> 136ms
  docxDiffCompare                     278ms -> 153ms
  compareProducts [redline+revisions] 268ms -> 167ms
  separate compare + getRevisions     337ms -> 247ms

and the live stress readouts, which are what the page actually shows:

  revisions    5.4/s p50 184ms  ->  7.0/s p50 144ms
  redline      3.1/s p50 324ms  ->  4.9/s p50 206ms
  full + HTML  1.9/s p50 515ms  ->  2.4/s p50 422ms

So the headline moves from "74x to 283x the cost of recording" to "75x to 196x",
and the loop from 2-6 frames per second to roughly 2-7. The thesis is unchanged
and the numbers are better; what would have been embarrassing is shipping a page
that quotes the old ones next to the commit that improved them.

The panel itself needed no change — it reports what it just measured, so it
picked the improvement up on its own. That is worth saying out loud in the README
because it is the argument for measuring live rather than printing a table, and
this merge is the first evidence of it.

Also cross-references benchmarks/docxdiff-stress/FINDINGS.md, which arrived in the
same merge and asks the same question far more rigorously — 147KB certificate of
incorporation, stage attribution, allocation figures, medians of nine runs. That
harness is the authority on engine performance; this mode is the one you can
watch, on a 3KB document, in a tab. Saying so keeps the demo from implying it is
the measurement of record.

Updated: the diff-stress.js header, the README table and its compareProducts note,
and the CHANGELOG entry. Verified with a rebuilt WASM engine: 22 browser
assertions across demo-redline and demo-golf, plus the full pretest.
JSv4 added a commit that referenced this pull request Aug 31, 2026
After #594 and #616 the redundancy left in the engine is ACROSS comparisons:
nothing let a caller say "I already read this document". One baseline against
many counterparties re-read the baseline once per counterparty; a version chain
A->B->C->D read every interior version twice; GetConflicts followed by
Consolidate read everything twice. The read is the largest stage of a comparison,
so that is the biggest remaining lever.

DocxDiff.CreateSnapshot is that statement, and CreateComparison(snapshot,
snapshot, settings) produces exactly the same products without reading either
side again. Only the input-revision policy reaches a read, so one snapshot serves
comparisons that differ in author, granularity, move detection or format
comparison; a snapshot read under a different policy is REJECTED rather than
silently reused, because serving it would compare a different view of the
document than the caller asked for. Creation is free -- nothing is read until a
comparison needs it -- and the compatibility pre-flight stays a property of the
comparison, so a reused snapshot never makes a subscription go quiet.

DocxDiffConsolidation is the N-way twin: the four consolidate statics delegate to
one memoized pass a caller can also hold directly. That closes a second gap on
the way -- only Consolidate ran the compatibility pre-flight, so a caller asking
for conflicts, consolidated revisions or the consolidated edit script with
ThrowOnCompatibilityWarning set was silently never told. The N-way half of what
#622 fixed on the pairwise side.

On the bridges the snapshot becomes a batch. It holds parsed XML and cannot cross
a process boundary; exposing it as a handle would put a memory-pinning lifetime
on transports with no way to bound it. So the transports get the workload it
exists for: CompareBatchJson (facade + WASM), docxDiffCompareBatch (npm),
docx_diff_compare_batch (python), docxodus_compare mode=fan_out (MCP) -- one
baseline, many candidates, baseline read once, per-candidate products identical
to comparing that pair alone. A candidate that fails carries its error instead of
products rather than failing the batch.

Measured on a heavyweight pair, four comparisons against one baseline: 8219/6526
ms through the statics against 4028/4159 ms sharing the baseline snapshot.

Output parity: [parity] OK, all 14,916 corpus digests identical across 678
documents. Suite 4203 passed; npm docx-diff 11 passed; python 9 passed.

Also moves the #614 CHANGELOG entries out of the 10.0.0 section, where an
insertion at the first "### Fixed" had wrongly put them, into [Unreleased].

Closes #617
JSv4 pushed a commit that referenced this pull request Aug 31, 2026
The theater GIF predated the footnote beat, so it showed Act II inserting
a plain paragraph — a step that no longer exists. The new capture shows
the footnote rendering at the foot of the document and the proof panel
reading REVERSIBLE over it, which is the whole point of putting the beat
back. 31 calls, 26 revisions, 4.9 ms median.

The stress GIF predated #616 and #626 and read slower than the engine now
runs. Recut on the current build.

Its meter still reads higher than the table in the README (236 ms against
a published 181 ms for the same depth), and that gap is real rather than
a mistake in either: the capture runs a 10fps screenshot recorder against
the same CPU, and the clip starts after a full negotiation has already
grown the document. Both are the confound the README already describes,
so it now says so at the GIF rather than leaving a reader to find a
contradiction and trust neither number.
JSv4 added a commit that referenced this pull request Aug 31, 2026
…urn value (#628)

The #616 regression -- a fast path that skipped the compatibility pre-flight --
passed all 8,136 digests, correctly. For two byte-identical documents "no
revisions" is the right answer before and after, so the return value never moved.
The harness was not broken; the defect was on a channel nothing watched.

The recorded unit is now an Observation with one field per channel, so adding a
channel is one field on one type applied to every product and every document at
once rather than a new sink[...] family per mode. A mismatch names the channel
that moved instead of saying "the digest changed".

Two new channels. Input immutability: IrReader.Read documents "the caller's
DocumentByteArray is left byte-for-byte unchanged" and PreAccept promises "the
input is untouched", and nothing verified either while the engine moves toward
sharing one parsed snapshot across stages. Product-order variance: since #616 the
statics delegate to a DocxDiffComparison that memoizes one IR snapshot and shares
it across products, so each observation asks one comparison for all three in
reverse order and requires each to match the static -- which also pins
CreateComparison against Compare, a class corpus mode otherwise never reaches.

The pre-flight report is captured from the call under observation rather than a
second run of every product: cheaper, and stricter. Verified result-neutral --
attaching the callback changes 0 of 760 results.

A fifth comparison per document varies exactly ONE DocxDiffSettings property,
rotated by document index, so all twenty are exercised across the corpus for one
extra comparison rather than a cross-product. Deterministic=false is deliberately
excluded and the omission documented: a setting whose purpose is to defeat
reproducibility cannot be covered by a reproducibility check.

The redline digest's generated-part-name fold is removed. It existed because
Compare was not byte-reproducible; #623 fixed that, so the fold only made the
harness less sensitive, and its [PR][0-9a-f]{32} pattern would also fold a
legitimate token out of XML content. Confirmed by running the corpus twice on one
build: all 12,882 observations identical.

Finally a test for the one class no harness can see. A leaked resource is
invisible to every digest, and CA2000 does not fire on the shape IrReader.Read
used to leak. What is assertable is the contract the throw path owes its caller,
so that is what is asserted -- reached through the WmlDocument MemoryStream
constructor, which unlike the byte-array ones does not validate the package type.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants