Skip to content

Improve bulk-read throughput and reduce transport allocations - #20

Merged
jogibear9988 merged 3 commits into
masterfrom
codex/bulk-read-performance
Oct 14, 2026
Merged

jogibear9988 merged 3 commits into
masterfrom
codex/bulk-read-performance

Conversation

@jogibear9988

Copy link
Copy Markdown
Member

Large symbolic reads previously added avoidable PLC exchanges by splitting logical requests at transport-frame boundaries. This change batches against the negotiated item limit and protocol length bound, lets the transport fragment once, and adds opt-in pipelining on the existing TCP/TLS connection.

Changes

  • Add ReadPipelineDepth from 1–16, defaulting to 1. Match responses by sequence number, validate integrity, and restore caller order even when responses arrive out of order. Close the transport on pipeline failure and publish no partial tag updates.
  • Reserve response dispatch throughout foreground exchanges so subscription polling cannot consume read responses.
  • Decode bulk-read values directly into ordered staging buffers, replace the asynchronous receive-channel bridge with a synchronous FIFO, and calculate request lengths without temporary serialization.
  • Reduce scratch allocations with targeted spans, stack storage, shared immutable boolean values, direct UTF-8 decoding, and pooled transport buffers.
  • Pass borrowed TLS buffer slices internally while preserving owned exact-length buffers for public callbacks. Validate active payload lengths and send actual fragment lengths rather than pooled capacity.
  • Respect the optional response-object flag in CreateObject replies. After long read runs, an integrity-counter prefix such as 0xAB must not be mistaken for a variable-type list.
  • Document pipeline configuration and per-CPU tuning. Writes retain the existing explicit enable gate.

Validation

  • 822 tests passed across net8.0 and net48, with no skips. Release builds passed for net48/net6.0/net8.0/net9.0 without warnings or errors.
  • Regression coverage includes bounded pipelines, reversed responses, sequence wrap, integrity/transport failures, notification routing, decoder errors, codec boundaries, borrowed-buffer ownership, and COTP fragmentation.
  • All 110 captured CreateObject response fixtures replayed successfully.
  • Read-only live checks passed on TLS and legacy PLCs with zero stable value mismatches, concurrent tag/alarm subscriptions, and 20/5 additional subscription create/read/dispose cycles. No PLC values were written or alarms acknowledged; active alarm text was not exercised.
  • On the TLS PLC, the latest 21,464-signal median was 167.9 ms at depth two versus 304.6 ms sequentially. The span/buffer pass reduced depth-two cycle allocations from approximately 6.93 MB to 4.29 MB (38%).
  • On the legacy PLC, 7,787 signals measured 1,093.5 ms at depth one. Depth two did not help that CPU; keep the default unless measurements justify increasing it.

Live timings vary with PLC and host load, so these results do not establish a universal speedup. This PR contains the three committed driver changes and regression tests; local investigation artifacts are excluded.

Large symbolic read batches were split twice: first at the PLC's negotiated
item limit, then again to fit each logical request into a single transport
frame. Transport fragmentation already supports larger requests, so the
second split added avoidable request/response exchanges and cycle latency.
A captured 10,000-signal cycle previously required 200 responses; it now
requires 100 while preserving the PLC's 100-item request limit.

Build read batches against the PLC item limit and the protocol's 16-bit
PDU data-length bound. Reserve space for the legacy digest and growth of
the integrity identifier assigned during send. Size ordinary batches with
one serialization and retain the bounded payload search for unusually
long addresses. Send GetMultiVariables requests as one logical PDU and
leave fragmentation to the transport.

Remove the TLS callback's additional fixed 960-byte splitting. Fragment
records once at the negotiated COTP boundary, preserve the supplied data
length, and set EOT only on the final fragment. Write batch sizing and the
explicit write-enable gate remain unchanged.

Add regression coverage for full and final partial read batches across
transport frames, protocol length limits with long addresses, and payload
and EOT preservation for both plain sends and TLS callback data.

Validation:
- 301 tests passed on .NET 8; 296 passed on .NET Framework 4.8.
- Release builds passed for net48, net6.0, net8.0 and net9.0 with no warnings.
- Read-only live validation covered 21,464 signals across 59 data blocks.
  Median cycle time decreased from 499 ms to 331 ms across 20 measured
  cycles. No value mismatches were found among 21,421 unchanged signals;
  43 signals changed between comparison reads. No PLC writes were made.
Add opt-in ReadPipelineDepth (1-16, default 1) to keep a bounded number
of read requests outstanding on the existing TCP/TLS connection. Pass
complete bulk reads to the protocol session so high-level batching does
not serialize the pipeline prematurely. Each request still respects the
PLC item limit and protocol PDU length limit.

Match responses by sequence number, validate integrity identifiers, and
restore values and per-item errors to caller order even when responses
arrive out of order. Abort the logical read and close the transport on
transport or validation failure, preventing pending responses from being
reused by a subsequent operation. Do not publish partial tag updates
when the bulk operation fails.

Reserve response dispatch before sending acknowledged exchanges and
throughout pipelined reads. This prevents subscription polling from
consuming foreground responses; encountered notifications remain queued
for their subscribers. Other operations retain existing serialization
and write safety gates.

Remove per-field UInt32 VLQ scratch-array allocations with a one-byte
fast path and stack-based encoding. Preserve wire bytes and support all
target frameworks, including the .NET Framework stream API.

Document configuration, per-CPU measurement, and refreshing communication
resources after connecting, especially for legacy fallback item limits.

Validation:
- 329 tests passed on net8.0 and 323 on net48, with no skips.
- Release driver builds passed for net48/net6.0/net8.0/net9.0 with no
  warnings or errors; staged whitespace checks passed.
- Cover bounded windows, out-of-order responses, sequence wrap, partial
  batches, item failures, malformed responses, integrity/transport
  failures, option validation, and response-dispatch reservation.
- Verify UInt32 VLQ boundary encodings and allocation-free modern path.
- On a TLS CPU, 21,464 identical signals improved from a 338 ms median
  sequential cycle to 181 ms at depth two across 20 samples per mode.
- On a legacy CPU, 7,787 signals measured 1,094 ms sequentially versus
  1,136 ms at depth two across 10 samples; retain depth one there.
- Stable live values matched, and foreground reads with tag/alarm
  subscriptions succeeded on the same connection on both CPUs.
- Live validation performed reads only, without writes or alarm
  acknowledgements. Active alarm text was not exercised.
Decode bulk-read responses directly into ordered staging buffers instead of
building per-response dictionaries. Validate item indices and duplicates,
preserve per-item errors and missing values, and retain sequence/integrity
checks before publishing the complete read result.

Replace the asynchronous channel bridge with a monitor-based receive FIFO for
the synchronous protocol worker. Preserve timeout, completion and receive-error
behavior while removing per-response task and cancellation-source allocations.
Remove the now-unused net48 channel dependency.

Calculate serialized read-request lengths without encoding temporary payloads,
preallocate request/result buffers, send the active MemoryStream buffer directly,
and reuse immutable flag-zero scalar boolean values.

Remove numeric and VLQ scratch arrays with targeted spans, stack storage and
allocation-free IEEE bit conversion. Decode UTF-8 directly from exposed stream
storage; use stack or pooled buffers for UTF-8 encoding. Preserve byte order,
VLQ boundary behavior, stream origins and floating-point bit patterns.

Deliver TLS plaintext and encrypted record slices through internal synchronous
borrowed-buffer callbacks. Preserve owned exact-length buffers for public
callbacks. Pool COTP fragments, send only their actual wire lengths, and validate
active receive lengths so spare buffer capacity cannot become parsed payload.

Fix CreateObject response parsing to honor the optional response-object flag.
After long read runs, an integrity counter can begin with an object-like marker
such as 0xAB; it must not be decoded as a variable-type list when no object is
present. Keep diagnostic formatting safe for responses without objects.

Validation:
- 822 tests passed across net8.0 and net48, with no skips.
- Release builds passed for net48/net6.0/net8.0/net9.0 without warnings or errors.
- All 110 captured CreateObject response fixtures replayed successfully.
- Read-only live checks passed on TLS and legacy PLCs, with zero stable value
  mismatches and 20/5 additional subscription create/read/dispose cycles.
- The 21,464-signal depth-two workload allocated approximately 4.29 MB per cycle
  versus 6.93 MB before the span/buffer pass, a reduction of about 38%.
- Latest read medians were 167.9 ms at depth two and 304.6 ms sequentially.
  Separate live runs vary; these timings do not establish a universal speedup.

No PLC values were written or alarms acknowledged.
@jogibear9988
jogibear9988 merged commit 2d42164 into master Oct 14, 2026
3 checks passed
@jogibear9988
jogibear9988 deleted the codex/bulk-read-performance branch October 14, 2026 14:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant