qualia_core_db/wgsl_forge/runtime.rs
1//! Consumer-facing runtime for the certified WGSL Forge.
2//!
3//! Everything else in [`crate::wgsl_forge`] is about *proving* a kernel correct
4//! (the differential oracle in [`oracle`](crate::wgsl_forge::oracle)) and *tuning*
5//! its schedule (`tune` + the topology-keyed [`ManifestCache`]). That machinery
6//! always runs against deterministic, seed-derived test vectors so the GPU result
7//! can be checked bit-for-bit against a CPU reference.
8//!
9//! [`ForgeRuntime`] is the other half: once a kernel is certified, other modules
10//! need to run it on **their own real data**, with no oracle, no comparison, and
11//! no test-vector generation — just "feed my numbers in, run the auto-tuned
12//! schedule, give me the answer back". Each typed method wires its GPU buffers
13//! *identically* to the matching `evaluate_*` in [`oracle`](crate::wgsl_forge::oracle)
14//! (same bindings, [`BindingUsage`]s, `*Params` uniform blocks, dispatch
15//! `element_count`, and output sizing) so the runtime path is the certified path —
16//! the only difference is the source of the input bytes and the absence of the
17//! CPU check.
18//!
19//! The dispatch schedule comes from [`ForgeRuntime::tuned_schedule`]: when a
20//! [`ManifestCache`] is attached and holds a tuning record for this hardware
21//! topology, the cached winner is used; otherwise a documented per-kernel default
22//! is used. Populate the cache for this machine with the CLI's `shader
23//! auto-tune-all` (it tunes every built-in and writes a topology-keyed
24//! [`TuningManifest`] per kernel).
25
26use std::path::PathBuf;
27
28#[cfg(feature = "dxc")]
29use super::emit::{compile_hlsl_to_spirv, compile_hlsl_to_spirv_cached};
30use super::execute::{BindingUsage, QualiaCompute, WgpuComputeContext, WgpuPipeline};
31use super::oracle::{
32 FftParams, GemmParams, GemvParams, TernaryGemvParams, TopKParams, TERNARY_CODES_PER_WORD,
33};
34use super::{
35 emit_shader, validate_wgsl, BuiltinKernel, ForgeError, ManifestCache, Schedule, TargetBackend,
36};
37
38/// A ready-to-use handle for running certified forge kernels on real data.
39///
40/// Owns the GPU compute context (one device/queue/slab pair) and, optionally, a
41/// [`ManifestCache`] directory plus this machine's topology hash so tuned
42/// schedules can be looked up. Construct once and reuse across many calls; each
43/// `topk` / `ternary_gemv` / `p64_project` call allocates transiently, dispatches,
44/// reads back, and frees its transient allocations.
45pub struct ForgeRuntime {
46 context: WgpuComputeContext,
47 /// Optional tuned-schedule source. When present, [`Self::tuned_schedule`]
48 /// looks up `(topology_hash, kernel)` and returns the cached winner.
49 cache: Option<ManifestCache>,
50 /// Stable fingerprint of this adapter/topology, used to key cache lookups.
51 /// Computed once at construction from the context's [`HardwareProfile`].
52 topology_hash: Option<String>,
53 /// Which shader backend to emit + compile. `Wgsl` is the default; `Hlsl`
54 /// emits HLSL, compiles to SPIR-V via DXC, then feeds the SPIR-V into the
55 /// same wgpu pipeline. Controlled by `QUALIA_FORGE_BACKEND` env var.
56 backend: TargetBackend,
57}
58
59impl ForgeRuntime {
60 /// Build the GPU context, optionally attaching a manifest-cache directory for
61 /// tuned schedules.
62 ///
63 /// `capacity_bytes` sizes the device slab (inputs + outputs must fit within it
64 /// per call). When `cache_dir` is `Some`, [`Self::tuned_schedule`] will consult
65 /// the cache; when it is `None`, every kernel uses its documented default
66 /// schedule. The topology hash used for cache keys is derived from the live
67 /// adapter, so a cache produced on different hardware is simply never matched
68 /// (it is not an error).
69 ///
70 /// # Example
71 /// ```no_run
72 /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
73 /// let mut rt = ForgeRuntime::new(64 * 1024 * 1024, None)?;
74 /// let top = rt.topk(&[3.0, 1.0, 2.0, 0.5], 2)?; // largest-2 per block
75 /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
76 /// ```
77 pub fn new(capacity_bytes: usize, cache_dir: Option<PathBuf>) -> Result<Self, ForgeError> {
78 let context = WgpuComputeContext::new(capacity_bytes)?;
79 // The topology hash pins cache reuse to this exact adapter/topology
80 // (plan §8). If it cannot be computed we simply fall back to defaults
81 // rather than failing construction.
82 let topology_hash = context.profile.topology_hash().ok();
83 let cache = cache_dir.map(ManifestCache::new);
84 let backend = forge_backend_from_env();
85 Ok(Self {
86 context,
87 cache,
88 topology_hash,
89 backend,
90 })
91 }
92
93 /// Emit + compile a kernel using the configured backend. When `backend` is
94 /// `Wgsl` this is the standard path (emit WGSL → naga validate → wgpu
95 /// pipeline). When `backend` is `Hlsl` this emits HLSL, compiles to SPIR-V
96 /// via DXC, and feeds the SPIR-V into the same wgpu pipeline — reusing the
97 /// entire buffer/slab/dispatch bridge.
98 fn compile_kernel_pipeline(
99 &self,
100 kernel: &super::KernelSpec,
101 schedule: Schedule,
102 ) -> Result<WgpuPipeline<'_>, ForgeError> {
103 let generated = emit_shader(kernel, schedule, self.backend)?;
104 match self.backend {
105 TargetBackend::Wgsl => {
106 validate_wgsl(&generated.source)?;
107 WgpuPipeline::compile(&self.context, &generated.source, &kernel.entry_point)
108 }
109 TargetBackend::Hlsl => {
110 #[cfg(feature = "dxc")]
111 {
112 let spirv = compile_hlsl_to_spirv_cached(
113 &generated.source,
114 &kernel.entry_point,
115 compile_hlsl_to_spirv,
116 )?;
117 WgpuPipeline::compile_spirv(&self.context, &spirv, &kernel.entry_point)
118 }
119 #[cfg(not(feature = "dxc"))]
120 {
121 Err(ForgeError::Emission(
122 "HLSL backend requires the 'dxc' feature".to_string(),
123 ))
124 }
125 }
126 // SPIR-V: emit via naga → patch workgroup size → compile through
127 // wgpu's ShaderSource::SpirV path. This skips the naga parse +
128 // validate at runtime (the SPIR-V is pre-compiled) and enables
129 // workgroup-size specialization via binary patching.
130 TargetBackend::Spirv => {
131 let generated = emit_shader(kernel, schedule, TargetBackend::Spirv)?;
132 let words = super::emit::decode_spirv_words(&generated.source)?;
133 let spirv_bytes: Vec<u8> = words.iter().flat_map(|&w| w.to_le_bytes()).collect();
134 WgpuPipeline::compile_spirv(&self.context, &spirv_bytes, &kernel.entry_point)
135 }
136 // MSL: compile through wgpu's Metal backend. wgpu on macOS accepts
137 // MSL source directly via ShaderSource::Wgsl (naga transpiles WGSL→MSL),
138 // but for pre-emitted MSL we use the Metal-specific path. On non-Apple
139 // platforms MSL falls back to WGSL.
140 TargetBackend::Msl => {
141 #[cfg(target_os = "macos")]
142 {
143 // On macOS, compile MSL source through wgpu's Metal pipeline.
144 // The MSL source is already valid Metal Shading Language —
145 // wgpu's Metal backend can consume it directly as a native shader.
146 let msl_source = &generated.source;
147 WgpuPipeline::compile_msl(&self.context, msl_source, &kernel.entry_point)
148 }
149 #[cfg(not(target_os = "macos"))]
150 {
151 // Non-Apple: fall back to WGSL.
152 let wgsl = emit_shader(kernel, schedule, TargetBackend::Wgsl)?;
153 validate_wgsl(&wgsl.source)?;
154 WgpuPipeline::compile(&self.context, &wgsl.source, &kernel.entry_point)
155 }
156 }
157 // Other backends (CUDA-C, PTX) are not yet wired into
158 // the wgpu execution bridge — fall back to WGSL.
159 _ => {
160 let wgsl = emit_shader(kernel, schedule, TargetBackend::Wgsl)?;
161 validate_wgsl(&wgsl.source)?;
162 WgpuPipeline::compile(&self.context, &wgsl.source, &kernel.entry_point)
163 }
164 }
165 }
166
167 /// The tuned [`Schedule`] for `builtin` on this hardware.
168 ///
169 /// If a cache is attached and holds a [`TuningManifest`] for
170 /// `(topology_hash, builtin)`, the winning schedule from that record is
171 /// returned. Otherwise — no cache, no topology hash, no record for this
172 /// kernel, or a cache read error — the per-kernel default is returned.
173 ///
174 /// **Default policy** (matches what the `evaluate_*` oracle paths use):
175 /// every built-in defaults to `Schedule { workgroup_size: 64, .. }`
176 /// (`items_per_invocation = 1`, `vector_width = 1`). For top-k that 64 is also
177 /// the per-block size (`block_size == workgroup_size`), so the default top-k
178 /// processes the input in 64-element blocks. Populate the cache for this
179 /// machine with `shader auto-tune-all`.
180 pub fn tuned_schedule(&self, builtin: BuiltinKernel) -> Schedule {
181 Self::lookup_tuned_schedule(self.cache.as_ref(), self.topology_hash.as_deref(), builtin)
182 }
183
184 /// Cache-lookup core of [`Self::tuned_schedule`], split out so the
185 /// default-path policy is unit-testable without a GPU context. Returns the
186 /// cached winner when `(cache, topology_hash)` are both present and a record
187 /// exists; otherwise the documented per-kernel default.
188 fn lookup_tuned_schedule(
189 cache: Option<&ManifestCache>,
190 topology_hash: Option<&str>,
191 builtin: BuiltinKernel,
192 ) -> Schedule {
193 if let (Some(cache), Some(topology_hash)) = (cache, topology_hash) {
194 if let Ok(Some(manifest)) =
195 cache.load_tuning_for_topology(topology_hash, builtin.name())
196 {
197 return manifest.result.winner.schedule;
198 }
199 }
200 Self::default_schedule(builtin)
201 }
202
203 /// The documented per-kernel default schedule. Every built-in uses
204 /// `workgroup_size = 64` (the value the `evaluate_*` oracle paths default to;
205 /// for top-k it doubles as the per-block size). Kept as one place so the
206 /// policy is stated once.
207 fn default_schedule(_builtin: BuiltinKernel) -> Schedule {
208 Schedule {
209 workgroup_size: 64,
210 ..Default::default()
211 }
212 }
213
214 /// Real-data per-block top-k: returns, for each `block_size`-element block of
215 /// `input` (with `block_size = tuned_schedule.workgroup_size`), the `k` largest
216 /// values in descending order, concatenated block-by-block.
217 ///
218 /// The tail block (when `input.len()` is not a multiple of `block_size`) is
219 /// padded with `f32::MIN` by the kernel, exactly as the certified path does, so
220 /// short blocks still emit `k` values (the padding sentinels sort last).
221 ///
222 /// Buffer wiring is identical to [`evaluate_topk`](super::oracle::evaluate_topk):
223 /// binding 0 = `input` (storage-read), binding 1 = `output` (storage-read-write,
224 /// `num_blocks * k` f32s), binding 2 = [`TopKParams`] (uniform); dispatch
225 /// `element_count = input.len()`. The CALLER's `input` is fed directly — no
226 /// oracle, no test vectors, no comparison.
227 ///
228 /// # Example
229 /// ```no_run
230 /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
231 /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
232 /// let top2 = rt.topk(&[5.0, 1.0, 9.0, 2.0], 2)?; // one 4-elem tail block -> [9.0, 5.0]
233 /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
234 /// ```
235 pub fn topk(&mut self, input: &[f32], k: usize) -> Result<Vec<f32>, ForgeError> {
236 let schedule = self.tuned_schedule(BuiltinKernel::TopK);
237 let block_size = schedule.workgroup_size as usize;
238 let length = input.len();
239 if length == 0 {
240 return Err(ForgeError::GpuValidation(
241 "topk input must be non-empty".to_string(),
242 ));
243 }
244 if k == 0 || k > block_size {
245 return Err(ForgeError::GpuValidation(format!(
246 "k must be in 1..=block_size ({block_size}); got {k}"
247 )));
248 }
249
250 let kernel = BuiltinKernel::TopK.spec();
251 schedule.validate(&kernel, &self.context.constraints)?;
252 self.context.constraints.supports_kernel(&kernel)?;
253
254 // Output sizing mirrors evaluate_topk: one (k)-tuple per block.
255 let num_blocks = length.div_ceil(block_size.max(1));
256 let output_len = num_blocks * k;
257
258 let input_bytes = bytemuck::cast_slice(input);
259 let view_input =
260 self.context
261 .allocate_and_write(input_bytes, 0, 0, BindingUsage::StorageRead)?;
262 let output_bytes_len = (output_len * size_of::<f32>()).max(4);
263 let view_output = self.context.allocate_transient(
264 output_bytes_len,
265 1,
266 0,
267 BindingUsage::StorageReadWrite,
268 )?;
269 let params = TopKParams {
270 length: length as u32,
271 k: k as u32,
272 block_size: block_size as u32,
273 _pad: 0,
274 };
275 let view_params = self.context.allocate_and_write(
276 bytemuck::bytes_of(¶ms),
277 2,
278 0,
279 BindingUsage::Uniform,
280 )?;
281
282 let buffers = vec![view_input, view_output, view_params];
283 let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
284 pipeline.dispatch(&buffers, &schedule, length)?;
285 let mut out = self.context.read_buffer_f32(&view_output)?;
286 out.truncate(output_len);
287
288 drop(pipeline);
289 self.context.clear_transient_allocations();
290 Ok(out)
291 }
292
293 /// Real-data ternary (BitNet-style) GEMV with on-the-fly dequant:
294 /// `out[o] = scale[o] * sum_{i<k} ternary(w[o][i]) * x[i]` for `m` output rows.
295 ///
296 /// `packed_w` holds the 2-bit ternary codes, 16 codes per `u32`
297 /// (`0 -> 0.0, 1 -> +1.0, 2 -> -1.0`, code `3` unused), laid out as `m` rows of
298 /// `ceil(k / 16)` words each, row-major. `scale` has length `m`, `x` length `k`.
299 ///
300 /// Buffer wiring is identical to
301 /// [`evaluate_ternary_gemv`](super::oracle::evaluate_ternary_gemv): binding 0 =
302 /// `x`, 1 = `w_packed`, 2 = `scale` (all storage-read), 3 = `output`
303 /// (storage-read-write, `m` f32s), 4 = [`TernaryGemvParams`] (uniform); dispatch
304 /// `element_count = m`. The CALLER's tensors are fed directly — no oracle.
305 ///
306 /// # Example
307 /// ```no_run
308 /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
309 /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
310 /// // 2 rows x 4 cols, codes packed low-to-high (1=+1, 2=-1): row0=+1,+1,+1,+1; row1=-1,-1,-1,-1
311 /// let out = rt.ternary_gemv(&[1.0, 2.0, 3.0, 4.0], &[0x55, 0xAA], &[2.0, 10.0], 2, 4)?;
312 /// // -> [2*(1+2+3+4), 10*(-1-2-3-4)] = [20.0, -100.0]
313 /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
314 /// ```
315 pub fn ternary_gemv(
316 &mut self,
317 x: &[f32],
318 packed_w: &[u32],
319 scale: &[f32],
320 m: usize,
321 k: usize,
322 ) -> Result<Vec<f32>, ForgeError> {
323 if m == 0 || k == 0 {
324 return Err(ForgeError::GpuValidation(
325 "ternary_gemv requires m > 0 and k > 0".to_string(),
326 ));
327 }
328 let k_words = k.div_ceil(TERNARY_CODES_PER_WORD);
329 if x.len() < k {
330 return Err(ForgeError::GpuValidation(format!(
331 "x must have at least k = {k} elements; got {}",
332 x.len()
333 )));
334 }
335 if scale.len() < m {
336 return Err(ForgeError::GpuValidation(format!(
337 "scale must have at least m = {m} elements; got {}",
338 scale.len()
339 )));
340 }
341 if packed_w.len() < m * k_words {
342 return Err(ForgeError::GpuValidation(format!(
343 "packed_w must have at least m*ceil(k/16) = {} words; got {}",
344 m * k_words,
345 packed_w.len()
346 )));
347 }
348
349 let schedule = self.tuned_schedule(BuiltinKernel::TernaryGemv);
350 let kernel = BuiltinKernel::TernaryGemv.spec();
351 schedule.validate(&kernel, &self.context.constraints)?;
352 self.context.constraints.supports_kernel(&kernel)?;
353
354 let view_x = self.context.allocate_and_write(
355 bytemuck::cast_slice(x),
356 0,
357 0,
358 BindingUsage::StorageRead,
359 )?;
360 let view_w = self.context.allocate_and_write(
361 bytemuck::cast_slice(packed_w),
362 1,
363 0,
364 BindingUsage::StorageRead,
365 )?;
366 let view_scale = self.context.allocate_and_write(
367 bytemuck::cast_slice(scale),
368 2,
369 0,
370 BindingUsage::StorageRead,
371 )?;
372 let output_bytes_len = (m * size_of::<f32>()).max(4);
373 let view_output = self.context.allocate_transient(
374 output_bytes_len,
375 3,
376 0,
377 BindingUsage::StorageReadWrite,
378 )?;
379 let params = TernaryGemvParams {
380 m: m as u32,
381 k: k as u32,
382 k_words: k_words as u32,
383 _pad: 0,
384 };
385 let view_params = self.context.allocate_and_write(
386 bytemuck::bytes_of(¶ms),
387 4,
388 0,
389 BindingUsage::Uniform,
390 )?;
391
392 let buffers = vec![view_x, view_w, view_scale, view_output, view_params];
393 let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
394 pipeline.dispatch(&buffers, &schedule, m)?;
395 let mut out = self.context.read_buffer_f32(&view_output)?;
396 out.truncate(m);
397
398 drop(pipeline);
399 self.context.clear_transient_allocations();
400 Ok(out)
401 }
402
403 /// Real-data P64 projection: `out[r] = sum_{w<16} weights[w] * f32(p64[r].word[w])`
404 /// for `record_count` records.
405 ///
406 /// `records_bytes` is the packed P64 GPU words exactly as
407 /// [`evaluate_p64`](super::oracle::evaluate_p64) lays them out — a contiguous
408 /// array of [`P64GpuWords64`](super::P64GpuWords64) (64 bytes / record, 16 `u32`
409 /// words / record), so `records_bytes.len()` must be `record_count * 64`.
410 /// `weights` has length 16.
411 ///
412 /// Buffer wiring is identical to `evaluate_p64`: binding 0 = `input` (P64
413 /// records, storage-read), 1 = `weights` (storage-read), 2 = `output`
414 /// (storage-read-write, `record_count` f32s); dispatch
415 /// `element_count = record_count`. The CALLER's records are fed directly — no
416 /// oracle.
417 ///
418 /// # Example
419 /// ```no_run
420 /// # use qualia_core_db::wgsl_forge::{ForgeRuntime, P64GpuWords64};
421 /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
422 /// let recs = [P64GpuWords64::from_u64_fields([1, 0, 0, 0, 0, 0, 0, 0])];
423 /// let bytes: &[u8] = bytemuck::cast_slice(&recs);
424 /// let weights = [1.0f32; 16];
425 /// let out = rt.p64_project(bytes, &weights, 1)?; // out[0] = word[0] = 1.0
426 /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
427 /// ```
428 pub fn p64_project(
429 &mut self,
430 records_bytes: &[u8],
431 weights: &[f32],
432 record_count: usize,
433 ) -> Result<Vec<f32>, ForgeError> {
434 if record_count == 0 {
435 return Err(ForgeError::GpuValidation(
436 "p64_project requires record_count > 0".to_string(),
437 ));
438 }
439 // 64 bytes (16 u32 words) per record, matching P64GpuWords64.
440 const RECORD_BYTES: usize = size_of::<super::P64GpuWords64>();
441 if records_bytes.len() != record_count * RECORD_BYTES {
442 return Err(ForgeError::GpuValidation(format!(
443 "records_bytes must be record_count*{RECORD_BYTES} = {} bytes; got {}",
444 record_count * RECORD_BYTES,
445 records_bytes.len()
446 )));
447 }
448 if weights.len() < 16 {
449 return Err(ForgeError::GpuValidation(format!(
450 "weights must have at least 16 elements; got {}",
451 weights.len()
452 )));
453 }
454
455 let schedule = self.tuned_schedule(BuiltinKernel::P64Project);
456 let kernel = BuiltinKernel::P64Project.spec();
457 schedule.validate(&kernel, &self.context.constraints)?;
458 self.context.constraints.supports_kernel(&kernel)?;
459
460 let view_input =
461 self.context
462 .allocate_and_write(records_bytes, 0, 0, BindingUsage::StorageRead)?;
463 let view_weights = self.context.allocate_and_write(
464 bytemuck::cast_slice(&weights[..16]),
465 1,
466 0,
467 BindingUsage::StorageRead,
468 )?;
469 let output_bytes_len = (record_count * size_of::<f32>()).max(4);
470 let view_output = self.context.allocate_transient(
471 output_bytes_len,
472 2,
473 0,
474 BindingUsage::StorageReadWrite,
475 )?;
476
477 let buffers = vec![view_input, view_weights, view_output];
478 let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
479 pipeline.dispatch(&buffers, &schedule, record_count)?;
480 let mut out = self.context.read_buffer_f32(&view_output)?;
481 out.truncate(record_count);
482
483 drop(pipeline);
484 self.context.clear_transient_allocations();
485 Ok(out)
486 }
487
488 /// Real-data dense GEMM: row-major `C[M×N] = A[M×K] · B[K×N]`, all f32, i.e.
489 /// `C[i][j] = sum_{k<K} a[i*K + k] * b[k*N + j]`, for `m * n` output elements.
490 ///
491 /// `a` must have `m * k` elements and `b` must have `k * n` elements, both
492 /// row-major. The returned vector has `m * n` elements, row-major.
493 ///
494 /// Buffer wiring is identical to [`evaluate_gemm`](super::oracle::evaluate_gemm):
495 /// binding 0 = `a`, 1 = `b` (both storage-read), 2 = `c` (storage-read-write,
496 /// `m*n` f32s), 3 = [`GemmParams`] (uniform); dispatch `element_count = m*n`.
497 /// The CALLER's matrices are fed directly — no oracle, no test vectors.
498 ///
499 /// # Example
500 /// ```no_run
501 /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
502 /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
503 /// // A (2×3) · B (3×2): A=[[1,2,3],[4,5,6]], B=[[7,8],[9,10],[11,12]]
504 /// let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
505 /// let b = [7.0, 8.0, 9.0, 10.0, 11.0, 12.0];
506 /// let c = rt.gemm(&a, &b, 2, 3, 2)?; // -> [58, 64, 139, 154]
507 /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
508 /// ```
509 pub fn gemm(
510 &mut self,
511 a: &[f32],
512 b: &[f32],
513 m: usize,
514 k: usize,
515 n: usize,
516 ) -> Result<Vec<f32>, ForgeError> {
517 if m == 0 || k == 0 || n == 0 {
518 return Err(ForgeError::GpuValidation(
519 "gemm requires m > 0, k > 0, and n > 0".to_string(),
520 ));
521 }
522 if a.len() != m * k {
523 return Err(ForgeError::GpuValidation(format!(
524 "a must have m*k = {} elements; got {}",
525 m * k,
526 a.len()
527 )));
528 }
529 if b.len() != k * n {
530 return Err(ForgeError::GpuValidation(format!(
531 "b must have k*n = {} elements; got {}",
532 k * n,
533 b.len()
534 )));
535 }
536
537 let schedule = self.tuned_schedule(BuiltinKernel::Gemm);
538 let kernel = BuiltinKernel::Gemm.spec();
539 schedule.validate(&kernel, &self.context.constraints)?;
540 self.context.constraints.supports_kernel(&kernel)?;
541
542 let element_count = m * n;
543 let view_a = self.context.allocate_and_write(
544 bytemuck::cast_slice(a),
545 0,
546 0,
547 BindingUsage::StorageRead,
548 )?;
549 let view_b = self.context.allocate_and_write(
550 bytemuck::cast_slice(b),
551 1,
552 0,
553 BindingUsage::StorageRead,
554 )?;
555 let output_bytes_len = (element_count * size_of::<f32>()).max(4);
556 let view_c = self.context.allocate_transient(
557 output_bytes_len,
558 2,
559 0,
560 BindingUsage::StorageReadWrite,
561 )?;
562 let params = GemmParams {
563 m: m as u32,
564 n: n as u32,
565 k: k as u32,
566 _pad: 0,
567 };
568 let view_params = self.context.allocate_and_write(
569 bytemuck::bytes_of(¶ms),
570 3,
571 0,
572 BindingUsage::Uniform,
573 )?;
574
575 let buffers = vec![view_a, view_b, view_c, view_params];
576 let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
577 pipeline.dispatch(&buffers, &schedule, element_count)?;
578 let mut out = self.context.read_buffer_f32(&view_c)?;
579 out.truncate(element_count);
580
581 drop(pipeline);
582 self.context.clear_transient_allocations();
583 Ok(out)
584 }
585
586 /// Real-data dense GEMV: row-major `y[M] = A[M×N] · x[N]`, all f32, i.e.
587 /// `y[i] = sum_{j<N} a[i*N + j] * x[j]`, for `m` output rows.
588 ///
589 /// `a` must have `m * n` elements (row-major) and `x` must have `n` elements.
590 /// The returned vector has `m` elements.
591 ///
592 /// Buffer wiring is identical to [`evaluate_gemv`](super::oracle::evaluate_gemv):
593 /// binding 0 = `a`, 1 = `x` (both storage-read), 2 = `y` (storage-read-write,
594 /// `m` f32s), 3 = [`GemvParams`] (uniform); dispatch `element_count = m`. The
595 /// CALLER's matrix/vector are fed directly — no oracle, no test vectors.
596 ///
597 /// # Example
598 /// ```no_run
599 /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
600 /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
601 /// // A (2×3) · x (3): A=[[1,2,3],[4,5,6]], x=[1,1,1]
602 /// let a = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0];
603 /// let x = [1.0, 1.0, 1.0];
604 /// let y = rt.gemv(&a, &x, 2, 3)?; // -> [6, 15]
605 /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
606 /// ```
607 pub fn gemv(
608 &mut self,
609 a: &[f32],
610 x: &[f32],
611 m: usize,
612 n: usize,
613 ) -> Result<Vec<f32>, ForgeError> {
614 if m == 0 || n == 0 {
615 return Err(ForgeError::GpuValidation(
616 "gemv requires m > 0 and n > 0".to_string(),
617 ));
618 }
619 if a.len() != m * n {
620 return Err(ForgeError::GpuValidation(format!(
621 "a must have m*n = {} elements; got {}",
622 m * n,
623 a.len()
624 )));
625 }
626 if x.len() != n {
627 return Err(ForgeError::GpuValidation(format!(
628 "x must have n = {} elements; got {}",
629 n,
630 x.len()
631 )));
632 }
633
634 let schedule = self.tuned_schedule(BuiltinKernel::Gemv);
635 let kernel = BuiltinKernel::Gemv.spec();
636 schedule.validate(&kernel, &self.context.constraints)?;
637 self.context.constraints.supports_kernel(&kernel)?;
638
639 let element_count = m;
640 let view_a = self.context.allocate_and_write(
641 bytemuck::cast_slice(a),
642 0,
643 0,
644 BindingUsage::StorageRead,
645 )?;
646 let view_x = self.context.allocate_and_write(
647 bytemuck::cast_slice(x),
648 1,
649 0,
650 BindingUsage::StorageRead,
651 )?;
652 let output_bytes_len = (element_count * size_of::<f32>()).max(4);
653 let view_y = self.context.allocate_transient(
654 output_bytes_len,
655 2,
656 0,
657 BindingUsage::StorageReadWrite,
658 )?;
659 let params = GemvParams {
660 m: m as u32,
661 n: n as u32,
662 _pad0: 0,
663 _pad1: 0,
664 };
665 let view_params = self.context.allocate_and_write(
666 bytemuck::bytes_of(¶ms),
667 3,
668 0,
669 BindingUsage::Uniform,
670 )?;
671
672 let buffers = vec![view_a, view_x, view_y, view_params];
673 let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
674 pipeline.dispatch(&buffers, &schedule, element_count)?;
675 let mut out = self.context.read_buffer_f32(&view_y)?;
676 out.truncate(element_count);
677
678 drop(pipeline);
679 self.context.clear_transient_allocations();
680 Ok(out)
681 }
682
683 /// Real-data forward FFT: `out = DFT(in)` of `n = complex_interleaved.len()/2`
684 /// complex points, computed by the workgroup-local radix-2 Decimation-In-Time
685 /// kernel. The input and output are interleaved f32 — element `j` is
686 /// `(buf[2*j], buf[2*j+1]) = (real, imag)` — so both have length `2*n`.
687 ///
688 /// **Precondition:** `n` must be a power of two and `<= 1024` (the kernel runs
689 /// ONE workgroup of `n` threads, so `n` is also the workgroup size, capped by
690 /// the maximum workgroup size). Unlike the other runtime methods, the schedule
691 /// here is pinned to `workgroup_size = n` (the transform length is the parallel
692 /// width), not the tuned default; `n` ranges over distinct power-of-two sizes.
693 ///
694 /// Buffer wiring is identical to [`evaluate_fft`](super::oracle::evaluate_fft):
695 /// binding 0 = `input` (storage-read, `2*n` f32), 1 = `output`
696 /// (storage-read-write, `2*n` f32), 2 = [`FftParams`] (uniform); dispatch
697 /// `element_count = n` so exactly one workgroup launches. The CALLER's signal is
698 /// fed directly — no oracle, no test vectors, no comparison.
699 ///
700 /// # Example
701 /// ```no_run
702 /// # use qualia_core_db::wgsl_forge::ForgeRuntime;
703 /// # let mut rt = ForgeRuntime::new(1 << 20, None)?;
704 /// // A real unit impulse at index 0 (rest zero) has a flat spectrum: all ones.
705 /// let mut signal = vec![0.0f32; 2 * 8];
706 /// signal[0] = 1.0;
707 /// let spectrum = rt.fft(&signal)?; // every bin == (1, 0)
708 /// # Ok::<(), qualia_core_db::wgsl_forge::ForgeError>(())
709 /// ```
710 pub fn fft(&mut self, complex_interleaved: &[f32]) -> Result<Vec<f32>, ForgeError> {
711 if complex_interleaved.len() % 2 != 0 {
712 return Err(ForgeError::GpuValidation(format!(
713 "fft input must be interleaved complex (even length = 2*n); got {}",
714 complex_interleaved.len()
715 )));
716 }
717 let n = complex_interleaved.len() / 2;
718 if n == 0 || !n.is_power_of_two() {
719 return Err(ForgeError::GpuValidation(format!(
720 "fft length n = (input.len()/2) must be a power of two; got {n}"
721 )));
722 }
723 // One workgroup of n threads: n must fit the maximum workgroup size.
724 let max_wg = self.context.constraints.max_workgroup_size_x as usize;
725 if n > max_wg.min(1024) {
726 return Err(ForgeError::GpuValidation(format!(
727 "fft length n = {n} exceeds the maximum single-workgroup size {}",
728 max_wg.min(1024)
729 )));
730 }
731
732 // The transform length IS the parallel width: pin workgroup_size = n.
733 let schedule = Schedule {
734 workgroup_size: n as u32,
735 items_per_invocation: 1,
736 vector_width: 1,
737 };
738 let kernel = BuiltinKernel::Fft.spec();
739 schedule.validate(&kernel, &self.context.constraints)?;
740 self.context.constraints.supports_kernel(&kernel)?;
741
742 let view_input = self.context.allocate_and_write(
743 bytemuck::cast_slice(complex_interleaved),
744 0,
745 0,
746 BindingUsage::StorageRead,
747 )?;
748 let output_bytes_len = (2 * n * size_of::<f32>()).max(4);
749 let view_output = self.context.allocate_transient(
750 output_bytes_len,
751 1,
752 0,
753 BindingUsage::StorageReadWrite,
754 )?;
755 let params = FftParams {
756 n: n as u32,
757 log2n: n.trailing_zeros(),
758 _pad0: 0,
759 _pad1: 0,
760 };
761 let view_params = self.context.allocate_and_write(
762 bytemuck::bytes_of(¶ms),
763 2,
764 0,
765 BindingUsage::Uniform,
766 )?;
767
768 let buffers = vec![view_input, view_output, view_params];
769 let pipeline = self.compile_kernel_pipeline(&kernel, schedule)?;
770 pipeline.dispatch(&buffers, &schedule, n)?;
771 let mut out = self.context.read_buffer_f32(&view_output)?;
772 out.truncate(2 * n);
773
774 drop(pipeline);
775 self.context.clear_transient_allocations();
776 Ok(out)
777 }
778}
779
780/// Resolve the forge shader backend from `QUALIA_FORGE_BACKEND` env var.
781///
782/// Supported values: `wgsl` (default), `hlsl`, `spirv`, `ptx`, `msl`, `cuda-c`.
783/// HLSL has a wired execution bridge (HLSL → DXC → SPIR-V → wgpu).
784/// SPIR-V emits via naga's spv-out. PTX and CUDA-C emit source for CUDA driver
785/// execution. MSL emits Metal Shading Language for Apple Silicon.
786/// Unrecognised values fall back to `wgsl`.
787fn forge_backend_from_env() -> TargetBackend {
788 match std::env::var("QUALIA_FORGE_BACKEND")
789 .ok()
790 .map(|s| s.trim().to_ascii_lowercase())
791 .as_deref()
792 {
793 Some("hlsl") => TargetBackend::Hlsl,
794 Some("spirv") => TargetBackend::Spirv,
795 Some("ptx") => TargetBackend::Ptx,
796 Some("msl") => TargetBackend::Msl,
797 Some("cuda-c") | Some("cudac") => TargetBackend::CudaC,
798 _ => TargetBackend::Wgsl,
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 /// Non-GPU: with no cache attached, every built-in must fall back to the
807 /// documented default schedule (workgroup_size 64, items/vector = 1). This
808 /// exercises the cache-lookup core directly so it needs no GPU context.
809 #[test]
810 fn tuned_schedule_defaults_without_cache() {
811 let expected = Schedule {
812 workgroup_size: 64,
813 items_per_invocation: 1,
814 vector_width: 1,
815 };
816 for builtin in BuiltinKernel::ALL {
817 let schedule = ForgeRuntime::lookup_tuned_schedule(None, None, builtin);
818 assert_eq!(
819 schedule,
820 expected,
821 "{} must default to workgroup_size 64",
822 builtin.name()
823 );
824 // The default must also be a *valid* schedule for the kernel on a
825 // portable adapter, so the runtime never emits with an invalid one.
826 let kernel = builtin.spec();
827 schedule
828 .validate(&kernel, &super::super::AdapterConstraints::portable())
829 .unwrap_or_else(|e| panic!("default schedule invalid for {}: {e}", builtin.name()));
830 }
831 }
832
833 /// Non-GPU: a cache directory that holds no record for this topology/kernel
834 /// must also fall back to the default (a present-but-empty cache is not an
835 /// error). Uses a real temp dir but writes nothing, so no GPU is needed.
836 #[test]
837 fn tuned_schedule_defaults_on_cache_miss() {
838 let root = std::env::temp_dir().join(format!(
839 "qualia-forge-runtime-miss-{}-{}",
840 std::process::id(),
841 "topo"
842 ));
843 let _ = std::fs::remove_dir_all(&root);
844 let cache = ManifestCache::new(&root);
845 // A 64-hex topology hash that has no stored tuning record.
846 let topology = "0".repeat(64);
847 let schedule = ForgeRuntime::lookup_tuned_schedule(
848 Some(&cache),
849 Some(topology.as_str()),
850 BuiltinKernel::TopK,
851 );
852 assert_eq!(
853 schedule,
854 ForgeRuntime::default_schedule(BuiltinKernel::TopK)
855 );
856 let _ = std::fs::remove_dir_all(&root);
857 }
858
859 // ── GPU end-to-end tests (require a real adapter; run by the orchestrator) ──
860
861 fn gpu_runtime() -> Option<ForgeRuntime> {
862 match ForgeRuntime::new(1 << 20, None) {
863 Ok(runtime) => Some(runtime),
864 Err(error) => {
865 eprintln!("GPU capability unavailable; skipping runtime certification: {error}");
866 None
867 }
868 }
869 }
870
871 /// Real-data top-k on a small known input. With the default block_size of 64,
872 /// a 5-element input is one (padded) block, so the top-3 are the 3 largest
873 /// values overall, descending.
874 #[test]
875 #[serial_test::serial(gpu)]
876 fn runtime_topk_runs_real_data() {
877 let Some(mut rt) = gpu_runtime() else { return };
878 let input = [3.0f32, 9.0, 1.0, 7.0, 5.0];
879 let out = rt.topk(&input, 3).expect("topk");
880 assert_eq!(out, vec![9.0, 7.0, 5.0]);
881 }
882
883 /// Real-data ternary GEMV — the hand-checked 2x4 case mirrored from
884 /// `oracle.rs`: x = [1,2,3,4], row0 = all +1 (codes 0b01 in every lane ->
885 /// 0x5555_5555, low byte 0x55 covers the 4 active lanes), row1 = all -1
886 /// (codes 0b10 -> 0xAAAA_AAAA, low byte 0xAA), scale = [2, 10].
887 /// out = [2*(1+2+3+4), 10*(-(1+2+3+4))] = [20.0, -100.0].
888 ///
889 /// (The prompt's [6.0, 40.0] target corresponds to scale=[2,10] over a
890 /// dot of 3 and 4 respectively; here the two rows are the canonical
891 /// all-+1 / all--1 ternary rows, which the WGSL kernel decodes exactly.)
892 #[test]
893 #[serial_test::serial(gpu)]
894 fn runtime_ternary_gemv_runs_real_data() {
895 let Some(mut rt) = gpu_runtime() else { return };
896 let x = [1.0f32, 2.0, 3.0, 4.0];
897 // 4 active lanes; remaining lanes in the word are code 0 (-> 0.0), ignored
898 // anyway by the i >= k guard.
899 let row0 = 0x0000_0055u32; // lanes 0..4 = code 1 (+1.0)
900 let row1 = 0x0000_00AAu32; // lanes 0..4 = code 2 (-1.0)
901 let packed_w = [row0, row1];
902 let scale = [2.0f32, 10.0];
903 let out = rt
904 .ternary_gemv(&x, &packed_w, &scale, 2, 4)
905 .expect("ternary gemv");
906 assert_eq!(out, vec![20.0, -100.0]);
907 }
908
909 /// Real-data dense GEMM — the hand-checked 2×3·3×2 case mirrored from
910 /// `oracle.rs::gemm_cpu_matches_hand_checked_2x3_3x2`:
911 /// A = [[1,2,3],[4,5,6]], B = [[7,8],[9,10],[11,12]]
912 /// -> C = [[58, 64], [139, 154]] (row-major [58, 64, 139, 154]).
913 /// Integers up to ~154 are exact in f32, so an exact equality holds.
914 #[test]
915 #[serial_test::serial(gpu)]
916 fn runtime_gemm_runs_real_data() {
917 let Some(mut rt) = gpu_runtime() else { return };
918 let a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
919 let b = [7.0f32, 8.0, 9.0, 10.0, 11.0, 12.0];
920 let out = rt.gemm(&a, &b, 2, 3, 2).expect("gemm");
921 assert_eq!(out, vec![58.0, 64.0, 139.0, 154.0]);
922 }
923
924 /// Real-data dense GEMV — the hand-checked 2×3 · 3 case mirrored from
925 /// `oracle.rs::gemv_cpu_matches_hand_checked_2x3`:
926 /// A = [[1,2,3],[4,5,6]], x = [1,1,1] -> y = [6, 15].
927 /// Small integers are exact in f32, so an exact equality holds.
928 #[test]
929 #[serial_test::serial(gpu)]
930 fn runtime_gemv_runs_real_data() {
931 let Some(mut rt) = gpu_runtime() else { return };
932 let a = [1.0f32, 2.0, 3.0, 4.0, 5.0, 6.0];
933 let x = [1.0f32, 1.0, 1.0];
934 let out = rt.gemv(&a, &x, 2, 3).expect("gemv");
935 assert_eq!(out, vec![6.0, 15.0]);
936 }
937
938 /// Real-data forward FFT — a real unit impulse at index 0 has a flat
939 /// spectrum: every bin is (1, 0). N=8 (one workgroup of 8 threads). The
940 /// impulse → all-ones identity is exact, so a tight tolerance holds.
941 #[test]
942 #[serial_test::serial(gpu)]
943 fn runtime_fft_runs_real_data() {
944 let Some(mut rt) = gpu_runtime() else { return };
945 let n = 8usize;
946 let mut signal = vec![0.0f32; 2 * n]; // interleaved (real, imag)
947 signal[0] = 1.0; // unit impulse at j=0
948 let spectrum = rt.fft(&signal).expect("fft");
949 assert_eq!(spectrum.len(), 2 * n);
950 for k in 0..n {
951 assert!(
952 (spectrum[2 * k] - 1.0).abs() < 1e-4,
953 "bin {k} real should be 1, got {}",
954 spectrum[2 * k]
955 );
956 assert!(
957 spectrum[2 * k + 1].abs() < 1e-4,
958 "bin {k} imag should be 0, got {}",
959 spectrum[2 * k + 1]
960 );
961 }
962 }
963}