결합 루프보다 개별 루프에서 요소 별 추가가 더 빠른 이유는 무엇입니까?


2246

가정 a1, b1, c1,와 d1힙 메모리 내 숫자 코드 포인트는 다음과 같은 핵심 루프를 가지고있다.

const int n = 100000;

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
    c1[j] += d1[j];
}

이 루프는 다른 외부 for루프 를 통해 10,000 번 실행 됩니다. 속도를 높이기 위해 코드를 다음과 같이 변경했습니다.

for (int j = 0; j < n; j++) {
    a1[j] += b1[j];
}

for (int j = 0; j < n; j++) {
    c1[j] += d1[j];
}

Intel Core 2 Duo (x64)에서 32 비트에 대해 SSE2가 활성화 되고 완전 최적화 된 MS Visual C ++ 10.0 에서 컴파일 된 첫 번째 예제는 5.5 초, 이중 루프 예제는 1.9 초입니다. 내 질문은 : (하단에 내 대답을 참조하십시오)

추신 : 이것이 도움이되는지 확실하지 않습니다.

첫 번째 루프의 디스 어셈블리는 기본적으로 다음과 같습니다 (이 블록은 전체 프로그램에서 약 5 번 반복됨).

movsd       xmm0,mmword ptr [edx+18h]
addsd       xmm0,mmword ptr [ecx+20h]
movsd       mmword ptr [ecx+20h],xmm0
movsd       xmm0,mmword ptr [esi+10h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [edx+20h]
addsd       xmm0,mmword ptr [ecx+28h]
movsd       mmword ptr [ecx+28h],xmm0
movsd       xmm0,mmword ptr [esi+18h]
addsd       xmm0,mmword ptr [eax+38h]

이중 루프 예제의 각 루프는이 코드를 생성합니다 (다음 블록은 약 3 번 반복됨).

addsd       xmm0,mmword ptr [eax+28h]
movsd       mmword ptr [eax+28h],xmm0
movsd       xmm0,mmword ptr [ecx+20h]
addsd       xmm0,mmword ptr [eax+30h]
movsd       mmword ptr [eax+30h],xmm0
movsd       xmm0,mmword ptr [ecx+28h]
addsd       xmm0,mmword ptr [eax+38h]
movsd       mmword ptr [eax+38h],xmm0
movsd       xmm0,mmword ptr [ecx+30h]
addsd       xmm0,mmword ptr [eax+40h]
movsd       mmword ptr [eax+40h],xmm0

이 문제는 어레이의 크기 (n)와 CPU 캐시에 따라 크게 달라 지므로 관련성이없는 것으로 판명되었습니다. 따라서 더 관심이 있다면 질문을 다시 표현하십시오.

다음 그래프의 5 개 영역에서 설명하는 것처럼 다른 캐시 동작을 유발하는 세부 사항에 대한 통찰력을 제공 할 수 있습니까?

이러한 CPU에 대해 유사한 그래프를 제공하여 CPU / 캐시 아키텍처의 차이점을 지적하는 것도 흥미로울 수 있습니다.

PPS : 전체 코드는 다음과 같습니다. 고해상도 타이밍을 위해 TBB Tick_Count 를 사용하며 , TBB_TIMING매크로를 정의하지 않으면 비활성화 할 수 있습니다 .

#include <iostream>
#include <iomanip>
#include <cmath>
#include <string>

//#define TBB_TIMING

#ifdef TBB_TIMING   
#include <tbb/tick_count.h>
using tbb::tick_count;
#else
#include <time.h>
#endif

using namespace std;

//#define preallocate_memory new_cont

enum { new_cont, new_sep };

double *a1, *b1, *c1, *d1;


void allo(int cont, int n)
{
    switch(cont) {
      case new_cont:
        a1 = new double[n*4];
        b1 = a1 + n;
        c1 = b1 + n;
        d1 = c1 + n;
        break;
      case new_sep:
        a1 = new double[n];
        b1 = new double[n];
        c1 = new double[n];
        d1 = new double[n];
        break;
    }

    for (int i = 0; i < n; i++) {
        a1[i] = 1.0;
        d1[i] = 1.0;
        c1[i] = 1.0;
        b1[i] = 1.0;
    }
}

void ff(int cont)
{
    switch(cont){
      case new_sep:
        delete[] b1;
        delete[] c1;
        delete[] d1;
      case new_cont:
        delete[] a1;
    }
}

double plain(int n, int m, int cont, int loops)
{
#ifndef preallocate_memory
    allo(cont,n);
#endif

#ifdef TBB_TIMING   
    tick_count t0 = tick_count::now();
#else
    clock_t start = clock();
#endif

    if (loops == 1) {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++){
                a1[j] += b1[j];
                c1[j] += d1[j];
            }
        }
    } else {
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                a1[j] += b1[j];
            }
            for (int j = 0; j < n; j++) {
                c1[j] += d1[j];
            }
        }
    }
    double ret;

#ifdef TBB_TIMING   
    tick_count t1 = tick_count::now();
    ret = 2.0*double(n)*double(m)/(t1-t0).seconds();
#else
    clock_t end = clock();
    ret = 2.0*double(n)*double(m)/(double)(end - start) *double(CLOCKS_PER_SEC);
#endif

#ifndef preallocate_memory
    ff(cont);
#endif

    return ret;
}


void main()
{   
    freopen("C:\\test.csv", "w", stdout);

    char *s = " ";

    string na[2] ={"new_cont", "new_sep"};

    cout << "n";

    for (int j = 0; j < 2; j++)
        for (int i = 1; i <= 2; i++)
#ifdef preallocate_memory
            cout << s << i << "_loops_" << na[preallocate_memory];
#else
            cout << s << i << "_loops_" << na[j];
#endif

    cout << endl;

    long long nmax = 1000000;

#ifdef preallocate_memory
    allo(preallocate_memory, nmax);
#endif

    for (long long n = 1L; n < nmax; n = max(n+1, long long(n*1.2)))
    {
        const long long m = 10000000/n;
        cout << n;

        for (int j = 0; j < 2; j++)
            for (int i = 1; i <= 2; i++)
                cout << s << plain(n, m, j, i);
        cout << endl;
    }
}

(의 다른 값에 대한 FLOP / s를 보여줍니다 n.)

여기에 이미지 설명을 입력하십시오


4
운영 체제는 물리적 메모리에 액세스 할 때마다 물리적 메모리를 검색하는 동안 속도가 느려지고 동일한 memblock에 대한 보조 액세스의 경우 캐시와 같은 것을 가질 수 있습니다.
AlexTheo

7
최적화로 컴파일하고 있습니까? O2에 대한 ASM 코드의 많은처럼 그 모습 ...
Luchian 고르

1
나는 얼마 전에 비슷한 질문으로 보이는 것을 물었다 . 그 또는 그 대답에 관심있는 정보가있을 수 있습니다.
Mark Wilkins

61
이 두 코드 스 니펫은 까다롭기 때문에 포인터가 겹칠 수 있기 때문에 동일하지 않습니다. C99에는 restrict이러한 상황에 대한 키워드가 있습니다. MSVC에 비슷한 것이 있는지 모르겠습니다. 물론 이것이 문제라면 SSE 코드가 올바르지 않을 것입니다.
user510306

