Skip to main content

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: &params,
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}