ทำไมการโอนเมทริกซ์ที่ 512x512 ช้ากว่าการแปลงเมทริกซ์ที่ 513x513 มาก?


218

หลังจากทำการทดลองบางอย่างเกี่ยวกับเมทริกซ์จตุรัสที่มีขนาดต่างกันรูปแบบจะปรากฏขึ้น คงเส้นคงวาtransposing เมทริกซ์ที่มีขนาด2^nช้ากว่า transposing 2^n+1หนึ่งขนาด สำหรับค่าเล็กน้อยของnความแตกต่างไม่สำคัญ

ความแตกต่างใหญ่เกิดขึ้นอย่างไรก็ตามค่า 512 (อย่างน้อยสำหรับฉัน)

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

ตามรหัส:

#define SAMPLES 1000
#define MATSIZE 512

#include <time.h>
#include <iostream>
int mat[MATSIZE][MATSIZE];

void transpose()
{
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
   {
       int aux = mat[i][j];
       mat[i][j] = mat[j][i];
       mat[j][i] = aux;
   }
}

int main()
{
   //initialize matrix
   for ( int i = 0 ; i < MATSIZE ; i++ )
   for ( int j = 0 ; j < MATSIZE ; j++ )
       mat[i][j] = i+j;

   int t = clock();
   for ( int i = 0 ; i < SAMPLES ; i++ )
       transpose();
   int elapsed = clock() - t;

   std::cout << "Average for a matrix of " << MATSIZE << ": " << elapsed / SAMPLES;
}

การเปลี่ยนMATSIZEให้เราเปลี่ยนขนาดได้ (duh!) ฉันโพสต์สองรุ่นใน ideone:

ในสภาพแวดล้อมของฉัน (MSVS 2010, การปรับให้เหมาะสมเต็มรูปแบบ) ความแตกต่างคล้ายกัน:

  • ขนาด 512 - เฉลี่ย2.19 ms
  • ขนาด 513 - เฉลี่ย0.57 ms

ทำไมสิ่งนี้จึงเกิดขึ้น


9
รหัสของคุณดูแคชไม่เป็นมิตรกับฉัน
CodesInChaos

7
เป็นปัญหาเดียวกันกับคำถามนี้: stackoverflow.com/questions/7905760/…
Mysticial

สนใจที่จะลิ้มรส @CodesInChaos? (หรือใคร ๆ )
corazza

@Bane เกี่ยวกับการอ่านคำตอบที่ยอมรับได้อย่างไร
CodesInChaos

4
@nzomkxia มันไม่มีจุดหมายที่จะวัดอะไรโดยไม่ปรับให้เหมาะสม เมื่อปิดใช้งานการปรับให้เหมาะสมรหัสที่สร้างขึ้นจะถูกทิ้งด้วยขยะภายนอกที่จะซ่อนคอขวดอื่น ๆ (เช่นหน่วยความจำ)
Mysticial

คำตอบ:


197

คำอธิบายมาจาก Agner Fog ในซอฟต์แวร์การปรับให้เหมาะสมใน C ++และลดการเข้าถึงและจัดเก็บข้อมูลในแคช

สำหรับข้อตกลงและข้อมูลรายละเอียดดูรายการ wiki ในการแคชฉันจะแคบลงที่นี่

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

สำหรับที่อยู่หน่วยความจำเฉพาะเราสามารถคำนวณชุดที่ควรทำมิเรอร์ด้วยสูตร:

set = ( address / lineSize ) % numberOfsets

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

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

ฉันจะพยายามทำตามตัวอย่างจาก Agner:

สมมติว่าแต่ละชุดมี 4 บรรทัดแต่ละถือ 64 ไบต์ ครั้งแรกที่เราพยายามที่จะอ่านที่อยู่ที่จะไปอยู่ในชุด0x2710 28และจากนั้นเรายังพยายามที่จะอ่านที่อยู่0x2F00, 0x3700, และ0x3F00 0x4700ทั้งหมดเหล่านี้เป็นของชุดเดียวกัน ก่อนอ่าน0x4700บรรทัดทั้งหมดในชุดจะถูกครอบครอง อ่านหน่วยความจำที่ evicts 0x2710สายที่มีอยู่ในชุดบรรทัดที่แรกคือการถือครอง ปัญหาเกิดขึ้นจากข้อเท็จจริงที่ว่าเราอ่านที่อยู่ที่0x800แยกออกจากกัน นี่คือก้าวย่างที่สำคัญ (อีกครั้งสำหรับตัวอย่างนี้)

วิกฤติที่สำคัญสามารถคำนวณได้:

criticalStride = numberOfSets * lineSize

ตัวแปรที่เว้นระยะห่างcriticalStrideหรือหลายรายการแย่งกันสำหรับบรรทัดแคชเดียวกัน

นี่คือส่วนของทฤษฎี ต่อไปคำอธิบาย (เช่น Agner ฉันติดตามอย่างใกล้ชิดเพื่อหลีกเลี่ยงการทำผิดพลาด):

สมมติว่าเมทริกซ์ขนาด 64x64 (จำไว้ว่าเอฟเฟกต์จะแตกต่างกันไปตามแคช) ด้วยแคชขนาด 8kb, 4 บรรทัดต่อชุด * ขนาดบรรทัด 64 ไบต์ แต่ละบรรทัดสามารถเก็บ 8 องค์ประกอบในเมทริกซ์ (64- บิตint)

วิกฤตที่สำคัญคือ 2048 ไบต์ซึ่งสอดคล้องกับ 4 แถวของเมทริกซ์ (ซึ่งต่อเนื่องในหน่วยความจำ)

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

เมื่อถึงองค์ประกอบ 16 ในคอลัมน์ (4 แคชบรรทัดต่อชุด & 4 แถวห่างกัน = ปัญหา) องค์ประกอบ ex-0 จะถูกขับออกจากแคช เมื่อเราไปถึงจุดสิ้นสุดของคอลัมน์บรรทัดแคชก่อนหน้านี้ทั้งหมดจะหายไปและจำเป็นต้องโหลดซ้ำในการเข้าถึงองค์ประกอบถัดไป (ทั้งบรรทัดถูกเขียนทับ)

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

ข้อจำกัดความรับผิดชอบอื่น - ฉันเพิ่งได้รับคำอธิบายและหวังว่าฉันจะจับมัน แต่ฉันอาจเข้าใจผิด อย่างไรก็ตามผมกำลังรอการตอบสนอง (หรือการยืนยัน) จากMysticial :)


โอ้และครั้งต่อไป เพียงแค่ ping ผมโดยตรงผ่านเลาจน์ ฉันไม่พบชื่อทุกชื่อใน SO :) ฉันเห็นสิ่งนี้ผ่านการแจ้งเตือนทางอีเมลเป็นระยะ
Mysticial

@Master อย่างเป็นทางการ @Luchian Grigore หนึ่งในเพื่อนของฉันบอกฉันว่าIntel core i3พีซีของเขาที่ทำงานอยู่Ubuntu 11.04 i386แสดงให้เห็นถึงประสิทธิภาพการทำงานแบบเดียวกันกับgcc 4.6และเช่นเดียวกันสำหรับคอมพิวเตอร์ของฉันที่Intel Core 2 Duoมีmingw gcc4.4ที่ทำงานอยู่windows 7(32)มันแสดงความแตกต่างอย่างมากเมื่อ ฉันคอมไพล์เซ็กเมนต์นี้ด้วยพีซีที่เก่ากว่าintel centrinoด้วยgcc 4.6ที่ทำงานubuntu 12.04 i386ผู้ที่ทำงานอยู่บน
Hongxu Chen

นอกจากนี้โปรดทราบว่าการเข้าถึงหน่วยความจำโดยที่ที่อยู่แตกต่างกันตามจำนวนของ 4096 นั้นขึ้นอยู่กับการใช้งาน CPU ของ Intel SnB ตระกูล (เช่นเดียวกันชดเชยภายในหน้า) สิ่งนี้สามารถลดปริมาณงานเมื่อการดำเนินการบางอย่างเป็นร้านค้าโดยเฉพาะ การผสมผสานของโหลดและร้านค้า
Peter Cordes

which goes in set 24คุณหมายถึง "in set 28 " หรือไม่ และคุณคิดว่า 32 ชุดไหม?
Ruslan

คุณถูกต้องมันคือ 28. :) ฉันยังตรวจสอบกระดาษที่เชื่อมโยงอีกสองครั้งสำหรับคำอธิบายดั้งเดิมคุณสามารถไปที่ 9.2 Cache Organization
Luchian Grigore

78

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

โดยทั่วไปอัลกอริทึมของคุณจะ:

for (int i = 0; i < N; i++) 
   for (int j = 0; j < N; j++) 
        A[j][i] = A[i][j];

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

เราทำได้ดีกว่านั้นได้ไหม ใช่เราทำได้: วิธีการทั่วไปในการแก้ไขปัญหานี้คืออัลกอริทึมลบเลือนของแคชที่ชื่อบอกว่าหลีกเลี่ยงการพึ่งพาขนาดแคชเฉพาะ [1]

วิธีแก้ปัญหาจะมีลักษณะเช่นนี้:

void recursiveTranspose(int i0, int i1, int j0, int j1) {
    int di = i1 - i0, dj = j1 - j0;
    const int LEAFSIZE = 32; // well ok caching still affects this one here
    if (di >= dj && di > LEAFSIZE) {
        int im = (i0 + i1) / 2;
        recursiveTranspose(i0, im, j0, j1);
        recursiveTranspose(im, i1, j0, j1);
    } else if (dj > LEAFSIZE) {
        int jm = (j0 + j1) / 2;
        recursiveTranspose(i0, i1, j0, jm);
        recursiveTranspose(i0, i1, jm, j1);
    } else {
    for (int i = i0; i < i1; i++ )
        for (int j = j0; j < j1; j++ )
            mat[j][i] = mat[i][j];
    }
}

ซับซ้อนขึ้นเล็กน้อย แต่การทดสอบสั้น ๆ แสดงให้เห็นถึงสิ่งที่ค่อนข้างน่าสนใจสำหรับ e8400 โบราณของฉันด้วยการเปิดตัว VS2010 x64 รหัสการทดสอบสำหรับ MATSIZE 8192

int main() {
    LARGE_INTEGER start, end, freq;
    QueryPerformanceFrequency(&freq);
    QueryPerformanceCounter(&start);
    recursiveTranspose(0, MATSIZE, 0, MATSIZE);
    QueryPerformanceCounter(&end);
    printf("recursive: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));

    QueryPerformanceCounter(&start);
    transpose();
    QueryPerformanceCounter(&end);
    printf("iterative: %.2fms\n", (end.QuadPart - start.QuadPart) / (double(freq.QuadPart) / 1000));
    return 0;
}

results: 
recursive: 480.58ms
iterative: 3678.46ms

แก้ไข: เกี่ยวกับอิทธิพลของขนาด: มันมีความเด่นชัดน้อยกว่าแม้ว่าจะยังคงเห็นได้ชัดเจนในระดับหนึ่งนั่นเป็นเพราะเราใช้โซลูชันวนซ้ำเป็นโหนดปลายใบไม้แทนการเรียกซ้ำถึง 1 (การปรับให้เหมาะสมตามปกติสำหรับอัลกอริทึมแบบเรียกซ้ำ) หากเราตั้งค่า LEAFSIZE = 1 แคชจะไม่มีผลกับฉัน [ 8193: 1214.06; 8192: 1171.62ms, 8191: 1351.07ms- นั่นคือภายในขอบของข้อผิดพลาดความผันผวนอยู่ในพื้นที่ 100ms; "เบนช์มาร์ก" นี้ไม่ใช่สิ่งที่ฉันจะสบายเกินไปถ้าเราต้องการค่าที่แม่นยำอย่างสมบูรณ์])

[1] แหล่งข้อมูลสำหรับสิ่งนี้: ถ้าคุณไม่สามารถบรรยายจากคนที่ทำงานร่วมกับ Leiserson และร่วมในเรื่องนี้ .. ฉันถือว่าเอกสารของพวกเขาเป็นจุดเริ่มต้นที่ดี อัลกอริธึมเหล่านั้นยังไม่ค่อยอธิบาย - CLR มีเชิงอรรถเดียวเกี่ยวกับพวกเขา ยังคงเป็นวิธีที่ดีในการทำให้คนแปลกใจ


แก้ไข (หมายเหตุ: ฉันไม่ใช่คนที่โพสต์คำตอบนี้ฉันแค่อยากจะเพิ่ม):
นี่คือรหัสด้านบนทั้งหมดของ C ++:

template<class InIt, class OutIt>
void transpose(InIt const input, OutIt const output,
    size_t const rows, size_t const columns,
    size_t const r1 = 0, size_t const c1 = 0,
    size_t r2 = ~(size_t) 0, size_t c2 = ~(size_t) 0,
    size_t const leaf = 0x20)
{
    if (!~c2) { c2 = columns - c1; }
    if (!~r2) { r2 = rows - r1; }
    size_t const di = r2 - r1, dj = c2 - c1;
    if (di >= dj && di > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, (r1 + r2) / 2, c2);
        transpose(input, output, rows, columns, (r1 + r2) / 2, c1, r2, c2);
    }
    else if (dj > leaf)
    {
        transpose(input, output, rows, columns, r1, c1, r2, (c1 + c2) / 2);
        transpose(input, output, rows, columns, r1, (c1 + c2) / 2, r2, c2);
    }
    else
    {
        for (ptrdiff_t i1 = (ptrdiff_t) r1, i2 = (ptrdiff_t) (i1 * columns);
            i1 < (ptrdiff_t) r2; ++i1, i2 += (ptrdiff_t) columns)
        {
            for (ptrdiff_t j1 = (ptrdiff_t) c1, j2 = (ptrdiff_t) (j1 * rows);
                j1 < (ptrdiff_t) c2; ++j1, j2 += (ptrdiff_t) rows)
            {
                output[j2 + i1] = input[i2 + j1];
            }
        }
    }
}

2
สิ่งนี้จะเกี่ยวข้องถ้าคุณเปรียบเทียบเวลาระหว่างเมทริกซ์ที่มีขนาดต่างกันไม่ใช่แบบเรียกซ้ำและซ้ำ ลองใช้โซลูชันแบบเรียกซ้ำในเมทริกซ์ตามขนาดที่ระบุ
Luchian Grigore

@ Luchian เนื่องจากคุณอธิบายแล้วว่าทำไมเขาถึงเห็นพฤติกรรมฉันคิดว่ามันน่าสนใจทีเดียวที่จะแนะนำวิธีแก้ปัญหานี้ให้กับคนทั่วไป
Voo

เพราะฉันถามว่าทำไมเมทริกซ์ที่ใหญ่กว่าใช้เวลาในการประมวลผลที่สั้นกว่าไม่มองหาอัลกอริธึมที่เร็วกว่านี้ ...
Luchian Grigore

@ Luchian ความแตกต่างระหว่าง 16383 และ 16384 คือ .. 28 vs 27ms สำหรับฉันที่นี่หรือประมาณ 3.5% - ไม่สำคัญจริงๆ และฉันจะแปลกใจถ้ามันเป็น
Voo

3
มันอาจเป็นเรื่องที่น่าสนใจที่จะอธิบายrecursiveTransposeว่ามันทำอะไรได้บ้างนั่นก็คือมันไม่ได้เติมแคชมากนักโดยการทำงานบนไทล์ขนาดเล็ก ( LEAFSIZE x LEAFSIZEขนาด)
Matthieu M.

60

จากภาพประกอบเพื่ออธิบายในคำตอบของ Luchian Grigore ต่อไปนี้เป็นสิ่งที่การปรากฏตัวของแคชเมทริกซ์สำหรับสองกรณีของเมทริกซ์ 64x64 และ 65x65 (ดูลิงก์ด้านบนเพื่อดูรายละเอียดเกี่ยวกับตัวเลข)

สีในภาพเคลื่อนไหวด้านล่างหมายถึงสิ่งต่อไปนี้:

  • ขาว - ไม่ได้อยู่ในแคช
  • สีเขียวอ่อน - ในแคช
  • เขียวชอุ่ม - แคชโดน
  • ส้ม - แค่อ่านจาก RAM
  • สีแดง - แคชพลาด

กรณี 64x64:

ภาพเคลื่อนไหวการแสดงตนของแคชสำหรับเมทริกซ์ 64x64

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

ภาพเคลื่อนไหวการแสดงตนแคชสำหรับ 65x65 เมทริกซ์

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


รหัสที่เฟรมที่สร้างขึ้นสำหรับภาพเคลื่อนไหวดังกล่าวข้างต้นจะเห็นได้ที่นี่


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

ฉันเห็นได้จากคำตอบของ @ LuchianGrigore ว่าเป็นเพราะทุกบรรทัดในคอลัมน์อยู่ในเซตเดียวกัน
Josiah Yoder

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

@kelalaka ใช่ภาพเคลื่อนไหว FPS เหมือนกัน ฉันไม่ได้จำลองการชะลอตัวเฉพาะสีที่สำคัญที่นี่
Ruslan

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