Skip to content

perf: use native predicates for accessibility id lookups - #1286

Merged
KazuCocoa merged 3 commits into
masterfrom
codex/perf-native-accessibility-id
Sep 30, 2026
Merged

KazuCocoa merged 3 commits into
masterfrom
codex/perf-native-accessibility-id

Conversation

@KazuCocoa

@KazuCocoa KazuCocoa commented Sep 29, 2026 •

Copy link
Copy Markdown
Member

Accessibility-ID searches currently use an Objective-C block that calls wdNameWithSnapshot:. Express the same matching rule as a structured XCTest predicate on identifier and label. The existing fb_snapshotBlockPredicateWithPredicate: helper returns an incoming block predicate unchanged, so this change removes block-based matching; it does not eliminate an additional snapshot-wrapper allocation from this accessibility-ID path.

A nonempty identifier still takes precedence over the label. Label fallback applies only when the identifier is absent or empty; an empty name remains unmatchable. Preserve first-match extraction, result ordering, and matching the scope element itself. The WDA id and name aliases use this same implementation. Predicate-string, class-chain, class-name, and XPath searches are outside this change.

Live WDA measurements

findElements workload Before median After median Latency reduction
100 labels, 100 matching IDs 2,613.78 ms 1,966.22 ms 24.78%
1,024 labels, 1 matching ID 370.08 ms 256.87 ms 30.59%
1,024 labels, no matching IDs 178.76 ms 128.54 ms 28.10%

Measured the complete HTTP response for POST /session/{sid}/elements with {"using":"accessibility id","value":"something"} against running WDA. This includes lookup and element serialization. These are not predicate-only microbenchmarks.

  • Xcode 27.1 (27A9269), iOS 26.5 (23F77) simulator, Release builds, identical UIKit fixture binary.
  • Baseline: 261c08d53917b138eaf2f7d8a744d1151fe3bc8d. After: that revision plus exactly the production XCUIElement+FBFind.m change in this PR. No other performance PRs were included.
  • PR based on c9366a227b6de19381c96d56a548e9fb53d77dba; the intervening screenshot change does not change this lookup implementation. Repository regression tests run on the PR version.
  • Before → after → after → before, fresh WDA launch per block and fresh app session per case. Each case/block has one warmup and five measured requests: 10 samples per variant/case, 60 timed requests total. No timed samples discarded.
  • Compact responses, native caching enabled, default accessibility-element binding. Parsing, assertions, and artifact writes occur outside the timed interval.
Workload Before 1 After 1 After 2 Before 2
100 matches 2,476.02 ms 1,966.28 ms 1,966.15 ms 2,735.89 ms
1 match 348.65 ms 256.66 ms 257.08 ms 380.91 ms
No matches 176.19 ms 127.74 ms 130.33 ms 181.74 ms

The second baseline block was slower, but both after blocks were faster than both baseline blocks in each case. This is a small simulator experiment, not a universal speedup guarantee. It demonstrates end-to-end latency improvement, not where XCTest executes the predicate or how many AX IPC calls it makes. Other locator strategies and the id/name aliases were not separately benchmarked.

Correctness and regression coverage

All 60 timed requests and 12 warmups returned the expected counts and stable ordered identities within each session. Another 24 expanded-response checks compared type, label, rect, attribute/name, attribute/index, and enabled: identical within sessions including IDs, and identical across versions after removing only the two element-ID fields. Settings and capabilities matched.

A separate before/after live HTTP fixture passed identifier-over-label precedence, nil/empty identifier fallback, empty/missing queries, quotes/Unicode, first-match order, parent-scoped lookup, matching the parent itself, and a button-driven identifier mutation (four old-name matches become three, with one new-name match and stable remaining IDs). Saved semantic outputs matched after removing element IDs.

Validation: 8 targeted integration tests passed, 0 failures, on the PR checkout (Xcode 27.1 / iOS 26.5 simulator). git diff --check also passed.

The dedicated testEmptyIdentifierDoesNotMatchEmptyAccessibilityId regression test documents why the identifier branch must reject empty strings. It explicitly checks fixtures with an empty identifier and either a nonempty or empty label, then verifies empty searches return no matches for both single/all-result requests and both useFirstMatch settings. All 5 identifier-semantics integration tests passed after adding this coverage. As a mutation check, removing only identifier != '' while retaining identifier != nil made the dedicated test fail: 10 matches instead of 0. Restoring the guard made it pass again. The test comments explain the existing wdName fallback and empty-to-nil behavior. This validation ran on Xcode 27.1 / iOS 26.5 simulator.

The PR adds a small opt-in IntegrationApp scene and focused regression tests for those matching rules, scoped lookup under both element-binding modes, and identifier changes. Existing identifier and stable-instance tests are also exercised. Supported older XCTest versions, physical devices, WebViews, and custom accessibility implementations have not been exhaustively tested.

Memory follow-up results

Overall lifetime peaks overlap in the ABBA series (before: 78.44–79.22 MiB; after: 78.52–78.58 MiB). However, the focused 60-request follow-up ended at 78.13 MiB after versus 75.17 MiB before, a 2.95 MiB / 3.93% higher idle footprint in that pair. Thus these measurements do not establish memory neutrality. There is no clear pattern of continuous growth proportional to requests in the sampled traces, but the sample size and duration cannot rule out leaks or a small memory regression. The observed timing of footprint rises varies, including a rise during the optimized follow-up's post-request idle window; this is why its post-idle footprint exceeds its sampled request-only peak. Allocator/runtime behavior and the specific allocations responsible were not profiled, so the difference is not attributed to removal of the autorelease pool alone.

Total: 1,040 measured searches plus 70 warmups, six fresh WDA launches. The 920-request ABBA series and the 120-request focused follow-up are reported separately below. All element-count/order/identity assertions passed. Host: Apple M5 Pro, 24 GiB RAM, macOS 26.6.2. These are WDA runner process measurements on a simulator, not whole-device memory measurements.

