Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,10 +305,14 @@ that exercise the salvage path.
`IRtlDevice::FastRetune(channel)` is a lean intra-band, same-bandwidth channel
retune every generation implements (default = the full `SetMonitorChannel`): it
does the RF channel switch only, skipping the steady-state work a hop doesn't
need, writes the channel RF register from a cached value, and re-writes the
channel-keyed constants only when their bucket changes. Measured medians:
8812AU ~251→1.6 ms, 8822BU ~62→18 ms, 8821CU ~29→5.6 ms, 8822CU/8812EU
~11→3-4 ms. It falls back to the full path on a band change; on Jaguar3 it
need, and runs write-only from a compose cache (full dwords primed once per
epoch, bit changes composed in memory — a masked register write is otherwise a
read+write USB round-trip, and hop cost is purely transfer count × the chip's
EP0 latency). Measured medians: 8812AU ~277→1.6 ms, 8822BU ~65→2.5 ms, 8821CU
~30→0.55 ms, 8822CU/8812EU ~12→2-2.4 ms — FHSS-grade on every generation
(soak: 12k consecutive dwell-1 per-packet hops, zero TX stalls; kickless
hopping RX holds a constant catch rate). `DEVOURER_HOP_PROF=1` emits per-stage
hop timing. It falls back to the full path on a band change; on Jaguar3 it
serializes on the coex thread's register mutex (the sanctioned in-session hop)
and preserves the 5/10 MHz narrowband dividers across hops. `send_packet` on
all three generations also honours a radiotap `CHANNEL` field, so hopping is
Expand Down
68 changes: 43 additions & 25 deletions docs/frequency-hopping.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,33 +298,51 @@ constants once); every subsequent same-band hop is ~1.5 ms.
point (default = the full `SetMonitorChannel` at the current width/offset), and
every generation overrides it with a lean path built from the tricks above:

| DUT | full path | fast (cached) | fast (no cache) |
|-----|-----------|---------------|-----------------|
| RTL8812AU (Jaguar1) | ~251 ms | **~1.6 ms** | ~99 ms |
| RTL8822BU (Jaguar2) | ~62 ms | **~18 ms** | ~20 ms |
| RTL8821CU (Jaguar2) | ~29 ms | **~5.6 ms** | ~5.6 ms |
| RTL8822CU (Jaguar3) | ~11 ms | **~3.6 ms** | ~3 ms |
| RTL8812EU (Jaguar3) | ~11 ms | **~4.3 ms** | ~3 ms |

(Median `<devourer-hop>switch_us` over a 1/6/11 hop set. The newer generations'
full paths are already far cheaper than Jaguar1's — no 20 ms C-cut read sleeps —
so their win comes from transfer-count reduction, and `cache_rf=false` costs
only the one extra RF18 read.)

**Jaguar2** (`HalJaguar2::fast_retune`, both variants): one cached full-register
RF18 write collapses the full path's read-modify-write rounds (three of them on
the 8821C's band/channel/BW split); AGC index, CFO fc, the 8822B RF 0xBE VCO
band / ch144 RF 0xDF flag / 2G spur registers and the 8821C 2G CCK filter are
written on bucket change; the per-hop RX kick (8822B RF 0xb8 + RX-path toggles,
IGI toggle on both variants) runs every hop. RFE pins and the 8821C
switch-band/RF-set block are band-keyed and stay untouched.
| DUT | full path | fast (cached) | USB op cost | fast ops/hop |
|-----|-----------|---------------|-------------|--------------|
| RTL8812AU (Jaguar1) | ~277 ms | **~1.6 ms** | ~0.8 ms | 2 |
| RTL8822BU (Jaguar2) | ~65 ms | **~2.5 ms** | ~1.0 ms | 2 |
| RTL8821CU (Jaguar2) | ~30 ms | **~0.55 ms** | ~0.5 ms | 1 |
| RTL8822CU (Jaguar3) | ~12 ms | **~1.9 ms** | ~0.21 ms | 9 |
| RTL8812EU (Jaguar3) | ~12 ms | **~2.4 ms** | ~0.27 ms | 9 |

(Median `<devourer-hop>switch_us` over a 1/6/11 hop set; per-stage numbers from
`DEVOURER_HOP_PROF=1`. Every hop microsecond is USB round-trips: one register
read or write is one synchronous control transfer, whose latency is a property
of the chip's EP0 handling — it varies 5× across the family — so the only code
lever is op count. FHSS-soak-validated: 12,000 (8822CU) and 9,000 (8822BU)
consecutive dwell-1 per-packet hops, zero bulk-OUT failures; a kickless hopping
receiver holds a constant catch rate over ~850 retunes.)

Two techniques carried the newer generations to the table above, beyond the
original tricks:

- **The compose cache (Trick 2 generalised to every masked write).** A masked
`phy_set_bb_reg` is a read-modify-write — two control transfers. The fast
path primes the full dwords of every register it touches ONCE per epoch
(first fast hop, lazily — priming at full-set time would cache values that
init calibration then rewrites), composes bit changes in memory, and writes
whole dwords: the steady hop is write-only. Correct by construction — the
untouched bits are written back as read.
- **No per-hop RX kick.** The vendor `switch_channel` tail (Jaguar2's RF 0xb8
toggle, RX-path toggle, IGI toggle — 13 of the 19 transfers the first Jaguar2
port paid) belongs to the full path only: hardware A/B on both Jaguar2
variants, both directions, shows identical hopping-RX catch rate (no decay
over hundreds of kickless retunes) and identical hopping-TX delivery without
it. A hop needs the channel write, not the state-machine kick.

