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: ¶ms,
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}