GPU性能优化:展开归约压榨最后一滴性能

目录

背景

分支

展开循环

展开归约

完全展开的归约

总结


背景

通过减少循环的开销(分支指令和循环计数器维护)以及增大单次处理的数据量,来榨干 GPU 的最后一滴性能。

分支

在CUDA/GPU语境下,"分支(branch)"指的是程序执行路径出现选择的地方,本质上对应到SASS(GPU的汇编指令)层面,就是各种条件跳转指令。常见会产生分支的C++/CUDA源码结构包括:

if (condition) { ... }              // 最常见
if (condition) { ... } else { ... }
for (...) { ... }                   // 循环本身也是分支(每次判断是否继续循环)
while (...) { ... }
switch (...) { ... }
condition ? a : b;                  // 三元运算符,编译后也可能是分支(也可能被优化成无分支的select指令)

理解divergence的关键,是理解GPU的**SIMT(Single Instruction, Multiple Threads)**执行模型,这和CPU的多线程完全不同。

1. Warp是最小调度单位,32个线程"绑在一起"执行同一条指令

一个warp里的32个线程,物理上共享同一个指令计数器(Program Counter),意味着这32个线程永远在执行同一条指令,不存在"warp内有的线程在执行第5条指令,有的在执行第10条指令"这种情况。

2. 遇到分支时,硬件用"谓词掩码(predicate mask)"来决定哪些线程真正生效

当warp执行到一条if (condition)对应的分支指令时:

  1. 硬件先让warp内所有32个线程都计算一遍condition这个判断表达式,得到每个线程各自的true/false结果,存进一个32位的活跃掩码(active mask),比如11110000111100001111000011110000这种位图,1表示该位置的线程这次该走if分支,0表示不该走

  2. 如果这32位全部相同(要么全是1,要么全是0)——这就是uniform分支,硬记数说"线程束没有分化"。硬件只需要按掩码结果,让整个warp要么集体执行if内的代码,要么集体跳过,只需要一次指令发射,效率最高

  3. 如果这32位有1也有0——这就是divergent分支,硬件必须做下面这件事:

    这是divergence最核心的执行机制,分两步(在较老架构上是串行的两阶段,Volta之后引入了Independent Thread Scheduling,机制更灵活,但本质开销逻辑类似):

    第一步:用当前的active mask(比如掩码里值为1的那些线程),只让这些线程真正执行if分支内的指令,而掩码为0的线程,这一轮指令周期里什么都不做(不是不存在,而是被"禁用",物理上依然占着位置,只是这条指令对它们不产生任何效果,相当于NOP)

    第二步:等第一组(掩码为1的)线程把if分支内的所有指令都执行完,硬件翻转掩码,让原本是0的那些线程(也就是该走else分支,或者该跳过if直接往后走的那些)开始执行,而原本执行过if的那些线程这次变成"禁用"状态

    这两步是串行的,不是并行的! 也就是说,一个divergent的warp,原本可能一条指令就能完成的工作,现在要花两倍(或者更多,如果分支更复杂)的执行时间,因为两条路径上的指令分别都要走一遍,只是每次只有一部分线程在真正干活,另一部分在"陪跑"等待。

分支有可能会造成线程束分化,从而指令吞吐浪费,执行时间变长,运算资源被浪费,没有跑满,会增加开销

展开循环

for (itn i=0;i<tid;i++)
{
    // to do something

}

对于这种循环是最垃圾的,一个循环造成了线程束分化,当tid=0时没有进入循环,tid=1循环一次,tid=2循环2次……tid=31循环31次

循环次数不一致,会导致严重的线程束分化:跑得快的线程要停下来等跑得慢的线程,计算资源被浪费。

那么就需要去优化,如何优化???------->>>循环展开

循环展开的核心思想是:减少循环次数,从而减少分支指令的执行次数。

比如展开前

for (int i=0; i<32; i++) {
    a[i] = b[i] + c[i];
}
  • 比较 i < 32
  • 递增 i++
  • 条件跳转回循环头部

展开后

for (int i=0; i<32; i+=4) {
    a[i+0] = b[i+0] + c[i+0];
    a[i+1] = b[i+1] + c[i+1];
    a[i+2] = b[i+2] + c[i+2];
    a[i+3] = b[i+3] + c[i+3];
}

直觉上很明显,循环控制相关的指令(比较、递增、跳转)总数减少到原来的1/4,这就是展开带来的直接收益。

在CPU上,这部分开销不是免费的——尤其是跳转指令对CPU的指令流水线不友好,每次跳转都可能引发分支预测失败的风险,一旦预测错误,CPU要清空流水线重新取指,代价相当可观。展开后跳转次数减少,这部分代价也跟着降低。