**Jaguar2** (`HalJaguar2::fast_retune`, both variants): one composed
full-register RF18 write per path collapses the full path's read-modify-write
rounds (three of them on the 8821C's band/channel/BW split) — the 8821CU hop is
a **single LSSI write**; AGC index, CFO fc, the 8822B RF 0xBE VCO band / ch144
RF 0xDF flag / 2G spur registers and the 8821C 2G CCK filter are composed
writes on bucket change. RFE pins and the 8821C switch-band/RF-set block are
band-keyed and stay untouched.

**Jaguar3** (`RadioManagementJaguar3::fast_retune`, both variants): the RF18
write inside its 3-wire bracket + force-anapar + BB reset every hop; SCO fc,
TX DFIR and the AGC table on bucket change; the 8822c RF18 read-modify-write
becomes a lazily-primed cached write (primed on the first fast hop, after
init-time IQK has settled RF18 — priming at full-set time would cache a value
calibration then rewrites). In 5/10 MHz narrowband mode fast hops preserve the
window write inside its 3-wire bracket + force-anapar + BB reset every hop —
nine composed writes total; SCO fc, TX DFIR (both nibbles in one dword) and the
AGC table on bucket change. In 5/10 MHz narrowband mode fast hops preserve the
divider re-clock (no per-dwell `set_bandwidth_dividers` + DAC-FIFO reset), and
the TX-DFIR write uses the NB-owned `0x808[6:4]` value on the 8822e.

Expand Down
68 changes: 68 additions & 0 deletions src/HopProf.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#ifndef HOP_PROF_H
#define HOP_PROF_H

#include <chrono>
#include <cstdio>
#include <cstdlib>

/* DEVOURER_HOP_PROF=1 — per-stage timing inside the fast_retune paths, for
* driving the hop-latency work (FHSS wants every hop microsecond accounted
* for). Each fast hop emits one machine-parseable line:
*
* <devourer-hop-prof>gen=<tag> ch=<n> <stage>_us=<n> ... total_us=<n>
*
* Stages are generation-specific labels passed at mark() time. Zero overhead
* when the env is unset beyond one cached getenv + a branch. */

namespace devourer {

class HopProf {
public:
static bool enabled() {
static const bool on = std::getenv("DEVOURER_HOP_PROF") != nullptr;
return on;
}

HopProf(const char *gen, unsigned channel) : _gen(gen), _ch(channel) {
if (!enabled())
return;
_t0 = _last = std::chrono::steady_clock::now();
_len = std::snprintf(_buf, sizeof(_buf), "<devourer-hop-prof>gen=%s ch=%u",
_gen, _ch);
}

/* Record the time since the previous mark under `stage`. */
void mark(const char *stage) {
if (!enabled())
return;
const auto now = std::chrono::steady_clock::now();
const long long us =
std::chrono::duration_cast<std::chrono::microseconds>(now - _last)
.count();
_last = now;
if (_len > 0 && _len < static_cast<int>(sizeof(_buf)))
_len += std::snprintf(_buf + _len, sizeof(_buf) - _len, " %s_us=%lld",
stage, us);
}

~HopProf() {
if (!enabled() || _len <= 0)
return;
const long long total =
std::chrono::duration_cast<std::chrono::microseconds>(
std::chrono::steady_clock::now() - _t0)
.count();
std::fprintf(stderr, "%s total_us=%lld\n", _buf, total);
}

private:
const char *_gen;
unsigned _ch;
std::chrono::steady_clock::time_point _t0{}, _last{};
char _buf[512];
int _len = 0;
};

} /* namespace devourer */

#endif /* HOP_PROF_H */
65 changes: 38 additions & 27 deletions src/jaguar2/HalJaguar2.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#if defined(DEVOURER_HAVE_JAGUAR2_8821C)
#include "Hal8821c_TxpwrLmt.h" /* generated: hal8821c_txpwr_lmt() WW-min limits */
#endif
#include "HopProf.h" /* DEVOURER_HOP_PROF fast-retune stage timing */
#include "PhyTableLoader.h"

namespace jaguar2 {
Expand Down Expand Up @@ -465,7 +466,7 @@ void HalJaguar2::set_channel_bw(uint8_t channel, uint8_t bw, uint8_t rfe_type,
/* Any full channel set rewrites what fast_retune caches (RF18 band/BW bits,
* AGC/fc buckets, RF 0xBE, spur/CCK-filter regs) — invalidate; the caches
* only ever mirror fast-path writes. */
_rf18_cached = false;
_cw_primed = false;
_last_agc_bucket = -1;
_last_fc = 0xffffffff;
_last_rf_be = -1;
Expand Down Expand Up @@ -686,19 +687,26 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw,
if (channel == _last_tuned_ch)
return true; /* no-op hop */

devourer::HopProf prof("j2", channel);
const uint8_t cch = central_ch(channel, bw, primary_ch_idx);
const bool g2 = cch <= 14;
const bool c8821 = (_variant == ChipVariant::C8821C);
const bool r2t2r = _ver.rf_2t2r != 0;

/* RF18: one cached full-register write replaces the full path's
* read-modify-write round(s) (three of them on the 8821C). One direct-window
* read primes the cache; the band bits ([16]/[8], band-keyed) and the BW bits
* ([11:10], bandwidth-keyed) ride along in the cached value. */
if (!cache_rf || !_rf18_cached) {
/* Compose-cache prime — one read per touched register per epoch; the hop
* itself is then write-only. RF18's band bits ([16]/[8], band-keyed) and BW
* bits ([11:10], bandwidth-keyed) ride along in the cached value; the AGC /
* fc / RF 0xBE dwords carry their untouched neighbour bits the same way.
* cache_rf=false re-primes every hop (the A/B read-penalty knob). */
if (!cache_rf || !_cw_primed) {
_rf18_cache = rf_read(0, 0x18);
_rf18_cached = true;
_cw_agc = _device.rtw_read32(c8821 ? 0x0c1c : 0x0958);
_cw_fc = _device.rtw_read32(0x0860);
if (!c8821)
_cw_rfbe = rf_read(0, 0xbe);
_cw_primed = true;
}
prof.mark("prime");
uint32_t rf18 = _rf18_cache & ~((1u << 18) | (1u << 17) | 0xffu);
rf18 |= cch;

Expand All @@ -718,11 +726,14 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw,
else if (cch >= 149)
agc_bucket = 3;
if (agc_bucket >= 0 && agc_bucket != _last_agc_bucket) {
if (c8821)
_device.phy_set_bb_reg(0x0c1c, 0x00000F00,
static_cast<uint32_t>(agc_bucket));
else
_device.phy_set_bb_reg(0x0958, 0x1f, static_cast<uint32_t>(agc_bucket));
if (c8821) {
_cw_agc = (_cw_agc & ~0x00000F00u) |
(static_cast<uint32_t>(agc_bucket) << 8);
_device.rtw_write32(0x0c1c, _cw_agc);
} else {
_cw_agc = (_cw_agc & ~0x1Fu) | static_cast<uint32_t>(agc_bucket);
_device.rtw_write32(0x0958, _cw_agc);
}
_last_agc_bucket = agc_bucket;
}

Expand All @@ -738,7 +749,8 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw,
else if (cch >= 118 && cch <= 177)
fc = 0x412;
if (fc != 0xffffffff && fc != _last_fc) {
_device.phy_set_bb_reg(0x0860, 0x1ffe0000, fc);
_cw_fc = (_cw_fc & ~0x1ffe0000u) | (fc << 17);
_device.rtw_write32(0x0860, _cw_fc);
_last_fc = fc;
}

Expand All @@ -747,7 +759,9 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw,
* VCO), the ch144 RF 0xDF[18] flag, and the 2G spur registers. */
const uint8_t rf_be = rf_be_for_8822b(cch);
if (rf_be != 0xff && rf_be != _last_rf_be) {
rf_set(0, 0xbe, (1u << 17) | (1u << 16) | (1u << 15), rf_be);
_cw_rfbe = (_cw_rfbe & ~0x38000u) |
(static_cast<uint32_t>(rf_be) << 15);
rf_write(0, 0xbe, _cw_rfbe);
_last_rf_be = rf_be;
}
const int df18 = (cch == 144) ? 1 : 0;
Expand Down Expand Up @@ -803,23 +817,20 @@ bool HalJaguar2::fast_retune(uint8_t channel, uint8_t bw,
}
}

prof.mark("consts");
rf_write(0, 0x18, rf18);
if (!c8821 && r2t2r)
rf_write(1, 0x18, rf18);
_rf18_cache = rf18; /* refresh so the next hop merges into what we wrote */

/* Per-hop RX kick — the vendor switch_channel tail: 8822B RF read-error
* toggle + RX-path toggle (leave the RX dead-zone), then the IGI toggle on
* both variants. */
if (!c8821) {
rf_set(0, 0xb8, (1u << 19), 0);
rf_set(0, 0xb8, (1u << 19), 1);
const uint8_t rx_ant = r2t2r ? 0x3 : 0x1;
_device.phy_set_bb_reg(0x0808, 0xff, 0x0);
_device.phy_set_bb_reg(0x0808, 0xff,
static_cast<uint32_t>(rx_ant | (rx_ant << 4)));
}
igi_toggle();
prof.mark("rf18");

/* No per-hop RX kick. The vendor switch_channel tail (8822B RF 0xb8 +
* RX-path toggles, IGI toggle) stays in the full path, but a hop does not
* need it — hardware-measured on both variants, both directions: a hopping
* receiver catches a parked beacon at the same per-dwell rate with and
* without the kick (8822BU/8821CU: identical medians, no decay over ~70
* kickless retunes), and hopping-TX delivery is unchanged. The kick was
* >80% of the hop's USB round-trips. */

_last_tuned_ch = channel;
_logger->debug("Jaguar2: fast retune -> ch {} (central {}, RF18=0x{:05x})",
Expand Down
34 changes: 22 additions & 12 deletions src/jaguar2/HalJaguar2.h
Original file line number Diff line number Diff line change
Expand Up @@ -76,17 +76,21 @@ class HalJaguar2 {

/* Lean intra-band, same-bandwidth hop retune — the Jaguar2 FastRetune core
* (see docs/frequency-hopping.md), variant-dispatched like set_channel_bw.
* Only the per-hop essentials run: one cached full-register RF18 write (a
* single direct-window read primes the cache, collapsing the full path's
* read-modify-write rounds), the channel-keyed constants (AGC table index,
* CFO-tracking fc, 8822B RF 0xBE VCO band / ch144 RF 0xDF flag / 2G spur
* regs, 8821C 2G CCK filter) written only when their bucket moves, and the
* per-hop RX kick (8822B RF 0xb8 toggle + RX-path toggle; both variants the
* IGI toggle). Everything bandwidth-keyed (0x8ac/0x8c4 block, RX DFIR, CCA
* thresholds) and band-keyed (RFE pins, 8821C switch-band/RF-set block)
* stays untouched — set by the last full set at this BW/band. Returns false
* (chip untouched) on a band change or when the radio was never tuned; the
* caller falls back to the full set_channel_bw. */
* Only the per-hop essentials run: one cached full-register RF18 write per
* path (a compose cache primed on the first fast hop collapses the full
* path's read-modify-write rounds — the steady hop is write-only), plus the
* channel-keyed constants (AGC table index, CFO-tracking fc, 8822B RF 0xBE
* VCO band / ch144 RF 0xDF flag / 2G spur regs, 8821C 2G CCK filter) as
* composed writes only when their bucket moves. The vendor switch_channel
* tail (RF 0xb8 / RX-path / IGI toggles) stays in the full path only — a
* hop does not need it (hardware-measured on both variants, both
* directions: identical hopping-RX catch rate and hopping-TX delivery with
* and without, no decay over repeated kickless retunes). Everything
* bandwidth-keyed (0x8ac/0x8c4 block, RX DFIR, CCA thresholds) and
* band-keyed (RFE pins, 8821C switch-band/RF-set block) stays untouched —
* set by the last full set at this BW/band. Returns false (chip untouched)
* on a band change or when the radio was never tuned; the caller falls back
* to the full set_channel_bw. */
bool fast_retune(uint8_t channel, uint8_t bw, uint8_t primary_ch_idx,
bool cache_rf);

Expand Down Expand Up @@ -197,7 +201,13 @@ class HalJaguar2 {
* fast path (0 = never tuned / unknown band). */
uint8_t _last_tuned_ch = 0;
uint32_t _rf18_cache = 0;
bool _rf18_cached = false;
/* Compose cache (the Jaguar1 cached-LSSI trick generalised): the dwords the
* fast path touches are primed by one read each on the first fast hop of an
* epoch; thereafter bit changes are composed in memory and written as whole
* dwords — zero per-hop reads (every masked write is otherwise a read+write
* USB round-trip). _cw_rfbe is the 8822B RF 0xBE 20-bit word. */
bool _cw_primed = false;
uint32_t _cw_agc = 0, _cw_fc = 0, _cw_rfbe = 0;
int _last_agc_bucket = -1;
uint32_t _last_fc = 0xffffffff;
int _last_rf_be = -1;
Expand Down
13 changes: 6 additions & 7 deletions src/jaguar2/RtlJaguar2Device.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,13 +48,12 @@ class RtlJaguar2Device : public IRtlDevice {
void StopRxLoop() override { _rx_stop = true; }
void SetMonitorChannel(SelectedChannel channel) override;
/* Lean frequency-hop retune (the Jaguar2 port of the Jaguar1 FastRetune —
* see docs/frequency-hopping.md): cached RF18 write + on-change channel
* constants + the RX kick, via HalJaguar2::fast_retune; falls back to the
* full set_channel_bw on a band change. Same concurrency model as
* SetMonitorChannel: no lock — the DIG thread (RX sessions only) may
* interleave IGI writes with the retune's IGI toggle, which is benign
* (single-register writes; DIG re-converges), and TX-only sessions have no
* concurrent register writer. cache_rf=false re-reads RF18 per hop (A/B
* see docs/frequency-hopping.md): composed cached writes for RF18 + the
* on-change channel constants, via HalJaguar2::fast_retune; falls back to
* the full set_channel_bw on a band change. Same concurrency model as
* SetMonitorChannel: no lock — the DIG thread (RX sessions only) writes its
* own registers (IGI/FA), and TX-only sessions have no concurrent register
* writer. cache_rf=false re-primes the compose cache per hop (A/B
* measurement). */
void FastRetune(uint8_t channel, bool cache_rf) override;
void InitWrite(SelectedChannel channel) override;
Expand Down
Loading
Loading