Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
420 changes: 420 additions & 0 deletions memoization.md

Large diffs are not rendered by default.

72 changes: 72 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using System.Collections.Concurrent;
using System.Collections.Generic;

namespace SIL.Machine.Morphology.HermitCrab
{
/// <summary>
/// Per-parse cache carrier threaded through <see cref="Word"/> clones exactly like
/// <see cref="Word.CurrentTrace"/> -- reference-shared, excluded from <c>Word.FreezeImpl</c>/
/// <c>Word.ValueEquals</c> so existing dedup semantics are unchanged. Holds the analysis-cascade memo
/// (memoization.md) -- see <see cref="MemoizedCombinationRuleCascade"/>.
/// One instance per <see cref="Morpher.ParseWord(string, out object)"/> call: entries are state facts
/// about a specific parse (a state key does not encode the target surface word), so sharing this
/// across concurrent parses of different words would be unsound without also scoping the key to the
/// word -- that cross-word extension is explicitly out of scope.
/// Thread-safe because a single word's analysis can itself run in parallel
/// (<c>ParallelCombinationRuleCascade</c>, used when <see cref="Morpher.MaxDegreeOfParallelism"/> is not
/// 1) -- though today only the sequential cascade actually reads/writes this.
/// </summary>
internal sealed class AnalysisScope
{
// OOM guard: a positive memo holds actual Word lists (not just a boolean like the nogood case), so
// it is the one that can grow unboundedly on a pathological word. Past the cap, new subtrees are
// simply not memoized -- correctness is unaffected, only the hit rate degrades. No corpus word
// seen so far has come close to this cap; deliberately untested against an actual overflow.
private const int MaxMemoEntries = 100_000;

public ConcurrentDictionary<AnalysisStateKey, MemoEntry> Memo { get; } =
new ConcurrentDictionary<AnalysisStateKey, MemoEntry>();

// Second table, same key space, different computation: the affix-template battery result for a
// state (AnalysisStratumRule.ApplyTemplateBattery), as opposed to the mrule-cascade subtree result
// above. Kept separate because a state can be memoized in one table but not (yet) the other.
public ConcurrentDictionary<AnalysisStateKey, MemoEntry> TemplateMemo { get; } =
new ConcurrentDictionary<AnalysisStateKey, MemoEntry>();

// Keys currently under expansion on some call stack -- guards the in-flight re-entry case (a
// multiApp cascade can reach the same state again before its own first expansion has completed,
// e.g. via a self-loop). A hit here must fall through to plain, unmemoized expansion rather than
// read a nonexistent/partial entry or deadlock; see MemoizedCombinationRuleCascade.ApplyRules. The
// template battery needs no equivalent guard: ApplyTemplateBattery's call is eager and
// self-contained (no template<->mrule mutual recursion happens inside it).
public ConcurrentDictionary<AnalysisStateKey, byte> InProgress { get; } =
new ConcurrentDictionary<AnalysisStateKey, byte>();

public bool HasMemoCapacity => Memo.Count < MaxMemoEntries;

public bool HasTemplateMemoCapacity => TemplateMemo.Count < MaxMemoEntries;
}

/// <summary>
/// A memoized analysis-cascade subtree or template-battery result (memoization.md). <see cref="Results"/>
/// empty = the "nogood" case (subtree/battery proved to yield nothing); non-empty = the positive case,
/// replayable onto a differently-ordered arrival at the same <see cref="AnalysisStateKey"/> via
/// <see cref="Word.ReplayOnto"/>, using <see cref="MruleTrailPrefixLength"/>/<see cref="NonHeadPrefixLength"/>
/// to split each stored result's trail/non-heads into the (discarded, replaced) prefix and the (kept)
/// subtree-local suffix. There is no "budget exhausted / incomplete" flag: this engine has no step/time
/// budget infrastructure, so every recorded subtree was explored to full completion.
/// </summary>
internal sealed class MemoEntry
{
public MemoEntry(IReadOnlyList<Word> results, int mruleTrailPrefixLength, int nonHeadPrefixLength)
{
Results = results;
MruleTrailPrefixLength = mruleTrailPrefixLength;
NonHeadPrefixLength = nonHeadPrefixLength;
}

public IReadOnlyList<Word> Results { get; }
public int MruleTrailPrefixLength { get; }
public int NonHeadPrefixLength { get; }
}
}
110 changes: 110 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisStateKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using SIL.Machine.Annotations;
using SIL.Machine.FeatureModel;

