portico/Benchmarks/Sources/PorticoBench/Scenarios.swift

419 lines
No EOL
16 KiB
Swift

import Adw
import Gtk
import Observation
@_spi(Portico) import Portico
/// An `@Observable` model for the Observation-bridge scenarios.
@Observable final class Model {
var counter: Int = 0
}
/// Shares one executor for loop pumping. `GLibMainExecutor.runUntil(_:)`
/// iterates the default `GMainContext` until a condition holds, which is the
/// only typed pump Portico exposes; it lets the benchmark avoid duplicating
/// the framework's internal `@_silgen_name` GLib declarations.
let benchExecutor = GLibMainExecutor()
/// Every measured scenario, grouped by subsystem.
///
/// Each scenario isolates one framework mechanism so a regression can be
/// attributed to a single code path rather than to "the app got slower".
@MainActor enum Scenarios {
// MARK: - Mount
/// The bare GTK object with no Portico layer at all. The delta against the
/// Portico label scenarios is the framework's per-widget tax.
static func mountBareGtkLabel() {
Harness.measure("baseline: Gtk.Label(str:) only", iterations: 20_000) { _ in
_ = Gtk.Label(str: "hi")
}
}
/// Mount cost of the cheapest possible view, the claimed zero-subscription
/// static path. Establishes the per-widget floor.
static func mountStaticLabel() {
Harness.measure("mount Label(\"hi\") static", iterations: 20_000) { _ in
let ctx = MountContext()
_ = AnyView(Label("hi")).makeWidget(ctx)
ctx.registry.teardown()
}
}
/// Same widget through the raw generated initializer, bypassing the
/// `Label+Extras` convenience. The delta is the convenience overhead.
static func mountRawLabel() {
Harness.measure("mount Label(str:) generated init", iterations: 20_000) { _ in
let ctx = MountContext()
_ = AnyView(Label(str: "hi")).makeWidget(ctx)
ctx.registry.teardown()
}
}
/// Modifier-chain cost. `WidgetView.appending` copies the `configure`
/// array per modifier, so an O(k^2) chain cost shows up between these two.
static func mountModifierChain() {
Harness.measure("mount Label + 1 modifier", iterations: 20_000) { _ in
let ctx = MountContext()
_ = AnyView(Label("hi").hexpand(true)).makeWidget(ctx)
ctx.registry.teardown()
}
Harness.measure("mount Label + 8 modifiers", iterations: 20_000) { _ in
let ctx = MountContext()
let v = Label("hi")
.hexpand(true).vexpand(true)
.marginTop(4).marginBottom(4)
.marginStart(4).marginEnd(4)
.halign(.center).valign(.center)
_ = AnyView(v).makeWidget(ctx)
ctx.registry.teardown()
}
}
/// A 100-widget nested tree: the "open the most complex widget tree"
/// scenario, sized so it can run repeatedly.
static func mountTree() {
Harness.measure("mount 100-widget nested tree", iterations: 300) { _ in
let ctx = MountContext()
_ = AnyView(Self.makeTree()).makeWidget(ctx)
ctx.registry.teardown()
}
}
/// Teardown in isolation: the fixture is built outside the window so only
/// `NodeRegistry.teardown()` is measured.
static func teardownTree() {
Harness.measureWithSetup(
"teardown 100-widget tree",
iterations: 300,
setup: { _ -> MountContext in
let ctx = MountContext()
_ = AnyView(Self.makeTree()).makeWidget(ctx)
return ctx
},
{ ctx in ctx.registry.teardown() }
)
}
private static func makeTree() -> VStack {
VStack(spacing: 4) {
for _ in 0..<10 {
HStack(spacing: 2) {
for _ in 0..<9 { Label("cell") }
}
}
}
}
// MARK: - State and reactivity
/// A write with no dependents at all. Isolates the fixed cost of `notify()`
/// itself, independent of any subscriber work.
static func stateChurnNoDependents() {
let box = StateBox(0)
Harness.measure("StateBox.set, 0 dependents", iterations: 500_000) { i in
box.set(i)
}
}
/// The dominant runtime event: one `@State` write with a single dependent
/// tracker attached, i.e. `count += 1` behind a reactive label.
static func stateChurnOneDependent() {
let box = StateBox(0)
var sink = 0
let tracker = DependencyTracker(observing: false) { sink = box.get() }
tracker.run()
Harness.measure("StateBox.set, 1 tracker", iterations: 200_000) { i in
box.set(i)
}
_ = sink
tracker.teardown()
}
/// The same write fanned out to 50 dependents. `notify()` reuses its
/// steady-state snapshot buffers, so per-write cost grows with fan-out.
static func stateChurnManyDependents() {
let box = StateBox(0)
var trackers: [DependencyTracker] = []
var sink = 0
for _ in 0..<50 {
let t = DependencyTracker(observing: false) { sink = box.get() }
t.run()
trackers.append(t)
}
Harness.measure("StateBox.set, 50 trackers", iterations: 50_000) { i in
box.set(i)
}
_ = sink
for t in trackers { t.teardown() }
}
/// Subscriber-path fan-out, the shape used by every generated
/// `bindProperty` binding.
static func subscriberChurn() {
let box = StateBox(0)
var tokens: [SubscriptionToken] = []
var sink = 0
for _ in 0..<50 { tokens.append(box.subscribe { sink = $0 }) }
Harness.measure("StateBox.set, 50 subscribers", iterations: 50_000) { i in
box.set(i)
}
_ = sink
for t in tokens { t.cancel() }
}
/// `$state` projection cost. `Binding` stores four closures, so every
/// `$count` written in a body is a fresh set of closure contexts.
static func bindingProjection() {
let box = StateBox(0)
Harness.measure("Binding(box) projection", iterations: 500_000) { _ in
let b = Binding(box)
_ = b.untrackedValue
}
}
/// End-to-end reactive label update: state write -> tracker -> interpolation
/// re-evaluation -> GTK property write. The frame-time-critical path.
static func reactiveLabelUpdate() {
let ctx = MountContext()
let box = StateBox(0)
_ = AnyView(Label("Count: \(box.get())")).makeWidget(ctx)
Harness.measure("reactive Label text update", iterations: 100_000) { i in
box.set(i)
}
ctx.registry.teardown()
}
/// Baseline for `setIfChanged`: half the writes carry the value the box
/// already holds, and `set` notifies for all of them.
static func setAlternatingHalfNoOp() {
let box = StateBox(0)
var sink = 0
let token = box.subscribe { sink = $0 }
Harness.measure("set, 50% no-op writes", iterations: 200_000) { i in
box.set(i / 2)
}
_ = sink
token.cancel()
}
/// The same write pattern through `Binding.setIfChanged`, which skips the
/// notify pipeline for the unchanged half.
static func setIfChangedHalfNoOp() {
let box = StateBox(0)
var sink = 0
let token = box.subscribe { sink = $0 }
let binding = Binding(box)
Harness.measure("setIfChanged, 50% no-op writes", iterations: 200_000) { i in
binding.setIfChanged(i / 2)
}
_ = sink
token.cancel()
}
/// Two-way GObject property binding. `bindProperty` connects a verified
/// per-property `notify` detail, so this exercises the targeted handler.
static func twoWayPropertyChurn() {
let ctx = MountContext()
let box = StateBox(false)
_ = AnyView(Switch().active(Binding(box))).makeWidget(ctx)
Harness.measure("two-way bind: 1 bound property", iterations: 100_000) { i in
box.set(i % 2 == 0)
}
ctx.registry.teardown()
}
/// The same write against a widget carrying eight bound properties. With a
/// detail per binding, each write invokes only the matching property handler
/// instead of broadcasting to all eight.
static func twoWayPropertyFanout() {
let ctx = MountContext()
let driver = StateBox(false)
let others = (0..<7).map { _ in StateBox(true) }
var v = Switch().active(Binding(driver))
for o in others { v = v.sensitive(Binding(o)) }
_ = AnyView(v).makeWidget(ctx)
Harness.measure("two-way bind: 8 bound properties", iterations: 100_000) { i in
driver.set(i % 2 == 0)
}
ctx.registry.teardown()
}
/// Tracker attach cost: each new (box, tracker) pair inserts into a
/// dictionary and allocates a deregistration closure.
static func trackerAttach() {
let box = StateBox(0)
Harness.measure("tracker attach+teardown (plain)", iterations: 200_000) { _ in
let t = DependencyTracker(observing: false) { _ = box.get() }
t.run()
t.teardown()
}
}
/// The same with Observation enabled, which is the default for every
/// interpolated label and closure modifier in a real app.
static func trackerAttachObserving() {
let box = StateBox(0)
Harness.measure("tracker attach+teardown (observing)", iterations: 200_000) { _ in
let t = DependencyTracker { _ = box.get() }
t.run()
t.teardown()
}
}
/// `ObservationBridge` registry pressure. `unregister` does a linear
/// `dirty.removeAll` and `markDirty` a linear `contains`, so per-tracker
/// cost grows with the number of live trackers.
static func observationRegistryPressure() {
for count in [100, 1000] {
var trackers: [DependencyTracker] = []
let model = Model()
for _ in 0..<count {
let t = DependencyTracker { _ = model.counter }
t.run()
trackers.append(t)
}
Harness.measure(
"tracker teardown with \(count) registered",
iterations: count,
warmup: 0
) { i in
trackers[i].teardown()
}
trackers.removeAll()
}
}
/// Cost of one `@Observable` property mutation through the bridge. Measures
/// the synchronous half (`markDirty`, `contains`, `append`, idle-source
/// scheduling); the deferred flush is not measured here because pumping the
/// main context to force it would add noise to the window.
static func observationMutation() {
let model = Model()
var trackers: [DependencyTracker] = []
var sink = 0
for _ in 0..<50 {
let t = DependencyTracker { sink = model.counter }
t.run()
trackers.append(t)
}
Harness.measure("observable mutation (sync half), 50 trackers", iterations: 10_000) { i in
model.counter = i
}
_ = sink
for t in trackers { t.teardown() }
}
// MARK: - Lists
/// Initial mount of an N-row `ForEach`. Quadratic diff or ledger behavior
/// shows up as superlinear ns/iter across the three sizes.
static func forEachMount() {
for size in [100, 500, 1000] {
let rows = (0..<size).map { Row(id: $0, title: "row \($0)") }
Harness.measure("ForEach mount \(size) rows", iterations: 5) { _ in
let ctx = MountContext()
let box = StateBox(rows)
let list = VStack {
ForEach(Binding(box), id: \.id) { r in Label(r.title) }
}
_ = AnyView(list).makeWidget(ctx)
ctx.registry.teardown()
}
}
}
/// Appending one row to an already-mounted list. The ideal cost is O(1)
/// plus one row mount; anything worse is diff or ledger overhead.
static func forEachAppend() {
for size in [100, 500, 1000] {
let ctx = MountContext()
let box = StateBox((0..<size).map { Row(id: $0, title: "row \($0)") })
let list = VStack { ForEach(Binding(box), id: \.id) { r in Label(r.title) } }
_ = AnyView(list).makeWidget(ctx)
Harness.measure("ForEach append 1 row to \(size)", iterations: 100, warmup: 0) { i in
var v = box.peek()
v.append(Row(id: size + i, title: "new"))
box.set(v)
}
ctx.registry.teardown()
}
}
/// Removing the first row. Every surviving row shifts, which is where an
/// O(n) offset recomputation per row becomes O(n^2).
static func forEachRemoveFirst() {
for size in [100, 500, 1000] {
let ctx = MountContext()
let box = StateBox((0..<size).map { Row(id: $0, title: "row \($0)") })
let list = VStack { ForEach(Binding(box), id: \.id) { r in Label(r.title) } }
_ = AnyView(list).makeWidget(ctx)
Harness.measure("ForEach remove first of \(size)", iterations: 50, warmup: 0) { _ in
var v = box.peek()
if !v.isEmpty { v.removeFirst() }
box.set(v)
}
ctx.registry.teardown()
}
}
/// Reversing the list: the worst case for a keyed diff plus a
/// sibling-based host, since every row moves.
static func forEachReverse() {
for size in [100, 300] {
let ctx = MountContext()
let box = StateBox((0..<size).map { Row(id: $0, title: "row \($0)") })
let list = VStack { ForEach(Binding(box), id: \.id) { r in Label(r.title) } }
_ = AnyView(list).makeWidget(ctx)
Harness.measure("ForEach reverse \(size) rows", iterations: 6, warmup: 0) { _ in
box.set(box.peek().reversed())
}
ctx.registry.teardown()
}
}
// MARK: - Show/hide
/// Rapid show/hide of a conditional subtree via `EitherView`, the
/// `Gtk.Stack`-backed branch switch.
static func conditionalFlip() {
let ctx = MountContext()
let box = StateBox(true)
let cond = Binding(box)
let v = VStack {
EitherView(cond, first: { Label("on") }, second: { Label("off") })
}
_ = AnyView(v).makeWidget(ctx)
Harness.measure("EitherView branch flip", iterations: 100_000) { i in
box.set(i % 2 == 0)
}
ctx.registry.teardown()
}
// MARK: - Main loop and concurrency
/// GLib idle-source churn measured through Portico's typed `Idle` API.
/// Every source allocates a `SourceCallbackBox`, retains it into C,
/// attaches, and releases through a destroy notify.
///
/// Profiler limitation: raw `GSource` cost without the `SourceCallbackBox`
/// wrapper, and `Task { }` enqueue through `GLibMainExecutor`, are not
/// measured here because isolating them would require duplicating the
/// framework's internal `@_silgen_name` GLib declarations. The `Idle`
/// scenario captures the dominant source-creation path; `GLibMainExecutor`
/// enqueue uses the same `g_idle_source_new` + `g_source_attach` under the
/// hood, so the cost is comparable.
static func idleSourceChurn() {
Harness.measure("Idle source create+dispatch", iterations: 20_000) { _ in
var fired = false
Idle { fired = true }
try? benchExecutor.runUntil { fired }
}
}
}
/// A row element for the `ForEach` scenarios.
struct Row: Hashable {
let id: Int
let title: String
}