Разбивка на страницы — это способ управления памятью, выделяемой для пользовательских процессов. Все доступы процессов к памяти являются виртуальными, а преобразование их адресов в адреса физической памяти выполняют ОС и аппаратный MMU.
При разбивке на страницы память делится на блоки фиксированного размера. В Linux на x86/64-платформах размер страниц обычно составляет 4 Кб. Каждый процесс содержит в себе таблицу, в которой хранится информация о соответствии адресов страницы и физической памяти — элемент таблицы страниц (page table entry). Чтобы ОС не лезла в эту таблицу при каждом обращении к памяти (иначе для обработки каждого запроса на обращение к памяти потребуется обращаться к ней дважды), применяется небольшой кэш — буфер ассоциативной трансляции (Translationlookaside Buffer, TLB). Этот аппаратный компонент находится в MMU и работает чрезвычайно быстро и эффективно. Система сканирует TLB с целью поиска записи о соответствии адресов страницы и физической памяти. Если нужной записи там не оказывается, тогда ядру ОС приходится обращаться к памяти, искать нужное соответствие и обновлять информацию в TLB, чтобы получить из памяти нужные нам данные.
Если вы хотите больше узнать об управлении виртуальной памятью, то можете изучить эту публикацию. А пока давайте разберем, как в PHP 7 устроена работа с большими страницами (Huge Page).
Все просто: чем больше размер страницы, тем больше данных в нее можно поместить. Значит, ядро ОС за одно обращение к памяти получает доступ к большему объему данных. Также снижается вероятность промаха в TLB, ведь каждая запись теперь «покрывает» больше данных. Начиная с версии 2.6.20 ядро Linux получило возможность работать с большими страницами (подробнее об этом: раз, два, три). Большая страница обычно в 512 раз больше стандартной: 2 Мб вместо 4 Кб. Чаще всего ядро выполняет так называемое прозрачное выделение больших страниц (transparent huge page mapping): виртуальная память делится на стандартные страницы по 4 Кб, но иногда группа из следующих друг за другом страниц объединяется в одну большую. Обычно это используется при работе с занимающим огромное адресное пространство массивом. Но будьте внимательны: эта память может возвращаться операционной системе маленькими порциями, что приведет к потере огромного объема страницы, а ядру придется откатывать процедуру объединения, снова выделяя 512 страниц по 4 Кб.
Инициировать процедуру объединения может сам пользовательский процесс. Если вы уверены, что сможете заполнить данными всю большую страницу, то лучше запросите у ядра о ее выделении. Наличие больших страниц облегчает управление памятью, ведь ядру приходится просматривать меньше элементов таблицы страниц. К тому же уменьшается количество записей в TLB, да и система в целом будет работать эффективнее и быстрее.
Трудясь над PHP 7, мы потратили много сил на более эффективную работу с памятью. Критически важные внутренние структуры в PHP 7 были переписаны с целью более эффективного использования кэша ЦПУ. В частности, улучшена пространственная локальность, поэтому в кэш помещается больше односвязных данных, а движок реже обращается к памяти. Расширение OPCache теперь имеет больше возможностей по работе с большими страницами.
В мире Unix существует два API для работы с распределением виртуальной памяти. Предпочтительнее использовать функцию mmap(), поскольку она действительно позволяет выделять большие страницы. Также есть функция madvise(), которая лишь дает подсказки (рекомендации) ядру относительно преобразования части памяти в большую страницу, но гарантий никаких.
Прежде чем запрашивать выделение большой страницы, нужно удостовериться, что:
С помощью sysctl нужно настроить vm.nr_hugepages, а затем проверить доступность больших страниц с помощью cat /proc/meminfo:
В этом примере доступно 20 больших страниц по 2 Мб. Linux на x86/64-платформе может работать со страницами до 1 Гб, хотя для PHP такой размер не рекомендован, в отличие от СУБД, где возможен выигрыш от больших размеров.
Далее можно использовать API. Чтобы выделить часть памяти под большую страницу, необходимо удостовериться в том, что границы адресного пространства совпадают с границами большой страницы. В любом случае, это нужно делать для повышения эффективности ЦПУ. После этого можно запросить у ядра выделение страницы. В следующем примере выравнивание адресов будет сделано с помощью языка С, а буфер для этой задачи взят из кучи. Ради кроссплатформенной совместимости мы не будем использовать существующие функции для выравнивания, вроде posix_memalign().
Если вы знакомы с языком С, то пояснять особо нечего. Память не освобождается явным образом, поскольку выполнение приложения все равно завершится, да и пример этот нужен лишь для того, чтобы проиллюстрировать идею.
Когда процесс разметил память и практически завершился, можно наблюдать те самые большие страницы, зарезервированные ядром:
Зарезервированы, потому что страница не будет занесена в виртуальную память, пока вы не запишете в нее данные. Здесь 16 страниц помечены как зарезервированные. 16 х 2 Мб = 32 Мб — такой объем памяти мы можем использовать для создания большой страницы с помощью mmap().
Объем кодового сегмента PHP 7 весьма велик. На моей LP64 x86/64-машине он составляет около 9 Мб (отладочная сборка):
В этом примере текстовый сегмент занимает кусок памяти с 00400000 по 00db8000. То есть общий объем бинарного машинного кода PHP составляет больше 9 Мб. Да, PHP развивается, обрастает функциями, и содержит все больше С-кода, преобразованного в машинный код.
Рассмотрим свойства нашего сегмента памяти. Он выделен с помощью традиционных страниц по 4 Кб:
Ядро не использовало прозрачное выделение большой страницы для данного сегмента. Возможно, оно прибегнет к этому позднее, по мере дальнейшего использования процесса с pid8435. Не станем углубляться в вопросы управления ядром большими страницами, однако с помощью OPCache можем перераспределить наш сегмент в большую страницу.
Использование больших страниц в данном случае целесообразно, поскольку кодовый сегмент не меняется в размере и не перемещается при завершении процесса. Наши 9 952 Кб можно уместить в четыре страницы по 2 Мб, а остаток рассредоточить по обычным страницам по 4 Кб.
Если:
OPCache открывает /proc/self/maps и ищет кодовый сегмент памяти. По-другому сделать это не получится, поскольку доступ к подобной информации нельзя получить без явного использования зависимостей ядра. Сегодня procfs используется во всех Unix-системах.
Сканируем файл, находим кодовый сегмент, выравниваем границы в соответствии с адресным пространством большой страницы. Затем вызываем accel_remap_huge_pages() с указанием выровненных границ.
Все достаточно просто. Мы создали новый временный буфер (mem), скопировали в него данные, затем с помощью mmap() попытались распределить выровненный буфер по большим страницам. Если попытка не увенчалась успехом, то можно подсказать ядру с помощью madvise(). После распределения сегмента по страницам копируем данные обратно и возвращаемся.
8 Мб распределены по четырем большим страницам, а 1760 Кб — по стандартным. Мне это дало прирост производительности Zend в 3% при больших нагрузках.
При использовании больших страниц:
Теперь понятно, каким образом расширение OPCache для PHP 7 помогает повысить производительность системы при использовании теперь уже распространенной техники управления памятью, известной как «большие страницы».
Кстати, ряд СУБД (например, Oracle, PostgreSQL) уже несколько лет используют преимущества больших страниц.
При разбивке на страницы память делится на блоки фиксированного размера. В Linux на x86/64-платформах размер страниц обычно составляет 4 Кб. Каждый процесс содержит в себе таблицу, в которой хранится информация о соответствии адресов страницы и физической памяти — элемент таблицы страниц (page table entry). Чтобы ОС не лезла в эту таблицу при каждом обращении к памяти (иначе для обработки каждого запроса на обращение к памяти потребуется обращаться к ней дважды), применяется небольшой кэш — буфер ассоциативной трансляции (Translationlookaside Buffer, TLB). Этот аппаратный компонент находится в MMU и работает чрезвычайно быстро и эффективно. Система сканирует TLB с целью поиска записи о соответствии адресов страницы и физической памяти. Если нужной записи там не оказывается, тогда ядру ОС приходится обращаться к памяти, искать нужное соответствие и обновлять информацию в TLB, чтобы получить из памяти нужные нам данные.
Если вы хотите больше узнать об управлении виртуальной памятью, то можете изучить эту публикацию. А пока давайте разберем, как в PHP 7 устроена работа с большими страницами (Huge Page).
Зачем вообще нужны большие страницы?
Все просто: чем больше размер страницы, тем больше данных в нее можно поместить. Значит, ядро ОС за одно обращение к памяти получает доступ к большему объему данных. Также снижается вероятность промаха в TLB, ведь каждая запись теперь «покрывает» больше данных. Начиная с версии 2.6.20 ядро Linux получило возможность работать с большими страницами (подробнее об этом: раз, два, три). Большая страница обычно в 512 раз больше стандартной: 2 Мб вместо 4 Кб. Чаще всего ядро выполняет так называемое прозрачное выделение больших страниц (transparent huge page mapping): виртуальная память делится на стандартные страницы по 4 Кб, но иногда группа из следующих друг за другом страниц объединяется в одну большую. Обычно это используется при работе с занимающим огромное адресное пространство массивом. Но будьте внимательны: эта память может возвращаться операционной системе маленькими порциями, что приведет к потере огромного объема страницы, а ядру придется откатывать процедуру объединения, снова выделяя 512 страниц по 4 Кб.
Инициировать процедуру объединения может сам пользовательский процесс. Если вы уверены, что сможете заполнить данными всю большую страницу, то лучше запросите у ядра о ее выделении. Наличие больших страниц облегчает управление памятью, ведь ядру приходится просматривать меньше элементов таблицы страниц. К тому же уменьшается количество записей в TLB, да и система в целом будет работать эффективнее и быстрее.
OPCache вам в помощь
Трудясь над PHP 7, мы потратили много сил на более эффективную работу с памятью. Критически важные внутренние структуры в PHP 7 были переписаны с целью более эффективного использования кэша ЦПУ. В частности, улучшена пространственная локальность, поэтому в кэш помещается больше односвязных данных, а движок реже обращается к памяти. Расширение OPCache теперь имеет больше возможностей по работе с большими страницами.
Запрос на выделение больших страниц
В мире Unix существует два API для работы с распределением виртуальной памяти. Предпочтительнее использовать функцию mmap(), поскольку она действительно позволяет выделять большие страницы. Также есть функция madvise(), которая лишь дает подсказки (рекомендации) ядру относительно преобразования части памяти в большую страницу, но гарантий никаких.
Прежде чем запрашивать выделение большой страницы, нужно удостовериться, что:
- ваша ОС способна с ними работать,
- есть в наличии свободные большие страницы.
С помощью sysctl нужно настроить vm.nr_hugepages, а затем проверить доступность больших страниц с помощью cat /proc/meminfo:
> cat /proc/meminfo
HugePages_Total: 20
HugePages_Free: 20
HugePages_Rsvd: 0
HugePages_Surp: 0
Hugepagesize: 2048 kB
В этом примере доступно 20 больших страниц по 2 Мб. Linux на x86/64-платформе может работать со страницами до 1 Гб, хотя для PHP такой размер не рекомендован, в отличие от СУБД, где возможен выигрыш от больших размеров.
Далее можно использовать API. Чтобы выделить часть памяти под большую страницу, необходимо удостовериться в том, что границы адресного пространства совпадают с границами большой страницы. В любом случае, это нужно делать для повышения эффективности ЦПУ. После этого можно запросить у ядра выделение страницы. В следующем примере выравнивание адресов будет сделано с помощью языка С, а буфер для этой задачи взят из кучи. Ради кроссплатформенной совместимости мы не будем использовать существующие функции для выравнивания, вроде posix_memalign().
#include <stdio.h>
#include <sys/mman.h>
#include <string.h>
#include <stdlib.h>
#define ALIGN 1024*1024*2 /* We assume huge pages are 2Mb */
#define SIZE 1024*1024*32 /* Let's allocate 32Mb */
int main(int argc, char *argv[])
{
void *addr;
void *buf = NULL;
void *aligned_buf;
/* As we're gonna align on 2Mb, we need to allocate 34Mb if
we want to be sure we can use huge pages on 32Mb total */
buf = malloc(SIZE + ALIGN);
if (!buf) {
perror("Could not allocate memory");
exit(1);
}
printf("buf is at: %p\n", buf);
*/ Align on ALIGN boundary */
aligned_buf = (void *) ( ((unsigned long)buf + ALIGN - 1) & ~(ALIGN -1) );
printf("aligned buf: %p\n", aligned_buf);
/* Turn the address to huge page backed address, using MAP_HUGETLB */
addr = mmap(aligned_buf, SIZE, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE | MAP_HUGETLB | MAP_FIXED, -1, 0);
if (addr == MAP_FAILED) {
printf("failed mapping, check address or huge page support\n");
exit(0);
}
printf("mmapped: %p with huge page usage\n", addr);
return 0;
}
Если вы знакомы с языком С, то пояснять особо нечего. Память не освобождается явным образом, поскольку выполнение приложения все равно завершится, да и пример этот нужен лишь для того, чтобы проиллюстрировать идею.
Когда процесс разметил память и практически завершился, можно наблюдать те самые большие страницы, зарезервированные ядром:
HugePages_Total: 20
HugePages_Free: 20
HugePages_Rsvd: 16
HugePages_Surp: 0
Hugepagesize: 2048 kB
Зарезервированы, потому что страница не будет занесена в виртуальную память, пока вы не запишете в нее данные. Здесь 16 страниц помечены как зарезервированные. 16 х 2 Мб = 32 Мб — такой объем памяти мы можем использовать для создания большой страницы с помощью mmap().
Размещение сегмента кода PHP 7 в большой странице
Объем кодового сегмента PHP 7 весьма велик. На моей LP64 x86/64-машине он составляет около 9 Мб (отладочная сборка):
> cat /proc/8435/maps
00400000-00db8000 r-xp 00000000 08:01 4196579 /home/julien.pauli/php70/nzts/bin/php /* text segment */
00fb8000-01056000 rw-p 009b8000 08:01 4196579 /home/julien.pauli/php70/nzts/bin/php
01056000-01073000 rw-p 00000000 00:00 0
02bd0000-02ce8000 rw-p 00000000 00:00 0 [heap]
... ... ...
В этом примере текстовый сегмент занимает кусок памяти с 00400000 по 00db8000. То есть общий объем бинарного машинного кода PHP составляет больше 9 Мб. Да, PHP развивается, обрастает функциями, и содержит все больше С-кода, преобразованного в машинный код.
Рассмотрим свойства нашего сегмента памяти. Он выделен с помощью традиционных страниц по 4 Кб:
> cat /proc/8435/smaps
00400000-00db8000 r-xp 00000000 08:01 4196579 /home/julien.pauli/php70/nzts/bin/php
Size: 9952 kB /* VM size */
Rss: 1276 kB /* PM busy load */
Pss: 1276 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 1276 kB
Private_Dirty: 0 kB
Referenced: 1276 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB /* page size is 4Kb */
MMUPageSize: 4 kB
Locked: 0 kB
Ядро не использовало прозрачное выделение большой страницы для данного сегмента. Возможно, оно прибегнет к этому позднее, по мере дальнейшего использования процесса с pid8435. Не станем углубляться в вопросы управления ядром большими страницами, однако с помощью OPCache можем перераспределить наш сегмент в большую страницу.
Использование больших страниц в данном случае целесообразно, поскольку кодовый сегмент не меняется в размере и не перемещается при завершении процесса. Наши 9 952 Кб можно уместить в четыре страницы по 2 Мб, а остаток рассредоточить по обычным страницам по 4 Кб.
Распределение кодового сегмента по большим страницам
Если:
- вы используете PHP 7;
- ваша система поддерживает большие страницы;
- вы присвоили opcache.huge_code_pages значение 1 (вместо 0);
- и PHP не является модулем вебсервера,
- тогда OPCache сразу после запуска попытается разместить ваш кодовый сегмент в больших страницах. Это делается с помощью функции accel_move_code_to_huge_pages().
static void accel_move_code_to_huge_pages(void)
{
FILE *f;
long unsigned int huge_page_size = 2 * 1024 * 1024;
f = fopen("/proc/self/maps", "r");
if (f) {
long unsigned int start, end, offset, inode;
char perm[5], dev[6], name[MAXPATHLEN];
int ret;
ret = fscanf(f, "%lx-%lx %4s %lx %5s %ld %s\n", &start, &end, perm, &offset, dev, &inode, name);
if (ret == 7 && perm[0] == 'r' && perm[1] == '-' && perm[2] == 'x' && name[0] == '/') {
long unsigned int seg_start = ZEND_MM_ALIGNED_SIZE_EX(start, huge_page_size);
long unsigned int seg_end = (end & ~(huge_page_size-1L));
if (seg_end > seg_start) {
zend_accel_error(ACCEL_LOG_DEBUG, "remap to huge page %lx-%lx %s \n", seg_start, seg_end, name);
accel_remap_huge_pages((void*)seg_start, seg_end - seg_start, name, offset + seg_start - start);
}
}
fclose(f);
}
}
OPCache открывает /proc/self/maps и ищет кодовый сегмент памяти. По-другому сделать это не получится, поскольку доступ к подобной информации нельзя получить без явного использования зависимостей ядра. Сегодня procfs используется во всех Unix-системах.
Сканируем файл, находим кодовый сегмент, выравниваем границы в соответствии с адресным пространством большой страницы. Затем вызываем accel_remap_huge_pages() с указанием выровненных границ.
# if defined(MAP_HUGETLB) || defined(MADV_HUGEPAGE)
static int accel_remap_huge_pages(void *start, size_t size, const char *name, size_t offset)
{
void *ret = MAP_FAILED;
void *mem;
mem = mmap(NULL, size,
PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS,
-1, 0);
if (mem == MAP_FAILED) {
return -1;
}
memcpy(mem, start, size);
# ifdef MAP_HUGETLB
ret = mmap(start, size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED | MAP_HUGETLB,
-1, 0);
# endif
# ifdef MADV_HUGEPAGE
if (ret == MAP_FAILED) {
ret = mmap(start, size,
PROT_READ | PROT_WRITE | PROT_EXEC,
MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
-1, 0);
if (-1 == madvise(start, size, MADV_HUGEPAGE)) {
munmap(mem, size);
return -1;
}
}
# endif
if (ret == start) {
memcpy(start, mem, size);
mprotect(start, size, PROT_READ | PROT_EXEC);
}
munmap(mem, size);
return (ret == start) ? 0 : -1;
}
#endif
Все достаточно просто. Мы создали новый временный буфер (mem), скопировали в него данные, затем с помощью mmap() попытались распределить выровненный буфер по большим страницам. Если попытка не увенчалась успехом, то можно подсказать ядру с помощью madvise(). После распределения сегмента по страницам копируем данные обратно и возвращаемся.
00400000-00c00000 r-xp 00000000 00:0b 1008956 /anon_hugepage
Size: 8192 kB
Rss: 0 kB
Pss: 0 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 0 kB
Referenced: 0 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 2048 kB
MMUPageSize: 2048 kB
Locked: 0 kB
00c00000-00db8000 r-xp 00800000 08:01 4196579 /home/julien.pauli/php70/nzts/bin/php
Size: 1760 kB
Rss: 224 kB
Pss: 224 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 224 kB
Private_Dirty: 0 kB
Referenced: 224 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Locked: 0 kB
8 Мб распределены по четырем большим страницам, а 1760 Кб — по стандартным. Мне это дало прирост производительности Zend в 3% при больших нагрузках.
При использовании больших страниц:
- в 512 раз снижается общее количество страниц виртуальной памяти;
- TLB задействуется гораздо активнее, что снижает частоту обращения к памяти;
- можно оптимизировать исполнение машинных инструкций, которые PHP загружает в ЦПУ.
Заключение
Теперь понятно, каким образом расширение OPCache для PHP 7 помогает повысить производительность системы при использовании теперь уже распространенной техники управления памятью, известной как «большие страницы».
Кстати, ряд СУБД (например, Oracle, PostgreSQL) уже несколько лет используют преимущества больших страниц.