Tại sao sao chép danh sách xáo trộn lại chậm hơn nhiều?


89

Việc sao chép một range(10**6)danh sách xáo trộn mười lần, tôi mất khoảng 0,18 giây: (đây là năm lần chạy)

0.175597017661
0.173731403198
0.178601711594
0.180330912952
0.180811964451

Sao chép danh sách không xáo trộn mười lần, tôi mất khoảng 0,05 giây:

0.058402235973
0.0505464636856
0.0509734306934
0.0526022752744
0.0513324916184

Đây là mã thử nghiệm của tôi:

from timeit import timeit
import random

a = range(10**6)
random.shuffle(a)    # Remove this for the second test.
a = list(a)          # Just an attempt to "normalize" the list.
for _ in range(5):
    print timeit(lambda: list(a), number=10)

Tôi cũng đã thử sao chép với a[:], kết quả tương tự (tức là, tốc độ chênh lệch lớn)

Tại sao sự khác biệt tốc độ lớn? Tôi biết và hiểu sự khác biệt về tốc độ trong nổi tiếng Tại sao xử lý một mảng được sắp xếp nhanh hơn một mảng không được sắp xếp? ví dụ, nhưng ở đây quá trình xử lý của tôi không có quyết định. Nó chỉ sao chép một cách mù quáng các tham chiếu bên trong danh sách, phải không?

Tôi đang sử dụng Python 2.7.12 trên Windows 10.

Chỉnh sửa: Bây giờ cũng đã thử Python 3.5.2, kết quả gần như giống nhau (xáo trộn liên tục trong khoảng 0,17 giây, không xáo trộn liên tục trong khoảng 0,05 giây). Đây là mã cho điều đó:

a = list(range(10**6))
random.shuffle(a)
a = list(a)
for _ in range(5):
    print(timeit(lambda: list(a), number=10))


5
Xin đừng hét vào mặt tôi, tôi đang cố gắng giúp bạn! Sau khi thay đổi thứ tự, tôi nhận được khoảng 0.25trong mỗi lần lặp lại của mỗi bài kiểm tra. Vì vậy, trên nền tảng của tôi, thứ tự thực sự quan trọng.
barak manos

1
@vaultah Cảm ơn, nhưng tôi đã đọc nó bây giờ và tôi không đồng ý. Khi tôi nhìn thấy đoạn mã ở đó, tôi ngay lập tức nghĩ đến những lần truy cập / bỏ lỡ bộ nhớ cache của các int, đây cũng là kết luận của tác giả. Nhưng mã của anh ấy thêm các số, đòi hỏi phải nhìn vào chúng. Mã của tôi không. Của tôi chỉ cần sao chép các tài liệu tham khảo, không phải truy cập thông qua chúng.
Stefan Pochmann

2
Có một câu trả lời đầy đủ trong một liên kết của @vaultah (bạn hơi không đồng ý ngay bây giờ, tôi hiểu rồi). Nhưng dù sao thì tôi vẫn nghĩ rằng chúng ta không nên sử dụng python cho các tính năng cấp thấp và do đó, hãy lo lắng về điều đó. Nhưng dù sao chủ đề đó cũng thú vị, cảm ơn bạn.
Nikolay Prokopyev

1
@NikolayProkopyev Vâng, tôi không lo lắng về điều đó, chỉ nhận thấy điều này trong khi làm việc khác, không thể giải thích và tò mò. Và tôi rất vui vì tôi đã hỏi và có câu trả lời ngay bây giờ :-)
Stefan Pochmann

Câu trả lời:


100

Điều thú vị là nó phụ thuộc vào thứ tự mà các số nguyên được tạo lần đầu tiên . Ví dụ: thay vì shuffletạo một chuỗi ngẫu nhiên với random.randint:

from timeit import timeit
import random

a = [random.randint(0, 10**6) for _ in range(10**6)]
for _ in range(5):
    print(timeit(lambda: list(a), number=10))

Điều này nhanh như sao chép của bạn list(range(10**6))(ví dụ đầu tiên và nhanh chóng).

Tuy nhiên, khi bạn xáo trộn - thì các số nguyên của bạn không theo thứ tự lần đầu tiên được tạo nữa, đó là điều khiến nó chậm đi.

