|
1 | 1 | #!/usr/bin/env bash |
2 | | -# Renders the cross-netcode scaling comparison from the datapoints emitted by bench-aggregate.sh |
3 | | -# (dp-<netcode>-<connections>.json). Output goes to the job summary and to <out_dir>/summary.md, |
4 | | -# with the merged datapoints in <out_dir>/scaling.json. |
| 2 | +# Merges the datapoints emitted by bench-aggregate.sh (dp-<netcode>-<tag>.json) into |
| 3 | +# <out_dir>/scaling.json and writes a short coverage table to the job summary. The readable |
| 4 | +# tables come from render-summary.py and the full picture from render-report.py; both read |
| 5 | +# scaling.json. |
5 | 6 | # |
6 | | -# Usage: bench-scaling.sh <datapoints_dir> <window_s> <objects> [versions_json] [out_dir] |
| 7 | +# Usage: bench-scaling.sh <datapoints_dir> [out_dir] |
7 | 8 | set -euo pipefail |
8 | 9 |
|
9 | 10 | DP_DIR="${1:?datapoints dir}" |
10 | | -WINDOW="${2:-?}" |
11 | | -OBJECTS="${3:-?}" |
12 | | -VERSIONS="${4:-}" |
13 | | -OUT_DIR="${5:-results-out}" |
| 11 | +OUT_DIR="${2:-results-out}" |
14 | 12 |
|
15 | 13 | SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/stdout}" |
16 | 14 | mkdir -p "$OUT_DIR" |
17 | | -MD="$OUT_DIR/summary.md" |
18 | 15 |
|
19 | 16 | shopt -s nullglob |
20 | 17 | FILES=("$DP_DIR"/dp-*.json) |
21 | 18 |
|
22 | 19 | if [ ${#FILES[@]} -eq 0 ]; then |
23 | | - echo "## Netcode scaling benchmark" > "$MD" |
24 | | - echo "" >> "$MD" |
25 | | - echo "_No datapoints collected._" >> "$MD" |
26 | | - cat "$MD" >> "$SUMMARY" |
| 20 | + echo "[]" > "$OUT_DIR/scaling.json" |
| 21 | + printf '## Netcode scaling benchmark\n\n_No datapoints collected._\n' >> "$SUMMARY" |
27 | 22 | exit 0 |
28 | 23 | fi |
29 | 24 |
|
30 | | -VERSIONS_JSON="{}" |
31 | | -[ -n "$VERSIONS" ] && [ -f "$VERSIONS" ] && VERSIONS_JSON=$(cat "$VERSIONS") |
32 | | - |
33 | 25 | jq -s '.' "${FILES[@]}" > "$OUT_DIR/scaling.json" |
34 | 26 |
|
35 | | -jq -r --argjson versions "$VERSIONS_JSON" --arg window "$WINDOW" --arg objects "$OBJECTS" ' |
36 | | - def r1: if . == null then "-" else "\((.*10|floor)/10)" end; |
37 | | - def r2: if . == null then "-" else "\((.*100|floor)/100)" end; |
38 | | - def kb: if . == null then "-" else "\((./1024*10|floor)/10)" end; |
39 | | - def d1: (. // 0) | (.*10|floor)/10; |
40 | | - def shortCpu: if . == null then "-" else (gsub("\\(R\\)|\\(TM\\)|CPU|Processor|@.*$"; "") | gsub(" +"; " ") | ltrimstr(" ") | rtrimstr(" ")) end; |
41 | | -
|
| 27 | +# Coverage: one row per session, one column per netcode, cell = clients connected at start. |
| 28 | +jq -r ' |
42 | 29 | (["purrnet", "fishnet", "mirror", "ngo", "fusion"]) as $order |
43 | | - | ["#2f6feb", "#e36209", "#6f42c1", "#1a7f37", "#cf222e"] as $palette |
44 | 30 | | (map(.netcode) | unique) as $present |
45 | 31 | | [ $order[] | select(. as $n | $present | index($n)) ] as $netcodes |
46 | | - # Rows are keyed by the requested size (Fusion capped at 99 still sits in the 100 row); the |
47 | | - # "clients connected" table shows the actual counts. |
48 | | - | (map(.size // .connections) | unique | sort) as $conns |
49 | | - # dp[netcode][size] = datapoint |
50 | | - | (reduce .[] as $d ({}; .[$d.netcode][($d.size // $d.connections) | tostring] = $d)) as $dp |
51 | | - | def cell($n; $c): $dp[$n][$c | tostring]; |
52 | | - # Generic table: rows = connection counts, columns = netcodes, cell = f(datapoint) |
53 | | - def table(title; f): |
54 | | - "#### \(title)\n\n| Connections | \($netcodes | join(" | ")) |\n|---|\($netcodes | map("---") | join("|"))|\n" |
55 | | - + ( [ $conns[] as $c | "| \($c) | " + ( [ $netcodes[] as $n | (cell($n; $c)) as $d | if $d == null then "-" else ($d | f) end ] | join(" | ") ) + " |" ] | join("\n") ) |
56 | | - + "\n\n"; |
57 | | - def chart(title; ylabel; f): |
58 | | - ( [ $netcodes[] as $n | [ $conns[] as $c | (cell($n; $c)) as $d | if $d == null then 0 else ($d | f | d1) end ] ] ) as $series |
59 | | - | ( [ $series[][] ] | max // 0 ) as $m |
60 | | - | "```mermaid\n%%{init: {\"themeVariables\": {\"xyChart\": {\"plotColorPalette\": \"\([ range(0; $netcodes|length) as $i | $palette[$i] ] | join(","))\"}}}}%%\nxychart-beta\n" |
61 | | - + " title \"\(title)\"\n" |
62 | | - + " x-axis [\($conns | map(tostring) | join(", "))]\n" |
63 | | - + " y-axis \"\(ylabel)\" 0 --> \((($m * 1.1) | floor) + 1)\n" |
64 | | - + ( [ $series[] | " line [\(map(tostring) | join(", "))]" ] | join("\n") ) |
65 | | - + "\n```\n" |
66 | | - + "Series: " + ( [ range(0; $netcodes|length) as $i | "\($palette[$i]) \($netcodes[$i])" ] | join(" · ") ) + "\n\n"; |
67 | | - "## Netcode scaling benchmark\n\n" |
68 | | - + "Window: \($window)s per test · Objects per test: \($objects) · Connections: \($conns | map(tostring) | join(" / "))\n\n" |
69 | | - + "| Netcode | Version |\n|---|---|\n" |
70 | | - + ( [ $netcodes[] as $n | "| \($n) | \($versions[$n] // "?") |" ] | join("\n") ) |
71 | | - + "\n| unity | \($versions.unity // "?") |\n\n" |
72 | | - + "Every netcode runs the same scenario: the server spawns N objects and replicates them to every client. MoveY / MoveAllAxis / MoveWander move them each tick; SendRPC fires one observers-RPC with one float per object per tick; Static spawns them and never touches them; SpawnChurn keeps N alive while despawning and spawning N/50 per tick; ClientInput spawns one hub object and every client sends one small server RPC (Vector3 + float) per tick; SyncVars changes one of four synced fields per object per tick. " |
73 | | - + "Server numbers come from the single server process; client numbers are averages over the single-process measured clients. " |
74 | | - + "On-wire bandwidth is read from the network interface (headers, ACKs and resends included). CPU is the whole process, all threads, as % of one core with the frame loop capped at 60 fps; nothing is subtracted, so the Idle row (connected, nothing spawned) is what holding the connections costs on its own. " |
75 | | - + "Fusion is relay-based: server and clients talk to Photon Cloud (traffic measured on the public interface, RTT includes the relay hop); the server still sends one stream per client, so its downstream is comparable.\n\n" |
76 | | - + "### Machines\n\n" |
77 | | - + table("Server CPU model (numbers are only comparable within a row when these match)"; "\(.meta.cpuModel | shortCpu) x\(.meta.cpuCount)\(if .meta.devBuild then " (dev)" else "" end)") |
78 | | - + table("Clients connected at start / expected · measured clients (rows are the requested size; a capped or partially connected run shows its real count here)"; "\(.meta.connectedAtStart)/\(.meta.expectedClients) · \(.meta.measuredClients)\(if .meta.serverError != null then " ⚠️ \(.meta.serverError)" else "" end)") |
79 | | - + "### Idle baseline (connected, nothing spawned)\n\n" |
80 | | - + table("Server CPU % · frame p95 ms"; "\(.server.Idle.cpuPercent | r1)% · \(.server.Idle.p95FrameMs | r2) ms") |
81 | | - + ( [ ("MoveY", "MoveAllAxis", "MoveWander", "SendRPC", "Static", "SpawnChurn", "ClientInput", "SyncVars") as $t |
82 | | - | "### \($t)\n\n" |
83 | | - + table("\($t) — server downstream on-wire (KB/s, all clients)"; .server[$t].txBytesPerSec | kb) |
84 | | - + table("\($t) — per-client downstream on-wire (KB/s, client-measured)"; .clients[$t].rxBytesPerSec | kb) |
85 | | - + table("\($t) — server upstream on-wire (KB/s, all clients)"; .server[$t].rxBytesPerSec | kb) |
86 | | - + table("\($t) — per-client upstream on-wire (KB/s, client-measured)"; .clients[$t].txBytesPerSec | kb) |
87 | | - + ( if $t == "ClientInput" then table("ClientInput — server input RPCs received per second (expected ≈ 20 × connections)"; .server[$t].inputsPerSec | if . == null then "-" else floor end) else "" end ) |
88 | | - + table("\($t) — server CPU %"; .server[$t].cpuPercent | if . == null then "-" else "\(. | r1)%" end) |
89 | | - + table("\($t) — server frame avg / p95 / p99 (ms)"; "\(.server[$t].avgFrameMs | r2) / \(.server[$t].p95FrameMs | r2) / \(.server[$t].p99FrameMs | r2)") |
90 | | - + table("\($t) — server GC collections · peak RSS"; "\(.server[$t].gcCollections // "-") · \(((.server[$t].peakRssBytes // 0) / 1048576) | floor) MB") |
91 | | - + table("\($t) — client RTT p50 / p95 (ms, netcode-reported)"; "\(.clients[$t].rttP50Ms | r2) / \(.clients[$t].rttP95Ms | r2)") |
92 | | - + ( if ($conns | length) < 2 then "_Charts need ≥2 connection sizes._\n\n" |
93 | | - else chart("\($t) — server downstream KB/s vs connections"; "KB/s"; (.server[$t].txBytesPerSec // 0) / 1024) |
94 | | - + chart("\($t) — per-client downstream KB/s vs connections"; "KB/s"; (.clients[$t].rxBytesPerSec // 0) / 1024) |
95 | | - + chart("\($t) — server CPU % vs connections"; "CPU %"; .server[$t].cpuPercent // 0) |
96 | | - + chart("\($t) — client RTT p95 ms vs connections"; "ms"; .clients[$t].rttP95Ms // 0) |
97 | | - end ) |
98 | | - ] | join("") ) |
99 | | -' "$OUT_DIR/scaling.json" > "$MD" || { |
100 | | - echo "_Failed to render datapoints._" > "$MD" |
101 | | -} |
102 | | - |
103 | | -cat "$MD" >> "$SUMMARY" |
104 | | -echo "" >> "$SUMMARY" |
105 | | -echo "Summary written to $MD" |
| 32 | + | (map({size: .size, tick: .tick}) | unique | sort_by(.size, .tick)) as $sessions |
| 33 | + | (reduce .[] as $d ({}; .[$d.netcode + "@" + ($d.size|tostring) + "@" + ($d.tick|tostring)] = $d)) as $dp |
| 34 | + | "### Datapoints\n\n| Session | " + ($netcodes | join(" | ")) + " |\n|---|" + ($netcodes | map("---") | join("|")) + "|\n" |
| 35 | + + ( [ $sessions[] as $s |
| 36 | + | "| \($s.size) connections @ \($s.tick) Hz | " |
| 37 | + + ( [ $netcodes[] as $n | $dp[$n + "@" + ($s.size|tostring) + "@" + ($s.tick|tostring)] as $d |
| 38 | + | if $d == null then "missing" |
| 39 | + else "\($d.meta.connectedAtStart)/\($d.meta.expectedClients)" |
| 40 | + + (if $d.meta.tickRate != $s.tick then " ⚠️ \($d.meta.tickRate) Hz" else "" end) |
| 41 | + + (if $d.meta.serverError != null then " ⚠️ \($d.meta.serverError)" else "" end) |
| 42 | + end ] | join(" | ") ) |
| 43 | + + " |" ] | join("\n") ) |
| 44 | + + "\n\nCells are clients connected at start / expected.\n" |
| 45 | +' "$OUT_DIR/scaling.json" >> "$SUMMARY" |
| 46 | + |
| 47 | +echo "Merged ${#FILES[@]} datapoints into $OUT_DIR/scaling.json" |
0 commit comments