Mengapa kode Python berjalan lebih cepat dalam suatu fungsi?

835
def main():
    for i in xrange(10**8):
        pass
main()

Sepotong kode dalam Python ini berjalan di (Catatan: Pengaturan waktu dilakukan dengan fungsi waktu dalam BASH di Linux.)

real    0m1.841s
user    0m1.828s
sys     0m0.012s

Namun, jika for loop tidak ditempatkan dalam suatu fungsi,

for i in xrange(10**8):
    pass

kemudian berjalan untuk waktu yang lebih lama:

real    0m4.543s
user    0m4.524s
sys     0m0.012s

Kenapa ini?

thedoctar
sumber
16
Bagaimana sebenarnya Anda melakukan pengaturan waktu?
Andrew Jaffe
53
Hanya intuisi, tidak yakin apakah itu benar: Saya kira itu karena lingkup. Dalam kasus fungsi, ruang lingkup baru dibuat (yaitu jenis hash dengan nama variabel terikat dengan nilainya). Tanpa fungsi, variabel berada dalam lingkup global, ketika Anda dapat menemukan banyak hal, maka memperlambat lingkaran.
Scharron
4
@ Skarron Sepertinya bukan itu. Menentukan 200k variabel dummy ke dalam ruang lingkup tanpa hal itu memengaruhi waktu berjalan.
Deestan
2
Alex Martelli menulis jawaban yang bagus mengenai stackoverflow.com/a/1813167/174728
John La Rooy
53
@ Skarron Anda setengah benar. Ini tentang cakupan, tetapi alasannya lebih cepat di penduduk lokal adalah bahwa cakupan lokal sebenarnya diimplementasikan sebagai array bukan kamus (karena ukurannya diketahui pada waktu kompilasi).
Katriel

Jawaban:

532

Anda mungkin bertanya mengapa lebih cepat menyimpan variabel lokal daripada global. Ini adalah detail implementasi CPython.

Ingat bahwa CPython dikompilasi ke bytecode, yang dijalankan oleh interpreter. Ketika suatu fungsi dikompilasi, variabel lokal disimpan dalam array ukuran tetap ( bukan a dict) dan nama variabel ditugaskan ke indeks. Ini dimungkinkan karena Anda tidak dapat menambahkan variabel lokal secara dinamis ke suatu fungsi. Kemudian mengambil variabel lokal secara harfiah adalah pencarian pointer ke dalam daftar dan peningkatan jumlah referensi PyObjectyang sepele.

Bandingkan ini dengan pencarian global ( LOAD_GLOBAL), yang merupakan dictpencarian sebenarnya yang melibatkan hash dan sebagainya. Kebetulan, inilah mengapa Anda perlu menentukan global iapakah Anda ingin menjadi global: jika Anda pernah menetapkan variabel di dalam lingkup, kompiler akan mengeluarkan STORE_FASTs untuk aksesnya kecuali jika Anda tidak melakukannya.

Omong-omong, pencarian global masih cukup optimal. Pencarian atribut foo.baradalah yang sangat lambat!

Berikut adalah ilustrasi kecil tentang efisiensi variabel lokal.

Katriel
sumber
6
Ini juga berlaku untuk PyPy, hingga versi saat ini (1,8 pada saat penulisan ini.) Kode uji dari OP berjalan sekitar empat kali lebih lambat dalam lingkup global dibandingkan dengan di dalam suatu fungsi.
GDorn
4
@Walkerneo Mereka tidak, kecuali jika Anda mengatakannya mundur. Menurut apa yang dikatakan katrielalex dan ecatmur, pencarian variabel global lebih lambat daripada pencarian variabel lokal karena metode penyimpanan.
Jeremy Pridemore
2
@Walkerneo. Percakapan utama yang terjadi di sini adalah perbandingan antara pencarian variabel lokal dalam suatu fungsi dan pencarian variabel global yang ditentukan pada tingkat modul. Jika Anda melihat dalam komentar asli Anda membalas jawaban ini Anda berkata, "Saya tidak akan berpikir pencarian variabel global lebih cepat daripada pencarian properti variabel lokal." dan mereka tidak. katrielalex mengatakan bahwa, meskipun pencarian variabel lokal lebih cepat daripada yang global, bahkan yang global cukup dioptimalkan dan lebih cepat daripada pencarian atribut (yang berbeda). Saya tidak punya cukup ruang dalam komentar ini untuk lebih.
Jeremy Pridemore
3
@Walkerneo foo.bar bukan akses lokal. Ini adalah atribut dari suatu objek. (Maafkan kekurangan format) def foo_func: x = 5, xbersifat lokal untuk suatu fungsi. Mengakses xadalah lokal. foo = SomeClass(), foo.baradalah akses atribut. val = 5global adalah global. Adapun atribut local> global> speed sesuai dengan apa yang saya baca di sini. Jadi mengakses xdi foo_funcadalah tercepat, diikuti oleh val, diikuti oleh foo.bar. foo.attrbukan pencarian lokal karena dalam konteks obrolan ini, kita berbicara tentang pencarian lokal menjadi pencarian variabel yang termasuk dalam fungsi.
Jeremy Pridemore
3
@thedoctar melihat globals()fungsinya. Jika Anda ingin info lebih dari itu, Anda mungkin harus mulai mencari kode sumber untuk Python. Dan CPython hanyalah nama untuk implementasi Python yang biasa - jadi Anda mungkin sudah menggunakannya!
Katriel
661

Di dalam suatu fungsi, bytecode adalah:

  2           0 SETUP_LOOP              20 (to 23)
              3 LOAD_GLOBAL              0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_FAST               0 (i)

  3          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               0 (None)
             26 RETURN_VALUE        

Di tingkat atas, bytecode adalah:

  1           0 SETUP_LOOP              20 (to 23)
              3 LOAD_NAME                0 (xrange)
              6 LOAD_CONST               3 (100000000)
              9 CALL_FUNCTION            1
             12 GET_ITER            
        >>   13 FOR_ITER                 6 (to 22)
             16 STORE_NAME               1 (i)

  2          19 JUMP_ABSOLUTE           13
        >>   22 POP_BLOCK           
        >>   23 LOAD_CONST               2 (None)
             26 RETURN_VALUE        

Perbedaannya adalah bahwa STORE_FASTlebih cepat (!) Daripada STORE_NAME. Ini karena dalam suatu fungsi, iadalah lokal tetapi pada tingkat atas itu adalah global.

Untuk memeriksa bytecode, gunakan dismodul . Saya dapat membongkar fungsi secara langsung, tetapi untuk membongkar kode tingkat atas saya harus menggunakan compilebuiltin .

ecatmur
sumber
171
Dikonfirmasi oleh eksperimen. Memasukkan global ike dalam mainfungsi membuat waktu berjalan setara.
Deestan
44
Ini menjawab pertanyaan tanpa menjawab pertanyaan :) Dalam kasus variabel fungsi lokal, CPython sebenarnya menyimpan ini dalam tuple (yang dapat diubah dari kode C) hingga kamus diminta (misalnya melalui locals(), atau inspect.getframe()dll.). Mencari elemen array dengan integer konstan jauh lebih cepat daripada mencari dict.
dmw
3
Ini sama dengan C / C ++ juga, menggunakan variabel global menyebabkan perlambatan signifikan
codejammer
3
Ini adalah yang pertama saya lihat dari bytecode .. Bagaimana cara melihatnya, dan penting untuk diketahui?
Zack
4
@ kimsey saya setuju. Hanya ingin berbagi dua hal i) Perilaku ini dicatat dalam bahasa pemrograman lain ii) Agen sebab akibat lebih merupakan sisi arsitektur dan bukan bahasa itu sendiri dalam arti yang sebenarnya
codejammer
41

Selain waktu penyimpanan variabel lokal / global, prediksi opcode membuat fungsi lebih cepat.

Seperti jawaban lain menjelaskan, fungsi menggunakan STORE_FASTopcode di loop. Inilah bytecode untuk loop fungsi:

    >>   13 FOR_ITER                 6 (to 22)   # get next value from iterator
         16 STORE_FAST               0 (x)       # set local variable
         19 JUMP_ABSOLUTE           13           # back to FOR_ITER

Biasanya ketika sebuah program dijalankan, Python mengeksekusi setiap opcode satu demi satu, melacak stack dan melakukan preforming cek lainnya pada frame stack setelah setiap opcode dieksekusi. Prediksi opcode berarti bahwa dalam kasus-kasus tertentu Python dapat melompat langsung ke opcode berikutnya, sehingga menghindari beberapa overhead ini.

