Skip to content

Commit 19b71e7

Browse files
kmbandyclaude
andcommitted
mt:: paged refactor — Phase 1 scaffolding (BlockPool + BlockTable)
First step of the paged-attention refactor that will eventually replace the position-keyed warm/cold tier bookkeeping with vLLM-style block-level abstractions. These two classes are inert data structures in this commit — no live wiring to the existing eviction or restore paths. The defensive n_seq_max==1 assert (commit 01d8e29) still gates multi-seq until the wiring lands. mt::BlockPool (mt-block-pool.{h,cpp}): - Two free-stack pools (GPU + CPU) of physical block IDs - ID space [0, n_gpu) = GPU blocks; [n_gpu, n_gpu+n_cpu) = CPU blocks → is_gpu() is a single integer comparison - Watermark gating (vLLM-default 5%) reserves a fraction of free blocks against admission spikes - LIFO allocation for cache-line reuse - Cold tier (NVMe via KvtcStore) is OUT of scope here — that path keys by (layer, position) and doesn't need a physical block ID mt::BlockTable (mt-block-table.{h,cpp}): - Per-seq dense vector mapping logical_block_idx → physical_block_id - O(1) lookup via get_physical(seq, logical_idx) - Convenience get_physical_for_pos(seq, pos) does pos / block_size - swap_block() rewrites a single entry in place — used by tier migration (hot→warm copy: pull new CPU block, swap entry, free old GPU block back to pool) - clear_seq() returns the freed block IDs in append order so the caller (eventually mt::seq_rm whole-seq wipe path) can release them back to BlockPool Design adapted from vLLM's vllm/v1/core/block_pool.py + vllm/v1/worker/block_table.py (Apache 2.0). Code is independent (C++ vs Python, llama.cpp vs vLLM runtime), architecture is theirs — attribution will move to docs/memory-tier/CREDITS.md when this lands as a complete subsystem. Build: linked into libllama.so cleanly. Both files reach 100% target build with no warnings. Phase 2 (next): wire BlockPool + BlockTable into mt::seq_rm / backup_seq_rm_range / restore_from_warm behind a feature flag, with the existing position-keyed paths preserved as the default. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 01d8e29 commit 19b71e7

4 files changed

Lines changed: 370 additions & 0 deletions

File tree

src/memory-tier/mt-block-pool.cpp

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
#include "mt-block-pool.h"
2+
3+
#include "llama-impl.h" // LLAMA_LOG_*
4+
5+
#include <cassert>
6+
#include <cmath>
7+
8+
namespace mt {
9+
10+
void BlockPool::init(uint32_t n_gpu, uint32_t n_cpu, float watermark) {
11+
total_gpu_blocks_ = n_gpu;
12+
total_cpu_blocks_ = n_cpu;
13+
14+
// Watermark is a fraction; round up so a small reserve never floors
15+
// to 0 (which would defeat the purpose). vLLM uses ceil() too.
16+
watermark_gpu_ = (uint32_t) std::ceil((double) n_gpu * (double) watermark);
17+
watermark_cpu_ = (uint32_t) std::ceil((double) n_cpu * (double) watermark);
18+
19+
// Initialize free stacks. Push descending so pop() returns block 0
20+
// first — predictable in tests, no behavior cost.
21+
gpu_free_.clear();
22+
gpu_free_.reserve(n_gpu);
23+
for (uint32_t i = n_gpu; i-- > 0; ) {
24+
gpu_free_.push_back(i);
25+
}
26+
27+
cpu_free_.clear();
28+
cpu_free_.reserve(n_cpu);
29+
for (uint32_t i = n_cpu; i-- > 0; ) {
30+
// CPU IDs live above the GPU range so is_gpu() is a single
31+
// comparison.
32+
cpu_free_.push_back(n_gpu + i);
33+
}
34+
35+
LLAMA_LOG_INFO("mt::BlockPool: init n_gpu=%u n_cpu=%u watermark=%.2f "
36+
"(reserve gpu=%u cpu=%u)\n",
37+
n_gpu, n_cpu, (double) watermark,
38+
watermark_gpu_, watermark_cpu_);
39+
}
40+
41+
uint32_t BlockPool::alloc_gpu() {
42+
if (gpu_free_.empty()) return kInvalidBlockId;
43+
const uint32_t id = gpu_free_.back();
44+
gpu_free_.pop_back();
45+
return id;
46+
}
47+
48+
uint32_t BlockPool::alloc_cpu() {
49+
if (cpu_free_.empty()) return kInvalidBlockId;
50+
const uint32_t id = cpu_free_.back();
51+
cpu_free_.pop_back();
52+
return id;
53+
}
54+
55+
void BlockPool::free_block(uint32_t block_id) {
56+
if (block_id == kInvalidBlockId) {
57+
LLAMA_LOG_WARN("mt::BlockPool::free_block: kInvalidBlockId — ignoring\n");
58+
return;
59+
}
60+
61+
if (is_gpu(block_id)) {
62+
assert(block_id < total_gpu_blocks_);
63+
gpu_free_.push_back(block_id);
64+
} else {
65+
assert(block_id < total_gpu_blocks_ + total_cpu_blocks_);
66+
cpu_free_.push_back(block_id);
67+
}
68+
}
69+
70+
bool BlockPool::is_gpu(uint32_t block_id) const {
71+
return block_id < total_gpu_blocks_;
72+
}
73+
74+
size_t BlockPool::n_free_gpu() const {
75+
return gpu_free_.size();
76+
}
77+
78+
size_t BlockPool::n_free_cpu() const {
79+
return cpu_free_.size();
80+
}
81+
82+
bool BlockPool::has_free_gpu_blocks(uint32_t n) const {
83+
const size_t avail = gpu_free_.size();
84+
if (avail <= watermark_gpu_) return false;
85+
return n <= (avail - watermark_gpu_);
86+
}
87+
88+
bool BlockPool::has_free_cpu_blocks(uint32_t n) const {
89+
const size_t avail = cpu_free_.size();
90+
if (avail <= watermark_cpu_) return false;
91+
return n <= (avail - watermark_cpu_);
92+
}
93+
94+
void BlockPool::reset() {
95+
gpu_free_.clear();
96+
gpu_free_.reserve(total_gpu_blocks_);
97+
for (uint32_t i = total_gpu_blocks_; i-- > 0; ) {
98+
gpu_free_.push_back(i);
99+
}
100+
cpu_free_.clear();
101+
cpu_free_.reserve(total_cpu_blocks_);
102+
for (uint32_t i = total_cpu_blocks_; i-- > 0; ) {
103+
cpu_free_.push_back(total_gpu_blocks_ + i);
104+
}
105+
}
106+
107+
} // namespace mt

