ocr: say how many pods the pages imply, not how many max_pods allows

Comparing 64 pages against max_pods x inflight (64 x 16 = 1024 on the Digesta
orchestrator) would never have fired, and would have been reassuring if it
had: the fleet's ceiling is not what serves a burst. A pod takes ~12 minutes
to become ready, so the burst is served by the pods already warm. What is
worth saying at setup time is the arithmetic itself — these pages need that
many pods — plus the case where it exceeds max_pods and the surplus can only
queue.
Co-Authored-By: 's avatarClaude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYHWbCAjjjD7A1rhFKtaW
parent 4b99fcb8
...@@ -276,13 +276,11 @@ def _engine_instances(cfg, name: str) -> int: ...@@ -276,13 +276,11 @@ def _engine_instances(cfg, name: str) -> int:
return want return want
def _fleet_page_slots(name: str): def _fleet_shape(name: str):
"""How many pages the remote fleet can actually have in flight, or None. """``(inflight_per_pod, max_pods)`` for the model this engine reads from.
``max_pods x scale_up_inflight_per_pod``. Not a limit we impose — the pool Taken from the model's own ``runpod`` block, which is where per-pod
queues the excess perfectly correctly — but a number worth comparing with concurrency lives — one knob per model, not an OCR setting.
ours, because pages beyond it do not go faster, they only wait, and a pod
takes ~12 minutes to appear so the fleet cannot grow into a burst.
""" """
try: try:
from codai.models.manager import _model_entry_for from codai.models.manager import _model_entry_for
...@@ -298,42 +296,53 @@ def _fleet_page_slots(name: str): ...@@ -298,42 +296,53 @@ def _fleet_page_slots(name: str):
mcfg = parse_model_runpod(block) mcfg = parse_model_runpod(block)
if (mcfg.mode or "").lower() != "pods": if (mcfg.mode or "").lower() != "pods":
continue continue
pods = max(1, int(getattr(mcfg, "max_pods", 1) or 1))
per = int(getattr(mcfg, "scale_up_inflight_per_pod", 0) or 0) per = int(getattr(mcfg, "scale_up_inflight_per_pod", 0) or 0)
if per <= 0: if per <= 0:
per = int(getattr(mcfg, "max_inflight_per_pod", 0) or 0) per = int(getattr(mcfg, "max_inflight_per_pod", 0) or 0)
if per > 0: if per > 0:
return pods * per return per, max(1, int(getattr(mcfg, "max_pods", 1) or 1))
except Exception: except Exception:
continue continue
return None return None
#: The served model names an engine may be reading from, for the slot estimate. #: The served model names an engine may be reading from, for the fleet estimate.
_SERVED_NAMES = {"surya": ("datalab-to/surya-ocr-2", "surya-ocr-2"), _SERVED_NAMES = {"surya": ("datalab-to/surya-ocr-2", "surya-ocr-2"),
"chandra": ("datalab-to/chandra-ocr-2", "chandra-ocr-2"), "chandra": ("datalab-to/chandra-ocr-2", "chandra-ocr-2"),
"olmocr": ("allenai/olmOCR-2-7B-1025-FP8", "olmocr")} "olmocr": ("allenai/olmOCR-2-7B-1025-FP8", "olmocr")}
#: Measured on the Digesta orchestrator: ~7 min image pull + ~5 min warm-up.
_POD_READY_MINUTES = 12
def _warn_if_oversubscribed(name: str, pages: int) -> None: def _warn_if_oversubscribed(name: str, pages: int) -> None:
"""Say so when we fan out more pages than the fleet can serve at once. """Say how many pods this many pages implies, at the moment they are set up.
Oversubscription does not lose work and it does not go faster: the surplus Not a limit — the pool queues the surplus perfectly correctly. But pages
waits in the gateway, every page's latency rises by the queue, and because a beyond what the READY pods can hold do not go faster, they only wait, and
client usually counts finished DOCUMENTS, spreading N pages over many because a pod takes ~12 minutes to appear the fleet cannot be scaled into a
documents at once means they all advance and none completes until near the burst: the first burst is served by whatever is already warm, however high
end. That reads from outside as "one opening burst, then silence" — which is ``max_pods`` is. That matters most to a client counting finished DOCUMENTS,
exactly how the 2026-10-13 batch was reported, while the pod sat at 18 which completes none of them until one document's LAST page lands — so 64
concurrent requests, `Waiting: 0` and 5-7% KV cache the entire time. pages spread over sixteen documents reads from outside as a stall while
every pod is busy. Exactly how the 2026-10-13 batch was reported, with the
pod at 18 concurrent requests, ``Waiting: 0`` and 5-7% KV cache throughout.
""" """
slots = _fleet_page_slots(name) shape = _fleet_shape(name)
if not slots or pages <= slots: if not shape:
return return
print(f"[ocr] engine {name!r}: {pages} pages at once, but the fleet serves " per, max_pods = shape
f"{slots} (max_pods x scale_up_inflight_per_pod). The surplus waits in " need = (pages + per - 1) // per
f"the gateway — it does not go faster, and a pod takes ~12 min to " tail = (f"a pod takes ~{_POD_READY_MINUTES} min to become ready, so the "
f"appear, so a burst cannot be scaled into. Lower instances/lanes, or " f"first burst is served by whatever is already warm")
f"raise the pod's inflight ceiling.", flush=True) if need > max_pods:
print(f"[ocr] engine {name!r}: {pages} pages at once needs {need} pods at "
f"{per} in flight each, but max_pods is {max_pods} — "
f"{max_pods * per} will be served and the rest will queue. "
f"Lower instances/lanes, or raise max_pods.", flush=True)
elif need > 1:
print(f"[ocr] engine {name!r}: {pages} pages at once spreads over {need} "
f"pods ({per} in flight each); {tail}.", flush=True)
def _engine_lanes(eng) -> int: def _engine_lanes(eng) -> int:
......
...@@ -1033,16 +1033,26 @@ pool queues pages beyond `max_pods x scale_up_inflight_per_pod` and serves them ...@@ -1033,16 +1033,26 @@ pool queues pages beyond `max_pods x scale_up_inflight_per_pod` and serves them
perfectly correctly, but every page's latency rises by the queue — and a client perfectly correctly, but every page's latency rises by the queue — and a client
that counts finished DOCUMENTS sees nothing at all until the first document's that counts finished DOCUMENTS sees nothing at all until the first document's
LAST page lands. Spread 64 pages over sixteen documents and all sixteen advance LAST page lands. Spread 64 pages over sixteen documents and all sixteen advance
while none completes. That reads exactly like a stall. The pool logs the while none completes. That reads exactly like a stall. The pool therefore says, when the
mismatch at build time: pages are set up, how many pods they imply:
``` ```
[ocr] engine 'surya': 64 pages at once, but the fleet serves 32 [ocr] engine 'surya': 64 pages at once spreads over 4 pods (16 in flight each);
(max_pods x scale_up_inflight_per_pod). The surplus waits in the gateway — a pod takes ~12 min to become ready, so the first burst is served by
it does not go faster, and a pod takes ~12 min to appear, so a burst whatever is already warm.
cannot be scaled into.
``` ```
and, when the arithmetic exceeds `max_pods`, that the surplus simply queues:
```
[ocr] engine 'surya': 64 pages at once needs 4 pods at 16 in flight each, but
max_pods is 2 — 32 will be served and the rest will queue.
```
A high `max_pods` does not help a burst: it is the pods already **warm** that
serve it, because the twelve minutes are spent before the first of the new ones
answers anything.
**The defect: 2,644 generated tokens for a page whose text is ~400.** Surya **The defect: 2,644 generated tokens for a page whose text is ~400.** Surya
re-runs a page whose generation repeated itself — `_should_retry` in re-runs a page whose generation repeated itself — `_should_retry` in
`surya.inference.backends.openai_client` — up to `max_retries` (3) more times, `surya.inference.backends.openai_client` — up to `max_retries` (3) more times,
......
...@@ -251,44 +251,57 @@ def test_local_mode_gets_no_client_parallel(): ...@@ -251,44 +251,57 @@ def test_local_mode_gets_no_client_parallel():
# ------------------------------------------------- the oversubscription note # # ------------------------------------------------- the oversubscription note #
def test_more_pages_than_the_fleet_can_serve_is_reported(capsys, monkeypatch): def test_how_many_pods_this_many_pages_implies(capsys, monkeypatch):
"""64 pages into 2 pods x 16 slots does not go faster — it queues, every """The useful number. 64 pages at 16 per pod is 4 pods — and a pod takes
page's latency rises, and a client counting DOCUMENTS sees one opening ~12 minutes, so the first burst gets whatever is already warm, however
burst then silence.""" high max_pods is."""
from codai.ocr import manager as m from codai.ocr import manager as m
monkeypatch.setattr(m, "_fleet_page_slots", lambda name: 32) monkeypatch.setattr(m, "_fleet_shape", lambda name: (16, 64))
m._warn_if_oversubscribed("surya", 64) m._warn_if_oversubscribed("surya", 64)
out = capsys.readouterr().out out = capsys.readouterr().out
assert "64 pages at once" in out and "fleet serves 32" in out assert "64 pages at once spreads over 4 pods" in out
assert "12 min" in out and "already warm" in out
def test_a_fleet_that_can_take_it_says_nothing(capsys, monkeypatch): def test_more_pods_than_max_pods_is_called_out(capsys, monkeypatch):
"""Here the surplus genuinely cannot be served: it queues until a pod frees
up, and no amount of waiting grows the fleet past max_pods."""
from codai.ocr import manager as m from codai.ocr import manager as m
monkeypatch.setattr(m, "_fleet_page_slots", lambda name: 64) monkeypatch.setattr(m, "_fleet_shape", lambda name: (16, 2))
m._warn_if_oversubscribed("surya", 64) m._warn_if_oversubscribed("surya", 64)
out = capsys.readouterr().out
assert "needs 4 pods" in out and "max_pods is 2" in out and "32 will be served" in out
def test_one_pod_is_worth_no_remark(capsys, monkeypatch):
"""8 pages at 16 per pod fits one pod. Saying so would be noise."""
from codai.ocr import manager as m
monkeypatch.setattr(m, "_fleet_shape", lambda name: (16, 64))
m._warn_if_oversubscribed("surya", 8)
assert capsys.readouterr().out == "" assert capsys.readouterr().out == ""
def test_an_unknown_fleet_says_nothing(capsys, monkeypatch): def test_an_unknown_fleet_says_nothing(capsys, monkeypatch):
"""No RunPod config, a local engine, a lookup that failed — none of those """No RunPod config, a local engine, a lookup that failed — none of those
are oversubscription, and guessing would cry wolf on every build.""" are a fleet, and guessing would cry wolf on every build."""
from codai.ocr import manager as m from codai.ocr import manager as m
monkeypatch.setattr(m, "_fleet_page_slots", lambda name: None) monkeypatch.setattr(m, "_fleet_shape", lambda name: None)
m._warn_if_oversubscribed("surya", 9999) m._warn_if_oversubscribed("surya", 9999)
assert capsys.readouterr().out == "" assert capsys.readouterr().out == ""
def test_the_slot_estimate_reads_the_models_own_runpod_block(monkeypatch): def test_the_estimate_reads_the_models_own_runpod_block(monkeypatch):
"""Per-pod concurrency is one knob per model, not an OCR setting."""
from codai.ocr import manager as m from codai.ocr import manager as m
import codai.models.manager as mm import codai.models.manager as mm
monkeypatch.setattr(mm, "_model_entry_for", monkeypatch.setattr(mm, "_model_entry_for",
lambda name: {"runpod": {"mode": "pods", "max_pods": 4, lambda name: {"runpod": {"mode": "pods", "max_pods": 4,
"scale_up_inflight_per_pod": 16}}, "scale_up_inflight_per_pod": 16}},
raising=False) raising=False)
assert m._fleet_page_slots("surya") == 64 assert m._fleet_shape("surya") == (16, 4)
def test_a_serverless_model_has_no_pod_slots(monkeypatch): def test_a_serverless_model_has_no_pod_shape(monkeypatch):
"""The arithmetic is about pods; serverless scales on its own terms.""" """The arithmetic is about pods; serverless scales on its own terms."""
from codai.ocr import manager as m from codai.ocr import manager as m
import codai.models.manager as mm import codai.models.manager as mm
...@@ -297,4 +310,4 @@ def test_a_serverless_model_has_no_pod_slots(monkeypatch): ...@@ -297,4 +310,4 @@ def test_a_serverless_model_has_no_pod_slots(monkeypatch):
"max_pods": 4, "max_pods": 4,
"scale_up_inflight_per_pod": 16}}, "scale_up_inflight_per_pod": 16}},
raising=False) raising=False)
assert m._fleet_page_slots("surya") is None assert m._fleet_shape("surya") is None
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment