高质量性能实战案例:作者优化Rust关键代码路径获得27倍性能提升,并深入讨论为何拒绝接受AI生成的修复方案。
两年前,我开源了 KeyEcho,一个小型桌面应用,在你按下按键的瞬间播放机械键盘声音。它获得了 800 多个星标。然后我在 2024 年 7 月发布了 v0.0.5,之后就没再维护了。
问题从未停止。人们要求音包、报告平台 bug,并继续使用一个我已经停止维护的东西。这个月我回来了,在一个 PR 中发布了 1.0:130 个文件,新增 11,405 行,删除 9,292 行。核心内容是音频热路径的重建。缓存查找的微基准从 1184.07 ns/op(每次按键复制 66.84 KiB)降至 43.50 ns/op(零字节样本复制)。这是平均切片的 27 倍,最大切片的 38 倍。
我用一个 AI agent 来编写大部分代码。这篇文章讲的是重构的工作原理、Rust 为什么使其能够安全快速地完成,以及 agent 建议的一处看起来干净但会无声地破坏一个功能的改动。
全局键盘钩子在每个按键事件时触发。每次按下的第一个按键事件被推入一个有界队列。音频线程从队列中拉取,将按键映射到所选音包的一个切片,并在本地播放。
因为这个路径很短,留在其中的任何东西都要在每次按键时付出代价:任何解码工作、任何内存分配、任何样本复制、任何锁。整个游戏就是把那些操作赶出这个每按键的路径。
功劳该给谁就给谁。v0.0.5 是一个轻量、快速的东西:Tauri + Rust、小的构建体积、低内存占用;针对每个平台的 API 手写原生键盘监听,几乎没有 unsafe;无损 WAV 音频,没有解压步骤;它已经在 LRU 中缓存解码的音频,所以按同一个键两次不会解码两次。它运行了两年,几百个人使用过。它不是慢速软件。
但"已经很快"和"没有改进空间"是两回事。即使在缓存命中时,每次按键仍然会复制样本(基准中是 66.84 KiB)并通过一个与音包切换和音量变化共享的全局互斥锁。缓存未命中时仍然会现场解码。1.0 删除的正好是这些:那个复制、那个锁,以及缓存未命中根本不会发生这一事实。
预解码一次,当选择一个音包时。 一个音包是一个包含 sound.ogg 和 config.json 的文件夹。配置将每个按键映射到音频的一个切片:按键 -> [start_ms, duration_ms]。当你选择一个音包时,每个切片都提前解码一次。没有什么在按键时解码。
去重相同的切片。 许多按键指向同一个切片,所以按键解码会多次解码相同的音频。构建器在 (start_ms, duration_ms) 对上键控一个映射,将每个唯一切片解码一次,并共享它。
零复制播放。 解码的样本存储在 Arc<[f32]> 中。按键查找返回该 Arc 的克隆,这是一个引用计数增加,不是样本复制。每按键的成本降至零字节复制。
丢弃全局互斥锁。 旧路径用互斥锁保护当前声音和音量。新播放句柄在 ArcSwapOption<KeySound> 中保存当前声音,在 AtomicU32(通过 f32::to_bits)中保存音量。查找按键是一个无锁加载:
预解码用前期工作换取 RAM,所以需要两个护栏。按键事件通过有界队列,所以一个突发应用背压而不是无限制地增长内存。一个音包必须符合 10 MiB 的解码样本预算:加载前,应用从唯一切片的时长估算解码大小并拒绝任何更大的。预解码只有在有界时才是安全的。
这些是查找路径的微基准,不是端到端的扬声器延迟,这取决于你的操作系统和硬件。方法和内存预算在 docs/performance.md 中,基准在仓库中发布。如果你不信任,运行 pnpm run bench:audio。
我在这个 diff 中依赖了 agent。感到安全的原因是编译器。
借用检查器和类型系统是 AI 生成代码的第一个审查者。大多数错误的代码活不过 cargo check。将共享音频缓冲区移到 Arc<[f32]> 并移除互斥锁正是那种错误是别名或 Send/Sync 错误的改动,而 Rust 在编译时拒绝那些,在它们到达人工审查或用户之前。agent 写的 diff 越多,那个护栏就越值钱。
不是"它编写了应用"。有用的工作范围更窄,说实话,也更有价值。
迁移助手。 Tauri 1 到 2 涉及配置、权限、更新器和每个插件边界。一个已经读过所有迁移文档的伙伴可以节省真正的时间。
热路径审计员。 它逐行地和我一起检查了旧的播放路径,并推动了上面的预解码、共享缓冲区、无互斥锁的重构。
待办项分类。 我让它读取每个开放 issue,按照哪些属于 1.0 来排序。其中一个是一个 10 个月前的请求,来自某个提出愿意为特定声音付费的人。在 1.0 后回复它变成了项目的第一个付费客户。待办项是我停止了阅读的需求。
基准和文档纪律。 它保持性能注释的结论优先、可重现,以及诚实地对待数字做什么和不做什么的测量。
我很少给逐步的命令。我给了目标("让样本复制在关键路径中归零"、"按照哪些属于 1.0 来排序待办项")并在检查点审查。加速本身来自重构:预解码、共享缓冲区、丢弃的互斥锁、有界队列。agent 是让找到、完成和验证整个循环足够快以至于实际发生的东西。
这是我想让你窃取的那个。
Linux armv7 构建的 CI 在 QEMU 下失败了:libgit2 无法获取 git 依赖。agent 的修复很干净。在音频 crate 上删除 git 固定,改用 crates.io 版本。CI 转绿了。
我拒绝了它。那个固定的存在是有原因的,这个原因在代码中没有写任何地方。它将 cpal 保持在 0.18,它发布了默认设备重新路由(及其 DeviceChanged 通知),当你拔出耳机或切换到蓝牙扬声器时使声音跟随你。恢复到 crates.io 版本无声地将 cpal 降级到 0.17.3 并删除了设备跟随。两个用户 issue,#41 和 #20,正好是关于这个行为的。没有测试覆盖"拔掉设备并且声音跟随",所以除了知道固定为什么在那里之外,没有什么会捕捉到这个回归。
真正的修复是用 cargo vendor 供应依赖,并在 QEMU 内离线构建。那个固定从未移动。
两个我现在视为规则的教训:
在每个固定、hack 和魔数旁边写下"为什么",在注释或项目的 AI 规则文件中。 你的 agent 对放它那里的事件没有记忆。它是一个才华横溢的工程师,对你的项目有零背景。
永远不要接受一个你没有要求的依赖版本变化,即使 CI 是绿的。 绿色管道证明测试通过了。它不证明测试覆盖了你将要失去的东西。
armv7 构建在仿真下运行了大约两小时,而且没有明确的 32 位 ARM Linux 用户。我从 1.0 中删除了它。
一个 agent 没有沉没成本或机会成本的感觉。如果你让它,它会永远优化一个构建目标。它可以任意接近"完成"。决定停下来是一个人类的工作。
从发布这个来的两个较小的注释。
在发布前,我在 git worktrees 中运行了一个 AI 审查通过,所以多个 agent 可以扫描代码并提出修复建议,并行进行,而不触及主树。每个 agent 的输出是一个建议,只有在审查后才合并。探索可以并行化。综合仍然必须在一个头脑中发生,因为每个 agent 只看到自己的切片。
模型也开始专门化了。Claude Fable 感觉像一个在思考框外的工程师,并不断浮现我错过的角度。GPT-5.6 是我用过的最好的执行器:给它一个已决定的计划,代码回来时接近完美。感觉像在运行一个两人团队,其中一个思考,一个构建,而我决定并签署。
仍然免费,仍然开源。AGPL-3.0、小的原生安装程序、无账户、无分析。在 Tauri 2 + SolidJS 上重建。签署的 Windows 构建和公证的 macOS 构建,这减少了未签署的应用警告和反病毒误报。x64 和 ARM64 的 Linux 包。
你可以不下载任何东西就试用它:打开 keyecho.app 并输入,你会在浏览器中听到它。基准和方法在仓库中。
我写一个关于用 AI agent 发布真实生产系统的每周构建日志,仅有真实数字。在 upweb.dev 订阅。