Repository navigation
fix(frontend): evict async tokenizer executors when the tokenizer is retired #13368
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
rmccorm4
merged 6 commits into
ai-dynamo:main
from
panpan0000:fix/frontend-async-tokenizer-executor-leak
Sep 16, 2026
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
5082d1b
fix(frontend): evict async tokenizer executors when the tokenizer is …
panpan0000 0752e11
fix(frontend): retain tokenizer in strong fallback pool; regenerate A…
panpan0000 e93d4c3
docs: refresh frontend API reference line anchors after black reformat
panpan0000 0a19e57
fix(frontend): shut down pooled executors with wait=False
panpan0000 8a1cbf8
fix(frontend): preserve tokenizer identity across main merge
panpan0000 1bfab05
test(frontend): cover in-flight tokenizer executor cleanup
panpan0000 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,233 @@ | ||
| # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. | ||
| # SPDX-License-Identifier: Apache-2.0 | ||
|
|
||
| """Unit tests for the async tokenizer executor pool in dynamo.frontend.prepost.""" | ||
|
|
||
| # mypy seems to be running both sides of the HAS_VLLM if statement | ||
| # mypy: ignore-errors | ||
|
|
||
| import asyncio | ||
| import gc | ||
| import threading | ||
| import weakref | ||
| from concurrent.futures import ThreadPoolExecutor | ||
| from types import SimpleNamespace | ||
|
|
||
| import pytest | ||
|
|
||
| from .common import check_module_available | ||
|
|
||
| HAS_VLLM = check_module_available("vllm.entrypoints.openai.chat_completion.protocol") | ||
| if HAS_VLLM: | ||
| from dynamo.frontend import prepost | ||
|
|
||
|
|
||
| pytestmark = [ | ||
| pytest.mark.vllm, | ||
| pytest.mark.core, | ||
| pytest.mark.gpu_0, # "Hardware" | ||
| pytest.mark.pre_merge, # "Lifecyle" | ||
| pytest.mark.unit, # "Test Type" | ||
| pytest.mark.skipif(not HAS_VLLM, reason="requires vllm"), | ||
| ] | ||
|
|
||
|
|
||
| class _DummyTokenizer: | ||
| def __init__(self): | ||
| self.prompts = [] | ||
|
|
||
| def __call__(self, prompt, **kwargs): | ||
| self.prompts.append(prompt) | ||
| return SimpleNamespace(input_ids=[1, 2, 3]) | ||
|
|
||
|
|
||
| class _BlockingTokenizer(_DummyTokenizer): | ||
| def __init__(self): | ||
| super().__init__() | ||
| self.started = threading.Event() | ||
| self.release = threading.Event() | ||
|
|
||
| def __call__(self, prompt, **kwargs): | ||
| self.started.set() | ||
| if not self.release.wait(timeout=5): | ||
| raise TimeoutError("test tokenizer was not released") | ||
| return super().__call__(prompt, **kwargs) | ||
|
|
||
|
|
||
| class _SlotsTokenizer: | ||
| # No __weakref__: weakref.ref() raises TypeError. | ||
| __slots__ = () | ||
|
|
||
| def __call__(self, prompt, **kwargs): | ||
| return SimpleNamespace(input_ids=[4, 5, 6]) | ||
|
|
||
|
|
||
| class _EqualTokenizer(_DummyTokenizer): | ||
| def __eq__(self, other): | ||
| return isinstance(other, _EqualTokenizer) | ||
|
|
||
| def __hash__(self): | ||
| return 1 | ||
|
|
||
|
|
||
| class _RecordingExecutor(ThreadPoolExecutor): | ||
| def __init__(self, *args, **kwargs): | ||
| super().__init__(*args, **kwargs) | ||
| self.shutdown_calls = 0 | ||
| self.shutdown_waits = [] | ||
|
|
||
| def shutdown(self, wait=True, *, cancel_futures=False): | ||
| self.shutdown_calls += 1 | ||
| self.shutdown_waits.append(wait) | ||
| super().shutdown(wait, cancel_futures=cancel_futures) | ||
|
|
||
|
|
||
| @pytest.fixture(autouse=True) | ||
| def _clean_pools(monkeypatch): | ||
| monkeypatch.setattr(prepost, "ThreadPoolExecutor", _RecordingExecutor) | ||
| prepost._ASYNC_TOKENIZER_EXECUTORS.clear() | ||
| prepost._STRONG_ASYNC_TOKENIZER_EXECUTORS.clear() | ||
| yield | ||
| for _, executor in list(prepost._ASYNC_TOKENIZER_EXECUTORS.values()): | ||
| executor.shutdown() | ||
| for _, executor in list(prepost._STRONG_ASYNC_TOKENIZER_EXECUTORS.values()): | ||
| executor.shutdown() | ||
| prepost._ASYNC_TOKENIZER_EXECUTORS.clear() | ||
| prepost._STRONG_ASYNC_TOKENIZER_EXECUTORS.clear() | ||
|
panpan0000 marked this conversation as resolved.
Resolved
|
||
|
|
||
|
|
||
| async def _call_tokenizer(tokenizer, prompt): | ||
| # make_async returns a Future, so the call must be awaited inside a | ||
| # running loop rather than passed to asyncio.run directly. | ||
| return await prepost._get_async_tokenizer(tokenizer)(prompt) | ||
|
|
||
|
|
||
| def test_same_tokenizer_reuses_one_executor(): | ||
| tokenizer = _DummyTokenizer() | ||
| prepost._get_async_tokenizer(tokenizer) | ||
| prepost._get_async_tokenizer(tokenizer) | ||
|
|
||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 1 | ||
| tokenizer_ref, executor = prepost._ASYNC_TOKENIZER_EXECUTORS[id(tokenizer)] | ||
| assert tokenizer_ref() is tokenizer | ||
| assert isinstance(executor, _RecordingExecutor) | ||
|
|
||
| result = asyncio.run(_call_tokenizer(tokenizer, "hello")) | ||
| assert result.input_ids == [1, 2, 3] | ||
| assert tokenizer.prompts == ["hello"] | ||
|
|
||
|
|
||
| def test_executor_evicted_and_shut_down_when_tokenizer_collected(): | ||
|
panpan0000 marked this conversation as resolved.
Resolved
|
||
| tokenizer = _DummyTokenizer() | ||
| prepost._get_async_tokenizer(tokenizer) | ||
| _, executor = prepost._ASYNC_TOKENIZER_EXECUTORS[id(tokenizer)] | ||
| tokenizer_ref = weakref.ref(tokenizer) | ||
|
|
||
| # No cached wrapper may survive this del: it would pin the tokenizer. | ||
| del tokenizer | ||
| gc.collect() | ||
|
|
||
| assert tokenizer_ref() is None | ||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 0 | ||
| assert executor.shutdown_calls == 1 | ||
|
|
||
|
|
||
| def test_new_tokenizer_after_eviction_gets_fresh_executor(): | ||
| first = _DummyTokenizer() | ||
| prepost._get_async_tokenizer(first) | ||
| _, first_executor = prepost._ASYNC_TOKENIZER_EXECUTORS[id(first)] | ||
| del first | ||
| gc.collect() | ||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 0 | ||
|
|
||
| second = _DummyTokenizer() | ||
| prepost._get_async_tokenizer(second) | ||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 1 | ||
| _, second_executor = prepost._ASYNC_TOKENIZER_EXECUTORS[id(second)] | ||
| assert second_executor is not first_executor | ||
|
|
||
|
|
||
| def test_equal_tokenizers_have_independent_executor_lifetimes(): | ||
| first = _EqualTokenizer() | ||
| second = _EqualTokenizer() | ||
| assert first == second | ||
| assert first is not second | ||
|
|
||
| prepost._get_async_tokenizer(first) | ||
| prepost._get_async_tokenizer(second) | ||
| _, first_executor = prepost._ASYNC_TOKENIZER_EXECUTORS[id(first)] | ||
| _, second_executor = prepost._ASYNC_TOKENIZER_EXECUTORS[id(second)] | ||
|
|
||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 2 | ||
| assert first_executor is not second_executor | ||
|
|
||
| first_ref = weakref.ref(first) | ||
| del first | ||
| gc.collect() | ||
|
|
||
| assert first_ref() is None | ||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 1 | ||
| assert first_executor.shutdown_calls == 1 | ||
| assert second_executor.shutdown_calls == 0 | ||
|
|
||
| result = asyncio.run(_call_tokenizer(second, "still live")) | ||
| assert result.input_ids == [1, 2, 3] | ||
| assert second.prompts == ["still live"] | ||
|
|
||
|
|
||
| async def _run_inflight_tokenizer(tokenizer): | ||
| tokenizer_ref = weakref.ref(tokenizer) | ||
| async_tokenizer = prepost._get_async_tokenizer(tokenizer) | ||
| _, executor = prepost._ASYNC_TOKENIZER_EXECUTORS[id(tokenizer)] | ||
| future = async_tokenizer("in flight") | ||
| release = tokenizer.release | ||
| assert await asyncio.to_thread(tokenizer.started.wait, 5) | ||
|
|
||
| try: | ||
| # The running work item is the only remaining strong reference to the | ||
| # tokenizer after the wrapper and caller references are released. | ||
| del async_tokenizer | ||
| del tokenizer | ||
| gc.collect() | ||
| assert tokenizer_ref() is not None | ||
|
|
||
| release.set() | ||
| result = await future | ||
| for _ in range(100): | ||
| gc.collect() | ||
| if tokenizer_ref() is None: | ||
| break | ||
| await asyncio.sleep(0.01) | ||
|
|
||
| return result, executor, tokenizer_ref() | ||
| finally: | ||
| release.set() | ||
|
|
||
|
|
||
| def test_inflight_tokenizer_is_evicted_from_worker_thread(): | ||
| result, executor, tokenizer = asyncio.run( | ||
| _run_inflight_tokenizer(_BlockingTokenizer()) | ||
| ) | ||
|
|
||
| assert result.input_ids == [1, 2, 3] | ||
| assert tokenizer is None | ||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 0 | ||
| assert executor.shutdown_calls == 1 | ||
| assert executor.shutdown_waits == [False] | ||
|
|
||
|
|
||
| def test_non_weakrefable_tokenizer_falls_back_to_strong_pool(): | ||
| tokenizer = _SlotsTokenizer() | ||
| result = asyncio.run(_call_tokenizer(tokenizer, "hi")) | ||
| assert result.input_ids == [4, 5, 6] | ||
|
|
||
| assert len(prepost._ASYNC_TOKENIZER_EXECUTORS) == 0 | ||
| assert len(prepost._STRONG_ASYNC_TOKENIZER_EXECUTORS) == 1 | ||
|
|
||
| # The fallback entry must retain the tokenizer itself so its id() cannot | ||
| # be recycled by a different tokenizer while the entry lives. | ||
| retained_tokenizer, _ = prepost._STRONG_ASYNC_TOKENIZER_EXECUTORS[id(tokenizer)] | ||
| assert retained_tokenizer is tokenizer | ||
|
|
||
| prepost._get_async_tokenizer(tokenizer) | ||
| assert len(prepost._STRONG_ASYNC_TOKENIZER_EXECUTORS) == 1 | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.