These two arrays hold the same million integers under the same million keys in
the same order, and === agrees they are equal. One of them occupies 16.0 MB
and the other 40.0 MB.
<?php
declare(strict_types=1);
$baseline = memory_get_usage();
$packed = range(0, 999_999);
printf("packed %s bytes\n", number_format(memory_get_usage() - $baseline));
$baseline = memory_get_usage();
$hashed = range(0, 999_999);
$hashed['total'] = 0;
unset($hashed['total']);
printf("hashed %s bytes\n", number_format(memory_get_usage() - $baseline));
var_dump($packed === $hashed);
packed 16,793,680 bytes
hashed 41,943,120 bytes
bool(true)
The second array briefly held a string key. That was enough to change how the engine stores it, and removing the key again did not change it back.
One structure, two layouts
The array is a single type doing the work of a list, a dictionary and a set, and the structure underneath is a hashtable. In its general form each element gets a bucket holding the value, the key and the key’s hash, and a separate index array maps a hash to a position among those buckets. A string key is hashed on the way in; an integer key is its own hash.
Buckets are appended in the order elements arrive, and the index points into
that sequence rather than defining it. This is why foreach is predictable in a
way a dictionary in most other languages is not — iteration walks the buckets,
so it yields insertion order, not hash order and not key order.
<?php
declare(strict_types=1);
$stock = ['widget' => 4, 'anvil' => 19, 'rope' => 7];
$stock['crate'] = 2;
foreach ($stock as $sku => $count) {
echo $sku, ' ', $count, PHP_EOL;
}
widget 4
anvil 19
rope 7
crate 2
That ordering guarantee is load-bearing for a great deal of PHP code, and it is also the reason the structure cannot be a plain vector. Every element carries the bookkeeping that makes ordering and arbitrary keys work — which is what the 40 MB above is mostly made of.
What the packed layout drops
When the keys are integers running from zero without arriving out of order, the engine drops to a packed layout: it stores the values in a row and derives the key from the position. There is no bucket and no index array, so a read is an offset rather than a hash lookup.
Measured on PHP 8.5.9, arm64, NTS, Homebrew build, on an Apple M4 Pro laptop running macOS and not quiesced, one million elements: the packed form held 16.79 bytes per element and the hashed form 41.94, a factor of 2.5. Sixteen of those bytes are the size of one value slot on a 64-bit build, and the rest is the key, the hash and the index the packed form does not keep. Both figures include capacity the array has allocated but not filled, which is why the packed number is not exactly 16. Every memory figure below is a single exact reading rather than a median: repeated runs return byte-identical values, so there is no variance to report.
Per-element overhead is diluted by whatever you store in the elements. Repeating the measurement at the same one million elements with fixed six-character strings as the values gave 49.59 against 74.74 bytes per element — a ratio of 1.51 rather than 2.50. The gap between the layouts did not move: 25.15 bytes, exactly where the integers left it. At sixteen characters it is 64.79 against 89.94, a ratio of 1.39, and the gap is still 25.15. The overhead belongs to the table and is a flat per-element charge; the ratio belongs to whatever you put in the elements.
What ends the packed layout, and what does not
The received wisdom is that unset() in the middle of a list breaks packing. On
this build it does not. I applied each operation to a fresh range(0, N - 1)
and read the layout back from the array’s footprint, where the two forms differ
by more than a factor of two:
| Operation | Layout afterwards |
|---|---|
unset() at the start, middle or end |
packed |
unset() of half the elements |
packed |
sort(), rsort(), usort(), shuffle() |
packed |
array_values(), array_map(), array_slice() |
packed |
| Appending an integer key immediately past the end | packed |
| Adding a string key | hashed |
| Adding a negative integer key | hashed |
| Adding an integer key far past the end | hashed |
ksort(), krsort(), asort() |
hashed |
array_filter() that drops elements |
hashed |
Two of those are worth pausing on. ksort() converted an array that was
already in key order — the sort had nothing to reorder and still cost 25 MB on
a million elements. And an integer key past the end is tolerated up to a
boundary that tracks the array’s allocated capacity rather than its element
count: appending index N to a million-element list kept it packed, while index
10N converted it. Where exactly that boundary sits, and why, I did not chase
into the C source.
What unset() does instead is nothing at all to the footprint:
<?php
declare(strict_types=1);
$rows = range(0, 999_999);
$before = memory_get_usage();
for ($i = 0; $i < 500_000; $i++) {
unset($rows[$i * 2]);
}
printf("after unsetting half: %+d bytes, %d elements left\n", memory_get_usage() - $before, count($rows));
$rows = array_values($rows);
printf("after array_values(): %+d bytes, %d elements left\n", memory_get_usage() - $before, count($rows));
after unsetting half: +0 bytes, 500000 elements left
after array_values(): -8388576 bytes, 500000 elements left
Half a million elements went away and the array released none of its memory. The rebuild released 8 MB, because it allocated for what was left rather than for what had once been there.
What the layout costs depends on how you read it
Memory is charged before the first read and does not move with how you read it: 2.5x here with integers in the elements, less as the values get larger. The time does move — it runs from a few percent to well over double, and which one you get is decided by the access pattern rather than by the layout.
This table uses 1,048,576 elements rather than the one million above, and the
change is deliberate: the random rows index the array with a linear congruential
generator masked to the array’s size, and a mask is only all-ones when the size
is a power of two. At one million, & 999999 has twelve bits set and reaches 512
distinct slots — a working set that fits in cache and reports nothing about
random access. Same keys, same values, same insertion order, same count, 15
interleaved runs per variant, opcache.jit=disable throughout, and each run
prints its own footprint so the two layouts are confirmed different at the moment
they are timed. Each cell is the median of its fifteen runs, with the full range
in parentheses:
| Access pattern | Packed | Hashed | Ratio |
|---|---|---|---|
| Sequential read | 4.597 ms (4.428–4.762) | 4.865 ms (4.738–5.049) | 1.058x |
| Sequential read, four per iteration | 3.797 ms (3.629–3.863) | 4.136 ms (3.974–4.318) | 1.089x |
| Random read | 17.284 ms (16.853–18.962) | 40.554 ms (39.255–41.192) | 2.346x |
| Random read, four per iteration | 16.155 ms (15.904–17.023) | 38.195 ms (36.353–39.694) | 2.364x |
| Write | 7.276 ms (7.139–7.381) | 8.802 ms (8.560–9.573) | 1.210x |
The sequential rows sit 6% apart with ranges that barely touch, so read that as a small effect this method can barely resolve. Quadrupling the reads per iteration widens it to 8.9% with the ranges clear of each other, which says loop overhead was diluting a real per-fetch difference rather than hiding nothing.
Random access is the row that matters. At 2.35x, with the two ranges nowhere near each other and the four-per-iteration variant agreeing at 2.36x, the hashed layout costs more than double — on a 40 MB table where almost every lookup is a cache miss and the hashed form has one more indirection to miss on. Writes land at 1.21x.
So a hashed list of a million integers costs 2.5x the memory, about 6% on a scan, and well over double on scattered lookups. A pass that walks the array start to finish barely notices; one that jumps around it pays twice. The memory is charged either way, and memory pressure is one of the costs that neither OPcache nor the JIT removes.
The alternatives and the conditions they need
SplFixedArray stores 16 bytes per element, the same as a packed array. Its
advantage is that it allocates the size you asked for, while an array rounds its
capacity up: at one million elements it measured 0.95x the packed array, at
1,048,576 exactly 1.00x, and at 1,048,577 — one element past a doubling —
0.50x. Against the hashed form it is smaller by 2.62x, 2.50x and 5.00x at those
same three sizes, the last because the hashed array’s capacity doubles there too.
It also has a fixed length and lacks array semantics, so it fits a buffer of
known size rather than a working list.
A generator sidesteps the question. Iterating a million values through a generator held 960 bytes at the high-water mark against 16.0 MB for the materialized list, because only one value exists at a time. The condition is that one pass is enough: nothing can be counted, sorted or read twice.
The third option is not building the list. A query that returns 50,000 rows so the application can sum a column materializes 50,000 arrays to produce one number, and the VM that walks them is doing it one opcode at a time.
What to do about it
Reach for the memory figure before the layout. memory_get_peak_usage(true)
against a realistic dataset tells you whether arrays are the problem at all, and
for most applications holding a few thousand rows the answer is that they are
not. The 25.15-byte gap above is the one-million figure; at a few thousand rows
capacity rounding moves it, and across sizes from 1,000 to 8,193 I measured
between 20 and 48 bytes per element. None of that buys a rewrite.
Where the layout does earn attention is a large array read out of order. That is
the one pattern above that cost more than double, so if a hot path indexes
scattered positions in a list of hundreds of thousands of elements, check whether
something converted it and whether array_values() puts it back.
When the number is large, count elements before optimizing bytes. Halving
per-element overhead saves half; not materializing the list at all saves
essentially the whole of it, and the measurements above put those two options
four orders of magnitude apart. Reach for SplFixedArray when the length is
known and fixed, for a generator when a single pass will do, and for
array_values() when a long-lived array has been holding capacity it stopped
needing.
Frequently asked
- Does unset() in the middle of a list make it slower?
- Not on PHP 8.5. The packed layout survives an unset, so lookups cost what they did before. What unset does not do is return the slot, so the array keeps its full footprint until something rebuilds it.
- Is SplFixedArray better for large lists?
- Against a hashed array it measured between 2.5 and 5 times smaller, depending on where the size falls against a power of two. Against a packed array it stores the same 16 bytes per element and wins only on the capacity a packed array rounds up to, which is nothing at some sizes and half the memory at others.
- Why does array_values() sometimes reduce memory?
- Because it rebuilds the array with sequential keys, which can restore the packed layout the original had lost and drops the unused capacity the original was holding.