Skip to content

HermitCrab engine: share frozen Shapes on internal Word clones (clone 36% to 9% of profile; peak memory 11 GB to 6 GB) #593

Open

Description

@johnml1135

Internal Word clones deep-copy the whole Shape (every node, feature structure and annotation), but three of the four clone sites never use the copy. The change adds Word.CloneForEngine, which shares a frozen source shape and marks it shared. EnsureOwnShape copies on demand before in-place mutation, and is called in AnalysisStratumRule and SynthesisStratumRule. ResetShape and Shape.CreateEmptyLike replace clone-then-clear at the four GenerateShape sites. Public Word.Clone() still returns a deep copy. A missed mutation throws because the shared shape is frozen. This is sharing, not a cache. CloneForEngine, EnsureOwnShape, ResetShape and CreateEmptyLike are on the archived branch only.

Measured. Profile on the heaviest Sena word: Word clone 36% to 9% of wall time, Shape.CopyTo 28.6% to 13%, ReplayOnto 21.7% to 6.5%. Peak working set 11 GB to 6 GB. Wall-time attribution is unreliable on that machine. Counts are unchanged by construction.

What a revival must do first. The disposition says to reopen only if memory becomes the constraint, because the aliasing rules are subtle. Re-measure with a gloss, allomorph-index and feature-structure signature, since the old parity signature checked only morph counts.

Record: see the archived write-up below
Ledger: #490 (comment); disposition: #490 (comment)
From #490 (closed).

Activity

  1. johnml1135 commented on Nov 7, 2026

    @johnml1135
    CollaboratorAuthor

    Archived from docs/hc-optimization-ledger @ 2eba2f5 (docs/optimizations/shape-sharing-on-clone.md), which is being deleted:

    Copy-on-write Shape sharing in Word.Clone

    Status: retained on perf/hc-optimization-archive, not merged (Word.CloneForEngine, EnsureOwnShape,
    ResetShape, Shape.CreateEmptyLike). Portable: partly. The observation (three of four clone sites
    never use the copied shape) is engine-independent; the mechanism (shared reference to an immutable
    structure) is how a Rust port would naturally write it already.

    The observation

    A CPU profile of the heaviest Sena word (60 s sampled, memoized path) put Word cloning at 36% of wall,
    of which Shape.CopyTo was 28%: every clone deep-copied every ShapeNode, every node's feature
    structure (SimpleFeatureValue.CloneImpl 22%), every annotation, and built a node-mapping dictionary.
    The callers: Word.ReplayOnto 15% (memo replay; never mutates the shape), Word.ExpandAlternatives 9%
    (immediately replaces _shape), AnalysisAffixProcessAllomorphRuleSpec.ApplyRhs 5% (GenerateShape
    immediately Clear()s the copy), non-head clones 2.5%, LexicalLookup (the RootAllomorph setter
    replaces _shape).

    The change

    • Internal engine clone: if the source shape is frozen, share the reference and mark _shapeShared; otherwise
      deep-copy it. Public Word.Clone() always returns a deep, independently mutable shape.
    • EnsureOwnShape(): clone once, on demand, before in-place mutation. Called in AnalysisStratumRule and
      SynthesisStratumRule before phonological rules are applied to a fresh clone (rewrite specs insert
      nodes and hold node references obtained by matching, so the un-share must precede the match).
    • ResetShape(): replace with a new empty shape of the same configuration. Used by the four
      GenerateShape/rebuild sites (analysis affix, analysis compounding subrule, synthesis affix, synthesis
      compounding) instead of clone-then-clear.
    • ExpandAlternatives marks the adopted shape as shared; the RootAllomorph setter and constructors mark
      their fresh clones as owned.

    The safety net is that a shared shape is frozen: any mutation through a missed site throws
    (Shape.CheckFrozen, frozen node feature structures; a missing guard on Annotation.Range was added).
    During development the first full test run threw at exactly the two stratum sites, which is how they
    were found; no other site surfaced across 122 HermitCrab tests, 464 fixture words and 45 Sena words.

    Measured

    Profile after the change: Word clone 36% → 9% of wall, Shape.CopyTo 28.6% → 13% (the remainder is
    the stratum's EnsureOwnShape and part copying inside GenerateShape), ReplayOnto 21.7% → 6.5%. Peak
    working set on the heaviest word 11 GB → 6 GB. Wall-time attribution is unreliable on this machine (see
    the round document); counts are unchanged by construction.

    WordShapeSharingTests covers public-clone independence, copy-on-write, reset paths, and missed-mutation
    guards. The final conformance-inclusive run passed 603 tests with one expected host-privilege skip.

  2. johnml1135 commented on Nov 12, 2026

    @johnml1135
    CollaboratorAuthor

    Reference numbers from the PanGloss port, in case this is revived. In PanGloss, equal shapes and syntactic feature structures made by separate rule applications in one parse are interned to one allocation (sillsdev/PanGloss#249). That is broader than sharing on clone: at a 200M work cap the search held 379,394 words over only 160 distinct shapes and 42 distinct syntactic feature structures, and the private copies were 690 MB of 1,152 MB. Word mindeshi (Mbugwe), peak resident set, one thread:

    work cap before shape + feature-structure sharing
    50M 199 MB 82 MB
    200M 1,152 MB 389 MB
    1G 5,074 MB 1,408 MB

    Results were identical on four grammars. Cost: Swahili -0.55%, Amharic +0.51% instructions. These are Rust numbers, not C# ones. If the clone-sharing here leaves equal-but-separate shapes from different rule applications, interning is the follow-on idea. Remaining per-word growth is tracked in #639 (finish each stratum as it completes) and #640 (store folded alternatives as differences).

  3. johnml1135 commented on Nov 12, 2026

    @johnml1135
    CollaboratorAuthor

    WIN alloc -35..-47% vs master, wall -23..-37% (Amharic -37%, Mbugwe -23%, Aweti -3%), CPU -5..-24%, mbugwe 250-word peak RSS 2.40->1.88 GB | parse output identical | not pushed, no PR (owner)

    machine #593: internal Word clones share a frozen Shape (PanGloss #232 idea)

    Ready to post as a comment on sillsdev/machine #593. Branch shape-share, commit 8c96a4d7, off origin/master 0cbf85f. Not pushed. Per owner, no PR body written and no further code.

    Change (12 files, +153/-17; 76 of those lines are a new test file)

    • Revived the idea from the archived HermitCrab optimization round record: what was tried, and a faster package that is not mergeable #490 code (perf/hc-optimization-archive: CloneForEngine, EnsureOwnShape, ResetShape), reimplemented minimally on master (the archive is on a memoized, older base and bundles HermitCrab engine: share frozen syntactic feature structures on internal Word clones (about 15% of a heavy Sena profile, never isolated) #595 syntactic-FS sharing, which is NOT included here).
    • Word.CloneForEngine() (internal): if the source shape is frozen, share it (_shapeShared), else deep-copy. Public Word.Clone() and the protected copy ctor stay deep. Non-head words are engine-cloned too.
    • EnsureOwnShape(): copy once before in-place edits; used in SynthesisStratumRule before the phonological rules. ResetShape() + Shape.CreateEmptyLike(): replaces clone-then-Clear() in the four rule-spec sites (AnalysisAffixProcessAllomorph, AnalysisCompoundingSubrule, SynthesisAffixProcessAllomorph, SynthesisCompounding).
    • Engine clones now at: Morpher lexical lookup (2), AnalysisAffixTemplateRule, SynthesisStratumRule (2), SynthesisAffixTemplatesRule (2), AnalysisCompoundingRule (1), Word.ExpandAlternatives. AnalysisStratumRule stays a deep clone (phonological rules mutate and hold node references, so sharing would be undone at once).
    • Safety net: a shared shape is frozen, so a missed mutation throws rather than corrupting. None surfaced in tests or word lists.

    Identity (sorted parses per word: surface, shape string, morph list with morpheme id/gloss/allomorph index, syntactic FS, realizational FS, MPR features, partial flag)

    Compared master vs shape-share vs shape-share+#633, byte-for-byte (cmp): all IDENTICAL.

    list words with parses
    Aweti all 206 157
    Amharic all 673 190
    Mbugwe first 250 239
    Sena 154-word sample (every 40th) + first 100 69 + 34
    Limits: Mbugwe line 283 (chaanjiyɛ, the known >16 GB word) excluded; master hit the 6 GB heap limit (OOM) at word 304 on a 400-word run, so Mbugwe was cut to 250. Sena is a sample, not 6146 words.
    Conformance: fieldworks-parser-conformance run through machine's hermitcrab-conformance-runner branch (merged onto master and onto shape-share, HC_CONFORMANCE_SUITE), with the runner's PR633_DUMP hook logging every word's signature: 1414 word results, sorted, identical on both. The 4 conformance gate tests that fail (CoverageReport_*, CheckedInCoverageTablesAreUpToDate, EveryConformanceFixturePassesSelfCheck, EveryFixtureContributesAtLeastOneCoverageRow) fail the same four on unmodified master+runner (suite version vs the pin), unrelated.

    Tests

    HermitCrab.Tests 181 -> 186 passed (5 new WordShapeSharingTests: public clone stays deep, engine clone shares frozen/copies unfrozen, shared shape rejects mutation, EnsureOwnShape, ResetShape). SIL.Machine.Tests 910 passed, 3 skipped (preexisting), 0 failed. dotnet csharpier check . clean. Not run red-without-change for the new tests (they call the new methods).

    A/B (interleaved, order randomised per round, n=15, new process per sample, 1 warm + 2 timed passes, getrusage CPU and ru_maxrss per child, load avg 10-20 on 20 cores, nice, GC heap hard limit 6 GB)

    Arms: B = master; M = master+#633 (alloc-medium e6852c4); S = master+shape-share; SM = both (clean merge). Medians.

    workload arm alloc MB/pass wall ms CPU s peak RSS MB GC pause ms
    Amharic 40 B 2747 2428 11.33 121 572
    M 2251 2081 10.02 118 447
    S 1471 1556 8.69 115 344
    SM 1239 1344 8.13 114 272
    Aweti 15 B 492 739 7.57 248 102
    M 439 698 7.31 220 98
    S 305 752 7.29 243 85
    SM 285 645 6.95 214 84
    Mbugwe 12 B 1419 1597 11.55 231 264
    M 1216 1440 10.99 229 241
    S 794 1221 10.21 223 206
    SM 708 1123 9.63 224 188

    Paired ratios (median; wins = rounds where the new arm is lower):

    compare workload alloc wall CPU peak RSS
    S / B (vs master) Amharic 0.535 (15/15) 0.630 (14/15) 0.761 (14/15) 0.959 (13/15)
    Aweti 0.622 (15/15) 0.968 (9/15) 0.947 (9/15) 0.988 (13/15)
    Mbugwe 0.560 (15/15) 0.770 (14/15) 0.904 (13/15) 0.961 (14/15)
    S / M (vs #633 alone) Amharic 0.653 (15/15) 0.759 (13/15) 0.850 (14/15) 0.975 (10/15)
    Aweti 0.697 (15/15) 1.093 (6/15) 0.945 (10/15) 1.105 (0/15)
    Mbugwe 0.653 (15/15) 0.829 (13/15) 0.920 (12/15) 0.965 (10/15)
    SM / M (shape-share on top of #633) Amharic 0.551 (15/15) 0.655 (15/15) 0.818 (15/15) 0.983 (11/15)
    Aweti 0.649 (15/15) 0.923 (12/15) 0.900 (11/15) 0.964 (15/15)
    Mbugwe 0.583 (15/15) 0.750 (15/15) 0.909 (12/15) 0.983 (11/15)
    SM / B (both vs master) Amharic 0.451 0.569 0.709 0.958
    Aweti 0.580 0.826 0.898 0.861
    Mbugwe 0.499 0.681 0.839 0.965

    Peak memory on a long run (word-list dump, 250 Mbugwe words, one process, /usr/bin/time max RSS): master 2.40 GB, shape-share 1.88 GB (-22%), both 2.60 GB (not lower; single run, noisy, not repeated). Aweti dump (206 words): 516 -> 417 MB (S), 497 MB (SM). Wall for the same dumps: Mbugwe 717 s -> 442 s (-38%), Amharic 37 s -> 24 s, Aweti 29 s -> 24 s. These are single runs under load.

    Reading it

    Risks / not done

    • Sena heavy-word memory claim (11 -> 6 GB) not re-measured; no perf counters (not installed), so CPU is getrusage time. Identity on Sena and Mbugwe is a subset. Concurrency (MaxDegreeOfParallelism > 1) not exercised; shared frozen shapes are read-only so it should be safe, untested.
    • Not implemented: lazy un-share in AnalysisStratumRule (clones there are still deep), HermitCrab engine: share frozen syntactic feature structures on internal Word clones (about 15% of a heavy Sena profile, never isolated) #595 syntactic-FS sharing.
    • Temporary helpers left: branch/worktrees shape-share-on633, ss-conf-base, ss-conf-shape under ~/work/machine.worktrees (local only, delete with the branches conf-base-tmp, conf-shape-tmp). Bench harness and raw data: lanes/machine-shape-share-bench (ab-results.txt, dumps/, ab2.py, sum3.py).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions