[Task32] feat: fla mcore chunkwise cp stage 3 - #273
Conversation
Compatibility layer only: FLA 0.4.1 -> 0.4.2 and a selective backport of Megatron-LM `5139086e` (NVIDIA/Megatron-LM#3282) onto the pinned MCore `85bced0a`. No Relax routing changes -- `relax/` is untouched, `auto` never selects chunkwise, and every existing recipe runs the same path as before. RFC: redai-studio#213, reworked per the 2026-08-05 review decisions: * the GDN CP mode is **static** for the whole process. There is no per-call override; `linear_cp_mode` is read by both the construction-time head check and by `GatedDeltaNet.forward`, and nothing in a forward writes to `self` or the shared config. Dynamic CP varies only `cp_group` / `local_cp_size`. * **v1 depends on #3282 only.** Nothing from the still-open #5664 is included. # ⭐ Feature ## MCore backport (docker/patch/megatron/20260805-85bced0ae.patch) - New `megatron/core/context_parallel_layout.py`, **byte-identical to `5139086e` below the module docstring**: zigzag <-> contiguous THD/SBHD partitions and a single-all-to-all swap between them. The THD swap rebuilds its routing from `cu_seqlens` per call, which is upstream behaviour. - `packed_seq_params.py`: `resolve_cp_group()` only. The dataclass field list is untouched. - `transformer_config.py`: `linear_cp_mode` with headwise `% (tp*cp)` vs chunkwise `% tp` head divisibility. Default is `headwise`, NOT upstream's `chunkwise`, so upgrading the image cannot silently reroute a recipe. `all_gather` is accepted as a third declared value using the TP-only rule, so the declared config equals the resolved `--gdn-cp-mode` rather than declaring one mode while running another. Unknown values, including an unresolved `auto`, assert at construction. - `gated_delta_net.py`: `_resolve_cp_routing()` gives the whole CP group to exactly one of headwise / chunkwise and `None` to the other; validates `local_cp_size == cp_group.size()`; never creates a process group; short circuits on `cp_size == 1` before the mode is read so a CP=1 micro-batch is legal under any declared mode; raises if `all_gather` reaches MCore's forward with cp>1 (the Relax wrapper was not installed). Plus zigzag<->contiguous conversion around conv + scan and `cp_context` for both FLA kernels. - Backwards compatible by construction: `cp_context=` is only passed when chunkwise is active, so with it off the FLA call is byte-identical to before and still works against FLA 0.4.1; `_prepare_qkv_for_gated_delta_rule` gains an optional argument so Relax's all-gather fallback keeps calling it unchanged; both existing Relax GDN fixes are preserved verbatim. ## Dependency - `docker/Dockerfile`: `flash-linear-attention==0.4.2` (first release carrying `fla.ops.cp`), plus build-time capability assertions after the FLA install and after the patch apply. One of them asserts `linear_cp_mode` is **absent** from `GatedDeltaNet.forward`, so re-introducing a per-call override fails the build. --- # ✅ Tests ## tests/backends/megatron/test_gdn_chunkwise_cp_layout.py (45 CPU tests) - Both partitions cover every token exactly once for CP in {1,2,4,8} and are permutations of each other. - MCore's zigzag partition is token-for-token identical to Relax's `slice_with_cp` and `gdn_cp_slice`. - Construction gate: default is `headwise`; chunkwise and `all_gather` use the TP-only head rule; `auto` and other unresolved values are rejected. ## tests/backends/megatron/test_gdn_chunkwise_cp_gpu.py (7 NCCL tests) - FLA `causal_conv1d` / `chunk_gated_delta_rule` under `cp_context` vs no CP, in fp32 and bf16, including `dweight`/`dbias`. - Full `GatedDeltaNet` CP=2 vs CP=1 in fp32 and bf16, with headwise run side by side as the control: in fp32 the two CP algorithms' deviation from CP=1 agrees to 1.00x-1.03x per tensor. - zigzag -> contiguous -> zigzag over a real CP group is token-exact, for packed THD with unequal-length samples and for SBHD. - Illegal combinations fail fast: `all_gather` reaching MCore's forward, mismatched `local_cp_size`, chunkwise + deterministic, chunkwise + inference. - `state_dict` / `sharded_state_dict` keys and shard dims identical across CP=1 / headwise / chunkwise. ## tests/backends/megatron/gdn_cp_numeric_probe.py - Cross-image probe for RFC 3.4-2. Old image vs new image on CP=1 / headwise / all-gather: 41 of 45 tensors bitwise identical and **0 outside tolerance**. The four that differ are at relative RMS 1e-9..6e-7 with cosine 1.0000000000 -- FLA 0.4.2 reorders a few backward reductions. All-gather is 18 of 18 bitwise identical. --- # 📝 Documentation - `docker/patch/megatron/TASK32-BACKPORT.md`: file-level and hunk-level record of what came from `5139086e`, which Relax adaptations were made, which existing Relax GDN fixes are preserved, and what was excluded. Includes two mechanical commands a reviewer can run to confirm the new module matches upstream byte-for-byte and that no #5664 content is present. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pre-commit was not run before pushing the previous commit, so CI's ruff-format and docformatter hooks failed on the new test file. Formatting only -- no test logic changed. Co-authored-by: Cursor <cursoragent@cursor.com>
|
这个正确性验证是如何做的?有跑 >150 个 step 比对 loss、grad norm、reward、mismatch 吗? |
|
另外, |
|
@xiaoliang0601 感谢review,以下是两个问题的回答~
端到端逐 step 精确对拍我试过,但是这条路径 run-to-run 本身就不确定(FLA 的 gated delta rule 反向用 atomics,加上 NCCL 规约顺序不固定),同一份配置、同一个 seed 跑两遍,loss 就能差 1.5e-4 ~ 5.3e-4,grad_norm 相对差能到 6% ~ 58%;(主要是grad_norm抖动大,单纯比loss和有效token的话,本 PR chunkwise 对比 Stage 0 老镜像老代码headwise 220步中: loss 逐步绝对差 max 2.08e-4,mean 6.29e-5,中位 5.81e-5,逐步有效token数完全相同) 我主要比较的是:step 0 的 loss 逐 bit 相等。我比了四组:同配置跑两遍、chunkwise 改动前后、chunkwise vs headwise、chunkwise vs all_gather;他们step 0 的 loss 全部相同,grad_norm 相对差 0.01%。 二是单测,现在的单测会涵盖cpu端的route计算(确保我们迁移后的新的route计算和老版本一致),以及gpu端的一次实际的cp chunkwise forward正确性结果检查。
现在这条路径上是不会被静默修改的;Relax 和 MCore 里所有对 刚刚提交了新的commit,新加了一些守卫是:身份之外再比一下 autograd 的版本计数器 这样实现不知道可以不,或者你有什么建议吗? |
绝对值差多少?如果绝对值太小的话,grad_norm 的相对值意义并不大。 我希望你能贴一个 reward、loss 和 grad norm 的 A/A 和 A/B 对比的曲线(可以用绝对值,也可以取 log),这样直观地判断训练是否有问题。 |
|
如果 A/B 能落到 A/A 的误差范围内的话,我觉得是没问题的,不需要 bitwise 对齐。 |
|
好,我没什么问题了。 |

Task 32 第三阶段:迁移 MCore #5664 的 THD route 预构建,让 Chunkwise 产生正向收益
对应 RFC:redai-infra/Relax#213
第一阶段 PR(FLA 0.4.2 + MCore #3282 backport):redai-infra/Relax#251
第二阶段 PR(Relax 侧 GDN CP 静态路由接入):redai-infra/Relax#254
结论:接入后 chunkwise 从"比 headwise 慢 17%"变成静态 CP 下快 45–52%、dynamic CP 下快 23%,在测过的四种 recompute × sequence length × 静态/动态 CP 组合下都是最快的模式。同 harness A/B 显示 chunkwise 训练吞吐 23,371 → 43,397 tok/s(+85.7%),headwise 在噪声范围内不变(+1.3%)。GDN 布局转换引入的 device-host 同步从 65,280 次 / 2.69 s 降到 0,GPU 忙碌率 56.2% → 85.1%。
1. 要解决什么问题
headwise 和 chunkwise 的模型算力是相同的——headwise 按 head 切(每卡算全序列 × 1/cp 的头),chunkwise 按时间切(每卡算 1/cp 序列 × 全部头),总 FLOPs 一样,两者都不重复计算;只有 all_gather 每卡跑全序列 × 全部头,重复 CP 倍。trace 直接证实了这点:两种模式的 GEMM kernel 名字、调用次数、耗时全都对得上(例如
nvjet_tss_128x256_64x4_2x1_v_badd_coopA_NTN是 630.8 ms × 963 对 631.4 ms × 963)。然而 Stage 2(#254)里朴素实现的 chunkwise,性能既不如 all_gather 也不如 headwise。
既然算力相同,chunkwise 慢就只能是开销。把 GPU kernel 时间拆成 NCCL 与非 NCCL 两部分看(同为
--profile-with-stack抓取,rank 0):通过 trace 分析,热点集中在
_zigzag_contiguous_thd_swap。每做一次 zigzag↔contiguous 转换,它都要现场推导 all-to-all 的路由:2 × cp_size次get_thd_context_parallel_rank_indices,每次内部有cu[0].item()、cu[-1].item()和两次torch.any(...)判断 → CP=4 时 32 次同步;cp_size次nonzero()和cp_size次布尔索引 —— 输出形状依赖数据,必然同步;arange/bucketize/argsort/scatter的小 kernel,规模是O(T_global)。而这套路由只依赖
cu_seqlens和(cp_size, cp_rank):一个 micro-batch 内所有 GDN 层、两个转换方向、以及 full recompute 的重放,算出来的结果完全一样。Stage 1 backport 的代码里本来就留着这个 TODO:NVIDIA/Megatron-LM#5664 是一个比较大的 PR 改动,其中包含这个路由缓存的优化。本 PR 只最小化迁移这部分的相关代码。
2. 实施方案
从上游 MCore PR 迁移代码,改动只落在 pinned MCore patch 的两个文件,
relax/目录零改动。2.1 核心思路(来自 #5664)
两种 layout 都是全局 token 区间的并集——contiguous 每 rank 一段连续区间,zigzag 每 rank
2 × 序列数段。所以整条 all-to-all 路由可以用区间求交得到,不需要逐 token 的索引张量:把cu_seqlens一次性.tolist()到 CPU,之后全部用 Python int 做双指针求交,产出send_rows/recv_rows/input_split_sizes/output_split_sizes。相比于之前的朴素实现(逐 token 计算索引),复杂度从
O(cp_size × T_global)(GPU)降到O(cp_size × 序列条数)(CPU)。与此同时把这四个对象存进 route 缓存,之后 forward 时只需取出直接做 all-to-all,不必每层重算一遍 CPU/GPU 上的索引:
2.2 原封不动迁移过来的函数(6 个)
_cp_layout_nvtx_range_compact_thd_cu_seqlens_to_listcu_seqlens一次性.tolist()+ 去掉重复边界(padding 空槽)_append_rangerows.extend(range(start, start + length))_row_list_is_identity_pack_thd_cp_route_send_bufferindex_select_scatter_thd_cp_route_recv_bufferindex_copy_2.3 只改了命名和报错文案,核心逻辑不修改(4 个)
_validate_thd_route_partitioning_build_thd_layout_segmentscp_partition_mode→layout;加了 docstring_intersect_thd_layout_segments_thd_cp_partition_route_attr_name为什么改名:#5664 在它自己的重构里把
layout全局重命名成了cp_partition_mode。跟着改会污染 Stage 1 已经暴露出去的接口(get_thd_context_parallel_rank_indices(..., layout=...)以及已合入的单测),所以保留 Stage 1 的命名。2.4 迁移过来但做了一定改动(3 个)
build_thd_cp_partition_route求交主体(区间构建 → 双指针求交 → 展开行号 → 两个完整性 assert)与上游逐字节相同,nvtx range 也保留。唯一实质差异是返回类型:
torch.Tensor,每次转换再decode_thd_cp_partition_route()解开;ThdCpPartitionRoute。原因:上游那层编码是为了让 route 能当 CUDA graph 的捕获输入,而 decode 里有 3 处
.cpu(),有一定性能影响,而且 Megatron 本身对 THD 布局转换是拒绝 full-iteration CUDA graph 的。兼容性:
ThdCpPartitionRoute的前 6 个字段就是上游decode_thd_cp_partition_route()的返回元组,连"恒等用None表示"这个约定都一样(上游_encode把恒等的 payload 存成空列表,decode 出来就是None)。这条偏离已写进函数 docstring,并注明:若将来 GDN 需要在 graph capture 下运行,这里必须重新考虑。get_thd_cp_partition_route/prebuild_thd_cp_partition_routes接口与上游一致,函数体不同。上游假定 route 由数据侧(
get_batch)主动 prebuild,所以查找是一次裸getattr,build-on-miss 只是兼容后路并会warnings.warn(FutureWarning)。我们这边 build-on-miss 是预期路径(原因见 2.5),warn 只会变成噪声;取而代之的是每次复用前先过一道有效性校验。2.5 Relax 所必须要的特有逻辑(2 个)
当前不采用上游"在数据准备侧提前算好 route"的做法,改成了懒构建 + 缓存。
上游 #5664 的用法是:数据侧在
get_batch里调prebuild_thd_cp_partition_routes(packed_seq_params),把两个方向的 route 一次性写到PackedSeqParams上;模型里所有消费点只管getattr取用。当前 PR 考虑到两个问题暂时没有使用这套机制:
GDN.forward里(#3282 的位置),route 的消费点在模型内部而不是数据侧;preprocess_packed_seqs会在 embedding 之后重打包出新的PackedSeqParams,GDN 拿到的未必是数据侧建的那个,提前 prebuild 有可能挂在一个随后被替换掉的对象上。所以改成:第一个 GDN 层用到时才构建,缓存到本次 forward 实际收到的那个对象上,同 micro-batch 的后续层与 recompute 重放直接命中。
上游那套单写多读天然不会陈旧,我们这套懒构建 + 缓存复用则必须自己证明缓存没过期(下一个 micro-batch 换边界、dynamic CP 换
cp_size/cp_rank)。校验要拿当前请求和 route 的来源逐项比对,route 因此得自带来源信息——这就引出两个 Relax 特有的符号:ThdCpPartitionRoute(NamedTuple)decode_thd_cp_partition_route()的返回值,参与计算;后 5 个(cu_seqlens/cp_size/cp_rank/source_layout/target_layout)是 Relax 加的来源凭据,不参与任何计算_thd_cp_partition_route_is_reusablecu_seqlens用对象身份(is)而不是数值比较prebuild_thd_cp_partition_routes()也按上游接口导出了,但本 PR 未调用;等 Bridge/VLM 的最终PackedSeqParams对象归属理清后,可以直接切回上游那套数据侧 prebuild 的用法。2.6 老代码的改动面
context_parallel_layout.py里 Stage 1 已有的函数:| 函数 | 状态 |
|
_zigzag_contiguous_thd_swap| 96 → 53 行。头(cp_size==1短路、movedim、contiguous)、尾(movedim回去、contiguous)和all_to_all的调用形式全部没动,只把中间 60 行的路由推导换成"取 route + 三步执行" |2.8 一个不属于 #5664 的附带优化
route 优化之后,profile 里第一名同步点变成
_resolve_cu_seqlens(943 ms / 2,880 次)。它每层都要做cu_seqlens[-1].item()、(seq_lengths % cp_size != 0).any()和torch.equal(cu_q, cu_kv)。新增
_resolve_thd_cu_seqlens(),用与 route 相同的缓存键(四个源张量的对象身份 +seq_len_global+cp_size)把这套校验收敛到每 micro-batch 一次。影响面:对 headwise 也生效(A/B 实测 +1.3%,噪声量级);对 all_gather 不生效(Relax fallback 整个替换了 MCore 的 forward)
2.8 改动清单
docker/patch/megatron/20260805-85bced0ae.patchcontext_parallel_layout.py307 → 691 行;gated_delta_net.py的forward+ 新增_resolve_thd_cu_seqlenstests/backends/megatron/test_gdn_chunkwise_cp_route.py3. Trace 对比
同一 recipe(8×H200 / Qwen3.5-9B / TP2 / 静态 CP4 / SP / full recompute /
max-tokens-per-gpu 8192),seed 1234,用 Relax 自带的--use-pytorch-profiler --profile-target train_overall --profile-with-stack抓 step 5,取 rank 0。优化前(Stage 2)
优化后(本 PR)
3.1 同步热点
context_parallel_layout.get_thd_context_parallel_rank_indicesgated_delta_net._resolve_cu_seqlensTensor.nonzero_resolve_cu_seqlens剩下的 60 次 = 10 个 micro-batch × 2(q/kv)× 3 次检查,正好是"每 micro-batch 一次"。3.2 整步指标
gpu_memset计算 kernel 少掉的 1.49 s 是 route 构建原先在 GPU 上跑的那批索引小算子——GEMM kernel 的名字、调用次数、耗时前后完全不变,模型算力一点没动。优化后 chunkwise 的计算 kernel(4.88 s)从"比 headwise 多 0.97 s"变成"比它少 0.52 s";这 0.52 s 同样不是算力差异,而是 headwise 自己的开销:它按 packed 序列逐条做 a2a 再
torch.cat拼回,光CatArrayBatchedCopy就是 262 ms / 15,984 次,chunkwise 优化后的前 8 大 kernel 则全是 GEMM。NCCL 略升是因为 CPU 不再拖后腿,collective 发得更密,单次 kernel 里等待 peer 的时间变长。
4. 端到端性能
4.1 口径
8×H200,Qwen3.5-9B,OpenMathReasoning-mini SFT,TP2 + SP,seed 1234,35 step,取 step 10–34 共 25 step。为让每一步都是训练步,关掉了 eval。每组 25 步的 token 数完全相同(7,270,905),吞吐可直接比。
个别 step 会出现 2–4 倍的偶发 stall(TFLOPs 同步塌陷,说明是同样的工作被拉长的环境噪声)。下表统一剔除
> 1.6 ×中位数的步并给出剔除数量——最终表中所有配置的剔除数均为 0。S3 整组复跑过一次以排除噪声,两轮吻合在 2.3% 以内(chunkwise 48,087 / 46,998,headwise 31,692 / 32,306,all_gather 23,043 / 23,186),表中用的是干净的复跑。4.2 S1 — Stage 2 的配置(full recompute,
max-tokens-per-gpu 8192)4.3 S2 — 关掉 recompute(
max-tokens-per-gpu 8192)all_gather 在这个场景跑不了:它在每张卡上重放完整序列的 scan,GDN 激活按全局上下文长度增长,Relax 的
_assert_gdn_full_recompute()因此强制要求 full recompute:chunkwise 和 headwise 全程保持 1/cp 分片,所以两者在这个配置下都能跑;差别在速度——chunkwise 快 50%,而且是全部实验里绝对吞吐最高的一组(54,254 tok/s,MFU 0.381)。
4.4 S3 — 拉长 pack(full recompute,
max-tokens-per-gpu 24576,即每 micro-batch 全局 98,304 token)序列拉长后 all_gather 从"略快于 headwise"掉到"慢 28%"——它重复 CP 倍的 scan,代价随上下文长度线性增长。
4.5 S4 — dynamic CP(
--dynamic-context-parallel,max CP=4,full recompute,8192)CP 逐 micro-batch 在 {1,2,4} 中选择,本轮实际用到 CP1 × 8、CP2 × 41、CP4 × 7 个 micro-batch。
优势收窄符合预期:CP=1 的 micro-batch 根本不做布局转换,CP=2 时 headwise 的 all-to-all 也便宜得多。这一组同时验证了 route 缓存在
cp_size/cp_rank逐 micro-batch 变化下的正确失效。4.6 A/B:收益确实来自本次改动
同一 harness、同一 spec,把 MCore 的两个文件换回 Stage 1 版本(无 route 预构建)再跑一遍:
两点交叉验证:
5. 正确性
test_gdn_chunkwise_cp_route.py,10 个测试函数、参数化展开 33 项,分三组:cp_size ∈ {1,2,4,8}× 两个方向 × 三种 packed 边界(单序列、不等长多序列、含重复边界即空 padding 槽),在本地模拟全体 rank 的 all-to-all,要求 route 产出的每一行都与 Stage 1 未改动的get_thd_context_parallel_rank_indices分区完全相同,同时断言相邻 rank 的 split sizes 互相对得上。2×cp整除、cu_seqlens不从 0 开始、非单调、非法方向,新旧实现必须同样报ValueError。cu_seqlens对象重建、dynamic CP 换cp_size/cp_rank重建、prebuild填充两个方向、非 THD / CP=1 时prebuild为 no-op。test_gdn_chunkwise_cp_layout.py(51)+test_gdn_cp_mode_stage2.py(19)全过。三个文件合计 103 项全过。test_gdn_chunkwise_cp_gpu.py10 项全过(20 分 36 秒),含真实 CP 组上 zigzag→contiguous→zigzag 的 token 级往返、CP=2 vs CP=1 的 fp32/bf16 前反向对齐、state_dict/sharded_state_dict一致性。mean train/loss全部落在 0.35111–0.35119,跨模式、跨 recompute、跨 sequence length、跨静态/动态 CP 一致。6. 已知边界
dev,对应 main 的是 #6233)。本 PR 的定位是"提前迁移其核心优化并验证收益",正确性由"与 Stage 1 已合入实现逐 token 对拍"保证。prebuild_thd_cp_partition_routes()已导出,待对象流理清后可直接切换。build_thd_cp_partition_route的返回类型要换回上游的编码张量形式。ThdCpPartitionRoute、_thd_cp_partition_route_is_reusable和 GDN forward 里的粘合代码可整体删除,改用上游的 block-level 调度器 + 数据侧 prebuild。