earthsci_ast/simulate_array/tape/mod.rs
1//! Tape compilation and execution.
2//!
3//! Compiles a model's observed + RHS rule set into a flat instruction program
4//! ([`TapeProgram`]) with build-time value numbering (subsuming the runtime
5//! CSE overlay for taped rules), cadence-sectioned instruction streams
6//! (CONST / SEGMENT / CONTINUOUS), liveness-based slab coloring, `dy` scatter
7//! descriptors and observed exports (Step 3a), and executes it as the
8//! DEFAULT production RHS hot path through the fast slab executor in
9//! [`exec`] (Step 3b): a model builds the program once, keeps it
10//! ([`TapeCache`]), and each segment's RHS scratch runs it. Which evaluator runs is the caller's
11//! choice of compiler (API_SPEC §5.8) and nothing else: `native` is this
12//! tape, `interpreter` is the per-cell oracle. Under `native` the tape also
13//! serves the finite-difference Jacobian closure and the observed passes
14//! (the build-time materialization of static observeds, the per-segment seed
15//! and the observeds reported at output times; see `tape_serves_passes`).
16//! `debug_eval_rhs` is the oracle and never runs the tape.
17//!
18//! ## Instruction set
19//!
20//! [`ir`] defines it and fixes each instruction's element semantics; the
21//! short version is that everything is straight-line and shape-static.
22//! Elementwise work is [`Instr::Bin`] / [`Instr::Un`] / [`Instr::Neg`] /
23//! [`Instr::Select`] (and, after the Step 4 fusion pass, [`Instr::Fused`]);
24//! data movement is [`Instr::Gather`] / [`Instr::LoadElem`] /
25//! [`Instr::Copy`] / [`Instr::Region`] / [`Instr::Fill`] / [`Instr::Ramp`];
26//! control flow is the one structured [`Instr::JmpIfZero`]; and the
27//! boundaries with the rest of the runtime are [`Instr::Export`],
28//! [`Instr::DyWrite`] and [`Instr::LoadForcing`] (the forcing buffer, read in
29//! the CONST or SEGMENT section).
30//!
31//! There is no instruction that hands a rule to the per-cell oracle. A
32//! program exists only when every rule lowered; a build with a rule the tape
33//! cannot express yields a [`DeclinedTape`] instead, which names each
34//! declined rule and its reason and holds nothing to execute. So `native`,
35//! which runs nothing but this tape, cannot reach per-cell evaluation: the
36//! per-cell code lives only in the interpreter (`evaluate_rhs_legacy`, the
37//! observed passes), which the historical routing runs for a model whose tape
38//! declined.
39//!
40//! Two instructions carry data or a fold rather than a per-element map, and
41//! both exist so a whole downstream cone of rules stops declining:
42//!
43//! * [`Instr::ConstArray`] materializes an inline array literal into a
44//! CONST-section slot — one instruction and one store per solve, whatever
45//! the literal's size, with the payload on
46//! [`TapeProgram::const_data`]. Without it an array-valued `const` had no
47//! representation at all (`Operand::Lit` is one `f64`; `Fill` broadcasts
48//! one scalar), so every reader of such a constant bailed.
49//! * [`Instr::Reduce`] folds a source box over a set of axes with a binary
50//! kernel, visiting the source in ROW-MAJOR order — which is the per-cell
51//! oracle's contraction odometer, so a rank-0 `faq` (every index
52//! contracted, scalar result) folds the same terms in the same
53//! association the interpreter would. It is the one construct the tape
54//! lowers that the whole-array overlay declines outright.
55//!
56//! A rule that reads an observed whose own rule declined declines too, at
57//! the read: there is no operand for a value that is not on the tape, so the
58//! decline list names the producer and every rule that reads it.
59//!
60//! ## Closed functions
61//!
62//! A fourth piece is likewise not an instruction. The esm-spec §9.2
63//! `datetime.*` family — `year`, `month`, `day`, `hour`, `minute`, `second`,
64//! `day_of_year`, `julian_day`, `is_leap_year` — is expanded at LOWERING time
65//! into the arithmetic above (one floored divmod by 86400, then floors,
66//! remainders, comparisons and selects on integer-valued `f64`s), so all
67//! three executors gained it at once and none of them can drift from
68//! another. The `interp.*` entries of the same registry are not lowered: they
69//! read a table, and a rule that calls one declines, naming the function.
70
71#[cfg(test)]
72mod array_tests;
73#[cfg(test)]
74mod data_gather_tests;
75mod exec;
76#[cfg(test)]
77mod forcing_tests;
78mod fuse;
79mod geom;
80mod ir;
81mod layout;
82mod lower;
83#[cfg(test)]
84mod lowering_limit_tests;
85mod prune;
86#[cfg(test)]
87mod refexec;
88mod reroll;
89mod scan_shift;
90mod sink;
91#[cfg(test)]
92mod tests;
93#[cfg(test)]
94mod tuple_tests;
95// Phase 2: the XLA emitter over this IR (feature `xla`, OFF by default). Last
96// in the list because it is the only optional one.
97#[cfg(feature = "xla")]
98pub mod xla_emit;
99
100#[cfg(test)]
101pub(crate) use exec::section_primes;
102pub(in crate::simulate_array) use exec::{
103 CallInputs, ForcingView, TapeCtx, run_tape_call, run_tape_inputs,
104};
105pub(crate) use ir::*;
106#[cfg(not(target_arch = "wasm32"))]
107pub(crate) use lower::StandaloneError;
108use lower::build_tape_program;
109pub(crate) use lower::{TileAxisDom, TileLowering, TileObserved, TilePiece};
110#[cfg(not(target_arch = "wasm32"))]
111pub(in crate::simulate_array) use lower::{collect_variable_names, eval_standalone_rule};
112
113use super::ArrayCompiled;
114use std::cell::RefCell;
115use std::collections::HashSet;
116use std::fmt;
117use std::rc::Rc;
118
119/// Human-readable summary of one tape build. Public so external diagnostics
120/// (the `tape_report` example) can print it; the program itself stays
121/// crate-internal.
122#[derive(Debug, Clone, Default)]
123pub struct TapeBuildReport {
124 /// Total rules considered (observed + RHS).
125 pub n_rules: usize,
126 /// Rules fully lowered onto the tape.
127 pub n_taped: usize,
128 /// `(rule name, deepest bail reason)` for every rule that did not lower.
129 /// Non-empty means the build produced no program at all (the instruction
130 /// counts below are then zero): the model runs on the interpreter under
131 /// the historical routing, and a strict compiler refuses it.
132 pub fallbacks: Vec<(String, String)>,
133 pub n_instr_const: usize,
134 pub n_instr_segment: usize,
135 pub n_instr_continuous: usize,
136 /// Instruction counts per opcode, whole program, descending.
137 pub opcode_counts: Vec<(String, usize)>,
138 pub n_slots: usize,
139 pub n_storages: usize,
140 pub n_gather_plans: usize,
141 pub n_exports: usize,
142 pub n_dy_writes: usize,
143 /// Total slab size in bytes (all storages, `f64`).
144 pub slab_bytes: usize,
145 /// Dedicated (cross-section-live) CONST storage bytes.
146 pub slab_bytes_const: usize,
147 /// Dedicated SEGMENT storage bytes.
148 pub slab_bytes_segment: usize,
149 /// Recycled (within-section) storage bytes.
150 pub slab_bytes_recycled: usize,
151 /// Build-time value-numbering hits served from the scope-local memo.
152 pub vn_scope_hits: usize,
153 /// Hits served from the cross-rule (box-pure) hoist map.
154 pub vn_hoist_hits: usize,
155 /// Step 4: kernel-fusion diagnostics (all-zero when fusion is disabled).
156 pub fuse: FuseStats,
157}
158
159impl fmt::Display for TapeBuildReport {
160 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161 writeln!(f, "tape build report")?;
162 writeln!(
163 f,
164 " rules: {} taped / {} declined (of {})",
165 self.n_taped,
166 self.fallbacks.len(),
167 self.n_rules
168 )?;
169 for (name, reason) in &self.fallbacks {
170 writeln!(f, " declined {name}: {reason}")?;
171 }
172 if !self.fallbacks.is_empty() {
173 return writeln!(f, " no program: a declined rule leaves no tape");
174 }
175 writeln!(
176 f,
177 " instructions: {} CONST + {} SEGMENT + {} CONTINUOUS = {}",
178 self.n_instr_const,
179 self.n_instr_segment,
180 self.n_instr_continuous,
181 self.n_instr_const + self.n_instr_segment + self.n_instr_continuous
182 )?;
183 write!(f, " opcodes:")?;
184 for (op, n) in &self.opcode_counts {
185 write!(f, " {op}={n}")?;
186 }
187 writeln!(f)?;
188 writeln!(
189 f,
190 " slots: {} ({} storages, {} gather plans, {} dy writes, {} exports)",
191 self.n_slots, self.n_storages, self.n_gather_plans, self.n_dy_writes, self.n_exports
192 )?;
193 writeln!(
194 f,
195 " slab: {:.2} MiB total ({:.2} MiB CONST + {:.2} MiB SEGMENT dedicated, {:.2} MiB recycled)",
196 self.slab_bytes as f64 / (1024.0 * 1024.0),
197 self.slab_bytes_const as f64 / (1024.0 * 1024.0),
198 self.slab_bytes_segment as f64 / (1024.0 * 1024.0),
199 self.slab_bytes_recycled as f64 / (1024.0 * 1024.0),
200 )?;
201 writeln!(
202 f,
203 " value numbering: {} scope hits, {} hoist hits",
204 self.vn_scope_hits, self.vn_hoist_hits
205 )?;
206 if self.fuse.n_groups > 0 {
207 writeln!(
208 f,
209 " fusion: {} -> {} instructions; {} groups absorbing {} \
210 ({} gathers folded, {} kept, {} reductions folded); group sizes \
211 [2-3]={} [4-7]={} [8-15]={} [16-31]={} [32-63]={} [64+]={}",
212 self.fuse.instrs_before,
213 self.fuse.instrs_after,
214 self.fuse.n_groups,
215 self.fuse.n_member_instrs,
216 self.fuse.n_gathers_folded,
217 self.fuse.n_gathers_kept,
218 self.fuse.n_reduces_folded,
219 self.fuse.group_size_hist[0],
220 self.fuse.group_size_hist[1],
221 self.fuse.group_size_hist[2],
222 self.fuse.group_size_hist[3],
223 self.fuse.group_size_hist[4],
224 self.fuse.group_size_hist[5],
225 )?;
226 }
227 Ok(())
228 }
229}
230
231/// Where ONE rule of a model landed, for `compiler_report` (API_SPEC §5.8).
232///
233/// Deliberately plain data rather than a borrow of the program: the record
234/// lives on the Problem, apart from the program the model keeps.
235#[derive(Clone, Debug)]
236pub(crate) struct TapeRuleRecord {
237 /// The rule's variable name, as the compiled model spells it (the caller
238 /// qualifies it with the component).
239 pub name: String,
240 /// `"observed"` or `"state derivative"`.
241 pub kind: &'static str,
242 /// The cadence tier the rule runs at: `"const"` (once per solve, at
243 /// setup), `"segment"` (once per forcing-refresh segment) or
244 /// `"continuous"` (every right-hand-side call).
245 pub cadence: &'static str,
246 /// `None` when the rule lowered onto the tape; otherwise the DEEPEST
247 /// decline reason reached while trying (and the model has no tape).
248 pub fallback_reason: Option<String>,
249}
250
251/// Assemble the report from a finished program.
252pub(crate) fn make_report(prog: &TapeProgram, vn_hits: (usize, usize)) -> TapeBuildReport {
253 let mut opcode_counts: std::collections::HashMap<&'static str, usize> =
254 std::collections::HashMap::new();
255 for i in &prog.instrs {
256 *opcode_counts.entry(i.opcode()).or_insert(0) += 1;
257 }
258 let mut opcode_counts: Vec<(String, usize)> = opcode_counts
259 .into_iter()
260 .map(|(k, v)| (k.to_string(), v))
261 .collect();
262 opcode_counts.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
263 let n_cont = prog.instrs.len() - (prog.n_const + prog.n_segment) as usize;
264 TapeBuildReport {
265 n_rules: prog.rules.len(),
266 n_taped: prog.rules.len(),
267 fallbacks: Vec::new(),
268 n_instr_const: prog.n_const as usize,
269 n_instr_segment: prog.n_segment as usize,
270 n_instr_continuous: n_cont,
271 opcode_counts,
272 n_slots: prog.slots.len(),
273 n_storages: prog.slab.storages.len(),
274 n_gather_plans: prog.plans.len(),
275 n_exports: prog.exports.len(),
276 n_dy_writes: prog.dy_writes.len(),
277 slab_bytes: prog.slab.total_elems * 8,
278 slab_bytes_const: prog.slab.const_elems * 8,
279 slab_bytes_segment: prog.slab.segment_elems * 8,
280 slab_bytes_recycled: prog.slab.recycled_elems * 8,
281 vn_scope_hits: vn_hits.0,
282 vn_hoist_hits: vn_hits.1,
283 fuse: prog.fuse_stats.clone(),
284 }
285}
286
287/// The report of a build that declined: the rule counts and the declined
288/// rules, and nothing about a program, because there is none.
289pub(crate) fn make_declined_report(
290 declined: &DeclinedTape,
291 vn_hits: (usize, usize),
292) -> TapeBuildReport {
293 let fallbacks = declined.declined_rules();
294 TapeBuildReport {
295 n_rules: declined.rules.len(),
296 n_taped: declined.rules.len() - fallbacks.len(),
297 fallbacks,
298 vn_scope_hits: vn_hits.0,
299 vn_hoist_hits: vn_hits.1,
300 ..TapeBuildReport::default()
301 }
302}
303
304impl DeclinedTape {
305 /// `(rule name, reason)` for every rule that did not lower, in rule order.
306 pub(crate) fn declined_rules(&self) -> Vec<(String, String)> {
307 self.rules
308 .iter()
309 .zip(&self.reasons)
310 .filter_map(|(r, why)| why.as_ref().map(|why| (r.name.clone(), why.clone())))
311 .collect()
312 }
313}
314
315impl RuleInfo {
316 /// `"observed"` or `"state derivative"`, as the compiler report spells it.
317 pub(crate) fn kind_name(&self) -> &'static str {
318 match self.kind {
319 RuleKind::Observed(_) => "observed",
320 RuleKind::Rhs(_) => "state derivative",
321 }
322 }
323
324 /// The cadence tier's name in the compiler report.
325 pub(crate) fn cadence_name(&self) -> &'static str {
326 match self.cadence {
327 Cadence::Const => "const",
328 Cadence::Segment => "segment",
329 Cadence::Continuous => "continuous",
330 }
331 }
332}
333
334/// Test seam: the program of a build every rule of which lowered, panicking
335/// with the build report when one declined.
336#[cfg(test)]
337pub(crate) fn taped(
338 (prog, report): (Result<TapeProgram, DeclinedTape>, TapeBuildReport),
339) -> (TapeProgram, TapeBuildReport) {
340 match prog {
341 Ok(prog) => (prog, report),
342 Err(_) => panic!("the tape declined a rule:\n{report}"),
343 }
344}
345
346/// What a model's tape build produced: the program, or — when any rule did
347/// not lower — the [`DeclinedTape`] in its place. Either way the build's
348/// report rides beside it.
349pub(crate) type TapeBuild = Result<std::sync::Arc<TapeProgram>, Rc<DeclinedTape>>;
350
351/// A tape program this model built, with its report, kept so that every pass
352/// that runs the tape — the construction-time gate, a state-free evaluation,
353/// each solve, the diagnostic seams — runs the one program instead of
354/// building its own.
355///
356/// What a build reads, and so what invalidates a kept program:
357///
358/// * the compiled model itself — its rules, state layout, parameter NAMES and
359/// const-array registry. These are fixed when the model is constructed and
360/// nothing mutates them through `&self`; a changed document is a new model,
361/// with an empty cache.
362/// * the DISCRETE-forcing set, which decides each observed's cadence tier and
363/// the section each forcing load runs in;
364/// * the NAMES and SHAPES the forcing buffer holds, which decide which reads
365/// are forcing loads and the box each one compiles against (a name the
366/// buffer does not hold compiles against its declared shape);
367/// * the precision in force (`crate::precision::active`), which rounds every
368/// literal the build folds;
369/// * whether the tape serves the observed passes (the runtime mode), which
370/// decides what it exports.
371///
372/// The last four are the key an entry is looked up by. Parameter VALUES and
373/// the forcing buffer's VALUES are not inputs: the program reads parameters
374/// on every call and loads the forcing buffer in its CONST and SEGMENT
375/// sections. So a new parameter vector, or new forcing data, needs no rebuild:
376/// those sections are per-scratch state, re-run when the parameter vector
377/// changes, and the SEGMENT section again when the driver bumps the forcing
378/// epoch after a refresh (`TapeCtx::bump_forcing_epoch`).
379pub(crate) struct TapeCache {
380 entries: RefCell<Vec<CachedTape>>,
381}
382
383struct CachedTape {
384 discrete_forcing: Vec<String>,
385 forcing_buffer: Vec<(String, Vec<usize>)>,
386 precision: crate::precision::Precision,
387 serves_passes: bool,
388 prog: TapeBuild,
389 report: Rc<TapeBuildReport>,
390}
391
392impl TapeCache {
393 pub(crate) fn new() -> Self {
394 TapeCache {
395 entries: RefCell::new(Vec::new()),
396 }
397 }
398}
399
400impl ArrayCompiled {
401 /// The production tape for `discrete_forcing`, built on first use and then
402 /// served from [`TapeCache`]: see there for what a kept program depends on.
403 /// `Err` when a rule did not lower: there is then no program to run.
404 pub(crate) fn tape(
405 &self,
406 discrete_forcing: &HashSet<String>,
407 ) -> (TapeBuild, Rc<TapeBuildReport>) {
408 let mut forcing: Vec<String> = discrete_forcing.iter().cloned().collect();
409 forcing.sort();
410 let forcing_buffer = self.forcing_buffer_signature();
411 let precision = crate::precision::active();
412 let serves_passes = self.tape_serves_passes();
413 if let Some(e) = self.tape_cache.entries.borrow().iter().find(|e| {
414 e.discrete_forcing == forcing
415 && e.forcing_buffer == forcing_buffer
416 && e.precision == precision
417 && e.serves_passes == serves_passes
418 }) {
419 return (e.prog.clone(), Rc::clone(&e.report));
420 }
421 let (prog, report) = self.build_tape(discrete_forcing);
422 let (prog, report) = (
423 prog.map(std::sync::Arc::new).map_err(Rc::new),
424 Rc::new(report),
425 );
426 self.tape_cache.entries.borrow_mut().push(CachedTape {
427 discrete_forcing: forcing,
428 forcing_buffer,
429 precision,
430 serves_passes,
431 prog: prog.clone(),
432 report: Rc::clone(&report),
433 });
434 (prog, report)
435 }
436
437 /// The tape a STRICT compiler (`native`, `xla`) runs for
438 /// `discrete_forcing`. A build that declined a rule has no program, so
439 /// this is the strict compilers' refusal (`compiler_refused_rule`, naming
440 /// the first declined rule and its reason) — there is nothing else for
441 /// them to run. `esm_problem` raises the same refusal at construction,
442 /// with the component-qualified rule name, before any pass gets here.
443 pub(crate) fn strict_tape(
444 &self,
445 discrete_forcing: &HashSet<String>,
446 ) -> Result<std::sync::Arc<TapeProgram>, crate::simulate::SimulateError> {
447 let declined = match self.tape(discrete_forcing).0 {
448 Ok(prog) => return Ok(prog),
449 Err(declined) => declined,
450 };
451 let (k, reason) = declined
452 .reasons
453 .iter()
454 .enumerate()
455 .find_map(|(k, why)| why.clone().map(|why| (k, why)))
456 .expect("a declined tape names at least one rule");
457 let rule = &declined.rules[k];
458 Err(crate::simulate::SimulateError::Compile(
459 crate::compile_error::CompileError::CompilerRefusedRule {
460 compiler: if self.is_xla() { "xla" } else { "native" },
461 kind: rule.kind_name(),
462 rule: rule.name.clone(),
463 tier: rule.cadence_name(),
464 reason,
465 },
466 ))
467 }
468
469 /// Every entry the forcing buffer holds, as `(name, shape)` sorted by name:
470 /// the part of the buffer a tape build reads (see [`TapeCache`]).
471 fn forcing_buffer_signature(&self) -> Vec<(String, Vec<usize>)> {
472 let mut sig: Vec<(String, Vec<usize>)> = self
473 .forcing
474 .borrow()
475 .iter()
476 .map(|(name, a)| (name.clone(), a.shape().to_vec()))
477 .collect();
478 sig.sort();
479 sig
480 }
481
482 /// The forcing half of a build's inputs: every name the forcing buffer can
483 /// serve, with the box a read of it compiles against, and the refreshed
484 /// parameters it cannot.
485 ///
486 /// That is every entry the buffer holds now, at its own shape (the
487 /// interpreter's lookup resolves any name the buffer holds that nothing
488 /// else binds), then every externally refreshed parameter the buffer does
489 /// not hold yet, at its declared shape. A parameter whose declared shape
490 /// the registry cannot size, and which the buffer does not hold, is not
491 /// served: a read of it stays unresolved.
492 fn forcing_inputs<'a>(&self, discrete: &'a HashSet<String>) -> lower::ForcingInputs<'a> {
493 let mut shapes: rustc_hash::FxHashMap<String, super::DimU> = self
494 .forcing
495 .borrow()
496 .iter()
497 .map(|(name, a)| (name.clone(), a.shape().iter().copied().collect()))
498 .collect();
499 let mut unsized_ = HashSet::new();
500 for (name, decl) in &self.forcing_decls {
501 if shapes.contains_key(name) {
502 continue;
503 }
504 match decl {
505 Some(shape) => {
506 shapes.insert(name.clone(), shape.iter().copied().collect());
507 }
508 None => {
509 unsized_.insert(name.clone());
510 }
511 }
512 }
513 lower::ForcingInputs {
514 shapes,
515 unsized_,
516 discrete,
517 }
518 }
519
520 /// Build the tape program for this model, uncached. Production passes go
521 /// through [`Self::tape`]; this is the build itself, for the tests that
522 /// take a program apart.
523 pub(crate) fn build_tape(
524 &self,
525 discrete_forcing: &HashSet<String>,
526 ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
527 self.build_tape_opts(discrete_forcing, Some(fuse::SuperopCfg::from_env()))
528 }
529
530 /// [`Self::build_tape`] with the Step 4 fusion pass explicitly on/off
531 /// (the entry the fused-vs-unfused A/B tests drive).
532 pub(crate) fn build_tape_opts(
533 &self,
534 discrete_forcing: &HashSet<String>,
535 fuse: Option<fuse::SuperopCfg>,
536 ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
537 self.build_tape_layout(discrete_forcing, fuse, true)
538 }
539
540 /// [`Self::build_tape_opts`] with the state-layout alignment
541 /// ([`layout`]) explicitly on/off. The XLA emitter builds with it off: it
542 /// lowers logical boxes.
543 pub(crate) fn build_tape_layout(
544 &self,
545 discrete_forcing: &HashSet<String>,
546 fuse: Option<fuse::SuperopCfg>,
547 align_layout: bool,
548 ) -> (Result<TapeProgram, DeclinedTape>, TapeBuildReport) {
549 let const_names = self.classify_static_observeds(discrete_forcing);
550 let seg_names = self.classify_segment_invariant_observeds(discrete_forcing, true);
551 let forcing = self.forcing_inputs(discrete_forcing);
552 let (prog, vn_hits) = build_tape_program(
553 self,
554 &const_names,
555 &seg_names,
556 &forcing,
557 fuse,
558 align_layout,
559 None,
560 );
561 let report = match &prog {
562 Ok(prog) => make_report(prog, vn_hits),
563 Err(declined) => make_declined_report(declined, vn_hits),
564 };
565 (prog, report)
566 }
567
568 /// The tape of a range-restricted program (API_SPEC §5.8): `self` holds
569 /// the tile's clipped rules and storage-box state layout, `tile` the
570 /// domains its reads are classified against. Built with the same sections
571 /// and fusion as the whole-domain tape; `Err` names the declined rules.
572 ///
573 /// Every forcing name is discrete: a program reads its forcing from the
574 /// set each call names, so every forcing load, and every observed that
575 /// reads one, is in the SEGMENT section, re-run when the set changes.
576 pub(crate) fn build_tile_tape(&self, tile: &TileLowering) -> Result<TapeProgram, DeclinedTape> {
577 let discrete: HashSet<String> = self.forcing_decls.keys().cloned().collect();
578 let const_names = self.classify_static_observeds(&discrete);
579 let seg_names = self.classify_segment_invariant_observeds(&discrete, true);
580 let forcing = self.forcing_inputs(&discrete);
581 build_tape_program(
582 self,
583 &const_names,
584 &seg_names,
585 &forcing,
586 Some(fuse::SuperopCfg::from_env()),
587 true,
588 Some(tile),
589 )
590 .0
591 }
592
593 /// Build the tape and return the diagnostic report (see
594 /// [`TapeBuildReport`]). Evaluation is unchanged: this is a build-and-
595 /// discard entry for inspection tooling (`examples/tape_report.rs`).
596 pub fn debug_build_tape_report(&self) -> TapeBuildReport {
597 (*self.tape(&HashSet::new()).1).clone()
598 }
599
600 /// The program as text, one instruction per line with its section and
601 /// the box it defines, then the fused groups (`TAPE_LISTING=1` in
602 /// `examples/tape_report.rs`). Diagnostics only.
603 #[doc(hidden)]
604 pub fn debug_tape_listing(&self) -> String {
605 use std::fmt::Write;
606 let (prog, report) = self.tape(&HashSet::new());
607 let Ok(prog) = prog else {
608 return report.to_string();
609 };
610 let mut out = String::new();
611 for (pc, ins) in prog.instrs.iter().enumerate() {
612 let boxed = ins
613 .out()
614 .map(|o| {
615 let d = &prog.slots[o as usize];
616 format!(" -> s{o}{:?}", &d.shape[..])
617 })
618 .unwrap_or_default();
619 let _ = writeln!(out, "{pc:4} {:?} {ins:?}{boxed}", prog.section_of(pc));
620 }
621 for (i, fs) in prog.fused.iter().enumerate() {
622 let _ = writeln!(
623 out,
624 "fused {i}: box {:?}, {} micro-ops, {} inputs ({} shifted), {} scalars, {} outputs, {} runs ({} schedule nodes), reduce {:?}",
625 &fs.shape[..],
626 fs.micro.len(),
627 fs.inputs.len(),
628 fs.inputs.iter().filter(|x| x.shifted_ix.is_some()).count(),
629 fs.scalars.len(),
630 fs.outputs.len(),
631 fs.schedule.n_runs,
632 fs.schedule.nodes.len(),
633 fs.reduce
634 );
635 for (k, inp) in fs.inputs.iter().enumerate() {
636 let _ = writeln!(out, " in {k}: {inp:?}");
637 }
638 for op in &fs.micro {
639 let _ = writeln!(out, " {op:?}");
640 }
641 }
642 for (i, ls) in prog.lanes.iter().enumerate() {
643 let _ = writeln!(
644 out,
645 "lanes {i}: {} lanes, {} micro-ops, {} inputs, {} scalars, {} registers, {} writes",
646 ls.lanes,
647 ls.micro.len(),
648 ls.inputs.len(),
649 ls.scalars.len(),
650 ls.n_regs,
651 ls.writes.len()
652 );
653 for (k, inp) in ls.inputs.iter().enumerate() {
654 let _ = writeln!(out, " in {k}: {:?} {:?}", inp.kind, inp.ix);
655 }
656 for op in &ls.micro {
657 let _ = writeln!(out, " {op:?}");
658 }
659 for w in &ls.writes {
660 let _ = writeln!(out, " write {:?} -> {:?}", w.src, w.dst);
661 }
662 }
663 out
664 }
665
666 /// Where every rule of this model LANDS, in program order — the per-rule
667 /// half of `compiler_report` (API_SPEC §5.8).
668 ///
669 /// [`TapeBuildReport::fallbacks`] answers only "which rules did not lower",
670 /// which is the wrong half for a caller asking what a compiler did: a
671 /// document with no fallbacks reports an empty list and says nothing about
672 /// the rules that DID lower, nor at which cadence they run. This walks the
673 /// program's whole rule table instead, so a taped rule is named too.
674 ///
675 /// Read off the same kept program the solve runs ([`Self::tape`]).
676 pub(crate) fn tape_rule_records(
677 &self,
678 discrete_forcing: &HashSet<String>,
679 ) -> (Vec<TapeRuleRecord>, TapeBuildReport) {
680 let (prog, report) = self.tape(discrete_forcing);
681 let report = (*report).clone();
682 let (rules, reasons): (&[RuleInfo], &[Option<String>]) = match &prog {
683 Ok(prog) => (&prog.rules, &[]),
684 Err(declined) => (&declined.rules, &declined.reasons),
685 };
686 let records = rules
687 .iter()
688 .enumerate()
689 .map(|(k, r)| TapeRuleRecord {
690 name: r.name.clone(),
691 kind: r.kind_name(),
692 cadence: r.cadence_name(),
693 fallback_reason: reasons.get(k).cloned().flatten(),
694 })
695 .collect();
696 (records, report)
697 }
698
699 /// Step 4 diagnostic: dump per-group shape statistics of the fused
700 /// program to stderr (`examples/fuse_stats.rs`).
701 pub fn debug_dump_fuse_stats(&self) {
702 let (prog, report) = self.tape(&HashSet::new());
703 eprintln!("{report}");
704 let Ok(prog) = prog else {
705 return;
706 };
707 let mut by_regs: Vec<&FusedSpec> = prog.fused.iter().collect();
708 by_regs.sort_by_key(|f| std::cmp::Reverse(f.n_regs));
709 let max_regs = by_regs.first().map(|f| f.n_regs).unwrap_or(0);
710 let tot_runs: usize = prog.fused.iter().map(|f| f.schedule.n_runs).sum();
711 let tot_micro: usize = prog.fused.iter().map(|f| f.micro.len()).sum();
712 let tot_inputs: usize = prog.fused.iter().map(|f| f.inputs.len()).sum();
713 let tot_outputs: usize = prog.fused.iter().map(|f| f.outputs.len()).sum();
714 let tot_elem_ops: usize = prog.fused.iter().map(|f| f.micro.len() * f.n_elems()).sum();
715 let tot_out_elems: usize = prog
716 .fused
717 .iter()
718 .map(|f| f.outputs.len() * f.n_elems())
719 .sum();
720 eprintln!(
721 "element-ops per call: {} micro x elems | output elems stored {}",
722 tot_elem_ops, tot_out_elems
723 );
724 eprintln!(
725 "fused groups: {} | micro ops {} | inputs {} | outputs {} | runs {} | max n_regs {}",
726 prog.fused.len(),
727 tot_micro,
728 tot_inputs,
729 tot_outputs,
730 tot_runs,
731 max_regs
732 );
733 eprintln!("micro-op histogram (element-weighted, post-superop):");
734 for (k, n) in prog.fuse_stats.micro_hist.iter().take(24) {
735 eprintln!(" {n:>10} {k}");
736 }
737 eprintln!("single-use adjacency (element-weighted, pre-superop):");
738 for (k, n) in prog.fuse_stats.adj_hist.iter().take(24) {
739 eprintln!(" {n:>10} {k}");
740 }
741 eprintln!("arith chain lengths (element-weighted, pre-superop):");
742 for (len, n) in prog.fuse_stats.chain_hist.iter() {
743 eprintln!(" {n:>10} len={len}");
744 }
745 for f in by_regs.iter().take(12) {
746 eprintln!(
747 " group: shape={:?} micro={} regs={} inputs={} (shifted {}) scalars={} \
748 outputs={} runs={} folded_gathers={}",
749 &f.shape[..],
750 f.micro.len(),
751 f.n_regs,
752 f.inputs.len(),
753 f.inputs.iter().filter(|i| i.shifted_ix.is_some()).count(),
754 f.scalars.len(),
755 f.outputs.len(),
756 f.schedule.n_runs,
757 f.n_folded_gathers
758 );
759 }
760 }
761}