Skip to content

Fix stale compile cache entry when released on another thread - #4096

Open
yentur wants to merge 3 commits into
ml-explore:mainfrom
yentur:fix/compile-cache-cross-thread-erase
Open

Fix stale compile cache entry when released on another thread#4096
yentur wants to merge 3 commits into
ml-explore:mainfrom
yentur:fix/compile-cache-cross-thread-erase

Conversation

@yentur

@yentur yentur commented Aug 9, 2026

Copy link
Copy Markdown

Proposed changes

Fixes #3940.

compile_erase runs from ~PyCompiledFun, and from the shared_ptr deleter in the C++
compile(), so it runs on whichever thread drops the last reference to a compiled
function. compiler_cache() has been thread_local since #3280, so when the release
happens off the thread that traced the function the erase goes to the wrong cache and the
entry stays behind.

That is not only a leak. fun_id is the address of the callable, and
CompilerCache::find matches on fun_id plus shapes, dtypes, stream and constants, so
once the address is reused a later mx.compile of an unrelated function matches the
stale entry and gets the dead function's tape back.

The caches stay thread local and are now also registered process wide so an erase can
reach all of them. The calling thread erases from its own cache directly, the others are
handed the id and apply it the next time they use their cache, so a cache is still only
ever mutated by the thread that owns it. find checks for pending ids with a single
acquire load, so compiled calls stay lock free.

Repro from the issue, on 8c28c38:

$ python repro_stale_entry.py
traces=1  (expected 2)
$ python repro_wrong_result.py
8/10 returned another function's result: [(2, 1), (3, 1), (4, 1), (5, 1), (6, 1), (7, 1), (8, 1), (9, 1)]

With the patch, three runs each:

$ python repro_stale_entry.py
traces=2  (expected 2)
$ python repro_wrong_result.py
0/10 returned another function's result: []

test_compile_release_on_another_thread covers the deterministic half of that. On main
it fails with

AssertionError: 1 != 2

and it passed 20 runs in a row with the patch. Full runs: python/tests 811 tests OK
(75 skipped), tests/tests 247 cases and 3326 assertions passing.

Two things worth flagging. I built this CPU only on an M4 Pro because there is no Metal
toolchain on the machine, so the Metal path is untested here, though nothing in the
change is backend specific. And a thread that keeps a compile cache but never touches it
again now accumulates 8 bytes per queued id until it next uses the cache or exits, which
is far below the tape that is leaked today but is not nothing.

The reporter mentioned in the issue that they had a patch ready. If theirs is further
along, close this one.

Checklist

  • I have read the CONTRIBUTING document
  • I have run pre-commit run --all-files to format my code / installed pre-commit prior to committing changes
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the necessary documentation (if needed)

The compile cache is thread local, but compile_erase runs on whichever
thread drops the last reference to a compiled function. When that is not
the thread that traced it, the erase hits the wrong cache and the entry
stays behind.

It does not only leak. fun_id is the address of the callable and the
cache matches on it plus shapes, dtypes, stream and constants, so once
the address is reused a later compile of an unrelated function can match
the stale entry and get the dead function's tape.

Register the caches process wide so an erase can reach them all. Threads
other than the caller are handed the id instead of having their cache
touched from the outside, and they apply it the next time they use the
cache. The check for pending ids is a single atomic load, so compiled
calls stay lock free.
@sashko-zakharchuk

Copy link
Copy Markdown
Contributor

thanks @yentur, and thanks for the credit. good to see this hit independently, and the process-wide registry is the right shape.

one thing worth checking before it lands, as a race rather than a certain break: the lazy drain introduces a lifetime hazard on the owning thread. find() still returns a bare CacheEntry& into cache_[fun_id]'s vector, and the caller in compile.cpp holds that reference across compile_trace(), which runs user code. if that traced body invokes an already-compiled function on non-tracer (constant) inputs, that nested compiled call runs find(), which calls apply_pending_erases(), which can run cache_.erase(id) on the same thread_local cache_. if the drained id is the outer in-flight fun_id, the vector holding the outer entry is freed and the post-trace writes to entry.tape/entry.outputs are a use-after-free.

the outer id gets into that thread's pending_erases_ when a second thread drops a handle to the same callable (fun_id is the reused callable address). full sequence:

  • A: find(F), empty, enters compile_trace
  • B: drops its wrapper of F, erase(F) pushes F into A's pending_erases_
  • A: traced body calls a compiled function on constant inputs, nested find() drains, cache_.erase(F) frees A's entry
  • A: compile_trace returns, writes through the now-dangling entry -> UAF

narrow, needs that three-way overlap in one window, but each piece is legitimate (per-thread thread_local caches under an inference server hit it).

to be clear the lock-order side looks correct: swapping the queue under caches_mutex and doing the destructive cache_.erase after unlock keeps CacheEntry destructors (which can touch python/GIL) out from under the registry lock, so there's no GIL/registry inversion here. the remaining issue is purely lifetime.

it's fixable inside your mechanism: make entries shared_ptr<CacheEntry> and have the caller hold the shared_ptr across compile_trace, so a foreign or re-entrant erase only drops the map slot while the in-flight entry stays alive until the trace finishes writing. we have a C++ regression test that erases while a trace is in flight (the added python test releases the handle before recompiling, so it doesn't cross this path) and are glad to send it as compile_tests.cpp coverage.

re your question: no need to close #4096 on our account. the goal is just a correct fix landed, so we'd rather converge on your registry and contribute the test plus the shared_ptr lifetime change than run a competing PR.

find() handed out a bare reference into the vector inside cache_, and the
caller holds it across compile_trace(), which runs user code. A traced
body that calls an already compiled function on constant inputs reaches
find() again, and the drain there can erase the outer in-flight fun_id
and free the entry the caller is still writing to.

Store the entries as shared_ptr and let the caller keep one for the whole
call, so an erase during a trace, re-entrant or from another thread, only
drops the cache slot while the entry in use stays alive.
@yentur

yentur commented Aug 9, 2026

Copy link
Copy Markdown
Author

Confirmed, it reproduces. I built the branch under ASAN with a test running your exact sequence: thread B calls compile_erase(F) while A is inside compile_trace(F), then A's traced body calls an already-compiled function on a constant input.

ERROR: AddressSanitizer: heap-use-after-free
freed by thread T0 here:
  #3 CompilerCache::apply_pending_erases() compile.cpp:431
  #4 CompilerCache::find(...)              compile.cpp:324
  #9 detail::compile_trace(...)            compile.cpp:479
previously allocated by thread T0 here:
  #2 CompilerCache::find(...)              compile.cpp:369

The part I wanted to check before agreeing was reachability of the nested find(), and it holds. array::is_tracer() is (array_desc_->is_tracer && in_tracing()) || retain_graph(), and RetainGraph is only constructed in vjp (transforms.cpp:529), never on the compile_trace path. So an array built inside the traced body has is_tracer() false and the any_of guard in the compiled lambda does not short-circuit the call.

Worth adding: this is not only a consequence of the registry. The same shape is already on main. A traced body that drops a second wrapper over the same callable calls compile_erase re-entrantly on the tracing thread, and on 8c28c38 that is also a use-after-free:

freed by thread T0 here:
  #3 detail::compile_erase(unsigned long) compile.cpp:1196
  #6 detail::compile_trace(...)           compile.cpp:415
previously allocated by thread T0 here:
  #2 CompilerCache::find(...)             compile.cpp:366

So the lifetime change is worth having on its own, independent of the erase routing.

Pushed as cf6e3ac: entries are shared_ptr<CacheEntry>, find() returns one, and the caller holds it for the whole call. Both variants are clean under ASAN afterwards, and the full C++ suite under ASAN is 248 cases / 3327 assertions with no reports.

Please send the C++ test as tests/compile_tests.cpp. Mine were scratch and are not in the branch, so erase-during-trace has no committed coverage right now and yours would close that gap. Either a commit on this branch or a patch here works.

@zcbenz zcbenz added the await verification This pull request is non-trivial and requires a human expert to verify its correctness. label Aug 10, 2026
@sashko-zakharchuk

Copy link
Copy Markdown
Contributor

here is the erase-during-trace coverage as two cases in tests/compile_tests.cpp. the first is
the lifetime one your cf6e3ac fixes; the second covers the cross-thread stale-entry half from
269c880 (trace on a worker that stays alive, erase from another thread, then reuse the id for a
different function).

verified against your branch: both pass under ASAN on cf6e3ac. reverting only cf6e3ac (back to
the bare CacheEntry&) makes the first case a heap-use-after-free on every run here, when the
caller writes the trace result back into the freed entry (the
std::tie(entry.inputs, entry.outputs, entry.extra) = compile_trace(...) at compile.cpp:1200),
the entry freed on the worker thread by the nested find()'s drain of outer_id. the trigger is
the one your commit message describes: a nested compiled helper on a constant reaches find() on
the tracing thread mid-trace.

happy to send this as a PR against your branch instead if that is easier than pasting.

TEST_CASE("test compile erase while a trace is in flight") {
  // An entry handed to a caller must outlive an erase that lands while the
  // caller is still filling it in. Here a second thread erases the outer id
  // while the worker is inside its trace, and the traced body then invokes an
  // already-compiled helper on a fresh (non-tracer) constant. That helper's
  // find() drains the pending erase on the worker's own cache, which frees the
  // outer entry unless the caller is holding it.
  auto x = zeros({1}, float32);
  eval(x);

  constexpr std::uintptr_t nested_id = 0xf00d;
  constexpr std::uintptr_t outer_id = 0xbeef;

  auto nested = detail::compile(
      [](const std::vector<array>& in) {
        return std::vector<array>{in[0] + 2.0f};
      },
      nested_id);
  eval(nested({zeros({1}, float32)}));

  std::mutex mtx;
  std::condition_variable cv;
  int stage = 0;

  auto traced = [&](const std::vector<array>& inputs) {
    {
      std::lock_guard<std::mutex> lk(mtx);
      stage = 1;
    }
    cv.notify_one();
    {
      std::unique_lock<std::mutex> lk(mtx);
      cv.wait(lk, [&stage] { return stage == 2; });
    }
    // Non-tracer input, so the compiled helper runs find() here and drains the
    // erase queued for the outer id above.
    auto tmp = nested({zeros({1}, float32)});
    return std::vector<array>{inputs[0] + tmp[0]};
  };

  float result = 0.0f;
  std::thread worker([&]() {
    auto compiled = detail::compile(traced, outer_id);
    auto outputs = compiled({x});
    eval(outputs);
    result = outputs[0].item<float>();
  });

  {
    std::unique_lock<std::mutex> lk(mtx);
    cv.wait(lk, [&stage] { return stage == 1; });
  }
  detail::compile_erase(outer_id);
  {
    std::lock_guard<std::mutex> lk(mtx);
    stage = 2;
  }
  cv.notify_one();
  worker.join();

  CHECK_EQ(result, 2.0f);
}

TEST_CASE("test compile erase from another thread") {
  // The compile cache is thread local, so an erase that reaches only the
  // calling thread leaves behind the entry of a function traced elsewhere.
  // Since |fun_id| is a reused address, the next compile on that id would then
  // be handed the dead function's tape. Trace on a worker thread which stays
  // alive, erase from this one, then reuse the id for a different function.
  auto x = zeros({1}, float32);
  eval(x);

  constexpr std::uintptr_t fun_id = 0xc0ffee;
  auto add_one = [](const std::vector<array>& inputs) {
    return std::vector<array>{inputs[0] + 1.0f};
  };
  auto add_two = [](const std::vector<array>& inputs) {
    return std::vector<array>{inputs[0] + 2.0f};
  };

  std::mutex mtx;
  std::condition_variable cv;
  int stage = 0;
  float before = 0.0f;
  float after = 0.0f;

  std::thread worker([&]() {
    {
      auto compiled = detail::compile(add_one, fun_id);
      auto outputs = compiled({x});
      eval(outputs);
      before = outputs[0].item<float>();
    }
    {
      std::lock_guard<std::mutex> lk(mtx);
      stage = 1;
    }
    cv.notify_one();
    {
      std::unique_lock<std::mutex> lk(mtx);
      cv.wait(lk, [&stage] { return stage == 2; });
    }
    // Same id, different function. Without the erase reaching this thread the
    // cached tape still adds one.
    auto compiled = detail::compile(add_two, fun_id);
    auto outputs = compiled({x});
    eval(outputs);
    after = outputs[0].item<float>();
  });

  {
    std::unique_lock<std::mutex> lk(mtx);
    cv.wait(lk, [&stage] { return stage == 1; });
  }
  detail::compile_erase(fun_id);
  {
    std::lock_guard<std::mutex> lk(mtx);
    stage = 2;
  }
  cv.notify_one();
  worker.join();

  CHECK_EQ(before, 1.0f);
  CHECK_EQ(after, 2.0f);
}

these need #include "mlx/compile_impl.h" plus <condition_variable>, <mutex>, <thread> at
the top of the file.

Two cases in compile_tests.cpp. The first erases the outer id from a
second thread while a worker is inside its trace, then has the traced
body call an already compiled helper on a constant so the nested find()
drains on the worker's own cache. The second traces on a worker that
stays alive, erases from another thread, then reuses the id for a
different function.

Both tests were written by @sashko-zakharchuk and are added here with
his agreement in ml-explore#4096.
@yentur

yentur commented Aug 11, 2026

Copy link
Copy Markdown
Author

Applied both cases as 6242ddb, with mlx/compile_impl.h, <condition_variable>, <mutex> and <thread> added to the includes. clang-format was already clean and the test bodies are as you wrote them.

I re-derived the three states here rather than taking the report as given, CPU-only build on an M4 Pro:

  • cf6e3ac33: both cases pass, normal build and ASAN.
  • compile.cpp reverted to 269c88026 (registry, bare CacheEntry&): case 1 is a heap-use-after-free on 10 of 10 runs, freed on the worker thread at apply_pending_erases() compile.cpp:431 via find() :324 inside compile_trace :479, allocated at find() :369. Case 2 still passes on that state.
  • compile.cpp reverted to 8c28c385f: case 2 fails on 10 of 10 runs, CHECK_EQ( after, 2.0f ) reporting values: CHECK_EQ( 1, 2 ). Case 1 passes there, since without the registry there is no drain to free the entry, so it guards the lifetime given the erase routing rather than upstream behaviour.

Since both are threaded and the PR is green right now, I ran each case 30 times on the normal build and 30 times under ASAN, 120 runs, no failures. Full suite is 249 cases and 3329 assertions passing on both builds, and I ran the ASAN suite three times with both cases in place with no reports.

Credited you in the commit body.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

await verification This pull request is non-trivial and requires a human expert to verify its correctness.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] mx.compile can return another function's result when a compiled function is released off its tracing thread

3 participants