Skip to main content

mlx_native/
encoder.rs

1//! [`CommandEncoder`] — batched GPU command submission.
2//!
3//! Wraps a Metal command buffer.  Encode one or more compute kernel dispatches,
4//! then call [`commit_and_wait`](CommandEncoder::commit_and_wait) to submit the
5//! entire batch and block until the GPU finishes.
6//!
7//! # Persistent compute encoder
8//!
9//! A single Metal `ComputeCommandEncoder` is kept alive across multiple
10//! dispatches within the same command buffer.  This avoids the overhead of
11//! creating and ending a new compute encoder per dispatch — the same pattern
12//! candle uses (`compute_per_buffer`).  On a forward pass with ~800 dispatches
13//! this saves ~800 encoder create/end cycles.
14//!
15//! # Capture mode (Phase 4e.1)
16//!
17//! When `start_capture()` is called, subsequent dispatches are recorded into a
18//! `Vec<CapturedNode>` instead of being encoded into Metal.  `memory_barrier()`
19//! records a barrier sentinel.  Call `take_capture()` to extract the recorded
20//! graph for later replay via `ComputeGraph::encode_sequential()`.
21
22use std::sync::atomic::{AtomicBool, AtomicI8, AtomicU64, Ordering};
23
24use metal::{
25    CommandBuffer, CommandQueue, ComputeCommandEncoderRef, ComputePipelineState,
26    ComputePipelineStateRef, CounterSampleBuffer, CounterSampleBufferDescriptor,
27    MTLCommandBufferStatus, MTLCounterSamplingPoint, MTLDispatchType, MTLSize, MTLStorageMode,
28    NSRange,
29};
30#[allow(unused_imports)]
31use objc::{msg_send, sel, sel_impl};
32
33use crate::buffer::MlxBuffer;
34use crate::error::{MlxError, Result};
35use crate::mem_ranges::MemRanges;
36use crate::residency::ResidencySet;
37
38/// A buffer or inline-bytes binding for a compute kernel argument slot.
39pub enum KernelArg<'a> {
40    /// Bind an existing Metal buffer at the given index.
41    Buffer(&'a MlxBuffer),
42    /// Bind an existing Metal buffer at the given index with a byte offset.
43    BufferWithOffset(&'a MlxBuffer, u64),
44    /// Bind inline bytes (small constant data) at the given index.
45    /// The data must be `Pod` and is copied into the command encoder.
46    Bytes(&'a [u8]),
47}
48
49/// Pre-baked dispatch record for hot decode paths.
50///
51/// ADR-029 — first piece of the multi-week
52/// "Option A" refactor that the gemma4 decode gap analysis localized
53/// to per-dispatch CPU orchestration (forward_mlx::forward_decode →
54/// encode_one_layer → dispatch_qmatmul → quantized_matmul_ggml →
55/// dispatch_mv → encoder.encode_threadgroups_with_args).
56///
57/// At gemma4 decode m=1, every dispatch within the inner loop has
58/// load-time-immutable shape: the kernel pipeline, threadgroup
59/// geometry, params struct bytes, and binding-slot layout are fully
60/// determined by the weight + ggml_type and never change across the
61/// thousands of decode tokens that follow.  `DispatchRecord` captures
62/// that state once at model-load (or on first-call lazy-init) so the
63/// hot path skips:
64///   - `KernelRegistry::get_pipeline*` HashMap lookups
65///   - match expressions over `ggml_type` for kernel-name + geometry
66///   - `MTLSize::new` construction (already-known values)
67///   - param-struct field stores + bytemuck::bytes_of conversion
68///
69/// Only the runtime-varying buffers (input, output) need to be passed
70/// to [`CommandEncoder::dispatch_record`].  Weight buffers are baked
71/// inline via the `bake_buffers` slot list.
72///
73/// # Bake-time invariants
74///
75/// - `buffer_slots.len() == bake_buffers.len() + runtime_buffer_count`
76///   for the call site contract; the call site documents
77///   `runtime_buffer_count` and the order of runtime buffers.
78/// - `params_bytes.len()` is whatever the kernel's `KernelArg::Bytes`
79///   expects (typically 8-byte aligned per Metal struct layout).
80/// - `threadgroup_mem` is `(slot, byte_length)` pairs; empty when the
81///   kernel doesn't request `[[threadgroup]]` memory.
82///
83/// # Coherence
84///
85/// `dispatch_record` produces a byte-identical Metal command stream
86/// to the equivalent `encode_threadgroups_with_args*` call.  Capture
87/// mode is supported (replays into `CapturedNode::Dispatch` exactly
88/// like the unbaked path).  See `dispatch_record` for the lockstep.
89#[derive(Clone)]
90pub struct DispatchRecord {
91    /// Pipeline reference, looked up once at bake time.
92    pub pipeline: ComputePipelineState,
93    /// Threadgroup count.
94    pub threadgroups: MTLSize,
95    /// Threads per threadgroup.
96    pub threads_per_tg: MTLSize,
97    /// Threadgroup shared-memory bindings: `(slot_index, byte_length)`.
98    /// Empty when the kernel doesn't allocate `[[threadgroup]]` memory.
99    pub threadgroup_mem: Vec<(u64, u64)>,
100    /// Pre-encoded params struct bytes (bound as `KernelArg::Bytes`).
101    /// Empty when the kernel has no inline-bytes parameter.
102    pub params_bytes: Vec<u8>,
103    /// Slot index for `params_bytes`.  Ignored when `params_bytes` is empty.
104    pub params_slot: u64,
105    /// Slot indices for runtime buffer arguments, in caller order.
106    /// `dispatch_record` zips `runtime_buffers` against this list.
107    pub buffer_slots: Vec<u64>,
108    /// `CapturedOpKind` used when the encoder is in capture mode.
109    pub op_kind: CapturedOpKind,
110    /// Diagnostic label (kernel name) for debug/timing.
111    pub kernel_name: String,
112}
113
114/// Convert a `Pod` value to a byte slice suitable for `KernelArg::Bytes`.
115///
116/// # Safety
117///
118/// The caller must ensure `T` has the same layout as the corresponding
119/// MSL struct in the shader (matching field order, sizes, and alignment).
120pub fn as_bytes<T: bytemuck::Pod>(val: &T) -> &[u8] {
121    bytemuck::bytes_of(val)
122}
123
124// ---------------------------------------------------------------------------
125// Capture-mode types (Phase 4e.1 — Graph IR)
126// ---------------------------------------------------------------------------
127
128/// A recorded kernel argument binding.
129///
130/// When the encoder is in capture mode, each `set_buffer` / `set_bytes` call
131/// is stored as a `RecordedBinding` instead of being applied to Metal.
132#[derive(Clone)]
133pub enum RecordedBinding {
134    /// A Metal buffer at the given offset.
135    Buffer {
136        metal_buffer: metal::Buffer,
137        offset: u64,
138    },
139    /// Inline bytes (small constant data, copied).
140    Bytes(Vec<u8>),
141}
142
143/// How to dispatch the recorded kernel.
144#[derive(Clone, Copy, Debug)]
145pub enum DispatchKind {
146    /// `dispatch_threads(grid_size, threadgroup_size)` — Metal picks threadgroup count.
147    Threads,
148    /// `dispatch_thread_groups(threadgroups, threadgroup_size)` — caller specifies threadgroup count.
149    ThreadGroups,
150}
151
152/// Operation kind tag for captured nodes, used by the fusion pass (4e.2).
153///
154/// When the encoder is in capture mode, each dispatch can be tagged with an
155/// `OpKind` so the fusion pass can identify fuseable sequences without
156/// inspecting pipeline names.
157#[derive(Clone, Copy, Debug, PartialEq, Eq)]
158pub enum CapturedOpKind {
159    /// RMS normalization (with learned scale).
160    RmsNorm,
161    /// Elementwise multiply.
162    ElemMul,
163    /// Elementwise add.
164    ElemAdd,
165    /// Scaled dot-product attention (NOT reorderable — breaks lookahead).
166    Sdpa,
167    /// Softmax (NOT reorderable — breaks lookahead).
168    Softmax,
169    /// Any other operation — treated as reorderable by the graph optimizer.
170    Other,
171}
172
173impl CapturedOpKind {
174    /// Whether this captured op kind is safe to reorder past in the graph
175    /// optimizer (Phase 4e.3).
176    ///
177    /// Mirrors the `h_safe` whitelist from llama.cpp's
178    /// `ggml_metal_graph_optimize_reorder`.  Non-safe ops break the 64-node
179    /// lookahead — the reorder pass cannot look past them.
180    pub fn is_reorderable(&self) -> bool {
181        match self {
182            Self::Sdpa | Self::Softmax => false,
183            Self::RmsNorm | Self::ElemMul | Self::ElemAdd | Self::Other => true,
184        }
185    }
186
187    /// Stable string label suitable for embedding in the per-dispatch
188    /// profile dump (ADR-015).  Matches the variant name —
189    /// `Other` is preserved verbatim so an aggregate-by-op_kind sort
190    /// produces a clean "what isn't yet labeled" bucket.
191    pub fn name(&self) -> &'static str {
192        match self {
193            Self::RmsNorm => "RmsNorm",
194            Self::ElemMul => "ElemMul",
195            Self::ElemAdd => "ElemAdd",
196            Self::Sdpa => "Sdpa",
197            Self::Softmax => "Softmax",
198            Self::Other => "Other",
199        }
200    }
201}
202
203/// A memory range annotation: (start_address, end_address).
204///
205/// Represents a contiguous GPU buffer region for conflict detection in the
206/// reorder pass (Phase 4e.3).  Addresses are CPU-visible `contents_ptr()`
207/// values, which on Apple Silicon unified memory equal the GPU addresses.
208pub type MemRange = (usize, usize);
209
210/// A single captured compute dispatch or barrier sentinel.
211///
212/// Created when the encoder is in capture mode.  Replayed later by
213/// `ComputeGraph::encode_sequential()`.
214#[derive(Clone)]
215pub enum CapturedNode {
216    /// A compute dispatch to replay.
217    Dispatch {
218        /// Pipeline state object to bind.
219        pipeline: ComputePipelineState,
220        /// Kernel argument bindings: (slot_index, binding).
221        bindings: Vec<(u64, RecordedBinding)>,
222        /// Grid or threadgroup count (interpretation depends on `dispatch_kind`).
223        threads_per_grid: MTLSize,
224        /// Threads per threadgroup.
225        threads_per_threadgroup: MTLSize,
226        /// Optional threadgroup memory allocations: (index, byte_length).
227        threadgroup_memory: Vec<(u64, u64)>,
228        /// Whether this is a dispatch_threads or dispatch_thread_groups call.
229        dispatch_kind: DispatchKind,
230        /// Operation kind tag for the fusion pass (4e.2).
231        /// Defaults to `Other` if not explicitly set via `set_op_kind()`.
232        op_kind: CapturedOpKind,
233        /// Read buffer ranges for reorder conflict detection (4e.3).
234        /// Populated from `barrier_between` calls in capture mode.
235        reads: Vec<MemRange>,
236        /// Write buffer ranges for reorder conflict detection (4e.3).
237        /// Populated from `barrier_between` calls in capture mode.
238        writes: Vec<MemRange>,
239    },
240    /// A memory barrier sentinel — forces a barrier at replay time.
241    Barrier,
242}
243
244/// Convert a slice of buffer references into capture-mode
245/// [`MemRange`] tuples.  Used by the [`CommandEncoder::dispatch_tracked*`]
246/// family in capture mode — equivalent to the conversion
247/// `GraphSession::barrier_between` does at `graph.rs:1452-1465`.
248///
249/// `(start, end)` uses `contents_ptr() + byte_offset` as the start
250/// and `contents_ptr() + byte_offset + slice_extent` as the end.
251fn ranges_from_buffers(bufs: &[&MlxBuffer]) -> Vec<MemRange> {
252    bufs.iter()
253        .map(|b| {
254            let base = b.contents_ptr() as usize + b.byte_offset() as usize;
255            let extent = (b.byte_len()).saturating_sub(b.byte_offset() as usize);
256            (base, base + extent)
257        })
258        .collect()
259}
260
261/// Apply a slice of `KernelArg` bindings to a compute encoder.
262///
263/// `KernelArg::Buffer(buf)` propagates the `MlxBuffer::byte_offset()` so
264/// `slice_view`-derived sub-buffers are honored automatically — the
265/// kernel sees memory starting at the slice's offset. This matches the
266/// documented contract of `slice_view` and the offset-handling in the
267/// other binding paths in this file (`encode`, `encode_threadgroups`,
268/// `encode_threadgroups_with_shared`, replay). Without it, every
269/// `slice_view`-derived buffer bound via `KernelArg::Buffer` silently
270/// exposes the entire underlying allocation — surfaced by hf2q's
271/// nomic-bert cosine parity bisection (cosine 0.098 → 0.999962
272/// after fix).
273///
274/// `KernelArg::BufferWithOffset(buf, offset)` continues to use the
275/// explicit `offset` argument verbatim (callers asking for an explicit
276/// offset get exactly that, even on sliced buffers). The two API
277/// surfaces are intentional: implicit (sliced views auto-propagate) vs.
278/// explicit (caller-controlled).
279#[inline]
280fn apply_bindings(encoder: &ComputeCommandEncoderRef, bindings: &[(u64, KernelArg<'_>)]) {
281    for &(index, ref arg) in bindings {
282        match arg {
283            KernelArg::Buffer(buf) => {
284                encoder.set_buffer(index, Some(buf.metal_buffer()), buf.byte_offset());
285            }
286            KernelArg::BufferWithOffset(buf, offset) => {
287                encoder.set_buffer(index, Some(buf.metal_buffer()), *offset);
288            }
289            KernelArg::Bytes(bytes) => {
290                encoder.set_bytes(index, bytes.len() as u64, bytes.as_ptr() as *const _);
291            }
292        }
293    }
294}
295
296/// Number of times `commit_and_wait()` has been called (CPU sync points).
297static SYNC_COUNT: AtomicU64 = AtomicU64::new(0);
298
299/// Number of times an encode method has been called (GPU dispatches).
300static DISPATCH_COUNT: AtomicU64 = AtomicU64::new(0);
301
302/// Number of `MTLCommandBuffer` instances created via `CommandEncoder::new`.
303/// Increments once per `device.command_encoder()` call.  Used by hf2q's
304/// `HF2Q_DECODE_PROFILE` instrumentation to measure command-buffer
305/// overhead per decode token (ADR-012 §Optimize / Task #15 follow-up).
306static CMD_BUF_COUNT: AtomicU64 = AtomicU64::new(0);
307
308/// Number of `memory_barrier()` calls that reached the
309/// `objc::msg_send![encoder, memoryBarrierWithScope:]` site.  Capture-mode
310/// no-ops and pre-encoder no-ops are excluded so the count reflects
311/// actual MTL barriers issued.
312///
313/// Always tracked — the increment is one atomic op, ~5 ns.  ADR-015 H4
314/// (Wave 2b hard gate #2) requires per-barrier counter resolution to
315/// confirm-or-falsify the barrier-coalescing lever; xctrace TimeProfiler
316/// at 1 ms sampling cannot resolve `memory_barrier` even though it fires
317/// ~440×/token (`docs/ADR-015-mlx-native-single-cb-decode.md` §"P3a' live
318/// profile pass" hypothesis register row H4).
319static BARRIER_COUNT: AtomicU64 = AtomicU64::new(0);
320
321/// Total nanoseconds spent inside the `objc::msg_send!` barrier site,
322/// summed across all calls.  ONLY updated when the env var
323/// `MLX_PROFILE_BARRIERS=1` is set on the process (cached on first
324/// `memory_barrier` call).  When disabled the timing path is a single
325/// branch + the unconditional barrier dispatch — same hot-path cost as
326/// before this counter was added.
327///
328/// Why env-gated: timing adds 2 × `Instant::now()` (~50–100 ns each via
329/// `mach_absolute_time`) per barrier.  At ~440 barriers/token that is
330/// ~22–44 µs/token of measurement overhead — comparable to what we are
331/// trying to measure.  Production must keep this off; profiling runs
332/// opt-in.
333static BARRIER_NS: AtomicU64 = AtomicU64::new(0);
334
335/// ADR-040 §0.21 decode-gap probe — accumulated GPU-busy time (sum of
336/// `GPUEndTime - GPUStartTime` across `commit_and_wait` command buffers), in ns.
337/// Gated by `HF2Q_GPU_BUSY=1` (reads two ObjC props per sync only when set).
338/// Compare `gpu_busy_ns()` to the wall-clock of a workload to split GPU-busy
339/// from CPU-encode/idle: GPU-busy ≪ wall-clock ⇒ CPU-encode/launch bound.
340static GPU_BUSY_NS: AtomicU64 = AtomicU64::new(0);
341static GPU_BUSY_ON: std::sync::LazyLock<bool> =
342    std::sync::LazyLock::new(|| std::env::var("HF2Q_GPU_BUSY").as_deref() == Ok("1"));
343
344/// Read accumulated GPU-busy ns (see [`GPU_BUSY_NS`]).
345pub fn gpu_busy_ns() -> u64 {
346    GPU_BUSY_NS.load(Ordering::Relaxed)
347}
348
349/// Reset all counters to zero.
350pub fn reset_counters() {
351    SYNC_COUNT.store(0, Ordering::Relaxed);
352    DISPATCH_COUNT.store(0, Ordering::Relaxed);
353    CMD_BUF_COUNT.store(0, Ordering::Relaxed);
354    BARRIER_COUNT.store(0, Ordering::Relaxed);
355    BARRIER_NS.store(0, Ordering::Relaxed);
356    GPU_BUSY_NS.store(0, Ordering::Relaxed);
357    AUTO_BARRIER_COUNT.store(0, Ordering::Relaxed);
358    AUTO_BARRIER_CONCURRENT.store(0, Ordering::Relaxed);
359}
360
361/// Read the current value of `SYNC_COUNT`.
362///
363/// Each call to `commit_and_wait()` increments this counter.
364pub fn sync_count() -> u64 {
365    SYNC_COUNT.load(Ordering::Relaxed)
366}
367
368/// Read the current value of `DISPATCH_COUNT`.
369///
370/// Each call to `encode()`, `encode_threadgroups()`, or
371/// `encode_threadgroups_with_shared()` increments this counter.
372pub fn dispatch_count() -> u64 {
373    DISPATCH_COUNT.load(Ordering::Relaxed)
374}
375
376/// Per-pipeline dispatch bucket support (ADR-028).
377///
378/// Env-gated via `MLX_DISP_BUCKET=1`.  When enabled, every
379/// `encode*` call records its pipeline's label in a global hash map.
380/// This gives a per-kernel breakdown comparable to llama.cpp's
381/// instrumented dispatch site for finding *which* kernels make up
382/// the per-token dispatch budget.
383fn pipeline_buckets()
384    -> &'static std::sync::Mutex<std::collections::HashMap<String, u64>> {
385    static BUCKETS: std::sync::OnceLock<
386        std::sync::Mutex<std::collections::HashMap<String, u64>>,
387    > = std::sync::OnceLock::new();
388    BUCKETS.get_or_init(|| std::sync::Mutex::new(std::collections::HashMap::new()))
389}
390
391/// Cached env-flag check — single load on the hot path.
392fn pipeline_bucket_enabled() -> bool {
393    static CACHED: AtomicI8 = AtomicI8::new(-1);
394    let v = CACHED.load(Ordering::Relaxed);
395    if v >= 0 {
396        return v == 1;
397    }
398    let on = std::env::var("MLX_DISP_BUCKET").as_deref() == Ok("1");
399    CACHED.store(if on { 1 } else { 0 }, Ordering::Relaxed);
400    on
401}
402
403/// Record a dispatch into the per-pipeline bucket if the env-flag is on.
404/// Called from every `encode*` site alongside the `DISPATCH_COUNT` bump.
405#[inline]
406pub(crate) fn bucket_dispatch(pipeline: &ComputePipelineStateRef) {
407    if !pipeline_bucket_enabled() {
408        return;
409    }
410    let label = pipeline.label();
411    if label.is_empty() {
412        return;
413    }
414    if let Ok(mut t) = pipeline_buckets().lock() {
415        *t.entry(label.to_string()).or_insert(0) += 1;
416    }
417}
418
419/// Public dump of `MLX_DISP_BUCKET` data: `Vec<(label, count)>` sorted
420/// descending by count.  Returns empty when env-flag is off / never
421/// recorded.
422pub fn pipeline_dispatch_buckets() -> Vec<(String, u64)> {
423    let mut v: Vec<(String, u64)> = if let Ok(t) = pipeline_buckets().lock() {
424        t.iter().map(|(k, v)| (k.clone(), *v)).collect()
425    } else {
426        Vec::new()
427    };
428    v.sort_by(|a, b| b.1.cmp(&a.1));
429    v
430}
431
432/// Reset the per-pipeline dispatch buckets (typically called at decode
433/// start to ignore prefill / warmup contributions).
434pub fn reset_pipeline_dispatch_buckets() {
435    if let Ok(mut t) = pipeline_buckets().lock() {
436        t.clear();
437    }
438}
439
440/// Read the current value of `CMD_BUF_COUNT`.
441///
442/// Each `CommandEncoder::new` (i.e. each `MlxDevice::command_encoder()`)
443/// increments this counter.  Useful for diagnosing per-dispatch Metal
444/// command-buffer overhead in inner loops.
445pub fn cmd_buf_count() -> u64 {
446    CMD_BUF_COUNT.load(Ordering::Relaxed)
447}
448
449/// Read the current value of `BARRIER_COUNT`.
450///
451/// Each `memory_barrier()` call that reaches the underlying
452/// `objc::msg_send![encoder, memoryBarrierWithScope:]` site increments this
453/// counter.  Capture-mode no-ops and pre-encoder no-ops are excluded.
454/// ADR-015 H4 hypothesis: ~440 barriers/token on the qwen35 decode hot
455/// path (verify against this counter).
456pub fn barrier_count() -> u64 {
457    BARRIER_COUNT.load(Ordering::Relaxed)
458}
459
460/// Read the total nanoseconds spent in the `memoryBarrierWithScope:`
461/// `objc::msg_send!` site.  Only non-zero when `MLX_PROFILE_BARRIERS=1`
462/// was in the environment at the time of the first `memory_barrier()`
463/// call (the env check is cached on first use).
464///
465/// Combined with [`barrier_count`] this gives µs/barrier =
466/// `barrier_total_ns() / 1000 / barrier_count()`.
467pub fn barrier_total_ns() -> u64 {
468    BARRIER_NS.load(Ordering::Relaxed)
469}
470
471/// Whether barrier timing is enabled (env-gated, cached on first check).
472///
473/// Reading the env var via `std::env::var` is itself non-trivial; using
474/// `OnceLock` caches the decision so the per-barrier branch is a single
475/// atomic-load + compare.
476fn barrier_profile_enabled() -> bool {
477    use std::sync::OnceLock;
478    static FLAG: OnceLock<bool> = OnceLock::new();
479    *FLAG.get_or_init(|| {
480        std::env::var("MLX_PROFILE_BARRIERS")
481            .map(|v| v == "1")
482            .unwrap_or(false)
483    })
484}
485
486/// Runtime-flippable gate for the encode trace, so a caller can scope the dump
487/// to a single region (e.g. just `lm_head_batched`) instead of every dispatch.
488static ENCODE_TRACE_ACTIVE: AtomicBool = AtomicBool::new(false);
489
490/// DIAGNOSTIC: enable/disable the per-dispatch + per-barrier encode trace at
491/// runtime (scoped dump). See [`encode_trace_enabled`].
492pub fn set_encode_trace(active: bool) {
493    ENCODE_TRACE_ACTIVE.store(active, Ordering::Relaxed);
494}
495
496/// DIAGNOSTIC (MLX_ENCODE_TRACE=1 OR [`set_encode_trace(true)`]): log every
497/// dispatch + memory_barrier as it is encoded, with the active-encoder pointer —
498/// a textual command-stream dump to verify barrier placement/ordering relative
499/// to dispatches (codex-requested for the mN lm_head→softcap race).
500fn encode_trace_enabled() -> bool {
501    ENCODE_TRACE_ACTIVE.load(Ordering::Relaxed)
502        || std::env::var("MLX_ENCODE_TRACE").as_deref() == Ok("1")
503}
504
505/// Whether `MLX_UNRETAINED_REFS=1` is set in the process environment.
506///
507/// ADR-015 — when true, `CommandEncoder::new_with_residency` opens
508/// each `MTLCommandBuffer` via
509/// [`CommandQueueRef::new_command_buffer_with_unretained_references`]
510/// instead of the default `commandBuffer`.  llama.cpp's per-token decode
511/// CBs use this same call (`/opt/llama.cpp/ggml/src/ggml-metal/`
512/// `ggml-metal-context.m:512` `[queue commandBufferWithUnretainedReferences]`)
513/// and gain ~3-5% wall on M-series GPUs by skipping per-buffer-binding ARC
514/// retains on submit.
515///
516/// **Caller-side prerequisite.**  Every Metal buffer bound to a dispatch
517/// must outlive the CB — see the docstring on
518/// [`CommandEncoder::new_with_residency`] for the full caller contract.
519/// In hf2q, the per-decode-token `MlxBufferPool` (`buffer_pool.rs`)
520/// already keeps ARC clones alive in its `in_use` list across the entire
521/// decode token; routing transient scratches through that pool is the
522/// canonical way to satisfy the contract.
523///
524/// Cached on first read via `OnceLock` to keep the per-CB-construction
525/// branch single-atomic-load fast.  Default OFF so any production decode
526/// run that does NOT explicitly set the var preserves retained-refs
527/// behavior verbatim.
528fn unretained_refs_enabled() -> bool {
529    use std::sync::OnceLock;
530    static FLAG: OnceLock<bool> = OnceLock::new();
531    *FLAG.get_or_init(|| {
532        std::env::var("MLX_UNRETAINED_REFS")
533            .map(|v| v == "1")
534            .unwrap_or(false)
535    })
536}
537
538/// Whether `HF2Q_PIPELINE_TG_MULT_HINT=1` is set.  Cached on first read.
539///
540/// ADR-029 safety gate: when this flag is ON, every
541/// pipeline created via `KernelRegistry` has
542/// `threadGroupSizeIsMultipleOfThreadExecutionWidth(true)`.  Apple's
543/// Metal spec says this is UB unless every dispatched threadgroup is
544/// a multiple of 32.  `assert_tg_size_multiple_of_32_if_hinted()`
545/// asserts the constraint before each dispatch, converting UB to a
546/// safe panic with diagnostic info.
547fn pipeline_tg_mult_hint_enabled() -> bool {
548    use std::sync::OnceLock;
549    static FLAG: OnceLock<bool> = OnceLock::new();
550    *FLAG.get_or_init(|| {
551        std::env::var("HF2Q_PIPELINE_TG_MULT_HINT")
552            .map(|v| v == "1")
553            .unwrap_or(false)
554    })
555}
556
557/// ADR-029 — runtime safety check for
558/// `HF2Q_PIPELINE_TG_MULT_HINT=1`.
559///
560/// When the env flag is ON, the Metal pipeline descriptor sets
561/// `threadGroupSizeIsMultipleOfThreadExecutionWidth(true)`, which
562/// requires every dispatched threadgroup to have
563/// `tg.x * tg.y * tg.z % 32 == 0` on Apple silicon (where
564/// `threadExecutionWidth == 32`).  Violating this is **undefined behavior**
565/// per the Metal spec — the GPU may produce garbage, hang, or panic.
566///
567/// This function panics with a clear message before dispatching so an
568/// offending site is caught immediately instead of silently corrupting
569/// output.  Returns immediately (one atomic-load cost) when the env
570/// flag is OFF.  Includes the pipeline's label in the panic message
571/// (Step 1r) so the offending kernel can be identified without a
572/// debugger.
573#[inline]
574fn assert_tg_size_multiple_of_32_if_hinted(
575    tg: MTLSize,
576    pipeline: &ComputePipelineStateRef,
577) {
578    if !pipeline_tg_mult_hint_enabled() {
579        return;
580    }
581    let total = tg.width.saturating_mul(tg.height).saturating_mul(tg.depth);
582    if total % 32 != 0 {
583        let label = pipeline.label();
584        panic!(
585            "ADR-029 Step 1q safety: HF2Q_PIPELINE_TG_MULT_HINT=1 requires \
586             threadgroup_size.x * y * z to be a multiple of 32 (Apple's \
587             threadExecutionWidth).  Got tg=({}, {}, {}) → total={} → \
588             {} mod 32 = {}.  Pipeline label: \"{}\".  Either fix the \
589             dispatch site to use a multiple-of-32 threadgroup, or unset \
590             HF2Q_PIPELINE_TG_MULT_HINT.",
591            tg.width, tg.height, tg.depth, total, total, total % 32,
592            label
593        );
594    }
595}
596
597/// Whether `HF2Q_AUTO_BARRIER=1` is set in the process environment.
598///
599/// ADR-015 —when true, every [`CommandEncoder::dispatch_tracked`]
600/// call consults a [`MemRanges`](crate::mem_ranges::MemRanges) tracker
601/// and auto-emits a `memoryBarrierWithScope:` exactly when the new
602/// dispatch's read/write ranges conflict with previously-recorded
603/// ranges (mirrors llama.cpp's `ggml_metal_op_concurrency_check` at
604/// `/opt/llama.cpp/ggml/src/ggml-metal/ggml-metal-ops.cpp:147-225`).
605/// When false, `dispatch_tracked` collapses to the same code path as
606/// `encode*` — no tracking, no auto-barriers — preserving sourdough
607/// behavior for any caller that opts into the tracked API but runs
608/// without the env gate.
609///
610/// Cached on first read via `OnceLock`.  Default OFF — production
611/// decode/prefill keeps its hand-placed `enc.memory_barrier()` calls
612/// until the migration to auto-barrier.
613fn auto_barrier_enabled() -> bool {
614    use std::sync::OnceLock;
615    static FLAG: OnceLock<bool> = OnceLock::new();
616    *FLAG.get_or_init(|| {
617        std::env::var("HF2Q_AUTO_BARRIER")
618            .map(|v| v == "1")
619            .unwrap_or(false)
620    })
621}
622
623/// Number of `memory_barrier()` calls auto-emitted by
624/// [`CommandEncoder::dispatch_tracked`] under
625/// `HF2Q_AUTO_BARRIER=1`.  Disjoint from [`BARRIER_COUNT`] —
626/// auto-barriers also bump `BARRIER_COUNT` since they go through
627/// `memory_barrier()`, so this counter measures only the
628/// auto-emitted subset.
629static AUTO_BARRIER_COUNT: AtomicU64 = AtomicU64::new(0);
630
631/// Number of `dispatch_tracked` calls whose mem-ranges check returned
632/// "concurrent" (no barrier needed).  Together with
633/// [`AUTO_BARRIER_COUNT`] this measures the elision rate of the
634/// dataflow barrier: `concurrent / (concurrent + barriers)` is the
635/// fraction of dispatches that ran inside the previous concurrent
636/// group rather than starting a new one.
637static AUTO_BARRIER_CONCURRENT: AtomicU64 = AtomicU64::new(0);
638
639// ---------------------------------------------------------------------------
640// ADR-015 —per-dispatch GPU sampling support
641// ---------------------------------------------------------------------------
642
643/// Hard cap on per-CB sample-buffer sample count (Risk R4 in
644/// PROFILING-KIT-DESIGN §A.7).
645///
646/// Empirically verified on Apple Silicon (M-series, macOS 26): the
647/// underlying `MTLCounterSampleBufferDescriptor.sampleCount` is bounded
648/// by a per-buffer **byte-size** limit of 32768 B.  At 8 bytes per
649/// `MTLCounterResultTimestamp` sample that maps to a sample-count
650/// ceiling of `32_768 / 8 = 4096`.  We allocate two samples per
651/// dispatch (start + end), so this ceiling = 2048 dispatches per CB.
652/// Decode CBs (~120 dispatches) fit comfortably; long prefill CBs
653/// (~6K dispatches per design §A.7) will truncate after 2048 — see
654/// [`Self::sample_dispatch_pre`] for the truncation path.  Future
655/// iter can chunk-resolve every 2K dispatches.
656///
657/// The original design constant of 32_768 (PROFILING-KIT-DESIGN §A.7)
658/// was based on Apple's documented ~64K-per-buffer "practical" limit,
659/// but the measured constraint on this hardware is the 32 KB byte
660/// budget.  Setting the budget below that would underutilize the
661/// buffer; setting it above causes
662/// `newCounterSampleBufferWithDescriptor` to fail with `Invalid sample
663/// buffer length: <bytes> B. Expected range: 8 -> 32768`.
664const MAX_SAMPLES_PER_CB: u64 = 4096;
665
666/// Whether the per-CB warning about a missing `MTLCommonCounterSetTimestamp`
667/// has been emitted yet.  Risk R1: if `device.counter_sets()` does not
668/// return a set named `"timestamp"` (case-insensitive), we degrade the
669/// per-dispatch path to a no-op and log once via stderr.
670static TIMESTAMP_SET_WARN_LOGGED: AtomicU64 = AtomicU64::new(0);
671
672/// Pending per-dispatch metadata that pairs with sample indices `2i`
673/// (start) and `2i+1` (end) inside the CB's `MTLCounterSampleBuffer`.
674/// Resolved by `CommandEncoder::resolve_dispatch_samples` at CB
675/// commit-time and converted to [`crate::kernel_profile::DispatchEntry`]
676/// before being pushed to the global table.
677#[derive(Clone, Debug)]
678struct PendingDispatchMeta {
679    op_kind: &'static str,
680    dispatch_index: u32,
681}
682
683/// Read the cumulative number of auto-emitted barriers across all
684/// encoders since process start (or last [`reset_counters`]).
685pub fn auto_barrier_count() -> u64 {
686    AUTO_BARRIER_COUNT.load(Ordering::Relaxed)
687}
688
689/// Read the cumulative number of `dispatch_tracked` calls that did NOT
690/// emit a barrier (ran concurrent with the previous group).
691pub fn auto_barrier_concurrent_count() -> u64 {
692    AUTO_BARRIER_CONCURRENT.load(Ordering::Relaxed)
693}
694
695/// Issue the underlying Metal `memoryBarrierWithScope:` ObjC msg_send.
696///
697/// Held in its own `#[inline(never)]` function so xctrace / Instruments
698/// has a stable Rust frame to attribute barrier time against, separate
699/// from the surrounding encoder accounting.  Per ADR-015 §P3a' Codex
700/// review Q2: TimeProfiler at 1 ms sampling cannot see this site when
701/// inlined; an explicit non-inline frame plus the [`BARRIER_NS`] counter
702/// closes the H4 hard gate.
703#[inline(never)]
704fn issue_metal_buffer_barrier(encoder: &ComputeCommandEncoderRef) {
705    // MTLBarrierScopeBuffers = 1 << 0 = 1.
706    const MTL_BARRIER_SCOPE_BUFFERS: u64 = 1;
707    unsafe {
708        let _: () =
709            objc::msg_send![encoder, memoryBarrierWithScope: MTL_BARRIER_SCOPE_BUFFERS];
710    }
711}
712
713/// A batched compute command encoder.
714///
715/// Keeps a single Metal `ComputeCommandEncoder` alive across multiple
716/// dispatches.  The encoder is created on the first dispatch and ended
717/// only when the command buffer is committed.  This mirrors candle's
718/// `compute_per_buffer` pattern and avoids per-dispatch encoder overhead.
719///
720/// # Typical usage
721///
722/// ```ignore
723/// let mut enc = device.command_encoder()?;
724/// // Multiple dispatches share the same compute encoder:
725/// enc.encode_threadgroups(pipeline1, &buffers1, tg1, tg_size1);
726/// enc.encode_threadgroups(pipeline2, &buffers2, tg2, tg_size2);
727/// enc.commit_and_wait()?;
728/// ```
729pub struct CommandEncoder {
730    cmd_buf: CommandBuffer,
731    /// Owned clone of the originating command queue.
732    ///
733    /// ADR-019: stored at `new_with_residency` time so
734    /// downstream lifecycle code (e.g. `EncoderSession::reset_for_next_stage`
735    /// in Phase 0b-B) can open a fresh `CommandBuffer` from the same queue
736    /// after a non-blocking `commit_stage()`. metal-rs 0.33's
737    /// `CommandQueue` type is `Send + Sync` via `foreign_obj_type!`
738    /// (`/Users/robert/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/metal-0.33.0/src/lib.rs:179`),
739    /// so adding this field preserves the existing unsafe `Send` impl
740    /// on `CommandEncoder` (declared below).
741    ///
742    /// ADR-019 (CONSUMED): read by
743    /// [`Self::reset_command_buffer`] to spawn a fresh `CommandBuffer`
744    /// after a non-blocking `commit*` so `EncoderSession::reset_for_next_stage`
745    /// can chain stage CBs without re-constructing the encoder. Holding a
746    /// clone here (rather than a `&CommandQueue` borrow) avoids a lifetime
747    /// parameter on `CommandEncoder` that would propagate through every
748    /// consumer in mlx-native and hf2q.
749    queue: CommandQueue,
750    // SAFETY marker: see unsafe Send impl below.
751    /// Raw pointer to the persistent compute encoder.
752    /// Non-null when a compute pass is active.
753    /// The encoder borrows from `cmd_buf` but we cannot express this
754    /// lifetime in safe Rust, so we use a raw pointer.
755    /// SAFETY: the pointer is valid as long as `cmd_buf` is alive and
756    /// `end_encoding()` has not been called on it.
757    active_encoder: *const ComputeCommandEncoderRef,
758    /// When `Some`, dispatches are recorded here instead of being encoded
759    /// into Metal.  Set via `start_capture()`, extracted via `take_capture()`.
760    capture: Option<Vec<CapturedNode>>,
761    /// Op kind tag for the NEXT captured dispatch.  Set via `set_op_kind()`,
762    /// consumed (reset to `Other`) when a dispatch is captured.
763    pending_op_kind: CapturedOpKind,
764    /// Pending read buffer ranges for the NEXT captured dispatch.
765    /// Set via `set_pending_buffer_ranges()`, consumed when the next dispatch
766    /// is captured.  Used by the reorder pass (Phase 4e.3).
767    pending_reads: Vec<MemRange>,
768    /// Pending write buffer ranges for the NEXT captured dispatch.
769    pending_writes: Vec<MemRange>,
770    /// ADR-015:residency set whose pending add/remove
771    /// staging is flushed at every `commit*` boundary.
772    ///
773    /// Cloned from the device at `device.command_encoder()` time. `None`
774    /// when residency sets are disabled (HF2Q_NO_RESIDENCY=1, macOS<15,
775    /// or test-only `CommandEncoder::new` from a residency-less queue).
776    residency_set: Option<ResidencySet>,
777    /// ADR-015: dataflow barrier inference state.
778    ///
779    /// Populated only when `HF2Q_AUTO_BARRIER=1` is set at process
780    /// start (cached via [`auto_barrier_enabled`]).  Each
781    /// [`Self::dispatch_tracked`] call consults this state to decide
782    /// whether a Metal memory barrier is required; on conflict the
783    /// barrier is emitted, the state is reset, and the new dispatch's
784    /// ranges seed the next concurrent group.  When the env gate is
785    /// off, `dispatch_tracked` collapses to its untracked equivalent
786    /// and this field is left empty for the encoder's lifetime.
787    ///
788    /// The field is always present (zero-sized when empty) so the
789    /// gate-off branch is a single bool-load + early return rather
790    /// than an allocation/Option indirection.
791    mem_ranges: MemRanges,
792    /// ADR-015 (per-dispatch profiling): the sample buffer for
793    /// `MTLCounterSampleBuffer.sampleCounters` calls that bracket every
794    /// `encode*` dispatch in this CB.  Lazily allocated on first
795    /// dispatch when `MLX_PROFILE_DISPATCH=1`; `None` otherwise.
796    /// Released (set to `None`) inside `resolve_dispatch_samples` after
797    /// the CB completes — re-allocated on the next `encode*` if the env
798    /// gate stays set.
799    sample_buffer: Option<CounterSampleBuffer>,
800    /// ADR-015:pending per-dispatch metadata that pairs with
801    /// sample indices `2*i` and `2*i+1` inside `sample_buffer`.  Each
802    /// `encode*` call appends one entry (when sampling is active);
803    /// `resolve_dispatch_samples` drains the vec at commit time.
804    pending_dispatch_meta: Vec<PendingDispatchMeta>,
805    /// ADR-015:0-based dispatch ordinal within the current CB.
806    /// Incremented in every `encode*` site after taking the pending
807    /// op_kind; reset to 0 inside `resolve_dispatch_samples`.
808    dispatch_in_cb: u32,
809    /// ADR-015:most recent label set via `apply_labels`, used
810    /// as the per-dispatch `cb_label` field.  `String::new()` until
811    /// `commit_and_wait_labeled` / `commit_labeled` is called.
812    last_label: String,
813}
814
815/// SAFETY: CommandEncoder is safe to Send across threads provided that:
816/// 1. Only one thread accesses the encoder at a time (exclusive ownership).
817/// 2. The encoder is not used concurrently from multiple threads.
818///
819/// Metal command buffers and compute encoders are thread-safe for exclusive
820/// access (Apple documentation: "You can create command buffers, encode
821/// commands, and submit them from any thread"). The raw pointer
822/// `active_encoder` borrows from `cmd_buf` and is valid as long as
823/// `cmd_buf` is alive — this invariant holds across thread boundaries
824/// because both fields move together.
825///
826/// This matches llama.cpp's pattern of encoding command buffers on GCD
827/// worker threads via `dispatch_apply`, and is used for the dual-buffer
828/// pipeline where buf1 is encoded on a worker thread while buf0 executes.
829unsafe impl Send for CommandEncoder {}
830
831impl CommandEncoder {
832    /// Create a new command encoder from the given command queue.
833    ///
834    /// This immediately creates a Metal command buffer.
835    ///
836    /// # Why retained references
837    ///
838    /// We use the regular `commandBuffer` (Metal retains every bound
839    /// resource for the lifetime of the buffer) rather than
840    /// `commandBufferWithUnretainedReferences`.  llama.cpp uses unretained
841    /// refs for an additional perf bump (~3-5% on M-series GPUs), but the
842    /// hf2q dispatch pattern allocates many transient scratch buffers
843    /// inside helper functions (`apply_proj` → `weight_bf16_owned`,
844    /// `apply_pre_norm` → `params`, etc.) that go out of scope at the
845    /// helper's return.  With unretained refs the metal::Buffer's ARC
846    /// drops to zero, freeing the underlying GPU memory before the
847    /// dispatch executes.  Verified 2026-04-26: switching to unretained
848    /// hits "Command buffer error: GPU command buffer completed with
849    /// error status" on the first MoE FFN dispatch.
850    ///
851    /// To enable unretained refs in the future, every helper that
852    /// allocates and dispatches must thread its scratch buffers up to a
853    /// caller scope that outlives the eventual commit, OR all such
854    /// scratch must come from the per-decode-token pool (which already
855    /// ARC-retains in its in_use list).  Today the lm_head + router-
856    /// download paths are still unpooled.
857    #[allow(dead_code)]
858    pub(crate) fn new(queue: &CommandQueue) -> Result<Self> {
859        Self::new_with_residency(queue, None)
860    }
861
862    /// Create a new command encoder, optionally bound to a residency set so
863    /// `commit*` boundaries can flush deferred add/remove staging.
864    ///
865    /// ADR-015:the encoder's `commit_and_wait`,
866    /// `commit_and_wait_labeled`, `commit`, `commit_labeled`,
867    /// `commit_wait_with_gpu_time` all call
868    /// [`ResidencySet::flush_pending`](ResidencySet::flush_pending) before
869    /// submitting the Metal command buffer. This converts the
870    /// per-allocation `[set commit]` storm
871    /// (~880 commits/decode-token) into
872    /// at most one commit per CB submission — mirrors llama.cpp's
873    /// `ggml-metal-device.m:1378-1382` pattern (batch addAllocation in
874    /// loop, commit ONCE).
875    ///
876    /// ADR-015: when the `MLX_UNRETAINED_REFS=1` env var is set at
877    /// process start, this constructor uses
878    /// [`CommandQueueRef::new_command_buffer_with_unretained_references`]
879    /// instead of `new_command_buffer`.  llama.cpp's per-token decode CBs
880    /// use `commandBufferWithUnretainedReferences` (see
881    /// `/opt/llama.cpp/ggml/src/ggml-metal/ggml-metal-context.m:512`) which
882    /// skips Metal's per-buffer-binding ARC-retain on submit and saves
883    /// ~3-5% on M-series GPUs (per the docstring above).
884    ///
885    /// **Caller contract under unretained refs.**  Every Metal buffer bound
886    /// to a dispatch in this CB MUST outlive the CB's GPU completion.  In
887    /// the hf2q decode path, that means every transient scratch must be
888    /// either (a) backed by the per-decode-token arena pool
889    /// (`MlxBufferPool` keeps an ARC clone in `in_use` until the next
890    /// `reset` — see `buffer_pool.rs:60`) or (b) hoisted to a caller scope
891    /// that lives across the terminal `commit_and_wait_labeled`.  Helpers
892    /// in `apply_proj` / `apply_pre_norm` / lm_head cast / router-download
893    /// that allocated transients via `device.alloc_buffer` and dropped
894    /// them at function return MUST be lifted to `pooled_alloc_buffer`
895    /// before `MLX_UNRETAINED_REFS=1` is enabled, or the first MoE FFN
896    /// dispatch will crash with "Command buffer error: GPU command buffer
897    /// completed with error status" (verified 2026-04-26).
898    ///
899    /// The default (`MLX_UNRETAINED_REFS` unset) preserves retained-refs
900    /// behavior verbatim — this is the sourdough-safe path.
901    pub(crate) fn new_with_residency(
902        queue: &CommandQueue,
903        residency_set: Option<ResidencySet>,
904    ) -> Result<Self> {
905        let cmd_buf = if unretained_refs_enabled() {
906            queue.new_command_buffer_with_unretained_references().to_owned()
907        } else {
908            queue.new_command_buffer().to_owned()
909        };
910        CMD_BUF_COUNT.fetch_add(1, Ordering::Relaxed);
911        Ok(Self {
912            cmd_buf,
913            queue: queue.to_owned(),
914            active_encoder: std::ptr::null(),
915            capture: None,
916            pending_op_kind: CapturedOpKind::Other,
917            pending_reads: Vec::new(),
918            pending_writes: Vec::new(),
919            residency_set,
920            mem_ranges: MemRanges::new(),
921            sample_buffer: None,
922            pending_dispatch_meta: Vec::new(),
923            dispatch_in_cb: 0,
924            last_label: String::new(),
925        })
926    }
927
928    /// Enable capture mode.
929    ///
930    /// All subsequent dispatch and barrier calls will be recorded into a
931    /// `Vec<CapturedNode>` instead of being encoded into Metal.
932    /// Call `take_capture()` to extract the recorded nodes.
933    pub fn start_capture(&mut self) {
934        self.capture = Some(Vec::with_capacity(128));
935    }
936
937    /// Whether the encoder is currently in capture mode.
938    pub fn is_capturing(&self) -> bool {
939        self.capture.is_some()
940    }
941
942    /// Extract the captured nodes, ending capture mode.
943    ///
944    /// Returns `None` if capture mode was not active.
945    pub fn take_capture(&mut self) -> Option<Vec<CapturedNode>> {
946        self.capture.take()
947    }
948
949    /// Tag the NEXT captured dispatch with the given operation kind.
950    ///
951    /// The tag is consumed (reset to `Other`) after the next dispatch is
952    /// captured.  Only meaningful in capture mode — has no effect on
953    /// direct-dispatch encoding.
954    ///
955    /// Used by op dispatch functions to annotate captures for the fusion
956    /// pass (Phase 4e.2).
957    pub fn set_op_kind(&mut self, kind: CapturedOpKind) {
958        self.pending_op_kind = kind;
959    }
960
961    /// Consume and return the pending op kind, resetting it to `Other`.
962    fn take_pending_op_kind(&mut self) -> CapturedOpKind {
963        let kind = self.pending_op_kind;
964        self.pending_op_kind = CapturedOpKind::Other;
965        kind
966    }
967
968    /// Stash buffer range annotations for the NEXT captured dispatch.
969    ///
970    /// Called by `GraphSession::barrier_between()` in capture mode to record
971    /// which buffers the next dispatch reads from and writes to.  The ranges
972    /// are consumed by the next `encode_*` call and attached to the captured
973    /// `CapturedNode::Dispatch`.
974    ///
975    /// Only meaningful in capture mode — has no effect on direct-dispatch.
976    pub fn set_pending_buffer_ranges(&mut self, reads: Vec<MemRange>, writes: Vec<MemRange>) {
977        self.pending_reads = reads;
978        self.pending_writes = writes;
979    }
980
981    /// Patch the last captured dispatch node's empty reads/writes with the
982    /// given ranges. No-op if not capturing, or if the last node isn't a
983    /// Dispatch, or if its ranges are already populated.
984    ///
985    /// Used by `GraphSession::track_dispatch` in recording mode to annotate
986    /// dispatches that were called without a preceding `barrier_between`.
987    pub fn annotate_last_dispatch_if_missing(&mut self, reads: Vec<MemRange>, writes: Vec<MemRange>) {
988        if let Some(ref mut nodes) = self.capture {
989            if let Some(CapturedNode::Dispatch { reads: r, writes: w, .. }) = nodes.last_mut() {
990                if r.is_empty() && !reads.is_empty() {
991                    *r = reads;
992                }
993                if w.is_empty() && !writes.is_empty() {
994                    *w = writes;
995                }
996            }
997        }
998    }
999
1000    /// Consume and return the pending buffer range annotations.
1001    fn take_pending_buffer_ranges(&mut self) -> (Vec<MemRange>, Vec<MemRange>) {
1002        let reads = std::mem::take(&mut self.pending_reads);
1003        let writes = std::mem::take(&mut self.pending_writes);
1004        (reads, writes)
1005    }
1006
1007    /// Record buffer bindings into `RecordedBinding` form.
1008    fn record_buffer_bindings(buffers: &[(u64, &MlxBuffer)]) -> Vec<(u64, RecordedBinding)> {
1009        buffers
1010            .iter()
1011            .map(|&(index, buf)| {
1012                (
1013                    index,
1014                    RecordedBinding::Buffer {
1015                        metal_buffer: buf.metal_buffer().clone(),
1016                        offset: buf.byte_offset(),
1017                    },
1018                )
1019            })
1020            .collect()
1021    }
1022
1023    /// Record `KernelArg` bindings into `RecordedBinding` form.
1024    ///
1025    /// `KernelArg::Buffer(buf)` records `buf.byte_offset()` so capture →
1026    /// replay round-trips of `slice_view`-derived buffers preserve their
1027    /// offsets, matching `record_buffer_bindings`'s behavior at line 382.
1028    fn record_arg_bindings(bindings: &[(u64, KernelArg<'_>)]) -> Vec<(u64, RecordedBinding)> {
1029        bindings
1030            .iter()
1031            .map(|(index, arg)| {
1032                let recorded = match arg {
1033                    KernelArg::Buffer(buf) => RecordedBinding::Buffer {
1034                        metal_buffer: buf.metal_buffer().clone(),
1035                        offset: buf.byte_offset(),
1036                    },
1037                    KernelArg::BufferWithOffset(buf, offset) => RecordedBinding::Buffer {
1038                        metal_buffer: buf.metal_buffer().clone(),
1039                        offset: *offset,
1040                    },
1041                    KernelArg::Bytes(bytes) => RecordedBinding::Bytes(bytes.to_vec()),
1042                };
1043                (*index, recorded)
1044            })
1045            .collect()
1046    }
1047
1048    /// Get or create the persistent compute encoder.
1049    ///
1050    /// On the first call, creates a new compute encoder from the command
1051    /// buffer.  On subsequent calls, returns the existing one.
1052    ///
1053    /// SAFETY: The returned reference borrows from `self.cmd_buf` which is
1054    /// alive for the lifetime of this `CommandEncoder`.  The raw pointer is
1055    /// valid until `end_active_encoder()` is called.
1056    #[inline]
1057    fn get_or_create_encoder(&mut self) -> &ComputeCommandEncoderRef {
1058        if self.active_encoder.is_null() {
1059            // Use MTLDispatchTypeConcurrent to allow independent dispatches
1060            // to overlap on the GPU.  Memory barriers are inserted between
1061            // dependent dispatches via `memory_barrier()`.
1062            //
1063            // ADR-015 probe: HF2Q_FORCE_SERIAL_DISPATCH=1 falls back
1064            // to MTLDispatchType::Serial — every dispatch waits for the
1065            // previous to complete, eliminating concurrent-dispatch race
1066            // windows. Used to falsify Hypothesis (g): missing memory_barrier
1067            // calls between dependent dispatches cause cold-run logit
1068            // non-determinism via thread-race on a shared buffer.
1069            let dispatch_type = if std::env::var("HF2Q_FORCE_SERIAL_DISPATCH")
1070                .map(|v| v == "1")
1071                .unwrap_or(false)
1072            {
1073                MTLDispatchType::Serial
1074            } else {
1075                MTLDispatchType::Concurrent
1076            };
1077            let encoder = self
1078                .cmd_buf
1079                .compute_command_encoder_with_dispatch_type(dispatch_type);
1080            // ADR-040 §0.21c-track2 ROOT FIX (codex): `compute_command_encoder_*`
1081            // returns a borrowed `&ComputeCommandEncoderRef` to an AUTORELEASED
1082            // object (metal-rs commandbuffer.rs warning / gfx-rs/metal-rs#128).
1083            // We hold this pointer across many Rust calls (and an autorelease-pool
1084            // drain can release it out from under us), so messages sent through it
1085            // — including `memoryBarrierWithScope:` — are NOT guaranteed to land on
1086            // a live, owned encoder, defeating cross-dispatch ordering. llama RETAINS
1087            // its concurrent encoder (`[res->obj retain]`, released at end). Match
1088            // that: take a strong +1 ref now, balanced by `release` in
1089            // `end_active_encoder`. Keeps MTLDispatchTypeConcurrent +
1090            // memoryBarrierWithScope unchanged.
1091            let _: () = unsafe { msg_send![encoder, retain] };
1092            self.active_encoder = encoder as *const ComputeCommandEncoderRef;
1093        }
1094        // SAFETY: active_encoder is non-null and points to a valid encoder
1095        // owned by cmd_buf.
1096        unsafe { &*self.active_encoder }
1097    }
1098
1099    /// End the active compute encoder if one exists.
1100    #[inline]
1101    fn end_active_encoder(&mut self) {
1102        if !self.active_encoder.is_null() {
1103            // SAFETY: the pointer was obtained from cmd_buf.new_compute_command_encoder()
1104            // and has not been ended yet.
1105            unsafe { &*self.active_encoder }.end_encoding();
1106            // ADR-040 §0.21c-track2: balance the +1 `retain` taken in
1107            // get_or_create_encoder (the encoder is no longer needed after
1108            // end_encoding). SAFETY: active_encoder is the non-null pointer we
1109            // retained; this drops our strong ref.
1110            let enc = self.active_encoder;
1111            unsafe {
1112                let _: () = msg_send![enc, release];
1113            }
1114            self.active_encoder = std::ptr::null();
1115        }
1116    }
1117
1118    /// Insert a memory barrier with scope `MTLBarrierScopeBuffers`.
1119    ///
1120    /// When the encoder uses `MTLDispatchTypeConcurrent`, all dispatches can
1121    /// execute concurrently unless separated by a barrier.  Call this between
1122    /// dispatches where the later dispatch reads a buffer written by an
1123    /// earlier one.
1124    ///
1125    /// This is the same pattern llama.cpp uses:
1126    /// `[encoder memoryBarrierWithScope:MTLBarrierScopeBuffers]`
1127    #[allow(unexpected_cfgs)]
1128    pub fn memory_barrier(&mut self) {
1129        if let Some(ref mut nodes) = self.capture {
1130            nodes.push(CapturedNode::Barrier);
1131            return;
1132        }
1133        if self.active_encoder.is_null() {
1134            return;
1135        }
1136        BARRIER_COUNT.fetch_add(1, Ordering::Relaxed);
1137        // ADR-029: when HF2Q_AUTO_BARRIER=1, hand-placed
1138        // barriers must reset the MemRanges tracker so partial migration to
1139        // `dispatch_tracked_*` stays correct.  A hand-placed `memory_barrier()`
1140        // drains the GPU at this point; any tracked dispatch after it
1141        // should start with a fresh cumulative state instead of false-
1142        // conflicting against ranges recorded before this barrier.
1143        // No-op under default HF2Q_AUTO_BARRIER=0 (tracker is empty).
1144        if auto_barrier_enabled() {
1145            self.mem_ranges.reset();
1146        }
1147        // SAFETY: active_encoder is non-null and valid.
1148        let encoder = unsafe { &*self.active_encoder };
1149        if encode_trace_enabled() {
1150            eprintln!("[ENCODE-TRACE] BARRIER  enc={:p}", self.active_encoder);
1151        }
1152        if barrier_profile_enabled() {
1153            // mach_absolute_time path — only on when MLX_PROFILE_BARRIERS=1.
1154            let start = std::time::Instant::now();
1155            issue_metal_buffer_barrier(encoder);
1156            let elapsed_ns = start.elapsed().as_nanos() as u64;
1157            BARRIER_NS.fetch_add(elapsed_ns, Ordering::Relaxed);
1158        } else {
1159            issue_metal_buffer_barrier(encoder);
1160        }
1161    }
1162
1163    /// Set the compute pipeline state for subsequent dispatches.
1164    ///
1165    /// This begins a new compute pass if one is not already active.
1166    pub fn set_pipeline(&mut self, pipeline: &ComputePipelineStateRef) {
1167        let encoder = self.get_or_create_encoder();
1168        encoder.set_compute_pipeline_state(pipeline);
1169    }
1170
1171    /// Bind a buffer to a compute kernel argument slot.
1172    ///
1173    /// The `index` corresponds to the `[[buffer(N)]]` attribute in the MSL shader.
1174    pub fn set_buffer(&self, index: u64, buffer: &MlxBuffer) {
1175        let _ = (index, buffer);
1176    }
1177
1178    /// Dispatch threads on the GPU.
1179    pub fn dispatch_threads(&self, grid_size: MTLSize, threadgroup_size: MTLSize) {
1180        let _ = (grid_size, threadgroup_size);
1181    }
1182
1183    /// Encode a complete compute pass: set pipeline, bind buffers, dispatch.
1184    ///
1185    /// Reuses the persistent compute encoder — no per-dispatch encoder
1186    /// creation overhead.
1187    ///
1188    /// # Arguments
1189    ///
1190    /// * `pipeline`         — The compiled compute pipeline to execute.
1191    /// * `buffers`          — Slice of `(index, &MlxBuffer)` pairs for buffer bindings.
1192    /// * `grid_size`        — Total number of threads to launch.
1193    /// * `threadgroup_size` — Threads per threadgroup.
1194    pub fn encode(
1195        &mut self,
1196        pipeline: &ComputePipelineStateRef,
1197        buffers: &[(u64, &MlxBuffer)],
1198        grid_size: MTLSize,
1199        threadgroup_size: MTLSize,
1200    ) {
1201        DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
1202        bucket_dispatch(pipeline);
1203        let op_kind = self.take_pending_op_kind();
1204        let (pending_reads, pending_writes) = self.take_pending_buffer_ranges();
1205        if let Some(ref mut nodes) = self.capture {
1206            nodes.push(CapturedNode::Dispatch {
1207                pipeline: pipeline.to_owned(),
1208                bindings: Self::record_buffer_bindings(buffers),
1209                threads_per_grid: grid_size,
1210                threads_per_threadgroup: threadgroup_size,
1211                threadgroup_memory: Vec::new(),
1212                dispatch_kind: DispatchKind::Threads,
1213                op_kind,
1214                reads: pending_reads,
1215                writes: pending_writes,
1216            });
1217            return;
1218        }
1219        self.ensure_sample_buffer();
1220        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1221        // SAFETY: encoder_ptr aliases &self via active_encoder which we
1222        // know is non-null after get_or_create_encoder; this pattern is
1223        // used throughout the file (see memory_barrier).
1224        let encoder = unsafe { &*encoder_ptr };
1225        encoder.set_compute_pipeline_state(pipeline);
1226        for &(index, buf) in buffers {
1227            encoder.set_buffer(index, Some(buf.metal_buffer()), buf.byte_offset());
1228        }
1229        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1230        assert_tg_size_multiple_of_32_if_hinted(threadgroup_size, pipeline);
1231        encoder.dispatch_threads(grid_size, threadgroup_size);
1232        self.sample_dispatch_post(encoder, pre_idx);
1233    }
1234
1235    /// Encode a compute pass using threadgroups instead of raw thread counts.
1236    ///
1237    /// Reuses the persistent compute encoder — no per-dispatch encoder
1238    /// creation overhead.
1239    pub fn encode_threadgroups(
1240        &mut self,
1241        pipeline: &ComputePipelineStateRef,
1242        buffers: &[(u64, &MlxBuffer)],
1243        threadgroups: MTLSize,
1244        threadgroup_size: MTLSize,
1245    ) {
1246        DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
1247        bucket_dispatch(pipeline);
1248        let op_kind = self.take_pending_op_kind();
1249        let (pending_reads, pending_writes) = self.take_pending_buffer_ranges();
1250        if let Some(ref mut nodes) = self.capture {
1251            nodes.push(CapturedNode::Dispatch {
1252                pipeline: pipeline.to_owned(),
1253                bindings: Self::record_buffer_bindings(buffers),
1254                threads_per_grid: threadgroups,
1255                threads_per_threadgroup: threadgroup_size,
1256                threadgroup_memory: Vec::new(),
1257                dispatch_kind: DispatchKind::ThreadGroups,
1258                op_kind,
1259                reads: pending_reads,
1260                writes: pending_writes,
1261            });
1262            return;
1263        }
1264        self.ensure_sample_buffer();
1265        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1266        // SAFETY: see encode() above.
1267        let encoder = unsafe { &*encoder_ptr };
1268        if encode_trace_enabled() {
1269            eprintln!(
1270                "[ENCODE-TRACE] DISPATCH(tg) enc={:p} pipeline={:p} tgs=({},{},{})",
1271                encoder_ptr, pipeline as *const _,
1272                threadgroups.width, threadgroups.height, threadgroups.depth
1273            );
1274        }
1275        encoder.set_compute_pipeline_state(pipeline);
1276        for &(index, buf) in buffers {
1277            encoder.set_buffer(index, Some(buf.metal_buffer()), buf.byte_offset());
1278        }
1279        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1280        assert_tg_size_multiple_of_32_if_hinted(threadgroup_size, pipeline);
1281        encoder.dispatch_thread_groups(threadgroups, threadgroup_size);
1282        self.sample_dispatch_post(encoder, pre_idx);
1283    }
1284
1285    /// Encode a compute pass using threadgroups with shared threadgroup memory.
1286    ///
1287    /// Like [`encode_threadgroups`](Self::encode_threadgroups), but additionally
1288    /// allocates threadgroup memory at the specified indices.  This is required
1289    /// for kernels that use `threadgroup` memory (e.g. reductions in rms_norm
1290    /// and softmax).
1291    ///
1292    /// # Arguments
1293    ///
1294    /// * `pipeline`         — The compiled compute pipeline to execute.
1295    /// * `buffers`          — Slice of `(index, &MlxBuffer)` pairs for buffer bindings.
1296    /// * `threadgroup_mem`  — Slice of `(index, byte_length)` pairs for threadgroup memory.
1297    /// * `threadgroups`     — Number of threadgroups to dispatch.
1298    /// * `threadgroup_size` — Threads per threadgroup.
1299    pub fn encode_threadgroups_with_shared(
1300        &mut self,
1301        pipeline: &ComputePipelineStateRef,
1302        buffers: &[(u64, &MlxBuffer)],
1303        threadgroup_mem: &[(u64, u64)],
1304        threadgroups: MTLSize,
1305        threadgroup_size: MTLSize,
1306    ) {
1307        DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
1308        bucket_dispatch(pipeline);
1309        let op_kind = self.take_pending_op_kind();
1310        let (pending_reads, pending_writes) = self.take_pending_buffer_ranges();
1311        if let Some(ref mut nodes) = self.capture {
1312            nodes.push(CapturedNode::Dispatch {
1313                pipeline: pipeline.to_owned(),
1314                bindings: Self::record_buffer_bindings(buffers),
1315                threads_per_grid: threadgroups,
1316                threads_per_threadgroup: threadgroup_size,
1317                threadgroup_memory: threadgroup_mem.to_vec(),
1318                dispatch_kind: DispatchKind::ThreadGroups,
1319                op_kind,
1320                reads: pending_reads,
1321                writes: pending_writes,
1322            });
1323            return;
1324        }
1325        self.ensure_sample_buffer();
1326        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1327        // SAFETY: see encode() above.
1328        let encoder = unsafe { &*encoder_ptr };
1329        if encode_trace_enabled() {
1330            eprintln!(
1331                "[ENCODE-TRACE] DISPATCH(tg+sh) enc={:p} pipeline={:p} tgs=({},{},{})",
1332                encoder_ptr, pipeline as *const _,
1333                threadgroups.width, threadgroups.height, threadgroups.depth
1334            );
1335        }
1336        encoder.set_compute_pipeline_state(pipeline);
1337        for &(index, buf) in buffers {
1338            encoder.set_buffer(index, Some(buf.metal_buffer()), buf.byte_offset());
1339        }
1340        for &(index, byte_length) in threadgroup_mem {
1341            encoder.set_threadgroup_memory_length(index, byte_length);
1342        }
1343        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1344        assert_tg_size_multiple_of_32_if_hinted(threadgroup_size, pipeline);
1345        encoder.dispatch_thread_groups(threadgroups, threadgroup_size);
1346        self.sample_dispatch_post(encoder, pre_idx);
1347    }
1348
1349    /// Encode a dispatch with mixed buffer/bytes bindings (dispatch_threads).
1350    ///
1351    /// Reuses the persistent compute encoder.
1352    pub fn encode_with_args(
1353        &mut self,
1354        pipeline: &ComputePipelineStateRef,
1355        bindings: &[(u64, KernelArg<'_>)],
1356        grid_size: MTLSize,
1357        threadgroup_size: MTLSize,
1358    ) {
1359        DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
1360        bucket_dispatch(pipeline);
1361        let op_kind = self.take_pending_op_kind();
1362        let (pending_reads, pending_writes) = self.take_pending_buffer_ranges();
1363        if let Some(ref mut nodes) = self.capture {
1364            nodes.push(CapturedNode::Dispatch {
1365                pipeline: pipeline.to_owned(),
1366                bindings: Self::record_arg_bindings(bindings),
1367                threads_per_grid: grid_size,
1368                threads_per_threadgroup: threadgroup_size,
1369                threadgroup_memory: Vec::new(),
1370                dispatch_kind: DispatchKind::Threads,
1371                op_kind,
1372                reads: pending_reads,
1373                writes: pending_writes,
1374            });
1375            return;
1376        }
1377        self.ensure_sample_buffer();
1378        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1379        // SAFETY: see encode() above.
1380        let encoder = unsafe { &*encoder_ptr };
1381        encoder.set_compute_pipeline_state(pipeline);
1382        apply_bindings(encoder, bindings);
1383        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1384        assert_tg_size_multiple_of_32_if_hinted(threadgroup_size, pipeline);
1385        encoder.dispatch_threads(grid_size, threadgroup_size);
1386        self.sample_dispatch_post(encoder, pre_idx);
1387    }
1388
1389    /// Encode a dispatch with mixed buffer/bytes bindings (dispatch_thread_groups).
1390    ///
1391    /// Reuses the persistent compute encoder.
1392    pub fn encode_threadgroups_with_args(
1393        &mut self,
1394        pipeline: &ComputePipelineStateRef,
1395        bindings: &[(u64, KernelArg<'_>)],
1396        threadgroups: MTLSize,
1397        threadgroup_size: MTLSize,
1398    ) {
1399        DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
1400        bucket_dispatch(pipeline);
1401        let op_kind = self.take_pending_op_kind();
1402        let (pending_reads, pending_writes) = self.take_pending_buffer_ranges();
1403        if let Some(ref mut nodes) = self.capture {
1404            nodes.push(CapturedNode::Dispatch {
1405                pipeline: pipeline.to_owned(),
1406                bindings: Self::record_arg_bindings(bindings),
1407                threads_per_grid: threadgroups,
1408                threads_per_threadgroup: threadgroup_size,
1409                threadgroup_memory: Vec::new(),
1410                dispatch_kind: DispatchKind::ThreadGroups,
1411                op_kind,
1412                reads: pending_reads,
1413                writes: pending_writes,
1414            });
1415            return;
1416        }
1417        self.ensure_sample_buffer();
1418        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1419        // SAFETY: see encode() above.
1420        let encoder = unsafe { &*encoder_ptr };
1421        if encode_trace_enabled() {
1422            eprintln!(
1423                "[ENCODE-TRACE] DISPATCH enc={:p} pipeline={:p} tgs=({},{},{})",
1424                encoder_ptr, pipeline as *const _,
1425                threadgroups.width, threadgroups.height, threadgroups.depth
1426            );
1427        }
1428        encoder.set_compute_pipeline_state(pipeline);
1429        apply_bindings(encoder, bindings);
1430        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1431        assert_tg_size_multiple_of_32_if_hinted(threadgroup_size, pipeline);
1432        encoder.dispatch_thread_groups(threadgroups, threadgroup_size);
1433        self.sample_dispatch_post(encoder, pre_idx);
1434    }
1435
1436    /// Encode a dispatch with mixed buffer/bytes bindings and shared memory.
1437    ///
1438    /// Reuses the persistent compute encoder.
1439    pub fn encode_threadgroups_with_args_and_shared(
1440        &mut self,
1441        pipeline: &ComputePipelineStateRef,
1442        bindings: &[(u64, KernelArg<'_>)],
1443        threadgroup_mem: &[(u64, u64)],
1444        threadgroups: MTLSize,
1445        threadgroup_size: MTLSize,
1446    ) {
1447        DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
1448        bucket_dispatch(pipeline);
1449        let op_kind = self.take_pending_op_kind();
1450        let (pending_reads, pending_writes) = self.take_pending_buffer_ranges();
1451        if let Some(ref mut nodes) = self.capture {
1452            nodes.push(CapturedNode::Dispatch {
1453                pipeline: pipeline.to_owned(),
1454                bindings: Self::record_arg_bindings(bindings),
1455                threads_per_grid: threadgroups,
1456                threads_per_threadgroup: threadgroup_size,
1457                threadgroup_memory: threadgroup_mem.to_vec(),
1458                dispatch_kind: DispatchKind::ThreadGroups,
1459                op_kind,
1460                reads: pending_reads,
1461                writes: pending_writes,
1462            });
1463            return;
1464        }
1465        self.ensure_sample_buffer();
1466        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1467        // SAFETY: see encode() above.
1468        let encoder = unsafe { &*encoder_ptr };
1469        if encode_trace_enabled() {
1470            eprintln!(
1471                "[ENCODE-TRACE] DISPATCH(args+sh) enc={:p} pipeline={:p} tgs=({},{},{})",
1472                encoder_ptr, pipeline as *const _,
1473                threadgroups.width, threadgroups.height, threadgroups.depth
1474            );
1475        }
1476        encoder.set_compute_pipeline_state(pipeline);
1477        apply_bindings(encoder, bindings);
1478        for &(index, byte_length) in threadgroup_mem {
1479            encoder.set_threadgroup_memory_length(index, byte_length);
1480        }
1481        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1482        assert_tg_size_multiple_of_32_if_hinted(threadgroup_size, pipeline);
1483        encoder.dispatch_thread_groups(threadgroups, threadgroup_size);
1484        self.sample_dispatch_post(encoder, pre_idx);
1485    }
1486
1487    // -----------------------------------------------------------------
1488    // ADR-015 —dataflow-driven auto-barrier dispatch family.
1489    //
1490    // These mirrors of `encode_threadgroups*_with_args*` take explicit
1491    // `reads: &[&MlxBuffer]` and `writes: &[&MlxBuffer]` slices.  When
1492    // the process started with `HF2Q_AUTO_BARRIER=1`, the encoder's
1493    // [`MemRanges`] tracker checks the new ranges against the
1494    // cumulative state since the last barrier; on conflict it emits
1495    // `memory_barrier()` and resets the state before recording the
1496    // new ranges.  When the env gate is unset, the check is skipped
1497    // entirely and the dispatch is applied identically to the
1498    // matching `encode_*` method — sourdough-safe by construction.
1499    //
1500    // Capture mode: the `reads`/`writes` ranges are recorded onto the
1501    // captured node via the existing `pending_reads`/`pending_writes`
1502    // mechanism, so a `dispatch_tracked` call inside capture mode is
1503    // equivalent to `set_pending_buffer_ranges + encode_*`.
1504    //
1505    // This API surface is opt-in; every call to `dispatch_tracked`
1506    // from a production code path lives behind an explicit caller
1507    // decision.
1508    // -----------------------------------------------------------------
1509
1510    /// Auto-barrier-aware dispatch with [`KernelArg`] bindings (uses
1511    /// `dispatch_thread_groups`).
1512    ///
1513    /// Behaves identically to
1514    /// [`encode_threadgroups_with_args`](Self::encode_threadgroups_with_args)
1515    /// when `HF2Q_AUTO_BARRIER` is unset.  When set, consults the
1516    /// per-encoder [`MemRanges`] tracker:
1517    ///
1518    /// * Conflict (RAW/WAR/WAW on a same-buffer range) → emit
1519    ///   `memory_barrier()`, increment [`AUTO_BARRIER_COUNT`], reset
1520    ///   the tracker, then dispatch and seed the new concurrent group
1521    ///   with this dispatch's ranges.
1522    /// * No conflict → increment [`AUTO_BARRIER_CONCURRENT`], record
1523    ///   the ranges into the cumulative state, dispatch.
1524    pub fn dispatch_tracked_threadgroups_with_args(
1525        &mut self,
1526        pipeline: &ComputePipelineStateRef,
1527        bindings: &[(u64, KernelArg<'_>)],
1528        reads: &[&MlxBuffer],
1529        writes: &[&MlxBuffer],
1530        threadgroups: MTLSize,
1531        threadgroup_size: MTLSize,
1532    ) {
1533        // Capture mode: stash ranges + delegate to the standard encode.
1534        // The ranges flow through `pending_reads`/`pending_writes` and
1535        // attach to the captured `Dispatch` node — identical to what
1536        // `GraphSession::barrier_between` already does in capture mode.
1537        if self.is_capturing() {
1538            let read_ranges = ranges_from_buffers(reads);
1539            let write_ranges = ranges_from_buffers(writes);
1540            self.set_pending_buffer_ranges(read_ranges, write_ranges);
1541            self.encode_threadgroups_with_args(pipeline, bindings, threadgroups, threadgroup_size);
1542            return;
1543        }
1544
1545        if auto_barrier_enabled() {
1546            self.maybe_auto_barrier(reads, writes);
1547        }
1548
1549        self.encode_threadgroups_with_args(pipeline, bindings, threadgroups, threadgroup_size);
1550    }
1551
1552    /// Auto-barrier-aware dispatch with [`KernelArg`] bindings + shared
1553    /// threadgroup memory.
1554    ///
1555    /// See [`dispatch_tracked_threadgroups_with_args`](Self::dispatch_tracked_threadgroups_with_args)
1556    /// for the behavioral contract; this variant additionally takes a
1557    /// `threadgroup_mem` slice that is forwarded to
1558    /// [`encode_threadgroups_with_args_and_shared`](Self::encode_threadgroups_with_args_and_shared).
1559    ///
1560    /// The 8-argument signature mirrors the existing
1561    /// `encode_threadgroups_with_args_and_shared` plus the two
1562    /// dataflow slices; `clippy::too_many_arguments` is allowed
1563    /// because each parameter is load-bearing for either the dispatch
1564    /// (pipeline/bindings/threadgroups/threadgroup_size/shared_mem)
1565    /// or the auto-barrier (reads/writes).
1566    #[allow(clippy::too_many_arguments)]
1567    pub fn dispatch_tracked_threadgroups_with_args_and_shared(
1568        &mut self,
1569        pipeline: &ComputePipelineStateRef,
1570        bindings: &[(u64, KernelArg<'_>)],
1571        threadgroup_mem: &[(u64, u64)],
1572        reads: &[&MlxBuffer],
1573        writes: &[&MlxBuffer],
1574        threadgroups: MTLSize,
1575        threadgroup_size: MTLSize,
1576    ) {
1577        if self.is_capturing() {
1578            let read_ranges = ranges_from_buffers(reads);
1579            let write_ranges = ranges_from_buffers(writes);
1580            self.set_pending_buffer_ranges(read_ranges, write_ranges);
1581            self.encode_threadgroups_with_args_and_shared(
1582                pipeline,
1583                bindings,
1584                threadgroup_mem,
1585                threadgroups,
1586                threadgroup_size,
1587            );
1588            return;
1589        }
1590
1591        if auto_barrier_enabled() {
1592            self.maybe_auto_barrier(reads, writes);
1593        }
1594
1595        self.encode_threadgroups_with_args_and_shared(
1596            pipeline,
1597            bindings,
1598            threadgroup_mem,
1599            threadgroups,
1600            threadgroup_size,
1601        );
1602    }
1603
1604    /// Auto-barrier-aware dispatch using `(slot, &MlxBuffer)` bindings
1605    /// (uses `dispatch_thread_groups`).
1606    ///
1607    /// Convenience wrapper for callers that don't need
1608    /// [`KernelArg::Bytes`] inline-byte arguments.  See
1609    /// [`dispatch_tracked_threadgroups_with_args`](Self::dispatch_tracked_threadgroups_with_args)
1610    /// for behavioral contract.
1611    pub fn dispatch_tracked_threadgroups(
1612        &mut self,
1613        pipeline: &ComputePipelineStateRef,
1614        buffers: &[(u64, &MlxBuffer)],
1615        reads: &[&MlxBuffer],
1616        writes: &[&MlxBuffer],
1617        threadgroups: MTLSize,
1618        threadgroup_size: MTLSize,
1619    ) {
1620        if self.is_capturing() {
1621            let read_ranges = ranges_from_buffers(reads);
1622            let write_ranges = ranges_from_buffers(writes);
1623            self.set_pending_buffer_ranges(read_ranges, write_ranges);
1624            self.encode_threadgroups(pipeline, buffers, threadgroups, threadgroup_size);
1625            return;
1626        }
1627
1628        if auto_barrier_enabled() {
1629            self.maybe_auto_barrier(reads, writes);
1630        }
1631
1632        self.encode_threadgroups(pipeline, buffers, threadgroups, threadgroup_size);
1633    }
1634
1635    /// Auto-barrier-aware dispatch using `(slot, &MlxBuffer)` bindings
1636    /// **plus shared threadgroup memory** (uses `dispatch_thread_groups`).
1637    ///
1638    /// Mirrors [`encode_threadgroups_with_shared`](Self::encode_threadgroups_with_shared)
1639    /// — convenience variant for kernels that allocate threadgroup
1640    /// memory (reductions in `rms_norm`, `softmax`, etc.) but don't
1641    /// need [`KernelArg::Bytes`] inline-byte arguments.  See
1642    /// [`dispatch_tracked_threadgroups_with_args`](Self::dispatch_tracked_threadgroups_with_args)
1643    /// for the behavioral contract; the only addition here is the
1644    /// `threadgroup_mem` slice forwarded to the underlying encode.
1645    ///
1646    /// Used by the 5 `rms_norm.rs` callsites
1647    /// (`/opt/mlx-native/src/ops/rms_norm.rs:124,236,443, 516,589`)
1648    /// that use `encode_threadgroups_with_shared` and need dataflow
1649    /// tracking for auto-barrier migration.
1650    ///
1651    /// 7-argument signature; `clippy::too_many_arguments` is allowed
1652    /// because each parameter is load-bearing for either the dispatch
1653    /// (pipeline/buffers/threadgroups/threadgroup_size/shared_mem) or
1654    /// the auto-barrier (reads/writes).
1655    #[allow(clippy::too_many_arguments)]
1656    pub fn dispatch_tracked_threadgroups_with_shared(
1657        &mut self,
1658        pipeline: &ComputePipelineStateRef,
1659        buffers: &[(u64, &MlxBuffer)],
1660        threadgroup_mem: &[(u64, u64)],
1661        reads: &[&MlxBuffer],
1662        writes: &[&MlxBuffer],
1663        threadgroups: MTLSize,
1664        threadgroup_size: MTLSize,
1665    ) {
1666        if self.is_capturing() {
1667            let read_ranges = ranges_from_buffers(reads);
1668            let write_ranges = ranges_from_buffers(writes);
1669            self.set_pending_buffer_ranges(read_ranges, write_ranges);
1670            self.encode_threadgroups_with_shared(
1671                pipeline,
1672                buffers,
1673                threadgroup_mem,
1674                threadgroups,
1675                threadgroup_size,
1676            );
1677            return;
1678        }
1679
1680        if auto_barrier_enabled() {
1681            self.maybe_auto_barrier(reads, writes);
1682        }
1683
1684        self.encode_threadgroups_with_shared(
1685            pipeline,
1686            buffers,
1687            threadgroup_mem,
1688            threadgroups,
1689            threadgroup_size,
1690        );
1691    }
1692
1693    /// Auto-barrier-aware `dispatch_threads` variant with
1694    /// [`KernelArg`] bindings.
1695    ///
1696    /// Mirrors [`encode_with_args`](Self::encode_with_args) — the
1697    /// `dispatch_threads` (per-thread grid) flavor, as opposed to the
1698    /// `dispatch_thread_groups` flavor of
1699    /// [`dispatch_tracked_threadgroups_with_args`](Self::dispatch_tracked_threadgroups_with_args).
1700    /// See that method for the behavioral contract.
1701    ///
1702    /// Callers using per-thread grids — `rope.rs:108` (IMROPE),
1703    /// `sigmoid_mul.rs:76` (sigmoid-mul), and `encode_helpers.rs:41`
1704    /// (kv_cache_copy) — need a `dispatch_threads` flavor of the
1705    /// tracked dispatch because their grid sizes are expressed in
1706    /// threads, not threadgroups.
1707    ///
1708    /// Note: the simpler `(slot, &MlxBuffer)` form (from
1709    /// [`encode`](Self::encode)) is a special case of this method —
1710    /// callers can wrap each binding as `KernelArg::Buffer(buf)` to
1711    /// reuse this single tracked variant rather than introducing a
1712    /// fifth one.
1713    pub fn dispatch_tracked_threads_with_args(
1714        &mut self,
1715        pipeline: &ComputePipelineStateRef,
1716        bindings: &[(u64, KernelArg<'_>)],
1717        reads: &[&MlxBuffer],
1718        writes: &[&MlxBuffer],
1719        grid_size: MTLSize,
1720        threadgroup_size: MTLSize,
1721    ) {
1722        if self.is_capturing() {
1723            let read_ranges = ranges_from_buffers(reads);
1724            let write_ranges = ranges_from_buffers(writes);
1725            self.set_pending_buffer_ranges(read_ranges, write_ranges);
1726            self.encode_with_args(pipeline, bindings, grid_size, threadgroup_size);
1727            return;
1728        }
1729
1730        if auto_barrier_enabled() {
1731            self.maybe_auto_barrier(reads, writes);
1732        }
1733
1734        self.encode_with_args(pipeline, bindings, grid_size, threadgroup_size);
1735    }
1736
1737    /// Dispatch a pre-baked record.
1738    ///
1739    /// ADR-029 — fast path for decode hot kernels
1740    /// whose pipeline + threadgroup geometry + params bytes are
1741    /// load-time-immutable.  `runtime_buffers` must be in the same
1742    /// order as `rec.buffer_slots`.
1743    ///
1744    /// Equivalent Metal command stream to:
1745    /// ```ignore
1746    /// encoder.encode_threadgroups_with_args_and_shared(
1747    ///     &rec.pipeline,
1748    ///     bindings,  // = runtime_buffers zipped with buffer_slots + (params_slot, Bytes(&rec.params_bytes))
1749    ///     &rec.threadgroup_mem,
1750    ///     rec.threadgroups,
1751    ///     rec.threads_per_tg,
1752    /// );
1753    /// ```
1754    /// — but skips the kernel-name lookup, ggml_type match arms,
1755    /// MTLSize::new, and param-struct field stores that the unbaked
1756    /// path performs on every call.
1757    ///
1758    /// Capture mode and auto-barrier are supported identically to
1759    /// `encode_threadgroups_with_args_and_shared`.  The caller is
1760    /// expected to have called `set_pending_buffer_ranges` (capture)
1761    /// or rely on auto-barrier for dataflow correctness before this
1762    /// call, matching the contract of the unbaked dispatch_tracked_*
1763    /// family.
1764    pub fn dispatch_record(
1765        &mut self,
1766        rec: &DispatchRecord,
1767        runtime_buffers: &[&MlxBuffer],
1768    ) {
1769        debug_assert_eq!(
1770            rec.buffer_slots.len(),
1771            runtime_buffers.len(),
1772            "dispatch_record: runtime_buffers count must match buffer_slots ({}); got {}",
1773            rec.buffer_slots.len(),
1774            runtime_buffers.len(),
1775        );
1776
1777        DISPATCH_COUNT.fetch_add(1, Ordering::Relaxed);
1778        bucket_dispatch(&rec.pipeline);
1779        let op_kind_override = self.take_pending_op_kind();
1780        // If a caller set an op_kind override via set_op_kind(), honor it;
1781        // otherwise use the baked op_kind from the record.
1782        let op_kind = if matches!(op_kind_override, CapturedOpKind::Other) {
1783            rec.op_kind
1784        } else {
1785            op_kind_override
1786        };
1787        let (pending_reads, pending_writes) = self.take_pending_buffer_ranges();
1788
1789        if let Some(ref mut nodes) = self.capture {
1790            // Reconstruct bindings for replay — runtime buffers first,
1791            // params bytes (if any) last.  Order matches what the
1792            // baked-path runtime encoding produces below.
1793            let cap = runtime_buffers.len() + if rec.params_bytes.is_empty() { 0 } else { 1 };
1794            let mut bindings: Vec<(u64, RecordedBinding)> = Vec::with_capacity(cap);
1795            for (slot, buf) in rec.buffer_slots.iter().zip(runtime_buffers.iter()) {
1796                bindings.push((
1797                    *slot,
1798                    RecordedBinding::Buffer {
1799                        metal_buffer: buf.metal_buffer().to_owned(),
1800                        offset: buf.byte_offset(),
1801                    },
1802                ));
1803            }
1804            if !rec.params_bytes.is_empty() {
1805                bindings.push((
1806                    rec.params_slot,
1807                    RecordedBinding::Bytes(rec.params_bytes.clone()),
1808                ));
1809            }
1810            nodes.push(CapturedNode::Dispatch {
1811                pipeline: rec.pipeline.clone(),
1812                bindings,
1813                threads_per_grid: rec.threadgroups,
1814                threads_per_threadgroup: rec.threads_per_tg,
1815                threadgroup_memory: rec.threadgroup_mem.clone(),
1816                dispatch_kind: DispatchKind::ThreadGroups,
1817                op_kind,
1818                reads: pending_reads,
1819                writes: pending_writes,
1820            });
1821            return;
1822        }
1823
1824        self.ensure_sample_buffer();
1825        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1826        // SAFETY: see encode() above — encoder reference outlives this scope
1827        // because `get_or_create_encoder` only mutates the `Option` wrapper.
1828        let encoder = unsafe { &*encoder_ptr };
1829        encoder.set_compute_pipeline_state(&rec.pipeline);
1830        for (slot, buf) in rec.buffer_slots.iter().zip(runtime_buffers.iter()) {
1831            encoder.set_buffer(*slot, Some(buf.metal_buffer()), buf.byte_offset());
1832        }
1833        if !rec.params_bytes.is_empty() {
1834            encoder.set_bytes(
1835                rec.params_slot,
1836                rec.params_bytes.len() as u64,
1837                rec.params_bytes.as_ptr() as *const _,
1838            );
1839        }
1840        for &(idx, len) in rec.threadgroup_mem.iter() {
1841            encoder.set_threadgroup_memory_length(idx, len);
1842        }
1843        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1844        // Skip assert_tg_size_multiple_of_32_if_hinted: bake-time
1845        // construction already validated the geometry.
1846        encoder.dispatch_thread_groups(rec.threadgroups, rec.threads_per_tg);
1847        self.sample_dispatch_post(encoder, pre_idx);
1848    }
1849
1850    /// Run the dataflow check, emit a barrier on conflict, and record
1851    /// the dispatch's ranges into the cumulative state.
1852    ///
1853    /// Always called *before* the underlying `encode_*` method
1854    /// applies the dispatch.  Mirrors lines 220-225 of
1855    /// `ggml-metal-ops.cpp` (`concurrency_check + concurrency_reset +
1856    /// concurrency_add` around each node).
1857    fn maybe_auto_barrier(
1858        &mut self,
1859        reads: &[&MlxBuffer],
1860        writes: &[&MlxBuffer],
1861    ) {
1862        if self.mem_ranges.check_dispatch(reads, writes) {
1863            // Concurrent — no barrier needed; just record the new ranges.
1864            self.mem_ranges.add_dispatch(reads, writes);
1865            AUTO_BARRIER_CONCURRENT.fetch_add(1, Ordering::Relaxed);
1866        } else {
1867            // Conflict — emit barrier, reset state, seed new group.
1868            //
1869            // `memory_barrier()` itself increments `BARRIER_COUNT` and,
1870            // when `MLX_PROFILE_BARRIERS=1`, accumulates `BARRIER_NS`.
1871            // We additionally bump `AUTO_BARRIER_COUNT` so the
1872            // "auto-emitted vs hand-placed" subset is queryable.
1873            self.memory_barrier();
1874            self.mem_ranges.reset();
1875            self.mem_ranges.add_dispatch(reads, writes);
1876            AUTO_BARRIER_COUNT.fetch_add(1, Ordering::Relaxed);
1877        }
1878    }
1879
1880    /// Force a barrier and reset the auto-barrier tracker.
1881    ///
1882    /// Use at boundaries where the caller knows a barrier is required
1883    /// regardless of dataflow — typically before reading data back to
1884    /// CPU, or at the end of an op group whose internal dependencies
1885    /// the tracker can't see (e.g. host-driven memcpy).
1886    ///
1887    /// Equivalent to `memory_barrier()` plus a `MemRanges::reset()`
1888    /// when `HF2Q_AUTO_BARRIER=1`; equivalent to plain
1889    /// `memory_barrier()` otherwise.
1890    pub fn force_barrier_and_reset_tracker(&mut self) {
1891        self.memory_barrier();
1892        if auto_barrier_enabled() {
1893            self.mem_ranges.reset();
1894        }
1895    }
1896
1897    /// Diagnostic accessor — number of ranges currently recorded in
1898    /// this encoder's [`MemRanges`] tracker.  Always zero unless
1899    /// `HF2Q_AUTO_BARRIER=1` and at least one `dispatch_tracked` call
1900    /// has fired since the last conflict.
1901    #[inline]
1902    pub fn mem_ranges_len(&self) -> usize {
1903        self.mem_ranges.len()
1904    }
1905
1906    /// Replay a single captured dispatch node into this encoder.
1907    ///
1908    /// This is the inverse of capture: it takes a previously recorded
1909    /// `CapturedNode::Dispatch` and encodes it into the live Metal encoder.
1910    /// Barrier nodes are handled by the caller (ComputeGraph::encode_sequential).
1911    ///
1912    /// Does NOT increment `DISPATCH_COUNT` — that was already counted at
1913    /// capture time.
1914    pub fn replay_dispatch(
1915        &mut self,
1916        pipeline: &ComputePipelineStateRef,
1917        bindings: &[(u64, RecordedBinding)],
1918        threadgroup_memory: &[(u64, u64)],
1919        threads_per_grid: MTLSize,
1920        threads_per_threadgroup: MTLSize,
1921        dispatch_kind: DispatchKind,
1922    ) {
1923        // ADR-015: mirror the per-dispatch sampling
1924        // scaffold here so capture-mode-recorded graphs (graph.rs
1925        // encode_sequential / encode_with_barriers / encode_chunk_with
1926        // _barriers) still produce per-dispatch entries.  The replay
1927        // path bypasses encode*; without this hook the per-dispatch
1928        // table would be silently empty for any model that uses
1929        // `GraphExecutor::begin_recorded`.
1930        //
1931        // Captured `op_kind` is forwarded via `pending_op_kind`: the
1932        // graph replay layer at graph.rs:197/236/727 sets it from the
1933        // CapturedNode.op_kind before calling replay_dispatch.
1934        self.ensure_sample_buffer();
1935        let op_kind = self.take_pending_op_kind();
1936        let encoder_ptr = self.get_or_create_encoder() as *const ComputeCommandEncoderRef;
1937        // SAFETY: see encode() above.
1938        let encoder = unsafe { &*encoder_ptr };
1939        encoder.set_compute_pipeline_state(pipeline);
1940        for (index, binding) in bindings {
1941            match binding {
1942                RecordedBinding::Buffer { metal_buffer, offset } => {
1943                    encoder.set_buffer(*index, Some(metal_buffer), *offset);
1944                }
1945                RecordedBinding::Bytes(bytes) => {
1946                    encoder.set_bytes(
1947                        *index,
1948                        bytes.len() as u64,
1949                        bytes.as_ptr() as *const _,
1950                    );
1951                }
1952            }
1953        }
1954        for &(index, byte_length) in threadgroup_memory {
1955            encoder.set_threadgroup_memory_length(index, byte_length);
1956        }
1957        let pre_idx = self.sample_dispatch_pre(encoder, op_kind);
1958        match dispatch_kind {
1959            DispatchKind::Threads => {
1960                assert_tg_size_multiple_of_32_if_hinted(threads_per_threadgroup, pipeline);
1961                encoder.dispatch_threads(threads_per_grid, threads_per_threadgroup);
1962            }
1963            DispatchKind::ThreadGroups => {
1964                assert_tg_size_multiple_of_32_if_hinted(threads_per_threadgroup, pipeline);
1965                encoder.dispatch_thread_groups(threads_per_grid, threads_per_threadgroup);
1966            }
1967        }
1968        self.sample_dispatch_post(encoder, pre_idx);
1969    }
1970
1971    /// Flush any pending residency-set add/remove staging.
1972    ///
1973    /// Hooked at every commit boundary so per-allocation
1974    /// [`ResidencySet::add_allocation`](ResidencySet::add_allocation) and
1975    /// [`ResidencySet::remove_allocation`](ResidencySet::remove_allocation)
1976    /// calls (as fired by `MlxDevice::alloc_buffer` and
1977    /// `MlxBufferStorage::Drop`) collapse into at most ONE `[set commit]`
1978    /// per CB submission. Mirrors llama.cpp's
1979    /// `ggml-metal-device.m:1378-1382` (batch addAllocation in loop,
1980    /// commit ONCE).
1981    #[inline]
1982    fn flush_residency_pending(&self) {
1983        if let Some(set) = self.residency_set.as_ref() {
1984            set.flush_pending();
1985        }
1986    }
1987
1988    // ----------------------------------------------------------------
1989    // ADR-015 —per-dispatch sample buffer lifecycle
1990    // ----------------------------------------------------------------
1991
1992    /// Allocate the per-CB `MTLCounterSampleBuffer` if it has not been
1993    /// allocated yet for this CB.
1994    ///
1995    /// No-op when `MLX_PROFILE_DISPATCH` is unset, when the buffer is
1996    /// already present, or when the device does not expose a counter
1997    /// set named `"timestamp"` (Risk R1 — graceful degrade with a
1998    /// one-shot stderr warning).
1999    ///
2000    /// The sample buffer is sized to [`MAX_SAMPLES_PER_CB`] (32_768).
2001    /// This is the start-+-end pair budget — i.e. ≤ 16,384 dispatches
2002    /// per CB.  Above that ceiling, additional dispatches will skip
2003    /// sampling (see [`Self::sample_dispatch_pre`]).
2004    #[inline]
2005    fn ensure_sample_buffer(&mut self) {
2006        if !crate::kernel_profile::is_dispatch_enabled() {
2007            return;
2008        }
2009        if self.sample_buffer.is_some() {
2010            return;
2011        }
2012        // Discover the timestamp counter set.  metal-rs 0.33 does not
2013        // export the `MTLCommonCounterSetTimestamp` constant, so we
2014        // name-match `"timestamp"` case-insensitively.  Reach the
2015        // device via the cmd_buf's `device` selector (metal-rs 0.33
2016        // exposes `CommandQueue::device` but not `CommandBuffer::device`,
2017        // so we go through ObjC directly).
2018        let device: &metal::DeviceRef = unsafe {
2019            let cb = &*self.cmd_buf;
2020            msg_send![cb, device]
2021        };
2022        // ADR-015 —Apple Silicon hardware constraint (NEW Risk
2023        // discovered at impl time, supersedes design §A.7).  M-series
2024        // GPUs (verified: AGXG17XFamilyComputeContext = M5 Max series,
2025        // macOS 26) only support counter sampling AtStageBoundary —
2026        // i.e. between compute *passes*, not between dispatches inside
2027        // a persistent compute encoder.  Calling
2028        // `sampleCountersInBuffer:atSampleIndex:withBarrier:` on such
2029        // hardware aborts with `failed assertion ... not supported on
2030        // this device`.  The persistent-encoder design (mlx-native uses
2031        // ONE compute encoder per CB to amortize ~800 encoder
2032        // create/end cycles per forward pass — see `get_or_create_
2033        // encoder` docstring) is incompatible with stage-boundary-only
2034        // sampling, so on Apple Silicon we degrade per-dispatch
2035        // profiling to a no-op and log once.  Per-CB profiling is
2036        // unaffected (it uses MTLCommandBuffer.GPUStartTime/
2037        // GPUEndTime, which are always available).
2038        //
2039        // Future: if Apple ever ships AtDispatchBoundary support on
2040        // Apple Silicon, this branch becomes a true cap check.  For
2041        // now, the kit infrastructure is in place; only the sample-
2042        // point cooperates.
2043        if !device.supports_counter_sampling(MTLCounterSamplingPoint::AtDispatchBoundary) {
2044            if TIMESTAMP_SET_WARN_LOGGED
2045                .compare_exchange(0, 1, Ordering::Relaxed, Ordering::Relaxed)
2046                .is_ok()
2047            {
2048                eprintln!(
2049                    "[mlx-native] MLX_PROFILE_DISPATCH=1 ignored: \
2050                     device {:?} does NOT support \
2051                     MTLCounterSamplingPointAtDispatchBoundary \
2052                     (Apple Silicon limitation; only AtStageBoundary \
2053                     is supported, which is incompatible with the \
2054                     persistent compute-encoder pattern). \
2055                     MLX_PROFILE_CB=1 still produces per-CB GPU times.",
2056                    device.name()
2057                );
2058            }
2059            return;
2060        }
2061        let counter_sets = device.counter_sets();
2062        let timestamp_set = counter_sets
2063            .iter()
2064            .find(|c: &&metal::CounterSet| c.name().eq_ignore_ascii_case("timestamp"));
2065        let timestamp_set = match timestamp_set {
2066            Some(s) => s,
2067            None => {
2068                // Risk R1: device does not expose a timestamp set.
2069                // Log once and degrade to no-op (sample_buffer stays None).
2070                if TIMESTAMP_SET_WARN_LOGGED
2071                    .compare_exchange(0, 1, Ordering::Relaxed, Ordering::Relaxed)
2072                    .is_ok()
2073                {
2074                    eprintln!(
2075                        "[mlx-native] MLX_PROFILE_DISPATCH=1 ignored: \
2076                         device {:?} exposes no MTLCommonCounterSetTimestamp",
2077                        device.name()
2078                    );
2079                }
2080                return;
2081            }
2082        };
2083        // Build descriptor.  StorageMode::Shared is required by
2084        // resolveCounterRange (MTLCounters.h:185-188).
2085        let descriptor = CounterSampleBufferDescriptor::new();
2086        descriptor.set_counter_set(timestamp_set);
2087        descriptor.set_storage_mode(MTLStorageMode::Shared);
2088        descriptor.set_label("mlx_native.dispatch_samples");
2089        descriptor.set_sample_count(MAX_SAMPLES_PER_CB);
2090        match device.new_counter_sample_buffer_with_descriptor(&descriptor) {
2091            Ok(buf) => {
2092                self.sample_buffer = Some(buf);
2093            }
2094            Err(e) => {
2095                if TIMESTAMP_SET_WARN_LOGGED
2096                    .compare_exchange(0, 1, Ordering::Relaxed, Ordering::Relaxed)
2097                    .is_ok()
2098                {
2099                    eprintln!(
2100                        "[mlx-native] MLX_PROFILE_DISPATCH=1 ignored: \
2101                         newCounterSampleBufferWithDescriptor failed: {}",
2102                        e
2103                    );
2104                }
2105                self.sample_buffer = None;
2106            }
2107        }
2108    }
2109
2110    /// Insert the start-of-dispatch counter sample (sample index `2*i`)
2111    /// and queue the per-dispatch metadata.  Returns the dispatch
2112    /// ordinal `i` so the caller can emit the matching post-sample.
2113    ///
2114    /// No-op when sampling is inactive — returns 0 in that case (the
2115    /// returned value is only consumed when the sample buffer is
2116    /// active, so this is safe).
2117    ///
2118    /// `with_barrier:true` is mandatory: the encoder uses
2119    /// `MTLDispatchTypeConcurrent` and without the barrier the start
2120    /// timestamp would race against any in-flight dispatch (PROFILING-
2121    /// KIT-DESIGN §A.5).
2122    #[inline]
2123    fn sample_dispatch_pre(
2124        &mut self,
2125        encoder: &ComputeCommandEncoderRef,
2126        op_kind: CapturedOpKind,
2127    ) -> Option<u32> {
2128        let sb = self.sample_buffer.as_ref()?;
2129        let i = self.dispatch_in_cb;
2130        let pre_idx = (i as u64).checked_mul(2)?;
2131        if pre_idx >= MAX_SAMPLES_PER_CB {
2132            // Ceiling exceeded — skip sampling for the remainder of
2133            // this CB.  Risk R4 (PROFILING-KIT-DESIGN §A.7): future
2134            // iter can chunk-resolve every N dispatches; for now we
2135            // accept truncation with a one-shot warning (re-uses the
2136            // R1 warn flag).
2137            return None;
2138        }
2139        encoder.sample_counters_in_buffer(sb, pre_idx, true);
2140        self.pending_dispatch_meta.push(PendingDispatchMeta {
2141            op_kind: op_kind.name(),
2142            dispatch_index: i,
2143        });
2144        Some(i)
2145    }
2146
2147    /// Insert the end-of-dispatch counter sample (sample index `2*i+1`)
2148    /// matching the most recent [`Self::sample_dispatch_pre`].
2149    ///
2150    /// No-op when sampling is inactive or when `pre_idx` is `None`.
2151    #[inline]
2152    fn sample_dispatch_post(
2153        &mut self,
2154        encoder: &ComputeCommandEncoderRef,
2155        pre_idx: Option<u32>,
2156    ) {
2157        let i = match pre_idx {
2158            Some(v) => v,
2159            None => return,
2160        };
2161        let sb = match self.sample_buffer.as_ref() {
2162            Some(b) => b,
2163            None => return,
2164        };
2165        let post_idx = match (i as u64).checked_mul(2).and_then(|v| v.checked_add(1)) {
2166            Some(v) if v < MAX_SAMPLES_PER_CB => v,
2167            _ => return,
2168        };
2169        encoder.sample_counters_in_buffer(sb, post_idx, true);
2170        // Bump the per-CB ordinal only after both samples committed
2171        // successfully so a truncation skip leaves the meta queue
2172        // length matching the buffer's resolved range.
2173        self.dispatch_in_cb = i.saturating_add(1);
2174    }
2175
2176    /// Resolve the per-CB sample buffer, push entries into
2177    /// [`crate::kernel_profile`], and reset per-CB state.
2178    ///
2179    /// Called from [`Self::commit_and_wait_labeled`] after the CB
2180    /// completes; the caller is responsible for ensuring the GPU has
2181    /// finished (otherwise `resolveCounterRange` returns garbage).
2182    ///
2183    /// On the first resolve after a [`crate::kernel_profile::reset`],
2184    /// also captures a `(cpu_ns, gpu_ticks)` pair via
2185    /// `device.sampleTimestamps` so subsequent ticks→ns conversion
2186    /// uses a fresh scale factor.
2187    fn resolve_dispatch_samples(&mut self, cb_label: &str) -> Result<()> {
2188        let sb = match self.sample_buffer.take() {
2189            Some(b) => b,
2190            None => {
2191                self.pending_dispatch_meta.clear();
2192                self.dispatch_in_cb = 0;
2193                return Ok(());
2194            }
2195        };
2196        let n = self.pending_dispatch_meta.len();
2197        if n == 0 {
2198            self.dispatch_in_cb = 0;
2199            return Ok(());
2200        }
2201        // Refresh the (cpu, gpu) scale pair on every resolve; the
2202        // device call is cheap and keeps us robust against driver-side
2203        // timebase changes between CBs.
2204        let mut cpu_t: u64 = 0;
2205        let mut gpu_t: u64 = 0;
2206        let device: &metal::DeviceRef = unsafe {
2207            let cb = &*self.cmd_buf;
2208            msg_send![cb, device]
2209        };
2210        device.sample_timestamps(&mut cpu_t, &mut gpu_t);
2211        crate::kernel_profile::record_clock_pair(cpu_t, gpu_t);
2212        let length = (n as u64).saturating_mul(2);
2213        let data = sb.resolve_counter_range(NSRange {
2214            location: 0,
2215            length,
2216        });
2217        // `resolve_counter_range` returns one NSUInteger per sample.
2218        // Pair them up: data[2i] = start, data[2i+1] = end.
2219        for (i, meta) in self.pending_dispatch_meta.drain(..).enumerate() {
2220            let start_idx = 2 * i;
2221            let end_idx = 2 * i + 1;
2222            if end_idx >= data.len() {
2223                break;
2224            }
2225            let start_raw = data[start_idx] as u64;
2226            let end_raw = data[end_idx] as u64;
2227            let start_ns = crate::kernel_profile::convert_gpu_ticks_to_ns(start_raw);
2228            let end_ns = crate::kernel_profile::convert_gpu_ticks_to_ns(end_raw);
2229            let gpu_ns = end_ns.saturating_sub(start_ns);
2230            crate::kernel_profile::record_dispatch(
2231                crate::kernel_profile::DispatchEntry {
2232                    cb_label: cb_label.to_string(),
2233                    op_kind: meta.op_kind,
2234                    dispatch_index: meta.dispatch_index,
2235                    gpu_ns,
2236                    start_gpu_ns: start_ns,
2237                    end_gpu_ns: end_ns,
2238                },
2239            );
2240        }
2241        // Buffer dropped at end of scope releases the underlying
2242        // CounterSampleBuffer; per-CB lifetime correctly bounded.
2243        drop(sb);
2244        self.dispatch_in_cb = 0;
2245        Ok(())
2246    }
2247
2248    /// Commit the command buffer and block until the GPU finishes execution.
2249    ///
2250    /// # Errors
2251    ///
2252    /// Returns `MlxError::CommandBufferError` if the GPU reports an error.
2253    pub fn commit_and_wait(&mut self) -> Result<()> {
2254        SYNC_COUNT.fetch_add(1, Ordering::Relaxed);
2255
2256        // End the persistent compute encoder before committing.
2257        self.end_active_encoder();
2258
2259        // ADR-015:flush deferred residency-set
2260        // add/remove staging so the residency hint covers any buffers
2261        // referenced by this CB. Single commit per CB boundary; no-op
2262        // when no residency set or no staged changes.
2263        self.flush_residency_pending();
2264
2265        self.cmd_buf.commit();
2266        self.cmd_buf.wait_until_completed();
2267
2268        // ADR-040 §0.21 — accumulate GPU-busy time (gated; 2 ObjC reads/sync).
2269        if *GPU_BUSY_ON {
2270            let (gpu_start, gpu_end): (f64, f64) = unsafe {
2271                let cb = &*self.cmd_buf;
2272                let s: f64 = msg_send![cb, GPUStartTime];
2273                let e: f64 = msg_send![cb, GPUEndTime];
2274                (s, e)
2275            };
2276            let ns = ((gpu_end - gpu_start).max(0.0) * 1_000_000_000.0) as u64;
2277            GPU_BUSY_NS.fetch_add(ns, Ordering::Relaxed);
2278        }
2279
2280        match self.cmd_buf.status() {
2281            MTLCommandBufferStatus::Completed => Ok(()),
2282            MTLCommandBufferStatus::Error => {
2283                Err(MlxError::CommandBufferError(
2284                    "GPU command buffer completed with error status".into(),
2285                ))
2286            }
2287            status => Err(MlxError::CommandBufferError(format!(
2288                "Unexpected command buffer status after wait: {:?}",
2289                status
2290            ))),
2291        }
2292    }
2293
2294    /// Commit + wait, accumulating GPU wall-clock time under `label` into
2295    /// the [`crate::kernel_profile`] global table when `MLX_PROFILE_CB=1`
2296    /// is set.  When the env var is unset, this is identical to
2297    /// [`commit_and_wait`](Self::commit_and_wait) — zero overhead.
2298    ///
2299    /// Used by hf2q's decode hot path to attribute per-cb GPU time to
2300    /// labeled phases (per-layer attn, per-layer ffn, output_head, etc.)
2301    /// without manually wiring `commit_wait_with_gpu_time` everywhere.
2302    ///
2303    /// # Errors
2304    ///
2305    /// Returns `MlxError::CommandBufferError` if the GPU reports an error.
2306    pub fn commit_and_wait_labeled(&mut self, label: &str) -> Result<()> {
2307        // ADR-015 —propagate `label` to MTLCommandBuffer.setLabel and
2308        // (if a compute encoder is active) MTLComputeCommandEncoder.setLabel
2309        // BEFORE end_encoding/commit so xctrace's
2310        // `metal-application-encoders-list` table populates `cmdbuffer-label`
2311        // and `encoder-label` columns with the semantic phase name (e.g.
2312        // `layer.attn_moe_ffn`, `output_head.fused_norm_lm_argmax`,
2313        // `layer.delta_net.ops1-9`).  Joined to per-CB GPU duration via
2314        // `metal-gpu-submission-to-command-buffer-id` (sub_id ↔ encoder_id) →
2315        // `metal-gpu-execution-points` (per-dispatch start/end), this enables
2316        // per-phase µs/token attribution comparing hf2q vs llama side-by-side
2317        // (label attribution path).  Cost is a single ObjC
2318        // msg_send per CB submission — sub-µs on M5 Max — and a no-op when
2319        // xctrace isn't recording, so this is unconditionally safe to call on
2320        // the production decode hot path.
2321        self.apply_labels(label);
2322        // ADR-015:record GPU time AND resolve per-dispatch samples
2323        // when either env gate is set.  Per-dispatch sampling force-enables
2324        // the per-CB path so cross-validation per Risk R3 always has a
2325        // ground-truth comparator.
2326        let need_gpu_time =
2327            crate::kernel_profile::is_enabled() || crate::kernel_profile::is_dispatch_enabled();
2328        if need_gpu_time {
2329            let (start_s, end_s) = self.commit_wait_with_gpu_time()?;
2330            let ns = ((end_s - start_s).max(0.0) * 1_000_000_000.0) as u64;
2331            if crate::kernel_profile::is_enabled() {
2332                crate::kernel_profile::record(label, ns);
2333            }
2334            if crate::kernel_profile::is_dispatch_enabled() {
2335                self.resolve_dispatch_samples(label)?;
2336            }
2337            Ok(())
2338        } else {
2339            self.commit_and_wait()
2340        }
2341    }
2342
2343    /// Async commit, but with profiling label.  When `MLX_PROFILE_CB=1`
2344    /// is set, redirects to a synchronous [`commit_and_wait_labeled`]
2345    /// call to capture per-cb GPU time (this defeats async pipelining
2346    /// while profiling, which is the whole point — profile-mode is slow
2347    /// but informative).  When unset, identical to [`commit`](Self::commit).
2348    pub fn commit_labeled(&mut self, label: &str) {
2349        // ADR-015 —see `commit_and_wait_labeled` for rationale.
2350        if crate::kernel_profile::is_enabled() {
2351            // Profile mode: force sync to capture GPU time.  apply_labels is
2352            // called inside commit_and_wait_labeled — do NOT call it twice
2353            // here (would double the ObjC msg_send under MLX_PROFILE_CB=1).
2354            // Errors are logged via stderr because the void return matches
2355            // commit().
2356            if let Err(e) = self.commit_and_wait_labeled(label) {
2357                eprintln!("[mlx-native] commit_labeled({}) failed: {}", label, e);
2358            }
2359        } else {
2360            // Async path: apply labels here so xctrace MST traces capture
2361            // per-CB phase attribution under default decode (no
2362            // `MLX_PROFILE_CB`).
2363            self.apply_labels(label);
2364            self.commit();
2365        }
2366    }
2367
2368    /// Apply `label` to the underlying `MTLCommandBuffer` and, if a compute
2369    /// encoder is currently active, to the `MTLComputeCommandEncoder`.
2370    ///
2371    /// Called from [`commit_labeled`] and [`commit_and_wait_labeled`] BEFORE
2372    /// the encoder is ended / the CB is committed so xctrace's
2373    /// `metal-application-encoders-list` table picks up the label on the
2374    /// row emitted at the encoder's `endEncoding` / CB submission boundary.
2375    /// Single ObjC `msg_send` per call (two if an encoder is active); sub-µs
2376    /// on M5 Max; no-op when xctrace isn't recording.
2377    ///
2378    /// Skipped (debug-only assert) if `label` is empty — empty labels would
2379    /// produce an indistinguishable trace row from the metal-rs default
2380    /// `Command Buffer 0` placeholder.
2381    #[inline]
2382    fn apply_labels(&mut self, label: &str) {
2383        debug_assert!(!label.is_empty(), "commit_*_labeled called with empty label");
2384        if label.is_empty() {
2385            return;
2386        }
2387        self.cmd_buf.set_label(label);
2388        if !self.active_encoder.is_null() {
2389            // SAFETY: active_encoder is non-null and points to a live encoder
2390            // owned by cmd_buf — same invariant as get_or_create_encoder /
2391            // memory_barrier.  set_label is a single property write on the
2392            // ObjC object; safe before endEncoding.
2393            unsafe { &*self.active_encoder }.set_label(label);
2394        }
2395        // ADR-015:capture the most recent label for per-dispatch
2396        // entries.  Cheap String allocation — only happens at CB commit
2397        // boundaries, not per dispatch.
2398        self.last_label.clear();
2399        self.last_label.push_str(label);
2400    }
2401
2402    /// Commit + wait, returning `(gpu_start_s, gpu_end_s)` CFTimeInterval
2403    /// timestamps from `MTLCommandBuffer`'s `GPUStartTime`/`GPUEndTime`
2404    /// properties.  Both are mach-absolute CFTimeInterval seconds (double).
2405    ///
2406    /// Intended for `HF2Q_PROFILE_GPU_TS=1` per-bucket GPU wall-clock
2407    /// attribution.  Adds exactly two ObjC property reads per call on top
2408    /// of the regular `commit_and_wait` — measured well under 1 μs on
2409    /// M5 Max.
2410    ///
2411    /// # Errors
2412    ///
2413    /// Returns `MlxError::CommandBufferError` if the GPU reports an error.
2414    pub fn commit_wait_with_gpu_time(&mut self) -> Result<(f64, f64)> {
2415        self.commit_and_wait()?;
2416        // SAFETY: cmd_buf is a valid MTLCommandBuffer that has been
2417        // committed and awaited.  GPUStartTime / GPUEndTime return
2418        // CFTimeInterval (double precision seconds).  See
2419        // https://developer.apple.com/documentation/metal/mtlcommandbuffer/1639925-gpustarttime
2420        let (gpu_start, gpu_end): (f64, f64) = unsafe {
2421            let cb = &*self.cmd_buf;
2422            let s: f64 = msg_send![cb, GPUStartTime];
2423            let e: f64 = msg_send![cb, GPUEndTime];
2424            (s, e)
2425        };
2426        Ok((gpu_start, gpu_end))
2427    }
2428
2429    /// Commit the command buffer WITHOUT blocking.
2430    ///
2431    /// The GPU begins executing the encoded commands immediately.  Call
2432    /// [`wait_until_completed`](Self::wait_until_completed) later to block
2433    /// the CPU and check for errors.  This allows the CPU to continue doing
2434    /// other work (e.g. preparing the next batch) while the GPU runs.
2435    pub fn commit(&mut self) {
2436        self.end_active_encoder();
2437        // ADR-015:same flush hook as commit_and_wait —
2438        // this is the async-pipeline path that production decode uses.
2439        self.flush_residency_pending();
2440        self.cmd_buf.commit();
2441    }
2442
2443    /// ADR-040 §25 — accumulate this (async-committed) command buffer's GPU-busy
2444    /// time into the global accumulator, for HF2Q_GPU_BUSY profiling of the
2445    /// CB-pipelined decode path (where chunks `commit()` async and only the last
2446    /// `commit_and_wait()`s — without this, the async chunks' GPU time is missed,
2447    /// undercounting GPU-busy). Call ONLY after the CB is known complete (e.g.
2448    /// after a later same-queue `commit_and_wait` returned). No-op when the
2449    /// HF2Q_GPU_BUSY gate is off.
2450    pub fn accumulate_gpu_busy(&self) {
2451        if *GPU_BUSY_ON {
2452            let (gpu_start, gpu_end): (f64, f64) = unsafe {
2453                let cb = &*self.cmd_buf;
2454                let s: f64 = msg_send![cb, GPUStartTime];
2455                let e: f64 = msg_send![cb, GPUEndTime];
2456                (s, e)
2457            };
2458            let ns = ((gpu_end - gpu_start).max(0.0) * 1_000_000_000.0) as u64;
2459            GPU_BUSY_NS.fetch_add(ns, Ordering::Relaxed);
2460        }
2461    }
2462
2463    /// Block until a previously committed command buffer completes.
2464    ///
2465    /// Must be called after [`commit`](Self::commit).  Do not call after
2466    /// [`commit_and_wait`](Self::commit_and_wait) — that method already waits.
2467    ///
2468    /// # Errors
2469    ///
2470    /// Returns `MlxError::CommandBufferError` if the GPU reports an error.
2471    pub fn wait_until_completed(&self) -> Result<()> {
2472        self.cmd_buf.wait_until_completed();
2473        match self.cmd_buf.status() {
2474            MTLCommandBufferStatus::Completed => Ok(()),
2475            MTLCommandBufferStatus::Error => Err(MlxError::CommandBufferError(
2476                "GPU command buffer completed with error status".into(),
2477            )),
2478            status => Err(MlxError::CommandBufferError(format!(
2479                "Unexpected command buffer status after wait: {:?}",
2480                status
2481            ))),
2482        }
2483    }
2484
2485    /// Borrow the underlying Metal command buffer.
2486    #[inline]
2487    pub fn metal_command_buffer(&self) -> &CommandBuffer {
2488        &self.cmd_buf
2489    }
2490
2491    /// Borrow the residency set bound to this encoder, if one exists.
2492    ///
2493    /// ADR-019:exposed `pub(crate)` so
2494    /// [`crate::EncoderSession`] can route caller-driven add/remove
2495    /// requests through the same `Arc<ResidencySetInner>` the encoder
2496    /// itself flushes at every `commit*` boundary. The single-set
2497    /// invariant from `device.rs::MlxDevice` is preserved — both the
2498    /// encoder's `flush_residency_pending` and the session's delegated
2499    /// add/remove operate on the SAME residency set. Returns `None` when
2500    /// residency sets are disabled (HF2Q_NO_RESIDENCY=1, macOS<15, or
2501    /// `CommandEncoder::new` from a residency-less queue).
2502    #[inline]
2503    pub(crate) fn residency_set(&self) -> Option<&ResidencySet> {
2504        self.residency_set.as_ref()
2505    }
2506
2507    /// Reopen `cmd_buf` with a fresh `CommandBuffer` from the originating queue.
2508    ///
2509    /// ADR-019:enables multi-stage chaining. After a
2510    /// non-blocking `commit*` has handed the prior CB to Metal, this method
2511    /// rotates `cmd_buf` to a freshly-allocated CB on the same queue and
2512    /// resets every per-CB scratch field so the next dispatch is encoded
2513    /// onto the new CB.
2514    ///
2515    /// # Caller contract
2516    ///
2517    /// Only valid when `active_encoder.is_null()` (the persistent compute
2518    /// encoder must have been ended via `end_active_encoder()`, which both
2519    /// `commit_and_wait` and `commit` already do). Calling this method
2520    /// while a compute encoder is open would leak the encoder (the new
2521    /// `cmd_buf` does not own it) and trip Metal's "Command encoder
2522    /// released without endEncoding" assertion when the prior `cmd_buf`
2523    /// drops. Callers are [`crate::EncoderSession::reset_for_next_stage`]
2524    /// only — the session has already committed before invoking this.
2525    ///
2526    /// # F2 / F11 / F12 fence preservation
2527    ///
2528    /// - **F2 — residency-rescission**: this method does NOT re-flush
2529    ///   the residency set. The prior `commit*` already flushed; staged
2530    ///   add/remove since then will flush at the next `commit*` on the
2531    ///   new CB. The residency-set Arc clone is preserved.
2532    /// - **F11 — zero-init alloc_buffer**: untouched (no buffer allocs).
2533    /// - **F12 — `HF2Q_FORCE_SERIAL_DISPATCH`**: the new CB will lazily
2534    ///   open its compute encoder via `get_or_create_encoder`, which
2535    ///   re-reads the env var; the falsification probe still fires on
2536    ///   the new CB.
2537    ///
2538    /// # Counter semantics
2539    ///
2540    /// Bumps `CMD_BUF_COUNT` exactly once per call, matching the
2541    /// `new_with_residency` accounting. Does NOT bump `SYNC_COUNT` (no
2542    /// commit/wait happens here).
2543    pub(crate) fn reset_command_buffer(&mut self) {
2544        // ADR-040 §0.21c-track2: leak-safe + end-safe reset. With the
2545        // encoder-retain fix, the bare `active_encoder = null` below would, on
2546        // ANY path that reaches here with a non-null active encoder, LEAK the +1
2547        // retain AND leave an un-`endEncoding`'d encoder (Metal then asserts
2548        // "Command encoder released without endEncoding"). Pre-patch this was a
2549        // harmless borrowed +0 pointer guarded only by a debug_assert (compiled
2550        // out in release). Now route through `end_active_encoder` FIRST: it ends
2551        // + releases the encoder if non-null, and is a no-op if null — so the
2552        // reset is correct regardless of caller contract, in debug AND release.
2553        // The contract is still that callers commit first; the debug_assert
2554        // (after the safe handling) flags a violation in tests without crashing
2555        // production.
2556        self.end_active_encoder();
2557        debug_assert!(
2558            self.active_encoder.is_null(),
2559            "reset_command_buffer: active_encoder should be null after \
2560             end_active_encoder — caller should commit before reset"
2561        );
2562        let cmd_buf = if unretained_refs_enabled() {
2563            self.queue
2564                .new_command_buffer_with_unretained_references()
2565                .to_owned()
2566        } else {
2567            self.queue.new_command_buffer().to_owned()
2568        };
2569        CMD_BUF_COUNT.fetch_add(1, Ordering::Relaxed);
2570        self.cmd_buf = cmd_buf;
2571        // Per-CB scratch state — every field that's documented as being
2572        // bounded by a CB lifetime resets here.
2573        self.active_encoder = std::ptr::null();
2574        self.dispatch_in_cb = 0;
2575        self.last_label.clear();
2576        self.pending_dispatch_meta.clear();
2577        // `mem_ranges` is a per-CB barrier inference state; clearing on
2578        // CB rotation matches the `commit_and_wait` post-commit invariant
2579        // (any new CB starts with no pending hazards). The field's own
2580        // `clear` is invoked via `MemRanges::default` here to avoid
2581        // exposing internals.
2582        self.mem_ranges = MemRanges::new();
2583        // `sample_buffer` is dropped explicitly inside
2584        // `resolve_dispatch_samples` after a CB completes; we leave it
2585        // in whatever state the prior commit left it (typically `None`
2586        // after `commit_and_wait` finishes). A stale `Some` here would
2587        // be visible only under `MLX_PROFILE_DISPATCH=1` which fires its
2588        // own one-shot warning; not worth a special case.
2589        // `capture` (if Some) persists across CB rotation — capture mode
2590        // accumulates across stages within a session by design.
2591        // `pending_op_kind` / `pending_reads` / `pending_writes` only
2592        // hold tags for the NEXT dispatch and are consumed when that
2593        // dispatch fires — leaving them as-is is correct.
2594    }
2595
2596    /// Commit, wait for GPU completion, AND rotate to a fresh command
2597    /// buffer in one shot — for callers that need a CPU sync point
2598    /// mid-pipeline but want to continue dispatching ops onto the
2599    /// same logical encoder.
2600    ///
2601    /// Use case: hf2q's ADR-033 §Pi imatrix intercept fires
2602    /// `commit_and_wait` to sync the input buffer before reading it
2603    /// on the CPU (per `forward.rs` doc "Syncs the input buffer
2604    /// (commit_and_wait via the shared encoder)"), then immediately
2605    /// continues with the original matmul dispatch. Without rotating
2606    /// the CB, the next dispatch hits Metal's
2607    /// `MTLCommandBufferStatusCommitted` assertion at
2608    /// `setCurrentCommandEncoder:` line 323.
2609    ///
2610    /// Equivalent to `commit_and_wait()` + an internal-only
2611    /// `reset_command_buffer()`. Bumps `SYNC_COUNT` and
2612    /// `CMD_BUF_COUNT` exactly once each.
2613    ///
2614    /// # Errors
2615    ///
2616    /// Returns `MlxError::CommandBufferError` if the commit-side GPU
2617    /// reports an error. The CB-rotation half is infallible.
2618    pub fn commit_wait_and_rotate(&mut self) -> Result<()> {
2619        self.commit_and_wait()?;
2620        self.reset_command_buffer();
2621        Ok(())
2622    }
2623
2624    /// Encode an `MTLSharedEvent` wait at `value` on the current CB.
2625    ///
2626    /// ADR-019:pairs with [`Self::encode_signal_event`]
2627    /// to express the inter-CB ordering D3 stage boundaries need. The new
2628    /// CB's GPU work blocks until the prior CB's signal lands on the same
2629    /// event at >= `value`.
2630    ///
2631    /// # Caller contract
2632    ///
2633    /// Must be called BEFORE any compute encoder is opened on the new
2634    /// CB — the wait is a CB-level op that must precede every dispatch
2635    /// in the new CB to actually order them. [`crate::EncoderSession::reset_for_next_stage`]
2636    /// fires this immediately after `reset_command_buffer`, before any
2637    /// dispatch lazy-opens the encoder.
2638    #[inline]
2639    pub(crate) fn encode_wait_for_event(&self, event: &metal::EventRef, value: u64) {
2640        debug_assert!(
2641            self.active_encoder.is_null(),
2642            "encode_wait_for_event called with an open compute encoder \
2643             — wait must precede the first dispatch on the new CB"
2644        );
2645        self.cmd_buf.encode_wait_for_event(event, value);
2646    }
2647
2648    /// End the active compute encoder, encode a stage-fence signal, and
2649    /// commit the CB non-blocking — atomically from the caller's view.
2650    ///
2651    /// ADR-019:this is the helper
2652    /// [`crate::EncoderSession::fence_stage`] uses to thread the signal
2653    /// between the encoder-end and the CB-commit boundaries that
2654    /// `commit_labeled` would otherwise serialize. Sequence:
2655    ///
2656    /// 1. End the persistent compute encoder (so `encodeSignalEvent:` is
2657    ///    encoded at CB-level, not encoder-level — Metal validates that
2658    ///    `encodeSignalEvent:` outside any encoder pass is the only
2659    ///    legal placement).
2660    /// 2. Apply `label` (when `Some`) to the CB. Note: at this point
2661    ///    the encoder is already ended, so the encoder's own
2662    ///    `setLabel:` is a no-op site — only the CB label propagates.
2663    ///    `last_label` and per-dispatch profiling keep working as
2664    ///    documented.
2665    /// 3. Encode `encodeSignalEvent:event:value:new_value` at CB-level.
2666    /// 4. Flush the residency-set pending staging (matches the
2667    ///    `commit_labeled` / `commit` flush at encoder.rs:2004).
2668    /// 5. Commit the CB non-blocking (matches `commit()` at
2669    ///    encoder.rs:2026).
2670    ///
2671    /// # Counter semantics
2672    ///
2673    /// Bumps `SYNC_COUNT` zero times (non-blocking). Bumps
2674    /// `CMD_BUF_COUNT` zero times (no new CB allocated here —
2675    /// [`Self::reset_command_buffer`] does that on the next stage).
2676    ///
2677    /// # Errors
2678    ///
2679    /// Infallible (matches `commit()` semantics — errors surface only
2680    /// at `wait_until_completed`).
2681    pub(crate) fn fence_signal_and_commit(
2682        &mut self,
2683        event: &metal::EventRef,
2684        new_value: u64,
2685        label: Option<&str>,
2686    ) {
2687        // Step 1: end the active compute encoder. encode_signal_event's
2688        // debug_assert requires this be done first.
2689        self.end_active_encoder();
2690        // Step 2: apply the CB label so xctrace MST attribution still
2691        // works on the fenced CB. apply_labels' debug_assert against
2692        // empty labels matches commit_labeled's semantics.
2693        if let Some(l) = label {
2694            self.apply_labels(l);
2695        }
2696        // Step 3: encode the signal at CB-level.
2697        self.cmd_buf.encode_signal_event(event, new_value);
2698        // Step 4 + 5: same as commit() — flush residency staging, then
2699        // hand the CB to Metal.
2700        self.flush_residency_pending();
2701        self.cmd_buf.commit();
2702    }
2703}
2704
2705impl Drop for CommandEncoder {
2706    fn drop(&mut self) {
2707        // End the persistent compute encoder before the command buffer
2708        // is dropped, otherwise Metal will assert:
2709        // "Command encoder released without endEncoding"
2710        self.end_active_encoder();
2711    }
2712}