เหตุใดคอมไพเลอร์จึงไม่สามารถปรับแต่งลูปการเพิ่มที่คาดเดาได้ให้เหมาะสมกับการคูณ


132

นี่คือคำถามที่มาถึงใจในขณะที่อ่านคำตอบที่ยอดเยี่ยมโดยMysticialคำถาม: ทำไมมันเร็วขึ้นในการประมวลผลอาร์เรย์เรียงกว่าอาร์เรย์ไม่ได้เรียงลำดับ ?

บริบทสำหรับประเภทที่เกี่ยวข้อง:

const unsigned arraySize = 32768;
int data[arraySize];
long long sum = 0;

ในคำตอบของเขาเขาอธิบายว่า Intel Compiler (ICC) เพิ่มประสิทธิภาพสิ่งนี้:

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            sum += data[c];

... เป็นสิ่งที่เทียบเท่ากับสิ่งนี้:

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

เครื่องมือเพิ่มประสิทธิภาพตระหนักดีว่าสิ่งเหล่านี้เทียบเท่ากันดังนั้นจึงแลกเปลี่ยนลูปโดยย้ายสาขาออกนอกวงใน ฉลาดมาก!

แต่ทำไมถึงไม่ทำเช่นนี้?

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

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


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

7
การเพิ่มประสิทธิภาพนี้จะใช้ได้ก็ต่อเมื่อค่าที่อยู่ในอาร์เรย์ข้อมูลไม่เปลี่ยนรูป ตัวอย่างเช่นหากหน่วยความจำถูกจับคู่กับอุปกรณ์อินพุต / เอาต์พุตทุกครั้งที่คุณอ่านข้อมูล [0] จะให้ค่าที่แตกต่างกัน ...
Thomas CG de Vilhena

2
ชนิดข้อมูลนี้เป็นจำนวนเต็มหรือทศนิยม การเพิ่มจุดลอยตัวซ้ำ ๆ ให้ผลลัพธ์ที่แตกต่างกันมากจากการคูณ
Ben Voigt

6
@ โทมัส: หากข้อมูลเป็นvolatileเช่นนั้นการแลกเปลี่ยนลูปก็จะเป็นการเพิ่มประสิทธิภาพที่ไม่ถูกต้องเช่นกัน
Ben Voigt

3
GNAT (คอมไพเลอร์ Ada ที่มี GCC 4.6) จะไม่สลับลูปที่ O3 แต่ถ้ามีการสลับลูปมันจะแปลงเป็นการคูณ
prosfilaes

คำตอบ:


105

โดยทั่วไปคอมไพเลอร์ไม่สามารถแปลงร่างได้

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

เป็น

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000 * data[c];

เนื่องจากจำนวนเต็มหลังอาจนำไปสู่การล้นของจำนวนเต็มที่ลงนามโดยที่เดิมไม่มี แม้จะมีการรับประกันลักษณะการทำงานแบบตัดรอบสำหรับการล้นของจำนวนเต็มเสริมของทั้งสองที่เซ็นชื่อแล้วมันก็จะเปลี่ยนผลลัพธ์ (ถ้าdata[c]เป็น 30000 ผลิตภัณฑ์จะกลายเป็น-1294967296สำหรับ 32 บิตintทั่วไปที่มีการพันรอบในขณะที่ 100000 ครั้งจะเพิ่ม 30000 เป็นsumจะถ้าเป็นเช่นนั้น ไม่ล้นเพิ่มขึ้นsum3000000000) โปรดทราบว่าการกักเก็บที่เหมือนกันสำหรับปริมาณที่ไม่ได้ลงนามโดยมีตัวเลขที่แตกต่างกัน100000 * data[c]โดยทั่วไปแล้วการล้นเกินจะทำให้โมดูโลลดลง2^32ซึ่งจะต้องไม่ปรากฏในผลลัพธ์สุดท้าย

มันสามารถเปลี่ยนเป็น

for (int c = 0; c < arraySize; ++c)
    if (data[c] >= 128)
        sum += 100000LL * data[c];  // resp. 100000ull

แต่ถ้าเป็นปกติพอมีขนาดใหญ่กว่าlong longint

