Home

CNN

A convolutional neural network written, without any maths or machine learning libraries, in C++. It was tested and designed around a plant identification dataset but its architecture can be configured to work with any dataset. More of a learning exercise than a practical project.

Interesting Things

Performant code isn't pretty

At the low level of the implementation of convolutions and other mathemtical operations, the fast code becomes pretty ugly. Taking my convolution code as an example, there are some fast things which make it ugly.
Tensor CnnUtils::convolution(const Tensor& image,Tensor& kernel,const int xStride,const int yStride,bool padding
#if PROFILING
    ,Timer *parentTimer
#endif
){ 
    ...
    if(kernelDimens[1]==3 &&  kernelDimens[2]==3){
        //unrolled 3x3 version 
        ...
    }
    else if(kernelDimens[2]>=8){
        //Do x_i*k_i for 8 in the same row with AVX
        //Saves gathering
        ...
    }
    else{
        const int originalImgYBound = imHeight-yKernelRadius;
        const int originalImgXBound = imWidth-xKernelRadius;
        const int kernelChildSizes0 = kernelChildSizes[0];
        const int kernelChildSizes1 = kernelChildSizes[1];
        ...
        const __m256i paddedImageOffsets = _mm256_setr_epi32(
            0,
            xStride,
            2 * xStride,
            3 * xStride,
            4 * xStride,
            5 * xStride,
            6 * xStride,
            7 * xStride
        );
        for(int l=0;l<paddedImgDimens0;l++){
            int newY = 0;
            int newX = 0;
            //Precomputing multiplications
            int kernelChannel = l*kernelChildSizes0;
            int paddedImageChannel = l*paddedImageChildSizes0-xKernelRadius; //saving the subtractions
            for(int y=yKernelRadius;y<originalImgYBound;y+=yStride){
                int resultRow = newY*resultChildSizes0;
                //Vectorised
                int x=xKernelRadius;
                for(;x+7*xStride<originalImgXBound;x+=8*xStride){
                    int paddedImageChannelShortct = paddedImageChannel + x; 
                    float* __restrict__ resultPtr = resultData+resultRow+newX;
                    //May already have result from another input channel
                    __m256 acc = _mm256_loadu_ps(resultPtr);
                    //Do each individual kernel element across 8 convolutions at once
                    //e.g. do kernel (0,0) multiplied by image (0,0),(0,3),(0,6) ...
                    for(int j=0;j<kernelDimens1;j++){
                        int kernelRow = kernelChannel + j*kernelChildSizes1;
                        int paddedImageRow = paddedImageChannelShortct + (y+j-yKernelRadius)*paddedImageChildSizes1;
                        const float* __restrict__ paddedImageRowBase = &paddedImageData[paddedImageRow];
                        float *kernelRowBase = kernelData+kernelRow;
                        for(int k=0;k<kernelDimens2;k++){
                            const float kernelVal = *(kernelRowBase+k);
                            const __m256i paddedImageIndices = _mm256_add_epi32(paddedImageOffsets,_mm256_set1_epi32(k));
                            const __m256 R = _mm256_i32gather_ps(paddedImageRowBase,paddedImageIndices,4); 
                            const __m256 K = _mm256_set1_ps(kernelVal);  
                            //Add our result
                            acc = _mm256_fmadd_ps(K,R,acc);
                        }
                    }
                    //Save our result
                    _mm256_storeu_ps(resultPtr,acc);
                    newX+=8;
                }
                //Scalar tail
                for(;x<originalImgXBound;x+=xStride){
                    float sum = 0;
                    int paddedImageChannelShortct = paddedImageChannel + x; 
                    for(int j=0;j<kernelDimens1;j++){
                        int kernelRow = kernelChannel + j*kernelChildSizes1;
                        int paddedImageRow = paddedImageChannelShortct + (y+j-yKernelRadius)*paddedImageChildSizes1;
                        float *kernelEndPtr = &kernelData[kernelRow+kernelDimens2];
                        const float* __restrict__ paddedImageDataPtr = &paddedImageData[paddedImageRow];
                        for(float *kernelDataPtr = &kernelData[kernelRow]
                            ;kernelDataPtr<kernelEndPtr;kernelDataPtr++,paddedImageDataPtr++){
                            sum += (*kernelDataPtr) * (*paddedImageDataPtr);
                        }
                    }
                    resultData[resultRow+newX] += sum; 
                    newX++;
                }
                newX=0;
                newY++;
            }
        }
    }
    ...
    return result;
}
Such as:
  • Branching for cases which can be optimised and then hardcoding values
  • Using intrinsics with ugly names
  • Vectorised instructions which result in a scalar tail
  • Explicitly loading reused values into variables
I realise that some of these will be done by the compiler (especially in -O3) but making them explicit ensures this and also makes it performant in lower optimisation levels such as debug builds. I also realise that there are more optimisations that could take place (e.g. the 8*xStride) but I like some level of readability. My code is significantly slower than Pytorch (around 3x slower on the same CPU; the CV will not be hearing about this). It seems like some people spend a lot longer writing even uglier code.

Initialisation is surprisingly important

Without a machine learning background, I just thought that randomly intialise weights meant a suitably scaled rand() . After struggling with exploding gradients for a while, I found He intialisation which aims to keep the activations having a constant variance of 1. At first I though it was some fancy unnecessary technique (like some parts of ML are) but it drastically improved my accuracy and stability.

Profiling tool

Proper profiling tools looked like a bit of a faff and so I made my own. It's a single header file and it outputs a tree of timings. Each timer owns child timers which forms the tree. Timers can be reused to get a mean time for each section of code. In my code, the preprocessor only puts them in the profiling build and they don't particularly overcomplicate the code.
Tensor CnnUtils::convolution(const Tensor& image,Tensor& prePaddedImage,Tensor& kernel,const int xStride,const int yStride
#if PROFILING
    ,Timer *parentTimer
#endif
){
    #if PROFILING
        Timer *prePaddedConvolutionTimer = nullptr;
        Timer *paddingTimer = nullptr;
        if(parentTimer){
            prePaddedConvolutionTimer = parentTimer->addChildTimer("prePaddedConvolution");
            paddingTimer = prePaddedConvolutionTimer->addChildTimer("padding");
        }
    #endif
    // Padding and stuff
    #if PROFILING
        if(parentTimer) paddingTimer->stop();
    #endif 

    Tensor result = convolution(prePaddedImage,kernel,xStride,yStride,false
    #if PROFILING
        ,prePaddedConvolutionTimer
    #endif
    );
    
    #if PROFILING
        if(parentTimer) prePaddedConvolutionTimer->stop();
    #endif 
    return result;
}

Fake convolutions

With my YouTube eduction, I didn't quite have the correct idea of convolutions. I thought that a kernel was a 2D matrix which you pass over each input channel individually to produce an output channel. This doesn't allow channels to mix like a proper 3D kernel does. However, they are a lot quicker to compute (by a factor of the number of input channels), and seem to do useful processing. When switching my model over to real convolutions, I couldn't beat the accuracy of my fake convolutions for quite a while. For simple tasks, fake convolutions could have uses.
If you want any more information on any of this, I would be happy to give it (contact details on homepage).