Почему запись в буфер, заполненный на 42 пути быстрее, чем запись в буфер нулей?

Я бы ожидал, что записи в буфере char * будут выполняться одинаково независимо от существующего содержимого памяти 1. Не правда ли?

Однако, сужая несогласованность в бенчмарке, я столкнулся с ситуацией, когда это, по-видимому, неверно. Буфер, содержащий все нули, ведет себя по-разному, по производительности, из буфера, заполненного 42.

Графически это выглядит (подробнее см. ниже):

Время записи в буфер

Здесь код, который я использовал для создания выше 3:

#include <stdio.h>
#include <stdlib.h>
#include <inttypes.h>
#include <string.h>
#include <time.h>

volatile char *sink;

void process(char *buf, size_t len) {
  clock_t start = clock();
  for (size_t i = 0; i < len; i += 678)
    buf[i] = 'z';
  printf("Processing took %lu μs\n",
      1000000UL * (clock() - start) / CLOCKS_PER_SEC);
  sink = buf;
}

int main(int argc, char** argv) {
  int total = 0;
  int memset42 = argc > 1 && !strcmp(argv[1], "42");
  for (int i=0; i < 5; i++) {
    char *buf = (char *)malloc(BUF_SIZE);
    if (memset42)
      memset(buf, 42, BUF_SIZE);
    else
      memset(buf,  0, BUF_SIZE);
    process(buf, BUF_SIZE);
  }
  return EXIT_SUCCESS;
}

Я скомпилирую его в своем ящике Linux, например:

 gcc -O2 buffer_weirdness.cpp -o buffer_weirdness

... и когда я запускаю версию с нулевым буфером, я получаю:

./buffer_weirdness zero
Processing took   12952 μs
Processing took  403522 μs
Processing took  626859 μs
Processing took  626965 μs
Processing took  627109 μs

Обратите внимание, что первая итерация выполняется быстро, а оставшиеся итерации длиной 50 раз больше.

Когда буфер сначала заполняется 42, обработка всегда быстрая:

./buffer_weirdness 42
Processing took   12892 μs
Processing took   13500 μs
Processing took   13482 μs
Processing took   12965 μs
Processing took   13121 μs

Поведение зависит от `BUF_SIZE (1GB в примере выше) - более крупные размеры, скорее всего, покажут проблему, а также зависят от текущего состояния хоста. Если я останусь на ходу один на некоторое время, медленные итерации занимают, возможно, 60 000 мкс, а не 600 000 - поэтому в 10 раз быстрее, но все же ~ 5 раз медленнее, чем быстрое время обработки. В конце концов, времена возвращаются к полностью медленному поведению.

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

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

Что здесь происходит?


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

2sudo sh -c "echo never > /sys/kernel/mm/transparent_hugepage/enabled"

3 Это дистиллированная версия исходного теста. Да, я утечка распределений, преодолеваю это - это приводит к более кратким примерам. Исходный пример не просачивался. Фактически, когда вы не просачиваете распределения, поведение меняется: возможно, потому, что malloc может просто повторно использовать область для следующего выделения, вместо того, чтобы запрашивать ОС для большей памяти.

Ответ 1

Кажется, трудно воспроизвести, поэтому, возможно, специфичный для компилятора /libc.

Мое лучшее предположение здесь:

Когда вы вызываете malloc, вы получаете карту памяти в пространство процесса, что не означает, что ОС уже взяла необходимые страницы из своего пула свободной памяти, но что она просто добавила записи в некоторые таблицы.

Теперь, когда вы пытаетесь получить доступ к памяти там, ваш CPU/MMU вызовет ошибку - и ОС может поймать это и проверить, принадлежит ли этот адрес категории "уже в памяти", но еще не на самом деле выделенных для процесса ". Если это так, необходимая свободная память будет найдена и отображена в пространство памяти вашего процесса.

Теперь современные ОС часто имеют встроенную опцию "обнулить" страницы до (повторного) использования. Если вы это сделаете, операция memset(,0,) становится ненужной. В случае систем POSIX, если вы используете calloc вместо malloc, память обнуляется.

Другими словами, ваш компилятор мог заметить это и полностью опустил memset(,0,), когда ваша ОС поддерживает это. Это означает, что в момент, когда вы пишете страницы в process(), это первый момент, когда они получают доступ, - и это вызывает механизм "отображения на лету" вашей ОС.

Конечно, memset(,42,) не может быть оптимизирован, поэтому в этом случае страницы будут предварительно выделены и вы не увидите время, затраченное на функцию process().

Вы должны использовать /usr/bin/time для фактического сравнения всего времени выполнения с временем, проведенным в process - мое подозрение подразумевает, что время, сохраненное в process, фактически потрачено на main, возможно, в контексте ядра.

ОБНОВЛЕНИЕ. Протестировано с отличным Godbolt Compiler Explorer: Да, с -O2 и -O3, современный gcc просто пропускает нуль-memsetting (или, скорее, просто вставляет его в calloc, который равен malloc с обнулением):

#include <cstdlib>
#include <cstring>
int main(int argc, char ** argv) {
  char *p = (char*)malloc(10000);
  if(argc>2) {
    memset(p,42,10000);
  } else {
    memset(p,0,10000);
  }
  return (int)p[190]; // had to add this for the compiler to **not** completely remove all the function body, since it has no effect at all.
}

Становится для x86_64 на gcc6.3

main:
        // store frame state
        push    rbx
        mov     esi, 1
        // put argc in ebx
        mov     ebx, edi
        // Setting up call to calloc (== malloc with internal zeroing)
        mov     edi, 10000
        call    calloc 
        // ebx (==argc) compared to 2 ?
        cmp     ebx, 2
        mov     rcx, rax
        // jump on less/equal to .L2
        jle     .L2
        // if(argc > 2):
        // set up call to memset
        mov     edx, 10000
        mov     esi, 42
        mov     rdi, rax
        call    memset
        mov     rcx, rax
.L2:    //else case
        //notice the distinct lack of memset here!
        // move the value at position rcx (==p)+190 into the "return" register
        movsx   eax, BYTE PTR [rcx+190]
        //restore frame
        pop     rbx
        //return
        ret

Кстати, если вы удалите return p[190],

  }
  return 0;
}

тогда нет причин для компилятора вообще сохранить тело функции - его возвращаемое значение легко определить во время компиляции и не имеет побочного эффекта. Затем вся программа компилируется в

main:
        xor     eax, eax
        ret

Обратите внимание: A xor A 0 для каждого A.