Почему моя программа работает медленно, когда зацикливается ровно на 8192 элемента?

678

Вот выдержка из рассматриваемой программы. Матрица img[][] имеет размер SIZE × SIZE и инициализируется по адресу:

img[j][i] = 2 * j + i

Затем вы создаете матрицу res[][], и каждое поле здесь составляет среднее из 9 полей вокруг него в матрице img. Для простоты граница оставлена ​​на 0.

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

Что там все в программе. Для полноты, вот что было раньше. После этого код не появляется. Как вы можете видеть, это просто инициализация.

#define SIZE 8192
float img[SIZE][SIZE]; // input image
float res[SIZE][SIZE]; //result of mean filter
int i,j,k,l;
for(i=0;i<SIZE;i++) 
    for(j=0;j<SIZE;j++) 
        img[j][i] = (2*j+i)%8196;

В основном, эта программа медленна, когда SIZE кратно 2048, например. время выполнения:

SIZE = 8191: 3.44 secs
SIZE = 8192: 7.20 secs
SIZE = 8193: 3.18 secs

Компилятор - это GCC. Из того, что я знаю, это из-за управления памятью, но я действительно не знаю слишком много об этом предмете, поэтому я спрашиваю здесь.

Также, как исправить это было бы хорошо, но если бы кто-то мог объяснить эти времена выполнения, я уже был бы достаточно счастлив.

Я уже знаю malloc/free, но проблема заключается не в количестве используемой памяти, а просто в времени выполнения, поэтому я не знаю, как это могло бы помочь.

  • 0
    Вы уверены, что проблема вызвана кодом, который вы разместили? Где еще i и j использовали?
  • 0
    Я не понимаю, как можно получить более быстрые результаты с 8193 вместо 8192. Вы пробовали это несколько раз?
Показать ещё 13 комментариев
Теги:
performance
gcc
memory-management

3 ответа

900
Лучший ответ

Разница вызвана тем же вопросом супер-выравнивания из следующих связанных вопросов:

Но это только потому, что есть еще одна проблема с кодом.

Начиная с исходного цикла:

for(i=1;i<SIZE-1;i++) 
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        for(k=-1;k<2;k++) 
            for(l=-1;l<2;l++) 
                res[j][i] += img[j+l][i+k];
        res[j][i] /= 9;
}

Сначала заметим, что две внутренние петли тривиальны. Их можно развернуть следующим образом:

for(i=1;i<SIZE-1;i++) {
    for(j=1;j<SIZE-1;j++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

Таким образом, мы оставляем две внешние петли, которые нас интересуют.

Теперь мы видим, что в этом вопросе проблема одинакова: Почему порядок циклов влияет на производительность при повторении по двумерному массиву?

Вы выполняете итерацию по столбцам по столбцам, а не по строкам.


Чтобы решить эту проблему, вы должны обменивать две петли.

for(j=1;j<SIZE-1;j++) {
    for(i=1;i<SIZE-1;i++) {
        res[j][i]=0;
        res[j][i] += img[j-1][i-1];
        res[j][i] += img[j  ][i-1];
        res[j][i] += img[j+1][i-1];
        res[j][i] += img[j-1][i  ];
        res[j][i] += img[j  ][i  ];
        res[j][i] += img[j+1][i  ];
        res[j][i] += img[j-1][i+1];
        res[j][i] += img[j  ][i+1];
        res[j][i] += img[j+1][i+1];
        res[j][i] /= 9;
    }
}

Это полностью исключает весь непоследовательный доступ, так что вы больше не получаете случайные замедления на больших мощностях.


Core i7 920 @3,5 ГГц

Исходный код:

8191: 1.499 seconds
8192: 2.122 seconds
8193: 1.582 seconds

Перестроенные внешние петли:

8191: 0.376 seconds
8192: 0.357 seconds
8193: 0.351 seconds
  • 6
    Теперь, когда я снова смотрю на код, он выглядит как операция размытия изображения. Усредняет интенсивность с соседними клетками.
  • 199
    Также отмечу, что развертывание внутренних циклов никак не влияет на производительность. Компилятор, вероятно, делает это автоматически. Я развернул их с единственной целью - избавиться от них, чтобы было легче обнаружить проблему с внешними петлями.
Показать ещё 18 комментариев
54

Следующие тесты были выполнены с помощью компилятора Visual С++, так как он используется установкой Qt Creator по умолчанию (думаю, без флага оптимизации). При использовании GCC нет большой разницы между мистической версией и моим "оптимизированным" кодом. Таким образом, вывод заключается в том, что оптимизация компилятора лучше заботится о микро оптимизации, чем люди (я наконец). Я оставляю остальную часть своего ответа для справки.


Неэффективно обрабатывать изображения таким образом. Лучше использовать одномерные массивы. Обработка всех пикселей выполняется в одном цикле. Случайный доступ к точкам можно выполнить, используя:

pointer + (x + y*width)*(sizeOfOnePixel)

В этом конкретном случае лучше вычислить и кешировать сумму трех пикселей групп по горизонтали, потому что они используются три раза каждый.

Я сделал несколько тестов, и я думаю, что это стоит того. Каждый результат составляет в среднем пять тестов.

Оригинальный код пользователя1615209:

8193: 4392 ms
8192: 9570 ms

Мистическая версия:

8193: 2393 ms
8192: 2190 ms

Два прохода с использованием массива 1D: первый проход для горизонтальных сумм, второй для вертикальной суммы и среднего. Двухпроходная адресация с тремя указателями и только приращения:

imgPointer1 = &avg1[0][0];
imgPointer2 = &avg1[0][SIZE];
imgPointer3 = &avg1[0][SIZE+SIZE];

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(*(imgPointer1++)+*(imgPointer2++)+*(imgPointer3++))/9;
}

8193: 938 ms
8192: 974 ms

Два прохода с использованием 1D-массива и адресация следующим образом:

for(i=SIZE;i<totalSize-SIZE;i++){
    resPointer[i]=(hsumPointer[i-SIZE]+hsumPointer[i]+hsumPointer[i+SIZE])/9;
}

8193: 932 ms
8192: 925 ms

Однократное кеширование горизонтальных сумм только на одну строку вперед, поэтому они остаются в кеше:

// Horizontal sums for the first two lines
for(i=1;i<SIZE*2;i++){
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
}
// Rest of the computation
for(;i<totalSize;i++){
    // Compute horizontal sum for next line
    hsumPointer[i]=imgPointer[i-1]+imgPointer[i]+imgPointer[i+1];
    // Final result
    resPointer[i-SIZE]=(hsumPointer[i-SIZE-SIZE]+hsumPointer[i-SIZE]+hsumPointer[i])/9;
}

8193: 599 ms
8192: 652 ms

Вывод:

  • Невозможно использовать несколько указателей и просто увеличить (я думал, что это было бы быстрее)
  • Кэширование горизонтальных сумм лучше, чем вычисление их несколько раз.
  • Два прохода не три раза быстрее, а два раза.
  • Достичь в 3,6 раза быстрее, используя как один проход, так и кеширование промежуточного результата.

Я уверен, что это можно сделать намного лучше.

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

  • 9
    «Я думаю, что это по крайней мере в 3 раза быстрее» - хотите подкрепить это утверждение некоторыми показателями или цитатами?
  • 7
    @AdamRosenfield "Я думаю" = предположение! = "Это" = претензия. У меня нет метрики для этого, и я хотел бы увидеть тест. Но мой требует 7 приращений, 2 sub, 2 add и один div на пиксель. Каждый цикл использует меньше локальных переменных, чем регистр в CPU. Другие требуют 7 приращений, 6 приращений, 1 деление и от 10 до 20 муль для адресации в зависимости от оптимизации компилятора. Также каждая инструкция в цикле требует результата предыдущей инструкции, что исключает преимущества суперскалярной архитектуры Pentiums. Так должно быть быстрее.
Показать ещё 6 комментариев
0

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

Стоимость до: 9 читать, 9 сложение, 1 деление Стоимость после: 3 чтения, 3 сложения, 1 деление

Подумайте о окне выборки как поле 3x3, где вы будете отслеживать каждый столбец (1x3) отдельно. Накопите новый столбец и оставьте самый старый.

Разделение - это команда с высокой задержкой, поэтому может быть полезно скрыть задержку, но перед тем, как перейти туда, вывод компилятора должен быть проверен, если деление на константу отменено, и если разворачивание цикла (компилятором) уже делает некоторые компенсация задержки.

Но после самой резкой оптимизации правильного использования кеша это действительно незначительные вещи.

Ещё вопросы

Сообщество Overcoder
Наверх
Меню