บังคับใช้คำสั่งคำสั่งใน C ++


111

สมมติว่าฉันมีคำสั่งจำนวนหนึ่งที่ฉันต้องการดำเนินการตามลำดับคงที่ ฉันต้องการใช้ g ++ กับการเพิ่มประสิทธิภาพระดับ 2 ดังนั้นจึงสามารถจัดเรียงคำสั่งใหม่ได้ มีเครื่องมืออะไรบ้างในการบังคับใช้คำสั่งบางอย่าง

ลองพิจารณาตัวอย่างต่อไปนี้

using Clock = std::chrono::high_resolution_clock;

auto t1 = Clock::now(); // Statement 1
foo();                  // Statement 2
auto t2 = Clock::now(); // Statement 3

auto elapsedTime = t2 - t1;

ในตัวอย่างนี้เป็นสิ่งสำคัญที่จะต้องดำเนินการคำสั่ง 1-3 ตามลำดับที่กำหนด อย่างไรก็ตามคอมไพเลอร์ไม่สามารถคิดว่าคำสั่ง 2 เป็นอิสระจาก 1 และ 3 และรันโค้ดดังนี้ได้หรือไม่?

using Clock=std::chrono::high_resolution_clock;

foo();                  // Statement 2
auto t1 = Clock::now(); // Statement 1
auto t2 = Clock::now(); // Statement 3

auto elapsedTime = t2 - t1;

34
หากคอมไพเลอร์คิดว่ามันเป็นอิสระเมื่อไม่ได้คอมไพเลอร์ก็เสียและคุณควรใช้คอมไพเลอร์ที่ดีกว่า
David Schwartz


1
อาจ__sync_synchronize()จะมีการช่วยเหลือใด ๆ
vsz

3
@HowardHinnant: พลังเชิงความหมายของมาตรฐาน C จะได้รับการปรับปรุงอย่างมากหากมีการกำหนดคำสั่งดังกล่าวและหากกฎการกำหนดนามแฝงถูกปรับให้ได้รับการยกเว้นการอ่านที่ดำเนินการหลังจากอุปสรรคของข้อมูลซึ่งถูกเขียนขึ้นก่อนหน้านั้น
supercat

4
@DavidSchwartz ในกรณีนี้มันเกี่ยวกับการวัดเวลาที่fooใช้ในการรันซึ่งคอมไพเลอร์ได้รับอนุญาตให้เพิกเฉยเมื่อเรียงลำดับใหม่เช่นเดียวกับที่อนุญาตให้ละเว้นการสังเกตจากเธรดอื่น
CodesInChaos

คำตอบ:


100

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

โดยพื้นฐานแล้วไม่มีวิธีใดที่จะใช้อุปสรรคหรือการดำเนินการบางอย่างในลำดับเพื่อให้เกิดการเปลี่ยนแปลงเหล่านี้ ปัญหาพื้นฐานคือความหมายเชิงปฏิบัติการของบางสิ่งบางอย่างเช่นการบวกจำนวนเต็มนั้นเป็นที่รู้จักกันโดยสิ้นเชิงในการนำไปใช้งาน มันสามารถจำลองพวกมันได้โดยรู้ว่าพวกเขาไม่สามารถสังเกตได้ด้วยโปรแกรมที่ถูกต้องและมีอิสระที่จะย้ายพวกมันไปรอบ ๆ

เราสามารถพยายามป้องกันสิ่งนี้ได้ แต่มันจะมีผลลบอย่างมากและในที่สุดก็ล้มเหลว

ประการแรกวิธีเดียวที่จะป้องกันสิ่งนี้ในคอมไพเลอร์คือการบอกว่าการดำเนินการพื้นฐานทั้งหมดนี้สามารถสังเกตได้ ปัญหาคือสิ่งนี้จะขัดขวางการปรับแต่งคอมไพลเลอร์ส่วนใหญ่ที่ท่วมท้น ภายในคอมไพเลอร์เราไม่มีกลไกที่ดีในการสร้างแบบจำลองว่าเวลานั้นสามารถสังเกตได้ แต่ไม่มีอะไรอื่น เราไม่มีแม้แต่รูปแบบที่ดีของสิ่งที่ใช้เวลาในการดำเนินงาน ตัวอย่างเช่นการแปลงจำนวนเต็ม 32 บิตที่ไม่ได้ลงชื่อเป็นจำนวนเต็ม 64 บิตที่ไม่ได้ลงชื่อต้องใช้เวลาหรือไม่ ใช้เวลาเป็นศูนย์ใน x86-64 แต่ในสถาปัตยกรรมอื่น ๆ จะใช้เวลาที่ไม่ใช่ศูนย์ ไม่มีคำตอบที่ถูกต้องโดยทั่วไปที่นี่

แต่แม้ว่าเราจะประสบความสำเร็จจากวีรกรรมบางอย่างในการป้องกันไม่ให้คอมไพเลอร์จัดลำดับการดำเนินการเหล่านี้ใหม่ แต่ก็ไม่มีการรับประกันว่าสิ่งนี้จะเพียงพอ พิจารณาวิธีที่ถูกต้องและสอดคล้องในการรันโปรแกรม C ++ ของคุณบนเครื่อง x86: DynamoRIO นี่คือระบบที่ประเมินรหัสเครื่องของโปรแกรมแบบไดนามิก สิ่งหนึ่งที่ทำได้คือการเพิ่มประสิทธิภาพออนไลน์และยังสามารถดำเนินการตามคำแนะนำพื้นฐานทางคณิตศาสตร์ทั้งหมดโดยไม่ระบุเวลาได้อีกด้วย และพฤติกรรมนี้ไม่ได้เป็นลักษณะเฉพาะสำหรับผู้ประเมินแบบไดนามิก CPU x86 จริงจะคาดเดาคำสั่ง (จำนวนน้อยกว่ามาก) และเรียงลำดับใหม่แบบไดนามิก

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

แต่ทั้งหมดนี้ไม่ควรทำให้คุณหมดความหวัง เมื่อคุณต้องการกำหนดเวลาของการดำเนินการทางคณิตศาสตร์ขั้นพื้นฐานเราได้ศึกษาเทคนิคต่างๆที่ทำงานได้อย่างน่าเชื่อถือ โดยปกติเหล่านี้จะใช้เมื่อทำไมโครเปรียบเทียบ ฉันพูดคุยเกี่ยวกับเรื่องนี้ที่ CppCon2015: https://youtu.be/nXaxk27zwlk

เทคนิคที่แสดงมีให้โดยไลบรารีมาตรฐานขนาดเล็กต่างๆเช่นของ Google: https://github.com/google/benchmark#preventing-optimization

กุญแจสำคัญของเทคนิคเหล่านี้คือการมุ่งเน้นไปที่ข้อมูล คุณทำให้ข้อมูลเข้าในการคำนวณทึบแสงไปยังเครื่องมือเพิ่มประสิทธิภาพและผลลัพธ์ของการคำนวณทึบแสงไปยังเครื่องมือเพิ่มประสิทธิภาพ เมื่อคุณทำเสร็จแล้วคุณสามารถจับเวลาได้อย่างน่าเชื่อถือ ลองดูตัวอย่างเวอร์ชันจริงในคำถามเดิม แต่มีคำจำกัดความของfooการใช้งานที่มองเห็นได้อย่างสมบูรณ์ ฉันยังแยกเวอร์ชัน (ไม่พกพา) DoNotOptimizeจากไลบรารีเกณฑ์มาตรฐานของ Google ซึ่งคุณสามารถพบได้ที่นี่: https://github.com/google/benchmark/blob/master/include/benchmark/benchmark_api.h#L208

#include <chrono>

template <class T>
__attribute__((always_inline)) inline void DoNotOptimize(const T &value) {
  asm volatile("" : "+m"(const_cast<T &>(value)));
}

// The compiler has full knowledge of the implementation.
static int foo(int x) { return x * 2; }

auto time_foo() {
  using Clock = std::chrono::high_resolution_clock;

  auto input = 42;

  auto t1 = Clock::now();         // Statement 1
  DoNotOptimize(input);
  auto output = foo(input);       // Statement 2
  DoNotOptimize(output);
  auto t2 = Clock::now();         // Statement 3

  return t2 - t1;
}

ที่นี่เราตรวจสอบให้แน่ใจว่าข้อมูลอินพุตและข้อมูลเอาต์พุตถูกทำเครื่องหมายว่าไม่สามารถปรับให้เหมาะสมได้รอบ ๆ การคำนวณfooและเฉพาะรอบ ๆ เครื่องหมายเหล่านั้นเท่านั้นที่คำนวณเวลาที่คำนวณ เนื่องจากคุณกำลังใช้ข้อมูลเพื่อบีบการคำนวณจึงรับประกันได้ว่าจะอยู่ระหว่างการกำหนดเวลาทั้งสองและการคำนวณนั้นได้รับอนุญาตให้ปรับให้เหมาะสม แอสเซมบลี x86-64 ที่เป็นผลลัพธ์ที่สร้างโดย Clang / LLVM รุ่นล่าสุดคือ:

% ./bin/clang++ -std=c++14 -c -S -o - so.cpp -O3
        .text
        .file   "so.cpp"
        .globl  _Z8time_foov
        .p2align        4, 0x90
        .type   _Z8time_foov,@function
_Z8time_foov:                           # @_Z8time_foov
        .cfi_startproc
# BB#0:                                 # %entry
        pushq   %rbx
.Ltmp0:
        .cfi_def_cfa_offset 16
        subq    $16, %rsp
.Ltmp1:
        .cfi_def_cfa_offset 32
.Ltmp2:
        .cfi_offset %rbx, -16
        movl    $42, 8(%rsp)
        callq   _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, %rbx
        #APP
        #NO_APP
        movl    8(%rsp), %eax
        addl    %eax, %eax              # This is "foo"!
        movl    %eax, 12(%rsp)
        #APP
        #NO_APP
        callq   _ZNSt6chrono3_V212system_clock3nowEv
        subq    %rbx, %rax
        addq    $16, %rsp
        popq    %rbx
        retq
.Lfunc_end0:
        .size   _Z8time_foov, .Lfunc_end0-_Z8time_foov
        .cfi_endproc


        .ident  "clang version 3.9.0 (trunk 273389) (llvm/trunk 273380)"
        .section        ".note.GNU-stack","",@progbits

ที่นี่คุณสามารถเห็นคอมไพลเลอร์ปรับแต่งการเรียกfoo(input)ลงไปที่คำสั่งเดียวaddl %eax, %eaxแต่ไม่ต้องย้ายออกนอกเวลาหรือกำจัดมันทั้งหมดแม้จะมีอินพุตคงที่ก็ตาม

หวังว่านี่จะช่วยได้และคณะกรรมการมาตรฐาน C ++ กำลังพิจารณาถึงความเป็นไปได้ในการกำหนดมาตรฐาน API ที่คล้ายกับDoNotOptimizeที่นี่


1
ขอบคุณสำหรับคำตอบ. ฉันได้กำหนดให้เป็นคำตอบใหม่ที่ดีที่สุด ฉันทำได้ก่อนหน้านี้ แต่ฉันไม่ได้อ่านหน้า stackoverflow นี้มาหลายเดือนแล้ว ฉันสนใจที่จะใช้คอมไพเลอร์ Clang เพื่อสร้างโปรแกรม C ++ เหนือสิ่งอื่นใดฉันชอบที่สามารถใช้อักขระ Unicode ในชื่อตัวแปรใน Clang ฉันคิดว่าฉันจะถามคำถามเพิ่มเติมเกี่ยวกับเสียงดังใน Stackoverflow
S2108887

5
ในขณะที่ฉันเข้าใจว่าวิธีนี้ป้องกันไม่ให้ foo ถูกปรับให้เหมาะสมอย่างสมบูรณ์ได้อย่างไรคุณสามารถอธิบายรายละเอียดได้เล็กน้อยว่าเหตุใดสิ่งนี้จึงป้องกันไม่ให้มีClock::now()การจัดลำดับการโทรใหม่เมื่อเทียบกับ foo () เครื่องมือเพิ่มประสิทธิภาพต้องถือว่าDoNotOptimizeและClock::now()สามารถเข้าถึงและอาจแก้ไขสถานะส่วนกลางบางอย่างซึ่งจะผูกเข้ากับอินและเอาต์พุตหรือไม่ หรือคุณกำลังอาศัยข้อ จำกัด บางประการในการใช้งานเครื่องมือเพิ่มประสิทธิภาพในปัจจุบัน?
MikeMB

2
DoNotOptimizeในตัวอย่างนี้คือเหตุการณ์ "สังเกตได้" ที่สังเคราะห์ขึ้น เหมือนกับว่ามันพิมพ์เอาต์พุตที่มองเห็นได้โดยใช้แนวคิดไปยังเทอร์มินัลบางเครื่องพร้อมกับการแสดงอินพุต เนื่องจากการอ่านนาฬิกานั้นสามารถสังเกตได้เช่นกัน (คุณกำลังสังเกตเวลาที่ผ่านไป) จึงไม่สามารถสั่งซื้อใหม่ได้โดยไม่เปลี่ยนพฤติกรรมที่สังเกตได้ของโปรแกรม
Chandler Carruth

1
ฉันยังไม่ค่อยชัดเจนกับแนวคิด "สังเกตได้" ถ้าfooฟังก์ชันกำลังดำเนินการบางอย่างเช่นการอ่านจากซ็อกเก็ตซึ่งอาจถูกบล็อกชั่วขณะการดำเนินการนี้จะนับเป็นการดำเนินการที่สังเกตได้หรือไม่ และเนื่องจากการดำเนินการreadนี้ไม่ใช่การดำเนินการที่ "ทราบทั้งหมด" (ใช่ไหม) รหัสจะถูกเก็บไว้ตามลำดับ
ravenisadesk

"ปัญหาพื้นฐานคือความหมายเชิงปฏิบัติการของบางสิ่งบางอย่างเช่นการบวกจำนวนเต็มเป็นที่รู้จักกันโดยสิ้นเชิงในการนำไปใช้" แต่สำหรับฉันแล้วดูเหมือนว่าปัญหาไม่ใช่ความหมายของการบวกจำนวนเต็ม แต่เป็นความหมายของการเรียกฟังก์ชัน foo () เว้นแต่ foo () จะอยู่ในหน่วยคอมไพล์เดียวกันจะรู้ได้อย่างไรว่า foo () และ clock () ไม่โต้ตอบ?
Dave

59

สรุป:

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

คำตอบเดิม:

GCC จัดลำดับการโทรใหม่ภายใต้การเพิ่มประสิทธิภาพ -O2:

#include <chrono>
static int foo(int x)    // 'static' or not here doesn't affect ordering.
{
    return x*2;
}
int fred(int x)
{
    auto t1 = std::chrono::high_resolution_clock::now();
    int y = foo(x);
    auto t2 = std::chrono::high_resolution_clock::now();
    return y;
}

GCC 5.3.0:

g++ -S --std=c++11 -O0 fred.cpp :

_ZL3fooi:
        pushq   %rbp
        movq    %rsp, %rbp
        movl    %ecx, 16(%rbp)
        movl    16(%rbp), %eax
        addl    %eax, %eax
        popq    %rbp
        ret
_Z4fredi:
        pushq   %rbp
        movq    %rsp, %rbp
        subq    $64, %rsp
        movl    %ecx, 16(%rbp)
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, -16(%rbp)
        movl    16(%rbp), %ecx
        call    _ZL3fooi
        movl    %eax, -4(%rbp)
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movq    %rax, -32(%rbp)
        movl    -4(%rbp), %eax
        addq    $64, %rsp
        popq    %rbp
        ret

แต่:

g++ -S --std=c++11 -O2 fred.cpp :

_Z4fredi:
        pushq   %rbx
        subq    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        call    _ZNSt6chrono3_V212system_clock3nowEv
        leal    (%rbx,%rbx), %eax
        addq    $32, %rsp
        popq    %rbx
        ret

ตอนนี้ด้วย foo () เป็นฟังก์ชันภายนอก:

#include <chrono>
int foo(int x);
int fred(int x)
{
    auto t1 = std::chrono::high_resolution_clock::now();
    int y = foo(x);
    auto t2 = std::chrono::high_resolution_clock::now();
    return y;
}

g++ -S --std=c++11 -O2 fred.cpp :

_Z4fredi:
        pushq   %rbx
        subq    $32, %rsp
        movl    %ecx, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %ecx
        call    _Z3fooi
        movl    %eax, %ebx
        call    _ZNSt6chrono3_V212system_clock3nowEv
        movl    %ebx, %eax
        addq    $32, %rsp
        popq    %rbx
        ret

แต่ถ้าสิ่งนี้เชื่อมโยงกับ -flto (การเพิ่มประสิทธิภาพเวลาเชื่อมโยง):

0000000100401710 <main>:
   100401710:   53                      push   %rbx
   100401711:   48 83 ec 20             sub    $0x20,%rsp
   100401715:   89 cb                   mov    %ecx,%ebx
   100401717:   e8 e4 ff ff ff          callq  100401700 <__main>
   10040171c:   e8 bf f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
   100401721:   e8 ba f9 ff ff          callq  1004010e0 <_ZNSt6chrono3_V212system_clock3nowEv>
   100401726:   8d 04 1b                lea    (%rbx,%rbx,1),%eax
   100401729:   48 83 c4 20             add    $0x20,%rsp
   10040172d:   5b                      pop    %rbx
   10040172e:   c3                      retq

3
MSVC และ ICC ก็เช่นกัน เสียงดังเป็นคนเดียวที่ดูเหมือนจะรักษาลำดับดั้งเดิมไว้
โคดี้เกรย์

3
คุณไม่ได้ใช้ t1 และ t2 ที่ใดก็ได้ดังนั้นอาจคิดว่าผลลัพธ์สามารถละทิ้งและจัดลำดับรหัสใหม่ได้
phuclv

3
@Niall - ฉันไม่สามารถเสนออะไรที่เป็นรูปธรรมได้มากกว่านี้ แต่ฉันคิดว่าความคิดเห็นของฉันพาดพิงถึงเหตุผลพื้นฐาน: คอมไพเลอร์รู้ว่า foo () ไม่สามารถส่งผลกระทบในขณะนี้ () หรือในทางกลับกันและการเรียงลำดับใหม่ก็เช่นกัน การทดลองต่างๆที่เกี่ยวข้องกับฟังก์ชันขอบเขตภายนอกและข้อมูลดูเหมือนจะยืนยันสิ่งนี้ ซึ่งรวมถึงการมี foo แบบคงที่ () ขึ้นอยู่กับตัวแปรขอบเขตไฟล์ N - หาก N ถูกประกาศว่าเป็นแบบคงที่จะเกิดการจัดลำดับใหม่ในขณะที่หากประกาศว่าไม่คงที่ (กล่าวคือหน่วยคอมไพล์อื่น ๆ สามารถมองเห็นได้และด้วยเหตุนี้จึงอาจได้รับผลข้างเคียงของ ฟังก์ชันภายนอกเช่น now ()) จะไม่เกิดการเรียงลำดับใหม่
Jeremy

3
@ LưuVĩnhPhúc: ยกเว้นการเรียกตัวเองจะไม่ถูกหลีกเลี่ยง อีกครั้งหนึ่งที่ฉันสงสัยว่านี้เป็นเพราะคอมไพเลอร์ไม่ทราบว่าผลข้างเคียงของพวกเขาอาจจะมี - แต่ก็ไม่ทราบว่าผู้ที่มีผลข้างเคียงไม่สามารถมีอิทธิพลต่อพฤติกรรมของ foo () ที่
Jeremy

3
และหมายเหตุสุดท้าย: การระบุ -flto (การเพิ่มประสิทธิภาพเวลาเชื่อมโยง) ทำให้เกิดการเรียงลำดับใหม่แม้ในกรณีอื่นที่ไม่ได้เรียงลำดับใหม่
Jeremy

