ferrotherm_gpu/device.rs
1//! The GPU as a [`Device`], so the conformance machinery can score it.
2//!
3//! Until this existed, `ferrotherm`'s survey of its own capabilities carried a line it had earned:
4//! *"no `impl Device` for a GPU, so `conform` cannot even score the GPU path."* The fastest sampler
5//! in the stack was the one path the verification machinery could not reach — it could be run, and
6//! it could not be **checked against the fabric it claims to be**.
7//!
8//! What that check turns out to be about is precision. The CPU sampler is `f64` throughout and the
9//! shader is `f32`, because that is what WGSL storage buffers hold. Every other `Device` here
10//! declares its precision honestly — D-Wave as `Unstated`, the fixed-point fabric as
11//! `Fixed { bits }` — and the GPU had no declaration at all, so nothing downstream could reason
12//! about it. It is [`Precision::Float`] with a 24-bit mantissa, and saying so is what lets
13//! `conform` compare the two paths knowing which differences are the arithmetic and which are the
14//! sampler.
15//!
16//! ```no_run
17//! use ferrotherm::{fabric::Device, ising::lattice2d, schedule::Schedule, ftp::Program};
18//! use ferrotherm_gpu::GpuDevice;
19//!
20//! let Some(mut dev) = GpuDevice::open() else { return };
21//! let g = lattice2d(32, 1.0);
22//! let p = Program::from_graph(&g, &Schedule::default());
23//! assert!(dev.program(&p).is_empty());
24//! let state = dev.run(&Schedule::constant(0.6, 200), 7).unwrap();
25//! assert_eq!(state.len(), g.n);
26//! // The writes are charged, which is the term the ledger's thesis rests on.
27//! assert_eq!(dev.ledger().writes, g.n as u64);
28//! ```
29
30use ferrotherm::fabric::{Device, Fabric, Precision, Unsupported};
31use ferrotherm::ftp::Program;
32use ferrotherm::ledger::{Ledger, Prices};
33use ferrotherm::rng::Pcg;
34use ferrotherm::schedule::Schedule;
35use ferrotherm::graph::Graph;
36use ferrotherm::wgsl::GpuModel;
37
38/// A [`Device`] backed by the native WGSL sampler.
39///
40/// Separate from [`Gpu`](crate::Gpu) rather than implemented on it, because `Gpu` is a handle to an
41/// adapter and holds no problem: `Device` is a stateful loader-and-runner, and merging the two
42/// would make every `Gpu` carry a program it may never be given.
43pub struct GpuDevice {
44 gpu: crate::Gpu,
45 model: Option<GpuModel>,
46 /// Kept beside the `GpuModel` so a stage's result can be SCORED without a second lowering.
47 /// `GpuModel` is the device-side layout and carries no energy function.
48 graph: Option<Graph>,
49 state: Vec<i8>,
50 ledger: Ledger,
51}
52
53impl GpuDevice {
54 /// Open the default adapter, or `None` where this machine exposes none.
55 ///
56 /// `None` means **not found here**, never "impossible" — the same contract as
57 /// [`Gpu::new`](crate::Gpu::new), and worth preserving because a headless CI runner is the
58 /// common case and every test around this skips rather than fails on it.
59 pub fn open() -> Option<GpuDevice> {
60 Some(GpuDevice::with(crate::Gpu::new()?))
61 }
62
63 /// Wrap an adapter already opened, so a caller that enumerated once does not enumerate again.
64 pub fn with(gpu: crate::Gpu) -> GpuDevice {
65 GpuDevice { gpu, model: None, graph: None, state: Vec::new(), ledger: Ledger::default() }
66 }
67
68 /// What the driver reports, for a caller that needs to know whether this is real silicon.
69 pub fn adapter(&self) -> &wgpu::AdapterInfo {
70 self.gpu.adapter()
71 }
72
73 /// True when the adapter is hardware rather than a software rasteriser.
74 #[must_use = "false means a software rasteriser, whose timings say nothing about a GPU"]
75 pub fn is_hardware(&self) -> bool {
76 self.gpu.is_hardware()
77 }
78}
79
80impl Device for GpuDevice {
81 fn fabric(&self) -> Fabric {
82 // NOT Prices::UNSTATED by oversight -- by fact. A GPU vendor publishes board power, which
83 // is a rate for the whole card, not an energy per spin update. `ferrotherm-meter` derives
84 // the per-operation figure by measuring THIS machine, and that measured value is what
85 // belongs here; a datasheet number would be a different machine's.
86 let mut f = Fabric::unconstrained("gpu", Prices::UNSTATED);
87 // Lowers through `Program::to_graph`, which is pairwise.
88 f.max_arity = 2;
89 // The declaration that was missing. WGSL storage buffers hold f32: `GpuModel::w` and
90 // `GpuModel::h` are `Vec<f32>`, so every coupling and field is rounded to 24 mantissa bits
91 // on the way in. The CPU path keeps f64. Neither is wrong; an undeclared difference is.
92 f.coupling_precision = Precision::Float { mantissa: 24 };
93 f.field_precision = Precision::Float { mantissa: 24 };
94 f.unstated = &[
95 "per-operation energy: GPU vendors publish board power (a rate for the whole card), \
96 not joules per spin update. Measure it with ferrotherm-meter on the machine that ran \
97 the work.",
98 ];
99 f
100 }
101
102 fn program(&mut self, p: &Program) -> Vec<Unsupported> {
103 let bad = self.fabric().check(p);
104 if !bad.is_empty() {
105 return bad;
106 }
107 match p.to_graph() {
108 Ok(g) => {
109 self.model = Some(GpuModel::from_graph(&g));
110 self.state = vec![-1; g.n];
111 // The write, charged -- one node's couplings, bias and clamp state flashed. The
112 // CPU device charges it and the ledger's whole thesis rests on it; a GPU device
113 // that skipped it would make the GPU look free at exactly the term that is not.
114 self.ledger.writes += g.n as u64;
115 self.graph = Some(g);
116 Vec::new()
117 }
118 Err(e) => vec![Unsupported::Unplaceable { detail: e.to_string() }],
119 }
120 }
121
122 /// Run the schedule and return the BEST state seen, not the last one.
123 ///
124 /// The trait's wording says "the final state" and every implementation here returns the best:
125 /// `Cpu` delegates to `tempering::anneal_scheduled`, which tracks the minimum over every sweep,
126 /// and `sbm` calls the same thing a best-so-far readout. That is not pedantry about wording. An
127 /// anneal's last state is wherever the coldest stage happened to stop, and this returned it --
128 /// which `conform` caught the moment it could reach this path at all, scoring **-57 against
129 /// variable elimination's exact -59** while the CPU on the same ladder found -59. Nothing was
130 /// wrong with the sampler; it was being asked the wrong question at the end.
131 ///
132 /// Tracked per STAGE rather than per sweep, and that difference is real: scoring a state means
133 /// reading it back off the device, so per-sweep tracking would put a round trip between every
134 /// sweep and spend the throughput the GPU exists for. The conformance ladder is 80 stages, so
135 /// the minimum is taken over 80 checkpoints against the CPU's 3,200.
136 fn run(&mut self, schedule: &Schedule, seed: u64) -> Result<Vec<i8>, String> {
137 let m = self.model.as_ref().ok_or("no program loaded")?;
138 let g = self.graph.as_ref().ok_or("no program loaded")?;
139 if schedule.is_empty() {
140 return Err("an empty schedule runs nothing; give it at least one stage".into());
141 }
142 // A RUN STARTS FROM THE SEED, and does not inherit the last one's answer.
143 //
144 // Two differences from the reference `Cpu` device, both found by a test that could not
145 // otherwise have failed. `Cpu::run` builds a fresh `Sampler::new(g, beta, seed)`, whose
146 // initial state is drawn from the seed -- so it resets per run AND starts somewhere the
147 // seed chose. This carried `self.state` between calls and began at all-minus-one, which
148 // made a second `run` start from the first one's best: two different seeds then returned
149 // the same state, because the second was handed an answer it could not improve on and
150 // simply gave it back. Matching `Sampler::new` exactly also means CPU and GPU now start a
151 // given seed at the SAME configuration, which is what makes the two paths comparable.
152 let mut rng = Pcg::new(seed, 0x5EED);
153 self.state = (0..g.n).map(|_| rng.spin(0.5)).collect();
154 let mut best = self.state.clone();
155 let mut best_e = g.energy(&best);
156 // Stage by stage, because a schedule is a temperature ladder and the shader takes one beta
157 // per dispatch. The state carries across stages, which is what makes it an anneal rather
158 // than a sequence of independent runs.
159 for (i, st) in schedule.stages().iter().enumerate() {
160 // The seed varies per stage. Reusing one stream across stages would have every stage
161 // draw the same numbers at the same nodes, which is the failure the step counter
162 // already exists to prevent, one level up.
163 let stage_seed = seed.wrapping_add(i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
164 self.gpu.sweep_seeded(m, &mut self.state, st.beta, st.sweeps as u32, stage_seed)?;
165 self.ledger.samples += m.n as u64 * st.sweeps as u64;
166 let e = g.energy(&self.state);
167 if e < best_e {
168 best_e = e;
169 best = self.state.clone();
170 }
171 }
172 self.state = best.clone();
173 Ok(best)
174 }
175
176 fn ledger(&self) -> Ledger {
177 self.ledger
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use super::*;
184 use ferrotherm::ising::lattice2d;
185
186 macro_rules! dev_or_skip {
187 () => {
188 match GpuDevice::open() {
189 Some(d) => d,
190 None => {
191 eprintln!("no GPU adapter on this machine; skipping");
192 return;
193 }
194 }
195 };
196 }
197
198 #[test]
199 fn conform_can_finally_score_the_gpu_path() {
200 // THE POINT OF THIS MODULE. `conform::run` takes a `&mut dyn Device`, so before this impl
201 // existed the fastest sampler in the stack was the one path the conformance suite could not
202 // reach -- runnable, and uncheckable against the fabric it claims to be.
203 let mut d = dev_or_skip!();
204 if !d.is_hardware() {
205 eprintln!("software rasteriser; the physics is still checked, the timings mean nothing");
206 }
207 let report = ferrotherm::conform::run(&mut d);
208 assert!(
209 report.passed(),
210 "the GPU path fails conformance:\n{report}\n{}",
211 report.failures().map(|c| format!(" {} -- {}", c.name, c.detail)).collect::<Vec<_>>().join("\n")
212 );
213 }
214
215 #[test]
216 fn the_fabric_declares_f32_rather_than_leaving_it_unsaid() {
217 // The difference that was invisible. `GpuModel::{w,h}` are `Vec<f32>`, so every coupling and
218 // field is rounded to 24 mantissa bits going in while the CPU path keeps f64. Neither is
219 // wrong; an undeclared difference is, because nothing downstream can then tell an
220 // arithmetic gap from a sampler gap.
221 let d = dev_or_skip!();
222 let f = d.fabric();
223 assert_eq!(f.coupling_precision, Precision::Float { mantissa: 24 });
224 assert_eq!(f.field_precision, Precision::Float { mantissa: 24 });
225 assert_eq!(f.max_arity, 2, "it lowers through to_graph, which is pairwise");
226 assert!(!f.prices.is_stated(), "a GPU publishes board power, not joules per spin update");
227 assert!(
228 f.unstated.iter().any(|u| u.contains("per-operation energy")),
229 "the gap has to be named, not merely left empty: {:?}",
230 f.unstated
231 );
232 }
233
234 #[test]
235 fn the_seed_selects_a_stream_rather_than_being_swallowed() {
236 // The trait hands `run` a seed and `Gpu::sweep` had none, so the obvious implementation
237 // takes the argument and drops it -- and a caller varying the seed to gauge spread gets one
238 // answer every time and reads a deaf sampler as a confident one. Note that `conform`'s
239 // determinism case CANNOT catch this: an ignored seed is perfectly reproducible.
240 //
241 // Checked at the sweep level, where it is unambiguous: identical starting state, identical
242 // beta, identical sweep count, two seeds.
243 let d = dev_or_skip!();
244 let g = lattice2d(24, 1.0);
245 let m = GpuModel::from_graph(&g);
246 let hot = 0.15; // disordered, so two streams separate immediately
247
248 let mut a = vec![1i8; g.n];
249 let mut b = vec![1i8; g.n];
250 let mut a_again = vec![1i8; g.n];
251 d.gpu.sweep_seeded(&m, &mut a, hot, 40, 1).unwrap();
252 d.gpu.sweep_seeded(&m, &mut b, hot, 40, 2).unwrap();
253 d.gpu.sweep_seeded(&m, &mut a_again, hot, 40, 1).unwrap();
254 assert_ne!(a, b, "two seeds produced identical states; the seed is being ignored");
255 assert_eq!(a, a_again, "the same seed must reproduce; this is a seed, not just noise");
256
257 // And seed 0 has to leave the unseeded stream exactly where it was, or every Onsager number
258 // ever taken on this shader silently moved.
259 let mut viaseed = vec![1i8; g.n];
260 let mut unseeded = vec![1i8; g.n];
261 d.gpu.sweep_seeded(&m, &mut viaseed, hot, 40, 0).unwrap();
262 d.gpu.sweep(&m, &mut unseeded, hot, 40).unwrap();
263 assert_eq!(viaseed, unseeded, "seed 0 must be the stream that existed before seeding");
264 }
265
266 #[test]
267 fn the_device_threads_its_seed_through_to_the_sampler() {
268 // The same property one level up, where it is what the trait actually promises.
269 //
270 // On a FRUSTRATED instance, deliberately. A ferromagnet's all-minus-one start is already a
271 // ground state, so best-so-far never leaves it and both seeds return the initial state --
272 // which is correct behaviour and a completely blind test. This assertion only means
273 // something where the sampler has somewhere to go.
274 let mut d = dev_or_skip!();
275 let inst = ferrotherm::planted::frustrated_loops(12, 24, 5);
276 let p = Program::from_graph(&inst.graph, &Schedule::default());
277 assert!(d.program(&p).is_empty());
278
279 let hot = Schedule::constant(0.25, 30);
280 let a = d.run(&hot, 11).unwrap();
281 let b = d.run(&hot, 22).unwrap();
282 assert!(
283 inst.graph.energy(&a) < 0.0 && inst.graph.energy(&b) < 0.0,
284 "both runs should have moved off the initial state at all"
285 );
286 assert_ne!(a, b, "two seeds gave the same trajectory; the device is not threading the seed");
287 }
288
289 #[test]
290 fn the_write_is_charged_because_that_is_the_term_the_ledger_rests_on() {
291 let mut d = dev_or_skip!();
292 assert_eq!(d.ledger().writes, 0);
293 let g = lattice2d(16, 1.0);
294 let p = Program::from_graph(&g, &Schedule::default());
295 assert!(d.program(&p).is_empty());
296 assert_eq!(d.ledger().writes, g.n as u64, "one write per node flashed");
297 assert_eq!(d.ledger().samples, 0, "loading is not sampling");
298
299 d.run(&Schedule::constant(0.6, 10), 3).unwrap();
300 assert_eq!(d.ledger().samples, g.n as u64 * 10, "one sample per node per sweep");
301 }
302
303 #[test]
304 fn running_without_a_program_is_an_error_not_an_empty_state() {
305 let mut d = dev_or_skip!();
306 let e = d.run(&Schedule::constant(0.6, 10), 1).unwrap_err();
307 assert!(e.contains("no program"), "{e}");
308 // And an empty schedule runs nothing, which is worth saying rather than returning the
309 // initial state as though it had been sampled.
310 let g = lattice2d(8, 1.0);
311 assert!(d.program(&Program::from_graph(&g, &Schedule::default())).is_empty());
312 let e2 = d.run(&Schedule::new(), 1).unwrap_err();
313 assert!(e2.contains("empty schedule"), "{e2}");
314 }
315}