让 Julia 达到 C++ 的速度(2019)
摘要
这是 BYU FLOW Lab 于 2019 年发布的一篇博客文章,以真实的空气动力学应用(涡粒子法)作为基准测试,探讨如何优化 Julia 代码以匹配 C++ 的性能。作者分享了在 Julia 中实现高性能计算的经验,涵盖类型声明、JIT 编译以及代码优化技巧。
暂无内容
查看缓存全文
缓存时间: 2026/05/09 12:35
# 让 Julia 达到 C++ 的速度 · FLOW Lab 来源:https://flow.byu.edu/posts/julia-c++ 2019年4月18日,Eduardo Alvarez 视频链接
## 介绍
传言称 Julia 能够达到与 C++ 和 FORTRAN 等编译语言相同的[计算性能](https://julialang.org/benchmarks/)。在过去两年使用 Julia 编程的过程中,我深深爱上了它类似 Python 的语法、多重分发机制,以及类似 MATLAB 的线性代数便利性,同时还能使用显式类型声明等编译语言特性来预防 bug。总的来说,Julia 的设计理念兼具解释型语言的灵活性,而其即时(JIT)编译机制使其实际上成为一门编译型语言。
Julia 的高级语法让来自任何背景的程序员都能轻松上手,然而实现高性能在某种程度上是一门艺术。在这篇文章中,我总结了在为高性能计算优化 Julia 代码过程中学到的一些经验。我将通过一个来自空气动力学领域的实际计算应用——[涡粒子法](https://scholarsarchive.byu.edu/facpub/2116/)\(^{[1,\,2]}\)——来展示代码优化的过程。
## 问题定义
在[涡粒子法](https://scholarsarchive.byu.edu/facpub/2116/)中,我们关注的是计算由 \(N\) 个涡粒子组成的粒子场在任意位置 \(\mathbf{x}\) 处所诱导的速度 \(\mathbf{u}\) 和速度雅可比矩阵 \(\mathbf{J}\)。计算公式如下:
\[{\bf u}\left( {\bf x} \right) = \sum\limits_p^N g_\sigma\left( {\bf x}-{\bf x}_p \right) {\bf K}\left( {\bf x}-{\bf x}_p \right) \times \boldsymbol\Gamma_p\]
\[\frac{\partial {\bf u}}{\partial x_j}\left( {\bf x} \right) = \sum\limits_p^N \left[ \left( \frac{1}{\sigma } \frac{\Delta x_j}{\Vert \Delta \mathbf{x} \Vert} \frac{\partial g}{\partial r} \left( \frac{\Vert \Delta\mathbf{x} \Vert}{\sigma} \right) - 3 g_\sigma\left( \Delta{\bf x} \right) \frac{\Delta x_j}{\Vert \Delta\mathbf{x} \Vert^2} \right) {\bf K}\left( \Delta\mathbf{x} \right) \times \boldsymbol\Gamma_p - \frac{g_\sigma\left( \Delta{\bf x} \right) }{4\pi \Vert \Delta{\bf x} \Vert^3} \delta_{ij} \times \boldsymbol\Gamma_p \right] ,\]
其中 \({\bf K}\) 为奇异牛顿核 \({\bf K}\left( {\bf x}\right)=-\frac{ {\bf x} }{4\pi \Vert{\bf x}\Vert^3}\),\(g_\sigma\) 为平滑半径为 \(\sigma\) 的正则化函数,\(\mathbf{x}_p\) 和 \(\boldsymbol{\Gamma}_p\) 分别为第 \(p\) 个粒子的位置和矢量强度。
此外,该方法的控制方程需要计算粒子集合对自身所诱导的速度 \(\mathbf{u}\) 和雅可比矩阵 \(\mathbf{J}\),从而引出众所周知的计算复杂度为 \(\mathcal{O}(N^2)\) 的 [N 体问题](https://en.wikipedia.org/wiki/N-body_problem)。
选用 Winckelmans 正则化核\(^{[1]}\):
\[g(r) = r^3 \frac{r^2 + 5/2}{\left( r^2 + 1 \right)^{5/2}}\]
\[\frac{\partial g}{\partial r} (r) = \frac{15}{2} \frac{r^2}{\left( r^2 + 1 \right)^{7/2}} ,\]
上述方程在 C++ 中的实现如下:
```cpp
// 粒子间相互作用
void P2P(Particle * P, const int numParticles) {
real_t r, ros, aux, g_sgm, dgdr;
vec3 dX;
for (int i=0; i
```
**输入 \[1\]:**
```julia
"""
这是一个 Python 风格的实现,
没有任何为提升性能而做的调整
"""
function P2P_pythonic(particles, g, dgdr)
for Pi in particles
for Pj in particles
dX = Pi.X - Pj.X
r = norm(dX)
if r != 0
# g_σ 和 ∂gσ∂r
gsgm = g(r / Pj.sigma)
dgsgmdr = dgdr(r / Pj.sigma)
# K × Γp
crss = cross(-const4 * (dX/r^3), Pj.Gamma)
# U = ∑g_σ(x-xp) * K(x-xp) × Γp
Pi.U[:] += gsgm * crss
# ∂u∂xj(x) = ∑[ ∂gσ∂xj(x−xp) * K(x−xp)×Γp + gσ(x−xp) * ∂K∂xj(x−xp)×Γp ]
for j in 1:3
Pi.J[:, j] += (
dX[j] / (Pj.sigma*r) * (dgsgmdr*crss)
- gsgm * (3*dX[j]/r^2) * crss
- gsgm * (const4/r^3) * cross([i==j for i in 1:3], Pj.Gamma)
)
end
end
end
end
end
```
**输入 \[2\]:**
```julia
# 生成一个 6x6x6 的粒子网格
n = 6 # 每轴粒子数
lambda = 2.0 # 粒子间距与 sigma 之比
# 创建一个简单的粒子 struct(无具体类型)
mutable struct ParticleAmbiguous
X # 位置
Gamma # 矢量环量
sigma # 平滑半径
U # 粒子处的速度
J # 粒子处的雅可比矩阵 J[i,j]=dUi/dxj
end
particles = generate_particles(ParticleAmbiguous, n, lambda)
```
**输入 \[3\]:**
```julia
args = (particles, g_wnk, dgdr_wnk)
@benchmark P2P_pythonic(args...);
```
```
BenchmarkTools.Trial:
memory estimate: 399.26 MiB
allocs estimate: 4782960
--------------
minimum time: 233.679 ms (15.21% GC)
median time: 253.098 ms (16.10% GC)
mean time: 252.237 ms (16.27% GC)
maximum time: 270.524 ms (16.39% GC)
--------------
samples: 5
evals/sample: 1
```
### 修复 \#1:避免使用抽象类型
注意我们定义的 `ParticleAmbiguous` struct 中,属性没有指定类型。这应该会立即引起警觉,因为 Julia 中的动态类型非常低效。让我们用 `@code_warntype` 来确认一下这个问题:
**输入 \[4\]:**
```julia
Pi = particles[1]
@code_warntype P2P_pythonic(particles, g_wnk, dgdr_wnk)
```
```
Variables:
#self# <optimized out>
particles::Array{ParticleAmbiguous,1}
g::getfield(Main, Symbol("##7#9"))
dgdr::getfield(Main, Symbol("##8#10"))
Pi@_4::Union{}
Pj@_5::Union{}
Pi@_6::ParticleAmbiguous
Pj@_7::ParticleAmbiguous
dX::Any
r::Any
gsgm::Any
dgsgmdr::Any
crss::Any
j::Int64
Body::Nothing
│╻ iterate
2 1 ─ %1 = (Base.arraylen)(particles)::Int64
│ %2 = (Base.sle_int)(1, %1)::Bool
│ %3 = (Base.ifelse)(%2, 1, 0)::Int64
│ %4 = (Base.slt_int)(%3, 1)::Bool
└── goto #13 if not %4
. . .
│╻ getproperty
37 │ %35 = (Base.getfield)(%16, :X)::Any
││ │ %36 = (Base.getfield)(%33, :X)::Any
│ │ %37 = (%35 - %36)::Any
│
38 │ %38 = (Main.norm)(%37)::Any
. . .
```
理解这种降级 AST 语法本身就是一门艺术,但你很快会发现 `@code_warntype` 是优化代码时最好的朋友。当我们向下滚动 AST 时,会看到代码在 `ParticleAmbiguous` 类型的属性中遇到了 `Any` 类型,这应该立刻引起警觉(`Any` 是抽象类型)。实际上,运行 `@code_warntype` 时,输出会自动将 `::Any` 断言以红色高亮显示。
我们可以通过[参数化定义 struct 属性](https://docs.julialang.org/en/v1/manual/performance-tips/index.html#Type-declarations-1)来解决这些抽象类型的问题:
**输入 \[5\]:**
```julia
"""
这是一个显式/参数化定义了属性类型的粒子 struct。
"""
struct Particle{T}
# 用户输入
X::Array{T, 1} # 位置
Gamma::Array{T, 1} # 矢量环量
sigma::T # 平滑半径
# 属性
U::Array{T, 1} # 粒子处的速度
J::Array{T, 2} # 粒子处的雅可比矩阵 J[i,j]=dUi/dxj
end
# 另一个初始化别名
Particle{T}(X, Gamma, sigma) where {T} = Particle(X, Gamma, sigma, zeros(T,3), zeros(T, 3, 3))
# 空初始化
Base.zero(::Type{<:Particle{T}}) where {T} = Particle(zeros(T, 3), zeros(T, 3), zero(T), zeros(T, 3), zeros(T, 3, 3))
```
`P2P_pythonic` 函数无需进一步修改,因为 Julia 的多重分发和 JIT 会自动即时编译出一个专门针对我们新的 `Particle{T}` 类型的函数版本。不过,我们还是定义一个别名以便比较基准:
**输入 \[6\]:**
```julia
P2P_concretetypes(args...) = P2P_pythonic(args...)
```
**输入 \[7\]:**
```julia
# 创建一个 6x6x6 的具体 Float64 粒子网格
particles = generate_particles(Particle{Float64}, n, lambda)
# 运行基准测试
args = (particles, g_wnk, dgdr_wnk)
compare(P2P_concretetypes, P2P_pythonic, args)
@benchmark P2P_concretetypes(args...);
```
```
P2P_concretetypes 比 P2P_pythonic 快 3.06 倍(76.488ms vs 233.679ms)
BenchmarkTools.Trial:
memory estimate: 189.93 MiB
allocs estimate: 2275776
--------------
minimum time: 76.488 ms (13.63% GC)
median time: 77.860 ms (13.79% GC)
mean time: 84.567 ms (17.89% GC)
maximum time: 145.918 ms (42.64% GC)
--------------
samples: 12
evals/sample: 1
```
太棒了!通过在 `Particle` struct 中指定具体类型,我们获得了 3 倍的速度提升(还应该再次运行 `@code_warntype` 以确认所有抽象类型都已消除,但为简洁起见此处略去)。现在来看看与 C++ 的对比:
**输入 \[8\]:**
```julia
printcomparison(P2P_concretetypes, "C++", false)
```
```
C++ 比 P2P_concretetypes 快 19.14 倍(3.996ms vs 76.488ms)
```
使用具体类型大大加快了计算速度;然而,C++ 版本仍然比 Julia 快约 20 倍。让我们看看还能优化什么。
### 修复 \#2:避免列表推导式
列表推导式的便利性可能会诱使你写出行数精简的代码;然而,随意使用可能导致非常低效的计算。以这个列表推导式求和为例:
**输入 \[9\]:**
```julia
sum_list(n) = sum([i for i in 1:n])
@btime sum_list(100);
```
```
80.975 ns (1 allocation: 896 bytes)
```
下面是同一函数不使用列表推导式的展开版本,速度快了 60 倍:
**输入 \[10\]:**
```julia
function sum_unrolled(n)
out = 0
for i in 1:n
out += i
end
return out
end
@btime sum_unrolled(100);
```
```
1.374 ns (0 allocations: 0 bytes)
```
在我们的 P2P 函数中,有一个 Kronecker delta 叉积原本用一行列表推导式计算:
```julia
# ∂u∂xj(x) = ∑[ ∂gσ∂xj(x−xp) * K(x−xp)×Γp + gσ(x−xp) * ∂K∂xj(x−xp)×Γp ]
for j in 1:3
Pi.J[:, j] += (
dX[j] / (Pj.sigma*r) * (dgsgmdr*crss)
- gsgm * (3*dX[j]/r^2) * crss
- gsgm * (const4/r^3) * cross([i==j for i in 1:3], Pj.Gamma)
)
end
```
替代方案是将其展开为几行:
```julia
# ∂u∂xj(x) = ∑[ ∂gσ∂xj(x−xp) * K(x−xp)×Γp + gσ(x−xp) * ∂K∂xj(x−xp)×Γp ]
for j in 1:3
Pi.J[:, j] += (
dX[j] / (Pj.sigma*r) * (dgsgmdr*crss)
- gsgm * (3*dX[j]/r^2) * crss
)
end
# ∂u∂xj(x) = −∑gσ/(4πr^3) δij×Γp
# 添加 Kronecker delta 项
aux = -const4 * gsgm / r^3
# j=1
Pi.J[2, 1] -= aux * Pj.Gamma[3]
Pi.J[3, 1] += aux * Pj.Gamma[2]
# j=2
Pi.J[1, 2] += aux * Pj.Gamma[3]
Pi.J[3, 2] -= aux * Pj.Gamma[1]
# j=3
Pi.J[1, 3] -= aux * Pj.Gamma[2]
Pi.J[2, 3] += aux * Pj.Gamma[1]
```
列表推导式操作的问题在于它需要分配内存来构建生成的数组。抵制使用列表推导式来节省几行代码的诱惑,直接将其展开即可。如下所示,通过展开这一行,我们获得了 1.5 倍的速度提升:
**输入 \[11\]:**
```julia
"""
展开列表推导式
"""
function P2P_nocomprehension(particles, g, dgdr)
for Pi in particles
for Pj in particles
dX = Pi.X - Pj.X
r = norm(dX)
if r != 0
# g_σ 和 ∂gσ∂r
gsgm = g(r / Pj.sigma)
dgsgmdr = dgdr(r / Pj.sigma)
# K × Γp
crss = cross(-const4 * (dX/r^3), Pj.Gamma)
# U = ∑g_σ(x-xp) * K(x-xp) × Γp
Pi.U[:] += gsgm * crss
# ∂u∂xj(x) = ∑[ ∂gσ∂xj(x−xp) * K(x−xp)×Γp + gσ(x−xp) * ∂K∂xj(x−xp)×Γp ]
for j in 1:3
Pi.J[:, j] += (
dX[j] / (Pj.sigma*r) * (dgsgmdr*crss)
- gsgm * (3*dX[j]/r^2) * crss
)
end
# ∂u∂xj(x) += −∑gσ/(4πr^3) δij×Γp
# 添加 Kronecker delta 项
aux = -const4 * gsgm / r^3
# j=1
Pi.J[2, 1] -= aux * Pj.Gamma[3]
Pi.J[3, 1] += aux * Pj.Gamma[2]
# j=2
Pi.J[1, 2] += aux * Pj.Gamma[3]
Pi.J[3, 2] -= aux * Pj.Gamma[1]
# j=3
Pi.J[1, 3] -= aux * Pj.Gamma[2]
Pi.J[2, 3] += aux * Pj.Gamma[1]
end
end
end
end
```
**输入 \[12\]:**
```julia
args = (particles, g_wnk, dgdr_wnk)
compare(P2P_nocomprehension, P2P_concretetypes, args)
@benchmark P2P_nocomprehension(args...);
```
```
P2P_nocomprehension 比 P2P_concretetypes 快 1.52 倍(50.196ms vs 76.488ms)
BenchmarkTools.Trial:
memory estimate: 126.16 MiB
allocs estimate: 1300536
--------------
minimum time: 50.196 ms (11.28% GC)
median time: 51.929 ms (11.65% GC)
mean time: 55.319 ms (15.41% GC)
maximum time: 107.039 ms (50.23% GC)
--------------
samples: 19
evals/sample: 1
```
### 修复 \#3:减少内存分配
接下来,我们注意到基准测试分配了大量内存(126MiB)和分配操作(130万次)。我怀疑这是 Julia 允许动态大小数组导致的问题。解决此问题的第一步是**避免创建任何数组类型的内部变量**。在下面的代码中,注意我将数组变量 `dX` 和 `crss` 替换为浮点变量 `dX1, dX2, dX3` 和 `crss1, crss2, crss3`,这就需要将内层 for 循环完全展开:
**输入 \[13\]:**
```julia
"""
减少内存分配
"""
function P2P_noallocation(particles, g, dgdr)
for Pi in particles
for Pj in particles
dX1 = Pi.X[1] - Pj.X[1]
dX2 = Pi.X[2] - Pj.X[2]
dX3 = Pi.X[3] - Pj.X[3]
r = norm(Pi.X - Pj.X)
if r != 0
# g_σ 和 ∂gσ∂r
gsgm = g(r / Pj.sigma)
dgsgmdr = dgdr(r / Pj.sigma)
# K × Γp
crss1, crss2, crss3 = -const4 / r^3 * cross(Pi.X - Pj.X, Pj.Gamma)
# U = ∑g_σ(x-xp) * K(x-xp) × Γp
Pi.U[1] += gsgm * crss1
Pi.U[2] += gsgm * crss2
Pi.U[3] += gsgm * crss3
# ∂u∂xj(x) = ∑[ ∂gσ∂xj(x−xp) * K(x−xp)×Γp + gσ(x−xp) * ∂K∂xj(x−xp)×Γp ]
# ∂u∂xj(x) += ∑p[(Δxj∂gσ∂r/(σr) − 3Δxjgσ/r^2) K(Δx)×Γp
aux = dgsgmdr/(Pj.sigma*r)* - 3*gsgm /r^2
# j=1
Pi.J[1, 1] += aux * crss1 * dX1
Pi.J[2, 1] += aux * crss2 * dX1
Pi.J[3, 1] += aux * crss3 * dX1
# j=2
Pi.J[1, 2] += aux * crss1 * dX2
Pi.J[2, 2] += aux * crss2 * dX2
Pi.J[3, 2] += aux * crss3 * dX2
# j=3
Pi.J[1, 3] += aux * crss1 * dX3
Pi.J[2, 3] += aux * crss2 * dX3
Pi.J[3, 3] += aux * crss3 * dX3
# 添加 Kronecker delta 项
aux = -const4 * gsgm / r^3
# j=1
Pi.J[2, 1] -= aux * Pj.Gamma[3]
Pi.J[3, 1] += aux * Pj.Gamma[2]
# j=2
Pi.J[1, 2] += aux * Pj.Gamma[3]
Pi.J[3, 2] -= aux * Pj.Gamma[1]
# j=3
Pi.J[1, 3] -= aux * Pj.Gamma[2]
Pi.J[2, 3] += aux * Pj.Gamma[1]
end
end
end
end
```
**输入 \[14\]:**
```julia
args = (particles, g_wnk, dgdr_wnk)
compare(P2P_noallocation, P2P_nocomprehension, args)
@benchmark P2P_noallocation(args...);
```
```
P2P_noallocation 比 P2P_nocomprehension 快 3.68 倍(13.627ms vs 50.196ms)
BenchmarkTools.Trial:
memory estimate: 21.99 MiB
allocs estimate: 325296
--------------
minimum time: 13.627 ms (7.41% GC)
median time: 15.615 ms (8.40% GC)
mean time: 16.582 ms (12.83% GC)
maximum time: 59.011 ms (66.91% GC)
--------------
samples: 61
evals/sample: 1
```
我们将内存分配从 126MiB 降低到了 22MiB,带来了 3.5 倍的速度提升。让我们看看还能做什么来进一步减少内存分配。
### 修复 \#4:避免线性代数函数
接下来需要考虑的是,**使用 Julia 基础库中的线性代数运算(即 `dot(X,X)`、`cross(X,X)`、`norm(X,X)`)比显式地将操作展开为代码行代价更高**。我怀疑这也是一个内存分配问题,因为这些函数需要分配内部数组来存储计算过程中的中间结果,然后再输出。
以下是不使用任何基础库线性代数函数的代码(注意我不再使用 `norm()` 和 `cross()`):
**输入 \[15\]:**
```julia
"""
不使用线性代数函数
"""
function P2P_nolinalg(particles, g, dgdr)
for Pi in particles
for Pj in particles
dX1 = Pi.X[1] - Pj.X[1]
dX2 = Pi.X[2] - Pj.X[2]
dX3 = Pi.X[3] - Pj.X[3]
r = sqrt(dX1*dX1 + dX2*dX2 + dX3*dX3)
if r != 0
# g_σ 和 ∂gσ∂r
gsgm = g(r / Pj.sigma)
dgsgmdr = dgdr(r / Pj.sigma)
# K × Γp
crss1 = -const4 / r^3 * ( dX2*Pj.Gamma[3] - dX3*Pj.Gamma[2] )
crss2 = -const4 / r^3 * ( dX3*Pj.Gamma[1] - dX1*Pj.Gamma[3] )
crss3 = -const4 / r^3 * ( dX1*Pj.Gamma[2] - dX2*Pj.Gamma[1] )
# U = ∑g_σ(x-xp) * K(x-xp) × Γp
Pi.U[1] += gsgm * crss1
Pi.U[2] += gsgm * crss2
Pi.U[3] += gsgm * crss3
# ∂u∂xj(x) = ∑[ ∂gσ∂xj(x−xp) * K(x−xp)×Γp + gσ(x−xp) * ∂K∂xj(x−xp)×Γp ]
# ∂u∂xj(x) += ∑p[(Δxj∂gσ∂r/(σr) − 3Δxjgσ/r^2) K(Δx)×Γp
aux = dgsgmdr/(Pj.sigma*r)* - 3*gsgm /r^2
# j=1
Pi.J[1, 1] += aux * crss1 * dX1
Pi.J[2, 1] += aux * crss2 * dX1
Pi.J[3, 1] += aux * crss3 * dX1
# j=2
Pi.J[1, 2] += aux * crss1 * dX2
Pi.J[2, 2] += aux * crss2 * dX2
Pi.J[3, 2] += aux * crss3 * dX2
# j=3
Pi.J[1, 3] += aux * crss1 * dX3
Pi.J[2, 3] += aux * crss2 * dX3
Pi.J[3, 3] += aux * crss3 * dX3
# 添加 Kronecker delta 项
aux = -const4 * gsgm / r^3
# j=1
Pi.
相似文章
Clojure 速度几乎媲美 C(需借助一些优化)
本文详细介绍了 Clojure 如何借助 JVM 的 Vector API 和精心优化,在 3D 压力测试中达到接近 C 的帧率(仅差 20%),展示了动态语言在热循环中也能接近底层性能。
Python 太慢了。Julia 能解决两语言问题吗?
这篇《连线》文章探讨了 Python 在科学计算中的性能限制,并讨论了 Julia 作为两语言问题的潜在解决方案,同时借鉴了图灵奖演讲中的历史类比。
信任你的编译器:现代C++
本文对比了旧的C++性能技巧与现代编译器的能力,表明编译器现在能够将朴素代码优化得比手工调整的技巧更好。包含在AMD Zen 5上使用Clang 21的基准测试。
一种全GPU工作流:构建高超声速流动物理仿真器
本文介绍了一种全GPU工作流,通过可微分求解器(JAX-Fluids)和基于残差的精化方法加速高超声速流动神经仿真器的数据生成与训练,提高训练分布之外的物理一致性和可靠性。
我测试了Claude的“快速C++”,并没有更快
一位开发者对Claude的C++代码进行了基准测试,发现要求达到最高速度往往会引入内存安全违规,而实际性能却没有提升,从而打破了速度与安全之间权衡的假设。