1use std::cell::Ref;
4use std::collections::VecDeque;
5use std::ops::Deref;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::sync::{Arc, Mutex, OnceLock};
8
9use oximo_core::{
10 Constraint, Model, ModelConstraints, ModelKind, Objective, ObjectiveSense, Sense,
11 SocConstraint, SocForm, Variable,
12};
13use oximo_expr::{
14 ExprArena, ExprId, ExprNode, LinearTerms, QuadraticTerms, extract_linear, extract_quadratic,
15};
16use rustc_hash::FxHashMap;
17
18use crate::SolverError;
19
20#[derive(Clone, Debug)]
22pub enum AffineTerms<'a> {
23 Borrowed(LinearTerms<'a>),
25 Shared(Arc<LinearTerms<'static>>),
26}
27
28impl<'a> Deref for AffineTerms<'a> {
29 type Target = LinearTerms<'a>;
30 #[inline]
31 fn deref(&self) -> &Self::Target {
32 match self {
33 Self::Borrowed(t) => t,
34 Self::Shared(t) => t,
35 }
36 }
37}
38
39impl AffineTerms<'_> {
40 #[inline]
41 pub fn into_owned(self) -> LinearTerms<'static> {
42 match self {
43 Self::Borrowed(t) => t.into_owned(),
44 Self::Shared(t) => Arc::unwrap_or_clone(t),
45 }
46 }
47}
48
49#[derive(Clone, Debug)]
51pub enum Extracted<T> {
52 Owned(T),
53 Shared(Arc<T>),
54}
55
56#[derive(Clone, Debug)]
58pub enum PolynomialTerms<'a> {
59 Affine(LinearTerms<'a>),
60 Quadratic(Extracted<QuadraticTerms>),
61}
62
63impl<T> Deref for Extracted<T> {
64 type Target = T;
65 #[inline]
66 fn deref(&self) -> &T {
67 match self {
68 Self::Owned(value) => value,
69 Self::Shared(value) => value,
70 }
71 }
72}
73
74#[derive(Debug)]
81pub struct LoweringContext<'a> {
82 expressions: PreparedExpressions,
83 variables: Ref<'a, Vec<Variable>>,
84 constraints: ModelConstraints<'a>,
85 objective: Ref<'a, Option<Objective>>,
86 kind: ModelKind,
87}
88
89#[derive(Debug)]
99pub struct PreparedExpressions {
100 arena: ExprArena,
101 linear: ExtractionCache<LinearTerms<'static>>,
102 quadratic: ExtractionCache<QuadraticTerms>,
103}
104
105const CACHE_EXPRESSIONS: usize = 256;
106const CACHE_COEFFICIENTS: usize = 16_384;
107const CACHE_SHARDS: usize = 16;
108
109#[derive(Debug)]
110struct CacheEntries<T> {
111 values: FxHashMap<ExprId, Option<Arc<T>>>,
112 order: VecDeque<(ExprId, usize)>,
113 coefficients: usize,
114}
115
116#[derive(Debug)]
117struct ExtractionCache<T> {
118 recent: [AtomicU64; CACHE_SHARDS],
119 shards: OnceLock<Box<[CacheShard<T>; CACHE_SHARDS]>>,
120}
121
122#[derive(Debug)]
124#[repr(align(64))]
125struct CacheShard<T> {
126 entries: Mutex<CacheEntries<T>>,
127}
128
129impl<T> Default for ExtractionCache<T> {
130 fn default() -> Self {
131 Self { recent: std::array::from_fn(|_| AtomicU64::new(u64::MAX)), shards: OnceLock::new() }
132 }
133}
134
135fn cache_shards<T>() -> Box<[CacheShard<T>; CACHE_SHARDS]> {
136 Box::new(std::array::from_fn(|_| CacheShard {
137 entries: Mutex::new(CacheEntries {
138 values: FxHashMap::default(),
139 order: VecDeque::new(),
140 coefficients: 0,
141 }),
142 }))
143}
144
145impl<T> ExtractionCache<T> {
146 fn extract(
147 &self,
148 expr: ExprId,
149 extract: impl FnOnce() -> Option<T>,
150 size: impl FnOnce(&T) -> usize,
151 ) -> Option<Extracted<T>> {
152 let key = u64::from(expr.0);
153 let admit =
155 || self.recent[expr.0 as usize % CACHE_SHARDS].swap(key, Ordering::Relaxed) == key;
156 let Some(shards) = self.shards.get() else {
157 if !admit() {
158 return extract().map(Extracted::Owned);
159 }
160 let shards = self.shards.get_or_init(cache_shards);
161 let shard = &shards[(expr.0.wrapping_mul(0x9e37_79b9) >> 28) as usize];
162 return shard.extract(expr, extract, size, || true);
163 };
164 let shard = &shards[(expr.0.wrapping_mul(0x9e37_79b9) >> 28) as usize];
165 shard.extract(expr, extract, size, admit)
166 }
167}
168
169impl<T> CacheShard<T> {
170 fn extract(
171 &self,
172 expr: ExprId,
173 extract: impl FnOnce() -> Option<T>,
174 size: impl FnOnce(&T) -> usize,
175 admit: impl FnOnce() -> bool,
176 ) -> Option<Extracted<T>> {
177 if let Some(value) =
178 self.entries.lock().expect("preparation cache poisoned").values.get(&expr)
179 {
180 return value.clone().map(Extracted::Shared);
181 }
182 if !admit() {
184 return extract().map(Extracted::Owned);
185 }
186 let value = extract();
189 let coefficients = value.as_ref().map_or(0, size);
190 if coefficients > CACHE_COEFFICIENTS / CACHE_SHARDS {
191 return value.map(Extracted::Owned);
192 }
193 let mut cache = self.entries.lock().expect("preparation cache poisoned");
194 if let Some(existing) = cache.values.get(&expr) {
195 return existing.clone().map(Extracted::Shared);
196 }
197 while cache.values.len() >= CACHE_EXPRESSIONS / CACHE_SHARDS
198 || cache.coefficients + coefficients > CACHE_COEFFICIENTS / CACHE_SHARDS
199 {
200 let (old, size) = cache.order.pop_front().expect("nonempty cache");
201 cache.values.remove(&old);
202 cache.coefficients -= size;
203 }
204 cache.coefficients += coefficients;
205 cache.order.push_back((expr, coefficients));
206 let value = value.map(Arc::new);
207 cache.values.insert(expr, value.clone());
208 value.map(Extracted::Shared)
209 }
210}
211
212impl<'a> LoweringContext<'a> {
213 pub fn new(model: &'a Model) -> Result<Self, SolverError> {
218 model.ensure_objective_declared()?;
219 Ok(Self {
220 expressions: PreparedExpressions::new((*model.arena()).clone()),
221 variables: model.variables(),
222 constraints: model.constraints(),
223 objective: model.objective(),
224 kind: model.kind(),
225 })
226 }
227
228 pub fn variables(&self) -> &[Variable] {
229 &self.variables
230 }
231 pub fn constraints(&self) -> &ModelConstraints<'a> {
232 &self.constraints
233 }
234 pub fn objective(&self) -> Option<&Objective> {
235 self.objective.as_ref()
236 }
237 pub fn kind(&self) -> ModelKind {
238 self.kind
239 }
240 pub fn sense(&self) -> ObjectiveSense {
241 self.objective().map_or(ObjectiveSense::Minimize, |o| o.sense)
242 }
243
244 #[cold]
246 #[inline(never)]
247 pub fn nonlinear_error(&self, expr: ExprId, location: impl Into<String>) -> SolverError {
248 SolverError::Nonlinear {
249 location: location.into(),
250 term: oximo_expr::describe_nonlinear_term(self.arena(), expr, &|id| {
251 oximo_core::var_name(self.variables(), id)
252 })
253 .unwrap_or_else(|| "<nonlinear>".into()),
254 }
255 }
256
257 #[inline]
262 pub fn require_linear(
263 &self,
264 expr: ExprId,
265 location: impl FnOnce() -> String,
266 ) -> Result<AffineTerms<'_>, SolverError> {
267 self.linear(expr).ok_or_else(|| self.nonlinear_error(expr, location()))
268 }
269
270 #[expect(clippy::inline_always, reason = "called once per streamed backend row")]
279 #[inline(always)]
280 pub fn require_linear_once(
281 &self,
282 expr: ExprId,
283 location: impl FnOnce() -> String,
284 ) -> Result<LinearTerms<'_>, SolverError> {
285 extract_linear(&self.arena, expr).ok_or_else(|| self.nonlinear_error(expr, location()))
286 }
287
288 #[inline]
293 pub fn require_quadratic(
294 &self,
295 expr: ExprId,
296 location: impl FnOnce() -> String,
297 ) -> Result<Extracted<QuadraticTerms>, SolverError> {
298 self.quadratic(expr).ok_or_else(|| self.nonlinear_error(expr, location()))
299 }
300
301 pub fn require_polynomial(
308 &self,
309 expr: ExprId,
310 location: impl FnOnce() -> String,
311 ) -> Result<PolynomialTerms<'_>, SolverError> {
312 if let ExprNode::Linear { coeffs, constant } = self.arena.get(expr) {
313 return Ok(PolynomialTerms::Affine(LinearTerms::borrowed(coeffs, *constant)));
314 }
315 self.quadratic(expr)
316 .map(PolynomialTerms::Quadratic)
317 .ok_or_else(|| self.nonlinear_error(expr, location()))
318 }
319
320 pub fn detected_soc(&self, row: &Constraint) -> Option<SocForm> {
322 self.expressions.detected_soc(self.variables(), row)
323 }
324}
325
326#[inline]
329pub fn shifted_bounds(row: &Constraint, constant: f64) -> (f64, f64) {
330 (row.lower - constant, row.upper - constant)
331}
332
333#[inline]
336pub fn row_sides(row: &Constraint) -> [Option<(Sense, f64)>; 2] {
337 match row.as_single() {
338 Some(single) => [Some(single), None],
339 None if row.is_range() => [Some((Sense::Ge, row.lower)), Some((Sense::Le, row.upper))],
340 None => [None, None],
341 }
342}
343
344impl Deref for LoweringContext<'_> {
345 type Target = PreparedExpressions;
346 #[inline]
347 fn deref(&self) -> &Self::Target {
348 &self.expressions
349 }
350}
351
352impl Deref for PreparedExpressions {
353 type Target = ExprArena;
354 #[inline]
355 fn deref(&self) -> &Self::Target {
356 &self.arena
357 }
358}
359
360impl PreparedExpressions {
361 pub fn new(arena: ExprArena) -> Self {
362 Self { arena, linear: ExtractionCache::default(), quadratic: ExtractionCache::default() }
363 }
364 pub fn arena(&self) -> &ExprArena {
365 &self.arena
366 }
367 #[inline]
373 pub fn linear(&self, expr: ExprId) -> Option<AffineTerms<'_>> {
374 if let ExprNode::Linear { coeffs, constant } = self.arena.get(expr) {
375 return Some(AffineTerms::Borrowed(LinearTerms::borrowed(coeffs, *constant)));
376 }
377 self.compound_linear(expr)
378 }
379
380 #[inline(never)]
382 fn compound_linear(&self, expr: ExprId) -> Option<AffineTerms<'_>> {
383 self.linear
384 .extract(
385 expr,
386 || extract_linear(&self.arena, expr).map(LinearTerms::into_owned),
387 |t| t.coeffs.len(),
388 )
389 .map(|terms| match terms {
390 Extracted::Owned(terms) => AffineTerms::Borrowed(terms),
391 Extracted::Shared(terms) => AffineTerms::Shared(terms),
392 })
393 }
394
395 pub fn quadratic(&self, expr: ExprId) -> Option<Extracted<QuadraticTerms>> {
400 self.quadratic.extract(
401 expr,
402 || extract_quadratic(&self.arena, expr),
403 |t| t.linear.len() + t.hessian.len(),
404 )
405 }
406
407 pub fn detected_soc(&self, variables: &[Variable], row: &Constraint) -> Option<SocForm> {
409 let q = self.quadratic(row.lhs)?;
410 oximo_core::__detect_soc_from_quadratic(variables, row, &q)
411 }
412
413 pub fn explicit_soc(&self, soc: &SocConstraint) -> Result<SocForm, SolverError> {
418 let affine = |expr| {
419 extract_linear(&self.arena, expr).map(LinearTerms::into_owned).ok_or_else(|| {
420 SolverError::Backend(format!(
421 "invalid affine expressions in SOC constraint {:?}",
422 soc.name
423 ))
424 })
425 };
426 Ok(SocForm {
427 terms: soc.terms.iter().map(|&expr| affine(expr)).collect::<Result<_, _>>()?,
428 bound: affine(soc.bound)?,
429 })
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 #[test]
438 fn interleaved_roots_are_admitted_and_cached_hits_ignore_history() {
439 let cache = ExtractionCache::default();
440 let shard = |id: u32| id.wrapping_mul(0x9e37_79b9) >> 28;
441 let a = 0;
442 let b = (1..1000).find(|&id| shard(id) == shard(a) && id % 16 != a % 16).unwrap();
443 for id in [a, b] {
444 assert!(matches!(
445 cache.extract(ExprId(id), || Some(vec![id]), Vec::len),
446 Some(Extracted::Owned(_))
447 ));
448 }
449 for id in [a, b, a, b] {
450 assert!(matches!(
451 cache.extract(ExprId(id), || Some(vec![id]), Vec::len),
452 Some(Extracted::Shared(_))
453 ));
454 }
455 cache.extract(ExprId(a + 16), || Some(vec![16]), Vec::len);
457 let hit =
458 cache.extract(ExprId(a), || panic!("cached root was re-extracted"), Vec::len).unwrap();
459 assert_eq!(hit.as_slice(), &[a]);
460 }
461
462 #[test]
463 fn unique_roots_do_not_allocate_cache_entries() {
464 let cache = ExtractionCache::default();
465 for index in 0..1000 {
466 assert!(matches!(
467 cache.extract(ExprId(index), || Some(vec![1]), Vec::len),
468 Some(Extracted::Owned(_))
469 ));
470 }
471 assert!(cache.shards.get().is_none());
472 }
473
474 #[test]
475 fn cache_bounds_storage_and_keeps_returned_terms_alive() {
476 let cache = ExtractionCache::default();
477 let extract =
478 |id, size| cache.extract(ExprId(id), || Some(vec![1; size]), Vec::len).unwrap();
479 extract(0, 1);
480 let first = extract(0, 1);
481 assert!(matches!(first, Extracted::Shared(_)));
482 for index in 1..1000 {
483 extract(index, 100);
484 extract(index, 100);
485 }
486 assert_eq!(first.len(), 1);
487 let shards = cache.shards.get().unwrap();
488 let entries: usize = shards.iter().map(|s| s.entries.lock().unwrap().values.len()).sum();
489 let coefficients: usize =
490 shards.iter().map(|s| s.entries.lock().unwrap().coefficients).sum();
491 assert!(entries <= CACHE_EXPRESSIONS);
492 assert!(coefficients <= CACHE_COEFFICIENTS);
493 extract(1001, CACHE_COEFFICIENTS + 1);
494 assert!(matches!(extract(1001, CACHE_COEFFICIENTS + 1), Extracted::Owned(_)));
495 for index in 2000..3000 {
496 for _ in 0..2 {
497 assert!(cache.extract(ExprId(index), || None, Vec::len).is_none());
498 }
499 }
500 assert!(shards
501 .iter()
502 .all(|s| s.entries.lock().unwrap().values.len() <= CACHE_EXPRESSIONS / CACHE_SHARDS));
503 }
504
505 #[test]
506 fn cache_extracts_outside_lock_and_shares_concurrent_publication() {
507 let single = ExtractionCache::default();
508 single.extract(ExprId(0), || Some(vec![1]), Vec::len);
509 single.extract(
510 ExprId(0),
511 || {
512 assert!(single.shards.get().unwrap()[0].entries.try_lock().is_ok());
513 Some(vec![1])
514 },
515 Vec::len,
516 );
517 let cache = ExtractionCache::default();
518 cache.extract(ExprId(0), || Some(vec![1]), Vec::len);
519 let barrier = std::sync::Barrier::new(2);
520 std::thread::scope(|scope| {
521 let extract = || {
522 let terms = cache
523 .extract(
524 ExprId(0),
525 || {
526 barrier.wait();
527 Some(vec![1])
528 },
529 Vec::len,
530 )
531 .unwrap();
532 let Extracted::Shared(value) = terms else { panic!("reuse not admitted") };
533 value
534 };
535 let a = scope.spawn(extract);
536 let b = scope.spawn(extract);
537 assert!(Arc::ptr_eq(&a.join().unwrap(), &b.join().unwrap()));
538 });
539 }
540}