Cuda编程3:模型部署优化(量化与剪枝概念)

文章目录

一:基础知识

1:硬件性能

1:FLPOS(FLOP/s)

Floating point number operations per second

  1. 指的是一秒钟可以处理的浮动小数点运算的次数
  2. 衡量计算机硬件性能、计算能力的一个单位

注意:FLOPs(Floating point number operations)是衡量模型大小的一个指标,多少多少G,比如4.5G
在这里插入图片描述

1.1:计算方式

FLOPS = 频率* core数量* 每个时钟周期可以处理的FLOPS

  1. CPU计算方式:
    eg:Intel i7 Haswell架构(8核,频率3.0GHz)
    那么它的FLOPS在双精度的时候就是:3.0 * 10^9 Hz * 8 core * 16 FLOPS/clk = 0.38 TFLOPS
    那么它的FLOPS在单精度的时候就是:3.0 * 10^9 Hz * 8 core * 32FLOPS/clk = 0.76TFLOPS
    16 FLOPS/clk:有两个FMA(一个时钟周期完成一次乘加),并且支持AVX-256指令集(4个FP64),所以2 FMA * 4个FP64的SIMD运算 * 2 乘加融合= 16FLOPS/clk

  2. GPU计算方式(CUDA CORE)
    跟CPU不同的地方:
    1:没有AVX这种东西
    2:但是有大量的core来提高吞吐量
    3:有Tensor Core来优化矩阵运算

一块A100由有108个SM在这里插入图片描述如下图,一个SM里面有64个处理INT32的CUDA Core,64个处理FP32的CUDA Core,32个处理FP64的CUDA Core,4个处理矩阵计算的的Tensor Core在这里插入图片描述
在这里插入图片描述
a:由于有32个处理FP64的CUDA Core,FP64计算数量=32。
b:由于有64个处理FP32的CUDA Core和32个处理FP64的CUDA Core,则可以换算为256(642+324)个FP16,所以FP16计算数量256。
那么:

  • 频率:1.41 GHz
  • SM数量:108
  • 一个SM中计算FP16的CUDA core的数量: 256
  • 一个CUDA core一个时钟周期可以处理的FP16: 1
  • 乘加: 2

Throughput= 1.41 GHz * 108 * 256* 1 * 2 = 78 TFLOPS

  1. GPU计算方式(TENSOR CORE)

Ampere架构使用的是第三代Tensor Core(第一代为44与44),可以一个clk完成一个1024 ( = 256 * 4)个FP16运算。准确来说是4x8的矩阵与8x8的矩阵的FMA

那么:

  • 频率:1.41 GHz
  • SM数量:108
  • 一个SM中计算FP16的Tensor core的数量: 4
  • 一个Tensorcore一个时钟周期可以处理的FP16: 256
  • 乘加: 2

Throughput= 1.41 GHz * 108 * 4 * 256 * 2 = 312 TFLOPS

在这里插入图片描述

2:TOPS

Tera operations per second

  1. 指的是一秒钟可以处理的整型运算的次数
  2. 衡量计算机硬件性能、计算能力的一个单位
    在这里插入图片描述

2:Roofline model

1:指标

1.1:计算量

单位是FLOPs,表示模型中有多少个floating point operations。是衡量模型大小的标准。

在这里插入图片描述

1.2:计算峰值

单位是FLOPS (也可以是FLOP/s),表示计算机每秒可以执行的floating point operations。是衡量计算机性能的标准(第一节所讲)。

1.3:参数量

单位是Byte,表示模型中所有的weights(主要在conv和FC中)的量。是衡量模型大小的标准。

在这里插入图片描述

1.4:访存量

单位是Byte,表示模型中某一个算子,或者某一层layer进行计算时需要与memory产生read/write的量。是分析模型中某些计算的计算效率的标准之一

在这里插入图片描述

1.5:带宽

单位是Byte/s,全称是memory bindwidth,表示的是单位时间内可以传输的数据量的多少。是衡量计算机硬件memor性能的一个标准(DDR/DDR2/DDR3/DDR4/GDDR表示的是这个)

  1. memory clock (GHz)
    • 表示的是单位时间内可以read/write的频率(Hz),一般以GHz为基本单位。
  2. memory bus width (Byte)
    • 表示的是可以同时读写的数据多少。单位是Byte。
  3. memory channel
    • 表示的是通道数量,越多越好。
      在这里插入图片描述

2:Operational intensity (计算密度)

单位是FLOPs/Byte,表示的是传送单位数据可以进行的浮点运算数。

  								计算密度=计算量/访存量

3:Roofline Model

在这里插入图片描述

这个图说明,我们可以通过提高计算密度,让我们的硬件尽量处于饱和状态,从而提高计算效率

1:(重点)到目前讲的是理论值。然而实际上我们会发现:

  • 峰值可能会小于22.4TOPS
  • bandwidth可能会小于137GB/s

需要根据一系列benchmark找到部署架构的真实值。(比如自己写几个计算密集的核函数)
在这里插入图片描述

二:模型部署的几大误区

1:FLOPs不能衡量模型性能

  1. 因为FLOPs只是模型计算大小的单位,还需要考虑:
    • 访存量
    • 跟计算无关的DNN部分(reshape, shortcut, nchw2nhwc等等)
    • DNN以外的部分(前处理、后处理这些)

2:不能够完全依靠TensorRT

  1. TensorRT可以对模型做适当的优化,但是有上限,比如:
    • 计算密度低的1x1 conv, depthwise conv不会重构
    • GPU无法优化的地方会到CPU执行(可以手动修改代码实现部分,让部分cpu执行转到gpu执行)
    • 有些冗长的计算,TensorRT可能不能优化(直接修改代码实现部分)
    • 存在TensorRT尚未支持的算子(可以自己写plugin)
    • TensorRT不一定会分配Tensor Core(因为TensorRT kernel auto tuning会选择最合适的kernel)

3:CUDA Core和Tensor Core的使用

  1. 有的时候TensorRT并不会分配Tensor Core
    • kernel auto tuning自动选择最优解
    • 所以有时会出现类似于INT8的速度比FP16反而慢了
    • 使用Tensor Core需要让tensor size为8或者16的倍数

4:不能忽视 前处理/后处理 的overhead

  1. 对于一些轻量的模型,相比于DNN推理部分,前处理/后处理可能会更耗时间
  2. 因为有些前处理/后处理的复杂逻辑不适合GPU并行,然而有很多种解决办法:
    1. 可以把前处理/后处理中可并行的地方拿出来让GPU并行话
      • 比如RGB2BGR, Normalization, resize, crop, NCHW2NHWC
    2. 可以在cpu上使用一些针对图像处理的优化库
      • 比如Halide
        • 使用Halide进行blur, resize, crop, DBSCAN, sobel这些会比CPU快

5:对使用TensorRT得到的推理引擎做benchmark和profiling

  1. 使用TensorRT得到推理引擎并实现infer只是优化的第一步
  2. 需要使用NVIDIA提供的benchmark tools进行profiling
    • 分析模型瓶颈在哪里
    • 分析模型可进一步优化的地方在哪里
    • 分析模型中多余的memory access在哪里
  3. 可以使用:
    • nsys, nvprof, dlprof, Nsight这些工具

三:Quantization(量化)

官方文档
在这里插入图片描述
量化内容:

  1. activation value:激活值
  2. weight:权重

3.1:存在问题

在这里插入图片描述
在这里插入图片描述

仅仅用256种数据去表现FP32的所有可能出现的数据,有可能会造成表现力下降。如果能够比较完美的用这256个数据去最大限度的表现FP32的原始数据分布,是量化的一个很大挑战。换句话说,就是如何合理的设计这个dynamic range是量化的重点。

3.2:量化方式

在这里插入图片描述

3.2.1:存在问题

情况1:
在这里插入图片描述

R中的数据分布不均匀,所以将-100~0均分20个部分映射到Q中是可以接受的,误差也会比较小

情况2:
在这里插入图片描述

R中的数据呈现高斯分布,主要集中在-80~-20中。这个时候R中的数据分布是属于正态分布,所以数据主要集中在靠拢中间的部分,靠近边缘的数据出现的概率比较低。如果依然等分为20个部分的话,靠近中间的数据会出现较大的误差

所以,为了能够让R到Q的映射合理,以及将Q中的数据还原为R时误差能够控制到最小,我们需要根据R中的数据分布合理的设计这个ratio和distance。

