Skip to content

Commit b29d205

Browse files
withinboredomclaude
andcommitted
Add instruction-count benchmark for reified collections
perfcount.c (perf_event_open wrapper) + perfbench.sh compare bench/baseline (plain) vs bench/reified (no-defaults native generics) on the same fork binary. Because reified has no defaults, each branch runs its own workload file (collbench_{baseline,reified}.php) — identical operations, only the `new ArrayCollection` line differs; reified uses a concrete <int, Item> monomorph so T/TKey checks fire on add/set/get/filter/partition. Result (opcache off, deterministic): +0.079% instructions, checksum match. (opcache on is unreliable on this fork due to persistent-SHM churn.) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3d0f8d8 commit b29d205

5 files changed

Lines changed: 252 additions & 0 deletions

File tree

bench/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
/perfcount

bench/collbench_baseline.php

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
// Baseline-branch workload: plain ArrayCollection (docblock generics, no
3+
// reification). Identical operations to collbench_reified.php; only the
4+
// `new ArrayCollection` construction line differs (bare, no type args).
5+
//
6+
// php bench/collbench_baseline.php [ITERS] [SIZE]
7+
declare(strict_types=1);
8+
require getenv('BENCH_AUTOLOAD') ?: __DIR__ . '/../vendor/autoload.php';
9+
10+
use Doctrine\Common\Collections\ArrayCollection;
11+
12+
final class Item
13+
{
14+
public function __construct(public int $v)
15+
{
16+
}
17+
}
18+
19+
$ITERS = (int) ($argv[1] ?? 2000);
20+
$SIZE = (int) ($argv[2] ?? 500);
21+
22+
/** @var array<int, Item> $data */
23+
$data = [];
24+
for ($i = 0; $i < $SIZE; $i++) {
25+
$data[$i] = new Item(($i * 7) % 101);
26+
}
27+
28+
$checksum = 0;
29+
for ($it = 0; $it < $ITERS; $it++) {
30+
$c = new ArrayCollection($data); // <-- only line that differs from reified
31+
$c->add(new Item($it % 13));
32+
$c->set(0, new Item(1));
33+
$g = $c->get(5);
34+
$ck = $c->containsKey(3);
35+
$ct = $c->contains($data[2]);
36+
$f = $c->filter(static fn (Item $v, int $k): bool => $v->v % 2 === 0);
37+
$fr = $c->first();
38+
$la = $c->last();
39+
$sl = $c->slice(0, 10);
40+
[$m, $n] = $c->partition(static fn (int $k, Item $v): bool => $v->v > 50);
41+
42+
$checksum += $c->count()
43+
+ ($g instanceof Item ? $g->v : 0)
44+
+ ($ck ? 1 : 0) + ($ct ? 1 : 0)
45+
+ $f->count()
46+
+ ($fr instanceof Item ? $fr->v : 0)
47+
+ ($la instanceof Item ? $la->v : 0)
48+
+ count($sl)
49+
+ $m->count() + $n->count();
50+
}
51+
52+
printf("class=%s iters=%d size=%d (checksum=%d)\n", (new ArrayCollection())::class, $ITERS, $SIZE, $checksum);

bench/collbench_reified.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
// Reified-branch workload: ArrayCollection<int, Item> — a CONCRETE monomorph so
3+
// the reified TKey/T checks actually fire on add/set/get/filter/etc.
4+
// Identical operations to collbench_baseline.php; only the `new ArrayCollection`
5+
// construction line differs (explicit ::<int, Item> args, no defaults).
6+
//
7+
// php bench/collbench_reified.php [ITERS] [SIZE]
8+
declare(strict_types=1);
9+
require getenv('BENCH_AUTOLOAD') ?: __DIR__ . '/../vendor/autoload.php';
10+
11+
use Doctrine\Common\Collections\ArrayCollection;
12+
13+
final class Item
14+
{
15+
public function __construct(public int $v)
16+
{
17+
}
18+
}
19+
20+
$ITERS = (int) ($argv[1] ?? 2000);
21+
$SIZE = (int) ($argv[2] ?? 500);
22+
23+
/** @var array<int, Item> $data */
24+
$data = [];
25+
for ($i = 0; $i < $SIZE; $i++) {
26+
$data[$i] = new Item(($i * 7) % 101);
27+
}
28+
29+
$checksum = 0;
30+
for ($it = 0; $it < $ITERS; $it++) {
31+
$c = new ArrayCollection::<int, Item>($data); // <-- only line that differs from baseline
32+
$c->add(new Item($it % 13));
33+
$c->set(0, new Item(1));
34+
$g = $c->get(5);
35+
$ck = $c->containsKey(3);
36+
$ct = $c->contains($data[2]);
37+
$f = $c->filter(static fn (Item $v, int $k): bool => $v->v % 2 === 0);
38+
$fr = $c->first();
39+
$la = $c->last();
40+
$sl = $c->slice(0, 10);
41+
[$m, $n] = $c->partition(static fn (int $k, Item $v): bool => $v->v > 50);
42+
43+
$checksum += $c->count()
44+
+ ($g instanceof Item ? $g->v : 0)
45+
+ ($ck ? 1 : 0) + ($ct ? 1 : 0)
46+
+ $f->count()
47+
+ ($fr instanceof Item ? $fr->v : 0)
48+
+ ($la instanceof Item ? $la->v : 0)
49+
+ count($sl)
50+
+ $m->count() + $n->count();
51+
}
52+
53+
printf("class=%s iters=%d size=%d (checksum=%d)\n", (new ArrayCollection::<int, Item>())::class, $ITERS, $SIZE, $checksum);

