Stress-test DocxDiff on a heavyweight legal document, and stop it reading each file four times - #616
Conversation
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.
|
CI note — the one red check on Failing: Why it isn't this PR's:
What the failure looks like: the flight recorder dumped 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 |
|
Correction to the previous comment: I cannot re-run the job — Everything else on 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.
…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.
Adversarial reviewI 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 one behaviour regression
// 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 [] hereSame for Why the validation could not have caught itThis 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:
#622 closes both. The differential becomes 678 documents → 14,916 digests, still green. Smaller findings
Two observations, not defects
On the
|
…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.
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.
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
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.
…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
Why
We had no way to answer "how fast is
DocxDiffon 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.xmlholding 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. ADocxDiff.Compareof 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.DocxDiffComparisonread both sides withRetainSourcesoff to build the edit script.IrMarkupRendererthen re-read the same two documents withRetainSourceson, to get the sourcew:p/w:tblelements it clones from.None of it was necessary.
RetainSourcesdecides only whetherIrProvenancepins the sourceXElement, andIrProvenanceis 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 becauseGetXDocumentcaches 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
Consolidateread2*(N+1)packages to compareN+1documents.How it works now
IrMarkupRenderer.RenderandIrCompositeMarkupRenderer.Rendertake an optional pre-read snapshot;DocxDiffComparisonandConsolidateread once with provenance on and hand it over.IrReader.Readopens the package, decides the revision view against it, and walks it. Only a document that genuinely needs aRevisionProcessorround-trip reopens.GetRevisionson byte-identical packages returns empty immediately, the same shortcutToRedlinealready had. The edit script keeps no such shortcut: its all-Equal operations are the answer the caller asked for.UnidHelper.ContentSignaturewalked 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:
GetRevisionsGetEditScriptJsonGetRevisions, identical packagesConsolidate, 4 reviewersAllocation 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:
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
PreAcceptInputRevisionsandPreserveInputRevisions— 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.Readwas restructured around.TestFileshas 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:
mainvs this branchmainvs itselfw:tskip inUnidHelper.ContentSignatureAnd its limit, stated rather than glossed: reintroducing the
wp:docPr/@idstripping-order bug thatIrHasherTests.Canonicalize_LoneDocPrId_StillStrippedguards produces zero corpus mismatches — real Word documents emitdocPrwith bothidandname, so the lone-attribute shape never occurs in these 678 files. Corpus parity and unit tests cover different things.Found on the way:
Compareis not byte-deterministic with media (#621)The first corpus run reported 161 mismatches. They were not this branch's:
maindisagrees with itself on exactly the same 161. Media and diagram parts imported into a redline are namedP+ a fresh GUID, with relationship ids ofR+ a fresh GUID, so any document whose redline imports media produces different bytes every run — contradictingDocxDiffSettings.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/@idby consultingattribute.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 beforeRemoveAttributes()). 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.csprojdoes not setWasmEnableThreads, so the browser runtime is single-threaded:Task.Runqueues the delegate for the one thread that would then block on the result, and the runtime refuses rather than deadlocking —Measured, not inferred: with the guard forced open, all ten
npm/tests/docx-diff.spec.tscases fail with exactly that exception; with it in place they pass (10 passed, 14.0s). Both call sites go throughDocxodus.Internal.ParallelWork, compiled out underWASM_BUILDand additionally requiringEnvironment.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 (
UnidHelperhashes twice per element for elements the IR never reads back), #619 (MarkupCompatibilityNormalizerfull-parsesdocument.xmlon every call to find nothing). A fourth — spend the remaining two cores — is deliberately not filed;FINDINGS.mdsays why.Files
benchmarks/docxdiff-stress/is a new standalone harness, outsideDocxodus.slnso 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 reportedUnidHelperat 108 ms where the steady state is 51 ms).FINDINGS.mdcarries the full stage attribution.docs/architecture/ir_diff_engine.mdpreviously 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