How To Make Your Python Code Run Faster — 1st Installment | by Ujjwal Dalmia | Towards Data Science

In the last tutorial, we introduced you to line_profiler, a package that can help you time profile your code. It is now time to take a step forward.
In this tutorial, we will learn about implementing multi-threading and multi-processing approaches using Python. These approaches guide the operating system to make optimum utilization of one’s system hardware and hence make the code execution efficient.
Let’s get started...
Quoting Wiki — In computer architecture, multi-threading is the ability of a central processing unit (CPU) (or a single core in a multi-core processor) to provide multiple threads of execution concurrently, supported by the operating system.
Given concurrency, one can initiate parallel execution of multiple processes and achieve faster runtime. Without going into the technical details (google and read about GIL), one thing to keep in mind is that multi-threading is more efficient when performing I/O based tasks like downloading images and files. On the other hand, multi-processing is better suited for CPU based tasks that are computationally intensive.
To implement multi-threading, we will be using Python’s standard library, threading. The library comes by default with standard Python installation and hence can be imported directly in our code.
For demonstrating the effectiveness of multi-threading, we will be downloading 5 images from Unsplash. Let us observe the execution time when we download these images sequentially:
#### Importing requests libraryimport requests#### Defining the function def down_img(name,link):    data = requests.get(link).content    name = f"/home/isud/DidYouKnow/Tutorial 5/{name}.jpg"    with open(name, "wb") as file:        file.write(data)#### 5 images downloaded sequentially%%timeit -n1 -r1images = ['https://images.unsplash.com/photo-1531458999205-f31f14fa217b',          'https://images.unsplash.com/photo-1488572749058-7f52dd70e0fa',          'https://images.unsplash.com/photo-1531404610614-68f9e73e35db',          'https://images.unsplash.com/photo-1523489405193-3884f5ca475f',          'https://images.unsplash.com/photo-1565098735462-5db3412ac4cb']for i,link in enumerate(images):    down_img(i,link)#### %%timeit results51.4 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)
As can be observed, a complete download of 5 images took 51.4 seconds. Also, a new download started only after the previous download has ended. Let us now see how multi-threading can improve code performance.
#### Importing necessary libraryimport threadingimport requests#### Defining the functiondef down_img(name,link):    data = requests.get(link).content    name = f"/home/isud/DidYouKnow/Tutorial 5/{name}.jpg"    with open(name, "wb") as file:        file.write(data)#### Images downloaded in parallel threads%%timeit -n1 -r1threads = []images = ['https://images.unsplash.com/photo-1531458999205-f31f14fa217b',          'https://images.unsplash.com/photo-1488572749058-7f52dd70e0fa',          'https://images.unsplash.com/photo-1531404610614-68f9e73e35db',          'https://images.unsplash.com/photo-1523489405193-3884f5ca475f',          'https://images.unsplash.com/photo-1565098735462-5db3412ac4cb']for i,link in enumerate(images):    t = threading.Thread(target=down_img, args=(i,link))    t.start()    threads.append(t)for thread in threads:    thread.join()#### %%timeit results25.6 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)
Step 1 (Thread Initiation) — Python runs the complete code in a single thread (let’s call it the main thread). In this example, by calling Thread function from the threading library, we are initiating parallel threads and assigning them a target process to be executed (down_image in this case). All the arguments, required by the called function, should be passed as a sequence object (tuple in this case). Each call to the Thread function initiates a new thread (let’s call them parallel threads).
Step 2 (Thread start) — Call to the thread’s start method will instruct Python to start thread execution. Given the for loop is executing in the main thread and the function calls are in a parallel thread, the for loop execution will continue while the image download is in progress.
Step 3 (Thread join) — Each new thread is captured in a list named threads. The parallel threads are then joined to the main thread by calling the join method.
Until step 2, all our threads (main thread and parallel threads) were under parallel execution. In such a scenario, the main thread execution can complete much before the parallel threads. To avoid this, joining the parallel threads to the main thread is inevitable. This will ensure that the execution of the main thread completes only after the completion of parallel threads. The following diagram explains these 2 scenarios:
As can be seen, the execution time to download images has gone down by close to 50% (25.6 seconds approx.). The above example showcases how multi-threading can be helpful in I/O operations and improves the efficiency of the download/upload processes.
Unlike multi-threading which executes multiple threads in a single process, multi-processing initiates a new parallel process for every task. As mentioned above, it delivers considerable improvement in runtime for CPU intensive tasks, tasks requiring heavy computations.
Multiprocessing is another standard library which enables multi-processing feature in Python. To understand it’s functionality, we will call a computationally intensive function multiple times. The function used for demonstration purpose calculates the square of numbers from 1 to 10 million. This function is executed in parallel 8 times. Let’s observe the performance of this function under normal circumstances.
#### Importing time libraryimport time#### Defining the function def demo_func(num):    for i in range(num):        a = i**2#### Calling demo function sequentially%%timeit -n1 -r1for i in range(8):    demo_func(10000000)#### %%timeit results21.2 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)
The sequential execution of the demo function took a total of 21.2 seconds. Now let’s check the performance uplift when this is done in a multi-processing setup.
#### Importing time libraryimport time#### Defining the function def demo_func(num):    for i in range(num):        a = i**2#### Multi-processing demo function %%timeit -n1 -r1processes = []lop_size = [10000000,10000000,10000000,10000000,10000000,10000000,10000000, 10000000]p = multiprocessing.Pool()p.map(demo_func,lop_size)p.close()p.join()#### %%timeit results11.6 s ± 0 ns per loop (mean ± std. dev. of 1 run, 1 loop each)
Under a multi-processing framework, the execution time dropped by 50% to 11.6 seconds. In sequential processing, a single CPU core is utilized at a time whereas, in multi-processing, all the system cores are utilized in parallel. The CPU usage screenshots demonstrate the same:
Each line in the above graph represents a CPU core. Notice how in sequential execution, a single core is triggered for every function call where as in parallel execution, all the cores are triggered at once.
Step 1(Pool Creation) — The Pooling method creates the pool of processes that can be leveraged in parallel. Without any arguments, the number of processes created is equal to the number of CPU cores on your system. I have a quad-core system (4 cores) which means, on execution, out of 8 function calls, the first 4 calls will run in parallel followed by the next 4 calls. Note that one can also define a custom number of processes in the pool (more than number of cores) but beyond a point, it will start eating up your system memory and might degrade performance
Step 2 (Pool Mapping) — This is where your processes are instructed to execute a specific function (first argument) along with a list of arguments to be passed to it (second argument)
Step 3(Pool Close) — Close method instructs Python interpreter that we have submitted everything we wanted to submit to the pool and there will be no more inputs provided to the pool in the future.
Step 4(Pool Join) — As in the case of threading, the join method ensures that the code execution completes only once all the parallel processes complete.
From the above scenario, we could see how multi-processing can be a great weapon to fight against in-efficient code performances.
In this tutorial, we kept our focus on improving the code performance by making optimum utilization of our system hardware. In the next installment, we will demonstrate specific programming practices to improve code performance.
I hope this tutorial was informative and you learned something new.
Will try and bring more interesting topics in future tutorials. Till then:
HAPPY LEARNING ! ! ! !