1 000 000 000 вычислений на микросекунду?

ОК, я говорил с другом о компиляторах и оптимизации программ, и он предположил, что n * 0.5 быстрее, чем n / 2. Я сказал, что компиляторы делают такую ​​оптимизацию автоматически, поэтому я написал небольшую программу, чтобы увидеть, существует ли разница между n / 2 и n * 0.5:

Раздел:

#include <stdio.h>
#include <time.h>

int main(int argc, const char * argv[]) {
    int i, m;
    float n, s;
    clock_t t;

    m = 1000000000;
    t = clock();
    for(i = 0; i < m; i++) {
        n = i / 2;
    }
    s = (float)(clock() - t) / CLOCKS_PER_SEC;

    printf("n = i / 2: %d calculations took %f seconds (last calculation = %f)\n", m, s, n);

    return 0;
}

Умножение:

#include <stdio.h>
#include <time.h>

int main(int argc, const char * argv[]) {
    int i, m;
    float n, s;
    clock_t t;

    m = 1000000000;
    t = clock();
    for(i = 0; i < m; i++) {
        n = i * 0.5;
    }
    s = (float)(clock() - t) / CLOCKS_PER_SEC;

    printf("n = i * 0.5: %d calculations took %f seconds (last calculation = %f)\n", m, s, n);

    return 0;
}

И для обеих версий я получил 0.000002s avg. когда скомпилировано с clang main.c -O1. И он сказал, что должно быть что-то не так с измерением времени. Затем он написал программу:

#include <cstdio>
#include <iostream>
#include <ctime>

using namespace std;

int main()
{
    clock_t ts, te;
    double  dT;

    int i, m;
    double n, o, p, q, r, s;
    m = 1000000000;

    cout << "Independent calculations:\n";
    ts = clock();
    for (i = 0; i < m; i++)
    {
        //  make it a trivial pure float calculation with no int casting to float
        n = 11.1 / 2.3;
        o = 22.2 / 2.3;
        p = 33.3 / 2.3;
        q = 44.4 / 2.3;
        r = 55.5 / 2.3;
        s = 66.6 / 2.3;
    }

    te = clock();
    dT = ((float)(te - ts)) / CLOCKS_PER_SEC;   //  make initial call to get the elapsed time to run the loop
    ts = clock();

    printf("Division: %d calculations took %f seconds\n", m, dT);

    for (i = 0; i < m; i++)
    {
        //  make it a trivial pure float calculation with no int casting to float
        n = 11.1 * 0.53;
        o = 22.2 * 0.53;
        p = 33.3 * 0.53;
        q = 44.4 * 0.53;
        r = 55.5 * 0.53;
        s = 66.6 * 0.53;
    }

    te = clock();
    dT = ((float)(te - ts)) / CLOCKS_PER_SEC;   //  make initial call to get the elapsed time to run the loop
    ts = clock();

    printf("Multiplication: %d calculations took %f seconds\n", m, dT);

    cout << "\nDependent calculations:\n";
    for (i = 0; i < m; i++)
    {
        //  make it a trivial pure float calculation with no int casting to float
        n = 11.1 / 2.3;
        o = n / 2.3;
        p = o / 2.3;
        q = p / 2.3;
        r = q / 2.3;
        s = r / 2.3;
    }


    te = clock();
    dT = ((float)(te - ts)) / CLOCKS_PER_SEC;   //  make initial call to get the elapsed time to run the loop
    ts = clock();

    printf("Division: %d calculations took %f seconds\n", m, dT);

    for (i = 0; i < m; i++)
    {
        //  make it a trivial pure float calculation with no int casting to float
        n = 11.1 * 0.53;
        o = n * 0.53;
        p = o * 0.53;
        q = p * 0.53;
        r = q * 0.53;
        s = r * 0.53;
    }


    te = clock();
    dT = ((float)(te - ts)) / CLOCKS_PER_SEC;   //  make initial call to get the elapsed time to run the loop
    ts = clock();

    printf("Multiplication: %d calculations took %f seconds\n", m, dT);

    return 0;
}

И для этого он получил...

1.869570s
1.868254s
25.674016s
3.497555s

... в этом порядке.

Итак, я запустил программу на своем компьютере, скомпилированном с помощью clang++ main.cpp -O1, и получил аналогичные результаты: 0.000002 to 0.000011.

Однако, когда я скомпилировал программу без оптимизации, я получил с ним аналогичные результаты в своем первом тесте. Итак, мой вопрос: как может любая оптимизация сделать программу намного быстрее?

Ответ 1

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

Ответ 2

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

У вашего друга есть точка, деление (фактическое деление, а не только написание / в C) медленнее, чем умножение. Для удвоений отношение составляет около 4 для задержки, а деление не конвейерно, а умножение - так, поэтому коэффициент пропускной способности намного хуже: около 20. (эти числа для Хасуэлла, но являются типичными)

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

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

int div2(int i)
{
    return i / 2;
}

GCC превращает это в

mov eax, edi
shr eax, 31
add eax, edi
sar eax
ret

который в зависимости от μarch будет занимать 3 или 4 цикла (исключая поток управления).

С другой стороны,

int div2(int i)
{
    return i * 0.5;
}

Перевернут в

    cvtsi2sd    xmm0, edi
    mulsd   xmm0, QWORD PTR .LC0[rip]
    cvttsd2si   eax, xmm0
    ret
.LC0:
    .long   0
    .long   1071644672

Что займет около 4 + 5 + 4 = 13 циклов.

Компилятору также разрешено превращать f / 2.0 в f * 0.5 даже без каких-либо "небезопасных оптимизаций", деление на две силы эквивалентно умножению на его обратное. Это не выполняется для чисел, которые не являются степенью двух.

Таким образом, даже с эталоном, который, по крайней мере, измерил что-то, он, вероятно, не измерил бы правильную вещь. Чтобы измерить деление с плавающей запятой с задержкой и умножением, вы можете написать что-то вроде:

.section data
align 16
one: dq 1.0, 1.0
.section text
_bench1:
  mov ecx, -10000000
  movapd xmm0, [one]
loopone:
  mulpd xmm0, xmm0
  mulpd xmm0, xmm0
  add ecx, 1
  jnz loopone
  ret
_bench2:
  mov ecx, -10000000
  movapd xmm0, [one]
looptwo:
  divpd xmm0, xmm0
  divpd xmm0, xmm0
  add ecx, 1
  jnz looptwo
  ret

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

Вы также можете быть заинтересованы в списке задержек и производительности команд.

Ответ 3

for (i = 0; i < m; i++)
{
    //  make it a trivial pure float calculation with no int casting to float
    n = 11.1 * 0.53;
    o = n * 0.53;
    p = o * 0.53;
    q = p * 0.53;
    r = q * 0.53;
    s = r * 0.53;
}

- это цикл, который не ссылается на i или m и не содержит круговых ссылок, поэтому для компилятора тривиально удалить оператор цикла