Skip to content

perf(plc4j/spi/buffers): speed up byte-aligned integer read/write#2650

Merged
sruehl merged 4 commits into
apache:developfrom
LivingLikeKrillin:feature/buffer-context-arraydeque
Jul 21, 2026
Merged

perf(plc4j/spi/buffers): speed up byte-aligned integer read/write#2650
sruehl merged 4 commits into
apache:developfrom
LivingLikeKrillin:feature/buffer-context-arraydeque

Conversation

@LivingLikeKrillin

@LivingLikeKrillin LivingLikeKrillin commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

What & why

The shared byte buffers in plc4j/spi/buffers sit on every driver's hottest path — every
primitive field of every message is read/written through them (~70+ files across plc4j — drivers,
spi, transports, test-utils — construct these byte buffers). Profiling a 32× uint16 batch (a
typical Modbus/S7-style read) with JMH showed two avoidable costs per field:

  1. the option-context stack was a java.util.Stack (a synchronized Vector), so every
    getContext() did a monitor enter/exit that guarded nothing; and
  2. every whole-byte, byte-aligned integer field still went through the generic bit-by-bit path,
    allocating an intermediate byte[] and a fresh ByteOrder object per field.

This change removes both. Wire behaviour is preserved on every previously-correct path; the review
follow-ups below additionally fixed a pre-existing alignment bug for buffers and sub-buffers that
start at a non-byte-aligned bit position (which affected the whole-byte paths on develop too).

Changes

  • AbstractBuffer — use ArrayDeque for the option-context stack instead of java.util.Stack.
    A buffer is a single-threaded, per-message scratch object (positionInBits and the backing array
    are already unsynchronized), so Stack's synchronization protected nothing here.
  • ByteOrderBigEndian — expose a shared stateless INSTANCE and return it from getByteOrder()
    instead of allocating one per field (process() is the identity, so the instance is reusable).
  • Read/WriteBufferByteBased (with shared guards + a signExtend helper in
    AbstractBufferByteBased) — add a zero-allocation fast path for whole-byte, byte-aligned,
    big-endian, plain-binary integer fields (unsigned and signed short/int/long) that reads/writes
    straight from the backing array. It is gated on the concrete EncodingUnsignedBinary /
    EncodingTwosComplement (not the broader EncodingDefault), so BCD / float / etc. fall through
    to the existing slow path unchanged.

Benchmarks

JMH, JDK 21, 32× uint16 batch, -prof gc:

metric improvement
read throughput ~6.4× faster
write throughput ~5.6× faster
allocation −38% (~1944 → ~1200 B/msg)

Thread-safety

No regression. Buffers are per-message throwaway objects — no pooling, no static/ThreadLocal
sharing — and positionInBits / the backing array were already unsynchronized, so each buffer is
confined to a single thread for the duration of one message's parse/serialize. The Stack's
synchronization was incidental, not an anti-interleaving guarantee: concurrent read/write requests
each get their own buffer. ArrayDeque is a drop-in — the code uses the context field only as a
LIFO stack via push/pop/peek/isEmpty/size, all of which behave identically, and never
iterates it or reads it by index.

Binary compatibility

Changing AbstractBuffer's context field type from Stack to Deque is source-compatible but
binary-incompatible for any subclass that reads the protected context field directly. Within the
repo exactly one class does so — WriteBufferXmlBased (context.isEmpty() / context.size()) —
and it compiles and recompiles cleanly against Deque; the other buffer subclasses
(ReadBufferXmlBased, the ascii-box and byte buffers) never touch the field. All three buffer
modules (byte, xml, ascii-box) recompile in a normal/CI build; only a stale incremental build
(Maven not recompiling the xml module after api changed), or an out-of-repo precompiled subclass
that reads context, would hit a NoSuchFieldError. Flagging it here for downstream awareness.