20

การเรียงลำดับใหม่อาจทำได้โดยคอมไพเลอร์หรือโดยโปรเซสเซอร์

คอมไพเลอร์ส่วนใหญ่เสนอวิธีการเฉพาะแพลตฟอร์มเพื่อป้องกันการเรียงลำดับคำสั่งอ่าน - เขียนซ้ำ ใน gcc นี่คือ

asm volatile("" ::: "memory");

( ข้อมูลเพิ่มเติมที่นี่ )

โปรดทราบว่าสิ่งนี้จะป้องกันการจัดลำดับการดำเนินการใหม่ทางอ้อมเท่านั้นตราบเท่าที่การดำเนินการดังกล่าวขึ้นอยู่กับการอ่าน / เขียน

ในทางปฏิบัติฉันยังไม่เห็นระบบที่ระบบโทรเข้าClock::now()มีผลเช่นเดียวกับอุปสรรคดังกล่าว คุณสามารถตรวจสอบการประกอบผลลัพธ์เพื่อความแน่ใจ

อย่างไรก็ตามไม่ใช่เรื่องแปลกที่ฟังก์ชันภายใต้การทดสอบจะได้รับการประเมินระหว่างเวลาคอมไพล์ ในการบังคับใช้การดำเนินการที่ "เหมือนจริง" คุณอาจต้องได้รับข้อมูลfoo()จาก I / O หรือvolatileอ่าน


อีกทางเลือกหนึ่งคือปิดการใช้งาน inlining สำหรับfoo()- อีกครั้งนี่เป็นคอมไพเลอร์เฉพาะและโดยปกติจะไม่พกพาได้ แต่จะมีผลเช่นเดียวกัน

ใน gcc นี่จะเป็น __attribute__ ((noinline))


@Ruslan นำเสนอประเด็นพื้นฐาน: การวัดผลนี้มีความเป็นจริงเพียงใด?

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

ดังนั้นสิ่งที่เรามักจะทำเพื่อให้ได้เวลาที่เทียบเคียงกัน: ตรวจสอบให้แน่ใจว่าสามารถทำซ้ำได้โดยมีขอบข้อผิดพลาดต่ำ สิ่งนี้ทำให้พวกเขาค่อนข้างเทียม

"แคชร้อน" กับ "แคชเย็น" ประสิทธิภาพการดำเนินการอาจแตกต่างกันได้อย่างง่ายดายตามลำดับขนาด - แต่ในความเป็นจริงมันจะเป็นสิ่งที่อยู่ระหว่างกัน ("อุ่น"?)


2
การแฮ็กของคุณมีasmผลต่อเวลาดำเนินการของคำสั่งระหว่างการเรียกตัวจับเวลา: โค้ดหลังจาก memory clobber ต้องโหลดตัวแปรทั้งหมดจากหน่วยความจำ
Ruslan

@Ruslan: แฮ็คของพวกเขาไม่ใช่ของฉัน มีระดับการกวาดล้างที่แตกต่างกันและการทำบางสิ่งเช่นนั้นเป็นสิ่งที่หลีกเลี่ยงไม่ได้สำหรับผลลัพธ์ที่ทำซ้ำได้
peterchen

2
โปรดทราบว่าการแฮ็กด้วย 'asm' ช่วยเป็นอุปสรรคสำหรับการดำเนินการที่สัมผัสหน่วยความจำเท่านั้นและ OP จะสนใจมากกว่านั้น ดูคำตอบของฉันสำหรับรายละเอียดเพิ่มเติม
Chandler Carruth

11

ภาษา C ++ กำหนดสิ่งที่สังเกตได้ในหลายวิธี

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

หากfoo()มีความสัมพันธ์กับไฟล์หรือจอแสดงผลและคอมไพเลอร์ไม่สามารถพิสูจน์ได้ว่าClock::now()ไม่ได้มีปฏิสัมพันธ์กับไฟล์หรือการแสดงผลจากนั้นก็สั่งใหม่ไม่สามารถทำได้เพราะการมีปฏิสัมพันธ์กับไฟล์หรือจอแสดงผลเป็นพฤติกรรมที่สังเกตได้

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

สร้างไลบรารีที่โหลดแบบไดนามิก โหลดก่อนโค้ดที่มีปัญหา

ห้องสมุดนั้นเปิดเผยสิ่งหนึ่ง:

namespace details {
  void execute( void(*)(void*), void *);
}

และห่อไว้ดังนี้:

template<class F>
void execute( F f ) {
  struct bundle_t {
    F f;
  } bundle = {std::forward<F>(f)};

  auto tmp_f = [](void* ptr)->void {
    auto* pb = static_cast<bundle_t*>(ptr);
    (pb->f)();
  };
  details::execute( tmp_f, &bundle );
}

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

ภายในไลบรารีไดนามิกเราทำ:

void details::execute( void(*f)(void*), void *p) {
  f(p);
}

ซึ่งค่อนข้างเรียบง่าย

ตอนนี้เพื่อจัดลำดับการโทรใหม่ไปที่ executeใหม่ต้องเข้าใจไดนามิกไลบรารีซึ่งไม่สามารถในขณะรวบรวมรหัสทดสอบของคุณได้

มันยังคงสามารถกำจัดfoo()s โดยไม่มีผลข้างเคียง แต่คุณชนะบ้างคุณก็แพ้บ้าง


19
"อีกวิธีหนึ่งคือการพยายามชิงไหวชิงพริบคอมไพเลอร์ของคุณ"ถ้าวลีนั้นไม่ได้บ่งบอกถึงการลงไปในโพรงกระต่ายฉันก็ไม่รู้ว่าคืออะไร :-)
โคดี้เกรย์

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

1
... เผยแพร่ผ่านแคชฮาร์ดแวร์จะต้องใช้วิธีการเฉพาะฮาร์ดแวร์ในการทำเช่นนั้น แต่วิธีการเฉพาะฮาร์ดแวร์ในการรอจนกว่าการเขียนที่โพสต์ทั้งหมดจะเสร็จสมบูรณ์จะไร้ประโยชน์หากไม่มีคำสั่งกั้นเพื่อให้แน่ใจว่าการเขียนที่รอดำเนินการทั้งหมดจะถูกติดตามโดยคอมไพเลอร์ ต้องได้รับการโพสต์ไปยังฮาร์ดแวร์ก่อนที่ฮาร์ดแวร์จะถูกถามเพื่อให้แน่ใจว่าการเขียนที่โพสต์ทั้งหมดเสร็จสมบูรณ์] ฉันรู้ว่าไม่มีวิธีใดในการทำเช่นนั้นในภาษาใดภาษาหนึ่งโดยไม่ใช้การvolatileเข้าถึงแบบจำลองหรือโทรไปยังรหัสภายนอก
supercat

4

ไม่มันไม่ได้ ตามมาตรฐาน C ++ [intro.execution]:

14 ทุกการคำนวณค่าและผลข้างเคียงที่เกี่ยวข้องกับนิพจน์เต็มจะเรียงลำดับก่อนการคำนวณค่าและผลข้างเคียงทุกครั้งที่เกี่ยวข้องกับนิพจน์เต็มถัดไปที่จะได้รับการประเมิน

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

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


12
ยังคงมีกฎ as-if อยู่
MM

18
โดยคอมไพเลอร์กฎ as-ifสามารถทำอะไรก็ได้เพื่อเขียนโค้ดตราบเท่าที่มันไม่เปลี่ยนพฤติกรรมที่สังเกตได้ เวลาของการดำเนินการไม่สามารถสังเกตได้ ดังนั้นจึงสามารถเรียงลำดับบรรทัดของโค้ดใหม่ได้ตราบเท่าที่ผลลัพธ์จะเหมือนกัน (คอมไพเลอร์ส่วนใหญ่ทำสิ่งที่สมเหตุสมผลและไม่เรียงลำดับการเรียกเวลาใหม่ แต่ไม่จำเป็นต้องใช้)
Revolver_Ocelot

6
เวลาของการดำเนินการไม่สามารถสังเกตได้ นี่เป็นเรื่องแปลกทีเดียว จากมุมมองที่ใช้งานได้จริงและไม่ใช่ทางเทคนิคเวลาในการดำเนินการ (หรือที่เรียกว่า "ประสิทธิภาพ") เป็นสิ่งที่สังเกตได้มาก
Frédéric Hamidi

3
ขึ้นอยู่กับว่าคุณวัดเวลาอย่างไร เป็นไปไม่ได้ที่จะวัดจำนวนรอบสัญญาณนาฬิกาที่ใช้ในการรันเนื้อหาของโค้ดใน C ++ มาตรฐาน
ปีเตอร์

3
@dba คุณกำลังผสมบางอย่างเข้าด้วยกัน ตัวเชื่อมโยงไม่สามารถสร้างแอปพลิเคชัน Win16 ได้อีกต่อไปนั่นเป็นความจริงเพียงพอ แต่นั่นเป็นเพราะพวกเขาได้ลบการสนับสนุนสำหรับการสร้างไบนารีประเภทนั้น แอป WIn16 ไม่ใช้รูปแบบ PE นั่นไม่ได้หมายความว่าคอมไพเลอร์หรือตัวเชื่อมโยงมีความรู้พิเศษเกี่ยวกับฟังก์ชัน API ปัญหาอื่น ๆ เกี่ยวข้องกับไลบรารีรันไทม์ ไม่มีปัญหาในการรับ MSVC เวอร์ชันล่าสุดเพื่อสร้างไบนารีที่ทำงานบน NT 4 ฉันได้ทำไปแล้ว ปัญหาเกิดขึ้นทันทีที่คุณพยายามเชื่อมโยงใน CRT ซึ่งเรียกใช้ฟังก์ชันไม่ได้
โคดี้เกรย์

2

ไม่

บางครั้งตามกฎ "as-if" อาจมีการเรียงลำดับคำสั่งใหม่ นี่ไม่ใช่เพราะพวกมันเป็นอิสระจากกันในเชิงเหตุผล แต่เป็นเพราะความเป็นอิสระดังกล่าวทำให้การสั่งซื้อใหม่เกิดขึ้นได้โดยไม่ต้องเปลี่ยนความหมายของโปรแกรม

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

โดยทั่วไปฉันไม่คาดหวังว่านิพจน์ใด ๆ ที่ส่งผลให้การเรียกระบบเป็น "การเดาครั้งที่สอง" แม้แต่คอมไพเลอร์ที่เพิ่มประสิทธิภาพอย่างจริงจัง มันไม่เพียงพอเกี่ยวกับสิ่งที่ระบบเรียกใช้


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

4
@Revolver_Ocelot: การเพิ่มประสิทธิภาพที่เปลี่ยนความหมายของโปรแกรม (โอเคบันทึกสำหรับคัดลอก elision) ไม่เป็นไปตามมาตรฐานไม่ว่าคุณจะเห็นด้วยหรือไม่ก็ตาม
Lightness Races in Orbit

6
เล็กน้อยในกรณีของการint x = 0; clock(); x = y*2; clock();มีไม่มีวิธีการที่กำหนดไว้สำหรับรหัสในการโต้ตอบกับสถานะของclock() xภายใต้ c ++ มาตรฐานก็ไม่จำเป็นต้องรู้ว่าสิ่งที่clock()ไม่ - มันอาจจะตรวจสอบสแต็ค (และแจ้งให้ทราบเมื่อมีการคำนวณที่เกิดขึ้น) แต่ที่ไม่ได้เป็น C ++ 's ปัญหา
Yakk - Adam Nevraumont

5
เพื่อนำประเด็นของ Yakk ไปให้ไกลกว่านั้น: เป็นเรื่องจริงที่การสั่งซื้อการเรียกระบบอีกครั้งเพื่อให้ผลลัพธ์ของครั้งแรกถูกกำหนดให้t2และอันดับที่สองt1จะไม่เป็นไปตามข้อกำหนดและโง่เขลาหากใช้ค่าเหล่านั้นสิ่งที่คำตอบนี้พลาดก็คือ บางครั้งคอมไพลเลอร์ที่สอดคล้องกันสามารถสั่งซื้อรหัสอื่นใหม่ได้ในการเรียกระบบ ในกรณีนี้ถ้ามันรู้ว่าอะไรfoo()ทำอะไรได้บ้าง (เช่นมันมีอินไลน์) และด้วยเหตุนี้ (พูดแบบหลวม ๆ ) มันจึงเป็นฟังก์ชันที่บริสุทธิ์จากนั้นจึงสามารถเคลื่อนย้ายไปรอบ ๆ ได้
Steve Jessop

1
.. พูดอีกอย่างหลวม ๆ นี่เป็นเพราะไม่มีการรับประกันว่าการนำไปใช้งานจริง (แม้ว่าจะไม่ใช่เครื่องนามธรรม) จะไม่คำนวณอย่างละเอียดy*yก่อนที่ระบบจะเรียกใช้เพียงเพื่อความสนุกสนาน นอกจากนี้ยังมีการรับประกันว่าการดำเนินงานที่เกิดขึ้นจริงจะไม่ใช้ผลจากการคำนวณการเก็งกำไรนี้ต่อมาที่จุดสิ่งที่ถูกนำมาใช้จึงทำอะไรระหว่างสายไปx clock()เช่นเดียวกันกับสิ่งที่ฟังก์ชันอินไลน์fooทำหากไม่มีผลข้างเคียงและไม่สามารถขึ้นอยู่กับสถานะที่อาจเปลี่ยนแปลงclock()ได้
Steve Jessop

0

noinline ฟังก์ชั่น + กล่องดำแอสเซมบลีแบบอินไลน์ + การอ้างอิงข้อมูลแบบเต็ม

นี่อ้างอิงจากhttps://stackoverflow.com/a/38025837/895245แต่เนื่องจากฉันไม่เห็นเหตุผลที่ชัดเจนว่าทำไมจึง::now()ไม่สามารถจัดเรียงใหม่ได้ที่นั่นฉันค่อนข้างจะหวาดระแวงและวางไว้ในฟังก์ชัน noinline พร้อมกับ asm.

ด้วยวิธีนี้ฉันค่อนข้างมั่นใจว่าการจัดลำดับใหม่ไม่สามารถเกิดขึ้นได้เนื่องจากnoinline"ความสัมพันธ์" ของการ::nowพึ่งพาและการพึ่งพาข้อมูล

main.cpp

#include <chrono>
#include <iostream>
#include <string>

// noinline ensures that the ::now() cannot be split from the __asm__
template <class T>
__attribute__((noinline)) auto get_clock(T& value) {
    // Make the compiler think we actually use / modify the value.
    // It can't "see" what is going on inside the assembly string.
    __asm__ __volatile__ ("" : "+g" (value));
    return std::chrono::high_resolution_clock::now();
}

template <class T>
static T foo(T niters) {
    T result = 42;
    for (T i = 0; i < niters; ++i) {
        result = (result * result) - (3 * result) + 1;
    }
    return result;
}

int main(int argc, char **argv) {
    unsigned long long input;
    if (argc > 1) {
        input = std::stoull(argv[1], NULL, 0);
    } else {
        input = 1;
    }

    // Must come before because it could modify input
    // which is passed as a reference.
    auto t1 = get_clock(input);
    auto output = foo(input);
    // Must come after as it could use the output.
    auto t2 = get_clock(output);
    std::cout << "output " << output << std::endl;
    std::cout << "time (ns) "
              << std::chrono::duration_cast<std::chrono::nanoseconds>(t2 - t1).count()
              << std::endl;
}

GitHub อัปสตรี

รวบรวมและเรียกใช้:

g++ -ggdb3 -O3 -std=c++14 -Wall -Wextra -pedantic -o main.out main.cpp
./main.out 1000
./main.out 10000
./main.out 100000

ข้อเสียเพียงเล็กน้อยของวิธีนี้คือเราเพิ่มcallqคำสั่งพิเศษหนึ่งคำสั่งในinlineวิธีการหนึ่ง objdump -CDแสดงว่าmainประกอบด้วย:

    11b5:       e8 26 03 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>
    11ba:       48 8b 34 24             mov    (%rsp),%rsi
    11be:       48 89 c5                mov    %rax,%rbp
    11c1:       b8 2a 00 00 00          mov    $0x2a,%eax
    11c6:       48 85 f6                test   %rsi,%rsi
    11c9:       74 1a                   je     11e5 <main+0x65>
    11cb:       31 d2                   xor    %edx,%edx
    11cd:       0f 1f 00                nopl   (%rax)
    11d0:       48 8d 48 fd             lea    -0x3(%rax),%rcx
    11d4:       48 83 c2 01             add    $0x1,%rdx
    11d8:       48 0f af c1             imul   %rcx,%rax
    11dc:       48 83 c0 01             add    $0x1,%rax
    11e0:       48 39 d6                cmp    %rdx,%rsi
    11e3:       75 eb                   jne    11d0 <main+0x50>
    11e5:       48 89 df                mov    %rbx,%rdi
    11e8:       48 89 44 24 08          mov    %rax,0x8(%rsp)
    11ed:       e8 ee 02 00 00          callq  14e0 <auto get_clock<unsigned long long>(unsigned long long&)>

เราจึงเห็นสิ่งนั้นfooอยู่ในแนวเส้น แต่get_clockไม่ใช่และล้อมรอบ

get_clock อย่างไรก็ตามตัวมันเองมีประสิทธิภาพสูงมากซึ่งประกอบด้วยคำสั่งที่ปรับให้เหมาะกับการโทรแบบใบเดียวที่ไม่ได้สัมผัสสแต็ก:

00000000000014e0 <auto get_clock<unsigned long long>(unsigned long long&)>:
    14e0:       e9 5b fb ff ff          jmpq   1040 <std::chrono::_V2::system_clock::now()@plt>

ตั้งแต่ความแม่นยำนาฬิกาเป็นตัว จำกัด jmpqผมคิดว่าไม่น่าที่คุณจะสามารถที่จะแจ้งให้ทราบถึงผลกระทบระยะเวลาของการอย่างใดอย่างหนึ่งเป็นพิเศษ โปรดทราบว่าcallจำเป็นต้องมีไม่ว่า::now()จะอยู่ในไลบรารีที่แชร์

โทร::now()จากแอสเซมบลีแบบอินไลน์พร้อมการพึ่งพาข้อมูล

นี่จะเป็นวิธีแก้ปัญหาที่มีประสิทธิภาพมากที่สุดเท่าที่จะเป็นไปได้โดยสามารถเอาชนะสิ่งที่jmpqกล่าวมาข้างต้นได้

นี่เป็นเรื่องยากมากที่จะทำอย่างถูกต้องดังที่แสดงไว้ที่: เรียกใช้ printf ใน ASM อินไลน์แบบขยาย

หากการวัดเวลาของคุณสามารถทำได้โดยตรงในการประกอบแบบอินไลน์โดยไม่ต้องใช้สายสามารถใช้เทคนิคนี้ได้ นี่เป็นกรณีตัวอย่างสำหรับคำแนะนำในการใช้เครื่องมือวัดเวทย์มนตร์ gem5 , x86 RDTSC (ไม่แน่ใจว่านี่เป็นตัวแทนอีกต่อไปหรือไม่) และตัวนับประสิทธิภาพอื่น ๆ

หัวข้อที่เกี่ยวข้อง:

ทดสอบกับ GCC 8.3.0, Ubuntu 19.04


1
โดยปกติคุณไม่จำเป็นต้องบังคับให้มีการรั่วไหล / โหลดซ้ำการ"+m"ใช้"+r"เป็นวิธีที่มีประสิทธิภาพมากขึ้นในการทำให้คอมไพเลอร์เป็นตัวกำหนดค่าจากนั้นถือว่าตัวแปรมีการเปลี่ยนแปลง
Peter Cordes
โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.