ความเร็วของการคูณ << >> และการหาร


9

คุณสามารถใช้<<เพื่อคูณและ>>หารตัวเลขในไพ ธ อนเมื่อฉันเวลาพวกเขาพบว่าการใช้วิธีการเปลี่ยนแบบไบนารีในการทำนั้นเร็วกว่าการหารหรือคูณด้วยวิธีปกติเป็น 10 เท่า

ทำไมถึงใช้งาน<<และ>>เร็วกว่า*และมาก/?

เบื้องหลังกระบวนการอะไรที่เกิดขึ้น*และ/ช้าลง?


2
Bit shift เร็วขึ้นในทุกภาษาไม่ใช่แค่ Python โปรเซสเซอร์หลายตัวมีคำสั่งการเปลี่ยนบิตแบบเนทีฟซึ่งจะทำให้สำเร็จในหนึ่งหรือสองรอบนาฬิกา
Robert Harvey

4
อย่างไรก็ตามควรจำไว้ว่าการเปลี่ยนบิตแทนที่จะใช้ตัวแบ่งส่วนปกติและตัวดำเนินการคูณนั้นโดยทั่วไปเป็นวิธีที่ไม่ดีและสามารถขัดขวางการอ่านได้
Azar

6
@crizly เพราะที่ดีที่สุดคือการเพิ่มประสิทธิภาพขนาดเล็กและมีโอกาสที่ดีที่คอมไพเลอร์จะเปลี่ยนเป็นการเปลี่ยนแปลงใน bytecode ต่อไป (ถ้าเป็นไปได้) มีข้อยกเว้นสำหรับสิ่งนี้เช่นเมื่อรหัสมีความสำคัญอย่างยิ่งต่อประสิทธิภาพ แต่ส่วนใหญ่ที่คุณทำอยู่นั้นทำให้รหัสของคุณงงงวย
Azar

7
@Crizly: คอมไพเลอร์ใด ๆ ที่มีเครื่องมือเพิ่มประสิทธิภาพที่เหมาะสมจะรับรู้การคูณและหน่วยงานที่สามารถทำได้ด้วยบิตกะและสร้างรหัสที่ใช้พวกเขา อย่าอัปเดตโค้ดของคุณอย่างน่าเกลียดเพื่อพยายามเอาชนะคอมไพเลอร์
Blrfl

2
ในคำถามนี้ใน StackOverflow microbenchmark พบว่าประสิทธิภาพที่ดีขึ้นเล็กน้อยใน Python 3 สำหรับการคูณด้วย 2 มากกว่าการเลื่อนซ้ายที่เทียบเท่าสำหรับจำนวนที่น้อยพอ ฉันคิดว่าฉันสืบเหตุผลไปจนถึงการทวีคูณเล็ก ๆ (ในปัจจุบัน) ที่ได้รับการปรับปรุงให้แตกต่างจากการเลื่อนบิต เพียงไปเพื่อแสดงว่าคุณไม่สามารถรับสิ่งที่จะทำงานได้เร็วขึ้นตามทฤษฎี
Dan Getz

คำตอบ:


15

ให้ดูที่โปรแกรม C สองตัวที่ทำหน้าที่เป็นบิตและแบ่ง

#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int b = i << 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
        int i = atoi(argv[0]);
        int d = i / 4;
}

เหล่านี้จะถูกรวบรวมgcc -Sเพื่อดูว่าชุดประกอบจริงจะเป็นอะไร

ด้วยรุ่น bit shift จากการเรียกไปยัง atoiยังการส่งคืน:

    callq   _atoi
    movl    $0, %ecx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    shll    $2, %eax
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

ในขณะที่รุ่นแบ่ง:

    callq   _atoi
    movl    $0, %ecx
    movl    $4, %edx
    movl    %eax, -20(%rbp)
    movl    -20(%rbp), %eax
    movl    %edx, -28(%rbp)         ## 4-byte Spill
    cltd
    movl    -28(%rbp), %r8d         ## 4-byte Reload
    idivl   %r8d
    movl    %eax, -24(%rbp)
    movl    %ecx, %eax
    addq    $32, %rsp
    popq    %rbp
    ret

เพียงแค่ดูที่นี่มีคำแนะนำเพิ่มเติมในเวอร์ชันหารเมื่อเทียบกับการเปลี่ยนบิต

ที่สำคัญคือพวกเขาทำอะไร?