8
메모리 별칭과 관련이있을 수 있습니다. 하나의 루프를 사용 d1[j]하면와 별명을 지정할 수 a1[j]있으므로 컴파일러가 일부 메모리 최적화를 취소 할 수 있습니다. 쓰기를 두 개의 루프로 메모리에 분리하면 발생하지 않습니다.
rturrado

답변:


1690

이것에 대한 추가 분석에 따르면, 이것은 4 포인터의 데이터 정렬에 의해 (적어도 부분적으로) 발생한다고 생각합니다. 이로 인해 일정 수준의 캐시 뱅크 / 웨이 충돌이 발생합니다.

배열을 할당하는 방법에 대해 올바르게 짐작했다면 페이지 행에 정렬 될 수 있습니다 .

즉, 각 루프의 모든 액세스는 동일한 캐시 방식에 속합니다. 그러나 인텔 프로세서는 8-way L1 캐시 연관성을 가지고있었습니다. 그러나 실제로는 성능이 완전히 균일하지 않습니다. 4-way에 액세스하는 것은 여전히 ​​2-way보다 느립니다.

편집 : 실제로 모든 배열을 개별적으로 할당하는 것처럼 보입니다. 일반적으로 이러한 큰 할당이 요청되면 할당자는 OS에서 새로운 페이지를 요청합니다. 따라서 큰 할당이 페이지 경계와 동일한 오프셋에 나타날 가능성이 높습니다.

테스트 코드는 다음과 같습니다.

int main(){
    const int n = 100000;

#ifdef ALLOCATE_SEPERATE
    double *a1 = (double*)malloc(n * sizeof(double));
    double *b1 = (double*)malloc(n * sizeof(double));
    double *c1 = (double*)malloc(n * sizeof(double));
    double *d1 = (double*)malloc(n * sizeof(double));
#else
    double *a1 = (double*)malloc(n * sizeof(double) * 4);
    double *b1 = a1 + n;
    double *c1 = b1 + n;
    double *d1 = c1 + n;
#endif

    //  Zero the data to prevent any chance of denormals.
    memset(a1,0,n * sizeof(double));
    memset(b1,0,n * sizeof(double));
    memset(c1,0,n * sizeof(double));
    memset(d1,0,n * sizeof(double));

    //  Print the addresses
    cout << a1 << endl;
    cout << b1 << endl;
    cout << c1 << endl;
    cout << d1 << endl;

    clock_t start = clock();

    int c = 0;
    while (c++ < 10000){

#if ONE_LOOP
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
            c1[j] += d1[j];
        }
#else
        for(int j=0;j<n;j++){
            a1[j] += b1[j];
        }
        for(int j=0;j<n;j++){
            c1[j] += d1[j];
        }
#endif

    }

    clock_t end = clock();
    cout << "seconds = " << (double)(end - start) / CLOCKS_PER_SEC << endl;

    system("pause");
    return 0;
}

벤치 마크 결과 :

편집 : 실제 Core 2 아키텍처 시스템의 결과 :

Intel Xeon X5482 Harpertown @ 3.2GHz 2 개 :

#define ALLOCATE_SEPERATE
#define ONE_LOOP
00600020
006D0020
007A0020
00870020
seconds = 6.206

#define ALLOCATE_SEPERATE
//#define ONE_LOOP
005E0020
006B0020
00780020
00850020
seconds = 2.116

//#define ALLOCATE_SEPERATE
#define ONE_LOOP
00570020
00633520
006F6A20
007B9F20
seconds = 1.894

//#define ALLOCATE_SEPERATE
//#define ONE_LOOP
008C0020
00983520
00A46A20
00B09F20
seconds = 1.993

관찰 :

  • 6.206 초 한 루프와 2.116 초 두 개의 루프와. 이것은 OP의 결과를 정확하게 재현합니다.

  • 처음 두 테스트에서 배열은 별도로 할당됩니다. 그것들은 페이지에 대해 동일한 정렬을 가지고 있음을 알 수 있습니다.

  • 두 번째 테스트에서는 배열이 정렬되어 해당 정렬을 해제합니다. 여기서 두 루프가 더 빠릅니다. 또한 두 번째 (이중) 루프는 일반적으로 예상 한대로 느립니다.

@Stephen Cannon이 주석에서 지적한 것처럼,이 정렬로 인해 로드 / 저장 장치 또는 캐시에서 잘못된 앨리어싱 이 발생할 가능성이 큽니다 . 나는 이것을 둘러 본 결과 인텔이 실제로 부분 주소 앨리어싱 스톨에 대한 하드웨어 카운터를 가지고 있음을 발견했다 .

http://software.intel.com/sites/products/documentation/doclib/stdxe/2013/~amplifierxe/pmw_dp/events/partial_address_alias.html


5 개 지역-설명

지역 1 :

이것은 쉽다. 데이터 세트가 너무 작아서 루핑 및 분기와 같은 오버 헤드가 성능을 좌우합니다.

지역 2 :

여기서 데이터 크기가 증가하면 상대적 오버 헤드의 양이 줄어들고 성능이 "포화"됩니다. 여기에서 두 개의 루프는 두 배의 루프와 분기 오버 헤드를 갖기 때문에 더 느립니다.

정확히 무슨 일이 일어나고 있는지 잘 모르겠습니다 ... Agner Fog가 캐시 뱅크 충돌을 언급하면서 정렬은 여전히 ​​효과를 발휘할 수 있습니다 . (이 링크는 Sandy Bridge에 관한 것이지만 아이디어는 여전히 Core 2에 적용되어야합니다.)

지역 3 :

이 시점에서 데이터는 더 이상 L1 캐시에 맞지 않습니다. 따라서 성능은 L1 <-> L2 캐시 대역폭에 의해 제한됩니다.

지역 4 :

단일 루프의 성능 저하가 관찰하고 있습니다. 언급 한 바와 같이, 이것은 프로세서로드 / 스토어 유닛에서 가명 앨리어싱을 중단시키는 원인 일 가능성이 높습니다 .

그러나 잘못된 앨리어싱이 발생하려면 데이터 집합간에 보폭이 충분히 커야합니다. 이것이 지역 3에서 이것을 볼 수없는 이유입니다.

지역 5 :

이 시점에서 캐시에 맞는 것은 없습니다. 따라서 메모리 대역폭에 구속됩니다.


Intel X5482 Harpertown @ 3.2GHz 2 개 Intel Core i7 870 @ 2.8GHz 4.4GHz에서 Intel Core i7 2600K


162
+1 : 이것이 답이라고 생각합니다. 다른 모든 답변과 달리 캐시 미스가 더 많은 단일 루프 변형이 아니라 캐시 미스를 유발하는 배열의 특정 정렬에 관한 것입니다.
Oliver Charlesworth

30
이; 거짓 앨리어싱 스톨은 대부분 설명이다.
Stephen Canon

7
@VictorT. OP가 연결된 코드를 사용했습니다. Excel에서 열고 .css 파일을 생성하여 그래프를 만들 수 있습니다.
Mysticial

5
@Nawaz 페이지는 일반적으로 4KB입니다. 내가 출력 한 16 진수 주소를 보면, 별도로 할당 된 테스트는 모두 같은 모듈로 4096을 갖습니다. (4KB 경계의 시작에서 32 바이트) 아마도 GCC에는이 동작이 없을 것입니다. 차이점이 보이지 않는 이유를 설명 할 수 있습니다.
Mysticial


224

정답은 분명히 CPU 캐시와 관련이 있습니다. 그러나 캐시 인수를 사용하는 것은 특히 데이터가 없으면 매우 어려울 수 있습니다.

캐시에 대한 문제는 매우 복잡하고 일차원 적이 지 않을 수 있습니다. 그들은 데이터의 크기에 크게 의존하므로 내 질문은 불공평했습니다. 캐시 그래프에서 매우 흥미로운 점으로 밝혀졌습니다.

@Mysticial의 대답은 사실을 의지하는 유일한 사람 이었기 때문에 많은 사람들 (나를 포함하여)을 설득했지만 그것은 진실의 한 "데이터 포인트"일뿐입니다.

그래서 나는 그의 테스트 (연속 대 별도 할당 사용)와 @James 'Answer의 조언을 결합했습니다.

아래 그래프는 사용 된 정확한 시나리오와 매개 변수에 따라 대부분의 답변, 특히 질문과 답변에 대한 대부분의 의견이 완전히 잘못되거나 참으로 간주 될 수 있음을 보여줍니다.

내 초기 질문은 n = 100.000 입니다. 우연히이 시점은 특별한 행동을 나타냅니다.

  1. 그것은 하나와 두 개의 루프 버전 사이에 가장 큰 불일치를 가지고 있습니다 (거의 세 가지 요소)

  2. 이는 단일 루프 (즉, 연속 할당)가 2 루프 버전을 능가하는 유일한 지점입니다. (이로 인해 Mysticial의 대답이 가능해졌습니다.)

초기화 된 데이터를 사용한 결과 :

여기에 이미지 설명을 입력하십시오

초기화되지 않은 데이터를 사용한 결과 (Mysticial에서 테스트 한 결과) :

여기에 이미지 설명을 입력하십시오

그리고 이것은 설명하기 어려운 것입니다. 초기화 된 데이터. 한 번 할당되고 다른 벡터 크기의 다음 테스트 사례마다 재사용됩니다.

여기에 이미지 설명을 입력하십시오

신청

모든 캐시 관련 데이터 크기에 대한 MFLOPS 정보를 제공하려면 스택 오버플로에 대한 모든 저수준 성능 관련 질문이 필요합니다! 답을 생각하고 특히이 정보없이 다른 사람들과 토론하는 것은 모든 사람들의 시간 낭비입니다.


18
+1 멋진 분석. 처음에는 데이터를 초기화하지 않은 상태로 두려고하지 않았습니다. 어쨌든 할당자가 제로를 제로화 한 것입니다. 따라서 초기화 된 데이터가 중요합니다. 방금 실제 Core 2 아키텍처 시스템 에서 결과로 내 답변을 편집 했으며 관찰 한 내용에 훨씬 가깝습니다. 또 다른 점은 다양한 크기를 테스트했으며 n다음과 같은 성능 차이를 보여줍니다 n = 80000, n = 100000, n = 200000.
Mysticial

2
@Mysticial 나는 프로세스 간 스파이 가능성을 피하기 위해 프로세스에 새 페이지를 제공 할 때마다 OS가 페이지 제로화를 구현한다고 생각합니다.
v.oddou

1
@ v.oddou : 동작도 OS에 따라 다릅니다. IIRC, Windows에는 사용 가능한 빈 페이지를 백그라운드로 처리하는 스레드가 있으며 이미 0으로 된 페이지 VirtualAlloc에서 요청을 충족시킬 수없는 경우 요청을 충족시키기에 충분할 때까지 호출이 차단됩니다. 대조적으로, 리눅스는 제로 페이지를 필요할 때마다 복사 중 복사로 매핑하고, 새 데이터를 쓰기 전에 새로운 제로를 새로운 페이지로 복사합니다. 어느 쪽이든 사용자 모드 프로세스의 관점에서 페이지는 제로화되지만 초기화되지 않은 메모리를 처음 사용하는 것은 일반적으로 Windows보다 Linux에서 더 비쌉니다.
ShadowRanger

81

두 번째 루프는 캐시 작업이 훨씬 적으므로 프로세서가 메모리 요구를보다 쉽게 ​​처리 할 수 ​​있습니다.


1
두 번째 변형에서 캐시 누락이 더 적다는 말입니까? 왜?
Oliver Charlesworth

2
@Oli : 첫 번째 변형에서, 상기 프로세서는 시간 - 액세스에서 네 개의 메모리 라인 필요가 a[i], b[i], c[i]d[i]두번째 변형 예에서, 단지 두 필요하다. 따라서 추가하는 동안 해당 줄을 다시 채울 수 있습니다.
강아지

4
그러나 어레이가 캐시에서 충돌하지 않는 한 각 변형에는 주 메모리에서 정확히 동일한 수의 읽기 및 쓰기가 필요합니다. 결론은이 두 배열이 항상 충돌한다는 것입니다.
Oliver Charlesworth

3
나는 따르지 않는다. 명령어 당 (즉,의 인스턴스 당 x += y) 두 번의 읽기와 하나의 쓰기가 있습니다. 이는 두 가지 변형 모두에 해당됩니다. 따라서 캐시 <-> CPU 대역폭 요구 사항은 동일합니다. 충돌이없는 한, 캐시 <-> RAM 대역폭 요구 사항도 동일합니다.
Oliver Charlesworth

2
stackoverflow.com/a/1742231/102916 에서 언급했듯이 Pentium M의 하드웨어 프리 페치는 12 개의 서로 다른 순방향 스트림을 추적 할 수 있습니다. 루프 2는 여전히 4 개의 스트림 만 읽으므로 그 한계 내에 있습니다.
Brooks Moses

50

n한 번에 두 개의 어레이를 메모리에 담을 수만있는 올바른 값을 가진 머신에서 작업하고 있다고 가정 하지만 디스크 캐싱을 통해 사용 가능한 총 메모리는 여전히 4 개를 모두 보유하기에 충분하다고 상상해보십시오 .

간단한 LIFO 캐싱 정책을 가정하면 다음 코드는 다음과 같습니다.

for(int j=0;j<n;j++){
    a[j] += b[j];
}
for(int j=0;j<n;j++){
    c[j] += d[j];
}

겠습니까 첫째 원인 ab후 RAM에로드 할 수는 RAM에 완전히 작업 할. 두 번째 루프 시작, 때 c와는 d다음 RAM에 디스크에서로드에서 작동 할 것입니다.

다른 루프

for(int j=0;j<n;j++){
    a[j] += b[j];
    c[j] += d[j];
}

루프 주위에서 매번 두 배열을 페이징하고 다른 배열을 페이징 합니다 . 이것은 분명히 훨씬 느려질 것입니다.

테스트에서 디스크 캐싱을 볼 수 없지만 다른 캐싱 형식의 부작용을 볼 수 있습니다.


여기에 약간의 혼란 / 오해가있는 것처럼 보이므로 예제를 사용하여 조금 더 자세히 설명하려고합니다.

n = 2우리는 바이트 노력하고 있습니다. 내 시나리오에서 우리는 단지 4 바이트의 RAM 을 가지고 있으며 나머지 메모리는 상당히 느립니다 (예 : 100 배 더 긴 액세스).

