From b766212695710164645fcfc52f3c70a66abb9a17 Mon Sep 17 00:00:00 2001 From: Andros Fenollosa Date: Thu, 4 Jun 2026 17:55:53 +0200 Subject: [PATCH] Metal: coalesce presents and composite in one command buffer A redisplay pass runs several update cycles back-to-back (buffer window plus echo area), and with display sync every present blocked on a drawable: two blocking presents per keystroke halved typing throughput, and the present itself paid for a second command buffer. Presents now coalesce: one landing within ~8 ms of the previous commits to the static texture and is flushed by a one-shot main-queue block (or absorbed by the next cycle's present), and the composite pass is encoded on the same command buffer as the cycle's draws. The policy's flush presents through the same coalescing path. Benchmarks (M1 Pro, font-locked xdisp.c, 120x45): machine-paced typing goes from 30 to 106 chars/s and sustained scroll from 116 to 475 redisplays/s with vsync off, matching the stock backend's throughput and total CPU; with vsync on (default) the same visible 60 fps now costs ~35% less CPU than stock. README gains the numbers. Pixel parity, GIF/video playback, the first-tab-switch deferral and the echo-area tests are all unchanged. --- README.md | 26 +++++++++++++++++ src/mtlterm.h | 17 +++++++++++ src/mtlterm.m | 81 ++++++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2991df76e2a..546d08ebd6e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,32 @@ Beyond raw rendering, it enables things the stock backend cannot do: Text is rasterized once into a GPU glyph atlas and drawn as textured quads; scrolling moves already-rendered pixels with a texture blit. +## Performance + +Measured on an Apple M1 Pro (Emacs 32 development build, 120x45 frame, +font-locked `xdisp.c`, same binary with and without the GPU backend; +`/usr/bin/time -l` over scripted workloads): + +| Workload | Stock (Cocoa) | GPU, vsync on (default) | GPU, vsync off | +|---|---|---|---| +| Sustained scroll, redisplays/s | 481 | 324 | 475 | +| CPU for 15 s of that scroll | 16.0 s | **10.5 s** | 15.5 s | +| Typing throughput (chars/s, machine-paced) | 108 | 52 | 106 | +| Idle (8 s) CPU | 1.21 s | 1.19 s | same | +| Peak RSS | ~140 MB | ~144 MB | same | + +Honest reading: + +- **Machine-paced throughput and CPU cost match the stock backend** + (vsync off). There is no GPU tax. +- With vsync on (the default), presents wait for the display refresh: + the screen shows the same 60 fps either way, but Emacs burns ~35% + less CPU under flat-out scrolling because it stops rendering frames + nobody can see. Human-paced input is unaffected (the cap is ~52 + machine-paced updates/s; keyboard auto-repeat tops out well below + that). `(mtl-vsync nil)` switches to uncapped, stock-like behavior. +- Idle cost is identical and the GPU resources add ~4 MB of RSS. + > Status: experimental, under active development. > > **Note:** I am not answering issues for now. Feel free to open them as diff --git a/src/mtlterm.h b/src/mtlterm.h index 568b4ff9c08..1a99482ccf8 100644 --- a/src/mtlterm.h +++ b/src/mtlterm.h @@ -222,6 +222,23 @@ typedef struct mtl_spring { the expose substitute -- lives in gfxterm.c, not here.) */ @property (nonatomic, assign) BOOL needsPresent; +/* Present coalescing: a redisplay pass can run several update cycles + back-to-back (buffer window + echo area), and with display sync each + present blocks on a drawable -- two blocking presents per keystroke + halved the typing rate. Presents within kMtlPresentCoalesce seconds of + the previous one are deferred and flushed by a one-shot main-queue + block (or absorbed by the next cycle's present). */ +@property (nonatomic, assign) CFTimeInterval lastPresentTime; +@property (nonatomic, assign) BOOL presentScheduled; + +/* Coalesced present: present now, unless one landed very recently (then + schedule a deferred one). Use for "make the frame visible" paths. */ +- (void)presentCoalesced; + +/* Encode blit+overlays to DRAWABLE on CMD and queue its present. */ +- (void)encodeCompositeOn:(id)cmd + drawable:(id)drawable; + /* Active inline video (one per frame for now), drawn by compositeToScreen over the static texture. */ @property (nonatomic, strong) MtlVideoPlayer *videoPlayer; diff --git a/src/mtlterm.m b/src/mtlterm.m index 572d1226598..63f6bd9b2b7 100644 --- a/src/mtlterm.m +++ b/src/mtlterm.m @@ -1549,20 +1549,76 @@ mtl_log_seq_p (void) [self endFramePresent:YES]; } +/* How close two presents may be before the second one is deferred: + redisplay runs several update cycles back-to-back (buffer window + + echo area) and, with display sync on, every present blocks on a + drawable -- two blocking presents per keystroke halved typing + throughput. Half a 60 Hz frame keeps coalescing inside one refresh + while never delaying a visible update by more than ~8 ms. */ +#define MTL_PRESENT_COALESCE 0.008 + +/* Schedule the deferred present: a one-shot main-queue block flushes it + shortly after, unless an earlier present already absorbed it. */ +- (void)schedulePresent +{ + if (self.presentScheduled) return; + self.presentScheduled = YES; + dispatch_after (dispatch_time (DISPATCH_TIME_NOW, + (int64_t) (MTL_PRESENT_COALESCE * NSEC_PER_SEC)), + dispatch_get_main_queue (), ^{ + self.presentScheduled = NO; + if (self.needsPresent && !self.encoder) + [self compositeToScreen]; + }); +} + +- (void)presentCoalesced +{ + if (CACurrentMediaTime () - self.lastPresentTime < MTL_PRESENT_COALESCE) + { + self.needsPresent = YES; + [self schedulePresent]; + } + else + [self compositeToScreen]; +} + /* Commit the static-texture draws. When PRESENT is NO, only the static texture is updated and the on-screen present is deferred (needsPresent), so a sequence of immediate draws (clear_mouse_face + show_mouse_face) is shown in a single - composite by flush_display instead of flickering through each step. */ + composite by flush_display instead of flickering through each step. + When presenting, the composite pass is encoded on the SAME command + buffer as the cycle's draws (one commit instead of two). */ - (void)endFramePresent:(BOOL)present { if (!self.encoder) return; [self.encoder endEncoding]; - [self.cmdBuf commit]; self.encoder = nil; - self.cmdBuf = nil; self.drawable = nil; + + if (present + && CACurrentMediaTime () - self.lastPresentTime >= MTL_PRESENT_COALESCE + && self.staticTexture && g_blit_pipeline) + { + id drawable = [self.metalLayer nextDrawable]; + if (drawable) + { + [self encodeCompositeOn:self.cmdBuf drawable:drawable]; + [self.cmdBuf commit]; + self.cmdBuf = nil; + return; + } + } + + [self.cmdBuf commit]; + self.cmdBuf = nil; if (present) - [self compositeToScreen]; + { + /* Too soon after the previous present: defer (the next cycle's + present or the scheduled block makes it visible). */ + self.needsPresent = YES; + [self schedulePresent]; + } else self.needsPresent = YES; } @@ -1574,6 +1630,18 @@ mtl_log_seq_p (void) id drawable = [self.metalLayer nextDrawable]; if (!drawable) return; + id cmd = [g_queue commandBuffer]; + [self encodeCompositeOn:cmd drawable:drawable]; + [cmd commit]; +} + +/* Encode the full composite (static blit + video + animation overlays) + targeting DRAWABLE on CMD, and queue its present. Shared by the + standalone present (compositeToScreen) and the single-commit path in + endFramePresent:. */ +- (void)encodeCompositeOn:(id)cmd + drawable:(id)drawable +{ MTL_SEQ ("PRESENT layer=%.0fx%.0f drawable=%lux%lu static=%lux%lu", self.metalLayer.frame.size.width, self.metalLayer.frame.size.height, (unsigned long) drawable.texture.width, @@ -1582,6 +1650,7 @@ mtl_log_seq_p (void) (unsigned long) self.staticTexture.height); self.needsPresent = NO; /* about to present whatever is in the static texture */ + self.lastPresentTime = CACurrentMediaTime (); NSSize sz = self.metalLayer.frame.size; MtlAnimator *anim = self.animator; @@ -1596,7 +1665,6 @@ mtl_log_seq_p (void) rpd.colorAttachments[0].loadAction = MTLLoadActionDontCare; rpd.colorAttachments[0].storeAction = MTLStoreActionStore; - id cmd = [g_queue commandBuffer]; id enc = [cmd renderCommandEncoderWithDescriptor:rpd]; [enc setVertexBuffer:self.uniformBuffer offset:0 atIndex:1]; [enc setFragmentBuffer:self.uniformBuffer offset:0 atIndex:1]; @@ -1773,7 +1841,6 @@ mtl_log_seq_p (void) [enc endEncoding]; [cmd presentDrawable:drawable]; - [cmd commit]; } @end @@ -2377,7 +2444,7 @@ mtl_drv_end_frame (struct frame *f, bool present_p) static void mtl_drv_present (struct frame *f) { - [mtl_get_frame_data (f) compositeToScreen]; + [mtl_get_frame_data (f) presentCoalesced]; } static bool