GPU上有一个CPU不那么强调的额外因素:GPU没有CPU那种复杂的分支预测器,每次循环跳转对GPU来说是相对"诚实"的开销(不像CPU能靠预测器提前猜测、隐藏跳转代价),所以减少跳转次数这件事在GPU上的收益占比,相对会比CPU上更明显一些

SM内部的指令流水线依然可以让独立指令的load操作重叠发出

  • 原始版本:发出b[i]的load → 等待数据回来 → 做加法 → store到a[i] → 进入下一轮循环,重复
  • 展开版本:可以连续发出b[i+0]b[i+1]b[i+2]b[i+3]c[i+0..3]这8条load请求,这些请求没有依赖关系,可以同时在"飞行"中,线程不需要等第一条数据回来才发第二条,等所有数据都陆续返回后再统一做4次加法

访存合并

发出来的这几条指令,同时load,如果地址相近,可以合并成一条访存指令

对于现代编译器,这种简单的代码,结构一致性的代码,就算你不是手动展开,编译器也会自动帮你展开

展开归约

基于以上的前置知识,回到上一节,我们深度聊了一些归约的问题
那么对于之前的归约,是否可以展开呢???

完全可以,如果我们把要算的算一轮呢?那这样我们的for循环是不是可以少一次,block的数量是不是可以少一半

对于之前,我们的数据是1<<24,每个block1024,那么就需要16384个block

我们的线程是一一对应数据量的,如果我们每一个线程对应两个数据量呢?那block是不是可以少一半,那调度器的压力是不是可以少一点

__global__ void reduceUnroll2(int * g_idata,int * g_odata,unsigned int n){
	unsigned int tid = threadIdx.x;
	unsigned int idx = blockDim.x*blockIdx.x*2+threadIdx.x;
	if (tid >= n) return;
	int *idata = g_idata + blockIdx.x*blockDim.x*2;
	if(idx+blockDim.x<n){
		g_idata[idx]+=g_idata[idx+blockDim.x];

	}
	__syncthreads();
	for (int stride = blockDim.x/2; stride>0 ; stride >>=1){
		if (tid <stride){
			idata[tid] += idata[tid + stride];
		}
		__syncthreads();
	}
	if (tid == 0)
		g_odata[blockIdx.x] = idata[0];

}

blockDim.x*blockIdx.x*2这里的意思就是原先一个block算一个block的数据量,但是现在一个block算两个的量,也就是数据总量就是那么多,原本一个block该负责blockDim.x个元素,现在每个block要负责blockDim.x*2个元素。,后面还是交错分配

reduceUnroll2reduceInterleaved快了将近2倍,这个提升幅度非常显著


就是因为一行简单的提前归约,导致性能快了接近2倍,为什么???

// reduceInterleaved:一个block处理blockDim.x个元素,然后开始归约
// grid.x = n / blockDim.x = 16384

// reduceUnroll2:一个block先把两份数据合并成一份,然后再用同样的归约逻辑
// grid.x = n / blockDim.x / 2 = 8192
unsigned int idx = blockDim.x * blockIdx.x * 2 + threadIdx.x;
if(idx + blockDim.x < n)
    g_idata[idx] += g_idata[idx + blockDim.x];  // 这一行是关键
__syncthreads();
// 之后的归约循环和reduceInterleaved完全一样

回看reduceInterleaved

每一轮归约之后都有一个__syncthreads(),这个同步点把所有访存的并发机会强制截断了。等所有线程都完成当前轮次的写入,才能进入下一轮的读取。
每一轮都是一个完整的"读-算-写-等待"的串行周期,上一轮和下一轮之间完全没有重叠。

每个block里面都有一个__syncthreads(),这个才是性能大杀器

grid.x = 16384个block
每个block处理1024个元素
block内部归约轮数 = log2(1024) = 10轮
每轮:load+load+加法+store+__syncthreads()

总循环轮次 = 10轮
每轮有效处理的数据范围 = 1024个元素(但每轮活跃线程数在递减)
grid.x = 8192个block(减半!)
每个block处理2048个元素
预归约1轮:load+load+加法+store+__syncthreads()
block内部归约轮数 = log2(1024) = 10轮(和之前一样!!)
总循环轮次 = 1 + 10 = 11轮
但每轮有效处理的数据范围 = 2048个元素
16384个block × 10轮 = 163840次block内同步
8192个block × 11轮 = 90112次block内同步
每个block处理数据量翻倍
        ↓
需要的block总数减半(16384→8192)
        ↓
全局总同步轮次减半(163840→90112)
        ↓
__syncthreads()的总累积开销大幅降低
        ↓
整体耗时接近减半
CPU发起kernel launch
        ↓
GigaThread Engine接收这次launch的所有block
        ↓
按照SM的资源情况(寄存器/shared memory/warp slot是否够用)
把block一批一批地分发给各个SM
        ↓
SM执行完一个block后,GigaThread Engine再补充新的block进来

reduceUnroll2把grid.x从16384减到8192,GigaThread Engine需要追踪、分发、回收的block总数减少了一半。

具体体现在:

追踪开销减少:GigaThread Engine内部维护着一张"哪些block已完成、哪些还在跑、哪些等待分发"的状态表,block数量减半,这张表的维护开销跟着减少。

分发轮次减少:RTX 4060(AD107核心)有24个SM,每个SM能同时驻留的block数有限(受寄存器和shared memory约束)。GigaThread Engine需要反复把block"喂"给SM,block总数减半意味着需要喂的轮次也跟着减少,调度器的工作量直接降低。

block完成通知减少:每个block执行完毕后,SM要通知GigaThread Engine"这个block跑完了,来新的",这个通知本身也有开销,16384次通知变成8192次通知,开销减半。

  • block数量减半 → 同等SM资源下调度压力减半
  • 总同步轮次从163840降到90112,降幅约45%
  • 加上block减少带来的launch和调度开销降低

__syncthreads()减少(总同步轮次降低)
        ↓
kernel总执行时间缩短(实测从0.00113201→0.00065589,快了约1.7倍)
        ↓
搬运的数据总量基本不变(归约的数据量没变,sector/request=3.77≈3.58,访存模式基本一致)
        ↓
相同数据量 ÷ 更短时间 = 单位时间内搬运更多数据
        ↓
带宽利用率从42.17%升到71.50%

那么如果我们是提前一个block算四份数据呢?如果算八份数据呢?效率会不会提升???

可以理论分析一下,如果是四份的话,那就是block减少到原来的1/4,数量是4096

那__syncthreads()=11*4096=45056,相较于一开始的降72.5%,那性能时间应该会提升个四倍左右?

算8份数据的__syncthreads()=11*2048=22528,降86.25%,但是应该不会提升这么大,因为你还需要并行啊,需要多个block去填满SM,否则如果计算资源有空闲,你让每个block的计算很重,达不到轻量并行效果,所以性能的提升不是线性的,而是一个先突增然后缓慢增加,越来越慢

reduceInterleaved:  0.00113392
展开2:              0.00062298  (相比Interleaved快了约1.82倍)
展开4:              0.00050401  (相比展开2快了约1.24倍)
展开8:              0.00045585  (相比展开4快了约1.11倍)

可以看到性能的提升并不是线性的,边际收益在快速衰减。

展开倍数继续增大
        ├── 预归约阶段的额外开销越来越重(7行→15行→31行...指数增长)
        ├── 每个线程需要的寄存器数量增多(要暂存更多中间值)→ 寄存器溢出到local memory → 访存开销暴增
        ├── grid.x减小到接近SM数量级别 → SM开始"吃不饱" → 延迟无法被掩盖
        └── 带宽利用率已经接近硬件上限 → 继续优化访存没有空间

接下来我们展开大一点看一下

实际的实验结果要看你当时的环境,这个有点像一个效率上升到最大,后面反而降下来了,主要还是得看的你数据量啊数据计算的难易程度啊之类的,要结合实际去分析

这里博主猜想会不会是因为单个block过于重,所以导致性能曲线是先增大后减小呢???

博主实力还不够,可能这块知识只是猜想,后期如果能力上来在回来验证

完全展开的归约

既然可以把前面的展开,那后面是不是也可以展开,当一个block只剩下64个数据块的时候,这部分是不是可以展开?

