前言
最近在跑多卡GPU,发现一些小问题,于是乎写下来记录一下,主要是3个问题:
- Signal 9 (SIGKILL)
- in-place operation error
- npz 与 分散小样本
正文
1. Signal 9 (SIGKILL)
这个Error,其实很简单,是因为your job ran out of memory.
,简单来说,显卡爆了,减小batch size
,或者用更大显存
的显卡即可。
========================================================
train_sft_multi_gpus.py FAILED
--------------------------------------------------------
Failures:
<NO_OTHER_FAILURES>
--------------------------------------------------------
Root Cause (first observed failure):
[0]:
time : 2025-07-22_18:49:06
host : k28g30.tier2.hpc.kuleuven.be
rank : 0 (local_rank: 0)
exitcode : -9 (pid: 3377681)
error_file: <N/A>
traceback : Signal 9 (SIGKILL) received by PID 3377681
========================================================
2. in-place operation error
这件事情,发生在多卡GPUadversarial training
的时候,正常训练的时候,完全没有问题,为什么一旦加入AE就有问题了呢,报错如下:
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation: [torch.cuda.FloatTensor [256]] is at version 125; expected version 124 instead. Hint: enable anomaly detection to find the operation that failed to compute its gradient, with torch.autograd.set_detect_anomaly(True).
E0722 19:05:16.550000 22378720421696 torch/distributed/elastic/multiprocessing/api.py:826] failed (exitcode: 1) local_rank: 0 (pid: 3383252) of binary: /data/leuven/360/vsc36044/miniconda3/envs/rl_robust/bin/python
Traceback (most recent call last):
原因是由于我们的模型有in-place操作,而为什么这样的操作会在(多卡GPU)有问题呢,根据我们调查发现,这样的操作不仅仅在多卡有问题,问题是出现在了我们用了两次model
,但是因为由于in-place
操作的存在,导致两次model
的参数不一致,从而造成了gradient computation
出现了问题,其实这样的问题单卡也存在,但是单卡不够严格,DDP足够严格,所以把这样的错误检测出来了。
# --- 分支2: TRADES风格的对抗训练 ---
images.requires_grad = True
# 1. 计算干净样本的损失
outputs_clean = model(images) <---- 第一次in-place operation
loss_clean = criterion(outputs_clean, labels)
# 2. 生成对抗样本
grad = torch.autograd.grad(outputs=loss_clean, inputs=images, grad_outputs=torch.ones_like(loss_clean),retain_graph=True)[0]
images_adv = fgsm_attack(images.detach(), epsilon, grad.detach())
# 3. 计算对抗样本的损失
outputs_adv = model(images_adv) <---- 第二次in-place operation
loss_adv = criterion(outputs_adv, labels)
# 4. 组合损失
loss = loss_clean + beta_adv * loss_adv
loss.backward()
以上的两次in-place
计算:outputs_clean = model(images)
和 outputs_adv = model(images_adv)
造成了这个RuntimeError
,让我们来看看究竟是什么in-place operation
,我们打开模型发现:
class CNNModel(nn.Module):
def __init__(self):
super(CNNModel, self).__init__()
self.conv1 = nn.Conv2d(3, 264, kernel_size=4, padding='same')
self.bn1 = nn.BatchNorm2d(264) <---- in-place operation
self.conv2 = nn.Conv2d(264, 128, kernel_size=4, padding='same')
self.bn2 = nn.BatchNorm2d(128) <---- in-place operation
self.pool = nn.MaxPool2d(kernel_size=2, stride=2)
self.drop1 = nn.Dropout(0.2)
...
这是模型的其中一段结构,然而这里面我们发现了问题,BatchNorm2D
是in-place operation
,这就造成了问题。
更具体的解释:在计算梯度时(也就是执行loss.backward()
时),PyTorch发现有一个用于计算损失的张量(Tensor
)被“原地修改 (inplace operation
)”了,导致它无法正确地回溯计算图。这是一个非常经典的PyTorch报错,让我们来详细解读一下。
最核心的问题
你的日志中最关键的错误信息是这一句:
RuntimeError: one of the variables needed for gradient computation has been modified by an inplace operation
更具体的解释:在计算梯度时(也就是执行loss.backward()时),PyTorch发现有一个用于计算损失的张量(Tensor)被“原地修改 (inplace operation)”了,导致它无法正确地回溯计算图。
为什么会发生这个错误?
让我们在你进行对抗训练的那个代码分支里,一步步看发生了什么:
- outputs_clean = model(images):模型对干净图片进行前向传播。
- loss_clean = criterion(outputs_clean, labels):计算出干净样本的损失。为了之后能计算梯度,PyTorch会构建一个计算图,记住loss_clean是如何通过outputs_clean和模型权重计算出来的。
- grad = torch.autograd.grad(...):你计算了loss_clean相对于输入images的梯度,用于生成攻击。到这里都没问题。
- images_adv = fgsm_attack(...):你成功创建了对抗样本。
- outputs_adv = model(images_adv):问题很可能出在这里! 你将对抗样本再次输入到同一个model实例中。如果你的模型(比如CNNModel或AliCNN)的定义中包含了任何原地操作(最常见的就是nn.ReLU(inplace=True)),那么在这次前向传播中,模型的一些中间层输出就会被直接修改。
- loss = loss_clean + beta_adv * loss_adv:你把干净损失和对抗损失加起来。
- loss.backward():灾难发生了。当PyTorch尝试为loss计算梯度时:
- 为loss_adv计算梯度:没问题,它的计算图是完整的。
- 为loss_clean计算梯度:PyTorch需要回溯到第1步中的outputs_clean以及更早的中间层结果。但它惊恐地发现,这些它赖以计算梯度的“草稿纸”(中间层张量)已经在第5步中被inplace=True的操作给涂改了!计算图被破坏了,于是它只能报错退出。
额外思考
会是images.requires_grad=True
造成的问题吗,如果提前images.clone
会解决问题吗?是images的问题,还是model的问题?
第一个问题的答案是:不会。
你提前对 images
进行 .clone()
的想法,是为了确保传递给 fgsm_attack
的原始图片是一个副本,这个思路在很多其他场景下是完全正确的。但在我们当前遇到的 inplace operation
问题中,它无法解决问题。
为什么没用?
因为这个问题的根源不在于输入的 images
张量本身被修改,而在于 model
对象自身的内部状态在两次前向传播之间被修改了。
无论你给 model()
传入的是 images
还是 images.clone()
,你调用的都是同一个 model
实例。这就好比,不管你是用原版钥匙还是复制版钥匙去开一把锁,你操作的都是同一把锁芯。如果开锁这个动作会改变锁芯的内部结构(比如磨损了弹簧),那么无论用哪把钥匙,这个改变都会发生。
在这里,model
就是那把锁,它的 BatchNorm
层就是内部的弹簧。model(images_adv)
这个动作,不可避免地改变了 model
的内部状态,从而破坏了 model(images)
留下的计算图。
第二个问题的答案是:model的问题。inplace
错误根源于模型内部模块的操作(in-place operation
)。
让我们把这两个概念分开来看:
- 问题的“凶手”:模型内部的原地操作 (In-place Operation)
- 什么是原地操作? 直接在原始内存上修改数据,而不是先创建一个副本来修改。
- 谁在做这件事? 在你的场景里,就是
CNNModelforCifar100
里的nn.BatchNorm
层。当模型处于.train()
模式时,BatchNorm
在每次前向传播时,都会原地更新它自己的running_mean
和running_var
这两个内部状态。 - 所以,这是对模型的操作。
- “案发现场”:PyTorch的自动求导计算图
images.requires_grad=True
的作用是什么? 它的作用是开启“录像模式”。当它为True
时,PyTorch的自动求导引擎会开始记录所有基于 images 的计算步骤,形成一个计算图(一张“地图”)。loss.backward()
的作用是什么? 它的作用是“按图索骥”。它会沿着这张地图原路返回,计算出所有参数的梯度。- 错误如何发生?
loss.backward()
在为loss = loss_clean + loss_adv
回溯时,它需要两张地图:一张是从loss_clean
回溯的,一张是从loss_adv
回溯的。但是,在计算loss_adv
的过程中,模型(BatchNorm)已经把loss_clean
那张地图上的一个关键路标(BN层的内部状态)给原地修改了。侦探(求导引擎)发现地图和现场对不上,于是只能报错。
- 总结一下:
images.requires_grad=True
并没有“造成”问题,它只是启用了让问题能够被发现的求导机制。- 真正的“罪魁祸首”是
model
自身的BatchNorm
层,它在训练模式下的原地更新行为,污染了“案发现场”。
3. npz 与 分散小样本
这个是由于上述 多卡GPU,或者说in-place operation
和 TRADES
模式的adversarial training
造成的问题,导致的想用 2张单卡 分别去train不同的模型,以免造成只有一张卡运行的资源浪费。
但是问题发现:我的核心是36核心,每个GPU给16 个 number_workers,居然会比一个GPU训练的时候速度慢上好几倍:
# cuda:0
MODEL_ARCH="resnet18"
DATASET_NAME="imagenet100"
BATCH_SIZE=512
EPOCHS=100
LEARNING_RATE=0.002
START_ADV=80
EPSILON_VAL=0.01
DEVICE="cuda:0"
NUM_WORKERS=16
# cuda:1
MODEL_ARCH="resnet18"
DATASET_NAME="imagenet100"
BATCH_SIZE=512
EPOCHS=100
LEARNING_RATE=0.002
START_ADV=80
EPSILON_VAL=0.01
DEVICE="cuda:1"
NUM_WORKERS=16
这是为什么呢,一个cuda:0
训练的时候,相同的NUM_WORKERS=16
参数,大概是40-60s,但是如果两个cuda:0
、cuda:1
同时训练,各自的速度反而是10mins,这是为什么呢?
原因一(CPU资源竞争):我们的机器总共有36个CPU核心。这32个worker进程会和2个主训练进程,以及操作系统本身的其他任务,一起去争抢这36个核心。当活跃的进程数接近或超过核心数时,CPU会花费大量时间在不同进程之间进行上下文切换,而不是真正地执行数据加载和预处理任务。这会导致严重的性能下降,我们称之为“CPU颠簸”(CPU thrashing)。这就造成了为数据加载分配了过多的CPU资源,导致内部“交通堵塞”,CPU效率降低,数据反而“出不来”了,GPU就只能空闲等待。
万能公式:num_workers ≈ 总CPU核心数 / GPU数量 / 2
根据我们36cores、2卡的情况:num_workers = 36 / 2 / 2 = 9
,大概可以从num_workers = 8
开始。通常num_workers
会有一个“甜点区”,超过这个值后性能就会开始下降。
原因二(.npz
的弊端):所有32辆卡车(workers
)都挤在这一个大门口,试图从同一个巨大的集装箱(.npz
文件)里取货。这就造成了门口会非常拥堵。优化方向是将一个大文件拆分成海量的小文件。这相当于把一个大集装箱拆成成千上万个小包裹,每辆卡车都可以从自己的小门直接取走自己的小包裹,互不干扰。这就是为什么ImageNet等大型数据集的标准格式是每个图片一个单独的.jpg
或.png
文件。
额外思考
正确的优化方向不是复制大文件,而是将一个大文件拆分成海量的小文件。 对于这个优化方向不会造成大量的IO读取吗? 如果一次性npz 读取很多 那不是减少IO的读取数目吗?
“一次性读取很多数据(一个大NPZ文件)不是应该比反复读取很多次(海量小文件)IO次数更少、效率更高吗?”——在很多传统的文件处理场景下是完全正确的。这是优化单线程程序性能的一个黄金法则。
然而,这个法则成立的关键前提是“单线程”或“串行读取”。
在深度学习数据加载这个场景中,我们恰恰是在用 num_workers 进行大规模的并行读取。这时,规则就完全改变了。
为什么“海量小文件”在并行时反而更快?
让我们用一个比喻来解释:去图书馆查资料。
场景一:单个巨大的 .npz 文件
- 这就像图书馆里只有一本巨大无比、包罗万象的百科全书。
- 你的程序: 你是总指挥。
num_workers=16
: 你雇佣了16个助手。- 工作流程: 你对16个助手说:“你们都去给我查这本百科全-书,A去查第5页,B去查第800页,C去查第2万页…”
- 瓶颈: 虽然你有16个助手,但这只有一本书。他们必须排队轮流使用这本书,或者挤在一起互相干扰。这本书(单个
.npz
文件)本身成为了性能瓶颈。所有的“I/O请求”都集中在了这一个文件上,造成了严重的读写竞争 (I/O Contention)。
场景二:海量的小 .jpg 文件
- 这就像图书馆里有成千上万本独立的、很薄的小册子,分别放在不同的书架上。
- 你的程序: 你是总指挥。
- 工作流程: 你对16个助手说:“A去A1书架拿第5本册子,B去B3书架拿第800本,C去C5书架拿第2万本…”
- 优势: 16个助手可以同时、独立、并行地去不同的书架取他们各自需要的小册子,他们之间互不干扰。
- 结论: 尽管“打开册子”这个动作(文件打开操作)发生了16次,而不是1次,但因为这16次是同时发生的,总耗时远远小于16个助手排队等一本书的时间。
底层的硬件和系统原因(机械硬盘 (HDD) vs. 固态硬盘 (SSD))
- 这种直觉在机械硬盘时代是绝对正确的。HDD有一个物理磁头,读取不同位置的文件需要移动磁头(寻道),这个过程非常耗时。对于HDD,“减少读取次数”至关重要。
- 但现代的服务器和工作站几乎都使用固态硬盘 (SSD),特别是NVMe SSD。SSD没有移动部件,它可以极快地响应来自不同位置的随机读取请求(高IOPS)。它天生就为并行读取而设计。让它同时处理16个小文件的读取请求,远比让它处理16个进程对同一个大文件的读取请求要高效得多。
总结
总结一下:
- signal9 -> 显存不够 -> 调小batch size / 选更好的显卡
- in-place error -> model backward不一致,无法计算 -> 使用单卡(暂时没有更好的策略,如果有大神知道,请务必告诉!)
- .npz 与 分散小样本问题 -> .npz 并行慢 -> 大数据库还是用分散的小样本(
.img
)好得多
参考
[1] Gemini
Q.E.D.