Skip to content

perf: avoid repeated sibling and frame scans during XML serialization - #1283

Merged
KazuCocoa merged 1 commit into
masterfrom
codex/perf-linear-snapshot-serialization
Sep 30, 2026
Merged

KazuCocoa merged 1 commit into
masterfrom
codex/perf-linear-snapshot-serialization

Conversation

@KazuCocoa

@KazuCocoa KazuCocoa commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Serializing each child's index searches its parent's children again, creating quadratic work for wide trees. The four geometry attributes also read and convert the same frame independently.

Pass the known child index through recursive serialization and calculate the rectangle once per serialized snapshot. Preserve the root's existing index behavior. A 64-child regression checks one sibling-list read, one frame read per node, and the resulting XML indices.

Validation: all 178 native unit tests passed on an iOS 26.5 simulator using a local integration branch containing this review series. The regression tests are included in this PR. Native changes were built with Xcode 27.1.

Live WDA /source benchmark

Both WDA variants were launched as actual XCTest runners and measured through HTTP, including snapshot acquisition, attribute retrieval, XML generation, and response transfer. Only the production FBXPath.m implementation differs; #1276's streaming change and the other optimization PRs are not included.

Screen Before: median / p95 After: median / p95 Median time reduction
IntegrationApp main screen (26 XML nodes) 64.62 / 150.50 ms 63.86 / 66.04 ms 1.18%
256 sibling labels (261 XML nodes) 470.42 / 495.93 ms 468.41 / 496.80 ms 0.43%
1,024 sibling labels (1029 XML nodes) 2882.73 / 3068.14 ms 2787.37 / 2891.56 ms 3.31%

The live endpoint benefit is much smaller than the isolated serializer benchmark. The table reports observed differences, not a demonstrated improvement for every application. Small percentage differences should be treated cautiously given simulator noise and only two WDA launches per variant. These measurements do not separately time snapshot acquisition and serialization, so they cannot identify the precise remaining bottleneck.

The main-screen baseline p95 includes a cluster of slower requests in the second baseline launch. With only two launches per variant, that tail difference cannot confidently be attributed to this change. No slow samples were removed.

Method

  • Baseline: 261c08d53917b138eaf2f7d8a744d1151fe3bc8d. PR: 4fcd3ff36872410468d919e66b109b3913a5dcea.
  • Release builds, Xcode 27.1, iOS 26.5 (23F77) simulator. Same simulator and test-app binary for both variants. The status bar was fixed at 9:41, Wi-Fi, battery 100%.
  • Order: before-1 → after-1 → after-2 → before-2 (ABBA), stopping and starting WDA for each block. /status build timestamps confirmed the intended variant was running.
  • Per block and screen: create a fresh session, three warmups, then 15 sequential measured requests using the same HTTP connection. Thus 30 measured requests per version per screen, 180 measured requests total, plus 36 warmups. No requests or outliers were discarded from the measured set.
  • Endpoint: GET /session/{sessionId}/source?format=xml, with no attributes excluded. Time starts before the HTTP request and stops after the complete response body is read. JSON parsing, XML checking, and file saving are outside the timed interval. Startup and session creation/deletion are excluded.
  • Session capabilities: bundleId=com.facebook.IntegrationApp, forceAppLaunch=true, shouldTerminateApp=true, shouldWaitForQuiescence=true. Wide fixtures use arguments=["--source-benchmark-nodes", "256"] or "1024"; the main screen uses no arguments.
  • All returned WDA settings were equal across runs. Relevant defaults: waitForIdleTimeout=10, snapshotMaxDepth=50, snapshotMaxChildren=2147483647, enforceCustomSnapshots=false. Optional hittable/native-frame/native-accessibility/min-max/custom-action page-source attributes were disabled.
  • Fixture: the repository's IntegrationApp main screen (26 XML nodes, at most 8 direct children) plus static UIKit grids of 256 / 1,024 accessible UILabels (261 / 1,029 XML nodes, at most 256 / 1,024 direct children). The labels have distinct identifiers, labels, and on-screen frames. These are real accessibility snapshots of a running app, but the wide grids are synthetic stress fixtures, not a claim about typical production screens.
  • p95 uses linear interpolation of the 30 measured values. This is a small descriptive sample, not a statistical significance claim or a physical-device result.

