Apakah "x <y <z" lebih cepat dari "x <y dan y <z"?

129

Dari halaman ini , kita tahu bahwa:

Perbandingan berantai lebih cepat daripada menggunakan andoperator. Tulis x < y < zalih-alih x < y and y < z.

Namun, saya mendapat hasil berbeda menguji cuplikan kode berikut:

$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y < z"
1000000 loops, best of 3: 0.322 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.8" "x < y and y < z"
1000000 loops, best of 3: 0.22 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y < z"
1000000 loops, best of 3: 0.279 usec per loop
$ python -m timeit "x = 1.2" "y = 1.3" "z = 1.1" "x < y and y < z"
1000000 loops, best of 3: 0.215 usec per loop

Sepertinya itu x < y and y < zlebih cepat daripada x < y < z. Mengapa?

Setelah mencari beberapa posting di situs ini (seperti ini ) saya tahu bahwa "dievaluasi hanya sekali" adalah kuncinya x < y < z, namun saya masih bingung. Untuk melakukan studi lebih lanjut, saya membongkar dua fungsi ini menggunakan dis.dis:

import dis

def chained_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y < z

def and_compare():
        x = 1.2
        y = 1.3
        z = 1.1
        x < y and y < z

dis.dis(chained_compare)
dis.dis(and_compare)

Dan hasilnya adalah:

## chained_compare ##

  4           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

  5           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

  6          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

  7          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 DUP_TOP
             25 ROT_THREE
             26 COMPARE_OP               0 (<)
             29 JUMP_IF_FALSE_OR_POP    41
             32 LOAD_FAST                2 (z)
             35 COMPARE_OP               0 (<)
             38 JUMP_FORWARD             2 (to 43)
        >>   41 ROT_TWO
             42 POP_TOP
        >>   43 POP_TOP
             44 LOAD_CONST               0 (None)
             47 RETURN_VALUE

## and_compare ##

 10           0 LOAD_CONST               1 (1.2)
              3 STORE_FAST               0 (x)

 11           6 LOAD_CONST               2 (1.3)
              9 STORE_FAST               1 (y)

 12          12 LOAD_CONST               3 (1.1)
             15 STORE_FAST               2 (z)

 13          18 LOAD_FAST                0 (x)
             21 LOAD_FAST                1 (y)
             24 COMPARE_OP               0 (<)
             27 JUMP_IF_FALSE_OR_POP    39
             30 LOAD_FAST                1 (y)
             33 LOAD_FAST                2 (z)
             36 COMPARE_OP               0 (<)
        >>   39 POP_TOP
             40 LOAD_CONST               0 (None)

Tampaknya perintah itu x < y and y < zmemiliki lebih sedikit perbedaan daripada x < y < z. Haruskah saya mempertimbangkan x < y and y < zlebih cepat daripada x < y < z?

Diuji dengan Python 2.7.6 pada CPU Intel (R) Xeon (R) E5640 @ 2.67GHz.

zangw
sumber
8
Semakin banyak perintah yang dibongkar tidak berarti lebih banyak kompleksitas atau kode lebih lambat. Namun melihat timeittes Anda, saya tertarik pada ini.
Marco Bonelli
6
Saya berasumsi perbedaan kecepatan dari "dievaluasi sekali" datang ketika ybukan hanya pencarian variabel, tetapi proses yang lebih mahal seperti pemanggilan fungsi? Yaitu 10 < max(range(100)) < 15lebih cepat daripada 10 < max(range(100)) and max(range(100)) < 15karena max(range(100))disebut sekali untuk kedua perbandingan.
zehnpaard
2
@MarcoBonelli Itu terjadi ketika kode 1 yang dibongkar tidak mengandung loop dan 2) setiap bytecode sangat cepat, karena pada saat itu overhead mainloop menjadi signifikan.
Bakuriu
2
Prediksi cabang mungkin mengacaukan tes Anda.
Corey Ogburn
2
@zehnpaard Saya setuju dengan Anda. Ketika "y" lebih dari nilai sederhana (misalnya, pemanggilan fungsi atau perhitungan), saya berharap fakta bahwa "y" dievaluasi sekali dalam x <y <z memiliki dampak yang jauh lebih terlihat. Dalam kasus yang disajikan, kami berada dalam bar kesalahan: efek prediksi cabang (gagal), bytecode yang kurang optimal, dan efek spesifik platform / CPU lainnya mendominasi. Itu tidak membatalkan aturan umum bahwa mengevaluasi sekali lebih baik (dan juga lebih mudah dibaca), tetapi menunjukkan bahwa ini mungkin tidak menambah nilai sebanyak pada ekstrem.
MartyMacGyver

