[Dwarf-discuss] Revisiting two-level line tables Dwarf proposal

Eric Christopher echristo@gmail.com
Thu Nov 5 22:31:54 GMT 2026


+ayermolovich@nvidia.com <ayermolovich@nvidia.com> as well as having a
standardized version of these sounds great and he's up for helping as well.
:)

And yes, thanks for following up on this Zequan! :)

-eric

On Sat, Oct 31, 2026 at 2:27 AM Suprateeka R Hegde via Dwarf-discuss <
dwarf-discuss@lists.dwarfstd.org> wrote:

> NVIDIA has been using this in production for years now. Thanks for some of
> these numbers.
>
> --
> Supra
>
> On 31-Oct-2026 04:40 am, Zequan Wu via Dwarf-discuss wrote:
>
> *External email: Use caution opening links or attachments*
> Hi Cary,
>
> I've prototyped two-level line tables in LLVM, following issue 140906.1
> [1] and the wiki page [2], and I'd like to share some data and ask whether
> the proposal could be revisited.
>
> Motivation: today, when the optimizer merges instructions from different
> source locations, the merged instruction gets line 0. Sample-based
> profiling (AutoFDO) then loses the samples for those source locations. What
> we want is for one instruction to map to several source locations, each
> with its own inline stack, and to get all of that from the line table
> alone. Two-level line tables provide exactly this. At the LLVM Dev Meeting,
> Eric Christopher mentioned that some concerns were raised when this
> proposal was discussed before. Cary, could you share what they were? I'd
> like to know whether they still apply, and what a revised proposal would
> need to address.
>
> To show what the prototype emits, here's a small demo:
> ```
> $ cat -n demo.c
>      1 void foo(int);
>      2
>      3 static inline void work(int c) {
>      4  if (c)
>      5    foo(1);
>      6  else
>      7    foo(1);
>      8 }
>      9
>     10 void caller(int c) {
>     11  work(c);
>     12 }
>
> $ llvm-dwarfdump --debug-line demo.o
> ...
> file_names[  0]:
>            name: "demo.c"
>       dir_index: 0
>    md5_checksum: 20924601724a32ef530cc082992f3600
> subprograms[  1]:
>            name: "caller"
>       decl_file: 0
>       decl_line: 10
> subprograms[  2]:
>            name: "work"
>       decl_file: 0
>       decl_line: 3
> actuals_table_offset: 0x00000017
>
> Logicals Table:
> Index  Line   Column File   Discriminator Context Subprogram         Flags
> ------ ------ ------ ------ ------------- ------- ------------------
> -------------
>      1     11      3      0             0         1 (caller)
>      2      5      5      0             0       1 2 (work)
> is_stmt prologue_end
>      3      7      5      0             0       1 2 (work)
> is_stmt prologue_end
>
> Actuals Table:
> Address            OpIndex Log    ISA Flags
> ------------------ ------- ------ --- -------------
> 0x0000000000000000       0      2   0
> 0x0000000000000000       0      3   0
> 0x000000000000000a       0      3   0 end_sequence
> ```
> Debug info size on clang itself
>
> I built upstream clang (llvm-project c1d907e151c9, X86 target only,
> RelWithDebInfo, -O2, DWARF 5) twice with the prototype compiler: once with
> plain DWARF 5 (merged locations get line 0) and once with
> -gdwarf-two-level-line-tables. Nothing else differs. After removing the
> debug sections, the two binaries are byte-identical. Sizes below are
> uncompressed.
>
> With -g: file size 1,686.1 MB -> 2,010.6 MB (+19.2%) .debug_line 89.0 MB
> -> 242.4 MB (2.72x) .debug_line_str 0.3 MB -> 160.5 MB all .debug_* 1,511.3
> MB -> 1,835.8 MB (+21.5%)
> With -gmlt: file size 500.0 MB -> 670.8 MB (+34.2%) .debug_line 88.0 MB ->
> 219.8 MB (2.50x) .debug_line_str 0.3 MB -> 35.4 MB all .debug_* 325.3 MB ->
> 496.1 MB (+52.5%)
> Inside the two-level .debug_line, the logicals tables take 83% of the
> bytes with both -g and -gmlt (201.4 MB and 182.3 MB), the actuals tables
> 10-11% (24.5 MB in both), and the headers, i.e. file tables plus subprogram
> lists, 6-7% (16.5 MB and 13.0 MB).
>
> Function names are a large part of the growth. To rebuild the inline stack
> from .debug_line/.debug_line_str alone, the subprogram list stores each
> function's name and declaration line. Those names repeat what is already in
> .symtab and .debug_info. Counting the strings in .debug_line_str plus the
> subprogram-list entries in the .debug_line headers, names account for about
> 54% of the total growth with -g (175 MB of 324 MB; about 929K unique
> mangled linkage names, 170 bytes on average). With -gmlt they account for
> about 26% (45 MB of 171 MB). At -gmlt, clang omits linkage names unless
> -fdebug-info-for-profiling is used (as AutoFDO builds do), so in this build
> the list stores short names.
>
> On the other hand, with -gmlt the line table alone now carries everything
> a profiler or symbolizer needs: all source locations of an instruction and
> their inline stacks. A build could therefore keep only .debug_line and
> .debug_line_str. In this experiment those two sections total 255.2 MB, less
> than the 325.3 MB of all debug sections in today's -gmlt build. In that
> setup the names in .debug_line_str are no longer duplicates of .debug_info.
> (Consumers would have to build their own address index, e.g. by parsing all
> of .debug_line and recording the address range of each sequence. Today they
> reach the line table for an address through the compile unit in .debug_info
> or .debug_aranges. My prototype doesn't do this yet.)
>
> The other DWARF sections also grow slightly (.debug_info +0.7%,
> .debug_loclists +3%, .debug_rnglists +7%). This is because the two-level
> build has 157,710 more DW_TAG_inlined_subroutine entries (8,745,925 ->
> 8,903,635, +1.8%). With line 0, a merged instruction moves to the common
> ancestor scope, so an inlined call whose instructions were all merged
> disappears from .debug_info. With two-level tables the instruction keeps
> its original scope, so those inlined calls are recorded.
>
> With zstd-compressed debug sections (-gz=zstd), the -g file grows from
> 653.3 MB to 758.3 MB (+16.1%): .debug_line 27.0 -> 106.1 MB and
> .debug_line_str 0.05 -> 20.9 MB.
>
> Since the original proposal is from 2014, I think the encoding could also
> be made more compact. In a sample of 39 compile units from the clang build
> above, 40% of logical rows are call-site rows, which exist only to be
> referenced as a Context and which no address refers to.
> DW_LNS_inlined_call, which sets Context and Subprogram, precedes 73% of
> logical rows and accounts for 37% of the logicals table's bytes. My
> prototype writes the call-site rows first and then the other rows in the
> order of their first use, which mostly follows addresses. Code from
> different inline frames is interleaved in the machine code, so the
> (Context, Subprogram) pair changes on most rows.
>
> The proposal doesn't require any particular order of logical rows, because
> actuals rows and Context refer to them by number. So, as an experiment, I
> grouped the logical rows of the same inline frame together, with no format
> change. This shrinks .debug_line by about 12%:
>
> -g: .debug_line 242.4 MB -> 213.1 MB (-12.1%) logicals 201.4 -> 152.9 MB,
> actuals 24.5 -> 43.8 MB -gmlt: .debug_line 219.8 MB -> 194.4 MB (-11.6%)
>
> The actuals tables grow because the row numbers they refer to no longer
> mostly increase by one. Compared with plain DWARF 5, .debug_line is then
> 2.40x with -g (file +17.5%) and 2.21x with -gmlt (file +29.1%).
>
> I'm happy to share the prototype, the measurements, and my scripts, and to
> help with a revised proposal.
>
> Thanks,
> Zequan
>
> [1] https://dwarfstd.org/issues/140906.1.html
> [2] https://wiki.dwarfstd.org/TwoLevelLineTables.md
>
>
> --
> Dwarf-discuss mailing list
> Dwarf-discuss@lists.dwarfstd.org
> https://lists.dwarfstd.org/mailman/listinfo/dwarf-discuss
>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.dwarfstd.org/pipermail/dwarf-discuss/attachments/20261105/6c2fe09e/attachment-0001.htm>


More information about the Dwarf-discuss mailing list