Döngülerin sırası, bir 2D dizi üzerinden yineleme yaparken performansı neden etkiler?


361

Aşağıda, ive jdeğişkenlerini değiştirmem dışında neredeyse aynı olan iki program var . İkisi de farklı zaman aralıklarında koşarlar. Birisi bunun neden olduğunu açıklayabilir mi?

Versiyon 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; }
  }
}

Versiyon 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
Kıyaslama sonuçları ekleyebilir misiniz?
naught101


14
@ naught101 Karşılaştırma ölçütleri, 3 ila 10 kez arasında herhangi bir yerde bir performans farkı gösterecektir. Bu temel C / C ++, bunun nasıl çok oy aldığını tamamen şaşırttım ...
TC1

12
@ TC1: Bunun temel olduğunu düşünmüyorum; belki ara. Ancak, "temel" şeylerin daha fazla insan için faydalı olma eğiliminde olması şaşırtıcı olmamalı, dolayısıyla birçok upvotes. Ayrıca, bu "temel" olsa bile, google için zor bir sorudur.
LarsH

Yanıtlar:


595

Diğerleri söylediler, konu dizideki hafıza konumuna mağaza: x[i][j]. İşte size nedenlerle ilgili biraz bilgi:

2 boyutlu bir diziniz var, ancak bilgisayardaki bellek doğal olarak 1 boyutlu. Dizinizi böyle hayal ederken:

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

Bilgisayarınız bellekte tek bir satır olarak saklar:

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. örnekte, diziye önce 2. sayının üzerine döngü yaparak erişirsiniz, yani:

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

Yani hepsini sırayla vuruyorsunuz. Şimdi 1. versiyona bakın. Yapıyoruz:

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

C'nin bellekte 2 boyutlu diziyi yerleştirme biçimi nedeniyle, tüm yere atlamasını istiyorsunuz. Ama şimdi vurucu için: Bu neden önemli? Tüm bellek erişimleri aynı, değil mi?

Hayır: önbellekler yüzünden. Belleğinizdeki veriler CPU'ya küçük parçalar halinde ('önbellek satırları' olarak adlandırılır), genellikle 64 bayt olarak aktarılır. 4 baytlık tamsayılarınız varsa, bu, düzgün küçük bir pakette 16 ardışık tamsayı elde ettiğiniz anlamına gelir. Aslında bu bellek parçalarını almak oldukça yavaş; CPU'nuz tek bir önbellek hattının yüklenmesi için gereken sürede çok iş yapabilir.

Şimdi erişim sırasına tekrar bakın: İkinci örnek (1) 16 inçlik bir yığın kapmak, (2) hepsini değiştirerek, (3) 4000 * 4000/16 kez tekrarlayın. Bu güzel ve hızlı ve CPU'nun üzerinde çalışacağı bir şey var.

İlk örnek (1) 16 inçlik bir yığın kapmak, (2) bunlardan sadece birini değiştirmek, (3) 4000 * 4000 kez tekrarlamaktır. Bu, bellekten "getirme" sayısının 16 katını gerektirir. CPU'nuz aslında bu belleğin görünmesini beklemek için oturup vakit geçirmek zorunda kalacak ve bu sırada otururken değerli zamanınızı boşa harcıyorsunuz.

Önemli Not:

Şimdi cevabınız var, işte ilginç bir not: ikinci örneğinizin hızlı olması için doğal bir neden yok. Örneğin, Fortran'da, ilk örnek hızlı ve ikincisi yavaş olacaktır. Çünkü Fortran, şeyleri C'nin yaptığı gibi kavramsal "satırlara" genişletmek yerine "sütunlara" genişler, yani:

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 düzenine 'row-major' ve Fortran's düzenine 'column-major' denir. Gördüğünüz gibi, programlama dilinizin satır-büyük veya sütun-büyük olduğunu bilmek çok önemlidir! Daha fazla bilgi için bir bağlantı: http://en.wikipedia.org/wiki/Row-major_order


14
Bu oldukça kapsamlı bir cevap; önbellek isimleri ve bellek yönetimi ile uğraşırken bana öğretilen şey buydu.
Makoto

7
Yanlış şekilde "ilk" ve "ikinci" sürümlere sahipsiniz; ilk örnek , iç döngüdeki ilk dizini değiştirir ve daha yavaş çalışan örnek olur.
caf

Mükemmel cevap. Mark böyle bir cesur cesur hakkında daha fazla okumak istiyorsa, Büyük Kod Yaz gibi bir kitap tavsiye ederim.
wkl

8
C'nin satır sırasını Fortran'dan değiştirdiğine işaret eden bonus puanlar. Bilimsel hesaplama için L2 önbellek boyutu her şeydir, çünkü tüm dizileriniz L2'ye sığarsa hesaplama ana belleğe gitmeden tamamlanabilir.
Mart'ta Michael Shopsin

