Skip to content

Memoize HermitCrab's sequential analysis cascade - #456

Merged
ddaspit merged 4 commits into
masterfrom
feature/memoization
Aug 19, 2026
Merged

Memoize HermitCrab's sequential analysis cascade#456
ddaspit merged 4 commits into
masterfrom
feature/memoization

Conversation

@johnml1135

@johnml1135 johnml1135 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Ports a memoization scheme (from an archived prototype) for HermitCrab's Unordered-mode analysis cascade: repeated cascade states reached via different rule-unapplication orders are computed once and replayed, instead of re-expanded — this is the source of a well-known ~98% expansion redundancy on template-heavy (Bantu-style) grammars.
  • Off by default. The existing parallel cascade is completely unchanged; you opt in with new Morpher(traceManager, language, maxDegreeOfParallelism: 1), which selects the new sequential+memo path.
  • Acceptance criterion is analysis-set equality (canonical morpheme-signature sets), not byte-identical objects — a memo-replayed Word isn't guaranteed field-for-field identical to a freshly-computed one, even when it represents the same analysis.

The design write-up (memoization.md) that earlier revisions of this branch carried has been removed — it duplicated what the code now documents in place. The key-completeness audit lives on AnalysisStateKey, the graft's soundness argument on Word.ReplayOnto, and the memo's resource and re-entry rules on AnalysisScope. This PR description is now the only record of the corpus measurements below.

Follow-up: #457 stacks a data-structure rearchitecture (array-backed, copy-on-write Shape) on top of this branch and re-measures the same two heavy words — read that PR for whether it's worth the larger diff.

Why off by default — the actual tradeoff

This is not a pure win, and the corpus evidence says so directly:

  • On a known-pathological heavy word (H1), sequential+memo measured 6.2x faster than today's parallel default (isolated to ~6.3x attributable to the memo mechanism itself, via a mutation test separating memo-on/off from sequential/parallel — parallelism alone contributed only ~2% on this word).
  • On typical (non-template) grammars — Indonesian was tested directly, 3 repeated runs to average out noise — sequential+memo runs ~25-28% slower in aggregate than the parallel default: it loses parallelism, and the memo has little order-variant redundancy to collapse there. Forcing the memo off entirely (mutation-tested) widens the slowdown to ~25-47%, confirming most — not all — of this is the lost thread, not the memo itself.
  • On a 70-word Sena slice including one heavy word: 53/69 (77%) of words are individually faster under the memo, 16/69 (23%) slower — but in aggregate wall-clock, the corpus finishes 2.42x faster, because the heavy word dominates the sum. Both of these are true at once and don't contradict each other.

So flipping the library's default cascade mode trades typical-word latency for pathological-word latency. Whether that's the right tradeoff depends on a given corpus's word-difficulty distribution — a decision for a future PR with its own evidence, not this one.

None of these numbers have been re-measured since the review changes below, which are correctness/resource fixes rather than algorithmic ones.

maxDegreeOfParallelism

The new constructor parameter replaces the dead SINGLE_THREADED compile-time toggles it was standing in for, and is now an enforced runtime cap rather than only a mode switch:

  • 1 runs the parse fully sequentially and is the only configuration eligible for the memo.
  • Any value >= 2 is passed to every Parallel.ForEach a parse or generation goes through — the mrule cascade, affix-template unapplication, synthesis, and word generation. Previously only ParallelCombinationRuleCascade honoured it, so maxDegreeOfParallelism: 2 still saturated the machine.
  • 0 (the default) and anything below 1 leave concurrency unbounded, preserving today's behaviour exactly. Call sites that already had a narrower default than TPL's unbounded — Synthesize's loop used Environment.ProcessorCount — keep it.

It must remain a pure performance knob: nothing that changes which analyses a parse returns may be gated on it, or the memoized and unmemoized configurations stop being comparable.

Review changes

Two follow-up commits address review findings; neither changes the algorithm or the analyses produced.

Address review findings in analysis-cascade memo:

  • Clear AnalysisScope entering synthesis, so returned parses no longer pin the per-parse memo tables.
  • Keep the template memo off Linear strata, whose key completeness is unaudited, and reject unfrozen words as memo keys.
  • Deduplicate replay/store onto AnalysisScope, drop ReplayOnto's discarded clones, and match CombinationRuleCascade's expansion order.

Tighten memo resource bounds, diagnostics, and parallelism cap:

  • Wire the parallelism cap through the Parallel.ForEach call sites that ignored it (above).
  • Bound the memo by retained Words, not just entry count. The entry cap alone was a poor proxy for memory: entry size is unbounded — a node's stored list holds every descendant, undeduplicated — and storing them keeps every intermediate of the search alive for the whole parse. Both tables share one per-parse budget. It is a coarse backstop, not a figure derived from measured memory, and should be re-derived from peak RSS on the heavy words before being treated as a real bound.
  • Skip the per-node result lists entirely when no scope is installed. Those lists exist only to feed a memo write, so a traced sequential parse was paying allocations proportional to the sum of all subtree sizes for nothing.
  • Replace the process-global diagnostic counters with per-scope counts folded into per-Morpher totals. The old statics were only safe because this test assembly is not [Parallelizable]; the equivalence gates now assert on a fresh Morpher's own counts, which is both stronger and immune to cross-test leakage.
  • Build keys via AnalysisStateKey.PinAndKey so the freeze side effect on the caller's Word is visible at every call site, and document AnalysisScope.InProgress as defensive — no path reaches it today, since every unapplication grows the multiset the key hashes, so a key cannot recur while still on the stack.

Verification

  • Unit tests: AnalysisStateKey order-invariance/hash/equality and its frozen-word precondition, Word.ReplayOnto graft correctness (including a test that specifically distinguishes "grafted the right subtree" from "grafted the wrong one" via distinct lexical entries), the InProgress in-flight re-entry guard, positive-replay-vs-unmemoized-result-set equivalence (including trail order), MaxAlternatives enforcement on both the raw and replay paths, the template memo's Unordered-only gating (AnalysisStratumRuleTests), the parallelism-cap mapping, and an mrule/template equivalence battery against real analysis-rule content via MorpherTests.
  • The equivalence gates are counter-guarded: each asserts the memo actually fired on its grammar, so a memo that silently stopped firing cannot pass as a green test.
  • Corpus runs (local, uncommitted grammars — Sena, Indonesian, Amharic; this repo never commits real grammar/corpus data): zero analysis-set divergences across all three, with both memo tables exercised non-vacuously (hundreds of thousands of hits, not a no-op) — but this excludes the heaviest words, which timed out and never entered the comparison (60 of 312 in the full Sena run). That's exactly the class of word the memo targets, so it's a real gap, not a formality: soundness on the hard cases rests on the two heavy words checked individually below, not on the corpus aggregate.
  • The two heavy words were checked individually to close that gap: H1 (the word above) is 0 divergences on a positive, 2-analysis match. H2 did not complete within a 400s timeout even with the memo on — an inconclusive (not failing) data point, recorded here rather than glossed over. (Stack RUSTIFY's array/COW rearchitecture on top of memoization #457 revisits H2 with the array-backed rearchitecture and gets a very different result.)
  • Full test suite: 93/93 HermitCrab, 796 passed / 3 skipped of 799 SIL.Machine (pre-existing unrelated skips), 83/83 Thot — all green.

Test plan

  • dotnet build Machine.sln
  • dotnet csharpier check .
  • dotnet test across HermitCrab, SIL.Machine, and Thot test projects
  • Corpus verification harness (MemoCorpusVerification, [Explicit]) run manually against local Sena/Indonesian/Amharic grammars — 0 divergences on the words that completed within the timeout budget

This change is Reviewable

@codecov-commenter

codecov-commenter commented Jul 17, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.50181% with 18 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.53%. Comparing base (fb30fed) to head (7b8304a).

Files with missing lines Patch % Lines
....Machine.Morphology.HermitCrab/AnalysisStateKey.cs 76.66% 6 Missing and 8 partials ⚠️
...SIL.Machine.Morphology.HermitCrab/AnalysisScope.cs 95.74% 1 Missing and 1 partial ⚠️
src/SIL.Machine.Morphology.HermitCrab/Morpher.cs 94.44% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #456      +/-   ##
==========================================
+ Coverage   73.31%   73.53%   +0.21%     
==========================================
  Files         446      449       +3     
  Lines       37334    37633     +299     
  Branches     5121     5174      +53     
==========================================
+ Hits        27371    27673     +302     
+ Misses       8836     8824      -12     
- Partials     1127     1136       +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ddaspit ddaspit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ddaspit made 1 comment.
Reviewable status: 0 of 11 files reviewed, 1 unresolved discussion (waiting on johnml1135).


a discussion (no related file):
Before I do a full review, it would be great if you could rebase this on master. I suspect that some of the logic might need to be updated to work with the changes in PR #452.

@johnml1135
johnml1135 force-pushed the feature/memoization branch from 8f5438a to 70bff72 Compare August 11, 2026 09:11
@johnml1135

Copy link
Copy Markdown
Collaborator Author

Rebased this branch onto current master (0b588308, v3.9.2).

Decisions made during the rebase:

  • Kept the original memoization patch semantically unchanged; git range-diff maps the original commit exactly to rebased commit 9cbbcfb9.
  • Adapted the memoized sequential cascade to PR Support LT-22605: Add ability to limit HermitCrab parses #452's MaxAlternatives contract. The memoized cascade remains a RuleCascade, propagates the configured limit, and now checks the distinct output count on both normal expansion and memo-replay paths.
  • Added a regression test that exercises both paths. This avoids a case where maxDegreeOfParallelism: 1 could bypass the parse limit while the default parallel cascade enforced it.
  • Kept memoization opt-in and made no change to the existing parallel/default behavior.
  • Used an explicit RuleCascade<Word, ShapeNode> cast so the conditional expression remains compatible with the repository's C# language version.

Verification on final commit 70bff728:

  • dotnet build Machine.sln --nologo — succeeded
  • dotnet test Machine.sln --no-build --nologo — 974 passed, 0 failed, 4 skipped/explicit
  • dotnet csharpier check . — 702 files checked

@johnml1135
johnml1135 force-pushed the feature/memoization branch 2 times, most recently from af80918 to d9ccc1f Compare August 18, 2026 11:14
@johnml1135

Copy link
Copy Markdown
Collaborator Author

@ddaspit - it has been rebased and shouldn't have any issues.

johnml1135 and others added 3 commits August 18, 2026 17:28
Adds AnalysisStateKey/AnalysisScope/Word.ReplayOnto and wires a memo table
into both the mrule cascade and the affix-template battery, so repeated
analysis-cascade states reached via different rule-unapplication orders are
computed once and replayed rather than re-expanded. Off by default (the
existing parallel cascade is unchanged); opt in via
Morpher(maxDegreeOfParallelism: 1), which selects the new sequential+memo
path.

Ported from an archived prototype (parse-optimization-archive) with
stronger verification: the acceptance gate is analysis-set equality
(canonical morpheme-signature sets), not byte-identical objects, since a
memo-replayed Word is not guaranteed field-for-field identical to a
freshly-computed one. Verified via unit tests (key order-invariance, replay
graft correctness, in-flight re-entry guard) plus corpus runs against three
real grammars (Sena, Indonesian, Amharic) with zero analysis-set
divergences. On a known-pathological word, sequential+memo measured 6.2x
faster than the parallel default (isolated to ~6.3x attributable to the
memo itself, not threading); aggregate corpus evidence and the typical-word
tradeoff are in memoization.md, along with honestly-reported open gaps.
- Clear AnalysisScope entering synthesis, so returned parses no longer pin
  the per-parse memo tables
- Make maxDegreeOfParallelism an enforced cap and retire the dead
  SINGLE_THREADED toggles it was meant to replace
- Keep the template memo off Linear strata, whose key completeness is
  unaudited, and reject unfrozen words as memo keys
- Deduplicate replay/store onto AnalysisScope, drop ReplayOnto's discarded
  clones, use plain collections on the sequential-only path, and match
  CombinationRuleCascade's expansion order
- Make the diagnostic counters atomic; trim comments to non-obvious
  constraints and remove memoization.md

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@ddaspit
ddaspit force-pushed the feature/memoization branch from d09e405 to e37c288 Compare August 18, 2026 21:28
- Wire MaxDegreeOfParallelism through the Parallel.ForEach call sites that
  ignored it, so values above 1 actually cap concurrency
- Bound the memo by retained Words, not just entry count
- Skip the per-node result lists when no scope is installed
- Replace process-global hit counters with per-scope counts folded into
  per-Morpher totals
- Build keys via AnalysisStateKey.PinAndKey so the freeze side effect is
  visible at call sites; document InProgress as defensive

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

@ddaspit ddaspit left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:lgtm:

@ddaspit reviewed 14 files and all commit messages, made 1 comment, and resolved 1 discussion.
Reviewable status: :shipit: complete! all files reviewed, all discussions resolved (waiting on johnml1135).

@ddaspit
ddaspit merged commit 5d26fac into master Aug 19, 2026
5 of 7 checks passed
@ddaspit
ddaspit deleted the feature/memoization branch August 19, 2026 21:39
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.

3 participants