span-02: single-source release folder (engine final path, soft blend)
Browse files- README.md +7 -1
- engine/crates/runtime/src/engine.rs +13 -0
- engine/crates/runtime/src/tests/engine.rs +4 -0
- engine/crates/server/src/lib.rs +33 -9
- launch.sh +4 -1
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(
|
| 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 "$@"
|