perf(streaming): stop rebuilding the channel map and copying the buffer on every frame - #512
perf(streaming): stop rebuilding the channel map and copying the buffer on every frame#512tylerkron wants to merge 2 commits into
Conversation
…er on every frame A device streaming at 1 kHz re-derived, for every single frame, a set that only changes when someone configures channels: a snapshot of the channel list taken under the device's lock, a fresh list filtered from it, and a sort. The consumer underneath it copied its accumulation buffer out on every read purely because the parser entry point wanted an array, and every frame was wrapped twice more on its way to an event nobody had subscribed to. Measured on a 16-analog/16-digital frame: 2312 -> 1648 bytes per frame on the decode path and 1385 -> 1310 on the consumer path, which at 1 kHz is ~2.4 GB/hour of gen0 churn that no longer happens. Decode time per frame 1.79 -> 1.36 us. The cache is invalidated by a channel-state version the device bumps on repopulation and on any change to a channel's IsEnabled — including a caller writing it straight onto the channel, which IChannel permits and no device API sees. Without that the decoder would keep mapping values onto the previously active channels, silently. closes #490 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by Qodoperf(streaming): cache active channels and avoid per-read buffer/frame allocations
AI Description
Diagram
High-Level Assessment
Files changed (22)
|
Code Review by Qodo
1.
|
…ersion only on real membership changes Qodo round 1, both taken. An exception from a MessageParsed subscriber suppressed MessageReceived for that message — Core's own subscribers ride MessageParsed, so an internal handler that threw would silently withhold the message from an unrelated external consumer. Isolated and reported like any other dispatch fault; MessageReceived keeps propagating to ProcessMessageBuffer's catch exactly as before. PopulateChannelsFromStatus bumped the channel-state version even when the status described the channels the device already had, throwing away the decoder's cache on every status poll. It now compares membership first — by reference, not by (type, number), because the populator builds a new instance whenever it cannot reuse one and a cache holding the replaced instance would keep delivering samples to a channel the device no longer has. An enabled-mask change on reused instances still moves the version through those channels' own notifications. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 3fd4594 |
|
Qodo-clean, CI green — ready for review. Round 2 on head Both round-1 findings were valid and taken. The second one's suggested fix was not — comparing Bench re-run after those production fixes (non-destructive, Nq1 fw 3.7.2 on |
What was wrong
A DAQiFi streaming at 1 kHz made the host do the same throwaway work a thousand times a second. For every frame, Core re-derived which analog channels were active — copying the whole channel list out under the device's lock, filtering it into a fresh list, and sorting that list — for a set that only changes when someone configures the device. Underneath it, the message consumer copied its entire accumulation buffer on every read, and then wrapped each frame twice more on its way to an event that, in practice, nobody had subscribed to.
None of it was visible as a bug. It showed up as an application that streams for hours quietly generating gigabytes of garbage, with the decode thread contending against
EnableChannelfor the same lock the whole time.How it was fixed
The active-channel set is worked out once and cached, and rebuilt only when the device says the channel state actually moved; the parser is handed a view over the buffer instead of a copy; and the per-frame wrappers are built only when something is listening.
What a reviewer may want to push back on:
IChannel.IsEnabledhas a public setter. A caller writingchannel.IsEnabled = truestraight onto a channel is legal and no device API sees it — so a version counter bumped only from the device's own configure paths would leave the decoder mapping frame values onto the previously active channels, filing AI1's reading under AI0, silently and forever. So the channel types now raise an internal notification when their enabled state actually changes, the device subscribes to every channel it owns, and that is what moves the version. Tests cover all three routes in (device API, direct write, status repopulation) and the two lifecycle edges (a dropped channel stops moving the version; a reused one still moves it exactly once).(type, number), which would report "unchanged" for a set whose instances had been replaced and leave the cache writing samples into channels the device no longer has.StreamMessageConsumergrew a second, internal event.MessageReceivedcarries a snapshot of everything buffered at the time of the read; taking that snapshot is the per-read copy. No subscriber inside Core has ever looked at it, so Core's three subscribers moved to a new internalMessageParsed, and the snapshot is now taken only when the public event has a subscriber. The public event's behaviour is unchanged for anyone using it, including when an internalMessageParsedhandler throws — that is isolated, so it cannot withhold a message from an external subscriber.DaqifiDevice.OnMessageReceivedis no longer called for a frame whenMessageReceivedhas no subscribers (the frame would have to be wrapped to be passed to it, and that wrapper was the allocation); overrides that must see every frame should use the classifiedOnStatusMessageReceived/OnStreamMessageReceived, which stay unconditional. AndStreamMessageConsumer.OnMessageReceived'srawDataargument is empty in that same no-subscriber case. Both are stated in the XML docs.IMessageParser<T>gained a span overload as a default interface method, so existing parsers keep compiling and behave exactly as before;ProtobufMessageParserandLineBasedMessageParserimplement it for real (the protobuf parser's body already worked in spans — only its entry point demanded an array).PopulateChannelsFromStatus/ChannelControlOperationsonly, which is exactly the hole above). The notification approach is the correction.Verification
Measured, branch vs.
origin/main, same harness, 16 analog + 16 digital channels, reproducible across runs:The 664 B/frame saved on decode accounts for the channel-snapshot array, the intermediate list and its growth steps, and the two per-frame wrappers — roughly seven objects per frame. At 1 kHz that is ~2.4 GB/hour of gen0 churn that no longer happens, which is the order the issue estimated. The 75 B/frame saved on the consumer path is, as expected, almost exactly the frame size: that copy was the wire throughput, duplicated. The remaining ~1.3 KB/frame is the generated
DaqifiOutMessageitself, which the issue puts explicitly out of scope.Tests — 39 new. Proven regression catchers by reverting each half of the fix: removing the enablement notification fails 10 tests (including the direct-write mapping cases and the warm-up guard), removing the repopulation version bump fails 2, removing the unsubscribe on repopulation fails 2, making the version bump unconditional again fails 1, restoring the unconditional buffer snapshot fails 1, and neutering the
MessageParsedisolation fails 1. Full suite green on net9.0 (3014 Core + 95 Mcp) and net10.0 (3014), 0 failures, 0 warnings.Bench (non-destructive), Nq1 fw 3.7.2 on
/dev/cu.usbmodem1101— example CLI built against this branch:--discover-serialfound the unit (sn=9090539562006014104, fw 3.7.2) — this also exercises the changed subscription inSerialDeviceFinder.--sd-list(47 files) and--sd-storage(7.80 GB, 0.0% used) — the text-exchange path, whose line collection also moved to the new event.SD:GET, no firmware, no LAN writes.closes #490
Not merging — this is for your review.