[php-src] PR #23789: Zend/standard: Add SSE2/NEON fast path for bin2hex()/hex2bin()
| From: | adapik | Date: | Sun, 20 Sep 2026 14:05:53 +0000 |
| Subject: | [php-src] PR #23789: Zend/standard: Add SSE2/NEON fast path for bin2hex()/hex2bin() | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-38625@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/23789
Author: adapik
# Summary
bin2hex()/hex2bin() currently run a plain byte-by-byte scalar loop. This
adds a SIMD fast path (zend_simd.h: SSE2 on x86-64, NEON on aarch64) that processes 16
bytes at a time, falling back to the existing scalar loop for tails and, in hex2bin,
for any chunk containing an invalid hex character — so error handling and output stay
byte-for-byte identical to before.
**Who benefits:** anything hex-encoding/decoding hashes, checksums, UUIDs, MAC addresses, tokens, or
signatures — hash()/md5()/sha1() formatting,
crypto/signature verification, logging, cache/session keys. This is the dominant real-world call
shape for both functions.
**Precedent:** vectorizing hot byte/string functions via this header is established practice —
[stripslashes
SSE2](https://github.com/php/php-src/commit/2b55dee4dccb695b7ae13be08efab6b147c698d3), [bcmath
ported to
zend_simd.h](https://github.com/php/php-src/commit/f142b6e6032c08b8fb8102fff8c7710390e68dbe),
[SSE2 mb_strlen](https://github.com/php/php-src/pull/10257), [zend_simd.h
itself](https://github.com/php/php-src/pull/18413).
## Benchmarks
Real hardware, phpt correctness gate passing (ALL OK) before timing, on
both hosts:
| | Linux x86-64 | macOS arm64 |
|---|---|---|
| CPU | AMD Ryzen 5 7500F | Apple M2 Silicon, 10 cores |
| SIMD path | SSE2 | NEON |
### Crypto/hash-sized inputs
| Size | bin2hex x86 | bin2hex arm64 | hex2bin x86 |
hex2bin arm64 |
|---|---|---|---|---|
| 4B (CRC32) | ~1.0× | ~1.0× | ~1.0× | ~1.0× |
| 16B (MD5/UUID) | **1.53×** | **1.34×** | **2.97×** | **2.15×** |
| 32B (SHA-256) | **1.98×** | **1.76×** | **4.06×** | **2.92×** |
| 64B (SHA-512) | **2.93×** | **2.38×** | **5.04×** | **3.65×** |
| 256B (RSA-2048 sig) | **5.46×** | **4.56×** | **6.38×** | **4.87×** |
| 512B (RSA-4096 sig) | **6.86×** | **5.46×** | **6.85×** | **4.98×** |
4B/6B show no win on either function — both are below the size where the SIMD path even engages
(16 bytes for bin2hex, one full 32-char chunk for hex2bin), so those calls
run the unmodified scalar loop. Not a regression, nothing changed for them.
### Full corpus (throughput, branch/patched vs baseline/unpatched)
| Size | bin2hex x86 | bin2hex arm64 | hex2bin x86 |
hex2bin arm64 |
|---|---|---|---|---|
| 1KB–512KB | up to **9.9×** (20.6 GB/s) | up to **6.8×** | up to **7.3×** | up to **5.7×** |
| 1MB–16MB | **1.4–1.6×** | **2.6–3.0×** | **3.7–7.3×** | **3.6–5.1×** |
## Allocator-boundary step-down, not a SIMD regression
strace on x86, 20 calls each: crossing the ~2MB output-buffer threshold takes
bin2hex from 6 munmap/24 mmap calls to 65/63 — the
allocator switches from heap reuse to a fresh mmap/munmap pair almost
every call. That fixed syscall cost is the same for branch and baseline, but eats a much bigger
share of the branch's now-tiny per-call time, which is why the throughput ratio compresses from
~10× down to 1.4–3× above 1MB rather than the branch getting slower in absolute terms.