Post

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.

Experience Sharing on Compile-Time Optimizations in VCS

引言

在数字芯片设计与验证领域,仿真器的性能一直是影响开发效率的关键因素。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为例:

  1. 常量传播会产生新的常量表达式
  2. 表达式化简可能产生0值或1值
  3. 死代码消除可能删除某些变量声明
  4. 这些变化反过来又为新的常量传播创造条件

因此,我们将这四个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;

算法的核心思想是最长匹配

  1. 收集所有对相同信号进行位操作的assign语句
  2. 分析下标表达式的连续性
  3. 尝试寻找最长的连续区间
  4. 如果区间长度等于数组声明长度,直接替换为整体赋值
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

副作用的判断需要递归检查:

  1. 条件表达式本身是否有副作用(函数调用、数组写入等)
  2. then/else分支是否修改了条件相关变量
  3. 上层作用域是否包含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. 必须是完美循环:循环变量从常量开始,每次加1,不能有break/return
  2. 运算必须可向量化:按位与、或、加减等
  3. 数据类型必须是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接口的边界情况

  1. GetUniqueDrivers并不总是返回”唯一”驱动
    • 某些预处理后的代码可能无法正确识别
    • 函数返回值FuncRetVariable可能不计入driver
    • 建议:使用时增加防御性检查
  2. 预处理改变了代码结构
    • 声明时初始化会被移到独立的Block中
    • 多维PackedArray会被展开为一维
    • 建议:优化前先充分了解预处理行为
  3. 迭代器失效问题
    • 删除AST节点时需要特别注意迭代器状态
    • 建议:先收集要删除的节点,再统一删除

语义保持的验证策略

  1. 保守优先:不确定的情况下不做优化
  2. 单驱动限制:几乎所有优化都要求单驱动源
  3. 副作用分析:条件语句优化前必须分析副作用
  4. 回归测试:在多个case上验证优化前后的功能等价性

性能优化的经验

优化Pass预期效果实际效果
always转assign减少调度开销✅ 显著
assign合并减少语句数量✅ 显著
常量传播降低表达式复杂度✅ 显著
死代码消除减少信号声明✅ 稳定
条件合并减少判断次数⚠️ 有限
循环向量化减少循环开销⚠️ 视情况

测试结果分析

我们在公开的测试case上验证了优化效果:

Case优化前(ms)优化后(ms)提升
case116.130.9894%
case213.970.8294%
case315.300.6596%
case421.011.8491%
Cores_VeeR_EL295.6693.832%
openc910_xuantie99.2394.555%

从结果可以看出:

  1. 简单的公开case提升非常显著(90%+)
  2. 复杂的真实设计提升有限,因为:
    • 已经有手工优化
    • 代码结构复杂,难以识别优化机会
    • 仿真热点可能不在我们优化的范围内

总结与展望

这次比赛让我们对SystemVerilog仿真器的编译时优化有了更深入的理解。几个关键的心得:

  1. 语义保持是底线:任何优化都不能改变程序的行为
  2. 保守策略往往是对的:不完美的优化好过错误的优化
  3. 不动点迭代很重要:单次遍历无法达到最优
  4. 理解语言语义是基础:Verilog的并发和时序特性决定了优化的边界

未来可以探索的方向:

  • 更激进的优化策略(如考虑多驱动源的强度分析)
  • SSA形式的IR转换(便于更精确的数据流分析)
  • 自动向量化检测的增强
  • 与仿真器调度器的协同优化

This post is licensed under CC BY 4.0 by the author.