diff --git a/crates/llvm-export/src/export/debug.rs b/crates/llvm-export/src/export/debug.rs index 6b24c49175..2008fc755e 100644 --- a/crates/llvm-export/src/export/debug.rs +++ b/crates/llvm-export/src/export/debug.rs @@ -148,16 +148,9 @@ impl<'a> ModuleExportState<'a> { return None; } - let (path, pos) = self.source_position_from_location(loc)?; - if self - .debug_subprogram_files - .get(&scope) - .is_some_and(|scope_path| scope_path.as_path() != path) - { - return None; - } - + let (path, pos) = self.local_variable_position_from_location(loc)?; let file_id = self.ensure_debug_file(&path); + let variable_scope = self.debug_scope_for_file(scope, &path)?; let type_id = self.ensure_debug_type(&info.ty); let location_id = self.debug_location_for_scope(scope, loc)?; let name = escape_debug_string(&info.name); @@ -170,7 +163,7 @@ impl<'a> ModuleExportState<'a> { self.debug_nodes.push(( id, format!( - "!DILocalVariable(name: \"{name}\", {arg}scope: !{scope}, file: !{file_id}, \ + "!DILocalVariable(name: \"{name}\", {arg}scope: !{variable_scope}, file: !{file_id}, \ line: {}, type: !{type_id})", pos.line ), @@ -275,50 +268,124 @@ impl<'a> ModuleExportState<'a> { return None; } + match loc { + Location::CallSite { callee, caller } => { + return self.debug_call_site_location_for_scope(scope, callee, caller); + } + Location::Named { child_loc, .. } => { + return self.debug_location_for_scope(scope, child_loc); + } + Location::Fused { locations, .. } => { + return locations + .iter() + .find_map(|loc| self.debug_location_for_scope(scope, loc)); + } + Location::SrcPos { .. } | Location::Unknown => {} + } + let (path, pos) = self.source_position_from_location(loc)?; - if self - .debug_subprogram_files - .get(&scope) - .is_some_and(|scope_path| scope_path.as_path() != path) - { - return None; + let location_scope = self.debug_scope_for_file(scope, &path)?; + + self.ensure_debug_location(location_scope, pos, None) + } + + fn debug_call_site_location_for_scope( + &mut self, + scope: usize, + callee: &Location, + caller: &Location, + ) -> Option { + let caller_location = self + .source_position_from_location(caller) + .and_then(|(path, pos)| { + let caller_scope = self.debug_scope_for_file(scope, &path)?; + self.ensure_debug_location(caller_scope, pos, None) + }); + + let Some((callee_path, callee_pos)) = self.source_position_from_location(callee) else { + return caller_location; + }; + let callee_scope = self.debug_scope_for_file(scope, &callee_path)?; + + self.ensure_debug_location(callee_scope, callee_pos, caller_location) + } + + fn debug_scope_for_file(&mut self, scope: usize, path: &Path) -> Option { + let scope_path = self.debug_subprogram_files.get(&scope)?; + if scope_path.as_path() == path { + return Some(scope); } - let key = (scope, pos.line, pos.column); - if let Some(id) = self.debug_locations.get(&key).copied() { + let key = (scope, path.to_path_buf()); + if let Some(id) = self.debug_file_scopes.get(&key).copied() { return Some(id); } + let file_id = self.ensure_debug_file(path); let id = self.alloc_metadata_id(); self.debug_nodes.push(( id, - format!( - "!DILocation(line: {}, column: {}, scope: !{})", - pos.line, pos.column, scope - ), + format!("!DILexicalBlockFile(scope: !{scope}, file: !{file_id}, discriminator: 0)"), )); - self.debug_locations.insert(key, id); + self.debug_file_scopes.insert(key, id); Some(id) } - fn debug_fallback_location_for_scope(&mut self, scope: usize) -> Option { - let (line, column) = self.debug_subprogram_fallbacks.get(&scope).copied()?; - let key = (scope, line, column); + fn ensure_debug_location( + &mut self, + scope: usize, + pos: SourcePosition, + inlined_at: Option, + ) -> Option { + if pos.line <= 0 || pos.column <= 0 { + return None; + } + + let key = (scope, pos.line, pos.column, inlined_at); if let Some(id) = self.debug_locations.get(&key).copied() { return Some(id); } let id = self.alloc_metadata_id(); + let inlined_at = inlined_at + .map(|location_id| format!(", inlinedAt: !{location_id}")) + .unwrap_or_default(); self.debug_nodes.push(( id, - format!("!DILocation(line: {line}, column: {column}, scope: !{scope})"), + format!( + "!DILocation(line: {}, column: {}, scope: !{}{inlined_at})", + pos.line, pos.column, scope + ), )); self.debug_locations.insert(key, id); Some(id) } + fn debug_fallback_location_for_scope(&mut self, scope: usize) -> Option { + let (line, column) = self.debug_subprogram_fallbacks.get(&scope).copied()?; + self.ensure_debug_location(scope, SourcePosition { line, column }, None) + } + + fn local_variable_position_from_location( + &self, + loc: &Location, + ) -> Option<(PathBuf, SourcePosition)> { + match loc { + Location::CallSite { callee, caller } => self + .source_position_from_location(callee) + .or_else(|| self.source_position_from_location(caller)), + Location::Named { child_loc, .. } => { + self.local_variable_position_from_location(child_loc) + } + Location::Fused { locations, .. } => locations + .iter() + .find_map(|loc| self.local_variable_position_from_location(loc)), + Location::SrcPos { .. } | Location::Unknown => self.source_position_from_location(loc), + } + } + fn source_position_from_location(&self, loc: &Location) -> Option<(PathBuf, SourcePosition)> { match loc { Location::SrcPos { diff --git a/crates/llvm-export/src/export/state.rs b/crates/llvm-export/src/export/state.rs index f6087fe7fd..f68a1d5173 100644 --- a/crates/llvm-export/src/export/state.rs +++ b/crates/llvm-export/src/export/state.rs @@ -66,12 +66,14 @@ pub(super) struct ModuleExportState<'a> { pub(super) debug_files: HashMap, /// Shared empty function type used by all line-table-only subprograms. pub(super) debug_subroutine_type: Option, - /// `DISubprogram` file paths, used to avoid attaching locations to the wrong scope. + /// `DISubprogram` file paths, used to create file-correct nested scopes. pub(super) debug_subprogram_files: HashMap, /// Fallback line/column for calls that LLVM requires to have a location. pub(super) debug_subprogram_fallbacks: HashMap, - /// `DILocation` nodes keyed by `(scope, line, column)`. - pub(super) debug_locations: HashMap<(usize, i32, i32), usize>, + /// `DILexicalBlockFile` nodes keyed by `(parent scope, file path)`. + pub(super) debug_file_scopes: HashMap<(usize, PathBuf), usize>, + /// `DILocation` nodes keyed by `(scope, line, column, inlined-at location)`. + pub(super) debug_locations: HashMap<(usize, i32, i32, Option), usize>, /// `DIType` nodes keyed by the simple debug type they describe. pub(super) debug_types: HashMap, /// Numbered debug metadata definitions, in allocation order. @@ -103,6 +105,7 @@ impl<'a> ModuleExportState<'a> { debug_subroutine_type: None, debug_subprogram_files: HashMap::new(), debug_subprogram_fallbacks: HashMap::new(), + debug_file_scopes: HashMap::new(), debug_locations: HashMap::new(), debug_types: HashMap::new(), debug_nodes: Vec::new(), diff --git a/crates/llvm-export/tests/export_test.rs b/crates/llvm-export/tests/export_test.rs index fbadcca2cb..9454ff6aef 100644 --- a/crates/llvm-export/tests/export_test.rs +++ b/crates/llvm-export/tests/export_test.rs @@ -372,6 +372,120 @@ fn line_table_debug_metadata_emits_function_scope_and_instruction_locations() { ); } +#[test] +fn line_table_debug_metadata_uses_file_scope_for_cross_file_locations() { + let mut ctx = Context::new(); + + let module = ModuleOp::new(&mut ctx, "test_module".try_into().unwrap()); + let module_region = module.get_operation().deref(&ctx).get_region(0); + let module_block = { + let region = module_region.deref(&ctx); + region.iter(&ctx).next().unwrap() + }; + + let void_ty = VoidType::get(&ctx); + let func_ty = FuncType::get(&mut ctx, void_ty.to_ptr(), vec![], false); + let func = FuncOp::new(&mut ctx, "debug_kernel".try_into().unwrap(), func_ty); + let func_loc = src_location(&mut ctx, "/tmp/cuda-oxide/tests/kernel.rs", 38, 1); + func.get_operation().deref_mut(&ctx).set_loc(func_loc); + + let entry = func.get_or_create_entry_block(&mut ctx); + let ret = ReturnOp::new(&mut ctx, None); + let ret_loc = src_location( + &mut ctx, + "/tmp/cuda-oxide/crates/cuda-device/src/thread.rs", + 292, + 19, + ); + ret.get_operation().deref_mut(&ctx).set_loc(ret_loc); + ret.get_operation().insert_at_back(entry, &ctx); + + func.get_operation().insert_at_back(module_block, &ctx); + + let config = DebugConfig { + inner: PtxExportConfig, + debug_kind: DebugKind::LineTables, + }; + let ir = + export_module_to_string_with_config(&ctx, &module, &config).expect("debug export succeeds"); + + let ret_line = ir + .lines() + .find(|line| line.trim_start().starts_with("ret void")) + .expect("return instruction"); + assert!( + ret_line.contains(", !dbg !"), + "cross-file instructions should keep source locations:\n{ir}" + ); + assert!( + ir.contains( + "!DIFile(filename: \"thread.rs\", directory: \"/tmp/cuda-oxide/crates/cuda-device/src\")" + ), + "cross-file locations should get their own DIFile:\n{ir}" + ); + assert!( + ir.contains("!DILexicalBlockFile(scope: !"), + "cross-file locations should use a file-specific debug scope:\n{ir}" + ); + assert!( + ir.contains("!DILocation(line: 292, column: 19, scope: !"), + "cross-file locations should preserve their real source line:\n{ir}" + ); +} + +#[test] +fn line_table_debug_metadata_emits_inlined_at_for_callsite_locations() { + let mut ctx = Context::new(); + + let module = ModuleOp::new(&mut ctx, "test_module".try_into().unwrap()); + let module_region = module.get_operation().deref(&ctx).get_region(0); + let module_block = { + let region = module_region.deref(&ctx); + region.iter(&ctx).next().unwrap() + }; + + let void_ty = VoidType::get(&ctx); + let func_ty = FuncType::get(&mut ctx, void_ty.to_ptr(), vec![], false); + let func = FuncOp::new(&mut ctx, "debug_kernel".try_into().unwrap(), func_ty); + let func_loc = src_location(&mut ctx, "/tmp/cuda-oxide/tests/kernel.rs", 38, 1); + func.get_operation().deref_mut(&ctx).set_loc(func_loc); + + let entry = func.get_or_create_entry_block(&mut ctx); + let ret = ReturnOp::new(&mut ctx, None); + let callee = src_location( + &mut ctx, + "/tmp/cuda-oxide/crates/cuda-device/src/thread.rs", + 292, + 19, + ); + let caller = src_location(&mut ctx, "/tmp/cuda-oxide/tests/kernel.rs", 39, 13); + ret.get_operation() + .deref_mut(&ctx) + .set_loc(Location::CallSite { + callee: Box::new(callee), + caller: Box::new(caller), + }); + ret.get_operation().insert_at_back(entry, &ctx); + + func.get_operation().insert_at_back(module_block, &ctx); + + let config = DebugConfig { + inner: PtxExportConfig, + debug_kind: DebugKind::LineTables, + }; + let ir = + export_module_to_string_with_config(&ctx, &module, &config).expect("debug export succeeds"); + + assert!( + ir.contains("!DILocation(line: 39, column: 13, scope: !"), + "callsite metadata should preserve the caller location:\n{ir}" + ); + assert!( + ir.contains("!DILocation(line: 292, column: 19, scope: !") && ir.contains(", inlinedAt: !"), + "callsite metadata should describe the callee location as inlined at the caller:\n{ir}" + ); +} + #[test] fn debug_metadata_shares_allocator_with_nvvm_metadata() { let mut ctx = Context::new(); @@ -548,6 +662,85 @@ fn full_debug_metadata_emits_dbg_declare_for_tagged_allocas() { ); } +#[test] +fn full_debug_metadata_uses_file_scope_for_cross_file_local_variables() { + let mut ctx = Context::new(); + + let module = ModuleOp::new(&mut ctx, "test_module".try_into().unwrap()); + let module_region = module.get_operation().deref(&ctx).get_region(0); + let module_block = { + let region = module_region.deref(&ctx); + region.iter(&ctx).next().unwrap() + }; + + let void_ty = VoidType::get(&ctx); + let func_ty = FuncType::get(&mut ctx, void_ty.to_ptr(), vec![], false); + let func = FuncOp::new(&mut ctx, "debug_kernel".try_into().unwrap(), func_ty); + let func_loc = src_location(&mut ctx, "/tmp/cuda-oxide/tests/kernel.rs", 30, 1); + func.get_operation().deref_mut(&ctx).set_loc(func_loc); + + let entry = func.get_or_create_entry_block(&mut ctx); + let i32_ty = IntegerType::get(&mut ctx, 32, Signedness::Signless); + let one_attr = IntegerAttr::new(i32_ty, APInt::from_u32(1, NonZero::new(32).unwrap())); + let one = ConstantOp::new(&mut ctx, one_attr.into()); + one.get_operation().insert_at_back(entry, &ctx); + let one_val = one.get_operation().deref(&ctx).get_result(0); + + let tid = AllocaOp::new(&mut ctx, i32_ty.into(), one_val); + let tid_loc = src_location( + &mut ctx, + "/tmp/cuda-oxide/crates/cuda-device/src/thread.rs", + 292, + 19, + ); + tid.get_operation().deref_mut(&ctx).set_loc(tid_loc); + llvm_export::ops::set_debug_local_variable( + &mut ctx, + tid.get_operation(), + DebugLocalVariableInfo { + name: "tid".to_string(), + argument_index: None, + ty: DebugLocalTypeKind::Basic { + name: "u32".to_string(), + size_bits: 32, + encoding: "DW_ATE_unsigned", + }, + }, + ); + tid.get_operation().insert_at_back(entry, &ctx); + + ReturnOp::new(&mut ctx, None) + .get_operation() + .insert_at_back(entry, &ctx); + func.get_operation().insert_at_back(module_block, &ctx); + + let config = DebugConfig { + inner: PtxExportConfig, + debug_kind: DebugKind::Full, + }; + let ir = + export_module_to_string_with_config(&ctx, &module, &config).expect("debug export succeeds"); + + assert!( + ir.contains("!DILexicalBlockFile(scope: !"), + "cross-file local variables should get a file-specific debug scope:\n{ir}" + ); + assert!( + ir.contains( + "!DIFile(filename: \"thread.rs\", directory: \"/tmp/cuda-oxide/crates/cuda-device/src\")" + ), + "cross-file local variables should reference their source file:\n{ir}" + ); + assert!( + ir.contains("!DILocalVariable(name: \"tid\", scope: !") && ir.contains("line: 292"), + "cross-file local variables should preserve the variable file scope and line:\n{ir}" + ); + assert!( + ir.contains("call void @llvm.dbg.declare"), + "cross-file local variables should still get dbg.declare bindings:\n{ir}" + ); +} + #[test] fn line_table_debug_metadata_ignores_tagged_alloca_variables() { let mut ctx = Context::new(); diff --git a/cuda-oxide-book/appendix/supported-features.md b/cuda-oxide-book/appendix/supported-features.md index 250a1be823..2a9eae24c1 100644 --- a/cuda-oxide-book/appendix/supported-features.md +++ b/cuda-oxide-book/appendix/supported-features.md @@ -84,7 +84,8 @@ roadmap, **N/A** = not applicable or no identified need. | LTOIR Linking | **Full** | Device-side LTO via libNVVM and nvJitLink. | | Float Math Intrinsics (libdevice) | **Full** | Rust `f32`/`f64` math methods (`sin`, `cos`, `exp`, `pow`, `sqrt`, ...) lower to CUDA libdevice (`__nv_*`). cuda-oxide auto-detects libdevice usage and emits NVVM IR; `cuda_host::load_kernel_module` (sync) and `cuda_host::load_kernel_module_async` (async) build the cubin via libNVVM + nvJitLink at runtime. | | Pipeline Inspection | **Full** | `cargo oxide pipeline ` shows IR at each compilation stage. | -| cuda-gdb Debug Support | **Full** | Build with debug info and launch `cuda-gdb`. `breakpoint()` intrinsic for programmatic breakpoints. | +| cuda-gdb Source Debugging | **Full** | `cargo oxide debug` builds with device line-table metadata and launches `cuda-gdb`. Supports source breakpoints, stepping, backtraces, and cross-file helper spans. | +| cuda-gdb Local / Argument Inspection | **Partial** | `CUDA_OXIDE_DEBUG=full` emits basic DWARF metadata for whole locals and arguments with scalar or pointer-shaped types. Rich Rust type trees, projections, destructuring, and Pliron-side optimized variable locations are still work in progress. | ## Compiler: Inline PTX diff --git a/cuda-oxide-book/compiler/lowering-pipeline.md b/cuda-oxide-book/compiler/lowering-pipeline.md index ba8722fc48..a8c8a4436e 100644 --- a/cuda-oxide-book/compiler/lowering-pipeline.md +++ b/cuda-oxide-book/compiler/lowering-pipeline.md @@ -319,6 +319,107 @@ naming everything upfront. --- +## Debug Locations + +rustc already knows where most MIR operations came from in the source program. +cuda-oxide preserves that information as it lowers the kernel: + +```text +rustc Span + ↓ +pliron Location + ↓ +LLVM !dbg metadata + ↓ +PTX .loc / DWARF + ↓ +cuda-gdb source line +``` + +The common case is a source line in the same file as the function: + +```llvm +define ptx_kernel void @vecadd(...) !dbg !func { + %idx = call i64 @cuda_device____internal__index_1d(...), !dbg !loc +} + +!loc = !DILocation(line: 39, column: 13, scope: !func) +``` + +That says: "this instruction belongs to line 39, column 13, inside this +function." It is just enough for cuda-gdb to stop on the right Rust line. + +### Cross-file spans + +Some source spans point into helper files. For example, a kernel in +`src/main.rs` may step into `cuda-device/src/thread.rs` when calling +`thread::index_1d()`. + +The exporter must not attach `thread.rs:292` directly to the kernel's +`src/main.rs` scope. That would tell the debugger "line 292 of main.rs", which +is the kind of small lie that turns debugging into archaeology. + +Instead, cuda-oxide creates a file-specific debug scope: + +```text +src/main.rs function scope + | + +-- cuda-device/src/thread.rs file scope + | + +-- line 292, column 19 +``` + +In LLVM metadata this looks like: + +```llvm +!thread_scope = !DILexicalBlockFile(scope: !kernel_scope, file: !thread_file, discriminator: 0) +!loc = !DILocation(line: 292, column: 19, scope: !thread_scope) +``` + +When Pliron gives an explicit callsite location, the exporter also records +where the helper code was reached from: + +```llvm +!call = !DILocation(line: 39, column: 13, scope: !kernel_scope) +!loc = !DILocation(line: 292, column: 19, scope: !thread_scope, inlinedAt: !call) +``` + +Read that as: "we are executing `thread.rs:292`, reached from `main.rs:39`." + +### Variables + +Full debug mode adds the first variable records on top of line locations: + +```llvm +%tid_slot = alloca i32 +call void @llvm.dbg.declare(metadata ptr %tid_slot, metadata !tid, metadata !DIExpression()) + +!tid = !DILocalVariable(name: "tid", scope: !func, file: !file, line: 31, type: !u32) +``` + +The initial model is intentionally simple: + +```text +dbg.declare -> "this source variable lives at this address" +``` + +LLVM can often rewrite that into `dbg.value` during optimization: + +```text +dbg.value -> "this source variable has this SSA value here" +``` + +Pliron's own optimization passes do not yet perform that debug salvage, so the +full-debug path avoids Pliron `mem2reg` for now. LLVM `-O2` is allowed because +LLVM already has machinery to preserve many variable locations. + +PHI nodes generated by the exporter normally do not get `!dbg`: they are SSA +bookkeeping, not source operations the user wrote. Later variable metadata can +refer to PHI values through `dbg.value`, but the PHI itself should not become a +surprise stop in cuda-gdb. + +--- + ## Symbol Name Sanitization Function names flow through several stages, each applying its own constraints: diff --git a/cuda-oxide-book/gpu-programming/error-handling-and-debugging.md b/cuda-oxide-book/gpu-programming/error-handling-and-debugging.md index 15a048c5a8..6639a22e02 100644 --- a/cuda-oxide-book/gpu-programming/error-handling-and-debugging.md +++ b/cuda-oxide-book/gpu-programming/error-handling-and-debugging.md @@ -150,6 +150,81 @@ cargo oxide debug vecadd --tui # GDB with TUI cargo oxide debug vecadd --cgdb # cgdb front-end ``` +By default this gives you **source-level debugging**: cuda-gdb can stop in +Rust source files and show a useful backtrace. Local-variable inspection is a +separate, heavier mode that you opt into when you need it. + +### Debug info modes + +cuda-oxide has three device debug modes: + +| Mode | How to enable it | What you get | Cost | +|:-----|:-----------------|:-------------|:-----| +| Off | default for normal `build` / `run` | Fastest generated PTX, no source mapping | none | +| Line tables | `cargo oxide debug`, or `CUDA_OXIDE_DEBUG=line-tables` | Source breakpoints, stepping, backtraces | low | +| Full | `CUDA_OXIDE_DEBUG=full cargo oxide debug ` | Line tables plus basic argument/local inspection | higher | + +Think of line tables as a map from machine instructions back to source lines: + +```text +PTX instruction ──debug line table──> src/main.rs:39 +``` + +Full debug adds variable records: + +```text +source local `tid` + | + v +LLVM/DWARF says: "tid lives in this stack slot" + | + v +cuda-gdb can try: print tid +``` + +Use line tables first. They are enough for most "where did execution go?" +questions, and they avoid the slower CUDA debug target mode. Use full debug +when you specifically want `print idx`, `print ptr`, or similar local-variable +inspection. Debuggers are allowed to be nosy; they are not always allowed to be +fast. + +The `CUDA_OXIDE_DEBUG` override works with `build`, `run`, `pipeline`, and +`debug`: + +```bash +CUDA_OXIDE_DEBUG=line-tables cargo oxide pipeline vecadd +CUDA_OXIDE_DEBUG=full cargo oxide debug vecadd +``` + +Useful aliases: + +| Value | Meaning | +|:------|:--------| +| `off`, `none`, `0` | no device debug metadata | +| `line-tables`, `line`, `lines`, `1` | source line tables only | +| `full`, `2` | line tables plus basic variable metadata | + +### What works today + +Line-table mode supports: + +- breakpoints by kernel name, e.g. `break vecadd` +- source stepping and backtraces +- helper/inlined source locations from other files, such as stepping from your + kernel into `cuda-device/src/thread.rs` + +Full mode currently supports the first simple variable slice: + +- whole local variables and arguments that rustc exposes through + `var_debug_info` +- `bool`, integer, float, raw-pointer, and reference-shaped debug types +- `llvm.dbg.declare` / LLVM-salvaged `dbg.value` metadata where LLVM can keep + the variable location alive + +Full mode does **not** yet describe rich Rust type trees such as structs, +tuples, slices, arrays, closures, projections like `x.0`, or destructured +variables. + ### Breakpoint workflow 1. Build with debug: `cargo oxide debug ` @@ -227,6 +302,8 @@ For more targeted inspection: |:-------------------------------|:----------------------------------| | `CUDA_OXIDE_VERBOSE=1` | Verbose compiler output | | `CUDA_OXIDE_SHOW_RUSTC_MIR=1` | Dump the rustc MIR before import | +| `CUDA_OXIDE_DEBUG=line-tables` | Emit source line-table metadata | +| `CUDA_OXIDE_DEBUG=full` | Emit full metadata for basic locals and args | ## Profiling with Nsight Compute