В C , почему я нахожу, что доступ к кучной памяти происходит быстрее, чем доступ к стеку

#c #stack #heap-memory #mmu

Вопрос:

Я играл с профилировщиком на C и заметил кое-что действительно странное: запись в память кучи каким-то образом быстрее, чем в память стека?!

Вот фрагмент, который я запустил

 #include <iostream>       // std::cout
#include <chrono>

using namespace std;
const int size = 1000000;
void incStack(){
    int seconds = 0;
    for(int i = 0; i < size; i  ){
        seconds  ;
    }
}

void incHeap(){
    int* seconds = new int(0);
    for(int i = 0; i < size; i  ){
        (*seconds)  ;
    }
}

void testCycles(void (*func)(), string funcName){
    int total = 0;
    int count = 0;
    for(int i = 0; i < 20; i  ){
        clock_t t = clock();
        func();
        t = clock() - t;
        // we move the function to the cache on the first call which can possibly give
        // us overhead, so we'll ignore the first call
        if(i != 1){
            total = t;
            count  ;
        }
    }
    cout << funcName << " cycles: " << total/count << endl;
}

int main() {
    testCycles(incStack, "incStack");
    testCycles(incHeap, "incHeap");
    return 0;
}
 

с выходом

 incStack cycles: 1997
incHeap cycles: 1487
 

что довольно важно! Я понимаю, что, поскольку я обращаюсь только к одной части памяти в куче, она, вероятно, всегда будет в кэше. Если бы я обращался ко многим частям кучи, я предполагаю, что производительность снизилась бы из-за всех перемещений страниц в кэш, которые я бы перенес, потому что память, скорее всего, будет фрагментирована в куче. Но, игнорируя все это, куча должна иметь по крайней мере два доступа к памяти (один для адреса и один для значения адреса), что должно занимать больше времени, чем стек.

Но доступ к куче происходит быстрее, когда я запускаю этот код, так что я что-то упускаю в C ? Я попробовал скомпилировать несколько компиляторов, переключил оптимизацию и получил аналогичные результаты. Я действительно не понимаю, почему это может быть.

Комментарии:

1. Первый вопрос: Это полностью оптимизированная сборка, которую вы тестируете? Также стоит отметить, что сравнительный анализ в течение нескольких наносекунд-не очень хороший показатель. Вам действительно нужно разобраться в этой вещи, например, протестировать не менее 60 секунд на температуру процессора и эффекты «турбо», чтобы успокоиться. Вам также нужно протестировать их в случайном порядке, так как первый из них может показаться медленным по разным причинам.

2. Разыменование указателя будет более болезненным, чем прямое увеличение. Проверьте это с помощью адреса переменной стека для более справедливого сравнения.

3. Вы проверили сгенерированную сборку? Вы подтвердили, что компилятор сохранил вызовы clock() вокруг тестируемой функции, а не изменил порядок этих бесплатных вызовов с побочными эффектами? Вы проверили, что компилятор не полностью удалил эти бесплатные вызовы с побочными эффектами?

4. Этот код даже не компилируется для меня. Где это size определено? Когда total и count когда можно настроиться на что-то другое, кроме 0 ? Каков фактический код, который вы запустили для выполнения этого теста?

5. Если я сделаю несколько небольших изменений в этом коде , чтобы он скомпилировался для меня и использовался std::chrono::high_resolution_clock вместо стиля C clock_t , то в O3 я получу, что версия стека значительно быстрее . На моем рабочем столе, использующем MSVC, разница составляет ~10% в пользу стека при отладке, на несколько порядков в пользу стека при выпуске.

Ответ №1:

Как правило, память-это память, процессор на самом деле не различает. Однако он заботится о том, как осуществляется доступ к этой памяти. В C для этого у вас есть три инструмента: значения, указатели и ссылки.

Прямые значения всегда будут самыми быстрыми. Они могут вообще не храниться в памяти, а регистрироваться и сохраняться в памяти только в том случае, если/когда это необходимо. Увеличение значения в регистре будет очень быстрым.

Косвенные значения, такие как указатели, предполагают совершенно другой вид доступа. Они не могут храниться в регистрах, они требуют манипулирования значением, внешним по отношению к процессору (ядру), надеюсь, в каком-нибудь кэше, таком как L1. Попав в кэш, он не будет таким медленным, как основная память, но всегда будет медленнее, чем регистр.

Ссылки являются своего рода псевдонимами для обычных значений, поэтому они также должны работать, но это зависит от того, насколько оптимизирован ваш код при его компиляции. При сильно оптимизированной сборке, подобной -O3 или эквивалентной, они часто могут перемещаться в регистрах, вместо того чтобы когда-либо фактически попадать в память.