ferrotherm_gpu/lib.rs
1//! Native GPU sampling: the same chromatic sweep the browser runs, on Vulkan, Metal or DX12.
2//!
3//! # Why this is a separate crate
4//!
5//! `ferrotherm` is std-only with zero dependencies, and that is load-bearing rather than
6//! decorative: it is what lets the same source compile to `wasm32-unknown-unknown` and to a
7//! microcontroller. A GPU backend needs a driver stack. So it lives out here beside `silicon`,
8//! `serve` and `cloud`, each of which exists for exactly the same reason.
9//!
10//! # Why it does not have its own shader
11//!
12//! The WGSL comes from [`ferrotherm::wgsl::sweep_shader`] — the same string the browser fetches
13//! through `ft_shader`. A second copy would be a second implementation of the update rule, and the
14//! two would drift the first time one was tuned. The core crate already pins the sigmoid with a
15//! test (`the_shader_states_the_same_update_as_the_kernel`); binding that same text here means a
16//! native run and a browser run cannot disagree about the arithmetic, only about the hardware.
17//!
18//! # What it does not promise
19//!
20//! **Not bit-identical to the CPU sampler.** The shader's RNG is a counter-based hash of
21//! `(step, node)`, chosen so a lane needs no state and the result does not depend on the order
22//! lanes happen to execute in. The CPU sampler draws from its own stream. Both sample the same
23//! distribution; neither reproduces the other's individual flips, and a test that asserted they did
24//! would be asserting something false.
25//!
26//! What they DO agree on is physics, and that is what [`Gpu::sweep`]'s tests check: the same
27//! magnetisation at the same temperature, and the exact mean energy from variable elimination.
28//!
29//! # Verified on two vendors and two APIs
30//!
31//! | | adapter | API | tests |
32//! |---|---|---|---|
33//! | Apple M5 Max | IntegratedGpu | Metal | 6/6 |
34//! | NVIDIA L4 (EC2 g6.xlarge) | DiscreteGpu | Vulkan 1.4 | 6/6 |
35//! | Microsoft Basic Render Driver (EC2 Windows) | **Cpu** | DX12 | 6/6 |
36//!
37//! All three run the same WGSL from the core crate and all three reproduce the exact mean energy
38//! computed by variable elimination. A shader can pass on Metal and fail on Vulkan, whose validation
39//! is stricter and whose f32 behaviour differs, so this was worth checking rather than assuming.
40//!
41//! **The DX12 row is WARP, a software rasteriser, and that is a real limit on what it proves.** It
42//! establishes that the shader compiles under DX12 and that the physics is right; it says nothing
43//! about DX12 on hardware, because there was none on that instance. [`Gpu::is_hardware`] reported
44//! `Cpu` and the benchmark refused to quote a speedup, which is the guard working rather than a
45//! caveat added afterwards. DX12 correctness: checked. DX12 on a real GPU: still not.
46//!
47//! ```no_run
48//! use ferrotherm::{ising::lattice2d, wgsl::GpuModel};
49//! # fn main() -> Result<(), String> {
50//! let g = lattice2d(8, 1.0);
51//! let m = GpuModel::from_graph(&g);
52//! let mut spins = vec![1i8; 64];
53//!
54//! let gpu = ferrotherm_gpu::Gpu::new().ok_or("no adapter")?;
55//! gpu.sweep(&m, &mut spins, 0.44, 100)?;
56//! # Ok(()) }
57//! ```
58
59use ferrotherm::wgsl::{sweep_shader, GpuModel};
60pub mod device;
61pub use device::GpuDevice;
62
63use wgpu::util::DeviceExt;
64
65/// A GPU that can run the sweep.
66///
67/// Holds a device and queue. Creating one enumerates adapters, which is slow enough that it should
68/// happen once per process rather than once per sweep.
69pub struct Gpu {
70 device: wgpu::Device,
71 queue: wgpu::Queue,
72 /// What the adapter reported. Worth carrying because a software rasteriser will happily run
73 /// this and report timings that mean nothing about hardware — see [`Gpu::adapter`].
74 info: wgpu::AdapterInfo,
75}
76
77impl Gpu {
78 /// Open the default adapter, or `None` if this machine exposes none.
79 ///
80 /// `None` means **not found on this machine**, never "impossible". A headless CI runner with no
81 /// driver is the common case, which is why every test here skips rather than fails on it.
82 pub fn new() -> Option<Gpu> {
83 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
84 let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
85 power_preference: wgpu::PowerPreference::HighPerformance,
86 force_fallback_adapter: false,
87 compatible_surface: None,
88 apply_limit_buckets: false,
89 }))
90 .ok()?;
91 let info = adapter.get_info();
92 let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
93 label: Some("ferrotherm"),
94 required_features: wgpu::Features::empty(),
95 // The WebGPU baseline, NOT downlevel_defaults. Downlevel caps storage buffers at 4
96 // per stage and this shader binds 6 (nbr, w, h, cls, spin, dbg), so asking for
97 // downlevel produces a device that cannot compile the pipeline -- and the failure
98 // arrives as a validation error at pipeline creation, far from the line that chose
99 // the limit. The browser runs this same shader under the WebGPU baseline, so the
100 // baseline is exactly the right floor: anything that runs the page runs this.
101 required_limits: wgpu::Limits::default(),
102 memory_hints: wgpu::MemoryHints::Performance,
103 experimental_features: wgpu::ExperimentalFeatures::disabled(),
104 trace: wgpu::Trace::Off,
105 }))
106 .ok()?;
107 Some(Gpu { device, queue, info })
108 }
109
110 /// What the driver says this is.
111 ///
112 /// Read it before quoting a speedup. `DeviceType::Cpu` is a software rasteriser — lavapipe,
113 /// SwiftShader, WARP — which runs the shader correctly and tells you nothing about a GPU, and a
114 /// benchmark that does not check this reports the wrong machine with full confidence.
115 pub fn adapter(&self) -> &wgpu::AdapterInfo {
116 &self.info
117 }
118
119 /// True when the adapter is real silicon rather than a software rasteriser.
120 #[must_use = "false means a software rasteriser, whose timings say nothing about a GPU. Quoting a speedup without checking this reports the wrong machine"]
121 pub fn is_hardware(&self) -> bool {
122 !matches!(self.info.device_type, wgpu::DeviceType::Cpu | wgpu::DeviceType::Other)
123 }
124
125 /// Run `sweeps` chromatic sweeps over `spins`, in place.
126 ///
127 /// One dispatch per colour class per sweep, which is what makes the update correct: nodes in a
128 /// class share no edge, so they can be resampled simultaneously without any of them reading a
129 /// neighbour another lane is writing. Dispatching all nodes at once would be faster and wrong.
130 pub fn sweep(
131 &self,
132 m: &GpuModel,
133 spins: &mut [i8],
134 beta: f64,
135 sweeps: u32,
136 ) -> Result<(), String> {
137 // Seed 0 mixes to an offset of 0, so this is bit-identical to the version that had no seed
138 // at all. That is deliberate: the Onsager checks and the browser's numbers were taken on
139 // this stream, and a "harmless" refactor that silently moved every draw would invalidate
140 // them without failing anything.
141 self.sweep_seeded(m, spins, beta, sweeps, 0)
142 }
143
144 /// The same sweep, drawing from the stream `seed` selects.
145 ///
146 /// The shader's RNG is `hash(step, node, const)` and `step` is the dispatch counter, so a seed
147 /// does not need a shader change: offsetting the counter by a mixing of the seed moves the
148 /// whole run onto a different stream while keeping every dispatch's counter distinct, which is
149 /// what stops a class resampling with the draws it just used.
150 ///
151 /// This exists because [`ferrotherm::fabric::Device::run`] takes a seed. A `Device` that
152 /// accepted one and ignored it would report reproducibility it does not have -- the caller
153 /// varies the seed, gets the same answer every time, and concludes the sampler is confident
154 /// rather than deaf.
155 pub fn sweep_seeded(
156 &self,
157 m: &GpuModel,
158 spins: &mut [i8],
159 beta: f64,
160 sweeps: u32,
161 seed: u64,
162 ) -> Result<(), String> {
163 if spins.len() != m.n as usize {
164 return Err(format!(
165 "this model has {} nodes and that state has {}",
166 m.n,
167 spins.len()
168 ));
169 }
170 if !beta.is_finite() || beta < 0.0 {
171 return Err(format!("beta must be finite and non-negative, not {beta}"));
172 }
173 if m.classes.is_empty() {
174 return Err("a model with no colour classes has nothing to dispatch".into());
175 }
176
177 let dev = &self.device;
178 let storage = wgpu::BufferUsages::STORAGE;
179 let rw = storage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
180
181 // A zero-length storage buffer is invalid, and a graph with no couplings produces one. Pad
182 // to a single element rather than failing: the shader reads k = 0 and never indexes it.
183 let pad_u32 = |v: &[u32]| if v.is_empty() { vec![0u32] } else { v.to_vec() };
184 let pad_f32 = |v: &[f32]| if v.is_empty() { vec![0f32] } else { v.to_vec() };
185
186 let mk_u32 = |label: &str, data: &[u32], usage: wgpu::BufferUsages| {
187 dev.create_buffer_init(&wgpu::util::BufferInitDescriptor {
188 label: Some(label),
189 contents: bytes_u32(&pad_u32(data)),
190 usage,
191 })
192 };
193 let mk_f32 = |label: &str, data: &[f32], usage: wgpu::BufferUsages| {
194 dev.create_buffer_init(&wgpu::util::BufferInitDescriptor {
195 label: Some(label),
196 contents: bytes_f32(&pad_f32(data)),
197 usage,
198 })
199 };
200
201 let b_nbr = mk_u32("nbr", &m.nbr, storage);
202 let b_w = mk_f32("w", &m.w, storage);
203 let b_h = mk_f32("h", &m.h, storage);
204 // The shader stores spins as i32; the library holds them as i8.
205 let state: Vec<i32> = spins.iter().map(|&s| s as i32).collect();
206 let b_spin = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor {
207 label: Some("spin"),
208 contents: bytes_i32(&state),
209 usage: rw,
210 });
211 let b_dbg = mk_f32("dbg", &vec![0f32; m.n as usize], rw);
212 let classes: Vec<(u32, wgpu::Buffer)> = m
213 .classes
214 .iter()
215 .map(|c| (c.len() as u32, mk_u32("cls", c, storage)))
216 .collect();
217
218
219 let readback = dev.create_buffer(&wgpu::BufferDescriptor {
220 label: Some("readback"),
221 size: (state.len() * 4) as u64,
222 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
223 mapped_at_creation: false,
224 });
225
226 let module = dev.create_shader_module(wgpu::ShaderModuleDescriptor {
227 label: Some("sweep"),
228 source: wgpu::ShaderSource::Wgsl(sweep_shader().into()),
229 });
230
231 // An EXPLICIT layout, because binding 0 needs `has_dynamic_offset`. An auto-derived layout
232 // cannot express that, and without it every dispatch needs its own params buffer, its own
233 // bind group and -- fatally -- its own submit.
234 //
235 // That is what the first version did, and it made the GPU slower than the CPU at every
236 // size: 200 sweeps over 2 colour classes is 400 submits, each a driver round trip, and the
237 // measured time was ~60 ms almost independent of node count. Constant time under a growing
238 // workload is the signature of paying for round trips rather than arithmetic.
239 let sto = |ro: bool| wgpu::BindingType::Buffer {
240 ty: wgpu::BufferBindingType::Storage { read_only: ro },
241 has_dynamic_offset: false,
242 min_binding_size: None,
243 };
244 let entry = |binding: u32, ty: wgpu::BindingType| wgpu::BindGroupLayoutEntry {
245 binding,
246 visibility: wgpu::ShaderStages::COMPUTE,
247 ty,
248 count: None,
249 };
250 let layout = dev.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
251 label: Some("sweep"),
252 entries: &[
253 entry(0, wgpu::BindingType::Buffer {
254 ty: wgpu::BufferBindingType::Uniform,
255 has_dynamic_offset: true,
256 min_binding_size: wgpu::BufferSize::new(PARAMS_BYTES),
257 }),
258 entry(1, sto(true)),
259 entry(2, sto(true)),
260 entry(3, sto(true)),
261 entry(4, sto(true)),
262 entry(5, sto(false)),
263 entry(6, sto(false)),
264 ],
265 });
266 let pipeline_layout = dev.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
267 label: Some("sweep"),
268 bind_group_layouts: &[Some(&layout)],
269 immediate_size: 0,
270 });
271 let pipeline = dev.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
272 label: Some("sweep"),
273 layout: Some(&pipeline_layout),
274 module: &module,
275 entry_point: Some("sweep"),
276 compilation_options: Default::default(),
277 cache: None,
278 });
279
280 // Every dispatch's params, written once into one buffer at the alignment the device
281 // requires, then selected by dynamic offset. The step counter advances per dispatch --
282 // it feeds the shader's counter-based RNG, and repeating it would make every class
283 // resample with the same draws and the chain stop mixing.
284 let stride = align_up(PARAMS_BYTES, dev.limits().min_uniform_buffer_offset_alignment as u64);
285 let live: Vec<usize> = (0..classes.len()).filter(|&i| classes[i].0 > 0).collect();
286 if live.is_empty() {
287 return Err("every colour class is empty; there is nothing to sample".into());
288 }
289 let steps = sweeps as usize * live.len();
290 let mut params = vec![0u8; steps * stride as usize];
291 for s in 0..sweeps as usize {
292 for (li, &ci) in live.iter().enumerate() {
293 // Knuth's multiplicative constant: mixes the seed across the whole u32 range and
294 // maps 0 to 0, which is what keeps the unseeded path exactly where it was.
295 let offset = (seed as u32).wrapping_mul(0x9E37_79B9);
296 let step = offset.wrapping_add((s * live.len() + li + 1) as u32);
297 let at = (s * live.len() + li) * stride as usize;
298 let p = &mut params[at..at + PARAMS_BYTES as usize];
299 p[0..4].copy_from_slice(&m.n.to_le_bytes());
300 p[4..8].copy_from_slice(&m.k.to_le_bytes());
301 p[8..12].copy_from_slice(&classes[ci].0.to_le_bytes());
302 p[12..16].copy_from_slice(&step.to_le_bytes());
303 p[16..20].copy_from_slice(&(beta as f32).to_le_bytes());
304 }
305 }
306 let b_params = dev.create_buffer_init(&wgpu::util::BufferInitDescriptor {
307 label: Some("params"),
308 contents: ¶ms,
309 usage: wgpu::BufferUsages::UNIFORM,
310 });
311
312 // One bind group per colour class, created once rather than per dispatch. Only the class
313 // buffer differs between them; the dynamic offset carries everything else.
314 let binds: Vec<wgpu::BindGroup> = live
315 .iter()
316 .map(|&ci| {
317 dev.create_bind_group(&wgpu::BindGroupDescriptor {
318 label: Some("sweep"),
319 layout: &layout,
320 entries: &[
321 wgpu::BindGroupEntry {
322 binding: 0,
323 resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
324 buffer: &b_params,
325 offset: 0,
326 size: wgpu::BufferSize::new(PARAMS_BYTES),
327 }),
328 },
329 wgpu::BindGroupEntry { binding: 1, resource: b_nbr.as_entire_binding() },
330 wgpu::BindGroupEntry { binding: 2, resource: b_w.as_entire_binding() },
331 wgpu::BindGroupEntry { binding: 3, resource: b_h.as_entire_binding() },
332 wgpu::BindGroupEntry { binding: 4, resource: classes[ci].1.as_entire_binding() },
333 wgpu::BindGroupEntry { binding: 5, resource: b_spin.as_entire_binding() },
334 wgpu::BindGroupEntry { binding: 6, resource: b_dbg.as_entire_binding() },
335 ],
336 })
337 })
338 .collect();
339
340 // ONE encoder, ONE pass, ONE submit for the whole run. Dispatches inside a pass execute in
341 // order and each sees the previous one's writes, which is what makes the chromatic schedule
342 // correct without a barrier between them.
343 let mut enc = dev.create_command_encoder(&Default::default());
344 {
345 let mut pass = enc.begin_compute_pass(&Default::default());
346 pass.set_pipeline(&pipeline);
347 for s in 0..sweeps as usize {
348 for (li, &ci) in live.iter().enumerate() {
349 let off = ((s * live.len() + li) * stride as usize) as u32;
350 pass.set_bind_group(0, &binds[li], &[off]);
351 pass.dispatch_workgroups(classes[ci].0.div_ceil(WORKGROUP), 1, 1);
352 }
353 }
354 }
355 enc.copy_buffer_to_buffer(&b_spin, 0, &readback, 0, (state.len() * 4) as u64);
356 self.queue.submit(Some(enc.finish()));
357
358 let slice = readback.slice(..);
359 let (tx, rx) = std::sync::mpsc::channel();
360 slice.map_async(wgpu::MapMode::Read, move |r| {
361 let _ = tx.send(r);
362 });
363 self.device.poll(wgpu::PollType::wait_indefinitely()).map_err(|e| format!("device poll failed: {e:?}"))?;
364 rx.recv()
365 .map_err(|_| "the readback never completed".to_string())?
366 .map_err(|e| format!("the readback failed: {e:?}"))?;
367
368 {
369 let data = slice.get_mapped_range().map_err(|e| format!("mapping failed: {e:?}"))?;
370 for (i, &chunk) in data.as_chunks::<4>().0.iter().enumerate().take(spins.len()) {
371 let v = i32::from_le_bytes(chunk);
372 // Not `if v > 0 { 1 } else { -1 }`. That coercion turns any garbage — a dropped
373 // dispatch, a short copy — into a valid-looking state which is then scored with
374 // full confidence. The browser had exactly this bug; refusing is the whole point.
375 if v != 1 && v != -1 {
376 return Err(format!("the GPU returned {v} at spin {i}; states are +1/-1"));
377 }
378 spins[i] = v as i8;
379 }
380 }
381 readback.unmap();
382 Ok(())
383 }
384}
385
386/// Must match `@workgroup_size` in the shader. The core crate owns that number; if it ever changes
387/// there, `the_workgroup_size_matches_the_shader` fails here rather than the dispatch quietly
388/// covering the wrong number of lanes.
389const WORKGROUP: u32 = 64;
390
391/// Bytes in the shader's `Params` uniform: two vec4s.
392const PARAMS_BYTES: u64 = 32;
393
394/// Round `v` up to a multiple of `to`. Uniform dynamic offsets must land on the device's
395/// `min_uniform_buffer_offset_alignment`, which is 256 on most hardware and validated, not ignored.
396fn align_up(v: u64, to: u64) -> u64 {
397 v.div_ceil(to) * to
398}
399
400fn bytes_u32(v: &[u32]) -> &[u8] {
401 // Safe: u32 has no padding and no invalid bit patterns, and the slice is read-only.
402 unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
403}
404fn bytes_i32(v: &[i32]) -> &[u8] {
405 unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
406}
407fn bytes_f32(v: &[f32]) -> &[u8] {
408 unsafe { std::slice::from_raw_parts(v.as_ptr() as *const u8, std::mem::size_of_val(v)) }
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use ferrotherm::wgsl::GpuModel;
415 use ferrotherm::gibbs::Sampler;
416 use ferrotherm::ising::lattice2d;
417
418 /// Skip rather than fail where there is no adapter. A headless runner having no driver is not
419 /// a defect in this crate, and a red suite that means "this machine has no GPU" trains people
420 /// to ignore it.
421 macro_rules! gpu_or_skip {
422 () => {
423 match Gpu::new() {
424 Some(g) => g,
425 None => {
426 eprintln!("no GPU adapter on this machine; skipping");
427 return;
428 }
429 }
430 };
431 }
432
433 #[test]
434 fn the_workgroup_size_matches_the_shader() {
435 // A dispatch count computed from the wrong workgroup size covers too few lanes, and the
436 // nodes it misses simply never update -- silently, with the run reporting success.
437 let src = ferrotherm::wgsl::sweep_shader();
438 assert!(
439 src.contains(&format!("@workgroup_size({WORKGROUP})")),
440 "this crate dispatches in groups of {WORKGROUP}; the shader says otherwise"
441 );
442 }
443
444 #[test]
445 fn a_ferromagnet_orders_at_low_temperature_and_melts_at_high() {
446 // The physics check, not a bit-comparison. The shader's RNG is a counter hash of
447 // (step, node) and the CPU sampler has its own stream, so they cannot agree flip for flip.
448 // What they must agree on is the phase.
449 let gpu = gpu_or_skip!();
450 let g = lattice2d(16, 1.0);
451 let m = GpuModel::from_graph(&g);
452
453 let mag = |beta: f64| {
454 let mut s = vec![1i8; 256];
455 gpu.sweep(&m, &mut s, beta, 400).unwrap();
456 (s.iter().map(|&x| x as f64).sum::<f64>() / 256.0).abs()
457 };
458
459 let cold = mag(1.0);
460 let hot = mag(0.05);
461 assert!(cold > 0.8, "a ferromagnet at beta=1 should be ordered, got |m| = {cold:.3}");
462 assert!(hot < 0.4, "and disordered at beta=0.05, got |m| = {hot:.3}");
463 }
464
465 #[test]
466 fn the_gpu_reproduces_the_exact_mean_energy() {
467 // Against EXACT physics, not against the CPU sampler. My first version of this test
468 // compared the two samplers at beta = 0.44 and they disagreed by 0.55 per site -- because
469 // 0.4407 is the 2D Ising critical point, where correlation times are long, and the two
470 // chains started from opposite ends (all-up versus random). Each stayed near where it
471 // began. That measured initialisation bias in both, not a discrepancy between them, and
472 // the test would have been "wrong" no matter which sampler was correct.
473 //
474 // Variable elimination gives the true answer on a small lattice, and
475 // E = -d(ln Z)/d(beta) is a two-point finite difference away from `log_partition`.
476 let gpu = gpu_or_skip!();
477 let g = lattice2d(4, 1.0);
478 let n = 16.0;
479 let solver = ferrotherm::exact::Elimination { max_width: 20 };
480
481 let ln_z = |beta: f64| solver.log_partition(&g, beta).unwrap().log_z.expect("log_partition returns log_z");
482 let beta = 0.7; // well below T_c: fast mixing, so a finite chain is actually equilibrated
483 let h = 1e-3;
484 let exact_per_site = -(ln_z(beta + h) - ln_z(beta - h)) / (2.0 * h) / n;
485
486 // Average over independent runs: one chain's energy fluctuates about the mean, and a
487 // single sample of a fluctuating quantity is not an estimate of its mean.
488 let runs = 24;
489 let mut total = 0.0;
490 for r in 0..runs {
491 let m = GpuModel::from_graph(&g);
492 // Start from a different state each run so the average is not anchored to one basin.
493 let mut s: Vec<i8> = (0..16).map(|i| if (i + r) % 2 == 0 { 1 } else { -1 }).collect();
494 gpu.sweep(&m, &mut s, beta, 400).unwrap();
495 total += g.energy(&s);
496 }
497 let got = total / runs as f64 / n;
498
499 assert!(
500 (got - exact_per_site).abs() < 0.12,
501 "GPU {got:.4} vs exact {exact_per_site:.4} per site at beta {beta} -- the shader is \
502 sampling a different distribution from the one the model defines"
503 );
504 }
505
506 #[test]
507 fn the_gpu_and_the_cpu_agree_away_from_criticality() {
508 // The two samplers, compared where the comparison is meaningful: beta = 0.7 is well below
509 // T_c (0.4407), so both chains equilibrate inside the budget and their means are
510 // comparable. Both start from the SAME state, so any difference is the sampler rather than
511 // where it began.
512 let gpu = gpu_or_skip!();
513 let g = lattice2d(12, 1.0);
514 let n = 144.0;
515 let beta = 0.7;
516 let start: Vec<i8> = (0..144).map(|i| if i % 2 == 0 { 1 } else { -1 }).collect();
517
518 let m = GpuModel::from_graph(&g);
519 let mut s = start.clone();
520 gpu.sweep(&m, &mut s, beta, 800).unwrap();
521 let e_gpu = g.energy(&s) / n;
522
523 let mut sim = Sampler::new(&g, beta, 7);
524 sim.s = start;
525 sim.sweeps(800, None);
526 let e_cpu = g.energy(&sim.s) / n;
527
528 assert!(
529 (e_gpu - e_cpu).abs() < 0.12,
530 "GPU {e_gpu:.4} vs CPU {e_cpu:.4} per site -- two implementations of one update rule"
531 );
532 }
533
534 #[test]
535 fn a_state_that_is_not_plus_or_minus_one_is_refused_rather_than_coerced() {
536 // The length guard, which is the reachable half of the same discipline: a mismatched
537 // state is refused instead of being padded into something plausible.
538 let gpu = gpu_or_skip!();
539 let g = lattice2d(4, 1.0);
540 let m = GpuModel::from_graph(&g);
541 let mut wrong = vec![1i8; 9];
542 let e = gpu.sweep(&m, &mut wrong, 0.5, 1).unwrap_err();
543 assert!(e.contains("16 nodes") && e.contains('9'), "must name both counts: {e}");
544 }
545
546 #[test]
547 fn a_bad_temperature_is_refused_by_name() {
548 let gpu = gpu_or_skip!();
549 let g = lattice2d(4, 1.0);
550 let m = GpuModel::from_graph(&g);
551 let mut s = vec![1i8; 16];
552 for bad in [f64::NAN, f64::INFINITY, -1.0] {
553 let e = gpu.sweep(&m, &mut s, bad, 1).unwrap_err();
554 assert!(e.contains("beta"), "{bad} should be refused by name, got: {e}");
555 }
556 }
557}