From 1e2a64db40d24ac440b03a30b474ce1b2c46b90a Mon Sep 17 00:00:00 2001 From: Hari K T Date: Tue, 8 Sep 2026 19:59:19 +0530 Subject: [PATCH] Add the benchmark behind the Redis session storage decision Issue #95 asked for hash-based per-field session storage on the grounds that it avoids transferring the whole session. #101 shipped a string instead. That decision rested on numbers that lived only in a PR description, so nobody could check them. This is the measurement, as a script anyone can run: one request under PHP's session lifecycle, string versus per-field, across session shapes from 2x100B up to 20x20KB, with a README recording the method, the results, and what they mean. The finding is narrower than either side of the original discussion assumed. Below ~3KB of session the two are indistinguishable once the two round trips both shapes pay are accounted for. Per-field pulls ahead above ~10-20KB, but only when most segments go untouched -- a request that reads every segment is slower per-field than as a string. --- benchmarks/README.md | 86 ++++++++++++++++ benchmarks/session-shape.php | 189 +++++++++++++++++++++++++++++++++++ 2 files changed, 275 insertions(+) create mode 100644 benchmarks/README.md create mode 100644 benchmarks/session-shape.php diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..b2bd67c --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,86 @@ +# Benchmarks + +Standalone scripts. They are not part of the test suite and are not run in CI; +they exist so that performance claims about this package can be re-checked +rather than taken on trust. + +## `session-shape.php` + +Answers the question behind [issue #95](https://github.com/auraphp/Aura.Session/issues/95): +is it faster to store a session as one Redis **string**, or as a **hash with one +field per segment** so that a request reads and writes only the fields it +touches? + +### Running it + +Needs `ext-redis` and a Redis server. Point it at a throwaway server — it only +writes keys prefixed `aura-bench:` and deletes them afterwards, and never +flushes the database, but there is no reason to aim it at anything real. + +``` +redis-server --port 6399 --save '' --appendonly no --daemonize yes +php benchmarks/session-shape.php 127.0.0.1 6399 +redis-cli -p 6399 shutdown nosave +``` + +Defaults to `127.0.0.1:6379` if no host and port are given. + +### What it measures + +One HTTP request under PHP's session lifecycle, which reads once at +`session_start()` and writes once at `session_write_close()`: + +| shape | read | write | +|-------|------|-------| +| string | `GET` whole session | `SETEX` whole session | +| per-field, 1 segment | `HGET` one segment | `HSET` one segment | +| per-field, all segments | `HGETALL` | `HMSET` | + +The string row is what `RedisSessionHandler` does today. "per-field, 1 segment" +is the best case for the Segment-backed redesign #95 asks for; "per-field, all +segments" is its worst case. + +Session shapes are varied from 2 segments of 100 bytes (a typical auth identity +plus a flash message) up to 20 segments of 20 KB. + +### Reading the output + +**Both shapes make exactly two round trips.** Latency is therefore paid equally +by both and cancels out; what differs is bytes on the wire and the +serialization work. The script adds modelled wire time for a 1 Gbit LAN and a +100 Mbit link, but round-trip latency is deliberately excluded from those +figures — at a 0.5 ms RTT a request already spends ~1 ms waiting before any of +these numbers apply. + +So read the **absolute** saving, not the percentage. A "90% saving" on a figure +that omits 1 ms of unavoidable latency is a few percent of the real request. + +### Results as of 2026-09, Redis 6.2.6, PHP 8.4.1, 2000 iterations + +| session shape | total bytes | string | per-field, 1 seg | per-field, all | +|---|---:|---:|---:|---:| +| 2 seg × 100B | 368 | 0.044 | 0.043 | 0.045 | +| 3 seg × 300B | 1,149 | 0.044 | 0.043 | 0.046 | +| 5 seg × 500B | 2,911 | 0.048 | 0.047 | 0.048 | +| 5 seg × 2000B | 10,416 | 0.047 | 0.044 | 0.051 | +| 10 seg × 2000B | 20,827 | 0.059 | 0.046 | 0.065 | +| 20 seg × 2000B | 41,657 | 0.072 | 0.045 | 0.085 | +| 20 seg × 20000B | 401,677 | 0.244 | 0.056 | 0.282 | + +(ms per request; lower is better.) + +### Conclusions + +- **Below ~3 KB of session — the realistic case — there is no difference.** + Adding 1 Gbit wire time, per-field saves 0.007–0.038 ms per request, against + the ~1 ms both shapes spend on two round trips. That is 1–4% of real cost. +- **The crossover is around 10–20 KB of session.** At 41 KB per-field saves + 0.66 ms on a LAN; at 400 KB it saves 6.3 ms. Those are worth having. +- **Per-field is *slower* when a request touches every segment** — 0.085 vs + 0.072 ms at 41 KB — because it pays hash overhead and per-field serialization + while moving the same bytes. The win depends on most segments going untouched, + not merely on the session being large. + +Hence the shipped handler stores a string. A Segment-backed store doing +`HGET`/`HSET` per segment is worth building for large sessions with a sparse +access pattern, and is not worth it otherwise. diff --git a/benchmarks/session-shape.php b/benchmarks/session-shape.php new file mode 100644 index 0000000..ed8c581 --- /dev/null +++ b/benchmarks/session-shape.php @@ -0,0 +1,189 @@ +connect($host, $port); + +const PREFIX = 'aura-bench:'; + +/** Remove only the keys this script created. */ +function cleanup(Redis $redis): void +{ + $it = null; + while (($keys = $redis->scan($it, PREFIX . '*', 1000)) !== false) { + if ($keys !== []) { + $redis->del($keys); + } + } +} + +cleanup($redis); + +const ITERATIONS = 2000; +const WARMUP = 200; + +/** Build a session of $segments segments, each about $bytes of payload. */ +function makeSession(int $segments, int $bytes): array +{ + $session = []; + for ($i = 0; $i < $segments; $i++) { + $session["Vendor\\Package\\Segment{$i}"] = [ + 'user_id' => 1000 + $i, + 'payload' => str_repeat('x', $bytes), + ]; + } + return $session; +} + +function timeIt(callable $fn): float +{ + for ($i = 0; $i < WARMUP; $i++) { $fn(); } + $start = hrtime(true); + for ($i = 0; $i < ITERATIONS; $i++) { $fn(); } + return (hrtime(true) - $start) / ITERATIONS / 1e6; // ms per op +} + +$scenarios = [ + ['segments' => 2, 'bytes' => 100], + ['segments' => 3, 'bytes' => 300], + ['segments' => 5, 'bytes' => 500], + ['segments' => 5, 'bytes' => 2000], + ['segments' => 10, 'bytes' => 2000], + ['segments' => 20, 'bytes' => 2000], + ['segments' => 20, 'bytes' => 20000], +]; + +printf("Redis %s at %s:%d, PHP %s, %d iterations\n\n", + $redis->info('server')['redis_version'], $host, $port, PHP_VERSION, ITERATIONS); + +printf("%-18s %10s %10s | %9s %9s %9s | %s\n", + 'session shape', 'total', 'one seg', 'string', 'pf(1seg)', 'pf(all)', 'verdict'); +printf("%s\n", str_repeat('-', 108)); + +$rows = []; + +foreach ($scenarios as $s) { + $session = makeSession($s['segments'], $s['bytes']); + $keys = array_keys($session); + $touched = $keys[0]; + + $whole = serialize($session); + $field = serialize($session[$touched]); + $sid = PREFIX . $s['segments'] . ':' . $s['bytes']; + $hkey = $sid . ':h'; + + // seed both shapes + $redis->set($sid, $whole); + $hash = []; + foreach ($session as $k => $v) { $hash[$k] = serialize($v); } + $redis->hMSet($hkey, $hash); + + // --- a request that touches one segment --- + + // string: read the whole session, write the whole session back + $stringMs = timeIt(function () use ($redis, $sid, $touched) { + $raw = $redis->get($sid); + $data = unserialize($raw); + $data[$touched]['user_id']++; + $redis->setEx($sid, 1440, serialize($data)); + }); + + // per-field: read only the touched segment, write only that segment + $fieldMs = timeIt(function () use ($redis, $hkey, $touched) { + $raw = $redis->hGet($hkey, $touched); + $seg = unserialize($raw); + $seg['user_id']++; + $redis->hSet($hkey, $touched, serialize($seg)); + }); + + // adversarial case for per-field: the request touches every segment, so + // it must fetch them all (HGETALL) and write them all back. + $allMs = timeIt(function () use ($redis, $hkey, $keys) { + $raw = $redis->hGetAll($hkey); + $out = []; + foreach ($raw as $k => $v) { + $seg = unserialize($v); + $seg['user_id']++; + $out[$k] = serialize($seg); + } + $redis->hMSet($hkey, $out); + }); + + $ratio = $stringMs / $fieldMs; + $verdict = $ratio > 1.10 ? sprintf('per-field %.1fx faster', $ratio) + : ($ratio < 0.91 ? sprintf('string %.1fx faster', 1 / $ratio) + : 'no real difference'); + + printf("%-18s %10s %10s | %8.3f %8.3f %8.3f | %s\n", + $s['segments'] . ' seg x ' . $s['bytes'] . 'B', + number_format(strlen($whole)), + number_format(strlen($field)), + $stringMs, $fieldMs, $allMs, + $verdict + ); + + $rows[] = [ + 'shape' => $s['segments'] . ' seg x ' . $s['bytes'] . 'B', + 'string' => $stringMs, + 'field' => $fieldMs, + 'bs' => strlen($whole) * 2, + 'bf' => strlen($field) * 2, + 'all' => $allMs, + ]; +} + +// Both shapes use 2 round trips, so added network latency cancels out; what +// differs is bytes on the wire. Model a slow link to see when that matters. +echo "\nSame comparison over a slower link (bytes matter, round trips do not:\n"; +echo "both shapes make exactly 2). Assumes 100 Mbit/s, i.e. 12.5 MB/s.\n\n"; +echo "Absolute saving is what matters, not the percentage: both shapes make 2\n"; +echo "round trips, and that latency is paid by both. At 0.5ms RTT each request\n"; +echo "already spends ~1ms waiting, before any of these numbers.\n\n"; + +foreach ([['1 Gbit/s LAN', 125_000_000], ['100 Mbit/s', 12_500_000]] as [$label, $bps]) { + printf("-- %s --\n", $label); + printf("%-18s %12s %12s | %s\n", 'session shape', 'string', 'per-field', 'per-field saves'); + printf("%s\n", str_repeat('-', 70)); + foreach ($rows as $r) { + $wireS = $r['string'] + ($r['bs'] / $bps) * 1000; + $wireF = $r['field'] + ($r['bf'] / $bps) * 1000; + printf("%-18s %10.3fms %10.3fms | %.3fms (%.0f%%)\n", + $r['shape'], $wireS, $wireF, $wireS - $wireF, ($wireS - $wireF) / $wireS * 100); + } + echo "\n"; +} + +cleanup($redis);