Tại sao mã sử dụng biến trung gian nhanh hơn mã không sử dụng?


76

Tôi đã gặp phải hành vi kỳ lạ này và không giải thích được. Đây là những điểm chuẩn:

py -3 -m timeit "tuple(range(2000)) == tuple(range(2000))"
10000 loops, best of 3: 97.7 usec per loop
py -3 -m timeit "a = tuple(range(2000));  b = tuple(range(2000)); a==b"
10000 loops, best of 3: 70.7 usec per loop

Làm thế nào mà so sánh với phép gán biến lại nhanh hơn sử dụng một lớp lót với các biến tạm thời nhiều hơn 27%?

Theo tài liệu Python, việc thu thập rác bị vô hiệu hóa trong thời gian chờ nên không thể như vậy. Nó có phải là một loại tối ưu hóa không?

Kết quả cũng có thể được tái tạo bằng Python 2.x mặc dù ở mức độ thấp hơn.

Chạy Windows 7, CPython 3.5.1, Intel i7 3.40 GHz, 64 bit cả OS và Python. Có vẻ như một máy khác mà tôi đã thử chạy ở Intel i7 3,60 GHz với Python 3.5.0 không tái tạo kết quả.


Chạy bằng cùng một quy trình Python với timeit.timeit()@ 10000 vòng lặp được tạo ra lần lượt là 0,703 và 0,804. Vẫn hiển thị mặc dù ở mức độ thấp hơn. (~ 12,5%)


6
So sánh dis.dis("tuple(range(2000)) == tuple(range(2000))")với dis.dis("a = tuple(range(2000)); b = tuple(range(2000)); a==b"). Trong cấu hình của tôi, đoạn mã thứ hai thực sự chứa tất cả mã bytecode từ đoạn mã đầu tiên và một số hướng dẫn bổ sung. Thật khó tin rằng nhiều lệnh bytecode hơn dẫn đến việc thực thi nhanh hơn. Có thể đó là một số lỗi trong phiên bản Python cụ thể?
Łukasz Rogalski

1
Nếu bạn cố gắng tái tạo điều này, vui lòng chạy thử nghiệm nhiều lần theo các lệnh thực thi khác nhau. - Bất kể kết quả và sự kỳ quặc của điều này, tôi nghĩ rằng câu hỏi không có giá trị đặc biệt cho SO.
chọc

3
Tôi nghĩ điều này khá thú vị. @poke, bạn cần nhớ rằng câu trả lời cho hiện tượng tương tự hiện là câu trả lời được ủng hộ nhiều nhất trong stackoverflow.
Antti Haapala

3
Ngoài ra, hãy thử chạy thử nghiệm trong một quy trình Python duy nhất bằng cách sử dụng timeitmô-đun trực tiếp. So sánh giữa hai quy trình Python riêng biệt có thể bị ảnh hưởng bởi bộ lập lịch tác vụ của hệ điều hành hoặc các tác động khác.
poke

1
@aluriak "tốt nhất trong số 3" có nghĩa là tốt nhất trong ba mức trung bình . Điều này được thực hiện bởi vì một số mức trung bình có thể bao gồm, chẳng hạn, một quá trình đình trệ không mong muốn. Việc tận dụng mức trung bình tốt nhất sẽ tránh được điều đó.
Veedrac

Câu trả lời:


107

Kết quả của tôi tương tự như của bạn: mã sử dụng các biến trung gian khá nhất quán nhanh hơn ít nhất 10-20% trong Python 3.4. Tuy nhiên, khi tôi sử dụng IPython trên cùng một trình thông dịch Python 3.4, tôi nhận được những kết quả sau:

In [1]: %timeit -n10000 -r20 tuple(range(2000)) == tuple(range(2000))
10000 loops, best of 20: 74.2 µs per loop

In [2]: %timeit -n10000 -r20 a = tuple(range(2000));  b = tuple(range(2000)); a==b
10000 loops, best of 20: 75.7 µs per loop

Đáng chú ý, tôi không bao giờ đạt được thậm chí gần với 74,2 µs cho trước đây khi tôi sử dụng -mtimeittừ dòng lệnh.

Vì vậy, Heisenbug này hóa ra là một cái gì đó khá thú vị. Tôi quyết định chạy lệnh với stracevà thực sự có điều gì đó khó hiểu đang xảy ra:

% strace -o withoutvars python3 -m timeit "tuple(range(2000)) == tuple(range(2000))"
10000 loops, best of 3: 134 usec per loop
% strace -o withvars python3 -mtimeit "a = tuple(range(2000));  b = tuple(range(2000)); a==b"
10000 loops, best of 3: 75.8 usec per loop
% grep mmap withvars|wc -l
46
% grep mmap withoutvars|wc -l
41149

Bây giờ đó là một lý do chính đáng cho sự khác biệt. Mã không sử dụng biến khiến lệnh mmapgọi hệ thống được gọi nhiều hơn gần 1000 lần so với mã sử dụng biến trung gian.

Các withoutvarsđầy mmap/munmap cho một vùng 256k; những dòng này được lặp đi lặp lại nhiều lần:

mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0
mmap(NULL, 262144, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f32e56de000
munmap(0x7f32e56de000, 262144)          = 0

Cuộc mmapgọi dường như đến từ hàm _PyObject_ArenaMmapfrom Objects/obmalloc.c; những obmalloc.ccũng chứa các vĩ mô ARENA_SIZE, mà là #defined là (256 << 10)(có nghĩa là 262144); tương tự như vậymunmap trận đấu với_PyObject_ArenaMunmap from obmalloc.c.

obmalloc.c nói rằng

Trước Python 2.5, các đấu trường chưa bao giờ được chỉnh sửa free(). Bắt đầu với Python 2.5, chúng tôi cố gắngfree() đấu trường và sử dụng một số chiến lược kinh nghiệm nhẹ để tăng khả năng các đấu trường cuối cùng có thể được giải phóng.

Do đó, những kinh nghiệm này và thực tế là trình cấp phát đối tượng Python giải phóng các vùng trống này ngay khi chúng được làm trống dẫn đến việc python3 -mtimeit 'tuple(range(2000)) == tuple(range(2000))'kích hoạt hành vi bệnh lý trong đó một vùng bộ nhớ 256 kiB được cấp phát lại và giải phóng nhiều lần; và phân bổ này xảy ra với mmap/ munmap, tương đối tốn kém khi chúng là các lệnh gọi hệ thống - hơn nữa,mmap vớiMAP_ANONYMOUS yêu cầu rằng các trang mới được ánh xạ phải được đánh dấu bằng 0 - mặc dù Python sẽ không quan tâm.

Hành vi không xuất hiện trong mã sử dụng các biến trung gian, vì nó đang sử dụng nhiều bộ nhớ hơn một chút và không có vùng bộ nhớ nào có thể được giải phóng vì một số đối tượng vẫn được cấp phát trong đó. Đó là bởi vì timeitnó sẽ làm cho nó thành một vòng lặp không giống như

for n in range(10000)
    a = tuple(range(2000))
    b = tuple(range(2000))
    a == b

Bây giờ hành vi là cả hai absẽ tiếp tục bị ràng buộc cho đến khi chúng * được gán lại, vì vậy trong lần lặp thứ hai, tuple(range(2000))sẽ phân bổ một bộ thứ 3 và việc gán a = tuple(...)sẽ làm giảm số lượng tham chiếu của bộ cũ, khiến nó được giải phóng và tăng số tham chiếu của bộ tuple mới; thì điều tương tự cũng xảy ra với b. Do đó, sau lần lặp đầu tiên luôn có ít nhất 2 trong số các bộ giá trị này, nếu không phải là 3, vì vậy việc ném không xảy ra.

Đáng chú ý nhất là không thể đảm bảo rằng mã sử dụng các biến trung gian luôn nhanh hơn - thực sự trong một số thiết lập, việc sử dụng các biến trung gian sẽ dẫn đến các mmapcuộc gọi bổ sung , trong khi mã so sánh trực tiếp các giá trị trả về có thể tốt.


Có người hỏi rằng tại sao điều này lại xảy ra, khi timeittắt tính năng thu gom rác. Nó thực sự là sự thật rằng timeitcó phải nó :

Ghi chú

Theo mặc định, timeit()tạm thời tắt tính năng thu gom rác trong thời gian. Ưu điểm của phương pháp này là nó làm cho các thời gian độc lập có thể so sánh được với nhau. Nhược điểm này là GC có thể là một thành phần quan trọng của hiệu suất của chức năng được đo. Nếu vậy, GC có thể được bật lại dưới dạng câu lệnh đầu tiên trong chuỗi thiết lập. Ví dụ:

Tuy nhiên, trình thu gom rác của Python chỉ ở đó để thu hồi rác theo chu kỳ , tức là tập hợp các đối tượng có tham chiếu tạo thành chu trình. Nó không phải là trường hợp ở đây; thay vào đó các đối tượng này được giải phóng ngay lập tức khi số lượng tham chiếu giảm xuống không.


1
Woha, thật thú vị. Người thu gom rác (đã bị vô hiệu hóa đúng giờ) không nên lo việc giải phóng hay ít nhất là nên chăm sóc nó? Và nó đặt ra một câu hỏi khác: Không phải những cuộc gọi lặp đi lặp lại đó là lỗi sao?
Bharel

6
@Bharel giống như "bị hỏng như được thiết kế"
Antti Haapala

1
@Bharel Nó phụ thuộc vào việc cấp phát vùng nhớ mới hay không ; rất có thể các hệ thống khác có các đấu trường miễn phí một phần có đủ bộ nhớ trống trong các vùng mà không cần nhiều hơn. Ngay cả cùng một phiên bản Python trên các hệ thống bề ngoài tương tự cũng có thể có hành vi khác nhau - những thứ như đường dẫn cài đặt Python, số lượng gói trong site-packages, biến môi trường, thư mục làm việc hiện tại - tất cả đều ảnh hưởng đến bố cục bộ nhớ của quá trình.
Antti Haapala

7
@Bharel: Người thu gom rác trong CPython được gọi đúng hơn là "người thu gom rác theo chu kỳ"; nó chỉ quan tâm đến việc giải phóng các chu trình tham chiếu bị cô lập, không phải việc thu gom rác chung. Tất cả các hoạt động dọn dẹp khác đều đồng bộ và theo thứ tự; nếu tham chiếu cuối cùng đến đối tượng cuối cùng trong đấu trường được phát hành, đối tượng đó sẽ bị xóa ngay lập tức và đấu trường ngay lập tức được giải phóng, không cần sự tham gia của người thu gom rác theo chu kỳ. Đó là lý do tại sao nó hợp pháp để vô hiệu hóa gc; nếu nó tắt tính năng dọn dẹp chung, bạn sẽ hết bộ nhớ khá nhanh.
ShadowRanger

1
để tóm tắt: hiệu ứng trong câu trả lời không thể tái tạo (không có sự khác biệt đáng kể về số lượng lệnh gọi mmap) trên bản /usr/bin/python3phân phối mặc định với Ubuntu 16.04 ( python3-minimalgói). Tôi cũng đã thử các hình ảnh docker khác nhau, ví dụ, docker run --rm python:3.6.4 python -m timeit ...- không có hiệu ứng (bao gồm cả 3,4). Các hành vi trong câu trả lời của bạn là tái sản xuất nếu python được biên soạn từ nguồn (ví dụ: 3.6.4-d48eceb, nhưng không ảnh hưởng đến 3,7-e3256087)
JFS

7

Câu hỏi đầu tiên ở đây phải là, nó có thể tái tạo được không? Đối với một số người trong chúng ta, nó chắc chắn là mặc dù những người khác nói rằng họ không thấy hiệu quả. Điều này trên Fedora, với bài kiểm tra bình đẳng được thay đổi thành isthực sự so sánh dường như không liên quan đến kết quả và phạm vi được đẩy lên đến 200.000 vì điều đó dường như tối đa hóa hiệu ứng:

$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7.03 msec per loop
$ python3 -m timeit "a = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "a = b = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 9.99 msec per loop
$ python3 -m timeit "a = b = tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.2 msec per loop
$ python3 -m timeit "tuple(range(200000)) is tuple(range(200000))"
100 loops, best of 3: 10.1 msec per loop
$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7 msec per loop
$ python3 -m timeit "a = tuple(range(200000));  b = tuple(range(200000)); a is b"
100 loops, best of 3: 7.02 msec per loop

Tôi lưu ý rằng các biến thể giữa các lần chạy và thứ tự mà các biểu thức được chạy tạo ra rất ít khác biệt đối với kết quả.

Thêm bài tập vào ab vào phiên bản chậm không làm tăng tốc độ. Trên thực tế, như chúng ta có thể mong đợi việc gán cho các biến cục bộ có tác dụng không đáng kể. Điều duy nhất làm tăng tốc độ là chia hoàn toàn biểu thức thành hai. Sự khác biệt duy nhất mà điều này nên tạo ra là nó làm giảm độ sâu ngăn xếp tối đa mà Python sử dụng trong khi đánh giá biểu thức (từ 4 xuống 3).

Điều đó cho chúng ta manh mối rằng hiệu ứng có liên quan đến độ sâu ngăn xếp, có lẽ mức bổ sung sẽ đẩy ngăn xếp sang một trang bộ nhớ khác. Nếu vậy, chúng ta sẽ thấy rằng việc thực hiện các thay đổi khác ảnh hưởng đến ngăn xếp sẽ thay đổi (rất có thể sẽ giết hiệu ứng) và trên thực tế, đó là những gì chúng ta thấy:

$ python3 -m timeit -s "def foo():
   tuple(range(200000)) is tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   tuple(range(200000)) is tuple(range(200000))" "foo()"
100 loops, best of 3: 10 msec per loop
$ python3 -m timeit -s "def foo():
   a = tuple(range(200000));  b = tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 9.97 msec per loop
$ python3 -m timeit -s "def foo():
   a = tuple(range(200000));  b = tuple(range(200000)); a is b" "foo()"
100 loops, best of 3: 10 msec per loop

Vì vậy, tôi nghĩ rằng hiệu quả hoàn toàn là do lượng ngăn xếp Python được tiêu thụ trong quá trình định thời gian. Nó vẫn còn kỳ lạ mặc dù.


Tuy nhiên, 2 máy có cùng thẻ nhớ và cùng hệ điều hành sẽ cho kết quả khác nhau. Độ sâu ngăn xếp nghe có vẻ là một lý thuyết hay nhưng nó không giải thích được sự khác biệt giữa các máy.
Bharel
Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.