perf: remove FFI reflection and per-frame allocations from the hot path (#31)
Bump purego to v0.13.0-bnema.1, purego-vulkan to v0.5.0 and purego-libwayland to v0.5.0: libwayland and Vulkan are now called through fixed-arity zero-allocation syscalls and C calls back into Go without reflection. Vulkan command availability is checked with Has<Command>(). Remove the remaining per-frame allocators: DRM presentation reports and flip Shows reuse immutable snapshots; the frame pacer reuses its maps and slices; the Vulkan renderer reuses draw, cover, damage, fence and semaphore scratch; surface updates are recycled once no queued update references them through deps or prev; input-region traversal no longer allocates a closure. Keep the runtime default GOGC: with per-frame garbage gone, GOGC=50 ran 13 collections (91 ms) on the tiled playback test where 100 runs 2 (3 ms). Add --pprof to serve Go runtime profiles, extend perf-check with allocation guards for every path above, and document how to profile and the reference numbers in docs/performance.md. During 4K HDR tiled video playback CPU drops from 20-30% to 13-14% and the GC share of the profile from 20% to 8.7%; reflection frames are gone. Closes #19
- commit
- 560aaa9992f4cb60ef9282ab4905e5d88efd6348
- author
- bnema · · 2026-09-27 18:52 UTC
- committer
- GitHub · · 2026-09-27 18:52 UTC
- parent
- 541e2c7
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
23 files · +546 -81
M Makefile +1 -1
| @@ -50,7 +50,7 @@ arch: | |||
| 50 | 50 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | |
| 51 | 51 | # Fast, mandatory guards for tiled content publications and SHM copies. | |
| 52 | 52 | perf-check: | |
| 53 | - | CGO_ENABLED=0 go test ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 53 | + | CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | |
| 54 | 54 | check: vet test arch fakes-check perf-check | |
| 55 | 55 | ||
| 56 | 56 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | |
| @@ -50,7 +50,7 @@ arch: | |||||
| 50 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | 50 | $(HOME)/go/bin/hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./... | ||
| 51 | # Fast, mandatory guards for tiled content publications and SHM copies. | 51 | # Fast, mandatory guards for tiled content publications and SHM copies. | ||
| 52 | perf-check: | 52 | perf-check: | ||
| 53 | - | CGO_ENABLED=0 go test ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 | 53 | + | CGO_ENABLED=0 go test ./internal/adapters/drm ./internal/adapters/vulkan ./internal/adapters/wayland -run '^(TestReportSeenAllocations|TestShownBySnapshotAllocations|TestDueFramesAllocations|TestRenderSteadyStateAllocations|TestSceneWalkUnchangedTiledSHM|TestTiledCommitPublishAllocations|TestEffectiveInputEmptyTreeAllocations|TestHeadlessTiledSHMCallbackAndCopyBudget)$$' -count=1 |
| 54 | check: vet test arch fakes-check perf-check | 54 | check: vet test arch fakes-check perf-check | ||
| 55 | 55 | ||||
| 56 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | 56 | # Arch package of the committed HEAD (packaging/arch/PKGBUILD). Go modules | ||
M README.md +1 -0
| @@ -98,6 +98,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil | |||
| 98 | 98 | - [Configuration](docs/config.md): every key, action and default bind, and HDR. | |
| 99 | 99 | - [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts. | |
| 100 | 100 | - [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts. | |
| 101 | + | - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers. | |
| 101 | 102 | ||
| 102 | 103 | ## Why not Rust? | |
| 103 | 104 | ||
| @@ -98,6 +98,7 @@ The file has one `key = value` per line, and `#` starts a comment. A missing fil | |||||
| 98 | - [Configuration](docs/config.md): every key, action and default bind, and HDR. | 98 | - [Configuration](docs/config.md): every key, action and default bind, and HDR. | ||
| 99 | - [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts. | 99 | - [Desktop integration](docs/desktop.md): running commands, clipboard, X11 apps, idle and screen off, bars, and the state file for scripts. | ||
| 100 | - [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts. | 100 | - [Headless mode](docs/headless.md): run without a screen, take screenshots and play input scripts. | ||
| 101 | + | - [Performance](docs/performance.md): how to profile, the allocation guards, and reference numbers. | |||
| 101 | 102 | ||||
| 102 | ## Why not Rust? | 103 | ## Why not Rust? | ||
| 103 | 104 | ||||
M cmd/neferwl/main.go +40 -8
| @@ -6,6 +6,9 @@ import ( | |||
| 6 | 6 | "errors" | |
| 7 | 7 | "flag" | |
| 8 | 8 | "fmt" | |
| 9 | + | "net" | |
| 10 | + | "net/http" | |
| 11 | + | "net/http/pprof" | |
| 9 | 12 | "os" | |
| 10 | 13 | "os/signal" | |
| 11 | 14 | "runtime/debug" | |
| @@ -31,19 +34,40 @@ func main() { | |||
| 31 | 34 | os.Exit(runCode()) | |
| 32 | 35 | } | |
| 33 | 36 | ||
| 34 | - | // tuneGC keeps the heap close to what neferwl uses: a compositor runs for | |
| 35 | - | // days and its per-frame garbage is small, so the default GOGC=100 (heap | |
| 36 | - | // grows to twice the live data) mostly holds freed memory. A soft limit | |
| 37 | - | // makes the collector work harder before the process gets large. GOGC and | |
| 38 | - | // GOMEMLIMIT set in the environment win. | |
| 37 | + | // tuneGC bounds the heap of a process that runs for days. The Go live heap | |
| 38 | + | // is small (tens of MB; buffers live in GPU and shared memory), so the | |
| 39 | + | // default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13 | |
| 40 | + | // collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB | |
| 41 | + | // more heap. A soft limit makes the collector work harder before the | |
| 42 | + | // process gets large. GOGC and GOMEMLIMIT set in the environment win. | |
| 39 | 43 | func tuneGC() { | |
| 40 | - | if os.Getenv("GOGC") == "" { | |
| 41 | - | debug.SetGCPercent(50) | |
| 42 | - | } | |
| 43 | 44 | if os.Getenv("GOMEMLIMIT") == "" { | |
| 44 | 45 | debug.SetMemoryLimit(256 << 20) | |
| 45 | 46 | } | |
| 46 | 47 | } | |
| 48 | + | ||
| 49 | + | // servePprof exposes the runtime profiles under /debug/pprof/ until ctx is | |
| 50 | + | // done. It binds before returning so a bad address fails at startup. | |
| 51 | + | func servePprof(ctx context.Context, addr string) error { | |
| 52 | + | ln, err := net.Listen("tcp", addr) | |
| 53 | + | if err != nil { | |
| 54 | + | return fmt.Errorf("--pprof: %w", err) | |
| 55 | + | } | |
| 56 | + | mux := http.NewServeMux() | |
| 57 | + | mux.HandleFunc("/debug/pprof/", pprof.Index) | |
| 58 | + | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) | |
| 59 | + | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) | |
| 60 | + | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) | |
| 61 | + | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) | |
| 62 | + | srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} | |
| 63 | + | go func() { _ = srv.Serve(ln) }() | |
| 64 | + | go func() { | |
| 65 | + | <-ctx.Done() | |
| 66 | + | _ = srv.Close() | |
| 67 | + | }() | |
| 68 | + | return nil | |
| 69 | + | } | |
| 70 | + | ||
| 47 | 71 | func runCode() int { | |
| 48 | 72 | err := run() | |
| 49 | 73 | if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) { | |
| @@ -123,6 +147,7 @@ func run() error { | |||
| 123 | 147 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)") | |
| 124 | 148 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |
| 125 | 149 | session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | |
| 150 | + | pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060") | |
| 126 | 151 | if err := flags.Parse(os.Args[1:]); err != nil { | |
| 127 | 152 | if errors.Is(err, flag.ErrHelp) { | |
| 128 | 153 | return err | |
| @@ -224,6 +249,13 @@ func run() error { | |||
| 224 | 249 | panic(p) | |
| 225 | 250 | } | |
| 226 | 251 | }() | |
| 252 | + | if *pprofAddr != "" { | |
| 253 | + | if err := servePprof(ctx, *pprofAddr); err != nil { | |
| 254 | + | fmt.Fprintln(os.Stderr, err) | |
| 255 | + | return err | |
| 256 | + | } | |
| 257 | + | log.Info().Str("addr", *pprofAddr).Msg("pprof") | |
| 258 | + | } | |
| 227 | 259 | start := time.Now() | |
| 228 | 260 | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | |
| 229 | 261 | configLog := logging.For(ctx, "config") | |
| @@ -6,6 +6,9 @@ import ( | |||||
| 6 | "errors" | 6 | "errors" | ||
| 7 | "flag" | 7 | "flag" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "net" | |||
| 10 | + | "net/http" | |||
| 11 | + | "net/http/pprof" | |||
| 9 | "os" | 12 | "os" | ||
| 10 | "os/signal" | 13 | "os/signal" | ||
| 11 | "runtime/debug" | 14 | "runtime/debug" | ||
| @@ -31,19 +34,40 @@ func main() { | |||||
| 31 | os.Exit(runCode()) | 34 | os.Exit(runCode()) | ||
| 32 | } | 35 | } | ||
| 33 | 36 | ||||
| 34 | - | // tuneGC keeps the heap close to what neferwl uses: a compositor runs for | 37 | + | // tuneGC bounds the heap of a process that runs for days. The Go live heap |
| 35 | - | // days and its per-frame garbage is small, so the default GOGC=100 (heap | 38 | + | // is small (tens of MB; buffers live in GPU and shared memory), so the |
| 36 | - | // grows to twice the live data) mostly holds freed memory. A soft limit | 39 | + | // default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13 |
| 37 | - | // makes the collector work harder before the process gets large. GOGC and | 40 | + | // collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB |
| 38 | - | // GOMEMLIMIT set in the environment win. | 41 | + | // more heap. A soft limit makes the collector work harder before the |
| 42 | + | // process gets large. GOGC and GOMEMLIMIT set in the environment win. | |||
| 39 | func tuneGC() { | 43 | func tuneGC() { | ||
| 40 | - | if os.Getenv("GOGC") == "" { | |||
| 41 | - | debug.SetGCPercent(50) | |||
| 42 | - | } | |||
| 43 | if os.Getenv("GOMEMLIMIT") == "" { | 44 | if os.Getenv("GOMEMLIMIT") == "" { | ||
| 44 | debug.SetMemoryLimit(256 << 20) | 45 | debug.SetMemoryLimit(256 << 20) | ||
| 45 | } | 46 | } | ||
| 46 | } | 47 | } | ||
| 48 | + | ||||
| 49 | + | // servePprof exposes the runtime profiles under /debug/pprof/ until ctx is | |||
| 50 | + | // done. It binds before returning so a bad address fails at startup. | |||
| 51 | + | func servePprof(ctx context.Context, addr string) error { | |||
| 52 | + | ln, err := net.Listen("tcp", addr) | |||
| 53 | + | if err != nil { | |||
| 54 | + | return fmt.Errorf("--pprof: %w", err) | |||
| 55 | + | } | |||
| 56 | + | mux := http.NewServeMux() | |||
| 57 | + | mux.HandleFunc("/debug/pprof/", pprof.Index) | |||
| 58 | + | mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline) | |||
| 59 | + | mux.HandleFunc("/debug/pprof/profile", pprof.Profile) | |||
| 60 | + | mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol) | |||
| 61 | + | mux.HandleFunc("/debug/pprof/trace", pprof.Trace) | |||
| 62 | + | srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} | |||
| 63 | + | go func() { _ = srv.Serve(ln) }() | |||
| 64 | + | go func() { | |||
| 65 | + | <-ctx.Done() | |||
| 66 | + | _ = srv.Close() | |||
| 67 | + | }() | |||
| 68 | + | return nil | |||
| 69 | + | } | |||
| 70 | + | ||||
| 47 | func runCode() int { | 71 | func runCode() int { | ||
| 48 | err := run() | 72 | err := run() | ||
| 49 | if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) { | 73 | if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) { | ||
| @@ -123,6 +147,7 @@ func run() error { | |||||
| 123 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)") | 147 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all; input-motion logs every pointer motion)") | ||
| 124 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | 148 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | ||
| 125 | session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | 149 | session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready") | ||
| 150 | + | pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060") | |||
| 126 | if err := flags.Parse(os.Args[1:]); err != nil { | 151 | if err := flags.Parse(os.Args[1:]); err != nil { | ||
| 127 | if errors.Is(err, flag.ErrHelp) { | 152 | if errors.Is(err, flag.ErrHelp) { | ||
| 128 | return err | 153 | return err | ||
| @@ -224,6 +249,13 @@ func run() error { | |||||
| 224 | panic(p) | 249 | panic(p) | ||
| 225 | } | 250 | } | ||
| 226 | }() | 251 | }() | ||
| 252 | + | if *pprofAddr != "" { | |||
| 253 | + | if err := servePprof(ctx, *pprofAddr); err != nil { | |||
| 254 | + | fmt.Fprintln(os.Stderr, err) | |||
| 255 | + | return err | |||
| 256 | + | } | |||
| 257 | + | log.Info().Str("addr", *pprofAddr).Msg("pprof") | |||
| 258 | + | } | |||
| 227 | start := time.Now() | 259 | start := time.Now() | ||
| 228 | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | 260 | log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run") | ||
| 229 | configLog := logging.For(ctx, "config") | 261 | configLog := logging.For(ctx, "config") | ||
A docs/performance.md +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | # Performance | |
| 2 | + | ||
| 3 | + | How NeferWL keeps its CPU and memory use low, how to measure it, and the reference numbers to compare against. | |
| 4 | + | ||
| 5 | + | ## Design | |
| 6 | + | ||
| 7 | + | - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts. | |
| 8 | + | - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them. | |
| 9 | + | - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win. | |
| 10 | + | ||
| 11 | + | ## Guards | |
| 12 | + | ||
| 13 | + | `make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, pacer bookkeeping, surface update recycling and input-region traversal. Run it with `make check`. | |
| 14 | + | ||
| 15 | + | ## Measuring | |
| 16 | + | ||
| 17 | + | Profile a running compositor on another TTY or over SSH. | |
| 18 | + | ||
| 19 | + | ```sh | |
| 20 | + | # CPU: where the cycles go (Go and C frames). | |
| 21 | + | perf record -g -p (pgrep -f bin/neferwl) -- sleep 8 | |
| 22 | + | perf report --children --percent-limit 1 | |
| 23 | + | ||
| 24 | + | # Allocations: which Go line allocates how many objects. | |
| 25 | + | neferwl --backend=drm --pprof=localhost:6060 | |
| 26 | + | go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap | |
| 27 | + | ||
| 28 | + | # GC: one line per collection (count, heap before/after, CPU). | |
| 29 | + | GODEBUG=gctrace=1 neferwl --backend=drm | |
| 30 | + | ``` | |
| 31 | + | ||
| 32 | + | `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap. | |
| 33 | + | ||
| 34 | + | ## Reference: 4K HDR tiled video playback | |
| 35 | + | ||
| 36 | + | Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive). | |
| 37 | + | ||
| 38 | + | | Metric | Before (issue #19) | 2026-09-27 | | |
| 39 | + | |---|---|---| | |
| 40 | + | | CPU (top) | 20–30 % | 13–14 % | | |
| 41 | + | | GC (`gcBgMarkWorker`) | 20 % | 8.7 % | | |
| 42 | + | | `runtime.mallocgc` | — | 3.2 % | | |
| 43 | + | | Go → C reflection (`purego.RegisterFunc`) | 46.7 % | 0 | | |
| 44 | + | | C → Go reflection (`reflect.Value.Call`) | ~30 % | 0 | | |
| 45 | + | | Request handlers (`server.dispatch`) | 25.9 % | 27.6 % (real work) | | |
| 46 | + | | Vulkan `Render` | — | 15.3 % | | |
| 47 | + | ||
| 48 | + | Component numbers (linux/amd64, Go 1.27, `CGO_ENABLED=0`): | |
| 49 | + | ||
| 50 | + | | Path | Before | Now | | |
| 51 | + | |---|---|---| | |
| 52 | + | | C → Go callback, 5 args (purego) | 428 ns, 3 allocs | 101 ns, 0 | | |
| 53 | + | | Go → C, 6 args (purego) | 71 ns, 1 alloc | 34 ns, 0 | | |
| 54 | + | | Wayland request dispatch (purego-libwayland) | 27 ns, 1 alloc | 5 ns, 0 | | |
| 55 | + | | Wayland `PostEvent`, 3 args | 344 ns, 5 allocs | 41 ns, 0 | | |
| 56 | + | | Vulkan Reset+Begin+Barrier+End (purego-vulkan) | 6.4 µs, 22 allocs | 1.2 µs, 0 | | |
| 57 | + | | `Render`, tiled scene, per frame | 1321 allocs | 10 allocs | | |
| 58 | + | | Surface update per commit | 2 allocs | 0 (recycled) | | |
| 59 | + | | DRM report / flip `Shows` per flip | 2 maps | 0 (immutable snapshots) | | |
| 60 | + | | GC on tiled playback test | 13 cycles, 91 ms | 2 cycles, 3 ms | | |
| 61 | + | ||
| 62 | + | What remains is compositor work: `surface.Commit` and the fifo logic (~23 %), real C calls (~5 %), the Go scheduler (~8 %). | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | # Performance | |||
| 2 | + | ||||
| 3 | + | How NeferWL keeps its CPU and memory use low, how to measure it, and the reference numbers to compare against. | |||
| 4 | + | ||||
| 5 | + | ## Design | |||
| 6 | + | ||||
| 7 | + | - **No reflection on the FFI boundary.** libwayland and Vulkan are called through `purego.Syscall6/Syscall15` (fixed arity, zero allocation) and C calls back into Go through `purego.NewCallbackInts`. The Vulkan dispatch tables are generated methods; the libwayland wrapper caches resource id, version and client, and precomputes request argument counts. | |||
| 8 | + | - **No allocation per frame or per request on the steady-state path.** Frame callbacks, presentation reports, DRM flips, Vulkan submissions and surface updates reuse owner-goroutine scratch or immutable snapshots. Surface updates (one per `wl_surface.commit`) are recycled once no queued update references them. | |||
| 9 | + | - **Default GOGC.** The Go live heap is a few MB (client buffers live in shared and GPU memory), so the runtime default `GOGC=100` with a 256 MiB soft limit gives 2 collections on the tiled playback test where `GOGC=50` gave 13. `GOGC` and `GOMEMLIMIT` from the environment win. | |||
| 10 | + | ||||
| 11 | + | ## Guards | |||
| 12 | + | ||||
| 13 | + | `make perf-check` runs `testing.AllocsPerRun` guards that fail when a hot path starts allocating: tiled SHM scene walk, tiled commit publication, headless tiled playback, steady-state `Render`, DRM report and flip snapshots, pacer bookkeeping, surface update recycling and input-region traversal. Run it with `make check`. | |||
| 14 | + | ||||
| 15 | + | ## Measuring | |||
| 16 | + | ||||
| 17 | + | Profile a running compositor on another TTY or over SSH. | |||
| 18 | + | ||||
| 19 | + | ```sh | |||
| 20 | + | # CPU: where the cycles go (Go and C frames). | |||
| 21 | + | perf record -g -p (pgrep -f bin/neferwl) -- sleep 8 | |||
| 22 | + | perf report --children --percent-limit 1 | |||
| 23 | + | ||||
| 24 | + | # Allocations: which Go line allocates how many objects. | |||
| 25 | + | neferwl --backend=drm --pprof=localhost:6060 | |||
| 26 | + | go tool pprof -sample_index=alloc_objects -top http://localhost:6060/debug/pprof/heap | |||
| 27 | + | ||||
| 28 | + | # GC: one line per collection (count, heap before/after, CPU). | |||
| 29 | + | GODEBUG=gctrace=1 neferwl --backend=drm | |||
| 30 | + | ``` | |||
| 31 | + | ||||
| 32 | + | `--pprof` also serves `/debug/pprof/profile` (CPU) and `/debug/pprof/trace` (scheduler and GC timeline for `go tool trace`). Escape analysis (`go build -gcflags=-m ./internal/adapters/vulkan`) shows why a value reaches the heap. | |||
| 33 | + | ||||
| 34 | + | ## Reference: 4K HDR tiled video playback | |||
| 35 | + | ||||
| 36 | + | Workload: a browser playing 4K HDR video with 65 tiled subsurfaces committing every frame, DRM backend, `perf record -g` for 8 s. Percentages are the share of the process's own samples (inclusive). | |||
| 37 | + | ||||
| 38 | + | | Metric | Before (issue #19) | 2026-09-27 | | |||
| 39 | + | |---|---|---| | |||
| 40 | + | | CPU (top) | 20–30 % | 13–14 % | | |||
| 41 | + | | GC (`gcBgMarkWorker`) | 20 % | 8.7 % | | |||
| 42 | + | | `runtime.mallocgc` | — | 3.2 % | | |||
| 43 | + | | Go → C reflection (`purego.RegisterFunc`) | 46.7 % | 0 | | |||
| 44 | + | | C → Go reflection (`reflect.Value.Call`) | ~30 % | 0 | | |||
| 45 | + | | Request handlers (`server.dispatch`) | 25.9 % | 27.6 % (real work) | | |||
| 46 | + | | Vulkan `Render` | — | 15.3 % | | |||
| 47 | + | ||||
| 48 | + | Component numbers (linux/amd64, Go 1.27, `CGO_ENABLED=0`): | |||
| 49 | + | ||||
| 50 | + | | Path | Before | Now | | |||
| 51 | + | |---|---|---| | |||
| 52 | + | | C → Go callback, 5 args (purego) | 428 ns, 3 allocs | 101 ns, 0 | | |||
| 53 | + | | Go → C, 6 args (purego) | 71 ns, 1 alloc | 34 ns, 0 | | |||
| 54 | + | | Wayland request dispatch (purego-libwayland) | 27 ns, 1 alloc | 5 ns, 0 | | |||
| 55 | + | | Wayland `PostEvent`, 3 args | 344 ns, 5 allocs | 41 ns, 0 | | |||
| 56 | + | | Vulkan Reset+Begin+Barrier+End (purego-vulkan) | 6.4 µs, 22 allocs | 1.2 µs, 0 | | |||
| 57 | + | | `Render`, tiled scene, per frame | 1321 allocs | 10 allocs | | |||
| 58 | + | | Surface update per commit | 2 allocs | 0 (recycled) | | |||
| 59 | + | | DRM report / flip `Shows` per flip | 2 maps | 0 (immutable snapshots) | | |||
| 60 | + | | GC on tiled playback test | 13 cycles, 91 ms | 2 cycles, 3 ms | | |||
| 61 | + | ||||
| 62 | + | What remains is compositor work: `surface.Commit` and the fifo logic (~23 %), real C calls (~5 %), the Go scheduler (~8 %). | |||
M go.mod +3 -3
| @@ -3,9 +3,9 @@ module github.com/bnema/neferwl | |||
| 3 | 3 | go 1.27 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | - | github.com/bnema/purego v0.12.0-bnema.1 | |
| 7 | - | github.com/bnema/purego-libwayland v0.4.1 | |
| 8 | - | github.com/bnema/purego-vulkan v0.4.2 | |
| 6 | + | github.com/bnema/purego v0.13.0-bnema.1 | |
| 7 | + | github.com/bnema/purego-libwayland v0.5.0 | |
| 8 | + | github.com/bnema/purego-vulkan v0.5.0 | |
| 9 | 9 | github.com/bnema/wlturbo v0.2.0 | |
| 10 | 10 | github.com/bnema/zerowrap v1.4.1 | |
| 11 | 11 | github.com/rs/zerolog v1.35.1 | |
| @@ -3,9 +3,9 @@ module github.com/bnema/neferwl | |||||
| 3 | go 1.27 | 3 | go 1.27 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | - | github.com/bnema/purego v0.12.0-bnema.1 | 6 | + | github.com/bnema/purego v0.13.0-bnema.1 |
| 7 | - | github.com/bnema/purego-libwayland v0.4.1 | 7 | + | github.com/bnema/purego-libwayland v0.5.0 |
| 8 | - | github.com/bnema/purego-vulkan v0.4.2 | 8 | + | github.com/bnema/purego-vulkan v0.5.0 |
| 9 | github.com/bnema/wlturbo v0.2.0 | 9 | github.com/bnema/wlturbo v0.2.0 | ||
| 10 | github.com/bnema/zerowrap v1.4.1 | 10 | github.com/bnema/zerowrap v1.4.1 | ||
| 11 | github.com/rs/zerolog v1.35.1 | 11 | github.com/rs/zerolog v1.35.1 | ||
M go.sum +6 -6
| @@ -1,9 +1,9 @@ | |||
| 1 | - | github.com/bnema/purego v0.12.0-bnema.1 h1:/jQhDj74geYzFg57t6qDvTc2PbU5uEsM93YgzVXJenI= | |
| 2 | - | github.com/bnema/purego v0.12.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |
| 3 | - | github.com/bnema/purego-libwayland v0.4.1 h1:D7N0Gu7M5+iL/RPC9ijGBVMKLRYNR+w/X/n/H6iG/cA= | |
| 4 | - | github.com/bnema/purego-libwayland v0.4.1/go.mod h1:6p9eNaWKaKcnk6lH22sJ9ueMhzv2kirq5P3KY9XGEvc= | |
| 5 | - | github.com/bnema/purego-vulkan v0.4.2 h1:lQKXcSSnrHtU6GouKjVnDsUs1oituLrBEYpJ5y4NR/E= | |
| 6 | - | github.com/bnema/purego-vulkan v0.4.2/go.mod h1:jJOFPhqZANTLVxGbJncUQeiXiv/KPtbvLzDwXR/cTic= | |
| 1 | + | github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8= | |
| 2 | + | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | |
| 3 | + | github.com/bnema/purego-libwayland v0.5.0 h1:+mrp3grCkl1URZSna5nC2ru1g3wixpjjD5Wg0HfKl0o= | |
| 4 | + | github.com/bnema/purego-libwayland v0.5.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg= | |
| 5 | + | github.com/bnema/purego-vulkan v0.5.0 h1:n6FzSEo8Ypmc98EFqLXGxQtSAWc4CtsuoCxV4vB6TWA= | |
| 6 | + | github.com/bnema/purego-vulkan v0.5.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= | |
| 7 | 7 | github.com/bnema/wlturbo v0.2.0 h1:gZPe81TrrrYW8xrbZpEvZnDI7VQBm60+dgAapbXJyRQ= | |
| 8 | 8 | github.com/bnema/wlturbo v0.2.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | |
| 9 | 9 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | |
| @@ -1,9 +1,9 @@ | |||||
| 1 | - | github.com/bnema/purego v0.12.0-bnema.1 h1:/jQhDj74geYzFg57t6qDvTc2PbU5uEsM93YgzVXJenI= | 1 | + | github.com/bnema/purego v0.13.0-bnema.1 h1:fKRdztnvTmRqAjANMoN+utEIH73dvPqVPJh2iZCCAO8= |
| 2 | - | github.com/bnema/purego v0.12.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= | 2 | + | github.com/bnema/purego v0.13.0-bnema.1/go.mod h1:xS1iX3noypxnbOUEUAqOvkfnUzgHuu19VtL4+dfUMJM= |
| 3 | - | github.com/bnema/purego-libwayland v0.4.1 h1:D7N0Gu7M5+iL/RPC9ijGBVMKLRYNR+w/X/n/H6iG/cA= | 3 | + | github.com/bnema/purego-libwayland v0.5.0 h1:+mrp3grCkl1URZSna5nC2ru1g3wixpjjD5Wg0HfKl0o= |
| 4 | - | github.com/bnema/purego-libwayland v0.4.1/go.mod h1:6p9eNaWKaKcnk6lH22sJ9ueMhzv2kirq5P3KY9XGEvc= | 4 | + | github.com/bnema/purego-libwayland v0.5.0/go.mod h1:+2zIEBX+iNlRB27xrm5Z8Dnd+cmqX+1AYvuwguxK2dg= |
| 5 | - | github.com/bnema/purego-vulkan v0.4.2 h1:lQKXcSSnrHtU6GouKjVnDsUs1oituLrBEYpJ5y4NR/E= | 5 | + | github.com/bnema/purego-vulkan v0.5.0 h1:n6FzSEo8Ypmc98EFqLXGxQtSAWc4CtsuoCxV4vB6TWA= |
| 6 | - | github.com/bnema/purego-vulkan v0.4.2/go.mod h1:jJOFPhqZANTLVxGbJncUQeiXiv/KPtbvLzDwXR/cTic= | 6 | + | github.com/bnema/purego-vulkan v0.5.0/go.mod h1:7kFoR1p01JbknuZlfA20oeImryBe8Fx+Zd9hm2uSUM4= |
| 7 | github.com/bnema/wlturbo v0.2.0 h1:gZPe81TrrrYW8xrbZpEvZnDI7VQBm60+dgAapbXJyRQ= | 7 | github.com/bnema/wlturbo v0.2.0 h1:gZPe81TrrrYW8xrbZpEvZnDI7VQBm60+dgAapbXJyRQ= | ||
| 8 | github.com/bnema/wlturbo v0.2.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | 8 | github.com/bnema/wlturbo v0.2.0/go.mod h1:4i1k1wyw7FDWOkh2hg1ZwPfrPRn3+E6WrYg9Qrt74CA= | ||
| 9 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | 9 | github.com/bnema/zerowrap v1.4.1 h1:v70w8LDU2+FU+EX/etY9DjVwOAWN0fUKShwZyZHnyLU= | ||
M internal/adapters/drm/output.go +65 -11
| @@ -93,6 +93,12 @@ type Output struct { | |||
| 93 | 93 | shown, queued uint64 | |
| 94 | 94 | reason string // why the last frame was composed ("" = scanout) | |
| 95 | 95 | unsent []ports.OutputPresented | |
| 96 | + | // Last immutable snapshot sent in a report. Never mutate it: wayland may | |
| 97 | + | // still be reading a prior report on another goroutine. | |
| 98 | + | seenSnapshot map[ports.WindowID]uint64 | |
| 99 | + | showsSnapshot map[ports.WindowID]uint64 | |
| 100 | + | directShowsSnapshot map[ports.WindowID]uint64 | |
| 101 | + | showsScratch map[ports.WindowID]uint64 | |
| 96 | 102 | // Tearing (ADR 006): async commits of scanned-out buffers that ask for | |
| 97 | 103 | // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence | |
| 98 | 104 | // is set when an async commit may carry IN_FENCE_FD (probed once). | |
| @@ -967,6 +973,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 967 | 973 | }() | |
| 968 | 974 | stats := time.NewTicker(10 * time.Second) | |
| 969 | 975 | defer stats.Stop() | |
| 976 | + | // Timer channels are enabled only when their condition holds. Go 1.27 | |
| 977 | + | // guarantees a stopped/reset timer cannot deliver its old value. | |
| 978 | + | retryTimer := time.NewTimer(time.Hour) | |
| 979 | + | vrrTimer := time.NewTimer(time.Hour) | |
| 980 | + | stuckTimer := time.NewTimer(time.Hour) | |
| 981 | + | retryTimer.Stop() | |
| 982 | + | vrrTimer.Stop() | |
| 983 | + | stuckTimer.Stop() | |
| 984 | + | defer retryTimer.Stop() | |
| 985 | + | defer vrrTimer.Stop() | |
| 986 | + | defer stuckTimer.Stop() | |
| 970 | 987 | // seen is the latest content Seq per window. It is reported only while | |
| 971 | 988 | // no frame is in flight: then the GPU has finished every frame that | |
| 972 | 989 | // read older buffers (the kernel waited for their fence to flip). | |
| @@ -989,17 +1006,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 989 | 1006 | // so is one that waits for an uncommitted frame's fence. | |
| 990 | 1007 | var retry <-chan time.Time | |
| 991 | 1008 | if len(o.unsent) > 0 || readWait { | |
| 992 | - | retry = time.After(time.Millisecond) | |
| 1009 | + | retryTimer.Reset(time.Millisecond) | |
| 1010 | + | retry = retryTimer.C | |
| 1011 | + | } else { | |
| 1012 | + | retryTimer.Stop() | |
| 993 | 1013 | } | |
| 994 | 1014 | // A composed screen that stopped changing still drops VRR on time. | |
| 995 | 1015 | var vrrOff <-chan time.Time | |
| 996 | 1016 | if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() { | |
| 997 | - | vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince))) | |
| 1017 | + | vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince))) | |
| 1018 | + | vrrOff = vrrTimer.C | |
| 1019 | + | } else { | |
| 1020 | + | vrrTimer.Stop() | |
| 998 | 1021 | } | |
| 999 | 1022 | // A commit whose event never comes must not stop the output. | |
| 1000 | 1023 | var stuck <-chan time.Time | |
| 1001 | 1024 | if enabled && o.pending { | |
| 1002 | - | stuck = time.After(max(0, time.Until(o.stuckAt))) | |
| 1025 | + | stuckTimer.Reset(max(0, time.Until(o.stuckAt))) | |
| 1026 | + | stuck = stuckTimer.C | |
| 1027 | + | } else { | |
| 1028 | + | stuckTimer.Stop() | |
| 1003 | 1029 | } | |
| 1004 | 1030 | stateDirty := false | |
| 1005 | 1031 | select { | |
| @@ -1144,7 +1170,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1144 | 1170 | start := time.Now() | |
| 1145 | 1171 | // shows is what the frame puts on screen: presentation feedback | |
| 1146 | 1172 | // of windows it does not draw is discarded, not presented. | |
| 1147 | - | f := pendingFrame{shows: shownBy(scene, seen)} | |
| 1173 | + | f := pendingFrame{shows: o.shownBy(scene, seen)} | |
| 1148 | 1174 | var fb uint32 | |
| 1149 | 1175 | var c ports.SurfaceContent | |
| 1150 | 1176 | forceCompose := len(requests) > 0 | |
| @@ -1153,7 +1179,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||
| 1153 | 1179 | } | |
| 1154 | 1180 | direct := false | |
| 1155 | 1181 | if fb != 0 { | |
| 1156 | - | direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}}) | |
| 1182 | + | direct, err = o.commitScanout(fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}) | |
| 1157 | 1183 | } | |
| 1158 | 1184 | if !direct { | |
| 1159 | 1185 | var ov overlayWin | |
| @@ -1312,7 +1338,10 @@ const maxUnsent = 16 | |||
| 1312 | 1338 | // buffers can be released. A flip-less report merges into the newest | |
| 1313 | 1339 | // queued one; flips are never merged unless maxUnsent is reached. | |
| 1314 | 1340 | func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | |
| 1315 | - | r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)} | |
| 1341 | + | if !maps.Equal(o.seenSnapshot, seen) { | |
| 1342 | + | o.seenSnapshot = maps.Clone(seen) | |
| 1343 | + | } | |
| 1344 | + | r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot} | |
| 1316 | 1345 | if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) { | |
| 1317 | 1346 | if flip == nil { | |
| 1318 | 1347 | r.Flip = o.unsent[n-1].Flip | |
| @@ -1325,8 +1354,18 @@ func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | |||
| 1325 | 1354 | old, next := o.unsent[0].Flip, o.unsent[1].Flip | |
| 1326 | 1355 | if next != nil && old != nil { | |
| 1327 | 1356 | next.Merged += 1 + old.Merged | |
| 1357 | + | // A snapshot can be shared with previously sent flips. Clone only | |
| 1358 | + | // when a merge actually needs to add an absent window. | |
| 1359 | + | cloned := false | |
| 1328 | 1360 | for id, seq := range old.Shows { | |
| 1329 | 1361 | if _, ok := next.Shows[id]; !ok { | |
| 1362 | + | if !cloned { | |
| 1363 | + | next.Shows = maps.Clone(next.Shows) | |
| 1364 | + | cloned = true | |
| 1365 | + | } | |
| 1366 | + | if next.Shows == nil { | |
| 1367 | + | next.Shows = make(map[ports.WindowID]uint64) | |
| 1368 | + | } | |
| 1330 | 1369 | next.Shows[id] = seq | |
| 1331 | 1370 | } | |
| 1332 | 1371 | } | |
| @@ -1529,13 +1568,28 @@ func (o *Output) freeImages() { | |||
| 1529 | 1568 | } | |
| 1530 | 1569 | } | |
| 1531 | 1570 | ||
| 1532 | - | // shownBy is the content Seq of each window the scene draws. | |
| 1533 | - | func shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 { | |
| 1534 | - | out := map[ports.WindowID]uint64{} | |
| 1571 | + | // shownBy returns an immutable snapshot of the content Seq of drawn windows. | |
| 1572 | + | // The scratch map is private to the output; a snapshot may still be read by | |
| 1573 | + | // wayland after a subsequent flip, so never refill a previously sent map. | |
| 1574 | + | func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 { | |
| 1575 | + | if o.showsScratch == nil { | |
| 1576 | + | o.showsScratch = make(map[ports.WindowID]uint64) | |
| 1577 | + | } | |
| 1578 | + | clear(o.showsScratch) | |
| 1535 | 1579 | for id, seq := range seen { | |
| 1536 | 1580 | if s.Shows(id) { | |
| 1537 | - | out[id] = seq | |
| 1581 | + | o.showsScratch[id] = seq | |
| 1538 | 1582 | } | |
| 1539 | 1583 | } | |
| 1540 | - | return out | |
| 1584 | + | if !maps.Equal(o.showsSnapshot, o.showsScratch) { | |
| 1585 | + | o.showsSnapshot = maps.Clone(o.showsScratch) | |
| 1586 | + | } | |
| 1587 | + | return o.showsSnapshot | |
| 1588 | + | } | |
| 1589 | + | ||
| 1590 | + | func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 { | |
| 1591 | + | if len(o.directShowsSnapshot) != 1 || o.directShowsSnapshot[id] != seq { | |
| 1592 | + | o.directShowsSnapshot = map[ports.WindowID]uint64{id: seq} | |
| 1593 | + | } | |
| 1594 | + | return o.directShowsSnapshot | |
| 1541 | 1595 | } | |
| @@ -93,6 +93,12 @@ type Output struct { | |||||
| 93 | shown, queued uint64 | 93 | shown, queued uint64 | ||
| 94 | reason string // why the last frame was composed ("" = scanout) | 94 | reason string // why the last frame was composed ("" = scanout) | ||
| 95 | unsent []ports.OutputPresented | 95 | unsent []ports.OutputPresented | ||
| 96 | + | // Last immutable snapshot sent in a report. Never mutate it: wayland may | |||
| 97 | + | // still be reading a prior report on another goroutine. | |||
| 98 | + | seenSnapshot map[ports.WindowID]uint64 | |||
| 99 | + | showsSnapshot map[ports.WindowID]uint64 | |||
| 100 | + | directShowsSnapshot map[ports.WindowID]uint64 | |||
| 101 | + | showsScratch map[ports.WindowID]uint64 | |||
| 96 | // Tearing (ADR 006): async commits of scanned-out buffers that ask for | 102 | // Tearing (ADR 006): async commits of scanned-out buffers that ask for | ||
| 97 | // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence | 103 | // them, when the card has DRM_CAP_ATOMIC_ASYNC_PAGE_FLIP. asyncFence | ||
| 98 | // is set when an async commit may carry IN_FENCE_FD (probed once). | 104 | // is set when an async commit may carry IN_FENCE_FD (probed once). | ||
| @@ -967,6 +973,17 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 967 | }() | 973 | }() | ||
| 968 | stats := time.NewTicker(10 * time.Second) | 974 | stats := time.NewTicker(10 * time.Second) | ||
| 969 | defer stats.Stop() | 975 | defer stats.Stop() | ||
| 976 | + | // Timer channels are enabled only when their condition holds. Go 1.27 | |||
| 977 | + | // guarantees a stopped/reset timer cannot deliver its old value. | |||
| 978 | + | retryTimer := time.NewTimer(time.Hour) | |||
| 979 | + | vrrTimer := time.NewTimer(time.Hour) | |||
| 980 | + | stuckTimer := time.NewTimer(time.Hour) | |||
| 981 | + | retryTimer.Stop() | |||
| 982 | + | vrrTimer.Stop() | |||
| 983 | + | stuckTimer.Stop() | |||
| 984 | + | defer retryTimer.Stop() | |||
| 985 | + | defer vrrTimer.Stop() | |||
| 986 | + | defer stuckTimer.Stop() | |||
| 970 | // seen is the latest content Seq per window. It is reported only while | 987 | // seen is the latest content Seq per window. It is reported only while | ||
| 971 | // no frame is in flight: then the GPU has finished every frame that | 988 | // no frame is in flight: then the GPU has finished every frame that | ||
| 972 | // read older buffers (the kernel waited for their fence to flip). | 989 | // read older buffers (the kernel waited for their fence to flip). | ||
| @@ -989,17 +1006,26 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 989 | // so is one that waits for an uncommitted frame's fence. | 1006 | // so is one that waits for an uncommitted frame's fence. | ||
| 990 | var retry <-chan time.Time | 1007 | var retry <-chan time.Time | ||
| 991 | if len(o.unsent) > 0 || readWait { | 1008 | if len(o.unsent) > 0 || readWait { | ||
| 992 | - | retry = time.After(time.Millisecond) | 1009 | + | retryTimer.Reset(time.Millisecond) |
| 1010 | + | retry = retryTimer.C | |||
| 1011 | + | } else { | |||
| 1012 | + | retryTimer.Stop() | |||
| 993 | } | 1013 | } | ||
| 994 | // A composed screen that stopped changing still drops VRR on time. | 1014 | // A composed screen that stopped changing still drops VRR on time. | ||
| 995 | var vrrOff <-chan time.Time | 1015 | var vrrOff <-chan time.Time | ||
| 996 | if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() { | 1016 | if enabled && o.vrrOn && !o.pending && !o.composedSince.IsZero() { | ||
| 997 | - | vrrOff = time.After(max(0, vrrHold-time.Since(o.composedSince))) | 1017 | + | vrrTimer.Reset(max(0, vrrHold-time.Since(o.composedSince))) |
| 1018 | + | vrrOff = vrrTimer.C | |||
| 1019 | + | } else { | |||
| 1020 | + | vrrTimer.Stop() | |||
| 998 | } | 1021 | } | ||
| 999 | // A commit whose event never comes must not stop the output. | 1022 | // A commit whose event never comes must not stop the output. | ||
| 1000 | var stuck <-chan time.Time | 1023 | var stuck <-chan time.Time | ||
| 1001 | if enabled && o.pending { | 1024 | if enabled && o.pending { | ||
| 1002 | - | stuck = time.After(max(0, time.Until(o.stuckAt))) | 1025 | + | stuckTimer.Reset(max(0, time.Until(o.stuckAt))) |
| 1026 | + | stuck = stuckTimer.C | |||
| 1027 | + | } else { | |||
| 1028 | + | stuckTimer.Stop() | |||
| 1003 | } | 1029 | } | ||
| 1004 | stateDirty := false | 1030 | stateDirty := false | ||
| 1005 | select { | 1031 | select { | ||
| @@ -1144,7 +1170,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1144 | start := time.Now() | 1170 | start := time.Now() | ||
| 1145 | // shows is what the frame puts on screen: presentation feedback | 1171 | // shows is what the frame puts on screen: presentation feedback | ||
| 1146 | // of windows it does not draw is discarded, not presented. | 1172 | // of windows it does not draw is discarded, not presented. | ||
| 1147 | - | f := pendingFrame{shows: shownBy(scene, seen)} | 1173 | + | f := pendingFrame{shows: o.shownBy(scene, seen)} |
| 1148 | var fb uint32 | 1174 | var fb uint32 | ||
| 1149 | var c ports.SurfaceContent | 1175 | var c ports.SurfaceContent | ||
| 1150 | forceCompose := len(requests) > 0 | 1176 | forceCompose := len(requests) > 0 | ||
| @@ -1153,7 +1179,7 @@ func (o *Output) Run(ctx context.Context, newRenderer func(w, h int) (ports.Rend | |||||
| 1153 | } | 1179 | } | ||
| 1154 | direct := false | 1180 | direct := false | ||
| 1155 | if fb != 0 { | 1181 | if fb != 0 { | ||
| 1156 | - | direct, err = o.commitScanout(fb, c, pendingFrame{shows: map[ports.WindowID]uint64{c.ID: seen[c.ID]}}) | 1182 | + | direct, err = o.commitScanout(fb, c, pendingFrame{shows: o.directShownBy(c.ID, seen[c.ID])}) |
| 1157 | } | 1183 | } | ||
| 1158 | if !direct { | 1184 | if !direct { | ||
| 1159 | var ov overlayWin | 1185 | var ov overlayWin | ||
| @@ -1312,7 +1338,10 @@ const maxUnsent = 16 | |||||
| 1312 | // buffers can be released. A flip-less report merges into the newest | 1338 | // buffers can be released. A flip-less report merges into the newest | ||
| 1313 | // queued one; flips are never merged unless maxUnsent is reached. | 1339 | // queued one; flips are never merged unless maxUnsent is reached. | ||
| 1314 | func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | 1340 | func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | ||
| 1315 | - | r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: maps.Clone(seen)} | 1341 | + | if !maps.Equal(o.seenSnapshot, seen) { |
| 1342 | + | o.seenSnapshot = maps.Clone(seen) | |||
| 1343 | + | } | |||
| 1344 | + | r := ports.OutputPresented{Output: o.conn.name, Flip: flip, Shown: o.shown, Queued: o.queued, Seen: o.seenSnapshot} | |||
| 1316 | if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) { | 1345 | if n := len(o.unsent); n > 0 && (flip == nil || o.unsent[n-1].Flip == nil) { | ||
| 1317 | if flip == nil { | 1346 | if flip == nil { | ||
| 1318 | r.Flip = o.unsent[n-1].Flip | 1347 | r.Flip = o.unsent[n-1].Flip | ||
| @@ -1325,8 +1354,18 @@ func (o *Output) report(flip *ports.FlipInfo, seen map[ports.WindowID]uint64) { | |||||
| 1325 | old, next := o.unsent[0].Flip, o.unsent[1].Flip | 1354 | old, next := o.unsent[0].Flip, o.unsent[1].Flip | ||
| 1326 | if next != nil && old != nil { | 1355 | if next != nil && old != nil { | ||
| 1327 | next.Merged += 1 + old.Merged | 1356 | next.Merged += 1 + old.Merged | ||
| 1357 | + | // A snapshot can be shared with previously sent flips. Clone only | |||
| 1358 | + | // when a merge actually needs to add an absent window. | |||
| 1359 | + | cloned := false | |||
| 1328 | for id, seq := range old.Shows { | 1360 | for id, seq := range old.Shows { | ||
| 1329 | if _, ok := next.Shows[id]; !ok { | 1361 | if _, ok := next.Shows[id]; !ok { | ||
| 1362 | + | if !cloned { | |||
| 1363 | + | next.Shows = maps.Clone(next.Shows) | |||
| 1364 | + | cloned = true | |||
| 1365 | + | } | |||
| 1366 | + | if next.Shows == nil { | |||
| 1367 | + | next.Shows = make(map[ports.WindowID]uint64) | |||
| 1368 | + | } | |||
| 1330 | next.Shows[id] = seq | 1369 | next.Shows[id] = seq | ||
| 1331 | } | 1370 | } | ||
| 1332 | } | 1371 | } | ||
| @@ -1529,13 +1568,28 @@ func (o *Output) freeImages() { | |||||
| 1529 | } | 1568 | } | ||
| 1530 | } | 1569 | } | ||
| 1531 | 1570 | ||||
| 1532 | - | // shownBy is the content Seq of each window the scene draws. | 1571 | + | // shownBy returns an immutable snapshot of the content Seq of drawn windows. |
| 1533 | - | func shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 { | 1572 | + | // The scratch map is private to the output; a snapshot may still be read by |
| 1534 | - | out := map[ports.WindowID]uint64{} | 1573 | + | // wayland after a subsequent flip, so never refill a previously sent map. |
| 1574 | + | func (o *Output) shownBy(s ports.Scene, seen map[ports.WindowID]uint64) map[ports.WindowID]uint64 { | |||
| 1575 | + | if o.showsScratch == nil { | |||
| 1576 | + | o.showsScratch = make(map[ports.WindowID]uint64) | |||
| 1577 | + | } | |||
| 1578 | + | clear(o.showsScratch) | |||
| 1535 | for id, seq := range seen { | 1579 | for id, seq := range seen { | ||
| 1536 | if s.Shows(id) { | 1580 | if s.Shows(id) { | ||
| 1537 | - | out[id] = seq | 1581 | + | o.showsScratch[id] = seq |
| 1538 | } | 1582 | } | ||
| 1539 | } | 1583 | } | ||
| 1540 | - | return out | 1584 | + | if !maps.Equal(o.showsSnapshot, o.showsScratch) { |
| 1585 | + | o.showsSnapshot = maps.Clone(o.showsScratch) | |||
| 1586 | + | } | |||
| 1587 | + | return o.showsSnapshot | |||
| 1588 | + | } | |||
| 1589 | + | ||||
| 1590 | + | func (o *Output) directShownBy(id ports.WindowID, seq uint64) map[ports.WindowID]uint64 { | |||
| 1591 | + | if len(o.directShowsSnapshot) != 1 || o.directShowsSnapshot[id] != seq { | |||
| 1592 | + | o.directShowsSnapshot = map[ports.WindowID]uint64{id: seq} | |||
| 1593 | + | } | |||
| 1594 | + | return o.directShowsSnapshot | |||
| 1541 | } | 1595 | } | ||
M internal/adapters/drm/output_test.go +43 -0
| @@ -218,6 +218,49 @@ func TestFlipReportsTimestampAndShownFrame(t *testing.T) { | |||
| 218 | 218 | } | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | + | // Unchanged reports share an immutable snapshot; a later content update | |
| 222 | + | // must not change an earlier report, even after it has been sent. | |
| 223 | + | func TestReportSeenAllocations(t *testing.T) { | |
| 224 | + | o, _, _ := testOutput(t) | |
| 225 | + | seen := map[ports.WindowID]uint64{1: 1, 2: 2} | |
| 226 | + | o.report(nil, seen) | |
| 227 | + | // Keep the queue bounded without measuring the queue's growth. | |
| 228 | + | if allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) }); allocs != 0 { | |
| 229 | + | t.Errorf("unchanged report: %.1f allocs, want 0", allocs) | |
| 230 | + | } else { | |
| 231 | + | t.Logf("unchanged report: %.1f allocs", allocs) | |
| 232 | + | } | |
| 233 | + | previous := o.unsent[0].Seen | |
| 234 | + | seen[1] = 3 | |
| 235 | + | o.report(nil, seen) | |
| 236 | + | if previous[1] != 1 || o.unsent[0].Seen[1] != 3 { | |
| 237 | + | t.Fatalf("snapshot mutated: old %v new %v", previous, o.unsent[0].Seen) | |
| 238 | + | } | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func TestShownBySnapshotAllocations(t *testing.T) { | |
| 242 | + | o := &Output{} | |
| 243 | + | s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}}} | |
| 244 | + | seen := map[ports.WindowID]uint64{1: 1, 2: 2} | |
| 245 | + | previous := o.shownBy(s, seen) | |
| 246 | + | if allocs := testing.AllocsPerRun(100, func() { o.shownBy(s, seen) }); allocs != 0 { | |
| 247 | + | t.Errorf("unchanged shows: %.1f allocs, want 0", allocs) | |
| 248 | + | } | |
| 249 | + | seen[1] = 3 | |
| 250 | + | current := o.shownBy(s, seen) | |
| 251 | + | if previous[1] != 1 || current[1] != 3 { | |
| 252 | + | t.Fatalf("snapshot mutated: old %v new %v", previous, current) | |
| 253 | + | } | |
| 254 | + | direct := o.directShownBy(1, 3) | |
| 255 | + | if allocs := testing.AllocsPerRun(100, func() { o.directShownBy(1, 3) }); allocs != 0 { | |
| 256 | + | t.Errorf("unchanged direct shows: %.1f allocs, want 0", allocs) | |
| 257 | + | } | |
| 258 | + | o.directShownBy(1, 4) | |
| 259 | + | if direct[1] != 3 { | |
| 260 | + | t.Fatalf("direct snapshot mutated: %v", direct) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 221 | 264 | func TestReportsKeepEveryFlip(t *testing.T) { | |
| 222 | 265 | o, _, _ := testOutput(t) | |
| 223 | 266 | seen := map[ports.WindowID]uint64{1: 1} | |
| @@ -218,6 +218,49 @@ func TestFlipReportsTimestampAndShownFrame(t *testing.T) { | |||||
| 218 | } | 218 | } | ||
| 219 | } | 219 | } | ||
| 220 | 220 | ||||
| 221 | + | // Unchanged reports share an immutable snapshot; a later content update | |||
| 222 | + | // must not change an earlier report, even after it has been sent. | |||
| 223 | + | func TestReportSeenAllocations(t *testing.T) { | |||
| 224 | + | o, _, _ := testOutput(t) | |||
| 225 | + | seen := map[ports.WindowID]uint64{1: 1, 2: 2} | |||
| 226 | + | o.report(nil, seen) | |||
| 227 | + | // Keep the queue bounded without measuring the queue's growth. | |||
| 228 | + | if allocs := testing.AllocsPerRun(100, func() { o.report(nil, seen) }); allocs != 0 { | |||
| 229 | + | t.Errorf("unchanged report: %.1f allocs, want 0", allocs) | |||
| 230 | + | } else { | |||
| 231 | + | t.Logf("unchanged report: %.1f allocs", allocs) | |||
| 232 | + | } | |||
| 233 | + | previous := o.unsent[0].Seen | |||
| 234 | + | seen[1] = 3 | |||
| 235 | + | o.report(nil, seen) | |||
| 236 | + | if previous[1] != 1 || o.unsent[0].Seen[1] != 3 { | |||
| 237 | + | t.Fatalf("snapshot mutated: old %v new %v", previous, o.unsent[0].Seen) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func TestShownBySnapshotAllocations(t *testing.T) { | |||
| 242 | + | o := &Output{} | |||
| 243 | + | s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}}} | |||
| 244 | + | seen := map[ports.WindowID]uint64{1: 1, 2: 2} | |||
| 245 | + | previous := o.shownBy(s, seen) | |||
| 246 | + | if allocs := testing.AllocsPerRun(100, func() { o.shownBy(s, seen) }); allocs != 0 { | |||
| 247 | + | t.Errorf("unchanged shows: %.1f allocs, want 0", allocs) | |||
| 248 | + | } | |||
| 249 | + | seen[1] = 3 | |||
| 250 | + | current := o.shownBy(s, seen) | |||
| 251 | + | if previous[1] != 1 || current[1] != 3 { | |||
| 252 | + | t.Fatalf("snapshot mutated: old %v new %v", previous, current) | |||
| 253 | + | } | |||
| 254 | + | direct := o.directShownBy(1, 3) | |||
| 255 | + | if allocs := testing.AllocsPerRun(100, func() { o.directShownBy(1, 3) }); allocs != 0 { | |||
| 256 | + | t.Errorf("unchanged direct shows: %.1f allocs, want 0", allocs) | |||
| 257 | + | } | |||
| 258 | + | o.directShownBy(1, 4) | |||
| 259 | + | if direct[1] != 3 { | |||
| 260 | + | t.Fatalf("direct snapshot mutated: %v", direct) | |||
| 261 | + | } | |||
| 262 | + | } | |||
| 263 | + | ||||
| 221 | func TestReportsKeepEveryFlip(t *testing.T) { | 264 | func TestReportsKeepEveryFlip(t *testing.T) { | ||
| 222 | o, _, _ := testOutput(t) | 265 | o, _, _ := testOutput(t) | ||
| 223 | seen := map[ports.WindowID]uint64{1: 1} | 266 | seen := map[ports.WindowID]uint64{1: 1} | ||
M internal/adapters/drm/scanout_test.go +1 -1
| @@ -224,7 +224,7 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) { | |||
| 224 | 224 | // a window on another workspace or hidden is discarded, not presented. | |
| 225 | 225 | func TestShownByScene(t *testing.T) { | |
| 226 | 226 | s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}} | |
| 227 | - | got := shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7}) | |
| 227 | + | got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7}) | |
| 228 | 228 | if len(got) != 2 || got[1] != 3 || got[5] != 6 { | |
| 229 | 229 | t.Fatalf("shows %v", got) | |
| 230 | 230 | } | |
| @@ -224,7 +224,7 @@ func TestPlaneWithoutInFormatsIsLinear(t *testing.T) { | |||||
| 224 | // a window on another workspace or hidden is discarded, not presented. | 224 | // a window on another workspace or hidden is discarded, not presented. | ||
| 225 | func TestShownByScene(t *testing.T) { | 225 | func TestShownByScene(t *testing.T) { | ||
| 226 | s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}} | 226 | s := ports.Scene{Windows: []ports.SceneWindow{{ID: 1}, {ID: 2, Hidden: true}}, Layers: []ports.SceneLayer{{ID: 5}}} | ||
| 227 | - | got := shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7}) | 227 | + | got := (&Output{}).shownBy(s, map[ports.WindowID]uint64{1: 3, 2: 4, 5: 6, 9: 7}) |
| 228 | if len(got) != 2 || got[1] != 3 || got[5] != 6 { | 228 | if len(got) != 2 || got[1] != 3 || got[5] != 6 { | ||
| 229 | t.Fatalf("shows %v", got) | 229 | t.Fatalf("shows %v", got) | ||
| 230 | } | 230 | } | ||
M internal/adapters/vulkan/cursor.go +1 -1
| @@ -32,7 +32,7 @@ const fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24 | |||
| 32 | 32 | func (r *Renderer) CursorBuffers(size int) ([2]ports.DMABuf, error) { | |
| 33 | 33 | var out [2]ports.DMABuf | |
| 34 | 34 | r.dropCursors() | |
| 35 | - | if r.dd.GetMemoryFdKHR == nil || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 { | |
| 35 | + | if !r.dd.HasGetMemoryFdKHR() || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 { | |
| 36 | 36 | return out, errors.New("device cannot export cursor images") | |
| 37 | 37 | } | |
| 38 | 38 | for i := range r.cursors { | |
| @@ -32,7 +32,7 @@ const fourccARGB = 'A' | 'R'<<8 | '2'<<16 | '4'<<24 | |||||
| 32 | func (r *Renderer) CursorBuffers(size int) ([2]ports.DMABuf, error) { | 32 | func (r *Renderer) CursorBuffers(size int) ([2]ports.DMABuf, error) { | ||
| 33 | var out [2]ports.DMABuf | 33 | var out [2]ports.DMABuf | ||
| 34 | r.dropCursors() | 34 | r.dropCursors() | ||
| 35 | - | if r.dd.GetMemoryFdKHR == nil || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 { | 35 | + | if !r.dd.HasGetMemoryFdKHR() || len(r.dmabuf.Formats) == 0 || size <= 0 || size > 512 { |
| 36 | return out, errors.New("device cannot export cursor images") | 36 | return out, errors.New("device cannot export cursor images") | ||
| 37 | } | 37 | } | ||
| 38 | for i := range r.cursors { | 38 | for i := range r.cursors { | ||
M internal/adapters/vulkan/damage.go +18 -0
| @@ -39,6 +39,24 @@ func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion | |||
| 39 | 39 | return d | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | // frameDamage reuses the bookkeeping maps. drawn is swapped with the | |
| 43 | + | // target's previous map after hold, so a target never observes mutations. | |
| 44 | + | func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion { | |
| 45 | + | if r.damageSeen == nil { | |
| 46 | + | r.damageSeen = make(map[ports.WindowID]bool) | |
| 47 | + | } | |
| 48 | + | clear(r.damageSeen) | |
| 49 | + | if r.damageDrawn == nil { | |
| 50 | + | r.damageDrawn = make(map[ports.WindowID]heldWindow) | |
| 51 | + | } | |
| 52 | + | clear(r.damageDrawn) | |
| 53 | + | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds} | |
| 54 | + | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | |
| 55 | + | r.damage.held = tg | |
| 56 | + | } | |
| 57 | + | return &r.damage | |
| 58 | + | } | |
| 59 | + | ||
| 42 | 60 | // all reports whether the whole target is redrawn. | |
| 43 | 61 | func (d *damageRegion) all() bool { return d.held == nil } | |
| 44 | 62 | ||
| @@ -39,6 +39,24 @@ func newDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion | |||||
| 39 | return d | 39 | return d | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | // frameDamage reuses the bookkeeping maps. drawn is swapped with the | |||
| 43 | + | // target's previous map after hold, so a target never observes mutations. | |||
| 44 | + | func (r *Renderer) frameDamage(tg *target, s ports.Scene, bounds image.Rectangle) *damageRegion { | |||
| 45 | + | if r.damageSeen == nil { | |||
| 46 | + | r.damageSeen = make(map[ports.WindowID]bool) | |||
| 47 | + | } | |||
| 48 | + | clear(r.damageSeen) | |||
| 49 | + | if r.damageDrawn == nil { | |||
| 50 | + | r.damageDrawn = make(map[ports.WindowID]heldWindow) | |||
| 51 | + | } | |||
| 52 | + | clear(r.damageDrawn) | |||
| 53 | + | r.damage = damageRegion{seen: r.damageSeen, drawn: r.damageDrawn, bound: bounds} | |||
| 54 | + | if tg.valid && s.Seq != 0 && tg.sceneSeq == s.Seq { | |||
| 55 | + | r.damage.held = tg | |||
| 56 | + | } | |||
| 57 | + | return &r.damage | |||
| 58 | + | } | |||
| 59 | + | ||||
| 42 | // all reports whether the whole target is redrawn. | 60 | // all reports whether the whole target is redrawn. | ||
| 43 | func (d *damageRegion) all() bool { return d.held == nil } | 61 | func (d *damageRegion) all() bool { return d.held == nil } | ||
| 44 | 62 | ||||
M internal/adapters/vulkan/frame.go +23 -6
| @@ -176,7 +176,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 176 | 176 | if hdr { | |
| 177 | 177 | tg = &r.hdrOwn | |
| 178 | 178 | } | |
| 179 | - | dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | |
| 179 | + | dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | |
| 180 | 180 | ds := r.draws(s, contents, dmg) | |
| 181 | 181 | dmg.finish() | |
| 182 | 182 | r.dropPools() | |
| @@ -184,7 +184,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 184 | 184 | if partial { | |
| 185 | 185 | // The pass keeps the image: repaint the background under the | |
| 186 | 186 | // region, then everything clipped to it. | |
| 187 | - | ds = dmg.clip(append([]draw{r.fillDraw(dmg.area, parseColor(s.Background))}, ds...)) | |
| 187 | + | // Keep the background first without allocating a second draw slice. | |
| 188 | + | ds = append(ds, draw{}) | |
| 189 | + | copy(ds[1:], ds[:len(ds)-1]) | |
| 190 | + | ds[0] = r.fillDraw(dmg.area, parseColor(s.Background)) | |
| 191 | + | ds = dmg.clip(ds) | |
| 192 | + | r.scratchDraws = ds | |
| 188 | 193 | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | |
| 189 | 194 | } else { | |
| 190 | 195 | r.redrawn += r.width * r.height | |
| @@ -212,8 +217,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 212 | 217 | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | |
| 213 | 218 | // Client buffers come from the foreign queue family (their driver) | |
| 214 | 219 | // and go back to it after the frame, so the next frame acquires them. | |
| 215 | - | var dmas []*imported | |
| 216 | - | acquires := map[*imported]*os.File{} | |
| 220 | + | dmas := r.frameDMAs[:0] | |
| 221 | + | if r.frameAcquires == nil { | |
| 222 | + | r.frameAcquires = make(map[*imported]*os.File) | |
| 223 | + | } | |
| 224 | + | clear(r.frameAcquires) | |
| 225 | + | acquires := r.frameAcquires | |
| 217 | 226 | for _, dr := range ds { | |
| 218 | 227 | if dr.im != nil && !slices.Contains(dmas, dr.im) { | |
| 219 | 228 | dmas = append(dmas, dr.im) | |
| @@ -223,6 +232,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 223 | 232 | acquires[dr.im] = dr.acquire | |
| 224 | 233 | } | |
| 225 | 234 | } | |
| 235 | + | r.frameDMAs = dmas | |
| 226 | 236 | r.ownership(cmd, dmas, true) | |
| 227 | 237 | r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial) | |
| 228 | 238 | r.ownership(cmd, dmas, false) | |
| @@ -247,7 +257,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 247 | 257 | } | |
| 248 | 258 | // Wait for the client's GPU writes to each buffer: its explicit | |
| 249 | 259 | // acquire fence, else the buffer's implicit fences. | |
| 250 | - | var waits []vk.Semaphore | |
| 260 | + | waits := r.frameWaits[:0] | |
| 251 | 261 | for _, im := range dmas { | |
| 252 | 262 | if f := acquires[im]; f != nil { | |
| 253 | 263 | if sem := r.importFence(f); sem != 0 { | |
| @@ -257,12 +267,17 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 257 | 267 | waits = r.appendReadFences(waits, im) | |
| 258 | 268 | } | |
| 259 | 269 | } | |
| 270 | + | r.frameWaits = waits | |
| 260 | 271 | frame := r.frame | |
| 261 | 272 | for _, sem := range waits { | |
| 273 | + | sem := sem | |
| 262 | 274 | r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) }) | |
| 263 | 275 | } | |
| 264 | 276 | submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd} | |
| 265 | - | stages := make([]vk.PipelineStageFlags, len(waits)) | |
| 277 | + | if cap(r.frameStages) < len(waits) { | |
| 278 | + | r.frameStages = make([]vk.PipelineStageFlags, len(waits)) | |
| 279 | + | } | |
| 280 | + | stages := r.frameStages[:len(waits)] | |
| 266 | 281 | if len(waits) > 0 { | |
| 267 | 282 | for i := range stages { | |
| 268 | 283 | stages[i] = vk.PipelineStageFragmentShaderBit | |
| @@ -285,7 +300,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||
| 285 | 300 | } else { | |
| 286 | 301 | tg.layout = b.NewLayout | |
| 287 | 302 | } | |
| 303 | + | oldWindows := tg.windows | |
| 288 | 304 | tg.hold(s, dmg) | |
| 305 | + | r.damageDrawn = oldWindows | |
| 289 | 306 | r.last, r.readBack = tg, false | |
| 290 | 307 | r.dropUnused() | |
| 291 | 308 | r.dropShm() | |
| @@ -176,7 +176,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 176 | if hdr { | 176 | if hdr { | ||
| 177 | tg = &r.hdrOwn | 177 | tg = &r.hdrOwn | ||
| 178 | } | 178 | } | ||
| 179 | - | dmg := newDamage(tg, s, image.Rect(0, 0, r.width, r.height)) | 179 | + | dmg := r.frameDamage(tg, s, image.Rect(0, 0, r.width, r.height)) |
| 180 | ds := r.draws(s, contents, dmg) | 180 | ds := r.draws(s, contents, dmg) | ||
| 181 | dmg.finish() | 181 | dmg.finish() | ||
| 182 | r.dropPools() | 182 | r.dropPools() | ||
| @@ -184,7 +184,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 184 | if partial { | 184 | if partial { | ||
| 185 | // The pass keeps the image: repaint the background under the | 185 | // The pass keeps the image: repaint the background under the | ||
| 186 | // region, then everything clipped to it. | 186 | // region, then everything clipped to it. | ||
| 187 | - | ds = dmg.clip(append([]draw{r.fillDraw(dmg.area, parseColor(s.Background))}, ds...)) | 187 | + | // Keep the background first without allocating a second draw slice. |
| 188 | + | ds = append(ds, draw{}) | |||
| 189 | + | copy(ds[1:], ds[:len(ds)-1]) | |||
| 190 | + | ds[0] = r.fillDraw(dmg.area, parseColor(s.Background)) | |||
| 191 | + | ds = dmg.clip(ds) | |||
| 192 | + | r.scratchDraws = ds | |||
| 188 | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | 193 | r.redrawn += dmg.area.Dx() * dmg.area.Dy() | ||
| 189 | } else { | 194 | } else { | ||
| 190 | r.redrawn += r.width * r.height | 195 | r.redrawn += r.width * r.height | ||
| @@ -212,8 +217,12 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 212 | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | 217 | d.CmdPipelineBarrier(cmd, vk.PipelineStageTransferBit|vk.PipelineStageColorAttachmentOutputBit, vk.PipelineStageColorAttachmentOutputBit, 0, 0, nil, 0, nil, 1, &b) | ||
| 213 | // Client buffers come from the foreign queue family (their driver) | 218 | // Client buffers come from the foreign queue family (their driver) | ||
| 214 | // and go back to it after the frame, so the next frame acquires them. | 219 | // and go back to it after the frame, so the next frame acquires them. | ||
| 215 | - | var dmas []*imported | 220 | + | dmas := r.frameDMAs[:0] |
| 216 | - | acquires := map[*imported]*os.File{} | 221 | + | if r.frameAcquires == nil { |
| 222 | + | r.frameAcquires = make(map[*imported]*os.File) | |||
| 223 | + | } | |||
| 224 | + | clear(r.frameAcquires) | |||
| 225 | + | acquires := r.frameAcquires | |||
| 217 | for _, dr := range ds { | 226 | for _, dr := range ds { | ||
| 218 | if dr.im != nil && !slices.Contains(dmas, dr.im) { | 227 | if dr.im != nil && !slices.Contains(dmas, dr.im) { | ||
| 219 | dmas = append(dmas, dr.im) | 228 | dmas = append(dmas, dr.im) | ||
| @@ -223,6 +232,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 223 | acquires[dr.im] = dr.acquire | 232 | acquires[dr.im] = dr.acquire | ||
| 224 | } | 233 | } | ||
| 225 | } | 234 | } | ||
| 235 | + | r.frameDMAs = dmas | |||
| 226 | r.ownership(cmd, dmas, true) | 236 | r.ownership(cmd, dmas, true) | ||
| 227 | r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial) | 237 | r.recordDraws(cmd, tg.view, parseColor(s.Background), ds, partial) | ||
| 228 | r.ownership(cmd, dmas, false) | 238 | r.ownership(cmd, dmas, false) | ||
| @@ -247,7 +257,7 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 247 | } | 257 | } | ||
| 248 | // Wait for the client's GPU writes to each buffer: its explicit | 258 | // Wait for the client's GPU writes to each buffer: its explicit | ||
| 249 | // acquire fence, else the buffer's implicit fences. | 259 | // acquire fence, else the buffer's implicit fences. | ||
| 250 | - | var waits []vk.Semaphore | 260 | + | waits := r.frameWaits[:0] |
| 251 | for _, im := range dmas { | 261 | for _, im := range dmas { | ||
| 252 | if f := acquires[im]; f != nil { | 262 | if f := acquires[im]; f != nil { | ||
| 253 | if sem := r.importFence(f); sem != 0 { | 263 | if sem := r.importFence(f); sem != 0 { | ||
| @@ -257,12 +267,17 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 257 | waits = r.appendReadFences(waits, im) | 267 | waits = r.appendReadFences(waits, im) | ||
| 258 | } | 268 | } | ||
| 259 | } | 269 | } | ||
| 270 | + | r.frameWaits = waits | |||
| 260 | frame := r.frame | 271 | frame := r.frame | ||
| 261 | for _, sem := range waits { | 272 | for _, sem := range waits { | ||
| 273 | + | sem := sem | |||
| 262 | r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) }) | 274 | r.retire(frame, func() { d.DestroySemaphore(r.device, sem, nil) }) | ||
| 263 | } | 275 | } | ||
| 264 | submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd} | 276 | submit := vk.SubmitInfo{SType: vk.StructureTypeSubmitInfo, CommandBufferCount: 1, CommandBuffers: &cmd} | ||
| 265 | - | stages := make([]vk.PipelineStageFlags, len(waits)) | 277 | + | if cap(r.frameStages) < len(waits) { |
| 278 | + | r.frameStages = make([]vk.PipelineStageFlags, len(waits)) | |||
| 279 | + | } | |||
| 280 | + | stages := r.frameStages[:len(waits)] | |||
| 266 | if len(waits) > 0 { | 281 | if len(waits) > 0 { | ||
| 267 | for i := range stages { | 282 | for i := range stages { | ||
| 268 | stages[i] = vk.PipelineStageFragmentShaderBit | 283 | stages[i] = vk.PipelineStageFragmentShaderBit | ||
| @@ -285,7 +300,9 @@ func (r *Renderer) Render(s ports.Scene, contents map[ports.WindowID]ports.Surfa | |||||
| 285 | } else { | 300 | } else { | ||
| 286 | tg.layout = b.NewLayout | 301 | tg.layout = b.NewLayout | ||
| 287 | } | 302 | } | ||
| 303 | + | oldWindows := tg.windows | |||
| 288 | tg.hold(s, dmg) | 304 | tg.hold(s, dmg) | ||
| 305 | + | r.damageDrawn = oldWindows | |||
| 289 | r.last, r.readBack = tg, false | 306 | r.last, r.readBack = tg, false | ||
| 290 | r.dropUnused() | 307 | r.dropUnused() | ||
| 291 | r.dropShm() | 308 | r.dropShm() | ||
M internal/adapters/vulkan/renderer.go +11 -1
| @@ -75,6 +75,16 @@ type Renderer struct { | |||
| 75 | 75 | // storage buffer binding. | |
| 76 | 76 | compose composer | |
| 77 | 77 | maxRange int | |
| 78 | + | // Scratch belongs to the renderer goroutine; neither slice is published. | |
| 79 | + | scratchDraws []draw | |
| 80 | + | scratchCovers []image.Rectangle | |
| 81 | + | damage damageRegion | |
| 82 | + | damageSeen map[ports.WindowID]bool | |
| 83 | + | damageDrawn map[ports.WindowID]heldWindow | |
| 84 | + | frameDMAs []*imported | |
| 85 | + | frameAcquires map[*imported]*os.File | |
| 86 | + | frameWaits []vk.Semaphore | |
| 87 | + | frameStages []vk.PipelineStageFlags | |
| 78 | 88 | } | |
| 79 | 89 | ||
| 80 | 90 | func checked(name string, result vk.Result) error { | |
| @@ -200,7 +210,7 @@ func New(width, height int) (r *Renderer, err error) { | |||
| 200 | 210 | } | |
| 201 | 211 | // VK_KHR_external_semaphore_fd (enabled above): frames export | |
| 202 | 212 | // their fence as a sync file. | |
| 203 | - | r.syncFD = r.dd.GetSemaphoreFdKHR != nil | |
| 213 | + | r.syncFD = r.dd.HasGetSemaphoreFdKHR() | |
| 204 | 214 | } | |
| 205 | 215 | r.dd.GetDeviceQueue(r.device, family, 0, &r.queue) | |
| 206 | 216 | extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1} | |
| @@ -75,6 +75,16 @@ type Renderer struct { | |||||
| 75 | // storage buffer binding. | 75 | // storage buffer binding. | ||
| 76 | compose composer | 76 | compose composer | ||
| 77 | maxRange int | 77 | maxRange int | ||
| 78 | + | // Scratch belongs to the renderer goroutine; neither slice is published. | |||
| 79 | + | scratchDraws []draw | |||
| 80 | + | scratchCovers []image.Rectangle | |||
| 81 | + | damage damageRegion | |||
| 82 | + | damageSeen map[ports.WindowID]bool | |||
| 83 | + | damageDrawn map[ports.WindowID]heldWindow | |||
| 84 | + | frameDMAs []*imported | |||
| 85 | + | frameAcquires map[*imported]*os.File | |||
| 86 | + | frameWaits []vk.Semaphore | |||
| 87 | + | frameStages []vk.PipelineStageFlags | |||
| 78 | } | 88 | } | ||
| 79 | 89 | ||||
| 80 | func checked(name string, result vk.Result) error { | 90 | func checked(name string, result vk.Result) error { | ||
| @@ -200,7 +210,7 @@ func New(width, height int) (r *Renderer, err error) { | |||||
| 200 | } | 210 | } | ||
| 201 | // VK_KHR_external_semaphore_fd (enabled above): frames export | 211 | // VK_KHR_external_semaphore_fd (enabled above): frames export | ||
| 202 | // their fence as a sync file. | 212 | // their fence as a sync file. | ||
| 203 | - | r.syncFD = r.dd.GetSemaphoreFdKHR != nil | 213 | + | r.syncFD = r.dd.HasGetSemaphoreFdKHR() |
| 204 | } | 214 | } | ||
| 205 | r.dd.GetDeviceQueue(r.device, family, 0, &r.queue) | 215 | r.dd.GetDeviceQueue(r.device, family, 0, &r.queue) | ||
| 206 | extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1} | 216 | extent := vk.Extent3D{Width: uint32(width), Height: uint32(height), Depth: 1} | ||
M internal/adapters/vulkan/scene.go +6 -2
| @@ -24,7 +24,9 @@ type sceneWalk struct { | |||
| 24 | 24 | ||
| 25 | 25 | // draws walks the scene into quads in paint order. | |
| 26 | 26 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | |
| 27 | - | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height)} | |
| 27 | + | clear(r.scratchDraws) // release references from longer earlier scenes | |
| 28 | + | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} | |
| 29 | + | r.scratchCovers = r.scratchCovers[:0] | |
| 28 | 30 | if w.scale <= 0 { | |
| 29 | 31 | w.scale = 1 | |
| 30 | 32 | } | |
| @@ -36,6 +38,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||
| 36 | 38 | w.popups(false) | |
| 37 | 39 | w.layers(true) | |
| 38 | 40 | w.popups(true) | |
| 41 | + | r.scratchDraws = w.draws | |
| 39 | 42 | return w.draws | |
| 40 | 43 | } | |
| 41 | 44 | ||
| @@ -160,7 +163,7 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||
| 160 | 163 | // left out, so the surface below it still shows: a dmabuf that fails to | |
| 161 | 164 | // import (opaqueChildren pre-imports it) or an unreadable shm buffer. | |
| 162 | 165 | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle { | |
| 163 | - | var covers []image.Rectangle | |
| 166 | + | covers := w.r.scratchCovers[:0] | |
| 164 | 167 | for i := range content.Children { | |
| 165 | 168 | ch := &content.Children[i] | |
| 166 | 169 | if ch.Below || !ch.Opaque { | |
| @@ -172,6 +175,7 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, cl | |||
| 172 | 175 | } | |
| 173 | 176 | covers = append(covers, dst) | |
| 174 | 177 | } | |
| 178 | + | w.r.scratchCovers = covers | |
| 175 | 179 | return covers | |
| 176 | 180 | } | |
| 177 | 181 | ||
| @@ -24,7 +24,9 @@ type sceneWalk struct { | |||||
| 24 | 24 | ||||
| 25 | // draws walks the scene into quads in paint order. | 25 | // draws walks the scene into quads in paint order. | ||
| 26 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | 26 | func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.SurfaceContent, dmg *damageRegion) []draw { | ||
| 27 | - | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height)} | 27 | + | clear(r.scratchDraws) // release references from longer earlier scenes |
| 28 | + | w := &sceneWalk{r: r, s: s, contents: contents, dmg: dmg, scale: s.Scale, bounds: image.Rect(0, 0, r.width, r.height), draws: r.scratchDraws[:0]} | |||
| 29 | + | r.scratchCovers = r.scratchCovers[:0] | |||
| 28 | if w.scale <= 0 { | 30 | if w.scale <= 0 { | ||
| 29 | w.scale = 1 | 31 | w.scale = 1 | ||
| 30 | } | 32 | } | ||
| @@ -36,6 +38,7 @@ func (r *Renderer) draws(s ports.Scene, contents map[ports.WindowID]ports.Surfac | |||||
| 36 | w.popups(false) | 38 | w.popups(false) | ||
| 37 | w.layers(true) | 39 | w.layers(true) | ||
| 38 | w.popups(true) | 40 | w.popups(true) | ||
| 41 | + | r.scratchDraws = w.draws | |||
| 39 | return w.draws | 42 | return w.draws | ||
| 40 | } | 43 | } | ||
| 41 | 44 | ||||
| @@ -160,7 +163,7 @@ func (w *sceneWalk) place(id ports.WindowID, content *ports.SurfaceContent, x, y | |||||
| 160 | // left out, so the surface below it still shows: a dmabuf that fails to | 163 | // left out, so the surface below it still shows: a dmabuf that fails to | ||
| 161 | // import (opaqueChildren pre-imports it) or an unreadable shm buffer. | 164 | // import (opaqueChildren pre-imports it) or an unreadable shm buffer. | ||
| 162 | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle { | 165 | func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, clip image.Rectangle) []image.Rectangle { | ||
| 163 | - | var covers []image.Rectangle | 166 | + | covers := w.r.scratchCovers[:0] |
| 164 | for i := range content.Children { | 167 | for i := range content.Children { | ||
| 165 | ch := &content.Children[i] | 168 | ch := &content.Children[i] | ||
| 166 | if ch.Below || !ch.Opaque { | 169 | if ch.Below || !ch.Opaque { | ||
| @@ -172,6 +175,7 @@ func (w *sceneWalk) opaqueChildren(content *ports.SurfaceContent, ox, oy int, cl | |||||
| 172 | } | 175 | } | ||
| 173 | covers = append(covers, dst) | 176 | covers = append(covers, dst) | ||
| 174 | } | 177 | } | ||
| 178 | + | w.r.scratchCovers = covers | |||
| 175 | return covers | 179 | return covers | ||
| 176 | } | 180 | } | ||
| 177 | 181 | ||||
M internal/adapters/vulkan/scene_perf_test.go +38 -2
| @@ -7,6 +7,30 @@ import ( | |||
| 7 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | + | // TestRenderSteadyStateAllocations measures the cost of a small stable scene. | |
| 11 | + | func TestRenderSteadyStateAllocations(t *testing.T) { | |
| 12 | + | r, err := New(32, 32) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Skipf("Vulkan unavailable: %v", err) | |
| 15 | + | } | |
| 16 | + | defer r.Close() | |
| 17 | + | scene := ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 32}}}} | |
| 18 | + | content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255}) | |
| 19 | + | content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1 | |
| 20 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: content} | |
| 21 | + | frame := func() { | |
| 22 | + | if err := render(r, scene, contents); err != nil { | |
| 23 | + | t.Fatal(err) | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | frame() | |
| 27 | + | if allocs := testing.AllocsPerRun(20, frame); allocs > 13 { | |
| 28 | + | t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs) | |
| 29 | + | } else { | |
| 30 | + | t.Logf("small steady-state Render: %.1f allocs/frame", allocs) | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 10 | 34 | // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged | |
| 11 | 35 | // subsurface tree. Each child has its own real wl_shm pool. | |
| 12 | 36 | func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | |
| @@ -37,13 +61,25 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | |||
| 37 | 61 | _ = r.draws(scene, contents, dmg) | |
| 38 | 62 | } | |
| 39 | 63 | walk() | |
| 40 | - | if allocs := testing.AllocsPerRun(20, walk); allocs > 16 { | |
| 41 | - | t.Errorf("%d tiles: %.1f allocations/walk, want <=16", count, allocs) | |
| 64 | + | if allocs := testing.AllocsPerRun(20, walk); allocs > 8 { | |
| 65 | + | t.Errorf("%d tiles: %.1f allocations/walk, want <=8", count, allocs) | |
| 42 | 66 | } else { | |
| 43 | 67 | t.Logf("%d tiles: %.1f allocations/walk", count, allocs) | |
| 44 | 68 | } | |
| 45 | 69 | } | |
| 46 | 70 | contents[1] = root | |
| 71 | + | // The same renderer and scene are reused; measure only steady-state frame work. | |
| 72 | + | frame := func() { | |
| 73 | + | if err := render(r, scene, contents); err != nil { | |
| 74 | + | t.Fatal(err) | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | frame() | |
| 78 | + | if allocs := testing.AllocsPerRun(20, frame); allocs > 16 { | |
| 79 | + | t.Errorf("steady-state Render: %.1f allocs/frame, want <=16", allocs) | |
| 80 | + | } else { | |
| 81 | + | t.Logf("steady-state Render: %.1f allocs/frame", allocs) | |
| 82 | + | } | |
| 47 | 83 | before := r.copied | |
| 48 | 84 | for i := range 100 { | |
| 49 | 85 | root.Seq++ | |
| @@ -7,6 +7,30 @@ import ( | |||||
| 7 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | + | // TestRenderSteadyStateAllocations measures the cost of a small stable scene. | |||
| 11 | + | func TestRenderSteadyStateAllocations(t *testing.T) { | |||
| 12 | + | r, err := New(32, 32) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Skipf("Vulkan unavailable: %v", err) | |||
| 15 | + | } | |||
| 16 | + | defer r.Close() | |||
| 17 | + | scene := ports.Scene{Seq: 1, Windows: []ports.SceneWindow{{ID: 1, Rect: ports.Rect{W: 32, H: 32}}}} | |||
| 18 | + | content := solidContent(t, 8, 8, color.RGBA{R: 255, A: 255}) | |||
| 19 | + | content.ID, content.Surface, content.Seq, content.Version = 1, 1, 1, 1 | |||
| 20 | + | contents := map[ports.WindowID]ports.SurfaceContent{1: content} | |||
| 21 | + | frame := func() { | |||
| 22 | + | if err := render(r, scene, contents); err != nil { | |||
| 23 | + | t.Fatal(err) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | frame() | |||
| 27 | + | if allocs := testing.AllocsPerRun(20, frame); allocs > 13 { | |||
| 28 | + | t.Errorf("small steady-state Render: %.1f allocs/frame, want <=13", allocs) | |||
| 29 | + | } else { | |||
| 30 | + | t.Logf("small steady-state Render: %.1f allocs/frame", allocs) | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 10 | // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged | 34 | // TestSceneWalkUnchangedTiledSHM guards the GPU copy budget of an unchanged | ||
| 11 | // subsurface tree. Each child has its own real wl_shm pool. | 35 | // subsurface tree. Each child has its own real wl_shm pool. | ||
| 12 | func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | 36 | func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | ||
| @@ -37,13 +61,25 @@ func TestSceneWalkUnchangedTiledSHM(t *testing.T) { | |||||
| 37 | _ = r.draws(scene, contents, dmg) | 61 | _ = r.draws(scene, contents, dmg) | ||
| 38 | } | 62 | } | ||
| 39 | walk() | 63 | walk() | ||
| 40 | - | if allocs := testing.AllocsPerRun(20, walk); allocs > 16 { | 64 | + | if allocs := testing.AllocsPerRun(20, walk); allocs > 8 { |
| 41 | - | t.Errorf("%d tiles: %.1f allocations/walk, want <=16", count, allocs) | 65 | + | t.Errorf("%d tiles: %.1f allocations/walk, want <=8", count, allocs) |
| 42 | } else { | 66 | } else { | ||
| 43 | t.Logf("%d tiles: %.1f allocations/walk", count, allocs) | 67 | t.Logf("%d tiles: %.1f allocations/walk", count, allocs) | ||
| 44 | } | 68 | } | ||
| 45 | } | 69 | } | ||
| 46 | contents[1] = root | 70 | contents[1] = root | ||
| 71 | + | // The same renderer and scene are reused; measure only steady-state frame work. | |||
| 72 | + | frame := func() { | |||
| 73 | + | if err := render(r, scene, contents); err != nil { | |||
| 74 | + | t.Fatal(err) | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | frame() | |||
| 78 | + | if allocs := testing.AllocsPerRun(20, frame); allocs > 16 { | |||
| 79 | + | t.Errorf("steady-state Render: %.1f allocs/frame, want <=16", allocs) | |||
| 80 | + | } else { | |||
| 81 | + | t.Logf("steady-state Render: %.1f allocs/frame", allocs) | |||
| 82 | + | } | |||
| 47 | before := r.copied | 83 | before := r.copied | ||
| 48 | for i := range 100 { | 84 | for i := range 100 { | ||
| 49 | root.Seq++ | 85 | root.Seq++ | ||
M internal/adapters/vulkan/targets.go +1 -1
| @@ -56,7 +56,7 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err | |||
| 56 | 56 | if n == 0 { | |
| 57 | 57 | return nil, nil | |
| 58 | 58 | } | |
| 59 | - | if r.dd.GetMemoryFdKHR == nil || r.dd.GetImageDrmFormatModifierPropertiesEXT == nil || len(r.dmabuf.Formats) == 0 { | |
| 59 | + | if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 { | |
| 60 | 60 | return nil, errors.New("device cannot export dmabufs") | |
| 61 | 61 | } | |
| 62 | 62 | mods := r.exportModifiers(modifiers) | |
| @@ -56,7 +56,7 @@ func (r *Renderer) ExportTargets(n int, modifiers []uint64) ([]ports.DMABuf, err | |||||
| 56 | if n == 0 { | 56 | if n == 0 { | ||
| 57 | return nil, nil | 57 | return nil, nil | ||
| 58 | } | 58 | } | ||
| 59 | - | if r.dd.GetMemoryFdKHR == nil || r.dd.GetImageDrmFormatModifierPropertiesEXT == nil || len(r.dmabuf.Formats) == 0 { | 59 | + | if !r.dd.HasGetMemoryFdKHR() || !r.dd.HasGetImageDrmFormatModifierPropertiesEXT() || len(r.dmabuf.Formats) == 0 { |
| 60 | return nil, errors.New("device cannot export dmabufs") | 60 | return nil, errors.New("device cannot export dmabufs") | ||
| 61 | } | 61 | } | ||
| 62 | mods := r.exportModifiers(modifiers) | 62 | mods := r.exportModifiers(modifiers) | ||
M internal/adapters/wayland/fifo.go +71 -5
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package wayland | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "sync" | |
| 4 | 5 | "time" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/neferwl/internal/ports" | |
| @@ -233,6 +234,45 @@ type update struct { | |||
| 233 | 234 | bufDamage []ports.Rect | |
| 234 | 235 | readyGeneration uint64 | |
| 235 | 236 | readyResult bool | |
| 237 | + | // refs counts incoming deps and prev links. Retired updates remain readable | |
| 238 | + | // until their last incoming link goes away, even after dropQueue. | |
| 239 | + | refs int | |
| 240 | + | retired bool | |
| 241 | + | } | |
| 242 | + | ||
| 243 | + | // updatePool contains only retired updates with no incoming graph links. A | |
| 244 | + | // popped update is NOT reusable just because it left its owner's queue: a | |
| 245 | + | // parent's queued deps can still point to it (including after role teardown). | |
| 246 | + | // The display owner goroutine changes graph links; sync.Pool only shares dead, | |
| 247 | + | // zeroed storage between independent displays. | |
| 248 | + | var updatePool sync.Pool | |
| 249 | + | ||
| 250 | + | func (u *update) releaseRef() { | |
| 251 | + | u.refs-- | |
| 252 | + | if u.refs == 0 && u.retired { | |
| 253 | + | deps := u.deps[:0] | |
| 254 | + | *u = update{deps: deps} | |
| 255 | + | updatePool.Put(u) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | // retireUpdate severs outgoing links only after the update is no longer used | |
| 260 | + | // by applyUpdate/dropQueue. Incoming links keep its identity until consumed. | |
| 261 | + | func (u *update) retireUpdate() { | |
| 262 | + | u.retired = true | |
| 263 | + | prev, deps := u.prev, u.deps | |
| 264 | + | u.prev, u.deps = nil, deps[:0] | |
| 265 | + | if prev != nil { | |
| 266 | + | prev.releaseRef() | |
| 267 | + | } | |
| 268 | + | for i, dep := range deps { | |
| 269 | + | deps[i] = nil // the pooled backing array must not retain old dependencies | |
| 270 | + | dep.releaseRef() | |
| 271 | + | } | |
| 272 | + | if u.refs == 0 { | |
| 273 | + | *u = update{deps: u.deps} | |
| 274 | + | updatePool.Put(u) | |
| 275 | + | } | |
| 236 | 276 | } | |
| 237 | 277 | ||
| 238 | 278 | type childLayout struct { | |
| @@ -347,7 +387,13 @@ func (s *Server) nextRefresh(name string, now time.Time) time.Time { | |||
| 347 | 387 | ||
| 348 | 388 | // queueUpdate queues the pending commit until it can apply. | |
| 349 | 389 | func (s *surface) queueUpdate() { | |
| 350 | - | u := s.takePending() | |
| 390 | + | u, _ := updatePool.Get().(*update) | |
| 391 | + | if u == nil { | |
| 392 | + | u = new(update) | |
| 393 | + | } | |
| 394 | + | deps := u.deps[:0] | |
| 395 | + | *u = s.takePending() | |
| 396 | + | u.deps = deps | |
| 351 | 397 | u.owner, u.synced = s, s.effectivelySynced() | |
| 352 | 398 | for _, ch := range s.sub.children { | |
| 353 | 399 | for i := len(ch.queue) - 1; i >= 0; i-- { | |
| @@ -355,14 +401,16 @@ func (s *surface) queueUpdate() { | |||
| 355 | 401 | if candidate.synced && !candidate.bound { | |
| 356 | 402 | candidate.bound = true | |
| 357 | 403 | u.deps = append(u.deps, candidate) | |
| 404 | + | candidate.refs++ | |
| 358 | 405 | break | |
| 359 | 406 | } | |
| 360 | 407 | } | |
| 361 | 408 | } | |
| 362 | 409 | if len(s.queue) > 0 { | |
| 363 | 410 | u.prev = s.queue[len(s.queue)-1] | |
| 411 | + | u.prev.refs++ | |
| 364 | 412 | } | |
| 365 | - | s.queue = append(s.queue, &u) | |
| 413 | + | s.queue = append(s.queue, u) | |
| 366 | 414 | s.server.fifoSurfaces[s] = struct{}{} | |
| 367 | 415 | s.server.wakePacer() | |
| 368 | 416 | } | |
| @@ -431,7 +479,11 @@ func (s *surface) dropQueue() { | |||
| 431 | 479 | u.buffer.SendRelease() | |
| 432 | 480 | } | |
| 433 | 481 | } | |
| 434 | - | s.queue = nil | |
| 482 | + | for i, u := range s.queue { | |
| 483 | + | s.queue[i] = nil // do not retain retired objects in the queue backing array | |
| 484 | + | u.retireUpdate() | |
| 485 | + | } | |
| 486 | + | s.queue = s.queue[:0] | |
| 435 | 487 | delete(s.server.fifoSurfaces, s) | |
| 436 | 488 | } | |
| 437 | 489 | ||
| @@ -574,6 +626,9 @@ func (s *surface) flushDesync() { | |||
| 574 | 626 | } | |
| 575 | 627 | ||
| 576 | 628 | func (u *update) graphReady(now time.Time) bool { | |
| 629 | + | if u.retired { | |
| 630 | + | return true // applied or dropped; a queued parent cannot replay it | |
| 631 | + | } | |
| 577 | 632 | s := u.owner | |
| 578 | 633 | gen := s.server.readinessGeneration | |
| 579 | 634 | if gen != 0 && u.readyGeneration == gen { | |
| @@ -598,6 +653,9 @@ func (u *update) graphReady(now time.Time) bool { | |||
| 598 | 653 | } | |
| 599 | 654 | ||
| 600 | 655 | func (u *update) applyGraph() { | |
| 656 | + | if u.retired { | |
| 657 | + | return | |
| 658 | + | } | |
| 601 | 659 | s := u.owner | |
| 602 | 660 | for len(s.queue) > 0 && s.queue[0] != u { | |
| 603 | 661 | s.queue[0].applyGraph() | |
| @@ -610,11 +668,19 @@ func (u *update) applyGraph() { | |||
| 610 | 668 | if len(s.queue) == 0 || s.queue[0] != u { | |
| 611 | 669 | return | |
| 612 | 670 | } | |
| 613 | - | s.queue = s.queue[1:] | |
| 671 | + | s.queue[0] = nil | |
| 672 | + | if len(s.queue) == 1 { | |
| 673 | + | s.queue = s.queue[:0] // retain capacity for the next commit | |
| 674 | + | } else { | |
| 675 | + | s.queue = s.queue[1:] | |
| 676 | + | } | |
| 614 | 677 | if len(s.queue) > 0 { | |
| 615 | - | s.queue[0].prev = nil | |
| 678 | + | next := s.queue[0] | |
| 679 | + | next.prev = nil | |
| 680 | + | u.releaseRef() // next's prev no longer points to u | |
| 616 | 681 | } | |
| 617 | 682 | s.applyUpdate(u) | |
| 683 | + | u.retireUpdate() | |
| 618 | 684 | } | |
| 619 | 685 | ||
| 620 | 686 | // sameBuffer reports whether two wrappers are the same wl_buffer. | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package wayland | 1 | package wayland | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "sync" | |||
| 4 | "time" | 5 | "time" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/neferwl/internal/ports" | 7 | "github.com/bnema/neferwl/internal/ports" | ||
| @@ -233,6 +234,45 @@ type update struct { | |||||
| 233 | bufDamage []ports.Rect | 234 | bufDamage []ports.Rect | ||
| 234 | readyGeneration uint64 | 235 | readyGeneration uint64 | ||
| 235 | readyResult bool | 236 | readyResult bool | ||
| 237 | + | // refs counts incoming deps and prev links. Retired updates remain readable | |||
| 238 | + | // until their last incoming link goes away, even after dropQueue. | |||
| 239 | + | refs int | |||
| 240 | + | retired bool | |||
| 241 | + | } | |||
| 242 | + | ||||
| 243 | + | // updatePool contains only retired updates with no incoming graph links. A | |||
| 244 | + | // popped update is NOT reusable just because it left its owner's queue: a | |||
| 245 | + | // parent's queued deps can still point to it (including after role teardown). | |||
| 246 | + | // The display owner goroutine changes graph links; sync.Pool only shares dead, | |||
| 247 | + | // zeroed storage between independent displays. | |||
| 248 | + | var updatePool sync.Pool | |||
| 249 | + | ||||
| 250 | + | func (u *update) releaseRef() { | |||
| 251 | + | u.refs-- | |||
| 252 | + | if u.refs == 0 && u.retired { | |||
| 253 | + | deps := u.deps[:0] | |||
| 254 | + | *u = update{deps: deps} | |||
| 255 | + | updatePool.Put(u) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | // retireUpdate severs outgoing links only after the update is no longer used | |||
| 260 | + | // by applyUpdate/dropQueue. Incoming links keep its identity until consumed. | |||
| 261 | + | func (u *update) retireUpdate() { | |||
| 262 | + | u.retired = true | |||
| 263 | + | prev, deps := u.prev, u.deps | |||
| 264 | + | u.prev, u.deps = nil, deps[:0] | |||
| 265 | + | if prev != nil { | |||
| 266 | + | prev.releaseRef() | |||
| 267 | + | } | |||
| 268 | + | for i, dep := range deps { | |||
| 269 | + | deps[i] = nil // the pooled backing array must not retain old dependencies | |||
| 270 | + | dep.releaseRef() | |||
| 271 | + | } | |||
| 272 | + | if u.refs == 0 { | |||
| 273 | + | *u = update{deps: u.deps} | |||
| 274 | + | updatePool.Put(u) | |||
| 275 | + | } | |||
| 236 | } | 276 | } | ||
| 237 | 277 | ||||
| 238 | type childLayout struct { | 278 | type childLayout struct { | ||
| @@ -347,7 +387,13 @@ func (s *Server) nextRefresh(name string, now time.Time) time.Time { | |||||
| 347 | 387 | ||||
| 348 | // queueUpdate queues the pending commit until it can apply. | 388 | // queueUpdate queues the pending commit until it can apply. | ||
| 349 | func (s *surface) queueUpdate() { | 389 | func (s *surface) queueUpdate() { | ||
| 350 | - | u := s.takePending() | 390 | + | u, _ := updatePool.Get().(*update) |
| 391 | + | if u == nil { | |||
| 392 | + | u = new(update) | |||
| 393 | + | } | |||
| 394 | + | deps := u.deps[:0] | |||
| 395 | + | *u = s.takePending() | |||
| 396 | + | u.deps = deps | |||
| 351 | u.owner, u.synced = s, s.effectivelySynced() | 397 | u.owner, u.synced = s, s.effectivelySynced() | ||
| 352 | for _, ch := range s.sub.children { | 398 | for _, ch := range s.sub.children { | ||
| 353 | for i := len(ch.queue) - 1; i >= 0; i-- { | 399 | for i := len(ch.queue) - 1; i >= 0; i-- { | ||
| @@ -355,14 +401,16 @@ func (s *surface) queueUpdate() { | |||||
| 355 | if candidate.synced && !candidate.bound { | 401 | if candidate.synced && !candidate.bound { | ||
| 356 | candidate.bound = true | 402 | candidate.bound = true | ||
| 357 | u.deps = append(u.deps, candidate) | 403 | u.deps = append(u.deps, candidate) | ||
| 404 | + | candidate.refs++ | |||
| 358 | break | 405 | break | ||
| 359 | } | 406 | } | ||
| 360 | } | 407 | } | ||
| 361 | } | 408 | } | ||
| 362 | if len(s.queue) > 0 { | 409 | if len(s.queue) > 0 { | ||
| 363 | u.prev = s.queue[len(s.queue)-1] | 410 | u.prev = s.queue[len(s.queue)-1] | ||
| 411 | + | u.prev.refs++ | |||
| 364 | } | 412 | } | ||
| 365 | - | s.queue = append(s.queue, &u) | 413 | + | s.queue = append(s.queue, u) |
| 366 | s.server.fifoSurfaces[s] = struct{}{} | 414 | s.server.fifoSurfaces[s] = struct{}{} | ||
| 367 | s.server.wakePacer() | 415 | s.server.wakePacer() | ||
| 368 | } | 416 | } | ||
| @@ -431,7 +479,11 @@ func (s *surface) dropQueue() { | |||||
| 431 | u.buffer.SendRelease() | 479 | u.buffer.SendRelease() | ||
| 432 | } | 480 | } | ||
| 433 | } | 481 | } | ||
| 434 | - | s.queue = nil | 482 | + | for i, u := range s.queue { |
| 483 | + | s.queue[i] = nil // do not retain retired objects in the queue backing array | |||
| 484 | + | u.retireUpdate() | |||
| 485 | + | } | |||
| 486 | + | s.queue = s.queue[:0] | |||
| 435 | delete(s.server.fifoSurfaces, s) | 487 | delete(s.server.fifoSurfaces, s) | ||
| 436 | } | 488 | } | ||
| 437 | 489 | ||||
| @@ -574,6 +626,9 @@ func (s *surface) flushDesync() { | |||||
| 574 | } | 626 | } | ||
| 575 | 627 | ||||
| 576 | func (u *update) graphReady(now time.Time) bool { | 628 | func (u *update) graphReady(now time.Time) bool { | ||
| 629 | + | if u.retired { | |||
| 630 | + | return true // applied or dropped; a queued parent cannot replay it | |||
| 631 | + | } | |||
| 577 | s := u.owner | 632 | s := u.owner | ||
| 578 | gen := s.server.readinessGeneration | 633 | gen := s.server.readinessGeneration | ||
| 579 | if gen != 0 && u.readyGeneration == gen { | 634 | if gen != 0 && u.readyGeneration == gen { | ||
| @@ -598,6 +653,9 @@ func (u *update) graphReady(now time.Time) bool { | |||||
| 598 | } | 653 | } | ||
| 599 | 654 | ||||
| 600 | func (u *update) applyGraph() { | 655 | func (u *update) applyGraph() { | ||
| 656 | + | if u.retired { | |||
| 657 | + | return | |||
| 658 | + | } | |||
| 601 | s := u.owner | 659 | s := u.owner | ||
| 602 | for len(s.queue) > 0 && s.queue[0] != u { | 660 | for len(s.queue) > 0 && s.queue[0] != u { | ||
| 603 | s.queue[0].applyGraph() | 661 | s.queue[0].applyGraph() | ||
| @@ -610,11 +668,19 @@ func (u *update) applyGraph() { | |||||
| 610 | if len(s.queue) == 0 || s.queue[0] != u { | 668 | if len(s.queue) == 0 || s.queue[0] != u { | ||
| 611 | return | 669 | return | ||
| 612 | } | 670 | } | ||
| 613 | - | s.queue = s.queue[1:] | 671 | + | s.queue[0] = nil |
| 672 | + | if len(s.queue) == 1 { | |||
| 673 | + | s.queue = s.queue[:0] // retain capacity for the next commit | |||
| 674 | + | } else { | |||
| 675 | + | s.queue = s.queue[1:] | |||
| 676 | + | } | |||
| 614 | if len(s.queue) > 0 { | 677 | if len(s.queue) > 0 { | ||
| 615 | - | s.queue[0].prev = nil | 678 | + | next := s.queue[0] |
| 679 | + | next.prev = nil | |||
| 680 | + | u.releaseRef() // next's prev no longer points to u | |||
| 616 | } | 681 | } | ||
| 617 | s.applyUpdate(u) | 682 | s.applyUpdate(u) | ||
| 683 | + | u.retireUpdate() | |||
| 618 | } | 684 | } | ||
| 619 | 685 | ||||
| 620 | // sameBuffer reports whether two wrappers are the same wl_buffer. | 686 | // sameBuffer reports whether two wrappers are the same wl_buffer. | ||
M internal/adapters/wayland/fifo_test.go +49 -0
| @@ -207,6 +207,55 @@ func TestFifoQueuedBufferDestroyed(t *testing.T) { | |||
| 207 | 207 | } | |
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | + | // A parent can keep a dependency after the child's queue is dropped or its | |
| 211 | + | // update is applied independently. Neither path may recycle that dependency | |
| 212 | + | // into a new commit while the parent still holds it. | |
| 213 | + | func TestQueuedParentRetainsRetiredChild(t *testing.T) { | |
| 214 | + | for _, dropped := range []bool{true, false} { | |
| 215 | + | name := "applied" | |
| 216 | + | if dropped { | |
| 217 | + | name = "dropped" | |
| 218 | + | } | |
| 219 | + | t.Run(name, func(t *testing.T) { | |
| 220 | + | srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)} | |
| 221 | + | parent := &surface{server: srv} | |
| 222 | + | child := &surface{server: srv} | |
| 223 | + | child.sub.parent, child.sub.synced = parent, true | |
| 224 | + | parent.sub.children = []*surface{child} | |
| 225 | + | child.pendingScale = 2 | |
| 226 | + | child.queueUpdate() | |
| 227 | + | old := child.queue[0] | |
| 228 | + | parent.pendingScale = 3 | |
| 229 | + | parent.queueUpdate() | |
| 230 | + | pu := parent.queue[0] | |
| 231 | + | if old.refs != 1 || len(pu.deps) != 1 || pu.deps[0] != old { | |
| 232 | + | t.Fatal("parent did not capture child's update") | |
| 233 | + | } | |
| 234 | + | if dropped { | |
| 235 | + | child.dropQueue() | |
| 236 | + | } else { | |
| 237 | + | old.applyGraph() | |
| 238 | + | } | |
| 239 | + | if !old.retired || old.refs != 1 { | |
| 240 | + | t.Fatalf("retired child ref count: retired=%v refs=%d", old.retired, old.refs) | |
| 241 | + | } | |
| 242 | + | child.pendingScale = 7 | |
| 243 | + | child.queueUpdate() | |
| 244 | + | if child.queue[0] == old || old.scale != 2 || pu.deps[0] != old { | |
| 245 | + | t.Fatal("live dependency was recycled as a new child commit") | |
| 246 | + | } | |
| 247 | + | if !pu.graphReady(time.Now()) { | |
| 248 | + | t.Fatal("retired dependency kept parent waiting") | |
| 249 | + | } | |
| 250 | + | pu.applyGraph() | |
| 251 | + | if parent.bufferScale != 3 || len(parent.queue) != 0 || len(child.queue) != 1 || child.queue[0].scale != 7 { | |
| 252 | + | t.Fatal("parent replayed old dependency or changed new child commit") | |
| 253 | + | } | |
| 254 | + | child.dropQueue() | |
| 255 | + | }) | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | ||
| 210 | 259 | // Rechecking a shared predecessor in a traversal must reuse its readiness. | |
| 211 | 260 | func TestGraphReadyMemoizesPredecessors(t *testing.T) { | |
| 212 | 261 | s := &Server{readinessGeneration: 1} | |
| @@ -207,6 +207,55 @@ func TestFifoQueuedBufferDestroyed(t *testing.T) { | |||||
| 207 | } | 207 | } | ||
| 208 | } | 208 | } | ||
| 209 | 209 | ||||
| 210 | + | // A parent can keep a dependency after the child's queue is dropped or its | |||
| 211 | + | // update is applied independently. Neither path may recycle that dependency | |||
| 212 | + | // into a new commit while the parent still holds it. | |||
| 213 | + | func TestQueuedParentRetainsRetiredChild(t *testing.T) { | |||
| 214 | + | for _, dropped := range []bool{true, false} { | |||
| 215 | + | name := "applied" | |||
| 216 | + | if dropped { | |||
| 217 | + | name = "dropped" | |||
| 218 | + | } | |||
| 219 | + | t.Run(name, func(t *testing.T) { | |||
| 220 | + | srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)} | |||
| 221 | + | parent := &surface{server: srv} | |||
| 222 | + | child := &surface{server: srv} | |||
| 223 | + | child.sub.parent, child.sub.synced = parent, true | |||
| 224 | + | parent.sub.children = []*surface{child} | |||
| 225 | + | child.pendingScale = 2 | |||
| 226 | + | child.queueUpdate() | |||
| 227 | + | old := child.queue[0] | |||
| 228 | + | parent.pendingScale = 3 | |||
| 229 | + | parent.queueUpdate() | |||
| 230 | + | pu := parent.queue[0] | |||
| 231 | + | if old.refs != 1 || len(pu.deps) != 1 || pu.deps[0] != old { | |||
| 232 | + | t.Fatal("parent did not capture child's update") | |||
| 233 | + | } | |||
| 234 | + | if dropped { | |||
| 235 | + | child.dropQueue() | |||
| 236 | + | } else { | |||
| 237 | + | old.applyGraph() | |||
| 238 | + | } | |||
| 239 | + | if !old.retired || old.refs != 1 { | |||
| 240 | + | t.Fatalf("retired child ref count: retired=%v refs=%d", old.retired, old.refs) | |||
| 241 | + | } | |||
| 242 | + | child.pendingScale = 7 | |||
| 243 | + | child.queueUpdate() | |||
| 244 | + | if child.queue[0] == old || old.scale != 2 || pu.deps[0] != old { | |||
| 245 | + | t.Fatal("live dependency was recycled as a new child commit") | |||
| 246 | + | } | |||
| 247 | + | if !pu.graphReady(time.Now()) { | |||
| 248 | + | t.Fatal("retired dependency kept parent waiting") | |||
| 249 | + | } | |||
| 250 | + | pu.applyGraph() | |||
| 251 | + | if parent.bufferScale != 3 || len(parent.queue) != 0 || len(child.queue) != 1 || child.queue[0].scale != 7 { | |||
| 252 | + | t.Fatal("parent replayed old dependency or changed new child commit") | |||
| 253 | + | } | |||
| 254 | + | child.dropQueue() | |||
| 255 | + | }) | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | ||||
| 210 | // Rechecking a shared predecessor in a traversal must reuse its readiness. | 259 | // Rechecking a shared predecessor in a traversal must reuse its readiness. | ||
| 211 | func TestGraphReadyMemoizesPredecessors(t *testing.T) { | 260 | func TestGraphReadyMemoizesPredecessors(t *testing.T) { | ||
| 212 | s := &Server{readinessGeneration: 1} | 261 | s := &Server{readinessGeneration: 1} | ||
M internal/adapters/wayland/frames.go +13 -3
| @@ -26,9 +26,14 @@ func (s *Server) pace(ctx context.Context) { | |||
| 26 | 26 | timer := time.NewTimer(defaultFramePeriod) | |
| 27 | 27 | defer timer.Stop() | |
| 28 | 28 | presented := s.channels.Presented | |
| 29 | + | if s.flipped == nil { | |
| 30 | + | s.flipped = make(map[string]bool) | |
| 31 | + | } | |
| 29 | 32 | for { | |
| 30 | - | flipped := map[string]bool{} | |
| 31 | - | var reports []ports.OutputPresented | |
| 33 | + | clear(s.flipped) | |
| 34 | + | flipped := s.flipped | |
| 35 | + | s.frameReports = s.frameReports[:0] | |
| 36 | + | reports := s.frameReports | |
| 32 | 37 | select { | |
| 33 | 38 | case <-ctx.Done(): | |
| 34 | 39 | return | |
| @@ -52,6 +57,7 @@ func (s *Server) pace(ctx context.Context) { | |||
| 52 | 57 | break drain | |
| 53 | 58 | } | |
| 54 | 59 | } | |
| 60 | + | s.frameReports = reports | |
| 55 | 61 | wait, idle := defaultFramePeriod, false | |
| 56 | 62 | if !s.display.Do(func() { | |
| 57 | 63 | if ctx.Err() == nil { | |
| @@ -130,7 +136,11 @@ func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) { | |||
| 130 | 136 | // their deadline. wait is the time to the next deadline; idle is set when | |
| 131 | 137 | // no callback waits after firing. | |
| 132 | 138 | func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) { | |
| 133 | - | periods := make(map[string]time.Duration, len(s.outputs)+1) | |
| 139 | + | if s.framePeriods == nil { | |
| 140 | + | s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1) | |
| 141 | + | } | |
| 142 | + | periods := s.framePeriods | |
| 143 | + | clear(periods) | |
| 134 | 144 | for _, o := range s.outputs { | |
| 135 | 145 | periods[o.name()] = framePeriod(o.place.Info.RefreshMilli) | |
| 136 | 146 | } | |
| @@ -26,9 +26,14 @@ func (s *Server) pace(ctx context.Context) { | |||||
| 26 | timer := time.NewTimer(defaultFramePeriod) | 26 | timer := time.NewTimer(defaultFramePeriod) | ||
| 27 | defer timer.Stop() | 27 | defer timer.Stop() | ||
| 28 | presented := s.channels.Presented | 28 | presented := s.channels.Presented | ||
| 29 | + | if s.flipped == nil { | |||
| 30 | + | s.flipped = make(map[string]bool) | |||
| 31 | + | } | |||
| 29 | for { | 32 | for { | ||
| 30 | - | flipped := map[string]bool{} | 33 | + | clear(s.flipped) |
| 31 | - | var reports []ports.OutputPresented | 34 | + | flipped := s.flipped |
| 35 | + | s.frameReports = s.frameReports[:0] | |||
| 36 | + | reports := s.frameReports | |||
| 32 | select { | 37 | select { | ||
| 33 | case <-ctx.Done(): | 38 | case <-ctx.Done(): | ||
| 34 | return | 39 | return | ||
| @@ -52,6 +57,7 @@ func (s *Server) pace(ctx context.Context) { | |||||
| 52 | break drain | 57 | break drain | ||
| 53 | } | 58 | } | ||
| 54 | } | 59 | } | ||
| 60 | + | s.frameReports = reports | |||
| 55 | wait, idle := defaultFramePeriod, false | 61 | wait, idle := defaultFramePeriod, false | ||
| 56 | if !s.display.Do(func() { | 62 | if !s.display.Do(func() { | ||
| 57 | if ctx.Err() == nil { | 63 | if ctx.Err() == nil { | ||
| @@ -130,7 +136,11 @@ func (s *Server) queueFrames(name string, callbacks []*wayland.Callback) { | |||||
| 130 | // their deadline. wait is the time to the next deadline; idle is set when | 136 | // their deadline. wait is the time to the next deadline; idle is set when | ||
| 131 | // no callback waits after firing. | 137 | // no callback waits after firing. | ||
| 132 | func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) { | 138 | func (s *Server) dueFrames(now time.Time, flipped map[string]bool) (fire []string, wait time.Duration, idle bool) { | ||
| 133 | - | periods := make(map[string]time.Duration, len(s.outputs)+1) | 139 | + | if s.framePeriods == nil { |
| 140 | + | s.framePeriods = make(map[string]time.Duration, len(s.outputs)+1) | |||
| 141 | + | } | |||
| 142 | + | periods := s.framePeriods | |||
| 143 | + | clear(periods) | |||
| 134 | for _, o := range s.outputs { | 144 | for _, o := range s.outputs { | ||
| 135 | periods[o.name()] = framePeriod(o.place.Info.RefreshMilli) | 145 | periods[o.name()] = framePeriod(o.place.Info.RefreshMilli) | ||
| 136 | } | 146 | } | ||
M internal/adapters/wayland/frames_test.go +11 -0
| @@ -9,6 +9,17 @@ import ( | |||
| 9 | 9 | "github.com/bnema/purego-libwayland/protocol/wayland" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | func TestDueFramesAllocations(t *testing.T) { | |
| 13 | + | s := &Server{frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}} | |
| 14 | + | now := time.Unix(100, 0) | |
| 15 | + | s.dueFrames(now, nil) | |
| 16 | + | if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, nil) }); allocs != 0 { | |
| 17 | + | t.Errorf("idle dueFrames: %.1f allocs, want 0", allocs) | |
| 18 | + | } else { | |
| 19 | + | t.Logf("idle dueFrames: %.1f allocs", allocs) | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 12 | 23 | func TestFramePeriod(t *testing.T) { | |
| 13 | 24 | for _, tc := range []struct { | |
| 14 | 25 | milli int | |
| @@ -9,6 +9,17 @@ import ( | |||||
| 9 | "github.com/bnema/purego-libwayland/protocol/wayland" | 9 | "github.com/bnema/purego-libwayland/protocol/wayland" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | func TestDueFramesAllocations(t *testing.T) { | |||
| 13 | + | s := &Server{frameDue: map[string]time.Time{}, awaiting: map[string][]*wayland.Callback{}} | |||
| 14 | + | now := time.Unix(100, 0) | |||
| 15 | + | s.dueFrames(now, nil) | |||
| 16 | + | if allocs := testing.AllocsPerRun(100, func() { s.dueFrames(now, nil) }); allocs != 0 { | |||
| 17 | + | t.Errorf("idle dueFrames: %.1f allocs, want 0", allocs) | |||
| 18 | + | } else { | |||
| 19 | + | t.Logf("idle dueFrames: %.1f allocs", allocs) | |||
| 20 | + | } | |||
| 21 | + | } | |||
| 22 | + | ||||
| 12 | func TestFramePeriod(t *testing.T) { | 23 | func TestFramePeriod(t *testing.T) { | ||
| 13 | for _, tc := range []struct { | 24 | for _, tc := range []struct { | ||
| 14 | milli int | 25 | milli int | ||
M internal/adapters/wayland/input_region.go +26 -24
| @@ -34,33 +34,10 @@ func (g *region) boxRect() ports.Rect { | |||
| 34 | 34 | ||
| 35 | 35 | // effectiveInput includes all committed, bounded subsurface regions. | |
| 36 | 36 | func (s *surface) effectiveInput() (bool, []ports.Rect) { | |
| 37 | - | var rects []ports.Rect | |
| 38 | - | var visit func(*surface, int, int) | |
| 39 | - | visit = func(v *surface, x, y int) { | |
| 40 | - | if v.has { | |
| 41 | - | bounds := ports.Rect{W: v.content.LogicalW, H: v.content.LogicalH} | |
| 42 | - | if v.inputAll { | |
| 43 | - | if bounds.W > 0 && bounds.H > 0 { | |
| 44 | - | rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H}) | |
| 45 | - | } | |
| 46 | - | } else { | |
| 47 | - | for _, r := range v.inputRects { | |
| 48 | - | if r = intersectRect(r, bounds); r.W > 0 { | |
| 49 | - | r.X += x | |
| 50 | - | r.Y += y | |
| 51 | - | rects = append(rects, r) | |
| 52 | - | } | |
| 53 | - | } | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | for _, item := range v.sub.layout { | |
| 57 | - | visit(item.child, x+item.x, y+item.y) | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | 37 | if s.inputAll && len(s.sub.children) == 0 { | |
| 61 | 38 | return true, nil | |
| 62 | 39 | } | |
| 63 | - | visit(s, 0, 0) | |
| 40 | + | rects := s.appendEffectiveInput(nil, 0, 0) | |
| 64 | 41 | if s.xdg != nil { | |
| 65 | 42 | for i := range rects { | |
| 66 | 43 | rects[i].X -= s.xdg.geometry.X | |
| @@ -70,6 +47,31 @@ func (s *surface) effectiveInput() (bool, []ports.Rect) { | |||
| 70 | 47 | return false, rects | |
| 71 | 48 | } | |
| 72 | 49 | ||
| 50 | + | // appendEffectiveInput walks the committed tree without a per-call recursive | |
| 51 | + | // closure. The returned slice belongs to the caller (emitInput publishes it). | |
| 52 | + | func (s *surface) appendEffectiveInput(rects []ports.Rect, x, y int) []ports.Rect { | |
| 53 | + | if s.has { | |
| 54 | + | bounds := ports.Rect{W: s.content.LogicalW, H: s.content.LogicalH} | |
| 55 | + | if s.inputAll { | |
| 56 | + | if bounds.W > 0 && bounds.H > 0 { | |
| 57 | + | rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H}) | |
| 58 | + | } | |
| 59 | + | } else { | |
| 60 | + | for _, r := range s.inputRects { | |
| 61 | + | if r = intersectRect(r, bounds); r.W > 0 { | |
| 62 | + | r.X += x | |
| 63 | + | r.Y += y | |
| 64 | + | rects = append(rects, r) | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | for _, item := range s.sub.layout { | |
| 70 | + | rects = item.child.appendEffectiveInput(rects, x+item.x, y+item.y) | |
| 71 | + | } | |
| 72 | + | return rects | |
| 73 | + | } | |
| 74 | + | ||
| 73 | 75 | // resetInputEmission makes the next mapping publish its current region again. | |
| 74 | 76 | func (s *surface) resetInputEmission() { | |
| 75 | 77 | s.sentInput = false | |
| @@ -34,33 +34,10 @@ func (g *region) boxRect() ports.Rect { | |||||
| 34 | 34 | ||||
| 35 | // effectiveInput includes all committed, bounded subsurface regions. | 35 | // effectiveInput includes all committed, bounded subsurface regions. | ||
| 36 | func (s *surface) effectiveInput() (bool, []ports.Rect) { | 36 | func (s *surface) effectiveInput() (bool, []ports.Rect) { | ||
| 37 | - | var rects []ports.Rect | |||
| 38 | - | var visit func(*surface, int, int) | |||
| 39 | - | visit = func(v *surface, x, y int) { | |||
| 40 | - | if v.has { | |||
| 41 | - | bounds := ports.Rect{W: v.content.LogicalW, H: v.content.LogicalH} | |||
| 42 | - | if v.inputAll { | |||
| 43 | - | if bounds.W > 0 && bounds.H > 0 { | |||
| 44 | - | rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H}) | |||
| 45 | - | } | |||
| 46 | - | } else { | |||
| 47 | - | for _, r := range v.inputRects { | |||
| 48 | - | if r = intersectRect(r, bounds); r.W > 0 { | |||
| 49 | - | r.X += x | |||
| 50 | - | r.Y += y | |||
| 51 | - | rects = append(rects, r) | |||
| 52 | - | } | |||
| 53 | - | } | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | - | for _, item := range v.sub.layout { | |||
| 57 | - | visit(item.child, x+item.x, y+item.y) | |||
| 58 | - | } | |||
| 59 | - | } | |||
| 60 | if s.inputAll && len(s.sub.children) == 0 { | 37 | if s.inputAll && len(s.sub.children) == 0 { | ||
| 61 | return true, nil | 38 | return true, nil | ||
| 62 | } | 39 | } | ||
| 63 | - | visit(s, 0, 0) | 40 | + | rects := s.appendEffectiveInput(nil, 0, 0) |
| 64 | if s.xdg != nil { | 41 | if s.xdg != nil { | ||
| 65 | for i := range rects { | 42 | for i := range rects { | ||
| 66 | rects[i].X -= s.xdg.geometry.X | 43 | rects[i].X -= s.xdg.geometry.X | ||
| @@ -70,6 +47,31 @@ func (s *surface) effectiveInput() (bool, []ports.Rect) { | |||||
| 70 | return false, rects | 47 | return false, rects | ||
| 71 | } | 48 | } | ||
| 72 | 49 | ||||
| 50 | + | // appendEffectiveInput walks the committed tree without a per-call recursive | |||
| 51 | + | // closure. The returned slice belongs to the caller (emitInput publishes it). | |||
| 52 | + | func (s *surface) appendEffectiveInput(rects []ports.Rect, x, y int) []ports.Rect { | |||
| 53 | + | if s.has { | |||
| 54 | + | bounds := ports.Rect{W: s.content.LogicalW, H: s.content.LogicalH} | |||
| 55 | + | if s.inputAll { | |||
| 56 | + | if bounds.W > 0 && bounds.H > 0 { | |||
| 57 | + | rects = append(rects, ports.Rect{X: x, Y: y, W: bounds.W, H: bounds.H}) | |||
| 58 | + | } | |||
| 59 | + | } else { | |||
| 60 | + | for _, r := range s.inputRects { | |||
| 61 | + | if r = intersectRect(r, bounds); r.W > 0 { | |||
| 62 | + | r.X += x | |||
| 63 | + | r.Y += y | |||
| 64 | + | rects = append(rects, r) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | for _, item := range s.sub.layout { | |||
| 70 | + | rects = item.child.appendEffectiveInput(rects, x+item.x, y+item.y) | |||
| 71 | + | } | |||
| 72 | + | return rects | |||
| 73 | + | } | |||
| 74 | + | ||||
| 73 | // resetInputEmission makes the next mapping publish its current region again. | 75 | // resetInputEmission makes the next mapping publish its current region again. | ||
| 74 | func (s *surface) resetInputEmission() { | 76 | func (s *surface) resetInputEmission() { | ||
| 75 | s.sentInput = false | 77 | s.sentInput = false | ||
M internal/adapters/wayland/server.go +5 -0
| @@ -71,6 +71,11 @@ type Server struct { | |||
| 71 | 71 | awaiting map[string][]*wayland.Callback | |
| 72 | 72 | frameDue map[string]time.Time | |
| 73 | 73 | frameReady chan struct{} | |
| 74 | + | // Pacer scratch: periods is used on the display goroutine, while | |
| 75 | + | // flipped and frameReports belong to pace and its synchronous Do call. | |
| 76 | + | framePeriods map[string]time.Duration | |
| 77 | + | flipped map[string]bool | |
| 78 | + | frameReports []ports.OutputPresented | |
| 74 | 79 | // held buffers wait until no output reads them (release.go); reports | |
| 75 | 80 | // are the outputs' latest ports.OutputPresented. | |
| 76 | 81 | held []heldBuffer | |
| @@ -71,6 +71,11 @@ type Server struct { | |||||
| 71 | awaiting map[string][]*wayland.Callback | 71 | awaiting map[string][]*wayland.Callback | ||
| 72 | frameDue map[string]time.Time | 72 | frameDue map[string]time.Time | ||
| 73 | frameReady chan struct{} | 73 | frameReady chan struct{} | ||
| 74 | + | // Pacer scratch: periods is used on the display goroutine, while | |||
| 75 | + | // flipped and frameReports belong to pace and its synchronous Do call. | |||
| 76 | + | framePeriods map[string]time.Duration | |||
| 77 | + | flipped map[string]bool | |||
| 78 | + | frameReports []ports.OutputPresented | |||
| 74 | // held buffers wait until no output reads them (release.go); reports | 79 | // held buffers wait until no output reads them (release.go); reports | ||
| 75 | // are the outputs' latest ports.OutputPresented. | 80 | // are the outputs' latest ports.OutputPresented. | ||
| 76 | held []heldBuffer | 81 | held []heldBuffer | ||
M internal/adapters/wayland/surface_perf_test.go +51 -6
| @@ -9,9 +9,43 @@ import ( | |||
| 9 | 9 | "github.com/bnema/neferwl/internal/ports" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | + | // TestEffectiveInputEmptyTreeAllocations guards against a heap-allocated | |
| 13 | + | // recursive closure for every input-region traversal of a tiled tree. | |
| 14 | + | func TestEffectiveInputEmptyTreeAllocations(t *testing.T) { | |
| 15 | + | root := &surface{} | |
| 16 | + | for range 64 { | |
| 17 | + | child := &surface{} | |
| 18 | + | root.sub.children = append(root.sub.children, child) | |
| 19 | + | root.sub.layout = append(root.sub.layout, childLayout{child: child}) | |
| 20 | + | } | |
| 21 | + | walk := func() { | |
| 22 | + | all, rects := root.effectiveInput() | |
| 23 | + | if all || len(rects) != 0 { | |
| 24 | + | t.Fatal("empty tree has input") | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | if allocs := testing.AllocsPerRun(100, walk); allocs != 0 { | |
| 28 | + | t.Errorf("empty tiled input traversal: %.1f allocs, want 0", allocs) | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 12 | 32 | // TestTiledCommitPublishAllocations exercises real content publication without | |
| 13 | 33 | // a client connection, holding old snapshots while subsequent commits publish. | |
| 14 | 34 | func TestTiledCommitPublishAllocations(t *testing.T) { | |
| 35 | + | // The top-level test is listed in Makefile perf-check; this subtest guards | |
| 36 | + | // the per-commit update storage after its first retirement. | |
| 37 | + | t.Run("queueUpdateSteadyState", func(t *testing.T) { | |
| 38 | + | srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)} | |
| 39 | + | surf := &surface{server: srv} | |
| 40 | + | commit := func() { | |
| 41 | + | surf.queueUpdate() | |
| 42 | + | surf.dropQueue() | |
| 43 | + | } | |
| 44 | + | commit() | |
| 45 | + | if allocs := testing.AllocsPerRun(100, commit); allocs != 0 { | |
| 46 | + | t.Errorf("steady-state queueUpdate/dropQueue: %.1f allocs/commit; want 0", allocs) | |
| 47 | + | } | |
| 48 | + | }) | |
| 15 | 49 | for _, count := range []int{16, 64} { | |
| 16 | 50 | t.Run(fmt.Sprintf("%d", count), func(t *testing.T) { | |
| 17 | 51 | channels := Channels{Contents: make(chan ports.SurfaceContent, 1)} | |
| @@ -35,8 +69,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||
| 35 | 69 | if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 { | |
| 36 | 70 | t.Fatalf("previous publication changed: %+v", first) | |
| 37 | 71 | } | |
| 38 | - | if allocs := testing.AllocsPerRun(100, publish); allocs > 4 { | |
| 39 | - | t.Errorf("%d children: %.1f allocs/publication; want <=4", count, allocs) | |
| 72 | + | if allocs := testing.AllocsPerRun(100, publish); allocs > 2 { | |
| 73 | + | t.Errorf("%d children: %.1f allocs/publication; want <=2", count, allocs) | |
| 40 | 74 | } else { | |
| 41 | 75 | t.Logf("%d children: %.1f allocs/unchanged publication", count, allocs) | |
| 42 | 76 | } | |
| @@ -44,8 +78,19 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||
| 44 | 78 | // proportionally to the number of children. | |
| 45 | 79 | root.sub.pendingLayout = root.sub.layout | |
| 46 | 80 | queue := func() { _ = root.takePending() } | |
| 47 | - | if allocs := testing.AllocsPerRun(100, queue); allocs > 2 { | |
| 48 | - | t.Errorf("%d children: %.1f allocs/queued commit; want <=2", count, allocs) | |
| 81 | + | if allocs := testing.AllocsPerRun(100, queue); allocs > 0 { | |
| 82 | + | t.Errorf("%d children: %.1f allocs/queued commit; want 0", count, allocs) | |
| 83 | + | } else { | |
| 84 | + | t.Logf("%d children: %.1f allocs/takePending", count, allocs) | |
| 85 | + | } | |
| 86 | + | commit := func() { | |
| 87 | + | root.queueUpdate() | |
| 88 | + | root.dropQueue() | |
| 89 | + | } | |
| 90 | + | if allocs := testing.AllocsPerRun(100, commit); allocs != 0 { | |
| 91 | + | t.Errorf("%d children: %.1f allocs/queueUpdate; want 0", count, allocs) | |
| 92 | + | } else { | |
| 93 | + | t.Logf("%d children: %.1f allocs/queueUpdate", count, allocs) | |
| 49 | 94 | } | |
| 50 | 95 | if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 || first.DamageHistory[0].Seq != 1 { | |
| 51 | 96 | t.Fatal("previous publication was mutated") | |
| @@ -68,8 +113,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||
| 68 | 113 | root.treeDirty = true | |
| 69 | 114 | root.redraw() | |
| 70 | 115 | } | |
| 71 | - | if allocs := testing.AllocsPerRun(100, changed); allocs > 4 { | |
| 72 | - | t.Errorf("%d children: %.1f allocs/changed publication; want <=4", count, allocs) | |
| 116 | + | if allocs := testing.AllocsPerRun(100, changed); allocs > 3 { | |
| 117 | + | t.Errorf("%d children: %.1f allocs/changed publication; want <=3", count, allocs) | |
| 73 | 118 | } else { | |
| 74 | 119 | t.Logf("%d children: %.1f allocs/changed publication", count, allocs) | |
| 75 | 120 | } | |
| @@ -9,9 +9,43 @@ import ( | |||||
| 9 | "github.com/bnema/neferwl/internal/ports" | 9 | "github.com/bnema/neferwl/internal/ports" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | + | // TestEffectiveInputEmptyTreeAllocations guards against a heap-allocated | |||
| 13 | + | // recursive closure for every input-region traversal of a tiled tree. | |||
| 14 | + | func TestEffectiveInputEmptyTreeAllocations(t *testing.T) { | |||
| 15 | + | root := &surface{} | |||
| 16 | + | for range 64 { | |||
| 17 | + | child := &surface{} | |||
| 18 | + | root.sub.children = append(root.sub.children, child) | |||
| 19 | + | root.sub.layout = append(root.sub.layout, childLayout{child: child}) | |||
| 20 | + | } | |||
| 21 | + | walk := func() { | |||
| 22 | + | all, rects := root.effectiveInput() | |||
| 23 | + | if all || len(rects) != 0 { | |||
| 24 | + | t.Fatal("empty tree has input") | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | if allocs := testing.AllocsPerRun(100, walk); allocs != 0 { | |||
| 28 | + | t.Errorf("empty tiled input traversal: %.1f allocs, want 0", allocs) | |||
| 29 | + | } | |||
| 30 | + | } | |||
| 31 | + | ||||
| 12 | // TestTiledCommitPublishAllocations exercises real content publication without | 32 | // TestTiledCommitPublishAllocations exercises real content publication without | ||
| 13 | // a client connection, holding old snapshots while subsequent commits publish. | 33 | // a client connection, holding old snapshots while subsequent commits publish. | ||
| 14 | func TestTiledCommitPublishAllocations(t *testing.T) { | 34 | func TestTiledCommitPublishAllocations(t *testing.T) { | ||
| 35 | + | // The top-level test is listed in Makefile perf-check; this subtest guards | |||
| 36 | + | // the per-commit update storage after its first retirement. | |||
| 37 | + | t.Run("queueUpdateSteadyState", func(t *testing.T) { | |||
| 38 | + | srv := &Server{fifoSurfaces: make(map[*surface]struct{}), frameReady: make(chan struct{}, 1)} | |||
| 39 | + | surf := &surface{server: srv} | |||
| 40 | + | commit := func() { | |||
| 41 | + | surf.queueUpdate() | |||
| 42 | + | surf.dropQueue() | |||
| 43 | + | } | |||
| 44 | + | commit() | |||
| 45 | + | if allocs := testing.AllocsPerRun(100, commit); allocs != 0 { | |||
| 46 | + | t.Errorf("steady-state queueUpdate/dropQueue: %.1f allocs/commit; want 0", allocs) | |||
| 47 | + | } | |||
| 48 | + | }) | |||
| 15 | for _, count := range []int{16, 64} { | 49 | for _, count := range []int{16, 64} { | ||
| 16 | t.Run(fmt.Sprintf("%d", count), func(t *testing.T) { | 50 | t.Run(fmt.Sprintf("%d", count), func(t *testing.T) { | ||
| 17 | channels := Channels{Contents: make(chan ports.SurfaceContent, 1)} | 51 | channels := Channels{Contents: make(chan ports.SurfaceContent, 1)} | ||
| @@ -35,8 +69,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||||
| 35 | if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 { | 69 | if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 { | ||
| 36 | t.Fatalf("previous publication changed: %+v", first) | 70 | t.Fatalf("previous publication changed: %+v", first) | ||
| 37 | } | 71 | } | ||
| 38 | - | if allocs := testing.AllocsPerRun(100, publish); allocs > 4 { | 72 | + | if allocs := testing.AllocsPerRun(100, publish); allocs > 2 { |
| 39 | - | t.Errorf("%d children: %.1f allocs/publication; want <=4", count, allocs) | 73 | + | t.Errorf("%d children: %.1f allocs/publication; want <=2", count, allocs) |
| 40 | } else { | 74 | } else { | ||
| 41 | t.Logf("%d children: %.1f allocs/unchanged publication", count, allocs) | 75 | t.Logf("%d children: %.1f allocs/unchanged publication", count, allocs) | ||
| 42 | } | 76 | } | ||
| @@ -44,8 +78,19 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||||
| 44 | // proportionally to the number of children. | 78 | // proportionally to the number of children. | ||
| 45 | root.sub.pendingLayout = root.sub.layout | 79 | root.sub.pendingLayout = root.sub.layout | ||
| 46 | queue := func() { _ = root.takePending() } | 80 | queue := func() { _ = root.takePending() } | ||
| 47 | - | if allocs := testing.AllocsPerRun(100, queue); allocs > 2 { | 81 | + | if allocs := testing.AllocsPerRun(100, queue); allocs > 0 { |
| 48 | - | t.Errorf("%d children: %.1f allocs/queued commit; want <=2", count, allocs) | 82 | + | t.Errorf("%d children: %.1f allocs/queued commit; want 0", count, allocs) |
| 83 | + | } else { | |||
| 84 | + | t.Logf("%d children: %.1f allocs/takePending", count, allocs) | |||
| 85 | + | } | |||
| 86 | + | commit := func() { | |||
| 87 | + | root.queueUpdate() | |||
| 88 | + | root.dropQueue() | |||
| 89 | + | } | |||
| 90 | + | if allocs := testing.AllocsPerRun(100, commit); allocs != 0 { | |||
| 91 | + | t.Errorf("%d children: %.1f allocs/queueUpdate; want 0", count, allocs) | |||
| 92 | + | } else { | |||
| 93 | + | t.Logf("%d children: %.1f allocs/queueUpdate", count, allocs) | |||
| 49 | } | 94 | } | ||
| 50 | if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 || first.DamageHistory[0].Seq != 1 { | 95 | if first.Seq != 1 || len(first.Children) != count || len(first.DamageHistory) != 1 || first.DamageHistory[0].Seq != 1 { | ||
| 51 | t.Fatal("previous publication was mutated") | 96 | t.Fatal("previous publication was mutated") | ||
| @@ -68,8 +113,8 @@ func TestTiledCommitPublishAllocations(t *testing.T) { | |||||
| 68 | root.treeDirty = true | 113 | root.treeDirty = true | ||
| 69 | root.redraw() | 114 | root.redraw() | ||
| 70 | } | 115 | } | ||
| 71 | - | if allocs := testing.AllocsPerRun(100, changed); allocs > 4 { | 116 | + | if allocs := testing.AllocsPerRun(100, changed); allocs > 3 { |
| 72 | - | t.Errorf("%d children: %.1f allocs/changed publication; want <=4", count, allocs) | 117 | + | t.Errorf("%d children: %.1f allocs/changed publication; want <=3", count, allocs) |
| 73 | } else { | 118 | } else { | ||
| 74 | t.Logf("%d children: %.1f allocs/changed publication", count, allocs) | 119 | t.Logf("%d children: %.1f allocs/changed publication", count, allocs) | ||
| 75 | } | 120 | } | ||