바이트가 캐시에없는 경우 상당히 멍청한 캐싱 정책을 가정하면 거기에 넣고 다음 바이트를 가져 와서 다음과 같은 시나리오를 얻습니다.

  • for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
  • 캐시 a[0]a[1]b[0]b[1]와 세트 a[0] = a[0] + b[0]캐시는 -이 현재 캐시에 네 개의 바이트, a[0], a[1]b[0], b[1]. 비용 = 100 + 100

  • a[1] = a[1] + b[1]캐시에 설정 합니다. 비용 = 1 + 1
  • c및에 대해 반복하십시오 d.
  • 총 비용 = (100 + 100 + 1 + 1) * 2 = 404

  • for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
  • 캐시 a[0]a[1]b[0]b[1]와 세트 a[0] = a[0] + b[0]캐시는 -이 현재 캐시에 네 개의 바이트, a[0], a[1]b[0], b[1]. 비용 = 100 + 100

  • 꺼내기 a[0], a[1], b[0], b[1]캐시와 캐시 c[0]c[1]d[0]d[1]와 세트 c[0] = c[0] + d[0]캐시한다. 비용 = 100 + 100
  • 내가가는 곳을보기 시작한 것 같습니다.
  • 총 비용 = (100 + 100 + 100 + 100) * 2 = 800

이것은 전형적인 캐시 스 래시 시나리오입니다.


12
이것은 올바르지 않습니다. 배열의 특정 요소에 대한 참조로 인해 전체 배열이 디스크 (또는 캐시되지 않은 메모리)에서 페이징되지 않습니다. 관련 페이지 또는 캐시 라인 만 페이징 인됩니다.
Brooks Moses

1
@Brooks Moses-여기에서 일어나는 것처럼 전체 배열을 살펴보면 그렇게 될 것입니다.
OldCurmudgeon

1
글쎄,하지만 루프 전체에서 매번 일어나는 일이 아니라 전체 작업에서 일어나는 일입니다. 두 번째 형식은 "루프를 돌 때마다 두 개의 배열과 다른 두 개의 페이지를 페이지 아웃 할 것"이라고 주장했습니다. 전체 배열의 크기에 관계없이이 루프의 중간에 RAM이 4 개의 각 배열에서 페이지를 보유하게되며 루프가 완료된 후에도 페이지가 출력되지 않습니다.
Brooks Moses

n이 단지 두 값의 메모리를 한 번에 저장할 수 있기 때문에 n이 올바른 값인 특정 경우에는 한 루프에서 4 개의 배열 의 모든 요소에 액세스하는 것이 반드시 스 래싱으로 끝나야합니다.
OldCurmudgeon

1
왜 각 루프 의 첫 페이지가 아닌 전체 a1b1첫 번째 할당 에서 2 페이지를 루프로 유지 합니까? (5 바이트 페이지를 가정한다고 가정하면 페이지가 RAM의 절반입니까? 이것은 실제 프로세서와는 완전히 다른 스케일링이 아닙니다.)
Brooks Moses

35

다른 코드 때문이 아니라 캐싱 때문입니다. RAM이 CPU 레지스터보다 느리고 변수가 변경 될 때마다 RAM을 쓰지 않도록 캐시 메모리가 CPU 내부에 있습니다. 그러나 캐시는 RAM만큼 크지 않으므로 캐시의 일부만 매핑합니다.

첫 번째 코드는 각 루프에서 먼 메모리 주소를 번갈아 수정하여 캐시를 계속 무효화해야합니다.

두 번째 코드는 대체되지 않습니다. 인접한 주소를 두 번만 통과합니다. 이렇게하면 모든 작업이 캐시에서 완료되어 두 번째 루프가 시작된 후에 만 ​​무효화됩니다.


캐시가 계속 무효화되는 이유는 무엇입니까?
Oliver Charlesworth

1
@OliCharlesworth : 인접한 메모리 주소 범위의 하드 카피로 캐시를 생각하십시오. 주소의 일부가 아닌 주소에 액세스하려는 경우 캐시를 다시로드해야합니다. 캐시의 내용이 수정 된 경우 RAM으로 다시 써야합니다. 그렇지 않으면 손실됩니다. 샘플 코드에서 100'000 정수 (400kBytes)의 4 개 벡터는 L1 캐시 용량 (128 또는 256K)보다 더 큽니다.
Emilio Garavaglia

5
캐시 크기는이 시나리오에 영향을 미치지 않습니다. 각 배열 요소는 한 번만 사용되며 그 후에는 제거되었는지는 중요하지 않습니다. 캐시 크기는 일시적인 지역이있는 경우에만 중요합니다 (즉, 나중에 동일한 요소를 재사용 할 예정 임).
Oliver Charlesworth

2
@OliCharlesworth : 캐시에 새 값을로드해야하고 이미 수정 된 값이 있으면 먼저 값을 기록해야하므로 쓰기가 발생할 때까지 기다립니다.
Emilio Garavaglia

2
그러나 OP 코드의 두 변형에서 각 값은 정확히 한 번 수정됩니다. 각 변형에서 동일한 수의 쓰기 저장을 수행합니다.
Oliver Charlesworth

22

여기서 논의한 결과를 복제 할 수 없습니다.

나쁜 벤치 마크 코드가 무엇인지 비난할지 모르겠지만 다음 코드를 사용하여 두 방법이 내 컴퓨터에서 서로 10 % 이내이며 일반적으로 하나의 루프가 두 개보다 약간 빠릅니다. 배고 있다.

배열 크기는 8 개의 루프를 사용하여 2 ^ 16에서 2 ^ 24 사이였습니다. +=할당이 FPU 에 이중으로 해석되는 메모리 가비지를 추가하도록 요구하지 않도록 소스 배열을 초기화하는 데주의를 기울 였습니다 .

나는 그런의 할당을 두는 등 다양한 제도와 주변 연주 b[j], d[j]InitToZero[j]루프 내부, 또한 사용하여 += b[j] = 1그리고 += d[j] = 1, 나는 상당히 일관된 결과를 얻었다.

예상 한 바와 같이 루프를 사용하여 초기화 bd내부를 사용 InitToZero[j]하면 결합 된 접근 방식이 aand에 할당되기 전에 연속적으로 수행 c되었으나 여전히 10 % 이내 의 이점 을 얻었습니다 . 그림을 이동.

하드웨어는 3 세대 i7 @ 3.4GHz 및 8GB 메모리 가 장착 된 Dell XPS 8500 입니다 . 8 ^ 루프를 사용하는 2 ^ 16-2 ^ 24의 경우 누적 시간은 각각 44.987 및 40.965입니다. 완전히 최적화 된 Visual C ++ 2010

추신 : 루프를 0으로 카운트 다운하도록 변경했으며 결합 된 방법이 약간 더 빠릅니다. 내 머리를 긁적. 새로운 배열 크기 및 루프 수를 확인하십시오.

// MemBufferMystery.cpp : Defines the entry point for the console application.
//
#include "stdafx.h"
#include <iostream>
#include <cmath>
#include <string>
#include <time.h>

#define  dbl    double
#define  MAX_ARRAY_SZ    262145    //16777216    // AKA (2^24)
#define  STEP_SZ           1024    //   65536    // AKA (2^16)

