Skip to main content

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}