为什么当函数调用开销添加到循环中时,这段代码会加速?

问题描述 投票:0回答:1

我有以下 Swift 代码,它接受迭代计数和另一个参数,并在循环中执行一些计算。

@inline(never)
func rawComputeAll(iterCount: Int, timeStep: Double) -> Double {
    // most things up here are constants, scroll down to
    let rho = 995.59
    let c = 0.04017672438703612,
        Rt = 0.0042879141883543715
    let s = 4184.0
    let T_in = 300.0,
        S_in = 1000.0
    var h_st = 1.0e5,
        f_st = 0.5,
        m = c * rho,
        sv = s
    var T_out = 300.0,
        h_out = 1.0e5
    for _ in 0..<iterCount {
        T_out = T_in - Rt * S_in
        let T_st = 273.15 + h_st / sv
        let deltaT = T_out - T_st
        let sign = deltaT != 0.0 ? deltaT / abs(deltaT) : deltaT
        let deltaS = sign * S_in * timeStep
        let deltaU = deltaS * 1.0
        let m_in = f_st * timeStep
        let E_in = m_in * h_out
        let m_total = m + m_in
        let E_state = m * h_out + E_in + deltaU
        h_st = E_state / m_total
    }
    return h_st
}

我用两种方式对其进行基准测试。

  1. 像这样直接用一些大的
    iterCount
    对函数进行计时。
let startTime1 = DispatchTime.now()
let result1 = rawComputeAll(iterCount: iterCount, timeStep: timeStep)
let endTime1 = DispatchTime.now()

(我实际上使用了一个正确的基准测试框架来报告运行时的分布,但为了简洁和易于重现而在此处使用这个简单的计时代码)。

  1. 将循环移到外部并将迭代计数作为 1 传递给函数。我还对
    timeStep
    进行了一些计算,以便 swift 编译器不会优化循环。
let scaling = 1.0e-6
var result2 = Double.random(in: 0.1...10.0)
let startTime2 = DispatchTime.now()
for _ in 1...iterCount {
    result2 = rawComputeAll(iterCount: 1, timeStep: timeStep + scaling*result2)
}
let endTime2 = DispatchTime.now()

现在,我预计第二种方法会更慢,因为循环中函数调用的开销(注意用于确保这一点的

@inline(never)
),而且还因为它在循环体中执行了一些额外的 FLOP。

但是,事实证明,这种在外部时钟上进行循环的方法比直接的基准测试要快得多——在我的机器上,它们之间存在 30% 的差异。这里有一个 Godbolt 链接 展示了这个问题。

那么,我有两个问题。

  1. 为什么第二个基准测试明显更快,尽管原则上它应该做更多的工作?
  2. 到目前为止我只有猜测和分析并没有透露任何有趣的东西。我如何诊断这种性能病理并发现根本原因?

我对发布版本进行了所有测量(分析和基准测试)。

swift performance profiling benchmarking disassembly
1个回答
0
投票

for _ in 0..<iterCount
只是为了重复相同的工作来进行基准测试吗?所以编译器至少在理论上可以将其优化为
if iterCount > 0 { }

对于高计数,运行时间是否与迭代计数几乎呈线性关系?如果不是,编译器可能正在优化循环。如果这只是同类吞吐量基准,我预计优化编译器至少会在第二个版本中内联该函数,但您的 Godbolt 链接确认这没有发生。

将循环从

.LBB1_4:
dec     rdi / jne   .LBB1_4
output.rawComputeAll
复制/粘贴到 https://uica.uops.info/ 以分析瓶颈,显然存在 65 个周期的循环承载依赖瓶颈天湖。

根据依赖性分析,它是通过 XMM11 进行的,它一开始初始化为 100000.00 又名 1e5(来自

movsd   xmm11, qword ptr [rip + .LCPI1_0]
,将鼠标悬停在
.quad   0x40f86a0000000000
位模式上会显示该值)。所以是
h_st
。好的,是的,我们可以在您的代码中看到变量是根据
h_st
计算的,然后在循环结束时将内容分配回
h_st

所以你的代码有一个循环携带的依赖链

这就是编译器无法优化循环的原因。

  • 传递高迭代计数可测量循环中计算的延迟
  • 使用 iterCount=1 重复调用可测量
  • 吞吐量,其中无序执行可以跨迭代重叠工作。
在乱序执行 CPU 上,吞吐量和延迟是不同的。见

对于您的循环,如果我们通过编辑 asm 循环以在

xorps xmm11, xmm11

 之前添加 
div xmm11, xmm4
 来打破依赖关系,那么 uiCA 预测它将在 Skylake 上每次迭代运行大约 11 个周期,而不是 66.5 个周期,瓶颈在分频器上吞吐量。但是,由于函数调用和每次重新加载常量的所有开销,这可能会在重新排序缓冲区 (ROB) 中占用太多空间,让无序执行器无法查看足够远的内容以找到跨迭代的指令级并行性。 (
https://blog.stuffedcow.net/2013/05/measuring-rob-capacity/)。或者也许我们正陷入前端瓶颈。 (现代 CPU 上每个时钟加载 2 次时,当它进入缓存时,重新加载常量没什么大不了的。)

由于与 AWS 实例位于相同 CPU 上的其他代码,Godbolt 本身的基准测试也会非常嘈杂。如果 CPU 频率尚未预热到最大睿频,则会遭受未知量的失真。在您自己的桌面上运行时,如果不运行任何预热循环,CPU 预热可能是一个更大的因素,除非您通过 shell 脚本循环或其他方式连续多次运行基准测试。 (不是手动,任何间隙都不是连续负载。)

性能评估的惯用方式?

© www.soinside.com 2019 - 2024. All rights reserved.