int _tmain(int argc, _TCHAR* argv[]) {
    long i, j, ArraySz = 0,  LoopKnt = 1024;
    time_t start, Cumulative_Combined = 0, Cumulative_Separate = 0;
    dbl *a = NULL, *b = NULL, *c = NULL, *d = NULL, *InitToOnes = NULL;

    a = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    b = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    c = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    d = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    InitToOnes = (dbl *)calloc( MAX_ARRAY_SZ, sizeof(dbl));
    // Initialize array to 1.0 second.
    for(j = 0; j< MAX_ARRAY_SZ; j++) {
        InitToOnes[j] = 1.0;
    }

    // Increase size of arrays and time
    for(ArraySz = STEP_SZ; ArraySz<MAX_ARRAY_SZ; ArraySz += STEP_SZ) {
        a = (dbl *)realloc(a, ArraySz * sizeof(dbl));
        b = (dbl *)realloc(b, ArraySz * sizeof(dbl));
        c = (dbl *)realloc(c, ArraySz * sizeof(dbl));
        d = (dbl *)realloc(d, ArraySz * sizeof(dbl));
        // Outside the timing loop, initialize
        // b and d arrays to 1.0 sec for consistent += performance.
        memcpy((void *)b, (void *)InitToOnes, ArraySz * sizeof(dbl));
        memcpy((void *)d, (void *)InitToOnes, ArraySz * sizeof(dbl));

        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
                c[j] += d[j];
            }
        }
        Cumulative_Combined += (clock()-start);
        printf("\n %6i miliseconds for combined array sizes %i and %i loops",
                (int)(clock()-start), ArraySz, LoopKnt);
        start = clock();
        for(i = LoopKnt; i; i--) {
            for(j = ArraySz; j; j--) {
                a[j] += b[j];
            }
            for(j = ArraySz; j; j--) {
                c[j] += d[j];
            }
        }
        Cumulative_Separate += (clock()-start);
        printf("\n %6i miliseconds for separate array sizes %i and %i loops \n",
                (int)(clock()-start), ArraySz, LoopKnt);
    }
    printf("\n Cumulative combined array processing took %10.3f seconds",
            (dbl)(Cumulative_Combined/(dbl)CLOCKS_PER_SEC));
    printf("\n Cumulative seperate array processing took %10.3f seconds",
        (dbl)(Cumulative_Separate/(dbl)CLOCKS_PER_SEC));
    getchar();

    free(a); free(b); free(c); free(d); free(InitToOnes);
    return 0;
}

MFLOPS가 관련 측정 항목으로 결정된 이유를 잘 모르겠습니다. 아이디어는 메모리 액세스에 중점을 두었지만 부동 소수점 계산 시간을 최소화하려고했습니다. 에 (을) 떠났지만 +=왜 그런지 잘 모르겠습니다.

계산이없는 직접 할당은 메모리 액세스 시간을보다 명확하게 테스트하는 것으로 루프 수에 관계없이 균일 한 테스트를 생성합니다. 대화에서 뭔가를 놓쳤을 수도 있지만 두 번 생각할 가치가 있습니다. 더하기가 할당에서 제외되면 누적 시간은 각각 31 초에서 거의 동일합니다.


1
여기에 언급 된 정렬 오류는 정렬되지 않은 개별로드 / 저장소 (정렬되지 않은 SSE로드 / 저장소 포함)입니다. 그러나 성능이 다른 어레이의 상대적 정렬에 민감하기 때문에 여기서는 그렇지 않습니다. 명령 수준에는 오정렬이 없습니다. 모든 단일로드 / 스토어가 올바르게 정렬되었습니다.
Mysticial

18

CPU에 캐시 누락이 많지 않기 때문입니다 (어레이 데이터가 RAM 칩에서 나올 때까지 기다려야 함). CPU 의 레벨 1 캐시 (L1), 레벨 2 캐시 (L2) 의 크기를 초과하고 코드에 걸리는 시간을 계획 하도록 배열의 크기를 계속 조정하는 것이 흥미로울 것입니다. 배열의 크기에 대해 실행합니다. 그래프는 예상대로 직선이 아니어야합니다.


2
캐시 크기와 배열 크기 사이에 상호 작용이 없다고 생각합니다. 각 배열 요소는 한 번만 사용 된 다음 안전하게 제거 할 수 있습니다. 그러나 캐시 라인 크기와 어레이 크기 간에 상호 작용이있을 수 있지만 4 개의 어레이가 충돌하는 경우가 있습니다.
Oliver Charlesworth

15

첫 번째 루프는 각 변수의 쓰기를 번갈아합니다. 두 번째와 세 번째는 요소 크기의 작은 점프 만합니다.

펜과 종이로 20cm 분리 된 20 개의 십자선 두 줄을 써보십시오. 한 줄을 끝내고 다른 줄을 끝내고 각 줄에 교대로 십자가를 써서 다른 시간을 시도하십시오.


CPU 명령어와 같은 것에 대해 생각할 때 실제 활동과 유사하다. 당신이 설명하는 것은 효과적으로 탐색 시간입니다 . 이것은 회전 디스크에 저장된 데이터를 읽고 쓰는 것에 대해 이야기하지만 CPU 캐시 (또는 RAM 또는 SSD)에는 탐색 시간이없는 경우 적용됩니다. 분리 된 메모리 영역에 대한 액세스는 인접한 액세스에 대해 페널티가 발생하지 않습니다.
FeRD

7

원래 질문

왜 하나의 루프가 두 개의 루프보다 훨씬 느립니까?


결론:

사례 1 은 비효율적 인 전형적인 보간 문제입니다. 또한 이것이 많은 기계 아키텍처와 개발자가 멀티 스레드 응용 프로그램과 병렬 프로그래밍을 수행 할 수있는 멀티 코어 시스템을 구축하고 설계하게 된 주요 이유 중 하나라고 생각합니다.

RAM, 캐시, 페이지 파일 등을 사용하는 힙 할당을 수행하기 위해 하드웨어, OS 및 컴파일러가 함께 작동하는 방식을 포함하지 않고 이러한 종류의 접근 방식에서이를 살펴보십시오. 이 알고리즘의 기초가되는 수학은이 두 가지 중 어느 것이 더 나은 솔루션인지 보여줍니다.

우리는 노동자 와 사이를 여행해야하는 것을 나타내는 Boss존재 의 비유를 사용할 수 있습니다 .SummationFor LoopAB

여행하는 데 필요한 거리와 작업자 간 소요 시간의 차이로 인해 사례 1 보다 약간 크지 않은 경우 사례 2 가 절반 이상으로 빠름을 쉽게 알 수 있습니다 . 이 수학은 BenchMark Times 및 조립 지침의 차이 수와 거의 사실상 완벽하게 일치합니다.


이제이 모든 것이 아래에서 어떻게 작동하는지 설명하겠습니다.


문제 평가

OP의 코드 :

const int n=100000;

for(int j=0;j<n;j++){
    a1[j] += b1[j];
    c1[j] += d1[j];
}

for(int j=0;j<n;j++){
    a1[j] += b1[j];
}
for(int j=0;j<n;j++){
    c1[j] += d1[j];
}

고려 사항

for 루프의 두 가지 변형에 대한 OP의 원래 질문과 캐시의 동작에 대한 수정 된 질문과 다른 많은 훌륭한 답변과 유용한 주석을 고려합니다. 이 상황과 문제에 대해 다른 접근법을 취하여 여기에서 다른 것을 시도하고 싶습니다.


접근

두 개의 루프와 캐시 및 페이지 정리에 대한 모든 토론을 고려할 때 다른 관점에서이를 보는 또 다른 접근법을 원합니다. 캐시와 페이지 파일을 포함하지 않는 메모리 나 메모리 할당을위한 실행은 실제로이 접근 방식은 실제 하드웨어 나 소프트웨어와 전혀 관련이 없습니다.


관점

코드를 잠시 살펴본 후 문제가 무엇인지, 코드가 생성되는 것이 분명해졌습니다. 이것을 알고리즘 문제로 나누고 수학 표기법을 사용하는 관점에서 살펴본 다음 수학 문제뿐만 아니라 알고리즘에도 적용 해 봅시다.


우리가 아는 것

우리는이 루프가 100,000 번 실행된다는 것을 알고 있습니다. 우리는 또한 알고 a1, b1, c1d164 비트 아키텍처에 대한 포인터입니다. 32 비트 시스템의 C ++에서 모든 포인터는 4 바이트이고 64 비트 시스템에서는 포인터의 길이가 고정되어 있으므로 8 바이트 크기입니다.

우리는 두 경우 모두에 할당 할 32 바이트가 있다는 것을 알고 있습니다. 유일한 차이점은 각 반복마다 32 바이트 또는 2-8 바이트의 2 세트를 할당하는 것입니다. 두 번째 경우에는 두 개의 독립 루프 모두에 대해 각 반복마다 16 바이트를 할당합니다.

두 루프는 여전히 총 ​​할당에서 32 바이트와 같습니다. 이 정보를 통해 이제 이러한 개념의 일반적인 수학, 알고리즘 및 유추를 보여 드리겠습니다.

우리는 두 경우 모두 동일한 집합 또는 작업 그룹이 수행해야하는 횟수를 알고 있습니다. 두 경우 모두 할당해야 할 메모리 양을 알고 있습니다. 두 경우 사이에 할당의 전체 워크로드가 거의 동일하다고 평가할 수 있습니다.


우리가 모르는 것

카운터를 설정하고 벤치 마크 테스트를 실행하지 않으면 각 사례에 소요되는 시간을 알 수 없습니다. 그러나 벤치 마크는 원래 질문과 일부 답변과 의견에서 이미 포함되었습니다. 우리는이 둘 사이에 큰 차이가 있음을 알 수 있습니다. 이것이이 문제에 대한이 제안에 대한 전체 추론입니다.


조사합시다

힙 할당, 벤치 마크 테스트, RAM, 캐시 및 페이지 파일을 보면 많은 사람들이 이미이 작업을 수행했음을 분명히 알 수 있습니다. 특정 데이터 포인트와 특정 반복 지수를 살펴 보았으며이 특정 문제에 대한 다양한 대화를 통해 많은 사람들이 다른 관련 문제에 대해 질문하기 시작했습니다. 수학적 알고리즘을 사용하고 유추를 적용하여이 문제를 어떻게 살펴볼 수 있습니까? 우리는 몇 가지 주장을 시작하여 시작합니다! 그런 다음 알고리즘을 구축합니다.


우리의 주장 :

  • 루프에서와 같이 루프와 반복은 0에서 시작하는 대신 1에서 시작하여 100000에서 끝나는 Summation이 될 것입니다. 알고리즘 자체.
  • 두 경우 모두 작업 할 4 개의 함수와 각 함수 호출마다 2 개의 작업이 수행되는 2 개의 함수 호출이 있습니다. 우리는 다음과 같은 기능에 대한 기능과 전화 등이 최대를 설정합니다 : F1(), F2(), f(a), f(b), f(c)f(d).

알고리즘 :

첫 번째 경우 : -하나의 요약 만 있고 두 개의 독립적 인 함수 호출.

Sum n=1 : [1,100000] = F1(), F2();
                       F1() = { f(a) = f(a) + f(b); }
                       F2() = { f(c) = f(c) + f(d); }

두 번째 경우 : -두 개의 요약이 있지만 각각 고유 한 함수 호출이 있습니다.

Sum1 n=1 : [1,100000] = F1();
                        F1() = { f(a) = f(a) + f(b); }

Sum2 n=1 : [1,100000] = F1();
                        F1() = { f(c) = f(c) + f(d); }

당신이 발견하는 경우 F2()에만 존재 Sum에서 Case1경우 F1()에 포함 Sum에서 Case1모두에서 Sum1Sum2에서 Case2. 이것은 나중에 두 번째 알고리즘 내에서 최적화가 있다고 결론을 내릴 때 분명해질 것입니다.

자체에 추가 되는 첫 번째 경우 Sum호출 f(a)을 통한 반복 은 동일하지만 각 반복 에 대해 자체적으로 추가 되는 f(b)호출 f(c)입니다 . 두 번째 경우에, 우리는이 과 가 같은 기능을 두 번 연속 호출되는 것처럼 모두 같은 행동 것이다.f(d)100000Sum1Sum2

이 경우 우리는 처리 할 수 Sum1Sum2다만 보통 오래된 같은 Sum경우 Sum이 경우이 같은 모습에 : Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }지금 우리가 그냥 같은 기능으로 간주 할 수있는 최적화 등이 보인다.


유추로 요약

두 번째 경우에서 보았 듯이 두 for 루프 모두 동일한 정확한 서명을 갖기 때문에 최적화가있는 것처럼 거의 나타나지만 실제 문제는 아닙니다. 문제는에 의해 수행되고있는 작업 아니다 f(a), f(b), f(c),와 f(d). 두 경우와 둘 사이의 비교에서, 각 경우에 Summation이 이동해야하는 거리의 차이가 실행 시간의 차이를 제공합니다.

의 생각 For Loops것으로 Summations수있는 Being으로 반복 수행이 Boss이명에 명령을 내리고되는 A&를 B자신의 작업 고기 것을 CD각각 그들에서 일부 패키지를 선택하고 그것을 반환 할 수 있습니다. 이 비유에서, for 루프 또는 합산 반복 및 조건 확인 자체는 실제로를 나타내지 않습니다 Boss. 어떤 사실이 나타내는 것은 Boss직접 실제의 수학적 알고리즘에서하지만 실제 개념으로부터 아니다 ScopeCode Block루틴 또는 서브 루틴, 메소드, 함수, 변환 부 등을 제 알고리즘은 제 2 알고리즘은 2 개 개의 연속 범위를 갖는 1 개 범위가 내.

각 호출 전표의 첫 번째 사례 내에서 Boss이동 A은 주문을 전달하고 패키지 A를 가져 오기 B's위해 Boss이동 한 다음 C주문은 동일한 작업을 수행하고 D각 반복 에서 패키지를 수신하도록 명령합니다 .

두 번째 경우에는 모든 패키지가 수신 될 때까지 패키지 를 Boss직접 A가져오고 가져 오기 위해 직접 작업합니다 B's. 그런 다음 모든 패키지 를 가져 오기 위해 동일한 Boss작업 C을 수행 D's합니다.

우리는 8 바이트 포인터로 작업하고 힙 할당을 다루기 때문에 다음 문제를 고려해 봅시다. 하자이 (가) 말 Boss1백피트 출신 A과 그 A에서 500 피트입니다 C. 실행 순서로 인해 Boss처음부터 얼마나 멀리 떨어져 있는지 걱정할 필요가 없습니다 C. 두 경우 모두 Boss처음에는 처음부터 A다음으로 이동 합니다 B. 이 비유는이 거리가 정확하다고 말하는 것은 아닙니다. 알고리즘의 작동을 보여주는 유용한 테스트 사례 시나리오 일뿐입니다.

대부분의 경우 힙 할당을 수행하고 캐시 및 페이지 파일로 작업 할 때 주소 위치 간의 거리는 그다지 다르지 않거나 데이터 유형의 특성 및 배열 크기에 따라 크게 달라질 수 있습니다.


테스트 사례 :

첫 번째 사례 : 첫 번째 반복에서는Boss처음에 주문 슬립을주고 100 발을 가야A하고A꺼지고 자신의 일을하지만, 다음은Boss500 발을 여행하는C그에게 자신의 주문 전표를 제공 할 수 있습니다. 그런 다음 다음 반복과 다른 모든 반복Boss에서 두 사이에서 500 피트를 앞뒤로 이동해야합니다.

두 번째 사례 :Boss에 첫 번째 반복 100 발을 여행하는A, 그러나 그 후, 그는 이미이 있고만을위한 대기A모든 전표가 작성 될 때까지 다시 얻을. 그 다음은Boss에 첫 번째 반복에 5백피트 여행이C때문에C500 피트입니다A. 이것이Boss( Summation, For Loop )작업 후 바로 호출되기 때문에모든주문 전표가 완료될 때까지A그가했던 것처럼 기다립니다. AC's


이동 거리의 차이

const n = 100000
distTraveledOfFirst = (100 + 500) + ((n-1)*(500 + 500); 
// Simplify
distTraveledOfFirst = 600 + (99999*100);
distTraveledOfFirst = 600 + 9999900;
distTraveledOfFirst =  10000500;
// Distance Traveled On First Algorithm = 10,000,500ft

distTraveledOfSecond = 100 + 500 = 600;
// Distance Traveled On Second Algorithm = 600ft;    

임의의 가치 비교

600이 천만보다 훨씬 적다는 것을 쉽게 알 수 있습니다. RAM의 주소 또는 각 반복마다 호출 할 캐시 또는 페이지 파일 간의 거리 차이에 대한 실제 차이를 알 수 없기 때문에 이것은 정확하지 않습니다. 이것은 최악의 시나리오에서 상황을 인식하고보고있는 상황에 대한 평가 일뿐입니다.

이 숫자들에서 거의 알고리즘 1 99%이 알고리즘 2보다 느려 야하는 것처럼 보입니다 . 그러나, 이것은 단지입니다 Boss's일부 또는 알고리즘의 책임과 실제 노동자 고려하지 않습니다 A, B, C, D그리고 그들이 각각의 루프의 모든 반복에해야한다. 따라서 보스의 업무는 총 작업 중 약 15-40 % 만 차지합니다. 작업자를 통해 수행되는 대부분의 작업은 속도 차이의 비율을 약 50-70 %로 유지하는 데 약간 더 큰 영향을 미칩니다.


관찰 : - 두 알고리즘의 차이점

이 상황에서 그것은 수행되는 작업의 프로세스 구조입니다. 사례 2 는 이름과 거리에 따라 다른 변수 만있는 유사한 함수 선언 및 정의를 갖는 부분 최적화 모두에서 더 효율적 임을 보여줍니다 .

우리는 또한 사례 1 에서 이동 한 총 거리 가 사례 2 에서보다 훨씬 먼 거리를 보았으며이 거리 가 두 알고리즘 사이 의 시간 계수로 이동 한 것으로 간주 할 수 있습니다 . 사례 1사례 2 보다 훨씬 더 많은 작업을 수행 합니다.

이는 ASM두 경우 모두에 표시된 지침 의 증거 에서 확인할 수 있습니다. 이미 이러한 경우에 대해 언급 한 것과 함께,이에 있다는 사실을 고려하지 않는 경우 1 보스가 모두 기다려야 할 것 A& C그가 다시 갈 전에 돌아 가야 A각 반복에 대해 다시. 또한 시간이 오래 걸리 A거나 B너무 오래 걸리면 Boss다른 작업자와 다른 작업자가 모두 실행 대기 중이 라는 사실을 고려하지 않습니다 .

에서 사례 2 유일한 존재 유휴은은 Boss노동자가 돌아 오기까지. 따라서 이것도 알고리즘에 영향을 미칩니다.



OP 수정 된 질문

편집 : 문제는 배열의 크기 (n)와 CPU 캐시에 따라 크게 다르기 때문에 질문은 관련이없는 것으로 판명되었습니다. 따라서 더 관심이 있다면 질문을 다시 표현하십시오.

다음 그래프의 5 개 영역에서 설명하는 것처럼 다른 캐시 동작을 유발하는 세부 사항에 대한 통찰력을 제공 할 수 있습니까?

이러한 CPU에 대해 유사한 그래프를 제공하여 CPU / 캐시 아키텍처의 차이점을 지적하는 것도 흥미로울 수 있습니다.


이 질문들에 대하여

의심 할 여지없이 시연 한 바와 같이 하드웨어 및 소프트웨어가 관여되기 전에도 근본적인 문제가 있습니다.

이제 페이지 파일 등과 함께 메모리 및 캐싱을 관리 할 때 다음과 같은 통합 시스템 세트에서 모두 작동합니다.

  • The Architecture {하드웨어, 펌웨어, 일부 임베디드 드라이버, 커널 및 ASM 명령어 세트}.
  • The OS{파일 및 메모리 관리 시스템, 드라이버 및 레지스트리}.
  • The Compiler {소스 코드의 번역 단위 및 최적화}.
  • 또한 Source Code고유 알고리즘 세트가 있는 자체 도 마찬가지 입니다.

우리는 이미 우리가 심지어 임의의 어떤 시스템에 적용하기 전에 먼저 알고리즘 내에서 일어나는 병목 현상이 있음을 볼 수 있습니다 Architecture, OS그리고 Programmable Language두 번째 알고리즘에 비해. 현대 컴퓨터의 본질을 다루기 전에 이미 문제가있었습니다.


결말 결과

하나; 이 새로운 질문들이 그들 자신이기 때문에 중요하지 않다고 말할 수는 없습니다. 그것들은 절차와 전반적인 성과에 영향을 미치며, 답변이나 의견을 제시 한 많은 사람들의 다양한 그래프와 평가를 통해 알 수 있습니다.

당신의 비유에 주목하면 Boss두 노동자 AB이동에서 패키지를 검색했다 CD각각 문제의 두 알고리즘의 수학적 표기 고려; 당신은 컴퓨터 하드웨어의 참여없이 볼 수 있으며 소프트웨어는 Case 260%보다 더 빨리 Case 1.

이러한 알고리즘을 일부 소스 코드에 적용하고, 컴파일, 최적화 및 OS를 통해 실행하여 주어진 하드웨어에서 작업을 수행 한 후 그래프와 차트를 보면 차이 사이에서 약간의 성능 저하를 볼 수 있습니다 이 알고리즘에서.

는 IF Data세트가 상당히 작습니다 그것은 처음에 차이의 모든 나쁜 보이지 않을 수 있습니다. 그러나 이후로는 Case 1에 관한 60 - 70%보다 느리게 Case 2우리가 시간 실행의 차이의 관점에서이 함수의 성장을 볼 수 있습니다 :

DeltaTimeDifference approximately = Loop1(time) - Loop2(time)
//where 
Loop1(time) = Loop2(time) + (Loop2(time)*[0.6,0.7]) // approximately
// So when we substitute this back into the difference equation we end up with 
DeltaTimeDifference approximately = (Loop2(time) + (Loop2(time)*[0.6,0.7])) - Loop2(time)
// And finally we can simplify this to
DeltaTimeDifference approximately = [0.6,0.7]*Loop2(time)

이 근사치는 알고리즘과 소프트웨어 최적화 및 기계 명령어가 포함 된 기계 작동 모두에서이 두 루프의 평균 차이입니다.

데이터 세트가 선형으로 증가하면 둘 사이의 시간 차이도 커집니다. 알고리즘 1은 알고리즘 2보다 더 많은 인출 (fetch)을 가지고 있는데, 알고리즘 2 는 첫 번째 반복 후 모든 반복에 대해 & Boss사이의 최대 거리를 앞뒤로 이동 해야하는 반면, 알고리즘 2는 한 번만 이동 한 다음 이동 이 완료된 후 분명 해야합니다. 한 번만 최대 거리에서 갈 때 까지 .ACBossAAAC

Boss비슷한 연속 작업에 집중하는 대신 두 가지 유사한 일을 한 번에 수행하고 앞뒤로 저글링 하는 데 집중 하려고 노력하는 것은 여행과 업무를 두 배로 늘려야했기 때문에 하루가 끝날 무렵 그를 화나게 할 것입니다. 따라서 상사의 배우자와 자녀가 그것을 인정하지 않기 때문에 상사가 보간 된 병목에 빠지게하여 상황의 범위를 잃지 마십시오.



개정 : 소프트웨어 엔지니어링 설계 원칙

- 반복 for 루프 내에서의 계산 의 차이 Local StackHeap Allocated사용법, 효율성 및 효율성의 차이-

위에서 제안한 수학적 알고리즘은 주로 힙에 할당 된 데이터에 대한 작업을 수행하는 루프에 적용됩니다.

  • 연속 스택 작업 :
    • 루프가 스택 프레임 내에있는 단일 코드 블록 또는 범위 내에서 로컬로 데이터에 대한 작업을 수행하는 경우 여전히 적용되지만 메모리 위치는 일반적으로 순차적 인 위치와 이동 거리 또는 실행 시간의 차이에 훨씬 더 가깝습니다. 거의 무시할 수 있습니다. 힙 내에서 할당이 수행되지 않기 때문에 메모리가 분산되지 않으며 메모리가 램을 통해 페치되지 않습니다. 메모리는 일반적으로 순차적이며 스택 프레임 및 스택 포인터에 상대적입니다.
    • 스택에서 연속 작업이 수행되는 경우 최신 프로세서는 반복 값과 주소를 캐시하여 이러한 값을 로컬 캐시 레지스터 내에 유지합니다. 여기에서 작동 또는 명령 시간은 나노초 단위입니다.
  • 연속 힙 할당 작업 :
    • 힙 할당을 적용하기 시작하고 프로세서가 CPU, 버스 컨트롤러 및 Ram 모듈의 아키텍처에 따라 연속 호출에서 메모리 주소를 가져와야 할 때 작업 또는 실행 시간은 마이크로에서 밀리 초 캐시 된 스택 작업과 비교하면 속도가 느립니다.
    • CPU는 Ram에서 메모리 주소를 가져와야하며 일반적으로 시스템 버스의 모든 내용은 CPU 자체의 내부 데이터 경로 또는 데이터 버스와 비교할 때 느립니다.

따라서 힙에 있어야하는 데이터로 작업하고 루프를 통해 순회하는 경우 각 데이터 세트와 해당 알고리즘을 자체 단일 루프 내에 유지하는 것이 더 효율적입니다. 힙에있는 서로 다른 데이터 세트의 여러 작업을 단일 루프에 배치하여 연속 루프를 제거하는 것보다 더 나은 최적화를 얻을 수 있습니다.

자주 캐시되기 때문에 스택에있는 데이터를 사용하여이 작업을 수행 할 수 있지만 메모리 주소가 모든 반복을 쿼리해야하는 데이터에는 적용되지 않습니다.

소프트웨어 엔지니어링과 소프트웨어 아키텍처 디자인이 시작됩니다. 데이터를 구성하는 방법, 데이터를 캐시 할시기, 힙에 데이터를 할당 할시기, 알고리즘을 설계 및 구현하는 방법, 알고리즘을 호출하는시기 및 위치를 알 수있는 기능입니다.

동일한 데이터 세트와 관련된 동일한 알고리즘을 가질 수 있지만 O(n)작업시 알고리즘 의 복잡성으로 인한 위의 문제 때문에 스택 변형에 대한 구현 설계와 힙 할당 변형에 대한 구현 설계를 원할 수 있습니다. 힙과 함께.

몇 년 동안 내가 알았던 것에서 많은 사람들이이 사실을 고려하지 않습니다. 이들은 특정 데이터 세트에서 작동하는 하나의 알고리즘을 설계하는 경향이 있으며 스택에서 로컬로 캐시되는 데이터 세트 또는 힙에 할당 된 데이터 세트에 관계없이 알고리즘을 사용합니다.

진정한 최적화를 원한다면 코드 복제처럼 보일 수 있지만 일반화하는 경우 동일한 알고리즘의 두 가지 변형을 갖는 것이 더 효율적입니다. 하나는 스택 작업을위한 것이고 다른 하나는 반복 루프에서 수행되는 힙 작업을위한 것입니다!

여기에 의사 예제가 있습니다 : 두 개의 간단한 구조체, 하나의 알고리즘.

struct A {
    int data;
    A() : data{0}{}
    A(int a) : data{a}{} 
};
struct B {
    int data;
    B() : data{0}{}
    A(int b) : data{b}{}
}                

template<typename T>
void Foo( T& t ) {
    // do something with t
}

// some looping operation: first stack then heap.

// stack data:
A dataSetA[10] = {};
B dataSetB[10] = {};

// For stack operations this is okay and efficient
for (int i = 0; i < 10; i++ ) {
   Foo(dataSetA[i]);
   Foo(dataSetB[i]);
}

// If the above two were on the heap then performing
// the same algorithm to both within the same loop
// will create that bottleneck
A* dataSetA = new [] A();
B* dataSetB = new [] B();
for ( int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]); // dataSetA is on the heap here
    Foo(dataSetB[i]); // dataSetB is on the heap here
} // this will be inefficient.