Memory measurements and limitations

Memory regression check

Measured the same Release binary pair and identical grid fixture as the latency experiment, with the production diff verified identical to PR commit 86ec5147. Xcode 27.1 / iOS 26.5 simulator. Fresh WDA launches in before → after → after → before order. Each launch uses fresh sessions for all three cases, with five warmups followed by 30 requests for 100 matches, and 100 requests each for one/no match. 920 measured requests plus 60 warmups, all with expected counts and stable ordered IDs. Settings/capabilities were equal across runs.

An external sampler records WDA runner physical footprint and RSS with proc_pid_rusage(RUSAGE_INFO_V4), targeting a 20 ms interval. Idle values are medians over the last two seconds of a five-second wait. Growth means idle after repeated searches minus idle after warmup, with the same session still alive. Negative values indicate reclamation/accounting changes, not negative allocation.

Physical footprint, MiB. Each range spans the two independent launches of that variant. Request peaks are sampled maxima per launch.

Workload Before request peak After request peak Before idle growth After idle growth
100 nodes / 100 matches 65.33 to 76.10 75.88 to 76.53 -0.11 to +1.91 +10.03 to +10.45
1,024 nodes / 1 match 77.06 to 77.86 76.89 to 76.96 -2.25 to -0.52 -2.88 to -2.66
1,024 nodes / no matches 73.99 to 74.69 73.14 to 73.30 -0.09 to -0.02 -0.33 to -0.11

Per-launch idle footprint (warmup → repeated searches → session deletion), in MiB:

Launch Workload Warm Post-search Post-delete
before-1 100 nodes / 100 matches 73.36 75.27 75.60
before-1 1,024 nodes / 1 match 76.58 76.06 73.42
before-1 1,024 nodes / no matches 74.08 74.06 73.86
after-1 100 nodes / 100 matches 65.05 75.50 76.06
after-1 1,024 nodes / 1 match 76.78 73.91 73.49
after-1 1,024 nodes / no matches 72.99 72.88 72.46
after-2 100 nodes / 100 matches 65.00 75.03 76.17
after-2 1,024 nodes / 1 match 76.69 74.03 73.60
after-2 1,024 nodes / no matches 73.08 72.75 72.55
before-2 100 nodes / 100 matches 65.02 64.91 75.36
before-2 1,024 nodes / 1 match 77.38 75.13 73.58
before-2 1,024 nodes / no matches 73.53 73.44 73.60

Kernel-maintained lifetime peak footprint, including WDA startup and all three cases:

Launch Lifetime peak MiB Sample interval median / max ms
before-1 78.44 24.36 / 28.91
after-1 78.52 24.63 / 32.09
after-2 78.58 24.69 / 26.96
before-2 79.22 24.40 / 28.56

RSS cross-check (sampled request peaks, MiB):

Workload Before After
100 nodes / 100 matches 316.00 to 337.80 338.97 to 339.80
1,024 nodes / 1 match 290.27 to 316.61 316.92 to 320.44
1,024 nodes / no matches 284.64 to 316.59 316.45 to 318.39

Scope: sampled per-case peaks can miss brief spikes; the kernel lifetime high-water mark is reported separately and cannot be attributed to a single request. RSS includes shared pages, so physical footprint is the primary comparison. This measures the WDA runner, not aggregate memory across the app, accessibility services, and simulator. Two launches per variant and these fixed-size scenes cannot establish absence of leaks in every workload or during hours-long sessions. A clock-calibration pilot was interrupted and excluded before the final series; no completed measured requests or final-series memory samples were discarded.

Focused follow-up: 60 repeated 100-match searches

The original ABBA runs had a low-footprint baseline launch (~65 MiB after 30 requests), while other launches reached ~75 MiB. This prompted an additional fresh before/after pair, restricted to 100 nodes / 100 matches, with five warmups and 60 measured searches per variant. These 120 requests and 10 warmups are reported separately; the original results were not discarded.

Variant Warm idle Post-search idle Idle growth Request peak Kernel lifetime peak
before-followup 64.97 MiB 75.17 MiB +10.20 MiB 76.08 MiB 76.10 MiB
after-followup 64.80 MiB 78.13 MiB +13.33 MiB 69.47 MiB 78.35 MiB

Physical footprint during each consecutive group of six requests (median MiB):

Variant 1–6 7–12 13–18 19–24 25–30 31–36 37–42 43–48 49–54 55–60
before-followup 64.91 64.77 64.92 64.88 64.91 67.16 73.89 75.81 75.62 75.33
after-followup 65.00 65.50 66.08 66.25 66.25 66.24 66.21 66.10 69.31 69.19

The old implementation also exhibits the approximately 10 MiB rise, so that observation alone does not establish a regression caused by removing the per-evaluation autorelease pool. The experiment does not identify the allocations responsible for the rise.

Reproduce the memory measurements

Memory regression experiment for WDA PR #1286

The source change is byte-for-byte identical to the production diff in PR commit 86ec514. This experiment reuses the earlier Release latency benchmark pair: baseline 261c08d, and that baseline plus the locator optimization only. See provenance.json for hashes. The PR's later unrelated master change and regression-test app scene are not part of these benchmark binaries. Both variants use the identical installed grid fixture from ../fixture.patch.

Run order is before-1, after-1, after-2, before-2. Each block launches a fresh WDA runner and creates a fresh session for each of three cases, in the same order:

  • 100 nodes / 100 matches: 5 warmups then 30 measured repeated requests.
  • 1,024 nodes / 1 match: 5 warmups then 100 measured repeated requests.
  • 1,024 nodes / 0 matches: 5 warmups then 100 measured repeated requests.

