Skip to content

Dispatch operations to address space fragments without Streams - #2120

Merged
kevinherron merged 2 commits into
perf/lock-free-node-attribute-readsfrom
perf/address-space-routing-loops
Oct 12, 2026
Merged

kevinherron merged 2 commits into
perf/lock-free-node-attribute-readsfrom
perf/address-space-routing-loops

Conversation

@kevinherron

@kevinherron kevinherron commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Part 3 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

Every service that takes a list of operations routes each one to the address space fragment that owns it, then puts the results back in request order. For a Read of 100 Variables that is 100 lookups plus the access check's internal Read of 400 more attributes. Both steps ran a Stream pipeline per call:

  • AddressSpaceComposite.getAddressSpace ran a filter().findFirst() over the fragments for every operation, and CompositeAddressSpaceFilter ran an anyMatch over its children for every nested test.
  • GroupMapCollate.groupMapCollate, used by 33 call sites across the services, ran groupingBy and three collect calls per request.

In a profile of 10 clients reading batches of 100 Variables, taken with part 2 (#2119) applied, the routing lookups were about 12% of server CPU and GroupMapCollate's stream collection about 11%, more than the attribute reads themselves. Separately, ManagedAddressSpace.read and write built their DEBUG log arguments for every operation even at INFO: getNodeId() through the attribute filter chain, and an AttributeId lookup mapped through an Optional. That was about 2% of server CPU.

Change

Two commits.

Dispatch operations to address space fragments without Streams. getAddressSpace and every CompositeAddressSpaceFilter method use plain loops. GroupMapCollate groups item indices in a LinkedHashMap and collates the results into an array. Routing is unchanged: the first fragment whose filter accepts an operation handles it, and an empty fragment is built only when none does (the fix from #2098 is kept).

GroupMapCollate keeps its contract, which had no tests: each mapper is called once with a new mutable list of its group's items in request order; results come back in request order; a mapper that returns the wrong number of results is rejected, even if it changed the list it was given; a null key still throws NullPointerException. One thing changes: groups are now visited in first-seen order rather than HashMap order, which was never specified.

Build ManagedAddressSpace read and write log arguments only for DEBUG. The DEBUG messages are unchanged. With DEBUG off, a custom attribute filter no longer sees the incidental getNodeId() call those arguments made.

Results

Same benchmark setup as the rest of the stack: open62541 C clients, default configuration, Ryzen 9 7950X with the server on 16 CPUs, median of 6 runs.

Workload 1.2 Before this PR With this PR Change
async, 10 clients, batch of 100 read 57,564 60,708 118,137 +95%
sync, 1 client, batch of 100 read 6,408 6,495 9,890 +52%
async, 10 clients, scalar read 540,578 543,592 595,458 +10%
sync, 1 client, scalar read 34,382 32,268 36,604 +13%

Tests

  • New GroupMapCollateTest (sdk-core): results follow request order across interleaved groups, including a group larger than the initial index capacity; each mapper sees its group once, in order; a mapper that clears its input still has its results collated; a mapper that returns too few results is rejected; a null key is rejected.
  • AddressSpaceCompositeRoutingTest covers first-match routing and the lazily built empty fragment.

Verification

  • GroupMapCollateTest: 6 tests pass. AddressSpaceCompositeRoutingTest: 8 tests pass.
  • 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) in two rounds. It checked all synchronous GroupMapCollate callers for dependence on group order, list type, or key semantics. Its round-1 finding, that a mapper which mutated its input could defeat the result-count check, is fixed and covered by the two mutation tests.

@kevinherron
kevinherron added this pull request to stack #2124 October 12, 2026 15:28
@kevinherron kevinherron changed the title perf/address space routing loops Dispatch operations to address space fragments without Streams Oct 12, 2026
@greptile-apps

greptile-apps Bot commented Oct 12, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[Medium impact] This PR appears safe to merge; no actionable issues were found.

Summary

This PR replaces stream-based address-space routing and grouped result collection with loops.

  • Grouped request results keep their original order with less bookkeeping.
  • Address-space routing checks fragments with direct loops.
  • Read and write logs skip detail work when debug is off.

No actionable issues were found. Tests were inspected, not run.

Reviews (1) · Last reviewed commit: "Build ManagedAddressSpace read and write..." · Reviewed by Greptile

AddressSpaceComposite finds the fragment for each operation of a
request, such as each ReadValueId of a Read, and GroupMapCollate groups
the operations by fragment and puts the results back in request order.
Both ran a Stream pipeline per call: a findFirst over the fragments for
every lookup, an anyMatch over child filters for every nested
composite test, and groupingBy plus three collects per request. In
profiles of 100-node Reads these pipelines, not the attribute reads,
were the largest per-operation cost after the access check.

getAddressSpace and CompositeAddressSpaceFilter now use plain loops,
and GroupMapCollate groups item indices in a LinkedHashMap and collates
into an array. Routing is unchanged: the first fragment whose filter
accepts an operation handles it, and an empty fragment is built only
when none does.

GroupMapCollate keeps its contract: each mapper is called once with a
new mutable list of its group's items in request order, results come
back in request order, a mapper that returns the wrong number of
results is rejected, and a null key still throws
NullPointerException. Groups are now visited in first-seen order
rather than HashMap order, which was never specified. The new
GroupMapCollateTest covers this contract, which had no tests.
read and write logged every operation at DEBUG, and evaluated the
arguments first whatever the log level: getNodeId through the
attribute filter chain, and an AttributeId lookup mapped through an
Optional. At the default INFO level that work was thrown away for
every attribute of every Read and Write, about 2% of server CPU in a
100-node Read benchmark. The arguments are now built only when DEBUG
is enabled.
@kevinherron
kevinherron force-pushed the perf/address-space-routing-loops branch from 8f94593 to 0ca952d Compare October 12, 2026 18:15
@kevinherron
kevinherron merged commit 9da7adc into integration/1.2 Oct 12, 2026
3 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