Found while repairing a sanitize-cpu (thread) red on
#2047.
.github/workflows/ci.yml:1598 sets VT_POOL_BYPASS: "1" for the whole ctest
invocation, on both matrix lanes:
env:
UBSAN_OPTIONS: print_stacktrace=1
ASAN_OPTIONS: detect_leaks=1:strict_string_checks=1
# The production DevicePool deliberately retains scratch blocks. Its
# detector lane uses exact allocations and real frees so ASan can
# distinguish that cache from a leak and can see use-after-free.
VT_POOL_BYPASS: "1"
The stated reason is a good one and it is an ASan reason. detect_leaks=1
against an allocator that deliberately never returns blocks to the driver would
report the pool's retained cache as a leak, and a pooled block reused after
release reads as live memory rather than as a use-after-free. Both are real, and
both are properties of LeakSanitizer and AddressSanitizer.
ThreadSanitizer has neither. Its runtime carries no leak detector and no
redzone-based use-after-free detection, so the thread lane pays the full cost
of the bypass and collects none of its benefit.
What the cost is, measured
Under VT_POOL_BYPASS=1 every DevicePool::Get is a raw Backend::Alloc and
every Put a real Free (device_pool.h:113-127, :246-252), Drain reports
0, and PreGrowForCapture returns before it grows anything (:429). So the
free list, the size-class ladder, the best-fit borrow (#1922), the retention
accounting and the capture pre-grow (#1380) are all unexecuted in both
sanitizer lanes. Every concurrency-sensitive path in DevicePool — it has a
std::mutex, a classes_ map and a block_class_ map shared across threads —
is precisely what the thread lane exists to look at, and it is the one thing
that lane cannot see.
Measured locally on this box, -DVLLM_CPP_SANITIZE=thread, setarch -R,
tests/test_qwen3_5_decode_graph_seam:
| Environment |
Result |
VT_POOL_BYPASS=1 (as CI sets it) |
exit 0, 8 passed / 2 skipped, 138 assertions |
| bypass unset — the pool ENABLED |
exit 0, 10 passed / 0 skipped, 156 assertions, 0 ThreadSanitizer warnings |
So the instrumented runtime executes the pooled path perfectly well and reports
nothing. Only the environment variable removes the coverage.
Consequence already visible
#2047 adds two cases asserting that a CUDA-graph capture performs no driver
allocation. That guarantee is a property of the pool's free list, so it is
false by design under bypass and the cases skip there with an explicit
reason. The guarantee is therefore gated on build-test-cpu and on no sanitizer
lane. That is honest but it is not what anyone wants.
Ask
Scope the variable to the lane that needs it, e.g.
VT_POOL_BYPASS: ${{ matrix.lane == 'thread' && '0' || '1' }}
and let the thread lane run the pooled allocator. If the thread lane then
reports races in DevicePool, that is the lane doing its job and the finding is
worth having on its own.
A narrower alternative, if the pooled thread lane turns out to be noisy: keep
the bypass and add an LSan suppression for the pool's retained blocks so the
address lane can drop it too. That is more work and it is the better end state.
Not attempted here: #2047 is a decode-graph fix and rewriting a CI matrix is a
different change with a different reviewer. Owner: the hardening-adoption row
that owns sanitize-cpu and its continue-on-error removal. Listed under
## Owed in .agents/specs/cudagraph-pregrow-nonspec.md.
Found while repairing a
sanitize-cpu (thread)red on#2047.
.github/workflows/ci.yml:1598setsVT_POOL_BYPASS: "1"for the wholectestinvocation, on both matrix lanes:
The stated reason is a good one and it is an ASan reason.
detect_leaks=1against an allocator that deliberately never returns blocks to the driver would
report the pool's retained cache as a leak, and a pooled block reused after
release reads as live memory rather than as a use-after-free. Both are real, and
both are properties of LeakSanitizer and AddressSanitizer.
ThreadSanitizer has neither. Its runtime carries no leak detector and no
redzone-based use-after-free detection, so the
threadlane pays the full costof the bypass and collects none of its benefit.
What the cost is, measured
Under
VT_POOL_BYPASS=1everyDevicePool::Getis a rawBackend::Allocandevery
Puta realFree(device_pool.h:113-127,:246-252),Drainreports0, and
PreGrowForCapturereturns before it grows anything (:429). So thefree list, the size-class ladder, the best-fit borrow (#1922), the retention
accounting and the capture pre-grow (#1380) are all unexecuted in both
sanitizer lanes. Every concurrency-sensitive path in
DevicePool— it has astd::mutex, aclasses_map and ablock_class_map shared across threads —is precisely what the
threadlane exists to look at, and it is the one thingthat lane cannot see.
Measured locally on this box,
-DVLLM_CPP_SANITIZE=thread,setarch -R,tests/test_qwen3_5_decode_graph_seam:VT_POOL_BYPASS=1(as CI sets it)So the instrumented runtime executes the pooled path perfectly well and reports
nothing. Only the environment variable removes the coverage.
Consequence already visible
#2047 adds two cases asserting that a CUDA-graph capture performs no driver
allocation. That guarantee is a property of the pool's free list, so it is
false by design under bypass and the cases skip there with an explicit
reason. The guarantee is therefore gated on
build-test-cpuand on no sanitizerlane. That is honest but it is not what anyone wants.
Ask
Scope the variable to the lane that needs it, e.g.
and let the
threadlane run the pooled allocator. If thethreadlane thenreports races in
DevicePool, that is the lane doing its job and the finding isworth having on its own.
A narrower alternative, if the pooled
threadlane turns out to be noisy: keepthe bypass and add an LSan suppression for the pool's retained blocks so the
addresslane can drop it too. That is more work and it is the better end state.Not attempted here: #2047 is a decode-graph fix and rewriting a CI matrix is a
different change with a different reviewer. Owner: the hardening-adoption row
that owns
sanitize-cpuand itscontinue-on-errorremoval. Listed under## Owedin.agents/specs/cudagraph-pregrow-nonspec.md.