Skill: steipete/native-app-performance (version 1.0.0)

--- name: native-app-performance description: Native macOS/iOS app performance profiling via xctrace/Time Profiler and CLI-only analysis of Instruments traces. Use when asked to profile, attach, record, or analyze Instruments .trace files, find hotspots, or optimize native app performance without opening Instruments UI. --- # Native App Performance (CLI-only) Goal: record Time Profiler via `xctrace`, extract samples, symbolicate, and propose hotspots without opening Instruments. ## Quick start (CLI) 1) Record Time Profiler (attach): ```bash # Start app yourself, then attach xcrun xctrace record --template 'Time Profiler' --time-limit 90s --output /tmp/App.trace --attach <pid> ``` 2) Record Time Profiler (launch): ```bash xcrun xctrace record --template 'Time Profiler' --time-limit 90s --output /tmp/App.trace --launch -- /path/App.app/Contents/MacOS/App ``` 3) Extract time samples: ```bash scripts/extract_time_samples.py --trace /tmp/App.trace --output /tmp/time-sample.xml ``` 4) Get load address for symbolication: ```bash # While app is running vmmap <pid> | rg -m1 "__TEXT" -n ``` 5) Symbolicate + rank hotspots: ```bash scripts/top_hotspots.py --samples /tmp/time-sample.xml \ --binary /path/App.app/Contents/MacOS/App \ --load-address 0x100000000 --top 30 ``` ## Workflow notes - Always confirm you’re profiling the correct binary (local build vs /Applications). Prefer direct binary path for `--launch`. - Ensure you trigger the slow path during capture (menu open/close, refresh, etc.). - If stacks are empty, capture longer or avoid idle sections. - `xcrun xctrace help record` and `xcrun xctrace help export` show correct flags. ## Included scripts - `scripts/record_time_profiler.sh`: record via attach or launch. - `scripts/extract_time_samples.py`: export time-sample XML from a trace. - `scripts/top_hotspots.py`: symbolicate and rank top app frames. ## Gotchas - ASLR means you must use the runtime `__TEXT` load address from `vmmap`. - If using a new build, update the `--binary` path; symbols must match the trace. - CLI-only flow: no need to open Instruments if stacks are symbolicated via `atos`.

path: scripts/extract_time_samples.py
content: #!/usr/bin/env python3
import argparse
import subprocess
from pathlib import Path


def main() -> int:
    parser = argparse.ArgumentParser(description="Export time-sample XML from a .trace file.")
    parser.add_argument("--trace", required=True, help="Path to .trace bundle")
    parser.add_argument("--output", required=True, help="Output XML path")
    args = parser.parse_args()

    trace = Path(args.trace)
    output = Path(args.output)

    if not trace.exists():
        raise SystemExit(f"trace not found: {trace}")

    # xctrace export needs an XPath into the trace table-of-contents. The schema
    # name 'time-sample' is stable for Time Profiler sample tables.
    xpath = '/trace-toc/run[@number="1"]/data/table[@schema="time-sample"]'

    cmd = [
        "xcrun",
        "xctrace",
        "export",
        "--input",
        str(trace),
        "--xpath",
        xpath,
        "--output",
        str(output),
    ]

    subprocess.check_call(cmd)

    if not output.exists():
        raise SystemExit(f"export failed: {output} missing")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


path: scripts/record_time_profiler.sh
content: #!/usr/bin/env bash
set -euo pipefail

# Minimal CLI wrapper for Time Profiler recording via xctrace.
# Supports attach or launch and always writes a .trace output.

usage() {
  cat <<'USAGE'
Usage:
  record_time_profiler.sh --attach <pid> --trace <path> [--duration 90s]
  record_time_profiler.sh --launch <binary> --trace <path> [--duration 90s]
USAGE
}

attach_pid=""
launch_cmd=""
trace_path=""
duration="90s"

while [[ $# -gt 0 ]]; do
  case "$1" in
    --attach)
      attach_pid="$2"; shift 2 ;;
    --launch)
      launch_cmd="$2"; shift 2 ;;
    --trace)
      trace_path="$2"; shift 2 ;;
    --duration)
      duration="$2"; shift 2 ;;
    -h|--help)
      usage; exit 0 ;;
    *)
      echo "Unknown arg: $1"; usage; exit 1 ;;
  esac
done

if [[ -z "$trace_path" ]]; then
  echo "--trace is required"; usage; exit 1
fi

if [[ -n "$attach_pid" && -n "$launch_cmd" ]]; then
  echo "Use either --attach or --launch, not both"; exit 1
fi

if [[ -z "$attach_pid" && -z "$launch_cmd" ]]; then
  echo "Must supply --attach or --launch"; usage; exit 1
fi

if [[ -n "$attach_pid" ]]; then
  xcrun xctrace record --template 'Time Profiler' --time-limit "$duration" \
    --output "$trace_path" --attach "$attach_pid"
else
  xcrun xctrace record --template 'Time Profiler' --time-limit "$duration" \
    --output "$trace_path" --launch -- "$launch_cmd"
fi


path: scripts/top_hotspots.py
content: #!/usr/bin/env python3
import argparse
import subprocess
import xml.etree.ElementTree as ET
from collections import Counter
from pathlib import Path
from typing import Dict, List, Tuple


def parse_text_vmsize(binary: Path) -> int:
    # otool -l provides __TEXT vmsize; we need it to cap the address range.
    out = subprocess.check_output(["otool", "-l", str(binary)], text=True)
    lines = out.splitlines()
    in_text = False
    for i, line in enumerate(lines):
        if line.strip() == "segname __TEXT":
            in_text = True
        if in_text and line.strip().startswith("vmsize"):
            _, size_hex = line.strip().split()
            return int(size_hex, 16)
        # Stop scanning once we leave the __TEXT section block
        if in_text and line.strip().startswith("segname") and line.strip() != "segname __TEXT":
            in_text = False
    raise SystemExit("Could not find __TEXT vmsize via otool -l")


def load_callstacks(samples_xml: Path) -> List[int]:
    root = ET.parse(samples_xml).getroot()

    # kperf-bt entries are referenced by id/ref; build a map first.
    bt_by_id: Dict[str, List[int]] = {}
    for bt in root.findall('.//kperf-bt'):
        bid = bt.get('id')
        text_addrs = bt.find('text-addresses')
        if bid and text_addrs is not None and text_addrs.text:
            # Addresses are space-separated decimal strings.
            addrs = [int(x) for x in text_addrs.text.strip().split() if x.strip().isdigit()]
            bt_by_id[bid] = addrs

    addrs: List[int] = []
    for row in root.findall('.//row'):
        bt = row.find('kperf-bt')
        if bt is None:
            continue
        ref = bt.get('ref')
        if ref and ref in bt_by_id:
            addrs.extend(bt_by_id[ref])
        else:
            bid = bt.get('id')
            if bid and bid in bt_by_id:
                addrs.extend(bt_by_id[bid])

    return addrs


def chunked(items: List[str], size: int) -> List[List[str]]:
    return [items[i:i + size] for i in range(0, len(items), size)]


def symbolicate(binary: Path, load_addr: str, addrs: List[int]) -> List[str]:
    # atos can take multiple addresses; chunk to avoid arg limits on large traces.
    addr_hex = [hex(a) for a in addrs]
    symbols: List[str] = []
    for chunk in chunked(addr_hex, 80):
        cmd = ["xcrun", "atos", "-o", str(binary), "-l", load_addr] + chunk
        symbols.extend(subprocess.check_output(cmd, text=True).splitlines())
    return symbols


def main() -> int:
    parser = argparse.ArgumentParser(description="Rank top hotspots from Time Profiler samples.")
    parser.add_argument("--samples", required=True, help="time-sample XML from extract_time_samples.py")
    parser.add_argument("--binary", required=True, help="Path to app binary")
    parser.add_argument("--load-address", required=True, help="Runtime __TEXT load address (from vmmap)")
    parser.add_argument("--top", type=int, default=30, help="Top N symbols")
    args = parser.parse_args()

    samples_xml = Path(args.samples)
    binary = Path(args.binary)
    load_addr = args.load_address

    if not samples_xml.exists():
        raise SystemExit(f"samples not found: {samples_xml}")
    if not binary.exists():
        raise SystemExit(f"binary not found: {binary}")

    vmsize = parse_text_vmsize(binary)
    base = int(load_addr, 16)
    end = base + vmsize

    addrs = load_callstacks(samples_xml)
    counts = Counter(addrs)

    # Filter to app addresses only, using runtime load address + __TEXT size.
    app_counts = Counter({a: c for a, c in counts.items() if base <= a <= end})

    top = app_counts.most_common(args.top)
    if not top:
        print("No app frames found; check load address and binary match.")
        return 0

    addrs_only = [a for a, _ in top]
    symbols = symbolicate(binary, load_addr, addrs_only)

    print("address,count,symbol")
    for (addr, count), symbol in zip(top, symbols):
        print(f"{hex(addr)},{count},{symbol}")

    return 0


if __name__ == "__main__":
    raise SystemExit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)