main
py 194 lines 6.56 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Thread pool:
4 Two queues (load, store) and two sets of threads:
5 - Load-priority threads: drain the load queue first, then the store queue.
6 - Store-priority threads: drain the store queue first, then the load queue.
7 Load jobs are enqueued to the load queue; store jobs to the store queue.
8 """
9
10 import threading
11 import time
12 from collections import deque
13 from collections.abc import Callable, Iterable
14
15 from vllm.logger import init_logger
16 from vllm.v1.kv_offload.tiering.base import JobId
17
18 logger = init_logger(__name__)
19
20
21 class JobState:
22 """Thread-safe completion tracker for a set of per-block I/O tasks.
23
24 Each task calls task_done(success) when it finishes.
25 """
26
27 __slots__ = (
28 "_job_id",
29 "_n_tasks",
30 "_completed",
31 "_success",
32 "_transfer_time",
33 "_lock",
34 )
35
36 def __init__(self, job_id: JobId, n_tasks: int) -> None:
37 self._job_id: JobId = job_id
38 self._n_tasks = n_tasks
39 self._completed = 0
40 self._success = True
41 self._transfer_time = 0.0
42 self._lock = threading.Lock()
43
44 @property
45 def job_id(self) -> JobId:
46 return self._job_id
47
48 def task_done(
49 self, success: bool, transfer_time: float
50 ) -> tuple[bool, bool, float]:
51 """Returns if job completed and success flag."""
52 with self._lock:
53 self._completed += 1
54 self._transfer_time += transfer_time
55 if not success:
56 self._success = False
57 return self._completed == self._n_tasks, self._success, self._transfer_time
58
59
60 class DualQueueThreadPool:
61 """Thread pool with two task queues (load and store) and two thread groups.
62
63 Load-priority threads drain the load queue first, then fall back to the
64 store queue. Store-priority threads do the reverse. Both queues share
65 a single condition variable.
66 """
67
68 def __init__(
69 self,
70 n_read_threads: int,
71 n_write_threads: int,
72 thread_name_prefix: str = "fs_secondary_tier",
73 ) -> None:
74 self._load_q: deque = deque()
75 self._store_q: deque = deque()
76 self._condition = threading.Condition(threading.Lock())
77 self._stop = False
78 self._threads: list[threading.Thread] = []
79 self._finished_q: deque[tuple[JobId, bool, float]] = deque()
80 self._inflight_jobs = 0 # guarded by _condition
81
82 for i in range(n_read_threads):
83 t = threading.Thread(
84 target=self._worker,
85 args=(True,),
86 name=f"{thread_name_prefix}_l{i}",
87 daemon=True,
88 )
89 t.start()
90 self._threads.append(t)
91
92 for i in range(n_write_threads):
93 t = threading.Thread(
94 target=self._worker,
95 args=(False,),
96 name=f"{thread_name_prefix}_s{i}",
97 daemon=True,
98 )
99 t.start()
100 self._threads.append(t)
101
102 def enqueue_load(
103 self,
104 job_id: JobId,
105 n_tasks: int,
106 tasks: Iterable[Callable],
107 ) -> None:
108 """Enqueue load tasks for a job (high-priority for load-priority threads)."""
109 state = JobState(job_id, n_tasks)
110 with self._condition:
111 self._inflight_jobs += 1
112 for fn in tasks:
113 self._load_q.append((fn, state))
114 self._condition.notify(n_tasks)
115
116 def enqueue_store(
117 self,
118 job_id: JobId,
119 n_tasks: int,
120 tasks: Iterable[Callable],
121 ) -> None:
122 """Enqueue store tasks for a job (high-priority for store-priority threads)."""
123 state = JobState(job_id, n_tasks)
124 with self._condition:
125 self._inflight_jobs += 1
126 for fn in tasks:
127 self._store_q.append((fn, state))
128 self._condition.notify(n_tasks)
129
130 def get_finished(self) -> list[tuple[JobId, bool, float]]:
131 # No lock needed: deque is thread-safe for concurrent append/popleft,
132 # and the manager is the sole popper.
133 jobs = []
134 while self._finished_q:
135 jobs.append(self._finished_q.popleft())
136 return jobs
137
138 def wait_idle(self) -> None:
139 """Block until there are no in-flight jobs.
140
141 After this returns, every submitted job has had its last task
142 finish, so no worker thread is still copying data. Note:
143 completed jobs may still be sitting in ``_finished_q`` waiting
144 for ``get_finished()`` to drain them.
145 """
146 with self._condition:
147 self._condition.wait_for(lambda: self._inflight_jobs == 0)
148
149 def shutdown(self, wait: bool = True) -> None:
150 with self._condition:
151 self._stop = True
152 self._load_q.clear()
153 self._store_q.clear()
154 # Cancelled tasks will not decrement _inflight_jobs; reset it so a
155 # subsequent wait_idle() returns instead of hanging.
156 self._inflight_jobs = 0
157 self._condition.notify_all()
158 if wait:
159 for t in self._threads:
160 t.join()
161
162 def _worker(self, load_priority: bool) -> None:
163 # Wait for tasks, process from primary queue first, fall back to secondary.
164 while True:
165 with self._condition:
166 self._condition.wait_for(
167 lambda: self._stop or self._load_q or self._store_q
168 )
169 if self._stop:
170 return
171 primary = self._load_q if load_priority else self._store_q
172 secondary = self._store_q if load_priority else self._load_q
173 task, state = primary.popleft() if primary else secondary.popleft()
174 try:
175 start_time = time.monotonic()
176 task()
177 transfer_time = time.monotonic() - start_time
178 job_finished, success, total_time = state.task_done(True, transfer_time)
179 except Exception as exc:
180 transfer_time = time.monotonic() - start_time
181 logger.error(
182 "Job %s block I/O failed: %s",
183 state.job_id,
184 exc,
185 )
186 job_finished, success, total_time = state.task_done(
187 False, transfer_time
188 )
189
190 if job_finished:
191 with self._condition:
192 self._finished_q.append((state.job_id, success, total_time))
193 self._inflight_jobs -= 1
194 self._condition.notify_all()