Tại sao mã Python chạy nhanh hơn trong một hàm?


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

Đoạn mã này trong Python chạy trong (Lưu ý: Thời gian được thực hiện với hàm thời gian trong BASH trong Linux.)

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

Tuy nhiên, nếu vòng lặp for không được đặt trong một hàm,

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

sau đó nó chạy trong một thời gian dài hơn nhiều:

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

Tại sao lại thế này?


16
Làm thế nào bạn thực sự làm thời gian?
Andrew Jaffe

53
Chỉ là một trực giác, không chắc nó có đúng không: tôi sẽ đoán nó là do phạm vi. Trong trường hợp hàm, một phạm vi mới được tạo ra (tức là loại băm có tên biến liên kết với giá trị của chúng). Không có hàm, các biến nằm trong phạm vi toàn cục, khi bạn có thể tìm thấy nhiều thứ, do đó làm chậm vòng lặp.
Scharron

4
@Scharron Điều đó dường như không phải là nó. Xác định các biến giả 200k vào phạm vi mà không ảnh hưởng rõ rệt đến thời gian chạy.
Deestan

2
Alex Martelli đã viết một câu trả lời hay liên quan đến stackoverflow.com/a/1813167/174728
John La Rooy

53
@Sarron bạn đúng một nửa. Đó là về phạm vi, nhưng lý do nó nhanh hơn ở người dân địa phương là vì phạm vi địa phương thực sự được triển khai dưới dạng mảng thay vì từ điển (vì kích thước của chúng được biết đến vào thời gian biên dịch).
Katriel

Câu trả lời:


532

Bạn có thể hỏi tại sao lưu trữ các biến cục bộ nhanh hơn so với toàn cầu. Đây là một chi tiết thực hiện CPython.

Hãy nhớ rằng CPython được biên dịch thành mã byte, trình thông dịch chạy. Khi một hàm được biên dịch, các biến cục bộ được lưu trữ trong một mảng có kích thước cố định ( không phải a dict) và các tên biến được gán cho các chỉ mục. Điều này là có thể bởi vì bạn không thể tự động thêm các biến cục bộ vào một hàm. Sau đó, lấy một biến cục bộ theo nghĩa đen là một con trỏ tra cứu vào danh sách và tăng số đếm trên PyObjectđó là tầm thường.

Tương phản điều này với một tra cứu toàn cầu ( LOAD_GLOBAL), đó là một dicttìm kiếm thực sự liên quan đến hàm băm và vân vân. Ngẫu nhiên, đây là lý do tại sao bạn cần chỉ định global inếu bạn muốn nó là toàn cục: nếu bạn đã gán cho một biến trong phạm vi, trình biên dịch sẽ cấp STORE_FASTs cho quyền truy cập của nó trừ khi bạn không nói cho nó biết.

Nhân tiện, tra cứu toàn cầu vẫn được tối ưu hóa khá. Tra cứu thuộc tính foo.barlà những người thực sự chậm!

Dưới đây là minh họa nhỏ về hiệu quả biến cục bộ.


6
Điều này cũng áp dụng cho PyPy, cho đến phiên bản hiện tại (1.8 tại thời điểm viết bài này.) Mã kiểm tra từ OP chạy chậm hơn khoảng bốn lần trong phạm vi toàn cầu so với bên trong một chức năng.
GDorn

4
@Walkerneo Họ không, trừ khi bạn nói ngược lại. Theo những gì katrielalex và ecatmur đang nói, việc tra cứu biến toàn cầu chậm hơn so với tra cứu biến cục bộ do phương thức lưu trữ.
Jeremy Pridemore

2
@Walkerneo Cuộc trò chuyện chính đang diễn ra ở đây là so sánh giữa tra cứu biến cục bộ trong một hàm và tra cứu biến toàn cục được xác định ở cấp mô-đun. Nếu bạn nhận thấy trong câu trả lời nhận xét ban đầu của mình cho câu trả lời này, bạn đã nói "Tôi sẽ không nghĩ rằng việc tra cứu biến toàn cục nhanh hơn so với tra cứu thuộc tính biến cục bộ." và họ thì không. katrielalex nói rằng, mặc dù tra cứu biến cục bộ nhanh hơn so với toàn cầu, nhưng ngay cả những cái toàn cầu cũng được tối ưu hóa và nhanh hơn so với tra cứu thuộc tính (khác nhau). Tôi không có đủ chỗ trong bình luận này để biết thêm.
Jeremy Pridemore

