1. 从一颗芯片的启动说起:为什么全栈软件地图比芯片本身更难画
很多人第一次接触"AI芯片全栈软件"这个概念,脑子里浮现的是一张漂亮的架构图:最上面是框架,中间是编译器,下面是驱动,最底下是硅片。图很好看,但真到动手的时候你会发现,这张图根本没法指导你干活——因为每一层之间的接口、每一层内部的分工、以及"谁该为性能负责"这些要命的问题,架构图上一个字都没写。
我做过几年芯片软件栈的活儿,也带过团队从零搭过一套面向自研加速卡的软件栈。最深的体会是:芯片是硬件团队几个月流片一次的事,而全栈软件是几百号人几年都填不满的坑。一颗AI芯片从流片回来到能跑通第一个真实模型,中间隔着的不是"写个驱动"这么简单,而是一整套从内核态到用户态、从编译期到运行期、从单卡到集群的软件体系。
这篇要聊的,就是怎么用Agent的思路,把这块"全栈软件地图"给画出来、拆明白。注意,这里说的Agent不是指某个具体产品,而是一种用智能体去辅助梳理、生成、验证软件栈各层组件的方法论。为什么想到用Agent来做这件事?因为全栈软件栈的知识极度分散:UMD的接口文档在A仓库,KMD的寄存器手册在B仓库,编译器的pass pipeline在C仓库,runtime的调度策略又在D仓库。人脑记不住这么多,但Agent可以帮你把这些碎片拼成一张可查询、可追溯、可验证的地图。
先给不太熟悉这块的读者补个背景。AI芯片的全栈软件,通常按"离硅片的距离"分层:
- KMD(Kernel Mode Driver,内核态驱动):跑在操作系统内核里,负责寄存器读写、中断处理、内存映射、DMA引擎管理。它是最贴近硬件的一层,也是最危险的一层——写错了直接内核panic。
- UMD(User Mode Driver,用户态驱动):跑在用户空间,负责命令队列提交、上下文管理、内存分配策略、以及与上层runtime的对接。它把KMD的裸能力包装成相对友好的API。
- Runtime(运行时):负责模型加载、算子调度、内存池管理、多流并发、错误恢复。它是框架和驱动之间的粘合剂。
- Compiler(编译器):把上层框架的图(Graph)翻译成芯片能执行的指令序列,中间要经过算子融合、内存规划、指令调度、量化等一系列pass。
- Framework Adapter(框架适配层):对接PyTorch、TensorFlow、ONNX等,把它们的IR转成自家编译器的输入。
这五层,每一层都有自己的"黑话"和"潜规则"。而Agent要做的,就是帮你把这五层的知识结构化,让你在需要的时候能快速定位到"这个功能到底该在哪一层实现"。
提示:全栈软件地图的核心价值不在于"画得全",而在于"边界清晰"。哪一层该做什么、不该做什么,比每层内部有多少组件重要得多。很多团队的性能问题,根源都是层与层之间职责不清。
2. 用Agent拆解软件栈:从"知识碎片"到"可执行地图"的四步法
2.1 第一步:让Agent做知识采集,而不是知识生成
这是最容易踩的坑。很多人一上来就让Agent"帮我设计一个AI芯片的软件栈",结果Agent给你吐出一堆看起来正确但完全没法落地的通用架构。原因很简单:Agent不知道你的芯片长什么样。
正确的做法是让Agent先做采集和归纳,而不是生成。具体来说,把你手头所有能拿到的材料喂给它:
- 芯片的寄存器手册(哪怕是PDF)
- 已有的驱动代码(哪怕是半成品)
- 编译器的pass列表和IR定义
- runtime的API文档
- 甚至是一些内部的会议纪要和技术讨论记录
然后让Agent做一件事:按"层"归类,并标注每一层的输入、输出、依赖关系。这一步的输出不是架构图,而是一张表。我实测下来,用Agent做这个归类,比人手工整理快至少5倍,而且不容易漏。
这里有个关键技巧:让Agent在归类时强制回答三个问题——"这个组件属于哪一层?""它的上游是谁?""它的下游是谁?"。这三个问题能逼着Agent把模糊的边界说清楚。如果Agent答不上来,说明你的材料里缺了关键信息,这本身就是有价值的发现。
2.2 第二步:用"接口契约"把层与层钉死
软件栈最容易出问题的地方,永远是层与层之间的接口。KMD给UMD暴露什么?UMD给Runtime暴露什么?Runtime给Compiler暴露什么?这些接口一旦定下来,后面所有的工作都围绕它展开。
我习惯让Agent做一件事:为每一对相邻层生成一份"接口契约"。这份契约要包含:
| 契约要素 | 说明 | 示例 |
|---|---|---|
| 接口名称 | 函数/命令/消息的名字 | submit_command_queue |
| 输入 | 参数类型和含义 | 命令队列句柄、命令缓冲区指针、超时时间 |
| 输出 | 返回值或副作用 | 提交成功返回0,失败返回错误码 |
| 同步/异步 | 调用是否阻塞 | 异步,通过fence通知完成 |
| 错误处理 | 出错时怎么办 | 返回错误码,不抛异常 |
| 性能约束 | 延迟/吞吐要求 | 单次提交延迟<10us |
让Agent生成这份契约的好处是:它会逼你把"想当然"的地方写清楚。比如"这个调用是同步还是异步",人写文档时经常漏,但Agent会追问。而这个问题恰恰是后面性能调优时最要命的。
2.3 第三步:让Agent做"反向验证",找出地图上的空洞
地图画完了,怎么知道它对不对?我的经验是:让Agent做反向验证。具体做法是给它一个具体的场景,让它沿着地图走一遍,看能不能走通。
比如给Agent一个场景:"用户调用PyTorch的torch.matmul,最终在芯片上执行,请描述完整的调用链路。"然后看Agent能不能从Framework Adapter一路走到KMD,中间每一层的函数调用、数据流转、内存分配都说清楚。
如果Agent在某一层卡住了,或者开始说"这里通常由runtime处理"这种模糊的话,那说明这一层的地图有空洞。这时候你要做的不是让Agent编,而是回去补材料。
我踩过的一个坑:早期我们的地图里,Compiler和Runtime之间的接口写得很粗,只写了"Compiler输出二进制,Runtime加载执行"。结果真到联调时发现,Compiler输出的二进制格式、Runtime加载时的内存对齐要求、以及两者对"算子边界"的理解完全不一致,导致大量返工。后来让Agent把这个接口细化到"二进制头部字段、段对齐、符号表格式"这个粒度,问题才暴露出来。
2.4 第四步:把地图变成"可查询的知识库"
地图画完不是终点,能查才是。我建议把Agent整理出来的所有内容,按"层-组件-接口-场景"四个维度建索引。这样当有人问"我想加一个新的量化算子,该改哪几层"时,能快速定位到:
- Compiler层:加一个quantize pass
- Runtime层:加对应的算子注册
- UMD层:如果涉及新的指令,可能要加命令构造
- KMD层:通常不用改,除非涉及新的硬件特性
这个知识库的形态可以很简单,一个结构化的Markdown仓库就行,关键是索引维度要对。我见过太多团队把文档写成了一本"书",按章节组织,结果查的时候根本找不到。按"层-组件-接口-场景"组织,才是符合工程直觉的。
3. UMD与KMD的边界:全栈地图里最容易画错的一段
3.1 为什么这两层的边界这么难画
UMD和KMD的边界,是全栈软件地图里争议最大的地方。争议的核心是一个问题:哪些事情必须在内核态做,哪些可以放到用户态?
这个问题的答案不是技术洁癖,而是被三个现实约束决定的:
- 安全性:内核态代码出错会panic,用户态代码出错最多进程崩溃。所以危险操作(寄存器直接读写、物理内存映射)必须放KMD。
- 性能:内核态和用户态的切换有开销(通常几百纳秒到几微秒),频繁切换会拖垮性能。所以高频操作(命令提交)要尽量放UMD。
- 可维护性:内核态代码调试困难,用户态代码可以用gdb、valgrind等工具。所以复杂逻辑尽量放UMD。
我让Agent做过一个实验:把同一个功能(比如"分配一块设备内存")分别按"全放KMD""全放UMD""混合"三种方案描述,然后对比它们的优缺点。结果很清晰:
| 方案 | 安全性 | 性能 | 可维护性 | 适用场景 |
|---|---|---|---|---|
| 全放KMD | 高 | 低(频繁切换) | 低 | 极简驱动,功能极少 |
| 全放UMD | 低(需要内核暴露裸接口) | 高 | 高 | 功能复杂,性能敏感 |
| 混合 | 中 | 中 | 中 | 大多数实际场景 |
实际工程中,绝大多数芯片走的是混合路线。但"混"到什么程度,就是各家功夫的差别了。
3.2 一个具体的边界划分案例
拿"命令队列提交"这个操作举例。用户态要提交一个计算命令,完整流程通常是:
- UMD在用户态准备好命令缓冲区(command buffer),里面是芯片能识别的指令。
- UMD通过一个ioctl(或类似的系统调用)通知KMD:"我有个命令缓冲区要提交"。
- KMD做权限检查、地址转换(虚拟地址到物理地址)、然后把命令缓冲区的物理地址写入硬件的队列寄存器。
- 硬件执行,完成后发中断。
- KMD收到中断,通知UMD(通过eventfd或类似机制)。
- UMD处理完成事件,释放资源。
这个流程里,步骤1在UMD,步骤3在KMD,步骤2和5是边界。为什么步骤1不放KMD?因为命令缓冲区的构造可能很复杂(涉及算子融合、内存布局优化),放内核态既难写又难调。为什么步骤3不放UMD?因为写硬件寄存器是特权操作,必须在内核态。
这个划分不是拍脑袋定的,而是被"最小特权原则"和"性能路径最短原则"共同决定的。我让Agent把这个流程画成时序图(当然,这里不用mermaid,用文字描述),然后逐步骤问"这一步能不能挪到另一边",挪过去之后安全性、性能、可维护性各有什么变化。这个练习能帮你把边界想清楚。
3.3 踩坑实录:UMD和KMD的"责任真空"
我遇到过最坑的情况,是UMD和KMD之间出现"责任真空"——两边都以为对方会处理某件事,结果谁都没处理。
具体案例:内存释放。UMD分配了一块设备内存,用完后调用free。UMD以为KMD会在收到释放请求后,等所有引用这块内存的命令都执行完再真正回收。KMD以为UMD会在调用free之前确保没有未完成的命令引用这块内存。结果就是:命令还在执行,内存已经被回收,硬件访问了野指针,整个系统挂死。
这个坑的根源,是接口契约里没写清楚"内存释放的同步语义"。后来我们在契约里加了一条:UMD调用释放接口时,必须保证所有引用该内存的命令已经完成(通过fence等待);KMD在收到释放请求后,立即回收,不做额外等待。这样责任就清晰了。
让Agent做边界划分时,一定要让它显式回答"这个操作的同步责任在谁"。这个问题不写清楚,后面必出问题。
4. 编译器与Runtime的接口:地图上最"抽象"的一段
4.1 编译器输出什么,Runtime才能接得住
编译器和Runtime的接口,是全栈地图里最抽象的一段。因为它涉及的是"编译产物"的格式问题——编译器吐出来的东西,Runtime得能读懂、能加载、能执行。
我见过很多团队在这里翻车,根本原因是:编译器和Runtime是两拨人做的,两边对"编译产物"的理解不一致。编译器团队觉得"我输出一个二进制就行了",Runtime团队觉得"你得告诉我这个二进制里有哪些算子、内存怎么布局、依赖关系是什么"。
让Agent来梳理这段接口时,我建议强制它回答以下问题:
- 编译产物的格式是什么?(ELF?自定义格式?)
- 里面包含哪些段?(代码段、数据段、常量段、符号表?)
- 算子信息怎么表达?(每个算子的输入输出、内存需求、执行顺序?)
- 内存布局怎么描述?(静态分配还是动态分配?对齐要求?)
- 依赖关系怎么表达?(哪些算子可以并行?哪些必须串行?)
这些问题答完,接口基本就清晰了。我实测下来,让Agent做这件事的最大价值是:它会逼编译器团队和Runtime团队用同一套语言说话。以前两边各说各的,现在有了Agent整理的"接口契约",争论就有了依据。
4.2 一个真实的接口设计:从Graph到Executable
拿一个具体的例子。假设编译器要把一个计算图编译成可执行文件,Runtime要加载并执行它。一个合理的接口设计是这样的:
编译器输出:
- 一个
Executable对象,包含:code_segment:芯片指令序列const_segment:常量数据(权重等)symbol_table:算子名称到代码偏移的映射memory_plan:每个算子的内存需求(大小、对齐、生命周期)dependency_graph:算子间的依赖关系
Runtime加载:
- 读取
Executable,校验格式和版本。 - 根据
memory_plan分配设备内存。 - 把
const_segment拷贝到设备内存。 - 把
code_segment加载到指令内存(或通过UMD提交)。 - 根据
dependency_graph调度算子执行。
这个设计里,memory_plan和dependency_graph是最关键的两个字段。前者决定了内存利用率,后者决定了并行度。很多团队只关注code_segment,结果内存利用率上不去、并行度也上不去。
我让Agent做过一个对比:如果memory_plan只给"总大小",不给"每个算子的生命周期",Runtime就只能串行执行(因为不知道什么时候能复用内存)。如果给了生命周期,Runtime就能做内存复用,内存占用能降30%以上。这个数字不是拍脑袋的,是我们实测下来的。
4.3 编译器pass与Runtime调度的"责任划分"
还有一个容易扯皮的地方:算子融合到底该在编译器做,还是在Runtime做?
编译期做融合的好处是:融合后的代码是静态的,执行时没有额外开销。坏处是:融合策略在编译期就定死了,运行时如果发现某个融合不合适(比如内存不够),没法改。
运行期做融合的好处是:可以根据运行时状态动态调整。坏处是:融合本身有开销,而且Runtime会变得很复杂。
我的经验是:静态融合放编译器,动态调度放Runtime。编译器负责把能融合的算子融合掉(比如conv+bn+relu),Runtime负责根据实际内存和算力情况,决定哪些融合后的算子并行执行、哪些串行执行。这样两边职责清晰,不会互相甩锅。
让Agent梳理这段时,可以给它一个具体的图,让它分别按"编译期融合"和"运行期融合"两种策略生成执行计划,然后对比优劣。这个练习能帮你找到最适合自己芯片的划分方式。
5. 用Agent做全栈地图的"压力测试":三个必做的验证场景
5.1 场景一:新算子接入,看地图能不能走通
地图画得再漂亮,不能指导实际工作就是废纸。我习惯用"新算子接入"这个场景来验证地图的完整性。
具体做法:选一个当前不支持的算子(比如某种特殊的attention变体),让Agent沿着地图走一遍,回答:
- 这个算子在Framework Adapter层怎么表示?
- Compiler层需要加哪些pass?
- Runtime层需要注册什么?
- UMD层需要新的命令吗?
- KMD层需要改吗?
如果Agent能在每一层都给出具体的改动点,说明地图是完整的。如果某一层它只能说"这里可能需要调整",说明地图有空洞。
我实测下来,这个验证能暴露80%以上的地图问题。而且它有个额外好处:能帮你评估新算子接入的工作量。如果Agent说"只需要改Compiler层",那可能几天就能搞定;如果它说"五层都要改",那可能就是几周的事。
5.2 场景二:性能瓶颈定位,看地图能不能指路
第二个验证场景是性能瓶颈定位。给Agent一个具体的性能问题(比如"某个模型在芯片上跑得比预期慢3倍"),让它沿着地图逐层排查:
- Framework Adapter层:有没有多余的格式转换?
- Compiler层:有没有生成低效的指令序列?有没有该融合的没融合?
- Runtime层:调度策略是不是最优?内存复用做了没有?
- UMD层:命令提交有没有瓶颈?有没有不必要的同步?
- KMD层:中断处理有没有延迟?DMA效率高不高?
这个场景的价值在于:它能把"性能问题"这个模糊的概念,拆解成每一层的具体检查项。我以前带团队时,遇到性能问题大家就互相甩锅,编译器说runtime调度不行,runtime说编译器生成的代码烂。有了这张地图,排查就有了顺序和依据。
5.3 场景三:错误恢复,看地图能不能兜底
第三个场景是错误恢复。芯片跑着跑着出错了(比如ECC错误、超时、硬件异常),整个软件栈该怎么反应?
让Agent沿着地图走一遍:
- KMD层:怎么检测错误?怎么上报?
- UMD层:怎么接收错误?怎么清理上下文?
- Runtime层:怎么恢复?能不能重试?要不要重新加载模型?
- Compiler层:需不需要重新编译?
- Framework Adapter层:怎么把错误传给用户?
这个场景最容易被忽略,但实际生产中极其重要。我见过太多团队,功能跑通了就以为万事大吉,结果一上生产环境,各种硬件错误导致整个服务挂掉。错误恢复的路径,必须在设计阶段就想清楚。
让Agent做这个验证时,重点是看它能不能给出"每一层的错误处理责任"。如果某一层说"这里不处理错误",那就要问:为什么不处理?是错误不会传到这一层,还是传到了但被忽略了?这个问题问清楚,能避免很多生产事故。
6. 地图维护:Agent不是画完就完,而是要持续更新
6.1 为什么全栈地图会"腐烂"
软件栈地图不是画一次就完事的。芯片在迭代,软件在迭代,地图如果不跟着更新,很快就会腐烂。我见过最夸张的情况:地图上写的接口,和代码里的实际接口差了三个版本,新来的人照着地图干活,结果全是坑。
地图腐烂的根本原因,是地图和代码是两套东西。代码改了,地图没改,时间一长就对不上了。
解决这个问题的思路,是让Agent做"地图-代码一致性检查"。具体做法:
- 定期(比如每周)让Agent扫描代码仓库,提取实际的接口定义。
- 和地图上记录的接口做对比,找出差异。
- 差异分两类:一类是地图过时了(代码改了地图没改),一类是代码写错了(没按地图来)。
- 两类都要处理,但处理方式不同。
这个检查我实测下来,每次能发现5-10处不一致。听起来不多,但如果不查,半年后地图就彻底没法用了。
6.2 让Agent做"变更影响分析"
地图的另一个价值,是做变更影响分析。当有人要改某一层的接口时,让Agent沿着地图分析:这个改动会影响哪些层?哪些组件需要跟着改?哪些测试需要重跑?
比如,如果KMD要改命令提交的接口,Agent应该能分析出:
- UMD层:需要改命令构造和提交逻辑。
- Runtime层:如果UMD的接口变了,Runtime的调用也要改。
- Compiler层:如果命令格式变了,编译器生成的指令序列可能要调整。
- 测试:所有涉及命令提交的测试都要重跑。
这个分析能帮你评估变更的风险和工作量。我见过太多团队,改一个接口结果引发连锁反应,就是因为没做影响分析。
6.3 一个实用的地图维护流程
最后分享一个我实际用过的地图维护流程:
- 每周自动扫描:让Agent扫描代码仓库,提取接口定义,和地图对比,生成差异报告。
- 每月人工评审:团队review差异报告,决定哪些是地图该更新的,哪些是代码该改的。
- 每季度全量校验:用前面说的三个验证场景(新算子接入、性能瓶颈定位、错误恢复)跑一遍,看地图还能不能走通。
- 版本化管理:地图本身也要版本化,和芯片版本、软件版本对应。不要只有一份"最新版",那样历史问题没法追溯。
这个流程听起来有点重,但实际跑下来,每周的自动扫描是Agent做的,人工只需要review报告,工作量不大。关键是坚持。地图这东西,用起来才有价值,不用就会腐烂。
注意:Agent生成的地图内容,一定要有人工review的环节。Agent擅长整理和归纳,但它不理解你的业务优先级和工程约束。哪些接口重要、哪些可以简化,这些判断必须由人来做。
7. 我在这件事上踩过的几个坑
第一个坑:过早追求"完整"。一开始我想把地图画得面面俱到,结果花了三个月,画出来的东西没人看。后来想明白了:地图的价值在于"能用",不在于"完整"。先画核心路径(模型加载、执行、错误处理),再逐步补全边缘路径,这样才有生命力。
第二个坑:让Agent生成太多"通用架构"。Agent有个毛病,你让它设计架构,它总喜欢给你画那种"放之四海而皆准"的图。但这种图对你的具体芯片没用。后来我改了prompt,强制它"只基于我提供的材料,不要引入外部知识",生成的内容才落地。
第三个坑:忽略"人"的因素。地图是给人用的,但不同角色关注的点不一样。编译器工程师关心pass pipeline,Runtime工程师关心调度策略,驱动工程师关心寄存器操作。一张地图如果只有一种视角,那只能服务一部分人。后来我们做了"角色视图",同一份底层数据,按不同角色呈现不同的视图,使用率才上去。
第四个坑:没有和CI/CD集成。地图如果只是文档,很容易被忽略。后来我们把地图检查和CI集成,每次代码提交都跑一遍一致性检查,不一致就报警。这样地图才真正"活"在了工程流程里。
这几个坑,本质上都是"把地图当成了目的,而不是手段"。地图的目的是帮人干活,不是让人供着。想清楚这一点,很多决策就简单了。
8. 给准备做这件事的团队的一点建议
如果你正准备用Agent做AI芯片的全栈软件地图,我的建议是:从小处着手,先解决一个具体问题。
不要一上来就画全栈地图,那太大了。先选一个具体的场景,比如"新算子接入",用Agent把这条路径上的所有层都梳理清楚。跑通之后,再扩展到其他场景。这样每一步都有产出,团队也有信心。
另外,Agent的能力边界要清楚。它擅长的是:整理碎片信息、发现不一致、生成结构化文档、做影响分析。它不擅长的是:做工程决策、理解业务优先级、处理模糊需求。把这两类事情分开,Agent用起来才顺手。
最后,地图的维护比创建更重要。创建一次可能几周,维护是长期的事。如果团队没有维护地图的机制和意愿,那不如不创建——一张腐烂的地图,比没有地图更害人。
我在实际项目里的体会是:全栈软件地图这东西,画的时候痛苦,用的时候真香。尤其是当团队规模上去、芯片版本迭代加快之后,一张清晰的地图能省下大量的沟通成本和返工成本。Agent在这里的角色,不是替代人画地图,而是把人从"整理碎片信息"这种低价值劳动里解放出来,让人专注于"做决策"和"定边界"这些真正需要经验的事。