diff --git a/gradle.properties b/gradle.properties index f59bd1b..5cd5f19 100644 --- a/gradle.properties +++ b/gradle.properties @@ -35,8 +35,23 @@ org.gradle.jvmargs=-Xmx3g -XX:MaxMetaspaceSize=1536m -XX:+HeapDumpOnOutOfMemoryE # The Kotlin compile daemon is a separate JVM that every module compiles through, and it was never # configured at all - so it ran on whatever the Kotlin Gradle plugin defaults to, which varies by -# plugin version. Setting it explicitly is worth more than the specific number. -kotlin.daemon.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=768m +# plugin version. Measured with MODE=kotlin, run TWICE with the candidate order reversed, because +# a single pass cannot tell a heap effect from the build progressively warming up: +# +# heap 1st order 2nd order (reversed) mean +# 1g 44.1s 40.7s 42.4s +# 1536m 37.9s 37.2s 37.6s +# 2g 32.6s 33.8s 33.2s <- chosen +# 3g 30.1s 38.0s 34.1s +# +# The control is what makes this readable. 1g is slowest even from the LAST slot, where warming +# helps most, so it is genuinely too small. 3g looks like the winner in one order and mid-pack in +# the other - that swing is position, not heap. 2g is the only candidate that is fast in both. +# +# Honest limitation: only wall time is trustworthy here. The Kotlin daemon exits as soon as +# compilation ends, and its GC log was not reliably captured, so live-set and GC-overhead figures +# for this daemon varied ~40% between identical runs and were not used to pick the value. +kotlin.daemon.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=768m # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/konsist/build.gradle.kts b/konsist/build.gradle.kts index d2d2e89..49b9e11 100644 --- a/konsist/build.gradle.kts +++ b/konsist/build.gradle.kts @@ -29,8 +29,74 @@ val filesUnderRules = // useJUnitPlatform() is not repeated here - billionbeers.jvm.library sets it for this tier. tasks.withType().configureEach { + // Gradle forks test workers with a 512 MB heap by default, and org.gradle.jvmargs does not + // reach them - it sizes the daemon, not the worker. These rules parse every .kt file in the repo + // into an in-memory Konsist model, and since this module joined billionbeers.jvm.library it also + // carries jacoco instrumentation, so 512 MB is not enough: a full run died with + // "OutOfMemoryError thrown from the UncaughtExceptionHandler in thread Test worker". + // + // It hid well. A single-class run (`--tests "*OneRuleTest*"`) fits in the default heap and + // passes, and an unchanged repo leaves the task UP-TO-DATE, so the gate looked green from both + // directions. Only a full, non-cached run reaches the ceiling - which is the run CI does. + maxHeapSize = "2g" + inputs .files(filesUnderRules) .withPropertyName("filesUnderArchitectureRules") .withPathSensitivity(PathSensitivity.RELATIVE) } + +// Every rule file must actually have run. +// +// This gate has now failed silently three separate ways: it went UP-TO-DATE while the code it +// guards changed (fixed by declaring inputs above), it passed a single-class `--tests` run while a +// full run died of OutOfMemory (fixed by maxHeapSize above), and before either of those it simply +// never ran at all because `make test` targets testDebugUnitTest, which a pure-JVM module does not +// have (AGENTS.md ยง5). Each fix addressed one symptom. This addresses the shape. +// +// The check is self-maintaining on purpose: it counts `*Test.kt` rule files on disk and compares +// against the JUnit XML classes the run produced, so adding a rule raises the bar automatically and +// a hardcoded expected number can never go stale. A rule that is added but never executed - the +// exact thing AGENTS.md warns about twice - now fails the build instead of reading as coverage. +// +// Skipped when a `--tests` filter is supplied, because running one rule class is then correct. +val ruleSourceDir = layout.projectDirectory.dir("src/test/kotlin/com/simtop/konsist") +val hasTestFilter = + gradle.startParameter.taskRequests.any { request -> request.args.any { it == "--tests" } } + +tasks.named("test") { + val resultsDir = reports.junitXml.outputLocation + val ruleDir = ruleSourceDir + val filtered = hasTestFilter + + doLast { + if (filtered) return@doLast + + val expected = + ruleDir.asFile + .listFiles { file -> file.name.endsWith("Test.kt") } + ?.map { it.name.removeSuffix(".kt") } + ?.toSet() + .orEmpty() + val executed = + resultsDir + .get() + .asFile + .listFiles { file -> file.name.startsWith("TEST-") && file.extension == "xml" } + ?.map { it.name.removePrefix("TEST-").removeSuffix(".xml").substringAfterLast('.') } + ?.toSet() + .orEmpty() + + check(expected.isNotEmpty()) { + "Found no *Test.kt rule files in $ruleDir - the layout changed and this check, plus the " + + "whole architecture gate, would pass vacuously." + } + + val missing = expected - executed + check(missing.isEmpty()) { + "These Konsist rules exist but did not run: ${missing.sorted().joinToString(", ")}.\n" + + "A rule that never executes looks like coverage and enforces nothing. Ran " + + "${executed.size} of ${expected.size}." + } + } +} diff --git a/scripts/measure-jvm-memory.sh b/scripts/measure-jvm-memory.sh index c06d99f..86d8e83 100644 --- a/scripts/measure-jvm-memory.sh +++ b/scripts/measure-jvm-memory.sh @@ -6,12 +6,18 @@ # Why this exists: the right heap size is not a property of the project, it is a property of the # machine and of what else is running on it. A 16 GB laptop with an emulator and a browser open # wants a different number from a 64 GB desktop, and the usual "4g and you're fine" advice assumes -# the build is the only thing running. So measure instead of guessing. +# the build is the only thing running. Measured here, 4g was *slower* than 3g for exactly that +# reason. So measure instead of guessing. # -# ./scripts/measure-jvm-memory.sh # sweep the default candidates -# ./scripts/measure-jvm-memory.sh 2g 3g 4g 6g # sweep your own +# ./scripts/measure-jvm-memory.sh # sweep the Gradle daemon heap +# ./scripts/measure-jvm-memory.sh 2g 3g 4g 6g # ... with your own candidates +# MODE=kotlin ./scripts/measure-jvm-memory.sh 1g 2g 3g # sweep the KOTLIN daemon heap # TASK=":app:assembleDebug" ./scripts/measure-jvm-memory.sh # +# The two daemons are separate JVMs and must be swept separately: Gradle runs the build, and a +# Kotlin compile daemon does the actual Kotlin compilation for every module. Settle the Gradle +# heap first, put it in gradle.properties, then sweep Kotlin with MODE=kotlin. +# # IMPORTANT: run it with your normal working set open - emulator, browser, IDE. The swap column is # the whole point, and it only means something under realistic memory pressure. Measuring on an # idle machine will tell you a larger heap is always fine, which is true right up until it isn't. @@ -20,39 +26,41 @@ # # live set Max heap still in use after a garbage collection. This is the real working set, # and it is the floor: a heap below it will thrash. Target roughly 1.5-2x. -# gc overhead Share of build wall time spent in GC pauses. Above ~5% the heap is too small; -# near 0% with a large heap means you are over-provisioned and could give the -# memory back to the emulator. +# gc overhead Share of build wall time spent in GC pauses. Compare candidates against each +# other rather than against an absolute - the measured build is a full recompile, +# so the percentages are a worst case, not a typical one. # swap delta Bytes the OS swapped out during the build. Anything above ~0 means the total # allocation is too high for this machine no matter how happy the JVM looks. This # is the ceiling, and it is the number the usual advice ignores. -# -# The recommendation is the smallest candidate that keeps GC overhead under the threshold without -# causing swap. set -euo pipefail REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" +MODE="${MODE:-gradle}" TASK="${TASK:-assembleDebug}" METASPACE="${METASPACE:-1536m}" -KOTLIN_HEAP="${KOTLIN_HEAP:-1536m}" -GC_OVERHEAD_BUDGET="${GC_OVERHEAD_BUDGET:-5.0}" +GRADLE_HEAP="${GRADLE_HEAP:-3g}" # held fixed while sweeping Kotlin +KOTLIN_HEAP="${KOTLIN_HEAP:-1536m}" # held fixed while sweeping Gradle +KOTLIN_METASPACE="${KOTLIN_METASPACE:-768m}" WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT +case "$MODE" in + gradle|kotlin) ;; + *) echo "MODE must be 'gradle' or 'kotlin', got '$MODE'" >&2; exit 1 ;; +esac + CANDIDATES=("$@") if [ ${#CANDIDATES[@]} -eq 0 ]; then - CANDIDATES=(2g 3g 4g 6g) + if [ "$MODE" = "kotlin" ]; then CANDIDATES=(1g 1536m 2g 3g); else CANDIDATES=(2g 3g 4g 6g); fi fi command -v python3 >/dev/null 2>&1 || { echo "python3 is required" >&2; exit 1; } -# macOS reports swap through sysctl; Linux through /proc/vmstat. Returns bytes swapped out. swap_out_bytes() { if [[ "$(uname)" == "Darwin" ]]; then - # vm.swapusage: "total = 2048.00M used = 1234.50M free = 813.50M (encrypted)" sysctl -n vm.swapusage 2>/dev/null \ | awk '{for(i=1;i<=NF;i++) if($i=="used"){gsub(/M/,"",$(i+2)); printf "%.0f", $(i+2)*1048576}}' else @@ -62,37 +70,85 @@ swap_out_bytes() { human_mb() { python3 -c "import sys; print(f'{int(sys.argv[1])/1048576:,.0f} MB')" "$1"; } -echo "Measuring on $(uname -s), $(sysctl -n hw.ncpu 2>/dev/null || nproc) cores, \ -$(python3 -c "import sys;print(f'{int(sys.argv[1])/1073741824:.0f} GB RAM')" "$(sysctl -n hw.memsize 2>/dev/null || echo 0)")" -echo "Task: $TASK Candidates: ${CANDIDATES[*]}" +# `./gradlew --stop` stops Gradle daemons only. Kotlin compile daemons are separate JVMs that +# outlive it, so sweeping Kotlin heaps without this would keep measuring the first one started. +stop_all_daemons() { + ./gradlew --stop >/dev/null 2>&1 || true + pkill -f "org.jetbrains.kotlin.daemon.KotlinCompileDaemon" >/dev/null 2>&1 || true + sleep 1 +} + +# Guards against the whole run being meaningless. `kotlin.daemon.jvmargs` is read as a Gradle +# property, so it is passed with -P; if a future plugin version stops honouring that, the daemon +# would quietly keep its old heap and every row below would be identical. Read the requested -Xmx +# back off the running process instead of trusting that it applied. +# Guards against the whole run being meaningless. Two earlier versions of this check were wrong in +# instructive ways, so it now verifies the ARTIFACT rather than the process: +# +# 1. `pgrep -f PATTERN -l` matches nothing on macOS - options must precede the pattern - so it +# reported "no daemon" while one was running with exactly the requested heap. +# 2. Even corrected, polling after the build is the wrong moment: the Kotlin daemon exits once +# compilation finishes, so a correct pgrep still finds nothing. +# +# The thing we actually depend on is the daemon having accepted `-Xlog:gc:file=`. If that file +# exists and has content, the args were honoured and the numbers are real. If it does not, the row +# is meaningless and must be labelled so rather than quietly reported as zero. +kotlin_measurement_is_valid() { + local log="$1" + [ -s "$log" ] +} + +echo "Mode: $MODE Task: $TASK Candidates: ${CANDIDATES[*]}" +if [ "$MODE" = "kotlin" ]; then + echo "Gradle daemon held fixed at -Xmx$GRADLE_HEAP" +else + echo "Kotlin daemon held fixed at -Xmx$KOTLIN_HEAP" +fi echo "Keep your normal apps open - the swap column depends on it." echo RESULTS="$WORK_DIR/results.tsv" : > "$RESULTS" -for heap in "${CANDIDATES[@]}"; do - gc_log="$WORK_DIR/gc-$heap.log" - jvmargs="-Xmx$heap -XX:MaxMetaspaceSize=$METASPACE -Dfile.encoding=UTF-8 -Xlog:gc:file=$gc_log" +for candidate in "${CANDIDATES[@]}"; do + gradle_gc_log="$WORK_DIR/gradle-gc-$candidate.log" + kotlin_gc_log="$WORK_DIR/kotlin-gc-$candidate.log" - # A new daemon per candidate, or we would measure the previous heap setting. - ./gradlew --stop >/dev/null 2>&1 || true + if [ "$MODE" = "gradle" ]; then + gradle_heap="$candidate"; kotlin_heap="$KOTLIN_HEAP"; gc_log="$gradle_gc_log" + else + gradle_heap="$GRADLE_HEAP"; kotlin_heap="$candidate"; gc_log="$kotlin_gc_log" + fi + + gradle_args="-Xmx$gradle_heap -XX:MaxMetaspaceSize=$METASPACE -Dfile.encoding=UTF-8" + gradle_args="$gradle_args -Xlog:gc:file=$gradle_gc_log" + kotlin_args="-Xmx$kotlin_heap -XX:MaxMetaspaceSize=$KOTLIN_METASPACE" + kotlin_args="$kotlin_args -Xlog:gc:file=$kotlin_gc_log" - # Warm-up: fills the build cache and the daemon's JIT so the measured run is steady-state. - ./gradlew "$TASK" -Dorg.gradle.jvmargs="$jvmargs" \ - -Dkotlin.daemon.jvmargs="-Xmx$KOTLIN_HEAP" --console=plain >/dev/null 2>&1 || true + stop_all_daemons - # Note where the warm-up left off instead of deleting the log. The measured run reuses the same - # daemon (same jvmargs, by design - we want it JIT-warm), and that daemon holds the log file - # open: deleting it here would leave the daemon writing to an unlinked inode and every GC number - # below would come back as zero. Parsing from this offset isolates the measured window instead. - gc_offset=$(wc -c < "$gc_log" 2>/dev/null | tr -d ' ' || echo 0) + # Warm-up: fills the build cache and lets both daemons JIT, so the measured run is steady state. + ./gradlew "$TASK" -Dorg.gradle.jvmargs="$gradle_args" -Pkotlin.daemon.jvmargs="$kotlin_args" \ + --console=plain >/dev/null 2>&1 || true + + # Read from where the warm-up stopped rather than deleting the log: the daemon holds the file + # open, so deleting it leaves it writing to an unlinked inode and every GC figure reads as zero. + # In kotlin mode the log legitimately may not exist yet, so a missing file is offset 0, not an + # error worth printing. + gc_offset=0 + [ -f "$gc_log" ] && gc_offset=$(wc -c < "$gc_log" | tr -d ' ') swap_before="$(swap_out_bytes)" start_ns=$(python3 -c "import time;print(time.time_ns())") - ./gradlew "$TASK" --rerun-tasks -Dorg.gradle.jvmargs="$jvmargs" \ - -Dkotlin.daemon.jvmargs="-Xmx$KOTLIN_HEAP" --console=plain >"$WORK_DIR/build-$heap.log" 2>&1 \ - && status="ok" || status="FAILED" + ./gradlew "$TASK" --rerun-tasks -Dorg.gradle.jvmargs="$gradle_args" \ + -Pkotlin.daemon.jvmargs="$kotlin_args" --console=plain \ + >"$WORK_DIR/build-$candidate.log" 2>&1 && status="ok" || status="FAILED" + + # In kotlin mode the GC log is the proof the daemon honoured our args. No log, no measurement - + # say so instead of reporting a confident zero. + if [ "$status" = "ok" ] && [ "$MODE" = "kotlin" ] && ! kotlin_measurement_is_valid "$gc_log"; then + status="NO-GC-LOG" + fi end_ns=$(python3 -c "import time;print(time.time_ns())") swap_after="$(swap_out_bytes)" @@ -101,14 +157,14 @@ for heap in "${CANDIDATES[@]}"; do [ "$swap_delta" -lt 0 ] && swap_delta=0 # JDK 9+ unified GC logging: - # [12.345s][info][gc] GC(7) Pause Young (Normal) (G1 Evacuation Pause) 812M->233M(3072M) 9.8ms - # The value after "->" is heap still live once the collection finished; the max of those across - # the build is the live set. The trailing duration summed is total pause time. + # [12.345s][info][gc] GC(7) Pause Young (G1 Evacuation Pause) 812M->233M(3072M) 9.8ms + # The value after "->" is heap still live once the collection finished; the max across the build + # is the live set. The trailing durations summed are total pause time. read -r live_set_mb gc_ms gc_count <<<"$(python3 - "$gc_log" "$gc_offset" <<'PY' import re, sys try: with open(sys.argv[1], errors="replace") as handle: - handle.seek(int(sys.argv[2])) # skip everything the warm-up build logged + handle.seek(int(sys.argv[2])) text = handle.read() except OSError: print("0 0 0"); raise SystemExit @@ -121,14 +177,16 @@ PY gc_pct=$(python3 -c \ "import sys; print(f'{(int(sys.argv[1])/max(int(sys.argv[2]),1))*100:.1f}')" "$gc_ms" "$wall_ms") printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ - "$heap" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$swap_delta" "$status" >> "$RESULTS" + "$candidate" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$swap_delta" "$status" >> "$RESULTS" - printf ' %-4s wall %6sms live set %5s MB gc %5sms (%s%%) swapped %s %s\n' \ - "$heap" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$(human_mb "$swap_delta")" "$status" + printf ' %-6s wall %6sms live set %5s MB gc %5sms (%s%%) swapped %s %s\n' \ + "$candidate" "$wall_ms" "$live_set_mb" "$gc_ms" "$gc_pct" "$(human_mb "$swap_delta")" "$status" done +stop_all_daemons echo -python3 - "$RESULTS" "$GC_OVERHEAD_BUDGET" "$METASPACE" "$KOTLIN_HEAP" <<'PY' + +python3 - "$RESULTS" "$MODE" "$METASPACE" "$KOTLIN_METASPACE" <<'PY' import sys rows = [] @@ -137,37 +195,53 @@ for line in open(sys.argv[1]): rows.append(dict(heap=heap, wall=int(wall), live=int(live), gc_pct=float(gc_pct), swap=int(swap), status=status)) -budget, metaspace, kotlin_heap = float(sys.argv[2]), sys.argv[3], sys.argv[4] +mode, metaspace, kotlin_metaspace = sys.argv[2], sys.argv[3], sys.argv[4] ok = [r for r in rows if r["status"] == "ok"] if not ok: print("Every candidate failed - fix the build before tuning memory."); raise SystemExit(1) no_swap = [r for r in ok if r["swap"] == 0] -if not no_swap: +all_swapped = not no_swap +if all_swapped: print("WARNING: every candidate caused swapping. This machine is short on RAM for this") print("workload - close something, or accept that the build competes with the emulator.") no_swap = ok -healthy = [r for r in no_swap if r["gc_pct"] <= budget] or no_swap -pick = min(healthy, key=lambda r: (r["wall"], r["heap"])) +pick = min(no_swap, key=lambda r: (r["wall"], r["heap"])) live_max = max(r["live"] for r in ok) +if live_max == 0: + print("Live set read as 0 MB for every candidate - the GC log was not captured, so these") + print("numbers mean nothing. Check that the daemon accepted -Xlog:gc.") + raise SystemExit(1) + print(f"Live set peaked at {live_max} MB, so anything at or below that will thrash.") print(f"Rule of thumb puts the floor around {int(live_max*1.5)}-{live_max*2} MB.\n") print("Recommended, for this machine under this workload:\n") -print(f" org.gradle.jvmargs=-Xmx{pick['heap']} -XX:MaxMetaspaceSize={metaspace} " - f"-XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8") -print(f" kotlin.daemon.jvmargs=-Xmx{kotlin_heap} -XX:MaxMetaspaceSize=768m\n") -if all(r["swap"] > 0 for r in ok): - print("Chosen as the fastest candidate overall - none of them avoided swapping, so the") - print("swap criterion could not discriminate and wall time decided it.") +if mode == "gradle": + print(f" org.gradle.jvmargs=-Xmx{pick['heap']} -XX:MaxMetaspaceSize={metaspace} " + f"-XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8") + print("\nNow settle the Kotlin daemon separately:") + print(f" MODE=kotlin GRADLE_HEAP={pick['heap']} ./scripts/measure-jvm-memory.sh") +else: + print(f" kotlin.daemon.jvmargs=-Xmx{pick['heap']} -XX:MaxMetaspaceSize={kotlin_metaspace}") + +print() +if all_swapped: + print("Chosen as the fastest candidate overall - none avoided swapping, so that criterion") + print("could not discriminate and wall time decided it.") else: - print(f"Chosen as the fastest candidate that neither swapped nor spent more than " - f"{budget}% in GC.") + print("Chosen as the fastest candidate that did not swap.") print() print("Note on GC %: the measured build uses --rerun-tasks, i.e. a full recompile, which is the") -print("worst case rather than the typical one. Percentages well above the budget are expected") +print("worst case rather than the typical one. Percentages far above a few percent are expected") print("here; what matters is how they compare BETWEEN candidates, and where swap starts.") -print("Re-run KOTLIN_HEAP= to sweep the Kotlin daemon the same way once the Gradle") -print("daemon is settled - it is a separate JVM and every module compiles through it.") +print() +walls = [r["wall"] for r in ok] +if len(walls) > 2 and (walls == sorted(walls) or walls == sorted(walls, reverse=True)): + print("CAUTION: wall time moved monotonically across the candidates in the order they were") + print("run. That is what progressive warming (OS file cache, JIT) looks like as well as a real") + print("heap effect, and the two are indistinguishable from one pass. Re-run with the candidate") + print("order REVERSED: if the same heap wins again it is real, if the winner tracks position") + print("in the list it was warming and these numbers should be discarded.") PY