src/memory-tier/mt-block-pool.h

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
#pragma once
2+
3+
// BlockPool — physical block allocation pool for the paged tier refactor.
4+
//
5+
// Design adapted from vLLM (Apache 2.0, vllm/v1/core/block_pool.py +
6+
// vllm/v1/worker/block_table.py). Code is independent (C++ vs Python),
7+
// architecture is theirs. See docs/memory-tier/CREDITS.md when this
8+
// lands as a complete subsystem.
9+
//
10+
// A "physical block" is a fixed-size chunk of KV cache storage capable
11+
// of holding `block_size` tokens worth of K/V for one layer. Blocks are
12+
// allocated from one of two pools — GPU (hot tier) or CPU (warm tier) —
13+
// distinguished by their ID range:
14+
//
15+
// [0, total_gpu_blocks) GPU blocks
16+
// [total_gpu_blocks, total_gpu_blocks + total_cpu_blocks) CPU blocks
17+
//
18+
// Cold tier (NVMe) is handled outside this pool — KvtcStore writes by
19+
// (layer, position) and doesn't need a physical block ID.
20+
//
21+
// Allocation is LIFO from a free stack; cache-line reuse on freshly-
22+
// freed blocks helps decode locality. Watermark gating reserves a small
23+
// fraction of free blocks against admission spikes so the scheduler
24+
// never starves itself by accepting one more request than it can
25+
// retire.
26+
//
27+
// PHASE 1: This is a pure data structure with no live wiring. The
28+
// existing position-keyed warm_pos_to_slot_ / cold_positions_ paths
29+
// in mt-tiered.cpp continue to work. PHASE 2 wires this in behind a
30+
// feature flag; PHASE 3 makes it the only path.
31+
//
32+
// Single-threaded — caller must serialize. mt:: is single-seq today
33+
// and the scheduler runs single-threaded, so no internal locking.
34+
35+
#include <cstddef>
36+
#include <cstdint>
37+
#include <vector>
38+
39+
namespace mt {
40+
41+
// Sentinel for "no block." Chosen as ~0u so it sticks out in logs and
42+
// any accidental use as an array index will explode loudly.
43+
static constexpr uint32_t kInvalidBlockId = ~0u;
44+
45+
class BlockPool {
46+
public:
47+
// Initialize the pool with `n_gpu` GPU blocks and `n_cpu` CPU blocks.
48+
// `watermark` is the fraction (0.0..1.0) of each pool kept in reserve
49+
// before has_free_*_blocks() returns true for new admissions. 0.0
50+
// disables the watermark; vLLM defaults to ~0.05 (5%).
51+
void init(uint32_t n_gpu, uint32_t n_cpu, float watermark);
52+
53+
// Allocate one physical block from the GPU pool. Returns
54+
// kInvalidBlockId when the pool is exhausted (caller should evict
55+
// first). Watermark is NOT enforced here — callers that care about
56+
// admission control should consult has_free_gpu_blocks() up-front.
57+
// alloc_*() always allocates if anything is free.
58+
uint32_t alloc_gpu();
59+
uint32_t alloc_cpu();
60+
61+
// Return a block to its pool. Idempotent on already-free blocks
62+
// (logs a warning); double-free does NOT corrupt the pool. Asserts
63+
// on out-of-range IDs.
64+
void free_block(uint32_t block_id);
65+
66+
// True if `block_id` is a GPU block (vs CPU). Inferred from the ID
67+
// range — no per-block bool stored.
68+
bool is_gpu(uint32_t block_id) const;
69+
70+
// Pool size queries. n_free_*() is the actual count of free blocks
71+
// (ignores watermark); has_free_*_blocks(N) tests whether N blocks
72+
// can be allocated WITHOUT crossing the watermark reserve.
73+
size_t n_free_gpu() const;
74+
size_t n_free_cpu() const;
75+
bool has_free_gpu_blocks(uint32_t n) const;
76+
bool has_free_cpu_blocks(uint32_t n) const;
77+
78+
uint32_t total_gpu_blocks() const { return total_gpu_blocks_; }
79+
uint32_t total_cpu_blocks() const { return total_cpu_blocks_; }
80+
81+
// Reset to all-free state. Used by whole-cache wipes (mt::clear()).
82+
// Block IDs are NOT renumbered; existing block-table entries that
83+
// pointed into the freed range become dangling and the caller must
84+
// also clear those before next use.
85+
void reset();
86+
87+
private:
88+
// Free stacks (LIFO). gpu_free_ holds IDs in [0, total_gpu_blocks_),
89+
// cpu_free_ holds IDs in [total_gpu_blocks_, total_gpu_blocks_ +
90+
// total_cpu_blocks_). Stack push/pop is the eviction order under
91+
// pure LRU; smarter eviction policies layer above.
92+
std::vector<uint32_t> gpu_free_;
93+
std::vector<uint32_t> cpu_free_;
94+
95+
uint32_t total_gpu_blocks_ = 0;
96+
uint32_t total_cpu_blocks_ = 0;
97+
uint32_t watermark_gpu_ = 0; // reserve count, NOT a fraction
98+
uint32_t watermark_cpu_ = 0;
99+
};
100+
101+
} // namespace mt

src/memory-tier/mt-block-table.cpp

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
#include "mt-block-table.h"
2+
3+
#include "mt-block-pool.h" // kInvalidBlockId
4+
#include "llama-impl.h" // LLAMA_LOG_*
5+
6+
#include <cassert>
7+
8+
namespace mt {
9+
10+
void BlockTable::init(uint32_t max_seqs, uint32_t block_size) {
11+
max_seqs_ = max_seqs;
12+
block_size_ = block_size;
13+
table_.clear();
14+
table_.resize(max_seqs);
15+
LLAMA_LOG_INFO("mt::BlockTable: init max_seqs=%u block_size=%u\n",
16+
max_seqs, block_size);
17+
}
18+
19+
void BlockTable::append_block(llama_seq_id seq, uint32_t physical_block_id) {
20+
assert(seq >= 0 && (uint32_t) seq < max_seqs_ && "seq out of range");
21+
table_[seq].push_back(physical_block_id);
22+
}
23+
24+
uint32_t BlockTable::swap_block(llama_seq_id seq, uint32_t logical_idx,
25+
uint32_t new_physical_block_id) {
26+
assert(seq >= 0 && (uint32_t) seq < max_seqs_ && "seq out of range");
27+
auto & row = table_[seq];
28+
assert(logical_idx < row.size() && "logical_idx out of range");
29+
const uint32_t old = row[logical_idx];
30+
row[logical_idx] = new_physical_block_id;
31+
return old;
32+
}
33+
34+
uint32_t BlockTable::get_physical(llama_seq_id seq, uint32_t logical_idx) const {
35+
if (seq < 0 || (uint32_t) seq >= max_seqs_) return kInvalidBlockId;
36+
const auto & row = table_[seq];
37+
if (logical_idx >= row.size()) return kInvalidBlockId;
38+
return row[logical_idx];
39+
}
40+
41+
uint32_t BlockTable::get_physical_for_pos(llama_seq_id seq, llama_pos pos) const {
42+
if (pos < 0 || block_size_ == 0) return kInvalidBlockId;
43+
return get_physical(seq, (uint32_t) pos / block_size_);
44+
}
45+
46+
uint32_t BlockTable::num_blocks(llama_seq_id seq) const {
47+
if (seq < 0 || (uint32_t) seq >= max_seqs_) return 0;
48+
return (uint32_t) table_[seq].size();
49+
}
50+
51+
std::vector<uint32_t> BlockTable::clear_seq(llama_seq_id seq) {
52+
if (seq < 0 || (uint32_t) seq >= max_seqs_) return {};
53+
std::vector<uint32_t> freed = std::move(table_[seq]);
54+
table_[seq].clear(); // post-move state is unspecified; explicit clear
55+
return freed;
56+
}
57+
58+
void BlockTable::reset() {
59+
for (auto & row : table_) {
60+
row.clear();
61+
}
62+
}
63+
64+
} // namespace mt