// To improve the efficiency above, put them into separate loops... 

for (int i = 0; i < 10; i++ ) {
    Foo(dataSetA[i]);
}
for (int i = 0; i < 10; i++ ) {
    Foo(dataSetB[i]);
}
// This will be much more efficient than above.
// The code isn't perfect syntax, it's only psuedo code
// to illustrate a point.

이것이 스택 변형과 힙 변형에 대해 별도의 구현을 통해 언급 한 것입니다. 알고리즘 자체는 그다지 중요하지 않습니다. 그것은 여러분이 사용할 루프 구조입니다.


이 답변을 게시한지 오래되었지만이를 이해하는 데 도움이되는 빠른 의견을 추가하고 싶었습니다. 보스와 for 루프 또는 루프를 통한 요약 또는 반복으로 보스와의 비유에서 이 보스는 범위 및 스택 변수를 관리하는 스택 프레임 및 스택 포인터와 for 루프의 메모리 주소 지정을 조합 한 것으로 간주하십시오.
Francis Cugler

@PeterMortensen 나는 원래 답변을 약간 수정하여 조언을 고려했습니다. 나는 이것이 당신이 제안한 것이라고 믿습니다.
Francis Cugler

2

오래된 C ++ 및 최적화 일 수 있습니다. 내 컴퓨터에서 나는 거의 같은 속도를 얻었습니다.

하나의 루프 : 1.577ms

두 개의 루프 : 1.507ms

16GB RAM이있는 E5-1620 3.5GHz 프로세서에서 Visual Studio 2015를 실행합니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.