-
Notifications
You must be signed in to change notification settings - Fork 415
Expand file tree
/
Copy pathJacobi+MLX.swift
More file actions
178 lines (160 loc) · 6.76 KB
/
Copy pathJacobi+MLX.swift
File metadata and controls
178 lines (160 loc) · 6.76 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
// Copyright © 2026 Apple Inc.
import Foundation
import IOSurface
import MLX
/// Straightforward implementation of Jacobi iterations using
/// `conv2d` -- the weights implement the stencil.
///
/// Advantages:
///
/// - inner loop is a straightforward implementation of the math
/// - computes every point at once
/// - convolution is likely highly optimized
///
/// Disadvantages:
///
/// - convolution does read 9 points when it only needs 4
///
/// Note: the computation is very fast -- much faster than the 60 Hz rate we are targeting
/// for the display. We use the `count` to run multiple iterations per displayed frame.
/// This speeds up the display (more interesting for humans) and amortizes the overhead
/// a bit.
public func computeJacobiConv2d(state: inout Room, count: Int) {
// [1, H, W, 1] -- match what conv2d needs
var temperature = state.temperature[.newAxis, .ellipsis, .newAxis]
let staticMask = state.staticMask[.newAxis, .ellipsis, .newAxis]
let heatSources = state.heatSources[.newAxis, .ellipsis, .newAxis]
let kernel = MLXArray(converting: [
0, 0.25, 0,
0.25, 0, 0.25,
0, 0.25, 0,
]
).reshaped(1, 3, 3, 1)
for _ in 0 ..< count {
let next = conv2d(temperature, kernel, padding: 1)
temperature = which(staticMask, heatSources, next)
}
state.temperature = temperature.squeezed()
}
/// A more direct implementation of the 4 point stencil.
///
/// If you were writing this for the CPU it would look something like this (inner loop, per point):
///
/// ```swift
/// next[x, y] = 0.25 * (current[x - 1, y] + current[x + 1, y] + current[x, y - 1] + current[x, y + 1])
/// ```
///
/// In array processing code you would do the same thing by _moving_ the array
/// around and then doing element-wise arithmetic. This is not (typically) physically
/// shifting the array around, but manipulating a view on top of it.
///
/// We can shift the array around using:
///
/// - `roll()` -- rotate the array on one axis. this works because there are walls on the border
/// - slicing -- e.g. indexing operations like `temperature[1..., 0...]` (shift up). This
/// requires dealing with losing an edge value (the shape is smaller by 2 in each direction)
/// - `padded()` + slicing -- same as slicing, but add padding to fix the edge issue
///
/// This example shows roll.
///
/// Advantages:
///
/// - inner loop is recognizable compared to the math
/// - computes every point at once
/// - does less work than convolution
/// - amenable to `compile()` to fuse the operations
///
/// Disadvantages:
///
/// - the code exposes more of the details -- it is slightly harder to read than convolution
/// - the edge conditions are a real concern and the simplicity of this problem largely avoids them
///
/// On my laptop I find that this is 2 to 3 times faster than conv2d when compiled. With roll
/// you have to reason about the edges explicitly. Convolution handles them via padding.
///
/// Note: the computation is very fast -- much faster than the 60 Hz rate we are targeting
/// for the display. We use the `count` to run multiple iterations per displayed frame.
/// This speeds up the display (more interesting for humans) and amortizes the overhead
/// a bit.
public func computeJacobiStencil(state: inout Room, count: Int) {
@Sendable
func step(_ temperature: MLXArray, _ staticMask: MLXArray, _ heatSources: MLXArray) -> MLXArray
{
let next =
0.25
* (roll(temperature, shift: -1, axis: 0)
+ roll(temperature, shift: 1, axis: 0)
+ roll(temperature, shift: -1, axis: 1)
+ roll(temperature, shift: 1, axis: 1))
return which(staticMask, heatSources, next)
}
let compiledStep = compile(step)
for _ in 0 ..< count {
state.temperature = compiledStep(
state.temperature, state.staticMask, state.heatSources)
}
}
func checkerboard(rows: Int, cols: Int, phase: Int) -> MLXArray {
let rows = arange(0, rows)[.ellipsis, .newAxis]
let cols = arange(0, cols)[.newAxis, .ellipsis]
return (((rows + cols) % 2) .== phase)[.newAxis, .ellipsis, .newAxis]
}
/// A more efficient algorithm for computing heat transfer.
///
/// Jacobi iterations takes O(n^2) (where n is the length of
/// the edge of the grid) iterations to converge.
/// Successive over-relaxation (SOR)
/// converges in O(n) when an optimal ω is used. This is
/// like switching bubble sort for quicksort.
///
/// This uses an ω to overdrive the prediction. Red/black lets each color's
/// update read the most recent values from its opposite-color neighbors,
/// giving Gauss-Seidel-style propagation without serialising the grid.
///
/// Advantages:
///
/// - inner loop is a straightforward implementation of the math
/// - computes every point at once
/// - big win: this converges faster
///
/// Disadvantages:
///
/// - the math is more complex
/// - this is twice as expensive as conv2d (it uses 2 of them) but fewer calls are needed
///
/// When comparing the amortized version (200 iterations, see "SOR Full Speed"), this is
/// roughly twice as slow as conv2d. SOR (1 iter/frame) runs ~10x slower per
/// iteration than SOR Full Speed (200 iter/frame) — dispatch and sync overhead
/// dominate at small batch sizes.
///
/// Note: the computation is very fast -- much faster than the 60 Hz rate we are targeting
/// for the display. We use the `count` to run multiple iterations per displayed frame.
/// This speeds up the display (more interesting for humans) and amortizes the overhead
/// a bit.
public func computeSOR(state: inout Room, count: Int) {
var temperature = state.temperature[.newAxis, .ellipsis, .newAxis]
let heatMask = state.staticMask[.newAxis, .ellipsis, .newAxis]
let heatSources = state.heatSources[.newAxis, .ellipsis, .newAxis]
let M = state.temperature.dim(0)
let N = state.temperature.dim(1)
let kernel = MLXArray(converting: [
0, 0.25, 0,
0.25, 0, 0.25,
0, 0.25, 0,
]
).reshaped(1, 3, 3, 1)
let ω: Float = 2.0 / (1.0 + sin(Float.pi / Float(max(M, N))))
let redMask = checkerboard(rows: M, cols: N, phase: 0)
let blackMask = checkerboard(rows: M, cols: N, phase: 1)
for _ in 0 ..< count {
// Update red cells using black neighbors
let sorRed = ω * conv2d(temperature, kernel, padding: 1) + (1 - ω) * temperature
temperature = which(redMask, sorRed, temperature)
temperature = which(heatMask, heatSources, temperature)
// Update black cells using (now-updated) red neighbors
let sorBlack = ω * conv2d(temperature, kernel, padding: 1) + (1 - ω) * temperature
temperature = which(blackMask, sorBlack, temperature)
temperature = which(heatMask, heatSources, temperature)
}
state.temperature = temperature.squeezed()
}