We can use dis
to check the difference of the emitted byte code:
import dis
def deconstruction_approach(index_location):
l = []
for key, value in index_location:
l.append(key)
dis.dis(deconstruction_approach)
4 0 RESUME 0
5 2 BUILD_LIST 0
4 STORE_FAST 1 (l)
6 6 LOAD_FAST 0 (index_location)
8 GET_ITER
>> 10 FOR_ITER 26 (to 64)
12 UNPACK_SEQUENCE 2
16 STORE_FAST 2 (key)
18 STORE_FAST 3 (value)
7 20 LOAD_FAST 1 (l)
22 LOAD_METHOD 0 (append)
44 LOAD_FAST 2 (key)
46 PRECALL 1
50 CALL 1
60 POP_TOP
62 JUMP_BACKWARD 27 (to 10)
6 >> 64 LOAD_CONST 0 (None)
66 RETURN_VALUE
def range_approach(index_location):
l = []
for i in range(len(index_location)):
l.append(index_location[i][0])
dis.dis(range_approach)
1 0 RESUME 0
2 2 BUILD_LIST 0
4 STORE_FAST 1 (l)
3 6 LOAD_GLOBAL 1 (NULL + range)
18 LOAD_GLOBAL 3 (NULL + len)
30 LOAD_FAST 0 (index_location)
32 PRECALL 1
36 CALL 1
46 PRECALL 1
50 CALL 1
60 GET_ITER
>> 62 FOR_ITER 35 (to 134)
64 STORE_FAST 2 (i)
4 66 LOAD_FAST 1 (l)
68 LOAD_METHOD 2 (append)
90 LOAD_FAST 0 (index_location)
92 LOAD_FAST 2 (i)
94 BINARY_SUBSCR
104 LOAD_CONST 1 (0)
106 BINARY_SUBSCR
116 PRECALL 1
120 CALL 1
130 POP_TOP
132 JUMP_BACKWARD 36 (to 62)
3 >> 134 LOAD_CONST 0 (None)
136 RETURN_VALUE
Nothing crazy here, a few CALL
s less for the deconstruction_approach
, the name index_location
doesn't have to be loaded on every iteration, and we skip the BINARY_SUBSCR
operators, trading them for the UNPACK_SEQUENCE
operator.
It seems reasonable to assume the deconstruction_approach
should be more performant.
Profiling:
import dis
import random
from performance_measurement import run_performance_comparison
def deconstruction_approach(index_location):
l = []
for key, value in index_location:
l.append(key)
def range_approach(index_location):
l = []
for i in range(len(index_location)):
l.append(index_location[i][0])
def setup(N):
data = [[""] * 2 for _ in range(N)]
for i in range(N):
data[i][0] = chr(ord("a") + i)
data[i][1] = random.randint(1, 100)
return [data]
run_performance_comparison(
approaches=[range_approach, deconstruction_approach],
data_size=[10000, 20000, 30000, 100000, 200_000, 300_000, 500_000],
setup=setup,
)
Result as expected:

Profiling Code:
import timeit
from functools import partial
import matplotlib.pyplot as plt
from typing import List, Dict, Callable
from contextlib import contextmanager
import matplotlib.pyplot as plt
import matplotlib.transforms as mtransforms
import matplotlib.ticker as ticker
import numpy as np
@contextmanager
def data_provider(data_size, setup=lambda N: N, teardown=lambda: None):
data = setup(data_size)
yield data
teardown(*data)
def run_performance_comparison(approaches: List[Callable],
data_size: List[int],
*,
setup=lambda N: [N],
teardown=lambda *N: None,
number_of_repetitions=5,
title='Performance Comparison',
data_name='N',
yscale='log',
xscale='log'):
approach_times: Dict[Callable, List[float]] = {approach: [] for approach in approaches}
for N in data_size:
with data_provider(N, setup, teardown) as data:
print(f'Running performance comparison for {data_name}={N}')
for approach in approaches:
function = partial(approach, *data)
approach_time = min(timeit.Timer(function).repeat(repeat=number_of_repetitions, number=1))
approach_times[approach].append(approach_time)
for approach in approaches:
plt.plot(data_size, approach_times[approach], label=approach.__name__)
plt.yscale(yscale)
plt.xscale(xscale)
plt.xlabel(data_name)
plt.ylabel('Execution Time (seconds)')
plt.title(title)
plt.legend()
plt.show()
Having said that, the clarity of intent and the explicitness of the deconstruction_approach
really tip the scales for me, not the performance.