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
@@ -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();
}
}