Skip to main content

mlx_native/
encoder_session.rs

1//! [`EncoderSession`] — D3 Per-Stage Fence encoder abstraction (ADR-019).
2//!
3//! `EncoderSession` lifts [`CommandEncoder`] into a session-aware shell that
4//! carries semantic *stage* metadata across the lifetime of one or more
5//! logical transformer stages (e.g. `"layer.full_attn.stage1"` →
6//! `"layer.full_attn.stage2"`). Adds the
7//! [`MTLSharedEvent`](metal::SharedEvent) inter-CB ordering primitives D3
8//! needs:
9//!
10//! - [`Self::fence_stage`] — encode signal-event(N+1) on the current CB,
11//!   commit non-blocking, increment the per-session monotonic counter.
12//! - [`Self::reset_for_next_stage`] — open a fresh CB on the same queue
13//!   and (when a fence is active) encode wait-event(N) on the new CB so
14//!   its GPU work blocks until the prior fenced CB completes.
15//! - [`Self::add_to_residency_set`] / [`Self::remove_from_residency_set`]
16//!   — public delegation surface for the single residency set owned by
17//!   [`MlxDevice`](crate::MlxDevice). EncoderSession does NOT own a
18//!   separate set; it routes calls through the Arc clone the inner
19//!   [`CommandEncoder`] already holds.
20//!
21//! Production callers stay on [`crate::CommandEncoder`] until the FA-path
22//! D3 stage migration wires `forward_gpu.rs` to consume `EncoderSession`.
23//! The struct is feature-flagged behind `HF2Q_ENCODER_SESSION=1`
24//! (default OFF) and is constructed only via
25//! [`MlxDevice::encoder_session`](crate::MlxDevice::encoder_session).
26//!
27//! # Lifecycle (multi-stage chaining)
28//!
29//! ```text
30//!                   MlxDevice::encoder_session()
31//!                                |
32//!                                v
33//!                          +-----------+
34//!                          | Empty     |  no CB or encoder open yet
35//!                          +-----------+
36//!                                |
37//!                          first dispatch
38//!                          (via inner CommandEncoder)
39//!                                |
40//!                                v
41//!                          +-----------+
42//!                          | Encoding  |  CB open, persistent compute encoder open
43//!                          +-----------+
44//!                            |       |
45//!                            |       +---fence_stage(label)----+
46//!                            |                                 |
47//!                            |                                 v
48//!                            |                          +-----------+
49//!                            |                          | Fenced    |  signal encoded; CB submitted
50//!                            |                          +-----------+
51//!                            |                                 |
52//!                  commit_stage()                  reset_for_next_stage()
53//!                  commit_and_wait()                            |
54//!                            |                                 v
55//!                            v                          (loop back to Encoding
56//!                      +-----------+                    on next dispatch — wait
57//!                      | Drained   |                    is encoded automatically
58//!                      +-----------+                    on the new CB)
59//!                            |
60//!                          Drop
61//! ```
62//!
63//! `fence_stage` collapses the design-doc's separate Encoding→Fenced→
64//! Committed transitions into a single "submit-with-fence" call: the
65//! signal is encoded on the current CB at `event_value+1`, the encoder
66//! is ended, the CB is committed non-blocking, and the per-session
67//! monotonic counter is incremented. The session is `drained` until
68//! [`Self::reset_for_next_stage`] rotates the inner [`CommandEncoder`]'s
69//! command buffer to a fresh CB on the same queue and (when the event is
70//! present) encodes the matching wait at `event_value` on the new CB.
71//!
72//! # Risk register fence preservation (F1-F12 from ADR-019)
73//!
74//! - **F1 — persistent compute encoder per CB**: ADOPTED unchanged.
75//!   `EncoderSession` borrows `&mut CommandEncoder` via [`Self::encoder`];
76//!   every dispatch reuses the same lazy-opened encoder per CB. Each
77//!   stage CB still has exactly one persistent compute encoder.
78//! - **F2 — residency-rescission**: PRESERVED. `commit_stage`,
79//!   `commit_and_wait`, and `fence_stage` all delegate to the inner
80//!   encoder, which calls `flush_residency_pending()` at every commit
81//!   boundary (`encoder.rs:1842, 2004`). `reset_for_next_stage` does NOT
82//!   re-flush — staged add/remove operations between stages flush at
83//!   the next commit on the new CB. The single residency set is owned
84//!   by [`MlxDevice`](crate::MlxDevice) (single-set invariant per
85//!   ADR-019:467). Multi-stage chaining DOES widen the in-flight CB
86//!   window — dropping a buffer between stage 1's `fence_stage` and
87//!   stage 2's `commit_*` stages a remove-allocation that flushes at
88//!   stage 2's commit, while stage 1's CB may still be GPU-pipelined.
89//!   Under retained-refs (default), the prior CB's ARC retains keep the
90//!   underlying Metal buffer alive across the residency-set demotion;
91//!   the GPU completes safely. Under `MLX_UNRETAINED_REFS=1`,
92//!   caller-owned arenas remain the only structural mitigation —
93//!   same contract as the existing async-commit path. The adversarial
94//!   F2 test (see
95//!   `/opt/mlx-native/tests/encoder_session_multistage.rs`) explicitly
96//!   exercises this window.
97//! - **F11 — zero-init alloc_buffer**: INVARIANT. `EncoderSession` does
98//!   not allocate buffers; the zero-init contract on
99//!   `MlxDevice::alloc_buffer` is unchanged.
100//! - **F12 — `HF2Q_FORCE_SERIAL_DISPATCH` falsification probe**: PRESERVED.
101//!   The probe lives in `CommandEncoder::get_or_create_encoder` and is
102//!   re-read every time a fresh CB lazily opens its compute encoder
103//!   (every `reset_for_next_stage` rotation). Both pre- and post-fence
104//!   CBs honor the env var.
105//! - F3-F10 are out of scope — `EncoderSession` is purely structural
106//!   and does not touch any forward path.
107//!
108//! # Feature gate
109//!
110//! [`MlxDevice::encoder_session`] returns `Ok(None)` when the
111//! `HF2Q_ENCODER_SESSION` env var is unset (default). When set to `"1"`
112//! it returns `Ok(Some(EncoderSession))`. Production code paths in hf2q
113//! consume `device.command_encoder()` (returns plain `CommandEncoder`) so
114//! the gate is a no-op in default builds — zero behavior change.
115
116use crate::buffer::MlxBuffer;
117use crate::encoder::CommandEncoder;
118use crate::error::Result;
119use crate::residency::ResidencySet;
120
121/// Cached `HF2Q_ENCODER_SESSION` decision.
122///
123/// Identical pattern to `auto_barrier_enabled` / `unretained_refs_enabled`
124/// in `encoder.rs` — `OnceLock` so the env-read happens exactly once per
125/// process, and the per-call cost is a single atomic load. Declared at
126/// module scope so the gate is observable from both
127/// [`EncoderSession::env_enabled`] (the public introspection helper) and
128/// [`MlxDevice::encoder_session`] (the factory site).
129fn encoder_session_enabled() -> bool {
130    use std::sync::OnceLock;
131    static FLAG: OnceLock<bool> = OnceLock::new();
132    *FLAG.get_or_init(|| {
133        std::env::var("HF2Q_ENCODER_SESSION")
134            .map(|v| v == "1")
135            .unwrap_or(false)
136    })
137}
138
139/// Session-level wrapper around a [`CommandEncoder`] for one or more
140/// logical transformer stages.
141///
142/// See module docs for lifecycle and fence preservation. iter89e2-B scope:
143/// multi-stage chaining via [`MTLSharedEvent`](metal::SharedEvent), residency
144/// delegation surface, and the matching test cohort. Phase 0b-C will
145/// broaden label propagation; Phase 2+ will wire this struct into the
146/// production forward path.
147///
148/// # Thread safety
149///
150/// `EncoderSession` is `Send` because [`CommandEncoder`] is `Send` (the
151/// existing unsafe impl at `encoder.rs:613-619`), `String`/`u64`/`bool`
152/// are `Send`, [`metal::Device`] is `Send + Sync` (foreign_obj_type! at
153/// metal-rs 0.33 lib.rs:179), and [`metal::SharedEvent`] is `Send + Sync`
154/// for the same reason. It is NOT `Sync` — exclusive ownership during
155/// dispatch encoding is the same contract as the inner [`CommandEncoder`].
156pub struct EncoderSession {
157    /// Inner command encoder. Carries `cmd_buf`, the persistent
158    /// `active_encoder`, the `queue` clone (read by
159    /// [`CommandEncoder::reset_command_buffer`]), the residency-set
160    /// flush hook, capture-mode IR, the auto-barrier `MemRanges`
161    /// tracker, the per-dispatch sample buffer, and the `last_label`
162    /// history. All dispatch operations flow through here.
163    ///
164    /// INVARIANT: `inner` is in a consistent state at every public API
165    /// boundary. Drops cleanly via `CommandEncoder::Drop` which calls
166    /// `end_active_encoder()` (Metal-asserts on a CB dropped with an
167    /// unended encoder).
168    inner: CommandEncoder,
169
170    /// Owned clone of the originating [`metal::Device`].
171    ///
172    /// Held so [`Self::fence_stage`] can lazily allocate an
173    /// [`metal::SharedEvent`] on first call without threading a
174    /// `&MlxDevice` through every call site. metal-rs 0.33's `Device`
175    /// is `Send + Sync` (foreign_obj_type! lib.rs:179), so adding this
176    /// field preserves the existing unsafe `Send` impl on
177    /// [`EncoderSession`] declared below.
178    device: metal::Device,
179
180    /// Lazily-allocated [`MTLSharedEvent`](metal::SharedEvent) backing
181    /// the per-session monotonic stage fence.
182    ///
183    /// `None` until the first [`Self::fence_stage`] call. Once
184    /// allocated, the same event is reused across every fence in this
185    /// session — the value half of the (event, value) pair carries the
186    /// monotonic identity. Cost is one ObjC alloc + autorelease per
187    /// session lifetime; subsequent fences reuse the same event.
188    event: Option<metal::SharedEvent>,
189
190    /// Per-session monotonic fence counter.
191    ///
192    /// Mirrors `ggml_metal_event::value` at
193    /// `/opt/llama.cpp/ggml/src/ggml-metal/ggml-metal-device.m:941`.
194    /// [`Self::fence_stage`] post-increments (signal = current+1, then
195    /// store current+1); [`Self::reset_for_next_stage`] reads (wait =
196    /// current). Starts at 0; bumps to 1 on first fence; CB N waits on
197    /// value N to gate after CB N's signal lands.
198    event_value: u64,
199
200    /// Human-readable stage label for xctrace MST attribution.
201    ///
202    /// Set by [`Self::begin_stage`] and by the `Some` arm of
203    /// [`Self::fence_stage`]'s `label` parameter. Empty by default.
204    /// When non-empty, [`Self::commit_stage`], [`Self::commit_and_wait`],
205    /// and [`Self::fence_stage`] all delegate to the inner encoder's
206    /// `commit_labeled` / `commit_and_wait_labeled` path, which
207    /// propagates the label to `MTLCommandBuffer.label` and
208    /// `MTLComputeCommandEncoder.label` via `apply_labels` at
209    /// `encoder.rs:1968-1986`.
210    ///
211    /// Cleared by [`Self::reset_for_next_stage`] so each chained stage
212    /// starts with a fresh label slot — the caller calls `begin_stage`
213    /// (or passes `Some(label)` to the next `fence_stage`) per stage.
214    stage_label: String,
215
216    /// Latch flipped to `true` after a `commit_stage` / `commit_and_wait`
217    /// / `fence_stage` call.
218    ///
219    /// Used to enforce the one-CB-per-state contract: a `EncoderSession`
220    /// in the `Drained` (or `Fenced`) state must call
221    /// [`Self::reset_for_next_stage`] before further dispatches encode
222    /// onto a new CB. Calling `commit_*` twice without an intervening
223    /// reset is a logic error — we surface it as a no-op rather than a
224    /// panic so the session remains drop-safe.
225    drained: bool,
226
227    /// Whether the most recent commit was a [`Self::fence_stage`] call.
228    ///
229    /// When `true`, [`Self::reset_for_next_stage`] encodes an
230    /// `encodeWaitForEvent` on the new CB at `event_value`. Cleared by
231    /// `reset_for_next_stage` so a subsequent `commit_stage` (no fence)
232    /// does not spuriously emit a wait on the next reset.
233    fence_pending: bool,
234
235    /// Per-session count of `encodeWaitForEvent` calls actually emitted
236    /// inside [`Self::reset_for_next_stage`].
237    ///
238    /// Symmetric counterpart to `event_value` (the signal-side high-water
239    /// mark) — `wait_count` is the wait-side scoreboard. Bumped exactly
240    /// once each time `reset_for_next_stage` finds `fence_pending == true`
241    /// and routes through `inner.encode_wait_for_event`. Read-only via
242    /// [`Self::wait_count`]; never mutated by control flow (introspection
243    /// only — does NOT widen F1/F2/F11/F12 windows).
244    ///
245    /// Test invariant: the multi-stage chain test asserts this equals
246    /// `(num_stages - 1)` for an N-stage chain (one wait per reset;
247    /// the first stage's CB never had a prior signal to wait on).
248    wait_count: u64,
249
250    /// Value of the most recent `encodeWaitForEvent` actually emitted
251    /// inside [`Self::reset_for_next_stage`].
252    ///
253    /// Mirrors the relationship between `event_value` (signal-side) and
254    /// the value passed to `inner.encode_wait_for_event`. Starts at 0
255    /// (no wait yet emitted); each successful wait sets this to the
256    /// `value` argument. Read-only via [`Self::wait_value`]; pure
257    /// introspection (does NOT widen F1/F2/F11/F12 windows).
258    ///
259    /// Test invariant: after a `fence_stage(N)` followed by
260    /// `reset_for_next_stage()`, this MUST equal N (the wait-side
261    /// matches the signal we just signaled).
262    last_wait_value: u64,
263}
264
265// SAFETY: `EncoderSession` is `Send` provided that:
266// 1. `CommandEncoder` is `Send` (existing unsafe impl at encoder.rs:606,
267//    Apple documents that command buffers / encoders may be encoded
268//    from any thread provided exclusive ownership).
269// 2. `metal::Device` is `Send + Sync` via foreign_obj_type!
270//    (metal-0.33.0/src/lib.rs:179).
271// 3. `metal::SharedEvent` is `Send + Sync` via foreign_obj_type!
272//    (same site — the macro emits `unsafe type ...: Sync + Send`
273//    for every type, including SharedEvent in sync.rs:36-40).
274// 4. `String`, `u64`, `bool` are `Send`.
275// All five hold. `EncoderSession` does NOT add any non-Send fields
276// beyond `metal::Device` + `Option<metal::SharedEvent>` + `u64` +
277// `bool`, all already validated.
278unsafe impl Send for EncoderSession {}
279
280impl EncoderSession {
281    /// Construct a new session over a fresh `CommandEncoder`.
282    ///
283    /// Returns `Err` if the underlying `CommandEncoder::new_with_residency`
284    /// fails (currently impossible past metal-rs 0.33's
285    /// `new_command_buffer`, but the `Result` is preserved for
286    /// future-proofing against driver-side allocation failures).
287    ///
288    /// # Crate-internal
289    ///
290    /// `pub(crate)` because the public construction surface is
291    /// [`MlxDevice::encoder_session`](crate::MlxDevice::encoder_session),
292    /// which threads the env-gate. Direct construction from outside
293    /// `mlx-native` would bypass the `HF2Q_ENCODER_SESSION` flag, which
294    /// is the wrong layering.
295    pub(crate) fn new(
296        device: &metal::DeviceRef,
297        queue: &metal::CommandQueue,
298        residency_set: Option<ResidencySet>,
299    ) -> Result<Self> {
300        Ok(Self {
301            inner: CommandEncoder::new_with_residency(queue, residency_set)?,
302            device: device.to_owned(),
303            event: None,
304            event_value: 0,
305            stage_label: String::new(),
306            drained: false,
307            fence_pending: false,
308            wait_count: 0,
309            last_wait_value: 0,
310        })
311    }
312
313    /// Whether `HF2Q_ENCODER_SESSION=1` is set in the process environment.
314    ///
315    /// Public introspection helper for hf2q-side dispatch wrappers that
316    /// need to choose between the legacy `command_encoder()` path and the
317    /// new `encoder_session()` path. Cached on first read via `OnceLock`
318    /// so the per-call cost is a single atomic load.
319    #[inline]
320    pub fn env_enabled() -> bool {
321        encoder_session_enabled()
322    }
323
324    /// Set the semantic stage label.
325    ///
326    /// The label propagates to `MTLCommandBuffer.label` and (when an
327    /// encoder is active) `MTLComputeCommandEncoder.label` at the next
328    /// `commit_stage` / `commit_and_wait` / `fence_stage` call, enabling
329    /// xctrace MST attribution per ADR-015 iter16. Calling `begin_stage`
330    /// does NOT itself touch any Metal object — it only stores the
331    /// string.
332    ///
333    /// Idempotent: calling `begin_stage` multiple times before commit
334    /// overwrites the previous label with the latest value, matching
335    /// the existing `apply_labels` semantic at `encoder.rs:1980-1985`
336    /// (the `last_label` field is overwritten on every labeled commit).
337    pub fn begin_stage(&mut self, label: &str) {
338        self.stage_label.clear();
339        self.stage_label.push_str(label);
340    }
341
342    /// Borrow the inner [`CommandEncoder`] for dispatch encoding.
343    ///
344    /// All dispatch APIs (`encode`, `encode_threadgroups`,
345    /// `encode_with_args`, `dispatch_tracked_*`, `memory_barrier`,
346    /// `start_capture` / `take_capture`, etc.) live on
347    /// [`CommandEncoder`]; `EncoderSession` adds a stage-aware commit
348    /// surface on top of them. Use this accessor inside the dispatch
349    /// loop, then call one of [`Self::commit_stage`] /
350    /// [`Self::commit_and_wait`] / [`Self::fence_stage`] at the stage
351    /// boundary.
352    ///
353    /// # Caller contract
354    ///
355    /// Do NOT call `inner.commit*` methods directly through this
356    /// borrow. Use the session's commit surface so the stage label
357    /// propagates and the drained-latch / fence state stay consistent.
358    /// Calling the inner commit bypasses these — it is not unsafe (no
359    /// UB risk) but it makes the session state inconsistent with what
360    /// it has actually committed.
361    #[inline]
362    pub fn encoder(&mut self) -> &mut CommandEncoder {
363        &mut self.inner
364    }
365
366    /// Commit the stage's command buffer non-blocking (no fence).
367    ///
368    /// Delegates to `CommandEncoder::commit_labeled` (when a label is
369    /// set) or `CommandEncoder::commit` (when not). Both end the
370    /// persistent compute encoder, flush the residency-set pending
371    /// staging (`flush_residency_pending` at `encoder.rs:2004`), and
372    /// hand the CB to the GPU without blocking the CPU.
373    ///
374    /// The session enters the `Drained` state. To chain into another
375    /// stage on the same session, call [`Self::reset_for_next_stage`]
376    /// — that opens a fresh CB and (if a fence was pending from a prior
377    /// `fence_stage`) encodes the matching wait. After
378    /// `commit_stage` (no fence), `reset_for_next_stage` does NOT emit
379    /// a wait — the CBs are merely sequenced by the Metal queue's FIFO
380    /// dispatch order.
381    ///
382    /// # Errors
383    ///
384    /// Returns `Ok(())` unconditionally — `CommandEncoder::commit` and
385    /// `CommandEncoder::commit_labeled` are infallible (they hand the
386    /// CB to Metal without waiting for completion; errors surface only
387    /// at `wait_until_completed`). The `Result` is preserved for
388    /// symmetry with [`Self::commit_and_wait`] and for future-proofing.
389    pub fn commit_stage(&mut self) -> Result<()> {
390        if self.drained {
391            return Ok(());
392        }
393        self.drained = true;
394        self.fence_pending = false;
395        if self.stage_label.is_empty() {
396            self.inner.commit();
397        } else {
398            // Take a snapshot of the label so we don't borrow `self`
399            // both immutably (for the label) and mutably (for inner)
400            // — clones a small String, fine for stage-boundary cost.
401            let label = self.stage_label.clone();
402            self.inner.commit_labeled(&label);
403        }
404        Ok(())
405    }
406
407    /// Commit the stage's command buffer and block until GPU completion.
408    ///
409    /// Delegates to `CommandEncoder::commit_and_wait_labeled` (when a
410    /// label is set) or `CommandEncoder::commit_and_wait` (when not).
411    /// Required at K-batch boundaries (F7) and at output-head CPU reads
412    /// (F6). Increments `SYNC_COUNT` exactly once per call (matches
413    /// `encoder.rs:1845`).
414    ///
415    /// The session enters the `Drained` state with NO fence pending —
416    /// blocking commit fully drains the GPU, so the next stage (after
417    /// [`Self::reset_for_next_stage`]) needs no wait-event.
418    ///
419    /// # Errors
420    ///
421    /// Returns `MlxError::CommandBufferError` if the GPU reports an
422    /// error after wait — propagated from `CommandEncoder`.
423    ///
424    /// ADR-015 iter94 Task #2 — fail-loud contract.  iter93 final-report
425    /// §"Root-cause hypothesis" point 5 noted that under
426    /// `MLX_UNRETAINED_REFS=1` + `HF2Q_ENCODER_SESSION=1` + `K>1`, the
427    /// session appeared to silently absorb a `MTLCommandBufferStatus::
428    /// Error` and produce deterministic-but-wrong tokens.  By code
429    /// reading, the tail-expression `self.inner.commit_and_wait()`
430    /// already returns the inner error (commit_and_wait at
431    /// encoder.rs:1852 explicitly matches on `cmd_buf.status()`).  This
432    /// re-shape converts the implicit propagation into an explicit `?`
433    /// chain so future maintainers cannot accidentally swallow the
434    /// error by inserting a `let _ = inner.commit_and_wait();` or
435    /// adding fall-through logic between the inner call and the
436    /// function return.  Latched `drained = true` happens BEFORE the
437    /// inner call so a panicking unwind through Drop sees the same
438    /// drained-state contract.
439    pub fn commit_and_wait(&mut self) -> Result<()> {
440        if self.drained {
441            return Ok(());
442        }
443        self.drained = true;
444        self.fence_pending = false;
445        let result = if self.stage_label.is_empty() {
446            self.inner.commit_and_wait()
447        } else {
448            let label = self.stage_label.clone();
449            self.inner.commit_and_wait_labeled(&label)
450        };
451        // Explicit `?`-style propagation: any `Err` from the inner
452        // commit_and_wait MUST surface to the caller.  This is the
453        // iter94 Task #2 fail-loud guarantee — silent absorption here
454        // would replicate the iter93 §"Root-cause hypothesis" point 5
455        // failure mode (deterministic-but-wrong outputs at the triple
456        // combo).  The extra `?` is a no-op codegen-wise vs the prior
457        // tail-expression form but documents intent and is unit-tested
458        // by `test_commit_and_wait_propagates_inner_cb_error`.
459        result?;
460        Ok(())
461    }
462
463    /// Encode a stage-fence signal on the current CB and commit non-blocking.
464    ///
465    /// This is the D3 multi-stage building block: the prior stage's
466    /// final CB-level op is `encodeSignalEvent:value:value+1`, where
467    /// `value+1` is then both stored in `event_value` (so the next
468    /// stage's `encodeWaitForEvent:value:` blocks on it) and committed.
469    /// The session enters the `Fenced` (drained-with-fence-pending)
470    /// state; [`Self::reset_for_next_stage`] rotates the inner CB and
471    /// emits the matching wait.
472    ///
473    /// # Lazy event allocation
474    ///
475    /// On the first call, allocates the per-session
476    /// [`MTLSharedEvent`](metal::SharedEvent) via
477    /// [`metal::DeviceRef::new_shared_event`]
478    /// (`/Users/robert/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/metal-0.33.0/src/device.rs:2063`).
479    /// Subsequent calls reuse the same event — the monotonic
480    /// `event_value` carries the per-fence identity. This matches the
481    /// llama.cpp pattern at
482    /// `/opt/llama.cpp/ggml/src/ggml-metal/ggml-metal-device.m:944-958`.
483    ///
484    /// # Label
485    ///
486    /// `label`'s `Some(value)` arm overwrites `stage_label` and
487    /// propagates via `commit_labeled`'s `apply_labels` chain — same as
488    /// calling [`Self::begin_stage`] before this. `None` keeps any
489    /// previously-set `begin_stage` label intact.
490    ///
491    /// # Counter semantics
492    ///
493    /// Bumps `SYNC_COUNT` zero times (non-blocking). Bumps
494    /// `CMD_BUF_COUNT` zero times (no new CB allocated here —
495    /// `reset_for_next_stage` does that). Increments `event_value` by
496    /// exactly 1.
497    ///
498    /// # Errors
499    ///
500    /// Returns `Ok(())` unconditionally for the same reason
501    /// [`Self::commit_stage`] does.
502    pub fn fence_stage(&mut self, label: Option<&str>) -> Result<()> {
503        if self.drained {
504            return Ok(());
505        }
506        // Apply the label argument before committing so commit_labeled
507        // (called below) propagates the latest value to the CB. Note
508        // that the encoder.rs:1968 apply_labels writes to the active
509        // compute encoder iff one is open — at this point one IS open
510        // (we have not yet ended it), so the encoder picks up the label
511        // before end_encoding fires. After end_encoding the CB still
512        // has its label set (set on the CB itself, not the encoder).
513        if let Some(l) = label {
514            self.stage_label.clear();
515            self.stage_label.push_str(l);
516        }
517
518        // Lazy-alloc the SharedEvent on first fence in this session.
519        // metal::DeviceRef::new_shared_event lives at
520        // /Users/robert/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/metal-0.33.0/src/device.rs:2063.
521        if self.event.is_none() {
522            self.event = Some(self.device.new_shared_event());
523        }
524
525        // Sequence: end-active-encoder + encodeSignalEvent (CB-level) +
526        // residency-flush + cmd_buf.commit, all inside the inner
527        // helper. This preserves F1 (encoder is ended exactly once per
528        // CB), F2 (residency-flush still fires at the commit boundary),
529        // and matches llama.cpp's pattern at
530        // `/opt/llama.cpp/ggml/src/ggml-metal/ggml-metal-device.m:944-950`.
531        let new_value = self.event_value + 1;
532        let event_ref: &metal::SharedEventRef = self
533            .event
534            .as_ref()
535            .expect("event allocated immediately above this borrow")
536            .as_ref();
537        // Deref-coerce SharedEventRef -> EventRef via the
538        // ParentType = Event chain in metal-0.33.0/src/sync.rs:36-40.
539        let label_opt: Option<&str> = if self.stage_label.is_empty() {
540            None
541        } else {
542            Some(self.stage_label.as_str())
543        };
544        self.inner
545            .fence_signal_and_commit(event_ref, new_value, label_opt);
546
547        self.event_value = new_value;
548        self.drained = true;
549        self.fence_pending = true;
550        Ok(())
551    }
552
553    /// Open a fresh command buffer on the same queue and (when a fence
554    /// is pending) encode the matching wait on the new CB.
555    ///
556    /// This is the second half of the multi-stage chaining primitive.
557    /// After [`Self::fence_stage`] (or [`Self::commit_stage`] /
558    /// [`Self::commit_and_wait`]) has put the session in the `Drained`
559    /// state, callers invoke this to start the next stage's CB. The
560    /// session transitions back to `Encoding` (no CB or compute encoder
561    /// open until the next dispatch lazy-opens them).
562    ///
563    /// # Wait-event encoding
564    ///
565    /// If [`Self::fence_stage`] was the most recent commit, this
566    /// method encodes `encodeWaitForEvent:value:event_value` on the
567    /// freshly-allocated CB before returning. The new CB's GPU work
568    /// blocks until the prior CB's signal lands at the same value.
569    /// After [`Self::commit_stage`] / [`Self::commit_and_wait`] (no
570    /// fence), no wait is encoded — Metal's queue-FIFO sequencing is
571    /// the implicit ordering primitive.
572    ///
573    /// # State machine
574    ///
575    /// | Before | After |
576    /// |---|---|
577    /// | Drained (no fence) | Encoding (new CB, no wait) |
578    /// | Fenced (fence pending) | Encoding (new CB, wait encoded) |
579    /// | Encoding (not drained) | no-op (returns Ok) |
580    ///
581    /// The not-drained case is intentionally a no-op rather than a
582    /// panic: it keeps the session drop-safe under unusual call
583    /// sequences (e.g. test scaffolding that calls reset speculatively).
584    ///
585    /// # Counter semantics
586    ///
587    /// Bumps `CMD_BUF_COUNT` by exactly 1 (the new CB). Does NOT bump
588    /// `SYNC_COUNT` (no commit/wait happens here).
589    ///
590    /// # Errors
591    ///
592    /// Returns `Ok(())` unconditionally. Future error paths (e.g.
593    /// queue-side allocation failure on `new_command_buffer`) would
594    /// surface here.
595    pub fn reset_for_next_stage(&mut self) -> Result<()> {
596        if !self.drained {
597            return Ok(());
598        }
599
600        // Snapshot the wait-event metadata BEFORE rotating cmd_buf so
601        // we encode the wait on the NEW CB.
602        let wait_metadata = if self.fence_pending {
603            self.event
604                .as_ref()
605                .map(|ev| (ev.clone(), self.event_value))
606        } else {
607            None
608        };
609
610        self.inner.reset_command_buffer();
611
612        if let Some((event, value)) = wait_metadata {
613            // Deref-coerce SharedEventRef → EventRef via the
614            // ParentType = Event chain in metal-0.33.0/src/sync.rs:36-40.
615            let event_ref: &metal::EventRef = event.as_ref();
616            self.inner.encode_wait_for_event(event_ref, value);
617            // iter90b §2 H1b — track the wait-event for introspection.
618            // Bump scoreboard ONLY after the wait actually encoded
619            // (mirrors the signal-side discipline: `event_value` is
620            // updated AFTER `fence_signal_and_commit` returns). These
621            // fields are pure read-only observability — they do NOT
622            // alter F1 (encoder lazy-open), F2 (residency-flush), F11
623            // (alloc_buffer zero-init), or F12 (force-serial-dispatch).
624            self.wait_count += 1;
625            self.last_wait_value = value;
626        }
627
628        self.drained = false;
629        self.fence_pending = false;
630        self.stage_label.clear();
631        Ok(())
632    }
633
634    /// Add a buffer to the device-level residency set.
635    ///
636    /// Delegates to the inner encoder's [`ResidencySet::add_allocation`]
637    /// (the same Arc clone the device, the encoder, and every other
638    /// concurrent encoder shares — single-set invariant per ADR-019:467).
639    /// The actual `[set commit]` is deferred until the next
640    /// `commit_stage` / `commit_and_wait` / `fence_stage`, which all
641    /// route through `flush_residency_pending`.
642    ///
643    /// Returns `false` and is a no-op when the device booted without
644    /// a residency set (HF2Q_NO_RESIDENCY=1, macOS<15, or
645    /// `MlxError::DeviceNotFound` test paths).
646    ///
647    /// # Use case
648    ///
649    /// Caller holds an [`MlxBuffer`] not previously registered (e.g.
650    /// from a pool, slice_view, or external interop) and wants the GPU
651    /// pages hinted as resident before the stage's first dispatch.
652    /// `MlxDevice::alloc_buffer` already auto-registers — this method
653    /// is the explicit hook for the residual cases.
654    pub fn add_to_residency_set(&self, buffer: &MlxBuffer) -> bool {
655        match self.inner.residency_set() {
656            Some(set) => {
657                set.add_allocation(buffer.metal_buffer());
658                true
659            }
660            None => false,
661        }
662    }
663
664    /// Remove a buffer from the device-level residency set.
665    ///
666    /// Mirror of [`Self::add_to_residency_set`]. Stages a deferred
667    /// `removeAllocation:` that flushes at the next commit boundary.
668    /// Returns `false` and no-ops when no residency set is active.
669    ///
670    /// # F2 caveat
671    ///
672    /// Removing a buffer that the in-flight CB still references is the
673    /// residency-rescission class. Under retained-refs (default), the
674    /// CB's ARC retain keeps the underlying Metal page alive; the
675    /// residency-set demotion only affects the resident-hint (a perf
676    /// knob, not a safety knob). Under `MLX_UNRETAINED_REFS=1`, the
677    /// caller-owned arena contract is the only structural mitigation.
678    pub fn remove_from_residency_set(&self, buffer: &MlxBuffer) -> bool {
679        match self.inner.residency_set() {
680            Some(set) => {
681                set.remove_allocation(buffer.metal_buffer());
682                true
683            }
684            None => false,
685        }
686    }
687
688    /// Whether the session has been committed (any commit path).
689    ///
690    /// Test-and-introspection helper. Production code should use the
691    /// explicit `reset_for_next_stage` cycle to chain stages rather
692    /// than polling this field.
693    #[inline]
694    pub fn is_drained(&self) -> bool {
695        self.drained
696    }
697
698    /// Whether a fence is pending (most recent commit was `fence_stage`).
699    ///
700    /// Test-and-introspection helper for verifying the multi-stage
701    /// state machine. Cleared by the next `reset_for_next_stage` /
702    /// `commit_stage` / `commit_and_wait`.
703    #[inline]
704    pub fn is_fence_pending(&self) -> bool {
705        self.fence_pending
706    }
707
708    /// The current monotonic fence value.
709    ///
710    /// Returns 0 before the first `fence_stage`; otherwise returns the
711    /// most recently signaled value. Mirrors the semantics of
712    /// `ggml_metal_event::value` — a fence at value `N` means signal
713    /// `N` is in flight (or completed) and any subsequent waiters at
714    /// `N` will be unblocked.
715    #[inline]
716    pub fn fence_value(&self) -> u64 {
717        self.event_value
718    }
719
720    /// Whether a [`MTLSharedEvent`](metal::SharedEvent) has been allocated
721    /// in this session.
722    ///
723    /// Returns `false` until the first `fence_stage`; `true` afterwards.
724    /// Test helper for verifying lazy-allocation behavior.
725    #[inline]
726    pub fn has_event(&self) -> bool {
727        self.event.is_some()
728    }
729
730    /// The most recent value passed to `encode_wait_for_event` inside
731    /// [`Self::reset_for_next_stage`].
732    ///
733    /// Returns 0 until the first `reset_for_next_stage` actually emits
734    /// a wait (i.e. the prior commit was [`Self::fence_stage`], not
735    /// [`Self::commit_stage`] / [`Self::commit_and_wait`]). After a
736    /// `fence_stage(N)` followed by `reset_for_next_stage()`, this MUST
737    /// equal `N` — the wait-side scoreboard mirrors the signal-side
738    /// [`Self::fence_value`].
739    ///
740    /// iter90b §2 H1b proof helper: makes the wait-event encoding
741    /// observable from a Rust test without xctrace.
742    ///
743    /// # Risk register
744    ///
745    /// Pure read-only introspection. Reads a `u64` field updated under
746    /// `&mut self` exclusively (no concurrent mutation possible —
747    /// `EncoderSession` is `!Sync`). Does NOT widen F1/F2/F11/F12.
748    #[inline]
749    pub fn wait_value(&self) -> u64 {
750        self.last_wait_value
751    }
752
753    /// Cumulative count of `encode_wait_for_event` calls actually
754    /// emitted inside [`Self::reset_for_next_stage`] in this session.
755    ///
756    /// Bumped exactly once per `reset_for_next_stage` call that finds
757    /// `fence_pending == true` — i.e. once per "fence + reset" pair.
758    /// `commit_stage` / `commit_and_wait` followed by
759    /// `reset_for_next_stage` does NOT bump this (no wait emitted —
760    /// Metal queue FIFO is the implicit ordering primitive in that
761    /// case).
762    ///
763    /// For an N-stage chain (N fences + (N-1) resets), this returns
764    /// `N - 1` after the last reset. The Nth (terminal) fence is
765    /// drained by the caller via `metal_command_buffer().wait_until_completed()`
766    /// or by a subsequent `commit_and_wait`, neither of which emits an
767    /// additional wait.
768    ///
769    /// iter90b §2 H1b proof helper: paired with [`Self::wait_value`] to
770    /// make the wait-event side of the multi-stage chain observable.
771    ///
772    /// # Risk register
773    ///
774    /// Same as [`Self::wait_value`] — pure read-only introspection over
775    /// a `u64` field updated under `&mut self` exclusively. Does NOT
776    /// widen F1/F2/F11/F12.
777    #[inline]
778    pub fn wait_count(&self) -> u64 {
779        self.wait_count
780    }
781
782    /// Borrow the underlying Metal command buffer.
783    ///
784    /// Mirrors [`CommandEncoder::metal_command_buffer`]. Used by
785    /// label-propagation tests and by callers that need to call
786    /// `wait_until_completed` after a non-blocking `commit_stage` /
787    /// `fence_stage`.
788    #[inline]
789    pub fn metal_command_buffer(&self) -> &metal::CommandBuffer {
790        self.inner.metal_command_buffer()
791    }
792}
793
794impl Drop for EncoderSession {
795    /// Drain the inner [`CommandEncoder`] safely on drop.
796    ///
797    /// # F2 residency-rescission preservation (load-bearing)
798    ///
799    /// Drop scenarios across the multi-stage state machine:
800    ///
801    /// 1. **Drained (no fence)** — `commit_stage` / `commit_and_wait`
802    ///    already ran. `inner.flush_residency_pending()` was already
803    ///    called; the GPU has the CB (and may already have completed
804    ///    it under `commit_and_wait`). `CommandEncoder::Drop` runs and
805    ///    calls `end_active_encoder()`, which is a no-op because
806    ///    `commit*` already ended the encoder. Safe.
807    ///
808    /// 2. **Fenced (fence pending)** — `fence_stage` already ran. The
809    ///    signal-event has been encoded onto the prior CB and the CB
810    ///    has been submitted non-blocking. The session never opened a
811    ///    new CB (no `reset_for_next_stage` call), so `cmd_buf` still
812    ///    points at the FENCED CB. `CommandEncoder::Drop` runs and
813    ///    end_active_encoder is a no-op (encoder was ended inside
814    ///    `fence_signal_and_commit`). The submitted CB executes on the
815    ///    GPU normally — the signal lands, the value is observable to
816    ///    any external `waitUntilSignaledValue:` consumer (currently
817    ///    none), and the next allocation/CB on the same residency set
818    ///    will see the bumped pending flag flushed at its commit
819    ///    boundary. The fence event itself is dropped with `event` (an
820    ///    Option<SharedEvent>); ARC drop releases it.
821    ///
822    /// 3. **Encoding (uncommitted)** — caller created the session,
823    ///    optionally encoded dispatches, then dropped without calling
824    ///    any `commit_*`. `CommandEncoder::Drop` ends the active
825    ///    compute encoder cleanly (`encoder.rs:2057-2063`). The
826    ///    `cmd_buf` is dropped without ever being committed — Metal
827    ///    discards the encoded work. **No residency-remove is staged**
828    ///    because no buffers were registered as freed during this
829    ///    session (the F2 race requires a buffer drop staging a remove
830    ///    that a later `flush_pending` commits before the in-flight CB
831    ///    finishes; here no commit ever happens). The residency-set's
832    ///    pending state persists into the next encoder; correct.
833    ///
834    /// 4. **Empty** — no dispatches encoded. `active_encoder` is null;
835    ///    `CommandEncoder::Drop`'s `end_active_encoder` is a no-op.
836    ///    Safe.
837    ///
838    /// We deliberately do NOT call `wait_until_completed` here for the
839    /// committed-but-not-waited case (scenarios 1 with `commit_stage`
840    /// or 2 with `fence_stage`). Under retained-refs mode (default —
841    /// `MLX_UNRETAINED_REFS=0`), the in-flight CB holds ARC retains on
842    /// every bound buffer, so the GPU completes safely after the
843    /// session drops. Under `MLX_UNRETAINED_REFS=1`, the
844    /// caller-owned-arena contract is the only structural mitigation —
845    /// same as the existing async-`commit()` path at
846    /// `encoder.rs:2014-2022`.
847    ///
848    /// In short: `Drop` does no extra work; the inner `CommandEncoder`'s
849    /// own Drop is the entire safety story. `metal::SharedEvent` drops
850    /// via its foreign_obj_type! ARC release.
851    fn drop(&mut self) {
852        // The actual end-encoder call lives in `CommandEncoder::Drop`,
853        // which fires automatically when `self.inner` goes out of scope
854        // here. The `event` field's ObjC release fires via
855        // foreign_obj_type! ARC. No additional work needed — see this
856        // docstring's case analysis above for the F2 fence preservation
857        // argument.
858    }
859}