bench/perfbench.sh

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Instruction-count comparison: bench/baseline (plain Collections) vs
3+
# bench/reified (native reified generics, no defaults, concrete <int,Item>
4+
# monomorph). Same fork binary; via perf_event_open (bench/perfcount).
5+
#
6+
# Because reified has NO defaults, construction syntax differs per branch, so
7+
# each branch runs its OWN workload file (identical operations, only the
8+
# `new ArrayCollection` line differs). Workloads are pinned to /tmp so they
9+
# survive `git checkout`.
10+
#
11+
# gcc -O2 -o bench/perfcount bench/perfcount.c
12+
# [PHP=...] [OPCACHE=on|off] [ITERS=] [SIZE=] [N=] bench/perfbench.sh
13+
set -uo pipefail
14+
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
15+
PHP="${PHP:-/home/withinboredom/code/php-src/sapi/cli/php}"
16+
PC="$REPO/bench/perfcount"
17+
ITERS="${ITERS:-2000}"; SIZE="${SIZE:-500}"; N="${N:-5}"
18+
cd "$REPO" || exit 1
19+
[ -x "$PC" ] || { echo "build first: gcc -O2 -o bench/perfcount bench/perfcount.c" >&2; exit 1; }
20+
21+
case "${OPCACHE:-on}" in
22+
off) FLAGS=(-d opcache.enable_cli=0); OPMODE="opcache OFF" ;;
23+
*) FLAGS=(-d opcache.enable_cli=1 -d opcache.jit=disable -d opcache.jit_buffer_size=0 -d opcache.validate_timestamps=1); OPMODE="opcache ON" ;;
24+
esac
25+
26+
# Pin workloads outside the repo so branch switches can't delete them.
27+
cp "$REPO/bench/collbench_baseline.php" /tmp/.cb_baseline.php
28+
cp "$REPO/bench/collbench_reified.php" /tmp/.cb_reified.php
29+
export BENCH_AUTOLOAD="$REPO/vendor/autoload.php"
30+
31+
median() { sort -n | awk '{a[NR]=$1} END{print a[int((NR+1)/2)]}'; }
32+
33+
echo "### $OPMODE | ITERS=$ITERS SIZE=$SIZE | N=$N runs/branch" >&2
34+
declare -A INS CHK
35+
for spec in "baseline:bench/baseline:/tmp/.cb_baseline.php" "reified:bench/reified:/tmp/.cb_reified.php"; do
36+
label="${spec%%:*}"; rest="${spec#*:}"; ref="${rest%%:*}"; work="${rest#*:}"
37+
git checkout -q "$ref" || { echo "checkout $ref failed" >&2; exit 1; }
38+
"$PHP" "${FLAGS[@]}" -r 'opcache_reset();' >/dev/null 2>&1 || true
39+
"$PHP" "${FLAGS[@]}" "$work" "$ITERS" "$SIZE" >/dev/null 2>&1 # prime
40+
echo "=== $label ($(git rev-parse --short HEAD)) ===" >&2
41+
ins_list=()
42+
for i in $(seq 1 "$N"); do
43+
"$PC" "$PHP" "${FLAGS[@]}" "$work" "$ITERS" "$SIZE" >/tmp/.pcout 2>/tmp/.pcerr
44+
ins="$(grep -oE 'instructions=[0-9]+' /tmp/.pcerr | cut -d= -f2)"
45+
ins_list+=("$ins"); echo " run $i: instructions=$ins" >&2
46+
done
47+
CHK["$label"]="$(grep -oE 'checksum=[0-9]+' /tmp/.pcout | cut -d= -f2)"
48+
INS["$label"]="$(printf '%s\n' "${ins_list[@]}" | median)"
49+
done
50+
git checkout -q bench/reified
51+
52+
echo "============================================================"
53+
b="${INS[baseline]}"; r="${INS[reified]}"
54+
echo "checksum baseline=${CHK[baseline]} reified=${CHK[reified]} $([ "${CHK[baseline]}" = "${CHK[reified]}" ] && echo MATCH || echo MISMATCH!!)"
55+
awk -v b="$b" -v r="$r" 'BEGIN{ printf "median instructions baseline=%d reified=%d delta=%+.3f%%\n", b, r, (r-b)/b*100 }'
56+
echo "($OPMODE; restored to bench/reified)"

bench/perfcount.c

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Minimal perf_event_open wrapper: counts userspace HW events for a child process.
2+
// No `perf` binary and no root needed when /proc/sys/kernel/perf_event_paranoid <= 2
3+
// (we set exclude_kernel). Works on WSL2 when the guest exposes the PMU
4+
// (check: ls /sys/devices/cpu/events should list `instructions`, `cycles`, ...).
5+
//
6+
// gcc -O2 -o bench/perfcount bench/perfcount.c
7+
// bench/perfcount <cmd> [args...]
8+
//
9+
// Prints one line to stderr:
10+
// PERF instructions=<n> cycles=<n> branch-misses=<n> cache-misses=<n>
11+
//
12+
// Instruction counts are frequency-independent, so they're stable on battery,
13+
// under load, and across machines -- far better than wall-clock for resolving
14+
// a sub-percent engine cost.
15+
#define _GNU_SOURCE
16+
#include <stdio.h>
17+
#include <stdlib.h>
18+
#include <unistd.h>
19+
#include <string.h>
20+
#include <errno.h>
21+
#include <sys/wait.h>
22+
#include <sys/ioctl.h>
23+
#include <linux/perf_event.h>
24+
#include <asm/unistd.h>
25+
26+
static long perf_open(struct perf_event_attr *a, pid_t pid) {
27+
return syscall(__NR_perf_event_open, a, pid, -1, -1, 0);
28+
}
29+
30+
static int mkcounter(unsigned type, unsigned long long config, pid_t pid) {
31+
struct perf_event_attr a;
32+
memset(&a, 0, sizeof(a));
33+
a.type = type;
34+
a.size = sizeof(a);
35+
a.config = config;
36+
a.disabled = 1; // start off...
37+
a.enable_on_exec = 1; // ...auto-enable when the child execs
38+
a.exclude_kernel = 1; // userspace only (allowed at paranoid=2)
39+
a.exclude_hv = 1;
40+
a.inherit = 1; // follow into the exec'd image
41+
int fd = (int) perf_open(&a, pid);
42+
if (fd < 0) fprintf(stderr, "perf_open(type=%u,config=%llu) failed: %s\n", type, config, strerror(errno));
43+
return fd;
44+
}
45+
46+
static long long readval(int fd) {
47+
long long v = -1;
48+
if (fd >= 0 && read(fd, &v, sizeof(v)) != sizeof(v)) v = -1;
49+
return v;
50+
}
51+
52+
int main(int argc, char **argv) {
53+
if (argc < 2) { fprintf(stderr, "usage: %s <cmd> [args...]\n", argv[0]); return 2; }
54+
55+
int pfd[2];
56+
if (pipe(pfd) != 0) { perror("pipe"); return 3; }
57+
58+
pid_t pid = fork();
59+
if (pid < 0) { perror("fork"); return 3; }
60+
61+
if (pid == 0) {
62+
// child: wait for parent to arm counters, then exec
63+
close(pfd[1]);
64+
char b; if (read(pfd[0], &b, 1) < 0) _exit(126);
65+
close(pfd[0]);
66+
execvp(argv[1], &argv[1]);
67+
perror("execvp");
68+
_exit(127);
69+
}
70+
71+
// parent: arm counters against the (still-blocked) child
72+
close(pfd[0]);
73+
int f_ins = mkcounter(PERF_TYPE_HARDWARE, PERF_COUNT_HW_INSTRUCTIONS, pid);
74+
int f_cyc = mkcounter(PERF_TYPE_HARDWARE, PERF_COUNT_HW_CPU_CYCLES, pid);
75+
int f_brm = mkcounter(PERF_TYPE_HARDWARE, PERF_COUNT_HW_BRANCH_MISSES, pid);
76+
int f_chm = mkcounter(PERF_TYPE_HARDWARE, PERF_COUNT_HW_CACHE_MISSES, pid);
77+
78+
// release the child
79+
char go = 1; if (write(pfd[1], &go, 1) < 0) { /* child will EOF and exit */ }
80+
close(pfd[1]);
81+
82+
int status = 0;
83+
waitpid(pid, &status, 0);
84+
85+
fprintf(stderr, "PERF instructions=%lld cycles=%lld branch-misses=%lld cache-misses=%lld\n",
86+
readval(f_ins), readval(f_cyc), readval(f_brm), readval(f_chm));
87+
88+
if (WIFEXITED(status)) return WEXITSTATUS(status);
89+
return 1;
90+
}

0 commit comments

Comments
 (0)