Review follow-ups (commits after the initial review)

  • Absolute-bit alignment fix — the fast-path guards (and, at root, isAligned(), which the
    pre-existing readBits/writeBits whole-byte arraycopy paths also use) tested only
    positionInBits, ignoring startBit; a sub-buffer created at a non-byte-aligned offset read/wrote
    the wrong bytes — on develop too. isAligned() now tests the absolute bit index, and writeBit
    (which ignored startBit entirely, unlike readBit) now indexes absolutely as well. Regression
    tests cover both directions.
  • Resolve-once — encoding/byte order are resolved once per field and reused by both the fast-path
    eligibility check and the slow path (previously a non-eligible aligned field resolved them twice).
  • Exact-class gates — the fast path is gated on getClass() == EncodingUnsignedBinary.class /
    EncodingTwosComplement.class / ByteOrderBigEndian.class rather than instanceof, so a codec
    subclass registered through the managers keeps its overridden (virtually-dispatched) slow-path
    behaviour instead of being silently bypassed.

Testing

  • Buffer unit tests green (Read 135, Write 116; 779 total in the buffers modules), including new
    regression tests: the unsigned byte-aligned fast path returns/emits the same value as the slow path
    (read & write), BCD-must-not-take-the-binary-fast-path (read & write), signed sign-extension /
    two's-complement (read & write), and the alignment fixes (a whole-byte read from a non-byte-aligned
    sub-buffer, and a write through a buffer constructed with a non-byte-aligned startBit).
  • Modbus driver module test suite green (213 tests).

The byte buffers sit on every driver's hottest path (every primitive
field of every message). Measured with JMH on a 32x uint16 batch (JDK 21):
~6x faster reads, ~5.6x faster writes, ~38% less allocation, with all 775
buffer tests (incl. fuzz) and the Modbus parser/serializer suite green.

- AbstractBuffer: use ArrayDeque for the option-context stack instead of
  java.util.Stack (a synchronized Vector). A buffer is a single-threaded,
  per-message scratch object -- positionInBits and the backing array are
  already unsynchronized -- so the monitor enter/exit on every getContext()
  guarded nothing. (Changing the protected context field type is
  binary-incompatible for subclasses that read it directly; all in-repo
  buffer modules recompile.)
- ByteOrderBigEndian: expose a shared stateless INSTANCE and return it from
  getByteOrder() instead of allocating one per field.
- Read/WriteBufferByteBased: add a zero-allocation fast path for whole-byte,
  byte-aligned, big-endian, plain-binary integer fields (unsigned and signed
  short/int/long) that reads/writes straight from the backing array. Gated
  on the concrete EncodingUnsignedBinary / EncodingTwosComplement (not the
  broader EncodingDefault) so BCD/float/etc. fall through to the slow path
  unchanged; covered by new regression tests.

Signed-off-by: Jooyoung Jung <livinglikekrillin@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes PLC4X’s byte-based buffers on the hot path by removing avoidable synchronization and allocations, and by adding a zero-allocation fast path for byte-aligned, whole-byte, big-endian integer reads/writes while preserving existing behavior for non-binary encodings.

Changes:

  • Replaced the option-context stack in AbstractBuffer from java.util.Stack to ArrayDeque to remove unnecessary synchronization overhead.
  • Added a reusable ByteOrderBigEndian.INSTANCE and updated default byte order resolution to reuse it instead of allocating per field.
  • Introduced guarded fast paths in ReadBufferByteBased / WriteBufferByteBased (shared helpers in AbstractBufferByteBased) and added regression tests to ensure binary vs BCD and signed sign-extension behavior remain correct.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
plc4j/spi/buffers/api/src/main/java/org/apache/plc4x/java/spi/buffers/api/AbstractBuffer.java Swaps context stack implementation to ArrayDeque for lower overhead.
plc4j/spi/buffers/byte/src/main/java/org/apache/plc4x/java/spi/buffers/bytebased/AbstractBufferByteBased.java Adds shared fast-path guards and signExtend, and reuses shared big-endian instance.
plc4j/spi/buffers/byte/src/main/java/org/apache/plc4x/java/spi/buffers/bytebased/ReadBufferByteBased.java Adds byte-aligned big-endian integer read fast path reading directly from backing array.
plc4j/spi/buffers/byte/src/main/java/org/apache/plc4x/java/spi/buffers/bytebased/WriteBufferByteBased.java Adds byte-aligned big-endian integer write fast path writing directly into backing array.
plc4j/spi/buffers/byte/src/main/java/org/apache/plc4x/java/spi/buffers/bytebased/byteorder/ByteOrderBigEndian.java Introduces shared stateless INSTANCE to avoid repeated allocations.
plc4j/spi/buffers/byte/src/test/java/org/apache/plc4x/java/spi/buffers/bytebased/ReadBufferByteBasedTest.java Adds tests guarding fast-path gating (BCD must not take binary path) and signed sign-extension.
plc4j/spi/buffers/byte/src/test/java/org/apache/plc4x/java/spi/buffers/bytebased/WriteBufferByteBasedTest.java Adds tests guarding fast-path gating (BCD) and signed two’s-complement emission.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…ub-buffers

isAligned() (used by the whole-byte arraycopy fast paths in readBits/writeBits, and
now by the integer fast-path guards) tested only positionInBits, ignoring startBit.
A sub-buffer created at a non-byte-aligned offset has a non-zero startBit while its
own positionInBits is 0, so a whole-byte read/write took the byte-copy fast path and
indexed the backing array by (startBit + positionInBits) / 8 -- truncating the sub-byte
offset and reading/writing from the wrong byte. Test it on the absolute bit index.

Also gate the integer fast-path guards on isAligned() (absolute) instead of
positionInBits alone, and use `getByteOrder() instanceof ByteOrderBigEndian` instead
of reference-equality with the singleton, so an explicitly big-endian-configured buffer
(ServiceLoader instance) is still eligible for the fast path.

Adds a regression test: a whole-byte unsigned read from a non-byte-aligned sub-buffer
now returns the correct value.

Signed-off-by: Jooyoung Jung <livinglikekrillin@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.

… field

The fast-path eligibility guards resolved the encoding and byte order, and
when the fast path was not taken (e.g. a little-endian buffer or a non-binary
encoding) the slow path resolved both again -- an extra context/manager lookup
per whole-byte aligned integer field. Resolve them once in each read/write
method and reuse the resolved values for both the eligibility check and the
slow path. The BE-specific guards collapse into a single structural
isByteAlignedWholeBytes check combined with instanceof tests on the already
resolved values. (EncodingRaw paths now resolve the byte order they do not
use -- a negligible constant -- in exchange for exactly-once resolution
everywhere else.)

Signed-off-by: Jooyoung Jung <livinglikekrillin@gmail.com>
…th on exact codec classes

Two hardening items in the same alignment/fast-path family as the previous
commits:

- writeBit indexed the backing array by positionInBits alone, ignoring
  startBit -- unlike readBit and the whole-byte arraycopy path in writeBits,
  which both use the absolute bit index. A WriteBufferByteBased constructed
  with a non-zero startBit therefore wrote from the start of the backing
  array. Index by (startBit + positionInBits), mirroring readBit, with a
  regression test (writeHonoursNonByteAlignedStartBit).

- The integer fast-path gates checked `instanceof EncodingUnsignedBinary` /
  `EncodingTwosComplement` / `ByteOrderBigEndian`. The slow path dispatches
  virtually, so a subclass registered through the Encoding/ByteOrder managers
  with overridden codec behaviour would have been silently bypassed whenever
  a field was byte-aligned. Gate on the exact class (getClass() == ...)
  instead: multiple instances remain eligible, subclasses fall through to the
  virtual-dispatch slow path.

Also document that writeAlignedBytesBE relies on the caller's
ensureAvailable(numBits) (it has no internal capacity check), and drop the
dead value mask in writeUnsignedShort's fast path (the range check already
guarantees a non-negative value, and the mask was inconsistent with the
other five write sites).

Signed-off-by: Jooyoung Jung <livinglikekrillin@gmail.com>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.

@sruehl
sruehl merged commit 361e322 into apache:develop Jul 21, 2026
7 checks passed
@LivingLikeKrillin
LivingLikeKrillin deleted the feature/buffer-context-arraydeque branch July 21, 2026 12:05
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.

3 participants