Tại sao các phần tử bổ sung nhanh hơn nhiều trong các vòng lặp riêng biệt hơn trong một vòng lặp kết hợp?


2246

Giả sử a1, b1, c1, và d1điểm đến bộ nhớ heap và mã số của tôi có vòng lặp cốt lõi sau đây.

const int n = 100000;

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

Vòng lặp này được thực hiện 10.000 lần thông qua một forvòng lặp bên ngoài khác . Để tăng tốc, tôi đã thay đổi mã thành:

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

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

Được biên dịch trên MS Visual C ++ 10.0 với tối ưu hóa hoàn toàn và SSE2 được kích hoạt 32 bit trên Intel Core 2 Duo (x64), ví dụ đầu tiên mất 5,5 giây và ví dụ hai vòng chỉ mất 1,9 giây. Câu hỏi của tôi là: (Vui lòng tham khảo câu hỏi lặp lại của tôi ở phía dưới)

PS: Tôi không chắc chắn, nếu điều này giúp:

Việc tháo gỡ cho vòng lặp đầu tiên về cơ bản trông như thế này (khối này được lặp lại khoảng năm lần trong toàn bộ chương trình):

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]

Mỗi vòng lặp của ví dụ vòng lặp kép tạo ra mã này (khối sau được lặp lại khoảng ba lần):

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

Câu hỏi hóa ra không liên quan, vì hành vi phụ thuộc rất nhiều vào kích thước của mảng (n) và bộ đệm CPU. Vì vậy, nếu có thêm sự quan tâm, tôi viết lại câu hỏi:

Bạn có thể cung cấp một số cái nhìn sâu sắc về các chi tiết dẫn đến các hành vi bộ đệm khác nhau như được minh họa bởi năm vùng trên biểu đồ sau?

Cũng có thể thú vị khi chỉ ra sự khác biệt giữa các kiến ​​trúc CPU / bộ đệm, bằng cách cung cấp một biểu đồ tương tự cho các CPU này.

PPS: Đây là mã đầy đủ. Nó sử dụng TBB Tick_Count để định thời gian độ phân giải cao hơn, có thể bị vô hiệu hóa bằng cách không xác định TBB_TIMINGMacro:

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

(Nó hiển thị FLOP / s cho các giá trị khác nhau của n.)

nhập mô tả hình ảnh ở đây


4
Có thể là hệ điều hành chậm trong khi tìm kiếm bộ nhớ vật lý mỗi khi bạn truy cập vào nó và có một cái gì đó giống như bộ đệm trong trường hợp truy cập thứ cấp vào cùng một bộ nhớ.
AlexTheo

7
Bạn đang biên dịch với tối ưu hóa? Trông giống như rất nhiều mã asm cho O2 ...
Luchian Grigore

1
Tôi đã hỏi những gì dường như là một câu hỏi tương tự một thời gian trước đây. Nó hoặc câu trả lời có thể có thông tin quan tâm.
Mark Wilkins

61
Chỉ cần kén chọn, hai đoạn mã này không tương đương do các con trỏ có khả năng chồng chéo. C99 có restricttừ khóa cho các tình huống như vậy. Tôi không biết nếu MSVC có cái gì đó tương tự. Tất nhiên, nếu đây là vấn đề thì mã SSE sẽ không chính xác.
dùng510306

8
Điều này có thể có một cái gì đó để làm với răng cưa. Với một vòng lặp, d1[j]có thể aliase với a1[j], vì vậy trình biên dịch có thể rút lại khỏi việc thực hiện một số tối ưu hóa bộ nhớ. Trong khi điều đó không xảy ra nếu bạn tách các bài viết vào bộ nhớ thành hai vòng.
rturrado

Câu trả lời:


1690

Sau khi phân tích sâu hơn về điều này, tôi tin rằng điều này (ít nhất là một phần) gây ra bởi sự liên kết dữ liệu của bốn con trỏ. Điều này sẽ gây ra một số mức độ xung đột ngân hàng / cách bộ đệm.

Nếu tôi đã đoán chính xác về cách bạn phân bổ các mảng của mình, chúng có khả năng được căn chỉnh theo dòng trang .

Điều này có nghĩa là tất cả các truy cập của bạn trong mỗi vòng lặp sẽ nằm trên cùng một cách bộ đệm. Tuy nhiên, bộ xử lý Intel đã có sự kết hợp bộ nhớ cache L1 8 chiều trong một thời gian. Nhưng trong thực tế, hiệu suất không hoàn toàn thống nhất. Truy cập 4 cách vẫn chậm hơn so với nói 2 cách.

EDIT: Thực tế có vẻ như bạn đang phân bổ tất cả các mảng riêng biệt. Thông thường khi phân bổ lớn như vậy được yêu cầu, người cấp phát sẽ yêu cầu các trang mới từ HĐH. Do đó, có nhiều khả năng phân bổ lớn sẽ xuất hiện ở cùng mức bù từ ranh giới trang.

Đây là mã kiểm tra:

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

Kết quả điểm chuẩn:

EDIT: Kết quả trên máy kiến ​​trúc Core 2 thực tế :

2 x Intel Xeon X5482 Harpertown @ 3,2 GHz:

#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

Quan sát:

  • 6,206 giây với một vòng lặp và 2,116 giây với hai vòng lặp. Điều này tái tạo chính xác kết quả của OP.

  • Trong hai thử nghiệm đầu tiên, các mảng được phân bổ riêng. Bạn sẽ nhận thấy rằng tất cả chúng đều có cùng một liên kết với trang.

  • Trong hai thử nghiệm thứ hai, các mảng được đóng gói với nhau để phá vỡ sự liên kết đó. Ở đây bạn sẽ nhận thấy cả hai vòng lặp nhanh hơn. Hơn nữa, vòng lặp thứ hai (gấp đôi) bây giờ chậm hơn như bạn thường mong đợi.

Như @Stephen Cannon chỉ ra trong các bình luận, rất có khả năng sự liên kết này gây ra hiện tượng sai lệch trong các đơn vị tải / lưu trữ hoặc bộ đệm. Tôi đã tìm kiếm điều này và thấy rằng Intel thực sự có một bộ đếm phần cứng cho các quầy hàng răng cưa địa chỉ một phần :

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


5 khu vực - Giải thích

Vùng 1:

Điều này là dễ dàng. Bộ dữ liệu nhỏ đến mức hiệu suất bị chi phối bởi chi phí như vòng lặp và phân nhánh.

Khu vực 2:

Ở đây, khi kích thước dữ liệu tăng lên, lượng chi phí tương đối giảm xuống và hiệu suất "bão hòa". Ở đây hai vòng lặp chậm hơn vì nó có gấp đôi vòng lặp và phân nhánh trên cao.

Tôi không chắc chính xác những gì đang diễn ra ở đây ... Căn chỉnh vẫn có thể phát huy tác dụng khi Agner Fog đề cập đến xung đột ngân hàng bộ đệm . (Liên kết đó là về Sandy Bridge, nhưng ý tưởng vẫn nên được áp dụng cho Core 2.)

Vùng 3:

Tại thời điểm này, dữ liệu không còn phù hợp với bộ đệm L1. Vì vậy, hiệu suất được giới hạn bởi băng thông bộ đệm L1 <-> L2.

Vùng 4:

Hiệu suất giảm trong vòng lặp đơn là những gì chúng ta đang quan sát. Và như đã đề cập, điều này là do sự liên kết (rất có thể) gây ra các gian hàng răng cưa sai trong các đơn vị tải / lưu trữ bộ xử lý.

Tuy nhiên, để răng cưa sai xảy ra, phải có một bước tiến đủ lớn giữa các bộ dữ liệu. Đây là lý do tại sao bạn không thấy điều này trong khu vực 3.

Vùng 5:

Tại thời điểm này, không có gì phù hợp trong bộ đệm. Vì vậy, bạn bị ràng buộc bởi băng thông bộ nhớ.


2 x Intel X5482 Harpertown @ 3,2 GHz Intel Core i7 870 @ 2,8 GHz Intel Core i7 2600K @ 4,4 GHz


162
+1: Tôi nghĩ đây là câu trả lời. Trái ngược với tất cả những gì các câu trả lời khác nói, đó không phải là về biến thể vòng lặp đơn vốn đã có nhiều lỗi nhớ cache hơn, đó là về sự liên kết cụ thể của các mảng khiến bộ nhớ cache bị mất.
Oliver Charlesworth

30
Điều này; một gian hàng răng cưa sai là lời giải thích có khả năng nhất.
Stephen Canon

7
@VictorT. Tôi đã sử dụng mã OP liên kết đến. Nó tạo ra một tệp .css mà tôi có thể mở trong Excel và tạo một biểu đồ từ nó.
Bí ẩn

5
@Nawaz Một trang thường là 4KB. Nếu bạn nhìn vào các địa chỉ thập lục phân mà tôi in ra, tất cả các thử nghiệm được phân bổ riêng đều có cùng một modulo 4096. (đó là 32 byte từ khi bắt đầu ranh giới 4KB) Có lẽ GCC không có hành vi này. Điều đó có thể giải thích tại sao bạn không thấy sự khác biệt.
Bí ẩn


224

OK, câu trả lời đúng chắc chắn phải làm gì đó với bộ đệm CPU. Nhưng để sử dụng đối số bộ đệm có thể khá khó khăn, đặc biệt là không có dữ liệu.

Có nhiều câu trả lời, dẫn đến nhiều cuộc thảo luận, nhưng hãy đối mặt với nó: Các vấn đề về bộ nhớ cache có thể rất phức tạp và không phải là một chiều. Chúng phụ thuộc rất nhiều vào kích thước của dữ liệu, vì vậy câu hỏi của tôi là không công bằng: Hóa ra là ở một điểm rất thú vị trong biểu đồ bộ đệm.

Câu trả lời của @ Mysticial đã thuyết phục rất nhiều người (bao gồm cả tôi), có lẽ bởi vì đó là người duy nhất dường như dựa vào sự thật, nhưng đó chỉ là một "điểm dữ liệu" của sự thật.

Đó là lý do tại sao tôi kết hợp bài kiểm tra của anh ấy (sử dụng phân bổ liên tục so với phân bổ riêng biệt) và lời khuyên của Người trả lời của @James.

Các biểu đồ dưới đây cho thấy, hầu hết các câu trả lời và đặc biệt là phần lớn các ý kiến ​​cho câu hỏi và câu trả lời có thể được coi là hoàn toàn sai hoặc đúng tùy thuộc vào kịch bản và tham số chính xác được sử dụng.

Lưu ý rằng câu hỏi ban đầu của tôi là n = 100.000 . Điểm này (tình cờ) thể hiện hành vi đặc biệt:

  1. Nó có sự khác biệt lớn nhất giữa phiên bản một và hai vòng lặp (gần như là một yếu tố của ba)

  2. Đó là điểm duy nhất, trong đó một vòng lặp (cụ thể là phân bổ liên tục) đánh bại phiên bản hai vòng. (Điều này làm cho câu trả lời của Mysticial có thể, tất cả.)

Kết quả sử dụng dữ liệu khởi tạo:

Nhập mô tả hình ảnh ở đây

Kết quả sử dụng dữ liệu chưa được khởi tạo (đây là những gì Mysticial đã kiểm tra):

Nhập mô tả hình ảnh ở đây

Và đây là một điều khó giải thích: Dữ liệu được khởi tạo, được phân bổ một lần và được sử dụng lại cho mọi trường hợp thử nghiệm sau có kích thước vectơ khác nhau:

Nhập mô tả hình ảnh ở đây

Đề nghị

Mỗi câu hỏi liên quan đến hiệu suất cấp thấp trên Stack Overflow nên được yêu cầu để cung cấp thông tin MFLOPS cho toàn bộ phạm vi kích thước dữ liệu liên quan đến bộ đệm! Thật lãng phí thời gian của mọi người khi nghĩ đến câu trả lời và đặc biệt là thảo luận chúng với những người khác mà không có thông tin này.


18
+1 Phân tích đẹp. Tôi đã không có ý định để lại dữ liệu chưa được khởi tạo ở nơi đầu tiên. Nó chỉ xảy ra rằng người cấp phát không có họ dù sao. Vì vậy, dữ liệu khởi tạo là những gì quan trọng. Tôi vừa chỉnh sửa câu trả lời của mình với kết quả trên máy kiến ​​trúc Core 2 thực tế và chúng gần với những gì bạn đang quan sát hơn. Một điều nữa là tôi đã thử nghiệm một loạt các kích thước nvà nó cho thấy khoảng cách hiệu suất tương tự n = 80000, n = 100000, n = 200000, v.v ...
Bí ẩn

2
@Mysticial Tôi nghĩ rằng hệ điều hành thực hiện zeroing trang bất cứ khi nào đưa các trang mới vào một quy trình để tránh gián điệp có thể xảy ra.
v.oddou

1
@ v.oddou: Hành vi cũng phụ thuộc vào HĐH; IIRC, Windows có một luồng để giải phóng các trang không có trang và nếu một yêu cầu không thể được thỏa mãn từ các trang đã bị xóa, các VirtualAllockhối gọi cho đến khi nó có thể đủ để đáp ứng yêu cầu. Ngược lại, Linux chỉ ánh xạ trang 0 dưới dạng sao chép khi viết càng nhiều càng cần thiết và khi viết, nó sao chép các số 0 mới sang một trang mới trước khi ghi vào dữ liệu mới. Dù bằng cách nào, từ quan điểm của quá trình chế độ người dùng, các trang đều bằng 0, nhưng lần đầu tiên sử dụng bộ nhớ chưa được khởi tạo thường sẽ đắt hơn trên Linux so với trên Windows.
ShadowRanger

81

Vòng lặp thứ hai liên quan đến hoạt động bộ nhớ cache ít hơn rất nhiều, do đó bộ xử lý dễ dàng theo kịp nhu cầu bộ nhớ hơn.


1
Bạn đang nói rằng biến thể thứ hai phát sinh ít bộ nhớ cache hơn? Tại sao?
Oliver Charlesworth

2
@Oli: Trong phiên bản đầu tiên, bộ vi xử lý cần phải truy cập có bốn dòng bộ nhớ tại một tốn nhiều thời gian a[i], b[i], c[i]d[i]Trong biến thể thứ hai, nó chỉ hai cần. Điều này làm cho nó dễ dàng hơn nhiều để nạp đầy những dòng đó trong khi thêm.
Cún con

4
Nhưng miễn là các mảng không va chạm trong bộ đệm, mỗi biến thể yêu cầu chính xác số lần đọc và ghi từ / đến bộ nhớ chính. Vì vậy, kết luận là (tôi nghĩ) rằng hai mảng này xảy ra xung đột mọi lúc.
Oliver Charlesworth

3
Tôi không làm theo. Mỗi lệnh (ví dụ: mỗi ví dụ x += y), có hai lần đọc và một lần ghi. Điều này đúng cho cả hai biến thể. Do đó, yêu cầu băng thông CPU <-> là như nhau. Miễn là không có xung đột, yêu cầu băng thông bộ nhớ cache <-> RAM cũng giống nhau ..
Oliver Charlesworth

2
Như đã lưu ý trong stackoverflow.com/a/1742231/102916 , tính năng tìm nạp trước phần cứng của Pentium M có thể theo dõi 12 luồng chuyển tiếp khác nhau (và tôi hy vọng phần cứng sau này có khả năng ít nhất là có khả năng). Vòng 2 vẫn chỉ đọc bốn luồng, vì vậy cũng nằm trong giới hạn đó.
Brooks Moses

50

Hãy tưởng tượng bạn đang làm việc trên một máy có ngiá trị phù hợp để chỉ có thể giữ hai mảng của bạn trong bộ nhớ cùng một lúc, nhưng tổng bộ nhớ có sẵn, thông qua bộ nhớ đệm trên đĩa, vẫn đủ để chứa cả bốn.

Giả sử chính sách lưu trữ LIFO đơn giản, mã này:

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

đầu tiên sẽ gây ra abđược tải vào RAM và sau đó được xử lý hoàn toàn trong RAM. Khi vòng lặp thứ hai bắt đầu, cdsau đó sẽ được tải từ đĩa vào RAM và hoạt động.

vòng lặp khác

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

sẽ trang ra hai mảng và trang trong hai mảng còn lại mỗi lần xung quanh vòng lặp . Điều này rõ ràng sẽ chậm hơn nhiều .

Bạn có thể không thấy bộ nhớ đệm đĩa trong các thử nghiệm của mình nhưng có lẽ bạn đang thấy tác dụng phụ của một số hình thức lưu trữ khác.


Dường như có một chút nhầm lẫn / hiểu lầm ở đây vì vậy tôi sẽ cố gắng xây dựng một chút bằng cách sử dụng một ví dụ.

Nói n = 2và chúng tôi đang làm việc với byte. Do đó, trong kịch bản của tôi, chúng tôi chỉ4 byte RAM và phần còn lại của bộ nhớ chậm hơn đáng kể (truy cập lâu hơn 100 lần).

Giả sử một chính sách bộ đệm khá ngu ngốc nếu byte không có trong bộ đệm, hãy đặt nó ở đó và nhận byte sau trong khi chúng ta ở đó, bạn sẽ nhận được một kịch bản như thế này:

  • Với

    for(int j=0;j<n;j++){
     a[j] += b[j];
    }
    for(int j=0;j<n;j++){
     c[j] += d[j];
    }
  • bộ đệm a[0]a[1]sau đó b[0]b[1]được đặt a[0] = a[0] + b[0]trong bộ đệm - hiện có bốn byte trong bộ đệm a[0], a[1]b[0], b[1]. Chi phí = 100 + 100.

  • đặt a[1] = a[1] + b[1]trong bộ đệm. Chi phí = 1 + 1.
  • Lặp lại cho cd.
  • Tổng chi phí = (100 + 100 + 1 + 1) * 2 = 404

  • Với

    for(int j=0;j<n;j++){
     a[j] += b[j];
     c[j] += d[j];
    }
  • bộ đệm a[0]a[1]sau đó b[0]b[1]được đặt a[0] = a[0] + b[0]trong bộ đệm - hiện có bốn byte trong bộ đệm a[0], a[1]b[0], b[1]. Chi phí = 100 + 100.

  • đẩy a[0], a[1], b[0], b[1]từ bộ nhớ cache và bộ nhớ cache c[0]c[1]sau đó d[0]d[1]và thiết lập c[0] = c[0] + d[0]trong bộ nhớ cache. Chi phí = 100 + 100.
  • Tôi nghi ngờ bạn đang bắt đầu để xem nơi tôi sẽ đi.
  • Tổng chi phí = (100 + 100 + 100 + 100) * 2 = 800

Đây là một kịch bản thrash cache cổ điển.


12
Điều này là không chính xác. Tham chiếu đến một phần tử cụ thể của một mảng không làm cho toàn bộ mảng được phân trang từ đĩa (hoặc từ bộ nhớ không được lưu trong bộ nhớ cache); chỉ có trang liên quan hoặc dòng bộ đệm được phân trang.
Brooks Moses

1
@Brooks Moses - Nếu bạn đi qua toàn bộ mảng, như đang xảy ra ở đây, thì nó sẽ.
OldCurmudgeon

1
Vâng, vâng, nhưng đó là những gì xảy ra trong toàn bộ hoạt động, không phải những gì xảy ra mỗi lần xung quanh vòng lặp. Bạn tuyên bố rằng biểu mẫu thứ hai "sẽ trang ra hai mảng và trang trong hai phần còn lại mỗi lần xung quanh vòng lặp" và đó là điều tôi đang phản đối. Bất kể kích thước của các mảng tổng thể, ở giữa vòng lặp này, RAM của bạn sẽ giữ một trang từ mỗi trong bốn mảng, và sẽ không có gì được phân trang cho đến khi vòng lặp kết thúc với nó.
Brooks Moses

Trong trường hợp cụ thể, trong đó n chỉ là giá trị phù hợp, chỉ có thể giữ hai mảng của bạn trong bộ nhớ cùng một lúc, sau đó truy cập tất cả các phần tử của bốn mảng trong một vòng lặp chắc chắn phải kết thúc.
OldCurmudgeon

