Может ли кто-нибудь объяснить поведение производительности следующей памяти, выделяющей программу C?

На моей машине время A и время B меняются в зависимости от того, существует ли A (который изменяет порядок, в котором вызывается два calloc).

Я первоначально объяснил это поисковой системе. Странно, когда mmap используется вместо calloc, ситуация еще более сложная - обе петли занимают одинаковое количество времени, как и ожидалось. В виде можно увидеть с помощью strace, calloc в конечном итоге привести к двум mmap s, так что магия с возвратом уже выделенной памяти не происходит.

Я запускаю тестирование Debian на Intel i7.

#include <stdlib.h>
#include <stdio.h>
#include <sys/mman.h>

#include <time.h>

#define SIZE 500002816

#ifndef USE_MMAP
#define ALLOC calloc
#else
#define ALLOC(a, b) (mmap(NULL, a * b, PROT_READ | PROT_WRITE,  \
                          MAP_PRIVATE | MAP_ANONYMOUS, -1, 0))
#endif

int main() {
  clock_t start, finish;
#ifdef A
  int *arr1 = ALLOC(sizeof(int), SIZE);
  int *arr2 = ALLOC(sizeof(int), SIZE);
#else
  int *arr2 = ALLOC(sizeof(int), SIZE);
  int *arr1 = ALLOC(sizeof(int), SIZE);
#endif
  int i;

  start = clock();
  {
    for (i = 0; i < SIZE; i++)
      arr1[i] = (i + 13) * 5;
  }
  finish = clock();

  printf("Time A: %.2f\n", ((double)(finish - start))/CLOCKS_PER_SEC);

  start = clock();
  {
    for (i = 0; i < SIZE; i++)
      arr2[i] = (i + 13) * 5;
  }
  finish = clock();

  printf("Time B: %.2f\n", ((double)(finish - start))/CLOCKS_PER_SEC);

  return 0;
}

Выход, который я получаю:

 ~/directory $ cc -Wall -O3 bench-loop.c -o bench-loop
 ~/directory $ ./bench-loop 
Time A: 0.94
Time B: 0.34
 ~/directory $ cc -DA -Wall -O3 bench-loop.c -o bench-loop
 ~/directory $ ./bench-loop                               
Time A: 0.34
Time B: 0.90
 ~/directory $ cc -DUSE_MMAP -DA -Wall -O3 bench-loop.c -o bench-loop
 ~/directory $ ./bench-loop                                          
Time A: 0.89
Time B: 0.90
 ~/directory $ cc -DUSE_MMAP -Wall -O3 bench-loop.c -o bench-loop 
 ~/directory $ ./bench-loop                                      
Time A: 0.91
Time B: 0.92

Ответы

Ответ 1

Короткий ответ

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

Подробнее

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

  • Вставьте вызов calloc перед первым вызовом ALLOC. Вы увидите, что после этого Time for Time A и Time B будут одинаковыми.

  • Используйте функцию clock(), чтобы проверить, как долго выполняется каждый вызов ALLOC. В случае, когда они оба используют calloc, вы увидите, что первый вызов занимает гораздо больше времени, чем второй.

  • Используйте time для времени выполнения версии calloc и версии USE_MMAP. Когда я это сделал, я увидел, что время выполнения для USE_MMAP было последовательно немного меньше.

  • Я работал с strace -tt -T, который показывает время, когда был сделан системный вызов и сколько времени потребовалось. Вот часть вывода:

Выход Strace:

