前言

最近在跑多卡GPU,发现一些小问题,于是乎写下来记录一下,主要是3个问题:

  1. Signal 9 (SIGKILL)
  2. in-place operation error
  3. 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)
...

这是模型的其中一段结构,然而这里面我们发现了问题,BatchNorm2Din-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)”了,导致它无法正确地回溯计算图。

为什么会发生这个错误?
让我们在你进行对抗训练的那个代码分支里,一步步看发生了什么:

  1. outputs_clean = model(images):模型对干净图片进行前向传播。
  2. loss_clean = criterion(outputs_clean, labels):计算出干净样本的损失。为了之后能计算梯度,PyTorch会构建一个计算图,记住loss_clean是如何通过outputs_clean和模型权重计算出来的。
  3. grad = torch.autograd.grad(...):你计算了loss_clean相对于输入images的梯度,用于生成攻击。到这里都没问题。
  4. images_adv = fgsm_attack(...):你成功创建了对抗样本。
  5. outputs_adv = model(images_adv):问题很可能出在这里! 你将对抗样本再次输入到同一个model实例中。如果你的模型(比如CNNModel或AliCNN)的定义中包含了任何原地操作(最常见的就是nn.ReLU(inplace=True)),那么在这次前向传播中,模型的一些中间层输出就会被直接修改。
  6. loss = loss_clean + beta_adv * loss_adv:你把干净损失和对抗损失加起来。
  7. 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)。

让我们把这两个概念分开来看:

  1. 问题的“凶手”:模型内部的原地操作 (In-place Operation)
    • 什么是原地操作? 直接在原始内存上修改数据,而不是先创建一个副本来修改。
    • 谁在做这件事? 在你的场景里,就是 CNNModelforCifar100 里的 nn.BatchNorm 层。当模型处于 .train() 模式时,BatchNorm 在每次前向传播时,都会原地更新它自己的 running_meanrunning_var 这两个内部状态。
    • 所以,这是对模型的操作。
  2. “案发现场”: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层的内部状态)给原地修改了。侦探(求导引擎)发现地图和现场对不上,于是只能报错。
  3. 总结一下:
    • images.requires_grad=True 并没有“造成”问题,它只是启用了让问题能够被发现的求导机制。
    • 真正的“罪魁祸首”是 model 自身的 BatchNorm 层,它在训练模式下的原地更新行为,污染了“案发现场”。

3. npz 与 分散小样本

这个是由于上述 多卡GPU,或者说in-place operationTRADES模式的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:0cuda: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个进程对同一个大文件的读取请求要高效得多。

总结

总结一下:

  1. signal9 -> 显存不够 -> 调小batch size / 选更好的显卡
  2. in-place error -> model backward不一致,无法计算 -> 使用单卡(暂时没有更好的策略,如果有大神知道,请务必告诉!
  3. .npz 与 分散小样本问题 -> .npz 并行慢 -> 大数据库还是用分散的小样本(.img)好得多

参考

[1] Gemini

Q.E.D.


立志做一个有趣的碳水化合物