mt76 rate control: why a client gets pinned at MCS0 (and how to prove it)
By Wu Jun · Updated 2026-10-29 · 6 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:
for f in /sys/kernel/debug/ieee80211/phy*/mt76/vow_atf; do
printf '%s: ' "$f"; cat "$f" # firmware airtime fairness
done
for f in /sys/kernel/debug/ieee80211/phy*/mt76/aql_enable; do
printf '%s: ' "$f"; cat "$f" # airtime queue limit
done
Do not redirect directly to the wildcard path. A router can expose more than one PHY, in which case the shell rejects the ambiguous redirect. Loop over the matched files and change only the radio you recorded.
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. Trigger conditions supported by the current evidence
Two independent triggers, both reproducible on MT7986 + mt76:
- Client idle/power-save behaviour: about 1 s of idle is enough for the AP transmit rate to fall to
MCS0 1SS. Withvow_atf=1it then stays there even under sustained load. - Narrow client in wide BSS: a 40 MHz-only client inside an 80 MHz BSS collapses periodically regardless of the ATF switch. Native-width operation (40 MHz BSS) is stable at 380-420 Mbps. This confirms a reproducible condition, not a specific firmware register.
5. What to do
# 1) disable the firmware feature that prevents recovery (and persist it)
for f in /sys/kernel/debug/ieee80211/phy*/mt76/vow_atf; do
echo 0 >"$f"
done
# 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).
Download: Router CSI Toolkit v0.1.3 and its SHA-256 checksum.
6. A reproducible 20-minute A/B test
Changing one debugfs value is not enough evidence. Before testing, write down the radio, client, distance, channel, width, firmware and the minimum recovery you will accept. For this test, define the baseline as the median of six warm transfers with the client already associated. A reasonable pass rule is recovery to at least 70% of that baseline within 20 seconds, but choose and record the threshold before seeing the result.
Start with a read-only evidence pack:
AP=csi0
CLIENT_MAC='<client-mac>'
OUT="/tmp/mt76-ab-$(date +%s)"
mkdir -p "$OUT"
{
date +%Y-%m-%dT%H:%M:%S%z
uname -a
ubus call system board
iw dev "$AP" info
iw phy "$(cat /sys/class/ieee80211/phy*/name 2>/dev/null | head -n1)" info 2>/dev/null || true
cat /sys/kernel/debug/ieee80211/phy*/rate_ctrl_alg
cat /sys/kernel/debug/ieee80211/phy*/hwflags
for f in /sys/kernel/debug/ieee80211/phy*/mt76/vow_atf; do
printf '%s: ' "$f"; cat "$f"
done
dmesg | tail -n 120
} >"$OUT/context.txt" 2>&1
Use an internal transfer for the load, never a public speed-test mirror. Run six transfers of six seconds, with a two-second gap, after 60 seconds of no client traffic. While each transfer runs, sample the station:
for i in $(seq 1 36); do
printf '\n--- sample %s ---\n' "$i" >>"$OUT/station.log"
date +%Y-%m-%dT%H:%M:%S%z >>"$OUT/station.log"
iw dev "$AP" station dump |
sed -n "/Station $CLIENT_MAC/,/^$/p" >>"$OUT/station.log"
sleep 1
done
The load generator can be iperf3, a local HTTP file, or another tool. Keep it identical in both arms.
On the client, record throughput for every transfer; on the AP, record the bytes sent and received for the
same interval. Repeat the complete sequence with only vow_atf changed:
# Arm A: set the original value recorded in context.txt
ORIGINAL_VOW_ATF='<original-value>'
for f in /sys/kernel/debug/ieee80211/phy*/mt76/vow_atf; do
echo "$ORIGINAL_VOW_ATF" >"$f"
done
# Arm B: change only this variable
for f in /sys/kernel/debug/ieee80211/phy*/mt76/vow_atf; do
echo 0 >"$f"
done
Do not change power save, channel, BSS width, antenna orientation, client location or background traffic between arms. If the client is a 40 MHz-only device, perform a second width A/B: HE80 versus HE40. Restore the original debugfs value after the run unless the toolkit's boot script and watchdog are deliberately enabled.
| Evidence | Why it is needed | Reject the run if |
|---|---|---|
| six throughput values per arm | shows whether recovery is repeatable | only a best run is kept |
timestamped station dump samples |
captures the rate transition | rate is sampled only once |
| AP/client byte counts | separates low PHY rate from retransmission loss | more than about 5% unexplained difference |
| radio and client width | detects the 40 MHz-in-80 MHz confounder | width or channel changed |
| firmware and driver identifiers | makes the result attributable | the build changed mid-test |
The expected pattern is not "0 Mbps versus 400 Mbps on every run". In the measured case, the first B-arm transfers were still slow and recovery took roughly 15 seconds. What matters is whether the rate converges under sustained load and whether that convergence repeats. If both arms stay fast, both stay slow, or the result changes when only one control changes back, the test is inconclusive rather than proof of a root cause.
7. What not to conclude
- Do not trust a single
tx bitratesample (it is the last rate, not an average); - Do not blame the client without direction tests (upload was fine at 600 Mbps PHY, 188 Mbps measured);
- Do not test with a public mirror: broadband variation will masquerade as Wi-Fi behaviour.
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.
- 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
okwujun.pw/en/datasets/ · verified: Reassembled chunks verified byte-identical (2026-10-11) · The local raw master has SHA-256 00373f83…95e2; the public de-identified derivative has SHA-256 527466ce…a440. They must not be confused. - Grade E · first-party measurement
Router CSI Toolkit — capture/analysis/watchdog scripts (MIT, public repository)
Router CSI Lab · 2026
github.com/jjjwu/router-csi-toolkit · verified: GitHub API verified the public repository, capture-quality preflight, successful CI and the v0.1.3 release on 2026-11-02; the archive SHA-256 is 59f144c0…adea7 · The public repository includes a dependency-free capture preflight; the browser checker uses the same container definition and evidence boundaries.