3
@Walkerneo foo.bar không phải là truy cập địa phương. Nó là một thuộc tính của một đối tượng. (Tha thứ cho việc thiếu định dạng) def foo_func: x = 5, xlà cục bộ của một hàm. Truy cập xlà địa phương. foo = SomeClass(), foo.barlà quyền truy cập thuộc tính. val = 5toàn cầu là toàn cầu. Đối với thuộc tính tốc độ cục bộ> toàn cầu> theo những gì tôi đã đọc ở đây. Vì vậy, truy cập xvào foo_funclà nhanh nhất, theo sau val, tiếp theo foo.bar. foo.attrkhông phải là một tra cứu cục bộ bởi vì trong bối cảnh của convo này, chúng ta đang nói về việc tra cứu cục bộ là một tra cứu của một biến thuộc về một hàm.
Jeremy Pridemore

3
@thedoctar có cái nhìn về globals()chức năng. Nếu bạn muốn biết thêm thông tin, bạn có thể phải bắt đầu xem mã nguồn cho Python. Và CPython chỉ là tên cho việc triển khai Python thông thường - vì vậy có lẽ bạn đang sử dụng nó!
Katriel

661

Bên trong một hàm, mã byte là:

  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        

Ở cấp độ cao nhất, mã byte là:

  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        

Sự khác biệt là STORE_FASTnhanh hơn (!) So với STORE_NAME. Điều này là bởi vì trong một chức năng, ilà một địa phương nhưng tại toplevel nó là một toàn cầu.

Để kiểm tra mã byte, sử dụng dismô-đun . Tôi đã có thể tháo rời hàm trực tiếp, nhưng để tháo rời mã toplevel tôi phải sử dụng compilenội dung .


171
Khẳng định bằng thí nghiệm. Chèn global ivào mainhàm làm cho thời gian chạy tương đương.
Deestan

44
Điều này trả lời câu hỏi mà không trả lời câu hỏi :) Trong trường hợp biến hàm cục bộ, CPython thực sự lưu trữ chúng trong một tuple (có thể thay đổi từ mã C) cho đến khi từ điển được yêu cầu (ví dụ: thông qua locals(), hoặc inspect.getframe()v.v.). Tra cứu một phần tử mảng bằng một số nguyên không đổi nhanh hơn nhiều so với tìm kiếm một lệnh.
dmw

3
Điều này cũng tương tự với C / C ++, sử dụng các biến toàn cục gây ra sự chậm lại đáng kể
codejammer

3
Đây là lần đầu tiên tôi nhìn thấy mã byte .. Làm thế nào để một người nhìn vào nó, và điều quan trọng là phải biết?
Zack

4
@gkimsey Tôi đồng ý. Chỉ muốn chia sẻ hai điều i) Hành vi này được ghi chú trong các ngôn ngữ lập trình khác ii) Tác nhân nhân quả là khía cạnh kiến ​​trúc chứ không phải ngôn ngữ theo đúng nghĩa
codejammer

41

Ngoài thời gian lưu trữ biến cục bộ / toàn cầu, dự đoán opcode làm cho chức năng nhanh hơn.

Như các câu trả lời khác giải thích, hàm sử dụng STORE_FASTopcode trong vòng lặp. Đây là mã byte cho vòng lặp của hàm:

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

Thông thường khi một chương trình được chạy, Python thực thi lần lượt từng opcode, theo dõi ngăn xếp và tạo ra các kiểm tra khác trên khung ngăn xếp sau khi mỗi opcode được thực thi. Dự đoán Opcode có nghĩa là trong một số trường hợp, Python có thể nhảy trực tiếp sang opcode tiếp theo, do đó tránh được một số chi phí này.

Trong trường hợp này, mỗi khi Python nhìn thấy FOR_ITER(đỉnh của vòng lặp), nó sẽ "dự đoán" đó STORE_FASTlà opcode tiếp theo mà nó phải thực thi. Python sau đó nhìn trộm mã op tiếp theo và, nếu dự đoán là chính xác, nó sẽ nhảy thẳng vào STORE_FAST. Điều này có tác dụng ép hai opcode thành một opcode duy nhất.

Mặt khác, STORE_NAMEopcode được sử dụng trong vòng lặp ở cấp độ toàn cầu. Python không * không * đưa ra dự đoán tương tự khi nhìn thấy opcode này. Thay vào đó, nó phải quay trở lại đầu vòng lặp đánh giá có ý nghĩa rõ ràng đối với tốc độ thực hiện vòng lặp.

Để cung cấp thêm một số chi tiết kỹ thuật về tối ưu hóa này, đây là trích dẫn từ ceval.ctệp ("công cụ" của máy ảo Python):

Một số opcode có xu hướng đi theo cặp do đó có thể dự đoán mã thứ hai khi mã đầu tiên được chạy. Ví dụ, GET_ITERthường được theo sau bởi FOR_ITER. Và FOR_ITERthường được theo sau bởiSTORE_FAST hoặc UNPACK_SEQUENCE.

Việc xác minh dự đoán sẽ tốn một thử nghiệm tốc độ cao duy nhất của biến đăng ký so với hằng số. Nếu việc ghép nối là tốt, thì việc xác định nhánh bên trong của bộ xử lý có khả năng thành công cao, dẫn đến việc chuyển đổi gần như bằng không sang opcode tiếp theo. Một dự đoán thành công tiết kiệm một chuyến đi thông qua vòng lặp eval bao gồm hai nhánh không thể đoán trước của nó, HAS_ARGthử nghiệm và trường hợp chuyển đổi. Kết hợp với dự đoán nhánh bên trong của bộ xử lý, một thành công PREDICTcó tác dụng làm cho hai opcode chạy như thể chúng là một opcode mới duy nhất với các phần thân được kết hợp.

Chúng ta có thể thấy trong mã nguồn cho FOR_ITERopcode chính xác nơi dự đoán STORE_FASTđược thực hiện:

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                                     

Các PREDICTchức năng mở rộng để if (*next_instr == op) goto PRED_##optức là chúng ta chỉ cần nhảy đến sự bắt đầu của opcode dự đoán. Trong trường hợp này, chúng tôi nhảy vào đây:

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;

Biến cục bộ hiện được đặt và opcode tiếp theo sẽ được thực thi. Python tiếp tục lặp đi lặp lại cho đến khi nó kết thúc, đưa ra dự đoán thành công mỗi lần.

Các trang wiki Python có thêm thông tin về làm thế nào máy ảo CPython của hoạt động.


Cập nhật nhỏ: Kể từ CPython 3.6, tiền tiết kiệm từ dự đoán giảm xuống một chút; thay vì hai nhánh không thể đoán trước, chỉ có một. Sự thay đổi là do việc chuyển đổi từ mã byte sang mã từ ; bây giờ tất cả các "mã từ" đều có đối số, nó chỉ là zero-ed khi lệnh không thực hiện một cách hợp lý. Do đó, HAS_ARGkiểm tra không bao giờ xảy ra (ngoại trừ khi theo dõi mức độ thấp được bật cả khi biên dịch và thời gian chạy, điều mà không có bản dựng bình thường nào làm được), chỉ để lại một bước nhảy không thể đoán trước.
ShadowRanger

Ngay cả bước nhảy không thể đoán trước đó cũng không xảy ra trong hầu hết các bản dựng của CPython, vì tính năng mới ( kể từ Python 3.1 , được bật theo mặc định trong 3.2 ) hành vi gotos được tính toán; khi được sử dụng, PREDICTmacro bị vô hiệu hóa hoàn toàn; thay vào đó hầu hết các trường hợp kết thúc trong một DISPATCHchi nhánh trực tiếp. Nhưng trên các CPU dự đoán nhánh, hiệu ứng tương tự như vậy PREDICT, vì việc phân nhánh (và dự đoán) là trên mỗi opcode, làm tăng tỷ lệ dự đoán nhánh thành công.
ShadowRanger
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.