3.3:基本术语

1:对称映射,非对称映射

根据R和Q的dynamic range的选择以及mapping的方式,我们可以分为,对称映射(symmetric quantization)以及,非对称映射(asymmetric quantization/Affine Quantization)。

对称量化中量化前后的0是对齐的,所以不会有偏移量(z, shift)的存在,这个可以让量化过程的计算简单。NVIDIA默认的mapping就是对称量化,因为快!

在这里插入图片描述

2:Quantization Granularity(量化粒度)

量化粒度:是对于一个Tensor,以多大的粒度去共享scale和z,或者dynamic range
在这里插入图片描述

  1. Per-tensor量化
    • 优点:低延迟,一个tensor共享同一个量化参数
    • 缺点:高错误率, 一个scale很难覆盖所有FP32的dynamic range
  2. Per-channel(layer)量化
    • 优点:低错误率,每一个channel都有自己的scale来体现这个channel中的数据的dynamic range
    • 缺点:高延迟,需要使用vector来存储每一个channel的scale
  1. 从很多实验结果与测试中,对于weight和activation values的量化方法,一般对于activation values,选取per-tensor量化对于weights,选取per-channel量化
  2. 目前的TensorRT已经默认对于Activation values选用Per-tensor,Weights选用Per-channel

3:量化校准(calibration)

校准:对于一个训练好的模型,权重是固定的,所以可以通过一次计算就可以得到每一层的量化参数。但是activation value(激活值)是根据输入的改变而改变的。所以需要通过类似于统计的方式去寻找对于不同类型的输入的不同的dynamic range。
在这里插入图片描述

1:calibration dataset
  1. calibration dataset:针对不同的输入,各层layer的input activation value都会有不同的分布和取值。大数据集的差别比较大。我们需要通过训练数据集中的一部分数据来尝试表征整个数据集的分布。
  2. calibration dataset一般很小,但需要尽量有整体的特征。
2:校准过程

calibration的过程一般是在模型训练以后进行的,所以一般与PTQ搭配使用,流程如下:
1. 在calibration dataset中做一次FP32的推理
2. 以histogram的形式去统计每一层的floating point的分布
3. 寻找能够表征当前层的floating point分布的scale
4. 算法:Minmax calibration,Entropy calibration,Percentile calibration
5. 算法介绍可在此网页获得

3:Minmax calibration

在这里插入图片描述

4:Entropy calibration(TensorRT默认校准算法)

在这里插入图片描述

5:Percentile calibration

在这里插入图片描述

6:calibration算法选择
  1. weight的calibration,选用minmax
  2. activation的calibration,选用entropy或者percentile

注:在创建histogram直方图的时候,如果出现了大于当前histogram可以表示的最大值的时候,TensorRT会直接平方当前histogram的最大值,来扩大存储空间。所以calibratio的batch size越大越好,但不是绝对的

4:PTQ AND QAT

  1. PTQ(Post-Training Quantization),训练后量化.

PTQ一般是指对于训练好的模型,通过calibration算法等来获取dynamic range来进行量化。但量化普遍上会产生精度下降。

  1. QAT(Quantization-Aware Training),训练时量化

QAT为了弥补精度下降,在学习过程中通过Fine-tuning权重来适应这种误差,实现精度下降的最小化。所以一般来讲,QAT的精度会高于PTQ。但并不绝对。

在这里插入图片描述

4.1:PTQ(Post-Training Quantization),训练后量化)
  1. PTQ(Post-training quantization)也被称作隐式量化(implicit quantization)。因为不会显式的对算子添加量化节点(Q/DQ)
  2. trtexec在选择参数进行fp16或者int8指定的时候,使用的就是PTQ。(int8的时候需要指定calibration dataset)。
4.1.1:PTQ的优缺点

