Skip to content

Cog for Swift: the performance record

August 25, 2026

This document answers four questions:

  1. What does Cog cost in normal app code?
  2. How does it perform in a larger workload?
  3. Which trade-offs shaped the shipping runtime?
  4. What should be improved next?

It records the current build. Older results and retired designs live in perf-history.md. The benchmark design lives in design/perf.md. Commands and tool versions live in the swift/Benchmarks/Runner README. If a number here conflicts with perf-history.md, use this one.

The short answer

  • A warm, keyless state update takes well under 1 µs and allocates nothing.
  • A Storefront interaction takes about 140 µs. That is under 2% of one 120 Hz frame on the measured host.
  • Cog is about 2× to 3× slower than careful hand-written memoization in the Storefront workload. It uses fewer allocations and removes the need to maintain invalidation lists by hand.
  • Cog is about 10× faster than swift-state-graph on a steady Storefront interaction and 10× to 20× faster on the synthetic graph shapes.
  • Keyed state and cold construction are the clearest remaining gaps.
  • The default specialized arena favors speed. CompactArena saves about 6% of the measured library code segment but makes graph construction slower.

These are benchmark results, not promises for every app. Each number links to an environment ID such as E14. All runs used release builds. p50 is the median; p90 is the 90th percentile. Lower time, instructions, allocations, retains, and releases are better. ARC means Swift's reference-counting work.

1. What users should expect

Cog ships one runtime: the specialized arena. It stores state in columns and edges in a shared integer pool. A typed frontier lets the compiler specialize generic value work in the app. CompactArena turns off that frontier to save code size.

Common updates

These cuts give useful scale. They come from different sessions, so compare them only as rough examples, not as one ranked test.

Workp50What it representsEnvironment
keyless steady turn709 nsone source, one automatic value, one tracked readE12
keyed steady turn1.27 µsthe same shape, with keyed referencesE13
update with 16 consumers7.6 µsone source fans out to 16 valuesE5
settle a 100-node chain64 µsone change flows through a deep dependency pathE5
settled Storefront interaction144 µswrite state and settle a 23-node, 16-policy pricing pathE14
build and settle 1,000 fresh keyed states1.10 ms500 keyed sources and 500 keyed consumersE5

A 120 Hz frame lasts 8.3 ms. On these hosts, the first four operations use far less than one frame.

User example: A setting toggle with one derived label is close to the steady-turn case. Favoriting a product while totals and pricing update is closer to the Storefront interaction. The second case still used only about 1.7% of a 120 Hz frame in E14.

The keyless steady turn and the 100-node chain allocate nothing. Building the 1,000-state keyed graph allocates 1,699 times because it creates and keeps the graph.

What keys cost

E13 ran the same graph twice. The only change was whether every reference had a key.

Measurekeylesskeyedadded cost
p50 wall time582 ns1,267 ns2.18×
retains / releases18 / 2527 / 34+9 / +9
mallocs000

Only about 32 ns of the 685 ns gap came from ARC. Most of the gap came from AnyHashable, hashing, generic metadata, witness lookup, and value copies. Creating one box[key] reference costs about 65 ns. A keyed turn creates three, so about 195 ns is the cost of the public AnyHashable shape itself. The remaining roughly 490 ns may be reduced by a more specialized keyed path.

User example: A single app-wide theme value should be keyless. Product inventory needs keys because one declaration represents many products. Use keys for real identity, but do not add them to values that only have one instance.

Larger graph shapes

E9 compared Cog with raw @Observable and swift-state-graph 0.28.0. Raw Observation is a floor, not a full competitor: it has no automatic-value cache or dependency graph. Each result is p50 time and p50 instructions.

Shaperaw @ObservableCogswift-state-graphCog vs. rawCog vs. state graph
diamond788 µs / 16 M1,530 µs / 37 M25 ms / 505 M1.9×16× faster
deep202 µs / 4.1 M697 µs / 18 M14 ms / 300 M3.5×20× faster
broad2,055 µs / 45 M3,703 µs / 88 M36 ms / 721 M1.8×9.7× faster
unstable324 µs / 7.1 M471 µs / 12 M7,471 µs / 153 M1.5×16× faster

Cog's cache, dependency tracking, and glitch-free settlement cost 1.5× to 3.5× over the raw floor. The deep chain has the largest gap because Cog walks 100 cache nodes that the raw version does not have.

2. Storefront: an app-sized example

Storefront runs an eleven-phase commerce session through four state runtimes. All four use the same script, fixtures, async service, and shadow model. It is a representative workload v1, not an average app.

The standard profile has 1,200 products, 24 categories, 120 visited rows, 30 live rows, an eight-row prefetch margin, and 16 pricing policies. Its longest useful dependency path has 23 nodes. It covers search, keyed async state, multi-source writes, stale results, replaced requests, cart totals, and release after a grace period.

Tests also pin the other profile sizes:

Propertysmokestandardstress
products1201,2006,000
categories62448
rows visited24120400
rows held at once123040
prefetch margin on each side4812
pricing policies41648

The Cog port declares 12 keyless manual values, 5 keyed manual families, 18 keyless automatic values, 8 keyed automatic families, 7 keyless async values, and 3 keyed async families. Shape tests check those exact counts.

The four runtimes

RuntimeRole in the comparison
cogThe workload written with Cog sources, automatic values, keyed selectors, async policies, and one assembly mechanism.
observation-rawPlain @Observable. It recomputes every derived value on every read. This is the floor, not a realistic cached app.
observation-memoPlain @Observable plus seven hand-written caches and manual invalidation. This is the practical competitor.
state-graphThe same graph built with swift-state-graph 0.28.0 Stored and Computed nodes. This is the closest library comparison.

Cross-runtime results

Before E14, mise run test:storefront-all passed all four suites on the exact working copy that was measured. Each runtime matched the same shadow model at every checkpoint and ended with no outstanding requests.

All 22 Storefront cuts then ran in one session on one host. The harness chooses units per row, so check whether a value uses µs, ms, or s.

Cold start builds the runtime and reaches the first complete screen. Ten samples ran per runtime.

Runtimewall p50wall p90instructions p50CPU p50
cog19 ms20 ms487 M19 ms
observation-raw1,526 ms1,548 ms31 G1,526 ms
observation-memo5,865 µs5,931 µs89 M6,611 µs
state-graph56 ms57 ms1,337 M57 ms

Whole session runs the complete trace. Three samples ran per runtime, so the p90 column is not a useful tail measurement.

Runtimewall p50wall p90instructions p50CPU p50
cog118 ms119 ms2,886 M124 ms
observation-raw12 s13 s260 G12 s
observation-memo44 ms45 ms642 M50 ms
state-graph771 ms775 ms19 G775 ms

Async burst accepts and settles one inventory burst. Fifty samples ran per runtime.

Runtimewall p50wall p90instructions p50CPU p50
cog3,426 µs3,586 µs63 M3,701 µs
observation-raw731 ms738 ms15 G731 ms
observation-memo1,799 µs1,839 µs21 M2,109 µs
state-graph17 ms17 ms409 M17 ms

Steady interaction covers settled favorite, cart, variant, and multi-write actions. This is the only cross-runtime cut that is quiet enough for process-wide allocation and ARC counters.

Runtimewall p50wall p90instructions p50samples
cog140 µs149 µs3,895 K2,787
observation-raw115 ms118 ms2,657 M26
observation-memo59 µs66 µs1,242 K3,178
state-graph1,569 µs1,611 µs40 M1,200
Runtimemallocsmalloc bytesobjectsretains / releases
cog12536 B122,117 / 2,161
observation-raw4,432378 M4,4328,218 K / 9,596 K
observation-memo746,035 B741,543 / 1,725
state-graph2,602185 K2,49238 K / 44 K

Every runtime had a zero malloc/free difference inside the timed interaction. The raw port completed only 26 samples before the three-second limit, so its wall-clock spread is weak.

The practical reading is simple:

  • Hand-written memoization is 1.9× to 3.2× faster than Cog across these four cuts. On the steady interaction, it is about 2.4× faster.
  • Cog allocates 12 times per interaction. The memo port allocates 74 times.
  • Cog is about 11× faster than swift-state-graph on the steady interaction.
  • Recomputing every derived value is about 820× slower than Cog on that same interaction.

What the faster memo port costs to maintain

The memo port has 89 executable lines across 19 methods that say which caches each write must clear. It uses seven broad caches. Its 16-policy pricing ladder is one cache cell per product, so any pricing change recomputes the full ladder. Cog tracks each stage from the reads in that stage.

User example: If a developer adds a seventeenth pricing rule, the Cog version declares the values that rule reads. The memo version must also update every manual invalidation path that can affect the rule. Missing one path can leave a stale price with no compiler error.

The raw port has almost no cache code. That is why it is simple and why it repeats so much work. The state-graph port had to add keyed-node dictionaries, an async generation layer, and TTL eviction. Two choices favor that port: its service is a constant instead of a graph node, and it detects browse runs by comparing the output it last rendered. The comparison includes the cost of that output check.

All three non-Cog ports also render explicitly at the end of a transaction. Their tracking callbacks do not provide the same next-line settlement barrier as Cog reactions. The Observation ports still register tracking scopes, so their registration and notification costs remain in the sample. This is a scheduling difference, not a defect in those libraries.

None of the three comparison runtimes has Cog's async-value primitive. Each port therefore includes its own request generations, stale-result rule, and demand handles. The raw port also caches request identity so a render cannot start the same request again. It caches no derived value.

Behavior checked across runtimes

Each runtime declares these semantics. The shared trace tests them.

FieldCograw Observationmemo Observationstate graph
browse runs after a content change1111
browse runs after an equal write0100
browse runs after an unseen change0100
account runs through sign-in2222
unseen request starts0000
releases unobserved valuesyesno cacheyesyes
rejects stale generationsyesyesyesyes
has per-generation refresh handlesyesyesyesyes

The memo and state-graph ports match Cog on every field. That matters: careful manual caching can reproduce Cog's visible behavior. Cog's benefit is not a result that manual code cannot reach. It is automatic dependency tracking with less invalidation code.

The raw port's zero unseen-request starts comes from the render shape, not from state management. It never asks for products outside the visible window and prefetch margin.

Cog-only Storefront results

E14 also reran all six Cog-only perf-15 cuts through the generic driver.

Cutwall p50wall p90instructions p50mallocsbytessamples
cold18 ms19 ms484 M10
session119 ms119 ms2,888 M3
async burst3,389 µs3,670 µs63 M50
interactions144 µs153 µs3,930 K12536 B2,807
footprint3,033 µs3,159 µs84 M1772,220 K3
compute-only control468 µs481 µs13 M5,611625 K5,913

The footprint held 2,402 states. It kept 51 allocations and 1,208 K requested bytes after the timed region. The control kept none. The matching perf-15 and perf-16-cog cuts agreed within normal run noise, including the exact 12 interaction allocations. This checks that the generic driver did not change the Cog workload.

There is no cross-runtime footprint cut. The shared runtime API has no neutral operation that starts the catalog and search index without materializing the rest of the funnel.

SwiftUI results

E8 ran the Cog app in release on an iPhone 17 Pro simulator. All eight tests passed, with five samples per metric.

Measuremedian
cold launch to responsive first frame1.194 s
settled scroll signpost2.568 s
scroll signpost during an inventory burst2.567 s
detail navigation0.518 s
search wall time0.382 s
search CPU / instructions0.212 s / 2.28 G
search peak physical memory73.3 MB
cart checkout wall time2.257 s
cart checkout CPU / instructions0.354 s / 3.17 G

The settled and inventory-burst scroll times differ by less than 1.5 ms. In this test, offscreen inventory writes did not slow the visible scroll. XCTHitchMetric produced no series on the simulator, so there is no hitch number. Simulator results are regression signals, not device guarantees. Only the Cog port has a UI app today.

Limits of the Storefront comparison

  • E14 is one session on koomac, not a set of repeated sessions on the pinned benchmark runner.
  • Storefront is one commerce shape. A chat app, document editor, or map may rank the runtimes differently.
  • Cog maintainers wrote all ports against a Cog-shaped workload. A library author may find a better port.
  • Cold start has 10 samples, the whole session has 3, and the raw interaction has 26. Their p90 values are not strong tail data.
  • Storefront does not cover real network or disk work, memory pressure, several live screens, full navigation history, or physical devices.

3. Why the runtime is built this way

The typed frontier is the default

The arena once took 2,163 µs to build and settle 1,000 keyed states. Profiles showed repeated generic metadata and unspecialized value work at its erased storage boundary. The typed frontier made the value-typed entry points @inlinable, which lets the app compiler specialize them.

Seven paired E5 runs measured the result:

Measurefrontier offfrontier onchange
median p502,163 µs1,102 µs-49.1%
median instructions55 M27 M-51%
probe allocations5,6971,699-70.2%

The stable frontier matched a temporary compiler-attribute experiment without using an underscored attribute. The trade-off is code size and stability: @inlinable bodies and the @usableFromInline symbols they call must remain compatible with client builds.

CompactArena trades speed for size

CompactArena keeps the arena and edge pool but turns off the typed frontier. The public API and behavior stay the same.

Retained clean arm64 release artifacts measured the library's Mach-O __TEXT segment. These artifact measurements have no environment ID.

ArtifactdefaultCompactArenasaved
CogGraph __TEXT2,670,592 B2,506,752 B163,840 B (6.1%)

E7 ran both configurations back to back on the standard Storefront profile. The comparison is still useful as a ratio, though its absolute numbers predate the generic Storefront driver.

Cutdefault p50compact p50compact costdefault / compact instructions
cold21 ms33 ms+57%457 M / 793 M
session131 ms169 ms+29%2,741 M / 3,756 M
interactions178 µs197 µs+11%4.01 M / 4.52 M
async burst3.74 ms3.99 ms+7%63 M / 68 M
footprint3.60 ms7.43 ms+106%81 M / 174 M
compute-only control529 µs542 µs+2.5%13 M / 13 M

The default and compact interaction cuts both allocated 12 times. Their controls both allocated 5,611 times. The compact footprint build allocated about 10,000 times, compared with 177 for the default, because generic cold storage allocates more.

User example: Choose CompactArena only when executable size matters more than startup and graph-build speed. Because SwiftPM traits are additive, an app should make this choice. A reusable library should not force it.

The measured whole Storefront __TEXT was 991,232 bytes with the frontier on, 19.8% larger than the retired simple-core app. No compact Storefront artifact or post-driver-lift paired run exists yet.

Safety choices kept small costs

Several faster experiments were rejected because they weakened safety. The checked versions kept most of the gain.

ChoiceResultWhy
checked descriptor cache instead of unsafeDowncaststeady turn fell from 2,198 to 1,337 nsthe unsafe version saved only 5% and could turn an invariant failure into undefined behavior
unchecked exclusivity on scalar columns onlysteady turn fell from 2,152 to 1,696 nsscalar storage is MainActor-only and trivial; user values keep checks because deinit can run code
scoped record borrowssteady ARC fell from 26/35 to 21/30 in the probethe arena owns records for the whole synchronous walk
inline AnyHashable keyskeyed references allocate nothingother layouts added a global table, slower calls, or more public overloads

The checked descriptor cache uses a never-reused context ID, not a memory address, and validates the slot generation before reuse. Those checks prevent ABA and stale-slot bugs.

The scalar exclusivity change also moved one-key turns from 2,861 to 2,390 ns and 1,000-key turns from 2,902 to 2,441 ns. Typed value columns kept full checks, giving up another measured 4.4% to preserve a clear trap.

Record borrowing had a larger effect on deep graphs. In the probe, a 100-node walk fell from 729 to 524 retains and from 738 to 533 releases. The benchmark then measured 22 retains, 28 releases, zero mallocs, and 676 ns for a steady turn in E11. A smaller unowned(unsafe) field-only experiment changed no totals and was reverted.

4. What still needs work

Keyed turns are the clearest target

Keyed turns take 2.18× as long as keyless turns in E13. About 195 ns of the 685 ns gap belongs to the public AnyHashable representation. The remaining roughly 490 ns includes repeated metadata, witness lookup, hashing, and an uncached protocol-conformance path. A specialized keyed frontier is the most promising untested route.

Cold start needs a profile before a fix

Cog starts Storefront in 19 ms, compared with 5,865 µs for the memo port. The Cog-only footprint build takes 3,033 µs. The rest has not been split among first settlement, async scheduling, and fixture work. A phase-split profile is required before changing cold code.

Some of this gap is structural. The memo port builds almost no dependency graph. The exact 3.2× gap may still contain avoidable work, but current data cannot say how much.

ARC is lower, but not gone

The steady turn still retains 18 times and releases 25 times. An ARC-reduction series cut it in three steps while preserving the zero-allocation gate:

Pointretainsreleasesp50Environment
before the series3037756 nsE10
thread slot, remove payload2834744 nsE10
borrow descriptor records2228676 nsE11
trim two small sites1825709 nsE12

Only the 744 to 676 ns drop is large enough to claim from this series. The other time changes are session noise.

About five retain/release pairs remain per settled node in the deep probe. Likely sites are recompute closure copies, typed-column value moves, and Reader construction. The steady turn also retains some flush closures and Writer or Reader fields. Apple's Observation registrar accounts for about three pairs and is outside Cog's control.

The last full profile is stale

The last full arena profile ran before the typed frontier and the ARC series above. It is useful history, not a current list of percentages.

Cost in that profileshare
exclusivity checks31.7%
generic metadata26.0%
Cog code12.2%
value copies8.2%
ARC6.5%
actor checks2.2%

That profile led to targeted scalar exclusivity changes, a checked location cache, and then the typed frontier. Those changes make its shares stale. The repeatable call-site attribution probe contains the full method and call-site record. Re-profile before optimizing one of those old hot sites again.

Missing evidence

  • No current profile of the shipping core.
  • No pinned-runner session behind the proposed Storefront thresholds.
  • No compact Storefront artifact and no post-lift paired compact run.
  • No neutral cross-runtime footprint cut.
  • No UI app for the three comparison runtimes.
  • No physical-device UI measurements.

5. Regression gates

mise run bench:thresholds:check enforces these rules on the pinned host. It first runs an allocating witness so a broken counter cannot report a false zero. mise run bench:thresholds:sentinel proves the gate rejects an impossible limit.

  • A keyless steady turn has exactly zero mallocs at p90.
  • Settling one node has exactly zero mallocs.
  • Building a keyed value reference has exactly zero mallocs.
  • Writing one pinned key costs O(changed keys), not O(all pinned keys). At 1,000 pinned keys, a turn may use at most 90 retains and 110 releases.
  • With 1,000 states and 12 tracked reads, exactly 12 Observation boundary objects exist.
  • A keyed steady turn has zero mallocs and no more than 30 retains and 38 releases.

Absolute graph limits

The perf-10-* limits are intentionally loose. They catch large regressions, not small changes. They are about three times the slower p90 from E1.

Runtimerecorded p90: diamond / deep / broad / unstablelimits: diamond / deep / broad / unstable
Cog5.231 / 2.750 / 13 / 2.755 ms20 / 10 / 40 / 10 ms
raw @Observable0.820 / 0.216 / 2.277 / 0.350 ms3 / 1 / 8 / 2 ms
swift-state-graph26 / 15 / 37 / 7.696 ms80 / 50 / 120 / 25 ms

Proposed Storefront gates

Two Cog-only cuts are good CI candidates:

  • perf-15-storefront-interactions: exactly 12 mallocs at p90 and at most 600 µs wall time. E14 measured 12 mallocs and 153 µs at p90.
  • perf-15-storefront-compute-control: exactly 5,611 mallocs at p90 and at most 1.7 ms. E14 measured 5,611 mallocs and 481 µs at p90.

These gates are not committed yet. One paired run on the pinned runner must confirm them first. Cold, session, footprint, and async cuts stay report-only because they have low sample counts or scheduler-shaped variance. Cross-runtime perf-16 cuts also stay report-only because Cog does not control the tuning of the other libraries. bench:compact has no thresholds.

6. Next measurements

