server: save the draft state in the slot file (#30574)

The slot file held the target context and the context checkpoints,
but never the draft context, so a restored slot drafted with an
empty or stale draft KV until the cache refilled.

The file now ends with the live draft state, the full draft context
and the speculative state, in the same appendix style as the
checkpoints. A draft state that does not fit the draft context is
dropped and the draft starts empty, while older files and older
servers keep working.

[no release]
This commit is contained in:
Pascal authored and GitHub committed 2026-10-15 16:24:19 +03:00
1 parent b0d239164b
commit ea736c1d5c
2 files changed
+139 -2

No files matched your search

+90 -2
View File
@@ -2736,6 +2736,87 @@ private:
return n_read;
}
// the live draft state follows the checkpoints: the full draft context and the speculative state
// the llama state payload holds the target context only
static constexpr uint32_t SLOT_DRAFT_MAGIC = 0x54464453; // "SDFT"
static constexpr uint32_t SLOT_DRAFT_VERSION = 1;
// returns false if the draft state could not be written completely
bool save_slot_draft(const std::string & filepath, const server_slot & slot, size_t & n_written) const {
n_written = 0;
std::vector<uint8_t> data_dft;
std::vector<uint8_t> data_spec;
if (ctx_dft != nullptr) {
data_dft.resize(llama_state_seq_get_size_ext(ctx_dft, slot.id, LLAMA_STATE_SEQ_FLAGS_NONE));
llama_state_seq_get_data_ext(ctx_dft, data_dft.data(), data_dft.size(), slot.id, LLAMA_STATE_SEQ_FLAGS_NONE);
}
common_speculative_get_state(spec.get(), slot.id, data_spec);
if (data_dft.empty() && data_spec.empty()) {
return true;
}
std::ofstream ofs(std::filesystem::u8path(filepath), std::ios::binary | std::ios::app);
if (!ofs) {
SLT_WRN(slot, "failed to append the draft state to '%s'\n", filepath.c_str());
return false;
}
const uint32_t magic = SLOT_DRAFT_MAGIC;
const uint32_t version = SLOT_DRAFT_VERSION;
ckpt_write(ofs, &magic, sizeof(magic), n_written);
ckpt_write(ofs, &version, sizeof(version), n_written);
ckpt_write_buf(ofs, data_dft, n_written);
ckpt_write_buf(ofs, data_spec, n_written);
ofs.flush();
if (!ofs) {
SLT_WRN(slot, "failed to append the draft state to '%s' - the draft state is incomplete\n", filepath.c_str());
return false;
}
SLT_INF(slot, "appended the draft state (%.3f MiB) to '%s'\n", (float) n_written / 1024 / 1024, filepath.c_str());
return true;
}
// returns the number of bytes consumed, 0 if there is no usable draft state
// the draft starts empty unless the file holds a draft state that fits the draft context
size_t load_slot_draft(const std::string & filepath, size_t offset, server_slot & slot) const {
if (ctx_dft != nullptr) {
llama_memory_seq_rm(llama_get_memory(ctx_dft), slot.id, -1, -1);
}
std::ifstream ifs(std::filesystem::u8path(filepath), std::ios::binary | std::ios::ate);
const size_t file_size = ifs ? (size_t) ifs.tellg() : 0;
if (!ifs || file_size < offset || !ifs.seekg(offset)) {
return 0;
}
const size_t n_avail = file_size - offset;
size_t n_read = 0;
uint32_t magic = 0;
uint32_t version = 0;
if (!ckpt_read(ifs, &magic, sizeof(magic), n_read) || magic != SLOT_DRAFT_MAGIC) {
return 0;
}
std::vector<uint8_t> data_dft;
std::vector<uint8_t> data_spec;
if (!ckpt_read(ifs, &version, sizeof(version), n_read) || version != SLOT_DRAFT_VERSION ||
!ckpt_read_buf(ifs, data_dft, n_avail, n_read) ||
!ckpt_read_buf(ifs, data_spec, n_avail, n_read)) {
SLT_WRN(slot, "invalid draft state in '%s' - ignored\n", filepath.c_str());
return 0;
}
if (!data_dft.empty()) {
if (ctx_dft == nullptr ||
llama_state_seq_set_data_ext(ctx_dft, data_dft.data(), data_dft.size(), slot.id, LLAMA_STATE_SEQ_FLAGS_NONE) != data_dft.size()) {
if (ctx_dft != nullptr) {
llama_memory_seq_rm(llama_get_memory(ctx_dft), slot.id, -1, -1);
}
SLT_WRN(slot, "draft state in '%s' does not match the draft context - dropped\n", filepath.c_str());
return n_read;
}
}
if (!data_spec.empty()) {
common_speculative_set_state(spec.get(), slot.id, data_spec);
}
SLT_INF(slot, "restored the draft state (%.3f MiB) from '%s'\n", (float) n_read / 1024 / 1024, filepath.c_str());
return n_read;
}
// returns false to decline the task, it is offered again after the decode is done
bool process_single_task(server_task && task, bool is_yielding) {
// while yielding, an encode / decode is running and only reading the server state is safe
@@ -2951,6 +3032,12 @@ private:
break;
}
size_t nwrite_dft = 0;
if (!save_slot_draft(filepath, *slot, nwrite_dft)) {
send_error(task, "Unable to save slot: incomplete draft state", ERROR_TYPE_SERVER);
break;
}
const int64_t t_end = ggml_time_us();
const double t_save_ms = (t_end - t_start) / 1000.0;
@@ -2960,7 +3047,7 @@ private:
res->filename = filename;
res->is_save = true;
res->n_tokens = slot->prompt.tokens.size();
res->n_bytes = nwrite + nwrite_ckpt;
res->n_bytes = nwrite + nwrite_ckpt + nwrite_dft;
res->t_ms = t_save_ms;
queue_results.send(std::move(res));
} break;
@@ -3018,6 +3105,7 @@ private:
// nread is the end offset of the llama state payload within the file
const size_t nread_ckpt = load_slot_checkpoints(filepath, nread, *slot);
const size_t nread_dft = load_slot_draft(filepath, nread + nread_ckpt, *slot);
const int64_t t_end = ggml_time_us();
const double t_restore_ms = (t_end - t_start) / 1000.0;
@@ -3028,7 +3116,7 @@ private:
res->filename = filename;
res->is_save = false;
res->n_tokens = slot->prompt.tokens.size();
res->n_bytes = nread + nread_ckpt;
res->n_bytes = nread + nread_ckpt + nread_dft;
res->t_ms = t_restore_ms;
queue_results.send(std::move(res));
} break;
+49
View File
@@ -132,6 +132,55 @@ def test_slot_restore_legacy_token_list():
assert res.body["timings"]["prompt_n"] == 6 # only the different part is processed
# the slot file carries the live draft state: a restored slot drafts like a slot that was never saved
def test_slot_restore_draft_state():
global server
server.model_draft_hf_repo = server.model_hf_repo
server.spec_type = "draft-simple"
server.start()
prompt = "What is the capital of France?"
turn = {}
for id_slot in [0, 1]:
res = server.make_request("POST", "/completion", data={
"prompt": prompt,
"n_predict": 16,
"id_slot": id_slot,
"cache_prompt": True,
})
assert res.status_code == 200
turn[id_slot] = res.body["content"]
assert turn[0] == turn[1]
res = server.make_request("POST", "/slots/0?action=save", data={
"filename": "draft_slot0.bin",
})
assert res.status_code == 200
res = server.make_request("POST", "/slots/0?action=erase")
assert res.status_code == 200
res = server.make_request("POST", "/slots/0?action=restore", data={
"filename": "draft_slot0.bin",
})
assert res.status_code == 200
timings = {}
for id_slot in [0, 1]:
res = server.make_request("POST", "/completion", data={
"prompt": prompt + turn[id_slot] + " And",
"n_predict": 32,
"id_slot": id_slot,
"cache_prompt": True,
})
assert res.status_code == 200
timings[id_slot] = res.body["timings"]
assert timings[0]["prompt_n"] == timings[1]["prompt_n"]
assert timings[0]["draft_n_accepted"] > 0
assert timings[0]["draft_n"] == timings[1]["draft_n"]
assert timings[0]["draft_n_accepted"] == timings[1]["draft_n_accepted"]
def test_slot_erase():
global server