Day37,讲点实际的。今天这篇不是来背概念的,而是把最近折腾的GPU训练和一些Python类的高级玩法放在一起聊。标题写的是“类call的方法”,说白了就是Python类里的__call__,这东西在深度学习代码里出现的频率高得惊人,PyTorch的模型层、函数式API、各种封装器里全是它的影子。另一个主角是GPU训练,这也是跑模型绕不开的坎。
先说下这篇适合谁看。如果你已经在用PyTorch写模型,但始终是CPU在跑,或者你的代码里见过model = MyModel()然后直接model(x)这种写法,但不太明白为什么一个实例能被“调用”,那这篇就是写给你的。如果你只是听说过GPU训练能加速,但不知道怎么把代码从CPU平滑切到GPU,这篇也会把关键路径全部拆开。我尽量用踩坑现场的方式讲,不搞教科书那套。
1. GPU训练到底在优化什么
1.1 先弄明白GPU和CPU的分工逻辑
很多新手第一次接触GPU训练时会有一个直觉误区:GPU跑得快,是因为它的时钟频率高。实际上完全不是这么回事。现代CPU的单核频率经常能冲到4GHz以上,而GPU的核心频率通常只有1.5GHz到2GHz左右,单纯比频率,CPU反而占优。
GPU真正强的地方在于并行计算规模。一个普通的消费级显卡,比如RTX 3060,有3584个CUDA核心;旗舰卡像RTX 4090,核心数能到16384个。CPU再猛,消费级桌面处理器主流也就8到16个物理核心,服务器级的也就64核到96核顶天了。GPU等于用几千个低频小核心同时干活,而CPU是少数几个高频大核心按顺序硬算。
用生活类比来说,CPU像一个数学天才,你抛给它一道特别复杂的微积分,它能很快算完;但如果让它做一万道简单的四则运算,反而容易烦躁出错。GPU像是一万个小学生在排队做口算,每一道题对单个学生来说很简单,但一万个人同时开算,整体吞吐量远超那个天才。
深度学习模型里的矩阵乘法、卷积运算,本质上都是大量可并行的简单数学操作。一个卷积核在一个特征图上滑动,每个位置的计算互不依赖,这就是天然的并行任务。GPU的架构就是为这种“多而简单”的运算场景设计的。明白了这一点,你就能理解为什么数据量小的时候GPU优势不明显——启动CUDA上下文、数据传输的开销摆在那里,任务本身的计算量填不满空闲算力,反而比CPU慢。这也是一开始我为什么建议跑小demo先用CPU的原因。
1.2 我实测的CPU到GPU速度对比
给一组我最近的实测数据。环境是一个包含三层卷积和两层全连接的小型分类网络,训练数据集是CIFAR-10,batch size设为64,跑20个epoch。
- 纯CPU模式(i7-12700H,8核16线程):训练完成耗时17分42秒。
- 相同代码切换到GPU(RTX 3060 Laptop,6GB显存):训练完成耗时1分26秒。
加速比大约12.3倍。这个速度差异在更大的模型和数据规模上会更夸张。比如跑ResNet-50在ImageNet上做完整训练,CPU可能需要几周,GPU按天数甚至小时算。所以业界常说一句话:“深度学习不是被算法卡住的,是被算力卡住的。”
但别急着把代码搬到GPU就跑——里面有个非常典型的坑。如果batch size设得太小,比如设成1或者2,GPU上几千个核心大部分时间处于空闲等待状态,因为每个batch需要的数据量太少,喂不满计算管线。相反,batch size设太大又可能直接显存溢出。这个度需要你根据显卡型号去试,通常做法是看显卡的显存,6GB的卡batch size从32到128都是常见区间,8GB以上的卡可以尝试更大的值。
1.3 GPU训练的三个核心要素:显存、算力、CUDA环境
围绕GPU训练的知识可以压缩成三个关键点:显存容量、计算能力、CUDA环境。这三者缺一不可,而且互相牵制。
显存解决的是“装得下”的问题。模型参数、梯度、优化器状态、中间激活值,全都住在显存里。一个参数量为1亿的FP32模型,光参数就是400MB,加上梯度和优化器状态,直接翻三倍到1.2GB,再算上激活值,实际占用可能超过2GB。所以很多人的报错是从CUDA out of memory开始的,而不是从数学错误开始的。
算力决定“跑多快”。每张NVIDIA显卡都有对应的Compute Capability,比如RTX 30系列是8.6,RTX 40系列是8.9(部分型号是8.6)。你装的PyTorch版本会对算力有一个最低要求,太老的显卡配上太新的PyTorch,可能会直接提示找不到合适的CUDA设备。
CUDA环境解决的是“能不能用”的问题。这里说的是软件层面的CUDA Toolkit、cuDNN以及显卡驱动。很多人搞混一个概念:GPU是硬件,CUDA是驱动和运行库的集合。显卡驱动负责操作系统和GPU通信,CUDA Toolkit提供开发库和编译器,cuDNN则是深度神经网络专门优化的加速库。PyTorch是预编译的,它内部的CUDA依赖已经打包了,所以你在训练时其实不装完整的CUDA Toolkit也能跑,只要显卡驱动够新就行。
注意:如果你用conda安装的是
pytorch-cuda版本,PyTorch自带的CUDA运行时足以支撑训练。但如果你要自己编译CUDA扩展,比如某些需要写C++/CUDA混合代码的算子,那就必须单独安装CUDA Toolkit,而且要保证版本和PyTorch编译时的CUDA版本一致或兼容。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 类里的__call__方法:为什么它值得单独讲一天
2.1 __call__到底是什么
先看一段最基础的代码,我在学习第20天左右第一次遇到这个写法时是真的愣了一下:
python复制class Greeter:
def __init__(self, name):
self.name = name
def __call__(self, message):
return f"{self.name} says: {message}"
greeter = Greeter("Alice")
print(greeter("hello"))
# 输出: Alice says: hello
注意greeter("hello")这一行。greeter是Greeter类的一个实例,按常规的认知,实例不是函数,它是不能被调用的。但这里它不仅能被调用,还直接执行了__call__方法里的逻辑。这就是__call__方法的作用:让一个类的实例变得“可调用(callable)”。
在Python里,判断一个对象是不是可调用的,可以用内置函数callable():
python复制print(callable(greeter))
# 输出: True
只要类里定义了__call__方法,这个类的所有实例都会变成可调用对象。而且这个方法和普通的类方法完全一样,可以定义参数、默认值、*args和**kwargs,也可以返回值。它的特殊性只在于触发时机——当你在实例后面加括号时,Python解释器会自动调用它。
从语法层面拆解一下,greeter("hello")这行代码的背后,实际上等价于:
python复制Greeter.__call__(greeter, "hello")
Python把实例作为第一个参数自动传递进去。这种设计让类的使