1
Tại sao bạn ở lại toàn bộ 2 trang đó cho toàn bộ a1b1cho bài tập đầu tiên, thay vì chỉ trang đầu tiên của mỗi trang? (Bạn có đang giả sử các trang 5 byte, vì vậy một trang là một nửa RAM của bạn không? Đó không chỉ là tỷ lệ, mà hoàn toàn không giống như một bộ xử lý thực sự.)
Brooks Moses

35

Không phải vì một mã khác, mà là do bộ nhớ đệm: RAM chậm hơn các thanh ghi CPU và bộ nhớ đệm nằm trong CPU để tránh ghi RAM mỗi khi thay đổi. Nhưng bộ nhớ cache không lớn như RAM, do đó, nó chỉ ánh xạ một phần của nó.

Mã đầu tiên sửa đổi các địa chỉ bộ nhớ xa xen kẽ chúng tại mỗi vòng lặp, do đó yêu cầu liên tục để vô hiệu hóa bộ đệm.

Mã thứ hai không thay thế: nó chỉ chảy trên các địa chỉ liền kề hai lần. Điều này làm cho tất cả các công việc được hoàn thành trong bộ đệm, chỉ vô hiệu hóa nó sau khi vòng lặp thứ hai bắt đầu.


Tại sao điều này sẽ khiến bộ đệm liên tục bị vô hiệu?
Oliver Charlesworth

1
@OliCharlesworth: Nghĩ đến bộ đệm như một bản sao cứng của một dải địa chỉ bộ nhớ liền kề. Nếu bạn giả vờ truy cập một địa chỉ không phải là một phần của chúng, bạn phải tải lại bộ đệm. Và nếu một cái gì đó trong bộ đệm đã được sửa đổi, nó phải được ghi lại vào RAM, nếu không nó sẽ bị mất. Trong mã mẫu, 4 ​​vectơ của 100'000 số nguyên (400kBytes) nhiều khả năng nhiều hơn dung lượng của bộ đệm L1 (128 hoặc 256K).
Emilio Garavaglia

5
Kích thước của bộ đệm không có tác động trong kịch bản này. Mỗi phần tử mảng chỉ được sử dụng một lần và sau đó nó không thành vấn đề nếu nó bị trục xuất. Kích thước bộ đệm chỉ quan trọng nếu bạn có địa phương tạm thời (nghĩa là bạn sẽ sử dụng lại các yếu tố tương tự trong tương lai).
Oliver Charlesworth

2
@OliCharlesworth: Nếu tôi phải tải một giá trị mới trong bộ đệm và đã có một giá trị trong đó đã được sửa đổi, trước tiên tôi phải viết nó xuống và điều này khiến tôi chờ đợi việc ghi xảy ra.
Emilio Garavaglia

2
Nhưng trong cả hai biến thể của mã OP, mỗi giá trị được sửa đổi chính xác một lần. Bạn làm như vậy với cùng số lần ghi lại trong mỗi biến thể.
Oliver Charlesworth

22

Tôi không thể sao chép các kết quả được thảo luận ở đây.

Tôi không biết liệu mã điểm chuẩn kém có đáng trách hay không, nhưng hai phương thức nằm trong phạm vi 10% của nhau trên máy của tôi bằng cách sử dụng mã sau và một vòng lặp thường chỉ nhanh hơn hai lần - như bạn chờ đợi.

Kích thước mảng dao động từ 2 ^ 16 đến 2 ^ 24, sử dụng tám vòng. Tôi đã cẩn thận khởi tạo các mảng nguồn để +=bài tập không yêu cầu FPU thêm rác bộ nhớ được hiểu là gấp đôi.

Tôi đã chơi xung quanh với các sơ đồ khác nhau, chẳng hạn như đặt sự phân công b[j], d[j]vào InitToZero[j]bên trong các vòng lặp, và cả với việc sử dụng += b[j] = 1+= d[j] = 1, và tôi đã nhận được kết quả khá nhất quán.

Như bạn có thể mong đợi, việc khởi tạo bdbên trong vòng lặp sử dụng InitToZero[j]đã mang lại lợi thế cho cách tiếp cận kết hợp, vì chúng được thực hiện ngược lại trước khi gán cho ac, nhưng vẫn trong phạm vi 10%. Đi hình.

Phần cứng là Dell XPS 8500 với thế hệ 3 Core i7 @ 3,4 GHz và bộ nhớ 8 GB. Đối với 2 ^ 16 đến 2 ^ 24, sử dụng tám vòng, thời gian tích lũy lần lượt là 44.987 và 40.965. Visual C ++ 2010, được tối ưu hóa hoàn toàn.

PS: Tôi đã thay đổi các vòng lặp để đếm ngược về 0 và phương thức kết hợp nhanh hơn một chút. Gãi đầu. Lưu ý kích thước mảng mới và số vòng lặp.

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

Tôi không chắc tại sao quyết định MFLOPS là một số liệu có liên quan. Tôi mặc dù ý tưởng là tập trung vào truy cập bộ nhớ, vì vậy tôi đã cố gắng giảm thiểu thời gian tính toán dấu phẩy động. Tôi rời đi trong +=, nhưng tôi không chắc tại sao.

Một phép gán thẳng không có tính toán sẽ là một bài kiểm tra sạch hơn về thời gian truy cập bộ nhớ và sẽ tạo ra một bài kiểm tra thống nhất bất kể số vòng lặp. Có thể tôi đã bỏ lỡ điều gì đó trong cuộc trò chuyện, nhưng nó đáng để suy nghĩ hai lần. Nếu cộng trừ khỏi bài tập, thời gian tích lũy gần như giống nhau ở mức 31 giây mỗi lần.


1
Hình phạt sai lệch mà bạn đề cập ở đây là khi một tải / cửa hàng riêng lẻ bị sai lệch (bao gồm cả tải / cửa hàng SSE không được phân bổ). Nhưng đó không phải là trường hợp ở đây vì hiệu suất rất nhạy cảm với sự sắp xếp tương đối của các mảng khác nhau. Không có sai lệch ở cấp độ hướng dẫn. Mỗi tải / cửa hàng được căn chỉnh chính xác.
Bí ẩn

18

Đó là bởi vì CPU không có quá nhiều lỗi bộ nhớ cache (trong đó nó phải chờ dữ liệu mảng đến từ các chip RAM). Sẽ rất thú vị khi bạn điều chỉnh kích thước của các mảng liên tục để bạn vượt quá kích thước của bộ đệm cấp 1 (L1), và sau đó là bộ đệm cấp 2 (L2), của CPU và vẽ thời gian cho mã của bạn để thực hiện đối với các kích thước của mảng. Biểu đồ không nên là một đường thẳng như bạn mong đợi.


2
Tôi không tin có bất kỳ sự tương tác nào giữa kích thước bộ đệm và kích thước mảng. Mỗi phần tử mảng chỉ được sử dụng một lần và sau đó có thể được gỡ bỏ một cách an toàn. Tuy nhiên, cũng có thể có sự tương tác giữa kích thước dòng bộ đệm và kích thước mảng, nếu điều đó làm cho bốn mảng xung đột.
Oliver Charlesworth

15

Vòng lặp đầu tiên luân phiên viết trong mỗi biến. Cái thứ hai và thứ ba chỉ thực hiện các bước nhảy nhỏ có kích thước phần tử.

Hãy thử viết hai đường thẳng song song 20 chữ thập bằng bút và giấy cách nhau 20 cm. Hãy thử một lần hoàn thành một và sau đó đến dòng khác và thử một lần khác bằng cách viết một chữ thập trong mỗi dòng xen kẽ.


Tương tự như các hoạt động trong thế giới thực đầy nguy hiểm, khi nghĩ về những thứ như hướng dẫn CPU. Những gì bạn đang minh họa là tìm kiếm thời gian một cách hiệu quả , sẽ áp dụng nếu chúng ta đang nói về việc đọc / ghi dữ liệu được lưu trữ trên đĩa quay, nhưng không có thời gian tìm kiếm trong bộ đệm CPU (hoặc trong RAM hoặc trên SSD). Truy cập vào các vùng khác nhau của bộ nhớ không bị phạt so với các truy cập liền kề.
FeRD

7

Câu hỏi gốc

Tại sao một vòng lặp chậm hơn nhiều so với hai vòng lặp?


Phần kết luận:

Trường hợp 1 là một vấn đề nội suy cổ điển xảy ra là một vấn đề không hiệu quả. Tôi cũng nghĩ rằng đây là một trong những lý do hàng đầu khiến nhiều kiến ​​trúc sư và nhà phát triển kết thúc việc xây dựng và thiết kế các hệ thống đa lõi với khả năng thực hiện các ứng dụng đa luồng cũng như lập trình song song.

Nhìn vào phương pháp này theo cách tiếp cận mà không liên quan đến cách Phần cứng, HĐH và Trình biên dịch hoạt động cùng nhau để thực hiện phân bổ heap liên quan đến làm việc với RAM, Cache, Tệp trang, v.v.; toán học là nền tảng của các thuật toán này cho chúng ta thấy cái nào trong hai cái này là giải pháp tốt hơn.

Chúng ta có thể sử dụng một sự tương tự của một Bosssinh vật Summationsẽ đại diện cho một người For Loopphải đi lại giữa các công nhân A& B.

Chúng ta có thể dễ dàng thấy rằng Trường hợp 2 nhanh nhất ít nhất là một nửa nếu không hơn một chút so với Trường hợp 1 do sự khác biệt về khoảng cách cần thiết để đi lại và thời gian giữa các công nhân. Toán học này sắp xếp gần như hoàn hảo và hoàn hảo với cả Thời báo BenchMark cũng như số lượng khác biệt trong Hướng dẫn lắp ráp.


Bây giờ tôi sẽ bắt đầu giải thích làm thế nào tất cả những điều này hoạt động dưới đây.


Đánh giá vấn đề

Mã của 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];
}

Sự xem xét

Xem xét câu hỏi ban đầu của OP về 2 biến thể của vòng lặp và câu hỏi sửa đổi của anh ấy đối với hành vi của bộ nhớ cache cùng với nhiều câu trả lời xuất sắc và nhận xét hữu ích khác; Tôi muốn thử và làm điều gì đó khác biệt ở đây bằng cách thực hiện một cách tiếp cận khác về tình huống và vấn đề này.


Tiếp cận

Xem xét hai vòng lặp và tất cả các cuộc thảo luận về bộ đệm và lưu trang tôi muốn thực hiện một cách tiếp cận khác để xem xét điều này từ một quan điểm khác. Một cách không liên quan đến bộ đệm và tệp trang cũng như thực thi để phân bổ bộ nhớ, trên thực tế, phương pháp này thậm chí không liên quan đến phần cứng hoặc phần mềm thực tế.


Quan điểm

Sau khi xem mã một lúc, nó trở nên khá rõ ràng vấn đề là gì và cái gì đang tạo ra nó. Chúng ta hãy giải quyết vấn đề này thành một vấn đề thuật toán và xem xét nó từ góc độ sử dụng các ký hiệu toán học sau đó áp dụng một sự tương tự cho các vấn đề toán học cũng như các thuật toán.


Những gì chúng ta biết

Chúng tôi biết rằng vòng lặp này sẽ chạy 100.000 lần. Chúng tôi cũng biết rằng a1, b1, c1& d1là gợi ý về một kiến trúc 64-bit. Trong C ++ trên máy 32 bit, tất cả các con trỏ là 4 byte và trên máy 64 bit, chúng có kích thước 8 byte do các con trỏ có độ dài cố định.

Chúng tôi biết rằng chúng tôi có 32 byte để phân bổ trong cả hai trường hợp. Sự khác biệt duy nhất là chúng ta đang phân bổ 32 byte hoặc 2 bộ 2-8byte trên mỗi lần lặp trong trường hợp thứ 2 chúng ta đang phân bổ 16 byte cho mỗi lần lặp cho cả hai vòng lặp độc lập.

Cả hai vòng vẫn bằng 32 byte trong tổng phân bổ. Với thông tin này, bây giờ chúng ta hãy tiếp tục và hiển thị toán học chung, thuật toán và sự tương tự của các khái niệm này.

Chúng tôi biết số lần mà cùng một nhóm hoặc nhóm hoạt động sẽ phải được thực hiện trong cả hai trường hợp. Chúng tôi biết số lượng bộ nhớ cần được phân bổ trong cả hai trường hợp. Chúng tôi có thể đánh giá rằng khối lượng công việc chung của phân bổ giữa cả hai trường hợp sẽ xấp xỉ nhau.


Những gì chúng ta không biết

Chúng tôi không biết sẽ mất bao lâu cho mỗi trường hợp trừ khi chúng tôi đặt bộ đếm và chạy thử nghiệm điểm chuẩn. Tuy nhiên, điểm chuẩn đã được bao gồm từ câu hỏi ban đầu và từ một số câu trả lời cũng như nhận xét; và chúng ta có thể thấy một sự khác biệt đáng kể giữa hai và đây là toàn bộ lý do cho đề xuất này cho vấn đề này.


Hãy điều tra

Rõ ràng là nhiều người đã thực hiện điều này bằng cách xem xét phân bổ heap, kiểm tra điểm chuẩn, xem xét RAM, Cache và tệp trang. Nhìn vào các điểm dữ liệu cụ thể và các chỉ số lặp cụ thể cũng được đưa vào và các cuộc hội thoại khác nhau về vấn đề cụ thể này khiến nhiều người bắt đầu đặt câu hỏi về những điều liên quan khác về nó. Làm thế nào để chúng ta bắt đầu xem xét vấn đề này bằng cách sử dụng các thuật toán toán học và áp dụng một sự tương tự cho nó? Chúng tôi bắt đầu bằng cách đưa ra một vài khẳng định! Sau đó, chúng tôi xây dựng thuật toán của chúng tôi từ đó.


