Skip to content

Key write access results by WriteValue identity - #2123

Merged
kevinherron merged 1 commit into
perf/bulk-builtin-array-encodingfrom
perf/write-access-identity-keys
Oct 12, 2026
Merged

kevinherron merged 1 commit into
perf/bulk-builtin-array-encodingfrom
perf/write-access-identity-keys

Conversation

@kevinherron

@kevinherron kevinherron commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Part 6 of 6 (#2118 to #2123) of a stack of server performance changes, found by profiling the Milo server in the o6-automation/opcua-benchmarks suite.

Problem

DefaultAccessController.checkWriteAccess collected its results into a HashMap keyed by WriteValue, and DefaultAttributeServiceSet looked each result up with the same WriteValue. WriteValue.hashCode includes the DataValue being written, and Variant hashes arrays element by element. So every Write hashed its whole payload just to file and find its access result: a Write of a 921,600-element Int32 array spent about 6% of the server's CPU time there, and the cost grows with the size of every written array, ByteString and structure.

Change

checkWriteAccess returns an IdentityHashMap. The service set looks results up with the instances it passed in, so it finds the same results. The Javadoc of DefaultAccessController.checkWriteAccess documents what changes for other callers: equal WriteValues each get their own entry, and a lookup with an equal but different instance finds nothing.

The only production caller is DefaultAttributeServiceSet. Custom AccessController implementations, which return their own maps, are unaffected, as are the read and other access checks: ReadAccessCache and others may look up equal but different ReadValueIds, and those keys are cheap to hash.

Results

Same benchmark setup as the rest of the stack, default configuration (the benchmark server stores Integer[]; Writes decode to Integer[]).

Workload 1.2 Before this PR With this PR Change
sync, 1 client, HD array write 100 100 92 -8%
async, 10 clients, HD array write 224 230 288 +25%

The 10-client result is stable: all six runs with this PR land between 270 and 297, against 226 to 233 before. Single-client sync HD Writes are bimodal on every build, each run landing near 70 to 90 or near 100 to 130, so the single-client median does not show a reliable difference.

Most of a large Write's remaining cost is decoding into a boxed Integer[], which allocates about 18 MB of Integer objects per Write and keeps the garbage collector busy.

Tests

DefaultAccessControllerTest:

  • checkWriteAccess_DoesNotHashTheWriteValue passes a WriteValue whose hashCode throws. It fails on the parent commit.
  • checkWriteAccess_ResultsAreKeyedByIdentity checks two equal WriteValues get two entries, each found by its own instance, and an equal copy finds nothing.

Follow-ups

checkCallAccess has the same cost for large Method input arguments: its map is keyed by CallMethodRequest, which hashes its inputArguments. It is not changed here because it was not measured; its callers would need the same review.

Verification

  • DefaultAccessControllerTest: 38 tests pass; with OpcUaServerServiceSetsTest, 41.
  • Full build with tests on the top of the stack: 5,792 tests, no failures, 4 skipped.
  • Reviewed independently by GPT-6.1-Sol (Codex). It found no caller that looks results up with equal but different instances or depends on the map's order; its one nit, a test for the documented identity semantics, is addressed.

The whole stack

Requests per second, median of 6 measured runs from 3 interleaved rounds per build. open62541 C clients against the Milo benchmark server, default configuration, Ryzen 9 7950X with the server pinned to 16 CPUs that share physical cores with the clients' 16.

Workload 1.2 + PR 1 + PR 2 + PR 3 + PR 4 + PR 5 + PR 6
sync, 1 client, scalar read 34,382 31,280 32,268 36,604 36,902 39,474 37,663
sync, 1 client, batch of 100 read 6,408 6,499 6,495 9,890 10,215 10,131 10,321
async, 10 clients, scalar read 540,578 540,292 543,592 595,458 624,958 639,982 626,140
async, 10 clients, batch of 100 read 57,564 55,460 60,708 118,137 129,422 129,224 127,788
async, 1 client, HD array read 164 163 163 164 164 200 170
async, 10 clients, HD array read 456 456 451 452 459 511 512
sync, 1 client, HD array write 100 76 92 96 102 100 92
async, 10 clients, HD array write 224 226 229 224 226 230 288

The single-client scalar rows vary by about ±10% between runs of the same build, and single-client HD Reads and sync HD Writes are bimodal (see parts 5 and 6), so read those rows as unchanged except where a part says otherwise. The 10-client rows are stable.

Single-client latency is the largest remaining gap to the open62541 C server. Each request crosses from the Netty event loop to the service executor and back, about 8 µs per request; that is not addressed in this stack.

@greptile-apps

greptile-apps Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; the server's write flow already supports identity-based lookup.

Summary

checkWriteAccess now keys results by WriteValue identity, avoiding calls to the value's hashCode.

  • Write results stay tied to the exact request objects.

Reviews (1) · Last reviewed commit: "Key write access results by WriteValue i..." · Reviewed by Greptile

DefaultAccessController.checkWriteAccess collected its results into a
HashMap keyed by WriteValue, and DefaultAttributeServiceSet looked each
result up with the same WriteValue. WriteValue.hashCode includes the
DataValue being written, and Variant hashes arrays element by element,
so every Write hashed its whole payload just to file the access
result. For a Write of a 921,600-element Int32 array that was about 6%
of the server's CPU time, and the cost grows with the size of every
written array, ByteString and structure.

The results are now in an IdentityHashMap. The service set looks them
up with the instances it passed in, so it finds the same results. The
difference for other callers of DefaultAccessController is documented:
equal WriteValues each get their own entry, and a lookup with an equal
but different instance finds nothing. Custom AccessController
implementations are unaffected.
@kevinherron
kevinherron force-pushed the perf/write-access-identity-keys branch from 4a0ba4b to 54793e2 Compare October 12, 2026 18:15
@kevinherron
kevinherron merged commit 9da7adc into integration/1.2 Oct 12, 2026
5 checks passed
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