Skip to content

sanitize-cpu sets VT_POOL_BYPASS=1 on BOTH lanes, so neither sanitizer ever exercises the DevicePool — and the thread lane gains nothing from it #2059

Description

@localai-bot

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions