diff --git a/docs/index.md b/docs/index.md index 021ca864a..07578aa17 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,7 +54,7 @@ Organized by domain (model line / subsystem / playbook / lesson) instead of by l | `models/qwen35/model-crate.md` | `pegainfer-qwen35` owns Qwen3.5 model/scheduler/recurrent ops/tests/benches; feature-gated behind `qwen35` (Triton AOT is the only Python build dependency); root loads it through `EngineHandle`. Build/check/clippy, root bench sanity check, historical Qwen3.5 e2e, and scheduler e2e records live here. | | `models/qwen35/batched-step-tail.md` | Qwen3.5 issue #353 implementation record: final prefill tail is batched, decode/unified sample from batched logits, host full-vocab copies are logprobs-only, HF + scheduler e2e pass, and final serving A/B supports only the first-token/short-output TTFT claim. | | `models/qwen35/tp-design.md` | Qwen3.5 TP design: Phase 1 is eager dense TP on Qwen3's controller/worker runtime; validate TP2 first, fail closed for indivisible degrees and TP+CUDA Graph, shard dense full-attention/MLP, and leave sharded linear/GDR state to follow-up. | -| `models/qwen35/tp-implementation.md` | Qwen3.5 TP Phase 1 and P2A are complete: TP2 has start-gated eager unified prefill+decode, strict ID-aligned artifacts, fail-closed lifecycle recovery, and pre-load ordinal validation; P2B GDR state sharding is next. | +| `models/qwen35/tp-implementation.md` | Qwen3.5 TP Phase 1, P2A, and P2B GDR state sharding are complete: TP2 has start-gated eager unified prefill+decode, fail-closed lifecycle recovery, and rank-local linear-attention weights/state with a post-`out_proj` hidden all-reduce; batched TP decode (#1004) and TP CUDA Graph (#1005) are next. | | `models/qwen35/mixed-load-itl-470.md` | Issue #470: full cold `--max-batch 8/bg=4` matrix on RTX 4090 (24/24 valid) + starvation negative control. Qwen3.5 is not immune; chunking bounds max/per-step stall but raises p99 at low QPS (~14→~80–92ms) and pulls p99/max back from the prefill wall to the chunk wall at high load; `qps·prefill_s≳1` is a throughput wall (chunking can't fix it, and ON's +15% TTFT can trip it earlier). The old "p99 immunity" was a slot-starvation artifact. | | `models/qwen35/adaptive-scheduler-policy.md` | Issue #727 adaptive scheduler policy record: default `off`, opt-in `auto`, hard `--max-prefill-tokens` cap, TP `auto` rejection, and pre-review whole-prefill benchmark tradeoff retained as non-default evidence. | | `models/qwen35/unified-prefill-overlap.md` | Issue #715 implementation record: opt-in single-GPU shared-SM overlap keeps one prefill chunk in flight while active decode continues; default serial policy and unsupported-combination guards remain explicit. | diff --git a/docs/models/qwen35/tp-implementation.md b/docs/models/qwen35/tp-implementation.md index d8ebda80d..8d8209ac5 100644 --- a/docs/models/qwen35/tp-implementation.md +++ b/docs/models/qwen35/tp-implementation.md @@ -1,8 +1,8 @@ # Qwen3.5 TP Implementation Record -> **TL;DR:** Qwen3.5 TP Phase 1 and P2A are complete: TP2 now supports start-gated eager unified prefill+decode with strict ID-aligned artifacts, fail-closed lifecycle recovery, and pre-load CUDA ordinal validation; P2B GDR state sharding is next. +> **TL;DR:** Qwen3.5 TP Phase 1, P2A, and the P2B GDR state sharding are complete: TP2 has start-gated eager unified prefill+decode, fail-closed lifecycle recovery, and rank-local linear-attention weights/state with a single post-`out_proj` hidden all-reduce; batched eager TP decode and TP CUDA Graph are next. > -> **Last touched:** 2026-08 +> **Last touched:** 2026-09 ## Scope @@ -139,7 +139,7 @@ Stable test knobs: ## Phase 2 Progress -Phase 2 is locked in `docs/models/qwen35/tp-design.md` as two separate implementation series: P2a is eager mixed unified execution on the replicated Phase 1 GDR path; P2b shards the head-indexed linear-attention/GDR weight and state surface. P2a protocol/lifecycle gates are complete, so P2b can now change loader, kernel, and state shapes while preserving those contracts. +Phase 2 is locked in `docs/models/qwen35/tp-design.md` as two separate implementation series: P2a is eager mixed unified execution on the replicated Phase 1 GDR path; P2b shards the head-indexed linear-attention/GDR weight and state surface. P2a protocol/lifecycle gates are complete, and P2b's core sharding has landed on top of them without weakening the P2A lifecycle and ID contracts (see below). The remaining Phase 2 work is batched eager TP decode (#1004) and TP CUDA Graph (#1005). ### P2a: TP mixed-step unified execution @@ -445,24 +445,33 @@ Why this should be separated from GDR sharding: ### P2b: sharded linear-attention/GDR state -Shard the Qwen3.5 linear-attention/GDR path after P2a establishes the mixed-step and state-lifecycle contract. +Landed as #946 split 1/4 (#1003), after P2a had established the mixed-step and state-lifecycle contract. Each TP rank now owns a rank-local slice of the linear-attention/GDR surface instead of replicating it: -Expected work: +- `LocalGeometry` computes rank-local linear dims (`local_linear_num_key_heads`, `local_linear_num_value_heads`, `local_linear_v_dim`, `local_linear_qkv_dim`, `local_linear_z_dim`) and fails closed with `ConfigError::TpIndivisible` when `linear_num_key_heads` does not divide by `world_size`; there is no silent replication fallback. Value-head divisibility needs no second guard: `Config35` already validates `linear_num_value_heads % linear_num_key_heads == 0`. +- Weight loading shards the head-indexed tensors: the fused QKV projection and the depthwise conv1d are stitched head-locally per segment (`load_linear_in_proj_qkv_shard` / `load_linear_conv1d_shard`; Q/K segments follow key-head ranges, V/conv follow value-head ranges), z/beta/alpha are row shards, `dt_bias`/`A_log` are 1-D shards (A_log stays f32), and linear `out_proj` is column sharded as a row-parallel `[hidden, local_z]` matrix. +- `RecurrentState` (`[local_value_heads, K, V]` f32), conv state (`[local_qkv x (kernel_dim - 1)]` bf16), and all prefill/decode scratch (`GdrChunkwiseScratch35`, prefill/decode buffers) size themselves from the local geometry; worker capacity math uses the same locals. +- The hidden-residual all-reduce happens once after the local linear-attention `out_proj` (`all_reduce_hidden`), on prefill and decode alike; the column-sharded `out_proj` is what makes that reduction point sufficient. +- Full-attention decode-group supportability uses the config-level GQA group (`Config35::decode_group_is_compiled`): head sharding leaves the q-per-kv group size unchanged, so the predicate is identical on every rank and the reroute adds no collectives. The 27B case leaves `q/kv = 6`, which has no compiled FlashInfer batch-decode kernel, so those layers reroute decode through the eager/paged fallback. +- TP1 contract is unchanged: at `world_size == 1` every local dim equals the global dim, so kernels, buffers, and fixture behavior are byte-identical to pre-P2b. -- shard linear-attention projection weights -- shard conv state and GDR recurrent state by local value/key heads -- adapt or regenerate GDR kernels for local state shapes -- keep recurrent/conv state rank-local and request-local -- all-reduce only after local linear-attention `out_proj` -- report matched Phase 1 TP2 versus P2b TP2 HBM/latency/throughput data before making a performance claim - -Non-negotiable invariant: +Non-negotiable invariant (still held): - Never all-reduce GDR recurrent state or conv state. These states are owned by rank-local request state. +Acceptance at `fcdeb5a4` (27B TP2 on 2x RTX 4090 48GB, sm_89; fixture-pinned 27B revision `fc05daec`): + +- TP2 short HF logits gate passes: + - sequential eager: `108` positions, mean `0.0210`, p99 `0.0749`, max `0.1240` + - batched eager: `72` positions, mean `0.0201`, p99 `0.0749`, max `0.0803`; the batched leg includes drop -> re-prefill slot cycles +- TP2 long HF logits gate passes with prompts `4097` and `8192`: sequential eager, `18` positions, mean `0.0177`, p99 `0.0660` +- TP2 scheduler E2E and TP2 HTTP serving gate pass. +- Peak per-rank HBM is `35,988` / `36,822` MiB of `49,140` MiB: 27B TP2 now fits the 2x48GB pair that Phase-1 replicated state OOMed, and memory fully releases between test processes. + +Not in this step: batching the TP decode loop across rows, TP CUDA Graph capture, and the matched Phase-1-vs-P2b HBM/latency/throughput A/B promised in #1001; no performance claim is made until that rerun lands on the merged stack. + ## Follow-Ups -- Design and implement P2B sharded linear-attention/GDR state without weakening the completed P2A lifecycle and ID contracts. +- Land batched eager TP decode (#1004) on top of the P2B state sharding, then TP CUDA Graph (#1005); rerun the #946 throughput A/B on the merged stack before any performance claim. - Promote any stable contract changes discovered here back into `tp-design.md` through the design-doc branch. - Decide whether Qwen3.5 server CLI should accept arbitrary TP device ordinals instead of only `0..tp_size`. - Consider lifting the per-device Triton AOT handle lesson into a kernels or runtime subsystem doc if another model hits the same issue. diff --git a/pegainfer-core/src/weight_loader.rs b/pegainfer-core/src/weight_loader.rs index a6849153d..373afdf85 100644 --- a/pegainfer-core/src/weight_loader.rs +++ b/pegainfer-core/src/weight_loader.rs @@ -393,6 +393,49 @@ fn tensor_bf16_cow<'d>( } } +/// Typed F32 payload with dtype and 1D-shape validation. Aligned payloads +/// borrow zero-copy; misaligned ones (legal in safetensors) decode +/// little-endian into an owned buffer, since a misaligned f32 view is UB. +#[allow(clippy::cast_ptr_alignment)] +fn tensor_f32_cow<'d>( + tensor: &safetensors::tensor::TensorView<'d>, + name: &str, +) -> Result> { + anyhow::ensure!( + tensor.dtype() == Dtype::F32, + "Tensor '{name}': expected dtype F32, got {:?}", + tensor.dtype() + ); + anyhow::ensure!( + tensor.shape().len() == 1, + "Tensor '{name}': expected 1D shape, got {:?}", + tensor.shape() + ); + let data = tensor.data(); + anyhow::ensure!( + data.len().is_multiple_of(std::mem::size_of::()), + "Tensor '{name}': {} bytes is not a whole number of f32 elements", + data.len() + ); + if (data.as_ptr() as usize).is_multiple_of(std::mem::align_of::()) { + // SAFETY: alignment checked; any bit pattern is a valid f32. + Ok(Cow::Borrowed(unsafe { + std::slice::from_raw_parts( + data.as_ptr().cast::(), + data.len() / std::mem::size_of::(), + ) + })) + } else { + Ok(Cow::Owned( + data.as_chunks::<4>() + .0 + .iter() + .map(|&b| f32::from_le_bytes(b)) + .collect(), + )) + } +} + /// One row-consecutive part of a fused matrix: `rows` rows starting at /// `row_offset` of a source tensor that must have exactly `src_rows` rows. pub struct FusedPart<'a> { @@ -805,29 +848,131 @@ pub fn load_tensor_2d_col_shard( DeviceMatrix::from_host(ctx, &host, rows, cols) } -#[allow(clippy::cast_ptr_alignment)] -/// Load a 1D F32 tensor to GPU as CudaSlice. -/// For weights stored in float32 (e.g., A_log, norm.weight in linear attention). -pub fn load_tensor_1d_f32( +/// Load a 2D tensor assembled from multiple row ranges of one source tensor, +/// stitched in `ranges` order: each entry is (row_offset, rows). +pub fn load_tensor_2d_row_stitch( ctx: &DeviceContext, shards: &[SafeTensors], weight_map: &HashMap, name: &str, + ranges: &[(usize, usize)], +) -> Result { + let tensor = find_tensor(shards, weight_map, name)?; + let shape = tensor.shape(); + if shape.len() != 2 { + return Err(anyhow::anyhow!( + "Tensor '{}' expected 2D, got shape {:?}", + name, + shape + )); + } + let total_rows = shape[0]; + let cols = shape[1]; + let mut total = 0usize; + for &(row_offset, rows) in ranges { + if row_offset + rows > total_rows { + return Err(anyhow::anyhow!( + "2D row stitch out of bounds for '{}': row_offset={} rows={} total_rows={}", + name, + row_offset, + rows, + total_rows + )); + } + total += rows; + } + let elems = tensor_bf16_cow(&tensor, name)?; + let mut host = Vec::with_capacity(total * cols); + for &(row_offset, rows) in ranges { + let start = row_offset * cols; + host.extend_from_slice(&elems[start..start + rows * cols]); + } + DeviceMatrix::from_host(ctx, &host, total, cols) +} + +/// Load a 1D BF16 tensor assembled from multiple element ranges of one source +/// tensor, stitched in `ranges` order: each entry is (offset, len). +pub fn load_tensor_1d_stitch( + ctx: &DeviceContext, + shards: &[SafeTensors], + weight_map: &HashMap, + name: &str, + ranges: &[(usize, usize)], +) -> Result { + let tensor = find_tensor(shards, weight_map, name)?; + let elems = tensor_bf16_cow(&tensor, name)?; + let mut total = 0usize; + for &(offset, len) in ranges { + if offset + len > elems.len() { + return Err(anyhow::anyhow!( + "1D stitch out of bounds for '{}': offset={} len={} total_len={}", + name, + offset, + len, + elems.len() + )); + } + total += len; + } + let mut host = Vec::with_capacity(total); + for &(offset, len) in ranges { + host.extend_from_slice(&elems[offset..offset + len]); + } + DeviceVec::from_host(ctx, &host) +} + +/// Load a 1D BF16 element range to GPU (tensor-parallel shard of a 1D weight). +pub fn load_tensor_1d_shard( + ctx: &DeviceContext, + shards: &[SafeTensors], + weight_map: &HashMap, + name: &str, + offset: usize, + len: usize, +) -> Result { + load_tensor_1d_stitch(ctx, shards, weight_map, name, &[(offset, len)]) +} + +/// Load a 1D F32 element range to GPU (tensor-parallel shard of a 1D weight). +pub fn load_tensor_1d_f32_shard( + ctx: &DeviceContext, + shards: &[SafeTensors], + weight_map: &HashMap, + name: &str, + offset: usize, + len: usize, ) -> Result> { let tensor = find_tensor(shards, weight_map, name)?; - let data = tensor.data(); - if data.len() % 4 != 0 { + let elems = tensor_f32_cow(&tensor, name)?; + if offset + len > elems.len() { return Err(anyhow::anyhow!( - "F32 tensor '{}': data length {} not multiple of 4", + "F32 1D shard out of bounds for '{}': offset={} len={} total_len={}", name, - data.len() + offset, + len, + elems.len() )); } - let len = data.len() / 4; - let slice = unsafe { std::slice::from_raw_parts(data.as_ptr().cast::(), len) }; let gpu_data = ctx .stream - .clone_htod(slice) + .clone_htod(&elems[offset..offset + len]) + .map_err(|e| anyhow::anyhow!("H2D copy failed for '{}': {}", name, e))?; + Ok(gpu_data) +} + +/// Load a 1D F32 tensor to GPU as CudaSlice. +/// For weights stored in float32 (e.g., A_log, norm.weight in linear attention). +pub fn load_tensor_1d_f32( + ctx: &DeviceContext, + shards: &[SafeTensors], + weight_map: &HashMap, + name: &str, +) -> Result> { + let tensor = find_tensor(shards, weight_map, name)?; + let elems = tensor_f32_cow(&tensor, name)?; + let gpu_data = ctx + .stream + .clone_htod(elems.as_ref()) .map_err(|e| anyhow::anyhow!("H2D copy failed for '{}': {}", name, e))?; Ok(gpu_data) } @@ -945,6 +1090,45 @@ mod tests { use safetensors::tensor::TensorView; use super::tensor_bf16_cow; + use super::tensor_f32_cow; + + #[test] + fn tensor_f32_cow_borrows_aligned_and_decodes_unaligned() { + let vals: [u32; 4] = [0x3f80_0000, 0x0000_0001, 0xbf12_3456, 0x7f80_0001]; + let mut bytes = vec![0u8; vals.len() * 4 + 3]; + // A Vec base has no alignment guarantee; derive both offsets from + // the actual address so each branch is forced deterministically. + let base = bytes.as_ptr() as usize; + let aligned_off = base.next_multiple_of(4) - base; + for (off, expect_borrowed) in [(aligned_off, true), (aligned_off + 1, false)] { + for (i, v) in vals.iter().enumerate() { + bytes[off + i * 4..off + i * 4 + 4].copy_from_slice(&v.to_le_bytes()); + } + let view = TensorView::new( + Dtype::F32, + vec![vals.len()], + &bytes[off..off + vals.len() * 4], + ) + .unwrap(); + let cow = tensor_f32_cow(&view, "w").unwrap(); + assert_eq!( + matches!(cow, Cow::Borrowed(_)), + expect_borrowed, + "off={off}" + ); + let got: Vec = cow.iter().map(|f| f.to_bits()).collect(); + assert_eq!(got, vals, "off={off}"); + } + } + + #[test] + fn tensor_f32_cow_rejects_wrong_dtype_and_rank() { + let bytes = vec![0u8; 8]; + let bf16_view = TensorView::new(Dtype::BF16, vec![4], &bytes).unwrap(); + assert!(tensor_f32_cow(&bf16_view, "w").is_err()); + let f32_2d_view = TensorView::new(Dtype::F32, vec![2, 1], &bytes).unwrap(); + assert!(tensor_f32_cow(&f32_2d_view, "w").is_err()); + } #[test] fn tensor_bf16_cow_borrows_aligned_and_decodes_unaligned() { diff --git a/pegainfer-qwen35/src/batch_decode.rs b/pegainfer-qwen35/src/batch_decode.rs index 8d1c373c6..f294000f0 100644 --- a/pegainfer-qwen35/src/batch_decode.rs +++ b/pegainfer-qwen35/src/batch_decode.rs @@ -179,6 +179,9 @@ impl Qwen35Model { bufs: &mut BatchDecodeBuffers35, ) -> Result<()> { let eps = self.config.rms_norm_eps; + let geom = self.geometry; + let num_attention_heads = geom.local_num_attention_heads(); + let num_key_value_heads = geom.local_num_key_value_heads(); ops::gemm_into(&self.ctx, &attn.q_proj, &bufs.normed, &mut bufs.q_full); ops::gemm_into(&self.ctx, &attn.k_proj, &bufs.normed, &mut bufs.k_attn); @@ -194,8 +197,8 @@ impl Qwen35Model { &self.cos_cache, &self.sin_cache, &bufs.positions_d, - self.config.num_attention_heads, - self.config.num_key_value_heads, + num_attention_heads, + num_key_value_heads, self.config.rotary_dim, eps, ); @@ -211,7 +214,7 @@ impl Qwen35Model { plan, &bufs.positions_d, &mut bufs.attn_out_full, - self.config.num_attention_heads, + num_attention_heads, bs, )?; @@ -221,7 +224,7 @@ impl Qwen35Model { crate::ffi::attention_gate_batch_hd256_cuda( qf_ptr as *const crate::ffi::Half, out_ptr as *mut crate::ffi::Half, - self.config.num_attention_heads as i32, + num_attention_heads as i32, bs as i32, self.ctx.stream.cu_stream(), ); @@ -288,12 +291,31 @@ impl Qwen35Model { let kv_refs: Vec<&KvState> = kv_states.iter().map(|s| &**s).collect(); bufs.sync_paged_meta(&self.ctx, &kv_refs, bs)?; + // When this GQA group has no compiled batch-decode kernel, run full + // attention through the paged-prefill kernel with a per-step plan. + // Head sharding leaves the q-per-kv group size unchanged, so the + // config-level predicate decides the per-rank route identically on + // every rank; the reroute adds no collectives. + let prefill_attn_plan = if self.config.decode_group_is_compiled() { + None + } else { + let start_positions: Vec = positions.iter().map(|&p| p as usize).collect(); + Some(self.one_token_paged_plan( + &kv_refs, + &start_positions, + self.geometry.local_num_attention_heads(), + self.geometry.local_num_key_value_heads(), + "eager decode", + )?) + }; + let kv_buffer = kv_states[0].buffer(); let layout = *kv_states[0].layout(); self.batch_decode_kernels_graph( kv_buffer, &layout, bs, + prefill_attn_plan.as_ref(), &linear_pointer_tables.state_ptrs, &linear_pointer_tables.conv_state_ptrs, bufs, @@ -394,6 +416,7 @@ impl Qwen35Model { kv_buffer, &layout, padded_bs, + None, linear_state_ptrs, linear_conv_state_ptrs, &mut graph_state.buffers, @@ -450,31 +473,14 @@ impl Qwen35Model { ) })?; - let page_indices: Vec> = - kv_states.iter().map(|kv| kv.page_indices_i32()).collect(); - let last_page_lens: Vec = kv_states.iter().map(|kv| kv.last_page_len()).collect(); - let seq_lens = vec![1usize; bs]; - // cta_tile_q 0 = the kernel's own FA2 derivation; the hd256 FFI takes no override. - let plan = ops::PrefillPagedPlan::from_raw_batch_with_cta_tile_q( - &self.ctx, - &page_indices, - &last_page_lens, + let kv_refs: Vec<&KvState> = kv_states.iter().map(|s| &**s).collect(); + let plan = self.one_token_paged_plan( + &kv_refs, &start_positions, - &seq_lens, - self.config.num_attention_heads, - self.config.num_key_value_heads, - self.config.head_dim, - 0, - ) - .with_context(|| { - format!( - "hybrid decode build PrefillPagedPlan bs={bs}, pages={}, heads={}/{}, head_dim={}", - page_indices.iter().map(Vec::len).sum::(), - self.config.num_attention_heads, - self.config.num_key_value_heads, - self.config.head_dim - ) - })?; + self.geometry.local_num_attention_heads(), + self.geometry.local_num_key_value_heads(), + "hybrid decode", + )?; let kv_buffer = kv_states[0].buffer(); let layout = *kv_states[0].layout(); @@ -498,11 +504,48 @@ impl Qwen35Model { ) } + /// Paged-prefill plan that runs one decode row per request through the + /// prefill attention kernel; used when the GQA group has no compiled + /// batch-decode kernel. `cta_tile_q` 0 = the kernel's own FA2 derivation; + /// the hd256 FFI takes no override. + fn one_token_paged_plan( + &self, + kv_refs: &[&KvState], + start_positions: &[usize], + num_q_heads: usize, + num_kv_heads: usize, + label: &str, + ) -> Result { + let bs = kv_refs.len(); + let page_indices: Vec> = kv_refs.iter().map(|kv| kv.page_indices_i32()).collect(); + let last_page_lens: Vec = kv_refs.iter().map(|kv| kv.last_page_len()).collect(); + let seq_lens = vec![1usize; bs]; + ops::PrefillPagedPlan::from_raw_batch_with_cta_tile_q( + &self.ctx, + &page_indices, + &last_page_lens, + start_positions, + &seq_lens, + num_q_heads, + num_kv_heads, + self.config.head_dim, + 0, + ) + .with_context(|| { + format!( + "{label} build PrefillPagedPlan bs={bs}, pages={}, heads={num_q_heads}/{num_kv_heads}, head_dim={}", + page_indices.iter().map(Vec::len).sum::(), + self.config.head_dim + ) + }) + } + fn batch_decode_kernels_graph( &self, kv_buffer: &cudarc::driver::CudaSlice, layout: &KvLayout, padded_bs: usize, + prefill_attn_plan: Option<&ops::PrefillPagedPlan>, linear_state_ptrs: &[CudaSlice], linear_conv_state_ptrs: &[CudaSlice], bufs: &mut BatchDecodeBuffers35, @@ -529,9 +572,17 @@ impl Qwen35Model { match &layer.attn { LayerKind::FullAttention(attn) => { - self.batch_decode_full_attention( - attn, kv_buffer, layout, full_idx, padded_bs, bufs, - )?; + // The eager TP path passes a per-step prefill plan when the + // TP-local GQA group has no compiled batch-decode kernel; + // graph capture always passes None (rerouted earlier). + match prefill_attn_plan { + Some(plan) => self.batch_decode_full_attention_via_prefill( + attn, kv_buffer, layout, plan, full_idx, padded_bs, bufs, + )?, + None => self.batch_decode_full_attention( + attn, kv_buffer, layout, full_idx, padded_bs, bufs, + )?, + } full_idx += 1; } LayerKind::LinearAttention(attn) => { @@ -541,7 +592,7 @@ impl Qwen35Model { &linear_conv_state_ptrs[linear_idx], padded_bs, bufs, - ); + )?; linear_idx += 1; } } @@ -646,7 +697,7 @@ impl Qwen35Model { &linear_conv_state_ptrs[linear_idx], bs, bufs, - ); + )?; linear_idx += 1; } } @@ -719,6 +770,9 @@ impl Qwen35Model { /// Iterates 0..`padded_bs`. Real requests are in 0..real_bs; padding slots /// (real_bs..padded_bs) run but their output columns are ignored by the caller. /// All GPU addresses are stable per slot index, making this CUDA Graph safe. + /// + /// `out_proj` is column-sharded, so its partial hidden sum is the one + /// linear-attention output all-reduced under TP (no-op at world_size 1). fn batch_decode_linear_attention_slots( &self, attn: &LinearAttentionLayer, @@ -726,7 +780,9 @@ impl Qwen35Model { conv_state_ptrs: &CudaSlice, padded_bs: usize, bufs: &mut BatchDecodeBuffers35, - ) { + ) -> Result<()> { + let geom = self.geometry; + ops::gemm_into(&self.ctx, &attn.in_proj_qkv, &bufs.normed, &mut bufs.qkv); ops::gemm_into(&self.ctx, &attn.in_proj_z, &bufs.normed, &mut bufs.z); ops::gemm_into(&self.ctx, &attn.in_proj_b, &bufs.normed, &mut bufs.b_proj); @@ -750,8 +806,8 @@ impl Qwen35Model { state_ptrs, &mut bufs.gdr_out, padded_bs, - self.config.linear_num_key_heads, - self.config.linear_num_value_heads, + geom.local_linear_num_key_heads(), + geom.local_linear_num_value_heads(), self.config.linear_key_head_dim, self.config.linear_value_head_dim, ); @@ -762,7 +818,7 @@ impl Qwen35Model { &attn.norm_weight, &bufs.z, &mut bufs.normed_gated, - self.config.linear_num_value_heads, + geom.local_linear_num_value_heads(), self.config.linear_value_head_dim, self.config.rms_norm_eps, ); @@ -772,5 +828,7 @@ impl Qwen35Model { &bufs.normed_gated, &mut bufs.attn_results, ); + self.all_reduce_hidden(&mut bufs.attn_results)?; + Ok(()) } } diff --git a/pegainfer-qwen35/src/batch_decode_graph.rs b/pegainfer-qwen35/src/batch_decode_graph.rs index eb88ba691..59d5225d3 100644 --- a/pegainfer-qwen35/src/batch_decode_graph.rs +++ b/pegainfer-qwen35/src/batch_decode_graph.rs @@ -79,7 +79,7 @@ impl BatchDecodeGraphState { let mut slot_states = Vec::with_capacity(max_batch); for _ in 0..max_batch { - slot_states.push(RecurrentState::new(ctx, config)?); + slot_states.push(RecurrentState::new(ctx, config, geometry)?); } let linear_pointer_tables = { let mut slot_refs: Vec<&mut RecurrentState> = slot_states.iter_mut().collect(); diff --git a/pegainfer-qwen35/src/config/model.rs b/pegainfer-qwen35/src/config/model.rs index 64c7fdecd..566c25459 100644 --- a/pegainfer-qwen35/src/config/model.rs +++ b/pegainfer-qwen35/src/config/model.rs @@ -141,14 +141,6 @@ impl Config35 { .contains(&(self.num_attention_heads / self.num_key_value_heads)) } - /// QKV projection output dimension for linear attention. - pub(crate) fn linear_attn_qkv_dim(&self) -> usize { - let q_dim = self.linear_num_key_heads * self.linear_key_head_dim; - let k_dim = q_dim; - let v_dim = self.linear_num_value_heads * self.linear_value_head_dim; - q_dim + k_dim + v_dim - } - /// Z projection output dimension for linear attention. pub(crate) fn linear_attn_z_dim(&self) -> usize { self.linear_num_value_heads * self.linear_value_head_dim diff --git a/pegainfer-qwen35/src/config/tp.rs b/pegainfer-qwen35/src/config/tp.rs index 9f2a97e1a..bb58d8e79 100644 --- a/pegainfer-qwen35/src/config/tp.rs +++ b/pegainfer-qwen35/src/config/tp.rs @@ -83,6 +83,10 @@ pub(crate) struct LocalGeometry { local_full_attn_q_dim: usize, local_full_attn_kv_dim: usize, local_full_attn_gated_q_dim: usize, + local_linear_num_key_heads: usize, + local_linear_num_value_heads: usize, + local_linear_v_dim: usize, + local_linear_qkv_dim: usize, } impl LocalGeometry { @@ -92,7 +96,8 @@ impl LocalGeometry { /// Fails on unsupported combinations before expensive loading: /// - sharded TP demands eager execution (`enable_cuda_graph` off); /// - every sharded model dimension must divide evenly by `world_size` - /// (linear-attention head counts are intentionally exempt); + /// (linear-attention key heads included; the value-head count follows + /// from the `Config35` key/value-head invariant); /// - `rank < world_size` and `world_size >= 1` are guaranteed by /// `TensorParallelConfig::try_from`. pub(crate) fn try_new( @@ -126,12 +131,28 @@ impl LocalGeometry { world_size: tp.world_size(), }); } + // Fail closed on an indivisible key-head count rather than falling + // back to replication; value-head divisibility follows from the + // Config35 value % key invariant and needs no second guard. + if !config.linear_num_key_heads.is_multiple_of(tp.world_size()) { + return Err(ConfigError::TpIndivisible { + field: "linear_num_key_heads", + value: config.linear_num_key_heads, + world_size: tp.world_size(), + }); + } let local_num_attention_heads = config.num_attention_heads / tp.world_size(); let local_num_key_value_heads = config.num_key_value_heads / tp.world_size(); let local_intermediate_size = config.intermediate_size / tp.world_size(); let local_full_attn_q_dim = local_num_attention_heads * config.head_dim; let local_full_attn_kv_dim = local_num_key_value_heads * config.head_dim; + let local_linear_num_key_heads = config.linear_num_key_heads / tp.world_size(); + let local_linear_num_value_heads = config.linear_num_value_heads / tp.world_size(); + // Local q/k segment rows of the fused linear-attention qkv projection; + // q is keyed by key heads (one key head per value-head group). + let local_linear_q_dim = local_linear_num_key_heads * config.linear_key_head_dim; + let local_linear_v_dim = local_linear_num_value_heads * config.linear_value_head_dim; Ok(Self { tp, @@ -141,6 +162,11 @@ impl LocalGeometry { local_full_attn_q_dim, local_full_attn_kv_dim, local_full_attn_gated_q_dim: local_full_attn_q_dim * 2, + local_linear_num_key_heads, + local_linear_num_value_heads, + local_linear_v_dim, + // [q_local | k_local | v_local] in storage order; k == q. + local_linear_qkv_dim: local_linear_q_dim * 2 + local_linear_v_dim, }) } @@ -184,6 +210,28 @@ impl LocalGeometry { pub(crate) fn local_full_attn_gated_q_dim(&self) -> usize { self.local_full_attn_gated_q_dim } + + // ── Linear-attention local dims ─────────────────────────────────────── + // TP1 contract: at world_size 1 every local dim equals the global dim, so + // all linear-attention kernels/buffers/state keep their pre-TP shapes. + + pub(crate) fn local_linear_num_key_heads(&self) -> usize { + self.local_linear_num_key_heads + } + + pub(crate) fn local_linear_num_value_heads(&self) -> usize { + self.local_linear_num_value_heads + } + + /// Local fused qkv rows: [q_local | k_local | v_local] in storage order. + pub(crate) fn local_linear_qkv_dim(&self) -> usize { + self.local_linear_qkv_dim + } + + /// Local z projection output dimension (equals local v dim). + pub(crate) fn local_linear_z_dim(&self) -> usize { + self.local_linear_v_dim + } } #[cfg(test)] @@ -308,11 +356,21 @@ mod tests { } #[test] - fn linear_attention_heads_need_not_divide_world_size() { - let mut cfg = config(); - cfg.linear_num_key_heads = 17; - cfg.linear_num_value_heads = 31; + fn requires_linear_attention_key_head_divisibility() { let tp = TensorParallelConfig::try_from((1, 2)).unwrap(); - LocalGeometry::try_new(&cfg, tp, false).unwrap(); + let mut broken = config(); + // Keep the Config35 value % key invariant intact so the failing + // branch is the key-head TP guard itself. + broken.linear_num_key_heads = 17; + broken.linear_num_value_heads = 34; + let err = LocalGeometry::try_new(&broken, tp, false).unwrap_err(); + assert_eq!( + err, + ConfigError::TpIndivisible { + field: "linear_num_key_heads", + value: 17, + world_size: 2, + } + ); } } diff --git a/pegainfer-qwen35/src/decode_buffers.rs b/pegainfer-qwen35/src/decode_buffers.rs index 2275d2167..cea677445 100644 --- a/pegainfer-qwen35/src/decode_buffers.rs +++ b/pegainfer-qwen35/src/decode_buffers.rs @@ -75,9 +75,9 @@ impl BatchDecodeBuffers35 { let q_proj_dim = geometry.local_full_attn_gated_q_dim(); let q_dim = geometry.local_full_attn_q_dim(); let kv_dim = geometry.local_full_attn_kv_dim(); - let qkv_dim = config.linear_attn_qkv_dim(); - let z_dim = config.linear_attn_z_dim(); - let b_dim = config.linear_num_value_heads; + let qkv_dim = geometry.local_linear_qkv_dim(); + let z_dim = geometry.local_linear_z_dim(); + let b_dim = geometry.local_linear_num_value_heads(); let a_dim = b_dim; let intermediate = geometry.local_intermediate_size(); diff --git a/pegainfer-qwen35/src/executor.rs b/pegainfer-qwen35/src/executor.rs index 60e021330..956d0f9ab 100644 --- a/pegainfer-qwen35/src/executor.rs +++ b/pegainfer-qwen35/src/executor.rs @@ -167,7 +167,13 @@ impl Qwen35Executor { let mut recurrent_states: Vec = plan .requests .iter() - .map(|_| RecurrentState::new(self.model.device_ctx(), self.model.config())) + .map(|_| { + RecurrentState::new( + self.model.device_ctx(), + self.model.config(), + self.model.geometry, + ) + }) .collect::>()?; let mut recurrent_refs: Vec<&mut RecurrentState> = recurrent_states.iter_mut().collect(); let logits = diff --git a/pegainfer-qwen35/src/prefill.rs b/pegainfer-qwen35/src/prefill.rs index 6b65d9459..043dfa4dc 100644 --- a/pegainfer-qwen35/src/prefill.rs +++ b/pegainfer-qwen35/src/prefill.rs @@ -170,7 +170,8 @@ impl Qwen35Model { // Allocate the chunk scratch before advancing the KV state. It is the // largest, most allocation-prone buffer here, so failing first leaves // `kv_state` untouched and the request can be rejected cleanly. - let mut gdr_chunkwise_scratch = GdrChunkwiseScratch35::new(&self.ctx, c, seq_len)?; + let mut gdr_chunkwise_scratch = + GdrChunkwiseScratch35::new(&self.ctx, c, self.geometry, seq_len)?; // Advance paged KV state and build this chunk's prefill plan. kv_state.ensure_capacity(end_pos)?; @@ -240,7 +241,7 @@ impl Qwen35Model { let geom = self.geometry; let attn_out_dim = match &layer.attn { LayerKind::FullAttention(_) => geom.local_full_attn_q_dim(), - LayerKind::LinearAttention(_) => c.linear_attn_z_dim(), + LayerKind::LinearAttention(_) => geom.local_linear_z_dim(), }; // Batch project, then per-token attention/recurrent @@ -435,6 +436,8 @@ impl Qwen35Model { Ok(projected) } + /// `out_proj` is column-sharded, so its partial hidden sum is the one + /// linear-attention output all-reduced under TP (no-op at world_size 1). fn prefill_linear_attention( &self, attn: &LinearAttentionLayer, @@ -445,6 +448,7 @@ impl Qwen35Model { seq_len: usize, ) -> Result { let c = &self.config; + let geom = self.geometry; // Batch projections let qkv_batch = ops::gemm(&self.ctx, &attn.in_proj_qkv, normed_batch)?; @@ -452,8 +456,8 @@ impl Qwen35Model { let b_batch = ops::gemm(&self.ctx, &attn.in_proj_b, normed_batch)?; let a_batch = ops::gemm(&self.ctx, &attn.in_proj_a, normed_batch)?; - let qkv_dim = c.linear_attn_qkv_dim(); - let z_dim = c.linear_attn_z_dim(); + let qkv_dim = geom.local_linear_qkv_dim(); + let z_dim = geom.local_linear_z_dim(); let layer_state = &mut recurrent.layers[*linear_idx]; let mut qkv_conv_batch = HiddenStates::zeros(&self.ctx, qkv_dim, seq_len)?; @@ -477,8 +481,8 @@ impl Qwen35Model { &mut layer_state.state, gdr_chunkwise_scratch, &mut gdr_out_batch, - c.linear_num_key_heads, - c.linear_num_value_heads, + geom.local_linear_num_key_heads(), + geom.local_linear_num_value_heads(), c.linear_key_head_dim, c.linear_value_head_dim, )?; @@ -490,15 +494,17 @@ impl Qwen35Model { &attn.norm_weight, &z_batch, &mut normed_out_batch, - c.linear_num_value_heads, + geom.local_linear_num_value_heads(), c.linear_value_head_dim, c.rms_norm_eps, ); *linear_idx += 1; - // Output projection (batched) - ops::gemm(&self.ctx, &attn.out_proj, &normed_out_batch) + // Output projection (batched), then all-reduce the partial hidden sum. + let mut projected = ops::gemm(&self.ctx, &attn.out_proj, &normed_out_batch)?; + self.all_reduce_hidden(&mut projected)?; + Ok(projected) } fn batched_rms_norm_offset( diff --git a/pegainfer-qwen35/src/prefill_buffers.rs b/pegainfer-qwen35/src/prefill_buffers.rs index 92b38eb8a..b4f4b023c 100644 --- a/pegainfer-qwen35/src/prefill_buffers.rs +++ b/pegainfer-qwen35/src/prefill_buffers.rs @@ -7,6 +7,7 @@ use pegainfer_core::tensor::DeviceContext; use pegainfer_core::tensor::HiddenStates; use super::config::Config35; +use super::config::LocalGeometry; /// Scratch buffers for a single Qwen3.5 linear-attention chunk-wise GDR prefill call. /// @@ -50,10 +51,16 @@ pub struct GdrChunkwiseScratch35 { impl GdrChunkwiseScratch35 { pub(crate) const CHUNK_SIZE: usize = 64; - pub(crate) fn new(ctx: &DeviceContext, config: &Config35, seq_len: usize) -> Result { + pub(crate) fn new( + ctx: &DeviceContext, + config: &Config35, + geometry: LocalGeometry, + seq_len: usize, + ) -> Result { + // GDR scratch sizes follow the rank's local value-head geometry. Self::from_dims( ctx, - config.linear_num_value_heads, + geometry.local_linear_num_value_heads(), config.linear_key_head_dim, config.linear_value_head_dim, seq_len, @@ -120,8 +127,12 @@ impl GdrChunkwiseScratch35 { /// /// Direct-paged prefill writes full-attention K/V into the paged pool, so /// HND KVCache staging buffers are no longer part of the prefill scratch. - pub(crate) fn estimate_bytes(config: &Config35, max_seq_len: usize) -> usize { - let num_vh = config.linear_num_value_heads; + pub(crate) fn estimate_bytes( + config: &Config35, + geometry: LocalGeometry, + max_seq_len: usize, + ) -> usize { + let num_vh = geometry.local_linear_num_value_heads(); let key_dim = config.linear_key_head_dim; let val_dim = config.linear_value_head_dim; let chunk_sz = Self::CHUNK_SIZE; @@ -150,15 +161,15 @@ impl GdrChunkwiseScratch35 { // 2. Per-layer transient peak (all bf16 = 2 bytes). // Attention and MLP temps don't coexist — MLP runs after attention. let hidden_dim = config.hidden_size; - let intermediate = config.intermediate_size; + let intermediate = geometry.local_intermediate_size(); // Shared: hidden_batch + normed + hidden_plus_attn + normed_for_mlp let shared_layer = hidden_dim * seq * 4; // Full attention: q_full(with gate) + k + v + attn_out + q_prepped - let full_qkv = config.num_attention_heads * config.head_dim * 2; - let full_kv = config.num_key_value_heads * config.head_dim; - let full_out = config.num_attention_heads * config.head_dim; + let full_qkv = geometry.local_full_attn_gated_q_dim(); + let full_kv = geometry.local_full_attn_kv_dim(); + let full_out = geometry.local_full_attn_q_dim(); let full_attn_temps = (full_qkv + full_kv * 2 + full_out * 2) * seq; // MLP: gate_up_out + act_out (same peak footprint as separate gate/up) diff --git a/pegainfer-qwen35/src/recurrent_state.rs b/pegainfer-qwen35/src/recurrent_state.rs index f7279ec12..d35344343 100644 --- a/pegainfer-qwen35/src/recurrent_state.rs +++ b/pegainfer-qwen35/src/recurrent_state.rs @@ -1,8 +1,12 @@ //! Recurrent state for Qwen3.5 linear attention layers. //! //! Each linear attention layer maintains: -//! - Recurrent state: [num_value_heads, key_head_dim, value_head_dim] f32, V contiguous ([H,K,V]) -//! - Conv state: [qkv_dim × (conv_kernel_dim - 1)] bf16 +//! - Recurrent state: [local_value_heads, key_head_dim, value_head_dim] f32, V contiguous ([H,K,V]) +//! - Conv state: [local_qkv_dim × (conv_kernel_dim - 1)] bf16 +//! +//! Under TP, value heads (and fused qkv channels) are sharded across ranks, +//! so every rank owns its own recurrent/conv state; these states are never +//! all-reduced. use anyhow::Result; use cudarc::driver::CudaSlice; @@ -11,13 +15,14 @@ use pegainfer_core::tensor::DeviceContext; use pegainfer_core::tensor::DeviceVec; use super::config::Config35; +use super::config::LocalGeometry; /// Per-layer recurrent state for a single linear attention layer. pub(crate) struct LayerRecurrentState { - /// Recurrent state matrix: [num_value_heads * key_head_dim * value_head_dim] f32 + /// Recurrent state matrix: [local_value_heads * key_head_dim * value_head_dim] f32 /// Stored as f32 per mamba_ssm_dtype="float32" in config. pub(crate) state: CudaSlice, - /// Conv1d state buffer: [qkv_dim * (conv_kernel_dim - 1)] bf16 + /// Conv1d state buffer: [local_linear_qkv_dim * (conv_kernel_dim - 1)] bf16 /// Stores the last (kernel_dim - 1) inputs for causal conv1d. pub(crate) conv_state: DeviceVec, } @@ -43,18 +48,23 @@ pub(crate) struct LinearStatePointerTables { /// Per-layer element counts shared by allocation and reservation: /// (linear layers, f32 state elements, bf16 conv elements). -fn per_layer_dims(config: &Config35) -> (usize, usize, usize) { +fn per_layer_dims(config: &Config35, geometry: LocalGeometry) -> (usize, usize, usize) { let num_linear_layers = config.num_hidden_layers - config.num_full_attention_layers(); - let state_size = - config.linear_num_value_heads * config.linear_key_head_dim * config.linear_value_head_dim; - let conv_state_size = config.linear_attn_qkv_dim() * (config.linear_conv_kernel_dim - 1); + let state_size = geometry.local_linear_num_value_heads() + * config.linear_key_head_dim + * config.linear_value_head_dim; + let conv_state_size = geometry.local_linear_qkv_dim() * (config.linear_conv_kernel_dim - 1); (num_linear_layers, state_size, conv_state_size) } impl RecurrentState { /// Allocate zeroed recurrent state for all linear attention layers. - pub(crate) fn new(ctx: &DeviceContext, config: &Config35) -> Result { - let (num_linear_layers, state_size, conv_state_size) = per_layer_dims(config); + pub(crate) fn new( + ctx: &DeviceContext, + config: &Config35, + geometry: LocalGeometry, + ) -> Result { + let (num_linear_layers, state_size, conv_state_size) = per_layer_dims(config, geometry); let mut layers = Vec::with_capacity(num_linear_layers); for _ in 0..num_linear_layers { @@ -145,16 +155,16 @@ impl LinearStatePointerTables { } /// Device bytes of one request's recurrent state. -pub(crate) fn bytes_per_request(config: &Config35) -> usize { - let (num_linear_layers, state_size, conv_state_size) = per_layer_dims(config); +pub(crate) fn bytes_per_request(config: &Config35, geometry: LocalGeometry) -> usize { + let (num_linear_layers, state_size, conv_state_size) = per_layer_dims(config, geometry); num_linear_layers * (state_size * std::mem::size_of::() + conv_state_size * std::mem::size_of::()) } impl RecurrentState { - pub(crate) fn allocation_bytes(config: &Config35) -> usize { - bytes_per_request(config) + pub(crate) fn allocation_bytes(config: &Config35, geometry: LocalGeometry) -> usize { + bytes_per_request(config, geometry) } } diff --git a/pegainfer-qwen35/src/scheduler/backend.rs b/pegainfer-qwen35/src/scheduler/backend.rs index 2224378e6..819f1cfef 100644 --- a/pegainfer-qwen35/src/scheduler/backend.rs +++ b/pegainfer-qwen35/src/scheduler/backend.rs @@ -179,7 +179,11 @@ impl SingleGpuBackend { } pub(super) fn alloc_recurrent(&self) -> Result { - RecurrentState::new(self.model.device_ctx(), self.model.config()) + RecurrentState::new( + self.model.device_ctx(), + self.model.config(), + self.model.geometry, + ) } pub(super) fn batch_prefill_logits(&self, chunk: &mut ScheduledChunk) -> Result { diff --git a/pegainfer-qwen35/src/tp_executor.rs b/pegainfer-qwen35/src/tp_executor.rs index e35a05d36..16d245b3a 100644 --- a/pegainfer-qwen35/src/tp_executor.rs +++ b/pegainfer-qwen35/src/tp_executor.rs @@ -1,7 +1,7 @@ //! Tensor-parallel worker runtime for Qwen3.5. //! -//! Phase 2A adds one canonical eager unified command while retaining the -//! replicated linear-attention state layout from Phase 1. +//! One canonical eager unified command per step. Linear-attention/GDR weights +//! and state are sharded per rank. use std::collections::HashSet; use std::panic::AssertUnwindSafe; @@ -1037,10 +1037,15 @@ impl TpWorkerPrepared { .ctx .mem_get_info() .map_err(|err| anyhow::anyhow!("failed to query TP rank {rank} memory: {err}"))?; - let recurrent_bytes = RecurrentState::allocation_bytes(model.config()); + // Recurrent state is rank-local, so worker capacity math uses the + // local value-head/qkv sizes. + let recurrent_bytes = RecurrentState::allocation_bytes(model.config(), model.geometry); let prefill_scratch_tokens = prefill_scratch_tokens(max_prefill_tokens); - let prefill_scratch_bytes = - GdrChunkwiseScratch35::estimate_bytes(model.config(), prefill_scratch_tokens); + let prefill_scratch_bytes = GdrChunkwiseScratch35::estimate_bytes( + model.config(), + model.geometry, + prefill_scratch_tokens, + ); let max_batch = effective_recurrent_capacity( requested_max_batch, free_bytes, @@ -1480,7 +1485,11 @@ impl TpWorkerState { if let Some(idx) = self.request_index(request_id) { return Ok(idx); } - let mut recurrent = RecurrentState::new(self.model.device_ctx(), self.model.config())?; + let mut recurrent = RecurrentState::new( + self.model.device_ctx(), + self.model.config(), + self.model.geometry, + )?; let linear_pointer_tables = { let mut recurrent_refs = [&mut recurrent]; LinearStatePointerTables::from_recurrent_refs( diff --git a/pegainfer-qwen35/src/unified_forward.rs b/pegainfer-qwen35/src/unified_forward.rs index 6526225bc..32f216672 100644 --- a/pegainfer-qwen35/src/unified_forward.rs +++ b/pegainfer-qwen35/src/unified_forward.rs @@ -174,8 +174,8 @@ mod tests { let prompts_ref: Vec<&[u32]> = vec![&prompt_a, &prompt_b]; let mut kv_states: Vec = vec![model.alloc_kv(), model.alloc_kv()]; let mut rec_states: Vec = vec![ - RecurrentState::new(&model.ctx, &model.config).unwrap(), - RecurrentState::new(&model.ctx, &model.config).unwrap(), + RecurrentState::new(&model.ctx, &model.config, model.geometry).unwrap(), + RecurrentState::new(&model.ctx, &model.config, model.geometry).unwrap(), ]; let mut rec_refs: Vec<&mut RecurrentState> = rec_states.iter_mut().collect(); let first_logits = model @@ -213,8 +213,8 @@ mod tests { let prompts_ref: Vec<&[u32]> = vec![&prompt_a, &prompt_b]; let mut kv_states: Vec = vec![model.alloc_kv(), model.alloc_kv()]; let mut rec_states: Vec = vec![ - RecurrentState::new(&model.ctx, &model.config).unwrap(), - RecurrentState::new(&model.ctx, &model.config).unwrap(), + RecurrentState::new(&model.ctx, &model.config, model.geometry).unwrap(), + RecurrentState::new(&model.ctx, &model.config, model.geometry).unwrap(), ]; let mut rec_refs: Vec<&mut RecurrentState> = rec_states.iter_mut().collect(); diff --git a/pegainfer-qwen35/src/weights.rs b/pegainfer-qwen35/src/weights.rs index ada49b72f..214e9442d 100644 --- a/pegainfer-qwen35/src/weights.rs +++ b/pegainfer-qwen35/src/weights.rs @@ -269,10 +269,14 @@ impl Qwen35Model { // Reserve space for prefill scratch (GDR chunkwise + per-layer transients) // before allocating KV pool, so prefill doesn't OOM. let max_prefill_len = super::prefill::SCRATCH_ESTIMATE_SEQ; - let scratch_reserve = - super::prefill_buffers::GdrChunkwiseScratch35::estimate_bytes(&config, max_prefill_len); - let recurrent_reserve = - STATES_PER_DECODE_SLOT * max_batch * super::recurrent_state::bytes_per_request(&config); + let scratch_reserve = super::prefill_buffers::GdrChunkwiseScratch35::estimate_bytes( + &config, + geometry, + max_prefill_len, + ); + let recurrent_reserve = STATES_PER_DECODE_SLOT + * max_batch + * super::recurrent_state::bytes_per_request(&config, geometry); let min_kv_bytes = MIN_KV_PAGES * bytes_per_page; anyhow::ensure!( free_bytes >= scratch_reserve + recurrent_reserve + min_kv_bytes, @@ -380,9 +384,9 @@ impl Qwen35Model { let geom = self.geometry; let full_q = geom.local_full_attn_gated_q_dim(); let full_kv = geom.local_full_attn_kv_dim(); - let linear_qkv = self.config.linear_attn_qkv_dim(); - let linear_z = self.config.linear_attn_z_dim(); - let linear_ba = self.config.linear_num_value_heads; + let linear_qkv = geom.local_linear_qkv_dim(); + let linear_z = geom.local_linear_z_dim(); + let linear_ba = geom.local_linear_num_value_heads(); let intermediate = geom.local_intermediate_size(); let full_attn = || { diff --git a/pegainfer-qwen35/src/weights/layers.rs b/pegainfer-qwen35/src/weights/layers.rs index bbee3eae9..fd593956f 100644 --- a/pegainfer-qwen35/src/weights/layers.rs +++ b/pegainfer-qwen35/src/weights/layers.rs @@ -3,9 +3,13 @@ use pegainfer_core::weight_loader::load_tensor_1d; use pegainfer_core::weight_loader::load_tensor_1d_f32; +use pegainfer_core::weight_loader::load_tensor_1d_f32_shard; +use pegainfer_core::weight_loader::load_tensor_1d_shard; +use pegainfer_core::weight_loader::load_tensor_1d_stitch; use pegainfer_core::weight_loader::load_tensor_2d; use pegainfer_core::weight_loader::load_tensor_2d_col_shard; use pegainfer_core::weight_loader::load_tensor_2d_row_shard; +use pegainfer_core::weight_loader::load_tensor_2d_row_stitch; use super::*; @@ -26,23 +30,28 @@ pub(crate) struct FullAttentionLayer { /// Linear attention layer weights (24 layers in Qwen3.5-4B). pub(crate) struct LinearAttentionLayer { - /// Fused QKV projection: [q_dim + k_dim + v_dim, hidden_size] + /// Fused QKV projection: [local_linear_qkv_dim, hidden_size] — rows keep + /// the global [q | k | v] segment layout, with each segment restricted to + /// this rank's head-local slice (see `linear_qkv_shard_segments`). pub(crate) in_proj_qkv: DeviceMatrix, - /// Z projection (for output gating): [z_dim, hidden_size] + /// Z projection (for output gating): [local_linear_z_dim, hidden_size] pub(crate) in_proj_z: DeviceMatrix, - /// Beta projection: [num_value_heads, hidden_size] + /// Beta projection: [local_linear_num_value_heads, hidden_size] pub(crate) in_proj_b: DeviceMatrix, - /// Alpha projection: [num_value_heads, hidden_size] + /// Alpha projection: [local_linear_num_value_heads, hidden_size] pub(crate) in_proj_a: DeviceMatrix, - /// Depthwise conv1d weight: [qkv_dim * conv_kernel_dim] (flattened from [qkv_dim, 1, 4]) + /// Depthwise conv1d weight: [local_linear_qkv_dim * conv_kernel_dim] + /// (flattened from [qkv_dim, 1, 4]); channel layout mirrors in_proj_qkv. pub(crate) conv1d_weight: DeviceVec, - /// dt_bias: [num_value_heads] bf16 + /// dt_bias: [local_linear_num_value_heads] bf16 pub(crate) dt_bias: DeviceVec, - /// A_log: [num_value_heads] f32 + /// A_log: [local_linear_num_value_heads] f32 pub(crate) a_log: CudaSlice, - /// RMSNorm weight for output normalization: [value_head_dim] f32 + /// RMSNorm weight for output normalization: [value_head_dim] f32 — + /// head-shared, so replicated on every rank. pub(crate) norm_weight: CudaSlice, - /// Output projection: [hidden_size, z_dim] + /// Output projection: [hidden_size, local_linear_z_dim] (row-parallel; + /// the layer all-reduces the partial hidden sum under TP). pub(crate) out_proj: DeviceMatrix, } @@ -138,17 +147,36 @@ impl FullAttentionLayer { } impl LinearAttentionLayer { + /// Phase 2b: shard linear attention over TP ranks. The value-head unit + /// drives z/b/a/dt_bias/A_log rows; the fused qkv weight and its conv need + /// per-segment head-local stitching. fn load(src: &WeightSource, prefix: &str) -> Result { Ok(Self { - in_proj_qkv: src.tensor_2d(&format!("{prefix}.in_proj_qkv.weight"))?, - in_proj_z: src.tensor_2d(&format!("{prefix}.in_proj_z.weight"))?, - in_proj_b: src.tensor_2d(&format!("{prefix}.in_proj_b.weight"))?, - in_proj_a: src.tensor_2d(&format!("{prefix}.in_proj_a.weight"))?, - conv1d_weight: src.tensor_1d(&format!("{prefix}.conv1d.weight"))?, - dt_bias: src.tensor_1d(&format!("{prefix}.dt_bias"))?, - a_log: src.tensor_1d_f32(&format!("{prefix}.A_log"))?, + in_proj_qkv: src.linear_in_proj_qkv(&format!("{prefix}.in_proj_qkv.weight"))?, + in_proj_z: src + .row_shard_if_needed(&format!("{prefix}.in_proj_z.weight"), src.linear_z)?, + in_proj_b: src.row_shard_if_needed( + &format!("{prefix}.in_proj_b.weight"), + src.linear_value_heads, + )?, + in_proj_a: src.row_shard_if_needed( + &format!("{prefix}.in_proj_a.weight"), + src.linear_value_heads, + )?, + conv1d_weight: src.linear_conv1d(&format!("{prefix}.conv1d.weight"))?, + dt_bias: src + .tensor_1d_shard_if_needed(&format!("{prefix}.dt_bias"), src.linear_value_heads)?, + a_log: src.tensor_1d_f32_shard_if_needed( + &format!("{prefix}.A_log"), + src.linear_value_heads, + )?, + // Gated RMSNorm weight is per value-head dim (128) and shared by + // every head: replicated, never sharded. norm_weight: src.tensor_1d_f32(&format!("{prefix}.norm.weight"))?, - out_proj: src.tensor_2d(&format!("{prefix}.out_proj.weight"))?, + // Row-parallel out_proj: shard input columns to the local z dim; + // the layer all-reduces the partial sum. + out_proj: src + .col_shard_if_needed(&format!("{prefix}.out_proj.weight"), src.linear_z)?, }) } } @@ -169,6 +197,14 @@ pub(super) struct WeightSource<'a> { kv_rows: (usize, usize), /// MLP intermediate row (gate/up) and column (down) shard. intermediate: (usize, usize), + /// Linear-attention value-head unit: in_proj_b/a rows, dt_bias, A_log. + linear_value_heads: (usize, usize), + /// Linear-attention z dim: in_proj_z rows and out_proj columns. + linear_z: (usize, usize), + /// Per-segment row slices inside the fused linear qkv projection. + linear_qkv: [(usize, usize); 3], + /// The same slices in conv1d channel-tap units. + linear_conv1d: [(usize, usize); 3], } impl<'a> WeightSource<'a> { @@ -188,6 +224,10 @@ impl<'a> WeightSource<'a> { q_cols: geometry.shard_range(config.full_attn_q_dim()), kv_rows: geometry.shard_range(config.full_attn_kv_dim()), intermediate: geometry.shard_range(config.intermediate_size), + linear_value_heads: geometry.shard_range(config.linear_num_value_heads), + linear_z: geometry.shard_range(config.linear_attn_z_dim()), + linear_qkv: linear_qkv_shard_segments(config, geometry), + linear_conv1d: linear_conv1d_shard_segments(config, geometry), } } @@ -241,6 +281,60 @@ impl<'a> WeightSource<'a> { } } + fn tensor_1d_shard_if_needed( + &self, + name: &str, + (offset, len): (usize, usize), + ) -> Result { + if self.geometry.is_sharded() { + load_tensor_1d_shard(self.ctx, self.shards, self.weight_map, name, offset, len) + } else { + self.tensor_1d(name) + } + } + + fn tensor_1d_f32_shard_if_needed( + &self, + name: &str, + (offset, len): (usize, usize), + ) -> Result> { + if self.geometry.is_sharded() { + load_tensor_1d_f32_shard(self.ctx, self.shards, self.weight_map, name, offset, len) + } else { + self.tensor_1d_f32(name) + } + } + + /// Fused linear qkv: stitch this rank's head-local slice out of each of the + /// three global segments rather than cutting one flat row range. + fn linear_in_proj_qkv(&self, name: &str) -> Result { + if !self.geometry.is_sharded() { + return self.tensor_2d(name); + } + load_tensor_2d_row_stitch( + self.ctx, + self.shards, + self.weight_map, + name, + &self.linear_qkv, + ) + } + + /// conv1d channels mirror the fused qkv rows, so they stitch with the same + /// segments scaled into kernel-tap units. + fn linear_conv1d(&self, name: &str) -> Result { + if !self.geometry.is_sharded() { + return self.tensor_1d(name); + } + load_tensor_1d_stitch( + self.ctx, + self.shards, + self.weight_map, + name, + &self.linear_conv1d, + ) + } + /// Q projection carries a per-head output gate, so its rows shard per head /// (keeping each head's [q, gate] chunk adjacent), not as one flat range. fn gated_q_proj(&self, name: &str) -> Result { @@ -259,6 +353,34 @@ impl<'a> WeightSource<'a> { } } +/// Row ranges this rank owns inside the fused global linear-attention qkv +/// projection. The checkpoint stores [all q rows | all k rows | all v rows]; +/// each segment contributes its head-local slice so the rank's stitched rows +/// stay [q_local | k_local | v_local]. Never reblock across segments — q rows +/// key on key heads, v rows on value heads (the gated-q lesson). +fn linear_qkv_shard_segments(config: &Config35, geometry: LocalGeometry) -> [(usize, usize); 3] { + let global_q = config.linear_num_key_heads * config.linear_key_head_dim; + let global_k = global_q; + let global_v = config.linear_attn_z_dim(); + let (q_rel, q_rows) = geometry.shard_range(global_q); + let (k_rel, k_rows) = geometry.shard_range(global_k); + let (v_rel, v_rows) = geometry.shard_range(global_v); + [ + (q_rel, q_rows), + (global_q + k_rel, k_rows), + (global_q + global_k + v_rel, v_rows), + ] +} + +/// The flattened conv1d weight keeps each channel's kernel taps contiguous +/// ([channel, 1, kernel_dim]); its channel layout mirrors the fused qkv rows, +/// so shard it with the same per-segment ranges scaled by the kernel dim. +fn linear_conv1d_shard_segments(config: &Config35, geometry: LocalGeometry) -> [(usize, usize); 3] { + let kernel_dim = config.linear_conv_kernel_dim; + linear_qkv_shard_segments(config, geometry) + .map(|(offset, len)| (offset * kernel_dim, len * kernel_dim)) +} + /// HF/PegaInfer kernels interpret q_proj rows as per-head [q, gate] chunks. /// Keep each local head's q rows adjacent to its gate rows. fn full_attention_gated_q_shard_range( @@ -314,6 +436,32 @@ mod tests { LocalGeometry::try_new(&config, tp, false).unwrap() } + #[test] + fn linear_qkv_shard_segments_stitch_head_local_slices() { + // test_config: k heads 16, v heads 32, head dim 128 → q=k=2048, v=4096. + let config = test_config(); + let rank0 = linear_qkv_shard_segments(&config, test_geometry(0, 2)); + assert_eq!(rank0, [(0, 1024), (2048, 1024), (4096, 2048)]); + + let rank1 = linear_qkv_shard_segments(&config, test_geometry(1, 2)); + assert_eq!(rank1, [(1024, 1024), (3072, 1024), (6144, 2048)]); + + // Every rank's stitched rows tile [0, qkv) with no overlap: each + // segment's local slices across ranks are contiguous and complete. + for (r0, r1) in rank0.iter().zip(rank1.iter()) { + assert_eq!(r0.1, r1.1); + assert_eq!(r1.0, r0.0 + r0.1); + } + } + + #[test] + fn linear_conv1d_shard_segments_scale_by_kernel_dim() { + let config = test_config(); + let rank1 = linear_conv1d_shard_segments(&config, test_geometry(1, 2)); + // conv1d.weight is [qkv * 4]: same ranges as qkv, scaled by 4. + assert_eq!(rank1, [(4096, 4096), (12288, 4096), (24576, 8192)]); + } + #[test] fn gated_q_shard_range_keeps_matching_q_and_gate_rows() { let config = test_config();