diff --git a/cutile-rs/CHANGELOG.md b/cutile-rs/CHANGELOG.md index 3cd8f95782..33025e0906 100644 --- a/cutile-rs/CHANGELOG.md +++ b/cutile-rs/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/). ### Fixed +- Generated constants without captured source spans use their containing + function definition, retaining exact user-expression locations and nested + cross-file inline caller chains. + - Const-generic array bindings retain their function or method signature as their source origin, and generated reduction bodies retain the reduction call's location and inline chain. diff --git a/cutile-rs/cutile-book/guide/debugging-and-profiling.md b/cutile-rs/cutile-book/guide/debugging-and-profiling.md index 32e88001d6..fdda6386f8 100644 --- a/cutile-rs/cutile-book/guide/debugging-and-profiling.md +++ b/cutile-rs/cutile-book/guide/debugging-and-profiling.md @@ -230,6 +230,15 @@ when inlined, including across modules. Core operations are attributed to the user's call site. Multiple generated instructions can still map to one Rust line; this is not a one-to-one mapping, especially with optimization. +Known toolkit limitation: tileiras **13.4.59** (in the CUDA 13.4.1 CI image) and +**13.4.92** can replace Rust helper locations +with generated Tile IR locations and omit inline frames, even when the frontend +bytecode contains the correct source information. This affects source correlation +and helper backtraces. The same bytecode retains those locations with tileiras +13.3.36. CI reports this exact backend limitation while continuing to +check frontend origins and surviving kernel locations; it does not waive checks +for other releases, including 13.5. See [#1385](https://github.com/NVIDIA/cuda-rust/pull/1385). + Generated const-generic array bindings use the containing function or method's signature as their source origin. Generated reduction bodies use the reduction call's location, including its inline caller chain. diff --git a/cutile-rs/cutile-compiler/src/compiler/_function.rs b/cutile-rs/cutile-compiler/src/compiler/_function.rs index 86334ddf50..7f01c4fa5c 100644 --- a/cutile-rs/cutile-compiler/src/compiler/_function.rs +++ b/cutile-rs/cutile-compiler/src/compiler/_function.rs @@ -784,9 +784,25 @@ impl<'m> CUDATileFunctionCompiler<'m> { /// stack. pub(crate) fn ir_location(&self, span: &proc_macro2::Span) -> cutile_ir::ir::Location { let stack = self.call_site_stack.borrow(); + // Runtime parsing gives each captured module its own source span. + // Generated types, arrays, and wrapper calls do not join that span. + let captured_location = || { + self.modules + .name_resolver + .all_modules() + .iter() + .find_map(|(name, module)| { + module.ident.span().join(*span)?; + let base = self.modules.get_span_base(name).filter(|b| b.is_known())?; + Some(base.resolve_span(span)) + }) + }; let (caller, callee_scope, span_base) = match stack.last() { None => { - let loc = self.resolve_span(span); + let loc = captured_location().unwrap_or_else(|| { + self.span_base() + .resolve_span(&self._function.sig.ident.span()) + }); return if loc.is_known() { cutile_ir::ir::Location::FileLineCol { filename: loc.file, @@ -809,7 +825,12 @@ impl<'m> CUDATileFunctionCompiler<'m> { // callee's own module base; scope the op to the CALLEE's subprogram // (so inline debug frames name the inlined function, not the kernel // entry) and chain where it was inlined. - let loc = span_base.resolve_span(span); + let loc = captured_location().unwrap_or_else(|| match callee_scope { + Some(scope) if span_base.is_known() => { + SourceLocation::new(span_base.file.clone(), scope.line as usize, 0) + } + _ => SourceLocation::unknown(), + }); if !loc.is_known() { return cutile_ir::ir::Location::Unknown; } diff --git a/cutile-rs/cutile/tests/debug_info.rs b/cutile-rs/cutile/tests/debug_info.rs index 4b1734f85c..1975c35ae9 100644 --- a/cutile-rs/cutile/tests/debug_info.rs +++ b/cutile-rs/cutile/tests/debug_info.rs @@ -393,3 +393,497 @@ fn lowered_dispatch_call_keeps_its_call_site() { ); }); } + +#[cutile::module] +mod assertion_kernel { + use cutile::core::*; + + #[cutile::entry()] + fn checked_scale( + out: &mut Tensor, + x: &Tensor, + factor: f32, + ) { + validate_factor(factor); + let tile_x = x.load_like(out); + out.store(tile_x * factor.broadcast(out.shape())); + } + + fn validate_factor(factor: f32) { + assert_in_range(factor, 0.0f32, 1000.0f32); + } + + #[allow(unreachable_code, unused_variables)] + fn assert_in_range(value: f32, lo: f32, hi: f32) { + cuda_tile_assert!(value >= lo, "checked_scale: factor below the allowed range"); + cuda_tile_assert!(value <= hi, "checked_scale: factor above the allowed range"); + } +} + +/// Renders a location as `callee <- caller`, each frame as +/// `subprogram[definition line]@file:line`. The outermost frame of a kernel is +/// rendered as `file:line`. +fn describe(location: &Location) -> String { + let base = |file: &str| file.rsplit('/').next().unwrap().to_string(); + match location { + Location::FileLineCol { filename, line, .. } => format!("{}:{line}", base(filename)), + Location::DebugInfo(info) => match &info.scope { + DebugScope::Subprogram(scope) => format!( + "{}[{}]@{}:{}", + scope.name, + scope.line, + base(&info.filename), + info.line + ), + other => panic!("unexpected scope {other:?}"), + }, + Location::CallSite { callee, caller } => { + format!("{} <- {}", describe(callee), describe(caller)) + } + other => panic!("unexpected location {other:?}"), + } +} + +/// Every constant in the module as (little-endian value bytes, described location). +fn constant_locations(artifacts: &CompileArtifacts) -> Vec<(Vec, String)> { + let mut constants = Vec::new(); + visit_ops( + artifacts.module(), + &artifacts.module().functions, + &mut |op| { + if op.opcode != cutile_ir::bytecode::Opcode::Constant { + return; + } + let Some((_, cutile_ir::ir::Attribute::DenseElements(value))) = + op.attributes.iter().find(|(name, _)| name == "value") + else { + panic!("constant without a dense value: {op:?}"); + }; + constants.push((value.data.clone(), describe(&op.location))); + }, + ); + constants +} + +/// The set of locations stored in the serialized debug section, rendered like `describe`. +fn decoded_locations(bytecode: &[u8]) -> std::collections::HashSet { + use std::collections::HashMap; + fn field<'a>(entry: &'a str, key: &str) -> &'a str { + let start = entry.find(&format!("{key}=")).unwrap() + key.len() + 1; + let rest = &entry[start..]; + match rest.strip_prefix('"') { + Some(quoted) => "ed[..quoted.find('"').unwrap()], + None => &rest[..rest.find([',', ')']).unwrap()], + } + } + let dump = cutile_ir::decode_bytecode(bytecode).expect("decode"); + let debug = dump.split("=== Debug").nth(1).expect("debug section"); + let mut entries: HashMap<&str, &str> = HashMap::new(); + for line in debug + .lines() + .map(str::trim_start) + .filter(|line| line.starts_with("di[")) + { + let (id, body) = line.split_once(" = ").unwrap(); + entries.insert(id, body); + } + fn render(id: &str, entries: &HashMap<&str, &str>) -> String { + let body = entries[id]; + if body.starts_with("CallSite(") { + return format!( + "{} <- {}", + render(field(body, "callee"), entries), + render(field(body, "caller"), entries) + ); + } + let scope = entries[field(body, "scope")]; + let quoted = body.split('"').nth(1).unwrap(); + let line = body.split('"').nth(2).unwrap()[1..] + .split(':') + .next() + .unwrap(); + let file = quoted.rsplit('/').next().unwrap(); + if field(scope, "linkage").ends_with("_entry") { + format!("{file}:{line}") + } else { + format!( + "{}[{}]@{file}:{line}", + field(scope, "name"), + field(scope, "line") + ) + } + } + entries + .iter() + .filter(|(_, body)| body.starts_with("DILoc(") || body.starts_with("CallSite(")) + .map(|(id, _)| render(id, &entries)) + .collect() +} + +/// Checks the exact location of the selected constants, rejects any location +/// outside the allowed set, and checks the same locations after serialization. +fn check_constant_locations( + artifacts: &CompileArtifacts, + allowed: &[String], + required: &[(Vec, String)], +) { + let constants = constant_locations(artifacts); + assert!(!constants.is_empty(), "expected generated constants"); + let unexpected: Vec<_> = constants + .iter() + .filter(|(_, location)| !allowed.contains(location)) + .collect(); + assert!( + unexpected.is_empty(), + "unexpected locations: {unexpected:#?}" + ); + for (value, location) in required { + assert!( + constants + .iter() + .any(|(bytes, found)| bytes == value && found == location), + "no constant {value:?} at {location}, found {constants:#?}" + ); + } + let decoded = decoded_locations(&artifacts.bytecode().expect("serialize")); + for (value, location) in &constants { + assert!( + decoded.contains(location), + "location of constant {value:?} lost in serialization: {location}\n{decoded:#?}" + ); + } +} + +fn check_generated_constant_locations(level: DebugInfoLevel) { + common::with_test_stack(move || { + let artifacts = KernelCompiler::new( + assertion_kernel::__module_ast_self, + "assertion_kernel", + "checked_scale", + ) + .target("sm_120") + .generics(vec!["128".into()]) + .strides(&[("out", &[1]), ("x", &[1])]) + .options(CompileOptions::new().debug_info(level)) + .compile() + .expect("compile assertion kernel"); + let source = include_str!("debug_info.rs"); + let kernel = source_line(source, "fn checked_scale("); + let validator = source_line(source, "fn validate_factor(factor: f32) {"); + let validate_call = source_line(source, "validate_factor(factor);"); + let range_call = source_line(source, "assert_in_range(factor, 0.0f32, 1000.0f32);"); + let load = source_line(source, "let tile_x = x.load_like(out);"); + let store = source_line(source, "out.store(tile_x * factor.broadcast(out.shape()));"); + let root = format!("debug_info.rs:{kernel}"); + // Generated constants without a source span fall back to the kernel definition. + let fallback = format!("checked_scale[{kernel}]@debug_info.rs:{kernel} <- {root}"); + let statement = + |line: u32| format!("checked_scale[{kernel}]@debug_info.rs:{line} <- {root}"); + // The literals written in the user's helper keep their own line and caller chain. + let literal = format!( + "validate_factor[{validator}]@debug_info.rs:{range_call} <- \ + checked_scale[{kernel}]@debug_info.rs:{validate_call} <- {root}" + ); + let allowed = [ + root.clone(), + fallback.clone(), + statement(load), + statement(store), + literal.clone(), + ]; + check_constant_locations( + &artifacts, + &allowed, + &[ + (128i32.to_le_bytes().to_vec(), fallback), + (0.0f32.to_le_bytes().to_vec(), literal.clone()), + (1000.0f32.to_le_bytes().to_vec(), literal.clone()), + ], + ); + for (value, location) in constant_locations(&artifacts) { + if value == 1000.0f32.to_le_bytes() { + assert_eq!(location, literal, "user literal replaced by a fallback"); + } + } + }); +} + +#[test] +fn generated_constants_keep_function_locations_line() { + check_generated_constant_locations(DebugInfoLevel::Line); +} + +#[test] +fn generated_constants_keep_function_locations_full() { + check_generated_constant_locations(DebugInfoLevel::Full); +} + +#[cutile::module] +mod generated_helper_kernel { + use super::debug_helpers_file::debug_helpers::generated; + use cutile::core::*; + + #[cutile::entry()] + fn generated_source(out: &mut Tensor, x: i32) { + let value = generated::<{ [-17, 23] }, true>(x); + out.store(value.broadcast(out.shape())); + } +} + +#[cutile::module] +mod nested_helper_kernel { + use super::debug_helpers_file::debug_helpers::generated_nested; + use cutile::core::*; + + #[cutile::entry()] + fn nested_source(out: &mut Tensor, x: i32) { + let total = generated_nested::<{ [-17, 23] }>(x); + out.store(total.broadcast(out.shape())); + } +} + +fn check_cross_file_constant_locations(level: DebugInfoLevel, nested: bool) { + common::with_test_stack(move || { + macro_rules! compile_helper { + ($module:ident, $entry:literal) => { + KernelCompiler::new($module::__module_ast_self, stringify!($module), $entry) + .target("sm_120") + .generics(vec!["32".into()]) + .strides(&[("out", &[1])]) + .options(CompileOptions::new().debug_info(level)) + .compile() + .expect("compile cross-file helper") + }; + } + let artifacts = if nested { + compile_helper!(nested_helper_kernel, "nested_source") + } else { + compile_helper!(generated_helper_kernel, "generated_source") + }; + let source = include_str!("debug_info.rs"); + let helpers = include_str!("fixtures/debug_helpers.rs"); + let (name, signature, call, store) = if nested { + ( + "nested_source", + "fn nested_source(out: &mut Tensor, x: i32) {", + "let total = generated_nested::<{ [-17, 23] }>(x);", + "out.store(total.broadcast(out.shape()));", + ) + } else { + ( + "generated_source", + "fn generated_source(out: &mut Tensor, x: i32) {", + "let value = generated::<{ [-17, 23] }, true>(x);", + "out.store(value.broadcast(out.shape()));", + ) + }; + let kernel = source_line(source, signature); + let call_line = source_line(source, call); + let store = source_line(source, store); + let definition = source_line( + helpers, + "pub fn generated(x: i32) -> i32 {", + ); + let indexing = source_line(helpers, "x + VALUES[0] + VALUES[1]"); + let root = format!("debug_info.rs:{kernel}"); + let fallback = format!("{name}[{kernel}]@debug_info.rs:{kernel} <- {root}"); + let call_site = format!("{name}[{kernel}]@debug_info.rs:{call_line} <- {root}"); + let store_site = format!("{name}[{kernel}]@debug_info.rs:{store} <- {root}"); + let mut allowed = vec![root, fallback, store_site]; + let mut required = Vec::new(); + // Caller chain of the inner `generated` call, as seen by its constants. + let inner_caller = if nested { + let nested_definition = source_line( + helpers, + "pub fn generated_nested(x: i32) -> i32 {", + ); + let nested_call = source_line(helpers, "generated::(x) + 1"); + let frame = |line: u32| { + format!( + "generated_nested[{nested_definition}]@debug_helpers.rs:{line} <- {call_site}" + ) + }; + let outer_values = frame(nested_definition); + let outer_call = frame(nested_call); + for value in [-17i32, 23] { + required.push((value.to_le_bytes().to_vec(), outer_values.clone())); + } + // The `+ 1` written in the outer helper keeps the outer helper's own line. + required.push((1i32.to_le_bytes().to_vec(), outer_call.clone())); + allowed.push(outer_values); + allowed.push(outer_call.clone()); + outer_call + } else { + call_site + }; + let values = + format!("generated[{definition}]@debug_helpers.rs:{definition} <- {inner_caller}"); + let index = + format!("generated[{definition}]@debug_helpers.rs:{indexing} <- {inner_caller}"); + for value in [-17i32, 23] { + required.push((value.to_le_bytes().to_vec(), values.clone())); + } + for value in [0i32, 1] { + required.push((value.to_le_bytes().to_vec(), index.clone())); + } + allowed.push(values); + allowed.push(index); + check_constant_locations(&artifacts, &allowed, &required); + }); +} + +#[test] +fn generated_helper_constants_keep_definition_at_line_level() { + check_cross_file_constant_locations(DebugInfoLevel::Line, false); +} + +#[test] +fn generated_helper_constants_keep_definition_at_full_level() { + check_cross_file_constant_locations(DebugInfoLevel::Full, false); +} + +#[test] +fn nested_helper_constants_keep_caller_chain_at_line_level() { + check_cross_file_constant_locations(DebugInfoLevel::Line, true); +} + +#[test] +fn nested_helper_constants_keep_caller_chain_at_full_level() { + check_cross_file_constant_locations(DebugInfoLevel::Full, true); +} + +// Restrict the backend exception to the exact releases reproduced in #1385. +// In particular, 13.5 must establish its own profiling/debugging behavior. +fn has_known_inline_debug_loss(fingerprint: &str) -> bool { + fingerprint.lines().any(|line| { + matches!( + line.trim(), + "Cuda compilation tools, release 13.4, V13.4.59" + | "Cuda compilation tools, release 13.4, V13.4.92" + ) + }) +} + +#[test] +fn inline_debug_exception_does_not_cover_other_toolkit_versions() { + for (version, expected) in [ + ("13.3, V13.3.36", false), + ("13.4, V13.4.59", true), + ("13.4, V13.4.60", false), + ("13.4, V13.4.92", true), + ("13.4, V13.4.93", false), + ("13.5, V13.5.92", false), + ] { + let banner = format!("tileiras\nCuda compilation tools, release {version}\n"); + assert_eq!(has_known_inline_debug_loss(&banner), expected); + } + assert!(!has_known_inline_debug_loss("unknown assembler")); +} + +// Opt-in, like `cubin_has_rust_lines_and_inline_frames`: the line table the +// device compiler emits for generated constants must stay inside the kernel +// and the helpers it calls. The exact IR/bytecode origins are checked above +// on every toolkit; only the known 13.4.59/13.4.92 backend loss is accounted for here. +#[test] +#[ignore = "requires tileiras, nvdisasm, and readelf"] +fn nested_helper_cubin_lines_stay_inside_their_functions() { + common::with_test_stack(|| { + use cutile_compiler::cuda_tile_runtime_utils::{ + run_tileiras, tileiras_fingerprint, TileirasOptions, + }; + let fingerprint = tileiras_fingerprint(); + eprintln!("Assembler for debug provenance checks:\n{fingerprint}"); + let known_debug_loss = has_known_inline_debug_loss(fingerprint); + let source = include_str!("debug_info.rs"); + let helpers = include_str!("fixtures/debug_helpers.rs"); + let kernel = source_line( + source, + "fn nested_source(out: &mut Tensor, x: i32) {", + ); + let store = source_line(source, "out.store(total.broadcast(out.shape()));"); + let first_helper = source_line( + helpers, + "pub fn generated(x: i32) -> i32 {", + ); + let last_helper = source_line(helpers, "generated::(x) + 1"); + let directory = CubinDirectory::new(); + for level in [DebugInfoLevel::Line, DebugInfoLevel::Full] { + let artifacts = KernelCompiler::new( + nested_helper_kernel::__module_ast_self, + "nested_helper_kernel", + "nested_source", + ) + .target("sm_120") + .generics(vec!["32".into()]) + .strides(&[("out", &[1])]) + .options(CompileOptions::new().debug_info(level)) + .compile() + .expect("compile nested helper kernel"); + let flags = + TileirasOptions::from_compile_options(&CompileOptions::new().debug_info(level)); + let cubin = run_tileiras(&artifacts.bytecode().unwrap(), "sm_120", &flags) + .expect("tileiras must accept the generated locations"); + let path = directory.0.join(format!("{level:?}.cubin")); + std::fs::write(&path, cubin).unwrap(); + let sass = output(Command::new("nvdisasm").arg("--print-line-info").arg(&path)); + let mut kernel_lines = Vec::new(); + let mut helper_lines = Vec::new(); + let mut generated_lines = Vec::new(); + for entry in sass + .lines() + .filter_map(|line| line.trim().strip_prefix("//## File \"")) + { + let (file, line) = entry.split_once("\", line ").unwrap(); + let line: u32 = line.trim().parse().unwrap(); + if file.ends_with("fixtures/debug_helpers.rs") { + helper_lines.push(line); + } else if file.ends_with("tests/debug_info.rs") { + kernel_lines.push(line); + } else if known_debug_loss && file.starts_with("./tile-ir-") { + // These assembler builds substitute internal Tile IR locations + // for Rust helper locations. Do not accept arbitrary foreign files. + generated_lines.push(line); + } else { + panic!("line entry outside the kernel and its helpers: {entry}"); + } + } + assert!( + !kernel_lines.is_empty(), + "{level:?}: no kernel lines\n{sass}" + ); + assert!( + !helper_lines.is_empty() || known_debug_loss, + "{level:?}: no helper lines\n{sass}" + ); + assert!( + kernel_lines + .iter() + .all(|line| (kernel..=store).contains(line)), + "{level:?}: kernel lines {kernel_lines:?} outside {kernel}..={store}\n{sass}" + ); + assert!( + helper_lines + .iter() + .all(|line| (first_helper..=last_helper).contains(line)), + "{level:?}: helper lines {helper_lines:?} outside {first_helper}..={last_helper}\n{sass}" + ); + let mut missing_inline_scope = false; + if level == DebugInfoLevel::Full { + let dwarf = output(Command::new("readelf").arg("--debug-dump=info").arg(&path)); + assert!(dwarf.contains("nested_source"), "{dwarf}"); + missing_inline_scope = !dwarf.contains("generated_nested"); + assert!(!missing_inline_scope || known_debug_loss, "{dwarf}"); + } + if known_debug_loss + && (helper_lines.is_empty() || !generated_lines.is_empty() || missing_inline_scope) + { + eprintln!( + "::warning::the active tileiras has known inline-debug loss ({level:?}); \ + surviving Rust locations were checked and frontend/bytecode checks remain strict, \ + but helper locations/frames are not established by this backend. \ + See NVIDIA/cuda-rust#1385. Other versions remain strict." + ); + } + } + }); +} diff --git a/cutile-rs/cutile/tests/fixtures/debug_helpers.rs b/cutile-rs/cutile/tests/fixtures/debug_helpers.rs index edf0ec2a5a..8bffc42c7d 100644 --- a/cutile-rs/cutile/tests/fixtures/debug_helpers.rs +++ b/cutile-rs/cutile/tests/fixtures/debug_helpers.rs @@ -13,6 +13,18 @@ pub mod debug_helpers { offset(x) * 2 } + pub fn generated(x: i32) -> i32 { + if FLAG { + x + VALUES[0] + VALUES[1] + } else { + x + } + } + + pub fn generated_nested(x: i32) -> i32 { + generated::(x) + 1 + } + pub trait Bump { fn bump(self) -> i32; } diff --git a/cutile-rs/scripts/run_cpu_tests.sh b/cutile-rs/scripts/run_cpu_tests.sh index d529110b65..09f2ea3516 100755 --- a/cutile-rs/scripts/run_cpu_tests.sh +++ b/cutile-rs/scripts/run_cpu_tests.sh @@ -82,6 +82,10 @@ do cargo test -p cutile --test "$test_target" done +run_step \ + "cutile debug provenance through the assembler" \ + cargo test -p cutile --test debug_info nested_helper_cubin_lines_stay_inside_their_functions -- --ignored --nocapture + run_step \ "cutile generated source locations through the assembler" \ cargo test -p cutile --test generated_source_locations generated_locations_survive_assembly -- --ignored