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

Suprateeka R Hegde srhegde@nvidia.com
Sat Oct 31 07:26:23 GMT 2026


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 
> <https://dwarfstd.org/issues/140906.1.html>
> [2] https://wiki.dwarfstd.org/TwoLevelLineTables.md 
> <https://wiki.dwarfstd.org/TwoLevelLineTables.md>
>
-------------- next part --------------
An HTML attachment was scrubbed...
URL: <https://lists.dwarfstd.org/pipermail/dwarf-discuss/attachments/20261031/de35975f/attachment.htm>


More information about the Dwarf-discuss mailing list