Dalam hal ini, setiap kali Python melihat FOR_ITER(bagian atas loop), itu akan "memprediksi" itu STORE_FASTadalah opcode berikutnya yang harus dijalankan. Python kemudian mengintip opcode berikutnya dan, jika prediksi itu benar, ia langsung melompat STORE_FAST. Ini memiliki efek memeras dua opcode menjadi satu opcode tunggal.

Di sisi lain, STORE_NAMEopcode digunakan dalam loop di tingkat global. Python tidak * tidak * membuat prediksi serupa ketika melihat opcode ini. Sebaliknya, itu harus kembali ke atas evaluasi-loop yang memiliki implikasi yang jelas untuk kecepatan di mana loop dieksekusi.

Untuk memberikan detail teknis lebih lanjut tentang optimasi ini, berikut adalah kutipan dari ceval.cfile ("mesin" mesin virtual Python):

Beberapa opcode cenderung berpasangan sehingga memungkinkan untuk memprediksi kode kedua ketika yang pertama dijalankan. Misalnya, GET_ITERsering diikuti oleh FOR_ITER. Dan FOR_ITERsering diikuti olehSTORE_FAST atau UNPACK_SEQUENCE.

Memverifikasi biaya prediksi uji tunggal berkecepatan tinggi dari variabel register terhadap konstanta. Jika pasangan itu baik, maka predikasi cabang internal prosesor sendiri memiliki kemungkinan keberhasilan yang tinggi, menghasilkan transisi hampir nol-overhead ke opcode berikutnya. Prediksi yang berhasil menghemat perjalanan melalui eval-loop termasuk dua cabangnya yang tidak dapat diprediksi, HAS_ARGtes dan switch-case. Dikombinasikan dengan prediksi cabang internal prosesor, sukses PREDICTmemiliki efek membuat dua opcode berjalan seolah-olah mereka adalah opcode baru tunggal dengan tubuh digabungkan.

Kita dapat melihat dalam kode sumber untuk FOR_ITERopcode di mana prediksi STORE_FASTdibuat:

case FOR_ITER:                         // the FOR_ITER opcode case
    v = TOP();
    x = (*v->ob_type->tp_iternext)(v); // x is the next value from iterator
    if (x != NULL) {                     
        PUSH(x);                       // put x on top of the stack
        PREDICT(STORE_FAST);           // predict STORE_FAST will follow - success!
        PREDICT(UNPACK_SEQUENCE);      // this and everything below is skipped
        continue;
    }
    // error-checking and more code for when the iterator ends normally                                     

The PREDICTFungsi memperluas untuk if (*next_instr == op) goto PRED_##opyaitu kita hanya melompat ke awal dari opcode diprediksi. Dalam hal ini, kita lompat ke sini:

PREDICTED_WITH_ARG(STORE_FAST);
case STORE_FAST:
    v = POP();                     // pop x back off the stack
    SETLOCAL(oparg, v);            // set it as the new local variable
    goto fast_next_opcode;

Variabel lokal sekarang diatur dan opcode berikutnya siap untuk dieksekusi. Python terus melalui iterable hingga mencapai akhir, membuat prediksi sukses setiap saat.

The halaman wiki Python memiliki informasi lebih lanjut tentang bagaimana mesin virtual CPython ini bekerja.

Alex Riley
sumber
Pembaruan kecil: Pada CPython 3.6, penghematan dari prediksi turun sedikit; bukannya dua cabang yang tidak dapat diprediksi, hanya ada satu. Perubahan ini karena beralih dari bytecode ke wordcode ; sekarang semua "kode kata" memiliki argumen, itu hanya nol ketika instruksi tidak secara logis mengambil argumen. Dengan demikian, HAS_ARGpengujian tidak pernah terjadi (kecuali ketika pelacakan tingkat rendah diaktifkan pada saat kompilasi dan runtime, yang tidak dilakukan oleh bangunan normal), hanya menyisakan satu lompatan yang tidak dapat diprediksi.
ShadowRanger
Bahkan lompatan tak terduga itu tidak terjadi di sebagian besar build CPython, karena lompatan baru ( seperti Python 3.1 , diaktifkan secara default dalam 3.2 ) dihitung; saat digunakan, PREDICTmakro sepenuhnya dinonaktifkan; sebaliknya kebanyakan kasus berakhir dengan DISPATCHcabang yang secara langsung. Tetapi pada CPU prediksi cabang, efeknya mirip dengan PREDICT, karena percabangan (dan prediksi) adalah per opcode, meningkatkan kemungkinan prediksi cabang yang sukses.
ShadowRanger