lihe07 commited on
Commit
80e7e3a
·
verified ·
1 Parent(s): 19886eb

span-02: single-source release folder (engine final path, soft blend)

Browse files
README.md CHANGED
@@ -159,7 +159,7 @@ The capacity errors carry the Decision Index capacity markers, so the DI client
159
  ## Serving settings (Decision Index submission)
160
  ```
161
  --max-len 16384 --budget-ms 0 --tau 0.8 --cap 1024 --think-ms 750 --think-cost think_cost.json
162
- --image-soft-tokens 560 --serial --no-prefix-cache --spec-max-branches 16
163
  ```
164
  * `--budget-ms 0`: there is no wall-clock limit; the clock is never read for thinking decisions.
165
  * `--think-ms 750 --think-cost think_cost.json`: deterministic thought budget. Each request's per-question thought
@@ -176,6 +176,10 @@ The capacity errors carry the Decision Index capacity markers, so the DI client
176
  * `--serial --no-prefix-cache --spec-max-branches 16`: each answer is a deterministic function of its request.
177
  It does not depend on load or on earlier requests (for up to 16 questions of one request thinking at once).
178
  `--serial` runs one request at a time; concurrent clients queue.
 
 
 
 
179
 
180
  ## Latency
181
  Measured with the submission settings on one RTX PRO 6000 Blackwell, one request at a time, as client wall time
@@ -204,6 +208,8 @@ Requests over these limits are rejected with the DI capacity markers. Nothing is
204
  * Context: the prompt tokens + 562 per image + the reserved thought tokens + 3 must fit in 16384 tokens. The
205
  reservation is the deterministic cap of a lone thinking question of that size (about 330 tokens for a short
206
  prompt, about 145 at 16k prompt tokens). That allows up to about 16240 prompt tokens without images.
 
 
207
  * Request body: at most 64 MB.
208
 
209
  ## License
 
159
  ## Serving settings (Decision Index submission)
160
  ```
161
  --max-len 16384 --budget-ms 0 --tau 0.8 --cap 1024 --think-ms 750 --think-cost think_cost.json
162
+ --image-soft-tokens 560 --serial --no-prefix-cache --spec-max-branches 16 --pages 12000 --kv-gb 24
163
  ```
164
  * `--budget-ms 0`: there is no wall-clock limit; the clock is never read for thinking decisions.
165
  * `--think-ms 750 --think-cost think_cost.json`: deterministic thought budget. Each request's per-question thought
 
176
  * `--serial --no-prefix-cache --spec-max-branches 16`: each answer is a deterministic function of its request.
177
  It does not depend on load or on earlier requests (for up to 16 questions of one request thinking at once).
178
  `--serial` runs one request at a time; concurrent clients queue.
179
+ * `--pages 12000 --kv-gb 24`: KV cache pools (full-attention layers 12000 x 16 = 192K tokens, 3.9 GB;
180
+ sliding-window layers get the rest of `--kv-gb 24`, ~20 GB), sized so that every Decision Index request fits on its own under `--serial` (the
181
+ largest needs about 3.7K full pages: many questions with long tails). The pool size does not change any answer.
182
+ The server uses about 41 GB of GPU memory.
183
 
184
  ## Latency
185
  Measured with the submission settings on one RTX PRO 6000 Blackwell, one request at a time, as client wall time
 
208
  * Context: the prompt tokens + 562 per image + the reserved thought tokens + 3 must fit in 16384 tokens. The
209
  reservation is the deterministic cap of a lone thinking question of that size (about 330 tokens for a short
210
  prompt, about 145 at 16k prompt tokens). That allows up to about 16240 prompt tokens without images.
