BaseConvolutionLayer
是所有卷積層的基類。爲什麼卷積層還需要基類呢?因爲再caffe裏除了 ConvolutionLayer
還有 DeconvolutionLayer
BaseConvolutionLayer 類
BaseConvolutionLayer 構造函數
explicit BaseConvolutionLayer(const LayerParameter& param)
: Layer<Dtype>(param) {}
因爲它是一個基類,所以這個構造函數就寫成了空的,傳入了 param
這一個參數,但是什麼都沒做,傳一個參數把方向確定了,具體內容留給子類去實現。
LayerSetUp 函數
這個 LayerSetUp
函數是很關鍵的,因爲它實現了,而且實現的非常長。
virtual void LayerSetUp(const vector<Blob<Dtype>*>& bottom,
const vector<Blob<Dtype>*>& top);
第一步首先加載我們這一個卷積的參數
ConvolutionParameter conv_param = this->layer_param_.convolution_param();
這個參數是從 layer_param_
這個變量裏來的,而這個變量裏的參數又是從哪裏來的呢?這就要從 Layer
這個類說起了,因爲我們這個BaseConvolutionLayer
是繼承的Layer
這個類。
template <typename Dtype>
class BaseConvolutionLayer : public Layer<Dtype>
所以一切的變量也是定義在Layer
這個類裏面的,我們去Layer
這個類裏面找一下。
/** The protobuf that stores the layer parameters */
LayerParameter layer_param_;
它定義的這個 LayerParameter
是在 proto
文件裏
class LayerParameter : public ::google::protobuf::Message /* @@protoc_insertion_point(class_definition:caffe.LayerParameter) */
而 convolution_param
函數把參數變量的指針返回回來,實際上就是把參數返回到了這個類裏面。
inline const ::caffe::ConvolutionParameter& LayerParameter::convolution_param() const {
// @@protoc_insertion_point(field_get:caffe.LayerParameter.convolution_param)
return convolution_param_ != NULL ? *convolution_param_ : *default_instance_->convolution_param_;
}
接着問一下是不是要強制把矩陣轉成caffe的矩陣形式,就是我們說的 im2col
force_nd_im2col_ = conv_param.force_nd_im2col();
這個 im2col
真的非常重要,我覺得這個是caffe計算部分的核心,建議大家一定要認真的學習這一塊。
接着獲取bottom的 channel_axis
channel_axis_ = bottom[0]->CanonicalAxisIndex(conv_param.axis());
這一步不是獲取 channel
的數量,而是返回 channel
在 shape
裏是第幾位,比方說 shape
是 3224224
,那麼 channel
就在第0位,這一步返回值就是0.
所以真正空間形狀的開始就是從第1位開始的,接下來這個函數就是返回的空間形狀開始的那個axis
const int first_spatial_axis = channel_axis_ + 1;
然後是總共的axis的數量
const int num_axes = bottom[0]->num_axes();
接下來是 num_spatial_axes_
,這個代表的就是真正空間的axis的數量,也就是排除了channel那一層的數量
num_spatial_axes_ = num_axes - first_spatial_axis;
檢查一下 num_spatial_axes_
是不是大於0的
CHECK_GE(num_spatial_axes_, 0);
因爲如果不是大於0的就說明空間的維度不存在,這顯然是不可能的,就會直接拋出異常。
然後聲明瞭一個變量,專門去存空間的形狀。
vector<int> spatial_dim_blob_shape(1, std::max(num_spatial_axes_, 1));
所以剛纔纔要了空間形狀的起始位置,因爲在這裏等着,準備拷貝空間形狀。
因爲卷積核的空間維度數量要和圖片是一樣的,所以要把卷積核 Reshape
了
// Setup filter kernel dimensions (kernel_shape_).
kernel_shape_.Reshape(spatial_dim_blob_shape);
注意啊,這裏是維度是一樣的,並不是形狀是一樣的,也就是說假設圖片是個2維圖片,那麼卷積核也就必須是2維的。假設圖片大小是224224,那麼卷積核是33就可以和它匹配。假設圖片是三維的,比方說224224224,那麼卷積核也就必須是三維的,比方說555.
接着準備對 kernel_shape_
賦值了
int* kernel_shape_data = kernel_shape_.mutable_cpu_data();
我們知道 mutable_cpu_data
存的是最新修改的數據的指針,所以這裏取了這個地址,就準備開始賦值。
if (conv_param.has_kernel_h() || conv_param.has_kernel_w()) {
CHECK_EQ(num_spatial_axes_, 2)
<< "kernel_h & kernel_w can only be used for 2D convolution.";
CHECK_EQ(0, conv_param.kernel_size_size())
<< "Either kernel_size or kernel_h/w should be specified; not both.";
kernel_shape_data[0] = conv_param.kernel_h();
kernel_shape_data[1] = conv_param.kernel_w();
} else {
const int num_kernel_dims = conv_param.kernel_size_size();
CHECK(num_kernel_dims == 1 || num_kernel_dims == num_spatial_axes_)
<< "kernel_size must be specified once, or once per spatial dimension "
<< "(kernel_size specified " << num_kernel_dims << " times; "
<< num_spatial_axes_ << " spatial dims).";
for (int i = 0; i < num_spatial_axes_; ++i) {
kernel_shape_data[i] =
conv_param.kernel_size((num_kernel_dims == 1) ? 0 : i);
}
}
這裏判斷了,如果是二維的話,那麼就從參數裏面讀取高和寬,如果是多維的話,就一維一維的從參數裏面去讀取。
最後檢查一下空間的維度有沒有存在0
for (int i = 0; i < num_spatial_axes_; ++i) {
CHECK_GT(kernel_shape_data[i], 0) << "Filter dimensions must be nonzero.";
}
這一步很好理解,你總不能有高或者寬是0的圖吧
接下來把 stride_
這個blob也給它 Reshape
一下,因爲這個 stride_
blob它是記錄步長的一個blob,所以它的維度也得和我們這個圖片是一致的。
// Setup stride dimensions (stride_).
stride_.Reshape(spatial_dim_blob_shape);
int* stride_data = stride_.mutable_cpu_data();
if (conv_param.has_stride_h() || conv_param.has_stride_w()) {
CHECK_EQ(num_spatial_axes_, 2)
<< "stride_h & stride_w can only be used for 2D convolution.";
CHECK_EQ(0, conv_param.stride_size())
<< "Either stride or stride_h/w should be specified; not both.";
stride_data[0] = conv_param.stride_h();
stride_data[1] = conv_param.stride_w();
} else {
const int num_stride_dims = conv_param.stride_size();
CHECK(num_stride_dims == 0 || num_stride_dims == 1 ||
num_stride_dims == num_spatial_axes_)
<< "stride must be specified once, or once per spatial dimension "
<< "(stride specified " << num_stride_dims << " times; "
<< num_spatial_axes_ << " spatial dims).";
const int kDefaultStride = 1;
for (int i = 0; i < num_spatial_axes_; ++i) {
stride_data[i] = (num_stride_dims == 0) ? kDefaultStride :
conv_param.stride((num_stride_dims == 1) ? 0 : i);
CHECK_GT(stride_data[i], 0) << "Stride dimensions must be nonzero.";
}
}
和上面的卷積核是一樣的。
然後是 pad_
,和 stride_
一模一樣,也是給他形狀賦一個值。padding就是卷積邊界處理的長度。
// Setup pad dimensions (pad_).
pad_.Reshape(spatial_dim_blob_shape);
int* pad_data = pad_.mutable_cpu_data();
if (conv_param.has_pad_h() || conv_param.has_pad_w()) {
CHECK_EQ(num_spatial_axes_, 2)
<< "pad_h & pad_w can only be used for 2D convolution.";
CHECK_EQ(0, conv_param.pad_size())
<< "Either pad or pad_h/w should be specified; not both.";
pad_data[0] = conv_param.pad_h();
pad_data[1] = conv_param.pad_w();
} else {
const int num_pad_dims = conv_param.pad_size();
CHECK(num_pad_dims == 0 || num_pad_dims == 1 ||
num_pad_dims == num_spatial_axes_)
<< "pad must be specified once, or once per spatial dimension "
<< "(pad specified " << num_pad_dims << " times; "
<< num_spatial_axes_ << " spatial dims).";
const int kDefaultPad = 0;
for (int i = 0; i < num_spatial_axes_; ++i) {
pad_data[i] = (num_pad_dims == 0) ? kDefaultPad :
conv_param.pad((num_pad_dims == 1) ? 0 : i);
}
}
還有決定這個圖將被放大多少倍的 dilation_
也是一樣的。
// Setup dilation dimensions (dilation_).
dilation_.Reshape(spatial_dim_blob_shape);
int* dilation_data = dilation_.mutable_cpu_data();
const int num_dilation_dims = conv_param.dilation_size();
CHECK(num_dilation_dims == 0 || num_dilation_dims == 1 ||
num_dilation_dims == num_spatial_axes_)
<< "dilation must be specified once, or once per spatial dimension "
<< "(dilation specified " << num_dilation_dims << " times; "
<< num_spatial_axes_ << " spatial dims).";
const int kDefaultDilation = 1;
for (int i = 0; i < num_spatial_axes_; ++i) {
dilation_data[i] = (num_dilation_dims == 0) ? kDefaultDilation :
conv_param.dilation((num_dilation_dims == 1) ? 0 : i);
}
接着就要開始轉成計算矩陣了。
首先判斷一下圖是不是11的,因爲如果是11的話,那麼這個計算就會省很多事情。
// Special case: im2col is the identity for 1x1 convolution with stride 1
// and no padding, so flag for skipping the buffer and transformation.
is_1x1_ = true;
for (int i = 0; i < num_spatial_axes_; ++i) {
is_1x1_ &=
kernel_shape_data[i] == 1 && stride_data[i] == 1 && pad_data[i] == 0;
if (!is_1x1_) { break; }
}
接着獲取 bottom
層的通道數,也就是輸入的通道數,還有 output
的數量,也就是輸出的通道數,還有 group
的數量,分 group
的目的是爲了指定哪些通道只能哪些卷積核做卷積,不相干的就不會卷積。
// Configure output channels and groups.
channels_ = bottom[0]->shape(channel_axis_);
num_output_ = this->layer_param_.convolution_param().num_output();
CHECK_GT(num_output_, 0);
group_ = this->layer_param_.convolution_param().group();
CHECK_EQ(channels_ % group_, 0);
CHECK_EQ(num_output_ % group_, 0)
<< "Number of output should be multiples of group.";
因爲分組是均分的,所以判斷了一下 group
的數量是不是能被 channel
的數量整除。
之後判斷一下輸入和輸出順序是不是設定了需要反過來。
if (reverse_dimensions()) {
conv_out_channels_ = channels_;
conv_in_channels_ = num_output_;
} else {
conv_out_channels_ = num_output_;
conv_in_channels_ = channels_;
}
如果是反捲積這種的話就要把輸入和輸出的channel給反過來了。
定義一下權值的形狀
// Handle the parameters: weights and biases.
// - blobs_[0] holds the filter weights
// - blobs_[1] holds the biases (optional)
vector<int> weight_shape(2);
weight_shape[0] = conv_out_channels_;
weight_shape[1] = conv_in_channels_ / group_;
for (int i = 0; i < num_spatial_axes_; ++i) {
weight_shape.push_back(kernel_shape_data[i]);
}
然後是 bias
,因爲這個 bias
是可以選擇開關的,所以首先判斷了一下,如果開的話就定義一下這個偏置的形狀.
bias_term_ = this->layer_param_.convolution_param().bias_term();
vector<int> bias_shape(bias_term_, num_output_);
最後檢查 blob
的 size
if (this->blobs_.size() > 0) {
CHECK_EQ(1 + bias_term_, this->blobs_.size())
<< "Incorrect number of weight blobs.";
if (weight_shape != this->blobs_[0]->shape()) {
Blob<Dtype> weight_shaped_blob(weight_shape);
LOG(FATAL) << "Incorrect weight shape: expected shape "
<< weight_shaped_blob.shape_string() << "; instead, shape was "
<< this->blobs_[0]->shape_string();
}
if (bias_term_ && bias_shape != this->blobs_[1]->shape()) {
Blob<Dtype> bias_shaped_blob(bias_shape);
LOG(FATAL) << "Incorrect bias shape: expected shape "
<< bias_shaped_blob.shape_string() << "; instead, shape was "
<< this->blobs_[1]->shape_string();
}
LOG(INFO) << "Skipping parameter initialization";
} else {
if (bias_term_) {
this->blobs_.resize(2);
} else {
this->blobs_.resize(1);
}
如果沒有開 bias
的話,那麼 blob
的size就是1,只存了權值,如果開了的話就是2,所以它和這個 1+bias_term
做比較看看是否相等。其實這樣寫是個非常差勁的寫法,很具有迷惑性,但是結果肯定是對的,因爲布爾變量爲true就是1.
然後初始化權值和bias
// Initialize and fill the weights:
// output channels x input channels per-group x kernel height x kernel width
this->blobs_[0].reset(new Blob<Dtype>(weight_shape));
shared_ptr<Filler<Dtype> > weight_filler(GetFiller<Dtype>(
this->layer_param_.convolution_param().weight_filler()));
weight_filler->Fill(this->blobs_[0].get());
// If necessary, initialize and fill the biases.
if (bias_term_) {
this->blobs_[1].reset(new Blob<Dtype>(bias_shape));
shared_ptr<Filler<Dtype> > bias_filler(GetFiller<Dtype>(
this->layer_param_.convolution_param().bias_filler()));
bias_filler->Fill(this->blobs_[1].get());
}
}
從文件中讀取權值,然後填給blob裏面。
接着獲取一下卷積核的總大小
kernel_dim_ = this->blobs_[0]->count(1);
這個 count
就是總的大小,比方說卷積核形狀是355,那麼 count
的結果就是75,而加了個參數就是從第幾位開始,那麼 count(1)
就是5*5=25
因爲第0位就是輸入的channel,這個和卷積核沒什麼關係,所以從1開始數,用來分配矩陣的大小。
接着根據分組情況找一下權值的指針偏移量,方便換組的時候知道往後數多少位
weight_offset_ = conv_out_channels_ * kernel_dim_ / group_;
最後在 blob
裏給反向傳播騰個位置就結束了
// Propagate gradients to the parameters (as directed by backward pass).
this->param_propagate_down_.resize(this->blobs_.size(), true);
Reshape 函數
virtual void Reshape(const vector<Blob<Dtype>*>& bottom,
const vector<Blob<Dtype>*>& top);
Reshape
函數傳入了兩個參數,一個 bottom
一個 top
。
一開始還是獲取了 first_spatial_axis
const int first_spatial_axis = channel_axis_ + 1;
然後開始檢查 spatial_axis
和 bottom
的num_axes
是不是一致的。
CHECK_EQ(bottom[0]->num_axes(), first_spatial_axis + num_spatial_axes_)
<< "bottom num_axes may not change.";
如果不一致的話他就會拋出異常,然後告訴你這個維度可能被改變過。
接下來獲取輸入的維度數量
num_ = bottom[0]->count(0, channel_axis_);
CHECK_EQ(bottom[0]->shape(channel_axis_), channels_)
<< "Input size incompatible with convolution kernel.";
然後寫了個循環遍歷所有的bottom
,因爲這些bottom
可能來自不同的blob
,所以我們挨個去遍歷他。
// TODO: generalize to handle inputs of different shapes.
for (int bottom_id = 1; bottom_id < bottom.size(); ++bottom_id) {
CHECK(bottom[0]->shape() == bottom[bottom_id]->shape())
<< "shape mismatch - bottom[0]: " << bottom[0]->shape_string()
<< " vs. bottom[" << bottom_id << "]: "
<< bottom[bottom_id]->shape_string();
}
如果發現有某一個bottom blob
形狀不一樣的話,它也會拋出異常,因爲這樣就沒法統一進行下去了。
然後這些檢查完畢之後,就獲取了bottom
的形狀,然後去計算輸出的形狀。
// Shape the tops.
bottom_shape_ = &bottom[0]->shape();
compute_output_shape();
它計算輸出的形狀是根據不同層使用了不同的算法的,所以這裏也沒有實現,只有一個虛函數放在這裏,我們可以來看一下。
// Compute height_out_ and width_out_ from other parameters.
virtual void compute_output_shape() = 0;
接着定義了top_shape
這個vector
,注意,這裏還是shape
的vector
,不是top
這個blob
的vector
vector<int> top_shape(bottom[0]->shape().begin(),
bottom[0]->shape().begin() + channel_axis_);
首先把輸出的個數賦值給它
top_shape.push_back(num_output_);
然後把各個維度的大小寫個循環賦值
for (int i = 0; i < num_spatial_axes_; ++i) {
top_shape.push_back(output_shape_[i]);
}
緊接着我們把傳進來的這個top
blob
挨個的reshape
成我們想要的這個shape
for (int top_id = 0; top_id < top.size(); ++top_id) {
top[top_id]->Reshape(top_shape);
}
同時這裏也是要判斷一下是不是要反過來,就是給反捲積用的,如果是的話,就要把輸出和輸入的位置交換
if (reverse_dimensions()) {
conv_out_spatial_dim_ = bottom[0]->count(first_spatial_axis);
} else {
conv_out_spatial_dim_ = top[0]->count(first_spatial_axis);
}
接下來算出原圖的計算矩陣的空間大小
col_offset_ = kernel_dim_ * conv_out_spatial_dim_;
因爲這個矩陣的行數就是kernel_dim_
,而列數呢就是要卷積多少次,也就是conv_out_spatial_dim_
,比如說輸出是3*3維的,那麼就有9列
這個col_offset_
在caffe_cpu_gemm
中用到了,方便做矩陣運算的之後指針切換到下一張圖用的偏移量。
然後是output_offset_
就是算出結果的輸出的矩陣,就是output
的輸出的數量。
output_offset_ = conv_out_channels_ * conv_out_spatial_dim_ / group_;
它最後還除了一個group
就是假設有分組的情況下,它是一組一組的計算的,所以每次的偏移量都是這個組內的數量。
接着定義了一個輸入維度的vector
// Setup input dimensions (conv_input_shape_).
vector<int> bottom_dim_blob_shape(1, num_spatial_axes_ + 1);
這裏容易讓人產生誤解,其實它是一個維度爲1的vector
,其實完全可以寫成一個變量的,他的值是num_spatial_axes_ + 1
爲什麼是+1呢?因爲這裏這個變量的作用是用來初始化形狀數組用的,形狀數組除了記錄了空間維度以外,還要記錄channel的數量,所以多留了一位存channel用的。這裏用一個vector
表示而不是用一個int變量表示完全是爲了接口統一,方便下一步的處理。
下一步就開始reshape
形狀的數組了。
conv_input_shape_.Reshape(bottom_dim_blob_shape);
緊接着定義了一個指針指向了它,這樣做的目的是爲了方便下一步去用這個指針去修改它的值。
int* conv_input_shape_data = conv_input_shape_.mutable_cpu_data();
接着把各個維度的真實大小一一賦值給它
for (int i = 0; i < num_spatial_axes_ + 1; ++i) {
if (reverse_dimensions()) {
conv_input_shape_data[i] = top[0]->shape(channel_axis_ + i);
} else {
conv_input_shape_data[i] = bottom[0]->shape(channel_axis_ + i);
}
}
接着開始reshapeim2col
結果的一個buffer
// The im2col result buffer will only hold one image at a time to avoid
// overly large memory usage. In the special case of 1x1 convolution
// it goes lazily unused to save memory.
col_buffer_shape_.clear();
這裏是im2col
的結果,而不是我們最終矩陣計算後的結果,這裏其實就是得到一個計算矩陣用的。所以他的形狀就是kernel_dim_
* group_
col_buffer_shape_.push_back(kernel_dim_ * group_);
for (int i = 0; i < num_spatial_axes_; ++i) {
if (reverse_dimensions()) {
col_buffer_shape_.push_back(input_shape(i + 1));
} else {
col_buffer_shape_.push_back(output_shape_[i]);
}
}
col_buffer_.Reshape(col_buffer_shape_);
接着獲取它輸入的大小和輸出的大小
bottom_dim_ = bottom[0]->count(channel_axis_);
top_dim_ = top[0]->count(channel_axis_);
後面一步求了num_kernels_im2col_
,它的算法是
num_kernels_im2col_ = conv_in_channels_ * conv_out_spatial_dim_;
這裏我沒有看懂是什麼意思,這兩個數字相乘讓人很迷,而且它實際的用途只有在im2col_nd_gpu
這一個函數中才用到了,其他的都沒有用到,這個函數是什麼意思我也沒有搞明白。
後面還有一個它反向操作的變量
num_kernels_col2im_ = reverse_dimensions() ? top_dim_ : bottom_dim_;
接下來設置了一下輸出的空間大小
// Set up the all ones "bias multiplier" for adding biases by BLAS
out_spatial_dim_ = top[0]->count(first_spatial_axis);
如果設置了bias
的話,我們就要reshape這個bias_multiplier_
的大小。
if (bias_term_) {
vector<int> bias_multiplier_shape(1, out_spatial_dim_);
bias_multiplier_.Reshape(bias_multiplier_shape);
caffe_set(bias_multiplier_.count(), Dtype(1),
bias_multiplier_.mutable_cpu_data());
}
它最後使用的是caffe_set
這個函數,把所有的bias
先全部初始化爲1
MinBottomBlobs 函數
virtual inline int MinBottomBlobs() const { return 1; }
這個函數是個虛函數,也就是留給子類來實現的,也就是把BottomBlobs
最小值返回回去
forward_cpu_gemm 函數
使用cpu的前傳函數
// Helper functions that abstract away the column buffer and gemm arguments.
// The last argument in forward_cpu_gemm is so that we can skip the im2col if
// we just called weight_cpu_gemm with the same input.
void forward_cpu_gemm(const Dtype* input, const Dtype* weights,
Dtype* output, bool skip_im2col = false);
第一步先生成im2col
的矩陣
const Dtype* col_buff = input;
if (!is_1x1_) {
if (!skip_im2col) {
conv_im2col_cpu(input, col_buffer_.mutable_cpu_data());
}
col_buff = col_buffer_.cpu_data();
}
然後根據分組來進行卷積計算
for (int g = 0; g < group_; ++g) {
caffe_cpu_gemm<Dtype>(CblasNoTrans, CblasNoTrans, conv_out_channels_ /
group_, conv_out_spatial_dim_, kernel_dim_,
(Dtype)1., weights + weight_offset_ * g, col_buff + col_offset_ * g,
(Dtype)0., output + output_offset_ * g);
}
forward_cpu_bias 函數
計算偏移量,因爲caffe的前傳把計算權值和偏移量分開算了,主要就是方便矩陣計算的操作,沒有什麼難懂的地方。
它用的函數是一樣的,但是這次權值那個位置都設置的1,只計算bias
caffe_cpu_gemm<Dtype>(CblasNoTrans, CblasNoTrans, num_output_,
out_spatial_dim_, 1, (Dtype)1., bias, bias_multiplier_.cpu_data(),
(Dtype)1., output);
backward_cpu_bias 函數
這個函數求出當前層bias
的梯度
因爲y=w*x+b
,那麼可以得知bias
的梯度就是1乘以上一層傳下來的梯度
caffe_cpu_gemv<Dtype>(CblasNoTrans, num_output_, out_spatial_dim_, 1.,
input, bias_multiplier_.cpu_data(), 1., bias);
因爲我們bias_multiplier_
設置的就是1,所以這個函數翻譯過來就是bias = bias *1 + input * bias_multiplier_
,所以
bias = bias + input
這裏這個加號是什麼意思呢?因爲一個batch裏面是所有的圖加起來求得平均值,所以要先把這個batch裏的數據都先加起來,方便之後求平均。
weight_cpu_gemm
求權重的梯度
根據公式y=w*x+b
,那麼可以得知weight
的梯度就是x乘以上一層傳下來的梯度
const Dtype* col_buff = input;
if (!is_1x1_) {
conv_im2col_cpu(input, col_buffer_.mutable_cpu_data());
col_buff = col_buffer_.cpu_data();
}
for (int g = 0; g < group_; ++g) {
caffe_cpu_gemm<Dtype>(CblasNoTrans, CblasTrans, conv_out_channels_ / group_,
kernel_dim_, conv_out_spatial_dim_,
(Dtype)1., output + output_offset_ * g, col_buff + col_offset_ * g,
(Dtype)1., weights + weight_offset_ * g);
}
最終算出來的值就給了weights + weight_offset_ * g
這個位置的數組
backward_cpu_gemm 函數
這個函數是由損失函數那裏傳來的這一層的梯度,這一層的梯度其實就是y對於x的求導
因爲y=wx+b
,所以y對於x的求導其實就是w
那麼總的梯度就應該是上一層的梯度w
Dtype* col_buff = col_buffer_.mutable_cpu_data();
if (is_1x1_) {
col_buff = input;
}
for (int g = 0; g < group_; ++g) {
caffe_cpu_gemm<Dtype>(CblasTrans, CblasNoTrans, kernel_dim_,
conv_out_spatial_dim_, conv_out_channels_ / group_,
(Dtype)1., weights + weight_offset_ * g, output + output_offset_ * g,
(Dtype)0., col_buff + col_offset_ * g);
}
if (!is_1x1_) {
conv_col2im_cpu(col_buff, input);
}