- 文档
- 教程
- 人工智能
- 深度学习
- NLP
- 计算机视觉
- 强化学习
【免费下载链接】d2l-en
Interactive deep learning book with multi-framework code, math, and discussions. Adopted at 500 universities from 70 countries including Stanford, MIT, Harvard, and Cambridge.
本文以 D2L(Dive into Deep Learning)仓库中 chapter_computational-performance/async-computation.md 为主线,系统讲解深度学习框架为何采用异步编程模型、前端(Python)与后端(C++ 执行引擎)如何解耦协作,以及waitall、wait_to_read、print、asnumpy等显式与隐式屏障对性能的影响。读完本文,你将掌握借助异步执行加速训练/推理的底层原理,学会用基准实验量化异步收益,并能在 MXNet 与 PyTorch 两种框架中正确使用同步原语、避免"同步陷阱"。
为什么深度学习框架需要异步编程
今天的计算机是高度并行的系统:一块 CPU 拥有多个核心(每个核心往往还有多线程),一块 GPU 内含大量处理单元,单台设备上甚至常常挂载多块 GPU。换句话说,我们可以在同一时刻、在多个不同设备上处理大量互不相关的计算。遗憾的是,Python 并不擅长编写并行与异步代码——Python 是单线程语言,而且这一特性在可预见的未来不会改变。
深度学习框架因此普遍采用异步编程模型来绕开 Python 单线程的限制:
- MXNet 与 TensorFlow从设计上就采用异步执行模型,把计算命令"即发即忘"地交给后端引擎,以此换取吞吐性能;
- PyTorch则主要依赖 Python 自身的调度器,形成不同的性能取舍。默认情况下,PyTorch 的GPU 操作是异步的:当你调用一个使用 GPU 的函数时,操作只是被排队到对应设备上,并不保证立刻执行完毕。这种排队机制允许 CPU 或其它 GPU 上的计算与当前操作并行推进。
理解异步编程的运作方式,有助于我们主动减少计算量与相互依赖,从而降低内存开销、提高处理器利用率,写出更高效的训练程序。
本文实验代码位于 async-computation.md,需要引入框架与 D2L 工具库:
# MXNet 版本 from d2l import mxnet as d2l import numpy, os, subprocess from mxnet import autograd, gluon, np, npx from mxnet.gluon import nn npx.set_np()# PyTorch 版本 from d2l import torch as d2l import numpy, os, subprocess import torch from torch import nn其中d2l.Benchmark是 D2L 提供的计时上下文管理器,其实现位于 d2l/mxnet.py(PyTorch 对应实现见 d2l/torch.py),底层复用d2l.Timer(见 d2l/mxnet.py)记录区间耗时:
class Benchmark: """For measuring running time.""" def __init__(self, description='Done'): self.description = description def __enter__(self): self.timer = d2l.Timer() return self def __exit__(self, *args): print(f'{self.description}: {self.timer.stop():.4f} sec')通过后端实现的异步:一个热身基准实验
为了直观感受异步执行,我们先做一个玩具问题:生成随机矩阵并做矩阵乘法,分别在 NumPy 与框架张量中完成,对比两者耗时。
MXNet 侧(同机对比numpy与mxnet.np):
with d2l.Benchmark('numpy'): for _ in range(10): a = numpy.random.normal(size=(1000, 1000)) b = numpy.dot(a, a) with d2l.Benchmark('mxnet.np'): for _ in range(10): a = np.random.normal(size=(1000, 1000)) b = np.dot(a, a)PyTorch 侧(注意:PyTorch 张量定义在 GPU 上,先做一次预热):
# Warmup for GPU computation device = d2l.try_gpu() a = torch.randn(size=(1000, 1000), device=device) b = torch.mm(a, a) with d2l.Benchmark('numpy'): for _ in range(10): a = numpy.random.normal(size=(1000, 1000)) b = numpy.dot(a, a) with d2l.Benchmark('torch'): for _ in range(10): a = torch.randn(size=(1000, 1000), device=device) b = torch.mm(a, a)运行结果往往出人意料:框架张量侧的耗时比 NumPy 快若干个数量级。
- 在 MXNet 场景中,两者在同一处理器上执行,因此唯一的解释是"另有隐情":计算其实由后端在后台执行,而前端(Python 侧)早已把控制权交还给了解释器;
- 在 PyTorch 场景中,NumPy 的
dot在 CPU 上执行,而 PyTorch 的矩阵乘法在 GPU 上执行,后者更快本属预期,但差距大到反常,同样提示我们 PyTorch 的 GPU 操作默认就是异步的。
d2l.try_gpu()用于优先选择 GPU、无 GPU 时回退到 CPU,其实现位于 d2l/torch.py:
def try_gpu(i=0): """Return gpu(i) if exists, otherwise return cpu().""" if num_gpus() >= i + 1: return gpu(i) return cpu()强制同步后再测:时间真相浮出水面
如果在计时结束前强制框架"做完所有事"再返回,就能看到真实耗时:
# MXNet:npx.waitall() 等待全部计算完成 with d2l.Benchmark(): for _ in range(10): a = np.random.normal(size=(1000, 1000)) b = np.dot(a, a) npx.waitall()# PyTorch:torch.cuda.synchronize(device) 同步指定设备 with d2l.Benchmark(): for _ in range(10): a = torch.randn(size=(1000, 1000), device=device) b = torch.mm(a, a) torch.cuda.synchronize(device)加上强制同步后,耗时与 NumPy 才在同一量级。这印证了核心结论:计算由后端线程执行,而前端早已返回控制权给 Python——这就是异步带来的"假快"。
前端与后端:两种角色,一条依赖感知的任务队列
宽泛地说,深度学习框架包含两部分:
- 前端(frontend):直接与用户交互,例如 Python(也可以是 R、Scala、C++ 等其它语言);
- 后端(backend):系统真正执行计算的部分,以 C++ 实现为主。
无论前端使用哪种编程语言,MXNet/PyTorch 程序的执行都主要发生在 C++ 后端。前端语言发出的操作被传递到后端执行:后端管理自己的线程,这些线程不断收集并执行排队的任务。要做到这一点,后端必须能够追踪计算图中各个步骤之间的依赖关系,因此相互依赖的操作无法被并行化,而彼此独立的操作则可以被并发执行。
下图展示了编程语言前端与深度学习框架后端的对应关系(图片来源 img/frontends.png):
依赖图的玩具示例
再看一个更小的例子,理解依赖追踪:
# MXNet x = np.ones((1, 2)) y = np.ones((1, 2)) z = x * y + 2 z# PyTorch x = torch.ones((1, 2), device=device) y = torch.ones((1, 2), device=device) z = x * y + 2 z[)](img/asyncgraph.svg)
上述代码的执行过程如下:Python 前端线程执行前三条语句时,只是把任务投递到后端队列便立即返回;直到最后一条语句的结果需要被打印时,Python 前端线程才真正等待 C++ 后端线程算完变量z的结果。
这种设计的一个显著收益是:Python 前端线程完全不需要执行实际计算,因此无论 Python 本身的性能如何,都不会影响程序的整体性能。前端与后端线程的交互过程如下图所示(图源 img/threading.svg):
屏障与阻塞:哪些操作会让 Python 等待
异步执行并不总是"零等待"。在 MXNet 中,有若干操作会强制 Python 等待计算完成,它们分为显式屏障与隐式屏障两类。
显式屏障:waitall与wait_to_read
npx.waitall():等待所有计算完成,无论计算指令是何时发出的。这是最"简单粗暴"的同步手段,但除非万不得已,实践中不建议使用——它会显著损害性能;z.wait_to_read():只等待特定变量可用。调用后,MXNet 会阻塞 Python 直到变量z计算完成,但其它计算仍可继续推进。
对比实验如下:
with d2l.Benchmark('waitall'): b = np.dot(a, a) npx.waitall() with d2l.Benchmark('wait_to_read'): b = np.dot(a, a) b.wait_to_read()在文档的实验中,两种操作耗时大致相同。需要说明的是,这里的a是此前热身实验中的矩阵,二次计算已经命中缓存与调度状态,因此两者差距并不明显;当任务队列中存在大量未完成计算时,两者的语义差异才会放大——waitall需要清空整个队列,而wait_to_read只需等待目标变量所在的依赖子图。
隐式屏障:print、asnumpy、item
除了显而易见的阻塞操作,还需警惕隐式阻塞:
- 打印变量(
print):打印显然要求变量已就绪,因此print就是一个阻塞点; z.asnumpy():转换为 NumPy 数组是阻塞的,因为 NumPy 没有异步的概念,它需要像print一样真正访问到数值;z.item():转换为标量同样阻塞。
with d2l.Benchmark('numpy conversion'): b = np.dot(a, a) b.asnumpy() with d2l.Benchmark('scalar conversion'): b = np.dot(a, a) b.sum().item()隐式屏障为何伤性能
频繁地把少量数据从框架的内存空间拷贝到 NumPy(或反向拷贝)会摧毁本可高效的代码性能。原因在于:每次这样的转换都会强制计算图先求出得到该数值所需的全部中间结果,然后才能继续做别的事。换句话说,一次asnumpy()可能引发整条依赖链的同步求值,把异步流水线的收益一次性抹平。
改善计算:异步调度到底省了多少时间
在高度多线程的系统上(普通笔记本也常有 4 个线程以上,多路服务器上线程数可超过 256),操作调度本身的开销会变得相当可观。因此,让计算与调度异步、并行地进行是非常必要的。
为了量化收益,我们对比"顺序执行"与"异步执行"下将变量自增 1 一万次(10000 次)的耗时。同步版本通过在每个加法之间插入wait_to_read屏障来模拟:
# MXNet with d2l.Benchmark('synchronous'): for _ in range(10000): y = x + 1 y.wait_to_read() with d2l.Benchmark('asynchronous'): for _ in range(10000): y = x + 1 npx.waitall()实测中,异步版本明显更快。背后的时序模型可以简化为三个阶段:
- 前端命令后端把计算任务
y = x + 1插入队列——耗时记为 $t_1$; - 后端从队列中取出任务并完成实际计算——耗时记为 $t_2$;
- 后端把计算结果返回给前端——耗时记为 $t_3$。
如果不使用异步编程,10000 次计算的总耗时约为 $10000(t_1 + t_2 + t_3)$;而采用异步编程后,前端无需在每一轮循环中等待后端返回结果,总耗时可以压缩到
$$t_1 + 10000,t_2 + t_3 \quad (\text{在 } 10000,t_2 > 9999,t_1 \text{ 的前提下})$$
即前端只负责首尾各一次交互,中间的计算全部由后端流水线化完成。
为什么需要假设 $10000,t_2 > 9999,t_1$?该公式成立的前提是"后端计算"是整条流水线的瓶颈:只有后端处理全部任务的总耗时超过前端投递剩余任务的总耗时,前端才能在计算期间"从容投递、永不等待"。若反之前端投递过慢($10000,t_1$ 主导),总耗时就会被前端投递速度卡住,公式便不再适用。这也是理解练习 1 的关键:异步优化的是"前端等待后端"的空转时间,而无法消除真正的计算瓶颈本身。
实践建议:按小批量同步一次
异步带来的是更"灵敏"的前端,但也要注意不要无限填充任务队列——队列过深会导致内存占用飙升。实践中的推荐做法是:每个小批量(minibatch)训练结束同步一次(例如 PyTorch 中在反向传播与优化器更新之间借助设备同步点、MXNet 中周期性调用waitall),使前端与后端大致保持同步,既保留流水线收益,又避免内存失控。
相关章节与延伸阅读
异步计算是"计算性能"专题的一环,本章导航见 chapter_computational-performance/index.md。可与之配合阅读:
- hybridize.md:命令式编程与符号式编程的权衡,与异步调度密切相关;
- auto-parallelism.md:基于计算图依赖关系,在 CPU/GPU 之间以及多 GPU 之间自动并行,其中再次复用了本文的
asyncgraph依赖图概念; - 训练时 GPU 选择工具
try_gpu/try_all_gpus见 d2l/mxnet.py 与 d2l/torch.py。
总结
- 深度学习框架可以将 Python 前端与执行后端解耦,实现命令的快速异步插入与相应并行,这是其性能优势的重要来源;
- 异步带来响应灵敏的前端,但需注意不要过度填充任务队列导致内存膨胀;推荐每个小批量同步一次,让前后端保持大致同步;
- 芯片厂商提供了成熟的性能分析工具(如 NVIDIA 的 profiling 工具链),可获得比框架级计时更细粒度的效率洞察;
- 特别留意从框架内存管理转换到 Python 的操作(
print、asnumpy、item等)会强制后端等待特定变量就绪,这有时是必要的,但粗心地滥用同步会毁掉性能。
练习与思考
- 文档提到异步计算可将 10000 次计算的总耗时降为 $t_1 + 10000 t_2 + t_3$。为什么必须假设 $10000,t_2 > 9999,t_1$?提示:从"后端计算总耗时 vs 前端投递总耗时"谁主导流水线的角度分析(思路见上文"为什么需要假设"小节)。
- 实测对比
waitall与wait_to_read的差异。提示:先向队列投递一批指令,再对某个中间结果同步,观察剩余指令是否仍能继续执行。 - 在 CPU 上重复本节的矩阵乘法基准:PyTorch 在 CPU 上执行时,是否仍能观察到后端异步?提示:PyTorch 默认的异步行为主要针对 GPU(CUDA 流),CPU 路径的行为有所不同。
- 文档
- 教程
- 人工智能
- 深度学习
- NLP
- 计算机视觉
- 强化学习
【免费下载链接】d2l-en
Interactive deep learning book with multi-framework code, math, and discussions. Adopted at 500 universities from 70 countries including Stanford, MIT, Harvard, and Cambridge.
相关推荐
异步计算(Asynchronous Computation)实战:深度学习框架前端/后端解耦与性能优化
异步计算(Asynchronous Computation)实战:深度学习框架前端/后端解耦与性能优化 导读 《动手学深度学习》(d2l zh)是一本面向中文读
人工智能深度学习机器学习教程《动手学深度学习》异步计算深度解析:前端-后端解耦、障碍器阻塞器与性能调优实战
《动手学深度学习》异步计算深度解析:前端 后端解耦、障碍器阻塞器与性能调优实战 本文深入讲解《动手学深度学习》中"异步计算"一章的核心原理与实战方法:深度学习框
人工智能深度学习机器学习教程深度学习计算性能实战:D2L 中的命令式编程、异步执行与多 GPU 并行优化
深度学习计算性能实战:D2L 中的命令式编程、异步执行与多 GPU 并行优化 导读 :深度学习中数据集与模型通常规模庞大,计算开销高昂,计算性能直接决定训练效率
文档教程人工智能深度学习NLP计算机视觉强化学习
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考