我为 Emacs 构建了一个 GPU 后端

Hacker News Top 工具

摘要

作者描述了如何在 macOS 上使用 Metal、在 Linux 上使用 OpenGL 为 Emacs 构建基于 GPU 的显示后端,从而提升渲染性能并启用视频播放和动画光标等新效果,且无需修改核心重新显示引擎。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/06/25 20:13

# 我是如何为 Emacs 构建一个 GPU 后端的 | Andros Fenollosa 来源:https://en.andros.dev/blog/4b707a03/how-i-built-a-gpu-backend-for-emacs/ 几个月前,我突然迷上了一个傻问题:为什么我的 Emacs,在一台配备强大 GPU 的笔记本上,却用 CPU 来绘制所有文本?接着又引出了更多问题:为什么我不能在缓冲区里播放视频?为什么不能有动画光标效果?为什么不能在缓冲区之间做淡入淡出?我需要满足自己的好奇心,于是开始深挖。 我带着一个 AI 伙伴开始阅读代码。我发现每个字形、每个下划线、每次滚动,都由处理器重新计算和绘制。Emacs 的重新显示引擎 (`xdisp.c`) 诞生于别无选择的时代,它的优化已经精雕细琢到极致。而且,没有人能在不重写半个 Emacs 的情况下把 GPU 塞进去……直到最近。 所以我决定试一试。本是一个周末实验,结果却变成了 macOS 上的 Metal 完整显示后端、GNU/Linux 上的 OpenGL 后端、缓冲区内的视频播放器、基于着色器的光标效果,以及在 Emacs 开发者邮件列表上超过百条消息的辩论——话题从 Cairo 的性能到软件自由,再到人工智能伦理。 这篇文章的存在,是因为我想把故事讲出来,而且它可能对未来实现者有用。文章末尾,我会总结学到的教训,并给出一个与我开始时意料不同的结论。 事先诚实声明:我借助了一个 LLM 作为副驾驶来完成这个项目,从头到尾。我在公开场合被问及时也这么说。我会再次提到这一点,因为它成了整个旅程中最重要的剧情转折。 ## 阶段 1:架构决策 任何人的第一直觉都会是打开 macOS 的代码,即 Cocoa 后端 (`nsterm.m`),开始用 Metal 调用替换 CoreGraphics 调用。这是最直接的路径。而这正是我决定不做的事情。 这种做法的弊端在于它会把你绑定在一个平台上。如果我写了“带 Metal 的 Emacs”,那我只有针对 Mac 的 Emacs,没有别的。我需要编写一个显示后端抽象层,让我能为每个平台提供一个驱动程序。于是我在一张便利贴上草拟了一个三层架构: ``` flowchart TD X["Redisplay engine(xdisp.c, untouched)"]:::core --> P["src/gfxterm.cNeutral drawing policy (plain C)"]:::policy P --> D["src/gfxdrv.hDriver interface (~25 operations)"]:::iface D --> M["src/mtlterm.m (macOS)Metal driver"]:::mtl D --> G["src/glterm.c (GNU/Linux, X11)OpenGL ES / EGL driver"]:::gl classDef core fill:#37474F,stroke:#263238,stroke-width:2px,color:#fff classDef policy fill:#00897B,stroke:#00695C,stroke-width:2px,color:#fff classDef iface fill:#7CB342,stroke:#558B2F,stroke-width:2px,color:#fff classDef mtl fill:#8E24AA,stroke:#6A1B9A,stroke-width:2px,color:#fff classDef gl fill:#D32F2F,stroke:#B71C1C,stroke-width:2px,color:#fff ``` 思路是:所有绘制**逻辑**(如字形串如何组合、波浪下划线怎么画、图像如何在窗口中裁剪、滚动如何工作)都放在一个纯 C 文件中,不包含任何平台特定的代码。每个平台只需实现一个小的契约:大约 25 种基本操作,如“上传此纹理”、“绘制此四边形”、“呈现帧”。这个契约就是 `gfxdrv.h`。第一个驱动程序会是 Metal,在 `mtlterm.m` 中。 黄金法则——我强制自己遵守,从未打破的一条:**不动 `xdisp.c`**。重新显示引擎照常计算字形矩阵;我只挂接到已有的绘制接口。如果实验失败,Emacs 还是 Emacs。 事后看来,这是整个项目中最明智的决定! ## 阶段 2:Metal 后端与像素的专制 架构明确后,我深入 Metal。技术计划与任何现代文本渲染器相同: 1. 通过 CoreText 将每个字形栅格化一次,生成灰度纹理(R8 格式的字形图集)。 2. 将文本绘制为带纹理的四边形,从该图集中采样。 3. 将图像(PNG、JPEG、SVG、GIF)作为纹理上传。 4. 在 GPU 上合成整帧(保存在持久纹理中),然后呈现。 纸上谈兵,两个下午。实际呢,数周。原因有一个名字:**像素级一致性**。 我的成功标准不是“看起来不错”,而是结果必须*与原始 Cocoa 后端逐像素一致*。同一个二进制,开启与关闭 GPU,两者截图的差异必须近乎为零。我构建了一个测试框架:同时启动两个 Emacs 实例,加载相同场景,在两处截图并用 Python 和 PIL 进行比较。基准线的差异像素比例约为 0.055%,任何偏离此值的情况都是需要追查的 bug。 那个测试框架毫不留情,它暴露了一系列需要我用放大镜审视的细节: - **墨水重量**。CoreText 和我的着色器在抗锯齿处理上不同。 - **浮雕颜色**(按钮和模式行的 3D 边框)显示不正确。 - 字形垂直位置存在**差一错误**。 我们不能忽略绘制方式在方法和架构上完全不同。这使得 bug 微妙而难以发现。 ## 阶段 3:凝固的光标 在所有这些 bug 中,教会我最多的那个是光标问题。 我想要动画光标效果:跳跃时展开的环、彗星般的拖尾——这些 GPU 几乎免费就能实现的视觉糖果。我把它们作为合成层实现在帧之上,不触及底层缓冲区内容。它们完美运行……但仅限于我打字时。一旦我停止敲键盘,动画就卡在半路。罪魁祸首是苹果的同步机制 `CADisplayLink`:**在空闲时会死亡**——Emacs 的事件循环在没有用户输入时不会喂给它事件。我打字时,键盘事件泵动运行循环,一切正常;我停下时,就没人推动时钟了。 解决方案是停止依赖系统,将**所有连续性操作移到 Lisp 定时器中**。光标、缓冲区淡入淡出以及视频,全都通过 Emacs Lisp 中的一个单一“泵”来推进,该泵定期触发并告诉驱动程序“推进你所有的内容,最多呈现一次”。后来我将三个定时器统一为一个,并加入自动节拍(有淡入淡出时 60 Hz,否则 30 Hz,且在没有动画内容时自动关闭)。 这个问题解决后,macOS 方面就完成了。文本、装饰、图像、动画 GIF、行号、带自定义位图的边缘、模式行、头部行、标签栏、2x Retina/HiDPI、四种光标类型、分屏、动态 `text-scale`。与 Cocoa 逐像素完美一致。 现在是时候添加只有 GPU 能做到的事情了: - 缓冲区内的视频 - 基于着色器的光标效果 - 切换缓冲区时的淡入淡出 作为实验,我甚至拼凑了一个在 Emacs 内运行的 YouTube 前端:它搜索视频并在缓冲区直接播放,GPU 在文本之上合成帧。这是一个有趣的小玩具,只有帧由显卡绘制时才有可能。 而切换缓冲区时的淡入淡出——一种平滑的过渡,在 GPU 上仅仅是又一个着色器通道: 它相对简单,因为重新显示引擎既不知道也不关心我在上面做什么;它们只是 GPU 上的合成操作。 ## 阶段 4:打包是工作的一半 让二进制在本地机器上运行,与让其他人能够安装,是截然不同的两个世界。这一阶段没有光彩,却吞噬了整整几天。 苹果的签名和公证本身就是个迷宫。当我加入 native-comp(AOT 原生编译)时,出现了大约 1564 个 `.eln` 文件,它们也是 Mach-O 代码,也必须逐个签名并带有安全时间戳,才能通过公证。 我发布了第一个签名并公证的版本,一个 Homebrew cask,并与一位同事开始日常使用。它运行良好。我很开心。我以为最困难的部分已经过去了。 然后,我决定把它展示给 Emacs 邮件列表。 ## 阶段 5:emacs-devel,或如何在邮件线程中学会谦逊 2026 年 6 月 8 日,我向 `emacs-devel` 发送了一封 `[RFC PATCH]`,主题为*“带有中性驱动层的 GPU 显示后端(macOS 上的 Metal)”*。我小心地措辞:我不是在推销“带 Metal 的 Emacs”,而是在推销**抽象层**。一个中性绘制层加上每个平台的小型驱动,通过一个小型 vtable 实现,Metal 作为第一个驱动,`xdisp.c` 不变,一致性通过自动化框架验证,并且 FSF 版权分配已经归档。 我的第一个错误是发送了完整的补丁,而不是先讨论设计思想、架构和最小演示的 RFC。回复很快到来: **Sean Whitton** 写道: > “人们通常不会在没有先在列表上讨论设计问题的情况下,直接发布这么大的补丁。鉴于此,我只想问:这不是 LLM 生成的吧?” 我诚实地回答: > “100% 使用 LLM 创建。我明白这是一个相当大的新增内容,如果被拒绝,我也不会介意。我的目的是分享它,因为它已经完整开发 […],我每天使用它没有任何问题(还有几位同事一起用)。” Whitton 的回复礼貌而决绝: > “恐怕存在政策冲突。GNU 项目目前不接受任何 LLM 生成的贡献。不过,感谢你对 Emacs 的兴趣。” 就这样,从“是否会合并”的角度来看,这个项目在不到一天内就结束了。GNU 项目目前不接受 LLM 生成的贡献。句号。当与硬性政策相抵触时,任何技术辩论都无关紧要。 但我没料到的是,这个线程远没有关闭,反而同时朝三个方向发散。 ### 转向“研究对象” **Dmitry Gutov** 为后续定下了基调: > “我们无法接受此代码作为贡献,但如果你已经在本地使用,它可能作为研究对象有用。不过,若能有 Linux 端口的测试,或许更有帮助。” 换句话说,作为代码它进不来,但作为参考或实验,可能有些价值。而这,也正是我接下来要做的种子。 ### 自由辩论 这时 Richard Stallman 介入,将线程主题分裂为*“没有 GPU 特定功能的 GPU 特定代码?”*,并提升到道德层面: > “总的来说,GPU 是软件自由的灾难:它把你的电脑变成了监狱。” 后来他又坚持道: > “他们并没有给用户戴上物理锁链,但他们给用户的计算戴上了数字锁链。GPU 是我们正在争取解放人们的重要战场之一。” 并非所有人都买账。**Arsen Arsenović** 以线程中最尖锐的技术异议回应: > “这是一种基于轻率词语联想的奇怪比较。GPU 编程 API(如 Vulkan 或 OpenGL)完全可以软件实现,实际上也确实在 Mesa 中使用完全自由的软件实现,所以从这一角度来看,使用它们没有坏处。” 而 **Madhu** 提出了一个尴尬的事实,拆解了部分讨论: > “如果你在现代(比如 2021 年后)的 Intel 机器上使用 X11,你所有的 2D 图形很可能都经过 GPU 后端,X11 窗口只是纹理而已。” 他们说得对,我可以证明:我的 OpenGL 驱动也能在 Mesa 的软件光栅器 (`llvmpipe`) 上运行。事实上,一致性测试套件就在其上无头运行。换句话说,代码并不需要专有 GPU 固件来执行。我在线程中说了这一点,但当时辩论已拥有了自己的生命力。 ### 潜在的技术怀疑 最实质性的批评,也是让我思考最多的,既不是政治性也不是意识形态性的。它来自 **Eli Zaretskii**,历史维护者之一: > “我并不惊讶在合理大小的帧上使用 GPU 后端并没有带来显著的性能提升:当前显示引擎的设计是针对 CPU 驱动的重绘进行优化的,要更好地利用 GPU,可能需要进行更彻底的重新设计,而不仅仅是做一个独立的后端。” 而 **Gerd Möllmann**,重绘引擎维护者,以优雅的漠然终结了这一论点: > “在我看来,这像是在增加 GPU 支持,却没有增加 GPU 独有的特性,也没有改变重绘架构 […] 可能带来性能提升,也许吧,不知道,但这超出了我的兴趣范围。” 他们部分正确。引擎设计为在 CPU 上重绘小的脏矩形,Cairo 在这方面做得异常出色。在不重新设计引擎的情况下塞入 GPU,是有天花板的。但他们也部分地提出了一个只有通过第二个后端和实际数据才能看清的观点。而这两者我都没有。 社区在不知不觉中为我写下了接下来几周的路线图:**构建第二个后端**(以验证抽象层),并**带来诚实的数据**(以消除 Eli 的疑虑)。 ## 阶段 6:OpenGL 驱动,或如何兑现架构赌注 如果我的设计最大的承诺是“中性层原封不动重用,只更换驱动”,那么唯一的证明方式就是从头写第二个驱动,看看有多少共享代码能幸存而不变动。 我选择了 GNU/Linux 上的 **OpenGL ES 3 with EGL**,基于 X11。这是 Metal 驱动的跨平台对应版本:我用 FreeType 将字形栅格化到 GPU 图集,渲染到 FBO,然后通过 blit 到窗口表面以及 `eglSwapBuffers` 呈现。绘制策略 `gfxterm.c` 完全重用。它成功了:第二个后端与标准的 GTK/Cairo Emacs 逐像素一致,在 macOS 上同样全面的测试套件中运行,既在真实 X 服务器(带 GPU)上,也在 Xvfb 下无头运行(用于自动化测试)。 那一刻,架构不再是一个承诺,而成为事实。编写整个驱动,包含所有 EGL 和 FreeType 的古怪细节,所花时间远少于第一个驱动,因为所有棘手的逻辑已经在中性层中编写并测试过。 但 Linux 带来了它自己的地狱,整个项目中最难的 bug 就在这里。最糟的一个:切换缓冲区时,在一个闪烁周期(一个 vblank)内,**半绘制的启动仪表盘**泄露出来,出现其他内容的鬼影。我花了几天才发现,根本原因并非我的 GPU 代码,而是 X11 双缓冲扩展(XDBE)的后缓冲——Emacs 在启动时绘制了它,而我的后端从此再也没碰过它。 然而,经过一段时间的努力和调试,OpenGL 驱动变得稳定且可用。虽非完美,但足以运行性能测试工具并与 Cairo 进行比较。 ## 阶段 7:优化与带来诚实的数据 结果,在一台配备**集成 AMD Radeon (Renoir)** GPU 的笔记本上,1616x912 的帧,一个 8000 行的语法高亮缓冲区: | 工作负载 | 原生 (X/cairo) | GPU (OpenGL) | 比率 | |-----------|----------------|--------------|------| | 行滚动 | 530 fps | 487 fps | 0.92x | | 页滚动 | 297 fps | 296 fps | 1.00x | | 全帧重绘 | 247 fps | 294 fps | **1.19x** | | 打字 | 1857 fps | 1311 fps | 0.71x | | 图像滚动 | 1359 fps | 1239 fps | 0.91x | 在笔记本大小的帧上,打字和行滚动**仍然比 Cairo 慢**——Cairo 非常擅长裁剪小矩形。我的下限是每次重绘一次 EGL 缓冲区交换;Cairo 的只是一个小损伤矩形,没有交换链。就绝对值而言,一切远高于可感知范围(最坏情况,打字仍远高于 60 fps,并且实际上由于操作系统帧定时,Cairo 可能不会真的达到那么高的 fps,但相对比较仍然有效)。 我的主要收获:改进纹理上传(将字形图集预先绑定到绑定点,使用恒定结构缓冲区)可能会缩小差距,但重新设计引擎以使用 GPU 友好的重绘方式(基于图块的脏矩形?)才是真正释放潜力的方向。这超出了我当前的范围。 ## 结论:这不是关于 GPU 的 我一开始认为这个故事是关于 Emacs 和 GPU 的——关于突破显示后端的边界,带给用户更流畅的体验。但我错了。 这个故事是关于**约束**的。最终,阻止这个项目被采纳的并非技术限制,而是政策约束:GNU 不接受 LLM 生成的代码。没有谈判余地。如果我用传统方式编写它,没有 AI 辅助,结果可能会不同——尽管我仍然需要面对架构决策和社区审查。 这个故事也是关于**架构**的。我做出的最好决定是构建一个中性层,这让我能够通过编写第二个驱动来严格验证设计。抽象层奏效了:两个驱动共享约 85% 的代码。如果我在 Cocoa 后端中直接在 Metal 中硬编码,两个平台之间的重构将是艰巨的。相反,Linux 端口的实现只花了几个月,而非数年。 最后,这个故事是关于**学习**的。关于追踪难以捉摸的像素差异。关于调试那个阴魂不散的差一光标。关于理解重绘引擎不是为 GPU 而建,并且我无法不受惩罚地将其塞入。 我仍然每天都在使用 GPU 后端。它稳定、像素完美,在 HiDPI 屏幕上滚动比 Cairo 稍快。它永远不会被合并到上游 Emacs,但我不为此后悔。 我还了解到:有时候,最好的贡献不是被合并的代码,而是教给你的东西。

相似文章

Gooey:面向 Zig 的 GPU 加速 UI 框架

Hacker News Top

Gooey 是一个面向 Zig 的 GPU 加速 UI 框架,通过 Metal、Vulkan/Wayland 和 WebGPU/WASM 支持 macOS、Linux 和浏览器。它提供声明式 UI、动画、主题、无障碍和零外部依赖。

在 Linux 和 Unix 系统上编译 Emacs 以提升性能的技术指南

Lobsters Hottest

本技术指南提供了在各类 Linux 发行版上从源码编译 Emacs 的详细步骤,旨在通过 CPU 特定指令集和 Wayland 等现代显示协议来优化性能。文中还涵盖了依赖项配置以及微调原生 Lisp 编译器以提升执行速度的相关内容。

Rigel:逆向工程Apple M4 Max GPU上的Metal 4.1张量计算路径

arXiv cs.CL

Rigel是对Apple M4 Max GPU上Metal 4.1张量计算路径的经验性表征,揭示了fp8 matmul2d是模拟的(而非硬件加速),该操作完全在GPU着色器核心上执行,没有专用的矩阵数据路径,并重构了不透明的协作张量片段布局。

软件界的Emacs化

Hacker News Top

作者讲述了在终端中阅读 Markdown 的烦恼,并描述了如何使用 Claude 快速构建一个自定义的 macOS Markdown 查看器(MDV.app),展示了 AI 如何让人能够迅速创建个人软件工具。