mt76 rate control: why a client gets pinned at MCS0 (and how to prove it)

2026-10-11 · 3 min read

If an OpenWrt + mt76 AP sends to one client at MCS0 while every other client is fine, you are not imagining it — and it is usually not a signal problem. This is the measured investigation.

1. Read the right counters first

iw dev <ap> station dump | grep -E "Station|tx bitrate|rx bitrate|tx failed|tx retries|expected"
Field Meaning Trap
tx bitrate last rate used AP to client it is a sample, not an average — sample it repeatedly
rx bitrate last rate client to AP usually healthy even when downlink collapses
tx failed / tx retries link-layer outcomes compare against bytes moved, not frames alone

Also confirm the direction with a cheap LAN transfer and count bytes on both ends (see section 3). If the AP sends far more bytes than the client receives, you have retransmission storms; if it sends roughly the same, the airtime is simply idle.

2. Where mt76 rate control lives

cat /sys/kernel/debug/ieee80211/phy*/rate_ctrl_alg      # -> hw/driver
cat /sys/kernel/debug/ieee80211/phy*/hwflags            # -> HAS_RATE_CONTROL

hw/driver plus HAS_RATE_CONTROL means the firmware/SoC picks the rate — mac80211's software rate control (minstrel) is not in charge. So "just switch the rate algorithm" is not an option; you influence it indirectly.

Two debugfs knobs matter in practice:

cat /sys/kernel/debug/ieee80211/phy*/mt76/vow_atf      # firmware airtime fairness, default 1
cat /sys/kernel/debug/ieee80211/phy*/mt76/aql_enable   # airtime queue limit

3. The measurement that identified the bug

Same client, same minutes, only vow_atf changed (grade E — raw capture and the scripts that produced it are published [1] [2]). Idle 60 s, then six 6-second LAN downloads:

vow_atf Results Behaviour
1 (default) 9.5 / 7.3 / 9.5 / 7.4 / 9.6 / 7.2 Mbps 36 s of continuous traffic never recovers
0 7.8 / 8.8 / 20 / 146 / 387 / 393 Mbps recovers within ~15 s
0 + client reassociation 369 / 374 / 376 / 390 / 409 / 397 Mbps fast from the first burst

Byte accounting during the collapsed state: 12 s of transfer gave 12.4 MB received vs 12.87 MB transmitted by the AP. So there was almost no retransmission waste — the rate was low and the channel mostly idle. That rules out a deaf client and points at rate selection.

4. What actually causes the collapse

Two independent triggers, both reproducible on MT7986 + mt76:

  1. Client idle/power-save behaviour: about 1 s of idle is enough for the AP transmit rate to fall to MCS0 1SS. With vow_atf=1 it then stays there even under sustained load.
  2. Narrow client in wide BSS: a 40 MHz-only client inside an 80 MHz BSS collapses periodically regardless of ATF. Native-width operation (40 MHz BSS) is stable at 380-420 Mbps.

5. What to do

# 1) disable the firmware feature that prevents recovery (and persist it)
echo 0 > /sys/kernel/debug/ieee80211/phy*/mt76/vow_atf

# 2) if the client is a 40 MHz-only device, match the radio width
sh /usr/bin/wifi-htmode HE40      # CSI resolution drops; clients stay usable

# 3) as a last resort, force a fresh association (resets the rate state)
iw dev <ap> station del <client-mac>

Persisting step 1 matters: the debugfs value is lost on driver reload. The project toolkit ships an init script and a watchdog that re-apply it (scripts/mt76-wifi-tune, scripts/csi-watchdog.sh).

6. What not to conclude

Related: 80 MHz vs 40 MHz · OpenWrt Wi-Fi slow after idle · router CSI reproduction

References and data sources

Every external claim carries a source and an evidence grade (grading scheme). Grade E marks first-party, reproducible measurements from this project.

  1. Grade E · first-party measurement This project's 10-minute raw capture (57,293 records / 600.0 s, SHA-256 00373f83…95e2)
    Router CSI Lab · 2026
    csi.okwujun.pw/zh/datasets/ · verified: Reassembled chunks verified byte-identical (2026-10-11)
  2. Grade E · first-party measurement Router CSI Toolkit — capture/analysis/watchdog scripts (MIT)
    Router CSI Lab · 2026
    csi.okwujun.pw/zh/tools/ · verified: Repository bundle has a SHA-256; scripts run on real hardware