Một intermezzo nhanh chóng:

  • Tất cả các đối tượng Python đều nằm trên heap, vì vậy mọi đối tượng đều là một con trỏ.
  • Sao chép danh sách là một hoạt động nông cạn.
  • Tuy nhiên, Python sử dụng phương pháp đếm tham chiếu nên khi một đối tượng được đưa vào một vùng chứa mới thì số lượng tham chiếu phải được tăng lên ( Py_INCREFtronglist_slice ), vì vậy Python thực sự cần phải đi đến vị trí của đối tượng. Nó không thể chỉ sao chép tài liệu tham khảo.

Vì vậy, khi bạn sao chép danh sách của mình, bạn sẽ nhận được từng mục của danh sách đó và đặt nó "nguyên trạng" trong danh sách mới. Khi mục tiếp theo của bạn được tạo ngay sau mục hiện tại, rất có thể (không có gì đảm bảo!) Rằng nó được lưu bên cạnh nó trên đống.

Giả sử rằng bất cứ khi nào máy tính của bạn tải một mục trong bộ nhớ cache, nó cũng tải các mục xtiếp theo trong bộ nhớ ( cục bộ bộ nhớ cache). Sau đó, máy tính của bạn có thể thực hiện tăng số lượng tham chiếu cho x+1các mục trên cùng một bộ nhớ cache!

Với trình tự xáo trộn, nó vẫn tải các mục tiếp theo trong bộ nhớ nhưng đây không phải là những mục trong danh sách tiếp theo. Vì vậy, nó không thể thực hiện tăng số lượng tham chiếu mà không "thực sự" tìm kiếm mục tiếp theo.

TL; DR: Tốc độ thực tế phụ thuộc vào những gì đã xảy ra trước khi sao chép: các mục này được tạo theo thứ tự nào và các mục này nằm trong danh sách theo thứ tự nào.


Bạn có thể xác minh điều này bằng cách xem id:

Chi tiết triển khai CPython: Đây là địa chỉ của đối tượng trong bộ nhớ.

a = list(range(10**6, 10**6+100))
for item in a:
    print(id(item))

Chỉ để hiển thị một đoạn trích ngắn:

1496489995888
1496489995920  # +32
1496489995952  # +32
1496489995984  # +32
1496489996016  # +32
1496489996048  # +32
1496489996080  # +32
1496489996112
1496489996144
1496489996176
1496489996208
1496489996240
1496507297840
1496507297872
1496507297904
1496507297936
1496507297968
1496507298000
1496507298032
1496507298064
1496507298096
1496507298128
1496507298160
1496507298192

Vì vậy, những đối tượng này thực sự là "cạnh nhau trên đống". Với shufflehọ thì không:

import random
a = list(range(10**6, 100+10**6))
random.shuffle(a)
last = None
for item in a:
    if last is not None:
        print('diff', id(item) - id(last))
    last = item

Điều này cho thấy chúng không thực sự ở cạnh nhau trong bộ nhớ:

diff 736
diff -64
diff -17291008
diff -128
diff 288
diff -224
diff 17292032
diff -1312
diff 1088
diff -17292384
diff 17291072
diff 608
diff -17290848
diff 17289856
diff 928
diff -672
diff 864
diff -17290816
diff -128
diff -96
diff 17291552
diff -192
diff 96
diff -17291904
diff 17291680
diff -1152
diff 896
diff -17290528
diff 17290816
diff -992
diff 448

Lưu ý quan trọng:

Tôi chưa nghĩ ra điều này bản thân mình. Hầu hết các thông tin có thể được tìm thấy trong bài đăng blog của Ricky Stewart .

Câu trả lời này dựa trên việc triển khai CPython "chính thức" của Python. Các chi tiết trong các triển khai khác (Jython, PyPy, IronPython, ...) có thể khác. Cảm ơn @ JörgWMittag đã chỉ ra điều này .


6
@augurar Sao chép một tài liệu tham khảo ngụ ý incrementing quầy tham khảo đó là trong đối tượng (như vậy, việc tiếp cận đối tượng là không thể tránh khỏi)
Leon

1
@StefanPochmann Chức năng thực hiện sao chép list_slicevà ở dòng 453, bạn có thể thấy lệnh Py_INCREF(v);gọi cần truy cập đối tượng được cấp phát đống.
MSeifert

1
@MSeifert Một thử nghiệm tốt khác đang sử dụng a = [0] * 10**7(tăng từ 10 ** 6 vì điều đó quá không ổn định), nó thậm chí còn nhanh hơn so với sử dụng a = range(10**7)(theo hệ số khoảng 1,25). Rõ ràng vì điều đó thậm chí còn tốt hơn cho bộ nhớ đệm.
Stefan Pochmann

1
Tôi chỉ tự hỏi tại sao tôi nhận được số nguyên 32 bit trên máy tính 64 bit với python 64 bit. Nhưng thực ra điều đó cũng tốt cho bộ nhớ đệm :-) Thậm chí [0,1,2,3]*((10**6) // 4)là nhanh như a = [0] * 10**6. Tuy nhiên, với các số nguyên từ 0-255 có một sự thật khác xảy ra: Các số nguyên này được thực hiện nên với những số nguyên này, thứ tự tạo (bên trong tập lệnh của bạn) không còn quan trọng nữa - vì chúng được tạo khi bạn bắt đầu python.
MSeifert

2
Lưu ý rằng trong số bốn triển khai Python sẵn sàng sản xuất hiện có, chỉ có một triển khai sử dụng tính tham chiếu. Vì vậy, phân tích này thực sự chỉ áp dụng cho một triển khai duy nhất.
Jörg W Mittag

24

Khi bạn xáo trộn các mục trong danh sách, chúng có vị trí tham chiếu kém hơn, dẫn đến hiệu suất bộ nhớ cache kém hơn.

Bạn có thể nghĩ rằng việc sao chép danh sách chỉ sao chép các tham chiếu, không phải các đối tượng, vì vậy vị trí của chúng trên heap không quan trọng. Tuy nhiên, việc sao chép vẫn liên quan đến việc truy cập từng đối tượng để sửa đổi số tiền hoàn lại.


Đây có thể là một câu trả lời tốt hơn cho tôi (ít nhất nếu nó có liên kết đến "bằng chứng" như của MSeifert) vì đây là tất cả những gì tôi còn thiếu và nó rất ngắn gọn, nhưng tôi nghĩ tôi sẽ gắn bó với MSeifert vì tôi cảm thấy nó có thể tốt hơn cho những người khác. Mặc dù vậy, cũng ủng hộ điều này, cảm ơn.
Stefan Pochmann

Cũng sẽ thêm rằng pentioid, điền kinh, v.v. có logic huyền bí trong chúng để phát hiện các mẫu địa chỉ và sẽ bắt đầu tìm nạp trước dữ liệu khi chúng nhìn thấy một mẫu. Trong trường hợp này, có thể được khởi động để tìm nạp trước dữ liệu (giảm số lần bỏ lỡ bộ nhớ cache) khi các số theo thứ tự. Tất nhiên, hiệu ứng này còn đối với việc tăng% số lần truy cập từ địa phương.
greggo

5

Theo giải thích của những người khác, nó không chỉ sao chép các tham chiếu mà còn tăng số lượng tham chiếu bên trong các đối tượng và do đó các đối tượng được truy cập và bộ nhớ cache đóng một vai trò.

Ở đây tôi chỉ muốn thêm nhiều thử nghiệm hơn. Không quá nhiều về xáo trộn và không bị xáo trộn (trong đó việc truy cập một phần tử có thể bỏ lỡ bộ nhớ cache nhưng đưa các phần tử sau vào bộ nhớ cache để chúng bị tấn công). Nhưng về phần tử lặp lại, trong đó các lần truy cập sau của cùng một phần tử có thể chạm vào bộ nhớ cache vì phần tử vẫn còn trong bộ nhớ cache.

Kiểm tra một phạm vi bình thường:

>>> from timeit import timeit
>>> a = range(10**7)
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[5.1915339142808925, 5.1436351868889645, 5.18055115701749]

Một danh sách có cùng kích thước nhưng chỉ có một phần tử được lặp đi lặp lại nhiều lần sẽ nhanh hơn vì nó luôn truy cập vào bộ nhớ cache:

>>> a = [0] * 10**7
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[4.125743135926939, 4.128927210087596, 4.0941229388550795]

Và dường như không quan trọng nó là số nào:

>>> a = [1234567] * 10**7
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[4.124106479141709, 4.156590225249886, 4.219242600790949]

Thật thú vị, nó thậm chí còn nhanh hơn khi tôi lặp lại hai hoặc bốn phần tử giống nhau:

>>> a = [0, 1] * (10**7 / 2)
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[3.130586101607932, 3.1001001764957294, 3.1318465707127814]

>>> a = [0, 1, 2, 3] * (10**7 / 4)
>>> [timeit(lambda: list(a), number=100) for _ in range(3)]
[3.096105435911994, 3.127148431279352, 3.132872673690855]

Tôi đoán có điều gì đó không thích cùng một bộ đếm duy nhất tăng lên liên tục. Có thể một số đường ống bị đình trệ bởi vì mỗi lần tăng phải chờ kết quả của lần tăng trước, nhưng đây là một phỏng đoán hoang đường.

Dù sao, hãy thử điều này cho số lượng lớn hơn các phần tử lặp lại:

from timeit import timeit
for e in range(26):
    n = 2**e
    a = range(n) * (2**25 / n)
    times = [timeit(lambda: list(a), number=20) for _ in range(3)]
    print '%8d ' % n, '  '.join('%.3f' % t for t in times), ' => ', sum(times) / 3

Kết quả đầu ra (cột đầu tiên là số phần tử khác nhau, cho mỗi phần tử tôi kiểm tra ba lần và sau đó lấy giá trị trung bình):

       1  2.871  2.828  2.835  =>  2.84446732686
       2  2.144  2.097  2.157  =>  2.13275338734
       4  2.129  2.297  2.247  =>  2.22436720645
       8  2.151  2.174  2.170  =>  2.16477771575
      16  2.164  2.159  2.167  =>  2.16328197911
      32  2.102  2.117  2.154  =>  2.12437970598
      64  2.145  2.133  2.126  =>  2.13462250728
     128  2.135  2.122  2.137  =>  2.13145065221
     256  2.136  2.124  2.140  =>  2.13336283943
     512  2.140  2.188  2.179  =>  2.1688431668
    1024  2.162  2.158  2.167  =>  2.16208440826
    2048  2.207  2.176  2.213  =>  2.19829998424
    4096  2.180  2.196  2.202  =>  2.19291917834
    8192  2.173  2.215  2.188  =>  2.19207065277
   16384  2.258  2.232  2.249  =>  2.24609975704
   32768  2.262  2.251  2.274  =>  2.26239771771
   65536  2.298  2.264  2.246  =>  2.26917420394
  131072  2.285  2.266  2.313  =>  2.28767871168
  262144  2.351  2.333  2.366  =>  2.35030805124
  524288  2.932  2.816  2.834  =>  2.86047313113
 1048576  3.312  3.343  3.326  =>  3.32721167007
 2097152  3.461  3.451  3.547  =>  3.48622758473
 4194304  3.479  3.503  3.547  =>  3.50964316455
 8388608  3.733  3.496  3.532  =>  3.58716466865
16777216  3.583  3.522  3.569  =>  3.55790996695
33554432  3.550  3.556  3.512  =>  3.53952594744

Vì vậy, từ khoảng 2,8 giây cho một phần tử duy nhất (lặp lại) nó giảm xuống khoảng 2,2 giây cho 2, 4, 8, 16, ... các phần tử khác nhau và giữ nguyên ở khoảng 2,2 giây cho đến hàng trăm nghìn. Tôi nghĩ rằng điều này sử dụng bộ nhớ cache L2 của tôi (4 × 256 KB, tôi có i7-6700 ).

Sau đó, qua một vài bước, thời gian lên đến 3,5 giây. Tôi nghĩ rằng điều này sử dụng kết hợp bộ nhớ cache L2 và bộ nhớ cache L3 của tôi (8 MB) cho đến khi nó "cạn kiệt".

Cuối cùng, nó vẫn ở mức khoảng 3,5 giây, tôi đoán vì bộ nhớ đệm của tôi không giúp ích gì với các phần tử lặp lại nữa.


0

Trước khi xáo trộn, khi được phân bổ trong heap, các đối tượng chỉ mục liền kề nằm liền kề trong bộ nhớ và tỷ lệ truy cập bộ nhớ cao khi được truy cập; sau khi xáo trộn, đối tượng của chỉ mục liền kề của danh sách mới không có trong bộ nhớ. Liền kề, tỷ lệ trúng đích rất kém.

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.