ทำไมมันถึงไม่ทำอย่างนั้นฉันบอกไม่ได้ฉันเดาว่ามันคือสิ่งที่Mysticial พูด "เห็นได้ชัดว่ามันไม่ได้วิ่งผ่านการยุบวงหลังจากการแลกเปลี่ยนแบบวนซ้ำ"

โปรดทราบว่าโดยทั่วไปการแลกเปลี่ยนแบบวนซ้ำนั้นไม่ถูกต้อง (สำหรับจำนวนเต็มที่ลงชื่อ) เนื่องจาก

for (int c = 0; c < arraySize; ++c)
    if (condition(data[c]))
        for (int i = 0; i < 100000; ++i)
            sum += data[c];

สามารถนำไปสู่การล้นที่

for (int i = 0; i < 100000; ++i)
    for (int c = 0; c < arraySize; ++c)
        if (condition(data[c]))
            sum += data[c];

จะไม่ มันเป็นโคเชอร์ที่นี่เนื่องจากเงื่อนไขทำให้มั่นใจได้ว่าสิ่งdata[c]ที่เพิ่มทั้งหมดมีเครื่องหมายเดียวกันดังนั้นหากมีค่าหนึ่งล้นทั้งสองอย่าง

ฉันไม่แน่ใจเกินไปว่าคอมไพเลอร์คำนึงถึงสิ่งนั้นแม้ว่า (@Mysticial คุณลองใช้เงื่อนไขเช่นdata[c] & 0x80นั้นได้หรือไม่ว่าจะเป็นจริงสำหรับค่าบวกและค่าลบ) ฉันมีคอมไพเลอร์ทำการเพิ่มประสิทธิภาพที่ไม่ถูกต้อง (ตัวอย่างเช่นเมื่อสองสามปีก่อนฉันมี ICC (11.0, iirc) ใช้การแปลง sign-32-bit-int-to-double 1.0/nโดยที่nเป็นunsigned intความเร็วประมาณสองเท่าของ gcc เอาท์พุท แต่ผิดหลายค่ามากกว่า2^31อ๊ะ)


4
ฉันจำเวอร์ชันของคอมไพเลอร์ MPW ซึ่งเพิ่มตัวเลือกเพื่ออนุญาตให้ใช้สแต็กเฟรมที่มีขนาดใหญ่กว่า 32K [เวอร์ชันก่อนหน้าถูก จำกัด โดยใช้ @ A7 + int16 addressing สำหรับตัวแปรในเครื่อง] มีทุกอย่างที่ถูกต้องสำหรับสแต็กเฟรมที่ต่ำกว่า 32K หรือมากกว่า 64K แต่สำหรับเฟรมสแต็ก 40K จะใช้ADD.W A6,$A000โดยลืมไปว่าการดำเนินการของคำที่มีที่อยู่จะลงทะเบียนเพื่อขยายคำเป็น 32 บิตก่อนที่จะเพิ่ม ใช้เวลาสักครู่ในการแก้ไขปัญหาเนื่องจากสิ่งเดียวที่รหัสทำระหว่างนั้นADDและในครั้งต่อไปที่จะดึง A6 ออกจากสแต็กคือการกู้คืนการลงทะเบียนของผู้โทรที่บันทึกไว้ในเฟรมนั้น ...
supercat

3
... และสิ่งเดียวที่ลงทะเบียนที่ผู้โทรสนใจคือแอดเดรส [ค่าคงที่เวลาโหลด] ของอาร์เรย์แบบคงที่ คอมไพเลอร์รู้ว่าที่อยู่ของอาร์เรย์ถูกบันทึกไว้ในรีจิสเตอร์ดังนั้นจึงสามารถปรับให้เหมาะสมตามนั้นได้ แต่ดีบักเกอร์เพียงแค่รู้ที่อยู่ของค่าคงที่ ดังนั้นก่อนคำสั่งMyArray[0] = 4;ฉันสามารถตรวจสอบที่อยู่ของMyArrayและดูตำแหน่งนั้นก่อนและหลังคำสั่งดำเนินการ มันจะไม่เปลี่ยนแปลง รหัสเป็นสิ่งที่คล้ายกันmove.B @A3,#4และ A3 ควรจะชี้ไปที่MyArrayทุกครั้งที่คำสั่งดำเนินการ แต่ก็ไม่ได้ สนุก.
supercat

