perf(s7): span-grouped reads — one PDU per contiguous block instead of one per tag

This commit is contained in:
Joseph Doherty
2026-08-16 04:15:37 -04:00
parent 63081d52d8
commit 39f67a0fce
6 changed files with 1188 additions and 37 deletions
@@ -79,6 +79,56 @@ public sealed class S7DriverOptions
[Display(Name = "Probe timeout (seconds)", Description = "Connection test timeout. Default 5s.", GroupName = "Diagnostics")]
[Range(1, 60)]
public int ProbeTimeoutSeconds { get; init; } = 5;
/// <summary>
/// Largest hole, in bytes, the read planner will read THROUGH when merging two neighbouring
/// tags into one block request. It is a budget for deliberately wasted bytes: raising it
/// merges more aggressively (fewer wire requests, more bytes nobody asked for), lowering it
/// to <c>0</c> merges only tags that are strictly adjacent or overlapping.
/// </summary>
/// <remarks>
/// <para>
/// <b>Any value <c>&lt;= -1</c> disables span grouping entirely</b> — every tag is read
/// on its own, exactly as the driver behaved before grouping existed. That is the
/// escape hatch for a CPU that dislikes wide block reads, and the control arm the
/// parity tests read through.
/// </para>
/// <para>
/// Grouping cannot make a read fail where the per-tag reads would have succeeded: a
/// span never extends past its LAST member's end, so every byte it requests lies inside
/// a range some tag was already going to read. Timer and Counter tags are never grouped
/// — their offset is a device number, not a byte address.
/// </para>
/// </remarks>
[Display(Name = "Span read max gap (bytes)",
Description = "Largest hole read through when merging neighbouring tags into one block request. -1 disables grouping.",
GroupName = "Performance")]
public int SpanReadMaxGapBytes { get; init; } = DefaultSpanReadMaxGapBytes;
/// <summary>Default for <see cref="SpanReadMaxGapBytes"/>, shared with the config DTO so the two can't drift.</summary>
public const int DefaultSpanReadMaxGapBytes = 16;
/// <summary>
/// Largest single planned span, in bytes. A run of tags that would exceed it is broken into
/// several spans; a single tag wider than the cap still gets its own span, because one tag
/// is never split across requests.
/// </summary>
/// <remarks>
/// This is <b>not</b> a correctness bound: S7.Net transparently chunks a <c>ReadBytes</c>
/// call larger than the negotiated PDU into several wire requests, so an oversized span
/// reads correctly either way. The cap exists to bound wasted-gap amplification — without
/// it, a long sparse run of tags each separated by an under-budget hole would coalesce into
/// one enormous request that is mostly padding. The 220-byte default sits just under the
/// usable payload of the common 240-byte S7-300/400 PDU, so a typical span is one request
/// on the wire.
/// </remarks>
[Display(Name = "Span read max span (bytes)",
Description = "Largest single planned block read. Bounds wasted-gap amplification, not correctness.",
GroupName = "Performance")]
public int SpanReadMaxSpanBytes { get; init; } = DefaultSpanReadMaxSpanBytes;
/// <summary>Default for <see cref="SpanReadMaxSpanBytes"/>, shared with the config DTO so the two can't drift.</summary>
public const int DefaultSpanReadMaxSpanBytes = 220;
}
public sealed class S7ProbeOptions