vram_fit: request slots ("Concurrent requests" 2 or more) take their sessions out of the expert cache's room. Each slot is a session the size of the main one on every backend (the engine's 0.23 GiB at 8K int8); the Flash-Next figures measured on the 8 GB A750 (base 4500 MiB, 2.22 MiB per cache slot) are used for these starts only. A start the engine would refuse gets a smaller context (32K, 16K, 8K, 4K, 2K) for that start, or runs one request at a time, with the fit note; the person's own --max-context is kept, and one-card-less, layer-split, DeepSeek and Metal starts are untouched (fit_start, called from serve/server.py engine_args). Tests for an 8 GB Arc at 8K with 2 requests, a 24 GB CUDA card, an explicit context. verify_refusal_test is registered with add_test

This commit is contained in:
Niko1221 committed 2026-11-02 11:59:43 +01:00
1 parent ab7baa5b17
commit 2a1042877d
4 files changed
+201 -15

No files matched your search

+12
View File
@@ -75,6 +75,7 @@ from strata_control import vision_reader # noqa: E402
from serve.ds_pictures import DsPictures # noqa: E402
from strata_control import draft_layer # noqa: E402
from strata_control import drafting # noqa: E402
from strata_control import vram_fit # noqa: E402
from strata_control import user_args # noqa: E402
from strata_control import gpu_roles # noqa: E402
from serve import ready as ready_file # noqa: E402
@@ -3427,6 +3428,17 @@ def engine_args(cfg: dict) -> list[str]:
print(f"[strata] {drafting.line(decision.drafter, decision.note)}", flush=True)
elif decision.flags:
print(f"[strata] drafting: DFlash2 alone ({' '.join(decision.flags[:2])}, --draft-conf {args[args.index('--draft-conf') + 1] if '--draft-conf' in args else '-'})", flush=True)
# "parallel" 2 or more: every request slot is a session of its own, taken out of the expert cache's room. A start the engine would refuse for lack of VRAM
# ("no VRAM is left for the expert cache") gets a smaller context for this start, else runs one request at a time, and says so; the person's own
# --max-context is kept (strata_control/vram_fit.py fit_start). Nothing changes without one card's memory, on a layer split, or for DeepSeek.
try:
args, fit_parallel, fit_note = vram_fit.fit_start(cfg, args, drafting_vram_mib(cfg, backend, archs), backend)
except Exception: # noqa: BLE001 - an estimate that fails decides nothing; the engine still refuses what does not fit
fit_parallel, fit_note = None, ""
if fit_note:
print(f"[strata] {fit_note}", flush=True)
if fit_parallel is not None:
cfg = {**cfg, "parallel": fit_parallel}
# "mtp": false (the Models settings, `strata launch --mtp off`, or by hand): no draft layer, whatever the args say. A setup run
# that writes --mtp back into the args cannot undo it, because this is applied at every start.
if draft_layer.is_off(cfg):
+95 -15
View File
@@ -19,6 +19,9 @@ The arithmetic (MiB), measured on the Arc A750 (8 GB, i915) with the SYCL engine
* prompt buffers: 170 + 0.66 * chunk MiB, measured at chunks 256 .. 2048 (338 / 508 / 850 / 1525 MiB), independent of the context.
`--prefill auto` resolved to 2048 on that card.
* slot: 1.243 MiB (0.50 GiB / 412 slots).
* request slots ("Concurrent requests" 2 or more, the engine's --batch N): every slot holds a session of its own, the size of the main one, on every backend
(the engine's "--batch 2: 2 slot sessions (0.23 GiB each)" is session(8192) = 234 MiB), taken out of the expert cache's room. Only these starts also use the
measured Qwen3.8-Flash-Next figures below (BASE_SLOTTED_MIB, SLOT_SLOTTED_MIB); the single-request estimate above is left as it is.
Why these numbers and not the engine's own: the engine only finds out after it has loaded 30 GB of weights; setup has to choose before.
It is an estimate; the engine still says what is short if it is off (and a user who asked for a context or a reserve keeps it).
@@ -48,6 +51,16 @@ MIN_CARD_GB = 5.0 # under this nothing here is claimed (th
BASE_MIB = {"gyro-m": 5945, "gyro-s": 4720}
BASE_GENERIC_MIB = 2448
# Starts with request slots. The generic base above is deliberately small (a default that fits today is never changed), so it cannot tell whether two more
# sessions leave the expert cache room; for Qwen3.8-Flash-Next the engine's own figures can. Arc A750, IQ3_XXS with the MTP layer, 2 slots, 8192 tokens, the
# engine log: 7721 MiB usable (8 GB less the 471 MiB gap), 2519 MiB free after everything but the expert cache, which held the weights (1466 + 1781 MiB), the
# MTP layer (802), the Q5_K head (417), the main session (234) and the two slot sessions (468): base = 7721 - 2519 - 234 - 468 = 4500 MiB. The cache's
# largest expert blob is 2.33 MB (2.22 MiB). Another quant or card holds somewhat different weights; MARGIN_MIB and the engine's own refusal cover the rest.
BASE_SLOTTED_MIB = {"flash-next": 4500}
SLOT_SLOTTED_MIB = 2.22
CTX_STEPS_SLOTTED = CTX_STEPS + (4096, 2048) # below 8K only for a start with request slots: each slot's session is one more KV cache to pay for
SLOTS_MAX = 8 # the engine's batch window (serve/server.py PARALLEL_MAX)
def base_mib(model: str) -> int:
low = str(model or "").lower().replace("_", "-")
@@ -57,6 +70,15 @@ def base_mib(model: str) -> int:
return BASE_GENERIC_MIB
def slotted_base_mib(model: str) -> int:
"""The base for a start with request slots: the measured Flash-Next figure, else the single-request one."""
low = str(model or "").lower().replace("_", "-")
for key, mib in BASE_SLOTTED_MIB.items():
if key in low:
return mib
return base_mib(model)
def known_model(model: str) -> bool:
low = str(model or "").lower().replace("_", "-")
return any(k in low for k in BASE_MIB)
@@ -75,19 +97,27 @@ def chunk_of(prefill) -> int:
def slack_mib(model: str, vram_gb: float, ctx: int, kv: str = "int8", reserve_mib: int = 300, prefill="auto",
vendor: str = "intel") -> float:
"""MiB the estimate leaves for the expert cache (negative: the engine would stop with "no VRAM is left for the expert cache")."""
vendor: str = "intel", slots: int = 0) -> float:
"""MiB the estimate leaves for the expert cache (negative: the engine would stop with "no VRAM is left for the expert cache").
`slots` >= 2: a start with that many request slots, each one a session of the main one's size."""
gap = GAP_MIB.get(str(vendor or "").lower(), GAP_OTHER_MIB)
prompt = PROMPT_BASE_MIB + PROMPT_PER_TOKEN_MIB * chunk_of(prefill)
return (vram_gb * 1024 - gap - base_mib(model) - session_mib(ctx, kv) - HEAD_MIB - max(int(reserve_mib), 0) - prompt)
base = base_mib(model)
extra = 0.0
if slots >= 2:
base = slotted_base_mib(model)
extra = min(int(slots), SLOTS_MAX) * session_mib(ctx, kv)
return (vram_gb * 1024 - gap - base - session_mib(ctx, kv) - extra - HEAD_MIB - max(int(reserve_mib), 0) - prompt)
def needed_mib() -> float:
def needed_mib(model: str = "", slots: int = 0) -> float:
if slots >= 2 and slotted_base_mib(model) != base_mib(model):
return MIN_SLOTS * SLOT_SLOTTED_MIB + MARGIN_MIB
return MIN_SLOTS * SLOT_MIB + MARGIN_MIB
def fits(model: str, vram_gb: float, ctx: int, kv: str = "int8", reserve_mib: int = 300, prefill="auto", vendor: str = "intel") -> bool:
return slack_mib(model, vram_gb, ctx, kv, reserve_mib, prefill, vendor) >= needed_mib()
def fits(model: str, vram_gb: float, ctx: int, kv: str = "int8", reserve_mib: int = 300, prefill="auto", vendor: str = "intel", slots: int = 0) -> bool:
return slack_mib(model, vram_gb, ctx, kv, reserve_mib, prefill, vendor, slots) >= needed_mib(model, slots)
def ctx_label(ctx: int) -> str:
@@ -104,10 +134,11 @@ class Fit:
slack_mib: float = 0.0
line: str = "" # one plain sentence for the user, "" when nothing changed
changes: list = field(default_factory=list)
slots: int = 0 # the request slots this start keeps (plan(slots=N)): N, or 1 when not even the smallest context leaves the cache room
def plan(model: str, vram_gb: float, ctx: int, kv: str = "int8", reserve_mib: int = 300, prefill="auto", vendor: str = "intel",
title: str = "", ctx_fixed: bool = False, reserve_fixed: bool = False, prefill_fixed: bool = False) -> Fit:
title: str = "", ctx_fixed: bool = False, reserve_fixed: bool = False, prefill_fixed: bool = False, slots: int = 0) -> Fit:
"""The start that fits, as close to the one asked for as possible; the SAME values back when that one already fits (byte-identical).
Order of harm, mildest first: the context down the ladder 32K -> 16K -> 8K (the outer loop), at each step the prompt chunk (1024)
@@ -115,25 +146,40 @@ def plan(model: str, vram_gb: float, ctx: int, kv: str = "int8", reserve_mib: in
ladder fits, the smallest start is returned with fits=False and a line that says so."""
ctx = int(ctx)
reserve_mib = int(reserve_mib)
keep = Fit(ctx, reserve_mib, None, False, True, 0.0)
slots = int(slots) if slots and int(slots) >= 2 else 0
keep = Fit(ctx, reserve_mib, None, False, True, 0.0, slots=slots)
if not vram_gb or vram_gb < MIN_CARD_GB:
return keep
keep.slack_mib = slack_mib(model, vram_gb, ctx, kv, reserve_mib, prefill, vendor)
if keep.slack_mib >= needed_mib():
need = needed_mib(model, slots)
keep.slack_mib = slack_mib(model, vram_gb, ctx, kv, reserve_mib, prefill, vendor, slots)
if keep.slack_mib >= need:
return keep
ctxs = [ctx] if ctx_fixed else [ctx] + [c for c in CTX_STEPS if c < ctx]
steps = CTX_STEPS_SLOTTED if slots else CTX_STEPS
ctxs = [ctx] if ctx_fixed else [ctx] + [c for c in steps if c < ctx]
chunks = [prefill] if prefill_fixed else [prefill] + [c for c in CHUNK_STEPS if c < chunk_of(prefill)]
reserves = [reserve_mib] if reserve_fixed or reserve_mib <= RESERVE_FLOOR_MIB else [reserve_mib, RESERVE_FLOOR_MIB]
best = None
for c in ctxs:
for p in chunks:
for r in reserves:
s = slack_mib(model, vram_gb, c, kv, r, p, vendor)
s = slack_mib(model, vram_gb, c, kv, r, p, vendor, slots)
best = (c, p, r, s)
if s >= needed_mib():
return _result(model, vram_gb, title, ctx, reserve_mib, prefill, c, p, r, s, True)
if s >= need:
out = _result(model, vram_gb, title, ctx, reserve_mib, prefill, c, p, r, s, True)
out.slots = slots
if slots and out.changed:
out.line = out.line.replace(" so it fits", f" so {slots} requests at once fit")
return out
c, p, r, s = best
return _result(model, vram_gb, title, ctx, reserve_mib, prefill, c, p, r, s, False)
out = _result(model, vram_gb, title, ctx, reserve_mib, prefill, c, p, r, s, False)
out.slots = slots
if slots and not ctx_fixed: # not even the smallest context leaves the expert cache room for the request slots: one at a time, said plainly
name = title or "This model"
out.slots = 1
out.changed = True
out.line = (f"{name} on this {vram_gb:.0f} GB card: {slots} requests at once need more memory than is free here, even with the context at "
f"{ctx_label(c)}, so this start answers one request at a time")
return out
def _result(model, vram_gb, title, ctx0, reserve0, prefill0, ctx, chunk, reserve, slack, ok) -> Fit:
@@ -154,6 +200,40 @@ def _result(model, vram_gb, title, ctx0, reserve0, prefill0, ctx, chunk, reserve
return Fit(ctx, reserve, str(chunk) if str(chunk) != str(prefill0) else None, bool(changes), ok, slack, line, changes)
def fit_start(cfg: dict, args: list, vram_mib: float | None, backend: str | None) -> tuple[list, int | None, str]:
"""The start the config asks for, when it has request slots ("parallel" 2 or more, the app's "Concurrent requests"): (args, parallel, note).
The slot sessions are taken out of the expert cache's room (plan(slots=N)); when that leaves too little, the context for this start is lowered to the
largest that fits (the same ladder, then 4K and 2K), and when not even that fits the start runs one request at a time. The person's own context
(``--max-context`` in user_args) is kept: the engine's plain refusal answers it. `parallel` is the effective count (None: the config's, untouched).
Nothing changes without a single card's memory (several cards, a shared-memory chip: None) or for a layer split / DeepSeek, which size their slots themselves."""
n = cfg.get("parallel")
if isinstance(n, bool) or not isinstance(n, int) or n < 2 or not vram_mib or backend in (None, "", "metal"):
return args, None, ""
if (cfg.get("arch") or "") == "deepseek41" or cfg.get("layer_split") or "--layer-split" in args or "--batch" in args or "--slots" in args:
return args, None, ""
def flag(name):
return args[args.index(name) + 1] if name in args and args.index(name) + 1 < len(args) else None
ctx_s = flag("--max-context")
ctx = int(ctx_s) if ctx_s and str(ctx_s).isdigit() else 131072
reserve_s = flag("--vram-reserve-mib")
reserve = int(reserve_s) if reserve_s and str(reserve_s).isdigit() else 700 # the engine's default reserve
user = [str(a) for a in (cfg.get("user_args") or [])]
model = str(cfg.get("model_name") or flag("--native") or "")
fr = plan(model, float(vram_mib) / 1024.0, ctx, flag("--kv") or "int8", reserve, flag("--prefill") or "auto", "intel" if backend == "sycl" else "other",
title=str(cfg.get("model_name") or "This model"), ctx_fixed="--max-context" in user, reserve_fixed=True, prefill_fixed=True, slots=n)
if not fr.changed:
return args, None, ""
out = list(args)
if fr.ctx != ctx:
if "--max-context" in out:
out[out.index("--max-context") + 1] = str(fr.ctx)
else:
out += ["--max-context", str(fr.ctx)]
return out, (fr.slots if fr.slots != n else None), fr.line
def fit_context(model: str, vram_gb: float, ctx: int, kv: str = "int8", reserve_mib: int = 300, vendor: str = "intel") -> int:
"""The context to recommend: `ctx` when it fits (unchanged), else the biggest step of the ladder that does (the reserve and
chunk lowered the way plan() lowers them), else the smallest step."""
+1
View File
@@ -430,6 +430,7 @@ if(STRATA_SYCL_PARITY)
target_link_libraries(dflash2_ring_test PRIVATE strata_engine strata_prefill strata_spec)
add_executable(verify_refusal_test ${CMAKE_CURRENT_SOURCE_DIR}/src/core/verify_refusal_test.cpp) # a refused start (no VRAM expert tier) ends cleanly: the verifiers leave the default queue alone
target_link_libraries(verify_refusal_test PRIVATE strata_engine strata_prefill strata_spec)
add_test(NAME verify_refusal_test COMMAND verify_refusal_test) # needs one SYCL GPU
add_executable(dflash2_bench ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/dflash2_bench.cpp) # DF2-SYCL: the DFlash2 Q4_0 drafter round on an Arc, kernel checks
target_link_libraries(dflash2_bench PRIVATE strata_kernels)
add_executable(dense_bench ${CMAKE_CURRENT_SOURCE_DIR}/src/kernels/dense_bench.cpp) # the decode window's dense GEMVs, graph, hash
+93
View File
@@ -148,5 +148,98 @@ class SetupIntel(unittest.TestCase):
self.assertEqual(notes, [])
FN = "qwen3.8-flash-next-iq3_xxs"
class SlotSessions(unittest.TestCase):
"""Request slots ("Concurrent requests" 2 or more, the engine's --batch N): each slot is a session of its own, out of the expert cache's room.
The 8 GB Arc A750 figures are the engine's: --batch 2 at 8192 tokens with the MTP layer left 2519 MiB free and the cache needed 99 MiB more (0 slots, a
refused start, SIGSEGV before ab7baa5b1)."""
def test_the_slot_session_is_the_main_sessions_size(self):
self.assertAlmostEqual(V.session_mib(8192), 234, delta=8) # the engine: "2 slot sessions (0.23 GiB each)"
def test_without_slots_nothing_changes(self):
a = V.slack_mib(FN, 8.0, 8192, "int8", 700, "auto", "intel")
self.assertEqual(a, V.slack_mib(FN, 8.0, 8192, "int8", 700, "auto", "intel", 0))
self.assertEqual(a, V.slack_mib(FN, 8.0, 8192, "int8", 700, "auto", "intel", 1))
self.assertEqual(V.needed_mib(), V.needed_mib(FN, 1))
def test_an_8gb_arc_with_two_requests_at_8k_gets_a_context_that_fits(self):
f = V.plan(FN, 8.0, 8192, "int8", 700, "auto", "intel", title=FN, reserve_fixed=True, prefill_fixed=True, slots=2)
self.assertTrue(f.changed and f.fits)
self.assertEqual((f.ctx, f.slots, f.reserve_mib), (4096, 2, 700))
self.assertGreaterEqual(f.slack_mib, V.needed_mib(FN, 2))
self.assertIn("context set to 4K", f.line)
self.assertTrue(f.line.endswith("so 2 requests at once fit"), f.line)
# the engine's refusal case, as the estimate sees it: 8192 with two slots does not leave the cache its 128 slots
self.assertLess(V.slack_mib(FN, 8.0, 8192, "int8", 700, "auto", "intel", 2), V.needed_mib(FN, 2))
def test_not_even_the_smallest_context_fits_then_one_request_at_a_time(self):
f = V.plan(FN, 8.0, 8192, "int8", 1024, "auto", "intel", title=FN, reserve_fixed=True, prefill_fixed=True, slots=2)
self.assertEqual(f.slots, 1)
self.assertTrue(f.changed)
self.assertIn("one request at a time", f.line)
def test_a_24gb_cuda_card_is_unchanged(self):
f = V.plan(FN, 24.0, 131072, "int8", 700, "auto", "other", title=FN, reserve_fixed=True, prefill_fixed=True, slots=4)
self.assertFalse(f.changed)
self.assertEqual((f.ctx, f.slots, f.line), (131072, 4, ""))
def test_a_chosen_context_is_kept(self):
f = V.plan(FN, 8.0, 8192, "int8", 700, "auto", "intel", ctx_fixed=True, reserve_fixed=True, prefill_fixed=True, slots=2)
self.assertEqual(f.ctx, 8192)
self.assertFalse(f.changed) # the engine's plain refusal answers it
class FitStart(unittest.TestCase):
"""fit_start: the config's "parallel" and the engine arguments of one start (serve/server.py engine_args calls it)."""
ARGS = ["--native", "/m/Qwen3.8-Flash-Next-IQ3_XXS-00001-of-00002.gguf", "--expert-cache", "auto", "--prefill", "auto", "--max-context", "8192",
"--kv", "int8", "--vram-reserve-mib", "700"]
def cfg(self, **kw):
c = {"model_name": FN, "parallel": 2}
c.update(kw)
return c
def test_an_8gb_arc_gets_a_smaller_context_for_this_start(self):
args, par, note = V.fit_start(self.cfg(), list(self.ARGS), 8192, "sycl")
self.assertEqual(args[args.index("--max-context") + 1], "4096")
self.assertIsNone(par) # still 2 requests at once
self.assertIn("2 requests at once fit", note)
self.assertEqual(self.ARGS[self.ARGS.index("--max-context") + 1], "8192") # the input is not touched
def test_the_persons_own_context_wins(self):
args, par, note = V.fit_start(self.cfg(user_args=["--max-context", "8192"]), list(self.ARGS), 8192, "sycl")
self.assertEqual((args, par, note), (self.ARGS, None, ""))
def test_a_24gb_cuda_card_and_one_request_are_unchanged(self):
big = ["--native", "/m/q.gguf", "--max-context", "131072", "--kv", "int8", "--vram-reserve-mib", "700"]
self.assertEqual(V.fit_start(self.cfg(parallel=4), list(big), 24576, "cuda"), (big, None, ""))
self.assertEqual(V.fit_start(self.cfg(parallel=1), list(self.ARGS), 8192, "sycl"), (self.ARGS, None, ""))
self.assertEqual(V.fit_start({"model_name": FN}, list(self.ARGS), 8192, "sycl"), (self.ARGS, None, ""))
def test_nothing_without_one_cards_memory_or_on_a_split_or_deepseek_or_metal(self):
self.assertEqual(V.fit_start(self.cfg(), list(self.ARGS), None, "sycl"), (self.ARGS, None, ""))
self.assertEqual(V.fit_start(self.cfg(), list(self.ARGS), 8192, "metal"), (self.ARGS, None, ""))
self.assertEqual(V.fit_start(self.cfg(layer_split="auto"), list(self.ARGS), 8192, "sycl"), (self.ARGS, None, ""))
self.assertEqual(V.fit_start(self.cfg(arch="deepseek41"), list(self.ARGS), 8192, "sycl"), (self.ARGS, None, ""))
def test_a_start_that_cannot_fit_runs_one_at_a_time(self):
tight = [a if a != "700" else "1024" for a in self.ARGS]
args, par, note = V.fit_start(self.cfg(), tight, 8192, "sycl")
self.assertEqual(par, 1)
self.assertIn("one request at a time", note)
def test_the_context_is_added_when_the_args_name_none(self):
no_ctx = [a for a in self.ARGS]
i = no_ctx.index("--max-context")
del no_ctx[i:i + 2]
args, par, note = V.fit_start(self.cfg(), no_ctx, 8192, "sycl")
self.assertNotEqual(note, "")
self.assertIn("--max-context", args)
if __name__ == "__main__":
unittest.main()