เหตุใดการคัดลอกรายการแบบสับจึงช้ากว่ามาก


90

การคัดลอกrange(10**6)รายการแบบสับสิบครั้งใช้เวลาประมาณ 0.18 วินาที: (นี่คือการรันห้าครั้ง)

0.175597017661
0.173731403198
0.178601711594
0.180330912952
0.180811964451

การคัดลอกรายการที่ไม่ได้สับสิบครั้งใช้เวลาประมาณ 0.05 วินาที:

0.058402235973
0.0505464636856
0.0509734306934
0.0526022752744
0.0513324916184

นี่คือรหัสทดสอบของฉัน:

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)

ฉันลองคัดลอกด้วยa[:]ผลลัพธ์ก็คล้ายกัน (เช่นความเร็วแตกต่างกันมาก)

ทำไมความเร็วแตกต่างกันมาก? ฉันรู้และเข้าใจความแตกต่างของความเร็วในชื่อเสียงเหตุใดจึงประมวลผลอาร์เรย์ที่เรียงลำดับได้เร็วกว่าอาร์เรย์ที่ไม่เรียงลำดับ ตัวอย่าง แต่ที่นี่การประมวลผลของฉันไม่มีการตัดสินใจ เป็นเพียงการคัดลอกการอ้างอิงภายในรายการแบบสุ่มสี่สุ่มห้าไม่ใช่เหรอ?

ฉันใช้ Python 2.7.12 บน Windows 10

แก้ไข:ลองใช้ Python 3.5.2 แล้วผลลัพธ์ก็เกือบจะเหมือนกัน (สับอย่างสม่ำเสมอประมาณ 0.17 วินาทีโดยไม่สับอย่างสม่ำเสมอประมาณ 0.05 วินาที) นี่คือรหัสสำหรับสิ่งนั้น:

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


5
โปรดอย่าตะโกนใส่ฉันฉันพยายามช่วยคุณ! หลังจากเปลี่ยนลำดับแล้วฉันจะได้รับ0.25การทำซ้ำในการทดสอบแต่ละครั้งโดยประมาณ ดังนั้นบนแพลตฟอร์มของฉันคำสั่งจึงมีความสำคัญ
barak manos

1
@vaultah ขอบคุณ แต่ฉันอ่านตอนนี้แล้วและฉันไม่เห็นด้วย เมื่อฉันเห็นรหัสที่นั่นฉันนึกถึงแคช hit / misses ของ ints ทันทีซึ่งเป็นข้อสรุปของผู้เขียนเช่นกัน แต่รหัสของเขาเพิ่มตัวเลขซึ่งต้องดูที่พวกเขา รหัสของฉันไม่ได้ Mine ต้องคัดลอกข้อมูลอ้างอิงเท่านั้นไม่สามารถเข้าถึงได้
Stefan Pochmann

2
มีคำตอบที่สมบูรณ์ในลิงค์โดย @vaultah (ตอนนี้คุณไม่เห็นด้วยเล็กน้อย) แต่อย่างไรก็ตามฉันยังคงคิดว่าเราไม่ควรใช้ python สำหรับคุณสมบัติระดับต่ำดังนั้นจึงไม่ต้องกังวล แต่หัวข้อนั้นก็น่าสนใจอยู่ดีขอบคุณ
Nikolay Prokopyev

1
@NikolayProkopyev ใช่ฉันไม่กังวลเกี่ยวกับเรื่องนี้เพิ่งสังเกตเห็นสิ่งนี้ในขณะที่ทำอย่างอื่นอธิบายไม่ได้และอยากรู้อยากเห็น และฉันดีใจที่ฉันถามและมีคำตอบตอนนี้ :-)
Stefan Pochmann

คำตอบ:


100

บิตที่น่าสนใจก็คือว่ามันขึ้นอยู่กับลำดับที่เลขจะเป็นครั้งแรกที่สร้างขึ้น ตัวอย่างเช่นแทนที่จะshuffleสร้างลำดับแบบสุ่มด้วย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))

