Skip to main content

logicaffeine_compile/spatial/decode_engine/
twin.rs

1//! The decode engine's cycle twin: a step's program priced command by command on the engine's units.
2//!
3//! Each command asks its units for work ([`Demand`]): the stream engines for its weights from the card's memory and
4//! for the state and caches its rows' sequences read and write, each way of moving them at the share of the memory's
5//! peak it sustains ([`crate::spatial::serving::Streams`]); the array and the block-scale lanes for a block-format
6//! matrix; the vector units for the norms, the attention, the recurrence, a float matrix and the elementwise steps
7//! between them. A command takes as long as its slowest unit, the others overlapped with it. Weights stream ahead of
8//! the command that reads them into an on-chip window while earlier commands run ([`schedule`]): a command's own state
9//! and cache traffic is served first, and the memory time it leaves spare fetches the next commands' weights as far as
10//! the window holds.
11//!
12//! The weights, the traffic of each kind, the array's multiply-adds and the lanes' cycles are the pricer's
13//! ([`crate::spatial::serving::PassShape`]) term for term; the twin's vector work is the engine's own, every head of a
14//! per-head norm and every elementwise step the pricer leaves out.
15
16use std::collections::BTreeMap;
17
18use serde::Serialize;
19
20use crate::model::spec::{Mixer, ModelSpec};
21use crate::spatial::decode::{GgmlType, GgufHeader};
22use crate::spatial::serving::{array_passes, is_float, lane_cycles, Card, VALUE_BLOCK};
23use crate::spatial::serving::{AttentionMath, StateFlush};
24
25use super::model::{CacheFormat, Replay, StateFormat};
26use super::numerics::Words;
27use super::program::{Command, Program};
28
29/// f32 operations an element of each of the vector unit's functions takes, as [`super::numerics`] sequences them:
30/// `exp` its range reduction, its cubic and its scaling; `recip` and `rsqrt` a seed and two Newton steps;
31/// `sigmoid` an `exp`, an add and a `recip`; `silu` a `sigmoid` and a product; `softplus` an `exp` and an `ln1p`.
32pub const EXP_OPS: f64 = 10.0;
33pub const RECIP_OPS: f64 = 6.0;
34pub const RSQRT_OPS: f64 = 8.0;
35pub const SIGMOID_OPS: f64 = EXP_OPS + 1.0 + RECIP_OPS;
36pub const SILU_OPS: f64 = SIGMOID_OPS + 1.0;
37pub const SOFTPLUS_OPS: f64 = EXP_OPS + 12.0;
38
39/// One sequence a step advances: the rows it brings and the positions its caches hold before them.
40#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
41pub struct SequenceRows {
42    pub rows: usize,
43    pub position: usize,
44    /// Tokens its lazily flushed memories log before the step.
45    pub logged: usize,
46}
47
48/// The rows a step runs: every sequence it advances, and whether the rows past each sequence's first are drafts the
49/// step verifies, so every candidate prefix's state is kept.
50#[derive(Clone, Debug, PartialEq, Serialize)]
51pub struct Step {
52    pub sequences: Vec<SequenceRows>,
53    pub drafted: bool,
54}
55
56/// The work one command asks of each unit.
57#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize)]
58pub struct Demand {
59    /// Weight bytes streamed from the card's memory: the command's tensors, the embedding's rows.
60    pub weights: f64,
61    /// Bytes of the rows' sequences' recurrent states read and written back in place.
62    pub rewritten: f64,
63    /// Cache, ring, log and state bytes the rows' sequences read in long runs and append their rows after.
64    pub appended: f64,
65    /// Multiply-adds of the 4-bit array.
66    pub array: f64,
67    /// Block-scale lane cycles.
68    pub lanes: f64,
69    /// f32 operations of the vector units.
70    pub vector: f64,
71}
72
73impl std::ops::AddAssign for Demand {
74    fn add_assign(&mut self, o: Demand) {
75        self.weights += o.weights;
76        self.rewritten += o.rewritten;
77        self.appended += o.appended;
78        self.array += o.array;
79        self.lanes += o.lanes;
80        self.vector += o.vector;
81    }
82}
83
84impl Demand {
85    /// Seconds `card`'s memory takes to move the demand's state and cache traffic and `weights` bytes of its weights,
86    /// each at its own stream's share of the peak.
87    pub fn memory_s(&self, card: &Card, weights: f64) -> f64 {
88        let peak = card.memory.bandwidth.value;
89        weights / (peak * card.streams.read.value)
90            + self.rewritten / (peak * card.streams.rewrite.value)
91            + self.appended / (peak * card.streams.append.value)
92    }
93}
94
95/// Why the twin cannot price a program.
96#[derive(Debug, PartialEq)]
97pub enum TwinRefusal {
98    MissingTensor(String),
99    UnknownType { tensor: String, id: u32 },
100    /// A matrix in a format neither the array nor the vector units read.
101    Undecoded { tensor: String, ty: GgmlType },
102}
103
104/// The work of every command of `program` over `step` on the checkpoint `h` describes.
105pub fn demands(spec: &ModelSpec, h: &GgufHeader, program: &Program, step: &Step) -> Result<Vec<Demand>, TwinRefusal> {
106    let drafted = step.drafted;
107    let step = &step.sequences;
108    let tensors: BTreeMap<&str, &crate::spatial::decode::TensorRow> = h.tensors.iter().map(|t| (t.name.as_str(), t)).collect();
109    let tensor = |name: &str| -> Result<(GgmlType, f64, Vec<u64>), TwinRefusal> {
110        let t = tensors.get(name).ok_or_else(|| TwinRefusal::MissingTensor(name.into()))?;
111        let unknown = || TwinRefusal::UnknownType { tensor: name.into(), id: t.type_id };
112        let ty = GgmlType::from_id(t.type_id).ok_or_else(unknown)?;
113        let bytes = ty.bytes(t.dims.iter().product::<u64>()).ok_or_else(unknown)?;
114        Ok((ty, bytes as f64, t.dims.clone()))
115    };
116    let row_bytes = |name: &str| -> Result<f64, TwinRefusal> {
117        let (ty, _, dims) = tensor(name)?;
118        Ok(ty.bytes(dims[0]).ok_or_else(|| TwinRefusal::UnknownType { tensor: name.into(), id: ty.id() })? as f64)
119    };
120    let n = program.numerics;
121    let words = match n.activations {
122        Words::One => 1.0,
123        Words::Two => 2.0,
124    };
125    let state_bytes = match n.state {
126        StateFormat::F32 => 4.0,
127        StateFormat::F16 => 2.0,
128    };
129    // A position's cached bytes: every element binary16, or a code a byte and each head row's f16 scale.
130    let per_position = |kv_heads: usize, width: usize, value_width: usize| -> f64 {
131        match n.cache {
132            CacheFormat::F16 => (kv_heads * (width + value_width) * 2) as f64,
133            CacheFormat::Int8 | CacheFormat::Int8Hadamard => (kv_heads * (width + value_width + 4)) as f64,
134        }
135    };
136    let rows: f64 = step.iter().map(|s| s.rows as f64).sum();
137    let mut out = Vec::with_capacity(program.commands.len());
138    for c in &program.commands {
139        let mut d = Demand::default();
140        match c {
141            Command::Embed { table } => d.weights = rows * row_bytes(table)?,
142            Command::Norm { weight, .. } => {
143                let (_, bytes, dims) = tensor(weight)?;
144                d.weights = bytes;
145                d.vector = 2.0 * dims[0] as f64 * rows + RSQRT_OPS * rows;
146            }
147            Command::Matvec { tensor: name, .. } => {
148                let (ty, bytes, dims) = tensor(name)?;
149                let elems = dims.iter().product::<u64>() as f64;
150                d.weights = bytes;
151                if let Some(p) = array_passes(ty) {
152                    d.array = elems * p as f64 * words * rows;
153                    d.lanes = elems / ty.block_elems() as f64 * lane_cycles(ty, n.scaling).expect("a type the array passes") * words * rows;
154                } else if is_float(ty) {
155                    d.vector = elems * rows;
156                } else {
157                    return Err(TwinRefusal::Undecoded { tensor: name.clone(), ty });
158                }
159            }
160            Command::Add { .. } => d.vector = spec.d as f64 * rows,
161            Command::SwiGlu { .. } => {
162                let dff = ffn_width(spec);
163                d.vector = dff * rows * (SILU_OPS + 1.0);
164            }
165            Command::Conv { kernel, taps, .. } => {
166                let (_, bytes, dims) = tensor(kernel)?;
167                let channels = *dims.last().expect("a kernel's channels") as f64;
168                d.weights = bytes;
169                // The ring read once a sequence, each row appended, as the state stores it.
170                let ring = (*taps as f64 - 1.0) * channels * state_bytes;
171                d.appended = step.iter().map(|s| ring + s.rows as f64 * channels * state_bytes).sum();
172                d.vector = *taps as f64 * channels * rows + SILU_OPS * channels * rows;
173            }
174            Command::L2Heads { heads, width, .. } => d.vector = (2.0 * (*heads * *width) as f64 + RSQRT_OPS * *heads as f64) * rows,
175            Command::Gates { dt, a, .. } => {
176                let (_, b1, _) = tensor(dt)?;
177                let (_, b2, dims) = tensor(a)?;
178                d.weights = b1 + b2;
179                d.vector = dims[0] as f64 * rows * (SOFTPLUS_OPS + 2.0 + EXP_OPS + SIGMOID_OPS);
180            }
181            Command::Delta { k_heads, v_heads, width, .. } => {
182                let state = (*v_heads * *width * *width) as f64;
183                let log = (*k_heads * *width + *v_heads * *width + 2 * *v_heads) as f64;
184                match n.flush {
185                    StateFlush::EveryPass => {
186                        // The state read once a sequence and written back once, or every candidate prefix's when the
187                        // rows are drafts.
188                        d.rewritten = step.iter().map(|s| state * state_bytes * (1.0 + if drafted { s.rows as f64 } else { 1.0 })).sum();
189                        d.vector = 3.0 * state * rows;
190                    }
191                    StateFlush::Every { .. } | StateFlush::Trailing { .. } => {
192                        for s in step {
193                            let mut logged = s.logged;
194                            // Each row reads the state through its folded query and walks the whole log; the rows of a
195                            // head its replay names take the log's replayed tokens again.
196                            let (mut walked, mut replayed, mut replays) = (0usize, 0usize, 0usize);
197                            for _ in 0..s.rows {
198                                logged += 1;
199                                walked += logged;
200                                let replay = Replay::at(n.flush, *width, logged);
201                                replayed += replay.rows.len();
202                                replays += replay.rows.len() * replay.by;
203                                logged -= replay.drop;
204                            }
205                            // The rows a step replays are read and written back, every replay's; the rest of the state
206                            // is read alone.
207                            let rewritten = replayed.min(*width) as f64 / *width as f64;
208                            d.rewritten += state * state_bytes * (rewritten + replayed as f64 / *width as f64);
209                            d.appended += state * state_bytes * (1.0 - rewritten);
210                            d.appended += (s.logged + s.rows) as f64 * log * state_bytes;
211                            d.vector += s.rows as f64 * state
212                                + *v_heads as f64 * walked as f64 * (2.0 * *width as f64 + *width as f64)
213                                + 2.0 * (*v_heads * *width * replays) as f64;
214                        }
215                    }
216                }
217            }
218            Command::GatedNorm { weight, width, .. } => {
219                let (_, bytes, _) = tensor(weight)?;
220                let heads = gated_heads(spec, *width);
221                d.weights = bytes;
222                d.vector = heads * *width as f64 * rows * (3.0 + SILU_OPS + 1.0) + RSQRT_OPS * heads * rows;
223            }
224            Command::Split { .. } => {}
225            Command::RmsHeads { weight, width, slot } => {
226                let (_, bytes, _) = tensor(weight)?;
227                let heads = attention_heads(spec, *slot);
228                d.weights = bytes;
229                d.vector = (2.0 * heads * *width as f64 + RSQRT_OPS * heads) * rows;
230            }
231            Command::Rotate { slot, rope, .. } => {
232                let heads = attention_heads(spec, *slot);
233                // A pair is four products and two adds.
234                d.vector = heads * (rope.dims / 2) as f64 * 6.0 * rows;
235            }
236            Command::Hadamard { slot, width } => {
237                let heads = attention_heads(spec, *slot);
238                d.vector = heads * transform(*width as f64) * rows;
239            }
240            Command::CacheAppend { kv_heads, width, value_width, .. } => {
241                d.appended = per_position(*kv_heads, *width, *value_width) * rows;
242                if n.cache.is_int8() {
243                    d.vector = (*kv_heads * (*width + *value_width)) as f64 * rows * 3.0;
244                }
245            }
246            Command::Attend { heads, kv_heads, width, value_width, .. } => {
247                let per_position = per_position(*kv_heads, *width, *value_width);
248                for s in step {
249                    // One read of the cache serves every row of the sequence; each row attends to its own position.
250                    let read_to = (s.position + s.rows) as f64;
251                    d.appended += per_position * read_to;
252                    for r in 0..s.rows {
253                        let attended = (s.position + r + 1) as f64;
254                        let products = *heads as f64 * (*width + *value_width) as f64 * attended;
255                        d.vector += crate::spatial::serving::SOFTMAX_MACS_PER_SCORE * *heads as f64 * attended;
256                        match n.attention {
257                            AttentionMath::Vector => d.vector += products,
258                            AttentionMath::Array => {
259                                d.array += 2.0 * products;
260                                d.lanes += *heads as f64 * attended * (1.0 + *value_width as f64 / VALUE_BLOCK);
261                            }
262                        }
263                    }
264                }
265                if n.cache == CacheFormat::Int8Hadamard {
266                    d.vector += *heads as f64 * transform(*value_width as f64) * rows;
267                }
268            }
269            Command::SigmoidGate { .. } => {
270                let width = attention_gate_width(spec);
271                d.vector = width * rows * (SIGMOID_OPS + 1.0);
272            }
273            Command::Advance => {}
274        }
275        out.push(d);
276    }
277    Ok(out)
278}
279
280/// A Walsh–Hadamard transform of `n` elements: `n·log2 n` additions.
281fn transform(n: f64) -> f64 {
282    n * n.max(1.0).log2()
283}
284
285fn ffn_width(spec: &ModelSpec) -> f64 {
286    spec.layers
287        .iter()
288        .find_map(|l| match l.ffn {
289            crate::model::spec::Ffn::Dense { dff, .. } => Some(dff as f64),
290            _ => None,
291        })
292        .unwrap_or(0.0)
293}
294
295fn gated_heads(spec: &ModelSpec, width: usize) -> f64 {
296    spec.layers
297        .iter()
298        .find_map(|l| match &l.mixer {
299            Mixer::GatedDelta(g) if g.head_dim == width => Some(g.v_heads as f64),
300            _ => None,
301        })
302        .unwrap_or(0.0)
303}
304
305fn attention_heads(spec: &ModelSpec, slot: super::program::Slot) -> f64 {
306    spec.layers
307        .iter()
308        .find_map(|l| match &l.mixer {
309            Mixer::Attention(a) => Some(match slot {
310                super::program::Slot::Query => a.heads as f64,
311                _ => a.kv_heads as f64,
312            }),
313            _ => None,
314        })
315        .unwrap_or(0.0)
316}
317
318fn attention_gate_width(spec: &ModelSpec) -> f64 {
319    spec.layers
320        .iter()
321        .find_map(|l| match &l.mixer {
322            Mixer::Attention(a) => Some((a.heads * a.value_dim) as f64),
323            _ => None,
324        })
325        .unwrap_or(0.0)
326}
327
328/// Which unit a command waited on.
329#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
330pub enum Bound {
331    Memory,
332    Array,
333    Lanes,
334    Vector,
335}
336
337/// One command on the step's timeline.
338#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
339pub struct Span {
340    pub start_s: f64,
341    pub end_s: f64,
342    pub bound: Bound,
343    /// Weight bytes of the command already in the window when it started.
344    pub prefetched: f64,
345}
346
347/// A step on the card: every command's span and the step's length.
348#[derive(Clone, Debug, PartialEq, Serialize)]
349pub struct Timeline {
350    pub spans: Vec<Span>,
351    pub step_s: f64,
352    /// The time each unit is busy over the step.
353    pub memory_s: f64,
354    pub array_s: f64,
355    pub lanes_s: f64,
356    pub vector_s: f64,
357}
358
359/// The step's commands in order on `card`: each starts when the one before it ends and lasts as long as its slowest
360/// unit; the memory serves the command's own traffic, then its weights not yet fetched, then the following commands'
361/// weights in order while `window` bytes hold them.
362pub fn schedule(demands: &[Demand], card: &Card, window: f64) -> Timeline {
363    // Weight bytes a second the memory reads.
364    let reads = card.memory.bandwidth.value * card.streams.read.value;
365    let (array, vector, lanes) = (card.array.value, card.vector.value, card.scale.value);
366    // Weight bytes of each command already fetched ahead of it.
367    let mut fetched = vec![0f64; demands.len()];
368    let mut held = 0f64;
369    let mut next = 0usize;
370    let mut now = 0f64;
371    let mut spans = Vec::with_capacity(demands.len());
372    let (mut memory_s, mut array_s, mut lanes_s, mut vector_s) = (0f64, 0f64, 0f64, 0f64);
373    for (i, d) in demands.iter().enumerate() {
374        let prefetched = fetched[i];
375        held -= prefetched;
376        let own = d.memory_s(card, (d.weights - prefetched).max(0.0));
377        let times = [(own, Bound::Memory), (d.array / array, Bound::Array), (d.lanes / lanes, Bound::Lanes), (d.vector / vector, Bound::Vector)];
378        let (length, bound) = times.into_iter().fold((0.0, Bound::Memory), |a, b| if b.0 > a.0 { b } else { a });
379        memory_s += own;
380        array_s += d.array / array;
381        lanes_s += d.lanes / lanes;
382        vector_s += d.vector / vector;
383        // The memory time the command leaves spare fetches the next commands' weights, in order, into the window.
384        let mut spare = (length - own) * reads;
385        next = next.max(i + 1);
386        while spare > 0.0 && next < demands.len() {
387            let want = demands[next].weights - fetched[next];
388            let room = window - held;
389            let take = want.min(room).min(spare);
390            if take <= 0.0 {
391                break;
392            }
393            fetched[next] += take;
394            held += take;
395            spare -= take;
396            memory_s += take / reads;
397            if fetched[next] >= demands[next].weights {
398                next += 1;
399            } else {
400                break;
401            }
402        }
403        spans.push(Span { start_s: now, end_s: now + length, bound, prefetched });
404        now += length;
405    }
406    Timeline { spans, step_s: now, memory_s, array_s, lanes_s, vector_s }
407}
408
409// ── The prediction ──────────────────────────────────────────────────────────────────────────────────
410
411/// The header ledger the prediction prices.
412pub const PREDICTED_HEADER: &str = "qwen3.8-27b-q4_k_m";
413/// Batches the prediction prices where the card holds them, every sequence at [`PREDICTED_POSITION`]; with them each
414/// knob point's largest batch the card holds ([`crate::spatial::serving::max_batch`]).
415pub const PREDICTED_BATCHES: [usize; 3] = [1, 16, 64];
416/// Positions each sequence's caches hold before the step.
417pub const PREDICTED_POSITION: usize = 2047;
418/// Each sequence's context limit: the roofline's 2k-of-4k class, its cache paged.
419pub const PREDICTED_LIMIT: u64 = 4096;
420
421/// One configuration the twin predicts, beside the roofline's price of it.
422#[derive(Clone, Debug, PartialEq, Serialize)]
423pub struct PredictedRow {
424    pub numerics: String,
425    pub batch: usize,
426    pub position: usize,
427    /// On-chip bytes the weights may stream ahead into: the card's on-chip memory less the batch's token-rows.
428    pub window_bytes: f64,
429    pub step_s: f64,
430    pub tokens_per_s: f64,
431    /// The pricer's tokens per second for the same serving, the bound no schedule passes.
432    pub roofline_tokens_per_s: f64,
433    /// The share of the step each unit was the command's bound for.
434    pub bound_share: BTreeMap<String, f64>,
435    pub memory_s: f64,
436    pub array_s: f64,
437    pub lanes_s: f64,
438    pub vector_s: f64,
439}
440
441/// The twin's prediction of the 27B on the D5005's standard plan: every knob point the real checkpoints are held to,
442/// at each of [`PREDICTED_BATCHES`] the card holds and at the largest batch it holds, every sequence at
443/// [`PREDICTED_POSITION`].
444#[derive(Clone, Debug, PartialEq, Serialize)]
445pub struct Predicted {
446    pub card: &'static str,
447    pub header: &'static str,
448    pub rows: Vec<PredictedRow>,
449}
450
451/// The serving the pricer describes for a step of `batch` sequences at `n`, each attending to `attended` positions.
452pub fn serving_of(spec: &ModelSpec, n: super::model::EngineNumerics, batch: usize, attended: f64) -> crate::spatial::serving::Serving {
453    let (hd, vd) = spec
454        .layers
455        .iter()
456        .find_map(|l| match &l.mixer {
457            Mixer::Attention(a) => Some((a.head_dim as f64, a.value_dim as f64)),
458            _ => None,
459        })
460        .unwrap_or((0.0, 0.0));
461    crate::spatial::serving::Serving {
462        batch: batch as u32,
463        contexts: crate::spatial::serving::ContextMix::uniform(attended, PREDICTED_LIMIT),
464        kv_page: Some(crate::spatial::d5005::KV_PAGE),
465        attention: n.attention,
466        // A code a byte and each head row's f16 scale, or binary16.
467        kv_bytes: if n.cache.is_int8() { (hd + vd + 4.0) / (hd + vd) } else { 2.0 },
468        state_bytes: match n.state {
469            StateFormat::F32 => 4.0,
470            StateFormat::F16 => 2.0,
471        },
472        activation_words: match n.activations {
473            Words::One => 1,
474            Words::Two => 2,
475        },
476        scaling: n.scaling,
477        kv_rotated: n.cache == CacheFormat::Int8Hadamard,
478        draft: None,
479        flush: n.flush,
480        host_memory: false,
481    }
482}
483
484/// The steps a steady decode of `batch` sequences a row each at `position` cycles through: one, or under a lazy flush
485/// one for every phase of the flush window, sequence `i` of phase `p` having logged `(i + p) mod tokens` tokens past
486/// the window a trailing flush is replaying — so their mean is a mean step.
487pub fn steady_steps(n: super::model::EngineNumerics, batch: usize, position: usize) -> Vec<Step> {
488    let (window, behind) = match n.flush {
489        StateFlush::EveryPass => (1, 0),
490        StateFlush::Every { tokens } => (tokens.max(1) as usize, 0),
491        StateFlush::Trailing { tokens } => (tokens.max(1) as usize, tokens as usize),
492    };
493    (0..window)
494        .map(|p| Step {
495            sequences: (0..batch).map(|i| SequenceRows { rows: 1, position, logged: if window > 1 { behind + (i + p) % window } else { 0 } }).collect(),
496            drafted: false,
497        })
498        .collect()
499}
500
501/// The prediction, from the header ledgers under `decode_dir` (`benchmarks/silicon/decode`): the architecture the
502/// ledger describes ([`GgufHeader::spec`]), the program of each knob point priced and scheduled.
503pub fn predicted(decode_dir: &std::path::Path) -> Result<Predicted, String> {
504    use crate::spatial::d5005::{card, EnginePlan};
505    let h = GgufHeader::read_ledger(&decode_dir.join(format!("headers/{PREDICTED_HEADER}.json")))?;
506    let spec = h.spec().map_err(|e| format!("{PREDICTED_HEADER}: {e}"))?;
507    let c = card(&EnginePlan::standard());
508    let mut rows = Vec::new();
509    for n in super::model::EngineNumerics::real_points() {
510        let program = super::program::lower(&spec, n, PREDICTED_POSITION + 1).map_err(|e| format!("{PREDICTED_HEADER}: {e}"))?.program;
511        let attended = (PREDICTED_POSITION + 1) as f64;
512        let most = crate::spatial::serving::max_batch(&h, &c, &serving_of(&spec, n, 1, attended)).map_err(|e| format!("{e:?}"))? as usize;
513        let mut batches: Vec<usize> = PREDICTED_BATCHES.iter().copied().filter(|&b| b < most).collect();
514        batches.push(most);
515        for batch in batches {
516            let shape = crate::spatial::serving::PassShape::of(&h, &serving_of(&spec, n, batch, (PREDICTED_POSITION + 1) as f64))
517                .map_err(|e| format!("{e:?}"))?;
518            let window = (c.on_chip.capacity.value - batch as f64 * shape.on_chip_per_sequence).max(0.0);
519            let steps = steady_steps(n, batch, PREDICTED_POSITION);
520            let timelines: Vec<Timeline> = steps
521                .iter()
522                .map(|step| Ok(schedule(&demands(&spec, &h, &program, step).map_err(|e| format!("{e:?}"))?, &c, window)))
523                .collect::<Result<_, String>>()?;
524            let mean = |f: &dyn Fn(&Timeline) -> f64| timelines.iter().map(f).sum::<f64>() / timelines.len() as f64;
525            let step_s = mean(&|t| t.step_s);
526            let roofline = shape.cost(&c, batch as u32, 0).map(|cost| cost.tokens_per_s()).unwrap_or(0.0);
527            let mut bound_share = BTreeMap::new();
528            for t in &timelines {
529                for s in &t.spans {
530                    *bound_share.entry(format!("{:?}", s.bound)).or_insert(0.0) += (s.end_s - s.start_s) / (step_s * timelines.len() as f64);
531                }
532            }
533            rows.push(PredictedRow {
534                numerics: format!("{n:?}"),
535                batch,
536                position: PREDICTED_POSITION,
537                window_bytes: window,
538                step_s,
539                tokens_per_s: batch as f64 / step_s,
540                roofline_tokens_per_s: roofline,
541                bound_share,
542                memory_s: mean(&|t| t.memory_s),
543                array_s: mean(&|t| t.array_s),
544                lanes_s: mean(&|t| t.lanes_s),
545                vector_s: mean(&|t| t.vector_s),
546            });
547        }
548    }
549    Ok(Predicted { card: c.name, header: PREDICTED_HEADER, rows })
550}