__global__ void reduceUnrollWarp8(int * g_idata,int * g_odata,unsigned int n){
	unsigned int tid = threadIdx.x;
	unsigned int idx = blockDim.x*blockIdx.x*8+threadIdx.x;
	if (tid >= n) return;
	int *idata = g_idata + blockIdx.x*blockDim.x*8;
	if(idx + 7*blockDim.x < n){
    g_idata[idx] = g_idata[idx]
                 + g_idata[idx + blockDim.x]
                 + g_idata[idx + 2*blockDim.x]
                 + g_idata[idx + 3*blockDim.x]
                 + g_idata[idx + 4*blockDim.x]
                 + g_idata[idx + 5*blockDim.x]
                 + g_idata[idx + 6*blockDim.x]
                 + g_idata[idx + 7*blockDim.x];
    }
	__syncthreads();
	for (int stride = blockDim.x/2; stride>32 ; stride >>=1){
		if (tid <stride){
			idata[tid] += idata[tid + stride];
		}
		__syncthreads();
	}
    //做最后64数据块的归约
    if(tid<32){
		volatile int *vmem = idata;
		vmem[tid]+=vmem[tid+32];
		vmem[tid]+=vmem[tid+16];
		vmem[tid]+=vmem[tid+8];
		vmem[tid]+=vmem[tid+4];
		vmem[tid]+=vmem[tid+2];
		vmem[tid]+=vmem[tid+1];

	}
	if (tid == 0)
		g_odata[blockIdx.x] = idata[0];

}

注意不一样的地方是for循环的结束条件是stride>32,那么就会剩下64个数据块没有被归约

volatile

在c语言当中我们讲过volatile关键字,这个关键字就是可见性,强制要求编译器每次读写某个数据时,不能从寄存器当中读写,而是必须从内存当中读写

因为如果编译器认为你这个线程反复读写同一个地址vmem[tid],优化在寄存器当中,导致数据没有刷新到内存,那么后续别的线程在用到上一步的中间结果的时候,会导致内存当中的数据是旧的,因为寄存器是私有的,不加绝对会出错

tid=16写vmem[16]
    ↓ volatile强制写到L1,不留在寄存器里
L1 Cache(SM内所有线程共享)
    ↑ volatile强制从L1读,不用寄存器里的缓存值
tid=0读vmem[16]  → 拿到最新值 ✓

__syncthreads(),它本身就是一个内存屏障(memory barrier),会强制所有线程在继续执行之前把所有写操作刷新到内存,并且让所有后续读操作能看到最新的值。所以有__syncthreads()的轮次,编译器的寄存器缓存优化不会造成跨线程的可见性问题,不需要volatile。

很明显最后几轮是可以减少__syncthreads()的数量的,每个block减少了6轮,那总共有2048个block,又减少了12288个__syncthreads()

__syncthreads() 为什么开销大:因为 Block 内的 Warp 可能分布在同一个 SM 的不同子分区。__syncthreads() 需要跨子分区协调,让不同子分区的所有 Warp 都到达同步点,这涉及全局的通信和等待,比子分区内部的 Warp 切换开销大得多。

分析到这里,我们似乎没有看过指标,通过网络上的查询

smsp__warp_issue_stalled_barrier_per_warp_active.pct
==PROF== Disconnected from process 5419
[5419] main@127.0.0.1
  warmUp(int *, int *, unsigned int) (16384, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        18.52
    ---------------------------------------------------- ----------- ------------

  reduceNeighbored(int *, int *, unsigned int) (16384, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        18.54
    ---------------------------------------------------- ----------- ------------

  reduceNeighboredLess(int *, int *, unsigned int) (16384, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        52.98
    ---------------------------------------------------- ----------- ------------

  reduceInterleaved(int *, int *, unsigned int) (16384, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        45.47
    ---------------------------------------------------- ----------- ------------

  reduceUnroll2(int *, int *, unsigned int) (8192, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        32.71
    ---------------------------------------------------- ----------- ------------

  reduceUnroll4(int *, int *, unsigned int) (4096, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        26.98
    ---------------------------------------------------- ----------- ------------

  reduceUnroll8(int *, int *, unsigned int) (2048, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        14.12
    ---------------------------------------------------- ----------- ------------

  reduceUnrollWarp8(int *, int *, unsigned int) (2048, 1, 1)x(1024, 1, 1), Context 1, Stream 7, Device 0, CC 8.9
    Section: Command line profiler metrics
    ---------------------------------------------------- ----------- ------------
    Metric Name                                          Metric Unit Metric Value
    ---------------------------------------------------- ----------- ------------
    smsp__warp_issue_stalled_barrier_per_warp_active.pct           %        11.33
    ---------------------------------------------------- ----------- ------------

 reduceNeighboredLess这个为什么最高,消除divergence,但barrier暴涨,而且工作的线程聚集在前面

warp0~15:在认真做归约计算,执行时间相对较长
warp16~31:if(index<blockDim.x)不满足,直接跳到__syncthreads()等待

结果:warp16~31早早到达barrier,
      warp0~15还在慢慢算,
      所有warp都要等warp0~15最慢的那个完成,
      等待时间比所有warp均匀工作时更长

工作分配不均匀,导致"快的线程等慢的线程"的时间变长,每次__syncthreads()的实际等待时间比reduceNeighbored更久,即使调用次数一样,stall占比反而更高。

reduceInterleaved(45.47%)比reduceNeighboredLess(52.98%)好是因为你采用了交错分配,更快的完成了任务,导致等待的时间就少了,所以占比肯定上去了

这印证了一个重要结论:barrier stall高低,不只取决于__syncthreads()调用次数,更取决于block内各warp到达barrier时的时间差异——差异越大,先到的warp等待时间越长,stall越高。

但是接下来的趋势大家的类似初始条件相同,但一直因为展开,所以减少了__syncthreads()

这样完成的更快,展开系列通过减少block总数、降低总同步轮次 barrier stall持续下降(45.47%→32.71%→26.98%→14.12%)

reduceUnrollWarp8进一步消除最后6轮的__syncthreads() barrier stall降至最低(11.33%)

更进一步的,我们将中间的循环展开

注意之前的最后一轮展开,是在一个线程束当中,所以不用__syncthreads(),而我们把for循环展开,一定要加__syncthreads(),不然肯定出错

__global__ void reduceCompleteUnrollWarp8(int * g_idata,int * g_odata,unsigned int n){
	unsigned int tid = threadIdx.x;
	unsigned int idx = blockDim.x*blockIdx.x*8+threadIdx.x;
	if (idx >= n) return;
	int *idata = g_idata + blockIdx.x*blockDim.x*8;
	if(idx + 7*blockDim.x < n){
    g_idata[idx] = g_idata[idx]
                 + g_idata[idx + blockDim.x]
                 + g_idata[idx + 2*blockDim.x]
                 + g_idata[idx + 3*blockDim.x]
                 + g_idata[idx + 4*blockDim.x]
                 + g_idata[idx + 5*blockDim.x]
                 + g_idata[idx + 6*blockDim.x]
                 + g_idata[idx + 7*blockDim.x];
    }
	__syncthreads();
	if(blockDim.x>=1024 && tid <512)
		idata[tid]+=idata[tid+512];
	__syncthreads();
	if(blockDim.x>=512 && tid <256)
		idata[tid]+=idata[tid+256];
	__syncthreads();
	if(blockDim.x>=256 && tid <128)
		idata[tid]+=idata[tid+128];
	__syncthreads();
	if(blockDim.x>=128 && tid <64)
		idata[tid]+=idata[tid+64];
	__syncthreads();

    //做最后64数据块的归约
    if(tid<32){
		volatile int *vmem = idata;
		//int *vmem = idata;

		vmem[tid]+=vmem[tid+32];
		vmem[tid]+=vmem[tid+16];
		vmem[tid]+=vmem[tid+8];
		vmem[tid]+=vmem[tid+4];
		vmem[tid]+=vmem[tid+2];
		vmem[tid]+=vmem[tid+1];

	}
	if (tid == 0)
		g_odata[blockIdx.x] = idata[0];

}

实验结果

这里的优化在于把中间的for循环展开,写成了一个一个的if语句

对于if语句:if(blockDim.x>=1024 && tid <512)

这种会在编译器完全确定,blockDim.x是编译期已知的常量(kernel launch时确定,编译器可以在编译时就知道这个值),所以这些if条件在编译阶段就会被判断,不会出现在运行时的指令流里

对于blockDim.x=1024的时候,直接优化

idata[tid]+=idata[tid+512]; __syncthreads();
idata[tid]+=idata[tid+256]; __syncthreads();
idata[tid]+=idata[tid+128]; __syncthreads();
idata[tid]+=idata[tid+64];  __syncthreads();

当你blockDIm.x=512的时候,直接优化

idata[tid]+=idata[tid+256]; __syncthreads();
idata[tid]+=idata[tid+128]; __syncthreads();
idata[tid]+=idata[tid+64];  __syncthreads();

没有任何循环跳转、没有循环计数器、没有循环条件判断,是完全展开的线性指令序列

总结

版本新增的优化点
reduceInterleaved解决divergence,访存合并改善
reduceUnroll2/4/8预归约减少block总数,总同步__syncthreads()轮次降低
reduceUnrollWarp8最后6轮去掉__syncthreads(),用volatile代替
reduceCompleteUnrollWarp8block内归约循环彻底展开为编译期常量判断的线性序列,消除循环控制开销+让编译器做更激进的指令调度
注意有时候性能提升很少,绝大多数是因为编译器进化的问题,编译器帮你把绝大多数能够优化的提前优化了,而不需要你显示的去优化,所以有时候实验的结果不太和理论分析一样,这取决于硬件,取决于实验的环境,取决于编译器等等
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值