3

Are these two code snippets exactly the same (as they would be in C++) or will they both produce slightly different running times?

first:

x = 'hello joe'

if x == 'hello':
  print('nope')
elif x == 'hello joe':
  print(x)

second:

x = 'hello joe'

if x == 'hello':
  print('nope')
else:
  if x == 'hello joe':
    print(x)

I wanted to find out myself, but I am not sure how I might go about watching this code run in its assembly form in real time. Which brings me to my second question: how might I see the compiled assembly instructions that are made when I compile a Python program?

Wolf
  • 9,679
  • 7
  • 62
  • 108
Rob
  • 545
  • 3
  • 21
  • 3
    have you tried the `dis` module? https://stackoverflow.com/questions/19560057/how-to-read-python-bytecode – Jean-François Fabre Aug 07 '18 at 19:17
  • Depends on your interpreter/compiler. I guess you're asking about CPython? – Aran-Fey Aug 07 '18 at 19:18
  • Awesome, thanks guys! – Rob Aug 07 '18 at 19:19
  • If we break this down to assembler you are looking at more cycles in the CPU to process the first example over the second so unless the first example gets converted to an else then the second will always be a faster run time. The compare command takes a min of 2 cycles on an Intel processor. – SudoKid Aug 07 '18 at 19:23
  • 1
    @EmettSpeer What program are you using to compile the Python shown directly to Intel assembly language? Python byte code is executed by an interpreter, not bare CPU. – chepner Aug 07 '18 at 19:36
  • @chepner I didn't run the code or use any tooling to do the conversion. I just already know that a compare on a modern Intel/AMD CPU is 2 cycles and a jump is a single cycle. This means that preforming an if is a min of 2 cycles on the CPU with a jump of 1 if not matched. If you do an extra compare in your else that's a min of 2 more cycles with a possible 3rd for a jump. Though with my not seeing the second `if` my statement is 100% incorrect and both are equal in assembler as well. – SudoKid Aug 07 '18 at 20:06
  • @EmettSpeer And how is your analysis relevant if my Python implementation isn't running on an Intel architecture? – chepner Aug 07 '18 at 20:29
  • @chepner 1) I have admitted fault as I failed to fully read the provided code. 2) Well my statement is directly regarding the x86 line of CPU's it will still hold true just with possible different cycle counts on all other CPU's no matter architecture. 3) My statement is regarding the logical flow of code which only holds true if the poster didn't have a second `if`. The key there being only if the second `if` is not used. Which I have now noted 3 times I missed in my first pass. In which case in assembler of any kind both examples are equal. – SudoKid Aug 07 '18 at 20:43

2 Answers2

9

First, let's put your code(s) in a function

def func():               # line 1
    x = 'hello joe'       # line 2

    if x == 'hello':      # line 4
      print('nope')       # line 5
    else:                 # line 6
     if x == 'hello joe': # line 7
      print(x)            # line 8

now disassemble with that (using CPython 3.4):

import dis
dis.dis(func)

we get:

  2           0 LOAD_CONST               1 ('hello joe')
              3 STORE_FAST               0 (x)

  4           6 LOAD_FAST                0 (x)
              9 LOAD_CONST               2 ('hello')
             12 COMPARE_OP               2 (==)
             15 POP_JUMP_IF_FALSE       31

  5          18 LOAD_GLOBAL              0 (print)
             21 LOAD_CONST               3 ('nope')
             24 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             27 POP_TOP
             28 JUMP_FORWARD            25 (to 56)

  7     >>   31 LOAD_FAST                0 (x)
             34 LOAD_CONST               1 ('hello joe')
             37 COMPARE_OP               2 (==)
             40 POP_JUMP_IF_FALSE       56

  8          43 LOAD_GLOBAL              0 (print)
             46 LOAD_FAST                0 (x)
             49 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             52 POP_TOP
             53 JUMP_FORWARD             0 (to 56)
        >>   56 LOAD_CONST               0 (None)
             59 RETURN_VALUE

now change to elif:

  2           0 LOAD_CONST               1 ('hello joe')
              3 STORE_FAST               0 (x)

  4           6 LOAD_FAST                0 (x)
              9 LOAD_CONST               2 ('hello')
             12 COMPARE_OP               2 (==)
             15 POP_JUMP_IF_FALSE       31

  5          18 LOAD_GLOBAL              0 (print)
             21 LOAD_CONST               3 ('nope')
             24 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             27 POP_TOP
             28 JUMP_FORWARD            25 (to 56)

  6     >>   31 LOAD_FAST                0 (x)
             34 LOAD_CONST               1 ('hello joe')
             37 COMPARE_OP               2 (==)
             40 POP_JUMP_IF_FALSE       56

  7          43 LOAD_GLOBAL              0 (print)
             46 LOAD_FAST                0 (x)
             49 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             52 POP_TOP
             53 JUMP_FORWARD             0 (to 56)
        >>   56 LOAD_CONST               0 (None)
             59 RETURN_VALUE

The only differences are the line numbers.

    else:                 # line 6
     if x == 'hello joe': # line 7

becomes (and shifts the rest as well)

    elif x == 'hello joe': # line 6

There are as many instructions in both versions. The else and if keywords in that case seem to have been converted exactly the same way as elif. Not guaranteed in all implementations. Personally I'd stick to the shortest code (elif) because it's more "meaningful" and if a code should be faster, it would probably be that one.

Jean-François Fabre
  • 137,073
  • 23
  • 153
  • 219
  • I am not exactly sure what the line number represent. Are they locations in memory or blocks in memory? As you said it would seem that elif is really just a shorthand keyword for else if{}. If that is the case, then maybe it takes longer for an elif because it has to take the time to look up what elif means? Could this explain the difference in the line numbers? – Rob Aug 07 '18 at 20:29
  • 1
    I have updated my post to add line numbers of the source I've shown and why the numbers change. note that the byte compilation is done when loading the program, not execution. – Jean-François Fabre Aug 07 '18 at 20:33
  • Oh there actual line numbers.. of course they are, I over complicate everything. – Rob Aug 07 '18 at 20:35
  • Inspiring answer that made me find a simple way to compare compiled code (without saving and comparing files), which by the way eliminates irrelevant details. (see second part of my answer) – Wolf Feb 13 '23 at 15:19
1

As Jean-François Fabre already answered, both variants are equivalent in this very case (i.e., the bytecode is the same). In other cases where the resulting code should be checked for equality, we can do this by comparing its binary representation. A string can be compiled by dis.Bytecode(str), the binary representation is stored in attributes of codeobj.co_code.

first = """
x = 'hello joe'

if x == 'hello':
  print('nope')
elif x == 'hello joe':
  print(x)
"""

second = """
x = 'hello joe'

if x == 'hello':
  print('nope')
else:
  if x == 'hello joe':
    print(x)
"""

import dis

# binary representation of bytecode of compiled str 
def str_to_binary(f):
    return dis.Bytecode(f).codeobj.co_code

print(f"{str_to_binary(first) == str_to_binary(second) = }")

output:

str_to_binary(first) == str_to_binary(second) = True

In order to get the bytecode of a function it is not even necessary to import dis, you can access the code object via __code__, see the following example with two functions doing exactly the same thing: 1

def f(a):
    return a
    
def g(b):
    return b

# binary representation of a function's bytecode
def fun_binary(function):
    return function.__code__.co_code
    
print(f"{fun_binary(f) == fun_binary(g) = }")

output:

fun_binary(f) == fun_binary(g) = True

1 The functions f and g use different names for local variables which is reflected by the parenthesized information given by dis.dis() but the resulting byte code is identical for both functions.

Wolf
  • 9,679
  • 7
  • 62
  • 108