logicaffeine_compile/codegen_sass.rs
1//! codegen_sass — the foundation of our OWN sm_86 SASS assembler (the MaxAs / turingas lever).
2//!
3//! `ptxas` is the only PTX→SASS path and it is closed-source: it fixes our register allocation and, crucially,
4//! the per-instruction CONTROL CODES (stall counts, yields, read/write barriers, the operand reuse cache) that
5//! decide how well a hot loop hides latency. That last 5-20% cuBLAS keeps lives there. To take it we must read
6//! AND write SASS ourselves. This module is that capability: locate the kernel's machine code in the cubin ELF,
7//! decode every 128-bit instruction's control field (verified bit-exact against `nvdisasm` on sm_86), re-encode
8//! it, and patch the bytes back. A scheduler/optimizer on top is the follow-on; this is the assembler core.
9//!
10//! Ampere (sm_80/86) 128-bit instruction = (lo:u64, hi:u64). The control field sits in the high word:
11//!
12//! ```text
13//! stall bits [41:44] yield bit [45] wr_barrier [46:48] rd_barrier [49:51] wait_mask [52:57] reuse [58:61]
14//! ```
15//!
16//! (barrier index 7 = "none".) Verified: S2R/S2UR/memory ops set a write barrier; pure ALU ops do not.
17
18use std::path::Path;
19use std::process::Command;
20
21/// One decoded SASS instruction: its byte offset within the .text section and the raw 128-bit encoding.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct SassInsn {
24 pub offset: usize,
25 pub lo: u64,
26 pub hi: u64,
27}
28
29/// The per-instruction control codes — where the GPU's static scheduling lives.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub struct Control {
32 /// Fixed stall before issuing the next instruction (0–15 cycles).
33 pub stall: u8,
34 /// Yield hint (let another warp issue).
35 pub yield_: bool,
36 /// Write-barrier scoreboard this instruction sets (7 = none) — for variable-latency results.
37 pub wr_barrier: u8,
38 /// Read-barrier scoreboard this instruction sets (7 = none) — for operand-still-in-use.
39 pub rd_barrier: u8,
40 /// Which barrier scoreboards this instruction waits on (6-bit mask).
41 pub wait_mask: u8,
42 /// Operand reuse-cache flags (4 bits) — avoids re-reading the register file.
43 pub reuse: u8,
44}
45
46impl Control {
47 /// Decode the control field out of the high 64-bit word.
48 pub fn decode(hi: u64) -> Control {
49 Control {
50 stall: ((hi >> 41) & 0xF) as u8,
51 yield_: ((hi >> 45) & 0x1) != 0,
52 wr_barrier: ((hi >> 46) & 0x7) as u8,
53 rd_barrier: ((hi >> 49) & 0x7) as u8,
54 wait_mask: ((hi >> 52) & 0x3F) as u8,
55 reuse: ((hi >> 58) & 0xF) as u8,
56 }
57 }
58
59 /// Write this control field back into `hi`, replacing exactly the control bits and leaving the rest intact.
60 pub fn apply(&self, hi: u64) -> u64 {
61 // Clear bits [41:61] then set them from the fields.
62 let clear_mask: u64 = ((1u64 << (61 - 41 + 1)) - 1) << 41;
63 let mut h = hi & !clear_mask;
64 h |= ((self.stall & 0xF) as u64) << 41;
65 h |= ((self.yield_ as u64) & 0x1) << 45;
66 h |= ((self.wr_barrier & 0x7) as u64) << 46;
67 h |= ((self.rd_barrier & 0x7) as u64) << 49;
68 h |= ((self.wait_mask & 0x3F) as u64) << 52;
69 h |= ((self.reuse & 0xF) as u64) << 58;
70 h
71 }
72}
73
74/// Locate the `.text.<kernel>` section in a cubin ELF64: returns (file_offset, size_in_bytes).
75pub fn find_text_section(cubin: &[u8]) -> Option<(usize, usize)> {
76 if cubin.len() < 64 || &cubin[0..4] != b"\x7fELF" {
77 return None;
78 }
79 let rd_u64 = |o: usize| u64::from_le_bytes(cubin[o..o + 8].try_into().unwrap());
80 let rd_u16 = |o: usize| u16::from_le_bytes(cubin[o..o + 2].try_into().unwrap());
81 let e_shoff = rd_u64(0x28) as usize;
82 let e_shentsize = rd_u16(0x3a) as usize;
83 let e_shnum = rd_u16(0x3c) as usize;
84 let e_shstrndx = rd_u16(0x3e) as usize;
85 // section-header string table
86 let shstr_hdr = e_shoff + e_shstrndx * e_shentsize;
87 let shstr_off = rd_u64(shstr_hdr + 0x18) as usize;
88 for i in 0..e_shnum {
89 let sh = e_shoff + i * e_shentsize;
90 let name_off = u32::from_le_bytes(cubin[sh..sh + 4].try_into().unwrap()) as usize;
91 let name_start = shstr_off + name_off;
92 let name_end = cubin[name_start..]
93 .iter()
94 .position(|&b| b == 0)
95 .map(|p| name_start + p)?;
96 let name = std::str::from_utf8(&cubin[name_start..name_end]).ok()?;
97 if name.starts_with(".text.") {
98 let off = rd_u64(sh + 0x18) as usize;
99 let size = rd_u64(sh + 0x20) as usize;
100 return Some((off, size));
101 }
102 }
103 None
104}
105
106/// Parse every 128-bit instruction out of the kernel's .text section of a cubin.
107pub fn instructions(cubin: &[u8]) -> Vec<SassInsn> {
108 let Some((off, size)) = find_text_section(cubin) else {
109 return Vec::new();
110 };
111 let mut out = Vec::new();
112 let mut o = off;
113 while o + 16 <= off + size {
114 let lo = u64::from_le_bytes(cubin[o..o + 8].try_into().unwrap());
115 let hi = u64::from_le_bytes(cubin[o + 8..o + 16].try_into().unwrap());
116 out.push(SassInsn {
117 offset: o - off,
118 lo,
119 hi,
120 });
121 o += 16;
122 }
123 out
124}
125
126/// Patch instruction `idx`'s high word (control codes live here) in-place in the cubin bytes.
127pub fn patch_hi(cubin: &mut [u8], idx: usize, new_hi: u64) {
128 if let Some((off, _)) = find_text_section(cubin) {
129 let o = off + idx * 16 + 8;
130 cubin[o..o + 8].copy_from_slice(&new_hi.to_le_bytes());
131 }
132}
133
134impl SassInsn {
135 /// Source operand register Ra (Ampere: low word bits `[24:31]`). 255 = RZ (the zero register).
136 pub fn ra(&self) -> u8 {
137 ((self.lo >> 24) & 0xFF) as u8
138 }
139 /// Source operand register Rb (low word bits `[32:39]`) — only a real register for register-form opcodes.
140 pub fn rb(&self) -> u8 {
141 ((self.lo >> 32) & 0xFF) as u8
142 }
143 /// Destination register Rd (low word bits `[16:23]`).
144 pub fn rd(&self) -> u8 {
145 ((self.lo >> 16) & 0xFF) as u8
146 }
147}
148
149/// Execution-unit class of a SASS opcode — what a scheduler must know to place it and set control codes.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
151pub enum SassClass {
152 /// Fixed-latency integer/logic ALU (IMAD, IADD3, LEA, SHF, LOP3, ISETP, SEL, MOV, PRMT, …).
153 Alu,
154 /// Fixed-latency FP32 (FADD, FFMA, FMUL, FSETP, …) and FP16x2 (HADD2, HFMA2, …).
155 Fp,
156 /// Fixed-latency FP64 (DADD, DFMA, DMUL, …) — longer than FP32.
157 Fp64,
158 /// Multi-function unit (MUFU: rcp/sqrt/sin) — long fixed latency.
159 Mufu,
160 /// Tensor core matrix-multiply-accumulate (IMMA/HMMA) — fixed throughput (~8 cyc/int8 IMMA).
161 Tensor,
162 /// Variable-latency memory that SETS A WRITE BARRIER (LDG/LDS/LDSM/LDL/LD/ATOMG/CS2R/S2R/…).
163 Memory,
164 /// Asynchronous copy global->shared (LDGSTS = cp.async) — completion tracked by the pipeline, not a barrier.
165 AsyncCopy,
166 /// Store (STG/STS/STL/ST) — no result register.
167 Store,
168 /// Control-flow / barriers / sync (BRA/BSSY/BSYNC/BAR/WARPSYNC/EXIT/CALL/RET/YIELD/MEMBAR/…).
169 Control,
170 /// Uniform-datapath (scalar per-warp) ops (ULDC/UIADD3/UMOV/USHF/…).
171 Uniform,
172 /// Anything not yet classified.
173 Other,
174}
175
176/// Classify an sm_86 SASS base mnemonic (the token before the first `.`). Covers the full 81-opcode inventory
177/// extracted from real machine code (our GEMM + cuBLAS) — this is the ISA a SASS scheduler operates over.
178pub fn classify_opcode(mnemonic: &str) -> SassClass {
179 use SassClass::*;
180 let base = mnemonic.split('.').next().unwrap_or(mnemonic);
181 match base {
182 // tensor cores
183 "IMMA" | "HMMA" | "BMMA" => Tensor,
184 // async copy (cp.async)
185 "LDGSTS" => AsyncCopy,
186 // variable-latency loads (set a write barrier) + special-register reads
187 "LDG" | "LDS" | "LDSM" | "LDL" | "LD" | "LDGDEPBAR" | "ATOMG" | "ATOMS" | "RED" | "S2R"
188 | "S2UR" | "CS2R" | "CCTL" | "SHFL" | "VOTEU" => Memory,
189 // stores
190 "STG" | "STS" | "STL" | "ST" => Store,
191 // fp64
192 "DADD" | "DMUL" | "DFMA" | "DSETP" => Fp64,
193 // fp32 + fp16
194 "FADD" | "FMUL" | "FFMA" | "FSETP" | "FSEL" | "FMNMX" | "FCHK" | "HADD2" | "HMUL2"
195 | "HFMA2" | "HSETP2" | "F2F" | "F2I" | "I2F" | "I2FP" | "F2FP" | "FRND" | "F2IP" => Fp,
196 "MUFU" => Mufu,
197 // integer/logic ALU
198 "IMAD" | "IADD3" | "LEA" | "SHF" | "LOP3" | "PLOP3" | "ISETP" | "SEL" | "MOV" | "PRMT"
199 | "IMNMX" | "FLO" | "POPC" | "BFE" | "BFI" | "P2R" | "R2P" | "B2R" | "R2UR" | "NOP" => Alu,
200 // uniform datapath
201 "ULDC" | "UIADD3" | "UIMAD" | "USHF" | "ULEA" | "UMOV" | "UISETP" | "ULOP3" | "USEL"
202 | "UPLOP3" => Uniform,
203 // control / barriers
204 "BRA" | "BSSY" | "BSYNC" | "BAR" | "WARPSYNC" | "EXIT" | "CALL" | "RET" | "YIELD"
205 | "MEMBAR" | "ERRBAR" | "BREAK" | "BMOV" | "NANOSLEEP" | "DEPBAR" | "PBK" | "SSY" => {
206 Control
207 }
208 _ => Other,
209 }
210}
211
212impl SassClass {
213 /// Fixed issue-to-RESULT latency in cycles — the delay before a dependent consumer may read the result.
214 /// CALIBRATED by microbenchmark on this sm_86 GPU (clock64 dependent chains), NOT guessed: ALU/FP ~4.2-4.5
215 /// (round to 5), int8 IMMA = 24 (MEASURED — a guessed 8 under-stalls the cross-iteration accumulate and
216 /// hangs). Variable-latency classes (Memory/AsyncCopy) use write barriers instead of a fixed stall.
217 pub fn fixed_latency(&self) -> u8 {
218 match self {
219 SassClass::Alu | SassClass::Uniform => 5, // measured IMAD 4.48, SHF/LOP3 4.21
220 SassClass::Fp => 5, // measured FFMA 4.21
221 SassClass::Fp64 => 8,
222 SassClass::Mufu => 10,
223 SassClass::Tensor => 24, // MEASURED int8 m16n8k32 dependent-accumulate latency (was 8 => hung)
224 SassClass::Store | SassClass::Control => 1,
225 SassClass::Memory | SassClass::AsyncCopy | SassClass::Other => 2,
226 }
227 }
228
229 /// Functional-unit THROUGHPUT — minimum cycles between consecutive ops on the same pipe even with no
230 /// register dependency. CALIBRATED on sm_86: ALU ~1.2, FP32 ~2.7, int8 IMMA ~16 for a single warp (ptxas
231 /// uses ~8 assuming >=2 warps interleave the tensor pipe). Memory issue ~2. This is the constraint that
232 /// serializes independent IMMAs and independent FFMAs — the register-only model misses it.
233 pub fn throughput(&self) -> u8 {
234 match self {
235 SassClass::Tensor => 16, // single-warp measured; scheduler halves this per extra interleaved warp
236 SassClass::Fp => 3, // measured FFMA throughput 2.65
237 SassClass::Fp64 => 4,
238 SassClass::Mufu => 4,
239 SassClass::Memory | SassClass::AsyncCopy | SassClass::Store => 2,
240 _ => 1, // measured IMAD throughput 1.21
241 }
242 }
243 /// Measured VARIABLE-latency result delay in cycles for barrier-tracked ops (L1/shared hit): LDG/LDSM ~45
244 /// on sm_86 (pointer-chasing microbenchmark). The scheduler doesn't stall for this (a barrier does), but it
245 /// uses it to know these results are FAR from ready — a consumer MUST wait on the scoreboard, never a stall.
246 pub fn variable_result_latency(&self) -> u32 {
247 match self {
248 SassClass::Memory | SassClass::AsyncCopy => 45,
249 _ => 0,
250 }
251 }
252
253 /// Whether this op's result is produced with VARIABLE latency and therefore must set a write barrier
254 /// (scoreboard) that dependent consumers wait on — the key fact a scheduler needs for correctness.
255 pub fn is_variable_latency(&self) -> bool {
256 matches!(self, SassClass::Memory | SassClass::AsyncCopy)
257 }
258}
259
260impl SassInsn {
261 /// The registers this instruction WRITES, given its execution `class` (from the nvdisasm mnemonic), SOUNDLY
262 /// over-approximated — never under-count, or the scheduler would drop a real hazard. Multi-register
263 /// producers (IMMA's 4-reg accumulator, wide LDSM/LDG) claim a conservative range from Rd; stores/control
264 /// write nothing.
265 pub fn writes_for(&self, class: SassClass) -> Vec<u8> {
266 let rd = self.rd();
267 if rd == 255 {
268 return Vec::new();
269 }
270 let width: u8 = match class {
271 SassClass::Tensor => 4,
272 SassClass::Memory | SassClass::AsyncCopy => 4,
273 SassClass::Store | SassClass::Control => 0,
274 _ => 1,
275 };
276 (0..width)
277 .map(|k| rd.wrapping_add(k))
278 .filter(|&r| r != 255)
279 .collect()
280 }
281
282 /// The registers this instruction READS, given its `class`, soundly over-approximated. ALU/FP read Ra,Rb;
283 /// tensor ops also read accumulator (Rd..) + A-frag (Ra..) + B-frag (Rb..); stores read data Rd + addr Ra.
284 pub fn reads_for(&self, class: SassClass) -> Vec<u8> {
285 let (ra, rb, rd) = (self.ra(), self.rb(), self.rd());
286 let mut r = Vec::new();
287 let mut push = |x: u8| {
288 if x != 255 && !r.contains(&x) {
289 r.push(x);
290 }
291 };
292 match class {
293 SassClass::Tensor => {
294 for k in 0..4 {
295 push(rd.wrapping_add(k));
296 push(ra.wrapping_add(k));
297 }
298 push(rb);
299 push(rb.wrapping_add(1));
300 }
301 SassClass::Store => {
302 push(rd);
303 push(ra);
304 push(ra.wrapping_add(1));
305 }
306 SassClass::Memory | SassClass::AsyncCopy => {
307 push(ra);
308 push(ra.wrapping_add(1));
309 }
310 SassClass::Control => {}
311 _ => {
312 push(ra);
313 push(rb);
314 }
315 }
316 r
317 }
318}
319
320/// PRECISE operand decode from the nvdisasm TEXT of one instruction (everything after the mnemonic, before the
321/// `;`). nvdisasm already prints the exact registers, so we parse them rather than reverse-engineer the binary
322/// operand fields. Returns (writes, reads) as register numbers (RZ/255 dropped). Rules, verified against real
323/// SASS: operand 0 is the WRITE destination UNLESS it is a memory ref `[...]` (a store — then it's a read
324/// address and nothing is written); every other operand is a READ; registers inside `[...]` are address reads;
325/// a `.64`/`.128` width on an address adds the consecutive address registers; accumulate ops (IMMA/FFMA)
326/// naturally read their destination because it reappears as a later operand. Groups `{R8,R9,...}` expand.
327pub fn precise_operands(operand_text: &str) -> (Vec<u8>, Vec<u8>) {
328 // split the operand list on commas at brace/bracket depth 0.
329 let mut parts: Vec<String> = Vec::new();
330 let (mut depth, mut cur) = (0i32, String::new());
331 for ch in operand_text.chars() {
332 match ch {
333 '{' | '[' => {
334 depth += 1;
335 cur.push(ch);
336 }
337 '}' | ']' => {
338 depth -= 1;
339 cur.push(ch);
340 }
341 ',' if depth == 0 => {
342 parts.push(cur.trim().to_string());
343 cur.clear();
344 }
345 _ => cur.push(ch),
346 }
347 }
348 if !cur.trim().is_empty() {
349 parts.push(cur.trim().to_string());
350 }
351
352 // extract register numbers from a token (handles R12, R12.reuse, {R8, R9}, [R16.64+0x10], etc.).
353 fn regs_in(tok: &str) -> Vec<u8> {
354 let bytes = tok.as_bytes();
355 let mut out = Vec::new();
356 let mut i = 0;
357 while i < bytes.len() {
358 // a register is 'R' followed by digits, not preceded by a letter (avoid 'UR', 'PR', 'SR').
359 if bytes[i] == b'R'
360 && (i == 0 || !bytes[i - 1].is_ascii_alphabetic())
361 && i + 1 < bytes.len()
362 && bytes[i + 1].is_ascii_digit()
363 {
364 let mut j = i + 1;
365 let mut num = 0u32;
366 while j < bytes.len() && bytes[j].is_ascii_digit() {
367 num = num * 10 + (bytes[j] - b'0') as u32;
368 j += 1;
369 }
370 if num < 255 {
371 out.push(num as u8);
372 }
373 i = j;
374 } else {
375 i += 1;
376 }
377 }
378 out
379 }
380 // widen an address register by .64/.128 (adds the consecutive pair/quad).
381 fn addr_regs(tok: &str) -> Vec<u8> {
382 let mut base = regs_in(tok);
383 let extra = if tok.contains(".128") {
384 3
385 } else if tok.contains(".64") {
386 1
387 } else {
388 0
389 };
390 if let Some(&b0) = base.first() {
391 for k in 1..=extra {
392 let r = b0.wrapping_add(k as u8);
393 if r != 255 {
394 base.push(r);
395 }
396 }
397 }
398 base
399 }
400
401 let mut writes = Vec::new();
402 let mut reads = Vec::new();
403 for (idx, p) in parts.iter().enumerate() {
404 let is_mem = p.starts_with('[') || (p.starts_with("desc") && p.contains('['));
405 if idx == 0 && !is_mem {
406 // destination register(s).
407 for r in regs_in(p) {
408 if !writes.contains(&r) {
409 writes.push(r);
410 }
411 }
412 } else if is_mem {
413 for r in addr_regs(p) {
414 if !reads.contains(&r) {
415 reads.push(r);
416 }
417 }
418 } else {
419 for r in regs_in(p) {
420 if !reads.contains(&r) {
421 reads.push(r);
422 }
423 }
424 }
425 }
426 (writes, reads)
427}
428
429/// Build a PRECISE dependency DAG from a cubin by parsing nvdisasm's exact operands per instruction (not the
430/// conservative range approximation). This gives the scheduler maximal legal freedom — the graph has only the
431/// TRUE hazards, so a reschedule can hide latency ptxas left exposed. Returns (insns, classes, mnemonics, DAG).
432pub fn precise_dependency_graph(
433 cubin_path: &Path,
434) -> Option<(Vec<SassInsn>, Vec<SassClass>, DepGraph)> {
435 let bytes = std::fs::read(cubin_path).ok()?;
436 let insns = instructions(&bytes);
437 if insns.is_empty() {
438 return None;
439 }
440 let dis = nvdisasm(cubin_path)?;
441 // per-instruction (mnemonic, operand-text) from the disassembly lines, in order.
442 let mut ops: Vec<(String, String)> = Vec::new();
443 for l in dis.lines() {
444 let s = l.trim();
445 if s.starts_with("/*") {
446 if let Some(rest) = s.split("*/").nth(1) {
447 let body = rest.split(';').next().unwrap_or("").trim();
448 // strip a leading predicate '@P0' / '@!P0'
449 let body = body
450 .strip_prefix('@')
451 .map(|b| b.split_whitespace().skip(1).collect::<Vec<_>>().join(" "))
452 .unwrap_or_else(|| body.to_string());
453 let mut it = body.splitn(2, char::is_whitespace);
454 let mnem = it.next().unwrap_or("").to_string();
455 let operands = it.next().unwrap_or("").to_string();
456 if !mnem.is_empty() {
457 ops.push((mnem, operands));
458 }
459 }
460 }
461 }
462 let n = insns.len().min(ops.len());
463 let classes: Vec<SassClass> = (0..n).map(|i| classify_opcode(&ops[i].0)).collect();
464 let mut reads: Vec<Vec<u8>> = Vec::with_capacity(n);
465 let mut writes: Vec<Vec<u8>> = Vec::with_capacity(n);
466 for i in 0..n {
467 let (w, r) = precise_operands(&ops[i].1);
468 writes.push(w);
469 reads.push(r);
470 }
471 let is_barrier: Vec<bool> = classes.iter().map(|c| *c == SassClass::Control).collect();
472 let g = build_dependency_graph(&reads, &writes, &is_barrier);
473 Some((insns[..n].to_vec(), classes, g))
474}
475
476/// Build the dependency DAG directly from a cubin, using nvdisasm to name each opcode (so classification —
477/// hence the read/write sets — is precise). Returns the graph paired with the parsed instructions. This is the
478/// mnemonic-aware, SOUND path the scheduler uses.
479pub fn dependency_graph_from_cubin(
480 cubin_path: &Path,
481) -> Option<(Vec<SassInsn>, Vec<SassClass>, DepGraph)> {
482 let bytes = std::fs::read(cubin_path).ok()?;
483 let insns = instructions(&bytes);
484 if insns.is_empty() {
485 return None;
486 }
487 let dis = nvdisasm(cubin_path)?;
488 let mnems: Vec<String> = dis
489 .lines()
490 .filter_map(|l| {
491 let s = l.trim();
492 if s.starts_with("/*") {
493 s.split("*/")
494 .nth(1)
495 .and_then(|r| r.trim().split_whitespace().next())
496 .map(|m| m.to_string())
497 } else {
498 None
499 }
500 })
501 .collect();
502 let classes: Vec<SassClass> = (0..insns.len())
503 .map(|i| classify_opcode(mnems.get(i).map(|s| s.as_str()).unwrap_or("IMAD")))
504 .collect();
505 let reads: Vec<Vec<u8>> = insns
506 .iter()
507 .zip(&classes)
508 .map(|(ins, &c)| ins.reads_for(c))
509 .collect();
510 let writes: Vec<Vec<u8>> = insns
511 .iter()
512 .zip(&classes)
513 .map(|(ins, &c)| ins.writes_for(c))
514 .collect();
515 let is_barrier: Vec<bool> = classes.iter().map(|c| *c == SassClass::Control).collect();
516 let g = build_dependency_graph(&reads, &writes, &is_barrier);
517 Some((insns, classes, g))
518}
519
520/// A register-dependency DAG over a straight-line SASS region: `deps[i]` lists earlier instructions that `i`
521/// must stay after (RAW/WAR/WAW hazards + barrier ordering). Any reordering that respects these edges preserves
522/// semantics — this is what the scheduler is free to permute to hide latency.
523#[derive(Debug, Clone)]
524pub struct DepGraph {
525 pub deps: Vec<Vec<usize>>,
526}
527
528/// Build the dependency DAG from per-instruction (reads, writes, is_barrier) triples. Barriers (BAR/BSYNC/
529/// control) force a total order across themselves: nothing moves past a barrier in either direction.
530pub fn build_dependency_graph(
531 reads: &[Vec<u8>],
532 writes: &[Vec<u8>],
533 is_barrier: &[bool],
534) -> DepGraph {
535 let n = reads.len();
536 let mut deps: Vec<Vec<usize>> = vec![Vec::new(); n];
537 let mut last_writer: [Option<usize>; 256] = [None; 256];
538 let mut readers: Vec<Vec<usize>> = vec![Vec::new(); 256];
539 let mut last_barrier: Option<usize> = None;
540 let add = |deps: &mut Vec<Vec<usize>>, i: usize, j: usize| {
541 if i != j && !deps[i].contains(&j) {
542 deps[i].push(j);
543 }
544 };
545 for i in 0..n {
546 // barrier ordering: this instruction stays after the previous barrier.
547 if let Some(b) = last_barrier {
548 add(&mut deps, i, b);
549 }
550 // RAW: read-after-write on each source register.
551 for &r in &reads[i] {
552 if let Some(w) = last_writer[r as usize] {
553 add(&mut deps, i, w);
554 }
555 }
556 // WAW + WAR on each destination register.
557 for &w in &writes[i] {
558 if let Some(pw) = last_writer[w as usize] {
559 add(&mut deps, i, pw); // WAW
560 }
561 let rs = std::mem::take(&mut readers[w as usize]);
562 for reader in rs {
563 add(&mut deps, i, reader); // WAR
564 }
565 }
566 // update state.
567 for &r in &reads[i] {
568 readers[r as usize].push(i);
569 }
570 for &w in &writes[i] {
571 last_writer[w as usize] = Some(i);
572 readers[w as usize].clear();
573 }
574 if is_barrier[i] {
575 // a barrier depends on everything before it, and becomes the new fence.
576 for j in 0..i {
577 add(&mut deps, i, j);
578 }
579 last_barrier = Some(i);
580 }
581 }
582 DepGraph { deps }
583}
584
585/// CONTROL-CODE ASSIGNER (correctness-first, stalls). Recompute every instruction's STALL count from our own
586/// latency model — `stall = fixed_latency(class)` — which is provably >= the cycles any fixed-latency consumer
587/// needs (the immediate next instruction already issues `stall` cycles later, so later consumers have even
588/// more). We KEEP ptxas's write/read barriers + wait masks (the variable-latency memory / cp.async tracking,
589/// the fragile part) untouched. If the kernel stays numerically correct with OUR stalls, the stall model is
590/// validated on-device — the first half of a working control-code assigner. Returns the patched cubin bytes.
591pub fn assign_stalls_by_latency(cubin_path: &Path) -> Option<Vec<u8>> {
592 let bytes = std::fs::read(cubin_path).ok()?;
593 let insns = instructions(&bytes);
594 if insns.is_empty() {
595 return None;
596 }
597 let dis = nvdisasm(cubin_path)?;
598 let mnems: Vec<String> = dis
599 .lines()
600 .filter_map(|l| {
601 let s = l.trim();
602 if s.starts_with("/*") {
603 s.split("*/")
604 .nth(1)
605 .and_then(|r| r.trim().split_whitespace().next())
606 .map(|m| m.to_string())
607 } else {
608 None
609 }
610 })
611 .collect();
612 let mut out = bytes.clone();
613 for (i, ins) in insns.iter().enumerate() {
614 let class = classify_opcode(mnems.get(i).map(|s| s.as_str()).unwrap_or("IMAD"));
615 let mut ctl = Control::decode(ins.hi); // preserve barriers/waits/reuse/yield
616 // our own stall from the latency model, but never LOWER than what ptxas chose (ptxas may know a
617 // throughput/dual-issue constraint we don't model — staying >= its stall keeps us sound).
618 let ours = class.fixed_latency().min(15);
619 ctl.stall = ctl.stall.max(ours);
620 patch_hi(&mut out, i, ctl.apply(ins.hi));
621 }
622 Some(out)
623}
624
625/// CONTROL-CODE ASSIGNER (perf): compute the MINIMAL stall per instruction from the fixed-latency dependency
626/// timing, keeping ptxas's memory barriers. We walk the instruction order tracking the cycle each register's
627/// fixed-latency result becomes ready; each instruction issues at the max of (prev issue + 1) and its
628/// producers' ready cycles; the stall between two instructions is exactly that gap (clamped to `[1,15]`). This is
629/// the tightest schedule that still satisfies every fixed-latency hazard — if ptxas over-stalled anywhere, ours
630/// is faster; if it was already minimal, ours matches. Correctness is guarded on-device (a too-tight stall that
631/// violates a hazard we don't model shows up as a wrong result). Returns the patched cubin bytes.
632pub fn assign_minimal_stalls(cubin_path: &Path) -> Option<Vec<u8>> {
633 assign_minimal_stalls_opt(cubin_path, true)
634}
635
636/// As above, but `floor_ptxas` selects whether to floor our computed stall at ptxas's (sound) or trust our
637/// CALIBRATED model outright (`false` — the true test of whether the measured latencies are complete).
638pub fn assign_minimal_stalls_opt(cubin_path: &Path, floor_ptxas: bool) -> Option<Vec<u8>> {
639 let (insns, classes, _g) = precise_dependency_graph(cubin_path)?;
640 // precise reads/writes again (the graph consumed them); recompute from the disassembly text.
641 let dis = nvdisasm(cubin_path)?;
642 let mut ops: Vec<String> = Vec::new();
643 for l in dis.lines() {
644 let s = l.trim();
645 if s.starts_with("/*") {
646 if let Some(rest) = s.split("*/").nth(1) {
647 let body = rest.split(';').next().unwrap_or("").trim();
648 let body = body
649 .strip_prefix('@')
650 .map(|b| b.split_whitespace().skip(1).collect::<Vec<_>>().join(" "))
651 .unwrap_or_else(|| body.to_string());
652 let operands = body
653 .splitn(2, char::is_whitespace)
654 .nth(1)
655 .unwrap_or("")
656 .to_string();
657 ops.push(operands);
658 }
659 }
660 }
661 let n = insns.len().min(ops.len());
662 let lat: Vec<u32> = classes.iter().map(|c| c.fixed_latency() as u32).collect();
663
664 let mut ready = [0u32; 256]; // cycle each reg's FIXED-latency result is available
665 let mut issue = vec![0u32; n];
666 // functional-unit throughput: consecutive ops on the same unit cannot issue faster than the unit's rate,
667 // EVEN with no register dependency (e.g. IMMAs writing different accumulators still serialize on the tensor
668 // pipe). This is the constraint the register-only model missed (it caused a tensor-pipe overrun -> hang).
669 let mut last_unit_issue: std::collections::HashMap<SassClass, u32> =
670 std::collections::HashMap::new();
671 // multi-warp effective tensor issue rate (8) — a well-occupied kernel interleaves >=2 warps over the
672 // 16-cycle tensor pipe. Other units from the calibrated throughput() table.
673 let throughput = |c: SassClass| -> u32 {
674 match c {
675 SassClass::Tensor => 8,
676 other => other.throughput() as u32,
677 }
678 };
679 for p in 0..n {
680 let (w, r) = precise_operands(&ops[p]);
681 let mut earliest = if p > 0 { issue[p - 1] + 1 } else { 0 };
682 for ® in &r {
683 earliest = earliest.max(ready[reg as usize]);
684 }
685 // throughput on this instruction's functional unit
686 if let Some(&last) = last_unit_issue.get(&classes[p]) {
687 earliest = earliest.max(last + throughput(classes[p]));
688 }
689 issue[p] = earliest;
690 last_unit_issue.insert(classes[p], issue[p]);
691 if !classes[p].is_variable_latency() {
692 for ® in &w {
693 ready[reg as usize] = issue[p] + lat[p];
694 }
695 }
696 // variable-latency writes use ptxas's write barrier (kept) — consumers wait on it, not on `ready`.
697 }
698 // stall[p] = gap to the next instruction's issue (>=1, <=15). Keep barriers/waits/reuse from ptxas.
699 let bytes = std::fs::read(cubin_path).ok()?;
700 let mut out = bytes.clone();
701 for p in 0..n {
702 let mut ctl = Control::decode(insns[p].hi);
703 let gap = if p + 1 < n {
704 issue[p + 1].saturating_sub(issue[p])
705 } else {
706 lat[p]
707 };
708 // SOUND floor: never go below ptxas's stall. Our latency+throughput model captures the hazards we know;
709 // ptxas encodes additional ones we haven't fully reverse-engineered (barrier-wait timing, dual-issue,
710 // register-bank conflicts). Flooring at its value keeps the kernel correct while applying our (higher)
711 // stalls where our model demands them. A truly-minimal below-ptxas schedule needs the COMPLETE hazard
712 // model — that is the remaining reverse-engineering to actually beat ptxas.
713 ctl.stall = if floor_ptxas {
714 (gap.clamp(1, 15) as u8).max(ctl.stall)
715 } else {
716 gap.clamp(1, 15) as u8
717 };
718 patch_hi(&mut out, p, ctl.apply(insns[p].hi));
719 }
720 Some(out)
721}
722
723/// LIST SCHEDULER — the scheduler engine. Given the sound dependency DAG and per-instruction latencies, produce
724/// a new instruction order that respects every hazard while placing latency-critical work early so its result
725/// is ready by the time a consumer needs it (classic critical-path list scheduling). Returns the new order as
726/// original-instruction indices. This is what a re-emitter writes back to close the gap ptxas's schedule leaves.
727pub fn list_schedule(g: &DepGraph, latencies: &[u8]) -> Vec<usize> {
728 let n = g.deps.len();
729 // successors + in-degree
730 let mut succ: Vec<Vec<usize>> = vec![Vec::new(); n];
731 let mut indeg = vec![0usize; n];
732 for (i, ds) in g.deps.iter().enumerate() {
733 indeg[i] = ds.len();
734 for &j in ds {
735 succ[j].push(i);
736 }
737 }
738 // critical-path length (longest latency-weighted path to a sink), computed in reverse topo order.
739 let mut cpl = vec![0u32; n];
740 // process nodes in reverse index order — since edges point backward (j<i), successors have larger indices;
741 // a reverse pass gives each node its successors' cpl already computed.
742 for i in (0..n).rev() {
743 let mut best = 0u32;
744 for &s in &succ[i] {
745 best = best.max(cpl[s]);
746 }
747 cpl[i] = best + latencies.get(i).copied().unwrap_or(1) as u32;
748 }
749 // Kahn with a max-cpl tie-break (and stable original order as the final tie-break for determinism).
750 let mut ready: Vec<usize> = (0..n).filter(|&i| indeg[i] == 0).collect();
751 let mut order = Vec::with_capacity(n);
752 while !ready.is_empty() {
753 // pick the ready node with the highest critical-path length; tie-break to the earliest original index
754 // (keeps the schedule close to ptxas's where the DAG is indifferent, which is where it is already good).
755 let (mut bi, mut bpos) = (0usize, 0usize);
756 for (pos, &node) in ready.iter().enumerate() {
757 let better = cpl[node] > cpl[bi] || (cpl[node] == cpl[bi] && node < bi);
758 if pos == 0 || better {
759 bi = node;
760 bpos = pos;
761 }
762 }
763 ready.swap_remove(bpos);
764 order.push(bi);
765 for &s in &succ[bi] {
766 indeg[s] -= 1;
767 if indeg[s] == 0 {
768 ready.push(s);
769 }
770 }
771 }
772 order
773}
774
775/// The reuse-cache optimizer pass — our first SASS optimization ON TOP of ptxas. The operand collector can
776/// REUSE a source register read by the previous instruction instead of re-reading the register file, saving
777/// RF bandwidth (the bottleneck under our 222-register, 16.7%-occupancy kernel). ptxas already sets many reuse
778/// flags; this finds the ones it MISSED: consecutive instructions that read the same register into the same
779/// operand slot (Ra→reuse bit 0 = `hi[58]`, Rb→reuse bit 1 = `hi[59]`) but where the flag is unset. RZ is skipped
780/// (reusing the zero register is pointless). Returns the patched cubin bytes and the number of flags added.
781/// The result MUST be validated on-device (varied-fill oracle) — the operand→slot mapping is verified there,
782/// never trusted blind.
783pub fn optimize_reuse(cubin: &[u8]) -> (Vec<u8>, usize) {
784 let insns = instructions(cubin);
785 let mut out = cubin.to_vec();
786 let mut added = 0usize;
787 for i in 1..insns.len() {
788 let (prev, cur) = (&insns[i - 1], &insns[i]);
789 let mut ctl = Control::decode(cur.hi);
790 let mut changed = false;
791 // Ra reuse (slot 0): current Ra read the same non-RZ register the previous instruction read as Ra.
792 if cur.ra() != 255 && cur.ra() == prev.ra() && (ctl.reuse & 0b0001) == 0 {
793 ctl.reuse |= 0b0001;
794 changed = true;
795 }
796 // Rb reuse (slot 1): same for the Rb operand slot.
797 if cur.rb() != 255 && cur.rb() == prev.rb() && (ctl.reuse & 0b0010) == 0 {
798 ctl.reuse |= 0b0010;
799 changed = true;
800 }
801 if changed {
802 patch_hi(&mut out, i, ctl.apply(cur.hi));
803 added += 1;
804 }
805 }
806 (out, added)
807}
808
809/// Disassemble a cubin file to SASS text via nvdisasm (for inspection / labelling).
810pub fn nvdisasm(cubin_path: &Path) -> Option<String> {
811 let o = Command::new("nvdisasm")
812 .args(["-c", "-hex"])
813 .arg(cubin_path)
814 .output()
815 .ok()?;
816 if !o.status.success() {
817 return None;
818 }
819 Some(String::from_utf8_lossy(&o.stdout).into_owned())
820}
821
822/// What the COMPILER learns about the machine code `ptxas` produced for one of its kernels — the SASS-awareness
823/// the backend needs to reason about the ptxas ceiling (register pressure/occupancy, tensor-core utilisation,
824/// reuse-cache usage). Every field is read from the real cubin, not guessed.
825#[derive(Debug, Clone, PartialEq, Eq)]
826pub struct SassReport {
827 pub kernel: String,
828 pub instructions: usize,
829 pub imma: usize, // tensor-core MACs
830 pub ldsm: usize, // ldmatrix cooperative loads
831 pub reuse_insns: usize, // instructions that use the operand reuse cache
832 pub registers: usize, // registers/thread (SHI_REGISTERS) — the occupancy driver
833 pub avg_stall_x100: u64, // mean fixed stall ×100 (integer to stay Eq/Hash-friendly)
834}
835
836impl SassReport {
837 /// Estimate resident blocks/SM from the register count (Ampere: 65,536 regs/SM) at `threads` per block —
838 /// this is where a register-bound kernel loses to cuBLAS, and the compiler can now SEE it.
839 pub fn blocks_per_sm(&self, threads: usize) -> usize {
840 if self.registers == 0 || threads == 0 {
841 return 0;
842 }
843 (65536 / (self.registers * threads)).min(16)
844 }
845
846 /// Analyse the cubin a compiler backend just produced: parse its machine code + control codes and correlate
847 /// with the SASS mnemonics. The backend calls this to know how well ptxas scheduled its kernel.
848 pub fn analyze(cubin_path: &Path, kernel: &str) -> Option<SassReport> {
849 let bytes = std::fs::read(cubin_path).ok()?;
850 let insns = instructions(&bytes);
851 if insns.is_empty() {
852 return None;
853 }
854 let dis = nvdisasm(cubin_path)?;
855 // mnemonic per instruction (the token after the `/*addr*/` marker).
856 let mnems: Vec<String> = dis
857 .lines()
858 .filter_map(|l| {
859 let s = l.trim();
860 if s.starts_with("/*") {
861 s.split("*/")
862 .nth(1)
863 .and_then(|r| r.trim().split_whitespace().next())
864 .map(|m| m.to_string())
865 } else {
866 None
867 }
868 })
869 .collect();
870 let count = |needle: &str| mnems.iter().filter(|m| m.contains(needle)).count();
871 let reuse_insns = insns
872 .iter()
873 .filter(|i| Control::decode(i.hi).reuse != 0)
874 .count();
875 let stall_sum: u64 = insns
876 .iter()
877 .map(|i| Control::decode(i.hi).stall as u64)
878 .sum();
879 // registers/thread from nvdisasm's `.sectioninfo @"SHI_REGISTERS=N"`.
880 let registers = dis
881 .lines()
882 .find_map(|l| l.split("SHI_REGISTERS=").nth(1))
883 .and_then(|r| {
884 r.trim_end_matches(|c: char| !c.is_ascii_digit())
885 .parse::<usize>()
886 .ok()
887 })
888 .unwrap_or(0);
889 Some(SassReport {
890 kernel: kernel.to_string(),
891 instructions: insns.len(),
892 imma: count("IMMA"),
893 ldsm: count("LDSM"),
894 reuse_insns,
895 registers,
896 avg_stall_x100: stall_sum * 100 / insns.len() as u64,
897 })
898 }
899}
900
901/// The compiler emits `cu_src`, compiles it with nvcc to a cubin, and returns the SASS report — the backend
902/// "knowing" the machine code its codegen ultimately becomes. Requires nvcc + nvdisasm; returns None otherwise.
903pub fn analyze_source(cu_src: &str, kernel: &str, arch: &str) -> Option<SassReport> {
904 let dir = std::env::temp_dir().join(format!("logos_sassrep_{}_{}", std::process::id(), kernel));
905 std::fs::create_dir_all(&dir).ok()?;
906 let src = dir.join("k.cu");
907 let cubin = dir.join("k.cubin");
908 std::fs::write(&src, cu_src).ok()?;
909 let o = Command::new("nvcc")
910 .args(["-O3", &format!("-arch={arch}"), "-cubin", "-o"])
911 .arg(&cubin)
912 .arg(&src)
913 .output()
914 .ok()?;
915 if !o.status.success() {
916 return None;
917 }
918 let rep = SassReport::analyze(&cubin, kernel);
919 let _ = std::fs::remove_dir_all(&dir);
920 rep
921}
922
923#[cfg(test)]
924#[path = "../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sass/tests.rs"]
925mod tests;