1. 为什么硬造一块AI芯片,反而是学全栈软件最快的路
先说一句可能有点反直觉的话:如果你真想搞懂AI芯片的全栈软件,最快的方式不是去读几百页的架构手册,也不是直接上手某个开源IP核的驱动代码,而是——自己造一块AI芯片,然后逼着自己把它的软件栈从零写到能跑通一个AI模型。
我说的“造”不是流片,而是用SystemVerilog或者开源工具链做一颗逻辑上完整的AI加速器,跑在FPGA或者仿真环境里。然后,你唯一的“用户”是一块没有任何现成软件支持的硬件,所有驱动、编译、运行时、算子库、框架对接,全都要靠你自己——以及你现在手上这柄“AI Agent”这把新式的软件工程工具。
这件事特别适合Agent来干,原因也很直白:全栈软件地图的本质是跨层知识密集型的活。从寄存器配置头文件到汇编器,从算子调度到ONNX Runtime的EP插件,每一层都是不同的知识体系,知识跨度极大、样板代码多、试错成本极低、又极度依赖对整体架构的理解。Agent写单层代码的能力已经足够成熟,而它最擅长干的事情恰恰好就是这种“按地图边界逐格啃下来”的工程。
这篇文章我会把整条路线摊开,讲清楚一张完整的AI芯片全栈软件地图应该怎么画、每一层具体要做什么、Agent在每一层承担什么角色、以及实际操作里你一定会踩进去的坑。第1篇我讲了怎么用Agent写RTL把硬件本身生成出来,这篇是紧接着的续篇——芯片已经能在仿真里跑起来了,现在给这颗“尸体”注入灵魂。
很多人会问一个问题:这玩意是不是只有做芯片的人需要?其实不是。AI芯片的全栈软件地图这个练习,几乎把计算机系统里最硬核的那几个角落全点亮了一遍——编译原理、操作系统里的设备驱动模型、并行计算、深度学习的计算图优化、运行时设计。你哪怕不做芯片,这套知识的骨架和用Agent拆解大型工程的能力也是完全可迁移的。
2. 一张图看懂全栈软件地图的层次划分
在动手之前,先把地图画清楚。我建议你把一块AI芯片的软件栈想象成一座五层楼,从下往上分别是地基、街道、出租车、城市、机场——这个类比虽然糙,但整个数据流和边界一下就清晰了。
2.1 第一层:硬件抽象与驱动层,你芯片的门卫
这一层接触的是最底层的物理资源。核心工作是四块:
- 寄存器映射与配置接口:芯片上所有可编程控制点(DMA、计算单元、中断控制器、内存控制器)都要暴露成结构体或者寄存器位域。这一层Agent极擅长自动生成,因为硬件设计里的寄存器定义往往是SystemVerilog里某个module的端口和参数,可以以RTL代码作为唯一事实来源,让Agent把头文件和访问函数“拓印”出来。
- 中断与异常处理:计算完成通知、越界访问、总线错误等场景,驱动需要注册ISR(中断服务程序)并把中断语义翻译成用户态可感知的事件。
- MMIO与DMA管理:设备端内存和主机端内存的映射关系,分配、回收、cache一致性操作,这部分纯属“工程活”,但文档少、边界条件一堆,Agent在写这类代码的时候通常比人更有耐心。
- 电源与时钟管理:真正流片之前,这层可以先留空,但在仿真环境里建议保留功耗域和时钟门控的接口位,因为后面调编译器和调度器的时候会用到这些信息。
我们的实际做法是:写了一个dev/axc_regs.h,里面所有寄存器偏移量都是让Agent先解析RTL文件的parameter生成,再来做IO读写封装。实测下来,这一步如果用手敲,一个包含两三百个寄存器的加速器,光写头文件和测试存根就要一个整天。换成Agent之后,我只需要把RTL里每个寄存器的注释整理成一份CSV,让它生成#define AXC_REG_STATUS 0x04这样的条目以及axc_device_write/read()的包装,一小时能迭代三十多轮。
2.2 第二层:指令集与编译器工具链,芯片的母语
第二层是整个软件栈里技术含量最高的部分。AI芯片的指令集和CPU指令集有很大区别——很多AI加速器的指令是粗粒度的张量指令,一条指令可能代表“从全局内存加载一个[128, 128]的矩阵块到片上SRAM,跟另一个块做矩阵乘,再把结果累加到累加器里”。这种指令集的编译器,本质上是计算图调度器+存储分配器+张量指令选择器的三位一体。
在你没有供应商编译器可抄的情况下,我强烈建议第一版别直接上LLVM后端,而是基于一个“中间IR”路线自己实现:
- 定义一份由Annotation标记的张量操作IR(类似ONNX的算子集,但算子粒度更粗更偏硬件);
- 写一个前端Parser,读取来自PyTorch导出的计算图(ONNX格式);
- 写一个调度器,按照数据依赖关系把算子映射到硬件的计算流水线上;
- 写一个代码生成器,输出你自己的汇编语言(可以直接是文本形式)再交给一个简单的Assembler转换成二进制指令。
Agent在这一层的核心用法不是“让它直接写一个编译器”,而是“让它按模块地图逐步实现”。我实际的操作是:先把IR的数据结构定义好(所有字段名、类型、语义注释),然后让Agent基于这份头文件写那个调度器里“给每个算子标记执行单元、排队、分配中间缓冲区”的逻辑。每一步跑出来的单测,就是它能开始写下一步的“扶手”。
这里有一个很值得说的细节:让Agent“写LLVM后端”是一个危险的任务,因为它对LLVM的理解经常是幻觉式的,很容易给你生搬硬套出一大堆看似合理但编译根本不过的接口代码。反过来,自己定义一套小指令集,知识边界完全清楚,Agent犯错的可能性大幅降低。先做小闭环,再考虑广义的LLVM,是这条路线上我亲身试过最靠谱的策略。
2.3 第三层:运行时与算子库,芯片的精装修
硬件抽象层和编译器把“程序”准备好了,但程序要真正跑起来、要让神经元“流过”芯片,还差一个关键环节:算子库。
算子库其实分三层:通用标量算子(元素级的加、乘、激活函数)、张量密集型算子(GEMM、Convolution、BatchNorm)、融合算子(把卷积+ReLU+Pooling一次性合进一个kernel里减少访存)。Agent在算子库上的能力边界其实非常理想,因为算子的语义极其清晰:输入输出形状完全确定、算法公式确定、优化空间由硬件资源消耗决定。
建议第一版算子库直接用C++编写,每个算子分三个文件:kernel逻辑、主机端launcher封装、单元测试。做到后面会发现,AI芯片最有价值的部分恰恰是这些融合算子——它们决定了芯片在真实模型上的有效算力,而不是峰值算力。Agent非常适合从“朴素正确版本”出发逐步做融合优化,每次跑一遍perf数据再改写策略,这个循环让Agent做简直像是为它量身定制的。比如我做一层conv2d_fusion_relu_maxpool的算子,让Agent先从语义上保证正确,再针对片上SRAM的分配策略做优化,3个小时就出了多版设计。
2.4 第四层:深度学习框架接入,芯片的公共语言
世界上没有谁只为了跑裸算子而用AI芯片。最终用户只认一个东西:能不能用PyTorch跑一个ResNet18,Loss能不能正常降。所以第四层你至少要干两件事:
- ONNX Runtime的Execution Provider接入——如果你的芯片有一套从ONNX算子到自家IR映射的功能,那这里就能通过ONNX Runtime的Epsilon框架挂进去;
- PyTorch的自定义后端接入——有两条路:一条是接TorchScript的custom op,另一条是用TorchDynamo的Custom Backend注册接口,把计算图直接导出成你的IR,再走你自己的编译栈。
Agent在这一层特别顺手的点是:它熟知各框架的插件接口样板代码模式,很快就能把一个个EP的骨架搭起来。但你一定得自己盯好lifecycle,比如session的初始化顺序、内存管理约定、profile(Profiling)接口的注册时机。这些地方官方文档写得很模糊,只有踩坑经验才管用。强推一个我在项目里反复用的办法:在你的IR里面加一个JSON Trace插桩点,让Agent基于这个Trace再反推各接口的正确调用顺序。
2.5 第五层:调试工具与性能剖析,芯片的体检报告
最后一层是锦上添花但决定能不能走到生产的那一类:调试和剖析工具。
- 指令级Trace器:记录每一条指令的发射、完成时间戳,输出一个Pandas友好的CSV;
- 内存带宽分析器:按周期统计不同bank的读写访问次数,算出利用率;
- Kernel性能计数器:给每个算子计时、统计stall时钟周期、按执行单元聚合;
- Profiling API:向上暴露给PyTorch的profiler插件,或者TensorBoard的插件。
Agent在这层的输出质量大概是最高的——因为它本质上是“面向特定Log格式的ETL代码”,几乎没有歧义,逻辑直接,容易用Golden数据验证。强烈建议这层和第一层的驱动一起做,因为驱动测试本身就有一堆Trace日志要解析。
3. 实操链路:从RTL到ResNet可推理的完整流水线
地图画完,接下来是你最想看的带参数实操过程。我们以一颗名叫“Axc”的极简AI加速器为例,完整描述用Agent搭建这一整套软件栈的过程和关键命令。硬件参数如下,全栈地图就围着这套参数展开。
| 项目 | 参数 | 说明 |
|---|---|---|
| MAC阵列 | 16x16=256个MAC | 小但足够跑通流程 |
| 片上SRAM | 512KB | 分4个bank,每bank 128KB |
| 累加器精度 | FP32,支持FP16输入 | |
| 外部DDR | 2GB,带宽16GB/s | |
| 指令类型 | NOOP/LOAD/STORE/GEMM/ACT/CONV/POOL/BRANCH/END | 9条指令 |
| 中断 | 完成中断+故障中断 | |
| DMA | 2D搬运DMA,支持行间跨步 |
好,现在开工。
3.1 第一步:让Agent先“读懂”RTL,建立唯一事实源
全栈软件最忌讳的一件事就是:硬件已经改了,软件还在按老规格写。避免方式只有一个——让RTL成为所有衍生物的单一权威源头。
我用一个脚本解析RTL里的模块端口和参数生成一份machine readable的axc_hw_spec.json,里面包含寄存器偏移、位域含义、内存布局、请求/响应信号定义。这份JSON就是整条全栈链路里每一层Agent的“输入上下文”。
我自己的标准prompt模板长这样:
你是一个AI芯片的系统软件工程师。 以下是芯片硬件规格(JSON): {省略为axc_hw_spec.json的内容} 请生成驱动层的C++代码,要求: 1. 提供寄存器读写的内存映射类; 2. 提供DMA描述符结构体定义与填充函数; 3. 提供命令队列的提交与完成轮询函数; 4. 所有关键操作添加参数ALWAYS_ASSERT的范围检查; 5. 生成一份配套的单元测试文件,使用Google Test,覆盖边界条件。为什么这份prompt有效?核心是第4条。AI生成代码最常见的坑是“因为知道运行时不会出问题所以省略掉防御逻辑”,但硬件驱动恰恰是必须有防御逻辑的那一种——非法地址访问、非法对齐方式、向只写寄存器做读操作,这些都应该在软件层被挡掉而不是寄希望于硬件容错。
3.2 第二步:搭建指令集与汇编器
我给Axc芯片定义了一套非常精简的指令编码,每种指令定长48bit,分成三个type。这里放一个定义示例:
Type A: VAL/GEMM/CONV/POOL (计算类指令) bit[47:46]=opcode_type // 2'b01 bit[45:42]=opcode // 4位,区分具体指令 bit[41:40]=执行单元ID // 0=MAC,1=act,2=pool bit[39:32]=目的寄存器或地址描述符索引 bit[31:16]=operand A描述符索引(指向SRAM段) bit[15:0]= operand B描述符索引(指向SRAM段或立即数)然后我让Agent基于这个编码格式生成三样东西:汇编器(文本指令转二进制的Python类)、反汇编器(二进制转文本)、以及一个指令级仿真器(执行单条指令并模拟SRAM内容变化)。
这个指令级仿真器极其关键。它是后面所有上层软件测试的基础设施——你在没有硬件原型之前,用这个仿真器就能把调度器和算子库先验证一遍,等到FPGA版本就绪之后,再把底层backend从仿真器换成MMIO读写,上层代码一行不改。
注意一个关键点:指令级仿真器不能直接用硬件行为级仿真替代。你需要的是一份跑得快、语义精确、能精确到单个寄存器/SRAM字节的“参考实现”,而不是一个跑一个周期要好几微秒的完整RTL仿真。二者定位不同,缺一不可。
3.3 第三步:编译器的实现路径
编译器是这一条链路上最长的一块骨头。我建议按对象切分任务:
- 算子IR定义:所有算子的输入输出Schema、属性列表、内存归约信息;
- 图优化Pass:算子融合、布局转换、常量折叠、存储规划预估;
- 指令选择与调度模块:把IR算子逐个映射为具体的硬件指令序列,插入数据搬运LOAD/STORE操作,保证片上SRAM装得下计算所需的数据块;
- 内存分配器:这是最容易被低估的模块。片上SRAM总共512KB,分成4个bank,如果分配器写不好,一个大算子直接分配失败,或者因为bank冲突把吞吐拖垮。内存分配器建议先实现一个最朴素的“按序扫描+best-fit”策略,等稳定了再做优化。
实际上整个编译器的编写过程,我强烈推荐做成一个Agent做一股数据流拆解的方式——中间层有一个共享的Context文件存所有IR定义、硬件参数和编译错误反馈,让Agent每次只管改自己那段代码,然后统一回归。上下文一多,单个Agent的幻觉就开始失控,所以不能让一个Agent端到端生成整个编译器。这跟你让一个外包团队用4个工程师和你自己迷茫盯着的区别是一样的。
3.4 第四步:算子库的编写与验证
算子库是所有自动驾驶中的“弯道最平滑”的一段。我们的做法是先把每个算子的正确性建立在“输入随机化+输出对照”的框架上:
- 生成随机输入张量;
- 在CPU上用朴素算法计算预期结果;
- 调用芯片编译运行得到实际结果;
- 对比误差,FP32容差1e-5,FP16容差1e-2。
这一步的Agent任务描述可以相对放权,因为验证机制足够强。我会把“正确性门槛”作为反馈喂回给Agent让它自改自测,这样迭代效率非常高。
完成后,把算子库按src/operators/*.cpp的结构放好,统一导出成C ABI接口,方便上层框架调用。所有算子的内存管理都走axc_alloc_on_chip(size, bank_id)和axc_alloc_on_host(size)两个人。
3.5 第五步:ONNX Runtime接入与PyTorch推理闭环
接入ONNX Runtime挺枯燥的,但Agent能给出一个能跑的最小实现。我实现了一个极简EP,只实现五个必须的接口:GetCapability(声明支持哪些算子)、CreateKernel(给定节点信息创建kernel实例)、Compute(真正执行)、Initialize和Release。
为了把PyTorch模型导进来,流程是:
torch_model.export -> ONNX -> ONNX Runtime + custom EP -> axc compiler -> 指令二进制 -> 指令仿真/FPGA执行这步跑通之后,你才算真正有了一块“软件上完整”的AI芯片。什么指标都是虚的,一个ResNet18的loss能正常反传更新,你就能说全栈通了。
4. 用Agent做全栈软件时必踩的坑
Agent不是神话,它写全栈代码的时候,坑不比人少,但坑的形状跟人不一样。这里挑工作里最值得盘的几个。
4.1 上下文窗口这个最贵的瓶颈
Agent的上下文窗口是稀缺资源。全栈软件地图里,每一层的Agent看到的“相关硬件信息”加起来非常庞大。如果你一股脑把一整份RTL代码、一整份寄存器列表、一整份指令集定义都塞给一个Agent,它大概率会在后半截开始胡编。
解决办法是构建分层的上下文。我自己建了一个知识库目录,把硬件规格按“寄存器-内存-指令-中断”拆成小块,同时在给Agent的每个任务描述里只提供它这个子任务真正依赖的那些文件路径和摘要,而不是全文。开发过程中用RAG做关键词检索,核心的那几百条数据全部用模板化的“prompt+上下文”封装好了。
4.2 对硬件行为的“幻觉”是最贵的幻觉
人写驱动,遇到不会的寄存器行为会去查spec,会停下来试。Agent写驱动,为了让你觉得它“有产出”,它会生造出一种很合理但硬件根本不支持的行为。
最典型的例子:给一个只支持同步中断的设备强行生成一个轮询模式的异步事件循环。所以我用了一个很土但很有效的检查办法:凡是Agent生成的驱动里,所有和“事件”“等待”“状态轮询”相关的代码路径,必须对应芯片RTL里确实存在的状态信号。我在知识库里维护了一个“状态信号白名单”,Agent请求访问的状态变量不在名单里,直接拒审。
4.3 错误聚合与回归策略
全栈联调时,你面对的问题是错误会在多层之间互相渗透和放大。一个算子库的错误可能表现为编译器的非法指令序列,而编译器的一个存储分配错误又可能在指令仿真器里表现为“死循环”。如果你让Agent一开始就带有多层上下文的错误去改顶层代码,它很容易改出一个看起来更对但事实上引入更多新问题的方案。
我的经验是一定要建立一个分层回归流水线:任何一层代码改动,先只跑这一层和下一层的测试,确认没有把“原本对的东西改错”之后,再放行到上层。这条流水线在项目里就是一条简单的Makefile脚本,每条命令对着一个可选参数控制层数深度,跑完输出回归矩阵。Agent补丁打进去之前,先在这个矩阵里滚一遍,绿灯放行。
4.4 别让Agent“过度设计”
几乎所有Agent都有过度设计的倾向。你让它写一个DMA搬运的函数,它给你整出一套AxDmaQueue、AxDmaBufferPool、AxDmaCompletionHandler三个类外加一个VIP架构。对着一个200行的内聚任务,这种设计纯粹是负担。
实际操作里我会在任务开始前明令约束复杂度:“本任务只允许在文件A中修改,最多新增两个函数,禁止引入新的类或依赖库。如果确实存在无法绕过的重构,请先在对话里说明理由并经我确认。”有了这个护栏,Agent的收敛速度快了非常明显。
5. 常见问题速查表
| 症状 | 原因 | 排查与解法 |
|---|---|---|
| 寄存器读回全0 | 寄存器映射偏移写错 | 让Agent重新导入RTL的寄存器列表JSON并生成偏移表,对照dmesg/仿真波形核实 |
| 指令序列执行到一半卡死 | 指令级仿真器缺了某种指令的处理分支 | 在“未识别指令”位置打印错误码,检查反汇编日志 |
| GEMM计算结果随机性灾难 | 存在SRAM bank冲突或DMA描述符跨步参数错 | 开Trace工具,查具体访问地址落到哪个bank,调整内存分配对齐 |
| ONNX EP注册成功但推理报节点不支持 | GetCapability实现过滤太严 | 检查ONNX节点类型名多带版本后缀,用NodeType字符串比对,使用完全匹配+版本匹配 |
| PyTorch导出模型推理结果值全对但速度奇慢 | 每次算子启动缓存了太多无用配置 | 检查Initialize是不是只应在第一次调用时做,不要每算子重跑 |
| Agent生成代码大量重复 | 上下文被截断后丢失之前的约定 | 增加共享头文件,把所有类型定义集中放置,Agent每次引用该文件而不是重新声明 |
6. 我最后的几句经验
整个过程做下来,我最真切的一个体感是:Agent把“从零写出全栈软件”的门槛从“天才级”降到了“按图施工级”,但“按图施工”的前提是你自己必须有个清晰的地图。地图里每一层该有什么、边界在哪里、验证标准是什么,这些必须由人定死。Agent负责的是在地图标注的路段里高效填充材料。
如果你打算复刻这条路线,我给三个最优先级建议:第一,先做通“指令仿真器+算子库”,这是整条链路上最便宜也最能给你信心的闭环;第二,强制让RTL的寄存器定义作为单一事实源,所有软件生成的寄存器访问代码都从那份JSON导出,切断手写漂移;第三,在你开始写任何第四层框架接入之前,先保证第三层算子库在仿真环境里能把ResNet18的conv和linear跑出数值正确的结果。
等到什么时候你可以让自己的Axc芯片在PyTorch里把一轮MNIST训练跑完,看着loss曲线一路往下走,你放心,那一刻的成就感比看到任何跑分都要来的实在。后面第3篇我们聊聊怎么把这条软件链路从仿真环境挪到真实的FPGA板卡上,那里会遇到一些仿真里根本不会暴露的时序问题,写代码的思维得再换一档。