Waarom is variabele1 += variabele2 veel sneller dan variabele1 = variabele1 + variabele2?

Ik heb wat Python-code geërfd die wordt gebruikt om enorme tabellen te maken (van maximaal 19 kolommen breed en 5000 rijen). Het duurde negen secondenvoordat de tafel op het scherm werd getekend. Ik heb gemerkt dat elke rij is toegevoegd met deze code:

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

waar sTableeen tekenreeks is.

Ik heb dat veranderd in:

sTable += '\n' + GetRow()

en ik zag dat de tafel nu binnen zes secondenverscheen.

En toen veranderde ik het in:

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

gebaseerd op deze prestatietips voor Python(nog steeds zes seconden).

Aangezien dit ongeveer 5000 keer is gebeld, werd het prestatieprobleem duidelijk. Maar waarom was er zo’n groot verschil? En waarom heeft de compiler het probleem niet in de eerste versie opgemerkt en geoptimaliseerd?


Antwoord 1, autoriteit 100%

Dit gaat niet over het gebruik van inplace +=versus +binaire add. Je hebt ons niet het hele verhaal verteld. Uw originele versie heeft 3 strings aaneengeschakeld, niet slechts twee:

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

Python probeert te helpen en optimaliseert stringconcatenatie; zowel bij gebruik van strobj += otherstrobjals strobj = strobj + otherstringobj, maar het kan deze optimalisatie niet toepassen als er meer dan 2 strings zijn.

Python-strings zijn normaal gesprokenonveranderlijk, maar als er geen andere verwijzingen zijn naar het linker stringobject enhet toch wordt teruggekaatst, dan cheat Python en muteert de string. Dit voorkomt dat je elke keer dat je samenvoegt een nieuwe string moet maken, en dat kan leiden tot een grote snelheidsverbetering.

Dit wordt geïmplementeerd in de bytecode-evaluatielus. Zowel bij gebruik van BINARY_ADDop twee stringsals wanneer met behulp van INPLACE_ADDop twee strings, delegeert Python aaneenschakeling naar een speciale hulpfunctie string_concatenate(). Om de aaneenschakeling te kunnen optimaliseren door de string te muteren, moet hij er eerst voor zorgen dat de string er geen andere verwijzingen naar heeft; als alleen de stapel en de originele variabele ernaar verwijzen, dan kan dit worden gedaan, ende volgendebewerking zal de originele variabele verwijzing vervangen.

Dus als er slechts 2 verwijzingen naar de tekenreeks zijn, en de volgende operator is een van STORE_FAST(stel een lokale variabele in), STORE_DEREF(stel een variabele in waarnaar wordt verwezen door gesloten over functies) of STORE_NAME(stel een globale variabele in), en de betrokken variabele verwijst momenteel naar dezelfde tekenreeks, dan wordt die doelvariabele gewist om het aantal verwijzingen te verminderen tot slechts 1, de stapel.

En dit is de reden waarom uw oorspronkelijke code deze optimalisatie niet volledig kon gebruiken. Het eerste deel van uw expressie is sTable + '\n'en de volgendebewerking is een andere BINARY_ADD:

>>> import dis
>>> dis.dis(compile(r"sTable = sTable + '\n' + sRow", '<stdin>', '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        

De eerste BINARY_ADDwordt gevolgd door een LOAD_NAMEom toegang te krijgen tot de variabele sRow, niet een winkelbewerking. Deze eerste BINARY_ADDmoet altijd resulteren in een nieuw string-object, steeds groter naarmate sTablegroeit en het kost steeds meer tijd om dit nieuwe string-object te maken.

Je hebt deze code gewijzigd in:

sTable += '\n%s' % sRow

die de tweede aaneenschakeling heeft verwijderd. Nu is de bytecode:

>>> dis.dis(compile(r"sTable += '\n%s' % sRow", '<stdin>', '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        

en alles wat we nog hebben is een INPLACE_ADDgevolgd door een winkel. Nu kan sTableter plekke worden gewijzigd, wat niet resulteert in een steeds groter nieuw tekenreeksobject.

Je zou hetzelfde snelheidsverschil hebben gekregen met:

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

hier.

Een tijdrit toont het verschil:

>>> 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

De moraal van dit verhaal is dat je in de eerste plaats geen string-concatenatie moet gebruiken. De juiste manier om een ​​nieuwe string op te bouwen uit heel veel andere strings is door een lijst te gebruiken en vervolgens str.join():

te gebruiken

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

Dit is nog sneller:

>>> 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

maar je kunt het niet verslaan met alleen '\n'.join(lst):

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

Other episodes