Skip to main content

softgpu_functional/
sanitize.rs

1//! SoftGPU Phase 8 sanitizers (functional fidelity).
2//!
3//! Declared subset (honest, not AMDGPU memory-model evidence):
4//! - Shadow tracks allocated / uninitialized / initialized / freed per byte
5//! - SoftGPU race: different workitems, overlapping bytes, ≥1 non-atomic write,
6//!   same workgroup, same barrier generation (no SoftGPU barrier between them)
7//! - Cross-workgroup non-atomic conflicting global accesses are findings
8//! - SoftGPU atomics on the same address do not race with each other under the
9//!   SoftGPU sequential interpreter; they still mark bytes initialized
10//! - Divergent barriers remain validate-time errors (Phase 7)
11//!
12//! Blind spots (documented): full GPU memory orders, silent aliasing through
13//! host pointers, and true hardware concurrency are out of scope.
14
15use crate::error::{FunctionalError, Result};
16use crate::exec::{ExecConfig, SchedulePolicy};
17use crate::ir::{AddrSpace, Program, TypeId};
18use crate::memory::ty_size;
19use serde::{Deserialize, Serialize};
20
21pub const SANITIZER_REPLAY_SCHEMA: &str = "softgpu-sanitizer-replay-v1";
22pub const MAX_SHADOW_BYTES: usize = 4 * 1024 * 1024;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
25#[serde(rename_all = "snake_case")]
26pub enum SanitizeMode {
27    /// No shadow / race instrumentation.
28    Off,
29    /// Collect findings; execution continues unless a hard bounds fault occurs.
30    Collect,
31    /// Stop at the first sanitizer finding.
32    FailFast,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
36#[serde(rename_all = "snake_case")]
37pub enum FindingKind {
38    OutOfBounds,
39    UseAfterFree,
40    UninitializedRead,
41    Race,
42    MissingBarrier,
43    CrossWorkgroupRace,
44    ShadowLimit,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub struct WorkItemId {
50    pub workgroup: [u32; 3],
51    pub wave: u32,
52    pub lane: u32,
53    pub flat_local: u32,
54}
55
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Finding {
58    pub kind: FindingKind,
59    pub space: AddrSpace,
60    pub addr: u64,
61    pub size: usize,
62    pub step: u64,
63    pub barrier_gen: u64,
64    pub actor: WorkItemId,
65    pub other: Option<WorkItemId>,
66    pub detail: String,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70pub struct SanitizeReport {
71    pub fidelity: &'static str,
72    pub mode: &'static str,
73    pub note: &'static str,
74    pub findings: Vec<Finding>,
75}
76
77impl SanitizeReport {
78    pub fn clean() -> Self {
79        Self {
80            fidelity: "sanitized",
81            mode: "softgpu_functional_sanitizer_v1",
82            note: "not_gfx1201_isa_emulation",
83            findings: Vec::new(),
84        }
85    }
86
87    pub fn ok(&self) -> bool {
88        self.findings.is_empty()
89    }
90}
91
92#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
93pub struct ReplayBundle {
94    pub schema: String,
95    pub program_name: String,
96    pub source_provenance: String,
97    pub exec: ReplayExec,
98    pub finding: Finding,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct ReplayExec {
103    pub grid: [u32; 3],
104    pub workgroup: [u32; 3],
105    pub wave_size: u32,
106    pub group_bytes: u32,
107    pub schedule: SchedulePolicy,
108    pub step_budget: u64,
109}
110
111impl ReplayBundle {
112    pub fn from_finding(program: &Program, cfg: &ExecConfig, finding: Finding) -> Self {
113        Self {
114            schema: SANITIZER_REPLAY_SCHEMA.into(),
115            program_name: program.name.clone(),
116            source_provenance: program.source_provenance.clone(),
117            exec: ReplayExec {
118                grid: cfg.launch.grid,
119                workgroup: cfg.launch.workgroup,
120                wave_size: cfg.wave_size,
121                group_bytes: cfg.group_bytes.max(program.group_bytes),
122                schedule: cfg.schedule,
123                step_budget: cfg.step_budget,
124            },
125            finding,
126        }
127    }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131enum ByteState {
132    Unallocated,
133    Uninitialized,
134    Initialized,
135    Freed,
136}
137
138#[derive(Debug, Clone, Copy)]
139struct AccessRecord {
140    actor: WorkItemId,
141    barrier_gen: u64,
142    #[allow(dead_code)]
143    step: u64,
144    is_write: bool,
145    is_atomic: bool,
146}
147
148#[derive(Debug, Clone)]
149struct ByteShadow {
150    state: ByteState,
151    last: Option<AccessRecord>,
152}
153
154impl Default for ByteShadow {
155    fn default() -> Self {
156        Self {
157            state: ByteState::Unallocated,
158            last: None,
159        }
160    }
161}
162
163#[derive(Debug)]
164pub struct Sanitizer {
165    mode: SanitizeMode,
166    global: Vec<ByteShadow>,
167    group: Vec<ByteShadow>,
168    findings: Vec<Finding>,
169    /// SoftGPU WG-local barrier generation (bumped between barrier segments).
170    pub barrier_gen: u64,
171    pub step: u64,
172}
173
174impl Sanitizer {
175    pub fn new(mode: SanitizeMode, global_len: usize, group_len: usize) -> Result<Self> {
176        if mode == SanitizeMode::Off {
177            return Ok(Self {
178                mode,
179                global: Vec::new(),
180                group: Vec::new(),
181                findings: Vec::new(),
182                barrier_gen: 0,
183                step: 0,
184            });
185        }
186        let total = global_len.saturating_add(group_len);
187        if total > MAX_SHADOW_BYTES {
188            return Err(FunctionalError::Sanitize(Finding {
189                kind: FindingKind::ShadowLimit,
190                space: AddrSpace::Global,
191                addr: 0,
192                size: total,
193                step: 0,
194                barrier_gen: 0,
195                actor: WorkItemId {
196                    workgroup: [0, 0, 0],
197                    wave: 0,
198                    lane: 0,
199                    flat_local: 0,
200                },
201                other: None,
202                detail: format!("shadow bytes {total} > SoftGPU max {MAX_SHADOW_BYTES}"),
203            }));
204        }
205        let mut s = Self {
206            mode,
207            global: vec![ByteShadow::default(); global_len],
208            group: vec![ByteShadow::default(); group_len],
209            findings: Vec::new(),
210            barrier_gen: 0,
211            step: 0,
212        };
213        // Host-visible global arena bytes are SoftGPU-initialized at launch; group
214        // memory starts uninitialized each workgroup (see reset_group).
215        s.mark_initialized(AddrSpace::Global, 0, global_len);
216        s.mark_allocated(AddrSpace::Group, 0, group_len);
217        Ok(s)
218    }
219
220    pub fn mode(&self) -> SanitizeMode {
221        self.mode
222    }
223
224    pub fn findings(&self) -> &[Finding] {
225        &self.findings
226    }
227
228    pub fn into_report(self) -> SanitizeReport {
229        let mut r = SanitizeReport::clean();
230        r.findings = self.findings;
231        r
232    }
233
234    pub fn reset_group(&mut self) {
235        for b in &mut self.group {
236            *b = ByteShadow {
237                state: ByteState::Uninitialized,
238                last: None,
239            };
240        }
241    }
242
243    pub fn mark_allocated(&mut self, space: AddrSpace, addr: u64, len: usize) {
244        if self.mode == SanitizeMode::Off || len == 0 {
245            return;
246        }
247        let map = self.map_mut(space);
248        let start = addr as usize;
249        let end = (start + len).min(map.len());
250        for b in map.iter_mut().take(end).skip(start) {
251            b.state = ByteState::Uninitialized;
252            b.last = None;
253        }
254    }
255
256    pub fn mark_initialized(&mut self, space: AddrSpace, addr: u64, len: usize) {
257        if self.mode == SanitizeMode::Off || len == 0 {
258            return;
259        }
260        let map = self.map_mut(space);
261        let start = addr as usize;
262        let end = (start + len).min(map.len());
263        for b in map.iter_mut().take(end).skip(start) {
264            b.state = ByteState::Initialized;
265            b.last = None;
266        }
267    }
268
269    pub fn mark_uninitialized(&mut self, space: AddrSpace, addr: u64, len: usize) {
270        self.mark_allocated(space, addr, len);
271    }
272
273    pub fn mark_freed(&mut self, space: AddrSpace, addr: u64, len: usize) {
274        if self.mode == SanitizeMode::Off || len == 0 {
275            return;
276        }
277        let map = self.map_mut(space);
278        let start = addr as usize;
279        let end = (start + len).min(map.len());
280        for b in map.iter_mut().take(end).skip(start) {
281            b.state = ByteState::Freed;
282            b.last = None;
283        }
284    }
285
286    pub fn note_barrier(&mut self) {
287        self.barrier_gen = self.barrier_gen.saturating_add(1);
288    }
289
290    pub fn on_access(
291        &mut self,
292        space: AddrSpace,
293        addr: u64,
294        ty: TypeId,
295        is_write: bool,
296        is_atomic: bool,
297        actor: WorkItemId,
298    ) -> Result<()> {
299        if self.mode == SanitizeMode::Off {
300            return Ok(());
301        }
302        let size = ty_size(ty);
303        let map_len = self.map(space).len();
304        let start = match usize::try_from(addr) {
305            Ok(s) => s,
306            Err(_) => {
307                return self.push_finding(Finding {
308                    kind: FindingKind::OutOfBounds,
309                    space,
310                    addr,
311                    size,
312                    step: self.step,
313                    barrier_gen: self.barrier_gen,
314                    actor,
315                    other: None,
316                    detail: "address does not fit SoftGPU arena".into(),
317                });
318            }
319        };
320        let end = match start.checked_add(size) {
321            Some(e) => e,
322            None => {
323                return self.push_finding(Finding {
324                    kind: FindingKind::OutOfBounds,
325                    space,
326                    addr,
327                    size,
328                    step: self.step,
329                    barrier_gen: self.barrier_gen,
330                    actor,
331                    other: None,
332                    detail: "access size overflow".into(),
333                });
334            }
335        };
336        if end > map_len {
337            return self.push_finding(Finding {
338                kind: FindingKind::OutOfBounds,
339                space,
340                addr,
341                size,
342                step: self.step,
343                barrier_gen: self.barrier_gen,
344                actor,
345                other: None,
346                detail: format!("addr+size exceeds arena_len={map_len}"),
347            });
348        }
349
350        for off in 0..size {
351            let idx = start + off;
352            let state = self.map(space)[idx].state;
353            match state {
354                ByteState::Unallocated => {
355                    return self.push_finding(Finding {
356                        kind: FindingKind::OutOfBounds,
357                        space,
358                        addr: addr + off as u64,
359                        size: 1,
360                        step: self.step,
361                        barrier_gen: self.barrier_gen,
362                        actor,
363                        other: None,
364                        detail: "access to unallocated SoftGPU shadow byte".into(),
365                    });
366                }
367                ByteState::Freed => {
368                    return self.push_finding(Finding {
369                        kind: FindingKind::UseAfterFree,
370                        space,
371                        addr: addr + off as u64,
372                        size: 1,
373                        step: self.step,
374                        barrier_gen: self.barrier_gen,
375                        actor,
376                        other: None,
377                        detail: "access to SoftGPU-freed shadow byte".into(),
378                    });
379                }
380                ByteState::Uninitialized if !is_write => {
381                    return self.push_finding(Finding {
382                        kind: FindingKind::UninitializedRead,
383                        space,
384                        addr: addr + off as u64,
385                        size: 1,
386                        step: self.step,
387                        barrier_gen: self.barrier_gen,
388                        actor,
389                        other: None,
390                        detail: "read of uninitialized SoftGPU shadow byte".into(),
391                    });
392                }
393                _ => {}
394            }
395
396            if let Some(prev) = self.map(space)[idx].last {
397                if let Some(kind) =
398                    race_kind(prev, actor, self.barrier_gen, is_write, is_atomic, space)
399                {
400                    let detail = match kind {
401                        FindingKind::MissingBarrier => {
402                            "group conflict in same SoftGPU barrier generation without barrier"
403                                .into()
404                        }
405                        FindingKind::Race => {
406                            "global conflict in same SoftGPU barrier generation (declared SoftGPU HB subset)".into()
407                        }
408                        FindingKind::CrossWorkgroupRace => {
409                            "conflicting SoftGPU global accesses from different workgroups".into()
410                        }
411                        _ => "SoftGPU happens-before race in declared subset".into(),
412                    };
413                    return self.push_finding(Finding {
414                        kind,
415                        space,
416                        addr: addr + off as u64,
417                        size: 1,
418                        step: self.step,
419                        barrier_gen: self.barrier_gen,
420                        actor,
421                        other: Some(prev.actor),
422                        detail,
423                    });
424                }
425            }
426        }
427
428        let record = AccessRecord {
429            actor,
430            barrier_gen: self.barrier_gen,
431            step: self.step,
432            is_write,
433            is_atomic,
434        };
435        for off in 0..size {
436            let b = &mut self.map_mut(space)[start + off];
437            if is_write {
438                b.state = ByteState::Initialized;
439            }
440            b.last = Some(record);
441        }
442        Ok(())
443    }
444
445    fn push_finding(&mut self, finding: Finding) -> Result<()> {
446        self.findings.push(finding.clone());
447        let hard = matches!(
448            finding.kind,
449            FindingKind::OutOfBounds | FindingKind::UseAfterFree | FindingKind::ShadowLimit
450        );
451        if self.mode == SanitizeMode::FailFast || hard {
452            return Err(FunctionalError::Sanitize(finding));
453        }
454        Ok(())
455    }
456
457    fn map(&self, space: AddrSpace) -> &[ByteShadow] {
458        match space {
459            AddrSpace::Global => &self.global,
460            AddrSpace::Group => &self.group,
461        }
462    }
463
464    fn map_mut(&mut self, space: AddrSpace) -> &mut [ByteShadow] {
465        match space {
466            AddrSpace::Global => &mut self.global,
467            AddrSpace::Group => &mut self.group,
468        }
469    }
470}
471
472fn race_kind(
473    prev: AccessRecord,
474    cur: WorkItemId,
475    cur_barrier_gen: u64,
476    cur_write: bool,
477    cur_atomic: bool,
478    space: AddrSpace,
479) -> Option<FindingKind> {
480    // Same workitem: SoftGPU program order — not a race.
481    if prev.actor.workgroup == cur.workgroup && prev.actor.flat_local == cur.flat_local {
482        return None;
483    }
484    // Need a write involved.
485    if !prev.is_write && !cur_write {
486        return None;
487    }
488    // SoftGPU atomics on both sides: ordered by SoftGPU sequential interpreter.
489    if prev.is_atomic && cur_atomic {
490        return None;
491    }
492    if prev.actor.workgroup != cur.workgroup {
493        return Some(FindingKind::CrossWorkgroupRace);
494    }
495    // Same WG: race if same SoftGPU barrier generation (no barrier between).
496    if prev.barrier_gen == cur_barrier_gen {
497        return Some(match space {
498            AddrSpace::Group => FindingKind::MissingBarrier,
499            AddrSpace::Global => FindingKind::Race,
500        });
501    }
502    None
503}
504
505#[cfg(test)]
506mod tests {
507    use super::*;
508
509    #[test]
510    fn replay_schema_is_stable() {
511        assert_eq!(SANITIZER_REPLAY_SCHEMA, "softgpu-sanitizer-replay-v1");
512    }
513}