GEMM与AutoKernel算子优化

随着AI技术的快速发展,深度学习在各个领域得到了广泛应用。深度学习模型能否成功在终端落地应用,满足产品需求,一个关键的指标就是神经网络模型的推理性能。一大波算法工程师为了算法的部署转岗算子优化工程师。优化代码并不是一件简单的事,要求工程师既要精通计算机体系架构,又要熟悉算法的计算流程,稍微有经验的深度学习推理优化工程师都成了各家公司争抢的“香饽饽”。需求多,算子优化自动化成为了未来的一大趋势。

为了方便更多的工程师进行推理优化,一个致力于降低优化门槛,提升优化开发效率的算子自动优化工具AutoKernel宣布正式开源!

AutoKernel特色:

低门槛: 无需底层优化汇编的知识门槛简单易用: 提供docker环境,无需安装环境,plugin一键集成到推理框架Tengine高效率: 无需手写优化汇编,一键生成优化代码,一键部署AutoKernel使用业界广泛使用的自动代码生成项目Halide,通过输入计算描述和调度策略,自动生成底层代码。AutoKernel支持以plugin的形式,将生成的自动优化算子一键部署到推理框架Tengine中。

本文将带领大家一步步优化矩阵乘法GEMM。无需手工撸代码,编写繁杂冗长的底层汇编代码,只需十几行简洁的调度代码。

优化的本质。优化的时候,计算机底层做了什么?优化的”瓶颈“是什么?为什么通过一波”优化操作“,性能就能提升呢?AutoKernel使用的Halide是如何实现自动优化的呢?

需要了解一下硬件的基础的体系结构,了解硬件如何工作,才能在软件上实现算法的时候,尽可能去考虑利用硬件的一些特性,来做到高效的、极致的优化。

上图是典型的存储理器层次结构:主存容量大,访问速度慢,寄存器和缓存读取速度快,但容量有限。在寄存器的层级上,CPU可以在一个时钟周期内访问它们,如果CPU去访问外部的DDR的话,延迟是非常大的,大概是200个时钟周期左右。如果CPU去访问cache的话,一般需要6到12个cycle就够了。所以,一个很重要的一个优化宗旨是:优化内存访问,充分利用寄存器和高速缓存去存数据。

第二个优化宗旨则是提高并行性:充分利用SIMD进行指令向量化和多核心并行。大部分现代CPU支持SIMD(Single Instruction Multiple Data,单指令流多数据流)。在同一个CPU循环中,SIMD可在多个值上同时执行相同的运算/指令。在4个数据点上进行向量化,一次计算四个数据,理论上就可以实现4倍的加速。

运行环境搭建

AutoKernel提供了docker镜像,docker里已经配置好运行环境,进入docker即可直接运行demo代码:

# 拉取镜像docker pull openailab/autokernel# 启动容器,进入开发环境docker run -it openailab/autokernel /bin/bash# 获取代码git clone https://github.com/OAID/AutoKernel.gitcd AutoKernel/doc/tutorials/data/目录下的build.sh是demo的执行脚本,运行需要指定优化步骤step,可选的step是从1 到7,其中step= 1 是默认不优化的,step=7是最极致优化的。

优化效果

# 执行demo./build.sh 1./build.sh 7

下图展示了在Intel(R) Core(TM) i9-9900K CPU @ 3.60GHz的电脑上的优化效果,无需手工撸代码,无需编写繁杂冗长的底层汇编代码,只需十几行简洁的调度代码, 就能性能优化200+倍~

优化步骤

以下是更为详细的优化步骤:

STEP1

第一个步骤是不带任何优化的。用Halide语言直接描述GEMM的计算过程。

Var x,y; RDom k(0, K); Func gemm("gemm"); gemm(x, y) += A(k, y) * B(x, k);

计算M=N=K=640的矩阵乘法。运行脚本第一个参数指定step=1。耗时结果如下:

root@bd3faab0f079:/AutoKernel/doc/tutorials/data# ./06_build.sh 1step = 1M N K = 640 640 640 err 0.00 [rep 50] autokernel | blas 240.8523 ms 1.1376 ms

STEP2

这一步采用分块tile。分块的目的是为了充分利用缓存。如果原来的循环较大,tile分块改成小块数据去计算,可以使得每次计算的数据都比较舒适地呆在缓存里,不用经历重复的驱逐(在缓存中重复的添加和删除数据)。分块后进行reorder操作,交换两个嵌套循环的顺序,目的是最内层的内存访问友好。按照x,y维度划分成16x8的小分块去计算:

.gemm.update .tile(x, y, xo, yo, xi, yi, 16, 8) .reorder(xi, yi, k, xo, yo);

执行结果如下:

root@bd3faab0f079:/AutoKernel/doc/tutorials/data# ./06_build.sh 2step = 2M N K = 640 640 640 err 0.00 [rep 50] halide | blas 81.8148 ms 1.1281 ms

性能从240ms优化到82ms,提升了近3倍。

STEP3

在上一步的基础上增加向量化vectorize。向量化是把几个标量计算(scale)转换为一个向量计算(vector),充分利用SIMD向量指令。大部分现代CPU支持SIMD(Single Instruction Multiple Data,单指令流多数据流)。在同一个CPU循环中,SIMD可在多个值上同时执行相同的运算/指令。

gemm.update .tile(x, y, xo, yo, xi, yi, 16, 8) .reorder(xi, yi, k, xo, yo) .vectorize(xi, 8);

执行结果:

root@bd3faab0f079:/AutoKernel/doc/tutorials/data# ./06_build.sh 3step = 3M N K = 640 640 640 err 0.00 [rep 50] autokernel | blas 27.5433 ms 1.1445 ms

性能从82ms优化到27ms,又加速了接近3倍。可以看到,围绕前面提到的两条优化宗旨:优化内存访问和提高并行性,从step1到step3,性能已经提升了近9倍。

STEP4

调度策略在step3的基础上增加并行化parallel。对一个循环并行化是把循环的每次迭代分给多个线程或者处理器去同时处理,每个线程处理通过代码段(loop body),但是处理不同的数据。

gemm(x, y) += A(k, y) * B(x, k); gemm.update .tile(x, y, xo, yo, xi, yi, 16, 8) .reorder(xi, yi, k, xo, yo) .vectorize(xi, 8) .parallel(yo);

执行结果:

root@bd3faab0f079:/home/chunying/AutoKernel/doc/tutorials# ./06_build.sh 4step = 4M N K = 640 640 640 err 0.00 [rep 50] autokernel | blas 7.2605 ms 1.1605 ms

增加并行化后,build.sh默认指定四线程,性能直接翻了近4倍,从27ms到7.3ms.

STEP5

调度策略在上一步的基础上增加unroll展开。如果循环体内的语句没有数据相关依赖,循环展开可以增加并发执行的机会,使得更充分利用寄存器,减少循环时每个操作内存加载和保存的次数。

gemm.update .tile(x, y, xo, yo, xi, yi, 16, 8) .reorder(xi, yi, k, xo, yo) .vectorize(xi, 8) .parallel(yo) .unroll(xi) .unroll(yi,2);

执行结果:

root@bd3faab0f079:/AutoKernel/doc/tutorials/data# ./06_build.sh 5step = 5M N K = 640 640 640 err 0.00 [rep 50] autokernel | blas 4.7617 ms 1.1597 ms

unroll展开后,性能从7.3ms优化到4.8ms.

STEP6

前面的分块成 16 x 8的小kernel, 这一步先划分成 16 x 32的分块,然后把每个分块再分成 16 x 8的子分块。把最外层的两层循环合并到一层,并对这一层进行并行化。这一步计算描述多了一个prod函数来定义子分块的计算,prod函数的计算公式和总的gemm是一样的,通过 compute_at指定在 yi维度之下计算prod,则prod计算的是 16x8的小kernel, 大致逻辑如下:

总的代码如下:

Func prod; prod(x, y) += A(k, y) * B(x, k); gemm(x, y) = prod(x, y); gemm.tile(x, y, xi, yi, 16, 32) .fuse(x, y, xy).parallel(xy) .split(yi, yi, yii, 4) .vectorize(xi, 8) .unroll(xi) .unroll(yii); prod.compute_at(gemm, yi) .vectorize(x, 8).unroll(y); prod.update .reorder(x, y, k) .vectorize(x, 8) .unroll(x) .unroll(y) .unroll(k, 2);

执行结果

root@bd3faab0f079:/AutoKernel/doc/tutorials/data# ./06_build.sh 6step = 6M N K = 640 640 640 err 0.00 [rep 50] autokernel | blas 3.1824 ms 1.1373 ms

这一步距离STEP1性能已经优化了近80倍了,性能越来越接近OpenBlas了。

STEP 7

这一步添加的操作是对矩阵B进行数据重排,使得在计算小kernel 16x8时,内存读取更顺畅。因为小kernel的x维度是按照16划分的,因此重排数据B的x维度也是按照16重排。

总的代码如下:

Func B_interleave("B"), Bs("Bs"); Bs(x, y, xo) = B(xo * 16 + x, y); B_interleave(x, y) = Bs(x % 16, y, x / 16); Func prod; prod(x, y) += A(k, y) * B_interleave(x, k); gemm(x, y) = prod(x, y); gemm.tile(x, y, xi, yi, 16, 32)

.fuse(x, y, xy).parallel(xy) .split(yi, yi, yii, 4) .vectorize(xi, 8) .unroll(xi) .unroll(yii); prod.compute_at(gemm, yi) .vectorize(x, 8).unroll(y); prod.update .reorder(x, y, k) .vectorize(x, 8) .unroll(x) .unroll(y) .unroll(k, 2); Bs.compute_root .split(y, yo, yi, 16) .reorder(x, yi, xo, yo) .unroll(x)

.vectorize(yi).parallel(yo, 4);

执行结果:

root@bd3faab0f079:/AutoKernel/doc/tutorials/data# ./06_build.sh 7step = 7M N K = 640 640 640 err 0.00 [rep 50] autokernel | blas 1.1957 ms 1.1425 ms

至此,的每一步调优策略始终都围绕两条优化宗旨“优化内存访问”,“提高并行性”展开优化,到最后性能已经与OpenBlAS差不多了,距离STEP1已经加速了200+倍了。

GEMM与AutoKernel算子优化的更多相关文章

  1. Spark为什么只有在调用action时才会触发任务执行呢(附算子优化和使用示例)?

    Spark算子主要划分为两类:transformation和action,并且只有action算子触发的时候才会真正执行任务.还记得之前的文章<Spark RDD详解>中提到,Spark ...

  2. 深度学习算子优化-FFT

    作者:严健文 | 旷视 MegEngine 架构师 背景 在数字信号和数字图像领域, 对频域的研究是一个重要分支. 我们日常"加工"的图像都是像素级,被称为是图像的空域数据.空域数 ...

  3. spark算子优化

    一.在聚合前在map端先预聚合 使用reduceByKey/aggregateByKey代替groupByKey 二.一次处理一个分区的数据,不过要注意一个分区里的数据不要太大,不然会报oom * 使 ...

  4. BigData-‘基于代价优化’究竟是怎么一回事?

    本文由  网易云发布. 本文具体讨论了Join基础算法的一种优化方案  – Runtime Filter,在本文最后还引申地聊了聊谓词 下推技术.同时,在本文文章开头,笔者引出了两个问题,SQL执行引 ...

  5. spark核心优化详解

    大家好!转眼又到了经验分享的时间了.吼吼,我这里没有摘要也没有引言,只有单纯的经验分享,请见谅哦! 言归正传,目前在大数据领域能够提供的核心计算的工具,如离线计算hadoop生态圈的mr计算模型,以及 ...

  6. flink任务性能优化

    如何提高 Flink 任务性能 一.Operator Chain 为了更高效地分布式执行,Flink 会尽可能地将 operator 的 subtask 链接(chain)在一起形成 task,每个 ...

  7. CUDA 矩阵乘法终极优化指南

    作者:马骏 | 旷视 MegEngine 架构师 前言 单精度矩阵乘法(SGEMM)几乎是每一位学习 CUDA 的同学绕不开的案例,这个经典的计算密集型案例可以很好地展示 GPU 编程中常用的优化技巧 ...

  8. Spark 3.x Spark Core详解 & 性能优化

    Spark Core 1. 概述 Spark 是一种基于内存的快速.通用.可扩展的大数据分析计算引擎 1.1 Hadoop vs Spark 上面流程对应Hadoop的处理流程,下面对应着Spark的 ...

  9. Halide应用开发

    Halide应用开发 1. 基本原理 1.1.介绍 随着人工智能的普及,深度学习网络的不断涌现,为了让各硬件(CPU, GPU, NPU,...)能够支持深度学习应用,各硬件芯片需要软件库去支持高性能 ...

随机推荐

  1. Java中常见的包

    目录 JDK自带的包 第三方包 JDK自带的包 JAVA提供了强大的应用程序接口,既JAVA类库.他包含大量已经设计好的工具类,帮助程序员进行字符串处理.绘图.数学计算和网络应用等方面的工作.下面简单 ...

  2. JEET W1S运动蓝牙耳机简评

    对于我这种喜欢运动的人来说,很早之前就一直想买个运动蓝牙耳机了.这次正好有此机会可以评测来自JEET的W1S运动蓝牙耳机,真的是非常的幸运! 终于,在期盼中,我的来自深圳的快递隔了四天终于到了!JEE ...

  3. coding push 上传文件

    git config --global user.name "lyshark" &git config --global user.email "11815068 ...

  4. 一次 Go 程序 out of memory 排查及反思

    前言 最近在搞数据导出模块,在测试大文件下载的过程中,报了 Out of memory (OOM) 错误,因为之前没有遇到过这类问题,导致此次排查问题花费了大半天,也走了不少弯路,特此复盘记录. 现象 ...

  5. C++入门教程之二:变量

    C++入门教程之二:变量 变量,顾名思义,意思是变化的量.变量的定义是计算机语言中能储存计算结果或能表示值的抽象概念.一个基本的程序需要变量,因此变量是程序设计中的一大重点. 变量基本结构 var_t ...

  6. OO随笔之魔鬼的第一单元——多项式求导

    OO是个借助Java交我们面向对象的课,可是萌新们总是喜欢带着面向过程的脑子去写求导,然后就是各种一面(main)到底.各种方法杂糅,然后就是被hack的很惨. 第一次作业:萌新入门面向对象 题目分析 ...

  7. 【转载】geany linux python编译器 开源

    http://www.dekiru.cn/?p=1491 Geany 不好用,建议用一些好用的编辑器或ide Subliem Text 或 VS code Pycharm等. 设置运行环境 菜单栏–生 ...

  8. WPS-插入-公式-菜单 怎样在EXCEL中使用PRODUCT函数

    怎样在EXCEL中使用PRODUCT函数 ################   WPS2018 -插入-公式-[专门有公式菜单] 插入函数       ##################       ...

  9. lsscsi

    # lsscsi[2:0:0:0] cd/dvd Slimtype DVD A DS8ACSH LC2M /dev/sr0[3:0:0:0] disk ATA Samsung SSD 860 3B6Q ...

  10. Java 进制及转换

    Java 整型的表现形式 Java 数据类型中有四种整型,分别是 byte.short.int.long,而整型定义下的数据还会按进制来区分: 十进制整数:都是以 0-9 这九个数字组成,不能以 0 ...