ในรุ่น shift shift คำสั่งที่สำคัญคือ shll $2, %eaxคือการเลื่อนไปทางซ้ายตรรกะ - มีการแบ่งและทุกสิ่งทุกอย่างเป็นเพียงการเคลื่อนย้ายค่าไปรอบ ๆ

ในเวอร์ชันหารคุณจะเห็นidivl %r8d- แต่เหนือกว่านั้นคือcltd(แปลงยาวเป็นสองเท่า) และตรรกะเพิ่มเติมบางส่วนรอบ ๆ การหกและโหลดใหม่ งานเพิ่มเติมนี้โดยรู้ว่าเรากำลังเผชิญกับคณิตศาสตร์มากกว่าบิตเป็นสิ่งที่จำเป็นเพื่อหลีกเลี่ยงข้อผิดพลาดต่าง ๆ ที่อาจเกิดขึ้นได้ด้วยการทำคณิตศาสตร์เพียงเล็กน้อย

ให้คูณอย่างรวดเร็ว:

#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int b = i >> 2;
}
#include <stdlib.h>

int main(int argc, char* argv[]) {
    int i = atoi(argv[0]);
    int d = i * 4;
}

แทนที่จะผ่านทั้งหมดนี้มีหนึ่งบรรทัดที่แตกต่าง:

$ diff mult.s bit.s
24c24
> shll $ 2,% eax
---
<sarl $ 2,% eax

ที่นี่คอมไพเลอร์ก็สามารถระบุได้ว่าคณิตศาสตร์สามารถทำได้ด้วยการเปลี่ยน แต่แทนที่จะเป็นตรรกะการเปลี่ยนแปลงมันจะทำการเปลี่ยนแปลงทางคณิตศาสตร์ ความแตกต่างระหว่างสิ่งเหล่านี้จะเห็นได้ชัดถ้าเราวิ่งเหล่านี้ - sarlรักษาสัญญาณ ดังนั้น-2 * 4 = -8ในขณะที่shllไม่

ให้ดูที่นี้ในสคริปต์ Perl อย่างรวดเร็ว:

#!/usr/bin/perl

$foo = 4;
print $foo << 2, "\n";
print $foo * 4, "\n";

$foo = -4;
print $foo << 2, "\n";
print $foo * 4, "\n";

เอาท์พุท:

16
16
18446744073709551600
-16

อืม ... -4 << 2เป็น18446744073709551600ซึ่งไม่ได้เป็นสิ่งที่คุณมีแนวโน้มที่จะคาดหวังว่าเมื่อต้องรับมือกับการคูณและการหาร ถูกต้อง แต่ไม่ใช่การคูณจำนวนเต็ม

และระวังการเพิ่มประสิทธิภาพก่อนวัยอันควร ให้คอมไพเลอร์ปรับให้เหมาะสมสำหรับคุณ - มันรู้ว่าคุณกำลังพยายามทำอะไรจริงๆและมีแนวโน้มที่จะทำงานได้ดีขึ้นโดยมีข้อบกพร่องน้อยลง


12
มันอาจจะชัดเจนกว่าในการจับคู่<< 2กับ* 4และ>> 2ด้วย/ 4เพื่อให้ทิศทางการเปลี่ยนแปลงเหมือนกันในแต่ละตัวอย่าง
Greg Hewgill

5

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

ตัวอย่างเช่นมันเป็นความจริงอย่างแน่นอนว่าการคูณนั้นเป็นการดำเนินการที่ซับซ้อนกว่าที่จะนำไปใช้กับฮาร์ดแวร์ แต่ก็ไม่จำเป็นว่าจะต้องช้าลงเสมอไป มันกลับกลายaddเป็นว่ามีความซับซ้อนในการใช้งานมากกว่าxor(หรือโดยทั่วไปการทำงานระดับบิตใด ๆ ) แต่add(และsub) มักจะได้รับทรานซิสเตอร์เพียงพอสำหรับการทำงานที่จบลงด้วยความเร็วเท่ากับตัวดำเนินการระดับบิต ดังนั้นคุณไม่สามารถดูความซับซ้อนในการติดตั้งฮาร์ดแวร์เพื่อเป็นแนวทางในการเพิ่มความเร็วได้

ลองดูรายละเอียดการขยับกับตัวดำเนินการ "เต็ม" อย่างการคูณและการเลื่อน

การขยับ

