The devil is in the details.
Tags: python, concurrency, datascience, numpy
Published: 2020-12-28 20:22:20

# Introduction

At work I often use this saying to highlight the importance of understanding the mechanics of libraries and modules we deal with everyday. Bird's-eye view thinking is important, but a detailed knowledge is crucial as well. This post will show an interesting example.

# Myth: Use multiprocessing over threading for CPU bound tasks

At some point each python developer faces the decision between `multiprocessing` vs. `threading`. Many posts [1-3] advise to use the former for CPU-heavy work and the latter for network/IO bound tasks.

Let's define our worker-function which reads a JPEG image from disk and converts it to PNG:

```python
import pathlib
import imageio

# convert a JPEG image to PNG
def convert_image(input_image: pathlib.Path,
    output_folder: pathlib.Path, cpu_work=False):

    # read and decode
    image = imageio.imread(input_image)

    basename_wo_extension = input_image.name[:-4]
    output_path = output_folder / (basename_wo_extension + ".png")
    
    # write and decode 
    imageio.imwrite(output_path, image)
```

Since the function has to decompress a JPEG file, compress it to PNG I would argue that we do CPU bound work.

The benchmark script uses `Pool` vs. `ThreadPool`. Let's take a look at the benchmark results:

| Number images | `multiprocessing` | `threading`|
|------|-----------------|-----------|
| 10   | 1.4s            | 0.9       |
| 100  | 5.2s            | 4.6s      |
| 1000 | 43s             | 42s       |

Despite the common opinion to use `multiprocessing` for such tasks `ThreadPool` is even faster! The reason for that, is that `imageio` makes use of `Pillow` which itself uses `libjpeg`. Dependent on your system `libjpeg-turbo` is compiled with a bunch of SIMD instructions (MMX, SSE2, AVX2, ...) to accelerate baseline JPEG compression and decompression on x86.[4] The image decoding seems to be faster than the `multiprocessing.Pool` management!

Threading has some more advantages:

- shared memory (easier communication between threads)
- lightweight - low memory footprint (you can save real money on VMs)

Of course, if we add some python-level processing, like following:

``` python
# def convert_image( ...):

shape = image.shape
width, height = shape[0], shape[1]

for x in range(width):
    for y in range(height):
        # useless calculation
        image[x, y, ...] = (image[x, y, ...] * 0.99).astype(np.uint8)
```

the situation changes:

| Number images | `multiprocessing` | `threading`|
|------|-----------------|-----------|
| 10   | 17.1s           | 95s       |

> Always check what you want to do in your special case. Sometimes it can be worth it!

# Thanks

Thank you for reading! Did you experience similar situations?

# Sources

[1] [https://dev.to/nbosco/multithreading-vs-multiprocessing-in-python--63j](https://dev.to/nbosco/multithreading-vs-multiprocessing-in-python--63j)
[2] [https://timber.io/blog/multiprocessing-vs-multithreading-in-python-what-you-need-to-know/](https://timber.io/blog/multiprocessing-vs-multithreading-in-python-what-you-need-to-know/)
[3] [https://blog.floydhub.com/multiprocessing-vs-threading-in-python-what-every-data-scientist-needs-to-know/](https://blog.floydhub.com/multiprocessing-vs-threading-in-python-what-every-data-scientist-needs-to-know/)
[4] [https://libjpeg-turbo.org/](https://libjpeg-turbo.org/)