¿Por qué es variable1 + = variable2 mucho más rápido que variable1 = variable1 + variable2?

He heredado un código Python que se usa para crear tablas enormes (de hasta 19 columnas de ancho por 5000 filas). Tomó nueve segundos para que la tabla se dibujara en la pantalla. Noté que cada fila fue agregada usando este código:

sTable = sTable + '\n' + GetRow() 

donde sTable es una cadena.

Cambié eso a

 sTable += '\n' + GetRow() 

y noté que la mesa ahora aparecía en seis segundos .

Y luego lo cambié a:

 sTable += '\n%s' % GetRow() 

basado en estos consejos de rendimiento de Python (aún seis segundos).

Dado que esto fue llamado alrededor de 5000 veces, destacó el problema de rendimiento. Pero ¿por qué había una diferencia tan grande? ¿Y por qué el comstackdor no detectó el problema en la primera versión y lo optimizó?

No se trata de usar inplace += versus + binary add. No nos contaste toda la historia. Su versión original concatenó 3 cadenas, no solo dos:

 sTable = sTable + '\n' + sRow # simplified, sRow is a function call 

Python intenta ayudar y optimiza la concatenación de cadenas; tanto cuando se usa strobj += otherstrobj como strobj = strobj + otherstringobj , pero no se puede aplicar esta optimización cuando están involucradas más de 2 cadenas.

Las cadenas de Python son normalmente inmutables, pero si no hay otras referencias al objeto de la cadena de la izquierda y se está recuperando de todos modos, Python hace trampa y muta la cadena . Esto evita tener que crear una nueva cadena cada vez que concatene, y eso puede llevar a una gran mejora en la velocidad.

Esto se implementa en el ciclo de evaluación de bytecode. Tanto cuando se usa BINARY_ADD en dos cadenas como cuando se usa INPLACE_ADD en dos cadenas , Python delega la concatenación a una función auxiliar especial string_concatenate() . Para poder optimizar la concatenación mediante la mutación de la cadena, primero debe asegurarse de que la cadena no tenga otras referencias a ella; si solo la stack y la variable original la referencia, entonces esto se puede hacer, y la siguiente operación reemplazará la variable original de referencia.

Entonces, si solo hay 2 referencias a la cadena, y el siguiente operador es uno de STORE_FAST (establecer una variable local), STORE_DEREF (establecer una variable referenciada por funciones cerradas) o STORE_NAME (establecer una variable global), y la variable afectada actualmente hace referencia a la misma cadena, entonces esa variable de destino se borra para reducir el número de referencias a solo 1, la stack.

Y esta es la razón por la que su código original no pudo utilizar esta optimización por completo. La primera parte de tu expresión es sTable + '\n' y la siguiente operación es otra BINARY_ADD :

 >>> import dis >>> dis.dis(compile(r"sTable = sTable + '\n' + sRow", '', 'exec')) 1 0 LOAD_NAME 0 (sTable) 3 LOAD_CONST 0 ('\n') 6 BINARY_ADD 7 LOAD_NAME 1 (sRow) 10 BINARY_ADD 11 STORE_NAME 0 (sTable) 14 LOAD_CONST 1 (None) 17 RETURN_VALUE 

Al primer BINARY_ADD le sigue un LOAD_NAME para acceder a la variable sRow , no a una operación de almacenamiento. Este primer BINARY_ADD siempre debe dar como resultado un nuevo objeto de cadena, cada vez más grande a medida que sTable crece y toma más y más tiempo crear este nuevo objeto de cadena.

Cambiaste este código a:

 sTable += '\n%s' % sRow 

Lo que eliminó la segunda concatenación . Ahora el bytecode es:

 >>> dis.dis(compile(r"sTable += '\n%s' % sRow", '', 'exec')) 1 0 LOAD_NAME 0 (sTable) 3 LOAD_CONST 0 ('\n%s') 6 LOAD_NAME 1 (sRow) 9 BINARY_MODULO 10 INPLACE_ADD 11 STORE_NAME 0 (sTable) 14 LOAD_CONST 1 (None) 17 RETURN_VALUE 

y todo lo que nos queda es un INPLACE_ADD seguido de una tienda. Ahora sTable se puede modificar en el lugar, no dando como resultado un nuevo objeto de cadena cada vez más grande.

Habrías obtenido la misma diferencia de velocidad con:

 sTable = sTable + ('\n%s' % sRow) 

aquí.

Una contrarreloj muestra la diferencia:

 >>> import random >>> from timeit import timeit >>> testlist = [''.join([chr(random.randint(48, 127)) for _ in range(random.randrange(10, 30))]) for _ in range(1000)] >>> def str_threevalue_concat(lst): ... res = '' ... for elem in lst: ... res = res + '\n' + elem ... >>> def str_twovalue_concat(lst): ... res = '' ... for elem in lst: ... res = res + ('\n%s' % elem) ... >>> timeit('f(l)', 'from __main__ import testlist as l, str_threevalue_concat as f', number=10000) 6.196403980255127 >>> timeit('f(l)', 'from __main__ import testlist as l, str_twovalue_concat as f', number=10000) 2.3599119186401367 

La moraleja de esta historia es que no debes usar concatenación de cuerdas en primer lugar. La forma correcta de crear una nueva cadena a partir de cargas de otras cadenas es usar una lista, luego usar str.join() :

 table_rows = [] for something in something_else: table_rows += ['\n', GetRow()] sTable = ''.join(table_rows) 

Esto es aún más rápido aún:

 >>> def str_join_concat(lst): ... res = ''.join(['\n%s' % elem for elem in lst]) ... >>> timeit('f(l)', 'from __main__ import testlist as l, str_join_concat as f', number=10000) 1.7978830337524414 

pero no puedes vencer usando solo '\n'.join(lst) :

 >>> timeit('f(l)', 'from __main__ import testlist as l, nl_join_concat as f', number=10000) 0.23735499382019043