Skip to content

Commit 29397d2

Browse files
fluffy314cursoragent
authored andcommitted
fix(prefill): keep MLX worker compute thread-affine
Warm the model on its dedicated job thread so MLX 0.31 streams never cross thread boundaries, and emit LaunchAgent files with permissions launchd accepts during upgrades. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3cdc747 commit 29397d2

5 files changed

Lines changed: 83 additions & 12 deletions

File tree

deploy/install_prefill_worker_launchd.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ cat > "$PLIST" <<EOF
8787
</dict></plist>
8888
EOF
8989

90+
chmod 644 "$PLIST"
9091
launchctl bootout "gui/$(id -u)/$LABEL" 2>/dev/null || true
9192
launchctl bootstrap "gui/$(id -u)" "$PLIST"
9293
echo "installed $LABEL -> $PLIST"

inference_engine/distributed/prefill_worker.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
from concurrent.futures import Future, ThreadPoolExecutor
1010
from dataclasses import dataclass, field
1111
from enum import IntEnum
12-
from typing import Protocol, Sequence
12+
from typing import Callable, Protocol, Sequence
1313

1414
import grpc
1515

@@ -85,9 +85,10 @@ class PrefillJobStore:
8585

8686
def __init__(
8787
self,
88-
engine: PrefillComputeEngine,
88+
engine: PrefillComputeEngine | None,
8989
cache_store: PrefixCacheStore,
9090
*,
91+
engine_factory: Callable[[], PrefillComputeEngine] | None = None,
9192
max_concurrent_jobs: int = 1,
9293
max_jobs: int = 128,
9394
completed_ttl_s: float = 600.0,
@@ -100,7 +101,10 @@ def __init__(
100101
max_prompt_tokens,
101102
) <= 0:
102103
raise ValueError("worker limits must be > 0")
104+
if (engine is None) == (engine_factory is None):
105+
raise ValueError("provide exactly one of engine or engine_factory")
103106
self.engine = engine
107+
self.engine_factory = engine_factory
104108
self.cache_store = cache_store
105109
self.max_concurrent_jobs = int(max_concurrent_jobs)
106110
self.max_jobs = int(max_jobs)
@@ -109,11 +113,16 @@ def __init__(
109113
self._jobs: dict[str, PrefillJob] = {}
110114
self._requests: dict[tuple[str, str], str] = {}
111115
self._lock = threading.RLock()
116+
self._thread_local = threading.local()
112117
self._executor = ThreadPoolExecutor(
113118
max_workers=self.max_concurrent_jobs,
114119
thread_name_prefix="kakeya-prefill-worker",
115120
)
116121

122+
def warmup(self) -> None:
123+
"""Construct a factory-backed engine on its eventual compute thread."""
124+
self._executor.submit(self._engine_for_current_thread).result()
125+
117126
def submit(
118127
self,
119128
*,
@@ -243,7 +252,7 @@ def _run(self, job_id: str) -> None:
243252
timer.daemon = True
244253
timer.start()
245254
try:
246-
blocks = tuple(self.engine.compute_prefill(
255+
blocks = tuple(self._engine_for_current_thread().compute_prefill(
247256
job.token_ids,
248257
job.block_hashes,
249258
compression=job.compression,
@@ -289,6 +298,16 @@ def _run(self, job_id: str) -> None:
289298
job.compute_ms = (time.perf_counter() - started) * 1000.0
290299
job.finished_at = time.time()
291300

301+
def _engine_for_current_thread(self) -> PrefillComputeEngine:
302+
if self.engine is not None:
303+
return self.engine
304+
engine = getattr(self._thread_local, "engine", None)
305+
if engine is None:
306+
assert self.engine_factory is not None
307+
engine = self.engine_factory()
308+
self._thread_local.engine = engine
309+
return engine
310+
292311
def _gc_locked(self) -> None:
293312
cutoff = time.time() - self.completed_ttl_s
294313
for job_id, job in list(self._jobs.items()):

scripts/start_prefill_worker_node.py

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -83,27 +83,37 @@ async def serve(args) -> None:
8383
)
8484
if args.fleet_psk_file else None
8585
)
86-
verifier = MLXSinkWindowVerifier(VerifierConfig(
87-
model_id=args.model_id,
88-
sink_size=args.sink,
89-
window_size=args.window,
90-
dtype=torch.bfloat16,
91-
device="cpu",
92-
))
86+
if args.max_concurrent_jobs != 1:
87+
raise SystemExit(
88+
"MLX prefill workers require --max-concurrent-jobs 1 so the "
89+
"model and its stream remain on one compute thread",
90+
)
9391
store = PrefixCacheStore(
9492
compatibility,
9593
max_bytes=int(args.cache_gb * (1 << 30)),
9694
node_id=args.node_id,
9795
)
98-
engine = MLXPrefillComputeEngine(verifier, compatibility)
96+
97+
def engine_factory() -> MLXPrefillComputeEngine:
98+
verifier = MLXSinkWindowVerifier(VerifierConfig(
99+
model_id=args.model_id,
100+
sink_size=args.sink,
101+
window_size=args.window,
102+
dtype=torch.bfloat16,
103+
device="cpu",
104+
))
105+
return MLXPrefillComputeEngine(verifier, compatibility)
106+
99107
jobs = PrefillJobStore(
100-
engine,
108+
None,
101109
store,
110+
engine_factory=engine_factory,
102111
max_concurrent_jobs=args.max_concurrent_jobs,
103112
max_jobs=args.max_jobs,
104113
completed_ttl_s=args.job_ttl_s,
105114
max_prompt_tokens=args.max_prompt_tokens,
106115
)
116+
jobs.warmup()
107117

108118
def card() -> NodeCapability:
109119
inflight, queued, load, queued_tokens = jobs.stats()

tests/inference_engine/bridge/test_prefill_worker_launchd.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def test_worker_installer_emits_full_cache_compatibility_contract():
2222
assert f"<string>{flag}</string>" in source
2323
assert 'PEER="${KAKEYA_WORKER_PEER:-}"' in source
2424
assert "<string>--peer</string>" in source
25+
assert 'chmod 644 "$PLIST"' in source
2526

2627

2728
def test_head_runtime_discovers_and_uses_worker_cache_port():

tests/inference_engine/distributed/test_prefill_worker.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,10 @@ def test_job_store_validation_queue_stats_and_gc():
162162
cache = PrefixCacheStore(COMPAT, max_bytes=1024, node_id="w")
163163
with pytest.raises(ValueError):
164164
PrefillJobStore(_Engine(), cache, max_jobs=0)
165+
with pytest.raises(ValueError, match="exactly one"):
166+
PrefillJobStore(None, cache)
167+
with pytest.raises(ValueError, match="exactly one"):
168+
PrefillJobStore(_Engine(), cache, engine_factory=_Engine)
165169
blocking = _Engine()
166170
blocking.block.set()
167171
jobs = PrefillJobStore(blocking, cache, max_jobs=1, max_prompt_tokens=4)
@@ -230,6 +234,42 @@ def test_job_store_validation_queue_stats_and_gc():
230234
completed_jobs.close()
231235

232236

237+
def test_factory_engine_is_warmed_and_used_on_same_compute_thread():
238+
cache = PrefixCacheStore(COMPAT, max_bytes=1024, node_id="w")
239+
created_on = []
240+
computed_on = []
241+
242+
class ThreadBoundEngine(_Engine):
243+
def compute_prefill(self, *args, **kwargs):
244+
computed_on.append(threading.get_ident())
245+
return super().compute_prefill(*args, **kwargs)
246+
247+
def factory():
248+
created_on.append(threading.get_ident())
249+
return ThreadBoundEngine()
250+
251+
jobs = PrefillJobStore(None, cache, engine_factory=factory)
252+
try:
253+
jobs.warmup()
254+
job = jobs.submit(
255+
request_id="thread-affinity",
256+
tenant_id="tenant",
257+
token_ids=[1, 2],
258+
block_hashes=[b"a" * 32],
259+
compatibility=COMPAT,
260+
compression=CompressionCodec.NONE,
261+
)
262+
for _ in range(100):
263+
if job.state == PrefillJobState.COMPLETED:
264+
break
265+
time.sleep(0.005)
266+
assert job.state == PrefillJobState.COMPLETED
267+
assert created_on == computed_on
268+
assert created_on[0] != threading.get_ident()
269+
finally:
270+
jobs.close()
271+
272+
233273
def test_job_store_failure_modes_and_precancelled_run():
234274
cache = PrefixCacheStore(COMPAT, max_bytes=1024, node_id="w")
235275

0 commit comments

Comments
 (0)