1. 在Windows上跑ResNet训练这件事,先想清楚值不值得
很多人一提到ImageNet-1K级别的训练,脑子里第一反应就是"这活儿得在Linux服务器上干",理由也充分:多进程DataLoader在Linux上是fork,开销小;文件系统IO调度成熟;工具链和论文复现脚本默认都在Linux环境里验证过。但现实是,相当一部分做CV方向的同学、工程师,手头的主力机器就是一台装好显卡的Windows工作站或者带独显的笔记本,要额外配一台Linux机器,成本、维护精力、跨机器传数据的麻烦,都不划算。所以我这几年一直坚持把训练链路搭在Windows上,从环境配置到数据读取到训练循环,每个环节都自己踩一遍。
这篇文章讲的,就是在Windows下用PyTorch把ResNet在ImageNet-1K上训练起来的完整经验,包含可直接复用的代码、环境版本对应关系、以及Windows平台独有的一堆坑。它解决的核心问题不是"ResNet怎么定义"这种教科书问题,而是"为什么同样的脚本在Linux上跑得好好的,搬到Windows就慢、就报错、就卡死"。适合谁看:已经会写基本PyTorch训练脚本、想用自己机器跑中等规模图像分类的人;或者已经在Windows上装好环境但训练速度远低于预期的人。如果你连conda和显卡驱动都还没装,这篇文章也能当一份按图索骥的安装清单,只是进度会稍微慢一点。
我先把结论摆出来:Windows跑ImageNet训练完全可行,单机单卡ResNet-50,一个epoch(128万张图)在合理配置下是可以压缩到可用时间区间的,关键是数据处理流水线要调对,而这恰恰是Windows上最容易出问题的地方。
1.1 为什么很多人默认用Linux,而我还是选了Windows
先承认Linux的优势是客观存在的。DataLoader的多进程在Linux上有fork可用,子进程能直接继承父进程的内存映射,启动成本低;Windows从Python层面只能用spawn方式启动子进程,每个worker都要重新import一遍主模块,重新初始化一遍环境,这就是为什么在Windows上把num_workers调大,反而可能变慢甚至内存爆炸。这个差异是真实的,不是心理作用。
但我选Windows的理由也很实在。第一,我的数据集和标注工具、可视化脚本、后续的推理部署验证,都在Windows上;数据在本地NVMe盘上,读起来带宽够。第二,Windows现在的WDDM驱动模型虽然有一点调度开销,但在单卡训练场景下,这点开销相比数据加载的瓶颈几乎可以忽略。第三,也是最重要的一点:我在一次次的踩坑过程中,把Windows上那几个固定的坑摸清楚了,一旦跨过去,稳定性并不比Linux差。很多"Linux一定更快"的说法,其实是拿一个调优过的Linux环境和一个没调优的Windows环境作对比,不公平。
所以我的态度是:环境选哪个不重要,重要的是你知不知道这个平台的边界在哪。下面我会把Windows上那几个真实存在的边界,一个一个拆开讲。
1.2 Windows和Linux在训练链路上的三处真实差异
第一个差异是进程启动模型。Linux是fork,Windows是spawn。spawn意味着每个DataLoader worker都会重新执行你的主脚本的顶层代码,所以如果你把模型定义、数据集实例化、甚至训练循环都写在模块顶层,而不放在ifname== 'main':保护块里,Windows下就会无限递归创建进程,直接报错或者把内存吃光。这是我第一次在Windows上跑训练时最先撞上的墙。
第二个差异是共享内存。Windows没有Linux那种/dev/shm,PyTorch的DataLoader在传递张量时用的共享内存机制在Windows上走的是另一套实现,当num_workers开得比较大、每个batch又比较大的时候,容易碰到"共享内存不足"或者worker进程莫名退出的问题。表现就是训练中途某个epoch突然卡住,日志不再刷新。
第三个差异是文件句柄和路径。Windows对同时打开的文件句柄数量、路径长度(历史上260字符限制)、以及反斜杠转义都比较敏感。ImageNet的目录是train/n01440764/xxx.JPEG这种结构,路径不长,问题不大,但如果你把数据集放在很深的嵌套目录里,或者目录名带空格和中文,就会时不时冒出读取失败。
这三处差异,后面我会用专门的章节给对应的修法。先把环境搭起来。
2. 环境链路:从显卡驱动到能import torch的完整搭建
Windows下装PyTorch,出问题的从来不是pip install本身,而是版本对应关系。我会按"显卡驱动 → CUDA运行时 → PyTorch → torchvision"这条链路讲,每一环都要能对上下一个。你可能听过CUDA Toolkit必须装,其实用官方的pip wheel安装PyTorch时,wheel里已经自带了对CUDA运行时库的依赖,你不一定需要在系统里单独装完整的CUDA Toolkit,只需要显卡驱动版本足够新,能支持对应的CUDA版本就行。但如果你后续要自己编译CUDA扩展,那就得装Toolkit,这是两回事。
我建议的路线是:先确定显卡驱动支持的CUDA上限,再选定PyTorch版本,最后用conda或者pip安装对应cu版本的wheel。整个过程不需要手动下载CUDA安装包,除非你有编译需求。
2.1 CUDA、驱动、PyTorch三者版本怎么对上
这块的逻辑很多人搞混。显卡驱动里包含了一个"驱动能支持的最高CUDA运行时版本",这个版本号可以在命令行用nvidia-smi查看,右上角会显示"CUDA Version: xx.x"。注意,这个数字不是"你装的CUDA版本",而是"驱动能兼容的最高版本"。你完全可以在驱动支持12.x的情况下,安装cu118(CUDA 11.8)版本的PyTorch,这是向下兼容的,没问题。
反过来就不行:如果你的驱动只支持到11.7,你却装了需要CUDA 12.x运行时的PyTorch wheel,那import torch的时候就会报错,或者torch.cuda.is_available()返回False。所以顺序是:先看nvidia-smi的版本号,再决定装哪个cu前缀的PyTorch。
下面这张表是我实际用过的几组稳定搭配,供参考:
| 显卡驱动CUDA上限 | 建议PyTorch版本 | wheel的CUDA前缀 | 备注 |
|---|---|---|---|
| 12.4及以上 | 2.2 / 2.3 | cu121 | 目前最省心的组合 |
| 12.0 - 12.3 | 2.1 / 2.2 | cu118 | 兼容性广,够用 |
| 11.7 - 11.8 | 1.13 / 2.0 | cu117 | 老机器上常见 |
| 11.6及以下 | 1.12 | cu116 | 建议先升级驱动 |
有人会问conda装和pip装有什么区别。conda装的PyTorch会把cudatoolkit作为conda包一起装上,好处是它自己在conda环境里维护一份CUDA库,不会和系统冲突;pip装的PyTorch则是依赖wheel里打包的运行库。两者都能用,我个人偏向conda建环境、pip装torch的组合,因为conda对Windows上一些C++依赖(比如微软的VC运行时)处理得更省心。
2.2 conda环境与依赖清单
我习惯给每个项目单独建环境,避免不同项目的torch版本互相污染。命令很简单:
conda create -n resnet_train python=3.10 -y conda activate resnet_trainPython选3.10是我这几年最稳的,3.11、3.12在新一点的torch上也行,但一些周边的库(比如早期版本的tensorboard、一些编译型依赖)在3.12上偶尔没有预编译wheel,会触发源码编译,在Windows上编译是很痛苦的。所以保守起见,3.10能省很多事。
然后是核心依赖:
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121 pip install numpy pillow tqdm tensorboard这里用--index-url指向官方的CUDA wheel仓库,能确保拿到的是cu121版本,而不是默认的CPU版本。我踩过的坑就是直接pip install torch,结果装成了CPU版,训练跑起来torch.cuda.is_available()是False,还以为是驱动坏了,排查半天。
装完之后可以顺手确认一下:
python -c "import torch; print(torch.__version__, torch.version.cuda, torch.cuda.is_available())"输出形如2.2.0 12.1 True就对了。如果最后是False,先别急着重装,按下面的顺序查。
2.3 验证环境是否真的能用到GPU
排查is_available为False,我一般的顺序是:第一,确认nvidia-smi能正常输出,如果这个命令本身都找不到或者报错,那是驱动没装好,先去装显卡驱动,这一步和PyTorch无关;第二,确认装的是CUDA版wheel,用上面那条命令看torch.version.cuda是不是None,如果是None,说明装成了CPU版,重装;第三,确认驱动版本上限不低于wheel的CUDA版本,如果wheel是cu121而驱动上限只有11.7,就降级装cu118。
再补一个硬件层面的真实检查:
import torch print("设备数:", torch.cuda.device_count()) print("设备名:", torch.cuda.get_device_name(0)) x = torch.randn(1024, 1024, device="cuda") y = x @ x torch.cuda.synchronize() print("矩阵乘完成, 结果均值:", y.mean().item())前面叫"环境能识别GPU",这段叫"环境能真正算东西"。有些情况下识别没问题,但一跑算子就报非法内存访问,通常是驱动和运行时之间的兼容问题,这时候更新驱动是最直接的解法。
3. ImageNet-1K数据集的落地:目录结构、预处理与Windows读取瓶颈
环境好了,接下来是最耗精力的一环:数据。ImageNet-1K是1000类的分类数据集,训练集大约128万张,验证集5万张,全部是JPEG格式的图片,尺寸不一,基本都在几百像素这个量级。它的目录组织方式是固定的:train目录下按类别ID分子目录,每个子目录里放该类的图片;val目录平铺所有图片,另外配一个验证集标注文件来告诉程序每张图属于哪一类。理解这个结构很重要,因为它是决定你怎么写Dataset和怎么配transforms的前提。
这里我要强调一个很多人忽略的点:数据放在哪种盘上,对Windows训练速度的影响比换GPU还大。我用过机械硬盘、SATA SSD和NVMe SSD三种,同样的ResNet-50脚本,仅数据加载这一块的时间差就能拉到三四倍。原因是ImageNet的图都是小文件,随机读取密集,机械盘的随机IO完全扛不住。所以如果你的数据还在机械盘上,先把它挪到SSD,这一步的收益立竿见影,比调任何参数都来得实在。
3.1 数据集结构必须长成什么样
标准结构长这样:
imagenet/ train/ n01440764/ n01440764_10026.JPEG ... n01443537/ ...(共1000个目录) val/ ILSVRC2012_val_00000001.JPEG ... val_label.txtval_label.txt的每一行是"文件名 类别索引",因为是平铺结构,torchvision的ImageFolder处理不了,需要自己写一个Dataset。我下面的实现会把这个逻辑写清楚。有些人找的"val分好类的"版本,其实是把验证集也按类别放了子目录,那种版本可以直接用ImageFolder,但要注意类别顺序必须和训练集一致,否则算出来的准确率是错位的。判断方法很简单:看一眼train的1000个目录名排序,和val的目录名排序是否完全一样,一样就可以放心用ImageFolder。
我实际用的写法是自定义Dataset读val_label.txt,更可控。代码在后半部分给。
3.2 预处理流水线里每个参数的道理
训练用的transforms,我固定用这一套,它是ImageNet上的事实标准:
from torchvision import transforms train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.08, 1.0)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])逐个说为什么。RandomResizedCrop(224, scale=(0.08, 1.0))是随机裁剪一块面积占比8%到100%的区域,再缩放到224,这是数据增强的核心,让模型对物体位置和尺度不敏感;scale下限0.08看起来很小,但它保证了裁出来的小块至少还有一定内容,是原论文验证过的稳妥值。RandomHorizontalFlip是随机水平翻转,对自然图像来说翻转不改变语义,能免费增大数据多样性,但在字符识别或医学影像这类任务里要谨慎。
Normalize里的mean和std是ImageNet统计出来的像素均值方差,不是随便填的。这三个通道的数值来自对整个训练集RGB通道的统计,如果你不做这一步归一化,训练初期很容易出现梯度爆炸或者loss震荡。这个坑我见得太多:有人训练loss一直下不去,换成这套标准化立刻就好了。
验证集的处理要克制,只做确定性操作:
val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]), ])先缩到短边256,再中心裁224,这是标准的评估流程。用随机增强去做验证,会让每轮的val准确率波动很大,无法客观反映模型状态。我刚入门时就犯过这个错,把增强也用到验证上,val准确率忽高忽低,误以为模型不稳定。
3.3 Windows下数据读取慢的根因与缓解
前面说了Windows的进程启动是spawn,这在数据加载上的直接后果是:num_workers每加一个,就多一次Python解释器冷启动、多一次torch库的加载、多一次数据集路径扫描(ImageFolder会扫描全部1000个目录,这个扫描在128万张图上是要花时间的)。所以num_workers不是越大越好。
我实测下来,8核到16核的CPU,num_workers给4到8比较合适。给到16反而会因为进程调度和内存复制开销,速度不升反降。而且要注意,spawn方式下每个worker都会独立执行一遍你脚本顶层的代码,如果你在顶层实例化了ImageFolder,那就是每个worker都扫一遍全部目录,非常浪费。正确做法是把数据集实例化放在ifname== 'main':内部,只执行一次。
缓解读取慢的第二个手段是关掉多余的图像校验。PIL加载图片时默认会做一些检查,大流量读取时这些检查也是开销。可以用下面这种写法跳过:
from PIL import Image, ImageFile ImageFile.LOAD_TRUNCATED_IMAGES = True把LOAD_TRUNCATED_IMAGES设为True可以避免因为个别损坏的JPEG导致整个读取报错中断,这在ImageNet上是很有必要的,因为样本量大,难免有几张图是坏的。但要注意,这个设置在Linux上也是一样的道理,只是Windows上文件系统对文件锁更敏感,遇到损坏文件时更容易卡住。
第三个手段是把预处理的一部分下沉到worker里,也就是把ToTensor和Normalize放在Dataset的__getitem__里,而不是在训练循环里做。这样每个worker并行地完成CPU密集的转换,GPU只管前向反向。这个原则在Linux和Windows上通用,但在Windows上尤其重要,因为一旦CPU成了瓶颈,spawn的额外开销会让整体更慢。
4. ResNet训练脚本的关键组成与代码实现
到这一节,环境、数据都到位了,我来把这个训练脚本拆开讲。我会给一份完整的、能在Windows单卡上直接跑的代码,同时解释每一处为什么这么写。这份代码不是最花哨的,但每一行我都在真实数据上验证过。为便于阅读,我拆成几个部分讲:模型构建、优化器与调度、训练循环、混合精度。这里的数据集路径、batch大小这类参数,都会给出我实测过的取值。
先说一个原则:训练脚本的所有顶层执行代码,都必须放在ifname== 'main':保护块内。这不是风格问题,是Windows下能不能跑起来的硬性要求。
4.1 模型构建与是否加载预训练权重
关于要不要用预训练权重,我的态度是分情况。如果你是在做研究、想复现ImageNet上的基准精度,从零训练是必要的;但如果你是在自己的业务任务上做迁移学习,哪怕数据量在百万级,加载ImageNet预训练权重再微调,收敛速度和最终精度都明显更优。很多人以为"数据够多就不用预训练",实际上预训练省的不只是时间,还有对超参的敏感性。
模型直接调torchvision的实现最省事,但要改最后一层适配类别数(虽然ImageNet就是1000类,不改也行):
import torch.nn as nn from torchvision import models def build_model(num_classes=1000, pretrained=False): model = models.resnet50(weights=None) # 从零训练 # 若加载预训练:models.resnet50(weights=models.ResNet50_Weights.IMAGENET1K_V1) if num_classes != 1000: model.fc = nn.Linear(model.fc.in_features, num_classes) return model这里有个版本提醒:老版本torchvision用pretrained=True参数,新版本改成了weights=枚举,两者混用会报错或者给出警告。我建议统一用weights参数,明确写清楚用哪个预训练版本,避免以后换环境时出问题。
另外,如果你在Windows上多卡并行,要用nn.DataParallel或者DistributedDataParallel。单卡就直接.cuda()。多卡场景下Windows的DDP配置比Linux麻烦一些,涉及端口、NCCL(Windows上其实走的是Gloo或新的后端),这个如果环境不是多卡,建议先不碰,把单卡跑通再说。
4.2 优化器、学习率调度与损失
ResNet的经典训练配比是SGD + momentum + weight decay,加上warmup和余弦退火。具体参数:
optimizer = torch.optim.SGD( model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4 )学习率0.1是配合batch_size=256用的标准值。如果你显存小只能开batch_size=64,那学习率要按线性缩放,大致降到0.025,不然容易训崩。这个线性缩放法则在小批量上不是绝对精确,但作为起点非常好用。momentum 0.9和weight decay 1e-4也是ImageNet上的经验值,改动它们之前先确认你有充分的理由。
调度器我用余弦退火,它能平滑地降低学习率,避免手动调step:
from torch.optim.lr_scheduler import CosineAnnealingLR scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6)如果你的训练轮数少(比如只训30轮),余弦退火能把最后阶段的学习率压下去,对收敛有帮助。如果轮数很多(90轮以上),可以再加warmup,前5个epoch把学习率从很小线性升到0.1,避免一开始梯度太大,这在从零训练时尤其重要。
损失函数用交叉熵,注意PyTorch的CrossEntropyLoss内部已经包含了softmax,你的模型最后一层不要自己再加softmax:
criterion = nn.CrossEntropyLoss()我见过有人在模型里加了softmax,外面又用CrossEntropyLoss,结果loss一直不下降,排查很久才发现在这里重复了。
4.3 混合精度与显存控制
Windows单卡显存通常有限(8G、12G、24G不等),开启混合精度(AMP)能显著降低显存占用并提升速度。原理是用FP16做前向反向计算,同时保留一份FP32的主权重,并用动态损失缩放来防止FP16下梯度下溢。用法很简单:
from torch.cuda.amp import autocast, GradScaler scaler = GradScaler() with autocast(): output = model(images) loss = criterion(output, targets) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True)四个步骤一个都不能少。scale是把loss放大,防止小梯度在FP16里变成0;backward在放大后的loss上做;scaler.step是先把梯度还原再更新权重,如果这一步检测到梯度里有inf或nan,会跳过这次更新;scaler.update是动态调整缩放系数。很多人只写了前面两步,忘了后面的step和update,结果就是混合精度下训练不动。
开AMP之后,ResNet-50的显存占用能降大约三到四成,速度能有百分之二三十的提升,收益非常可观。但要注意,AMP不是万能的,如果你的模型里有大量小数值运算(比如某些归一化),可能需要谨慎处理。
4.4 完整训练循环代码
把上面的拼起来,给出完整的可运行版本。注意每个函数和实例化都在main保护块内:
import os import time import torch import torch.nn as nn from torch.utils.data import DataLoader, Dataset from torchvision import transforms, models from torch.optim.lr_scheduler import CosineAnnealingLR from torch.cuda.amp import autocast, GradScaler from PIL import Image, ImageFile from tqdm import tqdm ImageFile.LOAD_TRUNCATED_IMAGES = True class TrainFolder(Dataset): """ImageNet train目录:train/类别ID/图片""" def __init__(self, root, transform): self.transform = transform self.samples = [] self.classes = sorted(os.listdir(root)) self.class_to_idx = {c: i for i, c in enumerate(self.classes)} for c in self.classes: cdir = os.path.join(root, c) for f in os.listdir(cdir): self.samples.append((os.path.join(cdir, f), self.class_to_idx[c])) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, label = self.samples[idx] img = Image.open(path).convert("RGB") return self.transform(img), label class ValFolder(Dataset): """ImageNet val:平铺图片 + val_label.txt""" def __init__(self, root, label_file, transform): self.transform = transform self.root = root self.samples = [] with open(label_file, "r") as fp: for line in fp: name, cls = line.strip().split() self.samples.append((name, int(cls))) def __len__(self): return len(self.samples) def __getitem__(self, idx): name, label = self.samples[idx] img = Image.open(os.path.join(self.root, name)).convert("RGB") return self.transform(img), label def accuracy(output, target, topk=(1, 5)): with torch.no_grad(): maxk = max(topk) _, pred = output.topk(maxk, 1, True, True) pred = pred.t() correct = pred.eq(target.view(1, -1).expand_as(pred)) res = [] for k in topk: correct_k = correct[:k].reshape(-1).float().sum(0) res.append(correct_k.mul_(100.0 / target.size(0))) return res def main(): device = torch.device("cuda" if torch.cuda.is_available() else "cpu") epochs = 60 batch_size = 256 workers = 8 train_tf = transforms.Compose([ transforms.RandomResizedCrop(224, scale=(0.08, 1.0)), transforms.RandomHorizontalFlip(), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) val_tf = transforms.Compose([ transforms.Resize(256), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) train_set = TrainFolder(r"D:\imagenet\train", train_tf) val_set = ValFolder(r"D:\imagenet\val", r"D:\imagenet\val_label.txt", val_tf) train_loader = DataLoader( train_set, batch_size=batch_size, shuffle=True, num_workers=workers, pin_memory=True, persistent_workers=True, prefetch_factor=2, ) val_loader = DataLoader( val_set, batch_size=batch_size, shuffle=False, num_workers=workers, pin_memory=True, persistent_workers=True, ) model = models.resnet50(weights=None).to(device) if torch.cuda.device_count() > 1: model = nn.DataParallel(model) criterion = nn.CrossEntropyLoss() optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9, weight_decay=1e-4) scheduler = CosineAnnealingLR(optimizer, T_max=epochs, eta_min=1e-6) scaler = GradScaler() for epoch in range(epochs): model.train() t0 = time.time() running_loss = 0.0 for images, targets in tqdm(train_loader, desc=f"Epoch {epoch+1}"): images = images.to(device, non_blocking=True) targets = targets.to(device, non_blocking=True) optimizer.zero_grad(set_to_none=True) with autocast(): output = model(images) loss = criterion(output, targets) scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() running_loss += loss.item() scheduler.step() model.eval() top1, top5 = 0.0, 0.0 with torch.no_grad(): for images, targets in val_loader: images = images.to(device, non_blocking=True) targets = targets.to(device, non_blocking=True) with autocast(): output = model(images) a1, a5 = accuracy(output.float(), targets, topk=(1, 5)) top1 += a1.item() top5 += a5.item() n = len(val_loader) print(f"Epoch {epoch+1} | loss {running_loss/len(train_loader):.4f} " f"| top1 {top1/n:.2f} | top5 {top5/n:.2f} " f"| {time.time()-t0:.1f}s") torch.save({ "epoch": epoch, "model": model.state_dict(), "optimizer": optimizer.state_dict(), "scheduler": scheduler.state_dict(), }, f"resnet50_epoch{epoch+1}.pth") if __name__ == "__main__": main()这份代码可以直接跑,把数据路径换成你自己的就行。下面几节我会讲它是怎么一步步踩坑调出来的。
5. Windows下DataLoader多进程与那些一定会踩的坑
这一节是全文最"值钱"的部分,因为上面那份代码里的某些参数,是我被反复折磨之后才加上的。Windows下的DataLoader和Linux差别很大,很多在Linux上不出问题的写法,在这里会以各种意想不到的方式炸掉。我把最典型的几类问题按处理顺序讲。
5.1 num_workers配合main保护
第一个坑,也是最常见的:忘记ifname== 'main':,或者只保护了main()调用,但数据集和模型的实例化放在了顶层。Windows上spawn的worker会在启动时重新执行整个模块,如果你的顶层有实例化代码,那么每个worker启动时都会重新跑一遍,轻则浪费几分钟扫目录,重则因为某些资源(比如端口、文件句柄)被重复占用而直接报错。
正确的结构是:所有会执行的操作,包括建数据集、建模型、建loader、开训练,全部放在main函数里,然后在模块底部用ifname== 'main': main()来启动。类定义、函数定义可以放顶层,因为它们只是定义,不会执行。我带人做项目时,看一份Windows训练脚本能不能跑,第一眼就看这个结构对不对。
5.2 共享内存不足与句柄泄漏
第二个坑是worker进程在训练中途静默退出。表现是某个epoch跑到一半,进度条不动了,CPU占用掉下去,GPU利用率掉到个位数,日志也不刷新。这个原因通常有两个:共享内存不够,或者句柄泄漏。
Windows没有Linux那种dev/shm,PyTorch在传递张量时依赖系统的共享内存段,每个batch的数据复制都要走这块。当你num_workers开得大、batch也大的时候,共享内存段会迅速膨胀,超过系统上限后worker就崩了。解决办法有这么几个:降低num_workers(我一般从8往下试到4);降低prefetch_factor(默认是2,可以设成1甚至不设);或者减小batch_size。
句柄泄漏则是另一个方向的坑:每个worker打开的文件如果没正确关闭,累积到一定程度就报too many open files。PIL的Image.open默认是惰性加载的,如果不调用load()或者不关闭,句柄会一直挂着。稳妥的写法是在__getitem__里用with语句,或者显式地img.load()之后让对象被回收。我在数据量大的时候会在__getitem__末尾加一句img.close(),虽然有点啰嗦,但能避免长跑训练时句柄慢慢堆积。
另外,persistent_workers=True这个参数值得说道。它让worker在epoch之间不销毁、复用,好处是避免了每个epoch重新spawn的开销。但代价是它要求数据集比较稳定,如果你在训练中途动态改数据集内容,可能拿到过期数据。对固定的ImageNet来说,开它是划算的。
5.3 常见报错对照表
这几年我记录下来的Windows训练报错,整理成一张表,方便你按现象查:
| 报错或现象 | 大概率原因 | 处理办法 |
|---|---|---|
| RuntimeError: An attempt has been made to start a new process... | 缺少main保护 | 把执行代码放进ifname== 'main': |
| DataLoader worker (pid xxx) is killed | 共享内存/内存不足 | 降num_workers,降prefetch_factor |
| BrokenPipeError / EOFError | worker崩溃导致管道断开 | 先修worker崩溃的根因 |
| OSError: [Errno 24] Too many open files | 文件句柄泄漏 | getitem里显式close或with |
| CUDA out of memory | 显存不足 | 开AMP、降batch、梯度累积 |
| loss长时间为nan | 学习率过高或精度问题 | 降lr、检查是否重复softmax、调AMP |
| 验证准确率乱跳 | 验证用了数据增强 | 验证只做Resize+CenterCrop |
| 训练速度远低于预期 | 数据在机械盘 / worker过多 | 换SSD,num_workers调4-8 |
这张表可以贴在电脑边上,出问题先对号入座,能省掉大量时间。我要特别说BrokenPipeError,它本身不是根因,是worker已经崩了之后主进程试图和它通信的结果。很多人看到这个报错就四处改管道相关的代码,其实应该往上翻日志,找到worker真正崩的那一行。
6. 训练过程中的调优与显存优化实战
环境和数据都通了之后,真正的折磨才刚开始:为什么我的显存不够、为什么收敛这么慢、为什么精度上不去。这一节我讲几个实战中调出来的经验。
6.1 学习率与warmup的真实作用
学习率是训练里最敏感的参数。前面说0.1配batch 256,这个值在从零训练ResNet时是标准配置。但它是配了warmup才稳的:如果直接上0.1,而你的权重是随机初始化的,前几百步梯度会非常大,loss很容易直接nan。warmup就是让学习率在前几个epoch从接近0线性升到0.1,给网络一个"适应期"。
warmup的实现可以用LambdaLR写:
from torch.optim.lr_scheduler import LambdaLR warmup_epochs = 5 def lr_lambda(epoch): if epoch < warmup_epochs: return (epoch + 1) / warmup_epochs return 0.5 * (1 + __import__("math").cos( (epoch - warmup_epochs) / (epochs - warmup_epochs) * 3.1415926)) scheduler = LambdaLR(optimizer, lr_lambda)这段把warmup和余弦退火合并在一起了。实测下来,从零训练加warmup,前几个epoch的loss曲线会平滑很多,不会再出现那种突然炸掉然后需要重启的情况。
如果你是从预训练权重开始微调,warmup可以短一些甚至不要,因为初始权重已经不是随机的了,学习率可以稍微小一点,比如0.01到0.05之间,具体看你的数据规模和任务相似度。
6.2 梯度累积模拟更大batch
显存不够是常态。假设你只有12G显存,batch 256开不起来,但你又不想改学习率(因为改了要重新调),这时候梯度累积是好办法。原理是一次前向反向算小批量的梯度,但不立即更新,攒够若干个步数再更新一次。这样等效于大batch,但显存只按小batch算。
代码上是把前面的训练循环改一下,把scaler.step和zero_grad按累积步数做条件:
accum_steps = 4 for i, (images, targets) in enumerate(train_loader): images = images.to(device, non_blocking=True) targets = targets.to(device, non_blocking=True) with autocast(): output = model(images) loss = criterion(output, targets) / accum_steps scaler.scale(loss).backward() if (i + 1) % accum_steps == 0: scaler.step(optimizer) scaler.update() optimizer.zero_grad(set_to_none=True)注意loss要除以accum_steps,这样累加的梯度和一次性大batch的梯度在量级上是一致的,否则梯度会成倍放大。这个细节很多人漏掉,导致训练发散。我自己有次就忘了除,loss直接起飞,排查了好一阵。另外要小心训练集长度不能被accum_steps整除时最后一批处理不干净,一般可以忽略,或者手动补一次step。
6.3 显存不够时按顺序尝试的几种手段
显存不足时,我会按这个顺序试:
第一,开AMP混合精度,收益最大,通常能省三成显存。第二,降batch_size,这个是直接办法,配合梯度累积保持等效批量。第三,用channels_last内存格式,把模型和输入都转成通道在后的格式,这对卷积网络有时能提速省显存,写法是model = model.to(memory_format=torch.channels_last),输入也做同样转换。第四,如果还不行,考虑梯度检查点(gradient checkpointing),用时间换空间,把中间激活值不保存,反向时重算,写法上对ResNet需要改结构,比较繁琐,作为最后手段。
这里给个我实测的参考数据:ResNet-50、224输入、batch 256,不做任何优化时大约需要22G左右显存;开了AMP能降到15G上下;再配合梯度累积4步,batch 64就能在8G显存的卡上跑起来。这个数字会随具体环境浮动,但量级上是这样。
7. 验证、断点续训与日志监控
训练跑起来之后,你需要能观察它、能中断它、能从中间恢复它。这一节讲怎么把这三件事做扎实。
7.1 Top-1与Top-5验证实现
ImageNet的标准评估是Top-1和Top-5准确率,前者看最高分是否命中,后者看前五是否命中。实现我写在4.4节的accuracy函数里了,核心是取topk、然后比较预测和目标。这里有个容易错的地方:验证时要model.eval()并用torch.no_grad(),否则BatchNorm的统计量会被验证数据污染,而且会白白占用显存。还有,预测结果要转回FP32再算topk,因为AMP下output是FP16,直接算topk在极端情况下会有精度问题,所以我代码里写了output.float()。
验证集的batch不用和训练一样大,它可以开大一点,因为只做前向,显存压力小。但val的num_workers也别开太大,验证本身快,worker太多反而在进程管理上浪费时间。
7.2 checkpoint保存与断点续训
长跑训练最怕的是跑到一半断电或者系统更新强制重启。所以每个epoch存一次checkpoint是必须的。我保存的内容包括模型权重、优化器状态、调度器状态和当前epoch号,因为断点续训时,优化器里的动量和调度器的学习率状态如果丢了,恢复后的曲线会和中断前对不上。
恢复的写法:
def load_checkpoint(path, model, optimizer, scheduler): ckpt = torch.load(path, map_location="cpu") model.load_state_dict(ckpt["model"]) optimizer.load_state_dict(ckpt["optimizer"]) scheduler.load_state_dict(ckpt["scheduler"]) return ckpt["epoch"] + 1map_location="cpu"很重要,它保证在CPU上先把权重读进来,再搬到GPU,避免直接往已经占满的显存里塞导致OOM。这个技巧在很多恢复场景都用得上。
日志方面,我一般除了print,还会把每轮的loss、top1、top5、耗时写进一个CSV,方便画曲线。tensorboard也可以,但Windows上tensorboard有时候会因为端口占用或者临时目录权限问题启动失败,纯CSV最稳,配合pandas随手就能画图。
8. 一些不成体系的碎碎念
写到这里,训练链路基本完整了。我最后说几个不那么"技术"、但确实影响体验的点。
第一,Windows的自动更新和杀毒软件是训练的两大隐形杀手。系统更新会强制重启,把没存checkpoint的训练干掉;杀毒软件实时扫描会在你读数据集的时候疯狂占用IO,我遇到过一次训练速度莫名掉一半,排查半天是杀毒在扫JPEG。建议训练前关掉自动重启,把数据集目录加入杀毒白名单。
第二,GPU温度要盯着。Windows上跑满载训练,笔记本或者散热不佳的台式机容易过热降频,表现为训练前期快、后期慢。我一般用nvidia-smi每隔几秒看一眼温度和功耗,确认没有降频。
第三,多卡训练在Windows上不是不能做,但配置成本比Linux高。如果你的模型是单机能塞下的,先把单卡调通、调好,再考虑多卡。多卡不是线性的,有时候两张卡的利用率加起来还不如一张卡调得好。
最后分享一个我自己的小习惯:我会在训练脚本里加一个"--smoke"参数,跑一个极小的子集(比如每个类抽10张图,总共1万张),用来在正式启动前快速验证环境、数据路径、前向反向、checkpoint保存这一整条链路是否通畅。这个习惯帮我省掉了无数次"启动之后等两小时才发现路径写错了"的尴尬。链路通了再放全量数据,心里踏实。