|
发表于 2023-5-9 11:34:47
|
显示全部楼层
这是从零构建 AI 推理引擎的最后一篇。想从头开始追踪我们整个过程的可以看我们之前的系列文章。
系列文章的,可以前往知乎:
金天:WNNX从0构建一套自己的推理IR金天:WNNX从0构建自己的AI推理IR(二)金天:WNNX从0构建一个AI推理引擎(三)
金天:WNNX从0构建AI推理引擎(四)
金天:2022_06_06_12_WNNX从0实现AI推理引擎(五)
金天:2022_06_07_11_WNNX从零实现一个AI推理引擎(六)金天:2022_08_01_03_WNNX从0实现AI推理引擎之终结篇前序
(理论上来说这应该是最后一篇,但为了让这个自建框架更加完备,我们特地给他实现了一套Python调用API)。
目标是从 0 开始,构建一套我们自己的 AI 推理引擎。现如今推理引擎繁多,但是你越往深入了解,你越会发现开源的推理框架或多或少存在一些问题。我们的目的就是取其精华、去其糟粕,构建一套及其简单同时又很合理的推理引擎,而自己去做一套引擎,其实比登天还难。
这里面涉及到诸多矩阵运算、算子库的把握、优化算法、图解析与调度、图融合、汇编知识、各种语言的灵活运用,以及一些必不可少的运气,当然,发量也是一个极其重要的因素,可能还没有等到你写完,你的头已经秃了。
好在,经过几个月的时间,我们完全自建的推理引擎,拥有了自己的一套完备的模型推理格式 WNNX,同时我们首次实现了再完全不依赖于任何第三方框架的前提下,实现了 NanoDet 的推理。
同时,最近我们也实现了我们完全自研的 MobileOne-YOLOv7 的推理,MobileOneS0 的计算量很小,经过 reparam 之后,差不多就是直来直去的卷积,速度可以说非常的快,即便是在 CPU 下。而它的精度,在 416x512 输入下,可以做到 mAP 35.6 。丝毫不逊色于其他目标检测模型。
这是上一个阶段我们已经完成的事情,这里只是重新回顾一下。
书接上文,上回书我们在实现了一些复杂模型的推理之后,想跟进一步的去挑战更复杂的模型。什么模型呢? 那当然是 Transformer!
但是可想而知,这个尝试进行到一半就失败了。主要原因是 transformer 变种太多,如果你适配了 Vit,Swin 这样的视觉模型,对于 Conformer,EcoFormer 这样的语音里面会用到的模型又会产生新的变种。难以做到非常科学合理。当然我们也可以为每一个变种实现一个 custom op,但是这样的话就会造成库体积过大。
值得一提的是,我们的推理框架到目前为止,库体积依旧很小,并且实现了一个非常有趣的操作:
我们的核心库到目前为止依旧只有 1.8M,用这个 1.8M 你就可以驱动 YOLO!
你可以看到我们应该是业内为数不多的可以把自定义算子单独编译的框架,换句话说,不管你的自定义算子多么多,也不太会影响整体的库体积,也不需要用 reduce op 的方式去挑选你需要的 op。
我们说的这个有趣的操作,就是这个 custom op 的设计。
从这里开始,就点题了。这是一篇写给产品经理看的技术文章,自然有一些对用户体验注重的细节。
按照常理来讲,对于普通程序员来说,如果我们按照上面的方法来实现了 custom op 单独编译,你可能会把这些 custom op 定义的头文件也单独零出来,跟我们的标准算子放一起,给到用户的东西就是一堆乱七八糟的头文件。
而在 wnn 里面,这些都没有。
插件召唤式注入
在我们的自建引擎里面,虽然我们有很多自定义算子,但是这些算子在用的时候,是否需要 include 对应的头文件呢?如果不 include。你怎么让 c++知道你的这些库对应的定义是什么呢?怎么把这些算子注册到的你 Layer Factory 里面去呢?
所有的这些问题,我们通过一个巧妙的方式解决了。
对于用户来说,他需要做的,只有一件事情:
namespace wnn {
extern void summon_plugins();
}
int main(int argc, char **argv) {
std::string model_f = argv[1];
int num_threads = 2;
int max_batchsize = 8;
auto wnn_model = wnn::NNForward(num_threads, max_batchsize, wnn::Backend_CPU,
wnn::summon_plugins);
auto a = wnn_mdoel.infer();
}
没有错,你只需要显式的调用 simmon_plugins() ,你的插件链接库就会被自动调用,你的系统算子库就可以自动的找到你定义的那些自定义算子的定义。
需要 include 对应的头文件么?并不需要,wnn 一如既往的,只需要一个文件 include。
你可能没有什么感觉,让我们来看看 MNN 是怎么使用自定义算子的吧:
#include "ncnn_upsample.h"
#include "ncnn_upsample3x.h"
#include "ncnn_upsample4x.h"
int main() {
ncnn::Net mynet;
mynet.register_custom_layer("Upsample", Upsample_layer_creator);
mynet.register_custom_layer("Upsample3x", Upsample_layer_creator);
mynet.register_custom_layer("Upsample4x", Upsample_layer_creator);
mynet.load_param("...\\view_classification.param");
mynet.load_model("...\\view_classification.bin");
ncnn::Extractor ex = mynet.create_extractor();
ex.input("input", in);
ncnn::Mat out;
ex.extract("output", out);
}
wnn 只需要调用 summon_plugins,召唤插件,你就可以把插件召唤回来,不需要的时候,拿掉即可。而ncnn需要每个插件都include对应的头文件,并且要显示的定义。 显然这种操作非常的繁琐。
WNN 有史以来第一个Python API
除了更加简洁优雅的插件引入方式以外,我们自己建造的推理框架,似乎还缺少了一点东西。
也许制约新人上手的不仅仅是c++的易用性,更有Python的易用性。
而我们 连Python都没有,何谈易用性??
于是我们说要有Python API!然后他就来了,一个自建框架,并且带有Python API,你可以用自己心爱的语音来把玩你的推理框架了!
先来看看这个Python API到底简单到多么的令人发指!(AI PM必看)
这里演示,我们用自建引擎,来推理Mobilenetv2,一个说难不难,说复杂不复杂的模型(从引擎的角度讲,mbv2有残差、有各种depthwise op,实现难度并不小,要求算子支持广度较广)。
m_f = sys.argv[1]
data_f = sys.argv[2]
model = NNForward()
model.load_from_file(m_f)
a, raw_img = load_input_from_file(data_f)
a = np.ascontiguousarray(a)
res = model.infer([a])[0]
lbl = np.argmax(res)
print(lbl)
没错,就这么简单!说实话,核心代码就3行!3行代码,你可以把玩自己构建的推理引擎了!
这个Python API的设计,我们延续了 C++的接口风格,所谓大道至简。根据我多年的人工智能行业摸爬滚打经验,相信我,这比你看到的任何一个推理框架都要简单!!
但是你不要小看它,有一点你需要注意,那就是你会发现,这里面没有任何申明的操作!一切都显得那么自然:你丢给一个numpy,它丢回给你一个numpy,就是结果!
你是否注意到一个问题,这里面你压根不需要关注输出的维度!基本上没有任何额外的操作。
为了对比,来看看MNNN的Python推理:
self.interpreter = MNN.Interpreter(self.mnn_f)
self.session = self.interpreter.createSession()
self.input_tensor = self.interpreter.getSessionInput(self.session)
self.output_tensor = self.interpreter.getSessionOutput(self.session)
def infer_si(self, inputs):
if isinstance(inputs, list):
inputs = np.array(inputs)
tmp_input = self.np_as_mnn(inputs)
self.input_tensor.copyFrom(tmp_input)
self.interpreter.runSession(self.session)
# output_tensor = self.interpreter.getSessionOutput(self.session, "output")
output_tensor_dict = self.interpreter.getSessionOutputAll(self.session)
res = {}
for k, v in output_tensor_dict.items():
tmp_output = MNN.Tensor(
v.getShape(),
v.getDataType(),
np.ones(v.getShape()).astype(np.float32),
MNN.Tensor_DimensionType_Caffe,
)
v.copyToHostTensor(tmp_output)
res[k] = np.array(tmp_output.getData()).reshape(v.getShape())
return res
def infer(self, inputs):
if isinstance(inputs, list):
inputs = np.array(inputs)
tmp_input = self.np_as_mnn(inputs)
self.input_tensor.copyFrom(tmp_input)
self.interpreter.runSession(self.session)
# output_tensor = self.interpreter.getSessionOutput(self.session, "output")
output_tensor_dict = self.interpreter.getSessionOutputAll(self.session)
res = {}
for k, v in output_tensor_dict.items():
tmp_output = MNN.Tensor(
v.getShape(),
v.getDataType(),
np.ones(v.getShape()).astype(np.float32),
MNN.Tensor_DimensionType_Caffe,
)
v.copyToHostTensor(tmp_output)
res[k] = np.array(tmp_output.getData()).reshape(v.getShape())
return res
说实话,这就是MNN的推理。。。当然你如果非要说它没有必要这么麻烦,但,你可以尝试你精简它,之后你才会发现这些东西都不可或缺。
可以看到,这简单的不止一点半点。。
其他框架我就不贴了,不管是onnxruntime,还是ncnn,都很难做到这样:
model = NNForward()
model.load_from_file(m_f)
a, raw_img = load_input_from_file(data_f)
a = np.ascontiguousarray(a)
res = model.infer([a])[0]
着么简单。
但其实这背后做了很多工作,你不需要关心数据拷贝、上传到device,以及输出的shape,这些运算都在框架内部通过比较合理的方式解决了。
为什么我们能够让用户做到直接丢进来一个numpy就能推理?我们的网络Tensor可不是numpy。因为我们的pybind11 API里面,对输入进行了更加方便的封装,目的就是使得它能够直接接受numpy array的输入。
而不是跟MNN一样。丢给用户手动处理。
关于Python API的思考
其实我们做这套自建推理引擎目的,并不是为了造轮子而造轮子,而是为了解决一些实际的需求。具体来说,我们想要的是:
- 一个足够有把控能力,可以随意修改,随意插入SOTA推理框架优化结果的框架(拿来主义);
- 能够解决现有框架弊端,对算子具有足够可控性,可以随意进行图融合和图优化;
- 足够小,符合自身需求,过犹不及,恰到好处。
- 它的动态库体积要足够小,大于4M都可以称为臃肿;
- 它的API要足够精简,精简到小学生二年级上手使用没有难度。
这样下来,我们发现只有从头构建才是出路。而当我们构建完成之后,发现可以做的事情远不止这些。我们重构的Python API就是一个很好的例子。
很多人是拿来主义,这并没有什么,毕竟很多开源的东西就是给你用的,但拿来主义却不去思考底层的原理、甚至觉得拿来主义是一种更聪明的方式,那就有点自作聪明了。
在中美对抗的大环境下,更多人拥有这样的能力,才能对抗可能的封锁,不过好在最起码在AI前推框架领域,国内还是走的比较前沿的。但国内的一些作品,跟人家ONNXRuntime这样的东西比,还是有差距的。
为什么我要给自建框架加上Python API,一来是更方便以后的更多测试,二来主要是因为这个:
代码不格式化、python里面非得跟c++一样驼峰命名、函数与函数之间没有任何空行。
这一看就是一个C++程序员用管用的vim写出来的python代码,完事之后也不会格式化一下,且代码动一下可能就跑不起来的shishan。
反过来看看WNN Python的接口:
不仅简单的令人发指,而且一看就是一位Python专业人士写出来的代码。
最后来对标一下C++下的推理结果和Python的:
完美契合,收工!
下一篇
我们的自建推理引擎到这里就要告一段落了。下一步我们要做什么呢?
没有错,继续死磕并优化Transformer!
目的是让Transformer在CPU上跑得飞起!不仅仅是CV,还包括语音识别和语音合成的transformer! |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
×
|