この記事では Odin言語の主にメモリレイアウト機能を使って、Array of StructsとStruct of Arraysのベンチマークを撮ってみたいと思います。
1. Odin とは
Odin は C に近い低レイヤ寄りの言語です。構文はかなり素直で、明示的なメモリ管理、値型中心の設計、手続き型の書き味があります。
Structure of Arrays
Odinにはデータ構造を直接的に扱う言語機能がありますが、その中で他の言語でもあまり見かけないのが #soaです。コードの見かけ上はAoSだけれど、SoAメモリレイアウトで動くようにコンパイルされる機能です。
通常のAoSは以下のような記述です。
// 通常の slice。Particle が粒子単位で並ぶ AoS。
particles := make([]Particle, count)
メモリ上のイメージは以下のようになります。
Particle0: x y vx vy color ...
Particle1: x y vx vy color ...
Particle2: x y vx vy color ...
一方で #soa を使うと、同じ Particle 型を SoA、Structure of Arrays として扱えます。
// Odin の #soa slice。Particle の各フィールドが列として並ぶ。
particles := make_soa(#soa []Particle, count)
イメージとしては以下のような感じです。
x[] y[] vx[] vy[] color[] ...
2. 結果
今回のAoS/SoAのベンチマーク結果を記載します。
パーティクル
raylibでパーティクルを描画するコードによるベンチマークです。
パーティクルは 10,000,000 個、60 step で測りました。描画なしの headless ベンチです。
SoA版ではさらにSIMD、SIMD+16バイトアラインメント、回転値を Vec3 にまとめた版も試しています。
| kernel | mode | count | steps | seconds | particles_per_second | checksum |
|---|---|---|---|---|---|---|
| 2d | aos | 10000000 | 60 | 0.660808 | 907979838 | 4311497739.6860 |
| 2d | soa | 10000000 | 60 | 1.015693 | 590729461 | 4311497739.6860 |
| x | aos | 10000000 | 60 | 0.603415 | 994340889 | 4311357140.5551 |
| x | soa | 10000000 | 60 | 0.330055 | 1817880441 | 4311357140.5551 |
| rotate | aos | 10000000 | 60 | 0.628294 | 954967448 | 4311209985.2312 |
| rotate | soa | 10000000 | 60 | 0.244129 | 2457715768 | 4311209985.2312 |
| rotate | soa_simd | 10000000 | 60 | 0.243285 | 2466245829 | 4311209985.2312 |
| rotate | soa_simd_align16 | 10000000 | 60 | 0.191001 | 3141343445 | 4311209985.2312 |
| rotate | soa_vec3 | 10000000 | 60 | 0.214069 | 2802835137 | 4311209985.2312 |
結果はかなり分かれました。
- 位置も回転もまとめて更新する
2dでは AoS のほうが速い -
xだけ動かす処理では SoA のほうが速い - 回転だけ更新する
rotateでは SoA がかなり速い -
rotateを#simd化したsoa_simdは、通常の SoA よりさらに少し速い -
#align(16)を付けたsoa_simd_align16はさらに速い -
rx,ry,rzをVec3にまとめたsoa_vec3も通常の SoA より速いが SIMD+Align16より遅い
次に、実際に raylib のウィンドウを出して描画した場合です。描画ありでは 10,000,000 個は重すぎるので、100,000 個、120 frames で測りました。VSync は切って、update、draw、1フレーム全体を分けて見ています。
| mode | count | frames | avg_update_ms | avg_draw_ms | avg_frame_ms | checksum |
|---|---|---|---|---|---|---|
| aos | 100000 | 120 | 0.121949 | 14.000524 | 14.122522 | 43119453.3120 |
| soa | 100000 | 120 | 0.159199 | 13.901809 | 14.061051 | 43119453.3120 |
ここでは draw とフレーム全体はほぼ同じくらいですが、update だけを見ると SoA のほうが遅くなりました。headless の回転だけベンチでは SoA が速かったのに、表示ありにすると素直には効かない、というのが今回見たかったところです。
レイマーチ
こちらは 1280x720、10 passes、128 steps で測りました。
| mode | width | height | passes | max_steps | seconds | rays_per_second | checksum |
|---|---|---|---|---|---|---|---|
| aos | 1280 | 720 | 10 | 128 | 7.349474 | 1253967 | 48373362.5000 |
| soa | 1280 | 720 | 10 | 128 | 6.950503 | 1325947 | 48373362.5000 |
| soa_align16 | 1280 | 720 | 10 | 128 | 6.920069 | 1331779 | 48373362.5000 |
| soa_simd | 1280 | 720 | 10 | 128 | 3.850956 | 2393172 | 48373362.5000 |
| soa_simd_align16 | 1280 | 720 | 10 | 128 | 3.854070 | 2391238 | 48373362.5000 |
こちらは SoA が少し速くなりました。差は約 1.07 倍です。
さらに SDF の距離計算を #simd[4]f32 で4 rayずつ処理する版では、soa の 6.95 秒に対して soa_simd が 3.85 秒になりました。ここはかなり効きました。
一方で、#align(16) 単体は 6.92 秒で、通常の SoA からわずかに改善する程度でした。soa_simd_align16 は 3.85 秒で、soa_simd とほぼ同じです。レイマーチは hit 判定や normal 計算の分岐も多いので、パーティクルの回転だけのベンチマークほど #align が素直に効くわけではなさそうです。
実は SoA だけでもっと派手に速くなるかと思っていました。ただ、シーンを複雑にして SDF の計算量を増やしたので、メモリ配置の差より計算そのものの重さが目立つようになったのだと思います。逆に、計算そのものを SIMD 化できると、そこで大きく差が出ました。
ここまで試した感触としては、結論はこれです。
- SoA は「大量の要素に対して、特定のフィールドだけを流す」処理に強い。
- 逆に「1要素ごとにたくさんのフィールドを集める」処理では AoS が強いことがある。
3. パーティクル
パーティクルは四角形にして、位置、速度、3軸の回転、角速度を持たせました。
実装方法
// AoS / SoA で持ち替える基本の粒子データ。
// []Particle なら粒子単位、#soa []Particle ならフィールド単位に並ぶ。
Particle :: struct {
x: f32,
y: f32,
vx: f32,
vy: f32,
rx: f32,
ry: f32,
rz: f32,
vrx: f32,
vry: f32,
vrz: f32,
half_size: f32,
color: rl.Color,
}
AoS は普通に以下のように記述します。
// AoS。1個の Particle がまとまってメモリ上に並ぶ。
particles := make([]Particle, config.count)
SoA にする場合は以下のように #soa を追加します。
// SoA。同じ Particle 型を、フィールドごとの配列として確保する。
particles := make_soa(#soa []Particle, config.count)
同じ Particle なのに、配列の持ち方だけを変えられるのが #soa の便利なところです。
まず、位置と回転をまとめて更新する処理です。
update_soa_2d :: proc(particles: #soa []Particle, width, height: f32) {
dt: f32 = 1.0 / 60.0
// #soa では particles.x のようにフィールド列を取り出せる。
// この kernel は触る列が多いので、SoA が必ず有利とは限らない。
x := particles.x
y := particles.y
vx := particles.vx
vy := particles.vy
rx := particles.rx
ry := particles.ry
rz := particles.rz
vrx := particles.vrx
vry := particles.vry
vrz := particles.vrz
half_size := particles.half_size
for i in 0..<len(particles) {
// 位置、速度、回転、サイズを同じループで読む。
// 複数の列をまたぐので、メモリアクセスは少し散らばる。
x[i] += vx[i] * dt
y[i] += vy[i] * dt
rx[i] += vrx[i] * dt
ry[i] += vry[i] * dt
rz[i] += vrz[i] * dt
if x[i] < half_size[i] {
x[i] = half_size[i]
vx[i] = -vx[i]
} else if x[i] > width - half_size[i] {
x[i] = width - half_size[i]
vx[i] = -vx[i]
}
if y[i] < half_size[i] {
y[i] = half_size[i]
vy[i] = -vy[i]
} else if y[i] > height - half_size[i] {
y[i] = height - half_size[i]
vy[i] = -vy[i]
}
}
}
これは SoA があまり得意ではありませんでした。触っているフィールドが多いからです。x, y, vx, vy, rx, ry, rz, vrx, vry, vrz, half_size を同じループで読むので、SoA だと複数の配列を同時に走査する形になります。
一方、回転だけ更新する処理はかなり SoA 向きでした。
update_soa_rotate :: proc(particles: #soa []Particle) {
dt: f32 = 1.0 / 60.0
// 回転に必要な列だけを取り出す。
// 位置、サイズ、色を読まないので SoA の得意な形になる。
rx := particles.rx
ry := particles.ry
rz := particles.rz
vrx := particles.vrx
vry := particles.vry
vrz := particles.vrz
for i in 0..<len(particles) {
// rx[]/ry[]/rz[] と角速度列だけを連続して更新する。
rx[i] += vrx[i] * dt
ry[i] += vry[i] * dt
rz[i] += vrz[i] * dt
}
}
この処理では、位置や色やサイズを一切読みません。SoA だと必要な列だけを連続して読めます。ここで差が出ました。
さらに、この rotate は #simd でも試しました。
update_soa_rotate_simd :: proc(particles: #soa []Particle) {
dt: f32 = 1.0 / 60.0
// Odin の #simd 型。f32 を4本まとめて扱う。
f32x4 :: #simd[4]f32
dt4 := f32x4{dt, dt, dt, dt}
// SoA の列は同じ成分が連続しているので SIMD load しやすい。
rx := particles.rx
ry := particles.ry
rz := particles.rz
vrx := particles.vrx
vry := particles.vry
vrz := particles.vrz
i := 0
for ; i + 3 < len(particles); i += 4 {
// 通常の Particle では 16 byte alignment を仮定せず unaligned load/store にする。
rx4 := intrinsics.unaligned_load((^f32x4)(&rx[i]))
ry4 := intrinsics.unaligned_load((^f32x4)(&ry[i]))
rz4 := intrinsics.unaligned_load((^f32x4)(&rz[i]))
vrx4 := intrinsics.unaligned_load((^f32x4)(&vrx[i]))
vry4 := intrinsics.unaligned_load((^f32x4)(&vry[i]))
vrz4 := intrinsics.unaligned_load((^f32x4)(&vrz[i]))
rx4 += vrx4 * dt4
ry4 += vry4 * dt4
rz4 += vrz4 * dt4
intrinsics.unaligned_store((^f32x4)(&rx[i]), rx4)
intrinsics.unaligned_store((^f32x4)(&ry[i]), ry4)
intrinsics.unaligned_store((^f32x4)(&rz[i]), rz4)
}
for ; i < len(particles); i += 1 {
// 4で割り切れない端数は scalar で処理する。
rx[i] += vrx[i] * dt
ry[i] += vry[i] * dt
rz[i] += vrz[i] * dt
}
}
結果はこうです。
| kernel | mode | count | steps | seconds | particles_per_second | checksum |
|---|---|---|---|---|---|---|
| rotate | aos | 10000000 | 60 | 0.628294 | 954967448 | 4311209985.2312 |
| rotate | soa | 10000000 | 60 | 0.244129 | 2457715768 | 4311209985.2312 |
| rotate | soa_simd | 10000000 | 60 | 0.243285 | 2466245829 | 4311209985.2312 |
| rotate | soa_simd_align16 | 10000000 | 60 | 0.191001 | 3141343445 | 4311209985.2312 |
| rotate | soa_vec3 | 10000000 | 60 | 0.214069 | 2802835137 | 4311209985.2312 |
#simd 版は通常の SoA より速くなりました。ただ、思ったほど劇的ではありませんでした。もともとの SoA 版もかなり単純なループなので、コンパイラ側の最適化もある程度効いていそうです。それでも、SoA で列にしたデータは SIMD 化しやすい、という感触は得られました。
そこで #align(16) も試しました。別型として Aligned_Particle を作り、SoA の各列が 16 byte alignment になるようにします。
// #align(16) で要素型の alignment を上げる。
// make_soa したとき、各フィールド列も 16 byte 境界に乗ることを期待する。
Aligned_Particle :: struct #align(16) {
x: f32,
y: f32,
vx: f32,
vy: f32,
rx: f32,
ry: f32,
rz: f32,
vrx: f32,
vry: f32,
vrz: f32,
half_size: f32,
color: rl.Color,
}
Odin の make_soa は要素型の alignment を見て SoA の各フィールド配列を配置するので、struct #align(16) にすると rx[], ry[], rz[] などの列も 16 byte 境界に乗ります。その上で SIMD 版では unaligned load/store ではなく、^f32x4 経由で aligned load/store する形にしました。
// #align(16) 版では、aligned load/store として直接 ^f32x4 で読む。
rx4 := (^f32x4)(&rx[i])^
ry4 := (^f32x4)(&ry[i])^
rz4 := (^f32x4)(&rz[i])^
vrx4 := (^f32x4)(&vrx[i])^
vry4 := (^f32x4)(&vry[i])^
vrz4 := (^f32x4)(&vrz[i])^
rx4 += vrx4 * dt4
ry4 += vry4 * dt4
rz4 += vrz4 * dt4
(^f32x4)(&rx[i])^ = rx4
(^f32x4)(&ry[i])^ = ry4
(^f32x4)(&rz[i])^ = rz4
これはかなり効きました。soa_simd が 0.243 秒だったのに対して、soa_simd_align16 は 0.191 秒でした。今回の中では、#soa、#simd、#align がきれいに噛み合ったケースです。
もうひとつ気になったので、rx, ry, rz と vrx, vry, vrz を Vec3 にまとめた版も試しました。
// 3軸を常にセットで扱う場合の比較用。
Vec3 :: struct {
x: f32,
y: f32,
z: f32,
}
// #soa []Vector_Particle では、rot[] と rot_vel[] という列になる。
// rx[]/ry[]/rz[] に分かれるわけではない。
Vector_Particle :: struct {
x: f32,
y: f32,
vx: f32,
vy: f32,
rot: Vec3,
rot_vel: Vec3,
half_size: f32,
color: rl.Color,
}
更新処理はこうなります。
update_soa_vec3_rotate :: proc(particles: #soa []Vector_Particle) {
dt: f32 = 1.0 / 60.0
// top-level field の rot / rot_vel を列として取り出す。
rot := particles.rot
rot_vel := particles.rot_vel
for i in 0..<len(particles) {
// 3軸を毎回まとめて触るので、Vec3 にした効果を見やすい。
rot[i].x += rot_vel[i].x * dt
rot[i].y += rot_vel[i].y * dt
rot[i].z += rot_vel[i].z * dt
}
}
この形では、#soa は rot と rot_vel をトップレベルのフィールドとして分けます。つまり rx[], ry[], rz[] の3本ではなく、rot[] という Vec3 の配列になります。
結果は soa の 0.244 秒に対して、soa_vec3 は 0.214 秒でした。3軸を毎回まとめて触る処理なので、rot[i].x/y/z が近くにある形が効いたのだと思います。
ただし、最速ではありませんでした。soa_simd_align16 は 0.191 秒です。軸ごとに rx[], ry[], rz[] と分かれているほうが、4要素ずつ #simd[4]f32 で読むには素直です。Vec3 にすると「1粒子の3軸」は近くなりますが、「4粒子ぶんの x 軸だけ」を連続して読む形ではなくなります。
ここは面白くて、SoA といっても「何を列にするか」で結果が変わりました。3軸をセットで扱うなら Vec3 化はあり。ただ、SIMD で同じ成分をまとめて流したいなら、rx, ry, rz を分けたほうが扱いやすい、という感触です。
四角形の描画
表示側では、円ではなく四角形を描いています。rx, ry, rz から3D回転っぽい2本の基底ベクトルを作り、4頂点を計算して DrawTriangle 2回で描いています。
draw_particle_quad :: proc(x, y, rx, ry, rz, half_size: f32, color: rl.Color) {
// 3軸の回転角から、画面上の四角形を張る2本の基底ベクトルを作る。
sx, cx := math.sincos(rx)
sy, cy := math.sincos(ry)
sz, cz := math.sincos(rz)
ux := cz * cy * half_size
uy := sz * cy * half_size
vx := (cz * sy * sx - sz * cx) * half_size
vy := (sz * sy * sx + cz * cx) * half_size
p0 := rl.Vector2{x - ux - vx, y - uy - vy}
p1 := rl.Vector2{x + ux - vx, y + uy - vy}
p2 := rl.Vector2{x + ux + vx, y + uy + vy}
p3 := rl.Vector2{x - ux + vx, y - uy + vy}
// raylib には粒子ごとに三角形2枚として渡す。
// この呼び方は1粒子単位なので、SoA の列アクセスとは相性がよくない。
rl.DrawTriangle(p0, p1, p2, color)
rl.DrawTriangle(p0, p2, p3, color)
}
なぜ表示すると SoA が遅いのか
更新だけを見ると、回転だけのような処理では SoA が速いです。でも画面に表示すると、SoA のほうが動作が遅いという結果が出ました。
理由は、描画がかなり AoS 的な処理だからです。
実際に表示ありで測ると、こうなりました。
| mode | count | frames | avg_update_ms | avg_draw_ms | avg_frame_ms | checksum |
|---|---|---|---|---|---|---|
| aos | 100000 | 120 | 0.121949 | 14.000524 | 14.122522 | 43119453.3120 |
| soa | 100000 | 120 | 0.159199 | 13.901809 | 14.061051 | 43119453.3120 |
この表だけ見るとフレーム全体はほぼ差がありません。描画が 14ms 前後かかっていて、update はその中のごく一部だからです。ただ、avg_update_ms は SoA のほうが悪化しています。
1個のパーティクルを描くには、これらを全部集めます。
x, y, rx, ry, rz, half_size, color
AoS なら、1個の Particle の中にだいたいまとまっています。
Particle0: x y vx vy rx ry rz ... color
Particle1: x y vx vy rx ry rz ... color
SoA だと、1個を描くために複数の配列から値を拾います。
x[i], y[i], rx[i], ry[i], rz[i], half_size[i], color[i]
raylib は内部である程度バッチしてくれますが、Odin 側では粒子ごとにデータを集めて、DrawTriangle を2回呼んでいます。この時点で、SoA の「列をまとめて流す」という良さを使えていません。
さらに、描画ループで広い範囲の配列を読むので、次フレームの update のキャッシュ状態にも影響します。update の測定値が、headless より表示ありで悪くなるのはこのあたりが効いていそうです。
4. レイマーチ
パーティクル描画では、どうしても「1個の粒子を描くために複数フィールドを集める」形になります。これは SoA の得意な形ではありません。
そこで、もっと SoA っぽい処理を探して CPU レイマーチを入れました。
レイマーチでは、ピクセルごとに ray を作り、全 ray に対して同じ処理を何度も繰り返します。
全 ray の現在位置を計算する
全 ray に対して SDF を評価する
全 ray の t を進める
hit した ray を止める
これは「同じ列を大量に流す」形にしやすいです。raylib は最後に texture を表示するだけにして、描画 API 呼び出しの影響をなるべく外しました。
実装方法
Ray は少し大きめの struct にしています。march 中に使う値と、shade 中に使う値をあえて同じ struct に入れています。
// march と shade の両方で使う値をあえて1つの struct に入れる。
// SoA だと march で必要な列だけを取り出せる。
Ray :: struct {
ox, oy, oz: f32,
dx, dy, dz: f32,
t: f32,
hit: bool,
step_count: i32,
nx, ny, nz: f32,
r, g, b: f32,
px, py: i32,
throughput: f32,
material: i32,
}
AoS だと、march で使わない r, g, b, px, py なども Ray の近くにいます。SoA だと、march で必要な列だけを取り出して処理できます。
中心になる処理はここです。
march_soa :: proc(rays: #soa []Ray, max_steps: int) {
// #soa のフィールド列を先に取り出す。
// march で使わない r/g/b や px/py はここでは読まない。
ox := rays.ox
oy := rays.oy
oz := rays.oz
dx := rays.dx
dy := rays.dy
dz := rays.dz
t := rays.t
hit := rays.hit
step_count := rays.step_count
nx := rays.nx
ny := rays.ny
nz := rays.nz
material_field := rays.material
for step in 0..<max_steps {
// 各 step で全 ray を少しずつ進める。
for i in 0..<len(rays) {
// hit 済み、または遠くまで進みすぎた ray は処理しない。
if hit[i] || t[i] > FAR {
continue
}
// SoA の列から現在位置を計算して SDF を評価する。
px := ox[i] + dx[i] * t[i]
py := oy[i] + dy[i] * t[i]
pz := oz[i] + dz[i] * t[i]
dist, material := scene_sdf(px, py, pz)
t[i] += dist
step_count[i] = i32(step + 1)
material_field[i] = material
if dist < HIT_EPS {
// hit した ray だけ normal を推定する。
hit[i] = true
nx[i], ny[i], nz[i] = estimate_normal(px, py, pz)
}
}
}
}
このレイマーチでも #simd を試しました。全部を SIMD 化するのは少し面倒です。ray は途中で hit したり、FAR を超えたりするので、4本まとめて処理していても「この lane はもう止まっている」という状態が出ます。
今回は #simd[4]f32 で4 rayぶんの距離評価をまとめ、active な lane だけ t や material を更新するようにしました。
// 4 ray ぶんをまとめる SIMD 型。
f32x4 :: #simd[4]f32
i32x4 :: #simd[4]i32
u32x4 :: #simd[4]u32
// t[] から4要素まとめて読む。
t4 := intrinsics.unaligned_load((^f32x4)(&t[i]))
// FAR 未満かつ未 hit の lane だけ active にする。
far_mask := intrinsics.simd_lanes_le(t4, far4)
active := far_mask & u32x4 {
0xffffffff if !hit[i + 0] else 0,
0xffffffff if !hit[i + 1] else 0,
0xffffffff if !hit[i + 2] else 0,
0xffffffff if !hit[i + 3] else 0,
}
// active/inactive に関係なく4 lane 分の現在位置を計算する。
px4 := ox4 + dx4 * t4
py4 := oy4 + dy4 * t4
pz4 := oz4 + dz4 * t4
dist4, material4 := scene_sdf4(px4, py4, pz4)
// active lane だけ t を更新し、inactive lane は元の値を残す。
next_t4 := t4 + dist4
t4 = intrinsics.simd_select(active, next_t4, t4)
soa_simd_align16 では Aligned_Ray :: struct #align(16) を別に用意し、load/store も aligned 前提の direct load/store に切り替えました。ただ、このレイマーチでは #align(16) だけで大きく伸びる感じではありませんでした。
scene_sdf4 側も scalar の scene_sdf と同じ形で、f32x4 を受け取る関数にしています。例えば球はこうです。
sd_sphere4 :: proc(x, y, z: f32x4, radius: f32) -> f32x4 {
// scalar 版の sd_sphere と同じ式を f32x4 にしたもの。
radius4 := f32x4{radius, radius, radius, radius}
return length3_4(x, y, z) - radius4
}
length3_4 :: proc(x, y, z: f32x4) -> f32x4 {
// core:math.sqrt ではなく、SIMD を受け取れる intrinsics.sqrt を使う。
return intrinsics.sqrt(x * x + y * y + z * z)
}
ただし、hit したときの normal 推定は scalar のまま残しています。ここまで SIMD 化するとコード量がかなり増えますし、今回見たいのは「SoA の列データを SIMD に流せるか」だったので、距離評価の部分に絞りました。
シーンは SDF で作っています。床、左右の壁、奥壁、天井、球、トーラス、回転した箱、柱列を合成しました。
scene_sdf :: proc(x, y, z: f32) -> (dist: f32, material: i32) {
// 最初は床を最短距離として置き、他の形状と min で合成していく。
best := y + 1.15
best_material := i32(2)
// 壁と天井。
union_min(&best, &best_material, 2.65 - abs_f32(x), 6)
union_min(&best, &best_material, z + 5.5, 6)
union_min(&best, &best_material, 2.2 - y, 6)
// 球、回転した箱、トーラスを配置する。
union_min(&best, &best_material, sd_sphere(x + 0.15, y - 0.05, z + 0.65, 0.86), 1)
box_x, box_z := rotate_y(x + 1.15, z + 1.75, 0.65)
union_min(&best, &best_material, sd_box(box_x, y + 0.35, box_z, 0.55, 0.55, 0.55), 4)
torus_x, torus_y := rotate_2d(x - 1.05, y - 0.05, -0.35)
union_min(&best, &best_material, sd_torus_y(torus_x, torus_y, z + 1.85, 0.62, 0.16), 3)
for zi in 0..<4 {
// 左右に柱を並べて、少し複雑なシーンにする。
cz := -0.35 - f32(zi) * 1.35
union_min(&best, &best_material, sd_cylinder_y(x - 2.05, y, z - cz, 0.12, 1.2), 5)
union_min(&best, &best_material, sd_cylinder_y(x + 2.05, y, z - cz, 0.12, 1.2), 5)
}
return best, best_material
}
このレイマーチでは SoA が速くなりました。しかし、シーンを複雑にしたら差は小さくなりました。
SoA はメモリアクセスの改善なので、SDF の計算自体が重くなるほど相対的な差は見えにくくなります。単純な SDF ではもっと差が出ていましたが、複雑な SDF では 1.07 倍くらいに落ち着きました。
5. 同じ関数に AoS と SoA を渡すとどうなるのか
途中で気になったのが、Odin の #soa []Particle は普通の []Particle と同じような書き味で扱えるけど、同じ関数に渡したとき内部的にはどうなっているのか、という点です。
Odin では polymorphic proc にすると、AoS と SoA の両方を同じ関数に渡せます。
Particle :: struct {
x: f32,
vx: f32,
}
// $T は Odin の polymorphic parameter。
// []Particle と #soa []Particle を渡すと、それぞれ別の実体に特殊化される。
update :: proc(particles: $T) {
for i in 0..<len(particles) {
// #soa の場合でも particles[i].x という書き方はできる。
particles[i].x += particles[i].vx
}
}
aos := make([]Particle, count)
soa := make_soa(#soa []Particle, count)
update(aos)
update(soa)
この particles[i].x という書き方が AoS でも SoA でも通るのが面白いところです。SoA の場合でも、Odin 側が particles[i].x をいい感じに扱ってくれます。感覚としては、particles.x[i] に近いアクセスへ変換されるイメージです。
ただし、これは []Particle 用の普通の関数に #soa []Particle も渡せる、という意味ではありません。[]Particle と #soa []Particle は別の型です。
update_aos :: proc(particles: []Particle) {
// これは []Particle 用
// #soa []Particle をそのまま渡せるわけではない。
}
この関数に #soa []Particle をそのまま渡すわけではありません。両方を受けたいなら、polymorphic proc にするか、AoS 用と SoA 用を明示的に分けます。
では、polymorphic proc にした場合、実行時に型を見て分岐しているのか。ここも気になったので、LLVM IR を出して見てみました。
結果としては、[]Particle 用と #soa []Particle 用で別々の関数が出ていました。
main::update:proc(particles:[]main::Particle)
main::update:proc(particles:#soa[]main::Particle)
つまり、C++ の template 的に、具体型ごとに specialization されたコードが生成される、と考えてよさそうです。少なくとも今回確認した範囲では、1つの関数が runtime で型を見て切り替える形ではありませんでした。
ここは記事の実装方針にも関係します。
共通化だけを考えるなら、こういう書き方は便利です。
particles[i].x += particles[i].vx * dt
AoS でも SoA でも同じ関数に渡せるので、実装の見通しはよくなります。
ただ、SoA の性能をちゃんと引き出したいときは、今回のベンチのように列を先に取り出す書き方のほうが効くことがありました。
// SoA 専用に書くなら、列を先に取り出す。
x := particles.x
vx := particles.vx
for i in 0..<len(particles) {
// 必要な列だけを連続して流す。
x[i] += vx[i] * dt
}
これはかなり SoA らしい書き方です。必要な列だけを取り出して、そこだけを連続して処理できます。
逆に、この書き方は普通の []Particle にはそのまま使えません。なので最終的には、次のどちらを優先するかで書き方が変わりそうです。
- AoS / SoA を同じコードで扱いたいなら polymorphic proc
- SoA の性能を引き出したいなら SoA 専用の列アクセス
今回の自分の感触としては、まず polymorphic proc で同じ処理を書いて比較し、差が出そうな場所だけ SoA 専用に書き直すのが扱いやすそうでした。
6. ソースコード
ソースコードはこのリポジトリにあります。
コーディングエージェントとしてCodexを利用しています。
7. 総括
今回やってみて、#soa は「とりあえず付ければ速い」というものではありませんでした。
効きやすいのは以下のような場合です。
- 要素数が多い
- ループ内で触るフィールドが少ない
- 同じフィールド群を連続して処理する
- まとめて触るフィールドの単位がデータ構造と合っている
- 距離評価のような同じ計算を複数要素へ並列に流せる
- 描画 API 呼び出しのような別の重い処理が混ざっていない
- 分岐や SDF 計算より、メモリアクセスの比率が高い
逆に苦手なのは以下のような場合になります。
- 1要素ごとに多くのフィールドを集める
-
Particle単位で処理が完結する - raylib の immediate draw のように、1オブジェクトずつ描画 API を呼ぶ
- 距離関数や shading などの計算が重く、データ配置の差が埋もれる
特に raylib で描画がある場合は注意が必要でした。
raylib は内部でバッチ描画を持っていますが、こちらのコードが DrawTriangle を粒子ごとに呼んでいるなら、Odin 側では結局「1粒子ぶんの値を集めて渡す」処理になります。これは SoA より AoS のほうが合うことがあります。
実際、表示ありの測定では avg_update_ms だけを見ても AoS のほうが速くなりました。
SoA を描画で活かしたいなら、粒子ごとに DrawTriangle を呼ぶより、以下のような形に寄せたほうがよさそうです。
SoA の particle 配列
-> position や rotation の列をまとめて処理
-> vertices[] や colors[] を一括生成
-> raylib の texture / mesh / buffer 更新へ渡す
まとめ
- 更新処理を列指向に分けられるなら #soa はかなり有効。
- 3軸を常にセットで扱うなら Vec3 化も効く。
- 描画が object 単位なら AoS のほうが素直。
- raylib と組み合わせるなら、描画 API 呼び出しより前の CPU 側処理に #soa を使うのがよさそう。
当初は線形メモリアクセスのキャッシュ効率によって、UnityのDOTSのような効果があるかと想像していました。
#soa をつけるだけで、その効果を享受できるとしたら素晴らしい機能なのでは?? と思った訳です。
そうして Odin を触ってみたわけですが、結果から分かる通りそういうわけではありませんでした。
コード上では AoS 的な表現であっても、どの処理が行指向で、どの処理が列指向なのかを意識して実装しなければ効果が薄いということです。
これは個人の感想ですが、AoSはSoAよりも把握しやすい構造だと思っています。明らかに列指向の処理がある場合、無理にAoS的なコードを書かなくても#soaとすれば良いのは、利点と感じました。
最後に
株式会社ボトルキューブでは Flutter を使ったお仕事を募集中です。
お問い合わせフォームからご連絡ください。
また、一緒に働く仲間も募集しています。
詳細は採用情報ページをご覧ください。

