1use rlx_ir::dynamic::collect_dynamic_symbols;
28use rlx_ir::hir::HirModule;
29use rlx_ir::lir::{LirBufferPlan, LirBufferSlot, LirIoManifest, LirModule, LirViewAlias};
30use rlx_ir::mir::MirModule;
31use rlx_ir::phase::derive_phases;
32use rlx_ir::{Graph, GraphModule, GraphStage, NodeId};
33
34use crate::DeadCodeElimination;
35use crate::debug_assert_graph;
36use crate::fusion_pipeline::{
37 FusionOptions, FusionTarget, fusion_limits_for_target, fusion_passes_for_supported,
38 supported_for_target,
39};
40use crate::fusion_target::with_fusion_target;
41use crate::legalize::{format_legalize_error, legalize_for_backend};
42use crate::memory::{self, MemoryPlan};
43use crate::rewrite::rewrite_for_backend_with_config;
44use rlx_fusion::fusion_report::FusionReport;
45use rlx_fusion::pass::run_passes;
46use rlx_fusion::{clip_elementwise_regions, with_fusion_limits};
47use rlx_ir::OpKind;
48use rlx_ir::logical_kernel::KernelDispatchConfig;
49
50#[derive(Debug, Clone)]
52pub struct CompileResult {
53 pub lir: LirModule,
54 pub fusion: FusionReport,
55}
56
57impl CompileResult {
58 pub fn has_dynamic_dims(&self) -> bool {
59 self.lir.has_dynamic_dims()
60 }
61
62 pub fn dynamic_symbols(&self) -> &[u32] {
63 self.lir.dynamic_symbols()
64 }
65
66 pub fn specialize(&self, pipeline: &CompilePipeline, binding: &rlx_ir::DimBinding) -> Self {
68 Self {
69 lir: pipeline.specialize_lir(&self.lir, binding),
70 fusion: self.fusion.clone(),
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy)]
77pub struct CompilePipeline {
78 pub target: FusionTarget,
79 pub opts: FusionOptions,
80 pub arena_alignment: usize,
81 pub assert_fusion_clean: bool,
84 pub supported_ops: Option<&'static [OpKind]>,
88 pub backend_label: Option<&'static str>,
92 pub kernel_dispatch: KernelDispatchConfig,
94}
95
96impl Default for CompilePipeline {
97 fn default() -> Self {
98 Self {
99 target: FusionTarget::Cpu,
100 opts: FusionOptions::for_cpu(),
101 arena_alignment: 64,
102 assert_fusion_clean: false,
103 supported_ops: None,
104 backend_label: None,
105 kernel_dispatch: KernelDispatchConfig::from_env(),
106 }
107 }
108}
109
110fn lstm_y_shape(x: &rlx_ir::Shape, hidden_size: usize, bidirectional: bool) -> rlx_ir::Shape {
111 let dirs = if bidirectional { 2 } else { 1 };
112 if x.rank() == 3 {
113 let seq = x.dim(0).unwrap_static();
114 let batch = x.dim(1).unwrap_static().max(1);
115 return rlx_ir::Shape::new(&[seq, dirs, batch, hidden_size], x.dtype());
116 }
117 rlx_ir::Shape::new(&[1, dirs, 1, hidden_size], x.dtype())
118}
119
120fn fix_import_lstm_x_shape(x: &rlx_ir::Shape) -> rlx_ir::Shape {
122 if x.rank() != 3 {
123 return x.clone();
124 }
125 let d0 = x.dim(0).unwrap_static();
126 let d1 = x.dim(1).unwrap_static();
127 let d2 = x.dim(2).unwrap_static();
128 if d0 == 1 && d1 <= 1 && (d2 == 640 || d2 == 512) {
129 let seq = std::env::var("RLX_ONNX_SEQUENCE_LENGTH")
130 .ok()
131 .and_then(|s| s.parse().ok())
132 .unwrap_or(128);
133 return rlx_ir::Shape::new(&[seq, d1.max(1), d2], x.dtype());
134 }
135 x.clone()
136}
137
138fn fix_lstm_output_shapes(graph: &mut Graph) {
139 use rlx_ir::Op;
140 let ids: Vec<NodeId> = graph.nodes().iter().map(|n| n.id).collect();
141 for id in ids {
142 let node = graph.node(id).clone();
143 let Op::Custom { name, attrs, .. } = &node.op else {
144 continue;
145 };
146 if !name.contains("LSTM") {
147 continue;
148 }
149 let hidden_size = if attrs.len() >= 4 {
150 u32::from_le_bytes(attrs[0..4].try_into().unwrap()) as usize
151 } else {
152 256
153 };
154 let bidirectional = attrs.len() > 4 && attrs[4] != 0;
155 let x_id = node.inputs[0];
156 let x = fix_import_lstm_x_shape(&graph.node(x_id).shape);
157 graph.node_mut(x_id).shape = x.clone();
158 graph.node_mut(id).shape = lstm_y_shape(&x, hidden_size, bidirectional);
159 }
160}
161
162fn fix_import_sequence_axis(graph: &mut Graph) {
168 let Ok(seq_str) = std::env::var("RLX_ONNX_SEQUENCE_LENGTH") else {
169 return;
170 };
171 let seq: usize = match seq_str.parse() {
172 Ok(s) if s > 1 => s,
173 _ => return,
174 };
175 for id in graph.nodes().iter().map(|n| n.id).collect::<Vec<_>>() {
176 let node = graph.node(id);
177 if node.shape.rank() != 3 {
178 continue;
179 }
180 let dims: Vec<_> = node
181 .shape
182 .dims()
183 .iter()
184 .map(|d| d.unwrap_static())
185 .collect();
186 if dims[0] == 1 && dims[1] == 1 && dims[2] >= 64 {
187 graph.node_mut(id).shape = rlx_ir::Shape::new(&[1, seq, dims[2]], node.shape.dtype());
188 }
189 }
190 for id in graph.topo_order().collect::<Vec<_>>() {
191 let node = graph.node(id).clone();
192 if let Some(shape) = rlx_ir::infer_shape::infer_output_shape(graph, &node) {
193 graph.node_mut(id).shape = shape;
194 }
195 }
196}
197
198impl CompilePipeline {
199 pub fn new(target: FusionTarget) -> Self {
200 let mut opts = match target {
201 FusionTarget::Cpu => FusionOptions::for_cpu(),
202 FusionTarget::Metal => FusionOptions::for_metal(),
203 FusionTarget::Wgpu => FusionOptions::for_wgpu(),
204 _ => FusionOptions::default(),
205 };
206 opts.fusion_limits = fusion_limits_for_target(target);
207 Self {
208 target,
209 opts,
210 ..Self::default()
211 }
212 }
213
214 pub fn with_assert_fusion_clean(mut self, assert: bool) -> Self {
215 self.assert_fusion_clean = assert;
216 self
217 }
218
219 pub fn lower_hir(hir: HirModule) -> Result<MirModule, rlx_ir::hir::LowerError> {
221 let mut mir = hir.lower_to_mir()?;
222 rlx_ir::dynamic::sync_graph_shapes(mir.as_graph_mut());
223 debug_assert_graph!(mir.as_graph(), "hir→mir");
224 Ok(mir)
225 }
226
227 pub fn preprocess_mir(mir: MirModule) -> MirModule {
229 use rlx_fusion::pass::Pass as _;
230 let graph = rlx_fusion::control_flow::LowerControlFlow.run(mir.into_graph());
231 let graph = DeadCodeElimination.run(graph);
232 MirModule::from_graph(graph)
233 }
234
235 pub fn with_supported_ops(mut self, ops: &'static [OpKind]) -> Self {
236 self.supported_ops = Some(ops);
237 self
238 }
239
240 pub fn with_backend_label(mut self, label: &'static str) -> Self {
241 self.backend_label = Some(label);
242 self
243 }
244
245 pub fn with_kernel_dispatch(
246 mut self,
247 policy: rlx_ir::logical_kernel::KernelDispatchPolicy,
248 ) -> Self {
249 self.kernel_dispatch.policy = policy;
250 self
251 }
252
253 pub fn with_kernel_dispatch_config(mut self, config: KernelDispatchConfig) -> Self {
254 self.kernel_dispatch = config;
255 self
256 }
257
258 fn effective_supported(&self) -> &'static [OpKind] {
259 self.supported_ops
260 .unwrap_or_else(|| supported_for_target(self.target))
261 }
262
263 fn backend_name(&self) -> &'static str {
264 if let Some(label) = self.backend_label {
265 return label;
266 }
267 match self.target {
268 FusionTarget::Cpu => "cpu",
269 FusionTarget::Metal => "metal",
270 FusionTarget::Mlx => "mlx",
271 FusionTarget::Wgpu => "wgpu",
272 FusionTarget::Cuda => "cuda",
273 FusionTarget::Rocm => "rocm",
274 FusionTarget::Tpu => "tpu",
275 }
276 }
277
278 pub fn optimize_with_report(&self, mir: MirModule) -> (MirModule, FusionReport) {
280 let need_fusion_diff = self.assert_fusion_clean || rlx_ir::env::flag("RLX_FUSION_REPORT");
281 let before = need_fusion_diff.then(|| mir.as_graph().clone());
282 let passes =
283 fusion_passes_for_supported(self.effective_supported(), self.opts, self.target);
284 let limits = self.opts.fusion_limits;
285 let graph = with_fusion_target(self.target, || {
286 with_fusion_limits(limits, || run_passes(mir.into_graph(), &passes, false))
287 });
288 let graph = clip_elementwise_regions(graph, limits);
289 debug_assert_graph!(&graph, "fusion");
290 let graph = rlx_fusion::pass::run_registered_ir_passes(graph);
294 let mut graph = self.legalize_after_fusion(graph);
295 rlx_ir::dynamic::sync_graph_shapes(&mut graph);
296 fix_import_sequence_axis(&mut graph);
297 fix_lstm_output_shapes(&mut graph);
298 debug_assert_graph!(&graph, "legalize");
299 let mir = MirModule::from_graph(graph);
300 if rlx_ir::env::flag("RLX_LINT_NUMERICS") {
303 for lint in crate::numeric_lint::lint_numerics(mir.as_graph()) {
304 eprintln!("rlx numeric-lint: {lint}");
305 }
306 }
307 let fusion = if let Some(ref before) = before {
308 rlx_fusion::FusionReport::analyze(before, mir.as_graph())
309 } else {
310 rlx_fusion::FusionReport::scan(mir.as_graph())
311 };
312 (mir, fusion)
313 }
314
315 pub(crate) fn legalize_after_fusion(&self, graph: Graph) -> Graph {
319 let Some(supported) = self.supported_ops else {
320 if self.kernel_dispatch.force_common_kinds.is_empty()
321 && self.kernel_dispatch.policy
322 == rlx_ir::logical_kernel::KernelDispatchPolicy::PreferNative
323 {
324 return graph;
325 }
326 return rewrite_for_backend_with_config(graph, &[], self.kernel_dispatch);
327 };
328 if supported.is_empty() {
329 return graph;
330 }
331 let graph = rewrite_for_backend_with_config(graph, supported, self.kernel_dispatch);
332 if let Err(errors) = legalize_for_backend(&graph, supported) {
333 panic!("{}", format_legalize_error(self.backend_name(), &errors));
334 }
335 graph
336 }
337
338 pub fn optimize(&self, mir: MirModule) -> MirModule {
340 self.optimize_with_report(mir).0
341 }
342
343 pub fn plan_lir(&self, mir: MirModule) -> LirModule {
345 self.plan_lir_with_options(mir, memory::MemoryPlanOptions::default())
346 }
347
348 pub fn plan_lir_with_options(
350 &self,
351 mir: MirModule,
352 opts: memory::MemoryPlanOptions,
353 ) -> LirModule {
354 let graph = mir.as_graph();
355 let plan = memory::plan_memory_with_options(graph, self.arena_alignment, opts);
356 let buffers = lir_buffer_plan_from_memory(graph, &plan, self.arena_alignment);
357 LirModule::new(mir, buffers)
358 }
359
360 pub fn specialize_lir(&self, lir: &LirModule, binding: &rlx_ir::DimBinding) -> LirModule {
362 use rlx_ir::dynamic::{
363 bind_graph, sync_concat_shapes, sync_expand_ops, sync_graph_shapes, sync_narrow_ops,
364 sync_reshape_ops,
365 };
366 let mut bound = bind_graph(lir.as_graph(), binding);
367 sync_reshape_ops(&mut bound);
368 sync_concat_shapes(&mut bound);
369 sync_narrow_ops(&mut bound);
370 sync_expand_ops(&mut bound);
371 sync_graph_shapes(&mut bound);
372 debug_assert_graph!(&bound, "specialize");
373 self.plan_lir(MirModule::from_graph(bound))
374 }
375
376 fn finish(&self, mir: MirModule, fusion: FusionReport) -> CompileResult {
377 debug_assert_graph!(mir.as_graph(), "pre-lir");
378 if self.assert_fusion_clean && !fusion.missed.is_empty() {
379 panic!(
380 "fusion contract violated: {} missed patterns\n{fusion}",
381 fusion.missed.len()
382 );
383 }
384 CompileResult {
385 lir: self.plan_lir(mir),
386 fusion,
387 }
388 }
389
390 pub fn compile_hir(&self, hir: HirModule) -> Result<CompileResult, rlx_ir::hir::LowerError> {
392 if rlx_ir::env::var("RLX_IR_DUMP").is_some() {
393 let name = hir.name.clone();
394 let dump = crate::inspect::inspect_pipeline(self, hir.clone())?;
395 crate::inspect::maybe_dump_pipeline(&dump, &name);
396 }
397 let dbg = rlx_ir::env::var("RLX_PHASE_TIMING").is_some();
398 #[cfg(not(target_arch = "wasm32"))]
399 let t = if dbg {
400 Some(std::time::Instant::now())
401 } else {
402 None
403 };
404 let mir = Self::lower_hir(hir)?;
405 #[cfg(not(target_arch = "wasm32"))]
406 if let Some(t) = t {
407 eprintln!("[phase] lower_hir = {}ms", t.elapsed().as_millis());
408 }
409 #[cfg(not(target_arch = "wasm32"))]
410 let t = if dbg {
411 Some(std::time::Instant::now())
412 } else {
413 None
414 };
415 let (mir, fusion) = self.optimize_with_report(mir);
416 #[cfg(not(target_arch = "wasm32"))]
417 if let Some(t) = t {
418 eprintln!("[phase] optimize = {}ms", t.elapsed().as_millis());
419 }
420 #[cfg(not(target_arch = "wasm32"))]
421 let t = if dbg {
422 Some(std::time::Instant::now())
423 } else {
424 None
425 };
426 let r = self.finish(mir, fusion);
427 #[cfg(not(target_arch = "wasm32"))]
428 if let Some(t) = t {
429 eprintln!("[phase] finish(plan+lir) = {}ms", t.elapsed().as_millis());
430 }
431 Ok(r)
432 }
433
434 pub fn compile_mir(&self, mir: MirModule) -> CompileResult {
436 let (mir, fusion) = self.optimize_with_report(mir);
437 self.finish(mir, fusion)
438 }
439
440 pub fn compile_graph(&self, graph: Graph) -> CompileResult {
442 self.compile_mir(MirModule::from_graph(graph))
443 }
444
445 pub fn compile_module(
447 &self,
448 module: GraphModule,
449 ) -> Result<CompileResult, rlx_ir::hir::LowerError> {
450 match module.stage() {
451 GraphStage::Hir => {
452 let hir = module
453 .into_hir()
454 .expect("GraphModule stage() / into_hir mismatch");
455 self.compile_hir(hir)
456 }
457 GraphStage::Mir => {
458 let mir = module.into_mir()?;
459 Ok(self.compile_mir(mir))
460 }
461 GraphStage::Lir => Ok(CompileResult {
462 lir: module
463 .into_lir()
464 .expect("GraphModule stage() / into_lir mismatch"),
465 fusion: FusionReport::default(),
466 }),
467 }
468 }
469}
470
471impl From<&MemoryPlan> for LirBufferPlan {
472 fn from(plan: &MemoryPlan) -> Self {
473 LirBufferPlan {
474 arena_size: plan.arena_size,
475 assignments: plan
476 .assignments
477 .iter()
478 .map(|(id, slot)| {
479 (
480 *id,
481 LirBufferSlot {
482 offset: slot.offset,
483 size: slot.size,
484 },
485 )
486 })
487 .collect(),
488 schedule: plan.schedule.clone(),
489 ..Default::default()
490 }
491 }
492}
493
494impl From<&LirBufferPlan> for MemoryPlan {
495 fn from(plan: &LirBufferPlan) -> Self {
496 MemoryPlan {
497 arena_size: plan.arena_size,
498 assignments: plan
499 .assignments
500 .iter()
501 .map(|(id, slot)| {
502 (
503 *id,
504 memory::BufferSlot {
505 offset: slot.offset,
506 size: slot.size,
507 },
508 )
509 })
510 .collect(),
511 schedule: plan.schedule.clone(),
512 }
513 }
514}
515
516pub(crate) fn lir_buffer_plan_from_memory(
517 graph: &Graph,
518 plan: &MemoryPlan,
519 alignment: usize,
520) -> LirBufferPlan {
521 let view_aliases = memory::collect_view_aliases(graph)
522 .into_iter()
523 .map(|(id, (root, byte_offset))| (id, LirViewAlias { root, byte_offset }))
524 .collect();
525 LirBufferPlan {
526 arena_size: plan.arena_size,
527 assignments: plan
528 .assignments
529 .iter()
530 .map(|(id, slot)| {
531 (
532 *id,
533 LirBufferSlot {
534 offset: slot.offset,
535 size: slot.size,
536 },
537 )
538 })
539 .collect(),
540 schedule: plan.schedule.clone(),
541 view_aliases,
542 phases: derive_phases(graph),
543 io: LirIoManifest::collect(graph),
544 alignment,
545 dynamic_symbols: collect_dynamic_symbols(graph),
546 }
547}
548
549#[cfg(test)]
550mod tests {
551 use super::*;
552 use rlx_ir::DType;
553 use rlx_ir::Op;
554 use rlx_ir::Shape;
555 use rlx_ir::hir::FusionPolicy;
556
557 fn f32_shape(d: &[usize]) -> Shape {
558 Shape::new(d, DType::F32)
559 }
560
561 #[test]
562 fn pipeline_hir_to_lir() {
563 let mut hir = HirModule::new("layer");
564 let x = hir.input("x", f32_shape(&[2, 128]));
565 let w = hir.param("w", f32_shape(&[128, 128]));
566 let b = hir.param("b", f32_shape(&[128]));
567 let h = hir.linear(x, w, Some(b), None, f32_shape(&[2, 128]));
568 hir.outputs = vec![h];
569
570 let pipe = CompilePipeline::new(FusionTarget::Cpu);
571 let result = pipe.compile_hir(hir).expect("compile");
572 assert!(result.lir.mir.len() <= 5);
573 assert!(result.lir.arena_size() > 0);
574 assert!(result.lir.buffers.bytes_saved() <= result.lir.buffers.total_unshared_bytes());
575 assert!(result.fusion.fused_matmul_bias_act >= 1 || result.lir.mir.len() <= 5);
576 }
577
578 #[test]
579 fn direct_hir_swiglu_emits_fused_op() {
580 let mut hir = HirModule::new("ffn");
581 let x = hir.input("x", f32_shape(&[4, 768]));
582 let up_w = hir.param("up", f32_shape(&[768, 2048]));
583 let gate_w = hir.param("gate", f32_shape(&[768, 2048]));
584 let down_w = hir.param("down", f32_shape(&[2048, 768]));
585 let out = hir.swiglu_ffn(x, up_w, gate_w, down_w, f32_shape(&[4, 768]));
586 hir.outputs = vec![out];
587
588 let pipe = CompilePipeline::new(FusionTarget::Cpu);
589 let result = pipe.compile_hir(hir).expect("compile");
590 let g = result.lir.mir.as_graph();
591 assert!(
592 g.nodes()
593 .iter()
594 .any(|n| matches!(n.op, Op::FusedSwiGLU { .. })),
595 "direct HIR SwiGLU should lower to FusedSwiGLU"
596 );
597 assert!(result.fusion.missed_matmul_bias_act() == 0 || result.fusion.fused_swiglu >= 1);
598 }
599
600 #[test]
601 fn compile_module_from_graph_define() {
602 let module = GraphModule::define("ffn", |m| {
603 let x = m.input("x", f32_shape(&[2, 64]));
604 let w = m.param("w", f32_shape(&[64, 64]));
605 m.linear(x, w, None, None, f32_shape(&[2, 64]))
606 });
607 assert_eq!(module.stage(), GraphStage::Hir);
608
609 let pipe = CompilePipeline::new(FusionTarget::Cpu);
610 let result = pipe.compile_module(module).expect("compile_module");
611 assert!(result.lir.arena_size() > 0);
612 }
613
614 #[test]
615 fn fusable_policy_leaves_room_for_passes() {
616 let mut hir = HirModule::new("ffn").with_fusion_policy(FusionPolicy::Fusable);
617 let x = hir.input("x", f32_shape(&[4, 768]));
618 let up_w = hir.param("up", f32_shape(&[768, 2048]));
619 let gate_w = hir.param("gate", f32_shape(&[768, 2048]));
620 let down_w = hir.param("down", f32_shape(&[2048, 768]));
621 let out = hir.swiglu_ffn(x, up_w, gate_w, down_w, f32_shape(&[4, 768]));
622 hir.outputs = vec![out];
623
624 let mir = CompilePipeline::lower_hir(hir).expect("lower");
625 let g = mir.as_graph();
626 assert!(g.nodes().iter().any(|n| matches!(n.op, Op::MatMul)));
627 assert_eq!(g.len(), 9);
628
629 let pipe = CompilePipeline::new(FusionTarget::Cpu);
630 let result = pipe.compile_mir(mir);
631 assert!(result.fusion.fused_swiglu >= 1);
632 }
633
634 #[test]
635 fn lir_plan_includes_phases_io_and_fingerprint() {
636 use rlx_ir::phase::Phase;
637
638 let mut hir = HirModule::new("stream");
639 let x = hir.input("x", f32_shape(&[1, 8]));
640 let w = hir.param("w", f32_shape(&[8, 4]));
641 let mm = hir.linear(x, w, None, None, f32_shape(&[1, 4]));
642 hir.set_outputs(vec![mm]);
643
644 let result = CompilePipeline::new(FusionTarget::Cpu)
645 .compile_hir(hir)
646 .expect("compile");
647 assert!(!result.lir.buffers.phases.is_empty());
648 let input_id = result.lir.buffers.io.inputs[0].1;
649 assert_eq!(
650 result.lir.buffers.phases.get(input_id),
651 Some(Phase::Prologue)
652 );
653 assert_eq!(result.lir.buffers.io.inputs.len(), 1);
654 assert_eq!(result.lir.fingerprint(), result.lir.fingerprint());
655 assert_eq!(result.lir.buffers.alignment, 64);
656 }
657
658 #[test]
659 fn numeric_lint_catches_const_div_by_zero_through_pipeline() {
660 let mut g = Graph::new("bad");
664 let one = g.add_node(
665 Op::Constant {
666 data: 1.0f32.to_le_bytes().to_vec(),
667 },
668 vec![],
669 f32_shape(&[1]),
670 );
671 let zero = g.add_node(
672 Op::Constant {
673 data: 0.0f32.to_le_bytes().to_vec(),
674 },
675 vec![],
676 f32_shape(&[1]),
677 );
678 let d = g.binary(rlx_ir::op::BinaryOp::Div, one, zero, f32_shape(&[1]));
679 g.set_outputs(vec![d]);
680
681 let result = CompilePipeline::new(FusionTarget::Cpu).compile_graph(g);
682 let lints = crate::numeric_lint::lint_numerics(result.lir.mir.as_graph());
683 assert!(
684 !lints.is_empty(),
685 "compiled graph should surface the div-by-zero as a numeric lint"
686 );
687 }
688
689 #[test]
690 fn decode_hidden_shape_not_expanded_without_env() {
691 let mut g = Graph::new("decode_out");
694 let x = g.input("x", f32_shape(&[1, 1, 1024]));
695 g.set_outputs(vec![x]);
696 let pipe = CompilePipeline::new(FusionTarget::Cpu);
697 let result = pipe.compile_graph(g);
698 let out = result
699 .lir
700 .mir
701 .as_graph()
702 .node(result.lir.mir.as_graph().outputs[0]);
703 assert_eq!(out.shape.dims()[1].unwrap_static(), 1);
704 assert_eq!(out.shape.num_elements(), Some(1024));
705 }
706
707 #[test]
708 fn dynamic_graph_compiles_and_specializes() {
709 use rlx_ir::DimBinding;
710 use rlx_ir::infer::GraphExt as _;
711 use rlx_ir::sym;
712
713 let mut g = Graph::new("dyn");
714 let x = g.input("x", Shape::batch_seq_2d(sym::BATCH, sym::SEQ, DType::F32));
715 let w = g.param("w", Shape::new(&[4, 8], DType::F32));
716 let y = g.mm(x, w);
717 g.set_outputs(vec![y]);
718
719 let pipe = CompilePipeline::new(FusionTarget::Cpu);
720 let result = pipe.compile_graph(g);
721 assert!(result.has_dynamic_dims());
722 assert!(result.lir.buffers.dynamic_symbols.contains(&sym::SEQ));
723
724 let bound = result.specialize(&pipe, &DimBinding::batch_seq(2, 16));
725 assert!(bound.lir.is_fully_static());
726 assert!(bound.lir.arena_size() > 0);
727 }
728}