Skip to content

[memory] BUFFERED loader: default io_depth staging footprint; cached pinned-buffer deleter lifetime #17

Description

@malaiwah

Found during a source-level memory review of a deployment image that pins InstantTensor at commit 85e7c5f5 ("add thread pool"). All code references and permalinks below are against that commit; both observations were re-verified line-by-line before filing. These are proposals from a static review — happy to be corrected if we misread the lifecycle.

Context for the numbers: we reviewed usage at tensor-parallel world_size=4 with the default 8 MiB chunks on 96 GB GPUs, loading a large sharded checkpoint via the AIO/URING (BUFFERED) path.

1. Default io_depth = 512 // world_size makes the load-phase staging footprint 4 GiB VRAM + 1 GiB pinned host per rank

Where

instanttensor/_impl.py:539-546 (permalink):

            else:
                # AIO/AIO_BUFFERED/URING/URING_BUFFERED
                if chunk_size is None:
                    chunk_size = 8*1024*1024
                if concurrency is None:
                    concurrency = 1 # max(1 // self.world_size, 1)
                if io_depth is None:
                    io_depth = max(512 // self.world_size, 3) # aio read + cudaMemcpyAsync + ncclAllGather

csrc/loader_common.cpp:75-95 (permalink):

    this->rank_chunk_size = this->thread_chunk_size * this->num_threads;
    this->world_chunk_size = this->rank_chunk_size * this->world_size;

    size_t inflight_device_buffer_size = this->io_depth * this->world_chunk_size;
    if (this->buffer_size < inflight_device_buffer_size) this->buffer_size = inflight_device_buffer_size;
    ...
    CUDA_CHECK(cudaMalloc(&this->device_buffer, this->buffer_size));
    ...
    if (this->need_host_buffer) {
        size_t inflight_host_buffer_size = this->io_depth * this->rank_chunk_size;

What / impact (recomputed)

At world_size=4 with all-default parameters on the AIO/URING path:

  • io_depth = max(512//4, 3) = 128, chunk_size = 8 MiB, concurrency (num_threads) = 1
  • rank_chunk_size = 8 MiB, world_chunk_size = 32 MiB
  • device staging floor = io_depth × world_chunk_size = 128 × 32 MiB = 4.00 GiB of VRAM per rank (plus ~60 KiB alignment padding; _determine_buffer_size uses the same product as buffer_size_for_io, so the Python-side buffer_size never lowers it)
  • pinned host buffer = io_depth × rank_chunk_size = 128 × 8 MiB = 1.00 GiB per rank (aligned_alloc + cudaHostRegister, BUFFERED backends)

The max_free_mem_usage guard (_impl.py:565-586) cannot moderate this in the common case: with the default ratio 0.5 the shrink only fires when free VRAM is below 2 × 4 GiB = 8 GiB, so on healthy GPUs every rank silently commits 5 GiB of staging for the whole load. And when free memory is tight, the fallback shrinks to io_depth=3 or hard-fails — issue #13 shows exactly this sequence ending in cudaMalloc ... out of memory at loader_common.cpp:79 (its second-model-load OOM has a separate proximate cause, but the size being requested comes from this formula).

The buffers are transient — Loader::closedestroy_buffer frees both (default env) — so this defines the load-phase peak rather than a leak. Still, the peak is what a co-resident allocator (e.g. an inference engine loading two models, or reserving KV cache right after load) has to survive, and 128-deep × 32 MiB in-flight is far past the point of diminishing returns for a single-threaded AIO/URING pipeline; the bandwidth-delay product of NVMe→host→device suggests depth 16–32 achieves the same throughput at 1/4–1/8 the footprint.

Suggested fix

Cap the automatic default (e.g. io_depth = clamp(512 // world_size, 3, 32)), or scale the default against avail_bytes up front instead of only shrinking on the 50%-of-free cliff. Users who want the deeper queue can still set INSTANTTENSOR_IO_DEPTH.

Verification
Run any multi-rank load with INSTANTTENSOR_DEBUG=1 and no overrides; the Config: line printed from Loader::open shows io_depth=128, host_buffer_size=1073741824, and device_buffer_size ≥ 4294967296 (the 4 GiB in-flight floor plus alignment padding; larger if the tensor-derived size dominates) at world_size=4. Compare wall-clock load time against INSTANTTENSOR_IO_DEPTH=32.

2. INSTANTTENSOR_CACHE_BUFFER=1: cached pinned-buffer deleter captures this of a Loader that is already destroyed — use-after-free at cache eviction / process exit

Where

The deleter is created in Loader::init_buffer, csrc/loader_common.cpp:108-114 (permalink):

            this->host_buffer_entry.deleter = [=](void *ptr) {
                if (this->backend == Backend::URING || this->backend == Backend::URING_BUFFERED) {
                    this->deregister_host_buffer_uring();
                }
                CUDA_CHECK(cudaHostUnregister(ptr));
                free(ptr);
            };

[=] captures the raw this pointer. With INSTANTTENSOR_CACHE_BUFFER=1, destroy_buffer (loader_common.cpp:131-138) moves the entry — deleter included — into the process-global cache instead of invoking it:

    if (this->need_host_buffer) {
        if (_env_cache_buffer()) {
            host_buffer_cache->put(std::move(this->host_buffer_entry));
        }

host_buffer_cache is a global (csrc/instant_tensor/io_context.hpp:89), and its destructor runs every stored deleter (io_context.hpp:45-50):

    ~HostBufferCache() {
        std::lock_guard<std::mutex> lock(mutex);
        for(auto& entry : cached_host_buffers) {
            entry.deleter(entry.ptr);
        }
    }

Lifecycle trace (why this is dead by then)

  1. LoaderManager::open (csrc/main.cpp:54-55) spawns the loader on a detached thread:

    std::thread loader_thread(run_loader, std::unique_ptr<SPSCQueue<RPCRequest>>(input_queue), std::unique_ptr<SPSCQueue<RPCResponse>>(output_queue));
    loader_thread.detach();
  2. run_loader (loader_common.cpp:601-610) holds the Loader as a stack local: Loader loader(std::move(input_queue), std::move(output_queue)); loader.run();. Nothing else owns or references the Loader object — LoaderManager only keeps the raw queue pointers, and even those are erased in LoaderManager::close.

  3. The CLOSE RPC runs Loader::close → sets stop = true and calls destroy_buffer, which put()s the entry (with the this-capturing deleter) into the global cache.

  4. Loader::run's loop observes stop, returns; run_loader returns; the stack-local Loader is destroyed and the detached thread exits. From this point the captured this dangles.

  5. The stale deleter is eventually invoked with the dangling this:

    • at ~HostBufferCache — triggered either by the cleanup() atexit hook (main.cpp:135-139, registered in _impl.py:28) or by static destruction; or
    • via reuse: a subsequent Loader that hits the cache in init_buffer (loader_common.cpp:96-97) inherits the entry with the first loader's deleter (the deleter is only assigned on a cache miss), so whichever path finally runs it still dereferences the first, long-dead Loader.

    The deleter reads this->backend, and on the URING path calls this->deregister_host_buffer_uring(), which touches this->uring_register_buffer and io_uring_unregister_buffers(&this->uring_ring) on freed thread-stack memory. Even in the "benign" misread case it's undefined behavior; in the URING case it operates on a destroyed ring.

Secondary effect: with the flag on, the pinned buffer (1 GiB per rank at the defaults above) intentionally stays registered for process lifetime; for a load-once workflow that is a permanent pinned-memory hold, worth documenting alongside the fix.

This is latent/opt-in — nothing sets INSTANTTENSOR_CACHE_BUFFER by default — but anyone enabling it to speed up repeated opens gets UB at exit or on entry reuse.

Suggested fix

Make the deleter self-contained: capture by value only what it needs, e.g. backend and, for URING, do the ring deregistration in destroy_buffer (before put()) rather than in the deleter — deregistering the buffer from the loader's ring must happen while the loader is alive anyway, so the cached entry's deleter can reduce to [](void *ptr){ CUDA_CHECK(cudaHostUnregister(ptr)); free(ptr); } with no captures.

Verification

Build with ASan (or run under valgrind), set INSTANTTENSOR_CACHE_BUFFER=1, do one safe_open/close cycle with an AIO_BUFFERED/URING_BUFFERED backend, and exit the process: the atexit cleanup()~HostBufferCacheentry.deleter(ptr) chain reports a read of freed memory at the this->backend load in the lambda (loader_common.cpp:109).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions