当前位置: 首页 > news >正文

今天,你的模型加速了吗?这里有5个方法供你参考(附代码解析)

 

作者 | 卢誉声

出品 | AI科技大本营(ID:rgznai100)

【导读】AIoT时代来临,移动平台正在成为工业实践最重要的阵地!如何把智能装进移动端,开发移动平台人工智能系统解决方案?这次,我们不止为大家分享知识,还将完整呈现深度学习的学习路径!,让你的学习之路有方向,不迷茫!

一、模型加速方法

诸如权重稀疏化等模型裁剪方法的核心思路是去掉那些不重要的权重和链接,整个网络的权重变少了,那么模型自然而然也就变小了,但是这种方法会带来比较明显的信息丢失,虽然我们会在最后的性能与模型体积中采取一种折中的方案,但性能的损失最后还是不可避免的。在下面的内容中,我们就和大家讨论并分享工业界的模型加速的方案,并附上代码供大家学习参考。

1. 半精度与权重量化

一种减小模型体积的方法叫做权重量化(weight quantize)。大家都知道每一个权重都是一个浮点数,那么这个浮点数在存储的时候至少是一个单精度(32位)浮点数,如果我们能用一个比32位小,但是又能近似等价于原来权重的数字来替代原本的权重,比如把每个数字变成16位甚至8位,那么就可以将整个模型的大小减小到原来的甚至是,相比于权重稀疏化我们能看到更为直接明显的效果,而且减小模型的效果也更加稳定。

这里如果我们将一个参数变成比其更窄的参数,但是每个权重依然是浮点数,这是比较简单的,比如所谓半精度的思路就是把每个32位的浮点数缩小成16位的浮点数,这样就可以将模型体积压缩为原来的。

但是计算机中其实整数才是存储空间占用更小而且计算速度更快的方式,而量化模型就是一个等价的小整数(比如8位整数)来替代原来的权重参数,这样就能得到更小的模型。整数不仅能缩小模型尺寸,还能加快计算速度,因此其实量化模型(Quantized Model)是一种模型压缩与加速(Model Acceleration)方法的总称,具体的量化模型包括二值化网络(Binary Network)、三值化网络(Ternary Network)以及深度压缩(Deep Compression)等。接下来我们逐一介绍这些算法。

2. 深度压缩

深度压缩(Deep Compression)在模型压缩一开始就提到过,这是Song Han在1989年的论文中提出的模型压缩算法,这个算法也是我们讨论的各种模型压缩算法的源头,我们就先探讨一下这个算法的细节。算法的整体框架如图9-1所示。

       

图9-1  深度压缩算法整体框架(该图来源于Deep Compression的论文)

Deep Compression主要分为3个主要的部分:剪枝、量化、哈夫曼编码,下面分别探讨这几种方法并且分析它们在硬件前向配置的加速潜力。

(1)剪枝

剪枝(pruning)的思路核心非常简单,就是当网络收敛到一定程度的时候,论文的作者认为阈值小于一定权重的权重对网络作用很小,那么这些权重就被无情的抛弃了。注意,是抛弃,彻底抛弃,在复现的时候这个地方是一个大坑,被剪掉的权重不会再接收任何梯度。这也就是我们上一节中讨论的权重稀疏化。

然后下面的套路简单了,就是很简单地重新加载网络,然后重新训练至收敛。重复这个过程,直到网络参数变成一个高度稀疏的矩阵。这个过程最难受的就是调参了,由于小的参数会不断被剪枝,为了持续增大压缩率,阈值必须不断增大,那么剩下的就看调参效果了。

最后参数会变成一个稀疏矩阵,具体方法我们上一节中都有介绍,这里就不再赘述了。

(2)量化

量化的作用就是将接近的值变成同一个数,我们在此援引论文中的图,其大致思路如图9-2所示。

             

图9-2  量化思路图

可以看出这里简单地将每个浮点数都近似成一个对应的整数,比如2.09、1.92、1.87这些数字都变成了3,而-0.98、-1.08之类的数字都对应成了0。这里需要注意,量化其实是一种权值共享的策略。量化后的权值张量是一个高度稀疏的有很多共享权值的矩阵,对非零参数,我们还可以进行定点压缩,以获得更高的压缩率。

(3)哈夫曼编码

论文的最后一步是使用哈夫曼编码进行权值的压缩,其实如果将权值使用哈夫曼编码进行编码,解码的代价其实是非常大的,尤其是时间代价,因此在实际使用的时候一般并不会采用这种方案。

无论Deep Compression的方法实现是多么粗糙,但是我们可以从中吸收到模型压缩的基本思路,后续的量化方法都可以视为是Deep Compression量化方法的延伸扩展与优化提高,并没有改变基本的思路。

3. 二值化网络

通常我们在构建神经网络模型中使用的精度都是 32 位单精度浮点数,在网络模型规模较大的时候,需要的内存资源就会非常巨大,而浮点数是由1位符号位,8位指数位和尾数位3个部分构成的。完成浮点加减运算的操作过程大体分为4步。

1)操作数的检查,即若至少有一个参与运算的数为零直接可得到结果。

2)比较阶码大小并完成对阶。

3)尾数进行加或减运算。

4)结果规格化并进行舍入处理。

这样的步骤所带来的问题是,网络在运行过程中不仅需要大量的内存还需要大量的计算资源。那么 quantization 的优越性就体现出来了,在 2016 年发表在 NIPS 的文章《Binarized Neural Networks: Training Deep Neural Networks with Weights and Activations Constrained to +1 or -1》[footnoteRef:1]中,提出了利用降低权重和输出的精度的方法来加速模型,因为这样会大幅的降低网络的内存大小和访问次数,并用 bit-wise operator 代替 arithmetic operator。

下面具体介绍一下这种方法的原理。在训练 BNN 时,将权重和输出置为1或-1,下面是两种二值化的方法。

第1种直接将大于等于零的参数置为 1,小于 0 的置为 -1:

第2种将绝对值大于 1 的参数置为 1,将绝对值小于 1 的参数根据距离 ±1 的远近按概率随机置为 ±1:

公式的函数中是一个 clip 函数:

第2种二值化方式看起来更为合理,但是由于引入了按概率分布的随机比特数,所以硬件实现会消耗很多时间,我们通常使用第一种量化方法来对权重和输出进行量化。

虽然BNN的参数和各层的输出是二值化的,但梯度不得不用较高精度的实数而不是二值进行存储。因为梯度很小,所以使用无法使用低精度来正确表达梯度,同时梯度是有高斯白噪声的,累加梯度才能抵消噪声。

另一方面,二值化相当于给权重和输出值添加了噪声,而这样的噪声具有正则化作用,可以防止模型过拟合。所以,二值化也可以被看作是Dropout的一种变形,Dropout是将输出按概率置0,从而造成一定的稀疏性,而二值化将权重也进行了稀疏,所以更加能够防止过拟合。

由于sign函数的导数在非零处都是0,所以在梯度回传时使用tanh来代替sign进行求导。假设loss function是C,input是r,对 r做二值化有:

 

C对q的导数使用gq表示,那么q对r的导数就变成了:

这样就可以进行梯度回传,然后就能根据梯度不断优化并训练参数了。这里我们需要使用BatchNorm层,BN 层最大的作用就是加速学习,减少权重尺度影响,带来一定量的正则化,可以提高网络性能,但是BN 涉及很多矩阵运算,会降低运算速度,因此,提出了一种 Shift-based Batch Normalization,后面简称为SBN。SBN 最大的优势就是几乎不需要进行矩阵运算,而且还不会对性能带来损失。

此外,由于网络除了输入以外,全部都是二值化的,所以需要对第一层进行处理,将其二值化,处理过程如图 9-3所示。

     

       

以上是我们假定每个数字只有8位的场景,如果我们希望采用任意n位的整数,那么可以对公式进行推广,可以得到如下公式:

二值化的实现代码参见9.2.3节。

该算法在MNIST、CIFAR-10等常见库中都做了测试,测试结果如图9-4所示(参见原论文)。

             

图9-4二值化网络性能测试表

我们可以看到,这些简单网络的误差还在可接受范围之内,但是这种二值化网络在ImageNet上的测试效果不尽如人意,出现了很大的误差。虽然我们有很多优化技巧,比如放宽tanh的边界,用2-bit的激活函数,可以提升一些准确率,但是在复杂的模型下,在牺牲那么多运算和储存资源的情况下准确率差强人意。这也就是二值化网络的缺点——可以应付简单模型,不适用于复杂模型。

4. 三值化网络

相比于二值化网络,三值化网络可以得到更好的效果,这是2016年由Fengfu Li在论文《Ternary Weight Networks》[footnoteRef:2]中提出的算法。

首先,该论文提出多权值比二值化具有更好的网络泛化能力。

其次,认为权值的分布接近于一个正态分布和一个均匀分布的组合。

最后,使用一个 scale 参数去最小化三值化前的权值和三值化之后的权值的 L2 距离。

参数三值化的公式如下所示:

其实就是简单的选取一个阈值(Δ),大于这个阈值的权值变成1,小于阈值的权值变成 -1,其他变成 0。当然这个阈值其实是根据权值的分布的先验知识算出来的。本文最核心的部分其实就是阈值和scale参数alpha的推导过程。

在参数三值化之后,该算法使用了一个 scale 参数去让三值化之后的参数更接近于三值化之前的参数。具体的描述如下:

利用此公式推导出 alpha 的值如下:

由此推得阈值的计算公式如下:

由于这个式子需要迭代才能得到解,会造成训练速度过慢的问题,所以如果可以提前预测权值的分布,就可以通过权值分布大大减少阈值计算的计算量。文中推导了正态分布和平均分布两种情况,并按照权值分布是正态分布和平均分布组合的先验知识提出了计算阈值的经验公式。

三值化的目的就是解决二值化BNN的问题。当然,这种方法有进化版本,我们完全可以将权值组合变成(-2,-1,0,1,2)的组合,以期获得更高的准确率。正好我之前也推过相关的公式,现在贴出来供大家参考,这个时候权值的离散化公式变成了:

Scale 参数的计算公式变成了:

  

此时阈值的计算公式变成了:

权值三值化并没有完全消除乘法器,在实际前向运算的时候,它需要给每一个输出乘以一个 scale 参数,然后这个时候的权值是(-1,0,1),以此来减少了乘法器的数目,至于为什么减少跟 BNN是一样的道理。

5. DoReFa-Net

DoReFa-Net是Face++团队在2016年提出的算法[footnoteRef:3],和上面两种量化方法思路也是比较接近,但DoReLa-Net 对比例因子的设计更为简单,这里并没有针对卷积层输出的每一个过滤映射计算比例因子,而是对卷积层的整体输出计算一个均值常量作为比例因子。这样的做法可以简化反向运算,因为在反向计算时也要实现量化。

首先我们来简介一下如何利用 DoReFa-Net 中的比特卷积内核,然后详细说明量化权值,激活和梯度以低比特数的方法。

和之前BNN的点积方法一样,DoReFa 也采用了这种简化的点积方式:

对于定点数 x 和 y,可以得到下面的公式:


同样为了规避 0 梯度的问题,采用了直通估计(STE):

对于权重二值化的梯度回传,采用下面的方法,即二值化乘比例因子,回传时直接跳过二值化:

比特数k大于1的梯度回传,需要先对参数clip到[0,1]之间:

由于二值化输出会降准确率,所以采用 k-bit 量化(k>1),这里的r也要经过clip:

DoReFa的梯度量化方法比较复杂,因为梯度是无界的,并且可能具有比隐层输出更大的值范围。我们可以通过使可微分非线性函数传递值来将隐层输出范围映射到[0,1]。但是,这种构造不适用于渐变。算法设计了以下用于梯度 k 位量化的函数,这里dr是r对损失函数C的偏导:

为了补偿量化梯度带来的潜在偏差,在clip后的结果增加了一个高斯噪声:

梯度的量化仅在回程中完成,因此文章在每个卷积层的输出上应用以下STE:

最终得到了DoReFa-net的算法,这里对第一层和最后一层不做量化,因为输入层对图像任务来说通常是8-bit的数据,做低比特量化会对精度造成很大的影响,输出层一般是一些one-hot向量,所以一般对输出层也保持原样,除非做特殊的声明。

DoReFa-Net分别对SVHN和ImageNet进行了实验,准确率明显比二值化与三值化网络更高。

二、编程实战

根据理论描述,DoReFa-Net实际上就是重写了原本的卷积层,解决了参数最多,运算最慢的一个层,因此我们不需要改动其他层的任何代码,只需要修改卷积层的实现就能完成对DoReFa-Net的支持,我们现在实现一下ConvDorefaLayer。

首先编写头文件conv_dorefa_conv.h,这是ConvDorefaLayer类的声明文件。如代码清单95所示。

代码清单95  conv_dorefa_conv.h 

template <typename Dtype>class ConvDorefaLayer : public Layer<Dtype> {public:  explicit ConvDorefaLayer(const LayerParameter& param)      : Layer<Dtype>(param) {}  virtual void LayerSetUp(const vector<Blob<Dtype>*>& bottom,      const vector<Blob<Dtype>*>& top);  virtual void Reshape(const vector<Blob<Dtype>*>& bottom,      const vector<Blob<Dtype>*>& top);  virtual inline const char* type() const { return "ConvDorefa"; }  virtual inline int ExactNumBottomBlobs() const { return 1; }  virtual inline int ExactNumTopBlobs() const { return 1; } protected:  virtual void Forward_cpu(const vector<Blob<Dtype>*>& bottom,      const vector<Blob<Dtype>*>& top){}  virtual void Backward_cpu(const vector<Blob<Dtype>*>& top,       const vector<bool>& propagate_down, const vector<Blob<Dtype>*>& bottom){}  virtual void Forward_gpu(const vector<Blob<Dtype>*>& bottom,       const vector<Blob<Dtype>*>& top);  virtual void Backward_gpu(const vector<Blob<Dtype>*>& top,       const vector<bool>& propagate_down, const vector<Blob<Dtype>*>& bottom);    shared_ptr<Layer<Dtype> > internalConv_layer_;    bool containActive;    bool weightIntiByConv;    int w_bit;    int a_bit;    int g_bit;    int conv_learnable_blob_size;    Blob<Dtype> bitW;    Blob<Dtype> bitA;    Blob<Dtype> bitG;    Dtype scale_w;    Dtype scale_a;    Dtype quanK2Pow_w;    Dtype quanK2Pow_a;    Dtype quanK2Pow_g;    bool blobsInitialized;    void binaryFw(Blob<Dtype>*fp, Blob<Dtype>*bin,const Dtype&bitCount); };

第2行,定义了一个模板类ConvDorefaLayer,该类的参数是Dtype,表示元素类型。

第4行,声明了ConvDorefaLayer构造函数,参数是层的参数对象。

第6行,声明了LayerSetUp成员函数,用于初始化层的内部状态。

第8行,声明了Reshape成员函数,用于在计算前处理输入和输出向量的维度。

第11行,定义了type成员函数,用于返回层的类型名称,这里返回ConvDorefa。

第12行,声明了ExactNumBottomBlobs成员函数,用于获取输入数据的数量。

第13行,声明了ExactNumTopBolobs成员函数,用于获取输出数据的数量。

第16行,声明了Forward_cpu成员函数,利用CPU完成网络的前向传播计算。

第18行,声明了Backward_cpu成员函数,利用CPU完成网络的反向传播计算。

第20行,声明了Forward_gpu成员函数,利用GPU完成网络的前向传播计算。

第22行,声明了Backward_gpu成员函数,利用GPU完成网络的反向传播计算。

第25行,定义了internalConvLayer成员变量,用于存储内部实际完成卷积计算的层对象指针。这里用shared_ptr防止内存泄漏。

第26~40行,定义了各类参数的成员变量。

第41行,声明了私有成员函数binaryFw,用于完成二值化计算。

接着编写源文件conv_dorefa_conv.cpp,这是ConvDorefaLayer类的实现文件。如代码清单96所示。

代码清单9-6  conv_dorefa_conv.cpp

