Repository navigation
Plan: emit line-table debug metadata for cuda-gdb source breakpoints #224
Description
Related: #105, #106, #103, #67
Problem
cuda-oxide currently emits LLVM IR for device kernels without source-level debug
metadata: no !dbg, no DICompileUnit, no DILocation, and no
!llvm.dbg.cu.
That means cargo oxide debug can launch cuda-gdb, but cuda-gdb cannot set a
Rust source-line breakpoint inside generated GPU code. Today users mostly fall
back to symbol-free debugging: break on device functions, disassemble, inspect
registers, or use gdb.cuda memory reads.
Issue #105 tracks this user-visible gap. PR #106 is a useful first attempt, but
the exporter has since moved on (#67), and I think it would help to agree on a
small, reviewable implementation plan before reworking it.
Proposed first milestone
Ship line-tables-only debug metadata first.
Plainly: make this work:
(cuda-gdb) break src/kernel.rs:42
(cuda-gdb) runThis first milestone should support source-line breakpoints and stepping. It
should not try to make info locals or print my_var work yet. Variable/type
debug info is a larger follow-up.
Why line-tables-only first?
DWARF has two useful layers:
- a line table: instruction address -> file/line/column
- variable/type records: locals, params, structs, pointers, etc.
The line table is much smaller and is enough for source breakpoints. It is also
compatible with optimized code. Full variable debugging usually wants much more
metadata and often pairs with -G / -O0-style device debugging, which changes
codegen and performance.
So the first target should be:
Rust source span
-> pliron Location::SrcPos
-> LLVM !dbg / DILocation
-> PTX .file / .loc
-> cuda-gdb source breakpoints
Current implementation facts
- rustc already gives us source spans during MIR translation.
- cuda-oxide currently turns those spans into
Location::Named { name: format!("{:?}", span), ... }, which preserves a printable string but loses
structured file/line/column data. - pliron already has
Location::SrcPos { src, pos }, and operations already
carry locations. - The mir-lower rewrite path copies locations forward by default, so this does
not need a new pass. - The LLVM exporter currently emits NVVM metadata, but no LLVM debug metadata.
- The exporter has duplicate module-level emission paths, so debug metadata has
to be wired into both or the common path should be refactored first.
Proposed design
1. Decode rustc spans at the importer boundary
Centralize a helper roughly like:
fn span_to_loc(ctx: &mut Context, span: Span) -> Location {
let file = span.get_filename();
let lines = span.get_lines();
Location::SrcPos {
src: Source::new_from_file(ctx, file.into()),
pos: SourcePosition {
line: lines.start_line as i32,
column: lines.start_col as i32,
},
}
}Use that helper where MIR body/statement/terminator locations are currently
created. For dummy, virtual, macro-generated, or otherwise unusable spans, fall
back to Location::Unknown and let the exporter omit !dbg.
2. Add one shared metadata-ID allocator
Before adding debug metadata, replace the current count-based NVVM metadata ID
scheme with a single monotonic allocator used by both:
- existing
!nvvm.annotations/!nvvmir.version - new
DIFile,DICompileUnit,DISubprogram,DILocation, etc.
This avoids future ID collisions and makes the debug work easier to review.
3. Gate debug emission
Add an exporter-level debug mode, defaulting to off:
enum DebugKind {
Off,
LineTables,
Full,
}Recommended behavior:
- default:
Off, so release output stays byte-identical -C debuginfo=line-tables-onlyor-g: enableLineTables- optional environment override for local experiments, e.g.
CUDA_OXIDE_DEBUG=line-tables
cargo oxide debug already exists from #103, so this should eventually line up
with that workflow.
4. Emit the minimal LLVM debug metadata
For line-tables-only, emit:
!llvm.dbg.cu = !{!cu}!llvm.module.flagscontaining:!{i32 2, !"Debug Info Version", i32 3}!{i32 2, !"Dwarf Version", i32 2}
- one
distinct !DICompileUnit(..., emissionKind: LineTablesOnly) - one interned
!DIFile(...)per source file - one
distinct !DISubprogram(...)per defined device function - one shared
!DISubroutineType(types: !{}) - one
!DILocation(line, column, scope: !subprogram)per source-mapped
instruction
Important detail: always give every DISubprogram a non-null
DISubroutineType, even for line-tables-only. LLVM 21/22 tolerate omitting it,
but LLVM 23 is stricter and may drop debug info if DISubprogram.type is
missing.
Example target shape:
define ptx_kernel void @add_one(ptr %p) !dbg !4 {
%0 = load i32, ptr %p, align 4, !dbg !7
%1 = add i32 %0, 1, !dbg !8
store i32 %1, ptr %p, align 4, !dbg !9
ret void, !dbg !10
}
!llvm.dbg.cu = !{!0}
!llvm.module.flags = !{!2, !3}
!0 = distinct !DICompileUnit(language: DW_LANG_Rust, file: !1, producer: "cuda-oxide", isOptimized: true, emissionKind: LineTablesOnly)
!1 = !DIFile(filename: "kernel.rs", directory: "/work/src")
!2 = !{i32 2, !"Dwarf Version", i32 2}
!3 = !{i32 2, !"Debug Info Version", i32 3}
!4 = distinct !DISubprogram(name: "add_one", scope: !1, file: !1, line: 1, type: !5, scopeLine: 1, spFlags: DISPFlagDefinition, unit: !0)
!5 = !DISubroutineType(types: !{})
!7 = !DILocation(line: 2, column: 13, scope: !4)
!8 = !DILocation(line: 2, column: 17, scope: !4)
!9 = !DILocation(line: 2, column: 9, scope: !4)
!10 = !DILocation(line: 3, column: 1, scope: !4)5. Do not add a debug pass
This should ride the existing Located field on operations. No new pass is
needed for the line-table milestone.
Synthetic instructions, PHIs, and instructions with unknown locations can omit
!dbg.
Suggested phases
-
Metadata allocator cleanup
- add one shared metadata ID allocator
- keep current output valid with debug off
-
Importer span decoding
- convert real rustc spans to
Location::SrcPos - centralize fallback behavior for unusable spans
- convert real rustc spans to
-
Line-table exporter
- emit module-level debug roots/flags
- attach
DISubprogramto each defined device function - attach
DILocationto source-mapped instructions
-
Validation
.llshape test:!llvm.dbg.cu, module flags,DICompileUnit,
DISubprogram,DILocationllcround-trip: PTX contains.file,.loc, and.target ..., debugopt -O2round-trip: line metadata is not stripped- GPU-gated cuda-gdb test:
break kernel.rs:<line>resolves and is hit
-
Later: full variable debug
- carry
Body::var_debug_info - emit
DILocalVariable, type metadata, andllvm.dbg.value - likely pair with an opt-skip or
-O0debug mode
- carry
Open questions
- Should the first implementation be controlled primarily by rustc
-C debuginfo, an explicit cuda-oxide env var, or both? - Should
DIFilepaths be absolute, matching rustc's local source paths, or
normalized for reproducible/distributable PTX? - For macro-expanded or inlined device-function spans, should Phase 1 omit debug
locations, point at the call site, or try to modelinlined_atlater? - Should PHIs and export-synthesized instructions omit
!dbg, or inherit the
surrounding block/function location? - Should this new plan replace PR Add debug information to the generated PTX code #106, build on it, or be split into smaller
PRs starting with the metadata allocator?
Non-goals for the first PR
- full variable inspection in cuda-gdb
- a custom DWARF writer
- a new optimization/debug pass
- changing host-side Rust debug info
- forcing
-G/-O0for all debug builds
Activity
nihalpasham commentedon Jun 15, 2026
I opened the first implementation PR here: #225
It covers the line-tables-only milestone from this plan:
DIFile/DICompileUnit/DISubprogram/DILocationmetadata!dbgis attached to function definitions and real instructions-C debuginfo, withCUDA_OXIDE_DEBUGas an overrideManual cuda-gdb smoke on
vecaddis working: a device breakpoint/backtrace resolves through bothcuda-device/src/thread.rsand the user kernel insrc/main.rs.Full variable/type debug info is still deferred; this PR is just the source-line breakpoint and stepping layer.
feat: provenance-checked tuning records (NVIDIA#224)