1use std::sync::{Condvar, Mutex};
5use std::time::{Duration, Instant};
6
7use crate::error::{CompileError, ErrorCode, Stage};
8
9const DEFAULT_WAIT: Duration = Duration::from_secs(30);
11
12#[derive(Debug)]
14pub(crate) struct BuildGate {
15 inner: Mutex<GateInner>,
16 avail: Condvar,
17 byte_budget: usize,
18 max_wait: Duration,
19 stage: Stage,
20}
21
22#[derive(Debug, Default)]
23struct GateInner {
24 permits: usize,
25 bytes_avail: usize,
26 in_use: usize,
27 active_bytes: usize,
28 peak: usize,
29 peak_bytes: usize,
30 rejected: u64,
31}
32
33impl BuildGate {
34 pub(crate) fn new(max_concurrent: usize, byte_budget: usize, stage: Stage) -> Self {
35 Self::with_max_wait(max_concurrent, byte_budget, DEFAULT_WAIT, stage)
36 }
37
38 pub(crate) fn with_max_wait(
39 max_concurrent: usize,
40 byte_budget: usize,
41 max_wait: Duration,
42 stage: Stage,
43 ) -> Self {
44 let byte_budget = byte_budget.max(1);
45 Self {
46 inner: Mutex::new(GateInner {
47 permits: max_concurrent.max(1),
48 bytes_avail: byte_budget,
49 ..GateInner::default()
50 }),
51 avail: Condvar::new(),
52 byte_budget,
53 max_wait,
54 stage,
55 }
56 }
57
58 fn gate_limit(&self, msg: &'static str) -> CompileError {
59 CompileError::new(ErrorCode::InternalLimitExceeded, self.stage, msg)
60 }
61
62 pub(crate) fn acquire(&self, est: usize) -> Result<PermitGuard<'_>, CompileError> {
65 if est > self.byte_budget {
66 self.inner
67 .lock()
68 .unwrap_or_else(|p| p.into_inner())
69 .rejected += 1;
70 return Err(self.gate_limit(
71 "estimated transient memory exceeds the configured build-memory budget",
72 ));
73 }
74 let deadline = Instant::now() + self.max_wait;
75 let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
76 while g.permits == 0 || g.bytes_avail < est {
77 let remaining = deadline.saturating_duration_since(Instant::now());
78 if remaining.is_zero() {
79 g.rejected += 1;
80 return Err(self.gate_limit("timed out waiting for a build-memory permit"));
81 }
82 let (guard, result) = self
83 .avail
84 .wait_timeout(g, remaining)
85 .unwrap_or_else(|p| p.into_inner());
86 g = guard;
87 if result.timed_out() && (g.permits == 0 || g.bytes_avail < est) {
88 g.rejected += 1;
89 return Err(self.gate_limit("timed out waiting for a build-memory permit"));
90 }
91 }
92 g.permits -= 1;
93 g.bytes_avail -= est;
94 g.in_use += 1;
95 g.active_bytes += est;
96 g.peak = g.peak.max(g.in_use);
97 g.peak_bytes = g.peak_bytes.max(g.active_bytes);
98 Ok(PermitGuard {
99 gate: self,
100 charge: est,
101 })
102 }
103
104 pub(crate) fn try_acquire(&self, est: usize) -> Result<PermitGuard<'_>, CompileError> {
107 let mut g = self.inner.lock().unwrap_or_else(|p| p.into_inner());
108 if est > self.byte_budget || g.permits == 0 || g.bytes_avail < est {
109 g.rejected += 1;
110 return Err(
111 self.gate_limit("serving memory budget is fully committed; try again shortly")
112 );
113 }
114 g.permits -= 1;
115 g.bytes_avail -= est;
116 g.in_use += 1;
117 g.active_bytes += est;
118 g.peak = g.peak.max(g.in_use);
119 g.peak_bytes = g.peak_bytes.max(g.active_bytes);
120 Ok(PermitGuard {
121 gate: self,
122 charge: est,
123 })
124 }
125
126 pub(crate) fn peak_concurrent(&self) -> usize {
127 self.inner.lock().unwrap_or_else(|p| p.into_inner()).peak
128 }
129
130 pub(crate) fn peak_bytes(&self) -> usize {
131 self.inner
132 .lock()
133 .unwrap_or_else(|p| p.into_inner())
134 .peak_bytes
135 }
136
137 pub(crate) fn active_bytes(&self) -> usize {
139 self.inner
140 .lock()
141 .unwrap_or_else(|p| p.into_inner())
142 .active_bytes
143 }
144
145 pub(crate) fn rejected(&self) -> u64 {
147 self.inner
148 .lock()
149 .unwrap_or_else(|p| p.into_inner())
150 .rejected
151 }
152}
153
154#[derive(Debug)]
157pub struct PermitGuard<'a> {
158 gate: &'a BuildGate,
159 charge: usize,
160}
161
162impl Drop for PermitGuard<'_> {
163 fn drop(&mut self) {
164 let mut g = self.gate.inner.lock().unwrap_or_else(|p| p.into_inner());
165 g.permits += 1;
166 g.bytes_avail += self.charge;
167 g.in_use -= 1;
168 g.active_bytes -= self.charge;
169 drop(g);
170 self.gate.avail.notify_all(); }
172}
173
174impl PermitGuard<'_> {
175 pub fn ensure_covers(&self, required: usize) -> Result<(), CompileError> {
178 if required > self.charge {
179 return Err(self
180 .gate
181 .gate_limit("build permit does not cover the projected peak"));
182 }
183 Ok(())
184 }
185}
186
187const DEFAULT_VOCAB_BUILD_BYTES: usize = 1 << 31;
190
191const DEFAULT_VOCAB_BUILD_CONCURRENCY: usize = 16;
193
194const DEFAULT_VOCAB_SERVING_BYTES: usize = 1 << 28;
197
198const DEFAULT_VOCAB_SERVING_CONCURRENCY: usize = 64;
200
201const DEFAULT_AUTOMATON_BUILD_BYTES: usize = 1 << 31;
203
204const DEFAULT_AUTOMATON_BUILD_CONCURRENCY: usize = 16;
206
207static VOCAB_BUILD_GATE: std::sync::OnceLock<BuildGate> = std::sync::OnceLock::new();
210static VOCAB_SERVING_GATE: std::sync::OnceLock<BuildGate> = std::sync::OnceLock::new();
211static AUTOMATON_BUILD_GATE: std::sync::OnceLock<BuildGate> = std::sync::OnceLock::new();
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum GateConfigError {
217 InvalidValue,
219 AlreadyInitialized,
221}
222
223impl GateConfigError {
224 #[must_use]
226 pub fn message(self) -> &'static str {
227 match self {
228 Self::InvalidValue => "max_concurrent and byte_budget must both be at least 1",
229 Self::AlreadyInitialized => {
230 "the gate already has a policy; configure it before the first use"
231 }
232 }
233 }
234}
235
236impl std::fmt::Display for GateConfigError {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.write_str(self.message())
239 }
240}
241
242impl std::error::Error for GateConfigError {}
243
244pub(crate) fn configure_vocab_build_gate(
247 max_concurrent: usize,
248 byte_budget: usize,
249) -> Result<(), GateConfigError> {
250 if max_concurrent == 0 || byte_budget == 0 {
251 return Err(GateConfigError::InvalidValue);
252 }
253 VOCAB_BUILD_GATE
254 .set(BuildGate::new(max_concurrent, byte_budget, Stage::L4Bind))
255 .map_err(|_| GateConfigError::AlreadyInitialized)
256}
257
258pub(crate) fn configure_vocab_serving_gate(
261 max_concurrent: usize,
262 byte_budget: usize,
263) -> Result<(), GateConfigError> {
264 if max_concurrent == 0 || byte_budget == 0 {
265 return Err(GateConfigError::InvalidValue);
266 }
267 VOCAB_SERVING_GATE
268 .set(BuildGate::new(max_concurrent, byte_budget, Stage::L4Bind))
269 .map_err(|_| GateConfigError::AlreadyInitialized)
270}
271
272pub(crate) fn configure_automaton_build_gate(
274 max_concurrent: usize,
275 byte_budget: usize,
276) -> Result<(), GateConfigError> {
277 if max_concurrent == 0 || byte_budget == 0 {
278 return Err(GateConfigError::InvalidValue);
279 }
280 AUTOMATON_BUILD_GATE
281 .set(BuildGate::new(max_concurrent, byte_budget, Stage::L3))
282 .map_err(|_| GateConfigError::AlreadyInitialized)
283}
284
285pub(crate) fn vocab_build_gate() -> &'static BuildGate {
288 VOCAB_BUILD_GATE.get_or_init(|| {
289 BuildGate::new(
290 DEFAULT_VOCAB_BUILD_CONCURRENCY,
291 DEFAULT_VOCAB_BUILD_BYTES,
292 Stage::L4Bind,
293 )
294 })
295}
296
297pub(crate) fn vocab_serving_gate() -> &'static BuildGate {
299 VOCAB_SERVING_GATE.get_or_init(|| {
300 BuildGate::new(
301 DEFAULT_VOCAB_SERVING_CONCURRENCY,
302 DEFAULT_VOCAB_SERVING_BYTES,
303 Stage::L4Bind,
304 )
305 })
306}
307
308pub(crate) fn automaton_build_gate() -> &'static BuildGate {
310 AUTOMATON_BUILD_GATE.get_or_init(|| {
311 BuildGate::new(
312 DEFAULT_AUTOMATON_BUILD_CONCURRENCY,
313 DEFAULT_AUTOMATON_BUILD_BYTES,
314 Stage::L3,
315 )
316 })
317}
318
319pub(crate) fn vocab_build_gate_stats() -> (usize, usize, usize, u64) {
322 VOCAB_BUILD_GATE.get().map_or((0, 0, 0, 0), |g| {
323 (
324 g.active_bytes(),
325 g.peak_bytes(),
326 g.peak_concurrent(),
327 g.rejected(),
328 )
329 })
330}
331
332pub(crate) fn vocab_serving_gate_stats() -> (usize, usize, usize, u64) {
334 VOCAB_SERVING_GATE.get().map_or((0, 0, 0, 0), |g| {
335 (
336 g.active_bytes(),
337 g.peak_bytes(),
338 g.peak_concurrent(),
339 g.rejected(),
340 )
341 })
342}
343
344pub(crate) fn automaton_build_gate_stats() -> (usize, usize, usize, u64) {
346 AUTOMATON_BUILD_GATE.get().map_or((0, 0, 0, 0), |g| {
347 (
348 g.active_bytes(),
349 g.peak_bytes(),
350 g.peak_concurrent(),
351 g.rejected(),
352 )
353 })
354}
355
356pub(crate) fn acquire_vocab_build_bytes(
359 estimated_bytes: usize,
360) -> Result<PermitGuard<'static>, CompileError> {
361 vocab_build_gate().acquire(estimated_bytes)
362}
363
364pub(crate) fn try_acquire_vocab_serving_bytes(
367 estimated_bytes: usize,
368) -> Result<PermitGuard<'static>, CompileError> {
369 vocab_serving_gate().try_acquire(estimated_bytes)
370}
371
372pub(crate) fn acquire_automaton_build_bytes(
374 estimated_bytes: usize,
375) -> Result<PermitGuard<'static>, CompileError> {
376 automaton_build_gate().acquire(estimated_bytes)
377}
378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn permit_releases_on_panic_not_just_normal_drop() {
385 let gate = BuildGate::new(4, 100, Stage::L4Bind);
386 let result = std::thread::scope(|scope| {
387 scope
388 .spawn(|| {
389 let _permit = gate.acquire(60).unwrap();
390 panic!("simulated failure while a permit is held");
391 })
392 .join()
393 });
394 assert!(result.is_err(), "the spawned thread must have panicked");
395 assert_eq!(
396 gate.active_bytes(),
397 0,
398 "PermitGuard::drop must run during unwind and release the charge"
399 );
400 }
401
402 #[test]
403 fn configure_vocab_gates_reject_a_zero_value_without_touching_the_singleton() {
404 assert_eq!(
407 configure_vocab_build_gate(0, 100),
408 Err(GateConfigError::InvalidValue)
409 );
410 assert_eq!(
411 configure_vocab_build_gate(4, 0),
412 Err(GateConfigError::InvalidValue)
413 );
414 assert_eq!(
415 configure_vocab_serving_gate(0, 100),
416 Err(GateConfigError::InvalidValue)
417 );
418 assert_eq!(
419 configure_vocab_serving_gate(4, 0),
420 Err(GateConfigError::InvalidValue)
421 );
422 }
423
424 #[test]
425 fn acquire_rejects_an_estimate_over_the_whole_budget() {
426 let gate = BuildGate::new(4, 100, Stage::L4Bind);
427 assert!(gate.acquire(101).is_err());
428 assert_eq!(gate.rejected(), 1);
429 }
430
431 #[test]
432 fn acquire_admits_up_to_the_budget_and_releases_on_drop() {
433 let gate = BuildGate::new(4, 100, Stage::L4Bind);
434 let p1 = gate.acquire(60).unwrap();
435 assert_eq!(gate.active_bytes(), 60);
436 drop(p1);
437 assert_eq!(gate.active_bytes(), 0);
438 let p2 = gate.acquire(100).unwrap();
439 assert_eq!(gate.active_bytes(), 100);
440 drop(p2);
441 }
442
443 #[test]
444 fn a_second_caller_times_out_while_the_budget_is_fully_held() {
445 let gate = BuildGate::with_max_wait(4, 100, Duration::from_millis(50), Stage::L4Bind);
446 let _held = gate.acquire(100).unwrap();
447 let err = gate.acquire(1).expect_err("no bytes are available");
448 assert_eq!(err.code, ErrorCode::InternalLimitExceeded);
449 assert_eq!(gate.rejected(), 1);
450 }
451
452 #[test]
453 fn every_rejection_path_reports_the_gate_own_stage_not_a_hardcoded_one() {
454 let gate = BuildGate::new(1, 100, Stage::L3);
455 assert_eq!(gate.acquire(200).unwrap_err().stage, Stage::L3);
456 assert_eq!(gate.try_acquire(200).unwrap_err().stage, Stage::L3);
457 let permit = gate.acquire(50).unwrap();
458 assert_eq!(permit.ensure_covers(51).unwrap_err().stage, Stage::L3);
459
460 let other = BuildGate::new(1, 100, Stage::L4Bind);
461 assert_eq!(other.acquire(200).unwrap_err().stage, Stage::L4Bind);
462 }
463
464 #[test]
465 fn peak_bytes_and_peak_concurrent_track_the_high_water_mark() {
466 let gate = BuildGate::new(4, 1000, Stage::L4Bind);
467 let p1 = gate.acquire(100).unwrap();
468 let p2 = gate.acquire(200).unwrap();
469 assert_eq!(gate.peak_concurrent(), 2);
470 assert_eq!(gate.peak_bytes(), 300);
471 drop(p1);
472 drop(p2);
473 assert_eq!(
474 gate.peak_concurrent(),
475 2,
476 "peak is a high-water mark, not current"
477 );
478 assert_eq!(gate.peak_bytes(), 300);
479 }
480
481 #[test]
482 #[ignore = "timing sanity check, not a correctness gate; run with --ignored --nocapture"]
483 fn uncontended_acquire_release_cost() {
484 let gate = BuildGate::new(16, 1 << 31, Stage::L4Bind);
485 let n = 100_000;
486 let t = std::time::Instant::now();
487 for _ in 0..n {
488 let _p = gate.acquire(1024).unwrap();
489 }
490 let elapsed = t.elapsed();
491 println!(
492 "{n} uncontended acquire+release cycles: {:?} total, {:?}/cycle",
493 elapsed,
494 elapsed / n
495 );
496 }
497
498 #[test]
499 #[ignore = "timing sanity check, not a correctness gate; run with --release --ignored --nocapture"]
500 fn contended_acquire_release_cost_1_8_16_32_threads() {
501 fn stats(mut xs: Vec<f64>) -> (f64, f64, f64, f64, f64, f64) {
502 xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
503 let n = xs.len();
504 let median = xs[n / 2];
505 let p95 = xs[((n as f64 * 0.95) as usize).min(n - 1)];
506 let p99 = xs[((n as f64 * 0.99) as usize).min(n - 1)];
507 let mean = xs.iter().sum::<f64>() / n as f64;
508 let var = xs.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / n as f64;
509 (median, p95, p99, var.sqrt(), xs[0], xs[n - 1])
510 }
511 const ITERS_PER_THREAD: usize = 5_000;
514 for &threads in &[1usize, 8, 16, 32] {
515 let gate = BuildGate::new(threads * 4, 1 << 34, Stage::L4Bind);
516 let samples: Vec<f64> = std::thread::scope(|scope| {
517 let handles: Vec<_> = (0..threads)
518 .map(|_| {
519 let gate = &gate;
520 scope.spawn(move || {
521 let mut xs = Vec::with_capacity(ITERS_PER_THREAD);
522 for _ in 0..ITERS_PER_THREAD {
523 let t = std::time::Instant::now();
524 let _p = gate.acquire(1024).unwrap();
525 xs.push(t.elapsed().as_secs_f64() * 1e9); }
527 xs
528 })
529 })
530 .collect();
531 handles
532 .into_iter()
533 .flat_map(|h| h.join().unwrap())
534 .collect()
535 });
536 let (median, p95, p99, stddev, min, max) = stats(samples.clone());
537 println!(
538 "threads={threads}: n={} median={median:.1} p95={p95:.1} p99={p99:.1} \
539 stddev={stddev:.1} min={min:.1} max={max:.1} ns/acquire",
540 samples.len()
541 );
542 }
543 }
544}