Skip to main content

logicaffeine_compile/codegen_sva/
preflight.rs

1//! Whether a persona build fits the card's partial-reconfiguration region and can close its clock, decided before
2//! Quartus runs, from what the build ledger has measured.
3//!
4//! A design is read as the timing model reads it ([`Design::of`] a [`TimingGraph`]): its mapped area, its deepest
5//! register-to-register path and its loop bound. The ALUTs it will take are the calibration's area fit of that area
6//! ([`AreaFit`]); the ALMs, the larger of those ALUTs and its registers each at the ratio Quartus's own syntheses in the
7//! ledger packed theirs ([`packing`]); the DSP blocks, the model's halves paired, an upper bound, since Quartus maps some small products
8//! to logic. The capacity is the region the ledger's last persona was placed in ([`capacity`]). A design over the
9//! region's ALMs, DSP blocks or registers is refused; one past the fullest persona that has fit, and still inside the
10//! region, is tight. The deepest path must fit the period, and so must the loop bound, which no retiming can cut; a
11//! loop through no register is refused outright. A design whose parts step at different rates (an engine core
12//! stepping once in many clocks under a multicycle constraint beside a side stepping every clock) is held to its area
13//! whole and timed part by part ([`preflight_domains`]), each part's paths given its cycles of the period. The fitter's time is predicted from the personas' own, a power law
14//! fitted to their ALMs. A verdict that has no measurement to rest on (no area fit, no region) is a refusal too.
15
16use std::fmt;
17
18use logicaffeine_proof::cycle_ratio::{Bound, GraphError};
19
20use super::quartus::{Ledger, RegionUsage, Row};
21use super::timing_graph::{Area, AreaFit, Calibration, TimingError, TimingGraph};
22
23/// The loop bound of a design: no loop, a loop through no register, or the most delay a register a loop holds.
24#[derive(Clone, Copy, Debug, PartialEq)]
25pub enum LoopBound {
26    Acyclic,
27    Combinational,
28    Ratio(f64),
29}
30
31/// A design as the timing model reads it: its area, its deepest path and its loop bound, in the model's unit.
32#[derive(Clone, Debug, PartialEq)]
33pub struct Design {
34    pub area: Area,
35    pub depth: i64,
36    pub loop_bound: LoopBound,
37}
38
39/// Why a timing graph is no design: one the model could not time, or not a graph.
40#[derive(Debug)]
41pub enum DesignError {
42    Timing(TimingError),
43    Graph(GraphError),
44}
45
46impl fmt::Display for DesignError {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        match self {
49            DesignError::Timing(e) => write!(f, "{e}"),
50            DesignError::Graph(e) => write!(f, "{e:?}"),
51        }
52    }
53}
54
55impl Design {
56    /// The design `graph` is. A loop through no register has no deepest path; its design's depth is 0, and the loop
57    /// is its verdict.
58    pub fn of(graph: &TimingGraph) -> Result<Design, DesignError> {
59        let loop_bound = match graph.loop_bound().map_err(DesignError::Graph)? {
60            Bound::Acyclic => LoopBound::Acyclic,
61            Bound::CombinationalLoop(_) => LoopBound::Combinational,
62            Bound::Ratio { delay, registers, .. } => LoopBound::Ratio(delay as f64 / registers as f64),
63        };
64        let depth = match graph.depth() {
65            Ok(d) => d.depth,
66            Err(TimingError::CombinationalLoop(_)) if loop_bound == LoopBound::Combinational => 0,
67            Err(e) => return Err(DesignError::Timing(e)),
68        };
69        Ok(Design { area: graph.area_under(""), depth, loop_bound })
70    }
71
72    /// The design with `loop_bound` for its loop bound.
73    pub fn with_loop(self, loop_bound: LoopBound) -> Design {
74        Design { loop_bound, ..self }
75    }
76}
77
78/// The clock a design must close: its period, and the nanoseconds one unit of the model's delay takes on the card.
79#[derive(Clone, Copy, Debug, PartialEq)]
80pub struct Target {
81    pub period_ns: f64,
82    pub ns_per_unit: f64,
83}
84
85/// What a persona may use: the region the ledger's last persona was placed in, and that compile's name.
86#[derive(Clone, Debug, PartialEq, Eq)]
87pub struct Capacity {
88    pub row: String,
89    pub alms: u64,
90    pub registers: u64,
91    pub m20k: u64,
92    pub dsp: u64,
93}
94
95/// The PR slot a persona is placed in.
96const PR_SLOT: &str = "afu_top|port_gasket|pr_slot|afu_main";
97
98/// The PR region a persona's fit was placed in: a compile of the persona revision (`iofs_pr_afu`) records the PR slot's
99/// region. A flat image's fit (the FIM's own revision) is placed over the whole device, and the regions it records are
100/// its floorplan's Logic Lock regions: no persona's.
101fn persona_region(r: &Row) -> Option<&RegionUsage> {
102    if r.build.flow.revision.as_deref() != Some("iofs_pr_afu") {
103        return None;
104    }
105    r.build.regions.iter().find(|g| g.name == PR_SLOT)
106}
107
108/// The PR region of the last persona compile the ledger records with one.
109pub fn capacity(ledger: &Ledger) -> Option<Capacity> {
110    ledger.rows.iter().rev().find_map(|r| {
111        let region = persona_region(r)?;
112        Some(Capacity {
113            row: r.name.clone(),
114            alms: region.alms,
115            registers: region.registers.map_or(0, |x| x.total),
116            m20k: region.m20k.map_or(0, |x| x.total),
117            dsp: region.dsp.map_or(0, |x| x.total),
118        })
119    })
120}
121
122/// How Quartus packs a synthesis into ALMs: the ALMs an ALUT takes, over the syntheses their ALUTs set, and the ALMs a
123/// register takes, over the syntheses their registers set (none when the ledger records no such synthesis).
124#[derive(Clone, Copy, Debug, PartialEq)]
125pub struct Packing {
126    pub per_alut: f64,
127    pub per_register: Option<f64>,
128}
129
130impl Packing {
131    /// The ALMs a design of `aluts` ALUTs and `registers` registers takes: the larger of the two packings.
132    pub fn alms(&self, aluts: f64, registers: f64) -> f64 {
133        let by_aluts = aluts * self.per_alut;
134        self.per_register.map_or(by_aluts, |r| by_aluts.max(registers * r))
135    }
136}
137
138/// The packing every synthesis the ledger records from one Verilog file shows: each synthesis's estimated ALMs over its
139/// top's ALUTs and registers, each synthesis set by the term the larger packing gives it (first by which it holds more
140/// of), each packing the geometric mean over the syntheses it sets, settled when no synthesis changes side.
141pub fn packing(ledger: &Ledger) -> Option<Packing> {
142    let rows: Vec<(f64, f64, f64)> = ledger
143        .rows
144        .iter()
145        .filter(|r| r.synthesis_sources().is_some())
146        .filter_map(|r| {
147            let alms = r.build.synthesis.first()?.estimate.alms?;
148            let top = r.build.top.iter().find(|n| n.path == "|")?;
149            (alms > 0 && top.aluts > 0).then(|| (alms as f64, top.aluts as f64, top.registers as f64))
150        })
151        .collect();
152    let geomean = |xs: Vec<f64>| (!xs.is_empty()).then(|| (xs.iter().map(|x| x.ln()).sum::<f64>() / xs.len() as f64).exp());
153    let fit = |by_registers: &[bool]| -> Option<Packing> {
154        let lut = rows.iter().zip(by_registers).filter(|(_, r)| !**r).map(|(&(a, l, _), _)| a / l).collect();
155        let reg = rows.iter().zip(by_registers).filter(|(_, r)| **r).map(|(&(a, _, g), _)| a / g).collect();
156        Some(Packing { per_alut: geomean(lut)?, per_register: geomean(reg) })
157    };
158    let mut sides: Vec<bool> = rows.iter().map(|&(_, l, g)| g > l).collect();
159    let mut p = fit(&sides).or_else(|| fit(&vec![false; rows.len()]))?;
160    for _ in 0..=rows.len() {
161        let next: Vec<bool> = rows.iter().map(|&(_, l, g)| p.per_register.is_some_and(|r| g * r > l * p.per_alut)).collect();
162        if next == sides {
163            break;
164        }
165        p = fit(&next).unwrap_or(p);
166        sides = next;
167    }
168    Some(p)
169}
170
171/// The ALMs Quartus packs an ALUT into ([`packing`]'s, over the syntheses their ALUTs set).
172pub fn alms_per_alut(ledger: &Ledger) -> Option<f64> {
173    packing(ledger).map(|p| p.per_alut)
174}
175
176/// The fullest persona that fit: the passed compile whose region needed the most ALMs, and those ALMs.
177pub fn fullest(ledger: &Ledger) -> Option<(String, f64)> {
178    ledger
179        .rows
180        .iter()
181        .filter(|r| r.verdict.passed)
182        .filter_map(|r| persona_region(r).map(|g| (r.name.clone(), g.alms_needed)))
183        .max_by(|a, b| a.1.total_cmp(&b.1))
184}
185
186/// The fitter's time for a persona of `alms` ALMs: `seconds = a × alms^b`, least squares in logs over every persona
187/// fit the ledger records with its region and fitter time; none without two of different sizes.
188pub fn fitter_seconds(ledger: &Ledger, alms: f64) -> Option<f64> {
189    let points: Vec<(f64, f64)> = ledger
190        .rows
191        .iter()
192        .filter_map(|r| {
193            let needed = persona_region(r)?.alms_needed;
194            let seconds = r.build.flow.phase("Fitter")?.seconds;
195            (needed > 0.0 && seconds > 0).then(|| (needed.ln(), (seconds as f64).ln()))
196        })
197        .collect();
198    let n = points.len() as f64;
199    let (mx, my) = (points.iter().map(|p| p.0).sum::<f64>() / n, points.iter().map(|p| p.1).sum::<f64>() / n);
200    let sxx: f64 = points.iter().map(|p| (p.0 - mx).powi(2)).sum();
201    if points.len() < 2 || sxx < 1e-12 {
202        return None;
203    }
204    let b = points.iter().map(|p| (p.0 - mx) * (p.1 - my)).sum::<f64>() / sxx;
205    Some((my + b * (alms.ln() - mx)).exp())
206}
207
208/// A part of a design timed on its own: its name, its design, and the periods its paths have.
209#[derive(Clone, Copy, Debug)]
210pub struct Timed<'a> {
211    pub name: &'a str,
212    pub design: &'a Design,
213    pub cycles: u32,
214}
215
216/// What a design is predicted to take and how fast it can run.
217#[derive(Clone, Debug, Default, PartialEq)]
218pub struct Predicted {
219    pub aluts: f64,
220    pub alms: f64,
221    pub dsp: u64,
222    pub registers: u64,
223    /// Each timed part's deepest path and loop bound in nanoseconds (none for a part with no loop through a
224    /// register), and the periods its paths have.
225    pub timing: Vec<PartTiming>,
226    pub fitter_seconds: Option<f64>,
227}
228
229/// One timed part's figures.
230#[derive(Clone, Debug, Default, PartialEq)]
231pub struct PartTiming {
232    pub name: String,
233    pub cycles: u32,
234    pub depth_ns: f64,
235    pub loop_ns: Option<f64>,
236}
237
238/// Why a design is not built.
239#[derive(Clone, Debug, PartialEq)]
240pub enum Refusal {
241    /// More than the region holds.
242    Over { what: &'static str, predicted: f64, capacity: u64 },
243    /// More than the device holds.
244    Device { what: &'static str, needs: u64, holds: u64 },
245    /// A part's deepest path past the time its paths have.
246    Depth { part: String, ns: f64, budget: f64 },
247    /// A part's loop whose bound is past the time its paths have, or a loop through no register.
248    Loop { part: String, ns: Option<f64>, budget: f64 },
249    /// Nothing measured to predict from.
250    Unmeasured { what: &'static str },
251}
252
253impl Refusal {
254    /// What the refusal is of.
255    pub fn what(&self) -> &'static str {
256        match self {
257            Refusal::Over { what, .. } | Refusal::Device { what, .. } | Refusal::Unmeasured { what } => what,
258            Refusal::Depth { .. } => "the deepest path",
259            Refusal::Loop { .. } => "a loop",
260        }
261    }
262}
263
264/// `"part: "`, or nothing for a design timed whole.
265fn of(part: &str) -> String {
266    if part.is_empty() {
267        String::new()
268    } else {
269        format!("{part}: ")
270    }
271}
272
273impl fmt::Display for Refusal {
274    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
275        match self {
276            Refusal::Over { what, predicted, capacity } => write!(f, "{predicted:.0} {what}, where the region holds {capacity}"),
277            Refusal::Device { what, needs, holds } => write!(f, "{needs} {what}, where the device holds {holds}"),
278            Refusal::Depth { part, ns, budget } => {
279                write!(f, "{}the deepest path takes {ns:.2} ns, past the {budget} ns its paths have: pipeline it", of(part))
280            }
281            Refusal::Loop { part, ns: Some(ns), budget } => {
282                write!(f, "{}a loop takes {ns:.2} ns a register, past the {budget} ns its paths have: no retiming reaches it", of(part))
283            }
284            Refusal::Loop { part, ns: None, .. } => write!(f, "{}a loop through no register", of(part)),
285            Refusal::Unmeasured { what } => write!(f, "no {what} to predict from"),
286        }
287    }
288}
289
290/// A design's preflight: what it is predicted to take, the region it is held to, every refusal, and every way it is
291/// tight without being refused.
292#[derive(Clone, Debug, PartialEq)]
293pub struct Preflight {
294    pub predicted: Predicted,
295    pub capacity: Option<Capacity>,
296    pub refusals: Vec<Refusal>,
297    pub tight: Vec<String>,
298}
299
300/// The preflight of `design` against what `ledger` and `calibration` measured, at `target`, timed whole a period a
301/// path.
302pub fn preflight(design: &Design, ledger: &Ledger, calibration: &Calibration, target: &Target) -> Preflight {
303    preflight_domains(&design.area, &[Timed { name: "", design, cycles: 1 }], ledger, calibration, target)
304}
305
306/// The preflight of a design of `area`, each of its `parts` timed on its own at its cycles of `target`'s period.
307pub fn preflight_domains(area: &Area, parts: &[Timed], ledger: &Ledger, calibration: &Calibration, target: &Target) -> Preflight {
308    let mut refusals = Vec::new();
309    let mut tight = Vec::new();
310    let mut predicted = Predicted { dsp: area.half_dsps.div_ceil(2), registers: area.flops, ..Predicted::default() };
311    let fit: Option<&AreaFit> = calibration.area.as_ref();
312    match (fit, packing(ledger)) {
313        (Some(fit), Some(packing)) => {
314            predicted.aluts = fit.aluts(area);
315            predicted.alms = packing.alms(predicted.aluts, area.flops as f64);
316        }
317        _ => refusals.push(Refusal::Unmeasured { what: "the area fit" }),
318    }
319    let capacity = capacity(ledger);
320    match &capacity {
321        Some(c) => {
322            if predicted.alms > c.alms as f64 {
323                refusals.push(Refusal::Over { what: "ALMs", predicted: predicted.alms, capacity: c.alms });
324            } else if let Some((row, alms)) = fullest(ledger).filter(|(_, a)| predicted.alms > *a) {
325                tight.push(format!("{:.0} ALMs, past {alms:.0}, the fullest persona that fit ({row})", predicted.alms));
326            }
327            if predicted.dsp > c.dsp {
328                refusals.push(Refusal::Over { what: "DSP blocks", predicted: predicted.dsp as f64, capacity: c.dsp });
329            }
330            if predicted.registers > c.registers {
331                refusals.push(Refusal::Over { what: "registers", predicted: predicted.registers as f64, capacity: c.registers });
332            }
333            predicted.fitter_seconds = fitter_seconds(ledger, predicted.alms.max(1.0));
334        }
335        None => refusals.push(Refusal::Unmeasured { what: "the PR region" }),
336    }
337    for part in parts {
338        let budget = target.period_ns * part.cycles as f64;
339        let timing = PartTiming {
340            name: part.name.to_string(),
341            cycles: part.cycles,
342            depth_ns: part.design.depth as f64 * target.ns_per_unit,
343            loop_ns: match part.design.loop_bound {
344                LoopBound::Ratio(r) => Some(r * target.ns_per_unit),
345                _ => None,
346            },
347        };
348        if timing.depth_ns > budget {
349            refusals.push(Refusal::Depth { part: timing.name.clone(), ns: timing.depth_ns, budget });
350        }
351        match part.design.loop_bound {
352            LoopBound::Combinational => refusals.push(Refusal::Loop { part: timing.name.clone(), ns: None, budget }),
353            LoopBound::Ratio(_) if timing.loop_ns.is_some_and(|ns| ns > budget) => {
354                refusals.push(Refusal::Loop { part: timing.name.clone(), ns: timing.loop_ns, budget })
355            }
356            _ => {}
357        }
358        predicted.timing.push(timing);
359    }
360    Preflight { predicted, capacity, refusals, tight }
361}
362
363/// The device primitives `verilog` instantiates and does not define, each declared as the box it is: the card's
364/// floating-point DSP block (`fourteennm_fp_mac`, [`super::fp_oracle::fp_mac_blackbox`]), which only Quartus elaborates.
365pub fn device_library(verilog: &str) -> String {
366    const FP_MAC: &str = "fourteennm_fp_mac";
367    let (mut defined, mut used) = (false, false);
368    for line in verilog.lines() {
369        let words: Vec<&str> = line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')).filter(|w| !w.is_empty()).collect();
370        for (i, w) in words.iter().enumerate() {
371            if *w == FP_MAC {
372                if i > 0 && words[i - 1] == "module" {
373                    defined = true;
374                } else {
375                    used = true;
376                }
377            }
378        }
379    }
380    if used && !defined {
381        super::fp_oracle::fp_mac_blackbox()
382    } else {
383        String::new()
384    }
385}
386
387/// A design's or a device's ALMs, DSP blocks and M20K blocks.
388#[derive(Clone, Copy, Debug, PartialEq, Eq)]
389pub struct Holds {
390    pub alms: u64,
391    pub dsp: u64,
392    pub m20k: u64,
393}
394
395/// What the D5005's device holds: the 1SX280's ALMs, DSP blocks and M20Ks.
396pub const DEVICE: Holds = Holds { alms: 933_120, dsp: 5_760, m20k: 11_721 };
397
398/// What a synthesized flat image needs of the device, from its synthesis report: the ALMs and DSP blocks its partitions'
399/// estimates sum to, and the M20K blocks the fitter gives its memories (`floorplan::Ram::m20k`, every partition's), not
400/// its block memory bits over a block's 20,480: a stack of blocks a power of two deep, and a byte-enabled block's 32
401/// bits, take more blocks than the bits do. None when the report gives no partition's ALMs or DSP blocks.
402pub fn synthesized_holds(syn: &str) -> Option<Holds> {
403    let partitions = super::quartus::parse_synthesis(syn);
404    let sum = |of: fn(&super::quartus::Estimate) -> Option<u64>| -> Option<u64> {
405        let each: Vec<u64> = partitions.iter().filter_map(|p| of(&p.estimate)).collect();
406        (!each.is_empty()).then(|| each.iter().sum())
407    };
408    Some(Holds {
409        alms: sum(|e| e.alms)?,
410        dsp: sum(|e| e.dsp_blocks)?,
411        m20k: super::floorplan::synthesized_rams(syn).iter().map(super::floorplan::Ram::m20k).sum(),
412    })
413}
414
415/// Each resource `needs` takes past what `device` holds, named.
416pub fn over(needs: Holds, device: Holds) -> Vec<Refusal> {
417    [("ALMs", needs.alms, device.alms), ("DSP blocks", needs.dsp, device.dsp), ("M20K blocks", needs.m20k, device.m20k)]
418        .into_iter()
419        .filter(|&(_, n, d)| n > d)
420        .map(|(what, needs, holds)| Refusal::Device { what, needs, holds })
421        .collect()
422}
423
424#[cfg(test)]
425#[path = "../../../logicaffeine_tests/unit/logicaffeine_compile/codegen_sva/preflight/tests.rs"]
426mod tests;