feat(llm): VRAM-aware generation routing — acquire_generation + pick_model (refs #654)
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
"""Least-loaded fan-out across Ollama hosts.
|
"""Least-loaded fan-out across Ollama hosts + largest-GPU generation.
|
||||||
|
|
||||||
Remote GPUs are plain Ollama endpoints (rig 4090 / laptop 5080 run
|
Remote GPUs are plain Ollama endpoints (rig 4090 / laptop 5070 Ti run
|
||||||
``ollama serve``); this pool spreads embed calls across them while all
|
``ollama serve``); this pool spreads embed calls across them while all
|
||||||
bib/pgvector I/O stays on the server. Uses the raw ``/api/embed`` HTTP API
|
bib/pgvector I/O stays on the server. Uses the raw ``/api/embed`` HTTP API
|
||||||
rather than langchain-ollama so one pool can juggle N base URLs;
|
rather than langchain-ollama so one pool can juggle N base URLs;
|
||||||
``PoolEmbeddings`` adapts it back to the langchain interface for PGVector.
|
``PoolEmbeddings`` adapts it back to the langchain interface for PGVector.
|
||||||
|
|
||||||
|
Hosts carry a declared VRAM size (``LLM_OLLAMA_HOSTS`` ``url@gb``);
|
||||||
|
``acquire_generation`` hands chat to the largest live host, while embeds
|
||||||
|
stay least-loaded (``acquire``).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -12,7 +16,7 @@ from __future__ import annotations
|
|||||||
import threading
|
import threading
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Iterator, Sequence
|
from typing import Iterator, Mapping, Sequence
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from langchain_core.embeddings import Embeddings
|
from langchain_core.embeddings import Embeddings
|
||||||
@@ -21,38 +25,67 @@ _TIMEOUT = httpx.Timeout(120.0, connect=5.0)
|
|||||||
|
|
||||||
|
|
||||||
class HostPool:
|
class HostPool:
|
||||||
"""Tracks in-flight requests per host; hands out the idlest one."""
|
"""Tracks in-flight requests per host; hands out the idlest one for
|
||||||
|
embeds and the largest live one for generation."""
|
||||||
|
|
||||||
def __init__(self, hosts: Sequence[str]) -> None:
|
def __init__(
|
||||||
|
self, hosts: Sequence[str], *, vram_gb: Mapping[str, float] | None = None
|
||||||
|
) -> None:
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
self._in_flight: dict[str, int] = {h.rstrip("/"): 0 for h in hosts}
|
self._in_flight: dict[str, int] = {h.rstrip("/"): 0 for h in hosts}
|
||||||
|
vram = vram_gb or {}
|
||||||
|
self._vram: dict[str, float] = {
|
||||||
|
h: float(vram.get(h, vram.get(h + "/", 0.0))) for h in self._in_flight
|
||||||
|
}
|
||||||
|
# Model names (full tag + bare prefix) seen on each host at the last
|
||||||
|
# check(); empty until a check has run.
|
||||||
|
self._models: dict[str, set[str]] = {}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_config(cls, cfg) -> "HostPool":
|
def from_config(cls, cfg) -> "HostPool":
|
||||||
return cls(cfg.ollama_hosts)
|
return cls(cfg.ollama_hosts, vram_gb=getattr(cfg, "host_vram", None))
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def hosts(self) -> list[str]:
|
def hosts(self) -> list[str]:
|
||||||
return list(self._in_flight)
|
return list(self._in_flight)
|
||||||
|
|
||||||
|
def vram(self, host: str) -> float:
|
||||||
|
return self._vram.get(host.rstrip("/"), 0.0)
|
||||||
|
|
||||||
|
def serves(self, host: str, model: str) -> bool:
|
||||||
|
names = self._models.get(host.rstrip("/"), set())
|
||||||
|
return model in names or model.split(":")[0] in names
|
||||||
|
|
||||||
|
def status(self) -> list[dict]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"host": h,
|
||||||
|
"vram_gb": self._vram.get(h, 0.0),
|
||||||
|
"models": sorted(n for n in self._models.get(h, set()) if ":" in n),
|
||||||
|
}
|
||||||
|
for h in self._in_flight
|
||||||
|
]
|
||||||
|
|
||||||
def check(self, model: str) -> list[str]:
|
def check(self, model: str) -> list[str]:
|
||||||
"""Keep only hosts that are up and serve ``model``.
|
"""Keep only hosts that are up and serve ``model``.
|
||||||
|
|
||||||
Ollama tags models ``name:latest``; match on the bare prefix.
|
Ollama tags models ``name:latest``; match on the bare prefix.
|
||||||
|
Remembers every model each live host serves (see ``serves``).
|
||||||
"""
|
"""
|
||||||
alive: list[str] = []
|
alive: list[str] = []
|
||||||
with httpx.Client(timeout=_TIMEOUT) as client:
|
with httpx.Client(timeout=_TIMEOUT) as client:
|
||||||
for host in self.hosts:
|
for host in self.hosts:
|
||||||
try:
|
try:
|
||||||
resp = client.get(f"{host}/api/tags")
|
resp = client.get(f"{host}/api/tags")
|
||||||
names = {
|
full = {m["name"] for m in resp.json().get("models", [])}
|
||||||
m["name"].split(":")[0] for m in resp.json().get("models", [])
|
names = full | {n.split(":")[0] for n in full}
|
||||||
}
|
self._models[host] = names
|
||||||
if model.split(":")[0] in names:
|
if model.split(":")[0] in names:
|
||||||
alive.append(host)
|
alive.append(host)
|
||||||
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
except (httpx.HTTPError, ValueError, KeyError, TypeError):
|
||||||
# Down, or up but returning a malformed/non-JSON body —
|
# Down, or up but returning a malformed/non-JSON body —
|
||||||
# either way, drop it rather than aborting the whole run.
|
# either way, drop it rather than aborting the whole run.
|
||||||
|
self._models.pop(host, None)
|
||||||
continue
|
continue
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._in_flight = {h: self._in_flight.get(h, 0) for h in alive}
|
self._in_flight = {h: self._in_flight.get(h, 0) for h in alive}
|
||||||
@@ -63,6 +96,11 @@ class HostPool:
|
|||||||
)
|
)
|
||||||
return alive
|
return alive
|
||||||
|
|
||||||
|
def _release(self, host: str) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if host in self._in_flight:
|
||||||
|
self._in_flight[host] -= 1
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def acquire(self) -> Iterator[str]:
|
def acquire(self) -> Iterator[str]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -71,9 +109,38 @@ class HostPool:
|
|||||||
try:
|
try:
|
||||||
yield host
|
yield host
|
||||||
finally:
|
finally:
|
||||||
with self._lock:
|
self._release(host)
|
||||||
if host in self._in_flight:
|
|
||||||
self._in_flight[host] -= 1
|
@contextmanager
|
||||||
|
def acquire_generation(self) -> Iterator[str]:
|
||||||
|
"""The largest live host (declared VRAM), ties → least in-flight.
|
||||||
|
|
||||||
|
Call ``check(model)`` first so the pool holds only live hosts that
|
||||||
|
serve the model; this then realises "largest GPU available now".
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
host = max(
|
||||||
|
self._in_flight,
|
||||||
|
key=lambda h: (self._vram.get(h, 0.0), -self._in_flight[h]),
|
||||||
|
)
|
||||||
|
self._in_flight[host] += 1
|
||||||
|
try:
|
||||||
|
yield host
|
||||||
|
finally:
|
||||||
|
self._release(host)
|
||||||
|
|
||||||
|
|
||||||
|
def pick_model(cfg, pool: HostPool, host: str) -> str:
|
||||||
|
"""``instruct_model_large`` when ``host`` declares enough VRAM and
|
||||||
|
serves it; else ``instruct_model``."""
|
||||||
|
large = getattr(cfg, "instruct_model_large", "")
|
||||||
|
if (
|
||||||
|
large
|
||||||
|
and pool.vram(host) >= float(getattr(cfg, "large_min_vram_gb", 20.0))
|
||||||
|
and pool.serves(host, large)
|
||||||
|
):
|
||||||
|
return large
|
||||||
|
return cfg.instruct_model
|
||||||
|
|
||||||
|
|
||||||
def embed_texts(
|
def embed_texts(
|
||||||
|
|||||||
@@ -155,3 +155,108 @@ class TestPoolEmbeddings:
|
|||||||
emb = PoolEmbeddings(pool, "m")
|
emb = PoolEmbeddings(pool, "m")
|
||||||
assert emb.embed_documents(["x"]) == [[0.1]]
|
assert emb.embed_documents(["x"]) == [[0.1]]
|
||||||
assert emb.embed_query("x") == [0.1]
|
assert emb.embed_query("x") == [0.1]
|
||||||
|
|
||||||
|
|
||||||
|
class TestGeneration:
|
||||||
|
def _pool(self):
|
||||||
|
return HostPool([H1, H2], vram_gb={H1: 12, H2: 24})
|
||||||
|
|
||||||
|
@patch("llm.pool.httpx.Client")
|
||||||
|
def test_acquire_generation_prefers_largest_live_host(self, MockClient):
|
||||||
|
client = MockClient.return_value.__enter__.return_value
|
||||||
|
client.get.side_effect = [
|
||||||
|
_resp({"models": [{"name": "chat:latest"}]}),
|
||||||
|
_resp({"models": [{"name": "chat:latest"}, {"name": "big:latest"}]}),
|
||||||
|
]
|
||||||
|
pool = self._pool()
|
||||||
|
pool.check("chat")
|
||||||
|
with pool.acquire_generation() as host:
|
||||||
|
assert host == H2
|
||||||
|
assert pool._in_flight[H2] == 1
|
||||||
|
assert pool._in_flight[H2] == 0
|
||||||
|
|
||||||
|
@patch("llm.pool.httpx.Client")
|
||||||
|
def test_falls_back_when_largest_is_down(self, MockClient):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
client = MockClient.return_value.__enter__.return_value
|
||||||
|
client.get.side_effect = [
|
||||||
|
_resp({"models": [{"name": "chat:latest"}]}),
|
||||||
|
httpx.ConnectError("rig down"),
|
||||||
|
]
|
||||||
|
pool = self._pool()
|
||||||
|
pool.check("chat")
|
||||||
|
with pool.acquire_generation() as host:
|
||||||
|
assert host == H1
|
||||||
|
|
||||||
|
def test_tie_breaks_on_least_in_flight(self):
|
||||||
|
pool = HostPool([H1, H2], vram_gb={H1: 24, H2: 24})
|
||||||
|
pool._in_flight[H1] = 3
|
||||||
|
with pool.acquire_generation() as host:
|
||||||
|
assert host == H2
|
||||||
|
|
||||||
|
def test_vram_defaults_to_zero(self):
|
||||||
|
assert HostPool([H1]).vram(H1) == 0.0
|
||||||
|
|
||||||
|
@patch("llm.pool.httpx.Client")
|
||||||
|
def test_serves_reflects_last_check(self, MockClient):
|
||||||
|
client = MockClient.return_value.__enter__.return_value
|
||||||
|
client.get.side_effect = [
|
||||||
|
_resp({"models": [{"name": "chat:latest"}, {"name": "big:latest"}]}),
|
||||||
|
]
|
||||||
|
pool = HostPool([H1])
|
||||||
|
pool.check("chat")
|
||||||
|
assert pool.serves(H1, "big") is True
|
||||||
|
assert pool.serves(H1, "big:latest") is True
|
||||||
|
assert pool.serves(H1, "nope") is False
|
||||||
|
assert pool.status() == [
|
||||||
|
{"host": H1, "vram_gb": 0.0, "models": ["big:latest", "chat:latest"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestPickModel:
|
||||||
|
def _cfg(self, **kw):
|
||||||
|
base = dict(
|
||||||
|
ollama_hosts=(H1, H2),
|
||||||
|
embed_model="e",
|
||||||
|
instruct_model="chat",
|
||||||
|
instruct_model_large="big",
|
||||||
|
large_min_vram_gb=20,
|
||||||
|
embed_dim=768,
|
||||||
|
build_ann_index=False,
|
||||||
|
pg_host="x",
|
||||||
|
pg_port=5432,
|
||||||
|
pg_db="llm",
|
||||||
|
pg_user="llm",
|
||||||
|
)
|
||||||
|
base.update(kw)
|
||||||
|
return LlmConfig(**base)
|
||||||
|
|
||||||
|
def _pool_serving(self, models_by_host):
|
||||||
|
pool = HostPool(list(models_by_host), vram_gb={H1: 12, H2: 24})
|
||||||
|
pool._models = {h: set(ms) for h, ms in models_by_host.items()}
|
||||||
|
return pool
|
||||||
|
|
||||||
|
def test_large_on_big_host_that_serves_it(self):
|
||||||
|
from llm.pool import pick_model
|
||||||
|
|
||||||
|
pool = self._pool_serving({H1: {"chat"}, H2: {"chat", "big"}})
|
||||||
|
assert pick_model(self._cfg(), pool, H2) == "big"
|
||||||
|
|
||||||
|
def test_baseline_on_small_host(self):
|
||||||
|
from llm.pool import pick_model
|
||||||
|
|
||||||
|
pool = self._pool_serving({H1: {"chat", "big"}, H2: {"chat", "big"}})
|
||||||
|
assert pick_model(self._cfg(), pool, H1) == "chat"
|
||||||
|
|
||||||
|
def test_baseline_when_big_host_lacks_large_model(self):
|
||||||
|
from llm.pool import pick_model
|
||||||
|
|
||||||
|
pool = self._pool_serving({H1: {"chat"}, H2: {"chat"}})
|
||||||
|
assert pick_model(self._cfg(), pool, H2) == "chat"
|
||||||
|
|
||||||
|
def test_baseline_when_no_large_configured(self):
|
||||||
|
from llm.pool import pick_model
|
||||||
|
|
||||||
|
pool = self._pool_serving({H2: {"chat", "big"}})
|
||||||
|
assert pick_model(self._cfg(instruct_model_large=""), pool, H2) == "chat"
|
||||||
|
|||||||
Reference in New Issue
Block a user