use ferrotherm::fabric::{Device, Fabric, Precision, Unsupported};
use ferrotherm::ftp::Program;
use ferrotherm::ledger::{Ledger, Prices};
use ferrotherm::rng::Pcg;
use ferrotherm::schedule::Schedule;
use ferrotherm::graph::Graph;
use ferrotherm::wgsl::GpuModel;
pub struct GpuDevice {
gpu: crate::Gpu,
model: Option<GpuModel>,
graph: Option<Graph>,
state: Vec<i8>,
ledger: Ledger,
}
impl GpuDevice {
pub fn open() -> Option<GpuDevice> {
Some(GpuDevice::with(crate::Gpu::new()?))
}
pub fn with(gpu: crate::Gpu) -> GpuDevice {
GpuDevice { gpu, model: None, graph: None, state: Vec::new(), ledger: Ledger::default() }
}
pub fn adapter(&self) -> &wgpu::AdapterInfo {
self.gpu.adapter()
}
#[must_use = "false means a software rasteriser, whose timings say nothing about a GPU"]
pub fn is_hardware(&self) -> bool {
self.gpu.is_hardware()
}
}
impl Device for GpuDevice {
fn fabric(&self) -> Fabric {
let mut f = Fabric::unconstrained("gpu", Prices::UNSTATED);
f.max_arity = 2;
f.coupling_precision = Precision::Float { mantissa: 24 };
f.field_precision = Precision::Float { mantissa: 24 };
f.unstated = &[
"per-operation energy: GPU vendors publish board power (a rate for the whole card), \
not joules per spin update. Measure it with ferrotherm-meter on the machine that ran \
the work.",
];
f
}
fn program(&mut self, p: &Program) -> Vec<Unsupported> {
let bad = self.fabric().check(p);
if !bad.is_empty() {
return bad;
}
match p.to_graph() {
Ok(g) => {
self.model = Some(GpuModel::from_graph(&g));
self.state = vec![-1; g.n];
self.ledger.writes += g.n as u64;
self.graph = Some(g);
Vec::new()
}
Err(e) => vec![Unsupported::Unplaceable { detail: e.to_string() }],
}
}
fn run(&mut self, schedule: &Schedule, seed: u64) -> Result<Vec<i8>, String> {
let m = self.model.as_ref().ok_or("no program loaded")?;
let g = self.graph.as_ref().ok_or("no program loaded")?;
if schedule.is_empty() {
return Err("an empty schedule runs nothing; give it at least one stage".into());
}
let mut rng = Pcg::new(seed, 0x5EED);
self.state = (0..g.n).map(|_| rng.spin(0.5)).collect();
let mut best = self.state.clone();
let mut best_e = g.energy(&best);
for (i, st) in schedule.stages().iter().enumerate() {
let stage_seed = seed.wrapping_add(i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
self.gpu.sweep_seeded(m, &mut self.state, st.beta, st.sweeps as u32, stage_seed)?;
self.ledger.samples += m.n as u64 * st.sweeps as u64;
let e = g.energy(&self.state);
if e < best_e {
best_e = e;
best = self.state.clone();
}
}
self.state = best.clone();
Ok(best)
}
fn ledger(&self) -> Ledger {
self.ledger
}
}
#[cfg(test)]
mod tests {
use super::*;
use ferrotherm::ising::lattice2d;
macro_rules! dev_or_skip {
() => {
match GpuDevice::open() {
Some(d) => d,
None => {
eprintln!("no GPU adapter on this machine; skipping");
return;
}
}
};
}
#[test]
fn conform_can_finally_score_the_gpu_path() {
let mut d = dev_or_skip!();
if !d.is_hardware() {
eprintln!("software rasteriser; the physics is still checked, the timings mean nothing");
}
let report = ferrotherm::conform::run(&mut d);
assert!(
report.passed(),
"the GPU path fails conformance:\n{report}\n{}",
report.failures().map(|c| format!(" {} -- {}", c.name, c.detail)).collect::<Vec<_>>().join("\n")
);
}
#[test]
fn the_fabric_declares_f32_rather_than_leaving_it_unsaid() {
let d = dev_or_skip!();
let f = d.fabric();
assert_eq!(f.coupling_precision, Precision::Float { mantissa: 24 });
assert_eq!(f.field_precision, Precision::Float { mantissa: 24 });
assert_eq!(f.max_arity, 2, "it lowers through to_graph, which is pairwise");
assert!(!f.prices.is_stated(), "a GPU publishes board power, not joules per spin update");
assert!(
f.unstated.iter().any(|u| u.contains("per-operation energy")),
"the gap has to be named, not merely left empty: {:?}",
f.unstated
);
}
#[test]
fn the_seed_selects_a_stream_rather_than_being_swallowed() {
let d = dev_or_skip!();
let g = lattice2d(24, 1.0);
let m = GpuModel::from_graph(&g);
let hot = 0.15;
let mut a = vec![1i8; g.n];
let mut b = vec![1i8; g.n];
let mut a_again = vec![1i8; g.n];
d.gpu.sweep_seeded(&m, &mut a, hot, 40, 1).unwrap();
d.gpu.sweep_seeded(&m, &mut b, hot, 40, 2).unwrap();
d.gpu.sweep_seeded(&m, &mut a_again, hot, 40, 1).unwrap();
assert_ne!(a, b, "two seeds produced identical states; the seed is being ignored");
assert_eq!(a, a_again, "the same seed must reproduce; this is a seed, not just noise");
let mut viaseed = vec![1i8; g.n];
let mut unseeded = vec![1i8; g.n];
d.gpu.sweep_seeded(&m, &mut viaseed, hot, 40, 0).unwrap();
d.gpu.sweep(&m, &mut unseeded, hot, 40).unwrap();
assert_eq!(viaseed, unseeded, "seed 0 must be the stream that existed before seeding");
}
#[test]
fn the_device_threads_its_seed_through_to_the_sampler() {
let mut d = dev_or_skip!();
let inst = ferrotherm::planted::frustrated_loops(12, 24, 5);
let p = Program::from_graph(&inst.graph, &Schedule::default());
assert!(d.program(&p).is_empty());
let hot = Schedule::constant(0.25, 30);
let a = d.run(&hot, 11).unwrap();
let b = d.run(&hot, 22).unwrap();
assert!(
inst.graph.energy(&a) < 0.0 && inst.graph.energy(&b) < 0.0,
"both runs should have moved off the initial state at all"
);
assert_ne!(a, b, "two seeds gave the same trajectory; the device is not threading the seed");
}
#[test]
fn the_write_is_charged_because_that_is_the_term_the_ledger_rests_on() {
let mut d = dev_or_skip!();
assert_eq!(d.ledger().writes, 0);
let g = lattice2d(16, 1.0);
let p = Program::from_graph(&g, &Schedule::default());
assert!(d.program(&p).is_empty());
assert_eq!(d.ledger().writes, g.n as u64, "one write per node flashed");
assert_eq!(d.ledger().samples, 0, "loading is not sampling");
d.run(&Schedule::constant(0.6, 10), 3).unwrap();
assert_eq!(d.ledger().samples, g.n as u64 * 10, "one sample per node per sweep");
}
#[test]
fn running_without_a_program_is_an_error_not_an_empty_state() {
let mut d = dev_or_skip!();
let e = d.run(&Schedule::constant(0.6, 10), 1).unwrap_err();
assert!(e.contains("no program"), "{e}");
let g = lattice2d(8, 1.0);
assert!(d.program(&Program::from_graph(&g, &Schedule::default())).is_empty());
let e2 = d.run(&Schedule::new(), 1).unwrap_err();
assert!(e2.contains("empty schedule"), "{e2}");
}
}