Skip to main content

sva_engine/render/
mod.rs

1// Concern: holds a composition's decided types, the buffers a reading needed and each label | Non-concern: ordering the work (schedule.rs), a sampled shader (sampled.rs) | IO: (&Graph, target) -> Render
2
3mod answer;
4mod pointwise;
5mod sampled;
6mod slots;
7
8use std::collections::BTreeMap;
9
10use sva_ast::Graph;
11use sva_formula::{Held, NodeId, SpectralSum, hash_closed_form};
12use sva_samples::{
13    AliasScore, Buffer, FilterTrace, Frames, Horizon, Label, PSYCHOACOUSTIC_V1, Profile, stft,
14};
15
16use crate::bindings::Binding;
17use crate::cache::{
18    CacheStats, Cost, Expected, Payload, PayloadKind, Recording, frames_key, symbolic_key,
19};
20use crate::cast::Cast;
21use crate::error::{Diagnostic, EngineError, Located};
22use crate::instantiate;
23use crate::query::Ask;
24use crate::refs;
25use crate::schedule::{self, Schedule};
26use crate::typing::{self, Typing, Value};
27
28#[derive(Clone, Debug, PartialEq)]
29pub struct RenderConfig {
30    pub rate: u32,
31    pub horizon: Horizon,
32    pub profile: Profile,
33    /// What the caller means to read. An empty list is audio out, which collapses the root.
34    pub asks: Vec<Ask>,
35    /// The operation count this render may pay; the profile's own until a caller raises it.
36    pub flop_budget: u128,
37}
38
39impl RenderConfig {
40    pub fn seconds(rate: u32, secs: f64) -> RenderConfig {
41        RenderConfig {
42            rate,
43            horizon: Horizon::secs(0.0, secs),
44            profile: PSYCHOACOUSTIC_V1,
45            asks: Vec::new(),
46            flop_budget: PSYCHOACOUSTIC_V1.flop_budget,
47        }
48    }
49
50    pub fn asking(mut self, asks: Vec<Ask>) -> RenderConfig {
51        self.asks = asks;
52        self
53    }
54}
55
56pub use answer::{answer, answer_buffer, sketch_atom};
57
58pub struct Render {
59    pub root: NodeId,
60    pub tys: Typing,
61    pub buffers: BTreeMap<NodeId, Buffer>,
62    pub frames: BTreeMap<NodeId, Frames>,
63    pub symbolic: BTreeMap<NodeId, SpectralSum>,
64    pub labels: BTreeMap<NodeId, Label>,
65    pub traces: Vec<FilterTrace>,
66    pub config: RenderConfig,
67    pub schedule: Schedule,
68    pub bindings: BTreeMap<NodeId, Vec<Binding>>,
69    pub cache_stats: Option<CacheStats>,
70}
71
72impl Render {
73    pub fn buffer(&self, node: NodeId) -> Option<&Buffer> {
74        self.buffers.get(&node)
75    }
76
77    pub fn id(&self, path: &str) -> Option<NodeId> {
78        self.tys.id(path)
79    }
80
81    /// The node a reading names, refusing by name where a file expanded into several.
82    pub fn node(&self, path: &str) -> Result<NodeId, EngineError> {
83        self.tys.resolve(path)
84    }
85
86    /// The multiple a reading asks this node's score against, where one asks for a score at all.
87    pub fn alias_oversample(&self, node: NodeId) -> Option<u32> {
88        self.config
89            .asks
90            .iter()
91            .find_map(|ask| match ask.representation {
92                crate::query::Representation::Alias { oversample }
93                    if self.node(&ask.node).is_ok_and(|asked| asked == node) =>
94                {
95                    Some(oversample)
96                }
97                _ => None,
98            })
99    }
100
101    pub fn alias_score(&self, node: NodeId) -> AliasScore {
102        match self.alias_oversample(node) {
103            Some(_) => AliasScore::Asked,
104            None => AliasScore::NotAsked,
105        }
106    }
107}
108
109/// Nothing is materialized that no reading asked for: a closed form answered off its spectral sum
110/// allocates no buffer at all.
111pub fn render(
112    graph: &Graph,
113    target: &str,
114    config: RenderConfig,
115    cache: Option<&dyn crate::cache::Cache>,
116) -> Result<Render, EngineError> {
117    let instances = instantiate::instantiate(graph, target)?;
118    let held = instances.instance_of(target)?;
119    let order = schedule::schedule_from(&instances, std::slice::from_ref(&held))?;
120    let tys = typing::infer_all(&instances, &order)?;
121    let root = tys
122        .id(&held)
123        .ok_or_else(|| EngineError::UnknownNode(held.clone()))?;
124    let schedule = schedule::plan(&tys, &order, root, &config.asks);
125
126    let bindings = tys
127        .paths()
128        .filter_map(|(path, id)| Some((id, resolved(&instances, path)?)))
129        .collect();
130    let mut held = Render {
131        root,
132        tys,
133        buffers: BTreeMap::new(),
134        frames: BTreeMap::new(),
135        symbolic: BTreeMap::new(),
136        labels: BTreeMap::new(),
137        traces: Vec::new(),
138        config,
139        schedule,
140        bindings,
141        cache_stats: None,
142    };
143    affordable(&held)?;
144    let recording = cache.map(Recording::over);
145    for id in held.schedule.materialize.clone() {
146        materialize(
147            &mut held,
148            id,
149            recording.as_ref().map(|r| r as &dyn crate::cache::Cache),
150        )?;
151    }
152    held.cache_stats = recording.map(Recording::finish);
153    compose_read(&mut held);
154    stamp(&mut held);
155    Ok(held)
156}
157
158/// A reading that materializes nothing is never refused for cost: asking what a render
159/// would cost is not paying for it. Anything that runs is counted first, and a count past
160/// the budget refuses before a sample is computed.
161fn affordable(held: &Render) -> Result<(), EngineError> {
162    if held.schedule.materialize.is_empty() {
163        return Ok(());
164    }
165    let total = crate::flops::total(held);
166    if total <= held.config.flop_budget {
167        return Ok(());
168    }
169    let counted = crate::flops::tree(held);
170    let over = crate::flops::dominating(&counted).expect("a counted tree holds its root");
171    Err(EngineError::refused(Diagnostic {
172        code: "collapse.over_budget".to_string(),
173        message: format!(
174            "this render counts {} operations, over the budget of {}; `{}` dominates it at \
175             {} by {}",
176            counted.total, counted.budget, over.node, over.subtree, over.route
177        ),
178        location: Located::at(held.tys.name(held.root), None),
179        help: format!("pass --flop-budget {} to render it anyway", counted.total),
180    }))
181}
182
183/// FORMAT 9.3: the render's own label says what it cost and what it was allowed.
184fn stamp(held: &mut Render) {
185    let root = held.root;
186    let Some(label) = held.labels.remove(&root) else {
187        return;
188    };
189    let counted = crate::flops::total(held);
190    held.labels
191        .insert(root, label.costing(counted, held.config.flop_budget));
192}
193
194/// One render, every reading: a closed form a reading asks for is composed once here, and each
195/// `--as` answers off that one form rather than walking the graph again. A form that
196/// refuses is left for the reading itself to raise, in its own words.
197fn compose_read(held: &mut Render) {
198    for id in held.schedule.compose.clone() {
199        if held.symbolic.contains_key(&id) {
200            continue;
201        }
202        if let Ok(sum) = refs::spectral_sum_of(&held.tys, id, held.tys.var(id)) {
203            held.symbolic.insert(id, sum);
204        }
205    }
206}
207
208fn materialize(
209    held: &mut Render,
210    id: NodeId,
211    cache: Option<&dyn crate::cache::Cache>,
212) -> Result<(), EngineError> {
213    match held.tys.ty(id).held {
214        Held::Frames => frames_of(held, id, cache),
215        Held::Sampled => sampled::run(held, id, cache),
216        _ => collapse_closed_form(held, id, cache),
217    }
218}
219
220/// The one place a closed form becomes samples: `sample(...)` and the render root, and nothing else.
221fn collapse_closed_form(
222    held: &mut Render,
223    id: NodeId,
224    cache: Option<&dyn crate::cache::Cache>,
225) -> Result<(), EngineError> {
226    let var = held.tys.var(id);
227    let written = match refs::resolve(&held.tys, id, 0, held.tys.ty(id).held) {
228        Ok(refs::Read::Substitute(form)) => Some(*form),
229        _ => None,
230    };
231    let symbolic = written.as_ref().map(|t| symbolic_key(hash_closed_form(t)));
232    let sum = remembered(held, id, symbolic, cache)
233        .map(Ok)
234        .unwrap_or_else(|| {
235            let began = Cost::begun();
236            let found = refs::spectral_sum_of(&held.tys, id, var);
237            if let (Ok(sum), Some(key), Some(cache)) = (&found, symbolic, cache) {
238                let payload = Payload::Symbolic(Box::new(sum.clone()));
239                if cache.worth_storing(began.elapsed(), payload.bytes(), PayloadKind::Symbolic) {
240                    cache.store(key, &payload, &[], None);
241                }
242            }
243            found
244        });
245    let identity = match refs::closed_form_identity(&sum, written.as_ref()) {
246        Ok(identity) => identity,
247        Err(_) if var == sva_formula::Var::T => refs::identity(&held.tys, id)?,
248        Err(e) => return Err(e),
249    };
250    let score = held.alias_score(id);
251    let key = crate::cache::buffer_key(
252        identity,
253        held.config.rate,
254        held.config.horizon.start_secs,
255        held.tys.ty(id).width as usize,
256        score,
257    );
258    if let Ok(sum) = &sum {
259        held.symbolic.insert(id, sum.clone());
260    }
261    // FORMAT 9.3: the label belongs to the value, so an entry without one is no value.
262    if let Some((hit, label)) = warm(held, id, key, cache) {
263        held.buffers.insert(id, hit);
264        held.labels.insert(id, label);
265        return Ok(());
266    }
267    let began = Cost::begun();
268    let (buffer, label) = match (&sum, &written) {
269        (Err(_), None) => pointwise::point_sample(held, id, score)?,
270        _ => sampled_form(held, &sum, written.as_ref(), score).map_err(|e| {
271            EngineError::refused(Diagnostic {
272                code: e.code().to_string(),
273                message: e.to_string(),
274                location: Located::at(held.tys.name(id), None),
275                help: e.help().to_string(),
276            })
277        })?,
278    };
279    store(key, &buffer, &label, began.elapsed(), cache);
280    held.buffers.insert(id, buffer);
281    held.labels.insert(id, label);
282    Ok(())
283}
284
285/// A closed form with a spectral sum takes the six rows over it; one without takes the point-sampled
286/// row over the written closed form itself, which is what FORMAT 9.1's no-dual row is.
287fn sampled_form(
288    held: &Render,
289    sum: &Result<SpectralSum, EngineError>,
290    written: Option<&sva_formula::ClosedForm>,
291    score: AliasScore,
292) -> Result<(Buffer, Label), sva_samples::CollapseError> {
293    let (rate, horizon, profile) = (held.config.rate, held.config.horizon, &held.config.profile);
294    match (sum, written) {
295        (Ok(sum), written) => {
296            sva_samples::of_spectral_sum_or_point(sum, written, rate, horizon, profile, score)
297        }
298        (Err(_), Some(form)) => sva_samples::render(form, rate, horizon, profile, score),
299        (Err(_), None) => unreachable!("a closed form with neither view is point-sampled above"),
300    }
301}
302
303/// A spectral sum is rate-free, so one entry answers every rate the same closed form is read at.
304fn remembered(
305    held: &Render,
306    id: NodeId,
307    key: Option<sva_formula::Hash>,
308    cache: Option<&dyn crate::cache::Cache>,
309) -> Option<SpectralSum> {
310    let entry = cache?.load(key?, held.tys.name(id), Expected::Symbolic)?;
311    entry.payload.symbolic().cloned()
312}
313
314/// A warm entry is the same bytes the cold run would write, so a hit is returned as it
315/// stands rather than checked against a second collapse.
316fn warm(
317    held: &Render,
318    id: NodeId,
319    key: sva_formula::Hash,
320    cache: Option<&dyn crate::cache::Cache>,
321) -> Option<(Buffer, Label)> {
322    let expected = Expected::Samples {
323        rate: held.config.rate,
324        width: held.tys.ty(id).width as usize,
325        samples: held.config.horizon.len(held.config.rate).ok()?,
326    };
327    let entry = cache?.load(key, held.tys.name(id), expected)?;
328    Some((entry.payload.samples().cloned()?, entry.label?))
329}
330
331fn store(
332    key: sva_formula::Hash,
333    buffer: &Buffer,
334    label: &Label,
335    cost: std::time::Duration,
336    cache: Option<&dyn crate::cache::Cache>,
337) {
338    let Some(cache) = cache else { return };
339    let payload = Payload::Samples(Box::new(buffer.clone()));
340    if cache.worth_storing(cost, payload.bytes(), PayloadKind::Samples) {
341        cache.store(key, &payload, &[], Some(label));
342    }
343}
344
345fn frames_of(
346    held: &mut Render,
347    id: NodeId,
348    cache: Option<&dyn crate::cache::Cache>,
349) -> Result<(), EngineError> {
350    let Value::Cast(Cast::Stft { window, hop }, source) = *held.tys.value(id) else {
351        return Err(not_frames(held, id));
352    };
353    let buffer = held
354        .buffers
355        .get(&source)
356        .ok_or_else(|| not_frames(held, id))?;
357    let key = frames_key(
358        crate::cache::buffer_key(
359            refs::identity(&held.tys, source)?,
360            buffer.rate,
361            buffer.origin_secs,
362            buffer.width,
363            AliasScore::NotAsked,
364        ),
365        window,
366        hop,
367    );
368    if let Some(entry) = cache.and_then(|c| c.load(key, held.tys.name(id), Expected::Frames))
369        && let Payload::Frames(frames) = entry.payload
370    {
371        held.frames.insert(id, *frames);
372        return Ok(());
373    }
374    let began = Cost::begun();
375    let frames = stft::forward(buffer, window, hop).map_err(|e| sampled::refused(held, id, &e))?;
376    if let Some(cache) = cache {
377        let payload = Payload::Frames(Box::new(frames.clone()));
378        if cache.worth_storing(began.elapsed(), payload.bytes(), PayloadKind::Frames) {
379            cache.store(key, &payload, &[], None);
380        }
381    }
382    held.frames.insert(id, frames);
383    Ok(())
384}
385
386fn not_frames(held: &Render, id: NodeId) -> EngineError {
387    EngineError::refused(Diagnostic {
388        code: "cast.stft_needs_samples".to_string(),
389        message: format!("`{}` holds no frames to read", held.tys.name(id)),
390        location: Located::at(held.tys.name(id), None),
391        help: "write stft(sample(x), window=, hop=)".to_string(),
392    })
393}
394
395/// One instance's resolved parameters, as the composer wrote them at the call site.
396fn resolved(instances: &instantiate::Instances, path: &str) -> Option<Vec<Binding>> {
397    Some(
398        instances
399            .bindings(path)?
400            .into_iter()
401            .map(|(name, expr, cx)| Binding {
402                name: name.to_string(),
403                source: instances.render(expr, cx),
404            })
405            .collect(),
406    )
407}