Experience Sharing on Compile-Time Optimizations in VCS
An experience report on implementing compile-time optimizations in galaxsim, documenting insights and practical understanding gained about both Verilog itself and the internal mechanisms of Verilog Compile Simulator.
引言
在数字芯片设计与验证领域,仿真器的性能一直是影响开发效率的关键因素。2025年中国研究生创”芯”大赛·EDA精英挑战赛为我们提供了一个绝佳的机会,让我们能够在真实的SystemVerilog仿真器GalaxSim上实现编译时优化。本文将分享我们在实现这套优化框架过程中的经验与思考。
赛题的核心挑战在于:在保证SystemVerilog语义完全等价的前提下,如何通过编译时优化来加速仿真器的运行?这不仅需要对Verilog语言本身有深刻的理解,更需要对仿真器的内部调度机制有清晰的认识。
整体架构设计
PassManager与Visitor模式
我们的项目通过一个PassManager类统一管理各个优化Pass,构建了一套可扩展、可迭代、明确分层的优化框架。这种设计借鉴了现代编译器(如LLVM)的经典架构,但又针对SystemVerilog的特定语义做了适配。
核心架构采用”访问者模式(Visitor) + Pass流水线”的组合:
1
2
3
4
5
6
7
8
9
10
11
PassManager
├── ReplaceAlsWithCa (always转assign)
├── AssignCombination (assign合并)
├── [循环不动点] ──────────────┐
│ ├── ConstantPropagation │
│ ├── CopyPropagation │
│ ├── ExpressionSimplify │
│ └── DeadSigErase │
├── ConditionSimplify (控制流化简)
├── ConditionMerge (条件合并)
└── VectorLoopSimplify (循环向量化)
选择Visitor模式的核心原因在于:它能够优雅地遍历和改写抽象语法树(AST),同时保持代码结构的清晰性。每一个Pass独立封装为一个访问者,通过design->Accept(visitor)对SystemVerilog的AST进行遍历与改写。
不动点迭代策略
在编译器优化中,有一个经典的问题:某些优化Pass之间存在相互依赖关系,单次遍历往往无法达到最优效果。我们的解决方案是”循环不动点迭代”。
以ConstantPropagation(常量传播)、CopyPropagation(复制传播)、ExpressionSimplify(表达式化简)和DeadSigErase(死代码消除)这四个Pass为例:
- 常量传播会产生新的常量表达式
- 表达式化简可能产生0值或1值
- 死代码消除可能删除某些变量声明
- 这些变化反过来又为新的常量传播创造条件
因此,我们将这四个Pass组合到一个循环中,持续执行直到收敛。这确保了优化能够反复、充分地进行,最终达到一个稳定的最优点。
核心优化Pass详解
always转assign:最直观的优化
这个优化看似简单,却是整个优化流水线的重要基础。
1
2
3
4
5
6
7
// 优化前
always @(*) begin
out = in1 & in2;
end
// 优化后
assign out = in1 & in2;
为什么这个优化有效?因为always @(*)块描述的是组合逻辑,它的语义本质上和连续赋值assign是等价的。但从仿真器的调度角度来看,always块需要额外的调度开销——仿真器需要追踪敏感信号列表、维护进程状态。而assign语句则简单直接,仿真器可以直接建立驱动关系。
需要注意的是,这个优化有严格的限制条件:
- 只能处理单条阻塞赋值语句
- 不能有if/case等复杂控制流
- 不能有时序逻辑元素(如
#延迟、@事件控制)
这些限制确保了转化的语义等价性。
assign合并:批量优化的艺术
这是针对连续赋值语句的高级优化,能够显著减少赋值语句的数量:
1
2
3
4
5
6
7
8
// 优化前
assign a[1] = b[1];
assign a[0] = b[0];
// 优化后
assign a[1:0] = b[1:0];
// 甚至可以进一步简化为
assign a = b;
算法的核心思想是最长匹配:
- 收集所有对相同信号进行位操作的assign语句
- 分析下标表达式的连续性
- 尝试寻找最长的连续区间
- 如果区间长度等于数组声明长度,直接替换为整体赋值
1
2
3
4
5
6
7
8
9
// 核心算法伪代码
for (auto& assignPair : collectedAssigns) {
auto indices = analyzeContinuousIndices(assignPair);
if (isFullRange(indices)) {
replaceWithWholeArray(assignPair);
} else {
replaceWithRange(assignPair, indices);
}
}
这个优化不仅减少了赋值语句的数量,更重要的是,它使得后续的常量传播和死代码消除能够发挥更大的威力——因为数组整体赋值更容易被识别为单驱动源。
常量传播:最核心的优化
常量传播是编译器优化中最经典的技术之一,但在SystemVerilog环境下实现起来却相当复杂。
位宽与类型处理
Verilog的表达式位宽计算规则非常特殊,涉及到”自确定表达式”(self-determined expression)和”上下文确定表达式”(context-determined expression)的区分:
1
2
3
4
5
// 自确定:表达式的位宽由自身决定
a + b // 位宽 = max(a位宽, b位宽)
// 上下文确定:表达式的位宽由目标决定
{1'b1, a} // 如果赋值给4位信号,则整体为4位
我们的实现需要严格遵守这些规则:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 传播与折叠过程
void ConstantPropagation::visit(BinaryExpr* expr) {
// 1. 先递归处理子节点
expr->left = propagate(expr->left);
expr->right = propagate(expr->right);
// 2. 更新位宽标志(自确定运算)
if (isSelfDetermined(expr->op)) {
expr->width = max(childWidth(expr->left),
childWidth(expr->right));
}
// 3. 折叠常量
if (isConstant(expr->left) && isConstant(expr->right)) {
expr->value = fold(expr->left, expr->op, expr->right);
expr->type = getContextWidth(expr);
}
}
驱动源分析
常量传播的一个关键限制是单驱动要求:
1
2
3
4
5
6
7
8
// 单驱动:可以传播
wire [7:0] a = 8'h42;
assign b = a + 1; // b可以被优化为 8'h43
// 多驱动:不能传播(可能有wire-or等多种语义)
wire [7:0] a;
assign a = cond1 ? b : 8'h0; // 驱动1
assign a = cond2 ? c : 8'h0; // 驱动2
GalaxSim提供了GetUniqueDrivers接口来帮助我们判断驱动数,但实际使用中发现它并不完美——某些边界情况(如函数返回值)可能不会被正确统计,需要在代码中做特殊处理。
死代码消除:优化的收尾
死代码消除是优化的最后一道工序,它负责清理优化过程中产生的冗余:
1
2
3
4
5
6
// 经过常量传播后
wire [7:0] a = 8'h42;
wire [7:0] b = a + 1; // b = 8'h43
// 死代码消除后
wire [7:0] b = 8'h43; // a被删除,因为没有load使用它
死信号的判定规则:
- 无加载(load)引用
- 无双向(bidirect)端口连接
1
2
3
4
5
6
7
for (auto* sig : allSignals) {
if (sig->getLoads().empty() && sig->getBidirects().empty()) {
// 安全删除该信号的驱动和声明
eraseDrivers(sig);
eraseDeclaration(sig);
}
}
SystemVerilog语义的特殊挑战
复制传播的限制
复制传播在传统编译器优化中是一个很简单的Pass:
1
2
x = y;
z = x + 1; // 可以优化为 z = y + 1;
但在SystemVerilog中,情况变得复杂:
| 赋值类型 | 可否复制传播 | 原因 |
|---|---|---|
| assign连续赋值 | ✅ 可以 | 连线语义,右值变化左值立即响应 |
| 阻塞赋值 | ❌ 不考虑 | 时序复杂,AST分析困难 |
| 非阻塞赋值 | ❌ 绝对不行 | “同时更新”语义,传播会破坏时序 |
1
2
3
4
5
// 非阻塞赋值的特殊性
always @(posedge clk) begin
a <= b; // 这个a不会立即更新
c <= a + 1; // c使用的是a的旧值,不是b
end
如果我们对非阻塞赋值做复制传播:
1
2
3
4
5
// 错误优化后
always @(posedge clk) begin
a <= b;
c <= b + 1; // 改变了时序行为!
end
这会导致完全错误的仿真结果。
条件合并的副作用分析
条件合并优化需要判断if-else语句是否可以安全合并。副作用分析是其中最复杂的部分:
1
2
3
4
5
6
7
8
// 可以合并
if (cond) begin x = 1; end
if (cond) begin x = 2; end
// 合并为: if (cond) begin x = 1; x = 2; end
// 不能合并(副作用:修改了cond相关的变量)
if (cond) begin cond = ~cond; end
if (cond) begin x = 1; end
副作用的判断需要递归检查:
- 条件表达式本身是否有副作用(函数调用、数组写入等)
- then/else分支是否修改了条件相关变量
- 上层作用域是否包含signal声明(涉及GalaxSim预处理)
高级优化:循环向量化
完美循环的识别
循环向量化是针对向量操作的特殊优化,能够将逐元素操作转换为向量整体操作:
1
2
3
4
5
6
7
// 优化前(逐元素操作)
for (i = 0; i < 8; i = i + 1) begin
result[i] = a[i] & b[i];
end
// 优化后(向量整体操作)
result[7:0] = a[7:0] & b[7:0];
但这个优化有严格的适用条件:
- 必须是完美循环:循环变量从常量开始,每次加1,不能有break/return
- 运算必须可向量化:按位与、或、加减等
- 数据类型必须是PackedArray:GalaxSim会将多维数组预处理为一维
边界情况处理
有一个容易踩的坑:如果for循环的begin-end所在scope里有变量声明,不能认定为完美循环。
1
2
3
4
5
6
// 不是完美循环(预处理会产生额外代码)
for (i = 0; i < 8; i = i + 1) begin
reg [7:0] temp; // 变量声明!
temp = a[i];
result[i] = temp & b[i];
end
这是因为GalaxSim会将声明时初始化放到一个新的Block中,改变代码结构。
开发经验与踩坑总结
GalaxSim接口的边界情况
- GetUniqueDrivers并不总是返回”唯一”驱动
- 某些预处理后的代码可能无法正确识别
- 函数返回值FuncRetVariable可能不计入driver
- 建议:使用时增加防御性检查
- 预处理改变了代码结构
- 声明时初始化会被移到独立的Block中
- 多维PackedArray会被展开为一维
- 建议:优化前先充分了解预处理行为
- 迭代器失效问题
- 删除AST节点时需要特别注意迭代器状态
- 建议:先收集要删除的节点,再统一删除
语义保持的验证策略
- 保守优先:不确定的情况下不做优化
- 单驱动限制:几乎所有优化都要求单驱动源
- 副作用分析:条件语句优化前必须分析副作用
- 回归测试:在多个case上验证优化前后的功能等价性
性能优化的经验
| 优化Pass | 预期效果 | 实际效果 |
|---|---|---|
| always转assign | 减少调度开销 | ✅ 显著 |
| assign合并 | 减少语句数量 | ✅ 显著 |
| 常量传播 | 降低表达式复杂度 | ✅ 显著 |
| 死代码消除 | 减少信号声明 | ✅ 稳定 |
| 条件合并 | 减少判断次数 | ⚠️ 有限 |
| 循环向量化 | 减少循环开销 | ⚠️ 视情况 |
测试结果分析
我们在公开的测试case上验证了优化效果:
| Case | 优化前(ms) | 优化后(ms) | 提升 |
|---|---|---|---|
| case1 | 16.13 | 0.98 | 94% |
| case2 | 13.97 | 0.82 | 94% |
| case3 | 15.30 | 0.65 | 96% |
| case4 | 21.01 | 1.84 | 91% |
| Cores_VeeR_EL2 | 95.66 | 93.83 | 2% |
| openc910_xuantie | 99.23 | 94.55 | 5% |
从结果可以看出:
- 简单的公开case提升非常显著(90%+)
- 复杂的真实设计提升有限,因为:
- 已经有手工优化
- 代码结构复杂,难以识别优化机会
- 仿真热点可能不在我们优化的范围内
总结与展望
这次比赛让我们对SystemVerilog仿真器的编译时优化有了更深入的理解。几个关键的心得:
- 语义保持是底线:任何优化都不能改变程序的行为
- 保守策略往往是对的:不完美的优化好过错误的优化
- 不动点迭代很重要:单次遍历无法达到最优
- 理解语言语义是基础:Verilog的并发和时序特性决定了优化的边界
未来可以探索的方向:
- 更激进的优化策略(如考虑多驱动源的强度分析)
- SSA形式的IR转换(便于更精确的数据流分析)
- 自动向量化检测的增强
- 与仿真器调度器的协同优化