namespace SIL.Machine.Morphology.HermitCrab
{
/// <summary>
/// Order-independent identity of an analysis-cascade node, used by the memo cache in
/// <see cref="AnalysisStratumRule"/>'s Unordered-mode morphological rule cascade (memoization.md). Two
/// Words with an equal key are guaranteed to make identical decisions in every analysis-side
/// morphological rule that cascade can invoke -- verified by inspecting what each one reads from its
/// input (key-completeness audit, re-run whenever an <c>Analysis*.cs</c> rule changes):
/// <list type="bullet">
/// <item><see cref="MorphologicalRules.AnalysisAffixProcessRule"/>: Shape (FST pattern match) and
/// <see cref="Word.SyntacticFeatureStruct"/> (unifiability gate) plus a per-rule unapplication count.</item>
/// <item><see cref="MorphologicalRules.AnalysisCompoundingRule"/>: adds <see cref="Word.NonHeadCount"/>
/// (<c>MaxStemCount</c> gate) -- it never reads the non-heads' own content, only the count.</item>
/// <item><see cref="MorphologicalRules.AnalysisRealizationalAffixProcessRule"/>: adds
/// <see cref="Word.RealizationalFeatureStruct"/>.</item>
/// </list>
/// but never the ORDER those rules were unapplied in -- exactly the redundancy this key collapses.
/// Deliberately excludes fields <c>Word.ValueEquals</c> includes for a different purpose (result
/// dedup): the unapplication trail as an ordered SEQUENCE (replaced here by an order-independent
/// multiset) and <c>_isLastAppliedRuleFinal</c>/<c>IsPartial</c>, which are not read by any
/// analysis-side rule (grep-verified against every file matching <c>MorphologicalRules/Analysis*.cs</c>
/// and <c>PhonologicalRules/Analysis*.cs</c>).
/// </summary>
internal readonly struct AnalysisStateKey : IEquatable<AnalysisStateKey>
{
private readonly Shape _shape;
private readonly Stratum _stratum;
private readonly FeatureStruct _syntacticFS;
private readonly FeatureStruct _realizationalFS;
private readonly int _nonHeadCount;
private readonly IReadOnlyDictionary<IMorphologicalRule, int> _ruleCounts;
private readonly int _hashCode;

public AnalysisStateKey(Word word)
{
_shape = word.Shape;
_stratum = word.Stratum;
_syntacticFS = word.SyntacticFeatureStruct;
_realizationalFS = word.RealizationalFeatureStruct;
_nonHeadCount = word.NonHeadCount;
_ruleCounts = word.UnappliedRuleCounts;

// Defensive: AnalysisAffixTemplateRule.Apply reassigns SyntacticFeatureStruct to a fresh,
// unfrozen clone AFTER the owning Word is already frozen (no CheckFrozen() guard on that
// setter). Freeze() is idempotent and safe to call again here.
_shape.Freeze();
_syntacticFS.Freeze();
_realizationalFS.Freeze();

int hash = 17;
hash = hash * 31 + _shape.GetFrozenHashCode();
hash = hash * 31 + (_stratum?.GetHashCode() ?? 0);
hash = hash * 31 + _syntacticFS.GetFrozenHashCode();
hash = hash * 31 + _realizationalFS.GetFrozenHashCode();
hash = hash * 31 + _nonHeadCount;
if (_ruleCounts != null)
{
// XOR, not the usual *31 rolling combine: the multiset is unordered, so the combination
// must be commutative -- two dictionaries with the same entries built up in different
// unapplication orders must hash identically.
int multisetHash = 0;
foreach (KeyValuePair<IMorphologicalRule, int> kvp in _ruleCounts)
multisetHash ^= (kvp.Key.GetHashCode() * 397) ^ kvp.Value;
hash = hash * 31 + multisetHash;
}
_hashCode = hash;
}

public override int GetHashCode() => _hashCode;

public override bool Equals(object obj) => obj is AnalysisStateKey other && Equals(other);

public bool Equals(AnalysisStateKey other)
{
if (_hashCode != other._hashCode)
return false;
if (_nonHeadCount != other._nonHeadCount || !ReferenceEquals(_stratum, other._stratum))
return false;
if (!_shape.ValueEquals(other._shape))
return false;
if (!_syntacticFS.ValueEquals(other._syntacticFS) || !_realizationalFS.ValueEquals(other._realizationalFS))
return false;
return RuleCountsEqual(_ruleCounts, other._ruleCounts);
}

private static bool RuleCountsEqual(
IReadOnlyDictionary<IMorphologicalRule, int> a,
IReadOnlyDictionary<IMorphologicalRule, int> b
)
{
int aCount = a?.Count ?? 0;
int bCount = b?.Count ?? 0;
if (aCount != bCount)
return false;
if (aCount == 0)
return true;
foreach (KeyValuePair<IMorphologicalRule, int> kvp in a)
{
if (!b.TryGetValue(kvp.Key, out int otherCount) || otherCount != kvp.Value)
return false;
}
return true;
}
}
}
68 changes: 54 additions & 14 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisStratumRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,19 +46,17 @@ public AnalysisStratumRule(Morpher morpher, Stratum stratum)
);
break;
case MorphologicalRuleOrder.Unordered:
#if SINGLE_THREADED
_mrulesRule = new CombinationRuleCascade<Word, ShapeNode>(
mrules,
true,
FreezableEqualityComparer<Word>.Default
);
#else
_mrulesRule = new ParallelCombinationRuleCascade<Word, ShapeNode>(
mrules,
true,
FreezableEqualityComparer<Word>.Default
);
#endif
// Sequential (and memoized, see MemoizedCombinationRuleCascade) when the caller caps
// within-word parallelism; parallel cascade otherwise.
_mrulesRule =
morpher.MaxDegreeOfParallelism == 1
? (IRule<Word, ShapeNode>)
new MemoizedCombinationRuleCascade(mrules, FreezableEqualityComparer<Word>.Default)
: new ParallelCombinationRuleCascade<Word, ShapeNode>(
mrules,
true,
FreezableEqualityComparer<Word>.Default
);
break;
}
}
Expand Down Expand Up @@ -166,9 +164,51 @@ private IEnumerable<Word> ApplyMorphologicalRules(Word input)
}
}

