Parallel acceleration using multi-processing or multi-threading is no longer a difficult task nowadays, and I believe many readers have experienced it. Generally speaking, we tend to conclude that the speedup ratio of multi-processing is difficult to reach 1. In other words, when you use 10 processes to run a task in parallel, you usually only get less than a 10-fold speedup, and the more processes you use, the lower this speedup ratio often becomes.
Note that when we say it is “difficult to reach 1,” it implies our subconscious belief that the speedup ratio can at most be 1. Theoretically, this is true; how could using 10 processes result in a 20-fold speedup? Wouldn’t that be a “free lunch”? However, I did encounter an example a few days ago where the speedup ratio was significantly greater than 1, so I would like to share it here.
Word Frequency Counting
My original task was to count word frequencies: I have many articles, and we need to perform word segmentation (tokenization) on these articles and finally aggregate a word frequency table. A typical implementation looks like this:
tokens = {}
for text in read_texts():
for token in tokenize(text):
tokens[token] = tokens.get(token, 0) + 1This implementation took about 20 minutes when I was counting the word frequencies of all articles in the THUCNews dataset.
Multi-processing Version
Next, let’s compare it with a multi-processing version. I have previously introduced multi-processing techniques in Python in the article “Multi-processing Programming Tips in Python”. For ease of reuse, I have encapsulated it into a function:
def parallel_apply(func,
iterable,
workers,
max_queue_size,
callback=None,
dummy=False):
"""
Apply func to each element of iterable using multi-processing or multi-threading.
Note that this apply is asynchronous and unordered, meaning if you input a, b, c,
the output might be func(c), func(a), func(b).
Parameters:
dummy: False for multi-processing, True for multi-threading;
callback: Callback function to handle a single output;
"""
if dummy:
from multiprocessing.dummy import Pool, Queue
else:
from multiprocessing import Pool, Queue
from six.moves import queue
in_queue, out_queue = Queue(max_queue_size), Queue()
def worker_step(in_queue, out_queue):
# Wrap single-step function into a loop
while True:
d = in_queue.get()
r = func(d)
out_queue.put(r)
# Start multi-processing/threading
pool = Pool(workers, worker_step, (in_queue, out_queue))
if callback is None:
results = []
# Post-processing function
def process_out_queue():
out_count = 0
for _ in range(out_queue.qsize()):
d = out_queue.get()
out_count += 1
if callback is None:
results.append(d)
else:
callback(d)
return out_count
# Input data, retrieve results
in_count, out_count = 0, 0
for d in iterable:
in_count += 1
while True:
try:
in_queue.put(d, block=False)
break
except queue.Full:
out_count += process_out_queue()
if in_count % max_queue_size == 0:
out_count += process_out_queue()
while out_count != in_count:
out_count += process_out_queue()
pool.terminate()
if callback is None:
return resultsThe code to call this function for multi-processing word frequency counting is roughly as follows:
def _batch_texts():
texts = []
for text in read_texts():
texts.append(text)
if len(texts) == 1000:
yield texts
texts = []
if texts:
yield texts
def _tokenize_and_count(texts):
tokens = {}
for text in texts:
for token in tokenize(text):
tokens[token] = tokens.get(token, 0) + 1
return tokens
tokens = {}
def _total_count(result):
for k, v in result.items():
tokens[k] = tokens.get(k, 0) + v
# Use 10 processes to complete word frequency counting
parallel_apply(
func=_tokenize_and_count,
iterable=_batch_texts(),
workers=10,
max_queue_size=200,
callback=_total_count,
)The entire process is: _batch_texts divides the text
into batches, with each batch containing 1000 texts;
_tokenize_and_count is used to count each batch;
_total_count aggregates the results of each batch; and
finally, parallel_apply implements this process using 10
processes.
How long did this take? The result was 55 seconds! This means a 20-fold speedup, and the speedup ratio (efficiency) is 2!
Principle Analysis
Why can we achieve a speedup ratio greater than 1? Actually, the
reason lies in the fact that in the initial single-process
implementation, the line
tokens[token] = tokens.get(token, 0) + 1 becomes slower and
slower. As the counting progresses, the number of elements in
tokens increases, making the CRUD (Create, Read, Update,
Delete) operations on tokens increasingly slow.
In the multi-processing version, the line
tokens[token] = tokens.get(token, 0) + 1 is only executed
for batches of no more than 1000 samples, which obviously maintains a
fast speed throughout. Although the final aggregation also involves
frequent reads and writes to tokens, the frequency is far
lower than in the original implementation, so it is also very fast.
Therefore, the multi-processing version can achieve a 20-fold speedup,
rather than just the theoretical limit of 10-fold.
Of course, readers may have already sensed that this isn’t truly making the speedup ratio exceed 1, but rather an illusion caused by the poor writing of the original single-process version. It could be improved with the following code:
count = 0
tokens = {}
_tokens = {}
for text in read_texts():
for token in tokenize(text):
_tokens[token] = _tokens.get(token, 0) + 1
count += 1
if count == 1000:
for k, v in _tokens.items():
tokens[k] = tokens.get(k, 0) + v
count = 0
_tokens = {}
for k, v in _tokens.items():
tokens[k] = tokens.get(k, 0) + vThis is still the approach of batch counting followed by aggregation, but it is single-processed. While this way of writing looks roundabout and unintuitive, it actually only took 8 minutes, which is about one-third of the original version! From this, we can see that the actual speedup ratio is approximately 0.8.
Summary
This article briefly discussed the issue of multi-processing in Python, provided an example where the speedup ratio seemingly exceeds 1, and analyzed the reasons behind it. From another perspective, this also serves as a reminder when writing similar code: even in a single-process scenario, the efficiency of batch calculation followed by aggregation is usually higher than calculating the entire batch all at once.