template <typename Dtype> void ConvDorefaLayer<Dtype>::LayerSetUp(const vector<Blob<Dtype>*>& bottom,      const vector<Blob<Dtype>*>& top) {   const ConvDorefaParameter convDorefa_param = this->layer_param_.convolution_dorefa_param();   const ConvolutionParameter conv_param = this->layer_param_.convolution_param();   containActive=convDorefa_param.contain_active();   w_bit = convDorefa_param.w_bits();   a_bit = convDorefa_param.a_bits();   g_bit = convDorefa_param.g_bits();   CHECK(w_bit>0);   CHECK(a_bit>0);   CHECK(g_bit>0);   quanK2Pow_w=quanK2Pow_a=quanK2Pow_g=1.0;   for(int i=0;i<w_bit && w_bit!=1;i++) quanK2Pow_w*=2.0;   for(int i=0;i<a_bit && a_bit!=1;i++) quanK2Pow_a*=2.0;   for(int i=0;i<g_bit && g_bit!=1;i++) quanK2Pow_g*=2.0;   this->conv_learnable_blob_size=this->layer_param_.convolution_param().bias_term()==true?2:1;   this->blobs_.resize(this->conv_learnable_blob_size);//fake     LayerParameter layer_param(this->layer_param_);     layer_param.set_name(this->layer_param_.name() + "_internalConv");     layer_param.set_type("Convolution");     internalConv_layer_ = LayerRegistry<Dtype>::CreateLayer(layer_param);     internalConv_layer_->LayerSetUp(bottom,top);     weightIntiByConv=false;     scale_w=-1.;     scale_a=-1.;     blobsInitialized=false; } template <typename Dtype> void ConvDorefaLayer<Dtype>::Reshape(const vector<Blob<Dtype>*>& bottom,       const vector<Blob<Dtype>*>& top) {       internalConv_layer_->Reshape(bottom, top);//bitW.Reshape(internalConv_layer_->blobs()[0]->shape());     if(containActive) bitA.Reshape(bottom[0]->shape());     if(blobsInitialized==false)     {         if (conv_learnable_blob_size==2) {           this->blobs_.resize(2);         } else {           this->blobs_.resize(1);         }         for(int i=0;i<this->conv_learnable_blob_size;i++)         {             this->blobs_[i].reset(new Blob<Dtype>(internalConv_layer_->blobs()[i]->shape()));             caffe_copy(this->blobs_[i]->count(),internalConv_layer_->blobs()[i]->cpu_data(), this->blobs_[i]->mutable_cpu_data());         }         blobsInitialized=true;     } }#ifdef CPU_ONLY STUB_GPU(ConvDorefaLayer);#endif INSTANTIATE_CLASS(ConvDorefaLayer); REGISTER_LAYER_CLASS(ConvDorefa);

第2行,定义了LayerSetUp成员函数,该函数的输入是bottom,也就是输入数据,输出是top,也就是输出数据。

第4行,从laayer_param的convolution_dorefa_param获取DoRefa层的特定参数。

第5行,从layer_param的convolution_params中获取卷积层的通用参数。

第6~9行,从convDorefa_param参数中获取containActive、w_bit、a_bit和g_bit等几个参数,完成初始化。

第13~16行,根据w_bit、a_bit和g_bit计算quanK2Pow_w、quanK2Pow_a和quanK2Pow_g等。

第18行,根据convolution_param计算conv_learnable_blob_size。

第19行,根据conv_learnable_blob_size调整内部存储数据块的数量。

第20~22行,初始化卷积层的层参数。

第23行,使用卷积层的构造函数构造卷积层对象,并将返回的指针存储在internalConv_layer_成员变量中。

第24行,调用卷积层的LayerSetUp初始化内部的卷积层对象。

第25~28行,初始化剩余的变量。

第32行,定义了Reshape成员函数,该函数用于在前向计算前调整输入和输出向量以及内部向量的维度。

第34行,调用内部卷积层对象的Reshape调整卷积层的内部维度。

第37行,如果包含Active向量,那么调用bitA的Reshape函数调整bitA向量的维度。

第38~51行,如果数据块没有初始化,那么就调用blobs的resize函数重新调整数据块的维度。

第45~49行,根据conv_learnable_blob_size调整数据块的数量与维度。

第57行,调用STUB_GPU生成GPU版本的成员函数实现。

第60行,调用INSTANTIATE_CLASS实例化ConvDorefaLayer类。

第61行,调用REGISTER_LAYER_CLASS注册ConvDorefaLayer类。

以上内容摘自机械工业出版社华章公司出版的《移动平台深度神经网络实战:原理、架构与优化》一书,经出版方授权发布。

【优惠信息】:今日8点至明日8点,这本书参加京东66折秒杀活动,希望系统学习移动平台深度学习的读者不要错过啦

精彩推荐

今天20:00,CSDN技术公开课邀请Autodesk数据平台和计算平台资深工程师,《移动平台深度神经网络实战:原理、架构与优化》作者卢誉声为大家分享:从入门到深入,移动平台模型的裁剪与优化

【扫码直接报名】

参与课程提问交流,就有机会获得此书

课程大纲:

1、移动平台开发的基础原理与架构设计

2、基础 AI 与移动平台开发核心学习路径

3、掌握核心模型裁剪与优化的工程方法

4、基于优化的TensorFlow Lite的落地案例

适合人群:

1.移动平台应用程序研发人员;

2.嵌入式设备软件研发人员;

3.大数据/机器学习架构师、研发人员;

4.智能系统架构设计与开发工作者;

5.高性能计算研发人员;

6.高等院校与科研院所师生。

讲师简介:卢誉声

Autodesk数据平台和计算平台资深工程师,负责平台架构研发工作。工作内容涵盖大规模分布式系统的服务器后端、前端以及SDK的设计与研发,在数据处理、实时计算、分布式系统设计与实现、性能调优、高可用性和自动化等方面积累了丰富的经验。擅长C/C++、JavaScript开发,此外对Scala、Java以及移动平台等也有一定研究。著有《移动平台深度神经网络实战:原理、架构与优化》、《分布式实时处理系统:原理架构与实现》,并译有《高级C/C++编译技术》和《JavaScript编程精解(原书第2版)》等。

你与世界

只差一个

公众号

相关文章:

  • #我与Java虚拟机的故事#连载02:“小蓝”陪伴的日日夜夜
  • #我与Java虚拟机的故事#连载03:面试过的百度,滴滴,快手都问了这些问题
  • 从治疗癌症到预测犯罪,细数数据科学在各领域的神奇应用
  • 入门大爆炸式发展的深度学习,你先要了解这4个最流行框架
  • #我与Java虚拟机的故事#连载04:一本让自己没面子的书
  • #我与Java虚拟机的故事#连载05:Java虚拟机的修炼之道
  • Java异步编程并没有广泛使用起来,这是什么原因? (文末有福利)
  • 新书推荐 | Java核心技术 卷II 高级特性(原书第11版)
  • #我与Java虚拟机的故事#连载06:收获颇多的经典之作
  • 中台四杰与阿里往事
  • #我与Java虚拟机的故事#连载07:我放弃了对JVM的进一步学习
  • InfoQ 编辑部开年聊聊 2020 年值得关注的十大技术趋势
  • 达摩院十大科技趋势发布:2020 非同小可!
  • #我与Java虚拟机的故事#连载08:书读百遍其义自见
  • 新书推荐 | 深入浅出图神经网络:GNN原理解析
  • echarts的各种常用效果展示
  • echarts花样作死的坑
  • go语言学习初探(一)
  • leetcode98. Validate Binary Search Tree
  • Netty 框架总结「ChannelHandler 及 EventLoop」
  • Python中eval与exec的使用及区别
  • Quartz初级教程
  • Sass Day-01
  • tab.js分享及浏览器兼容性问题汇总
  • ubuntu 下nginx安装 并支持https协议
  • webpack4 一点通
  • 关于字符编码你应该知道的事情
  • 机器人定位导航技术 激光SLAM与视觉SLAM谁更胜一筹?
  • 将回调地狱按在地上摩擦的Promise
  • 开源地图数据可视化库——mapnik
  • 理清楚Vue的结构
  • 吴恩达Deep Learning课程练习题参考答案——R语言版
  • 用Canvas画一棵二叉树
  • 自定义函数
  • 如何通过报表单元格右键控制报表跳转到不同链接地址 ...
  • # 手柄编程_北通阿修罗3动手评:一款兼具功能、操控性的电竞手柄
  • (003)SlickEdit Unity的补全
  • (1)bark-ml
  • (ResultSet.TYPE_SCROLL_INSENSITIVE,ResultSet.CONCUR_READ_ONLY)讲解
  • (二十三)Flask之高频面试点
  • (教学思路 C#之类三)方法参数类型(ref、out、parmas)
  • (利用IDEA+Maven)定制属于自己的jar包
  • (生成器)yield与(迭代器)generator
  • (循环依赖问题)学习spring的第九天
  • (转) ns2/nam与nam实现相关的文件
  • (转)ObjectiveC 深浅拷贝学习
  • (转载)PyTorch代码规范最佳实践和样式指南
  • ***汇编语言 实验16 编写包含多个功能子程序的中断例程
  • .NET CORE 3.1 集成JWT鉴权和授权2
  • .NET Core MongoDB数据仓储和工作单元模式封装
  • .NET Core、DNX、DNU、DNVM、MVC6学习资料
  • .NET Core实战项目之CMS 第一章 入门篇-开篇及总体规划
  • .NET Core使用NPOI导出复杂,美观的Excel详解
  • .NET 分布式技术比较
  • .net 重复调用webservice_Java RMI 远程调用详解,优劣势说明