spark_model/lora/
moe_row_adapter.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Feature-1 MoE-LoRA per-request routing primitives (pure, GPU-free).
4//!
5//! Two host-side building blocks that keep the MoE fold correct in a mixed
6//! batch without leaking a host D2H into the fold hot path:
7//!
8//! - [`resolve_moe_lora_route`] — the request-granularity fold decision used by
9//!   `TransformerModel::moe_lora_route`. Base / non-active requests SKIP (pay
10//!   nothing); the active-adapter request FOLDs; anything the single-active
11//!   phase-1 fold cannot serve REFUSES loudly. This is the correctness core of
12//!   the zero-overhead-when-a-request-opts-out invariant, unit-tested here.
13//! - [`build_moe_row_adapter_host`] — the `[total_tokens]` per-packed-row
14//!   adapter map (`< 0` = base) the device-side grouped fold (`lora-solid.md`
15//!   Incr 1/3) will consume as a fixed-address kernel arg. It is built the same
16//!   way the attention `seq_slot` buffer is (`slot_math::build_seq_slot_host`),
17//!   keyed off `cu_seqlens_host` (the packing SSOT = Σ proc_count), NOT
18//!   `b * chunk_len`, so varlen + partial-prefix-cache-hit batches stay aligned.
19//!   The device consumption is the documented follow-up; the builder is landed +
20//!   tested now so the row map is a solved, verified primitive.
21
22use crate::layer::MoeLoraRoute;
23
24/// SOLID Incr-4 host-side pre-lookup guard for the BATCHED decode entries
25/// (`decode_batch_compute_main`, `mixed_forward`): a batch containing a row
26/// routed to a NON-active adapter (`Refuse`) cannot be served by the
27/// single-active fold — [`build_moe_row_adapter_decode`] defensively maps such
28/// rows to base, so proceeding would SILENTLY serve base weights for an
29/// adapter-routed request. Call BEFORE any graph lookup/capture so captured
30/// `padded_n` graphs stay route-agnostic. Pure (no `self`, no GPU) so the
31/// decision is unit-testable without hardware.
32pub fn ensure_decode_route_servable(route: MoeLoraRoute, path: &str) -> anyhow::Result<()> {
33    anyhow::ensure!(
34        !matches!(route, MoeLoraRoute::Refuse),
35        "MoE LoRA {path}: a sequence routes to a non-active adapter under single-active \
36         phase-1; refusing rather than mis-folding the active adapter. One adapter per batch."
37    );
38    Ok(())
39}
40
41/// Resolve the Feature-1 MoE-LoRA fold decision for a single-request pass.
42///
43/// `adapter_slot` is the request's `SequenceState.adapter_slot` (`< 0` = no
44/// adapter / base); `active` is the installed pool's active slot index
45/// (`< 0` when no MoE adapter is installed); `has_moe_lora` is whether this
46/// layer actually installed an expert/router delta.
47///
48/// - no MoE delta installed ⇒ `Fold` (the fold hook no-ops on `self.lora ==
49///   None`, so the value is inert — kept `Fold` so nothing changes when off).
50/// - base request (`adapter_slot < 0`) ⇒ `Skip` — base tokens pay nothing.
51/// - request owns the active adapter (`adapter_slot == active`) ⇒ `Fold`.
52/// - request routes to a different, non-installed adapter ⇒ `Refuse` — phase-1
53///   installs one active MoE adapter; folding the wrong one is silently wrong,
54///   so refuse loudly instead.
55pub fn resolve_moe_lora_route(adapter_slot: i32, active: i32, has_moe_lora: bool) -> MoeLoraRoute {
56    if !has_moe_lora {
57        return MoeLoraRoute::Fold;
58    }
59    if adapter_slot < 0 {
60        return MoeLoraRoute::Skip;
61    }
62    if adapter_slot == active {
63        MoeLoraRoute::Fold
64    } else {
65        MoeLoraRoute::Refuse
66    }
67}
68
69/// Build the `[total_tokens]` per-packed-row adapter map for the device-side
70/// grouped fold. `cu_seqlens_host` is the `[batch + 1]` prefix-sum of per-stream
71/// token counts (the packing SSOT); `adapter_slots[b]` is stream `b`'s
72/// `adapter_slot`. Each stream's slot is broadcast across its
73/// `[cu_seqlens_host[b], cu_seqlens_host[b + 1])` row span. A base stream
74/// (`adapter_slot < 0`) writes `-1` (device kernel skips those rows); a stream
75/// deferring to the active adapter would resolve `< 0 → active` at the call site
76/// before this, so a genuine base row is a real `-1` here (distinct base
77/// sentinel — see `lora-solid.md` §6).
78///
79/// Returns `None` on a malformed `cu_seqlens_host` (empty, or a non-monotonic
80/// boundary) rather than panicking — the caller then declines the device fold.
81pub fn build_moe_row_adapter_host(
82    cu_seqlens_host: &[i32],
83    adapter_slots: &[i32],
84) -> Option<Vec<i32>> {
85    if cu_seqlens_host.len() < 2 {
86        return None;
87    }
88    let batch = cu_seqlens_host.len() - 1;
89    if adapter_slots.len() != batch {
90        return None;
91    }
92    if cu_seqlens_host[0] != 0 {
93        return None;
94    }
95    // Validate the WHOLE prefix-sum is non-decreasing and non-negative BEFORE
96    // writing any row, so a boundary that exceeds the declared total (e.g.
97    // `[0, 4, 2]`) is rejected rather than overflowing the map.
98    for b in 0..batch {
99        let start = cu_seqlens_host[b];
100        let end = cu_seqlens_host[b + 1];
101        if start < 0 || end < start {
102            return None; // negative or non-monotonic boundary
103        }
104    }
105    let total = cu_seqlens_host[batch];
106    let mut map = vec![-1i32; total as usize];
107    for b in 0..batch {
108        let start = cu_seqlens_host[b];
109        let end = cu_seqlens_host[b + 1];
110        let slot = adapter_slots[b];
111        for row in start..end {
112            map[row as usize] = slot;
113        }
114    }
115    Some(map)
116}
117
118/// SOLID Incr-4 (batched decode fold): build the per-row `[padded_n]` i32
119/// adapter map the device-side MoE gather-BGMV fold reads. One token per
120/// sequence at decode, so a per-row map IS a per-seq map (no `top_k` expansion —
121/// the kernel indexes `row_adapter[row / top_k]`).
122///
123/// Per row `i`, `resolve_moe_lora_route(adapter_slots[i], active, has_moe_lora)`:
124///   - `Fold` (owns the installed active adapter) ⇒ write `active` (`>= 0` when
125///     an adapter is resident — the kernel only tests the SIGN, and the single
126///     active adapter's per-expert tables are folded). When no adapter is
127///     installed (`has_moe_lora == false`) `active` is `-1`, so the row
128///     correctly skips.
129///   - `Skip` (base / non-active) ⇒ `-1` (device kernel skips the row — MoE's
130///     `< 0 = base` semantics, NOT the attention `seq_slot` `-1 → active`).
131///   - `Refuse` (non-active adapter present) ⇒ `-1` DEFENSIVELY. A `Refuse`
132///     batch is bailed host-side before this map is ever uploaded
133///     (`stamp_decode_moe_batch` + the `decode_batch_compute_main` pre-lookup
134///     guard); mapping to base here means a leaked `Refuse` row folds NOTHING
135///     rather than mis-folding the wrong adapter.
136///   - pad rows (`i >= adapter_slots.len()`) ⇒ `-1` (skip).
137///
138/// Pure + GPU-free so the routing is unit-testable without hardware; the device
139/// upload is a thin wrapper (`upload_moe_row_adapter`).
140pub fn build_moe_row_adapter_decode(
141    adapter_slots: &[i32],
142    padded_n: usize,
143    active: i32,
144    has_moe_lora: bool,
145) -> Vec<i32> {
146    (0..padded_n)
147        .map(|i| match adapter_slots.get(i).copied() {
148            Some(slot) => match resolve_moe_lora_route(slot, active, has_moe_lora) {
149                MoeLoraRoute::Fold => active,
150                MoeLoraRoute::Skip | MoeLoraRoute::Refuse => -1,
151            },
152            None => -1, // pad row
153        })
154        .collect()
155}
156
157#[cfg(test)]
158#[path = "moe_row_adapter_tests.rs"]
159mod tests;