21:29:06.127536 mmap(NULL, 2000015360, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fff806fd000 <0.000014>
21:29:07.778442 mmap(NULL, 2000015360, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7fff093a0000 <0.000021>
21:29:07.778563 times({tms_utime=63, tms_stime=102, tms_cutime=0, tms_cstime=0}) = 4324241005 <0.000011>

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

Я также выполнил часть выполнения приложения с помощью gdb и увидел, что первый вызов calloc привел к многочисленным вызовам на memset, в то время как второй вызов calloc не вызывал никаких вызовов на memset. Вы можете увидеть исходный код calloc здесь (ищите __libc_calloc), если вы заинтересованы. Что касается того, почему calloc выполняет memset при первом вызове, но не в последующих, я не знаю. Но я уверен, что это объясняет поведение, о котором вы просили.

Что касается того, почему массив, который был обнулен memset, имеет улучшенную производительность, я предполагаю, что это связано с тем, что значения загружаются в TLB, а не в кеш, поскольку это очень большой массив. Независимо от конкретной причины разницы в производительности, о которой вы спрашивали, это то, что два вызова calloc ведут себя по-разному, когда они выполняются.

Ответ 2

Вы также должны проверить с помощью malloc вместо calloc. Единственное, что делает calloc, это заполнить выделенную память нулями.

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

Ответ 3

Думаю, я мог бы написать больше или меньше, тем более, что меньше.

Причина может отличаться от системы к системе. Однако; для кливера:

Общее время, используемое для каждой операции, - это наоборот; если вы время calloc + итерация.

то есть:.

Calloc arr1 : 0.494992654
Calloc arr2 : 0.000021250
Itr arr1    : 0.430646035
Itr arr2    : 0.790992411
Sum arr1    : 0.925638689
Sum arr2    : 0.791013661

Calloc arr1 : 0.503130736
Calloc arr2 : 0.000025906
Itr arr1    : 0.427719162
Itr arr2    : 0.809686047
Sum arr1    : 0.930849898
Sum arr2    : 0.809711953

Первый calloc впоследствии malloc имеет более длительное время выполнения, затем второй. Вызов, т.е. malloc(0) до любого calloc и т.д., Выравнивает время используется для malloc подобных вызовов в одном процессе (пояснение ниже). Можно однако см. небольшое снижение времени для этих вызовов, если вы делаете несколько строк.

Итерационное время, однако, будет сглажено.

Итак, короче говоря; Общее используемое системное время самое высокое, на которое когда-либо получалось alloc'ed в первую очередь. Это, однако, накладные расходы, которые не могут быть экранированы в ограничении процесса.

Продолжается много технического обслуживания. Быстрое касание некоторых случаев:


Сокращение на странице

В память запроса процесса обслуживается диапазон виртуальных адресов. Этот диапазон переводит таблицу страниц в физическую память. Если страница переведена байтом на байт мы быстро получим огромные таблицы страниц. Это, как один, является причиной, почему диапазоны памяти подаются в кусках - или на страницах. Размер страницы - это система зависимый. Архитектура также может обеспечивать различные размеры страниц.

Если мы посмотрим на выполнение вышеуказанного кода и добавим некоторые чтения из /proc/PID/stat мы видим это в действии (примечание RSS):

PID Stat {
  PID          : 4830         Process ID
  MINFLT       : 214          Minor faults, (no page memory read)
  UTIME        : 0            Time user mode
  STIME        : 0            Time kernel mode
  VSIZE        : 2039808      Virtual memory size, bytes
  RSS          : 73           Resident Set Size, Number of pages in real memory
} : Init

PID Stat {
  PID          : 4830         Process ID
  MINFLT       : 51504        Minor faults, (no page memory read)
  UTIME        : 4            Time user mode
  STIME        : 33           Time kernel mode
  VSIZE        : 212135936    Virtual memory size, bytes
  RSS          : 51420        Resident Set Size, Number of pages in real memory
} : Post calloc arr1

PID Stat {
  PID          : 4830         Process ID
  MINFLT       : 51515        Minor faults, (no page memory read)
  UTIME        : 4            Time user mode
  STIME        : 33           Time kernel mode
  VSIZE        : 422092800    Virtual memory size, bytes
  RSS          : 51428        Resident Set Size, Number of pages in real memory
} : Post calloc arr2

PID Stat {
  PID          : 4830         Process ID
  MINFLT       : 51516        Minor faults, (no page memory read)
  UTIME        : 36           Time user mode
  STIME        : 33           Time kernel mode
  VSIZE        : 422092800    Virtual memory size, bytes
  RSS          : 51431        Resident Set Size, Number of pages in real memory
} : Post iteration arr1

PID Stat {
  PID          : 4830         Process ID
  MINFLT       : 102775       Minor faults, (no page memory read)
  UTIME        : 68           Time user mode
  STIME        : 58           Time kernel mode
  VSIZE        : 422092800    Virtual memory size, bytes
  RSS          : 102646       Resident Set Size, Number of pages in real memory
} : Post iteration arr2

PID Stat {
  PID          : 4830         Process ID
  MINFLT       : 102776       Minor faults, (no page memory read)
  UTIME        : 68           Time user mode
  STIME        : 69           Time kernel mode
  VSIZE        : 2179072      Virtual memory size, bytes
  RSS          : 171          Resident Set Size, Number of pages in real memory
} : Post free()

Как мы видим, страницы, фактически выделенные в памяти, переносятся на arr2 в ожидании запрос страницы; который длится до начала итерации. Если мы добавим a malloc(0) до calloc of arr1 мы можем зарегистрировать, что ни один массив не выделяется в физическом память перед итерацией.


Поскольку страница не может быть использована, более эффективно выполнять сопоставление по запросу. Поэтому, когда процесс, т.е. Делает a calloc достаточным количеством страниц зарезервированы, но не обязательно фактически выделены в реальной памяти.

Когда адрес ссылается на страницу таблицы страниц. Если адрес на странице, которая не выделена, система обслуживает ошибку страницы и страницу впоследствии распределяется. Общая сумма выделенных страниц называется Resident Установите размер (RSS).

Мы можем провести эксперимент с нашим массивом, итерируя (касаясь), т.е. 1/4 от него. Здесь я также добавил malloc(0) перед любым calloc.

Pre iteration 1/4:
RSS          : 171              Resident Set Size, Number of pages in real meory

for (i = 0; i < SIZE / 4; ++i)
    arr1[i] = 0;

Post iteration 1/4:
RSS          : 12967            Resident Set Size, Number of pages in real meory

Post iteration 1/1:
RSS          : 51134            Resident Set Size, Number of pages in real meory

Чтобы ускорить работу, большинство систем дополнительно кэшируют N самых последних page table в буфере просмотра перевода (TLB).


brk, mmap

Когда процесс (c|m|…)alloc, верхние границы кучи расширяются на brk() или sbrk(). Эти системные вызовы являются дорогостоящими и компенсируют этот malloc собирает несколько меньших вызовов в один более крупный brk().

Это также влияет на free(), поскольку отрицательный brk() также является ресурсом дорогостоящим они собираются и выполняются как большая операция.


Для огромного запроса; например, в вашем коде, malloc() использует mmap(). Порог для этого, который настраивается mallopt(), является образованным Значение

Мы можем повеселиться, модифицируя SIZE в вашем коде. Если мы используем malloc.h и используйте

struct mallinfo minf = mallinfo();

(no, not milf), мы можем это показать (Примечание Arena и Hblkhd,...):

Initial:

mallinfo {
  Arena   :         0 (Bytes of memory allocated with sbrk by malloc)
  Ordblks :         1 (Number of chunks not in use)
  Hblks   :         0 (Number of chunks allocated with mmap)
  Hblkhd  :         0 (Bytes allocated with mmap)
  Uordblks:         0 (Memory occupied by chunks handed out by malloc)
  Fordblks:         0 (Memory occupied by free chunks)
  Keepcost:         0 (Size of the top-most releasable chunk)
} : Initial

MAX = ((128 * 1024) / sizeof(int)) 

mallinfo {
  Arena   :         0 (Bytes of memory allocated with sbrk by malloc)
  Ordblks :         1 (Number of chunks not in use)
  Hblks   :         1 (Number of chunks allocated with mmap)
  Hblkhd  :    135168 (Bytes allocated with mmap)
  Uordblks:         0 (Memory occupied by chunks handed out by malloc)
  Fordblks:         0 (Memory occupied by free chunks)
  Keepcost:         0 (Size of the top-most releasable chunk)
} : After malloc arr1

mallinfo {
  Arena   :         0 (Bytes of memory allocated with sbrk by malloc)
  Ordblks :         1 (Number of chunks not in use)
  Hblks   :         2 (Number of chunks allocated with mmap)
  Hblkhd  :    270336 (Bytes allocated with mmap)
  Uordblks:         0 (Memory occupied by chunks handed out by malloc)
  Fordblks:         0 (Memory occupied by free chunks)
  Keepcost:         0 (Size of the top-most releasable chunk)
} : After malloc arr2

Затем вычитаем sizeof(int) из MAX и получаем:

mallinfo {
  Arena   :    266240 (Bytes of memory allocated with sbrk by malloc)
  Ordblks :         1 (Number of chunks not in use)
  Hblks   :         0 (Number of chunks allocated with mmap)
  Hblkhd  :         0 (Bytes allocated with mmap)
  Uordblks:    131064 (Memory occupied by chunks handed out by malloc)
  Fordblks:    135176 (Memory occupied by free chunks)
  Keepcost:    135176 (Size of the top-most releasable chunk)
} : After malloc arr1

mallinfo {
  Arena   :    266240 (Bytes of memory allocated with sbrk by malloc)
  Ordblks :         1 (Number of chunks not in use)
  Hblks   :         0 (Number of chunks allocated with mmap)
  Hblkhd  :         0 (Bytes allocated with mmap)
  Uordblks:    262128 (Memory occupied by chunks handed out by malloc)
  Fordblks:      4112 (Memory occupied by free chunks)
  Keepcost:      4112 (Size of the top-most releasable chunk)
} : After malloc arr2

Мы регистрируем, что система работает как рекламируемая. Если размер распределения ниже порогового значения sbrk, а память обрабатывается внутри malloc, else mmap.

Структура этого также помогает предотвратить фрагментацию памяти и т.д.


Точка, что семейство malloc оптимизировано для общего использования. Однако mmap пределы могут быть изменены для удовлетворения особых потребностей.

Обратите внимание на это (и вниз через 100 строк), когда/если изменяется порог mmap.  .

Это можно наблюдать, если заполнить (коснуться) каждую страницу arr1 и arr2 прежде чем мы сделаем выбор времени:

Touch pages … (Here with page size of 4 kB)

for (i = 0; i < SIZE; i += 4096 / sizeof(int)) {
    arr1[i] = 0;
    arr2[i] = 0;
}

Itr arr1    : 0.312462317
CPU arr1    : 0.32

Itr arr2    : 0.312869158
CPU arr2    : 0.31

Также смотрите:


Примечания:

Итак, CPU знает физический адрес? Нах.

В мире памяти многое должно быть адресовано;). Основное оборудование для это блок управления памятью (MMU). Либо как интегрированная часть процессор или внешний чип.

Операционная система настраивает MMU при загрузке и определяет доступ для различных регионы (только чтение, чтение-запись и т.д.), что обеспечивает уровень безопасности.

Адрес, который мы, как смертные, видим, является логическим адресом, который использует процессор. MMU переводит это на физический адрес.

Адрес CPU состоит из двух частей: адрес страницы и смещение. [PAGE_ADDRESS.OFFSET]

И процесс получения физического адреса может иметь что-то вроде:

.-----.                          .--------------.
| CPU > --- Request page 2 ----> | MMU          |
+-----+                          | Pg 2 == Pg 4 |
      |                          +------v-------+
      +--Request offset 1 -+            |
                           |    (Logical page 2 EQ Physical page 4)
[ ... ]     __             |            |
[ OFFSET 0 ]  |            |            |
[ OFFSET 1 ]  |            |            |
[ OFFSET 2 ]  |            |            |     
[ OFFSET 3 ]  +--- Page 3  |            |
[ OFFSET 4 ]  |            |            |
[ OFFSET 5 ]  |            |            |
[ OFFSET 6 ]__| ___________|____________+
[ OFFSET 0 ]  |            |
[ OFFSET 1 ]  | ...........+
[ OFFSET 2 ]  |
[ OFFSET 3 ]  +--- Page 4
[ OFFSET 4 ]  |
[ OFFSET 5 ]  |
[ OFFSET 6 ]__|
[ ... ]

Логическое адресное пространство процессора напрямую связано с длиной адреса. 32-разрядный адресный процессор имеет логическое адресное пространство из 2 32 байтов. Физическое адресное пространство - это объем памяти, которую система может себе позволить.

Существует также обработка фрагментированной памяти, повторное выравнивание и т.д.

Это подводит нас к миру файлов подкачки. Если процесс запрашивает больше памяти затем физически доступно; одна или несколько страниц других процессов передаются на диск/своп, а их страницы "украдены" запрашивающим процессом. MMU отслеживает это; поэтому ЦП не должен беспокоиться о том, где память фактически расположена.


Это приводит нас к грязной памяти.

Если мы печатаем некоторую информацию из /proc/ [pid]/smaps, более конкретный диапазон наших массивов мы получаем что-то вроде:

Start:
b76f3000-b76f5000
Private_Dirty:         8 kB

Post calloc arr1:
aaeb8000-b76f5000
Private_Dirty:        12 kB

Post calloc arr2:
9e67c000-b76f5000
Private_Dirty:        20 kB

Post iterate 1/4 arr1
9e67b000-b76f5000
Private_Dirty:     51280 kB

Post iterate arr1:
9e67a000-b76f5000
Private_Dirty:    205060 kB

Post iterate arr2:
9e679000-b76f5000
Private_Dirty:    410096 kB

Post free:
9e679000-9e67d000
Private_Dirty:        16 kB
b76f2000-b76f5000
Private_Dirty:        12 kB

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


Ответ 4

Это просто вопрос, когда изображение памяти процесса расширяется на странице.

Ответ 5

Резюме: Разница во времени объясняется при анализе времени на выделение массивов. Последний выделенный calloc занимает немного больше времени, тогда как другой (или все при использовании mmap) не принимает виртуальное время. Фактическое распределение в памяти, вероятно, отложено при первом доступе.

Я не знаю достаточно о внутреннем распределении памяти в Linux. Но я немного изменил ваш script: я добавил третий массив и несколько дополнительных итераций на операции массива. И я принял во внимание замечание Old Pro о том, что время для распределения массивов не принималось во внимание.

Заключение: использование calloc занимает больше времени, чем использование mmap для выделения (mmap virtualy не использует время, когда вы выделяете память, вероятно, переносится позже, когда кулак получает доступ), а с помощью моей программы практически нет разницы в использовании mmap или calloc для общего выполнения программы.

В любом случае, первое замечание, распределение памяти происходит в области отображения памяти, а не в куче. Чтобы убедиться в этом, я добавил кратковременную паузу, чтобы вы могли проверить отображение памяти процесса (/proc//maps)

Теперь, к вашему вопросу, последний выделенный массив с calloc, по-видимому, действительно выделен в памяти (не отложен). Поскольку arr1 и arr2 ведут себя точно так же (первая итерация медленная, последующие итерации быстрее). Arr3 быстрее для первой итерации, потому что память была выделена раньше. При использовании макроса A, это зависит от этого. Я предполагаю, что ядро ​​предварительно выделило массив в памяти для последнего calloc. Зачем? Я не знаю... Я тестировал его также только с одним массивом (поэтому я удалил все случаи arr2 и arr3), тогда у меня есть одно и то же время (примерно) для всех 10 итераций arr1.

Оба malloc и mmap ведут себя одинаково (результаты не показаны ниже), первая итерация медленная, последующие итерации быстрее для всех 3 массивов.

Примечание: все результаты были когерентными по различным флагам оптимизации gcc (от -O0 до -O3), поэтому не похоже, что корень поведения получен из некоторого оптимизатора gcc.

Примечание2: Тестирование на Ubuntu Precise Pangolin (ядро 3.2), с GCC 4.6.3

#include <stdlib.h>
#include <stdio.h>
#include <sys/mman.h>

#include <time.h>

#define SIZE 500002816
#define ITERATION 10

#if defined(USE_MMAP)
#  define ALLOC(a, b) (mmap(NULL, a * b, PROT_READ | PROT_WRITE,  \
                          MAP_PRIVATE | MAP_ANONYMOUS, -1, 0))
