Grok Build 在 WSL2 输入延迟 430ms:一次从 strace 到 9P 文件系统的排查实录

作者:

xAI 的 Grok Build CLI(grok)在 WSL2 里打字有明显延迟(单键 100–400ms 回显),而原生 Windows、原生 Linux、以及同为 TUI 的 btop 都流畅。折腾一晚上,最终用 strace 抓到真凶:一个每帧都要跑的 ffmpeg PATH 全扫描,在 WSL 的 9P 文件系统上每次花掉 400ms。修复后按键回显从 avg 405ms 降到 5.6ms。


现象

环境输入是否延迟
WSL2 + Windows Terminal❌ 卡
WSL2 + WSLg(Zutty / WezTerm)❌ 卡
原生 Windows(grok.exe)✅ 流畅
原生 Linux✅ 流畅
WSL2 里的 btop(同为 TUI)✅ 流畅

“只有 WSL2 卡”这个特征很重要——它把嫌疑圈在 WSL 特有的层上,但也差点把我们带进沟里。

排查过程(含走过的弯路)

弯路 1:怀疑 Windows PE / interop 层

一开始 file 命令看到的是 grok-0.2.114-linux-x86_64,但在 WSL 里 which grok 指向的符号链接被误判成了 Windows PE。WSL 里跑 Windows exe 确实要过 interop 翻译,但这个 grok 是原生 ELF,方向错了。

弯路 2:怀疑 epoll 100ms 超时轮询

strace 显示输入线程在 epoll_pwait(11, [], 3, 100) 里反复空转 100ms 超时,看起来像经典 bug:

26380 epoll_pwait(11, [], 3, 100) = 0   ← 空等
26380 epoll_pwait(11, [], 3, 100) = 0   ← 又空等
26380 epoll_pwait(11, ..., 100) = 1     ← 终于收到
26380 read(0, "4")                      ← 400ms 后才读到

于是:

  1. LD_PRELOAD shim 劫持 epoll_pwait 把超时降到 5ms —— 无效(二进制是 static-pie,LD_PRELOAD 根本不加载);
  2. 直接改源码,把输入线程的 POLL_TIMEOUT 从 100ms 改成 1ms —— 依然无效。

回头细看 strace 才发现被误导了:那些 100ms 空转发生在两次按键之间(空闲期),而按键本身的 epoll 事件是及时到达的:

20.015758 epoll_pwait(11, <unfinished>
20.068647 resumed [{EPOLLIN}] = 1     ← 事件立刻到,没等满 100ms
20.068833 read(0, "7") = 1            ← 读取零延迟

输入读取根本没问题。延迟在下游——按键读进来之后,回显迟迟没写回终端。

教训:strace 输出是海量的,先找对”延迟到底发生在哪一段”再深挖,否则会被漂亮的巧合现象带偏。

转折:一个能稳定复现的探针

与其在真实终端里凭手感测,不如写一个 pty 探针:key-latency(挂在 xai-grok-pager-pty-harness 里),用 mock 后端起一个真实 session,然后逐键注入、测量每个字符出现在屏幕上的时间:

key  3 ('c'): 534.1 ms
key  4 ('d'): 425.1 ms
key  5 ('e'): 461.0 ms
...
session-prompt echo latency: avg 404.8 ms  max 534.1 ms

稳定的 ~400ms,和手感一致。现在可以对它抓 strace 了。

真凶:每帧一次 ffmpeg PATH 全扫描

在探针运行中对 pager 抓 strace(strace -f -tt -T -o log <binary> 作为父进程,避免 ptrace 权限问题),看按键 k 之后、回显之前,渲染线程在干什么:

12:57:33.181 read(0, "k") = 1                          ← 按键到达,零延迟
12:57:33.186 openat("/dev/tty") / ioctl(TIOCGWINSZ)    ← 常规操作
12:57:33.187 statx("/home/bzsc/.cargo/bin/ffmpeg")         ENOENT
12:57:33.188 statx("/home/bzsc/.local/share/pnpm/bin/ffmpeg") ENOENT
12:57:33.188 statx("/home/bzsc/.pyenv/shims/ffmpeg")        ENOENT
... 20+ 个 Linux PATH 目录 ...
12:57:33.191 statx("/mnt/c/Windows/system32/ffmpeg")   ENOENT  ← 9P,0.7ms
12:57:33.195 statx("/mnt/c/WINDOWS/system32/ffmpeg")   ENOENT  ← 9P,3.7ms
12:57:33.203 statx("/mnt/c/Windows/ffmpeg")            ENOENT  ← 9P,2.9ms
... 十几个 /mnt/c、/mnt/d 目录,每个 1–6ms ...

每次按键后的重绘,渲染线程都在做一次完整的 ffmpeg PATH 扫描。

链路是这样的:

  • scrollback/state/mod.rsprepare_layout()每帧渲染前调用 ffmpeg_available();
  • ffmpeg_available() 实现是 which::which("ffmpeg"),即逐个目录 statx,负结果完全不缓存,下次调用重扫一遍;
  • WSL 会把 Windows 的 PATH 追加进 Linux 的 PATH。于是每次扫描除了 ~20 个 Linux 目录,还要遍历十几二十个 /mnt/c/.../mnt/d/...;
  • /mnt/c9P 协议访问 Windows 文件系统,一个 statx 要 1–6ms(内核态请求 → Windows 侧翻译 → 往返);
  • 整个 PATH 扫完正好 ~400ms —— 与体感延迟分毫不差。

原生 Linux 上这段扫描只要几百微秒(全在本地 ext4),所以”只有 WSL2 卡”。而”空闲时偶尔卡、打字时卡得更明显”是因为:打字 → 每次按键都重绘 → 每次重绘都扫描。

修复

方案 A(推荐,给用户):在 WSL 里装 ffmpeg

sudo apt install ffmpeg

ffmpeg_available() 对命中结果是永久锁存的(FOUND latch),只要在 PATH 里找到一次,之后连扫描都不发生。这是最干净、零风险的解法,还顺便解锁了内联视频功能。

方案 B(给上游,已改进源码):负结果限频 + 后台线程

crates/codegen/xai-grok-pager/src/inline_media_ffmpeg.rsffmpeg_available():

  • 负结果限频:5 秒内最多做一次 PATH 扫描(之前是每帧一次);
  • 限频后的扫描放后台线程,渲染线程永不阻塞;
  • 首次探测仍同步,保证第一帧就有准确答案;中途装 ffmpeg 后,5 秒内恢复内联视频提示。

验证

同一个 key-latency 探针,修复后的 release 二进制:

修复前:avg 404.8 ms  max 534.1 ms
修复后:avg   5.6 ms  max   7.8 ms   (800 词滚动内容下)

一些可以带走的东西

  1. 先量化,再定位。真实终端里”手感卡”没法 debug;一个能稳定复现、能给出数字的 pty 探针,把 4 小时的排查压缩到 20 分钟。
  2. 别被 strace 的表面现象带偏。epoll 100ms 空转看着像根因,但那是空闲期的正常休眠;按键事件的到达其实是及时的。先问”延迟发生在哪一段”,再问”为什么”。
  3. WSL 的 PATH 扫描很贵。9P 上每个 statx 1–6ms,”每帧查一次命令在不在 PATH 里”这种代码,在原生 Linux 上无感,在 WSL 上是灾难。凡是高频路径里的 which/PATH 探测,要么缓存、要么限频。
  4. 上游修复价值:这不只是 grok 的问题,任何在渲染循环里做 PATH 探测的 TUI(以及 WSL 用户)都会踩到。这个修复已经按”对原生 Linux 零影响”的方式设计(限频只在负结果时生效,命中即永久锁存)。

环境:WSL2 (kernel 6.18.33.2-microsoft-standard-WSL2), grok build 0.2.114 (linux-x86_64), strace 6.x