优点:方便使用,不需要训练。可以在部署设备上直接跑
缺点:

  1. 精度下降
    • 量化过程会导致精度下降。但PTQ没有类似于QAT这种fine-tuning的过程。所以权重不会更新来吸收这种误差
  2. 量化不可控
    • TensorRT会权衡量化后的结果来决定是否用INT8还是FP16。
    • TensorRT中的kernel autotuning会选择核函数来做FP16/INT8的计算。来查看是否在CUDA core上跑还是在Tensor core上跑
    • 有可能FP16是在Tensor core上,但转为INT8之后就在CUDA core上了(导致速度变慢)
  3. 层融合问题
    • 量化后有可能出现之前可以融合的层,不能融合了
    • 量化会添加reformatter这种更改tensor的格式的算子,如果本来融合的两个算子间添加了这个就不能被融合了
    • 比如有些算子支持int8,但某些不支持。之前可以融合的,但因为精度不同不能融合了

注:如果INT8量化后速度反而会比FP16/FP32要慢,我们可以从以上的2和3去分析并排查原因

4.1.2:敏感度分析(layer-wise sensitive analysis)

普遍来讲,模型框架中会有一些层的量化对精度的影响比较大。我们管它们叫做敏感层(sensitive layer)。对于这些敏感层的量化我们需要非常小心。尽量用FP16。敏感层一般靠近模型的输入输出

在这里插入图片描述
敏感度分析工具:Polygraphy,Polygraphy是TensorRT官方内部的一个tool,可以在TensorRT的git repository中找到,作用如下图所示:
在这里插入图片描述

4.1.3:Tensor core与Cuda core使用情况

在做量化后,我们无法指定将量化后的conv或者gemm放在Tensor core还是在CUDA core上计算。这些是TensorRT在帮我们选择核函数的时候自动完成的。

可以采用以下三种工具查看:

  1. 使用dlprof
  2. 使用nsight system
  3. 使用trtexec

DLProf

DLProf (Deep learning Profiler)工具可以把模型在GPU上的执行情况以TensorBoard的形式打印出来,分析TensorCore的使用情况。感兴趣的可以查看一下。但需要注意的是,DLProf不支持Jetson系列的Profile。对于Jetson,我们可以使用Nsight system或者trtexec

在这里插入图片描述
Nsight System/trtexec

利用Nsight system的话,我们可以查看到哪一个kernel的时间占用率最高,之后从kernel的名字取推测这个kernel是否在用Tensor Core。(从kernel名字推测kernel的计算设备需要经验)

在这里插入图片描述从kernel名字推测可以从kernel中的关键字去猜,比如

  1. h884 = HMMA = FP16 TensorCore
  2. i8816 = IMMA = INT8 TensorCore
  3. hcudnn = FP16 normal CUDA kernel (without TensorCore)
  4. icudnn = INT8 normal CUDA kernel (without TensorCore)
  5. scudnn = FP32 normal CUDA kernel (without TensorCore)
4.2:QAT(Quantization-Aware Training),训练时量化)
  1. QAT(Quantization Aware Training)也被称作显式量化。我们明确的在模型中添加Q/DQ节点(量化/反量化),来控制某一个算子的精度。并且通过fine-tuning来更新模型权重,让权重学习并适应量化带来的精度误差。
  2. QAT的核心就是通过添加fake quantization,也就是Q/DQ节点,来模拟量化过程。
4.2.1:Q/DQ node

Q/DQ node也被称作fake quantization node,是用来模拟fp32->int8的量化的scale和shift(zero-point),以及int8-fp32的反量化的scale和shift(zero-point)。QAT通过Q和DQ node里面存储的信息对fp32或者int8进行线性变换

在这里插入图片描述
1:可量化层的计算(此处不做推理,建议记住),DQ + fp32精度的op(操作)可以拼成一个int8精度的op,如图所示:
在这里插入图片描述

  1. 蓝色conv表示是fp32的op
  2. 绿色conv表示是int8的op
  3. 蓝色arrow表示的是fp32的tensor
  4. 绿色arrow表示的是int8的tensor

2:可量化层的计算(此处不做推理,建议记住),DQ + fp32精度OP + Q可以融合在一起凑成一个int8的op,我们称这个op或者layer为quantizable layer,翻译为可量化层。这个可量化层的输入和输出都是int8。计算的主体也是int8,可以节省带宽的同时,提高计算效率。如图所示:

  1. 蓝色conv表示是fp32的op
  2. 绿色conv表示是int8的op
  3. 蓝色arrow表示的是fp32的tensor
  4. 绿色arrow表示的是int8的tensor
    在这里插入图片描述
    3:结果
    在这里插入图片描述