211
+ * KV cache: a request whose prompt pages (shared prefix + every question's own tail) exceed the whole KV pool is
212
+ rejected with 422 "maximum context length"; none of the DI 0.3 public requests does with the pools above.
213
  * Request body: at most 64 MB.
214
 
215
  ## License
engine/crates/runtime/src/engine.rs CHANGED
@@ -883,6 +883,11 @@ impl Sched {
883
  let start_pages: Vec<usize> = (0..n).map(|b| sp_pages.max(matched[b].len())).collect();
884
  let need: usize = need_shared + (0..n).map(|b| total_pages[b] - start_pages[b]).sum::<usize>();
885
  let _ = r;
 
 
 
 
 
886
  // 3. allocate
887
  let alloc = match self.alloc(need) {
888
  Ok(a) => a,
@@ -899,6 +904,14 @@ impl Sched {
899
  let sw = self.swa.as_ref().unwrap();
900
  let bound = sw.wpages() + 2 * SWA_SLACK_PAGES + self.model.max_rows() / PAGE + 4;
901
  let p_req = need_shared.min(bound) + (0..n).map(|b| (total_pages[b] - start_pages[b]).min(bound)).sum::<usize>();
 
 
 
 
 
 
 
 
902
  if !self.sl_make_free(p_req) {
903
  for &p in &alloc {
904
  self.pool.decref(p);
 
883
  let start_pages: Vec<usize> = (0..n).map(|b| sp_pages.max(matched[b].len())).collect();
884
  let need: usize = need_shared + (0..n).map(|b| total_pages[b] - start_pages[b]).sum::<usize>();
885
  let _ = r;
886
+ // a request that needs more pages than the WHOLE pool can never be admitted (not a transient shortage): fail it as a capacity limit
887
+ if need > self.pool.rc.len() {
888
+ rollback(self, &matched, &nodes);
889
+ bail!("KV pool exhausted: KV cache capacity exceeded ({} pages needed, {} pages total)", need, self.pool.rc.len());
890
+ }
891
  // 3. allocate
892
  let alloc = match self.alloc(need) {
893
  Ok(a) => a,
 
904
  let sw = self.swa.as_ref().unwrap();
905
  let bound = sw.wpages() + 2 * SWA_SLACK_PAGES + self.model.max_rows() / PAGE + 4;
906
  let p_req = need_shared.min(bound) + (0..n).map(|b| (total_pages[b] - start_pages[b]).min(bound)).sum::<usize>();
907
+ if p_req > sw.n {
908
+ for &p in &alloc {
909
+ self.pool.decref(p);
910
+ }
911
+ rollback(self, &matched, &nodes);
912
+ let total = self.swa.as_ref().unwrap().n;
913
+ bail!("sliding-window KV pool exhausted: KV cache capacity exceeded ({} pages needed, {} pages total)", p_req, total);
914
+ }
915
  if !self.sl_make_free(p_req) {
916
  for &p in &alloc {
917
  self.pool.decref(p);
engine/crates/runtime/src/tests/engine.rs CHANGED
@@ -95,6 +95,8 @@ fn kv_exhaustion_is_an_error_not_a_panic_and_recovers() {
95
  let big = text_item("big", &toks(1, 400), &toks(2, 10), 2);
96
  let err = e.run(&[big], &[], &think_pol()).unwrap_err();
97
  assert!(format!("{err:#}").contains("KV pool exhausted"), "{err:#}");
 
 
98
  let small = text_item("s", &toks(1, 20), &toks(2, 5), 2);
99
  assert!(e.run(&[small], &[], &Policy { tau: 0.0, budget_ms: 0, ..Policy::default() }).is_ok());
100
  let st = e.stats();
@@ -113,6 +115,8 @@ fn decode_exhaustion_midway_fails_only_that_request() {
113
  match e.run(&[it], &[], &pol) {
114
  Err(err) => {
115
  assert!(format!("{err:#}").contains("KV pool exhausted"), "{err:#}");
 
 
116
  found = true;
117
  break;
118
  }
 
95
  let big = text_item("big", &toks(1, 400), &toks(2, 10), 2);
96
  let err = e.run(&[big], &[], &think_pol()).unwrap_err();
97
  assert!(format!("{err:#}").contains("KV pool exhausted"), "{err:#}");
98
+ // larger than the whole pool: reported as a capacity limit (server: 422 "maximum context length"), not a transient shortage
99
+ assert!(format!("{err:#}").contains("KV cache capacity exceeded"), "{err:#}");
100
  let small = text_item("s", &toks(1, 20), &toks(2, 5), 2);
101
  assert!(e.run(&[small], &[], &Policy { tau: 0.0, budget_ms: 0, ..Policy::default() }).is_ok());
102
  let st = e.stats();
 
115
  match e.run(&[it], &[], &pol) {
116
  Err(err) => {
117
  assert!(format!("{err:#}").contains("KV pool exhausted"), "{err:#}");
118
+ // the prompt fits the pool; running out while thinking is not the admission capacity error
119
+ assert!(!format!("{err:#}").contains("KV cache capacity exceeded"), "{err:#}");
120
  found = true;
121
  break;
122
  }
engine/crates/server/src/lib.rs CHANGED
@@ -239,14 +239,7 @@ async fn systemone(State(st): State<Arc<AppState>>, headers: HeaderMap, body: ax
239
  Ok(Ok(_)) => return err(ApiError::new(500, "inference failed: result count mismatch")),
240
  Ok(Err(e)) => {
241
  let m = format!("{e:#}");
242
- return err(if m.contains("cannot decode image") {
243
- ApiError::new(422, "cannot decode image as a base64 data URI")
244
- } else if m.contains("KV pool exhausted") {
245
- // transient overload (concurrent requests hold the KV cache): TypeSafe's 529, clients retry after a short delay
246
- ApiError::new(529, format!("overloaded, retry after a short delay ({m})"))
247
- } else {
248
- ApiError::new(500, format!("inference failed: {m}"))
249
- });
250
  }
251
  Err(e) => return err(ApiError::new(500, format!("inference failed: {e}"))),
252
  };
@@ -388,6 +381,9 @@ pub struct Args {
388
  /// hard row limit of one forward (workspace, ~0.45 MB per row); no-think prefill packs fill it
389
  #[arg(long, default_value_t = 8192)]
390
  pub max_rows: usize,
 
 
 
391
  /// KV memory budget (GB): the full-attention pool holds one max-length request plus 16K tokens, the sliding-window pool gets the rest
392
  #[arg(long, default_value_t = 9.0)]
393
  pub kv_gb: f64,
@@ -446,7 +442,7 @@ pub fn start_engine(a: &Args) -> anyhow::Result<EngineBackend> {
446
  // KV pools: bytes per 16-token page of the full-attention / sliding-window layers
447
  let pb = |k: LayerKind| -> usize { cfg.text.layers.iter().filter(|s| s.kind == k).map(|s| 2 * s.n_kv_heads * 16 * s.head_dim * 2).sum() };
448
  let (full_pb, sl_pb) = (pb(LayerKind::Full), pb(LayerKind::Sliding));
449
- let num_pages = 2500usize.max((a.max_len + a.cap + 15) / 16 + 1024);
450
  let sliding_pages = ((a.kv_gb * 1e9) as usize).saturating_sub(num_pages * full_pb) / sl_pb.max(1);
451
  anyhow::ensure!(sliding_pages >= 256, "sliding-window pool too small ({sliding_pages} pages): raise --kv-gb");
452
  eprintln!("KV pools: {} full-attention pages ({:.2} GB, {} tokens), {} sliding-window pages ({:.2} GB)", num_pages, (num_pages * full_pb) as f64 / 1e9, num_pages * 16, sliding_pages, ((sliding_pages + 1) * sl_pb) as f64 / 1e9);
@@ -526,6 +522,19 @@ mod tests {
526
  use axum::body::Body;
527
  use tower::ServiceExt;
528
 
 
 
 
 
 
 
 
 
 
 
 
 
 
529
  /// uniform distributions, never thinks
530
  struct UniformBackend;
531
  impl Backend for UniformBackend {
@@ -806,3 +815,18 @@ mod tests {
806
  assert_eq!(post_json(app, b, None).await.1["model"], "span-02");
807
  }
808
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
239
  Ok(Ok(_)) => return err(ApiError::new(500, "inference failed: result count mismatch")),
240
  Ok(Err(e)) => {
241
  let m = format!("{e:#}");
242
+ return err(backend_error(&m));
 
 
 
 
 
 
 
243
  }
244
  Err(e) => return err(ApiError::new(500, format!("inference failed: {e}"))),
245
  };
 
381
  /// hard row limit of one forward (workspace, ~0.45 MB per row); no-think prefill packs fill it
382
  #[arg(long, default_value_t = 8192)]
383
  pub max_rows: usize,
384
+ /// KV pages (16 tokens) of the full-attention layers; default max(2500, (max_len + cap) / 16 + 1024)
385
+ #[arg(long)]
386
+ pub pages: Option<usize>,
387
  /// KV memory budget (GB): the full-attention pool holds one max-length request plus 16K tokens, the sliding-window pool gets the rest
388
  #[arg(long, default_value_t = 9.0)]
389
  pub kv_gb: f64,
 
442
  // KV pools: bytes per 16-token page of the full-attention / sliding-window layers
443
  let pb = |k: LayerKind| -> usize { cfg.text.layers.iter().filter(|s| s.kind == k).map(|s| 2 * s.n_kv_heads * 16 * s.head_dim * 2).sum() };
444
  let (full_pb, sl_pb) = (pb(LayerKind::Full), pb(LayerKind::Sliding));
445
+ let num_pages = a.pages.unwrap_or_else(|| 2500usize.max((a.max_len + a.cap + 15) / 16 + 1024));
446
  let sliding_pages = ((a.kv_gb * 1e9) as usize).saturating_sub(num_pages * full_pb) / sl_pb.max(1);
447
  anyhow::ensure!(sliding_pages >= 256, "sliding-window pool too small ({sliding_pages} pages): raise --kv-gb");
448
  eprintln!("KV pools: {} full-attention pages ({:.2} GB, {} tokens), {} sliding-window pages ({:.2} GB)", num_pages, (num_pages * full_pb) as f64 / 1e9, num_pages * 16, sliding_pages, ((sliding_pages + 1) * sl_pb) as f64 / 1e9);
 
522
  use axum::body::Body;
523
  use tower::ServiceExt;
524
 
525
+ #[test]
526
+ fn kv_capacity_is_422_with_di_marker_and_transient_shortage_is_529() {
527
+ let cap = backend_error("KV pool exhausted: KV cache capacity exceeded (3652 pages needed, 2500 pages total)");
528
+ assert_eq!(cap.code, 422);
529
+ assert!(cap.msg.contains("maximum context length"), "{}", cap.msg);
530
+ let sw = backend_error("sliding-window KV pool exhausted: KV cache capacity exceeded (3000 pages needed, 2496 pages total)");
531
+ assert_eq!(sw.code, 422);
532
+ let tr = backend_error("KV pool exhausted (2500 pages total, 300 needed, 100 free)");
533
+ assert_eq!(tr.code, 529);
534
+ assert!(!tr.msg.contains("maximum context length"));
535
+ assert_eq!(backend_error("boom").code, 500);
536
+ }
537
+
538
  /// uniform distributions, never thinks
539
  struct UniformBackend;
540
  impl Backend for UniformBackend {
 
815
  assert_eq!(post_json(app, b, None).await.1["model"], "span-02");
816
  }
817
  }
818
+
819
+ /// HTTP error for a failed backend run. A request larger than the whole KV pool can never fit: 422 with the DI capacity marker
820
+ /// ("maximum context length"), so clients do not retry it. A transient shortage (concurrent requests hold the KV cache) stays 529.
821
+ pub fn backend_error(m: &str) -> ApiError {
822
+ if m.contains("cannot decode image") {
823
+ ApiError::new(422, "cannot decode image as a base64 data URI")
824
+ } else if m.contains("KV cache capacity exceeded") {
825
+ ApiError::new(422, format!("This model's maximum context length is exceeded: the request does not fit in the server's KV cache ({m})"))
826
+ } else if m.contains("KV pool exhausted") {
827
+ // transient overload (concurrent requests hold the KV cache): TypeSafe's 529, clients retry after a short delay
828
+ ApiError::new(529, format!("overloaded, retry after a short delay ({m})"))
829
+ } else {
830
+ ApiError::new(500, format!("inference failed: {m}"))
831
+ }
832
+ }
launch.sh CHANGED
@@ -44,6 +44,9 @@ dl "$DRAFT" "$D/draft"
44
  # --spec-draft MTP speculative decoding of the thought (lossless: token-identical to greedy)
45
  # --image-soft-tokens 560 Gemma-4 image budget (max_soft_tokens 560, about 1.3 MP)
46
  # --serial --no-prefix-cache --spec-max-branches 16 determinism: answers do not depend on load or earlier requests
 
 
47
  exec "$BIN" --bind "${HOST:-0.0.0.0}:${PORT:-8000}" --model "$M" --spec-draft "$D/draft" \
48
  --max-len 16384 --budget-ms 0 --tau 0.8 --cap 1024 --think-ms 750 --think-cost "$M/think_cost.json" \
49
- --image-soft-tokens 560 --serial --no-prefix-cache --spec-max-branches 16 "$@"
 
 
44
  # --spec-draft MTP speculative decoding of the thought (lossless: token-identical to greedy)
45
  # --image-soft-tokens 560 Gemma-4 image budget (max_soft_tokens 560, about 1.3 MP)
46
  # --serial --no-prefix-cache --spec-max-branches 16 determinism: answers do not depend on load or earlier requests
47
+ # --pages 12000 --kv-gb 24 KV pools (full attention 192K tokens, 3.9 GB; sliding window ~20 GB from --kv-gb 24) sized so every DI request
48
+ # fits on its own under --serial (largest DI 0.3 request: ~3.7K full pages); the pool size does not change answers
49
  exec "$BIN" --bind "${HOST:-0.0.0.0}:${PORT:-8000}" --model "$M" --spec-draft "$D/draft" \
50
  --max-len 16384 --budget-ms 0 --tau 0.8 --cap 1024 --think-ms 750 --think-cost "$M/think_cost.json" \
51
+ --image-soft-tokens 560 --serial --no-prefix-cache --spec-max-branches 16 \
52
+ --pages 12000 --kv-gb 24 "$@"