Skip to content

Commit 12e88b4

Browse files
johnml1135claude
andcommitted
RUSTIFY: cache filtered annotation views on frozen lists (~4% less allocation)
The filtered+direction-sorted annotation list that TraversalMethodBase.Reset rebuilds on every Transduce call depends only on (annotation list, filter, direction) — not on which FST asks. Measured on the sena grammar, ~89% of the 63k Reset calls per 20 words re-derive a view already built for the same frozen list: COW clones share the frozen source's int projection, and rule filters are a handful of compiler-cached non-capturing lambdas, so the same (list, filter, direction) triple recurs across all the rules in a stratum. Reset now consults a small cache of filtered views chained on the AnnotationList itself (lock-free CAS publish; ~filters x directions entries). Only FROZEN lists cache — a frozen list and its annotations' FeatureStructs are immutable, so a view is final; unfrozen lists never cache because rules edit matched nodes' FeatureStructs in place, which would silently invalidate a cached view. Shape.Freeze() now freezes the (already-final) int projection so the gate applies to it, which also hardens the COW invariant: unexpected mutation of a shared projection now throws instead of corrupting. Byte-identical: 827 SIL.Machine + 63 HermitCrab tests pass, and the 60-word uncapped sena signature diff is empty. Measured ~3.7% less per-word allocation (thread-local counter at dop=1) plus the skipped tree-walk + insertion sort on every cache hit; dop=16 throughput unchanged-to-better. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c3c5239 commit 12e88b4

3 files changed

Lines changed: 104 additions & 4 deletions

File tree

src/SIL.Machine/Annotations/AnnotationList.cs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,57 @@ internal void IncrementVersion()
4242
_version++;
4343
}
4444

45+
// Cache of filtered+direction-sorted annotation views for FST traversal (see
46+
// TraversalMethodBase.Reset). Only populated on FROZEN lists — a frozen list (and its
47+
// annotations' FeatureStructs) is immutable, so the filtered view is final; for unfrozen
48+
// lists a rule's in-place FeatureStruct edit could silently invalidate a cached view, so
49+
// they never cache. Keyed by filter-delegate reference: filters come from a handful of
50+
// compiler-cached non-capturing lambdas (one per rule-class call site), so the chain stays
51+
// tiny (≤ filters × directions). Lock-free CAS publish; a lost race just rebuilds once.
52+
private sealed class FilteredView
53+
{
54+
internal readonly object Filter;
55+
internal readonly Direction Direction;
56+
internal readonly List<Annotation<TOffset>> Annotations;
57+
internal readonly FilteredView Next;
58+
59+
internal FilteredView(
60+
object filter,
61+
Direction direction,
62+
List<Annotation<TOffset>> annotations,
63+
FilteredView next
64+
)
65+
{
66+
Filter = filter;
67+
Direction = direction;
68+
Annotations = annotations;
69+
Next = next;
70+
}
71+
}
72+
73+
private FilteredView _filteredViews;
74+
75+
internal List<Annotation<TOffset>> GetFilteredView(object filter, Direction dir)
76+
{
77+
for (FilteredView v = _filteredViews; v != null; v = v.Next)
78+
{
79+
if (ReferenceEquals(v.Filter, filter) && v.Direction == dir)
80+
return v.Annotations;
81+
}
82+
return null;
83+
}
84+
85+
internal void AddFilteredView(object filter, Direction dir, List<Annotation<TOffset>> annotations)
86+
{
87+
while (true)
88+
{
89+
FilteredView head = _filteredViews;
90+
var entry = new FilteredView(filter, dir, annotations, head);
91+
if (System.Threading.Interlocked.CompareExchange(ref _filteredViews, entry, head) == head)
92+
return;
93+
}
94+
}
95+
4596
public AnnotationList()
4697
: base(new AnnotationComparer(), begin => new Annotation<TOffset>(Range<TOffset>.Null)) { }
4798

src/SIL.Machine/Annotations/Shape.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,11 @@ public void Freeze()
258258
// than racing a lazy first build of the offset dictionaries. No extra work overall: a frozen
259259
// shape that is frozen is one that will be traversed (by itself or its COW clones).
260260
EnsureIntProjection();
261+
// Freeze the (final) projection so the FST traversal can cache filtered views on it
262+
// (AnnotationList.GetFilteredView gates on IsFrozen — for an unfrozen list, in-place
263+
// FeatureStruct edits could silently invalidate a cached view). Also fail-fast hardens
264+
// the COW invariant: any unexpected mutation of a shared projection now throws.
265+
_intAnnotations.Freeze();
261266
}
262267

263268
// Maps a ShapeNode annotation range to its int-offset range using the dense per-projection node

src/SIL.Machine/FiniteState/TraversalMethodBase.cs

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ internal abstract class TraversalMethodBase<TData, TOffset, TInst> : ITraversalM
1818
private bool _startAnchor;
1919
private bool _endAnchor;
2020
private bool _useDefaults;
21-
private readonly List<Annotation<TOffset>> _annotations;
21+
22+
// Either this method's own scratch list (built by Reset) or a shared filtered view cached on
23+
// a frozen AnnotationList (see Reset). When shared (_annotationsShared), it must never be
24+
// mutated — traversal only reads it after Reset, so the only guarded site is Reset's Clear().
25+
private List<Annotation<TOffset>> _annotations;
26+
private bool _annotationsShared;
2227

2328
// Instance free-list, kept across Reset() calls so a traversal method pooled for the
2429
// duration of one word (see Fst.Transduce + Morpher per-word reset) reuses instances across
@@ -34,7 +39,8 @@ internal abstract class TraversalMethodBase<TData, TOffset, TInst> : ITraversalM
3439
protected TraversalMethodBase(Fst<TData, TOffset> fst)
3540
{
3641
_fst = fst;
37-
_annotations = new List<Annotation<TOffset>>();
42+
// _annotations is created lazily in Reset: on the (common) cached-view hit path this
43+
// method never needs a scratch list of its own.
3844
_cachedInstances = new Queue<TInst>();
3945
_insertAnnotation = InsertAnnotation;
4046
}
@@ -50,11 +56,49 @@ public void Reset(TData data, VariableBindings varBindings, bool startAnchor, bo
5056
_startAnchor = startAnchor;
5157
_endAnchor = endAnchor;
5258
_useDefaults = useDefaults;
53-
_annotations.Clear();
59+
60+
// The filtered+sorted list built below depends only on (annotation list, filter,
61+
// direction) — NOT on which FST asks — and on the sena grammar ~89% of Transduce calls
62+
// re-derive a view that was already built for the same frozen list (COW clones share the
63+
// frozen source's projection, and rule filters are a handful of compiler-cached lambdas).
64+
// Frozen lists are immutable, so a cached view is final; unfrozen lists never cache
65+
// (their annotations' FeatureStructs can be edited in place, silently invalidating a
66+
// cached view).
67+
AnnotationList<TOffset> annList = _data.Annotations;
68+
bool cacheable = annList.IsFrozen;
69+
if (cacheable)
70+
{
71+
List<Annotation<TOffset>> cached = annList.GetFilteredView(_fst.Filter, _fst.Direction);
72+
if (cached != null)
73+
{
74+
_annotations = cached;
75+
_annotationsShared = true;
76+
return;
77+
}
78+
}
79+
80+
if (_annotations == null || _annotationsShared)
81+
{
82+
_annotations = new List<Annotation<TOffset>>();
83+
_annotationsShared = false;
84+
}
85+
else
86+
{
87+
_annotations.Clear();
88+
}
5489
// insertion sort (PreorderTraverse with a cached delegate — same depth-first order as
5590
// GetNodesDepthFirst but no per-call yield-iterator allocation; see _insertAnnotation).
56-
foreach (Annotation<TOffset> topAnn in _data.Annotations.GetNodes(_fst.Direction))
91+
foreach (Annotation<TOffset> topAnn in annList.GetNodes(_fst.Direction))
5792
topAnn.PreorderTraverse(_insertAnnotation, _fst.Direction);
93+
94+
if (cacheable)
95+
{
96+
// Publish for the next Transduce against the same frozen list. This method keeps
97+
// using the (now-shared) list read-only; mark it shared so a hypothetical re-Reset
98+
// of this method starts a fresh scratch list instead of clearing the published one.
99+
annList.AddFilteredView(_fst.Filter, _fst.Direction, _annotations);
100+
_annotationsShared = true;
101+
}
58102
}
59103

60104
private void InsertAnnotation(Annotation<TOffset> ann)

0 commit comments

Comments
 (0)