NCNN学习系列一:NCNN 内存层次设计与实现
开源摘星计划(WeOpen Star) 是由腾源会 2022 年推出的全新项目,旨在为开源人提供成长激励,为开源项目提供成长支持,助力开发者更好地了解开源,更快地跨越鸿沟,参与到开源的具体贡献与实践中。
不管你是开源萌新,还是希望更深度参与开源贡献的老兵,跟随“开源摘星计划”开启你的开源之旅,从一篇学习笔记、到一段代码的提交,不断挖掘自己的潜能,最终成长为开源社区的“闪亮之星”。
我们将同你一起,探索更多的可能性!
项目地址: WeOpen-Star
关键词:
C++,NCNN,Tensor内存布局,Pack,FP32toFP16,量化
基础:
俗话说:“工欲善其事必先利其器”,在开始进入到NCNN的世界中,我们得先了解一些数学知识:机器学习的基础是线性代数,而线性代数的基础数学概念主要有:
-
标量(scalar):标量在线性代数中一般以小写字母表示,声明标量时,也必须声明标量类型;
-
向量(vector):向量即是一种带有索引的集合类型,在线性代数中支持不同类型包含在同一集合中,但在计算机中我们一般默认该集合是同一类型(不同类型的将会被转换);
-
矩阵(matrix):矩阵是一个带有二维索引的集合类型,默认情况下一个Aij 的矩阵,在内存中表示为一个长度为j,宽度为i 的二维数组;
-
张量(Tensor):某些情况下,我们会讨论超过二维以上的数组,在这种情况中,我们使用A(i,j,k)来表示。(可以想象,k张长i 宽j 的纸张,被压平成一张纸上,每个点都包含着分别来自与k0,k1,k2的三个值);
int a = 128; vector<int> = {1,2,3}; float matrix[3][3] = {1.0f, 2.0f,3.0f; 1.0f, 2.0f,3.0f; 1.0f, 2.0f,3.0f}; float tensor[3][3][3] = {0.0f};
好,其中有几个点需要指出:
- 本部分暂不考虑模板,所以所有标量都带有明确类型做初始化;
- 根据计算机中内存分配原则,向量集合中所有元素默认按照统一类型初始化;
- 计算机图形学中 矩阵的长度= 图像的高度(height) 即 你看到的屏幕左上方到左下方,矩阵的宽度=图像的宽度(width)即你看到的屏幕左上方到右上方
- 对于张量,可以想象一张长i 宽j 的书页,k张书页被压平到一起;
NCNN中的实现:
ncnn的主要分为c的实现和python 的调用,二者通过pybind11 连接到一起,本系列文章中将主要集中在c_api上。假设你已经git clone ncnn(什么?你还没有clone,请看这里[文章零]),本系列的文章将集中在src 内容中。本篇文章,请先看ncnn/src/mat.h;
在深度学习框架中,作为基础数据结构的tensor(ncnn 中称为Mat)。起到的功能主要有:
- Mat 内存初始化 和内存基础操作;
- 转换Mat的数据类型;
- 针对输入所做的填充和裁剪;
- 量化;
下面我们先从基础的内存初始化和内存基本操作开始:
内存初始化和内存操作:
Mat 作为tensor的具体实现,必须要满足输入和权重的数据NCHW,所以,其最主要且重要的初始化为:
class Mat
{
public:
Mat(w);
Mat(h,w);
Mat(c,h,w);
...
void create(w);
void create(h,w);
void create(c,h,w);
...
void* data // pointer to the data
...
int w;
int h;
int d;
int c;
};
但是你会发现,Mat 和 create的初始化中,包含参数elemsize 和 elempack:
Mat(int w, int h, size_t elemsize, int elempack, Allocator* allocator = 0);
这是因为,内存结构是服务于算法实现的,NCNN中的一些算法的基础单元算子,针对不同的硬件结构做优化。elempack指将n个数据打包在一起,elemsize指这n个数据打包在一起后所占的内存大小;
举个例子:
float A[8] = {0.0f};
// use pack = 4;
size_t elemsize = 4 * sizeof(float); // 16u 4*32bit
// use pack = 8;
size_t elemsize = 8 * sizeof(float); // 32u 8*32 bit
限制pack 和size 的主要原因是硬件设计,比如Intel 的SSE指令集和 AVX指令集,比如Arm 的aarch32 与 aarch64;
指令集类型 | 主要数据类型宽度 | 对于单精度浮点数 32bit 的pack数量 |
---|---|---|
SSE | 128bit | 4 |
AVX | 256bit | 8 |
aarch32 | 128bit | 4 |
aarch64 | 256bit | 8 |
当然,也存在其他数据类型,比如int8 fp16,也存在其他的指令集宽度,比如AVX512; 这些数据类型同理可以根据数据类型本身的大小,和使用的指令集宽度计算出对应的Pack长度。
默认情况下elempack = 1, elemsize = 4u; 即在native 代码中,不考虑进行pack操作;
针对Mat的操作我们有:
// basic operation
void fill(...) // 以数据类型填充申请的内存空间,各种硬件实现不同;
void create(...) // 初始化Tensor
void reshape()/ Mat channel(int ) / float* row(int ) / // 属性解析等
零零散散,实际即对Mat 的操作,在此不一一详细描述;
使用c_api 的时候,首先要注意不要与opencv::mat混用,也就是说,尽量使用ncnn::Mat 来避免和Opencv 中的Mat产生混淆。其次要注意对于浮点数(所有的float类型,比如f32 f64 f16),是不支持直接比较的,因为计算机数值原因,float的精度位之间会有差异。所以,float之间最好使用abs(a-b) < 0.0001 类似的“绝对值 + 精度要求”的形式进行比较。
针对输入所做的内存填充和 扩展及 裁剪:
顺着pack往下说,逐步考虑以下情况,思考它们所占的内存宽度是多少
- float a[13] = {0.0f} elempack = 1 ;
- float a[11] = {0.0f} elempack = 4;
- float a[4][4] = {0.0f}; elempack = 4;
- float a[3][4] = {0.0f}; elempack = 4;
- float a[3][3][3] = {0.0f}; elempack = 4;
-
对于1.没有问题,内存宽度就是 13 * 4u ;
-
对于2.elempack = 4 ,内存填充到 11>>2<<2 * 4u = 12u;
-
对于3.elempack = 4,内存填充到 4*4*4u ;
-
对于4.elempack = 4,内存填充到3*4*4u(因为hw 在内存结构上属于同一行内,所以只要满足能够被pack整除,就不做内存填充);
-
对于5. elempack = 4,针对hw 方向为3*3 填充到 12 ,c方向为3 共计 3*12*4u;
下面的代码中,假设*为f32的数据,-为填充数据,我们有:
1. a[*************]
2. a[****|****|***-|]
3. a[****|****|****|****|] // w= 4; h = 4;
4. a[***|***|***|***|] // w= 3; h = 4;
5. a[ [****|****|*---|], // w = 3; h = 3
[****|****|*---|], // c= 3;
[****|****|*---|]]
以上是内存布局上小的填充,对于整个输入部分,为了尽量适配到以4或8为倍数的长度,方便计算过程中,读取效率,减少每次cache miss,也会做扩展和裁剪;而ncnn中主要通过构建一个新层,配置新层参数来实现对输入的扩展和裁剪,函数及对应的op为:
copy_make_border ==> create_layer(LayerType::Padding);
copy_cut_border ==> create_layer(LayerType::Crop);
转换Mat属性函数:
同样,对于转换Mat属性或者说转换Tensor属性,最好的办法也是新建一个cast op来转换,我们有以下cast函数:
cast_float32_to_float16(); ==> create_layer(LayerType::Cast);
cast_float16_to_float32(); ==> create_layer(LayerType::Cast);
cast_int8_to_float32(); ==> create_layer(LayerType::Cast);
cast_float32_to_bfloat16(); ==> create_layer(LayerType::Cast);
cast_bfloat16_to_float32(); ==> create_layer(LayerType::Cast);
这样做的好处有:
- 将所有权提升到Layer 层次上,减少函数内部变更数据类型的潜在风险;
- 方便对上下文做检验后就地进行上下文数据类型匹配,以维护上下文的同一性;
- 为需要切换数据类型的操作,提供良好修改接口;
量化:
上文中我们提到Mat需要支持切换数据类型的操作。原因在于,大多数模型都是基于PC训练处的模型,但是这些模型 最终都需要放到嵌入式设备上运行(比如手机,树莓派,开发板和单片机)。PC可使用的处理器,内存范围,精度都远远高于模型实际运行所在的硬件性能,所以,有没有方法,能够尽量减少模型精度损失的情况下,大幅度缩小模型所占内存大小。Mat中所实现的量化就是这样一种方法。(当然,其他的方法还有模型裁剪,知识蒸馏等,在此不赘述)。
将 f32 降为 Int8 的过程相当于信息再编码 ,就是原来使用 32bit 来表示一个 tensor,现在使用 8bit 来表示一个 tensor,还要求精度不能下降太多。将 f32 转换为 Int8 的操作需要针对每一层的输入tensor和网络学习到的weights进行。如何在转换过程中最小化信息损失,并保证较高的计算效率,目前大多采用的简单有效的转换方法是线性量化
对于ncnn来说,即实现以下三个函数:
quantize_to_int8 ==>create_layer(LayerType::Quantize);
dequantize_from_int32 ==>create_layer(LayerType::Dequantize);
requantize_from_int32_to_int8 ==>create_layer(LayerType::Requantize);
- quantize_to_int8 : 即f32等转int8 的线性缩放过程;
- dequantize_from_int32 : 量化过程的逆过程;
- requantize_from_int32_to_int8: 再量化,指检测下一个操作所需要的量化数据类型为int8 ,那么把在模型的层之间传递tensor时候,将上一次的结果量化成int8的过程;
补充链接:
感谢以下链接的作者,阅读他们的文章,能够更加深入理解本篇一些概念:
- ncnn初探二: 图解ncnn::Mat的内存排布;
- Int8量化-介绍(一)
- 待更新:NCNN内存pack测试程序;
本文来自博客园,转载请注明原文链接:https://www.cnblogs.com/Moonjou/p/16471061.html
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具