Skip to content

perf(router): cache classifier model instead of reloading it on every request#319

Merged
codelion merged 2 commits into
algorithmicsuperintelligence:mainfrom
SuperMarioYL:perf/cache-router-classifier-model
Jul 12, 2026
Merged

perf(router): cache classifier model instead of reloading it on every request#319
codelion merged 2 commits into
algorithmicsuperintelligence:mainfrom
SuperMarioYL:perf/cache-router-classifier-model

Conversation

@SuperMarioYL

Copy link
Copy Markdown
Contributor

Problem

router_plugin.run() calls load_optillm_model() on every request (router_plugin.py):

def run(system_prompt, initial_query, client, model, **kwargs):
    try:
        router_model, tokenizer, device = load_optillm_model()   # <-- every call

load_optillm_model() is not cheap. Each call:

  • AutoModel.from_pretrained("answerdotai/ModernBERT-large") — deserializes a ~400M-param base model,
  • hf_hub_download("codelion/optillm-modernbert-large", "model.safetensors") — a HuggingFace Hub round-trip (ETag check + potential download),
  • load_model(...) — loads the fine-tuned state dict,
  • AutoTokenizer.from_pretrained(...) — rebuilds the tokenizer.

The actual routing decision is a single forward pass (tens of ms), but it is preceded by a full transformer load on each request. So a step that should be O(1 forward pass) is O(load a transformer) every time the router approach is used.

Fix

Memoize the loaded (model, tokenizer, device) bundle behind a module-level singleton guarded by a threading.Lock (double-checked locking):

_model_cache = None
_model_cache_lock = threading.Lock()

def load_optillm_model():
    global _model_cache
    if _model_cache is None:
        with _model_cache_lock:
            if _model_cache is None:
                _model_cache = _load_optillm_model()
    return _model_cache

The heavy loading logic is unchanged — it just moves into _load_optillm_model() and now runs once.

Why this is safe

  • Stateless at inference. predict_approach() runs the model under model.eval() + torch.no_grad() and never mutates it, so reusing one instance across requests is correctness-safe.
  • Thread-safe first load. The lock serializes the first concurrent load so the expensive work happens exactly once under the threaded Flask server; steady-state calls take the lock-free fast path (the same idiom as CacheManager in optillm/inference.py, which caches models/tokenizers with a double-checked threading.Lock).
  • No poisoned cache. The global is only assigned on a successful load, so if loading raises (e.g. a Hub/network error) the exception still propagates to run()'s existing except fallback and the next request retries the load.

The one intentional trade-off is that the model now stays resident for the process lifetime instead of being re-created and garbage-collected each request — which is the desired behavior for a long-running inference proxy that routes repeatedly.

Testing

Adds tests/test_router_plugin_caching.py (mocks the heavy loaders, so no network/weights required — CI-friendly):

  • test_repeated_calls_load_model_once — 6 calls to load_optillm_model() load the base model exactly once and return the same cached bundle.
  • test_returns_expected_bundle_shape — the cached value is the (OptILMClassifier, tokenizer, device) triple callers expect.
  • test_concurrent_calls_load_model_once — 8 threads hitting the first load concurrently still load exactly once (double-checked lock holds).

The repeated-call test is red on main (base model loads 6×) and green with this change. Runs both under pytest and standalone, matching the repo's existing test convention.

router_plugin.run() called load_optillm_model() on every request, which
deserializes the ~400M-param ModernBERT-large base model, does a HuggingFace
Hub round-trip for the fine-tuned safetensors, and rebuilds the tokenizer —
all just to run a single forward pass for the routing decision. That turns an
O(1 forward pass) routing step into O(load a transformer) per call.

Memoize the loaded (model, tokenizer, device) bundle behind a module-level
singleton guarded by a threading.Lock (double-checked locking), the same
idiom already used by CacheManager in optillm/inference.py. The classifier is
stateless at inference (predict_approach runs under model.eval() +
torch.no_grad), so reusing the loaded objects across requests is
correctness-safe. A failed load is not cached (the global is only assigned on
success), so run()'s existing except-fallback still retries on the next call.

Add tests/test_router_plugin_caching.py: repeated calls load the base model
exactly once, the cached value is the expected (model, tokenizer, device)
triple, and concurrent first-time access loads exactly once. All tests mock
the heavy loaders, so they need no network or weights.
The dedicated tests/test_router_plugin_caching.py was not in the unit-tests
job's fixed file list in .github/workflows/test.yml, so CI never executed it.
Fold the three caching tests (single-load-on-repeat, bundle shape, concurrent
single-load) into tests/test_plugins.py — which the unit-tests job already runs
via `pytest tests/test_plugins.py` — and drop the standalone file, so the
regression is actually guarded in CI.
@codelion codelion merged commit c258858 into algorithmicsuperintelligence:main Jul 12, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants