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


360

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

รุ่น 1

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (i = 0; i < 4000; i++) {
    for (j = 0; j < 4000; j++) {
      x[j][i] = i + j; }
  }
}

เวอร์ชัน 2

#include <stdio.h>
#include <stdlib.h>

main () {
  int i,j;
  static int x[4000][4000];
  for (j = 0; j < 4000; j++) {
     for (i = 0; i < 4000; i++) {
       x[j][i] = i + j; }
   }
}


7
คุณสามารถเพิ่มผลลัพธ์มาตรฐานได้ไหม?
naught101

3
ที่เกี่ยวข้อง: stackoverflow.com/questions/9888154/ …
Thomas Padron-McCarthy

14
@ naught101 มาตรฐานจะแสดงความแตกต่างของประสิทธิภาพระหว่าง 3 ถึง 10 เท่า นี่คือ C / C ++ ขั้นพื้นฐานฉันนิ่งงันอย่างสมบูรณ์ว่าวิธีนี้ได้รับการโหวตมากมาย ...
TC1

12
@ TC1: ฉันไม่คิดว่ามันพื้นฐาน อาจจะอยู่ตรงกลาง แต่ก็ไม่น่าแปลกใจที่สิ่งที่ "พื้นฐาน" มีแนวโน้มที่จะเป็นประโยชน์กับผู้คนจำนวนมาก ยิ่งกว่านั้นนี่เป็นคำถามที่ยากสำหรับ Google แม้ว่าจะเป็น "พื้นฐาน" ก็ตาม
LarsH

คำตอบ:


595

ขณะที่คนอื่น ๆ x[i][j]ได้กล่าวว่าปัญหาคือการจัดเก็บไปยังตำแหน่งที่หน่วยความจำในอาร์เรย์: นี่เป็นข้อมูลเชิงลึกว่าทำไม:

คุณมีอาเรย์ 2 มิติ แต่หน่วยความจำในคอมพิวเตอร์นั้นมี 1 มิติโดยกำเนิด ดังนั้นในขณะที่คุณจินตนาการอาเรย์ของคุณเช่นนี้:

0,0 | 0,1 | 0,2 | 0,3
----+-----+-----+----
1,0 | 1,1 | 1,2 | 1,3
----+-----+-----+----
2,0 | 2,1 | 2,2 | 2,3

คอมพิวเตอร์ของคุณเก็บไว้ในหน่วยความจำในบรรทัดเดียว:

0,0 | 0,1 | 0,2 | 0,3 | 1,0 | 1,1 | 1,2 | 1,3 | 2,0 | 2,1 | 2,2 | 2,3

ในตัวอย่างที่ 2 คุณสามารถเข้าถึงอาร์เรย์ได้โดยวนลูปที่ 2 ก่อนเช่น:

x[0][0] 
        x[0][1]
                x[0][2]
                        x[0][3]
                                x[1][0] etc...

หมายความว่าคุณกำลังตีพวกเขาทั้งหมดตามลำดับ ตอนนี้ดูรุ่นที่ 1 คุณกำลังทำ:

x[0][0]
                                x[1][0]
                                                                x[2][0]
        x[0][1]
                                        x[1][1] etc...

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

ไม่: เพราะแคช ข้อมูลจากหน่วยความจำของคุณจะถูกส่งไปยัง CPU ด้วยชิ้นส่วนเล็ก ๆ (เรียกว่า 'แคชไลน์') โดยทั่วไปแล้ว 64 ไบต์ หากคุณมีจำนวนเต็ม 4 ไบต์นั่นหมายความว่าคุณได้จำนวนเต็มติดต่อกัน 16 ครั้งในชุดเล็ก ๆ ที่เรียบร้อย จริงๆแล้วมันค่อนข้างช้าในการดึงชิ้นส่วนของหน่วยความจำเหล่านี้ CPU ของคุณสามารถทำงานได้มากในเวลาที่โหลดแคชบรรทัดเดียว

ตอนนี้มองย้อนกลับไปที่คำสั่งของการเข้าถึง: ตัวอย่างที่สองคือ (1) คว้าอัน 16 ตัว (2) แก้ไขทั้งหมดของพวกเขา (3) ทำซ้ำ 4000 * 4000/16 ครั้ง มันดีและรวดเร็วและ CPU มักจะมีบางอย่างที่ต้องทำ

ตัวอย่างแรกคือ (1) คว้าอัน 16 ตัว (2) แก้ไขเพียงอันเดียวเท่านั้น (3) ทำซ้ำ 4000 * 4000 ครั้ง นั่นจะต้องใช้จำนวน 16 ครั้งของ "fetches" จากหน่วยความจำ CPU ของคุณจะต้องใช้เวลานั่งรอรอให้หน่วยความจำปรากฏขึ้นและในขณะที่กำลังนั่งอยู่คุณจะเสียเวลาอันมีค่าไป

โน๊ตสำคัญ:

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

0,0 | 1,0 | 2,0 | 0,1 | 1,1 | 2,1 | 0,2 | 1,2 | 2,2 | 0,3 | 1,3 | 2,3

เลย์เอาต์ของ C เรียกว่า 'row-major' และ Fortran เรียกว่า 'column-major' อย่างที่คุณเห็นสิ่งสำคัญคือต้องรู้ว่าภาษาการเขียนโปรแกรมของคุณเป็นแถวหลักหรือคอลัมน์ใหญ่! นี่คือลิงค์สำหรับข้อมูลเพิ่มเติม: http://en.wikipedia.org/wiki/Row-major_order


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

7
คุณมีเวอร์ชัน "แรก" และ "สอง" ในทางที่ผิด ตัวอย่างแรกแตกต่างกันไปดัชนีแรกในวงด้านในและจะเป็นตัวอย่างการดำเนินการช้าลง
caf

คำตอบที่ดี หาก Mark ต้องการอ่านเพิ่มเติมเกี่ยวกับ nitty gritty เช่นนี้ฉันอยากจะแนะนำหนังสือเช่น Write Great Code
wkl

8
คะแนนโบนัสสำหรับการชี้ให้เห็นว่า C เปลี่ยนลำดับแถวจาก Fortran สำหรับการคำนวณทางวิทยาศาสตร์ขนาดแคช L2 นั้นเป็นทุกอย่างเพราะถ้าอาร์เรย์ทั้งหมดของคุณพอดีกับ L2 การคำนวณสามารถทำได้โดยไม่ต้องไปที่หน่วยความจำหลัก
Michael Shopsin

4
@ Birryree: สิ่งที่โปรแกรมเมอร์ทุกคนควรรู้เกี่ยวกับหน่วยความจำก็เป็นสิ่งที่อ่านได้ดีเช่นกัน
caf

68

ไม่มีอะไรเกี่ยวข้องกับการชุมนุม นี่คือสาเหตุที่แคช

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

ดูเพิ่มเติม: http://en.wikipedia.org/wiki/Loop_interchange


23

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

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


ด้วยขนาดอาร์เรย์เหล่านี้อาจเป็นตัวจัดการแคชใน CPU แทนที่จะเป็นระบบปฏิบัติการที่รับผิดชอบที่นี่
krlmlr

12

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


11

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

  for (j=0; j<4000; j++) {
    int *p = x[j];
    for (i=0; i<4000; i++) {
      *p++ = i+j;
    }
  }

นี่เป็นโอกาสที่น้อยกว่าสำหรับลูปแรกเนื่องจากจะต้องเพิ่มพอยน์เตอร์ "p" ด้วย 4000 ในแต่ละครั้ง

แก้ไข: p++และแม้กระทั่ง*p++ = ..สามารถรวบรวมเป็นคำสั่ง CPU เดียวใน CPU ส่วนใหญ่ *p = ..; p += 4000ไม่สามารถทำได้ดังนั้นจึงมีประโยชน์น้อยกว่าในการปรับให้เหมาะสม นอกจากนี้ยังยากกว่าเนื่องจากคอมไพเลอร์จำเป็นต้องรู้และใช้ขนาดของอาร์เรย์ภายใน และจะไม่เกิดขึ้นที่มักจะอยู่ในวงในในรหัสปกติ (มันเกิดขึ้นเฉพาะสำหรับอาร์เรย์หลายมิติที่ดัชนีสุดท้ายจะถูกเก็บไว้อย่างต่อเนื่องในวงและครั้งที่สองถึงครั้งสุดท้ายถูกก้าว) ดังนั้นการเพิ่มประสิทธิภาพจึงมีความสำคัญน้อย .


ฉันไม่ได้รับสิ่งที่ 'เพราะมันจะต้องกระโดดตัวชี้ "p" กับ 4000 แต่ละครั้ง' หมายถึง
Veedrac

@Veedrac ตัวชี้จะต้องเพิ่มขึ้นด้วย 4,000 ภายในวงด้านใน: p += 4000isop++
fishinear

ทำไมผู้แปลถึงพบว่ามีปัญหา? iมีการเพิ่มขึ้นแล้วโดยค่าที่ไม่ใช่หน่วยเนื่องจากเป็นตัวชี้ที่เพิ่มขึ้น
Veedrac

ฉันได้เพิ่มคำอธิบายเพิ่มเติม
fishinear

ลองพิมพ์int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }ลงในgcc.godbolt.org ทั้งสองดูเหมือนจะรวบรวมโดยทั่วไปเหมือนกัน
Veedrac

7

บรรทัดนี้ผู้กระทำผิด:

x[j][i]=i+j;

รุ่นที่สองใช้หน่วยความจำอย่างต่อเนื่องดังนั้นจะเร็วขึ้นอย่างมีนัยสำคัญ

ฉันลองด้วย

x[50000][50000];

และเวลาของการดำเนินการคือ 13 วินาทีสำหรับ version1 กับ 0.6s สำหรับ version2


4

ฉันพยายามให้คำตอบทั่วไป

เพราะi[y][x]เป็นชวเลขสำหรับ*(i + y*array_width + x)ใน C (ลองคลาสซี่int P[3]; 0[P] = 0xBEEF;)

ในขณะที่คุณย้ำกว่าคุณย้ำกว่าชิ้นที่มีขนาดy array_width * sizeof(array_element)หากคุณมีสิ่งนั้นในวงในของคุณจากนั้นคุณจะมีarray_width * array_heightการทำซ้ำมากกว่าชิ้นเหล่านั้น

โดยการพลิกเพื่อที่คุณจะมีเพียงarray_heightก้อน-ซ้ำและระหว่างก้อนซ้ำคุณจะมีการทำซ้ำเพียงarray_widthsizeof(array_element)

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

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