use crate::dense::Padded;
use crate::graph::Graph;
pub struct GpuModel {
pub n: u32,
pub k: u32,
pub nbr: Vec<u32>,
pub w: Vec<f32>,
pub h: Vec<f32>,
pub classes: Vec<Vec<u32>>,
}
impl GpuModel {
pub fn from_graph(g: &Graph) -> GpuModel {
let d = Padded::from_graph(g);
GpuModel {
n: g.n as u32,
k: d.k as u32,
nbr: d.nbr,
w: d.w.iter().map(|&x| x as f32).collect(),
h: d.h.iter().map(|&x| x as f32).collect(),
classes: g.classes.iter().map(|c| c.to_vec()).collect(),
}
}
pub fn bytes(&self) -> usize {
let nk = (self.n as usize) * (self.k as usize);
nk * 4 + nk * 4 + (self.n as usize) * 4 + (self.n as usize) * 4
}
}
pub const WORKGROUP: u32 = 64;
pub fn sweep_shader() -> String {
format!(
r#"// ferrotherm: chromatic block-Gibbs sweep.
// Generated. One invocation per node of the active colour class.
// Nodes sharing a colour share no edges, so this is race-free by construction, not by locking.
// vec4 members, not eight scalars. A struct of scalars has 4-byte alignment, and the uniform
// address space requires 16. That mismatch does not fail loudly: the pipeline is simply invalid and
// every dispatch becomes a silent no-op, so the shader appears to run and changes nothing. Vectors
// carry 16-byte alignment by construction, which makes the layout correct rather than merely
// accepted.
struct Params {{
dims: vec4<u32>, // n, k, class_len, step
ctl: vec4<f32>, // beta, unused, unused, unused
}};
@group(0) @binding(0) var<uniform> P: Params;
@group(0) @binding(1) var<storage, read> nbr: array<u32>;
@group(0) @binding(2) var<storage, read> w: array<f32>;
@group(0) @binding(3) var<storage, read> h: array<f32>;
// `class` is a RESERVED KEYWORD in WGSL, so this is `cls`.
@group(0) @binding(4) var<storage, read> cls: array<u32>;
@group(0) @binding(5) var<storage, read_write> spin: array<i32>;
// The local field each lane computed. One extra store per node, and it means the shader being
// inspected is the shader that runs rather than a debug copy that might differ.
@group(0) @binding(6) var<storage, read_write> dbg: array<f32>;
// Counter-based RNG. A pure function of (seed, node, step), so a lane needs no state and the run
// reproduces regardless of the order lanes happen to execute in.
fn hash(a0: u32, b0: u32, c0: u32) -> u32 {{
// WGSL requires parentheses when mixing * and ^; it will not guess a precedence.
var x: u32 = (a0 * 0x9E3779B9u) ^ (b0 * 0x85EBCA6Bu) ^ (c0 * 0xC2B2AE35u);
x = x ^ (x >> 16u);
x = x * 0x7FEB352Du;
x = x ^ (x >> 15u);
x = x * 0x846CA68Bu;
x = x ^ (x >> 16u);
return x;
}}
fn unit(a0: u32, b0: u32, c0: u32) -> f32 {{
// 24 bits into [0,1); f32 has 24 bits of mantissa, so asking for more would be theatre
return f32(hash(a0, b0, c0) >> 8u) * (1.0 / 16777216.0);
}}
@compute @workgroup_size({wg})
fn sweep(@builtin(global_invocation_id) gid: vec3<u32>) {{
let t = gid.x;
if (t >= P.dims.z) {{ return; }}
let i = cls[t];
// local field: sum_j J_ij s_j + h_i. Padded slots carry weight 0.0 and contribute nothing.
var f: f32 = h[i];
let base = i * P.dims.y;
for (var s: u32 = 0u; s < P.dims.y; s = s + 1u) {{
let idx = base + s;
f = f + w[idx] * f32(spin[nbr[idx]]);
}}
dbg[i] = f;
// the one update: P(s_i = +1) = sigma(2 beta f)
let p = 1.0 / (1.0 + exp(-2.0 * P.ctl.x * f));
if (unit(P.dims.w, i, 0x5BF03635u) < p) {{
spin[i] = 1;
}} else {{
spin[i] = -1;
}}
}}
"#,
wg = WORKGROUP
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shader_states_the_same_update_as_the_kernel() {
let src = sweep_shader();
assert!(
src.contains("1.0 / (1.0 + exp(-2.0 * P.ctl.x * f))"),
"the sigmoid must be sigma(2*beta*f); anything else samples a different temperature"
);
assert!(src.contains("spin[i] = 1;") && src.contains("spin[i] = -1;"), "states are -1/+1");
}
#[test]
fn the_emitted_shader_is_syntactically_plausible() {
let src = sweep_shader();
assert_eq!(src.matches('{').count(), src.matches('}').count(), "unbalanced braces");
for needed in [
"vec4<u32>",
"@compute",
"@workgroup_size(64)",
"fn sweep(",
"var<storage, read_write> spin",
"var<uniform> P",
] {
assert!(src.contains(needed), "missing {needed}");
}
for b in 0..=6 {
assert_eq!(
src.matches(&format!("@binding({b})")).count(),
1,
"binding {b} should appear exactly once"
);
}
}
#[test]
fn the_model_matches_the_padded_layout() {
let g = crate::ising::lattice2d(8, 1.0);
let m = GpuModel::from_graph(&g);
assert_eq!(m.n, 64);
assert_eq!(m.k, 4, "a square lattice has degree 4");
assert_eq!(m.nbr.len(), 64 * 4);
assert_eq!(m.w.len(), 64 * 4);
assert_eq!(m.classes.len(), 2, "a bipartite lattice needs two colours");
let total: usize = m.classes.iter().map(|c| c.len()).sum();
assert_eq!(total, 64, "every node belongs to exactly one class");
}
#[test]
fn the_f32_field_stays_within_the_sigmoid_width() {
let g = crate::device::z1_grid(8, 8, 1.0, 0.2);
let m = GpuModel::from_graph(&g);
let d = crate::dense::Padded::from_graph(&g);
let mut rng = crate::rng::Pcg::new(4, 0);
let s: Vec<i8> = (0..g.n).map(|_| if rng.f64() < 0.5 { 1 } else { -1 }).collect();
let mut worst: f64 = 0.0;
for i in 0..g.n {
let exact = d.field(i, &s);
let mut f32_sum = m.h[i];
for slot in 0..m.k as usize {
let t = i * m.k as usize + slot;
f32_sum += m.w[t] * s[m.nbr[t] as usize] as f32;
}
let pa = crate::kernel::p_up(exact, 1.0);
let pb = crate::kernel::p_up(f32_sum as f64, 1.0);
worst = worst.max((pa - pb).abs());
}
assert!(worst < 1e-6, "f32 accumulation shifted a probability by {worst}");
}
#[test]
fn padding_needs_no_mask_on_the_gpu() {
let mut b = crate::graph::GraphBuilder::new(20);
for j in 1..20 {
b.couple(0, j, 1.0); }
let g = b.build();
let m = GpuModel::from_graph(&g);
let d = crate::dense::Padded::from_graph(&g);
for t in 0..(m.n as usize * m.k as usize) {
if d.active[t] == 0 {
assert_eq!(m.w[t], 0.0);
}
}
}
}
#[cfg(test)]
mod wgsl_language_rules {
use super::sweep_shader;
const RESERVED: [&str; 12] = [
"class", "enum", "typedef", "union", "template", "interface", "private", "public",
"shared", "namespace", "static", "match",
];
#[test]
fn no_binding_is_named_with_a_reserved_word() {
let src = sweep_shader();
for line in src.lines().filter(|l| l.contains("@binding")) {
let name = line.split_whitespace().last().unwrap_or("").trim_end_matches(':');
let name = name.split(':').next().unwrap_or("");
for r in RESERVED {
assert_ne!(name, r, "binding named with the reserved word `{r}`: {line}");
}
}
assert!(!src.contains(" class:"), "`class` is reserved in WGSL");
assert!(src.contains("cls"), "the colour-class binding should be `cls`");
}
#[test]
fn bitwise_and_arithmetic_are_parenthesised() {
let src = sweep_shader();
for line in src.lines() {
if line.contains('^') && line.contains('*') {
let body = line.split("//").next().unwrap_or("");
if body.contains('^') && body.contains('*') {
assert!(
body.contains(") ^ ("),
"mixing * and ^ needs parentheses: {line}"
);
}
}
}
}
}