penpot/common/dev/bench/component_sync.clj
alonso.torres d1104c608b ⚡ Make component sync about twice as fast
A profile of the benchmark worlds showed where a sync spends its time.

- The token sync of shapes that apply no tokens took 38% of a sync in
  the 1000-copy world: for every synced shape, generate-update-tokens
  walked every sync attribute, resolved its group and its token
  attributes, and only then found that neither the main shape nor the
  copy applies a token. It now returns at once, and the token
  attributes of each sync attribute are computed once, at load.
- A sync looks through every object of each page and component to
  find the copies it updates, so in a file with many shapes and few
  copies the search costs more than the sync itself. The new benchmark
  world main-edit-among-many-shapes syncs a main edit into 10 copies in
  a page with 20,000 plain rects; its baseline, measured before the
  change below, joins the recorded one with a note, and its result
  joins the golden master. generate-sync-container called the
  uses-assets? multimethod on every object through a lazy filter; the
  predicate is now chosen once per container (asset-user?) and the
  objects walked with reduce-kv into a vector, in the same order.
- compare-children searched the remaining children (d/seek) for every
  child out of place, and dropped a child taken out of order with a
  lazy remove, so a reorder took quadratic time and nested one lazy
  sequence per child taken. It now looks children up by their
  reference or id (index-positions) and skips those taken out of order
  when the walk reaches them, with the same callbacks in the same
  order. The sync of a reorder stays slow: when a main with 1,000
  children moves its first one to the end, each copy still gets 999
  moves (F85), and applying them is what costs (4.75 s to 4.26 s for
  10 copies). Fewer moves change what a sync writes, so that waits for
  Phase 4c.

No behavior change: the golden masters and the suites are unchanged.

Benchmark (lower quantile, ms; baseline / now / ratio):
  deepest-edit-through-three-levels    35.2 /  18.6 / 0.53
  edit-through-a-chain-of-dependents   68.9 /  36.4 / 0.53
  library-update-into-copies           83.1 /  43.0 / 0.52
  main-edit-among-many-shapes           4.8 /   2.3 / 0.48
  main-edit-into-10-copies              1.8 /   0.9 / 0.48
  main-edit-into-100-copies            15.4 /   8.4 / 0.54
  main-edit-into-1000-copies          153.1 /  90.1 / 0.59
  variant-switch-into-copies           32.1 /  20.0 / 0.63
(main-edit-among-many-shapes: baseline measured before the search
change.)

AI-assisted-by: claude-opus-5-5
2026-10-28 15:53:06 +01:00

360 lines
14 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS SUBSIDIARY SL
(ns bench.component-sync
"Benchmark of component sync on fixed-size worlds, with a recorded
baseline to compare against.
Each world is built once; only the work a user edit causes is timed: the
sync of the changed components, then of the components each sync changed,
until none is left (the frontend watcher does one pass per sync commit),
with every change applied to the file.
Run from `common/` with `scripts/bench-components` (`record` writes the
baseline, `compare`, the default, checks against it)."
(:require
[app.common.files.changes-builder :as pcb]
[app.common.logic.libraries :as cll]
[app.common.logic.shapes :as cls]
[app.common.logic.variants :as clv]
[app.common.test-helpers.components :as thc]
[app.common.test-helpers.composable.comp.runner :as r]
[app.common.test-helpers.compositions :as tho]
[app.common.test-helpers.files :as thf]
[app.common.test-helpers.ids-map :as thi]
[app.common.test-helpers.shapes :as ths]
[app.common.test-helpers.variants :as thv]
[app.common.types.file :as ctf]
[app.common.types.pages-list :as ctpl]
[app.common.types.shape :as cts]
[app.common.uuid :as uuid]
[clojure.edn :as edn]
[clojure.java.io :as io]
[clojure.java.shell :as shell]
[clojure.pprint :as pp]
[clojure.string :as str]
[criterium.core :as cri]))
(def ^:private baseline-path "dev/bench/component_sync_baseline.edn")
;; A world slower than its baseline by more than this ratio is flagged.
(def ^:private tolerance 1.10)
(def ^:private bench-opts
{:samples 20
:target-execution-time (* 500 1000 1000)
:warmup-jit-period (* 3 1000 1000 1000)})
;; How long each world runs untimed before any is measured, in ms, so the
;; code they share is compiled before the first measurement.
(def ^:private warmup-ms 3000)
;; ---- Sync
(defn- apply-changes
[file changes]
(thf/apply-changes file changes :validate? false))
(defn- edit
"`file` after `f` changed the shape `label` through the production path,
and the ids of the components that changed."
[file label f]
(let [page (thf/current-page file)
changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page))
#{(thi/id label)}
f
(:objects page)
{})]
[(apply-changes file changes) (r/changed-components (:data file) changes)]))
(defn- sync-cascade
"`[file passes]` after syncing the copies of `component-ids` in `file` to
a fixpoint (see `r/sync-cascade`)."
[file libraries component-ids]
(let [{:keys [file passes]} (r/sync-cascade file libraries component-ids)]
[file passes]))
(defn- red
[shape]
(assoc shape :fills (ths/sample-fills-color :fill-color "#ff0000")))
;; ---- Worlds
;;
;; A world is `{:key :describe :run :reds}`; `run` is the timed work, a
;; function of no arguments returning `[file passes]`, and `reds` how many
;; rects the resulting file must have red, so a world that synced nothing
;; fails instead of timing nothing. A world whose file uses libraries lists
;; them under `:libraries`.
(defn- red-rects
[file]
(->> (vals (:objects (thf/current-page file)))
(filter #(and (= :rect (:type %))
(= "#ff0000" (-> % :fills first :fill-color))))
(count)))
(defn- instantiate-many
"`file` with `n` copies of the component `label`, labelled `prefix-<i>`."
[file label prefix n & {:as params}]
(reduce (fn [file i]
(thc/instantiate-component file label (keyword (str prefix "-" i)) params))
file
(range n)))
(defn- nest
"`file` with a new component `label`, whose main `root-label` holds a copy
of the component `inner-label`."
[file label root-label inner-label]
(-> file
(tho/add-frame root-label)
(thc/instantiate-component inner-label (keyword (str (name root-label) "-inner"))
:parent-label root-label)
(thc/make-component label root-label)))
(defn- add-plain-shapes
"`file` with `n` plain rects at the root of its page, added in one step."
[file n]
(let [page (thf/current-page file)
rects (map (fn [i]
(cts/setup-shape {:type :rect :id (uuid/next) :name (str "Rect " i)
:x i :y i :width 10 :height 10
:parent-id uuid/zero :frame-id uuid/zero}))
(range n))]
(ctf/update-file-data
file
(fn [data]
(ctpl/update-page data (:id page)
(fn [page]
(-> page
(update :objects into (map (juxt :id identity)) rects)
(update-in [:objects uuid/zero :shapes] into (map :id rects)))))))))
(defn- main-edit-among-many-shapes
"One main whose child changes, synced into 10 copies in a page that also
holds 20000 plain shapes, which the sync must look through."
[]
(let [[file ids] (-> (thf/sample-file :file1)
(tho/add-simple-component :c0 :root0 :child0)
(add-plain-shapes 20000)
(instantiate-many :c0 "copy" 10)
(edit :child0 red))]
{:key :main-edit-among-many-shapes
:describe "a main child changes; 10 copies among 20000 plain shapes sync"
:run #(sync-cascade file {} ids)
:reds 11}))
(defn- main-edit-into-copies
"One main whose child changes, synced into `n` copies."
[n]
(let [[file ids] (-> (thf/sample-file :file1)
(tho/add-simple-component :c0 :root0 :child0)
(instantiate-many :c0 "copy" n)
(edit :child0 red))]
{:key (keyword (str "main-edit-into-" n "-copies"))
:describe (str "a main child changes; " n " copies sync")
:run #(sync-cascade file {} ids)
:reds (inc n)}))
(defn- deepest-edit-through-three-levels
"Components c1 ⊃ c0, c2 ⊃ c1, c3 ⊃ c2, 100 copies of c3; c0's child
changes."
[]
(let [[file ids] (-> (thf/sample-file :file1)
(tho/add-simple-component :c0 :root0 :child0)
(nest :c1 :root1 :c0)
(nest :c2 :root2 :c1)
(nest :c3 :root3 :c2)
(instantiate-many :c3 "copy" 100)
(edit :child0 red))]
{:key :deepest-edit-through-three-levels
:describe "the deepest of 3 nesting levels changes; 100 copies of the top sync"
:run #(sync-cascade file {} ids)
;; the main, its images in the three outer mains, and the copies
:reds 104}))
(defn- edit-through-a-chain-of-dependents
"Components a, b ⊃ a, c ⊃ b, with 100 copies of each; a's child changes."
[]
(let [[file ids] (-> (thf/sample-file :file1)
(tho/add-simple-component :a :root-a :child-a)
(nest :b :root-b :a)
(nest :c :root-c :b)
(instantiate-many :a "copy-a" 100)
(instantiate-many :b "copy-b" 100)
(instantiate-many :c "copy-c" 100)
(edit :child-a red))]
{:key :edit-through-a-chain-of-dependents
:describe "a changes in the chain a ⊂ b ⊂ c; 100 copies of each sync"
:run #(sync-cascade file {} ids)
;; the main, its images in b and c, and the copies
:reds 303}))
(defn- variant-switch-into-copies
"A component whose main holds a copy of variant member v1, with 100 copies;
the copy in the main switches to member v2."
[]
(let [file (-> (thf/sample-file :file1)
(thv/add-variant-with-child :vset :v1 :v1-root :v2 :v2-root :v1-child :v2-child
:child2-params {:fills (ths/sample-fills-color :fill-color "#ff0000")})
(nest :t :root-t :v1)
(instantiate-many :t "copy" 100))
page (thf/current-page file)
shape (ths/get-shape file :root-t-inner)
libs {(:id file) file}
target (clv/find-switch-target libs shape 0 "Value2")
switch (fn []
(let [[_ changes _] (clv/generate-component-swap-in-place
(pcb/empty-changes nil (:id page))
page libs (:data file) shape (:id target) true)]
(sync-cascade (apply-changes file changes)
{}
(r/changed-components (:data file) changes))))]
{:key :variant-switch-into-copies
:describe "a variant copy in a main switches; 100 copies of the main sync"
:run switch
;; member v2's rect, the switched copy in the main, and the copies
:reds 102}))
(defn- library-update-into-copies
"A library component with 500 copies in a consuming file; the library main
changed and the file accepts the update."
[]
(let [library (-> (thf/sample-file :library :is-shared true)
(tho/add-simple-component :lc :lroot :lchild))
file (-> (thf/sample-file :file1)
(instantiate-many :lc "copy" 500 :library library))
[library _] (edit library :lchild red)
libraries {(:id library) library (:id file) file}
update (fn []
(let [changes (cll/generate-sync-file-changes (pcb/empty-changes)
nil
:components
(:id file)
nil
(:id library)
libraries
(:id file))]
[(apply-changes file changes) 1]))]
{:key :library-update-into-copies
:describe "a library main changed; a file with 500 copies updates"
:run update
:reds 500
:libraries [library]}))
(defn world-builders
"One function of no arguments per world, building it. Each world builds
its file with test labels, so the golden master builds one world at a
time and reads its labels before the next one replaces them."
[]
[#(main-edit-into-copies 10)
#(main-edit-into-copies 100)
#(main-edit-into-copies 1000)
deepest-edit-through-three-levels
edit-through-a-chain-of-dependents
variant-switch-into-copies
library-update-into-copies
main-edit-among-many-shapes])
(defn- worlds
[]
(mapv #(%) (world-builders)))
;; ---- Measuring
(defn- ms
[seconds]
(/ (Math/round (* seconds 1e6)) 1e3))
(defn- measure
[{:keys [key describe run reds]}]
(println (str " " (name key) ": " describe))
(let [[file passes] (run)
_ (when-not (= reds (red-rects file))
(throw (ex-info (str (name key) " did not sync: " (red-rects file)
" red rects, expected " reds)
{:world key})))
result (cri/benchmark* run bench-opts)]
[key {:mean-ms (ms (first (:mean result)))
:lower-q-ms (ms (first (:lower-q result)))
:passes passes}]))
(defn- warm-up!
[worlds]
(doseq [{:keys [run]} worlds]
(let [end (+ (System/currentTimeMillis) warmup-ms)]
(while (< (System/currentTimeMillis) end)
(run)))))
(defn- run-all
[]
(let [worlds (worlds)]
(println " warming up")
(warm-up! worlds)
(into (sorted-map) (map measure) worlds)))
(defn- sh-out
[& args]
(try
(let [{:keys [exit out]} (apply shell/sh args)]
(when (zero? exit) (str/trim out)))
(catch Exception _ nil)))
(defn- machine
[]
{:cpu (some-> (sh-out "sh" "-c" "grep -m1 'model name' /proc/cpuinfo | cut -d: -f2")
(str/trim))
:cores (.availableProcessors (Runtime/getRuntime))
:os (str (System/getProperty "os.name") " " (System/getProperty "os.arch"))
:jvm (System/getProperty "java.version")})
(defn- record!
[results]
(let [baseline {:commit (or (sh-out "git" "rev-parse" "--short" "HEAD") "unknown")
:date (str (java.time.LocalDate/now))
:machine (machine)
:results results}]
(with-open [w (io/writer baseline-path)]
(binding [*out* w]
(println ";; Component sync benchmark baseline, written by `scripts/bench-components record`.")
(pp/pprint baseline)))
(println "Baseline written to" baseline-path)
true))
(defn- compare!
"Print each world's time against the baseline. Returns false when a world
is slower than its baseline by more than the tolerance. The lower
quantile is compared: background load and GC pauses move it far less
than the mean."
[results]
(let [{:keys [commit] recorded :machine :as baseline} (edn/read-string (slurp baseline-path))
rows (for [[key {:keys [lower-q-ms mean-ms]}] results
:let [base (get-in baseline [:results key :lower-q-ms])
ratio (when base (/ lower-q-ms base))]]
{:world (name key)
:baseline base
:now lower-q-ms
:mean mean-ms
:ratio (some-> ratio (* 100) Math/round (/ 100.0))
:flag (cond (nil? ratio) "new"
(> ratio tolerance) "SLOWER"
:else "")})]
(println "Baseline from commit" commit "on" (:cpu recorded))
(when (not= recorded (machine))
(println "Warning: the baseline was recorded on another machine or JVM; ratios are rough."))
(println "Times are the lower quantile (2.5%) of each world, in ms.")
(pp/print-table [:world :baseline :now :mean :ratio :flag] rows)
(not-any? #(= "SLOWER" (:flag %)) rows)))
(defn -main
[& [mode]]
(println "Component sync benchmark")
(let [results (run-all)
ok? (case (or mode "compare")
"record" (record! results)
"compare" (compare! results))]
(shutdown-agents)
(System/exit (if ok? 0 1))))