Files
AstrAI/tests/inference/test_scheduler.py
T
ViperEkura ae7fc3059a refactor: harden inference cache state and attention dispatch
- split KVCache into phase-specific PrefillKVCache/DecodeKVCache types selected by start_pos
- unify steady-state detection in TaskCacheManager
- guard decode steady-state reuse with the cached task signature so recycled req slots cannot replay a prior generation's tokens and positions
- collapse attention backend fwd_decode/fwd_prefill into a single subclass-owned forward with a shared _check_fwd guard
- fix thread-safety gap in weight update and validate prefill inputs before KV allocation
- centralize magic constants in InferenceConfig and align docs with behavior
2026-09-04 14:28:04 +08:00

811 lines
28 KiB
Python

"""Tests for scheduler concurrency."""
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import torch
from astrai.extension import CudaBackend, TorchNativeBackend, get_backend
from astrai.inference import GenerationResult, InferenceScheduler
from astrai.inference.metrics import MetricsCollector
from astrai.inference.runtime.executor import DecodeSteadyState, Executor
from astrai.inference.task import Task
from astrai.model.transformer import AutoRegressiveLM
from tests.helpers import FakeTokenizer, make_rollout_config
@pytest.fixture
def mock_model_and_tokenizer():
"""Create mock model and tokenizer."""
mock_model = MagicMock()
mock_model.config = MagicMock()
mock_model.config.num_key_value_heads = 8
mock_model.config.num_attention_heads = 8
mock_model.config.hidden_size = 128
mock_model.config.num_hidden_layers = 2
mock_model.config.max_position_embeddings = 100
mock_model.parameters.return_value = iter(
[MagicMock(dtype=torch.float32, device=torch.device("cpu"))]
)
mock_tokenizer = MagicMock()
mock_tokenizer.encode.return_value = [1, 2, 3, 4, 5]
mock_tokenizer.decode.return_value = "token"
mock_tokenizer.stop_ids = [0]
mock_tokenizer.pad_id = None
return mock_model, mock_tokenizer
def _make_mock_scheduler(mock_model_and_tokenizer):
"""Build a CPU scheduler over mocks, patching scheduler-internal imports."""
mock_model, mock_tokenizer = mock_model_and_tokenizer
with (
patch("astrai.inference.scheduler.AutoModel"),
patch("astrai.inference.scheduler.AutoTokenizer"),
):
return InferenceScheduler(
model=mock_model,
tokenizer=mock_tokenizer,
max_batch_size=4,
device="cpu",
)
def _run_threads(*workers, timeout=10.0):
threads = [threading.Thread(target=worker) for worker in workers]
for t in threads:
t.start()
for t in threads:
t.join(timeout=timeout)
def test_scheduler_concurrent_add_task(mock_model_and_tokenizer):
"""Test concurrent add_task operations."""
scheduler = _make_mock_scheduler(mock_model_and_tokenizer)
results = {"task_ids": [], "errors": []}
lock = threading.Lock()
def add_task_worker(worker_id):
try:
for i in range(10):
task_id = scheduler.add_task(f"prompt from worker {worker_id}-{i}")
with lock:
results["task_ids"].append(task_id)
except Exception as e:
results["errors"].append(str(e))
_run_threads(*(lambda wid=i: add_task_worker(wid) for i in range(5)))
scheduler.stop()
assert len(results["errors"]) == 0, f"Errors: {results['errors']}"
assert len(results["task_ids"]) == 50
def test_generation_loop_activates_backend_in_worker_thread():
scheduler = object.__new__(InferenceScheduler)
scheduler._backend = TorchNativeBackend()
scheduler._stop_event = threading.Event()
scheduler._task_cache = MagicMock()
observed = []
task_mgr = MagicMock()
task_mgr.tokenizer.stop_ids = [0]
task_mgr.remove_finished_tasks.return_value = []
task_mgr.get_active_tasks.return_value = []
task_mgr.max_batch_size = 1
task_mgr.pull_candidates.return_value = []
task_mgr.has_work.return_value = False
def observe_backend(*args, **kwargs):
observed.append(type(get_backend()))
scheduler._stop_event.set()
task_mgr.wait_for_tasks.side_effect = observe_backend
scheduler._task_mgr = task_mgr
thread = threading.Thread(target=scheduler._run_generation_loop)
thread.start()
thread.join(timeout=5)
assert not thread.is_alive()
assert observed == [TorchNativeBackend]
def test_step_splits_decode_batch_by_request_backend():
scheduler = object.__new__(InferenceScheduler)
scheduler._task_cache = MagicMock()
scheduler._task_cache.task_extend.return_value = True
scheduler._metrics = MetricsCollector()
scheduler._executor = MagicMock()
observed = []
def execute(tasks, **kwargs):
observed.append((type(get_backend()), [task.task_id for task in tasks]))
return [1] * len(tasks)
scheduler._executor.execute_decode.side_effect = execute
torch_task = Task("torch", [1], backend=TorchNativeBackend())
cuda_task = Task("cuda", [1], backend=CudaBackend())
for task in (torch_task, cuda_task):
task.input_tokens = 1
task.output_ids = [1]
task.mark_prefill_done()
scheduler._metrics.register(task.task_id)
produced, aborted = scheduler._step([torch_task, cuda_task])
assert aborted == []
assert produced == [torch_task, cuda_task]
assert observed == [
(TorchNativeBackend, ["torch"]),
(CudaBackend, ["cuda"]),
]
def test_step_batches_ragged_prefill_with_shared_cache_start():
scheduler = object.__new__(InferenceScheduler)
scheduler._cache = SimpleNamespace(page_size=64)
scheduler._task_cache = MagicMock()
scheduler._task_cache.task_cached.return_value = 0
scheduler._metrics = MetricsCollector()
scheduler._executor = MagicMock()
short = Task("short", [1, 2, 3])
long = Task("long", [4, 5, 6, 7, 8])
for task in (short, long):
scheduler._metrics.register(task.task_id)
scheduler._executor.execute_prefill.return_value = (
[long, short],
[11, 12],
)
produced, aborted = scheduler._step([short, long])
assert aborted == []
assert produced == [long, short]
scheduler._executor.execute_prefill.assert_called_once_with(
[short, long], start_pos=0, return_logprobs=False
)
assert long.output_ids == [11]
assert short.output_ids == [12]
def test_execute_prefill_packs_ragged_prompts_and_selects_last_logits():
executor = object.__new__(Executor)
executor.device = torch.device("cpu")
executor.task_cache = MagicMock()
executor.task_cache.bind.return_value = MagicMock()
executor._workspace = MagicMock()
executor._workspace.max_batch_size = 16 # Add max_batch_size for validation
all_logits = torch.arange(42, dtype=torch.float32).reshape(6, 7)
executor.model = MagicMock(return_value={"logits": all_logits})
executor._sample_logits = MagicMock(
return_value=([101, 102], torch.tensor([101, 102]))
)
task_b = Task("b", [20, 21, 22, 23, 24])
task_a = Task("a", [10, 11, 12])
tasks, output = executor.execute_prefill([task_b, task_a], start_pos=1)
assert tasks == [task_a, task_b]
assert output == [101, 102]
model_args, model_kwargs = executor.model.call_args
assert model_args[0].tolist() == [11, 12, 21, 22, 23, 24]
assert model_kwargs["position_ids"].tolist() == [1, 2, 1, 2, 3, 4]
executor.task_cache.bind.assert_called_once_with(
["a", "b"], executor._workspace, start_pos=1
)
sample_args, sample_kwargs = executor._sample_logits.call_args
torch.testing.assert_close(sample_args[0], all_logits[[1, 5]])
assert sample_args[1] == [task_a, task_b]
assert sample_args[2] is False
assert sample_kwargs == {}
def test_scheduler_concurrent_add_remove_task(mock_model_and_tokenizer):
"""Test concurrent add and remove task operations."""
scheduler = _make_mock_scheduler(mock_model_and_tokenizer)
results = {"added": [], "removed": [], "errors": []}
add_ready = threading.Event()
def add_worker():
try:
for i in range(20):
task_id = scheduler.add_task(f"prompt {i}")
results["added"].append(task_id)
if len(results["added"]) >= 10:
add_ready.set()
except Exception as e:
results["errors"].append(f"Add: {str(e)}")
def remove_worker():
try:
add_ready.wait(timeout=5.0)
for task_id in results["added"][:10]:
scheduler.remove_task(task_id)
results["removed"].append(task_id)
except Exception as e:
results["errors"].append(f"Remove: {str(e)}")
_run_threads(add_worker, remove_worker)
scheduler.stop()
assert len(results["errors"]) == 0, f"Errors: {results['errors']}"
assert len(results["added"]) == 20
def test_scheduler_concurrent_get_stats(mock_model_and_tokenizer):
"""Test concurrent get_stats operations."""
scheduler = _make_mock_scheduler(mock_model_and_tokenizer)
results = {"stats": [], "errors": []}
started = threading.Event()
stats_done = threading.Event()
def add_tasks():
try:
for i in range(20):
scheduler.add_task(f"prompt {i}")
started.set()
except Exception as e:
results["errors"].append(f"Add: {str(e)}")
def get_stats():
try:
started.wait(timeout=5.0)
for _ in range(50):
stats = scheduler.get_stats()
results["stats"].append(stats)
stats_done.set()
except Exception as e:
results["errors"].append(f"Get stats: {str(e)}")
_run_threads(add_tasks, get_stats)
scheduler.stop()
stats_done.wait(timeout=5.0)
assert len(results["errors"]) == 0, f"Errors: {results['errors']}"
assert len(results["stats"]) == 50
for stats in results["stats"]:
assert "total_tasks" in stats
assert stats["total_tasks"] >= 0
def _make_real_scheduler(device):
"""Build a scheduler backed by a tiny real model for run_batch tests."""
cfg = make_rollout_config(max_position_embeddings=64)
model = AutoRegressiveLM(cfg).to(device=device, dtype=torch.bfloat16).eval()
tokenizer = FakeTokenizer()
scheduler = InferenceScheduler(
model=model,
tokenizer=tokenizer,
max_batch_size=8,
max_seq_len=64,
)
return scheduler, tokenizer, model
def test_cancel_waiting_task_storm_returns_to_baseline(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
task_ids = [
scheduler.add_task(f"waiting-{index}", max_tokens=32) for index in range(32)
]
assert all(scheduler.cancel_task(task_id) for task_id in task_ids)
stats = scheduler.get_stats()
assert stats["active_tasks"] == 0
assert stats["waiting_tasks"] == 0
assert stats["in_flight_tasks"] == 0
assert stats["kv_cache_tasks"] == 0
assert stats["cancelled_total"] == len(task_ids)
finally:
scheduler.stop()
def test_cancel_active_task_releases_metrics_and_kv(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
task_id = scheduler.add_task("active", max_tokens=32)
task = scheduler._task_mgr.pull_candidates(1)[0]
assert scheduler._task_cache.task_alloc(task.task_id, task.prompt_ids)
assert scheduler._task_mgr.activate(task)
before = scheduler.get_stats()
assert before["active_tasks"] == 1
assert before["in_flight_tasks"] == 1
assert before["kv_cache_tasks"] == 1
assert scheduler.cancel_task(task_id)
scheduler.start()
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
after = scheduler.get_stats()
if (
after["active_tasks"] == 0
and after["in_flight_tasks"] == 0
and after["kv_cache_tasks"] == 0
):
break
time.sleep(0.01)
assert after["active_tasks"] == 0
assert after["waiting_tasks"] == 0
assert after["in_flight_tasks"] == 0
assert after["kv_cache_tasks"] == 0
assert after["cancelled_total"] == 1
finally:
scheduler.stop()
def test_cancel_during_kv_allocation_releases_metrics_and_kv(device):
scheduler, _tok, _model = _make_real_scheduler(device)
allocation_started = threading.Event()
continue_allocation = threading.Event()
original_alloc = scheduler._task_cache.task_alloc
def blocking_alloc(*args, **kwargs):
allocation_started.set()
assert continue_allocation.wait(timeout=5)
return original_alloc(*args, **kwargs)
try:
with patch.object(
scheduler._task_cache,
"task_alloc",
side_effect=blocking_alloc,
):
scheduler.start()
task_id = scheduler.add_task("allocation-race", max_tokens=32)
assert allocation_started.wait(timeout=5)
assert scheduler.cancel_task(task_id)
continue_allocation.set()
deadline = time.monotonic() + 5
while time.monotonic() < deadline:
stats = scheduler.get_stats()
if (
stats["active_tasks"] == 0
and stats["waiting_tasks"] == 0
and stats["in_flight_tasks"] == 0
and stats["kv_cache_tasks"] == 0
):
break
time.sleep(0.01)
assert stats["active_tasks"] == 0
assert stats["waiting_tasks"] == 0
assert stats["in_flight_tasks"] == 0
assert stats["kv_cache_tasks"] == 0
assert stats["cancelled_total"] == 1
finally:
continue_allocation.set()
scheduler.stop()
def test_run_batch_returns_token_sequences(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
prompts = [[10, 20, 30], [5, 6, 7, 8]]
results = scheduler.run_batch(prompts, max_tokens=4, temperature=1.0)
assert len(results) == 2
for ids in results:
assert isinstance(ids, list)
assert len(ids) <= 4
assert all(0 <= i < 200 for i in ids)
finally:
scheduler.stop()
def test_run_batch_tokens_match_full_sequence_forward(device):
scheduler, _tok, model = _make_real_scheduler(device)
prompt = [10, 20, 30, 40]
try:
expected = []
sequence = list(prompt)
for _ in range(2):
input_ids = torch.tensor([sequence], dtype=torch.long, device=device)
position_ids = torch.arange(len(sequence), device=device).unsqueeze(0)
input_mask = torch.ones(
1, len(sequence), len(sequence), dtype=torch.bool, device=device
).tril()
with torch.inference_mode():
logits = model(
input_ids,
input_mask=input_mask,
position_ids=position_ids,
)["logits"][:, -1, :]
token = logits.argmax(dim=-1).item()
expected.append(token)
sequence.append(token)
result = scheduler.run_batch(
prompt_ids_list=[prompt], max_tokens=2, temperature=0
)
assert result == [expected]
finally:
scheduler.stop()
def test_run_batch_return_logprobs_aligned(device):
"""return_logprobs=True gives (token_ids, logprobs) tuples with equal len."""
scheduler, _tok, _model = _make_real_scheduler(device)
try:
prompts = [[10, 20, 30, 40]]
results = scheduler.run_batch(
prompts, max_tokens=5, temperature=1.0, return_logprobs=True
)
assert len(results) == 1
token_ids, logprobs = results[0]
assert len(token_ids) == len(logprobs)
assert all(lp <= 1e-5 for lp in logprobs) # logprobs ≤ 0
finally:
scheduler.stop()
def test_ragged_prefill_matches_sequential_greedy_tokens_and_logprobs(device):
scheduler, _tok, _model = _make_real_scheduler(device)
prompts = [
[10, 20, 30],
[5, 6, 7, 8],
[40, 41, 42, 43, 44],
]
try:
ragged = scheduler.run_batch(
prompts, max_tokens=1, temperature=0, return_logprobs=True
)
sequential = [
scheduler.run_batch(
[prompt], max_tokens=1, temperature=0, return_logprobs=True
)[0]
for prompt in prompts
]
assert [result[0] for result in ragged] == [result[0] for result in sequential]
for ragged_result, sequential_result in zip(ragged, sequential):
assert ragged_result[1] == pytest.approx(sequential_result[1], abs=1e-6)
finally:
scheduler.stop()
def test_run_batch_zero_max_tokens_returns_empty(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
assert scheduler.run_batch([[10, 20, 30]], max_tokens=0) == [[]]
finally:
scheduler.stop()
def test_run_batch_stop_id_terminates(device):
"""A token matching stop_ids terminates generation for that prompt."""
scheduler, _tok, _model = _make_real_scheduler(device)
try:
# Make every token a stop id: generation must end after exactly
# one token (the stop token itself) instead of running to max_tokens.
scheduler._task_mgr.tokenizer.stop_ids = list(range(200))
prompts = [[10, 20, 30]]
results = scheduler.run_batch(prompts, max_tokens=32, temperature=1.0)
assert len(results[0]) == 1
finally:
scheduler.stop()
def test_run_batch_empty_prompts(device):
"""Empty prompt list yields empty result list."""
scheduler, _tok, _model = _make_real_scheduler(device)
try:
assert scheduler.run_batch([], max_tokens=4) == []
finally:
scheduler.stop()
def test_scheduler_weight_versions_are_monotonic_and_acknowledged(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
assert scheduler.policy_version == 0
assert scheduler.update_weights(1) == 1
assert scheduler.policy_version == 1
assert scheduler.get_stats()["policy_version"] == 1
assert scheduler.update_weights(1) == 1
with pytest.raises(ValueError, match="cannot move backwards"):
scheduler.update_weights(0)
with pytest.raises(ValueError, match="non-negative integer"):
scheduler.update_weights(True)
finally:
scheduler.stop()
def test_scheduler_applies_weight_mutation_and_version_atomically(device):
scheduler, _tok, model = _make_real_scheduler(device)
before = next(model.parameters()).detach().clone()
def mutate():
with torch.no_grad():
next(model.parameters()).add_(1)
return "updated"
try:
assert scheduler.apply_weight_update(1, mutate) == "updated"
assert scheduler.policy_version == 1
assert not torch.equal(next(model.parameters()), before)
with pytest.raises(ValueError, match="must advance"):
scheduler.apply_weight_update(1, mutate)
def failed_mutation():
raise RuntimeError("optimizer failed")
with pytest.raises(RuntimeError, match="optimizer failed"):
scheduler.apply_weight_update(2, failed_mutation)
assert scheduler.policy_version == 1
# None derives live+1 under the lock: no read-compute-write race
# on the current version for advance-by-one callers.
assert scheduler.apply_weight_update(None, mutate) == "updated"
assert scheduler.policy_version == 2
finally:
scheduler.stop()
def test_scheduler_atomic_advance_survives_interleaved_publish(device):
"""A concurrent publish between reading the live version and applying
the update must not fail ``require_advance`` (regression: callers
computed live+1 outside the lock, a TOCTOU that raised spuriously)."""
scheduler, _tok, _model = _make_real_scheduler(device)
try:
# Simulate the race directly: a version read that goes stale before
# apply_weight_update acquires the lock. With None the scheduler
# re-derives live+1 inside the critical section.
stale_read = scheduler.policy_version + 1
scheduler.update_weights(1)
assert stale_read == 1 # now equals live -> explicit form would raise
with pytest.raises(ValueError, match="must advance"):
scheduler.apply_weight_update(stale_read, lambda: "ok")
assert scheduler.apply_weight_update(None, lambda: "ok") == "ok"
assert scheduler.policy_version == 2
finally:
scheduler.stop()
def test_scheduler_serializes_policy_snapshot_and_direct_update(device):
scheduler, _tok, _model = _make_real_scheduler(device)
snapshot_started = threading.Event()
release_snapshot = threading.Event()
update_finished = threading.Event()
errors = []
def inspect(version):
assert version == 0
snapshot_started.set()
assert release_snapshot.wait(timeout=5)
def take_snapshot():
try:
scheduler.with_policy_snapshot(inspect)
except BaseException as exc:
errors.append(exc)
def update():
try:
scheduler.update_weights(1)
update_finished.set()
except BaseException as exc:
errors.append(exc)
snapshot_thread = threading.Thread(target=take_snapshot)
update_thread = threading.Thread(target=update)
try:
snapshot_thread.start()
assert snapshot_started.wait(timeout=5)
update_thread.start()
assert not update_finished.wait(timeout=0.1)
release_snapshot.set()
snapshot_thread.join(timeout=5)
update_thread.join(timeout=5)
assert not snapshot_thread.is_alive()
assert not update_thread.is_alive()
assert errors == []
assert scheduler.policy_version == 1
finally:
release_snapshot.set()
snapshot_thread.join(timeout=5)
update_thread.join(timeout=5)
scheduler.stop()
def test_scheduler_rejects_weight_update_with_queued_tasks(device):
scheduler, _tok, _model = _make_real_scheduler(device)
task_id = scheduler.add_task("queued")
try:
with pytest.raises(RuntimeError, match="while tasks are queued"):
scheduler.update_weights(1)
scheduler.remove_task(task_id)
assert scheduler.update_weights(1) == 1
finally:
scheduler.stop()
def test_run_batch_too_long_prompt_skipped(device):
"""A prompt longer than max_seq_len yields an empty result slot."""
scheduler, _tok, _model = _make_real_scheduler(device)
try:
long = list(range(100)) # > max_seq_len=64
results = scheduler.run_batch([long, [10, 20]], max_tokens=2)
assert results[0] == []
assert len(results[1]) <= 2
finally:
scheduler.stop()
def test_run_batch_details_distinguish_rejection_from_success(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
long_prompt = list(range(100))
results = scheduler.run_batch(
[long_prompt, [10, 20]],
max_tokens=2,
temperature=0,
return_logprobs=True,
return_details=True,
)
assert results[0] == GenerationResult(
token_ids=[],
logprobs=[],
finish_reason="rejected",
error_reason="prompt_too_long",
)
assert results[1].finish_reason in ("stop", "length")
assert results[1].error_reason is None
assert len(results[1].token_ids) == len(results[1].logprobs)
finally:
scheduler.stop()
def test_run_batch_details_report_non_positive_max_tokens(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
result = scheduler.run_batch([[10, 20]], max_tokens=0, return_details=True)[0]
assert result.finish_reason == "rejected"
assert result.error_reason == "max_tokens_non_positive"
finally:
scheduler.stop()
def test_run_batch_details_report_allocation_failure(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
with patch.object(scheduler._task_cache, "task_alloc", return_value=False):
result = scheduler.run_batch([[10, 20]], max_tokens=2, return_details=True)[
0
]
assert result.finish_reason == "rejected"
assert result.error_reason == "kv_cache_allocation_failed"
finally:
scheduler.stop()
def test_run_batch_details_report_extension_failure_and_cleanup(device):
scheduler, _tok, _model = _make_real_scheduler(device)
try:
with patch.object(
scheduler,
"_step",
side_effect=lambda tasks, **_kwargs: ([], list(tasks)),
):
result = scheduler.run_batch([[10, 20]], max_tokens=2, return_details=True)[
0
]
assert result.finish_reason == "rejected"
assert result.error_reason == "kv_cache_extension_failed"
assert scheduler._task_cache._states == {}
assert scheduler._metrics._timings == {}
finally:
scheduler.stop()
def test_decode_does_not_reuse_previous_batch_state():
executor = object.__new__(Executor)
executor.device = torch.device("cpu")
executor.task_cache = MagicMock()
executor.task_cache.bind_was_steady = True
executor.task_cache.last_task_signature_matches.return_value = (
False # Different task
)
executor.task_cache.bind.return_value = MagicMock()
executor._graph_supported = False
executor._graph_ctx = SimpleNamespace(enabled=False)
workspace = MagicMock()
workspace.max_batch_size = 16
workspace.position_ids = torch.tensor([2], dtype=torch.long)
workspace.fill_input_ids.return_value = torch.tensor([7], dtype=torch.long)
workspace.decode_mask.return_value = torch.ones(1, 1, 9, dtype=torch.bool)
executor._workspace = workspace
executor.model = MagicMock(
return_value={"logits": torch.zeros(1, 1, 10, dtype=torch.float32)}
)
old_info = object()
new_info = object()
executor._decode_cache = DecodeSteadyState(("old",), [2], old_info)
executor._sample_logits = MagicMock(
return_value=([3], torch.tensor([3], dtype=torch.long))
)
task = Task("new", list(range(8)), temperature=0)
task.input_tokens = 8
task.output_ids = [7]
task.mark_prefill_done()
with patch(
"astrai.inference.runtime.executor._build_sampling_batch_info",
return_value=new_info,
):
assert executor.execute_decode([task]) == [3]
assert workspace.position_ids.tolist() == [8]
assert executor._decode_cache.task_sig == ("new",)
executor._sample_logits.assert_called_once()
args, kwargs = executor._sample_logits.call_args
assert args[1:] == ([task], False)
assert kwargs["info"] is new_info
def test_decode_fills_input_ids_from_device_on_matching_signature():
"""Steady-state decode copies cached device tokens, skipping the host."""
executor = object.__new__(Executor)
executor.device = torch.device("cpu")
executor.task_cache = MagicMock()
executor.task_cache.bind_was_steady = True
executor.task_cache.last_task_signature_matches.return_value = True # Same task
executor.task_cache.bind.return_value = MagicMock()
executor._graph_supported = False
executor._graph_ctx = SimpleNamespace(enabled=False)
workspace = MagicMock()
workspace.max_batch_size = 16
workspace.position_ids = torch.tensor([2], dtype=torch.long)
workspace.fill_input_ids_from_device.return_value = torch.tensor(
[9], dtype=torch.long
)
executor._workspace = workspace
executor.model = MagicMock(
return_value={"logits": torch.zeros(1, 1, 10, dtype=torch.float32)}
)
info = object()
tokens = torch.tensor([3], dtype=torch.long)
executor._decode_cache = DecodeSteadyState(("t1",), [2], info, last_tokens=tokens)
executor._sample_logits = MagicMock(return_value=([3], tokens))
task = Task("t1", list(range(8)), temperature=0)
task.input_tokens = 8
task.output_ids = [7]
task.mark_prefill_done()
with patch(
"astrai.inference.runtime.executor._build_sampling_batch_info",
return_value=info,
):
assert executor.execute_decode([task]) == [3]
workspace.fill_input_ids.assert_not_called()
workspace.fill_input_ids_from_device.assert_called_once_with(tokens)
assert workspace.position_ids.tolist() == [3]
assert executor._decode_cache.task_sig == ("t1",)
assert executor._decode_cache.last_tokens is tokens