veloxquant_mlx.memory package¶
KV-cache-aware block pool allocator (issue #249).
Fixed-size block allocation and reuse for KV-cache storage, addressing fragmentation and repeated malloc/free overhead during long-running or multi-request inference — the memory-allocation analogue of the bit-level compression the rest of VeloxQuant-MLX provides.
Core pieces:
BlockPoolAllocator/PoolConfig— pure-Python block bookkeeping (allocate, free, reuse, per-owner tracking, fragmentation and allocation stats). No MLX dependency; usable and testable on any platform.MLXBlockStorage— pairs with a pool to provide the actual mx.array buffers, one per block id, reused in place across requests and supporting mixed compression formats (different blocks may hold fp16, int8, int4, … data) within the same pool.PooledKVCache— wraps any existing VeloxQuantKVCacheso its token appends check out/return blocks from a shared pool, without changing the wrapped cache’s own compression logic.PoolBackedKVCache/build_pooled_caches()— a drop-in replacement formlx_lm.models.cache.KVCachewhose growth is tracked by a shared pool. UnlikePooledKVCache(which wraps VeloxQuant’s ownappend_key/append_value/attendinterface, implemented only by the 5 “standalone” methods), this implementsmlx_lm’supdate_and_fetchprotocol directly, so it plugs intomlx_lm.generate()for any model — this is what actually puts the pool in a real model’s decode hot path.
Typical usage:
from veloxquant_mlx.memory import BlockPoolAllocator, PoolConfig, PooledKVCache
from veloxquant_mlx import KVCacheBuilder, KVCacheConfig
pool = BlockPoolAllocator(PoolConfig(block_size=16, n_blocks=512))
inner = KVCacheBuilder().with_method("kivi").with_head_dim(128).build()
cache = PooledKVCache(inner, pool, owner=request_id, format="int2")
...
cache.release() # return blocks to the pool for reuse by the next request
Driving real mlx_lm.generate() traffic through the pool:
import mlx_lm
from veloxquant_mlx.memory import BlockPoolAllocator, PoolConfig, build_pooled_caches
model, tokenizer = mlx_lm.load("mlx-community/Llama-3.2-3B-Instruct-4bit")
pool = BlockPoolAllocator(PoolConfig(block_size=16, n_blocks=4096))
cache = build_pooled_caches(model, pool, owner=request_id)
text = mlx_lm.generate(model, tokenizer, prompt, prompt_cache=cache)
cache[0].release() # every layer shares one owner, so any one releases all
- class veloxquant_mlx.memory.AllocationStats(n_blocks, n_allocations=0, n_frees=0, n_reused=0, n_exhausted=0, n_grown=0, n_retired=0, peak_blocks_in_use=0, _fragmented_count=0)[source]
Bases:
objectRunning counters for a
BlockPoolAllocator.All counts are cumulative since pool construction unless noted.
- Variables:
n_blocks – Total blocks managed by the pool.
n_allocations – Number of successful allocate() calls.
n_frees – Number of free() calls.
n_reused – Allocations satisfied by a block that had previously been freed and returned to the pool (as opposed to a block that had never been handed out before).
n_exhausted – Allocation attempts that failed because no free block was available.
n_grown – Number of blocks added to the pool by exhaustion-triggered growth (see PoolConfig.grow_on_exhaustion).
n_retired – Number of blocks permanently removed from the pool by shrink() (see BlockPoolAllocator.shrink).
peak_blocks_in_use – High-water mark of concurrently allocated blocks.
- Parameters:
- n_blocks: int
- n_allocations: int = 0
- n_frees: int = 0
- n_reused: int = 0
- n_exhausted: int = 0
- n_grown: int = 0
- n_retired: int = 0
- peak_blocks_in_use: int = 0
- fragmentation()[source]
Fraction of managed blocks that are neither free nor fully used.
A block counts as fragmented once it has been allocated (n_used > 0) but is not full. Returns 0.0 for an allocator with no blocks.
- Return type:
- snapshot()[source]
Return an independent copy of this stats object for history/export.
- Return type:
- to_prometheus(prefix='veloxquant_block_pool')[source]
Render these stats as Prometheus text-exposition-format gauges/counters.
- class veloxquant_mlx.memory.Block(block_id, stream='kv', format='fp16', n_used=0, owner=None, ever_allocated=False)[source]
Bases:
objectA single fixed-size block of KV-cache storage.
- Variables:
block_id – Index into the pool’s backing storage; stable for the lifetime of the pool (blocks are reused in place, never moved).
stream – Which logical stream (“k” or “v”) this block belongs to. Always “kv” when the owning pool was built with
separate_kv=False.format – Compression-format tag of the data currently stored in this block (e.g. “fp16”, “int2”, “int4”). Lets a single pool host blocks written by different quantization methods concurrently.
n_used – Number of token slots currently occupied (<= block_size).
owner – Opaque request id the block is currently checked out to, or None if the block is free.
ever_allocated – True once this block id has been handed out by
BlockPoolAllocator.allocate()at least once. Used to distinguish a fresh allocation from a reuse for stats purposes.
- Parameters:
- block_id: int
- stream: str = 'kv'
- format: str = 'fp16'
- n_used: int = 0
- ever_allocated: bool = False
- class veloxquant_mlx.memory.BlockPoolAllocator(config=None)[source]
Bases:
objectFixed-size block allocator for KV-cache storage.
Pre-allocates
n_blocksfixed-size blocks up front and hands them out on request, avoiding per-token malloc/free during generation. Freed blocks return to a free list and are reused by later allocations (LIFO — a just-freed block tends to be cache-hot), so steady-state generation performs zero new-memory allocation once the pool is warm.Keys and values can be tracked on independent free lists (
PoolConfig.separate_kv=True, the default) so K-heavy and V-heavy workloads (e.g. asymmetric bit-widths) don’t compete for the same blocks, or share a single free list for tighter packing when K/V pressure is uneven across requests.Thread-safe: every mutating and read method acquires an internal lock, so one pool can be shared across threads/concurrent request handlers without external synchronization. The lock is a plain (non-reentrant)
threading.Lock— do not call back into the pool from inside a callback invoked while holding it.This class only manages block bookkeeping (which block ids are free, who owns which, how full each is, fragmentation/latency stats). It does not itself own array storage — pair it with a backing store (e.g.
veloxquant_mlx.memory.mlx_storage.MLXBlockStorage) that allocates the actual mx.array buffers indexed by block id.- Parameters:
config (
PoolConfig|None) – Pool sizing/behavior configuration.
Example:
pool = BlockPoolAllocator(PoolConfig(block_size=16, n_blocks=256)) blocks = pool.allocate(stream="k", n_tokens=40, owner=request_id, format="int2") ... pool.free_all(owner=request_id)
- history: deque[AllocationStats]
- register_owner(owner)[source]
Mark
owneras active, guarding against accidental id reuse.allocate()calls this automatically for any owner not yet seen, but a second allocate() for an owner that is already active is treated as that same request continuing to grow, not a collision (see allocate()’s docstring) — it can’t otherwise tell the two cases apart. Call register_owner() explicitly yourself (once, before any allocate() calls for this owner) if you want a genuine collision — e.g. a request-id counter that wrapped around, or two callers racing on the same id — to raise immediately, including on a second register_owner() call for a still-active id.- Raises:
OwnerAlreadyActiveError – If
owneralready has blocks checked out or was registered and not yet released.- Return type:
- Parameters:
owner (int)
- release_owner(owner)[source]
Clear
ownerfrom the active-owner set without freeing blocks.Rarely needed directly —
free_all()calls this once an owner’s last block is freed. Useful if you registered an owner viaregister_owner()but never actually allocated anything for it.
- allocate(stream, n_tokens, owner, format='fp16')[source]
Allocate enough blocks to hold
n_tokensslots.- Parameters:
stream (
str) – “k” or “v” (ignored, treated as “kv”, when the pool was built withseparate_kv=False).n_tokens (
int) – Number of token slots needed.owner (
int) – Opaque id (e.g. request id) the blocks are checked out to. Repeated allocate() calls for an owner that is already active on this pool are treated as that same request growing (the normal case — see PoolBackedKVCache) and never raise. To catch a different caller accidentally reusing an id that is still checked out, callregister_owner()yourself before the first allocate() — a second register_owner() for an id that is still active raises OwnerAlreadyActiveError.format (
str) – Compression-format tag to record on each block.
- Return type:
- Returns:
List of newly allocated
Blockinstances, in order.- Raises:
BlockPoolExhaustedError – If fewer than the required number of free blocks remain and the pool cannot grow enough to cover the gap (see
PoolConfig.grow_on_exhaustion/max_blocks). No blocks are allocated on failure (all-or-nothing).ValueError – If
n_tokens<= 0 orstreamis invalid.
- free(blocks)[source]
Return blocks to the free list for their stream.
- free_all(owner)[source]
Free every block currently checked out to
owner.Unlike
free(), which frees an arbitrary caller-supplied subset and must locate+remove each block id from the owner’s list, this already has the owner’s complete, correct block-id list – so it pops that list once (O(1)) instead of doing N membership checks + Nlist.remove()calls (VeloxQuant-MLX#510; was 13.4ms at 256K tokens / 16K blocks, confirmed superlinear: 2x blocks -> 2.0-3.4x time).
- blocks_for(owner)[source]
Return the blocks currently checked out to
owner, in allocation order.
- n_free(stream='k')[source]
Number of free blocks remaining for
stream.
- shrink(stream, target_free)[source]
Permanently retire fully-free blocks from
streamdown totarget_free.Use this to give memory back when a pool was sized for peak load that turned out much higher than steady-state usage. Only blocks currently on the free list are eligible — in-use blocks are never touched, so this never breaks a live request. Retired blocks are removed from the pool’s bookkeeping entirely (not just marked free/unfree), so any external backing storage indexed by block id (e.g.
MLXBlockStorage) may drop the corresponding buffer.- Parameters:
- Return type:
- Returns:
Number of blocks actually retired.
- Raises:
ValueError – If
target_free< 0.
- mark_used(block, n_used)[source]
Update
block.n_usedand keep fragmentation stats consistent.Callers that track a block’s fill level themselves (e.g.
PooledKVCache, which appends token-by-token to an already-checked-out block) must go through this instead of writingblock.n_useddirectly –AllocationStats._fragmented_countis maintained incrementally (not recomputed by a full scan every allocate()/free()/shrink() call, which would be O(n_blocks) on every single-block growth step in the decode hot path), so it only stays correct if every write to a block’s fill level is observed here.
- class veloxquant_mlx.memory.PoolConfig(block_size=16, n_blocks=1024, separate_kv=True, grow_on_exhaustion=False, max_blocks=None)[source]
Bases:
objectConfiguration for a
BlockPoolAllocator.- Variables:
block_size – Number of token slots stored per block.
n_blocks – Total number of blocks the pool manages initially.
separate_kv – If True, keys and values are allocated from independent free lists (K and V blocks are never interchanged). If False, a single free list backs both keys and values, which packs tighter when K and V pressure is uneven across requests.
grow_on_exhaustion – If True, an allocate() call that would otherwise raise BlockPoolExhaustedError instead grows the exhausted stream’s block count just enough to satisfy the request (see max_blocks). If False (default), exhaustion always raises.
max_blocks – Upper bound on total blocks across all streams once grown. None means unbounded growth. Ignored when grow_on_exhaustion is False. Growth that would exceed this cap still raises BlockPoolExhaustedError.
- Parameters:
- block_size: int = 16
- n_blocks: int = 1024
- separate_kv: bool = True
- grow_on_exhaustion: bool = False
- class veloxquant_mlx.memory.PooledKVCache(inner, pool, owner=-1, format='fp16')[source]
Bases:
KVCacheWraps any VeloxQuant KVCache with block-pool-backed memory accounting.
The wrapped cache still owns and drives its own storage arrays (its compression format may be anything registered with
KVCacheFactory); this wrapper checks out and returns fixed-size blocks from a sharedBlockPoolAllocatorin lock-step with token appends, so a multi-request server can track allocation counts, reuse, and fragmentation across every active cache from one pool instead of each cache doing its own ad hoc growth.Block granularity is
pool.config.block_sizetokens: a new block is requested from the pool only when the previous one fills up, and every block owned by this cache is returned to the pool in one call when the request completes (release()). No block is ever allocated or freed on a per-token basis, matching the “avoid malloc/free during generation” goal in issue #249.- Parameters:
inner (
KVCache) – The underlying KVCache that performs actual compression.pool (
BlockPoolAllocator) – Shared BlockPoolAllocator to draw block accounting from.owner (
int) – Opaque id identifying this cache’s request/sequence. Two PooledKVCache instances must use different owners to avoid each other’srelease()reclaiming the wrong blocks.format (
str) – Compression-format tag recorded on each block this cache checks out (purely informational — passed through topool.allocate(format=...)).
- append_key(k)[source]
Check out a K block from the pool if needed, then append to the inner cache.
- append_value(v)[source]
Check out a V block from the pool if needed, then append to the inner cache.
- attend(q)[source]
Delegate attention computation to the inner cache.
- release()[source]
Return every block this cache holds to the pool.
Call this once the request finishes (or is evicted) so its blocks become available for reuse by later requests.
- Return type:
- class veloxquant_mlx.memory.PoolBackedKVCache(pool, owner=-1, step=None)[source]
Bases:
objectDrop-in replacement for
mlx_lm.models.cache.KVCachewhose growth is tracked by a sharedBlockPoolAllocator.mlx_lm’s stockKVCachegrows its backingmx.arrayin fixedstep=256-token chunks, hardcoded per instance, with no visibility into how many times that growth happened across a server’s caches or how much of the last chunk is wasted. This class keeps the same contiguous-buffer growth strategy — attention needs one contiguous(B, n_kv_heads, seq_len, head_dim)tensor every step, and RoPE / masking readcache.offsetdirectly, so growth cannot be replaced with true block-paged storage without a per-step gather cost — but routes every growth step throughpool.allocate()so it shows up in the pool’sAllocationStats(allocation count, reuse, fragmentation) the same way any other pool consumer’s growth does. The growth chunk size itself defaults to stock’s 256-token cadence (VeloxQuant-MLX#510), independent of the pool’s (typically much finer) accountingblock_size– growing in 16-token chunks to match a defaultblock_size=16pool cost 8.6-10.2% measured decode throughput vs. stock, since MLX dispatch/graph-construction overhead tracksmx.concatenatecall count, not bytes moved.This is the piece that puts
BlockPoolAllocatorin a real model’s decode hot path: unlikePooledKVCache, which wraps VeloxQuant’s ownappend_key/append_value/attendinterface (only implemented by the 5 “standalone” methods — seeSTANDALONE_METHODSinveloxquant_mlx/cache/base.py), this class implementsmlx_lm’supdate_and_fetchprotocol directly, so it plugs intomlx_lm.generate()for any model exactly where a stockKVCachewould.- Parameters:
pool (
BlockPoolAllocator) – Shared BlockPoolAllocator to draw growth accounting from.owner (
int) – Opaque id identifying this cache’s request/sequence. Two concurrentPoolBackedKVCacheinstances must use different owners sorelease()only reclaims their own blocks.step (
int|None) – Token-chunk size to grow the backing buffer by. Defaults tomax(pool.config.block_size, 256), matching stockKVCache’s hardcoded 256-token growth cadence (see_DEFAULT_GROWTH_STEP) regardless of the pool’s (typically much smaller) accounting block size. Pass an explicit value to override either direction.
- update_and_fetch(keys, values)[source]
Append one decode step’s keys/values and return the full history.
- release()[source]
Return every block this cache’s growth checked out to the pool.
Call this once the request finishes so its accounted blocks free up pool headroom for the next request. The backing mx.array itself is reclaimed by MLX’s normal garbage collection when this object goes out of scope; this only clears the pool’s bookkeeping.
- Return type:
- property state
(keys, values) trimmed to the current offset.
- property meta_state
No extra metadata for this cache type; always empty.
- trim(n)[source]
Rewind the offset by up to n tokens; returns the amount actually trimmed.
- make_mask(*args, **kwargs)[source]
Build an attention mask for the current offset via mlx_lm’s create_attention_mask.
- property nbytes: int
Combined byte size of the backing keys and values buffers.
- veloxquant_mlx.memory.build_pooled_caches(model, pool, owner, step=None)[source]
Build one PoolBackedKVCache per language-model layer.
Drop-in replacement for
mlx_lm.models.cache.make_prompt_cache/model.make_cache()for the “plain fp16, pool-tracked growth” case — every attention-bearing layer gets a PoolBackedKVCache sharing the same pool and owner, so a singlepool.free_all(owner)(or callingPoolBackedKVCache.release()on any one of them) releases every layer’s growth accounting for this request at once.- Parameters:
model – Loaded mlx_lm model instance.
pool (
BlockPoolAllocator) – Shared BlockPoolAllocator every layer’s cache draws from.owner (
int) – Opaque id (e.g. request id) shared by every layer’s cache for this request.step (
int|None) – Growth chunk size in tokens; defaults tomax(pool.config.block_size, 256)(see PoolBackedKVCache).
- Return type:
- Returns:
List of PoolBackedKVCache instances, one per language-model layer.
Submodules¶
- veloxquant_mlx.memory.block_pool module
PoolConfigBlockAllocationStatsAllocationStats.n_blocksAllocationStats.n_allocationsAllocationStats.n_freesAllocationStats.n_reusedAllocationStats.n_exhaustedAllocationStats.n_grownAllocationStats.n_retiredAllocationStats.peak_blocks_in_useAllocationStats.blocks_in_use()AllocationStats.fragmentation()AllocationStats.snapshot()AllocationStats.to_prometheus()
BlockPoolAllocatorBlockPoolAllocator.historyBlockPoolAllocator.register_owner()BlockPoolAllocator.release_owner()BlockPoolAllocator.allocate()BlockPoolAllocator.free()BlockPoolAllocator.free_all()BlockPoolAllocator.blocks_for()BlockPoolAllocator.n_free()BlockPoolAllocator.shrink()BlockPoolAllocator.mark_used()
- veloxquant_mlx.memory.mlx_storage module
- veloxquant_mlx.memory.pool_backed_cache module
- veloxquant_mlx.memory.pooled_cache module