桥接PyTorch和TVM

桥接PyTorch和TVM

人工智能最引人入胜的一些应用是自然语言处理。像BERT或GPT-2之类的模型及其变体,可以获住足够多的文本信息。

这些模型属于称为Transformers的神经网络类体系结构。 HuggingFace transformers library是实现最受欢迎的库之一。

与已经高度优化的实现的卷积模型或LSTM相比,对于Transformers而言,情况并非如此。本文探索TVM如何填补空白。分两个步骤进行操作:

  • 首先,在TVM上,使用BERT inference推理和调优。
  • 其次,进行一些更基本的探索,以了解如何在PyTorch中使用TVM进行训练。考虑到实验性质,将重点更多地放在可行性上,而不是性能上。
  • 使用TVM优化BERT推理

如何将BERT从Transformers库传输到TVM?

有用的是,transformers支持使用PyTorch JIT跟踪其模型,直到建立跟踪模型。

使用示例输入在AMD Radeon VII上进行100次运行后,PyTorch跟踪模型大约需要0.65-0.7秒,意味着每次运行6.5-7ms。看看是否可以使用TVM更快。将模型转换为TVM轻而易举:

shape_list = [(i.debugName().split('.')[0], i.type().sizes()) for i in  list(traced_model.graph.inputs())[1:]]

 

mod_bert, params_bert = tvm.relay.frontend.pytorch.from_pytorch(traced_model,

                        shape_list, default_dtype="float32")

找不到dtype信息将有一些警告,但是一切顺利!构建并运行,构建遵循标准的TVM配置。将PyTorch(cpu)张量转换为TVM数组。

target = 'rocm -model=gfx906'  # use what matches your GPU

 

target_host = 'llvm'

ctx = tvm.context(target)

 

tt_a = tvm.nd.array(tokens_tensor.numpy(), ctx)

st_a = tvm.nd.array(segments_tensors.numpy(), ctx)

tvm.relay.backend.compile_engine.get().clear() # just to be sure, see https://github.com/apache/incubator-tvm/pull/5724

 

with tvm.transform.PassContext(opt_level=3):

        graph, lib, params = tvm.relay.build(mod_bert,

                                     target=target,

                                     target_host=target_host,

                                     params=params_bert)

module = tvm.contrib.graph_runtime.create(graph, lib, ctx)

这会警告几次:

    WARNING:autotvm:Cannot find config for ... batch_matmul.cuda .... A fallback configuration is used, which may bring great performance regression.

可能会带来很大的性能下降

但是首先运行模型,看看输出是否匹配:

    (8.583069e-06, 8.493662e-07)

看起来不错。记住,在float32中进行计算,因此$ 10 ^ {-6-6 $$ ish是一个很好的结果。

建立模型并设置参数后,按以下方式对模型进行计时:

def x():

    for i in range(100):

        module.run()

    ctx.sync()

x()

%timeit x()

该模型每100次运行需要6.65s,或者模型运行67ms。确实很慢,警告说找不到(调整的)配置。调整调度。

调整确实需要半天左右的时间(在TVM调整,进行有关使用autotvm进行ResNet调整的介绍。)

此后,可以再次使用新配置来构建模型。这次应该看不到有关缺少配置的任何评论。现在,每次运行的时间约为6.5-7毫秒,类似于PyTorch。从最基本的算子优化中得到的结果。不过,可以将其进一步推进。

要了解如何操作,深入研究BERT建模和TVM。

如果不想获取完整的详细信息,请跳过下一部分并向下滚动到Results。补充一点,希望调优部分会在某种意义上过时,因为在不久的将来,可以立即获得改进,或者至少在进行一些初始调优后,速度会更好。因此,如果看不到这里和Results之间的加速,那是因为在提交补丁时做了功课。

BERT模型

让仔细看看BERT中的情况。

 

 像许多深度学习模型一样,BERT带有一些前言(词汇嵌入)和结语(池化),并且大部分被组织成相似的块,这里有12个BertLayer模块。该attention_mask防止BERT在与问题打交道时看答案。

因此,放大并详细查看BertLayer,这最终是需要快速完成的工作。正如在网络图中看到的那样,BertLayer模块的主要部分是一个子模块BertSelfAttention。

 

 现在,BertSelfAttention捕捉到了著名的自我关注机制,这是Transformers模型的标志。(不能推荐Sascha Rush的带注释的Transformers来作为详细的演练。)

BertLayer细节

如果要详细介绍,应该单独运行一个BertLayer。获取BertLayer的输入,然后BertLayer像对整个模型一样,将单个转换为TVM。

为了查看TVM模块,定义了一个小的可视化帮助器(基于TVM PR#4370)。

import graphviz

def visualize(expr, collapse_small=True, node_attr_dict = {}):

    def collect_ops(node):

        ops = set()

        def visitor(e):

            if isinstance(e, tvm.ir.Op):

                ops.add(e.name)

        tvm.relay.analysis.post_order_visit(node, visitor)

        return ops

 

    # node_dict maps a Relay node to an index (node ID)

    def _traverse_expr(node, node_dict):

        if node in node_dict:

            return

        node_dict[node] = len(node_dict)

 

    node_dict = {}

    tvm.relay.analysis.post_order_visit(expr, lambda x: _traverse_expr(x, node_dict))

 

    relayviz_nodes = []

 

    dot = graphviz.Digraph(format='svg', )

    dot.attr('node', shape = 'box')

 

    def to_str(node):

        if isinstance(node, tvm.relay.Constant):

            return repr(node).lstrip('Constant(')[:-1]

        else:

            raise NotImplementedError("to_str:" + repr(node))

 

    def is_small_const(c):

        if not (collapse_small and isinstance(c, tvm.relay.Constant)):

            return False

        if isinstance(c.data, tvm.runtime.ndarray.NDArray):

            return numpy.prod(c.data.shape) < 10

        return True

 

    # Sort by node ID

    for node, node_id in sorted(node_dict.items(), key=lambda x: x[1]):

        if isinstance(node, tvm.relay.Function):

            dot.node(str(node_id), 'Function', **node_attr_dict.get(node, {}))

            dot.edge(str(node_dict[node.body]), str(node_id))

        elif isinstance(node, tvm.relay.Var):

            if node.type_annotation is not None:

                if hasattr(node.type_annotation, 'shape'):

                    shape = tuple([int(x) for x in node.type_annotation.shape])

                    dtype = node.type_annotation.dtype

                    typstr = 'Tensor[{}, {}]'.format(shape, dtype)

                else:

                    typstr = str(node.type_annotation)

            else:

                typstr = '?'

            d = dict(shape = 'ellipse')

            d.update(node_attr_dict.get(node, {}))

            dot.node(str(node_id),

                     '{}: {}'.format(

                         node.name_hint, typstr

                     ), **d)

        elif isinstance(node, tvm.relay.Tuple):

            dot.node(str(node_id), 'Tuple[...])', **node_attr_dict.get(node, {}))

            for field in node.fields:

                dot.edge(str(node_dict[field]), str(node_id))

        elif isinstance(node, tvm.relay.Constant):

 

            if not is_small_const(node): # small consts are shown in ops

                dot.node(str(node_id), 'Constant({}, {})'.format(node.data.shape, node.data.dtype),

                        **node_attr_dict.get(node, {}))

        elif isinstance(node, tvm.relay.Call):

            args_with_edge = []

            arg_str_list = []

            for arg in node.args:

                if is_small_const(arg):

                    arg_str_list.append(to_str(arg))

                else:

                    arg_str_list.append('·')

                    args_with_edge.append(arg)

            arg_str = ', '.join(arg_str_list)

            if isinstance(node.op, tvm.ir.Op):

                name = node.op.name

                attrs = {k:getattr(node.attrs, k) for k in node.attrs.keys()} if hasattr(node.attrs, 'keys') else {}

                #attrs = inspect.getmembers(node.attrs)

                attr_str_list = [k+'='+(str(v) if len(str(v))<20 else "...") for k, v in attrs.items()]

                if attr_str_list:

                    attr_str = '| '+ ', '.join(attr_str_list)

                else:

                    attr_str = ''

            else:

                ops = collect_ops(node)

                if ops:

                    name = '_'.join(ops)

                else:

                    name = '...'

                attr_str = ''

            s = f'{name}({arg_str}{attr_str})'

            dot.node(str(node_id), s, **node_attr_dict.get(node, {}))

            for arg in args_with_edge:

                dot.edge(str(node_dict[arg]), str(node_id))

        elif isinstance(node, tvm.ir.Op):

            # dot.node(str(node_id), 'Op {}'.format(node.name))

            pass # covered in call

        elif isinstance(node, tvm.relay.TupleGetItem):

            dot.node(str(node_id), 'TupleGetItem(idx={})'.format(node.index), **node_attr_dict.get(node, {}))

            dot.edge(str(node_dict[node.tuple_value]), str(node_id))

        elif isinstance(node, tvm.relay.Let):

            dot.node(str(node_id), 'Let(XX)', **node_attr_dict.get(node, {}))

            dot.edge(str(node_dict[node.value]), str(node_id))

            dot.edge(str(node_id), str(node_dict[node.var]))

        else:

            raise RuntimeError(

                'Unknown node type. node_id: {}, node: {}'.format(node_id, type(node)))

 

    return dot

 

在主要功能上运行。由于某种原因(可能是完全笼统),PyTorchtransformers会将Linear图层转换为batch_matmul,而不是dense。由于TVMbatch_matmul在两个算子上都有scale(与PyTorch不同),也有很多转置算子。

visualize(mod['main'])

 

 

 除了命名输入外,还看到许多未命名(编号)的变量。这些是神经网络参数。

编译模型

就像完整模型一样,可以在检查子模块计算出相同数量后运行并计时。

100次运行需要20.2毫秒。包络计算的背后是,BertLayer在PyTorch中,在这一层上花费了约0.2ms,在12层上花费了约2.4ms-不是大多数,而是整个6-7ms runtime中的一部分。与TVM进行比较。(一个好的规则是永远不要进行优化,也不进行测量。)

TVM的时钟runtime为18.2ms,可运行100次。再次与PyTorch大致相同。

从图片中看到的一件事是输入被重塑了3次。有一个TVM优化阶段调用,称为“公共子表达式消除”(CSE),结合了三种重塑形式。(前一阵子没有成功,具有不同的形状参数, TVM开发人员在动态到静态转换过程中解决了这个问题。),模型参数被重塑和转置了。也可以摆脱吗?是的。为此,将首先绑定参数,即将其放入模型中。然后,参数已变为常量,而不是输入节点。通过该Foldconstant过程,可以通过transposes和reshapes传播常数,以使更靠近matmuls。

在这三个之后(当编译中继模型时,TVM将执行此算子),模型如下所示:

将具有相同输入的三个批处理matmul合并为一个更有效batch_matmul。在TVM PR 5791中实施了此操作。还有另一个恒定折叠的传递。

new_mod = tvm.relay.transform.CombineParallelBatchMatmul()(new_mod)

new_mod = tvm.relay.transform.FoldConstant()(new_mod)

visualize(new_mod["main"])

经过检查,仍然得到相同的结果。可以再次计时:100次运行需要25.2毫秒。再次有点慢,需要调整新的形状。调整后,在100次运行中处于12.6ms,从大约0.2ms变为大约0.13-0.15ms,这是一个不错的加速。通过手工计算,这应该从总运行时间中减少0.6-0.8ms,或者说介于5%-10%之间,检查。

优化后对整个BERT模型的结果

定义一个函数,结合上面的优化过程,然后在整个BERT模型上运行。进行与上述相同的训练。

可以达到624毫秒进行100次运行。在PyTorch中从6.5-7ms缩短到了TVM中的6.2ms。这是5%-10%的加速。仅采用了特定的形状,而不是很大的形状。更多的分析,将考虑更多的问题形式。

可能会更进一步-例如,在批处理matmul之后,通过处理重塑来融合添加的内容,现在暂时将其保留。此外,还将受益于TVM的进一步改进,因此,随着时间的推移,基准会如何提高也会很有趣。特别地,即将到来的Ansor调整机制,似乎很有希望。

 

  比较模型的实现

一直将PyTorch与TVM输出进行比较,查看是否良好。另外,当研究某个内层时,获取了该内层的输入,转换并输入到TVM模型中。这是一种非常有效的技术。

有时很难评估结果之间的偏差是由于数值精度,还是由于某处的误差。最初转换模型时,SelfAttentionTVM模型,将子模块输出复制到大约1e-6。但是,BertLayer转换的内容类似于1-e3。不确定是由于累积的数字误差,还是某些地方的材料偏差引起的。(原来是GELU激活,已转换为FastGELU。)

在这种情况下,想做的一件事,跳转到双精度并检查。数值误差应该变得更小,而其它偏差将保持相同的数量级。使用PyTorch前端,如果传递default_dtype="float64"给转换函数,可以在PyTorch端跟踪转换为float64的模型。

运行模块并与PyTorch进行比较,应该有1e-14左右的偏差。

 

 TVM的改进

必须弥合一些差距(git checkout包括所有差距):

  • TVM PyTorchtransformers不支持fp32以外的输入。实施了改进的转换,也包括在TVM升级中。
  • TVM调度(即main操作的计算组织,batch_matmul)是固定的,非常慢(类似于现在没有经过调整的调度运行)。因此,实施了可调度的调度表
  • PyTorchtransformers生成批量matmul操作(也可以将其更改为生成密集层)。较大的速度优势之一,将查询key和value线性层组合,实现了对批处理matmul操作的融合
  • 当比较计算结果时,注意到GELU函数已转换为其FastGELU变体。(TVM中有一个快速的数学优化过程,可以对误差函数进行一些替换,尽管没有检查,是否对用误差函数表示的GELU产生FastGELU。)
  • TVM最初(并且在某种程度上仍然)专注于静态形式。尝试动态算子,动态重塑(以目标形式作为参数)是这些实验的早期阶段,由于通用子表达式消除过程未检测到可以合并相同的输入重塑,阻止了批处理的融合,情况有所改善。
  • 使用TVM计算训练Pytorch模型

看看在PyTorch中训练BERT时,是否可以使用TVM。当需要处理自动分化时,这将打开全新的worms。从上面保留 theme,并以BertLayer示例为例,但方法通常代表着 non-trivial 模块。希望将训练期间的计算转移到TVM。

 

 用户可以采用一个(可跟踪的)模块并执行

add_tvm_dispatch(module, sample_input)

如果用与sample_input形状相同的输入来调用模块,将获得TVM计算的输出(当然是PyTorch张量),否则,将使用常规正向。

如何实现这些任务。仍将无法获得很大的提速。

通过运行BertLayer从TransformersBert模型到的跟踪,得到了relay模型tvm.relay.frontend.from_pytorch。

在这之间,要做的一件事是将PyTorch中的模块化接口(带有命名参数)转移到功能接口(TVM可以为做的事情)。按照可以使用的顺序排列函数参数-即首先以直接的方式输入模块,然后与PyTorch使用相同的顺序来传递参数。完成此操作后,BertLayer 在TVM中的性能如下:

 

 就像在BERT推理中一样,运行一些优化过程。

进行一些新的转换:

  • Autodifferentiation的一个特殊之处,将使用大量..._like算子来广播或“取消广播”(总和是广播带有autodifferentiation的对偶)。现在有两个张量参数,后者实际上并不需要梯度。ZappLike将这些算子替换为带有shape参数的相应函数。
  • 另一件事是派生类。TVM生成一个张量,所有张量都具有与函数的返回值相同的形状,以此作为链法则的起点。将这些乘以算子的派生产品。相乘并没有多大作用,类似地,TVM将变量(输入)的梯度初始化为相同形式的零。如果未使用,则渐变将为零,但如果使用,则“真实渐变”将添加到零。但是添加零也可以消除。这些由ZeroZapp和OneZapp负责。
  • TVM没有针对LayerNorm(BatchNorm或其它)的训练变体。实现了通过以阐明计算的过程。
  • TVM也没有训练停止。由于TVM当前没有随机数,在某种程度上很难解决。取而代之的是,用一个采用随机bernoulli抽取(值为0/1的值)的构造替换掉落对象,并以此模拟掉落对象。将使用PyTorch为生成版本。这样做还有一个好处,即(如果以与PyTorch相同的顺序生成滤除版本),将获得完全相同的结果。

正如上面所暗示的,TVM的梯度获取,假定是计算中的最后一个元素(上面讨论的“张量”)。这与PyTorch的模块化视图不太吻合,因为PyTorch希望grad_out每个输出都给出一个。令人高兴的是,这在计算上等效于乘以渐进求和,以此来修改函数。希望具有灵活性,允许两个函数都返回一个张量,而两个函数都返回一个张量元组。

应用这些修改后,模型如下所示:

 

 

随着let节点数量的增加,使用ToGraphNormalForm传递,将其恢复为正常形式。TVM的渐变获取返回一个函数,该函数具有与原始函数相同的参数(在本例中,使用grad_out和修改),然后返回原始返回值的元组和包含所有输入的梯度的元组。要做的第一件事就是放下所有的渐变色grad_out和dropout不需要。然后,运行简化流程。

因此,这是向前和向后的图表:

在PyTorch中,首先计算前向,然后计算后向,必须取出并拆分图形。困难的问题之一,如何处理为正向和反向计算的事物。这是一个与MinCut问题有关的难题。

极端选择可能是:

  • 只能保留输入并根据需要重新计算所有内容。
  • 如果有一个Salal输出,可以计算梯度并与后面的后一层的导数相乘。(损失函数可能会这样做。)但是,这不适用于非标量张量输出。

执行以下算子:通常计算正向,但保留所有将在向后使用。这很可能是没有看到端到端加速的原因。下面讨论一些潜在的启发式方法。

在这里使用一种颜色。首先,将正向计算的所有节点都涂成红色。然后,遍历梯度计算,然后从向后的蓝色为所需的节点着色。在可视化中展示属性支持。

一点(PyTorch)术语:当有一个功能Layer:x y,然后是一些Loss:y l,向后是BackwardOfLayer:grad _outgrad _ingrad _out = dl / dy和* grad _in = dl / dx`。

拆分功能,收集蓝色节点,进行捕获-但是常量将被复制,并且输入(Var节点)需要分别处理。现在可以拆分向后,用变量替换所有蓝色节点。

接下来,进行转发并进行修改,以返回所需的中间体。前向看起来像这样:

 

 

TVM无法返回嵌套元组,将函数中的输出展平。再次,区分张量值函数和元组值函数(那些可能返回多个张量的函数)。

最后,可以让TVM发挥其魔力并编译功能,对gr_only_compiled_module 和fw_and_cap_compiled_module,定义了便利函数,可以在PyTorch和TVM之间移动张量,并以TVM字典的形式获取模型参数。

def tensor_to_tvm(t):

    return tvm.nd.from_dlpack(torch.utils.dlpack.to_dlpack(t))

def tensor_from_tvm(a):

    return(torch.utils.dlpack.from_dlpack(a.to_dlpack()))

 

model_params_tvm = {k: tensor_to_tvm(v) for k, v in pytorch_model.state_dict().items()}

同样,在PyTorch和TVM中的GPU上获得输入。

记录的三个随机抽取的发生顺序与模型中顺序相同。在计算图上进行了深度优先搜索,如果dropout的值连接在图中而不是位于独立的分支上,也是PyTorch绘制矩阵的顺序。

torch.manual_seed(12345)

drop_c = {}

for k in dropout_info.keys(): # we don't know the order

    p, typ = dropout_info[k]

    drop_c[k] = torch.nn.functional.dropout(torch.ones([int(i) for i in typ.shape],

                                              dtype=getattr(torch, typ.dtype), device="cuda"), p=p)*(1-p)

 

drop_tvm = {n: tensor_to_tvm(t) for n, t in drop_c.items()}

现在可以前进了。

fw_and_cap_compiled_module.set_input('input', inp_tvm[0])

fw_and_cap_compiled_module.set_input('attention_mask', inp_tvm[1])

fw_and_cap_compiled_module.set_input(**model_params_tvm)

fw_and_cap_compiled_module.set_input(**drop_tvm)

fw_and_cap_compiled_module.run()

可以将输出与PyTorch的输出进行比较:

torch.manual_seed(12345)

pytorch_model.train()

res = pytorch_model(*inp_c)[0]

numpy.abs(fw_and_cap_compiled_module.get_output(0).asnumpy()-res.detach().cpu().numpy()).max()

这给了2.1457672e-06。

非常好。让也尝试向后。生成一个grad_out,设置所有变量,并运行向后模型

gr_out_c = torch.randn(res.shape, device="cuda", dtype=res.dtype)

num_captures = len(capture_vars)

num_regular_outputs = len(fw_and_cap_fn_flattened.body.fields) - num_captures

captured_values = {v.name_hint: fw_and_cap_compiled_module.get_output(num_regular_outputs + i) for i, v in enumerate(capture_vars)}

 

gr_only_compiled_module.set_input(**drop_tvm)

gr_only_compiled_module.set_input(**model_params_tvm)

gr_only_compiled_module.set_input(**captured_values)

gr_only_compiled_module.set_input('gr:out:0', tensor_to_tvm(gr_out_c))

gr_only_compiled_module.run()

在PyTorch方面,最简单的方法是重新运行前向(记住要重置随机种子),并获取证书。

torch.manual_seed(12345)

pytorch_model.train()

inp_c_rq = [i.requires_grad_() for i in inp_c]

for p in pytorch_model.parameters():

    p.requires_grad_()

res = pytorch_model(*inp_c_rq)[0]

grads_pt = torch.autograd.grad(res, inp_c_rq + list(pytorch_model.parameters()), gr_out_c, allow_unused=True)

 

奏效了吗?看来是这样的:

 

 for i, g_pt in enumerate(grads_pt):

    print(numpy.abs(gr_only_compiled_module.get_output(i).asnumpy() - g_pt.cpu().numpy()).max())

给列出了1e-5ish范围内的数字。

但是想在PyTorch中运行一些东西,对吗?

遵循PyTorch的工作方式,首先定义一个autograd.Function刚刚手动完成的操作:

在forward:

  • 产生随机值,
  • 向前运行,
  • 记录向后所需的catch,输入和丢失值。

在中backward,向后运行并返回结果(作为PyTorch张量)。

这样,得到了一个PyTorch autograd.Function,调用TVM(需要一个小的package)。

需要做的add_tvm_dispatch(module, sample_inputs)就是跟踪模块,从中创建基于TVM的autograd函数,然后替换调用该函数的正向调用(使用参数)(如果适用)或退回常规方法。向前,Python的无限动力使这种相对容易。由于所有这些都不是真正与TVM相关的,提供了一些保留。

性能

就性能而言,并不是一个最终想要达到的目标。调整任务后(以及来自HuggingFace BERT + PyTorch JIT不太实际的推理示例),向前和向后运行了100次启用TVM的BertLayer迭代,类似于为推理所做的方式。一次迭代通过TVM花费6.2毫秒,而在PyTorch上花费1.3毫秒。

可以通过TVM运行模型。但这还不如通常的方法快。

更严重的是,有两条直接的途径来提高性能:

  • 查找一组更好的捕获节点。
  • 在TVM图上找到优化。

就前者的启发式(记住它很可能是NP困难的,没有得出正式的认证),人们会想重新进行廉价的计算,最主要的是逐点计算(或者除了matmul之外,什么都可以?)。

 

posted @ 2021-05-23 09:51  吴建明wujianming  阅读(688)  评论(0编辑  收藏  举报