How come checking for printable bytes is faster with the "in" operator rather than interval comparisons?

76 Views Asked by At

I'm trying to figure out how come the 'in' operator gives better performance than checking if a value is within some intervals bounds.

How come the first piece of code is slower than the second one? Intuitively I would expect the first piece of code to be faster (3 boolean operations + 4 integer comparisons), since it only checks the bounds of each interval for every byte, while the second code will iterate over the entire array of our charset (compare against every integer in an array of length=100).

import random

data = random.randbytes(1000000)
a_lower, a_upper, b_lower, b_upper = (9, 13, 32, 126)
%timeit [((x >= a_lower and x <= a_upper) or (x >= b_lower and x <= b_upper)) for x in data]

results in around 82 ms

import string
import random

data = random.randbytes(1000000)
charset = bytearray(string.printable, "ascii")
%timeit [x in charset for x in data]

results in around 44 ms

I have looked into CPython implementation of bytesarray "in", and it looks like it eventually reaches memchr

The only theory I'm left with, is that the first piece of code might have more function calls within it, and that ends up taking more CPU time than the difference between the number of "basic operations".

1

There are 1 best solutions below

7
D.L On

I put both functions into the dis module to observe the number of operations.

import string
import random
import dis

def function_01():
    ''' the first function '''
    data = random.randbytes(1000000)
    a_lower, a_upper, b_lower, b_upper = (9, 13, 32, 126)    
    
    return [((x >= a_lower and x <= a_upper) or (x >= b_lower and x <= b_upper)) for x in data]


def function_02():
    ''' the second function '''
    data = random.randbytes(1000000)
    charset = bytearray(string.printable, "ascii")    
    
    return [x in charset for x in data]


# Disassemble the function and capture the results
disassembled_01 = dis.Bytecode(function_01)
disassembled_02 = dis.Bytecode(function_02)

# convert to list and count number of operations
operation_count_01 = len(list(disassembled_01))
operation_count_02 = len(list(disassembled_02))

print(f"The function 01 has {operation_count_01} operations.")
print(f"The function 02 has {operation_count_02} operations.")

which returns this:

The function 01 has 52 operations.
The function 02 has 34 operations.

From this, it can be seen that function_02 is considerable shorter than function_01. Whilst this does not show exact time itself (there are other factors), it certainly gives a good idea of function with the most operations which is a key factor to time.