XML compatibility

All 216 responses (including warmups) succeeded. Within each screen, all outputs matched byte-for-byte after replacing only the application-root processId, which necessarily changes when the app is relaunched. The comparison required exactly one such attribute per response. No index, geometry, labels, ordering, whitespace, or other XML content was removed or normalized. Measured outputs were 6,174 / 68,450 / 271,858 bytes for the three screens.

This supports unchanged XML for these live fixtures and settings. It does not prove regression freedom for every app, configuration, or snapshot shape.

Per-launch medians (ms), in measurement order:

Screen before-1 after-1 after-2 before-2
menu 63.97 62.89 64.39 67.84
256 465.33 464.19 472.16 470.97
1024 2878.65 2786.01 2800.93 2883.91
Live benchmark fixture and HTTP driver

The fixture and driver were temporary experiment files, not production changes. Build separate Release WDA runners for the baseline and PR revisions, both with USE_PORT=8213 USE_IP=127.0.0.1, using build-for-testing. Use separate derived-data paths /private/tmp/wda-live-before and /private/tmp/wda-live-after; the driver consumes each generated .xctestrun using test-without-building.

Example build command (run from the checkout of the relevant revision, changing the derived-data suffix for the variant):

xcodebuild -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner \
  -configuration Release -sdk iphonesimulator \
  -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' \
  -derivedDataPath /private/tmp/wda-live-after build-for-testing \
  CODE_SIGNING_ALLOWED=NO USE_PORT=8213 USE_IP=127.0.0.1

Apply the fixture patch below to IntegrationApp and build/install that app once for use by both WDA versions. With no launch arguments it uses the unchanged main screen; the driver passes an argument to enable either wide screen.

