Pytorch實現卷積神經網絡訓練量化(QAT)

1. 前言

深度學習在移動端的應用愈來愈普遍,而移動端相對於GPU服務來說算力較低而且存儲空間也相對較小。基於這一點咱們須要爲移動端定製一些深度學習網絡來知足咱們的平常續需求,例如SqueezeNet,MobileNet,ShuffleNet等輕量級網絡就是專爲移動端設計的。但除了在網絡方面進行改進,模型剪枝和量化應該算是最經常使用的優化方法了。剪枝就是將訓練好的「大模型」的不重要的通道刪除掉,在幾乎不影響準確率的條件下對網絡進行加速。而量化就是將浮點數(高精度)表示的權重和偏置用低精度整數(經常使用的有INT8)來近似表示,在量化到低精度以後就能夠應用移動平臺上的優化技術如NEON對計算過程進行加速,而且原始模型量化後的模型容量也會減小,使其可以更好的應用到移動端環境。但須要注意的問題是,將高精度模型量化到低精度必然會存在一個精度降低的問題,如何獲取性能和精度的TradeOff很關鍵。html

這篇文章是介紹使用Pytorch復現這篇論文:https://arxiv.org/abs/1806.08342 的一些細節並給出一些自測實驗結果。注意,代碼實現的是「Quantization Aware Training」 ,然後量化 「Post Training Quantization」 後面可能會再單獨講一下。代碼實現是來自666DZY666博主實現的https://github.com/666DZY666/model-compressionnode

2. 對稱量化

在上次的視頻中梁德澎做者已經將這些概念講得很是清楚了,若是不肯意看文字表述能夠移步到這個視頻連接下觀看視頻:深度學習量化技術科普 。而後直接跳到第四節,但爲了保證本次故事的完整性,我仍是會介紹一下這兩種量化方式。ios

對稱量化的量化公式以下:git

對稱量化量化公式

其中 表示量化的縮放因子, 分別表示量化前和量化後的數值。這裏經過除以縮放因子接取整操做就把原始的浮點數據量化到了一個小區間中,好比對於「有符號的8Bit」  就是 (無符號就是0到255了)。github

這裏有個Trick,即對於權重是量化到 ,這是爲了累加的時候減小溢出的風險。web

由於8bit的取值區間是[-2^7, 2^7-1],兩個8bit相乘以後取值區間是 (-2^14,2^14],累加兩次就到了(-2^15,2^15],因此最多隻能累加兩次並且第二次也有溢出風險,好比相鄰兩次乘法結果都剛好是2^14會超過2^15-1(int16正數可表示的最大值)。算法

因此把量化以後的權值限制在(-127,127)之間,那麼一次乘法運算獲得結果永遠會小於-128*-128 = 2^14性能優化

對應的反量化公式爲:微信

對稱量化的反量化公式

即將量化後的值乘以 就獲得了反量化的結果,固然這個過程是有損的,以下圖所示,橙色線表示的就是量化前的範圍 ,而藍色線表明量化後的數據範圍 ,注意權重取 網絡

量化和反量化的示意圖

咱們看一下上面橙色線的第 「黑色圓點對應的float32值」,將其除以縮放係數就量化爲了一個在 之間的值,而後取整以後就是 ,若是是反量化就乘以縮放因子返回上面的「第 個黑色圓點」 ,用這個數去代替之前的數繼續作網絡的Forward。

那麼這個縮放係數 是怎麼取的呢?以下式:

縮放係數Delta

3. 非對稱量化

非對稱量化相比於對稱量化就在於多了一個零點偏移。一個float32的浮點數非對稱量化到一個int8的整數(若是是有符號就是 ,若是是無符號就是 )的步驟爲 縮放,取整,零點偏移,和溢出保護,以下圖所示:

白皮書非對稱量化過程
對於8Bit無符號整數Nlevel的取值

而後縮放係數 和零點偏移的計算公式以下:

4. 中部小結

將上面兩種算法直接應用到各個網絡上進行量化後(訓練後量化PTQ)測試模型的精度結果以下:

紅色部分即將上面兩種量化算法應用到各個網絡上作精度測試結果

5. 訓練模擬量化

咱們要在網絡訓練的過程當中模型量化這個過程,而後網絡分前向和反向兩個階段,前向階段的量化就是第二節和第三節的內容。不過須要特別注意的一點是對於縮放因子的計算,權重和激活值的計算方法如今不同了。

對於權重縮放因子仍是和第2,3節的一致,即:

weight scale = max(abs(weight)) / 127

可是對於激活值的縮放因子計算就再也不是簡單的計算最大值,而是在訓練過程當中經過滑動平均(EMA)的方式去統計這個量化範圍,更新的公式以下:

moving_max = moving_max * momenta + max(abs(activation)) * (1- momenta)

其中,momenta取接近1的數就能夠了,在後面的Pytorch實驗中取0.99,而後縮放因子:

activation scale = moving_max /128

而後反向傳播階段求梯度的公式以下:

QAT反向傳播階段求梯度的公式

咱們在反向傳播時求得的梯度是模擬量化以後權值的梯度,用這個梯度去更新量化前的權值。

這部分的代碼以下,注意咱們這個實驗中是用float32來模擬的int8,不具備真實的板端加速效果,只是爲了驗證算法的可行性:

class Quantizer(nn.Module):
    def __init__(self, bits, range_tracker):
        super().__init__()
        self.bits = bits
        self.range_tracker = range_tracker
        self.register_buffer('scale'None)      # 量化比例因子
        self.register_buffer('zero_point'None# 量化零點

    def update_params(self):
        raise NotImplementedError

    # 量化
    def quantize(self, input):
        output = input * self.scale - self.zero_point
        return output

    def round(self, input):
        output = Round.apply(input)
        return output

    # 截斷
    def clamp(self, input):
        output = torch.clamp(input, self.min_val, self.max_val)
        return output

    # 反量化
    def dequantize(self, input):
        output = (input + self.zero_point) / self.scale
        return output

    def forward(self, input):
        if self.bits == 32:
            output = input
        elif self.bits == 1:
            print('!Binary quantization is not supported !')
            assert self.bits != 1
        else:
            self.range_tracker(input)
            self.update_params()
            output = self.quantize(input)   # 量化
            output = self.round(output)
            output = self.clamp(output)     # 截斷
            output = self.dequantize(output)# 反量化
        return output

6. 代碼實現

基於https://github.com/666DZY666/model-compression/blob/master/quantization/WqAq/IAO/models/util_wqaq.py 進行實驗,這裏實現了對稱和非對稱量化兩種方案。須要注意的細節是,對於權值的量化須要分通道進行求取縮放因子,而後對於激活值的量化總體求一個縮放因子,這樣效果最好(論文中提到)。

這部分的代碼實現以下:

# ********************* range_trackers(範圍統計器,統計量化前範圍) *********************
class RangeTracker(nn.Module):
    def __init__(self, q_level):
        super().__init__()
        self.q_level = q_level

    def update_range(self, min_val, max_val):
        raise NotImplementedError

    @torch.no_grad()
    def forward(self, input):
        if self.q_level == 'L':    # A,min_max_shape=(1, 1, 1, 1),layer級
            min_val = torch.min(input)
            max_val = torch.max(input)
        elif self.q_level == 'C':  # W,min_max_shape=(N, 1, 1, 1),channel級
            min_val = torch.min(torch.min(torch.min(input, 3, keepdim=True)[0], 2, keepdim=True)[0], 1, keepdim=True)[0]
            max_val = torch.max(torch.max(torch.max(input, 3, keepdim=True)[0], 2, keepdim=True)[0], 1, keepdim=True)[0]
            
        self.update_range(min_val, max_val)
class GlobalRangeTracker(RangeTracker):  # W,min_max_shape=(N, 1, 1, 1),channel級,取本次和以前相比的min_max —— (N, C, W, H)
    def __init__(self, q_level, out_channels):
        super().__init__(q_level)
        self.register_buffer('min_val', torch.zeros(out_channels, 111))
        self.register_buffer('max_val', torch.zeros(out_channels, 111))
        self.register_buffer('first_w', torch.zeros(1))

    def update_range(self, min_val, max_val):
        temp_minval = self.min_val
        temp_maxval = self.max_val
        if self.first_w == 0:
            self.first_w.add_(1)
            self.min_val.add_(min_val)
            self.max_val.add_(max_val)
        else:
            self.min_val.add_(-temp_minval).add_(torch.min(temp_minval, min_val))
            self.max_val.add_(-temp_maxval).add_(torch.max(temp_maxval, max_val))
class AveragedRangeTracker(RangeTracker):  # A,min_max_shape=(1, 1, 1, 1),layer級,取running_min_max —— (N, C, W, H)
    def __init__(self, q_level, momentum=0.1):
        super().__init__(q_level)
        self.momentum = momentum
        self.register_buffer('min_val', torch.zeros(1))
        self.register_buffer('max_val', torch.zeros(1))
        self.register_buffer('first_a', torch.zeros(1))

    def update_range(self, min_val, max_val):
        if self.first_a == 0:
            self.first_a.add_(1)
            self.min_val.add_(min_val)
            self.max_val.add_(max_val)
        else:
            self.min_val.mul_(1 - self.momentum).add_(min_val * self.momentum)
            self.max_val.mul_(1 - self.momentum).add_(max_val * self.momentum)

其中self.register_buffer這行代碼能夠在內存中定一個常量,同時,模型保存和加載的時候能夠寫入和讀出,即這個變量不會參與反向傳播。

pytorch通常狀況下,是將網絡中的參數保存成orderedDict形式的,這裏的參數其實包含兩種,一種是模型中各類module含的參數,即nn.Parameter,咱們固然能夠在網絡中定義其餘的nn.Parameter參數,另外一種就是buffer,前者每次optim.step會獲得更新,而不會更新後者。

另外,因爲卷積層後面常常會接一個BN層,而且在前向推理時爲了加速常常把BN層的參數融合到卷積層的參數中,因此訓練模擬量化也要按照這個流程。即,咱們首先須要把BN層的參數和卷積層的參數融合,而後再對這個參數作量化,具體過程能夠借用德澎的這頁PPT來講明:

Made By 梁德澎

所以,代碼實現包含兩個版本,一個是不融合BN的訓練模擬量化,一個是融合BN的訓練模擬量化,而關於爲何融合以後是上圖這樣的呢?請看下面的公式:

因此:

公式中的, 分別表示卷積層的權值與偏置, 分別爲卷積層的輸入與輸出,則根據 的計算公式,能夠推出融合了batchnorm參數以後的權值與偏置,

未融合BN的訓練模擬量化代碼實現以下(帶註釋):

# ********************* 量化卷積(同時量化A/W,並作卷積) *********************
class Conv2d_Q(nn.Conv2d):
    def __init__(
        self,
        in_channels,
        out_channels,
        kernel_size,
        stride=1,
        padding=0,
        dilation=1,
        groups=1,
        bias=True,
        a_bits=8,
        w_bits=8,
        q_type=1,
        first_layer=0,
    )
:

        super().__init__(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=kernel_size,
            stride=stride,
            padding=padding,
            dilation=dilation,
            groups=groups,
            bias=bias
        )
        # 實例化量化器(A-layer級,W-channel級)
        if q_type == 0:
            self.activation_quantizer = SymmetricQuantizer(bits=a_bits, range_tracker=AveragedRangeTracker(q_level='L'))
            self.weight_quantizer = SymmetricQuantizer(bits=w_bits, range_tracker=GlobalRangeTracker(q_level='C', out_channels=out_channels))
        else:
            self.activation_quantizer = AsymmetricQuantizer(bits=a_bits, range_tracker=AveragedRangeTracker(q_level='L'))
            self.weight_quantizer = AsymmetricQuantizer(bits=w_bits, range_tracker=GlobalRangeTracker(q_level='C', out_channels=out_channels))
        self.first_layer = first_layer

    def forward(self, input):
        # 量化A和W
        if not self.first_layer:
            input = self.activation_quantizer(input)
        q_input = input
        q_weight = self.weight_quantizer(self.weight) 
        # 量化卷積
        output = F.conv2d(
            input=q_input,
            weight=q_weight,
            bias=self.bias,
            stride=self.stride,
            padding=self.padding,
            dilation=self.dilation,
            groups=self.groups
        )
        return output

而考慮了摺疊BN的代碼實現以下(帶註釋):

def reshape_to_activation(input):
  return input.reshape(1-111)
def reshape_to_weight(input):
  return input.reshape(-1111)
def reshape_to_bias(input):
  return input.reshape(-1)
# ********************* bn融合_量化卷積(bn融合後,同時量化A/W,並作卷積) *********************
class BNFold_Conv2d_Q(Conv2d_Q):
    def __init__(
        self,
        in_channels,
        out_channels,
        kernel_size,
        stride=1,
        padding=0,
        dilation=1,
        groups=1,
        bias=False,
        eps=1e-5,
        momentum=0.01, # 考慮量化帶來的抖動影響,對momentum進行調整(0.1 ——> 0.01),削弱batch統計參數佔比,必定程度抑制抖動。經實驗量化訓練效果更好,acc提高1%左右
        a_bits=8,
        w_bits=8,
        q_type=1,
        first_layer=0,
    )
:

        super().__init__(
            in_channels=in_channels,
            out_channels=out_channels,
            kernel_size=kernel_size,
            stride=stride,
            padding=padding,
            dilation=dilation,
            groups=groups,
            bias=bias
        )
        self.eps = eps
        self.momentum = momentum
        self.gamma = Parameter(torch.Tensor(out_channels))
        self.beta = Parameter(torch.Tensor(out_channels))
        self.register_buffer('running_mean', torch.zeros(out_channels))
        self.register_buffer('running_var', torch.ones(out_channels))
        self.register_buffer('first_bn', torch.zeros(1))
        init.uniform_(self.gamma)
        init.zeros_(self.beta)
        
        # 實例化量化器(A-layer級,W-channel級)
        if q_type == 0:
            self.activation_quantizer = SymmetricQuantizer(bits=a_bits, range_tracker=AveragedRangeTracker(q_level='L'))
            self.weight_quantizer = SymmetricQuantizer(bits=w_bits, range_tracker=GlobalRangeTracker(q_level='C', out_channels=out_channels))
        else:
            self.activation_quantizer = AsymmetricQuantizer(bits=a_bits, range_tracker=AveragedRangeTracker(q_level='L'))
            self.weight_quantizer = AsymmetricQuantizer(bits=w_bits, range_tracker=GlobalRangeTracker(q_level='C', out_channels=out_channels))
        self.first_layer = first_layer

    def forward(self, input):
        # 訓練態
        if self.training:
            # 先作普通卷積獲得A,以取得BN參數
            output = F.conv2d(
                input=input,
                weight=self.weight,
                bias=self.bias,
                stride=self.stride,
                padding=self.padding,
                dilation=self.dilation,
                groups=self.groups
            )
            # 更新BN統計參數(batch和running)
            dims = [dim for dim in range(4if dim != 1]
            batch_mean = torch.mean(output, dim=dims)
            batch_var = torch.var(output, dim=dims)
            with torch.no_grad():
                if self.first_bn == 0:
                    self.first_bn.add_(1)
                    self.running_mean.add_(batch_mean)
                    self.running_var.add_(batch_var)
                else:
                    self.running_mean.mul_(1 - self.momentum).add_(batch_mean * self.momentum)
                    self.running_var.mul_(1 - self.momentum).add_(batch_var * self.momentum)
            # BN融合
            if self.bias is not None:  
              bias = reshape_to_bias(self.beta + (self.bias -  batch_mean) * (self.gamma / torch.sqrt(batch_var + self.eps)))
            else:
              bias = reshape_to_bias(self.beta - batch_mean  * (self.gamma / torch.sqrt(batch_var + self.eps)))# b融batch
            weight = self.weight * reshape_to_weight(self.gamma / torch.sqrt(self.running_var + self.eps))     # w融running
        # 測試態
        else:
            #print(self.running_mean, self.running_var)
            # BN融合
            if self.bias is not None:
              bias = reshape_to_bias(self.beta + (self.bias - self.running_mean) * (self.gamma / torch.sqrt(self.running_var + self.eps)))
            else:
              bias = reshape_to_bias(self.beta - self.running_mean * (self.gamma / torch.sqrt(self.running_var + self.eps)))  # b融running
            weight = self.weight * reshape_to_weight(self.gamma / torch.sqrt(self.running_var + self.eps))  # w融running
        
        # 量化A和bn融合後的W
        if not self.first_layer:
            input = self.activation_quantizer(input)
        q_input = input
        q_weight = self.weight_quantizer(weight) 
        # 量化卷積
        if self.training:  # 訓練態
          output = F.conv2d(
              input=q_input,
              weight=q_weight,
              bias=self.bias,  # 注意,這裏不加bias(self.bias爲None)
              stride=self.stride,
              padding=self.padding,
              dilation=self.dilation,
              groups=self.groups
          )
          # (這裏將訓練態下,卷積中w融合running參數的效果轉爲融合batch參數的效果)running ——> batch
          output *= reshape_to_activation(torch.sqrt(self.running_var + self.eps) / torch.sqrt(batch_var + self.eps))
          output += reshape_to_activation(bias)
        else:  # 測試態
          output = F.conv2d(
              input=q_input,
              weight=q_weight,
              bias=bias,  # 注意,這裏加bias,作完整的conv+bn
              stride=self.stride,
              padding=self.padding,
              dilation=self.dilation,
              groups=self.groups
          )
        return output

注意一個點,在訓練的時候bias設置爲None,即訓練的時候不量化bias

7. 實驗結果

在CIFAR10作Quantization Aware Training實驗,網絡結構爲:

import torch
import torch.nn as nn
import torch.nn.functional as F
from .util_wqaq import Conv2d_Q, BNFold_Conv2d_Q

class QuanConv2d(nn.Module):
    def __init__(self, input_channels, output_channels,
            kernel_size=-1, stride=-1, padding=-1, groups=1, last_relu=0, abits=8, wbits=8, bn_fold=0, q_type=1, first_layer=0)
:

        super(QuanConv2d, self).__init__()
        self.last_relu = last_relu
        self.bn_fold = bn_fold
        self.first_layer = first_layer

        if self.bn_fold == 1:
            self.bn_q_conv = BNFold_Conv2d_Q(input_channels, output_channels,
                    kernel_size=kernel_size, stride=stride, padding=padding, groups=groups, a_bits=abits, w_bits=wbits, q_type=q_type, first_layer=first_layer)
        else:
            self.q_conv = Conv2d_Q(input_channels, output_channels,
                    kernel_size=kernel_size, stride=stride, padding=padding, groups=groups, a_bits=abits, w_bits=wbits, q_type=q_type, first_layer=first_layer)
            self.bn = nn.BatchNorm2d(output_channels, momentum=0.01# 考慮量化帶來的抖動影響,對momentum進行調整(0.1 ——> 0.01),削弱batch統計參數佔比,必定程度抑制抖動。經實驗量化訓練效果更好,acc提高1%左右
        self.relu = nn.ReLU(inplace=True)

    def forward(self, x):
        if not self.first_layer:
            x = self.relu(x)
        if self.bn_fold == 1:
            x = self.bn_q_conv(x)
        else:
            x = self.q_conv(x)
            x = self.bn(x)
        if self.last_relu:
            x = self.relu(x)
        return x

class Net(nn.Module):
    def __init__(self, cfg = None, abits=8, wbits=8, bn_fold=0, q_type=1):
        super(Net, self).__init__()
        if cfg is None:
            cfg = [19216096192192192192192]
        # model - A/W全量化(除輸入、輸出外)
        self.quan_model = nn.Sequential(
                QuanConv2d(3, cfg[0], kernel_size=5, stride=1, padding=2, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type, first_layer=1),
                QuanConv2d(cfg[0], cfg[1], kernel_size=1, stride=1, padding=0, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                QuanConv2d(cfg[1], cfg[2], kernel_size=1, stride=1, padding=0, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
                
                QuanConv2d(cfg[2], cfg[3], kernel_size=5, stride=1, padding=2, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                QuanConv2d(cfg[3], cfg[4], kernel_size=1, stride=1, padding=0, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                QuanConv2d(cfg[4], cfg[5], kernel_size=1, stride=1, padding=0, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                nn.MaxPool2d(kernel_size=3, stride=2, padding=1),
                
                QuanConv2d(cfg[5], cfg[6], kernel_size=3, stride=1, padding=1, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                QuanConv2d(cfg[6], cfg[7], kernel_size=1, stride=1, padding=0, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                QuanConv2d(cfg[7], 10, kernel_size=1, stride=1, padding=0, last_relu=1, abits=abits, wbits=wbits, bn_fold=bn_fold, q_type=q_type),
                nn.AvgPool2d(kernel_size=8, stride=1, padding=0),
                )

    def forward(self, x):
        x = self.quan_model(x)
        x = x.view(x.size(0), -1)
        return x

訓練Epoch數爲30,學習率調整策略爲:

def adjust_learning_rate(optimizer, epoch):
    if args.bn_fold == 1:
        if args.model_type == 0:
            update_list = [121525]
        else:
            update_list = [8122025]
    else:
        update_list = [151720]
    if epoch in update_list:
        for param_group in optimizer.param_groups:
            param_group['lr'] = param_group['lr'] * 0.1
    return
類型 Acc 備註
原模型(nin) 91.01% 全精度
對稱量化, bn不融合 88.88% INT8
對稱量化,bn融合 86.66% INT8
非對稱量化,bn不融合 88.89% INT8
非對稱量化,bn融合 87.30% INT8

如今不清楚爲何量化後的精度損失了1-2個點,根據德澎在MxNet的實驗結果來看,分類任務不會損失精度,因此不知道這個代碼是否存在問題,有經驗的大佬歡迎來指出問題。

而後白皮書上提供的一些分類網絡的訓練模擬量化精度狀況以下:

QAT方式明顯好於Post Train Quantzation

注意前面有一些精度幾乎爲0的數據是由於MobileNet訓練出來以後某些層的權重很是接近0,使用訓練後量化方法以後權重也爲0,這就致使推理後結果徹底錯誤。

8. 總結

今天介紹了一下基於Pytorch實現QAT量化,並用一個小網絡測試了一下效果,但比較遺憾的是並無得到論文中那麼理想的數據,仍須要進一步研究。


福利時間:

本次聯合【機械工業出版社華章公司】爲你們帶來3本正版新書。在下方留言板留言,7月30日0點前,BBuf會從留言區挑選三名公衆號常讀用戶分別送出一本書籍。沒中獎的讀者也能夠掃描下方海報的二維碼購買。

戳這裏留言

今天京東購書5折優惠,這本書原價89元,今天掃描下方海報的二維碼購買,只要44.05元。很是划算。不管你是想要入門OpenCV的學生或初學者,仍是想要進階提高技術水平的算法工程師或圖像視頻開發人員,都推薦你購買閱讀。

推薦閱讀:

《OpenCV深度學習應用與性能優化實踐》

Intel與阿里巴巴高級圖形圖像專家聯合撰寫!深刻解析OpenCV DNN 模塊、基於GPU/CPU的加速實現、性能優化技巧與可視化工具,以及人臉活體檢測等應用,涵蓋Intel推理引擎加速等鮮見一手深度信息。知名專家傅文慶、鄒復好、Vadim Pisarevsky、周強(CV君)聯袂推薦!

點擊「閱讀原文」,查看更多五折AI好書!

原文連接:https://pro.m.jd.com/mall/active/3SMUsbc3hV2BagYYJ3zkbMs9HaVQ/index.html?utm_source=iosapp&utm_medium=appshare&utm_campaign=t_335139774&utm_term=Wxfriends&ad_od=share


歡迎關注GiantPandaCV, 在這裏你將看到獨家的深度學習分享,堅持原創,天天分享咱們學習到的新鮮知識。( • ̀ω•́ )✧

有對文章相關的問題,或者想要加入交流羣,歡迎添加BBuf微信:

二維碼

爲了方便讀者獲取資料以及咱們公衆號的做者發佈一些Github工程的更新,咱們成立了一個QQ羣,二維碼以下,感興趣能夠加入。

公衆號QQ交流羣


本文分享自微信公衆號 - GiantPandaCV(BBuf233)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索