Skip to content
Draft
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

187 changes: 187 additions & 0 deletions benchmarks/ios-ui/bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// iOS UI frame-time benchmark.
//
// Cycles through the workloads a cross-platform UI framework is normally
// judged on, printing a marker before each so the `[frame-stats]` lines the
// runtime emits can be attributed to a workload.
//
// Run with metrics on:
//
// PERRY_FRAME_STATS=1 perry run benchmarks/ios-ui/bench.ts --target ios
//
// `PERRY_FRAME_STATS_INTERVAL` (default 300 frames) sets the reporting
// cadence. The scroll and text-heavy phases need you to actually drag the
// list — nothing here injects touches, and a stationary list measures the
// idle path, not scrolling.

import {
App,
VStack,
Text,
ScrollView,
scrollviewSetChild,
widgetAddChild,
widgetClearChildren,
widgetSetBackgroundColor,
textSetString,
onFrame,
} from "perry/ui"

// Rows rebuilt per cycle in the add/remove phase. Large enough that the old
// O(children x every widget ever created) handle scan was visible, small
// enough to stay inside a frame when it isn't.
const CHURN_ROWS = 100
// Labels mutated every frame in the property-update phase.
const LIVE_LABELS = 50
// Rows built once for the text-heavy phase.
const TEXT_ROWS = 300

// 20s rather than 8s: the phases have to be long enough for a human to react
// to a phase marker and scroll inside the window, and long enough to yield
// several reports of steady state after the transition's own cost has washed
// out of the first one.
const PHASE_SECONDS = 20

type Phase = {
name: string
// Called once when the phase starts.
enter: () => void
// Called every frame while the phase is active.
tick: (frame: number) => void
}

const content = VStack(4, [])
const scroll = ScrollView()
scrollviewSetChild(scroll, content)

// Labels kept live across frames for the property-update and animation
// phases, so those measure mutation rather than construction.
const liveLabels: unknown[] = []

function clearContent(): void {
widgetClearChildren(content)
liveLabels.length = 0
}

function buildLabels(count: number, prefix: string): void {
for (let i = 0; i < count; i++) {
const row = Text(`${prefix} ${i}`)
liveLabels.push(row)
widgetAddChild(content, row)
}
}

const phases: Phase[] = [
{
// Baseline. Establishes the floor everything else is read against — a
// p99 that is already bad here is not a workload problem.
name: "idle",
enter: (): void => {
clearContent()
// 60, not 20: every phase must overflow the screen or it cannot be
// dragged, and a phase that cannot be dragged silently measures the
// static path while looking like a scroll result.
buildLabels(60, "idle row")
},
tick: (): void => {},
},
{
// Frequent property updates: the label-text path, which is the
// cheapest possible cross-framework UI operation and therefore the
// clearest read on per-call dispatch overhead.
name: "property-updates",
enter: (): void => {
clearContent()
buildLabels(LIVE_LABELS, "live")
},
tick: (frame: number): void => {
for (let i = 0; i < liveLabels.length; i++) {
textSetString(liveLabels[i] as never, `live ${i} @ ${frame}`)
}
},
},
{
// Adding/removing many elements. This is the phase the widget-table
// handle scan used to dominate, and it degraded as the run went on,
// so a flat p99 across the whole phase is the thing to check.
name: "add-remove",
enter: (): void => {
clearContent()
},
tick: (frame: number): void => {
// Every 6th frame, so the rebuild cost is visible as a spike
// rather than smeared across every frame.
if (frame % 6 !== 0) return
clearContent()
buildLabels(CHURN_ROWS, "row")
},
},
{
// Animation: per-frame property churn across many widgets.
name: "animation",
enter: (): void => {
clearContent()
buildLabels(LIVE_LABELS, "anim")
},
tick: (frame: number): void => {
for (let i = 0; i < liveLabels.length; i++) {
const t = ((frame + i * 3) % 60) / 60
widgetSetBackgroundColor(liveLabels[i] as never, t, 0.4, 1 - t, 1)
}
},
},
{
// Text-heavy screen. Built once, then static — drag the list during
// this phase to measure scrolling.
name: "text-heavy (scroll me)",
enter: (): void => {
clearContent()
for (let i = 0; i < TEXT_ROWS; i++) {
widgetAddChild(
content,
Text(
`${i}. The quick brown fox jumps over the lazy dog, ` +
`and then keeps going so this line has to wrap.`,
),
)
}
},
tick: (): void => {},
},
]

let phaseIndex = -1
let phaseStartMs = 0
let frame = 0

function enterPhase(index: number, nowMs: number): void {
phaseIndex = index
phaseStartMs = nowMs
frame = 0
const phase = phases[index]
// The runtime's [frame-stats] lines carry no workload label; this marker
// is what makes them attributable.
console.log(`=== phase: ${phase.name} ===`)
phase.enter()
}

function loop(timestampMs: number): void {
if (phaseIndex < 0) {
enterPhase(0, timestampMs)
} else if (timestampMs - phaseStartMs >= PHASE_SECONDS * 1000) {
enterPhase((phaseIndex + 1) % phases.length, timestampMs)
}

phases[phaseIndex].tick(frame)
frame++

onFrame(loop)
}

onFrame(loop)

