Skip to content

Plan: emit line-table debug metadata for cuda-gdb source breakpoints #224

Closed

Description

@nihalpasham

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) run

This 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-only or -g: enable LineTables
  • 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.flags containing:
    • !{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

  1. Metadata allocator cleanup

    • add one shared metadata ID allocator
    • keep current output valid with debug off
  2. Importer span decoding

    • convert real rustc spans to Location::SrcPos
    • centralize fallback behavior for unusable spans
  3. Line-table exporter

    • emit module-level debug roots/flags
    • attach DISubprogram to each defined device function
    • attach DILocation to source-mapped instructions
  4. Validation

    • .ll shape test: !llvm.dbg.cu, module flags, DICompileUnit,
      DISubprogram, DILocation
    • llc round-trip: PTX contains .file, .loc, and .target ..., debug
    • opt -O2 round-trip: line metadata is not stripped
    • GPU-gated cuda-gdb test: break kernel.rs:<line> resolves and is hit
  5. Later: full variable debug

    • carry Body::var_debug_info
    • emit DILocalVariable, type metadata, and llvm.dbg.value
    • likely pair with an opt-skip or -O0 debug mode

Open questions

  • Should the first implementation be controlled primarily by rustc
    -C debuginfo, an explicit cuda-oxide env var, or both?
  • Should DIFile paths 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 model inlined_at later?
  • 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 / -O0 for all debug builds

Activity

  1. nihalpasham commented on Jun 15, 2026

    @nihalpasham
    CollaboratorAuthor

    I opened the first implementation PR here: #225

    It covers the line-tables-only milestone from this plan:

    • rustc spans are now carried as structured pliron source locations
    • the LLVM exporter emits the minimal DIFile / DICompileUnit / DISubprogram / DILocation metadata
    • !dbg is attached to function definitions and real instructions
    • the gate follows rustc -C debuginfo, with CUDA_OXIDE_DEBUG as an override

    Manual cuda-gdb smoke on vecadd is working: a device breakpoint/backtrace resolves through both cuda-device/src/thread.rs and the user kernel in src/main.rs.

    Full variable/type debug info is still deferred; this PR is just the source-line breakpoint and stepping layer.

  2. added
    debug infoDebug information in generated PTX/IR (line/source maps)
    enhancementNew feature or request
    llvm-exportTextual LLVM/NVVM IR exporter (llvm-export crate)
    codegenDevice code-generation pipeline (Rust MIR to IR to PTX)
    trackingUmbrella tracking issue
    on Jun 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    codegenDevice code-generation pipeline (Rust MIR to IR to PTX)debug infoDebug information in generated PTX/IR (line/source maps)enhancementNew feature or requestllvm-exportTextual LLVM/NVVM IR exporter (llvm-export crate)trackingUmbrella tracking issue

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions