adaptive_k.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. # SPDX-License-Identifier: Apache-2.0
  2. """Choose how many drafts each step verifies from recent draft acceptance.
  3. The drafter proposes num_speculative_tokens drafts every step. Verifying fewer
  4. costs less (each extra verified token adds MoE experts to the step), so when
  5. acceptance falls off early, as it does in free prose, verifying all of them
  6. wastes time. This scheduler picks k, the number of drafts the next step
  7. verifies, from the levels that the dynamic speculative decoding schedule
  8. (num_speculative_tokens_per_batch_size) captured CUDA graphs for.
  9. Per request it keeps c[i], an average of the rate at which draft i is accepted
  10. given that draft i - 1 was. A step that verified d drafts and accepted a < d of
  11. them observes accepts at 0..a-1 and a reject at a. A step that accepted all d
  12. says nothing about positions d and beyond, so those drift towards g, the same
  13. rates averaged over every request, raised or lowered by how far the request's
  14. c[d - 1] is from g[d - 1]. A request that beats the average where it was last
  15. tested is expected to beat it deeper too, so k can climb for it after a run of
  16. poorer requests. g starts each new request's c, and its own unverified
  17. positions drift slowly towards its deepest verified one.
  18. Expected tokens for a step verifying k drafts is 1 + sum over i < k of
  19. c[0] * ... * c[i]. The next k maximises the batch's expected tokens over the
  20. step's cost, and changes only for a gain of at least VLLM_ADAPTIVE_K_MARGIN.
  21. The step's cost comes from a model learned while serving (VLLM_ADAPTIVE_K_ONLINE,
  22. default on): step_ms = base + per_expert * experts(d * t) + per_token * t for t
  23. verified tokens, where experts(n) is how many of the MoE's experts n independent
  24. tokens touch and d the batch's routing diversity (repetitive output touches
  25. fewer experts than its token count suggests). A step's weight reads scale with
  26. the experts it touches, so large steps cost far less than a straight line
  27. through small ones. The three coefficients start from VLLM_ADAPTIVE_K_MODEL,
  28. and a small Kalman filter moves them and d to fit each decode step's measured
  29. time, d much faster than the coefficients. VLLM_ADAPTIVE_K_ONLINE=0 uses the
  30. fixed table VLLM_ADAPTIVE_K_COST_MS ("tokens:ms,...") instead.
  31. With VLLM_ADAPTIVE_K_PER_REQUEST=1, a batch of several requests gets a k per
  32. request instead: every (request, draft) slot is scored by its survival
  33. probability and the best slots are admitted up to the budget that maximises
  34. expected tokens over step cost. Unequal k takes the batch off the full CUDA
  35. graph for attention (piecewise instead).
  36. If the file named by VLLM_ADAPTIVE_K_CONTROL holds "force N", every step
  37. verifies N drafts, which is how the cost table is measured.
  38. VLLM_ADAPTIVE_K_CODE_MODE (Chuck's idea) also keeps rates for two kinds of
  39. output that accept far longer draft runs than prose: code inside ``` fences,
  40. and tool calls between <tool_call> and </tool_call>. Each request keeps them
  41. beside its usual rates, updated only while it is in that mode, and each mode
  42. has its own g. "gated" uses a mode's rates only when every decoding request is
  43. in that mode, since the batch shares one k, and is the default; "1" uses them
  44. per request, and "0" turns code mode off. A fence is a run of three backticks,
  45. which the tokenizer can split across tokens (a closing fence becomes "``" then
  46. "`\n"), so runs are counted across tokens.
  47. """
  48. import os
  49. import time
  50. from collections import Counter
  51. import numpy as np
  52. from vllm.logger import init_logger
  53. from vllm.v1.core.sched.async_scheduler import AsyncScheduler
  54. from vllm.v1.core.sched.output import SchedulerOutput
  55. logger = init_logger(__name__)
  56. _ALPHA = float(os.environ.get("VLLM_ADAPTIVE_K_ALPHA", "0.25"))
  57. _MARGIN = float(os.environ.get("VLLM_ADAPTIVE_K_MARGIN", "0.03"))
  58. _PRIOR = 0.8
  59. # g's rate for verified positions, and its drift for unverified ones.
  60. _GLOBAL_ALPHA = 0.05
  61. _GLOBAL_DRIFT = 0.01
  62. _PER_REQUEST = os.environ.get("VLLM_ADAPTIVE_K_PER_REQUEST") == "1"
  63. _CODE_MODE = os.environ.get("VLLM_ADAPTIVE_K_CODE_MODE", "gated") # "gated", "1" or "0" (off)
  64. _PROSE, _CODE, _TOOL = 0, 1, 2
  65. _ONLINE = os.environ.get("VLLM_ADAPTIVE_K_ONLINE", "1") == "1"
  66. # Fitted on GLM-5.3-Flash at TP=4 on GB10 (k 2, 3, 5 and 7 forced, 1-32 streams, code,
  67. # prose and structured prompts): ms, ms per expert touched, ms per token.
  68. _MODEL = tuple(float(v) for v in (os.environ.get("VLLM_ADAPTIVE_K_MODEL") or "27.0,0.635,0.542").split(","))
  69. # Routed experts and experts per token.
  70. _EXPERTS, _TOPK = 288, 8
  71. class _CostModel:
  72. """step_ms = base + per_expert * experts(d * t) + per_token * t, learned online.
  73. An extended Kalman filter over (base, per_expert, per_token, d). Only d
  74. gets process noise: the workload changes while serving and the hardware
  75. does not. A run of steps with one size, where only one combination is
  76. observable, then moves d and leaves the coefficients alone.
  77. """
  78. _DRIFT = np.diag([0.0, 0.0, 0.0, 2e-3])
  79. def __init__(self, theta) -> None:
  80. self.x = np.array([*theta, 1.0])
  81. # Confidence in the seed: base to ~5 ms, the slopes to a few percent.
  82. self.P = np.diag([25.0, 4e-4, 4e-3, 0.1])
  83. self.n = 0
  84. self.err = 0.0
  85. @property
  86. def d(self) -> float:
  87. return float(self.x[3])
  88. @staticmethod
  89. def experts(n: float) -> float:
  90. return _EXPERTS * (1.0 - (1.0 - _TOPK / _EXPERTS) ** n)
  91. def cost(self, tokens: int) -> float:
  92. base, per_expert, per_token, d = self.x
  93. return max(base + per_expert * self.experts(d * tokens) + per_token * tokens, 1.0)
  94. def observe(self, tokens: int, ms: float) -> None:
  95. predicted = self.cost(tokens)
  96. self.err += 0.05 * (abs(ms - predicted) / predicted - self.err)
  97. self.n += 1
  98. base, per_expert, per_token, d = self.x
  99. touched = self.experts(d * tokens)
  100. # d(experts)/dd = (288 - experts) * t * -ln(1 - 8/288)
  101. slope = (_EXPERTS - touched) * tokens * -np.log1p(-_TOPK / _EXPERTS)
  102. H = np.array([1.0, touched, float(tokens), per_expert * slope])
  103. P = self.P + self._DRIFT
  104. Ph = P @ H
  105. gain = Ph / (H @ Ph + (0.05 * predicted) ** 2)
  106. self.x = self.x + gain * (ms - predicted)
  107. self.x[3] = np.clip(self.x[3], 0.05, 2.0)
  108. self.P = P - np.outer(gain, Ph)
  109. def _parse_costs(spec: str) -> tuple[np.ndarray, np.ndarray]:
  110. points = sorted((int(t), float(ms)) for t, ms in (p.split(":") for p in spec.split(",")))
  111. return np.array([p[0] for p in points], float), np.array([p[1] for p in points], float)
  112. class AdaptiveKScheduler(AsyncScheduler):
  113. def __init__(self, *args, **kwargs) -> None:
  114. super().__init__(*args, **kwargs)
  115. lookup = self.dynamic_sd_lookup or [self.num_spec_tokens]
  116. self._levels = sorted({k for k in lookup[1:] if 0 < k <= self.num_spec_tokens}) or [self.num_spec_tokens]
  117. self._cost_x, self._cost_y = _parse_costs(
  118. os.environ.get("VLLM_ADAPTIVE_K_COST_MS", "2:47,4:53,8:65"))
  119. self._control = os.environ.get("VLLM_ADAPTIVE_K_CONTROL", "")
  120. self._control_mtime = 0.0
  121. self._force: int | None = None
  122. self._rates: dict[str, np.ndarray] = {}
  123. self._global = np.full(self.num_spec_tokens, _PRIOR)
  124. # Code mode: per (request, mode) rates, per-mode g, and each request's mode.
  125. self._mode_rates: dict[tuple[str, int], np.ndarray] = {}
  126. self._mode_global = {m: np.full(self.num_spec_tokens, _PRIOR) for m in (_CODE, _TOOL)}
  127. self._mode: dict[str, int] = {}
  128. self._in_fence: dict[str, bool] = {}
  129. self._in_tool: dict[str, bool] = {}
  130. self._scanned: dict[str, int] = {}
  131. self._run: dict[str, int] = {} # backticks at the end of each request's output
  132. self._switches = 0
  133. self._backticks: dict[int, str] = {}
  134. self._tool_open = self._tool_close = -1
  135. if _CODE_MODE != "0":
  136. self._load_mode_tokens()
  137. self._k = self.num_spec_tokens
  138. self._last_log = 0.0
  139. self._steps: Counter[int] = Counter()
  140. self._model = _CostModel(_MODEL) if _ONLINE else None
  141. self._last_output = 0.0
  142. self._last_model_log = 0.0
  143. if self._model:
  144. logger.info("adaptive k: levels %s, online cost model from %s", self._levels, _MODEL)
  145. else:
  146. logger.info("adaptive k: levels %s, cost %s", self._levels,
  147. dict(zip(self._cost_x.astype(int).tolist(), self._cost_y.tolist())))
  148. def _load_mode_tokens(self) -> None:
  149. """The text of every token holding a backtick, and the tool-call markers' ids."""
  150. from vllm.tokenizers.registry import cached_tokenizer_from_config
  151. tok = cached_tokenizer_from_config(self.vllm_config.model_config)
  152. self._backticks = {i: t for i in range(len(tok)) if "`" in (t := tok.decode([i]))}
  153. self._tool_open = tok.convert_tokens_to_ids("<tool_call>")
  154. self._tool_close = tok.convert_tokens_to_ids("</tool_call>")
  155. logger.info("adaptive k: code mode %s, %d tokens hold a backtick, tool call markers %d/%d",
  156. _CODE_MODE, len(self._backticks), self._tool_open, self._tool_close)
  157. def _track_modes(self, req_ids: list[str]) -> None:
  158. """Advance each request's fence and tool-call state over its new output."""
  159. for r in req_ids:
  160. request = self.requests.get(r)
  161. if request is None:
  162. continue
  163. out = request.output_token_ids
  164. run, fence, tool = self._run.get(r, 0), self._in_fence.get(r, False), self._in_tool.get(r, False)
  165. for t in out[self._scanned.get(r, 0):]:
  166. if t == self._tool_open or t == self._tool_close:
  167. tool, run = t == self._tool_open, 0
  168. continue
  169. text = self._backticks.get(t)
  170. if text is None:
  171. run = 0
  172. continue
  173. for ch in text:
  174. if ch != "`":
  175. run = 0
  176. continue
  177. run += 1
  178. fence ^= run == 3
  179. self._scanned[r], self._run[r], self._in_fence[r], self._in_tool[r] = len(out), run, fence, tool
  180. mode = _TOOL if tool else _CODE if fence else _PROSE
  181. self._switches += mode != self._mode.get(r, _PROSE)
  182. self._mode[r] = mode
  183. def _active_rates(self, decoding: list[str]) -> list[np.ndarray]:
  184. """Each request's rates for choosing k: its mode's where code mode says so."""
  185. if _CODE_MODE == "0":
  186. return [self._rates[r] for r in decoding]
  187. modes = [self._mode.get(r, _PROSE) for r in decoding]
  188. if _CODE_MODE == "gated" and (len(set(modes)) > 1 or modes[0] == _PROSE):
  189. return [self._rates[r] for r in decoding]
  190. return [self._rates[r] if m == _PROSE else
  191. self._mode_rates.setdefault((r, m), self._mode_global[m].copy())
  192. for r, m in zip(decoding, modes)]
  193. def update_from_output(self, scheduler_output, model_runner_output):
  194. if self._model is not None:
  195. self._time_step(scheduler_output)
  196. return super().update_from_output(scheduler_output, model_runner_output)
  197. def _time_step(self, out: SchedulerOutput) -> None:
  198. """Teach the cost model with this step, if it was a steady decode step.
  199. Outputs arrive one step apart while the GPU stays busy, so the gap
  200. between them is the step's time. A step with a prefill chunk is not a
  201. decode step, a gap far past the prediction includes idle time, and one far
  202. under it is a timing artifact.
  203. """
  204. now = time.monotonic()
  205. last, self._last_output = self._last_output, now
  206. tokens = out.total_num_scheduled_tokens
  207. if not last or not tokens:
  208. return
  209. spec = out.scheduled_spec_decode_tokens
  210. if any(n > 1 + len(spec.get(r, ())) for r, n in out.num_scheduled_tokens.items()):
  211. return
  212. ms = (now - last) * 1000.0
  213. predicted = self._model.cost(tokens)
  214. if not 0.5 * predicted < ms < 2.0 * predicted + 20.0:
  215. return
  216. self._model.observe(tokens, ms)
  217. if now - self._last_model_log > 30:
  218. self._last_model_log = now
  219. m = self._model
  220. logger.info("adaptive k: cost model base %.1f ms, %.3f ms/expert, %.3f ms/token, diversity %.2f, "
  221. "%d steps, mean error %.0f%%, %d code-mode switches", *m.x[:3], m.d, m.n,
  222. 100 * m.err, self._switches)
  223. def make_spec_decoding_stats(self, spec_decoding_stats, num_draft_tokens, num_accepted_tokens,
  224. num_invalid_spec_tokens, request_id):
  225. if num_draft_tokens:
  226. self._observe(request_id, num_draft_tokens, num_accepted_tokens)
  227. return super().make_spec_decoding_stats(spec_decoding_stats, num_draft_tokens, num_accepted_tokens,
  228. num_invalid_spec_tokens, request_id)
  229. def _observe(self, req_id: str, drafted: int, accepted: int) -> None:
  230. g = self._global
  231. c = self._rates.get(req_id)
  232. if c is None:
  233. c = self._rates[req_id] = g.copy()
  234. self._update(c, g, drafted, accepted)
  235. mode = self._mode.get(req_id, _PROSE)
  236. if mode != _PROSE:
  237. gm = self._mode_global[mode]
  238. self._update(self._mode_rates.setdefault((req_id, mode), gm.copy()), gm, drafted, accepted)
  239. @staticmethod
  240. def _update(c: np.ndarray, g: np.ndarray, drafted: int, accepted: int) -> None:
  241. for rates, alpha in ((c, _ALPHA), (g, _GLOBAL_ALPHA)):
  242. rates[:accepted] += alpha * (1.0 - rates[:accepted])
  243. if accepted < drafted:
  244. rates[accepted] -= alpha * rates[accepted]
  245. if accepted == drafted:
  246. lead = c[drafted - 1] - g[drafted - 1]
  247. c[drafted:] += _ALPHA * (np.clip(g[drafted:] + lead, 0.0, 1.0) - c[drafted:])
  248. g[drafted:] += _GLOBAL_DRIFT * (g[drafted - 1] - g[drafted:])
  249. def _step_cost(self, tokens: int) -> float:
  250. if self._model is not None:
  251. return self._model.cost(tokens)
  252. x, y = self._cost_x, self._cost_y
  253. if tokens <= x[-1]:
  254. return float(np.interp(tokens, x, y))
  255. return float(y[-1] + (y[-1] - y[-2]) / (x[-1] - x[-2]) * (tokens - x[-1]))
  256. def _read_control(self) -> None:
  257. if not self._control:
  258. return
  259. try:
  260. mtime = os.stat(self._control).st_mtime
  261. except FileNotFoundError:
  262. self._force = None
  263. return
  264. if mtime != self._control_mtime:
  265. self._control_mtime = mtime
  266. words = open(self._control).read().split()
  267. self._force = int(words[1]) if len(words) == 2 and words[0] == "force" else None
  268. logger.info("adaptive k: control file says %s", self._force or "adapt")
  269. def _choose(self, req_ids: list[str]) -> int:
  270. self._read_control()
  271. if self._force is not None:
  272. return min(max(self._force, 1), self.num_spec_tokens)
  273. decoding = [r for r in req_ids if r in self._rates]
  274. if not decoding:
  275. return self._k
  276. survival = np.cumprod(np.stack(self._active_rates(decoding)), axis=1).sum(axis=0)
  277. expected = {k: len(decoding) + survival[:k].sum() for k in self._levels}
  278. score = {k: expected[k] / self._step_cost(len(decoding) * (k + 1)) for k in self._levels}
  279. best = max(score, key=score.get)
  280. current = self._k if self._k in score else best
  281. return best if score[best] > score[current] * (1 + _MARGIN) else current
  282. def _choose_per_request(self, req_ids: list[str]) -> dict[str, int]:
  283. """A k for each request from one draft budget: every (request, draft)
  284. slot is scored by its survival probability, and the budget is the
  285. number of best slots that maximises expected tokens over step cost."""
  286. decoding = [r for r in req_ids if r in self._rates]
  287. if not decoding:
  288. return {}
  289. survival = np.cumprod(np.stack([self._rates[r] for r in decoding]), axis=1)
  290. order = np.argsort(-survival, axis=None, kind="stable")
  291. best_b, best_score, n = 0, len(decoding) / self._step_cost(len(decoding)), len(decoding)
  292. gained = 0.0
  293. for b, flat in enumerate(order, 1):
  294. gained += survival.flat[flat]
  295. score = (n + gained) / self._step_cost(n + b)
  296. if score > best_score:
  297. best_b, best_score = b, score
  298. counts = np.bincount(order[:best_b] // survival.shape[1], minlength=n)
  299. return {r: max(int(c), 1) for r, c in zip(decoding, counts)}
  300. def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None:
  301. for req_id in [r for r in self._rates if r not in self.requests]:
  302. del self._rates[req_id]
  303. for d in (self._mode, self._in_fence, self._in_tool, self._scanned, self._run):
  304. d.pop(req_id, None)
  305. for m in (_CODE, _TOOL):
  306. self._mode_rates.pop((req_id, m), None)
  307. if _CODE_MODE != "0":
  308. self._track_modes(list(scheduler_output.num_scheduled_tokens))
  309. self._read_control()
  310. if self.num_spec_tokens and _PER_REQUEST and self._force is None:
  311. per = self._choose_per_request(list(scheduler_output.num_scheduled_tokens))
  312. if len(per) > 1:
  313. scheduler_output.num_spec_tokens_to_schedule = max(per.values())
  314. super()._update_after_schedule(scheduler_output)
  315. for req_id, k in per.items():
  316. request = self.requests.get(req_id)
  317. if request is not None and request.spec_token_ids:
  318. request.spec_token_ids = [-1] * k
  319. for k in per.values():
  320. self._steps[k] += 1
  321. return
  322. self._update_batch_k(scheduler_output)
  323. def _update_batch_k(self, scheduler_output: SchedulerOutput) -> None:
  324. if self.num_spec_tokens:
  325. k = self._choose(list(scheduler_output.num_scheduled_tokens))
  326. self._steps[k] += 1
  327. now = time.monotonic()
  328. if k != self._k and now - self._last_log > 5:
  329. self._last_log = now
  330. logger.info("adaptive k: %d -> %d; steps per k so far %s", self._k, k, dict(sorted(self._steps.items())))
  331. self._k = k
  332. scheduler_output.num_spec_tokens_to_schedule = self._k
  333. super()._update_after_schedule(scheduler_output)