Skip to main content

rill_lang/
schedule.rs

1//! Compile-time partitioning of the linear IR into a hybrid execution schedule.
2//!
3//! Feedforward instructions become whole-buffer [`Step::Block`] ops; recurrences
4//! (feedback loops, and the read/write of each state slot or delay line) become
5//! per-sample [`Step::Sample`] regions. See the block-processing design doc.
6
7use crate::ir::{Instr, Ir};
8
9/// One scheduled unit of work, in execution order.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum Step {
12    /// A single combinational instruction, executed over the whole buffer.
13    Block(usize),
14    /// An opaque whole-buffer built-in (1→1).
15    ForeignBlock(usize),
16    /// A recurrent region, executed per sample. Instruction indices are in
17    /// original IR order (which preserves intra-sample data + read-before-write
18    /// ordering established by lowering).
19    Sample(Vec<usize>),
20}
21
22/// The full execution plan for an [`Ir`].
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Schedule {
25    /// Steps in execution order (dependencies first).
26    pub steps: Vec<Step>,
27}
28
29/// Which register each instruction produces (`None` for sinks).
30fn instr_dst(instr: &Instr) -> Option<usize> {
31    match *instr {
32        Instr::Const { dst, .. }
33        | Instr::LoadInput { dst, .. }
34        | Instr::ReadState { dst, .. }
35        | Instr::ReadDelay { dst, .. }
36        | Instr::Un { dst, .. }
37        | Instr::Bin { dst, .. }
38        | Instr::Move { dst, .. }
39        | Instr::CallSample { dst, .. }
40        | Instr::CallBlock { dst, .. }
41        | Instr::ReadParam { dst, .. } => Some(dst),
42        Instr::WriteState { .. } | Instr::WriteDelay { .. } => None,
43    }
44}
45
46/// The registers an instruction consumes.
47fn instr_srcs(instr: &Instr) -> Vec<usize> {
48    match *instr {
49        Instr::Un { src, .. } | Instr::Move { src, .. } => vec![src],
50        Instr::Bin { a, b, .. } => vec![a, b],
51        Instr::WriteState { src, .. } | Instr::WriteDelay { src, .. } => vec![src],
52        Instr::CallSample { ref srcs, .. } => srcs.clone(),
53        Instr::CallBlock { src, .. } => vec![src],
54        _ => Vec::new(),
55    }
56}
57
58/// True for instructions that touch persistent state and therefore must run in
59/// a sample region (never as a standalone block op).
60fn is_stateful(instr: &Instr) -> bool {
61    matches!(
62        instr,
63        Instr::ReadState { .. }
64            | Instr::WriteState { .. }
65            | Instr::ReadDelay { .. }
66            | Instr::WriteDelay { .. }
67            | Instr::CallSample { .. }
68    )
69}
70
71/// Build the hybrid schedule for an IR.
72pub fn build_schedule(ir: &Ir) -> Schedule {
73    let n = ir.instrs.len();
74
75    // producer[reg] = instr index whose dst == reg (SSA: unique).
76    let mut producer: Vec<Option<usize>> = vec![None; ir.num_regs];
77    for (i, instr) in ir.instrs.iter().enumerate() {
78        if let Some(d) = instr_dst(instr) {
79            producer[d] = Some(i);
80        }
81    }
82
83    // Adjacency: consumer -> producer (dependency edges).
84    let mut adj: Vec<Vec<usize>> = vec![Vec::new(); n];
85    for (i, instr) in ir.instrs.iter().enumerate() {
86        for s in instr_srcs(instr) {
87            if let Some(p) = producer[s] {
88                adj[i].push(p);
89            }
90        }
91    }
92
93    // Recurrence edges (bidirectional) for each state slot and delay line, so
94    // the read/write ends share an SCC and feedback loops close.
95    let mut read_state: Vec<Option<usize>> = vec![None; ir.state.state_slots];
96    let mut write_state: Vec<Option<usize>> = vec![None; ir.state.state_slots];
97    let mut read_delay: Vec<Option<usize>> = vec![None; ir.state.delay_lens.len()];
98    let mut write_delay: Vec<Option<usize>> = vec![None; ir.state.delay_lens.len()];
99    for (i, instr) in ir.instrs.iter().enumerate() {
100        match *instr {
101            Instr::ReadState { slot, .. } => read_state[slot] = Some(i),
102            Instr::WriteState { slot, .. } => write_state[slot] = Some(i),
103            Instr::ReadDelay { line, .. } => read_delay[line] = Some(i),
104            Instr::WriteDelay { line, .. } => write_delay[line] = Some(i),
105            _ => {}
106        }
107    }
108    let add_pair = |a: Option<usize>, b: Option<usize>, adj: &mut Vec<Vec<usize>>| {
109        if let (Some(a), Some(b)) = (a, b) {
110            adj[a].push(b);
111            adj[b].push(a);
112        }
113    };
114    for s in 0..ir.state.state_slots {
115        add_pair(read_state[s], write_state[s], &mut adj);
116    }
117    for l in 0..ir.state.delay_lens.len() {
118        add_pair(read_delay[l], write_delay[l], &mut adj);
119    }
120
121    // Tarjan SCC. Emission order is reverse-finish = execution order
122    // (dependencies first) because edges point consumer -> producer.
123    let sccs = tarjan_scc(n, &adj);
124
125    // Classify each SCC into a Step.
126    let mut steps = Vec::with_capacity(sccs.len());
127    for scc in sccs {
128        if scc.len() == 1 && matches!(ir.instrs[scc[0]], Instr::CallBlock { .. }) {
129            steps.push(Step::ForeignBlock(scc[0]));
130        } else {
131            let recurrent = scc.len() > 1 || scc.iter().any(|&i| is_stateful(&ir.instrs[i]));
132            if recurrent {
133                let mut instrs = scc;
134                instrs.sort_unstable();
135                steps.push(Step::Sample(instrs));
136            } else {
137                steps.push(Step::Block(scc[0]));
138            }
139        }
140    }
141    Schedule { steps }
142}
143
144/// Iterative Tarjan strongly-connected-components.
145///
146/// Returns SCCs in reverse topological order of the condensation — with our
147/// consumer→producer edges, that is exactly execution order (a node's
148/// dependencies appear before it).
149fn tarjan_scc(n: usize, adj: &[Vec<usize>]) -> Vec<Vec<usize>> {
150    const UNVISITED: i64 = -1;
151    let mut index = vec![UNVISITED; n];
152    let mut low = vec![0i64; n];
153    let mut on_stack = vec![false; n];
154    let mut stack: Vec<usize> = Vec::new();
155    let mut next_index: i64 = 0;
156    let mut out: Vec<Vec<usize>> = Vec::new();
157
158    // Explicit DFS stack of (node, next-neighbor-index).
159    for root in 0..n {
160        if index[root] != UNVISITED {
161            continue;
162        }
163        let mut call: Vec<(usize, usize)> = vec![(root, 0)];
164        while let Some(&mut (v, ref mut ni)) = call.last_mut() {
165            if *ni == 0 {
166                index[v] = next_index;
167                low[v] = next_index;
168                next_index += 1;
169                stack.push(v);
170                on_stack[v] = true;
171            }
172            if *ni < adj[v].len() {
173                let w = adj[v][*ni];
174                *ni += 1;
175                if index[w] == UNVISITED {
176                    call.push((w, 0));
177                } else if on_stack[w] {
178                    low[v] = low[v].min(index[w]);
179                }
180            } else {
181                if low[v] == index[v] {
182                    let mut comp = Vec::new();
183                    loop {
184                        let w = stack.pop().unwrap();
185                        on_stack[w] = false;
186                        comp.push(w);
187                        if w == v {
188                            break;
189                        }
190                    }
191                    out.push(comp);
192                }
193                let finished = v;
194                call.pop();
195                if let Some(&mut (parent, _)) = call.last_mut() {
196                    low[parent] = low[parent].min(low[finished]);
197                }
198            }
199        }
200    }
201    out
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::lexer::tokenize;
208    use crate::lower::{lower, lower_with};
209    use crate::parser::parse;
210    use crate::types::infer::{infer_program, infer_program_with};
211
212    struct TestSigs;
213    impl crate::builtin::SignatureSource for TestSigs {
214        fn builtin_sig(&self, name: &str) -> Option<&crate::builtin::BuiltinSig> {
215            use crate::builtin::{BuiltinKind, BuiltinSig};
216            match name {
217                "lowpass" => Some(Box::leak(Box::new(BuiltinSig {
218                    name: "lowpass",
219                    signal_ins: 1,
220                    signal_outs: 1,
221                    num_params: 2,
222                    kind: BuiltinKind::Block,
223                }))),
224                "onepole" => Some(Box::leak(Box::new(BuiltinSig {
225                    name: "onepole",
226                    signal_ins: 1,
227                    signal_outs: 1,
228                    num_params: 2,
229                    kind: BuiltinKind::Sample,
230                }))),
231                _ => None,
232            }
233        }
234    }
235
236    fn schedule_of(src: &str) -> Schedule {
237        let p = parse(&tokenize(src).unwrap()).unwrap();
238        let tp = infer_program(&p).unwrap();
239        let ir = lower(&tp).unwrap();
240        build_schedule(&ir)
241    }
242
243    fn schedule_of_with(src: &str) -> (Ir, Schedule) {
244        let p = parse(&tokenize(src).unwrap()).unwrap();
245        let tp = infer_program_with(&p, &TestSigs).unwrap();
246        let ir = lower_with(&tp, &TestSigs, 44_100.0).unwrap();
247        let sched = build_schedule(&ir);
248        (ir, sched)
249    }
250
251    fn n_sample(s: &Schedule) -> usize {
252        s.steps
253            .iter()
254            .filter(|st| matches!(st, Step::Sample(_)))
255            .count()
256    }
257    fn n_block(s: &Schedule) -> usize {
258        s.steps
259            .iter()
260            .filter(|st| matches!(st, Step::Block(_)))
261            .count()
262    }
263
264    #[test]
265    fn combinational_program_is_all_block() {
266        let s = schedule_of("process = _ * 0.5;");
267        assert_eq!(n_sample(&s), 0);
268        assert!(n_block(&s) >= 1);
269    }
270
271    #[test]
272    fn feedback_program_has_one_sample_region() {
273        let s = schedule_of("process = + ~ _;");
274        assert_eq!(n_sample(&s), 1);
275    }
276
277    #[test]
278    fn const_feeding_feedback_stays_block() {
279        // `+ ~ (_ * 0.5)`: the 0.5 constant is combinational (Block); the
280        // ReadState/Add/Mul/WriteState cycle is one Sample region.
281        let s = schedule_of("process = + ~ (_ * 0.5);");
282        assert_eq!(n_sample(&s), 1);
283        assert!(n_block(&s) >= 1); // at least the Const 0.5 and the LoadInput
284    }
285
286    #[test]
287    fn feedforward_delay_is_isolated_sample_region() {
288        // `_ @ 3`: delay read/write form a sample region; no feedback.
289        let s = schedule_of("process = _ @ 3;");
290        assert_eq!(n_sample(&s), 1);
291    }
292
293    #[test]
294    fn feedback_through_delay_is_one_region() {
295        let s = schedule_of("process = + ~ (_ @ 2);");
296        assert_eq!(n_sample(&s), 1);
297    }
298
299    #[test]
300    fn gain_then_integrator_splits_block_and_sample() {
301        let s = schedule_of("process = (_ * 0.5) : (+ ~ _);");
302        assert_eq!(n_sample(&s), 1);
303        assert!(n_block(&s) >= 1);
304    }
305
306    #[test]
307    fn steps_are_in_dependency_order() {
308        // Every Block step's producer appears before any step that consumes it:
309        // here we only assert the schedule is non-empty and ends producing output.
310        let s = schedule_of("process = abs(_) : _ * 2.0;");
311        assert!(!s.steps.is_empty());
312        assert_eq!(n_sample(&s), 0);
313    }
314
315    #[test]
316    fn sample_builtin_schedules_as_sample_region() {
317        let (_, s) = schedule_of_with("process = _ : onepole(200.0, 0.5);");
318        assert_eq!(n_sample(&s), 1);
319    }
320
321    #[test]
322    fn block_builtin_schedules_as_foreign_block() {
323        let (_, s) = schedule_of_with("process = _ : lowpass(1000.0, 0.7);");
324        assert!(s.steps.iter().any(|st| matches!(st, Step::ForeignBlock(_))));
325        assert!(n_block(&s) >= 1); // LoadInput
326        assert_eq!(n_sample(&s), 0);
327    }
328
329    #[test]
330    fn block_builtin_in_feedback_lands_in_sample_region() {
331        let (ir, s) = schedule_of_with("process = + ~ lowpass(500.0, 0.7);");
332        // CallBlock inside feedback SCC → Sample region (illegal; caught by
333        // validate_block_builtins at compile time).
334        assert!(s.steps.iter().any(|st| {
335            matches!(st, Step::Sample(ref instrs)
336                if instrs.iter().any(|&i| matches!(ir.instrs[i], Instr::CallBlock { .. })))
337        }));
338    }
339}