косвенная стоимость ~ 3x умножения с плавающей запятой, правда? (с демо)

Я только что обнаружил, что стоимость косвенного обращения примерно в 3 раза превышает умножение числа с плавающей запятой!
Чего и следовало ожидать? Мой тест неверен?

Задний план

После того, как я прочитал Насколько косвенность указателя влияет на эффективность?, я стал паника по поводу косвенных затрат.

Прохождение косвенного указателя может быть намного медленнее из-за того, как работает современный процессор.

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

Я делаю некоторые трюки, чтобы найти грубое число (3x), как показано ниже:

Шаг 1

  • Test1: без косвенного обращения -> что-то вычислить
  • Test2: Косвенность -> что-то вычислить (то же самое)

Я обнаружил, что Test2 занимает больше времени, чем Test1.
Здесь нет ничего удивительного.

Шаг 2

  • Test1: без косвенных действий -> вычислить что-то дорогое
  • Test2 : Indirection -> вычислить что-то дешевое

Я пытаюсь постепенно изменить свой код в calculate something expensive, чтобы сделать его более дорогим, чтобы стоимость Test была примерно одинаковой.

Результат

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

  • Test1: без косвенного обращения -> возврат float*float*... 3 раза
  • Test2: Косвенность -> просто вернуть float

Вот мой тестовый пример (демонстрация ideone): -

class C{
    public: float hello;  
    public: float hello2s[10];  
    public: C(){
        hello=((double) rand() / (RAND_MAX))*10;
        for(int n=0;n<10;n++){
            hello2s[n]= ((double) rand() / (RAND_MAX))*10;
        }
    }
    public: float calculateCheap(){
        return hello;
    }
    public: float calculateExpensive(){
        float result=1;
        result=hello2s[0]*hello2s[1]*hello2s[2]*hello2s[3]*hello2s[4];
        return result;
    }
};

Вот основное:-

int main(){
    const int numTest=10000;
    C  d[numTest];
    C* e[numTest];
    for(int n=0;n<numTest;n++){
        d[n]=C();
        e[n]=new C();
    }
    float accu=0;
    auto t1= std::chrono::system_clock::now();
    for(int n=0;n<numTest;n++){
        accu+=d[n].calculateExpensive();  //direct call
    }
    auto t2= std::chrono::system_clock::now();
    for(int n=0;n<numTest;n++){
        accu+=e[n]->calculateCheap();     //indirect call
    }
    auto t3= std::chrono::system_clock::now();
    std::cout<<"direct call time ="<<(t2-t1).count()<<std::endl;
    std::cout<<"indirect call time ="<<(t3-t2).count()<<std::endl;
    std::cout<<"print to disable compiler cheat="<<accu<<std::endl;
}

Время прямого звонка и Время непрямого звонка настроены так, чтобы они были аналогичны указанным выше (через редактирование calculateExpensive).

Заключение

Косвенная стоимость = 3-кратное умножение с плавающей запятой.
На моем рабочем столе (Visual Studio 2015 с -O2) она равна 7-кратному.

Вопрос

Следует ли ожидать, что стоимость косвенного умножения примерно в 3 раза больше, чем умножение с плавающей запятой?
Если нет, то почему мой тест неверен?

(Спасибо enhzflep за предложение улучшения, оно отредактировано.)


person javaLover    schedule 25.05.2017    source источник
comment
Достаточно одного спецификатора доступа public:. Вы также теряете память с помощью этого оператора new. Если вы хотите использовать C++, я предлагаю вам забыть все, что вы знали о Java.   -  person    schedule 25.05.2017
comment
@Raw N Да, сэр! Но я чувствую себя как (java) дома таким образом. XD   -  person javaLover    schedule 25.05.2017
comment
@javaLover - не видя кода, сгенерированного компилятором, я бы не был так уверен, что вы на самом деле сравниваете стоимость 13 умножений со стоимостью косвенности. Большинство современных компиляторов достаточно умны, чтобы избежать генерации кода, который выполняет 13 операций умножения из предоставленного вами источника. Лучшим тестом было бы перемножение разных переменных, что заставит компилятор сгенерировать столько умножений, сколько содержит ваш исходный код. Например: a*b*c*d*e*f*g*h*i*j*k*l*m*n   -  person enhzflep    schedule 25.05.2017
comment
@enhzflep О, мой плохой. После того, как я улучшил его, он становится 3x-6x. (отредактировано-добавлено) Спасибо. На ваш взгляд, сейчас это разумное число?   -  person javaLover    schedule 25.05.2017
comment
Следует также отметить, что значительная часть ваших затрат будет связана не только с косвенностью, но, скорее всего, частично с фрагментацией памяти. Обратите внимание, что вы звоните new C() 100 000 раз. Это создаст 100 000 экземпляров C, разбросанных по всей вашей памяти. Выделение в виде массива (новый C[numTest]), скорее всего, приведет к совершенно другим результатам. Небольшое дополнение: такая инициализация C d[numTest] = {}; вызовет конструктор для каждого отдельного элемента.   -  person HeroicKatora    schedule 25.05.2017
comment
Возможный дубликат отсутствия кеша DRAM   -  person autistic    schedule 25.05.2017
comment
Мне кажется, вы могли бы поверить, что вся память находится в одной иерархии; вы можете подумать, что в стеке находится та же память, что и в куче... Обычно это не так. Ответ на этот вопрос не имеет ничего общего с косвенностью, а в основном связан с этой иерархией; Доступ к стеку обычно дешевле, чем доступ к куче, потому что стек с большей вероятностью будет находиться в кеше! Если я правильно, вы должны отказаться от этого убеждения в гомогенной иерархии и терминов stack и heap (они не нужны).   -  person autistic    schedule 25.05.2017
comment
@javaLover Я повысил свой комментарий до ответа. Кроме того, фрагментация обязательно означает большее количество промахов в кэше, поскольку она делает фактический шаблон доступа менее линейным и менее предсказуемым. Еще одно замечание: несколько очень маленьких выделений памяти приведут к более высокому использованию памяти, чем одно большое выделение. Это связано с тем, что в начале каждого блока могут храниться дополнительные данные, что еще больше ухудшает шаблоны доступа и локальность данных см. здесь   -  person HeroicKatora    schedule 25.05.2017
comment
Ваш вопрос не ясен. Какая из перечисленных выше функций выполняет косвенный вызов? Какой прямой вызов?? Что такое базовый случай? Я тоже не могу понять выходные числа в ваших ссылках   -  person WhiZTiM    schedule 25.05.2017
comment
@WhiZTiM Наконец-то я сделал это более ясным и кратким. Если это все еще неясно, пожалуйста, скажите мне. Благодарить.   -  person javaLover    schedule 25.05.2017
comment
@javaLover Когда я запускаю это на своем ноутбуке, а не в ideone, я получаю нестабильные результаты. Иногда косвенное быстрее. Затем я добавил несколько повторений тестов (ideone.com/2y5xhB), а косвенность всегда быстрее на моем ноутбуке. А на идеоне получаю обратное. Я думаю, что ideone не может быть хорошей платформой для бенчмаркинга.   -  person Shalom Craimer    schedule 25.05.2017
comment
@scraimer Это ценная информация. Благодарить. На моем рабочем столе я получаю 7-кратный результат (умножение на 7). Это пугает меня.   -  person javaLover    schedule 25.05.2017


Ответы (3)


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

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

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

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

Оба ваших случая являются косвенными. "Косвенный" случай должен следовать за двумя указателями, а "прямой" случай должен выполнять один экземпляр арифметики указателя. «Дорогой» случай может подойти для некоторых SIMD, особенно если вы ослабили точность с плавающей запятой (позволяя переупорядочивать умножение).

Как показано здесь или это изображение (не встроенное, у меня нет прав), количество ссылок на основную память будет доминировать над всем остальным в приведенном выше коде. ЦП с частотой 2 ГГц имеет время цикла 0,5 нс, а ссылка на основную память составляет 100 нс или 200 циклов задержки.

Между тем, процессор настольного компьютера может выполнять более 8 операций с плавающей запятой за цикл, если вы можете реализовать векторизованный код. Это потенциально в 1600 раз более быстрая операция с плавающей запятой, чем одиночный промах кэша.

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

Обратите внимание, что не все ЦП имеют одинаковый уровень векторизации, что некоторые усилия прилагаются для ускорения задержки ЦП/основной памяти, что FPU имеют разные характеристики и множество других сложностей.

person Yakk - Adam Nevraumont    schedule 26.05.2017
comment
Спасибо, The "indirect" case has to follow two pointers просветите меня. Хм, интересно, что в каждой такой хорошей информационной таблице всегда не хватает стоимости арифметических операций. - person javaLover; 26.05.2017
comment
@java 1 цикл или меньше; с векторизацией вы можете сделать много/цикл. Сколько именно - сложно. Гиперпоточность делает это сложным. TPD, приводящий к даунклокингу, также усложняет задачу. Но 1 цикл - хорошее место для начала. - person Yakk - Adam Nevraumont; 26.05.2017

Проще говоря, ваш тест очень нерепрезентативен и на самом деле не измеряет именно то, что, как вы думаете, он делает.

Обратите внимание, что вы звоните new C() 100 000 раз. Это создаст 100 000 экземпляров C, разбросанных по всей вашей памяти, каждый из которых будет очень маленьким. Современное оборудование очень хорошо предсказывает, если ваш доступ к памяти является регулярным. Поскольку каждое выделение, каждый вызов new происходит независимо от других, адреса памяти не будут хорошо сгруппированы вместе, что усложнит прогнозирование. Это приводит к так называемым промахам кеша.

Выделение в виде массива (new C[numTest]), вероятно, приведет к совершенно другим результатам, поскольку в этом случае адреса снова очень предсказуемы. Группировка вашей памяти как можно ближе и доступ к ней линейным и предсказуемым образом, как правило, дает гораздо лучшую производительность. Это связано с тем, что большинство кешей и предвыборщиков адресов ожидают, что именно этот шаблон будет встречаться в обычных программах.

Небольшое дополнение: такая инициализация C d[numTest] = {}; вызовет конструктор для каждого отдельного элемента.

person HeroicKatora    schedule 25.05.2017
comment
Я просто проверяю предположение о фрагментации памяти (избегайте new()). Результат указывает на то, что потеря производительности может быть вызвана не new() 100 000 раз или фрагментацией памяти. (но может быть из-за промаха кеша) ideone.com/p7QpcY (result=3-4x) .... , Да, создайте его с помощью new C[numTest] и получите к нему последовательный доступ, стоимость = 1 умножение. - person javaLover; 25.05.2017
comment
@javaLover, пожалуйста, посмотрите мой ответ в другой ветке комментариев, эта интерпретация не является доказательством ни одного из этих утверждений. Также имейте в виду, что стоимость new() может сильно зависеть от системы. - person HeroicKatora; 25.05.2017

На ваш вопрос нет простого ответа. Это зависит от возможностей и особенностей вашего оборудования (ЦП, ОЗУ, скорости шины и т. д.).

В старые времена умножение с плавающей запятой могло занимать десятки, если не сотни циклов. Доступ к памяти осуществлялся со скоростью, близкой к частоте ЦП (вспомните мегагерц), и умножение с плавающей запятой заняло бы больше времени, чем косвенное.

С тех пор все сильно изменилось. Современное оборудование может выполнять умножение с плавающей запятой всего за цикл или два, в то время как косвенное обращение (доступ к памяти) может занимать от нескольких циклов до сотен, в зависимости от того, где расположены данные, которые нужно прочитать. Уровней кэша может быть несколько. В крайних случаях память, к которой осуществляется косвенный доступ, была заменена на диск и должна быть считана обратно. Это может привести к задержке в тысячи циклов.

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

person 1201ProgramAlarm    schedule 25.05.2017