Skip to content

perf(swift/core): check each updateComponents against what it changes, and run message_processor_scaling.yaml - #3376

Merged
pinieb merged 16 commits into
a2ui-project:mainfrom
karolkulesza:perf/swift-incremental-update-checks
Oct 30, 2026
Merged

pinieb merged 16 commits into
a2ui-project:mainfrom
karolkulesza:perf/swift-incremental-update-checks

Conversation

@karolkulesza

@karolkulesza karolkulesza commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Description

Swift leg of the scaling work done for Dart (#3314, #3345), Python (#3326) and web_core (#3327).
Fixes #3375.

Timing runner

MessageProcessorScalingConformanceTests runs conformance/core/message_processor_scaling.yaml:
it writes each payload at its two sizes, times processMessages on a new processor once untimed
and then five times in turn, and expects the ratio of the median times to be at most max_ratio.
It runs only when A2UI_RUN_TIMING=1 (A2UI_TIMING_RUNS and A2UI_TIMING_CASE narrow a local
run), so swift test skips it. It fails when the suite has a case it can't parse, or when no
case ran (a typo in A2UI_TIMING_CASE, say). A new performance job in swift_build_and_test.yml runs it in a
release build.

The composition case is skipped as a known gap: SurfaceViewModel resolves the whole node tree
after every components change, which grows with n squared and dominates at 1000/4000. #3231
reworks resolution, so this PR leaves it alone.

Per-batch graph check

Under a config that allows dangling references and a missing root (as .relaxed, the default,
does), the whole-surface graph check can only fail on a self-reference, a cycle, a chain deeper
than the depth limit, or a composition constraint. The surface passed the check before the batch,
so MessageProcessor keeps a ComponentGraphBound per surface, as the Dart core does:

  • the references of the batch components replace their old ones; a self-reference fails;
  • the height bound of each batch component is recomputed and raised through its holders until
    none rises, so a cycle (which must pass through a batch component) or a chain over the limit
    exceeds the limit;
  • composition constraints are checked for the root when the batch holds it, for each batch
    component and its children, and for each holder outside the batch and the batch component it
    holds.

When the bound cannot show the batch passes, the whole surface is checked as before, so the error,
its type and message are those of the whole-surface check. Other configs (strict, or root
required) always use the whole-surface check. The bound is dropped on deleteSurface, and is only
used while it belongs to the same SurfaceViewModel and that surface's components are still at
the SurfaceComponentsModel revision the bound describes.

GraphTopologyValidator only changes maxGlobalDepth and lookupComponentAPI from private to
internal.

Numbers

M2 Pro, swift test -c release -Xswiftc -enable-testing, median processMessages time:

case main this PR
relaxed v1.0, 200 / 800 2.02 s / 17.57 s, 8.71 1.51 s / 9.49 s, 6.28
no config v1.0, 200 / 800 2.01 s / 17.57 s, 8.72 1.49 s / 9.37 s, 6.29
composition, 1000 / 4000 not run 13.2 s / 137.5 s, 10.44 (skipped, known gap)

Re-run on the same machine after merging main on 2026-10-30 (3 runs): relaxed 0.24 s / 1.46 s,
ratio 6.02; no config 0.31 s / 1.24 s, ratio 4.03. Absolute times dropped with main's changes;
both ratios stay under 8.

Tests

ComponentGraphBatchCheckTests sends each batch to a processor under .relaxed and checks the
same surface with the batch applied through GraphTopologyValidator.validate; both must accept
it, or both reject it with the same kind of error, and the surface must hold the same components.
Cases: a child after its container, a container after its child, a disallowed child added later,
a container rewritten to drop a disallowed child, a held child whose type changes, a holder the
child doesn't allow, a root the surface may not hold, a cycle closed by a later message, a
self-reference, a chain over the depth limit arriving from its end, two chains joined in the
middle, a cycle closed through an array of ComponentId, a component replaced outside the
processor, a duplicate id in one batch while a bound is cached, and 200 random sequences of 20 batches with and without composition constraints.
Only the kind of error is compared, because when a surface breaks several rules the whole-surface
check reports the first one in dictionary order. Disabling the holder check makes 6 of these
fail. Existing validator and message-processor conformance suites pass unchanged.

Pre-launch Checklist

  • I signed the CLA.
  • I read the Contributors Guide.
  • I read the Style Guide.
  • I have built and run at least one sample.
  • I have updated the relevant CHANGELOG.md file. (no Swift CHANGELOG)
  • I updated/added relevant documentation. (conformance/README.md)
  • My code changes (if any) have tests.
  • If my branch is on a fork, I have verified that scripts/e2e_test.sh passes.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces an incremental component graph validation optimization in the Swift core (MessageProcessor) using a new ComponentGraphBound class. When the validation configuration allows dangling references and missing roots, the processor now validates only the updated batch of components against the cached bounds of the existing graph, rather than re-validating the entire surface. This reduces the validation cost to scale with the batch size rather than the total surface size. The PR also adds comprehensive unit tests for this batch validation logic, performance/scaling conformance tests, and updates the documentation. There are no review comments, so I have no feedback to provide.

@github-actions github-actions Bot added the status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs label Oct 21, 2026
@Varun-S10

Copy link
Copy Markdown
Collaborator

Hi @karolkulesza, thank you for raising the PR to fix #3375. We have checked the changes and confirmed that they resolve the issue. Before merging, please rebase onto main to fix the merge conflict in conformance/README.md. Also, in MessageProcessor, please use a change counter on SurfaceComponentsModel instead of bound.count == held.count (so same-count direct updates don't leave bound stale), and clear graphBounds in dispose(). Could you please update the PR and let us know? Thank you for your contribution and understanding.

@Varun-S10 Varun-S10 added status: waiting-for-author-response and removed status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs labels Oct 22, 2026
@karolkulesza
karolkulesza force-pushed the perf/swift-incremental-update-checks branch from 92e4bc8 to 78cc5b2 Compare October 22, 2026 16:10
@karolkulesza

Copy link
Copy Markdown
Contributor Author

@Varun-S10 thanks, done in 78cc5b2, and rebased onto main:

  • SurfaceComponentsModel now has an internal revision, raised by addComponent, removeComponent and applyBatch. The bound records the revision left by the processor's own applyBatch, and a batch is checked against it only while the surface is still at that revision; otherwise the whole surface is checked and the bound rebuilt. ComponentGraphBound.count is gone. New test: sameCountChangeOutsideProcessorIsChecked closes a cycle by replacing a component through applyBatch / addComponent (same count) and expects the next batch to be rejected; it fails with the old count check.
  • dispose() clears graphBounds. There is no test for it: the map is private, and a surface created again under the same id is a new SurfaceViewModel, which the bound.surface === surface check already ignored.
  • conformance/README.md: kept main's paragraph and added the Swift sentence before its last sentence.
  • fix(core): read arrays of ComponentId as child references #3346 (arrays of ComponentId) changed only the field path the validator reports; the bound reads references through the same extractReferences. Added cycleClosedThroughArrayOfComponentIdIsRejected to cover that shape.

swift test passes apart from the two locale failures from #3144; the timing suite in release gives ratios of 6.2 and 6.5 (max 8).

@github-actions github-actions Bot added status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs and removed status: waiting-for-author-response labels Oct 22, 2026
@karolkulesza
karolkulesza force-pushed the perf/swift-incremental-update-checks branch from 78cc5b2 to 7743ea3 Compare October 22, 2026 17:44
@karolkulesza

Copy link
Copy Markdown
Contributor Author

Rebased onto main again after #3386 landed: swift_build_and_test.yml now has both the ios-build job from #3386 and this PR's performance job (prettier check passes). No code changes; lint is clean and swift test passes apart from the #3144 locale cases, which #3372 fixes.

Adds MessageProcessorScalingConformanceTests, which writes each
measure_growth payload at its two sizes, times processMessages on a new
processor once untimed and then five times in turn, and expects the ratio
of the median times to be at most the case's max_ratio. The cases run only
when A2UI_RUN_TIMING=1, so the default swift test skips them, and a new
performance job in swift_build_and_test.yml runs them in a release build.

The composition case is listed as a known gap: SurfaceViewModel resolves
the whole node tree after each message, which a2ui-project#3231 is expected to change.

Refs a2ui-project#3375
Under a validation config that allows dangling references and a missing
root, as .relaxed does, MessageProcessor copied every stored component and
ran GraphTopologyValidator on the whole surface for each batch, so a
surface arriving in n messages took time quadratic in n.

MessageProcessor now keeps a ComponentGraphBound per surface, following
the Dart core (a2ui-project#3314, a2ui-project#3345): the references each component holds, the
components holding each id, and an upper bound on the longest chain below
each component. A batch is checked for self-references, cycles and the
depth limit by raising the bounds above its components, and for
composition constraints on the references from and to its components. When
the bound cannot show the batch passes, or under any other config, the
whole surface is checked as before, so the error raised is that of the
whole-surface check.

message_processor_scaling.yaml, release build, median of 5 (M2 Pro):
relaxed v1.0 200/800 goes from 2.02 s / 17.57 s (ratio 8.71) to
1.51 s / 9.49 s (ratio 6.28).

Fixes a2ui-project#3375
…graph bound

The processor used its graph bound for a surface only while the bound held
as many components as the surface, so a change that kept the count, such as
host code replacing a component through applyBatch or addComponent, left the
bound describing a graph the surface no longer had.

SurfaceComponentsModel now counts its changes in an internal revision,
raised by addComponent, removeComponent and applyBatch. The bound records
the revision the processor's own applyBatch leaves, and a batch is checked
against the bound only while the surface is still at that revision;
otherwise the whole surface is checked and the bound rebuilt. dispose()
clears the bounds.

Tests: a same-count change made outside the processor is checked again,
through applyBatch and addComponent, and a cycle closed through an array of
ComponentId is rejected in a batch as in a check of the whole surface.
@karolkulesza
karolkulesza force-pushed the perf/swift-incremental-update-checks branch from 7743ea3 to 555766d Compare October 22, 2026 20:02
@karolkulesza

Copy link
Copy Markdown
Contributor Author

Rebased onto main once more: #3326 rewrote the timing paragraph in conformance/README.md, so I kept its Python sentence and added the Swift one before the closing sentence. No code changes; lint is clean and swift test passes apart from the #3144 locale cases (fixed by #3372).

@Varun-S10 Varun-S10 self-assigned this Oct 29, 2026
@Varun-S10

Copy link
Copy Markdown
Collaborator

Hi @karolkulesza, thank you for making the requested updates and rebasing onto main. We have verified the new revision counter on SurfaceComponentsModel and the dispose cleanup, and everything works now. I will escalate this PR to the team for final review. Thank you for your patience and understanding.
Hi @pinieb, Could you please take a look?

@Varun-S10 Varun-S10 added status: needs review and removed status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs labels Oct 29, 2026
@karolkulesza

Copy link
Copy Markdown
Contributor Author

Merged main. #3582 moved updateComponents validation into validateComponentsBatch and buildCombinedComponentsForTopology, so the graph check now hooks in there:

  • validateComponentGraph runs after main's per-component checks.
  • It rejects a duplicate id in the batch before trying the ComponentGraphBound.
  • When the bound can't admit the batch, it falls back to main's whole-surface check (buildCombinedComponentsForTopology + GraphTopologyValidator.validate), so errors and their order stay the same as on main.

I also switched ComponentGraphBatchCheckTests to the 1.0 wire version (018ff34). All swift/core tests pass, including message_processor_scaling.yaml.

@github-actions github-actions Bot added the status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs label Oct 29, 2026
@pinieb pinieb added status: waiting-for-author-response lang: swift Affects SDKs or apps in the Swift programming language and removed status: needs review labels Oct 29, 2026
@github-actions github-actions Bot removed the status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs label Oct 29, 2026
Spell out component in validateComponentGraph, drop empty parent sets
in ComponentGraphBound so parents only holds live references, and use
guard and max where the rest of the type does.
The scaling runner now requires every case to parse and at least one
to run, and ConformanceTiming.compare throws on runs below 1. The batch
check tests assert the outcomes of the rewritten-container case and
cover a duplicate id in a batch while a bound is cached.
@github-actions github-actions Bot added status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs and removed status: waiting-for-author-response labels Oct 30, 2026

@pinieb pinieb left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR!

@pinieb
pinieb merged commit d0c256e into a2ui-project:main Oct 30, 2026
54 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in A2UI Oct 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

lang: swift Affects SDKs or apps in the Swift programming language status: needs-triage auto-managed: https://github.com/a2ui-project/a2ui/blob/main/scripts/triage.mjs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Swift: run the scaling conformance suites

3 participants