src/memory-tier/mt-block-table.h

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#pragma once
2+
3+
// BlockTable — per-sequence logical→physical block mapping for the
4+
// paged tier refactor.
5+
//
6+
// Design adapted from vLLM (Apache 2.0, vllm/v1/worker/block_table.py).
7+
// Code is independent (C++ vs Python), architecture is theirs. See
8+
// docs/memory-tier/CREDITS.md when this lands as a complete subsystem.
9+
//
10+
// For each sequence:
11+
//
12+
// table_[seq] = [physical_block_0, physical_block_1, ..., physical_block_N]
13+
// └─ logical block 0 ┘└─ logical block 1 ┘
14+
//
15+
// To find which physical block holds position `p` for sequence `s`:
16+
//
17+
// logical_block_idx = p / block_size
18+
// physical_block_id = table_[s][logical_block_idx]
19+
// token_in_block = p % block_size
20+
//
21+
// Block size is fixed at construction (typical: 16 tokens). A sequence's
22+
// logical block list grows monotonically as the sequence is appended;
23+
// blocks are released only on whole-seq wipe (clear_seq) or via tier
24+
// migration paths that swap a logical block's physical mapping (e.g.
25+
// hot→warm copy that updates the table to point at the new CPU block).
26+
//
27+
// PHASE 1: Pure data structure with no live wiring. PHASE 2 wires this
28+
// into the eviction trigger + restore path; PHASE 3 makes it the only
29+
// path. Until then the existing position-keyed warm_pos_to_slot_ /
30+
// cold_positions_ paths remain authoritative.
31+
//
32+
// Single-threaded.
33+
34+
#include "llama.h" // llama_seq_id, llama_pos
35+
36+
#include <cstdint>
37+
#include <vector>
38+
39+
namespace mt {
40+
41+
class BlockTable {
42+
public:
43+
// Construct + size for `max_seqs` sequences with `block_size` tokens
44+
// per block. Per-seq capacity grows on demand (no upper bound at
45+
// construction — the BlockPool's exhaustion check is what bounds
46+
// total allocation).
47+
void init(uint32_t max_seqs, uint32_t block_size);
48+
49+
// Append a physical block to the end of `seq`'s logical sequence.
50+
// The new logical_block_idx is `num_blocks(seq) - 1` after this
51+
// call. Caller is responsible for pulling the physical block from
52+
// BlockPool first.
53+
void append_block(llama_seq_id seq, uint32_t physical_block_id);
54+
55+
// Replace the physical block at logical position `idx` for `seq`.
56+
// Used by tier migration: when a hot block is evicted to warm, the
57+
// table entry rewrites from the old GPU id to the new CPU id while
58+
// logical_block_idx stays put. Returns the OLD physical_block_id so
59+
// the caller can free it back to BlockPool.
60+
uint32_t swap_block(llama_seq_id seq, uint32_t logical_idx,
61+
uint32_t new_physical_block_id);
62+
63+
// Lookup: which physical block holds logical block `idx` of `seq`?
64+
// Returns kInvalidBlockId (from mt-block-pool.h) if `seq` has no
65+
// mapping or `idx` is out of range. Does NOT bounds-assert — paged
66+
// attention kernels read past the live range during prefill prep
67+
// and must get a sentinel rather than UB.
68+
uint32_t get_physical(llama_seq_id seq, uint32_t logical_idx) const;
69+
70+
// For position-based callers. Same as get_physical(seq, pos /
71+
// block_size). Convenience wrapper.
72+
uint32_t get_physical_for_pos(llama_seq_id seq, llama_pos pos) const;
73+
74+
// Number of physical blocks currently held by `seq`. Always equals
75+
// ceil(live_token_count / block_size).
76+
uint32_t num_blocks(llama_seq_id seq) const;
77+
78+
// Clear all blocks for `seq` and return the freed physical block
79+
// IDs in the order they were appended. Caller passes these back to
80+
// BlockPool::free_block(). Used on task-lifecycle wipe (whole-seq
81+
// seq_rm with sentinel range).
82+
std::vector<uint32_t> clear_seq(llama_seq_id seq);
83+
84+
// Reset everything. Used by mt::clear() (whole-cache wipe). Caller
85+
// is responsible for releasing block IDs back to BlockPool first.
86+
void reset();
87+
88+
uint32_t block_size() const { return block_size_; }
89+
uint32_t max_seqs() const { return max_seqs_; }
90+
91+
private:
92+
// table_[seq][logical_idx] = physical_block_id
93+
std::vector<std::vector<uint32_t>> table_;
94+
uint32_t max_seqs_ = 0;
95+
uint32_t block_size_ = 0;
96+
};
97+
98+
} // namespace mt

0 commit comments

Comments
 (0)