perf(abcip): cache the declaration-only UDT layout per member list

P11: AbCipUdtReadPlanner called AbCipUdtMemberLayout.TryBuild(parent.Members)
for every member reference on every poll - O(members^2) per parent per tick,
a dictionary allocation each time. GetOrBuild caches the layout (including a
"not groupable" verdict) in a ConditionalWeakTable keyed by the member list's
identity, which is immutable for the life of the tag table; a re-init's new
lists replace the entries and the old ones are collected with them.

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:31:32 -04:00
parent 7cc8415310
commit fc94b87634
3 changed files with 47 additions and 1 deletions
@@ -25,6 +25,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// </remarks>
public static class AbCipUdtMemberLayout
{
/// <summary>Layouts already computed, keyed by the parent definition's member list (reference identity).</summary>
private static readonly System.Runtime.CompilerServices.ConditionalWeakTable<IReadOnlyList<AbCipStructureMember>, LayoutBox> Cache = new();
/// <summary>Holds a computed layout, including a "not groupable" (<c>null</c>) verdict.</summary>
private sealed class LayoutBox(IReadOnlyDictionary<string, int>? offsets)
{
public IReadOnlyDictionary<string, int>? Offsets { get; } = offsets;
}
/// <summary>
/// <see cref="TryBuild"/>, computed once per member list (review 07 P11: the read planner rebuilt the layout —
/// a dictionary allocation and an O(members) walk — for every member reference on every poll, O(members²) per
/// parent per tick). A parent definition's member list is immutable for the life of the tag table that holds
/// it, so caching by list identity is exact; a re-init builds new lists and the old entries are collected with
/// them (<see cref="System.Runtime.CompilerServices.ConditionalWeakTable{TKey,TValue}"/>).
/// </summary>
/// <param name="members">The parent definition's declared members.</param>
/// <returns>The member offsets, or <c>null</c> when the members cannot be laid out by declaration.</returns>
public static IReadOnlyDictionary<string, int>? GetOrBuild(IReadOnlyList<AbCipStructureMember> members)
{
ArgumentNullException.ThrowIfNull(members);
return Cache.GetValue(members, m => new LayoutBox(TryBuild(m))).Offsets;
}
/// <summary>
/// Try to compute member offsets for the supplied declared members. Returns <c>null</c>
/// if any member type is unsupported for declaration-only layout.
@@ -67,7 +67,7 @@ public static class AbCipUdtReadPlanner
continue;
}
var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
var offsets = AbCipUdtMemberLayout.GetOrBuild(parent.Members);
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
@@ -99,4 +99,26 @@ public sealed class AbCipUdtMemberLayoutTests
new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4),
}).ShouldBeNull();
}
/// <summary>Review 07 P11: the layout is computed once per member list, not once per member per poll.</summary>
[Fact]
public void GetOrBuild_computes_a_layout_once_per_member_list()
{
IReadOnlyList<AbCipStructureMember> members =
[
new AbCipStructureMember("A", AbCipDataType.DInt),
new AbCipStructureMember("B", AbCipDataType.Real),
];
var first = AbCipUdtMemberLayout.GetOrBuild(members);
first.ShouldNotBeNull();
first["B"].ShouldBe(4);
AbCipUdtMemberLayout.GetOrBuild(members).ShouldBeSameAs(first);
// Positive control: a different (equal-content) list is its own entry, and a not-groupable verdict caches too.
IReadOnlyList<AbCipStructureMember> copy = [.. members];
AbCipUdtMemberLayout.GetOrBuild(copy).ShouldNotBeSameAs(first);
IReadOnlyList<AbCipStructureMember> withBool = [new AbCipStructureMember("F", AbCipDataType.Bool)];
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull();
}
}