Press "Enter" to skip to content

Yac 3.0 Released: 42% Higher Throughput for Common Cache Workloads

When I released Yac 2.4.0, the focus was small values: null, booleans, small integers, short strings, and empty arrays. If a value did not need to enter the values area, it should not.

That feature was called embedded. The idea was simple: the val field in a slot was already a pointer, aligned to 8 bytes, so its bottom three bits were always available. I used those bits as type tags and stored the value in the remaining bits. A small value then needed no block allocation, no space in the values area, and no extra copy.

But it had one hard limitation: after reserving the type tags, val had only 61 payload bits left. That is enough for integers; true, false, and null are no problem either; it can even hold a little of a short string. But it cannot hold a double.

A double needs all 64 bits of its IEEE 754 representation. Lose three bits and it is no longer the same value. Saving one block is not worth reading back 3.1415926 as something else. So 2.4 embedded only special-cased 0.0; other double values still had to go into the values area.

Then I reconsidered the layout. Yac keys are limited to 48 bytes, but most real keys do not come close to using that space: foo, alloptions, is_blog_installed, and so on. If a slot already reserves 48 bytes for a key, why not use the unused tail for the value when the key is short enough?

That became inline storage in 3.0.

Short Values Inline

Inline storage does not squeeze more bits into val. It places the value immediately after the key in the same slot. As long as:

strlen($key) + strlen($value) <= 48

the value does not need to enter the values area.

That means not only short strings but also double values can be stored inline:

$yac->set('pi', 3.1415926);     // A short key leaves room for the 8-byte double
$yac->set('status', 'ok');      // Short strings work the same way
$yac->set('feature:new_checkout', true);

The shorter the key, the more room remains for the value. A 48-byte key leaves no inline space, so Yac allocates a values block as before. The limit follows naturally from the existing slot layout.

The benefit is the same as embedded storage: one fewer allocation, one fewer copy, and less memory consumed in the values area. This is particularly useful for WordPress, where short options, status values, and empty results no longer compete with genuinely hot data.

3.0.0 also continued work on the read path.

In a benchmark with 16 workers, a 100:1 read/write ratio, 160 MB of shared memory, and compression disabled, Yac 3.0 delivers a 42.3% geometric-mean throughput increase over 2.4.2 across common values from 6 B to 8 KB. Common PHP workloads involving booleans, integers, switches, and short values improve by as much as 95.4%.

Those are aggregate cache operations per second, not a 42% page-speed improvement. A page also includes PHP, databases, templates, network calls, and plugins; the cache is only one component. It is worth stating that upfront before someone reads the headline as “a WordPress page is 42% faster.” 😂

Versioned Snapshot

Yac's key area is a shared hash table with one slot per entry. Previously, reading a slot required an additional coordination step on top of fetching the key and metadata. In 3.0, that path uses a versioned snapshot instead.

The idea comes from Linux kernel seqcount/seqlock: a writer changes the sequence to an odd number before publishing an update, then changes it back to an even number when it is done. A reader loads the sequence before and after copying the data. If both values are equal and even, the copy is a complete snapshot; otherwise, it retries.

seq1 = read_seq();
if (seq1 & 1) {
    retry;
}

copy_slot();
seq2 = read_seq();

if (seq1 != seq2) {
    retry;
}

PHP-FPM workloads are typically read-heavy. When no write collides with a read, the reader avoids unnecessary coordination. When one does collide, it simply rereads the slot rather than returning a partial key, TTL, or value metadata.

It is not a flashy change, but it is one of the main sources of the 3.0 improvement.

Epoch-based CRC

Yac's values area is reused in a cycle. While one reader is reading a value block, another worker may move a segment into its next reuse cycle.

Previously, Yac calculated CRC-32C when reading a block. It would rather treat suspicious data as a miss than hand incorrect data to PHP. That behavior must not disappear just because the fast path is being optimized.

3.0 adds epoch and segment state. When a reader can prove that a block remained stable for the entire read, it skips the CRC. When it cannot prove that, it still falls back to CRC validation.

In other words: do less on the fast path, but still validate at the boundary.

A New Serializer

Earlier Yac releases used PHP serialization or json_encode(). Those general-purpose formats need to support importing, exporting, and human-readable output. Yac 3.0 instead implements a faster internal serializer tailored to Yac's workload.

Supported arrays and stdClass values, including supported referenced values within them, use Yac's internal serializer. Unsupported objects and values still fall back to native PHP serialization.

What can take the fast path does; everything else continues to work as before.

The former yac.serializer setting, optional JSON, MsgPack, and igbinary serializers, and their corresponding build options are gone in 3.0:

; Removed in 3.0; do not configure this anymore
; yac.serializer = igbinary

Yac::has() and Property Checks

3.0 adds Yac::has():

$yac = new Yac();
$yac->set('flag', false);

var_dump($yac->has('flag'));    // bool(true)
var_dump($yac->has('missing')); // bool(false)

false and null are both valid cached values, so using get() to answer “does this key exist?” has never been ideal. has() now makes that explicit.

More importantly, it does not update atime or hits. A presence check should not also make an entry look recently accessed.

The property syntax behaves the same way:

$yac->set('n', null);
$yac->set('f', false);
$yac->set('e', '');

isset($yac->n);                 // false
isset($yac->f);                 // true
empty($yac->n);                 // true
empty($yac->f);                 // true
empty($yac->e);                 // true
property_exists($yac, 'n');     // true
property_exists($yac, 'none');  // false

Since 3.0, these checks do not affect LRU information either.

More Detail in dump()

dump() is what I use most often when investigating cache behavior. In 3.0, it includes three additional fields:

  • kind: the value type;
  • loc: where the value is stored;
  • enc: how the value is encoded.
Array
(
    [kind] => 5
    [loc] => 1
    [enc] => 0
    [key] => foo
)

For loc, YAC_LOC_BLOCK means a block in the values area, YAC_LOC_VALWORD means the slot value word, and YAC_LOC_INLINE means the value is stored inline in the slot.

For enc, YAC_ENC_NONE means no serialization, YAC_ENC_PHP means PHP serialization, and YAC_ENC_YAC means Yac's internal serializer.

The old embedded field stopped at 2.4.2. In 3.0, loc is all you need.

There is one more small change: for instances with a prefix, dump() now returns the key that the caller actually passed, without Yac's internal prefix.

$yac = new Yac('app:');
$yac->set('foo', 'bar');

// In dump() output from 3.0:
// [key] => foo

Performance

Test environment: MacBook Pro M5 Pro, PHP 8.5, 16 workers, a 100:1 read/write ratio; Yac and APCu both used 160 MB of shared memory, with compression disabled. These are aggregate operations per second across all workers, not a sum of single-process results.

Value size Yac 3.0 APCu 5.1.28 Memcached 3.4.0 Yac / APCu Yac / Memcached
6 B 129.5M 1.19M 0.105M 108.4x 1237.9x
64 B 99.1M 1.23M 0.103M 80.6x 964.8x
1 KB 39.4M 1.29M 0.106M 30.6x 371.8x
8 KB 15.5M 1.43M 0.098M 10.8x 158.1x
16 KB 9.51M 1.36M 0.093M 7.0x 101.9x

Yac 3.0 is clearly ahead of APCu and local Memcached at every tested size: it is 7.0x to 108.4x faster than APCu, and 101.9x to 1237.9x faster than Memcached.

Compared with Yac 2.4.2, 3.0 improves geometric-mean throughput by 42.3% across the matched 6 B to 8 KB workloads. The val-word path for booleans, integers, switches, and short values improves by as much as 95.4%.

These figures describe Yac's own read/write performance. Results will vary with CPU, PHP version, worker count, key distribution, read/write ratio, values-memory size, and data shape. Run it on your own machine and workload to know what it means for you.

I did not include larger values such as 16 KB in the geometric mean. At that size, memory-bandwidth limits are more likely to dominate, so the result reflects the platform as much as the cache implementation.

Upgrading

The current release is 3.0.1. It includes the main 3.0.0 read-path work, API additions, and compatibility changes.

pecl upgrade yac

# Or
pie install laruence/yac

# Build a specific release from source
curl -LO https://github.com/laruence/yac/archive/refs/tags/3.0.1.tar.gz
tar xf 3.0.1.tar.gz && cd yac-3.0.1
phpize && ./configure && make && sudo make install

Before upgrading, search your configuration:

grep -R "yac.serializer|enable-json|enable-msgpack|enable-igbinary" /etc/php* 2>/dev/null

Remove any matching settings. Scripts that analyze dump() output should also switch from embedded to loc, and account for the fact that 3.0 no longer includes an internal prefix in returned keys.

Windows DLLs are currently available through PHP 8.5: https://github.com/laruence/yac/releases/

In Closing

Yac is still Yac, just more than 40% faster. Give the upgrade a try.

Project: https://github.com/laruence/yac

Be First to Comment

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.