1. 从一颗芯片的诞生说起:为什么需要一张全栈软件地图
一颗 AI 芯片从设计到量产,硬件团队交付的是一块硅片,但真正让这块硅片跑起来的,是背后一整套软件栈。我见过太多团队在流片成功后才发现,驱动没写好、编译器没适配、算子库缺了一大半,芯片在实验室里跑分漂亮,到了客户手里却连一个主流模型都跑不起来。这不是硬件的问题,是软件地图没画清楚。
所谓“全栈软件地图”,指的是从最底层的固件、内核驱动,到中间的运行时、编译器、算子库,再到上层的框架适配、模型部署工具链,这一整条链路上所有需要软件团队覆盖的模块及其依赖关系。它回答的是三个问题:芯片上电之后,第一行代码在哪里执行?一个 PyTorch 模型从导出到在芯片上跑起来,中间经过哪些层?每一层由谁负责、交付物是什么、验收标准是什么?
这篇文章适合三类人看:一是刚进入 AI 芯片公司的软件工程师,需要快速建立全局认知;二是硬件背景的架构师,想理解软件栈的复杂度到底在哪里;三是技术管理者,需要一张地图来规划团队编制和里程碑。我会用 Agent 作为辅助工具来拆解这张地图,但重点始终是地图本身——Agent 只是帮我们把信息组织得更清楚的手段。
先给一个最粗的框架,让你有个锚点。AI 芯片的全栈软件大致分四层:固件与内核层、运行时与驱动层、编译器与算子层、框架与工具链层。每一层都有明确的输入输出,层与层之间的接口就是软件团队最核心的交付物。下面我会逐层拆开讲,每一层都会说清楚:它解决什么问题、核心模块有哪些、关键交付物是什么、常见的坑在哪里。
2. 用 Agent 拆解全栈软件地图的整体思路
2.1 为什么选择 Agent 来做这件事
你可能会问,画一张软件地图,为什么不用思维导图工具,非要用 Agent?原因很直接:AI 芯片的软件栈涉及的知识域太宽了,从 Linux 内核的 DMA 机制到 TVM 的算子调度策略,从 PCIe 驱动到 ONNX 算子映射,任何一个工程师都不可能同时精通所有层。传统做法是找各个领域的专家分别写文档,然后拼在一起,结果是文档之间接口对不上、术语不统一、版本不同步。
Agent 的价值在于,它可以基于一个统一的“芯片规格描述”作为输入,自动推导出每一层需要哪些模块、模块之间的依赖关系是什么、每个模块的验收标准应该怎么定义。我实际用下来,最有效的做法是:先把芯片的硬件规格(计算单元数量、内存带宽、互联方式、指令集架构)整理成一份结构化的描述文件,然后让 Agent 基于这份描述去生成软件栈的模块清单和依赖图。这样生成的地图,层与层之间的接口是一致推导出来的,不会出现“驱动团队以为运行时团队会处理某件事,运行时团队以为驱动团队会处理”这种经典扯皮。
2.2 拆解的核心原则:以数据流为主线
画软件地图最容易犯的错误是按组织架构来画——驱动组负责这一块、编译器组负责那一块。这样画出来的图,看起来清晰,实际上没法用,因为一个模型跑起来的数据流是跨组的。我建议以数据流为主线来拆解:从模型文件开始,经过图编译、算子融合、内存分配、指令生成、任务调度、硬件执行,最后输出结果。每一个数据流经过的节点,就是一个软件模块;节点之间的数据格式和接口协议,就是需要定义的交付物。
用 Agent 来做这件事的好处是,你可以把数据流的每一个环节描述给 Agent,让它帮你检查:这个环节的输入格式和上一个环节的输出格式是否匹配?这个环节需要的硬件能力,芯片规格里是否提供了?如果没提供,软件上有没有 fallback 方案?这种交叉检查,人工做很容易漏,Agent 做可以做到系统化。
2.3 地图的粒度控制:三层展开法
一张全栈地图如果画得太细,会有几百个模块,没人看得过来;画得太粗,又没法指导实际工作。我的经验是采用三层展开法:第一层只画四个大块(固件/内核、运行时/驱动、编译器/算子、框架/工具链),每个大块用一句话说明职责;第二层把每个大块展开到 5 到 8 个核心模块,每个模块标注输入输出和关键交付物;第三层只对当前里程碑涉及的模块展开到函数级或接口级。这样既保证了全局视野,又不会在不需要的地方浪费精力。
Agent 在这个过程中的角色是:你给它第一层的四个大块,它帮你生成第二层的模块清单;你选定某个模块,它帮你生成第三层的接口定义草案。你始终是决策者,Agent 是执行者。这个分工很重要,因为芯片软件栈的很多决策依赖硬件细节和商业考量,Agent 不可能替你做这些决策,但它可以帮你把决策的后果推演清楚。
3. 固件与内核层:芯片上电后的第一行代码
3.1 固件层到底做什么
芯片上电之后,CPU 核首先执行的是一段固化在 ROM 里的代码,这段代码的任务是把芯片带到一个“可被操作系统接管”的状态。具体来说,它需要完成:时钟初始化、内存控制器初始化、PCIe 链路训练、安全启动校验、以及把主固件从 Flash 加载到内存并跳转执行。对于 AI 芯片来说,固件层还有一个特殊任务:初始化所有的计算单元和片上内存,确保它们在操作系统加载驱动之前处于已知状态。
这一层的核心交付物是固件镜像和固件-驱动接口规范。固件镜像通常分两级:一级固件(Boot ROM 代码)在流片时就固化在芯片里,不可更改;二级固件(通常叫 SPL 或 BL2)可以从 Flash 加载,负责更复杂的初始化。固件-驱动接口规范定义了驱动如何查询固件版本、如何触发固件升级、如何获取硬件状态信息。这个规范如果定义得不好,后期驱动和固件联调时会非常痛苦。
注意:固件层的 bug 往往是最难排查的,因为它的执行环境没有操作系统、没有日志系统、没有调试器。我的经验是,在流片之前就要把固件的仿真环境搭好,用 FPGA 原型验证固件的每一个初始化步骤。流片后再发现固件问题,修复成本极高。
3.2 内核驱动的核心模块
操作系统加载后,内核驱动负责把硬件设备暴露给用户态。对于 AI 芯片,内核驱动通常包含以下模块:PCIe 设备驱动(负责枚举设备、映射 BAR 空间、处理中断)、字符设备驱动(提供用户态打开设备、发送命令、读写数据的接口)、DMA 引擎驱动(管理数据在主机内存和设备内存之间的搬运)、内存管理模块(管理设备侧的内存分配和映射)。
这里重点说 DMA 引擎驱动,因为它是 AI 芯片数据通路的核心。一个典型的 AI 芯片会有多个 DMA 通道,每个通道可以独立搬运数据。驱动需要实现:通道的申请和释放、搬运任务的提交和完成通知、错误处理和超时重试。我见过不少团队在这里踩坑:DMA 通道的并发控制没做好,多个进程同时提交任务时出现数据错乱;或者完成通知的机制设计得太简单,高负载下丢中断。
内核驱动的另一个关键点是中断处理。AI 芯片的中断源通常很多:DMA 完成、计算完成、错误上报、温度告警等。中断处理程序需要快速响应,把耗时的处理放到下半部或工作队列中。如果中断处理写得不好,高负载下会出现中断风暴,CPU 全部时间都在处理中断,用户态任务得不到调度。
3.3 UMD 与 KMD 的分工与接口
UMD(User Mode Driver)和 KMD(Kernel Mode Driver)的分工是 AI 芯片软件栈设计中最关键的决策之一。KMD 运行在内核态,负责硬件资源的直接管理和安全隔离;UMD 运行在用户态,负责命令的构建和提交。两者之间的接口通常通过 ioctl 实现。
分工的原则是:安全相关的、需要特权的操作放在 KMD,比如设备初始化、内存映射、中断处理、DMA 通道管理;性能相关的、频繁调用的操作放在 UMD,比如命令缓冲区的构建、任务提交、同步对象的等待。这样设计的理由是,ioctl 的系统调用开销比较大,如果每次任务提交都走 ioctl,性能会受影响。常见的做法是,UMD 和 KMD 共享一块内存区域(通过 mmap 映射),UMD 在这块内存里构建命令,然后通过一次 ioctl 通知 KMD 提交。
UMD 和 KMD 的接口定义需要非常小心。我建议在项目早期就把接口用文档固定下来,并且写一个 mock 实现,让 UMD 和 KMD 可以独立开发和测试。接口变更的成本很高,因为两边可能由不同的团队负责,变更需要同步。
4. 运行时与编译器:从模型到指令的翻译过程
4.1 运行时系统的核心职责
运行时系统是连接上层框架和底层硬件的桥梁。它的核心职责包括:内存管理(设备内存的分配、释放、复用)、流管理(任务的顺序执行和并发执行)、事件同步(不同流之间的依赖关系)、内核启动(把编译好的内核加载到设备并执行)。
内存管理是运行时系统中最容易出问题的部分。AI 芯片的设备内存通常比较有限(比如 16GB 或 32GB),而一个大模型可能需要几十 GB 的内存。运行时需要实现内存池机制,把频繁分配释放的小块内存缓存起来,减少向 KMD 申请内存的次数。同时,运行时还需要支持内存复用:当两个张量的生命周期不重叠时,可以复用同一块内存。这个分析通常在编译期完成,运行时根据编译期生成的分配方案来执行。
流管理是另一个关键点。AI 芯片通常支持多个流(Stream),每个流内的任务顺序执行,不同流之间可以并发。运行时需要管理流的创建、销毁、任务提交、流之间的同步。我见过的一个典型问题是:多个流同时提交任务时,硬件资源(比如计算单元)的分配出现冲突,导致某些任务饿死。解决方法是运行时实现一个资源调度器,根据任务的优先级和资源需求来分配硬件资源。
4.2 编译器栈的分层设计
AI 芯片的编译器栈通常分三层:图编译器、算子编译器、指令生成器。图编译器负责把上层框架的模型图(比如 ONNX 图)转换成芯片的中间表示(IR),并进行图级别的优化,比如算子融合、常量折叠、死代码消除。算子编译器负责把 IR 中的每个算子编译成芯片的指令序列,并进行算子级别的优化,比如循环展开、向量化、流水线调度。指令生成器负责把优化后的指令序列编码成二进制,并处理寄存器分配、指令调度等底层细节。
图编译器的核心挑战是算子融合。比如一个 Conv + Bias + ReLU 的结构,如果分别编译成三个算子,会有两次额外的内存读写。融合成一个算子后,中间结果留在片上内存,不需要写回设备内存,性能可以提升很多。但融合的决策需要考虑硬件能力:片上内存够不够大、计算单元支不支持融合后的算子。这些决策需要编译器和硬件团队紧密合作。
算子编译器的核心挑战是调度。同一个算子,不同的调度策略性能差异可能达到数倍。比如矩阵乘法,是分块大小取 32 还是 64,是先把数据搬到片上内存再计算还是边搬边算,这些选择需要根据硬件的内存带宽、计算单元数量、片上内存大小来定。我建议在项目早期就建立一个自动调优的框架,让编译器可以自动搜索最优的调度参数,而不是靠人工试。
4.3 算子库的覆盖策略
算子库是编译器栈的“最后一公里”。即使编译器再强大,如果某个算子在算子库里没有实现,模型就跑不起来。算子库的覆盖策略通常分三步:第一步覆盖主流框架的核心算子(比如 PyTorch 的 ATen 算子中频率最高的 100 个);第二步覆盖主流模型中的特殊算子(比如 Transformer 中的 Attention、LayerNorm);第三步提供自定义算子的开发框架,让用户自己实现算子库中没有的算子。
这里有一个经验:不要试图一开始就覆盖所有算子。我见过团队花大量时间实现了 500 个算子,结果发现用户模型里用到的只有 50 个,而且这 50 个中有 10 个的实现性能不达标。正确的做法是,先和产品团队确认目标模型清单,然后按模型来倒推算子需求,优先实现高频且性能关键的算子。
5. 框架与工具链:让用户真正用起来
5.1 框架适配的两种模式
框架适配有两种模式:插件模式和后端模式。插件模式是在框架内部注册一个新的设备类型,框架的算子会调用插件提供的实现。这种模式的优点是改动小、上手快,缺点是受限于框架的调度机制,性能优化空间有限。后端模式是把框架的模型导出成中间表示(比如 ONNX),然后由芯片自己的编译器栈接管。这种模式的优点是性能优化空间大,缺点是需要维护一个完整的编译器栈。
我的建议是:短期用插件模式快速跑通,长期用后端模式追求性能。插件模式可以在几周内让模型跑起来,给客户一个可用的版本;后端模式需要几个月的开发,但最终性能可以提升数倍。两个模式可以并行推进,插件模式作为 fallback,后端模式作为主路径。
5.2 模型部署工具链的关键组件
模型部署工具链通常包含:模型转换工具(把训练框架的模型转成芯片支持的格式)、量化工具(把 FP32 模型转成 INT8 或 FP16)、性能分析工具(分析模型在芯片上的性能瓶颈)、调试工具(定位精度问题或运行时错误)。
量化工具是其中最有价值也最难做的。INT8 量化可以把模型大小减少 75%,推理速度提升 2 到 4 倍,但精度损失需要控制在可接受范围内。量化工具需要支持:训练后量化(PTQ)和量化感知训练(QAT)。PTQ 不需要重新训练,但精度损失可能较大;QAT 需要重新训练,但精度可以接近 FP32。我建议先实现 PTQ,覆盖大部分场景,对于精度要求高的场景再支持 QAT。
性能分析工具是另一个关键组件。用户需要知道模型在芯片上的瓶颈在哪里:是计算单元利用率不够、还是内存带宽受限、还是任务调度有问题。性能分析工具需要采集硬件性能计数器(比如计算单元活跃周期、内存读写带宽、DMA 传输次数),并把这些数据关联到模型的算子上。这样用户才能有针对性地优化模型。
5.3 工具链的验收标准
工具链的验收标准应该从用户视角定义:一个用户拿到芯片和工具链,多长时间能把他的模型跑起来?跑起来之后,性能是多少?精度损失是多少?我建议设定三个指标:首次跑通时间(从拿到芯片到模型跑通的时间,目标是一周内)、性能达标率(目标模型清单中,性能达到预期值的比例,目标是 90% 以上)、精度达标率(量化后精度损失在可接受范围内的比例,目标是 95% 以上)。
这三个指标需要在项目早期就定义清楚,并且定期跟踪。我见过团队在项目后期才发现工具链的易用性很差,用户跑通一个模型需要一个月,这时候再改已经来不及了。正确的做法是,在工具链开发的同时,就让内部用户(比如算法团队)试用,收集反馈,持续改进。
6. 常见问题与排查技巧实录
6.1 固件与驱动联调中的典型问题
固件和驱动联调是问题最集中的阶段。我整理了一个常见问题速查表:
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 设备枚举失败 | PCIe 链路训练未完成 | 检查固件中 PCIe 初始化代码,用示波器看链路状态 |
| 驱动加载后系统崩溃 | 内存映射地址冲突 | 检查 BAR 空间映射,确认没有和系统其他设备冲突 |
| DMA 传输数据错乱 | 通道并发控制缺失 | 检查 DMA 通道的锁机制,确认同一通道不会并发提交 |
| 中断丢失 | 中断处理程序执行时间过长 | 检查中断处理程序,把耗时操作移到下半部 |
| 固件升级失败 | Flash 写入时序不对 | 检查 Flash 时序参数,确认写入前已擦除 |
提示:固件和驱动联调时,建议先用一个简单的测试程序验证基本功能(设备枚举、内存映射、DMA 传输),再跑复杂的模型。这样可以把问题隔离在最小的范围内。
6.2 编译器与算子库的精度问题排查
精度问题是编译器栈中最难排查的。一个模型跑出来的结果和预期不符,可能的原因有:算子实现有 bug、量化参数不对、内存复用导致数据被覆盖、浮点累加顺序不同导致精度差异。排查的思路是:先定位到具体的算子,再定位到具体的实现。
具体做法是:用逐层对比的方式,把模型的每一层输出和参考实现(比如 CPU 上的 PyTorch)对比,找到第一个出现偏差的层。然后检查这个层的算子实现:如果是量化算子,检查量化参数(scale 和 zero_point)是否正确;如果是浮点算子,检查累加顺序是否和参考实现一致。我见过的一个经典问题是:矩阵乘法的累加顺序不同,导致 FP16 精度下结果差异较大。解决方法是调整累加顺序,或者用 FP32 累加。
6.3 性能不达标的排查思路
性能不达标通常有三个原因:计算单元利用率低、内存带宽受限、任务调度有问题。排查的顺序是:先用性能分析工具看计算单元的利用率,如果利用率低于 50%,说明计算单元在等数据,问题可能在内存带宽或任务调度;如果利用率高于 80%,但性能还是不够,说明计算单元本身的能力不够,需要优化算子实现或增加计算单元。
内存带宽受限的典型表现是:计算单元利用率不高,但内存读写带宽已经接近峰值。解决方法是优化数据复用:把频繁访问的数据放到片上内存,减少对设备内存的访问。任务调度问题的典型表现是:多个流之间的任务互相等待,导致计算单元空闲。解决方法是优化流之间的同步关系,减少不必要的依赖。
6.4 工具链易用性问题的改进经验
工具链易用性问题往往被低估。我见过一个工具链,功能很强大,但用户跑通一个模型需要手动执行十几个步骤,每个步骤都有坑。改进的经验是:把用户的操作步骤降到最少,把能自动化的都自动化。比如模型转换,用户只需要提供模型文件和目标芯片型号,工具链自动完成图优化、量化、编译、部署。再比如性能分析,用户只需要提供模型和输入数据,工具链自动跑一遍并生成性能报告。
另一个经验是:提供端到端的示例。用户最喜欢的是“抄作业”,给一个完整的示例,从模型文件到最终部署,每一步都有命令和预期输出。这样用户可以快速验证环境是否正确,然后再替换成自己的模型。我建议为每个主流模型(ResNet、BERT、LLaMA)都提供一个端到端示例,并且定期更新。
7. 用 Agent 辅助软件栈开发的实操心得
7.1 Agent 在代码生成中的边界
Agent 可以帮我们生成很多代码:驱动框架、算子实现、测试用例。但 Agent 生成的代码不能直接用于生产,必须经过严格的审查和测试。我的经验是:Agent 生成的代码适合作为初稿,不适合作为终稿。比如生成一个 DMA 驱动的框架,Agent 可以生成设备枚举、内存映射、中断注册的代码,但 DMA 传输的具体实现需要人工根据硬件手册来写,因为 Agent 不了解硬件的具体寄存器定义。
另一个边界是:Agent 不擅长处理硬件相关的时序问题。比如固件初始化中,某个寄存器的写入需要等待几个时钟周期,Agent 生成的代码可能没有加延时,导致初始化失败。这类问题需要人工根据硬件手册来补充。
7.2 Agent 在文档生成中的价值
Agent 在文档生成中的价值比代码生成更大。因为文档的结构化程度高,Agent 可以基于代码和注释自动生成 API 文档、架构文档、用户手册。我实际用下来,最有效的做法是:先把代码的注释写清楚(每个函数的输入输出、每个模块的职责),然后让 Agent 基于注释生成文档初稿,人工再润色。这样可以把文档编写的时间减少一半以上。
Agent 还可以帮我们检查文档的一致性:比如驱动文档中描述的接口,和运行时文档中引用的接口是否一致;固件文档中描述的寄存器,和驱动代码中使用的寄存器是否一致。这种交叉检查人工做很费时,Agent 做可以做到系统化。
7.3 Agent 在测试用例生成中的应用
测试用例的生成是 Agent 的另一个强项。给定一个函数的接口定义和边界条件,Agent 可以生成覆盖各种情况的测试用例:正常输入、边界输入、异常输入。我建议把 Agent 生成的测试用例作为补充,而不是替代人工编写的测试用例。因为 Agent 不了解硬件的特殊行为,比如某些寄存器在特定条件下会返回错误码,这类测试用例需要人工根据硬件手册来写。
一个实用的技巧是:把硬件手册中的寄存器描述整理成结构化的格式(比如 JSON),然后让 Agent 基于这个格式生成寄存器读写测试用例。这样可以覆盖大部分寄存器的基本功能,人工只需要补充特殊场景的测试。
8. 地图的维护与演进
软件地图不是画一次就完了,它需要随着项目进展不断更新。我的做法是:每个里程碑结束时,回顾地图,更新模块的状态(未开始、进行中、已完成、已验收),并标注新发现的依赖关系。这样地图始终反映项目的真实状态,而不是一张过时的图纸。
另一个经验是:地图要放在所有人都能访问的地方,比如内部 Wiki 或代码仓库的 README。我见过团队把地图放在某个人的电脑里,结果其他人看不到,各自按自己的理解开发,最后接口对不上。地图的价值在于共享,只有所有人都能看到并参考,才能发挥它的作用。
最后分享一个我踩过的坑:早期画地图时,我只画了模块和依赖关系,没有标注每个模块的负责人和交付时间。结果地图看起来很完整,但没人知道谁该做什么、什么时候做完。后来我在每个模块上加了负责人和里程碑,地图才真正变成了可执行的项目计划。这个教训是:地图不仅是技术文档,也是管理工具,必须包含责任人和时间节点。