diff --git a/Makefile b/Makefile index 50b96e1..d4feac6 100644 --- a/Makefile +++ b/Makefile @@ -2,26 +2,48 @@ EMACS ?= emacs ECSS_DIR ?= ../ecss TP_DIR ?= ../tp LOAD_PATH = -L . -L ../ebox -L $(ECSS_DIR) -L $(TP_DIR) +EBOX_NATIVE_RELEASE_DIR = $(shell $(EMACS) -Q --batch $(LOAD_PATH) \ + --eval '(setq native-comp-jit-compilation nil)' \ + -l ../ebox/ebox-native-reflow.el \ + --eval '(princ (expand-file-name (format "../ebox/native/target/%s/release/" (ebox-native-reflow--rust-target))))') -.PHONY: all compile test check checkdoc load performance-evaluator clean +.PHONY: all compile test check checkdoc load performance performance-prepare \ + performance-evaluator clean all: check compile: rm -f *.elc tests/*.elc - $(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \ + $(EMACS) -Q --batch $(LOAD_PATH) \ + --eval '(setq load-prefer-newer t native-comp-jit-compilation nil byte-compile-error-on-warn t)' \ --eval '(load-file "ebox-playground.el")' \ - --eval '(byte-compile-file "ebox-playground.el")' + --eval '(byte-compile-file "ebox-playground.el")' \ + --eval '(byte-compile-file "scripts/ebox-playground-flex-resize-evaluator.el")' test: compile - $(EMACS) -Q --batch $(LOAD_PATH) --eval '(setq load-prefer-newer t)' \ + $(EMACS) -Q --batch $(LOAD_PATH) \ + --eval '(setq load-prefer-newer t native-comp-jit-compilation nil)' \ -l tests/ebox-playground-tests.el -f ert-run-tests-batch-and-exit load: compile - $(EMACS) -Q --batch $(LOAD_PATH) --eval '(require (quote ebox-playground))' \ + $(EMACS) -Q --batch $(LOAD_PATH) \ + --eval '(setq native-comp-jit-compilation nil)' \ + --eval '(require (quote ebox-playground))' \ --eval '(princ "ebox-playground load OK\n")' +performance: + $(MAKE) performance-prepare EMACS="$(EMACS)" ECSS_DIR="$(ECSS_DIR)" TP_DIR="$(TP_DIR)" + $(MAKE) performance-evaluator EMACS="$(EMACS)" ECSS_DIR="$(ECSS_DIR)" TP_DIR="$(TP_DIR)" + +performance-prepare: + $(MAKE) -C $(ECSS_DIR) compile EMACS="$(EMACS)" + $(MAKE) -C $(TP_DIR) compile EMACS="$(EMACS)" + $(MAKE) -C ../ebox compile EMACS="$(EMACS)" ECSS_DIR="$(ECSS_DIR)" TP_DIR="$(TP_DIR)" + $(MAKE) compile EMACS="$(EMACS)" ECSS_DIR="$(ECSS_DIR)" TP_DIR="$(TP_DIR)" + $(MAKE) -C ../ebox native-build EMACS="$(EMACS)" ECSS_DIR="$(ECSS_DIR)" TP_DIR="$(TP_DIR)" + performance-evaluator: + EBOX_NATIVE_REFLOW_MODULE_PATH="$(EBOX_NATIVE_RELEASE_DIR)" \ EMACS="$(EMACS)" ./scripts/flex-resize-evaluator.sh checkdoc: diff --git a/README.md b/README.md index 272c3b6..5ee1d52 100644 --- a/README.md +++ b/README.md @@ -28,3 +28,12 @@ The reference fixtures live directly under `examples/`. For example: ``` Run `make check EMACS=/Applications/Emacs.app/Contents/MacOS/Emacs` from this directory. + +Run `make performance` for the committed Flex resize gate. It prepares ECSS, +TP, Ebox, Playground bytecode, and the Rust module, then renders a read-only +`HEAD:examples/flex-reference.ebox` snapshot without touching the working-tree +fixture. The GUI driver performs five warmups followed by 30 accepted viewport +updates; requested and published widths must match exactly, and both p95 and max +must remain at or below 50ms. For uncontaminated repeated samples, run +`make performance-prepare`, let compilation activity settle, then run +`make performance-evaluator` without rebuilding. diff --git a/scripts/ebox-playground-flex-resize-evaluator.el b/scripts/ebox-playground-flex-resize-evaluator.el index 0b69d9a..dd10153 100644 --- a/scripts/ebox-playground-flex-resize-evaluator.el +++ b/scripts/ebox-playground-flex-resize-evaluator.el @@ -13,14 +13,21 @@ (require 'cl-lib) (require 'ebox-playground) +(require 'ebox-native-reflow) -(defconst ebox-playground-flex-resize-evaluator--fixture - (expand-file-name "examples/flex-reference.ebox" - ebox-playground-directory) - "Flex fixture used by the performance evaluator.") +(defcustom ebox-playground-flex-resize-evaluator-fixture + (or (getenv "EBOX_PLAYGROUND_FLEX_FIXTURE") + (expand-file-name "examples/flex-reference.ebox" + ebox-playground-directory)) + "Read-only Flex fixture used by the performance evaluator." + :type 'file + :group 'ebox-playground) -(defcustom ebox-playground-flex-resize-evaluator-max-seconds 0.5 - "Maximum allowed end-to-end time for one settled preview resize." +(defconst ebox-playground-flex-resize-evaluator-warmup-count 5) +(defconst ebox-playground-flex-resize-evaluator-sample-count 30) + +(defcustom ebox-playground-flex-resize-evaluator-max-seconds 2.0 + "Maximum allowed end-to-end time for the measured resize burst." :type 'number :group 'ebox-playground) @@ -37,18 +44,21 @@ (defvar ebox-playground-flex-resize-evaluator--output-lines nil "Report lines collected during one evaluator run.") +(defun ebox-playground-flex-resize-evaluator--percentile + (samples percentile) + "Return nearest-rank PERCENTILE from numeric SAMPLES." + (unless samples + (error "Flex evaluator cannot summarize empty samples")) + (let* ((ordered (sort (copy-sequence samples) #'<)) + (rank (max 1 (ceiling (* percentile (length ordered)))))) + (nth (1- rank) ordered))) + (defconst ebox-playground-flex-resize-evaluator--runtime-properties (delete-dups (append (mapcar #'cdr ebox-region-types) '(ebox-content-idx ebox-content-owners ebox-scroll-window))) "Ebox identity properties omitted from fresh-render comparison.") -(defun ebox-playground-flex-resize-evaluator--preview-buffer () - "Return the preview buffer for the fixed flex fixture." - (get-buffer - (format "*Ebox Preview: %s*" - ebox-playground-flex-resize-evaluator--fixture))) - (defun ebox-playground-flex-resize-evaluator--line-widths (buffer) "Return pixel widths for every rendered line in BUFFER." (with-current-buffer buffer @@ -84,9 +94,10 @@ mounted the split. This setup publication is outside the measured splitter action, so the action starts from a self-consistent runtime generation rather than charging initial frame settling to resize." (let* ((width (ebox-playground--window-viewport-width preview-window)) - (state (and (buffer-live-p preview) - (ebox--buffer-render-state preview))) - (published-width (and state (plist-get state :viewport-width)))) + (report (ebox-buffer-update-report preview)) + (published-width (and report + (or (plist-get report :target-viewport-width) + (plist-get report :viewport-width))))) (unless (numberp width) (error "flex preview has no settled viewport width")) (when (not (equal width published-width)) @@ -109,26 +120,37 @@ concurrent cache warming CPU work." (source nil) (preview nil) (preview-window nil) - (resize-advice nil) + (warmup-publications 0) (resize-count 0) - (resize-elapsed 0.0) (resize-finished nil) (action-start nil) (last-event-elapsed nil) (resize-publications nil) - (resize-durations nil) + (publication-durations nil) + (requested-widths nil) + (accepted-widths nil) + (observer nil) + (native-report nil) + (playground-library (symbol-file 'ebox-playground-open 'defun)) + (evaluator-library + (symbol-file 'ebox-playground-flex-resize-evaluator-run 'defun)) (completed-without-error nil) (ebox-playground-flex-resize-evaluator--output-lines nil) (checks nil)) (condition-case-unless-debug error-data (progn (delete-other-windows) + (setq native-report (ebox-native-reflow-runtime-report)) + (unless (plist-get native-report :layout-ready-p) + (error "Flex evaluator requires the prepared native layout module")) + (unless (file-readable-p + ebox-playground-flex-resize-evaluator-fixture) + (error "Flex evaluator fixture is not readable: %s" + ebox-playground-flex-resize-evaluator-fixture)) (setq source - (find-file ebox-playground-flex-resize-evaluator--fixture)) + (find-file ebox-playground-flex-resize-evaluator-fixture)) (ebox-dsl-mode) - (ebox-dsl-render) - (setq preview - (ebox-playground-flex-resize-evaluator--preview-buffer) + (setq preview (ebox-dsl-render) preview-window (get-buffer-window preview (selected-frame))) (unless (and (buffer-live-p preview) (window-live-p preview-window)) @@ -139,30 +161,52 @@ concurrent cache warming CPU work." preview preview-window) (redisplay t) (sleep-for 0.2) - (setq resize-advice - (lambda (original &rest arguments) - (setq resize-count (1+ resize-count)) - (let ((start (float-time))) - (unwind-protect - (apply original arguments) - (let ((duration (- (float-time) start))) - (setq resize-elapsed (+ resize-elapsed duration)) - (push duration resize-durations)) - (when action-start - (push (- (float-time) action-start) - resize-publications)) - (setq resize-finished t))))) - (advice-add 'ebox-rerender-buffer-with-context - :around resize-advice) + ;; Observe only accepted, completed Ebox publications. TP emits its + ;; paired report first; filtering by provider keeps this evaluator on + ;; the public Ebox boundary and avoids timing/advising the rerender + ;; implementation itself. + (setq observer + (lambda (_buffer report) + (when (and (eq (plist-get report :provider) 'ebox) + (eq (plist-get report :stage) 'viewport)) + (setq resize-count (1+ resize-count) + resize-finished t) + (when action-start + (push (- (float-time) action-start) + resize-publications)) + (push (/ (or (plist-get report :duration-ms) 0.0) + 1000.0) + publication-durations) + (push (or (plist-get report :target-viewport-width) + (plist-get report :viewport-width)) + accepted-widths)))) + (ebox-buffer-set-observer preview observer) + (dotimes (index ebox-playground-flex-resize-evaluator-warmup-count) + (window-resize preview-window (if (zerop (% index 2)) -1 1) t) + (redisplay t) + (sleep-for 0.01)) + (setq warmup-publications resize-count + resize-count 0 + resize-finished nil + resize-publications nil + publication-durations nil + requested-widths nil + accepted-widths nil) (let* ((start (float-time)) (old-width (ebox-playground--window-viewport-width preview-window))) (setq action-start start) - (dolist (delta '(-4 2 -4 2 -4 2)) - (setq last-event-elapsed (- (float-time) start)) - (window-resize preview-window delta t) - (redisplay t) - (sleep-for 0.02)) + (dotimes (index ebox-playground-flex-resize-evaluator-sample-count) + (let ((delta + (if (< index 28) + (if (zerop (% index 2)) -1 1) + -1))) + (setq last-event-elapsed (- (float-time) start)) + (window-resize preview-window delta t) + (push (ebox-playground--window-viewport-width preview-window) + requested-widths) + (redisplay t) + (sleep-for 0.01))) (let* ((elapsed (- (float-time) start)) (publication-times (sort (copy-sequence resize-publications) #'<)) @@ -173,7 +217,14 @@ concurrent cache warming CPU work." (first-publication (car publication-times)) (last-publication (car (last publication-times))) (max-publication - (if resize-durations (apply #'max resize-durations) 0.0)) + (if publication-durations + (apply #'max publication-durations) + 0.0)) + (p95-publication + (if publication-durations + (ebox-playground-flex-resize-evaluator--percentile + publication-durations 0.95) + 0.0)) (max-gap (if publication-gaps (apply #'max publication-gaps) 0.0)) (final-latency @@ -183,10 +234,8 @@ concurrent cache warming CPU work." (ebox-playground--window-viewport-width preview-window)) (report (ebox-buffer-update-report preview)) (projection-kind (plist-get report :projection-kind)) - (native-flex-calls - (and (boundp - 'ebox-native-reflow--flex-geometry-call-count) - ebox-native-reflow--flex-geometry-call-count)) + (expected-widths (nreverse requested-widths)) + (published-widths (nreverse accepted-widths)) (line-widths (ebox-playground-flex-resize-evaluator--line-widths preview)) @@ -202,16 +251,14 @@ concurrent cache warming CPU work." ;; Compare the published visible scroll ;; window with a fresh render at the same ;; viewport height. Omitting this binding - ;; rendered the entire lazy document and made - ;; every correct retained resize look unequal. (ebox-viewport-height - (or (plist-get - (ebox--buffer-render-state preview) - :viewport-height) + (or (plist-get report + :target-viewport-height) + (plist-get report :viewport-height) 36))) (ebox-render (ebox-playground-view - ebox-playground-flex-resize-evaluator--fixture + ebox-playground-flex-resize-evaluator-fixture viewport-width))))) (fresh-render-matches-p (and (stringp fresh-rendered) @@ -230,24 +277,55 @@ concurrent cache warming CPU work." (not (plist-get report :tp-scope-fallback))))) (setq checks (list + (ebox-playground-flex-resize-evaluator--check + "compiled evaluator artifacts" + (and (stringp playground-library) + (string-suffix-p ".elc" playground-library) + (stringp evaluator-library) + (string-suffix-p ".elc" evaluator-library)) + (format "playground=%S evaluator=%S" + playground-library evaluator-library)) + (ebox-playground-flex-resize-evaluator--check + "native module prepared" + (plist-get native-report :layout-ready-p) + (format "path=%S hash=%S" + (plist-get native-report :loaded-module-path) + (plist-get native-report :loaded-module-hash))) (ebox-playground-flex-resize-evaluator--check "preview mounted" (and (buffer-live-p preview) (window-live-p preview-window)) (format "source=%S preview=%S" source preview)) + (ebox-playground-flex-resize-evaluator--check + "warmup publications" + (= warmup-publications + ebox-playground-flex-resize-evaluator-warmup-count) + (format "warmups=%d expected=%d" + warmup-publications + ebox-playground-flex-resize-evaluator-warmup-count)) (ebox-playground-flex-resize-evaluator--check "intermediate resize updates" - (and (>= resize-count 4) + (and (= resize-count + ebox-playground-flex-resize-evaluator-sample-count) + (= resize-count (length expected-widths)) first-publication (<= first-publication ebox-playground-flex-resize-evaluator-max-update-seconds) (< first-publication last-event-elapsed)) - (format "updates=%d first=%.6fs last-event=%.6fs" + (format "updates=%d samples=%d first=%.6fs last-event=%.6fs" resize-count + ebox-playground-flex-resize-evaluator-sample-count (or first-publication 0.0) last-event-elapsed)) (ebox-playground-flex-resize-evaluator--check - "per-update resize budget" + "per-update p95 budget" + (<= p95-publication + ebox-playground-flex-resize-evaluator-max-update-seconds) + (format "p95=%.6fs threshold=%.6fs" + p95-publication + ebox-playground-flex-resize-evaluator-max-update-seconds)) + (ebox-playground-flex-resize-evaluator--check + "per-update max budget" (<= max-publication ebox-playground-flex-resize-evaluator-max-update-seconds) (format "max=%.6fs threshold=%.6fs" @@ -277,11 +355,15 @@ concurrent cache warming CPU work." "viewport was published" (and (numberp viewport-width) (not (equal old-width viewport-width)) + (cl-every #'numberp published-widths) + (equal expected-widths published-widths) + (equal viewport-width (car (last published-widths))) (eq (plist-get report :constraint-source) 'viewport) (plist-get report :runtime-published)) - (format "viewport=%S strategy=%S" - viewport-width (plist-get report :strategy))) + (format "viewport=%S expected=%S accepted=%S strategy=%S" + viewport-width expected-widths published-widths + (plist-get report :strategy))) (ebox-playground-flex-resize-evaluator--check "retained viewport projection" projection-retained-p @@ -304,21 +386,22 @@ concurrent cache warming CPU work." '((default (:background "#FAF7F0"))) ) (format "face-remapping=%S" background)) (ebox-playground-flex-resize-evaluator--check - "resize is millisecond-scale" + "bounded resize burst" (<= elapsed ebox-playground-flex-resize-evaluator-max-seconds) (format "elapsed=%.6fs threshold=%.6fs" elapsed ebox-playground-flex-resize-evaluator-max-seconds)))) (push (format - "RESIZE old-viewport-px=%S new-viewport-px=%S total=%.6fs rerender=%.6fs first=%.6fs max-update=%.6fs max-gap=%.6fs final=%.6fs strategy=%S projection=%S axes=%S reconciled=%S native-flex=%S tp-ops=%S cache-hits=%S cache-misses=%S" - old-width viewport-width elapsed resize-elapsed - (or first-publication 0.0) max-publication max-gap + "RESIZE warmups=%d samples=%d old-viewport-px=%S new-viewport-px=%S total=%.6fs p95-update=%.6fs max-update=%.6fs first=%.6fs max-gap=%.6fs final=%.6fs strategy=%S projection=%S axes=%S reconciled=%S tp-ops=%S cache-hits=%S cache-misses=%S" + warmup-publications resize-count old-width viewport-width + elapsed p95-publication max-publication + (or first-publication 0.0) max-gap (or final-latency -1.0) (plist-get report :strategy) projection-kind (plist-get report :viewport-axes) (plist-get report :reconciled-objects) - native-flex-calls tp-operations + tp-operations (plist-get report :cache-hit-count) (plist-get report :cache-miss-count)) ebox-playground-flex-resize-evaluator--output-lines) @@ -333,8 +416,8 @@ concurrent cache warming CPU work." (push "FAIL evaluator interrupted" ebox-playground-flex-resize-evaluator--output-lines) nil)) - (when resize-advice - (advice-remove 'ebox-rerender-buffer-with-context resize-advice)) + (when (and (buffer-live-p preview) observer) + (ebox-buffer-set-observer preview nil)) (when (buffer-live-p preview) (kill-buffer preview)) (when (buffer-live-p source) diff --git a/scripts/flex-resize-evaluator.sh b/scripts/flex-resize-evaluator.sh index 9c28b0d..4193e64 100755 --- a/scripts/flex-resize-evaluator.sh +++ b/scripts/flex-resize-evaluator.sh @@ -5,12 +5,42 @@ root=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) emacs=${EMACS:-/Applications/Emacs.app/Contents/MacOS/Emacs} emacsclient=${EMACSCLIENT:-/Applications/Emacs.app/Contents/MacOS/bin/emacsclient} server="ebox-playground-flex-evaluator-$$" +fixture=$(mktemp "${TMPDIR:-/tmp}/ebox-flex-reference.XXXXXX") + +require_fresh_elc() { + source_file=$1 + elc_file=$2 + if [ ! -r "$elc_file" ] || [ "$source_file" -nt "$elc_file" ]; then + echo "stale or missing evaluator artifact: $elc_file" >&2 + echo "run make performance-prepare first" >&2 + exit 1 + fi +} cleanup() { "$emacsclient" -n -s "$server" -e '(kill-emacs 0)' >/dev/null 2>&1 || true + rm -f "$fixture" } trap cleanup EXIT INT TERM +require_fresh_elc "$root/ebox-playground.el" "$root/ebox-playground.elc" +require_fresh_elc \ + "$root/scripts/ebox-playground-flex-resize-evaluator.el" \ + "$root/scripts/ebox-playground-flex-resize-evaluator.elc" +require_fresh_elc "$root/../ebox/ebox.el" "$root/../ebox/ebox.elc" +require_fresh_elc "$root/../ebox/ebox-surface.el" "$root/../ebox/ebox-surface.elc" +require_fresh_elc "$root/../ecss/ecss.el" "$root/../ecss/ecss.elc" +require_fresh_elc "$root/../tp/tp.el" "$root/../tp/tp.elc" + +: "${EBOX_NATIVE_REFLOW_MODULE_PATH:?run make performance-prepare first}" +if [ ! -r "$EBOX_NATIVE_REFLOW_MODULE_PATH/libebox_native_reflow.dylib" ]; then + echo "prepared Ebox native module is missing" >&2 + exit 1 +fi + +git -C "$root" show HEAD:examples/flex-reference.ebox >"$fixture" +export EBOX_PLAYGROUND_FLEX_FIXTURE=$fixture + # Keep the evaluator package-only, but reproduce only the four startup values # that directly affect this render path. Loading the complete user init would # add unrelated packages, timers, and UI state to the performance gate. @@ -18,12 +48,15 @@ trap cleanup EXIT INT TERM -L "$root" -L "$root/../ebox" -L "$root/../ecss" \ -L "$root/../tp" \ --eval '(setq load-prefer-newer t + native-comp-jit-compilation nil + ebox-native-reflow-module-path + (getenv "EBOX_NATIVE_REFLOW_MODULE_PATH") gc-cons-threshold most-positive-fixnum gc-cons-percentage 0.6 file-name-handler-alist nil auto-window-vscroll nil)' \ - -l "$root/ebox-playground.el" \ - -l "$root/scripts/ebox-playground-flex-resize-evaluator.el" + -l "$root/ebox-playground" \ + -l "$root/scripts/ebox-playground-flex-resize-evaluator" output=$("$emacsclient" -n -s "$server" -c -e \ '(ebox-playground-flex-resize-evaluator-run)' 2>&1) diff --git a/tests/ebox-playground-tests.el b/tests/ebox-playground-tests.el index 79d6256..da0743b 100644 --- a/tests/ebox-playground-tests.el +++ b/tests/ebox-playground-tests.el @@ -137,6 +137,36 @@ "EXPLICIT PLACEMENT" "PUBLIC COMPOSITION")) (should (string-match-p (regexp-quote label) rendered))))) +(ert-deftest ebox-playground-flex-evaluator-uses-public-observation () + "Keep the Flex resize gate on public Ebox reports without advice." + (let ((source (with-temp-buffer + (insert-file-contents + (expand-file-name + "scripts/ebox-playground-flex-resize-evaluator.el" + ebox-playground-directory)) + (buffer-string))) + (runner (with-temp-buffer + (insert-file-contents + (expand-file-name "scripts/flex-resize-evaluator.sh" + ebox-playground-directory)) + (buffer-string))) + (makefile (with-temp-buffer + (insert-file-contents + (expand-file-name "Makefile" ebox-playground-directory)) + (buffer-string)))) + (should (string-match-p "ebox-buffer-set-observer" source)) + (should (string-match-p "evaluator-warmup-count 5" source)) + (should (string-match-p "evaluator-sample-count 30" source)) + (should (string-match-p "per-update p95 budget" source)) + (should (string-match-p "EBOX_PLAYGROUND_FLEX_FIXTURE" runner)) + (should (string-match-p "show HEAD:examples/flex-reference.ebox" runner)) + (should (string-match-p "performance-prepare:" makefile)) + (should (string-match-p "native-build" makefile)) + (should-not (string-match-p "-l \"\$root/ebox-playground.el\"" runner)) + (dolist (private '("ebox--" "advice-add" "advice-remove" + "ebox-native-reflow--")) + (should-not (string-match-p (regexp-quote private) source))))) + (ert-deftest ebox-playground-keeps-layout-in-ebox-source () "Keep concrete gallery content out of the generic Elisp runner." (let ((runner (with-temp-buffer