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:
Kinneyzhang 2026-07-26 22:03:05 +08:00
parent c703ce0226
commit c4d7fdf8bb
4 changed files with 90 additions and 13 deletions

View File

@ -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 47.
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

View File

@ -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` 47 参。
特性完成后的性能(字节编译 + 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`:完整的

View File

@ -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
View File

@ -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))