Java у 8 разів швидше з масивами, ніж std :: vector в C ++. Що я зробив неправильно?


88

У мене є такий код Java з декількома великими масивами, які ніколи не змінюють свій розмір. Це працює на моєму комп’ютері за 1100 мс.

Я реалізував той самий код в C ++ і використовував std::vector.

Час реалізації С ++, який запускає точно такий самий код, становить 8800 мс на моєму комп'ютері. Що я зробив неправильно, так що це працює так повільно?

В основному код робить наступне:

for (int i = 0; i < numberOfCells; ++i) {
        h[i] =  h[i] + 1;
        floodedCells[i] =  !floodedCells[i];
        floodedCellsTimeInterval[i] =  !floodedCellsTimeInterval[i];
        qInflow[i] =  qInflow[i] + 1;
}

Він перебирає різні масиви розміром близько 20000.

Ви можете знайти обидві реалізації за такими посиланнями:

(На Ideone я міг запустити цикл лише 400 разів, а не 2000 разів через обмеження часу. Але навіть тут різниця тричі)


42
std::vector<bool>використовує один біт на елемент, щоб заощадити простір, що призводить до великої кількості змін бітів. Якщо ви хочете швидкості, вам слід триматися подалі від неї. Використовуйте std::vector<int>замість цього.
молбдніло

44
@molbdnilo Або std :: vector <char>. Там немає необхідності витрачати що багато ;-)
стефанівської

7
Досить смішно. Версія С ++ швидша, коли кількість комірок дорівнює 200. Місце кешування?
Капітан Жираф

9
Частина II: Вам було б набагато краще створити окремий клас / структуру, яка містить по одному з кожного члена масивів, а потім мати єдиний масив об’єктів цієї структури, оскільки тоді ви фактично ітераціюєте через пам’ять лише один раз, у один напрямок.
Timo Geusch

9
@TimoGeusch: Хоча я думаю, що h[i] += 1;(чи ще краще) ++h[i]є більш читабельним, ніж h[i] = h[i] + 1;, я був би здивований, побачивши якусь істотну різницю в швидкості між ними. Компілятор може "зрозуміти", що вони обидва роблять одне і те ж, і генерувати однаковий код у будь-який спосіб (принаймні в найпоширеніших випадках).
Джеррі Коффін

Відповіді:


36

Ось версія С ++ із даними по вузлу, зібраними у структуру, та єдиним вектором цієї структури:

#include <vector>
#include <cmath>
#include <iostream>



class FloodIsolation {
public:
  FloodIsolation() :
      numberOfCells(20000),
      data(numberOfCells)
  {
  }
  ~FloodIsolation(){
  }

  void isUpdateNeeded() {
    for (int i = 0; i < numberOfCells; ++i) {
       data[i].h = data[i].h + 1;
       data[i].floodedCells = !data[i].floodedCells;
       data[i].floodedCellsTimeInterval = !data[i].floodedCellsTimeInterval;
       data[i].qInflow = data[i].qInflow + 1;
       data[i].qStartTime = data[i].qStartTime + 1;
       data[i].qEndTime = data[i].qEndTime + 1;
       data[i].lowerFloorCells = data[i].lowerFloorCells + 1;
       data[i].cellLocationX = data[i].cellLocationX + 1;
       data[i].cellLocationY = data[i].cellLocationY + 1;
       data[i].cellLocationZ = data[i].cellLocationZ + 1;
       data[i].levelOfCell = data[i].levelOfCell + 1;
       data[i].valueOfCellIds = data[i].valueOfCellIds + 1;
       data[i].h0 = data[i].h0 + 1;
       data[i].vU = data[i].vU + 1;
       data[i].vV = data[i].vV + 1;
       data[i].vUh = data[i].vUh + 1;
       data[i].vVh = data[i].vVh + 1;
       data[i].vUh0 = data[i].vUh0 + 1;
       data[i].vVh0 = data[i].vVh0 + 1;
       data[i].ghh = data[i].ghh + 1;
       data[i].sfx = data[i].sfx + 1;
       data[i].sfy = data[i].sfy + 1;
       data[i].qIn = data[i].qIn + 1;


      for(int j = 0; j < nEdges; ++j) {
        data[i].flagInterface[j] = !data[i].flagInterface[j];
        data[i].typeInterface[j] = data[i].typeInterface[j] + 1;
        data[i].neighborIds[j] = data[i].neighborIds[j] + 1;
      }
    }

  }

private:

  const int numberOfCells;
  static const int nEdges = 6;
  struct data_t {
    bool floodedCells = 0;
    bool floodedCellsTimeInterval = 0;

    double valueOfCellIds = 0;
    double h = 0;

    double h0 = 0;
    double vU = 0;
    double vV = 0;
    double vUh = 0;
    double vVh = 0;
    double vUh0 = 0;
    double vVh0 = 0;
    double ghh = 0;
    double sfx = 0;
    double sfy = 0;
    double qInflow = 0;
    double qStartTime = 0;
    double qEndTime = 0;
    double qIn = 0;
    double nx = 0;
    double ny = 0;
    double floorLevels = 0;
    int lowerFloorCells = 0;
    bool floorCompleteleyFilled = 0;
    double cellLocationX = 0;
    double cellLocationY = 0;
    double cellLocationZ = 0;
    int levelOfCell = 0;
    bool flagInterface[nEdges] = {};
    int typeInterface[nEdges] = {};
    int neighborIds[nEdges] = {};
  };
  std::vector<data_t> data;

};

int main() {
  std::ios_base::sync_with_stdio(false);
  FloodIsolation isolation;
  clock_t start = clock();
  for (int i = 0; i < 400; ++i) {
    if(i % 100 == 0) {
      std::cout << i << "\n";
    }
    isolation.isUpdateNeeded();
  }
  clock_t stop = clock();
  std::cout << "Time: " << difftime(stop, start) / 1000 << "\n";
}

живий приклад

Зараз час удвічі більший за швидкість версії Java. (846 проти 1631).

Швидше за все, JIT помітив спалення кешу доступу до даних всюди і перетворив ваш код у логічно подібний, але більш ефективний порядок.

Я також вимкнув синхронізацію stdio, оскільки це потрібно, лише якщо ви змішуєте printf/ scanfз C ++ std::coutта std::cin. Як правило, ви роздруковуєте лише декілька значень, але поведінка C ++ за замовчуванням надто параноїдна та неефективна.

Якщо nEdgesце не фактичне постійне значення, тоді 3 значення "масиву" доведеться вилучити з struct. Це не повинно спричинити величезних показників продуктивності.

Можливо, ви зможете отримати ще один приріст продуктивності, відсортувавши значення за цим struct, зменшивши розмір, зменшивши таким чином розмір пам'яті (і сортуючи доступ також, коли це не має значення). Але я не впевнений.

Емпіричне правило полягає в тому, що пропуск однієї кеш-пам'яті в 100 разів дорожчий за інструкцію. Впорядкування даних для забезпечення узгодженості кеш-пам’яті має велике значення.

Якщо перегрупування даних у a structнездійсненне, ви можете змінити ітерацію по черзі на кожен контейнер.

Окрім того, зверніть увагу, що версії Java та C ++ мали деякі тонкі відмінності в них. Я помітив, що у версії Java є 3 змінні в циклі "для кожного краю", тоді як у версії C ++ було лише 2. Я змусив свою відповідати Java. Не знаю, чи є інші.


44

Так, кеш у версії c ++ вимагає забивання. Здається, JIT краще підготовлений для цього.

Якщо ви зміните зовнішній forфайл у isUpdateNeeded () на коротший фрагмент. Різниця зникає.

Наведений нижче зразок забезпечує чотирикратне прискорення.

void isUpdateNeeded() {
    for (int i = 0; i < numberOfCells; ++i) {
        h[i] =  h[i] + 1;
        floodedCells[i] =  !floodedCells[i];
        floodedCellsTimeInterval[i] =  !floodedCellsTimeInterval[i];
        qInflow[i] =  qInflow[i] + 1;
        qStartTime[i] =  qStartTime[i] + 1;
        qEndTime[i] =  qEndTime[i] + 1;
    }

    for (int i = 0; i < numberOfCells; ++i) {
        lowerFloorCells[i] =  lowerFloorCells[i] + 1;
        cellLocationX[i] =  cellLocationX[i] + 1;
        cellLocationY[i] =  cellLocationY[i] + 1;
        cellLocationZ[i] =  cellLocationZ[i] + 1;
        levelOfCell[i] =  levelOfCell[i] + 1;
        valueOfCellIds[i] =  valueOfCellIds[i] + 1;
        h0[i] =  h0[i] + 1;
        vU[i] =  vU[i] + 1;
        vV[i] =  vV[i] + 1;
        vUh[i] =  vUh[i] + 1;
        vVh[i] =  vVh[i] + 1;
    }
    for (int i = 0; i < numberOfCells; ++i) {
        vUh0[i] =  vUh0[i] + 1;
        vVh0[i] =  vVh0[i] + 1;
        ghh[i] =  ghh[i] + 1;
        sfx[i] =  sfx[i] + 1;
        sfy[i] =  sfy[i] + 1;
        qIn[i] =  qIn[i] + 1;
        for(int j = 0; j < nEdges; ++j) {
            neighborIds[i * nEdges + j] = neighborIds[i * nEdges + j] + 1;
        }
        for(int j = 0; j < nEdges; ++j) {
            typeInterface[i * nEdges + j] = typeInterface[i * nEdges + j] + 1;
        }
    }

}

Це в достатній мірі показує, що пропуски кешу є причиною уповільнення. Важливо також зазначити, що змінні не залежать, тому різьбове рішення легко створюється.

Порядок відновлений

Відповідно до коментаря stefans, я спробував згрупувати їх у структуру, використовуючи оригінальні розміри. Це усуває негайний тиск у кеші подібним чином. В результаті версія c ++ (CCFLAG -O3) приблизно на 15% швидша, ніж версія Java.

Варнінг ні короткий, ні гарний.

#include <vector>
#include <cmath>
#include <iostream>
 
 
 
class FloodIsolation {
    struct item{
      char floodedCells;
      char floodedCellsTimeInterval;
      double valueOfCellIds;
      double h;
      double h0;
      double vU;
      double vV;
      double vUh;
      double vVh;
      double vUh0;
      double vVh0;
      double sfx;
      double sfy;
      double qInflow;
      double qStartTime;
      double qEndTime;
      double qIn;
      double nx;
      double ny;
      double ghh;
      double floorLevels;
      int lowerFloorCells;
      char flagInterface;
      char floorCompletelyFilled;
      double cellLocationX;
      double cellLocationY;
      double cellLocationZ;
      int levelOfCell;
    };
    struct inner_item{
      int typeInterface;
      int neighborIds;
    };

    std::vector<inner_item> inner_data;
    std::vector<item> data;

public:
    FloodIsolation() :
            numberOfCells(20000), inner_data(numberOfCells * nEdges), data(numberOfCells)
   {

    }
    ~FloodIsolation(){
    }
 
    void isUpdateNeeded() {
        for (int i = 0; i < numberOfCells; ++i) {
            data[i].h = data[i].h + 1;
            data[i].floodedCells = !data[i].floodedCells;
            data[i].floodedCellsTimeInterval = !data[i].floodedCellsTimeInterval;
            data[i].qInflow = data[i].qInflow + 1;
            data[i].qStartTime = data[i].qStartTime + 1;
            data[i].qEndTime = data[i].qEndTime + 1;
            data[i].lowerFloorCells = data[i].lowerFloorCells + 1;
            data[i].cellLocationX = data[i].cellLocationX + 1;
            data[i].cellLocationY = data[i].cellLocationY + 1;
            data[i].cellLocationZ = data[i].cellLocationZ + 1;
            data[i].levelOfCell = data[i].levelOfCell + 1;
            data[i].valueOfCellIds = data[i].valueOfCellIds + 1;
            data[i].h0 = data[i].h0 + 1;
            data[i].vU = data[i].vU + 1;
            data[i].vV = data[i].vV + 1;
            data[i].vUh = data[i].vUh + 1;
            data[i].vVh = data[i].vVh + 1;
            data[i].vUh0 = data[i].vUh0 + 1;
            data[i].vVh0 = data[i].vVh0 + 1;
            data[i].ghh = data[i].ghh + 1;
            data[i].sfx = data[i].sfx + 1;
            data[i].sfy = data[i].sfy + 1;
            data[i].qIn = data[i].qIn + 1;
            for(int j = 0; j < nEdges; ++j) {
                inner_data[i * nEdges + j].neighborIds = inner_data[i * nEdges + j].neighborIds + 1;
                inner_data[i * nEdges + j].typeInterface = inner_data[i * nEdges + j].typeInterface + 1;
            }
        }
 
    }
 
    static const int nEdges;
private:
 
    const int numberOfCells;

};
 
const int FloodIsolation::nEdges = 6;

int main() {
    FloodIsolation isolation;
    clock_t start = clock();
    for (int i = 0; i < 4400; ++i) {
        if(i % 100 == 0) {
            std::cout << i << "\n";
        }
        isolation.isUpdateNeeded();
    }

    clock_t stop = clock();
    std::cout << "Time: " << difftime(stop, start) / 1000 << "\n";
}
                                                                              

Мій результат дещо відрізняється від Джері Трун за оригінальними розмірами. Для мене відмінності залишаються. Це цілком може бути моя версія Java, 1.7.0_75.


12
Можливо, було б непогано згрупувати ці дані у структуру та мати лише один вектор
stefan

Ну, я на мобільному, тому не можу робити вимірювання ;-) але один вектор повинен бути хорошим (також з точки зору розподілу)
stefan

1
Чи ++допомагає використання будь-якої якості? x = x + 1здається жахливо незграбним порівняно з ++x.
tadman

3
Будь ласка, виправте неправильно написане слово "результат". Мене вбиває .. :)
fleetC0m

1
Якщо весь ітератор вміщується в одному регістрі, то створення копії може бути насправді швидшим у деяких випадках, ніж оновлення на місці. Якщо ви робите оновлення на місці, це тому, що ви, швидше за все, використовуєте оновлене значення відразу після цього. Отже, у вас є залежність Read-after-Write. Якщо ви оновлюєте, але вам потрібно лише старе значення, ці операції не залежать одна від одної, і процесор має більше місця для їх паралельного виконання, наприклад, на різних конвеєрах, збільшуючи ефективну IPC.
Piotr Kołaczkowski

20

Як здогадався @Stefan у коментарі до відповіді @ CaptainGiraffe, ви отримуєте досить багато, використовуючи вектор структур замість структури векторів. Виправлений код виглядає так:

#include <vector>
#include <cmath>
#include <iostream>
#include <time.h>

class FloodIsolation {
public:
    FloodIsolation() :
            h(0),
            floodedCells(0),
            floodedCellsTimeInterval(0),
            qInflow(0),
            qStartTime(0),
            qEndTime(0),
            lowerFloorCells(0),
            cellLocationX(0),
            cellLocationY(0),
            cellLocationZ(0),
            levelOfCell(0),
            valueOfCellIds(0),
            h0(0),
            vU(0),
            vV(0),
            vUh(0),
            vVh(0),
            vUh0(0),
            vVh0(0),
            ghh(0),
            sfx(0),
            sfy(0),
            qIn(0),
            typeInterface(nEdges, 0),
            neighborIds(nEdges, 0)
    {
    }

    ~FloodIsolation(){
    }

    void Update() {
        h =  h + 1;
        floodedCells =  !floodedCells;
        floodedCellsTimeInterval =  !floodedCellsTimeInterval;
        qInflow =  qInflow + 1;
        qStartTime =  qStartTime + 1;
        qEndTime =  qEndTime + 1;
        lowerFloorCells =  lowerFloorCells + 1;
        cellLocationX =  cellLocationX + 1;
        cellLocationY =  cellLocationY + 1;
        cellLocationZ =  cellLocationZ + 1;
        levelOfCell =  levelOfCell + 1;
        valueOfCellIds =  valueOfCellIds + 1;
        h0 =  h0 + 1;
        vU =  vU + 1;
        vV =  vV + 1;
        vUh =  vUh + 1;
        vVh =  vVh + 1;
        vUh0 =  vUh0 + 1;
        vVh0 =  vVh0 + 1;
        ghh =  ghh + 1;
        sfx =  sfx + 1;
        sfy =  sfy + 1;
        qIn =  qIn + 1;
        for(int j = 0; j < nEdges; ++j) {
            ++typeInterface[j];
            ++neighborIds[j];
        }       
    }

private:

    static const int nEdges = 6;
    bool floodedCells;
    bool floodedCellsTimeInterval;

    std::vector<int> neighborIds;
    double valueOfCellIds;
    double h;
    double h0;
    double vU;
    double vV;
    double vUh;
    double vVh;
    double vUh0;
    double vVh0;
    double ghh;
    double sfx;
    double sfy;
    double qInflow;
    double qStartTime;
    double qEndTime;
    double qIn;
    double nx;
    double ny;
    double floorLevels;
    int lowerFloorCells;
    bool flagInterface;
    std::vector<int> typeInterface;
    bool floorCompleteleyFilled;
    double cellLocationX;
    double cellLocationY;
    double cellLocationZ;
    int levelOfCell;
};

int main() {
    std::vector<FloodIsolation> isolation(20000);
    clock_t start = clock();
    for (int i = 0; i < 400; ++i) {
        if(i % 100 == 0) {
            std::cout << i << "\n";
        }

        for (auto &f : isolation)
            f.Update();
    }
    clock_t stop = clock();
    std::cout << "Time: " << difftime(stop, start) / 1000 << "\n";
}

Скомпільований із компілятором з VC ++ 2015 CTP, використовуючи -EHsc -O2b2 -GL -Qpar, я отримую такі результати, як:

0
100
200
300
Time: 0.135

Компіляція з g ++ дає трохи повільніший результат:

0
100
200
300
Time: 0.156

На тому ж обладнанні, використовуючи компілятор / JVM з Java 8u45, я отримую такі результати:

0
100
200
300
Time: 181

Це приблизно на 35% повільніше, ніж версія з VC ++, і приблизно на 16% повільніше, ніж версія з g ++.

Якщо ми збільшимо кількість ітерацій до бажаного 2000 року, різниця падає лише до 3%, припускаючи, що частиною переваги C ++ у цьому випадку є просто швидше завантаження (багаторічна проблема з Java), а не насправді у самому виконанні. У цьому випадку це не здається мені дивним - обчислення, що вимірюється (у розміщеному коді), є настільки тривіальним, що я сумніваюся, що більшість компіляторів можуть зробити багато для його оптимізації.


1
Ще є місце для вдосконалення, хоча це, швидше за все, суттєво не вплине на продуктивність: групування булевих змінних (загалом групування змінних того самого типу).
stefan

1
@stefan: Є, але я навмисно уникав будь-якої важкої оптимізації коду, а замість цього робив (приблизно) мінімум, необхідний для усунення найбільш очевидних проблем в оригінальній реалізації. Якби я дійсно хотів оптимізувати, я додав би #pragma omp, і (можливо) трохи роботи, щоб забезпечити незалежність кожної ітерації циклу. Для того, щоб отримати прискорення ~ Nx, де N - кількість доступних ядер процесора, це зайняло б досить мінімальну роботу.
Джеррі Коффін

Гарна думка. Цього цілком достатньо для відповіді на це запитання
Стефан

Як 181 одиниця часу на 35% повільніше, ніж 0,135 одиниці часу, і на 16% повільніше, ніж 0,156 одиниці часу? Ви мали на увазі, що тривалість версії Java становить 0,181?
jamesdlin

1
@jamesdlin: вони використовують різні одиниці (залишилося так, бо так було в оригіналі). Код С ++ дає час у секундах, але код Java - час у мілісекундах.
Джеррі Коффін

9

Я підозрюю, що це стосується виділення пам'яті.

Я думаю, що Javaзахоплює великий суміжний блок під час запуску програми, тоді як C++запитує у ОС біти і шматки, як це відбувається.

Щоб перевірити цю теорію, я зробив одну модифікацію C++версії, і вона раптом почала працювати трохи швидше, ніж Javaверсія:

int main() {
    {
        // grab a large chunk of contiguous memory and liberate it
        std::vector<double> alloc(20000 * 20);
    }
    FloodIsolation isolation;
    clock_t start = clock();
    for (int i = 0; i < 400; ++i) {
        if(i % 100 == 0) {
            std::cout << i << "\n";
        }
        isolation.isUpdateNeeded();
    }
    clock_t stop = clock();
    std::cout << "Time: " << (1000 * difftime(stop, start) / CLOCKS_PER_SEC) << "\n";
}

Час роботи без попереднього розподілу вектора:

0
100
200
300
Time: 1250.31

Час роботи з попередньо розподіленим вектором:

0
100
200
300
Time: 331.214

Час роботи Javaверсії:

0
100
200
300
Time: 407

Ну, ви не можете покладатися на це. Дані у, як FloodIsolationі раніше, можуть бути розподілені в іншому місці.
stefan

@stefan Все-таки цікавий результат.
Капітан Жираф

@CaptainGiraffe це, я не казав, що це марно ;-)
stefan

2
@stefan Я не пропоную це як рішення, просто досліджуючи, на мою думку, проблема. Здається, це може не мати нічого спільного з кешуванням, але чим C ++ RTS відрізняється від Java.
Галік

1
@Galik Це не завжди причина, хоча досить цікаво спостерігати, як це робить такий великий вплив на вашу платформу. На ideone я не можу відтворити ваш результат (як здається, виділений блок не використовується повторно): ideone.com/im4NMO Однак вектор рішення структур має більш послідовний вплив на продуктивність: ideone.com/b0VWSN
stefan
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.