【CSDN 编者按】这是一个基于 Rust 来尝试编写 Linux GPU 的内核驱动程序,本文分享了研发的心路历程!
原文链接:https://asahilinux.org/2022/11/tales-of-the-m1-gpu/
本文为 CSDN 编译整理,未经授权,禁止转载!
什么是 GPU?
你可能知道 GPU 是什么,但你了解它们的底层逻辑吗?几乎所有现代 GPU 都拥有以下几个主要组件:● 若干着色器核心(shader core):通过运行用户定义的程序来处理三角形(顶点数据)和像素(片段数据)。每个 GPU 都有一套自定义的指令集。● 光栅化单元、纹理采样器、渲染输出单元以及其他组件:这些组件与着色器协同工作,将应用程序中的三角形转换为屏幕上的像素。具体的工作方式因 GPU 而异。● 命令处理器:从应用程序获取绘图命令,并设置着色器核心来处理。其中包括一系列数据,比如三角形列表、全局属性、纹理、着色器程序以及保存最终图像的内存位置等等。然后,将这些数据发送到着色器核心和其他单元,并指示GPU完成实际的渲染。● 内存管理单元:使用 GPU 的应用程序都有各自的内存区域,该组件的作用就是限制对这些内存的访问,以防止各个应用程序崩溃或相互干扰。为了以合理、安全的方式组织这些部件,现代 GPU 驱动程序主要分为两大部分:用户空间驱动程序和内核驱动程序。用户空间部分负责编译着色器程序,并将 API 调用(如 OpenGL 或 Vulkan)转换为特定的命令列表,供命令处理器渲染场景。而内核部分则负责管理内存管理单元, 并处理不同应用程序的内存分配和释放,以及何时、通过何种方式将命令发送到命令处理器。所有现代 GPU 驱动程序在所有主流操作系统上的工作方式都是如此。用户空间驱动程序和内核驱动程序之间有一些由GPU自己定义的API。通常每个驱动程序使用的 API 都是不同的。在 Linux 中,我们称之为 UAPI,但每个操作系统都有类似的 API。用户空间可以通过这个 UAPI 向内核请求分配或释放内存,并将命令列表提交给 GPU。这意味着,如果想在 Linux 中使用 M1 GPU,我们需要两个程序:一个内核驱动程序和一个用户空间驱动程序。
用户空间驱动程序的逆向工程
2021 年,我们开始对 M1 GPU 实施逆向工程,并与 Dougall Johnson(专门负责记录 GPU 着色器架构)合作,对所有用户空间位进行了逆向工程,包括着色器和渲染所需的所有命令列表结构。这是一项繁重的工作,但我们用了不到一个月的时间就画出了第一个三角形。
但是,如何在没有内核驱动程序的情况下,使用用户空间驱动程序的呢?很简单,使用 macOS。首先,对 macOS 的 GPU 驱动程序 UAPI 进行逆向工程,分配内存,并将命令提交给 GPU,这样即便没有内核驱动程序,用户空间驱动程序也可以正常工作。接着,为 Linux 用户空间图形栈 Mesa 编写 M1 GPU OpenGL 驱动程序,仅仅几个月后,我们就通过了 75% 的 OpenGL ES 2 一致性测试,所有这些工作都是在 macOS 上完成的。
今年早些时候,我们一路领先,在开源 Mesa OpenGL 栈(运行在 macOS 的苹果内核驱动程序之上)上运行游戏。下面,我们来解决 Linux 内核驱动程序的问题。
神秘的GPU固件
今年4月,我决定开始琢磨如何编写M1 GPU内核驱动。在最初的几个月里,我全身心投入为 GPU 编写和改进 m1n1 管理程序跟踪器,而且我发现了在 GPU 的世界里非常不寻常的一件事。通常,GPU 驱动程序会负责一些细节,例如安排和调整任务的优先级,以及在某些作业运行时间过长时抢过主动权,以允许应用程序公平地使用 GPU。有时电源管理由驱动程序负责,而有时则由运行在电源管理协处理器上的专用固件负责。有时,还有其他固件负责命令处理的一些细节,但一般内核驱动程序都不知道这些固件的存在。最后,特别是对于像 ARM Mali 这类更简单的“移动式”GPU,驱动 GPU 完成渲染工作的硬件接口通常非常简单,比如 MMU(工作方式与 CPU MMU 或 IOMMU 类似),然后由命令处理器直接获取指向用户空间命令缓冲区的指针(通常存储在某种寄存器或环形缓冲区内)。因此,内核驱动程序除了管理内存和安排 GPU 的工作外,实际需要负责的工作并不多, Linux 内核 DRM(Direct Rendering Manager,直接渲染管理器)子系统已经提供了大量帮助程序,因此编写驱动程序非常容易。虽然有一些棘手的问题,比如抢占,但这些问题对 GPU 在新驱动程序中正常工作的影响并不大。但 M1 GPU 不同……就像 M1 芯片的其他部分一样,GPU 有一个名叫“ASC”的协处理器,负责运行苹果固件并管理 GPU。这个协处理器是一个完整的 ARM64 CPU,运行了一个名叫 RTKit 的苹果专有实时操作系统,由它负责处理一切,比如电源管理、命令调度和抢占、故障恢复,乃至性能统计器、统计数据以及温度测量等等。事实上,macOS 内核驱动程序根本不与 GPU 硬件通信。所有与 GPU 的通信都是通过固件进行的,使用共享内存中的数据结构来传达指令。而且这样的结构还有很多,比如:● 初始化数据:用于配置固件中的电源管理设置以及其他 GPU 全局配置数据,还包括颜色空间转换表,原因不明。这些数据结构有将近1000个字段,我们至今仍未全部弄清楚其具体的作用。● 提交管道:用于处理 GPU 队列的环形缓冲区。● 事件消息:固件在发生某些情况(如命令完成或失败)时发回驱动程序的消息。● 统计信息、固件日志和跟踪消息:用于收集 GPU 的状态信息和调试。● 命令队列:应用程序的待处理 GPU 工作列表。● 缓冲区信息、统计信息和页面列表结构:用于管理平铺顶点缓冲区。● 上下文结构以及其他小部件:记录 GPU 固件的运行。● 顶点渲染命令:告诉 GPU 中负责顶点处理和平铺的部分如何处理来自用户空间的命令和着色器,从而运行整个渲染通道的顶点部分。● 片段渲染命令:告诉 GPU 的光栅化和片段处理部分如何将顶点处理的平铺顶点数据渲染到帧缓冲区中。实际的处理比这更复杂。顶点和片段渲染命令实际上是非常复杂的结构,其中有许多嵌套结构,而且每个命令上都有一个指针指向“微序列”——由 GPU 固件解释的小命令,就像自定义虚拟 CPU。通常这些命令会设置渲染过程,等待渲染完成,然后清理……但它也支持时间戳命令,甚至是循环和算术运算。所有这些结构都需要提供渲染的详细信息,例如指向深度和模板缓冲区的指针、帧缓冲区大小、是否启用 MSAA(Multisample anti-aliasing,多重采样抗锯齿)及其配置方式、指向特定的辅助着色器程序,以及其他等等。事实上,GPU 固件与 GPU MMU 的关系很奇怪。二者使用了同一个页表。固件会直接使用 GPU MMU 的页表基址指针,并将其配置为 ARM64 的页表。所以,GPU 内存就是固件的内存。固件自身以及与驱动程序的大部分通信都使用了一个共享的“内核”地址空间(类似于 Linux 中的内核地址空间),而一些缓冲区是与 GPU 硬件共享的并具有“ 用户空间”地址,这些地址在每个使用 GPU 的应用程序中也能够有单独的地址空间。那么,我们能否将所有这些复杂性转移到用户空间,并让它设置所有顶点或片段的渲染命令?不行!由于所有这些结构与固件本身都位于共享的内核地址空间中,并且它们之间有大量指针,因此在使用 GPU 的不同进程之间并不是独立的。所以,我们不能让应用程序直接访问它们,因为它们有可能会破坏彼此的渲染。这就是我们能在 macOS UAPI 中找到了所有这些渲染细节的原因。
使用 Python 编写GPU 驱动程序
由于正确设置所有这些结构关系到 GPU 与固件是否会崩溃,因此我需要一种在逆向工程时快速试验它们的方法。值得庆幸的是,Asahi Linux 项目有一个款工具:m1n1 Python 框架。因为我已经在为 m1n1 管理程序编写 GPU 跟踪器,并用 Python 编写结构定义,所以我决定使用 Python 编写 GPU 的内核驱动程序,使用相同的结构定义。Python 非常适合这项任务,因为我可以使用 Python 进行快速迭代开发。另外,Python 可以使用基本的 RTKit 协议通信,并解析崩溃日志,我为此改进了工具,这样在固件崩溃时就可以看到固件的行为。所有这些工作都是在开发机器上运行脚本完成的,我的开发机器通过 USB 连接到了 M1 机器上,因此每次测试时,只需要重启开发机器即可,而且测试周期非常快。
起初,驱动程序的大部分实际上只是一堆硬编码的结构,但最终我成功地渲染了一个三角形。
不过,这只是一个七拼八凑的演示。我只是想在动手编写 Linux 内核驱动程序之前,确保自己真正理解内部机制,以确保能够正确设计驱动程序。虽然只渲染一帧非常简单,但我希望能够渲染多帧,并测试一下并发和抢占等。所以,我所需要的是一个真正的“内核驱动程序”。但这真的可以用 Python 实现吗?
事实证明,Mesa 有一个名叫 drm-shim 的工具,可以模拟 Linux DRM 内核接口,并在用户空间中使用一些假的接口替换掉它们。通常,我们用这个库来处理着色器 CI 等,但我们也可以用它来做一些更疯狂的处理。
我是否可以这样做:让 Inochi2D 在 Mesa 上运行,后者是运行在 drm-shim 之上的 M1 GPU 驱动程序,而 drm-shim 运行在一个嵌入式 Python 解释器上,将命令发给在 m1n1 开发框架上运行的 Python 原型驱动程序,后者再通过 USB 与真正的 M1 机器通信并收发数据,从而驱动 GPU 固件进行渲染?听起来不太靠谱?
然而,这真的可行!
编写 Linux 内核的新语言
由于我的 Mesa+Python 驱动程序真的可以运行,我开始更好地了解内核驱动程序的内部机制以及必须实现的功能。事实证明,内核驱动程序需要完成的任务很多。首先,我必须同时兼顾 100 多个数据结构,一旦出现任何问题,一切都会崩溃。固件不会做任何检查(可能是为了性能),一旦遇到错误的指针或数据,它就会崩溃或或盲目地覆盖数据。更糟糕的是,如果固件崩溃,唯一的恢复方法就是重启机器。Linux 内核 DRM 驱动程序是用 C 编写的,但 C 不是编写复杂的数据结构管理的最佳语言。我必须手动跟踪每个 GPU 对象的生命周期,一旦发生任何错误,都有可能导致崩溃甚至安全漏洞。我要怎样才能做到这一点?可能出错的地方太多了,C 语言根本帮不了我。最重要的是,我必须支持多个版本的固件,苹果的固件结构定义在不同版本之间并不一致。作为实验,我添加了对第二个版本的支持,最终被迫修改了 100 多次数据结构。在 Python 演示中,我可以通过一些元编程来实现,根据版本号来构建不同的结构字段,但 C 语言中没有类似的功能。我必须使用一些技巧,例如使用不同的 #define 多次编译整个驱动程序。大约在同一时间,关于 Rust 很快被 Linux 内核正式采用的传言开始出现。多年来,Rust for Linux 项目一直致力实现这种支持,看起来他们的努力即将有成果。我可以用 Rust 编写 GPU 驱动程序吗?我没有太多使用 Rust 的经验,但根据我的了解,这种语言很适合编写 GPU 驱动程序。我对两个问题特别感兴趣:Rust 是否可以帮助我模拟 GPU 固件结构的生命周期(即使这些结构与 GPU 指针相关联,从 CPU 的角度来看这算不上真正的指针),Rust 宏是否可以处理好多个版本的问题。因此,在开始内核开发之前,我向 Rust 专家寻求帮助,并在简单的用户空间 Rust 中制作了 GPU 对象模型的一个原型。Rust 社区非常友好,有几个人帮助我完成了所有工作。在此表示感谢!看起来,选择 Rust 似乎可能。但是,Rust 尚未被主流 Linux 接受,这意味着我即将进入一个未知的领域。这将是一场赌博。犹豫再三,我内心一直有个声音告诉我,Rust 是正确的选择。我与 Linux DRM 的维护人员就此进行了交谈,他们似乎也接受了这个想法,所以,我决定试试看。
使用 Rust 编写 GPU 内核驱动程序
由于这将是第一个使用 Rust 编写的 Linux GPU 内核驱动程序,因此我有许多工作要做。我不仅需要编写驱动程序,而且还需要为 Linux DRM 图形子系统编写 Rust 抽象。虽然 Rust 可以直接调用 C 函数,但这样做就无法享受 Rust 的安全保证。因此,为了从 Rust 安全地调用 C 代码,首先我必须编写包装器,提供一个安全的类 Rust API。最终,我编写了一个将近 1500 行代码的抽象,因为优秀且安全的设计需要大量的思考,而且还需要重写许多代码。
8 月 18 日,我开始编写 Rust 驱动程序。最初,这个驱动程序依赖 C 代码来处理 MMU(部分代码是从 Panfrost 驱动程序复制过来的),但后来我决定用 Rust 重写所有代码。在接下来的几周里,我根据之前制作的原型添加了 Rust GPU 对象系统,然后用 Rust 重新实现了 Python 演示驱动程序的所有其他部分。
随着使用 Rust 的次数增多,渐渐地我爱上了这门编程语言。感觉 Rust 的设计可以引导你设计出更好的抽象和软件。Rust 的编译器非常苛刻,但代码一旦通过编译,你就可以相信它能可靠地工作。有时,我很难让编译器满意我尝试使用的设计,随后我就会意识到我的设计存在问题。
逐渐地,我的驱动程序有了眉目。9 月 24 日,我终于使用我全新的 Rust 驱动程序渲染了第一个立方体。
更不可思议的是,几天后,我就可以运行完整的 GNOME 桌面会话了。
Rust 很神奇
一般来讲,编写这样的一个复杂的内核驱动程序,想从简单的演示应用程序发展到支持整个桌面、多个应用程序并发使用GPU的系统,会引发很多竞争条件、内存泄漏、使用后释放内存的问题,以及其他各种问题。
但这一切问题都没有发生。我只修复了一些逻辑错误和内存管理代码核心的一个问题,而其他一切都可以稳定运行。Rust 真的很神奇。它的安全特性可以保证驱动程序的线程与内存安全,并引导我们实现安全且良好的设计。
当然,代码中总是存在一些不安全的因素,但是由于 Rust 会迫使你从安全抽象的角度进行思考,因此出现 bug 的可能性也保持在很低的水平。但是,有些安全问题仍然无法避免。例如,我的 DRM 内存管理抽象中存在一个 bug,最终可能会导致在所有分配的内存被释放之前,分配程序本身先被释放。但是由于这类错误仅限于特定的代码,因此往往是很容易发现的主要问题(可以通过代码审查发现)。因此,你只需要单独考虑特定的代码模块以及与安全相关的部分,而不必担心它们与其他所有内容的交互,最终你需要担心的错误数量也会很少。Rust 真的很神奇,若非亲身尝试,否则很难形容。
另外,还有错误和清理。在C语言中,我们需要通过 goto cleanup 风格的错误处理来清理资源,这些处理很容易出错,但 Rust 没有这个问题。仅此一点,Rust 就值得尝试。更不用说,自动化的迭代器和引用计数等等。