#elif defined(USE_MALLOC)
#  define ALLOC(a, b) (malloc(b * a))
#elif defined(USE_CALLOC)
#  define ALLOC calloc
#else
#  error "No alloc routine specified"
#endif

int main() {
  clock_t start, finish, gstart, gfinish;
  start = clock();
  gstart = start;
#ifdef A
  unsigned int *arr1 = ALLOC(sizeof(unsigned int), SIZE);
  unsigned int *arr2 = ALLOC(sizeof(unsigned int), SIZE);
  unsigned int *arr3 = ALLOC(sizeof(unsigned int), SIZE);
#else
  unsigned int *arr3 = ALLOC(sizeof(unsigned int), SIZE);
  unsigned int *arr2 = ALLOC(sizeof(unsigned int), SIZE);
  unsigned int *arr1 = ALLOC(sizeof(unsigned int), SIZE);
#endif
  finish = clock();
  unsigned int i, j;
  double intermed, finalres;

  intermed = ((double)(finish - start))/CLOCKS_PER_SEC;
  printf("Time to create: %.2f\n", intermed);

  printf("arr1 addr: %p\narr2 addr: %p\narr3 addr: %p\n", arr1, arr2, arr3);

  finalres = 0;
  for (j = 0; j < ITERATION; j++)
  {
    start = clock();
    {
      for (i = 0; i < SIZE; i++)
        arr1[i] = (i + 13) * 5;
    }
    finish = clock();

    intermed = ((double)(finish - start))/CLOCKS_PER_SEC;
    finalres += intermed;
    printf("Time A: %.2f\n", intermed);
  }

  printf("Time A (average): %.2f\n", finalres/ITERATION);


  finalres = 0;
  for (j = 0; j < ITERATION; j++)
  {
    start = clock();
    {
      for (i = 0; i < SIZE; i++)
        arr2[i] = (i + 13) * 5;
    }
    finish = clock();

    intermed = ((double)(finish - start))/CLOCKS_PER_SEC;
    finalres += intermed;
    printf("Time B: %.2f\n", intermed);
  }

  printf("Time B (average): %.2f\n", finalres/ITERATION);


  finalres = 0;
  for (j = 0; j < ITERATION; j++)
  {
    start = clock();
    {
      for (i = 0; i < SIZE; i++)
        arr3[i] = (i + 13) * 5;
    }
    finish = clock();

    intermed = ((double)(finish - start))/CLOCKS_PER_SEC;
    finalres += intermed;
    printf("Time C: %.2f\n", intermed);
  }

  printf("Time C (average): %.2f\n", finalres/ITERATION);

  gfinish = clock();

  intermed = ((double)(gfinish - gstart))/CLOCKS_PER_SEC;
  printf("Global Time: %.2f\n", intermed);

  return 0;
}

Результаты:

Использование USE_CALLOC

Time to create: 0.13
arr1 addr: 0x7fabcb4a6000
arr2 addr: 0x7fabe917d000
arr3 addr: 0x7fac06e54000
Time A: 0.67
Time A: 0.48
...
Time A: 0.47
Time A (average): 0.48
Time B: 0.63
Time B: 0.47
...
Time B: 0.48
Time B (average): 0.48
Time C: 0.45
...
Time C: 0.46
Time C (average): 0.46

С USE_CALLOC и A

Time to create: 0.13
arr1 addr: 0x7fc2fa206010
arr2 addr: 0xx7fc2dc52e010
arr3 addr: 0x7fc2be856010
Time A: 0.44
...
Time A: 0.43
Time A (average): 0.45
Time B: 0.65
Time B: 0.47
...
Time B: 0.46
Time B (average): 0.48
Time C: 0.65
Time C: 0.48
...
Time C: 0.45
Time C (average): 0.48

Использование USE_MMAP

Time to create: 0.0
arr1 addr: 0x7fe6332b7000
arr2 addr: 0x7fe650f8e000
arr3 addr: 0x7fe66ec65000
Time A: 0.55
Time A: 0.48
...
Time A: 0.45
Time A (average): 0.49
Time B: 0.54
Time B: 0.46
...
Time B: 0.49
Time B (average): 0.50
Time C: 0.57
...
Time C: 0.40
Time C (average): 0.43