当前位置: 早知道-热集合 > 正文

M1 GPU 的神话:编写自己的内核驱动程序


作者|AsahiLina

译者|弯月

出品|CSDN(ID:CSDNnews)

什么是GPU?

你可能知道GPU是什么,但你了解它们的底层逻辑吗?几乎所有现代GPU都拥有以下几个主要组件:

●若干着色器核心(shadercore):通过运行用户定义的程序来处理三角形(顶点数据)和像素(片段数据)。每个GPU都有一套自定义的指令集。

●光栅化单元、纹理采样器、渲染输出单元以及其他组件:这些组件与着色器协同工作,将应用程序中的三角形转换为屏幕上的像素。具体的工作方式因GPU而异。

●命令处理器:从应用程序获取绘图命令,并设置着色器核心来处理。其中包括一系列数据,比如三角形列表、全局属性、纹理、着色器程序以及保存最终图像的内存位置等等。然后,将这些数据发送到着色器核心和其他单元,并指示GPU完成实际的渲染。

●内存管理单元:使用GPU的应用程序都有各自的内存区域,该组件的作用就是限制对这些内存的访问,以防止各个应用程序崩溃或相互干扰。

为了以合理、安全的方式组织这些部件,现代GPU驱动程序主要分为两大部分:用户空间驱动程序和内核驱动程序。用户空间部分负责编译着色器程序,并将API调用(如OpenGL或Vulkan)转换为特定的命令列表,供命令处理器渲染场景。而内核部分则负责管理内存管理单元,并处理不同应用程序的内存分配和释放,以及何时、通过何种方式将命令发送到命令处理器。所有现代GPU驱动程序在所有主流操作系统上的工作方式都是如此。

用户空间驱动程序和内核驱动程序之间有一些由GPU自己定义的API。通常每个驱动程序使用的API都是不同的。在Linux中,我们称之为UAPI,但每个操作系统都有类似的API。用户空间可以通过这个UAPI向内核请求分配或释放内存,并将命令列表提交给GPU。

这意味着,如果想在Linux中使用M1GPU,我们需要两个程序:一个内核驱动程序和一个用户空间驱动程序。

用户空间驱动程序的逆向工程

2021年,我们开始对M1GPU实施逆向工程,并与DougallJohnson(专门负责记录GPU着色器架构)合作,对所有用户空间位进行了逆向工程,包括着色器和渲染所需的所有命令列表结构。这是一项繁重的工作,但我们用了不到一个月的时间就画出了第一个三角形。

但是,如何在没有内核驱动程序的情况下,使用用户空间驱动程序的呢?很简单,使用macOS。首先,对macOS的GPU驱动程序UAPI进行逆向工程,分配内存,并将命令提交给GPU,这样即便没有内核驱动程序,用户空间驱动程序也可以正常工作。接着,为Linux用户空间图形栈Mesa编写M1GPUOpenGL驱动程序,仅仅几个月后,我们就通过了75%的OpenGLES2一致性测试,所有这些工作都是在macOS上完成的。

今年早些时候,我们一路领先,在开源MesaOpenGL栈(运行在macOS的苹果内核驱动程序之上)上运行游戏。下面,我们来解决Linux内核驱动程序的问题。

神秘的GPU固件

今年4月,我决定开始琢磨如何编写M1GPU内核驱动。在最初的几个月里,我全身心投入为GPU编写和改进m1n1管理程序跟踪器,而且我发现了在GPU的世界里非常不寻常的一件事。

通常,GPU驱动程序会负责一些细节,例如安排和调整任务的优先级,以及在某些作业运行时间过长时抢过主动权,以允许应用程序公平地使用GPU。有时电源管理由驱动程序负责,而有时则由运行在电源管理协处理器上的专用固件负责。有时,还有其他固件负责命令处理的一些细节,但一般内核驱动程序都不知道这些固件的存在。最后,特别是对于像ARMMali这类更简单的“移动式”GPU,驱动GPU完成渲染工作的硬件接口通常非常简单,比如MMU(工作方式与CPUMMU或IOMMU类似),然后由命令处理器直接获取指向用户空间命令缓冲区的指针(通常存储在某种寄存器或环形缓冲区内)。因此,内核驱动程序除了管理内存和安排GPU的工作外,实际需要负责的工作并不多,Linux内核DRM(DirectRenderingManager,直接渲染管理器)子系统已经提供了大量帮助程序,因此编写驱动程序非常容易。虽然有一些棘手的问题,比如抢占,但这些问题对GPU在新驱动程序中正常工作的影响并不大。但M1GPU不同……

就像M1芯片的其他部分一样,GPU有一个名叫“ASC”的协处理器,负责运行苹果固件并管理GPU。这个协处理器是一个完整的ARM64CPU,运行了一个名叫RTKit的苹果专有实时操作系统,由它负责处理一切,比如电源管理、命令调度和抢占、故障恢复,乃至性能统计器、统计数据以及温度测量等等。事实上,macOS内核驱动程序根本不与GPU硬件通信。所有与GPU的通信都是通过固件进行的,使用共享内存中的数据结构来传达指令。而且这样的结构还有很多,比如:

●初始化数据:用于配置固件中的电源管理设置以及其他GPU全局配置数据,还包括颜色空间转换表,原因不明。这些数据结构有将近1000个字段,我们至今仍未全部弄清楚其具体的作用。

●提交管道:用于处理GPU队列的环形缓冲区。

●设备控制消息:用于控制全局的GPU操作。

●事件消息:固件在发生某些情况(如命令完成或失败)时发回驱动程序的消息。

●统计信息、固件日志和跟踪消息:用于收集GPU的状态信息和调试。

●命令队列:应用程序的待处理GPU工作列表。

●缓冲区信息、统计信息和页面列表结构:用于管理平铺顶点缓冲区。

●上下文结构以及其他小部件:记录GPU固件的运行。

●顶点渲染命令:告诉GPU中负责顶点处理和平铺的部分如何处理来自用户空间的命令和着色器,从而运行整个渲染通道的顶点部分。

●片段渲染命令:告诉GPU的光栅化和片段处理部分如何将顶点处理的平铺顶点数据渲染到帧缓冲区中。

实际的处理比这更复杂。顶点和片段渲染命令实际上是非常复杂的结构,其中有许多嵌套结构,而且每个命令上都有一个指针指向“微序列”——由GPU固件解释的小命令,就像自定义虚拟CPU。通常这些命令会设置渲染过程,等待渲染完成,然后清理……但它也支持时间戳命令,甚至是循环和算术运算。所有这些结构都需要提供渲染的详细信息,例如指向深度和模板缓冲区的指针、帧缓冲区大小、是否启用MSAA(Multisampleanti-aliasing,多重采样抗锯齿)及其配置方式、指向特定的辅助着色器程序,以及其他等等。

事实上,GPU固件与GPUMMU的关系很奇怪。二者使用了同一个页表。固件会直接使用GPUMMU的页表基址指针,并将其配置为ARM64的页表。所以,GPU内存就是固件的内存。固件自身以及与驱动程序的大部分通信都使用了一个共享的“内核”地址空间(类似于Linux中的内核地址空间),而一些缓冲区是与GPU硬件共享的并具有“用户空间”地址,这些地址在每个使用GPU的应用程序中也能够有单独的地址空间。

那么,我们能否将所有这些复杂性转移到用户空间,并让它设置所有顶点或片段的渲染命令?不行!由于所有这些结构与固件本身都位于共享的内核地址空间中,并且它们之间有大量指针,因此在使用GPU的不同进程之间并不是独立的。所以,我们不能让应用程序直接访问它们,因为它们有可能会破坏彼此的渲染。这就是我们能在macOSUAPI中找到了所有这些渲染细节的原因。

使用Python编写GPU驱动程序

由于正确设置所有这些结构关系到GPU与固件是否会崩溃,因此我需要一种在逆向工程时快速试验它们的方法。值得庆幸的是,AsahiLinux项目有一个款工具:m1n1Python框架。因为我已经在为m1n1管理程序编写GPU跟踪器,并用Python编写结构定义,所以我决定使用Python编写GPU的内核驱动程序,使用相同的结构定义。Python非常适合这项任务,因为我可以使用Python进行快速迭代开发。另外,Python可以使用基本的RTKit协议通信,并解析崩溃日志,我为此改进了工具,这样在固件崩溃时就可以看到固件的行为。所有这些工作都是在开发机器上运行脚本完成的,我的开发机器通过USB连接到了M1机器上,因此每次测试时,只需要重启开发机器即可,而且测试周期非常快。

起初,驱动程序的大部分实际上只是一堆硬编码的结构,但最终我成功地渲染了一个三角形。

不过,这只是一个七拼八凑的演示。我只是想在动手编写Linux内核驱动程序之前,确保自己真正理解内部机制,以确保能够正确设计驱动程序。虽然只渲染一帧非常简单,但我希望能够渲染多帧,并测试一下并发和抢占等。所以,我所需要的是一个真正的“内核驱动程序”。但这真的可以用Python实现吗?

事实证明,Mesa有一个名叫drm-shim的工具,可以模拟LinuxDRM内核接口,并在用户空间中使用一些假的接口替换掉它们。通常,我们用这个库来处理着色器CI等,但我们也可以用它来做一些更疯狂的处理。

我是否可以这样做:让Inochi2D在Mesa上运行,后者是运行在drm-shim之上的M1GPU驱动程序,而drm-shim运行在一个嵌入式Python解释器上,将命令发给在m1n1开发框架上运行的Python原型驱动程序,后者再通过USB与真正的M1机器通信并收发数据,从而驱动GPU固件进行渲染?听起来不太靠谱?

然而,这真的可行!

编写Linux内核的新语言

由于我的Mesa+Python驱动程序真的可以运行,我开始更好地了解内核驱动程序的内部机制以及必须实现的功能。事实证明,内核驱动程序需要完成的任务很多。首先,我必须同时兼顾100多个数据结构,一旦出现任何问题,一切都会崩溃。固件不会做任何检查(可能是为了性能),一旦遇到错误的指针或数据,它就会崩溃或或盲目地覆盖数据。更糟糕的是,如果固件崩溃,唯一的恢复方法就是重启机器。

Linux内核DRM驱动程序是用C编写的,但C不是编写复杂的数据结构管理的最佳语言。我必须手动跟踪每个GPU对象的生命周期,一旦发生任何错误,都有可能导致崩溃甚至安全漏洞。我要怎样才能做到这一点?可能出错的地方太多了,C语言根本帮不了我。

最重要的是,我必须支持多个版本的固件,苹果的固件结构定义在不同版本之间并不一致。作为实验,我添加了对第二个版本的支持,最终被迫修改了100多次数据结构。在Python演示中,我可以通过一些元编程来实现,根据版本号来构建不同的结构字段,但C语言中没有类似的功能。我必须使用一些技巧,例如使用不同的#define多次编译整个驱动程序。

我必须寻找一种新语言……

大约在同一时间,关于Rust很快被Linux内核正式采用的传言开始出现。多年来,RustforLinux项目一直致力实现这种支持,看起来他们的努力即将有成果。我可以用Rust编写GPU驱动程序吗?

我没有太多使用Rust的经验,但根据我的了解,这种语言很适合编写GPU驱动程序。我对两个问题特别感兴趣:Rust是否可以帮助我模拟GPU固件结构的生命周期(即使这些结构与GPU指针相关联,从CPU的角度来看这算不上真正的指针),Rust宏是否可以处理好多个版本的问题。因此,在开始内核开发之前,我向Rust专家寻求帮助,并在简单的用户空间Rust中制作了GPU对象模型的一个原型。Rust社区非常友好,有几个人帮助我完成了所有工作。在此表示感谢!

看起来,选择Rust似乎可能。但是,Rust尚未被主流Linux接受,这意味着我即将进入一个未知的领域。这将是一场赌博。犹豫再三,我内心一直有个声音告诉我,Rust是正确的选择。我与LinuxDRM的维护人员就此进行了交谈,他们似乎也接受了这个想法,所以,我决定试试看。

使用Rust编写GPU内核驱动程序

由于这将是第一个使用Rust编写的LinuxGPU内核驱动程序,因此我有许多工作要做。我不仅需要编写驱动程序,而且还需要为LinuxDRM图形子系统编写Rust抽象。虽然Rust可以直接调用C函数,但这样做就无法享受Rust的安全保证。因此,为了从Rust安全地调用C代码,首先我必须编写包装器,提供一个安全的类RustAPI。最终,我编写了一个将近1500行代码的抽象,因为优秀且安全的设计需要大量的思考,而且还需要重写许多代码。

8月18日,我开始编写Rust驱动程序。最初,这个驱动程序依赖C代码来处理MMU(部分代码是从Panfrost驱动程序复制过来的),但后来我决定用Rust重写所有代码。在接下来的几周里,我根据之前制作的原型添加了RustGPU对象系统,然后用Rust重新实现了Python演示驱动程序的所有其他部分。

随着使用Rust的次数增多,渐渐地我爱上了这门编程语言。感觉Rust的设计可以引导你设计出更好的抽象和软件。Rust的编译器非常苛刻,但代码一旦通过编译,你就可以相信它能可靠地工作。有时,我很难让编译器满意我尝试使用的设计,随后我就会意识到我的设计存在问题。

逐渐地,我的驱动程序有了眉目。9月24日,我终于使用我全新的Rust驱动程序渲染了第一个立方体。

更不可思议的是,几天后,我就可以运行完整的GNOME桌面会话了。

Rust很神奇

一般来讲,编写这样的一个复杂的内核驱动程序,想从简单的演示应用程序发展到支持整个桌面、多个应用程序并发使用GPU的系统,会引发很多竞争条件、内存泄漏、使用后释放内存的问题,以及其他各种问题。

但这一切问题都没有发生。我只修复了一些逻辑错误和内存管理代码核心的一个问题,而其他一切都可以稳定运行。Rust真的很神奇。它的安全特性可以保证驱动程序的线程与内存安全,并引导我们实现安全且良好的设计。

当然,代码中总是存在一些不安全的因素,但是由于Rust会迫使你从安全抽象的角度进行思考,因此出现bug的可能性也保持在很低的水平。但是,有些安全问题仍然无法避免。例如,我的DRM内存管理抽象中存在一个bug,最终可能会导致在所有分配的内存被释放之前,分配程序本身先被释放。但是由于这类错误仅限于特定的代码,因此往往是很容易发现的主要问题(可以通过代码审查发现)。因此,你只需要单独考虑特定的代码模块以及与安全相关的部分,而不必担心它们与其他所有内容的交互,最终你需要担心的错误数量也会很少。Rust真的很神奇,若非亲身尝试,否则很难形容。

另外,还有错误和清理。在C语言中,我们需要通过gotocleanup风格的错误处理来清理资源,这些处理很容易出错,但Rust没有这个问题。仅此一点,Rust就值得尝试。更不用说,自动化的迭代器和引用计数等等。

最新文章