Skip to main content

qualia_cli/
shader.rs

1use std::path::{Path, PathBuf};
2use std::str::FromStr;
3
4use clap::{Args, Subcommand};
5use qualia_core_db::wgsl_forge::execute::WgpuComputeContext;
6use qualia_core_db::wgsl_forge::{
7    candidate_evaluation, generate_builtin, resolve_execution_backend, tune_with, validate_native,
8    validate_wgsl, AdapterConstraints, BuiltinKernel, CertificationManifest, ForgeError,
9    ManifestCache, Schedule, ScheduleSpace, TargetBackend, TuningConfig, TuningManifest,
10};
11
12#[derive(Debug, Clone, Args)]
13pub struct ScheduleArgs {
14    /// Compute invocations in one workgroup.
15    #[arg(long, default_value_t = 64)]
16    pub workgroup: u32,
17    /// Scalar/vector items processed by one invocation.
18    #[arg(long, default_value_t = 1)]
19    pub items: u32,
20    /// Local vector width (1, 2, or 4).
21    #[arg(long, default_value_t = 1)]
22    pub vector_width: u32,
23}
24
25impl ScheduleArgs {
26    fn schedule(&self) -> Schedule {
27        Schedule {
28            workgroup_size: self.workgroup,
29            items_per_invocation: self.items,
30            vector_width: self.vector_width,
31            ..Default::default()
32        }
33    }
34}
35
36#[derive(Debug, Subcommand)]
37pub enum ShaderAction {
38    /// List deterministic kernels currently known to WGSL Forge.
39    ListKernels,
40    /// Check that the native backend toolchains (wgpu adapter, DXC, CUDA) are
41    /// present and report how the Forge will degrade if any are missing.
42    Doctor,
43    /// Print the roofline estimate (FLOP/byte, memory- vs compute-bound) for a kernel.
44    Roofline {
45        #[arg(default_value = "affine-f32")]
46        kernel: String,
47        /// Representative problem size (output elements / records).
48        #[arg(long, default_value_t = 65_536)]
49        n: u64,
50        #[arg(long)]
51        json: bool,
52    },
53    /// Probe the local adapter and print a rich hardware/topology profile.
54    ProfileHardware {
55        /// Write the profile JSON to this path (also prints the topology hash).
56        #[arg(long)]
57        export: Option<PathBuf>,
58        #[arg(long)]
59        json: bool,
60    },
61    /// Generate one deterministic WGSL module.
62    Generate {
63        #[arg(default_value = "affine-f32")]
64        kernel: String,
65        /// Target backend (wgsl, msl, hlsl, ptx, cuda, spirv)
66        #[arg(long, default_value = "wgsl")]
67        target: String,
68        #[command(flatten)]
69        schedule: ScheduleArgs,
70        /// Write WGSL to this path instead of stdout.
71        #[arg(long)]
72        out: Option<PathBuf>,
73        /// Emit the complete generated record as JSON.
74        #[arg(long)]
75        json: bool,
76    },
77    /// Run Naga parsing and semantic validation.
78    Validate {
79        /// Validate an existing WGSL file; otherwise generate the selected kernel.
80        #[arg(long)]
81        input: Option<PathBuf>,
82        #[arg(default_value = "affine-f32")]
83        kernel: String,
84        /// Target backend (wgsl, msl, hlsl, ptx, cuda, spirv)
85        #[arg(long, default_value = "wgsl")]
86        target: String,
87        #[command(flatten)]
88        schedule: ScheduleArgs,
89        /// Write a Naga-level certification manifest.
90        #[arg(long)]
91        manifest: Option<PathBuf>,
92        #[arg(long)]
93        json: bool,
94    },
95    /// Create a real pipeline, compare GPU output with the CPU oracle, and profile it.
96    Certify {
97        #[arg(default_value = "affine-f32")]
98        kernel: String,
99        #[command(flatten)]
100        schedule: ScheduleArgs,
101        #[arg(long, default_value_t = 4_099)]
102        length: usize,
103        #[arg(long, default_value_t = 3)]
104        warmups: usize,
105        #[arg(long, default_value_t = 9)]
106        samples: usize,
107        #[arg(long)]
108        manifest: Option<PathBuf>,
109        /// Also store the adapter-keyed certification in this cache directory.
110        #[arg(long)]
111        cache_dir: Option<PathBuf>,
112        /// Prune/emit/validate only; do not dispatch on the GPU.
113        #[arg(long)]
114        dry_run: bool,
115    },
116    /// Search the bounded schedule space and certify the fastest correct variant.
117    Tune {
118        #[arg(default_value = "affine-f32")]
119        kernel: String,
120        #[arg(long, default_value_t = 65_537)]
121        length: usize,
122        #[arg(long, default_value_t = 2)]
123        warmups: usize,
124        #[arg(long, default_value_t = 3)]
125        initial_samples: usize,
126        #[arg(long, default_value_t = 11)]
127        finalist_samples: usize,
128        #[arg(long, default_value_t = 6)]
129        finalists: usize,
130        #[arg(long, default_value_t = 48)]
131        max_candidates: usize,
132        #[arg(long)]
133        manifest: Option<PathBuf>,
134        /// Also store the adapter-keyed tuning record in this cache directory.
135        #[arg(long)]
136        cache_dir: Option<PathBuf>,
137        /// Report adapter-pruned candidate counts only; do not dispatch on the GPU.
138        #[arg(long)]
139        dry_run: bool,
140    },
141    /// Tune every GPU-certifiable kernel, reusing the topology-keyed cache.
142    AutoTuneAll {
143        #[arg(long, default_value_t = 65_537)]
144        length: usize,
145        #[arg(long, default_value_t = 2)]
146        warmups: usize,
147        #[arg(long, default_value_t = 24)]
148        max_candidates: usize,
149        /// Cache directory to read existing manifests from and (with
150        /// --update-local-manifest) write new ones to.
151        #[arg(long)]
152        cache_dir: Option<PathBuf>,
153        /// Persist freshly tuned manifests to the cache, keyed by topology.
154        #[arg(long)]
155        update_local_manifest: bool,
156        /// List what would be tuned vs. served from cache; do not dispatch.
157        #[arg(long)]
158        dry_run: bool,
159        /// Wall-clock tuning budget in milliseconds. Tuning stops *before*
160        /// starting a kernel once this elapses (kernel-granular; a true
161        /// mid-dispatch interrupt is not possible with the synchronous wgpu
162        /// poll). Remaining kernels are reported as skipped-due-to-budget.
163        #[arg(long)]
164        budget_ms: Option<u64>,
165        /// GPU thermal ceiling in degrees Celsius. Between kernels, if the GPU
166        /// temperature (read via `nvidia-smi`) is at/above this, tuning waits
167        /// and re-polls before continuing. Without an nvidia-smi sensor this
168        /// flag has no effect and a single warning is printed.
169        #[arg(long)]
170        thermal_limit: Option<f32>,
171    },
172}
173
174pub fn run(action: &ShaderAction) -> Result<(), Box<dyn std::error::Error>> {
175    match action {
176        ShaderAction::ListKernels => {
177            println!("Qualia WGSL Forge kernels:");
178            for builtin in BuiltinKernel::ALL {
179                let spec = builtin.spec();
180                println!(
181                    "  {:<16} v{}  {}",
182                    builtin.name(),
183                    spec.semantic_version,
184                    spec.description
185                );
186            }
187        }
188        ShaderAction::Roofline { kernel, n, json } => {
189            let builtin = parse_kernel(kernel)?;
190            let estimate = qualia_core_db::wgsl_forge::roofline_for(builtin, *n);
191            if *json {
192                println!("{}", serde_json::to_string_pretty(&estimate)?);
193            } else {
194                println!(
195                    "{} @ n={}: {} FLOP / {} bytes -> {:.3} FLOP/byte ({:?}-bound)",
196                    builtin.name(),
197                    n,
198                    estimate.flops,
199                    estimate.bytes,
200                    estimate.arithmetic_intensity,
201                    estimate.bound
202                );
203            }
204        }
205        ShaderAction::Doctor => {
206            use std::process::Command;
207            println!("Qualia WGSL Forge — environment doctor\n");
208
209            match WgpuComputeContext::new(1024 * 1024) {
210                Ok(runner) => println!(
211                    "[ok]   wgpu adapter: {} ({})",
212                    runner.adapter.name, runner.adapter.backend
213                ),
214                Err(error) => println!(
215                    "[warn] wgpu adapter: none ({error}); generation/validation still work headless"
216                ),
217            }
218
219            let dxc = std::env::var("QUALIA_DXC_PATH").unwrap_or_else(|_| "dxc".to_string());
220            match Command::new(&dxc).arg("--version").output() {
221                Ok(out) if out.status.success() => {
222                    println!("[ok]   DXC (HLSL->SPIR-V/DXIL): {dxc}");
223                }
224                _ => println!(
225                    "[warn] DXC not found — set QUALIA_DXC_PATH or add dxc to PATH.\n         HLSL native path disabled; get DXC: https://github.com/microsoft/DirectXShaderCompiler/releases"
226                ),
227            }
228
229            let nvcc = std::env::var("CUDA_PATH")
230                .map(|p| format!("{p}/bin/nvcc"))
231                .unwrap_or_else(|_| "nvcc".to_string());
232            match Command::new(&nvcc).arg("--version").output() {
233                Ok(out) if out.status.success() => {
234                    let text = String::from_utf8_lossy(&out.stdout);
235                    let release = text.lines().find(|l| l.contains("release")).unwrap_or("present");
236                    println!("[ok]   CUDA toolkit (nvcc): {}", release.trim());
237                }
238                _ => println!(
239                    "[warn] CUDA toolkit not found — set CUDA_PATH.\n         PTX/CUDA backend disabled; get CUDA: https://developer.nvidia.com/cuda-downloads"
240                ),
241            }
242
243            println!(
244                "\nMissing native toolchains degrade gracefully to the wgpu/WGSL path (plan §12)."
245            );
246        }
247        ShaderAction::ProfileHardware { export, json } => {
248            let runner = WgpuComputeContext::new(1024 * 1024)?;
249            let profile = &runner.profile;
250            let topology_hash = profile.topology_hash()?;
251            if let Some(path) = export {
252                std::fs::write(path, profile.to_pretty_json()?.as_bytes())?;
253                eprintln!("wrote {} (topology {})", path.display(), topology_hash);
254            }
255            if *json {
256                println!("{}", profile.to_pretty_json()?);
257            } else {
258                println!(
259                    "Adapter:        {} ({})",
260                    profile.adapter.name, profile.adapter.backend
261                );
262                println!("Device type:    {}", profile.adapter.device_type);
263                println!(
264                    "Driver:         {} {}",
265                    profile.adapter.driver, profile.adapter.driver_info
266                );
267                println!("Memory class:   {}", profile.memory_class);
268                println!("Subgroups:      {}", profile.constraints.supports_subgroups);
269                println!("Tensor (coopmat): {}", profile.constraints.supports_coopmat);
270                println!("RT cores:       {}", profile.constraints.supports_rt_cores);
271                println!("Timestamp query: {}", profile.supports_timestamp_query);
272                println!(
273                    "Max workgroup:  {} invocations, {} bytes shared",
274                    profile.constraints.max_invocations_per_workgroup,
275                    profile.max_compute_workgroup_storage_size
276                );
277                println!(
278                    "Bind alignment: storage {} / uniform {}",
279                    profile.min_storage_buffer_offset_alignment,
280                    profile.min_uniform_buffer_offset_alignment
281                );
282                println!("Topology hash:  {topology_hash}");
283            }
284        }
285        ShaderAction::Generate {
286            kernel,
287            target,
288            schedule,
289            out,
290            json,
291        } => {
292            let builtin = parse_kernel(kernel)?;
293            let target_backend = target
294                .parse()
295                .map_err(|e: String| ForgeError::Emission(e))?;
296            let generated = generate_builtin(builtin, schedule.schedule(), target_backend)?;
297            if let Some(path) = out {
298                std::fs::write(path, generated.source.as_bytes())?;
299                eprintln!(
300                    "generated {} -> {} ({})",
301                    generated.kernel_id,
302                    path.display(),
303                    generated.source_hash
304                );
305            } else if *json {
306                println!("{}", serde_json::to_string_pretty(&generated)?);
307            } else {
308                print!("{}", generated.source);
309            }
310        }
311        ShaderAction::Validate {
312            input,
313            kernel,
314            target,
315            schedule,
316            manifest,
317            json,
318        } => {
319            let requested_backend: TargetBackend = target
320                .parse()
321                .map_err(|e: String| ForgeError::Emission(e))?;
322            // §2 automatic fallback: validation runs a real native compile (DXC for
323            // HLSL, NVRTC/nvcc for PTX/CUDA-C, xcrun for MSL). If that toolchain is
324            // absent on this host, drop the *validation pipeline* to WGSL (always
325            // available, naga-validated in-process) instead of erroring. WGSL/SPIR-V
326            // need no native toolchain, so they never downgrade. This affects only
327            // the execution/validation pipeline — `shader generate --target ptx`
328            // still emits PTX source unchanged.
329            let (target_backend, fallback_note) =
330                resolve_execution_backend(requested_backend, |t| check_native_toolchain(t).is_ok());
331            if let Some(note) = fallback_note {
332                println!("note: {note}");
333            }
334            let (source, generated, spec) = if let Some(path) = input {
335                (std::fs::read_to_string(path)?, None, None)
336            } else {
337                let builtin = parse_kernel(kernel)?;
338                let generated = generate_builtin(builtin, schedule.schedule(), target_backend)?;
339                (
340                    generated.source.clone(),
341                    Some(generated),
342                    Some(builtin.spec()),
343                )
344            };
345            let report = if target_backend == TargetBackend::Wgsl {
346                Some(validate_wgsl(&source)?)
347            } else if target_backend == TargetBackend::Spirv {
348                // SPIR-V is emitted by parsing+validating the generated WGSL
349                // through naga, so the artifact is already naga-validated. To
350                // surface a real entry-point/binding report we validate the WGSL
351                // form (the canonical IR naga checks) rather than the opaque
352                // decimal-word blob in `source`.
353                if let Some(builtin) = parse_kernel(kernel).ok() {
354                    let wgsl = generate_builtin(builtin, schedule.schedule(), TargetBackend::Wgsl)?;
355                    Some(validate_wgsl(&wgsl.source)?)
356                } else {
357                    eprintln!("SPIR-V validation of an opaque --input blob is not supported; provide a kernel.");
358                    None
359                }
360            } else {
361                match validate_native(&source, target_backend, spec.as_ref()) {
362                    Ok(r) => Some(r),
363                    Err(ForgeError::WgslValidation(msg)) => {
364                        eprintln!("Native validation skipped or failed: {}", msg);
365                        None
366                    }
367                    Err(e) => return Err(Box::new(e)),
368                }
369            };
370            if let Some(path) = manifest {
371                let generated = generated.ok_or_else(|| {
372                    std::io::Error::new(
373                        std::io::ErrorKind::InvalidInput,
374                        "--manifest requires a generated kernel (omit --input)",
375                    )
376                })?;
377                if let Some(report) = report.clone() {
378                    let record = CertificationManifest::naga_only(&generated, report);
379                    write_json(path, &record)?;
380                } else {
381                    eprintln!("Warning: Validation manifest generation is currently only supported for WGSL targets.");
382                }
383            }
384            if let Some(report) = report {
385                if *json {
386                    println!("{}", serde_json::to_string_pretty(&report)?);
387                } else {
388                    println!(
389                        "Validated {} binding(s), entry point(s): {}",
390                        report.binding_count,
391                        report.entry_points.join(", ")
392                    );
393                    if let Some(tool) = report.native_tool_validated {
394                        println!(
395                            "Validation success (via {}): entry points = {:?}",
396                            tool, report.entry_points
397                        );
398                    } else {
399                        println!(
400                            "Validation success (via Naga): entry points = {:?}",
401                            report.entry_points
402                        );
403                    }
404                }
405            } else {
406                println!("Validation skipped for non-WGSL target.");
407            }
408        }
409        ShaderAction::Certify {
410            kernel,
411            schedule,
412            length,
413            warmups,
414            samples,
415            manifest,
416            cache_dir,
417            dry_run,
418        } => {
419            let builtin = parse_kernel(kernel)?;
420            if *dry_run {
421                let sched = schedule.schedule();
422                let spec = builtin.spec();
423                let generated = generate_builtin(builtin, sched, TargetBackend::Wgsl)?;
424                let report = validate_wgsl(&generated.source)?;
425                let constraints = AdapterConstraints::portable();
426                let schedule_ok = sched.validate(&spec, &constraints).is_ok();
427                let adapter_ok = constraints.supports_kernel(&spec).is_ok();
428                println!(
429                    "DRY-RUN certify {}: naga={} ({} bindings), schedule_valid={}, adapter_supported(portable)={}, gpu_oracle={}",
430                    builtin.name(), report.naga_validated, report.binding_count, schedule_ok, adapter_ok, builtin.has_gpu_oracle()
431                );
432                return Ok(());
433            }
434            let mut runner = WgpuComputeContext::new(4 * 1024 * 1024)?;
435            runner.constraints.supports_kernel(&builtin.spec())?;
436            let record = qualia_core_db::wgsl_forge::certify_builtin(
437                &mut runner,
438                builtin,
439                schedule.schedule(),
440                *length,
441                *warmups,
442                *samples,
443            )?;
444            if let Some(path) = manifest {
445                write_json(path, &record)?;
446            }
447            if let Some(root) = cache_dir {
448                let path = ManifestCache::new(root).store_certification(&record)?;
449                eprintln!("cached {}", path.display());
450            }
451            println!(
452                "CERTIFIED {} on {}: median {} ns, p95 {} ns",
453                record.kernel_id,
454                record
455                    .adapter
456                    .as_ref()
457                    .map(|value| value.name.as_str())
458                    .unwrap_or("unknown"),
459                record
460                    .timing
461                    .as_ref()
462                    .map(|value| value.median_ns)
463                    .unwrap_or(0),
464                record
465                    .timing
466                    .as_ref()
467                    .map(|value| value.p95_ns)
468                    .unwrap_or(0)
469            );
470            println!(
471                "cache key: {}",
472                record.cache_key.as_deref().unwrap_or("none")
473            );
474        }
475        ShaderAction::Tune {
476            kernel,
477            length,
478            warmups,
479            initial_samples,
480            finalist_samples,
481            finalists,
482            max_candidates,
483            manifest,
484            cache_dir,
485            dry_run,
486        } => {
487            let builtin = parse_kernel(kernel)?;
488            let spec = builtin.spec();
489            if *dry_run {
490                let constraints = match WgpuComputeContext::new(4 * 1024 * 1024) {
491                    Ok(runner) => runner.constraints,
492                    Err(_) => AdapterConstraints::portable(),
493                };
494                let space = ScheduleSpace::default();
495                let total = space.workgroup_sizes.len()
496                    * space.items_per_invocation.len()
497                    * space.vector_widths.len();
498                let candidates = space.candidates(&spec, &constraints);
499                let adapter_ok = constraints.supports_kernel(&spec);
500                let roofline = qualia_core_db::wgsl_forge::roofline_for(builtin, *length as u64);
501                println!(
502                    "DRY-RUN tune {}: {}/{} schedule(s) survive pruning; adapter_supported={}; gpu_oracle={}",
503                    builtin.name(),
504                    candidates.len(),
505                    total,
506                    adapter_ok.is_ok(),
507                    builtin.has_gpu_oracle()
508                );
509                println!(
510                    "  roofline @ n={}: {:.3} FLOP/byte ({:?}-bound)",
511                    length, roofline.arithmetic_intensity, roofline.bound
512                );
513                println!(
514                    "  warp size: {} (non-multiples pruned)",
515                    constraints.warp_size
516                );
517                for &wg in &space.workgroup_sizes {
518                    let kept = candidates.iter().filter(|c| c.workgroup_size == wg).count();
519                    let note = if wg % constraints.warp_size == 0 {
520                        ""
521                    } else {
522                        " [warp-pruned]"
523                    };
524                    println!("    workgroup {wg:>4}: {kept} kept{note}");
525                }
526                if let Err(error) = adapter_ok {
527                    println!("  pruned: {error}");
528                }
529                return Ok(());
530            }
531            let mut runner = WgpuComputeContext::new(4 * 1024 * 1024)?;
532            let constraints = runner.constraints;
533            let result = tune_with(
534                &spec,
535                &constraints,
536                &ScheduleSpace::default(),
537                TuningConfig {
538                    initial_samples: *initial_samples,
539                    finalist_samples: *finalist_samples,
540                    finalist_count: *finalists,
541                    max_candidates: *max_candidates,
542                },
543                |schedule, sample_count| {
544                    candidate_evaluation(
545                        &mut runner,
546                        builtin,
547                        schedule,
548                        *length,
549                        *warmups,
550                        sample_count,
551                    )
552                },
553            )?;
554            println!("\nBest configuration:");
555            let generated = generate_builtin(builtin, result.winner.schedule, TargetBackend::Wgsl)?;
556            println!("{}", generated.source);
557            let record = TuningManifest::new(&generated, runner.adapter.clone(), result)?;
558            if let Some(path) = manifest {
559                write_json(path, &record)?;
560            }
561            if let Some(root) = cache_dir {
562                let path = ManifestCache::new(root).store_tuning(&record)?;
563                eprintln!("cached {}", path.display());
564            }
565            let winner = &record.result.winner;
566            println!(
567                "TUNED {} on {}: wg={}, items={}, vector={} -> median {} ns, p95 {} ns",
568                record.kernel_id,
569                record.adapter.name,
570                winner.schedule.workgroup_size,
571                winner.schedule.items_per_invocation,
572                winner.schedule.vector_width,
573                winner.timing.median_ns,
574                winner.timing.p95_ns
575            );
576            println!(
577                "evaluated {}, rejected {}, cache key {}",
578                record.result.evaluated_candidates,
579                record.result.rejected_candidates,
580                record.cache_key
581            );
582        }
583        ShaderAction::AutoTuneAll {
584            length,
585            warmups,
586            max_candidates,
587            cache_dir,
588            update_local_manifest,
589            dry_run,
590            budget_ms,
591            thermal_limit,
592        } => {
593            // §12 setup gate: auto-tune-all certifies on the GPU through the
594            // wgpu/WGSL path, which needs no native compiler. We still pre-flight
595            // so an obviously-broken environment fails fast with an actionable
596            // message rather than deep inside a dispatch.
597            if let Err(why) = check_native_toolchain(TargetBackend::Wgsl) {
598                return Err(why.into());
599            }
600
601            let mut runner = WgpuComputeContext::new(4 * 1024 * 1024)?;
602            let topology_hash = runner.profile.topology_hash()?;
603            let constraints = runner.constraints;
604            let adapter_name = runner.adapter.name.clone();
605            let cache = cache_dir.as_ref().map(|p| ManifestCache::new(p.clone()));
606            println!("auto-tune-all on {adapter_name} (topology {topology_hash})");
607
608            // §7 tuning budget: kernel-granular wall-clock abort. A true
609            // mid-dispatch interrupt is not possible with the synchronous wgpu
610            // poll, so we check between kernels only.
611            let start = std::time::Instant::now();
612
613            // §3 thermal limiting: probe once up front so we can warn exactly
614            // once if the flag was given but no sensor is available.
615            let thermal_active = match (thermal_limit, read_gpu_temperature_celsius()) {
616                (Some(limit), Some(_)) => Some(*limit),
617                (Some(limit), None) => {
618                    eprintln!(
619                        "warning: --thermal-limit {limit} requested but no GPU temperature sensor is available on this host (nvidia-smi missing or non-NVIDIA GPU); proceeding WITHOUT thermal limiting."
620                    );
621                    None
622                }
623                (None, _) => None,
624            };
625
626            let mut tuned = 0usize;
627            let mut skipped_budget = 0usize;
628            for builtin in BuiltinKernel::ALL {
629                let spec = builtin.spec();
630                let name = builtin.name();
631                if constraints.supports_kernel(&spec).is_err() {
632                    println!("  {name:<12} SKIP (adapter lacks required intrinsics)");
633                    continue;
634                }
635                if !builtin.has_gpu_oracle() {
636                    println!("  {name:<12} SKIP (no GPU oracle wired yet)");
637                    continue;
638                }
639                if let Some(cache) = &cache {
640                    if let Some(existing) = cache.load_tuning_for_topology(&topology_hash, name)? {
641                        let winner = &existing.result.winner;
642                        println!(
643                            "  {name:<12} CACHED wg={} items={} -> median {} ns",
644                            winner.schedule.workgroup_size,
645                            winner.schedule.items_per_invocation,
646                            winner.timing.median_ns
647                        );
648                        continue;
649                    }
650                }
651                if *dry_run {
652                    println!("  {name:<12} WOULD TUNE");
653                    continue;
654                }
655                // §7 budget: stop BEFORE starting this kernel if we are out of
656                // wall-clock time (kernel-granular; see flag docs).
657                if let Some(budget) = budget_ms {
658                    let elapsed = start.elapsed().as_millis() as u64;
659                    if elapsed >= *budget {
660                        println!("  {name:<12} SKIP (budget: {elapsed} ms elapsed >= {budget} ms)");
661                        skipped_budget += 1;
662                        continue;
663                    }
664                }
665                // §3 thermal: between kernels, if the GPU is at/above the
666                // ceiling, wait and re-poll a few times before proceeding.
667                if let Some(limit) = thermal_active {
668                    const MAX_WAITS: u32 = 5;
669                    for attempt in 0..MAX_WAITS {
670                        match read_gpu_temperature_celsius() {
671                            Some(temp) if temp >= limit => {
672                                println!(
673                                    "  {name:<12} THERMAL HOLD ({temp:.0} C >= {limit:.0} C), waiting ~2s [{}/{}]",
674                                    attempt + 1,
675                                    MAX_WAITS
676                                );
677                                std::thread::sleep(std::time::Duration::from_secs(2));
678                            }
679                            _ => break,
680                        }
681                    }
682                }
683                let result = tune_with(
684                    &spec,
685                    &constraints,
686                    &ScheduleSpace::default(),
687                    TuningConfig {
688                        initial_samples: 3,
689                        finalist_samples: 11,
690                        finalist_count: 6,
691                        max_candidates: *max_candidates,
692                    },
693                    |schedule, sample_count| {
694                        candidate_evaluation(
695                            &mut runner,
696                            builtin,
697                            schedule,
698                            *length,
699                            *warmups,
700                            sample_count,
701                        )
702                    },
703                );
704                match result {
705                    Ok(result) => {
706                        tuned += 1;
707                        let generated =
708                            generate_builtin(builtin, result.winner.schedule, TargetBackend::Wgsl)?;
709                        let record =
710                            TuningManifest::new(&generated, runner.adapter.clone(), result)?;
711                        let winner = &record.result.winner;
712                        println!(
713                            "  {name:<12} TUNED wg={} items={} vec={} -> median {} ns, p95 {} ns",
714                            winner.schedule.workgroup_size,
715                            winner.schedule.items_per_invocation,
716                            winner.schedule.vector_width,
717                            winner.timing.median_ns,
718                            winner.timing.p95_ns
719                        );
720                        if *update_local_manifest {
721                            if let Some(cache) = &cache {
722                                let path = cache.store_tuning_for_topology(
723                                    &topology_hash,
724                                    name,
725                                    &record,
726                                )?;
727                                eprintln!("    cached {}", path.display());
728                            }
729                        }
730                    }
731                    Err(error) => println!("  {name:<12} FAILED: {error}"),
732                }
733            }
734            if budget_ms.is_some() {
735                println!(
736                    "auto-tune-all done in {} ms: {tuned} kernel(s) tuned, {skipped_budget} skipped due to budget",
737                    start.elapsed().as_millis()
738                );
739            }
740        }
741    }
742    Ok(())
743}
744
745fn parse_kernel(value: &str) -> Result<BuiltinKernel, Box<dyn std::error::Error>> {
746    Ok(BuiltinKernel::from_str(value)?)
747}
748
749/// Best-effort GPU temperature in degrees Celsius via `nvidia-smi`.
750///
751/// Returns `None` if nvidia-smi is absent or its output cannot be parsed (e.g.
752/// non-NVIDIA host, no driver). This is intentionally NVIDIA-only and honest:
753/// callers must treat `None` as "no thermal sensor available", not "cool".
754fn read_gpu_temperature_celsius() -> Option<f32> {
755    use std::process::Command;
756    let output = Command::new("nvidia-smi")
757        .arg("--query-gpu=temperature.gpu")
758        .arg("--format=csv,noheader,nounits")
759        .output()
760        .ok()?;
761    if !output.status.success() {
762        return None;
763    }
764    let text = String::from_utf8_lossy(&output.stdout);
765    // First line, first integer (multi-GPU hosts emit one line per GPU).
766    text.lines()
767        .next()?
768        .trim()
769        .split(|c: char| !c.is_ascii_digit())
770        .find(|s| !s.is_empty())
771        .and_then(|s| s.parse::<f32>().ok())
772}
773
774/// §12 pre-flight: confirm the native toolchain a non-WGSL/native target needs
775/// is present, reusing the same probes as `shader doctor`. Pure-WGSL/SPIR-V
776/// paths need no native compiler and return `Ok(())` immediately. The error is
777/// actionable and points at `shader doctor`.
778fn check_native_toolchain(target: TargetBackend) -> Result<(), String> {
779    use std::process::Command;
780    match target {
781        // Naga handles these in-process; no external compiler required.
782        TargetBackend::Wgsl | TargetBackend::Spirv => Ok(()),
783        TargetBackend::Hlsl => {
784            let dxc = std::env::var("QUALIA_DXC_PATH").unwrap_or_else(|_| "dxc".to_string());
785            match Command::new(&dxc).arg("--version").output() {
786                Ok(out) if out.status.success() => Ok(()),
787                _ => Err(format!(
788                    "DXC (HLSL compiler) not found as `{dxc}`; set QUALIA_DXC_PATH or add dxc to PATH. Run `shader doctor` for details."
789                )),
790            }
791        }
792        TargetBackend::Ptx | TargetBackend::CudaC => {
793            let nvcc = std::env::var("CUDA_PATH")
794                .map(|p| format!("{p}/bin/nvcc"))
795                .unwrap_or_else(|_| "nvcc".to_string());
796            match Command::new(&nvcc).arg("--version").output() {
797                Ok(out) if out.status.success() => Ok(()),
798                _ => Err(
799                    "CUDA toolkit (nvcc) not found at CUDA_PATH; set CUDA_PATH or add nvcc to PATH. Run `shader doctor` for details.".to_string(),
800                ),
801            }
802        }
803        TargetBackend::Msl => {
804            match Command::new("xcrun").arg("--version").output() {
805                Ok(out) if out.status.success() => Ok(()),
806                _ => Err(
807                    "Metal toolchain (xcrun) not found; install Xcode command-line tools. Run `shader doctor` for details.".to_string(),
808                ),
809            }
810        }
811    }
812}
813
814fn write_json<T: serde::Serialize>(
815    path: &Path,
816    value: &T,
817) -> Result<(), Box<dyn std::error::Error>> {
818    let json = serde_json::to_string_pretty(value)?;
819    std::fs::write(path, json.as_bytes())?;
820    eprintln!("wrote {}", path.display());
821    Ok(())
822}