Насколько большие массивы (и значения) в PHP? (Подсказка: ОЧЕНЬ БОЛЬШИЕ)

Original author: Nikita Popov
  • Translation
В этой статье я хочу исследовать расход памяти у массивов (и значений в целом) в PHP используя следующий скрипт в качестве примера, который создаёт 100 000 уникальных целочисленных элементов массива и в конце измеряет количество использованной памяти.

Это перевод (для таких как я, которые этого часто не замечают).

В начале я хочу поблагодарить Johannes и Tyrael за их помощь в поисках укромных мест расхода памяти.

<?php
$startMemory = memory_get_usage();
$array = range(1, 100000);
echo memory_get_usage() - $startMemory, ' bytes';


Как вы думаете сколько получится? Если целое число это 8 байт (на 64 архитектурах и используя тип long) и есть 100 000 целых чисел, то, очевидно, потребуется 800 000 байт. Это около 0,76 Мб.

Теперь попробуйте запустить код. Это можно сделать on-line. В результате получится 14 649 024 байт. Да, вы не ослышались, это 13,97 Мб — в 18 раз больше, чем мы прикинули.

Итак, откуда появилось это 18 кратное увеличение?

Краткое изложение



Для тех, кто не хочет разбираться со всем этим, вот краткий обзор вовлечённых компонент.

                             |  64 bit   | 32 bit
---------------------------------------------------
zval                         |  24 bytes | 16 bytes
+ cyclic GC info             |   8 bytes |  4 bytes
+ allocation header          |  16 bytes |  8 bytes
===================================================
zval (value) total           |  48 bytes | 28 bytes
===================================================
bucket                       |  72 bytes | 36 bytes
+ allocation header          |  16 bytes |  8 bytes
+ pointer                    |   8 bytes |  4 bytes
===================================================
bucket (array element) total |  96 bytes | 48 bytes
===================================================
total total                  | 144 bytes | 76 bytes


Приведённые выше числа могут меняться в зависимости от вашей операционной системы, компилятора и опций компилирования. Например, если вы компилируете PHP с debug или thread-safety, то получите различные значения. Но я думаю, что приведённые размеры вы увидите на рядовой сборке PHP 5.3 на 64 разрядном Линуксе.

Если умножить эти 144 байта на наши 100 000 чисел, то получится 14 400 000 байт, что составляет 13,73 Мб. Довольно близко к реальному результату, остальное — это в основном указатели для неинициализированных блоков(buckets), но я расскажу об этом позже.

Теперь, если вы хотите иметь более детальный анализ значений, которые указаны выше, то читайте дальше :).

Объединение zvalue_value



Сначала давайте обратим внимание на то, как PHP хранит значения. Как вы знаете PHP является слаботипизированным языком, поэтому ему нужен способ быстрого переключения между значениями. PHP использует объединение (union), который определён следующим образом zend.h#307 (комментарии мои):

typedef union _zvalue_value {
    long lval;                // Для целых и булей
    double dval;              // Для чисел с плавающей точкой
    struct {                  // Для строк
        char *val;            //     состоит из строки как таковой
        int len;              //     и её длины
    } str;
    HashTable *ht;            // Для массивов (хеш-таблицы)
    zend_object_value obj;    // Для объектов
} zvalue_value;


Если вы не знаете C, то это не проблема — код очень прост: объединение означает, что значение может выступать в роли различных типов. Например, если вы используете zvalue_value->lval, то значение будет интерпретировано как целое число. С другой стороны, если используете zvalue_value->ht, то значение будет интерпретировано как указатель на хеш-таблицу (aka массив).

Не будем на этом задерживаться. Важным для нас только то, что размер объединения равен размеру его крупнейшего компонента. Самый большой компонент — это строка (на самом деле структура zend_object_value имеет тоже размер, но этот момент я опущу для простоты). Структура состоит из указателя (8 байт) и целого числа (4 байта). Итого 12 байт. Благодаря выравниванию памяти (структуры в 12 байт — это не круто, потому что они не являются произведением 64 бит/8 байт) конечный размер структуры будет 16 байт и, соответственно, всего объединения в целом.

Итак, теперь мы знаем, что нам нужно не 8 байт для каждого значения, а 16 — за счёт динамической типизации PHP. Умножив на 100 000 получим 1 600 000 байт, т.е. 1,53 Мб. Но реальный объём 13,97 Мб, поэтому мы не достигли пока цели.

Структура zval



Вполне логично, что union хранит только значение, а PHP, очевидно, нужно хранить так же его тип и некоторую информацию для сборки мусора. Структура, которая содержит эту информацию, называется zval и вы, наверное, уже слышали о ней. Для получения дополнительной информации о том, зачем это PHP, я рекомендую прочитать статью Sara Golemon. Как бы то ни было эта структура определяется следующим образом:

struct _zval_struct {
    zvalue_value value;     // Значение
    zend_uint refcount__gc; // Количество ссылок на значение (для GC)
    zend_uchar type;        // Тип
    zend_uchar is_ref__gc;  // Является ли это значение ссылкой (&)
};


Размер структуры определяется суммой размеров всех её компонент: zvalue_value — 16 байт (расчёт выше), zend_uint — 4 байта, zend_uchar — 1 байт каждый. В общей сложности 22 байта. Опять же из-за выравнивания памяти реальный размер будет 24 байта.

Так что, если мы храним 100 000 значений по 24 байта, то это будет 2 400 000 байт или 2,29 Мб. Разрыв сокращается, но реальное значение ещё более чем в шесть раз больше.

Сборщик мусора для циклических ссылок (PHP 5.3)



PHP 5.3 представила новый сборщик мусора для циклических ссылок. Для этого PHP хранит некоторую дополнительную информацию. Я не хочу здесь объяснять как это работает, вы можете почерпнуть необходимую информацию из мануала. Для наших расчётов размеров важно, что каждый zval оборачивается zval_gc_info:

typedef struct _zval_gc_info {
    zval z;
    union {
        gc_root_buffer       *buffered;
        struct _zval_gc_info *next;
    } u;
} zval_gc_info;


Как вы видите Zend только добавляет объединение, которое содержит два указателя. Как вы помните размер объединения определяется самым большим компонентом. Оба компонента — это указатели по 8 байт. Соответственно, размер объединения тоже 8 байт.

Если мы добавим полученные выше 24 байта, то мы получим 32 байта. Умножаем это на 100 000 и полуаем 3,05 Мб.

Менеджер памяти ZEND



Си, в отличие от PHP, не управляет памятью за вас. Вы должны самостоятельно следить за распределением памяти. Для этого PHP использует оптимизированный для своих нужд собственный менеджер памяти: The Zend Memory Manager. MM Zend основан на malloc от Doug Lea и всяческих дополнительных специфических для PHP особенностей и оптимизаций (таких как ограничение памяти, очистка после каждого запроса и тому подобное).

Что важного для нас в этом так это то, что MM добавляет заголовок для каждого выделения памяти, которое проходит через него. И определяется следующим образом:

typedef struct _zend_mm_block {
    zend_mm_block_info info;
#if ZEND_DEBUG
    unsigned int magic;
# ifdef ZTS
    THREAD_T thread_id;
# endif
    zend_mm_debug_info debug;
#elif ZEND_MM_HEAP_PROTECTION
    zend_mm_debug_info debug;
#endif
} zend_mm_block;

typedef struct _zend_mm_block_info {
#if ZEND_MM_COOKIES
    size_t _cookie;
#endif
    size_t _size; // выделенный размер
    size_t _prev; // предыдущий блок (не уверен что это)
} zend_mm_block_info;


Как вы видите определение включает в себя множество проверок опций компилирования. Если хотя бы одна из эти опций будет включена заголовок для выделенной памяти будет больше, и будет он самым большим, если вы скомпилируете PHP с защитой кучи(heap protection), защитой потоков(thread safety), отладкой и MM cookies.

Для примера мы будем считать, что все эти опции отключены. В этом случае остается только две компоненты size_t _size и _prev. size_t занимет 8 байт (64 бита), так что заголовок имеет размер в 16 байт — и этот заголовок добавляется для каждого выделения памяти.

Так что мы должны скорректировать размер zval снова. На самом деле это будет не 32 байта, а 48, из-за этого заголовка. Умножаем на наши 100 000 элементов и получаем 4,58 Мб. Реальный размер 13,97 Мб, так что мы уже покрыли примерно треть.

Блоки



До сих пор мы рассматривали значения по отдельности. Но структура массива в PHP забирает много места. На самом деле термин «Массив» здесь подобран неудачно. В PHP массив — это на самом деле хеш таблицы/словари. Так как же хэш-таблицы работают? В основном для каждого ключа генерируется хэш, и этот хэш используется для перехода в «реальный» C массив. Хэши могут конфликтовать, все элементы, которые имеют одинаковые хэши хранятся в связанном списке. При обращении к элементу PHP сначала вычисляет хэш, ищет нужный блок(bucket), и проходит по списку в поисках точного совпадения элемент за элементом. Блок определяется следующим образом (zend_hash.h#54):

typedef struct bucket {
    ulong h;                  // Хэш (или ключ для целочисленных ключей)
    uint nKeyLength;          // Длина ключа (для строковых ключей)
    void *pData;              // Данные
    void *pDataPtr;           // ??? Что это ???
    struct bucket *pListNext; // PHP массивы упорядочены. Указатель на следующий элемент
    struct bucket *pListLast; // и на предыдущий
    struct bucket *pNext;     // Следущий элемент в этом (двойном) связном списке
    struct bucket *pLast;     // Предыдущий элемент в этом (двойном) связном списке
    const char *arKey;        // Ключ (для строковых ключей)
} Bucket;


Как вы видите необходимо хранить «груз» данных, чтобы получить абстрактный массив данных вроде такого, какой используется в PHP (массивы PHP являются массивами, словарями и связными списками в одно и тоже время, что, конечно, требует много данных). Размер отдельных компонент это: 8 байт для типа ulong, 4 байта для uint и 7 раз по 8 байт для указателей. В результате получается 68. Добавляем выравнивание и получаем 72 байта.

Для блоков как и для zval должны быть добавлены заголовки в 16 байт, что даёт нам 88 байт. Так же нам нужно хранить указатели на эти блоки в «настоящем» массиве C (Bucket **arBuckets;), я упомнил об этом выше, что добавляет ещё 8 байт на элемент. Так что в целом каждый блок расходует в 96 байтах памяти.

И так, если нам нужен блок для каждого значения — это будет 96 байт для bucket и 48 байт для zval, что составляет 144 байта в общей сложности. Для 100 000 элементов это будет 14 400 000 байт или 13,73 Мб.

Загадка решена.

Подождите, осталось ещё 0,24 Мб!



Эти последние 0,24 Мб обусловлены неинициализированными блоками: размер «реального» массива C в идеале должен быть равен количеству элементов. Таким образом мы получаем наименьшее количество коллизий (если вы не хотите тратить много памяти). Но PHP, очевидно, не может перераспределять весь массив каждый раз когда добавляется новый элемент — это было бы ооочень медленно. Вместо этого PHP всегда удваивает размер внутреннего массива блоков, если оно попадает в предел. Таким образом, размер массива всегда является степенью двойки.

В нашем случае это 2 ^ 17 = 131 072. Но нам нужно только 100 000 из этих блоков, поэтому мы оставляем 31 072 блока неиспользованными. Те, память под эти блоки выделена не будет (поэтому нам не надо тратить полные 96 байт), но память под указатель(который хранится в внутреннем массиве блоков) на блок должна быть использована. Поэтому мы дополнительно используем 8 байт (на указатель) * 31 072 элементов. Это 248 576 байт или 0,23 Мб. Что соответствует недостающей памяти. (Конечно, отсутствуют ещё несколько байт, но я не хочу полностью покрыть всё. Это такие вещи как сама структура хэш-таблицы, переменные и т.д.)

Загадка действительно решена.

О чём нам это говорит?



PHP не C. И это говорит нам только об этом. Вы не можете ожидать от супер-динамического языка PHP эффективного использования памяти как в C. Не можете и всё.

Но, если вы хотите сохранить память вы можете рассмотреть использование SplFixedArray для больших статических массивов.

Посмотрим на модифицированный скрипт:

<?php
$startMemory = memory_get_usage();
$array = new SplFixedArray(100000);
for ($i = 0; $i < 100000; ++$i) {
    $array[$i] = $i;
}
echo memory_get_usage() - $startMemory, ' bytes';


В основном он делает то же самое, но, если вы его запустите, то вы заметите, что он использует «всего лишь» 5 600 640 байт. Что составляет 56 байт на элемент, а это намного меньше, чем 144 байта на элемент обычного массива. Это происходит потому, что фиксированный массив не нуждается в bucket структуре: так что требуется только один zval (48 байт) и один указатель (8 байт) для каждого элемента, что даст нам наблюдаемые 56 байт.

P.S. Все замечания по поводу перевода прошу писать в ЛС, а я постараюсь оперативно их исправить.
Share post

Comments 57

    +6
    Спасибо! Статья интересная!
    Интересно как дела обстоят с памятью в руби и питоне?
      +1
      О перле уже ни кто не вспоминает :-))
        +8
        #!/usr/bin/perl
        $startMemory = `ps h -o vsz $$`;
        @array = (1 .. 100000);
        print `ps h -o vsz $$` - $startMemory;

        1.9 Мб на 32-битной, или 3Мб на 64-битной системе.
          0
          offtopic: Наблюдение из жизни на Perl пишут обычно довольно хорошие программисты. Не хочу никого задеть, но perl крут.
            0
            Читая интернеты, тоже создаётся такое впечатление, но в жизни бывает наоборот, если исходить из моего личного опыта.
              +1
              Думаю, тут дело в что то рассвет Perl был раньше, и люди подсевшие на него в то время, сейчас набрались большего экспириенса, чем люди только начинающие и выбирающие популярные сегодня языки.
          0
          В Питоне a = range(100000) дает увеличение памяти:
          4,1 мб → 7,2 мб. Того ровно 31 байт на значение.

          Это 64-х битная OS X. Кстати, Пэхапэ для скрипта приведенного в самом начале выдает 21 мегабайт.
            +5
            Как отмечалось выше, PHP много места резервирует для работы сборщика мусора. В реальном примере работа с большими массивами /объектами производится в пределах метода или функции, а это значит, что при выходе из него все неиспользуемые переменные в данной области видимости удаляются, и внутренняя память PHP освобождается.

            Это хорошо что питон расходует меньше памяти чем PHP для хранения массива, но ввиду того что я не знаком с работой сборщика мусора в питоне, это не дает мне права судить на сколько хорошо он работает с памятью.
              –7
              Из вашего сообщения мы узнали что вы не знаете как работает сборщик мусора в Питоне и больше ничего. Пожалуйста, держите нас в курсе событий.
                +2
                Вы не могли не заметить, что мое сообщение выше является ответом на ваш пример, который с моей точки зрения немного однобокий. Очень жаль, что вместо того что бы поделится своими знаниями, которые судя по профилю у вас есть, вы решили показать свой характер.
                  –3
                  Что-то я не заметил, что вы меня о чем-то спросили. Буду отвечать наугад.
                  В Питоне (речь конечно про cPython) как и в Пэхапэ есть подсчет ссылок и сборщик мусора для циклических ссылок. «Переменные при выходе их области видимости удаляются» и там и там. «Это хорошо что питон расходует меньше памяти чем PHP для хранения массива» — определенно.

                  Различия скаладываются во-первых оттого, что в Питоне списки это тип данных со строго определенным функционалом, а не все в кучу как Array в Пэхапэ. И нет такого понятия, как переменная-ссылка. Поэтому подсчет ссылок для значений не ведется, только для переменных.

                  Ну и если рассматривать способы уменьшить расход памяти, можно вспомнить массивы numpy, кторые занимают ровно столько памяти, сколько нужно их элементам. Но естественно, все элементы в таком случае будут одного типа.
                    –5
                    Ну а счас то, блядь, что не так в моем комментарии? Откуда минусы, ебаный в рот?
                      0
                      Гы, Вы не гламурный и использовали много непонятных слов.

                      numpy — я так понял python расширение, в php для массивов-стеков-очередей и т.д. есть соотвественные классы в spl, которые тоже дают серьёзный выигрыш в памяти и скорости. Если кому интересно www.php.net/manual/ru/spl.datastructures.php
                        0
                        Дают серьёзный выигрыш, который все равно меньше чем использование обычных списков в Питоне. Цифры в статье и моем комментарии.
                          0
                          Угу, я это прекрасно понимаю, просто привёл маленький костыль для структур, существующий в php, а то многие, судя по собеседованиям, этих вещей не знают.
              +4
              memoryBefore = `ps h -o vsz #{Process.pid}`.to_i
              
              v = (1..100000).to_a
              
              memoryAfter = `ps h -o vsz #{Process.pid}`.to_i
              
              puts "Mem: #{memoryAfter - memoryBefore}"
              


              560Kb (32bit Debian Squeeze, ruby 1.9.3-p125).
              0
              А кроме Reference counting GC другого сборщика мусора в PHP нет? Довольно странно увидеть использование такого типа GC для динамически компилируемого языка.
                +2
                Хотя, с другой стороны, учитывая что скрипт живет достаточно короткое время, данный тип сборки мусора может быть наиболее оптимальным.
                  0
                  ну, он с хитрым костылём, чтобы reference cycles не приводили к утечкам памяти www.php.net/manual/en/features.gc.collecting-cycles.php

                    +4
                    Хитрость эта известная очень: en.wikipedia.org/wiki/Reference_counting#Dealing_with_reference_cycles. Просто интересно, насколько большой overhead даст скажем Generational Mark-Copying GC на коротко-живущий процессах.
                      +6
                      >> Хитрость эта известная очень

                      Йоды магистра последователь обнаружен
                  –16
                  Почему вы «Си» называете «С»? Как читать такого уродца?
                  C в отличие от PHP не управляет памятью за вас. Вы должны самостоятельно следить за распределением памяти.
                    +8
                    а почему вы PHP называете PHP а не «пиэйчпи» или «похапе» или одно из кучи других фонетических прочтений в меру своей грамотности?
                      –7
                      Кто вам сказал, что не называю? Но мы, кажется, другое название тут обсуждаем? Ни разу на моей памяти обсуждение двух вещей одновременно не приводило к конструктиву.

                      «С» вполне себе реально спутать с предлогом «С». На мой взгляд, это проблема. Кроме того, я не понимаю почему не писать «Си», если язык так и называется на русском.
                        +4
                        У Вас я думаю достаточно хороший парсер в голове особенно когда вы на информатическом ресурсе.
                          –4
                          Обычно я распознаю всё правильно, а в этом случае запнулся. Потому что сразу непонятно что это — название языка или предлог. Вам кажется, что всё ок, потому что вы редко читаете вслух. Вы попробуйте.
                            –8
                            разве в английском есть предлог «C», в статье написана именно C (англ.) а не С (рус.)
                              +14
                              Вы меня извините, но я на глаз не умею отличать англ. «С» от рус. «С».
                                –8
                                плохо
                                  0
                                  > P.S. Все замечания по поводу перевода прошу писать в ЛС, а я постараюсь оперативно их исправить.
                                  0
                                  Если косяк с кодировками, то вполне себе можно отличить :)
                                    0
                                    Как вариант — пользовательский шрифт с различным начертанием для латиницы и кириллицы.
                                    –2

                                    — У тебя ушей робота нет
                        • UFO just landed and posted this here
                          0
                          14 метров… многовато конечно. Буду чаще делать unset теперь
                            +2
                            Про сборщик мусора не забывайте, а то будете под конец работы метода unsetы ставить.
                              0
                              а что нужно помнить про GC что б под конец работы метода ансеты не ставит ь?
                                +1
                                При выходе из области видимости, локальные переменные будут освобождены автоматически.
                            +7
                            Перевод просто отличный. Наверное, первый раз читал статью и не знал, что это перевод пока в самом низу не наткнулся на ссылку.
                              +2
                              offtop: Плохо читали, после первого абзаца примечание :)
                                0
                                виноват) пропустил вступление и сразу приступил к выкладкам потому что сам недавно столкнулся с подобной ситуацией в Apache AVRO
                                0
                                Спасибо, старался.
                                –1
                                codepad.viper-7.com лежит. Хабраэффект?
                                  0
                                  (Ссылка «Это можно сделать on-line»)
                                  +1
                                  В теории интересно, но на практике странно это — писать на PHP нечто обрабатывающее очень большие объемы данных, лично я ни разу не встречал массивов на 100к элементов в PHP, все решалось другими способами.
                                    0
                                    сейчас обрабатываю массив 32 000 000… и это еще маленький )))
                                    php тест из статьи — 21050840 байт
                                    на питоне

                                    os.system('ps h -o vsz PID')
                                    a = range(1,100000)
                                    3140K
                                    все тестировалось на Linux dn 2.6.32-5-amd64 (debian)
                                      +1
                                      Когда я увлекся покером, то пытался на РНР сделать рассчет возможных комбинаций… около 2.5M их было. Ни разу не смог дождаться завершения работы скрипта.
                                      Потом переписал все на С (или С++, не помню уже). Программа отработала за 6 секунд, и выдала все, что я от нее хотел.
                                      Это я к чему, может РНР — не лучший инструмент для обработки таких обьемов данных?
                                        0
                                        Тут смотря какая задача. Если просто обработать несложным алгоритмом даже большой массив 1 — 2 раза, то все ок. А вот если алгоритм сложный то это будет либо долго либо никто не даст гарантий что оно до конца выполнится. Все таки php по-моему предназначен для выполнения чего-нибудь за некоторое конечное количество времени, в идеале до 30 секунд. Да конечно и демонов можно на нем писать, я даже как-то пробовал… но стабильность и скорость очень сильно хромает.
                                      0
                                      а в Python, разве range — массив создает а не итератор?
                                        0
                                        docs.python.org/library/functions.html#range
                                        function to create lists containing arithmetic progressions
                                          +1
                                          xrange итератор
                                            +1
                                            В версии 2 отдает список, начиная с третьей — итератор.
                                            0
                                            >> супер-динамического языка PHP
                                            оххх
                                              –1
                                              У меня в x64 бунте вообще 24 метра вышло. :(
                                              Пора валить в питонисты.
                                                –1
                                                … всегда голодный этот php, бесконечные циклы долго выполняет… да-да, а еще php не может пылесосить, и мыть посуду.
                                                Просто его нужно использовать по назначению, как я считаю, и с умом.
                                                  –1
                                                  В ситуациях, когда нужна скорость, можно ли каким-то образом сохранить образ массива (без сериализации, без igbinary) и затем просто восстановить его в памяти, не пересоздавая его заново?
                                                  В C — да, memcpy и вперёд, хоть по сети передавай, хоть в кэш ложи. В PHP так невозможно?
                                                    0
                                                    Невозможно.

                                                  Only users with full accounts can post comments. Log in, please.