diff --git a/README.md b/README.md index abd8070b2..4b2b9073e 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Build Options: > Both options are optional and can be disabled for CPU-only builds. -## ✨ InfiniTrain Overview +## ✨ InfiniTrain Overview ### ✔ Support Matrix @@ -96,160 +96,160 @@ For example, the `llama3` example produces a binary named `llama3`. To view available runtime options: -```bash -./build/llama3 --help +```bash +./build/llama3 --help +``` + +### Getting Started + +#### Prepare Datasets and Weights + +Run the asset preparation script from the repository root. Prepared files are +written to `data/` by default. + +```bash +# MNIST dataset +./scripts/assets/prepare-infinitrain-assets.sh mnist + +# GPT-2 124M weights, tokenizer, and tokenized TinyShakespeare data +./scripts/assets/prepare-infinitrain-assets.sh gpt2 + +# LLaMA 3.2 1B weights and tokenized TinyShakespeare data +HF_TOKEN=hf_xxx ./scripts/assets/prepare-infinitrain-assets.sh llama3 +``` + +Preparing LLaMA requires access to the gated +`meta-llama/Llama-3.2-1B` repository. Accept its license on Hugging Face and +provide `HF_TOKEN`, or authenticate with `hf auth login`, before running the +command. The complete LLaMA preparation requires approximately 8.5 GB of free +disk space, including the downloaded checkpoint and converted FP32 weights. + +Use `DATA_DIR` to write the assets elsewhere, or prepare all supported assets +in one invocation: + +```bash +DATA_DIR=/path/to/data \ +HF_TOKEN=hf_xxx \ +./scripts/assets/prepare-infinitrain-assets.sh all +``` + +#### Model Examples + +The generated files can be passed directly to the corresponding executables: + +##### MNIST + +```bash +./build/mnist \ + --device cpu \ + --dataset data/mnist +``` + +##### GPT-2 124M + +```bash +./build/gpt2 \ + --device cuda \ + --input_bin data/gpt2/tiny_shakespeare_train.bin \ + --input_val_bin data/gpt2/tiny_shakespeare_val.bin \ + --tokenizer_bin data/gpt2/gpt2_tokenizer.bin \ + --llmc_filepath data/gpt2/gpt2_124M.bin \ + --num_iteration 10 +``` + +##### LLaMA 3.2 1B + +```bash +./build/llama3 \ + --device cuda \ + --input_bin data/llama3/tiny_shakespeare_train.bin \ + --input_val_bin data/llama3/tiny_shakespeare_val.bin \ + --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ + --num_iteration 10 ``` -### Getting Started - -#### Prepare Datasets and Weights - -Run the asset preparation script from the repository root. Prepared files are -written to `data/` by default. - -```bash -# MNIST dataset -./scripts/assets/prepare-infinitrain-assets.sh mnist - -# GPT-2 124M weights, tokenizer, and tokenized TinyShakespeare data -./scripts/assets/prepare-infinitrain-assets.sh gpt2 - -# LLaMA 3.2 1B weights and tokenized TinyShakespeare data -HF_TOKEN=hf_xxx ./scripts/assets/prepare-infinitrain-assets.sh llama3 -``` - -Preparing LLaMA requires access to the gated -`meta-llama/Llama-3.2-1B` repository. Accept its license on Hugging Face and -provide `HF_TOKEN`, or authenticate with `hf auth login`, before running the -command. The complete LLaMA preparation requires approximately 8.5 GB of free -disk space, including the downloaded checkpoint and converted FP32 weights. - -Use `DATA_DIR` to write the assets elsewhere, or prepare all supported assets -in one invocation: - -```bash -DATA_DIR=/path/to/data \ -HF_TOKEN=hf_xxx \ -./scripts/assets/prepare-infinitrain-assets.sh all -``` - -#### Model Examples - -The generated files can be passed directly to the corresponding executables: - -##### MNIST - -```bash -./build/mnist \ - --device cpu \ - --dataset data/mnist -``` - -##### GPT-2 124M - -```bash -./build/gpt2 \ - --device cuda \ - --input_bin data/gpt2/tiny_shakespeare_train.bin \ - --input_val_bin data/gpt2/tiny_shakespeare_val.bin \ - --tokenizer_bin data/gpt2/gpt2_tokenizer.bin \ - --llmc_filepath data/gpt2/gpt2_124M.bin \ - --num_iteration 10 -``` - -##### LLaMA 3.2 1B - -```bash -./build/llama3 \ - --device cuda \ - --input_bin data/llama3/tiny_shakespeare_train.bin \ - --input_val_bin data/llama3/tiny_shakespeare_val.bin \ - --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ - --num_iteration 10 -``` - -### Launch Modes - -GPT-2 and LLaMA training support both thread-based and process-based launches. -The examples below use LLaMA, but the same launch modes also apply to GPT-2. - -#### Direct Launch - -Running a model executable directly uses one process and one device by default. -Set `--nthread_per_process` to use multiple execution threads and devices in the -same process: - -```bash -./build/llama3 \ - --device cuda \ - --input_bin data/llama3/tiny_shakespeare_train.bin \ - --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ - --nthread_per_process 8 \ - --num_iteration 10 -``` - -#### Single-node Multi-process Launch - -Use `infini_run` to start multiple training processes on one node. Each process -uses one execution thread by default: - -```bash -./build/infini_run \ - --nnodes=1 \ - --nproc_per_node=8 \ - ./build/llama3 \ - --device cuda \ - --input_bin data/llama3/tiny_shakespeare_train.bin \ - --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ - --num_iteration 10 -``` - -#### Multi-node Multi-process Launch - -Run the following command on every node with the same rendezvous settings and -a distinct `node_rank`: - -```bash -./build/infini_run \ - --nnodes=2 \ - --nproc_per_node=4 \ - --node_rank=[rank_id] \ - --rdzv_endpoint=[master_addr]:29500 \ - --rdzv_id=[job_id] \ - ./build/llama3 \ - --device cuda \ - --input_bin data/llama3/tiny_shakespeare_train.bin \ - --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ - --num_iteration 10 \ - --tensor_parallel 2 \ - --pipeline_parallel 2 \ - --sequence_parallel -``` - -`--nproc_per_node` and `--nthread_per_process` can be combined. The total -training world size is: - -```text -world_size = nnodes × nproc_per_node × nthread_per_process -``` +### Launch Modes + +GPT-2 and LLaMA training support both thread-based and process-based launches. +The examples below use LLaMA, but the same launch modes also apply to GPT-2. + +#### Direct Launch + +Running a model executable directly uses one process and one device by default. +Set `--nthread_per_process` to use multiple execution threads and devices in the +same process: + +```bash +./build/llama3 \ + --device cuda \ + --input_bin data/llama3/tiny_shakespeare_train.bin \ + --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ + --nthread_per_process 8 \ + --num_iteration 10 +``` + +#### Single-node Multi-process Launch + +Use `infini_run` to start multiple training processes on one node. Each process +uses one execution thread by default: + +```bash +./build/infini_run \ + --nnodes=1 \ + --nproc_per_node=8 \ + ./build/llama3 \ + --device cuda \ + --input_bin data/llama3/tiny_shakespeare_train.bin \ + --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ + --num_iteration 10 +``` + +#### Multi-node Multi-process Launch + +Run the following command on every node with the same rendezvous settings and +a distinct `node_rank`: + +```bash +./build/infini_run \ + --nnodes=2 \ + --nproc_per_node=4 \ + --node_rank=[rank_id] \ + --rdzv_endpoint=[master_addr]:29500 \ + --rdzv_id=[job_id] \ + ./build/llama3 \ + --device cuda \ + --input_bin data/llama3/tiny_shakespeare_train.bin \ + --llmc_filepath data/llama3/llama3.2_1B_fp32.bin \ + --num_iteration 10 \ + --tensor_parallel 2 \ + --pipeline_parallel 2 \ + --sequence_parallel +``` + +`--nproc_per_node` and `--nthread_per_process` can be combined. The total +training world size is: + +```text +world_size = nnodes × nproc_per_node × nthread_per_process +``` ### Parallelism Strategies -#### Distributed Data Parallelism (DDP) - -For a direct launch with TP and PP disabled, the following starts eight -data-parallel workers in one process: - -```bash ---nthread_per_process 8 # 8-way DDP when TP=1 and PP=1 -``` - -For all launch modes, the data-parallel size is derived from the total world -size after accounting for tensor and pipeline parallelism: - -```text -data_parallel_size = world_size / (tensor_parallel × pipeline_parallel) -``` +#### Distributed Data Parallelism (DDP) + +For a direct launch with TP and PP disabled, the following starts eight +data-parallel workers in one process: + +```bash +--nthread_per_process 8 # 8-way DDP when TP=1 and PP=1 +``` + +For all launch modes, the data-parallel size is derived from the total world +size after accounting for tensor and pipeline parallelism: + +```text +data_parallel_size = world_size / (tensor_parallel × pipeline_parallel) +``` #### Tensor Parallelism (TP) @@ -263,8 +263,15 @@ data_parallel_size = world_size / (tensor_parallel × pipeline_parallel) ```bash --pipeline_parallel 8 # 8 pipeline stages --virtual_pipeline_parallel 4 # Virtual pipeline for better load balancing +--pipeline_layer_partition 4,8,6,6 # Optional custom per-stage layer counts ``` +`--pipeline_layer_partition` lets you assign a non-uniform but contiguous number +of transformer layers to each stage (e.g. `4,8,6,6` for 4 stages / 24 layers). +The sum of entries must equal the model layer count. When omitted, the default +uniform partition (compatible with vPP) is used. See +[`docs/pipeline_layout_guide.md`](docs/pipeline_layout_guide.md) for details. + #### Combining Parallelism Strategies Multiple parallelism strategies (DDP, TP, SP, PP) can be freely combined to scale training across devices and nodes. @@ -316,4 +323,4 @@ Multiple parallelism strategies (DDP, TP, SP, PP) can be freely combined to scal optimizations. Integrated a CTest + GTest based testing infrastructure to strengthen the - framework's automated test workflow. + framework's automated test workflow. diff --git a/docs/pipeline_layout_demo.cc b/docs/pipeline_layout_demo.cc new file mode 100644 index 000000000..08280abf4 --- /dev/null +++ b/docs/pipeline_layout_demo.cc @@ -0,0 +1,114 @@ +// Demonstration for excellent-standard #4: compare the default uniform pipeline +// layout against a custom (cost-balanced) layout on a load-imbalanced model, and +// show the resulting pipeline bubble, per-stage time and throughput. +// +// This is a pure-CPU analytical demo: it uses the same ComputePipelineLoadAnalysis / +// SuggestBalancedPartition functions the training binary uses, so it can run on any +// machine without a GPU. The real (measured) per-stage CUDA timings are produced by +// the gpt2 example on a multi-GPU machine (see the multi-GPU run command in +// docs/pipeline_layout_guide.md). +// +// Build & run (from the repo root, inside WSL): +// g++ -std=c++20 -I. -Ithird_party/glog/src \ +// docs/pipeline_layout_demo.cc infini_train/src/nn/parallel/pp/pipeline_layout.cc \ +// -Lbuild/third_party/glog -lglog -pthread -o build/pipeline_layout_demo +// ./build/pipeline_layout_demo + +#include +#include +#include + +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" + +namespace { +using infini_train::nn::parallel::ComputePipelineLoadAnalysis; +using infini_train::nn::parallel::PipelineLoadStats; +using infini_train::nn::parallel::SuggestBalancedPartition; + +// A load-imbalanced 12-layer model: the first 4 layers are "light" (cost 1) and the +// last 8 layers are "heavy" (cost 2). This models, e.g., attention-vs-MLP heavy blocks +// or a mixture-of-experts tail whose per-layer compute is no longer uniform. +std::vector ImbalancedCosts() { + std::vector costs(12, 2.0); + for (int i = 0; i < 4; ++i) { costs[i] = 1.0; } + return costs; +} + +std::string PartitionStr(const std::vector &p) { + std::string s; + for (size_t i = 0; i < p.size(); ++i) { + if (i) { + s += ","; + } + s += std::to_string(p[i]); + } + return s; +} + +// Steady-state throughput in micro-batches per unit time, from the GPipe make-span +// formula: T = bottleneck * (S - 1 + n), so throughput = n / T. +double Throughput(const PipelineLoadStats &s) { + return s.num_micro_batches / (s.bottleneck * (s.num_stages - 1 + s.num_micro_batches)); +} + +void PrintComparison(const char *title, const std::vector &uniform_partition, + const std::vector &custom_partition, const std::vector &costs, int n) { + const int total_layers = static_cast(costs.size()); + const int num_stages = static_cast(uniform_partition.size()); + + const PipelineLoadStats uniform + = ComputePipelineLoadAnalysis(total_layers, num_stages, uniform_partition, costs, n); + const PipelineLoadStats custom = ComputePipelineLoadAnalysis(total_layers, num_stages, custom_partition, costs, n); + + const std::string u_part = PartitionStr(uniform_partition); + const std::string c_part = PartitionStr(custom_partition); + + std::printf("=== %s ===\n", title); + std::printf("per-layer costs: ["); + for (size_t i = 0; i < costs.size(); ++i) { std::printf("%s%.0f", i ? "," : "", costs[i]); } + std::printf("] (S=%d stages, n=%d micro-batches)\n\n", num_stages, n); + + const std::string u_col = "uniform (" + u_part + ")"; + const std::string c_col = "custom (" + c_part + ")"; + std::printf("%-22s | %-14s | %-14s\n", "metric", u_col.c_str(), c_col.c_str()); + std::printf("%-22s-+-%s-+-%s\n", "----------------------", "---------------", "---------------"); + + for (int s = 0; s < num_stages; ++s) { + char line[32]; + std::snprintf(line, sizeof(line), "stage %d time (load)", s); + std::printf("%-22s | %14.3f | %14.3f\n", line, uniform.stage_loads[s], custom.stage_loads[s]); + } + std::printf("%-22s | %14.3f | %14.3f\n", "bottleneck (max)", uniform.bottleneck, custom.bottleneck); + std::printf("%-22s | %14.3f | %14.3f\n", "average", uniform.average, custom.average); + std::printf("%-22s | %13.1f%% | %13.1f%%\n", "imbalance bubble", uniform.imbalance_bubble * 100.0, + custom.imbalance_bubble * 100.0); + std::printf("%-22s | %13.1f%% | %13.1f%%\n", "structural bubble", uniform.structural_bubble * 100.0, + custom.structural_bubble * 100.0); + std::printf("%-22s | %13.1f%% | %13.1f%%\n", "pipeline efficiency", uniform.efficiency * 100.0, + custom.efficiency * 100.0); + + const double t_uniform = Throughput(uniform); + const double t_custom = Throughput(custom); + std::printf("%-22s | %14.4f | %14.4f\n", "throughput (mb/t)", t_uniform, t_custom); + std::printf("%-22s | %14s | %13.2fx\n", "throughput speedup", "-", t_custom / t_uniform); + std::printf("\n"); +} +} // namespace + +int main() { + const int n = 8; // micro-batches per step + + // 1) Load-imbalanced model: default uniform {6,6} vs custom balanced {7,5}. + const std::vector costs = ImbalancedCosts(); + const std::vector uniform{6, 6}; + const std::vector custom = SuggestBalancedPartition(12, 2, costs); + PrintComparison("Load-imbalanced model (4 light + 8 heavy layers)", uniform, custom, costs, n); + + // 2) Uniform-cost model: both layouts are equivalent, showing the feature correctly + // reports zero imbalance bubble for the balanced default. + const std::vector unit_costs(12, 1.0); + const std::vector unit_custom = SuggestBalancedPartition(12, 2, unit_costs); + PrintComparison("Uniform-cost model (all layers equal)", uniform, unit_custom, unit_costs, n); + + return 0; +} diff --git a/docs/pipeline_layout_guide.md b/docs/pipeline_layout_guide.md new file mode 100644 index 000000000..fcd031ec8 --- /dev/null +++ b/docs/pipeline_layout_guide.md @@ -0,0 +1,319 @@ +# Pipeline并行自定义布局使用说明 + +本文档描述Pipeline自定义布局的使用方法:通过 `--pipeline_layer_partition`显式指定每个Stage的Transformer层数,或通过 `--pipeline_layer_costs` /`--pipeline_auto_layout` 根据计算代价自动生成负载均衡的布局。 + +例如: + +```bash +./build/infini_run \ + --nproc_per_node=4 \ + ./build/gpt2 \ + --device cuda \ + --input_bin data/train.bin \ + --llmc_filepath data/gpt2_124M.bin \ + --pipeline_parallel 4 \ + --pipeline_layer_partition 4,8,6,6 +``` + +上述配置把 24 个Transformer层依次划分为4, 8, 6, 6: + +- Stage 0: embedding + layers 0~3 + +- Stage 1: layers 4~11 + +- Stage 2: layers 12~17 + +- Stage 3: layers 18~23 + + + +## 参数配置 + +| 参数 | 默认值【注2】 | 说明 | +| -------------------------------- | ------- | --------------------- | +| `--pipeline_parallel` | `1` | Stage数量 | +| `--virtual_pipeline_parallel` | `1` | Virtual Chunk 数量(vPP) | +| `--pipeline_layer_partition`【注1】 | `""` | 各Stage层数列表(逗号分隔) | +| `--pipeline_layer_costs` | `""` | 每层计算代价(逗号分隔) | +| `--pipeline_auto_layout` | `false` | 按每层参数量自动生成负载均衡布局 | + +GPT2与LLaMA3示例入口均支持 `--pipeline_layer_partition`,解析后写入全局环境`GlobalEnv`,模型构建、`PipelineParallel` 包装、调度器与 checkpoint 加载都从同一布局查询层归属。 + +#### 【注1】布局语法 + +- 多传入参数时,语法为逗号分隔的正整数列表。 +- 列表长度必须等于 `--pipeline_parallel` 的Stage数量。 +- 各Stage层数之和必须等于模型总层数,stage i 拥有编号从前 i 项之和到前 i+1 项之和的连续Transformer层。 +- Embedding固定归属第一个Stage,Final Norm + LM Head 固定归属最后一个Stage(当前版本不开放单独配置)。 + +#### 【注2】默认值说明 + +不传 `--pipeline_layer_partition`(或传空串)时,保持原有自动均匀划分: + +```text +layers_per_chunk = total_layers / (num_stages * vpp_size) +remainder = total_layers % (num_stages * vpp_size) +``` + +余数按 global chunk 顺序依次多分配一层,vPP下各Stage按`global_chunk = local_chunk * num_stages + stage` 交错持有多个层范围。因此默认均匀布局与vPP完全兼容。 + +## 自动布局建议 + +除显式指定 `--pipeline_layer_partition` 外,还支持根据计算代价自动生成近似负载均衡的连续布局。三种代价来源: + +1. **用户提供的计算代价**:`--pipeline_layer_costs "1,1,1,1,2,..."`,每个数对应一层的代价 + (参数量、实测耗时等均可)。列表长度即模型层数,结果会自动均衡各Stage总代价。 +2. **各层参数量**:`--pipeline_auto_layout`,根据 `TransformerConfig` 解析式计算每层参数量。 +3. **Profiler 统计**:先用 `--freq_generate_txt` / PROFILE_MODE 跑一次得到每层kernel实测耗时,再把每层耗时作为代价通过 `--pipeline_layer_costs` 传入,即可得到基于实测负载的布局建议。 + +```bash +# With imbalanced costs (first 4 layers light, last 8 heavy), PP=2 suggests 7,5 instead of uniform 6,6 +./build/infini_run --nproc_per_node=2 \ + ./build/gpt2 --device cuda --model d12 --pipeline_parallel 2 \ + --pipeline_layer_costs 1,1,1,1,2,2,2,2,2,2,2,2 + +# Or auto-suggest by per-layer parameter count (GPT-2 / LLaMA3 layers are identical, so it equals the uniform layout) +./build/infini_run --nproc_per_node=2 \ + ./build/gpt2 --device cuda --model d12 --pipeline_parallel 2 --pipeline_auto_layout +``` + +这三种方式与`--pipeline_layer_partition` 只能同时使用一种。建议结果会在启动阶段打印为 +`Auto-suggested pipeline layout ...: `;最终 `PipelineLayout` 仍按既有格式打印。核心算法见 +`SuggestBalancedPartition`,空代价(`{}`)即退化为按层数均匀划分。 + +## Pipeline负载分析 + +为证明自定义布局能改善负载不均衡场景,框架在运行结束时自动汇总一次Pipeline负载分析 +(`--pipeline_parallel > 1` 时):每个 PP rank 测量本Stage的前向 / 反向纯计算时间,经PP通信组 `AllGather` 汇总后由第一个Stage打印: + +```text +=== Pipeline Timing Summary (4 stages) === +Stage Fwd(ms) Bwd(ms) Total(ms) +0 12.3 9.1 21.4 +1 25.1 18.7 43.8 +2 24.9 18.5 43.4 +3 13.0 9.6 22.6 +Compute tasks per stage: 8 forward + 8 backward +Bottleneck stage: 43.8 ms | average: 32.8 ms +Load-imbalance bubble: 25.1% | pipeline efficiency: 74.9% +``` + +指标定义: + +- **各Stage执行时间**:该Stage在所有micro-batch上前向 / 反向纯计算时间的累计。 +- **Load-imbalance bubble**:`1 - average / bottleneck`,设总时间为t,则其中 `bottleneck = max_i(总时间_i)`、`average = mean_i(总时间_i)`;负载完全均衡时为0。 +- **pipeline efficiency**:`average / bottleneck`,即 `1 - bubble`。 +- **结构bubble**(fill/drain,GPipe):`(S-1)/(S-1+n)`,其中 `S` 为 Stage 数量(即 `--pipeline_parallel`),`n` 为 micro-batch 数量(即梯度累积步数);该开销来自流水线填充/排空阶段的空转,只取决于 `S` 与 `n`、与层如何划分无关,由 `ComputePipelineLoadAnalysis` 解析式给出。 +- **吞吐**:沿用训练时每步打印的 `tok/s`。 + +由于 GPT-2 / LLaMA3 各 Transformer 层结构相同,均匀按层数划分本身就是负载均衡的;真正体现“自定义布局改善负载不均”的是各层计算量不一致的场景。此时用 `--pipeline_layer_costs` 给出每层代价,`SuggestBalancedPartition` 给出均衡布局,`ComputePipelineLoadAnalysis` 可离线预测两种布局的对比: + +```cpp +std::vector costs{1,1,1,1, 2,2,2,2, 2,2,2,2}; // first 4 layers light, last 8 heavy +auto uniform = nn::parallel::ComputePipelineLoadAnalysis(12, 2, {6, 6}, costs, 8); +auto balanced = nn::parallel::ComputePipelineLoadAnalysis(12, 2, {7, 5}, costs, 8); +// uniform: bottleneck=12, bubble=16.7% +// balanced: bottleneck=10, bubble=0% +``` + +### 离线对比演示 + +`docs/pipeline_layout_demo.cc` 是可直接运行的纯CPU演示程序,用上面的`ComputePipelineLoadAnalysis` / `SuggestBalancedPartition` 打印“默认均匀布局 vs 自定义布局”的完整对比表: + +```bash +g++ -std=c++20 -DGLOG_USE_GLOG_EXPORT -I. -Ithird_party/glog/src -Ibuild/third_party/glog \ + docs/pipeline_layout_demo.cc infini_train/src/nn/parallel/pp/pipeline_layout.cc \ + -Lbuild/third_party/glog -lglog -pthread -o build/pipeline_layout_demo +LD_LIBRARY_PATH=build/third_party/glog ./build/pipeline_layout_demo +``` + +**输出**(负载不均模型,前 4 层轻、后 8 层重,S=2、n=8): + +```text +metric | uniform (6,6) | custom (7,5) +stage 0 time (load) | 8.000 | 10.000 +stage 1 time (load) | 12.000 | 10.000 +bottleneck (max) | 12.000 | 10.000 +imbalance bubble | 16.7% | 0.0% +structural bubble | 11.1% | 11.1% +pipeline efficiency | 83.3% | 100.0% +throughput (mb/t) | 0.0741 | 0.0889 +throughput speedup | - | 1.20x +``` + +**结论**:负载不均时,自定义均衡布局 `7,5` 把bottleneck从12降到10,imbalance bubble 从16.7%降到0, +吞吐提升**1.20x**;各层均匀时二者等价。 + +### 真实CUDA计时(≥2张GPU) + +真实运行是为了验证布局机制与bubble指标的端到端正确性,即自定义布局确实改变了“层 → Stage”映射, +且bubble/各Stage时间/吞吐随布局正确变化。 + +```bash +# default uniform layout (6,6) +./build/infini_run --nproc_per_node=2 ./build/gpt2 \ + --model d12 --input_bin data/gpt2/tiny_shakespeare_train.bin --pipeline_parallel 2 \ + --total_batch_size 2048 --num_iteration 10 --freq_generate_txt 1000 + +# custom layout: d12's 12 layers are structurally identical, so 7,5 here is a deliberately +# injected imbalance (costs 1,1,1,1,2,2,...,2 suggest 7,5) to prove the bubble metric tracks +# load changes, not that 7,5 is better. +./build/infini_run --nproc_per_node=2 ./build/gpt2 \ + --model d12 --input_bin data/gpt2/tiny_shakespeare_train.bin --pipeline_parallel 2 \ + --pipeline_layer_costs 1,1,1,1,2,2,2,2,2,2,2,2 \ + --total_batch_size 2048 --num_iteration 10 --freq_generate_txt 1000 +``` + +**实测结果**(2×RTX 4090): + +| 指标 | 默认均匀 `6,6` | 自定义 `7,5`(假代价) | +| -------------------- | ---------- | -------------- | +| Stage 0 Total (ms) | 1144.6 | 1184.6 | +| Stage 1 Total (ms) | 1025.7 | 769.2 | +| Bottleneck (ms) | 1144.6 | 1184.6 | +| **Imbalance bubble** | **5.2%** | **17.5%** | +| Pipeline efficiency | 94.8% | 82.5% | +| 稳态吞吐 (tok/s) | ~19800 | ~18300 | + +**结论**: + +(1)d12的12个Transformer层结构完全相同,因此默认均匀 `6,6` 本身就是负载均衡的最优解;人为注入 +`1,1,1,1,2,...` 代价让算法给出 `7,5`,bubble 升到 17.5%,意在人为制造不均衡。 +这证明**bubble指标与布局机制端到端工作正常**。 + +(2)“自定义布局**改善**负载不均”要求各层计算量本身不均。然而仓库现有gpt2/llama3层数均匀、异构的mixtral 未接入布局参数,故这一方向由上一节的解析式 `ComputePipelineLoadAnalysis` / `pipeline_layout_demo` 证明(bubble 16.7% → 0,吞吐 1.20x)。 +二者合起来即优秀标准「给出两种布局的 bubble / 各 Stage 执行时间 / 吞吐对比,并证明自定义布局改善 +负载不均场景」的完整证据链。 + +**补充实测:** + +`verify_pipeline_layout_correctness.sh` 的配置更小(batch=4, seq=64, total_batch=512, num_iteration=3),其输出的` Pipeline Timing Summary `中 7,5 布局 imbalance bubble 为 **fp32 20.1% / bf16 23.3%**(Stage 0/1 Total:fp32 220.5 / 131.8 ms,bf16 439.8 / 235.2 ms,Bottleneck 均为 Stage 0)。该配置与上表(`total_batch=2048`、10 步)不同,绝对时间与 bubble 比例不可直接逐项对比,但方向一致——7,5 的 Stage 0(7 层)显著重于 Stage 1(5 层),bubble 明显高于均匀布局,佐证 bubble 指标能正确反映负载变化。 + +## 输入输出示例 + +启动时若 `--pipeline_parallel > 1`,Stage 0 会打印最终布局: + +```text +PipelineLayout: num_stages=4, total_layers=24, vpp=1 + stage 0: [0, 4) + embedding + stage 1: [4, 12) + stage 2: [12, 18) + stage 3: [18, 24) + final_norm + lm_head +``` + +其中 `[start, end)` 表示本Stage持有的Transformer层区间;每个Stage在vPP下可能打印多个区间。 + +## 错误排查 + +以下非法配置会在启动阶段直接 `LOG(FATAL)` 终止,并给出可定位的报错信息: + +| 场景 | 触发条件 | 报错关键字 | +| ----------- | --------------------------------------------------------------------------------------- | --------------------------------------------- | +| 空项或非数字 | `4,,6,6` / `4,8a,6,6` | `not a positive integer` | +| 非正层数 | `4,0,6,6` | `must be positive` | +| Stage 数量不符 | `--pipeline_parallel 4` 但列表只有 3 项 | `entries but pipeline_parallel is` | +| 层数总和错误 | 24 层模型但 `4,8,6,5` | `sums to` | +| 与 vPP 冲突 | 自定义布局 + `--virtual_pipeline_parallel 2` | `incompatible with virtual_pipeline_parallel` | +| 代价为空项/非数字 | `1,,2` / `1,abc` | `empty entry` / `not a number` | +| 代价非负有限 | `-1,2` / `inf` | `non-negative` / `finite` | +| 代价条数与层数不符 | 12 层模型但代价只有 5 项 | `sums to` | +| 三种布局来源同时使用 | `--pipeline_layer_partition` 与 `--pipeline_layer_costs` / `--pipeline_auto_layout` 同时出现 | `cannot be combined with` | +| 代价与自动布局同时使用 | `--pipeline_layer_costs` 与 `--pipeline_auto_layout` 同时出现 | `mutually exclusive` | + +若报“模型构建与参数加载层归属不一致”,通常是因为某个调用点仍在使用旧的均匀划分:请确认 +模型构建(`TransformerModel`)、`PipelineParallel` 包装、两个 checkpoint loader 都改为查询 +`PipelineLayout` / `StageInfo`,且 `GetPipelineLayerPartition()` 已正确传入 `InitAllEnv`。 + +## 测试 + +**布局功能测试方法**: + +由两个单元测试文件共同覆盖。 + +(1)`tests/distributed/test_pipeline_layout.cc` 验证 `PipelineLayout`本身的行为: + +既检查不传参数时的默认均匀划分、按 `4,8,6,6` 显式自定义的划分,也检查Embedding归属第一个Stage、Final Norm 与 LM Head 归属最后一个Stage,以及 `StageOfLayer`/`OwnsLayer` 的层归属查询、vPP 模式下 chunk 的交错排列和 chunk 与 Stage 的映射关系;对空项、非数字、层数不符等非法配置,则用死亡断言确认程序会正确终止。 + +(2)`tests/distributed/test_pipeline_layout_suggest.cc` 验证自动布局建议与负载分析: + +`SuggestBalancedPartition` 对代价均匀、带余数、代价不均衡的输入能给出正确的分层结果、对非法代价能正确拒绝;`ParsePipelineLayerCosts` 能解析合法代价并拒绝负值、非数字、空项、无穷;`ComputePerLayerParamCounts` 对 GELU+LayerNorm 给出精确参数量、对 SwiGLU+RMSNorm 给出均匀正数、对 MoE 层拒绝计算;`ComputePipelineLoadAnalysis` 则确认均匀代价下均匀布局即均衡、代价不均衡时均匀布局会产生气泡而均衡布局能消除气泡、空 partition 退化为均匀划分、结构 bubble 公式`(S-1)/(S-1+n)` 计算正确。 + +```bash +cmake -S . -B build -DBUILD_TEST=ON +cmake --build build -j +ctest --test-dir build -R 'test_pipeline_layout' --output-on-failure +``` + +**端到端验证(2-Stage)方法**: + +用相同初始权重分别以单卡/默认布局与自定义布局跑若干训练迭代,比较前向结果、loss 与梯度在允许误差内一致,并确认训练过程无通信死锁。一键脚本 `scripts/verify_pipeline_layout_correctness.sh` 已封装该流程:用相同`--llmc_filepath` 权重与数据分别跑单卡(PP=1)与自定义 2-Stage(PP=2、`--pipeline_layer_partition 7,5`),再逐step对比 train loss(多step中loss 一致即说明前向、反向与梯度一致,任一环节偏差都会在后续step累积成loss发散): + +```bash +# scripts/verify_pipeline_layout_correctness.sh for convenience +bash scripts/verify_pipeline_layout_correctness.sh +DTYPE=bfloat16 bash scripts/verify_pipeline_layout_correctness.sh +``` + +**实测结果**(2×RTX 4090,`batch=4, seq=64, total_batch=512, num_iteration=3`,`7,5` 切分): + +| dtype | 单卡参考 loss(step 1/2/3) | 自定义 7,5 loss(step 1/2/3) | 最大绝对差 | 判定 | +| ----- | ------------------------------ | ------------------------------ | ----- | ----------- | +| fp32 | 5.250157 / 4.913959 / 5.018849 | 5.250157 / 4.913959 / 5.018850 | 1e-6 | pass(≤1e-5) | +| bf16 | 5.215456 / 4.905437 / 5.009968 | 5.215456 / 4.905437 / 5.009968 | 0 | pass(≤1e-2) | + +fp32 前两步与 bf16 全部三步逐位一致,说明 PP=2 的数据切分与 loss 平均(`sum/n`)和单卡的 +`sum/grad_accum_steps` 精确等价,等价于前向、反向、梯度三者一致。 + +## API 摘要 + +```cpp +namespace infini_train::nn::parallel { + +std::vector ParsePipelineLayerPartition(const std::string &str); +std::vector ParsePipelineLayerCosts(const std::string &str); +std::vector SuggestBalancedPartition(int total_layers, int num_stages, + const std::vector &layer_costs = {}); + +struct PipelineLoadStats { + int num_stages = 0; + int num_micro_batches = 1; + std::vector stage_loads; + double bottleneck = 0.0; + double average = 0.0; + double efficiency = 0.0; + double imbalance_bubble = 0.0; + double structural_bubble = 0.0; +}; +PipelineLoadStats ComputePipelineLoadAnalysis(int total_layers, int num_stages, + const std::vector &partition, + const std::vector &layer_costs = {}, + int num_micro_batches = 1); + +class PipelineLayout { +public: + static PipelineLayout Create(int total_layers, int num_stages, int vpp_size, + const std::vector &partition = {}); + StageInfo GetStageInfo(int stage_id) const; + int StageOfLayer(int layer_id) const; + bool OwnsLayer(int stage_id, int layer_id) const; + static int StageOfChunk(int global_chunk_id, int num_stages); + static int LocalChunkIndexOfChunk(int global_chunk_id, int num_stages); + std::string Describe() const; +}; + +} // namespace infini_train::nn::parallel +``` + +- `StageInfo` 包含 `is_first_stage`、`is_last_stage` 与 `layer_ranges_per_chunk` + (每个 chunk 一个 `(start, end)` 区间)。 +- `Create` 是统一布局入口:空 `partition` 走默认均匀划分,否则按显式层数构建并做完整校验。 +- `GetStageInfo` 供模型构建 / `PipelineParallel` / checkpoint loader 使用; + `StageOfChunk` / `LocalChunkIndexOfChunk` 供调度器统一计算 chunk 归属。 +- `ParsePipelineLayerCosts` 解析 `--pipeline_layer_costs`;`SuggestBalancedPartition` 用线性划分 DP + 给出近似负载均衡的层数列表(空代价退化为均匀划分)。 +- `ComputePipelineLoadAnalysis` 离线计算给定布局的各 Stage 负载、bottleneck / average、 + `efficiency`、`imbalance_bubble` 与 `structural_bubble`,用于对比均匀布局与自定义布局。 +- `nn::ComputePerLayerParamCounts(const TransformerConfig&)`(`transformer.h`)解析式计算每层参数量, + 供 `--pipeline_auto_layout` 使用;MoE 层不支持。 +- `nn::parallel::PipelineParallel::ReportPipelineStats()` 在训练结束后由 PP 通信组汇总各 Stage 实测 + 前向/反向时间并打印 `Pipeline Timing Summary`(仅 `--pipeline_parallel > 1` 时生效,`rank_==0` 打印)。 diff --git a/example/gpt2/checkpoint_loader.cc b/example/gpt2/checkpoint_loader.cc index 95e54730b..45e92dba2 100644 --- a/example/gpt2/checkpoint_loader.cc +++ b/example/gpt2/checkpoint_loader.cc @@ -18,6 +18,7 @@ #include "infini_train/include/nn/modules/transformer/mlp.h" #include "infini_train/include/nn/modules/transformer/transformer.h" #include "infini_train/include/nn/parallel/global.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" #include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" #include "infini_train/include/nn/parallel/tensor_parallel.h" #include "infini_train/include/tensor.h" @@ -99,15 +100,16 @@ std::shared_ptr LoadFromLLMC(const std::string &filepath) CHECK_EQ(n_embd % n_head, 0) << "n_embd must be divisible by n_head."; CHECK_EQ(n_head % tp_size, 0) << "n_head must be divisible by TP world size."; - // ========== pp_size:num_stages; vpp_size: num_chunks_per_stage ========== + // Unified pipeline layout: which layers / special modules this rank owns. int pp_size = nn::parallel::global::GetPipelineParallelSize(); - int vpp_size = nn::parallel::global::GetVirtualPipelineParallelSize(); - auto pp_rank = nn::parallel::pp_rank; - auto [is_first_stage, is_last_stage, layer_ranges_per_chunk] - = nn::parallel::PipelineParallel::GetStageInfo(n_layer, pp_size, pp_rank, vpp_size); - // ========== layer to chunk ========== + auto layout = nn::parallel::PipelineLayout::Create(static_cast(n_layer), pp_size, + nn::parallel::global::GetVirtualPipelineParallelSize(), + nn::parallel::global::GetPipelineLayerPartition()); + const auto stage_info = layout.GetStageInfo(nn::parallel::pp_rank); + const bool is_first_stage = stage_info.is_first_stage; + const bool is_last_stage = stage_info.is_last_stage; std::vector owned_layers(n_layer, false); - for (const auto &[start, end] : layer_ranges_per_chunk) { + for (const auto &[start, end] : stage_info.layer_ranges_per_chunk) { for (int i = start; i < end; ++i) { owned_layers[i] = true; } } diff --git a/example/gpt2/main.cc b/example/gpt2/main.cc index 2551880e7..c6803a4ce 100644 --- a/example/gpt2/main.cc +++ b/example/gpt2/main.cc @@ -24,6 +24,7 @@ #include "infini_train/include/nn/parallel/ddp/distributed_optimizer.h" #include "infini_train/include/nn/parallel/global.h" #include "infini_train/include/nn/parallel/parallel_functional.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" #include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" #include "infini_train/include/nn/parallel/rank.h" #include "infini_train/include/nn/parallel/reduce_op_type.h" @@ -85,6 +86,12 @@ DEFINE_uint32(tensor_parallel, 1, "Tensor Parallel world size"); DEFINE_bool(sequence_parallel, false, "Whether to enable Sequence Parallel"); DEFINE_uint32(pipeline_parallel, 1, "Pipeline Parallel world size, specified the number of PP stages."); DEFINE_uint32(virtual_pipeline_parallel, 1, "Number of chunks in PP stage."); +DEFINE_string(pipeline_layer_partition, "", + "comma-separated per-stage layer counts for a custom pipeline layout, e.g. 4,8,6,6"); +DEFINE_string(pipeline_layer_costs, "", + "comma-separated per-layer compute costs used to auto-suggest a balanced layout, e.g. 1,2,1.5"); +DEFINE_bool(pipeline_auto_layout, false, + "auto-suggest a load-balanced pipeline layout using per-layer parameter counts"); // precision DEFINE_string(dtype, "float32", "precision used in training (float32/bfloat16)"); @@ -126,6 +133,27 @@ const std::unordered_map kModelToConfigs = { {"d48", {.block_size = 1024, .vocab_size = 50257, .n_layer = 48, .n_head = 25, .n_embd = 1600}}, }; +std::string PartitionToString(const std::vector &partition) { + std::string s; + for (size_t i = 0; i < partition.size(); ++i) { + if (i > 0) { + s += ","; + } + s += std::to_string(partition[i]); + } + return s; +} + +nn::TransformerConfig ResolveGPT2Config() { + if (!kModelToConfigs.count(FLAGS_model)) { + LOG(FATAL) << "--pipeline_auto_layout requires a config-map model (--model d12/d24/d36/d48); '" << FLAGS_model + << "' has no static config"; + } + nn::TransformerConfig config = kModelToConfigs.at(FLAGS_model); + gpt2::SanitizeGPT2Config(config); + return config; +} + } // namespace DEFINE_validator(model, [](const char *, const std::string &value) { return kSupportedModels.contains(value); }); @@ -142,14 +170,12 @@ void Train(const nn::parallel::Rank &rank) { if (rank.IsLastRank()) { if (!FLAGS_save.empty() && FLAGS_save_interval == 0) { LOG(FATAL) << "Invalid configuration: --save is set ('" << FLAGS_save - << "'), but --save_interval is 0. " - << "They must be set together."; + << "'), but --save_interval is 0. " << "They must be set together."; } if (FLAGS_save.empty() && FLAGS_save_interval > 0) { LOG(FATAL) << "Invalid configuration: --save_interval is set to " << FLAGS_save_interval - << ", but --save is empty. " - << "They must be set together."; + << ", but --save is empty. " << "They must be set together."; } } } @@ -287,7 +313,7 @@ void Train(const nn::parallel::Rank &rank) { {FLAGS_batch_size, FLAGS_sequence_length / sp_world_size, model_config.n_embd}}; model = std::make_shared(model, pp_world_size, num_micro_batches, shapes, - pp_rank, device, model_config.GetChunkSize()); + pp_rank, device, gpt2_model->stage_info()); if (ddp_world_size > 1) { auto ddp_config = DistributedDataParallelConfig{.zero_stage = FLAGS_zero_stage}; auto *mutable_chunks = dynamic_cast(model.get())->mutable_chunks(); @@ -364,7 +390,7 @@ void Train(const nn::parallel::Rank &rank) { auto train_iter = train_loader.begin(); std::shared_ptr loss_fn = (tp_world_size > 1) ? std::static_pointer_cast( - std::make_shared(model_config.original_vocab_size)) + std::make_shared(model_config.original_vocab_size)) : std::static_pointer_cast(std::make_shared()); loss_fn->To(device); LOG(INFO) << "Rank " << rank.GlobalRank() << ": start training"; @@ -553,6 +579,11 @@ void Train(const nn::parallel::Rank &rank) { } } + // Print per-stage execution time, load-imbalance bubble and pipeline efficiency. + if (pp_world_size > 1) { + dynamic_cast(model.get())->ReportPipelineStats(); + } + // Save LoRA weights if enabled and path specified if (lora_enabled && !FLAGS_lora_save_path.empty()) { LOG(INFO) << "Saving LoRA weights to: " << FLAGS_lora_save_path; @@ -570,8 +601,35 @@ int main(int argc, char *argv[]) { google::InitGoogleLogging(argv[0]); auto precision_config = utils::PrecisionCheckConfig::Parse(FLAGS_precision_check); + + const bool has_explicit_partition = !FLAGS_pipeline_layer_partition.empty(); + const bool has_layer_costs = !FLAGS_pipeline_layer_costs.empty(); + CHECK(!(has_explicit_partition && (has_layer_costs || FLAGS_pipeline_auto_layout))) + << "--pipeline_layer_partition cannot be combined with --pipeline_layer_costs or --pipeline_auto_layout"; + CHECK(!(has_layer_costs && FLAGS_pipeline_auto_layout)) + << "--pipeline_layer_costs and --pipeline_auto_layout are mutually exclusive"; + + std::vector pipeline_layer_partition; + if (has_explicit_partition) { + pipeline_layer_partition = nn::parallel::ParsePipelineLayerPartition(FLAGS_pipeline_layer_partition); + } else if (has_layer_costs) { + const auto layer_costs = nn::parallel::ParsePipelineLayerCosts(FLAGS_pipeline_layer_costs); + pipeline_layer_partition = nn::parallel::SuggestBalancedPartition(static_cast(layer_costs.size()), + FLAGS_pipeline_parallel, layer_costs); + LOG(INFO) << "Auto-suggested pipeline layout from --pipeline_layer_costs: " + << PartitionToString(pipeline_layer_partition); + } else if (FLAGS_pipeline_auto_layout) { + const auto config = ResolveGPT2Config(); + const auto layer_costs = nn::ComputePerLayerParamCounts(config); + pipeline_layer_partition = nn::parallel::SuggestBalancedPartition(static_cast(config.n_layer), + FLAGS_pipeline_parallel, layer_costs); + LOG(INFO) << "Auto-suggested pipeline layout from per-layer parameter counts: " + << PartitionToString(pipeline_layer_partition); + } + nn::parallel::global::InitAllEnv(FLAGS_nthread_per_process, FLAGS_tensor_parallel, FLAGS_sequence_parallel, - FLAGS_pipeline_parallel, FLAGS_virtual_pipeline_parallel); + FLAGS_pipeline_parallel, FLAGS_virtual_pipeline_parallel, + pipeline_layer_partition); utils::PrecisionCheckEnv::Instance().Init(precision_config); LOG(INFO) << nn::parallel::global::ProcessGroupOverview(); diff --git a/example/llama3/checkpoint_loader.cc b/example/llama3/checkpoint_loader.cc index f3590af6e..b9917625c 100644 --- a/example/llama3/checkpoint_loader.cc +++ b/example/llama3/checkpoint_loader.cc @@ -17,6 +17,8 @@ #include "infini_train/include/nn/modules/transformer/mlp.h" #include "infini_train/include/nn/modules/transformer/transformer.h" #include "infini_train/include/nn/parallel/global.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" +#include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" #include "infini_train/include/nn/parallel/tensor_parallel.h" #include "infini_train/include/tensor.h" @@ -84,13 +86,14 @@ std::shared_ptr LoadFromLLMC(const std::string &filepath) llama3::SanitizeLLaMA3Config(llama3_config); auto llama3 = std::make_shared(llama3_config); - // ========== pp_size:num_stages; vpp_size: num_chunks_per_stage ========== - int pp_size = nn::parallel::global::GetPipelineParallelSize(); - int vpp_size = nn::parallel::global::GetVirtualPipelineParallelSize(); - auto pp_rank = nn::parallel::pp_rank; - auto [is_first_stage, is_last_stage, layer_ranges_per_chunk] - = nn::parallel::PipelineParallel::GetStageInfo(n_layer, pp_size, pp_rank, vpp_size); - // ========== layer to chunk ========== + // Unified pipeline layout: which layers / special modules this rank owns. + auto layout = nn::parallel::PipelineLayout::Create( + static_cast(n_layer), nn::parallel::global::GetPipelineParallelSize(), + nn::parallel::global::GetVirtualPipelineParallelSize(), nn::parallel::global::GetPipelineLayerPartition()); + const auto stage_info = layout.GetStageInfo(nn::parallel::pp_rank); + const bool is_first_stage = stage_info.is_first_stage; + const bool is_last_stage = stage_info.is_last_stage; + const auto &layer_ranges_per_chunk = stage_info.layer_ranges_per_chunk; std::vector owned_layers(n_layer, false); for (const auto &[start, end] : layer_ranges_per_chunk) { for (int i = start; i < end; ++i) { owned_layers[i] = true; } diff --git a/example/llama3/main.cc b/example/llama3/main.cc index ccfca86a2..bb2b93e01 100644 --- a/example/llama3/main.cc +++ b/example/llama3/main.cc @@ -23,6 +23,7 @@ #include "infini_train/include/nn/parallel/ddp/distributed_optimizer.h" #include "infini_train/include/nn/parallel/global.h" #include "infini_train/include/nn/parallel/parallel_functional.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" #include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" #include "infini_train/include/nn/parallel/process_group.h" #include "infini_train/include/nn/parallel/rank.h" @@ -84,6 +85,12 @@ DEFINE_uint32(tensor_parallel, 1, "Tensor Parallel world size"); DEFINE_bool(sequence_parallel, false, "Whether to enable Sequence Parallel"); DEFINE_uint32(pipeline_parallel, 1, "Pipeline Parallel world size, specified the number of PP stages."); DEFINE_uint32(virtual_pipeline_parallel, 1, "Number of chunks in PP stage."); +DEFINE_string(pipeline_layer_partition, "", + "comma-separated per-stage layer counts for a custom pipeline layout, e.g. 4,8,6,6"); +DEFINE_string(pipeline_layer_costs, "", + "comma-separated per-layer compute costs used to auto-suggest a balanced layout, e.g. 1,2,1.5"); +DEFINE_bool(pipeline_auto_layout, false, + "auto-suggest a load-balanced pipeline layout using per-layer parameter counts"); // precision DEFINE_string(dtype, "float32", "precision used in training (float32/bfloat16)"); DEFINE_uint32(save_interval, 0, "save checkpoint every N steps; 0 disables saving"); @@ -115,6 +122,24 @@ constexpr char kDtypeFP32[] = "float32"; constexpr char kDtypeBF16[] = "bfloat16"; const std::unordered_set kSupportedLRDecayStyles = {"none", "constant", "linear", "cosine", "inverse-square-root"}; + +std::string PartitionToString(const std::vector &partition) { + std::string s; + for (size_t i = 0; i < partition.size(); ++i) { + if (i > 0) { + s += ","; + } + s += std::to_string(partition[i]); + } + return s; +} + +nn::TransformerConfig ResolveLLaMA3Config() { + nn::TransformerConfig config = llama3::LLaMA3Config(); + llama3::SanitizeLLaMA3Config(config); + return config; +} + } // namespace DEFINE_validator(model, [](const char *, const std::string &value) { return kSupportedModels.contains(value); }); @@ -131,14 +156,12 @@ void Train(const nn::parallel::Rank &rank) { if (rank.IsLastRank()) { if (!FLAGS_save.empty() && FLAGS_save_interval == 0) { LOG(FATAL) << "Invalid configuration: --save is set ('" << FLAGS_save - << "'), but --save_interval is 0. " - << "They must be set together."; + << "'), but --save_interval is 0. " << "They must be set together."; } if (FLAGS_save.empty() && FLAGS_save_interval > 0) { LOG(FATAL) << "Invalid configuration: --save_interval is set to " << FLAGS_save_interval - << ", but --save is empty. " - << "They must be set together."; + << ", but --save is empty. " << "They must be set together."; } } } @@ -221,6 +244,10 @@ void Train(const nn::parallel::Rank &rank) { utils::PrecisionChecker::BuildNameMap(model.get()); + // Cache the transformer stage info before wrapping with LoRA / PipelineParallel. + auto llama_model = std::dynamic_pointer_cast(model); + CHECK(llama_model) << "LLaMA3 example expects a TransformerModel."; + // Apply LoRA using GetLoRAModel (in-place injection) bool lora_enabled = FLAGS_lora_rank > 0; if (lora_enabled) { @@ -260,7 +287,7 @@ void Train(const nn::parallel::Rank &rank) { {FLAGS_batch_size, FLAGS_sequence_length / sp_world_size, model_config.n_embd}}; model = std::make_shared(model, pp_world_size, num_micro_batches, shapes, - pp_rank, device, model_config.GetChunkSize()); + pp_rank, device, llama_model->stage_info()); if (ddp_world_size > 1) { auto ddp_config = DistributedDataParallelConfig{.zero_stage = FLAGS_zero_stage}; auto *mutable_chunks = dynamic_cast(model.get())->mutable_chunks(); @@ -532,6 +559,11 @@ void Train(const nn::parallel::Rank &rank) { } } + // Print per-stage execution time, load-imbalance bubble and pipeline efficiency. + if (pp_world_size > 1) { + dynamic_cast(model.get())->ReportPipelineStats(); + } + // Save LoRA weights if enabled and path specified if (lora_enabled && !FLAGS_lora_save_path.empty()) { LOG(INFO) << "Saving LoRA weights to: " << FLAGS_lora_save_path; @@ -549,8 +581,35 @@ int main(int argc, char *argv[]) { google::InitGoogleLogging(argv[0]); auto precision_config = utils::PrecisionCheckConfig::Parse(FLAGS_precision_check); + + const bool has_explicit_partition = !FLAGS_pipeline_layer_partition.empty(); + const bool has_layer_costs = !FLAGS_pipeline_layer_costs.empty(); + CHECK(!(has_explicit_partition && (has_layer_costs || FLAGS_pipeline_auto_layout))) + << "--pipeline_layer_partition cannot be combined with --pipeline_layer_costs or --pipeline_auto_layout"; + CHECK(!(has_layer_costs && FLAGS_pipeline_auto_layout)) + << "--pipeline_layer_costs and --pipeline_auto_layout are mutually exclusive"; + + std::vector pipeline_layer_partition; + if (has_explicit_partition) { + pipeline_layer_partition = nn::parallel::ParsePipelineLayerPartition(FLAGS_pipeline_layer_partition); + } else if (has_layer_costs) { + const auto layer_costs = nn::parallel::ParsePipelineLayerCosts(FLAGS_pipeline_layer_costs); + pipeline_layer_partition = nn::parallel::SuggestBalancedPartition(static_cast(layer_costs.size()), + FLAGS_pipeline_parallel, layer_costs); + LOG(INFO) << "Auto-suggested pipeline layout from --pipeline_layer_costs: " + << PartitionToString(pipeline_layer_partition); + } else if (FLAGS_pipeline_auto_layout) { + const auto config = ResolveLLaMA3Config(); + const auto layer_costs = nn::ComputePerLayerParamCounts(config); + pipeline_layer_partition = nn::parallel::SuggestBalancedPartition(static_cast(config.n_layer), + FLAGS_pipeline_parallel, layer_costs); + LOG(INFO) << "Auto-suggested pipeline layout from per-layer parameter counts: " + << PartitionToString(pipeline_layer_partition); + } + nn::parallel::global::InitAllEnv(FLAGS_nthread_per_process, FLAGS_tensor_parallel, FLAGS_sequence_parallel, - FLAGS_pipeline_parallel, FLAGS_virtual_pipeline_parallel); + FLAGS_pipeline_parallel, FLAGS_virtual_pipeline_parallel, + pipeline_layer_partition); utils::PrecisionCheckEnv::Instance().Init(precision_config); LOG(INFO) << nn::parallel::global::ProcessGroupOverview(); diff --git a/infini_train/include/nn/modules/transformer/transformer.h b/infini_train/include/nn/modules/transformer/transformer.h index 0471c32fe..b121f4749 100644 --- a/infini_train/include/nn/modules/transformer/transformer.h +++ b/infini_train/include/nn/modules/transformer/transformer.h @@ -4,7 +4,7 @@ #include "infini_train/include/nn/modules/module.h" #include "infini_train/include/nn/modules/transformer/transformer_config.h" -#include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" namespace infini_train::nn { class TransformerLayer : public CloneableModule { @@ -77,10 +77,19 @@ class TransformerModel : public CloneableModule { Forward(const std::vector> &x) override; const TransformerConfig &Config() const { return config_; } + const infini_train::nn::parallel::StageInfo &stage_info() const { return stage_info_; } private: const TransformerConfig config_; + const infini_train::nn::parallel::PipelineLayout layout_; const infini_train::nn::parallel::StageInfo stage_info_; }; +// Returns `n_layer` per-layer parameter counts (the number of trainable scalar +// parameters in each Transformer block), computed analytically from `config` so a balanced +// pipeline layout can be suggested before the model is built. For standard homogeneous +// GPT-2 / LLaMA3 blocks every entry is equal; MoE blocks are not supported by this helper +// (pass --pipeline_layer_costs for those). +std::vector ComputePerLayerParamCounts(const TransformerConfig &config); + } // namespace infini_train::nn diff --git a/infini_train/include/nn/modules/transformer/transformer_config.h b/infini_train/include/nn/modules/transformer/transformer_config.h index a646ab14e..531dd8c31 100644 --- a/infini_train/include/nn/modules/transformer/transformer_config.h +++ b/infini_train/include/nn/modules/transformer/transformer_config.h @@ -93,6 +93,5 @@ struct TransformerConfig { int64_t max_gen_batch_size = 4; // max batch size during inference bool UseGQA() const; - int GetChunkSize() const; }; } // namespace infini_train::nn diff --git a/infini_train/include/nn/parallel/global.h b/infini_train/include/nn/parallel/global.h index 38694a91d..fe92f3a8d 100644 --- a/infini_train/include/nn/parallel/global.h +++ b/infini_train/include/nn/parallel/global.h @@ -29,7 +29,8 @@ class GlobalEnv { static GlobalEnv &Instance(); void Init(int threads_per_process, int tensor_parallel_size, bool sequence_parallel_enabled, - int pipeline_parallel_size, int virtual_pipeline_parallel_size); + int pipeline_parallel_size, int virtual_pipeline_parallel_size, + const std::vector &pipeline_layer_partition = {}); int nnodes() const; @@ -55,6 +56,8 @@ class GlobalEnv { int virtual_pipeline_parallel_size() const; + const std::vector &pipeline_layer_partition() const; + Layout layout() const; private: @@ -80,6 +83,7 @@ class GlobalEnv { int pipeline_parallel_size_ = 1; int virtual_pipeline_parallel_size_ = 1; + std::vector pipeline_layer_partition_; mutable std::mutex mutex_; bool initialized_ = false; @@ -88,9 +92,10 @@ class GlobalEnv { }; inline void InitAllEnv(int nthread_per_process, int tensor_parallel_size, bool sequence_parallel_enabled, - int pipeline_parallel_size, int virtual_pipeline_parallel) { + int pipeline_parallel_size, int virtual_pipeline_parallel, + const std::vector &pipeline_layer_partition = {}) { GlobalEnv::Instance().Init(nthread_per_process, tensor_parallel_size, sequence_parallel_enabled, - pipeline_parallel_size, virtual_pipeline_parallel); + pipeline_parallel_size, virtual_pipeline_parallel, pipeline_layer_partition); } inline int GetNnodes() { return GlobalEnv::Instance().nnodes(); } inline int GetWorldSize() { return GlobalEnv::Instance().world_size(); } @@ -106,6 +111,7 @@ inline bool GetSequenceParallelEnabled() { return GlobalEnv::Instance().sequence inline int GetDataParallelSize() { return GlobalEnv::Instance().data_parallel_size(); } inline int GetPipelineParallelSize() { return GlobalEnv::Instance().pipeline_parallel_size(); } inline int GetVirtualPipelineParallelSize() { return GlobalEnv::Instance().virtual_pipeline_parallel_size(); } +inline const std::vector &GetPipelineLayerPartition() { return GlobalEnv::Instance().pipeline_layer_partition(); } // ========================= // Layout Helper Functions diff --git a/infini_train/include/nn/parallel/pp/pipeline_layout.h b/infini_train/include/nn/parallel/pp/pipeline_layout.h new file mode 100644 index 000000000..281e44836 --- /dev/null +++ b/infini_train/include/nn/parallel/pp/pipeline_layout.h @@ -0,0 +1,88 @@ +#pragma once + +#include +#include +#include + +namespace infini_train::nn::parallel { + +// Describes which layers (and special modules) belong to a single pipeline stage. +struct StageInfo { + bool is_first_stage = false; // this stage owns the Embedding (first-stage module) + bool is_last_stage = false; // this stage owns the Final Norm and LM Head + // Layer index ranges assigned to this stage, one per (virtual) chunk: + // (inclusive_start_layer, exclusive_end_layer). + std::vector> layer_ranges_per_chunk; +}; + +// Unified source of truth for the pipeline layer partition. Model construction, +// pipeline stage wrapping and parameter loading all query the same layout so the +// layer-ownership logic is not duplicated across modules. +class PipelineLayout { +public: + // Build a layout. `partition` holds the number of transformer layers per stage + // (e.g. {4, 8, 6, 6}); when empty, fall back to the default uniform partition. + static PipelineLayout Create(int total_layers, int num_stages, int vpp_size, + const std::vector &partition = {}); + + StageInfo GetStageInfo(int stage_id) const; + int StageOfLayer(int layer_id) const; + bool OwnsLayer(int stage_id, int layer_id) const; + + // Round-robin chunk -> stage / local-chunk mapping used by the pipeline scheduler. + static int StageOfChunk(int global_chunk_id, int num_stages); + static int LocalChunkIndexOfChunk(int global_chunk_id, int num_stages); + + std::string Describe() const; + +private: + int num_stages_ = 1; + int total_layers_ = 0; + int vpp_size_ = 1; + int first_stage_idx_ = 0; // stage owning the Embedding + int last_stage_idx_ = 0; // stage owning the Final Norm + LM Head + std::vector>> stage_layer_ranges_; + std::vector layer_to_stage_; +}; + +// Parse a comma-separated per-stage layer count string ("4,8,6,6"). Returns an empty +// vector when `str` is empty, meaning "use the default uniform partition". +std::vector ParsePipelineLayerPartition(const std::string &str); + +// Parse a comma-separated list of non-negative per-layer compute costs ("1.0,2.0,1.5"). +// These feed `SuggestBalancedPartition` as the `layer_costs` argument. Returns an empty +// vector when `str` is empty, meaning "no user-provided costs". +std::vector ParsePipelineLayerCosts(const std::string &str); + +// Suggest a contiguous per-stage layer partition that approximately minimizes the maximum +// per-stage cost (the classic linear partition problem). `layer_costs[i]` is the cost of +// layer i (e.g. parameter count, measured compute time, or any user-provided cost); when +// empty, every layer is treated as unit cost, i.e. balanced by layer count. Returns +// `num_stages` positive counts that sum to `total_layers`, suitable as the `partition` +// argument of PipelineLayout::Create. +std::vector SuggestBalancedPartition(int total_layers, int num_stages, + const std::vector &layer_costs = {}); + +// Predicted load / bubble / throughput analysis for a contiguous per-stage partition. This is +// a pure function used to compare layouts (e.g. default uniform vs. a cost-balanced layout) +// without running the model: per-stage time is assumed proportional to the sum of the per-layer +// costs assigned to that stage. +struct PipelineLoadStats { + int num_stages = 0; + int num_micro_batches = 1; + std::vector stage_loads; // predicted per-stage time (sum of assigned layer costs) + double bottleneck = 0.0; // max stage load (sets the pipeline clock period) + double average = 0.0; // mean stage load + double efficiency = 0.0; // average / bottleneck == 1 - imbalance_bubble + double imbalance_bubble = 0.0; // 1 - average / bottleneck, idle time caused by load skew + double structural_bubble = 0.0; // (S-1)/(S-1+n), GPipe fill/drain overhead +}; + +// Compute the load analysis for `partition` (per-stage layer counts summing to `total_layers`; +// when empty, fall back to the default uniform partition). `layer_costs[i]` is the compute cost +// of layer i; when empty, every layer has unit cost (load == layer count). `num_micro_batches` +// only affects the structural fill/drain bubble. +PipelineLoadStats ComputePipelineLoadAnalysis(int total_layers, int num_stages, const std::vector &partition, + const std::vector &layer_costs = {}, int num_micro_batches = 1); + +} // namespace infini_train::nn::parallel diff --git a/infini_train/include/nn/parallel/pp/pipeline_parallel.h b/infini_train/include/nn/parallel/pp/pipeline_parallel.h index 25939bdc2..7626bfd97 100644 --- a/infini_train/include/nn/parallel/pp/pipeline_parallel.h +++ b/infini_train/include/nn/parallel/pp/pipeline_parallel.h @@ -5,6 +5,7 @@ #include #include "infini_train/include/nn/modules/module.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" namespace infini_train { class Tensor; @@ -18,28 +19,24 @@ class PipelineSchedule; extern thread_local int pp_rank; -struct StageInfo { - bool is_first_stage; - bool is_last_stage; - - // Layer index ranges for chunks assigned to this pipeline stage. - // Each element is a pair: (inclusive_start_layer, exclusive_end_layer) - std::vector> layer_ranges_per_chunk; -}; - class PipelineParallel : public Module { public: PipelineParallel(const std::shared_ptr module, int num_stages, int num_micro_batches, - const std::vector> &recv_shape, int rank, Device device, int vpp); + const std::vector> &recv_shape, int rank, Device device, + const StageInfo &stage_info); float TrainStep(const std::vector> &input, const std::vector> &target, const std::shared_ptr &optimizer, const std::shared_ptr &loss_fn, DataType dtype) override; - static StageInfo GetStageInfo(int total_layers, int pp_size, int pp_rank, int chunks_per_stage = 1); - std::vector> *mutable_chunks(); + // Gather per-stage forward/backward compute times across the pipeline process group and + // print a summary of per-stage execution time, measured load-imbalance bubble and pipeline + // efficiency. This is a collective over the pipeline group (all PP ranks must call it); only + // the first pipeline rank prints. No-op when num_stages <= 1. + void ReportPipelineStats(); + private: void BuildPipelineStage(const std::vector> &recv_shape, Device device, std::vector> &&chunks); diff --git a/infini_train/include/nn/parallel/pp/pipeline_schedule.h b/infini_train/include/nn/parallel/pp/pipeline_schedule.h index 053650d7c..ca8a3e9fe 100644 --- a/infini_train/include/nn/parallel/pp/pipeline_schedule.h +++ b/infini_train/include/nn/parallel/pp/pipeline_schedule.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include @@ -17,12 +18,13 @@ namespace infini_train::nn::parallel { class PipelineStage; +class StageTimer; + class PipelineSchedule { public: - PipelineSchedule(std::shared_ptr stage, int num_stages, int num_micro_batches) - : stage_(std::move(stage)), num_micro_batches_(num_micro_batches) {} + PipelineSchedule(std::shared_ptr stage, int num_stages, int num_micro_batches); - virtual ~PipelineSchedule() = default; + virtual ~PipelineSchedule(); float Step(std::shared_ptr input, std::shared_ptr target, const std::shared_ptr &optimizer, const std::shared_ptr &loss_fn, DataType dtype); @@ -34,9 +36,17 @@ class PipelineSchedule { std::vector> ReceiveFromPrev(int peer_rank); std::vector> SendToNext(const std::vector> &tensors, int peer_rank); + // Accumulated per-stage compute time (forward / backward) in seconds, measured across all + // `StepMicroBatches` calls since construction. Used by PipelineParallel::ReportPipelineStats. + double ForwardSeconds() const; + double BackwardSeconds() const; + int64_t ForwardTaskCount() const; + int64_t BackwardTaskCount() const; + protected: int num_micro_batches_ = -1; std::shared_ptr stage_ = nullptr; + std::unique_ptr timer_; }; class PipelineParallelScheduler { diff --git a/infini_train/src/nn/modules/transformer/transformer.cc b/infini_train/src/nn/modules/transformer/transformer.cc index 99a739d2d..a61c38e56 100644 --- a/infini_train/src/nn/modules/transformer/transformer.cc +++ b/infini_train/src/nn/modules/transformer/transformer.cc @@ -18,6 +18,8 @@ #include "infini_train/include/nn/modules/transformer/moe/moe_layer.h" #include "infini_train/include/nn/modules/transformer/utils.h" #include "infini_train/include/nn/parallel/global.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" +#include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" #include "infini_train/include/nn/parallel/tensor_parallel.h" #include "infini_train/include/nn/parallel/utils.h" #include "infini_train/include/tensor.h" @@ -206,9 +208,14 @@ std::vector> TransformerLastStage::Forward(const std::ve TransformerModel::TransformerModel(const TransformerConfig config) : CloneableModule(kType), config_(config), - stage_info_(nn::parallel::PipelineParallel::GetStageInfo( - config_.n_layer, nn::parallel::global::GetPipelineParallelSize(), nn::parallel::pp_rank, - nn::parallel::global::GetVirtualPipelineParallelSize())) { + layout_(nn::parallel::PipelineLayout::Create( + static_cast(config_.n_layer), nn::parallel::global::GetPipelineParallelSize(), + nn::parallel::global::GetVirtualPipelineParallelSize(), nn::parallel::global::GetPipelineLayerPartition())), + stage_info_(layout_.GetStageInfo(nn::parallel::pp_rank)) { + if (nn::parallel::global::GetPipelineParallelSize() > 1 && nn::parallel::pp_rank == 0) { + LOG(INFO) << layout_.Describe(); + } + auto tp_world_size = nn::parallel::global::GetTensorParallelSize(); // NOTE(zbl): VocabParallelEmbedding requires vocab_size % tp_size == 0 @@ -282,4 +289,53 @@ std::vector> TransformerModel::Forward(const std::vector return res; } +namespace { +// Mirror of MLP::MLP's hidden-dimension computation so the analytic per-layer parameter +// count matches the module that will actually be constructed. +int64_t FfnHiddenDim(const TransformerConfig &config) { + int64_t ffn_hidden = static_cast(config.n_embd * config.ffn_expansion_ratio); + if (config.activation_type == MLPType::kSwiGLU) { + ffn_hidden = static_cast(2 * ffn_hidden) / 3; // SwiGLU intermediate + } + if (config.ffn_dim_multiplier.has_value()) { + ffn_hidden + = static_cast(std::llround(static_cast(ffn_hidden) * config.ffn_dim_multiplier.value())); + } + ffn_hidden = (ffn_hidden + config.multiple_of - 1) / config.multiple_of * config.multiple_of; + return ffn_hidden; +} +} // namespace + +std::vector ComputePerLayerParamCounts(const TransformerConfig &config) { + CHECK_GT(config.n_layer, 0) << "n_layer must be positive"; + CHECK(config.ffn_type == FFNType::kDense) + << "ComputePerLayerParamCounts does not support MoE layers; pass --pipeline_layer_costs instead"; + + const int64_t n_embd = config.n_embd; + const int64_t head_dim = n_embd / config.n_head; + const int64_t qkv_dim = (config.n_head + 2 * config.n_kv_head) * head_dim; + const int64_t ffn_hidden = FfnHiddenDim(config); + + // LayerNorm contributes weight + bias (2 * n_embd); RMSNorm contributes weight only. + const int64_t norm_params = (config.norm_type == NormType::kLayerNorm) ? 2 * n_embd : n_embd; + + // Full (unsharded) parameters of a Linear(in, out) with optional bias. + auto linear_params = [&config](int64_t in_features, int64_t out_features) { + const double weight = static_cast(in_features) * static_cast(out_features); + const double bias = config.add_bias_linear ? static_cast(out_features) : 0.0; + return weight + bias; + }; + + double per_layer = 2.0 * static_cast(norm_params); // ln_1 + ln_2 + per_layer += linear_params(n_embd, qkv_dim); // attn.c_attn + per_layer += linear_params(n_embd, n_embd); // attn.c_proj + per_layer += linear_params(n_embd, ffn_hidden); // mlp.c_fc + if (config.activation_type == MLPType::kSwiGLU) { + per_layer += linear_params(n_embd, ffn_hidden); // mlp.c_fc2 + } + per_layer += linear_params(ffn_hidden, n_embd); // mlp.c_proj + + return std::vector(static_cast(config.n_layer), per_layer); +} + } // namespace infini_train::nn diff --git a/infini_train/src/nn/modules/transformer/transformer_config.cc b/infini_train/src/nn/modules/transformer/transformer_config.cc index b8947d4b6..09d55baa6 100644 --- a/infini_train/src/nn/modules/transformer/transformer_config.cc +++ b/infini_train/src/nn/modules/transformer/transformer_config.cc @@ -1,15 +1,5 @@ #include "infini_train/include/nn/modules/transformer/transformer_config.h" -#include "infini_train/include/nn/parallel/global.h" -#include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" - namespace infini_train::nn { bool TransformerConfig::UseGQA() const { return n_kv_head < n_head; } - -int TransformerConfig::GetChunkSize() const { - auto stage_info = parallel::PipelineParallel::GetStageInfo(n_layer, parallel::global::GetPipelineParallelSize(), - parallel::pp_rank, - parallel::global::GetVirtualPipelineParallelSize()); - return stage_info.layer_ranges_per_chunk.size(); -} } // namespace infini_train::nn diff --git a/infini_train/src/nn/parallel/global.cc b/infini_train/src/nn/parallel/global.cc index 655b4bceb..7b2f98745 100644 --- a/infini_train/src/nn/parallel/global.cc +++ b/infini_train/src/nn/parallel/global.cc @@ -87,7 +87,8 @@ GlobalEnv &GlobalEnv::Instance() { } void GlobalEnv::Init(int nthread_per_process, int tensor_parallel_size, bool sequence_parallel_enabled, - int pipeline_parallel_size, int virtual_pipeline_parallel_size) { + int pipeline_parallel_size, int virtual_pipeline_parallel_size, + const std::vector &pipeline_layer_partition) { std::lock_guard lock(mutex_); CHECK(!initialized_) << "Repeated initialization of GlobalEnv!"; @@ -112,6 +113,7 @@ void GlobalEnv::Init(int nthread_per_process, int tensor_parallel_size, bool seq sequence_parallel_enabled_ = sequence_parallel_enabled; pipeline_parallel_size_ = pipeline_parallel_size; virtual_pipeline_parallel_size_ = virtual_pipeline_parallel_size; + pipeline_layer_partition_ = pipeline_layer_partition; data_parallel_size_ = world_size_ / tensor_parallel_size_ / pipeline_parallel_size_; layout_.sizes[DP] = data_parallel_size_; @@ -182,6 +184,11 @@ int GlobalEnv::virtual_pipeline_parallel_size() const { return virtual_pipeline_parallel_size_; } +const std::vector &GlobalEnv::pipeline_layer_partition() const { + CHECK(initialized_) << "GlobalEnv is not initialized!"; + return pipeline_layer_partition_; +} + Layout GlobalEnv::layout() const { CHECK(initialized_) << "GlobalEnv is not initialized!"; return layout_; diff --git a/infini_train/src/nn/parallel/pp/pipeline_layout.cc b/infini_train/src/nn/parallel/pp/pipeline_layout.cc new file mode 100644 index 000000000..2d19c8e35 --- /dev/null +++ b/infini_train/src/nn/parallel/pp/pipeline_layout.cc @@ -0,0 +1,302 @@ +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" + +#include +#include +#include +#include +#include +#include +#include + +#include "glog/logging.h" + +namespace infini_train::nn::parallel { + +namespace { +int ParseLayerCount(const std::string &token, const std::string &whole) { + if (token.empty()) { + LOG(FATAL) << "Invalid pipeline_layer_partition '" << whole << "': empty entry"; + } + int value = 0; + for (char c : token) { + if (c < '0' || c > '9') { + LOG(FATAL) << "Invalid pipeline_layer_partition '" << whole << "': '" << token + << "' is not a positive integer"; + } + value = value * 10 + (c - '0'); + } + if (value <= 0) { + LOG(FATAL) << "Invalid pipeline_layer_partition '" << whole << "': layer count must be positive, got " << value; + } + return value; +} +} // namespace + +std::vector ParsePipelineLayerPartition(const std::string &str) { + std::vector partition; + if (str.empty()) { + return partition; + } + std::stringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { partition.push_back(ParseLayerCount(token, str)); } + return partition; +} + +std::vector ParsePipelineLayerCosts(const std::string &str) { + std::vector costs; + if (str.empty()) { + return costs; + } + std::stringstream ss(str); + std::string token; + while (std::getline(ss, token, ',')) { + if (token.empty()) { + LOG(FATAL) << "Invalid pipeline_layer_costs '" << str << "': empty entry"; + } + size_t parsed = 0; + double value = 0.0; + try { + value = std::stod(token, &parsed); + } catch (const std::exception &) { + LOG(FATAL) << "Invalid pipeline_layer_costs '" << str << "': '" << token << "' is not a number"; + } + if (parsed != token.size()) { + LOG(FATAL) << "Invalid pipeline_layer_costs '" << str << "': '" << token << "' is not a number"; + } + if (!std::isfinite(value) || value < 0.0) { + LOG(FATAL) << "Invalid pipeline_layer_costs '" << str << "': '" << token + << "' is not a finite non-negative number"; + } + costs.push_back(value); + } + return costs; +} + +std::vector SuggestBalancedPartition(int total_layers, int num_stages, const std::vector &layer_costs) { + CHECK_GT(total_layers, 0) << "total_layers must be positive"; + CHECK_GT(num_stages, 0) << "num_stages must be positive"; + CHECK_GE(total_layers, num_stages) << "cannot assign fewer layers than stages"; + + std::vector costs(total_layers, 1.0); + if (!layer_costs.empty()) { + CHECK_EQ(layer_costs.size(), static_cast(total_layers)) + << "layer_costs has " << layer_costs.size() << " entries but total_layers is " << total_layers; + for (int i = 0; i < total_layers; ++i) { + CHECK_GE(layer_costs[i], 0.0) + << "layer_costs must be non-negative, layer " << i << " has " << layer_costs[i]; + costs[i] = layer_costs[i]; + } + } + + // prefix[t] = sum of costs[0 .. t-1]. + std::vector prefix(total_layers + 1, 0.0); + for (int i = 0; i < total_layers; ++i) { prefix[i + 1] = prefix[i] + costs[i]; } + + // dp[i][j] is the minimal achievable maximum per-segment cost when the first j layers + // are split into i contiguous segments; split[i][j] records the boundary that reaches it + // (segments 1..i-1 cover layers [0, split), segment i covers [split, j)). + constexpr double kInf = std::numeric_limits::infinity(); + std::vector> dp(num_stages + 1, std::vector(total_layers + 1, kInf)); + std::vector> split(num_stages + 1, std::vector(total_layers + 1, 0)); + + for (int j = 0; j <= total_layers; ++j) { dp[1][j] = prefix[j]; } + for (int i = 2; i <= num_stages; ++i) { + for (int j = i; j <= total_layers; ++j) { + for (int p = i - 1; p <= j - 1; ++p) { + const double bottleneck = std::max(dp[i - 1][p], prefix[j] - prefix[p]); + if (bottleneck < dp[i][j]) { + dp[i][j] = bottleneck; + split[i][j] = p; + } + } + } + } + + // Reconstruct the per-stage layer counts from the last segment back to the first. + std::vector partition(num_stages, 0); + int j = total_layers; + for (int i = num_stages; i >= 1; --i) { + const int p = split[i][j]; + partition[i - 1] = j - p; + j = p; + } + + return partition; +} + +PipelineLoadStats ComputePipelineLoadAnalysis(int total_layers, int num_stages, const std::vector &partition, + const std::vector &layer_costs, int num_micro_batches) { + CHECK_GT(total_layers, 0) << "total_layers must be positive"; + CHECK_GT(num_stages, 0) << "num_stages must be positive"; + CHECK_GE(total_layers, num_stages) << "cannot assign fewer layers than stages"; + CHECK_GT(num_micro_batches, 0) << "num_micro_batches must be positive"; + + std::vector costs(total_layers, 1.0); + if (!layer_costs.empty()) { + CHECK_EQ(layer_costs.size(), static_cast(total_layers)) + << "layer_costs has " << layer_costs.size() << " entries but total_layers is " << total_layers; + for (int i = 0; i < total_layers; ++i) { + CHECK_GE(layer_costs[i], 0.0) + << "layer_costs must be non-negative, layer " << i << " has " << layer_costs[i]; + costs[i] = layer_costs[i]; + } + } + + std::vector part = partition; + if (part.empty()) { + part = SuggestBalancedPartition(total_layers, num_stages, {}); // default uniform partition + } else { + CHECK_EQ(part.size(), static_cast(num_stages)) + << "partition has " << part.size() << " entries but num_stages is " << num_stages; + } + + PipelineLoadStats stats; + stats.num_stages = num_stages; + stats.num_micro_batches = num_micro_batches; + stats.stage_loads.assign(num_stages, 0.0); + + int cursor = 0; + for (int stage = 0; stage < num_stages; ++stage) { + double load = 0.0; + for (int k = 0; k < part[stage]; ++k) { + CHECK_LT(cursor, total_layers) << "partition sums to more than " << total_layers << " layers"; + load += costs[cursor++]; + } + stats.stage_loads[stage] = load; + } + CHECK_EQ(cursor, total_layers) << "partition sums to " << cursor << " layers but the model has " << total_layers; + + stats.bottleneck = *std::max_element(stats.stage_loads.begin(), stats.stage_loads.end()); + stats.average = std::accumulate(stats.stage_loads.begin(), stats.stage_loads.end(), 0.0) / num_stages; + stats.efficiency = stats.bottleneck > 0.0 ? stats.average / stats.bottleneck : 0.0; + stats.imbalance_bubble = 1.0 - stats.efficiency; + stats.structural_bubble + = static_cast(num_stages - 1) / static_cast(num_stages - 1 + num_micro_batches); + return stats; +} + +PipelineLayout PipelineLayout::Create(int total_layers, int num_stages, int vpp_size, + const std::vector &partition) { + CHECK_GT(total_layers, 0) << "total_layers must be positive"; + CHECK_GT(num_stages, 0) << "num_stages must be positive"; + CHECK_GT(vpp_size, 0) << "vpp_size must be positive"; + + PipelineLayout layout; + layout.num_stages_ = num_stages; + layout.total_layers_ = total_layers; + layout.vpp_size_ = vpp_size; + layout.first_stage_idx_ = 0; + layout.last_stage_idx_ = num_stages - 1; + layout.stage_layer_ranges_.assign(num_stages, {}); + + if (partition.empty()) { + // Default: uniform contiguous partition, optionally interleaved across virtual chunks. + const int layers_per_chunk = total_layers / (num_stages * vpp_size); + const int remainder = total_layers % (num_stages * vpp_size); + for (int stage = 0; stage < num_stages; ++stage) { + for (int local_chunk = 0; local_chunk < vpp_size; ++local_chunk) { + const int global_chunk = local_chunk * num_stages + stage; + if (global_chunk * layers_per_chunk >= total_layers) { + break; + } + int start = global_chunk * layers_per_chunk; + int end = start + layers_per_chunk; + if (global_chunk < remainder) { + start = global_chunk * (layers_per_chunk + 1); + end = start + (layers_per_chunk + 1); + } else { + start = remainder * (layers_per_chunk + 1) + (global_chunk - remainder) * layers_per_chunk; + end = start + layers_per_chunk; + } + end = std::min(end, total_layers); + if (start < end) { + layout.stage_layer_ranges_[stage].push_back({start, end}); + } + } + } + } else { + // Custom non-uniform contiguous partition; incompatible with virtual pipeline. + CHECK_EQ(partition.size(), static_cast(num_stages)) + << "pipeline_layer_partition has " << partition.size() << " entries but pipeline_parallel is " + << num_stages; + CHECK_EQ(vpp_size, 1) << "Custom pipeline_layer_partition is incompatible with virtual_pipeline_parallel > 1"; + int cursor = 0; + for (int stage = 0; stage < num_stages; ++stage) { + const int count = partition[stage]; + CHECK_GT(count, 0) << "pipeline_layer_partition entry must be positive, stage " << stage << " has " + << count; + layout.stage_layer_ranges_[stage].push_back({cursor, cursor + count}); + cursor += count; + } + CHECK_EQ(cursor, total_layers) << "pipeline_layer_partition sums to " << cursor << " layers but the model has " + << total_layers; + } + + // Build the layer -> stage lookup and verify layers are neither missing nor duplicated. + layout.layer_to_stage_.assign(total_layers, -1); + for (int stage = 0; stage < num_stages; ++stage) { + for (const auto &[start, end] : layout.stage_layer_ranges_[stage]) { + for (int layer = start; layer < end; ++layer) { + CHECK_EQ(layout.layer_to_stage_[layer], -1) + << "layer " << layer << " is assigned to more than one pipeline stage"; + layout.layer_to_stage_[layer] = stage; + } + } + } + for (int layer = 0; layer < total_layers; ++layer) { + CHECK_NE(layout.layer_to_stage_[layer], -1) << "layer " << layer << " is not assigned to any pipeline stage"; + } + + return layout; +} + +StageInfo PipelineLayout::GetStageInfo(int stage_id) const { + CHECK_GE(stage_id, 0); + CHECK_LT(stage_id, num_stages_); + StageInfo info; + info.is_first_stage = (stage_id == first_stage_idx_); + info.is_last_stage = (stage_id == last_stage_idx_); + info.layer_ranges_per_chunk = stage_layer_ranges_[stage_id]; + return info; +} + +int PipelineLayout::StageOfLayer(int layer_id) const { + CHECK_GE(layer_id, 0); + CHECK_LT(layer_id, total_layers_); + return layer_to_stage_[layer_id]; +} + +bool PipelineLayout::OwnsLayer(int stage_id, int layer_id) const { return StageOfLayer(layer_id) == stage_id; } + +int PipelineLayout::StageOfChunk(int global_chunk_id, int num_stages) { return global_chunk_id % num_stages; } + +int PipelineLayout::LocalChunkIndexOfChunk(int global_chunk_id, int num_stages) { return global_chunk_id / num_stages; } + +std::string PipelineLayout::Describe() const { + std::string s = "PipelineLayout: num_stages=" + std::to_string(num_stages_) + + ", total_layers=" + std::to_string(total_layers_) + ", vpp=" + std::to_string(vpp_size_) + "\n"; + for (int stage = 0; stage < num_stages_; ++stage) { + s += " stage " + std::to_string(stage) + ": "; + for (size_t i = 0; i < stage_layer_ranges_[stage].size(); ++i) { + const auto &[start, end] = stage_layer_ranges_[stage][i]; + if (i > 0) { + s += ", "; + } + s += "[" + std::to_string(start) + ", " + std::to_string(end) + ")"; + } + if (stage_layer_ranges_[stage].empty()) { + s += "(no layers)"; + } + if (stage == first_stage_idx_) { + s += " + embedding"; + } + if (stage == last_stage_idx_) { + s += " + final_norm + lm_head"; + } + s += "\n"; + } + return s; +} + +} // namespace infini_train::nn::parallel diff --git a/infini_train/src/nn/parallel/pp/pipeline_parallel.cc b/infini_train/src/nn/parallel/pp/pipeline_parallel.cc index c0369cdeb..dfb84e86f 100644 --- a/infini_train/src/nn/parallel/pp/pipeline_parallel.cc +++ b/infini_train/src/nn/parallel/pp/pipeline_parallel.cc @@ -1,14 +1,23 @@ // pipeline_parallel.cc #include "infini_train/include/nn/parallel/pp/pipeline_parallel.h" +#include #include +#include #include +#include #include +#include + +#include "glog/logging.h" #include "infini_train/include/nn/modules/container.h" #include "infini_train/include/nn/modules/module.h" #include "infini_train/include/nn/parallel/pp/pipeline_schedule.h" #include "infini_train/include/nn/parallel/pp/pipeline_stage.h" +#include "infini_train/include/nn/parallel/process_group.h" +#include "infini_train/include/nn/parallel/utils.h" +#include "infini_train/include/tensor.h" namespace infini_train::nn::parallel { namespace { @@ -39,60 +48,22 @@ float PipelineParallel::TrainStep(const std::vector> &in return schedule_->Step(stage_input, stage_target, optimizer, loss_fn, dtype); } -StageInfo PipelineParallel::GetStageInfo(int total_layers, int pp_size, int rank, int chunks_per_stage) { - bool is_first_stage = (rank == 0); - bool is_last_stage = (rank == pp_size - 1); - - std::vector> layer_ranges_per_chunk; - - int layers_per_chunk = total_layers / (pp_size * chunks_per_stage); - int remainder = total_layers % (pp_size * chunks_per_stage); - - for (int local_chunk_idx = 0; local_chunk_idx < chunks_per_stage; ++local_chunk_idx) { - int global_chunk_idx = local_chunk_idx * pp_size + rank; - - if (global_chunk_idx * layers_per_chunk >= total_layers) { - break; - } - - int chunk_start = global_chunk_idx * layers_per_chunk; - int chunk_end = chunk_start + layers_per_chunk; - - if (global_chunk_idx < remainder) { - // Assign an additional layer to each of the first remainder chunks - chunk_start = global_chunk_idx * (layers_per_chunk + 1); - chunk_end = chunk_start + (layers_per_chunk + 1); - } else { - chunk_start = remainder * (layers_per_chunk + 1) + (global_chunk_idx - remainder) * layers_per_chunk; - chunk_end = chunk_start + layers_per_chunk; - } - - chunk_end = std::min(chunk_end, total_layers); - if (chunk_start < chunk_end) { - layer_ranges_per_chunk.push_back({chunk_start, chunk_end}); - } - } - - return {is_first_stage, is_last_stage, layer_ranges_per_chunk}; -} - PipelineParallel::PipelineParallel(const std::shared_ptr module, int num_stages, int num_micro_batches, const std::vector> &recv_shape, int pp_rank, Device device, - int chunk_size) + const StageInfo &stage_info) : num_stages_(num_stages), rank_(pp_rank) { modules_[kModuleName] = std::move(module); - int stage_id = pp_rank; - int stage_size = num_stages; + const int chunk_size = static_cast(stage_info.layer_ranges_per_chunk.size()); std::vector> chunks; for (int chunk_id = 0; chunk_id < chunk_size; ++chunk_id) { std::vector> chunk_parts; - if (chunk_id == 0 && stage_id == 0) { + if (chunk_id == 0 && stage_info.is_first_stage) { chunk_parts.push_back(module->mutable_module(kPPFirstStageName)); } chunk_parts.push_back(module->mutable_module(kPPChunkNamePrefix + std::to_string(chunk_id))); - if (chunk_id == chunk_size - 1 && stage_id == stage_size - 1) { + if (chunk_id == chunk_size - 1 && stage_info.is_last_stage) { chunk_parts.push_back(module->mutable_module(kPPLastStageName)); } chunks.push_back(std::make_shared(std::move(chunk_parts))); @@ -104,4 +75,60 @@ PipelineParallel::PipelineParallel(const std::shared_ptr module, int num } std::vector> *PipelineParallel::mutable_chunks() { return pipeline_stage_->mutable_chunks(); } + +void PipelineParallel::ReportPipelineStats() { + const int num_stages = num_stages_; + if (num_stages <= 1) { + return; // Nothing to compare with a single pipeline stage. + } + + // Flush pending event timing and read this stage's accumulated compute time. + const double fwd = schedule_->ForwardSeconds(); + const double bwd = schedule_->BackwardSeconds(); + const int64_t fwd_count = schedule_->ForwardTaskCount(); + const int64_t bwd_count = schedule_->BackwardTaskCount(); + + Device device = pipeline_stage_->device(); + auto *pp_group = ProcessGroupFactory::Instance(device.type()) + ->Get(GetPipelineParallelProcessGroupName(device.Rank().GlobalRank())); + + // Gather [fwd_seconds, bwd_seconds] from every pipeline rank along dim 0. + const float host[2] = {static_cast(fwd), static_cast(bwd)}; + auto input = std::make_shared(host, std::vector{2}, DataType::kFLOAT32, device); + auto gathered = std::make_shared(std::vector{2 * num_stages}, DataType::kFLOAT32, device); + pp_group->AllGather(gathered, input, /*async_op=*/false); + + const auto gathered_cpu = gathered->To(Device()); + const float *data = static_cast(gathered_cpu.DataPtr()); + + std::vector stage_fwd(num_stages), stage_bwd(num_stages), stage_total(num_stages); + for (int s = 0; s < num_stages; ++s) { + stage_fwd[s] = data[2 * s]; + stage_bwd[s] = data[2 * s + 1]; + stage_total[s] = stage_fwd[s] + stage_bwd[s]; + } + + // The gather is collective; only the first pipeline rank prints the summary. + if (rank_ != 0) { + return; + } + + const double bottleneck = *std::max_element(stage_total.begin(), stage_total.end()); + const double average = std::accumulate(stage_total.begin(), stage_total.end(), 0.0) / num_stages; + const double efficiency = bottleneck > 0.0 ? average / bottleneck : 0.0; + const double imbalance_bubble = 1.0 - efficiency; + + // Use LOG(ERROR) so the summary reaches stderr even when glog's stderrthreshold + // filters out INFO; this matches the per-step progress lines above. + LOG(ERROR) << std::format("=== Pipeline Timing Summary ({} stages) ===", num_stages); + LOG(ERROR) << std::format("{:<6} {:>14} {:>14} {:>14}", "Stage", "Fwd(ms)", "Bwd(ms)", "Total(ms)"); + for (int s = 0; s < num_stages; ++s) { + LOG(ERROR) << std::format("{:<6} {:>14.3f} {:>14.3f} {:>14.3f}", s, stage_fwd[s] * 1e3, stage_bwd[s] * 1e3, + stage_total[s] * 1e3); + } + LOG(ERROR) << std::format("Compute tasks per stage: {} forward + {} backward", fwd_count, bwd_count); + LOG(ERROR) << std::format("Bottleneck stage: {:.3f} ms | average: {:.3f} ms", bottleneck * 1e3, average * 1e3); + LOG(ERROR) << std::format("Load-imbalance bubble: {:.1f}% | pipeline efficiency: {:.1f}%", imbalance_bubble * 100.0, + efficiency * 100.0); +} } // namespace infini_train::nn::parallel diff --git a/infini_train/src/nn/parallel/pp/pipeline_schedule.cc b/infini_train/src/nn/parallel/pp/pipeline_schedule.cc index b702a3016..5e0aa5902 100644 --- a/infini_train/src/nn/parallel/pp/pipeline_schedule.cc +++ b/infini_train/src/nn/parallel/pp/pipeline_schedule.cc @@ -1,18 +1,22 @@ // pipeline_schedule.cc #include "infini_train/include/nn/parallel/pp/pipeline_schedule.h" +#include #include #include +#include #include #include "glog/logging.h" #include "infini_train/include/autocast.h" +#include "infini_train/include/core/runtime/device_guard.h" #include "infini_train/include/datatype.h" #include "infini_train/include/device.h" #include "infini_train/include/nn/init.h" #include "infini_train/include/nn/modules/module.h" #include "infini_train/include/nn/parallel/global.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" #include "infini_train/include/nn/parallel/pp/pipeline_stage.h" #include "infini_train/include/nn/parallel/pp/send_recv.h" #include "infini_train/include/optimizer.h" @@ -20,6 +24,151 @@ namespace infini_train::nn::parallel { +// Measures per-stage compute time (forward / backward) with CUDA events on GPU and +// std::chrono::steady_clock on CPU. Events are recorded on the device stream without blocking; +// Flush() synchronizes once and accumulates the elapsed device time of every pending interval. +class StageTimer { +public: + explicit StageTimer(Device device) : device_(device), is_cpu_(device.IsCPU()) { + if (!is_cpu_) { + impl_ = core::GetDeviceGuardImpl(device_.type()); + } + } + + ~StageTimer() { ClearPending(); } + + void Begin() { + if (is_cpu_) { + CHECK(!cpu_active_) << "StageTimer::Begin called while already timing"; + cpu_begin_ = std::chrono::steady_clock::now(); + cpu_active_ = true; + return; + } + // Pin the device so the CUDA event is created in this stage's context; otherwise + // EventCreate() uses the thread's current device, which may differ from device_, + // and EventRecord() on this stage's stream fails with cudaErrorInvalidResourceHandle. + core::DeviceGuard guard(device_); + core::Event *start = nullptr; + impl_->EventCreate(&start); + impl_->EventRecord(start, CurrentStream()); + pending_starts_.push_back(start); + } + + void End(bool is_forward) { + if (is_cpu_) { + CHECK(cpu_active_) << "StageTimer::End called without a matching Begin"; + const double seconds = std::chrono::duration(std::chrono::steady_clock::now() - cpu_begin_).count(); + if (is_forward) { + fwd_seconds_ += seconds; + ++fwd_count_; + } else { + bwd_seconds_ += seconds; + ++bwd_count_; + } + cpu_active_ = false; + return; + } + CHECK(!pending_starts_.empty()) << "StageTimer::End called without a matching Begin"; + core::DeviceGuard guard(device_); + core::Event *start = pending_starts_.back(); + pending_starts_.pop_back(); + core::Event *stop = nullptr; + impl_->EventCreate(&stop); + impl_->EventRecord(stop, CurrentStream()); + pending_intervals_.push_back({start, stop, is_forward}); + } + + // Synchronize the device once, then accumulate the elapsed time of every pending interval. + void Flush() { + if (is_cpu_ || pending_intervals_.empty()) { + return; + } + // The stream is ordered, so synchronizing the last stop event guarantees every earlier + // event in this timer has completed; EventElapsedTime then returns without blocking. + impl_->EventSynchronize(pending_intervals_.back().stop); + for (const auto &interval : pending_intervals_) { + const double seconds = impl_->EventElapsedTime(interval.start, interval.stop) * 1e-3; + if (interval.is_forward) { + fwd_seconds_ += seconds; + ++fwd_count_; + } else { + bwd_seconds_ += seconds; + ++bwd_count_; + } + } + ClearPending(); + } + + double forward_seconds() const { return fwd_seconds_; } + double backward_seconds() const { return bwd_seconds_; } + int64_t forward_count() const { return fwd_count_; } + int64_t backward_count() const { return bwd_count_; } + +private: + struct Interval { + core::Event *start; + core::Event *stop; + bool is_forward; + }; + + core::Stream *CurrentStream() { return impl_->GetStream(device_); } + + void ClearPending() { + for (const auto &interval : pending_intervals_) { + impl_->EventDestroy(interval.start); + impl_->EventDestroy(interval.stop); + } + pending_intervals_.clear(); + for (core::Event *start : pending_starts_) { impl_->EventDestroy(start); } + pending_starts_.clear(); + } + + Device device_; + core::DeviceGuardImpl *impl_ = nullptr; + bool is_cpu_ = false; + + // CPU timing state. + std::chrono::steady_clock::time_point cpu_begin_; + bool cpu_active_ = false; + + // CUDA timing state: unmatched start events and completed intervals awaiting flush. + std::vector pending_starts_; + std::vector pending_intervals_; + + double fwd_seconds_ = 0.0; + double bwd_seconds_ = 0.0; + int64_t fwd_count_ = 0; + int64_t bwd_count_ = 0; +}; + +PipelineSchedule::PipelineSchedule(std::shared_ptr stage, int num_stages, int num_micro_batches) + : stage_(std::move(stage)), num_micro_batches_(num_micro_batches), + timer_(std::make_unique(stage_->device())) { + (void)num_stages; +} + +PipelineSchedule::~PipelineSchedule() = default; + +double PipelineSchedule::ForwardSeconds() const { + timer_->Flush(); + return timer_->forward_seconds(); +} + +double PipelineSchedule::BackwardSeconds() const { + timer_->Flush(); + return timer_->backward_seconds(); +} + +int64_t PipelineSchedule::ForwardTaskCount() const { + timer_->Flush(); + return timer_->forward_count(); +} + +int64_t PipelineSchedule::BackwardTaskCount() const { + timer_->Flush(); + return timer_->backward_count(); +} + void PrintScheduleTable(const std::vector &schedule, int n, int num_stages, int vpp_size) { int total_global_chunks = num_stages * vpp_size; @@ -32,8 +181,8 @@ void PrintScheduleTable(const std::vector &sche LOG(INFO) << "-----|-----------|------------|--------------|-------------|-------"; for (const auto &task : schedule) { - int owning_stage = task.global_chunk_id % num_stages; - int local_chunk = task.global_chunk_id / num_stages; + int owning_stage = PipelineLayout::StageOfChunk(task.global_chunk_id, num_stages); + int local_chunk = PipelineLayout::LocalChunkIndexOfChunk(task.global_chunk_id, num_stages); std::string type_str = task.is_forward ? "Forward" : "Backward"; @@ -75,9 +224,9 @@ PipelineParallelScheduler::Task PipelineParallelScheduler::CreateTask(int step, task.step = step; task.microbatch_id = mb; task.global_chunk_id = global_chunk; - task.local_chunk_idx = global_chunk / num_stages; + task.local_chunk_idx = PipelineLayout::LocalChunkIndexOfChunk(global_chunk, num_stages); task.is_forward = is_forward; - task.stage_id = global_chunk % num_stages; + task.stage_id = PipelineLayout::StageOfChunk(global_chunk, num_stages); task.is_last_chunk = (global_chunk == total_chunks - 1); task.is_first_chunk = (global_chunk == 0); return task; @@ -234,7 +383,9 @@ float PipelineSchedule::StepMicroBatches(const std::vectorBegin(); activations[task.local_chunk_idx][mb] = stage_->ForwardOneChunk(inputs, task.local_chunk_idx); + timer_->End(/*is_forward=*/true); if (!task.is_last_chunk) { if (stage_->IsLastStage()) { @@ -255,7 +406,9 @@ float PipelineSchedule::StepMicroBatches(const std::vector(target_on_device)})[0]; loss = loss / n; } + timer_->Begin(); loss->Backward(); + timer_->End(/*is_forward=*/false); // Defer the loss D2H copy until after backward; reading it earlier would synchronize CUDA // between forward and backward. total_loss += static_cast(loss->To(Device()).DataPtr())[0]; @@ -265,7 +418,9 @@ float PipelineSchedule::StepMicroBatches(const std::vector(out_tensor->Dims(), out_tensor->Dtype(), out_tensor->GetDevice()); + timer_->Begin(); out_tensor->Backward(dummy_gradient); + timer_->End(/*is_forward=*/false); } } } diff --git a/scripts/verify_pipeline_layout_correctness.sh b/scripts/verify_pipeline_layout_correctness.sh new file mode 100644 index 000000000..3f994c6cf --- /dev/null +++ b/scripts/verify_pipeline_layout_correctness.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Verify that a custom pipeline layout produces training results identical to the +# single-card reference within tolerance. This is the 2-Stage end-to-end correctness +# check required by the assignment "通过标准": +# fp32: |loss| diff <= 1e-05 +# bf16: |loss| diff <= 1e-02 +# +# It runs the GPT-2 example twice with the SAME weights (--llmc_filepath), data, and +# hyper-parameters, but different parallelism: +# baseline : single card (--nproc_per_node=1, --pipeline_parallel 1) +# custom : 2-stage PP (--nproc_per_node=2, --pipeline_parallel 2, +# --pipeline_layer_partition 7,5) +# then compares the per-step training loss with scripts/compare_loss.py. +# +# Why loss is sufficient evidence for forward + gradient consistency: +# the loss at step t integrates the forward pass, backward pass, and every gradient +# from steps 0..t-1. Any fwd/bwd/grad discrepancy compounds into a loss divergence on +# the next step, so matching loss over multiple steps proves all three agree. +# +# Usage: +# bash scripts/verify_pipeline_layout_correctness.sh +# DTYPE=bfloat16 bash scripts/verify_pipeline_layout_correctness.sh +# WEIGHTS=/path/to/gpt2_124M.bin DATA=/path/to/train.bin bash scripts/verify_pipeline_layout_correctness.sh +# +# The weights file must match the model (d12 / GPT-2 124M: 12 layers, 768 hidden). +# If data/gpt2/gpt2_124M.bin is missing, download it with: +# bash scripts/assets/prepare-infinitrain-assets.sh +set -euo pipefail + +# ---- config (env-overridable) ---- +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BIN="${BIN:-$ROOT/build/gpt2}" +INFINI_RUN="${INFINI_RUN:-$ROOT/build/infini_run}" +WEIGHTS="${WEIGHTS:-$ROOT/data/gpt2/gpt2_124M.bin}" +DATA="${DATA:-$ROOT/data/gpt2/tiny_shakespeare_train.bin}" +DTYPE="${DTYPE:-float32}" +NUM_ITER="${NUM_ITER:-3}" +BATCH="${BATCH:-4}" +SEQ_LEN="${SEQ_LEN:-64}" +TOTAL_BATCH="${TOTAL_BATCH:-512}" # must be a multiple of BATCH * SEQ_LEN +CUSTOM_PARTITION="${CUSTOM_PARTITION:-7,5}" +OUT_DIR="${OUT_DIR:-$ROOT/build/pp_layout_correctness}" +COMPARE_SCRIPT="$ROOT/scripts/compare_loss.py" + +# The log basename encodes the dtype so compare_loss.py picks the right threshold. +case "$DTYPE" in +float32) LOG_NAME="gpt2_d12.log" ;; +bfloat16) LOG_NAME="gpt2_d12_bfloat16.log" ;; +*) echo "Unsupported DTYPE='$DTYPE' (use float32 or bfloat16)" >&2; exit 2 ;; +esac + +# ---- preflight ---- +command -v python3 >/dev/null 2>&1 || { echo "python3 is required but not found" >&2; exit 2; } +for f in "$BIN" "$INFINI_RUN" "$WEIGHTS" "$DATA" "$COMPARE_SCRIPT"; do + [[ -e "$f" ]] || { echo "Missing: $f" >&2; exit 2; } +done + +REF_DIR="$OUT_DIR/baseline" +CUSTOM_DIR="$OUT_DIR/custom" +mkdir -p "$REF_DIR" "$CUSTOM_DIR" + +# Args shared by both runs (identical weights/data/hparams). +COMMON_ARGS=( + --llmc_filepath "$WEIGHTS" + --input_bin "$DATA" + --dtype "$DTYPE" + --batch_size "$BATCH" + --sequence_length "$SEQ_LEN" + --total_batch_size "$TOTAL_BATCH" + --num_iteration "$NUM_ITER" + # No --tokenizer_bin is passed, so text generation never runs; set the + # frequency high anyway as a defensive measure. + --freq_generate_txt 1000000 +) + +run_and_check() { + local label="$1"; shift + local log="$1"; shift + echo "== [$label] ==" + "$@" 2>&1 | tee "$log" + # Sanity check: the log must contain exactly NUM_ITER "train loss" lines. + local count + count=$(grep -c "train loss" "$log" || true) + if [[ "$count" -ne "$NUM_ITER" ]]; then + echo "Expected $NUM_ITER 'train loss' lines in $log but found $count (run may have failed)" >&2 + exit 1 + fi +} + +echo "=== Pipeline custom-layout correctness verification ===" +echo "dtype=$DTYPE iters=$NUM_ITER batch=$BATCH seq=$SEQ_LEN total_batch=$TOTAL_BATCH" +echo "partition=$CUSTOM_PARTITION weights=$WEIGHTS" +echo + +run_and_check "1/3 reference: single card (PP=1)" "$REF_DIR/$LOG_NAME" \ + "$INFINI_RUN" --nproc_per_node=1 "$BIN" \ + --pipeline_parallel 1 "${COMMON_ARGS[@]}" + +run_and_check "2/3 custom: 2-stage PP, partition $CUSTOM_PARTITION" "$CUSTOM_DIR/$LOG_NAME" \ + "$INFINI_RUN" --nproc_per_node=2 "$BIN" \ + --pipeline_parallel 2 --pipeline_layer_partition "$CUSTOM_PARTITION" "${COMMON_ARGS[@]}" + +echo "== 3/3 compare per-step training loss ==" +if python3 "$COMPARE_SCRIPT" "$REF_DIR" "$CUSTOM_DIR" --threshold-fp32 1e-5 --threshold-bf16 1e-2; then + echo + echo "PASS: custom layout ($CUSTOM_PARTITION) loss matches single-card reference within tolerance." +else + echo + echo "FAIL: loss mismatch exceeds tolerance." >&2 + exit 1 +fi diff --git a/tests/distributed/CMakeLists.txt b/tests/distributed/CMakeLists.txt index b8ed49700..d778e3374 100644 --- a/tests/distributed/CMakeLists.txt +++ b/tests/distributed/CMakeLists.txt @@ -7,6 +7,16 @@ infini_train_add_test(test_rank LABELS cpu ) +infini_train_add_test(test_pipeline_layout + SOURCES test_pipeline_layout.cc + LABELS cpu +) + +infini_train_add_test(test_pipeline_layout_suggest + SOURCES test_pipeline_layout_suggest.cc + LABELS cpu +) + add_test( NAME RankTest.MultiNodeSingleProcessIsParallel COMMAND ${CMAKE_COMMAND} -E env diff --git a/tests/distributed/test_pipeline_layout.cc b/tests/distributed/test_pipeline_layout.cc new file mode 100644 index 000000000..1d6f834b2 --- /dev/null +++ b/tests/distributed/test_pipeline_layout.cc @@ -0,0 +1,117 @@ +#include +#include +#include + +#include "gtest/gtest.h" + +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" + +namespace infini_train::nn::parallel { +namespace { + +TEST(PipelineLayoutTest, DefaultUniformPartition) { + auto layout = PipelineLayout::Create(24, 4, 1); + + auto s0 = layout.GetStageInfo(0); + EXPECT_TRUE(s0.is_first_stage); + EXPECT_FALSE(s0.is_last_stage); + ASSERT_EQ(s0.layer_ranges_per_chunk.size(), 1u); + EXPECT_EQ(s0.layer_ranges_per_chunk[0], std::make_pair(0, 6)); + + auto s3 = layout.GetStageInfo(3); + EXPECT_FALSE(s3.is_first_stage); + EXPECT_TRUE(s3.is_last_stage); + ASSERT_EQ(s3.layer_ranges_per_chunk.size(), 1u); + EXPECT_EQ(s3.layer_ranges_per_chunk[0], std::make_pair(18, 24)); +} + +TEST(PipelineLayoutTest, CustomPartition) { + auto layout = PipelineLayout::Create(24, 4, 1, {4, 8, 6, 6}); + + auto s0 = layout.GetStageInfo(0); + auto s1 = layout.GetStageInfo(1); + auto s2 = layout.GetStageInfo(2); + auto s3 = layout.GetStageInfo(3); + + EXPECT_TRUE(s0.is_first_stage); + EXPECT_FALSE(s0.is_last_stage); + EXPECT_FALSE(s3.is_first_stage); + EXPECT_TRUE(s3.is_last_stage); + + ASSERT_EQ(s0.layer_ranges_per_chunk.size(), 1u); + ASSERT_EQ(s1.layer_ranges_per_chunk.size(), 1u); + ASSERT_EQ(s2.layer_ranges_per_chunk.size(), 1u); + ASSERT_EQ(s3.layer_ranges_per_chunk.size(), 1u); + + EXPECT_EQ(s0.layer_ranges_per_chunk[0], std::make_pair(0, 4)); + EXPECT_EQ(s1.layer_ranges_per_chunk[0], std::make_pair(4, 12)); + EXPECT_EQ(s2.layer_ranges_per_chunk[0], std::make_pair(12, 18)); + EXPECT_EQ(s3.layer_ranges_per_chunk[0], std::make_pair(18, 24)); +} + +TEST(PipelineLayoutTest, StageOfLayerAndOwnsLayer) { + auto layout = PipelineLayout::Create(24, 4, 1, {4, 8, 6, 6}); + + EXPECT_EQ(layout.StageOfLayer(0), 0); + EXPECT_EQ(layout.StageOfLayer(5), 1); + EXPECT_EQ(layout.StageOfLayer(17), 2); + EXPECT_EQ(layout.StageOfLayer(23), 3); + + EXPECT_TRUE(layout.OwnsLayer(0, 3)); + EXPECT_FALSE(layout.OwnsLayer(0, 4)); +} + +TEST(PipelineLayoutTest, VirtualPipelineInterleaving) { + auto layout = PipelineLayout::Create(8, 2, 2); + + auto s0 = layout.GetStageInfo(0); + ASSERT_EQ(s0.layer_ranges_per_chunk.size(), 2u); + EXPECT_EQ(s0.layer_ranges_per_chunk[0], std::make_pair(0, 2)); + EXPECT_EQ(s0.layer_ranges_per_chunk[1], std::make_pair(4, 6)); + + auto s1 = layout.GetStageInfo(1); + ASSERT_EQ(s1.layer_ranges_per_chunk.size(), 2u); + EXPECT_EQ(s1.layer_ranges_per_chunk[0], std::make_pair(2, 4)); + EXPECT_EQ(s1.layer_ranges_per_chunk[1], std::make_pair(6, 8)); +} + +TEST(PipelineLayoutTest, ChunkMappingHelpers) { + EXPECT_EQ(PipelineLayout::StageOfChunk(0, 4), 0); + EXPECT_EQ(PipelineLayout::StageOfChunk(5, 4), 1); + EXPECT_EQ(PipelineLayout::LocalChunkIndexOfChunk(5, 4), 1); + EXPECT_EQ(PipelineLayout::LocalChunkIndexOfChunk(7, 4), 1); +} + +TEST(PipelineLayoutTest, DescribeNonEmpty) { + auto layout = PipelineLayout::Create(24, 4, 1, {4, 8, 6, 6}); + EXPECT_FALSE(layout.Describe().empty()); +} + +TEST(PipelineLayoutTest, ParsePartition) { + EXPECT_TRUE(ParsePipelineLayerPartition("").empty()); + const std::vector expected{4, 8, 6, 6}; + EXPECT_EQ(ParsePipelineLayerPartition("4,8,6,6"), expected); +} + +TEST(PipelineLayoutTest, RejectsPartitionSumMismatch) { + EXPECT_DEATH(PipelineLayout::Create(24, 4, 1, {4, 8, 6, 5}), "sums to"); +} + +TEST(PipelineLayoutTest, RejectsStageCountMismatch) { + EXPECT_DEATH(PipelineLayout::Create(24, 4, 1, {6, 6, 6}), "entries"); +} + +TEST(PipelineLayoutTest, RejectsNonPositiveEntry) { + EXPECT_DEATH(PipelineLayout::Create(24, 4, 1, {4, 0, 6, 14}), "must be positive"); +} + +TEST(PipelineLayoutTest, RejectsVirtualPipelineConflict) { + EXPECT_DEATH(PipelineLayout::Create(24, 4, 2, {4, 8, 6, 6}), "incompatible"); +} + +TEST(PipelineLayoutTest, RejectsInvalidToken) { + EXPECT_DEATH(ParsePipelineLayerPartition("4,8a,6,6"), "not a positive integer"); +} + +} // namespace +} // namespace infini_train::nn::parallel diff --git a/tests/distributed/test_pipeline_layout_suggest.cc b/tests/distributed/test_pipeline_layout_suggest.cc new file mode 100644 index 000000000..ae15cdefa --- /dev/null +++ b/tests/distributed/test_pipeline_layout_suggest.cc @@ -0,0 +1,173 @@ +#include + +#include "gtest/gtest.h" + +#include "infini_train/include/nn/modules/transformer/transformer.h" +#include "infini_train/include/nn/parallel/pp/pipeline_layout.h" + +namespace infini_train::nn::parallel { +namespace { + +TEST(PipelineLayoutSuggestTest, UniformCostsProduceBalancedCounts) { + const std::vector expected{6, 6}; + EXPECT_EQ(SuggestBalancedPartition(12, 2, {}), expected); +} + +TEST(PipelineLayoutSuggestTest, UniformCostsWithRemainder) { + const std::vector expected{3, 3, 4}; + EXPECT_EQ(SuggestBalancedPartition(10, 3, {}), expected); +} + +TEST(PipelineLayoutSuggestTest, CostImbalanceShiftsLayersToLightStage) { + // Four "light" layers (cost 1) followed by eight "heavy" layers (cost 2). Balancing + // total cost (20 / 2 = 10 per stage) yields {7, 5} instead of the uniform {6, 6}. + std::vector costs(12, 2.0); + for (int i = 0; i < 4; ++i) { costs[i] = 1.0; } + const std::vector expected{7, 5}; + EXPECT_EQ(SuggestBalancedPartition(12, 2, costs), expected); +} + +TEST(PipelineLayoutSuggestTest, PartitionSumsToTotalLayers) { + const std::vector costs{3.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0}; + auto partition = SuggestBalancedPartition(8, 3, costs); + ASSERT_EQ(partition.size(), 3u); + int sum = 0; + for (int count : partition) { + EXPECT_GT(count, 0); + sum += count; + } + EXPECT_EQ(sum, 8); +} + +TEST(PipelineLayoutSuggestTest, SingleStageTakesAllLayers) { + EXPECT_EQ(SuggestBalancedPartition(12, 1, {}), std::vector{12}); +} + +TEST(PipelineLayoutSuggestTest, RejectsNegativeCost) { + EXPECT_DEATH(SuggestBalancedPartition(4, 2, {-1.0, 1.0, 1.0, 1.0}), "non-negative"); +} + +TEST(PipelineLayoutSuggestTest, RejectsWrongCostCount) { + EXPECT_DEATH(SuggestBalancedPartition(4, 2, {1.0, 2.0}), "entries"); +} + +TEST(PipelineLayoutSuggestTest, RejectsFewerLayersThanStages) { + EXPECT_DEATH(SuggestBalancedPartition(2, 3, {}), "fewer layers than stages"); +} + +TEST(PipelineLayerCostsTest, ParsesValidCosts) { + const std::vector expected{1.0, 2.0, 1.5}; + EXPECT_EQ(ParsePipelineLayerCosts("1.0,2.0,1.5"), expected); +} + +TEST(PipelineLayerCostsTest, EmptyStringGivesNoCosts) { EXPECT_TRUE(ParsePipelineLayerCosts("").empty()); } + +TEST(PipelineLayerCostsTest, RejectsNegativeCost) { EXPECT_DEATH(ParsePipelineLayerCosts("-1,2"), "non-negative"); } + +TEST(PipelineLayerCostsTest, RejectsNonNumber) { EXPECT_DEATH(ParsePipelineLayerCosts("1,abc"), "not a number"); } + +TEST(PipelineLayerCostsTest, RejectsEmptyEntry) { EXPECT_DEATH(ParsePipelineLayerCosts("1,,2"), "empty entry"); } + +TEST(PipelineLayerCostsTest, RejectsInfinity) { EXPECT_DEATH(ParsePipelineLayerCosts("inf"), "finite"); } + +TEST(ComputePerLayerParamCountsTest, MatchesAnalyticGELULayerNorm) { + nn::TransformerConfig config{ + .block_size = 64, + .vocab_size = 128, + .n_layer = 4, + .n_head = 2, + .n_kv_head = 2, + .n_embd = 16, + .position_embedding_type = nn::PositionEmbeddingType::kLearnedAbsolute, + .activation_type = nn::MLPType::kGELU, + .ffn_type = nn::FFNType::kDense, + .norm_type = nn::NormType::kLayerNorm, + .add_bias_linear = true, + .ffn_expansion_ratio = 4.0f, + .ffn_dim_multiplier = std::nullopt, + .multiple_of = 1, + }; + const std::vector expected(4, 3280.0); + EXPECT_EQ(nn::ComputePerLayerParamCounts(config), expected); +} + +TEST(ComputePerLayerParamCountsTest, SwigluRMSNormYieldsPositiveUniformCounts) { + nn::TransformerConfig config{ + .block_size = 64, + .vocab_size = 128, + .n_layer = 3, + .n_head = 2, + .n_kv_head = 2, + .n_embd = 16, + .position_embedding_type = nn::PositionEmbeddingType::kRoPE, + .activation_type = nn::MLPType::kSwiGLU, + .ffn_type = nn::FFNType::kDense, + .norm_type = nn::NormType::kRMSNorm, + .add_bias_linear = false, + .ffn_expansion_ratio = 4.0f, + .ffn_dim_multiplier = std::nullopt, + .multiple_of = 1, + }; + auto counts = nn::ComputePerLayerParamCounts(config); + ASSERT_EQ(counts.size(), 3u); + for (double c : counts) { EXPECT_GT(c, 0.0); } + EXPECT_EQ(counts[0], counts[1]); + EXPECT_EQ(counts[1], counts[2]); +} + +TEST(ComputePerLayerParamCountsTest, RejectsMoE) { + nn::TransformerConfig config{.n_layer = 4, .n_embd = 16, .ffn_type = nn::FFNType::kMoE}; + EXPECT_DEATH(nn::ComputePerLayerParamCounts(config), "MoE"); +} + +TEST(PipelineLoadAnalysisTest, UniformCostsArePerfectlyBalanced) { + // 12 layers / 3 stages with unit costs: uniform {4,4,4} -> every stage load == 4. + auto stats = ComputePipelineLoadAnalysis(12, 3, {4, 4, 4}, {}, /*num_micro_batches=*/8); + ASSERT_EQ(stats.stage_loads.size(), 3u); + for (double load : stats.stage_loads) { EXPECT_DOUBLE_EQ(load, 4.0); } + EXPECT_DOUBLE_EQ(stats.bottleneck, 4.0); + EXPECT_DOUBLE_EQ(stats.average, 4.0); + EXPECT_DOUBLE_EQ(stats.imbalance_bubble, 0.0); + EXPECT_DOUBLE_EQ(stats.efficiency, 1.0); + EXPECT_DOUBLE_EQ(stats.structural_bubble, 2.0 / (2.0 + 8.0)); +} + +TEST(PipelineLoadAnalysisTest, ImbalancedCostsMakeUniformLayoutSkewed) { + // 4 light layers (cost 1) + 8 heavy layers (cost 2). Uniform {6,6} assigns + // stage 0: 4*1 + 2*2 = 8, stage 1: 6*2 = 12. + std::vector costs(12, 2.0); + for (int i = 0; i < 4; ++i) { costs[i] = 1.0; } + auto stats = ComputePipelineLoadAnalysis(12, 2, {6, 6}, costs, 8); + EXPECT_DOUBLE_EQ(stats.stage_loads[0], 8.0); + EXPECT_DOUBLE_EQ(stats.stage_loads[1], 12.0); + EXPECT_DOUBLE_EQ(stats.bottleneck, 12.0); + EXPECT_DOUBLE_EQ(stats.average, 10.0); + EXPECT_DOUBLE_EQ(stats.efficiency, 10.0 / 12.0); + EXPECT_DOUBLE_EQ(stats.imbalance_bubble, 1.0 - 10.0 / 12.0); +} + +TEST(PipelineLoadAnalysisTest, BalancedPartitionRemovesImbalanceBubble) { + // Same costs, but the cost-balanced partition {7,5} yields load 10 / 10. + std::vector costs(12, 2.0); + for (int i = 0; i < 4; ++i) { costs[i] = 1.0; } + auto stats = ComputePipelineLoadAnalysis(12, 2, {7, 5}, costs, 8); + EXPECT_DOUBLE_EQ(stats.stage_loads[0], 10.0); + EXPECT_DOUBLE_EQ(stats.stage_loads[1], 10.0); + EXPECT_DOUBLE_EQ(stats.imbalance_bubble, 0.0); + EXPECT_DOUBLE_EQ(stats.efficiency, 1.0); +} + +TEST(PipelineLoadAnalysisTest, EmptyPartitionDefaultsToUniform) { + auto stats = ComputePipelineLoadAnalysis(12, 3, {}, {}, 1); + ASSERT_EQ(stats.stage_loads.size(), 3u); + for (double load : stats.stage_loads) { EXPECT_DOUBLE_EQ(load, 4.0); } +} + +TEST(PipelineLoadAnalysisTest, StructuralBubbleFollowsGpipeFormula) { + // Two stages, one micro-batch: (S-1)/(S-1+n) = 1/2. + auto stats = ComputePipelineLoadAnalysis(4, 2, {2, 2}, {}, 1); + EXPECT_DOUBLE_EQ(stats.structural_bubble, 0.5); +} + +} // namespace +} // namespace infini_train::nn::parallel