基于 VIDIA GPU 的神经网络自动调度

%%shell
# Installs the latest dev build of TVM from PyPI. If you wish to build
# from source, see https://tvm.apache.org/docs/install/from_source.html
pip install apache-tvm --pre

基于 VIDIA GPU 的神经网络自动调度#

原作者: Lianmin Zheng

针对特定设备和工作负载的自动调优对于获得最佳性能至关重要。下面是关于如何用自动调度器调优 NVIDIA GPU 的整个神经网络的教程。

为了自动调优神经网络,需要将网络划分为小的子图,并独立地调优它们。每个子图被视为一个搜索任务。任务调度程序对时间进行切片,并动态地为这些任务分配时间资源。任务调度器预测每个任务对端到端执行时间的影响,并优先考虑能够最大程度减少执行时间的任务。

对于每个子图,使用 tvm/python/topi 中的 compute 声明来获得张量表达式形式的计算 DAG。然后,使用自动调度器来构造 DAG 的搜索空间,并搜索良好的调度(低级优化)。

与基于模板的 autotvm 依赖手动模板定义搜索空间不同,自动调度程序不需要任何调度模板。换句话说,自动调度器只使用 tvm/python/topi 中的 compute,而不使用现有的调度模板。

import numpy as np

import tvm
from tvm import relay, auto_scheduler
import tvm.relay.testing
from tvm.contrib import graph_executor

定义网络#

首先,需要用 relay 前端 AP I定义网络。可以从 tvm.relay.testing 加载一些预定义的网络。还可以从 MXNet、ONNX、PyTorch 和 TensorFlow 加载模型(参见前端教程)。

对于卷积神经网络,尽管自动调度器可以在任何布局下正确工作,但我们发现 NHWC 布局通常能获得最佳性能。我们还通过自动调度器实现了对 NHWC 布局的更多优化。因此,建议将模型转换为 NHWC 布局以使用自动调度器。可以使用 ConvertLayout pass 在 TVM 中进行布局转换。

def get_network(name, batch_size, layout="NHWC", dtype="float32"):
    """Get the symbol definition and random weight of a network"""

    # auto-scheduler prefers NHWC layout
    if layout == "NHWC":
        image_shape = (224, 224, 3)
    elif layout == "NCHW":
        image_shape = (3, 224, 224)
    else:
        raise ValueError("Invalid layout: " + layout)

    input_shape = (batch_size,) + image_shape
    output_shape = (batch_size, 1000)

    if name.startswith("resnet-"):
        n_layer = int(name.split("-")[1])
        mod, params = relay.testing.resnet.get_workload(
            num_layers=n_layer,
            batch_size=batch_size,
            layout=layout,
            dtype=dtype,
            image_shape=image_shape,
        )
    elif name.startswith("resnet3d-"):
        n_layer = int(name.split("-")[1])
        mod, params = relay.testing.resnet.get_workload(
            num_layers=n_layer,
            batch_size=batch_size,
            layout=layout,
            dtype=dtype,
            image_shape=image_shape,
        )
    elif name == "mobilenet":
        mod, params = relay.testing.mobilenet.get_workload(
            batch_size=batch_size, layout=layout, dtype=dtype, image_shape=image_shape
        )
    elif name == "squeezenet_v1.1":
        assert layout == "NCHW", "squeezenet_v1.1 only supports NCHW layout"
        mod, params = relay.testing.squeezenet.get_workload(
            version="1.1",
            batch_size=batch_size,
            dtype=dtype,
            image_shape=image_shape,
        )
    elif name == "inception_v3":
        input_shape = (batch_size, 3, 299, 299) if layout == "NCHW" else (batch_size, 299, 299, 3)
        mod, params = relay.testing.inception_v3.get_workload(batch_size=batch_size, dtype=dtype)
    elif name == "mxnet":
        # an example for mxnet model
        from mxnet.gluon.model_zoo.vision import get_model

        assert layout == "NCHW"

        block = get_model("resnet18_v1", pretrained=True)
        mod, params = relay.frontend.from_mxnet(block, shape={"data": input_shape}, dtype=dtype)
        net = mod["main"]
        net = relay.Function(
            net.params, relay.nn.softmax(net.body), None, net.type_params, net.attrs
        )
        mod = tvm.IRModule.from_expr(net)

    return mod, params, input_shape, output_shape


# Define the neural network and compilation target
network = "resnet-18"
batch_size = 1
layout = "NHWC"
target = tvm.target.Target("cuda")
dtype = "float32"
log_file = "%s-%s-B%d-%s.json" % (network, layout, batch_size, target.kind.name)

提取搜索任务#

接下来,从网络中提取搜索任务及其权重。任务的权重是该任务的子图在整个网络中出现的次数。通过使用权重,可以将网络的端到端延迟近似为 sum(latency[t] * weight[t]),其中 latency[t] 是任务的延迟,weight[t] 是任务的权重。任务调度器会优化这个目标。

# 从网络中提取任务
print("Extract tasks...")
mod, params, input_shape, output_shape = get_network(network, batch_size, layout, dtype=dtype)
tasks, task_weights = auto_scheduler.extract_tasks(mod["main"], params, target)

for idx, task in enumerate(tasks):
    print("========== Task %d  (workload key: %s) ==========" % (idx, task.workload_key))
    print(task.compute_dag)

开始调优#

现在,设置了一些调优选项并启动搜索任务

  • measure_ctx 启动不同的测量进程以提供隔离。它可以在测量期间保护主进程不受 GPU 崩溃的影响,并避免其他运行时冲突。

  • min_repeat_ms 定义每次测量中一次“重复”的最小持续时间。这可以预热 GPU,这对于获得准确的测量结果是必要的。通常,建议值 >= 300 ms。

  • num_measure_trials 是在调优期间可以使用的度量试验的数量。可以将它设置为小的数字(例如,200)以进行快速演示运行。在实践中,建议将它设置在 900 * len(tasks) 左右,这通常足以让搜索收敛。例如 resnet-18 中有 24 个任务,所以可以将其设置为 20000。可以根据自己的时间预算调整该参数。

  • 此外,使用 RecordToFile 将测量记录转储到日志文件中,测量记录可以用于查询历史,恢复搜索,并在以后进行更多的分析。

  • 查阅 tvm.auto_scheduler.TuningOptionstvm.auto_scheduler.LocalRPCMeasureContext 获取更多参数。

def run_tuning():
    print("开始调优...")
    measure_ctx = auto_scheduler.LocalRPCMeasureContext(repeat=1, min_repeat_ms=300, timeout=10)

    tuner = auto_scheduler.TaskScheduler(tasks, task_weights)
    tune_option = auto_scheduler.TuningOptions(
        num_measure_trials=200,  # change this to 20000 to achieve the best performance
        runner=measure_ctx.runner,
        measure_callbacks=[auto_scheduler.RecordToFile(log_file)],
    )
    tuner.tune(tune_option)

run_tuning()

解释在调优期间的打印信息

在调优过程中,控制台上将打印大量信息。它们用于调试目的。最重要的信息是任务调度器的输出。下表是示例输出。

----------------------------------------------------------------------
------------------------------  [ Task Scheduler ]
----------------------------------------------------------------------
|  ID  | Latency (ms) | Speed (GFLOPS) | Trials |
-------------------------------------------------
|    0 |        0.005 |           0.88 |     64 |
|    1 |        0.010 |          99.10 |     64 |
|    2 |        0.006 |           0.00 |     64 |
|    3 |        0.145 |         979.78 |    384 |
|    4 |        0.130 |        1097.02 |    384 |
|    5 |        0.143 |         992.69 |    384 |
|    6 |        0.076 |        1526.86 |    192 |
|    7 |        0.115 |         999.44 |    320 |
|    8 |        0.079 |        1449.39 |    320 |
|    9 |        0.122 |         938.73 |    384 |
|   10 |        0.063 |        1832.98 |    192 |
|   11 |        0.072 |        1763.62 |    256 |
|   12 |        0.062 |        2036.40 |    192 |
|   13 |        0.068 |        1874.44 |    192 |
|   14 |        0.049 |        2346.50 |    128 |
|   15 |        0.076 |        1694.31 |    256 |
|   16 |        0.067 |        1933.30 |    448 |
|   17 |        0.076 |        1680.90 |    256 |
|   18 |        0.022 |          98.43 |     64 |
|   19 |        0.076 |        3112.55 |    192 |
|   20 |        0.013 |        2026.44 |     64 |
|   21 |        0.011 |        1136.69 |     64 |
|   22 |        0.013 |         992.47 |     64 |
|   23 |        0.020 |         627.56 |     64 |
-------------------------------------------------
Estimated total latency: 1.587 ms  Trials: 4992  Used time : 13296 s  Next ID: 3

该表列出了所有任务的延迟和(估计的)速度。它还列出了所有任务的测量试验分配。最后一行打印这些任务的总加权延迟,这可以粗略估计网络的端到端执行时间。最后一行还输出测试的总数、自动调优所花费的总时间和下一个要调优的任务的 id。

也会有一些 “tvm::Error” 和 CUDA 的错误,因为自动调度程序将尝试一些无效的调度。如果可以继续进行调优,则可以安全地忽略它们,因为这些错误与主进程隔离开来。

提前终止调优

可以通过强制终止此进程提前终止调优。只要为日志文件中的每个任务获得至少一个有效的调度,就应该能够进行编译(见下面的部分)。

编译和评估#

在自动调优之后,可以用找到的最佳调度来编译网络。在自动调优期间,所有测量记录都被转储到日志文件中,因此可以读取日志文件并加载最佳调度。

# Compile with the history best
print("Compile...")
with auto_scheduler.ApplyHistoryBest(log_file):
    with tvm.transform.PassContext(opt_level=3, config={"relay.backend.use_auto_scheduler": True}):
        lib = relay.build(mod, target=target, params=params)

# Create graph executor
dev = tvm.device(str(target), 0)
module = graph_executor.GraphModule(lib["default"](dev))
data_tvm = tvm.nd.array((np.random.uniform(size=input_shape)).astype(dtype))
module.set_input("data", data_tvm)

# Evaluate
print("Evaluate inference time cost...")
print(module.benchmark(dev, repeat=3, min_repeat_ms=500))

其他技巧#

  1. 在调优过程中,自动调度器需要编译许多程序并从中提取特征。该部分是 CPU 密集型的,因此建议使用多核的高性能 CPU,以加快搜索速度。

  2. 可以使用 python3 -m tvm.auto_scheduler.measure_record --mode distill -i log.json 提取大的日志文件,只保存最好的有用的记录。

  3. 可以从上一个日志文件恢复搜索。在函数 run_tuning 中创建任务调度器时,只需要添加新的参数 load_log_file,即 tuner = auto_scheduler.TaskScheduler(tasks, task_weights, load_log_file=log_file)

  4. 如果有多个目标 CPU,您可以将它们都用于测量,从而使测量并行化。请查阅如何使用 RPC 跟踪器和 RPC 服务器。要在自动调度器中使用 RPC 跟踪器,请将 TuningOptions 中的运行器替换为 auto_scheduler.RPCRunner