Skip to main content

ferrum_engine/parallel/
tensor_parallel.rs

1//! Tensor Parallelism
2//!
3//! Implements tensor parallelism for splitting model weights and
4//! computations across multiple GPUs.
5//!
6//! ## Weight Distribution
7//!
8//! For a Linear layer with weight W of shape [out_features, in_features]:
9//! - Column-parallel: Split along out_features, each GPU has W[:, start:end]
10//! - Row-parallel: Split along in_features, each GPU has W[start:end, :]
11//!
12//! ## Communication Patterns
13//!
14//! - Column-parallel → Row-parallel: All-Reduce
15//! - Row-parallel → Column-parallel: All-Gather
16
17use ferrum_types::{Device, FerrumError, Result};
18
19/// Tensor parallel configuration
20#[derive(Debug, Clone)]
21pub struct TensorParallelConfig {
22    /// World size (number of tensor parallel ranks)
23    pub world_size: usize,
24    /// Local rank in tensor parallel group
25    pub rank: usize,
26    /// Device for this rank
27    pub device: Device,
28    /// Whether to use sequence parallelism
29    pub sequence_parallel: bool,
30    /// Whether to reduce scatter for efficiency
31    pub reduce_scatter: bool,
32}
33
34impl Default for TensorParallelConfig {
35    fn default() -> Self {
36        Self {
37            world_size: 1,
38            rank: 0,
39            device: Device::CPU,
40            sequence_parallel: false,
41            reduce_scatter: true,
42        }
43    }
44}
45
46impl TensorParallelConfig {
47    /// Create config for a specific rank
48    pub fn new(world_size: usize, rank: usize, device: Device) -> Self {
49        Self {
50            world_size,
51            rank,
52            device,
53            ..Default::default()
54        }
55    }
56
57    /// Check if tensor parallelism is enabled
58    pub fn is_parallel(&self) -> bool {
59        self.world_size > 1
60    }
61
62    /// Calculate shard size for a dimension
63    pub fn shard_size(&self, dim_size: usize) -> usize {
64        assert!(
65            dim_size.is_multiple_of(self.world_size),
66            "Dimension {} must be divisible by world size {}",
67            dim_size,
68            self.world_size
69        );
70        dim_size / self.world_size
71    }
72
73    /// Calculate offset for this rank's shard
74    pub fn shard_offset(&self, dim_size: usize) -> usize {
75        self.shard_size(dim_size) * self.rank
76    }
77
78    /// Get the range for this rank's shard
79    pub fn shard_range(&self, dim_size: usize) -> (usize, usize) {
80        let size = self.shard_size(dim_size);
81        let start = size * self.rank;
82        (start, start + size)
83    }
84}
85
86/// Tensor parallel group for collective operations
87pub struct TensorParallelGroup {
88    /// Configuration for this group
89    config: TensorParallelConfig,
90    /// Devices in this group
91    devices: Vec<Device>,
92}
93
94impl TensorParallelGroup {
95    /// Create a new tensor parallel group
96    pub fn new(devices: Vec<Device>, rank: usize) -> Result<Self> {
97        if devices.is_empty() {
98            return Err(FerrumError::config(
99                "No devices provided for tensor parallel group",
100            ));
101        }
102        if rank >= devices.len() {
103            return Err(FerrumError::config(format!(
104                "Rank {} >= num devices {}",
105                rank,
106                devices.len()
107            )));
108        }
109
110        let config = TensorParallelConfig::new(devices.len(), rank, devices[rank].clone());
111
112        Ok(Self { config, devices })
113    }
114
115    /// Get configuration
116    pub fn config(&self) -> &TensorParallelConfig {
117        &self.config
118    }
119
120    /// Get all devices in group
121    pub fn devices(&self) -> &[Device] {
122        &self.devices
123    }
124
125    /// Get world size
126    pub fn world_size(&self) -> usize {
127        self.config.world_size
128    }
129
130    /// Get local rank
131    pub fn rank(&self) -> usize {
132        self.config.rank
133    }
134
135    /// Get local device
136    pub fn device(&self) -> &Device {
137        &self.config.device
138    }
139
140    /// Check if this rank is the master (rank 0)
141    pub fn is_master(&self) -> bool {
142        self.config.rank == 0
143    }
144}
145
146/// Type of parallelism for a layer
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum LayerParallelType {
149    /// Column-parallel (split output features)
150    ColumnParallel,
151    /// Row-parallel (split input features)
152    RowParallel,
153    /// No parallelism (replicated)
154    Replicated,
155}
156
157/// Weight sharding specification
158#[derive(Debug, Clone)]
159pub struct WeightShard {
160    /// Original tensor name
161    pub name: String,
162    /// Parallel type
163    pub parallel_type: LayerParallelType,
164    /// Dimension to shard along
165    pub shard_dim: usize,
166    /// Local shard range (start, end)
167    pub shard_range: (usize, usize),
168}
169
170impl WeightShard {
171    /// Create column-parallel shard specification
172    pub fn column_parallel(
173        name: impl Into<String>,
174        dim_size: usize,
175        config: &TensorParallelConfig,
176    ) -> Self {
177        Self {
178            name: name.into(),
179            parallel_type: LayerParallelType::ColumnParallel,
180            shard_dim: 0, // Output dimension
181            shard_range: config.shard_range(dim_size),
182        }
183    }
184
185    /// Create row-parallel shard specification
186    pub fn row_parallel(
187        name: impl Into<String>,
188        dim_size: usize,
189        config: &TensorParallelConfig,
190    ) -> Self {
191        Self {
192            name: name.into(),
193            parallel_type: LayerParallelType::RowParallel,
194            shard_dim: 1, // Input dimension
195            shard_range: config.shard_range(dim_size),
196        }
197    }
198
199    /// Create replicated weight specification
200    pub fn replicated(name: impl Into<String>) -> Self {
201        Self {
202            name: name.into(),
203            parallel_type: LayerParallelType::Replicated,
204            shard_dim: 0,
205            shard_range: (0, 0), // Full tensor
206        }
207    }
208}
209
210/// Tensor parallel layer mapping for transformer models
211#[derive(Debug, Clone)]
212pub struct TransformerParallelMapping {
213    /// Number of attention heads per rank
214    pub heads_per_rank: usize,
215    /// Number of KV heads per rank
216    pub kv_heads_per_rank: usize,
217    /// Head dimension
218    pub head_dim: usize,
219    /// Hidden dimension per rank
220    pub hidden_per_rank: usize,
221    /// Intermediate dimension per rank (for MLP)
222    pub intermediate_per_rank: usize,
223}
224
225impl TransformerParallelMapping {
226    /// Create mapping for a transformer model
227    pub fn new(
228        num_heads: usize,
229        num_kv_heads: usize,
230        head_dim: usize,
231        hidden_dim: usize,
232        intermediate_dim: usize,
233        tp_size: usize,
234    ) -> Result<Self> {
235        // Validate divisibility
236        if !num_heads.is_multiple_of(tp_size) {
237            return Err(FerrumError::config(format!(
238                "num_heads {} must be divisible by tp_size {}",
239                num_heads, tp_size
240            )));
241        }
242        if !num_kv_heads.is_multiple_of(tp_size) {
243            return Err(FerrumError::config(format!(
244                "num_kv_heads {} must be divisible by tp_size {}",
245                num_kv_heads, tp_size
246            )));
247        }
248        if !intermediate_dim.is_multiple_of(tp_size) {
249            return Err(FerrumError::config(format!(
250                "intermediate_dim {} must be divisible by tp_size {}",
251                intermediate_dim, tp_size
252            )));
253        }
254
255        Ok(Self {
256            heads_per_rank: num_heads / tp_size,
257            kv_heads_per_rank: num_kv_heads / tp_size,
258            head_dim,
259            hidden_per_rank: hidden_dim, // Hidden dim is not sharded
260            intermediate_per_rank: intermediate_dim / tp_size,
261        })
262    }
263
264    /// Get Q projection output dimension per rank
265    pub fn q_proj_size(&self) -> usize {
266        self.heads_per_rank * self.head_dim
267    }
268
269    /// Get K projection output dimension per rank
270    pub fn k_proj_size(&self) -> usize {
271        self.kv_heads_per_rank * self.head_dim
272    }
273
274    /// Get V projection output dimension per rank
275    pub fn v_proj_size(&self) -> usize {
276        self.kv_heads_per_rank * self.head_dim
277    }
278
279    /// Get O projection input dimension per rank
280    pub fn o_proj_in_size(&self) -> usize {
281        self.heads_per_rank * self.head_dim
282    }
283
284    /// Get weight shards for attention layer
285    pub fn attention_weight_shards(
286        &self,
287        layer_idx: usize,
288        config: &TensorParallelConfig,
289    ) -> Vec<WeightShard> {
290        let prefix = format!("model.layers.{}.self_attn", layer_idx);
291
292        vec![
293            // Q, K, V are column-parallel
294            WeightShard::column_parallel(
295                format!("{}.q_proj.weight", prefix),
296                self.q_proj_size() * config.world_size,
297                config,
298            ),
299            WeightShard::column_parallel(
300                format!("{}.k_proj.weight", prefix),
301                self.k_proj_size() * config.world_size,
302                config,
303            ),
304            WeightShard::column_parallel(
305                format!("{}.v_proj.weight", prefix),
306                self.v_proj_size() * config.world_size,
307                config,
308            ),
309            // O is row-parallel
310            WeightShard::row_parallel(
311                format!("{}.o_proj.weight", prefix),
312                self.o_proj_in_size() * config.world_size,
313                config,
314            ),
315        ]
316    }
317
318    /// Get weight shards for MLP layer
319    pub fn mlp_weight_shards(
320        &self,
321        layer_idx: usize,
322        config: &TensorParallelConfig,
323    ) -> Vec<WeightShard> {
324        let prefix = format!("model.layers.{}.mlp", layer_idx);
325
326        vec![
327            // Gate and up projections are column-parallel
328            WeightShard::column_parallel(
329                format!("{}.gate_proj.weight", prefix),
330                self.intermediate_per_rank * config.world_size,
331                config,
332            ),
333            WeightShard::column_parallel(
334                format!("{}.up_proj.weight", prefix),
335                self.intermediate_per_rank * config.world_size,
336                config,
337            ),
338            // Down projection is row-parallel
339            WeightShard::row_parallel(
340                format!("{}.down_proj.weight", prefix),
341                self.intermediate_per_rank * config.world_size,
342                config,
343            ),
344        ]
345    }
346}
347
348// ============================================================================
349// Tests
350// ============================================================================
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    #[test]
357    fn test_tensor_parallel_config() {
358        let config = TensorParallelConfig::new(4, 2, Device::CUDA(2));
359        assert!(config.is_parallel());
360        assert_eq!(config.shard_size(128), 32);
361        assert_eq!(config.shard_offset(128), 64);
362        assert_eq!(config.shard_range(128), (64, 96));
363    }
364
365    #[test]
366    fn test_tensor_parallel_group() {
367        let devices = vec![
368            Device::CUDA(0),
369            Device::CUDA(1),
370            Device::CUDA(2),
371            Device::CUDA(3),
372        ];
373        let group = TensorParallelGroup::new(devices, 1).unwrap();
374
375        assert_eq!(group.world_size(), 4);
376        assert_eq!(group.rank(), 1);
377        assert_eq!(group.device(), &Device::CUDA(1));
378        assert!(!group.is_master());
379    }
380
381    #[test]
382    fn test_transformer_parallel_mapping() {
383        // Test with invalid intermediate_dim (not divisible by tp_size)
384        let mapping = TransformerParallelMapping::new(
385            32,    // num_heads
386            8,     // num_kv_heads
387            128,   // head_dim
388            4096,  // hidden_dim
389            11009, // intermediate_dim (11009 % 4 = 1, not divisible)
390            4,     // tp_size
391        );
392
393        // 11009 is not divisible by 4, so this should fail
394        assert!(mapping.is_err());
395
396        // Use a divisible intermediate_dim (11008 is divisible by 4)
397        let mapping = TransformerParallelMapping::new(32, 8, 128, 4096, 11008, 4);
398        assert!(mapping.is_ok());
399
400        let mapping = mapping.unwrap();
401        assert_eq!(mapping.heads_per_rank, 8);
402        assert_eq!(mapping.kv_heads_per_rank, 2);
403    }
404
405    #[test]
406    fn test_weight_shard() {
407        let config = TensorParallelConfig::new(4, 1, Device::CUDA(1));
408        let shard = WeightShard::column_parallel("test.weight", 4096, &config);
409
410        assert_eq!(shard.parallel_type, LayerParallelType::ColumnParallel);
411        assert_eq!(shard.shard_range, (1024, 2048));
412    }
413
414    // ======== TransformerParallelMapping comprehensive tests ========
415
416    #[test]
417    fn mapping_qwen3_4b_tp2() {
418        // Qwen3-4B: 32 heads, 8 kv_heads, hd=128, hidden=2560, inter=9728
419        let m = TransformerParallelMapping::new(32, 8, 128, 2560, 9728, 2).unwrap();
420        assert_eq!(m.heads_per_rank, 16);
421        assert_eq!(m.kv_heads_per_rank, 4);
422        assert_eq!(m.head_dim, 128);
423        assert_eq!(m.q_proj_size(), 2048); // 16 * 128
424        assert_eq!(m.k_proj_size(), 512); // 4 * 128
425        assert_eq!(m.v_proj_size(), 512);
426        assert_eq!(m.o_proj_in_size(), 2048);
427        assert_eq!(m.intermediate_per_rank, 4864); // 9728 / 2
428        assert_eq!(m.hidden_per_rank, 2560); // NOT sharded
429    }
430
431    #[test]
432    fn mapping_llama_70b_tp8() {
433        // Llama-70B: 64 heads, 8 kv_heads, hd=128, hidden=8192, inter=28672
434        let m = TransformerParallelMapping::new(64, 8, 128, 8192, 28672, 8).unwrap();
435        assert_eq!(m.heads_per_rank, 8);
436        assert_eq!(m.kv_heads_per_rank, 1);
437        assert_eq!(m.q_proj_size(), 1024);
438        assert_eq!(m.k_proj_size(), 128);
439        assert_eq!(m.intermediate_per_rank, 3584);
440    }
441
442    #[test]
443    fn mapping_tinyllama_tp2() {
444        // TinyLlama: 32 heads, 4 kv_heads, hd=64, hidden=2048, inter=5632
445        let m = TransformerParallelMapping::new(32, 4, 64, 2048, 5632, 2).unwrap();
446        assert_eq!(m.heads_per_rank, 16);
447        assert_eq!(m.kv_heads_per_rank, 2);
448        assert_eq!(m.q_proj_size(), 1024);
449        assert_eq!(m.k_proj_size(), 128);
450        assert_eq!(m.intermediate_per_rank, 2816);
451    }
452
453    #[test]
454    fn mapping_tp1_noop() {
455        // TP=1: no sharding, all dimensions stay full
456        let m = TransformerParallelMapping::new(32, 8, 128, 4096, 11008, 1).unwrap();
457        assert_eq!(m.heads_per_rank, 32);
458        assert_eq!(m.kv_heads_per_rank, 8);
459        assert_eq!(m.intermediate_per_rank, 11008);
460    }
461
462    #[test]
463    fn mapping_rejects_indivisible_heads() {
464        assert!(TransformerParallelMapping::new(7, 7, 64, 448, 1024, 2).is_err());
465    }
466
467    #[test]
468    fn mapping_rejects_indivisible_kv_heads() {
469        assert!(TransformerParallelMapping::new(32, 3, 64, 2048, 5632, 2).is_err());
470    }
471
472    #[test]
473    fn mapping_rejects_indivisible_intermediate() {
474        assert!(TransformerParallelMapping::new(32, 8, 128, 4096, 11009, 4).is_err());
475    }
476
477    #[test]
478    fn attention_weight_shards_correct() {
479        let m = TransformerParallelMapping::new(32, 8, 128, 4096, 11008, 4).unwrap();
480        let cfg = TensorParallelConfig::new(4, 2, Device::CUDA(2));
481        let shards = m.attention_weight_shards(5, &cfg);
482
483        assert_eq!(shards.len(), 4); // Q, K, V, O
484                                     // Q: column-parallel, full q_dim = 4096, rank 2 gets [2048, 3072)
485        assert_eq!(shards[0].name, "model.layers.5.self_attn.q_proj.weight");
486        assert_eq!(shards[0].parallel_type, LayerParallelType::ColumnParallel);
487        assert_eq!(shards[0].shard_range, (2048, 3072));
488        // K: column-parallel, full kv_dim = 1024, rank 2 gets [512, 768)
489        assert_eq!(shards[1].shard_range, (512, 768));
490        // V: same as K
491        assert_eq!(shards[2].shard_range, (512, 768));
492        // O: row-parallel, full q_dim = 4096, rank 2 gets [2048, 3072)
493        assert_eq!(shards[3].name, "model.layers.5.self_attn.o_proj.weight");
494        assert_eq!(shards[3].parallel_type, LayerParallelType::RowParallel);
495        assert_eq!(shards[3].shard_range, (2048, 3072));
496    }
497
498    #[test]
499    fn mlp_weight_shards_correct() {
500        let m = TransformerParallelMapping::new(32, 8, 128, 4096, 11008, 4).unwrap();
501        let cfg = TensorParallelConfig::new(4, 0, Device::CUDA(0));
502        let shards = m.mlp_weight_shards(3, &cfg);
503
504        assert_eq!(shards.len(), 3); // gate, up, down
505                                     // gate: column-parallel, full inter = 11008, rank 0 gets [0, 2752)
506        assert_eq!(shards[0].name, "model.layers.3.mlp.gate_proj.weight");
507        assert_eq!(shards[0].parallel_type, LayerParallelType::ColumnParallel);
508        assert_eq!(shards[0].shard_range, (0, 2752));
509        // up: same ranges
510        assert_eq!(shards[1].shard_range, (0, 2752));
511        // down: row-parallel
512        assert_eq!(shards[2].name, "model.layers.3.mlp.down_proj.weight");
513        assert_eq!(shards[2].parallel_type, LayerParallelType::RowParallel);
514        assert_eq!(shards[2].shard_range, (0, 2752));
515    }
516
517    #[test]
518    fn weight_shard_replicated() {
519        let shard = WeightShard::replicated("model.norm.weight");
520        assert_eq!(shard.parallel_type, LayerParallelType::Replicated);
521        assert_eq!(shard.shard_range, (0, 0));
522    }
523
524    #[test]
525    fn config_shard_range_all_ranks_cover_full_dim() {
526        let dim = 4096;
527        let tp_size = 4;
528        let mut covered = vec![false; dim];
529
530        for rank in 0..tp_size {
531            let cfg = TensorParallelConfig::new(tp_size, rank, Device::CUDA(rank));
532            let (start, end) = cfg.shard_range(dim);
533            assert_eq!(end - start, dim / tp_size);
534            for i in start..end {
535                assert!(!covered[i], "Overlap at index {i}");
536                covered[i] = true;
537            }
538        }
539        assert!(covered.iter().all(|&c| c), "Not all indices covered");
540    }
541
542    #[test]
543    fn config_non_parallel() {
544        let cfg = TensorParallelConfig::new(1, 0, Device::CPU);
545        assert!(!cfg.is_parallel());
546        assert_eq!(cfg.shard_size(4096), 4096);
547        assert_eq!(cfg.shard_range(4096), (0, 4096));
548    }
549
550    #[test]
551    fn group_rejects_invalid_rank() {
552        let devices = vec![Device::CUDA(0), Device::CUDA(1)];
553        assert!(TensorParallelGroup::new(devices, 5).is_err());
554    }
555
556    #[test]
557    fn group_rejects_empty_devices() {
558        assert!(TensorParallelGroup::new(vec![], 0).is_err());
559    }
560
561    #[test]
562    fn row_parallel_shard_dim_is_1() {
563        let cfg = TensorParallelConfig::new(2, 0, Device::CUDA(0));
564        let shard = WeightShard::row_parallel("test", 4096, &cfg);
565        assert_eq!(shard.shard_dim, 1); // input dimension
566    }
567
568    #[test]
569    fn column_parallel_shard_dim_is_0() {
570        let cfg = TensorParallelConfig::new(2, 0, Device::CUDA(0));
571        let shard = WeightShard::column_parallel("test", 4096, &cfg);
572        assert_eq!(shard.shard_dim, 0); // output dimension
573    }
574}