ในเกือบทุกฮาร์ดแวร์ขยับตามจำนวนเงินที่คงที่ (เช่นจำนวนเงินที่คอมไพเลอร์สามารถตรวจสอบที่รวบรวมเวลา) เป็นไปอย่างรวดเร็ว โดยเฉพาะอย่างยิ่งมันมักจะเกิดขึ้นกับเวลาแฝงของรอบเดียวและมีปริมาณงาน 1 ต่อรอบหรือดีกว่า ในฮาร์ดแวร์บางตัว (เช่นชิป Intel และ ARM บางตัว) การเลื่อนบางอย่างโดยค่าคงที่อาจเป็น "ฟรี" เนื่องจากสามารถสร้างเป็นคำสั่งอื่นได้ ( leaบน Intel ซึ่งเป็นความสามารถในการเลื่อนแบบพิเศษของแหล่งแรกใน ARM)

การเลื่อนตามจำนวนตัวแปรเป็นพื้นที่สีเทามากกว่า สำหรับฮาร์ดแวร์รุ่นเก่าบางครั้งมันช้ามากและความเร็วก็เปลี่ยนจากรุ่นสู่รุ่น ตัวอย่างเช่นในการเปิดตัวครั้งแรกของ P4 ของ Intel การขยับตามจำนวนตัวแปรนั้นช้ามาก - ต้องใช้เวลาตามสัดส่วนกับจำนวนกะ! บนแพลตฟอร์มนั้นการใช้การคูณเพื่อแทนที่การเปลี่ยนแปลงอาจทำกำไรได้ (เช่นโลกกลับหัวกลับหาง) สำหรับชิป Intel รุ่นก่อนหน้าและรุ่นต่อ ๆ มาการขยับตามจำนวนตัวแปรนั้นไม่ได้เจ็บปวดอะไรนัก

สำหรับชิป Intel ปัจจุบันการขยับตามจำนวนตัวแปรนั้นไม่ได้เร็วนัก แต่มันก็ไม่ได้แย่เหมือนกัน สถาปัตยกรรม x86 นั้นมีความสำคัญเมื่อพูดถึงการเปลี่ยนแปลงของตัวแปรเนื่องจากพวกมันกำหนดการดำเนินการในลักษณะที่ผิดปกติ: การเลื่อนจำนวน 0 ไม่ได้ปรับเปลี่ยนแฟล็กเงื่อนไข แต่การเลื่อนอื่น ๆ ทั้งหมดทำ สิ่งนี้ยับยั้งการเปลี่ยนชื่อที่มีประสิทธิภาพของการลงทะเบียนเนื่องจากไม่สามารถระบุได้จนกว่าการเปลี่ยนแปลงจะดำเนินการไม่ว่าคำสั่งที่ตามมาควรอ่านรหัสเงื่อนไขที่เขียนโดยกะหรือคำแนะนำก่อนหน้า นอกจากนี้เลื่อนการเขียนไปยังส่วนของการลงทะเบียนแฟล็กเท่านั้นซึ่งอาจทำให้แผงลอยบางส่วนหยุดทำงาน

ผลที่สุดคือสถาปัตยกรรมของ Intel เมื่อเร็ว ๆ นี้การเปลี่ยนแปลงโดยจำนวนตัวแปรใช้เวลาสาม "การดำเนินการไมโคร" ในขณะที่การดำเนินการอย่างง่ายอื่น ๆ ส่วนใหญ่ (เพิ่ม, bitwise ops, การเพิ่มจำนวนทวีคูณ) ใช้เวลาเพียง 1 กะเท่านั้น .

การคูณ

แนวโน้มของฮาร์ดแวร์เดสก์ท็อปและแล็ปท็อปที่ทันสมัยคือการคูณการดำเนินการที่รวดเร็ว ในความเป็นจริงแล้วชิป Intel และ AMD ที่ผ่านมาสามารถคูณได้หนึ่งรอบทุกรอบ (เราเรียกปริมาณงานซึ่งกันและกัน ) อย่างไรก็ตามเวลาแฝงของการคูณคือ 3 รอบ นั่นหมายความว่าคุณจะได้รับผลลัพธ์ของการคูณ 3 รอบที่กำหนดหลังจากที่คุณเริ่มต้น แต่คุณสามารถเริ่มต้นการคูณใหม่ได้ทุกรอบ ค่าใด (1 รอบหรือ 3 รอบ) สำคัญกว่าขึ้นอยู่กับโครงสร้างของอัลกอริทึมของคุณ หากการคูณนั้นเป็นส่วนหนึ่งของห่วงโซ่การพึ่งพาที่สำคัญเวลาในการตอบสนองนั้นเป็นสิ่งสำคัญ ถ้าไม่ปริมาณงานหรือปัจจัยอื่น ๆ อาจมีความสำคัญมากกว่า

ประเด็นสำคัญคือในแล็ปท็อปชิพสมัยใหม่(หรือดีกว่า) การคูณเป็นการดำเนินการที่รวดเร็วและน่าจะเร็วกว่าลำดับการเรียนการสอน 3 หรือ 4 ที่ผู้เรียบเรียงจะต้องออก "การปัดเศษ" เพื่อลดความแรง สำหรับตัวแปรที่เปลี่ยนแปลงบน Intel การคูณจะเป็นที่ต้องการโดยทั่วไปเนื่องจากปัญหาที่กล่าวถึงข้างต้น

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

แบ่ง

การหารเป็นการดำเนินการที่ซับซ้อนกว่าฮาร์ดแวร์ฉลาดกว่าการคูณและยังพบได้น้อยมากในรหัสจริง - ซึ่งหมายความว่ามีการจัดสรรทรัพยากรน้อยลง แนวโน้มของชิปที่ทันสมัยยังคงเป็นตัวแบ่งที่เร็วกว่า แต่แม้แต่ชิประดับท็อปของโมเดิร์นก็ใช้เวลา 10-40 รอบในการแบ่งและมันจะถูกส่งไปบางส่วนเท่านั้น โดยทั่วไปการแบ่ง 64- บิตจะช้ากว่าการแบ่ง 32- บิต แตกต่างจากการดำเนินงานอื่น ๆ ส่วนใหญ่อาจใช้จำนวนรอบของตัวแปรขึ้นอยู่กับข้อโต้แย้ง

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


2

BINARY_LSHIFT และ BINARY_RSHIFT เป็นกระบวนการที่ง่ายกว่าแบบขั้นตอนวิธีกว่า BINARY_MULTIPLY และ BINARY_FLOOR_DIVIDE และอาจใช้รอบสัญญาณนาฬิกาน้อยลง นั่นคือถ้าคุณมีเลขฐานสองใด ๆ และต้องการบิตการเลื่อนด้วย N สิ่งที่คุณต้องทำคือเลื่อนตัวเลขไปที่ช่องว่างจำนวนมากและแทนที่ด้วยศูนย์ โดยทั่วไปการคูณแบบไบนารีมีความซับซ้อนมากขึ้นแม้ว่าเทคนิคเช่นตัวคูณ Daddaทำให้มันค่อนข้างเร็ว

จริงอยู่มันเป็นไปได้ที่คอมไพเลอร์จะปรับการรับรู้กรณีเมื่อคุณทวีคูณ / หารด้วยพลังของสองและแทนที่ด้วยการเลื่อนซ้าย / ขวาที่เหมาะสม โดยดูที่รหัสไบต์ที่ถูกแยกส่วนไพ ธ อนไม่ได้ทำสิ่งนี้:

>>> dis.dis(lambda x: x*4)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (4)
              6 BINARY_MULTIPLY     
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x<<2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_LSHIFT       
              7 RETURN_VALUE        


>>> dis.dis(lambda x: x//2)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (2)
              6 BINARY_FLOOR_DIVIDE 
              7 RETURN_VALUE        

>>> dis.dis(lambda x: x>>1)
  1           0 LOAD_FAST                0 (x)
              3 LOAD_CONST               1 (1)
              6 BINARY_RSHIFT       
              7 RETURN_VALUE        

อย่างไรก็ตามในโปรเซสเซอร์ของฉันฉันพบว่าการคูณและการเลื่อนซ้าย / ขวามีเวลาที่คล้ายกันและการแบ่งพื้น (ด้วยกำลังสอง) จะช้าลงประมาณ 25%:

>>> import timeit

>>> timeit.repeat("z=a + 4", setup="a = 37")
[0.03717184066772461, 0.03291916847229004, 0.03287005424499512]

>>> timeit.repeat("z=a - 4", setup="a = 37")
[0.03534698486328125, 0.03207516670227051, 0.03196907043457031]

>>> timeit.repeat("z=a * 4", setup="a = 37")
[0.04594111442565918, 0.0408930778503418, 0.045324087142944336]

>>> timeit.repeat("z=a // 4", setup="a = 37")
[0.05412912368774414, 0.05091404914855957, 0.04910898208618164]

>>> timeit.repeat("z=a << 2", setup="a = 37")
[0.04751706123352051, 0.04259490966796875, 0.041903018951416016]

>>> timeit.repeat("z=a >> 2", setup="a = 37")
[0.04719185829162598, 0.04201006889343262, 0.042105913162231445]
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.