4.2.2:QAT的工作流
  1. QAT是一种Fine-tuning方式,通常对一个pre-trained model进行添加Q/DQ节点模拟量化,并通过训练来更新权重去吸收量化过程所带来的误差。添加了Q/DQ节点后的算子会以int8精度执行
    PyTorch已支持对已经训练好的模型自动添加Q/DQ节点

添加后的onnx如下图所示:
在这里插入图片描述

4.2.3:TensorRT中QAT的层融合的技巧

TensorRT对包含Q/DQ节点的onnx模型使用很多图优化,从而提高计算效率。主要分为

  1. Q/DQ fusion
    • 通过层融合,将Q/DQ中的线性计算与conv或者linear这种线性计算融合在一起,实现int8计算
  2. Q/DQ Propagation
    • 将Q节点尽量往前挪,将DQ节点尽量往后挪,让网络中int8计算的部分变得更长

1:Q/DQ fusion如下图所示:
在这里插入图片描述
2:Q/DQ Propagation

有的时候我们发现TensorRT并没有帮我们做到最好,这个时候我们可以使用TensorRT API来手动修改

上方图为DQ往后挪,下方图为Q往前挪。
在这里插入图片描述

4.2.4:QAT的学习过程
  1. 主要是训练weight来学习误差
    • Q/DQ中的scale和zero-point也是可以训练的。通过训练来学习最好的scale来表示dynamic range
  2. 没有PTQ中那样人为的指定calibration过程(自动calibration)
    • 不是因为没有calibration这个过程来做histogram的统计,而是因为QAT会利用fine-tuning的数据集在训练的过程中同时进行calibration,这个过程是我们看不见的。这就是为什么我们在pytorch创建QAT模型的时候需要选定calibration algorithm
4.3:部署量化过程
  1. 先进行PTQ(没必要盲目进行QAT)
    1. 从多种calibration策略中选取最佳的算法
    2. 查看是否精度满足,如果不行再下一步
  2. 进行partial-quantization
    1. 通过layer-wise(分层)的sensitve analysis分析每一层的精度损失
    2. 尝试fp16 + int8的混合精度组合
    3. fp16用在敏感层(网络入口和出口),int8用在计算密集处(网络的中间)
    4. 查看是否精度满足,如果不行再下一步。(注意,这里同时也需要查看计算效率是否得到满足)
  3. 进行QAT来通过学习权重来适应误差
    1. 选取PTQ实验中得到的最佳的calibration算法
    2. 通过fine-tuning来训练权重(大概是原本训练的10%个epoch)
    3. 查看是否精度满足,如果不行查看模型设计是否有问题(注意,这里同时也需要查看层融合是否被适用,以及Tensor core是否被用)

普遍来讲,量化后精度下降控制在相对精度损失<=2%是最好的。
在这里插入图片描述

四:Pruning(剪枝)

模型剪枝是不同于量化的另外一种模型压缩的方式。如果说“量化”是通过改变权重和激活值的表现形式从而让内存占用变小和计算变快的话,“剪枝”则是直接“删除”掉模型中没有意义的,或者意义较小的权重,来让推理计算量减少的过程。

在这里插入图片描述
为什么剪枝:学习的过程中会产生过参数化导致会产生一些意义并不是很大的权重,或者值为0的权重(ReLU)。对于这些权重所参与的计算是占用计算资源且没有作用的。我们需要想办法找到这些权重并让硬件去skip掉这些权重所参与的计算
在这里插入图片描述

4.1:剪枝流程

大体流程:

  1. 获取一个已经训练好的初始模型
  2. 对这个模型进行剪枝
    • 我们可以通过训练的方式让DNN去学习哪些权重是可以归零的(e.g. 使用L1 regularization和BN中的scaling factor让权重归零)
    • 可以通过自定义一些规则,手动的有规律的去让某些权重归零(e.g. 对一个1x4的vector进行2:4的weight prunning)
  3. 对剪枝后的模型进行fine-tuning
    • 有很大的可能性,在剪枝后初期的网络的精度掉点比较严重,需要fine-tuning这个过程来恢复精度
    • Fine-tuning后的模型有可能会比之前的精度还要上涨
  4. 获取到一个压缩的模型
    • 如果到这个阶段对模型压缩还不够满足的话,可以回到step2循环
      在这里插入图片描述

4.3:模型剪枝的分类

按剪枝方式分类:

  1. 结构化剪枝:按照规定好的方式进行剪枝,比如,多少个权重删除一个或者删除权重时以layer或channel为单位
  2. 非结构化剪枝

按剪枝粒度分类:

  1. 粗粒度减枝(Coarse Grain Pruning):与结构化剪枝绑定
  2. 细粒度减枝(Fine Grain Pruning):对每个权重分析是否剪枝

4.3.1:Coarse Grain Pruning(粗粒度减枝)

1:包括Channel/Kernel Pruning
2:Channel/Kernel Pruning是结构化减枝(Structured pruning)

Channel/Kernel Pruning就是直接把某些卷积核给去除掉,较常见的方法就是通过L1Norm寻找权重中影响度比较低的卷积核。

在这里插入图片描述
优势:

  1. 不依赖于硬件,可以在任何硬件上跑并且得到性能的提升

劣势:

  1. 由于减枝的粒度比较大(卷积核级别的),所以有潜在的掉精度的风险
  2. 不同DNN的层的影响程度是不一样的
  3. 减枝之后有可能反而不适合硬件加速(比如Tensor Core的使用条件是channel是8或者16的倍数)

4.3.2:Fine Grain Pruning(细粒度减枝)

细粒度剪枝主要是对权重的各个元素本身进行分析减枝
1:结构化减枝(structed)

  1. Vector-wise的减枝: 将权重按照4x1的vector进行分组,每四个中减枝两个的方式减枝权重
  2. Block-wise的减枝: 将权重按照2x2的block进行分区,block之间进行比较的方式来减枝block

2:非结构化减枝(unstructed)

  • Element-wise的减枝:每一个每一个减枝进行分析,看是不是影响度比较高

在这里插入图片描述
优势:

  1. 相比于Coarse Grain Pruning,精度的影响并不是很大

劣势:

  1. 需要特殊的硬件的支持(Tensor Core可以支持sparse)
  2. 需要用额外的memory来存储哪些index是可以保留计算的
  3. memory的访问不是很效率(跳着访问)
  4. 支持sparse计算的硬件内部会做一些针对sparse的tensor的重编,这个会比较耗时

4.4:channel-level pruning

channel-level pruning是结构化剪枝,也是粗粒度的剪枝。此文章介绍了剪枝方式。
在这里插入图片描述

L1-regularization的训练可以让权重趋向零,找到conv中不是很重要的channel,实现channel-level的pruning。

  1. Batch normalziation一般放在conv之后,对conv的输出进行normalization。整个计算是channel-wise的,,所以每一个channel都会有自己的BN参数(均值、方差、缩放因子、偏移因子)
  2. 若BN之后发现某一个channel的scaling非常小,或者为零,则可以认定此channel不重要,可以称为pruning的候选
  3. 对于scaling factor不是很大的channel,在pruning的时候可以把这些channel直接剪枝掉,但同时也需要把这些channel所对应的input/outputd的计算也skip掉。通过不断的实验找到最好的剪枝百分比:
    • 0% pruning
    • 25% pruning
    • 50% pruning
    • 75% pruning

注意事项:

  1. 刚剪枝完的网络,由于权重信息很多信息都没了,所以需要fine-tuning来提高精度(有一张mask,该mask指定哪些channel需要被剪枝)
  2. 剪枝完的channel size可能会让计算密度变低(64ch通过75% pruning后变成16ch)

在这里插入图片描述

4.4.1:channel-level pruning中的超参和技巧

1:整个pruning的过程中𝜆和𝑐 的剪枝力度是超参,需要不断的实验找到最优。𝜆表示的是在loss中L1-norm这个penalty所占的比重。𝜆越大就整个模型就会越趋近稀疏。
2:不同力度的channel pruning也会伴随着精度损失的不同

注意事项:

  1. pruning后的channel尽量控制在64的倍数(tensor core的使用需要tensor size为8或者16的倍数)
  2. 若需要大力度剪枝。则需要做sensitive analysis,敏感度较低则可以尝试做大力度pruning
  • 21
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值