4
@birryree: Her Programcının Bellek Hakkında Bilmesi Gerekenler serbestçe de okunabilir.
caf


23

Sürüm 2, bilgisayarınızın önbelleğini sürüm 1'den daha iyi kullandığından çok daha hızlı çalışacaktır. Bunu düşünürseniz, diziler yalnızca bitişik bellek alanlarıdır. Bir dizideki bir öğe istediğinizde, işletim sisteminiz büyük olasılıkla o öğeyi içeren önbelleğe bir bellek sayfası getirir. Ancak, sonraki birkaç öğe de bu sayfada olduğundan (bitişik oldukları için), bir sonraki erişim zaten önbellekte olacak! Sürüm 2, hızını artırmak için yapıyor.

Sürüm 1 ise öte yandan satır bazında değil, eleman sütununa erişiyor. Bu tür bir erişim bellek düzeyinde bitişik değildir, bu nedenle program OS önbelleklemesinden fazla yararlanamaz.


Bu dizi boyutları ile, muhtemelen işletim sistemi yerine CPU'daki önbellek yöneticisi burada sorumludur.
krlmlr

12

Nedeni önbellek-yerel veri erişimidir. İkinci programda, önbellekleme ve ön getirmeden faydalanan bellek yoluyla doğrusal olarak tararsınız. İlk programınızın bellek kullanım modeli çok daha yayılmıştır ve bu nedenle önbellek davranışı daha kötüdür.


11

Önbellek isabetlerinde diğer mükemmel cevapların yanı sıra, olası bir optimizasyon farkı da var. İkinci döngünüzün derleyici tarafından aşağıdakilere eşdeğer bir şekilde optimize edilmesi muhtemeldir:

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

Bu, ilk döngü için daha az olasıdır, çünkü "p" işaretçisini her seferinde 4000 ile arttırması gerekir.

EDIT: p++ ve hatta *p++ = ..çoğu CPU'larda tek bir CPU komutuna derlenebilir. *p = ..; p += 4000Bu nedenle, optimizasyonda daha az fayda vardır. Ayrıca daha zordur, çünkü derleyicinin iç dizinin boyutunu bilmesi ve kullanması gerekir. Ve genellikle normal koddaki iç döngüde (sadece son dizinin döngüde sabit tutulduğu ve ikinciden sonuncunun adımlandığı çok boyutlu dizilerde gerçekleşir), bu nedenle optimizasyon daha az önceliklidir .


"P" işaretçisini her seferinde 4000 ile atlaması gerekeceği için "ne demek istemiyorum.
Veedrac

@Veedrac İşaretçinin, iç döngü içinde 4000 ile artırılması gerekir: p += 4000isop++
fishin8

Derleyici neden bir sorun bulur? ibir işaretçi artışı olduğu için birim olmayan bir değerle zaten artırılmıştır.
Veedrac

Daha fazla açıklama ekledim
fishinMar

Yazmayı deneme int *f(int *p) { *p++ = 10; return p; } int *g(int *p) { *p = 10; p += 4000; return p; }içine gcc.godbolt.org . İkisi temelde aynı derlenmiş gibi görünüyor.
Veedrac

7

Bu satır suçlu:

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

İkinci versiyonda sürekli bellek kullanılır, bu nedenle önemli ölçüde daha hızlı olacaktır.

İle denedim

x[50000][50000];

ve yürütme süresi sürüm1 için 13s iken sürüm2 için 0.6s'dir.


4

Genel bir cevap vermeye çalışıyorum.

Çünkü C i[y][x]için bir kestirme *(i + y*array_width + x)(klas deneyin int P[3]; 0[P] = 0xBEEF;).

Eğer iterate üzerinde itibariyle ybüyüklüğü, sen yinelerler üzerinde parçalar array_width * sizeof(array_element). Eğer iç döngünüzde varsa, array_width * array_heighto parçalar üzerinde yinelemeler olacaktır .

Düzeni çevirerek, yalnızca array_heightyığın yinelemelere sahip olacaksınız ve herhangi bir yığın array_widthyinelemesi arasında yalnızca yinelemelere sahip olacaksınız sizeof(array_element).

Gerçekten eski x86-CPU'larda bu çok önemli değildi, bugünlerde x86 çok fazla veri önbellekleme ve önbellekleme yapıyor. Muhtemelen yavaş yineleme sırasınızda birçok önbellek özlüyor olursunuz.

Sitemizi kullandığınızda şunları okuyup anladığınızı kabul etmiş olursunuz: Çerez Politikası ve Gizlilik Politikası.
Licensed under cc by-sa 3.0 with attribution required.