Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import com.itsaky.androidide.lookup.Lookup
import com.itsaky.androidide.lsp.IDELanguageClientImpl
import com.itsaky.androidide.lsp.debug.DebugClientConnectionResult
import com.itsaky.androidide.lsp.java.utils.CancelChecker
import com.itsaky.androidide.memprof.Memprof
import com.itsaky.androidide.models.EditorIntentExtras
import com.itsaky.androidide.models.Position
import com.itsaky.androidide.models.Range
Expand Down Expand Up @@ -1622,9 +1623,26 @@ abstract class ProjectHandlerActivity :
}

protected open fun onProjectInitialized(result: InitializeResult.Success) {
Memprof.mark("sync_complete")
editorActivityScope.launch(Dispatchers.IO) {
val manager = ProjectManagerImpl.getInstance()
val gradleBuildResult = ProjectSyncHelper.readGradleBuild(result.cacheFile)
val gradleBuildResult =
Memprof.phase("Parse project model", "cache_parsed") { span ->
Comment thread
itsaky-adfa marked this conversation as resolved.
val startMs = if (span.isRecording) System.currentTimeMillis() else 0L
val parsed = ProjectSyncHelper.readGradleBuild(result.cacheFile)
if (span.isRecording) {
span.put("bytes", result.cacheFile.length())
span.put("durationMs", System.currentTimeMillis() - startMs)
Comment thread
itsaky-adfa marked this conversation as resolved.
if (parsed.isFailure) {
span.put("failed", 1)
Comment thread
itsaky-adfa marked this conversation as resolved.
}
}
// readGradleBuild returns its failure rather than throwing, so the phase would end as finished.
if (parsed.isFailure) {
span.abandon()
}
parsed
}
if (gradleBuildResult.isFailure) {
val error = gradleBuildResult.exceptionOrNull()
log.error("Failed to read project cache", error)
Expand Down
30 changes: 30 additions & 0 deletions app/src/profileable/AndroidManifest.xml
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>
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()) {
Comment thread
itsaky-adfa marked this conversation as resolved.
return
}

val runtime = Runtime.getRuntime()
val used = runtime.totalMemory() - runtime.freeMemory()
if (used.toDouble() / runtime.maxMemory() < threshold) {
Comment thread
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
}
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()
}
}
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
}
Loading
Loading