แล้วทำไมเสียงดังกราวจึงทำการเพิ่มประสิทธิภาพแบบนี้?
Jason S

คอมไพเลอร์สามารถดำเนินการเขียนซ้ำในการแทนค่ากลางภายในได้เนื่องจากได้รับอนุญาตให้มีพฤติกรรมที่ไม่ได้กำหนดน้อยลงในการเป็นตัวแทนระดับกลางภายใน
user253751

48

คำตอบนี้ใช้ไม่ได้กับกรณีเฉพาะที่เชื่อมโยง แต่ใช้กับชื่อคำถามและอาจเป็นที่สนใจสำหรับผู้อ่านในอนาคต:

เนื่องจากความแม่นยำแน่นอนซ้ำนอกจากจุดลอยตัวจะไม่เทียบเท่ากับการคูณ พิจารณา:

float const step = 1e-15;
float const init = 1;
long int const count = 1000000000;

float result1 = init;
for( int i = 0; i < count; ++i ) result1 += step;

float result2 = init;
result2 += step * count;

cout << (result1 - result2);

การสาธิต


10
นี่ไม่ใช่คำตอบสำหรับคำถามที่ถาม แม้จะมีข้อมูลที่น่าสนใจ (และสิ่งที่ต้องรู้สำหรับโปรแกรมเมอร์ C / C ++) นี่ไม่ใช่ฟอรัมและไม่ได้อยู่ที่นี่
orlp

30
@nightcracker: เป้าหมายที่ระบุไว้ของ StackOverflow คือการสร้างคลังคำตอบที่สามารถค้นหาได้ซึ่งมีประโยชน์ต่อผู้ใช้ในอนาคต และนี่คือคำตอบสำหรับคำถามที่ถาม ... มันก็เกิดขึ้นเช่นกันว่ามีข้อมูลบางอย่างที่ไม่ระบุรายละเอียดที่ทำให้คำตอบนี้ใช้ไม่ได้กับโปสเตอร์ต้นฉบับ อาจยังคงใช้กับผู้อื่นที่มีคำถามเดียวกัน
Ben Voigt

12
มันอาจจะเป็นคำตอบสำหรับคำถามที่ชื่อแต่ไม่ได้เป็นคำถามที่ไม่มี
orlp

7
อย่างที่บอกว่าเป็นข้อมูลที่น่าสนใจ แต่ก็ยังดูเหมือนว่าผิดกับผมว่า Nota Bene คำตอบด้านบนของคำถามที่ไม่สามารถตอบคำถามในขณะที่มันยืนอยู่ตอนนี้ นี่ไม่ใช่เหตุผลที่ Intel Compiler ตัดสินใจที่จะไม่ปรับให้เหมาะสม basta
orlp

4
@nightcracker: ดูเหมือนจะผิดสำหรับฉันด้วยที่นี่เป็นคำตอบอันดับต้น ๆ ฉันหวังว่าจะมีคนโพสต์คำตอบที่ดีสำหรับกรณีจำนวนเต็มที่เกินคะแนนนี้ น่าเสียดายที่ฉันไม่คิดว่าจะมีคำตอบสำหรับกรณีจำนวนเต็ม "ไม่สามารถ" ได้เนื่องจากการเปลี่ยนแปลงจะเป็นไปตามกฎหมายดังนั้นเราจึงเหลือ "ทำไมจึงไม่" ซึ่งจริงๆแล้วล้มเหลวใน " "เหตุผลใกล้เคียงที่แปลเป็นภาษาท้องถิ่นมากเกินไปเนื่องจากเป็นเรื่องแปลกสำหรับเวอร์ชันคอมไพเลอร์เฉพาะ คำถามที่ฉันตอบคือคำถามที่สำคัญกว่า IMO
Ben Voigt

6

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

การเพิ่มประสิทธิภาพที่ทำคือการเคลื่อนที่ของโค้ดแบบวนซ้ำ ซึ่งสามารถทำได้โดยใช้ชุดเทคนิค


