Coverage for src/lilbee/data/ingest/adaptive.py: 100%

178 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-14 11:46 +0000

1"""Adaptive ingest concurrency: hill-climb to the hardware's throughput knee. 

2 

3The extraction-admission limit (how many documents are in their compute phase at 

4once) can run in one of three modes, chosen by ``LILBEE_INGEST_CONCURRENCY``: 

5 

6- ``static`` -- a fixed limit (the pipeline's ``_max_concurrent()``); the proven 

7 default and the guaranteed-safe fallback. 

8- ``adaptive-conservative`` / ``adaptive-aggressive`` -- a background controller 

9 resizes the limit every few seconds, climbing while throughput still improves 

10 and backing off the instant a safety signal (CPU, free RAM, GPU temperature) 

11 says the box is under pressure. 

12 

13The control law is a safety-gated AIMD hill-climb on smoothed throughput. The 

14only thing that pushes the limit up is throughput still improving, so the fixed 

15point is *this box's* real operating knee rather than a hardcoded utilization 

16target (BBR / Kleinrock). GPU utilization is used only as a saturation veto, never 

17as a setpoint. Multiplicative decrease on any danger signal is AIMD's proven 

18fast-safe retreat; EWMA smoothing plus an asymmetric dead band and a one-step slew 

19limit keep it from oscillating (CLR ThreadPool hill-climbing, TCP Vegas). 

20 

21``decide`` is a pure function -- the whole control law with no clock, asyncio, or 

22hardware -- so the policy is unit-tested with plain numbers. ``ResizableGate`` and 

23``AdaptiveController`` are the async machinery around it. 

24""" 

25 

26from __future__ import annotations 

27 

28import asyncio 

29import logging 

30import math 

31import os 

32from collections.abc import Awaitable, Callable, Sequence 

33from dataclasses import dataclass 

34from enum import StrEnum 

35from typing import TYPE_CHECKING 

36 

37if TYPE_CHECKING: 

38 from lilbee.providers.fleet.gpu_stats import DeviceLike 

39 

40log = logging.getLogger(__name__) 

41 

42_MODE_ENV = "LILBEE_INGEST_CONCURRENCY" 

43 

44 

45class ConcurrencyMode(StrEnum): 

46 """How the extraction-admission limit is chosen for a sync run.""" 

47 

48 STATIC = "static" 

49 ADAPTIVE_CONSERVATIVE = "adaptive-conservative" 

50 ADAPTIVE_AGGRESSIVE = "adaptive-aggressive" 

51 

52 

53def resolve_mode() -> ConcurrencyMode: 

54 """The concurrency mode from ``LILBEE_INGEST_CONCURRENCY``; ``static`` by default. 

55 

56 An unset or unrecognized value falls back to ``static`` (a warning is logged for 

57 an unrecognized one), so a typo can never silently enable adaptive control. 

58 """ 

59 raw = os.environ.get(_MODE_ENV, "").strip().lower() 

60 if not raw: 

61 return ConcurrencyMode.STATIC 

62 try: 

63 return ConcurrencyMode(raw) 

64 except ValueError: 

65 log.warning( 

66 "Ignoring %s=%r: expected one of %s; using %s.", 

67 _MODE_ENV, 

68 raw, 

69 [m.value for m in ConcurrencyMode], 

70 ConcurrencyMode.STATIC.value, 

71 ) 

72 return ConcurrencyMode.STATIC 

73 

74 

75@dataclass(frozen=True) 

76class SafetyLimits: 

77 """Hard guardrails, identical across adaptive profiles -- safety is never relaxed. 

78 

79 Crossing a ``_crit`` line forces an immediate multiplicative backoff; a ``_warn`` 

80 / ``_soft`` line only vetoes further increases. Temperature and RAM are read raw 

81 (never smoothed -- a fire alarm should not be averaged away). 

82 """ 

83 

84 gpu_sat_pct: float = 97.0 

85 cpu_soft_pct: float = 90.0 

86 cpu_crit_pct: float = 97.0 

87 ram_soft_free: float = 0.20 

88 ram_min_free: float = 0.10 

89 temp_warn_c: float = 80.0 

90 temp_crit_c: float = 85.0 

91 decrease_factor: float = 0.5 

92 

93 

94@dataclass(frozen=True) 

95class ConcurrencyProfile: 

96 """Climb-speed parameters for one adaptive profile (safety limits are shared).""" 

97 

98 name: str 

99 interval_s: float 

100 ewma_gamma: float 

101 deadband_frac: float 

102 cool_down_intervals: int 

103 sqrt_step: bool 

104 latency_veto_ratio: float # veto increases once residence time inflates past baseline x this 

105 safety: SafetyLimits = SafetyLimits() 

106 

107 def step(self, permits: int) -> int: 

108 """Additive step size at the current limit; larger early when ``sqrt_step``.""" 

109 if self.sqrt_step: 

110 return 1 + math.isqrt(max(0, permits)) // 4 

111 return 1 

112 

113 

114CONSERVATIVE = ConcurrencyProfile( 

115 name="conservative", 

116 interval_s=5.0, 

117 ewma_gamma=0.3, 

118 deadband_frac=0.05, 

119 cool_down_intervals=3, 

120 sqrt_step=False, 

121 latency_veto_ratio=1.5, 

122) 

123AGGRESSIVE = ConcurrencyProfile( 

124 name="aggressive", 

125 interval_s=2.0, 

126 ewma_gamma=0.5, 

127 deadband_frac=0.03, 

128 cool_down_intervals=2, 

129 sqrt_step=True, 

130 latency_veto_ratio=2.0, 

131) 

132 

133 

134def profile_for(mode: ConcurrencyMode) -> ConcurrencyProfile | None: 

135 """The profile for an adaptive mode, or None for ``static``.""" 

136 return { 

137 ConcurrencyMode.ADAPTIVE_CONSERVATIVE: CONSERVATIVE, 

138 ConcurrencyMode.ADAPTIVE_AGGRESSIVE: AGGRESSIVE, 

139 }.get(mode) 

140 

141 

142@dataclass(frozen=True) 

143class Signals: 

144 """One tick's measured state. ``gpu_*`` are None when no GPU telemetry is available.""" 

145 

146 throughput: float # OCR pages completed since the previous tick (work done, not doc count) 

147 gpu_util_pct: float | None 

148 gpu_temp_c: float | None 

149 cpu_pct: float 

150 ram_free_frac: float 

151 

152 

153@dataclass(frozen=True) 

154class ControllerState: 

155 """The controller's carried state between ticks.""" 

156 

157 permits: int 

158 ewma_tput: float | None # smoothed throughput from the previous tick; None at start 

159 direction: int # +1 climbing, -1 backing off -- the last hill-climb step's sign 

160 cool_down: int # intervals remaining during which increases are suppressed 

161 w_min: float | None = None # smallest observed residence-time estimate (Little's Law baseline) 

162 

163 

164def _is_critical(signals: Signals, s: SafetyLimits) -> bool: 

165 """A signal that demands an immediate hard backoff (thermal / OOM / CPU meltdown).""" 

166 temp = signals.gpu_temp_c 

167 return ( 

168 (temp is not None and temp >= s.temp_crit_c) 

169 or signals.ram_free_frac <= s.ram_min_free 

170 or signals.cpu_pct >= s.cpu_crit_pct 

171 ) 

172 

173 

174def _increase_vetoed( 

175 profile: ConcurrencyProfile, 

176 signals: Signals, 

177 *, 

178 cool_down: int, 

179 gpu_saturated: bool, 

180 w_est: float | None, 

181 w_min: float | None, 

182) -> bool: 

183 """Whether soft pressure, saturation, cool-down, or inflating latency blocks a climb.""" 

184 s = profile.safety 

185 temp = signals.gpu_temp_c 

186 latency_inflated = ( 

187 w_est is not None and w_min is not None and w_est > w_min * profile.latency_veto_ratio 

188 ) 

189 return ( 

190 cool_down > 0 

191 or signals.cpu_pct >= s.cpu_soft_pct 

192 or signals.ram_free_frac < s.ram_soft_free 

193 or (temp is not None and temp >= s.temp_warn_c) 

194 or gpu_saturated 

195 or latency_inflated 

196 ) 

197 

198 

199def _hill_climb( 

200 profile: ConcurrencyProfile, 

201 permits: int, 

202 direction: int, 

203 delta: float | None, 

204 new_ewma: float, 

205 clamp: Callable[[int], int], 

206) -> tuple[int, int]: 

207 """One dead-banded hill-climb step; returns the next (permits, direction). 

208 

209 No baseline yet -> a gentle probe up; a clear gain -> step in the same direction; 

210 a clear loss -> reverse; inside the dead band -> hold. 

211 """ 

212 if delta is None: 

213 return clamp(permits + profile.step(permits)), 1 

214 band = profile.deadband_frac * (new_ewma if new_ewma > 0 else 1.0) 

215 if delta > band: 

216 return clamp(permits + direction * profile.step(permits)), direction 

217 if delta < -band: 

218 direction = -direction 

219 return clamp(permits + direction * profile.step(permits)), direction 

220 return permits, direction 

221 

222 

223def decide( 

224 profile: ConcurrencyProfile, 

225 state: ControllerState, 

226 signals: Signals, 

227 permit_min: int, 

228 permit_max: int, 

229) -> ControllerState: 

230 """Pure control law: fold one tick of signals into the next permit target. 

231 

232 Priority: (1) any critical safety signal forces a multiplicative decrease and a 

233 cool-down; (2) a soft-pressure, GPU-saturation, or latency-gradient signal vetoes 

234 increases (with a single additive decrease when GPUs are saturated *and* throughput 

235 is falling -- the Universal Scalability Law's retrograde region); (3) otherwise 

236 hill-climb toward the knee. Always clamped to ``[permit_min, permit_max]``. 

237 

238 The veto blocks climbing only. A hill-climb step that goes *down* -- the reversal 

239 on clearly falling throughput -- still runs under soft pressure, since backing off 

240 is exactly what soft pressure should permit. 

241 

242 The latency veto is the leading knee indicator (TCP Vegas / Netflix Gradient2): 

243 residence time ``W`` is estimated by Little's Law as ``permits / throughput``; once 

244 ``W`` inflates past its observed minimum (the unloaded baseline) by the profile's 

245 ratio, work is queueing at the bottleneck and increases stop -- before throughput 

246 visibly rolls over. 

247 """ 

248 if state.ewma_tput is None: 

249 new_ewma: float = signals.throughput 

250 delta: float | None = None 

251 else: 

252 gamma = profile.ewma_gamma 

253 new_ewma = gamma * signals.throughput + (1.0 - gamma) * state.ewma_tput 

254 delta = new_ewma - state.ewma_tput 

255 

256 permits = state.permits 

257 cool_down = max(0, state.cool_down - 1) 

258 

259 # Little's Law residence-time estimate and its running minimum (the latency baseline). 

260 w_est = permits / new_ewma if new_ewma > 0 else None 

261 w_min = state.w_min if w_est is None else min(state.w_min or w_est, w_est) 

262 

263 def clamp(p: int) -> int: 

264 return max(permit_min, min(permit_max, p)) 

265 

266 def out(new_permits: int, new_direction: int, new_cool_down: int) -> ControllerState: 

267 return ControllerState(new_permits, new_ewma, new_direction, new_cool_down, w_min) 

268 

269 if _is_critical(signals, profile.safety): 

270 backoff = clamp(int(permits * profile.safety.decrease_factor)) 

271 return out(backoff, -1, profile.cool_down_intervals) 

272 

273 util = signals.gpu_util_pct 

274 gpu_saturated = util is not None and util >= profile.safety.gpu_sat_pct 

275 if gpu_saturated and delta is not None and delta < 0: 

276 return out(clamp(permits - profile.step(permits)), -1, cool_down) # USL retrograde 

277 

278 vetoed = _increase_vetoed( 

279 profile, signals, cool_down=cool_down, gpu_saturated=gpu_saturated, w_est=w_est, w_min=w_min 

280 ) 

281 climbed, direction = _hill_climb(profile, permits, state.direction, delta, new_ewma, clamp) 

282 if vetoed and climbed > permits: 

283 # The veto blocks climbing, not retreating: a step down on falling 

284 # throughput is the only graceful decrease, so it must still pass. 

285 return out(permits, state.direction, cool_down) 

286 return out(climbed, direction, cool_down) 

287 

288 

289class ResizableGate: 

290 """An async admission gate whose permit ceiling can change while it is in use. 

291 

292 Same ``async with`` shape as ``asyncio.Semaphore``, plus ``set_limit``: growing 

293 wakes blocked acquirers, shrinking lowers the ceiling and lets the surplus drain 

294 as active holders release. The limit never drops below one, so a shrink can never 

295 deadlock a run. A plain counting gate, unlike ``anyio.CapacityLimiter``'s 

296 per-borrower token model. 

297 """ 

298 

299 def __init__(self, limit: int) -> None: 

300 self._limit = max(1, limit) 

301 self._active = 0 

302 self._cond = asyncio.Condition() 

303 

304 @property 

305 def limit(self) -> int: 

306 return self._limit 

307 

308 async def acquire(self) -> None: 

309 async with self._cond: 

310 await self._cond.wait_for(lambda: self._active < self._limit) 

311 self._active += 1 

312 

313 async def release(self) -> None: 

314 async with self._cond: 

315 self._active -= 1 

316 self._cond.notify_all() 

317 

318 async def __aenter__(self) -> ResizableGate: 

319 await self.acquire() 

320 return self 

321 

322 async def __aexit__(self, *exc: object) -> None: 

323 await self.release() 

324 

325 async def set_limit(self, new_limit: int) -> None: 

326 async with self._cond: 

327 self._limit = max(1, new_limit) 

328 self._cond.notify_all() 

329 

330 

331class AdaptiveController: 

332 """Drives a :class:`ResizableGate`'s limit from live signals until cancelled. 

333 

334 ``sample(throughput)`` returns the current :class:`Signals`; ``completed()`` is a 

335 monotonic count of finished work units, from which per-interval throughput is 

336 derived. The production wiring counts OCR pages, not documents (a per-document 

337 count would bias the controller toward files of a given size). Both are injected 

338 so the controller runs in tests with no clock or GPU. 

339 """ 

340 

341 def __init__( 

342 self, 

343 gate: ResizableGate, 

344 profile: ConcurrencyProfile, 

345 sample: Callable[[float], Signals], 

346 completed: Callable[[], int], 

347 *, 

348 permit_min: int, 

349 permit_max: int, 

350 sleep: Callable[[float], Awaitable[None]] = asyncio.sleep, 

351 ) -> None: 

352 self._gate = gate 

353 self._profile = profile 

354 self._sample = sample 

355 self._completed = completed 

356 self._permit_min = permit_min 

357 self._permit_max = permit_max 

358 self._sleep = sleep 

359 

360 async def run(self) -> None: 

361 """Sample-decide-resize loop. Runs until the task is cancelled. 

362 

363 A tuning tick is best-effort: a transient sampling/probe failure is logged and 

364 skipped, never propagated, so the controller can never crash the ingest it is 

365 only advising. Cancellation (``CancelledError``) still ends the loop cleanly. 

366 """ 

367 state = ControllerState(self._gate.limit, None, 1, 0) 

368 last_completed = self._completed() 

369 while True: 

370 await self._sleep(self._profile.interval_s) 

371 try: 

372 now_completed = self._completed() 

373 throughput = float(max(0, now_completed - last_completed)) 

374 last_completed = now_completed 

375 state = decide( 

376 self._profile, 

377 state, 

378 self._sample(throughput), 

379 self._permit_min, 

380 self._permit_max, 

381 ) 

382 if state.permits != self._gate.limit: 

383 await self._gate.set_limit(state.permits) 

384 log.debug("adaptive ingest: limit -> %d", state.permits) 

385 except Exception: 

386 log.debug("adaptive ingest: tuning tick failed; skipping", exc_info=True) 

387 

388 

389def enumerate_fleet_devices() -> Sequence[DeviceLike]: 

390 """The GPU devices to read telemetry from, or empty when none can be enumerated. 

391 

392 Any failure (no engine binary, probe error) degrades to an empty list, which the 

393 caller treats as "no fleet to feed" and falls back to the static limit. 

394 """ 

395 try: 

396 from lilbee.providers.fleet.binary import resolve_llama_server 

397 from lilbee.providers.fleet.planning import resolve_devices 

398 

399 return resolve_devices(resolve_llama_server()) 

400 except Exception: 

401 log.debug("adaptive ingest: device enumeration failed; using static limit", exc_info=True) 

402 return [] 

403 

404 

405def make_signal_sampler(devices: Sequence[DeviceLike]) -> Callable[[float], Signals]: 

406 """Build a sampler that reads mean GPU util, max GPU temp, CPU %, and free RAM. 

407 

408 GPU util/temp are None when no device reports them (the controller then relies on 

409 the CPU and RAM guards alone); throughput is supplied by the controller. 

410 """ 

411 import psutil 

412 

413 from lilbee.providers.fleet.gpu_stats import probe_gpu_stats 

414 

415 def sample(throughput: float) -> Signals: 

416 stats = probe_gpu_stats(devices) 

417 utils = [g.utilization_pct for g in stats.values() if g.utilization_pct is not None] 

418 temps = [g.temperature_c for g in stats.values() if g.temperature_c is not None] 

419 vm = psutil.virtual_memory() 

420 return Signals( 

421 throughput=throughput, 

422 gpu_util_pct=(sum(utils) / len(utils)) if utils else None, 

423 gpu_temp_c=float(max(temps)) if temps else None, 

424 cpu_pct=psutil.cpu_percent(interval=None), 

425 ram_free_frac=vm.available / vm.total, # psutil total is always positive 

426 ) 

427 

428 return sample