เร็วพอ ๆ กับการคัดลอกlist(range(10**6))(ตัวอย่างแรกและเร็ว) ของคุณ

อย่างไรก็ตามเมื่อคุณสับเปลี่ยน - จำนวนเต็มของคุณจะไม่อยู่ในลำดับที่สร้างขึ้นครั้งแรกอีกต่อไปนั่นคือสิ่งที่ทำให้มันช้า

intermezzo ด่วน:

  • วัตถุ Python ทั้งหมดอยู่บนฮีปดังนั้นทุกวัตถุจึงเป็นตัวชี้
  • การคัดลอกรายการเป็นการดำเนินการตื้น ๆ
  • อย่างไรก็ตามหลามใช้นับการอ้างอิงดังนั้นเมื่อวัตถุถูกวางในภาชนะใหม่ก็นับอ้างอิงจะต้องเพิ่มขึ้น ( Py_INCREFในlist_slice ) ดังนั้นหลามจริงๆต้องไปที่วัตถุคือ มันไม่สามารถคัดลอกข้อมูลอ้างอิงได้

ดังนั้นเมื่อคุณคัดลอกรายการของคุณคุณจะได้รับแต่ละรายการของรายการนั้นและวาง "ตามสภาพ" ในรายการใหม่ เมื่อรายการถัดไปของคุณถูกสร้างขึ้นหลังจากรายการปัจจุบันไม่นานมีโอกาสที่ดี (ไม่รับประกัน!) ที่จะบันทึกไว้ข้างๆบนฮีป

สมมติว่าเมื่อใดก็ตามที่คอมพิวเตอร์ของคุณโหลดรายการในแคชเครื่องจะโหลดรายการxในหน่วยความจำถัดไปด้วย (ตำแหน่งของแคช) จากนั้นคอมพิวเตอร์ของคุณจะสามารถเพิ่มจำนวนอ้างอิงสำหรับx+1รายการในแคชเดียวกันได้!

ด้วยลำดับการสับจะยังคงโหลดรายการถัดไปในหน่วยความจำ แต่สิ่งเหล่านี้ไม่ใช่รายการถัดไปในรายการ ดังนั้นจึงไม่สามารถทำการเพิ่มจำนวนการอ้างอิงโดยไม่ต้อง "จริงๆ" ค้นหารายการถัดไป

TL; DR:ความเร็วจริงขึ้นอยู่กับสิ่งที่เกิดขึ้นก่อนสำเนา: รายการเหล่านี้ถูกสร้างขึ้นในลำดับใดและสิ่งเหล่านี้อยู่ในลำดับใดในรายการ


คุณสามารถตรวจสอบได้โดยดูที่id:

รายละเอียดการใช้งาน CPython: นี่คือที่อยู่ของวัตถุในหน่วยความจำ

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

เพียงเพื่อแสดงข้อความที่ตัดตอนมาสั้น ๆ :

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

ดังนั้นวัตถุเหล่านี้จึง "อยู่ติดกันบนฮีป" จริงๆ กับshuffleพวกเขาไม่:

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

ซึ่งแสดงให้เห็นว่าสิ่งเหล่านี้ไม่ได้อยู่ติดกันในหน่วยความจำ:

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

โน๊ตสำคัญ:

ฉันไม่ได้คิดเรื่องนี้ขึ้นมาเอง ส่วนใหญ่เป็นข้อมูลที่สามารถพบได้ในบล็อกโพสต์ของริกกี้สจ๊วต

คำตอบนี้อ้างอิงจากการใช้งาน Python "อย่างเป็นทางการ" ของ CPython รายละเอียดในการใช้งานอื่น ๆ (Jython, PyPy, IronPython, ... ) อาจแตกต่างกัน ขอบคุณ @ JörgWMittag สำหรับการชี้ออกมานี้


6
@augurar การคัดลอกการอ้างอิงหมายถึงการเพิ่มตัวนับอ้างอิงซึ่งอยู่ในวัตถุ (ดังนั้นการเข้าถึงวัตถุจึงหลีกเลี่ยงไม่ได้)
Leon

1
@StefanPochmann ฟังก์ชั่นการทำสำเนาคือlist_sliceและในบรรทัด 453 คุณสามารถเห็นการPy_INCREF(v);เรียกที่ต้องการเข้าถึงวัตถุที่จัดสรรฮีป
MSeifert

1
@MSeifert การทดลองที่ดีอีกอย่างหนึ่งคือการใช้a = [0] * 10**7(เพิ่มขึ้นจาก 10 ** 6 เนื่องจากไม่เสถียรเกินไป) ซึ่งเร็วกว่าการใช้a = range(10**7)(โดยประมาณ 1.25) เห็นได้ชัดว่าดีกว่าสำหรับการแคช
Stefan Pochmann

1
ฉันแค่สงสัยว่าทำไมฉันถึงมีจำนวนเต็ม 32 บิตบนคอมพิวเตอร์ 64 บิตที่มี python 64bit แต่จริงๆแล้วมันก็ดีสำหรับการแคชเช่นกัน :-) แม้[0,1,2,3]*((10**6) // 4)จะเร็วพอa = [0] * 10**6ๆ อย่างไรก็ตามด้วยจำนวนเต็มตั้งแต่ 0-255 มีข้อเท็จจริงอีกอย่างเข้ามา: สิ่งเหล่านี้ถูก จำกัด ไว้ดังนั้นลำดับการสร้าง (ภายในสคริปต์ของคุณ) จึงไม่สำคัญอีกต่อไป - เนื่องจากถูกสร้างขึ้นเมื่อคุณเริ่ม python
MSeifert

2
โปรดทราบว่าการใช้งาน Python ที่พร้อมใช้งานในปัจจุบันมีอยู่ 4 แบบมีเพียงอันเดียวเท่านั้นที่ใช้การนับอ้างอิง ดังนั้นการวิเคราะห์นี้ใช้กับการใช้งานเพียงครั้งเดียวเท่านั้น
Jörg W Mittag

24

เมื่อคุณสับเปลี่ยนรายการจะมีตำแหน่งที่ตั้งของการอ้างอิงที่แย่กว่าซึ่งนำไปสู่ประสิทธิภาพแคชที่แย่ลง

คุณอาจคิดว่าการคัดลอกรายการเป็นเพียงการคัดลอกข้อมูลอ้างอิงไม่ใช่วัตถุดังนั้นตำแหน่งของพวกเขาบนฮีปจึงไม่สำคัญ อย่างไรก็ตามการคัดลอกยังคงเกี่ยวข้องกับการเข้าถึงแต่ละออบเจ็กต์เพื่อแก้ไขการอ้างอิง


นี่อาจเป็นคำตอบที่ดีกว่าสำหรับฉัน (อย่างน้อยถ้ามีลิงก์ไปยัง "การพิสูจน์" เหมือนของ MSeifert) เพราะนี่คือทั้งหมดที่ฉันขาดหายไปและมันรวบรัดมาก แต่ฉันคิดว่าฉันจะยึดติดกับ MSeifert เพราะฉันรู้สึกว่ามันอาจจะเป็น ดีกว่าสำหรับคนอื่น ๆ อย่างไรก็ตามขอบคุณมาก
Stefan Pochmann

นอกจากนี้ยังจะเพิ่มว่า pentioids, athlums และอื่น ๆ มีตรรกะลึกลับในพวกเขาเพื่อตรวจจับรูปแบบที่อยู่และจะเริ่มดึงข้อมูลล่วงหน้าเมื่อพวกเขาเห็นรูปแบบ ซึ่งในกรณีนี้อาจเป็นการเริ่มต้นเพื่อดึงข้อมูลล่วงหน้า (ลดการพลาดแคช) เมื่อตัวเลขเรียงตามลำดับ นอกจากนี้ผลกระทบนี้ยังมีผลต่อ% ที่เพิ่มขึ้นของ Hit จากพื้นที่
greggo

5

ตามที่ผู้อื่นอธิบายไว้ไม่ใช่แค่การคัดลอกการอ้างอิง แต่ยังเพิ่มจำนวนการอ้างอิงภายในอ็อบเจ็กต์ดังนั้นอ็อบเจ็กต์จึงถูกเข้าถึงและแคชมีบทบาท

ที่นี่ฉันต้องการเพิ่มการทดลองเพิ่มเติม ไม่มากเกี่ยวกับการสับและไม่สับ (ซึ่งการเข้าถึงองค์ประกอบหนึ่งอาจพลาดแคช แต่รับองค์ประกอบต่อไปนี้ลงในแคชเพื่อให้พวกเขาถูกโจมตี) แต่เกี่ยวกับองค์ประกอบที่ทำซ้ำซึ่งการเข้าถึงองค์ประกอบเดียวกันในภายหลังอาจกระทบแคชเนื่องจากองค์ประกอบยังอยู่ในแคช

การทดสอบช่วงปกติ:

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

รายการที่มีขนาดเท่ากัน แต่มีเพียงองค์ประกอบเดียวที่ทำซ้ำแล้วซ้ำอีกจะเร็วกว่าเพราะโดนแคชตลอดเวลา:

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

และดูเหมือนจะไม่สำคัญว่าจะเป็นเลขอะไร:

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

ที่น่าสนใจคือมันจะเร็วขึ้นเมื่อฉันทำซ้ำสองหรือสี่องค์ประกอบเดียวกัน:

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

ฉันเดาว่ามีบางอย่างไม่ชอบตัวนับเดียวที่เพิ่มขึ้นตลอดเวลา อาจจะแผงขายท่อเนื่องจากการเพิ่มขึ้นแต่ละครั้งต้องรอผลของการเพิ่มขึ้นก่อนหน้านี้ แต่เป็นการคาดเดา

อย่างไรก็ตามลองทำเช่นนี้เพื่อให้ได้องค์ประกอบซ้ำจำนวนมากขึ้น:

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

ผลลัพธ์ (คอลัมน์แรกคือจำนวนองค์ประกอบที่แตกต่างกันสำหรับแต่ละรายการที่ฉันทดสอบสามครั้งแล้วหาค่าเฉลี่ย):

       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

ดังนั้นจากประมาณ 2.8 วินาทีสำหรับองค์ประกอบเดียว (ซ้ำ) จะลดลงเหลือประมาณ 2.2 วินาทีสำหรับ 2, 4, 8, 16, ... องค์ประกอบที่แตกต่างกันและอยู่ที่ประมาณ 2.2 วินาทีจนถึงหลักแสน ฉันคิดว่านี่ใช้แคช L2 ของฉัน (4 × 256 KB ฉันมีi7-6700 )

จากนั้นในสองสามขั้นตอนเวลาจะเพิ่มขึ้นถึง 3.5 วินาที ฉันคิดว่านี่ใช้แคช L2 และแคช L3 ของฉัน (8 MB) ผสมกันจนกว่าจะ "หมด" เช่นกัน

ในตอนท้ายมันจะอยู่ที่ประมาณ 3.5 วินาทีฉันเดาว่าเพราะแคชของฉันไม่ได้ช่วยในองค์ประกอบซ้ำ ๆ


0

ก่อนสับเปลี่ยนเมื่อจัดสรรในฮีปวัตถุดัชนีที่อยู่ติดกันจะอยู่ติดกันในหน่วยความจำและอัตราการตีหน่วยความจำจะสูงเมื่อเข้าถึง หลังจากสับเปลี่ยนวัตถุของดัชนีที่อยู่ติดกันของรายการใหม่จะไม่อยู่ในหน่วยความจำ อัตราการตีที่อยู่ติดกันนั้นแย่มาก

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.