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
23 changes: 9 additions & 14 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisAffixTemplateRule.cs
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
using System.Collections.Generic;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using SIL.Machine.Annotations;
using SIL.Machine.FeatureModel;
using SIL.Machine.Rules;
using SIL.ObjectModel;
#if !SINGLE_THREADED
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
#endif

namespace SIL.Machine.Morphology.HermitCrab
{
Expand Down Expand Up @@ -47,18 +45,16 @@ public IEnumerable<Word> Apply(Word input)
inWord.Freeze();

var output = new HashSet<Word>(FreezableEqualityComparer<Word>.Default);
#if SINGLE_THREADED
ApplySlots(inWord, _rules.Count - 1, output);
#else
ParallelApplySlots(inWord, output);
#endif
if (_morpher.MaxDegreeOfParallelism == 1)
ApplySlots(inWord, _rules.Count - 1, output);
else
ParallelApplySlots(inWord, output);

foreach (Word outWord in output)
outWord.SyntacticFeatureStruct.Add(fs);
return output;
}

#if SINGLE_THREADED
private void ApplySlots(Word inWord, int index, HashSet<Word> output)
{
for (int i = index; i >= 0; i--)
Expand All @@ -78,7 +74,7 @@ private void ApplySlots(Word inWord, int index, HashSet<Word> output)
_morpher.TraceManager.EndUnapplyTemplate(_template, inWord, true);
output.Add(inWord);
}
#else

private void ParallelApplySlots(Word inWord, HashSet<Word> output)
{
var outStack = new ConcurrentStack<Word>();
Expand Down Expand Up @@ -126,6 +122,5 @@ private void ParallelApplySlots(Word inWord, HashSet<Word> output)

output.UnionWith(outStack);
}
#endif
}
}
118 changes: 118 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
using System.Collections.Generic;

namespace SIL.Machine.Morphology.HermitCrab
{
/// <summary>
/// Carrier for the analysis-cascade memo, threaded through <see cref="Word"/> clones
/// like <see cref="Word.CurrentTrace"/> and likewise excluded from <c>Word.FreezeImpl</c>/
/// <c>Word.ValueEquals</c>, so dedup semantics are unchanged.
/// <para>
/// One instance per <see cref="Morpher.ParseWord(string, out object)"/> call. A state key does not
/// encode the target surface word, so sharing a scope across parses of different words would be
/// unsound.
/// </para>
/// <para>
/// Not thread-safe, hence the plain collections: a scope is only installed when
/// <see cref="Morpher.MaxDegreeOfParallelism"/> is 1. Memoizing the parallel cascade would require
/// concurrent ones.
/// </para>
/// </summary>
internal sealed class AnalysisScope
{
// OOM guard, since a positive entry holds Word lists rather than just a flag. Past the cap
// subtrees simply go unmemoized: only the hit rate degrades, never correctness.
private const int MaxMemoEntries = 100_000;

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

// Same key space as Memo, different computation: the affix-template battery's result for a state
// (AnalysisStratumRule.ApplyTemplateBattery). Separate because a state can be memoized in one
// table but not the other.
public Dictionary<AnalysisStateKey, MemoEntry> TemplateMemo { get; } =
new Dictionary<AnalysisStateKey, MemoEntry>();

// Keys still under expansion somewhere on the call stack. A multiApp cascade can reach the same
// state again before its first expansion has finished (e.g. via a self-loop), which must fall
// through to unmemoized expansion rather than read a partial entry or deadlock. The template
// battery needs no equivalent: its call is eager, with no template/mrule mutual recursion inside.
public HashSet<AnalysisStateKey> InProgress { get; } = new HashSet<AnalysisStateKey>();

/// <summary>
/// Replay shared by both memo consumers. False on a miss; on a hit
/// <paramref name="replayed"/> holds the stored results grafted onto <paramref name="query"/>, or
/// is empty for a stored-empty ("nogood") entry. The query's non-head prefix is cloned once and
/// shared across this hit's replays, which is safe because each replay freezes immediately and
/// every non-head mutation path is CheckFrozen-guarded.
/// </summary>
public bool TryReplay(
Dictionary<AnalysisStateKey, MemoEntry> table,
AnalysisStateKey key,
Word query,
out List<Word> replayed
)
{
if (!table.TryGetValue(key, out MemoEntry entry))
{
replayed = null;
return false;
}
if (entry.Results.Count == 0)
{
replayed = new List<Word>();
return true;
}
List<Word> queryNonHeadPrefix = query.CloneNonHeadsForReplay();
replayed = new List<Word>(entry.Results.Count);
foreach (Word stored in entry.Results)
{
replayed.Add(
stored.ReplayOnto(
query,
entry.MruleTrailPrefixLength,
entry.NonHeadPrefixLength,
queryNonHeadPrefix
)
);
}
return true;
}

/// <summary>
/// Records a fully-expanded result list against <paramref name="key"/>, unless the table is full.
/// </summary>
public void Store(
Dictionary<AnalysisStateKey, MemoEntry> table,
AnalysisStateKey key,
Word query,
List<Word> results
)
{
if (table.Count < MaxMemoEntries)
table[key] = new MemoEntry(results, query.MorphologicalRuleTrailLength, query.NonHeadCount);
}
}

/// <summary>
/// A memoized subtree or template-battery result. An empty <see cref="Results"/> means the state was
/// proved to yield nothing. The two prefix lengths are the trail/non-head counts at the moment of the
/// write, which is where <see cref="Word.ReplayOnto"/> splits a stored result when grafting it onto a
/// new arrival.
/// <para>
/// There is deliberately no "incomplete" flag: only fully-explored subtrees may be recorded. Should a
/// step or time budget ever be added, an interrupted subtree must not be stored.
/// </para>
/// </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; }
}
}
117 changes: 117 additions & 0 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisStateKey.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
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. Two Words with an equal
/// key must make identical decisions in every analysis-side rule the cascade can invoke; that is the
/// memo's correctness contract, so this key-completeness audit of what each rule reads has to be
/// re-run whenever an <c>Analysis*.cs</c> rule changes:
/// <list type="bullet">
/// <item><see cref="MorphologicalRules.AnalysisAffixProcessRule"/>: Shape (FST pattern match),
/// <see cref="Word.SyntacticFeatureStruct"/> (unifiability gate), per-rule unapplication count.</item>
/// <item><see cref="MorphologicalRules.AnalysisCompoundingRule"/>: adds <see cref="Word.NonHeadCount"/>
/// (<c>MaxStemCount</c> gate) -- never the non-heads' own content, only the count.</item>
/// <item><see cref="MorphologicalRules.AnalysisRealizationalAffixProcessRule"/>: adds
/// <see cref="Word.RealizationalFeatureStruct"/>.</item>
/// </list>
/// No rule reads the order those rules were unapplied in, which is the redundancy this key collapses,
/// so the trail is reduced to an unordered multiset here. <c>_isLastAppliedRuleFinal</c> and
/// <c>IsPartial</c> are excluded as well: <c>Word.ValueEquals</c> includes them for result dedup, but
/// no analysis-side rule reads them.
/// </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)
{
// The cached hash covers live references -- notably Word.UnappliedRuleCounts, the word's own
// mutable dictionary. Keying an unfrozen word would let a later mutation invalidate a stored
// key's hash, silently causing permanent misses or entries that no longer match their bucket.
if (!word.IsFrozen)
throw new ArgumentException(
"The word must be frozen before it can be used as a memo key.",
nameof(word)
);

_shape = word.Shape;
_stratum = word.Stratum;
_syntacticFS = word.SyntacticFeatureStruct;
_realizationalFS = word.RealizationalFeatureStruct;
_nonHeadCount = word.NonHeadCount;
_ruleCounts = word.UnappliedRuleCounts;

// Word.FreezeImpl deliberately leaves SyntacticFeatureStruct unfrozen, and
// AnalysisAffixTemplateRule.Apply mutates it in place on already-frozen Words -- no
// Word-level CheckFrozen guards that path. Freezing here pins the key's view of it, so a
// future rule mutating an already-keyed word throws instead of silently corrupting the table.
// Freeze is idempotent.
_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 rather than the usual *31 rolling combine: the multiset is unordered, so entries
// accumulated in different unapplication orders must still 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;
}
}
}
61 changes: 47 additions & 14 deletions src/SIL.Machine.Morphology.HermitCrab/AnalysisStratumRule.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using SIL.Machine.Annotations;
using SIL.Machine.Rules;
using SIL.ObjectModel;
Expand Down Expand Up @@ -47,19 +48,18 @@ 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
_mrulesRule =
morpher.MaxDegreeOfParallelism == 1
? (RuleCascade<Word, ShapeNode>)
new MemoizedCombinationRuleCascade(mrules, FreezableEqualityComparer<Word>.Default)
: new ParallelCombinationRuleCascade<Word, ShapeNode>(
mrules,
true,
FreezableEqualityComparer<Word>.Default
)
{
MaxDegreeOfParallelism = morpher.MaxDegreeOfParallelism,
};
break;
}
}
Expand Down Expand Up @@ -188,9 +188,42 @@ private IEnumerable<Word> ApplyMorphologicalRules(Word input)
}
}

// Counterparts to MemoizedCombinationRuleCascade's counters, for the template table.
internal static long DiagTemplateMemoHits;
internal static long DiagTemplateNogoodHits;

// The affix-template battery, memoized by AnalysisStateKey against its own table. On
// template-heavy grammars this dominates parse time, which is why it is memoized separately from
// the mrule cascade. See AnalysisScope.InProgress for why no re-entry guard is needed here.
private IEnumerable<Word> ApplyTemplateBattery(Word input)
{
// Scope presence is the single source of truth for whether the memo is active; Morpher decides
// that once, at install time. Linear strata stay unmemoized because the key-completeness audit
// covers only the Unordered cascade.
AnalysisScope scope = input.AnalysisScope;
if (scope == null || _stratum.MorphologicalRuleOrder != MorphologicalRuleOrder.Unordered)
return _templatesRule.Apply(input);

var key = new AnalysisStateKey(input);
if (scope.TryReplay(scope.TemplateMemo, key, input, out List<Word> replayed))
{
if (replayed.Count == 0)
{
Interlocked.Increment(ref DiagTemplateNogoodHits);
return replayed;
}
Interlocked.Increment(ref DiagTemplateMemoHits);
return replayed;
}

var results = new List<Word>(_templatesRule.Apply(input));
scope.Store(scope.TemplateMemo, key, input, results);
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
Loading
Loading