IDNext stepSuccess looks like
F1Run Storefront on the pinned runner and repeat the UI suite.The two proposed gates reproduce and can be committed.
F2Profile cold start as build, first settlement, and teardown.The unexplained part of the 19 ms cold start has named call sites.
F3Measure a compact Storefront artifact and rerun both arena modes after the runtime lift.The size table and current compact ratio are complete.
F4Repeat the three-runtime graph comparison on pinned Xcode and qualify release archives.Comparative results reproduce on the release toolchain.
F5Add a neutral “demand roots only” runtime operation.A fair cross-runtime footprint cut can run.
F6Test a specialized keyed frontier and narrower internal key handling.The keyed/keyless ratio falls below 2.18× without changing the API.
F7Reduce remaining Reader, closure, and typed-column ARC work.Steady and deep ARC counts fall while zero-allocation gates hold.
F8Re-profile the shipping core.Current hot sites replace the stale percentages above.
F9Optimize cold construction after F2.Storefront cold and session times fall without weaker behavior.

Measurement environments

All measurements used optimized release builds and benchmark harness 1.36.2. “Not a release check” means a session did not qualify a release candidate on the pinned runner.

E3 and E5 through E12 used mactop: Apple M4 Pro arm64, 12 cores, 24 GB, macOS 26.4.1, Xcode 26.4 (17E192), and Swift 6.3. E1 and E2 recorded the same host name and hardware size, but only identified the chip as Apple Silicon. E13 and E14 used koomac: Apple M5 Pro arm64, 5 performance cores, 10 efficiency cores, 48 GB, macOS 26.5.1 (Darwin 25.5.0), Xcode 26.6 (17F113), Swift 6.3.3, and malloc interposer 1.4.0. UI session details are in their rows.

IDDateRunNotes
E12026-08-17initial baselinemactop; Darwin 25.4.0; Swift 6.3.0
E22026-08-19shared-runtime runmactop; Apple Swift 6.3
E32026-08-20corrected Storefront runmactop; both cores ran back to back on an idle host
E42026-08-19Storefront UI smokemactop; iPhone 17 Pro simulator, iOS 26.4 (23E244), smoke profile
E52026-08-21specialization runmactop; seven paired PERF-03 runs and three warm sweeps; not a release check
E62026-08-21three-core comparisonmactop; simple, unspecialized arena, and specialized arena ran back to back; not a release check
E72026-08-23paired Storefront arena modesmactop; both modes ran back to back after 12 Storefront tests passed; not a release check
E82026-08-23corrected Storefront UI runmactop; iPhone 17 Pro simulator, iOS 26.4 (23E244), release, smoke profile, five samples
E92026-08-23current graph runtime comparisonmactop; all twelve perf-10-* cuts in one idle session; swift-state-graph 0.28.0; not a release check
E102026-08-23turn-machinery ARC runmactop; 3,908 steady-turn samples after the thread-slot cut; exact counters; not a release check
E112026-08-23record-borrow runmactop; 4,177 steady-turn samples after the record borrows; exact counters; not a release check
E122026-08-23small-site runmactop; 4,104 steady-turn samples after the small-site trims; exact counters; not a release check
E132026-08-24keyed-turn comparisonkoomac; keyless and keyed steady turns in one idle session, plus probe attribution and six-second samples; CI toolchain, non-CI host; not a release check
E142026-08-25four-runtime Storefront comparisonkoomac; 22 perf-15 and perf-16 cuts from 16:27:11Z to 16:29:36Z; all Storefront suites green first; swift-state-graph 0.28.0 at e602fcdb19342a38c135543e7228b3fd60753dc7; CI toolchain, non-CI host; uncommitted runtime-lift work on 8f3f70e; not a release check

The call-site attribution and ARC-series profiler probes have no environment ID. They used mactop, release builds with debug symbols, and the same Xcode 26.4 toolchain described above. Do not compare their absolute values with an E-numbered session.

Measurement rules

  • Allocation and ARC counters are process-wide. Use them only while the graph is quiet, with no async completion or teardown inside the timer.
  • mallocFreeDelta is allocations minus frees during the timed region. memoryLeakedBytes is the matching requested-byte balance. Neither is a full live-heap count.
  • Resident memory is page-sized and sampled. Use it for large changes only.
  • Every sample checks its result and run count. Storefront also checks a shadow digest after timing.
  • A zero-allocation gate must run perf-witness-allocating first.
  • Compare runtimes only within one session. Do not subtract a result from one environment from a result in another.

Released under the MIT License.