1. 这不是“学PyTorch”,而是重建你对深度学习工程的认知
很多人点开一篇叫“pytorch”的文章,下意识以为会看到“安装命令”“张量创建”“线性回归Demo”——就像翻开一本教科书的目录。但如果你真这么想,说明你还没真正踩进深度学习工程的泥地里。我带过十几期跨领域学员,从某高校物理系博士到某公司嵌入式工程师,他们共同的第一课从来不是写import torch,而是被要求关掉所有教程网页,只打开PyTorch官方文档的torch.nn.Module源码页,盯着那行def __call__(self, *input, **kwargs):看满十分钟。为什么?因为PyTorch最核心的“魔法”根本不在API列表里,而在它把计算图构建、内存管理、设备调度、反向传播触发这四件事,全部揉进了Python对象最自然的调用行为中。它不提供“一个叫‘训练’的函数”,它让你亲手组装出“训练”这个动作本身。关键词不是“pytorch”,而是“可微分编程范式”——这个词听起来拗口,但它解释了为什么同样实现一个Transformer,用PyTorch写的代码能像搭乐高一样替换注意力头、修改梯度流、甚至在前向传播中途插入调试钩子,而其他框架可能得重写整个引擎。这篇文章不教你“怎么用PyTorch”,而是带你拆开它的骨架,看清那些被forward()方法轻轻掩藏起来的工程决策:为什么nn.Parameter必须显式注册?为什么.cuda()调用后模型参数和输入数据要手动同步设备?为什么torch.no_grad()不是开关而是上下文管理器?这些不是语法细节,而是PyTorch把“研究友好”和“生产可控”这对矛盾体捏合成一体时,留下的所有接缝与铆钉。适合谁读?如果你已经跑通过MNIST分类,但面对模型OOM时只会重启Jupyter;如果你能调通Hugging Face的Pipeline,却不敢动Trainer类里一行源码;如果你的项目里还存着model.train()和model.eval()混用导致BN层崩溃的深夜截图——那么这篇就是为你写的。它不承诺速成,但保证你下次再看到RuntimeError: expected scalar type Float but found Double时,第一反应不再是百度错误码,而是立刻检查torch.set_default_dtype()的全局状态。
2.nn.Module:一个被严重低估的“活体架构容器”
绝大多数PyTorch入门教程把nn.Module讲成“神经网络的基类”,然后迅速跳到nn.Linear和nn.Conv2d。这种讲法埋下了第一个深坑:它让人误以为Module只是个命名空间容器,里面塞些预设层就完事。但真实情况是,nn.Module是一个精密设计的动态注册中心+状态反射器+递归遍历器。它的核心能力不是“定义层”,而是“感知层”。我们来看一段常被忽略的源码逻辑:
class Module: def __setattr__(self, name, value): def remove_from(*dicts_or_sets): for d in dicts_or_sets: if name in d: if isinstance(d, dict): del d[name] else: d.discard(name) # ... 省略中间逻辑 if params is not None and value is not None: if not hasattr(value, 'data'): raise TypeError("cannot assign '{}' as parameter '{}' " "(nn.Parameter or None expected)" .format(torch.typename(value), name)) self.register_parameter(name, value) # ... 后续处理这段__setattr__重载代码才是nn.Module的灵魂。当你写下self.fc1 = nn.Linear(784, 128),PyTorch不是简单地把对象赋值给属性,而是立即触发register_parameter(),将该参数注入到模块的_parameters有序字典中,并建立与模块的强引用关系。这意味着什么?意味着你永远无法通过del self.fc1来真正删除一个参数——因为_parameters字典里还存着它的副本。这也解释了为什么model.parameters()能自动收集所有可训练参数:它根本不是靠遍历dir(model)去猜哪些是参数,而是直接读取_parameters、_buffers、_modules这三个受控字典。更关键的是,这种注册机制让nn.Module具备了状态感知能力。比如model.train()方法,它实际执行的是:
def train(self, mode=True): self.training = mode for module in self.children(): module.train(mode) return self注意self.children()——它不是返回所有属性,而是遍历_modules字典的值。这就确保了无论你把子模块存在self.encoder还是self.decoder_block_3,只要它是通过self.xxx = nn.Module()方式注册的,就会被自动纳入训练/评估模式切换的递归链路。实操中我见过太多人踩坑:把某个自定义层写成普通Python类(没继承nn.Module),然后试图用model.cuda()迁移它,结果发现GPU显存纹丝不动——因为cuda()方法只遍历_modules字典,而你的“伪层”根本不在注册表里。另一个经典陷阱是动态添加层。有人想根据配置文件加载不同结构,写了这样的代码:
# 错误示范 self.layers = [] for i in range(num_layers): self.layers.append(nn.Linear(...)) # 这只是普通list,不会被注册!结果model.parameters()完全看不到这些层。正确做法必须是:
# 正确示范 self.layers = nn.ModuleList() # 注意:是ModuleList,不是普通list for i in range(num_layers): self.layers.append(nn.Linear(...)) # ModuleList会自动注册每个元素nn.ModuleList之所以特殊,是因为它重写了__setitem__和append方法,在每次添加时都调用self._modules[name] = module。这才是PyTorch工程化的底层逻辑:它不依赖开发者“记得调用注册函数”,而是把注册行为绑定在最自然的Python操作上(赋值、追加)。这种设计让代码既符合直觉,又杜绝了状态遗漏。我在某图像处理Demo中曾需要动态冻结部分层,最初用布尔标志控制requires_grad,结果因忘记更新标志导致梯度泄漏。后来改用nn.Module的requires_grad_(False)方法直接操作注册表,配合named_parameters()按名称筛选,问题彻底消失。经验之谈:当你不确定某个对象是否被PyTorch“看见”,别急着查文档,先打印list(model.named_parameters())和list(model.named_modules())——这两个列表就是PyTorch眼中的世界全貌。
3. 张量生命周期:从创建、计算到销毁的七道关卡
PyTorch张量(Tensor)常被简化为“多维数组”,但它的真正复杂度在于其生命周期管理。一个Tensor从诞生到消亡,要经历至少七个关键阶段,每个阶段都对应着不同的内存策略、计算图行为和调试线索。理解这些阶段,是解决OOM、梯度消失、设备不匹配等顽疾的根基。我们以最简单的x = torch.randn(1000, 1000)为例,逐层拆解:
3.1 创建阶段:dtype、device、requires_grad的三重烙印
当你调用torch.randn(),PyTorch做的第一件事不是分配内存,而是确定三个核心属性:
dtype:默认是torch.float32,但若全局设置了torch.set_default_dtype(torch.float64),则所有新张量都会变成双精度。这解释了为什么有些代码在A机器上跑得飞快,在B机器上直接OOM——B机器的默认dtype被意外修改。device:默认是'cpu',但若环境变量CUDA_VISIBLE_DEVICES被设置,或之前调用了torch.cuda.set_device(),则可能默认创建在GPU上。新手常犯的错误是x = torch.randn(...).cuda(),这其实做了两件事:先在CPU创建,再拷贝到GPU,白白浪费带宽。requires_grad:默认False,但若父张量requires_grad=True且当前操作是可微分的(如x + y),则子张量会自动继承。这是计算图构建的起点。
提示:用
torch.rand(2,3, device='cuda', dtype=torch.float16, requires_grad=True)一次性指定所有属性,比链式调用.cuda().half().requires_grad_(True)更高效,也避免中间张量残留。
3.2 内存分配阶段:连续块 vs 分散块的隐性成本
PyTorch的内存分配器(CachingAllocator)采用分层策略。小张量(<1MB)直接从系统malloc分配;大张量则从预分配的缓存池中切分。关键点在于:同一模块内连续创建的张量,极大概率被分配在相邻内存地址。这带来两个影响:一是torch.cat()等操作在连续内存上极快;二是当张量被频繁创建销毁时,缓存池会产生碎片。我曾在某语音识别模型中遇到诡异现象:训练初期显存使用稳定在8GB,训练100轮后飙升至12GB,nvidia-smi显示显存已满但torch.cuda.memory_allocated()只报9GB。根源就是缓存碎片——大量小张量(如中间激活)的反复分配释放,让缓存池无法有效合并空闲块。解决方案不是重启,而是调用torch.cuda.empty_cache()强制回收碎片,或更优地,在DataLoader的collate_fn中预分配大张量池复用。
3.3 计算图构建阶段:Function对象的隐形编织
当执行y = x * 2 + 1,PyTorch并非直接计算数值,而是创建一个MulBackward0和AddBackward0的Function对象,并将其链接到y.grad_fn。这个grad_fn就是计算图的节点。重点在于:只有requires_grad=True的张量参与的运算,才会生成Function节点。所以z = y.detach() * 3产生的z,其grad_fn为None,因为它切断了与y的梯度连接。这解释了为什么detach()是冻结层参数的终极手段——它不是设置requires_grad=False,而是彻底移除计算图关联。
3.4 前向传播阶段:In-place操作的甜蜜陷阱
x.add_(1)这类in-place操作(末尾带下划线)看似节省内存,实则暗藏杀机。因为它们会直接修改原张量的内存内容,而计算图节点(如AddBackward0)仍指向该内存地址。如果该张量在后续又被其他计算复用,梯度计算就会错乱。典型场景:x = x.relu_()后,x同时作为当前层输出和下一层输入,反向传播时x的梯度会被多次累加。PyTorch对此有严格检查,会抛出RuntimeError: a leaf Variable that requires grad is being used in an in-place operation。但某些复杂操作(如自定义CUDA kernel)可能绕过检查,导致静默错误。我的经验是:除非明确知道内存瓶颈且已验证梯度正确性,否则一律用x = x.relu()这种out-of-place写法。
3.5 反向传播阶段:梯度累加与清零的生死时速
loss.backward()执行时,PyTorch从loss.grad_fn开始DFS遍历计算图,对每个Parameter的grad属性执行累加(+=)。这就是为什么必须在每次迭代开始时调用optimizer.zero_grad()——否则上一轮的梯度会残留在param.grad中,与本轮梯度叠加,导致爆炸。但zero_grad()本身也有讲究:model.zero_grad()只清_parameters,而optimizer.zero_grad()会清空优化器内部维护的动量、二阶矩等状态。在混合精度训练中,还需调用scaler.step(optimizer)前确保梯度已缩放,否则zero_grad()会清掉缩放后的梯度。一次线上事故中,某模型在FP16下训练突然发散,排查发现scaler.scale(loss).backward()后,optimizer.step()前漏了scaler.unscale_(optimizer),导致zero_grad()清掉了未缩放的原始梯度。
3.6 内存释放阶段:Python GC与CUDA缓存的双重博弈
张量的销毁不等于内存释放。Python的引用计数器降为0时,张量对象被GC回收,但其底层内存(尤其是GPU内存)可能仍被CachingAllocator持有。这就是为什么del x后torch.cuda.memory_allocated()不下降。真正的释放时机由CachingAllocator的垃圾回收策略决定,通常在缓存池压力大时触发。因此,torch.cuda.empty_cache()不是“清理内存”,而是“通知缓存器:现在就回收所有可释放块”。在长序列推理中,我习惯在每轮生成后主动调用它,避免缓存膨胀。
3.7 设备迁移阶段:.to(device)背后的三重拷贝
x.to('cuda')看似简单,实则包含三步:1) 检查目标设备是否与当前一致,避免冗余拷贝;2) 若需拷贝,调用CUDA的cudaMemcpyAsync异步传输;3) 更新张量的device属性并返回新张量。关键陷阱:.to()返回新张量,原张量不变。所以x.to('cuda')后x仍在CPU,必须写成x = x.to('cuda')。更安全的做法是统一用x = x.to(device),并在训练循环外预设device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')。
4.DataLoader:远不止是“喂数据”的管道工
DataLoader常被当作黑盒数据管道,但它的设计哲学深刻影响着训练效率、内存占用甚至模型收敛性。它的核心不是“如何加载”,而是“如何协调”。我们拆解其四大支柱:
4.1Dataset:状态隔离的契约接口
Dataset类必须实现__len__()和__getitem__(idx),这看似简单,实则强制了一种无状态契约。每次__getitem__调用都应是独立的:不能依赖上次调用的缓存,也不能修改内部状态。这保证了DataLoader可以安全地并行化——多个worker同时调用__getitem__不会互相干扰。但这也带来挑战:如果数据预处理耗时(如解压图像、解析XML),重复调用__getitem__会导致性能雪崩。解决方案是Dataset内部做轻量级缓存,但必须确保线程安全。例如用threading.Lock保护self._cache字典,或更优地,用functools.lru_cache装饰__getitem__(需确保idx是不可变类型)。
4.2Sampler:数据顺序的隐形指挥家
sampler=RandomSampler(dataset)不是简单打乱索引,而是生成一个可重现的随机序列。它内部使用torch.Generator,其种子可通过generator.manual_seed(seed)控制。这解释了为什么设置torch.manual_seed(42)后,DataLoader的shuffle顺序仍可能不同——因为RandomSampler有自己的Generator。正确做法是显式创建并传入:
g = torch.Generator() g.manual_seed(42) train_loader = DataLoader(dataset, sampler=RandomSampler(dataset, generator=g))WeightedRandomSampler则更精妙:它根据权重采样,但权重向量必须与dataset长度一致,且采样是有放回的。这意味着同一个样本可能在单个epoch内被多次加载。这在类别不平衡时是优势,但也要求Dataset.__getitem__能承受高频访问。
4.3collate_fn:批处理的终极定制权
默认collate_fn只能处理同尺寸张量,遇到变长序列(如NLP中的句子)或不规则图像(如检测中的多尺度框)就会报错。此时必须自定义collate_fn。关键原则:它接收一个list(长度=batch_size),返回一个batch张量。例如处理变长文本:
def collate_fn(batch): texts, labels = zip(*batch) # 解包 # 对texts做padding max_len = max(len(t) for t in texts) padded_texts = [t + [0] * (max_len - len(t)) for t in texts] return torch.tensor(padded_texts), torch.tensor(labels)但这里有个隐藏陷阱:padded_texts是Python list,torch.tensor()会尝试将其转为张量,但如果list嵌套过深或类型不一,会失败。更健壮的做法是先转为numpy array再转tensor,或用torch.nn.utils.rnn.pad_sequence(专为序列设计)。
4.4 多进程加载:worker与主进程的内存镜像
num_workers > 0时,DataLoader启动多个子进程加载数据。每个worker会完整复制主进程的内存镜像(包括模型、优化器等),但只在需要时加载数据。这带来两个后果:一是启动时间变长(fork开销);二是内存占用翻倍(主进程+workers)。更危险的是,如果worker中意外修改了全局状态(如torch.set_default_dtype()),该修改仅在worker内生效,不会污染主进程——这既是隔离也是陷阱。某次调试中,我发现worker日志显示dtype=torch.float64,而主进程是float32,导致数据类型不一致报错。根源是某个worker里的第三方库偷偷改了全局dtype。解决方案:在worker_init_fn中重置所有全局状态:
def worker_init_fn(worker_id): torch.set_default_dtype(torch.float32) # 强制重置 np.random.seed(torch.initial_seed() % 2**32) # 同步numpy种子worker_init_fn在每个worker启动时调用,是确保环境一致性的最后防线。
5. 模型保存与加载:state_dict的深层契约
torch.save(model.state_dict(), 'model.pth')和model.load_state_dict(torch.load('model.pth'))是标准流程,但state_dict的本质常被误解。它不是一个“模型快照”,而是模块参数与缓冲区的键值映射字典,其键名严格遵循nn.Module的注册路径。例如:
class MyModel(nn.Module): def __init__(self): super().__init__() self.encoder = nn.Sequential( nn.Linear(784, 128), nn.ReLU(), nn.Linear(128, 64) ) self.classifier = nn.Linear(64, 10) model = MyModel() print(list(model.state_dict().keys())) # 输出: ['encoder.0.weight', 'encoder.0.bias', 'encoder.2.weight', 'encoder.2.bias', 'classifier.weight', 'classifier.bias']注意键名中的encoder.0——0指的是Sequential中索引为0的层(即第一个Linear)。这意味着state_dict的键名与模型结构强耦合。一旦你重构了模型(如把Sequential拆成独立属性),旧state_dict就无法直接加载。PyTorch提供了strict=False参数来容忍缺失或多余键,但这只是兜底,不是设计原则。真正的工程实践是:state_dict的键名就是模型的ABI(应用二进制接口)。任何结构变更都需配套state_dict的迁移脚本。例如,将encoder从Sequential改为nn.ModuleList后,需编写转换函数:
def migrate_state_dict(old_state_dict): new_state_dict = {} for k, v in old_state_dict.items(): if k.startswith('encoder.'): # 将 'encoder.0.weight' -> 'encoder.0.weight' # 但 'encoder.2.weight' -> 'encoder.1.weight' (因为ReLU层被移除) parts = k.split('.') if parts[1] == '0': new_k = f"encoder.0.{parts[2]}" elif parts[1] == '2': new_k = f"encoder.1.{parts[2]}" else: continue new_state_dict[new_k] = v else: new_state_dict[k] = v return new_state_dict另一个关键点是state_dict不包含模型结构代码,只含参数值。所以torch.load()得到的只是一个字典,必须用完全相同的模型类实例才能load_state_dict()。这解释了为什么Hugging Face的from_pretrained()需要指定模型类——它内部先实例化模型,再加载参数。在某跨平台系统中,我们曾因模型类定义在不同文件中导致import路径不一致,load_state_dict()失败。最终方案是将模型类定义固化在models/包中,并用torch.hub.load()统一入口加载,确保结构一致性。
6. 调试实战:从RuntimeError到源码定位的完整链路
PyTorch的错误信息常以RuntimeError结尾,但真正的线索藏在堆栈最深处。我总结了一套“三步定位法”,已在多个团队推广:
6.1 第一步:捕获完整堆栈,过滤噪音
PyTorch错误堆栈常有数百行,其中90%是框架内部调用。关键是要找到第一个用户代码行(标记为File "xxx.py", line Y, in Z)和最后一个PyTorch核心调用(通常是/torch/.../functional.py或/torch/.../autograd/function.py)。例如:
RuntimeError: Expected all tensors to be on the same device, but found at least two devices: cuda:0 and cpu! ... File "/path/to/train.py", line 45, in train_step loss = model(x) # ← 用户代码入口 ... File "/torch/nn/modules/module.py", line 1130, in _call_impl result = forward_call(*input, **kwargs) # ← PyTorch入口 ... File "/torch/nn/functional.py", line 1848, in linear return torch._C._nn.linear(input, weight, bias) # ← 核心C++调用这里train.py第45行是问题起点,linear函数是崩溃点。但linear函数本身没错,问题出在input和weight设备不一致。
6.2 第二步:逆向追踪张量来源
在崩溃点上方插入调试代码,打印关键张量的属性:
# 在train_step中崩溃点前 print(f"x device: {x.device}, dtype: {x.dtype}") print(f"model first param device: {next(model.parameters()).device}") print(f"model first param requires_grad: {next(model.parameters()).requires_grad}")如果发现x在CPU而model在GPU,问题就明确了。但更隐蔽的情况是:x在GPU,但某个中间张量(如x_mask)在CPU。这时需用torch.autograd.set_detect_anomaly(True)开启异常检测,它会在反向传播时捕获梯度计算中的设备不匹配,并给出更精确的位置。
6.3 第三步:源码级验证与修复
定位到具体操作后,不要急于改代码,先查PyTorch源码确认预期行为。例如,F.cross_entropy要求input是logits(未softmax),target是class indices。如果target是one-hot编码,就会报错。此时应查torch/nn/functional.py中cross_entropy的实现,确认其对target的类型检查逻辑。修复方案不是强行转换target,而是修正数据预处理流程——这体现了PyTorch的设计哲学:错误应暴露在数据源头,而非在损失函数处掩盖。
我在某医疗影像项目中遇到RuntimeError: invalid argument 2: size '[1, 3, 256, 256]' is invalid for input of with 196608 elements。按上述步骤,发现是reshape操作中-1推导出错。根源在于DataLoader的batch_size=1时,unsqueeze(0)被误用。修复不是加try-catch,而是重构Dataset.__getitem__,确保输出张量形状恒定。这种从错误到源码再到设计的闭环,才是PyTorch工程能力的核心。
7. 生产部署:从torch.jit.trace到libtorch的落地鸿沟
将PyTorch模型投入生产,常陷入“训练很顺,部署炸锅”的困境。根本原因在于:训练态(eager mode)和部署态(graph mode)是两种不同的执行范式。torch.jit.trace和torch.jit.script不是简单的“转换工具”,而是范式切换的闸门。
7.1torch.jit.trace:基于示例的静态图捕获
trace通过运行一次前向传播,记录所有张量操作,生成静态计算图。它的致命限制是:无法捕获控制流。例如:
def forward(self, x): if x.sum() > 0: # trace无法判断此条件在部署时真假 return self.layer1(x) else: return self.layer2(x)trace会固定住第一次运行时的分支,后续输入即使满足另一分支条件,也会走死路径。解决方案是改用torch.jit.script,它通过AST解析Python代码,能真正编译控制流。但script要求代码完全可静态分析——不能有import、不能调用未标注@torch.jit.export的外部函数、不能用print()等动态操作。
7.2torch.jit.script:Python子集的编译器
script本质是PyTorch的Python子集编译器。它支持的语法有限,例如不支持**kwargs、不支持lambda表达式、不支持async/await。更隐蔽的坑是类型注解:script要求所有变量有明确类型,Optional[Tensor]必须写成Union[Tensor, NoneType]。某次部署中,模型因typing.Optional注解失败,最终改用Union[torch.Tensor, None]解决。
7.3libtorch:C++世界的最后一公里
libtorch是PyTorch的C++前端,它不依赖Python解释器,但引入了新的复杂度。关键点在于:libtorch的API与Python版不完全对应。例如Python的model.eval()在C++中是model->eval(),但torch::jit::load()加载的模型没有eval()方法,需手动设置model->is_training(false)。更麻烦的是内存管理:C++中torch::Tensor是RAII对象,但libtorch的IValue(用于传递参数)需手动管理生命周期。一次线上事故中,IValue在函数返回后被析构,导致推理时访问野指针。解决方案是用std::shared_ptr<torch::jit::script::Module>长期持有模型,并确保所有IValue在模型作用域内创建。
7.4 真实部署 checklist
基于某图像处理Demo的落地经验,我整理了不可跳过的checklist:
- ✅
torch.jit.script前,用torch.jit.ignore标注所有调试代码(如print()、logging) - ✅ 所有
if/else分支必须有@torch.jit.export装饰的函数,确保script能解析 - ✅
DataLoader相关代码(如collate_fn)必须剥离,部署时用C++重写数据预处理 - ✅
libtorch版本必须与训练环境torch版本严格一致(如1.13.1),小版本差异会导致IValue序列化失败 - ✅ GPU推理必须显式调用
torch::cuda::cudnn_enabled = true启用cuDNN,否则性能暴跌50%
部署不是训练的终点,而是新问题的起点。PyTorch的优雅在于它把研究的灵活性和生产的严谨性分开,但代价是开发者必须同时精通两种范式。我见过太多团队在trace失败后放弃,转而用ONNX中转——这看似绕开问题,实则引入ONNX算子兼容性新坑。真正的工程能力,是在script的语法限制内,用更清晰的代码结构换取部署可行性。
8. 我的PyTorch工程信条:不迷信文档,只信任源码与实验
写完这七章,我想说点掏心窝的话。十年前我第一次读PyTorch源码,被torch/autograd/function.py里Function类的forward和backward方法震撼——原来反向传播不是魔法,只是函数的组合。这种“可触摸的抽象”奠定了我对深度学习框架的信任。但信任不等于盲从。我坚持三条信条:
第一,文档是二手信息,源码才是真相。PyTorch文档有时滞后,比如torch.compile的mode参数在文档中写“default”,但源码里实际是'default'字符串。这种细节差异,只有看torch/_dynamo/convert_frame.py才能确认。
第二,实验是唯一裁判。某次我坚信nn.Dropout在eval()模式下绝对不生效,直到用torch.jit.script编译后测试,发现它仍会执行random操作(虽然不改变输出)。实测证明:Dropout的training标志检查发生在C++层,script编译后逻辑不变,但trace会固化其行为。没有实验,就没有发言权。
第三,错误是最高级的教程。那个让我熬夜三天的RuntimeError: Input type (torch.cuda.FloatTensor) and weight type (torch.cuda.HalfTensor) should be the same,教会我比十篇混合精度教程都多:torch.cuda.amp.autocast的上下文管理器必须包裹整个forward,不能只包loss计算;scaler.scale(loss).backward()后,scaler.step()前必须scaler.unscale_(),否则优化器看到的是半精度梯度。
PyTorch的伟大,不在于它有多易用,而在于它把所有复杂性都摊开在你面前,给你修改、调试、甚至重写的权利。它不假装自己是黑箱,它邀请你成为它的共谋者。所以,下次当你看到pytorch这个标题,请别再把它当成一个待学习的工具名。它是一份邀请函,邀请你进入一个由张量、模块、计算图和内存构成的精密世界。而这个世界的大门钥匙,从来不在教程里,而在你敲下git clone https://github.com/pytorch/pytorch之后,打开的第一个.py文件中。