Skip to main content

sim_lib_compute_auto/
store.rs

1//! Table-backed measured profile persistence.
2
3use sim_kernel::{Cx, Error, Expr, NumberLiteral, Result, Symbol, Value};
4use sim_lib_compute_model::ModeledComputeProfile;
5
6use crate::{
7    ComputeDeviceIdentity, ComputeEvidenceKind, ComputeProfileLimits, ComputeProfileProvenance,
8    ComputeProfileSamples, ComputeThermalPowerContext, MeasuredComputeProfile,
9};
10
11const PROFILE_SCHEMA: &str = "sim.compute.auto.profile.v2";
12const LEGACY_PROFILE_SCHEMA: &str = "sim.compute.auto.profile.v1";
13const DEFAULT_MAX_KEY_BYTES: usize = 96;
14const DEFAULT_MAX_PROFILE_BYTES: usize = 8192;
15
16/// Policy bounding profile persistence.
17#[derive(Clone, Debug, PartialEq, Eq)]
18pub struct ProfileStorePolicy {
19    /// Maximum UTF-8 bytes accepted in a profile key.
20    pub max_key_bytes: usize,
21    /// Maximum estimated bytes accepted for one profile record.
22    pub max_profile_bytes: usize,
23}
24
25impl Default for ProfileStorePolicy {
26    fn default() -> Self {
27        Self {
28            max_key_bytes: DEFAULT_MAX_KEY_BYTES,
29            max_profile_bytes: DEFAULT_MAX_PROFILE_BYTES,
30        }
31    }
32}
33
34/// Table-backed measured profile store.
35pub struct ProfileStore {
36    table: Value,
37    policy: ProfileStorePolicy,
38}
39
40impl ProfileStore {
41    /// Builds a store from a caller-supplied Table or Dir value.
42    pub fn new(table: Value, policy: ProfileStorePolicy) -> Result<Self> {
43        if table.object().as_table_impl().is_none() {
44            return Err(Error::TypeMismatch {
45                expected: "Table or Dir",
46                found: "non-table",
47            });
48        }
49        Ok(Self { table, policy })
50    }
51
52    /// Stores a profile with one bounded, atomic table update.
53    pub fn save(&self, cx: &mut Cx, key: Symbol, profile: &MeasuredComputeProfile) -> Result<()> {
54        self.validate_key(&key)?;
55        if profile.estimated_profile_bytes() > self.policy.max_profile_bytes {
56            return Err(Error::Eval(
57                "compute profile exceeds byte policy".to_owned(),
58            ));
59        }
60        let value = profile_to_value(cx, profile)?;
61        self.table
62            .object()
63            .as_table_impl()
64            .expect("profile store table")
65            .set(cx, key, value)
66    }
67
68    /// Loads a checked profile from a bounded key.
69    pub fn load(&self, cx: &mut Cx, key: Symbol) -> Result<Option<MeasuredComputeProfile>> {
70        self.validate_key(&key)?;
71        let table = self
72            .table
73            .object()
74            .as_table_impl()
75            .expect("profile store table");
76        if !table.has(cx, key.clone())? {
77            return Ok(None);
78        }
79        let value = table.get(cx, key)?;
80        let profile = profile_from_value(cx, &value)?;
81        if profile.estimated_profile_bytes() > self.policy.max_profile_bytes {
82            return Err(Error::Eval(
83                "stored compute profile exceeds byte policy".to_owned(),
84            ));
85        }
86        Ok(Some(profile))
87    }
88
89    /// Lists bounded profile keys.
90    pub fn keys(&self, cx: &mut Cx) -> Result<Vec<Symbol>> {
91        let keys = self
92            .table
93            .object()
94            .as_table_impl()
95            .expect("profile store table")
96            .keys(cx)?;
97        for key in &keys {
98            self.validate_key(key)?;
99        }
100        Ok(keys)
101    }
102
103    fn validate_key(&self, key: &Symbol) -> Result<()> {
104        if key.to_string().len() > self.policy.max_key_bytes {
105            return Err(Error::Eval(
106                "compute profile key exceeds byte policy".to_owned(),
107            ));
108        }
109        Ok(())
110    }
111}
112
113fn profile_to_value(cx: &mut Cx, profile: &MeasuredComputeProfile) -> Result<Value> {
114    let entries = vec![
115        (sym("schema"), string(cx, PROFILE_SCHEMA)?),
116        (sym("adapter"), string(cx, &profile.identity.adapter)?),
117        (sym("driver"), string(cx, &profile.identity.driver)?),
118        (sym("backend"), string(cx, &profile.identity.backend)?),
119        (
120            sym("max-resident-bytes"),
121            number(cx, profile.limits.max_resident_bytes)?,
122        ),
123        (
124            sym("max-storage-binding-bytes"),
125            number(cx, profile.limits.max_storage_binding_bytes)?,
126        ),
127        (
128            sym("max-queue-depth"),
129            number(cx, profile.limits.max_queue_depth as u64)?,
130        ),
131        (
132            sym("max-queue-bytes"),
133            number(cx, profile.limits.max_queue_bytes)?,
134        ),
135        (
136            sym("submission-deadline-ticks"),
137            number(cx, profile.limits.submission_deadline_ticks)?,
138        ),
139        (
140            sym("upload-bytes-per-tick"),
141            number_list(cx, &profile.samples.upload_bytes_per_tick)?,
142        ),
143        (
144            sym("download-bytes-per-tick"),
145            number_list(cx, &profile.samples.download_bytes_per_tick)?,
146        ),
147        (
148            sym("launch-ticks"),
149            number_list(cx, &profile.samples.launch_ticks)?,
150        ),
151        (
152            sym("element-elements-per-tick"),
153            number_list(cx, &profile.samples.element_elements_per_tick)?,
154        ),
155        (
156            sym("reduction-elements-per-tick"),
157            number_list(cx, &profile.samples.reduction_elements_per_tick)?,
158        ),
159        (
160            sym("matmul-ops-per-tick"),
161            number_list(cx, &profile.samples.matmul_ops_per_tick)?,
162        ),
163        (sym("thermal"), string(cx, &profile.context.thermal)?),
164        (sym("power"), string(cx, &profile.context.power)?),
165        (sym("tile-bytes"), number(cx, profile.tile_bytes)?),
166        (
167            sym("allocation-bytes"),
168            number_list(cx, &profile.allocation_bytes)?,
169        ),
170        (sym("producer"), string(cx, &profile.provenance.producer)?),
171        (
172            sym("measured-at-tick"),
173            number(cx, profile.provenance.measured_at_tick)?,
174        ),
175        (
176            sym("stale-after-ticks"),
177            number(cx, profile.provenance.stale_after_ticks)?,
178        ),
179        (
180            sym("modeled-provider"),
181            string(cx, &profile.modeled.provider)?,
182        ),
183        (
184            sym("evidence-kind"),
185            string(cx, profile.provenance.evidence_kind.as_str())?,
186        ),
187    ];
188    let mut entries = entries;
189    if let Some(observed) = &profile.provenance.observed_identity {
190        entries.extend([
191            (sym("observed-adapter"), string(cx, &observed.adapter)?),
192            (sym("observed-driver"), string(cx, &observed.driver)?),
193            (sym("observed-backend"), string(cx, &observed.backend)?),
194        ]);
195    }
196    cx.factory().table(entries)
197}
198
199fn profile_from_value(cx: &mut Cx, value: &Value) -> Result<MeasuredComputeProfile> {
200    let entries = value
201        .object()
202        .as_table_impl()
203        .ok_or(Error::TypeMismatch {
204            expected: "profile table",
205            found: "non-table",
206        })?
207        .entries(cx)?;
208    let get = |name: &str| -> Result<Value> {
209        entries
210            .iter()
211            .find(|(key, _)| *key == sym(name))
212            .map(|(_, value)| value.clone())
213            .ok_or_else(|| Error::Eval(format!("compute profile missing {name}")))
214    };
215    let schema = string_value(cx, &get("schema")?)?;
216    if schema != PROFILE_SCHEMA && schema != LEGACY_PROFILE_SCHEMA {
217        return Err(Error::Eval("unsupported compute profile schema".to_owned()));
218    }
219    let max_queue_depth = number_value(cx, &get("max-queue-depth")?)?;
220    let identity = ComputeDeviceIdentity {
221        adapter: string_value(cx, &get("adapter")?)?,
222        driver: string_value(cx, &get("driver")?)?,
223        backend: string_value(cx, &get("backend")?)?,
224    };
225    let evidence_kind = match maybe_string(cx, &entries, "evidence-kind")? {
226        Some(kind) => ComputeEvidenceKind::try_from(kind.as_str())
227            .map_err(|err| Error::Eval(err.to_string()))?,
228        None => ComputeEvidenceKind::Modeled,
229    };
230    let observed_identity = match (
231        maybe_string(cx, &entries, "observed-adapter")?,
232        maybe_string(cx, &entries, "observed-driver")?,
233        maybe_string(cx, &entries, "observed-backend")?,
234    ) {
235        (Some(adapter), Some(driver), Some(backend)) => {
236            Some(ComputeDeviceIdentity::new(adapter, driver, backend))
237        }
238        (None, None, None) => None,
239        _ => {
240            return Err(Error::Eval(
241                "compute profile has partial observed identity".to_owned(),
242            ));
243        }
244    };
245    Ok(MeasuredComputeProfile {
246        identity,
247        limits: ComputeProfileLimits {
248            max_resident_bytes: number_value(cx, &get("max-resident-bytes")?)?,
249            max_storage_binding_bytes: number_value(cx, &get("max-storage-binding-bytes")?)?,
250            max_queue_depth: usize::try_from(max_queue_depth)
251                .map_err(|_| Error::Eval("max-queue-depth exceeds usize".to_owned()))?,
252            max_queue_bytes: number_value(cx, &get("max-queue-bytes")?)?,
253            submission_deadline_ticks: number_value(cx, &get("submission-deadline-ticks")?)?,
254        },
255        samples: ComputeProfileSamples {
256            upload_bytes_per_tick: number_vec(cx, &get("upload-bytes-per-tick")?)?,
257            download_bytes_per_tick: number_vec(cx, &get("download-bytes-per-tick")?)?,
258            launch_ticks: number_vec(cx, &get("launch-ticks")?)?,
259            element_elements_per_tick: number_vec(cx, &get("element-elements-per-tick")?)?,
260            reduction_elements_per_tick: number_vec(cx, &get("reduction-elements-per-tick")?)?,
261            matmul_ops_per_tick: number_vec(cx, &get("matmul-ops-per-tick")?)?,
262        },
263        context: ComputeThermalPowerContext {
264            thermal: string_value(cx, &get("thermal")?)?,
265            power: string_value(cx, &get("power")?)?,
266        },
267        tile_bytes: number_value(cx, &get("tile-bytes")?)?,
268        allocation_bytes: number_vec(cx, &get("allocation-bytes")?)?,
269        provenance: ComputeProfileProvenance {
270            evidence_kind,
271            producer: string_value(cx, &get("producer")?)?,
272            measured_at_tick: number_value(cx, &get("measured-at-tick")?)?,
273            stale_after_ticks: number_value(cx, &get("stale-after-ticks")?)?,
274            observed_identity,
275        },
276        modeled: ModeledComputeProfile {
277            provider: string_value(cx, &get("modeled-provider")?)?,
278            max_queue_depth: usize::try_from(max_queue_depth)
279                .map_err(|_| Error::Eval("max-queue-depth exceeds usize".to_owned()))?,
280            max_queue_bytes: number_value(cx, &get("max-queue-bytes")?)?,
281            max_resident_bytes: number_value(cx, &get("max-resident-bytes")?)?,
282            segment_tile_bytes: number_value(cx, &get("tile-bytes")?)?,
283            max_storage_binding_bytes: number_value(cx, &get("max-storage-binding-bytes")?)?,
284            submission_deadline_ticks: number_value(cx, &get("submission-deadline-ticks")?)?,
285            fault: None,
286            auto_flush_batches: false,
287        },
288    })
289}
290
291fn maybe_string(cx: &mut Cx, entries: &[(Symbol, Value)], name: &str) -> Result<Option<String>> {
292    entries
293        .iter()
294        .find(|(key, _)| *key == sym(name))
295        .map(|(_, value)| string_value(cx, value))
296        .transpose()
297}
298
299fn number(cx: &mut Cx, value: u64) -> Result<Value> {
300    cx.factory()
301        .number_literal(Symbol::qualified("numbers", "u64"), value.to_string())
302}
303
304fn string(cx: &mut Cx, value: &str) -> Result<Value> {
305    cx.factory().string(value.to_owned())
306}
307
308fn number_list(cx: &mut Cx, values: &[u64]) -> Result<Value> {
309    let values = values
310        .iter()
311        .map(|value| number(cx, *value))
312        .collect::<Result<Vec<_>>>()?;
313    cx.factory().list(values)
314}
315
316fn string_value(cx: &mut Cx, value: &Value) -> Result<String> {
317    match value.object().as_expr(cx)? {
318        Expr::String(value) => Ok(value),
319        _ => Err(Error::TypeMismatch {
320            expected: "string",
321            found: "non-string",
322        }),
323    }
324}
325
326fn number_value(cx: &mut Cx, value: &Value) -> Result<u64> {
327    match value.object().as_expr(cx)? {
328        Expr::Number(NumberLiteral { canonical, .. }) => canonical
329            .parse::<u64>()
330            .map_err(|_| Error::Eval("invalid u64 profile value".to_owned())),
331        _ => Err(Error::TypeMismatch {
332            expected: "number",
333            found: "non-number",
334        }),
335    }
336}
337
338fn number_vec(cx: &mut Cx, value: &Value) -> Result<Vec<u64>> {
339    match value.object().as_expr(cx)? {
340        Expr::List(values) => values
341            .iter()
342            .map(|expr| match expr {
343                Expr::Number(NumberLiteral { canonical, .. }) => canonical
344                    .parse::<u64>()
345                    .map_err(|_| Error::Eval("invalid u64 profile sample".to_owned())),
346                _ => Err(Error::TypeMismatch {
347                    expected: "number list",
348                    found: "non-number list",
349                }),
350            })
351            .collect(),
352        _ => Err(Error::TypeMismatch {
353            expected: "list",
354            found: "non-list",
355        }),
356    }
357}
358
359fn sym(name: &str) -> Symbol {
360    Symbol::qualified("compute-profile", name)
361}