diff --git a/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m b/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m
index 646192cd..1b69fd31 100644
--- a/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m
+++ b/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m
@@ -11,6 +11,33 @@
 @implementation SceneDelegate
 
 - (void)scene:(UIScene *)scene willConnectToSession:(UISceneSession *)session options:(UISceneConnectionOptions *)connectionOptions {
+  NSArray<NSString *> *arguments = NSProcessInfo.processInfo.arguments;
+  NSUInteger argumentIndex = [arguments indexOfObject:@"--source-benchmark-nodes"];
+  if (argumentIndex != NSNotFound && argumentIndex + 1 < arguments.count) {
+    NSUInteger count = arguments[argumentIndex + 1].integerValue;
+    UIViewController *controller = [UIViewController new];
+    controller.view.backgroundColor = UIColor.whiteColor;
+    controller.view.accessibilityIdentifier = @"SourceBenchmark";
+    CGRect bounds = self.window.bounds;
+    NSUInteger columns = count > 256 ? 32 : 16;
+    NSUInteger rows = (count + columns - 1) / columns;
+    CGFloat cellWidth = (bounds.size.width - 20) / columns;
+    CGFloat cellHeight = (bounds.size.height - 160) / rows;
+    for (NSUInteger i = 0; i < count; i++) {
+      UILabel *label = [[UILabel alloc] initWithFrame:CGRectMake(
+        10 + (i % columns) * cellWidth, 80 + (i / columns) * cellHeight,
+        cellWidth - 1, cellHeight - 1)];
+      label.text = [NSString stringWithFormat:@"%lu", (unsigned long)i];
+      label.accessibilityIdentifier = [NSString stringWithFormat:@"node_%lu", (unsigned long)i];
+      label.accessibilityLabel = [NSString stringWithFormat:@"Node %lu & value", (unsigned long)i];
+      label.font = [UIFont systemFontOfSize:8];
+      label.isAccessibilityElement = YES;
+      [controller.view addSubview:label];
+    }
+    self.window.rootViewController = controller;
+    [self.window makeKeyAndVisible];
+    return;
+  }
   // Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
   // If using a storyboard, the `window` property will automatically be set and attached to the scene.
   // This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession:` instead).

Build the IntegrationApp scheme in Release configuration and install IntegrationApp.app with xcrun simctl install SIMULATOR_UUID /path/to/IntegrationApp.app. Fix the status bar using xcrun simctl status_bar SIMULATOR_UUID override --time '9:41' --dataNetwork wifi --wifiMode active --wifiBars 3 --batteryState charged --batteryLevel 100.

Save the following as run-block.py. Replace its udid with the same simulator UUID and create /private/tmp/wda-pr-artifacts/live-source before running. Keep the simulator, test app, and build settings unchanged across all four blocks. The script verifies that no other server is using port 8213, starts the chosen WDA build, records all responses and settings, and stops that WDA instance at the end.

import argparse, hashlib, http.client, json, os, signal, statistics, subprocess, time
from pathlib import Path
import xml.etree.ElementTree as ET

parser = argparse.ArgumentParser()
parser.add_argument('variant', choices=['before', 'after'])
parser.add_argument('label')
parser.add_argument('--samples', type=int, default=15)
parser.add_argument('--warmups', type=int, default=3)
parser.add_argument('--scenes', nargs='+', default=['menu', '256', '1024'])
args = parser.parse_args()
root = Path('/private/tmp/wda-pr-artifacts/live-source')
out = root / args.label
out.mkdir(exist_ok=True)
products = Path('/private/tmp/wda-live-' + args.variant + '/Build/Products')
xctestrun = next(products.glob('*.xctestrun'))
udid = '1AC04DDF-1B44-4C8C-967A-0F35042D6BD8'
conn = None
sid = None

# A running listener would make attribution to the tested binary ambiguous.
try:
    probe = http.client.HTTPConnection('127.0.0.1', 8213, timeout=1)
    probe.request('GET', '/status')
    probe.getresponse().read()
except (ConnectionError, OSError):
    pass
else:
    raise RuntimeError('A WDA server is already listening on the benchmark port')
finally:
    probe.close()

log = (out / 'runner.log').open('w')
process = subprocess.Popen(['xcodebuild', 'test-without-building', '-xctestrun', str(xctestrun),
    '-destination', 'platform=iOS Simulator,id=' + udid,
    '-only-testing:WebDriverAgentRunner/UITestingUITests/testRunner',
    '-parallel-testing-enabled', 'NO'], stdout=log, stderr=subprocess.STDOUT, start_new_session=True)


def request(method, path, body=None):
    global conn
    if conn is None:
        conn = http.client.HTTPConnection('127.0.0.1', 8213, timeout=120)
    data = None if body is None else json.dumps(body).encode()
    start = time.perf_counter_ns()
    conn.request(method, path, body=data, headers={'Content-Type': 'application/json'})
    response = conn.getresponse()
    raw = response.read()
    elapsed = (time.perf_counter_ns() - start) / 1e6
    obj = json.loads(raw)
    if response.status != 200 or (isinstance(obj.get('value'), dict) and obj['value'].get('error')):
        raise RuntimeError((response.status, obj))
    return obj, elapsed, raw

try:
    deadline = time.monotonic() + 120
    while True:
        try:
            status, _, _ = request('GET', '/status')
            break
        except (ConnectionError, OSError, http.client.HTTPException):
            if conn:
                conn.close()
            conn = None
            if time.monotonic() > deadline or process.poll() is not None:
                raise RuntimeError('WDA did not start; inspect runner.log')
            time.sleep(0.5)
    (out / 'status.json').write_text(json.dumps(status, indent=2))
    print(args.label, 'WDA ready', flush=True)
    for scene in args.scenes:
        scene_out = out / scene
        scene_out.mkdir(exist_ok=True)
        capabilities = {'bundleId': 'com.facebook.IntegrationApp', 'shouldTerminateApp': True,
                        'forceAppLaunch': True, 'shouldWaitForQuiescence': True,
                        'arguments': [] if scene == 'menu' else ['--source-benchmark-nodes', scene]}
        creation, _, _ = request('POST', '/session', {'capabilities': {'alwaysMatch': capabilities, 'firstMatch': [{}]}})
        sid = creation.get('sessionId') or creation['value']['sessionId']
        (scene_out / 'session.json').write_text(json.dumps(creation, indent=2))
        settings, _, _ = request('GET', f'/session/{sid}/appium/settings')
        (scene_out / 'settings.json').write_text(json.dumps(settings, indent=2))
        records = []
        print(args.label, scene, 'session ready', flush=True)
        for i in range(args.warmups + args.samples):
            obj, elapsed, raw = request('GET', f'/session/{sid}/source?format=xml')
            xml = obj['value']
            if not isinstance(xml, str):
                raise RuntimeError(obj)
            tree = ET.fromstring(xml)
            nodes = list(tree.iter())
            labels = [node for node in nodes if node.get('name', '').startswith('node_')]
            if scene != 'menu' and len(labels) != int(scene):
                raise RuntimeError(f'Expected {scene} fixture labels, found {len(labels)}')
            prefix = f'{i:02d}'
            (scene_out / (prefix + '.json')).write_bytes(raw)
            (scene_out / (prefix + '.xml')).write_text(xml)
            record = dict(iteration=i, warmup=i < args.warmups, elapsed_ms=elapsed,
                          xml_bytes=len(xml.encode()), nodes=len(nodes),
                          max_children=max(len(n) for n in nodes),
                          sha256=hashlib.sha256(xml.encode()).hexdigest())
            records.append(record)
            (scene_out / 'samples.json').write_text(json.dumps(records, indent=2))
            if i == 0 or i == args.warmups or i == args.warmups + args.samples - 1:
                print(args.label, scene, 'iteration', i, f'{elapsed:.2f} ms',
                      'nodes', len(nodes), 'max children', record['max_children'], flush=True)
        times = [r['elapsed_ms'] for r in records if not r['warmup']]
        print(args.label, scene, f'median={statistics.median(times):.3f} ms', flush=True)
        request('DELETE', f'/session/{sid}')
        sid = None
finally:
    if sid:
        try:
            request('DELETE', f'/session/{sid}')
        except Exception:
            pass
    if conn:
        conn.close()
    try:
        shutdown = http.client.HTTPConnection('127.0.0.1', 8213, timeout=5)
        shutdown.request('GET', '/wda/shutdown')
        shutdown.getresponse().read()
        shutdown.close()
    except Exception:
        pass
    try:
        process.wait(timeout=15)
    except subprocess.TimeoutExpired:
        os.killpg(process.pid, signal.SIGTERM)
        process.wait(timeout=15)
    log.close()

Run sequentially:

python3 run-block.py before before-1
python3 run-block.py after after-1
python3 run-block.py after after-2
python3 run-block.py before before-2

Pool only entries with warmup=false from samples.json for each variant/screen to compute the median and p95. All original XML files are preserved; comparison normalizes exactly one application processId attribute and requires the rest of the bytes to match. The app process ID is the only excluded field. Raw measurements, settings, XML responses, and binary hashes were saved with the experiment artifacts.

Performance experiment using the actual WDA serializer

Compared FBXPath.m from the review base (261c08d53917b138eaf2f7d8a744d1151fe3bc8d) with this PR (4fcd3ff3). The benchmark uses the same checkout, test harness, build configuration, and simulator, replacing only FBXPath.m between runs. No other snapshot optimizations from this review series are included, particularly the XML streaming change in #1276.

The synthetic snapshot has one root and 64, 256, or 1,024 direct children. Each child points back to the root, and every node has frame (1, 2, 3, 4). The query requests index, x, y, width, and height. The harness calls the real xmlRepresentationWithRootElement:... implementation, writes a libxml2 document, dumps it, converts the result to an NSString, and frees the native resources. Timed iterations also drain their autorelease pools. Fixture construction and correctness checks are outside the timed region.

Measurement: Release configuration, Xcode 27.1, iOS 26.5 arm64 simulator on macOS 26.6.2. Three warmups followed by 15 measured iterations per size and version, using CLOCK_MONOTONIC. This is one local comparison, with the PR version measured first and the baseline second; reported values are per-version medians, not an interleaved device benchmark. Read counters are enabled only for a separate counting pass and disabled during timing.

Direct children Before, median After, median Speedup Time reduction
64 3.988 ms 1.721 ms 2.32× 56.8%
256 15.911 ms 4.518 ms 3.52× 71.6%
1,024 155.595 ms 14.063 ms 11.06× 91.0%

The separate counting pass explains the improvement:

Direct children Parent's children getter calls, before → after Total frame getter calls, before → after
64 4,161 → 1 260 → 65
256 65,793 → 1 1,028 → 257
1,024 1,049,601 → 1 4,100 → 1,025

Previously, every child's wdIndex loop repeatedly accessed its parent's child list in both the loop condition and body. Across n children, this fixture produces n(n+1) child-list accesses, plus one for the serializer's traversal. Passing the traversal index removes those repeated searches. Reusing wdRect also reduces the four frame reads per node to one; the frame counts include the root.

Output verification and scope

Both benchmark runs passed. Every child was checked for the expected index and all four geometry values. The complete serialized XML was captured from both runs and compared byte-for-byte: all three outputs were identical (6,341 / 25,277 / 101,357 bytes).

This measures serialization of already-available synthetic snapshots with the five affected attributes. It excludes real XCTest/AX snapshot acquisition, idle waits, HTTP transport, and other attribute retrieval. The ~11.06× result at 1,024 siblings is not a claim about the full /source endpoint or typical application screens. Wide sibling lists benefit most; trees with few siblings, or queries excluding index/geometry, should benefit less. The two optimizations are measured together, not separately.

Reproduce the experiment

In an isolated checkout of PR revision 4fcd3ff3, save the patch below as /tmp/1283-benchmark.patch and apply it with git apply /tmp/1283-benchmark.patch. It adds an optional benchmark method to the existing test file. The original 64-child regression remains usable. This patch was used only for the experiment; it is not part of the production change.

diff --git a/WebDriverAgentTests/UnitTests/FBXPathTests.m b/WebDriverAgentTests/UnitTests/FBXPathTests.m
index d4836a63..41b70bc5 100644
--- a/WebDriverAgentTests/UnitTests/FBXPathTests.m
+++ b/WebDriverAgentTests/UnitTests/FBXPathTests.m
@@ -7,6 +7,7 @@
  */
 
 #import <XCTest/XCTest.h>
+#import <time.h>
 
 #import "FBMacros.h"
 #import "FBXPath.h"
@@ -19,13 +20,14 @@
 @interface FBTraversalSnapshot : XCElementSnapshotDouble
 @property (nonatomic, copy) NSArray *childSnapshots;
 @property (nonatomic, weak) FBTraversalSnapshot *parentSnapshot;
+@property (nonatomic) BOOL measureReads;
 @property (nonatomic) NSUInteger childrenReads;
 @property (nonatomic) NSUInteger frameReads;
 @end
 @implementation FBTraversalSnapshot
-- (NSArray *)children { self.childrenReads++; return self.childSnapshots ?: @[]; }
+- (NSArray *)children { if (self.measureReads) { self.childrenReads++; } return self.childSnapshots ?: @[]; }
 - (id)parent { return self.parentSnapshot; }
-- (CGRect)frame { self.frameReads++; return CGRectMake(1, 2, 3, 4); }
+- (CGRect)frame { if (self.measureReads) { self.frameReads++; } return CGRectMake(1, 2, 3, 4); }
 @end
 
 @interface FBXPathTests : XCTestCase
@@ -33,6 +35,97 @@ @interface FBXPathTests : XCTestCase
 
 @implementation FBXPathTests
 
+- (NSString *)benchmarkXmlStringWithElement:(id<FBXCElementSnapshot>)snapshot
+                        xpathQuery:(nullable NSString *)query
+               excludingAttributes:(nullable NSArray<NSString *> *)excludedAttributes
+{
+  xmlDocPtr doc;
+  
+  xmlTextWriterPtr writer = xmlNewTextWriterDoc(&doc, 0);
+  NSMutableDictionary *elementStore = [NSMutableDictionary dictionary];
+  int buffersize;
+  xmlChar *xmlbuff = NULL;
+  int rc = xmlTextWriterStartDocument(writer, NULL, "UTF-8", NULL);
+  if (rc >= 0) {
+    rc = [FBXPath xmlRepresentationWithRootElement:snapshot
+                                            writer:writer
+                                      elementStore:elementStore
+                                             query:query
+                               excludingAttributes:excludedAttributes];
+    if (rc >= 0) {
+      rc = xmlTextWriterEndDocument(writer);
+    }
+  }
+  if (rc >= 0) {
+    xmlDocDumpFormatMemory(doc, &xmlbuff, &buffersize, 1);
+  }
+  xmlFreeTextWriter(writer);
+  xmlFreeDoc(doc);
+  
+  XCTAssertTrue(rc >= 0);
+
+  NSString *result = [NSString stringWithCString:(const char *)xmlbuff encoding:NSUTF8StringEncoding];
+  xmlFree(xmlbuff);
+  return result;
+}
+
+- (void)testSerializationBenchmark
+{
+  NSString *query = @"//*[@index >= 0 and @x=1 and @y=2 and @width=3 and @height=4]";
+  for (NSNumber *size in @[@64, @256, @1024]) {
+    FBTraversalSnapshot *root = [FBTraversalSnapshot new];
+    NSMutableArray<FBTraversalSnapshot *> *children = [NSMutableArray array];
+    for (NSUInteger i = 0; i < size.unsignedIntegerValue; i++) {
+      FBTraversalSnapshot *child = [FBTraversalSnapshot new];
+      child.parentSnapshot = root;
+      [children addObject:child];
+    }
+    root.childSnapshots = children;
+    NSArray<FBTraversalSnapshot *> *nodes = [@[root] arrayByAddingObjectsFromArray:children];
+    for (FBTraversalSnapshot *node in nodes) { node.measureReads = YES; }
+    NSString *xml = [self benchmarkXmlStringWithElement:(id)root xpathQuery:query excludingAttributes:nil];
+    NSUInteger frameReads = 0;
+    for (FBTraversalSnapshot *node in nodes) {
+      frameReads += node.frameReads;
+      node.measureReads = NO;
+    }
+    printf("LINEAR_COUNTS children=%lu parentChildrenReads=%lu totalFrameReads=%lu\n",
+           size.unsignedLongValue, (unsigned long)root.childrenReads, (unsigned long)frameReads);
+    printf("LINEAR_XML children=%lu base64=%s\n", size.unsignedLongValue,
+           [[xml dataUsingEncoding:NSUTF8StringEncoding] base64EncodedStringWithOptions:0].UTF8String);
+    // Validate every child's index and all geometry attributes outside the timed loop.
+    xmlDocPtr doc = [self documentForSnapshot:root query:query];
+    @try {
+      for (NSUInteger i = 0; i < children.count; i++) {
+        NSString *check = [NSString stringWithFormat:
+          @"count(/*/*[@index=%lu and @x=1 and @y=2 and @width=3 and @height=4])",
+          (unsigned long)i];
+        XCTAssertEqualObjects([self xpathStringResultForQuery:check document:doc], @"1");
+      }
+    } @finally { xmlFreeDoc(doc); }
+    for (NSUInteger i = 0; i < 3; i++) {
+      @autoreleasepool {
+        [self benchmarkXmlStringWithElement:(id)root xpathQuery:query excludingAttributes:nil];
+      }
+    }
+    NSMutableArray<NSNumber *> *times = [NSMutableArray array];
+    for (NSUInteger i = 0; i < 15; i++) {
+      struct timespec start, end;
+      clock_gettime(CLOCK_MONOTONIC, &start);
+      @autoreleasepool {
+        [self benchmarkXmlStringWithElement:(id)root xpathQuery:query excludingAttributes:nil];
+      }
+      clock_gettime(CLOCK_MONOTONIC, &end);
+      [times addObject:@((end.tv_sec - start.tv_sec) * 1000.0
+                        + (end.tv_nsec - start.tv_nsec) / 1e6)];
+    }
+    [times sortUsingSelector:@selector(compare:)];
+    printf("LINEAR_TIME children=%lu medianMs=%.3f runs=15 warmups=3\n",
+           size.unsignedLongValue, times[7].doubleValue);
+  }
+}
+
+
 - (void)testSerializationVisitsSiblingListAndFrameOnce
 {
   FBTraversalSnapshot *root = [FBTraversalSnapshot new];
@@ -43,6 +136,8 @@ - (void)testSerializationVisitsSiblingListAndFrameOnce
     [children addObject:child];
   }
   root.childSnapshots = children;
+  root.measureReads = YES;
+  for (FBTraversalSnapshot *child in children) { child.measureReads = YES; }
   NSString *query = @"//*[@index >= 0 and @x=1 and @y=2 and @width=3 and @height=4]";
   xmlDocPtr doc = [self documentForSnapshot:root query:query];
   @try {

Run the benchmark with an available iOS simulator UUID substituted for SIMULATOR_UUID:

xcodebuild -project WebDriverAgent.xcodeproj \
  -scheme WebDriverAgentLib -configuration Release \
  -destination 'platform=iOS Simulator,id=SIMULATOR_UUID' \
  -derivedDataPath /tmp/wda-1283-derived \
  test -only-testing:UnitTests/FBXPathTests/testSerializationBenchmark \
  CODE_SIGNING_ALLOWED=NO > /tmp/1283-after.log 2>&1

Replace only the serializer with the baseline version, then repeat the same command with the log redirected to /tmp/1283-before.log:

git show 261c08d53917b138eaf2f7d8a744d1151fe3bc8d:WebDriverAgentLib/Utilities/FBXPath.m \
  > WebDriverAgentLib/Utilities/FBXPath.m

The logs contain LINEAR_COUNTS, LINEAR_TIME, and base64-encoded LINEAR_XML records. Confirm both tests succeeded, inspect the counts/times, and compare the exact output bytes:

import base64
import re
from pathlib import Path

def read_xml(path):
    log = Path(path).read_text()
    assert "** TEST SUCCEEDED **" in log
    return {
        int(n): base64.b64decode(data)
        for n, data in re.findall(r"LINEAR_XML children=(\d+) base64=(\S+)", log)
    }

before = read_xml("/tmp/1283-before.log")
after = read_xml("/tmp/1283-after.log")
assert set(before) == set(after) == {64, 256, 1024}
for n in sorted(before):
    assert before[n] == after[n]
    print(n, "children: identical XML,", len(before[n]), "bytes")

@KazuCocoa

Copy link
Copy Markdown
Member Author

Maybe this is not so bad

@KazuCocoa
KazuCocoa marked this pull request as ready for review September 29, 2026 06:10
@KazuCocoa
KazuCocoa merged commit f352660 into master Sep 30, 2026
58 of 62 checks passed
@KazuCocoa
KazuCocoa deleted the codex/perf-linear-snapshot-serialization branch September 30, 2026 02:34
github-actions Bot pushed a commit that referenced this pull request Sep 30, 2026
## [16.13.1](v16.13.0...v16.13.1) (2026-09-30)

### Performance Improvements

* reuse sibling indices and geometry during XML serialization ([#1283](#1283)) ([f352660](f352660))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 16.13.1 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants