Skip to main content

nxpu_opt/
tiling.rs

1//! Loop tiling and cache blocking.
2//!
3//! Computes tiling plans for MatMul and Conv2D operations to improve
4//! data locality by partitioning work into cache-friendly tile sizes.
5
6use std::fmt;
7
8use nxpu_ir::Module;
9
10use crate::Pass;
11
12/// Configuration for a single tile dimension.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct TileConfig {
15    /// Name of the dimension being tiled (e.g., "M", "N", "K").
16    pub dim_name: String,
17    /// Tile size in elements.
18    pub tile_size: u32,
19}
20
21impl fmt::Display for TileConfig {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        write!(f, "{}={}", self.dim_name, self.tile_size)
24    }
25}
26
27/// A tiling plan for a single operation.
28#[derive(Debug, Clone)]
29pub struct TilingPlan {
30    /// Name of the operation (e.g., "matmul_0").
31    pub op_name: String,
32    /// Per-dimension tile configurations.
33    pub tiles: Vec<TileConfig>,
34    /// Cache reuse factor (ratio of data reuse from tiling; >1.0 means improvement).
35    pub reuse_factor: f64,
36}
37
38impl fmt::Display for TilingPlan {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        write!(f, "TilingPlan({}: ", self.op_name)?;
41        for (i, tile) in self.tiles.iter().enumerate() {
42            if i > 0 {
43                write!(f, ", ")?;
44            }
45            write!(f, "{tile}")?;
46        }
47        write!(f, " reuse={:.2})", self.reuse_factor)
48    }
49}
50
51/// Default tile sizes for common operations.
52#[derive(Debug, Clone)]
53pub struct TilingDefaults {
54    /// Default tile size for MatMul M dimension.
55    pub matmul_m: u32,
56    /// Default tile size for MatMul N dimension.
57    pub matmul_n: u32,
58    /// Default tile size for MatMul K dimension.
59    pub matmul_k: u32,
60    /// Default tile size for Conv2D output height.
61    pub conv2d_oh: u32,
62    /// Default tile size for Conv2D output width.
63    pub conv2d_ow: u32,
64}
65
66impl Default for TilingDefaults {
67    fn default() -> Self {
68        Self {
69            matmul_m: 32,
70            matmul_n: 32,
71            matmul_k: 32,
72            conv2d_oh: 4,
73            conv2d_ow: 4,
74        }
75    }
76}
77
78/// MatMul shape for tiling purposes (numeric dimensions).
79#[derive(Debug, Clone)]
80pub struct MatMulShape {
81    /// Row dimension.
82    pub m: u32,
83    /// Column dimension.
84    pub n: u32,
85    /// Inner/reduction dimension.
86    pub k: u32,
87}
88
89/// Conv2D shape for tiling purposes (numeric dimensions).
90#[derive(Debug, Clone)]
91pub struct Conv2DShape {
92    /// Output height.
93    pub oh: u32,
94    /// Output width.
95    pub ow: u32,
96    /// Kernel height.
97    pub kh: u32,
98    /// Kernel width.
99    pub kw: u32,
100}
101
102/// Compute a tiling plan for a MatMul operation.
103///
104/// Tile sizes are clamped to the actual dimension size when the dimension
105/// is smaller than the default tile size.
106pub fn tile_matmul(shape: &MatMulShape, defaults: &TilingDefaults) -> TilingPlan {
107    let tm = defaults.matmul_m.min(shape.m);
108    let tn = defaults.matmul_n.min(shape.n);
109    let tk = defaults.matmul_k.min(shape.k);
110
111    // Reuse factor: how much more data is reused from L1 cache.
112    // For tiled MatMul: each tile of A is reused N/tn times, each tile of B is reused M/tm times.
113    // Overall reuse factor ≈ min(M/tm, N/tn) * (K/tk) / (K/tk) simplified to:
114    // reuse ≈ (M * N) / (tm * tn) for the output tile reuse.
115    let tiles_m = (shape.m as f64) / (tm as f64);
116    let tiles_n = (shape.n as f64) / (tn as f64);
117    let tiles_k = (shape.k as f64) / (tk as f64);
118    // Each A-tile (tm×tk) is read tiles_n times, each B-tile (tk×tn) is read tiles_m times.
119    // Without tiling: read A once (M×K), read B once (K×N), total = M*K + K*N.
120    // With tiling: read A tiles_n times, read B tiles_m times.
121    // Reuse factor = untiled_reads / tiled_reads? That's <1 since tiling reads MORE.
122    // Actually, reuse factor measures how much a tile is reused from cache:
123    // For K dimension: each (tm,tn) output tile needs tm*tk + tk*tn data per k-step,
124    // producing tm*tn partial results. The inner product over K amortizes loads.
125    // reuse ≈ tm * tn * tk / (tm*tk + tk*tn) = tn*tm / (tm + tn) [when tk factors out]
126    let reuse_factor = if tm + tn > 0 {
127        (tm as f64 * tn as f64) / ((tm + tn) as f64)
128    } else {
129        1.0
130    };
131    let _ = (tiles_m, tiles_n, tiles_k);
132
133    TilingPlan {
134        op_name: "matmul".into(),
135        tiles: vec![
136            TileConfig {
137                dim_name: "M".into(),
138                tile_size: tm,
139            },
140            TileConfig {
141                dim_name: "N".into(),
142                tile_size: tn,
143            },
144            TileConfig {
145                dim_name: "K".into(),
146                tile_size: tk,
147            },
148        ],
149        reuse_factor,
150    }
151}
152
153/// Compute a tiling plan for a Conv2D operation.
154pub fn tile_conv2d(shape: &Conv2DShape, defaults: &TilingDefaults) -> TilingPlan {
155    let toh = defaults.conv2d_oh.min(shape.oh);
156    let tow = defaults.conv2d_ow.min(shape.ow);
157
158    // Reuse factor: each output tile (toh, tow) shares input data from the
159    // receptive field. More tiles reuse kernel weights.
160    let reuse_factor = if toh + tow > 0 {
161        (shape.kh as f64 * shape.kw as f64 * toh as f64 * tow as f64)
162            / ((toh + shape.kh - 1) as f64 * (tow + shape.kw - 1) as f64)
163    } else {
164        1.0
165    };
166
167    TilingPlan {
168        op_name: "conv2d".into(),
169        tiles: vec![
170            TileConfig {
171                dim_name: "OH".into(),
172                tile_size: toh,
173            },
174            TileConfig {
175                dim_name: "OW".into(),
176                tile_size: tow,
177            },
178        ],
179        reuse_factor,
180    }
181}
182
183/// Tiling pass that classifies entry points and computes tiling plans.
184#[derive(Debug)]
185pub struct TilingPass {
186    defaults: TilingDefaults,
187}
188
189impl TilingPass {
190    /// Create a tiling pass with the given defaults.
191    pub fn new(defaults: TilingDefaults) -> Self {
192        Self { defaults }
193    }
194}
195
196impl Default for TilingPass {
197    fn default() -> Self {
198        Self::new(TilingDefaults::default())
199    }
200}
201
202impl Pass for TilingPass {
203    fn name(&self) -> &str {
204        "tiling"
205    }
206
207    fn run(&self, module: &mut Module) -> bool {
208        // Classify each entry point and compute tiling if applicable.
209        let mut any_tiled = false;
210        for i in 0..module.entry_points.len() {
211            if let Ok(pattern) = nxpu_analysis::classify_entry_point(module, i) {
212                match &pattern {
213                    nxpu_analysis::KernelPattern::MatMul { shape, .. } => {
214                        let m = shape.m.parse::<u32>().unwrap_or(256);
215                        let n = shape.n.parse::<u32>().unwrap_or(256);
216                        let k = shape.k.parse::<u32>().unwrap_or(256);
217                        let _plan = tile_matmul(&MatMulShape { m, n, k }, &self.defaults);
218                        any_tiled = true;
219                    }
220                    nxpu_analysis::KernelPattern::Conv2D { shape, .. } => {
221                        let oh = shape.height.parse::<u32>().unwrap_or(32);
222                        let ow = shape.width.parse::<u32>().unwrap_or(32);
223                        let _plan = tile_conv2d(
224                            &Conv2DShape {
225                                oh,
226                                ow,
227                                kh: shape.kernel_h_val as u32,
228                                kw: shape.kernel_w_val as u32,
229                            },
230                            &self.defaults,
231                        );
232                        any_tiled = true;
233                    }
234                    _ => {}
235                }
236            }
237        }
238        any_tiled
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn tile_matmul_default_sizes() {
248        let shape = MatMulShape {
249            m: 1024,
250            n: 1024,
251            k: 1024,
252        };
253        let plan = tile_matmul(&shape, &TilingDefaults::default());
254        assert_eq!(plan.tiles[0].tile_size, 32); // M
255        assert_eq!(plan.tiles[1].tile_size, 32); // N
256        assert_eq!(plan.tiles[2].tile_size, 32); // K
257    }
258
259    #[test]
260    fn tile_matmul_clamps_small_dim() {
261        let shape = MatMulShape {
262            m: 16,
263            n: 1024,
264            k: 1024,
265        };
266        let plan = tile_matmul(&shape, &TilingDefaults::default());
267        assert_eq!(plan.tiles[0].tile_size, 16); // M clamped to 16
268        assert_eq!(plan.tiles[1].tile_size, 32); // N stays 32
269    }
270
271    #[test]
272    fn tile_matmul_reuse_factor() {
273        let shape = MatMulShape {
274            m: 1024,
275            n: 1024,
276            k: 1024,
277        };
278        let plan = tile_matmul(&shape, &TilingDefaults::default());
279        // reuse = (32*32) / (32+32) = 1024/64 = 16.0
280        assert!((plan.reuse_factor - 16.0).abs() < 0.01);
281    }
282
283    #[test]
284    fn tile_conv2d_default_sizes() {
285        let shape = Conv2DShape {
286            oh: 32,
287            ow: 32,
288            kh: 3,
289            kw: 3,
290        };
291        let plan = tile_conv2d(&shape, &TilingDefaults::default());
292        assert_eq!(plan.tiles[0].tile_size, 4); // OH
293        assert_eq!(plan.tiles[1].tile_size, 4); // OW
294    }
295
296    #[test]
297    fn tile_conv2d_clamps_small_spatial() {
298        let shape = Conv2DShape {
299            oh: 2,
300            ow: 32,
301            kh: 3,
302            kw: 3,
303        };
304        let plan = tile_conv2d(&shape, &TilingDefaults::default());
305        assert_eq!(plan.tiles[0].tile_size, 2); // OH clamped
306        assert_eq!(plan.tiles[1].tile_size, 4); // OW stays 4
307    }
308
309    #[test]
310    fn tiling_defaults_sane() {
311        let d = TilingDefaults::default();
312        assert!(d.matmul_m >= 4);
313        assert!(d.matmul_n >= 4);
314        assert!(d.matmul_k >= 4);
315        assert!(d.conv2d_oh >= 4);
316        assert!(d.conv2d_ow >= 4);
317        // All should be powers of 2.
318        assert!(d.matmul_m.is_power_of_two());
319        assert!(d.matmul_n.is_power_of_two());
320        assert!(d.matmul_k.is_power_of_two());
321        assert!(d.conv2d_oh.is_power_of_two());
322        assert!(d.conv2d_ow.is_power_of_two());
323    }
324
325    #[test]
326    fn tiling_plan_display() {
327        let plan = TilingPlan {
328            op_name: "matmul_0".into(),
329            tiles: vec![
330                TileConfig {
331                    dim_name: "M".into(),
332                    tile_size: 32,
333                },
334                TileConfig {
335                    dim_name: "N".into(),
336                    tile_size: 32,
337                },
338            ],
339            reuse_factor: 16.0,
340        };
341        let s = format!("{plan}");
342        assert!(s.contains("matmul_0"));
343        assert!(s.contains("M=32"));
344        assert!(s.contains("N=32"));
345        assert!(s.contains("reuse=16.00"));
346    }
347
348    #[test]
349    fn pass_on_matmul_module() {
350        // Create a module that would classify as MatMul.
351        // Since we can't easily build a full MatMul IR from scratch, test the
352        // pass on an empty module (no entry points → no tiling → false).
353        let mut module = Module::default();
354        let pass = TilingPass::default();
355        let changed = pass.run(&mut module);
356        // No entry points → no tiling.
357        assert!(!changed);
358    }
359
360    #[test]
361    fn pass_noop_on_elementwise() {
362        // Empty module with no entry points → no tiling.
363        let mut module = Module::default();
364        let pass = TilingPass::default();
365        let changed = pass.run(&mut module);
366        assert!(!changed);
367    }
368
369    #[test]
370    fn performance_test() {
371        // Large MatMul should have reuse_factor > 1.0.
372        let shape = MatMulShape {
373            m: 2048,
374            n: 2048,
375            k: 2048,
376        };
377        let plan = tile_matmul(&shape, &TilingDefaults::default());
378        assert!(plan.reuse_factor > 1.0);
379    }
380}