rtl8733b: port USB TX aggregation (send_packets) - #400
Conversation
The 8733B was the last USB backend without send_packets batching, so every
frame cost its own bulk-OUT URB. That is cheap on a desktop and expensive
on the SoCs this part actually ships on.
Measured before writing any code, on the CV610 craft and on x86, by
sweeping payload and reading process CPU against frames submitted in
steady state:
per submission x86 ~22 us CV610 ~248 us (11x)
split per-call 19.1 us + 0.002 us/byte on x86;
on ARM the payload term is below noise -- it is
essentially ALL per-call
attribution ~87% kernel USB submit/completion, ~5% descriptor
build, ~4% libc
Per-call dominance is what makes packing worth it, because a 3:1 URB
removes two submissions in three while the descriptor build stays.
Ported: send_packets over the shared devourer::plan_tx_agg planner
(desc_size 40, no first-block reserve, max 3 blocks), and a DMA_TXAGG_NUM
field on the first descriptor. Two things differ from the 88xx siblings.
MAC init ALREADY programmed BLK_DESC_NUM = 3 into DWBCN0_CTRL[7:4], the
same field and value, so bring-up needed no change. And the count must be
written BEFORE the checksum rather than patched on after: the fold covers
32 bytes skipping only 0x1c-0x1d, so byte 0x1f is inside it. A selftest
cell pins that ordering and fails if the write moves below the checksum
(verified by mutation).
Result, same craft, ~1750 fps, frame rate unchanged (1733 -> 1800):
us/frame 248.08 -> 148.15
CPU 43.0% -> 26.7% of one core
At the craft's ~1100 pps operating point that is about ten points of a
core. On x86 the same A/B reads 21.5 -> 10.6 us.
DMA_TXAGG_NUM's placement was inferred from 8822C parity, so it was
confirmed on silicon rather than assumed. The 8822BU precedent is that
wrong packing makes the TXDMA re-air block 1 agg_num times, which frame
counts cannot see -- rx_hits is identical either way. Every frame was
therefore stamped (DEVOURER_TX_QOS_DATA) and the stamps counted distinct
at an 8812AU witness (DEVOURER_RX_PCTR): 31721 receptions, 31721 distinct
counters, ratio 1.00 where re-airing would read 3.00. All 10940
aggregated URBs carried frames=3.
Knob off (the default) is byte-identical: agg_num 0 leaves dword7[31:24]
clear and the single-frame path is untouched, which a selftest cell pins.
Note GetTxStats().submitted counts URBs rather than frames, so an
aggregated session reports about a third -- the same accounting the other
families have, not a throughput drop; tx.agg carries the true count.
ctest 54/54.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PR Summary by Qodortl8733b: add USB TX aggregation via send_packets batching
AI Description
Diagram
High-Level Assessment
Files changed (8)
|
Code Review by Qodo
1.
|
Two findings from an adversarial pass over the previous commit. 1. An off-channel LEADING frame hung the TX thread. The run collector ended the run on a radiotap CHANNEL mismatch, so when the mismatch was on the first frame the run came back empty, `done` never advanced, and the while loop spun forever -- holding _reg_mu, which every other path on this device takes, so it deadlocks the adapter rather than just stalling TX. Reachable from any caller that does radiotap-driven per-packet hopping with the knob on. A leading off-channel frame now enters the run alone, so the single-frame path refuses it (build_tx_block already does) and `done` moves past it. The empty-run branch also advances unconditionally now, so termination is structural rather than a property of the current run rules -- the previous cut had the correct rule for null/malformed frames documented one branch above the case that got it wrong. 2. The boundary shim was never exercised. All prior verification used a fixed payload, so the packed total never landed on a bulk-MPS multiple and the 8-byte first-block PKT_OFFSET reserve never appeared: 0 shim=true across ~22k URBs. That is the exact path that made the 8822BU re-air block 1, so leaving it unmeasured was the weakest point in the evidence. For three blocks at USB HS the reserve engages when the MPDU length is 472 mod 512 -- confirmed exactly: urb_bytes 1544 / 3080 / 4616 at payloads 472 / 984 / 1496, each n*512 + 8. Stamped distinctness with the shim engaged: 15027 shim URBs, 44032 receptions, 44032 distinct, ratio 1.00, against 1.00 for the no-shim control. This part's block walker does account the reserve. Also checked and NOT a defect: this backend ignores radiotap DBM_TX_POWER, so Jaguar3's rule about breaking a run when the power bank changes has nothing to guard here. ctest 54/54. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Adversarial pass over my own diff turned up two things, both fixed in 1. An off-channel leading frame hung the TX thread. The run collector ended a run on a radiotap CHANNEL mismatch — but when the mismatch was on the first frame the run came back empty, Galling detail: I had the correct rule written out one branch above, in the comment explaining why a null or malformed leading frame must advance 2. The boundary shim was never exercised — 0 For three blocks at USB HS the reserve engages when the MPDU length is ≡ 472 mod 512. Confirmed exactly — So this part's block walker does account the reserve, unlike the 8822BU's. Checked and not a defect: this backend ignores radiotap ctest 54/54. |
Qodo review on 400, plus a self-review pass for the same classes.
1. A short write counted as a full send. bulk_send_sync_ep returns BYTES
SUBMITTED, so `rc >= 0` also matches a truncated transfer: the chip got
a prefix, some trailing block is partial or absent, and there is no way
to say which frames aired -- but all of them were reported submitted.
send_packet has always refused a short write, so the aggregated path
was the looser of the two in the same backend. It now requires
rc == urb.size(), logs the short write, and reports none of the batch.
tx.agg gains `sent` and `ok` now means a FULL write.
Note the same pattern is live in jaguar2 and jaguar3 send_packets
(both `rc >= 0` -> `ok += plan.frames()`). Not touched here -- that is
two other backends and belongs in its own change -- but it is real,
and jaguar1 is exempt only because its async path returns bool.
2. src/rtl8733b/CLAUDE.md restated the descriptor placement, the
checksum-ordering rule and the URB accounting that the headers already
doc-comment, against this repo's standing "never duplicate what a
header already doc-comments". Trimmed to what only it can carry -- the
bring-up archaeology, the measured cost, and the verification method
-- pointing at the headers for the mechanism. The ordering rule now
lives on TxDescConfig::agg_num where a reader looks for it, instead of
only in the fill body.
Self-review found three more instances of the class the previous round
was about: a claim outliving what it described.
- The header quoted ~283 us for a craft submission. That was the
interpolated figure from the rate sweep; the direct A/B measured 248
and every other site says 248.
- docs/logging.md still listed tx.agg as "frames, bytes, shim, ok"
after this commit added `sent` and changed what `ok` means.
- The run-collector comment still described the pre-a8c1cbc design,
where an off-channel leading frame led the NEXT URB rather than
entering the current run alone.
The channel-mismatch infinite loop Qodo also reports was already fixed in
a8c1cbc; their review targets 720a4dd.
ctest 54/54.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All three review findings addressed in
Self-review pass on topI ran a second pass looking for the same classes rather than the same instances. The class from the last round — a claim outliving what it described — turned up three more times, all mine:
The loop-progress class I closed structurally rather than case-by-case: the empty-run branch now advances The return-value class I swept across the tree, which is what surfaced the Unrelated but verified while I had the bench open: the aggregation path is now confirmed on a Jaguar3 8812EU ( ctest 54/54. |
Host CPU only — not A-MPDU, and measured not to change on-air spacing. One bulk-OUT submission costs ~248 us of CPU on the CV610 craft against ~22 us on x86, ~87% of it the kernel USB submit/completion path. Batching up to 3 frames per URB measured 8.6-10.5 points of one core on the craft at ~1100 pps with pps unchanged, and 60% fewer USB submissions for identical frames (usbmon: 10974 -> 4347 URBs, frames/URB 1.00 -> 2.52). Only frames the framer emitted back to back within one fan-out are batched, so nothing is deferred. Ordering is enforced structurally by node::StagedAir, whose send_now()/resend() flush by construction — the first cut used ten hand-placed flush calls and adversarial review deleted all ten with the suite staying green. Correctness verified end to end rather than by counts: 8733BU TX to an 8812AU running a real rx node, every payload byte checked after §6.3a reassembly, bad=0 in every run. Default 0 = off, so every deployment stays byte-identical; only the CV610 craft profile opts in. Spec: §15.2 + Pass 184. third_party/devourer is pinned to OpenIPC/devourer#400's PR HEAD, not a merge commit — accepted deliberately; re-pin tracked as #215.
josephnef
left a comment
There was a problem hiding this comment.
Approve. Correct, well-evidenced, blast radius as claimed.
Independently verified on this bench:
- Full suite 54/54 on the PR head.
- Mutation check reproduced: moving the
agg_numwrite below the checksum line fails exactly the two ordering cells (agg-num is inside the checksum, and precedes it+agg-num actually changed the checksum) — the cell is genuinely load-bearing. - Packer loop termination: every path advances
done, including the leading off-channel frame entering the run alone so the single-frame path refuses it and moves past — the a8c1cbc hang fix is right, and the comment pins the invariant against future run rules. - Parity vs
RtlJaguar3Device::send_packets: same structure, two deliberate and defensible divergences (off-channel frames refused per this backend's single-frame contract rather than retuned; no power-grouping since per-packet power isn't ported here). The short-write handling is stricter than Jaguar3'src >= 0accounting — the right semantics. - The hardware evidence is the right kind: stamped distinctness (1.00 vs the 3.00 re-air signature frame counts can't see), the boundary shim exercised at a payload chosen to hit it (MPDU ≡ 472 mod 512), and
txagg_bench.shdocumenting its ownrx_hitsblind spot.
Findings, all minor — only #1 is worth fixing before merge:
- Silent transport error on the aggregated path: the error log gates on
rc >= 0 && !sent_all, so a hard libusb failure (rc < 0) produces only the JSONLtx.aggevent and no stderr diagnostic. The single-frame path logs both cases (sent != size). Suggest logging on!sent_allunconditionally. - Jaguar3's looser
okcounting is now the odd one out — not this PR's bug, but "must not be the looser of the two" cuts both ways; follow-up material to align J3 (and siblings) to full-write-only accounting. - The measurement lives in four places (
Rtl8733bDevice.h,src/rtl8733b/CLAUDE.md,docs/rtl8733b.md,docs/aggregation.mdall carry 248→148 µs / 43.0→26.7%). The subtree CLAUDE.md says "read it there" and then repeats the numbers. A remeasure now has four spots to update; consider one canonical home with pointers. - Nit: the explicit
pkts[i].data == nullptrternary in the collector is redundant —radiotap_hdr_lenalready null-checks (J3 relies on that). The comment does carry contract value. - Lock scope:
_reg_muis held across the entire batch (vs J3 per-operation). Worst case with a wedged device:count/3URBs × 100 ms timeout each blocksFastRetune/GetThermalStatusfor the duration. Fine at realistic batch sizes; a one-line comment saying it's deliberate would stop a future "why" hunt.
Docs discipline is good — every win paired with its counterpart in the same breath, and the URB-vs-frame submitted accounting trap called out before someone rediscovers it as a "3× regression".
…401) Follow-up from the #400 review: the RTL8733B port counted an aggregated frame as submitted only on a full bulk write, and that made its siblings the odd ones out — Jaguar2 and Jaguar3 counted `rc >= 0`, but `bulk_send_sync_ep` returns *bytes submitted*, so a short write (the chip got a prefix) was reported as delivered work. In an aggregated URB that is worse: trailing blocks partial or absent, no way to say which frames aired, all of them counted. ## What changed - **Jaguar2 + Jaguar3, single-frame and aggregated paths**: full-write-or-nothing accounting, matching the 8733B (and Kestrel, which was already strict). - **Jaguar2 + Jaguar3 aggregated paths** now emit the `tx.agg` `sent` byte count and log a genuine short write (`rc >= 0`, truncated) as an error. `rc < 0` stays quiet there deliberately — their single-frame paths' NAK-backoff contract means a failure log would flood exactly when the caller is already backing off. - **RTL8733B**: the aggregated error log no longer gates on `rc >= 0` — a hard transport failure reaches stderr like it does on that backend's single-frame path (the #400 review's finding 1). - **Jaguar1**: comment only. Its TX is async by design; `ok` is URB acceptance and bytes-on-wire resolve at completion reaping, so submit-time full-write accounting cannot apply there. - `docs/logging.md`: the `tx.agg` row now states the sync-generation `sent`/full-write semantics and the Jaguar1 async exception. ## Verification - `ctest` 54/54. - On-air on both changed generations (`tests/txagg_bench.sh`, BATCH=3, MCS7, 10 s floods): - 8812BU (Jaguar2) TX → 8812CU witness: **7459 agg URBs, every one `sent==bytes`, `ok=true`**, frames/URB 3.0, rx_hits 17100 vs 17200 single — delivery parity. - 8812CU (Jaguar3) TX → 8812BU witness: **10654 agg URBs, every one full-write**, frames/URB 3.0, rx_hits 26500 vs 26600 single. - Zero aggregated-TX error lines in either run — the new logging is silent when nothing is wrong. - The 8733B change is log-gate-only (its accounting was already strict and hardware-validated in #400); no 8733B unit was on the bench for this run. ## What this deliberately does not do No retry of a short-written aggregated URB: the chip holds a prefix, and resubmitting could re-air frames that did make it. Frames in a truncated URB are dropped and simply not counted — the caller's own accounting sees the loss. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Closes #215. OpenIPC/devourer#400 merged as 52c6549, retiring the PR-head pin this tree was carrying. The diff #215 insisted on was not a formality: upstream is TWO commits ahead. #401 is the maintainer's follow-up from #400's review — jaguar2/ jaguar3 counted a short write as delivered on both their single-frame and aggregated paths (bulk_send_sync_ep returns bytes submitted, so rc >= 0 matched one); both now require a full write. Reaching us, since the fleet default is 8812AU/CU/EU: on 8812CU/EU send_packet returns false where it returned true, which RadioAir::inject already treats as not-sent — better accounting, not a behaviour flip, and unreachable in practice anyway. The 8733B delta is log-only; the ok accounting flush_staged reads is untouched. Verified as vendored TX code rather than a docs bump: devourer ctest 54/54, gates.sh 26/0, and the byte-exact hardware check re-run on the re-vendored tree — 399 frames bad=0, batched and unbatched.
The RTL8733B was the last USB backend without
send_packetsbatching, so every frame cost its own bulk-OUT URB. That is cheap on a desktop and expensive on the SoCs this part actually ships on — which is the whole reason to bother.The measurement came first
I measured the cost before writing any code, because the answer could well have been "not worth it". Method: flood
txdemo, let bring-up finish, then sample process CPU against frames submitted in steady state (startup excluded by construction, not subtracted), sweeping payload to separate per-call from per-byte.perfattributes ~87% to the kernel USB submit/completion path, ~5% to the descriptor build, ~4% libc. That split is what makes packing worth it: a 3:1 URB removes two submissions in three, while the per-frame descriptor build stays.An independent in-situ cross-check on the real consumer agreed: the craft's hub sits at 5.9% of a core with no video TX and 27.1% at its operating point — ~21 points for ~1100 pps ≈ 193 µs/frame, same order as txdemo's 248 µs (txdemo also logs and polls thermal per frame, so it should read higher).
What changed
send_packetsover the shareddevourer::plan_tx_aggplanner (desc_size40, no first-block reserve, max 3 blocks), plus aDMA_TXAGG_NUMfield on the first descriptor.Two things differ from the 88xx siblings:
BLK_DESC_NUM = 3intoDWBCN0_CTRL[7:4](0x0208) — the same field and value Jaguar3 writes atREG_AUTO_LLT_V1, paired with the sameTXDMA_OFFSET_CHK+1 |= BIT(1). Bring-up needed no change at all.fill_tx_desc_8733b, and the fold covers 32 bytes skipping only0x1c-0x1d— so byte0x1fis inside the checksummed span. A selftest cell pins the ordering; moving the write below the checksum fails exactly that cell (verified by mutation).Result
Same craft, ~1750 fps, frame rate unchanged (1733 → 1800):
At the craft's ~1100 pps operating point that is about ten points of a core. On x86 the same A/B reads 21.5 → 10.6 µs. My pre-implementation prediction was 12–18%; the measured 40% per-frame saving (not the modelled 58%) brings it to ~10, so the model was slightly optimistic and the measurement corrects it.
The failure mode this was checked against
DMA_TXAGG_NUM's placement atdword7[31:24]was inferred from 8822C parity, so it was confirmed on silicon rather than assumed. The 8822BU precedent is that wrong packing makes the TXDMA re-air block 1agg_numtimes — and frame counts cannot see that:rx_hitscame back identical (23900) in both modes, exactly as it would if the chip were re-airing.So every frame was stamped (
DEVOURER_TX_QOS_DATA) and the stamps counted distinct at an RTL8812AU witness (DEVOURER_RX_PCTR):1.00 where re-airing would read 3.00.
tests/txagg_bench.shnow documents that its ownrx_hitscell cannot make this distinction, and how to run the stamped check.No-change control
Knob off is the default and is byte-identical:
agg_num0 leavesdword7[31:24]clear and the single-frame path is untouched — pinned by a selftest cell, and by the existing golden-byte descriptor tests still passing.One accounting note for reviewers:
GetTxStats().submittedcounts bulk-OUT transfers, so an aggregated session reports roughly frames/3. That is the same accounting Jaguar1/2/3 have and not a throughput drop — the per-URBtx.aggevent carries the true frame count. It briefly looked like a 3× regression until I checked it.Verification
ctest54/54, including the new agg-num cells; mutation check confirms the ordering cell is load-bearing.frames=3.Blast radius
Confined to
src/rtl8733b/. No other backend, no shared planner change (TxAggPlan.his used as-is), no MAC-init change. Knob defaults to off, so an unmodified caller gets today's behaviour byte for byte.Follow-on, deliberately not here: consumers must call
send_packetsto see any of this. waybeam-link currently callssend_packetsingly at all three TX sites, and batching there is a latency question rather than a CPU one — separate change, separate evidence.