Skip to content

Commit 8da5e91

Browse files
authored
Use abseil for readable POSIX stack traces in debug builds (microsoft#28405)
## Description Replace glibc `backtrace()`/`backtrace_symbols()` with abseil's `absl::GetStackTrace()`/`absl::Symbolize()` for POSIX/Linux debug builds, and add automatic `addr2line` resolution for file paths and line numbers. The previous implementation produced raw addresses requiring manual `addr2line` translation. The new implementation produces demangled function names with source locations directly in exception messages, with zero new dependencies. ## Summary of Changes ### Stack Trace Implementation | File | Change | |------|--------| | `onnxruntime/core/platform/posix/stacktrace.cc` | Replace glibc `backtrace()`/`backtrace_symbols()` with `absl::GetStackTrace()`/`absl::Symbolize()`. Use `dladdr()` + `addr2line` to resolve source file and line number for each frame. | | `onnxruntime/core/session/environment.cc` | Add one-time `absl::InitializeSymbolizer(nullptr)` call via `std::call_once` in `Environment::Initialize()`. On Linux, `nullptr` works because abseil reads `/proc/self/exe`. | ### Before vs After **Before** (raw addresses requiring manual `addr2line`): ``` Stacktrace: /home/me/build/Debug/onnxruntime_test_all(+0x3f46cc) [0x559543faf6cc] /home/me/build/Debug/onnxruntime_test_all(+0x2bef04d) [0x559543faf6cc] ... ``` **After** (demangled function names + file:line): ``` Stacktrace: onnxruntime::OpKernelContext::Output() at .../core/framework/op_kernel.cc:45 onnxruntime::Add<>::Compute() at .../core/providers/cpu/math/element_wise_ops.cc:596 ... ``` Environment variable `ORT_ADDR2LINE` controls number of frames need to call addr2line to get file and location. The default value is 0, which avoids timeout in CI pipeline. In local debugging, you can set a proper value to assist debugging in Linux or WSL. ## Motivation and Context Follow-up on microsoft#26257, which was closed because abseil's backtrace/symbolize is already available as a dependency. This PR implements that suggestion with additional file:line resolution: - **No new dependency**: `absl::stacktrace` and `absl::symbolize` are already in `ABSEIL_LIBS` and linked to `onnxruntime_common`. `dladdr()` and `addr2line` are standard POSIX/Linux utilities. - **No CMake changes needed**: Everything is already wired up - **Debug-only**: Guarded by `#ifndef NDEBUG` — no performance impact in release builds - **Best-effort file:line**: Uses `dladdr()` to compute file offsets, then calls `addr2line` in batch (once per binary). Falls back gracefully to function-name-only output if `addr2line` is unavailable. - **Windows unchanged**: Windows already has superior stack traces via C++23 `<stacktrace>` - **Platform exclusions preserved**: Android, WebAssembly, AIX, and `_OPSCHEMA_LIB_` builds continue to return empty stack traces It might also resolve build issues of stacktrace on BSD, Alpine Linux and other musl libc-based distributions (See microsoft#28249, microsoft#24755, microsoft#28161, microsoft#27437) Note that C++23 has stack trace support, but compiler support for C++23 is not mature, so abseil seems to be the best choice for now. ### How it works 1. `absl::GetStackTrace()` captures raw frame addresses 2. `absl::Symbolize()` resolves each address to a demangled function name 3. `dladdr()` determines which binary each address belongs to and computes the file offset 4. `addr2line` is called in batch (one invocation per binary) to resolve file:line 5. Results are combined into a single readable string per frame ## Testing - Built and verified on Linux with CUDA EP in Debug mode - Ran `onnxruntime_test_all --gtest_filter="*BadModelInvalidDimParamUsage*"` — confirmed stack trace shows demangled function names with file paths and line numbers through the full call chain - Verified graceful fallback when addr2line cannot resolve a frame (shows function name + address only) - No CMake changes, so no risk of build system regressions on other platforms
1 parent 8fcb725 commit 8da5e91

2 files changed

Lines changed: 325 additions & 27 deletions

File tree

onnxruntime/core/platform/posix/stacktrace.cc

Lines changed: 309 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,47 +1,329 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4+
#include <vector>
5+
46
#include "core/common/common.h"
57

6-
#if !defined(__ANDROID__) && !defined(__wasm__) && !defined(_OPSCHEMA_LIB_) && !defined(_AIX)
7-
#include <execinfo.h>
8+
#if !defined(NDEBUG) && !defined(__ANDROID__) && !defined(__wasm__) && !defined(_OPSCHEMA_LIB_) && !defined(_AIX)
9+
#include <dlfcn.h>
10+
#include <fcntl.h>
11+
#include <spawn.h>
12+
#include <sys/wait.h>
13+
#include <unistd.h>
14+
#include <cerrno>
15+
#include <cinttypes>
16+
#include <climits>
17+
#include <cstdio>
18+
#include <cstdlib>
19+
#include <cstring>
20+
#include <optional>
21+
#include <sstream>
22+
#include <string>
23+
#include <algorithm>
24+
#include <unordered_map>
25+
#include "absl/debugging/stacktrace.h"
26+
#include "absl/debugging/symbolize.h"
27+
#include "core/platform/scoped_resource.h"
28+
29+
#ifdef __APPLE__
30+
#include <crt_externs.h>
31+
#define environ (*_NSGetEnviron())
32+
#else
33+
extern char** environ;
834
#endif
9-
#include <vector>
35+
36+
namespace {
37+
38+
// Traits for a POSIX file descriptor, used with onnxruntime::ScopedResource to
39+
// close it on scope exit unless released.
40+
struct FdTraits {
41+
using Handle = int;
42+
static Handle GetInvalidHandleValue() noexcept { return -1; }
43+
static void CleanUp(Handle fd) noexcept { close(fd); }
44+
};
45+
using ScopedFd = onnxruntime::ScopedResource<FdTraits>;
46+
47+
// Traits for a FILE* obtained from fdopen, used with onnxruntime::ScopedResource
48+
// to fclose it on scope exit.
49+
struct FileTraits {
50+
using Handle = FILE*;
51+
static Handle GetInvalidHandleValue() noexcept { return nullptr; }
52+
static void CleanUp(Handle fp) noexcept { fclose(fp); }
53+
};
54+
using ScopedFile = onnxruntime::ScopedResource<FileTraits>;
55+
56+
// RAII wrapper for posix_spawn_file_actions_t.
57+
class ScopedSpawnFileActions {
58+
public:
59+
ScopedSpawnFileActions() = default;
60+
ScopedSpawnFileActions(const ScopedSpawnFileActions&) = delete;
61+
ScopedSpawnFileActions& operator=(const ScopedSpawnFileActions&) = delete;
62+
~ScopedSpawnFileActions() {
63+
if (initialized_) {
64+
posix_spawn_file_actions_destroy(&actions_);
65+
}
66+
}
67+
68+
int Init() {
69+
const int rc = posix_spawn_file_actions_init(&actions_);
70+
initialized_ = (rc == 0);
71+
return rc;
72+
}
73+
posix_spawn_file_actions_t* get() { return &actions_; }
74+
75+
private:
76+
posix_spawn_file_actions_t actions_{};
77+
bool initialized_ = false;
78+
};
79+
80+
// RAII wrapper that reaps a child process on scope exit and exposes its status.
81+
class ScopedChild {
82+
public:
83+
explicit ScopedChild(pid_t pid) : pid_(pid) {}
84+
ScopedChild(const ScopedChild&) = delete;
85+
ScopedChild& operator=(const ScopedChild&) = delete;
86+
~ScopedChild() { Wait(); }
87+
88+
// Wait for the child and cache its exit status. Safe to call more than once.
89+
void Wait() {
90+
if (pid_ == -1) {
91+
return;
92+
}
93+
int status = 0;
94+
while (waitpid(pid_, &status, 0) == -1 && errno == EINTR) {
95+
}
96+
status_ = status;
97+
pid_ = -1;
98+
}
99+
100+
// Returns true only if the child exited normally with code 0.
101+
bool ExitedCleanly() {
102+
Wait();
103+
return status_.has_value() && WIFEXITED(*status_) && WEXITSTATUS(*status_) == 0;
104+
}
105+
106+
private:
107+
pid_t pid_;
108+
std::optional<int> status_;
109+
};
110+
111+
// ORT_ADDR2LINE controls optional source file:line resolution for stack frames.
112+
// It is read as a non-negative integer N:
113+
// - unset or 0 : disabled (default). Only symbol names from absl::Symbolize
114+
// are shown and no subprocess is spawned.
115+
// - N > 0 : resolve file:line for the top N frames by invoking the
116+
// external `addr2line` tool (part of binutils). This is opt-in
117+
// because spawning addr2line and parsing DWARF can be very slow
118+
// on large debug binaries, and addr2line may not be installed.
119+
inline int GetAddr2LineEnv() {
120+
const char* val = std::getenv("ORT_ADDR2LINE");
121+
if (val == nullptr) {
122+
return 0;
123+
}
124+
char* end = nullptr;
125+
long parsed = strtol(val, &end, 10);
126+
// Reject empty input and any value with trailing non-numeric characters
127+
// (e.g. "10foo"), so a malformed setting disables resolution rather than
128+
// silently using a partially parsed number.
129+
if (end == val || *end != '\0' || parsed < 0 || parsed > INT_MAX) {
130+
return 0;
131+
}
132+
return static_cast<int>(parsed);
133+
}
134+
135+
int GetAddr2LineCount() {
136+
static const int count = GetAddr2LineEnv();
137+
return count;
138+
}
139+
140+
bool CreateCloseOnExecPipe(int pipe_fds[2]) {
141+
if (pipe(pipe_fds) != 0) {
142+
return false;
143+
}
144+
145+
for (int i = 0; i < 2; ++i) {
146+
const int flags = fcntl(pipe_fds[i], F_GETFD);
147+
if (flags == -1 || fcntl(pipe_fds[i], F_SETFD, flags | FD_CLOEXEC) == -1) {
148+
close(pipe_fds[0]);
149+
close(pipe_fds[1]);
150+
return false;
151+
}
152+
}
153+
154+
return true;
155+
}
156+
157+
// Resolve file offsets to file:line using addr2line, grouped by binary.
158+
// Returns a map from original address to "file:line" string.
159+
std::unordered_map<void*, std::string> ResolveWithAddr2Line(
160+
void** addresses, int depth) {
161+
std::unordered_map<void*, std::string> result;
162+
163+
// Group addresses by their containing binary/shared-object.
164+
// Key: binary path, Value: vector of (address, file_offset)
165+
struct FrameInfo {
166+
void* addr;
167+
uintptr_t offset;
168+
};
169+
std::unordered_map<std::string, std::vector<FrameInfo>> groups;
170+
171+
for (int i = 0; i < depth; ++i) {
172+
Dl_info info;
173+
if (dladdr(addresses[i], &info) && info.dli_fname) {
174+
const uintptr_t addr = reinterpret_cast<uintptr_t>(addresses[i]);
175+
const uintptr_t base = reinterpret_cast<uintptr_t>(info.dli_fbase);
176+
// Skip frames whose address is at or below the module base. Subtracting
177+
// below would underflow uintptr_t and feed a garbage offset to addr2line.
178+
if (addr <= base) {
179+
continue;
180+
}
181+
// Each captured address is a return address pointing just past the call
182+
// instruction. Subtract 1 so addr2line resolves the call site itself
183+
// rather than the following statement (which may be a different source
184+
// line or even the next function). The map is still keyed by the original
185+
// address so it matches the absl::Symbolize lookup in GetStackTrace.
186+
uintptr_t offset = addr - 1 - base;
187+
groups[info.dli_fname].push_back({addresses[i], offset});
188+
}
189+
}
190+
191+
// Call addr2line once per binary with all offsets in batch.
192+
// Use posix_spawnp + pipe instead of popen to avoid shell injection risks
193+
// from untrusted binary paths (e.g., paths with spaces or special chars).
194+
for (const auto& [binary, frames] : groups) {
195+
// Build argv: {"addr2line", "-e", binary, "0xoffset1", "0xoffset2", ..., nullptr}
196+
std::vector<std::string> offset_strs;
197+
offset_strs.reserve(frames.size());
198+
for (const auto& frame : frames) {
199+
char buf[32];
200+
snprintf(buf, sizeof(buf), "0x%" PRIxPTR, frame.offset);
201+
offset_strs.emplace_back(buf);
202+
}
203+
204+
// Build raw argv array for addr2line. All pointers reference stable strings above.
205+
std::vector<char*> argv;
206+
argv.reserve(3 + frames.size() + 1);
207+
argv.push_back(const_cast<char*>("addr2line"));
208+
argv.push_back(const_cast<char*>("-e"));
209+
argv.push_back(const_cast<char*>(binary.c_str()));
210+
for (auto& s : offset_strs) {
211+
argv.push_back(const_cast<char*>(s.c_str()));
212+
}
213+
argv.push_back(nullptr);
214+
215+
// Create a pipe for reading addr2line's stdout. RAII wrappers below own the
216+
// descriptors, spawn file actions, FILE*, and child process so each early
217+
// 'continue' releases everything without manual cleanup calls.
218+
int pipe_fds[2];
219+
if (!CreateCloseOnExecPipe(pipe_fds)) continue;
220+
ScopedFd read_fd(pipe_fds[0]);
221+
ScopedFd write_fd(pipe_fds[1]);
222+
223+
ScopedSpawnFileActions actions;
224+
if (actions.Init() != 0) continue;
225+
226+
if (posix_spawn_file_actions_adddup2(actions.get(), write_fd.Get(), STDOUT_FILENO) != 0 ||
227+
posix_spawn_file_actions_addclose(actions.get(), read_fd.Get()) != 0 ||
228+
// Redirect stderr to /dev/null to suppress error messages.
229+
posix_spawn_file_actions_addopen(actions.get(), STDERR_FILENO, "/dev/null", O_WRONLY, 0) != 0) {
230+
continue;
231+
}
232+
233+
pid_t pid = -1;
234+
const int spawn_ret = posix_spawnp(&pid, "addr2line", actions.get(), nullptr, argv.data(), environ);
235+
236+
// Close the write end in the parent so we observe EOF once addr2line exits.
237+
write_fd.Reset();
238+
239+
// If addr2line cannot be executed (e.g. not installed), glibc posix_spawnp
240+
// reports the failure here with a non-zero return (ENOENT) and no child is
241+
// created. Some implementations instead spawn a child that exits 127; that
242+
// case is caught by the ExitedCleanly() check below, which discards output.
243+
if (spawn_ret != 0) continue;
244+
ScopedChild child(pid);
245+
246+
FILE* raw_fp = fdopen(read_fd.Get(), "r");
247+
if (raw_fp == nullptr) continue;
248+
read_fd.Release(); // fp now owns the descriptor.
249+
ScopedFile fp(raw_fp);
250+
251+
// Read into a local map and only commit it if addr2line exited cleanly, so a
252+
// failed run (e.g., addr2line missing, or an unreadable binary) contributes
253+
// no partial or garbage results.
254+
std::unordered_map<void*, std::string> binary_result;
255+
256+
// We intentionally do not use -f/-i so output remains 1 line per address.
257+
// Adding those flags would break the 1:1 parsing below.
258+
char line_buf[1024];
259+
size_t frame_idx = 0;
260+
while (fgets(line_buf, sizeof(line_buf), fp.Get()) && frame_idx < frames.size()) {
261+
// Remove trailing newline
262+
size_t len = strlen(line_buf);
263+
if (len > 0 && line_buf[len - 1] == '\n') line_buf[len - 1] = '\0';
264+
265+
std::string resolved(line_buf);
266+
// addr2line returns "??:0" or "??:?" for unknown frames, so skip those.
267+
if (resolved != "??:0" && resolved != "??:?" && resolved.substr(0, 2) != "??") {
268+
binary_result[frames[frame_idx].addr] = std::move(resolved);
269+
}
270+
++frame_idx;
271+
}
272+
273+
// Reap the child and only trust the output if it exited with status 0.
274+
if (child.ExitedCleanly()) {
275+
for (auto& kv : binary_result) {
276+
result.insert(std::move(kv));
277+
}
278+
}
279+
}
280+
281+
return result;
282+
}
283+
284+
} // namespace
285+
#endif // !defined(NDEBUG) && !defined(__ANDROID__) && !defined(__wasm__) && !defined(_OPSCHEMA_LIB_) && !defined(_AIX)
10286

11287
namespace onnxruntime {
12288

13289
std::vector<std::string> GetStackTrace() {
14290
std::vector<std::string> stack;
15291

16-
#if !defined(NDEBUG) && !defined(__ANDROID__) && !defined(__wasm__) && !defined(_OPSCHEMA_LIB_)
17-
constexpr int kCallstackLimit = 64; // Maximum depth of callstack
18-
19-
void* array[kCallstackLimit];
20-
char** strings = nullptr;
292+
#if !defined(NDEBUG) && !defined(__ANDROID__) && !defined(__wasm__) && !defined(_OPSCHEMA_LIB_) && !defined(_AIX)
293+
constexpr int kCallstackLimit = 64;
294+
void* addresses[kCallstackLimit];
21295

22-
int size = backtrace(array, kCallstackLimit);
23-
stack.reserve(size);
24-
strings = backtrace_symbols(array, size);
296+
// skip_count=2 hides GetStackTrace and its caller (ORT_ENFORCE macro internals)
297+
int depth = absl::GetStackTrace(addresses, kCallstackLimit, /*skip_count=*/2);
298+
stack.reserve(depth);
25299

26-
// NOTE: To get meaningful info from the output, addr2line (or atos on osx) would need to be used.
27-
// See https://gist.github.com/jvranish/4441299 for an example.
28-
//
29-
// To manually translate the output, use the value in the '()' after the executable name with addr2line
30-
// e.g.
31-
// Stacktrace:
32-
// /home/me/src/github/onnxruntime/build/Linux/Debug/onnxruntime_test_all(+0x3f46cc) [0x559543faf6cc]
33-
//
34-
// >addr2line -f -C -e /home/me/src/github/onnxruntime/build/Linux/Debug/onnxruntime_test_all +0x3f46cc
35-
36-
// hide GetStackTrace so the output starts with the 'real' location
37-
constexpr int start_frame = 1;
38-
for (int i = start_frame; i < size; i++) {
39-
stack.push_back(strings[i]);
300+
// Resolve file:line via addr2line only when explicitly opted in via ORT_ADDR2LINE=*,
301+
// since it spawns external processes and can be very slow on large debug binaries.
302+
std::unordered_map<void*, std::string> resolved;
303+
int count = GetAddr2LineCount();
304+
if (count > 0) {
305+
// Only resolve the top N frames to limit addr2line overhead on large binaries.
306+
resolved = ResolveWithAddr2Line(addresses, std::min(depth, count));
40307
}
41308

42-
free(strings);
309+
for (int i = 0; i < depth; ++i) {
310+
std::ostringstream oss;
311+
char symbol[1024];
312+
if (absl::Symbolize(addresses[i], symbol, sizeof(symbol))) {
313+
oss << symbol;
314+
} else {
315+
oss << "[unknown]";
316+
}
43317

44-
#endif
318+
auto it = resolved.find(addresses[i]);
319+
if (it != resolved.end()) {
320+
oss << " at " << it->second;
321+
} else {
322+
oss << " [" << addresses[i] << "]";
323+
}
324+
stack.push_back(oss.str());
325+
}
326+
#endif // !defined(NDEBUG) && !defined(__ANDROID__) && !defined(__wasm__) && !defined(_OPSCHEMA_LIB_) && !defined(_AIX)
45327

46328
return stack;
47329
}

onnxruntime/core/session/environment.cc

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
#include "orttraining/core/optimizer/graph_transformer_registry.h"
6161
#endif
6262

63+
#if !defined(NDEBUG) && defined(__linux__) && !defined(__ANDROID__)
64+
#include "absl/debugging/symbolize.h"
65+
#endif
66+
6367
#if defined(USE_CUDA) || defined(USE_CUDA_PROVIDER_INTERFACE)
6468
#include "core/providers/cuda/cuda_provider_factory.h"
6569
#include "core/providers/cuda/cuda_execution_provider_info.h"
@@ -69,6 +73,7 @@ using namespace ::onnxruntime::common;
6973
using namespace ONNX_NAMESPACE;
7074

7175
std::once_flag schemaRegistrationOnceFlag;
76+
std::once_flag symbolizerInitOnceFlag;
7277
#if defined(USE_CUDA) || defined(USE_CUDA_PROVIDER_INTERFACE)
7378
ProviderInfo_CUDA& GetProviderInfo_CUDA();
7479
#endif // defined(USE_CUDA) || defined(USE_CUDA_PROVIDER_INTERFACE)
@@ -268,6 +273,17 @@ Status Environment::Initialize(std::unique_ptr<logging::LoggingManager> logging_
268273
ORT_RETURN_IF_ERROR(SetGlobalThreadingOptions(*tp_options));
269274
}
270275

276+
// Initialize abseil symbolizer for readable stack traces in debug builds.
277+
// Restricted to Linux: InitializeSymbolizer(nullptr) relies on /proc/self/exe
278+
// to locate the executable, which is Linux-specific. Other platforms either
279+
// use a different mechanism (Windows: C++23 <stacktrace>) or would need a real
280+
// argv[0] path plumbed through, which is out of scope for this debug helper.
281+
#if !defined(NDEBUG) && defined(__linux__) && !defined(__ANDROID__)
282+
std::call_once(symbolizerInitOnceFlag, []() {
283+
absl::InitializeSymbolizer(nullptr);
284+
});
285+
#endif
286+
271287
ORT_TRY {
272288
#if !defined(ORT_MINIMAL_BUILD)
273289
// Register Microsoft domain with min/max op_set version as 1/1.

0 commit comments

Comments
 (0)