第一次把自研AI芯片的软件栈画成一张地图,是在那台编译服务器连续三次内存溢出之后。硬件那边流片早就回来了,测试芯片能跑引导程序、能点灯,芯片团队觉得胜利在望,软件团队打开仓库却有点沉默:驱动、运行时、编译器、Python推理框架、模型转换工具加起来几十万行代码,分布在十几个仓库,再加上三四套构建系统和跨语言的胶水层,没有任何一份文档能把“一行PyTorch模型从Python层走到芯片执行”的全过程说清楚。
我就是在那时候决定,用Agent把这块芯片的全栈软件地形自动测绘出来,让新人也能够在入职三小时内知道去哪里改代码,让做芯片架构的同事也能在白板上指着某个NPU核讲清楚它的软件支撑到底依赖什么。这是我“用Agent做一块AI芯片”系列的第二篇,但即使你没看过第一篇,也不影响阅读——这一篇的核心是软件栈的“地图化”方法,以及Agent在这个过程中到底怎么干活。
先说结论:所谓全栈软件地图,不是画一张漂亮的架构图。它要回答三个问题——一个模型请求从哪进来,经过哪些模块、结构体和接口转换,最终在哪颗计算核上以什么格式执行。把这三个问题的答案变成可检索的索引、可维护的文档和清晰的依赖拓扑,你的软件栈才算真正“可导航”。
这篇文章适合两种人:一种是我这种正在做AI芯片或专用加速芯片软件栈的,需要快速梳理并行团队堆起来的代码;另一种是手里有几十万行C++/Python混合项目、想用Agent自动分析依赖关系并持续维护项目地图的。后面你在单测、代码评审、故障排查甚至文档生成上遇到的场景,跟它是同一套手法的延伸。
1. 为什么一张“软件地图”比芯片本身更值钱
1.1 AI芯片的死法,不是死在流片,是死在“软件跑不起来”
聊AI芯片的人总爱把流片当作分水岭,好像芯片回来后一切都会自然发生。实际上,流片成功只是入场券。一块AI芯片要被人真正用起来,必须让PyTorch模型、ONNX模型或TFLite模型跑出可复现的结果,而且性能还有指标要求。为了这个“跑起来”,你可能得同时打通驱动、内核模块、用户态运行时、编译器、算子库、图优化器和框架适配层。任何一个环节断了,整个芯片在用户眼里就是一块废硅。
我见过太多项目死在“软件跑不起来”上:驱动能加载,但Host和Device间的一次DMA搬运就要几毫秒;算子库有几百个kernel,但跑第一个ResNet全模型时发现关键算子没有实现;编译器能编出汇编,但生成的指令流在Cache策略上完全没对齐芯片的硬件设计。这些问题想快速定位,靠的往往不是逐行读代码,而是先有一张“地图”——知道问题大概出在哪个层、哪条链路上,再进去细看。
全栈软件地图最大的价值就在这里:它不是用来给老板汇报的PPT,而是用来回答“哪里最痛、哪里最短、哪两个模块看似无关实则强耦合”的工程工具。
1.2 “全栈”到底指哪几个栈:算力栈、工具链栈、应用栈
很多人一听到“全栈”,下意识以为是把AI芯片的所有代码都看完。这个想法既有误导性又无可行性——几十万行代码塞进任何人的脑子或上下文,结果都是一团浆糊。
我习惯把AI芯片的软件体系拆成三个栈:
- 算力栈:指芯片计算单元的直接软件支撑,包括驱动、运行时、内存管理、调度器、算子和底层加速指令。
- 工具链栈:指把模型转换成芯片可执行格式的编译器、图优化器、量化校准工具和性能剖析器。
- 应用栈:指面向用户的一层——Python框架绑定、推理服务SDK、模型仓库适配,以及上层Agent/应用调用推理能力时的API边界。
三个栈的分界要画清楚,因为不同团队维护不同栈,Agent在测绘时也要分开采样、分开建模,否则会把编译器的LLVM Pass和运行时里的调度队列混在一张图上,越画越乱。
我们这篇文章重点谈算力栈和工具链栈的“地图化”,应用栈留到结尾讲和Agent的衔接。
2. 拆开一块AI芯片的软件栈:从裸硅到模型服务一共有几层
2.1 硬件抽象层:寄存器、DMA和计算单元的“国家边界”
任何AI芯片的软件地图,最底层一定是硬件抽象层。它定义了软件和芯片之间的“国界”:哪些寄存器是可写的、哪些只能读、中断号怎么分配、MMIO地址范围在哪。
以我们自研芯片为例,硬件抽象层覆盖这些实体:
- 计算核(我们叫NPU Core)的寄存器组:包括控制状态寄存器、启动寄存器、中断状态寄存器。
- Global Memory和Local SRAM的地址窗口:DMA搬运的目标和源地址都要映射进来。
- 片间互联(NoC/总线)的端口配置:多核访问全局内存时要走的路径。
- 同步与事件机制:几个核同时完成一阶段计算后怎么互相通知。
Agent测绘这一层的核心任务是识别两类东西:一类是物理硬件的“暴露面”,比如mmio_base.h头文件里的地址宏定义;另一类是软件访问这些暴露面的唯一入口,比如hw_reg_read(reg_t addr)的封装函数。一份好的地图要把“哪些代码直接摸了寄存器”标出来——这些代码如果写坏了,整块芯片就可能卡死或跑飞,它们是代码评审和测试时最需要关注的地方。
实际测绘时,我让Agent重点检索这些模式:
#define+地址常量,或REG_BASE这类宏;readl/writel/ioremap/mmap设备寄存器;- 设备树文件(
.dts/.dtsi)中描述的中断号和地址范围。
Agent找到这些之后,会把它们记成一张“硬件接触面表”,后续每一层软件向下依赖这张表,谁乱动它,谁的依赖就会断裂。
2.2 编译层:从计算图到指令流的“翻译官”
编译层是AI芯片软件栈里最复杂的部分,也是外界最难理解的。芯片不认PyTorch模型,只认自己定义的指令或微码。中间的翻译,落在编译器身上。
编译器收到模型之后要经历这些步骤:
- 接收计算图(PyTorch的TorchScript、ONNX,或TFLite FlatBuffer)。
- 做算子分解和融合:比如把Conv、BatchNorm、ReLU合并成一个融合算子,减少内存访问次数。
- 图优化:常量折叠、死代码消除、布局转换(NCHW转NHWC或芯片特殊的tile布局)。
- 算子选择:把融合后的算子映射到芯片已有的预编译内核或可编程指令模板上。
- 内存规划:把张量安排到Global Memory、Local SRAM、寄存器文件三级存储中。
- 指令生成和调度:产出芯片指令流,并尽可能做双缓冲、流水线重叠。
Agent测绘编译层时,最容易抓狂,因为编译器的抽象层次极多:IR(中间表示)就有好几种,Pass链从IR进来再回到IR,中间还有各种方言。我让Agent按“输入格式—IR中间态—Pass链—代码生成后端”四个小结构去扫,而不是期望它把整个编译器的控制流都理解清楚。
举个例子,我们的编译器里有这样一层代码,专门把ONNX的Conv节点翻译成芯片的卷积指令模板:
struct ConvLowering : public OpLowering { LowerResult lower(const onnx::NodeProto& node, const MappingContext& ctx) override { // 从ONNX属性里读取kernel_shape、strides、pads auto kernel = node.attr("kernel_shape"); auto strides = node.attr("strides"); // 查算子库元数据,拿到对应NPU Core指令模板 auto tpl = op_lib_->find("npu.conv2d", kernel, strides); if (!tpl) return LowerResult::unsupported(node); // 做内存布局转换,把输入tensor重排成tile格式 auto layout = tpl->required_layout(); auto input = ctx.rewrite_layout(node.input(0), layout); // 输出指令候选 return LowerResult::success(tpl->emit(input, node.output(0))); } };这一层的地图,重点不是画出每一个Pass,而是画出“模型入口—Pass主链—后端出口”的骨干,并标清楚哪些算子支持、哪些走fallback、哪些会报Unsupported。有这张表,模型移植时一遇到跑不起来的算子,你就能马上判断到底是编译层的算子选择失败,还是运行时缺实现。
2.3 运行时与驱动层:内存、调度、并发都在这里兜底
编译层把模型翻译成指令流,运行时和驱动负责把这串指令流真正送进芯片,并管理芯片上的资源。
运行时层有两套最常见的架构:
- Host侧Runtime:运行在CPU上,负责申请内存、创建执行流(Stream)、提交任务、等待完成。类似CUDA的运行时API。
- 设备侧Firmware:运行在芯片内部,负责解释Host下发的指令队列、调度计算核、处理中断和错误。
Agent测绘这层的时候,我安排它优先追踪这些关键对象:
Device、Context、Stream、Event这些核心类之间的生命周期关系。- 内存管理:
malloc_device、memcpy、free_device的调用链,以及内存池的实现位置。 - 任务提交语义:是同步
sync提交,还是异步stream+event的依赖关系。 - 错误处理路径:芯片报错后,错误码从哪里产生、传到哪里、用户API在哪个位置能拿到。
这里有个特别容易忽略的地方,也是我建议Agent必须采集的:同步原语。多颗NPU Core并行计算时,谁等谁、谁通知谁,全部靠运行时层的同步机制。如果地图里没有把这些信号量、栅栏、事件对象标出来,后续做性能调优时你连并行瓶颈在哪都找不到。
2.4 框架与应用层:云边端模型的入口和出口
框架适配层决定了用户能不能把模型喂给你的芯片。常见做法是提供ONNX Runtime的ExecutionProvider,或者PyTorch的Custom Backend。
这层代码量通常不大,但极考“耐心”:你要对接PyTorch复杂的前端逻辑,还要处理Tensor在框架和设备之间的拷贝、DFS和自动微分钩子,有些模型算子会直接走CPU fallback,性能突然掉一个数量级。
Agent在这层的主要任务是画出“框架入口—张量搬运—设备侧执行—返回结果”的生命周期。我让它重点找这些内容:
torch::Tensor到设备张量的转换函数,例如to_blob或from_blob。- 框架调用设备算子的适配器,例如
at::native::里的自定义函数。 - ONNX Runtime里的
GetCapability()和Compile()接口实现。
另外,这层是未来AI应用接入最频繁的地方。如果你想让上层Agent(模型编排、自动调参、推理服务网关)调用芯片算力,它们在框架API层看到的就是你的执行入口和鉴权接口。地图上把这层标注得越清楚,上层系统的开发速度越快。
3. 让Agent当测绘员:我用什么架构把软件栈画成地图
3.1 Agent在这个任务里到底干哪些活
让Agent画地图,不是把几十万行代码丢给一个聊天窗口然后问“帮我总结一下”,那是幻觉制造机。我用的方式是把Agent当作“测绘员团队”,给它划分明确的岗位:
- 侦察员(Sampler):负责全局扫描,用代码检索工具找关键符号、文件和依赖。
- 分析师(Analyzer):负责深度解析单个模块,读取特定文件并提炼结构。
- 验证员(Verifier):负责检查分析结果,例如确认头文件是否存在、符号是否真的被引用。
- 制图员(Cartographer):负责把前几位的产出汇总成层级结构、依赖索引和Markdown文档。
这种角色划分的好处是,每个Agent的任务边界清晰,上下文窗口可以控制得很小,幻觉的扩散空间被压缩。坏处是你要多花时间做编排,但实测下来产出质量比单一大模型高很多。
3.2 整体流程:采样代码 — 解析依赖 — 验证拓扑 — 产出地图
我的Agent测绘流程固定为四步,每一步都对应明确的输入和输出:
采样代码:
- 用
rg(ripgrep)全仓库搜索关键pattern,比如REG_BASE、ONNX、torch::Tensor、Build目录下的CMakeLists.txt、BUILD.bazel。 - 按栈的层次切分搜索范围,避免一次搜全仓库。
- 用
解析依赖:
- 读取各类构建文件,提取目标、源码文件列表、头文件路径和依赖项。
- 对C++代码用
tree-sitter或clang提取符号引用;对Python代码用ast解析import关系和函数调用关系。
验证拓扑:
- 让Agent随机选一批“关键路径”,做交叉验证。比如从某个算子入口出发,追踪它的实现和调用者,确认地图上的链路与实际代码一致。
- 凡是验证不出来的依赖边,一律标记为“待人工确认”,绝不写进最终地图的“确定”区域。
产出地图:
- 按层次组织,输出Markdown文档和JSON拓扑文件。
- JSON拓扑文件是给机器用的,后续增量更新和性能分析都在它基础上做。
3.3 工具和提示词:我实际用的那套组合
工具选择是个门槛,选错了,Agent再怎么调也白搭。我用的是这套组合:
| 用途 | 工具 | 说明 |
|---|---|---|
| 代码搜索 | ripgrep /rg --json | 全仓库快速搜索,输出结构化结果,Agent可直接消费 |
| 符号提取 | tree-sitter CLI、clang AST dump | 解析C/C++/Python语法,提取函数、类、引用关系 |
| 构建依赖 | cmake-file-api、bazel query | 从构建系统拿目标级依赖,比静态扫描准确 |
| 文件解析 | grep + jq + 小型Python脚本 | 轻量处理JSON、日志、头文件宏定义 |
| 交互框架 | Agent框架 + 自定义工具封装 | 让Agent能调用上面这些命令,而不是把代码塞进上下文 |
给侦察员Agent的提示词,我经过多轮迭代后基本定格成了这样:
你是AI芯片软件栈的代码侦察员。现在需要你完成以下任务: 1. 在指定目录下搜索所有包含 NPU_REG_BASE 或 REG_BASE 的代码文件。 2. 对每个结果,输出文件路径、行号、宏名和附近代码片段。 3. 如果搜索结果超过50条,按目录聚合后再输出。 不要做任何推测,不要解释代码逻辑,只报告你看到的事实。 输出格式:Markdown表格。看到没,约束Agent“不做推测、只报告事实”,是减少幻觉的关键。很多Agent任务翻车,不是因为模型不够聪明,而是因为提示词没告诉它哪些事情不要做。
4. 实测记录:Agent给我画出的第一版地图
4.1 规划阶段:给Agent定的边界和验收标准
在让Agent正式跑之前,我先花了半天定义边界,否则Agent会失控。我最终圈定的范围是:
- 只覆盖算力栈和工具链栈,不碰芯片验证的UVM平台,不碰上层云编排。
- 代码仓库限定为:
device-driver、runtime、compiler、oplib、framework-adapter。 - 验收标准:地图能让一个刚入职的工程师在30分钟内找到“运行一个ResNet18模型”所涉及的全部核心文件和关键路径。
同时我给了Agent一个“禁区清单”:不生成流程图、不生成架构美化图、不输出性能推测、不给出优化建议,只做事实测绘。这一步很重要,因为Agent一旦开始自由发挥,你得到的就是一篇看起来很有逻辑但没法用的“AI幻觉纪要”。
4.2 执行阶段:Agent的代码采样、追踪和归档步骤
整个执行跑了两轮,第一轮花了大约四个小时,第二轮用来修正错漏又花了两个小时。第一轮的主要步骤:
- 全局扫描:侦察员Agent对五个仓库分别跑
rg,搜索几十组pattern,包括驱动入口、寄存器宏、DMA描述符、算子注册宏、ONNX Exporter、PyTorch Backend等。 - 构建依赖提取:用
cmake-file-api和bazel query分别生成目标视图,再让Agent把目标视图里每个文件映射到它分析出的模块上。 - 深度追踪:分析师Agent从
model_inference入口函数开始往下追,记录每一层调用的函数、所在文件和头文件依赖,生成调用栈快照。 - 交叉验证:验证员Agent随机抽了20条关键路径,对每一条路径从反方向(从底层硬件抽象层向上)重新追踪,把不一致的依赖边全部丢到“待确认”列表。
最终产出物是四个文件:
software_stack_architecture.md(人类可读地图)dependency_graph.json(机器可读拓扑)hardware_contact_surface.csv(硬件接触面清单)pending_review_list.md(待人工确认清单)
这份地图的最大价值,是让我第一次完整看到了“一个模型请求”从ONNX文件进入编译器、经过四个铁律类Pass、在算子库命中一个NPU卷积模板、再经过运行时内存规划和DMA调度,最后落到NPU Core指令发射的完整旅程。将近几十个人合作写了两年多的代码,第一次像一本地图册一样摊开在眼前。
4.3 结果复盘:哪些地方画对了,哪些地方在瞎编
复盘结果让我挺意外的。Agent对构建依赖和符号引用的追踪准确率很高,对宏定义和底层地址映射的挖掘也很可靠。但有三类问题是明显瞎编或者至少不准确的:
- 把同名不同作用域的变量连成了依赖边,比如两个不同文件里都有
input_成员,Agent容易画出一条根本不存在的引用关系。 - 把注释里的老接口当成活跃代码,导致地图里出现一些早就废弃的模块。
- 对“运行在Host侧还是设备侧”判断不清,有些类明明跑在设备固件里,Agent画成了Host运行时的一部分,这会误导性能分析。
所以我一直强调验证阶段不能省。第一版地图里,Agent自己列出的“待确认依赖边”有四十多条,这不算丢人——它知道自己不确定哪些,比假装确定强得多。我建议你无论用哪个Agent框架,都要保留一个机制,让Agent在置信度不足时输出UNKNOWN,而不是生造一个可能性出来。
5. 地图之后:从“静态地形”变成“动态导航”
5.1 把git提交和CI当作心跳,让地图自动更新
静态地图发布几天后就会过期,这是必然的。仓库每天都在变,新增算子、改接口、迁移模块,任何变动都会让地图失真。所以地图必须从一次性测绘,变成可持续更新的“动态导航”。
我目前的方案是给Agent接入了两个“心跳”信号:
- git提交事件:每次merge到主干时,Agent把变更文件列表和地图对应层做比对。如果变更涉及某一层的核心模块,就标记该层为“待更新”。
- CI构建事件:当CI失败或特定模板测试开始跑时,Agent自动在对应层次追加一条“构建失败提示”,让地图不仅能导航,还能预警。
增量更新不用每次全量跑,Agent只需要对变化文件做局部分析。比如一个算子库新增了某个NPU Kernel,就只更新算子库节点及它与编译层的依赖边,其余部分不动。更新后的JSON拓扑按时间戳存档,方便回溯“地图的某个判断是何时被修正的”。
5.2 软件地图怎么反哺芯片架构和软硬件协同
地图画出来之后,最大的收益之一,是它能反哺芯片下一版的架构决策。我们不到一个星期就用地图找到了一个重要瓶颈:从编译器到运行时,所有的张量描述符都要经过一个极长的序列化/反序列化过程,导致一个小模型的一次推理光搬运描述符就要浪费大量时间。
这个结论放到地图上非常直观:编译器生成的“tensor descriptor”先转成protobuf消息,运行时再解析成内部结构,最后驱动又把它转成固件能认的二进制定点结构。三次转换在三个层里分别进行,每层都觉得自己设计合理,但连起来看,这个链路明显可以砍掉一次或两次转换。于是我们提给芯片架构团队一个建议:下一版在硬件里增加一个计算描述符的专用寄存器区域,减少CPU侧转换。这个建议没有地图,根本没法快速定位到。
所以,软件地图不只是拿来“给新人看”的,它更是一张瓶颈地图:每个层次的边界、每次跨层调用的代价、每个序列化/反序列化点,都会跃然纸上。软硬件协同优化的切入点,往往就藏在这些边界角落里。
5.3 多Agent按层协作时的任务切分方式
既然一张软件栈天然分成了多个层次,多Agent协作时就可以按层切分任务,而不是按仓库切分。我建议切分方式如下:
- 硬件层Agent:负责寄存器、DMA、中断、地址映射。
- 编译层Agent:负责Pass链、IR转换、算子选择、后端代码生成。
- 运行层Agent:负责内存池、执行队列、同步机制、设备固件交互。
- 框架层Agent:负责PyTorch/ONNX Runtime适配、张量生命周期管理。
层与层之间通信靠一个共享的“接口边界表”完成。比如编译层输出一个EmittedKernel结构,运行层Agent不需要理解编译器的Pass逻辑,只要知道这个结构的字段含义和生命周期就可以了。这样每个Agent的上下文窗口都很小,既能深入分析,又不会相互干扰。
这种按层切分还有一个好处:评审和验收也按层做。硬件工程师评审硬件层的地图,编译器团队评审编译层的地图,而不是让一个不懂驱动的人去确认整个系统图。多Agent系统要真正可落地,职责边界比模型选型更重要。
6. 这次测绘踩过的坑,比收获还多
6.1 上下文窗口塞爆:给Agent喂全仓库是个馊主意
最早我犯过一个非常典型的错误:为了让Agent“全面理解系统”,我把整个仓库的代码摘要甚至关键文件全文都塞进它的上下文里,结果几分钟后Agent就开始胡说八道——不是它笨,而是上下文太长之后,注意力被大量无关文件占据,关键依赖反而抓不住。
后来我把策略改成了“工具存取,按需注入”:Agent想读哪个文件,就通过工具去读,读完的结论存进工作区,不占用长期上下文;Agent要横向搜索,就调用rg或bazel query拿结构化结果。上下文里只保留当前子任务的局部信息。这个调整的收益立竿见影,地图的质量直接上了一个台阶。
一句话总结:让Agent当测绘员,是靠它手里的工具包取胜,不是靠它把整个城市的地图背下来。
6.2 幻觉模块:如何识别和拦截不存在的依赖
Agent最大的毛病是会在依赖图里“编”出一些看起来合理但根本不存在的模块。例如某个头文件路径,如果Agent只根据记忆里的模式判断,可能直接写出一个include/npu/core/conv2d_engine.h,但实际这个路径在仓库里并不存在。
我的拦截方法是三重验证:
- 第一重,工具验证:所有路径、宏、符号,Agent必须调用
ls/rg/cmake query确认存在。 - 第二重,反向追踪:从地图上的依赖边B反向查回A,看A的调用栈里是否真的出现了B。
- 第三重,编译验证:对于最关键的头文件和依赖关系,直接用编译器的依赖扫描工具生成精确的
#include关系做比对。
经过三重验证后,幻觉模块的比例会降到很低。但你要接受一个现实:不可能降到零。所以地图上要保留“置信度”字段,低于某置信度的依赖边不参与后续的自动化分析,只作为参考信息。
6.3 版本漂移:地图生出来那天就已经过时了
最后一条经验是关于“更新成本”的。第一版地图用了不少精力,但三天后我再看它,已经有一些细节过时了——某个目录被重命名,某个接口增加参数,某个Pass链新增了一个优化步骤。
所以我现在反复跟团队强调:地图不是文档,地图是“可更新的数据库”。我们给JSON拓扑文件配置了结构化校验,定期跟每个仓库的主干分支做一次差异比对,每次大版本变更触发一次局部更新。Agent看起来像一次性的美差,做多了其实是在维护一个不断生长的软件地形数据库。
这套东西跑通之后,团队里一句很常见的话是:“帮我查一下这个报错顺着调用链是哪一层抛出来的?”以前摸半天,现在可以直接用地图索引定位,然后把相关代码拎出来看。这才是做全栈软件地图真正让人爽到的时刻。
这次测绘给我的体会是,AI芯片的量产门槛从来不只是晶圆和封装,软件栈的“可知性”可能更致命。Agent作为测绘员,本意不是替代软件工程师,而是把工程师从“在几十个仓库里大海捞针”的体力劳动里解放出来,把精力放到真正需要判断力的地方。下一篇我会谈谈,在这个全栈软件地图的基础上,怎么让Agent进一步辅助算子库的性能分析和回归测试。