Khẳng định của chúng tôi:

  • Chúng ta sẽ để vòng lặp và các vòng lặp của nó là một Tóm tắt bắt đầu từ 1 và kết thúc ở 100000 thay vì bắt đầu bằng 0 như trong các vòng lặp vì chúng ta không cần phải lo lắng về sơ đồ lập chỉ mục 0 của địa chỉ bộ nhớ vì chúng ta chỉ quan tâm đến các thuật toán chính nó.
  • Trong cả hai trường hợp, chúng ta có 4 hàm để làm việc và 2 lệnh gọi hàm với 2 thao tác được thực hiện trên mỗi lệnh gọi hàm. Chúng tôi sẽ thiết lập các lên như chức năng và các cuộc gọi đến các chức năng như sau: F1(), F2(), f(a), f(b), f(c)f(d).

Các thuật toán:

Trường hợp thứ nhất: - Chỉ có một tổng kết nhưng hai lệnh gọi hàm độc lập.

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

Trường hợp thứ 2: - Hai phép tóm tắt nhưng mỗi phép có hàm gọi riêng.

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

Nếu bạn nhận thấy F2()chỉ tồn tại trong Sumtừ Case1nơi F1()được chứa trong Sumtừ Case1và trong cả hai Sum1Sum2từ Case2. Điều này sẽ được chứng minh sau này khi chúng ta bắt đầu kết luận rằng có một sự tối ưu hóa đang diễn ra trong thuật toán thứ hai.

Các lần lặp thông qua các trường hợp đầu tiên Sumgọi f(a)sẽ tự thêm vào nó, f(b)sau đó nó gọi f(c)sẽ thực hiện tương tự nhưng thêm f(d)vào chính nó cho mỗi 100000lần lặp. Trong trường hợp thứ hai, chúng ta có Sum1Sum2cả hai đều hoạt động giống như thể chúng có cùng chức năng được gọi hai lần liên tiếp.

Trong trường hợp này chúng ta có thể đối xử với Sum1Sum2như chỉ đơn giản cũ Sumnơi Sumtrong trường hợp này trông như sau: Sum n=1 : [1,100000] { f(a) = f(a) + f(b); }và bây giờ điều này trông giống như một tối ưu hóa nơi chúng tôi chỉ có thể coi nó là chức năng tương tự.


Tóm tắt với Tương tự

Với những gì chúng ta đã thấy trong trường hợp thứ hai, nó gần như xuất hiện như thể có tối ưu hóa vì cả hai vòng lặp đều có cùng chữ ký chính xác, nhưng đây không phải là vấn đề thực sự. Vấn đề không phải là công việc đang được thực hiện bằng cách f(a), f(b), f(c), và f(d). Trong cả hai trường hợp và so sánh giữa hai trường hợp, đó là sự khác biệt về khoảng cách mà Tổng kết phải đi trong mỗi trường hợp mang lại cho bạn sự khác biệt về thời gian thực hiện.

Hãy nghĩ về For Loopsnhư là Summationsmà không được lặp đi lặp lại như là một Bossmà ra lệnh cho hai người A& Bvà rằng công việc của họ là để thịt C& Dtương ứng và chọn một vài gói từ họ và gửi lại. Trong sự tương tự này, các vòng lặp for hoặc lặp tổng và kiểm tra điều kiện tự chúng không thực sự đại diện cho Boss. Những gì thực sự đại diện Bosskhông phải là từ các thuật toán toán học thực tế mà là từ khái niệm thực tế ScopeCode Blocktrong một chương trình con hoặc chương trình con, phương thức, hàm, đơn vị dịch thuật, v.v. Thuật toán đầu tiên có 1 phạm vi trong đó thuật toán thứ 2 có 2 phạm vi liên tiếp.

Trong trường hợp đầu tiên trên mỗi phiếu gọi, Bosschuyển đến Avà đưa ra lệnh và Atắt để tìm nạp B'sgói, sau đó Bosschuyển đến Cvà đưa ra các lệnh để thực hiện tương tự và nhận gói từ Dmỗi lần lặp.

Trong trường hợp thứ hai, các Bosscông việc trực tiếp với Ađi và tìm nạp B'sgói cho đến khi tất cả các gói được nhận. Sau đó, các Bosscông việc Cđể làm tương tự để có được tất cả các D'sgói.

Vì chúng tôi đang làm việc với một con trỏ 8 byte và xử lý phân bổ heap, hãy xem xét vấn đề sau. Hãy nói rằng Boss100 feet từ AA500 feet từ C. Chúng ta không cần phải lo lắng về việc Bossban đầu từ bao xa Cvì thứ tự thực hiện. Trong cả hai trường hợp, Bossban đầu đi từ Ađầu tiên sau đó đến B. Sự tương tự này không phải để nói rằng khoảng cách này là chính xác; nó chỉ là một kịch bản trường hợp thử nghiệm hữu ích để hiển thị hoạt động của các thuật toán.

Trong nhiều trường hợp khi thực hiện phân bổ heap và làm việc với các tệp bộ đệm và trang, các khoảng cách giữa các vị trí địa chỉ có thể không thay đổi nhiều hoặc chúng có thể thay đổi đáng kể tùy thuộc vào bản chất của các loại dữ liệu và kích thước mảng.


Các trường hợp thử nghiệm:

Trường hợp đầu tiên: Trong lần lặp đầu tiên,Bossban đầu phải đi 100 feet để đưa ra lệnh trượtAAtắt và thực hiện công việc của mình, nhưng sau đóBossphải đi 500 feetCđể đưa cho anh ta phiếu trượt. Sau đó, ở lần lặp tiếp theo và mỗi lần lặp khác sau khiBossphải quay đi quay lại 500 feet giữa hai lần.

Thứ hai trường hợp: CácBosscó để đi du lịch 100 feet trên phiên đầu tiên đểA, nhưng sau đó, ông đã có và chỉ cần chờ đợiAđể có được trở lại cho đến khi tất cả phiếu được lấp đầy. Sau đó,Bossphải di chuyển 500 feet trong lần lặp đầu tiên đếnCvì cáchC500 feetA. Vì điều nàyBoss( Summation, For Loop )đang được gọi ngay sau khi làm việc vớiAanh ta, sau đó chỉ chờ ở đó như anh ta đã làmAcho đến khi tất cả cácC'sphiếu đặt hàng được thực hiện.


Sự khác biệt về khoảng cách di chuyển

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;    

So sánh các giá trị tùy ý

Chúng ta có thể dễ dàng thấy rằng 600 là ít hơn 10 triệu. Bây giờ, điều này không chính xác, bởi vì chúng tôi không biết sự khác biệt thực tế về khoảng cách giữa địa chỉ RAM hoặc từ Bộ nhớ cache hoặc Tệp trang mà mỗi cuộc gọi trên mỗi lần lặp sẽ do nhiều biến không nhìn thấy khác. Đây chỉ là một đánh giá về tình huống cần nhận thức và xem xét nó từ tình huống xấu nhất.

Từ những con số này, nó gần như sẽ xuất hiện như thể Thuật toán Một phải 99%chậm hơn Thuật toán Hai; Tuy nhiên, đây chỉ là Boss'smột phần hoặc trách nhiệm của các thuật toán và nó không chiếm người lao động thực tế A, B, C, & Dvà những gì họ phải làm trên mỗi và mỗi lần lặp của vòng lặp. Vì vậy, công việc của ông chủ chỉ chiếm khoảng 15 - 40% tổng số công việc đang làm. Phần lớn công việc được thực hiện thông qua các công nhân có tác động lớn hơn một chút đối với việc giữ tỷ lệ chênh lệch tốc độ ở mức khoảng 50-70%


Quan sát: - Sự khác biệt giữa hai thuật toán

Trong tình huống này, nó là cấu trúc của quá trình công việc đang được thực hiện. Nó cho thấy rằng Trường hợp 2 hiệu quả hơn từ cả tối ưu hóa một phần của việc có một khai báo và định nghĩa hàm tương tự trong đó chỉ có các biến khác nhau theo tên và khoảng cách di chuyển.

Chúng ta cũng thấy rằng tổng quãng đường di chuyển trong Trường hợp 1 xa hơn nhiều so với Trường hợp 2 và chúng ta có thể xem xét khoảng cách này đã di chuyển Yếu tố thời gian của chúng ta giữa hai thuật toán. Trường hợp 1 có nhiều việc phải làm hơn trường hợp 2 .

Điều này có thể quan sát được từ các bằng chứng của các ASMhướng dẫn đã được thể hiện trong cả hai trường hợp. Cùng với những gì đã được nêu về các trường hợp này, điều này không giải thích cho thực tế là trong trường hợp 1 , ông chủ sẽ phải chờ cả hai ACquay lại trước khi anh ta có thể quay lại Alần nữa cho mỗi lần lặp. Điều này cũng không giải thích cho thực tế rằng nếu Ahoặc Bmất một thời gian cực kỳ dài thì cả Boss(các) công nhân khác đều đang chờ để được thực thi.

Trong trường hợp 2, người duy nhất không hoạt động là Bosscho đến khi công nhân trở lại. Vì vậy, ngay cả điều này có tác động đến thuật toán.



Các câu hỏi sửa đổi của OP

EDIT: Câu hỏi hóa ra không liên quan, vì hành vi phụ thuộc rất nhiều vào kích thước của mảng (n) và bộ đệm CPU. Vì vậy, nếu có thêm sự quan tâm, tôi viết lại câu hỏi:

Bạn có thể cung cấp một số cái nhìn sâu sắc về các chi tiết dẫn đến các hành vi bộ đệm khác nhau như được minh họa bởi năm vùng trên biểu đồ sau?

Cũng có thể thú vị khi chỉ ra sự khác biệt giữa các kiến ​​trúc CPU / bộ đệm, bằng cách cung cấp một biểu đồ tương tự cho các CPU này.


Về những câu hỏi này

Như tôi đã chứng minh mà không nghi ngờ gì, có một vấn đề tiềm ẩn ngay cả trước khi Phần cứng và Phần mềm có liên quan.

Bây giờ đối với việc quản lý bộ nhớ và bộ nhớ đệm cùng với các tệp trang, v.v ... tất cả đều hoạt động cùng nhau trong một bộ hệ thống tích hợp giữa các mục sau:

  • The Architecture {Phần cứng, phần sụn, một số Trình điều khiển nhúng, Bộ hướng dẫn hạt nhân và ASM}.
  • The OS{Hệ thống quản lý tập tin và bộ nhớ, trình điều khiển và sổ đăng ký}.
  • The Compiler {Đơn vị dịch thuật và tối ưu hóa mã nguồn}.
  • Và ngay cả Source Codechính nó với (các) thuật toán đặc biệt của nó.

Chúng tôi đã có thể thấy rằng có một nút cổ chai mà đang xảy ra trong thuật toán đầu tiên trước khi chúng tôi thậm chí áp dụng nó vào máy tính bất kỳ với bất kỳ tùy ý Architecture, OSProgrammable Languageso với các thuật toán thứ hai. Đã tồn tại một vấn đề trước khi liên quan đến nội tại của một máy tính hiện đại.


Kết quả cuối cùng

Tuy nhiên; không phải để nói rằng những câu hỏi mới này không quan trọng bởi vì chính chúng và chúng thực sự đóng một vai trò. Chúng có tác động đến các quy trình và hiệu suất tổng thể và điều đó thể hiện rõ qua các biểu đồ và đánh giá khác nhau từ nhiều người đã đưa ra câu trả lời và hoặc nhận xét của họ.

Nếu bạn chú ý đến sự tương tự của Bossvà hai công nhân A& Bnhững người phải đi và lấy các gói từ C& Dtương ứng và xem xét các ký hiệu toán học của hai thuật toán được đề cập; bạn có thể nhìn thấy mà không có sự tham gia của các phần cứng máy tính và phần mềm Case 2là xấp xỉ 60%nhanh hơn Case 1.

Khi bạn nhìn vào biểu đồ và biểu đồ sau khi các thuật toán này được áp dụng cho một số mã nguồn, được biên dịch, tối ưu hóa và được thực thi thông qua HĐH để thực hiện các hoạt động của chúng trên một phần cứng nhất định, bạn thậm chí có thể thấy sự xuống cấp hơn một chút giữa các khác biệt trong các thuật toán này.

Nếu Databộ này khá nhỏ, ban đầu nó có vẻ không tệ lắm. Tuy nhiên, vì Case 1là về 60 - 70%chậm hơn so với Case 2chúng ta có thể nhìn vào sự phát triển của chức năng này về sự khác biệt trong hành thời gian:

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)

Giá trị gần đúng này là sự khác biệt trung bình giữa hai vòng lặp cả về hoạt động của thuật toán và máy liên quan đến tối ưu hóa phần mềm và hướng dẫn máy.

Khi tập dữ liệu phát triển tuyến tính, sự khác biệt về thời gian giữa hai dữ liệu. Thuật toán 1 có nhiều lần tìm nạp hơn thuật toán 2, điều này thể hiện rõ khi Bossphải di chuyển qua lại khoảng cách tối đa giữa A& Ccho mỗi lần lặp sau lần lặp đầu tiên trong khi Thuật toán 2 Bossphải di chuyển đến Amột lần và sau khi thực hiện xong A. một khoảng cách tối đa chỉ một lần khi đi từ Ađến C.

Cố gắng Bosstập trung làm hai việc tương tự cùng một lúc và tung hứng chúng qua lại thay vì tập trung vào các nhiệm vụ liên tiếp tương tự sẽ khiến anh ấy khá tức giận vào cuối ngày kể từ khi anh ấy phải đi lại và làm việc gấp đôi. Do đó, đừng để mất phạm vi của tình huống bằng cách để sếp của bạn rơi vào tình trạng tắc nghẽn nội suy vì vợ / chồng và con cái của sếp sẽ không đánh giá cao điều đó.



Sửa đổi: Nguyên tắc thiết kế kỹ thuật phần mềm

- Sự khác biệt giữa Local StackHeap Allocatedtính toán trong vòng lặp cho các vòng lặp và sự khác biệt giữa công dụng, hiệu quả và hiệu quả của chúng -

Thuật toán toán học mà tôi đề xuất ở trên chủ yếu áp dụng cho các vòng lặp thực hiện các hoạt động trên dữ liệu được phân bổ trên heap.

  • Hoạt động ngăn xếp liên tiếp:
    • Nếu các vòng lặp đang thực hiện các thao tác trên dữ liệu cục bộ trong một khối mã hoặc phạm vi trong khung ngăn xếp thì nó vẫn sẽ được áp dụng, nhưng các vị trí bộ nhớ gần hơn nhiều trong đó chúng thường tuần tự và chênh lệch về thời gian di chuyển hoặc thời gian thực hiện gần như không đáng kể Vì không có phân bổ nào được thực hiện trong heap, bộ nhớ không bị phân tán và bộ nhớ sẽ không được tải qua ram. Bộ nhớ thường tuần tự và liên quan đến khung ngăn xếp và con trỏ ngăn xếp.
    • Khi các hoạt động liên tiếp đang được thực hiện trên ngăn xếp, Bộ xử lý hiện đại sẽ lưu trữ các giá trị lặp lại và địa chỉ giữ các giá trị này trong các thanh ghi bộ đệm cục bộ. Thời gian hoạt động hoặc hướng dẫn ở đây là theo thứ tự nano giây.
  • Các hoạt động phân bổ liên tiếp của Heap:
    • Khi bạn bắt đầu áp dụng phân bổ heap và bộ xử lý phải tìm nạp các địa chỉ bộ nhớ trong các cuộc gọi liên tiếp, tùy thuộc vào kiến ​​trúc của CPU, Bộ điều khiển Bus và các mô-đun Ram, thời gian hoạt động hoặc thực thi có thể theo thứ tự micro mili giây. So với các hoạt động ngăn xếp được lưu trữ, chúng khá chậm.
    • CPU sẽ phải tìm nạp địa chỉ bộ nhớ từ Ram và thông thường mọi thứ trên bus hệ thống đều chậm so với đường dẫn dữ liệu bên trong hoặc bus dữ liệu trong chính CPU.

Vì vậy, khi bạn đang làm việc với dữ liệu cần có trong heap và bạn đang duyệt qua chúng trong các vòng lặp, sẽ hiệu quả hơn khi giữ mỗi bộ dữ liệu và các thuật toán tương ứng của nó trong vòng lặp riêng của nó. Bạn sẽ có được sự tối ưu hóa tốt hơn so với việc cố gắng tạo ra các vòng lặp liên tiếp bằng cách đặt nhiều hoạt động của các tập dữ liệu khác nhau trên heap vào một vòng lặp.

Bạn có thể thực hiện việc này với dữ liệu trên ngăn xếp vì chúng thường được lưu trong bộ nhớ cache, nhưng không phải đối với dữ liệu phải có địa chỉ bộ nhớ của nó truy vấn mỗi lần lặp.

Đây là lúc Kỹ thuật phần mềm và Thiết kế kiến ​​trúc phần mềm phát huy tác dụng. Đó là khả năng biết cách sắp xếp dữ liệu của bạn, biết khi nào nên lưu trữ dữ liệu của bạn, biết khi nào phân bổ dữ liệu của bạn trên heap, biết cách thiết kế và thực hiện các thuật toán của bạn và biết khi nào và ở đâu để gọi chúng.

Bạn có thể có cùng một thuật toán liên quan đến cùng một tập dữ liệu, nhưng bạn có thể muốn một thiết kế triển khai cho biến thể ngăn xếp của nó và một thuật toán khác cho biến thể được phân bổ heap của nó chỉ vì vấn đề nêu trên do sự O(n)phức tạp của thuật toán khi làm việc với đống.

Từ những gì tôi nhận thấy trong nhiều năm qua, nhiều người không xem xét thực tế này. Họ sẽ có xu hướng thiết kế một thuật toán hoạt động trên một tập dữ liệu cụ thể và họ sẽ sử dụng nó bất kể tập dữ liệu nào được lưu trữ cục bộ trên ngăn xếp hoặc nếu nó được phân bổ trên heap.

Nếu bạn muốn tối ưu hóa thực sự, vâng, nó có vẻ giống như sao chép mã, nhưng để khái quát hóa, sẽ có hiệu quả hơn khi có hai biến thể của cùng một thuật toán. Một cho các hoạt động ngăn xếp, và một cho các hoạt động heap được thực hiện trong các vòng lặp!

Đây là một ví dụ giả: Hai cấu trúc đơn giản, một thuật toá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.

Đây là những gì tôi đã đề cập bằng cách có các triển khai riêng cho các biến thể ngăn xếp so với các biến thể heap. Các thuật toán tự nó không quan trọng quá nhiều, đó là các cấu trúc lặp mà bạn sẽ sử dụng chúng trong đó.


Đã được một thời gian kể từ khi tôi đăng câu trả lời này, nhưng tôi cũng muốn thêm một nhận xét nhanh cũng có thể giúp hiểu điều này: Trong tương tự của tôi với Boss là vòng lặp for hoặc tổng kết hoặc lặp qua một vòng lặp, chúng ta cũng có thể coi ông chủ này là sự kết hợp giữa Stack Frame & Stack Pulum quản lý phạm vi và các biến stack và địa chỉ bộ nhớ của các vòng lặp for.
Francis Cugler

@PeterMortensen Tôi đã cân nhắc lời khuyên của bạn bằng cách sửa đổi một chút câu trả lời ban đầu của tôi. Tôi tin rằng đây là những gì bạn đã đề nghị.
Francis Cugler

2

Nó có thể là C ++ cũ và tối ưu hóa. Trên máy tính của tôi, tôi đạt được tốc độ gần như nhau:

Một vòng lặp: 1.577 ms

Hai vòng: 1,506 ms

Tôi chạy Visual Studio 2015 trên bộ xử lý E5-1620 3.5 GHz với RAM 16 GB.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.