Skip to content

Commit 667e7c7

Browse files
JohnMcLearclaude
andauthored
feat(scaling-dive): add CPU-profile capture workflow (#109)
One-shot V8 CPU profile capture for the SUT during a dive sweep. Used to answer "where do CPU cycles go at the cliff?" (#7756). NODE_OPTIONS=--cpu-prof --cpu-prof-dir=/tmp/cpuprof on the SUT, then SIGTERM at end of sweep so Node flushes the .cpuprofile to disk before the job exits. The profile is the deliverable; the sweep report is a secondary artifact. Adds ~10-30% profiling overhead, so this workflow is workflow_dispatch only and the numbers shouldn't be compared against the no-profile dive. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 79b6f83 commit 667e7c7

1 file changed

Lines changed: 163 additions & 0 deletions

File tree

.github/workflows/cpu-profile.yml

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
name: CPU profile capture
2+
3+
# One-shot V8 CPU profile capture for the SUT during a scaling-dive sweep.
4+
# Used to answer "where do CPU cycles go at the cliff?" (#7756). Adds
5+
# profiling overhead (~10-30%) so artifacts shouldn't be compared
6+
# directly against the no-profile dive numbers; the cpuprofile is the
7+
# deliverable, not the report.
8+
9+
on:
10+
workflow_dispatch:
11+
inputs:
12+
core_ref:
13+
description: 'ether/etherpad ref to test against'
14+
default: 'develop'
15+
type: string
16+
sweep:
17+
description: 'sweep spec to drive load while profiling'
18+
default: 'authors=100..400:step=50:dwell=10s:warmup=3s'
19+
type: string
20+
settings_lever:
21+
description: 'which dive lever to apply to settings (or "none")'
22+
default: 'none'
23+
type: choice
24+
options: [none, new-changes-batch, engine-flush-defer]
25+
26+
permissions:
27+
contents: read
28+
29+
jobs:
30+
capture:
31+
runs-on: ubuntu-latest
32+
timeout-minutes: 45
33+
34+
env:
35+
PNPM_HOME: /home/runner/setup-pnpm/node_modules/.bin
36+
37+
steps:
38+
- name: Checkout etherpad-load-test
39+
uses: actions/checkout@v4
40+
with:
41+
path: ./loadtest
42+
43+
- name: Checkout etherpad core (${{ inputs.core_ref }})
44+
uses: actions/checkout@v4
45+
with:
46+
repository: ether/etherpad
47+
ref: ${{ inputs.core_ref }}
48+
path: ./etherpad
49+
50+
- uses: actions/setup-node@v4
51+
with:
52+
node-version: 25
53+
54+
- uses: pnpm/action-setup@v4
55+
name: Install pnpm
56+
with:
57+
version: 10.33.2
58+
run_install: false
59+
60+
- name: Get pnpm store directory
61+
shell: bash
62+
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
63+
64+
- uses: actions/cache@v4
65+
name: Setup pnpm cache
66+
with:
67+
path: ${{ env.STORE_PATH }}
68+
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
69+
restore-keys: |
70+
${{ runner.os }}-pnpm-store-
71+
72+
- name: Build and link etherpad-load-test
73+
run: |
74+
cd ./loadtest
75+
pnpm install --frozen-lockfile
76+
pnpm run build
77+
pnpm link --global
78+
79+
- name: Install Etherpad core dependencies
80+
run: |
81+
cd ./etherpad
82+
pnpm install --frozen-lockfile
83+
84+
- name: Patch settings.json
85+
working-directory: ./etherpad
86+
run: |
87+
sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 100000000/' -i settings.json.template
88+
sed -e '
89+
s!"loadTest":[^,]*!"loadTest": true!
90+
s!"points":[^,]*!"points": 1000000!
91+
' settings.json.template > settings.json
92+
sed -i '/"loadTest": true,/a\ "scalingDiveMetrics": true,' settings.json
93+
94+
case "${{ inputs.settings_lever }}" in
95+
none) echo "no lever applied — baseline" ;;
96+
new-changes-batch)
97+
sed -i '/"loadTest": true,/a\ "newChangesBatch": true,' settings.json
98+
grep newChangesBatch settings.json
99+
;;
100+
engine-flush-defer)
101+
sed -i '/"loadTest": true,/a\ "engineFlushDefer": true,' settings.json
102+
grep engineFlushDefer settings.json
103+
;;
104+
*) echo "unknown lever"; exit 1 ;;
105+
esac
106+
107+
- name: Start Etherpad with --cpu-prof
108+
working-directory: ./etherpad
109+
run: |
110+
mkdir -p /tmp/cpuprof
111+
# --cpu-prof writes the profile when the process exits OR receives
112+
# SIGUSR2. Profile filename has a pid suffix, so we use a known
113+
# directory and grab whatever lands.
114+
export NODE_OPTIONS="--cpu-prof --cpu-prof-dir=/tmp/cpuprof --cpu-prof-name=etherpad.cpuprofile"
115+
(cd src && pnpm run prod >../ep.log 2>&1 &)
116+
for i in $(seq 1 90); do
117+
curl -sf http://127.0.0.1:9001/ >/dev/null && break
118+
sleep 1
119+
done
120+
curl -sf http://127.0.0.1:9001/ >/dev/null || { echo "Etherpad failed to start"; tail -n 100 ep.log; exit 1; }
121+
echo "Etherpad up with cpu-prof; NODE_OPTIONS=$NODE_OPTIONS"
122+
123+
- name: Run sweep
124+
working-directory: ./loadtest
125+
run: |
126+
mkdir -p ./out
127+
etherpad-loadtest \
128+
http://127.0.0.1:9001 \
129+
--sweep "${{ inputs.sweep }}" \
130+
--report ./out \
131+
--run-id "cpu-profile-$(echo '${{ inputs.core_ref }}' | tr '/' '-')" \
132+
--force
133+
134+
- name: Stop Etherpad cleanly (flushes the cpuprofile to disk)
135+
if: always()
136+
run: |
137+
# SIGTERM the node process running server.ts so Node writes the
138+
# .cpuprofile during clean shutdown. SIGKILL would lose it.
139+
pkill -SIGTERM -f 'node.*server\.ts' || true
140+
# Give Node up to 20s to write the profile.
141+
for i in $(seq 1 20); do
142+
ls /tmp/cpuprof/*.cpuprofile 2>/dev/null && break
143+
sleep 1
144+
done
145+
ls -la /tmp/cpuprof || true
146+
echo '--- ep.log tail ---'
147+
tail -n 80 ./etherpad/ep.log || true
148+
149+
- name: Upload CPU profile
150+
if: always()
151+
uses: actions/upload-artifact@v4
152+
with:
153+
name: cpuprofile-${{ inputs.settings_lever }}
154+
path: /tmp/cpuprof/*.cpuprofile
155+
if-no-files-found: error
156+
157+
- name: Upload sweep report
158+
if: always()
159+
uses: actions/upload-artifact@v4
160+
with:
161+
name: cpuprofile-report-${{ inputs.settings_lever }}
162+
path: loadtest/out/
163+
if-no-files-found: warn

0 commit comments

Comments
 (0)