Skip to content

Commit a52ee45

Browse files
committed
feat: restore D2H offload collection.
1 parent 53b3006 commit a52ee45

9 files changed

Lines changed: 189 additions & 36 deletions

xllm/core/framework/block/hierarchy_block_manager_pool.cpp

Lines changed: 112 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -54,21 +54,101 @@ HierarchyBlockManagerPool::HierarchyBlockManagerPool(
5454
void HierarchyBlockManagerPool::deallocate(Sequence* sequence) {
5555
DCHECK(sequence != nullptr);
5656
int32_t dp_rank = BlockManagerPool::get_dp_rank(sequence);
57+
// Publish device KV blocks into the device prefix cache first, so that
58+
// offload-eligible blocks reach ref_count == 2 (held only by the sequence
59+
// state and the prefix-cache node). deallocate_for_sequence below re-runs
60+
// this cache step; it is idempotent (hashes recomputed from token ids find
61+
// the nodes inserted here), so moved-out blocks do not corrupt the cache.
5762
BlockManagerPool::cache(sequence);
5863

59-
// Release host blocks if any
64+
collect_offload_pairs(sequence, dp_rank);
65+
66+
// Release the host blocks still held by the sequence. Blocks moved into the
67+
// offload queue are now invalid in this vector and are skipped by deallocate;
68+
// their host ids stay reserved (held by the queue) until the D2H copy
69+
// completes and the offload callback caches + frees them.
6070
auto host_blocks = sequence->host_kv_state().blocks(BlockType::KV);
6171
if (!host_blocks.empty()) {
6272
host_block_managers_[dp_rank]->deallocate(host_blocks);
6373
}
6474

65-
// Release device blocks via the composite (includes prefix cache flush)
75+
// Release device blocks via the composite (includes prefix cache flush).
76+
// Offloaded device blocks were moved out above, so the KV leaf skips them;
77+
// the offload callback releases them once the copy is done.
6678
auto* composite =
6779
static_cast<CompositeBlockManager*>(block_managers_[dp_rank].get());
6880
composite->deallocate_for_sequence(sequence);
6981
sequence->reset();
7082
}
7183

84+
void HierarchyBlockManagerPool::collect_offload_pairs(Sequence* sequence,
85+
int32_t dp_rank) {
86+
if (!options_.enable_prefix_cache()) {
87+
return;
88+
}
89+
90+
std::vector<Block>* device_blocks =
91+
sequence->kv_state().mutable_blocks(BlockType::KV);
92+
std::vector<Block>* host_blocks =
93+
sequence->host_kv_state().mutable_blocks(BlockType::KV);
94+
if (device_blocks == nullptr || device_blocks->empty()) {
95+
return;
96+
}
97+
98+
const size_t block_size = options_.block_size();
99+
const size_t cached_host_block_num =
100+
sequence->host_kv_state().kv_cache_tokens_num() / block_size;
101+
const size_t cached_device_block_num =
102+
sequence->kv_state().kv_cache_tokens_num() / block_size;
103+
104+
const size_t host_block_num =
105+
host_blocks == nullptr ? 0 : host_blocks->size();
106+
// Host already holds at least as many blocks as the device computed: nothing
107+
// new to offload.
108+
if (host_block_num >= device_blocks->size()) {
109+
return;
110+
}
111+
112+
// Allocate the host blocks needed to receive the device blocks that have no
113+
// host counterpart yet.
114+
const size_t needed_block_num = cached_device_block_num > host_block_num
115+
? cached_device_block_num - host_block_num
116+
: 0;
117+
if (needed_block_num != 0) {
118+
std::vector<Block> new_host_blocks =
119+
host_block_managers_[dp_rank]->allocate(needed_block_num);
120+
if (new_host_blocks.size() != needed_block_num) {
121+
// Host pool exhausted; skip offload this round rather than partially
122+
// copy.
123+
return;
124+
}
125+
sequence->add_host_blocks(BlockType::KV, new_host_blocks);
126+
host_blocks = sequence->host_kv_state().mutable_blocks(BlockType::KV);
127+
}
128+
if (host_blocks == nullptr) {
129+
return;
130+
}
131+
132+
// Only offload blocks that are fully computed on device. In-batch prefix
133+
// cache insertion may register blocks before they are computed, so bound the
134+
// offload range by cached_device_block_num to avoid copying uncomputed data.
135+
const size_t offload_end_block_num = std::min(
136+
{cached_device_block_num, host_blocks->size(), device_blocks->size()});
137+
for (size_t i = cached_host_block_num; i < offload_end_block_num; i++) {
138+
// ref_count == 2 means the block is held only by this sequence and the
139+
// prefix-cache node, i.e. it is uniquely owned and safe to offload. Beam
140+
// forks (shared blocks) have ref_count > 2 and are skipped.
141+
if (device_blocks->at(i).ref_count() != 2) {
142+
continue;
143+
}
144+
host_blocks->at(i).set_hash_value(
145+
device_blocks->at(i).get_immutable_hash_value());
146+
auto block_pair = std::make_shared<OffloadBlockPair>(
147+
std::move(device_blocks->at(i)), std::move(host_blocks->at(i)));
148+
offload_block_pair_queues_[dp_rank].enqueue(std::move(block_pair));
149+
}
150+
}
151+
72152
bool HierarchyBlockManagerPool::allocate(Sequence* sequence,
73153
size_t num_tokens,
74154
size_t max_copy_in_blocks_num) {
@@ -97,9 +177,20 @@ bool HierarchyBlockManagerPool::allocate(Sequence* sequence,
97177
}
98178
auto hbm_blocks = sequence->kv_state().blocks(BlockType::KV);
99179
auto host_blocks = sequence->host_kv_state().blocks(BlockType::KV);
100-
for (size_t i = hbm_cache_token_num / options_.block_size();
101-
i <
102-
max_copy_in_blocks_num + (hbm_cache_token_num / options_.block_size());
180+
// H2D copies host block i -> device block i, so i must index both vectors.
181+
// The host prefix match (host_cache_token_num) is computed over the full
182+
// prompt and can exceed the device blocks allocated for this (possibly
183+
// chunked) num_tokens, so clamp the copy range to the blocks that actually
184+
// exist on both sides to avoid out-of-bounds reads.
185+
const size_t hbm_block_begin = hbm_cache_token_num / options_.block_size();
186+
const size_t copy_block_limit =
187+
std::min(hbm_blocks.size(), host_blocks.size());
188+
if (hbm_block_begin + max_copy_in_blocks_num > copy_block_limit) {
189+
max_copy_in_blocks_num = copy_block_limit > hbm_block_begin
190+
? copy_block_limit - hbm_block_begin
191+
: 0;
192+
}
193+
for (size_t i = hbm_block_begin; i < max_copy_in_blocks_num + hbm_block_begin;
103194
i++) {
104195
load_block_transfer_infos_[dp_rank].emplace_back(
105196
BlockTransferInfo(host_blocks[i].id(),
@@ -111,9 +202,7 @@ bool HierarchyBlockManagerPool::allocate(Sequence* sequence,
111202
size_t target_hbm_cache_token_num =
112203
max_copy_in_blocks_num == 0
113204
? hbm_cache_token_num
114-
: (max_copy_in_blocks_num +
115-
(hbm_cache_token_num / options_.block_size())) *
116-
options_.block_size();
205+
: (max_copy_in_blocks_num + hbm_block_begin) * options_.block_size();
117206

118207
sequence->kv_state().incr_kv_cache_tokens_num(target_hbm_cache_token_num -
119208
hbm_cache_token_num);
@@ -139,16 +228,27 @@ bool HierarchyBlockManagerPool::allocate(Sequence* sequence,
139228
auto hbm_blocks = sequence->kv_state().blocks(BlockType::KV);
140229
auto host_blocks = sequence->host_kv_state().blocks(BlockType::KV);
141230

142-
for (size_t i = hbm_cache_token_num / options_.block_size();
143-
i < host_cache_token_num / options_.block_size();
144-
i++) {
231+
// H2D copies host block i -> device block i. host_cache_token_num is the
232+
// host prefix match over the full prompt and can exceed the device blocks
233+
// allocated for this (chunked) num_tokens, so clamp to the blocks present
234+
// on both sides to avoid out-of-bounds reads on hbm_blocks.
235+
const size_t copy_block_end =
236+
std::min({host_cache_token_num / options_.block_size(),
237+
hbm_blocks.size(),
238+
host_blocks.size()});
239+
const size_t hbm_block_begin = hbm_cache_token_num / options_.block_size();
240+
for (size_t i = hbm_block_begin; i < copy_block_end; i++) {
145241
load_block_transfer_infos_[dp_rank].emplace_back(
146242
BlockTransferInfo(host_blocks[i].id(),
147243
hbm_blocks[i].id(),
148244
host_blocks[i].get_immutable_hash_value(),
149245
TransferType::H2D));
150246
}
151-
sequence->kv_state().incr_kv_cache_tokens_num(host_cache_token_num -
247+
const size_t target_hbm_cache_token_num =
248+
copy_block_end > hbm_block_begin
249+
? copy_block_end * options_.block_size()
250+
: hbm_cache_token_num;
251+
sequence->kv_state().incr_kv_cache_tokens_num(target_hbm_cache_token_num -
152252
hbm_cache_token_num);
153253
}
154254
return true;

xllm/core/framework/block/hierarchy_block_manager_pool.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ class HierarchyBlockManagerPool : public BlockManagerPool {
6868

6969
private:
7070
void allocate_host_shared(Sequence* sequence);
71+
// Move offload-eligible device/host KV block pairs out of the sequence into
72+
// offload_block_pair_queues_[dp_rank] for the next D2H transfer.
73+
void collect_offload_pairs(Sequence* sequence, int32_t dp_rank);
7174

7275
private:
7376
Engine* engine_;

xllm/core/framework/kv_cache_transfer/hierarchy_kv_cache_transfer.cpp

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ namespace {
2424

2525
constexpr uint32_t TIMEOUT_MS = 60000;
2626

27+
// Streams reserved for concurrent D2H offload callers. D2H runs synchronously
28+
// on the RemoteWorker copy threadpool (4 threads, see remote_worker.h); reserve
29+
// one stream per such thread so concurrent offloads never block on the stream
30+
// queue.
31+
constexpr size_t kOffloadStreamCount = 4;
32+
2733
std::vector<HierarchyKVCacheTransfer::LayerBatchRange> build_layer_batch_ranges(
2834
int64_t num_layers,
2935
uint32_t requested_batches) {
@@ -133,13 +139,12 @@ HierarchyKVCacheTransfer::HierarchyKVCacheTransfer(
133139
/*init_func=*/[this]() mutable { device_.set_device(); },
134140
/*cpu_binding=*/false,
135141
/*pool_name=*/"HierarchyKVCacheTransfer.load");
136-
offload_threadpool_ = std::make_unique<ThreadPool>(
137-
/*num_threads=*/5,
138-
/*init_func=*/[this]() mutable { device_.set_device(); },
139-
/*cpu_binding=*/false,
140-
/*pool_name=*/"HierarchyKVCacheTransfer.offload");
141-
for (int i = 0; i < load_threadpool_->size() + offload_threadpool_->size();
142-
++i) {
142+
// D2H offload runs synchronously on the caller (RemoteWorker copy thread) so
143+
// its copied-block count can be returned to the scheduler; it is not posted
144+
// to a local pool. Size the shared stream pool to cover the H2D load threads
145+
// plus the concurrent D2H callers.
146+
const size_t num_streams = load_threadpool_->size() + kOffloadStreamCount;
147+
for (size_t i = 0; i < num_streams; ++i) {
143148
copy_stream_.enqueue(device_.get_stream_from_pool(TIMEOUT_MS));
144149
}
145150

@@ -300,11 +305,27 @@ uint32_t HierarchyKVCacheTransfer::transfer_kv_blocks(
300305
case TransferType::D2H2G:
301306
return offload(block_transfer_info);
302307
case TransferType::H2D: {
308+
// Create and register the synchronizer synchronously, before scheduling
309+
// the async copy. The scheduler issues transfer_kv_blocks before step, so
310+
// by the time this returns the entry is in the map and the forward path's
311+
// set_layer_synchronizer is guaranteed to find it. Registering inside the
312+
// async load_from_host would race the forward lookup (it could miss the
313+
// entry, skip the wait, and read KV before the H2D copy completed).
314+
auto synchronizer = create_layer_synchronizer(
315+
static_cast<int64_t>(layer_batch_ranges_.size()));
316+
if (synchronizer == nullptr) {
317+
LOG(ERROR) << "Failed to create layer synchronizer.";
318+
return 0;
319+
}
320+
{
321+
std::lock_guard<std::mutex> lock(mutex_);
322+
layer_wise_load_synchronizer_[batch_id] = synchronizer;
323+
}
303324
load_threadpool_->schedule(
304325
[this,
305-
batch_id,
326+
synchronizer,
306327
block_transfer_info = std::move(block_transfer_info)]() mutable {
307-
load_from_host(batch_id, block_transfer_info);
328+
load_from_host(synchronizer, block_transfer_info);
308329
});
309330
return 0;
310331
}
@@ -369,23 +390,13 @@ bool HierarchyKVCacheTransfer::offload_to_host(
369390
}
370391

371392
bool HierarchyKVCacheTransfer::load_from_host(
372-
uint64_t batch_id,
393+
std::shared_ptr<LayerSynchronizer> synchronizer,
373394
const std::vector<BlockTransferInfo>& block_transfer_info) {
374395
if (block_transfer_info.empty()) {
375396
return true;
376397
}
377398

378-
auto synchronizer = create_layer_synchronizer(
379-
static_cast<int64_t>(layer_batch_ranges_.size()));
380-
if (synchronizer == nullptr) {
381-
LOG(ERROR) << "Failed to create layer synchronizer.";
382-
return false;
383-
}
384-
{
385-
std::lock_guard<std::mutex> lock(mutex_);
386-
layer_wise_load_synchronizer_[batch_id] = synchronizer;
387-
}
388-
399+
CHECK(synchronizer != nullptr) << "layer synchronizer must not be null.";
389400
CHECK(batch_memcpy_ != nullptr) << "batch memcpy must be initialized.";
390401

391402
std::unique_ptr<Stream> stream;
@@ -416,6 +427,12 @@ bool HierarchyKVCacheTransfer::load_from_host(
416427
}
417428

418429
copy_stream_.enqueue(std::move(stream));
430+
// On failure some ranges were never recorded; abort the synchronizer so a
431+
// forward thread spinning on those layers unblocks and reports failure
432+
// (aborting the forward) instead of hanging or reading uncopied KV cache.
433+
if (!success) {
434+
synchronizer->abort();
435+
}
419436
return success;
420437
}
421438

xllm/core/framework/kv_cache_transfer/hierarchy_kv_cache_transfer.h

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,15 +96,14 @@ class HierarchyKVCacheTransfer {
9696
uint32_t offload(const std::vector<BlockTransferInfo>& block_transfer_info);
9797
bool offload_to_host(Slice<BlockTransferInfo>& block_transfer_info);
9898
bool load_from_host(
99-
uint64_t batch_id,
99+
std::shared_ptr<LayerSynchronizer> synchronizer,
100100
const std::vector<BlockTransferInfo>& block_transfer_info);
101101

102102
private:
103103
Options options_;
104104
Device device_;
105105

106106
std::unique_ptr<ThreadPool> load_threadpool_;
107-
std::unique_ptr<ThreadPool> offload_threadpool_;
108107
moodycamel::BlockingConcurrentQueue<std::unique_ptr<Stream>> copy_stream_;
109108

110109
std::vector<xllm::KVCache>* kv_caches_ptr_ = nullptr;

xllm/core/platform/layer_synchronizer.cpp

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ class NPULayerSynchronizerAdapter final : public LayerSynchronizer {
4545
return true;
4646
}
4747

48+
void abort() override { impl_.abort(); }
49+
4850
uint32_t size() const override {
4951
return const_cast<NPULayerSynchronizerImpl&>(impl_).get_event_size();
5052
}

xllm/core/platform/layer_synchronizer.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ class LayerSynchronizer {
2828

2929
virtual bool synchronize_layer(int64_t layer_index) = 0;
3030
virtual bool record_stream(int64_t layer_index, Stream* stream) = 0;
31+
// Force every layer's wait to unblock and report failure. Called when a copy
32+
// fails so a forward thread spinning in synchronize_layer does not hang
33+
// forever; synchronize_layer returns false after abort so the caller aborts
34+
// the forward instead of reading not-yet-copied KV cache.
35+
virtual void abort() = 0;
3136
virtual uint32_t size() const = 0;
3237
};
3338

xllm/core/platform/npu/npu_layer_synchronizer.cpp

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,14 @@ std::atomic<bool>* NPULayerSynchronizerImpl::get_event_flag(
4848
}
4949

5050
bool NPULayerSynchronizerImpl::synchronize_layer(const int64_t layer_index) {
51-
while (!event_record_flags_[layer_index].load(std::memory_order_acquire));
51+
while (!event_record_flags_[layer_index].load(std::memory_order_acquire)) {
52+
if (aborted_.load(std::memory_order_acquire)) {
53+
return false;
54+
}
55+
}
56+
if (aborted_.load(std::memory_order_acquire)) {
57+
return false;
58+
}
5259
auto ret = aclrtSynchronizeEventWithTimeout(events_[layer_index], timeout_);
5360
if (ret != ACL_SUCCESS) {
5461
LOG(ERROR) << "Synchronize event failed: " << ret;
@@ -69,4 +76,13 @@ bool NPULayerSynchronizerImpl::record_event(const int64_t layer_index,
6976
return true;
7077
}
7178

79+
void NPULayerSynchronizerImpl::abort() {
80+
// Set aborted_ before raising the flags so any thread released from the spin
81+
// observes the abort and reports failure rather than syncing a stale event.
82+
aborted_.store(true, std::memory_order_release);
83+
for (size_t i = 0; i < event_record_flags_.size(); ++i) {
84+
event_record_flags_[i].store(true, std::memory_order_release);
85+
}
86+
}
87+
7288
} // namespace xllm

xllm/core/platform/npu/npu_layer_synchronizer.h

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,14 @@ class NPULayerSynchronizerImpl {
3232
std::atomic<bool>* get_event_flag(const int64_t layer_index);
3333
bool synchronize_layer(const int64_t layer_index);
3434
bool record_event(const int64_t layer_index, const int32_t device_index);
35+
// Unblock all pending synchronize_layer spins and make them report failure.
36+
void abort();
3537
uint32_t get_event_size() { return events_.size(); };
3638

3739
private:
3840
std::vector<aclrtEvent> events_;
3941
std::vector<std::atomic<bool>> event_record_flags_;
42+
std::atomic<bool> aborted_{false};
4043
const int32_t timeout_;
4144
};
4245

xllm/core/scheduler/chunked_prefill_scheduler.cpp

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -808,6 +808,14 @@ std::vector<Batch> ChunkedPrefillScheduler::prepare_batch() {
808808
if (!is_batches_empty) {
809809
// only update the scheduling latency when there are requests to process
810810
COUNTER_ADD(scheduling_latency_seconds, timer.elapsed_seconds());
811+
// Dispatch the per-step KV transfers collected during allocate/deallocate:
812+
// H2D loads recorded for host prefix-cache hits, and D2H offloads queued
813+
// when sequences were deallocated. HierarchyBlockManagerPool overrides
814+
// these; the default BlockManagerPool no-ops. Without this call the offload
815+
// queue is never drained and its device/host blocks leak.
816+
kv_cache_manager_->transfer_blocks(batches);
817+
} else {
818+
kv_cache_manager_->transfer_blocks();
811819
}
812820

813821
GAUGE_SET(num_pending_requests,

0 commit comments

Comments
 (0)