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::close → destroy_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)
-
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();
-
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.
-
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.
-
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.
-
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() → ~HostBufferCache → entry.deleter(ptr) chain reports a read of freed memory at the this->backend load in the lambda (loader_common.cpp:109).
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_sizemakes the load-phase staging footprint 4 GiB VRAM + 1 GiB pinned host per rankWhere
instanttensor/_impl.py:539-546(permalink):csrc/loader_common.cpp:75-95(permalink):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) = 1rank_chunk_size = 8 MiB,world_chunk_size = 32 MiBio_depth × world_chunk_size= 128 × 32 MiB = 4.00 GiB of VRAM per rank (plus ~60 KiB alignment padding;_determine_buffer_sizeuses the same product asbuffer_size_for_io, so the Python-sidebuffer_sizenever lowers it)io_depth × rank_chunk_size= 128 × 8 MiB = 1.00 GiB per rank (aligned_alloc+cudaHostRegister, BUFFERED backends)The
max_free_mem_usageguard (_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 below2 × 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 toio_depth=3or hard-fails — issue #13 shows exactly this sequence ending incudaMalloc ... out of memoryatloader_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::close→destroy_bufferfrees 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 againstavail_bytesup front instead of only shrinking on the 50%-of-free cliff. Users who want the deeper queue can still setINSTANTTENSOR_IO_DEPTH.Verification
Run any multi-rank load with
INSTANTTENSOR_DEBUG=1and no overrides; theConfig:line printed fromLoader::openshowsio_depth=128,host_buffer_size=1073741824, anddevice_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 againstINSTANTTENSOR_IO_DEPTH=32.2.
INSTANTTENSOR_CACHE_BUFFER=1: cached pinned-buffer deleter capturesthisof aLoaderthat is already destroyed — use-after-free at cache eviction / process exitWhere
The deleter is created in
Loader::init_buffer,csrc/loader_common.cpp:108-114(permalink):[=]captures the rawthispointer. WithINSTANTTENSOR_CACHE_BUFFER=1,destroy_buffer(loader_common.cpp:131-138) moves the entry — deleter included — into the process-global cache instead of invoking it:host_buffer_cacheis a global (csrc/instant_tensor/io_context.hpp:89), and its destructor runs every stored deleter (io_context.hpp:45-50):Lifecycle trace (why
thisis dead by then)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();run_loader(loader_common.cpp:601-610) holds theLoaderas a stack local:Loader loader(std::move(input_queue), std::move(output_queue)); loader.run();. Nothing else owns or references theLoaderobject —LoaderManageronly keeps the raw queue pointers, and even those are erased inLoaderManager::close.The
CLOSERPC runsLoader::close→ setsstop = trueand callsdestroy_buffer, whichput()s the entry (with thethis-capturing deleter) into the global cache.Loader::run's loop observesstop, returns;run_loaderreturns; the stack-localLoaderis destroyed and the detached thread exits. From this point the capturedthisdangles.The stale deleter is eventually invoked with the dangling
this:~HostBufferCache— triggered either by thecleanup()atexit hook (main.cpp:135-139, registered in_impl.py:28) or by static destruction; orLoaderthat hits the cache ininit_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-deadLoader.The deleter reads
this->backend, and on the URING path callsthis->deregister_host_buffer_uring(), which touchesthis->uring_register_bufferandio_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_BUFFERby 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.
backendand, for URING, do the ring deregistration indestroy_buffer(beforeput()) 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 onesafe_open/close cycle with an AIO_BUFFERED/URING_BUFFERED backend, and exit the process: the atexitcleanup()→~HostBufferCache→entry.deleter(ptr)chain reports a read of freed memory at thethis->backendload in the lambda (loader_common.cpp:109).