Files
lmxopcua/docs/drivers/AbCip-Performance.md

18 KiB
Raw Permalink Blame History

AB CIP — Performance knobs

Phase 3 of the AB CIP driver plan introduces a small set of operator-tunable performance knobs that change how the driver talks to the controller without altering the address space or per-tag semantics. They consolidate decisions that Kepware exposes as a slider / advanced page so deployments running into high-latency PLCs, narrow-CPU CompactLogix parts, or legacy ControlLogix firmware have an explicit lever to pull.

This document is the home for those knobs as PRs land. PR abcip-3.1 ships the first knob: per-device CIP Connection Size.

Connection Size

What it is

CIP Connection Size — the byte ceiling on a single Forward Open response fragment, set during the EtherNet/IP Forward Open handshake. Larger connection sizes pack more tags into a single CIP RTT (higher request-packing density, fewer round-trips for the same scan list); smaller connection sizes stay compatible with legacy or narrow-buffer firmware that rejects oversized Forward Open requests.

Family defaults

The driver picks a Connection Size from the per-family profile when the device-level override is unset:

Family Default Rationale
ControlLogix 4002 Large Forward Open — FW20+
GuardLogix 4002 Same wire protocol as ControlLogix
CompactLogix 504 5069-L1/L2/L3 narrow-buffer parts (5370 family)
Micro800 488 Hard cap on Micro800 firmware

These map straight to libplctag's connection_size attribute and match the defaults Kepware uses out of the box for the same families.

Override knob

AbCipDeviceOptions.ConnectionSize (int?, default null) overrides the family default for one device. Bind it through driver config JSON:

{
  "Devices": [
    {
      "HostAddress": "ab://10.0.0.5/1,0",
      "PlcFamily": "ControlLogix",
      "ConnectionSize": 504
    }
  ]
}

The override threads through every libplctag handle the driver creates for that device — read tags, write tags, probe tags, UDT-template reads, the @tags walker, and BOOL-in-DINT parent runtimes. There is no per-tag override; one Connection Size applies to the whole controller (matches CIP session semantics).

Valid range

[500..4002] bytes. This matches the slider Kepware exposes for the same family. Values outside the range fail driver InitializeAsync with an InvalidOperationException — there's no silent clamp; misconfigured devices fail loudly so operators see the problem at deploy time.

Value Behaviour
null Use family default (4002 / 504 / 488)
499 or below Driver init fault — out-of-range
500..4002 Threaded through to libplctag
4003 or above Driver init fault — out-of-range

Legacy-firmware caveat

ControlLogix firmware v19 and earlier caps the CIP buffer at 504 bytes — Connection Sizes above that cause the controller to reject the Forward Open with CIP error 0x01/0x113. The 5069-L1/L2/L3 CompactLogix narrow parts are subject to the same cap.

The driver emits a warning via AbCipDriverOptions.OnWarning when the configured Connection Size exceeds 511 and the device's family profile default is also at-or-below the legacy cap (i.e. CompactLogix with default 504, or Micro800 with default 488). Production hosting should wire OnWarning to the application logger; the unit tests (AbCipConnectionSizeTests) collect into a list to assert which warnings fired.

The warning fires once per device at InitializeAsync. It does not block initialisation — operators may need the override anyway when running newer CompactLogix firmware that does support the larger Forward Open. The controller will reject the connection at runtime if it can't honour the size, and that surfaces through the standard IHostConnectivityProbe channel.

Performance trade-off

Larger Connection Size Smaller Connection Size
More tags per CIP RTT — higher throughput Compatible with legacy / narrow firmware
Bigger buffers held by libplctag native (RSS impact) Lower memory footprint
Forward Open rejected on FW19- ControlLogix Always works (assuming ≥500)
Required for high-density scan lists Forces more round-trips — higher latency

For most FW20+ ControlLogix shops, the default 4002 is correct and the override is unnecessary. The override is mainly useful when:

  1. Migrating off Kepware with a controller-specific slider value already tuned in production — set Connection Size to match.
  2. Mixed-firmware fleets where some controllers are still on FW19 — set the legacy controllers explicitly to 504.
  3. CompactLogix L1/L2/L3 running newer firmware that supports a larger Forward Open than the family-default 504 — bump the override up.
  4. Micro800 never goes above 488; the override is for documentation / discoverability rather than capability change.

libplctag wrapper limitation

The libplctag .NET wrapper (1.5.x) does not expose connection_size as a public Tag property. The driver propagates the value via reflection on the wrapper's internal NativeTagWrapper.SetIntAttribute("connection_size", N) after InitializeAsync — equivalent to libplctag's plc_tag_set_int_attribute. Because libplctag native parses connection_size only at create time, this is best-effort until either:

  • the libplctag .NET wrapper exposes ConnectionSize directly (planned in the upstream backlog), in which case the reflection no-ops cleanly, or
  • libplctag native gains post-create hot-update for connection_size, in which case the call lands as intended.

In the meantime the value is correctly stored on DeviceState.ConnectionSize

  • surfaces in every AbCipTagCreateParams the driver builds, so the override is observable end-to-end through the public driver surface and unit tests even if the underlying wrapper isn't yet honouring it on the wire.

Operators who need guaranteed Connection Size enforcement against FW19 controllers today can pin libplctag to a wrapper version that exposes ConnectionSize once one is available, or run a libplctag native build patched for runtime updates. Both paths are tracked in the AB CIP plan.

See also

Addressing mode

What it is

CIP exposes two equivalent ways to address a Logix tag on the wire:

  1. Symbolic — the request carries the tag's ASCII name and the controller parses + resolves the path on every read. This is the libplctag default and what every previous driver build has used.
  2. Logical — the request carries a CIP Symbol Object instance ID (a small integer assigned by the controller when the project was downloaded). The controller skips ASCII parsing entirely; the lookup is a single instance-table dereference.

Logical addressing is faster on the controller side and produces smaller request frames. The trade-off is that the driver has to learn the name → instance-id mapping once, by reading the @tags pseudo-tag at startup, and the resolution step has to repeat after a controller program download (instance IDs are re-assigned).

Enum values

AbCipDeviceOptions.AddressingMode (AddressingMode enum, default Auto) takes one of three values:

Value Behaviour
Auto Driver picks. Currently resolves to Symbolic — a future PR will plumb a real auto-detection heuristic (firmware version + symbol-table size).
Symbolic Force ASCII symbolic addressing on the wire. The historical default.
Logical Use CIP logical-segment / instance-ID addressing. Triggers a one-time @tags walk at the first read; subsequent reads consult the cached map.

Auto is documented as "Symbolic-for-now" so deployments setting Auto explicitly today will silently flip to a real heuristic when one ships, matching the spirit of the toggle. Operators who want to pin the wire behaviour should set Symbolic or Logical directly.

Family compatibility

Logical addressing depends on the controller implementing CIP Symbol Object class 0x6B with stable instance IDs. Older AB families don't:

Family Logical addressing supported? Why
ControlLogix yes Native class 0x6B support, FW10+
CompactLogix yes Same wire protocol as ControlLogix
GuardLogix yes Same wire protocol; safety partition is tag-level, not addressing-level
Micro800 no Firmware does not implement class 0x6B; instance-ID reads trip CIP "Path Segment Error" 0x04
SLC500 / PLC5 no Pre-CIP families; PCCC bridging only — no Symbol Object at all

When AddressingMode = Logical is set on an unsupported family, the driver falls back to Symbolic with a warning (via OnWarning) instead of faulting. This keeps mixed-firmware deployments working — operators can ship a uniform "Logical" config across the fleet and let the driver downgrade the families that can't honour it.

The driver-level decision is exposed via PlcFamilies.AbCipPlcFamilyProfile.SupportsLogicalAddressing and resolved at AbCipDriver.InitializeAsync time; the resolved mode is stored on DeviceState.AddressingMode and threaded through every AbCipTagCreateParams from then on.

One-time symbol-table walk

The first read on a Logical-mode device triggers a one-time @tags walk via LibplctagTagEnumerator (the same component used for opt-in controller browse). The driver caches the resulting name → instance-id map on DeviceState.LogicalInstanceMap; subsequent reads consult the cache without issuing another walk. The walk is gated by a per-device SemaphoreSlim so parallel first-reads serialise on a single dispatch.