4

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

ในขณะเดียวกันคอมไพเลอร์บางตัวอาจปฏิเสธที่จะทำเช่นนี้เนื่องจากการแทนที่การเพิ่มซ้ำด้วยการคูณอาจเปลี่ยนพฤติกรรมล้นของโค้ด สำหรับประเภทจำนวนเต็มที่ไม่ได้ลงนามไม่ควรสร้างความแตกต่างเนื่องจากลักษณะการทำงานล้นถูกระบุโดยภาษาอย่างสมบูรณ์ แต่สำหรับผู้ที่ลงนามแล้วอาจ (อาจไม่ได้อยู่บนแพลตฟอร์มเสริมของ 2) เป็นเรื่องจริงที่การลงนามล้นนำไปสู่พฤติกรรมที่ไม่ได้กำหนดไว้ใน C ซึ่งหมายความว่ามันควรจะเป็นเรื่องที่ดีที่จะเพิกเฉยต่อความหมายล้นนั้นโดยสิ้นเชิง แต่ไม่ใช่ทุกคอมไพเลอร์ที่กล้าพอที่จะทำเช่นนั้น บ่อยครั้งที่มีเสียงวิพากษ์วิจารณ์จากฝูงชน "C is just a higher-level assembly language" (จำสิ่งที่เกิดขึ้นเมื่อ GCC เปิดตัวการเพิ่มประสิทธิภาพตามความหมายของนามแฝงที่เข้มงวด)

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


ฉันต้องการทราบว่าฉันบังเอิญขึ้นอยู่กับพฤติกรรมที่ไม่ได้กำหนดหรือไม่ แต่ฉันเดาว่าคอมไพเลอร์ไม่มีทางรู้ว่าการล้นจะเป็นปัญหารันไทม์: /
jhabbott

2
@jhabbott: IFFล้นเกิดขึ้นนั้นมีพฤติกรรมที่ไม่ได้กำหนด ไม่ทราบว่ามีการกำหนดลักษณะการทำงานหรือไม่จนกว่าจะรันไทม์ (สมมติว่ามีการป้อนตัวเลขที่รันไทม์): P.
orlp

3

ตอนนี้ - อย่างน้อยเสียงดังก็ทำ :

long long add_100k_signed(int *data, int arraySize)
{
    long long sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

คอมไพล์ด้วย -O1 ถึง

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        movsxd  rdx, dword ptr [rdi + 4*rsi]
        imul    rcx, rdx, 100000
        cmp     rdx, 127
        cmovle  rcx, r8
        add     rax, rcx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

จำนวนเต็มล้นไม่มีส่วนเกี่ยวข้องกับมัน หากมีจำนวนเต็มล้นที่ทำให้เกิดพฤติกรรมที่ไม่ได้กำหนดอาจเกิดขึ้นได้ในทั้งสองกรณี นี่คือฟังก์ชันประเภทเดียวกันที่ใช้intแทนlong :

int add_100k_signed(int *data, int arraySize)
{
    int sum = 0;

    for (int c = 0; c < arraySize; ++c)
        if (data[c] >= 128)
            for (int i = 0; i < 100000; ++i)
                sum += data[c];
    return sum;
}

คอมไพล์ด้วย -O1 ถึง

add_100k_signed:                        # @add_100k_signed
        test    esi, esi
        jle     .LBB0_1
        mov     r9d, esi
        xor     r8d, r8d
        xor     esi, esi
        xor     eax, eax
.LBB0_4:                                # =>This Inner Loop Header: Depth=1
        mov     edx, dword ptr [rdi + 4*rsi]
        imul    ecx, edx, 100000
        cmp     edx, 127
        cmovle  ecx, r8d
        add     eax, ecx
        add     rsi, 1
        cmp     r9, rsi
        jne     .LBB0_4
        ret
.LBB0_1:
        xor     eax, eax
        ret

2

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


3
การแทนที่ลูปด้วยการคำนวณแบบปิดก็เป็นการลดความแข็งแรงด้วยใช่หรือไม่?
Ben Voigt

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

1

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

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