正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码完全拆解(十四):权重装载——覆盖校验、三条加载通道与按 EP rank 物化专家
背景:一次装载要同时满足六件事
DeepInfer 的默认模型是 DeepSeek-V4.1-Flash(HF checkpoint deepseek-ai/DeepSeek-V4.1-Flash-1017)。按模型仓库里的 config.json,它由 40 层主干、一个共享的 lm head 占位、3 个 DFlash 草稿层组成;主干每层是 384 个路由专家、1 个共享专家、每 token 激活 6 个;权重以 FP8 E4M3、32×32 方块缩放存储,路由专家则是 FP4(E2M1)打包。再看运行时:DeepInfer 不做张量并行,注意力在数据并行副本之间分摊,MoE 走专家并行(EP)并叠加 EPLB 冗余专家,这一层的拓扑已经在 DeepInfer 源码拆解(十三):DP Attention 与 Wide-EP 里讲过。于是同一个 checkpoint,在不同的 EP rank 上需要装进显存的东西完全不同:这个 rank 本地物理专家槽位对应的权重、共享专家、注意力、embedding,还有 Engram 表被切分后属于本 rank 的那一段行。
把上面这些拼起来,一次“合法”的权重装载至少要同时满足六件事:
- 文件完整:
model.safetensors.index.json里列出的每个分片都在,每个分片可读,索引引用的每个张量确实存在于某个分片。 - 覆盖完整:模型在 meta 设备上“建”出来的每一个参数,最终都要有来源。注意这里不能简单地按“名字对得上就装”,因为 HF 把共享专家的
w1/w3拆成两个张量、把路由专家按 expert 编号拆开存储,而运行时把它们合并成了别的形状。 - 形状兼容:加载前就能发现
HF 形状 != 运行期形状,而不是读到一半在copy_里炸掉。 - 精度正确:FP8 的 scale 在 HF 里是 ue8m0(只有指数、没有尾数)的紧凑字节,运行时要转成 fp32 再按 GEMM 需要的布局重新打包;Expert 的 scale 则直接是打包好的字节,不能再走一遍“通用 FP8 scale 转换”。这两条路径一旦串了,数值就错,但不会报错。
- 切分正确:EP 下每个 rank 只拿本地专家;但 EPLB 的冗余专家是把别人的逻辑专家复制过来,所以“本地物理槽位 → 全局逻辑专家”的映射必须在装载前就建好,而且要和运行时 EPLB 初始布局完全一致。
- 内存可控:不能先把整个数百 GB 的 checkpoint 读进内存再分发,也不能对超大表(Engram)按整表读取——这类 checkpoint 通常在共享文件系统上,整表读要么把页缓存撑满,要么产生不可预测的随机 I/O。
图 1 是 DeepInfer 官方仓库给出的整体架构图(doc/images/architecture_overview.png)。权重装载属于最底下那层的“模型执行器”范畴:它负责把 HF 的权重变成 GPU 上一份份已经按运行时布局摆好的参数,上层调度、KV 管理、采样都默认这些参数已经就位。

本篇沿着一次 get_model 调用从外到内走:从 ModelConfig 解析 config.json、在 meta 设备上建参数壳,到 prepare_hf_coverage 的索引与覆盖校验,再到 load_covered_params 的三条通道,最后是 engram_preallocate 与运行时适配。中间会穿插对比 vLLM 的装载路径,说明 DeepInfer 为什么要多出这一整层“先规划、后读取”的结构。
从 config.json 到 meta 参数壳:模型还没加载,形状已经定了
get_model 是整条链路的入口,很短,但它规定了后面所有校验的前提:模型类通过 ModelRegistry 解析出来,构造过程被包在“默认 dtype + torch.device("cuda")”两个上下文里,创建完成后逐参数检查“是否在 CUDA 上”,然后进入 eval 模式、关闭梯度。
1def get_model(2 model_config: ModelConfig,3 skip_load: bool = False,4) -> nn.Module:5 model_class = _get_model_architecture(model_config)6 with _set_default_torch_dtype(model_config.dtype), torch.device("cuda"):7 model = model_class.load_model(8 model_config.model,9 model_config.model_cfg,10 skip_load,11 )12 for n, p in model.named_parameters():13 assert p.is_cuda, f"{n} not on cuda"14 if hasattr(p, "sf"):15 assert p.sf.is_cuda, f"{n}.sf not on cuda"16 return model.eval().requires_grad_(False)注意这里的两层设计。第一层,model_config.model_cfg 是从 config.json 解析出来的配置,不是模型本身——ModelConfig._load_model_cfg 调用 load_model_config,后者读取 config.json 并做严格校验。第二层,真正的参数壳是 load_model 里在 meta 设备上建出来的:所有参数只占形状、不占存储,这就把“建壳”和“填数”彻底分开了。
config.json 校验:先拒绝不支持的架构,再谈加载
load_hf_model_config 对 V4.1 的配置做了两轮“硬性断言”。第一轮针对文本模型:激活函数必须是 silu、注意力不能有 bias、dropout 必须是 0、num_key_value_heads 必须是 1(MLA 的共享 KV 头只有一个)、HF 顶层 dtype 必须是 bfloat16。第二轮针对量化配置:
1quant = hf["quantization_config"]2for key, expected in {3 "quant_method": "fp8",4 "expert_dtype": "fp4",5 "activation_scheme": "dynamic",6 "weight_block_size": [32, 32],7 "scale_fmt": "ue8m0",8}.items():9 if quant[key] != expected:10 raise ValueError(f"Unsupported HF {key}: {quant[key]!r}; expected {expected!r}")这些断言不是“多此一举”。load_covered_params 里的 FP8 逻辑假定:权重是 E4M3、缩放粒度是 32 个通道一组、scale 的存储格式是 ue8m0 且能被无损还原成 fp32。如果 checkpoint 是 128×128 方块(比如 DeepSeek-V3 的格式),那么后面的 transform_sf_into_required_layout 和 batched_transpose_weight_sf 的布局都会对不上。在入口处就把不兼容的配置挡掉,比在加载到第 10 个分片时抛一个形状错误要省事得多。
每个 rank 看到的 config 是不同的:finalize_model_config
进入 load_model 后,第一步不是读文件,而是 finalize_model_config(model_cfg)。它把推理时才知道的并行拓扑写进 gpt 配置:
1ep_group = PG.expert_parallel_group()2ep_size = ep_group.size() if ep_group is not None else 13
4model_cfg.gpt.max_num_decoding_tokens = model_cfg.gpt.max_num_seqs * (1 + envs.DEEPINFER_USE_NEXTN_DECODE)5model_cfg.gpt.max_num_prefilling_tokens = model_cfg.gpt.max_num_batched_tokens // PG.context_parallel_group().size()6
7min_num_extra_experts = envs.DEEPINFER_OVERRIDE_NUM_EXTRA_EXPERTS8_configure_moe_redundancy(model_cfg.gpt, min_num_extra_experts, ep_size)_configure_moe_redundancy 是关键。EPLB 要求“逻辑专家数 + 冗余专家数”能被 ep_size 整除,否则每个 rank 分到的物理槽位数不一样,n_local_routed_experts 就不成立。所以这里把用户给的“最小冗余专家数”(环境变量 DEEPINFER_OVERRIDE_NUM_EXTRA_EXPERTS,示例脚本里默认 32)向上取整到满足整除的值,并把结果写回 gpt.moe.n_redundant_experts 和 DFlash 层的 dflash_cfg.moe.n_redundant_experts。
这一步看似只是数字调整,但它决定了后面专家物化的核心参数:FusedExperts 用 n_routed_experts = 逻辑专家数 + 冗余专家数 来分配 (n_local, h_out, h_in//2) 打包 FP4 的存储,用 n_local_routed_experts = n_routed_experts // ep_size 来算每个 rank 的槽位数;而 _local_expert_ids 又会用“初始全局物理→逻辑映射”在这段槽位里取出真正属于本 rank 的逻辑专家编号。三者必须基于同一个取整后的冗余数,否则装载出来的专家顺序就乱了。
在 meta 上建壳:V41Builder 与参数容器的职责
build_model_on_meta 把默认设备切到 meta,交给 V41Builder:
1def build_model_on_meta(model_cfg: AttrDict) -> nn.Module:2 with torch.device("meta"):3 return V41Builder(model_cfg).build_model()DeepseekForCausalLM.load_model 在规划阶段直接调用 build_model_on_meta(model_cfg),所以参数壳从头到尾没有碰过真实数据。V41Builder.build_model 建出来的是一个扁平的 nn.Sequential:
1stage_modules: list[nn.Module] = [MultimodalEmbedding(gpt_cfg)]2stage_modules.extend(self._build_block(gpt_cfg, layer_id, shared_topology) for layer_id in range(gpt_cfg.num_hidden_layers))3stage_modules.append(MainPredictHeadHC(gpt_cfg, gpt_cfg.num_hidden_layers))4if nextn_predict > 0:5 ...6 for shift in range(1, nextn_predict + 1):7 layer_id = gpt_cfg.num_hidden_layers + shift8 stage_modules.append(9 DFlashNextPredictHeadHC(gpt_cfg, layer_id,10 self._build_block(gpt_cfg, layer_id, shared_topology, block_cfg=dflash_block_cfg),11 ...)12 )13model = HFModelParameters(stage_modules, self.cfg)14prepare_meta_fp8(model)模块编号和 HF 的命名一一对应,这不是巧合,而是 hugging_face/names.py 里那张表在事后能工作的前提。那张表的含义是:
| 内部模块编号 | 含义 | HF 前缀 |
|---|---|---|
| 0 | embedding 段 | 全局 embed.weight / image_* |
| 1..n_layer | 主干层 L = 模块编号 − 1 | layers.{L}. |
| n_layer+1 | 主 lm head / 最终 norm | 全局 head.weight / norm.weight |
| n_layer+2+i | DFlash 第 i 层 | mtp.{i}.(去掉 block. 这一级) |
这种“固定编号”的设计在后面的覆盖校验里非常重要:加载器需要在 meta 建好的模型上,按 module.module.{k}.xxx 这样的路径找参数,而不是靠名字模糊匹配。同时,DFlash 层的 MoE 可以通过 dflash_cfg.moe 覆盖主模型的 MoE 配置(更少的专家与激活数),_build_block 在构造时把 block_cfg 换成 DFlash 版本,于是 DFlash 层的 FusedExperts 和主干层的槽位数可以不同——这又给装载器增加了一条“不同层覆盖粒度不同”的分支。
MainPredictHeadHC 的构造里有一个容易忽略的点:lm head 的权重是 shared_head.head.weight。到了运行时 DeepseekForCausalLM.__init__ 阶段,ParallelLMHead 会直接引用 head_module.shared_head.head.weight,而不是拷贝。也就是说,lm head 在“容器阶段”和“运行时阶段”共享同一块存储。这种“先建壳、后包装”的模式贯穿整个 DeepInfer 模型层:容器只负责形状和命名,运行时类负责加 kernel 与并行元数据,两者共享 storage。这正是为什么加载必须分两步——先在容器上填数,再包装成运行时——如果反过来,运行时层会在构造时对权重做 process_moe_weight 之类的重排,而重排后的参数已经不再对应 HF 的形状。

prepare_meta_fp8:形状在 meta 上已经改成了 fp8
prepare_meta_fp8 在模型建好之后遍历所有模块,为 FP8 层声明 weight 与 sf 的 meta 形状:
1block = get_fp8_casting_granularity() # 322for name, module in model.named_modules():3 if "tok_emb" in name or "head" in name or getattr(module, "mxfp4", False):4 continue5 if getattr(module, "use_fp8_embedding", False):6 weight = module.embedding7 sf_shape = (weight.shape[0], weight.shape[1] // block)8 elif isinstance(module, nn.Linear) and getattr(module, "use_fp8_gemm", False):9 weight = module.weight10 sf_shape = tuple((dim + block - 1) // block for dim in weight.shape)11 else:12 continue13 assert weight.is_meta, "HF parameter preparation must not quantize initialized weights"14 weight.data = weight.data.to(torch.float8_e4m3fn)15 weight.sf = torch.empty(sf_shape, device="meta", dtype=torch.float32)逐条读这段有两个信息量很大的地方。
第一,sf_shape 用的是上取整的方块划分,即 ceil(dim / 32)。HF 导出时的 scale 也是按 32 的方块对齐的(不够 32 的部分补零后再算 scale,导出时去掉填充),所以两边的方块数一样,validate_hf_shapes 里才能直接比较“HF 张量形状”。一旦运行期某层的输出维度不是 32 的倍数(例如 Engram 融合投影的 hidden_size * (hc_mult + 1),5120×5 = 25600 是 32 的倍数,这里恰好成立,但这种“恰好”必须靠断言守住),这条路径就要改。
第二,这里跳过 head 与 tok_emb:embedding 和 lm head 是 BF16。这对应 load_covered_params 的普通通道——element_size 为 1 的参数才会去读 .scale;元素大小为 2 或 4 的参数只拷权重。再看 mHC(多路残差主干),它的 hc_attn_fn/hc_ffn_fn 是折叠进了 norm 权重的 FP32 小矩阵,loading.py 里专门校验它们是 F32,就是防止被 dtype 转换顺手降成 BF16。
覆盖校验:先规划,再读盘
prepare_hf_coverage 的三重检查
prepare_hf_coverage 在读任何权重之前,先完成三件事:
1index: dict[str, str] = json.loads(index_path.read_text())["weight_map"]2missing_shards = sorted(shard for shard in set(index.values()) if not (hf_dir / shard).is_file())3if missing_shards:4 raise FileNotFoundError(f"HF checkpoint {hf_dir} is missing shards: {missing_shards}")5if norms := sorted(name for name in index if re.search(r"(?:^|\.)hc_(?:attn|ffn|head)_norm\.weight$", name)):6 raise ValueError(f"HF requires folded HC weights; separate HC norm tensors are unsupported: {norms[:10]}")7if len({name.removeprefix("model.") for name in index}) != len(index):8 raise ValueError("HF index contains ambiguous model.-prefixed aliases")- 分片存在性:索引里有、磁盘上没有的分片直接报错,并列出名字。
- 折叠约束:HC 的 norm 必须已经折叠进
hc_*_fn,不允许出现独立的hc_attn_norm.weight这类张量。原因很直接:运行期的hc_attn_fn是一个整体矩阵,装载时只做“形状不变”的搬运,不做折叠运算。 - 别名歧义:HF 索引里有
model.前缀和没有前缀的同名项同时出现时,resolve_hf_name在“加/去前缀都命中”的情况下会解析失败——这里提前把它变成明确错误,而不是在加载中途遇到KeyError。这一条看似是防呆,其实对应一个常见现象:不同工具链导出的 checkpoint 有时对model.前缀处理不一致。
然后逐个分片打开,safe_open 读 keys,把索引里能解析到的名称加入 available 集合;如果索引里有某些张量在任何分片里都找不到,报错并打印前 10 个名字。
build_coverage:三条通道的覆盖建模
接下来 build_coverage 把 weight_map 整理成三个分组,也就是后面加载时的三条通道:
1@dataclass(frozen=True)2class HFCoverage:3 weight_map: dict[str, str]4 tensors: dict[str, tuple[str, ...]] # 普通参数:1 个或 2 个 HF 张量(w1/w3 合并)5 expert_tensors: dict[str, tuple[tuple[str, ...], ...]] # 路由专家:按层聚合6 engram_tables: dict[str, str] # Engram 表:按行区间读取7 aliases: dict[str, str] # tied 参数:目标 -> 已装载的源普通参数的扫描规则很朴素:跳过 .scale、跳过 _is_expert、跳过 Engram 表的 embed.weight,剩下的名字走 hf_to_model 得到内部名;如果内部名是 experts.shared_experts.0.linear1.weight,则要求它有 2 个来源(w1 与 w3),否则这组直接丢弃——也就是“缺一半就算没覆盖”,不会被加载器悄悄装成一半。
路由专家不是一个名字对应一个张量,而是一层对应很多张量。这里不能按“单个专家”做覆盖,原因在于运行期参数的形状:FusedExperts 的 linear1 是一个 ParallelRoutedLinear,它的权重形状是 (hidden_dim * 2, hidden_size * n_local_experts)——即所有本地专家沿输入维拼接,而且每个专家内部还按 [w1; w3] 拼成一个 2*hidden_dim 的输出。_scan_expert_coverage 正是按这个聚合粒度来建模的:
1layers.setdefault(match["prefix"], {}).setdefault(int(match["expert"]), {})[match["which"]] = name2...3if expert_ids != list(range(len(expert_ids))):4 logger.warning(f"{prefix}: expert indices are not contiguous ({len(expert_ids)}), so HF cannot fully cover this layer")5 continue6if any(set(per) != {"w1", "w2", "w3"} for per in experts.values()):7 continue8expected = gpt_cfg.moe.n_routed_experts9if prefix.startswith("mtp."):10 override = gpt_cfg.get("dflash_cfg", {}).get("moe") or {}11 expected = override.get("n_routed_experts", expected)12if len(experts) != expected:13 continue14module_prefix = hf_layer_module_prefix(prefix, n_layer=gpt_cfg.num_hidden_layers)15expert_tensors[f"{module_prefix}experts.linear1.weight"] = tuple((experts[e]["w1"], experts[e]["w3"]) for e in expert_ids)16expert_tensors[f"{module_prefix}experts.linear2.weight"] = tuple((experts[e]["w2"],) for e in expert_ids)这段代码里有四个约束值得展开。
- 专家编号连续:从 0 开始,不能有缺号。缺号意味着这个层在 HF 里是“残缺”的,运行时无法在形状上对齐。
- 每个专家都得有
w1/w2/w3且带.scale:缺一个就是整层不完整。 - 数量必须等于配置:主干层用
gpt.moe.n_routed_experts,DFlash 层(mtp.前缀)用dflash_cfg.moe.n_routed_experts。这一点在多篇 V4 系列文章里被反复提到——DFlash 层的专家数是和主干不同的,所以这里必须按层判断。 - 每个专家的权重和 scale 必须成对出现:
if scale_name(name) not in available: continue。FP4 打包权重如果没带 scale,装进去就是一堆无意义的半字节。这个约束在“覆盖”层面就挡住了。
注意 expected 用的是 n_routed_experts(逻辑专家数),不是加上冗余后的物理数。因为 HF checkpoint 里只有一份逻辑专家;冗余专家是运行时按物理槽位复制出来的。这一点正是后面 _local_expert_ids 和“一层一个 3D 打包参数”能够成立的关键。
Engram 表的覆盖最简单:权重与 .scale 必须都在,且通过 hf_to_model 映射到内部名。
映射函数:一次“名字对名字”的翻译,但有 1:2 的合并
hf_to_model 是纯字符串映射,但它做了几件事:
1_HF_TO_MODEL_SUFFIX = {2 "hc_attn_base": "hc_attn",3 "attn.attn_sink": "self_attn.attn_sink_params",4 "attn.wq_a.weight": "self_attn.Wq.A.weight",5 "attn.wq_b.weight": "self_attn.Wq.B.weight",6 "attn.wkv.weight": "self_attn.Wkv.A.weight",7 "attn.wo_b.weight": "self_attn.out_proj.weight",8 "attn.compressor.wkv.weight": "self_attn.PackedWkv.A.weight",9 "attn.indexer.wq_b.weight": "self_attn.index_attn.q_B.weight",10 "ffn.gate.bias": "experts.gate.e_score_correction_bias",11 # linear1 = [w1; w3] concatenated along dim 0.12 "ffn.shared_experts.w1.weight": "experts.shared_experts.0.linear1.weight",13 "ffn.shared_experts.w3.weight": "experts.shared_experts.0.linear1.weight",14 "ffn.shared_experts.w2.weight": "experts.shared_experts.0.linear2.weight",15 "engram.q_weight": "engram_embedding.weight_hidden",16 ...17}三件事值得注意:
- V4.1 的注意力权重被命名成了 MLA 的形状:
wq_a/wq_b是 q 的低秩投影两段;wkv,wo_a,wo_b分别是 KV 压缩与输出投影;compress_ratios中压缩率大于 1 的层还有一个compressor,对应运行时的PackedWkv;带索引器的层有indexer.*的几项。这些名字的语义在 DeepInfer 源码拆解(十一):注意力族与压缩 KV 里有完整拆解,这里只需知道:加载器只做名称翻译,不做语义解释。 attn.attn_sink映射到attn_sink_params:sink 在模型里是“参数”而不是“权重”,在loading.py里还专门检查其 dtype 必须是 F32——因为它是以有效 logit 的形式存的。- 共享专家的
w1与w3映射到同一个内部名:这意味着tensors通道里这一项会有两个来源,加载时沿 dim 0cat起来,对应linear1 = [w1; w3]。
还有一层前缀映射(由 hf_layer_module_prefix 完成):layers.{L}. → module.module.{L+1}.,mtp.{i}. → module.module.{n_layer+2+i}.block.,以及 mtp.*.embed.weight / mtp.*.head.weight 返回 None(因为它们是 tied,HF 只存一份)。最后一维的细节是 DFlash 层的 block. 这一级在 HF 里被省掉了——加载器必须在映射时补上,否则在 meta 模型上找不到对应参数。
提前做形状校验:validate_hf_shapes
读权重之前,load_hf_checkpoint 先调用 validate_hf_shapes:
1def validate_hf_shapes(params, coverage, *, hf_dir) -> None:2 todo = {cm: names for cm, names in coverage.tensors.items() if cm in params}3 index = coverage.weight_map4 names = list({name for sources in todo.values() for name in sources})5 ...6 for shard, sources in _group_by_shard(index, names).items():7 with safe_open(str(hf_dir / shard), framework="pt", device="cpu") as handle:8 keys = set(handle.keys())9 for name in sources:10 view = handle.get_slice(resolve_hf_name(name, keys))11 shapes[name] = tuple(view.get_shape())12 dtypes[name] = view.get_dtype()13 for cm, sources in todo.items():14 shape = shapes[sources[0]]15 if len(sources) > 1:16 if any(shapes[name][1:] != shape[1:] for name in sources):17 raise ValueError(f"{cm}: incompatible HF concatenation shapes")18 shape = (sum(shapes[name][0] for name in sources), *shape[1:])19 if shape != tuple(params[cm].shape):20 raise ValueError(f"{cm}: HF shape {shape} != model shape {tuple(params[cm].shape)}")21 ...这段最“省钱”的地方是 get_slice:它只读 safetensors 头部,不读数据。所以对每一个普通参数,加载器在真正搬运数万个小张量之前,就已经知道“HF 形状(含合并)是否等于模型形状”、“dtype 是否等于模型 dtype”,并且对 HC 与 sink 强制要求 F32。对大模型来说,这一步的耗时基本就是读几十个分片的头部。
但它只校验普通通道。专家通道里的“形状”是按层聚合的,而且还有更麻烦的 rank 物化问题——所以在加载专家时,_load_expert_params 会做另一组断言,我们稍后展开。
tied 参数:先装一次,再按需复制
DFlash 层的 embedding 与 lm head 都是 tied 的,HF 里只存储一份(embed.weight 与 head.weight)。但 meta 模型里,DFlash 层可能有独立的 emb.tok_emb.weight 与 shared_head.head.weight 参数壳——这是因为 V41Builder 对每个 DFlash 层都构造了 DFlashNextPredictHeadHC,而它内部确实有一个共享 head 的名称路径。cover_tied_parameters 解决的就是这件事:
1def cover_tied_parameters(coverage, param_names, *, n_layer) -> HFCoverage:2 aliases = dict(coverage.aliases)3 sources = {4 "emb.tok_emb.weight": "module.module.0.emb.tok_emb.weight",5 "shared_head.head.weight": f"module.module.{n_layer + 1}.shared_head.head.weight",6 }7 for cm in param_names:8 parts = cm.split(".", 3)9 if len(parts) != 4 or parts[:2] != ["module", "module"] or not parts[2].isdigit() or int(parts[2]) <= n_layer + 1:10 continue11 source = sources.get(parts[3])12 if source in coverage.tensors and source in param_names:13 aliases[cm] = source14 return replace(coverage, aliases=aliases)它把所有 module.module.{>n_layer+1}.emb.tok_emb.weight 与 ...shared_head.head.weight 标记成“别名”,其源是 embedding 段与主 lm head。加载器只会真正装一遍 source,然后在物化阶段,把同名的 storage 共享给这些别名参数。这样做有两个好处:显存里只有一份 129280×5120 的 embedding(BF16 约 1.2 GiB),DFlash 层再多也不重复;而且这种共享在参数层面就是同一个 tensor 存储,运行时直接读就行。
覆盖完整性:最后的防线
最后,load_hf_checkpoint 把所有参数名与 coverage 的 model_names() 对一遍:
1missing = sorted(set(params) - coverage.model_names())2if missing:3 raise RuntimeError(f"HF checkpoint {hf_dir} 未覆盖 {len(missing)} 个模型参数:{missing}")4validate_hf_shapes(params, coverage, hf_dir=hf_dir)5with checkpoint_load_watchdog():6 load_covered_params(gpt, coverage, hf_dir=hf_dir)7logger.info(f"HF 加载完成:全部 {len(params)} 个模型参数来自 {hf_dir}")这里要理解一个细节:set(params) 是这个 rank 自己的参数全集,但 coverage 是从 HF 索引生成的、全 rank 共享的覆盖表。所以“缺失检查”在 EP 下判断的是“模型里每个参数名在该 rank 对应的覆盖条目里有没有来源”;这一点依赖 cover_tied_parameters 已经把所有 DFlash 别名补进来了。另外,checkpoint_load_watchdog 给了 30 分钟的超时保护:超时就 os.abort(),而不是让 loader 挂死在一个坏分片上——对大规模 checkpoint 来说,这比“永远不完成”更可控。
物化:meta 壳如何变成 GPU 上的真实存储
覆盖校验通过后,load_model 进入“物化”阶段:把 meta 设备上的每个 nn.Parameter 换成一个真正在 CUDA(或 Engram 用的 CPU)上分配好的参数。这一段是 DeepInfer 和 vLLM 路径差别最大的地方,因为它必须在分配时就按运行时布局。
1engram_device = "cuda" if envs.DEEPINFER_ENGRAM_FORCE_GPU_STORAGE else "cpu"2materialized_by_name: dict[str, torch.nn.Parameter] = {}3for nm, m in gpt.named_modules():4 for pn, p in m.named_parameters(recurse=False):5 device = engram_device if getattr(p, "is_engram", False) else "cuda"6 packed_fp4 = ("experts.linear1" in nm or "experts.linear2" in nm) and model_cfg.gpt.mxfp47 alias_source = hf_coverage.aliases.get(f"{nm}.{pn}")8 if alias_source is not None:9 source = materialized_by_name[alias_source]10 assert p.shape == source.shape and p.dtype == source.dtype, f"Incompatible HF tie: {nm}.{pn} -> {alias_source}"11 materialized_p = torch.nn.Parameter(source.data, requires_grad=False)12 elif not skip_load:13 materialized_p = materialize_hf_parameter(p, device=device, packed_fp4=packed_fp4)14 else:15 ... # dummy16 for k in set(p.__dict__.keys()) - set(materialized_p.__dict__.keys()) - {"sf"}:17 setattr(materialized_p, k, getattr(p, k))18 setattr(m, pn, materialized_p)19 materialized_by_name[f"{nm}.{pn}"] = materialized_p三个要点。
第一,别名在这里是“共享 storage”而不是复制:torch.nn.Parameter(source.data, ...) 直接用源参数的 data 构造新参数,两个 Parameter 对象共享同一块存储。注意构造参数对象本身会重新注册到模块上,named_parameters() 的遍历会看到两个不同的名字但同一份存储——所以在计数时它们是不同参数,在显存里只是一份。
第二,packed_fp4 由“模块名 + 全局 mxfp4 标志”决定:名字里带 experts.linear1 / experts.linear2 的模块,如果模型配置是 FP4 专家,就按打包 FP4 分配。这个判断用的是模块名,而不是某个属性,是因为 meta 壳上的参数还没有 .n_experts——那是 ParallelRoutedLinear.__init__ 写在权重上的元数据,而 prepare_meta_fp8 又把权重 data 换成了 meta FP8,属性还在,但真正决定“这是不是专家参数”的,还是语义路径。
第三,除了 sf 以外的所有属性都要搬过去:layer_id、n_experts、einsum_info、is_engram、row_start、head_vocab_sizes 等都是加载和运行时需要的元数据;sf 单独处理是因为它要按通道拆开或重分配。
materialize_hf_parameter:一个函数里装下四种参数布局
1def materialize_hf_parameter(param: nn.Parameter, *, device: str, packed_fp4: bool = False) -> nn.Parameter:2 target = torch.device(device)3 if target.type == "cuda" and target.index is None:4 target = torch.device("cuda", torch.cuda.current_device())5
6 if packed_fp4:7 assert param.ndim == 2 and param.dtype == torch.float8_e4m3fn8 n_experts = getattr(param, "n_experts", None)9 assert isinstance(n_experts, int) and n_experts > 010 k, remainder = divmod(param.shape[0], n_experts)11 n = param.shape[1]12 sf_dtype = PLATFORM.fp8_sf_type13 assert sf_dtype in (torch.int16, torch.int32)14 sf_pack_granularity = 32 * sf_dtype.itemsize15 assert remainder == 0 and k > 0 and n > 0 and k % sf_pack_granularity == 0 and n % 32 == 016 assert get_fp8_casting_granularity() == 3217 assert hasattr(param, "sf") and param.sf.dtype == torch.float3218 assert tuple(param.sf.shape) == (n_experts * (k // 32), n // 32)19 result = nn.Parameter(torch.empty((n_experts, n, k // 2), dtype=torch.int8, device=target), requires_grad=False)20 # Pack UE8M0 bytes in the backend dtype, column-major within each expert.21 sf = torch.empty((n_experts, k // sf_pack_granularity, n), dtype=sf_dtype, device=target).mT22 else:23 ...先讲 packed_fp4 分支,因为它是 V4.1 最特殊的部分。
param 此时是 meta 上的 ParallelRoutedLinear.weight,形状是 (hidden_dim*2, hidden_size * n_local_experts)(linear1)或 (hidden_size, hidden_dim * n_local_experts)(linear2)。这里把它拆成 n_experts 份,每份 k × n,再分配成一个 3D int8 张量 (n_experts, n, k//2)。k//2 是因为 FP4 两两打包进一个字节——在 matmul 之前,kernel 需要的就是这种“每个专家的权重按输出通道放在第一维”的排布。
下面那组断言才是重点:
k % sf_pack_granularity == 0:这里的sf_pack_granularity = 32 * itemsize。若sf_dtype是 int32,即 128;k必须是 128 的倍数。这对应什么呢?对linear1来说k = 2 * moe_intermediate_size= 2 × 2304 = 4608,是 128 的整数倍(36×128);linear2的k = hidden_dim= 2304,是 128 的整数倍(18×128)。这个整除其实也是 TP4/FP4 专家在 SM100 上走 grouped GEMM 的要求:每一组专家的输出维(n)在内存里被 128 个 scale 包在一起。n % 32 == 0:输出维按 32 通道一组量化。对linear1的n = hidden_size= 5120(160 组),linear2的n = hidden_size= 5120——两者正好满足。sf.shape == (n_experts*(k//32), n//32):HF 导出的打包 scale 就是“每个专家、每个输入方块、每个输出方块一个 ue8m0 字节”。- scale 的布局是列优先:
torch.empty((n_experts, k // 128, n), dtype=sf_dtype, device=target).mT。这个mT不是随意加的。HF 导出的时候按“行主序”存储每个权重方块组的 scale,而运行时的 grouped GEMM 需要“每个输出通道的 128 个 scale 连在一起”。mT做的是 transpose 视图:分配的是(n_experts, k/128, n)的连续内存,返回的视图把最后两维转置,于是“每个专家、每个输出通道”在内存里是连续的。后面_load_expert_params里用scale.view(param.sf.dtype)再把每个专家的 scale 写进这个视图——字节直接搬,没有重新计算。
再看普通 FP8 分支:result 分配成与 meta 同形状、dtype 为 float8_e4m3fn 的参数;sf 是一个“空的”占位——如果 param.sf.is_meta,就再分配一个新的 meta 占位(因为普通 FP8 的 scale 加载时会整体替换,见下面);如果在 CPU/GPU 上已有存储,就沿用。这个分支和 packed_fp4 的差异在于:普通通道的 scale 是运行时从 ue8m0 还原成 fp32 再打包,因此分配阶段不需要知道“按什么布局”。
第三种布局是 Engram:if getattr(param, "is_engram", False) 时,sf 被分配在 CUDA 上,即使权重表在 CPU——原因是 gather_sf() 使用 CUDA 集合通信(all_gather_into_tensor),如果 sf 留在 CPU 会直接报错。
第四种是 BF16/FP32 的普通参数(embedding、head、norm、sink、HC 的 fn):它们走上图的 else 分支,形状、dtype 不变,直接分配。这一支看起来最平凡,但 load_hf_checkpoint 对 HC fn 与 attention sink 额外要求 F32,原因是它们存的是折叠后的精确值,降成 BF16 会直接改变残差主干的数值行为。
为什么 n_experts 一定要“是本地物理槽位数”
回到 ParallelRoutedLinear:
1class ParallelRoutedLinear(nn.Linear):2 def __init__(self, h_in: int, h_out: int, n_local_experts: int, *, use_fp8_gemm: bool = False) -> None:3 super().__init__(h_out, h_in * n_local_experts, bias=False)4 self.use_fp8_gemm = use_fp8_gemm5 self.n_local_experts = n_local_experts6 self.weight.n_experts = n_local_experts在 FusedExperts.__init__ 里,n_local_routed_experts = n_routed_experts // ep_size,其中 n_routed_experts 是加上了冗余的物理数。所以一个 (n_local, n, k//2) 的打包参数,第一维是“本 rank 的物理槽位数”。
这就把问题推给了读取端:HF 里是“按逻辑专家编号”的单个张量,怎么把它摆成按“物理槽位”排列的 3D 结构?逻辑专家编号和物理槽位编号不是一一对应的——以示例脚本的 EP=8、384 个路由专家 + 32 个冗余为例(384+32=416,416/8=52):rank 0 的物理槽位 0..51 对应逻辑专家 0..51;rank 7 的 local_start = 52 × 7 = 364,物理槽位 364..415 对应逻辑专家 364..383(20 个),再加上复制出来的逻辑专家 0..7(32 个)(get_initial_global_physical_to_logical_map 返回 [*range(384), *(i % 384 for i in range(32))])。也就是说,最后一个 rank 里会同时出现“本地逻辑专家”和“从 rank 0 复制过来的逻辑专家”。加载器必须知道每个物理槽位对应哪个逻辑专家。
_local_expert_ids:和 EPLB 初始布局一致的映射
1def _local_expert_ids(model, params) -> dict[str, tuple[int, ...]]:2 from deepinfer.model_executor.eplb import get_initial_global_physical_to_logical_map3 from deepinfer.model_executor.models.v4_modules.moe import FusedExperts4 from deepinfer.model_executor.parallel_utils.parallel_state import PG5
6 cm_of_param = {id(param): cm for cm, param in params.items()}7 ep_rank = PG.expert_parallel_group().rank()8 modules = sorted((m for m in model.modules() if isinstance(m, FusedExperts)), key=lambda m: m.layer_id)9
10 ids: dict[str, tuple[int, ...]] = {}11 for module in modules:12 num_routed = module.n_logical_routed_experts13 physical_to_logical = get_initial_global_physical_to_logical_map(num_routed, module.n_routed_experts - num_routed)14 local_start = module.n_local_routed_experts * ep_rank15 local_ids = tuple(int(i) for i in physical_to_logical[local_start : local_start + module.n_local_routed_experts])16 for attr in ("linear1", "linear2"):17 model_name = cm_of_param.get(id(getattr(module, attr).weight))18 if model_name is not None:19 ids[model_name] = local_ids20 return ids这段很短,但每一行都有意为之:
- 按
layer_id排序FusedExperts:保证遍历顺序与层号一致。虽然这里每个模块各自算映射,排序更多是为了日志与后续调试方便,但它也确保了“同名层”不会被混用。 num_extra_experts用的是“本层的物理数 − 本层逻辑数”,而不是全局环境变量。这一点至关重要:DFlash 层的n_routed_experts与主干不同,如果直接拿环境变量的冗余数去算,就会在 DFlash 层上得到一个长度不匹配的映射,physical_to_logical[local_start: local_start + n_local]取出来的内容也会错位。- 映射表是按层现算的:
get_initial_global_physical_to_logical_map是纯 Python 函数,返回[0..n-1] + [i % n for i in range(extra)]。它和ExpertLoadBalancer.__init__里初始化时用的函数是同一个。这意味着装载时的“物理槽位→逻辑专家”顺序与运行时 EPLB 的初始physical_to_logical_map完全一致。如果 EPLB 启动后第一次重排就 shuffle 权重,它是在这个初始布局上做搬移的;如果装载时顺序错了,那次 shuffle 会把错误的专家搬到新位置。 cm_of_param按id(param)建立索引:因为这里没有名字路径可用,但model_params(model)返回的cm与参数对象一一对应。这一步把“模块属性 → 参数名”接上了。
一个容易忽略的边界:physical_to_logical[local_start : local_start + n_local] 的长度必须等于 n_local。如果 n_routed_experts % ep_size != 0,FusedExperts.__init__ 的 assert 会先拦住;如果 _configure_moe_redundancy 取整写回的值和这里不一致,长度可能不足,_load_expert_params 里的 assert param.shape[0] == len(ids) 会兜底。
三条加载通道
物化完成后,load_hf_checkpoint 只做一件事:调用 load_covered_params。它内部依次执行三条通道:
1def load_covered_params(model, coverage, *, hf_dir) -> dict[str, int]:2 started = time.perf_counter()3 params = model_params(model)4 todo = {cm: names for cm, names in coverage.tensors.items() if cm in params}5 index = coverage.weight_map6 fp8_params, loaded = [], 07 with _TensorReader(hf_dir, index) as reader:8 for model_name, names in sorted(todo.items()):9 param = params[model_name]10 assert param.data.element_size() in (1, 2, 4)11 assert not getattr(param, "is_engram", False) and not getattr(param, "mxfp4", False)12 weight = reader.read_concat(names, param.device)13 if tuple(weight.shape) != tuple(param.data.shape):14 raise ValueError(f"{model_name}: HF shape {tuple(weight.shape)} != model shape {tuple(param.data.shape)}")15 param.data.copy_(weight)16 if param.data.element_size() == 1:17 sf = reader.read_concat((scale_name(name) for name in names), param.device)18 param.sf = _ue8m0_to_fp32(sf).contiguous()19 fp8_params.append(param)20 del weight21 loaded += 122 if fp8_params:23 set_ue8m0_sf(fp8_params)24 ...25 stats |= _load_expert_params(model, params, coverage, hf_dir)26 stats |= _load_engram_tables(params, coverage, hf_dir)通道一:普通参数(含共享专家 w1/w3 合并)
这是最简单也最“通用”的通道:
todo是 coverage 里有、而且这个 rank 的模型里确实存在的参数。按名字排序后遍历,保证多个 rank 的日志顺序一致。reader.read_concat(names, device)负责一个或两个 HF 张量:如果一个直接读,两个沿 dim 0cat。共享专家的linear1就是在这里被拼成[w1; w3]。- 形状在
copy_之前再断言一次(validate_hf_shapes已经检查过,但这里再检查一次是防止 meta 模型在物化时形状被改写)。 - element_size == 1 才是 FP8:这个判断比 dtype 更通用,也避免了
float8_e4m3fn和int8混用的问题(专家打包参数在这个通道里会被跳过,见下)。 param.sf = _ue8m0_to_fp32(sf).contiguous():这是整体替换,而不是原地写。_ue8m0_to_fp32把每个字节视为 exponent + 127 的偏移,用ldexp(1.0, exp)还原——因为 ue8m0 本身就是“2 的整数次幂”,所以还原是无损的。- 所有 FP8 参数收集起来后统一调用
set_ue8m0_sf,一次性调用tile_kernels.quant.batched_transpose_weight_sf,把 fp32 scale 转成运行时要的 ue8m0 打包布局,并挂到param.ue8m0_sf。注意这里存了两个东西:param.sf(fp32、供某些层读取)和param.ue8m0_sf(打包字节、供 DeepGEMM 直接用)。分开存是刻意的:不同 kernel 需要不同的 scale 形式,一个来源两种视图。
这里还藏着一个“为什么不能把大张量保留着再处理”的细节:del weight 在每个参数处理完后立即释放。对于 671B 级别的模型,任何一个普通参数(比如 embedding)都可能上 GB,如果保留在列表里等全部读完再统一处理,CPU 内存会瞬间爆掉。
通道二:路由专家(按层、按槽位、按 3D 打包)
1def _load_expert_params(model, params, coverage, hf_dir) -> dict[str, int]:2 if not coverage.expert_tensors:3 return {"expert_params": 0, "experts": 0, "expert_skipped": 0}4 index = coverage.weight_map5 ids_by_cm = _local_expert_ids(model, params)6 experts_loaded = params_loaded = skipped = 07
8 for model_name, per_expert in sorted(coverage.expert_tensors.items()):9 param = params.get(model_name)10 ids = ids_by_cm.get(model_name)11 if param is None: skipped += 1; continue12 if ids is None: raise RuntimeError(f"HF expert parameter has no local expert layout: {model_name}")13 assert param.dim() == 3 and param.dtype == torch.int8, f"{model_name}: expected native FP4 3D int8, got {param.shape} {param.dtype}"14 assert param.sf.dtype in (torch.int16, torch.int32), f"{model_name}: unsupported packed scale dtype {param.sf.dtype}"15 assert param.shape[0] == len(ids), f"{model_name}: local expert count {param.shape[0]} does not match the derived {len(ids)}"16 assert max(ids, default=-1) < len(per_expert), f"{model_name}: HF has only {len(per_expert)} experts, yet needs index {max(ids)}"17
18 with _TensorReader(hf_dir, index) as reader:19 for local, expert in enumerate(ids):20 names = per_expert[expert]21 weight = reader.read_concat(names, param.device)22 scale = reader.read_concat((scale_name(name) for name in names), param.device)23 want = tuple(param.data[local].shape)24 assert tuple(weight.shape) == want, f"{model_name}[{local}]: HF weight {tuple(weight.shape)} != runtime {want}"25 assert scale.dtype == torch.float8_e8m0fnu, f"{model_name}: expected UE8M0 scale, got {scale.dtype}"26 packed_sf = scale.view(param.sf.dtype)27 want_sf = tuple(param.sf[local].shape)28 assert tuple(packed_sf.shape) == want_sf, f"{model_name}[{local}]: incompatible HF scale"29 param.data[local].copy_(weight)30 param.sf[local].copy_(packed_sf)31 experts_loaded += 132 param.mxfp4 = True33 params_loaded += 134 return {"expert_params": params_loaded, "experts": experts_loaded, "expert_skipped": skipped}逐段解释:
① 跳过与报错:param is None 意味着这一层在这个 rank 的模型里不存在(比如某些层不是 MoE,或者这个 rank 没有 DFlash),计数为 expert_skipped;ids is None 则意味着“有参数但找不到本地布局”,这是不变量被破坏,直接抛异常。
② 形状断言:param.dim() == 3 and param.dtype == torch.int8 是“这条通道只接打包 FP4 专家”的硬约束。如果配置是 BF16 专家或 FP8 专家,它们会走普通通道(FP8 专家在 process_moe_weight 里转换成别的布局,见下);如果这里误入了 2D 的 ParallelRoutedLinear 壳(例如 skip_load 为假的正常路径里,mxfp4 为真时 materialize_hf_parameter 才会分配 3D),断言就会先炸。
③ scale 的 dtype 必须已经是打包格式:torch.int16 / int32 是 PLATFORM.fp8_sf_type——CUDA 上是 int32,Ascend 上是 int16(每 4/2 个 ue8m0 字节打包进一个整数)。这也是为什么前面物化时用 sf_dtype 来算 sf_pack_granularity。
④ 每个槽位逐个拼:对 linear1,per_expert[expert] 是两个名字 (w1, w3);read_concat 沿 dim 0 拼成 [w1; w3],形状是 (2*hidden_dim, hidden_size),正好等于 param.data[local] 的形状 (n, k//2)——注意这里比较的是字节形状:param.data 是 int8,而 weight 是 fp8 e4m3fn,两者 element_size 都是 1,所以形状相同。这正是 materialize_hf_parameter 里 k//2 的来源:HF 里的 FP4 专家已经是“每两个 4 位值打包成一个字节”,加载时直接拷贝,而不是从半精度重新量化。
⑤ scale 的直接拷贝:scale.view(param.sf.dtype) 把 ue8m0 的字节重新解释成 int32/int16。对 CUDA(int32)来说,param.sf[local] 的形状是 (k/128, n),而 HF 的 .scale 形状是 (k/32, n/32),元素数是 (k/32)*(n/32) 个 ue8m0 字节,恰好是 (k/128)*n 个 int32——两者字节数相同。于是 view + copy_ 就是把“每 4 个 ue8m0 字节打包”这件事,从 HF 的行主序变成运行时的列主序。这就是前面物化里 mT 的意义:整个转置是在分配时一次性完成的,加载时只需要字节复制,不需要 tensor 级别的转置运算。
⑥ param.mxfp4 = True:标记这个参数已经自带打包 scale,跳过通用 FP8 转换。否则 set_ue8m0_sf 在普通通道里会对它再调用一次 batched_transpose_weight_sf——那次转换会假定输入是 fp32,结果要么报错,要么静默写坏。
⑦ 每层结束时 _TensorReader 上下文退出:每个专家参数都新开一个 reader(虽然文件句柄会被 ExitStack 复用),但张量不保留——读完这个专家立刻拷进 param.data[local],然后丢弃。这样即便一层有几百个专家,CPU 侧峰值也只有“一个专家的权重 + 一个专家的 scale”。
⑧ 一个隐藏的时间特征:这是一个对所有本地专家逐项、逐层的双重循环。在 384 experts、EP=8 的情况下,主干每层每 rank 52 个槽位,40 层共 40 × 52 × 2 = 4160 次读取(linear1 与 linear2 各一次);DFlash 的 3 层每层 128 个逻辑专家 + 32 个冗余,物理 160 个槽位,EP=8 下每 rank 20 个,共 3 × 20 × 2 = 120 次读取。每个读取都会在所属分片里 seek 一次。由于分片是按张量大小排序切分的,同一层的专家往往落在不同分片里,所以这里的 I/O 模式是“按层随机读”,而不是顺序读。_TensorReader 之所以要“保留文件句柄 + 只读请求的字节区间”,就是为了这种模式:对每个分片只解析一次 header,之后靠 seek + readinto 完成随机读,避免 mmap 逐页 fault。
通道三:Engram 表(按行区间读,超大表分块)
1def _load_engram_tables(params, coverage, hf_dir) -> dict[str, int]:2 from safetensors import safe_open3 ...4 row_chunk_bytes = 1 << 28 # 256 MiB5 for model_name, weight_name in sorted(coverage.engram_tables.items()):6 param = params.get(model_name)7 ...8 row_start = int(getattr(param, "row_start", 0))9 with torch.device("cpu"), safe_open(str(hf_dir / shard), framework="pt", device="cpu") as handle:10 keys = set(handle.keys())11 weight_view, scale_view = handle.get_slice(weight_key), handle.get_slice(scale_key)12 table_shape, scale_shape = weight_view.get_shape(), scale_view.get_shape()13 valid_rows = min(param.data.shape[0], int(table_shape[0]) - row_start)14 rows_per_chunk = max(1, row_chunk_bytes // int(table_shape[1]))15 for start in range(0, valid_rows, rows_per_chunk):16 stop = min(start + rows_per_chunk, valid_rows)17 source = row_start + start18 weight = weight_view[source: source + (stop - start)]19 param.data[start:stop].copy_(weight)20 sf = _ue8m0_to_fp32(scale_view[source: source + (stop - start)].to(device=param.sf.device))21 param.sf.data[start:stop].copy_(sf)22 if valid_rows < param.data.shape[0]:23 # HF 不存对齐行;空的目标行需要两个值都补上24 param.data[valid_rows:].zero_()25 param.sf.data[valid_rows:].fill_(1)这里的核心是 NgramEmbeddingParameters 的行切分:每个 rank 拿到的表是“全局表的一段行区间”,row_start = engram_parallel_rank * num_per_part,而 num_per_part 是“全表行数按 256 的倍数对齐后 / engram_parallel_size”。所以这里不是按专家切分,而是按行切分:weight_view[source : source + rows] 取的就是本 rank 的那一段。
几条关键设计:
safe_open的get_slice只取一个行区间,不把整张 384006168 行的表读进来。table_shape[1]是每行的宽度embedding_dim = 256(EngramLayout.ngram_embed_dim取head_dim,不是 8 个 head 拼起来的 2048;2048 只是融合投影的输入维度),rows_per_chunk = 256 MiB / 256= 1,048,576 行。于是每块拷贝只占 256 MB 的 CPU 空间,不会让一个 3 亿行的表把内存吃光。- scale 先打包字节、后解码:
scale_view[...].to(device=param.sf.device)先把 ue8m0 字节搬到 CUDA,再_ue8m0_to_fp32解码。注意顺序:_ue8m0_to_fp32依赖scale.view(torch.uint8),在搬运之后做能避免在 CPU 上对小块做很多次 launch,也把“解码”放到了最终设备上。 - 对齐行补零、单位 scale:HF 表不存对齐行(表行数是 vocab 总量,按
lcm(256, engram_parallel_size)对齐后多出来的部分不在 HF 里)。这些行的权重写 0、scale 写 1。为什么 scale 是 1 而不是 0?因为 ue8m0 的 0 字节代表的指数是 -127,而 1 表示“2^0”;如果 lookup 命中了这些行,权重是 0,输出自然为 0,scale 取 1 不会引入 NaN。 - assert 是“跨分片一致”的:权重与 scale 必须在同一个分片里,否则
_shard_of不同会报错。这条约束来自 HF 导出时的同一张量分组。
一个与“运行时”相关的细节:Engram 表如果留在 CPU(DEEPINFER_ENGRAM_FORCE_GPU_STORAGE 未开启),后面的 EngramRuntime.initialize 会用 cuda_view_for_write 把这个 CPU 存储“零拷贝视图”成 CUDA 上的 view(通过 _construct_storage_from_data_pointer 直接复用数据指针),然后交给 DeepEP 的 userbuffer 做跨 rank gather。这也是为什么物化时 Engram 的 sf 必须分配在 CUDA 上——gather_sf 用 all_gather_into_tensor,CPU 张量没法参与集合通信。
专家通道之外:非 FP4 专家怎么走
上面讲的是“打包 FP4 专家”的直接加载通道。如果模型不是 mxfp4(例如 BF16 专家,或 FP8 专家),物化阶段 packed_fp4 为假,ParallelRoutedLinear 的权重就保留 2D 形状,然后:
- 加载时走普通通道:权重是 BF16 的话直接
copy_,是 FP8 的话顺带读 scale、还原、打包。 - 运行时包装时,
DeepseekMoE会调用process_moe_weight(moe_module.linear1.weight, n_experts=self.num_local_physical_experts):先把 2D(h_out, h_in*n_experts)reshape 成(n_experts, h_in, h_out),再用mT转成(n_experts, h_out, h_in)(这样每个专家的输出维在前,GEMM 时可以直接按专家分组),然后transform_sf_into_required_layout或per_token_cast转 FP4。FP8 → FP4 的转换还带一个((w.view(torch.int32) & 0x03030303) == 0).all()的断言,只接受“低两位为零”的 FP8 值(即 “FP4-QAT checkpoint”),防止把任意 FP8 量化到 FP4 时静默改变数值。
这个对比说明了一点:只有“natively FP4”的专家在加载时就能直接按运行时布局物化;其他精度走两段式(先按 HF 形状装,再在运行时重排 + 转换)。DeepInfer 对 V4.1 专门走前者,是因为这个 checkpoint 本身就是 FP4 导出的。

_TensorReader:一个类解决“别把整个文件读进内存”
1class _TensorReader(ExitStack):2 def read(self, name: str, device: torch.device) -> torch.Tensor:3 shard = _shard_of(self.index, name)4 if shard not in self.handles:5 handle = self.enter_context((self.hf_dir / shard).open("rb"))6 size = os.fstat(handle.fileno()).st_size7 prefix = handle.read(8)8 header_size = int.from_bytes(prefix, "little")9 if len(prefix) != 8 or not 0 < header_size <= min(100_000_000, size - 8):10 raise ValueError(f"Invalid safetensors header size: {shard}")11 header = json.loads(handle.read(header_size))12 self.handles[shard] = handle, header, 8 + header_size, size13 handle, header, data_start, size = self.handles[shard]14 entry = header[resolve_hf_name(name, header)]15 dtype, shape = self._DTYPES[entry["dtype"]], entry["shape"]16 start, end = entry["data_offsets"]17 if not 0 <= start <= end <= size - data_start or end - start != math.prod(shape) * dtype.itemsize:18 raise ValueError(f"Invalid HF tensor byte range: {shard}:{name}")19 if end == start:20 return torch.empty(shape, dtype=dtype, device=device)21 handle.seek(data_start + start)22 data = bytearray(end - start)23 view = memoryview(data)24 filled = 025 while filled < len(data):26 n = handle.readinto(view[filled:])27 if not n:28 raise EOFError(f"Short HF tensor read: {shard}:{name}")29 filled += n30 with torch.device("cpu"):31 tensor = torch.frombuffer(data, dtype=dtype).reshape(shape)32 return tensor.to(device=device)它做的事看起来和 safetensors.torch.safe_open 差不多,但有三处差异:
- 不走 mmap:直接
open("rb"),seek到data_start + start再readinto一段字节。safe_open背后是 mmap 映射整个分片,读到哪一页才 fault 哪一页;而专家通道与 Engram 通道都是“只取区间里的一小块”,显式seek + read让每次读取都成为可预测的连续区间读,对共享文件系统(NFS/Lustre)更友好,也不会让页缓存把大分片整片驻留。 - 严格的字节区间校验:
0 <= start <= end <= size - data_start加上end - start == prod(shape) * itemsize——不只是“文件在”,还要求这个张量的区间完整且尺寸吻合。对于截断的上传或写到一半的 checkpoint,这条检查会立刻发现异常,而不是读到后面才出现乱码。 - 返回时
to(device):在 CPU 上构造torch.frombuffer张量,然后搬到目标设备。注意torch.frombuffer的 data 是bytearray,to(device)会做一次拷贝;但如果目标设备正好是 CPU,to(device)其实是空操作,返回的 tensor 仍依赖那个bytearray的缓冲。在专家通道中,_TensorReader会读进最终设备;在普通通道中,每个参数的权重也会立刻拷贝到 GPU,因此 CPU 侧的内存峰值只有“当前参数”的大小。
还有一个细节:header[resolve_hf_name(name, header)]。resolve_hf_name 处理 model. 前缀歧义,而这里传入的 header 是 safetensors 的字典,它的 keys 集合正是同一个函数前面为索引检查时使用的 keys。所以索引与分片两侧的前缀处理是一致的。
和 vLLM 的路径对照
把 DeepInfer 的装载和 vLLM 的 DefaultModelLoader 放在一起,差异可以归结为两句话:
- vLLM 是“先物化、后迭代、由模型自己的
load_weights决定怎么装”:DefaultModelLoader.get_all_weights返回(name, tensor)的迭代器,模型类(例如DeepseekV3ForCausalLM)在load_weights里按名字匹配到自己的参数,并且负责所有“特殊布局”的处理(重复专家、tie、scale 转换)。loader 层面只做两件额外的事:“EP 权重过滤”和“加载后跟踪”。 - DeepInfer 是“先规划、后按布局物化、再严格三条通道”:它把“名字映射、覆盖、形状、tied 别名、专家物理槽位”全部在物化之前算出来,然后一次性装完。模型类(
DeepseekForCausalLM)在加载完成之后才被构造,所以模型层只处理运行时布局,不处理 HF 布局。
vLLM 的 EP 权重过滤有一个非常值得对照的逻辑:
1# When EPLB is enabled, redundant physical expert slots may map to2# logical experts that belong to other ranks in the default partition.3# The weight loader needs to see ALL logical expert weights so it can4# populate these redundant slots. Skip the filter entirely.5if parallel_config.enable_eplb:6 returnvLLM 面对 EPLB 时选择关闭按 rank 过滤,让每个 rank 看到所有逻辑专家的权重,由模型内部按物理槽位去挑。这当然正确,但代价是每个 rank 都要读更多的 tensor。DeepInfer 因为事先算好了 _local_expert_ids,可以按“本地物理槽位”精确地只读需要的那几十个逻辑专家(包括冗余的复制),所以在“每个 rank 读多少”这一项上是更省的。
vLLM 的 safetensors_weights_iterator 还有一个很实用的细节:它根据文件系统类型与内存是否够用,决定是否启用并行预读(prefetch),在 NFS 上默认打开。这个设计在 DeepInfer 里体现为 _TensorReader 的“按区间显式读”和 row_chunk_bytes 的分块,而不是多线程预读。两条路线都在试图解决同一个问题:不要让 loader 成为 GPU 等待的瓶颈。
加载后的“运行时适配”
DeepseekForCausalLM.__init__(model, cfg) 是在 from_model_cfg(gpt, model_cfg.gpt) 里被调用的,发生在装载之后。它做几件事:
- 把
gpt.module.module[: n_layer+1]与head_module交给DeepseekModel,让运行时层接管“前向 + mHC 主干 + 跨层共享”。 ParallelLMHead(cfg.vocab_size, hidden_size, weight=head_module.shared_head.head.weight):直接引用已装载的 lm head 权重。DeepseekMoE在构造时针对每个FusedExperts决定“直接复用打包参数”还是“调用process_moe_weight重排”,并且把初始的physical_to_logical_map交给ExpertLoadBalancer。EngramRuntime在load_model里被预分配(engram_preallocate),然后在 worker 初始化时initialize():把 CPU/GPU 上的表写入 DeepEP userbuffer,随后释放原始存储。
这一段的意义在于:装载层的输出不是“最终形态”,而是“运行时可以直接接管的形态”。例如,如果 _load_expert_params 把某个槽位装错了(比如本应是逻辑专家 371,却装了 0),DeepseekForCausalLM 在构造时是发现不了的——它只看形状和 dtype。错误会在第一次 forward 时以“路由到专家 371 但拿到专家 0 的权重”的方式,表现为奇怪的输出质量下降,而不是崩溃。这也是为什么 _local_expert_ids 要与 ExpertLoadBalancer 使用同一个初始映射函数:只有两者一致,正确的顺序才能被保证。
算一笔账:这些通道各自搬多少数据
前面的设计如果不落到数字上,很难判断“为什么要在意加载顺序”。拿 V4.1-Flash 的公开 config.json 和模型仓库的文件清单来估算(以下都是按配置推算的近似值,不是官方公布的装载耗时):
- 分片规模:HF 模型仓库的权重文件从
model-00001-of-00048到model-00048-of-00048,共 48 个分片;单分片大小约 0.97 GB 到 7.41 GB,总量约 342 GB(按十进制 GB 计)。 - 路由专家:每层 384 个逻辑专家,主干 40 层。每个专家的
w1/w3/w2合计3 × 2304 × 5120个元素;FP4 每元素半字节,所以每个专家约 16.9 MiB。单层约 6.79 GB,40 层约 271.8 GB——路由专家占了整个 checkpoint 的绝大部分。 - 专家 scale:每个专家的三块权重各有
(2304/32) × (5120/32) = 72 × 160 = 11,520个 ue8m0 字节,三块合计 34,560 字节(约 33.8 KiB);单层 384 个专家约 13.3 MB,40 层约 530 MB。scale 只占权重的约 0.2%,但它决定了 GEMM 能不能正确还原数值,不能省。 - Engram 表:配置里
engram_layer_ids = [1, 14],每层engram_num_embeddings分别是 384,006,168 与 384,016,682 行;engram_n_heads = 8、engram_head_dim = 256,所以每个 head 的嵌入是 256 维,一层总行数已经包含 3 个 n-gram 表 × 8 个 head 的分表(EngramLayout把这 8 个 head 各自的词表行数拆开、再拼成一个连续行空间)。每行的实际宽度是embedding_dim = 256个 FP8 值,不是 2048;n_embed_per_ngram = 8 × 256 = 2048是后续融合投影的输入总维度。按 1 字节/值算,单层约 98.3 GB(约 91.6 GiB),两层合计约 196.6 GB。这相当于把“第三个专家库”放进了模型文件,而且是按行切给各 rank 的。 - 普通参数与注意力:embedding 129280×5120×2 字节约 1.32 GB;lm head 同量级;每层的 q/kv 低秩投影、compressor、indexer、HC 的 fn 与 norm 加起来是百 MB 级,40 层合计远小于专家与 Engram。
用公式写出来更清楚。设 E 为逻辑路由专家数、L_moe 为主干 MoE 层数、h 为 hidden size、m 为 MoE intermediate size,则路由专家的 FP4 总字节为:
这里除以 2 是因为 E2M1 每个值只占 4 bit;3 是 w1、w3、w2 三块。代入 E=384, L_moe=40, h=5120, m=2304 得到约 2.72×10¹¹ 字节。Engram 单层则是:
R 是这一层的行数(3.84×10⁸),d_head=256。两个式子放在一起,就能看出为什么加载器要区分“普通参数一次性读”“专家按槽位读”“Engram 按行块读”:三者的单块大小差了三个数量级。
按行块读的块大小也是算出来的:
rows_per_chunk=⌊drow228⌋当 d_row=256 字节时,每块 1,048,576 行,权重块正好 256 MiB;对应的 scale 是每行 8 个 ue8m0(256/32),解码成 fp32 后每块约 32 MiB。这个量级的设计目标很明确:单块权重 + 单块 scale 的 CPU 峰值只有几百 MiB,而不是整层 98 GB。
离线转换 vs 运行时装载:两种工程路线
HF 模型仓库的 inference/convert.py 给出了另一条路线:先把 HF checkpoint 离线转换成“每个 rank 一个文件”的格式(model{i}-mp{mp}.safetensors),再让最小推理脚本直接读这些文件。它和 DeepInfer 的运行时装载形成了很有意思的对照。
convert.py 的关键动作是:
- 按
--model-parallel(MP)把权重切分到mp个文件。哪些张量按什么维度切,取决于mapping:
1mapping = {2 "embed": ("embed", 0),3 "wq_b": ("wq_b", 0),4 "wo_a": ("wo_a", 0),5 "wo_b": ("wo_b", 1),6 "head": ("head", 0),7 "attn_sink": ("attn_sink", 0),8 "weights_proj": ("weights_proj", 0),9}- 对路由专家,按专家编号的连续区间分给各 rank:
idx < i * n_local_experts或>= (i+1)*n_local_experts就跳过,所以 rank i 拿到第i * n_local到(i+1) * n_local个专家。这个切分是静态的、与 EPLB 无关。 - 对 Engram 表,按行切分:
shard_size = ceil(rows / mp),各 rank 取自己的行段,最后一段不足则补 0(权重)或补 1(scale)。 wo_a被反量化成 BF16(weight * scale后flatten再bfloat16),因为最小推理脚本把它当作普通线性层使用。- 专家权重可以保留 FP4(
view(torch.float4_e2m1fn_x2)),也可以转成 FP8(cast_e2m1fn_to_e4m3fn,带一个MAX_OFFSET_BITS=6的偏移技巧)。
DeepInfer 的运行时装载与它的本质差别有三点:
- 切分依据不同:
convert.py按“专家编号连续区间”切;DeepInfer 按“EPLB 物理槽位”切。当 EPLB 开启时,物理槽位到逻辑专家的映射不是连续区间,convert.py的静态切分无法表达冗余专家,所以 DeepInfer 必须自己按映射取。 - scale 的处理不同:
convert.py对wo_a直接反量化成 BF16;DeepInfer 保留 FP8 权重与 scale,并在运行时按 GEMM 需要的布局打包。前者简单,后者保留了 FP8 的算力优势。 - 文件形态不同:
convert.py产出每个 rank 一个文件,运行时顺序读即可,不需要处理model.前缀、共享专家合并、tied 别名;DeepInfer 则直接读官方 HF 分片,省掉了“再存一份几百 GB”的磁盘与时间成本,但把复杂度放进了加载器。
如果只是做一次性的最小推理验证,convert.py 的路线更省心;如果要支持 EPLB、动态专家放置、Ascend/NVIDIA 双平台,并且不想维护第二份 checkpoint,DeepInfer 的“按运行时布局直接装载”就是更合理的选择。代价是加载器必须知道大量运行时语义——这也是本篇花了大量篇幅讲 _local_expert_ids、mxfp4、ue8m0_sf 的原因。
装载的时间与内存:阶段日志说明了什么
DeepseekForCausalLM.load_model 用 log_load_phase 给每个阶段打日志:
1load_started = started = time.perf_counter()2...3hf_coverage = HFCoverage() if skip_load else prepare_hf_coverage(...)4gpt = build_model_on_meta(model_cfg)5...6started = log_load_phase("plan_build", started)7...8started = log_load_phase("materialize", started)9...10started = log_load_phase("engram_preallocate", started)11if not skip_load:12 load_hf_checkpoint(...)13started = log_load_phase("checkpoint", started)14...15model = cls.from_model_cfg(gpt, model_cfg)16del gpt17log_load_phase("runtime_adapt", started)18log_load_phase("total", load_started)而 log_load_phase 会先 torch.cuda.synchronize(),再记录 allocated、peak、reserved 三个显存指标。这个顺序很关键:加载期间的 to(device) 是异步的,如果不同步,日志里的“checkpoint 阶段耗时”会把真正的 H2D 拷贝算到下一阶段;同步之后,每个阶段的数字才对应真实的设备完成时间。reset_peak_memory_stats 在开头调用,所以 peak 是整个装载过程的峰值,而不是上一个阶段的残留。
从数据量可以推断各阶段的瓶颈:
| 阶段 | 主要工作 | 主要瓶颈 |
|---|---|---|
plan_build | 读索引、校验分片、meta 建壳、tied 别名 | 元数据 I/O 与 CPU;几乎不占 GPU |
materialize | 按参数分配 GPU/CPU 存储、搬迁属性 | 分配器与元数据;显存峰值开始上升 |
engram_preallocate | 为 Engram 表分配 userbuffer 并计算 scale 打包 | DeepEP/userbuffer 分配;CPU 表可能仍在磁盘 |
checkpoint | 三条通道读字节、拷贝、scale 转换 | 分片随机读 + H2D;专家按槽位、Engram 按块 |
runtime_adapt | 构造 DeepseekForCausalLM、包装层、EPLB 初始化 | 运行时对象的元数据与少量重排 |
checkpoint 阶段通常是总耗时的大头,原因是它同时受 I/O 与 H2D 限制。两个通道的优化点不一样:
- 普通参数的读取是“大块 H2D”,受 PCIe/NVLink 带宽限制;对 1.3 GB 的 embedding,单次拷贝就能占满链路。
- 专家通道是“按槽位的小块随机读 + 每块 H2D”,受存储延迟与 IOPS 限制;
_TensorReader不 mmap、只 seek 读区间,正是为了让每一块都可预测。 - Engram 通道是“块内顺序读 + scale 解码 + H2D”,块大小 256 MB 是为了让 H2D 有足够大的连续传输,同时不让 CPU 缓冲过大。
checkpoint_load_watchdog 的 30 分钟超时也是按这个量级设的:如果分片在远端 NFS、带宽不足,或者某个专家张量被切成了非常小的随机块,装到几十分钟是完全可能的;超时 abort() 比让进程一直挂着更好排查。
另外,skip_load=True 的 dummy 路径并不是“跳过校验”,而是跳过 prepare_hf_coverage 与真实读取,用 init_dummy_weight 与 process_moe_weight 生成同形状的假权重。它主要用于 profile 阶段:profile_num_available_blocks 需要一个形状正确、显存占用正确的模型,但不需要真实数值。理解这条路径有助于区分“加载器的职责”和“profile 的职责”——前者保证数值与布局正确,后者只关心显存预算。
一些容易踩的坑与对应断言
把上面的路径合起来看,会遇到几个典型问题;理解它们分别落在哪条断言上,有助于以后修改加载器:
- “模型在 meta 上建出来了,但有个参数永远没有来源”:
load_hf_checkpoint的missing检查会列出名字。最常见的原因是 layer 命名映射出错(例如某个新加的注意力子模块没有在_HF_TO_MODEL_SUFFIX里登记),或者mtp的block.前缀没被补上。它只发生在加载前,所以修复起来最便宜。 - “形状对不上,而且是在某个专家上第一个报错”:
_load_expert_params的param.data[local]断言会指出层名、本地槽位、HF 张量名。通常意味着expected的专家数与配置不一致(比如 checkpoint 是另一版本的 V4),或者 scale 的方块粒度与sf_pack_granularity的整除约束不一致。 - “加载完成,但 FP8 数值看起来像放大/缩小了”:优先检查
_ue8m0_to_fp32的还原是否与 HF 的.scale语义一致(指数偏移 127),以及set_ue8m0_sf是否被在错误的顺序调用(例如在专家参数上也调用了一次)。专家通道跳过通用转换靠的是param.mxfp4 = True,如果某个路径把mxfp4清掉了,就会触发这条问题。 - “EP rank 的专家输出比预期差”:基本就是
_local_expert_ids的映射与运行时的 EPLB 初始布局不一致。检查n_redundant_experts是否被_configure_moe_redundancy向上取整,以及 DFlash 层的dflash_cfg.moe.n_routed_experts是否参与计算。若 EPLB 已经 shuffle 过,先看初始布局阶段的输出。 - “Engram 的 lookups 全为 0 或完全乱掉”:一是
rows_per_chunk的下标是否用对了row_start;二是sf的gather_sf是否在 CUDA 上;三是“对齐行补零/scale=1”是否被执行(若补的是 scale=0,命中空行时权重×scale 为 0 但某些 kernel 可能除零)。 - “加载卡住,40 分钟还没结束”:
checkpoint_load_watchdog的 30 分钟超时会abort()。排查方向是分片是否在可低延迟的存储上、是否有_TensorReader在等一个特别大的字节区间。对专家通道,每层的专家散落在不同分片,是随机读;Engram的 256 MB 块在 NFS 上可能明显更慢。
小结
这篇走完了 DeepInfer 的权重装载:
- 入口:
get_model在“默认 dtype + 当前设备”的上下文里构造模型,并要求所有参数最终在 GPU;V4.1 的config.json在入口就被严格校验,不兼容的量化格式不会走到加载逻辑。 - 建壳:
config.json→finalize_model_config(把 EP 取整、Dflash 覆盖写进配置)→V41Builder在 meta 上建出固定编号的模块序列 →prepare_meta_fp8把 FP8 层声明成 fp8 形状。 - 规划:
prepare_hf_coverage做“分片存在、无歧义别名、HC 折叠”三重检查,build_coverage建了普通/专家/Engram 三条通道,cover_tied_parameters处理 DFlash 的 tied,validate_hf_shapes只读头部校验形状。 - 物化:
materialize_hf_parameter按“普通 FP8 / 打包 FP4 专家 / Engram / 其他”四种布局分配;“专家 3D 打包 + 列优先 scale”是 V4.1 快路径的核心。 - 加载:普通通道处理合并与 scale 还原;专家通道用
_local_expert_ids得到“物理槽位 → 逻辑专家”,按层按槽位拷贝字节(含冗余复制);Engram 通道按行区间分块读并补齐对齐行。 - 适配:
DeepseekForCausalLM构造函数在装载之后被调用,把容器形态转成运行时形态;此时 EPLB 的初始布局必须与装载期完全一致。
从系统层面看,这条路径把“加载”做成了一个有规划的编译期步骤:数据布局、槽位映射、别名共享在前,字节搬运在后。这既是 DeepInfer 针对 V4.1 的一次定制,也体现了推理框架在超大 MoE checkpoint 上的共性方向——装载时间、内存峰值与“运行时正确性”三者必须同时被设计,而不能把其中一个留给运行期去补救。
下一篇将从装载之后的 profile_num_available_blocks 接着走:真实的 GPU 显存预算如何减去模型峰值、非 PyTorch 分配与 state cache,再换算成 KV block 数;以及为什么这个数字要在所有 rank 之间做 all-reduce 后取最小并向下取偶。
参考资料
- DeepInfer:DeepSeek 高性能大规模推理框架(官方仓库)
- DeepInfer 权重装载源码:
deepinfer/hugging_face/weights.py - DeepInfer 索引与覆盖校验源码:
deepinfer/hugging_face/loading.py - DeepInfer 名称映射源码:
deepinfer/hugging_face/names.py - DeepInfer 模型装载入口:
deepinfer/model_executor/model_loader.py - DeepInfer V4.1 参数容器:
deepinfer/model_executor/models/v4_modules/containers.py - DeepInfer V4.1 构建器:
deepinfer/model_executor/models/v4_modules/builder.py - DeepInfer 路由专家与运行时 MoE:
moe.py与mlp.py - DeepInfer EPLB 初始布局与权重重排:
deepinfer/model_executor/eplb/__init__.py - DeepInfer Engram 运行时:
deepinfer/model_executor/models/v4_modules/engram.py - DeepInfer 官方整体架构图:
doc/images/architecture_overview.png - DeepSeek-V4.1-Flash 模型卡(含
config.json与inference/convert.py) - DeepSeek-V4.1-Flash-1017 模型卡
- DeepSeek-V3 技术报告(FP8 128×128 方块缩放与
weight_scale_inv约定) - DeepSeek-V3 权重文件说明(
README_WEIGHTS.md:FP8 存储格式与反量化公式) - NVIDIA Technical Blog:Per-Tensor and Per-Block Scaling Strategies for Effective FP8 Training
- safetensors:一种安全、快速的张量文件格式
- PyTorch meta 设备文档
- vLLM
DefaultModelLoader源码(EP 权重过滤与权重跟踪) - vLLM
safetensors_weights_iterator源码(预读策略与按 rank 跳过专家) - DeepSeek 开源算子库 TileKernels(
quant与engram内核) - DeepSeek 开源通信库 DeepEP(Engram userbuffer 与专家通信)
- DeepSeek 开源负载均衡库 EPLB
- FlashMLA(MLA 注意力内核)
- DeepGEMM(FP8 GEMM 与
transform_sf_into_required_layout)
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



