PyTorch多任务学习与多卡分布式并行训练调优实战 一个多任务模型同时跑分类、分割、深度回归三条线单卡还能勉强凑合一旦上到四卡八卡最先崩的往往不是显存而是训练节奏——某个任务的 loss 忽然反弹某个 rank 直接 hang 住或者吞吐量比单任务还低。PyTorch 的多任务学习和并行处理这两件事单独拿出来都不算难难的是它们叠在一起多任务带来了不平衡的 loss 量纲、稀疏的任务分支和梯度的相互拉扯并行处理又把这些复杂性复制到每个进程、每个通信桶里任何一个细节没对齐都会被放大成训练不收敛。我这两年把一套多任务训练框架从单卡折腾到多机踩过的坑基本都集中在任务调度和通信同步这两个交界处。下面这些内容适合已经能用 PyTorch 跑通单任务训练、准备把多个任务塞进同一个模型、并且需要靠多卡多进程把训练时间压下来的朋友。我会从整体设计思路讲起再到网络结构、损失加权、数据并行落地、显存优化最后把排查过程整理成能直接查的表。文中涉及的模型和数据集都是我搭的模拟项目思路和参数可以直接迁移。1. 整体设计与思路拆解为什么要这么组合1.1 多任务学习真正的价值点在哪里先把话说清楚多任务学习不是省一个模型这么简单。它的核心收益在于共享表征带来的正则化效应和标注数据的复用。举个我实际遇到的情况——一套图像处理 Demo需要同时输出区域分类、前景分割掩码和目标距离估计。如果拆成三个独立模型等于三份 backbone、三倍的前向开销而且分类任务标注量大、距离估计标注量小小任务单独训很容易过拟合。共享 backbone 之后分类带来的大量梯度给小任务的浅层特征提供了更丰富的监督信号小任务的泛化误差下来了不少。但这里有个容易被忽略的前提任务之间必须存在可共享的底层特征。分类靠的是语义分割靠的是边界和纹理距离估计靠的是尺度线索这三者在浅层是共通的边缘、颜色、梯度在深层就开始分化。所以我选择的是浅层硬共享、深层各自分支的结构而不是从第一层就分开。如果任务之间毫无关联比如把图像分类和音频分类硬凑一起共享层反而互相拖累这就是常说的负迁移。判断要不要上多任务我的经验是看三条任务输入是不是同一模态、标注能不能对齐到同一个样本粒度、单任务数据量是否都够。三条里有一条不满足先做单任务再考虑融合别一上来就搞多任务调参成本会把你劝退。1.2 并行处理的三条路线怎么选PyTorch 侧能用的并行手段大致分三类选错了会白折腾很久。并行方式解决什么问题典型手段适用边界数据并行单卡装得下模型训练太慢DDP、多进程 DataLoader绝大多数场景的首选模型并行切分单卡装不下模型参数/激活FSDP、张量并行、流水线并行大 backbone、长序列、高分辨率输入梯度累积/检查点显存不够但想用大 batchaccumulation、checkpointing、AMP单卡或小规模多卡多任务场景下我基本是数据并行打底 显存优化兜底的组合。原因很实在多任务模型通常是共享 backbone 加多个小 headhead 参数量很小瓶颈在 backbone 的激活值不在参数本身。所以先用 DDP 把吞吐拉起来再用混合精度和梯度累积压显存比一上来就上 FSDP 划算得多。FSDP 的切分和通信开销是有代价的参数规模没到那个量级纯属给自己添堵。真正需要切分模型的信号很明确单个 rank 上 backbone 的激活值就撑爆显存且减小 batch 已经影响到 BatchNorm 的统计稳定性。这时候再考虑 FSDP 的SHARDING_STRATEGY.FULL_SHARD或者对 backbone 做层级的流水线切分。1.3 组合方案的取舍逻辑把多任务和并行叠在一起最需要提前定下来的是任务采样策略和梯度同步粒度这两件事它们决定了后面所有代码的写法。任务采样上我试过两种。一种是每步混合采样——每个 batch 里按比例塞进不同任务的样本一次前向拿到所有任务的输出。好处是通信效率高一次 all-reduce 同步全部梯度坏处是不同任务的数据集要能拼到一起且 batch 内的有效样本数被摊薄。另一种是按步轮转——第 n 步只训任务 A第 n1 步只训任务 B。好处是每个任务的梯度是干净的坏处是每步都要同步通信次数翻倍而且某些 head 在当步没有梯度DDP 会报未使用参数的错误。我最后选的是混合采样理由是通信开销在多卡下是硬成本而混合采样天然适配 DDP 的一次性梯度同步。代价是要处理某任务在当前 batch 里没有样本的情况得用 mask 把对应的 loss 屏蔽掉这个后面会细讲。梯度同步粒度上我坚持一个原则所有任务的 loss 必须先在本 rank 内聚合成一个标量再做 backward。不要对每个任务分别 backward那样会触发多次 all-reduce通信量随任务数线性增长。聚合的方式就是加权求和权重怎么定是第 2 章的重点。2. 多任务学习的核心细节与结构实现2.1 硬共享、软共享与专家混合的取舍三种主流范式我都在模拟项目里跑过结论是硬共享是默认答案软共享是备选专家混合是特定场景的解药。硬共享就是共享底层、分叉高层。它的优势是参数量最省、推理最快、实现最简单。缺点是任务冲突不可控一旦某个任务的梯度主导了共享层其他任务就只能被动接受。我一般会在硬共享的基础上做两件事来缓解一是给每个 head 加一个独立的 BN 或者 LayerNorm让各任务有自己的归一化统计量二是让 head 的容量足够别用一个线性层草草了事两到三层的 MLP 往往能显著缓解 head 之间的干扰。软共享是每个任务各有一套完整网络在 loss 里加一个约束项让不同网络的中间特征保持接近比如对特征图做 L2 距离约束或者对注意力做一致性约束。它的好处是任务间干扰小坏处是参数量和显存都翻倍多卡下更吃紧。我一般只在任务差异极大、硬共享明显负迁移时才用。专家混合MoE 那一路比如 MMoE、PLE的思路是给每个任务配一组专家通过门控网络加权组合。它适合任务之间既有共性又有明显差异的场景比如用户行为预测里点击率和转化率的关系。代价是门控网络本身需要训练样本少的时候门控容易塌缩到只用一两个专家实际退化成硬共享。我在这套图像 Demo 里没上 MoE因为任务数只有三个硬共享加独立归一化已经够用。提示判断要不要升级范式别看论文里的指标看你自己项目里单任务 baseline 和多任务版本的差距。如果多任务版本在某个任务上比单任务差超过 3 个点才值得考虑换结构。2.2 损失权重与梯度冲突最容易翻车的地方多任务训练里我见过最多的翻车不是结构设计而是 loss 量纲没对齐。分类用交叉熵量级在 0.5 到 3 之间分割用 Dice 或者 BCE量级可能是 0.1距离回归用 L1量级取决于标签尺度可能是几十。三个 loss 直接相加回归任务直接主导整个训练分类和分割基本学不动。最朴素的解法是手工调权重我早期就是这么干的效果是调好了这个任务那个任务又崩了非常折磨。后来换成基于同方差不确定性的自动加权稳定性好了很多。原理不复杂假设每个任务的观测噪声是高斯分布噪声越大这个任务的权重就应该越小而噪声本身作为一个可学习参数参与训练。公式上每个任务的项变成L_i / (2σ_i²) log σ_i实现时用log σ_i²作为参数取指数就是精度。这个方法的妙处在于它是自适应的训练初期回归任务 loss 大σ 会被推大权重自动降下来等回归任务收敛了σ 回落权重又升上去。但它不是万能的如果某个任务本身就是病态的标签噪声极大它会把这个任务的权重压到接近零相当于放弃了这个任务。所以我会给它加一个下限约束或者对 σ 做 clamp。另一个必须处理的问题是梯度冲突。多任务共享层收到的梯度是各任务梯度的叠加如果两个任务的梯度方向相反就会互相抵消。检测方法很直接在共享层上分别算每个任务的梯度两两算余弦相似度如果长期为负说明存在冲突。缓解手段我用过两种一种是 PCGrad 的思路——当两个梯度夹角大于 90 度时把其中一个投影到另一个的法平面上去掉冲突分量另一种是 GradNorm动态调整权重让各任务的梯度范数保持接近。PCGrad 的实现不复杂核心就几行def project_conflicting(grads): # grads: list of list[Tensor]每个任务的梯度列表 n len(grads) for i in range(n): for j in range(n): if i j: continue dot sum((gi * gj).sum() for gi, gj in zip(grads[i], grads[j])) if dot 0: norm_j sum((gj ** 2).sum() for gj in grads[j]) 1e-12 coef dot / norm_j grads[i] [gi - coef * gj for gi, gj in zip(grads[i], grads[j])] return grads这段代码的代价是每个任务都要单独算一次梯度显存和时间都会涨。我的经验是只有在检测到明确冲突时才开 PCGrad而且只对共享 backbone 的最后几层开全网络开启性价比太低。2.3 一个可复现的多任务模型骨架结构上我遵循共享主干 独立归一化 head 统一输出字典的写法。输出统一成 dict后面无论是算 loss 还是做 DDP 封装都清爽。import torch import torch.nn as nn class SharedBackbone(nn.Module): def __init__(self, in_ch3, width32): super().__init__() self.stem nn.Sequential( nn.Conv2d(in_ch, width, 3, stride2, padding1), nn.BatchNorm2d(width), nn.ReLU(inplaceTrue), nn.Conv2d(width, width * 2, 3, stride2, padding1), nn.BatchNorm2d(width * 2), nn.ReLU(inplaceTrue), nn.Conv2d(width * 2, width * 4, 3, stride2, padding1), nn.BatchNorm2d(width * 4), nn.ReLU(inplaceTrue), ) self.out_ch width * 4 def forward(self, x): return self.stem(x) class MultiTaskNet(nn.Module): def __init__(self, num_classes10, head_hidden128): super().__init__() self.backbone SharedBackbone() c self.backbone.out_ch self.head_cls nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(c, head_hidden), nn.LayerNorm(head_hidden), nn.ReLU(inplaceTrue), nn.Linear(head_hidden, num_classes), ) self.head_seg nn.Sequential( nn.Conv2d(c, head_hidden, 3, padding1), nn.BatchNorm2d(head_hidden), nn.ReLU(inplaceTrue), nn.Conv2d(head_hidden, 1, 1), ) self.head_depth nn.Sequential( nn.Conv2d(c, head_hidden, 3, padding1), nn.BatchNorm2d(head_hidden), nn.ReLU(inplaceTrue), nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(head_hidden, 1), ) def forward(self, x): feat self.backbone(x) return { cls: self.head_cls(feat), seg: self.head_seg(feat), depth: self.head_depth(feat), }这里有几个刻意的设计。分类 head 用 LayerNorm 而不是 BatchNorm因为它接在全局池化之后维度是 1x1BN 在小 batch 下统计量不稳。分割 head 保留空间分辨率所以用 BN。深度回归 head 用全局池化后接线性层回归目标是一个标量不需要空间输出。三段 head 的容量都比一个线性层要大一些这是为了减少任务之间的干扰——head 越浅共享层的梯度冲突越容易直接传导。损失封装我用一个模块统管把自动加权和 mask 逻辑都放进去import torch import torch.nn as nn import torch.nn.functional as F class MTLLoss(nn.Module): def __init__(self, task_names, use_uncertaintyTrue, min_log_var-4.0, max_log_var4.0): super().__init__() self.task_names task_names self.use_uncertainty use_uncertainty self.min_log_var min_log_var self.max_log_var max_log_var if use_uncertainty: self.log_var nn.ParameterDict({ n: nn.Parameter(torch.zeros(())) for n in task_names }) def forward(self, outputs, targets, masks): raw {} if masks[cls].any(): raw[cls] F.cross_entropy( outputs[cls][masks[cls]], targets[cls][masks[cls]] ) if masks[seg].any(): raw[seg] F.binary_cross_entropy_with_logits( outputs[seg][masks[seg]].squeeze(1), targets[seg][masks[seg]].float(), ) if masks[depth].any(): raw[depth] F.l1_loss( outputs[depth][masks[depth]].squeeze(1), targets[depth][masks[depth]].float(), ) total 0.0 detail {} for n, loss in raw.items(): if self.use_uncertainty: lv torch.clamp(self.log_var[n], self.min_log_var, self.max_log_var) total total torch.exp(-lv) * loss lv else: total total loss detail[n] loss.detach() return total, detailmasks是每个任务在当批是否有样本的布尔掩码。这个设计在多卡混合采样下是必须的否则某个 rank 上某任务一个样本都没有对应的 head 就没有梯度DDP 默认会报错。这一点后面还会展开。3. 并行处理的落地实操3.1 多进程 DataLoader参数不是随便填的多卡训练首先卡住很多人的是数据加载。num_workers0时数据加载和计算串行GPU 利用率常年个位数num_workers开太大内存爆掉或者进程调度开销反而拖慢。我的经验公式是num_workers取min(8, CPU物理核数 // (GPU数 * 每GPU进程数))然后在这个基础上做实测微调。比如一台机器 32 物理核、8 张卡、每卡一个进程那每个进程分到 4 个 worker正好。如果开了超线程按物理核算别按逻辑核算否则上下文切换的代价会吃掉收益。其余几个参数的作用和取值要一起看参数作用我的常用取值注意点num_workers子进程数4~8超过 CPU 物理核数收益递减pin_memory锁页内存加速 H2D 拷贝True内存吃紧时关掉persistent_workers复用 worker 进程True避免每个 epoch 重建进程prefetch_factor每个 worker 预取 batch 数2~4与 worker 数相乘会占内存drop_last丢弃尾部不足一批True训练DDP 下必须否则各 rank 步数不一致drop_lastTrue在多卡下不是可选项而是必须项。如果各 rank 的最后一批样本数不同一个 rank 已经走到下一个 epoch另一个还在等同步整个训练就会 hang 住而且不报错只表现为卡死。我最早遇到这个问题时排查了整整一天最后靠打印每个 rank 的 step 计数才发现。还有一个容易忽略的点persistent_workersTrue必须配合num_workers0而且在 epoch 之间数据集状态比如随机种子的更新要放到 worker_init_fn 里否则每个 epoch 采出来的顺序是一样的。3.2 DDP 的标准落地步骤DDP 的接入流程我固化成五步照着走基本不会出结构性问题。第一步初始化进程组绑定当前进程的 GPUimport os import torch import torch.distributed as dist def setup_dist(): dist.init_process_group(backendnccl) local_rank int(os.environ[LOCAL_RANK]) torch.cuda.set_device(local_rank) return local_rank, dist.get_rank(), dist.get_world_size() def cleanup_dist(): dist.destroy_process_group()第二步构造 dataset 和 sampler。多任务场景下我用的是按任务分别建 sampler再组合from torch.utils.data import DataLoader from torch.utils.data.distributed import DistributedSampler def build_loaders(task_datasets, batch_size, world_size, rank): loaders {} samplers {} for name, ds in task_datasets.items(): sampler DistributedSampler( ds, num_replicasworld_size, rankrank, shuffleTrue, drop_lastTrue ) samplers[name] sampler loaders[name] DataLoader( ds, batch_sizebatch_size, samplersampler, num_workers4, pin_memoryTrue, persistent_workersTrue, drop_lastTrue, ) return loaders, samplers第三步模型包 DDP。这里的关键参数是find_unused_parametersfrom torch.nn.parallel import DistributedDataParallel as DDP model MultiTaskNet().cuda() model DDP( model, device_ids[local_rank], output_devicelocal_rank, find_unused_parametersTrue, gradient_as_bucket_viewTrue, broadcast_buffersTrue, )find_unused_parametersTrue在多任务里几乎躲不开。因为当某个 batch 里某个任务没有样本时对应 head 的参数不参与反向传播DDP 默认会认为这些参数未使用而报错。开启之后 DDP 会额外做一次图遍历性能损失大概在 5% 到 10%但换来的是稳定性。如果能把采样策略设计成每个 rank 每步都有所有任务的样本就可以关掉它我当时对比过关掉后吞吐提升约 8%。gradient_as_bucket_viewTrue是我建议默认打开的它让梯度的 bucket 直接作为视图省掉一次显存拷贝能明显降低显存峰值。broadcast_buffersTrue对含 BN 的模型必须开否则各卡上的 running_mean 和 running_var 会逐渐发散。第四步每个 epoch 前调sampler.set_epoch(epoch)。这行代码漏掉的话所有 epoch 的样本顺序完全一样分布式随机性等于没有。多任务下每个任务的 sampler 都要调。第五步日志和保存只在 rank 0 做if rank 0: torch.save({model: model.module.state_dict()}, ckpt.pt)用model.module拿原始模型否则 state_dict 的 key 会带module.前缀后面加载容易出问题。启动用的命令是torchruntorchrun --nproc_per_node4 --nnodes1 --node_rank0 \ --master_addr127.0.0.1 --master_port29500 \ train_mtl.py--nproc_per_node是每台机器的进程数通常等于 GPU 数。多机的话每台机器都要跑这条命令node_rank依次递增master_addr指向 0 号机器。3.3 显存优化与吞吐调优多任务模型的显存开销主要来自三个方面参数、优化器状态、激活值。前两者是固定的激活值随 batch 线性增长所以调优的主战场在激活值。混合精度是第一优先的手段。我用的写法是 PyTorch 2.x 的torch.ampscaler torch.amp.GradScaler(cuda) for step, batch in enumerate(loader): optimizer.zero_grad(set_to_noneTrue) with torch.amp.autocast(cuda, dtypetorch.float16): outputs model(batch[image]) loss, detail criterion(outputs, batch[targets], batch[masks]) scaler.scale(loss).backward() scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) scaler.step(optimizer) scaler.update()这里有三个细节值得说。optimizer.zero_grad(set_to_noneTrue)比默认的置零更快因为它只是把.grad置为 None省掉了写零的操作。unscale_之后再做梯度裁剪是必须的因为 scaler 会把梯度放大直接裁剪等于裁错了尺度。clip_grad_norm_在多任务下我固定开原因是自动加权会让不同阶段的 loss 量级剧烈变化梯度范数跳变是常态不裁剪容易炸。如果显存还是不够第二手段是梯度累积。注意累积时 loss 要除以累积步数否则等效学习率会放大accum_steps 4 loss loss / accum_steps scaler.scale(loss).backward() if (step 1) % accum_steps 0: scaler.unscale_(optimizer) torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0) scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_noneTrue)第三手段是激活检查点对 backbone 的每个 block 用torch.utils.checkpoint.checkpoint包一层。代价是前向要重算一次时间换空间大约多 30% 的时间换 40% 到 50% 的激活显存。我一般只在输入分辨率很高时才开。batch size 与学习率的对应关系要单独说。DDP 下有效 batch 是单卡 batch 乘以卡数如果沿用单卡的 lr学习率偏小收敛会明显变慢。线性缩放规则是lr base_lr * (global_batch / base_batch)base_batch 取 256、base_lr 取 0.1 是常见的组合但实际用的时候必须加 warmup否则训练前几百步容易发散。我通常用 500 到 1000 步的线性 warmup之后接余弦退火。4. 常见问题与排查实录4.1 训练不收敛与任务互相拖累的排查路径多任务最典型的问题是总 loss 在降但某个任务的指标不动甚至变差。我的排查顺序是固定的四步。第一步看每个任务的原始 loss 曲线不看加权后的总 loss。自动加权会把某些任务的权重压到很小总 loss 看着很漂亮实际上那个任务已经放弃了。我在 tensorboard 里给每个任务的原始 loss 单独开一条线这是判断问题的第一手证据。第二步跟单任务 baseline 对比。给同一个 backbone 单独训一个分类模型如果多任务的分类指标比单任务差 5 个点以上说明存在明显的负迁移这时候优先怀疑结构和权重而不是继续调学习率。第三步检查梯度余弦相似度。我在共享 backbone 的最后一层挂了个 hook每 100 步算一次各任务梯度的两两余弦相似度。如果长期低于 -0.2就是明确冲突考虑开 PCGrad 或者调整采样比例。第四步检查数据采样比例。这一步很多人会忽略。如果分类数据集有 10 万张分割只有 5000 张按自然比例混合采样分类的梯度会压倒性地主导共享层。我的做法是按1/sqrt(N_i)或者N_i^0.3的幂次做温度采样把大任务的采样概率压下来。实测下来把 10:1 的数据量差距压到 3:1 左右的采样比例小任务的指标能提升 4 到 6 个点。4.2 分布式训练卡死、OOM 与吞吐不达标的处理DDP 相关的问题我整理成了一张速查表按现象查就行。现象最可能的原因处理办法训练卡在第一步不动端口被占用或 NCCL 网络初始化失败换 master_port设NCCL_DEBUGINFO看日志训练中途 hang 住各 rank 步数不一致检查drop_last、数据集长度、条件分支报 unused parameters 错误某任务在当前 batch 无样本开find_unused_parametersTrue或保证每卡全任务采样显存 OOM 但 batch 不大激活值峰值、BN buffer 未共享开 AMP、set_to_noneTrue、检查是否在 rank 0 累积了额外张量吞吐远低于线性加速数据加载成瓶颈加num_workers、开persistent_workers、把预处理搬到 GPU各卡 loss 差异大BN 统计量发散或 sampler 未 set_epoch开broadcast_buffers、每 epoch 调set_epoch保存的模型加载后效果变差保存时用了 DDP 包装后的 state_dict改用model.module.state_dict()有几个点我想单独强调。卡在第一步不动八成是端口冲突尤其在共享机器上29500 这个默认端口经常被占。我现在的习惯是每次启动前随机挑一个 20000 到 30000 之间的端口。中途 hang 住最隐蔽。除了drop_last还有一种情况是代码里有if loss threshold: break之类的条件某个 rank 提前跳出循环其他 rank 还在等 all-reduce。DDP 里的控制流必须保证所有 rank 一致任何基于 loss 值的分支都要用dist.all_reduce先做全局同步再判断。吞吐不达标我一般的判断方法是看 GPU 利用率的波形。如果利用率呈现规律的锯齿高一段低一段说明数据加载跟不上加 worker如果利用率一直在 60% 到 70% 徘徊不上去可能是通信开销占比太高考虑加大 batch 让计算通信比上升或者检查是不是开了find_unused_parameters带来的图遍历开销。4.3 几个踩坑后才明白的细节NCCL 的亲和性设置。多卡机器上如果没有正确绑定 CPU 和 GPU 的 NUMA 关系跨 NUMA 访问会让通信带宽掉一截。我用numactl或者启动脚本里设OMP_NUM_THREADS把每个进程绑到对应的 NUMA 节点实测通信时间能降 10% 左右。这个不是必选项但在大规模下有感知。BN 在多任务里的隐性干扰。共享 backbone 里的 BN 层其 running statistics 是所有任务的数据混合统计出来的。如果任务间的数据分布差异大比如不同来源的图像亮度差很多混合统计量对每个任务都不是最优。我的处理是把 backbone 的 BN 改成 GroupNorm或者按任务做条件 BN。前者改动小、通用性好后者效果好但要改结构需要根据任务差异程度决定。检查点保存的频率与开销。多任务模型参数多保存一次可能几百 MB。如果每个 epoch 都存在多机共享存储上会成为瓶颈甚至阻塞训练。我改成每 N 个 epoch 存一次且只在 rank 0 上异步保存到本地盘再后台同步训练本身不受影响。评估阶段的 sampler。训练用DistributedSampler评估也必须用而且要shuffleFalse并且保证每个样本只被一个 rank 处理。如果直接用普通 DataLoader每个 rank 都会跑全量验证集时间乘以卡数。我见过有人为了省事在 rank 0 上做全量评估其他 rank 空等结果是评估阶段 GPU 利用率掉到 5%几卡的机器评估比单卡还慢。4.4 参数选择的经验数值汇总最后把几个关键参数的经验值整理一下这些是我在几个模拟项目里反复验证过的起点实际项目里要在此基础上微调。参数起点值调整方向单卡 batch size16~32显存允许就往上加全局 batch size256 附近与 lr 联动基础学习率0.1global 256按线性缩放warmup 步数500~1000大 batch 取大值梯度裁剪阈值1.0自动加权下建议常开不确定性参数 clamp[-4, 4]防止某任务被彻底放弃采样温度幂次0.3数据量差距大时下调num_workers4~8按 CPU 物理核分配这套参数不是最优解但能保证训练跑起来不崩、指标不塌。多任务和并行处理的调优本质上是个系统工程结构、损失、采样、通信四个环节里任何一个没对齐表现出来的都是不收敛而排查时最有效的手段永远是把每个任务的原始指标单独打出来看别被聚合后的总 loss 骗了。我在最近这个图像处理 Demo 上最后的配置是四卡 DDP、混合精度、不确定性加权、按 0.3 幂次温度采样三个任务的指标都超过了各自的单任务 baseline其中数据量最小的深度回归任务提升最明显。这个结果也印证了一件事多任务的价值不在于省模型而在于让小任务借到大任务的表征能力前提是你得把权重和采样这两件事调对。