Each case pauses 5 seconds after warmup, 5 seconds after measured requests, and 5 seconds after deleting its session. Requests are sequential, with no intentional pacing within each measured phase. Compact responses, native caching, and accessibility binding are held constant. Every request asserts the expected element count and stable ordered element IDs. The complete series has 920 measured requests plus 60 warmups.

sample-memory.c queries macOS proc_pid_rusage(RUSAGE_INFO_V4) for the WDA runner process selected by its listening port. It records resident size (RSS), physical footprint, and the kernel-maintained lifetime peak physical footprint roughly every 20 ms. The sampler is an external process, not instrumentation injected into WDA. mach_absolute_time and Python monotonic_ns share the same clock. An interrupted clock-calibration pilot is preserved in clock-calibration-discarded and excluded from all results.

Per-case peaks are maxima over sampled request execution. They can miss spikes shorter than the sampling interval. The kernel lifetime maximum captures the entire process lifetime, including startup and prior cases; it is reported per block, not misattributed to an individual case. Idle values are medians over the final two seconds of each five-second wait. Post-search growth is post-search idle minus post-warmup idle, while the same session remains alive. Negative deltas can result from allocator/runtime reclamation and memory accounting changes. RSS includes shared resident pages, whereas physical footprint is the main comparison metric. These results cover the WDA runner, not aggregate simulator/device memory or accessibility services.

Reproduction

Use the build and fixture instructions in PR #1286. Update simulator UUID and derived-data paths in run-memory.py. Compile the external host sampler:

clang -O2 -Wall sample-memory.c -o sample-memory
python3 run-series.py

Output directories must not already exist. summarize-memory.py writes summary.json from raw memory.csv, request records, and phase timestamps. No memory samples or completed measured requests are removed as outliers.

Focused follow-up

The original ABBA runs showed a roughly 10 MiB difference in the early 100-match phase of one baseline launch. To investigate rather than discard that observation, run-followup.py repeats only the 100-node/100-match case with a fresh runner per variant, five warmups, and 60 measured requests (before then after). This is a separate follow-up, not pooled into the original 30-request ABBA results. It adds 120 measured requests and 10 warmups. All original results are retained.

python3 run-followup.py

sample-memory.c

#include <libproc.h>
#include <mach/mach_time.h>
#include <sys/resource.h>
#include <time.h>
#include <stdint.h>
#include <stdlib.h>
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
static volatile sig_atomic_t running = 1;
static void stop(int sig) { running = 0; }
int main(int argc, char **argv) {
  if (argc != 2) return 2;
  int pid = atoi(argv[1]);
  signal(SIGTERM, stop); signal(SIGINT, stop);
  mach_timebase_info_data_t tb; mach_timebase_info(&tb);
  puts("time_ns,resident_bytes,footprint_bytes,lifetime_peak_footprint_bytes");
  while (running) {
    struct rusage_info_v4 info = {0};
    if (proc_pid_rusage(pid, RUSAGE_INFO_V4, (rusage_info_t *)&info)) { perror("proc_pid_rusage"); return 1; }
    uint64_t ns = (uint64_t)((__uint128_t)mach_absolute_time() * tb.numer / tb.denom);
    printf("%llu,%llu,%llu,%llu\n", (unsigned long long)ns,
      (unsigned long long)info.ri_resident_size, (unsigned long long)info.ri_phys_footprint,
      (unsigned long long)info.ri_lifetime_max_phys_footprint);
    fflush(stdout); usleep(20000);
  }
  return 0;
}

run-memory.py

import argparse, csv, hashlib, http.client, json, os, signal, subprocess, time
from pathlib import Path
p=argparse.ArgumentParser(); p.add_argument('variant', choices=['before','after']); p.add_argument('label'); p.add_argument('--case', choices=['all100','one1024','none1024']); p.add_argument('--repeats',type=int); args=p.parse_args()
root=Path(__file__).resolve().parent; out=root/args.label; out.mkdir()
products=Path('/private/tmp/wda-live-before' if args.variant=='before' else '/private/tmp/wda-native-id-after')/'Build/Products'
xctestrun=next(products.glob('*.xctestrun')); udid='1AC04DDF-1B44-4C8C-967A-0F35042D6BD8'
conn=None; sid=None; monitor=None; events=[]
def event(name, **extra):
    e=dict(time_ns=time.monotonic_ns(),name=name,**extra); events.append(e)
    (out/'events.json').write_text(json.dumps(events,indent=2)); return e['time_ns']
def request(method,path,body=None):
    global conn
    if conn is None: conn=http.client.HTTPConnection('127.0.0.1',8213,timeout=120)
    start=time.monotonic_ns(); conn.request(method,path,body=None if body is None else json.dumps(body).encode(),headers={'Content-Type':'application/json'})
    response=conn.getresponse(); raw=response.read(); end=time.monotonic_ns(); obj=json.loads(raw)
    assert response.status==200 and not (isinstance(obj.get('value'),dict) and obj['value'].get('error')),obj
    return obj,start,end
probe=http.client.HTTPConnection('127.0.0.1',8213,timeout=1)
try:
    probe.request('GET','/status'); probe.getresponse().read()
except OSError: pass
else: raise RuntimeError('Benchmark port already occupied')
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)
try:
    deadline=time.monotonic()+120
    while True:
        try: status,_,_=request('GET','/status'); break
        except (OSError,http.client.HTTPException):
            if conn: conn.close()
            conn=None
            if time.monotonic()>deadline or process.poll() is not None: raise RuntimeError('WDA start failed')
            time.sleep(.5)
    pids=subprocess.check_output(['/usr/sbin/lsof','-nP','-iTCP:8213','-sTCP:LISTEN','-t'],text=True).split()
    assert len(set(pids))==1,pids
    pid=int(pids[0]); command=subprocess.check_output(['ps','-p',str(pid),'-o','command='],text=True).strip()
    assert 'WebDriverAgentRunner-Runner' in command,command
    exe=products/'Release-iphonesimulator/WebDriverAgentRunner-Runner.app/PlugIns/WebDriverAgentRunner.xctest/WebDriverAgentRunner'
    lib=products/'Release-iphonesimulator/WebDriverAgentRunner-Runner.app/Frameworks/WebDriverAgentLib.framework/WebDriverAgentLib'
    # WDA library can be inside the xctest's Frameworks directory depending on Xcode.
    metadata=dict(variant=args.variant,pid=pid,command=command,xctestrun=str(xctestrun),status=status,interval_ms=20)
    if exe.exists(): metadata['runner_sha256']=hashlib.sha256(exe.read_bytes()).hexdigest()
    (out/'metadata.json').write_text(json.dumps(metadata,indent=2))
    sample_file=(out/'memory.csv').open('w')
    monitor=subprocess.Popen([str(root/'sample-memory'),str(pid)],stdout=sample_file,stderr=(out/'sampler.log').open('w'))
    event('runner_idle_start'); time.sleep(3); event('runner_idle_end')
    for case,nodes,matches,repeats in [('all100',100,100,30),('one1024',1024,1,100),('none1024',1024,0,100)]:
        if args.case and case != args.case: continue
        if args.repeats: repeats=args.repeats
        caps={'bundleId':'com.facebook.IntegrationApp','shouldTerminateApp':True,'forceAppLaunch':True,'shouldWaitForQuiescence':True,'useNativeCachingStrategy':True,'arguments':['--source-benchmark-nodes',str(nodes),'--identifier-match-count',str(matches)]}
        event('session_start',case=case)
        obj,_,_=request('POST','/session',{'capabilities':{'alwaysMatch':caps,'firstMatch':[{}]}}); sid=obj.get('sessionId') or obj['value']['sessionId']
        request('POST',f'/session/{sid}/appium/settings',{'settings':{'boundElementsByIndex':False,'shouldUseCompactResponses':True}})
        settings,_,_=request('GET',f'/session/{sid}/appium/settings')
        (out/(case+'-settings.json')).write_text(json.dumps(settings,indent=2)); (out/(case+'-caps.json')).write_text(json.dumps(caps,indent=2))
        expected=None; records=[]
        for warmup in [True,False]:
            event('warmup_start' if warmup else 'measured_start',case=case)
            for i in range(5 if warmup else repeats):
                result,start,end=request('POST',f'/session/{sid}/elements',{'using':'accessibility id','value':'something'})
                ids=[e['element-6066-11e4-a52e-4f735466cecf'] for e in result['value']]
                assert len(ids)==matches and len(set(ids))==matches,(case,len(ids))
                if expected is None: expected=ids
                assert ids==expected,(case,'identities/order changed')
                records.append(dict(iteration=i,warmup=warmup,start_ns=start,end_ns=end,elapsed_ms=(end-start)/1e6,count=len(ids)))
                if i%10==0: print(args.label,case,'warmup' if warmup else 'measured',i,flush=True)
                assert monitor.poll() is None,'Sampler stopped'
            event('warmup_idle_start' if warmup else 'post_idle_start',case=case)
            time.sleep(5)
            event('warmup_idle_end' if warmup else 'post_idle_end',case=case)
        (out/(case+'-requests.json')).write_text(json.dumps(records,indent=2))
        request('DELETE',f'/session/{sid}'); sid=None
        event('deleted_idle_start',case=case); time.sleep(5); event('deleted_idle_end',case=case)
        print(args.label,case,'complete',flush=True)
    event('complete')
finally:
    if monitor:
        monitor.terminate(); monitor.wait(timeout=5); sample_file.close()
    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=20)
    except subprocess.TimeoutExpired:
        os.killpg(process.pid,signal.SIGTERM); process.wait(timeout=20)
    log.close()

run-series.py

import subprocess,sys
from pathlib import Path
r=Path(__file__).resolve().parent
for variant,label in [('before','before-1'),('after','after-1'),('after','after-2'),('before','before-2')]:
 subprocess.run([sys.executable,str(r/'run-memory.py'),variant,label],check=True)
 subprocess.run([sys.executable,str(r/'summarize-memory.py')],check=True)

run-followup.py

import subprocess,sys
from pathlib import Path
r=Path(__file__).resolve().parent
for variant in ['before','after']:
 subprocess.run([sys.executable,str(r/'run-memory.py'),variant,variant+'-followup','--case','all100','--repeats','60'],check=True)
 subprocess.run([sys.executable,str(r/'summarize-memory.py')],check=True)

summarize-memory.py

import csv,json,statistics
from pathlib import Path
r=Path(__file__).resolve().parent; result={}; mib=1024**2
for block in ['before-1','after-1','after-2','before-2','before-followup','after-followup']:
 d=r/block
 if not (d/'memory.csv').exists(): continue
 rows=[{k:int(v) for k,v in x.items()} for x in csv.DictReader((d/'memory.csv').open())]
 events=json.loads((d/'events.json').read_text())
 def phase(case,name):
  e=[x['time_ns'] for x in events if x.get('case')==case and x['name']==name];return e[-1] if e else None
 def get(start,end): return [x for x in rows if start<=x['time_ns']<=end]
 def value(xs,k,method=statistics.median): return method(x[k] for x in xs)/mib
 cases={}
 for case in ['all100','one1024','none1024']:
  end=phase(case,'post_idle_end')
  if end is None or not (d/(case+'-requests.json')).exists():continue
  warmend=phase(case,'warmup_idle_end'); begin=phase(case,'measured_start'); idle=phase(case,'post_idle_start'); deleted=phase(case,'deleted_idle_end')
  warm=get(warmend-2_000_000_000,warmend); post=get(end-2_000_000_000,end); active=get(begin,idle)
  records=json.loads((d/(case+'-requests.json')).read_text()); measured=[x for x in records if not x['warmup']]
  c={'requests':len(measured),'median_ms':statistics.median(x['elapsed_ms'] for x in measured)}
  for key,short in [('footprint_bytes','footprint'),('resident_bytes','rss')]:
   c[short+'_warm_mib']=value(warm,key); c[short+'_post_mib']=value(post,key);c[short+'_growth_mib']=c[short+'_post_mib']-c[short+'_warm_mib'];c[short+'_peak_mib']=value(active,key,max)
   if deleted:c[short+'_deleted_mib']=value(get(deleted-2_000_000_000,deleted),key)
  # Divide continuous request sampling into ten equal request-count groups.
  c['footprint_decile_medians_mib']=[]
  for i in range(10):
   chunk=measured[i*len(measured)//10:(i+1)*len(measured)//10]
   c['footprint_decile_medians_mib'].append(value(get(chunk[0]['start_ns'],chunk[-1]['end_ns']),'footprint_bytes'))
  cases[case]=c
 result[block]={'complete':any(x['name']=='complete' for x in events),'sample_count':len(rows),'sampled_peak_footprint_mib':value(rows,'footprint_bytes',max),'kernel_lifetime_peak_footprint_mib':value(rows,'lifetime_peak_footprint_bytes',max),'cases':cases}
(r/'summary.json').write_text(json.dumps(result,indent=2))
for block,b in result.items():
 print(block,'complete',b['complete'],'kernel peak',round(b['kernel_lifetime_peak_footprint_mib'],2))
 for case,c in b['cases'].items(): print(case, 'warm/post/growth/peak MiB:',*[round(c['footprint_'+k+'_mib'],3) for k in ['warm','post','growth','peak']])

provenance.json

{
  "before": {
    "library_sha256": "04e2d8f2c4ce8a9664fc11552534ed5e1589b9eb3ddf5eef86e292a9d02bfde2"
  },
  "after": {
    "library_sha256": "2441970a2ebdc716b371bd4aa0761c98d56be3905bd1ea0b8e22ab6458e33849"
  },
  "fixture_sha256": "688bcace42aaa5240d8751e7794763fafe594ab77132e622c2adcdfd65f25907",
  "production_diff_identical_to_pr": true,
  "benchmark_base": "261c08d53917b138eaf2f7d8a744d1151fe3bc8d",
  "pr_commit": "86ec5147e307628923dfbfef71c934dcb03a3a13",
  "note": "Same Release binary pair and app fixture as previous latency experiment; only production locator diff differs. No other performance PRs included. Clock-calibration pilot discarded before measurement.",
  "host": {
    "cpu": "Apple M5 Pro",
    "ram_bytes": 25769803776,
    "macos": "26.6.2"
  }
}

Reproducing the experiment

The collapsed files below contain the exact temporary fixture, driver, summarizer, semantic validator, and raw timing samples. They are experiment artifacts, not production changes. Apply fixture.patch to a separate checkout of the benchmark baseline, build/install its IntegrationApp once, and use that identical app with both WDA runners. The committed small regression fixture is separate from this performance fixture.

Build baseline and changed WDA checkouts independently with xcodebuild -project WebDriverAgent.xcodeproj -scheme WebDriverAgentRunner -configuration Release -destination 'platform=iOS Simulator,id=<UDID>' -derivedDataPath <before-or-after-directory> build-for-testing CODE_SIGNING_ALLOWED=NO USE_PORT=8213 USE_IP=127.0.0.1. Use only the production source diff for the changed benchmark checkout. Build IntegrationApp from the temporary fixture checkout with the same configuration/destination and install the resulting .app using xcrun simctl install.

Save the scripts together, update their simulator UUID, output directory, and derived-data paths for your machine, then run:

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
python3 summarize.py
python3 semantics.py before semantics-before
python3 semantics.py after semantics-after
fixture.patch
diff --git a/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m b/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m
index 646192cd..aa9adbbe 100644
--- a/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m
+++ b/WebDriverAgentTests/IntegrationApp/Classes/SceneDelegate.m
@@ -11,6 +11,55 @@
 @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;
+    NSUInteger matchIndex = [arguments indexOfObject:@"--identifier-match-count"];
+    NSUInteger matchCount = matchIndex != NSNotFound ? arguments[matchIndex + 1].integerValue : count;
+    BOOL semantics = [arguments containsObject:@"--identifier-semantics"];
+    NSArray *identifiers = @[@"something", @"different", @"", NSNull.null, @"something", @"", NSNull.null, @"quote'\"é", NSNull.null];
+    NSArray *labels = @[@"other label", @"something", @"something", @"something", @"something", @"", @"", @"quoted", @"日本語"];
+    if (semantics) { count = identifiers.count; }
+    NSMutableArray<UILabel *> *labelViews = [NSMutableArray array];
+    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 = i < matchCount ? @"something" : [NSString stringWithFormat:@"unmatched_%lu", (unsigned long)i];
+      label.accessibilityLabel = [NSString stringWithFormat:@"Node %lu & value", (unsigned long)i];
+      if (semantics) {
+        label.accessibilityIdentifier = identifiers[i] == NSNull.null ? nil : identifiers[i];
+        label.accessibilityLabel = labels[i];
+      }
+      [labelViews addObject:label];
+      label.font = [UIFont systemFontOfSize:8];
+      label.isAccessibilityElement = YES;
+      [controller.view addSubview:label];
+    }
+    if (semantics) {
+      UIButton *mutate = [UIButton buttonWithType:UIButtonTypeSystem];
+      mutate.frame = CGRectMake(20, 30, 180, 40);
+      [mutate setTitle:@"Mutate identifier" forState:UIControlStateNormal];
+      mutate.accessibilityIdentifier = @"mutate";
+      [mutate addAction:[UIAction actionWithHandler:^(__kindof UIAction *action) {
+        labelViews[0].accessibilityIdentifier = @"changed";
+      }] forControlEvents:UIControlEventTouchUpInside];
+      [controller.view addSubview:mutate];
+    }
+    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).
run-block.py
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=5)
parser.add_argument('--warmups', type=int, default=1)
parser.add_argument('--scenes', nargs='+', default=['id-100','selective-1024-1','missing-1024-0'])
args = parser.parse_args()
root = Path('/private/tmp/wda-pr-artifacts/native-id')
out = root / args.label
out.mkdir(exist_ok=True)
products = Path(('/private/tmp/wda-live-before' if args.variant == 'before' else '/private/tmp/wda-native-id-after') + '/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:
        count = int(scene.split('-')[-1])
        total_nodes = 1024 if scene.startswith(('selective-','missing-')) else count
        native = 'custom-' not in scene
        bound_by_index = 'indexed-' in scene
        locator = {'using': 'accessibility id', 'value': 'something'}
        if 'xpath-' in scene:
            locator = {'using': 'xpath', 'value': "//XCUIElementTypeStaticText[@name='something']"}
        elif 'chain-' in scene:
            locator = {'using': 'class chain', 'value': "**/XCUIElementTypeStaticText[`name == 'something'`]"}
        scene_out = out / scene
        scene_out.mkdir(exist_ok=True)
        capabilities = {'bundleId': 'com.facebook.IntegrationApp', 'shouldTerminateApp': True,
                        'forceAppLaunch': True, 'shouldWaitForQuiescence': True,
                        'useNativeCachingStrategy': native,
                        'arguments': ['--source-benchmark-nodes', str(total_nodes), '--identifier-match-count', str(count)]}
        (scene_out / 'capabilities.json').write_text(json.dumps(capabilities, indent=2))
        (scene_out / 'locator.json').write_text(json.dumps(locator, indent=2))
        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))
        request('POST', f'/session/{sid}/appium/settings', {'settings': {'boundElementsByIndex': bound_by_index}})
        find_path = f'/session/{sid}/elements'
        if scene.startswith('parent-'):
            parent, _, _ = request('POST', f'/session/{sid}/element', {'using': 'accessibility id', 'value': 'SourceBenchmark'})
            parent_id = parent['value']['element-6066-11e4-a52e-4f735466cecf']
            find_path = f'/session/{sid}/element/{parent_id}/elements'
        if scene.startswith('source-'):
            _, _, raw_source = request('GET', f'/session/{sid}/source?format=xml')
            (scene_out / 'preceding-source.json').write_bytes(raw_source)
        (scene_out / 'endpoint.txt').write_text(find_path+'\n')
        attrs = 'type,label,rect,attribute/name,attribute/index,enabled' 
        def expanded_check(filename):
            request('POST', f'/session/{sid}/appium/settings', {'settings': {
                'shouldUseCompactResponses': False, 'elementResponseAttributes': attrs}})
            obj, _, raw = request('POST', find_path, locator)
            (scene_out / filename).write_bytes(raw)
            elements = obj['value']
            assert len(elements) == count, (scene, len(elements))
            for i, element in enumerate(elements):
                assert element['attribute/name'] == 'something', (scene, i, element)
                assert element['label'] == f'Node {i} & value', (scene, i, element)
                assert isinstance(element['attribute/index'], int), (scene, i, element)
                assert element['type'] == 'XCUIElementTypeStaticText'
            request('POST', f'/session/{sid}/appium/settings', {'settings': {'shouldUseCompactResponses': True}})
            return elements
        print(args.label, scene, 'validating expanded response', flush=True)
        expanded = expanded_check('expanded-before.json')
        idkey = 'element-6066-11e4-a52e-4f735466cecf'
        expected_ids = [e[idkey] for e in expanded]
        assert len(set(expected_ids)) == count
        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('POST', find_path, locator)
            elements = obj['value']
            assert [e[idkey] for e in elements] == expected_ids, (scene, 'element identity/order changed')
            (scene_out / f'{i:02d}.json').write_bytes(raw)
            records.append(dict(iteration=i, warmup=i < args.warmups, elapsed_ms=elapsed,
                                response_bytes=len(raw), elements=len(elements)))
            (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', 'elements', len(elements), flush=True)
        assert expanded_check('expanded-after.json') == expanded, (scene, 'expanded response changed')
        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()
summarize.py
import csv, hashlib, json, math, statistics
from pathlib import Path
r=Path(__file__).parent
cases=['id-100','selective-1024-1','missing-1024-0']
summary={};csvrows=[]
def pct(v,p):
 v=sorted(v);i=(len(v)-1)*p;lo=math.floor(i);hi=math.ceil(i);return v[lo]+(v[hi]-v[lo])*(i-lo)
for case in cases:
 d={};expanded_hashes=set();settings=[];caps=[];validation_count=0
 for variant in ['before','after']:
  records=[];blocks={}
  blocks_to_read = [variant+'-1',variant+'-2']
  for block in blocks_to_read:
   folder=r/block/case;p=folder/'samples.json'
   if not p.exists():continue
   samples=json.loads(p.read_text());measured=[x for x in samples if not x['warmup']];records.extend(measured)
   if measured:blocks[block]={'n':len(measured),'median_ms':statistics.median(x['elapsed_ms'] for x in measured)}
   settings.append(json.loads((folder/'settings.json').read_text())['value']);caps.append(json.loads((folder/'capabilities.json').read_text()))
   for name in ['expanded-before.json','expanded-after.json']:
    path=folder/name
    if not path.exists():continue
    elems=json.loads(path.read_text())['value']
    stripped=[{k:v for k,v in e.items() if k not in ['ELEMENT','element-6066-11e4-a52e-4f735466cecf']} for e in elems]
    expanded_hashes.add(hashlib.sha256(json.dumps(stripped,sort_keys=True).encode()).hexdigest());validation_count+=1
   csvrows.extend(dict(block=block,variant=variant,case=case,**x) for x in samples)
  if records:
   values=[x['elapsed_ms'] for x in records]
   d[variant]={'n':len(values),'median_ms':statistics.median(values),'p95_ms':pct(values,.95),'min_ms':min(values),'max_ms':max(values),'mean_ms':statistics.mean(values),'blocks':blocks}
 if not d:continue
 assert all(s==settings[0] for s in settings),case+' settings differ'
 assert all(c==caps[0] for c in caps),case+' capabilities differ'
 d['expanded_response_variants']=len(expanded_hashes);d['expanded_checks']=validation_count
 d['settings_equal']=True;d['capabilities_equal']=True
 if 'before' in d and 'after' in d:d['median_reduction_percent']=(1-d['after']['median_ms']/d['before']['median_ms'])*100
 summary[case]=d;print(case,json.dumps(d))
(r/'summary.json').write_text(json.dumps(summary,indent=2))
with (r/'measurements.csv').open('w',newline='') as f:
 writer=csv.DictWriter(f,fieldnames=['block','variant','case','iteration','warmup','elapsed_ms','response_bytes','elements']);writer.writeheader();writer.writerows(csvrows)
semantics.py
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=5)
parser.add_argument('--warmups', type=int, default=1)
parser.add_argument('--scenes', nargs='+', default=['id-100','selective-1024-1','missing-1024-0'])
args = parser.parse_args()
root = Path('/private/tmp/wda-pr-artifacts/native-id')
out = root / args.label
out.mkdir(exist_ok=True)
products = Path(('/private/tmp/wda-live-before' if args.variant == 'before' else '/private/tmp/wda-native-id-after') + '/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)
    capabilities = {'bundleId':'com.facebook.IntegrationApp', 'forceAppLaunch':True,
                    'shouldTerminateApp':True, 'shouldWaitForQuiescence':True,
                    'arguments':['--source-benchmark-nodes','9','--identifier-semantics']}
    creation,_,_=request('POST','/session',{'capabilities':{'alwaysMatch':capabilities,'firstMatch':[{}]}})
    sid=creation.get('sessionId') or creation['value']['sessionId']
    request('POST',f'/session/{sid}/appium/settings',{'settings':{'shouldUseCompactResponses':False,
      'elementResponseAttributes':'type,label,rect,attribute/name,attribute/index,enabled'}})
    idkey='element-6066-11e4-a52e-4f735466cecf'
    results={}
    def find(value, endpoint=None):
        result,_,_=request('POST',endpoint or f'/session/{sid}/elements',{'using':'accessibility id','value':value})
        return result['value']
    queries=['something','different','other label','',"quote'\"é",'日本語','missing-target','SourceBenchmark']
    for value in queries:
        results['query:'+value]=find(value)
        print(args.label,repr(value),'matches',len(results['query:'+value]),flush=True)
    assert len(results['query:something'])==4
    assert len(results['query:different'])==1
    assert len(results['query:other label'])==0
    assert len(results['query:'])==0
    assert len(results["query:quote'\"é"])==1
    assert len(results['query:日本語'])==1
    assert len(results['query:missing-target'])==0
    first,_,_=request('POST',f'/session/{sid}/element',{'using':'accessibility id','value':'something'})
    results['first']=first['value']
    assert results['first'][idkey]==results['query:something'][0][idkey]
    parent,_,_=request('POST',f'/session/{sid}/element',{'using':'accessibility id','value':'SourceBenchmark'})
    parent_path=f"/session/{sid}/element/{parent['value'][idkey]}/elements"
    results['parent:something']=find('something',parent_path)
    results['parent:self']=find('SourceBenchmark',parent_path)
    assert len(results['parent:something'])==4
    assert len(results['parent:self'])==1
    button,_,_=request('POST',f'/session/{sid}/element',{'using':'accessibility id','value':'mutate'})
    request('POST',f"/session/{sid}/element/{button['value'][idkey]}/click",{})
    results['mutated:something']=find('something')
    results['mutated:changed']=find('changed')
    assert len(results['mutated:something'])==3
    assert len(results['mutated:changed'])==1
    assert [x[idkey] for x in results['mutated:something']]==[x[idkey] for x in results['query:something'][1:]]
    (out/'results.json').write_text(json.dumps(results,indent=2))
    def normalize(value):
        if isinstance(value,list):return [normalize(x) for x in value]
        if isinstance(value,dict):return {k:normalize(v) for k,v in value.items() if k not in ['ELEMENT',idkey]}
        return value
    (out/'normalized.json').write_text(json.dumps(normalize(results),sort_keys=True,indent=2))
    print(args.label,'semantics passed, including mutation and parent scope',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()
measurements.csv
block,variant,case,iteration,warmup,elapsed_ms,response_bytes,elements
before-1,before,id-100,0,True,2437.1235,15676,100
before-1,before,id-100,1,False,2446.587208,15676,100
before-1,before,id-100,2,False,2554.670542,15676,100
before-1,before,id-100,3,False,2476.017958,15676,100
before-1,before,id-100,4,False,2445.125667,15676,100
before-1,before,id-100,5,False,2566.333709,15676,100
before-2,before,id-100,0,True,2822.346417,15676,100
before-2,before,id-100,1,False,2784.743166,15676,100
before-2,before,id-100,2,False,2661.231791,15676,100
before-2,before,id-100,3,False,2735.8935,15676,100
before-2,before,id-100,4,False,2679.85775,15676,100
before-2,before,id-100,5,False,2790.002042,15676,100
after-1,after,id-100,0,True,1993.819583,15676,100
after-1,after,id-100,1,False,1966.282625,15676,100
after-1,after,id-100,2,False,1997.99575,15676,100
after-1,after,id-100,3,False,1954.149959,15676,100
after-1,after,id-100,4,False,1964.396833,15676,100
after-1,after,id-100,5,False,1975.189958,15676,100
after-2,after,id-100,0,True,1969.757916,15676,100
after-2,after,id-100,1,False,1979.282208,15676,100
after-2,after,id-100,2,False,1965.651917,15676,100
after-2,after,id-100,3,False,1966.148208,15676,100
after-2,after,id-100,4,False,1974.570542,15676,100
after-2,after,id-100,5,False,1957.083416,15676,100
before-1,before,selective-1024-1,0,True,349.441,232,1
before-1,before,selective-1024-1,1,False,348.36725,232,1
before-1,before,selective-1024-1,2,False,347.502125,232,1
before-1,before,selective-1024-1,3,False,348.884042,232,1
before-1,before,selective-1024-1,4,False,348.649167,232,1
before-1,before,selective-1024-1,5,False,360.460291,232,1
before-2,before,selective-1024-1,0,True,389.503542,232,1
before-2,before,selective-1024-1,1,False,379.808833,232,1
before-2,before,selective-1024-1,2,False,383.578541,232,1
before-2,before,selective-1024-1,3,False,383.10025,232,1
before-2,before,selective-1024-1,4,False,379.699417,232,1
before-2,before,selective-1024-1,5,False,380.906417,232,1
after-1,after,selective-1024-1,0,True,255.166167,232,1
after-1,after,selective-1024-1,1,False,257.076833,232,1
after-1,after,selective-1024-1,2,False,255.331834,232,1
after-1,after,selective-1024-1,3,False,256.663666,232,1
after-1,after,selective-1024-1,4,False,257.433292,232,1
after-1,after,selective-1024-1,5,False,256.262083,232,1
after-2,after,selective-1024-1,0,True,262.062292,232,1
after-2,after,selective-1024-1,1,False,254.81675,232,1
after-2,after,selective-1024-1,2,False,257.07875,232,1
after-2,after,selective-1024-1,3,False,265.67975,232,1
after-2,after,selective-1024-1,4,False,254.428208,232,1
after-2,after,selective-1024-1,5,False,259.9965,232,1
before-1,before,missing-1024-0,0,True,177.298458,78,0
before-1,before,missing-1024-0,1,False,176.194792,78,0
before-1,before,missing-1024-0,2,False,175.890041,78,0
before-1,before,missing-1024-0,3,False,176.046792,78,0
before-1,before,missing-1024-0,4,False,177.034,78,0
before-1,before,missing-1024-0,5,False,176.851916,78,0
before-2,before,missing-1024-0,0,True,183.654375,78,0
before-2,before,missing-1024-0,1,False,181.840083,78,0
before-2,before,missing-1024-0,2,False,185.524542,78,0
before-2,before,missing-1024-0,3,False,181.739541,78,0
before-2,before,missing-1024-0,4,False,180.483334,78,0
before-2,before,missing-1024-0,5,False,180.643459,78,0
after-1,after,missing-1024-0,0,True,127.224375,78,0
after-1,after,missing-1024-0,1,False,127.742125,78,0
after-1,after,missing-1024-0,2,False,127.954,78,0
after-1,after,missing-1024-0,3,False,127.889167,78,0
after-1,after,missing-1024-0,4,False,126.889667,78,0
after-1,after,missing-1024-0,5,False,126.818083,78,0
after-2,after,missing-1024-0,0,True,134.620208,78,0
after-2,after,missing-1024-0,1,False,129.696875,78,0
after-2,after,missing-1024-0,2,False,130.331042,78,0
after-2,after,missing-1024-0,3,False,131.70175,78,0
after-2,after,missing-1024-0,4,False,130.938583,78,0
after-2,after,missing-1024-0,5,False,129.118334,78,0
metadata.json
{
  "baseline": "261c08d53917b138eaf2f7d8a744d1151fe3bc8d",
  "prototype": "baseline plus prototype.patch; no other performance PRs",
  "prototype_patch_sha256": "156503ddcf1160a0caec3a9acdcd58e0cff9f9aa47d827bf14b2e5955fc60e0e",
  "xcode": "27.1 (27A9269)",
  "runtime": "iOS 26.5 (23F77)",
  "configuration": "Release",
  "cases": [
    "id-100",
    "selective-1024-1",
    "missing-1024-0"
  ],
  "order": [
    "before-1",
    "after-1",
    "after-2",
    "before-2"
  ],
  "samples_per_case_per_block": 5,
  "warmups_per_case_per_block": 1,
  "semantic_checks_equal": true,
  "before_binary_sha256": "04e2d8f2c4ce8a9664fc11552534ed5e1589b9eb3ddf5eef86e292a9d02bfde2",
  "after_binary_sha256": "2441970a2ebdc716b371bd4aa0761c98d56be3905bd1ea0b8e22ab6458e33849"
}

@KazuCocoa
KazuCocoa marked this pull request as ready for review September 29, 2026 06:59
@autoreleasepool {
return [[FBXCElementSnapshotWrapper wdNameWithSnapshot:snapshot] isEqualToString:accessibilityId];
NSPredicate *predicate = [NSPredicate predicateWithFormat:
@"(identifier != nil AND identifier != '' AND identifier == %@) OR "

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

why do we need to compare with nil and empty string?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Since they caused regressions. I have added tests to cover the behavior

Comment thread WebDriverAgentLib/Categories/XCUIElement+FBFind.m
@KazuCocoa
KazuCocoa merged commit 2ceb940 into master Sep 30, 2026
60 checks passed
@KazuCocoa
KazuCocoa deleted the codex/perf-native-accessibility-id branch September 30, 2026 06:37
github-actions Bot pushed a commit that referenced this pull request Sep 30, 2026
## [16.13.4](v16.13.3...v16.13.4) (2026-09-30)

### Performance Improvements

* use native predicates for accessibility id lookups ([#1286](#1286)) ([2ceb940](2ceb940))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 16.13.4 🎉

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