mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
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
360 lines
14 KiB
Clojure
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))))
|