Skip to content

Latest commit

 

History

History

README.md

This is a lab about loop interchange, which is more advanced than the previous one.

In this lab assignment you will optimize Gaussian blur algorithm applied to a grayscale image. Modern cameras have good matrices and produce big files. How fast can modern CPU filter a camera shot? Significant speedup has been already achieved by two passes of 1-dimensional digital filter instead of a plain 2D convolution.

Hints

Hint 1:

There is one loop in the code that has a suboptimal memory access pattern (one that causes a large number of cache misses). Identify it, work out what the ideal loop iteration pattern should look like, and refactor accordingly.


Hint 2:

The loop in question is this one:

for (int i = 0; i < radius + 1 + radius; i++) {
    dot += input[(r - radius + i) * width + c] * kernel[i];
}

It is actually nested in two other loops:

for (int c = 0; c < width; c++) {
    ...
    for (int r = 0; r < std::min(radius, height); r++) {
        ...
        for (int i = 0; i < radius + 1 + radius; i++) {
            dot += input[(r - radius + i) * width + c] * kernel[i];
        }
        ...
    }
    ...
}

Think about what the ideal memory access pattern should be. From there, you can work out what would need to change in this loop layout to achieve that goal.


Hint 3:

The code loops over c, then r, then i. However, the ideal loop nest hierarchy should be r -> i -> c:

for (int r = 0; r < std::min(radius, height); r++) {
    ...
    for (int i = 0; i < radius + 1 + radius; i++) {
        ...
        for (int c = 0; c < width; c++) {
            ...
        }
        ...
    }
...
}

What change do we need to make to the existing variables in our nested loop to allow us to rearrange the order? Normally, you need to increase the dimension of any accumulator to allow the refactoring. In this case, focus on dot.


Worked Solution:

Background:

This lab involves an algorithm that applies Gaussian blur to a supplied input image. Its performance is hindered by a hot spot that needs to be rewritten.

We can perform a top-down analysis of the code using pmu-tools, which is a suite of tools built on top of Linux perf to run performance analysis on Intel CPUs.

We can run the toplev.py script (provided Python is installed) in pmu-tools on this lab as follows:

toplev.py --core S0-C0 --run-sample -l2 -v --no-desc taskset -c 0 ./lab ARG1 ARG2

where ARG1 and ARG2 are the paths to the input and output .pgm files, respectively.

(To use the examples in the repository, ARG1 is ${CMAKE_CURRENT_SOURCE_DIR}/pexels-pixabay-434334.pbm and ARG2 is output.pgm, which will be in the build directory alongside lab after lab is run).

Part of the output shows us the following:

--------------------------------------------------------------
Benchmark                    Time             CPU   Iterations
--------------------------------------------------------------
bench1/iterations:5        290 ms          290 ms            5
...
C0    FE               Frontend_Bound                      % Slots                        3.5  < [ 8.0%]
C0    BAD              Bad_Speculation                     % Slots                        0.3  < [ 8.0%]
C0    BE               Backend_Bound                       % Slots                       74.5    [ 8.0%]
C0    RET              Retiring                            % Slots                       21.6  < [ 8.0%]
C0    FE               Frontend_Bound.Fetch_Latency        % Slots                        1.9  < [ 8.0%]
C0    FE               Frontend_Bound.Fetch_Bandwidth      % Slots                        1.1  < [ 8.0%]
C0    BAD              Bad_Speculation.Branch_Mispredicts  % Slots                        0.2  < [ 8.0%]
C0    BAD              Bad_Speculation.Machine_Clears      % Slots                        0.1  < [ 8.0%]
C0    BE/Mem           Backend_Bound.Memory_Bound          % Slots                       49.7    [ 8.0%]<==
C0    BE/Core          Backend_Bound.Core_Bound            % Slots                       24.8    [ 8.0%]
C0    RET              Retiring.Light_Operations           % Slots                       20.9  < [ 8.0%]
C0    RET              Retiring.Heavy_Operations           % Slots                        0.4  < [ 8.0%]
C0-T0 MUX                                                  %                              8.00  
C0-T1 MUX                                                  %                              8.00  
Run toplev --describe Memory_Bound^ to get more information on bottleneck```

This output highlights that the code is primarily memory bound, where almost 50% of resources are held up simply waiting for data to be available.

If we run perf report, we can locate the problematic line of code:

dot += input[(r - radius + i) * width + c] * kernel[i];

This is from line 37 in solution.cpp. The full loop:

for (int c = 0; c < width; c++) {
  ...
  // Middle part of computations with full kernel
  for (int r = radius; r < height - radius; r++) {
      // Accumulation
      int dot = 0;
      for (int i = 0; i < radius + 1 + radius; i++) {
          dot += input[(r - radius + i) * width + c] * kernel[i];
      }

      // Fast shift instead of division
      int value = (dot + rounding) >> shift;
      output[r * width + c] = static_cast<uint8_t>(value);
  }
}

As we see, when the inner loop over i advances, there is a jump of width elements in the next input lookup. This is not cache-friendly at all; jumping around in memory in this way does not make good use of the surrounding elements that would also be loaded in a cache line (or that may also be in L1 cache). These large strides will lead to many expensive cache misses and degraded performance.

Solution

We need to rearrange the loops so that we iterate over c inside the loop of i. As it is currently written, this is not trivial as there are dependencies between loop iterations; the value dot is incremented to on the Kth iteration depends on its value from the K-1th iteration, and so on.

To break this dependency, we therefore need to expand dot to be an int array of length width.

First, we extract this section of the code from top-level loop over c in this function:

  for (int c = 0; c < width; c++) {
    // Top part of line, partial kernel
    ...
      int value = static_cast<int>(dot / static_cast<float>(sum) + 0.5f);
      output[r * width + c] = static_cast<uint8_t>(value);
    }
  } // *** <--- STOP `c` HERE ***

  // Middle part of computations with full kernel
  ...

  for (int c = 0; c < width; c++) { // <--- *** RESTART `c` HERE ***
    // Bottom part of line, partial kernel

We then change dot to be an array of length width, so we can accumulate for a given c (as in the previous code), but perform the c loop inside the i loop in order to improve the memory access pattern:

  for (int r = radius; r < height - radius; r++) {
    int dot[width];
    for (int c = 0; c < width; c++) {
      dot[c] = 0; // <- INITIALISATION
    }
    // Accumulation
    for (int i = 0; i < radius + 1 + radius; i++) {
      for (int c = 0; c < width; c++) {
        // <- LOOP OVER `c` INSIDE `i`; sequential accesses are cache-friendly
        dot[c] += input[(r - radius + i) * width + c] * kernel[i];
      }
    }
    // <- PUT `c`th loop value into `output` as before
    for (int c = 0; c < width; c++) {
      // Fast shift instead of division
      int value = (dot[c] + rounding) >> shift;
      output[r * width + c] = static_cast<uint8_t>(value);
    }
  }

Running the benchmark again after these changes shows a remarkable speedup (from 290ms to 71.4ms), with memory bound accesses down to only 5.4% of the resource usage. The code is now mainly bound by the computations the algorithm just has to do.

--------------------------------------------------------------
Benchmark                    Time             CPU   Iterations
--------------------------------------------------------------
bench1/iterations:5       71.4 ms         71.0 ms            5
...
C0    FE               Frontend_Bound                      % Slots                        5.4  < [ 8.0%]
C0    BAD              Bad_Speculation                     % Slots                        0.7  < [ 8.0%]
C0    BE               Backend_Bound                       % Slots                       27.4    [ 8.0%]
C0    RET              Retiring                            % Slots                       66.6  < [ 8.0%]
C0    FE               Frontend_Bound.Fetch_Latency        % Slots                        2.8  < [ 8.0%]
C0    FE               Frontend_Bound.Fetch_Bandwidth      % Slots                        2.1  < [ 8.0%]
C0    BAD              Bad_Speculation.Branch_Mispredicts  % Slots                        0.5  < [ 8.0%]
C0    BAD              Bad_Speculation.Machine_Clears      % Slots                        0.2  < [ 8.0%]
C0    BE/Mem           Backend_Bound.Memory_Bound          % Slots                        5.4  < [ 8.0%]
C0    BE/Core          Backend_Bound.Core_Bound            % Slots                       22.0    [ 8.0%]<==
C0    RET              Retiring.Light_Operations           % Slots                       64.5    [ 8.0%]
C0    RET              Retiring.Heavy_Operations           % Slots                        3.3  < [ 8.0%]
C0-T0 MUX                                                  %                              8.00  
C0-T1 MUX                                                  %                              8.00 

Code for Solution (Link):

Link to Solution