This report documents a ThreadSanitizer (TSAN) reproduction of two data races
in aten/src/ATen/core/CachingHostAllocator.h that were introduced/exposed by
the "Support memory snapshot for CPU pinned memory" change, and confirms the
committed fixes eliminate them.
The host (pinned-memory) caching allocator uses fine-grained per-block locking
(block->mutex_), unlike the single-lock CUDACachingAllocator. Adding
getSegments() (reached only via _snapshot() when history recording is
enabled) created a concurrent reader of two per-block fields:
-
context_when_allocated_(astd::shared_ptr<GatheredContext>) — the allocate fast path (block reused from the free list) wrote it withoutblock->mutex_, whilegetSegments()reads it underblock->mutex_. The block stays inpool.blocks_the whole time it is cached, so a concurrent snapshot can read theshared_ptrwhile the allocating thread overwrites it — ashared_ptrcontrol-block refcount race (corruption / potential use-after-free, not a benign torn read). -
allocated_(a plainbool) — written inget_free_block()under the free-list mutex, read ingetSegments()under the block mutex. Two different locks guarding the same field is a data race (benign on x86 in practice, but UB and a guaranteed TSAN report).
PyTorch's build makes CUDA and TSAN mutually exclusive
(CMakeLists.txt: cmake_dependent_option(USE_CUDA "Use CUDA" ON "NOT USE_TSAN" OFF)), and the existing cuda_caching_host_allocator_test.cpp tests
are single-threaded and gated on at::cuda::is_available(). So the real CUDA
pinned-memory path cannot be TSAN'd, and the existing tests would never trip
the race even with CUDA.
The racing code, however, lives entirely in the device-agnostic template
CachingHostAllocatorImpl<S, E> in the header. The CUDA-specific parts are
backend virtuals (allocate_host_memory, record_stream, query_event,
stream_is_capturing, ...). So the race is reproducible with zero CUDA by
instantiating the real template with a malloc-backed mock backend.
A standalone gtest-free program (mock_host_alloc_race.cpp) that:
- Subclasses the real
CachingHostAllocatorImplwith a mock backend (malloc/free, no-op stream/event ops, no background threads). - Runs 4 worker threads doing
allocate(512)->free()in a loop, so blocks churn through the free list and get reused via the fast path. - Runs 1 reader thread calling
getSegments()in a loop, counting how many segments it observed with a non-nullcontext_when_allocated. - Runs 1 controller thread toggling
recordHistory(on/off)continuously for the whole run (with a 200us sleep between toggles) so disable windows are spread across the entire run, exercising the fixed code's disable-mid-snapshot else-branch concurrently withgetSegments.
After the run it asserts positive coverage, so that a clean TSAN result is not a false negative from the racy paths never executing:
- fast-path reuse dominated (
num_host_alloctiny vs. total allocations), - allocations occurred while recording was off (the else-branch was reachable),
- the reader observed non-null contexts (it read the racy field populated).
Built with g++ -std=c++17 -fsanitize=thread -O1 -g, linked against the
(uninstrumented) libc10.so. This does not undermine the result, for two
precise reasons: (1) both sides of both races are in the instrumented TU --
allocate, get_free_block, getSegments, maybe_cache_block are all header
template code compiled into the instrumented object, and the shared_ptr
refcount/pointer ops are libstdc++ header code also compiled into it (which is
why TSAN sees the refcount race at shared_ptr_base.h); (2) happens-before
comes from pthread interceptors, not instrumentation -- TSAN interposes
pthread_mutex_lock/unlock via the dynamic linker, so std::mutex establishes
HB even through uninstrumented libc10. The only per-iteration libc10 surface is
config accessors and RingBuffer/clock plumbing, none of which touches the two
fields under test.
Each configuration was run multiple times; results were stable (not flaky).
| Version | TSAN races | What TSAN reported |
|---|---|---|
| Pre-fix: both fixes reverted | 7 | the shared_ptr context race (size-8 reads in getSegments holding the block mutex vs. the unlocked fast-path write holding none, plus refcount atomics) and the allocated_ bool race (size-1 read in getSegments under the block mutex vs. the get_free_block write under the free-list mutex -- a different lock) |
| Fixed (committed HEAD, both fixes) | 0 | clean, reproduced across repeated runs, with positive coverage satisfied |
The pre-fix reports show the exact locking asymmetry: the reader thread holds the block mutex while the writer holds none (context race) or a different mutex (bool race). The fixed run is clean while its coverage assertions confirm the racy paths actually executed -- in a representative fixed run: ~282k total allocations, only 4 real host allocations (so fast-path reuse dominated), ~206k allocations performed while recording was off (so the disable-window else-branch was heavily exercised concurrently with the reader), and ~72k segments observed by the reader with a non-null context. So the clean result is a true negative, not the harness failing to schedule the race.
Note: line numbers in the TSAN excerpts below are from the reverted (pre-fix) working tree used for the positive control, and differ slightly from committed HEAD (e.g. the
getSegmentsread is at:1143/:1145at HEAD). They identify the racing statements, not exact HEAD locations.
WARNING: ThreadSanitizer: data race
Read of size 8 ... by thread T5 (mutexes: write M0, write M1):
#0 std::__shared_ptr<c10::GatheredContext, ...>::operator=(... const&) shared_ptr_base.h:1523
...
getSegments aten/src/ATen/core/CachingHostAllocator.h:1133
Previous write of size 8 ... by thread T1 (no mutexes):
#0 std::__shared_ptr<c10::GatheredContext, ...>::operator=(...&&)
...
allocate aten/src/ATen/core/CachingHostAllocator.h:354
WARNING: ThreadSanitizer: data race
Read of size 1 ... by thread T5 (mutexes: write M0, write M1):
getSegments aten/src/ATen/core/CachingHostAllocator.h:1135
Previous write of size 1 ... by thread T1 (mutexes: write M2):
get_free_block aten/src/ATen/core/CachingHostAllocator.h:685
(M0/M1 = block mutex; M2 = the free-list mutex — different locks, same field.)
-
Fast-path context write — gate the
block->mutex_lock behindrecord_history_, and in the recording-off branch only take the lock when there is actually something to write (else if (context || block->context_when_allocated_)). This closes the disable-mid-snapshot window (a cached block can carry a stale non-null context thatmaybe_cache_blockonly resets while recording) while keeping the steady- state hot path lock-free and write-free. -
allocated_flag — changed tostd::atomic<bool>with relaxed load/store at all sites. Relaxed ordering suffices: it is an independent flag, and every non-atomic invariant around it is still enforced by the existing mutexes.
Note: the fast-path lock in fix (1) is only taken when recording is enabled, so there is no added overhead on the common cached-alloc/free hot path when memory history recording is off.