pisarev opened a new issue, #727:
URL: https://github.com/apache/tvm-ffi/issues/727

   **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:
   
   ```cpp
   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:**
   
   1. **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:**
   
   2. **`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:**
   
   3. **`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.
   
   4. **`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.
   
   ```cpp
   #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
   
   ```diff
    #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.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to