Skip to main content

qualia_core_db/platform/
device_benchmark.rs

1//! STELLAR §A AH-track H1(a) — **cross-circuit capability benchmark** (decisions D26/D30).
2//!
3//! "Rank by *measured* throughput, not a static device-type hierarchy" (Timothy). This runs an
4//! identical representative GEMV on **every available compute circuit** — each wgpu adapter
5//! (discrete GPU, integrated GPU) plus a native-Rust CPU path — and produces a **capability
6//! matrix** sorted fastest-first. The residency/device planner (H2) consumes this to decide where
7//! work goes; e.g. a weak iGPU can still beat PCIe-streaming for overflow, and a many-core CPU can
8//! beat an old iGPU — only the numbers decide.
9//!
10//! Scope (honest): GPUs/iGPU via wgpu; CPU via a `rayon` GEMV. **NPU is not benchmarked** — NPU
11//! access is a platform API (DirectML / NNAPI / CoreML), not wgpu, and is reported as "not probed".
12//! This is part (a) of H1 (probe + matrix); the human-key *signing* of the passport (part (b)) is
13//! blocked on the identity remediation (`identity-governance-remediation.md`) and lives elsewhere.
14//!
15//! Native only.
16#![cfg(not(target_arch = "wasm32"))]
17
18use serde::{Deserialize, Serialize};
19use std::io::{Read, Write};
20use std::process::Command;
21use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
22use std::time::Instant;
23
24const GEMV_BENCH_WGSL: &str = include_str!("../shaders/gemv_bench.wgsl");
25
26/// A compute circuit's class in the capability matrix.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28pub enum CircuitKind {
29    DiscreteGpu,
30    IntegratedGpu,
31    Cpu,
32    Npu,
33    Other,
34}
35
36impl CircuitKind {
37    fn from_wgpu(t: wgpu::DeviceType) -> Self {
38        match t {
39            wgpu::DeviceType::DiscreteGpu => Self::DiscreteGpu,
40            wgpu::DeviceType::IntegratedGpu => Self::IntegratedGpu,
41            wgpu::DeviceType::Cpu => Self::Cpu,
42            _ => Self::Other,
43        }
44    }
45}
46
47/// One benchmarked circuit.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct CircuitBench {
50    pub label: String,
51    pub kind: CircuitKind,
52    pub backend: String,
53    /// Milliseconds per GEMV dispatch (lower is faster).
54    pub ms_per_gemv: f64,
55    /// Effective GFLOP/s for the GEMV (2·n·n flops / time).
56    pub gflops: f64,
57    /// Host→device upload bandwidth (GB/s) for a representative buffer. The **transfer axis** (D31):
58    /// PCIe for a discrete GPU; staging-path for an iGPU (wgpu can't show true zero-copy → relative
59    /// signal); `f64::INFINITY` for the CPU (data is already in its pool — no transfer). Decode that
60    /// streams weights to a device pays this every token; in-pool compute does not.
61    pub upload_gbps: f64,
62    /// Relative score in [0,1]: fastest circuit = 1.0, others = fastest_ms / this_ms
63    /// (or highest decode_proxy_tok_s when decode ranking is active).
64    pub rel_score: f64,
65    /// Optional real-decode proxy (tok/s) from a short resident decode on a small model.
66    /// When present for ≥1 GPU circuit, passport ranking prefers this over GEMV µs.
67    #[serde(default, skip_serializing_if = "Option::is_none")]
68    pub decode_proxy_tok_s: Option<f64>,
69}
70
71/// Stable process boundary for one physical adapter/backend benchmark.
72#[derive(Debug, Clone, Serialize, Deserialize)]
73pub struct DeviceBenchmarkRequest {
74    pub backend: String,
75    pub vendor: u32,
76    pub device: u32,
77    pub gemv_n: usize,
78}
79
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct DeviceBenchmarkResponse {
82    pub request: DeviceBenchmarkRequest,
83    pub bench: Option<CircuitBench>,
84    pub error: Option<String>,
85}
86
87#[cfg(not(test))]
88const WORKER_ENV: &str = "QUALIA_DEVICE_BENCHMARK_WORKER";
89const WORKER_OUTPUT_ENV: &str = "QUALIA_DEVICE_BENCHMARK_OUTPUT";
90static WORKER_SEQUENCE: AtomicU64 = AtomicU64::new(0);
91
92/// The measured capability matrix — circuits sorted fastest-first. This IS the priority order.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct CapabilityMatrix {
95    pub circuits: Vec<CircuitBench>,
96    pub gemv_n: usize,
97    /// NPU left unprobed (no portable compute path); recorded for honesty.
98    pub npu_probed: bool,
99}
100
101impl CapabilityMatrix {
102    /// Highest-throughput circuit, if any.
103    pub fn best(&self) -> Option<&CircuitBench> {
104        self.circuits.first()
105    }
106
107    pub fn summary(&self) -> String {
108        let mut s = format!(
109            "CapabilityMatrix (GEMV {n}x{n}, ranked by measured throughput; NPU probed={npu}):\n",
110            n = self.gemv_n,
111            npu = self.npu_probed
112        );
113        for (i, c) in self.circuits.iter().enumerate() {
114            let upload = if c.upload_gbps.is_infinite() {
115                "in-pool".to_string()
116            } else {
117                format!("{:.1} GB/s up", c.upload_gbps)
118            };
119            let decode = match c.decode_proxy_tok_s {
120                Some(t) => format!("{t:.2} tok/s"),
121                None => "—".into(),
122            };
123            s.push_str(&format!(
124                "  {}. {:<28} [{:?}/{}] {:>8.3} ms  {:>7.1} GFLOP/s  {:>12}  decode {:>10}  score {:.3}\n",
125                i + 1,
126                c.label,
127                c.kind,
128                c.backend,
129                c.ms_per_gemv,
130                c.gflops,
131                upload,
132                decode,
133                c.rel_score,
134            ));
135        }
136        s
137    }
138
139    /// Re-rank circuits: prefer higher `decode_proxy_tok_s` when present on any GPU row;
140    /// otherwise keep GEMV ranking. CPU rows without decode stay at the bottom of GPU ranking.
141    pub fn apply_decode_proxy_ranking(&mut self) {
142        let any_decode = self
143            .circuits
144            .iter()
145            .any(|c| c.decode_proxy_tok_s.is_some() && c.kind != CircuitKind::Cpu);
146        if !any_decode {
147            return;
148        }
149        self.circuits.sort_by(|a, b| {
150            let ta = a.decode_proxy_tok_s.unwrap_or(-1.0);
151            let tb = b.decode_proxy_tok_s.unwrap_or(-1.0);
152            // Higher tok/s first; unmeasured (-1) after measured.
153            match tb.partial_cmp(&ta).unwrap_or(std::cmp::Ordering::Equal) {
154                std::cmp::Ordering::Equal => a
155                    .ms_per_gemv
156                    .partial_cmp(&b.ms_per_gemv)
157                    .unwrap_or(std::cmp::Ordering::Equal),
158                o => o,
159            }
160        });
161        if let Some(best_t) = self
162            .circuits
163            .iter()
164            .filter_map(|c| c.decode_proxy_tok_s)
165            .fold(None, |acc: Option<f64>, t| {
166                Some(acc.map(|a| a.max(t)).unwrap_or(t))
167            })
168        {
169            for c in &mut self.circuits {
170                c.rel_score = match c.decode_proxy_tok_s {
171                    Some(t) if best_t > 0.0 => t / best_t,
172                    _ => 0.0,
173                };
174            }
175        }
176    }
177}
178
179#[inline]
180fn params_bytes(n_in: u32, n_out: u32) -> [u8; 16] {
181    let mut b = [0u8; 16];
182    b[0..4].copy_from_slice(&n_in.to_le_bytes());
183    b[4..8].copy_from_slice(&n_out.to_le_bytes());
184    b
185}
186
187/// Persistent-pipeline GEMV timing on one wgpu device (ms per dispatch). No readback — we poll to
188/// completion so the timing reflects execution, with submit overhead amortized over K dispatches.
189fn bench_gpu_gemv(device: &wgpu::Device, queue: &wgpu::Queue, n: usize) -> f64 {
190    let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
191        label: Some("gemv_bench"),
192        source: wgpu::ShaderSource::Wgsl(GEMV_BENCH_WGSL.into()),
193    });
194    let pipeline = device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
195        label: Some("gemv_bench_pipeline"),
196        layout: None,
197        module: &shader,
198        entry_point: Some("gemv"),
199        compilation_options: Default::default(),
200        cache: None,
201    });
202    let mk = |contents: &[u8], usage: wgpu::BufferUsages| {
203        let b = device.create_buffer(&wgpu::BufferDescriptor {
204            label: None,
205            size: contents.len().max(4) as u64,
206            usage,
207            mapped_at_creation: false,
208        });
209        if !contents.is_empty() {
210            queue.write_buffer(&b, 0, contents);
211        }
212        b
213    };
214    let input = mk(
215        bytemuck::cast_slice(&vec![0.1f32; n]),
216        wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
217    );
218    let weight = mk(
219        bytemuck::cast_slice(&vec![0.05f32; n * n]),
220        wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
221    );
222    let params = mk(
223        &params_bytes(n as u32, n as u32),
224        wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
225    );
226    let out = device.create_buffer(&wgpu::BufferDescriptor {
227        label: None,
228        size: (n * 4).max(4) as u64,
229        usage: wgpu::BufferUsages::STORAGE,
230        mapped_at_creation: false,
231    });
232    let bind = device.create_bind_group(&wgpu::BindGroupDescriptor {
233        label: None,
234        layout: &pipeline.get_bind_group_layout(0),
235        entries: &[
236            wgpu::BindGroupEntry {
237                binding: 0,
238                resource: input.as_entire_binding(),
239            },
240            wgpu::BindGroupEntry {
241                binding: 1,
242                resource: weight.as_entire_binding(),
243            },
244            wgpu::BindGroupEntry {
245                binding: 2,
246                resource: params.as_entire_binding(),
247            },
248            wgpu::BindGroupEntry {
249                binding: 3,
250                resource: out.as_entire_binding(),
251            },
252        ],
253    });
254    let wg_x = (n as u32).div_ceil(64).max(1);
255    let (k, s) = (16u32, 5u32);
256    let submit_batch = || {
257        let mut enc =
258            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
259        {
260            let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor {
261                label: None,
262                timestamp_writes: None,
263            });
264            pass.set_pipeline(&pipeline);
265            pass.set_bind_group(0, &bind, &[]);
266            for _ in 0..k {
267                pass.dispatch_workgroups(wg_x, 1, 1);
268            }
269        }
270        queue.submit(Some(enc.finish()));
271        let _ = device.poll(wgpu::PollType::wait_indefinitely());
272    };
273    submit_batch(); // warmup
274    let t0 = Instant::now();
275    for _ in 0..s {
276        submit_batch();
277    }
278    t0.elapsed().as_secs_f64() * 1e3 / (k * s) as f64
279}
280
281/// Host→device upload bandwidth (GB/s) for a `bytes`-sized buffer — the transfer axis (D31).
282/// Times `write_buffer` + a flushing submit + `poll(Wait)` so the upload is realized. For a discrete
283/// GPU this is the PCIe cost; for an iGPU it's the wgpu staging path (not the true near-zero of a
284/// unified pool — a relative signal, flagged honestly).
285fn bench_upload_gbps(device: &wgpu::Device, queue: &wgpu::Queue, bytes: usize) -> f64 {
286    let data = vec![0u8; bytes];
287    let buf = device.create_buffer(&wgpu::BufferDescriptor {
288        label: Some("upload_probe"),
289        size: bytes as u64,
290        usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
291        mapped_at_creation: false,
292    });
293    let flush = || {
294        let enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
295        queue.submit(Some(enc.finish()));
296        let _ = device.poll(wgpu::PollType::wait_indefinitely());
297    };
298    queue.write_buffer(&buf, 0, &data); // warmup
299    flush();
300    let iters = 3u32;
301    let t0 = Instant::now();
302    for _ in 0..iters {
303        queue.write_buffer(&buf, 0, &data);
304        flush();
305    }
306    let secs = t0.elapsed().as_secs_f64() / iters as f64;
307    if secs <= 0.0 {
308        0.0
309    } else {
310        bytes as f64 / secs / 1e9
311    }
312}
313
314/// Native-Rust (`rayon`) GEMV timing (ms per GEMV) — the CPU compute path, fairly multi-threaded.
315fn bench_cpu_gemv(n: usize) -> f64 {
316    use rayon::prelude::*;
317    let weight = vec![0.05f32; n * n];
318    let input = vec![0.1f32; n];
319    let mut out = vec![0.0f32; n];
320    let gemv = |out: &mut [f32]| {
321        out.par_iter_mut().enumerate().for_each(|(i, o)| {
322            let row = &weight[i * n..(i + 1) * n];
323            *o = row.iter().zip(input.iter()).map(|(a, b)| a * b).sum();
324        });
325    };
326    gemv(&mut out); // warmup
327    let iters = 5u32;
328    let t0 = Instant::now();
329    for _ in 0..iters {
330        gemv(&mut out);
331    }
332    t0.elapsed().as_secs_f64() * 1e3 / iters as f64
333}
334
335#[inline]
336fn gflops(n: usize, ms: f64) -> f64 {
337    if ms <= 0.0 {
338        0.0
339    } else {
340        (2.0 * n as f64 * n as f64) / (ms / 1e3) / 1e9
341    }
342}
343
344fn backend_rank(b: wgpu::Backend) -> u8 {
345    match b {
346        wgpu::Backend::Metal => 0,
347        wgpu::Backend::Dx12 => 1,
348        wgpu::Backend::Vulkan => 2,
349        wgpu::Backend::Gl => 3,
350        _ => 4,
351    }
352}
353
354fn backend_name(backend: wgpu::Backend) -> &'static str {
355    match backend {
356        wgpu::Backend::Vulkan => "vulkan",
357        wgpu::Backend::Dx12 => "dx12",
358        wgpu::Backend::Metal => "metal",
359        wgpu::Backend::Gl => "gl",
360        wgpu::Backend::BrowserWebGpu => "browser-webgpu",
361        wgpu::Backend::Noop => "noop",
362    }
363}
364
365fn backend_from_name(name: &str) -> Option<(wgpu::Backend, wgpu::Backends)> {
366    match name {
367        "vulkan" => Some((wgpu::Backend::Vulkan, wgpu::Backends::VULKAN)),
368        "dx12" => Some((wgpu::Backend::Dx12, wgpu::Backends::DX12)),
369        "metal" => Some((wgpu::Backend::Metal, wgpu::Backends::METAL)),
370        "gl" => Some((wgpu::Backend::Gl, wgpu::Backends::GL)),
371        _ => None,
372    }
373}
374
375fn encode_response(
376    path: &std::path::Path,
377    response: &DeviceBenchmarkResponse,
378) -> Result<(), String> {
379    let mut payload = Vec::new();
380    ciborium::into_writer(response, &mut payload).map_err(|e| format!("encode response: {e}"))?;
381    let mut file =
382        std::fs::File::create(path).map_err(|e| format!("create {}: {e}", path.display()))?;
383    file.write_all(&(payload.len() as u64).to_le_bytes())
384        .map_err(|e| e.to_string())?;
385    file.write_all(&payload).map_err(|e| e.to_string())?;
386    file.sync_all().map_err(|e| e.to_string())
387}
388
389fn decode_response(path: &std::path::Path) -> Result<DeviceBenchmarkResponse, String> {
390    let mut file =
391        std::fs::File::open(path).map_err(|e| format!("open {}: {e}", path.display()))?;
392    let mut length = [0u8; 8];
393    file.read_exact(&mut length)
394        .map_err(|e| format!("read response length: {e}"))?;
395    let length = u64::from_le_bytes(length) as usize;
396    if length == 0 || length > 1024 * 1024 {
397        return Err(format!("invalid worker response length {length}"));
398    }
399    let mut payload = vec![0u8; length];
400    file.read_exact(&mut payload)
401        .map_err(|e| format!("read response: {e}"))?;
402    ciborium::from_reader(payload.as_slice()).map_err(|e| format!("decode response: {e}"))
403}
404
405fn benchmark_one(request: &DeviceBenchmarkRequest) -> Result<CircuitBench, String> {
406    let (expected_backend, backends) = backend_from_name(&request.backend)
407        .ok_or_else(|| format!("unsupported backend {}", request.backend))?;
408    let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle();
409    descriptor.backends = backends;
410    let instance = wgpu::Instance::new(descriptor);
411    let adapters = pollster::block_on(instance.enumerate_adapters(backends));
412    let adapter = adapters
413        .into_iter()
414        .find(|adapter| {
415            let info = adapter.get_info();
416            info.backend == expected_backend
417                && info.vendor == request.vendor
418                && info.device == request.device
419        })
420        .ok_or_else(|| {
421            format!(
422                "adapter {:04x}:{:04x}/{} unavailable",
423                request.vendor, request.device, request.backend
424            )
425        })?;
426    let info = adapter.get_info();
427    let (device, queue) =
428        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default()))
429            .map_err(|e| format!("request_device: {e}"))?;
430    let ms = bench_gpu_gemv(&device, &queue, request.gemv_n);
431    let upload_gbps = bench_upload_gbps(&device, &queue, 16 * 1024 * 1024);
432    Ok(CircuitBench {
433        label: format!("{} ({:?})", info.name, info.backend),
434        kind: CircuitKind::from_wgpu(info.device_type),
435        backend: format!("{:?}", info.backend),
436        ms_per_gemv: ms,
437        gflops: gflops(request.gemv_n, ms),
438        upload_gbps,
439        rel_score: 1.0,
440        decode_proxy_tok_s: None,
441    })
442}
443
444/// Worker entry used by the dedicated binary and the unit-test subprocess route.
445pub fn run_worker_from_env() -> Result<(), String> {
446    let request: DeviceBenchmarkRequest = DeviceBenchmarkRequest {
447        backend: std::env::var("QUALIA_DEVICE_BENCHMARK_BACKEND").map_err(|_| "missing backend")?,
448        vendor: std::env::var("QUALIA_DEVICE_BENCHMARK_VENDOR")
449            .map_err(|_| "missing vendor")?
450            .parse()
451            .map_err(|_| "invalid vendor")?,
452        device: std::env::var("QUALIA_DEVICE_BENCHMARK_DEVICE")
453            .map_err(|_| "missing device")?
454            .parse()
455            .map_err(|_| "invalid device")?,
456        gemv_n: std::env::var("QUALIA_DEVICE_BENCHMARK_N")
457            .map_err(|_| "missing n")?
458            .parse()
459            .map_err(|_| "invalid n")?,
460    };
461    let response = match benchmark_one(&request) {
462        Ok(bench) => DeviceBenchmarkResponse {
463            request,
464            bench: Some(bench),
465            error: None,
466        },
467        Err(error) => DeviceBenchmarkResponse {
468            request,
469            bench: None,
470            error: Some(error),
471        },
472    };
473    let output = std::env::var_os(WORKER_OUTPUT_ENV).ok_or("missing worker output path")?;
474    encode_response(std::path::Path::new(&output), &response)
475}
476
477#[cfg(not(test))]
478fn worker_executable() -> Option<std::path::PathBuf> {
479    if let Some(path) = std::env::var_os(WORKER_ENV) {
480        return Some(path.into());
481    }
482    let current = std::env::current_exe().ok()?;
483    let sibling = current.with_file_name(if cfg!(windows) {
484        "qualia-device-benchmark-worker.exe"
485    } else {
486        "qualia-device-benchmark-worker"
487    });
488    if sibling.is_file() {
489        Some(sibling)
490    } else if current
491        .file_stem()
492        .and_then(|s| s.to_str())
493        .is_some_and(|name| name == "qualia-cli" || name == "webizen-desktop")
494    {
495        // Qualia's shipped CLI and desktop hosts expose the same private worker
496        // entry before normal argument/UI initialization, so no sidecar is
497        // required for those packages. Other embedders can set WORKER_ENV.
498        Some(current)
499    } else {
500        None
501    }
502}
503
504fn invoke_worker(request: &DeviceBenchmarkRequest) -> Result<CircuitBench, String> {
505    let sequence = WORKER_SEQUENCE.fetch_add(1, AtomicOrdering::Relaxed);
506    let output = std::env::temp_dir().join(format!(
507        "qualia-device-bench-{}-{sequence}.cbor",
508        std::process::id()
509    ));
510    let mut command;
511    #[cfg(test)]
512    {
513        command = Command::new(std::env::current_exe().map_err(|e| e.to_string())?);
514        command.args([
515            "--exact",
516            "platform::device_benchmark::tests::device_benchmark_worker_entry",
517            "--nocapture",
518        ]);
519    }
520    #[cfg(not(test))]
521    {
522        command = Command::new(
523            worker_executable().ok_or_else(|| format!("worker not found; set {WORKER_ENV}"))?,
524        );
525    }
526    let mut child = command
527        .env("QUALIA_DEVICE_BENCHMARK_BACKEND", &request.backend)
528        .env("QUALIA_DEVICE_BENCHMARK_VENDOR", request.vendor.to_string())
529        .env("QUALIA_DEVICE_BENCHMARK_DEVICE", request.device.to_string())
530        .env("QUALIA_DEVICE_BENCHMARK_N", request.gemv_n.to_string())
531        .env(WORKER_OUTPUT_ENV, &output)
532        .spawn()
533        .map_err(|e| format!("launch worker: {e}"))?;
534    let deadline = Instant::now() + std::time::Duration::from_secs(120);
535    let status = loop {
536        if let Some(status) = child
537            .try_wait()
538            .map_err(|e| format!("wait for worker: {e}"))?
539        {
540            break status;
541        }
542        if Instant::now() >= deadline {
543            let _ = child.kill();
544            let _ = child.wait();
545            let _ = std::fs::remove_file(&output);
546            return Err("worker exceeded 120-second deadline".into());
547        }
548        std::thread::sleep(std::time::Duration::from_millis(25));
549    };
550    let decoded = if status.success() {
551        decode_response(&output)
552    } else {
553        Err(format!("worker exited {status}"))
554    };
555    let _ = std::fs::remove_file(&output);
556    let response = decoded?;
557    if response.request.backend != request.backend
558        || response.request.vendor != request.vendor
559        || response.request.device != request.device
560        || response.request.gemv_n != request.gemv_n
561    {
562        return Err("worker response identity mismatch".into());
563    }
564    if let Some(error) = response.error {
565        return Err(error);
566    }
567    let bench = response
568        .bench
569        .ok_or("worker returned neither result nor error")?;
570    if !bench.ms_per_gemv.is_finite()
571        || bench.ms_per_gemv <= 0.0
572        || !bench.gflops.is_finite()
573        || !bench.upload_gbps.is_finite()
574        || bench.upload_gbps < 0.0
575    {
576        return Err("worker returned invalid metrics".into());
577    }
578    Ok(bench)
579}
580
581/// Benchmark every available compute circuit and return the ranked capability matrix.
582///
583/// `n` is the GEMV side length (representative shape; 2048 is a good fast default).
584/// **Each (vendor, device, backend) triple is benchmarked separately** so DX12 vs Vulkan
585/// (same physical GPU) can rank against each other — the whole point of the passport.
586/// The software/WARP "CPU" wgpu adapter is skipped — the native `rayon` path is the honest CPU number.
587pub fn benchmark_devices(n: usize) -> CapabilityMatrix {
588    let mut circuits: Vec<CircuitBench> = Vec::new();
589
590    // ── GPUs / iGPU via wgpu — one circuit row per backend that can open the device ──
591    let instance = wgpu::Instance::default();
592    let mut cand: Vec<(u8, wgpu::Adapter, wgpu::AdapterInfo)> = Vec::new();
593    for adapter in pollster::block_on(instance.enumerate_adapters(wgpu::Backends::all())) {
594        let info = adapter.get_info();
595        if info.device_type == wgpu::DeviceType::Cpu || info.device == 0 {
596            continue;
597        }
598        cand.push((backend_rank(info.backend), adapter, info));
599    }
600    cand.sort_by_key(|(r, _, _)| *r);
601    // Dedup exact (vendor, device, backend) only — keep Metal+DX12+Vulkan rows for the same card.
602    let mut seen: std::collections::HashSet<(u32, u32, u32)> = std::collections::HashSet::new();
603    let mut chosen: Vec<DeviceBenchmarkRequest> = Vec::new();
604    for (_, _adapter, info) in cand {
605        let backend_id = info.backend as u32;
606        if seen.insert((info.vendor, info.device, backend_id)) {
607            chosen.push(DeviceBenchmarkRequest {
608                backend: backend_name(info.backend).to_string(),
609                vendor: info.vendor,
610                device: info.device,
611                gemv_n: n,
612            });
613        }
614    }
615    for request in chosen {
616        match invoke_worker(&request) {
617            Ok(bench) => circuits.push(bench),
618            Err(error) => log::warn!(
619                "device_benchmark|skip|{:04x}:{:04x}|{}|{}",
620                request.vendor,
621                request.device,
622                request.backend,
623                error
624            ),
625        }
626        // Guard: if a backend hangs the probe, the process may stick — operators can
627        // Each worker has a hard deadline, so a wedged backend is skipped without
628        // poisoning the parent or preventing the remaining adapters from running.
629    }
630
631    // ── CPU via native rayon ──
632    let cpu_ms = bench_cpu_gemv(n);
633    circuits.push(CircuitBench {
634        label: format!("CPU native (rayon, {} cores)", num_cpus::get()),
635        kind: CircuitKind::Cpu,
636        backend: "native".to_string(),
637        ms_per_gemv: cpu_ms,
638        gflops: gflops(n, cpu_ms),
639        upload_gbps: f64::INFINITY, // data already in the CPU's pool — no transfer
640        rel_score: 1.0,
641        decode_proxy_tok_s: None,
642    });
643
644    // Rank fastest-first and fill relative scores.
645    circuits.sort_by(|a, b| {
646        a.ms_per_gemv
647            .partial_cmp(&b.ms_per_gemv)
648            .unwrap_or(std::cmp::Ordering::Equal)
649            .then_with(|| a.backend.cmp(&b.backend))
650            .then_with(|| a.label.cmp(&b.label))
651    });
652    if let Some(best_ms) = circuits.first().map(|c| c.ms_per_gemv) {
653        for c in &mut circuits {
654            c.rel_score = if c.ms_per_gemv > 0.0 {
655                best_ms / c.ms_per_gemv
656            } else {
657                0.0
658            };
659        }
660    }
661
662    CapabilityMatrix {
663        circuits,
664        gemv_n: n,
665        npu_probed: false,
666    }
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn device_benchmark_worker_entry() {
675        if std::env::var_os(WORKER_OUTPUT_ENV).is_some() {
676            run_worker_from_env().expect("device benchmark worker must write its response");
677        }
678    }
679
680    #[test]
681    fn worker_protocol_round_trips() {
682        let request = DeviceBenchmarkRequest {
683            backend: "vulkan".into(),
684            vendor: 1,
685            device: 2,
686            gemv_n: 32,
687        };
688        let response = DeviceBenchmarkResponse {
689            request: request.clone(),
690            bench: None,
691            error: Some("expected".into()),
692        };
693        let path = std::env::temp_dir().join(format!(
694            "qualia-device-protocol-{}.cbor",
695            std::process::id()
696        ));
697        encode_response(&path, &response).unwrap();
698        let decoded = decode_response(&path).unwrap();
699        let _ = std::fs::remove_file(path);
700        assert_eq!(decoded.request.backend, request.backend);
701        assert_eq!(decoded.error.as_deref(), Some("expected"));
702    }
703
704    /// Cross-circuit benchmark on whatever silicon is present. Prints the ranked matrix; asserts the
705    /// CPU path always appears and the ranking is consistent. Skips GPU rows cleanly if headless.
706    #[test]
707    #[serial_test::serial(gpu)]
708    fn h1a_capability_matrix() {
709        let matrix = benchmark_devices(2048);
710        eprintln!("{}", matrix.summary());
711
712        assert!(
713            !matrix.circuits.is_empty(),
714            "at least the CPU circuit must be benchmarked"
715        );
716        assert!(
717            matrix.circuits.iter().any(|c| c.kind == CircuitKind::Cpu),
718            "native CPU circuit must always be present"
719        );
720        // Sorted fastest-first → non-decreasing ms, non-increasing rel_score.
721        for w in matrix.circuits.windows(2) {
722            assert!(
723                w[0].ms_per_gemv <= w[1].ms_per_gemv + 1e-9,
724                "matrix must be sorted fastest-first"
725            );
726        }
727        assert!(
728            (matrix.best().unwrap().rel_score - 1.0).abs() < 1e-9,
729            "best score must be 1.0"
730        );
731    }
732}