mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-07-18 21:21:05 +02:00
Fix sync LLM concurrency limit
This commit is contained in:
parent
fc401c912b
commit
56590c63d5
3 changed files with 111 additions and 47 deletions
|
|
@ -2,6 +2,7 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import threading
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from contextvars import ContextVar
|
from contextvars import ContextVar
|
||||||
|
|
||||||
|
|
@ -142,6 +143,9 @@ _MAX_CONCURRENCY: int = _env_max_concurrency_default()
|
||||||
_MAX_CONCURRENCY_OVERRIDE: ContextVar[int | None] = ContextVar(
|
_MAX_CONCURRENCY_OVERRIDE: ContextVar[int | None] = ContextVar(
|
||||||
"pageindex_max_concurrency_override", default=None
|
"pageindex_max_concurrency_override", default=None
|
||||||
)
|
)
|
||||||
|
_MAX_CONCURRENCY_SCOPE_SEMAPHORE: ContextVar[threading.Semaphore | None] = ContextVar(
|
||||||
|
"pageindex_max_concurrency_scope_semaphore", default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _validate_max_concurrency(value) -> None:
|
def _validate_max_concurrency(value) -> None:
|
||||||
|
|
@ -175,6 +179,15 @@ def _process_wide_max_concurrency() -> int:
|
||||||
return _MAX_CONCURRENCY
|
return _MAX_CONCURRENCY
|
||||||
|
|
||||||
|
|
||||||
|
def _max_concurrency_scope_semaphore() -> threading.Semaphore | None:
|
||||||
|
"""Return the semaphore backing the active scoped override, if any.
|
||||||
|
|
||||||
|
Internal helper used by the LLM call sites so sync and async completions
|
||||||
|
share the same scoped cap when a context is copied across threads.
|
||||||
|
"""
|
||||||
|
return _MAX_CONCURRENCY_SCOPE_SEMAPHORE.get()
|
||||||
|
|
||||||
|
|
||||||
def set_max_concurrency(value: int) -> None:
|
def set_max_concurrency(value: int) -> None:
|
||||||
"""Set the process-wide default cap on concurrent in-flight LLM calls."""
|
"""Set the process-wide default cap on concurrent in-flight LLM calls."""
|
||||||
global _MAX_CONCURRENCY
|
global _MAX_CONCURRENCY
|
||||||
|
|
@ -193,10 +206,13 @@ def max_concurrency_scope(value: int | None):
|
||||||
"""
|
"""
|
||||||
if value is not None:
|
if value is not None:
|
||||||
_validate_max_concurrency(value)
|
_validate_max_concurrency(value)
|
||||||
|
scoped_sem = threading.Semaphore(value) if value is not None else None
|
||||||
token = _MAX_CONCURRENCY_OVERRIDE.set(value)
|
token = _MAX_CONCURRENCY_OVERRIDE.set(value)
|
||||||
|
sem_token = _MAX_CONCURRENCY_SCOPE_SEMAPHORE.set(scoped_sem)
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
_MAX_CONCURRENCY_SCOPE_SEMAPHORE.reset(sem_token)
|
||||||
_MAX_CONCURRENCY_OVERRIDE.reset(token)
|
_MAX_CONCURRENCY_OVERRIDE.reset(token)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import copy
|
||||||
import re
|
import re
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
import weakref
|
|
||||||
import PyPDF2
|
import PyPDF2
|
||||||
import pymupdf
|
import pymupdf
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -21,9 +20,14 @@ from pprint import pprint
|
||||||
# `pageindex.config` submodule for those modules.
|
# `pageindex.config` submodule for those modules.
|
||||||
from types import SimpleNamespace as _config
|
from types import SimpleNamespace as _config
|
||||||
|
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager, contextmanager
|
||||||
|
|
||||||
from ..config import get_llm_params, get_max_concurrency, _process_wide_max_concurrency
|
from ..config import (
|
||||||
|
get_llm_params,
|
||||||
|
get_max_concurrency,
|
||||||
|
_max_concurrency_scope_semaphore,
|
||||||
|
_process_wide_max_concurrency,
|
||||||
|
)
|
||||||
from ..tokens import count_tokens # re-exported for backward compat
|
from ..tokens import count_tokens # re-exported for backward compat
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -41,16 +45,6 @@ _PROCESS_LLM_SEMAPHORE: threading.Semaphore | None = None
|
||||||
_PROCESS_LLM_SEMAPHORE_SIZE: int | None = None
|
_PROCESS_LLM_SEMAPHORE_SIZE: int | None = None
|
||||||
_PROCESS_LLM_SEMAPHORE_LOCK = threading.Lock()
|
_PROCESS_LLM_SEMAPHORE_LOCK = threading.Lock()
|
||||||
|
|
||||||
# Per-loop, per-size semaphores for a max_concurrency_scope() override that's
|
|
||||||
# narrower than the process ceiling — isolates one call's own subtree to a
|
|
||||||
# tighter self-imposed limit without needing to be cross-thread itself (it can
|
|
||||||
# never let MORE calls through than the process ceiling above already allows,
|
|
||||||
# since both are held simultaneously; see _llm_semaphore). Keyed by (loop, size)
|
|
||||||
# rather than just loop so a later scope with a different size in the same loop
|
|
||||||
# isn't silently ignored.
|
|
||||||
_SCOPED_LLM_SEMAPHORES: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
|
|
||||||
_SCOPED_LLM_SEMAPHORES_LOCK = threading.Lock()
|
|
||||||
|
|
||||||
|
|
||||||
def _process_ceiling_semaphore() -> threading.Semaphore:
|
def _process_ceiling_semaphore() -> threading.Semaphore:
|
||||||
global _PROCESS_LLM_SEMAPHORE, _PROCESS_LLM_SEMAPHORE_SIZE
|
global _PROCESS_LLM_SEMAPHORE, _PROCESS_LLM_SEMAPHORE_SIZE
|
||||||
|
|
@ -62,33 +56,14 @@ def _process_ceiling_semaphore() -> threading.Semaphore:
|
||||||
return _PROCESS_LLM_SEMAPHORE
|
return _PROCESS_LLM_SEMAPHORE
|
||||||
|
|
||||||
|
|
||||||
def _scoped_llm_semaphore(size: int) -> asyncio.Semaphore:
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
per_loop = _SCOPED_LLM_SEMAPHORES.get(loop)
|
|
||||||
if per_loop is None:
|
|
||||||
with _SCOPED_LLM_SEMAPHORES_LOCK:
|
|
||||||
per_loop = _SCOPED_LLM_SEMAPHORES.get(loop)
|
|
||||||
if per_loop is None:
|
|
||||||
per_loop = {}
|
|
||||||
_SCOPED_LLM_SEMAPHORES[loop] = per_loop
|
|
||||||
sem = per_loop.get(size)
|
|
||||||
if sem is None:
|
|
||||||
with _SCOPED_LLM_SEMAPHORES_LOCK:
|
|
||||||
sem = per_loop.get(size)
|
|
||||||
if sem is None:
|
|
||||||
sem = asyncio.Semaphore(size)
|
|
||||||
per_loop[size] = sem
|
|
||||||
return sem
|
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def _llm_semaphore():
|
async def _llm_semaphore():
|
||||||
"""Bound concurrent in-flight LLM calls to a TRUE process-wide ceiling,
|
"""Bound concurrent in-flight LLM calls to a TRUE process-wide ceiling,
|
||||||
optionally narrowed further by an active max_concurrency_scope() override.
|
optionally narrowed further by an active max_concurrency_scope() override.
|
||||||
|
|
||||||
Acquired only around the leaf ``litellm.acompletion`` call in
|
Acquired only around the leaf ``litellm.acompletion`` call in
|
||||||
``llm_acompletion`` — the single point every LLM request funnels through —
|
``llm_acompletion`` — sync calls use ``_sync_llm_semaphore`` below — so the
|
||||||
so the cap holds no matter how deeply the indexing gathers nest
|
cap holds no matter how deeply the indexing gathers nest
|
||||||
(``tree_parser`` → ``process_large_node_recursively`` → …) AND no matter how
|
(``tree_parser`` → ``process_large_node_recursively`` → …) AND no matter how
|
||||||
many threads are each running their own indexing job concurrently. Bounding
|
many threads are each running their own indexing job concurrently. Bounding
|
||||||
at the leaf rather than at each gather call site is also deadlock-free: a
|
at the leaf rather than at each gather call site is also deadlock-free: a
|
||||||
|
|
@ -97,7 +72,7 @@ async def _llm_semaphore():
|
||||||
|
|
||||||
The process ceiling (threading.Semaphore, shared cross-thread) is sized from
|
The process ceiling (threading.Semaphore, shared cross-thread) is sized from
|
||||||
the process-wide default only; a narrower max_concurrency_scope() override
|
the process-wide default only; a narrower max_concurrency_scope() override
|
||||||
is enforced as a second, nested, per-loop restriction — it can only
|
is enforced as a second, nested context-local restriction — it can only
|
||||||
*tighten* the effective cap for its own call tree, never widen it past the
|
*tighten* the effective cap for its own call tree, never widen it past the
|
||||||
ceiling. Without the outer bound a many-node document opens one socket per
|
ceiling. Without the outer bound a many-node document opens one socket per
|
||||||
node at once and exhausts the process file-descriptor limit (Errno 24).
|
node at once and exhausts the process file-descriptor limit (Errno 24).
|
||||||
|
|
@ -115,15 +90,45 @@ async def _llm_semaphore():
|
||||||
# we've already given up on it.
|
# we've already given up on it.
|
||||||
while not ceiling_sem.acquire(False):
|
while not ceiling_sem.acquire(False):
|
||||||
await asyncio.sleep(0.05)
|
await asyncio.sleep(0.05)
|
||||||
|
scoped_sem = None
|
||||||
try:
|
try:
|
||||||
effective = get_max_concurrency()
|
effective = get_max_concurrency()
|
||||||
ceiling = _process_wide_max_concurrency()
|
ceiling = _process_wide_max_concurrency()
|
||||||
if effective < ceiling:
|
if effective < ceiling:
|
||||||
async with _scoped_llm_semaphore(effective):
|
scoped_sem = _max_concurrency_scope_semaphore()
|
||||||
yield
|
if scoped_sem is not None:
|
||||||
|
while not scoped_sem.acquire(False):
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
yield
|
||||||
else:
|
else:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
|
if scoped_sem is not None:
|
||||||
|
scoped_sem.release()
|
||||||
|
ceiling_sem.release()
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _sync_llm_semaphore():
|
||||||
|
"""Synchronous companion to ``_llm_semaphore`` for ``llm_completion``.
|
||||||
|
|
||||||
|
It uses the same process-wide ceiling so sync and async LLM calls share one
|
||||||
|
real cap. A scoped override can only narrow that cap for the active context.
|
||||||
|
"""
|
||||||
|
ceiling_sem = _process_ceiling_semaphore()
|
||||||
|
ceiling_sem.acquire()
|
||||||
|
scoped_sem = None
|
||||||
|
try:
|
||||||
|
effective = get_max_concurrency()
|
||||||
|
ceiling = _process_wide_max_concurrency()
|
||||||
|
if effective < ceiling:
|
||||||
|
scoped_sem = _max_concurrency_scope_semaphore()
|
||||||
|
if scoped_sem is not None:
|
||||||
|
scoped_sem.acquire()
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if scoped_sem is not None:
|
||||||
|
scoped_sem.release()
|
||||||
ceiling_sem.release()
|
ceiling_sem.release()
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -134,13 +139,16 @@ def llm_completion(model, prompt, chat_history=None, return_finish_reason=False)
|
||||||
messages = list(chat_history) + [{"role": "user", "content": prompt}] if chat_history else [{"role": "user", "content": prompt}]
|
messages = list(chat_history) + [{"role": "user", "content": prompt}] if chat_history else [{"role": "user", "content": prompt}]
|
||||||
for i in range(max_retries):
|
for i in range(max_retries):
|
||||||
try:
|
try:
|
||||||
response = litellm.completion(
|
# Hold a concurrency slot only around the actual network call, not
|
||||||
model=model,
|
# retry backoff, so sync completions obey the same cap as async ones.
|
||||||
messages=messages,
|
with _sync_llm_semaphore():
|
||||||
# Per-call litellm kwargs (default temperature=0, drop_params=True);
|
response = litellm.completion(
|
||||||
# configure via config.set_llm_params(...) — never the litellm global.
|
model=model,
|
||||||
**get_llm_params(),
|
messages=messages,
|
||||||
)
|
# Per-call litellm kwargs (default temperature=0, drop_params=True);
|
||||||
|
# configure via config.set_llm_params(...) — never the litellm global.
|
||||||
|
**get_llm_params(),
|
||||||
|
)
|
||||||
content = response.choices[0].message.content
|
content = response.choices[0].message.content
|
||||||
if return_finish_reason:
|
if return_finish_reason:
|
||||||
finish_reason = "max_output_reached" if response.choices[0].finish_reason == "length" else "finished"
|
finish_reason = "max_output_reached" if response.choices[0].finish_reason == "length" else "finished"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import asyncio
|
import asyncio
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
|
|
@ -16,7 +18,12 @@ from pageindex.config import (
|
||||||
set_llm_params,
|
set_llm_params,
|
||||||
set_max_concurrency,
|
set_max_concurrency,
|
||||||
)
|
)
|
||||||
from pageindex.index.utils import _llm_semaphore, _process_ceiling_semaphore, llm_acompletion
|
from pageindex.index.utils import (
|
||||||
|
_llm_semaphore,
|
||||||
|
_process_ceiling_semaphore,
|
||||||
|
llm_acompletion,
|
||||||
|
llm_completion,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
|
|
@ -142,8 +149,8 @@ def test_llm_semaphore_uses_scoped_override():
|
||||||
|
|
||||||
|
|
||||||
def test_llm_acompletion_holds_the_shared_semaphore(monkeypatch):
|
def test_llm_acompletion_holds_the_shared_semaphore(monkeypatch):
|
||||||
# Prove llm_acompletion (the single chokepoint every LLM call funnels
|
# Prove llm_acompletion actually acquires the shared cap around the async
|
||||||
# through) actually acquires the shared cap around the network call.
|
# network call.
|
||||||
set_max_concurrency(3)
|
set_max_concurrency(3)
|
||||||
state = {"in_flight": 0, "peak": 0}
|
state = {"in_flight": 0, "peak": 0}
|
||||||
|
|
||||||
|
|
@ -182,6 +189,39 @@ def test_llm_acompletion_passes_a_timeout_to_litellm(monkeypatch):
|
||||||
assert "timeout" in seen and seen["timeout"] == get_llm_params()["timeout"]
|
assert "timeout" in seen and seen["timeout"] == get_llm_params()["timeout"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_llm_completion_holds_the_shared_semaphore(monkeypatch):
|
||||||
|
# Sync litellm.completion calls must share the same process-wide cap as the
|
||||||
|
# async path; otherwise concurrent indexing threads can exceed
|
||||||
|
# set_max_concurrency().
|
||||||
|
set_max_concurrency(1)
|
||||||
|
state = {"in_flight": 0, "peak": 0}
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def fake_completion(**kwargs):
|
||||||
|
with lock:
|
||||||
|
state["in_flight"] += 1
|
||||||
|
state["peak"] = max(state["peak"], state["in_flight"])
|
||||||
|
time.sleep(0.02)
|
||||||
|
with lock:
|
||||||
|
state["in_flight"] -= 1
|
||||||
|
return SimpleNamespace(
|
||||||
|
choices=[
|
||||||
|
SimpleNamespace(
|
||||||
|
message=SimpleNamespace(content="ok"),
|
||||||
|
finish_reason="stop",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr("litellm.completion", fake_completion)
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=3) as pool:
|
||||||
|
results = list(pool.map(lambda i: llm_completion("gpt-x", f"p{i}"), range(6)))
|
||||||
|
|
||||||
|
assert results == ["ok"] * 6
|
||||||
|
assert state["peak"] == 1
|
||||||
|
|
||||||
|
|
||||||
def test_run_async_propagates_scope_into_worker_thread():
|
def test_run_async_propagates_scope_into_worker_thread():
|
||||||
# When build_index runs inside an already-running loop, _run_async hops to a
|
# When build_index runs inside an already-running loop, _run_async hops to a
|
||||||
# worker thread. The max_concurrency_scope override must ride along (copied
|
# worker thread. The max_concurrency_scope override must ride along (copied
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue