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