Skip to main content

padlock_output/
explain.rs

1// padlock-output/src/explain.rs
2//
3// Renders a visual field-by-field memory layout table for a single struct.
4// Shows each field's offset, size, alignment, and padding gaps inline.
5
6use padlock_core::analysis::impact::estimate_impact;
7use padlock_core::ir::{StructLayout, TypeInfo, find_padding};
8
9/// Render a visual layout table for one struct.
10///
11/// Example output:
12///
13/// ```text
14/// ReadyEvent  24 bytes  align=4
15/// ┌──────────────────────────────────────────────────────────────────┐
16/// │ offset │ size │ align │ CL │ field                              │
17/// ├──────────────────────────────────────────────────────────────────┤
18/// │      0 │    1 │     1 │  0 │ tick: u8                          │
19/// │      1 │    3 │     — │  0 │ <padding>                         │
20/// │      4 │    4 │     4 │  0 │ ready: Ready                      │
21/// │      8 │    1 │     1 │  0 │ is_shutdown: bool                 │
22/// │      9 │   15 │     — │  0 │ <padding> (trailing)              │
23/// └──────────────────────────────────────────────────────────────────┘
24/// 14 bytes wasted (58%) — reorder: ready, tick, is_shutdown → 8 bytes
25/// ```
26///
27/// The `CL` column shows the zero-indexed cache-line number for each field.
28/// A cache-line separator row (`╞══ cache line N ══╡`) is also emitted
29/// whenever the layout crosses a cache-line boundary.
30pub fn render_explain(layout: &StructLayout) -> String {
31    use padlock_core::analysis::reorder;
32
33    let mut out = String::new();
34
35    // Header
36    let loc = match (&layout.source_file, layout.source_line) {
37        (Some(f), Some(l)) => format!("  ({}:{})", f, l),
38        (Some(f), None) => format!("  ({})", f),
39        _ => String::new(),
40    };
41    out.push_str(&format!("{}{}\n", layout.name, loc));
42    out.push_str(&format!(
43        "{} bytes  align={}  fields={}{}{}\n",
44        layout.total_size,
45        layout.align,
46        layout.fields.len(),
47        if layout.is_packed { "  [packed]" } else { "" },
48        if layout.is_repr_rust {
49            "  [repr(Rust) — compiler may reorder]"
50        } else {
51            ""
52        },
53    ));
54
55    // Table — columns: offset(6) | size(4) | align(5) | CL(2) | field(36)
56    let col_field = 36usize;
57    let divider = format!(
58        "├{:─<8}┼{:─<6}┼{:─<7}┼{:─<4}┼{:─<col_field$}┤",
59        "", "", "", "", ""
60    );
61    let top = format!(
62        "┌{:─<8}┬{:─<6}┬{:─<7}┬{:─<4}┬{:─<col_field$}┐",
63        "", "", "", "", ""
64    );
65    let bot = format!(
66        "└{:─<8}┴{:─<6}┴{:─<7}┴{:─<4}┴{:─<col_field$}┘",
67        "", "", "", "", ""
68    );
69    let header = format!(
70        "│ {:>6} │ {:>4} │ {:>5} │ {:>2} │ {:<col_field$}│",
71        "offset", "size", "align", "CL", "field"
72    );
73
74    out.push_str(&top);
75    out.push('\n');
76    out.push_str(&header);
77    out.push('\n');
78    out.push_str(&divider);
79    out.push('\n');
80
81    // Build rows: interleave fields with padding gaps and cache-line markers
82    #[derive(Debug)]
83    enum Row {
84        Field {
85            offset: usize,
86            size: usize,
87            align: usize,
88            name: String,
89            ty: String,
90        },
91        Pad {
92            offset: usize,
93            size: usize,
94            trailing: bool,
95        },
96        CacheLine {
97            line_number: usize,
98            offset: usize,
99        },
100    }
101
102    let cache_line = layout.arch.cache_line_size;
103    let mut rows: Vec<Row> = Vec::new();
104    let gaps = find_padding(layout);
105
106    let last_field_name = layout.fields.last().map(|f| f.name.as_str()).unwrap_or("");
107
108    // Track which cache lines have been crossed so we can insert markers.
109    let mut last_cache_line: Option<usize> = None;
110
111    for field in &layout.fields {
112        let field_cache_line = field.offset / cache_line;
113
114        // Insert a cache-line boundary marker when entering a new cache line.
115        if last_cache_line.is_none_or(|prev| field_cache_line > prev) {
116            if last_cache_line.is_some() {
117                // Not the first cache line: insert a separator row.
118                rows.push(Row::CacheLine {
119                    line_number: field_cache_line,
120                    offset: field_cache_line * cache_line,
121                });
122            }
123            last_cache_line = Some(field_cache_line);
124        }
125
126        let ty_name = type_name(&field.ty);
127        rows.push(Row::Field {
128            offset: field.offset,
129            size: field.size,
130            align: field.align,
131            name: field.name.clone(),
132            ty: ty_name,
133        });
134        if let Some(gap) = gaps.iter().find(|g| g.after_field == field.name) {
135            let pad_offset = field.offset + field.size;
136            let is_trailing = field.name == last_field_name;
137            rows.push(Row::Pad {
138                offset: pad_offset,
139                size: gap.bytes,
140                trailing: is_trailing,
141            });
142        }
143    }
144
145    // Cache-line separator row width matches the table inner width.
146    // Inner width = 8(offset) + 1(┼) + 6(size) + 1(┼) + 7(align) + 1(┼) + 4(CL) + 1(┼) + col_field + 3
147    let cache_sep_inner = 8 + 1 + 6 + 1 + 7 + 1 + 4 + 1 + col_field + 3; // ─ count between outer │
148    for row in &rows {
149        match row {
150            Row::Field {
151                offset,
152                size,
153                align,
154                name,
155                ty,
156            } => {
157                let cl = offset / cache_line;
158                let label = format!("{}: {}", name, ty);
159                let label = if label.len() > col_field {
160                    format!("{}…", &label[..col_field - 1])
161                } else {
162                    label
163                };
164                out.push_str(&format!(
165                    "│ {:>6} │ {:>4} │ {:>5} │ {:>2} │ {:<col_field$}│\n",
166                    offset, size, align, cl, label
167                ));
168            }
169            Row::Pad {
170                offset,
171                size,
172                trailing,
173            } => {
174                let cl = offset / cache_line;
175                let label = if *trailing {
176                    "<padding> (trailing)".to_string()
177                } else {
178                    "<padding>".to_string()
179                };
180                out.push_str(&format!(
181                    "│ {:>6} │ {:>4} │ {:>5} │ {:>2} │ {:<col_field$}│\n",
182                    offset, size, "—", cl, label
183                ));
184            }
185            Row::CacheLine {
186                line_number,
187                offset,
188            } => {
189                let label = format!("── cache line {line_number} (offset {offset}) ");
190                // Pad to fill the inner width with '═' characters.
191                let used = label.len();
192                let pad = if cache_sep_inner > used + 4 {
193                    "═".repeat(cache_sep_inner - used - 4)
194                } else {
195                    String::new()
196                };
197                out.push_str(&format!("╞{label}{pad}╡\n"));
198            }
199        }
200    }
201
202    out.push_str(&bot);
203    out.push('\n');
204
205    // Summary line — gaps already includes trailing padding from find_padding.
206    let wasted: usize = gaps.iter().map(|g| g.bytes).sum();
207
208    if wasted > 0 && !layout.is_packed && !layout.is_union {
209        let pct = wasted as f64 / layout.total_size as f64 * 100.0;
210        let (opt_size, savings) = reorder::reorder_savings(layout);
211        if savings > 0 {
212            let opt_order: Vec<String> = reorder::optimal_order(layout)
213                .iter()
214                .map(|f| f.name.clone())
215                .collect();
216            out.push_str(&format!(
217                "{} bytes wasted ({:.0}%) — reorder: {} → {} bytes\n",
218                wasted,
219                pct,
220                opt_order.join(", "),
221                opt_size
222            ));
223
224            // Impact block: show concrete memory/cache effects at scale.
225            // Cache line size: default 64 bytes (x86-64 / aarch64).
226            const CACHE_LINE: usize = 64;
227            let impact = estimate_impact(savings, layout.total_size, opt_size, CACHE_LINE);
228            out.push_str(&format!(
229                "  ~{savings} KB extra per 1K instances · ~{savings} MB per 1M \
230                 instances · ~{cl_1m} extra cache lines/1M (seq. scan)\n",
231                cl_1m = fmt_count(impact.extra_cache_lines_1m),
232            ));
233            if impact.reduces_cache_line_crossings() {
234                out.push_str(&format!(
235                    "  Spans {} cache line(s); optimal spans {}\n",
236                    impact.current_cache_lines, impact.optimal_cache_lines,
237                ));
238            }
239        } else {
240            out.push_str(&format!(
241                "{} bytes wasted ({:.0}%) — already in optimal order\n",
242                wasted, pct
243            ));
244        }
245    } else if layout.is_packed {
246        out.push_str("packed — no padding\n");
247    } else {
248        out.push_str("no layout issues — struct is already optimally laid out\n");
249    }
250
251    out
252}
253
254/// Format a large count with K/M suffix for readability.
255fn fmt_count(n: usize) -> String {
256    if n >= 1_000_000 {
257        format!("{}M", n / 1_000_000)
258    } else if n >= 1_000 {
259        format!("{}K", n / 1_000)
260    } else {
261        n.to_string()
262    }
263}
264
265fn type_name(ty: &TypeInfo) -> String {
266    match ty {
267        TypeInfo::Primitive { name, .. } => name.clone(),
268        TypeInfo::Pointer { .. } => "*ptr".to_string(),
269        TypeInfo::Array { element, count, .. } => format!("[{}; {}]", type_name(element), count),
270        TypeInfo::Struct(inner) => inner.name.clone(),
271        TypeInfo::Opaque { name, .. } => name.clone(),
272    }
273}
274
275// ── tests ─────────────────────────────────────────────────────────────────────
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280    use padlock_core::ir::test_fixtures::connection_layout;
281
282    #[test]
283    fn explain_contains_field_names() {
284        let layout = connection_layout();
285        let out = render_explain(&layout);
286        assert!(out.contains("timeout"));
287        assert!(out.contains("port"));
288        assert!(out.contains("is_active"));
289        assert!(out.contains("is_tls"));
290    }
291
292    #[test]
293    fn explain_shows_padding_rows() {
294        let layout = connection_layout();
295        let out = render_explain(&layout);
296        assert!(out.contains("<padding>"));
297    }
298
299    #[test]
300    fn explain_shows_struct_size() {
301        let layout = connection_layout();
302        let out = render_explain(&layout);
303        assert!(out.contains("24 bytes"));
304    }
305
306    #[test]
307    fn explain_shows_reorder_suggestion() {
308        let layout = connection_layout();
309        let out = render_explain(&layout);
310        assert!(out.contains("reorder"));
311        assert!(out.contains("→"));
312    }
313
314    #[test]
315    fn explain_shows_impact_scale_line() {
316        let layout = connection_layout();
317        let out = render_explain(&layout);
318        // Connection saves 8B → should show ~8 KB per 1K and ~8 MB per 1M
319        assert!(out.contains("~8 KB extra per 1K instances"));
320        assert!(out.contains("~8 MB per 1M instances"));
321        assert!(out.contains("extra cache lines/1M"));
322    }
323
324    #[test]
325    fn explain_no_impact_line_when_no_savings() {
326        let layout = padlock_core::ir::test_fixtures::packed_layout();
327        let out = render_explain(&layout);
328        assert!(!out.contains("KB extra per 1K"));
329        assert!(!out.contains("MB per 1M"));
330    }
331
332    #[test]
333    fn explain_shows_cache_line_separator_when_struct_spans_multiple_lines() {
334        use padlock_core::arch::X86_64_SYSV;
335        use padlock_core::ir::{AccessPattern, Field, StructLayout, TypeInfo};
336        // Build a struct that spans two 64-byte cache lines
337        let big = StructLayout {
338            name: "Big".to_string(),
339            total_size: 128,
340            align: 8,
341            fields: vec![
342                Field {
343                    name: "a".to_string(),
344                    ty: TypeInfo::Primitive {
345                        name: "u8[60]".to_string(),
346                        size: 60,
347                        align: 1,
348                    },
349                    offset: 0,
350                    size: 60,
351                    align: 1,
352                    source_file: None,
353                    source_line: None,
354                    access: AccessPattern::Unknown,
355                },
356                Field {
357                    name: "b".to_string(),
358                    ty: TypeInfo::Primitive {
359                        name: "u64".to_string(),
360                        size: 8,
361                        align: 8,
362                    },
363                    offset: 64,
364                    size: 8,
365                    align: 8,
366                    source_file: None,
367                    source_line: None,
368                    access: AccessPattern::Unknown,
369                },
370            ],
371            source_file: None,
372            source_line: None,
373            arch: &X86_64_SYSV,
374            is_packed: false,
375            is_union: false,
376            is_repr_rust: false,
377            suppressed_findings: Vec::new(),
378            uncertain_fields: Vec::new(),
379        };
380        let out = render_explain(&big);
381        assert!(
382            out.contains("cache line 1"),
383            "must show cache line 1 separator: {out}"
384        );
385    }
386
387    #[test]
388    fn explain_shows_cl_column_header() {
389        let layout = connection_layout();
390        let out = render_explain(&layout);
391        assert!(out.contains("CL"), "CL column header must appear");
392    }
393
394    #[test]
395    fn explain_cl_column_shows_zero_for_small_struct() {
396        // connection_layout is 24 bytes — all fields on cache line 0
397        let layout = connection_layout();
398        let out = render_explain(&layout);
399        // Every data row should have │  0 │ (CL 0) and none should show │  1 │
400        assert!(out.contains("│  0 │"), "all fields must be on cache line 0");
401        assert!(
402            !out.contains("│  1 │"),
403            "no field should be on cache line 1"
404        );
405    }
406
407    #[test]
408    fn explain_cl_column_shows_nonzero_for_large_struct() {
409        use padlock_core::arch::X86_64_SYSV;
410        use padlock_core::ir::{AccessPattern, Field, StructLayout, TypeInfo};
411        let big = StructLayout {
412            name: "Big".to_string(),
413            total_size: 128,
414            align: 8,
415            fields: vec![
416                Field {
417                    name: "a".to_string(),
418                    ty: TypeInfo::Primitive {
419                        name: "u8[64]".to_string(),
420                        size: 64,
421                        align: 1,
422                    },
423                    offset: 0,
424                    size: 64,
425                    align: 1,
426                    source_file: None,
427                    source_line: None,
428                    access: AccessPattern::Unknown,
429                },
430                Field {
431                    name: "b".to_string(),
432                    ty: TypeInfo::Primitive {
433                        name: "u64".to_string(),
434                        size: 8,
435                        align: 8,
436                    },
437                    offset: 64,
438                    size: 8,
439                    align: 8,
440                    source_file: None,
441                    source_line: None,
442                    access: AccessPattern::Unknown,
443                },
444            ],
445            source_file: None,
446            source_line: None,
447            arch: &X86_64_SYSV,
448            is_packed: false,
449            is_union: false,
450            is_repr_rust: false,
451            suppressed_findings: Vec::new(),
452            uncertain_fields: Vec::new(),
453        };
454        let out = render_explain(&big);
455        // field 'b' starts at offset 64 → cache line 1
456        assert!(out.contains("│  1 │"), "field b must show CL 1");
457    }
458
459    #[test]
460    fn explain_no_cache_line_separator_for_small_struct() {
461        // Connection (24 bytes) fits in one cache line — no separator expected
462        let layout = connection_layout();
463        let out = render_explain(&layout);
464        assert!(
465            !out.contains("cache line 1"),
466            "single-cache-line struct must not show separator"
467        );
468    }
469
470    #[test]
471    fn fmt_count_formats_correctly() {
472        assert_eq!(fmt_count(999), "999");
473        assert_eq!(fmt_count(1_000), "1K");
474        assert_eq!(fmt_count(125_000), "125K");
475        assert_eq!(fmt_count(1_000_000), "1M");
476        assert_eq!(fmt_count(2_500_000), "2M");
477    }
478}