// Test/reporting hooks (memoization.md's standing hit/miss-count requirement), mirroring
// MemoizedCombinationRuleCascade's DiagMemoHits/DiagNogoodHits split.
internal static long DiagTemplateMemoHits;
internal static long DiagTemplateNogoodHits;

// Runs the affix-template battery for `input`, memoized by AnalysisStateKey (memoization.md),
// same replay mechanism as MemoizedCombinationRuleCascade -- see AnalysisScope.InProgress for
// why no re-entry guard is needed here. Measured motivation (an archive prototype benchmark on
// a Bantu-template grammar): this battery accounted for the large majority of parse wall time
// once the mrule cascade's own memo had already shrunk its own share to near-nothing.
private IEnumerable<Word> ApplyTemplateBattery(Word input)
{
AnalysisScope scope = input.AnalysisScope;
if (scope == null || _morpher.MaxDegreeOfParallelism != 1)
return _templatesRule.Apply(input);

var key = new AnalysisStateKey(input);
if (scope.TemplateMemo.TryGetValue(key, out MemoEntry entry))
{
if (entry.Results.Count == 0)
{
DiagTemplateNogoodHits++;
return Enumerable.Empty<Word>();
}
var replayed = new List<Word>(entry.Results.Count);
foreach (Word stored in entry.Results)
replayed.Add(stored.ReplayOnto(input, entry.MruleTrailPrefixLength, entry.NonHeadPrefixLength));
DiagTemplateMemoHits++;
return replayed;
}

var results = new List<Word>(_templatesRule.Apply(input));
if (scope.HasTemplateMemoCapacity)
{
scope.TemplateMemo.TryAdd(
key,
new MemoEntry(results, input.MorphologicalRuleTrailLength, input.NonHeadCount)
);
}
return results;
}

