Component: tvm-ffi — src/ffi/backtrace_win.cc
Affected version: apache-tvm-ffi 0.1.13.post3 (confirmed in the published
sdist and wheel), also present in older trees
Platform: Windows x64, MSVC
Severity: a C++ exception raised while another thread is also reporting one
kills the process, and the original error message is never printed
Summary
The crash originates in unsynchronized access to shared DbgHelp symbol-handler
state. Repeated SymInitialize/SymCleanup around every backtrace and eager
symbol loading increase the amount of DbgHelp work performed on that path. In
addition, the return value of SymInitialize is ignored, so an initialization
failure can leave subsequent symbol-handler calls operating without a
successfully established session.
The practical effect is worse than a crash in the error path. The original
exception is never printed, because the process dies while the library is
formatting the diagnostic for it. Every failure inside a multi-threaded TVM pass
therefore looks like a silent process death with no message at all.
That is how it turned up here. MetaSchedule tuning on Windows died at task
initialization with exit code 127, no Python traceback and no log line. It took
a native stack under cdb to see that the visible crash was in the reporter,
not in the code being reported on:
tvm_compiler!tvm::s_tir::SampleComputeLocation+0x5a
tvm_compiler!tvm::s_tir::ComputeInline+0x35b
tvm_ffi!TVMFFIBacktrace+0x257
dbghelp!SymInitialize -> dbghelp!LoadModule <- access violation
The C++ exception was raised on thread 5bec; the access violation happened on
thread 6c34.
What is wrong
src/ffi/backtrace_win.cc, lines 53–58 and 141:
HANDLE process = GetCurrentProcess();
HANDLE thread = GetCurrentThread();
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME);
SymInitialize(process, NULL, TRUE); // line 58
...
SymCleanup(process); // line 141
One correctness bug:
- No serialization. The DbgHelp documentation states that its functions are
single-threaded and that calling them from more than one thread leads to
unexpected behaviour or memory corruption; the caller is required to
serialize them. There is no lock here. This applies to the whole body —
StackWalk64, SymFromAddr, SymFunctionTableAccess64 and
SymGetModuleBase64 all read the same process-wide session, as do the
SymInitialize / SymCleanup pair discussed below.
One unchecked result:
SymInitialize's return value is ignored. If it fails, every later call
in the process can go on working against a session that was never
established, and nothing says so. This is an error-handling defect found by
reading; it is not claimed to be what happened in the crashes measured here.
Two things that make (1) worse without being separate bugs of their own:
-
SymInitialize / SymCleanup per call. Both are per-process, not
per-call, so one thread can tear the symbol tables down while another walks
them. That is a striking way for (1) to fail, but it is still (1) failing:
under a lock that covers the whole body, this lifecycle is merely expensive.
It is deliberately not listed as a second independent correctness bug —
see the ablation below.
-
fInvadeProcess = TRUE on every call. Enumerating every module and
loading symbols for all of them is legitimate on its own. Doing it per call,
in a process where tvm_compiler.dll alone is 168 MB, makes each call slow
and allocation-heavy, which increases the amount of DbgHelp work done on the
faulting path.
Linux is unaffected: backtrace.cc uses libbacktrace, which is designed for
concurrent use and has no global init/cleanup pair.
Minimal reproducer
No TVM, no Python — it loads tvm_ffi.dll and calls the one export from
several threads. It reports the work done, not only that it survived, so a
"fix" that produced empty backtraces could not pass it silently.
#include <windows.h>
#include <cstdio>
#include <cstdlib>
#include <thread>
#include <vector>
#include <atomic>
struct TVMFFIByteArray { const char* data; size_t size; };
typedef const TVMFFIByteArray* (*Backtrace)(const char*, int, const char*, int);
static Backtrace g_backtrace = nullptr;
static std::atomic<long long> g_calls{0};
static std::atomic<long long> g_bytes{0};
static void worker(int calls) {
for (int i = 0; i < calls; ++i) {
const TVMFFIByteArray* a = g_backtrace(__FILE__, __LINE__, "worker", 0);
if (a != nullptr) g_bytes += (long long)a->size;
g_calls += 1;
}
}
int main(int argc, char** argv) {
if (argc < 2) { std::fprintf(stderr, "usage: repro <tvm_ffi.dll> [threads] [calls]\n"); return 2; }
int threads = argc > 2 ? std::atoi(argv[2]) : 8;
int calls = argc > 3 ? std::atoi(argv[3]) : 200;
HMODULE h = LoadLibraryA(argv[1]);
if (h == nullptr) { std::fprintf(stderr, "LoadLibrary failed: %lu\n", GetLastError()); return 2; }
g_backtrace = (Backtrace)GetProcAddress(h, "TVMFFIBacktrace");
if (g_backtrace == nullptr) { std::fprintf(stderr, "no TVMFFIBacktrace export\n"); return 2; }
std::vector<std::thread> pool;
for (int i = 0; i < threads; ++i) pool.emplace_back(worker, calls);
for (auto& t : pool) t.join();
std::printf("SURVIVED: %lld calls, %lld bytes of backtrace\n",
g_calls.load(), g_bytes.load());
return 0;
}
cl /nologo /EHsc /O2 /std:c++17 repro.cpp /Fe:repro.exe
repro.exe path\to\tvm_ffi.dll 8 200
It reproduces in about 300 ms.
Measurements
Windows 11 x64, i5-12500 (12 logical cores), MSVC 14.51,
apache-tvm-ffi 0.1.13.post3 rebuilt from the published sdist with no change
other than the patch below. Each run is an independent process.
| build |
load |
runs |
crashed |
| as published |
8 threads x 200 calls |
100 |
100 |
| as published |
8 threads x 200 calls |
20 |
19 |
| as published |
16 threads x 2000 calls |
3 |
3 |
| patched |
8 threads x 200 calls |
300 |
0 |
| patched |
8 threads x 200 calls |
20 |
0 |
| patched |
16 threads x 2000 calls |
5 |
0 |
| patched (final) |
8 threads x 200 calls |
40 |
0 |
Totals: 122 of 123 as published, 0 of 365 patched. Exit codes on the
unpatched build were 127, 139, 0xC0000005, 0xC0000374 and 0xC0000409.
The patched runs report their work: 32000 calls and 1248000 bytes of backtrace
per 16x2000 run, identical across runs.
Memory. Private bytes after 1, 100 and 1000 calls are identical
(27 328 512); after 10 000 calls, 27 332 608 — a single page. Not calling
SymCleanup does not leak per backtrace.
Backtrace content. Checked on a purpose-built chain of ten noinline
functions compiled with symbols, so that there are real named frames to resolve
— a short trace would have proved nothing. Both builds return byte-identical
text, 692 bytes, twelve frames, every one of them named with its file and line:
File "chain.cpp", line 29, in probe_10
File "chain.cpp", line 32, in probe_9
...
File "chain.cpp", line 40, in probe_1
File "chain.cpp", line 49, in main
File "exe_common.inl", line 288, in __scrt_common_main_seh
SYMOPT_DEFERRED_LOADS does not degrade symbolization: same frame count, same
names, same order, no bare addresses.
The degraded path was exercised, not merely written. The patch reports why
symbols are unavailable rather than silently returning a short trace, and that
branch is reachable on purpose: DbgHelp keeps one session per process handle, so
a test program that calls SymInitialize first makes the library's own call
fail. Same program, two builds:
patched File "failinit.cpp", line 32, in main
File "<dbghelp>", line 0, in symbols unavailable:
SymInitialize failed, GetLastError=87
unpatched File "failinit.cpp", line 32, in main
87 is ERROR_INVALID_PARAMETER - the session already exists. The unpatched build
returns the short trace with nothing to explain it.
At the level of the original symptom. MetaSchedule tuning of a small
convolutional model died at 80 s in every attempt before the patch. After it,
the same run passes that point and keeps going, and the log now contains
Build errors: N sample(s) — those are the C++ exceptions that previously
killed the process while their diagnostic was being formatted. They are now
reported and the run continues.
Which part of the fix is actually required
The patch below changes three things at once — serialization, the lifetime of
SymInitialize/SymCleanup, and deferred symbol loading. Measuring only the
whole patch would show that this set of changes cures the crash, not that each
part is causally necessary. Three builds were made from the unmodified file,
each adding one thing, and measured with the same reproducer:
A lock only mutex around the body; init/cleanup left per call
B lifetime only call_once init, no SymCleanup; NO mutex
C lock + lifetime both, without SYMOPT_DEFERRED_LOADS
Each was run in independent processes, judged by the real exit code, and every
survivor had to report the full call count:
variant load runs crashed hollow
A 8 x 50 10 0 0
B 8 x 50 10 0 0
C 8 x 50 10 0 0
B 16 x 2000 10 0 0
C 16 x 2000 10 0 0
unpatched 16 x 2000 5 5 0 <- control, in one second
The first three rows were produced by an independent reviewer on this machine,
running the same script without being shown the expected outcome; the last three
are mine. The control row is what makes the rest readable: at 16 x 2000 the
unpatched build dies five times out of five in about a second, so a zero at that
load is not the probe being weak.
What this shows. Serializing everything (A) is sufficient: it completed the
full workload in every process. That is the minimal change worth proposing.
What it does not show. B - dropping the per-call SymInitialize/SymCleanup
without any lock - also produced no failure, in 320000 completed calls at the
load that kills the unpatched build instantly. That is strong evidence that the
per-call lifecycle was the dominant observed mechanism. It is not evidence
that B is correct: the concurrent StackWalk64, SymFromAddr and
SymGetLineFromAddr64 calls still violate the documented requirement. B removes
the most frequent collision and leaves the rest rare.
Crash counting cannot separate "the race is gone" from "the race became rare",
and no such claim is made here: any finite failure-free series is consistent
with both. Said plainly rather than papered over.
So, if it is worth saying outright: the lifetime change alone should not be
taken as the fix. It will make this reproducer stop failing.
One measurement about cost is already in, and it matters for what to recommend.
Variant A takes over two minutes for one 8-thread x 200-call run, against
about 0.3 seconds for the full patch: roughly three orders of magnitude, because
the per-call module walk is now serialized as well. The backtrace path does not
run in silence — during MetaSchedule tuning the log fills with
Build errors: N sample(s) — so a fix that removes the crash but keeps the
per-call invade turns a working build into one that looks hung. The lifetime
change is therefore proposed as a practical requirement, not a decoration, even
if the lock alone proves sufficient for correctness.
Patch
#include <iostream>
+#include <mutex>
+#include <stdexcept>
#include <vector>
@@
HANDLE process = GetCurrentProcess();
HANDLE thread = GetCurrentThread();
- SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME);
- SymInitialize(process, NULL, TRUE);
+ // Re-entry on the same thread would deadlock on the non-recursive mutex
+ // below, so it is answered before the lock is taken. It can happen if
+ // anything reached from inside this function raises in turn - which is
+ // exactly the situation this function exists to report on.
+ static thread_local bool collecting = false;
+ if (collecting) {
+ backtrace_str = backtrace.GetBacktrace();
+ backtrace_array.data = backtrace_str.data();
+ backtrace_array.size = backtrace_str.size();
+ return &backtrace_array;
+ }
+ struct ReentryGuard {
+ bool* flag;
+ explicit ReentryGuard(bool* f) : flag(f) { *flag = true; }
+ ~ReentryGuard() { *flag = false; }
+ } reentry_guard(&collecting);
+
+ // DbgHelp is single threaded: every call for a given process must be
+ // serialized by the caller. The lock covers the whole body on purpose -
+ // StackWalk64, SymFromAddr and SymGetLineFromAddr64 read the same session.
+ static std::mutex dbghelp_mutex;
+ std::lock_guard<std::mutex> dbghelp_lock(dbghelp_mutex);
+
+ // SymInitialize and SymCleanup are per-process, not per-call. Initialize
+ // once and never clean up: the tables live as long as the process.
+ // SYMOPT_DEFERRED_LOADS keeps that one-time invade cheap - symbol data for a
+ // module is read only when a frame lands in it.
+ //
+ // The result is checked, and the lambda throws on failure so that
+ // std::call_once does NOT record the flag as satisfied: a normal return
+ // would make one early failure permanent for the life of the process.
+ static std::once_flag sym_init_once;
+ static bool sym_ready = false;
+ static DWORD sym_error = 0;
+ try {
+ std::call_once(sym_init_once, [process]() {
+ SymSetOptions(SYMOPT_DEFERRED_LOADS | SYMOPT_LOAD_LINES | SYMOPT_UNDNAME);
+ if (!SymInitialize(process, NULL, TRUE)) {
+ sym_error = GetLastError();
+ throw std::runtime_error("SymInitialize failed");
+ }
+ sym_ready = true;
+ });
+ } catch (const std::exception&) {
+ // Deliberately swallowed: this function is the error reporter, and an
+ // exception escaping it would replace the message being reported.
+ }
+ if (!sym_ready) {
+ backtrace_str = backtrace.GetBacktrace();
+ backtrace_array.data = backtrace_str.data();
+ backtrace_array.size = backtrace_str.size();
+ return &backtrace_array;
+ }
CONTEXT context = {};
@@
- SymCleanup(process);
+ // No SymCleanup: the symbol tables are process-wide and are deliberately
+ // kept for the life of the process.
backtrace_str = backtrace.GetBacktrace();
The ABI is unchanged: 119 exported names before and after, no differences. The
SymCleanup import disappears from the import table, which is a convenient way
to tell a patched binary from an unpatched one.
Remaining limits, stated rather than hidden
- The mutex serializes calls inside
tvm_ffi only. Another DLL in the same
process that uses DbgHelp does not know about it — particularly if it also
uses GetCurrentProcess() as the session identifier and calls SymCleanup.
That is not what the reproducer hits, but it is where the guarantee ends.
- Never calling
SymCleanup is right for a process-wide singleton, but it
makes one assumption explicit that was previously implicit: tvm_ffi.dll is
not unloaded and reloaded inside a live process. After such a reload the
DLL-local once_flag would be fresh while the DbgHelp session left behind is
the old one. This is stated as a lifetime assumption rather than checked.
I am happy to open a pull request with this change if that is useful.
Component:
tvm-ffi—src/ffi/backtrace_win.ccAffected version:
apache-tvm-ffi0.1.13.post3 (confirmed in the publishedsdist and wheel), also present in older trees
Platform: Windows x64, MSVC
Severity: a C++ exception raised while another thread is also reporting one
kills the process, and the original error message is never printed
Summary
The crash originates in unsynchronized access to shared DbgHelp symbol-handler
state. Repeated
SymInitialize/SymCleanuparound every backtrace and eagersymbol loading increase the amount of DbgHelp work performed on that path. In
addition, the return value of
SymInitializeis ignored, so an initializationfailure can leave subsequent symbol-handler calls operating without a
successfully established session.
The practical effect is worse than a crash in the error path. The original
exception is never printed, because the process dies while the library is
formatting the diagnostic for it. Every failure inside a multi-threaded TVM pass
therefore looks like a silent process death with no message at all.
That is how it turned up here. MetaSchedule tuning on Windows died at task
initialization with exit code 127, no Python traceback and no log line. It took
a native stack under
cdbto see that the visible crash was in the reporter,not in the code being reported on:
The C++ exception was raised on thread
5bec; the access violation happened onthread
6c34.What is wrong
src/ffi/backtrace_win.cc, lines 53–58 and 141:One correctness bug:
single-threaded and that calling them from more than one thread leads to
unexpected behaviour or memory corruption; the caller is required to
serialize them. There is no lock here. This applies to the whole body —
StackWalk64,SymFromAddr,SymFunctionTableAccess64andSymGetModuleBase64all read the same process-wide session, as do theSymInitialize/SymCleanuppair discussed below.One unchecked result:
SymInitialize's return value is ignored. If it fails, every later callin the process can go on working against a session that was never
established, and nothing says so. This is an error-handling defect found by
reading; it is not claimed to be what happened in the crashes measured here.
Two things that make (1) worse without being separate bugs of their own:
SymInitialize/SymCleanupper call. Both are per-process, notper-call, so one thread can tear the symbol tables down while another walks
them. That is a striking way for (1) to fail, but it is still (1) failing:
under a lock that covers the whole body, this lifecycle is merely expensive.
It is deliberately not listed as a second independent correctness bug —
see the ablation below.
fInvadeProcess = TRUEon every call. Enumerating every module andloading symbols for all of them is legitimate on its own. Doing it per call,
in a process where
tvm_compiler.dllalone is 168 MB, makes each call slowand allocation-heavy, which increases the amount of DbgHelp work done on the
faulting path.
Linux is unaffected:
backtrace.ccuseslibbacktrace, which is designed forconcurrent use and has no global init/cleanup pair.
Minimal reproducer
No TVM, no Python — it loads
tvm_ffi.dlland calls the one export fromseveral threads. It reports the work done, not only that it survived, so a
"fix" that produced empty backtraces could not pass it silently.
It reproduces in about 300 ms.
Measurements
Windows 11 x64, i5-12500 (12 logical cores), MSVC 14.51,
apache-tvm-ffi0.1.13.post3 rebuilt from the published sdist with no changeother than the patch below. Each run is an independent process.
Totals: 122 of 123 as published, 0 of 365 patched. Exit codes on the
unpatched build were 127, 139,
0xC0000005,0xC0000374and0xC0000409.The patched runs report their work: 32000 calls and 1248000 bytes of backtrace
per 16x2000 run, identical across runs.
Memory. Private bytes after 1, 100 and 1000 calls are identical
(27 328 512); after 10 000 calls, 27 332 608 — a single page. Not calling
SymCleanupdoes not leak per backtrace.Backtrace content. Checked on a purpose-built chain of ten
noinlinefunctions compiled with symbols, so that there are real named frames to resolve
— a short trace would have proved nothing. Both builds return byte-identical
text, 692 bytes, twelve frames, every one of them named with its file and line:
SYMOPT_DEFERRED_LOADSdoes not degrade symbolization: same frame count, samenames, same order, no bare addresses.
The degraded path was exercised, not merely written. The patch reports why
symbols are unavailable rather than silently returning a short trace, and that
branch is reachable on purpose: DbgHelp keeps one session per process handle, so
a test program that calls
SymInitializefirst makes the library's own callfail. Same program, two builds:
87 is ERROR_INVALID_PARAMETER - the session already exists. The unpatched build
returns the short trace with nothing to explain it.
At the level of the original symptom. MetaSchedule tuning of a small
convolutional model died at 80 s in every attempt before the patch. After it,
the same run passes that point and keeps going, and the log now contains
Build errors: N sample(s)— those are the C++ exceptions that previouslykilled the process while their diagnostic was being formatted. They are now
reported and the run continues.
Which part of the fix is actually required
The patch below changes three things at once — serialization, the lifetime of
SymInitialize/SymCleanup, and deferred symbol loading. Measuring only thewhole patch would show that this set of changes cures the crash, not that each
part is causally necessary. Three builds were made from the unmodified file,
each adding one thing, and measured with the same reproducer:
Each was run in independent processes, judged by the real exit code, and every
survivor had to report the full call count:
The first three rows were produced by an independent reviewer on this machine,
running the same script without being shown the expected outcome; the last three
are mine. The control row is what makes the rest readable: at 16 x 2000 the
unpatched build dies five times out of five in about a second, so a zero at that
load is not the probe being weak.
What this shows. Serializing everything (A) is sufficient: it completed the
full workload in every process. That is the minimal change worth proposing.
What it does not show. B - dropping the per-call SymInitialize/SymCleanup
without any lock - also produced no failure, in 320000 completed calls at the
load that kills the unpatched build instantly. That is strong evidence that the
per-call lifecycle was the dominant observed mechanism. It is not evidence
that B is correct: the concurrent StackWalk64, SymFromAddr and
SymGetLineFromAddr64 calls still violate the documented requirement. B removes
the most frequent collision and leaves the rest rare.
Crash counting cannot separate "the race is gone" from "the race became rare",
and no such claim is made here: any finite failure-free series is consistent
with both. Said plainly rather than papered over.
So, if it is worth saying outright: the lifetime change alone should not be
taken as the fix. It will make this reproducer stop failing.
One measurement about cost is already in, and it matters for what to recommend.
Variant A takes over two minutes for one 8-thread x 200-call run, against
about 0.3 seconds for the full patch: roughly three orders of magnitude, because
the per-call module walk is now serialized as well. The backtrace path does not
run in silence — during MetaSchedule tuning the log fills with
Build errors: N sample(s)— so a fix that removes the crash but keeps theper-call invade turns a working build into one that looks hung. The lifetime
change is therefore proposed as a practical requirement, not a decoration, even
if the lock alone proves sufficient for correctness.
Patch
The ABI is unchanged: 119 exported names before and after, no differences. The
SymCleanupimport disappears from the import table, which is a convenient wayto tell a patched binary from an unpatched one.
Remaining limits, stated rather than hidden
tvm_ffionly. Another DLL in the sameprocess that uses DbgHelp does not know about it — particularly if it also
uses
GetCurrentProcess()as the session identifier and callsSymCleanup.That is not what the reproducer hits, but it is where the guarantee ends.
SymCleanupis right for a process-wide singleton, but itmakes one assumption explicit that was previously implicit:
tvm_ffi.dllisnot unloaded and reloaded inside a live process. After such a reload the
DLL-local
once_flagwould be fresh while the DbgHelp session left behind isthe old one. This is stated as a lifetime assumption rather than checked.
I am happy to open a pull request with this change if that is useful.