Run this notebook online:Binder or Colab: Colab

12.1. 自动并行

MXNet在后端自动构造计算图。使用计算图,系统知道所有的依赖关系,并且可以选择性地并行执行多个不相互依赖的任务提高速度。例如:numref:fig_asyncgraphin:numref:sec_async独立初始化两个变量。因此,系统可以选择并行执行它们。

通常,单个操作员将使用所有CPU或单个GPU上的所有计算资源。例如,dot操作符将使用所有CPU上的所有核心(和线程),即使在一台机器上有多个CPU处理器。这同样适用于单个GPU。因此,并行化并不是很有用的单设备计算机。对于多个设备,事情更重要。虽然并行化通常在多个gpu之间最相关,但是添加本地CPU将略微提高性能。参见例如:引用:`哈吉斯.张.米利亚卡.ea.2016年的一篇论文,重点是训练结合了GPU和CPU的计算机视觉模型。利用自动并行化框架的便利性,我们可以用几行Python代码来实现相同的目标。更广泛地说,我们对自动并行计算的讨论集中在使用cpu和gpu的并行计算以及计算和通信的并行化。

我们首先导入所需的包和模块。请注意,我们至少需要一个GPU来运行本节中的实验。

%load ../utils/djl-imports
%load ../utils/StopWatch.java

12.1.1. CPU与GPU的并行计算

让我们通过选择下面的一个数据矩阵来执行分配给cpu的两个变量的乘法运算。

public NDArray run(NDArray X){

    for(int i=0; i < 10; i++){
        X = X.dot(X);
    }
    return X;
}

NDManager manager = NDManager.newBaseManager();
NDArray x_cpu = manager.randomUniform(0f, 1f, new Shape(2000, 2000), DataType.FLOAT32, Device.cpu());
NDArray x_gpu = manager.randomUniform(0f, 1f, new Shape(6000, 6000), DataType.FLOAT32, Device.gpu());

现在我们将函数应用于数据。为了确保缓存不会在结果中起作用,我们在测量之前对每个设备执行一次循环来预热设备。

// 设备初始化预热
run(x_cpu);
run(x_gpu);

// 计算CPU计算时间
StopWatch stopWatch0 = new StopWatch();
stopWatch0.start();

run(x_cpu);

stopWatch0.stop();
ArrayList<Double> times = stopWatch0.getTimes();
System.out.println("CPU time: " + times.get(times.size() - 1) + " nanoseconds ");

// 计算GPU计算时间
StopWatch stopWatch1 = new StopWatch();
stopWatch1.start();

run(x_gpu);

stopWatch1.stop();
times = stopWatch1.getTimes();
System.out.println("GPU time: " + times.get(times.size() - 1) + " nanoseconds ");
CPU time: 0.03755235 nanoseconds
GPU time: 0.039931347 nanoseconds
// 计算CPU和GPU的组合计算时间
StopWatch stopWatch = new StopWatch();
stopWatch.start();

run(x_cpu);
run(x_gpu);

stopWatch.stop();
times = stopWatch.getTimes();
System.out.println("CPU & GPU: " + times.get(times.size() - 1) + " nanoseconds ");
CPU & GPU: 0.066029815 nanoseconds

在上述情况下,总执行时间小于各部分的总和,因为MXNet自动地在CPU和GPU设备上调度计算,而不需要用户编写复杂的代码。

12.1.2. 并行计算与通信

在许多情况下,我们需要在不同的设备之间移动数据,比如在CPU和GPU之间,或者在不同的GPU之间。例如,当我们要执行分布式优化时,我们需要在多个加速卡上聚合渐变。让我们通过在GPU上计算然后将结果复制回CPU来模拟这一点。

public NDArray copyToCPU(NDArray X){
    Y = X.toDevice(Device.cpu(), true);
    return Y;
}

// 计算GPU计算时间
StopWatch stopWatch = new StopWatch();
stopWatch.start();

NDArray Y = run(x_gpu);

stopWatch.stop();
times = stopWatch.getTimes();
System.out.println("Run on GPU: " + times.get(times.size() - 1) + " nanoseconds ");

// 计算复制到CPU的时间
StopWatch stopWatch1 = new StopWatch();
stopWatch1.start();

NDArray y_cpu = copyToCPU(Y);

stopWatch1.stop();
times = stopWatch1.getTimes();
System.out.println("Copy to CPU: " + times.get(times.size() - 1) + " nanoseconds ");
Run on GPU: 0.0617593 nanoseconds
Copy to CPU: 0.034062174 nanoseconds

这有点低效。注意,当列表的其余部分仍在计算时,我们可以开始将“Y”的一部分复制到CPU。例如,当我们计算一个小批量的(backprop)梯度时,就会出现这种情况。一些参数的梯度将比其他参数更早可用。因此,当GPU仍在运行时,开始使用PCI-Express总线带宽对我们是有利的。

// 计算组合GPU计算和复制到CPU时间。
StopWatch stopWatch = new StopWatch();
stopWatch.start();

NDArray Y = run(x_gpu);
NDArray y_cpu = copyToCPU(Y);

stopWatch.stop();
times = stopWatch.getTimes();
System.out.println("Run on GPU and copy to CPU: " + times.get(times.size() - 1) + " nanoseconds ");
Run on GPU and copy to CPU: 0.042300837 nanoseconds

两种操作所需的总时间(如预期)大大少于它们各部分的总和。注意,这个任务不同于并行计算,因为它使用不同的资源:CPU和GPU之间的总线。事实上,我们可以同时在两个设备上进行计算和通信。如上所述,在计算和通信之间有一个依赖关系:必须先计算Y[i],然后才能将其复制到CPU。幸运的是,系统可以在计算Y[i]的同时复制Y[i-1],以减少总的运行时间。

最后,我们给出了一个简单的两层MLP在一个CPU和两个GPU上训练时的计算图及其依赖关系,如:numref:fig_twogpu。手动调度由此产生的并行程序将非常痛苦。这是一个有利于优化的基于图形的计算后端的地方。

CPU和2个GPU上的两层MLP .. _fig_twogpu:

12.1.3. 总结

  • 现代系统有各种各样的设备,比如多个gpu和cpu。它们可以并行、异步地使用。

  • 现代系统也有各种各样的通信资源,如PCI-Express、存储器(通常是SSD或通过网络)和网络带宽。它们可以并行使用,以达到最高效率。

  • 后端通过自动并行计算和通信来提高性能。

12.1.4. 练习

  1. 在本节定义的“run”函数中执行了10个操作。它们之间没有依赖关系。设计一个实验,看看MXNet是否会自动并行执行它们。

  2. 当单个操作员的工作负载足够小时,即使在单个CPU或GPU上,并行化也有帮助。设计一个实验来验证这一点。

  3. 设计了一个在CPU、GPU和两个设备之间进行并行计算的实验。

  4. 使用调试器(如NVIDIA的Nsight)验证代码是否有效。

  5. 设计包含更复杂的数据依赖关系的计算任务,并运行实验,看看是否可以在提高性能的同时获得正确的结果。