419 lines
No EOL
16 KiB
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
|
|
} |