Files
romanett 2c431ad728 Client/transport: one recv per small chunk, ManagedSession lock fast path, write coalescing (#4737)
Follow-ups from the "Not changed" list of #4720 (client per-request
allocation and CPU): three changes that the paired A/B shows a gain for,
plus a write-up of the fourth (discarded `ResponseHeader`), which is not
changed.

## Results

Paired A/B, each change against the commit before it: 4 alternating
rounds, medians, 20 sessions, 24-core machine shared with other
sessions. Client = the harness process, server = the reference server
child. Allocated bytes per operation are deterministic; throughput and
latency carry the run-to-run noise noted below.

| Change | Scenario | Throughput | p50 | p90 | Client CPU / op | Client
alloc / op | Server CPU / op |
|---|---|---|---|---|---|---|---|
| Receive staging | Read, 1 value | +4% | **−9%** | **−13%** | ≈ | ±0 |
≈ |
| | Read, 1 value, SignAndEncrypt | +6% | −6% | −11% | ≈ | ±0 | ≈ |
| | Read, 100 values | +2% | −4% | ≈ | ≈ | ±0 | ≈ |
| | Data changes | ≈ | ≈ | ≈ | ≈ | ±0 | ≈ |
| ManagedSession lock | Read, 1 value, ManagedSession | ≈ | ≈ | ≈ | ≈ |
**−125 B** (2632 → 2507) | ≈ |
| | Same, inflight 8 | ≈ | ≈ | ≈ | ≈ | −133 B | ≈ |
| Write coalescing | Read, 1 value, 20 sessions on one shared channel,
inflight 4 | **+13%** | **−13%** | −3% | −6% | ±0 | −4% |
| | Same, inflight 1 | +20% ¹ | −8% | ¹ | −9% | ±0 | −13% |
| | Read, 1 value, inflight 8 (one channel per session) | +2% | −4% |
−6% | ≈ | ±0 | ≈ |
| | Read, 100 values, inflight 8 | +3% | −4% | −8% | ≈ | ±0 | ≈ |
| | Data changes | ≈ | ≈ | ≈ | ≈ | ±0 | ≈ |

≈: within run-to-run noise (about ±5% for CPU per op here).
¹ Rounds ranged from −42% to +32%; the inflight-4 runs were better in
all four rounds.

## Changes

- **`TcpByteTransport` receive**: `ReceiveChunkAsync` read the 8-byte
chunk header and then the body, two `recv` calls per chunk. It now waits
for data with a small staging buffer (8 KB, allocated on the first
receive, not from the `BufferManager` budget). One receive returns the
header and, when the chunk is small and already there, the whole chunk;
bytes of following chunks are kept for the next call. A larger chunk is
completed with a receive into the rented buffer, as before.
- The receive buffer is still rented only after the header has been
validated (message type, size against the negotiated limit), so an idle
peer holds none of the budget.
- A receive cancelled while its bytes are in the staging buffer keeps
them for the next call; on .NET Framework the staging receive takes no
token, so cancellation is observed once data arrives.
- A receive that fails while reading the body of a chunk larger than the
staging buffer has consumed part of the chunk; later receives then fail
with `BadTcpInternalError` rather than read body bytes as a header.
Before this PR such a failure lost the header and body bytes silently.
The channel cancels receives only when it closes.
  - Server listener channels use the same transport.
- **`ManagedSession` service calls**: each call was an async wrapper
(await the reader lock, await the inner call), one more state machine
box per request.
- When services are available and no writer holds or waits for the lock,
the reader lock is taken synchronously
(`AsyncReaderWriterLock.TryEnterReader`). It is released by a cached
continuation on the inner call's task, registered before the caller's
await, so the lock is free again when the caller resumes.
- Otherwise the call waits for the lock as before, so reconnect and
failover still block new calls and drain the ones in flight.
- The remaining 88 B per call is the task's continuation list (two
continuations).
- **Write coalescing** (`UaSCUaBinaryChannel`): when more writes for the
same transport are queued behind the one being sent, the writer takes up
to 16 of them and sends their segments in one vectored send.
- Each write is then reported on its own, in order, with its size. A
failed send is reported for every write in it, and their buffers are
returned.
- Writes still never start on the caller's stack, and one writer sends
one batch at a time, so chunk order is unchanged.
- Only byte-stream transports opt in (`TcpByteTransport`, also through
the server's admission lease, via the internal `IUaSCChunkCoalescing`).
WebSocket and capture decorators keep one send per chunk.

### Behaviour notes
- Each TCP connection that has received holds an 8 KB staging array
(pinned on .NET 5+), outside the `BufferManager` budget. The previous
transport held an 8-byte header array.
- `ManagedSession` service calls: if the inner call completes while the
release continuation is being registered, the reader lock is released on
the thread pool shortly after, instead of before the caller resumes.
- Concurrent `ReceiveChunkAsync` calls on one `TcpByteTransport` now
throw `InvalidOperationException`. The channel has a single receive
loop, and interleaved receives on a stream were never valid.

## Not changed: discarded `ResponseHeader`
Every generated structure's `Initialize()` constructs its
structure-typed fields: `new ResponseHeader()` (112 B), `new
RequestHeader()` (104 B). The decoders always create the instance (`new
T()` or the activator) and then assign every field in `Decode`, so the
default is thrown away. That is 4.6% of the client's 2.4 KB per Read and
about 3% on the server, which discards a `RequestHeader` per decoded
request and a `ResponseHeader` per response it constructs.
`PublishResponse` also discards a default `NotificationMessage`. Options
considered:
- **Lazy getter** (`get => m_x ??= new X()`): `IsEqual`/`GetHashCode`
compare fields, so a never-read default (null) would no longer equal an
explicit default. Two threads reading an unset header could get
different instances. Pooled instances reset through `Initialize()`
anyway.
- **Decode-only constructor** called by the activator: only the plain
`Class`/`DerivedClass` templates assign every field in `Decode`, not
optional-field or union types. A derived type's constructor must chain
to a base built by an older generator in another assembly, which lacks
it (the same problem `ForeignDerivedPooledExtensionClass` works around).
It also needs a new public marker type in `Opc.Ua.Types`.
- **Decode into the existing instance**: needs a new `IDecoder` member
in all decoders. It also changes behaviour when a caller decodes into an
object whose header it still references.

Each touches every generated type for about 100 B per message, so this
is left as is.

## Harness
- `--client shared`: classic sessions whose secure channels come from
one `ClientChannelManager`.
- Created sequentially (`--connect-parallel`, default 1). Concurrent
`Session.CreateAsync` calls on one manager fail with
`BadSecureChannelClosed`, a separate bug.
- 20 sessions on one channel reach about 45–70k req/s, against about
140k on 20 channels.
- `ab.sh`: `CANDIDATE=` selects the candidate build.

## Testing
- New: `TcpByteTransportTests`: chunks sent together, a chunk split
across sends (inside the next header), a chunk larger than the staging
buffer, cancellation keeping staged bytes.
`UaSCBinaryChannelWriteQueueTests`: coalesced in order (1 + 16 + 16 + 7
sends), framing transport not coalesced, a failed batch reported for
every write and later writes still sent. `AsyncReaderWriterLockTests`:
`TryEnterReader` against a writer, the reader held until the operation
completes and released on failure and on a completed operation, the
awaiter resuming after the release.
- Before the rebase onto master, net10.0, run sequentially: Core
Stack+Security 4689 passed, 1 failed
(`ClientBaseTests.ActivityTraceFlags_Metrics_ShouldRecordMetricsOnly`, a
meter-listener flake fixed on master by #4708; passes 3/3 in isolation);
Client 2768; Sessions 851 (379 skipped); Server 8398; Subscriptions 643.
- After the rebase: Core Stack+Security 4811 passed; Client 2781; Stress
Contract+Integration 45 passed; all 0 failed. Sessions, Server and
Subscriptions were not rerun on the new base.
- net48 build of `UA.slnx`: clean (0 warnings, 0 errors); `dotnet format
whitespace` clean.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 05:30:29 +02:00

26 lines
1.1 KiB
Bash

#!/bin/bash
# Paired A/B runs: alternates a baseline and a candidate harness build per scenario and round.
# BASELINE: harness built in a second worktree of the commit to compare against.
# CANDIDATE: harness to compare (default: the build in this worktree).
# SCENARIOS: file with one scenario per line (default: server-scenarios.txt).
# ROUNDS: rounds per scenario (default 3). OUT: result file (default ab-results.txt).
A=${BASELINE:-C:/uabase/perf/ServerLoadHarness/bin/Release/net10.0/ServerLoadHarness.exe}
B=${CANDIDATE:-./ServerLoadHarness/bin/Release/net10.0/ServerLoadHarness.exe}
O=${OUT:-ab-results.txt}; rm -f "$O"
ROUNDS=${ROUNDS:-3}
SCENARIOS=${SCENARIOS:-server-scenarios.txt}
while IFS= read -r s || [ -n "$s" ]; do
# tolerate CRLF scenario files
s=${s%$'\r'}
[ -z "$s" ] && continue
for r in $(seq 1 "$ROUNDS"); do
for side in A B; do
exe=$A; [ $side = B ] && exe=$B
echo "### side=$side round=$r $s" >> "$O"
# defaults first, so that a scenario can override them
timeout 300 $exe run --sessions 20 --duration 12 --warmup 6 $s --out "$O" >/dev/null 2>&1
done
done
done < "$SCENARIOS"
echo done >> "$O"