Become a MacRumors Supporter for $50/year with no ads, ability to filter front page stories, and private forums.

tiguanito

macrumors member
Original poster
I'm running into an issue with my new MacBook Pro M5 Pro (14" 18 CPU /20 GPU - 48GB) hooked up to a Studio Display XDR and wanted to see if anyone else is seeing this.
UI scrolling across all apps is noticeably choppy when the XDR is set to 120Hz or Adaptive (47–120Hz), especially during web browsing. The MacBook’s internal screen is smooth, and the stutter happens whether the MacBook lid is open or closed in clamshell mode.

Details on my setup:
- MacOS 26.6.2 (latest)
- XDR firmware 26.4, connected directly via Apple Thunderbolt cable (already tried power cycling).
- Happens with both the internal trackpad and an MX Master 4
- Tested the exact same XDR setup with a base M5 and an M5 Max: both were smooth at 120Hz. Seems oddly specific to the M5 Pro chip.
- Tested with a second (work) MBP M5 Pro and reproduced the exact same stuttering behavior, pointing to the M5 Pro chip variant.


Is this a known macOS display driver bug on the Pro chip ? I hope it's not a HW bug. I can still return it but don't want to spend an extra $1k+ in a Max just for that reason.
 
Last edited:
  • Like
Reactions: kevin_top1
Since you reproduced it on a second M5 Pro and two other chip variants were fine on the same XDR, that really does smell like a driver/refresh-rate bug rather than your unit being defective, so I wouldn't burn $1k on a Max over it yet. A couple of things worth isolating first: set the XDR to a fixed 120Hz instead of Adaptive, since the variable-refresh ramping is where a lot of the reported choppiness comes from, and see if the stutter changes character. Then check whether it's actually the display pipeline or the scroll pipeline by opening a WindowServer-heavy test — drag a window around, or scroll something native like Finder in list view — versus a browser page. If only browsers stutter, it's more likely the browser's own frame pacing than the GPU driver; Safari's built-in scrolling and Chrome's compositor behave differently at 120Hz. Also try knocking the resolution down one scaled step temporarily: if it goes smooth, you're looking at a bandwidth/scaling issue, not silicon. If nothing helps, file it with Feedback Assistant with a screen recording and note the three-chip comparison, because that's exactly the kind of detail that gets a display bug triaged. Your return window is the safety net, but I'd give it one OS point release before assuming hardware.
 
Thanks for the suggestions.
XDR to fixed 120Hz : no improvement
lowering display resolution : no improvement
Native UI (find in list view, etc): Finder in list view : same choppy scrolling behavior across every multi-page application.

I'll report the issue with the Feedback Assistant. I could also try the macOS 27 Beta I suppose.
 
Filed the feedback ticket FB24598596
I've just reproduced on a clean, secondary APFS volume running macOS 27 Beta (Build 26A5425a = today's build) with no third-party software, extensions, or restored user profiles.
 
the FB filing on a clean 27 beta volume is the right move. one thing worth adding to the ticket if you haven't — check ioreg for the actual negotiated timing while it's stuttering: `ioreg -l -w0 | grep -A5 IOFBCurrentPixelClock` shows what the SoC is really pushing to the XDR versus what the Displays pane claims. I saw very similar behavior on a colleague's M4 Pro + LG UltraFine last year where the pane read 120 but ioreg had it flapping between 96 and 120 under Adaptive; ended up being a link-layer negotiation quirk that only hit the Pro/Max variants. also worth grabbing a spindump during a stutter (`sudo spindump 5 -notarget`) — the WindowServer/backboardd stack traces will tell Apple more than "it's choppy" ever will.
 
I got same issue in external 5K 165hz display in M5Pro MBP, event in 120hz or 165hz mode in my display ,Window moving and scrolling doesn't not render smooth, It's even less smooth than the built-in screen.I think the problem lies with the M5Pro's rendering pipeline, but I'm unsure if it can be fixed via software later.
also , this issue didn't fix in macOS 27 beta 8(26A5425a), and I had reported lots of this issue since macos27beta1, but not getting better
 
There is a way to illustrate the issue by capturing traces with Xcode Instruments
About 30% of the frames at 120hz are either dropped or repeated.

1- set the display to 120hz or 60hz
2-
Code:
xcrun xctrace record --template 'Metal System Trace' --time-limit 30s --output  stutter_120hz.trace --all-processes
3- scroll a long web page or document for ~30 seconds
4-
Code:
xcrun xctrace export --input stutter_120hz.trace --xpath '/trace-toc/run[@number="1"]/data/table[@schema="displayed-surfaces-interval"]' --output surfaces_120hz.xml
5- python3 ./parse_surfaces.py surfaces_120hz.xml (see script below)

at 120hz :
Total frames: 2562
Held for exactly 1 interval (healthy): 1794 (70.0%)
Held for exactly 2x interval (dropped/repeated): 694 (27.1%)
Held 3x+ longer (multi-frame stalls): 74 (2.9%)
Longest single stall: 991.7 ms

at 60hz:
Total frames: 1717
Held for exactly 1 interval (healthy): 1676 (97.6%)
Held for exactly 2x interval (dropped/repeated): 20 (1.2%)
Held 3x+ longer (multi-frame stalls): 21 (1.2%)
Longest single stall: 383.3 ms

Python:
import xml.etree.ElementTree as ET
import statistics
import json
import sys
import os

def resolve_refs(elem, id_map):
    if elem is None:
        return None
    ref = elem.get('ref')
    if ref is not None:
        return id_map.get(ref)
    eid = elem.get('id')
    if eid is not None:
        id_map[eid] = elem
    return elem

def parse_surfaces(path, target_display='Display 1'):
    tree = ET.parse(path)
    root = tree.getroot()
    id_maps = {}
    def get_map(tag):
        if tag not in id_maps:
            id_maps[tag] = {}
        return id_maps[tag]

    rows_out = []
    for row in root.iter('row'):
        st = row.find('start-time')
        dur = row.findall('duration')  # can be 1 or 2 duration elements (display-duration, cpu-to-display-latency)
        dn = row.find('display-name')

        st_r = resolve_refs(st, get_map('start-time'))
        dn_r = resolve_refs(dn, get_map('display-name'))

        if st_r is None or dn_r is None:
            continue
        dname = dn_r.get('fmt')
        if dname != target_display:
            continue

        # first duration = display duration (how long frame stayed on screen)
        dur_r = None
        if len(dur) >= 1:
            dur_r = resolve_refs(dur[0], get_map('duration'))

        ts = int(st_r.text) if st_r.text else None
        dur_ns = int(dur_r.text) if (dur_r is not None and dur_r.text) else None
        if ts is None or dur_ns is None:
            continue
        rows_out.append((ts, dur_ns))
    return sorted(rows_out)

def analyze(path, expected_interval_ns, label):
    rows = parse_surfaces(path)
    durations_ms = [d/1e6 for _, d in rows]
    times_s = [(t - rows[0][0])/1e9 for t, _ in rows]
    n = len(durations_ms)
    mean = statistics.mean(durations_ms)
    stdev = statistics.pstdev(durations_ms)
    expected_ms = expected_interval_ns/1e6
    threshold = expected_ms * 1.5
    outlier_idx = [i for i, v in enumerate(durations_ms) if v > threshold]
    outliers = [durations_ms[i] for i in outlier_idx]
    outlier_times = [times_s[i] for i in outlier_idx]
    print(f"=== {label} ===")
    print(f"Total surfaces displayed: {n}  | Span: {times_s[-1]:.2f}s")
    print(f"Mean display duration: {mean:.4f} ms (expected ~{expected_ms:.3f} ms per single-vsync hold)")
    print(f"Stdev: {stdev:.4f} ms")
    print(f"Min: {min(durations_ms):.4f} ms   Max: {max(durations_ms):.4f} ms")
    print(f"Frames held > {threshold:.3f} ms (>1.5x expected single interval = stutter/dropped frame): {len(outliers)} ({100*len(outliers)/n:.2f}%)")
    if outliers:
        top = sorted(zip(outliers, outlier_times), reverse=True)[:20]
        print(f"  Worst stutters (duration_ms @ time_s): {[(round(v,3), round(t,2)) for v,t in top]}")
    # distribution of duration "buckets" rounded to nearest expected multiple
    from collections import Counter
    buckets = Counter(round(v/expected_ms) for v in durations_ms)
    print(f"  Duration buckets (multiples of {expected_ms:.3f}ms): {dict(sorted(buckets.items()))}")

    # collapsed summary matching the report table: 1x / 2x / 3x+ / longest stall
    n_1x = buckets.get(1, 0)
    n_2x = buckets.get(2, 0)
    n_3x_plus = sum(c for k, c in buckets.items() if k >= 3)
    max_ms = max(durations_ms)
    print()
    print(f"  Total frames: {n}")
    print(f"  Held for exactly 1 interval (healthy): {n_1x} ({100*n_1x/n:.1f}%)")
    print(f"  Held for exactly 2x interval (dropped/repeated): {n_2x} ({100*n_2x/n:.1f}%)")
    print(f"  Held 3x+ longer (multi-frame stalls): {n_3x_plus} ({100*n_3x_plus/n:.1f}%)")
    print(f"  Longest single stall: {max_ms:.1f} ms")
    print()

    return {
        'label': label, 'n': n, 'span_s': times_s[-1], 'mean_ms': mean, 'stdev_ms': stdev,
        'min_ms': min(durations_ms), 'max_ms': max_ms, 'expected_ms': expected_ms,
        'threshold_ms': threshold, 'n_outliers': len(outliers), 'pct_outliers': 100*len(outliers)/n,
        'outliers_ms': outliers, 'outlier_times_s': outlier_times,
        'durations_ms': durations_ms, 'times_s': times_s,
        'n_1x': n_1x, 'pct_1x': 100*n_1x/n,
        'n_2x': n_2x, 'pct_2x': 100*n_2x/n,
        'n_3x_plus': n_3x_plus, 'pct_3x_plus': 100*n_3x_plus/n,
    }

def guess_interval_ns(path):
    """Infer expected vsync interval from a refresh rate found in the filename, e.g. '60hz'/'120hz'."""
    import re
    name = os.path.basename(path).lower()
    match = re.search(r'(\d+)\s*hz', name)
    if not match:
        return None
    hz = int(match.group(1))
    if hz <= 0:
        return None
    return round(1e9 / hz)


if __name__ == '__main__':
    import argparse

    parser = argparse.ArgumentParser(
        description="Analyze displayed-surfaces-interval XML exported via xctrace."
    )
    parser.add_argument('xml_path', help='Path to the exported surfaces XML file')
    parser.add_argument(
        '--interval-ns', type=int, default=None,
        help='Expected single-vsync interval in nanoseconds (e.g. 8333333 for 120Hz, 16666667 for 60Hz). '
             'If omitted, inferred from a "<N>hz" pattern in the filename (e.g. surfaces_60hz.xml -> 60Hz).'
    )
    parser.add_argument('--label', default=None, help='Label to print for this run')
    parser.add_argument(
        '--out', default=None,
        help='Optional path to save results as JSON (default: <xml_path_stem>_results.json next to input)'
    )
    args = parser.parse_args()

    interval_ns = args.interval_ns
    if interval_ns is None:
        interval_ns = guess_interval_ns(args.xml_path)
        if interval_ns is None:
            parser.error(
                "Could not infer refresh rate from filename (expected e.g. '60hz' or '120hz' in the name). "
                "Pass --interval-ns explicitly."
            )
        print(f"Inferred interval from filename: {interval_ns} ns "
              f"({1e9/interval_ns:.1f} Hz)")

    label = args.label or os.path.basename(args.xml_path)
    result = analyze(args.xml_path, interval_ns, label)

    out_path = args.out or os.path.splitext(args.xml_path)[0] + '_results.json'
    with open(out_path, 'w') as f:
        json.dump(result, f)
    print(f"Saved results to {out_path}")
 
Register on MacRumors! This sidebar will go away, and you'll see fewer ads.