Jawaban:

111

Perbedaannya adalah bahwa x < y < z yhanya dievaluasi sekali. Ini tidak membuat perbedaan besar jika y adalah variabel, tetapi itu terjadi ketika itu adalah panggilan fungsi, yang membutuhkan waktu untuk menghitung.

from time import sleep
def y():
    sleep(.2)
    return 1.3
%timeit 1.2 < y() < 1.8
10 loops, best of 3: 203 ms per loop
%timeit 1.2 < y() and y() < 1.8
1 loops, best of 3: 405 ms per loop
rampok
sumber
18
Tentu saja, ada juga perbedaan semantik. Tidak hanya bisa y () mengembalikan dua nilai yang berbeda, tetapi dengan variabel evaluasi operator yang kurang dari pada x <y dapat mengubah nilai y. Inilah sebabnya mengapa itu sering tidak dioptimalkan dalam kode byte (jika y adalah variabel non-lokal atau yang berpartisipasi dalam penutupan, misalnya)
Random832
Hanya ingin tahu, mengapa Anda membutuhkan sleep()fungsi di dalamnya?
Prof
@Prof Yaitu untuk mensimulasikan fungsi yang membutuhkan waktu untuk menghitung hasilnya. Jika fungsi kembali segera, tidak akan ada banyak perbedaan antara dua hasil timeit.
Rob
@ Bob Mengapa tidak ada banyak perbedaan? Itu akan menjadi 3ms vs 205ms, yang menunjukkan dengan cukup baik bukan?
Prof
@Prof Anda kehilangan titik bahwa y () dihitung dua kali, jadi itu 2x200ms bukan 1x200ms. Sisanya (3/5 ms) adalah noise yang tidak relevan dalam pengukuran waktu.
Rob
22

Bytecode optimal untuk kedua fungsi yang Anda tetapkan adalah

          0 LOAD_CONST               0 (None)
          3 RETURN_VALUE

karena hasil perbandingan tidak digunakan. Mari kita buat situasi lebih menarik dengan mengembalikan hasil perbandingan. Mari juga hasilnya tidak diketahui pada waktu kompilasi.

def interesting_compare(y):
    x = 1.1
    z = 1.3
    return x < y < z  # or: x < y and y < z

Sekali lagi, dua versi perbandingan secara semantik identik, sehingga bytecode optimal adalah sama untuk kedua konstruksi. Sebisa mungkin aku bisa menyelesaikannya, akan terlihat seperti ini. Saya telah membubuhi keterangan setiap baris dengan konten stack sebelum dan sesudah setiap opcode, dalam Forth notation (atas stack di kanan, --membagi sebelum dan sesudah, trailing ?menunjukkan sesuatu yang mungkin ada atau tidak ada di sana). Perhatikan bahwa RETURN_VALUEbuang semua yang terjadi pada tumpukan di bawah nilai yang dikembalikan.

          0 LOAD_FAST                0 (y)    ;          -- y
          3 DUP_TOP                           ; y        -- y y
          4 LOAD_CONST               0 (1.1)  ; y y      -- y y 1.1
          7 COMPARE_OP               4 (>)    ; y y 1.1  -- y pred
         10 JUMP_IF_FALSE_OR_POP     19       ; y pred   -- y
         13 LOAD_CONST               1 (1.3)  ; y        -- y 1.3
         16 COMPARE_OP               0 (<)    ; y 1.3    -- pred
     >>  19 RETURN_VALUE                      ; y? pred  --

Jika implementasi bahasa, CPython, PyPy, apa pun, tidak menghasilkan bytecode ini (atau urutan operasinya yang setara) untuk kedua variasi, yang menunjukkan kualitas buruk kompiler bytecode itu . Mendapatkan dari urutan bytecode yang Anda posting di atas adalah masalah yang terpecahkan (saya pikir semua yang Anda butuhkan untuk kasus ini adalah pelipatan konstan , penghilangan kode mati , dan pemodelan yang lebih baik dari isi tumpukan; penghapusan subekspresi umum juga akan murah dan berharga ), dan benar-benar tidak ada alasan untuk tidak melakukannya dalam implementasi bahasa modern.

Sekarang, semua implementasi bahasa saat ini memiliki kompiler bytecode berkualitas rendah. Tetapi Anda harus mengabaikannya saat melakukan pengkodean! Anggap kompiler bytecode itu bagus, dan tulis kode yang paling mudah dibaca . Mungkin akan cukup cepat. Jika tidak, cari peningkatan algoritmik terlebih dahulu, dan coba Cython kedua - yang akan memberikan peningkatan lebih banyak untuk upaya yang sama daripada tweak tingkat ekspresi apa pun yang mungkin Anda terapkan.

zwol
sumber
Yah yang paling penting dari semua optimisasi harus dimungkinkan di tempat pertama: inlining. Dan itu jauh dari "masalah terpecahkan" untuk bahasa dinamis yang memungkinkan untuk mengubah implementasi fungsi secara dinamis (dapat dilakukan - HotSpot dapat melakukan hal yang serupa dan hal-hal seperti Graal sedang bekerja untuk membuat optimisasi semacam ini tersedia untuk Python dan bahasa dinamis lainnya ). Dan karena fungsi itu sendiri dapat dipanggil dari modul yang berbeda (atau panggilan mungkin dihasilkan secara dinamis!) Anda benar-benar tidak dapat melakukan optimasi ini di sana.
Voo
1
@ Voo bytecode yang dioptimalkan dengan tangan saya memiliki semantik yang sama persis dengan aslinya bahkan di hadapan dinamisme sewenang-wenang (satu pengecualian: diasumsikan <b ≡ b> a). Juga, inlining berlebihan. Jika Anda melakukan terlalu banyak - dan terlalu mudah untuk melakukannya terlalu banyak - Anda meniup I-cache dan kehilangan semua yang Anda peroleh dan kemudian beberapa.
zwol
Anda benar, saya pikir maksud Anda ingin mengoptimalkan interesting_comparekode bytecode sederhana di atas (yang hanya akan berfungsi dengan inlining). Sepenuhnya offtopic tetapi: Inlining adalah salah satu optimisasi yang paling penting untuk setiap kompiler. Anda dapat mencoba menjalankan beberapa tolok ukur dengan mengatakan HotSpot pada program nyata (bukan tes matematika yang menghabiskan 99% waktu mereka dalam satu loop panas yang dioptimalkan dengan tangan [dan karenanya tidak memiliki panggilan fungsi lagi]] ketika Anda menonaktifkan kemampuan untuk inline apa pun - Anda akan melihat regresi besar.
Voo
@ Voo Ya, bytecode sederhana di atas seharusnya menjadi "versi optimal" kode asli OP, bukan interesting_compare.
zwol
"satu pengecualian: a <b ≡ b> a dianggap" → yang tidak benar dalam Python. Plus, saya pikir CPython bahkan tidak dapat benar-benar menganggap operasi ytidak mengubah tumpukan, karena ia memiliki banyak alat debug.
Veedrac
8

Karena perbedaan dalam output tampaknya karena kurangnya optimasi, saya pikir Anda harus mengabaikan perbedaan itu untuk kebanyakan kasus - bisa jadi perbedaannya akan hilang. Perbedaannya adalah karena yhanya harus dievaluasi sekali dan itu diselesaikan dengan menduplikasi pada tumpukan yang membutuhkan tambahan POP_TOP- solusi untuk menggunakan LOAD_FASTmungkin dapat dilakukan.

Namun perbedaan yang penting adalah bahwa pada x<y and y<zyang kedua yharus dievaluasi dua kali jika x<ydievaluasi benar, ini memiliki implikasi jika evaluasi ymemakan waktu yang cukup lama atau memiliki efek samping.

Dalam sebagian besar skenario Anda harus menggunakan x<y<zmeskipun itu agak lambat.

meroket
sumber
6

Pertama-tama, perbandingan Anda tidak banyak berarti karena dua konstruksi yang berbeda tidak diperkenalkan untuk memberikan peningkatan kinerja, jadi Anda tidak boleh memutuskan apakah akan menggunakan satu di tempat yang lain berdasarkan itu.

The x < y < zmembangun:

  1. Lebih jelas dan lebih langsung dalam artinya.
  2. Semantiknya adalah apa yang Anda harapkan dari "makna matematika" dari perbandingan: evalute x, ydan z sekali dan periksa apakah seluruh kondisi berlaku. Menggunakan andperubahan semantik dengan mengevaluasi ybeberapa kali, yang dapat mengubah hasilnya .

Jadi pilih satu di tempat yang lain tergantung pada semantik yang Anda inginkan dan, jika mereka setara, apakah satu lebih mudah dibaca daripada yang lain.

Ini mengatakan: lebih banyak kode yang dibongkar tidak menyiratkan kode lebih lambat. Namun mengeksekusi lebih banyak operasi bytecode berarti bahwa setiap operasi lebih sederhana dan membutuhkan iterasi dari loop utama. Ini berarti bahwa jika operasi yang Anda lakukan sangat cepat (misalnya pencarian variabel lokal seperti yang Anda lakukan di sana), maka overhead untuk mengeksekusi lebih banyak operasi bytecode bisa jadi masalah.

Tetapi perhatikan bahwa hasil ini tidak berlaku dalam situasi yang lebih umum, hanya pada "kasus terburuk" yang terjadi pada profil Anda. Seperti yang dicatat orang lain, jika Anda berubahy sesuatu yang membutuhkan waktu sedikit lebih lama, Anda akan melihat bahwa hasilnya berubah, karena notasi berantai mengevaluasinya hanya sekali.

Meringkas:

  • Pertimbangkan semantik sebelum pertunjukan.
  • Mempertimbangkan keterbacaan akun.
  • Jangan percaya tolok ukur mikro. Selalu profil dengan berbagai jenis parameter untuk melihat bagaimana waktu fungsi / ekspresi berperilaku dalam kaitannya dengan parameter tersebut dan pertimbangkan bagaimana Anda berencana untuk menggunakannya.
Bakuriu
sumber
5
Saya pikir jawaban Anda tidak menyertakan fakta langsung dan relevan bahwa halaman yang dikutip, dalam kasus tertentu dari pertanyaan - membandingkan float, sama sekali salah. Perbandingan berantai tidak lebih cepat seperti yang terlihat di kedua pengukuran dan dalam bytecode yang dihasilkan.
pvg
30
Menjawab pertanyaan yang menandai kinerja dengan "mungkin Anda seharusnya tidak terlalu memikirkan kinerja" tampaknya tidak berguna bagi saya. Anda membuat asumsi yang berpotensi menggurui tentang pemahaman penanya tentang prinsip-prinsip pemrograman umum dan kemudian kebanyakan membicarakannya alih-alih masalah yang dihadapi.
Ben Millwood
@ Veerdac Anda salah membaca komentar. Optimasi yang diusulkan dalam dokumen asli yang diandalkan OP adalah salah, dalam hal mengapung minimal. Itu tidak lebih cepat.
pvg