后仿跑到第七个小时,终端上那行百分比数字已经二十多分钟没动过了。你盯着屏幕,心里没底——它到底还在算,还是早就卡死在某个时间步里出不来了?前者的情况下关掉就白跑一晚上,后者的情况下干等下去也是白耗一个下午。这种时候,一份能告诉你"这次run从几点开始、在哪个corner下跑、跑到哪一步、有没有命中报错关键字、波形存哪了"的仿真过程状态记录,比任何调试技巧都值钱。
这篇聊的就是后仿(post-layout simulation)里的过程状态记录这件事。它听起来像是工程管理层面的杂活,跟写testbench、调波形没半点关系,但真到了仿真发散、X态乱飞、跑一次要十几个小时的阶段,这套记录就是你唯一的抓手。它适合已经跑过前仿、手上有带寄生参数网表的人,也适合刚接手别人后仿工程、面对一堆没有说明的日志文件一脸懵的人。下面我按"为什么后仿的记录方式和前仿不一样—记录哪些字段—怎么让记录自动产生—出X态时怎么用—收敛失败和超长仿真怎么兜底—我踩过的坑"这条线讲。
1. 后仿的状态记录,为什么不能照搬前仿那套
1.1 一次run的代价从分钟级跳到小时级
前仿的时候我们对"状态"这件事是很随意的。RTL层次的仿真,一次跑完三五分钟,觉得不对,改两行,重跑一遍就行。记录什么?顶多是在终端里翻翻滚动历史。这个习惯延续到后仿就会出事。一旦网表换成带寄生RC的门级网表,节点数从几万涨到几百万,同样的激励,仿真时间可能从八分钟变成十二个小时,甚至更长。时间放大两三个数量级以后,"重跑一次"从随手操作变成了需要排进日程的事情。
代价变了,记录的定位就变了。前仿的记录是为了"复现问题",后仿的记录首先是为了"别让已经花掉的机时白白蒸发"。你得能在不重跑的前提下判断:这次run是正常结束、是超时、还是崩了;它跑到哪个时刻;上一次成功的run用的什么配置。没有这些,你面对的就是一个黑盒,而打开黑盒的成本是一次通宵。
我个人的习惯是,后仿开始之前先把"预期时长"估出来。方法不复杂:先跑一个很短的时间窗(比如几微秒仿真时间),记录真实墙钟耗时,再按目标仿真总时长线性外推,最后乘一个1.5到2的余量系数。因为后仿后期由于网络进入深度充放电、事件密度变化,单位时间的墙钟开销往往会比初期高。把这个预估值写进状态记录里,之后每次看进度,你心里就有个参照:现在这个速度是正常范围,还是已经慢得离谱了。这个预估值本身也是状态的一部分,它能让下一次run的判断更快。
1.2 后仿里最容易丢的三类现场:X态、不收敛、时序违例
后仿区别于前仿,有三类问题几乎必然会遇到,而它们共同的特点就是"现场极其宝贵,丢了就很难重建"。
第一类是X态。开了X传播分析(很多仿真器叫xprop)之后,你会发现在前仿里能正常跑通的激励,到了后仿里某些节点的波形全变成红线——这正是"仿真波形是红线"这种说法的来源,红线通常意味着该信号处于未知态。X态可能来自未初始化的寄存器、复位逻辑覆盖不全、多驱动冲突,也可能来自时序违例之后亚稳态的建模。它出现的时刻、首次出现的信号、传播路径,都是定位问题的关键。
第二类是不收敛。后仿里如果带了模拟或者混合信号部分,瞬态仿真会因为寄生电容电阻让时间常数变得很小,时间步被迫压缩,很容易报出"timestep too small"之类的收敛失败。这类问题的现场就是收敛日志——哪一个时间点、哪一个节点、迭代了多少次没收敛。
第三类是时序违例。SDF反标之后,建立时间和保持时间的违例会在仿真里被标出来,这些违例报告通常只打印一次,滚过去就没了。
这三类现场的共同点是:它们都产生在仿真过程中,而不是结束后;一旦run被中断、日志被覆盖、波形没保存,它们就永久消失了。记录的意义,就是让这些瞬间在现场之外还能被找回。
2. 一张够用的后仿状态记录表:字段该怎么定
2.1 最小可用字段集
我见过太多团队用一张Excel表格手工记后仿状态,前几天还能坚持,项目一忙就断更,最后表格里的信息比日志还不可信。要做状态记录,第一件事是把字段定下来,而且字段要足够少——少到你能坚持每一次run都记,又足够全——全到你三个月后翻出来能看懂。
下面是我常用的一套最小字段集,配合表格说明每个字段的作用:
| 字段 | 含义 | 为什么必须有 |
|---|---|---|
| run_id | 本次run的唯一编号 | 后续所有日志、波形、checkpoint都靠它关联 |
| 起止时间戳 | 墙钟开始/结束时刻 | 判断是否超时、估算下次耗时 |
| 设计版本 | 代码仓库的commit或tag | 结果变了能追到是哪次改动 |
| 网表+寄生版本 | 网表文件和SPEF/DSPF的指纹 | 后仿最关键的输入版本 |
| SDF版本 | 时序反标文件指纹 | 时序违例相关问题的根源 |
| corner | 工艺角、电压、温度 | 后仿多corner并行时区分结果 |
| 仿真器及版本 | 工具名加具体版本号 | 换版本后行为变化能定位 |
| 编译宏/选项 | 关键编译开关 | xprop等开关直接影响X态表现 |
| 目标仿真时长 | 计划跑多长仿真时间 | 对照实际进度 |
| 实际结束状态 | 正常/超时/崩溃/中断 | 最核心的状态字段 |
| 错误关键字命中 | grep到的ERROR/FATAL | 不翻日志就知道有没有事 |
| 产物路径 | 日志、波形、checkpoint路径 | 找得到东西 |
2.2 每一次run都必须带版本指纹
后仿最让人抓狂的一类情况,是"我明明改了,但结果没变化",或者反过来"结果变了,但我不知道是哪一环变了"。原因通常就是输入版本混乱:网表更新了但寄生文件还是旧的,SDF换了但没记录,仿真脚本改过一版没人知道。
所以每一个输入文件都要能算出一个指纹,用哈希或者版本号都行。记录里存指纹,不存"最新版"这种模糊描述。举个实际场景:某次后仿X态突然消失了,团队以为是自己的复位修改起了作用,结果翻状态记录发现,同一时间寄生文件被重新提取过一版。如果当时记录里没有寄生文件指纹,这个方向可能要被误导两三天。
版本指纹还有个附加好处,就是能防止"隐性重跑"。后仿常有多corner并行,如果状态记录里的配置指纹完全一致,那就说明这两次run其实是重复劳动,可以砍掉一个省机时。这在机时紧张的阶段是很实在的收益。
2.3 用结构化文件落地,别用Excel手记
如果要让记录真正有用,落地格式很关键。我推荐结构化文本,比如JSON Lines——每条run一行JSON,追加写,方便程序化处理,也方便用脚本做diff和聚合。示意结构如下:
{"run_id":"post_0412_a","start":"2025-04-12T09:13:22","end":"2025-04-12T21:40:05","design_commit":"a1b2c3d","spef_hash":"9f3e…","sdf_hash":"77aa…","corner":"ss_0p72v_125c","sim":"vcs_2024.09","xprop":"on","target_us":500,"status":"timeout","err_hits":["TIMESTEP_TOO_SMALL"],"log":"runs/post_0412_a/sim.log","wave":"runs/post_0412_a/wave.fsdb","ckpt":"runs/post_0412_a/ckpt_312us"}用JSON的好处是,你之后想看"所有超时的run""所有命中收敛错误又用了某一版SDF的run",一句脚本就筛出来了。而手填的Excel,一旦字段写错、格式不统一,检索就是灾难。结构化记录配上少量脚本,这才是状态记录该有的样子。
3. 让状态自己写下来:把记录嵌进仿真的wrapper里
3.1 用wrapper脚本包住仿真命令
手工记状态,坚持不了两周。真正能跑下去的方案,是让记录在仿真启动和结束的时候自动发生。做法就是写一个wrapper脚本,把仿真器的启动命令包在里面,脚本负责记录开始时间、计算输入指纹、启动仿真、捕获退出码、记录结束时间,最后把这一条追加进状态文件。
一个简化过的bash wrapper大致长这样:
#!/usr/bin/env bash set -uo pipefail RUN_ID="post_$(date +%m%d_%H%M%S)" RUN_DIR="runs/${RUN_ID}" mkdir -p "${RUN_DIR}" # 记录开始状态 START=$(date -Iseconds) SPEF_HASH=$(sha1sum "$SPEF" | cut -d' ' -f1) SDF_HASH=$(sha1sum "$SDF" | cut -d' ' -f1) # 启动仿真,日志落盘 "$SIM_BIN" -f run.f -l "${RUN_DIR}/sim.log" RC=$? END=$(date -Iseconds) STATUS=$([ $RC -eq 0 ] && echo "ok" || echo "fail_rc${RC}") HITS=$(grep -Eo 'TIMESTEP_TOO_SMALL|FATAL|ERROR' "${RUN_DIR}/sim.log" | sort -u | paste -sd, -) printf '{"run_id":"%s","start":"%s","end":"%s","status":"%s","rc":%d,"spef_hash":"%s","sdf_hash":"%s","err_hits":"%s"}\n' \ "$RUN_ID" "$START" "$END" "$STATUS" "$RC" "$SPEF_HASH" "$SDF_HASH" "$HITS" >> runs/status.jsonl关键点有几个。一是set -uo pipefail不加-e,因为仿真器返回非零是正常情况,不能让脚本在中途退出,否则就记不到结束状态。二是退出码一定要单独保存到变量再判断,不能直接用$?做多次操作,否则会被后续命令覆盖。三是err_hits用grep抓关键字,这是把"人翻日志"变成"程序翻日志"的核心一步。
3.2 日志分级和关键字抓取
自动抓关键字这件事,前提是你得先定义清楚哪些关键字算"问题"。不同仿真器、不同仿真类型的报错格式差别很大,我一般会维护一张关键字表,按严重程度分类:
| 级别 | 典型关键字 | 处理方式 |
|---|---|---|
| 致命 | FATAL、ABORT、core dumped | 立即标记失败,查现场 |
| 错误 | ERROR、TIMESTEP_TOO_SMALL、NOT_CONVERGED | 标记失败,记录命中的节点/时刻 |
| 警告 | WARNING、WARN | 累计计数,超阈值才关注 |
| 可疑 | x、unknown、multi-driver | 后仿重点关注,前仿可忽略 |
注意"警告"这一类不能一律当没事。后仿里很多WARNING在数量少的时候无害,但一旦同一个WARNING在日志里出现几百上千次,往往意味着某个模块在反复进入异常状态,这时候它就等同于错误信号了。所以我建议状态记录里除了命中关键字,还要带上每个关键字的出现次数,用"关键字:次数"的形式。这个次数信息在你事后排序"哪些run最可疑"时非常有用。
3.3 checkpoint和续跑的状态对齐
后仿时间太长的时候,分段跑是常规操作。仿真器通常支持把某个时刻的完整状态存成checkpoint,之后从这个checkpoint恢复继续跑。这里的状态记录要特别小心对齐问题:checkpoint对应的仿真时刻必须记下来,续跑的时候要确认是从这个时刻接着跑,而不是从头再来或者跳过了某段。
我遇到过一次尴尬的情况:一个run中断在312微秒,我存了checkpoint,续跑时新流程误用了更早的checkpoint,结果仿真重复计算了十几微秒,波形里同一段激励出现了两次,排查了半天才发现是续跑起点错了。如果状态记录里清清楚楚写着"每个checkpoint对应的仿真时刻",这种错误根本不会发生。所以字段里checkpoint路径要带时刻信息,续跑脚本要读这个字段,而不是靠人记。
4. 后仿跑出X态:状态记录怎么帮你把时间倒回去
4.1 后端启动X和真实X的区别
后仿里的X态,大致分两种来源。一种叫"启动X",是仿真刚开始时,很多寄存器还没被复位,天然处于未知态,随着复位信号到位,这些X会自己消失。另一种是"真实X",是仿真跑到中途某个时刻,由于功能逻辑问题或者时序违例突然产生的X,而且它会顺着组合逻辑一路传播下去。
区分这两种X,直接决定了你要不要花时间排查。启动X基本可以忽略,真实X必须查。但在长周期的后仿里,这两者混在一起,波形上就是一片红,肉眼很难判断。这时候状态记录的价值就体现出来了:如果你在record里记录了X首次出现的仿真时刻和信号名,那么"越早出现的X越可能是启动X、越晚出现的越可能是真实X"这条经验规律就有了数据支撑。你可以先按首次出现时刻排个序,把明显跑到很久之后才冒出来的X优先排查。
4.2 用状态记录还原"第一次发散"的时刻
"仿真发散"这个说法,本质上描述的就是某个变量在迭代过程中越算越大、无法收敛,或者状态传播失控。要定位发散点,最有效的做法是找到第一次异常的那个时间点,然后往前看一小段。但后仿日志动辄几百MB,波形文件几个GB,你怎么快速定位第一次异常?
我的办法是在状态记录里额外维护一个"事件时间线"。仿真过程中用脚本周期性地扫描日志,把X首次出现的时刻、收敛失败的首次时刻、第一个时序违例的时刻都抽出来,写进这条run的记录。等run结束,你手上就有了一条浓缩的时间线,直接跳到那个时刻看波形就行,不用把整个GB级波形从头翻到尾。
顺便提一个反直觉的现象:有时候开了X传播分析(xprop)之后,后仿反而冒出了前仿没有的X。这通常不是工具的问题,而是X传播分析把前仿里被"乐观处理"掉的未知态,如实地传播了出来。前仿在某些模式下遇到X会当作0或1继续算,显得一切正常;开启X传播分析,工具会严格地让X往下传,于是某个本就存在的隐患就暴露了。所以记录里"是否开启xprop"这个字段必须留着,不然你会误以为是后仿引入了新的bug。
4.3 记录里必须留的复现指纹
X态这种东西,找到一个之后你一定会想做的一件事是:用最小激励复现它。而能不能复现在很大程度上取决于你有没有留住"复现指纹"。除了前面说的网表、寄生、SDF版本,还有几个容易被忽略的:随机种子(如果激励里有随机成分)、初始条件设置、复位时序的延迟参数、以及仿真器的求解精度设置。
这几项任意一个变了,X的出现时刻和路径都可能改变,甚至消失。所以状态记录不是只记"跑了什么",还要记"用什么条件跑的"。我一般的做法是,把这一批复现相关参数单独归到一个repro字段里,run结束就固化,不允许覆盖。日后要用的时候,一条命令就能还原出当时的运行环境。
5. 收敛失败与超长仿真:状态记录的抗风险设计
5.1 收敛日志该怎么读
瞬态仿真不收敛,日志里通常会有几类信号:一是"timestep too small",说明时间步已经被压缩到下限还是算不下去;二是迭代次数超限,说明某个时间点的方程组在反复求解;三是具体的节点名,指出是哪里的电压电流异常。
这些信息如果不记录,重跑一次又是几小时。所以我的状态记录里,针对带模拟部分的仿真,会专门留一个字段存"收敛最慢的若干个时间点和对应节点"。实现方式很简单:用脚本在日志里grep收敛相关的行,把时间戳和迭代次数抽出来,按迭代次数降序取前几名。这样一个字段,就相当于把整段收敛日志压缩成了一句话,出问题的时候直接看这句话就够了。
还有个实用技巧:如果同一个run里,收敛困难的时刻集中在某几个时间窗,那很可能对应的是电路里某个模块切换状态的瞬间,比如时钟边沿或者电源切换。记录里把这些时间窗标出来,你排查的目标就从"整个仿真"缩小到了"几个瞬间",效率差别很大。
5.2 大后仿的分段与状态快照
对于要跑一整夜甚至几天的大后仿,我强烈建议做分段快照。把目标仿真时间切成若干段,比如每50微秒一段,每段结束就存一次checkpoint,同时把这一段的进度、耗时、关键字命中情况写进状态记录。这么做的直接好处是:万一在第七段崩了,你只需要从第六段的快照续跑,前面六段的机时不会浪费。
分段还有个隐性好处,就是让"进度可视化"。长仿真最折磨人的就是不知道进度,分段之后每一段都是一个可观测的里程碑,你可以看着状态记录里一个一个段被标记完成,心里有数。而且分段之后,单段的耗时数据也出来了,你可以据此判断后面的段大概需要多久,遇到某一段突然变慢,也能及时发现异常。
这里要提醒一点:分段边界最好选在电路相对"平静"的时刻,比如某个完整事务结束之后,避开正在翻转的时钟沿附近。在状态切换的瞬间存快照,恢复的时候容易引入额外的不确定性。这属于工程惯例上的细节,但真会影响续跑结果的一致性。
6. 我在后仿状态记录上踩过的几个坑
第一个坑是"只记成功不记失败"。一开始我的状态记录只在run正常结束时写一条,结果那些崩溃、超时的run全都没记录,导致我根本不知道某个corner到底试过几次、每次错在哪。后来改成无论成功失败都写记录,失败run的价值立刻显现——很多错误其实是重复的,看一眼历史记录就知道该往哪个方向查。
第二个坑是"日志被覆盖"。早期我没规范日志路径,同一个run目录反复复用,第二次运行直接把第一次的日志覆盖了,出问题时想追溯前一次的状态,什么都没了。后来强制每次run用独立目录,且目录名带run_id和时间戳,才算根治。
第三个坑是"关键字抓取太粗"。最开始我只grep"ERROR",结果漏掉了大量以其他措辞出现的错误,比如工具自定义的缩写。后来把关键字表同步维护起来,随着项目积累不断补充,命中率才上来。这件事没有一劳永逸的做法,关键字表是需要养的。
第四个坑是"状态记录和波形对不上"。有一次我发现状态记录显示某个时刻有X,但打开波形那个时刻看起来是正常的。查了半天,原因是状态记录的时间戳用的是墙钟时间,而波形用的是仿真时间,两者混了。从那以后我把墙钟时间和仿真时间严格分两个字段,再也没搞混过。这个坑不大,但踩过一次就足够记一辈子。
把这些做扎实之后,后仿这个过程就不再是"提交上去等结果"的黑盒。你能随时知道它在哪、干过什么、为什么停,这才是把机时真正用在了刀刃上。