Repository navigation
ADFA-6228: Add memory probes to the profileable build #2071
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+2,762
−326
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
43fbdd9
ADFA-6228: Log a failed Kotlin completion as an error, not a warning
itsaky-adfa 50821b3
ADFA-6228: Add profiling hooks that cost a null check unless a sink i…
itsaky-adfa 9a3f242
ADFA-6228: Summarise profiled phases as a report a person can read
itsaky-adfa fdb8c53
ADFA-6228: Record profiled phases in the profileable variant
itsaky-adfa b865768
ADFA-6228: Profile the project open phases
itsaky-adfa 43578f2
style: spotless reformat, no functional change
itsaky-adfa c5d90ae
ADFA-6228: Rewrite the memprof report while a phase is open
itsaky-adfa 3dc9055
ADFA-6228: Report each Kotlin index phase's own file counts
itsaky-adfa 3dadc87
ADFA-6228: Abandon the parse phase when the project cache fails to parse
itsaky-adfa 10df537
ADFA-6228: Log per-file memprof lines and the report at DEBUG
itsaky-adfa 88cf306
ADFA-6228: Correct the index phase handoff comment
itsaky-adfa 0bfbb45
ADFA-6228: Label the memprof retained column as allocated minus freed
itsaky-adfa 367e96e
style: spotless reformat, no functional change
itsaky-adfa 868ab9c
ADFA-6228: Tie the Kotlin index phases to the scan pass that opened them
itsaky-adfa a887ff0
ADFA-6228: Leave edit reindexes out of the Kotlin index pass count
itsaky-adfa File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| <?xml version="1.0" encoding="utf-8"?> | ||
| <!-- | ||
| `isProfileable = true` on the build type already merges in | ||
| `<profileable android:enabled="true" android:shell="true"/>`, so this manifest only adds the | ||
| measurement components. Neither is exported: `am broadcast` reaches the receiver because | ||
| ActivityManager skips the export check for root. | ||
| --> | ||
| <manifest xmlns:android="http://schemas.android.com/apk/res/android"> | ||
|
|
||
| <application> | ||
|
|
||
| <receiver | ||
| android:name="com.itsaky.androidide.memprof.MemprofReceiver" | ||
| android:enabled="true" | ||
| android:exported="false"> | ||
| <intent-filter> | ||
| <action android:name="com.itsaky.androidide.memprof.CMD" /> | ||
| </intent-filter> | ||
| </receiver> | ||
|
|
||
| <provider | ||
| android:name="com.itsaky.androidide.memprof.MemprofInitProvider" | ||
| android:authorities="${applicationId}.memprof" | ||
| android:enabled="true" | ||
| android:exported="false" | ||
| android:initOrder="1000" /> | ||
|
|
||
| </application> | ||
|
|
||
| </manifest> |
174 changes: 174 additions & 0 deletions
174
app/src/profileable/java/com/itsaky/androidide/memprof/AllocationSampler.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| /* | ||
| * This file is part of AndroidIDE. | ||
| * | ||
| * AndroidIDE is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * AndroidIDE is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package com.itsaky.androidide.memprof | ||
|
|
||
| import android.os.Debug | ||
| import org.slf4j.LoggerFactory | ||
| import java.io.File | ||
| import java.util.concurrent.atomic.AtomicBoolean | ||
|
|
||
| /** | ||
| * Samples ART's cumulative allocation counters to logcat on a fixed interval. | ||
| * | ||
| * Unlike `dumpsys meminfo`, which forces a full GC on every call and so erases the transient | ||
| * garbage a peak-heap investigation exists to measure, every counter read here is free of any GC | ||
| * side effect. `art.gc.bytes-allocated` is monotonic for the life of the process, so the difference | ||
| * between two samples is exactly what was allocated between them. | ||
| */ | ||
| object AllocationSampler { | ||
| private val logger = LoggerFactory.getLogger(AllocationSampler::class.java) | ||
|
|
||
| /** Fine enough to separate a completion request from its neighbours, cheap enough to leave on. */ | ||
| const val DEFAULT_INTERVAL_MS = 250L | ||
|
|
||
| private val running = AtomicBoolean(false) | ||
| private val dumped = AtomicBoolean(false) | ||
|
|
||
| @Volatile | ||
| private var worker: Thread? = null | ||
|
|
||
| /** | ||
| * Heap-in-use fraction at which a one-shot hprof is written, or 0 to never write one. | ||
| * | ||
| * Deliberately short of the ceiling: `dumpHprofData` needs to allocate, and a dump started with | ||
| * a few MB of headroom left dies of the OOM it was meant to explain. | ||
| */ | ||
| @Volatile | ||
| var dumpAtFraction: Double = 0.0 | ||
|
|
||
| /** | ||
| * Directory the hprof is written to, set from the app context at startup. | ||
| * | ||
| * It has to be a directory this uid owns: `/data/local/tmp` belongs to shell, and a dump aimed | ||
| * there fails with `Permission denied` only once the heap is already at the threshold. | ||
| */ | ||
| @Volatile | ||
| var dumpDir: String = "/data/local/tmp" | ||
|
|
||
| /** Called on the sampler thread with the heap in use after every tick. */ | ||
| @Volatile | ||
| var onTick: ((usedBytes: Long) -> Unit)? = null | ||
|
|
||
| /** Starts sampling every [intervalMs] milliseconds. A second call while running is ignored. */ | ||
| @JvmOverloads | ||
| fun start(intervalMs: Long = DEFAULT_INTERVAL_MS) { | ||
| if (!running.compareAndSet(false, true)) { | ||
| logger.info("MEMPROF|sampler|{}|state=already_running", System.currentTimeMillis()) | ||
| return | ||
| } | ||
|
|
||
| val thread = Thread({ loop(intervalMs) }, "memprof-alloc-sampler") | ||
| thread.isDaemon = true | ||
| thread.priority = Thread.MAX_PRIORITY | ||
| worker = thread | ||
| thread.start() | ||
| logger.info("MEMPROF|sampler|{}|state=started|intervalMs={}", System.currentTimeMillis(), intervalMs) | ||
| } | ||
|
|
||
| /** Stops sampling. Safe to call when not running. */ | ||
| fun stop() { | ||
| if (!running.compareAndSet(true, false)) { | ||
| return | ||
| } | ||
| worker?.interrupt() | ||
| worker = null | ||
| logger.info("MEMPROF|sampler|{}|state=stopped", System.currentTimeMillis()) | ||
| } | ||
|
|
||
| /** Emits one sample immediately, tagged with [reason]. */ | ||
| fun sampleNow(reason: String) { | ||
| logger.info(sample(reason, RuntimeHeapCounters.read())) | ||
| } | ||
|
|
||
| /** Emits a named marker plus a sample, so a phase boundary and its counters share a timestamp. */ | ||
| fun mark(name: String) { | ||
| logger.info("MEMPROF|mark|{}|name={}", System.currentTimeMillis(), name) | ||
| sampleNow(name) | ||
| } | ||
|
|
||
| private fun loop(intervalMs: Long) { | ||
| while (running.get()) { | ||
| val counters = RuntimeHeapCounters.read() | ||
| // DEBUG, not INFO: profileable inherits release's Sentry setup, which turns an INFO | ||
| // line into a breadcrumb, and this fires up to 4 times a second. | ||
| logger.debug(sample("tick", counters)) | ||
| onTick?.invoke(counters.usedBytes) | ||
| dumpIfAboveThreshold() | ||
| try { | ||
| Thread.sleep(intervalMs) | ||
| } catch (interrupted: InterruptedException) { | ||
| Thread.currentThread().interrupt() | ||
| return | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Writes one hprof the first time heap in use crosses [dumpAtFraction] of the growth limit. | ||
| * | ||
| * A dump taken here shows what is live while the heap is near its peak, which is the state a | ||
| * dump taken at idle cannot reach. | ||
| */ | ||
| private fun dumpIfAboveThreshold() { | ||
| val threshold = dumpAtFraction | ||
| if (threshold <= 0.0 || dumped.get()) { | ||
|
itsaky-adfa marked this conversation as resolved.
|
||
| return | ||
| } | ||
|
|
||
| val runtime = Runtime.getRuntime() | ||
| val used = runtime.totalMemory() - runtime.freeMemory() | ||
| if (used.toDouble() / runtime.maxMemory() < threshold) { | ||
|
itsaky-adfa marked this conversation as resolved.
|
||
| return | ||
| } | ||
|
|
||
| if (!dumped.compareAndSet(false, true)) { | ||
| return | ||
| } | ||
|
|
||
| val path = "$dumpDir/memprof-peak-${System.currentTimeMillis()}.hprof" | ||
| logger.info("MEMPROF|hprof_start|{}|path={}|usedMb={}", System.currentTimeMillis(), path, used shr 20) | ||
| runCatching { Debug.dumpHprofData(path) } | ||
| .onSuccess { | ||
| logger.info("MEMPROF|hprof|{}|path={}|bytes={}", System.currentTimeMillis(), path, File(path).length()) | ||
| }.onFailure { failure -> | ||
| logger.error("MEMPROF|hprof_failed|{}|path={}", System.currentTimeMillis(), path, failure) | ||
| } | ||
| } | ||
|
|
||
| /** The `MEMPROF|sample|` line for one already-read [counters], so a caller reads ART's counters only once. */ | ||
| private fun sample( | ||
| reason: String, | ||
| counters: HeapCounters, | ||
| ): String { | ||
| val runtime = Runtime.getRuntime() | ||
| return buildString { | ||
| append("MEMPROF|sample|").append(System.currentTimeMillis()) | ||
| append("|reason=").append(reason) | ||
| append("|alloc=").append(counters.allocatedBytes) | ||
| append("|freed=").append(counters.freedBytes) | ||
| append("|gcCount=").append(stat("art.gc.gc-count")) | ||
| append("|blockingGcCount=").append(counters.blockingGcCount) | ||
| append("|gcTime=").append(stat("art.gc.gc-time")) | ||
| append("|used=").append(counters.usedBytes) | ||
| append("|total=").append(runtime.totalMemory()) | ||
| append("|max=").append(runtime.maxMemory()) | ||
| } | ||
| } | ||
|
|
||
| private fun stat(key: String): Long = Debug.getRuntimeStat(key)?.toLongOrNull() ?: -1L | ||
| } | ||
54 changes: 54 additions & 0 deletions
54
app/src/profileable/java/com/itsaky/androidide/memprof/AsyncTraceSection.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| /* | ||
| * This file is part of AndroidIDE. | ||
| * | ||
| * AndroidIDE is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * AndroidIDE is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package com.itsaky.androidide.memprof | ||
|
|
||
| import android.os.Build | ||
| import android.os.Trace | ||
| import java.util.concurrent.atomic.AtomicInteger | ||
|
|
||
| /** | ||
| * A slice on the Perfetto timeline, which may start and end on different threads. | ||
| * | ||
| * Each instance carries its own cookie, so overlapping slices with the same name still pair up. | ||
| * Async slices need API 29; below that this records nothing. | ||
| */ | ||
| internal class AsyncTraceSection( | ||
| name: String, | ||
| ) { | ||
| private val name = name.take(MAX_NAME_LENGTH) | ||
| private val cookie = cookies.incrementAndGet() | ||
|
|
||
| init { | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | ||
| Trace.beginAsyncSection(this.name, cookie) | ||
| } | ||
| } | ||
|
|
||
| fun end() { | ||
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { | ||
| Trace.endAsyncSection(name, cookie) | ||
| } | ||
| } | ||
|
|
||
| private companion object { | ||
| /** Keeps names within the 127-char limit atrace section names use. */ | ||
| const val MAX_NAME_LENGTH = 127 | ||
|
|
||
| val cookies = AtomicInteger() | ||
| } | ||
| } |
64 changes: 64 additions & 0 deletions
64
app/src/profileable/java/com/itsaky/androidide/memprof/MemprofInitProvider.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| /* | ||
| * This file is part of AndroidIDE. | ||
| * | ||
| * AndroidIDE is free software: you can redistribute it and/or modify | ||
| * it under the terms of the GNU General Public License as published by | ||
| * the Free Software Foundation, either version 3 of the License, or | ||
| * (at your option) any later version. | ||
| * | ||
| * AndroidIDE is distributed in the hope that it will be useful, | ||
| * but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | ||
| * GNU General Public License for more details. | ||
| * | ||
| * You should have received a copy of the GNU General Public License | ||
| * along with AndroidIDE. If not, see <https://www.gnu.org/licenses/>. | ||
| */ | ||
|
|
||
| package com.itsaky.androidide.memprof | ||
|
|
||
| import android.content.ContentProvider | ||
| import android.content.ContentValues | ||
| import android.database.Cursor | ||
| import android.net.Uri | ||
|
|
||
| /** | ||
| * Starts the profileable variant's measurement as the process comes up. | ||
| * | ||
| * A content provider is created before `Application.onCreate`, the earliest hook available without | ||
| * editing shared sources, and cold-start allocation is part of what the measurement is for. | ||
| */ | ||
| class MemprofInitProvider : ContentProvider() { | ||
| override fun onCreate(): Boolean { | ||
| context?.applicationContext?.let(MemprofSession::install) | ||
| return true | ||
| } | ||
|
|
||
| override fun query( | ||
| uri: Uri, | ||
| projection: Array<out String>?, | ||
| selection: String?, | ||
| selectionArgs: Array<out String>?, | ||
| sortOrder: String?, | ||
| ): Cursor? = null | ||
|
|
||
| override fun getType(uri: Uri): String? = null | ||
|
|
||
| override fun insert( | ||
| uri: Uri, | ||
| values: ContentValues?, | ||
| ): Uri? = null | ||
|
|
||
| override fun delete( | ||
| uri: Uri, | ||
| selection: String?, | ||
| selectionArgs: Array<out String>?, | ||
| ): Int = 0 | ||
|
|
||
| override fun update( | ||
| uri: Uri, | ||
| values: ContentValues?, | ||
| selection: String?, | ||
| selectionArgs: Array<out String>?, | ||
| ): Int = 0 | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.