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;