Skip to content

Commit 457086e

Browse files
authored
ci(perf): isolate JMH runtime locks (#60)
1 parent 13d2fcc commit 457086e

6 files changed

Lines changed: 222 additions & 59 deletions

File tree

.github/workflows/performance-ab.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ jobs:
130130
--group "$(id -g)" \
131131
--mode 0711 \
132132
"$PERF_RESULTS_ROOT/evidence"
133+
sudo install -d \
134+
--owner "$(id -u)" \
135+
--group "$(id -g)" \
136+
--mode 0711 \
137+
"$PERF_RESULTS_ROOT/runtime-tmp"
133138
134139
- name: Make wrappers executable
135140
shell: bash
@@ -229,6 +234,7 @@ jobs:
229234
sudo -H -u mahjong-benchmark -- test ! -w "$PERF_RESULTS_ROOT/evidence/jars/base-jmh.jar"
230235
sudo -H -u mahjong-benchmark -- test ! -w "$PERF_RESULTS_ROOT/evidence/jars/candidate-jmh.jar"
231236
sudo -H -u mahjong-benchmark -- test ! -w "$PERF_RESULTS_ROOT/evidence"
237+
sudo -H -u mahjong-benchmark -- test ! -w "$PERF_RESULTS_ROOT/runtime-tmp"
232238
233239
- name: Run A/A drift control
234240
shell: bash
@@ -256,6 +262,7 @@ jobs:
256262
--candidate-sha "$CANDIDATE_SHA" \
257263
--config base/perf/ab/gate-config.json \
258264
--output-dir "$PERF_RESULTS_ROOT/evidence/aa" \
265+
--runtime-temp-root "$PERF_RESULTS_ROOT/runtime-tmp" \
259266
--java "$JAVA_HOME/bin/java" \
260267
--candidate-command-prefix-json '["sudo","-H","-u","mahjong-benchmark","--"]' \
261268
--candidate-file-user mahjong-benchmark \
@@ -288,6 +295,7 @@ jobs:
288295
--candidate-sha "$CANDIDATE_SHA" \
289296
--config base/perf/ab/gate-config.json \
290297
--output-dir "$PERF_RESULTS_ROOT/evidence/ab" \
298+
--runtime-temp-root "$PERF_RESULTS_ROOT/runtime-tmp" \
291299
--java "$JAVA_HOME/bin/java" \
292300
--candidate-command-prefix-json '["sudo","-H","-u","mahjong-benchmark","--"]' \
293301
--candidate-file-user mahjong-benchmark \

.github/workflows/performance-infra.yml

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ jobs:
7979
--group "$(id -g)" \
8080
--mode 0711 \
8181
"$PROBE_ROOT/evidence"
82+
sudo install -d \
83+
--owner "$(id -u)" \
84+
--group "$(id -g)" \
85+
--mode 0711 \
86+
"$PROBE_ROOT/runtime-tmp"
8287
python3 perf/ab/tests/isolation_probe.py \
8388
--user mahjong-probe \
8489
--output-dir "$PROBE_ROOT/evidence"
@@ -92,6 +97,49 @@ jobs:
9297
"-PmahjongPaperDevBundle=1.20.1-R0.1-SNAPSHOT"
9398
"-PmahjongJavaToolchain=25" "-PmahjongJavaTarget=17"
9499
100+
- name: Regress isolated JMH lock ownership
101+
shell: bash
102+
run: |
103+
set -euo pipefail
104+
PROBE_ROOT="/tmp/mahjong-performance-probe-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
105+
SOURCE_JAR="$(find build/libs -maxdepth 1 -name '*-jmh.jar' -print -quit)"
106+
test -n "$SOURCE_JAR"
107+
mkdir -p "$PROBE_ROOT/evidence/jars"
108+
cp "$SOURCE_JAR" "$PROBE_ROOT/evidence/jars/infra-jmh.jar"
109+
chmod 0444 "$PROBE_ROOT/evidence/jars/infra-jmh.jar"
110+
JAR="$PROBE_ROOT/evidence/jars/infra-jmh.jar"
111+
sudo -H -u mahjong-probe -- test -r "$JAR"
112+
sudo -H -u mahjong-probe -- test ! -w "$JAR"
113+
python3 perf/ab/run_matrix.py \
114+
--phase ab \
115+
--profile infra \
116+
--base-jar "$JAR" \
117+
--candidate-jar "$JAR" \
118+
--base-sha "$GITHUB_SHA" \
119+
--candidate-sha "$GITHUB_SHA" \
120+
--config perf/ab/gate-config.json \
121+
--output-dir "$PROBE_ROOT/evidence/lock-regression" \
122+
--runtime-temp-root "$PROBE_ROOT/runtime-tmp" \
123+
--java "$JAVA_HOME/bin/java" \
124+
--candidate-command-prefix-json '["sudo","-H","-u","mahjong-probe","--"]' \
125+
--candidate-file-user mahjong-probe \
126+
--forks 1 \
127+
--warmup-iterations 1 \
128+
--measurement-iterations 1 \
129+
--warmup-time 10ms \
130+
--measurement-time 10ms
131+
132+
- name: Stage isolated JMH lock evidence
133+
if: always()
134+
shell: bash
135+
run: |
136+
set -euo pipefail
137+
PROBE_ROOT="/tmp/mahjong-performance-probe-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
138+
if [[ -d "$PROBE_ROOT/evidence/lock-regression" ]]; then
139+
mkdir -p build/reports/jmh/lock-regression
140+
cp -a "$PROBE_ROOT/evidence/lock-regression/." build/reports/jmh/lock-regression/
141+
fi
142+
95143
- name: Run one smoke iteration for every protected benchmark
96144
shell: bash
97145
run: |

docs/performance-testing.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,11 @@ dedicated no-login UID: only the current candidate JSON path receives candidate
6262
while stdout is confined to the current runner-opened log and the jars, parent directories,
6363
previous results and manifests remain runner-owned. After each run, residual processes under
6464
that UID are killed and audited, ownership is reclaimed, and the result/log are locked
65-
read-only before hashing. The final statistics are evaluated on a fresh runner from a fresh
66-
base checkout; candidate code cannot rewrite base/AA evidence or the decision gate.
65+
read-only before hashing. Every A/A and A/B execution also receives a distinct `java.io.tmpdir`
66+
under a runner-owned parent. The JMH launcher retains its lock without sharing `/tmp/jmh.lock`
67+
across the runner and isolated UIDs, and the same directory is passed to its measurement fork.
68+
The final statistics are evaluated on a fresh runner from a fresh base checkout; candidate code
69+
cannot rewrite base/AA evidence or the decision gate.
6770
The infrastructure workflow exercises this boundary on Linux with a probe that attempts to
6871
rewrite runner-owned evidence and leaves a delayed background writer; both must be contained.
6972

perf/ab/gate.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ def load_pairs(
325325

326326
by_pair: dict[int, dict[str, dict[str, dict[str, Any]]]] = defaultdict(dict)
327327
ordered_roles: dict[int, dict[int, str]] = defaultdict(dict)
328+
runtime_temp_dirs: set[str] = set()
328329
for execution in manifest.get("executions", []):
329330
if execution.get("exit_code") != 0:
330331
raise ValueError(f"run contains failed execution: {manifest_path}")
@@ -342,8 +343,18 @@ def load_pairs(
342343
raise ValueError(f"execution jar digest mismatch in pair {pair_index}: {manifest_path}")
343344
if execution.get("fork_jvm_args_verified") is not True or execution.get("validation_error") is not None:
344345
raise ValueError(f"execution evidence was not validated in pair {pair_index}: {manifest_path}")
346+
runtime_temp_dir = execution.get("runtime_temp_dir")
347+
if not isinstance(runtime_temp_dir, str) or not runtime_temp_dir:
348+
raise ValueError(f"execution omitted its JMH runtime temp directory in pair {pair_index}")
349+
if runtime_temp_dir in runtime_temp_dirs:
350+
raise ValueError(f"execution reused JMH runtime temp directory in pair {pair_index}")
351+
runtime_temp_dirs.add(runtime_temp_dir)
352+
runtime_temp_arg = f"-Djava.io.tmpdir={runtime_temp_dir}"
353+
command = execution.get("command", [])
354+
command_offset = len(isolation_prefix) if role == "candidate" else 0
355+
if command[command_offset + 1 : command_offset + 2] != [runtime_temp_arg]:
356+
raise ValueError(f"execution launcher omitted its JMH runtime temp directory in pair {pair_index}")
345357
if role == "candidate":
346-
command = execution.get("command", [])
347358
if execution.get("isolated_candidate") is not True or command[: len(isolation_prefix)] != isolation_prefix:
348359
raise ValueError(f"candidate execution escaped its command prefix in pair {pair_index}")
349360
if execution.get("result_mode_after_lock") != 0o444:
@@ -353,7 +364,11 @@ def load_pairs(
353364
if execution.get("log_mode_after_lock") != 0o444:
354365
raise ValueError(f"JMH log was not locked after pair {pair_index}")
355366
log_path = manifest_path.parent / execution["log_file"]
356-
verify_fork_log(log_path, execution.get("log_sha256"), expected_jvm_args)
367+
verify_fork_log(
368+
log_path,
369+
execution.get("log_sha256"),
370+
[*expected_jvm_args, runtime_temp_arg],
371+
)
357372
if role in by_pair[pair_index] or position in ordered_roles[pair_index]:
358373
raise ValueError(f"duplicate role or position in pair {pair_index}: {manifest_path}")
359374
by_pair[pair_index][role] = read_jmh_results(

perf/ab/run_matrix.py

Lines changed: 60 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,20 @@ def command_for(
100100
jar: pathlib.Path,
101101
result_file: pathlib.Path,
102102
config: dict[str, Any],
103+
runtime_temp_dir: pathlib.Path,
103104
command_prefix: list[str] | None = None,
104105
) -> list[str]:
105106
jmh = config["jmh"]
106-
fork_jvm_args = jmh.get("jvm_args", [])
107-
command = [*(command_prefix or []), java, "-jar", str(jar), jmh["include"]]
107+
runtime_temp_arg = f"-Djava.io.tmpdir={runtime_temp_dir}"
108+
fork_jvm_args = [*jmh.get("jvm_args", []), runtime_temp_arg]
109+
command = [
110+
*(command_prefix or []),
111+
java,
112+
runtime_temp_arg,
113+
"-jar",
114+
str(jar),
115+
jmh["include"],
116+
]
108117
command.extend(
109118
[
110119
"-bm",
@@ -194,6 +203,36 @@ def prepare_untrusted_result(path: pathlib.Path, file_user: str) -> None:
194203
)
195204

196205

206+
def prepare_runtime_temp(path: pathlib.Path, file_user: str | None) -> None:
207+
if path.exists():
208+
raise FileExistsError(f"JMH runtime temp directory already exists: {path}")
209+
if file_user is None:
210+
path.mkdir(mode=0o700)
211+
return
212+
subprocess.run(
213+
[
214+
"sudo",
215+
"install",
216+
"-d",
217+
"--owner",
218+
file_user,
219+
"--group",
220+
file_user,
221+
"--mode",
222+
"0700",
223+
"--",
224+
str(path),
225+
],
226+
check=True,
227+
)
228+
writable = subprocess.run(
229+
["sudo", "-H", "-u", file_user, "--", "test", "-w", str(path)],
230+
check=False,
231+
)
232+
if writable.returncode != 0:
233+
raise RuntimeError(f"candidate user {file_user} cannot write JMH runtime temp {path}")
234+
235+
197236
def reclaim_untrusted_result(path: pathlib.Path, file_user: str) -> None:
198237
# A candidate can daemonize while retaining an open writable descriptor. Kill every
199238
# process under the dedicated UID before making the result immutable and hashing it.
@@ -298,6 +337,9 @@ def run(args: argparse.Namespace) -> int:
298337
}
299338

300339
output_dir = args.output_dir.resolve()
340+
runtime_temp_root = args.runtime_temp_root.resolve(strict=True)
341+
if not runtime_temp_root.is_dir():
342+
raise NotADirectoryError(runtime_temp_root)
301343
raw_dir = output_dir / "raw"
302344
log_dir = output_dir / "logs"
303345
raw_dir.mkdir(parents=True, exist_ok=True)
@@ -340,6 +382,7 @@ def run(args: argparse.Namespace) -> int:
340382
"github_runner_name": os.environ.get("RUNNER_NAME"),
341383
"github_run_id": os.environ.get("GITHUB_RUN_ID"),
342384
"github_run_attempt": os.environ.get("GITHUB_RUN_ATTEMPT"),
385+
"runtime_temp_root": str(runtime_temp_root),
343386
},
344387
"jmh": effective_config["jmh"],
345388
"ci_overrides": {key: value for key, value in overrides.items() if value is not None},
@@ -371,11 +414,17 @@ def run(args: argparse.Namespace) -> int:
371414
log_file.chmod(0o600)
372415
if isolated_candidate:
373416
prepare_untrusted_result(result_file, candidate_file_user)
417+
runtime_temp_dir = runtime_temp_root / stem
418+
prepare_runtime_temp(
419+
runtime_temp_dir,
420+
candidate_file_user if isolated_candidate else None,
421+
)
374422
command = command_for(
375423
args.java,
376424
jar,
377425
result_file,
378426
effective_config,
427+
runtime_temp_dir,
379428
candidate_prefix if isolated_candidate else None,
380429
)
381430
started_at = utc_now()
@@ -402,7 +451,13 @@ def run(args: argparse.Namespace) -> int:
402451
validation_error = None
403452
try:
404453
if run_succeeded:
405-
verify_fork_jvm_args(log_file, effective_config["jmh"].get("jvm_args", []))
454+
verify_fork_jvm_args(
455+
log_file,
456+
[
457+
*effective_config["jmh"].get("jvm_args", []),
458+
f"-Djava.io.tmpdir={runtime_temp_dir}",
459+
],
460+
)
406461
fork_jvm_args_verified = True
407462
verify_jars_unchanged(expected_jar_hashes)
408463
except ValueError as error:
@@ -427,6 +482,7 @@ def run(args: argparse.Namespace) -> int:
427482
"result_sha256": sha256_file(result_file) if result_file.is_file() else None,
428483
"log_file": log_file.relative_to(output_dir).as_posix(),
429484
"log_sha256": sha256_file(log_file),
485+
"runtime_temp_dir": str(runtime_temp_dir),
430486
"command": command,
431487
}
432488
manifest["executions"].append(record)
@@ -451,6 +507,7 @@ def parse_args() -> argparse.Namespace:
451507
parser.add_argument("--candidate-sha", required=True)
452508
parser.add_argument("--config", type=pathlib.Path, required=True)
453509
parser.add_argument("--output-dir", type=pathlib.Path, required=True)
510+
parser.add_argument("--runtime-temp-root", type=pathlib.Path, required=True)
454511
parser.add_argument("--java", default="java")
455512
parser.add_argument("--forks", type=int)
456513
parser.add_argument("--warmup-iterations", type=int)

0 commit comments

Comments
 (0)