diff --git a/docs/index.md b/docs/index.md index 63a5950b1..2aae4671f 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 6c6a263c3..1e502a658 100644 --- a/pegainfer-core/src/weight_loader.rs +++ b/pegainfer-core/src/weight_loader.rs @@ -371,6 +371,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> { @@ -783,29 +826,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) } @@ -923,6 +1068,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..204a3e080 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 - ) - })?; + "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..7a8b39347 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,51 @@ 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(); + 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, + } + ); + } + + #[test] + fn computes_tp2_linear_attention_local_dimensions() { + let cfg = config(); let tp = TensorParallelConfig::try_from((1, 2)).unwrap(); - LocalGeometry::try_new(&cfg, tp, false).unwrap(); + let geom = LocalGeometry::try_new(&cfg, tp, false).unwrap(); + assert_eq!(geom.local_linear_num_key_heads(), 8); + assert_eq!(geom.local_linear_num_value_heads(), 16); + // q/k rows derive as key_heads * key_head_dim; qkv = 2q + v, z = v. + assert_eq!(geom.local_linear_qkv_dim(), 4096); + assert_eq!(geom.local_linear_z_dim(), 2048); + assert_eq!( + (geom.local_linear_qkv_dim() - geom.local_linear_z_dim()) / 2, + 8 * cfg.linear_key_head_dim + ); + } + + #[test] + fn tp1_linear_attention_local_dimensions_equal_global() { + // TP1 invariant: every local dim equals the global dim, keeping TP1 + // numerics byte-identical to pre-sharding execution. + let cfg = config(); + let geom = LocalGeometry::try_new(&cfg, TensorParallelConfig::default(), false).unwrap(); + assert_eq!(geom.local_linear_num_key_heads(), 16); + assert_eq!(geom.local_linear_num_value_heads(), 32); + let global_qkv = + 2 * (cfg.linear_num_key_heads * cfg.linear_key_head_dim) + cfg.linear_attn_z_dim(); + assert_eq!(geom.local_linear_qkv_dim(), global_qkv); + assert_eq!(geom.local_linear_z_dim(), cfg.linear_attn_z_dim()); } } 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.rs b/pegainfer-qwen35/src/scheduler.rs index c2552c23c..bf4259c47 100644 --- a/pegainfer-qwen35/src/scheduler.rs +++ b/pegainfer-qwen35/src/scheduler.rs @@ -592,7 +592,11 @@ impl SingleGpuBackend { } 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, + ) } 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..568c98edc 100644 --- a/pegainfer-qwen35/src/tp_executor.rs +++ b/pegainfer-qwen35/src/tp_executor.rs @@ -1,7 +1,8 @@ //! 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, and decode rows run as one batched forward +//! plus one batched rank-0 sampling pass instead of a per-request bs=1 loop. use std::collections::HashSet; use std::panic::AssertUnwindSafe; @@ -1006,7 +1007,6 @@ struct TpRequestState { phase: TpRequestPhase, kv: KvState, recurrent: RecurrentState, - linear_pointer_tables: LinearStatePointerTables, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -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, @@ -1336,6 +1341,133 @@ impl TpWorkerState { Ok(primary_results) } + /// Run one batched eager decode step over all rows in command order: a + /// single forward for the whole batch on every rank, then (rank 0 only) + /// one batched sampling pass over the per-row sampling params. + /// + /// Seeded rows keep their former per-row semantics: they pass step 0 and + /// `select_batch` isolates each seeded row into its own single-row philox + /// call keyed on (request seed, step), so seeded output stays independent + /// of batch composition. Unseeded rows decorrelate inside the batched + /// call through the per-step command seed, same as the single-GPU batched + /// path. Returns one result row per request in command order on rank 0, + /// empty elsewhere. + fn run_decode_batch( + &mut self, + requests: &[TpDecodeStepItem], + sample_seed: u64, + ) -> Result> { + let bs = requests.len(); + if bs == 0 { + return Ok(Vec::new()); + } + + // Resolve the worker state slot of every row in command order. + // Decode request ids are unique within one command + // (validate_decode_requests), so each slot is borrowed at most once. + let mut row_of_state: Vec> = vec![None; self.requests.len()]; + for (row, request) in requests.iter().enumerate() { + let state_idx = self.request_index(request.request_id).ok_or_else(|| { + anyhow::anyhow!( + "Qwen3.5 TP decode request {} has no worker state", + request.request_id.get() + ) + })?; + anyhow::ensure!( + self.requests[state_idx].phase == TpRequestPhase::Decoding, + "Qwen3.5 TP request {} is not ready for decode", + request.request_id.get() + ); + debug_assert!(row_of_state[state_idx].is_none()); + row_of_state[state_idx] = Some(row); + } + let mut kv_slots: Vec> = + std::iter::repeat_with(|| None).take(bs).collect(); + let mut recurrent_slots: Vec> = + std::iter::repeat_with(|| None).take(bs).collect(); + for (state_idx, state) in self.requests.iter_mut().enumerate() { + if let Some(row) = row_of_state[state_idx] { + kv_slots[row] = Some(&mut state.kv); + recurrent_slots[row] = Some(&mut state.recurrent); + } + } + let mut kv_refs: Vec<&mut KvState> = Vec::with_capacity(bs); + let mut recurrent_refs: Vec<&mut RecurrentState> = Vec::with_capacity(bs); + for (kv, recurrent) in kv_slots.into_iter().zip(recurrent_slots) { + kv_refs.push(kv.expect("decode row state resolved above")); + recurrent_refs.push(recurrent.expect("decode row state resolved above")); + } + + // Step-scoped GDR pointer tables over the full decode batch. They are + // rebuilt every step, so swap_remove retirement between steps can + // never leave a stale table behind. + let linear_pointer_tables = LinearStatePointerTables::from_recurrent_refs( + self.model.device_ctx(), + self.model.config(), + &mut recurrent_refs, + bs, + "Qwen3.5 TP eager decode", + )?; + let token_ids: Vec = requests.iter().map(|request| request.token_id).collect(); + self.model.batch_decode_eager_logits( + &token_ids, + &mut kv_refs, + &mut recurrent_refs, + &linear_pointer_tables, + &mut self.decode_buffers, + )?; + + if self.rank != 0 { + return Ok(Vec::new()); + } + + // Snapshot requested logits rows BEFORE sampling: the sampler may + // modify bufs.logits in place. + let requested_logprobs: Vec = + requests.iter().map(|request| request.logprobs).collect(); + let cpu_logits = snapshot_requested_logprobs( + self.model.device_ctx(), + &self.decode_buffers.logits, + &requested_logprobs, + )?; + let params_refs: Vec<&SamplingParams> = requests + .iter() + .map(|request| &request.sampling_params) + .collect(); + let steps = vec![0u64; bs]; + let tokens = pegainfer_sample::select_batch( + self.model.device_ctx(), + &self.decode_buffers.logits, + ¶ms_refs, + &steps, + sample_seed, + &mut self.sample_scratch, + )?; + anyhow::ensure!( + tokens.len() == bs, + "Qwen3.5 TP decode sampling returned {} tokens for {bs} rows", + tokens.len() + ); + Ok(requests + .iter() + .enumerate() + .map(|(row, request)| { + let logprob = cpu_logits[row].as_ref().and_then(|logits_row| { + pegainfer_sample::token_logprob_from_row( + logits_row, + tokens[row], + request.logprobs, + ) + }); + DecodeRequestResult { + request_id: request.request_id, + token: tokens[row], + logprob, + } + }) + .collect()) + } + fn sample_final_prefill_chunk( &mut self, chunk: &TpPrefillChunkItem, @@ -1394,60 +1526,7 @@ impl TpWorkerState { self.max_batch ); - let mut primary_results = - Vec::with_capacity(if self.rank == 0 { requests.len() } else { 0 }); - for (row_idx, request) in requests.iter().enumerate() { - let state_idx = self.request_index(request.request_id).ok_or_else(|| { - anyhow::anyhow!( - "Qwen3.5 TP decode request {} has no worker state", - request.request_id.get() - ) - })?; - anyhow::ensure!( - self.requests[state_idx].phase == TpRequestPhase::Decoding, - "Qwen3.5 TP request {} is not ready for decode", - request.request_id.get() - ); - - { - let state = &mut self.requests[state_idx]; - let mut kv_refs = [&mut state.kv]; - let mut recurrent_refs = [&mut state.recurrent]; - self.model.batch_decode_eager_logits( - &[request.token_id], - &mut kv_refs, - &mut recurrent_refs, - &state.linear_pointer_tables, - &mut self.decode_buffers, - )?; - } - - if self.rank == 0 { - let cpu_logits = snapshot_requested_logprobs( - self.model.device_ctx(), - &self.decode_buffers.logits, - &[request.logprobs], - )?; - let params_refs = [&request.sampling_params]; - let tokens = pegainfer_sample::select_batch( - self.model.device_ctx(), - &self.decode_buffers.logits, - ¶ms_refs, - &[0], - sample_seed.wrapping_add(row_idx as u64), - &mut self.sample_scratch, - )?; - let token = tokens[0]; - let logprob = cpu_logits[0].as_ref().and_then(|row| { - pegainfer_sample::token_logprob_from_row(row, token, request.logprobs) - }); - primary_results.push(DecodeRequestResult { - request_id: request.request_id, - token, - logprob, - }); - } - } + let primary_results = self.run_decode_batch(requests, sample_seed)?; Ok(primary_results) } @@ -1480,23 +1559,16 @@ 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 linear_pointer_tables = { - let mut recurrent_refs = [&mut recurrent]; - LinearStatePointerTables::from_recurrent_refs( - self.model.device_ctx(), - self.model.config(), - &mut recurrent_refs, - 1, - "Qwen3.5 TP eager", - )? - }; + let recurrent = RecurrentState::new( + self.model.device_ctx(), + self.model.config(), + self.model.geometry, + )?; let state = TpRequestState { request_id, phase: TpRequestPhase::Prefilling, kv: self.model.alloc_kv(), recurrent, - linear_pointer_tables, }; self.requests.push(state); Ok(self.requests.len() - 1) 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 70cb6bb80..ed90e411d 100644 --- a/pegainfer-qwen35/src/weights.rs +++ b/pegainfer-qwen35/src/weights.rs @@ -18,9 +18,13 @@ use pegainfer_core::weight_loader::deserialize_shards; use pegainfer_core::weight_loader::load_shard_info_fixed; 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 pegainfer_core::weight_loader::mmap_shards; use safetensors::SafeTensors; @@ -46,23 +50,28 @@ pub(super) struct FullAttentionLayer { /// Linear attention layer weights (24 layers in Qwen3.5-4B). pub(super) 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(super) 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(super) in_proj_z: DeviceMatrix, - /// Beta projection: [num_value_heads, hidden_size] + /// Beta projection: [local_linear_num_value_heads, hidden_size] pub(super) in_proj_b: DeviceMatrix, - /// Alpha projection: [num_value_heads, hidden_size] + /// Alpha projection: [local_linear_num_value_heads, hidden_size] pub(super) 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(super) conv1d_weight: DeviceVec, - /// dt_bias: [num_value_heads] bf16 + /// dt_bias: [local_linear_num_value_heads] bf16 pub(super) dt_bias: DeviceVec, - /// A_log: [num_value_heads] f32 + /// A_log: [local_linear_num_value_heads] f32 pub(super) 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(super) 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(super) out_proj: DeviceMatrix, } @@ -338,60 +347,110 @@ impl Qwen35Model { } LayerType::LinearAttention => { let attn_prefix = format!("{}.linear_attn", prefix); + // Phase 2b: shard linear attention over TP ranks. Value-head + // unit drives z/b/a/dt_bias/a_log rows; the fused qkv weight + // and conv need per-segment head-local stitching. + let (vh_offset, vh_rows) = + tensor_parallel.shard_range(config.linear_num_value_heads); + let (z_row_offset, z_rows) = + tensor_parallel.shard_range(config.linear_attn_z_dim()); + let (z_col_offset, z_cols) = (z_row_offset, z_rows); LayerKind::LinearAttention(LinearAttentionLayer { - in_proj_qkv: load_tensor_2d( + in_proj_qkv: load_linear_in_proj_qkv_shard( &ctx, &shards, &weight_map, &format!("{}.in_proj_qkv.weight", attn_prefix), + &config, + tensor_parallel, )?, - in_proj_z: load_tensor_2d( + in_proj_z: load_tensor_2d_row_shard_if_needed( &ctx, &shards, &weight_map, &format!("{}.in_proj_z.weight", attn_prefix), + geometry, + z_row_offset, + z_rows, )?, - in_proj_b: load_tensor_2d( + in_proj_b: load_tensor_2d_row_shard_if_needed( &ctx, &shards, &weight_map, &format!("{}.in_proj_b.weight", attn_prefix), + geometry, + vh_offset, + vh_rows, )?, - in_proj_a: load_tensor_2d( + in_proj_a: load_tensor_2d_row_shard_if_needed( &ctx, &shards, &weight_map, &format!("{}.in_proj_a.weight", attn_prefix), + geometry, + vh_offset, + vh_rows, )?, - conv1d_weight: load_tensor_1d( + conv1d_weight: load_linear_conv1d_shard( &ctx, &shards, &weight_map, &format!("{}.conv1d.weight", attn_prefix), + &config, + tensor_parallel, )?, - dt_bias: load_tensor_1d( - &ctx, - &shards, - &weight_map, - &format!("{}.dt_bias", attn_prefix), - )?, - a_log: load_tensor_1d_f32( - &ctx, - &shards, - &weight_map, - &format!("{}.A_log", attn_prefix), - )?, + dt_bias: if tensor_parallel.is_sharded() { + load_tensor_1d_shard( + &ctx, + &shards, + &weight_map, + &format!("{}.dt_bias", attn_prefix), + vh_offset, + vh_rows, + )? + } else { + load_tensor_1d( + &ctx, + &shards, + &weight_map, + &format!("{}.dt_bias", attn_prefix), + )? + }, + a_log: if tensor_parallel.is_sharded() { + load_tensor_1d_f32_shard( + &ctx, + &shards, + &weight_map, + &format!("{}.A_log", attn_prefix), + vh_offset, + vh_rows, + )? + } else { + load_tensor_1d_f32( + &ctx, + &shards, + &weight_map, + &format!("{}.A_log", attn_prefix), + )? + }, + // Gated RMSNorm weight is per value-head dim (128) and + // shared by every head: replicated, never sharded. norm_weight: load_tensor_1d_f32( &ctx, &shards, &weight_map, &format!("{}.norm.weight", attn_prefix), )?, - out_proj: load_tensor_2d( + // Row-parallel out_proj: shard input columns to the + // local z dim; the layer all-reduces the partial sum. + out_proj: load_tensor_2d_col_shard_if_needed( &ctx, &shards, &weight_map, &format!("{}.out_proj.weight", attn_prefix), + geometry, + z_col_offset, + z_cols, )?, }) } @@ -499,10 +558,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, @@ -610,9 +673,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_q_samples: Vec<_> = self @@ -802,6 +865,70 @@ fn load_full_attention_gated_q_proj( load_tensor_2d_row_shard(ctx, shards, weight_map, name, range.row_offset, range.rows) } +/// 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, + tensor_parallel: TensorParallelConfig, +) -> [(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) = tensor_parallel.shard_range(global_q); + let (k_rel, k_rows) = tensor_parallel.shard_range(global_k); + let (v_rel, v_rows) = tensor_parallel.shard_range(global_v); + [ + (q_rel, q_rows), + (global_q + k_rel, k_rows), + (global_q + global_k + v_rel, v_rows), + ] +} + +fn load_linear_in_proj_qkv_shard( + ctx: &DeviceContext, + shards: &[SafeTensors], + weight_map: &HashMap, + name: &str, + config: &Config35, + tensor_parallel: TensorParallelConfig, +) -> Result { + if !tensor_parallel.is_sharded() { + return load_tensor_2d(ctx, shards, weight_map, name); + } + let segments = linear_qkv_shard_segments(config, tensor_parallel); + load_tensor_2d_row_stitch(ctx, shards, weight_map, name, &segments) +} + +/// 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, + tensor_parallel: TensorParallelConfig, +) -> [(usize, usize); 3] { + let kernel_dim = config.linear_conv_kernel_dim; + linear_qkv_shard_segments(config, tensor_parallel) + .map(|(offset, len)| (offset * kernel_dim, len * kernel_dim)) +} + +fn load_linear_conv1d_shard( + ctx: &DeviceContext, + shards: &[SafeTensors], + weight_map: &HashMap, + name: &str, + config: &Config35, + tensor_parallel: TensorParallelConfig, +) -> Result { + if !tensor_parallel.is_sharded() { + return load_tensor_1d(ctx, shards, weight_map, name); + } + let segments = linear_conv1d_shard_segments(config, tensor_parallel); + load_tensor_1d_stitch(ctx, shards, weight_map, name, &segments) +} + fn load_tensor_2d_row_shard_if_needed( ctx: &DeviceContext, shards: &[SafeTensors], @@ -912,4 +1039,33 @@ mod tests { assert_eq!(local_gate_up_rows, 9216); assert_eq!(local_down_cols, 4608); } + + #[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, TensorParallelConfig::try_from((0, 2)).unwrap()); + assert_eq!(rank0, [(0, 1024), (2048, 1024), (4096, 2048)]); + + let rank1 = + linear_qkv_shard_segments(&config, TensorParallelConfig::try_from((1, 2)).unwrap()); + 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, TensorParallelConfig::try_from((1, 2)).unwrap()); + // conv1d.weight is [qkv * 4]: same ranges as qkv, scaled by 4. + assert_eq!(rank1, [(4096, 4096), (12288, 4096), (24576, 8192)]); + } }