Skip to content

Add debug symbols that map generated code to the .stmt, and link the html outputs through them - #9611

Open
mcourteaux wants to merge 2 commits into
mcourteaux/stmt-html-from-irprinterfrom
mcourteaux/llvm-debug-symbols
Open

mcourteaux wants to merge 2 commits into
mcourteaux/stmt-html-from-irprinterfrom
mcourteaux/llvm-debug-symbols

Conversation

@mcourteaux

@mcourteaux mcourteaux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Second of a stack of three PRs: stacked on #9690 (the html outputs print the IR with IRPrinter), and followed by #9691 (GPU device code, draft). This PR was rebuilt after review: the GPU device code mapping moved to #9691, and the html printing to #9690.

This adds debug info that maps generated code back to the printed Halide IR, so you can step through a pipeline's .stmt in gdb/lldb, and links the html outputs to the assembly line by line through that same debug info. See doc/DebugSymbols.md.

Target features

  • debug_symbols: host code gets DWARF (CodeView on Windows) debug info that points into the .stmt output. The pipeline is still optimized as usual. let variables, loop variables and function arguments are visible to the debugger, and buffer arguments are typed as halide_buffer_t *. JIT code is registered with the debugger through the GDB JIT interface (also with a statically linked LLVM).
  • debug_symbols_conceptual: the same, but pointing into the .conceptual.stmt (the IR before offloading, closures and so on). Code that isn't in the conceptual stmt is attributed to the nearest enclosing IR that is. A target can't have both.

Source maps

IRPrinter can record where it prints each IR node (print_with_source_map), using the begin_node/end_node hooks of #9690 and an anchor() for the column of operators, variables and calls. The text is exactly that of operator<<. The conceptual stmt and the stmt share the nodes lowering didn't rewrite, which map_locations uses to carry locations from one to the other.

The html outputs

The stmt_html and conceptual_stmt_html outputs link each line of IR to the assembly generated for it, through the debug info in the assembly, instead of only loops by the names of their basic blocks (#9690). Their assembly is compiled with debug info even without the debug symbols features; it is then stripped from the other outputs, so those are the same as without an html output. The html shows the same text as the stmt files, so the line numbers in the debug info are those of the html.

Each IR node printed once

Debug info and the html find where code came from by the IR node it was made from. A node that the IR uses in more than one place (shared subexpressions, the copies of an unrolled loop, the checks of the buffers) is printed more than once, and then it is ambiguous which copy code came from. So, with the debug symbols features, or when the Module will be compiled to an html output, lowering rebuilds every node of the conceptual stmt right before it is kept (copy_shared_nodes, a mutate_with that rebuilds the nodes without IR in them, which forces all others to be rebuilt too).

Lowering can't see which outputs will be requested, so Pipeline::compile_to_module, AbstractGenerator::build_module and lower() get a for_html_outputs flag (default false), which compile_to and the generators set when an html output is requested. Without it (and without the debug features), nothing of this PR runs while lowering.

Checked: with this stack, all 73 app generators compile to byte-identical objects with and without an html output, on host and host-cuda. (That needs a fix for an unrelated address-dependence of loop partitioning on main, which makes some apps lower differently depending on what else the process allocated, e.g. which outputs are requested; that fix will be a separate PR.)

Compile time

Compile time of all 73 app generators to an object file (-e o: lowering, GPU kernels and LLVM codegen of the host code), mean of 3 runs, latest main against the top of this stack (#9691), both RelWithDebInfo with LLVM 22, without debug features or html outputs. "GPU" generators are the ones that launch kernels on that target.

target generators main this stack delta
host all 73 89264 ms 89126 ms -138 ms (0.998x)
host-cuda 24 GPU 55363 ms 55224 ms -139 ms (0.997x)
host-cuda 49 CPU only 100299 ms 100227 ms -72 ms (0.999x)
host-opencl 23 GPU 32430 ms 32346 ms -84 ms (0.997x)
host-opencl 50 CPU only 67241 ms 67128 ms -113 ms (0.998x)

All within the noise (the per-generator ratios have a standard deviation below 1%): without the debug features or an html output, none of the stack's work runs.

Screenshots

The conceptual_stmt_html of a blur with a line of IR clicked: the vector instructions generated for it are highlighted.

conceptual_stmt_html with a line clicked

Tests

correctness_debug_symbols checks the DWARF of host code (locations point at printed IR, one subprogram per function, variables and buffers), CodeView/Apple/ARM assembly, the JIT, debug_symbols_conceptual, that the html outputs show exactly the stmt text and link lines to assembly, and that no IR node is printed twice in the conceptual stmt when lowered for html.

Checklist

  • Tests added or updated
  • Documentation updated (doc/DebugSymbols.md, compile_to_module, build_module)
  • Python bindings updated (target features, compile_to_module(..., for_html_outputs=))
  • Benchmarks included (see above)
  • Commits include AI attribution where applicable (see Code of Conduct)

🤖 Generated with Claude Code

https://claude.ai/code/session_01TxSYmva6vWHCraKgSEe6Cu

@codecov

codecov Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 85.66629% with 128 lines in your changes missing coverage. Please review.
✅ Project coverage is 73.41%. Comparing base (9fc6be9) to head (f6546e5).
⚠️ Report is 10 commits behind head on main.

Files with missing lines Patch % Lines
src/IRPrinter.cpp 88.23% 18 Missing and 12 partials ⚠️
src/CodeGen_LLVM.cpp 91.11% 8 Missing and 15 partials ⚠️
src/CodeGen_D3D12Compute_Dev.cpp 17.39% 17 Missing and 2 partials ⚠️
src/CodeGen_C.cpp 70.49% 9 Missing and 9 partials ⚠️
src/JITModule.cpp 66.66% 9 Missing and 8 partials ⚠️
src/Module.cpp 85.93% 3 Missing and 6 partials ⚠️
src/CodeGen_GPU_Dev.cpp 92.59% 0 Missing and 6 partials ⚠️
src/CodeGen_ARM.cpp 50.00% 2 Missing ⚠️
src/CodeGen_Metal_Dev.cpp 94.11% 0 Missing and 1 partial ⚠️
src/CodeGen_OpenCL_Dev.cpp 95.23% 1 Missing ⚠️
... and 2 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #9611      +/-   ##
==========================================
+ Coverage   73.01%   73.41%   +0.39%     
==========================================
  Files         263      263              
  Lines       82383    82647     +264     
  Branches    20149    20416     +267     
==========================================
+ Hits        60151    60672     +521     
+ Misses      15915    15538     -377     
- Partials     6317     6437     +120     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@alexreinking

Copy link
Copy Markdown
Member

Why does this need three new Target features as opposed to just extending debug?

@mcourteaux

Copy link
Copy Markdown
Contributor Author

Why does this need three new Target features as opposed to just extending debug?

Because debug is extremely chatty when enabled on the runtime. It logs every gpu kernel launch. And AFAIK, you cannot combine a non-debug runtime with a debug pipeline.

Comment thread src/CodeGen_C.cpp Outdated

// The Stmts a Stmt is directly made of, in order.
std::vector<Stmt> stmt_children(const Stmt &s) {
switch (s->node_type) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should use visit_with, to catch new IR nodes as they are added

Comment thread src/CodeGen_C.h Outdated
CPlusPlusFunctionInfoHeader,
};

/** While recording where Stmts are printed (see IRPrinter), have a Stmt

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this comment. Can you rephrase?

Comment thread src/CodeGen_C.cpp
stream << get_indent() << "halide_maybe_unused(" << name << ");\n";
} else {
Expr new_var = Variable::make(op->value.type(), id_value);
body = substitute(op->name, new_var, body);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks like it could be much simpler if there's a pre-pass that renames lets as needed instead of doing it on demand. Then we wouldn't need the replacement map at all (I think?)

Comment thread src/CodeGen_D3D12Compute_Dev.cpp Outdated
// support predication.
Stmt original = s;
s = scalarize_predicated_loads_stores(s);
d3d12compute_c.remember_replaced(original, s);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternatively, would it be simpler if there were a stmt node Debug that attached .stmt line numbers to the child .stmt? It seems like a lot of this complexity is because you need to store the debug info in a separate map instead of it being in the IR.

@abadams abadams added the dev_meeting Topic to be discussed at the next dev meeting label Oct 26, 2026
@abadams

abadams commented Oct 26, 2026

Copy link
Copy Markdown
Member

This looks like a great feature! Alex and I were discussing if a new IR node type or field on Stmt would make the implementation easier/simpler. I personally lean towards a new IR node. Would that work? I tagged the PR with dev_meeting so we can discuss it there at the latest, but we can also discuss it here via text.

We also think it would be better for this to be a single target feature that turns on all three, but maybe we're missing something?

@abadams

abadams commented Oct 26, 2026

Copy link
Copy Markdown
Member

Separately, this made us wonder if conceptual_stmt shouldn't just replace .stmt at this point. I don't ever use the latter - do you find both useful?

@mcourteaux

Copy link
Copy Markdown
Contributor Author

Separately, this made us wonder if conceptual_stmt shouldn't just replace .stmt at this point. I don't ever use the latter - do you find both useful?

I don't find it useful, but it's what we hand to LLVM, and so debug information is derived from there. This should give the best assembly-to-source mapping. However, when you propose to introduce a new IR node type that holds debug location info, than that becomes a lot simpler, and we should get equally good mapping into the conceptual STMT.

discussing if a new IR node type or field on Stmt would make the implementation easier/simpler. I personally lean towards a new IR node.

I think a lot of things become simpler if we can just annotate the nodes with debug info. Unfortunately, we have already established that IR nodes should be kept small. But implementation-wise, just adding a 20 bits of line info and 12 bits of column info seems like a good 32-bit cost for every node type without needing to do any IR node wrapping. I think the "debug" node type would just double the IR node tree size for no good reason. We have line:col info for every IR node in the final printed stmt, so a field seems the appropriate tool if you ask me.

also think it would be better for this to be a single target feature that turns on all three, but maybe we're missing something?

If we remove the final stmt in favor the conceptual stmt, it is clearly possible to also remove one target feature. The reason I had it split out into two features is because line-to-assembly mapping is useful to go into gdb/lldb and often desirable for a very limited cost (emitting the source map info in the object file / dSYM). The GPU-source-mapping is much more expensive unfortunately: Claude thought it was a good idea to inject #line directives into the C-style shader source, which shader compilers can pick up if they support it. These #line directives blow up the shader source a lot. I haven't seen what the actual use-case is of this feature, but I suspect some tools from Apple or Nvidia might be able to then show you in some of their profiler/Debuggers what Stmt lines are being executed. In hindsight, I'm in favor of just dropping the C-style instrumentation with these debug locations. If we do that, it can become one target feature.

@abadams

abadams commented Oct 26, 2026

Copy link
Copy Markdown
Member

The point in favor of the IR node is that it's zero cost right up to the end of lowering where it gets injected, so intermediate lowering states where stmts might be very large (e.g. before simplifications) don't pay anything. Even more important, it's zero cost in non-debug builds.

@mcourteaux

mcourteaux commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor Author

Even more important, it's zero cost in non-debug builds.

Given you say this, I should point out that the PR as is computes source-mappings even without any feature flag enabled. It does it if you save HTML version of stmt to inject the links. The target feature is to actually emit the debug symbols into the generated object file / static library.

If we are concerned about IRNode cost, we should probably delete the vtable pointer.
We already have a node_type member that says what it is anyway. No point in doing inheritance if you ask me. That saves 64 bits. We can replace that with a 32-bit location field? UPDATE: #9624 deleted the vtable pointer.

@abadams

abadams commented Oct 26, 2026

Copy link
Copy Markdown
Member

In non-debug builds, the PR only computes the mappings if you ask for html output, correct?

@mcourteaux

Copy link
Copy Markdown
Contributor Author

In non-debug builds, the PR only computes the mappings if you ask for html output, correct?

No, it produces the mappings always, because the code in Lowering doesn't know what outputs will be required. If HTML is part of them, it would need to run through lowering again to get to the PTX offload, and then somehow do something different. To get a complete picture, I benchmarked lowering and LLVM backend of this PR against main:

target generators main this PR delta
host all 74 93974 ms 94105 ms +131 ms (1.001x)
host-cuda 25 GPU 59744 ms 60252 ms +508 ms (1.009x)
host-cuda 49 CPU only 104338 ms 104633 ms +295 ms (1.003x)
host-opencl 24 GPU 35587 ms 35672 ms +85 ms (1.002x)
host-opencl 50 CPU only 68583 ms 68672 ms +89 ms (1.001x)

Front-end only (-e stmt, which still includes compiling the GPU kernels):

target generators main this PR delta
host all 74 9880 ms 9857 ms -23 ms (0.998x)
host-cuda 25 GPU 31151 ms 31460 ms +309 ms (1.010x)
host-cuda 49 CPU only 43788 ms 43811 ms +23 ms (1.001x)
host-opencl 24 GPU 6206 ms 6252 ms +46 ms (1.007x)
host-opencl 50 CPU only 3605 ms 3561 ms -44 ms (0.988x)

So it's basically noise. No significant cost is paid for telling LLVM about the debug source locations.

@abadams

abadams commented Nov 2, 2026

Copy link
Copy Markdown
Member

Surely lowering could know whether or not it needs stmt source locations with an extra flag?

I think we could afford to make it a field on Stmt. Most of the memory savings of removing the vtable pointer would have come from Expr. However, an issue with making it a field though is that currently the same Stmt node can show up in multiple places in the IR (e.g. due to unrolling or specialization), and this would force them to be unique. A new IR node would do the same thing though, except for leaf stmts. Let's discuss at the dev meeting.

…html outputs through them

The debug_symbols target feature gives host code DWARF (CodeView on Windows)
debug info that points into the .stmt output, so a debugger can step through
the lowered IR; debug_symbols_conceptual points it into the .conceptual.stmt
instead. IRPrinter can record where it prints each IR node
(print_with_source_map); the text is the same as that of operator<<.

The html outputs link each line of IR to the assembly generated for it
through the same debug info: their assembly is compiled with it even without
the debug symbols features, and the other outputs then get none.

The html outputs and the debug symbols find where code came from by the IR
node it was made from, which must then be printed in one place only. With
the debug symbols features, or when the Module will be compiled to an html
output, which compile_to_module and generators are now told with a
for_html_outputs flag, lowering rebuilds every IR node of the conceptual
stmt so that none of them is used in more than one place.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TxSYmva6vWHCraKgSEe6Cu
@mcourteaux
mcourteaux force-pushed the mcourteaux/llvm-debug-symbols branch from f6546e5 to f67d10d Compare November 7, 2026 01:28
@mcourteaux
mcourteaux changed the base branch from main to mcourteaux/stmt-html-from-irprinter November 7, 2026 01:28
@mcourteaux
mcourteaux added this pull request to stack #9692 November 7, 2026 07:20
…vm-debug-symbols

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TxSYmva6vWHCraKgSEe6Cu

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dev_meeting Topic to be discussed at the next dev meeting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants