用Zig语言模拟n体问题
N体问题是指找出已知初始位置、速度和质量的多个物体在经典力学情况下的后续运动。 这个问题虽然在数学上还没有满意的解答(N≥3时),却比较容易用数值计算进行模拟。简单来说就是把时间离散化,在每一时刻,根据物体的受力和速度来确定速度和位置的变化量。
这个问题常用来做基准测试,因为它涉及大量浮点数运算和大量的循环步骤(模拟长时间的运动)。
想起这个测试是因为我正在看Zig语言的文档,发现它原生支持向量运算。例如,我可以定义含有3个分量的向量:
const Vec3 = @Vector(3, f64);
并对它们进行四则运算。
数乘、点积和模运算可以简单地表示为:
fn scalarP(s: f64, v: Vec3) Vec3 {
return @as(Vec3, @splat(s)) * v;
}
fn dotP(u: Vec3, v: Vec3) f64 {
return @reduce(.Add, u * v);
}
fn norm(v: Vec3) f64 {
return @sqrt(dotP(v, v));
}
这里的*
直接在3个数上同时进行运算。在支持的平台上,编译器直接生成一条SIMD指令。这就比做3次运算快了。
接下来定义物体,它的动量、动能和两物体的势能都可以用向量运算简洁地表示:
const Body = struct {
name: []const u8,
pos: Vec3,
veloc: Vec3,
mass: f64,
fn momemtum(self: Body) Vec3 {
return scalarP(self.mass, self.veloc);
}
fn kinetic(self: Body) f64 {
return self.mass * dotP(self.veloc, self.veloc) * 0.5;
}
fn potential(b1: Body, b2: Body) f64 {
return -(b1.mass * b2.mass / norm(b1.pos - b2.pos));
}
};
整个系统的能量是动能和势能之和:
fn calcSystemEnergy(system: []const Body) f64 {
var e: f64 = 0;
for (system, 0..) |b1, i| {
for (system[0..i]) |b2| {
e += Body.potential(b1, b2);
}
e += b1.kinetic();
}
return e;
}
模拟的关键在于算出一个时间单位(dt)内,各物体的运动变化:
fn advanceSystem(system: []Body, dt: f64) void {
for (system, 0..) |*b1, i| {
for (system[0..i]) |*b2| {
const d = b1.pos - b2.pos;
const dd = dotP(d, d);
const mag = dt / (dd * @sqrt(dd));
b1.veloc -= scalarP(b2.mass * mag, d);
b2.veloc += scalarP(b1.mass * mag, d);
}
}
for (system) |*b| {
b.pos += scalarP(dt, b.veloc);
}
}
关于这里的计算:两物体 \(m_1\), \(m_2\) 之间的位矢为 \(\mathbf r_{12}\) (由1指向2),则1受到2的万有引力为 \[\mathbf{F}_{12} = G\frac{m_1 m_2}{r^2}\hat{\mathbf{r}}_{12} = G\frac{m_1 m_2}{r^3}\mathbf{r}_{12},\] 根据牛顿第二定律,加速度 \[\mathbf a_{12} = \frac{Gm_2}{r^3}\mathbf r\] 选取合适的单位制可使\(G=1\),从而省去一次乘法计算。
这个网站有一组模拟数据(太阳和若干行星)。用这组数据按照dt=0.01,跑五千万次,比较前后的系统能量:
-0.169075164
-0.169059907
发现能量守恒并不成立😄;其实近似是成立的,毕竟用离散时间进行模拟会带来误差。
在我的笔记本电脑上(Intel Core Ultra 7),跑五千万次用时1.585秒;而标准C语言的实现1用时1.712秒。可见Zig的向量运算是相当高效的。
clang 20.1.3,
-O3 -march=native -flto
,编译器也会生成优化的SIMD指令。 ↩︎