1use 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 Off,
29 Collect,
31 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 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 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 if prev.actor.workgroup == cur.workgroup && prev.actor.flat_local == cur.flat_local {
482 return None;
483 }
484 if !prev.is_write && !cur_write {
486 return None;
487 }
488 if prev.is_atomic && cur_atomic {
490 return None;
491 }
492 if prev.actor.workgroup != cur.workgroup {
493 return Some(FindingKind::CrossWorkgroupRace);
494 }
495 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}