-
Notifications
You must be signed in to change notification settings - Fork 40
Add the benchmark behind the Redis session storage decision #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
harikt
wants to merge
1
commit into
7.x
Choose a base branch
from
redis-benchmark
base: 7.x
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,189 @@ | ||
| <?php | ||
| /** | ||
| * Does per-field (hash) session storage beat a single string? | ||
| * | ||
| * Models one HTTP request under PHP's session lifecycle, which reads at | ||
| * session_start() and writes at session_write_close(): | ||
| * | ||
| * string GET whole session + SETEX whole session | ||
| * per-field, 1 seg HGET one segment + HSET one segment | ||
| * per-field, all HGETALL + HMSET | ||
| * | ||
| * The string figure is what Aura.Session ships today. "per-field, 1 seg" is the | ||
| * best case for the Segment-backed redesign asked for in issue #95 -- a request | ||
| * that touches exactly one segment. "per-field, all" is its worst case, a | ||
| * request that touches every segment. | ||
| * | ||
| * Both shapes make exactly two round trips, so latency is paid equally by both | ||
| * and cancels out; what differs is bytes on the wire and serialization work. | ||
| * Read the absolute saving, not the percentage: at a 0.5ms RTT every request | ||
| * already spends ~1ms waiting before any of these numbers apply. | ||
| * | ||
| * Needs ext-redis. Writes only keys prefixed "aura-bench:" and deletes them | ||
| * afterwards; it never flushes the database, so it is safe against a Redis that | ||
| * holds other data. Even so, point it at a throwaway server. | ||
| * | ||
| * Usage: php benchmarks/session-shape.php [host] [port] | ||
| */ | ||
|
|
||
| $host = $argv[1] ?? '127.0.0.1'; | ||
| $port = (int) ($argv[2] ?? 6379); | ||
|
|
||
| if (! extension_loaded('redis')) { | ||
| fwrite(STDERR, "This benchmark needs ext-redis.\n"); | ||
| exit(1); | ||
| } | ||
|
|
||
| $redis = new Redis(); | ||
| $redis->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); | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: auraphp/Aura.Session
Length of output: 11255
🏁 Script executed:
Repository: auraphp/Aura.Session
Length of output: 3497
🏁 Script executed:
Repository: auraphp/Aura.Session
Length of output: 10423
Refresh
$hkeyexpiry in both hash write paths.setEx($sid, 1440, ...)refreshes the string key, buthSet()andhMSet()do not change a Redis hash TTL. The benchmark therefore compares expiring string sessions with non-expiring hash sessions. Pipeline each hash write withEXPIRE $hkey 1440, update the transfer model, and regenerate the README results and conclusions.Apply this to
benchmarks/session-shape.phplines 129 and 142, and updatebenchmarks/README.mdlines 58–86.📍 Affects 2 files
benchmarks/session-shape.php#L129-L129(this comment)benchmarks/session-shape.php#L142-L142benchmarks/README.md#L58-L86🤖 Prompt for AI Agents