perf(daemon): compose copy-mode frames into the spare buffers (#313)
In copy mode the base frame is copied into the spare composition page with Frame.CopyFrom instead of a fresh Clone. Plain copy viewports, scrolled or not, are built in the spare viewport page the same way, so keyboard moves also stop allocating a frame. Rows outside the scroll region are copied cell by cell instead of through an allocated Frame.Row slice. The committed cache is never written, so failed publications keep it intact. cloneComposeCache now deep-copies the viewport frame, and a failed scroll publication test pins that both committed frames stay unchanged. Copy scroll: -89..-94% B/op (747 KiB -> 60 KiB at 182x53), 166 -> 138 allocs/op, -12..-14% time. Other compose benchmarks unchanged.
- commit
- 668edccb650c6d80f6d8e25ed45311f59ba084e5
- author
- bnema · · 2026-10-04 16:39 UTC
- committer
- GitHub · · 2026-10-04 16:39 UTC
- parent
- 8ca904f
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
4 files · +44 -11
M internal/usecase/daemon/copy_scroll_delta.go +8 -4
| @@ -66,9 +66,11 @@ func copyViewportDamage(previous, next copyViewportState, frame renderer.Frame) | |||
| 66 | 66 | } | |
| 67 | 67 | ||
| 68 | 68 | // Reuse retained compact rows instead of decoding and interning the whole | |
| 69 | - | // immutable viewport. Both committed frames remain untouched until send succeeds. | |
| 70 | - | func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame { | |
| 71 | - | frame := previous.frame.Clone() | |
| 69 | + | // immutable viewport. The result is built in spare, the alternate viewport | |
| 70 | + | // buffer, so both committed frames remain untouched until send succeeds. | |
| 71 | + | func composeScrolledCopyViewport(state capturedRenderState, base, spare renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame { | |
| 72 | + | frame := spare | |
| 73 | + | frame.CopyFrom(previous.frame) | |
| 72 | 74 | target := next.target | |
| 73 | 75 | scroll := damage[0] | |
| 74 | 76 | if scroll.Kind == renderer.DamageScrollDown { | |
| @@ -78,7 +80,9 @@ func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, | |||
| 78 | 80 | } | |
| 79 | 81 | for y := range frame.Height { | |
| 80 | 82 | if y < target.Y || y >= target.Y+target.Height { | |
| 81 | - | frame.WriteRow(y, 0, base.Row(y)) | |
| 83 | + | for x := range frame.Width { | |
| 84 | + | frame.Set(x, y, base.Cell(x, y)) | |
| 85 | + | } | |
| 82 | 86 | } | |
| 83 | 87 | } | |
| 84 | 88 | paint := func(y int, row []renderer.Cell) { | |
| @@ -66,9 +66,11 @@ func copyViewportDamage(previous, next copyViewportState, frame renderer.Frame) | |||||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | // Reuse retained compact rows instead of decoding and interning the whole | 68 | // Reuse retained compact rows instead of decoding and interning the whole | ||
| 69 | - | // immutable viewport. Both committed frames remain untouched until send succeeds. | 69 | + | // immutable viewport. The result is built in spare, the alternate viewport |
| 70 | - | func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame { | 70 | + | // buffer, so both committed frames remain untouched until send succeeds. |
| 71 | - | frame := previous.frame.Clone() | 71 | + | func composeScrolledCopyViewport(state capturedRenderState, base, spare renderer.Frame, previous, next copyViewportState, damage []renderer.Damage) renderer.Frame { |
| 72 | + | frame := spare | |||
| 73 | + | frame.CopyFrom(previous.frame) | |||
| 72 | target := next.target | 74 | target := next.target | ||
| 73 | scroll := damage[0] | 75 | scroll := damage[0] | ||
| 74 | if scroll.Kind == renderer.DamageScrollDown { | 76 | if scroll.Kind == renderer.DamageScrollDown { | ||
| @@ -78,7 +80,9 @@ func composeScrolledCopyViewport(state capturedRenderState, base renderer.Frame, | |||||
| 78 | } | 80 | } | ||
| 79 | for y := range frame.Height { | 81 | for y := range frame.Height { | ||
| 80 | if y < target.Y || y >= target.Y+target.Height { | 82 | if y < target.Y || y >= target.Y+target.Height { | ||
| 81 | - | frame.WriteRow(y, 0, base.Row(y)) | 83 | + | for x := range frame.Width { |
| 84 | + | frame.Set(x, y, base.Cell(x, y)) | |||
| 85 | + | } | |||
| 82 | } | 86 | } | ||
| 83 | } | 87 | } | ||
| 84 | paint := func(y int, row []renderer.Cell) { | 88 | paint := func(y int, row []renderer.Cell) { | ||
M internal/usecase/daemon/copy_scroll_replay_test.go +21 -0
| @@ -66,3 +66,24 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) { | |||
| 66 | 66 | }) | |
| 67 | 67 | } | |
| 68 | 68 | } | |
| 69 | + | ||
| 70 | + | // TestCopyWheelFailedScrollKeepsCommittedFrames proves a scroll that fails to | |
| 71 | + | // publish writes only the spare buffers: the committed base and viewport | |
| 72 | + | // frames are byte-for-byte unchanged. | |
| 73 | + | func TestCopyWheelFailedScrollKeepsCommittedFrames(t *testing.T) { | |
| 74 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |
| 75 | + | f.d.enterCopyMode(f.sess, f.ac) | |
| 76 | + | for _, delta := range []int{-10, -3} { | |
| 77 | + | f.d.copyWheel(f.sess, f.ac, delta) | |
| 78 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |
| 79 | + | } | |
| 80 | + | require.NotZero(t, f.ac.render.spare.copyViewport.frame.Width, "both buffers must hold a viewport page") | |
| 81 | + | before := cloneComposeCache(f.ac.render.cache) | |
| 82 | + | var compositions int | |
| 83 | + | f.ac.renderStages = renderStageHooks{compose: func() { compositions++ }} | |
| 84 | + | f.ac.replaceTransport(cacheFailTransport{}) | |
| 85 | + | f.d.copyWheel(f.sess, f.ac, -3) | |
| 86 | + | f.d.paint(f.sess, f.ac, false, nil) | |
| 87 | + | require.Positive(t, compositions, "the failed scroll must be composed") | |
| 88 | + | require.Equal(t, before, cloneComposeCache(f.ac.render.cache)) | |
| 89 | + | } | |
| @@ -66,3 +66,24 @@ func TestCopyWheelReplayMatchesFullComposition(t *testing.T) { | |||||
| 66 | }) | 66 | }) | ||
| 67 | } | 67 | } | ||
| 68 | } | 68 | } | ||
| 69 | + | ||||
| 70 | + | // TestCopyWheelFailedScrollKeepsCommittedFrames proves a scroll that fails to | |||
| 71 | + | // publish writes only the spare buffers: the committed base and viewport | |||
| 72 | + | // frames are byte-for-byte unchanged. | |||
| 73 | + | func TestCopyWheelFailedScrollKeepsCommittedFrames(t *testing.T) { | |||
| 74 | + | f := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 80, Rows: 24}, panes: 1, historyRows: 200}) | |||
| 75 | + | f.d.enterCopyMode(f.sess, f.ac) | |||
| 76 | + | for _, delta := range []int{-10, -3} { | |||
| 77 | + | f.d.copyWheel(f.sess, f.ac, delta) | |||
| 78 | + | f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next) | |||
| 79 | + | } | |||
| 80 | + | require.NotZero(t, f.ac.render.spare.copyViewport.frame.Width, "both buffers must hold a viewport page") | |||
| 81 | + | before := cloneComposeCache(f.ac.render.cache) | |||
| 82 | + | var compositions int | |||
| 83 | + | f.ac.renderStages = renderStageHooks{compose: func() { compositions++ }} | |||
| 84 | + | f.ac.replaceTransport(cacheFailTransport{}) | |||
| 85 | + | f.d.copyWheel(f.sess, f.ac, -3) | |||
| 86 | + | f.d.paint(f.sess, f.ac, false, nil) | |||
| 87 | + | require.Positive(t, compositions, "the failed scroll must be composed") | |||
| 88 | + | require.Equal(t, before, cloneComposeCache(f.ac.render.cache)) | |||
| 89 | + | } | |||
M internal/usecase/daemon/render_pipeline.go +14 -7
| @@ -102,10 +102,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 102 | 102 | } | |
| 103 | 103 | if in.frame.Width == width && in.frame.Height == frameHeight { | |
| 104 | 104 | if state.overlays.copyMode != nil { | |
| 105 | - | // Compact Clone preserves page-local style IDs. Re-interning every | |
| 106 | - | // cell into scratch costs more than a structural copy while scrolling. | |
| 107 | - | // The clone still isolates committed state from failed publications. | |
| 108 | - | frame = in.frame.Clone() | |
| 105 | + | // A structural copy preserves page-local style IDs. Re-interning every | |
| 106 | + | // cell costs more while scrolling. Copying into the spare page keeps | |
| 107 | + | // committed state isolated from failed publications without a new page. | |
| 108 | + | frame.CopyFrom(in.frame) | |
| 109 | 109 | } else { | |
| 110 | 110 | for y := 0; y < frame.Height; y++ { | |
| 111 | 111 | for x := range frame.Width { | |
| @@ -207,9 +207,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 207 | 207 | } | |
| 208 | 208 | if overlaysActive { | |
| 209 | 209 | if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight { | |
| 210 | - | frame = composeScrolledCopyViewport(state, baseFrame, in.copyViewport, copyViewport, scrollDamage) | |
| 210 | + | frame = composeScrolledCopyViewport(state, baseFrame, scratch.copyViewport.frame, in.copyViewport, copyViewport, scrollDamage) | |
| 211 | 211 | } else { | |
| 212 | - | if !toastsVisible && !state.floating.visible { | |
| 212 | + | switch { | |
| 213 | + | case copyOnly && !toastsVisible: | |
| 214 | + | // A plain copy viewport becomes the next viewport cache, so build | |
| 215 | + | // it in the spare viewport page, which never shares baseFrame's. | |
| 216 | + | frame = scratch.copyViewport.frame | |
| 217 | + | frame.CopyFrom(baseFrame) | |
| 218 | + | case !toastsVisible && !state.floating.visible: | |
| 213 | 219 | frame = baseFrame.Clone() | |
| 214 | 220 | } | |
| 215 | 221 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | |
| @@ -241,7 +247,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 241 | 247 | cursorInputs := state.cursor | |
| 242 | 248 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | |
| 243 | 249 | cursor := desiredCapturedCursor(cursorInputs, contentY) | |
| 244 | - | // A plain copy viewport is painted on a clone, never into baseFrame. Keep | |
| 250 | + | // A plain copy viewport is painted on a clone or the spare viewport page, | |
| 251 | + | // never into baseFrame. Keep | |
| 245 | 252 | // that unadorned base reusable between wheel events. Modal/floating paths | |
| 246 | 253 | // retain their conservative invalidation, and copy exit requests a reset. | |
| 247 | 254 | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | |
| @@ -102,10 +102,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 102 | } | 102 | } | ||
| 103 | if in.frame.Width == width && in.frame.Height == frameHeight { | 103 | if in.frame.Width == width && in.frame.Height == frameHeight { | ||
| 104 | if state.overlays.copyMode != nil { | 104 | if state.overlays.copyMode != nil { | ||
| 105 | - | // Compact Clone preserves page-local style IDs. Re-interning every | 105 | + | // A structural copy preserves page-local style IDs. Re-interning every |
| 106 | - | // cell into scratch costs more than a structural copy while scrolling. | 106 | + | // cell costs more while scrolling. Copying into the spare page keeps |
| 107 | - | // The clone still isolates committed state from failed publications. | 107 | + | // committed state isolated from failed publications without a new page. |
| 108 | - | frame = in.frame.Clone() | 108 | + | frame.CopyFrom(in.frame) |
| 109 | } else { | 109 | } else { | ||
| 110 | for y := 0; y < frame.Height; y++ { | 110 | for y := 0; y < frame.Height; y++ { | ||
| 111 | for x := range frame.Width { | 111 | for x := range frame.Width { | ||
| @@ -207,9 +207,15 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 207 | } | 207 | } | ||
| 208 | if overlaysActive { | 208 | if overlaysActive { | ||
| 209 | if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight { | 209 | if scrollDamage != nil && in.copyViewport.frame.Width == width && in.copyViewport.frame.Height == frameHeight { | ||
| 210 | - | frame = composeScrolledCopyViewport(state, baseFrame, in.copyViewport, copyViewport, scrollDamage) | 210 | + | frame = composeScrolledCopyViewport(state, baseFrame, scratch.copyViewport.frame, in.copyViewport, copyViewport, scrollDamage) |
| 211 | } else { | 211 | } else { | ||
| 212 | - | if !toastsVisible && !state.floating.visible { | 212 | + | switch { |
| 213 | + | case copyOnly && !toastsVisible: | |||
| 214 | + | // A plain copy viewport becomes the next viewport cache, so build | |||
| 215 | + | // it in the spare viewport page, which never shares baseFrame's. | |||
| 216 | + | frame = scratch.copyViewport.frame | |||
| 217 | + | frame.CopyFrom(baseFrame) | |||
| 218 | + | case !toastsVisible && !state.floating.visible: | |||
| 213 | frame = baseFrame.Clone() | 219 | frame = baseFrame.Clone() | ||
| 214 | } | 220 | } | ||
| 215 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | 221 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | ||
| @@ -241,7 +247,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 241 | cursorInputs := state.cursor | 247 | cursorInputs := state.cursor | ||
| 242 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | 248 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | ||
| 243 | cursor := desiredCapturedCursor(cursorInputs, contentY) | 249 | cursor := desiredCapturedCursor(cursorInputs, contentY) | ||
| 244 | - | // A plain copy viewport is painted on a clone, never into baseFrame. Keep | 250 | + | // A plain copy viewport is painted on a clone or the spare viewport page, |
| 251 | + | // never into baseFrame. Keep | |||
| 245 | // that unadorned base reusable between wheel events. Modal/floating paths | 252 | // that unadorned base reusable between wheel events. Modal/floating paths | ||
| 246 | // retain their conservative invalidation, and copy exit requests a reset. | 253 | // retain their conservative invalidation, and copy exit requests a reset. | ||
| 247 | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | 254 | outCache := composeCacheInput{valid: !overlaysActive || copyOnly, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | ||
M internal/usecase/daemon/render_pipeline_cache_test.go +1 -0
| @@ -340,6 +340,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||
| 340 | 340 | func cloneComposeCache(in composeCacheInput) composeCacheInput { | |
| 341 | 341 | out := in | |
| 342 | 342 | out.frame = in.frame.Clone() | |
| 343 | + | out.copyViewport.frame = in.copyViewport.frame.Clone() | |
| 343 | 344 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | |
| 344 | 345 | maps.Copy(out.titleGenerations, in.titleGenerations) | |
| 345 | 346 | out.damage = append([]renderer.Damage(nil), in.damage...) | |
| @@ -340,6 +340,7 @@ func cacheState(title string, generation uint64) capturedRenderState { | |||||
| 340 | func cloneComposeCache(in composeCacheInput) composeCacheInput { | 340 | func cloneComposeCache(in composeCacheInput) composeCacheInput { | ||
| 341 | out := in | 341 | out := in | ||
| 342 | out.frame = in.frame.Clone() | 342 | out.frame = in.frame.Clone() | ||
| 343 | + | out.copyViewport.frame = in.copyViewport.frame.Clone() | |||
| 343 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | 344 | out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations)) | ||
| 344 | maps.Copy(out.titleGenerations, in.titleGenerations) | 345 | maps.Copy(out.titleGenerations, in.titleGenerations) | ||
| 345 | out.damage = append([]renderer.Damage(nil), in.damage...) | 346 | out.damage = append([]renderer.Damage(nil), in.damage...) | ||