private IEnumerable<Word> ApplyTemplates(Word input)
{
foreach (Word tempOutWord in _templatesRule.Apply(input).Distinct(FreezableEqualityComparer<Word>.Default))
foreach (Word tempOutWord in ApplyTemplateBattery(input).Distinct(FreezableEqualityComparer<Word>.Default))
{
switch (_stratum.MorphologicalRuleOrder)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
using System.Collections.Generic;
using SIL.Machine.Annotations;
using SIL.Machine.Rules;

namespace SIL.Machine.Morphology.HermitCrab
{
/// <summary>
/// Drop-in replacement for the sequential <see cref="CombinationRuleCascade{TData,TOffset}"/> on
/// Unordered-order analysis strata (memoization.md). Before expanding a node, checks whether an
/// earlier expansion elsewhere in the same word's analysis -- reached via a different unapplication
/// order, but with an equal <see cref="AnalysisStateKey"/> -- already searched this exact state:
/// <list type="bullet">
/// <item>proved empty (the "nogood" case) -> skip straight to "no results";</item>
/// <item>produced results (the positive case) -> replay them (<see cref="Word.ReplayOnto"/>) instead
/// of re-searching: clone each stored result and graft the CURRENT arrival's own trail/non-head
/// prefix onto the stored subtree-local suffix (see <see cref="MemoEntry"/> and
/// <see cref="Word.ReplayOnto"/> for why only the prefix -- never the suffix -- needs replacing).</item>
/// </list>
/// Scoped to the sequential cascade only. The parallel cascade
/// (<c>ParallelCombinationRuleCascade</c>, used when <see cref="Morpher.MaxDegreeOfParallelism"/> is
/// not 1) is a level-by-level breadth-first walk with no natural "this subtree is fully expanded"
/// moment to hang a memo write on, so it is left unmemoized -- callers that want this optimization
/// should construct their <see cref="Morpher"/> with <c>maxDegreeOfParallelism: 1</c>.
/// </summary>
internal class MemoizedCombinationRuleCascade : RuleCascade<Word, ShapeNode>
{
// Test/reporting hooks (memoization.md's standing hit/miss-count requirement): DiagMemoHits counts
// positive replays (a stored non-empty subtree grafted onto a new arrival); DiagNogoodHits counts
// nogood hits (a stored EMPTY subtree short-circuited without any replay work). The equivalence
// tests that cover the replay path assert DiagMemoHits is nonzero so they can never go vacuous --
// a memo that silently stopped firing would otherwise look exactly like a passing test.
internal static long DiagMemoHits;
internal static long DiagNogoodHits;

public MemoizedCombinationRuleCascade(
IEnumerable<IRule<Word, ShapeNode>> rules,
IEqualityComparer<Word> comparer
)
: base(rules, true, comparer) { }

public override IEnumerable<Word> Apply(Word input)
{
var output = new HashSet<Word>(Comparer);
ApplyRules(input, output);
return output;
}

// Returns every result produced strictly within the subtree rooted at `input` (i.e. by applying
// one or more rules starting from `input`, at any depth) -- NOT including `input` itself. This is
// both the return value callers use and the value memoized against `input`'s AnalysisStateKey
// once the subtree finishes, so a later differently-ordered arrival at the same state can replay
// it via Word.ReplayOnto instead of re-searching.
private List<Word> ApplyRules(Word input, HashSet<Word> output)
{
AnalysisScope scope = input.AnalysisScope;
// See Word.AnalysisScope's doc for when this is null.
if (scope == null)
return ApplyRulesRaw(input, output);

var key = new AnalysisStateKey(input);

if (scope.Memo.TryGetValue(key, out MemoEntry entry))
{
if (entry.Results.Count == 0)
{
DiagNogoodHits++;
return new List<Word>();
}
var replayed = new List<Word>(entry.Results.Count);
foreach (Word storedResult in entry.Results)
{
Word replay = storedResult.ReplayOnto(
input,
entry.MruleTrailPrefixLength,
entry.NonHeadPrefixLength
);
output.Add(replay);
replayed.Add(replay);
}
DiagMemoHits++;
return replayed;
}

// In-flight re-entry guard, see AnalysisScope.InProgress.
if (!scope.InProgress.TryAdd(key, 0))
return ApplyRulesRaw(input, output);

List<Word> results;
try
{
results = ApplyRulesRaw(input, output);
}
finally
{
scope.InProgress.TryRemove(key, out _);
}

// Past the cap, keep searching correctly, just stop growing the table (AnalysisScope.HasMemoCapacity).
if (scope.HasMemoCapacity)
scope.Memo.TryAdd(key, new MemoEntry(results, input.MorphologicalRuleTrailLength, input.NonHeadCount));

return results;
}

private List<Word> ApplyRulesRaw(Word input, HashSet<Word> output)
{
var local = new List<Word>();
for (int i = 0; i < Rules.Count; i++)
{
foreach (Word result in ApplyRule(Rules[i], i, input))
{
local.Add(result);
output.Add(result);
// avoid infinite loop -- same guard CombinationRuleCascade uses
if (!Comparer.Equals(input, result))
local.AddRange(ApplyRules(result, output));
}
}
return local;
}
}
}
Loading
Loading