tensor_wasm_jit/ptx_emit.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Craton Software Company
3//! PTX text emitter.
4//!
5//! Lowers a [`TensorWasmKernelBlueprint`] to a PTX assembly text suitable for
6//! `cust::module::Module::from_ptx` (CUDA-host path) or `ptxas` (CI
7//! validation). The emitter targets sm_80 — Ampere — which is the lowest
8//! deployed architecture TensorWasm claims first-class support for.
9//!
10//! ## Register allocation
11//!
12//! Each lowered op produces a fresh f32 register (`%fN`). A `Vec<u32>`
13//! "value stack" tracks the registers currently live on the abstract TensorWasm
14//! stack — `Op::VecAdd` pops two operands and pushes one, etc. The high-
15//! water mark of allocated registers is recorded so the kernel header
16//! declares an exact-sized `.reg .f32 %f<MAX>` rather than a fixed `8`.
17//!
18//! Loads pull a fresh `%f` from the input buffer (`%rd0`) at a running
19//! byte offset that advances by 4 bytes per lane. Stores pop their operand
20//! and write it to the output buffer (`%rd1`) at the corresponding offset.
21//! This is a contract with `tensor_wasm_exec::jit_dispatch`: the scratch buffer's
22//! `args` half maps to `in_ptr`, the `results` half maps to `out_ptr`.
23//!
24//! ## What is intentionally NOT lowered (default)
25//!
26//! [`TensorWasmOp::MatMul`] is **by default** rejected at emit time with
27//! [`EmitError::NotYetImplemented`]. A real wmma lowering needs fragment-
28//! handle materialisation (loading the `a`/`b` operand tiles into the
29//! fragment register handles) and a paired store that writes the
30//! accumulator fragments back to global memory — neither of which the
31//! default IR-to-PTX path encodes. Emitting a syntactically-valid-but-
32//! semantically-broken wmma block would silently corrupt GPU state on
33//! launch, so we refuse instead. See v0.4 roadmap.
34//!
35//! ## EXPERIMENTAL / UNVERIFIED ON HARDWARE: opt-in wmma MatMul
36//!
37//! Setting [`EmitConfig::enable_experimental_matmul`] to `true` flips the
38//! emitter into lowering [`TensorWasmOp::MatMul`] with `m == n == k == 16`
39//! to a wmma `m16n16k16` fragment-load → `wmma.mma.sync` → fragment-store
40//! sequence. This flag defaults to `false` and the safe refusal above
41//! remains the default behaviour; nothing in the auto-offload pipeline
42//! sets it. The generated PTX is **NOT verified on real GPU hardware** in
43//! this environment — its structural correctness (fragment shapes, mma.sync
44//! count, accumulator chaining, register declarations) is gated behind the
45//! differential oracle in `crate::differential`, which MUST pass before
46//! anyone enables this flag in a shipping path.
47//!
48//! ### wmma m16n16k16 tile / fragment contract
49//!
50//! The lowering targets the sm_80 `wmma.mma.sync.aligned.row.col.m16n16k16`
51//! shape with f16 operand fragments and an f32 accumulator (`.f32.f32`):
52//!
53//! * **Tile shape.** One warp cooperatively computes a single
54//! `C[16x16] += A[16x16] * B[16x16]` tile. A is loaded `row` major, B is
55//! loaded `col` major (the canonical `.row.col` operand layout).
56//! * **Operand fragments.** Each of the A and B operand fragments is held
57//! in [`WMMA_OPERAND_FRAG_REGS`] (`8`) packed `.b32` registers per warp
58//! (two f16 lanes per `.b32`), materialised with
59//! `wmma.load.a`/`wmma.load.b`.
60//! * **Accumulator fragment.** The C accumulator is held in
61//! [`WMMA_ACC_FRAG_REGS`] (`8`) `.f32` registers per warp, initialised
62//! from global memory with `wmma.load.c` and written back with
63//! `wmma.store.d`. The accumulator is *chained*: the `wmma.mma.sync`
64//! reads the same `%fa…` accumulator registers it writes (paired
65//! accumulator — `D = A*B + C` with `C` and `D` aliasing the same
66//! fragment), so back-to-back tiles over a longer `k` would accumulate
67//! correctly.
68//! * **Alignment / leading dimension.** `wmma.load.*`/`wmma.store.*`
69//! require the base pointer to be **128-bit aligned** and the supplied
70//! leading-dimension (stride, in elements) to be a multiple of 8 for the
71//! f16 operands and a multiple of 4 for the f32 accumulator. We assume a
72//! tightly-packed row-major tile, so the stride equals the tile width
73//! (`16`) and the caller guarantees the `in_ptr`/`out_ptr` buffers are
74//! 16-byte aligned (the unified-memory allocator already over-aligns to
75//! 256 bytes, so this holds for every legitimate caller).
76
77use std::fmt::Write;
78
79use thiserror::Error;
80
81use crate::ir::{ElemType, TensorWasmKernelBlueprint, TensorWasmOp};
82
83/// PTX register class a SIMD lane is materialised in.
84///
85/// jit CRITICAL fix: integer SIMD lanes go in the `.s32 %r` class and use
86/// typed integer instructions; float lanes stay in the `.f32 %f` class.
87/// Before this, every lane used `%f` + `.f32` ops regardless of element
88/// type, silently miscompiling integer SIMD.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90enum RegClass {
91 /// `.f32 %f` registers.
92 F32,
93 /// `.s32 %r` registers (32-bit integers).
94 S32,
95}
96
97impl RegClass {
98 /// The register class for a given element type. Only the 4-byte lane
99 /// widths the emitter supports end-to-end reach here (`f32`, `i32`);
100 /// other widths are failed closed upstream in
101 /// `rewrite::op_to_detector_op`, so a wider type defaulting to `S32`
102 /// here is never exercised by the production path.
103 fn for_elem(elem: ElemType) -> Self {
104 if elem.is_float() {
105 RegClass::F32
106 } else {
107 RegClass::S32
108 }
109 }
110
111 /// The `%`-prefix character for this class (`f` or `r`).
112 fn prefix(self) -> char {
113 match self {
114 RegClass::F32 => 'f',
115 RegClass::S32 => 'r',
116 }
117 }
118}
119
120/// PTX `add` mnemonic for an element type.
121///
122/// Integer add is sign-agnostic in two's complement, so `add.s32` is
123/// correct for both signed and unsigned i32 lanes. Only the widths the
124/// emitter supports end-to-end are accepted; anything else refuses so the
125/// caller deopts rather than emitting a wrong-width instruction.
126fn add_mnemonic(elem: ElemType) -> Result<&'static str, EmitError> {
127 match elem {
128 ElemType::F32 => Ok("add.f32"),
129 ElemType::I32 => Ok("add.s32"),
130 _ => Err(EmitError::NotYetImplemented(
131 "PTX add for this SIMD element width is not yet emittable",
132 )),
133 }
134}
135
136/// PTX `mul` mnemonic for an element type. Integer multiply takes the low
137/// half (`mul.lo.s32`) — the same lane width in, lane width out, matching
138/// WASM `i32x4.mul` semantics.
139fn mul_mnemonic(elem: ElemType) -> Result<&'static str, EmitError> {
140 match elem {
141 ElemType::F32 => Ok("mul.f32"),
142 ElemType::I32 => Ok("mul.lo.s32"),
143 _ => Err(EmitError::NotYetImplemented(
144 "PTX mul for this SIMD element width is not yet emittable",
145 )),
146 }
147}
148
149/// PTX fused-multiply-add mnemonic. Float only (single rounding).
150fn fma_mnemonic(elem: ElemType) -> Result<&'static str, EmitError> {
151 match elem {
152 ElemType::F32 => Ok("fma.rn.f32"),
153 _ => Err(EmitError::NotYetImplemented(
154 "PTX fma for this SIMD element width is not yet emittable",
155 )),
156 }
157}
158
159/// PTX load/store type suffix for an element type. Integer loads/stores use
160/// the bit-width `.u32` form (the bit pattern is type-agnostic at the
161/// memory boundary); floats use `.f32`.
162fn load_store_type(elem: ElemType) -> &'static str {
163 if elem.is_float() {
164 "f32"
165 } else {
166 "u32"
167 }
168}
169
170/// Errors produced by the PTX emitter.
171#[derive(Debug, Clone, Error, PartialEq, Eq)]
172pub enum EmitError {
173 /// The blueprint contains an op the emitter does not yet lower to PTX.
174 /// Callers should treat this as "keep this function on the CPU path".
175 #[error("PTX emission not yet implemented: {0}")]
176 NotYetImplemented(&'static str),
177 /// The blueprint's `entry` field is not a valid PTX identifier.
178 ///
179 /// Today every legitimate caller constructs `entry` via
180 /// `format!("func{idx}")` so this can never fire — but the Pliron
181 /// `UserFuncName::Testcase` path can carry tenant-controlled bytes that
182 /// would otherwise be interpolated unescaped into the PTX template
183 /// (closing the kernel scope, emitting a second adversary kernel, etc.).
184 /// Closes jit S-1.
185 #[error("invalid PTX entry-name: {entry}")]
186 InvalidEntryName {
187 /// The rejected identifier (echoed for diagnostic purposes only —
188 /// callers MUST NOT include the value in untrusted-facing errors).
189 entry: String,
190 },
191 /// The blueprint demands more SSA registers than the `%fN` index space
192 /// (`u32`) can address. Previously the allocator saturated at
193 /// `u32::MAX`, which silently aliased two distinct SSA values onto the
194 /// same physical register and corrupted results. We now refuse the
195 /// blueprint so the caller (rewrite.rs) deopts to the CPU path rather
196 /// than launch a miscompiled kernel. Closes jit L1.
197 #[error("PTX register allocation overflowed the u32 index space")]
198 TooManyRegisters,
199 /// The blueprint's emission budget (per-op `lanes`, or total ops *
200 /// lanes across the stream) exceeds the emitter's DoS guardrails.
201 ///
202 /// The lane-bearing ops (`VecAdd`/`VecMul`/`VecFma`/`LoadUnified`/
203 /// `StoreUnified`) each drive a `0..lanes` emission loop. The
204 /// production auto-offload path pins `lanes` to `DEFAULT_LANES = 4`,
205 /// but the PUBLIC `TensorWasmKernelBlueprint::push` + `emit`/`emit_with`
206 /// API accepts arbitrary `lanes` (up to `u32::MAX`). An embedder
207 /// building blueprints from untrusted dimensions could request billions
208 /// of PTX lines and exhaust CPU/memory before a byte of output is
209 /// returned. We bound `lanes` by [`MAX_LANES`] and the aggregate
210 /// (ops * lanes) emission budget by [`MAX_EMITTED_OPS`] and refuse up
211 /// front rather than rely on every caller to pre-bound. Closes jit L2
212 /// (unbounded `lanes` / op-count emission DoS).
213 #[error("PTX emission budget exceeded: {what}")]
214 EmissionBudgetExceeded {
215 /// Which limit was hit (for diagnostics only — this string is
216 /// static and carries no untrusted-derived value).
217 what: &'static str,
218 },
219}
220
221/// Maximum length of a PTX identifier (NVIDIA PTX ISA §A.3).
222pub const MAX_PTX_IDENTIFIER_LEN: usize = 1024;
223
224/// Returns `true` iff `s` matches `[A-Za-z_][A-Za-z0-9_$]*` and is no
225/// longer than [`MAX_PTX_IDENTIFIER_LEN`] bytes.
226///
227/// This is the validator that closes jit S-1 (PTX injection via guest-
228/// controlled entry name). Every value that ends up interpolated into
229/// the `.visible .entry {entry}(` template MUST be screened through this
230/// function first.
231#[must_use]
232pub fn is_valid_ptx_identifier(s: &str) -> bool {
233 if s.is_empty() || s.len() > MAX_PTX_IDENTIFIER_LEN {
234 return false;
235 }
236 let mut bytes = s.bytes();
237 let first = bytes.next().unwrap();
238 if !(first.is_ascii_alphabetic() || first == b'_') {
239 return false;
240 }
241 bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'$')
242}
243
244/// Number of `.b32` registers in one wmma `m16n16k16` operand (A or B)
245/// fragment, per warp. f16 operands pack two lanes per `.b32`, so a
246/// 16-wide row spread across the warp's 32 lanes lands in 8 registers.
247pub const WMMA_OPERAND_FRAG_REGS: u32 = 8;
248
249/// Number of `.f32` registers in the wmma `m16n16k16` f32 accumulator
250/// fragment, per warp. The 16x16 = 256 accumulator elements distributed
251/// over 32 lanes give 8 elements (registers) per lane.
252pub const WMMA_ACC_FRAG_REGS: u32 = 8;
253
254/// The only MatMul tile dimension the experimental wmma lowering accepts.
255/// `m == n == k == 16` is the single sm_80 wmma shape this emitter models;
256/// any other dimensions fall back to [`EmitError::NotYetImplemented`] even
257/// when the experimental flag is on.
258pub const WMMA_TILE_DIM: u32 = 16;
259
260/// DoS guardrail (jit L2): maximum `lanes` any single lane-bearing op may
261/// request. Each lane-bearing op (`VecAdd`/`VecMul`/`VecFma`/`LoadUnified`/
262/// `StoreUnified`) emits one PTX instruction line per lane in a `0..lanes`
263/// loop, so an attacker-supplied `lanes` near `u32::MAX` would attempt to
264/// emit billions of lines. `1024` is a generous upper bound that still
265/// comfortably covers realistic GPU lane / vector widths (the production
266/// auto-offload path only ever uses `DEFAULT_LANES = 4`).
267pub const MAX_LANES: u32 = 1024;
268
269/// DoS guardrail (jit L2): maximum aggregate emitted-op budget, i.e. the
270/// sum of `lanes` over every lane-bearing op in the blueprint (each lane
271/// emits ~one instruction line). Bounds total emission work even when each
272/// individual op is under [`MAX_LANES`] but the op stream is long. `1 << 20`
273/// (~1M instruction lines, low tens of MiB of PTX text) is well beyond any
274/// realistic kernel yet small enough to reject adversarial blueprints
275/// before any output bytes are produced.
276pub const MAX_EMITTED_OPS: u64 = 1 << 20;
277
278/// Default PTX target architecture.
279pub const DEFAULT_TARGET: &str = "sm_80";
280
281/// Default PTX language version.
282pub const DEFAULT_PTX_VERSION: &str = "8.0";
283
284/// Configuration knobs for the emitter.
285#[derive(Debug, Clone)]
286pub struct EmitConfig {
287 /// PTX `.target` directive value (e.g. "sm_80", "sm_89").
288 pub target: String,
289 /// PTX `.version` directive value (e.g. "8.0").
290 pub ptx_version: String,
291 /// Emit `__launch_bounds__` annotation on the entry.
292 pub launch_bounds: bool,
293 /// EXPERIMENTAL / UNVERIFIED ON HARDWARE.
294 ///
295 /// When `true`, [`emit_with`] lowers a `MatMul { m: 16, n: 16, k: 16 }`
296 /// op to a wmma `m16n16k16` fragment-load → `wmma.mma.sync` →
297 /// fragment-store sequence (see the module docs for the tile/fragment
298 /// contract). When `false` — the default — `MatMul` is refused with
299 /// [`EmitError::NotYetImplemented`], the safe behaviour the
300 /// auto-offload pipeline relies on.
301 ///
302 /// This MUST stay `false` outside of explicitly opted-in experiments:
303 /// the emitted PTX cannot be validated on a GPU here, so its
304 /// correctness is gated solely behind the differential oracle's
305 /// structural assertions (see `crate::differential`).
306 pub enable_experimental_matmul: bool,
307}
308
309impl Default for EmitConfig {
310 fn default() -> Self {
311 Self {
312 target: DEFAULT_TARGET.to_string(),
313 ptx_version: DEFAULT_PTX_VERSION.to_string(),
314 launch_bounds: true,
315 // SAFETY DEFAULT: MatMul stays refused unless a caller flips
316 // this on for an experiment. Never default this to `true`.
317 enable_experimental_matmul: false,
318 }
319 }
320}
321
322/// Result of emitting a blueprint.
323#[derive(Debug, Clone)]
324pub struct EmittedPtx {
325 /// The PTX text.
326 pub text: String,
327 /// Launch geometry the blueprint expects.
328 pub launch_geometry: (u32, u32),
329}
330
331impl EmittedPtx {
332 /// Byte length of the PTX text.
333 pub fn len(&self) -> usize {
334 self.text.len()
335 }
336
337 /// True if the PTX text is empty.
338 pub fn is_empty(&self) -> bool {
339 self.text.is_empty()
340 }
341}
342
343/// Emit PTX text for a blueprint with default config.
344///
345/// Returns [`EmitError::NotYetImplemented`] for blueprints containing ops
346/// the emitter cannot lower (currently: [`TensorWasmOp::MatMul`]).
347pub fn emit(blueprint: &TensorWasmKernelBlueprint) -> Result<EmittedPtx, EmitError> {
348 emit_with(blueprint, &EmitConfig::default())
349}
350
351/// Allocate fresh registers and track the per-op body lines.
352///
353/// Returns `(body_text, max_f32_reg, max_s32_reg, max_s64_reg,
354/// max_pred_reg, max_b32_reg)` where `max_*` is one greater than the
355/// highest register index used (i.e. the count to declare in
356/// `.reg .f32 %f<N>;`). Counts are floored at 1 so the declaration is
357/// always well-formed even for empty kernels. `max_b32_reg` is the wmma
358/// operand-fragment `.b32 %rb<N>` count and is `0` unless an experimental
359/// MatMul was lowered.
360fn lower_body(
361 blueprint: &TensorWasmKernelBlueprint,
362 cfg: &EmitConfig,
363) -> Result<(String, u32, u32, u32, u32, u32), EmitError> {
364 // PERF (T20): pre-size the body buffer rather than letting `writeln!`
365 // grow it by doubling. Each lowered op emits a comment line plus one
366 // or more instruction lines (`add.f32 %f… %f… %f…;`, ~32 ASCII chars
367 // each); 80 chars per op is a comfortable upper-bound average across
368 // VecAdd/VecMul/VecFma/Load/Store lowering, so this single allocation
369 // covers the common case without a single realloc. Empty blueprints
370 // still get a usable (zero-byte) capacity.
371 let mut body = String::with_capacity(blueprint.ops.len() * 80);
372 // Pre-size the value stack to `ops.len()` — each lowered op pushes at
373 // most one register, and most pop more than they push, so this is a
374 // safe upper bound that avoids grow-by-doubling reallocs on the hot
375 // emit path. The stack holds register *indices*; the register class is
376 // implicit per block — `clif_lower::infer_block_shape` guarantees a
377 // block is element-type-homogeneous, so every stacked register belongs
378 // to that block's single class (`%f` for float, `%r` for integer).
379 let mut value_stack: Vec<u32> = Vec::with_capacity(blueprint.ops.len());
380 let mut next_f = 0u32;
381 // Integer compute register counter. Starts at 1: `%r0` is reserved for
382 // the element count `n` loaded in the prologue. The high-water mark
383 // becomes the `.reg .s32 %r<N>` declaration count.
384 //
385 // jit CRITICAL fix: previously the s32 class was fixed at 1 (only `%r0`)
386 // because every op — integer SIMD included — was emitted with `.f32`
387 // ops into the `%f` class, silently miscompiling integer SIMD. Integer
388 // lanes now allocate real `%r` registers and use typed integer ops.
389 let mut next_r = 1u32;
390 let mut max_s64 = 2u32; // %rd0 (in_ptr), %rd1 (out_ptr)
391 let max_pred = 2u32;
392 // EXPERIMENTAL: high-water mark of the `.b32` wmma operand-fragment
393 // register class (`%rb<N>`). Stays 0 unless an experimental MatMul is
394 // lowered, in which case the header declares exactly the fragment
395 // registers the `wmma.load.a`/`wmma.load.b` ops use.
396 let mut max_b32 = 0u32;
397 // Running byte offset into the input / output buffers; advances by the
398 // element width per lane (4 B for f32/i32, 8 B for f64/i64, …). Loads
399 // consume from `%rd0+in_off`; stores consume from `%rd1+out_off`.
400 let mut in_off: u32 = 0;
401 let mut out_off: u32 = 0;
402
403 // Allocate a fresh register in the given class. Returns
404 // `EmitError::TooManyRegisters` rather than saturating at `u32::MAX` —
405 // saturation would alias two distinct SSA values onto the same physical
406 // register (jit L1).
407 let alloc = |class: RegClass, next_f: &mut u32, next_r: &mut u32| -> Result<u32, EmitError> {
408 let counter = match class {
409 RegClass::F32 => next_f,
410 RegClass::S32 => next_r,
411 };
412 let r = *counter;
413 *counter = counter.checked_add(1).ok_or(EmitError::TooManyRegisters)?;
414 Ok(r)
415 };
416 // Pop an operand register off the value stack, or allocate a fresh one
417 // in `class` if the stack underflows (an op consuming more than the
418 // abstract stack holds). Threads the fallible allocator through the
419 // underflow path.
420 let pop_or_alloc = |value_stack: &mut Vec<u32>,
421 class: RegClass,
422 next_f: &mut u32,
423 next_r: &mut u32|
424 -> Result<u32, EmitError> {
425 match value_stack.pop() {
426 Some(idx) => Ok(idx),
427 None => alloc(class, next_f, next_r),
428 }
429 };
430
431 for op in &blueprint.ops {
432 match op {
433 TensorWasmOp::VecAdd { elem, lanes } => {
434 let class = RegClass::for_elem(*elem);
435 let _ = writeln!(body, " // vec_add.{elem}[{lanes}] lanes");
436 let mnemonic = add_mnemonic(*elem)?;
437 let p = class.prefix();
438 for _ in 0..*lanes {
439 let b = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
440 let a = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
441 let dst = alloc(class, &mut next_f, &mut next_r)?;
442 let _ = writeln!(body, " {mnemonic} %{p}{dst}, %{p}{a}, %{p}{b};");
443 value_stack.push(dst);
444 }
445 }
446 TensorWasmOp::VecMul { elem, lanes } => {
447 let class = RegClass::for_elem(*elem);
448 let _ = writeln!(body, " // vec_mul.{elem}[{lanes}] lanes");
449 let mnemonic = mul_mnemonic(*elem)?;
450 let p = class.prefix();
451 for _ in 0..*lanes {
452 let b = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
453 let a = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
454 let dst = alloc(class, &mut next_f, &mut next_r)?;
455 let _ = writeln!(body, " {mnemonic} %{p}{dst}, %{p}{a}, %{p}{b};");
456 value_stack.push(dst);
457 }
458 }
459 TensorWasmOp::VecFma { elem, lanes } => {
460 // FMA is a single-rounding float primitive. Integer "fma"
461 // has no single-rounding semantics and the detector never
462 // produces an integer `VecFma`, but refuse defensively
463 // rather than emit a wrong instruction.
464 if !elem.is_float() {
465 return Err(EmitError::NotYetImplemented(
466 "integer VecFma has no single-rounding PTX form",
467 ));
468 }
469 let class = RegClass::for_elem(*elem);
470 let _ = writeln!(body, " // vec_fma.{elem}[{lanes}] lanes");
471 let mnemonic = fma_mnemonic(*elem)?;
472 let p = class.prefix();
473 for _ in 0..*lanes {
474 let c = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
475 let b = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
476 let a = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
477 let dst = alloc(class, &mut next_f, &mut next_r)?;
478 let _ = writeln!(body, " {mnemonic} %{p}{dst}, %{p}{a}, %{p}{b}, %{p}{c};");
479 value_stack.push(dst);
480 }
481 }
482 TensorWasmOp::MatMul { m, n, k } => {
483 // SAFETY DEFAULT: unless the caller explicitly opted into
484 // the experimental wmma lowering, MatMul is refused so the
485 // auto-offload pipeline deopts to the CPU path rather than
486 // launch a kernel that cannot be verified on hardware here.
487 if !cfg.enable_experimental_matmul {
488 return Err(EmitError::NotYetImplemented(
489 "MatMul lowering deferred to v0.4",
490 ));
491 }
492 // The experimental lowering models exactly one sm_80 wmma
493 // shape: m16n16k16. Any other tile dimension is still
494 // refused even with the flag on — emitting an unmodelled
495 // shape would be the same silent-miscompile hazard the
496 // refusal exists to prevent.
497 if (*m, *n, *k) != (WMMA_TILE_DIM, WMMA_TILE_DIM, WMMA_TILE_DIM) {
498 return Err(EmitError::NotYetImplemented(
499 "experimental wmma lowering only models m16n16k16",
500 ));
501 }
502 // EXPERIMENTAL / UNVERIFIED ON HARDWARE.
503 //
504 // wmma m16n16k16 fragment-load → wmma.mma.sync →
505 // fragment-store. See the module docs for the full
506 // tile/fragment + alignment contract. The PTX produced
507 // here is validated ONLY by the differential oracle's
508 // structural assertions; it has NOT been run on a GPU.
509 lower_wmma_m16n16k16(&mut body, &mut next_f, &mut max_b32, &mut max_s64)?;
510 }
511 TensorWasmOp::LoadUnified { elem, lanes } => {
512 let class = RegClass::for_elem(*elem);
513 let p = class.prefix();
514 let ld_ty = load_store_type(*elem);
515 let width = elem.byte_width();
516 let _ = writeln!(
517 body,
518 " // load_unified.{elem}[{lanes}] lanes (.lu cache hint) from %rd0+{in_off}"
519 );
520 for _ in 0..*lanes {
521 let dst = alloc(class, &mut next_f, &mut next_r)?;
522 if in_off == 0 {
523 let _ = writeln!(body, " ld.global.lu.{ld_ty} %{p}{dst}, [%rd0];");
524 } else {
525 let _ =
526 writeln!(body, " ld.global.lu.{ld_ty} %{p}{dst}, [%rd0+{in_off}];");
527 }
528 // jit CRITICAL fix: advance by the element width, not a
529 // hard-coded 4. `checked_add` so a pathological lane
530 // count refuses rather than wrapping the offset and
531 // aliasing two lanes onto the same byte.
532 in_off = in_off
533 .checked_add(width)
534 .ok_or(EmitError::TooManyRegisters)?;
535 value_stack.push(dst);
536 }
537 }
538 TensorWasmOp::StoreUnified { elem, lanes } => {
539 let class = RegClass::for_elem(*elem);
540 let p = class.prefix();
541 let ld_ty = load_store_type(*elem);
542 let width = elem.byte_width();
543 let _ = writeln!(
544 body,
545 " // store_unified.{elem}[{lanes}] lanes (.cs cache hint) to %rd1+{out_off}"
546 );
547 for _ in 0..*lanes {
548 let src = pop_or_alloc(&mut value_stack, class, &mut next_f, &mut next_r)?;
549 if out_off == 0 {
550 let _ = writeln!(body, " st.global.cs.{ld_ty} [%rd1], %{p}{src};");
551 } else {
552 let _ = writeln!(
553 body,
554 " st.global.cs.{ld_ty} [%rd1+{out_off}], %{p}{src};"
555 );
556 }
557 out_off = out_off
558 .checked_add(width)
559 .ok_or(EmitError::TooManyRegisters)?;
560 }
561 }
562 TensorWasmOp::Barrier => {
563 let _ = writeln!(body, " bar.sync 0;");
564 }
565 }
566 }
567
568 // `.reg .* %x<N>;` requires N >= 1, even if no register is used.
569 let max_f = next_f.max(1);
570 let max_s32 = next_r.max(1);
571 Ok((body, max_f, max_s32, max_s64, max_pred, max_b32))
572}
573
574/// EXPERIMENTAL / UNVERIFIED ON HARDWARE.
575///
576/// Lower one `MatMul { m: 16, n: 16, k: 16 }` op to a wmma `m16n16k16`
577/// fragment-load → `wmma.mma.sync` → fragment-store sequence targeting
578/// sm_80 (`.row.col`, f16 operands, f32 accumulator).
579///
580/// The emitted sequence is, per warp:
581///
582/// 1. Materialise the A operand fragment ([`WMMA_OPERAND_FRAG_REGS`]
583/// `.b32` regs) from `%rd0` with `wmma.load.a.sync.aligned.row`.
584/// 2. Materialise the B operand fragment (same width) from the second
585/// tile in `%rd0` with `wmma.load.b.sync.aligned.col`.
586/// 3. Load the C accumulator fragment ([`WMMA_ACC_FRAG_REGS`] `.f32`
587/// regs) from `%rd0`'s third tile with `wmma.load.c.sync.aligned.row`.
588/// 4. `wmma.mma.sync.aligned.row.col.m16n16k16.f32.f32` — the accumulator
589/// is **paired**: the same `%f` registers appear as both the `c`
590/// operand and the `d` result, so `D = A*B + C` chains in place.
591/// 5. Write the accumulator back to `%rd1` with
592/// `wmma.store.d.sync.aligned.row`.
593///
594/// Register accounting:
595/// * A and B fragments are allocated in the `.b32 %rb` class
596/// (`max_b32` high-water mark) — `2 * WMMA_OPERAND_FRAG_REGS` registers.
597/// * The accumulator is allocated in the existing `.f32 %f` class
598/// (`next_f`) — [`WMMA_ACC_FRAG_REGS`] registers.
599/// * The load/store base pointers are derived into the `.s64 %rd` class;
600/// the highest `%rd` index used is recorded in `max_s64`.
601///
602/// Alignment / leading-dimension assumptions are documented on the module
603/// (`row` stride == tile width == 16, 128-bit aligned base pointers).
604fn lower_wmma_m16n16k16(
605 body: &mut String,
606 next_f: &mut u32,
607 max_b32: &mut u32,
608 max_s64: &mut u32,
609) -> Result<(), EmitError> {
610 // Allocate the contiguous operand-fragment register windows. Each
611 // operand fragment occupies `WMMA_OPERAND_FRAG_REGS` `.b32` regs;
612 // `wmma.load`/`mma.sync` name the window by its base index `%rbN`.
613 let a_frag_base = *max_b32;
614 *max_b32 = max_b32
615 .checked_add(WMMA_OPERAND_FRAG_REGS)
616 .ok_or(EmitError::TooManyRegisters)?;
617 let b_frag_base = *max_b32;
618 *max_b32 = max_b32
619 .checked_add(WMMA_OPERAND_FRAG_REGS)
620 .ok_or(EmitError::TooManyRegisters)?;
621
622 // Accumulator fragment in the f32 class, chained through mma.sync.
623 let acc_frag_base = *next_f;
624 *next_f = next_f
625 .checked_add(WMMA_ACC_FRAG_REGS)
626 .ok_or(EmitError::TooManyRegisters)?;
627
628 // Derive the three tile base pointers into the s64 class. `%rd0`
629 // holds the input pointer (A tile); B and C tiles follow it at the
630 // tile-stride offset (16x16 f16 = 512 B for A/B, 16x16 f32 = 1024 B
631 // for C). `%rd1` holds the output (D tile) pointer. We materialise
632 // %rd2 (B base) and %rd3 (C base) so the loads name distinct bases.
633 let b_base = *max_s64; // %rd2
634 let c_base = b_base + 1; // %rd3
635 *max_s64 = c_base.checked_add(1).ok_or(EmitError::TooManyRegisters)?; // declare through %rd3
636
637 // Leading dimension (stride in elements) for the row/col loads. A
638 // tightly-packed 16-wide tile has stride 16.
639 let stride = WMMA_TILE_DIM;
640 // Byte offsets of the B and C tiles within the input buffer. A is
641 // 16x16 f16 (512 B); B is 16x16 f16 (512 B); C is 16x16 f32 (1024 B).
642 const A_TILE_BYTES: u32 = WMMA_TILE_DIM * WMMA_TILE_DIM * 2; // f16
643 const B_TILE_BYTES: u32 = WMMA_TILE_DIM * WMMA_TILE_DIM * 2; // f16
644 let b_tile_off = A_TILE_BYTES;
645 let c_tile_off = A_TILE_BYTES + B_TILE_BYTES;
646
647 let _ = writeln!(
648 body,
649 " // EXPERIMENTAL / UNVERIFIED ON HARDWARE: wmma m16n16k16 \
650 (row.col, f16xf16->f32)"
651 );
652
653 // Render a `{%rbB, %rbB+1, …}` fragment register list.
654 let frag_list = |base: u32, count: u32, prefix: &str| -> String {
655 let regs: Vec<String> = (0..count)
656 .map(|i| format!("%{prefix}{}", base + i))
657 .collect();
658 format!("{{{}}}", regs.join(", "))
659 };
660 let a_list = frag_list(a_frag_base, WMMA_OPERAND_FRAG_REGS, "rb");
661 let b_list = frag_list(b_frag_base, WMMA_OPERAND_FRAG_REGS, "rb");
662 let acc_list = frag_list(acc_frag_base, WMMA_ACC_FRAG_REGS, "f");
663
664 // Tile base-pointer setup.
665 let _ = writeln!(body, " add.s64 %rd{b_base}, %rd0, {b_tile_off};");
666 let _ = writeln!(body, " add.s64 %rd{c_base}, %rd0, {c_tile_off};");
667
668 // 1+2. Operand-fragment loads (A row-major, B col-major).
669 let _ = writeln!(
670 body,
671 " wmma.load.a.sync.aligned.row.m16n16k16.global.f16 {a_list}, [%rd0], {stride};"
672 );
673 let _ = writeln!(
674 body,
675 " wmma.load.b.sync.aligned.col.m16n16k16.global.f16 {b_list}, [%rd{b_base}], {stride};"
676 );
677 // 3. Accumulator load (paired: read C into the same regs mma writes).
678 let _ = writeln!(
679 body,
680 " wmma.load.c.sync.aligned.row.m16n16k16.global.f32 {acc_list}, [%rd{c_base}], {stride};"
681 );
682 // 4. The mma. `d` and `c` alias `acc_list` — paired accumulator.
683 let _ = writeln!(
684 body,
685 " wmma.mma.sync.aligned.row.col.m16n16k16.f32.f32 \
686 {acc_list}, {a_list}, {b_list}, {acc_list};"
687 );
688 // 5. Store the accumulator fragment back to the D tile in %rd1.
689 let _ = writeln!(
690 body,
691 " wmma.store.d.sync.aligned.row.m16n16k16.global.f32 [%rd1], {acc_list}, {stride};"
692 );
693
694 Ok(())
695}
696
697/// Emit PTX text for a blueprint with caller-supplied config.
698///
699/// Returns [`EmitError::NotYetImplemented`] for blueprints containing ops
700/// the emitter cannot lower (currently: [`TensorWasmOp::MatMul`]).
701pub fn emit_with(
702 blueprint: &TensorWasmKernelBlueprint,
703 cfg: &EmitConfig,
704) -> Result<EmittedPtx, EmitError> {
705 // SECURITY (jit S-1): every `blueprint.entry` value reaches the PTX
706 // template unescaped through several `writeln!` sites below
707 // (`.visible .entry {entry}(`, `{entry}_param_in_ptr`, etc.). A
708 // tenant-controlled entry like `myfunc) … \n.entry attacker(...)`
709 // would close the kernel scope and emit a second kernel. Reject
710 // anything that isn't a well-formed PTX identifier BEFORE writing
711 // a byte of output.
712 if !is_valid_ptx_identifier(&blueprint.entry) {
713 return Err(EmitError::InvalidEntryName {
714 entry: blueprint.entry.clone(),
715 });
716 }
717 // SECURITY (jit L2): bound the emission work BEFORE producing any output.
718 // The lane-bearing ops each drive a `0..lanes` loop in `lower_body`
719 // (see the emission loops in that fn), so a public-API caller supplying
720 // an unbounded `lanes` (e.g. `u32::MAX`) from untrusted dimensions would
721 // attempt to emit billions of PTX lines → CPU/memory DoS. The production
722 // auto-offload path pins `lanes` to `DEFAULT_LANES = 4`; this guard
723 // protects every other embedder of the public `push`/`emit_with` API.
724 // We reject (a) any single op whose `lanes` exceeds `MAX_LANES`, and
725 // (b) blueprints whose aggregate `ops * lanes` budget exceeds
726 // `MAX_EMITTED_OPS`. Computed in `u64` so the running sum cannot itself
727 // overflow before the cap fires.
728 let mut emitted_budget: u64 = 0;
729 for op in &blueprint.ops {
730 let lanes = match op {
731 TensorWasmOp::VecAdd { lanes, .. }
732 | TensorWasmOp::VecMul { lanes, .. }
733 | TensorWasmOp::VecFma { lanes, .. }
734 | TensorWasmOp::LoadUnified { lanes, .. }
735 | TensorWasmOp::StoreUnified { lanes, .. } => *lanes,
736 // MatMul / Barrier emit a fixed, bounded number of lines and
737 // carry no attacker-scalable `lanes` field.
738 TensorWasmOp::MatMul { .. } | TensorWasmOp::Barrier => 0,
739 };
740 if lanes > MAX_LANES {
741 return Err(EmitError::EmissionBudgetExceeded {
742 what: "single op `lanes` exceeds MAX_LANES",
743 });
744 }
745 emitted_budget = emitted_budget.saturating_add(u64::from(lanes));
746 if emitted_budget > MAX_EMITTED_OPS {
747 return Err(EmitError::EmissionBudgetExceeded {
748 what: "aggregate ops * lanes exceeds MAX_EMITTED_OPS",
749 });
750 }
751 }
752 // Rough estimate: ~64 B/op covers the per-op body line plus a fair share
753 // of the fixed prologue/epilogue. Avoids grow-by-doubling reallocs on
754 // the hot emit path.
755 let mut text = String::with_capacity(blueprint.ops.len().saturating_mul(64));
756 let _ = writeln!(text, "//");
757 let _ = writeln!(text, "// Auto-emitted by tensor-wasm-jit::ptx_emit");
758 let _ = writeln!(text, "// entry: {}", blueprint.entry);
759 let _ = writeln!(text, "// ops: {}", blueprint.ops.len());
760 let _ = writeln!(text, "//");
761 let _ = writeln!(text);
762 let _ = writeln!(text, ".version {}", cfg.ptx_version);
763 let _ = writeln!(text, ".target {}", cfg.target);
764 let _ = writeln!(text, ".address_size 64");
765 let _ = writeln!(text);
766
767 let (grid_size, block_size) = blueprint.grid_hint.launch_geometry();
768
769 // Lower the body first so we know how many registers to declare. This
770 // is the critical fix for the prior sham allocator: declare exactly
771 // what we use, not a fixed `8` that overflowed silently.
772 let (body, max_f, max_s32, max_s64, max_pred, max_b32) = lower_body(blueprint, cfg)?;
773
774 let _ = writeln!(text, ".visible .entry {}(", blueprint.entry);
775 let _ = writeln!(text, " .param .u64 {}_param_in_ptr,", blueprint.entry);
776 let _ = writeln!(text, " .param .u64 {}_param_out_ptr,", blueprint.entry);
777 let _ = writeln!(text, " .param .u32 {}_param_n", blueprint.entry);
778 let _ = writeln!(text, ")");
779 if cfg.launch_bounds {
780 // PTX directive — block size cap is hinted to ptxas via .maxntid.
781 // The runtime grid_size lives on the host side (see launch_geometry).
782 let _ = writeln!(text, ".maxntid {block_size}, 1, 1");
783 }
784 let _ = writeln!(text, "{{");
785
786 // Exact-sized register declarations.
787 let _ = writeln!(text, " .reg .pred %p<{max_pred}>;");
788 let _ = writeln!(text, " .reg .s32 %r<{max_s32}>;");
789 let _ = writeln!(text, " .reg .s64 %rd<{max_s64}>;");
790 let _ = writeln!(text, " .reg .f32 %f<{max_f}>;");
791 // EXPERIMENTAL: wmma operand-fragment registers. Only declared when a
792 // wmma MatMul was lowered (max_b32 > 0); otherwise omitted entirely so
793 // the default-path PTX is byte-for-byte unchanged.
794 if max_b32 > 0 {
795 let _ = writeln!(text, " .reg .b32 %rb<{max_b32}>;");
796 }
797 let _ = writeln!(text);
798
799 // Prologue: load the .param declarations into the registers the body
800 // uses. `%rd0` holds the input pointer, `%rd1` holds the output
801 // pointer, `%r0` holds the element count `n`. These mirror the host-
802 // side dispatch ABI in `tensor_wasm_exec::jit_dispatch`.
803 let _ = writeln!(
804 text,
805 " ld.param.u64 %rd0, [{entry}_param_in_ptr];",
806 entry = blueprint.entry,
807 );
808 let _ = writeln!(
809 text,
810 " ld.param.u64 %rd1, [{entry}_param_out_ptr];",
811 entry = blueprint.entry,
812 );
813 let _ = writeln!(
814 text,
815 " ld.param.u32 %r0, [{entry}_param_n];",
816 entry = blueprint.entry,
817 );
818 let _ = writeln!(text);
819
820 text.push_str(&body);
821
822 let _ = writeln!(text);
823 let _ = writeln!(text, " ret;");
824 let _ = writeln!(text, "}}");
825
826 Ok(EmittedPtx {
827 text,
828 launch_geometry: (grid_size, block_size),
829 })
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use crate::ir::{GridHint, TensorWasmKernelBlueprint, TensorWasmOp};
836
837 #[test]
838 fn vector_add_emits_add_f32() {
839 let bp = TensorWasmKernelBlueprint::new("vector_add")
840 .push(TensorWasmOp::LoadUnified {
841 elem: ElemType::F32,
842 lanes: 4,
843 })
844 .push(TensorWasmOp::LoadUnified {
845 elem: ElemType::F32,
846 lanes: 4,
847 })
848 .push(TensorWasmOp::VecAdd {
849 elem: ElemType::F32,
850 lanes: 4,
851 })
852 .push(TensorWasmOp::StoreUnified {
853 elem: ElemType::F32,
854 lanes: 4,
855 });
856 let out = emit(&bp).expect("emit");
857 assert!(out.text.contains(".target sm_80"));
858 assert!(out.text.contains(".visible .entry vector_add"));
859 assert!(out.text.contains("add.f32"));
860 assert!(out.text.contains("ld.global.lu.f32"));
861 assert!(out.text.contains("st.global.cs.f32"));
862 assert!(out.text.contains("ret;"));
863 }
864
865 /// jit CRITICAL (finding 1): an `i32x4` blueprint must emit INTEGER PTX
866 /// (`add.s32` into `%r` registers, `.u32` loads/stores), never the
867 /// `add.f32` float kernel the emitter previously produced for every
868 /// SIMD shape. This is the regression test that pins "integer SIMD is
869 /// not lowered to a float kernel".
870 #[test]
871 fn i32x4_add_emits_integer_ops_not_float() {
872 let bp = TensorWasmKernelBlueprint::new("int_add")
873 .push(TensorWasmOp::LoadUnified {
874 elem: ElemType::I32,
875 lanes: 4,
876 })
877 .push(TensorWasmOp::LoadUnified {
878 elem: ElemType::I32,
879 lanes: 4,
880 })
881 .push(TensorWasmOp::VecAdd {
882 elem: ElemType::I32,
883 lanes: 4,
884 })
885 .push(TensorWasmOp::StoreUnified {
886 elem: ElemType::I32,
887 lanes: 4,
888 });
889 let out = emit(&bp).expect("i32x4 must emit");
890 // Integer add into the integer register class.
891 assert!(
892 out.text.contains("add.s32"),
893 "expected integer add, got:\n{}",
894 out.text
895 );
896 // The float add must NOT appear — that was the miscompile.
897 assert!(
898 !out.text.contains("add.f32"),
899 "i32x4.add must not lower to a float kernel:\n{}",
900 out.text
901 );
902 // Integer loads/stores use the `.u32` width form, and the integer
903 // register class `%r` must be declared above the reserved `%r0`.
904 assert!(out.text.contains("ld.global.lu.u32"));
905 assert!(out.text.contains("st.global.cs.u32"));
906 assert!(out.text.contains("%r1"));
907 }
908
909 /// jit CRITICAL (finding 1): `i32x4.mul` emits `mul.lo.s32`, not
910 /// `mul.f32`.
911 #[test]
912 fn i32x4_mul_emits_integer_mul() {
913 let bp = TensorWasmKernelBlueprint::new("int_mul").push(TensorWasmOp::VecMul {
914 elem: ElemType::I32,
915 lanes: 4,
916 });
917 let out = emit(&bp).expect("i32x4 mul must emit");
918 assert!(out.text.contains("mul.lo.s32"));
919 assert!(!out.text.contains("mul.f32"));
920 }
921
922 /// jit CRITICAL (finding 1): the lane count drives how many instruction
923 /// lines are emitted — an f64x2 op (which the emitter does NOT support
924 /// end-to-end) is refused rather than miscompiled, and a non-default
925 /// lane count is honoured exactly for the supported widths.
926 #[test]
927 fn unsupported_element_width_refused_not_miscompiled() {
928 // f64x2 add reaches the emitter only via the public API (the
929 // rewrite path fails it closed upstream). The emitter must refuse
930 // rather than emit `add.f32`.
931 let bp = TensorWasmKernelBlueprint::new("f64_add").push(TensorWasmOp::VecAdd {
932 elem: ElemType::F64,
933 lanes: 2,
934 });
935 assert!(matches!(emit(&bp), Err(EmitError::NotYetImplemented(_))));
936 }
937
938 /// MatMul lowering is deferred to v0.4. The emitter must refuse to
939 /// produce PTX for blueprints containing a `MatMul` op rather than
940 /// emit a syntactically-valid-but-semantically-broken wmma block
941 /// (the prior emitter referenced undefined `%r1..%r7` and never paired
942 /// the accumulators with a store, so launched kernels would silently
943 /// corrupt state). Callers should treat this as a deopt signal.
944 #[test]
945 fn matmul_emission_is_not_yet_implemented() {
946 let bp = TensorWasmKernelBlueprint::new("matmul_16x16x16").push(TensorWasmOp::MatMul {
947 m: 16,
948 n: 16,
949 k: 16,
950 });
951 let err = emit(&bp).expect_err("MatMul emission must fail until v0.4");
952 assert!(matches!(err, EmitError::NotYetImplemented(_)));
953 }
954
955 /// SAFETY-DEFAULT PIN: the default `EmitConfig` must keep refusing
956 /// MatMul. This locks in the constraint that the experimental wmma
957 /// lowering is opt-in only — a regression that flips the default on
958 /// would fail here before it could reach a GPU launch path.
959 #[test]
960 fn matmul_refused_under_default_config() {
961 let bp = TensorWasmKernelBlueprint::new("matmul_16x16x16").push(TensorWasmOp::MatMul {
962 m: 16,
963 n: 16,
964 k: 16,
965 });
966 // Default config: flag is off.
967 assert!(!EmitConfig::default().enable_experimental_matmul);
968 let err = emit_with(&bp, &EmitConfig::default())
969 .expect_err("MatMul must be refused with the flag off");
970 assert!(matches!(err, EmitError::NotYetImplemented(_)));
971 // The convenience `emit` wrapper uses the default config, so it
972 // must refuse too.
973 assert!(matches!(emit(&bp), Err(EmitError::NotYetImplemented(_))));
974 }
975
976 /// EXPERIMENTAL opt-in: with the flag on, an m16n16k16 MatMul lowers
977 /// to the wmma fragment-load → mma.sync → fragment-store sequence.
978 #[test]
979 fn matmul_opt_in_emits_wmma_sequence() {
980 let bp = TensorWasmKernelBlueprint::new("matmul").push(TensorWasmOp::MatMul {
981 m: 16,
982 n: 16,
983 k: 16,
984 });
985 let cfg = EmitConfig {
986 enable_experimental_matmul: true,
987 ..EmitConfig::default()
988 };
989 let out = emit_with(&bp, &cfg).expect("opt-in emit must succeed");
990 // Fragment loads for both operands + the accumulator.
991 assert!(out.text.contains("wmma.load.a.sync.aligned.row.m16n16k16"));
992 assert!(out.text.contains("wmma.load.b.sync.aligned.col.m16n16k16"));
993 assert!(out.text.contains("wmma.load.c.sync.aligned.row.m16n16k16"));
994 // Exactly one mma.sync for a single k16 tile.
995 assert_eq!(out.text.matches("wmma.mma.sync.aligned").count(), 1);
996 // Paired store of the accumulator fragment.
997 assert!(out.text.contains("wmma.store.d.sync.aligned.row.m16n16k16"));
998 // The operand-fragment register class must be declared.
999 assert!(out.text.contains(".reg .b32 %rb<"));
1000 // The prominent experimental marker rides in the body.
1001 assert!(out.text.contains("EXPERIMENTAL / UNVERIFIED ON HARDWARE"));
1002 }
1003
1004 /// Even with the flag on, only the modelled m16n16k16 shape is
1005 /// lowered — any other tile is still refused (never silently
1006 /// miscompiled).
1007 #[test]
1008 fn matmul_opt_in_rejects_non_16_shape() {
1009 let bp = TensorWasmKernelBlueprint::new("matmul").push(TensorWasmOp::MatMul {
1010 m: 32,
1011 n: 32,
1012 k: 32,
1013 });
1014 let cfg = EmitConfig {
1015 enable_experimental_matmul: true,
1016 ..EmitConfig::default()
1017 };
1018 assert!(matches!(
1019 emit_with(&bp, &cfg),
1020 Err(EmitError::NotYetImplemented(_))
1021 ));
1022 }
1023
1024 /// The default (flag-off) PTX must be byte-identical to before the
1025 /// experimental wmma support was added for non-MatMul blueprints: the
1026 /// `.b32 %rb` operand-fragment declaration only appears when a wmma
1027 /// MatMul is actually lowered.
1028 #[test]
1029 fn no_wmma_reg_decl_without_matmul() {
1030 let bp = TensorWasmKernelBlueprint::new("vector_add")
1031 .push(TensorWasmOp::LoadUnified {
1032 elem: ElemType::F32,
1033 lanes: 4,
1034 })
1035 .push(TensorWasmOp::LoadUnified {
1036 elem: ElemType::F32,
1037 lanes: 4,
1038 })
1039 .push(TensorWasmOp::VecAdd {
1040 elem: ElemType::F32,
1041 lanes: 4,
1042 })
1043 .push(TensorWasmOp::StoreUnified {
1044 elem: ElemType::F32,
1045 lanes: 4,
1046 });
1047 let out = emit(&bp).expect("emit");
1048 assert!(!out.text.contains("%rb<"));
1049 assert!(!out.text.contains("wmma."));
1050 }
1051
1052 /// MatMul anywhere in the op stream taints the whole blueprint —
1053 /// not just blueprints whose only op is MatMul.
1054 #[test]
1055 fn matmul_in_mixed_stream_also_refused() {
1056 let bp = TensorWasmKernelBlueprint::new("mixed")
1057 .push(TensorWasmOp::LoadUnified {
1058 elem: ElemType::F32,
1059 lanes: 4,
1060 })
1061 .push(TensorWasmOp::MatMul {
1062 m: 16,
1063 n: 16,
1064 k: 16,
1065 })
1066 .push(TensorWasmOp::StoreUnified {
1067 elem: ElemType::F32,
1068 lanes: 4,
1069 });
1070 assert!(matches!(emit(&bp), Err(EmitError::NotYetImplemented(_))));
1071 }
1072
1073 #[test]
1074 fn ptx_header_includes_version_and_target() {
1075 let bp = TensorWasmKernelBlueprint::new("noop");
1076 let out = emit(&bp).expect("emit");
1077 assert!(out.text.contains(".version 8.0"));
1078 assert!(out.text.contains(".target sm_80"));
1079 assert!(out.text.contains(".address_size 64"));
1080 }
1081
1082 #[test]
1083 fn launch_geometry_in_header() {
1084 let bp = TensorWasmKernelBlueprint::new("k").with_grid(GridHint {
1085 total_threads: 1024,
1086 preferred_block_size: 128,
1087 });
1088 let out = emit(&bp).expect("emit");
1089 assert_eq!(out.launch_geometry, (8, 128));
1090 assert!(out.text.contains(".maxntid 128, 1, 1"));
1091 // Negative assertion: the broken C++-annotation form must not appear.
1092 assert!(!out.text.contains("__launch_bounds__"));
1093 }
1094
1095 #[test]
1096 fn barrier_emits_bar_sync() {
1097 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::Barrier);
1098 let out = emit(&bp).expect("emit");
1099 assert!(out.text.contains("bar.sync 0;"));
1100 }
1101
1102 #[test]
1103 fn fma_emits_fma_rn() {
1104 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecFma {
1105 elem: ElemType::F32,
1106 lanes: 4,
1107 });
1108 let out = emit(&bp).expect("emit");
1109 assert!(out.text.contains("fma.rn.f32"));
1110 }
1111
1112 #[test]
1113 fn custom_target() {
1114 let bp = TensorWasmKernelBlueprint::new("k");
1115 let cfg = EmitConfig {
1116 target: "sm_89".into(),
1117 ..EmitConfig::default()
1118 };
1119 let out = emit_with(&bp, &cfg).expect("emit");
1120 assert!(out.text.contains(".target sm_89"));
1121 }
1122
1123 #[test]
1124 fn emitted_text_nonempty() {
1125 let bp = TensorWasmKernelBlueprint::new("k");
1126 let out = emit(&bp).expect("emit");
1127 assert!(!out.is_empty());
1128 }
1129
1130 /// Critical correctness assertion: each lane-op produces a *fresh*
1131 /// register. The old emitter cycled `%f0..%f3` which silently read
1132 /// undefined values after four ops. With proper allocation, an 8-lane
1133 /// add produces `%f0..%fN` with strictly increasing destinations.
1134 #[test]
1135 fn register_allocator_assigns_fresh_destination_per_op() {
1136 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
1137 elem: ElemType::F32,
1138 lanes: 8,
1139 });
1140 let out = emit(&bp).expect("emit");
1141 // After lowering, we expect at least registers %f0 through %f7 to
1142 // appear as add destinations. The exact regex match would couple
1143 // the test to the syntax, so we just count distinct destinations.
1144 let mut destinations = std::collections::BTreeSet::new();
1145 for line in out.text.lines() {
1146 if let Some(rest) = line.trim_start().strip_prefix("add.f32 %f") {
1147 if let Some((reg, _)) = rest.split_once(',') {
1148 if let Ok(n) = reg.trim().parse::<u32>() {
1149 destinations.insert(n);
1150 }
1151 }
1152 }
1153 }
1154 assert!(
1155 destinations.len() >= 8,
1156 "expected at least 8 distinct add destinations, got {} ({:?})",
1157 destinations.len(),
1158 destinations,
1159 );
1160 }
1161
1162 #[test]
1163 fn register_declarations_match_usage() {
1164 // 16 lanes of add will need at least 16 fresh registers (plus the
1165 // operand registers materialised on stack-underflow). The header
1166 // `.reg .f32 %f<N>` must declare at least N regs.
1167 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
1168 elem: ElemType::F32,
1169 lanes: 16,
1170 });
1171 let out = emit(&bp).expect("emit");
1172 // Find the .reg .f32 declaration and parse out the count.
1173 let count_line = out
1174 .text
1175 .lines()
1176 .find(|l| l.contains(".reg .f32"))
1177 .expect("missing .reg .f32 declaration");
1178 let count: u32 = count_line
1179 .split('<')
1180 .nth(1)
1181 .and_then(|s| s.split('>').next())
1182 .and_then(|s| s.parse().ok())
1183 .expect("parse reg count");
1184 assert!(
1185 count >= 16,
1186 "register declaration must cover all uses (got {count})"
1187 );
1188 }
1189
1190 #[test]
1191 fn prologue_loads_params_into_registers() {
1192 let bp = TensorWasmKernelBlueprint::new("vec_op");
1193 let out = emit(&bp).expect("emit");
1194 assert!(out.text.contains("ld.param.u64 %rd0"));
1195 assert!(out.text.contains("ld.param.u64 %rd1"));
1196 assert!(out.text.contains("ld.param.u32 %r0"));
1197 }
1198
1199 #[test]
1200 fn loads_advance_input_offset() {
1201 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::LoadUnified {
1202 elem: ElemType::F32,
1203 lanes: 3,
1204 });
1205 let out = emit(&bp).expect("emit");
1206 // First lane at [%rd0], next at [%rd0+4], next at [%rd0+8].
1207 assert!(out.text.contains("[%rd0];"), "first load uses no offset");
1208 assert!(out.text.contains("[%rd0+4]"), "second lane at offset 4");
1209 assert!(out.text.contains("[%rd0+8]"), "third lane at offset 8");
1210 }
1211
1212 /// DoS guardrail (jit L2): a single op requesting more than `MAX_LANES`
1213 /// lanes is refused with the structured `EmissionBudgetExceeded` error
1214 /// before any PTX is produced. The public `push` API accepts arbitrary
1215 /// `lanes`, so without this the emitter would loop `0..u32::MAX`.
1216 #[test]
1217 fn oversized_lanes_is_rejected() {
1218 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
1219 elem: ElemType::F32,
1220 lanes: u32::MAX,
1221 });
1222 let err = emit(&bp).expect_err("oversized lanes must be refused");
1223 assert!(matches!(err, EmitError::EmissionBudgetExceeded { .. }));
1224 // Just over the per-op cap is also refused.
1225 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::LoadUnified {
1226 elem: ElemType::F32,
1227 lanes: MAX_LANES + 1,
1228 });
1229 assert!(matches!(
1230 emit(&bp),
1231 Err(EmitError::EmissionBudgetExceeded { .. })
1232 ));
1233 }
1234
1235 /// DoS guardrail (jit L2): even when each op is within `MAX_LANES`, a
1236 /// long enough op stream whose aggregate `ops * lanes` exceeds
1237 /// `MAX_EMITTED_OPS` is refused.
1238 #[test]
1239 fn aggregate_emission_budget_is_enforced() {
1240 // Each op is at the per-op cap; enough of them to blow the aggregate.
1241 let n_ops = (MAX_EMITTED_OPS / u64::from(MAX_LANES)) as usize + 2;
1242 let mut bp = TensorWasmKernelBlueprint::new("k");
1243 for _ in 0..n_ops {
1244 bp = bp.push(TensorWasmOp::VecMul {
1245 elem: ElemType::F32,
1246 lanes: MAX_LANES,
1247 });
1248 }
1249 assert!(matches!(
1250 emit(&bp),
1251 Err(EmitError::EmissionBudgetExceeded { .. })
1252 ));
1253 }
1254
1255 /// A blueprint at exactly the `MAX_LANES` per-op bound (and well under
1256 /// the aggregate cap) still emits successfully — the guard rejects only
1257 /// what is over budget.
1258 #[test]
1259 fn lanes_at_max_still_emits() {
1260 let bp = TensorWasmKernelBlueprint::new("k").push(TensorWasmOp::VecAdd {
1261 elem: ElemType::F32,
1262 lanes: MAX_LANES,
1263 });
1264 assert!(emit(&bp).is_ok());
1265 }
1266
1267 #[test]
1268 fn stores_advance_output_offset() {
1269 let bp = TensorWasmKernelBlueprint::new("k")
1270 .push(TensorWasmOp::LoadUnified {
1271 elem: ElemType::F32,
1272 lanes: 2,
1273 })
1274 .push(TensorWasmOp::StoreUnified {
1275 elem: ElemType::F32,
1276 lanes: 2,
1277 });
1278 let out = emit(&bp).expect("emit");
1279 assert!(out.text.contains("[%rd1]"));
1280 assert!(out.text.contains("[%rd1+4]"));
1281 }
1282}