Measure action durations and fps for our interactions (#3472)

Adds a feature flag `Measure performance` that allows us to:
- Measure the performance of all the actions (it wraps them into
`measureCbDuration`).
- Measure the frame rate of certain interactions like resizing,
erasing,....

Example of how it looks like:

![CleanShot 2024-04-17 at 18 04
05](https://github.com/tldraw/tldraw/assets/2523721/0fb69745-f7b2-4b55-ac01-27ea26963d9a)


### Change Type

<!--  Please select a 'Scope' label ️ -->

- [ ] `sdk` — Changes the tldraw SDK
- [ ] `dotcom` — Changes the tldraw.com web app
- [ ] `docs` — Changes to the documentation, examples, or templates.
- [ ] `vs code` — Changes to the vscode plugin
- [x] `internal` — Does not affect user-facing stuff

<!--  Please select a 'Type' label ️ -->

- [ ] `bugfix` — Bug fix
- [ ] `feature` — New feature
- [ ] `improvement` — Improving existing features
- [ ] `chore` — Updating dependencies, other boring stuff
- [ ] `galaxy brain` — Architectural changes
- [ ] `tests` — Changes to any test code
- [x] `tools` — Changes to infrastructure, CI, internal scripts,
debugging tools, etc.
- [ ] `dunno` — I don't know
=

---------

Co-authored-by: Mime Čuvalo <mimecuvalo@gmail.com>
This commit is contained in:
Mitja Bezenšek 2024-05-08 12:06:05 +02:00 committed by GitHub
parent ddebf3fc5c
commit 2dd71f8510
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 171 additions and 8 deletions

View file

@ -0,0 +1,23 @@
import { TLUiEventSource, TLUiOverrides, debugFlags, measureCbDuration, useValue } from 'tldraw'
export function usePerformance(): TLUiOverrides {
const measurePerformance = useValue(
'measurePerformance',
() => debugFlags.measurePerformance.get(),
[debugFlags]
)
if (!measurePerformance) return {}
return {
actions(_editor, actions) {
Object.keys(actions).forEach((key) => {
const action = actions[key]
const cb = action.onSelect
action.onSelect = (source: TLUiEventSource) => {
return measureCbDuration(`Action ${key}`, () => cb(source))
}
})
return actions
},
}
}

View file

@ -1,10 +1,13 @@
import { Tldraw } from 'tldraw' import { Tldraw } from 'tldraw'
import 'tldraw/tldraw.css' import 'tldraw/tldraw.css'
import { usePerformance } from '../hooks/usePerformance'
export default function Develop() { export default function Develop() {
const performanceOverrides = usePerformance()
return ( return (
<div className="tldraw__editor"> <div className="tldraw__editor">
<Tldraw <Tldraw
overrides={[performanceOverrides]}
persistenceKey="tldraw_example" persistenceKey="tldraw_example"
onMount={(editor) => { onMount={(editor) => {
;(window as any).app = editor ;(window as any).app = editor

View file

@ -23,6 +23,7 @@ import { JSX as JSX_2 } from 'react/jsx-runtime';
import { LegacyMigrations } from '@tldraw/store'; import { LegacyMigrations } from '@tldraw/store';
import { MigrationSequence } from '@tldraw/store'; import { MigrationSequence } from '@tldraw/store';
import { NamedExoticComponent } from 'react'; import { NamedExoticComponent } from 'react';
import { PerformanceTracker } from '@tldraw/utils';
import { PointerEventHandler } from 'react'; import { PointerEventHandler } from 'react';
import { react } from '@tldraw/state'; import { react } from '@tldraw/state';
import { default as React_2 } from 'react'; import { default as React_2 } from 'react';
@ -450,6 +451,7 @@ export const debugFlags: {
readonly logElementRemoves: DebugFlag<boolean>; readonly logElementRemoves: DebugFlag<boolean>;
readonly logPointerCaptures: DebugFlag<boolean>; readonly logPointerCaptures: DebugFlag<boolean>;
readonly logPreventDefaults: DebugFlag<boolean>; readonly logPreventDefaults: DebugFlag<boolean>;
readonly measurePerformance: DebugFlag<boolean>;
readonly reconnectOnPing: DebugFlag<boolean>; readonly reconnectOnPing: DebugFlag<boolean>;
readonly showFps: DebugFlag<boolean>; readonly showFps: DebugFlag<boolean>;
readonly throwToBlob: DebugFlag<boolean>; readonly throwToBlob: DebugFlag<boolean>;
@ -1924,6 +1926,8 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
// (undocumented) // (undocumented)
_path: Computed<string>; _path: Computed<string>;
// (undocumented) // (undocumented)
performanceTracker: PerformanceTracker;
// (undocumented)
setCurrentToolIdMask(id: string | undefined): void; setCurrentToolIdMask(id: string | undefined): void;
// (undocumented) // (undocumented)
shapeType?: string; shapeType?: string;

View file

@ -41,6 +41,7 @@ import {
import { import {
IndexKey, IndexKey,
JsonObject, JsonObject,
PerformanceTracker,
annotateError, annotateError,
assert, assert,
compact, compact,
@ -98,6 +99,7 @@ import { PI2, approximately, areAnglesCompatible, clamp, pointInPolygon } from '
import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap' import { ReadonlySharedStyleMap, SharedStyle, SharedStyleMap } from '../utils/SharedStylesMap'
import { WeakMapCache } from '../utils/WeakMapCache' import { WeakMapCache } from '../utils/WeakMapCache'
import { dataUrlToFile } from '../utils/assets' import { dataUrlToFile } from '../utils/assets'
import { debugFlags } from '../utils/debug-flags'
import { getIncrementedName } from '../utils/getIncrementedName' import { getIncrementedName } from '../utils/getIncrementedName'
import { getReorderingShapesChanges } from '../utils/reorderShapes' import { getReorderingShapesChanges } from '../utils/reorderShapes'
import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation' import { applyRotationToSnapshotShapes, getRotationSnapshot } from '../utils/rotation'
@ -676,6 +678,8 @@ export class Editor extends EventEmitter<TLEventMap> {
requestAnimationFrame(() => { requestAnimationFrame(() => {
this._tickManager.start() this._tickManager.start()
}) })
this.performanceTracker = new PerformanceTracker()
} }
/** /**
@ -8232,6 +8236,12 @@ export class Editor extends EventEmitter<TLEventMap> {
/** @internal */ /** @internal */
capturedPointerId: number | null = null capturedPointerId: number | null = null
/** @internal */
private readonly performanceTracker: PerformanceTracker
/** @internal */
private performanceTrackerTimeout = -1 as any
/** /**
* Dispatch an event to the editor. * Dispatch an event to the editor.
* *
@ -8318,7 +8328,7 @@ export class Editor extends EventEmitter<TLEventMap> {
if (info.ctrlKey) { if (info.ctrlKey) {
clearInterval(this._ctrlKeyTimeout) clearInterval(this._ctrlKeyTimeout)
this._ctrlKeyTimeout = -1 this._ctrlKeyTimeout = -1
inputs.ctrlKey = true /** @internal */ /** @internal */ /** @internal */ inputs.ctrlKey = true
} else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) { } else if (!info.ctrlKey && inputs.ctrlKey && this._ctrlKeyTimeout === -1) {
this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150) this._ctrlKeyTimeout = setTimeout(this._setCtrlKeyTimeout, 150)
} }
@ -8472,6 +8482,7 @@ export class Editor extends EventEmitter<TLEventMap> {
), ),
{ immediate: true } { immediate: true }
) )
this.maybeTrackPerformance('Zooming')
return return
} }
case 'pan': { case 'pan': {
@ -8479,6 +8490,7 @@ export class Editor extends EventEmitter<TLEventMap> {
this._setCamera(new Vec(cx + (dx * panSpeed) / cz, cy + (dy * panSpeed) / cz, cz), { this._setCamera(new Vec(cx + (dx * panSpeed) / cz, cy + (dy * panSpeed) / cz, cz), {
immediate: true, immediate: true,
}) })
this.maybeTrackPerformance('Panning')
return return
} }
} }
@ -8552,7 +8564,6 @@ export class Editor extends EventEmitter<TLEventMap> {
// If the user is in pen mode, but the pointer is not a pen, stop here. // If the user is in pen mode, but the pointer is not a pen, stop here.
if (!isPen && isPenMode) return if (!isPen && isPenMode) return
// If we've started panning, then clear any long press timeout
if (this.inputs.isPanning && this.inputs.isPointing) { if (this.inputs.isPanning && this.inputs.isPointing) {
// Handle spacebar / middle mouse button panning // Handle spacebar / middle mouse button panning
const { currentScreenPoint, previousScreenPoint } = this.inputs const { currentScreenPoint, previousScreenPoint } = this.inputs
@ -8563,6 +8574,7 @@ export class Editor extends EventEmitter<TLEventMap> {
new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz), new Vec(cx + (offset.x * panSpeed) / cz, cy + (offset.y * panSpeed) / cz, cz),
{ immediate: true } { immediate: true }
) )
this.maybeTrackPerformance('Panning')
return return
} }
@ -8719,6 +8731,20 @@ export class Editor extends EventEmitter<TLEventMap> {
return this return this
} }
/** @internal */
private maybeTrackPerformance(name: string) {
if (debugFlags.measurePerformance.get()) {
if (this.performanceTracker.isStarted()) {
clearTimeout(this.performanceTrackerTimeout)
} else {
this.performanceTracker.start(name)
}
this.performanceTrackerTimeout = setTimeout(() => {
this.performanceTracker.stop()
}, 50)
}
}
} }
function alertMaxShapes(editor: Editor, pageId = editor.getCurrentPageId()) { function alertMaxShapes(editor: Editor, pageId = editor.getCurrentPageId()) {

View file

@ -1,4 +1,6 @@
import { Atom, Computed, atom, computed } from '@tldraw/state' import { Atom, Computed, atom, computed } from '@tldraw/state'
import { PerformanceTracker } from '@tldraw/utils'
import { debugFlags } from '../../utils/debug-flags'
import type { Editor } from '../Editor' import type { Editor } from '../Editor'
import { import {
EVENT_NAME_MAP, EVENT_NAME_MAP,
@ -10,6 +12,19 @@ import {
} from '../types/event-types' } from '../types/event-types'
type TLStateNodeType = 'branch' | 'leaf' | 'root' type TLStateNodeType = 'branch' | 'leaf' | 'root'
const STATE_NODES_TO_MEASURE = [
'brushing',
'cropping',
'dragging',
'dragging_handle',
'drawing',
'erasing',
'lasering',
'resizing',
'rotating',
'scribble_brushing',
'translating',
]
/** @public */ /** @public */
export interface TLStateNodeConstructor { export interface TLStateNodeConstructor {
@ -21,6 +36,7 @@ export interface TLStateNodeConstructor {
/** @public */ /** @public */
export abstract class StateNode implements Partial<TLEventHandlers> { export abstract class StateNode implements Partial<TLEventHandlers> {
performanceTracker: PerformanceTracker
constructor( constructor(
public editor: Editor, public editor: Editor,
parent?: StateNode parent?: StateNode
@ -60,6 +76,7 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
this._current.set(this.children[this.initial]) this._current.set(this.children[this.initial])
} }
} }
this.performanceTracker = new PerformanceTracker()
} }
static id: string static id: string
@ -159,6 +176,10 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
// todo: move this logic into transition // todo: move this logic into transition
enter = (info: any, from: string) => { enter = (info: any, from: string) => {
if (debugFlags.measurePerformance.get() && STATE_NODES_TO_MEASURE.includes(this.id)) {
this.performanceTracker.start(this.id)
}
this._isActive.set(true) this._isActive.set(true)
this.onEnter?.(info, from) this.onEnter?.(info, from)
@ -171,6 +192,9 @@ export abstract class StateNode implements Partial<TLEventHandlers> {
// todo: move this logic into transition // todo: move this logic into transition
exit = (info: any, from: string) => { exit = (info: any, from: string) => {
if (debugFlags.measurePerformance.get() && this.performanceTracker.isStarted()) {
this.performanceTracker.stop()
}
this._isActive.set(false) this._isActive.set(false)
this.onExit?.(info, from) this.onExit?.(info, from)

View file

@ -41,6 +41,7 @@ export const debugFlags = {
showFps: createDebugValue('showFps', { showFps: createDebugValue('showFps', {
defaults: { all: false }, defaults: { all: false },
}), }),
measurePerformance: createDebugValue('measurePerformance', { defaults: { all: false } }),
throwToBlob: createDebugValue('throwToBlob', { throwToBlob: createDebugValue('throwToBlob', {
defaults: { all: false }, defaults: { all: false },
}), }),

View file

@ -242,6 +242,18 @@ export function omitFromStackTrace<Args extends Array<unknown>, Return>(fn: (...
// @internal // @internal
export function partition<T>(arr: T[], predicate: (item: T) => boolean): [T[], T[]]; export function partition<T>(arr: T[], predicate: (item: T) => boolean): [T[], T[]];
// @public (undocumented)
export class PerformanceTracker {
// (undocumented)
isStarted(): boolean;
// (undocumented)
recordFrame: () => void;
// (undocumented)
start(name: string): void;
// (undocumented)
stop(): void;
}
// @public (undocumented) // @public (undocumented)
export class PngHelpers { export class PngHelpers {
// (undocumented) // (undocumented)

View file

@ -1,3 +1,4 @@
export { PerformanceTracker } from './lib/PerformanceTracker'
export { export {
areArraysShallowEqual, areArraysShallowEqual,
compact, compact,

View file

@ -0,0 +1,52 @@
import { PERFORMANCE_COLORS, PERFORMANCE_PREFIX_COLOR } from './perf'
/** @public */
export class PerformanceTracker {
private startTime = 0
private name = ''
private frames = 0
private started = false
private frame: number | null = null
recordFrame = () => {
this.frames++
if (!this.started) return
this.frame = requestAnimationFrame(this.recordFrame)
}
start(name: string) {
this.name = name
this.frames = 0
this.started = true
if (this.frame !== null) cancelAnimationFrame(this.frame)
this.frame = requestAnimationFrame(this.recordFrame)
this.startTime = performance.now()
}
stop() {
this.started = false
if (this.frame !== null) cancelAnimationFrame(this.frame)
const duration = (performance.now() - this.startTime) / 1000
const fps = duration === 0 ? 0 : Math.floor(this.frames / duration)
const background =
fps > 55
? PERFORMANCE_COLORS.Good
: fps > 30
? PERFORMANCE_COLORS.Mid
: PERFORMANCE_COLORS.Poor
const color = background === PERFORMANCE_COLORS.Mid ? 'black' : 'white'
const capitalized = this.name[0].toUpperCase() + this.name.slice(1)
// eslint-disable-next-line no-console
console.debug(
`%cPerf%c ${capitalized} %c${fps}%c fps`,
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
'font-weight: normal',
`font-weight: bold; padding: 2px; background: ${background};color: ${color};`,
'font-weight: normal'
)
}
isStarted() {
return this.started
}
}

View file

@ -1,9 +1,21 @@
export const PERFORMANCE_COLORS = {
Good: '#40C057',
Mid: '#FFC078',
Poor: '#E03131',
}
export const PERFORMANCE_PREFIX_COLOR = PERFORMANCE_COLORS.Good
/** @internal */ /** @internal */
export function measureCbDuration(name: string, cb: () => any) { export function measureCbDuration(name: string, cb: () => any) {
const now = performance.now() const start = performance.now()
const result = cb() const result = cb()
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`${name} took`, performance.now() - now, 'ms') console.debug(
`%cPerf%c ${name} took ${performance.now() - start}ms`,
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
'font-weight: normal'
)
return result return result
} }
@ -13,9 +25,12 @@ export function measureDuration(_target: any, propertyKey: string, descriptor: P
descriptor.value = function (...args: any[]) { descriptor.value = function (...args: any[]) {
const start = performance.now() const start = performance.now()
const result = originalMethod.apply(this, args) const result = originalMethod.apply(this, args)
const end = performance.now()
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`${propertyKey} took ${end - start}ms `) console.debug(
`%cPerf%c ${propertyKey} took: ${performance.now() - start}ms`,
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
'font-weight: normal'
)
return result return result
} }
return descriptor return descriptor
@ -41,8 +56,10 @@ export function measureAverageDuration(
const count = value.count + 1 const count = value.count + 1
averages.set(descriptor.value, { total, count }) averages.set(descriptor.value, { total, count })
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log( console.debug(
`${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms` `%cPerf%c ${propertyKey} took ${(end - start).toFixed(2)}ms | average ${(total / count).toFixed(2)}ms`,
`color: white; background: ${PERFORMANCE_PREFIX_COLOR};padding: 2px;border-radius: 3px;`,
'font-weight: normal'
) )
} }
return result return result