The walk happens in AbCipDriver.EnsureLogicalMappingsAsync and runs only for devices that have actually resolved to Logical. Symbolic-mode devices skip the walk entirely. Walk failures are non-fatal: the LogicalWalkComplete flag still flips to true so the driver does not re-attempt indefinitely, and per-tag handles fall back to Symbolic addressing on the wire (libplctag's default).

A controller program download invalidates the instance IDs. There is no auto-invalidation today — operators trigger a fresh walk by either restarting the driver or calling RebrowseAsync (the same surface that clears the UDT template cache) with logic-mode plumbing extended in a future PR. For now, restart-on-download is the recommended workflow.

libplctag wrapper limitation

The libplctag .NET wrapper (1.5.x) does not expose a public knob for instance-ID addressing. The driver translates Logical-mode params into libplctag attributes via reflection on NativeTagWrapper.SetAttributeString("use_connected_msg", "1") + SetAttributeString("cip_addr", "0x6B,N") — same best-effort fallback pattern as the Connection Size knob.

This means Logical mode is observable end-to-end through the public driver surface and unit tests today, but the actual wire behaviour remains Symbolic until either:

  • the upstream libplctag .NET wrapper exposes the UseConnectedMessaging + CipAddr properties on Tag directly (planned in the upstream backlog), in which case the reflection no-ops cleanly, or
  • libplctag native gains post-create hot-update for cip_addr, in which case the call lands as intended.

The driver-level bookkeeping (resolved mode, instance-id map, family compatibility, fall-back warning) is fully wired so the upgrade path is purely a wrapper-version bump.

Performance trade-off

Symbolic addressing Logical addressing
Works everywhere Requires Symbol Object class 0x6B
ASCII parse on every read (controller-side cost) One-time walk; instance-id lookup thereafter
No first-read latency First read on a device pays the @tags walk
Smaller code surface Stale on program download — restart driver to re-walk
Best for small / sparse tag sets Best for >500-tag scans with stable controller

For scan lists in the single-digit-tag range, the per-poll ASCII parse cost is invisible. For medium scan lists (~100 tags) the gain is real but small — typically 510% per CIP RTT depending on tag-name length. The break-even point is where the ASCII-parse overhead starts dominating, roughly >500 tags in a tight scan loop, which is also where libplctag's own request-packing benefits compound. Large MES / batch projects with many UDT instances are the canonical case.

Driver config JSON

Bind the toggle through the driver-config JSON:

{
  "Devices": [
    {
      "HostAddress": "ab://10.0.0.5/1,0",
      "PlcFamily": "ControlLogix",
      "AddressingMode": "Logical"
    }
  ]
}

"Auto", "Symbolic", and "Logical" parse case-insensitively. Omitting the field defaults to "Auto".

See also

Read strategy (PR abcip-3.3)

A per-device toggle that controls how multi-member UDT batches are read. The default Auto value matches every previous build's behaviour for dense reads but switches to per-member bundling when only a handful of members of a large UDT are subscribed — the canonical "5 of 50" sparse-subscription case where reading the whole UDT buffer just to extract a few fields wastes wire bandwidth.

Three modes

Mode When to use
WholeUdt Most members of every subscribed UDT are read together. One libplctag read per parent UDT, members decoded in-memory at their byte offsets. The task #194 default.
MultiPacket A few members of a large UDT are subscribed at a time. One read per subscribed member, bundled per parent into one CIP Multi-Service Packet.
Auto (default) Planner picks per-batch from the subscribed-member fraction (see Sparsity threshold).

Sparsity threshold

Auto mode divides subscribedMembers / totalMembers for each parent UDT and picks MultiPacket when the fraction is strictly less than the threshold, else WholeUdt. Default threshold 0.25 — a 1/4 subscription is the rough break-even where the wire-cost of one whole-UDT read still beats N member reads on a ControlLogix 4002-byte connection-size buffer; above 1/4, the per-member overhead dominates.

Tune via AbCipDeviceOptions.MultiPacketSparsityThreshold (clamped to [0..1]). Threshold 0.0 = "never MultiPacket"; 1.0 = "always MultiPacket when any member is subscribed."

Family compatibility

MultiPacket requires CIP service 0x0A (Multi-Service Packet) on the controller. Source of truth is AbCipPlcFamilyProfile.SupportsRequestPacking:

Family SupportsRequestPacking
ControlLogix yes
CompactLogix yes
GuardLogix yes (wire identical to ControlLogix)
Micro800 no
SLC500 / PLC5 (when those profiles ship) no

User-forced MultiPacket against a non-packing family logs a warning at device init and falls back to WholeUdt. Auto against a non-packing family stays Auto at the device level — the per-batch heuristic caps the strategy to WholeUdt so the wire never sees a Multi-Service-Packet against a controller that can't decode it.

libplctag wrapper limitation

The libplctag .NET wrapper (1.5.x) does not expose the 0x0A service as a public knob — same wrapper-version constraint that gates PR abcip-3.1's connection_size and PR abcip-3.2's instance-ID addressing. Today's MultiPacket runtime therefore issues N libplctag reads sequentially when the planner picks the strategy; the wire-level bundling lands cleanly when an upstream wrapper release exposes the primitive.

The driver-level bookkeeping (resolved strategy, per-batch heuristic, family-compat fall-back, per-device dispatch counters) is fully wired so the upgrade path is a wrapper-version bump only — the planner already produces the right plan, and AbCipMultiPacketReadPlanner.Build is covered by unit tests that pin the plan shape rather than wire bytes.

Driver config JSON

{
  "Devices": [
    {
      "HostAddress": "ab://10.0.0.5/1,0",
      "PlcFamily": "ControlLogix",
      "ReadStrategy": "Auto",
      "MultiPacketSparsityThreshold": 0.25
    }
  ]
}

"Auto", "WholeUdt", and "MultiPacket" parse case-insensitively. Omitting the field defaults to "Auto". Omitting MultiPacketSparsityThreshold defaults to 0.25.

See also