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 the design left one problem: after reserving the type tags, val had only 61 payload bits left. That is enough for most 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 block 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 puts 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. This limit follows naturally and requires no change to the 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, switches, short status values, and empty results no longer compete with genuinely hot data.

3.0.0 continues from there. Another focus is the read path.

Versioned Snapshot

Yac's key area is a shared hash table with one slot per entry. Previously, reading a slot required a CAS coordination step in addition to fetching the key and metadata. Under high concurrency, reads and writes could still queue behind one another. In 3.0, I rebuilt this path entirely and replaced it with a versioned snapshot.

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. The reader never needs to queue.

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 no longer performs unnecessary coordination. When one does collide, it simply rereads the slot rather than returning a partial key, TTL, or value metadata.

The change is not complicated, but it is one of the main sources of the 3.0 improvement.

Epoch-based CRC

Yac's values block area is reused in a cycle. Under high concurrency, while one reader is reading a value block, another worker may be moving a segment into its next reuse cycle.

Previously, Yac performed a CRC-32C check whenever it read a block. It would rather treat suspicious data as a miss than hand incorrect data to PHP, avoiding potentially dirty data from reuse cycles.

But in read-heavy, write-light workloads, there is always a safe period between reuse cycles.

So I rebuilt this part in 3.0 and added epoch and segment state. Once a read completes and its CRC check passes, Yac records the current recycle epoch. On the next read, if that epoch has not changed, the data is safe. Thus, 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.

New Serializer

Earlier Yac releases used PHP serialization or json_encode(). Those general-purpose methods need their serialized results to be importable, exportable, and human-readable, while also pursuing serialization performance.

Yac's cache workload is different: writes are few and reads are many, so serialization is infrequent while deserialization dominates. Cached contents are also opaque to the user layer, so readability is unnecessary. In Yac 3.0, I therefore implemented Yac's own serializer and optimized it for deserialization performance.

To balance implementation complexity, the serializer supports the most common arrays and stdClass values, including referenced values within them when supported. This covers the great majority of cache cases. 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

This also reduces maintenance complexity to some extent.

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. Now has() makes that explicit.

Unlike get(), it does not update atime or hits. A presence check does not 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.

dump()

dump() is what I use most often when investigating cache behavior; it is also the most important method in the administration tools of the WordPress Yac plugin. 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 a short string stored 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 16 KB or larger values in the average. 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.