Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions server/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -747,6 +747,7 @@ add_library(luce_common STATIC
src/server/json_schema_grammar.cpp
src/server/tool_hint.cpp
src/server/tool_memory.cpp
src/server/pflash_unified.cpp
src/server/response_error.cpp
src/server/sse_emitter.cpp
src/server/prefix_cache.cpp
Expand Down Expand Up @@ -2482,6 +2483,7 @@ if(LUCE_TESTS)
test/pflash/test_pflash_drafter_ipc.cpp
test/pflash/test_pflash_selection.cpp
test/pflash/test_pflash_fold.cpp
test/pflash/test_pflash_unified.cpp
test/pflash/test_pflash_request_probe.cpp
test/pflash/test_pflash_request_stage2.cpp
test/pflash/test_pflash_query_candidates.cpp
Expand Down
48 changes: 47 additions & 1 deletion server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -390,7 +390,8 @@ the whole request's device footprint. `/status/json` reports
With a trained scoring head (`PFLASH_SCORING_HEAD_GGUF`), every PFlash setting
left unset takes the measured release profile: threshold 16384, the curve
`16384:0.315 32768:0.270 65536:0.270 120000:0.225`, budget selection over probe
segments and the request-probe query. Without a head the built-in defaults
segments, the request-probe query and the unified view for chats and agent
sessions (`PFLASH_UNIFIED=1`, below). Without a head the built-in defaults
below apply. The [PFlash configuration reference](docs/PFLASH_CONFIGURATION.md)
lists both, the validated overrides and the active experiments.

Expand Down Expand Up @@ -507,6 +508,51 @@ drafter selection (up to 48 s more per follow-up in a 10-turn coding session on
an R9700; the session took 281 s against 258 s with full prefill). Chats
without tools are not affected.

`PFLASH_UNIFIED=1` (on in the release profile, which also gives it the
strict-selection environment and `PFLASH_ADMISSION=cache` it needs;
`PFLASH_UNIFIED=0` turns it off) serves chat and agent sessions with
one mechanism, the unified view. Only the newest turn is ever paged: the user
messages since the last reply, or the tool results of the last tool call. A
message there with at least `--prefill-threshold` droppable tokens that can be
cut into lines becomes a region, and only what the drafter selection keeps of
it is served, in whole lines, with a note saying the content was shortened.
Tool outputs are paged for the tools in `PFLASH_REGION_TOOLS` (default `*`,
every tool; a comma-separated list names tools; empty pages none). Everything
before the newest turn is the view already served and stays cached: the
served prompt only grows by appending, and is never compressed again. A new
user question recalls, as excerpts at the start of its message, the lines of
earlier regions it needs that the view does not hold. Recall is gated: the
drafter scores the context for the question, and when the units the view does
not hold carry less than `PFLASH_RECALL_GATE` of that score mass (default
0.075, frozen from a development replay as the largest gate whose answers stay
within 3 of ungated recall; `off` recalls by the regions' caps alone) nothing
is appended; otherwise the best unheld units are recalled until what stays
unheld is below the gate. `PFLASH_RECALL=0` turns recall off. The request
probe's pins (the question it finds and its recency pin) stay whole, as in the
chat view. When the drafter pass fails, the newest content is served verbatim
and the view continues; `PFLASH_FAIL_CLOSED=1` refuses the request instead. A
re-read of a paged tool output is served verbatim. A view that would no longer fit the
context starts over from the raw prompt.

Requests the unified view does not take are handed back to the chat view
described above and served exactly as without the flag: a request without a
messages array, a remote drafter, a turn structure or generation prompt it
cannot read, and a conversation it would start (no stored view continues it)
whose uncached history before the newest turn, with newest content that
cannot be cut into lines, reaches `--prefill-threshold` (reason `uncached`,
or `reset_uncached` when a stored view could not continue), and a selection
that gives no per-candidate scores (`unscored_selection`: the legacy or split
scorer, `PFLASH_QWEN35_LEGACY_SCORER=1`, or the fold strategy). A conversation
handed back, or served raw after a PFlash failure, loses its unified views, so
no later turn continues one. Such a conversation stays with the chat view while its history is
uncached. The view trace records `pflash.unified = {decision: handback,
reason}` for these requests. Without a strict-selection environment the
unified view serves nothing (startup says so) and every request takes the
chat view.
`PFLASH_AGENT_PASTE`, `PFLASH_ARRIVE`, `PFLASH_CHAT_RECALL` and the other
chat-view knobs apply only to handed-back requests. `PFLASH_RECALL_SOURCE`
was removed.

Every turn of a multi-turn chat keeps its role header, and user turns (the
latest included) and assistant answers up to `PFLASH_CHAT_SKELETON_TOKENS`
(default 256 drafter tokens; 0 keeps headers only) stay whole: the
Expand Down
3 changes: 3 additions & 0 deletions server/docs/PFLASH_CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@ allowed below. Valid integer overrides retain their existing upper clamps.
| `--prefill-keep-ratio` | `0.05` | curve | Flat ratio; `--prefill-curve tokens:ratio ...` overrides it. The release curve is `16384:0.315 32768:0.270 65536:0.270 120000:0.225`; either flag replaces it |
| `--prefill-skip-park` | off | | Keep the target resident while the drafter scores; see the release profile |
| `PFLASH_ADMISSION` | `length` | `cache` | `length` or `cache`; cache admission accounts for restorable target KV |
| `PFLASH_UNIFIED` | `0` | `1` | One view and recall mechanism for chats and agent sessions: only the newest turn's long uncached content is paged, in whole lines. Needs a strict-selection environment; the chat-view settings below apply only to the requests it hands back. See the [server README](../README.md#pflash) |
| `PFLASH_REGION_TOOLS` | `*` | | Under the unified view: tools whose outputs may be paged; a comma-separated list names tools, empty pages none |
| `PFLASH_RECALL`, `PFLASH_RECALL_GATE` | `1`, `0.075` | | Under the unified view: recall for a new question, and the share of score mass outside the view below which nothing is recalled (`off` recalls up to the regions' caps) |
| `PFLASH_AGENT_PASTE` | `0` | | With cache admission in auto mode, `1` permits a large newest user paste in a tool session; earlier history stays raw |
| `PFLASH_CHAT_VIEW`, `PFLASH_CHAT_RECALL` | `1`, `1` | | Continue the served view; recall omitted material for a new question |
| `PFLASH_CHAT_COMPRESS_NEW_TOKENS` | `16384` | | Integer >= 1; smaller additions stay verbatim; capped at 2^30 |
Expand Down
41 changes: 33 additions & 8 deletions server/docs/PREFIX_CACHE.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,9 +150,28 @@ When a session's state is written, its shallower files on disk are deleted,
except a head at or below `--kv-cache-cold-max` tokens or, without one, the
shallowest (a longer head).

Compressed prompts (PFlash, FlowKV) are not raw history and never get a disk
key; a request whose disk scope is not `full` gives its snapshots no key
either.
Compressed prompts (PFlash, FlowKV) are not raw history and get no disk key,
except a prompt served from a PFlash unified view (`PFLASH_UNIFIED=1`): its
key is the tokens it served, and the view itself is kept on disk too, so the
conversation's next request serves the same tokens again and restores them. A
request whose disk scope is not `full` gives its snapshots no key either.

**Shortened conversations.** With the disk cache on, the unified view keeps its
views (what each shortened conversation served, and which lines it left out) in
`<kv-cache-dir>/.pflash-views`: a view is written when it leaves the RAM view
store and at shutdown, and read when a request finds no view in RAM, so neither
a restart nor an eviction turns a shortened conversation cold. One file per
view, a one-line header indexed at startup; at most 256 views, the least
recently written dropped first, and the cache's TTL applies, counted from a
view's last write and checked at startup and at every lookup (a view in use
returns to RAM and is written again when it leaves). Views written for another
model, template or context (the cache's identity salt) are never read. A view
file that does not load, or whose offsets and per-line and per-unit data do not
agree, is deleted and the request proceeds without it. A conversation served
raw after a PFlash failure, or handed to the chat view, loses its views in RAM
and on disk, so no later turn continues them.
The drafter's own scoring state stays in RAM only: after a restart, the first
question that needs recall scores the earlier messages again.

**How it is written.** Eviction hands the snapshot's buffer to the disk writer
without a copy (Qwen keeps snapshots in host memory; other backends copy to
Expand Down Expand Up @@ -217,11 +236,17 @@ them go through the same asynchronous writer.
the concurrent `qwen4exp` engine keeps its checkpoints outside the backend's
snapshot slots: both run without a disk cache (logged at startup).

**Follow-up: cache-aware admission.** Compression admission that counts the
cached prefix as warm (on the PFlash branches, not on `main`) consults RAM
only. `DiskPrefixCache::longest_prefix(prompt)` returns the deepest restorable
key length and KV position without reading a file, so admission can take the
max of the RAM and disk prefixes.
**Cache-aware admission.** PFlash's admission counts the deeper of the RAM
prefix snapshot and the deepest disk entry for the prompt
(`DiskPrefixCache::longest_prefix`, an index lookup that reads no file; ties
go to RAM), in the chat view (`PFLASH_ADMISSION=cache`) and in the unified view
(`PFLASH_UNIFIED=1`). A conversation restored after a restart, or written out
on eviction, is then cached history: the unified view serves it raw, from disk,
and pages only new long content, instead of handing a cold history to the chat
view. The disk counts only with `--kv-cache-dir` set and the request's disk
scope `full`. With the disk cache on, the chat view's admission record carries
`cached_source` (`ram`, `disk` or `none`) and the unified view's stats a
`cached` object with the tokens and source.

## Snapshot Memory Management

Expand Down
2 changes: 2 additions & 0 deletions server/src/common/model_backend.h
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,8 @@ struct ModelBackend {
// Strict selection with the head: every candidate's attention lift
// (mass per token relative to uniform); empty when unknown.
std::vector<std::pair<PFlashTokenSpan, double>> candidate_lifts;
// The request probe's own pins in that selection (pflash_last_probe_pins).
std::vector<PFlashTokenSpan> probe_pins;
int scorer_resume = -1;
int scorer_new_tokens = -1;
double scorer_forward_s = 0.0;
Expand Down
1 change: 1 addition & 0 deletions server/src/deepseek4/deepseek4_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5740,6 +5740,7 @@ std::vector<ModelBackend::CompressResult> DeepSeek4Backend::compress_batch(
for (const auto & candidate : pflash_last_candidate_lifts()) {
result.candidate_lifts.push_back({candidate.span, candidate.lift});
}
result.probe_pins = pflash_last_probe_pins();
}
}

Expand Down
10 changes: 10 additions & 0 deletions server/src/pflash/pflash_compress.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ namespace {
thread_local std::vector<PFlashTokenSpan> g_last_kept_spans;
thread_local PFlashScoringStats g_last_scoring_stats;
thread_local std::vector<PFlashCandidateLift> g_last_candidate_lifts;
thread_local std::vector<PFlashTokenSpan> g_last_probe_pins;
thread_local PFlashStage2Outcome g_last_stage2_outcome;
thread_local PFlashRelevanceSpread g_last_relevance_spread;
} // namespace
Expand Down Expand Up @@ -520,6 +521,14 @@ const std::vector<PFlashCandidateLift> & pflash_last_candidate_lifts() {
return g_last_candidate_lifts;
}

const std::vector<PFlashTokenSpan> & pflash_last_probe_pins() {
return g_last_probe_pins;
}

void pflash_set_probe_pins(std::vector<PFlashTokenSpan> pins) {
g_last_probe_pins = std::move(pins);
}

const PFlashScoringStats & pflash_last_scoring_stats() {
return g_last_scoring_stats;
}
Expand All @@ -536,6 +545,7 @@ void pflash_clear_kept_spans() {
g_last_kept_spans.clear();
g_last_scoring_stats = {};
g_last_candidate_lifts.clear();
g_last_probe_pins.clear();
g_last_stage2_outcome = {};
g_last_relevance_spread = {};
}
Expand Down
7 changes: 7 additions & 0 deletions server/src/pflash/pflash_compress.h
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,13 @@ struct PFlashCandidateLift {
};
const std::vector<PFlashCandidateLift> & pflash_last_candidate_lifts();

// The pins the request probe added to the last strict selection on this
// thread: the query tokens it picked and its recency pin (not the caller's
// pins, nor PFLASH_QUERY_PIN's top units). A caller that selects by the
// candidate lifts itself keeps them. Cleared with the kept spans.
const std::vector<PFlashTokenSpan> & pflash_last_probe_pins();
void pflash_set_probe_pins(std::vector<PFlashTokenSpan> pins);

// Relevance spread of one selection (PFLASH_FALLBACK_SPREAD): with
// p_i = s_i / sum(s) over every candidate's score (the trace's
// ``chunk_scores``), H = -sum_{p_i > 0} p_i ln p_i and k = the selected
Expand Down
5 changes: 5 additions & 0 deletions server/src/pflash/qwen35_drafter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3024,6 +3024,11 @@ static std::vector<int32_t> strict_score_impl(
request_trace.mandatory_tokens = charged(pins, segments);
request_trace.mandatory_tokens_base = request_trace.mandatory_tokens;
}
if (request_set) {
auto probe_pins = luce::pflash::pflash_request_token_spans(request_trace.set.tokens);
if (request_trace.recency_pin.begin >= 0) probe_pins.push_back(request_trace.recency_pin);
pflash_set_probe_pins(std::move(probe_pins));
}
std::string policy_note;
if (request_trace.policy_evaluated) {
char buf[256];
Expand Down
1 change: 1 addition & 0 deletions server/src/qwen35/qwen35_backend.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1509,6 +1509,7 @@ std::vector<ModelBackend::CompressResult> Qwen35Backend::compress_batch(
for (const auto & candidate : pflash_last_candidate_lifts()) {
result.candidate_lifts.push_back({candidate.span, candidate.lift});
}
result.probe_pins = pflash_last_probe_pins();
}
if (result.ok) {
std::fprintf(stderr, "[compress] %zu -> %zu tokens\n",
Expand Down
1 change: 1 addition & 0 deletions server/src/qwen35/qwen35_layer_split_adapter.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1416,6 +1416,7 @@ Qwen35LayerSplitAdapter::compress(const ModelBackend::CompressRequest & req) {
for (const auto & candidate : pflash_last_candidate_lifts()) {
result.candidate_lifts.push_back({candidate.span, candidate.lift});
}
result.probe_pins = pflash_last_probe_pins();
}
if (result.ok) {
std::fprintf(stderr, "[target-split][compress] %zu -> %zu tokens\n",
Expand Down
Loading
Loading