1. 13小时到底浪费在哪——先看清编译瓶颈的分布
先说结论:我那个工程从13小时压到5小时,不是靠换电脑,不是靠加内存,更不是靠运气。是靠把编译这件事拆开看,搞清楚时间花在了哪个环节,然后针对性地改工作流。
这个工程是什么样的呢?Zynq UltraScale+ MPSoC,差不多用了20万左右的LUT,里面有PCIe DMA通路、图像采集与预处理、DDR4读写、好几条跨时钟域的数据管道,外加MIPI和HDMI接口。这种规模在FPGA项目里不算最大,但也绝对不小了。早期我用的是Vivado默认流程,每次改完代码点一下Implementation,然后就去干别的。结果就是:早上提交编译,晚上下班还没跑完,第二天早上过来看结果再改——一天只能迭代一次。
13小时这个数字不是我编的,是某次比较大的改动后完整跑一遍的实测时间。而且这还只是Implementation(布局布线)的时间,不算综合。
13小时意味着什么?意味着你改一个跨时钟域的同步逻辑,想验证一下亚稳态处理对不对,得等一整个白天;意味着你发现时序违例是因为某条路径的约束写错了,改完还得再等一个轮回;意味着整个团队只要有一个大改动的版本在编译,其他人想插队跑个小验证,基本没戏。这种节奏下,项目怎么可能按期推进。
先说为什么默认流程会这么慢。Vivado的Implementation分成几个阶段:opt_design(优化)、place_design(布局)、phys_opt_design(物理优化)、route_design(布线)。对于一个大工程,布线永远是最大的时间黑洞,因为布线器要在整个芯片范围内寻找可行的走线方案,遇到拥塞或时序紧张的区域,它还要反复绕线、重试、换路径。物理优化也相当耗时,它是在布局之后对时序违例路径做修复,有时候会进行多轮次迭代。
那是不是换一台更强的机器就能解决?有一定帮助,但收效有限。Vivado是多线程工具,但它对CPU的利用率并不是线性增长的,到一定线程数之后收益就明显递减。而且大工程在布线阶段的内存带宽压力很大,你换一个更高主频的CPU,不如调整编译策略来得实在。
真正有用的思路是四个字:减少重算。我后面优化到5小时,本质上是让工具在第二次、第三次编译时不要把所有东西从头再来一遍。下面我会把每一步具体怎么改、为什么这么改、改完效果怎么样,全部展开来讲。
2. 从13到5的第一次跃迁:把综合模式改对
很多人一提到编译优化就直奔增量编译,但实际上,综合阶段的设置如果不对,增量布局布线的收益会大打折扣。我先从综合阶段讲起。
2.1 flatten_hierarchy:为什么它是时间杀手
Vivado综合器默认的hierarchy mode是full,也就是全局扁平化综合。这是什么意思呢?就是说工具会把整个设计的层次结构全部打散,把不同模块里的逻辑重新混合优化,跨模块边界做资源共享、寄存器合并、逻辑复制等。这种模式的优化效果通常最好,面积和性能都能得到较优的结果。
但是代价就是:任何一丁点代码改动,哪怕只是改了某个子模块里的一个条件判断,整个设计的综合结果都可能面目全非。因为层次已经被打散了,一个模块的改动可能影响到其他模块的综合策略,导致所有模块都要重新综合。这在编译时间上就是灾难。
我把综合设置里的flatten_hierarchy从full改成了rebuilt,这个操作很关键。rebuilt模式的意思是保留层次结构,综合时每个模块单独优化,然后在顶层把它们组装起来。这个模式下的综合结果跟full模式相比,面积可能会有少量增加(一般在5%以内),但带来的好处是:模块之间的综合结果相对独立,改了一个模块,其他模块的综合网表可以保持稳定。
这个改动对后续的增量编译有决定性的影响。如果层次被扁平化,那个增量的意义就大打折扣,因为布局器每一次看到的网表结构都可能是新的;如果层次保留下来,布局器就能相对容易地找到上一轮编译中对应模块的位置和走线方式。
2.2 增量综合的正确配置方法
Vivado的增量综合,本质上是利用上一次综合生成的网表和缓存信息,指导本次综合在相似逻辑上复用之前的综合结果。但它不是简单地把旧结果拿来用,而是有一个引导(guide)的机制。
在GUI上的操作路径是:Project Settings → Synthesis → More Options,勾选incremental选项,同时指定上一次的综合运行(synthesis run)作为参考。如果用Tcl命令,是这样的:
set_property STEPS.SYNTH_DESIGN.ARGS.INCREMENTAL true [get_runs synth_1]然后启动综合。增量综合跑完后,会在工程目录下生成一个.dcp文件,这个文件包含了综合后的网表和相关的逻辑映射信息。这个文件也是后续增量布局布线的基础。
很多人只关注布局布线的增量,忽略了综合的增量。但实际上,如果综合阶段每次都把网表从头生成一遍,网表的逻辑连接关系、模块划分、寄存器位置映射都变了,布局增量根本没法有效地参考上一轮的结果。所以综合增量一定要开。
2.3 综合阶段的时序预估不要乱开
还有一个容易被忽视的选项:综合时的timing summary估算。Vivado在综合阶段会做一个粗略的时序分析,将估算结果反馈给用户。如果你在GUI里勾选了综合后自动打开timing summary,工具会在综合结束后跑一次完整的时序评估。
这个功能本身不慢,但它会引导很多人形成坏习惯——看到综合后的时序报告有违例,就开始调整代码,然后再综合再评估。说实话,综合阶段的时序估算跟真实布局布线后的时序差距很大,因为这个时候还没有真实的布局、真实的走线延迟,完全是靠模型估算的。我见过很多人花大量时间想消除综合阶段的时序违例,实际上这大部分是徒劳的。正确的做法是:只要综合阶段的违例不是大范围、数量级的违例,就直接进入布局布线,以最终时序报告为准。
还有个更耗时的选项是综合时的retiming。retiming会把寄存器在组合逻辑中间挪来挪去,用来优化时序。这个功能在某些场景下很好用,但它的副作用是:即使很小的代码改动,在retiming作用下都会导致大量寄存器位置变动,这几乎会把增量综合的复用率清零。如果工程项目对编译时间敏感,我建议在综合设置里把这个功能关掉。
3. 增量编译的正确打开方式:原理与适用边界
增量编译是这次优化的核心手段,也是网上讨论最多、误解最多的一块。我很负责任地说一句:增量编译不是一个开关,它是一套方法论。用对了,时间真的能砍半;用不对,它可能给你一个编译通过但行为异常的结果,比编译失败更危险。
3.1 布局布线的guide机制是怎么工作的
增量布局布线的原理,我打个比方:你有一张桌子,上一轮编译已经把东西都摆好了。这轮编译你只是把某个抽屉里的东西换了一下,那你当然不想把所有东西都重新摆一遍。guide机制就是让工具记住上一轮每个逻辑单元在芯片上的位置、走线的路径,然后在这轮编译时尽量把相关的逻辑放在相近的位置。
Vivado里启用增量布局布线的操作是:在Implementation Settings里勾选incremental,然后在旁边指定参考的checkpoint,也就是上一轮布局布线完成后生成的.dcp文件。用Tcl的话是这样:
set_property STEPS.PLACE_DESIGN.ARGS.INCREMENTAL [get_runs impl_1] # 或者是通过place_design -incremental / route_design -incremental直接指定实际操作中,我习惯直接在Tcl Console里跑流程,因为这样可以看到每一步的日志输出,遇到问题也方便回溯。流程大概是:
open_checkpoint ./checkpoints/post_route_20241023.dcp place_design -incremental phys_opt_design route_design -incremental write_checkpoint -force ./checkpoints/post_route_new.dcp这里有个关键点:增量布局布线能不能生效,取决于两个checkpoint之间的差异程度。上一轮编译和这一轮编译,如果只是改了某个模块的内部逻辑,布局器会尝试把新旧逻辑映射到接近的位置,然后在这个区域做局部调整。这就是增量编译省时间的核心。
3.2 什么改动会毁掉增量优势
这个必须说清楚,否则会踩大坑。增量编译并不是在所有改动下都能加速。
先说清楚哪些是安全的改动:修改模块内部的功能逻辑、增加或删除少量寄存器、调整组合逻辑的层级、优化状态机的编码方式,这些属于“局部改动”,增量布局布线的效果很好。
哪些是危险的改动:顶层模块的端口列表变了、IP核的配置变了(比如DDR4控制器的参数调整)、器件型号换了、综合策略变了(比如从rebuilt改回full)、SDC约束的时钟结构大改了。这些改动会让新旧checkpoint之间的逻辑结构差异巨大,增量guide机制不但不能加速,反而可能因为需要不断匹配新旧逻辑关系而变慢。
更严重的情况是:如果你的时序约束里对某些路径的名字做了修改,或者把某个时钟域的名字改了,增量编译时工具找不到对应的逻辑节点,就会把这块逻辑当作新逻辑重新布局布线。这个区域如果恰好是时序收敛的瓶颈区域,那你这次编译基本等于全量重跑,甚至可能因为guide过程中的额外开销而比全量更慢。
所以我的建议是:建立工程规范。每次运行增量编译前,用diff工具检查一下自上次编译以来修改了哪些文件、每个文件改动的大小。如果发现SDC文件变了,或者IP核配置变了,就直接跑全量编译,不要指望增量。
3.3 保守一点:先验证再增量
还有一个特别重要的习惯:在大改动之后,在部分改动但工程量很大的情况下,先做一次全量编译,生成一个新的baseline checkpoint,然后再在这个baseline上做小改动的增量编译。这样做的好处是,你有一个干净的参考点。
我经历过的教训是:有一次我在一个旧的checkpoint上连续做了三轮增量编译,每次改动都不大,编译也确实很快。但到第四轮的时候,需要改一个跨时钟域逻辑,结果编译完成后出现了一个奇怪的时序违例,这条路径在之前几轮编译里从来没违例过。排查了很久,最后发现是连续增量编译导致工具在某些路径上积累了太多guide的历史信息,布局结果已经偏离了最优状态。
从那以后,我的规则就变成了:每五次增量编译之后,或者每两轮大改动之后,主动做一次全量编译来校准baseline。这个习惯看着保守,实际上是省时间的。
4. 模块级复用与OOC综合:把大盘子拆小
增量编译解决的是“在大盘子基础上做小改动”的加速问题。但如果你的项目经常有大范围的改动,比如要新增一个功能模块,或者重构某个子模块的数据通路,那增量编译的收益就会打折扣。这个时候,模块级的综合复用和布局约束就派上用场了。
4.1 OOC综合的缓存复用机制
Vivado里有个概念叫Out-of-Context(OOC)综合,直译就是“脱离上下文”的综合。默认情况下,IP核都是OOC综合的,也就是说每个IP核单独综合,生成独立的网表和时序约束,然后在顶层组装时直接引用。这样做的好处是:改了一个IP核的配置,只需要重新综合这个IP核,其他模块不受影响。
我们自己的模块也可以设置成OOC模式。Vivado里的做法是为每个要OOC的模块创建单独的synthesis run,并设置这个run的top为对应的模块名。用Tcl可以这样:
create_run ooc_mydesign -flow {Vivado Synthesis 2020} -strategy "Vivado Synthesis Defaults" set_property top my_design [get_runs ooc_mydesign] set_property used_in_synthesis true [get_files my_design.dcp]放在综合完成后,会在工程目录下生成一个针对该模块的.dcp文件。顶层综合时,工具会直接把这个.dcp引入,而不会重新综合这个模块。
这个机制的好处很直接:如果一个模块很大,但是独立性强(典型的就是DDR控制器、PCIE硬核的接口逻辑、图像处理流水线),把它设为OOC之后,即使顶层有大量改动,这个模块的综合结果也能直接复用。综合时间就能省掉一大块。
4.2 什么模块适合OOC,什么不适合
并不是所有模块都应该OOC。我总结了一些经验和判断标准。
适合OOC的模块特征:
- 有独立的时钟域,比如DDR控制器、PCIE逻辑
- 接口信号比较多、内部逻辑复杂,单独综合耗时很长
- 功能相对稳定,不经常改动
- 时序约束相对独立,有自己的input/output delay约束
不适合OOC的模块特征:
- 与顶层其他模块有频繁的跨模块数据交互,综合时需要全局视角做优化
- 经常和顶层其他模块一起调整时序
- 逻辑规模较小,单独综合收益不高
OOC的核心问题在于:它牺牲了跨模块的全局优化能力。一个模块如果在综合时看不到外部环境的约束,它的优化方向可能和整体设计不一致。比如一个数据通路模块,它内部的逻辑深度优化得很好,但外层和它衔接的模块时序吃紧,需要这个模块让出部分性能来做适配,OOC模式下就很难做到这一点。
我一般只对IP核和比较成熟的、不太改动的子模块设置OOC,对于正在快速迭代的核心逻辑,还是保持正常的层次综合。
4.3 Pblock逻辑锁定:用空间约束换编译时间
还有一个和增量编译配合起来很好用的手段是Pblock(逻辑锁定)。
Pblock允许你把某个子模块的逻辑锁定在芯片的特定区域内。这样做的好处有两点:第一,布局器不需要在整个芯片范围内为这个模块搜索位置,搜索空间缩小,布局速度提升;第二,因为模块被锁定在一个区域,这个模块的走线相对集中,对周边模块的干扰减小,跨编译的布局结果更容易保持一致,这对增量编译非常友好。
设置Pblock的命令大概是:
create_pblock pblock_dma add_cells_to_pblock [get_pblocks pblock_dma] [get_cells dma_inst] resize_pblock [get_pblocks pblock_dma] -add {SLICE_X40Y100 SLICE_X75Y180}Pblock的布局范围需要根据模块的大小和资源占用情况来设定。可以先用一次全量编译后的物理资源报告(utilization report)看这个模块实际用掉的资源分布范围,然后在此基础上留出15%到20%的余量,再设置Pblock范围。范围设得太大,锁定的意义就不大;设得太小,布局器在区域内塞不下,反而会产生大量穿越Pblock边界的走线,时序就崩了。
5. 约束质量是编译时间的隐形杠杆
这块内容很少被放在编译优化的文章里,但我实际做下来发现,约束质量对编译时间的影响巨大,甚至超过了某些工具设置。为什么?因为时序约束本质上是给布局布线器设定了一组“必须达到的目标”,目标不合理,工具就会在布线阶段反复尝试、反复迭代,时间全耗在里面了。
5.1 无效时序路径如何拖慢布线
早期我接手一个项目,工程规模不大,但布线时间异常长。后来检查发现,设计里有个模块用了异步复位,复位信号是在外部引脚引入的。因为我在SDC里没有对这个引脚做set_input_delay约束,工具不知道这个外部信号到达芯片内部的时间窗口,于是它假设这个信号可能在任意时刻变化,然后对所有使用该复位信号的时序路径都要求进行时序分析。
结果就是:几乎每一条路径都被当作需要严格收敛的时序路径来处理,布线器压力巨大。后来我在SDC里加上了对应的输入延迟约束,并把复位相关的路径设置成false path,布线速度立刻就有明显提升。
这是一个经典的错误,但也是一个很好的例子:约束不是越少越好,也不是越多越好,而是要准确。对于那些真正不需要时序收敛的路径,你要明确告诉工具。工具知道得越准确,就越不会做无用功。
5.2 时钟分组与异步路径的显式声明
FPGA设计里,最典型的耗时来源是跨时钟域逻辑。如果你的设计里有多个异步时钟域,而且这些时钟域之间确实有数据交互(同步器处理过的),你需要在约束里用set_clock_groups声明它们是异步的,这样工具就不会费劲去分析这些跨时钟域路径的建立保持时间。
命令示例:
set_clock_groups -asynchronous -group {clk_pix} -group {clk_sys} -group {clk_ddr}如果设计中存在多主时钟的case(比如同一个PS端的参考时钟经过MMCM产生了主时钟和辅助时钟),时钟结构理清楚也是关键一环。我曾经见过一个工程,因为时钟树定义混乱,工具不得不在布线阶段对大量无效路径做时序分析,整整多花了一个多小时。
还有一个高频考点:引脚上的复位信号。如果你用了按键复位,外部复位信号没有做异步复位同步释放,那它对内部所有时序路径都是一个异步输入。正确做法是把这些复位相关的路径显式声明为false path:
set_false_path -to [get_pins [list rst_sync_reg/C]]为什么要这么做?因为工具默认是悲观的,它会尽可能分析所有与时序有关联的路径。只有你主动告诉它哪些路径不用分析,它才会省下这个力气。
5.3 你可能没意识到的一个坑:物理约束的冗余循环
在布局布线过程中,物理优化(phys_opt_design)阶段会尝试通过调整寄存器的位置来修复时序违例。这个过程在时序紧张的设计里可能会反复执行多轮。
如果时序报告里的违例路径集中在某个区域内,phys_opt一般只会在这个区域做调整,速度上还可以。但如果违例路径分散在整个芯片上,phys_opt会像打地鼠一样四处调整,耗费的时间会大幅上涨。这种情况往往不是工具的问题,而是某些关键路径的约束设计不合理。比如你给某条总线设置的input_delay过大,导致所有经过这条总线的路径都紧张,phys_opt就需要在多个地方同时做手术。
这种情况我建议回到约束源头去排查,而不是指望物理优化来解决根本问题。很多时候,少吃两轮phys_opt的苦,就是编译加速的最好贡献。
6. 完整实测:一份从13小时缩到5小时的修改清单
说了这么多原理,总得来点实际的。下面是我在那个工程上实际做的改动、顺序和最终效果。列出来供参考,但一定要结合你自己的工程情况调整。
6.1 我实际改了什么:按优先级排列
第一步:修改综合设置。
这一步我把综合模式从hierarchical full改成了hierarchical rebuilt。注意,没有改得太激进,因为优化效果还是要保证的。同时关闭了综合后的自动时序报告生成,减少综合阶段的多余计算。
第二步:启用增量综合。
设置当前的综合run为增量模式,并指定参考运行。这一步需要在综合运行前完成,并且需要在综合运行配置里指定Reference Run。这个值可以在重新综合时动态调整。
第三步:检查SDC的完整性。
这个我花了一个下午仔细做,但效果立竿见影。把所有跨时钟域的路径用set_clock_groups声明为异步,把复位相关的IO路径设成false path,检查所有input_delay/ouput_delay约束是否与外部时序要求一致,删掉了一些过时的、不再生效的约束行。经过整理之后,我在综合阶段和布局阶段的时序分析日志里能明显看到,约束冲突的警告数量大幅下降。
第四步:建立新的baseline checkpoint。
把上述修改做完之后,我跑了一次完整的全量编译。这次的编译时间已经从13小时降到了8小时左右,主要省下来的时间在physical optimization阶段,因为时序约束清晰了,phys_opt没有那么多要修复的路径了。
第五步:开始使用增量编译。
在这个8小时的baseline基础上,后续的小改动跑增量编译,大部分在2到3小时内完成。如果仅仅改了一个模块的内部逻辑,且改动量不大,最快的记录是1小时40分钟。
第六步:对稳定的大模块设置Pblock,并限制它们的布局范围。
这一步是在有新版本需要全量编译的时候才引入的,短期的编译时间又会进一步压缩。最终某次需要全量编译的版本,跑到了5小时10分左右。
6.2 编译时间对比与资源、时序代价
我整理了一下这个优化过程的实测数据,但注意,这些数据依赖具体的工程规模、器件型号、约束情况,可能和你遇到的场景不完全一致,仅供参考。
| 阶段 | 综合耗时 | 布局耗时 | 布线耗时 | 总耗时(不含综合) | 备注 |
|---|---|---|---|---|---|
| 原始默认流程 | 约45分钟 | 约2.5小时 | 约8小时 | 约13小时 | 布线和phys_opt耗时最长 |
| 综合模式+约束优化后全量编译 | 约40分钟 | 约2小时 | 约5小时 | 约7.5小时 | 主要节省在phys_opt和布线 |
| 优化后的增量编译(小改动) | 约15分钟 | 约40分钟 | 约1小时 | 约1.5-2小时 | 模块改动小的情况下效果明显 |
| 优化后的大改动全量编译(含Pblock锁定) | 约45分钟 | 约1.5小时 | 约3.5小时 | 约5小时 | 依赖模块划分是否清晰 |
资源方面:flatten_hierarchy从full改成rebuilt后,LUT使用量增加了约2%,FF使用量基本持平。这个代价在资源不紧张的工程里完全可以接受。时序方面:同样的约束下,全量编译的时序结果比之前的默认流程略微变好(可能是因为约束整理后工具建立了更准确的目标);增量编译的时序结果与全量编译基本一致,部分的布局区域有些微变化,但都没有导致最终的时序违例。
6.3 后续还能继续压缩的增量空间
这套组合拳打完,5小时基本成了我这边全量编译的常态。但如果项目规模再扩大,需要更进一步,我还有一些可操作的手段。
一个是物理优化策略的定制。Vivado默认的phys_opt策略偏保守,会做很多轮迭代。如果你的设计时序余量大,可以试试把phys_opt的优化目标改成快速收敛模式,或者直接精简掉部分冗余的优化环节。这可以通过自定义Implementation策略来实现。
另一个是深入使用Partial Reconfiguration(部分重配置)的思路。虽然不一定用到运行时重配置功能,但PR的设计方法论本身就有价值,它天然要求把设计划分成几个相对独立的模块。如果把这种划分方式反向应用到普通编译流程里,配合OOC和Pblock,编译效率还能再往上涨。
还有一个很容易忽略的点:Vivado版本。我后来从2019.2升级到2021.1之后发现,同款工程在布局布线阶段的算法有明显改进,编译时间又快了一截。所以如果条件允许,定期升级工具链也是一个潜在的加速手段。
这些手段我没有全部在当前工程里用上,因为5小时的编译时间对这个项目的迭代节奏已经够用了。但如果你的项目规模更大,比如需要用到UltraScale+里最大的器件,或者系统的逻辑规模到了30万LUT以上,这些高级手段大概率会派上用场。
最后再说一个实操中的小细节:增量编译不是万能的,但它是一定要开的。哪怕是全量编译,也建议定期保留checkpoint,这对快速定位问题和回归验证都有好处。我在当前工程里已经养成了习惯:每次编译跑完,把分析后的checkpoint和综合后的dcp都归档保存。这样后续调试问题时,不需要重新编译,直接打开checkpoint就能查看内部信号,节省的时间远超维护这几个文件带来的负担。