17 categories, each with a README.org checklist and a self-contained
test.el that loads the same scenario in both GPU and vanilla Emacs for
side-by-side comparison.
Mermaid's xychart-beta cannot draw grouped bars or a legend: two bar
series overlap on the same axis position, so the paired GPU-vs-cairo
comparison was unreadable. Render the charts as images with real
grouped bars, value labels and a legend (bench/make-charts.py), and
embed them from .github/assets/.
Plot absolute redisplays/s as two colored bars per workload (GPU green,
cairo blue) so the per-feature contrast between the two backends is
visible at a glance. The macOS chart stays on CPU seconds.
The benchmark charts plotted absolute fps as two zero-padded bar series,
which hid the actual story and (on macOS) made the lowest-CPU option look
worst. Replace them with single-series charts: GPU/cairo speedup ratios
for Linux (1.0x = parity) and CPU seconds for the macOS scroll (lower is
better). The tables still carry the absolute numbers.
The README already pointed to run-bench.sh and run-bench-hires.sh as the
way to reproduce the performance tables; add them under bench/ along with
gl-bench.el (the five workloads) and a README describing the methodology.
The scripts are relocatable: the binary is taken from $EMACS, gl-bench.el
is found relative to the script, and results go to a temporary directory.
The backend is now at production maturity on two platforms with
comprehensive test coverage and precompiled distribution. Update status
from 'experimental' to 'Beta, fully functional', documenting known
limitations (Intel/universal on macOS, Wayland on Linux) and highlighting
that all Emacs display features are supported and the GPU path is opt-in.
Vertical interleaved bars crowded the workload labels into each other;
flip the charts horizontal so every pair gets a readable row, and give
cairo/Cocoa and the GPU distinct palette colors (named in the title --
xychart has no legend).
The ratio charts hid the actual numbers; plot cairo and GPU as
adjacent bars per workload in redisplays per second instead, matching
the tables (mermaid xychart has no grouped series, so the pairs are
interleaved on the x axis), and chart the macOS sustained-scroll rate
in fps as well.
Add a mermaid xychart under each performance table (GPU/cairo ratio
with a parity line for the two Linux tables, CPU cost of sustained
scroll for the macOS one) so the takeaway is visible at a glance; the
tables stay as the source of record. Right-align the numeric columns
of the macOS table to match the Linux ones.
Unified animation pump (one Lisp timer and at most one present per
tick for cursor effects, cross-fades and inline video; video holds its
native 30fps now) and the stale-frame flash fix (drop the XDBE back
buffer on GL frames; an Expose could flash the half-painted startup
frame for one vblank).
The packages were only linked from the release notes; give them a
section before the build-from-source instructions, including the
Debian/Ubuntu split (incompatible libjpeg sonames) and the
EMACS_GPU_DISABLE opt-out.
The stale-frame flash had a second source besides present pressure,
and this one explains the flashes that survived the 12ms throttle: the
frame's XDBE back buffer. Core X11 paints it during startup, the GPU
backend then takes over rendering and never touches it again, so it
keeps the half-painted startup frame forever -- and xterm.c's Expose
handler calls show_back_buffer unconditionally, so any Expose swaps
that ancient frame on screen for one vblank until the next GPU present
overwrites it. That is the "old frame from another buffer" flicker
reported during buffer switches, and why it resisted every change to
the EGL present path (vsync, glFinish, DRI2 vs DRI3): the offending
swap was never ours.
Fix: set inhibit-double-buffering on the frame when the OpenGL backend
takes over. Emacs tears the back buffer down through its own
machinery and every XdbeSwapBuffers becomes a no-op; the GPU FBO is
the backing store. Verified on hardware against the real scanout
(kmsgrab at 60fps, 152 buffer switches with cross-fades): 0 anomalous
frames in 8495, where the same soak detected ~70-105 single-frame
flashes before, on both this build and the previous release.
Cursor effects, buffer cross-fades and inline video each ran on their
own Lisp timer, and each timer presented on its own. With everything
active that stacked presents well above the refresh rate, which is
exactly the pressure under which radeonsi was caught presenting stale
swapchain buffers, and the blocking swaps starved the other timers
(inline video dropped from 30fps to ~20fps with its tick slipping to
49ms).
Replace the three timers with a single pump. gpu.el now runs one
repeating timer that calls the new gpu-pump-tick primitive: the driver
advances the cursor physics, the fade clock and the video upload
together and presents AT MOST ONCE per tick, under the same 12ms
last-swap throttle as before. The pump paces itself: 60Hz while a
fade runs, 30Hz otherwise, gone when nothing needs it. A skipped
subsystem loses nothing -- the fade and the video are composited by
every present, whoever issues it.
The old per-subsystem entry points (gpu-anim-tick, gpu-transition-tick,
gpu-video-tick) remain for compatibility; gpu-video-tick now presents
only when a fresh frame arrived or the rect moved, under the same
throttle, instead of unconditionally.
On Metal the pump consolidates the same two timers; the animator
already coalesces presents, so the change there is one deterministic
tick (video frame pull, then animator step) with a real elapsed dt.
Measured on the AMD iGPU test box (1920x1080, Cinnamon): video tick
median 33.0ms / p90 33.3ms (was 49ms), a 30fps file plays at 29.5fps,
and worst-case contention (video + cursor effects + 12 cross-fades)
peaks at 8 presents per 100ms window with a 16.1ms median gap.
GL_LOG_PRESENT now also traces the pump decisions and the per-tick
video upload state.
The cursor-effect and cross-fade pumps presented on top of redisplay's
own presents, pushing the window surface well past the refresh rate;
under that pressure Mesa juggles extra swapchain buffers and radeonsi
was caught presenting a stale one, an ancient frame flashing for one
vblank on buffer switches. Animation ticks now skip their present when
a swap happened less than 12 ms ago; the time-based animations lose
nothing. Also clamp copy_region to the framebuffer: a scroll computed
against a mid-resize frame size could blit from outside the FBO, which
writes undefined (recycled) VRAM into visible content. GL_LOG_PRESENT
now prints a present sequence number, timestamp and cross-fade phase,
and GL_PRESENT_FINISH=1 drains the GPU before each swap as a
diagnostic knob.
Both drivers cached image textures keyed by the struct image pointer
alone (plus display size). The Emacs image cache frees evicted images,
and the allocator can hand the same address to a different image later:
a stale entry would then draw the old picture in the new image's place,
and the most likely stale survivor is whatever was cached first and
evicted (a startup dashboard banner, say) bleeding into later buffers.
Pin each cache entry to the image spec hash (the hash field of struct
image): a recycled pointer with a different spec re-rasterizes instead
of hitting the stale texture. GL keeps the hash in its entry table;
Metal adds a parallel pointer-to-hash CFDictionary next to the texture
cache.
Image and fringe parity batteries unchanged on Linux; the Metal
reference scene stays byte-identical.
The partial present (buffer-age repair + compositor damage rects) has
now needed three rounds of race hardening on real hardware: against the
window manager's asynchronous resizes, against the driver's buffer
rotation (ages observed lying across reallocations on radeonsi), and
with the compositor's damage-driven texture updates still under
suspicion in the reported bleed-through of stale buffer content. What
it buys is roughly 10% on workloads that already run far beyond a
thousand frames per second; what it costs is correctness fights on
three asynchronous fronts at once.
Flip the trade: the default present now blits the whole frame and swaps
with full damage -- the behavior that was rock solid for every release
up to the optimization round -- and the partial machinery moves behind
GL_PARTIAL_PRESENT=1 as an experimental opt-in (GL_NO_DAMAGE still
forces it off). The glyph/fill batching, the persistent surface
binding and the post-swap size recheck, which carry the real
performance wins and have no asynchronous failure modes, stay as they
are.
Verified on the AMD/Muffin test machine: maximize/restore storms with
concurrent scrolling, free-form resizes, M-x bursts, and the user
configuration's dashboard-to-buffer flow run with zero
GL_VERIFY_PRESENT mismatches over ~1800 presents, and the parity
batteries are byte-identical to their baselines.
A window-manager resize can reallocate the EGL back buffers while Mesa
still reports the pre-resize surface size AND a pre-resize buffer age
for the present already in flight: the partial repair then lands on a
fresh zero-filled buffer and most of the frame goes on screen as
garbage. Caught red-handed on AMD/radeonsi under Muffin by the new
GL_VERIFY_PRESENT instrument: 232883 wrong pixels (41% of the buffer)
in an age=2 partial present during a maximize, healed only by the next
redisplay -- the reported black flicker.
The client cannot win that race beforehand (both the size query and the
age reflect pre-resize state until Mesa validates), but right after the
swap Mesa HAS validated the new geometry. So: re-query the surface
size after every swap, and if it changed under us, immediately present
again in full at the true size -- the artifact never reaches a vertical
retrace instead of surviving until the next redisplay. One bounded
retry; the steady state pays one cached-state query per swap.
GL_VERIFY_PRESENT=1 is the new debug mode behind this finding: before
the overlays, it reads the repaired back buffer and the FBO back and
reports any mismatch with the present's age and box count -- catching
partial-repair holes at the exact present that produced them.
Verified on the same hardware and compositor: ten maximize/restore
cycles with concurrent scroll storms, free-form resizes mid-scroll and
an M-x burst over 817 partial presents produce zero mismatches; the
unfixed build reproduced the 41% mismatch under the identical load.
Buffer-switch cross-fades measured at 8-10 frames at 60fps (the
configured 150ms), confirming no stale-content ghosts remain beyond
the designed fade.
A window-manager resize reaches the X server before Emacs processes the
ConfigureNotify, so for a few presents the EGL surface is larger than
the FBO and the cached surface size is stale. The present then blitted
at the stale size, leaving the newly exposed region of the freshly
reallocated back buffer undefined -- visible as a black band beside the
old frame's content (the reported black flicker with repeated text)
until the next full redisplay.
Re-query the surface size whenever the buffer age cannot vouch for it:
age 0 (the buffer was just reallocated, which is exactly what a resize
does), an overlay or whole-frame present (those full-blit anyway, and a
full blit at a stale size would not cover a just-resized window), or no
buffer-age support at all. The full blit then stretches over the real
surface, restoring the pre-optimization behavior on every resize path,
and the steady-state partial present still pays no extra round-trips:
with age >= 1 the buffer -- and therefore its size -- is unchanged.
Also stop consuming the dirty set when eglSwapBuffers fails: a failed
swap presented nothing, and recording it would desynchronize the swap
ring from the driver's buffer rotation, making later repairs index the
wrong slots (stale content on screen). The next present retries with
the dirty set intact.
Reproduced and verified in a containerized X session (Xvfb + openbox +
picom, llvmpipe): driving the WM through resizes flashed a one-frame
black band beside stale content before the fix, and a full input sweep
(typing, held arrows, paging, wheel, mouse drags, buffer cross-fades,
splits, isearch, text-scale, occlusion, dired) shows zero dark or
duplicated-content frames after it.
The OpenGL backend lands on GNU/Linux (driver, video, cursor effects,
cross-fade) and both backends gain the batched draw path and, on
OpenGL, the buffer-age partial present.
On-screen 1616x912 (median of 3, vsync off): page scroll reaches parity
with the cairo backend (1.00x) and full-frame redraws are now faster
(1.19x); line scroll 0.92x, image scroll 0.91x, typing 0.71x. At 4K
the render-throughput gap widens further in the GPU's favor
(full redraw 1.84x, line scroll 2.05x, typing 7.4x, image 11.5x).
Update the prose to match: glyphs and fills batch into one draw call,
and the partial present is on by default, driven by EGL_EXT_buffer_age
and eglSwapBuffersWithDamage instead of the unreliable
EGL_BUFFER_PRESERVED (the old GL_PARTIAL_PRESENT escape is gone).
Findings from a review pass over the optimization series:
- Guard degenerate (zero-area) quads at the top of both batch appends:
the proportional UV clip divides by the quad extent, and the Metal
fill path had no caller-side size check, so a zero-width rect under
an active clip produced NaN texture coordinates.
- Cache the GL_NO_DAMAGE / GL_LOG_PRESENT lookups in statics (the
established pattern for GL_NO_VSYNC): they sat on the per-present
path, and getenv scans the environment on every call.
- Move the Metal clip rect from MtlFrameData properties to file
statics: it is read per queued quad, where an objc_msgSend per
access is measurable, and render cycles never interleave, so a
single global pair is correct. Also reverts the header additions.
- Fix the two -Wdouble-promotion / -Wmisleading-indentation warnings
in the animation logging and the video overlay clamps; both drivers
now build warning-free.
- Describe the batch and the buffer-age present in glterm.c's header
comment so the design is visible up front.
Pixel parity re-verified on both backends after the changes
(byte-identical captures).
Glyph quads and solid rectangles now accumulate in one CPU vertex array
and flush as a single draw call, instead of one drawPrimitives (with its
pipeline switch) per glyph and per fill. The rects sample a 4x4 white
block reserved in the atlas -- coverage 1.0 passes the gamma curve
unchanged -- so backgrounds, underlines, boxes and reliefs share the
glyph pipeline and never split the batch. Clipping moves from the
encoder scissor to queue time: each quad is clamped against the glyph
string's clip rect with texture coordinates adjusted proportionally,
letting the batch survive clip changes and cross strings; the
non-batched primitives (images, color glyphs, fringe bitmaps) apply the
recorded clip as a real scissor right before their own draw. Small
batches ride in the command buffer via setVertexBytes, larger ones get
a one-shot shared buffer. The atlas reset paths are centralized in
mtl_atlas_reset, which re-reserves the white block.
Fringe bitmaps are cached by an FNV-1a hash of their rows instead of
creating a texture per call (the old per-call texture was also never
released under MRC -- this fixes that leak). scroll_run uses one
direct blit when source and destination are disjoint (page scrolls),
keeping the scratch bounce only for overlapping moves.
Verified byte-identical to the previous renderer: a scene exercising
font-lock, bold+box, underline (plain and wave), overline,
strike-through, inverse video, region and fringe marks captures
exactly the same staticTexture, and scrolling by copy matches a fresh
repaint of the same window start pixel for pixel.
copy_region: when source and destination do not overlap (page scrolls,
large jumps) one direct blit within the FBO replaces the bounce through
the scratch texture, halving the copied bandwidth; only overlapping
moves (single-line scrolls) still need the intermediate hop.
draw_bitmap: fringe bitmaps are uploaded once and cached by an FNV-1a
hash of their visible rows (the coverage is color-independent, the
color rides on the vertices), instead of creating and destroying a GL
texture on every call.
Also: cache the last frame-data lookup (it runs per drawing op and
scanned the whole slot table each time, and a freed frame now also
drops a stale batch target), skip the explicit glFlush when the frame
is presented immediately (the swap flushes; the deferred path keeps it
so GPU work overlaps the wait), and declare the atlas texture
coordinates highp (2048 texels exceed fp16's exact range on
strict-mediump GPUs; desktop Mesa promotes anyway, mobile would not).
Parity bytes unchanged across the text, scroll and image batteries.
Page scroll reaches 0.99x of the cairo backend, line scroll 0.93x,
image scroll 0.86x on the AMD Renoir test machine.
Solid rectangles now join the glyph batch instead of flushing it: the
atlas reserves a 4x4 white block whose center texel gives coverage 1.0
(the gamma curve passes it unchanged), so a fill is just a quad with
constant texture coordinates in the same program, same texture and same
submission-ordered stream as the glyphs around it. Backgrounds,
underlines, boxes and reliefs no longer split the batch, and the atlas
reset paths are centralized in gl_atlas_reset, which re-reserves the
white block.
Clipping moves from the GL scissor to queue time: each quad is clamped
against the glyph string's clip rect (the same integer bounds the
scissor used, with texture coordinates adjusted proportionally), so a
clip change no longer forces a flush either, and the batch survives
across strings and rows. A full-frame redraw drops from one draw call
per background/glyph-run pair (hundreds) to a handful per frame. The
non-batched primitives (image textures, fringe bitmaps) apply the
recorded clip as a real scissor right before their own draw.
Pixel parity re-verified across the text, decorations, cursor, scroll,
image/fringe and emoji batteries (byte-identical to the previous driver
in the deterministic cases), and the on-screen present still matches
the FBO exactly after a partial-present session. Full-frame redraw
goes from 192 to 290 fps on the AMD Renoir test machine, overtaking
the cairo backend (1.15x) on that workload for the first time;
page scroll reaches 0.95x, line scroll 0.86x.
Replace the EGL_BUFFER_PRESERVED-based partial present (opt-in via
GL_PARTIAL_PRESENT, unreliable because compositing window managers
advertise the behavior without honoring it) with EGL_EXT_buffer_age:
the driver reports how many swaps ago the back buffer was last shown,
which combined with a per-swap record of dirty regions yields exactly
the area the buffer is missing. That is what the driver actually
guarantees about its own buffer rotation, so it is correct under any
compositor, and it is on by default (GL_NO_DAMAGE=1 disables).
Dirty tracking now keeps a small set of disjoint boxes instead of one
union box: a typed character plus the mode line no longer degenerate
into a near-full-frame blit, so a keystroke present blits a few text
rows instead of the whole frame. The swap hands the compositor the
changed boxes through eglSwapBuffersWithDamage so it also recomposites
only those bands. The surface size is cached (two eglQuerySurface
round-trips per present gone), and an idle burst-mode cursor no longer
counts as an active overlay, so the default cursor effect does not
force full-frame presents while typing.
Verified on-screen against the FBO ground truth after a session of
partial presents (typing, cursor jumps, scrolls, splits): zero pixel
difference in the content area. Typing throughput +16%, image scroll
+12%, line scroll +7% on the AMD Renoir test machine.
The GNU/Linux counterpart of the Metal backend: the cross-platform
drawing policy in gfxterm.c now renders through a second gfx_driver
implementation, src/glterm.c, built on EGL + OpenGL ES 3 with glyphs
rasterized by FreeType/cairo into a GPU atlas.
Highlights:
- Pixel parity with the stock GTK/cairo renderer, verified with a
capture harness across text (ASCII/Latin/CJK/emoji/BiDi), faces,
decorations (underline/overline/box/relief), fringes, margins,
line numbers, mode/header/tab lines, images (PNG/JPEG/SVG/GIF),
scroll runs and the four cursor types.
- On-screen present: the frame FBO is blitted to the X window through
an EGL window surface with deferred presents (no flicker); headless
runs render to the FBO alone (GL_FORCE_SURFACELESS for Xvfb).
- Glyph quads are batched into one draw call per state change, and the
present can blit only the dirty region (GL_PARTIAL_PRESENT, opt-in).
- Color glyphs (emoji) rasterized through the cairo scaled font into
RGBA textures; HiDPI supersampling via GL_SCALE.
- Inline video through GStreamer (playbin + appsink): decoded RGBA
frames are uploaded and composited over the frame, following scroll
and clipped to the window (gpu-video-insert / gpu-video-mode).
- Buffer-switch cross-fade and the animated cursor effects (spring,
torpedo, sonicboom, ripple, pixiedust, hollow, beam), composited in
the present pass; sonicboom is the default on this backend.
- --with-gpu configure flag selects the platform backend (Metal on
macOS, OpenGL on X11) and auto-enables it at startup, with
EMACS_GPU_DISABLE as the opt-out.
- gpu-status / gpu-enable / gpu-set-cursor and friends work on both
backends; Metal-only primitives are guarded.
Shared-policy fixes that also benefit the Metal driver: relief edge
order and outermost shadow line now match the native renderers,
fringe bitmaps are clipped to the row's visible band, and the
mouse-face highlight no longer goes stale after a deferred present
(mouse_face_defer is cleared at update_end).
The character drawing path resolved glyphs through face->font, but
char2b holds glyph IDs encoded for s->font. These usually match, so
the bug stayed hidden until a mouse-face highlight over text in a
non-default font (e.g. shr / variable-pitch links in elfeed): Emacs
swaps s->face to the highlight face while leaving s->font and char2b
as the original font, so the highlight redraw looked the proportional
glyph IDs up in the highlight face's monospace atlas and rendered
garbage. Use s->font, matching the NS and X backends and the
composition glyph string path in this file.
Add declare-function forms for the primitives implemented in
src/mtlfns.m so byte-compiling the file without the backend is
warning-free, and capitalize two error messages to satisfy checkdoc.
Add gpu-video-mode: visiting a video file (mp4/mov/m4v/3gp) opens it in
a buffer that autoplays and loops, fit to the window, with a play/pause
button and a clickable, draggable timeline. Registered in
auto-mode-alist so RET in Dired opens it; animated GIFs keep using
image-mode.
Expose the AVPlayer state the controls need: gpu-video-duration,
gpu-video-position, gpu-video-seek, gpu-video-playing-p and
gpu-video-size.
- Auto-load the gpu module and enable the backend on the initial frame at
startup when built --with-mtl; opt out with EMACS_GPU_DISABLE.
- Add autoload cookies to gpu-enable/status/set-cursor/toggle-animations/
set-scroll so they are available via M-x without requiring gpu first.
- Suppress the motion cursor effects (sonicboom/ripple/pixiedust bursts and
the torpedo trail) during typing/editing commands; only cursor movement
and jumps trigger them. Configurable via gpu-cursor-effects-while-typing
and gpu-cursor-typing-commands.
- README: document startup auto-enable, the typing behavior, and the
Ventura+ requirement.
-[NSScreen displayLinkWithTarget:selector:] and CADisplayLink are macOS
14+ APIs. Guard startAnimating/stopAnimating behind @available so the
backend builds and runs with a 13.0 deployment target; continuous
animation is driven by the Lisp 30fps timer anyway. Bump version to 0.1.2.
The gpu-cursor-animation defcustom used :set #'set-default, so changing
the cursor mode at runtime (via gpu-set-cursor / setopt) only updated the
Lisp variable and never reached the live Metal driver. Wire :set to call
gpu-cursor-mode so the change propagates immediately.
Also set the default cursor to 'block (static, no effect); cursor effects
are now opt-in via gpu-set-cursor. Bump version to 0.1.1.
The project is emacs-gpu and the drawing core is platform-neutral; the
user API should not be Metal-branded (a future GNU/Linux user calling
mtl-enable would be absurd). gpu.el carries the whole API under the
gpu- prefix; the mtl- entry points live on as obsolete aliases and
(require 'mtl) still works through a compatibility shim.
C internals keep their accurate names: gfx_* is the neutral policy and
mtl_* is the Metal driver.