NCNN学习系列一:NCNN 内存层次设计与实现

开源摘星计划(WeOpen Star) 是由腾源会 2022 年推出的全新项目,旨在为开源人提供成长激励,为开源项目提供成长支持,助力开发者更好地了解开源,更快地跨越鸿沟,参与到开源的具体贡献与实践中。

不管你是开源萌新,还是希望更深度参与开源贡献的老兵,跟随“开源摘星计划”开启你的开源之旅,从一篇学习笔记、到一段代码的提交,不断挖掘自己的潜能,最终成长为开源社区的“闪亮之星”。

我们将同你一起,探索更多的可能性!

项目地址: WeOpen-Star

关键词:

C++NCNNTensor内存布局PackFP32toFP16量化

基础:

​ 俗话说:“工欲善其事必先利其器”,在开始进入到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};	
    

​ 好,其中有几个点需要指出:

  1. 本部分暂不考虑模板,所以所有标量都带有明确类型做初始化;
  2. 根据计算机中内存分配原则,向量集合中所有元素默认按照统一类型初始化;
  3. 计算机图形学中 矩阵的长度= 图像的高度(height) 即 你看到的屏幕左上方到左下方,矩阵的宽度=图像的宽度(width)即你看到的屏幕左上方到右上方
  4. 对于张量,可以想象一张长i 宽j 的书页,k张书页被压平到一起;

NCNN中的实现:

​ ncnn的主要分为c的实现和python 的调用,二者通过pybind11 连接到一起,本系列文章中将主要集中在c_api上。假设你已经git clone ncnn(什么?你还没有clone,请看这里[文章零]),本系列的文章将集中在src 内容中。本篇文章,请先看ncnn/src/mat.h;

​ 在深度学习框架中,作为基础数据结构的tensor(ncnn 中称为Mat)。起到的功能主要有:

  1. Mat 内存初始化 和内存基础操作;
  2. 转换Mat的数据类型;
  3. 针对输入所做的填充和裁剪;
  4. 量化;

​ 下面我们先从基础的内存初始化和内存基本操作开始:

内存初始化和内存操作:

​ 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往下说,逐步考虑以下情况,思考它们所占的内存宽度是多少

  1. float a[13] = {0.0f} elempack = 1 ;
  2. float a[11] = {0.0f} elempack = 4;
  3. float a[4][4] = {0.0f}; elempack = 4;
  4. float a[3][4] = {0.0f}; elempack = 4;
  5. 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);
  1. ​ quantize_to_int8 : 即f32等转int8 的线性缩放过程;
  2. ​ dequantize_from_int32 : 量化过程的逆过程;
  3. ​ requantize_from_int32_to_int8: 再量化,指检测下一个操作所需要的量化数据类型为int8 ,那么把在模型的层之间传递tensor时候,将上一次的结果量化成int8的过程;

补充链接:

​ 感谢以下链接的作者,阅读他们的文章,能够更加深入理解本篇一些概念:

posted @   MoonJou  阅读(1714)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· AI技术革命,工作效率10个最佳AI工具
点击右上角即可分享
微信分享提示