App({
title: "Perry UI Bench",
width: 400,
height: 800,
body: VStack(0, [scroll]),
})
76 changes: 76 additions & 0 deletions benchmarks/ios-ui/isolate_autoscroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// Isolation probe: programmatic scrolling + label mutation, no human needed.
//
// #7763 has so far only reproduced with a person dragging the list, which makes
// it expensive to hunt. This drives the scroll offset from the frame callback
// instead, combining the two ingredients the crash needs — scroll-driven layout
// and live label mutation — without touch input.
//
// KNOWN LIMITATION: `setContentOffset` does not run UIScrollView's gesture
// recognizer and does not switch the run loop into UITrackingRunLoopMode, so
// this is not equivalent to a real drag. If it reproduces, we get a
// human-free repro. If it does not, that is itself informative: it narrows the
// trigger to something only a real touch sequence provides (tracking mode,
// deceleration, or the gesture recognizer's own layout work).

import {
App,
VStack,
Text,
ScrollView,
scrollviewSetChild,
scrollviewSetOffset,
widgetAddChild,
textSetString,
onFrame,
} from "perry/ui"

const ROWS = 200
const MUTATE = 50

const content = VStack(4, [])
const scroll = ScrollView()
scrollviewSetChild(scroll, content)

const labels: unknown[] = []
for (let i = 0; i < ROWS; i++) {
const row = Text(`row ${i}`)
labels.push(row)
widgetAddChild(content, row)
}

let frame = 0
let offset = 0
let direction = 1

function loop(): void {
frame++

// Sawtooth scroll, fast enough to keep layout continuously busy.
offset += direction * 40
if (offset > 3000) {
direction = -1
} else if (offset < 0) {
direction = 1
}
scrollviewSetOffset(scroll, 0, offset)

// Mutate labels while the scroll layout is in flight — the same
// combination the crashing benchmark phase performs.
for (let i = 0; i < MUTATE; i++) {
textSetString(labels[i] as never, `row ${i} f${frame}`)
}

if (frame % 600 === 0) {
console.log(`frames: ${frame} offset: ${offset}`)
}
onFrame(loop)
}

onFrame(loop)

App({
title: "Autoscroll Isolate",
width: 400,
height: 800,
body: VStack(0, [scroll]),
})
41 changes: 41 additions & 0 deletions benchmarks/ios-ui/isolate_churn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Isolation probe: child churn WITHOUT onFrame.
//
// The full bench crashes on device with
// `malloc: pointer being freed was not allocated`. It differs from the
// known-stable minimal app in two ways: it drives `onFrame` (the CADisplayLink
// driver) and it churns children (`widgetClearChildren`, which now releases the
// removed subtree). This probe keeps the churn and drops `onFrame`, so a crash
// here implicates the release path and a clean run implicates the frame driver.

import {
App,
VStack,
Text,
widgetAddChild,
widgetClearChildren,
} from "perry/ui"

const ROWS = 100
const content = VStack(4, [])

let round = 0

function churn(): void {
round++
widgetClearChildren(content)
for (let i = 0; i < ROWS; i++) {
widgetAddChild(content, Text(`row ${i} / round ${round}`))
}
if (round % 10 === 0) {
console.log(`churn rounds: ${round}`)
}
}

setInterval(churn, 50)

App({
title: "Churn Isolate",
width: 400,
height: 800,
body: VStack(0, [content]),
})
61 changes: 61 additions & 0 deletions benchmarks/ios-ui/isolate_churn_frame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Isolation probe: child churn driven from `onFrame` instead of `setInterval`.
//
// `isolate_churn.ts` ran 3,230 clear+rebuild rounds from a `setInterval` with no
// crash. The full bench does the same structural mutation from `onFrame` and
// dies with:
//
// NSInvalidArgumentException: -[__NSArrayM insertObject:atIndex:]:
// object cannot be nil
//
// The only difference between the two is *where in the run loop* the mutation
// happens: an NSTimer fires at a quiescent point, while a CADisplayLink fires
// inside CoreAnimation's pre-commit phase. This probe changes nothing but the
// driver, so a crash here pins the fault on mutating the view hierarchy from a
// display-link callback rather than on the churn itself.

import {
App,
VStack,
Text,
ScrollView,
scrollviewSetChild,
widgetAddChild,
widgetClearChildren,
onFrame,
} from "perry/ui"

const ROWS = 100
const content = VStack(4, [])
// Variable under test: the bench churns a stack that lives inside a
// UIScrollView, this probe originally churned one parented directly to the
// root. Churn-from-onFrame alone did NOT reproduce (640 rounds clean).
const scroll = ScrollView()
scrollviewSetChild(scroll, content)

let frame = 0
let round = 0

function loop(): void {
// Same cadence as the bench's add-remove phase.
if (frame % 6 === 0) {
round++
widgetClearChildren(content)
for (let i = 0; i < ROWS; i++) {
widgetAddChild(content, Text(`row ${i} / round ${round}`))
}
if (round % 10 === 0) {
console.log(`churn rounds: ${round}`)
}
}
frame++
onFrame(loop)
}

onFrame(loop)

App({
title: "Churn+Frame Isolate",
width: 400,
height: 800,
body: VStack(0, [scroll]),
})
Loading