mlx_native/graph.rs
1//! [`GraphExecutor`] — batched Metal dispatch for single-encoder forward passes.
2//!
3//! llama.cpp's speed advantage over candle is NOT the kernels (Phase 0 proved
4//! candle's are as fast or faster per-call). It is the dispatch pattern:
5//! 1 encoder per command buffer instead of ~120. This module implements that
6//! pattern.
7//!
8//! # Usage
9//!
10//! ```ignore
11//! let mut executor = GraphExecutor::new(device.clone());
12//! let mut session = executor.begin()?;
13//!
14//! // All ops encode into the same command buffer — no per-op encoder creation.
15//! session.rms_norm(&mut registry, device.metal_device(), input, weight, output, params, rows, dim)?;
16//! session.quantized_matmul(&mut registry, &device, input, weight, scales, biases, &qparams)?;
17//! session.elementwise_add(&mut registry, device.metal_device(), a, b, out, n, DType::F32)?;
18//!
19//! // Single GPU sync point for the entire forward pass.
20//! session.finish()?;
21//! ```
22//!
23//! # Design
24//!
25//! The `GraphSession` holds a single `CommandEncoder`. Each op method delegates
26//! to the existing op dispatch functions in [`crate::ops`], passing the session's
27//! shared encoder. No new Metal code is needed — the ops already work with a
28//! shared encoder. The executor just prevents creating a new encoder per op.
29//!
30//! # Phase 4e.1 — Graph IR
31//!
32//! The `ComputeGraph` type captures dispatches into a `Vec<CapturedNode>` for
33//! later replay. `GraphExecutor::begin_recorded()` starts a session in capture
34//! mode: all op calls are intercepted at the `CommandEncoder` level and recorded
35//! instead of being sent to Metal. `GraphSession::finish()` detects capture
36//! mode, extracts the recorded graph, and replays it into a fresh encoder via
37//! `ComputeGraph::encode_sequential()`.
38//!
39//! The existing direct-dispatch path (`begin()`) is completely unchanged.
40
41use metal::foreign_types::ForeignType;
42
43use crate::device::MlxDevice;
44use crate::encoder::{CapturedNode, CapturedOpKind, CommandEncoder, MemRange, RecordedBinding};
45use crate::error::Result;
46use crate::kernel_registry::KernelRegistry;
47use crate::ops;
48
49// Re-export types used in the public API so callers don't need separate imports.
50pub use crate::buffer::MlxBuffer;
51pub use crate::dtypes::DType;
52
53// ---------------------------------------------------------------------------
54// OpKind — operation classification for the reorder safety whitelist (4e.3)
55// ---------------------------------------------------------------------------
56
57/// Classification of a compute operation for reorder safety analysis.
58///
59/// Operations marked as reorderable can be freely reordered by the graph
60/// optimizer (Phase 4e.3) as long as their data dependencies allow it.
61/// Non-reorderable operations have side effects or dependencies that
62/// require them to stay in their original sequential position.
63#[derive(Clone, Copy, Debug, PartialEq, Eq)]
64pub enum OpKind {
65 /// Matrix multiplication (reorderable).
66 MatMul,
67 /// Expert-routed matrix multiplication (reorderable).
68 MatMulId,
69 /// Normalization — RMS norm, layer norm (reorderable).
70 Norm,
71 /// Rotary position embedding (reorderable).
72 Rope,
73 /// Elementwise ops — add, mul, scale, gelu, softcap, etc. (reorderable).
74 Elementwise,
75 /// Memory copy — KV cache copy, embedding gather (reorderable).
76 Copy,
77 /// Gather/scatter (reorderable).
78 Gather,
79 /// Scaled dot-product attention (NOT reorderable).
80 Sdpa,
81 /// Softmax (NOT reorderable).
82 Softmax,
83 /// MoE gate with CPU readback dependency (NOT reorderable).
84 MoeGate,
85 /// Anything else (NOT reorderable).
86 Other,
87}
88
89impl OpKind {
90 /// Whether this op kind is safe to reorder in the graph optimizer.
91 pub fn is_reorderable(&self) -> bool {
92 matches!(
93 self,
94 Self::MatMul
95 | Self::MatMulId
96 | Self::Norm
97 | Self::Rope
98 | Self::Elementwise
99 | Self::Copy
100 | Self::Gather
101 )
102 }
103}
104
105// ---------------------------------------------------------------------------
106// ComputeGraph — the recorded graph IR
107// ---------------------------------------------------------------------------
108
109/// A recorded sequence of GPU compute dispatches and barriers.
110///
111/// Created by running a forward pass with the encoder in capture mode.
112/// Can be replayed into a real `CommandEncoder` via `encode_sequential()`,
113/// producing identical Metal dispatch behavior to the original direct path.
114///
115/// Future phases (4e.2, 4e.3) will add fusion and reorder passes that
116/// transform the graph before encoding.
117pub struct ComputeGraph {
118 nodes: Vec<CapturedNode>,
119}
120
121impl ComputeGraph {
122 /// Create an empty compute graph.
123 pub fn new() -> Self {
124 Self {
125 nodes: Vec::with_capacity(128),
126 }
127 }
128
129 /// Create a compute graph from a pre-built list of captured nodes.
130 pub fn from_nodes(nodes: Vec<CapturedNode>) -> Self {
131 Self { nodes }
132 }
133
134 /// Record a captured node into the graph.
135 pub fn record(&mut self, node: CapturedNode) {
136 self.nodes.push(node);
137 }
138
139 /// Number of nodes (dispatches + barriers) in the graph.
140 pub fn len(&self) -> usize {
141 self.nodes.len()
142 }
143
144 /// Whether the graph contains no nodes.
145 pub fn is_empty(&self) -> bool {
146 self.nodes.is_empty()
147 }
148
149 /// Number of dispatch nodes (excludes barriers).
150 pub fn dispatch_count(&self) -> usize {
151 self.nodes
152 .iter()
153 .filter(|n| matches!(n, CapturedNode::Dispatch { .. }))
154 .count()
155 }
156
157 /// Number of barrier nodes.
158 pub fn barrier_count(&self) -> usize {
159 self.nodes
160 .iter()
161 .filter(|n| matches!(n, CapturedNode::Barrier))
162 .count()
163 }
164
165 /// Borrow the node list.
166 pub fn nodes(&self) -> &[CapturedNode] {
167 &self.nodes
168 }
169
170 /// Count dispatch nodes that have empty read/write range annotations.
171 ///
172 /// Used for diagnostics: if >0, the reorder pass cannot guarantee
173 /// correctness because it relies on complete annotations.
174 pub fn unannotated_dispatch_count(&self) -> usize {
175 self.nodes
176 .iter()
177 .filter(|n| matches!(n, CapturedNode::Dispatch { reads, writes, .. }
178 if reads.is_empty() || writes.is_empty()))
179 .count()
180 }
181
182 /// Take ownership of the node list, consuming the graph.
183 pub fn into_nodes(self) -> Vec<CapturedNode> {
184 self.nodes
185 }
186
187 /// Encode all nodes sequentially into the given encoder.
188 ///
189 /// Barrier sentinel nodes emit a Metal memory barrier. Dispatch nodes
190 /// are replayed through `CommandEncoder::replay_dispatch()`.
191 ///
192 /// This produces identical GPU behavior to the direct-dispatch path —
193 /// same pipeline bindings, same dispatch dimensions, same barrier
194 /// placement.
195 ///
196 /// Returns the number of barriers emitted.
197 pub fn encode_sequential(&self, encoder: &mut CommandEncoder) -> u32 {
198 let mut barrier_count = 0u32;
199 for node in &self.nodes {
200 match node {
201 CapturedNode::Barrier => {
202 encoder.memory_barrier();
203 barrier_count += 1;
204 }
205 CapturedNode::Dispatch {
206 pipeline,
207 bindings,
208 threads_per_grid,
209 threads_per_threadgroup,
210 threadgroup_memory,
211 dispatch_kind,
212 op_kind,
213 ..
214 } => {
215 // ADR-015: forward captured op_kind
216 // into replay_dispatch via pending_op_kind so the
217 // per-dispatch profile entries from a recorded graph
218 // (GraphExecutor::begin_recorded path) are tagged with
219 // the same op label as direct-dispatch encoding.
220 encoder.set_op_kind(*op_kind);
221 encoder.replay_dispatch(
222 pipeline,
223 bindings,
224 threadgroup_memory,
225 *threads_per_grid,
226 *threads_per_threadgroup,
227 *dispatch_kind,
228 );
229 }
230 }
231 }
232 barrier_count
233 }
234
235 /// Encode the graph into a Metal command buffer, computing barriers on the
236 /// fly from each node's read/write buffer ranges.
237 ///
238 /// This is the correct encoding method for reordered graphs where barrier
239 /// sentinels have been stripped. Mirrors llama.cpp's encode-time barrier
240 /// insertion via `ggml_metal_op_concurrency_check`.
241 ///
242 /// Returns the number of barriers emitted.
243 pub fn encode_with_barriers(&self, encoder: &mut CommandEncoder) -> u32 {
244 let mut tracker = ReorderConflictTracker::new();
245 let mut barrier_count = 0u32;
246
247 for node in &self.nodes {
248 match node {
249 CapturedNode::Dispatch {
250 pipeline,
251 bindings,
252 threads_per_grid,
253 threads_per_threadgroup,
254 threadgroup_memory,
255 dispatch_kind,
256 reads,
257 writes,
258 op_kind,
259 ..
260 } => {
261 let has_ranges = !reads.is_empty() || !writes.is_empty();
262 if has_ranges && tracker.conflicts(reads, writes) {
263 encoder.memory_barrier();
264 tracker.reset();
265 barrier_count += 1;
266 }
267 if has_ranges {
268 tracker.add(reads, writes);
269 }
270 // ADR-015: see encode_sequential note.
271 encoder.set_op_kind(*op_kind);
272 encoder.replay_dispatch(
273 pipeline,
274 bindings,
275 threadgroup_memory,
276 *threads_per_grid,
277 *threads_per_threadgroup,
278 *dispatch_kind,
279 );
280 }
281 CapturedNode::Barrier => {
282 // Explicit barriers still force a barrier boundary
283 encoder.memory_barrier();
284 tracker.reset();
285 barrier_count += 1;
286 }
287 }
288 }
289 barrier_count
290 }
291
292 /// Encode the graph using two command buffers for CPU/GPU overlap.
293 ///
294 /// The first `n0` dispatches are encoded into `encoder0` and committed
295 /// immediately (GPU starts executing). The remaining dispatches are encoded
296 /// into `encoder1`. The caller is responsible for committing `encoder1`.
297 ///
298 /// This matches llama.cpp's dual command buffer pattern from
299 /// `ggml_metal_graph_compute` (ggml-metal-context.m:441-644):
300 /// `n_nodes_0 = MAX(64, 0.1 * n_nodes)` for the first buffer.
301 ///
302 /// Command buffers submitted to the same `MTLCommandQueue` execute in
303 /// submission order, so `encoder0.commit()` followed by `encoder1.commit()`
304 /// guarantees enc0 finishes before enc1 starts. The win: the GPU starts
305 /// executing enc0 while the CPU is still encoding enc1.
306 ///
307 /// Returns `(barriers_buf0, barriers_buf1)`.
308 pub fn encode_dual_buffer(
309 &self,
310 encoder0: &mut CommandEncoder,
311 encoder1: &mut CommandEncoder,
312 ) -> (u32, u32) {
313 let dispatch_total = self.dispatch_count();
314 let n0 = std::cmp::max(64, dispatch_total / 10);
315
316 // Find the split point: the index of the n0-th dispatch node.
317 let split_idx = find_dispatch_split_index(&self.nodes, n0);
318
319 // Encode first chunk with barrier recomputation, then commit immediately.
320 let barriers0 = encode_chunk_with_barriers(&self.nodes[..split_idx], encoder0);
321 encoder0.commit();
322
323 // Encode second chunk with barrier recomputation.
324 let barriers1 = encode_chunk_with_barriers(&self.nodes[split_idx..], encoder1);
325
326 (barriers0, barriers1)
327 }
328
329 /// Run the RMS norm + MUL fusion pass over the graph.
330 ///
331 /// Scans for the pattern:
332 /// Dispatch(RmsNorm) → Barrier(s) → Dispatch(ElemMul)
333 /// where the MUL reads the norm's output buffer, and replaces the
334 /// sequence with a single fused `rms_norm_mul_*` dispatch.
335 ///
336 /// The fused dispatch:
337 /// - Reads the norm's input (buffer 0) and weight (buffer 1)
338 /// - Reads the MUL's second operand as the scale (buffer 2)
339 /// - Writes to the MUL's output (buffer 3)
340 /// - Carries the norm's params (buffer 4)
341 /// - Uses the norm's threadgroup config and shared memory
342 ///
343 /// Returns the number of fusions applied.
344 ///
345 /// # Arguments
346 ///
347 /// * `registry` - Kernel registry for compiling the fused pipeline.
348 /// * `device` - Metal device for pipeline compilation.
349 pub fn fuse(
350 &mut self,
351 registry: &mut KernelRegistry,
352 device: &metal::DeviceRef,
353 ) -> Result<u32> {
354 let mut result: Vec<CapturedNode> = Vec::with_capacity(self.nodes.len());
355 let mut fusions = 0u32;
356 let mut i = 0;
357
358 while i < self.nodes.len() {
359 // Check if current node is an RMS norm dispatch.
360 let is_rms_norm = matches!(
361 &self.nodes[i],
362 CapturedNode::Dispatch { op_kind: CapturedOpKind::RmsNorm, .. }
363 );
364
365 if !is_rms_norm {
366 result.push(self.nodes[i].clone());
367 i += 1;
368 continue;
369 }
370
371 // Look ahead: skip barriers, then check for ElemMul.
372 let mut j = i + 1;
373 let mut barrier_count = 0usize;
374 while j < self.nodes.len() && matches!(&self.nodes[j], CapturedNode::Barrier) {
375 barrier_count += 1;
376 j += 1;
377 }
378
379 // Must have at least one barrier and the next node must be ElemMul.
380 if barrier_count == 0 || j >= self.nodes.len() {
381 result.push(self.nodes[i].clone());
382 i += 1;
383 continue;
384 }
385
386 let is_elem_mul = matches!(
387 &self.nodes[j],
388 CapturedNode::Dispatch { op_kind: CapturedOpKind::ElemMul, .. }
389 );
390
391 if !is_elem_mul {
392 result.push(self.nodes[i].clone());
393 i += 1;
394 continue;
395 }
396
397 // Extract norm and mul dispatch fields.
398 let (norm_pipeline, norm_bindings, norm_tpg, norm_tptg, norm_tgmem, norm_dk) =
399 match &self.nodes[i] {
400 CapturedNode::Dispatch {
401 pipeline,
402 bindings,
403 threads_per_grid,
404 threads_per_threadgroup,
405 threadgroup_memory,
406 dispatch_kind,
407 ..
408 } => (pipeline, bindings, threads_per_grid, threads_per_threadgroup, threadgroup_memory, dispatch_kind),
409 _ => unreachable!(),
410 };
411
412 let (mul_bindings, _mul_tpg, _mul_tptg) = match &self.nodes[j] {
413 CapturedNode::Dispatch {
414 bindings,
415 threads_per_grid,
416 threads_per_threadgroup,
417 ..
418 } => (bindings, threads_per_grid, threads_per_threadgroup),
419 _ => unreachable!(),
420 };
421
422 // Verify data dependency: the norm's output buffer (slot 2) must
423 // appear as one of the MUL's input buffers (slot 0 or 1).
424 //
425 // Norm binding layout: (0=input, 1=weight, 2=output, 3=params)
426 // MUL binding layout: (0=a, 1=b, 2=output, 3=params_bytes)
427 let norm_output_ptr = Self::buffer_ptr_for_slot(norm_bindings, 2);
428 let mul_a_ptr = Self::buffer_ptr_for_slot(mul_bindings, 0);
429 let mul_b_ptr = Self::buffer_ptr_for_slot(mul_bindings, 1);
430
431 if norm_output_ptr.is_none() || (norm_output_ptr != mul_a_ptr && norm_output_ptr != mul_b_ptr) {
432 // Data dependency not confirmed — don't fuse.
433 result.push(self.nodes[i].clone());
434 i += 1;
435 continue;
436 }
437
438 // Determine which MUL input is the scale (the one that is NOT
439 // the norm's output).
440 let scale_slot = if norm_output_ptr == mul_a_ptr { 1 } else { 0 };
441
442 // Build fused bindings:
443 // 0 = norm input
444 // 1 = norm weight
445 // 2 = scale (from MUL)
446 // 3 = MUL output
447 // 4 = norm params
448 // Gather all required bindings; bail if any are missing.
449 let (norm_input, norm_weight, scale, mul_output, norm_params) = match (
450 Self::get_binding(norm_bindings, 0),
451 Self::get_binding(norm_bindings, 1),
452 Self::get_binding(mul_bindings, scale_slot),
453 Self::get_binding(mul_bindings, 2),
454 Self::get_binding(norm_bindings, 3),
455 ) {
456 (Some(a), Some(b), Some(c), Some(d), Some(e)) => (a, b, c, d, e),
457 _ => {
458 // Missing bindings — don't fuse.
459 result.push(self.nodes[i].clone());
460 i += 1;
461 continue;
462 }
463 };
464
465 // Select fused pipeline based on the original norm pipeline name.
466 // The norm pipeline name is "rms_norm_f32", "rms_norm_f16", or
467 // "rms_norm_bf16" — we need the corresponding fused pipeline.
468 let fused_name = match Self::fused_pipeline_name(norm_pipeline) {
469 Some(name) => name,
470 None => {
471 result.push(self.nodes[i].clone());
472 i += 1;
473 continue;
474 }
475 };
476
477 let fused_pipeline = registry.get_pipeline(fused_name, device)?;
478
479 let fused_bindings = vec![
480 (0, norm_input),
481 (1, norm_weight),
482 (2, scale),
483 (3, mul_output),
484 (4, norm_params),
485 ];
486
487 // Merge read/write ranges from both the norm and mul nodes for the
488 // fused dispatch. The fused op reads everything the norm reads
489 // plus the mul's scale input, and writes to the mul's output.
490 let (fused_reads, fused_writes) = match (&self.nodes[i], &self.nodes[j]) {
491 (
492 CapturedNode::Dispatch { reads: nr, writes: _nw, .. },
493 CapturedNode::Dispatch { reads: mr, writes: mw, .. },
494 ) => {
495 let mut reads = nr.clone();
496 reads.extend_from_slice(mr);
497 (reads, mw.clone())
498 }
499 _ => (Vec::new(), Vec::new()),
500 };
501
502 result.push(CapturedNode::Dispatch {
503 pipeline: fused_pipeline.to_owned(),
504 bindings: fused_bindings,
505 threads_per_grid: *norm_tpg,
506 threads_per_threadgroup: *norm_tptg,
507 threadgroup_memory: norm_tgmem.clone(),
508 dispatch_kind: *norm_dk,
509 op_kind: CapturedOpKind::Other, // Fused ops are not further fuseable
510 reads: fused_reads,
511 writes: fused_writes,
512 });
513
514 fusions += 1;
515 // Skip past the norm, barrier(s), and mul nodes.
516 i = j + 1;
517 }
518
519 self.nodes = result;
520 Ok(fusions)
521 }
522
523 /// Run the reorder pass over the graph to improve GPU concurrency.
524 ///
525 /// Port of llama.cpp's `ggml_metal_graph_optimize_reorder` — a greedy
526 /// 64-node lookahead that pulls independent dispatches forward to fill
527 /// larger concurrent groups between barriers.
528 ///
529 /// **Prerequisites:** Call `fuse()` first if desired. The reorder pass
530 /// operates on the post-fusion graph. Barrier sentinel nodes are stripped
531 /// before reordering (they will be recomputed at encode time by the
532 /// `ConflictTracker` in `encode_sequential`).
533 ///
534 /// **Algorithm (matching llama.cpp exactly):**
535 /// 1. Strip all `CapturedNode::Barrier` nodes.
536 /// 2. For each unprocessed node `i0`:
537 /// - If it conflicts with the current concurrent group (`mrs0`):
538 /// * Initialize `mrs1` from `i0`'s ranges (skipped-over set)
539 /// * Lookahead up to 64 nodes for candidates that:
540 /// (a) Are reorderable (`CapturedOpKind::is_reorderable()`)
541 /// (b) Don't conflict with `mrs0` (current group)
542 /// (c) Don't conflict with `mrs1` (skipped-over nodes)
543 /// * Pull qualifying candidates into the current group
544 /// * Non-reorderable ops break the lookahead
545 /// - Reset `mrs0` (new concurrent group)
546 /// - Add `i0` to the new group
547 ///
548 /// Returns the number of nodes that were moved to earlier positions.
549 pub fn reorder(&mut self) -> u32 {
550 // Step 1: Strip barrier nodes. After fusion + reorder, barriers will
551 // be recomputed by the ConflictTracker at encode time.
552 self.nodes.retain(|n| !matches!(n, CapturedNode::Barrier));
553
554 let n = self.nodes.len();
555 if n == 0 {
556 return 0;
557 }
558
559 let mut result: Vec<usize> = Vec::with_capacity(n);
560 let mut used = vec![false; n];
561
562 // mrs0: memory ranges for the current concurrent group
563 let mut mrs0 = ReorderConflictTracker::new();
564 // mrs1: memory ranges for skipped-over (unprocessed) nodes
565 let mut mrs1 = ReorderConflictTracker::new();
566
567 const N_FORWARD: usize = 64;
568
569 for i0 in 0..n {
570 if used[i0] {
571 continue;
572 }
573
574 let node0 = &self.nodes[i0];
575
576 // Extract reads/writes for conflict check.
577 let (reads0, writes0, op_kind0) = match node0 {
578 CapturedNode::Dispatch { reads, writes, op_kind, .. } => {
579 (reads.as_slice(), writes.as_slice(), *op_kind)
580 }
581 CapturedNode::Barrier => continue, // stripped, but be safe
582 };
583
584 // Check if node0 conflicts with the current concurrent group.
585 // Empty nodes (no ranges) never conflict — like llama.cpp's is_empty.
586 let has_ranges = !reads0.is_empty() || !writes0.is_empty();
587 if has_ranges && mrs0.conflicts(reads0, writes0) {
588 // Before starting a new group, look forward for nodes that
589 // can be pulled into the CURRENT group.
590 mrs1.reset();
591 mrs1.add(reads0, writes0);
592
593 let end = (i0 + N_FORWARD).min(n);
594 for i1 in (i0 + 1)..end {
595 if used[i1] {
596 continue;
597 }
598
599 let node1 = &self.nodes[i1];
600 let (reads1, writes1, op_kind1) = match node1 {
601 CapturedNode::Dispatch { reads, writes, op_kind, .. } => {
602 (reads.as_slice(), writes.as_slice(), *op_kind)
603 }
604 CapturedNode::Barrier => continue,
605 };
606
607 // Non-reorderable ops break the lookahead.
608 if !op_kind1.is_reorderable() {
609 break;
610 }
611
612 let is_empty1 = reads1.is_empty() && writes1.is_empty();
613
614 // A node can be reordered into the current group if:
615 // 1. It's empty (no ranges) OR doesn't conflict with mrs0
616 // 2. It doesn't conflict with mrs1 (skipped-over nodes)
617 if (is_empty1 || !mrs0.conflicts(reads1, writes1))
618 && !mrs1.conflicts(reads1, writes1)
619 {
620 // Pull into current concurrent group.
621 mrs0.add(reads1, writes1);
622 result.push(i1);
623 used[i1] = true;
624 } else {
625 // Not eligible — expand the skipped-over set.
626 mrs1.add(reads1, writes1);
627 }
628 }
629
630 // Finalize the current concurrent group.
631 mrs0.reset();
632 }
633
634 // Expand the concurrent group with node0.
635 // (Barriers were stripped, so this is always a Dispatch.)
636 let _ = op_kind0; // suppress unused warning
637 mrs0.add(reads0, writes0);
638 result.push(i0);
639 }
640
641 // Apply the permutation to produce the reordered node list.
642 let mut reordered_count = 0u32;
643 for (pos, &orig_idx) in result.iter().enumerate() {
644 if orig_idx != pos {
645 reordered_count += 1;
646 }
647 }
648
649 // Build the reordered nodes vec.
650 let old_nodes = std::mem::take(&mut self.nodes);
651 self.nodes = result.iter().map(|&idx| old_nodes[idx].clone()).collect();
652
653 // Debug dump if requested.
654 if std::env::var("HF2Q_REORDER_DUMP").is_ok() {
655 eprintln!(
656 " [REORDER] nodes={} reordered={} ({:.1}%)",
657 n,
658 reordered_count,
659 100.0 * reordered_count as f64 / n as f64,
660 );
661 }
662
663 reordered_count
664 }
665
666 /// Get the Metal buffer pointer for a binding at the given slot index.
667 ///
668 /// Returns `Some(ptr)` if the slot has a `RecordedBinding::Buffer`,
669 /// `None` otherwise.
670 fn buffer_ptr_for_slot(bindings: &[(u64, RecordedBinding)], slot: u64) -> Option<*const std::ffi::c_void> {
671 for (idx, binding) in bindings {
672 if *idx == slot {
673 if let RecordedBinding::Buffer { metal_buffer, offset: _ } = binding {
674 // Use the Metal buffer's GPU address as the identity key.
675 // On Apple Silicon unified memory, this uniquely identifies
676 // the allocation.
677 let ptr: *const std::ffi::c_void = metal_buffer.as_ptr() as *const _;
678 return Some(ptr);
679 }
680 }
681 }
682 None
683 }
684
685 /// Clone the binding at the given slot index.
686 fn get_binding(bindings: &[(u64, RecordedBinding)], slot: u64) -> Option<RecordedBinding> {
687 for (idx, binding) in bindings {
688 if *idx == slot {
689 return Some(binding.clone());
690 }
691 }
692 None
693 }
694
695 /// Map a norm pipeline to its fused norm+mul pipeline name.
696 ///
697 /// The pipeline's `label()` is set by Metal to the function name, so we
698 /// can match on it. Returns `None` if the pipeline is not a known norm.
699 fn fused_pipeline_name(pipeline: &metal::ComputePipelineState) -> Option<&'static str> {
700 match pipeline.label() {
701 "rms_norm_f32" => Some("rms_norm_mul_f32"),
702 "rms_norm_f16" => Some("rms_norm_mul_f16"),
703 "rms_norm_bf16" => Some("rms_norm_mul_bf16"),
704 _ => None,
705 }
706 }
707}
708
709impl Default for ComputeGraph {
710 fn default() -> Self {
711 Self::new()
712 }
713}
714
715// ---------------------------------------------------------------------------
716// Dual-buffer encoding helpers (Phase 4e.4)
717// ---------------------------------------------------------------------------
718
719/// Find the node index where the n0-th dispatch starts.
720///
721/// Counts `CapturedNode::Dispatch` nodes until `n0` are reached, then returns
722/// the index of the n0-th dispatch (i.e., the first node of the second chunk).
723/// If `n0 >= dispatch_count`, returns `nodes.len()` (everything in chunk 0).
724fn find_dispatch_split_index(nodes: &[CapturedNode], n0: usize) -> usize {
725 let mut dispatches_seen = 0usize;
726 for (i, node) in nodes.iter().enumerate() {
727 if matches!(node, CapturedNode::Dispatch { .. }) {
728 dispatches_seen += 1;
729 if dispatches_seen == n0 {
730 return i + 1; // split AFTER the n0-th dispatch
731 }
732 }
733 }
734 nodes.len()
735}
736
737/// Encode a slice of captured nodes into a command encoder, recomputing
738/// barriers on the fly from each node's read/write buffer ranges.
739///
740/// This is the chunked counterpart of `ComputeGraph::encode_with_barriers()`.
741/// Factored out so both halves of a dual-buffer encode can use it.
742///
743/// Returns the number of barriers emitted.
744fn encode_chunk_with_barriers(nodes: &[CapturedNode], encoder: &mut CommandEncoder) -> u32 {
745 let mut tracker = ReorderConflictTracker::new();
746 let mut barrier_count = 0u32;
747
748 for node in nodes {
749 match node {
750 CapturedNode::Dispatch {
751 pipeline,
752 bindings,
753 threads_per_grid,
754 threads_per_threadgroup,
755 threadgroup_memory,
756 dispatch_kind,
757 reads,
758 writes,
759 op_kind,
760 ..
761 } => {
762 let has_ranges = !reads.is_empty() || !writes.is_empty();
763 if has_ranges && tracker.conflicts(reads, writes) {
764 encoder.memory_barrier();
765 tracker.reset();
766 barrier_count += 1;
767 }
768 if has_ranges {
769 tracker.add(reads, writes);
770 }
771 // ADR-015: forward captured op_kind so the
772 // per-dispatch profile dump groups dual-buffer chunked replay
773 // entries by the same op_kind label as direct dispatch.
774 encoder.set_op_kind(*op_kind);
775 encoder.replay_dispatch(
776 pipeline,
777 bindings,
778 threadgroup_memory,
779 *threads_per_grid,
780 *threads_per_threadgroup,
781 *dispatch_kind,
782 );
783 }
784 CapturedNode::Barrier => {
785 encoder.memory_barrier();
786 tracker.reset();
787 barrier_count += 1;
788 }
789 }
790 }
791 barrier_count
792}
793
794// ---------------------------------------------------------------------------
795// ReorderConflictTracker — range-based conflict detection for the reorder pass
796// ---------------------------------------------------------------------------
797
798/// Memory range conflict tracker for the reorder pass (Phase 4e.3).
799///
800/// Works with `MemRange` tuples `(start, end)` stored on `CapturedNode::Dispatch`,
801/// rather than requiring live `&MlxBuffer` references. This is the reorder-time
802/// equivalent of the runtime `ConflictTracker`.
803///
804/// Conflict rules match llama.cpp's `ggml_mem_ranges_check`:
805/// - Two read ranges: OK (read-read is concurrent-safe)
806/// - A new read overlapping an existing write: CONFLICT (RAW)
807/// - A new write overlapping any existing range: CONFLICT (WAR/WAW)
808struct ReorderConflictTracker {
809 /// (start, end, is_write) for all ranges in the tracked set.
810 ranges: Vec<(usize, usize, bool)>,
811}
812
813impl ReorderConflictTracker {
814 fn new() -> Self {
815 Self {
816 ranges: Vec::with_capacity(64),
817 }
818 }
819
820 fn reset(&mut self) {
821 self.ranges.clear();
822 }
823
824 /// Check if a dispatch with the given read/write ranges conflicts with
825 /// any range in this tracker.
826 fn conflicts(&self, reads: &[MemRange], writes: &[MemRange]) -> bool {
827 // New reads vs existing writes (RAW)
828 for &(r_start, r_end) in reads {
829 for &(s, e, is_write) in &self.ranges {
830 if is_write && r_start < e && r_end > s {
831 return true;
832 }
833 }
834 }
835 // New writes vs all existing ranges (WAR/WAW)
836 for &(w_start, w_end) in writes {
837 for &(s, e, _) in &self.ranges {
838 if w_start < e && w_end > s {
839 return true;
840 }
841 }
842 }
843 false
844 }
845
846 /// Add read and write ranges to the tracked set.
847 fn add(&mut self, reads: &[MemRange], writes: &[MemRange]) {
848 for &(start, end) in reads {
849 self.ranges.push((start, end, false));
850 }
851 for &(start, end) in writes {
852 self.ranges.push((start, end, true));
853 }
854 }
855}
856
857/// Batched Metal dispatch — encodes multiple ops into a single `CommandEncoder`.
858///
859/// Create one per model (or per forward-pass loop). Call [`begin`](Self::begin)
860/// at the start of each forward pass to get a [`GraphSession`] that holds the
861/// shared encoder.
862pub struct GraphExecutor {
863 device: MlxDevice,
864}
865
866impl GraphExecutor {
867 /// Create a new graph executor backed by the given device.
868 pub fn new(device: MlxDevice) -> Self {
869 Self { device }
870 }
871
872 /// Begin a new forward pass (direct-dispatch mode).
873 ///
874 /// Returns a [`GraphSession`] that holds a fresh `CommandEncoder`. All ops
875 /// encoded through the session share this single encoder. Call
876 /// [`GraphSession::finish`] to commit and wait.
877 pub fn begin(&self) -> Result<GraphSession<'_>> {
878 let encoder = self.device.command_encoder()?;
879 // ADR-015: start a programmatic capture if
880 // MLX_METAL_CAPTURE+METAL_CAPTURE_ENABLED are set. No-op when
881 // unset; one-shot per process so subsequent forward passes do
882 // not pay the env-check cost more than once.
883 let metal_capture = {
884 let mut c = crate::metal_capture::MetalCapture::from_env(&self.device);
885 if let Some(ref mut cap) = c {
886 cap.begin();
887 }
888 c
889 };
890 Ok(GraphSession {
891 encoder,
892 device: &self.device,
893 barrier_count: 0,
894 tracker: ConflictTracker::new(),
895 dispatch_in_group: 0,
896 total_dispatches: 0,
897 group_sizes: [0; 8],
898 recording: false,
899 metal_capture,
900 })
901 }
902
903 /// Begin a new forward pass in capture (record) mode.
904 ///
905 /// All op calls are recorded into a `ComputeGraph` instead of being
906 /// dispatched to Metal. When [`GraphSession::finish`] is called, the
907 /// recorded graph is replayed into a fresh encoder via
908 /// `ComputeGraph::encode_sequential()`.
909 ///
910 /// The API is identical to `begin()` — callers do not need to change
911 /// any op call code. The only behavioral difference: GPU work happens
912 /// at `finish()` time rather than at each op call.
913 pub fn begin_recorded(&self) -> Result<GraphSession<'_>> {
914 let mut encoder = self.device.command_encoder()?;
915 encoder.start_capture();
916 // ADR-015: see GraphExecutor::begin().
917 let metal_capture = {
918 let mut c = crate::metal_capture::MetalCapture::from_env(&self.device);
919 if let Some(ref mut cap) = c {
920 cap.begin();
921 }
922 c
923 };
924 Ok(GraphSession {
925 encoder,
926 device: &self.device,
927 barrier_count: 0,
928 tracker: ConflictTracker::new(),
929 dispatch_in_group: 0,
930 total_dispatches: 0,
931 group_sizes: [0; 8],
932 recording: true,
933 metal_capture,
934 })
935 }
936
937 /// Borrow the underlying device.
938 pub fn device(&self) -> &MlxDevice {
939 &self.device
940 }
941}
942
943/// A single forward pass execution context.
944///
945/// All ops are encoded into one `CommandEncoder`. Call [`finish`](Self::finish)
946/// to commit the command buffer and wait for GPU completion — this is the ONLY
947/// sync point per forward pass.
948///
949/// If an op returns an error, the session can be dropped without committing.
950/// The underlying command buffer is abandoned (never committed to the GPU).
951/// Tracks buffer address ranges for automatic barrier elision.
952///
953/// Mirrors llama.cpp's `ggml_mem_ranges` — accumulates the read and write
954/// ranges of all dispatches in the current concurrent group. When a new
955/// dispatch's reads overlap with an existing write (RAW), or its writes
956/// overlap with an existing read or write (WAR/WAW), a barrier is needed.
957/// Otherwise the dispatch can run concurrently and the barrier is elided.
958///
959/// Uses CPU-visible `contents_ptr()` addresses, which on Apple Silicon
960/// unified memory equal the GPU addresses.
961pub struct ConflictTracker {
962 /// (start, end, is_write) tuples for the current concurrent group.
963 ranges: Vec<(usize, usize, bool)>,
964}
965
966impl ConflictTracker {
967 fn new() -> Self {
968 Self {
969 ranges: Vec::with_capacity(32),
970 }
971 }
972
973 /// Reset the tracker — called after emitting a barrier.
974 fn reset(&mut self) {
975 self.ranges.clear();
976 }
977
978 /// Check if a new dispatch with the given reads and writes conflicts
979 /// with the current concurrent group.
980 ///
981 /// Conflict rules (same as llama.cpp `ggml_mem_ranges_check`):
982 /// - Two SRC (read) ranges in the same buffer: OK (read-read)
983 /// - A new SRC overlapping an existing DST: CONFLICT (RAW)
984 /// - A new DST overlapping an existing SRC or DST: CONFLICT (WAR/WAW)
985 /// Check for conflicts and return the reason if one is found.
986 /// Returns (conflict_type, new_buf_ptr, existing_buf_ptr) or None.
987 fn conflicts_reason(&self, reads: &[&MlxBuffer], writes: &[&MlxBuffer])
988 -> Option<(&'static str, usize, usize)>
989 {
990 // Check new reads against existing writes (RAW)
991 for r in reads {
992 let r_start = r.contents_ptr() as usize;
993 let r_end = r_start + r.byte_len();
994 for &(s, e, is_write) in &self.ranges {
995 if is_write && r_start < e && r_end > s {
996 return Some(("RAW", r_start, s));
997 }
998 }
999 }
1000 // Check new writes against existing reads and writes (WAR/WAW)
1001 for w in writes {
1002 let w_start = w.contents_ptr() as usize;
1003 let w_end = w_start + w.byte_len();
1004 for &(s, e, is_write) in &self.ranges {
1005 if w_start < e && w_end > s {
1006 let kind = if is_write { "WAW" } else { "WAR" };
1007 return Some((kind, w_start, s));
1008 }
1009 }
1010 }
1011 None
1012 }
1013
1014 /// Add read and write ranges to the current concurrent group.
1015 fn add(&mut self, reads: &[&MlxBuffer], writes: &[&MlxBuffer]) {
1016 for r in reads {
1017 let start = r.contents_ptr() as usize;
1018 let end = start + r.byte_len();
1019 self.ranges.push((start, end, false));
1020 }
1021 for w in writes {
1022 let start = w.contents_ptr() as usize;
1023 let end = start + w.byte_len();
1024 self.ranges.push((start, end, true));
1025 }
1026 }
1027}
1028
1029pub struct GraphSession<'a> {
1030 encoder: CommandEncoder,
1031 device: &'a MlxDevice,
1032 barrier_count: u32,
1033 tracker: ConflictTracker,
1034 dispatch_in_group: u32,
1035 total_dispatches: u32,
1036 /// Histogram: group_sizes[i] = number of concurrent groups with (i+1) dispatches
1037 group_sizes: [u32; 8],
1038 /// Whether this session was created in capture/record mode.
1039 recording: bool,
1040 /// ADR-015: optional Metal frame capture wrapping
1041 /// this session's GPU work. Populated by `MetalCapture::from_env`
1042 /// when `MLX_METAL_CAPTURE=<path>` + `METAL_CAPTURE_ENABLED=1` are
1043 /// both set in the process env AND the process-global one-shot
1044 /// latch has not yet flipped. `None` in all other cases (default
1045 /// production path). Capture scope is begun in
1046 /// [`GraphExecutor::begin`] / [`GraphExecutor::begin_recorded`]
1047 /// and ended in [`Self::finish`] / [`Self::commit`] BEFORE the
1048 /// CB is committed, so all enqueued CBs land inside the trace.
1049 metal_capture: Option<crate::metal_capture::MetalCapture>,
1050}
1051
1052/// ADR-040 §25 — gated accumulator for time spent in `barrier_between`
1053/// conflict-tracking (HF2Q_BARRIER_NS=1), to split the serial host encode.
1054static BARRIER_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1055static BARRIER_NS_ON: std::sync::LazyLock<bool> =
1056 std::sync::LazyLock::new(|| std::env::var("HF2Q_BARRIER_NS").as_deref() == Ok("1"));
1057
1058/// Total ns accumulated in barrier_between conflict-tracking since the last reset.
1059pub fn barrier_ns() -> u64 {
1060 BARRIER_NS.load(std::sync::atomic::Ordering::Relaxed)
1061}
1062/// Reset the barrier-ns accumulator (call before a timed decode region).
1063pub fn barrier_ns_reset() {
1064 BARRIER_NS.store(0, std::sync::atomic::Ordering::Relaxed);
1065}
1066
1067impl<'a> GraphSession<'a> {
1068 /// Encode an RMS normalization into this session's encoder.
1069 ///
1070 /// Delegates to [`ops::rms_norm::dispatch_rms_norm`].
1071 pub fn rms_norm(
1072 &mut self,
1073 registry: &mut KernelRegistry,
1074 device: &metal::DeviceRef,
1075 input: &MlxBuffer,
1076 weight: &MlxBuffer,
1077 output: &MlxBuffer,
1078 params_buf: &MlxBuffer,
1079 rows: u32,
1080 dim: u32,
1081 ) -> Result<()> {
1082 ops::rms_norm::dispatch_rms_norm(
1083 &mut self.encoder,
1084 registry,
1085 device,
1086 input,
1087 weight,
1088 output,
1089 params_buf,
1090 rows,
1091 dim,
1092 )
1093 }
1094
1095 /// Encode a quantized matrix multiplication into this session's encoder.
1096 ///
1097 /// Delegates to [`ops::quantized_matmul::quantized_matmul`].
1098 /// Returns the freshly allocated output buffer.
1099 pub fn quantized_matmul(
1100 &mut self,
1101 registry: &mut KernelRegistry,
1102 device: &MlxDevice,
1103 input: &MlxBuffer,
1104 weight: &MlxBuffer,
1105 scales: &MlxBuffer,
1106 biases: &MlxBuffer,
1107 params: &ops::quantized_matmul::QuantizedMatmulParams,
1108 ) -> Result<MlxBuffer> {
1109 ops::quantized_matmul::quantized_matmul(
1110 &mut self.encoder,
1111 registry,
1112 device,
1113 input,
1114 weight,
1115 scales,
1116 biases,
1117 params,
1118 )
1119 }
1120
1121 /// Encode a SIMD-optimized quantized matmul into this session's encoder.
1122 ///
1123 /// Delegates to [`ops::quantized_matmul::quantized_matmul_simd`].
1124 /// Returns the freshly allocated output buffer.
1125 pub fn quantized_matmul_simd(
1126 &mut self,
1127 registry: &mut KernelRegistry,
1128 device: &MlxDevice,
1129 input: &MlxBuffer,
1130 weight: &MlxBuffer,
1131 scales: &MlxBuffer,
1132 biases: &MlxBuffer,
1133 params: &ops::quantized_matmul::QuantizedMatmulParams,
1134 ) -> Result<MlxBuffer> {
1135 ops::quantized_matmul::quantized_matmul_simd(
1136 &mut self.encoder,
1137 registry,
1138 device,
1139 input,
1140 weight,
1141 scales,
1142 biases,
1143 params,
1144 )
1145 }
1146
1147 /// Encode a GGML block-format quantized mat-vec into this session's encoder.
1148 ///
1149 /// Delegates to [`ops::quantized_matmul_ggml::quantized_matmul_ggml`].
1150 pub fn quantized_matmul_ggml(
1151 &mut self,
1152 registry: &mut KernelRegistry,
1153 device: &MlxDevice,
1154 input: &MlxBuffer,
1155 weight: &MlxBuffer,
1156 output: &MlxBuffer,
1157 params: &ops::quantized_matmul_ggml::GgmlQuantizedMatmulParams,
1158 ) -> Result<()> {
1159 ops::quantized_matmul_ggml::quantized_matmul_ggml(
1160 &mut self.encoder,
1161 registry,
1162 device,
1163 input,
1164 weight,
1165 output,
1166 params,
1167 )
1168 }
1169
1170 /// Encode an expert-routed GGML block-format quantized mat-vec into this session's encoder.
1171 ///
1172 /// Delegates to [`ops::quantized_matmul_id_ggml::quantized_matmul_id_ggml`].
1173 #[allow(clippy::too_many_arguments)]
1174 pub fn quantized_matmul_id_ggml(
1175 &mut self,
1176 registry: &mut KernelRegistry,
1177 device: &MlxDevice,
1178 input: &MlxBuffer,
1179 weight: &MlxBuffer,
1180 ids: &MlxBuffer,
1181 output: &MlxBuffer,
1182 params: &ops::quantized_matmul_id_ggml::GgmlQuantizedMatmulIdParams,
1183 ) -> Result<()> {
1184 ops::quantized_matmul_id_ggml::quantized_matmul_id_ggml(
1185 &mut self.encoder,
1186 registry,
1187 device,
1188 input,
1189 weight,
1190 ids,
1191 output,
1192 params,
1193 )
1194 }
1195
1196 /// Byte-identity-required variant of [`Self::quantized_matmul_id_ggml`] —
1197 /// always routes to the per-token `mv_id` kernel (ADR-040 Phase F
1198 /// `iter-F-moe-mvid`). The gemma4 `[N,hidden]` batched decode body calls
1199 /// this for its MoE gate_up/down `_id` dispatches so that at N≥5 (where the
1200 /// down projection's `n_tokens = N*top_k > 32` would otherwise cross into
1201 /// the `mm_id` grouped kernel, whose reduction order is not bit-identical
1202 /// to serial) the batched forward stays byte-identical to N serial decodes.
1203 pub fn quantized_matmul_id_ggml_mv(
1204 &mut self,
1205 registry: &mut KernelRegistry,
1206 device: &MlxDevice,
1207 input: &MlxBuffer,
1208 weight: &MlxBuffer,
1209 ids: &MlxBuffer,
1210 output: &MlxBuffer,
1211 params: &ops::quantized_matmul_id_ggml::GgmlQuantizedMatmulIdParams,
1212 ) -> Result<()> {
1213 ops::quantized_matmul_id_ggml::quantized_matmul_id_ggml_mv(
1214 &mut self.encoder,
1215 registry,
1216 device,
1217 input,
1218 weight,
1219 ids,
1220 output,
1221 params,
1222 )
1223 }
1224
1225 /// Pooled-scratch variant of [`Self::quantized_matmul_id_ggml`] — the
1226 /// `IdMmScratch` is caller-owned so batched prefill amortises the
1227 /// per-call allocations that the auto entry point incurs (ADR-011
1228 /// Phase 3 Wave P3b).
1229 #[allow(clippy::too_many_arguments)]
1230 pub fn quantized_matmul_id_ggml_pooled(
1231 &mut self,
1232 registry: &mut KernelRegistry,
1233 device: &MlxDevice,
1234 input: &MlxBuffer,
1235 weight: &MlxBuffer,
1236 ids: &MlxBuffer,
1237 output: &MlxBuffer,
1238 scratch: &mut ops::quantized_matmul_id_ggml::IdMmScratch,
1239 params: &ops::quantized_matmul_id_ggml::GgmlQuantizedMatmulIdParams,
1240 ) -> Result<()> {
1241 ops::quantized_matmul_id_ggml::quantized_matmul_id_ggml_pooled(
1242 &mut self.encoder,
1243 registry,
1244 device,
1245 input,
1246 weight,
1247 ids,
1248 output,
1249 scratch,
1250 params,
1251 )
1252 }
1253
1254 /// Encode scaled dot-product attention into this session's encoder.
1255 ///
1256 /// Delegates to [`ops::sdpa::sdpa`].
1257 pub fn sdpa(
1258 &mut self,
1259 registry: &mut KernelRegistry,
1260 device: &MlxDevice,
1261 q: &MlxBuffer,
1262 k: &MlxBuffer,
1263 v: &MlxBuffer,
1264 output: &MlxBuffer,
1265 params: &ops::sdpa::SdpaParams,
1266 batch_size: u32,
1267 ) -> Result<()> {
1268 ops::sdpa::sdpa(
1269 &mut self.encoder,
1270 registry,
1271 device,
1272 q,
1273 k,
1274 v,
1275 output,
1276 params,
1277 batch_size,
1278 )
1279 }
1280
1281 /// Encode flash attention vector (SIMD-vectorized decode-path SDPA).
1282 ///
1283 /// Delegates to [`ops::flash_attn_vec::flash_attn_vec`].
1284 pub fn flash_attn_vec(
1285 &mut self,
1286 registry: &mut KernelRegistry,
1287 device: &MlxDevice,
1288 q: &MlxBuffer,
1289 k: &MlxBuffer,
1290 v: &MlxBuffer,
1291 output: &MlxBuffer,
1292 tmp: &MlxBuffer,
1293 params: &ops::flash_attn_vec::FlashAttnVecParams,
1294 ) -> Result<()> {
1295 ops::flash_attn_vec::flash_attn_vec(
1296 &mut self.encoder,
1297 registry,
1298 device,
1299 q,
1300 k,
1301 v,
1302 output,
1303 tmp,
1304 params,
1305 )
1306 }
1307
1308 /// Encode an elementwise add into this session's encoder.
1309 ///
1310 /// Delegates to [`ops::elementwise::elementwise_add`].
1311 pub fn elementwise_add(
1312 &mut self,
1313 registry: &mut KernelRegistry,
1314 device: &metal::DeviceRef,
1315 a: &MlxBuffer,
1316 b: &MlxBuffer,
1317 output: &MlxBuffer,
1318 n_elements: usize,
1319 dtype: DType,
1320 ) -> Result<()> {
1321 ops::elementwise::elementwise_add(
1322 &mut self.encoder,
1323 registry,
1324 device,
1325 a,
1326 b,
1327 output,
1328 n_elements,
1329 dtype,
1330 )
1331 }
1332
1333 /// Encode an elementwise multiply into this session's encoder.
1334 ///
1335 /// Delegates to [`ops::elementwise::elementwise_mul`].
1336 pub fn elementwise_mul(
1337 &mut self,
1338 registry: &mut KernelRegistry,
1339 device: &metal::DeviceRef,
1340 a: &MlxBuffer,
1341 b: &MlxBuffer,
1342 output: &MlxBuffer,
1343 n_elements: usize,
1344 dtype: DType,
1345 ) -> Result<()> {
1346 ops::elementwise::elementwise_mul(
1347 &mut self.encoder,
1348 registry,
1349 device,
1350 a,
1351 b,
1352 output,
1353 n_elements,
1354 dtype,
1355 )
1356 }
1357
1358 /// Encode a RoPE transform into this session's encoder.
1359 ///
1360 /// Delegates to [`ops::rope::dispatch_rope`].
1361 pub fn rope(
1362 &mut self,
1363 registry: &mut KernelRegistry,
1364 device: &metal::DeviceRef,
1365 input: &MlxBuffer,
1366 output: &MlxBuffer,
1367 params_buf: &MlxBuffer,
1368 positions_buf: &MlxBuffer,
1369 seq_len: u32,
1370 head_dim: u32,
1371 ) -> Result<()> {
1372 ops::rope::dispatch_rope(
1373 &mut self.encoder,
1374 registry,
1375 device,
1376 input,
1377 output,
1378 params_buf,
1379 positions_buf,
1380 seq_len,
1381 head_dim,
1382 )
1383 }
1384
1385 /// Encode a GELU activation into this session's encoder.
1386 ///
1387 /// Delegates to [`ops::gelu::dispatch_gelu`].
1388 pub fn gelu(
1389 &mut self,
1390 registry: &mut KernelRegistry,
1391 device: &metal::DeviceRef,
1392 input: &MlxBuffer,
1393 output: &MlxBuffer,
1394 ) -> Result<()> {
1395 ops::gelu::dispatch_gelu(
1396 &mut self.encoder,
1397 registry,
1398 device,
1399 input,
1400 output,
1401 )
1402 }
1403
1404 /// Encode a softmax into this session's encoder.
1405 ///
1406 /// Delegates to [`ops::softmax::dispatch_softmax`].
1407 pub fn softmax(
1408 &mut self,
1409 registry: &mut KernelRegistry,
1410 device: &metal::DeviceRef,
1411 input: &MlxBuffer,
1412 output: &MlxBuffer,
1413 params_buf: &MlxBuffer,
1414 rows: u32,
1415 cols: u32,
1416 ) -> Result<()> {
1417 ops::softmax::dispatch_softmax(
1418 &mut self.encoder,
1419 registry,
1420 device,
1421 input,
1422 output,
1423 params_buf,
1424 rows,
1425 cols,
1426 )
1427 }
1428
1429 /// Encode a softcap into this session's encoder.
1430 ///
1431 /// Delegates to [`ops::softcap::dispatch_softcap`].
1432 pub fn softcap(
1433 &mut self,
1434 registry: &mut KernelRegistry,
1435 device: &metal::DeviceRef,
1436 input: &MlxBuffer,
1437 output: &MlxBuffer,
1438 params_buf: &MlxBuffer,
1439 cap: f32,
1440 ) -> Result<()> {
1441 ops::softcap::dispatch_softcap(
1442 &mut self.encoder,
1443 registry,
1444 device,
1445 input,
1446 output,
1447 params_buf,
1448 cap,
1449 )
1450 }
1451
1452 /// Encode an RMS norm without learned scale (f32) into this session's encoder.
1453 ///
1454 /// Delegates to [`ops::rms_norm::dispatch_rms_norm_no_scale_f32`].
1455 pub fn rms_norm_no_scale_f32(
1456 &mut self,
1457 registry: &mut KernelRegistry,
1458 device: &metal::DeviceRef,
1459 input: &MlxBuffer,
1460 output: &MlxBuffer,
1461 params_buf: &MlxBuffer,
1462 rows: u32,
1463 dim: u32,
1464 ) -> Result<()> {
1465 ops::rms_norm::dispatch_rms_norm_no_scale_f32(
1466 &mut self.encoder,
1467 registry,
1468 device,
1469 input,
1470 output,
1471 params_buf,
1472 rows,
1473 dim,
1474 )
1475 }
1476
1477 /// Encode a NeoX RoPE (f32) with optional freq_factors into this session's encoder.
1478 ///
1479 /// Delegates to [`ops::rope::dispatch_rope_neox_f32`].
1480 #[allow(clippy::too_many_arguments)]
1481 pub fn rope_neox_f32(
1482 &mut self,
1483 registry: &mut KernelRegistry,
1484 device: &metal::DeviceRef,
1485 input: &MlxBuffer,
1486 output: &MlxBuffer,
1487 params_buf: &MlxBuffer,
1488 positions_buf: &MlxBuffer,
1489 freq_factors: Option<&MlxBuffer>,
1490 seq_len: u32,
1491 n_heads: u32,
1492 head_dim: u32,
1493 rope_dim: u32,
1494 ) -> Result<()> {
1495 ops::rope::dispatch_rope_neox_f32(
1496 &mut self.encoder,
1497 registry,
1498 device,
1499 input,
1500 output,
1501 params_buf,
1502 positions_buf,
1503 freq_factors,
1504 seq_len,
1505 n_heads,
1506 head_dim,
1507 rope_dim,
1508 )
1509 }
1510
1511 /// Insert a GPU memory barrier (MTLBarrierScopeBuffers).
1512 ///
1513 /// Unconditional barrier — always emits. Use `barrier_between` for
1514 /// automatic conflict detection that can elide unnecessary barriers.
1515 #[inline]
1516 pub fn barrier(&mut self) {
1517 // Record the outgoing group size
1518 if self.dispatch_in_group > 0 {
1519 let idx = (self.dispatch_in_group as usize).min(self.group_sizes.len()) - 1;
1520 self.group_sizes[idx] += 1;
1521 }
1522 self.encoder.memory_barrier();
1523 self.tracker.reset();
1524 self.barrier_count += 1;
1525 self.dispatch_in_group = 0;
1526 }
1527
1528 /// Smart barrier with conflict detection.
1529 ///
1530 /// Checks if the next dispatch (with the given read and write buffers)
1531 /// actually conflicts with any dispatch in the current concurrent group.
1532 /// If yes, emits a Metal barrier and resets the tracker. If no, the
1533 /// barrier is elided and the dispatch can run concurrently.
1534 ///
1535 /// This mirrors llama.cpp's `ggml_metal_op_concurrency_check` +
1536 /// `ggml_metal_op_concurrency_reset` pattern.
1537 #[inline]
1538 pub fn barrier_between(&mut self, reads: &[&MlxBuffer], writes: &[&MlxBuffer]) {
1539 // ADR-040 §25 — gated host-encode profiling (HF2Q_BARRIER_NS=1): time the
1540 // conflict-tracking work (conflicts_reason + tracker.add) to split the
1541 // ~2.44ms/step serial encode into barrier-overhead vs Metal arg-encoding.
1542 let _bt = if *BARRIER_NS_ON { Some(std::time::Instant::now()) } else { None };
1543 // In capture mode, stash the read/write ranges so the next captured
1544 // dispatch node carries them for the reorder pass (Phase 4e.3).
1545 if self.recording {
1546 let read_ranges: Vec<MemRange> = reads
1547 .iter()
1548 .map(|b| {
1549 let start = b.contents_ptr() as usize;
1550 (start, start + b.byte_len())
1551 })
1552 .collect();
1553 let write_ranges: Vec<MemRange> = writes
1554 .iter()
1555 .map(|b| {
1556 let start = b.contents_ptr() as usize;
1557 (start, start + b.byte_len())
1558 })
1559 .collect();
1560 self.encoder.set_pending_buffer_ranges(read_ranges, write_ranges);
1561 }
1562
1563 let reason = self.tracker.conflicts_reason(reads, writes);
1564 if let Some((_kind, _new_ptr, _existing_ptr)) = reason {
1565 // Record the outgoing group size before resetting
1566 if self.dispatch_in_group > 0 {
1567 let idx = (self.dispatch_in_group as usize).min(self.group_sizes.len()) - 1;
1568 self.group_sizes[idx] += 1;
1569 }
1570 self.encoder.memory_barrier();
1571 self.tracker.reset();
1572 self.barrier_count += 1;
1573 self.dispatch_in_group = 0;
1574 }
1575 self.dispatch_in_group += 1;
1576 self.total_dispatches += 1;
1577 self.tracker.add(reads, writes);
1578 if let Some(t) = _bt {
1579 BARRIER_NS.fetch_add(t.elapsed().as_nanos() as u64, std::sync::atomic::Ordering::Relaxed);
1580 }
1581 }
1582
1583 /// Print group size histogram to stderr (for HF2Q_MLX_TIMING debug).
1584 pub fn dump_group_stats(&self) {
1585 // Record the final (unterminated) group
1586 let mut gs = self.group_sizes;
1587 if self.dispatch_in_group > 0 {
1588 let idx = (self.dispatch_in_group as usize).min(gs.len()) - 1;
1589 gs[idx] += 1;
1590 }
1591 let total_groups: u32 = gs.iter().sum();
1592 eprintln!(" [GROUP_STATS] dispatches={} barriers={} groups={} ratio={:.2}",
1593 self.total_dispatches, self.barrier_count, total_groups,
1594 if total_groups > 0 { self.total_dispatches as f64 / total_groups as f64 } else { 0.0 });
1595 for (i, &count) in gs.iter().enumerate() {
1596 if count > 0 {
1597 eprintln!(" size {}: {} groups", i + 1, count);
1598 }
1599 }
1600 }
1601
1602 /// Register a dispatch's buffer ranges without checking for conflicts.
1603 ///
1604 /// Use after dispatching an op that doesn't need a barrier check (e.g.,
1605 /// the first dispatch in a session, or dispatches known to be concurrent).
1606 ///
1607 /// In recording mode, also retroactively annotates the most recently
1608 /// captured dispatch node with these ranges if it was missing them.
1609 /// That keeps the reorder pass able to reason about dispatches that
1610 /// were preceded by `track_dispatch` rather than `barrier_between`.
1611 #[inline]
1612 pub fn track_dispatch(&mut self, reads: &[&MlxBuffer], writes: &[&MlxBuffer]) {
1613 if self.recording {
1614 let read_ranges: Vec<MemRange> = reads
1615 .iter()
1616 .map(|b| {
1617 let start = b.contents_ptr() as usize;
1618 (start, start + b.byte_len())
1619 })
1620 .collect();
1621 let write_ranges: Vec<MemRange> = writes
1622 .iter()
1623 .map(|b| {
1624 let start = b.contents_ptr() as usize;
1625 (start, start + b.byte_len())
1626 })
1627 .collect();
1628 self.encoder
1629 .annotate_last_dispatch_if_missing(read_ranges, write_ranges);
1630 }
1631 self.tracker.add(reads, writes);
1632 }
1633
1634 /// Return the number of barriers inserted so far in this session.
1635 #[inline]
1636 pub fn barrier_count(&self) -> u32 {
1637 self.barrier_count
1638 }
1639
1640 /// Cumulative nanoseconds spent in ConflictTracker checks (diagnostic).
1641 /// Returns 0 when timing is not compiled in.
1642 pub fn tracker_overhead_ns(&self) -> u64 {
1643 0
1644 }
1645
1646 /// Borrow the underlying command encoder for direct op dispatch.
1647 ///
1648 /// Use this when you need to call an op function that is not wrapped by
1649 /// a `GraphSession` method. The returned encoder is the same shared
1650 /// encoder — all dispatches still go into the same command buffer.
1651 pub fn encoder_mut(&mut self) -> &mut CommandEncoder {
1652 &mut self.encoder
1653 }
1654
1655 /// Borrow the device reference.
1656 pub fn device(&self) -> &MlxDevice {
1657 self.device
1658 }
1659
1660 /// Whether this session is in capture/record mode.
1661 pub fn is_recording(&self) -> bool {
1662 self.recording
1663 }
1664
1665 /// Commit the command buffer and wait for GPU completion.
1666 ///
1667 /// This is the ONLY sync point per forward pass. After this call, all
1668 /// output buffers are readable by the CPU.
1669 ///
1670 /// In recording mode: extracts the captured graph, replays it into
1671 /// the encoder via `ComputeGraph::encode_sequential()`, then commits
1672 /// and waits. The result is identical to the direct-dispatch path.
1673 ///
1674 /// Consumes the session — no further ops can be encoded.
1675 pub fn finish(mut self) -> Result<()> {
1676 if self.recording {
1677 if let Some(nodes) = self.encoder.take_capture() {
1678 let graph = ComputeGraph::from_nodes(nodes);
1679 graph.encode_sequential(&mut self.encoder);
1680 }
1681 }
1682 self.encoder.commit_and_wait()
1683 }
1684
1685 /// Commit the command buffer WITHOUT waiting.
1686 ///
1687 /// The GPU begins executing immediately. Use this for fire-and-forget
1688 /// dispatch when you do not need results until later.
1689 ///
1690 /// In recording mode: replays the captured graph before committing.
1691 ///
1692 /// Consumes the session.
1693 pub fn commit(mut self) -> CommandEncoder {
1694 if self.recording {
1695 if let Some(nodes) = self.encoder.take_capture() {
1696 let graph = ComputeGraph::from_nodes(nodes);
1697 graph.encode_sequential(&mut self.encoder);
1698 }
1699 }
1700 self.encoder.commit();
1701 // ADR-015: close the capture window AFTER
1702 // commit (so the CB is recorded inside the trace) but BEFORE
1703 // returning the encoder (so the trace finalizes promptly).
1704 // `MTLCaptureManager.stopCapture` marks the recording
1705 // boundary at exactly this point. CBs committed BEFORE this
1706 // line are in the trace; any work done by the caller through
1707 // the returned encoder is NOT. This matches llama.cpp's
1708 // ggml-metal-context.m:608 pattern (`stopCapture` after the
1709 // last `commit + waitUntilCompleted`).
1710 //
1711 // Note: for the async commit() path, the GPU may still be
1712 // executing the CB when stopCapture fires. Apple's
1713 // MTLCaptureManager is documented to flush in-flight work
1714 // into the trace before finalizing the file.
1715 if let Some(mut cap) = self.metal_capture.take() {
1716 cap.end();
1717 }
1718 self.encoder
1719 }
1720
1721 /// Commit the command buffer and wait, returning split timing.
1722 ///
1723 /// Returns `(encoding_ns, gpu_wait_ns)` where:
1724 /// - `encoding_ns` is the time from session begin to commit (CPU encoding)
1725 /// - `gpu_wait_ns` is the time from commit to GPU completion
1726 ///
1727 /// The `session_begin` instant should be captured right after `exec.begin()`.
1728 ///
1729 /// In recording mode: replays the captured graph before committing.
1730 ///
1731 /// Consumes the session.
1732 pub fn finish_with_timing(mut self, session_begin: std::time::Instant) -> Result<(u64, u64)> {
1733 if self.recording {
1734 if let Some(nodes) = self.encoder.take_capture() {
1735 let graph = ComputeGraph::from_nodes(nodes);
1736 graph.encode_sequential(&mut self.encoder);
1737 }
1738 }
1739 let commit_start = std::time::Instant::now();
1740 let encoding_ns = commit_start.duration_since(session_begin).as_nanos() as u64;
1741 self.encoder.commit();
1742 self.encoder.wait_until_completed()?;
1743 let gpu_wait_ns = commit_start.elapsed().as_nanos() as u64;
1744 Ok((encoding_ns, gpu_wait_ns))
1745 }
1746
1747 /// Finish this session and return the GPU wall-clock interval in ns.
1748 ///
1749 /// Returns `(gpu_interval_ns,)` where `gpu_interval_ns` is the CFTimeInterval
1750 /// difference between `MTLCommandBuffer.GPUEndTime` and
1751 /// `MTLCommandBuffer.GPUStartTime`, converted to ns. Excludes CPU
1752 /// commit+wait overhead — that appears in the residual when bucket
1753 /// sums are compared to the outer wall-clock.
1754 ///
1755 /// Used by `HF2Q_PROFILE_GPU_TS=1` to accumulate pure GPU time per
1756 /// op bucket. In recording mode: replays the captured graph before
1757 /// committing.
1758 ///
1759 /// Consumes the session.
1760 pub fn finish_with_gpu_time(mut self) -> Result<u64> {
1761 if self.recording {
1762 if let Some(nodes) = self.encoder.take_capture() {
1763 let graph = ComputeGraph::from_nodes(nodes);
1764 graph.encode_sequential(&mut self.encoder);
1765 }
1766 }
1767 let (gs, ge) = self.encoder.commit_wait_with_gpu_time()?;
1768 // GPUStartTime/GPUEndTime are CFTimeInterval (seconds, double).
1769 // Guard against negative deltas (can happen on the first CB of
1770 // a run if the kernel driver lazily initialises the timeline;
1771 // clamp to zero in that case).
1772 let delta = (ge - gs).max(0.0);
1773 Ok((delta * 1.0e9) as u64)
1774 }
1775
1776 /// Finish with fusion: run the RMS norm + MUL fusion pass before
1777 /// replaying the graph.
1778 ///
1779 /// Only meaningful in recording mode. In direct-dispatch mode, this
1780 /// behaves identically to `finish()`.
1781 ///
1782 /// Returns `(fusions_applied,)` on success.
1783 pub fn finish_with_fusion(
1784 mut self,
1785 registry: &mut KernelRegistry,
1786 device: &metal::DeviceRef,
1787 ) -> Result<u32> {
1788 let mut fusions = 0;
1789 if self.recording {
1790 if let Some(nodes) = self.encoder.take_capture() {
1791 let mut graph = ComputeGraph::from_nodes(nodes);
1792 fusions = graph.fuse(registry, device)?;
1793 graph.encode_sequential(&mut self.encoder);
1794 }
1795 }
1796 self.encoder.commit_and_wait()?;
1797 Ok(fusions)
1798 }
1799
1800 /// Async-commit variant of `finish_with_fusion`.
1801 ///
1802 /// Runs the fusion pass on the captured graph, replays the fused
1803 /// graph into a fresh command buffer, then commits *without waiting*
1804 /// (fire-and-forget — GPU executes asynchronously while CPU returns).
1805 /// Mirrors `commit()` semantics, plus the fusion optimization pass.
1806 ///
1807 /// Used by the prefill per-layer pattern where the next layer's CPU
1808 /// encoding should overlap with this layer's GPU execution — peer's
1809 /// llama.cpp Metal backend uses the same async-commit-with-graph-opt
1810 /// pattern at `ggml-metal-context.m:617-621`.
1811 ///
1812 /// In direct-dispatch mode (recording=false), behaves identically to
1813 /// `commit()`: no fusion happens, just an async commit.
1814 ///
1815 /// Consumes the session. Returns `(encoder, fusions_applied)`.
1816 ///
1817 /// ADR-029: first step of graph_opt port to prefill.
1818 pub fn commit_with_fusion(
1819 mut self,
1820 registry: &mut KernelRegistry,
1821 device: &metal::DeviceRef,
1822 ) -> Result<(CommandEncoder, u32)> {
1823 let mut fusions = 0;
1824 if self.recording {
1825 if let Some(nodes) = self.encoder.take_capture() {
1826 let mut graph = ComputeGraph::from_nodes(nodes);
1827 fusions = graph.fuse(registry, device)?;
1828 graph.encode_sequential(&mut self.encoder);
1829 }
1830 }
1831 self.encoder.commit();
1832 // Close the metal_capture window after commit (same pattern as `commit`).
1833 if let Some(mut cap) = self.metal_capture.take() {
1834 cap.end();
1835 }
1836 Ok((self.encoder, fusions))
1837 }
1838
1839 /// Finish with fusion and split timing.
1840 ///
1841 /// Like `finish_with_timing` but runs the fusion pass first.
1842 /// Returns `(encoding_ns, gpu_wait_ns, fusions_applied)`.
1843 pub fn finish_with_fusion_and_timing(
1844 mut self,
1845 registry: &mut KernelRegistry,
1846 device: &metal::DeviceRef,
1847 session_begin: std::time::Instant,
1848 ) -> Result<(u64, u64, u32)> {
1849 let mut fusions = 0;
1850 if self.recording {
1851 if let Some(nodes) = self.encoder.take_capture() {
1852 let mut graph = ComputeGraph::from_nodes(nodes);
1853 fusions = graph.fuse(registry, device)?;
1854 graph.encode_sequential(&mut self.encoder);
1855 }
1856 }
1857 let commit_start = std::time::Instant::now();
1858 let encoding_ns = commit_start.duration_since(session_begin).as_nanos() as u64;
1859 self.encoder.commit();
1860 self.encoder.wait_until_completed()?;
1861 let gpu_wait_ns = commit_start.elapsed().as_nanos() as u64;
1862 Ok((encoding_ns, gpu_wait_ns, fusions))
1863 }
1864
1865 /// Finish with fusion AND reorder: run both graph optimization passes
1866 /// before replaying the graph.
1867 ///
1868 /// Only meaningful in recording mode. In direct-dispatch mode, this
1869 /// behaves identically to `finish()`.
1870 ///
1871 /// Returns `(fusions_applied, nodes_reordered)` on success.
1872 pub fn finish_with_fusion_and_reorder(
1873 mut self,
1874 registry: &mut KernelRegistry,
1875 device: &metal::DeviceRef,
1876 ) -> Result<(u32, u32)> {
1877 let mut fusions = 0;
1878 let mut reordered = 0;
1879 if self.recording {
1880 if let Some(nodes) = self.encoder.take_capture() {
1881 let mut graph = ComputeGraph::from_nodes(nodes);
1882 fusions = graph.fuse(registry, device)?;
1883 reordered = graph.reorder();
1884 graph.encode_with_barriers(&mut self.encoder);
1885 }
1886 }
1887 self.encoder.commit_and_wait()?;
1888 Ok((fusions, reordered))
1889 }
1890
1891 /// Finish with fusion, reorder, and split timing.
1892 ///
1893 /// Like `finish_with_fusion_and_timing` but also runs the reorder pass.
1894 /// Returns `(encoding_ns, gpu_wait_ns, fusions_applied, nodes_reordered)`.
1895 pub fn finish_with_fusion_reorder_and_timing(
1896 mut self,
1897 registry: &mut KernelRegistry,
1898 device: &metal::DeviceRef,
1899 session_begin: std::time::Instant,
1900 ) -> Result<(u64, u64, u32, u32)> {
1901 let mut fusions = 0;
1902 let mut reordered = 0;
1903 if self.recording {
1904 if let Some(nodes) = self.encoder.take_capture() {
1905 let mut graph = ComputeGraph::from_nodes(nodes);
1906 fusions = graph.fuse(registry, device)?;
1907 reordered = graph.reorder();
1908 graph.encode_with_barriers(&mut self.encoder);
1909 }
1910 }
1911 let commit_start = std::time::Instant::now();
1912 let encoding_ns = commit_start.duration_since(session_begin).as_nanos() as u64;
1913 self.encoder.commit();
1914 self.encoder.wait_until_completed()?;
1915 let gpu_wait_ns = commit_start.elapsed().as_nanos() as u64;
1916 Ok((encoding_ns, gpu_wait_ns, fusions, reordered))
1917 }
1918
1919 /// Finish with the full optimization pipeline: fuse, reorder, dual-buffer
1920 /// encode.
1921 ///
1922 /// Runs the fusion pass, reorder pass, then encodes the graph into two
1923 /// Metal command buffers for CPU/GPU overlap. The first ~10% of dispatches
1924 /// are committed immediately so the GPU can start executing while the CPU
1925 /// encodes the remaining ~90%.
1926 ///
1927 /// Only meaningful in recording mode. In direct-dispatch mode, this
1928 /// behaves identically to `finish()`.
1929 ///
1930 /// Returns `(fusions_applied, nodes_reordered, barriers_buf0, barriers_buf1)`.
1931 pub fn finish_optimized(
1932 mut self,
1933 registry: &mut KernelRegistry,
1934 device: &metal::DeviceRef,
1935 ) -> Result<(u32, u32, u32, u32)> {
1936 let mut fusions = 0;
1937 let mut reordered = 0;
1938 let mut barriers0 = 0u32;
1939 let mut barriers1 = 0u32;
1940
1941 if self.recording {
1942 if let Some(nodes) = self.encoder.take_capture() {
1943 // Commit the capture encoder's empty command buffer so its
1944 // MTLCommandQueue pool slot is freed (same fix as timing variant).
1945 self.encoder.commit();
1946
1947 let mut graph = ComputeGraph::from_nodes(nodes);
1948 fusions = graph.fuse(registry, device)?;
1949 reordered = graph.reorder();
1950
1951 let mut enc0 = self.device.command_encoder()?;
1952 let mut enc1 = self.device.command_encoder()?;
1953
1954 let (b0, b1) = graph.encode_dual_buffer(&mut enc0, &mut enc1);
1955 barriers0 = b0;
1956 barriers1 = b1;
1957
1958 // enc0 was already committed inside encode_dual_buffer.
1959 // Commit enc1 and wait — Metal queue ordering guarantees enc0
1960 // finishes before enc1 starts executing.
1961 enc1.commit_and_wait()?;
1962
1963 // The original encoder was never committed (capture mode drained
1964 // it). We need to end it cleanly — dropping it will end the
1965 // active encoder if any, and the uncommitted command buffer is
1966 // abandoned. That is safe: Metal silently drops uncommitted
1967 // command buffers.
1968 return Ok((fusions, reordered, barriers0, barriers1));
1969 }
1970 }
1971
1972 // Direct-dispatch fallback: just commit the original encoder.
1973 self.encoder.commit_and_wait()?;
1974 Ok((fusions, reordered, barriers0, barriers1))
1975 }
1976
1977 /// Finish with the full optimization pipeline and split timing.
1978 ///
1979 /// Like `finish_optimized` but returns timing information.
1980 /// Returns `(encoding_ns, gpu_wait_ns, fusions, reordered, barriers_buf0, barriers_buf1)`.
1981 ///
1982 /// Timing breakdown:
1983 /// - `encoding_ns`: CPU time from session begin to first buffer commit
1984 /// (fusion + reorder + encode chunk 0)
1985 /// - `gpu_wait_ns`: wall time from second buffer commit to GPU completion
1986 /// (includes GPU execution of both buffers, overlapped with chunk 1 encoding)
1987 pub fn finish_optimized_with_timing(
1988 mut self,
1989 registry: &mut KernelRegistry,
1990 device: &metal::DeviceRef,
1991 session_begin: std::time::Instant,
1992 ) -> Result<(u64, u64, u32, u32, u32, u32)> {
1993 let mut fusions = 0;
1994 let mut reordered = 0;
1995 let mut barriers0 = 0u32;
1996 let mut barriers1 = 0u32;
1997
1998 if self.recording {
1999 if let Some(nodes) = self.encoder.take_capture() {
2000 // Commit the capture encoder's empty command buffer so its
2001 // MTLCommandQueue pool slot is freed. Without this, each
2002 // token leaks one uncommitted buffer and the queue exhausts
2003 // its ~64-slot pool after ~64 tokens, causing a deadlock.
2004 self.encoder.commit();
2005
2006 let opt_t0 = std::time::Instant::now();
2007 let mut graph = ComputeGraph::from_nodes(nodes);
2008 let fuse_t0 = std::time::Instant::now();
2009 fusions = graph.fuse(registry, device)?;
2010 let fuse_us = fuse_t0.elapsed().as_micros();
2011
2012 let reorder_t0 = std::time::Instant::now();
2013 let unannotated = graph.unannotated_dispatch_count();
2014 if unannotated == 0 {
2015 reordered = graph.reorder();
2016 } else if std::env::var("HF2Q_MLX_TIMING").is_ok() {
2017 eprintln!(" [GRAPH_OPT] WARN: skipping reorder — {} of {} dispatches lack range annotations",
2018 unannotated, graph.dispatch_count());
2019 }
2020 let reorder_us = reorder_t0.elapsed().as_micros();
2021 let opt_us = opt_t0.elapsed().as_micros();
2022
2023 let diag = std::env::var("HF2Q_GRAPH_DIAG").is_ok();
2024 let t0 = std::time::Instant::now();
2025 let mut enc0 = self.device.command_encoder()?;
2026 let mut enc1 = self.device.command_encoder()?;
2027 let enc_create_us = t0.elapsed().as_micros();
2028
2029 let t1 = std::time::Instant::now();
2030 let (b0, b1) = graph.encode_dual_buffer(&mut enc0, &mut enc1);
2031 barriers0 = b0;
2032 barriers1 = b1;
2033 let encode_us = t1.elapsed().as_micros();
2034
2035 let encoding_ns = session_begin.elapsed().as_nanos() as u64;
2036
2037 let wait_start = std::time::Instant::now();
2038 enc1.commit_and_wait()?;
2039 let gpu_wait_ns = wait_start.elapsed().as_nanos() as u64;
2040
2041 if diag {
2042 eprintln!(" [DIAG] fuse={:.1}ms reorder={:.1}ms opt_total={:.1}ms enc_create={:.1}ms encode={:.1}ms gpu_wait={:.1}ms barriers={}+{}",
2043 fuse_us as f64 / 1e3, reorder_us as f64 / 1e3, opt_us as f64 / 1e3,
2044 enc_create_us as f64 / 1e3, encode_us as f64 / 1e3,
2045 gpu_wait_ns as f64 / 1e6, b0, b1);
2046 }
2047
2048 return Ok((encoding_ns, gpu_wait_ns, fusions, reordered, barriers0, barriers1));
2049 }
2050 }
2051
2052 // Direct-dispatch fallback.
2053 let commit_start = std::time::Instant::now();
2054 let encoding_ns = commit_start.duration_since(session_begin).as_nanos() as u64;
2055 self.encoder.commit();
2056 self.encoder.wait_until_completed()?;
2057 let gpu_wait_ns = commit_start.elapsed().as_nanos() as u64;
2058 Ok((encoding_ns, gpu_wait_ns, fusions, reordered, barriers0, barriers1))
2059 }
2060}