Skip to main content

hrx/loom/
report.rs

1//! Versioned compiler evidence. Missing analysis remains unknown.
2use crate::{Error, Result};
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use std::collections::BTreeMap;
6mod guidance;
7pub use guidance::{ReportGuidance, ReportSuggestion};
8
9/// Structured evidence tied to the exact compiler which produced it.
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct CompileReport {
12    compiler: String,
13    target: String,
14    processor_mode: super::ProcessorMode,
15    document: Value,
16}
17
18/// Maximum expansion of one source operation for a compiler selection.
19#[derive(Clone, Debug, Serialize, Deserialize)]
20pub struct Expansion {
21    /// Function containing the source operation.
22    pub function: Option<String>,
23    /// Source operation name.
24    pub source_op: Option<String>,
25    /// Compiler selection mechanism.
26    pub selection: Option<String>,
27    /// Selected lowering plan, when present.
28    pub plan_key: Option<String>,
29    /// Target descriptor, when present.
30    pub descriptor_key: Option<String>,
31    /// Semantic category of the selected target descriptor.
32    pub descriptor_semantic_tag: Option<String>,
33    /// Number of selected source operations in this group.
34    pub selected_op_count: Option<u64>,
35    /// Total emitted low operations for this group.
36    pub emitted_low_op_count: Option<u64>,
37    /// Largest single-operation expansion; not an average or measured latency.
38    pub maximum_emitted_low_op_count: Option<u64>,
39    /// Source operations with an exact dynamic count, when reported.
40    pub exact_dynamic_op_count: Option<u64>,
41    /// Source operations with unknown dynamic count, when reported.
42    pub unknown_dynamic_op_count: Option<u64>,
43    /// Weighted selected count when it differs from the static count.
44    pub dynamic_selected_op_count: Option<u64>,
45    /// Weighted emitted count when it differs from the static count.
46    pub dynamic_emitted_low_op_count: Option<u64>,
47}
48
49/// Resource facts for one compiled entry. These are not throughput estimates.
50#[derive(Clone, Debug, Serialize)]
51pub struct EntryResources {
52    /// Compiled function name.
53    pub function: Option<String>,
54    /// Machine-code bytes, excluding container overhead.
55    pub code_bytes: Option<u64>,
56    /// Final scalar register count.
57    pub scalar_registers: Option<u64>,
58    /// Final vector register count.
59    pub vector_registers: Option<u64>,
60    /// Workgroup-local storage in bytes.
61    pub local_bytes: Option<u64>,
62    /// Per-workitem private storage in bytes.
63    pub private_bytes: Option<u64>,
64    /// Compiler-reported spill count.
65    pub spills: Option<u64>,
66    /// Target-model occupancy percentage, when known.
67    pub occupancy_percent: Option<u64>,
68    /// Packets covered by the compiler's LDS bank-service model.
69    pub bank_modeled_packets: Option<u64>,
70    /// Packets outside the model's coverage; these are not conflict-free claims.
71    pub bank_unmodeled_packets: Option<u64>,
72    /// Modeled packets with structural bank conflicts, not measured stalls.
73    pub bank_conflicted_packets: Option<u64>,
74    /// Additional structural bank-service rounds, not hardware cycles.
75    pub bank_extra_rounds: Option<u64>,
76}
77
78/// Compiler-planned waits grouped by function, hardware counter and reason.
79/// Counts describe the compiled program, not measured device latency.
80#[derive(Clone, Debug, Serialize, Deserialize)]
81pub struct WaitReason {
82    /// Compiled function, when identified by the compiler.
83    pub function: Option<String>,
84    /// Target counter name.
85    pub counter: Option<String>,
86    /// Compiler explanation for this wait family.
87    pub reason: Option<String>,
88    /// Structural wait counts, preserving missing evidence.
89    pub summary: WaitCounts,
90}
91
92/// Structural wait evidence. Missing facts remain unknown.
93#[derive(Clone, Debug, Default, Serialize, Deserialize)]
94pub struct WaitCounts {
95    /// Total wait actions.
96    pub action_count: Option<u64>,
97    /// Waits explicitly authored in source.
98    pub explicit_action_count: Option<u64>,
99    /// Waits inserted by the compiler.
100    pub planned_action_count: Option<u64>,
101    /// Waits draining a counter completely.
102    pub full_drain_count: Option<u64>,
103    /// Waits permitting some requests to remain outstanding.
104    pub partial_wait_count: Option<u64>,
105    /// Total packets drained by these waits.
106    pub drained_count: Option<u64>,
107    /// Most packets drained by one wait.
108    pub max_drained_count: Option<u64>,
109    /// Maximum block-local outstanding packets before a wait.
110    pub max_outstanding_before: Option<u64>,
111    /// Maximum block-local outstanding packets before a full drain.
112    pub max_full_drain_outstanding_before: Option<u64>,
113}
114
115/// Wait evidence paired by function, counter and reason, independent of row order.
116#[derive(Clone, Debug, Serialize)]
117pub struct WaitReasonChange {
118    /// Compiled function shared by the compared rows.
119    pub function: String,
120    /// Target counter name.
121    pub counter: String,
122    /// Compiler explanation for this wait family.
123    pub reason: String,
124    /// Evidence before the change, or null for an added reason.
125    pub before: Option<WaitCounts>,
126    /// Evidence after the change, or null for a removed reason.
127    pub after: Option<WaitCounts>,
128    /// Signed after-minus-before changes, only for known facts on both sides.
129    pub delta: BTreeMap<&'static str, Option<i128>>,
130}
131
132impl WaitCounts {
133    fn fields(&self) -> [(&'static str, Option<u64>); 9] {
134        [
135            ("action_count", self.action_count),
136            ("explicit_action_count", self.explicit_action_count),
137            ("planned_action_count", self.planned_action_count),
138            ("full_drain_count", self.full_drain_count),
139            ("partial_wait_count", self.partial_wait_count),
140            ("drained_count", self.drained_count),
141            ("max_drained_count", self.max_drained_count),
142            ("max_outstanding_before", self.max_outstanding_before),
143            (
144                "max_full_drain_outstanding_before",
145                self.max_full_drain_outstanding_before,
146            ),
147        ]
148    }
149}
150
151/// Resource change for one named function; missing or unknown facts stay null.
152#[derive(Clone, Debug, Serialize)]
153pub struct EntryChange {
154    /// Function identity shared by the compared entries.
155    pub function: String,
156    /// Evidence before the change, or null if the entry was added.
157    pub before: Option<EntryResources>,
158    /// Evidence after the change, or null if the entry was removed.
159    pub after: Option<EntryResources>,
160    /// Signed after-minus-before changes, only for known facts on both sides.
161    pub delta: BTreeMap<&'static str, Option<i128>>,
162}
163
164impl CompileReport {
165    pub(super) fn new(
166        compiler: String,
167        target: String,
168        processor_mode: super::ProcessorMode,
169        document: Value,
170    ) -> Result<Self> {
171        let report = Self {
172            compiler,
173            target,
174            processor_mode,
175            document,
176        };
177        report.validate()?;
178        Ok(report)
179    }
180
181    /// Reject unrecognized evidence instead of interpreting a different schema.
182    pub fn validate(&self) -> Result<()> {
183        if self.compiler.is_empty()
184            || crate::Target::new(&self.target).is_err()
185            || self.document["kind"] != "loom.compile_report"
186            || self.document["schema_version"].as_u64() != Some(0)
187        {
188            return Err(Error::Unsupported(
189                "compile report schema or compiler identity is unsupported; regenerate the report"
190                    .into(),
191            ));
192        }
193        Ok(())
194    }
195
196    /// Content digest of the producing compiler library.
197    pub fn compiler_identity(&self) -> &str {
198        &self.compiler
199    }
200
201    /// Exact device profile used to specialize the source.
202    pub fn target(&self) -> &str {
203        &self.target
204    }
205
206    /// Requested AMDGPU execution policy.
207    pub fn processor_mode(&self) -> super::ProcessorMode {
208        self.processor_mode
209    }
210
211    /// Original versioned JSON, including evidence not represented by accessors.
212    pub fn json(&self) -> &Value {
213        &self.document
214    }
215
216    /// Per-selection lowering expansion, available in summary and detailed reports.
217    /// Missing collection is distinct from an empty collection.
218    pub fn expansions(&self) -> Result<Option<Vec<Expansion>>> {
219        self.validate()?;
220        self.document
221            .pointer("/source_low/selection_summaries/rows")
222            .map(|rows| serde_json::from_value(rows.clone()).map_err(Error::from))
223            .transpose()
224    }
225
226    /// Resource summaries, preserving unknown values rather than reporting zero.
227    pub fn entries(&self) -> Result<Vec<EntryResources>> {
228        self.validate()?;
229        let rows = self
230            .rows("entries")?
231            .ok_or_else(|| Error::Message("report entries are missing".into()))?;
232        Ok(rows
233            .iter()
234            .map(|row| {
235                let number = |path| row.pointer(path).and_then(Value::as_u64);
236                EntryResources {
237                    function: row["function"].as_str().map(str::to_owned),
238                    code_bytes: number("/code_byte_count"),
239                    scalar_registers: number("/target_resources/scalar/final/register_count"),
240                    vector_registers: number("/target_resources/vector/final/register_count"),
241                    local_bytes: number("/local_memory_bytes"),
242                    private_bytes: number("/private_memory_bytes"),
243                    spills: number("/allocation_spill_count"),
244                    occupancy_percent: number("/target_resources/occupancy_percent"),
245                    bank_modeled_packets: number(
246                        "/source_low_memory/bank_service/modeled_packet_count",
247                    ),
248                    bank_unmodeled_packets: number(
249                        "/source_low_memory/bank_service/unmodeled_packet_count",
250                    ),
251                    bank_conflicted_packets: number(
252                        "/source_low_memory/bank_service/structural/conflicted_packet_count",
253                    ),
254                    bank_extra_rounds: number(
255                        "/source_low_memory/bank_service/structural/extra_round_count",
256                    ),
257                }
258            })
259            .collect())
260    }
261
262    /// Wait explanations from detailed reports. `None` means this evidence was
263    /// not collected; `Some([])` means it was collected with no wait rows.
264    pub fn wait_reasons(&self) -> Result<Option<Vec<WaitReason>>> {
265        self.validate()?;
266        self.rows("wait_reason_summary_rows")?
267            .map(|rows| {
268                rows.iter()
269                    .cloned()
270                    .map(serde_json::from_value)
271                    .collect::<std::result::Result<Vec<_>, _>>()
272                    .map_err(Error::from)
273            })
274            .transpose()
275    }
276
277    fn rows(&self, name: &str) -> Result<Option<&[Value]>> {
278        let Some(section) = self.document.get(name).filter(|value| !value.is_null()) else {
279            return Ok(None);
280        };
281        let rows = match section.get("rows") {
282            Some(Value::Array(rows)) => rows.as_slice(),
283            None if section["count"].as_u64() == Some(0) => &[],
284            _ => {
285                return Err(Error::Message(format!(
286                    "report {name} is missing valid rows"
287                )));
288            }
289        };
290        if let Some(count) = section.get("count")
291            && count.as_u64() != Some(rows.len() as u64)
292        {
293            return Err(Error::Message(format!(
294                "report {name} count does not match its rows"
295            )));
296        }
297        if rows.iter().any(|row| !row.is_object()) {
298            return Err(Error::Message(format!(
299                "report {name} contains a non-object row"
300            )));
301        }
302        Ok(Some(rows))
303    }
304
305    /// Compare wait reasons by semantic identity. `None` means at least one
306    /// report lacks wait evidence; an empty vector means both collected no rows.
307    /// Added/removed reasons have null deltas, never invented zero counts.
308    pub fn wait_reason_changes(&self, after: &Self) -> Result<Option<Vec<WaitReasonChange>>> {
309        self.ensure_comparable(after)?;
310        let before_rows = self.wait_reasons()?;
311        let after_rows = after.wait_reasons()?;
312        let (Some(before_rows), Some(after_rows)) = (before_rows, after_rows) else {
313            return Ok(None);
314        };
315        let index =
316            |rows: Vec<WaitReason>| -> Result<BTreeMap<(String, String, String), WaitCounts>> {
317                let mut indexed = BTreeMap::new();
318                for row in rows {
319                    let identity = row
320                        .function
321                        .zip(row.counter)
322                        .zip(row.reason)
323                        .filter(|((function, counter), reason)| {
324                            !function.is_empty() && !counter.is_empty() && !reason.is_empty()
325                        })
326                        .map(|((function, counter), reason)| (function, counter, reason))
327                        .ok_or_else(|| {
328                            Error::Message("report wait reason has no complete identity".into())
329                        })?;
330                    if indexed.insert(identity, row.summary).is_some() {
331                        return Err(Error::Message(
332                            "report contains duplicate wait reason identities".into(),
333                        ));
334                    }
335                }
336                Ok(indexed)
337            };
338        let mut before = index(before_rows)?;
339        let mut after = index(after_rows)?;
340        let identities: std::collections::BTreeSet<_> =
341            before.keys().chain(after.keys()).cloned().collect();
342        Ok(Some(
343            identities
344                .into_iter()
345                .map(|identity| {
346                    let before = before.remove(&identity);
347                    let after = after.remove(&identity);
348                    let unknown = WaitCounts::default();
349                    let delta = before
350                        .as_ref()
351                        .unwrap_or(&unknown)
352                        .fields()
353                        .into_iter()
354                        .zip(after.as_ref().unwrap_or(&unknown).fields())
355                        .map(|((field, before), (_, after))| {
356                            (
357                                field,
358                                before
359                                    .zip(after)
360                                    .map(|(before, after)| i128::from(after) - i128::from(before)),
361                            )
362                        })
363                        .collect();
364                    WaitReasonChange {
365                        function: identity.0,
366                        counter: identity.1,
367                        reason: identity.2,
368                        before,
369                        after,
370                        delta,
371                    }
372                })
373                .collect(),
374        ))
375    }
376
377    /// Compare named entries after checking compiler, schema and target identity.
378    /// Added/removed entries are explicit; unknown analysis is never treated as zero.
379    pub fn changes(&self, after: &Self) -> Result<Vec<EntryChange>> {
380        self.ensure_comparable(after)?;
381        let index = |report: &Self| -> Result<BTreeMap<String, EntryResources>> {
382            let mut rows = BTreeMap::new();
383            for row in report.entries()? {
384                let name = row
385                    .function
386                    .clone()
387                    .filter(|name| !name.is_empty())
388                    .ok_or_else(|| {
389                        Error::Message("report entry has no function identity".into())
390                    })?;
391                if rows.insert(name, row).is_some() {
392                    return Err(Error::Message(
393                        "report contains duplicate function identities".into(),
394                    ));
395                }
396            }
397            Ok(rows)
398        };
399        let mut before = index(self)?;
400        let mut after = index(after)?;
401        let names: std::collections::BTreeSet<_> =
402            before.keys().chain(after.keys()).cloned().collect();
403        Ok(names
404            .into_iter()
405            .map(|function| {
406                let before = before.remove(&function);
407                let after = after.remove(&function);
408                let fields = |row: Option<&EntryResources>| {
409                    [
410                        row.and_then(|r| r.code_bytes),
411                        row.and_then(|r| r.scalar_registers),
412                        row.and_then(|r| r.vector_registers),
413                        row.and_then(|r| r.local_bytes),
414                        row.and_then(|r| r.private_bytes),
415                        row.and_then(|r| r.spills),
416                        row.and_then(|r| r.occupancy_percent),
417                        row.and_then(|r| r.bank_modeled_packets),
418                        row.and_then(|r| r.bank_unmodeled_packets),
419                        row.and_then(|r| r.bank_conflicted_packets),
420                        row.and_then(|r| r.bank_extra_rounds),
421                    ]
422                };
423                let names = [
424                    "code_bytes",
425                    "scalar_registers",
426                    "vector_registers",
427                    "local_bytes",
428                    "private_bytes",
429                    "spills",
430                    "occupancy_percent",
431                    "bank_modeled_packets",
432                    "bank_unmodeled_packets",
433                    "bank_conflicted_packets",
434                    "bank_extra_rounds",
435                ];
436                let delta = names
437                    .into_iter()
438                    .zip(
439                        fields(before.as_ref())
440                            .into_iter()
441                            .zip(fields(after.as_ref()))
442                            .map(|(before, after)| {
443                                before
444                                    .zip(after)
445                                    .map(|(before, after)| i128::from(after) - i128::from(before))
446                            }),
447                    )
448                    .collect();
449                EntryChange {
450                    function,
451                    before,
452                    after,
453                    delta,
454                }
455            })
456            .collect())
457    }
458
459    /// Check the minimum identity contract for comparing compiler evidence.
460    /// Source and configuration may differ: those are the experiment variables.
461    pub fn ensure_comparable(&self, other: &Self) -> Result<()> {
462        self.validate()?;
463        other.validate()?;
464        if self.compiler != other.compiler
465            || self.target != other.target
466            || self.processor_mode != other.processor_mode
467            || ["backend", "target_key", "mode"].iter().any(|key| {
468                self.document.get(key).is_none()
469                    || self.document.get(key) != other.document.get(key)
470            })
471        {
472            return Err(Error::Message(
473                "reports must use the same compiler, target, backend, processor policy, and detail mode".into(),
474            ));
475        }
476        Ok(())
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483    fn evidence() -> Value {
484        serde_json::json!({"kind":"loom.compile_report", "schema_version":0,
485            "backend":"amdgpu-hsaco", "target_key":"gfx1151", "mode":"summary",
486            "entries":{"rows":[{"function":"empty", "allocation_spill_count":0}]}})
487    }
488    #[test]
489    fn bank_coverage_and_wait_reasons_preserve_missing_evidence() {
490        let mut document = evidence();
491        document["entries"]["rows"][0]["source_low_memory"] = serde_json::json!({
492            "bank_service": {"modeled_packet_count": 3, "unmodeled_packet_count": 2,
493                "structural": {"conflicted_packet_count": 1, "extra_round_count": 4}}
494        });
495        let mut report = CompileReport::new(
496            "compiler-a".into(),
497            "gfx1151".into(),
498            super::super::ProcessorMode::Default,
499            document,
500        )
501        .unwrap();
502        let entry = report.entries().unwrap().remove(0);
503        assert_eq!(entry.bank_modeled_packets, Some(3));
504        assert_eq!(entry.bank_unmodeled_packets, Some(2));
505        assert_eq!(entry.bank_conflicted_packets, Some(1));
506        assert_eq!(entry.bank_extra_rounds, Some(4));
507        assert!(report.wait_reasons().unwrap().is_none());
508        report.document["wait_reason_summary_rows"] = serde_json::json!({"count": 0});
509        assert!(report.wait_reasons().unwrap().unwrap().is_empty());
510        report.document["wait_reason_summary_rows"] = serde_json::json!({"count": 1,
511            "rows": [{"function": "empty", "counter": "smem", "reason": "storage_reuse",
512                "summary": {"action_count": 2, "full_drain_count": 2}}]});
513        let waits = report.wait_reasons().unwrap().unwrap();
514        assert_eq!(waits[0].summary.full_drain_count, Some(2));
515        assert_eq!(waits[0].summary.partial_wait_count, None);
516        let mut after = report.clone();
517        after.document["entries"]["rows"][0]["source_low_memory"]["bank_service"]["structural"]["extra_round_count"] =
518            1.into();
519        assert_eq!(
520            report.changes(&after).unwrap()[0].delta["bank_extra_rounds"],
521            Some(-3)
522        );
523        report.document["wait_reason_summary_rows"] = serde_json::json!({"count": 1});
524        assert!(report.wait_reasons().is_err());
525    }
526
527    #[test]
528    fn unknown_resources_are_not_zero_and_comparisons_check_identity() {
529        let report = CompileReport::new(
530            "compiler-a".into(),
531            "gfx1151".into(),
532            super::super::ProcessorMode::Default,
533            evidence(),
534        )
535        .unwrap();
536        let row = report.entries().unwrap().remove(0);
537        assert_eq!(row.spills, Some(0));
538        assert_eq!(row.vector_registers, None);
539        report.ensure_comparable(&report).unwrap();
540        let changes = report.changes(&report).unwrap();
541        assert_eq!(changes[0].delta["spills"], Some(0));
542        assert_eq!(changes[0].delta["vector_registers"], None);
543        let other = CompileReport::new(
544            "compiler-b".into(),
545            "gfx1151".into(),
546            super::super::ProcessorMode::Default,
547            evidence(),
548        )
549        .unwrap();
550        assert!(report.ensure_comparable(&other).is_err());
551        let mut policy = report.clone();
552        policy.processor_mode = super::super::ProcessorMode::ComputeUnit;
553        assert!(report.ensure_comparable(&policy).is_err());
554        let mut target = report.clone();
555        target.target = "gfx1100".into();
556        assert!(report.ensure_comparable(&target).is_err());
557        let mut future = evidence();
558        future["schema_version"] = 1.into();
559        assert!(
560            CompileReport::new(
561                "compiler-a".into(),
562                "gfx1151".into(),
563                super::super::ProcessorMode::Default,
564                future
565            )
566            .is_err()
567        );
568    }
569}