diff --git a/ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.scss b/ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.scss new file mode 100644 index 00000000000..9fe0bd3eef6 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.scss @@ -0,0 +1,51 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.pf-progress { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + font-size: var(--pf-font-size-s); + color: var(--pf-color-text-muted); +} + +.pf-progress__label { + font-weight: 600; +} + +.pf-progress__track { + height: 6px; + border-radius: 3px; + background: var(--pf-color-background); + overflow: hidden; +} + +.pf-progress__fill { + height: 100%; + background: var(--pf-color-primary); + transition: width 0.3s ease; +} + +.pf-progress__value { + font-variant-numeric: tabular-nums; + font-weight: 600; + color: var(--pf-color-text); + white-space: nowrap; +} + +.pf-progress__suffix { + color: var(--pf-color-text-muted); + font-weight: 400; +} diff --git a/ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.ts b/ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.ts new file mode 100644 index 00000000000..7e17e0e19e7 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.Memscope/components/progress_bar.ts @@ -0,0 +1,48 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import {clamp} from '../../../base/math_utils'; + +export interface ProgressBarAttrs { + // Percentage 0..100. Values outside the range are clamped. + readonly pct: number; + // Optional label shown to the left of the track. + readonly label?: m.Children; + // Optional value shown to the right. If omitted, "%" is shown. + readonly value?: m.Children; + // Optional muted suffix appended after the value (e.g. " / 640 MB"). + readonly suffix?: m.Children; +} + +export class ProgressBar implements m.ClassComponent { + view({attrs}: m.Vnode) { + const clamped = clamp(attrs.pct, 0, 100); + const value = attrs.value ?? `${clamped.toFixed(1)}%`; + return m( + '.pf-progress', + attrs.label !== undefined && m('.pf-progress__label', attrs.label), + m( + '.pf-progress__track', + m('.pf-progress__fill', {style: {width: `${clamped}%`}}), + ), + m( + '.pf-progress__value', + value, + attrs.suffix !== undefined && + m('span.pf-progress__suffix', attrs.suffix), + ), + ); + } +} diff --git a/ui/src/plugins/dev.perfetto.Memscope/index.ts b/ui/src/plugins/dev.perfetto.Memscope/index.ts index bb5ad693f15..00fcd49c741 100644 --- a/ui/src/plugins/dev.perfetto.Memscope/index.ts +++ b/ui/src/plugins/dev.perfetto.Memscope/index.ts @@ -52,7 +52,7 @@ export default class implements PerfettoPlugin { } else { return m(ConnectionPage, { onConnected: (result) => { - session = new LiveSession(result); + session = new LiveSession(app, result); session.onSnapshot(() => m.redraw()); }, }); diff --git a/ui/src/plugins/dev.perfetto.Memscope/sessions/live_session.ts b/ui/src/plugins/dev.perfetto.Memscope/sessions/live_session.ts index f2cbe281fba..0aa798c1d23 100644 --- a/ui/src/plugins/dev.perfetto.Memscope/sessions/live_session.ts +++ b/ui/src/plugins/dev.perfetto.Memscope/sessions/live_session.ts @@ -21,7 +21,18 @@ import {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device'; import {createAdbTracingSession} from '../../dev.perfetto.RecordTraceV2/adb/adb_tracing_session'; import {TracingSession} from '../../dev.perfetto.RecordTraceV2/interfaces/tracing_session'; import {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target'; +import {App} from '../../../public/app'; import {ConnectionResult} from '../views/connection'; +import {ProfileSession, type ProfileState} from './profile_session'; + +export interface ProfileView { + readonly pid: number; + readonly processName: string; + readonly state: ProfileState; + readonly bufferUsagePct?: number; + /** Wall-clock timestamp (ms) at which profiling started. */ + readonly startMs: number; +} const SNAPSHOT_INTERVAL_MS = 3_000; // How over to take a snapshot of the runnign trace and extract data. const INITIAL_SNAPSHOT_INTERVAL_MS = 1_000; // Use a shorter interval for the first snapshot to get data on screen faster. @@ -83,6 +94,7 @@ export type OnSnapshotCallback = (data: SnapshotData) => void; * notifies registered callbacks. */ export class LiveSession { + private readonly app: App; private session?: TracingSession; private engine?: WasmEngineProxy; private readonly sessionName: string; @@ -113,7 +125,23 @@ export class LiveSession { // True when the last snapshot took longer than the configured interval. snapshotOverrun = false; - constructor(conn: ConnectionResult) { + // Active process profile (if any). Undefined when no profile is running. + private profileImpl?: {session: ProfileSession; startMs: number}; + + get profile(): ProfileView | undefined { + const p = this.profileImpl; + if (p === undefined) return undefined; + return { + pid: p.session.pid, + processName: p.session.processName, + state: p.session.state, + bufferUsagePct: p.session.bufferUsagePct, + startMs: p.startMs, + }; + } + + constructor(app: App, conn: ConnectionResult) { + this.app = app; this.device = conn.device; this.linuxTarget = conn.linuxTarget; this.deviceName = conn.deviceName; @@ -151,6 +179,43 @@ export class LiveSession { } } + /** Starts a heap profiling session for a single process. */ + async startProfile(pid: number, processName: string): Promise { + if (this.profileImpl) { + await this.profileImpl.session.cancel(); + } + const session = await ProfileSession.start( + this.linuxTarget ?? this.device!, + pid, + processName, + this.data?.xMax ?? 0, + ); + this.profileImpl = {session, startMs: Date.now()}; + } + + /** Stops the active profile and opens the trace in the main UI. */ + async stopAndOpenProfile(): Promise { + const profile = this.profileImpl?.session; + if (!profile) return; + const processName = profile.processName; + const pid = profile.pid; + await profile.stop(); + const traceData = profile.getTraceData(); + this.profileImpl = undefined; + if (traceData) { + const fileName = `heap-${processName}-${pid}.perfetto-trace`; + const buffer = traceData.buffer as ArrayBuffer; + this.app.openTraceFromBuffer({buffer, title: fileName, fileName}); + } + } + + /** Cancels the active profile and discards data. */ + async cancelProfile(): Promise { + if (!this.profileImpl) return; + await this.profileImpl.session.cancel(); + this.profileImpl = undefined; + } + /** Stops the tracing session, polling, and disposes of the engine. */ async dispose(): Promise { // Mark disposed first so any in-flight poll() bails out before touching diff --git a/ui/src/plugins/dev.perfetto.Memscope/sessions/profile_session.ts b/ui/src/plugins/dev.perfetto.Memscope/sessions/profile_session.ts new file mode 100644 index 00000000000..b1869b38019 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.Memscope/sessions/profile_session.ts @@ -0,0 +1,188 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import protos from '../../../protos'; +import {AdbDevice} from '../../dev.perfetto.RecordTraceV2/adb/adb_device'; +import {createAdbTracingSession} from '../../dev.perfetto.RecordTraceV2/adb/adb_tracing_session'; +import {TracingSession} from '../../dev.perfetto.RecordTraceV2/interfaces/tracing_session'; +import {TracedWebsocketTarget} from '../../dev.perfetto.RecordTraceV2/traced_over_websocket/traced_websocket_target'; + +const DUMP_INTERVAL_MS = 10_000; +const PROC_STATS_BUFFER_SIZE_KB = 4 * 1024; +const HEAPPROFD_BUFFER_SIZE_KB = 128 * 1024; +const JAVA_HPROF_BUFFER_SIZE_KB = 256 * 1024; +const STATS_POLL_INTERVAL_MS = 3000; + +export type ProfileState = 'recording' | 'stopping' | 'finished' | 'error'; + +export class ProfileSession { + readonly pid: number; + readonly processName: string; + readonly startX: number; + + private inner?: TracingSession; + private intervalHandle?: ReturnType; + private _state: ProfileState = 'recording'; + private _error?: string; + private _bufferUsagePct?: number; + + private constructor(pid: number, processName: string, startX: number) { + this.pid = pid; + this.processName = processName; + this.startX = startX; + } + + static async start( + targetOrDevice: TracedWebsocketTarget | AdbDevice, + pid: number, + processName: string, + startX: number, + ): Promise { + const self = new ProfileSession(pid, processName, startX); + const config = buildProcessProfileConfig(pid); + const result = + targetOrDevice instanceof TracedWebsocketTarget + ? await targetOrDevice.startTracing(config) + : await createAdbTracingSession(targetOrDevice, config); + if (!result.ok) { + self._state = 'error'; + self._error = `Failed to start profile: ${result.error}`; + return self; + } + self.inner = result.value; + self.intervalHandle = setInterval(async () => { + self._bufferUsagePct = await self.inner!.getBufferUsagePct(); + }, STATS_POLL_INTERVAL_MS); + self.inner.onSessionUpdate.addListener(() => { + const s = self.inner!.state; + if (s === 'FINISHED') { + self._state = 'finished'; + } else if (s === 'ERRORED') { + self._state = 'error'; + self._error = self + .inner!.logs.filter((l) => l.isError) + .map((l) => l.message) + .join('; '); + } + }); + return self; + } + + get state(): ProfileState { + return this._state; + } + + get error(): string | undefined { + return this._error; + } + + get bufferUsagePct(): number | undefined { + return this._bufferUsagePct; + } + + /** Stops recording and waits for the trace data to be ready. */ + async stop(): Promise { + if (this._state !== 'recording' || this.inner === undefined) return; + clearInterval(this.intervalHandle); + this._state = 'stopping'; + await this.inner.stop(); + if (this.inner.state !== 'FINISHED') { + await new Promise((resolve) => { + const sub = this.inner!.onSessionUpdate.addListener(() => { + const s = this.inner!.state; + if (s === 'FINISHED' || s === 'ERRORED') { + sub[Symbol.dispose](); + resolve(); + } + }); + }); + } + this._state = this.inner.state === 'FINISHED' ? 'finished' : 'error'; + } + + /** Cancels recording and discards trace data. */ + async cancel(): Promise { + if (this._state !== 'recording' || this.inner === undefined) return; + clearInterval(this.intervalHandle); + this._state = 'error'; + await this.inner.cancel(); + } + + /** Returns the trace buffer once state is 'finished'. */ + getTraceData(): Uint8Array | undefined { + return this.inner?.getTraceData(); + } +} + +function buildProcessProfileConfig(pid: number): protos.ITraceConfig { + return { + compressionType: + protos.TraceConfig.CompressionType.COMPRESSION_TYPE_DEFLATE, + buffers: [ + { + name: 'process_stats', + sizeKb: PROC_STATS_BUFFER_SIZE_KB, + fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.DISCARD, + }, + { + name: 'heapprofd', + sizeKb: HEAPPROFD_BUFFER_SIZE_KB, + fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER, + }, + { + name: 'java_hprof', + sizeKb: JAVA_HPROF_BUFFER_SIZE_KB, + fillPolicy: protos.TraceConfig.BufferConfig.FillPolicy.RING_BUFFER, + }, + ], + dataSources: [ + { + config: { + name: 'linux.process_stats', + targetBufferName: 'process_stats', + processStatsConfig: { + scanAllProcessesOnStart: true, // Necessary for track names. + }, + }, + }, + { + config: { + name: 'android.heapprofd', + targetBufferName: 'heapprofd', + heapprofdConfig: { + pid: [pid], + samplingIntervalBytes: 32 * 1024, // Slightly larger than default to reduce overhead. + shmemSizeBytes: 16 * 1024 * 1024, // Arbitrary, could use default. + blockClient: true, // Important for trace integrity. + continuousDumpConfig: { + dumpIntervalMs: DUMP_INTERVAL_MS, // Important for getting regular heap snapshots to see how memory usage evolves over time. + }, + }, + }, + }, + { + config: { + name: 'android.java_hprof', + targetBufferName: 'java_hprof', + javaHprofConfig: { + pid: [pid], + continuousDumpConfig: { + dumpIntervalMs: DUMP_INTERVAL_MS, // Required for Java profiles. + }, + }, + }, + }, + ], + }; +} diff --git a/ui/src/plugins/dev.perfetto.Memscope/styles.scss b/ui/src/plugins/dev.perfetto.Memscope/styles.scss index d89ce981750..79ebcf6b330 100644 --- a/ui/src/plugins/dev.perfetto.Memscope/styles.scss +++ b/ui/src/plugins/dev.perfetto.Memscope/styles.scss @@ -15,7 +15,9 @@ @import "./components/billboard.scss"; @import "./components/color_chip.scss"; @import "./components/panel.scss"; +@import "./components/progress_bar.scss"; @import "./views/dashboard.scss"; +@import "./views/profile_page.scss"; .pf-memscope-page__container { display: flex; diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/dashboard.ts b/ui/src/plugins/dev.perfetto.Memscope/views/dashboard.ts index e79cc7b8042..133376e3e27 100644 --- a/ui/src/plugins/dev.perfetto.Memscope/views/dashboard.ts +++ b/ui/src/plugins/dev.perfetto.Memscope/views/dashboard.ts @@ -17,8 +17,17 @@ import {App} from '../../../public/app'; import {Button, ButtonGroup, ButtonVariant} from '../../../widgets/button'; import {Intent} from '../../../widgets/common'; import {RadioGroup} from '../../../widgets/radio_group'; +import { + type LineChartData, + type LineChartSeries, +} from '../../../components/widgets/charts/line_chart'; import {GateDetector} from '../../../base/mithril_utils'; -import {LiveSession} from '../sessions/live_session'; +import { + LiveSession, + type ProfileView, + type SnapshotData, +} from '../sessions/live_session'; +import {ProfilePage} from './profile_page'; import {ProcessesTab} from './tabs/processes'; import {renderSystemTab} from './tabs/system'; import {renderPageCacheTab} from './tabs/page_cache'; @@ -29,6 +38,68 @@ import {MenuDivider, MenuItem, PopupMenu} from '../../../widgets/menu'; type Tab = 'processes' | 'system' | 'file_cache' | 'pressure_swap'; +function buildProcessMemoryBreakdown( + data: SnapshotData, + pid: number, + t0: number, +): LineChartData | undefined { + // SnapshotData keys process counters by upid; resolve from pid. + let upid: number | undefined; + for (const info of data.processInfo.values()) { + if (info.pid === pid) { + upid = info.upid; + break; + } + } + if (upid === undefined) return undefined; + const pidCounters = data.processCountersByUpid.get(upid); + if (pidCounters === undefined) return undefined; + const SERIES_NAMES = ['Anon + Swap', 'File', 'DMA-BUF'] as const; + const counterMapping: Record = { + 'mem.rss.anon': 'Anon + Swap', + 'mem.swap': 'Anon + Swap', + 'mem.rss.file': 'File', + 'mem.dmabuf_rss': 'DMA-BUF', + }; + const tsSet = new Set(); + const bySeriesTs = new Map>(); + for (const [counterName, samples] of pidCounters) { + const seriesName = counterMapping[counterName]; + if (seriesName === undefined) continue; + for (const {ts, value} of samples) { + tsSet.add(ts); + let seriesMap = bySeriesTs.get(ts); + if (seriesMap === undefined) { + seriesMap = new Map(); + bySeriesTs.set(ts, seriesMap); + } + seriesMap.set( + seriesName, + (seriesMap.get(seriesName) ?? 0) + Math.round(value / 1024), + ); + } + } + const timestamps = [...tsSet].sort((a, b) => a - b); + if (timestamps.length < 2) return undefined; + const colors: Record = { + 'Anon + Swap': 'var(--pf-color-warning)', + 'File': 'var(--pf-color-success)', + 'DMA-BUF': 'var(--pf-color-primary)', + }; + const series: LineChartSeries[] = []; + for (const name of SERIES_NAMES) { + const points = timestamps.map((ts) => ({ + x: (ts - t0) / 1e9, + y: bySeriesTs.get(ts)?.get(name) ?? 0, + })); + if (points.some((p) => p.y > 0)) { + series.push({name, points, color: colors[name]}); + } + } + if (series.length === 0) return undefined; + return {series}; +} + interface DashboardAttrs { readonly app: App; readonly session: LiveSession; @@ -37,6 +108,7 @@ interface DashboardAttrs { export class Dashboard implements m.ClassComponent { private activeTab: Tab = 'processes'; + private profileBaseline?: {anonSwap: number; file: number; dmabuf: number}; view({attrs}: m.CVnode) { const {session} = attrs; @@ -50,8 +122,14 @@ export class Dashboard implements m.ClassComponent { '.pf-memscope-page__container', m( '.pf-memscope-page', + + // Title bar with status and actions (always shown). this.renderTitleBar(attrs), - this.renderDashboard(attrs), + + // Profile page or dashboard content. + session.profile !== undefined + ? this.renderProfilePage(attrs, session.profile) + : this.renderDashboard(attrs), ), ), ); @@ -60,10 +138,11 @@ export class Dashboard implements m.ClassComponent { private renderTitleBar(attrs: DashboardAttrs): m.Children { return m( '.pf-memscope-title-bar', + // Left: title + session pill (device + snapshot counter + pause/play). m( '.pf-memscope-title-bar__left', m('h1', 'Memscope'), - this.renderTabStrip(), + attrs.session.profile === undefined && this.renderTabStrip(), ), this.renderSessionPill(attrs), ); @@ -86,6 +165,8 @@ export class Dashboard implements m.ClassComponent { {value: 'file_cache', icon: 'file_copy'}, 'Page Cache', ), + + // Right: action buttons. m( RadioGroup.Button, {value: 'pressure_swap', icon: 'speed'}, @@ -241,6 +322,55 @@ export class Dashboard implements m.ClassComponent { ); } + private renderProfilePage( + attrs: DashboardAttrs, + profile: ProfileView, + ): m.Children { + const {session} = attrs; + const data = session.data; + const t0 = data?.ts0 ?? 0; + const chartData = data + ? buildProcessMemoryBreakdown(data, profile.pid, t0) + : undefined; + + // Capture baseline from first chart data. + if (this.profileBaseline === undefined && chartData !== undefined) { + const first = (name: string): number => { + const s = chartData.series.find( + (sr: LineChartSeries) => sr.name === name, + ); + return s !== undefined && s.points.length > 0 ? s.points[0].y : 0; + }; + this.profileBaseline = { + anonSwap: first('Anon + Swap'), + file: first('File'), + dmabuf: first('DMA-BUF'), + }; + } + + return m(ProfilePage, { + state: profile.state as 'recording' | 'stopping' | 'finished', + bufferUsagePct: profile.bufferUsagePct, + processName: profile.processName, + pid: profile.pid, + startMs: profile.startMs, + chartData, + baseline: this.profileBaseline, + onStop: () => { + session.stopAndOpenProfile().then(() => { + this.profileBaseline = undefined; + m.redraw(); + }); + }, + onCancel: () => { + session.cancelProfile().then(() => { + this.profileBaseline = undefined; + m.redraw(); + }); + }, + }); + } + private async stopAndOpenTrace(attrs: DashboardAttrs) { const buffer = attrs.session.lastTraceBuffer; if (buffer === undefined) return; diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/profile_page.scss b/ui/src/plugins/dev.perfetto.Memscope/views/profile_page.scss new file mode 100644 index 00000000000..6292e985f7c --- /dev/null +++ b/ui/src/plugins/dev.perfetto.Memscope/views/profile_page.scss @@ -0,0 +1,173 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +.pf-memscope-profile-bar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: stretch; + border-radius: 8px; + background: var(--pf-color-background-secondary); + border: 1px solid var(--pf-color-danger); + overflow: hidden; + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--pf-color-danger) 25%, transparent); +} + +.pf-memscope-profile-bar--stopping { + border-color: var(--pf-color-warning); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--pf-color-warning) 25%, transparent); + + .pf-memscope-profile-bar__stopping { + color: var(--pf-color-warning); + font-style: normal; + } +} + +.pf-memscope-profile-bar__zone { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 20px; + min-width: 0; + + & + & { + border-left: 1px solid var(--pf-color-border-secondary); + } +} + +.pf-memscope-profile-bar__zone--actions { + flex-direction: row; + align-items: center; + gap: 8px; +} + +.pf-memscope-profile-bar__zone--sources .pf-memscope-profile-bar__field { + flex-direction: column; + align-items: flex-start; + gap: 4px; +} + +.pf-memscope-profile-bar__label { + font-size: var(--pf-font-size-xs); + font-weight: 700; + text-transform: uppercase; + color: var(--pf-color-text-muted); + display: flex; + align-items: center; + gap: 8px; +} + +.pf-memscope-profile-bar__field { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.pf-memscope-profile-bar__rec-badge { + display: inline-flex; + align-items: center; + gap: 8px; + font-size: var(--pf-font-size-xs); + font-weight: 700; + text-transform: uppercase; + color: var(--pf-color-danger); + + &::before { + content: ""; + flex-shrink: 0; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--pf-color-danger); + box-shadow: 0 0 0 3px + color-mix(in srgb, var(--pf-color-danger) 25%, transparent); + animation: pf-memscope-pulse 1.5s ease-in-out infinite; + } +} + +.pf-memscope-profile-bar__stopping { + font-size: var(--pf-font-size-xs); + font-weight: 700; + text-transform: uppercase; + color: var(--pf-color-text-muted); + font-style: italic; +} + +.pf-memscope-profile-bar__process { + font-size: var(--pf-font-size-l); + font-weight: 700; + color: var(--pf-color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} + +.pf-memscope-profile-bar__pid-chip { + font-size: var(--pf-font-size-s); + font-weight: 600; + color: var(--pf-color-text); + background: var(--pf-color-background); + border: 1px solid var(--pf-color-border-secondary); + border-radius: 4px; + padding: 2px 8px; + white-space: nowrap; +} + +.pf-memscope-profile-bar__duration { + margin-left: auto; + font-variant-numeric: tabular-nums; + font-size: var(--pf-font-size-m); + font-weight: 600; + color: var(--pf-color-text-muted); +} + +.pf-memscope-source-chip { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.pf-memscope-source-chip__name { + display: flex; + align-items: center; + gap: 6px; + font-size: var(--pf-font-size-m); + font-weight: 700; + color: var(--pf-color-text); + + .pf-icon { + font-size: var(--pf-font-size-m); + color: var(--pf-color-text-muted); + } +} + +.pf-memscope-source-chip__meta { + font-size: var(--pf-font-size-s); + color: var(--pf-color-text-muted); + font-weight: 400; +} + +@keyframes pf-memscope-pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.45; + } +} diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/profile_page.ts b/ui/src/plugins/dev.perfetto.Memscope/views/profile_page.ts new file mode 100644 index 00000000000..3ca207be645 --- /dev/null +++ b/ui/src/plugins/dev.perfetto.Memscope/views/profile_page.ts @@ -0,0 +1,239 @@ +// Copyright (C) 2026 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import m from 'mithril'; +import { + LineChartSvg, + LineChartData, +} from '../../../components/widgets/charts_svg/line_chart_svg'; +import {Button, ButtonVariant} from '../../../widgets/button'; +import {Intent} from '../../../widgets/common'; +import {Icon} from '../../../widgets/icon'; +import {Billboard} from '../components/billboard'; +import {ProgressBar} from '../components/progress_bar'; +import {billboardKb, formatKb, maxSeriesKb, niceKbInterval} from '../utils'; +import {Icons} from '../../../base/semantic_icons'; +import {Stack} from '../../../widgets/stack'; + +export interface ProfilePageAttrs { + readonly state: 'recording' | 'stopping' | 'finished'; + readonly bufferUsagePct?: number; + readonly processName: string; + readonly pid: number; + readonly startMs: number; + readonly chartData?: LineChartData; + readonly baseline?: {anonSwap: number; file: number; dmabuf: number}; + readonly onStop: () => void; + readonly onCancel: () => void; +} + +function formatElapsed(startMs: number): string { + const sec = Math.floor((Date.now() - startMs) / 1000); + const mins = Math.floor(sec / 60); + const secs = sec % 60; + return mins > 0 ? `${mins}m ${secs}s` : `${secs}s`; +} + +export class ProfilePage implements m.ClassComponent { + view({attrs}: m.Vnode): m.Children { + const {state, bufferUsagePct} = attrs; + const stopped = state === 'stopping' || state === 'finished'; + return m(Stack, {spacing: 'large'}, [ + m( + '.pf-memscope-profile-bar', + {className: stopped ? 'pf-memscope-profile-bar--stopping' : undefined}, + [ + m( + '.pf-memscope-profile-bar__zone.pf-memscope-profile-bar__zone--subject', + [ + m( + '.pf-memscope-profile-bar__label', + stopped + ? m( + '.pf-memscope-profile-bar__stopping', + state === 'finished' ? 'Finalizing…' : 'Stopping…', + ) + : m('.pf-memscope-profile-bar__rec-badge', 'Recording'), + ), + m( + '.pf-memscope-profile-bar__field', + m('.pf-memscope-profile-bar__process', attrs.processName), + m('.pf-memscope-profile-bar__pid-chip', `PID ${attrs.pid}`), + !stopped && + m( + '.pf-memscope-profile-bar__duration', + formatElapsed(attrs.startMs), + ), + ), + !stopped && + m(ProgressBar, { + pct: bufferUsagePct ?? 0, + label: 'Ring buffer', + suffix: ' / 388 MB', + }), + ], + ), + + !stopped && + m( + '.pf-memscope-profile-bar__zone.pf-memscope-profile-bar__zone--sources', + m('.pf-memscope-profile-bar__label', 'Recording config'), + m( + '.pf-memscope-profile-bar__field', + m( + '.pf-memscope-source-chip', + m( + '.pf-memscope-source-chip__name', + m(Icon, {icon: 'memory'}), + 'heapprofd', + ), + ), + m( + '.pf-memscope-source-chip', + m( + '.pf-memscope-source-chip__name', + m(Icon, {icon: 'local_cafe'}), + 'java_hprof', + m('span.pf-memscope-source-chip__meta', 'every 10 s'), + ), + ), + ), + ), + + !stopped && + m( + '.pf-memscope-profile-bar__zone.pf-memscope-profile-bar__zone--actions', + m(Button, { + variant: ButtonVariant.Filled, + label: 'Cancel', + icon: 'close', + onclick: () => attrs.onCancel(), + }), + m(Button, { + label: 'Stop & Open Trace', + icon: Icons.ExternalLink, + variant: ButtonVariant.Filled, + intent: Intent.Primary, + onclick: () => attrs.onStop(), + }), + ), + ], + ), + + renderBillboards(attrs.chartData, attrs.baseline), + renderBreakdownChart(attrs), + ]); + } +} + +function renderBillboards( + chartData?: LineChartData, + baseline?: {anonSwap: number; file: number; dmabuf: number}, +): m.Children { + if (chartData === undefined || chartData.series.length === 0) return null; + + const seriesColor = (name: string): string | undefined => + chartData.series.find((sr) => sr.name === name)?.color; + + const latest = (name: string): number => { + const s = chartData.series.find((sr) => sr.name === name); + if (s === undefined || s.points.length === 0) return 0; + return s.points[s.points.length - 1].y; + }; + + const card = ( + current: number, + baselineVal: number | undefined, + label: string, + desc: string, + ) => { + const delta = baselineVal !== undefined ? current - baselineVal : undefined; + const deltaStr = + delta !== undefined + ? `${delta >= 0 ? '+' : ''}${formatKb(delta)}` + : undefined; + return m(Billboard, { + ...billboardKb(current), + label, + desc, + delta: deltaStr, + color: seriesColor(label), + }); + }; + + return m(Stack, {orientation: 'horizontal', spacing: 'large'}, [ + card( + latest('Anon + Swap'), + baseline?.anonSwap, + 'Anon + Swap', + 'Anonymous resident + swapped pages', + ), + card(latest('File'), baseline?.file, 'File', 'File-backed resident pages'), + card(latest('DMA-BUF'), baseline?.dmabuf, 'DMA-BUF', 'GPU/DMA buffer RSS'), + ]); +} + +function xRange(data: LineChartData): { + xMin: number | undefined; + xMax: number | undefined; +} { + let xMin: number | undefined; + let xMax: number | undefined; + for (const s of data.series) { + for (const p of s.points) { + if (xMin === undefined || p.x < xMin) xMin = p.x; + if (xMax === undefined || p.x > xMax) xMax = p.x; + } + } + return {xMin, xMax}; +} + +function renderBreakdownChart(attrs: ProfilePageAttrs): m.Children { + const {chartData} = attrs; + const body = chartData + ? (() => { + const {xMin, xMax} = xRange(chartData); + return m(LineChartSvg, { + data: {series: chartData.series}, + height: 350, + xAxisLabel: 'Time (s)', + yAxisLabel: 'Memory', + showLegend: true, + showPoints: false, + stacked: true, + gridLines: 'both', + xAxisMin: xMin, + xAxisMax: xMax, + formatXValue: (v: number) => `${v.toFixed(0)}s`, + formatYValue: (v: number) => formatKb(v), + yAxisMinInterval: niceKbInterval(maxSeriesKb(chartData.series)), + }); + })() + : m('.pf-memscope-placeholder', 'Waiting for data…'); + + return m( + '.pf-memscope-panel', + m( + '.pf-memscope-panel__header', + m('h2', 'Process Memory Breakdown'), + m( + 'p', + `Stacked area chart of memory usage for ${attrs.processName}. ` + + 'Anon + Swap = anonymous resident + swapped pages. ' + + 'File = file-backed resident pages. DMA-BUF = GPU/DMA buffer RSS.', + ), + ), + m('.pf-memscope-panel__body', body), + ); +} diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/tabs/processes.ts b/ui/src/plugins/dev.perfetto.Memscope/views/tabs/processes.ts index 215180cc50a..88d365609ac 100644 --- a/ui/src/plugins/dev.perfetto.Memscope/views/tabs/processes.ts +++ b/ui/src/plugins/dev.perfetto.Memscope/views/tabs/processes.ts @@ -677,7 +677,8 @@ class ProcessTable implements m.ClassComponent { } view({attrs}: m.CVnode): m.Children { - const {processes, isUserDebug, searchQuery, onSearchChange} = attrs; + const {processes, isUserDebug, session, searchQuery, onSearchChange} = + attrs; const visible = processes.filter((p) => { if (this.showDebuggableOnly && !p.debuggable && !isUserDebug) { @@ -776,35 +777,91 @@ class ProcessTable implements m.ClassComponent { if (mn > 0) return `${mn}m ${s}s`; return `${s}s`; })(); + const canProfile = p.debuggable || isUserDebug; + const profileButton = m(Button, { + label: 'Profile', + rightIcon: 'arrow_forward', + rounded: true, + variant: ButtonVariant.Filled, + intent: Intent.Primary, + disabled: !canProfile, + tooltip: canProfile + ? undefined + : 'Process is not debuggable. A userdebug or eng build is required to heap profile.', + onclick: () => + session.startProfile(p.pid, p.processName).then(() => m.redraw()), + }); + const mutedStyle = canProfile + ? undefined + : {color: 'var(--pf-color-text-muted)', opacity: '0.7'}; + // Chip cells skip opacity so the colored tags remain crisp. + const mutedTextOnly = canProfile + ? undefined + : {color: 'var(--pf-color-text-muted)'}; return [ - m(GridCell, p.processName), - m(GridCell, m(ColorChip, {color}, cat.name)), - m(GridCell, {align: 'right'}, `${p.pid}`), m( GridCell, - {align: 'right'}, + {actionButtons: profileButton, style: mutedStyle}, + p.processName, + ), + m(GridCell, {style: mutedTextOnly}, m(ColorChip, {color}, cat.name)), + m(GridCell, {align: 'right', style: mutedStyle}, `${p.pid}`), + m( + GridCell, + {align: 'right', style: mutedTextOnly}, oomBucket ? m(ColorChip, {color: oomBucket.color}, oomLabel) : oomLabel, ), - m(GridCell, {align: 'right'}, ageStr), - m(GridCell, {align: 'right'}, formatKb(p.rssKb)), - m(GridCell, sparkline(p.rssTrendKb)), + m(GridCell, {align: 'right', style: mutedStyle}, ageStr), + m(GridCell, {align: 'right', style: mutedStyle}, formatKb(p.rssKb)), + m(GridCell, {style: mutedStyle}, sparkline(p.rssTrendKb)), m( GridCell, - {align: 'right'}, + {align: 'right', style: mutedStyle}, p.anonKb + p.swapKb > 0 ? formatKb(p.anonKb + p.swapKb) : '-', ), - m(GridCell, {align: 'right'}, p.fileKb > 0 ? formatKb(p.fileKb) : '-'), m( GridCell, - {align: 'right'}, + {align: 'right', style: mutedStyle}, + p.fileKb > 0 ? formatKb(p.fileKb) : '-', + ), + m( + GridCell, + {align: 'right', style: mutedStyle}, p.shmemKb > 0 ? formatKb(p.shmemKb) : '-', ), ]; }); + const profile = session.profile; + const isStopping = profile?.state === 'stopping'; + return [ + profile !== undefined && + m( + '.pf-memscope-status-bar', + m('.pf-memscope-status-bar__dot'), + isStopping + ? `Stopping and reading trace for ${profile.processName}\u2026` + : `Recording heap profile for ${profile.processName} (PID ${profile.pid})`, + !isStopping && [ + m(Button, { + label: 'Stop & Open', + icon: 'stop', + minimal: true, + intent: Intent.Danger, + onclick: () => + session.stopAndOpenProfile().then(() => m.redraw()), + }), + m(Button, { + label: 'Cancel', + icon: 'close', + minimal: true, + onclick: () => session.cancelProfile().then(() => m.redraw()), + }), + ], + ), m( '.pf-memscope-panel__header.pf-memscope-search-row', m(TextInput, {