diff --git a/pkg/symbol/addr2line/dwarf.go b/pkg/symbol/addr2line/dwarf.go index d9e655a26c8..d97740a3c5c 100644 --- a/pkg/symbol/addr2line/dwarf.go +++ b/pkg/symbol/addr2line/dwarf.go @@ -36,31 +36,48 @@ type DwarfLiner struct { dbgFile elfutils.DebugInfoFile f *elf.File filename string + + // unmapDWARF releases the memory mapping backing debugData. It is nil + // when the DWARF sections were loaded into heap buffers instead. + unmapDWARF func() error } // DWARF creates a new DwarfLiner. func DWARF(logger log.Logger, filename string, f *elf.File, demangler *demangle.Demangler) (*DwarfLiner, error) { - debugData, err := f.DWARF() + debugData, unmapDWARF, err := elfutils.LoadDWARFData(f, filename) if err != nil { return nil, fmt.Errorf("failed to read DWARF data: %w", err) } dbgFile, err := elfutils.NewDebugInfoFile(debugData, demangler) if err != nil { + // Release the mapping if initialization fails half-way. + if unmapDWARF != nil { + _ = unmapDWARF() + } return nil, err } return &DwarfLiner{ - logger: log.With(logger, "liner", "dwarf"), - dbgFile: dbgFile, - debugData: debugData, - f: f, - filename: filename, + logger: log.With(logger, "liner", "dwarf"), + dbgFile: dbgFile, + debugData: debugData, + f: f, + filename: filename, + unmapDWARF: unmapDWARF, }, nil } func (dl *DwarfLiner) Close() error { - return dl.f.Close() + // Release the mmap-backed DWARF sections, if any, before closing the file. + var unmapErr error + if dl.unmapDWARF != nil { + unmapErr = dl.unmapDWARF() + } + if err := dl.f.Close(); err != nil { + return err + } + return unmapErr } func (dl *DwarfLiner) File() string { diff --git a/pkg/symbol/addr2line/dwarfdata_test.go b/pkg/symbol/addr2line/dwarfdata_test.go new file mode 100644 index 00000000000..5e75c0e199a --- /dev/null +++ b/pkg/symbol/addr2line/dwarfdata_test.go @@ -0,0 +1,152 @@ +// Copyright 2022-2026 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +package addr2line + +import ( + "context" + "debug/dwarf" + "debug/elf" + "runtime" + "testing" + + "github.com/go-kit/log" + "github.com/stretchr/testify/require" + + "github.com/parca-dev/parca/pkg/symbol/demangle" + "github.com/parca-dev/parca/pkg/symbol/elfutils" +) + +func openTestELFFixture(t testing.TB) (*elf.File, string) { + t.Helper() + filename := "testdata/basic-cpp-no-fp-with-debuginfo" + f, err := elf.Open(filename) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, f.Close()) }) + return f, filename +} + +// countEntries counts every entry a fresh DWARF reader yields. +func countEntries(t *testing.T, d *dwarf.Data) int { + t.Helper() + r := d.Reader() + n := 0 + for { + e, err := r.Next() + require.NoError(t, err) + if e == nil { + break + } + n++ + } + return n +} + +func unixOnly(t testing.TB) { + t.Helper() + switch runtime.GOOS { + case "windows", "plan9", "js": + t.Skipf("mmap-backed DWARF loading is not supported on %s", runtime.GOOS) + } +} + +func TestLoadDWARFDataMmap(t *testing.T) { + unixOnly(t) + + f, filename := openTestELFFixture(t) + + d, unmap, err := elfutils.LoadDWARFData(f, filename) + require.NoError(t, err) + require.NotNil(t, d) + // The fixture is an uncompressed ET_EXEC binary: the mmap path must be taken. + require.NotNil(t, unmap, "expected mmap-backed DWARF loading for uncompressed sections") + + // The mmap-backed data must describe the same debug info as the copying loader. + want, err := f.DWARF() + require.NoError(t, err) + require.Equal(t, countEntries(t, want), countEntries(t, d)) + require.Greater(t, countEntries(t, d), 0) + + require.NoError(t, unmap()) +} + +func TestLoadDWARFDataFallback(t *testing.T) { + f, _ := openTestELFFixture(t) + + // Without a filename there is nothing to mmap: fall back to the copying loader. + d, unmap, err := elfutils.LoadDWARFData(f, "") + require.NoError(t, err) + require.NotNil(t, d) + require.Nil(t, unmap, "expected no mmap cleanup when falling back") + + want, err := f.DWARF() + require.NoError(t, err) + require.Equal(t, countEntries(t, want), countEntries(t, d)) +} + +func TestDwarfLinerMmapClose(t *testing.T) { + unixOnly(t) + + logger := log.NewNopLogger() + demangler := demangle.NewDemangler("simple", true) + f, filename := openTestELFFixture(t) + + liner, err := DWARF(logger, filename, f, demangler) + require.NoError(t, err) + require.NotNil(t, liner.unmapDWARF, "expected mmap-backed DWARF loading") + + // Symbolization must work off the mapped sections. + lines, err := liner.PCToLines(context.Background(), 0x401125) + require.NoError(t, err) + require.NotEmpty(t, lines) + + // Close must release the mapping without error. + require.NoError(t, liner.Close()) +} + +func BenchmarkDWARFLoadStdlib(b *testing.B) { + f, _ := openTestELFFixture(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + d, err := f.DWARF() + if err != nil { + b.Fatal(err) + } + if d == nil { + b.Fatal("nil dwarf data") + } + } +} + +func BenchmarkDWARFLoadMmap(b *testing.B) { + unixOnly(b) + f, filename := openTestELFFixture(b) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + d, unmap, err := elfutils.LoadDWARFData(f, filename) + if err != nil { + b.Fatal(err) + } + if d == nil { + b.Fatal("nil dwarf data") + } + if unmap != nil { + if err := unmap(); err != nil { + b.Fatal(err) + } + } + } +} diff --git a/pkg/symbol/elfutils/dwarfdata.go b/pkg/symbol/elfutils/dwarfdata.go new file mode 100644 index 00000000000..70ef841d9fc --- /dev/null +++ b/pkg/symbol/elfutils/dwarfdata.go @@ -0,0 +1,177 @@ +// Copyright 2022-2026 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package elfutils + +import ( + "debug/dwarf" + "debug/elf" + "fmt" + "strings" +) + +// LoadDWARFData loads the DWARF debug info of f. +// +// Where possible, uncompressed DWARF sections are served as read-only +// memory-mapped views of the underlying file instead of being copied into Go +// heap buffers (see https://github.com/parca-dev/parca/issues/6404). The +// returned cleanup function releases the mapping and must be called once the +// dwarf.Data is no longer needed; it is nil when the mmap path was not taken. +// +// The previous loading behavior (debug/elf's File.DWARF, which copies section +// contents into heap buffers) is retained for compressed sections, for inputs +// whose DWARF sections require relocations to be applied, and on platforms +// without mmap support. +func LoadDWARFData(f *elf.File, filename string) (*dwarf.Data, func() error, error) { + if d, unmap, ok, err := mmapDWARFData(f, filename); err != nil || ok { + return d, unmap, err + } + + d, err := f.DWARF() + if err != nil { + return nil, nil, err + } + return d, nil, nil +} + +// dwarfSection is a DWARF debug section along with its index in the ELF +// section table and its suffix (e.g. "info" for ".debug_info"). +type dwarfSection struct { + index int + suffix string + sec *elf.Section +} + +// dwarfSections returns all sections of f that carry DWARF debug info. +func dwarfSections(f *elf.File) []dwarfSection { + var out []dwarfSection + for i, s := range f.Sections { + suffix := dwarfSuffix(s) + if suffix == "" { + continue + } + out = append(out, dwarfSection{index: i, suffix: suffix, sec: s}) + } + return out +} + +// mmapEligible reports whether every DWARF section of f can be served from a +// read-only memory mapping of the file. +func mmapEligible(f *elf.File, secs []dwarfSection) bool { + // debug/elf applies relocations to DWARF section bytes for non-ET_EXEC + // files. A read-only mapping cannot be modified in place, so retain the + // copying loader for inputs with relocations against DWARF sections. + relocated := map[int]bool{} + if f.Type != elf.ET_EXEC { + for _, r := range f.Sections { + if r.Type != elf.SHT_REL && r.Type != elf.SHT_RELA { + continue + } + relocated[int(r.Info)] = true + } + } + + for _, ds := range secs { + s := ds.sec + if s.Type != elf.SHT_PROGBITS { + return false + } + if s.Flags&elf.SHF_COMPRESSED != 0 || strings.HasPrefix(s.Name, ".zdebug_") { + // Section.Data transparently decompresses such sections; a + // mapping would expose the raw compressed bytes. + return false + } + if relocated[ds.index] { + return false + } + } + return true +} + +// mmapDWARFData builds dwarf.Data from read-only memory-mapped views of the +// file's DWARF sections. It reports ok=false when the mmap path is not +// applicable so the caller can fall back to the copying loader; a non-nil err +// means the input is corrupt and the error is propagated. +func mmapDWARFData(f *elf.File, filename string) (d *dwarf.Data, unmap func() error, ok bool, err error) { + secs := dwarfSections(f) + if len(secs) == 0 || filename == "" || !mmapEligible(f, secs) { + return nil, nil, false, nil + } + + m, unmap, err := mmapReadOnly(filename) + if err != nil { + return nil, nil, false, nil + } + // Release the mapping on any failure below (cleanup on initialization + // failure); the caller owns it once we report success. + success := false + defer func() { + if !success { + _ = unmap() + } + }() + + // Mirror debug/elf.File.DWARF: first the sections debug/dwarf started + // with, then DWARF4 .debug_types and DWARF5 sections. + dat := map[string][]byte{"abbrev": nil, "info": nil, "str": nil, "line": nil, "ranges": nil} + for _, ds := range secs { + if _, ok := dat[ds.suffix]; !ok { + continue + } + b, ok := sectionBytes(m, ds.sec) + if !ok { + // Section extends beyond the mapped file (e.g. truncated after + // open); let the copying loader surface the proper error. + return nil, nil, false, nil + } + dat[ds.suffix] = b + } + + d, err = dwarf.New(dat["abbrev"], nil, nil, dat["info"], dat["line"], nil, dat["ranges"], dat["str"]) + if err != nil { + return nil, nil, false, err + } + + for _, ds := range secs { + if _, ok := dat[ds.suffix]; ok { + // Already handled. + continue + } + b, ok := sectionBytes(m, ds.sec) + if !ok { + return nil, nil, false, nil + } + if ds.suffix == "types" { + if err := d.AddTypes(fmt.Sprintf("types-%d", ds.index), b); err != nil { + return nil, nil, false, err + } + } else if err := d.AddSection(".debug_"+ds.suffix, b); err != nil { + return nil, nil, false, err + } + } + + success = true + return d, unmap, true, nil +} + +// sectionBytes returns the contents of s as a view into the memory mapping m. +// It reports ok=false when the section lies outside the mapped file. +func sectionBytes(m []byte, s *elf.Section) (b []byte, ok bool) { + // Guard against overflow and out-of-bounds sections (e.g. a file that was + // truncated after it was opened). + if s.Size > uint64(len(m)) || s.Offset > uint64(len(m))-s.Size { + return nil, false + } + off := int(s.Offset) + return m[off : off+int(s.Size)], true +} diff --git a/pkg/symbol/elfutils/dwarfdata_other.go b/pkg/symbol/elfutils/dwarfdata_other.go new file mode 100644 index 00000000000..082ad54f4ba --- /dev/null +++ b/pkg/symbol/elfutils/dwarfdata_other.go @@ -0,0 +1,24 @@ +// Copyright 2022-2026 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build !unix + +package elfutils + +import "errors" + +// mmapReadOnly is unsupported on this platform; callers fall back to the +// copying DWARF loader. +func mmapReadOnly(path string) ([]byte, func() error, error) { + return nil, nil, errors.New("mmap-backed DWARF loading is not supported on this platform") +} diff --git a/pkg/symbol/elfutils/dwarfdata_unix.go b/pkg/symbol/elfutils/dwarfdata_unix.go new file mode 100644 index 00000000000..f5548b0037d --- /dev/null +++ b/pkg/symbol/elfutils/dwarfdata_unix.go @@ -0,0 +1,51 @@ +// Copyright 2022-2026 The Parca Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//go:build unix + +package elfutils + +import ( + "fmt" + "os" + "syscall" +) + +// mmapReadOnly memory-maps the whole file at path read-only and returns its +// contents along with a cleanup function releasing the mapping. The file can +// be closed right after the mapping is established. +func mmapReadOnly(path string) ([]byte, func() error, error) { + f, err := os.Open(path) + if err != nil { + return nil, nil, err + } + defer f.Close() + + fi, err := f.Stat() + if err != nil { + return nil, nil, err + } + size := fi.Size() + if size <= 0 { + return nil, nil, fmt.Errorf("cannot mmap empty file %q", path) + } + if int64(int(size)) != size { + return nil, nil, fmt.Errorf("file %q too large to mmap", path) + } + + b, err := syscall.Mmap(int(f.Fd()), 0, int(size), syscall.PROT_READ, syscall.MAP_SHARED) + if err != nil { + return nil, nil, fmt.Errorf("mmap %q: %w", path, err) + } + return b, func() error { return syscall.Munmap(b) }, nil +}