perf: eliminate cold-scroll flicker and GC stalls

This commit is contained in:
Kinneyzhang 2026-08-27 16:42:59 +08:00
parent 0c7e7b974a
commit 9f48b2f283
3 changed files with 148 additions and 74 deletions

View File

@ -223,6 +223,11 @@ owned; no per-fragment substring is allocated."
(setq line-index (1+ line-index))) (setq line-index (1+ line-index)))
(nreverse result)))) (nreverse result))))
(defun ebox-surface--scroll-lines-owned-p (lines)
"Return non-nil when every rendered line has an owned fragment template."
(and lines
(cl-every #'ebox-surface--scroll-line-fragment-template lines)))
(defun ebox-surface--style-state-table (previous-state) (defun ebox-surface--style-state-table (previous-state)
"Copy retained style state from PREVIOUS-STATE into a weak table." "Copy retained style state from PREVIOUS-STATE into a weak table."
(let ((table (make-hash-table :test 'eq :weakness 'key))) (let ((table (make-hash-table :test 'eq :weakness 'key)))
@ -3310,12 +3315,14 @@ box with wrapper chrome falls back to the retained wrapper renderer."
scroll-state)))) scroll-state))))
(if (and visible (cadr visible)) (if (and visible (cadr visible))
(let* ((lines (cadr visible)) (let* ((lines (cadr visible))
(output (ebox-lines-join lines)) (output (ebox-lines-join lines)))
(fragment-data (when (and (ebox-surface--scroll-lines-owned-p lines)
(ebox-surface--scroll-fragment-data lines output)))
(when (and fragment-data
(plist-get state :retained-scroll-content-p)) (plist-get state :retained-scroll-content-p))
(plist-put state :scroll-patch-fragment-data fragment-data)) ;; Retain immutable line handles and stream their cached role
;; runs directly into ownership planning. Materializing one
;; fragment plist per visible property run on every wheel
;; event made scroll allocation O(all visible fragments).
(plist-put state :scroll-patch-lines lines))
(when (> (length output) 0) (when (> (length output) 0)
(add-text-properties (add-text-properties
0 (length output) 0 (length output)
@ -3745,25 +3752,18 @@ fragment's complete paint-owner ancestor chain."
(push copy metadata) (push copy metadata)
(setq offset (+ offset length)))))) (setq offset (+ offset length))))))
(defun ebox-surface--owned-ranges (defun ebox-surface--owned-ranges-from-runs
(context leaf fragments state node-objects region-objects (context leaf state node-objects region-objects attach-p visit-runs)
&optional attach-p) "Attach merged ownership produced by VISIT-RUNS to candidate LEAF.
"Attach merged owner ranges for FRAGMENTS to candidate LEAF. VISIT-RUNS receives one callback accepting ROLE-IDS, START, and END.
Adjacent ranges merge only when both their owner and opaque tags match." Adjacent ranges merge only when both their owner and opaque tags match."
(let ((active (make-hash-table :test #'eq)) (let ((active (make-hash-table :test #'eq))
(owner-cache (make-hash-table :test #'equal)) (owner-cache (make-hash-table :test #'equal))
(offset 0)
ranges) ranges)
(dolist (fragment fragments) (funcall
(let* ((role-ids (plist-get fragment :role-ids)) visit-runs
(owners (gethash role-ids owner-cache 'ebox/no-owners)) (lambda (role-ids start end)
(source-p (plist-get fragment :text-source-p)) (let ((owners (gethash role-ids owner-cache 'ebox/no-owners)))
(start (if source-p
(plist-get fragment :start)
offset))
(end (if source-p
(plist-get fragment :end)
(+ offset (length (plist-get fragment :text))))))
(when (eq owners 'ebox/no-owners) (when (eq owners 'ebox/no-owners)
(setq owners (setq owners
(ebox-surface--fragment-owners (ebox-surface--fragment-owners
@ -3772,7 +3772,8 @@ Adjacent ranges merge only when both their owner and opaque tags match."
(dolist (owner owners) (dolist (owner owners)
(let* ((object (nth 0 owner)) (let* ((object (nth 0 owner))
(tags (nth 1 owner)) (tags (nth 1 owner))
(tag-ranges (or (gethash object active) (tag-ranges
(or (gethash object active)
(let ((table (make-hash-table :test #'equal))) (let ((table (make-hash-table :test #'equal)))
(puthash object table active) (puthash object table active)
table))) table)))
@ -3782,13 +3783,52 @@ Adjacent ranges merge only when both their owner and opaque tags match."
(let ((range (list :object object :start start :end end (let ((range (list :object object :start start :end end
:tags tags))) :tags tags)))
(push range ranges) (push range ranges)
(puthash tags range tag-ranges))))) (puthash tags range tag-ranges))))))))
(setq offset (if source-p end (+ offset (length (plist-get fragment :text)))))))
(setq ranges (nreverse ranges)) (setq ranges (nreverse ranges))
(when attach-p (when attach-p
(tp-object-attach-content-ranges-owned context leaf ranges)) (tp-object-attach-content-ranges-owned context leaf ranges))
ranges)) ranges))
(defun ebox-surface--owned-ranges
(context leaf fragments state node-objects region-objects
&optional attach-p)
"Attach merged owner ranges for FRAGMENTS to candidate LEAF."
(ebox-surface--owned-ranges-from-runs
context leaf state node-objects region-objects attach-p
(lambda (visit)
(let ((offset 0))
(dolist (fragment fragments)
(let* ((source-p (plist-get fragment :text-source-p))
(start (if source-p
(plist-get fragment :start)
offset))
(end (if source-p
(plist-get fragment :end)
(+ offset (length (plist-get fragment :text))))))
(funcall visit (plist-get fragment :role-ids) start end)
(setq offset
(if source-p
end
(+ offset (length (plist-get fragment :text)))))))))))
(defun ebox-surface--scroll-owned-ranges
(context leaf lines state node-objects region-objects attach-p)
"Attach merged owner ranges for retained scroll LINES to candidate LEAF."
(ebox-surface--owned-ranges-from-runs
context leaf state node-objects region-objects attach-p
(lambda (visit)
(let ((offset 0))
(dolist (line lines)
(let ((template
(or (ebox-surface--scroll-line-fragment-template line)
(error "Ebox retained scroll line lost fragment ownership"))))
(dolist (fragment template)
(funcall visit
(plist-get fragment :role-ids)
(+ offset (plist-get fragment :start))
(+ offset (plist-get fragment :end))))
(setq offset (+ offset (length line) 1))))))))
(defun ebox-surface--native-owned-ranges (defun ebox-surface--native-owned-ranges
(context leaf template state node-objects region-objects attach-p) (context leaf template state node-objects region-objects attach-p)
"Attach owner ranges from compact native fragment TEMPLATE." "Attach owner ranges from compact native fragment TEMPLATE."
@ -3832,11 +3872,17 @@ Adjacent ranges merge only when both their owner and opaque tags match."
(defun ebox-surface--materialized-fragment-ledger (state) (defun ebox-surface--materialized-fragment-ledger (state)
"Return STATE's paint ledger, expanding a native frame only on demand." "Return STATE's paint ledger, expanding a native frame only on demand."
(let ((ledger (plist-get state :surface-fragments))) (let* ((ledger (plist-get state :surface-fragments))
(if-let* ((frame (and (listp ledger) (frame (and (listp ledger) (plist-get ledger :native-frame)))
(plist-get ledger :native-frame)))) (lines (and (listp ledger) (plist-get ledger :scroll-lines))))
(ebox-native-reflow-frame-fragments frame) (cond
ledger))) (frame (ebox-native-reflow-frame-fragments frame))
(lines
(ebox-surface--fragment-metadata
(or (ebox-surface--scroll-fragment-data lines "")
(error "Ebox retained scroll lines lost fragment ownership"))
state))
(t ledger))))
(defun ebox-surface--formatting-context-reflow-owned-ranges (defun ebox-surface--formatting-context-reflow-owned-ranges
(context leaf ranges state node-objects) (context leaf ranges state node-objects)
@ -3875,14 +3921,16 @@ all descendant ranges remain unchanged."
&optional transfer-text-p) &optional transfer-text-p)
"Return one shared-text TP plan for OUTPUT and STATE. "Return one shared-text TP plan for OUTPUT and STATE.
NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges." NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(let* ((fragment-root (let* ((scroll-lines
(and transfer-text-p
(plist-get state :retained-scroll-content-p)
(plist-get state :scroll-patch-lines)))
(fragment-root
(tp-object-ensure context surface-root (tp-object-ensure context surface-root
ebox-surface--fragments-key 'ebox/fragments)) ebox-surface--fragments-key 'ebox/fragments))
(fragment-data (fragment-data
(or (and transfer-text-p (unless scroll-lines
(plist-get state :retained-scroll-content-p) (or (and (eq output (plist-get state :native-render-output))
(plist-get state :scroll-patch-fragment-data))
(and (eq output (plist-get state :native-render-output))
(plist-get state :native-render-fragment-template)) (plist-get state :native-render-fragment-template))
(and (eq (plist-get state :projection-kind) (and (eq (plist-get state :projection-kind)
'mixed-owner-reflow) 'mixed-owner-reflow)
@ -3892,7 +3940,7 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(plist-get state :span-patch-fragment-data)) (plist-get state :span-patch-fragment-data))
(if (stringp output) (if (stringp output)
(ebox-surface--rendered-fragments output) (ebox-surface--rendered-fragments output)
output))) output))))
(rendered (rendered
(if (stringp output) (if (stringp output)
output output
@ -3922,6 +3970,10 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(eq output (plist-get state :native-render-output)))) (eq output (plist-get state :native-render-output))))
(owned-ranges (owned-ranges
(cond (cond
(scroll-lines
(ebox-surface--scroll-owned-ranges
context text-leaf scroll-lines state node-objects
region-objects attach-p))
((and native-template-p ((and native-template-p
(plist-get state :native-reuse-ownership-p)) (plist-get state :native-reuse-ownership-p))
(let ((ranges (let ((ranges
@ -4009,6 +4061,8 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(cond (cond
((plist-get state :native-render-frame) ((plist-get state :native-render-frame)
(list :native-frame (plist-get state :native-render-frame))) (list :native-frame (plist-get state :native-render-frame)))
(scroll-lines
(list :scroll-lines (copy-sequence scroll-lines)))
((or (plist-get state :mixed-owner-content-p) ((or (plist-get state :mixed-owner-content-p)
(plist-get state :span-patch-content-p)) (plist-get state :span-patch-content-p))
;; Mixed projection already produced fresh offset-only ;; Mixed projection already produced fresh offset-only
@ -4020,6 +4074,7 @@ NODE-OBJECTS and REGION-OBJECTS supply retained ownership ranges."
(cl-remf state :mixed-owner-content-p) (cl-remf state :mixed-owner-content-p)
(cl-remf state :span-patch-fragment-data) (cl-remf state :span-patch-fragment-data)
(cl-remf state :span-patch-content-p) (cl-remf state :span-patch-content-p)
(cl-remf state :scroll-patch-lines)
(let ((rendered-length (length rendered))) (let ((rendered-length (length rendered)))
(list plan rendered owned-ranges (list plan rendered owned-ranges
(list (list :object text-leaf :start 0 :end rendered-length (list (list :object text-leaf :start 0 :end rendered-length

22
ebox.el
View File

@ -954,10 +954,11 @@ several costly rows before yielding back to input."
This is the synchronous edge of the lazy scroll model: visible scrolling can This is the synchronous edge of the lazy scroll model: visible scrolling can
only replace cached line strings after those strings exist. When the user only replace cached line strings after those strings exist. When the user
reaches the current lazy prefix boundary, render at most one configured reaches the current lazy prefix boundary, render one configured interactive
interactive slice instead of pausing for an idle timer or materializing the slice plus the fixed lazy lookahead instead of pausing for an idle timer or
entire source. BUDGET-LINES caps how many new source lines this call may materializing the entire source. The lookahead amortizes composite source
request; callers that represent GUI animation ticks pass their tick size." rows across following wheel events. BUDGET-LINES caps the visible slice;
callers that represent GUI animation ticks pass their tick size."
(let* ((content-lines (plist-get state :content-lines)) (let* ((content-lines (plist-get state :content-lines))
(content-height (or (plist-get state :content-height) 0)) (content-height (or (plist-get state :content-height) 0))
(current-lines (length content-lines)) (current-lines (length content-lines))
@ -970,8 +971,7 @@ request; callers that represent GUI animation ticks pass their tick size."
(slice-lines (max 1 (or budget-lines ebox-scroll-step 1))) (slice-lines (max 1 (or budget-lines ebox-scroll-step 1)))
(slice-end (+ current-lines slice-lines)) (slice-end (+ current-lines slice-lines))
(required-lines (max current-lines (required-lines (max current-lines
(min visible-end slice-end))) (min visible-end slice-end))))
(ebox-scroll-lazy-prefix-lookahead-lines 0))
(ebox--scroll-state-ensure-prefix-lines (ebox--scroll-state-ensure-prefix-lines
region-id state required-lines t))))) region-id state required-lines t)))))
@ -3138,8 +3138,14 @@ inside the old prefix, so a line-slide never mixes two layout versions."
(throw 'result (list :result 'pending))) (throw 'result (list :result 'pending)))
(when (and (> delta 0) (when (and (> delta 0)
(or prefix-budget-lines (or prefix-budget-lines
(and (ebox--scroll-sync-prefix-render-p) ;; A small foreground intent already has an explicit
(<= delta (ebox--scroll-prefetch-slice-lines))))) ;; frame budget. Extend exactly that bounded prefix
;; before publication so Ebox remains the sole owner of
;; the visible scroll coordinate. Deferring this case
;; leaves the intent residual to Emacs buffer scrolling;
;; the following Ebox commit then snaps WINDOW-START
;; back, producing a cold-cache flicker.
(<= delta (ebox--scroll-prefetch-slice-lines))))
(setq state (setq state
(ebox--scroll-ensure-bounded-prefix-for-offset (ebox--scroll-ensure-bounded-prefix-for-offset
region-id state desired prefix-budget-lines))) region-id state desired prefix-budget-lines)))

View File

@ -8309,8 +8309,8 @@
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))
(kill-buffer buffer))))) (kill-buffer buffer)))))
(ert-deftest ebox-lazy-scroll-gui-cache-miss-returns-pending () (ert-deftest ebox-lazy-scroll-gui-small-cache-miss-stays-in-ebox-coordinate ()
"GUI scroll cache misses should not synchronously render missing content." "A bounded GUI cache miss should extend and scroll in one Ebox commit."
(ebox-test--reset-runtime-state) (ebox-test--reset-runtime-state)
(let* ((layout (let* ((layout
(ebox-test-build (ebox-test-build
@ -8331,7 +8331,7 @@
(ebox-viewport-height 20) (ebox-viewport-height 20)
(ebox-scroll-step 4) (ebox-scroll-step 4)
(ebox-scroll-lazy-idle-prefetch-lines 0) (ebox-scroll-lazy-idle-prefetch-lines 0)
(ebox-scroll-lazy-prefix-lookahead-lines 0) (ebox-scroll-lazy-prefix-lookahead-lines 8)
(noninteractive nil)) (noninteractive nil))
(cl-letf (((symbol-function 'display-graphic-p) (cl-letf (((symbol-function 'display-graphic-p)
(lambda (&optional _display) t))) (lambda (&optional _display) t)))
@ -8355,9 +8355,22 @@
(should (= boundary-offset (should (= boundary-offset
(ebox--scroll-region-by root-id (ebox--scroll-region-by root-id
boundary-offset))) boundary-offset)))
(should (eq 'pending (should (= 4 (ebox--scroll-region-by root-id 4)))
(ebox--scroll-region-by root-id 4))))) (should (= (+ boundary-offset 4)
(should (= prefix-count 0)))) (plist-get (ebox--scroll-get-state root-id)
:scroll-offset)))
(should (= (+ initial-lines 12)
(length
(plist-get (ebox--scroll-get-state root-id)
:content-lines))))
(should
(= content-height
(length
(plist-get
(plist-get (ebox--buffer-render-state buffer)
:surface-fragments)
:scroll-lines))))))
(should (= prefix-count 1))))
(when root-id (when root-id
(ebox--smooth-scroll-stop root-id)) (ebox--smooth-scroll-stop root-id))
(when (and buffer (buffer-live-p buffer)) (when (and buffer (buffer-live-p buffer))