Skip to content

⚡ make Skiko's periodic System.gc() a concurrent cycle - #4913

Open
guiyanakuang wants to merge 1 commit into
mainfrom
zap/explicit-gc-invokes-concurrent
Open

⚡ make Skiko's periodic System.gc() a concurrent cycle#4913
guiyanakuang wants to merge 1 commit into
mainfrom
zap/explicit-gc-invokes-concurrent

Conversation

@guiyanakuang

Copy link
Copy Markdown
Member

Summary

Follow-up to #4908 and the GC flags withdrawn from #4912. This PR adds exactly one JVM flag, -XX:+ExplicitGCInvokesConcurrent, backed by an A/B run on a 2-core VM. The other candidates were measured too and are documented below so they do not get re-proposed without data.

What the flag fixes

Skiko's FrameWatcher (org.jetbrains.skiko.Setup, skiko.gc.auto) calls System.gc() every 30 s whenever more than 1000 frames were rendered in that interval, to release native Skia peers held by small Java wrappers. Under G1 an explicit System.gc() is a stop-the-world full collection. In the baseline run it fired 20 times in 23 minutes at 54-200 ms each (p50 83 ms), and one run hit a 2.3 s full GC, so any window that is animating stalls twice a minute.

With -XX:+ExplicitGCInvokesConcurrent the same calls start a concurrent cycle instead. The reference processing Skiko relies on still happens at remark; the stop-the-world part becomes a 3-8 ms concurrent-start pause plus a remark pause (p50 20 ms on the test VM). The flag is ignored by collectors other than G1, so single-core machines that default to Serial GC are unaffected. It reaches the packaged app through initJvmArgs (verified in generated.conveyor.conf).

A/B setup

  • Parallels "Windows 11" VM set to 2 vCPUs / 8 GB, running the 2.2.0.2544 amd64 zip (x64 under ARM emulation, so absolute pause times are inflated; all variants share the same handicap).
  • Each variant: fresh ~/.crosspaste, 1 min warm-up, 6 min clipboard load (180 items: 1600x1000 random bitmaps and 200-line texts via Set-Clipboard), 16 min idle. Samples every 30 s (working set, private bytes, threads, CPU), -Xlog:gc* for pauses.
  • "shown" = first launch with the main window open (it keeps rendering, so Skiko's System.gc() fires every 30 s); "tray" = second launch, no window, the reporter's scenario.
Variant Flags Window Pauses Total STW ms Max ms >50 ms >100 ms Full GCs Committed heap at end Private bytes end of idle CPU s during 16 min idle
A baseline shown 156 3290 200 24 7 20 174 MB 621 MB 88
B PeriodicGC + MinHeapFreeRatio=10/MaxHeapFreeRatio=30 shown 564 7019 106 45 1 45 60 MB 433 MB 383
C G1PeriodicGCInterval=300000 shown 197 7757 2345 44 14 45 140 MB 562 MB 368
D ExplicitGCInvokesConcurrent shown 245 2733 98 3 0 0 154 MB 551 MB 345
A2 baseline tray 99 1260 68 3 0 0 120 MB 424 MB 13
C2 G1PeriodicGCInterval=300000 tray 108 1448 315 6 1 0 114 MB 427 MB 15

Idle CPU in the "shown" rows is dominated by the window rendering continuously (B, C and D rendered for the whole idle phase, A stopped after 12 min), not by GC; compare A2/C2 for the GC-only picture.

What was rejected, with data

  • Min/MaxHeapFreeRatio=10/30 (B): after shrinking the heap to 60 MB, G1 ran 16 collections per minute for the entire idle phase (A: zero after minute 13) and burned 4x the idle CPU. Confirms the review concern on 🐛 fix Coil memory cache limit being overridden to 435 MiB #4912.
  • G1PeriodicGCInterval alone (C, C2): never fires while a window is animating, because Skiko's System.gc() resets the 5-minute idle clock. In the tray scenario it fired twice, returned an expanded heap (268 MB committed to 114 MB, private bytes 568 to 417 MB) at the cost of two ~50-65 ms pauses per cycle, but ended at the same private bytes as the baseline, whose heap had simply not expanded that far. Real but conditional benefit; left out of this PR so it can be decided separately.
  • -XX:+UseG1GC / -XX:ParallelGCThreads=4: not measured, dropped for the reasons in the 🐛 fix Coil memory cache limit being overridden to 435 MiB #4912 review (fixed count, forces G1 on single-core machines). The 2-core VM confirms JBR's own ergonomics already pick 2 parallel / 1 concurrent worker there.

Side findings

  • The Conveyor launcher ignores JAVA_TOOL_OPTIONS; the A/B injected flags by starting the bundled runtime directly with the launcher's option list.
  • With the main window open the app rendered continuously at 30+ fps while idle (Skiko's frame counter exceeded 1000 per 30 s the whole time) and used roughly 20% of a core plus ~200 MB more private bytes than the tray-only run. That looks like a permanent animation somewhere on the main screen and deserves its own issue.

Test plan

  • writeConveyorConfig -PappEnv=PRODUCTION emits the flag into app.jvm.options
  • A/B run above (variant D vs A)
  • Real-machine: open the main window, scroll and animate for a minute, confirm no periodic stutter compared with 2.2.0

Skiko's FrameWatcher calls System.gc() every 30 s whenever more than
1000 frames were rendered in the interval, to release native Skia peers
held by tiny Java wrappers. With G1 that is a stop-the-world full
collection: on a 2-core VM it measured 54-200 ms per call (p50 83 ms,
one outlier of 2.3 s), so any animating window stalls twice a minute.

-XX:+ExplicitGCInvokesConcurrent turns those calls into a concurrent
cycle. The reference processing Skiko relies on still happens at remark,
but the stop-the-world part drops to a 3-8 ms concurrent-start pause. In
the A/B run the number of pauses above 50 ms went from 24 to 3 and none
exceeded 100 ms, with the lowest total pause time of all variants. The
flag is ignored by collectors other than G1, so single-core machines
that default to Serial GC see no change. Follow-up to #4908.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant