Skip to content

Add a mechanism to GC when building .dwps.#50

Merged
davidtwco merged 24 commits into
rust-lang:mainfrom
khuey:main
Jun 30, 2026
Merged

Add a mechanism to GC when building .dwps.#50
davidtwco merged 24 commits into
rust-lang:mainfrom
khuey:main

Conversation

@khuey

@khuey khuey commented May 30, 2026

Copy link
Copy Markdown
Contributor

Disclosure: this PR was written with the assistance of Claude. The following PR description was authored solely by me. The code changes have been either written by or reviewed by me. The tests have been reviewed by me to the extent practicable, and have been verified to fail without this PR where appropriate.

Background

It is well known that rustc relies on the linker to remove dead code via the equivalent of -ffunction-sections and --gc-sections. This approach works well for code, but while each function gets its own section for code, that is not true for DWARF (the resulting blow up in sizes if each function had to emit DWARF for all of its types, inlines, etc would surely be severe), and DWARF for dead code is retained. There have been multiple failed efforts to have lld GC the DWARF. To date, this issue remains unsolved and complaints about the size of Rust debuginfo remain common.

Split DWARF offers an opportunity to solve this at the DWARF package file generation stage. To produce a .dwp thorin has to touch all of the DWARF anyways (though it's currently largely just passed through). We are not bound by lld's legacy understanding of what --gc-sections means (where, based on the failure of the first attempt to do this in lld, its expected that DWARF for dead code will be passed through in some situations). Split DWARF (and DWARF 5 in general) restructures DWARF in ways that make garbage collecting DWARF more feasible (the introduction of .debug_addr, no cross-CU references in .debug_info, well defined offset tables for figuring out what is used in the supporting sections like .debug_loclists.dwo, .debug_rnglists.dwo, etc). And .dwp generation is downstream of linking the binary, so the linker has already identified all the dead code and tombstoned the appropriate .debug_addr table entries for us.

Mechanics

This PR introduces API surface for garbage collecting executables and input objects on addition to a DWARF package. Garbage collection only works if we can examine the .debug_addr tables, and since .debug_addr tables live in the executable, we can only GC input objects that belong to an executable. We also need to prescan the executable to find the relevant .debug_addr data before processing individual input objects. thorin-dwp-bin gets a --gc flag that drives the new API, and you can see there that the consumer changes are simple.

Conceptually GCing .debug_info.dwo is relatively straightforward. We identify roots based on subprograms and variables with non-tombstoned addresses, mark, and sweep. We always patch the .debug_locs.dwo/.debug_loclists.dwo sections to adjust the offsets of any references back into .debug_info.dwo. If circumstances permit, we attempt to shrink the .debug_loclists.dwo/.debug_rnglists.dwo/.debug_str_offsets.dwo sections by removing now-unused offset table entries and any corresponding data. We do not attempt the same for the DWARF 4 extension .debug_locs.dwo section. We do not attempt to modify the executable to shrink the .debug_addr table or any other data that lives there (e.g. the DWARF 4 .debug_ranges).

Results

The results here are highly workload dependent. On librustc_driver.so, for example, the size reduction is minimal.

However, on a large closed-source Rust project of mine that makes extensive use of third-party crates for things like interacting with AWS, the reduction is dramatic. --gc reduces the total size of the .dwp by 65% (from 1.35GB to just under 0.5GB) including a ~60% reduction in .debug_info, a ~75% reduction in .debug_rnglists, a ~83% reduction in .debug_loclists, a ~45% reduction in .debug_str_offsets, and a ~70% reduction in .debug_str.

I think that's compelling enough to merge.

Correctness Testing

There are a variety of unit tests included in this PR. It's difficult to measure programmatically whether useful debug info was lost/corrupted in real world applications but I have tested this on my project and not noticed any degradation in quality. I've also examined the diff in the .debug_str sections when --gc is used and the strings that are newly dropped generally comport with my expectations (e.g. for the aws-sdk-* crates we pull in I see lots of newly dropped strings corresponding to API calls we don't use).

To-dos before merging this PR.

  1. CI is failing. I believe we need to upgrade the version of LLVM CI uses, but that causes other tests to start failing. If we're happy to merge this I will make a separate PR updating the LLVM version and tests that we can merge first.
  2. Figure out the memory allocation. thorin has a Session trait that allows handing allocation off to the caller. The new gc module I've added does not use that internally, it only uses the trait to allocate once we have a final result. Is that acceptable?
  3. Cut a new gimli release and update this PR to use it. This PR currently points at the latest git rev to pick up a few changes I made.

To-dos either before or after merging this PR

  1. Some of the code that writes out DWARF probably belongs better in gimli. Some of it even exists in gimli, it is just not accessible in a way that's useful (e.g. it would be useful to write one element to an offset in a buffer rather than encode an entire section, but gimli only exposes the latter).

To-dos after merging this PR.

  1. Cut a release of thorin and update rustc to use it.
  2. Add an unstable mechanism to rustc to opt into this feature (e.g. a "packed-gc" split-debuginfo type, or a -Z flag).
  3. Get wider testing.
  4. Eventually stabilize the rustc mechanism, if and when appropriate.

@bjorn3

bjorn3 commented May 30, 2026

Copy link
Copy Markdown
Member

Maybe split out the added tests into a new commit and put the gimli + hashbrown updates as first commit? Trying to see if you can split out the GC implementation into a couple of commits (eg first adapt the writer impls, but ask it to preserve all items and in a later commit add the actual GC algorithm) may also be useful. I doubt anyone would be able to accurately review a single ~3000 line commit (excl tests).

Also just to check: Does the test harness automatically pickup on those added test files?

@khuey

khuey commented May 30, 2026

Copy link
Copy Markdown
Contributor Author

Yes, the tests are picked up automatically, that's why CI is failing (the tests use DW_FORM_addrx3 and the llvm-dwarfdump used on CI is apparently too old to recognize it).

I don't think splitting out the tests or the dependency changes into one or two separate commits helps to review this. Those are easy enough to distinguish simply by what file they're in. If I were to split this into smaller commits I would split out the rewriting of the supporting sections (.debug_loclists.dwo, .debug_rnglists.dwo, and .debug_str_offsets.dwo) first and then go from there.

@khuey khuey force-pushed the main branch 2 times, most recently from 0591da5 to 89477ca Compare May 31, 2026 14:00
@khuey

khuey commented Jun 1, 2026

Copy link
Copy Markdown
Contributor Author

I made a few minor changes to clean up error handling and use data structures more efficiently but I'm done now.

I assume @davidtwco would be the one to review this. Happy to discuss splitting up the patch with him if it's too big to review.

Comment thread thorin/src/gc.rs
Comment thread thorin/src/gc.rs Outdated
Comment thread thorin/src/gc.rs Outdated
Comment thread thorin/src/gc.rs Outdated
khuey added 3 commits June 4, 2026 21:50
…e DIEs in the tree, and if

there is no longer an eligible sibling, to point it at the correct DW_TAG_null.
…oned.

gdb and lldb both treat an empty rangelist and a rangelist where all the entries
are tombstoned identically.
@khuey

khuey commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

I had Fable 5 review this and it raised the following issues that need to be fixed and/or are worth considering:

  1. If thorin is used to produce a single .dwp that spans multiple executables, a .dwo could appear multiple times with different sets of tombstoned/non-tombstoned .debug_addr entries. put_data_for_dwo() lets the last one win rather than trying to merge anything.
  2. If there is a type unit, maybe_gc will return early in the second branch without ever appending debug_rnglists/debug_loc/debug_loclists/debug_str_offsets.
  3. The .debug_types.dwo arm doesn't set have_type_units, so DWARF 4 type units won't disable .debug_str_offsets compaction like they should.
  4. rewrite_loclists relies on the presence of the offsets table which apparently is actually optional, so we could end up leaving stale offsets back into .debug_info in .debug_loclists.
  5. The DataHolder that I introduced to allow the caller to choose Rc/Arc is pointless because the guts of the arenas aren't even potentially threadsafe anyways.

khuey added 14 commits June 12, 2026 07:31
Using a generic doesn't buy us anything here because the rest of the API can't be Send/Sync anyways.
referenced_rnglists/referenced_loclists can still be something.
Avoid pruning, which would catch any orphaned list entries and
shift indices incorrectly.

Apply the same rewritten.as_ref().and(...) guard already used for
str_offsets to the rnglists, loclists, and debug_loc paths.
Split it into functions, an outer emit_gc_sections() which always
appends the sections regardless of whether the GC ran and an inner
maybe_gc() that can safely return early.

Add a test with a .dwo whose dwo_id doesn't match any executable to
verify its str_offsets section is preserved in the output .dwp.
When a DWARF5 .debug_loclists.dwo section has offset_entry_count == 0
(spec-legal when DW_FORM_sec_offset is used instead of DW_FORM_loclistx),
rewrite_loclists' offset-table loop never executed, so DW_OP_call4
operands kept stale pre-GC DIE offsets. Flat-scan the entry data after
the header through patch_loclist_data, matching how patch_debug_loc
handles DWARF4.

@davidtwco davidtwco left a comment

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've had a high-level read through this and it makes sense to me. I'm open to merging it. I think I'd like it to be behind a Cargo feature flag or some similar mechanism to indicate that it's something we're still kicking the tyres on - it's a big change, and while everything here looks reasonable to me, I'm not 100% confident in it, it's hard to take in all the details from just reading the code a lot. We could still expose it through an unstable flag in rustc to make it easier to try out on bigger projects and get more confidence in it.

@khuey

khuey commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

That sounds reasonable. My biggest concern with this was the memory allocation behavior. There's a bunch of transient stuff in gc.rs that's not using the Session trait for allocation. You're ok with that?

@dwblaikie

Copy link
Copy Markdown

(super glad to see work in this direction) - the reason I hadn't looked into this on the LLVM side was I had assumed the compute and memory use was going to be too high for my situation - any sense of how much this increases the memory usage and runtime? (maybe it manages to reduce it if it saves so much work)

& may be of interest to compare/contrast performance and the resulting DWARF with llvm-dsymutil/dwarfutil and maybe llvm bolt, which recently got a DWARF-aware linking (implemented separately/differently from the dsymutil implementation, for better and worse)

@khuey

khuey commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

I haven't measured memory usage, but the runtime increase on the two cases I've tested is 10x (in the case where we shrink things substantially on the closed source project) and 25x (for rustc_driver.so, where the savings are minimal). That difference makes some amount of sense, you'd expect the runtime to be heavily influenced by how much of the DWARF tree is live. I haven't yet done any profiling to see if we could go faster, and this is all single threaded but in principle each .dwo could be processed independently in parallel.

@khuey

khuey commented Jun 25, 2026

Copy link
Copy Markdown
Contributor Author

Alright, the cfg feature is added, CI is green, and this depends on a released version of gimli now. As long as you're fine with the memory management stuff I mentioned earlier I think this is ready to go.

@davidtwco

Copy link
Copy Markdown
Member

As long as you're fine with the memory management stuff I mentioned earlier I think this is ready to go.

I'll be honest, I don't recall why I abstracted over the memory management like that. I'd probably prefer you leveraged it too for this, but that can happen as a follow-up given this is behind a feature flag.

@davidtwco davidtwco merged commit 66b9bff into rust-lang:main Jun 30, 2026
2 checks passed
@davidtwco

Copy link
Copy Markdown
Member

Filed #52 (comment) so we follow-up on that

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants