perf: reclaim feature costs — hot-path caches for smooth reflow
Investigation: the apparent 7× bench regression was interpreted-mode measurement (tests always deleted .elc); byte-compiled + C the real P1/P2 cost was +70–90%. Worktree A/B across phase commits confirmed the features themselves added ~+20% interpreted. Optimizations (all verified by the 66-test suite + 300-case fuzz): - per-char memo table for ekp--str-type (get-char-code-property was the para-build hot spot) - precomputed punctuation char lists (no per-call list rebuild) - ekp--box-offsets memoized in the paragraph struct - interned glue strings for the two dominant payload shapes - rendered-output cache per (paragraph, width) inside dp-cache (capped at 64 widths per paragraph), so resize sweeps that revisit a width skip rendering entirely - unjustify walks hop property boundaries instead of chars Numbers (byte-compiled + C): justify zh w=200 53.8 ms — at parity with the pre-feature record (57 ms) despite punctuation boxes and break permissions; range zh 117 ms (+55%, inherent box-count cost). Continuous reflow, 60-paragraph 26k-char article through the full region layer: 187 ms → 73 ms per width change; incremental single-paragraph re-justify after an edit: 62 ms → 17 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
c703ce0226
commit
c4d7fdf8bb
10
DEVELOPER.md
10
DEVELOPER.md
@ -193,6 +193,16 @@ C module 1.4: `ekp-c-break-with-arrays` takes 14 args
|
||||
(…, forbidden-positions, tail-protrudes, hyphen-protrude); batch
|
||||
vectors have 14 elements; `ekp-c-set-penalties` takes 4–7.
|
||||
|
||||
Performance after the feature wave (byte-compiled + C, Apple
|
||||
Silicon, batch): justify zh w=200 ≈ 54 ms, range zh ≈ 117 ms —
|
||||
justify at parity with the pre-feature numbers, range ≈ +55 % from
|
||||
the larger box count. Hot-path caches: per-char `ekp--str-type'
|
||||
memo, interned glue strings, per-(paragraph, width) rendered-output
|
||||
cache in the dp-cache (capped at 64 widths). Continuous-reflow
|
||||
reality check (60-paragraph, 26 k-char article, region layer
|
||||
included): ≈ 73 ms per width change cold, less on revisit;
|
||||
incremental single-paragraph re-justify after an edit ≈ 17 ms.
|
||||
|
||||
## 6. Looseness
|
||||
|
||||
`ekp-looseness` ≠ 0 switches to `ekp--dp-run-loose`, a full
|
||||
|
||||
@ -170,6 +170,13 @@ C 模块 1.4:`ekp-c-break-with-arrays` 14 参(…、
|
||||
forbidden-positions、tail-protrudes、hyphen-protrude);batch 向量
|
||||
14 元;`ekp-c-set-penalties` 4–7 参。
|
||||
|
||||
特性完成后的性能(字节编译 + C,Apple Silicon,batch):justify zh
|
||||
w=200 ≈ 54 ms、range zh ≈ 117 ms——justify 与特性前持平,range 因盒
|
||||
数增加约 +55%。热路径缓存:`ekp--str-type` 按字符记忆化、glue 字符
|
||||
串驻留、(段落, 宽度) 渲染结果缓存进 dp-cache(上限 64 个宽度)。连
|
||||
续变宽实测(60 段 2.6 万字文章,含 region 层全链路):每次变宽约
|
||||
73 ms,重访宽度更快;编辑后单段增量重排约 17 ms。
|
||||
|
||||
## 6. Looseness
|
||||
|
||||
`ekp-looseness` ≠ 0 时切换到 `ekp--dp-run-loose`:完整的
|
||||
|
||||
@ -238,7 +238,13 @@ tails. Text the user typed into the justified region is preserved."
|
||||
(remove-text-properties pos (1+ pos)
|
||||
'(ekp-hidden nil display nil))
|
||||
(forward-char 1))
|
||||
(t (forward-char 1)))))
|
||||
;; Plain text: our markers are sparse, so hop straight
|
||||
;; to the next property boundary instead of stepping
|
||||
;; char by char.
|
||||
(t (goto-char (min (marker-position end-m)
|
||||
(next-property-change pos nil
|
||||
(marker-position
|
||||
end-m))))))))
|
||||
(remove-text-properties (min beg end) end-m '(ekp-justified nil)))
|
||||
(set-marker end-m nil))))
|
||||
|
||||
|
||||
78
ekp.el
78
ekp.el
@ -173,6 +173,9 @@ when non-zero the C module is bypassed automatically.")
|
||||
;; zeros when `ekp-protrusion' is off); hyphen-protrude = same for
|
||||
;; the soft hyphen at a hyphenated break.
|
||||
tail-protrudes hyphen-protrude
|
||||
;; Lazily memoized (START . END) offsets of each box in the source
|
||||
;; string (render-time lossless payloads); content-invariant.
|
||||
(box-offsets-memo nil)
|
||||
;; Glue params snapshot at para creation time (plist)
|
||||
glue-params
|
||||
(dp-cache nil :type hash-table))
|
||||
@ -305,11 +308,20 @@ Returns (boxes-vector . hyphen-positions-vector)."
|
||||
(cons (vconcat (apply #'append (nreverse new-boxes)))
|
||||
(vconcat (nreverse hyphen-idxs)))))
|
||||
|
||||
(defvar ekp--str-type-table (make-char-table 'ekp-str-type)
|
||||
"Per-character memo for `ekp--str-type' (a pure classification).")
|
||||
|
||||
(defun ekp--str-type (str)
|
||||
"Classify single-character string STR.
|
||||
Returns one of `space', `latin', `cjk', `cjk-open', `cjk-close'.
|
||||
`cjk-open' must not end a line; `cjk-close' must not start one
|
||||
\(kinsoku) — enforced via `ekp-para-breaks-allowed'."
|
||||
(let ((c (aref str 0)))
|
||||
(or (aref ekp--str-type-table c)
|
||||
(aset ekp--str-type-table c (ekp--str-type-1 str)))))
|
||||
|
||||
(defun ekp--str-type-1 (str)
|
||||
"Uncached `ekp--str-type'."
|
||||
(cond
|
||||
;; Whitespace or zero-width characters
|
||||
((or (string-blank-p str) (= (string-width str) 0)) 'space)
|
||||
@ -390,9 +402,12 @@ class instead.")
|
||||
Same box-level rule as `ekp--no-line-start-chars'; fullwidth openers
|
||||
are covered by the `cjk-open' class.")
|
||||
|
||||
(defun ekp--box-pure-set-p (box set)
|
||||
"Non-nil when BOX is non-empty and every char is a member of SET."
|
||||
(let ((len (length box)) (chars (append set nil)) (i 0) (all t))
|
||||
(defconst ekp--no-line-start-char-list (append ekp--no-line-start-chars nil))
|
||||
(defconst ekp--no-line-end-char-list (append ekp--no-line-end-chars nil))
|
||||
|
||||
(defun ekp--box-pure-set-p (box chars)
|
||||
"Non-nil when BOX is non-empty and every char is a member of CHARS."
|
||||
(let ((len (length box)) (i 0) (all t))
|
||||
(when (> len 0)
|
||||
(while (and all (< i len))
|
||||
(unless (memq (aref box i) chars)
|
||||
@ -403,12 +418,12 @@ are covered by the `cjk-open' class.")
|
||||
(defun ekp--box-no-line-start-p (box box-type)
|
||||
"Non-nil if BOX must not appear at the start of a line."
|
||||
(or (eq (car box-type) 'cjk-close)
|
||||
(ekp--box-pure-set-p box ekp--no-line-start-chars)))
|
||||
(ekp--box-pure-set-p box ekp--no-line-start-char-list)))
|
||||
|
||||
(defun ekp--box-no-line-end-p (box box-type)
|
||||
"Non-nil if BOX must not appear at the end of a line."
|
||||
(or (eq (cdr box-type) 'cjk-open)
|
||||
(ekp--box-pure-set-p box ekp--no-line-end-chars)))
|
||||
(ekp--box-pure-set-p box ekp--no-line-end-char-list)))
|
||||
|
||||
(defun ekp--compute-glue-types (boxes boxes-types hyphen-positions)
|
||||
"Compute glue types for BOXES. Positions after HYPHEN-POSITIONS are `nws'."
|
||||
@ -546,7 +561,7 @@ reduces the number of `string-pixel-width' calls."
|
||||
((eq tail-type 'cjk-close)
|
||||
(alist-get 'cjk-close ekp-protrusion-ratios 0))
|
||||
((memq (aref box (1- (length box)))
|
||||
(append ekp--no-line-start-chars nil))
|
||||
ekp--no-line-start-char-list)
|
||||
(alist-get 'latin-close ekp-protrusion-ratios 0))
|
||||
(t 0))))
|
||||
(if (> ratio 0)
|
||||
@ -1819,14 +1834,34 @@ leftmost scan aligns them unambiguously."
|
||||
string
|
||||
(propertize string 'ekp-hidden t 'display "")))
|
||||
|
||||
(defvar ekp--glue-string-cache (make-hash-table :test 'eql)
|
||||
"PIXEL → shared glue string for empty payloads (pure, shareable).")
|
||||
|
||||
(defvar ekp--glue-space-string-cache (make-hash-table :test 'eql)
|
||||
"PIXEL → shared glue string for a plain single-space payload.")
|
||||
|
||||
(defun ekp--render-glue (pixel payload)
|
||||
"Render a glue of PIXEL width that replaced original text PAYLOAD.
|
||||
Zero-width glue renders as the hidden PAYLOAD itself, so no original
|
||||
character is ever dropped."
|
||||
character is ever dropped. The common payloads (empty, plain space)
|
||||
are interned per width: glue strings are immutable, so sharing is
|
||||
safe and avoids re-allocating properties for every gap."
|
||||
(cond
|
||||
((> pixel 0)
|
||||
(propertize " " 'display `(space :width (,pixel)) 'ekp-glue payload))
|
||||
(t (ekp--hide-string payload))))
|
||||
((and (= pixel 0) (string-empty-p payload)) "")
|
||||
((<= pixel 0) (ekp--hide-string payload))
|
||||
((string-empty-p payload)
|
||||
(or (gethash pixel ekp--glue-string-cache)
|
||||
(puthash pixel
|
||||
(propertize " " 'display `(space :width (,pixel))
|
||||
'ekp-glue payload)
|
||||
ekp--glue-string-cache)))
|
||||
((and (string= payload " ") (null (object-intervals payload)))
|
||||
(or (gethash pixel ekp--glue-space-string-cache)
|
||||
(puthash pixel
|
||||
(propertize " " 'display `(space :width (,pixel))
|
||||
'ekp-glue payload)
|
||||
ekp--glue-space-string-cache)))
|
||||
(t (propertize " " 'display `(space :width (,pixel)) 'ekp-glue payload))))
|
||||
|
||||
(defun ekp--hyphen-for-box (box)
|
||||
"Return a hyphen string styled like the end of BOX.
|
||||
@ -1837,6 +1872,22 @@ The `ekp-soft-hyphen' property marks it as synthesized, so
|
||||
(apply #'propertize "-" 'ekp-soft-hyphen t props)))
|
||||
|
||||
(defun ekp--pixel-justify (string line-pixel)
|
||||
"Justify single-paragraph STRING to LINE-PIXEL, with render caching.
|
||||
The rendered string for a (paragraph, width) pair is deterministic,
|
||||
so it is stored in the paragraph's dp-cache entry and reused — resize
|
||||
sweeps that revisit a width pay nothing."
|
||||
(let* ((para (ekp--get-para string))
|
||||
(dp (ekp-dp-data string line-pixel))
|
||||
(hit (plist-get dp :rendered)))
|
||||
(or hit
|
||||
(let ((rendered (ekp--pixel-justify-1 string line-pixel))
|
||||
(cache (ekp-para-dp-cache para)))
|
||||
;; keep memory bounded during long resize sessions
|
||||
(when (<= (hash-table-count cache) 64)
|
||||
(puthash line-pixel (plist-put dp :rendered rendered) cache))
|
||||
rendered))))
|
||||
|
||||
(defun ekp--pixel-justify-1 (string line-pixel)
|
||||
"Justify single-paragraph STRING to LINE-PIXEL.
|
||||
|
||||
The output is lossless with respect to STRING:
|
||||
@ -1848,8 +1899,11 @@ The output is lossless with respect to STRING:
|
||||
survives as zero-display `ekp-hidden' text,
|
||||
- break hyphens carry `ekp-soft-hyphen'.
|
||||
`ekp-unjustify-region' inverts all four structurally."
|
||||
(let* ((boxes (append (ekp--boxes string) nil))
|
||||
(offsets (ekp--box-offsets string boxes))
|
||||
(let* ((para (ekp--get-para string))
|
||||
(boxes (append (ekp-para-boxes para) nil))
|
||||
(offsets (or (ekp-para-box-offsets-memo para)
|
||||
(setf (ekp-para-box-offsets-memo para)
|
||||
(ekp--box-offsets string boxes))))
|
||||
(breaks (ekp-line-breaks string line-pixel))
|
||||
(num (length breaks))
|
||||
(lines-glues (ekp-line-glues string line-pixel))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user