Skip to content

perf(android): Read cpu time via Process.getElapsedCpuTime - #5926

Merged
runningcode merged 4 commits into
mainfrom
no/java-690-optimize-android-cpu-collector
Aug 11, 2026
Merged

perf(android): Read cpu time via Process.getElapsedCpuTime#5926
runningcode merged 4 commits into
mainfrom
no/java-690-optimize-android-cpu-collector

Conversation

@runningcode

@runningcode runningcode commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes #5925
Linear: #5925

📜 Description

TL;DR We can get the elapsed CPU time by calling android.os.Process.getElapsedCpuTime() instead of parsing it from a file. This is more performant and uses less memory and fixes one small bug. I hope it will reduce per device variation/issues. Let's see!

Ok on to the clanker generated details:
AndroidCpuCollector obtained the process cpu time by reading /proc/self/stat and parsing utime/stime/cutime/cstime out of it. It now calls android.os.Process.getElapsedCpuTime(), which AOSP implements as a @CriticalNative wrapper around clock_gettime(CLOCK_PROCESS_CPUTIME_ID).

That removes, per sample:

  • the FileReader/BufferedReader buffers (an 8 kB StreamDecoder byte buffer plus a 16 kB char[]), the StringBuilder, the line String and the toString() copy
  • a regex split of all 52 fields of /proc/self/stat — a Matcher, an ArrayList and ~52 Strings, of which 4 were used
  • five syscalls (readText stats the file three times via exists/isFile/canRead before the open)

It also removes the Pattern.compile() from the constructor, which ran on the SentryAndroid.init path whether or not performance collection ever started, along with the _SC_CLK_TCK sysconf and the IO/parse error handling that isEnabled = false existed for.

The new source excludes the cpu time of reaped child processes, which an app process does not have, and has millisecond rather than clock-tick (10 ms) resolution.

Second, unrelated fix in the same file: setup() seeded lastCpuNanos but not lastRealtimeNanos, so the first sample of every transaction divided the cpu delta by the time since device boot and reported ~0%.

No public API change (apiDump produces no diff).

💡 Motivation and Context

collect() runs every 100 ms for the whole duration of every transaction. On a Pixel 3 each call allocated 33623 bytes, i.e. ~336 kB/s of garbage while a transaction is in flight, to read four numbers out of a ~300 byte file.

per collect() before after
bytes allocated 33623 B 16 B
wall time 478 µs 2.95 µs

The remaining 16 bytes are the Double boxing in setCpuUsagePercentage, which this PR does not touch.

💚 How did you test it?

Existing unit tests pass. They can't cover the value itself — with isReturnDefaultValues = true the android.jar stubs return 0 for both clocks, so when collect cpu is collected really asserts NaN != 0.0, before and after this change.

So verification was done on a Pixel 3 (blueline, Android 12 / API 31, 8 cores) by dexing the class files out of the release build output and driving them with app_process.

1. getElapsedCpuTime() agrees with /proc/self/stat:

_SC_CLK_TCK = 100, _SC_NPROCESSORS_CONF = 8

idle 1000ms            wall=1002ms  getElapsedCpuTime=   1ms  /proc/self/stat=   0ms  diff= 1ms
busy 1 thread 1000ms   wall=1002ms  getElapsedCpuTime=1005ms  /proc/self/stat=1000ms  diff= 5ms
busy 4 threads 1000ms  wall=1003ms  getElapsedCpuTime=3966ms  /proc/self/stat=3970ms  diff=-4ms
busy 8 threads 1000ms  wall=1005ms  getElapsedCpuTime=7701ms  /proc/self/stat=7710ms  diff=-9ms

The unit is milliseconds, it counts all threads, and it tracks the /proc value to within one clock tick (10 ms) — the residual is the tick quantization on the /proc side.

2. The collector reports the same percentages as before, sampled at the 100 ms interval DefaultCompositePerformanceCollector uses:

idle                                   1.1%    1.4%    0.0%    0.4%    0.5%
1 of 8 cores busy (expect ~12.5%)      4.7%   12.8%   12.7%   12.7%   12.3%
4 of 8 cores busy (expect ~50%)       16.5%   49.7%   49.9%   49.5%   50.1%
8 of 8 cores busy (expect ~100%)      33.4%   95.3%   98.5%   96.0%   98.1%

(The low first value in each row is the load threads ramping up inside that 100 ms window.) The pre-change collector run through the same harness produces the same numbers.

3. The first sample after setup(), taken under full load:

before:  0.0%
after:  92.9%

4. Allocation and timing, via Debug.getRuntimeStat("art.gc.bytes-allocated") over 20k collect() calls, two runs each — the table above. Note app_process runs imageless (JIT-cold), so the absolute µs figures are inflated; the byte counts are exact.

📝 Checklist

  • I added GH Issue ID & Linear ID
  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.

🔮 Next steps

AndroidMemoryCollector runs on the same 100 ms timer and is worth the same look.

AndroidCpuCollector.collect() runs 10 times per second for the whole
duration of every transaction, and each sample read and parsed
/proc/self/stat: the file reader buffers, the intermediate strings and a
regex split of all 52 fields allocated roughly 25 kB per sample, on top
of five syscalls, to obtain four numbers.

Process.getElapsedCpuTime() is a @CriticalNative wrapper around
clock_gettime(CLOCK_PROCESS_CPUTIME_ID) and returns the same quantity
with no allocation, at millisecond rather than clock-tick resolution. It
excludes the cpu time of reaped child processes, which an app process
does not have. This also drops the Pattern.compile() that ran on the
SentryAndroid.init path whether or not performance collection started.

Measured on a Pixel 3 (Android 12): collect() drops from 33623 to 16
bytes allocated per call, the remainder being the Double boxing of the
result. The reported percentages are unchanged for 1, 4 and 8 of 8 busy
cores.

Also seed lastRealtimeNanos in setup(), so the first sample is measured
against the previous sample instead of against time since boot. Under
full load that sample reported 0.0% before and 92.9% after.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@linear-code

linear-code Bot commented Aug 10, 2026

Copy link
Copy Markdown

JAVA-690

runningcode and others added 2 commits August 10, 2026 17:33
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The percentage calculation still follows the linked article; only the
source of the process cpu time changed. Drop the measured allocation
sizes from the comment - they belong in the commit history, not next to
code that no longer allocates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@runningcode
runningcode marked this pull request as ready for review August 10, 2026 15:43
@runningcode runningcode added the sanity-check PR needs a lightweight review for obvious issues label Aug 10, 2026
@sentry

sentry Bot commented Aug 10, 2026

Copy link
Copy Markdown

📲 Install Builds

Android

🔗 App Name App ID Version Configuration
SDK Size io.sentry.tests.size 8.52.0 (1) release

⚙️ sentry-android Build Distribution Settings

@github-actions

Copy link
Copy Markdown
Contributor

Performance metrics 🚀

  Plain With Sentry Diff
Startup time 313.37 ms 354.43 ms 41.05 ms
Size 0 B 0 B 0 B

Baseline results on branch: main

Startup times

Revision Plain With Sentry Diff
6b019b7 343.31 ms 417.23 ms 73.91 ms
d15471f 286.65 ms 314.68 ms 28.03 ms
d217708 409.83 ms 474.72 ms 64.89 ms
d500866 326.13 ms 378.70 ms 52.58 ms
fcec2f2 314.96 ms 373.66 ms 58.70 ms
d501a7e 314.55 ms 343.34 ms 28.79 ms
7414e9b 322.49 ms 378.88 ms 56.39 ms
fcec2f2 357.47 ms 447.32 ms 89.85 ms
a416a65 316.52 ms 359.67 ms 43.15 ms
983e0f0 350.64 ms 386.44 ms 35.79 ms

App size

Revision Plain With Sentry Diff
6b019b7 0 B 0 B 0 B
d15471f 1.58 MiB 2.13 MiB 559.54 KiB
d217708 1.58 MiB 2.10 MiB 532.97 KiB
d500866 0 B 0 B 0 B
fcec2f2 1.58 MiB 2.12 MiB 551.50 KiB
d501a7e 0 B 0 B 0 B
7414e9b 0 B 0 B 0 B
fcec2f2 1.58 MiB 2.12 MiB 551.50 KiB
a416a65 1.58 MiB 2.12 MiB 555.26 KiB
983e0f0 0 B 0 B 0 B

Comment thread sentry-android-core/src/main/java/io/sentry/android/core/AndroidCpuCollector.java Outdated

@romtsn romtsn left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

dope 🚀 as mentioned, worth taking a look at the MemoryCollector to see if we can also improve it too

The linked article describes reading and parsing /proc/self/stat, which
this collector no longer does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@runningcode
runningcode enabled auto-merge (squash) August 11, 2026 12:51
@runningcode
runningcode merged commit e4bb886 into main Aug 11, 2026
69 of 72 checks passed
@runningcode
runningcode deleted the no/java-690-optimize-android-cpu-collector branch August 11, 2026 13:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

sanity-check PR needs a lightweight review for obvious issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Optimize AndroidCpuCollector

2 participants