原文地址:rosenzweig.io/blog/asahi-…
原文作者:rosenzweig.io/
发布时间:2021年1月22日
不到一个月前,我开始调查苹果M1 GPU,希望开发一个免费的开源驱动。本周,我已经达到了第二个里程碑:用自己的开源代码画出了一个三角形。顶点和碎片着色器是用机器代码手写的,我通过IOKit内核驱动与硬件接口,方式与系统的Metal用户空间驱动相同。
用开源代码在M1上渲染的一个三角形。
新代码的主要部分是负责构建各种命令缓冲区和描述符,常驻在共享内存中,用于控制GPU的行为。从Metal访问的任何状态都对应着这些缓冲区中的位,所以理解它们将是下一个主要任务。到目前为止,我对它们的内容关注较少,而对它们之间的联系关注较多。特别是,这些结构包含了相互之间的指针,有时深嵌多层。项目的三角区的带入过程提供了一个鸟瞰图,可以看到内存中所有这些不同的部分是如何结合在一起的。
举个例子,应用程序提供的顶点数据在它们自己的缓冲区中。另一个缓冲区中的内部表指向每个顶点缓冲区。该内部表直接作为输入传递给在另一个缓冲区中指定的顶点着色器。顶点着色器的那个描述,包括可执行内存中的代码地址,被另一个缓冲区指向,它本身是从主命令缓冲区引用的,而主命令缓冲区是由IOKit调用提交命令缓冲区的句柄引用的。呼!
换句话说,这段演示代码还不是为了证明对命令缓冲区的细枝末节的理解,而是为了证明 "什么都不缺"。由于GPU的虚拟地址在运行过程中会发生变化,因此演示验证了所有需要的指针都被识别出来,并且可以使用我们自己的(琐碎的)分配器在内存中自由重新定位。由于macOS上的内存和命令缓冲区分配有点 "魔力",所以在早期阶段有了这段代码的工作,就可以安心的前进了。
我采用了一个零散的带入过程。由于我的IOKit包装器与Metal应用程序存在于同一个地址空间,包装器可能会在提交给GPU之前修改命令缓冲区。作为早期的 "hello world",我在内存中确定了渲染目标的清晰颜色的编码,并演示了我可以随意修改颜色。同样,在学习提起拆解器的指令集时,我用手写的等价物替换了着色器,并确认我可以在GPU上执行代码,前提是我写出机器代码。但不必止步于系统的这些 "叶子节点",修改完着色器代码后,我尝试将着色器代码上传到可执行缓冲区的不同部分,同时修改命令缓冲区的代码指针进行补偿。之后,我可以尝试自己上传着色器的命令。以这种方式迭代,我可以建立起每一个需要的结构,同时对每个结构进行隔离测试。
尽管有一些曲折,但这个过程比直接跳到构建缓冲区,或许通过 "重放 "的替代方法要好得多。几年前,我曾用这种替代性的技术来引入Mali,但它有一个实质性的缺点,就是调试起来非常困难。如果在五百行魔法数字中出现一个错别字,除了GPU出错,不会有任何反馈。然而,通过一次一点的工作,错误可以被准确定位并立即修复,提供更快的周转时间和更愉快的带机体验。
但是,曲线球还是有的! 当我试图为颜色分配一个缓冲区时,我对修改清晰的颜色的那一瞬间的欣喜消失了。尽管编码的位数和之前一样,但GPU还是无法正确清除。我想是否是我修改指针的方式出了问题,于是我试着把颜色放在一个已经被Metal驱动创建的未使用的内存部分--这就成功了。内容是一样的,我修改指针的方式也是一样的,但不知为何GPU不喜欢我的内存分配。我想是不是我分配内存的方式出了问题,但是我用来调用内存分配IOKit调用的参数和Metal使用的参数是位数相同的,这一点得到了wrap的证实。我最后的努力是检查GPU内存是否必须通过一些侧通道显式映射,比如mmap系统调用。IOKit确实有一个独立于设备的内存映射调用,但是没有发现任何侧通道系统调用映射的证据。
麻烦正在酝酿。在花了这么多时间追寻一个 "不可能 "的bug之后,我感觉神志不清,我在想是不是系统调用中没有什么 "魔法"......而是在GPU内存本身。这是一个愚蠢的理论,因为如果是真的,它会产生一个严重的鸡飞蛋打的问题:如果一个GPU分配要被另一个GPU分配祝福,那么谁来祝福第一个分配?
但我觉得自己很傻,或许也很绝望,于是我按捺不住了,在应用流程中间插入一个内存分配调用来检验这个理论,这样以后的每次分配都会在不同的地址。在这一改变前后转储GPU内存并检查差异,发现了我的第一个恐怖之处:GPU内存中的一个辅助缓冲区跟踪了所有需要的分配。特别是,我注意到这个缓冲区中的值以可预测的偏移量(每0x40字节)增加一个,这表明缓冲区中包含一个分配的句柄数组。事实上,这些值与内核在GPU内存分配调用中返回的句柄完全对应。
抛开这个理论的明显问题不谈,我还是测试了一下,修改了这个表,在最后加入了一个额外的条目,包含了我的新分配的句柄,并且修改了头数据结构,将条目数增加了一个。还是没有结果。虽然令人沮丧,但这并没有使理论完全沉沦。事实上,我注意到了一些关于条目的奇特之处:与我所想的相反,并不是所有的条目都对应着有效的句柄。不,除了最后一个条目之外,所有的条目都是有效的。内核的句柄是1索引的,但是在每个内存转储中,最后的句柄总是0,不存在。也许这就像一个哨兵值,类似于C语言中的NULL结尾的字符串,这个解释引出了一个问题,为什么?如果头已经包含了一个条目数,那么一个哨兵值就是多余的了。
我按捺不住了。我没有用我的句柄增加一个额外的条目,而是把最后一个条目n复制到额外的条目n+1中,并用新的句柄覆盖了(现在是倒数第二个)条目n。
突然,我的透明色出现了。
谜底解开了吗?我让代码工作了,所以从某种意义上说,答案一定是肯定的。但这很难是一个令人满意的解释,在每一步,这个不可能的解决方案只会引发更多的问题。鸡和蛋的问题是最容易解决的:这个映射表,以及根命令缓冲区,是通过一个独立于一般缓冲区分配的特殊IOKit选择器分配的,映射表的句柄是和提交命令缓冲区选择器一起传递的。此外,将所需句柄与命令缓冲区提交一起传递的想法并非闻所未闻;类似的机制也被用于主线Linux驱动程序上。尽管如此,在共享内存中使用64字节的表项,而不是一个简单的CPU端数组,其理由仍然完全难以捉摸。
将内存分配的困境抛在脑后,前面的道路并非没有坎坷(和坑洼),但凭着耐心,我不断迭代,直到自己在Metal上并行构建了整个GPU内存,只依靠专有的用户空间来初始化设备。最后,剩下的就是跃跃欲试,自己启动IOKit握手,我就有了第一个三角形。
从上一篇博文到现在,这些改动相当于1700行左右的代码,可以在GitHub上找到。我已经拼凑了一个简单的演示,在屏幕上用GPU制作了一个三角形的动画。在这一点上,窗口系统的集成实际上是不存在的。需要使用XQuartz,并且在软件中使用天真无邪的标量代码来处理(64x64 Morton-order interleaved)framebuffer。尽管如此,M1的CPU已经足够快了。
现在,用户空间驱动的每个部分都已经启动了,往后我们可以单独对指令集和命令缓冲区进行迭代。我们可以把小细节挑开,一点一点地把代码从几百个莫名其妙的魔法常数转化为真正的驱动程序。继续前进!