diff --git a/example/App.tsx b/example/App.tsx index e045657..4e6ca7a 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -10,30 +10,26 @@ import { import AsyncStorage from "@react-native-async-storage/async-storage"; import { InfiniteInkCanvas, - type ContinuousEnginePoolToolState, type InfiniteInkCanvasRef, + type NativeInkRenderBackend, type SerializedNotebookData, } from "@mathnotes/mobile-ink"; +import { + tools, + type ToolConfig, +} from "./benchmark"; +import { BenchmarkScreen } from "./BenchmarkScreen"; -type ToolConfig = ContinuousEnginePoolToolState & { - label: string; -}; - -const tools: ToolConfig[] = [ - { label: "Pen", toolType: "pen", width: 3, color: "#111111", eraserMode: "pixel" }, - { label: "Highlighter", toolType: "highlighter", width: 18, color: "#FFE066", eraserMode: "pixel" }, - { label: "Crayon", toolType: "crayon", width: 9, color: "#1E6BFF", eraserMode: "pixel" }, - { label: "Calligraphy", toolType: "calligraphy", width: 7, color: "#111111", eraserMode: "pixel" }, - { label: "Eraser", toolType: "eraser", width: 64, color: "#FFFFFF", eraserMode: "pixel" }, - { label: "Select", toolType: "select", width: 3, color: "#111111", eraserMode: "pixel" }, -]; +type AppMode = "draw" | "benchmark"; const STORAGE_KEY = "mobile-ink-example-notebook"; export default function App() { const canvasRef = useRef(null); + const [mode, setMode] = useState("draw"); const [activeTool, setActiveTool] = useState(tools[0]); const [drawWithFinger, setDrawWithFinger] = useState(false); + const [renderBackend, setRenderBackend] = useState("ganesh"); const [savedNotebook, setSavedNotebook] = useState(null); const [storageStatus, setStorageStatus] = useState("not saved"); const [currentPageIndex, setCurrentPageIndex] = useState(0); @@ -88,62 +84,106 @@ export default function App() { return ( - - Draw with finger - + + {(["draw", "benchmark"] as AppMode[]).map((item) => ( + setMode(item)} + > + {item === "draw" ? "Draw" : "Benchmark"} + + ))} - {tools.map((tool) => ( - applyTool(tool)} - > - {tool.label} - - ))} - - canvasRef.current?.undo()}> - Undo - - canvasRef.current?.redo()}> - Redo - - canvasRef.current?.clearCurrentPage()}> - Clear - - canvasRef.current?.resetViewport(true)}> - Reset View - - - Save - - - Reload - + {mode === "draw" ? ( + <> + + {(["ganesh", "cpu"] as NativeInkRenderBackend[]).map((item) => ( + setRenderBackend(item)} + > + {item === "ganesh" ? "Ganesh" : "CPU"} + + ))} + + + + Draw with finger + + + + {tools.map((tool) => ( + applyTool(tool)} + > + {tool.label} + + ))} + + canvasRef.current?.undo()}> + Undo + + canvasRef.current?.redo()}> + Redo + + canvasRef.current?.clearCurrentPage()}> + Clear + + canvasRef.current?.resetViewport(true)}> + Reset View + + + Save + + + Reload + + + ) : null} - - Page {currentPageIndex + 1} / {pageCount} - {isMoving ? "moving" : "settled"} - {storageStatus} + {mode === "draw" ? ( + + Page {currentPageIndex + 1} / {pageCount} + {isMoving ? "moving" : "settled"} + {storageStatus} + Backend {renderBackend} + + ) : null} + + + setPageCount(pages.length)} + onMotionStateChange={setIsMoving} + /> + + {mode === "benchmark" ? ( + + ) : null} - - setPageCount(pages.length)} - onMotionStateChange={setIsMoving} - /> ); } @@ -162,6 +202,11 @@ const styles = StyleSheet.create({ borderBottomColor: "#D7DEE8", borderBottomWidth: StyleSheet.hairlineWidth, }, + segmentGroup: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, toggleRow: { alignItems: "center", flexDirection: "row", @@ -201,6 +246,9 @@ const styles = StyleSheet.create({ fontSize: 12, fontWeight: "700", }, + contentArea: { + flex: 1, + }, canvasHost: { flex: 1, }, diff --git a/example/BenchmarkScreen.tsx b/example/BenchmarkScreen.tsx new file mode 100644 index 0000000..122ca6b --- /dev/null +++ b/example/BenchmarkScreen.tsx @@ -0,0 +1,574 @@ +import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + ScrollView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from "react-native"; +import type { + InfiniteInkCanvasRef, + NativeInkRenderBackend, +} from "@mathnotes/mobile-ink"; +import { + benchmarkScenarios, + benchmarkSuitePageCount, + benchmarkSuiteScenarios, + formatBytes, + formatNumber, + isSuiteResult, + measureScrollSweep, + startViewportFrameSampler, + tools, + wait, + type BenchmarkDisplayResult, + type BenchmarkSuiteStep, + type ToolConfig, + type ViewportFrameSampler, +} from "./benchmark"; + +export function BenchmarkScreen({ + backend, + canvasRef, + activeTool, + applyTool, + setBackend, +}: { + backend: NativeInkRenderBackend; + canvasRef: React.RefObject; + activeTool: ToolConfig; + applyTool: (tool: ToolConfig) => void; + setBackend: (backend: NativeInkRenderBackend) => void; +}) { + const [scenarioId, setScenarioId] = useState(benchmarkScenarios[1].id); + const [activeRun, setActiveRun] = useState<"replay" | "manual" | "suite" | null>(null); + const [startedAt, setStartedAt] = useState(null); + const [elapsedMs, setElapsedMs] = useState(0); + const [result, setResult] = useState(null); + const [error, setError] = useState(null); + const [suiteProgress, setSuiteProgress] = useState(null); + const manualViewportSamplerRef = useRef(null); + + const isRunning = activeRun !== null; + const scenario = benchmarkScenarios.find((item) => item.id === scenarioId) ?? benchmarkScenarios[0]; + + useEffect(() => { + if (!isRunning || startedAt === null) { + return undefined; + } + + const timer = setInterval(() => { + setElapsedMs(Date.now() - startedAt); + }, 250); + + return () => clearInterval(timer); + }, [isRunning, startedAt]); + + useEffect(() => () => { + manualViewportSamplerRef.current?.stop(); + manualViewportSamplerRef.current = null; + }, []); + + const metrics = useMemo(() => { + if (!result) { + return []; + } + + if (isSuiteResult(result)) { + return [ + ["Suite steps", result.steps.length.toLocaleString()], + ["Pages", result.pageCount.toLocaleString()], + ["Duration", `${formatNumber(result.durationMs / 1000, 1)} s`], + ["Native steps", result.summary.nativeStepCount.toLocaleString()], + ["Render p95 avg", `${formatNumber(result.summary.renderP95Average, 2)} ms`], + ["Latency p95 avg", `${formatNumber(result.summary.latencyP95Average, 2)} ms`], + ["Dropped frames", result.summary.droppedFrameCount.toLocaleString()], + ["Scroll FPS", formatNumber(result.summary.scrollFpsAverage, 1)], + ["Scroll dropped", result.summary.scrollDroppedFrameCount.toLocaleString()], + ]; + } + + const rows = [ + [result.scope === "notebook" ? "Native FPS avg" : "FPS avg", formatNumber(result.fpsAverage, 1)], + ["Frame p95", `${formatNumber(result.frameIntervalMs.p95, 2)} ms`], + ["Frame p99", `${formatNumber(result.frameIntervalMs.p99, 2)} ms`], + ["Render p95", `${formatNumber(result.renderMs.p95, 2)} ms`], + ["Latency p95", `${formatNumber(result.inputToPresentLatencyMs.p95, 2)} ms`], + ["Latency p99", `${formatNumber(result.inputToPresentLatencyMs.p99, 2)} ms`], + ["Dropped frames", result.droppedFrameCount.toLocaleString()], + ["Frames", result.renderFrameCount.toLocaleString()], + ["Ganesh frames", result.ganeshFrameCount.toLocaleString()], + ["CPU frames", result.cpuFrameCount.toLocaleString()], + ["Fallback frames", result.ganeshFallbackFrameCount.toLocaleString()], + ["Memory delta", formatBytes(result.memory.deltaBytes)], + ["Peak memory delta", formatBytes(result.memory.peakDeltaBytes)], + ["Peak memory", formatBytes(result.memory.peakBytes)], + ]; + + if (typeof result.frameThroughputFps === "number") { + rows.splice(1, 0, ["Frame throughput", formatNumber(result.frameThroughputFps, 1)]); + } + + if (result.viewport) { + rows.splice( + 1, + 0, + ["Viewport FPS", formatNumber(result.viewport.fpsAverage, 1)], + ["Viewport p95", `${formatNumber(result.viewport.frameIntervalMs.p95, 2)} ms`], + ["Viewport dropped", result.viewport.droppedFrameCount.toLocaleString()], + ); + } + + if (result.presentationPauseMs && result.presentationPauseMs.count > 0) { + rows.push( + ["Presentation pauses", (result.presentationPauseCount ?? result.presentationPauseMs.count).toLocaleString()], + ["Pause p95", `${formatNumber(result.presentationPauseMs.p95, 2)} ms`], + ["Pause max", `${formatNumber(result.presentationPauseMs.max, 2)} ms`], + ["Pause total", `${formatNumber((result.presentationPauseTotalMs ?? 0) / 1000, 1)} s`], + ); + } + + return rows; + }, [result]); + + const runBenchmark = async () => { + if (isRunning) { + return; + } + + setError(null); + setResult(null); + setElapsedMs(0); + setStartedAt(Date.now()); + setActiveRun("replay"); + setSuiteProgress(null); + + try { + applyTool(scenario.tool); + const nextResult = await canvasRef.current?.runBenchmark?.({ + ...scenario.options, + scenario: scenario.id, + backend, + color: scenario.options.color ?? scenario.tool.color, + eraserMode: scenario.options.eraserMode ?? scenario.tool.eraserMode, + toolType: scenario.options.toolType ?? scenario.tool.toolType, + }); + + if (!nextResult) { + throw new Error("Benchmark runner is unavailable in this native build."); + } + + setResult(nextResult); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Benchmark failed."); + } finally { + setActiveRun(null); + setStartedAt(null); + } + }; + + const runBenchmarkSuite = async () => { + if (isRunning) { + return; + } + + const canvas = canvasRef.current; + if (!canvas?.runBenchmark) { + setError("Benchmark runner is unavailable in this native build."); + return; + } + + setError(null); + setResult(null); + setElapsedMs(0); + setStartedAt(Date.now()); + setActiveRun("suite"); + setSuiteProgress("page 1"); + + const suiteStartedAt = Date.now(); + const steps: BenchmarkSuiteStep[] = []; + + try { + canvas.resetViewport(false); + + for (let pageIndex = 0; pageIndex < benchmarkSuitePageCount; pageIndex += 1) { + const suiteScenario = + benchmarkSuiteScenarios[pageIndex % benchmarkSuiteScenarios.length]; + + if (pageIndex > 0) { + await canvas.addPage(); + await wait(450); + } + + canvas.scrollToPage(pageIndex, false); + await wait(350); + applyTool(suiteScenario.tool); + setSuiteProgress(`page ${pageIndex + 1}/${benchmarkSuitePageCount} ${suiteScenario.label}`); + + const nextResult = await canvas.runBenchmark({ + ...suiteScenario.options, + backend, + scenario: `${suiteScenario.id}-page-${pageIndex + 1}`, + color: suiteScenario.options.color ?? suiteScenario.tool.color, + eraserMode: suiteScenario.options.eraserMode ?? suiteScenario.tool.eraserMode, + toolType: suiteScenario.options.toolType ?? suiteScenario.tool.toolType, + }); + + steps.push({ + type: "native", + id: `${suiteScenario.id}-page-${pageIndex + 1}`, + label: `${suiteScenario.label} p${pageIndex + 1}`, + pageIndex, + tool: suiteScenario.tool.label, + result: nextResult, + }); + } + + setSuiteProgress("scroll sweep"); + const scrollResult = await measureScrollSweep(canvas, benchmarkSuitePageCount); + steps.push({ + type: "scroll", + id: "scroll-sweep", + label: "Scroll sweep", + result: scrollResult, + }); + + const nativeResults = steps.flatMap((step) => ( + step.type === "native" ? [step.result] : [] + )); + const renderP95Average = nativeResults.reduce( + (sum, nextResult) => sum + nextResult.renderMs.p95, + 0, + ) / Math.max(1, nativeResults.length); + const latencyP95Average = nativeResults.reduce( + (sum, nextResult) => sum + nextResult.inputToPresentLatencyMs.p95, + 0, + ) / Math.max(1, nativeResults.length); + const droppedFrameCount = nativeResults.reduce( + (sum, nextResult) => sum + nextResult.droppedFrameCount, + 0, + ); + + setResult({ + sessionId: `suite-${Date.now().toString(36)}`, + scenario: "suite", + requestedBackend: backend, + durationMs: Date.now() - suiteStartedAt, + pageCount: benchmarkSuitePageCount, + steps, + summary: { + nativeStepCount: nativeResults.length, + renderP95Average: Number(renderP95Average.toFixed(2)), + latencyP95Average: Number(latencyP95Average.toFixed(2)), + droppedFrameCount, + scrollFpsAverage: scrollResult.fpsAverage, + scrollDroppedFrameCount: scrollResult.droppedFrameCount, + }, + }); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Benchmark suite failed."); + } finally { + setActiveRun(null); + setStartedAt(null); + setSuiteProgress(null); + } + }; + + const toggleManualRecording = async () => { + if (activeRun && activeRun !== "manual") { + return; + } + + if (activeRun === "manual") { + const viewport = manualViewportSamplerRef.current?.stop(); + manualViewportSamplerRef.current = null; + try { + const nextResult = await canvasRef.current?.stopBenchmarkRecording?.(); + if (!nextResult) { + throw new Error("Benchmark recorder is unavailable in this native build."); + } + setResult(viewport ? { ...nextResult, viewport } : nextResult); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Benchmark recording failed."); + } finally { + setActiveRun(null); + setStartedAt(null); + } + return; + } + + setError(null); + setResult(null); + setElapsedMs(0); + setSuiteProgress(null); + + try { + const didStart = await canvasRef.current?.startBenchmarkRecording?.({ + scenario: "manual", + backend, + }); + if (!didStart) { + throw new Error("Benchmark recorder is unavailable in this native build."); + } + manualViewportSamplerRef.current = startViewportFrameSampler(); + setStartedAt(Date.now()); + setActiveRun("manual"); + } catch (caught) { + manualViewportSamplerRef.current?.stop(); + manualViewportSamplerRef.current = null; + setError(caught instanceof Error ? caught.message : "Benchmark recording failed."); + setStartedAt(null); + setActiveRun(null); + } + }; + + const logResult = () => { + if (result) { + console.log("[MobileInkBenchmark]", JSON.stringify(result, null, 2)); + } + }; + + return ( + + + + {(["ganesh", "cpu"] as NativeInkRenderBackend[]).map((item) => ( + setBackend(item)} + disabled={isRunning} + > + {item === "ganesh" ? "Ganesh" : "CPU"} + + ))} + + + + {benchmarkScenarios.map((item) => ( + setScenarioId(item.id)} + disabled={isRunning} + > + {item.label} + + ))} + + + + {tools.map((tool) => ( + applyTool(tool)} + disabled={activeRun === "replay" || activeRun === "suite"} + > + {tool.label} + + ))} + + + + {activeRun === "replay" ? "Running" : "Run"} + + + {activeRun === "suite" ? "Suite" : "Run Suite"} + + + + {activeRun === "manual" ? "Stop" : "Record Manual"} + + + canvasRef.current?.clearCurrentPage()} + disabled={isRunning} + > + Clear + + + Log JSON + + + + + Backend {backend} + Scenario {scenario.label} + Tool {activeTool.label} + + {activeRun ? `${activeRun} ${formatNumber(elapsedMs / 1000, 1)}s` : "idle"} + + {suiteProgress ? {suiteProgress} : null} + {error ? {error} : null} + + + + + + + {metrics.length > 0 ? ( + <> + + {metrics.map(([label, value]) => ( + + {label} + {value} + + ))} + + + {JSON.stringify(result, null, 2)} + + + ) : ( + Run a scenario to collect on-device metrics. + )} + + + + ); +} + +const styles = StyleSheet.create({ + benchmarkRoot: { + ...StyleSheet.absoluteFillObject, + justifyContent: "space-between", + }, + benchmarkToolbar: { + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + minHeight: 56, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: "#FFFFFF", + borderBottomColor: "#D7DEE8", + borderBottomWidth: StyleSheet.hairlineWidth, + }, + benchmarkStatus: { + alignItems: "center", + flexDirection: "row", + flexWrap: "wrap", + gap: 14, + minHeight: 34, + paddingHorizontal: 14, + backgroundColor: "#F9FAFC", + borderBottomColor: "#D7DEE8", + borderBottomWidth: StyleSheet.hairlineWidth, + }, + benchmarkSpacer: { + flex: 1, + }, + segmentGroup: { + flexDirection: "row", + flexWrap: "wrap", + gap: 6, + }, + button: { + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 8, + backgroundColor: "#EDF1F5", + }, + activeButton: { + backgroundColor: "#CFE1FF", + }, + primaryButton: { + backgroundColor: "#17202A", + }, + stopButton: { + backgroundColor: "#B42318", + }, + disabledButton: { + opacity: 0.55, + }, + buttonText: { + color: "#17202A", + fontWeight: "600", + }, + primaryButtonText: { + color: "#FFFFFF", + fontWeight: "700", + }, + stopButtonText: { + color: "#FFFFFF", + }, + statusText: { + color: "#4B5563", + fontSize: 12, + fontWeight: "700", + }, + errorText: { + color: "#B42318", + fontSize: 12, + fontWeight: "700", + }, + metricsPanel: { + maxHeight: 330, + minHeight: 190, + backgroundColor: "#FFFFFF", + borderTopColor: "#D7DEE8", + borderTopWidth: StyleSheet.hairlineWidth, + }, + metricsContent: { + gap: 12, + padding: 12, + }, + metricsGrid: { + flexDirection: "row", + flexWrap: "wrap", + gap: 8, + }, + metricCell: { + minWidth: 132, + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + backgroundColor: "#F4F6F8", + }, + metricLabel: { + color: "#667085", + fontSize: 11, + fontWeight: "700", + }, + metricValue: { + color: "#17202A", + fontSize: 17, + fontWeight: "800", + marginTop: 2, + }, + resultJson: { + color: "#334155", + fontFamily: "Menlo", + fontSize: 11, + lineHeight: 16, + }, + emptyMetrics: { + color: "#667085", + fontSize: 13, + fontWeight: "600", + }, +}); diff --git a/example/benchmark.ts b/example/benchmark.ts new file mode 100644 index 0000000..3e5634d --- /dev/null +++ b/example/benchmark.ts @@ -0,0 +1,443 @@ +import type { + ContinuousEnginePoolToolState, + InfiniteInkCanvasRef, + NativeInkBenchmarkOptions, + NativeInkBenchmarkResult, + NativeInkBenchmarkViewportMetrics, + NativeInkRenderBackend, +} from "@mathnotes/mobile-ink"; + +export type ToolConfig = ContinuousEnginePoolToolState & { + label: string; +}; + +export type BenchmarkScenario = { + id: string; + label: string; + tool: ToolConfig; + options: NativeInkBenchmarkOptions & Required>; +}; + +export type FrameBenchmarkDistribution = { + count: number; + average: number; + p50: number; + p95: number; + p99: number; + max: number; +}; + +export type ScrollBenchmarkResult = { + scenario: "scroll"; + durationMs: number; + pageCount: number; + fpsAverage: number; + droppedFrameCount: number; + frameIntervalMs: FrameBenchmarkDistribution; +}; + +export type BenchmarkSuiteStep = + | { + type: "native"; + id: string; + label: string; + pageIndex: number; + tool: string; + result: NativeInkBenchmarkResult; + } + | { + type: "scroll"; + id: string; + label: string; + result: ScrollBenchmarkResult; + }; + +export type BenchmarkSuiteResult = { + sessionId: string; + scenario: "suite"; + requestedBackend: NativeInkRenderBackend; + durationMs: number; + pageCount: number; + steps: BenchmarkSuiteStep[]; + summary: { + nativeStepCount: number; + renderP95Average: number; + latencyP95Average: number; + droppedFrameCount: number; + scrollFpsAverage: number; + scrollDroppedFrameCount: number; + }; +}; + +export type BenchmarkDisplayResult = NativeInkBenchmarkResult | BenchmarkSuiteResult; + +export const tools: ToolConfig[] = [ + { label: "Pen", toolType: "pen", width: 3, color: "#111111", eraserMode: "pixel" }, + { label: "Highlighter", toolType: "highlighter", width: 18, color: "#FFE066", eraserMode: "pixel" }, + { label: "Crayon", toolType: "crayon", width: 9, color: "#1E6BFF", eraserMode: "pixel" }, + { label: "Calligraphy", toolType: "calligraphy", width: 7, color: "#111111", eraserMode: "pixel" }, + { label: "Eraser", toolType: "eraser", width: 64, color: "#FFFFFF", eraserMode: "pixel" }, + { label: "Select", toolType: "select", width: 3, color: "#111111", eraserMode: "pixel" }, +]; + +export const benchmarkScenarios: BenchmarkScenario[] = [ + { + id: "smoke", + label: "Smoke", + tool: tools[0], + options: { + workload: "draw", + toolType: "pen", + strokeCount: 40, + pointsPerStroke: 24, + pointIntervalMs: 8, + strokeGapMs: 12, + settleMs: 500, + strokeWidth: 3, + }, + }, + { + id: "writing", + label: "Writing", + tool: tools[0], + options: { + workload: "draw", + toolType: "pen", + strokeCount: 140, + pointsPerStroke: 28, + pointIntervalMs: 8, + strokeGapMs: 18, + settleMs: 700, + strokeWidth: 3, + }, + }, + { + id: "dense", + label: "Dense", + tool: tools[0], + options: { + workload: "draw", + toolType: "pen", + strokeCount: 320, + pointsPerStroke: 20, + pointIntervalMs: 4, + strokeGapMs: 6, + settleMs: 900, + strokeWidth: 3, + }, + }, + { + id: "endurance", + label: "Endurance", + tool: tools[0], + options: { + workload: "draw", + toolType: "pen", + strokeCount: 600, + pointsPerStroke: 24, + pointIntervalMs: 6, + strokeGapMs: 12, + settleMs: 1_000, + strokeWidth: 3, + }, + }, + { + id: "eraser", + label: "Eraser", + tool: tools[4], + options: { + workload: "erase", + toolType: "eraser", + color: "#FFFFFF", + eraserMode: "pixel", + strokeCount: 140, + pointsPerStroke: 24, + pointIntervalMs: 6, + strokeGapMs: 10, + settleMs: 700, + strokeWidth: 54, + seedStrokeCount: 80, + }, + }, + { + id: "selection-move", + label: "Move", + tool: tools[5], + options: { + workload: "selectionMove", + toolType: "select", + strokeCount: 80, + pointsPerStroke: 24, + pointIntervalMs: 6, + strokeGapMs: 0, + settleMs: 700, + strokeWidth: 3, + seedStrokeCount: 70, + moveStepCount: 90, + moveDeltaX: 1.5, + moveDeltaY: 0.75, + }, + }, +]; + +export const benchmarkSuitePageCount = 12; + +export const benchmarkSuiteScenarios: BenchmarkScenario[] = [ + { + id: "suite-pen", + label: "Pen", + tool: tools[0], + options: { + workload: "draw", + toolType: "pen", + color: "#111111", + strokeCount: 44, + pointsPerStroke: 18, + pointIntervalMs: 5, + strokeGapMs: 8, + settleMs: 250, + strokeWidth: 3, + }, + }, + { + id: "suite-highlighter", + label: "Highlighter", + tool: tools[1], + options: { + workload: "draw", + toolType: "highlighter", + color: "#FFE066", + strokeCount: 36, + pointsPerStroke: 18, + pointIntervalMs: 5, + strokeGapMs: 8, + settleMs: 250, + strokeWidth: 18, + }, + }, + { + id: "suite-crayon", + label: "Crayon", + tool: tools[2], + options: { + workload: "draw", + toolType: "crayon", + color: "#1E6BFF", + strokeCount: 40, + pointsPerStroke: 18, + pointIntervalMs: 5, + strokeGapMs: 8, + settleMs: 250, + strokeWidth: 9, + }, + }, + { + id: "suite-calligraphy", + label: "Calligraphy", + tool: tools[3], + options: { + workload: "draw", + toolType: "calligraphy", + color: "#111111", + strokeCount: 40, + pointsPerStroke: 18, + pointIntervalMs: 5, + strokeGapMs: 8, + settleMs: 250, + strokeWidth: 7, + }, + }, + { + id: "suite-eraser", + label: "Eraser", + tool: tools[4], + options: { + workload: "erase", + toolType: "eraser", + color: "#FFFFFF", + eraserMode: "pixel", + strokeCount: 40, + pointsPerStroke: 18, + pointIntervalMs: 5, + strokeGapMs: 8, + settleMs: 250, + strokeWidth: 54, + seedStrokeCount: 44, + }, + }, + { + id: "suite-selection", + label: "Selection", + tool: tools[5], + options: { + workload: "selectionMove", + toolType: "select", + strokeCount: 36, + pointsPerStroke: 18, + pointIntervalMs: 5, + strokeGapMs: 0, + settleMs: 250, + strokeWidth: 3, + seedStrokeCount: 42, + moveStepCount: 56, + moveDeltaX: 1.5, + moveDeltaY: 0.75, + }, + }, +]; + +export const formatNumber = (value: number, fractionDigits = 1) => { + if (!Number.isFinite(value)) { + return "0"; + } + return value.toLocaleString(undefined, { + maximumFractionDigits: fractionDigits, + minimumFractionDigits: fractionDigits, + }); +}; + +export const formatBytes = (bytes: number) => { + const absBytes = Math.abs(bytes); + if (absBytes < 1024) { + return `${bytes} B`; + } + if (absBytes < 1024 * 1024) { + return `${formatNumber(bytes / 1024, 1)} KB`; + } + return `${formatNumber(bytes / (1024 * 1024), 1)} MB`; +}; + +export const wait = (durationMs: number) => + new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); + +export const summarizeDistribution = (values: number[]): FrameBenchmarkDistribution => { + if (values.length === 0) { + return { + count: 0, + average: 0, + p50: 0, + p95: 0, + p99: 0, + max: 0, + }; + } + + const sorted = [...values].sort((left, right) => left - right); + const average = values.reduce((sum, value) => sum + value, 0) / values.length; + const percentile = (target: number) => { + const boundedTarget = Math.max(0, Math.min(1, target)); + const rawIndex = boundedTarget * (sorted.length - 1); + const lowerIndex = Math.floor(rawIndex); + const upperIndex = Math.ceil(rawIndex); + if (lowerIndex === upperIndex) { + return sorted[lowerIndex]; + } + const fraction = rawIndex - lowerIndex; + return sorted[lowerIndex] * (1 - fraction) + sorted[upperIndex] * fraction; + }; + + return { + count: values.length, + average: Number(average.toFixed(2)), + p50: Number(percentile(0.5).toFixed(2)), + p95: Number(percentile(0.95).toFixed(2)), + p99: Number(percentile(0.99).toFixed(2)), + max: Number((sorted[sorted.length - 1] ?? 0).toFixed(2)), + }; +}; + +export type ViewportFrameSampler = { + stop: () => NativeInkBenchmarkViewportMetrics; +}; + +export const summarizeViewportFrames = ( + frameTimes: number[], + durationMs: number, +): NativeInkBenchmarkViewportMetrics => { + const intervals = frameTimes.slice(1).map((timestamp, index) => ( + timestamp - frameTimes[index] + )); + const frameIntervalMs = summarizeDistribution(intervals); + const frameBudgetMs = 1000 / 60; + const droppedFrameCount = intervals.reduce((sum, intervalMs) => { + if (intervalMs <= frameBudgetMs * 1.5) { + return sum; + } + return sum + Math.max(1, Math.floor(intervalMs / frameBudgetMs) - 1); + }, 0); + + return { + durationMs, + fpsAverage: Number(((frameTimes.length / Math.max(1, durationMs)) * 1000).toFixed(2)), + droppedFrameCount, + frameIntervalMs, + }; +}; + +export const startViewportFrameSampler = (): ViewportFrameSampler => { + const frameTimes: number[] = []; + const startedAt = Date.now(); + let isSampling = true; + let animationFrameId: number | null = null; + + const sample = (timestamp: number) => { + frameTimes.push(timestamp); + if (isSampling) { + animationFrameId = requestAnimationFrame(sample); + } + }; + + animationFrameId = requestAnimationFrame(sample); + + return { + stop: () => { + isSampling = false; + if (animationFrameId !== null) { + cancelAnimationFrame(animationFrameId); + } + return summarizeViewportFrames(frameTimes, Date.now() - startedAt); + }, + }; +}; + +export const measureScrollSweep = async ( + canvas: InfiniteInkCanvasRef, + pageCount: number, +): Promise => { + const sampler = startViewportFrameSampler(); + const startedAt = Date.now(); + + for (let pageIndex = 0; pageIndex < pageCount; pageIndex += 1) { + canvas.scrollToPage(pageIndex, true); + await wait(220); + } + for (let pageIndex = pageCount - 2; pageIndex >= 0; pageIndex -= 1) { + canvas.scrollToPage(pageIndex, true); + await wait(220); + } + + await wait(350); + + const durationMs = Date.now() - startedAt; + const viewport = sampler.stop(); + + return { + scenario: "scroll", + durationMs, + pageCount, + fpsAverage: viewport.fpsAverage, + droppedFrameCount: viewport.droppedFrameCount, + frameIntervalMs: viewport.frameIntervalMs, + }; +}; + +export const isSuiteResult = ( + result: BenchmarkDisplayResult | null, +): result is BenchmarkSuiteResult => ( + !!result && result.scenario === "suite" +); diff --git a/example/package-lock.json b/example/package-lock.json index add8885..dc0664a 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -32,15 +32,15 @@ "license": "Apache-2.0", "devDependencies": { "@babel/core": "^7.25.2", - "@react-native/babel-preset": "^0.81.5", - "@shopify/react-native-skia": "^2.2.12", + "@react-native/babel-preset": "0.81.5", + "@shopify/react-native-skia": "2.2.12", "@testing-library/react-native": "^12.8.4", "@types/jest": "^29.5.14", "@types/react": "^19.1.0", "babel-jest": "^29.7.0", "jest": "^29.7.0", "react": "19.1.0", - "react-native": "^0.81.5", + "react-native": "0.81.5", "react-native-builder-bob": "^0.41.0", "react-native-gesture-handler": "2.28.0", "react-native-reanimated": "4.1.3", diff --git a/example/tsconfig.json b/example/tsconfig.json index b9567f6..b8a661f 100644 --- a/example/tsconfig.json +++ b/example/tsconfig.json @@ -1,6 +1,11 @@ { "extends": "expo/tsconfig.base", "compilerOptions": { + "baseUrl": ".", + "paths": { + "@mathnotes/mobile-ink": ["../src/index.ts"], + "@mathnotes/mobile-ink/*": ["../src/*"] + }, "strict": true } } diff --git a/ios/MobileInkModule/MobileInkBenchmarkRecorder.swift b/ios/MobileInkModule/MobileInkBenchmarkRecorder.swift new file mode 100644 index 0000000..259f28b --- /dev/null +++ b/ios/MobileInkModule/MobileInkBenchmarkRecorder.swift @@ -0,0 +1,241 @@ +import Darwin +import Foundation +import QuartzCore + +enum MobileInkRenderBackend: String { + case cpu + case ganesh +} + +final class MobileInkBenchmarkRecorder { + private let frameBudgetMultiplier = 1.5 + private let presentationPauseThresholdMs = 250.0 + private(set) var isRunning = false + private var sessionId = UUID().uuidString + private var scenario = "custom" + private var requestedBackend = MobileInkRenderBackend.ganesh.rawValue + private var startTime = CACurrentMediaTime() + private var endTime = CACurrentMediaTime() + private var lastPresentedAt: CFTimeInterval? + private var pendingInputTimes: [CFTimeInterval] = [] + private var renderDurationsMs: [Double] = [] + private var presentIntervalsMs: [Double] = [] + private var presentationPauseDurationsMs: [Double] = [] + private var inputToPresentLatenciesMs: [Double] = [] + private var renderFrameCount = 0 + private var cpuFrameCount = 0 + private var ganeshFrameCount = 0 + private var ganeshFallbackFrameCount = 0 + private var droppedFrameCount = 0 + private var inputEventCount = 0 + private var syntheticStrokeCount = 0 + private var syntheticPointCount = 0 + private var memoryStartBytes: UInt64 = 0 + private var memoryEndBytes: UInt64 = 0 + private var memoryPeakBytes: UInt64 = 0 + private var memoryLowBytes: UInt64 = 0 + + func start(scenario: String, requestedBackend: String) { + self.sessionId = UUID().uuidString + self.scenario = scenario + self.requestedBackend = requestedBackend + self.startTime = CACurrentMediaTime() + self.endTime = startTime + self.lastPresentedAt = nil + self.pendingInputTimes.removeAll(keepingCapacity: true) + self.renderDurationsMs.removeAll(keepingCapacity: true) + self.presentIntervalsMs.removeAll(keepingCapacity: true) + self.presentationPauseDurationsMs.removeAll(keepingCapacity: true) + self.inputToPresentLatenciesMs.removeAll(keepingCapacity: true) + self.renderFrameCount = 0 + self.cpuFrameCount = 0 + self.ganeshFrameCount = 0 + self.ganeshFallbackFrameCount = 0 + self.droppedFrameCount = 0 + self.inputEventCount = 0 + self.syntheticStrokeCount = 0 + self.syntheticPointCount = 0 + self.memoryStartBytes = Self.currentResidentMemoryBytes() + self.memoryEndBytes = memoryStartBytes + self.memoryPeakBytes = memoryStartBytes + self.memoryLowBytes = memoryStartBytes + self.isRunning = true + } + + func finish() -> [String: Any] { + sampleMemory() + endTime = CACurrentMediaTime() + isRunning = false + return summary() + } + + func recordInputSample(at timestamp: CFTimeInterval = CACurrentMediaTime()) { + guard isRunning else { return } + inputEventCount += 1 + pendingInputTimes.append(timestamp) + } + + func recordSyntheticStroke() { + guard isRunning else { return } + syntheticStrokeCount += 1 + } + + func recordSyntheticPoint() { + guard isRunning else { return } + syntheticPointCount += 1 + } + + func recordPresentedFrame( + backend: MobileInkRenderBackend, + didFallbackFromGanesh: Bool, + renderDurationMs: Double, + completedAt: CFTimeInterval, + maximumFramesPerSecond: Int + ) { + guard isRunning else { return } + + renderFrameCount += 1 + renderDurationsMs.append(renderDurationMs) + + switch backend { + case .cpu: + cpuFrameCount += 1 + case .ganesh: + ganeshFrameCount += 1 + } + + if didFallbackFromGanesh { + ganeshFallbackFrameCount += 1 + } + + if let lastPresentedAt { + let intervalMs = (completedAt - lastPresentedAt) * 1000.0 + if intervalMs > presentationPauseThresholdMs { + presentationPauseDurationsMs.append(intervalMs) + } else { + presentIntervalsMs.append(intervalMs) + let frameBudgetMs = 1000.0 / Double(max(1, maximumFramesPerSecond)) + if intervalMs > frameBudgetMs * frameBudgetMultiplier { + droppedFrameCount += max(1, Int(intervalMs / frameBudgetMs) - 1) + } + } + } + lastPresentedAt = completedAt + + if !pendingInputTimes.isEmpty { + for inputTime in pendingInputTimes { + inputToPresentLatenciesMs.append((completedAt - inputTime) * 1000.0) + } + pendingInputTimes.removeAll(keepingCapacity: true) + } + + sampleMemory() + } + + func summary() -> [String: Any] { + let durationMs = max(0.0, (endTime - startTime) * 1000.0) + let durationSeconds = max(0.001, durationMs / 1000.0) + let fpsAverage = Double(renderFrameCount) / durationSeconds + let presentationPauseTotalMs = presentationPauseDurationsMs.reduce(0, +) + let memoryDeltaBytes = Int64(memoryEndBytes) - Int64(memoryStartBytes) + + return [ + "sessionId": sessionId, + "scenario": scenario, + "requestedBackend": requestedBackend, + "durationMs": rounded(durationMs), + "fpsAverage": rounded(fpsAverage), + "renderFrameCount": renderFrameCount, + "cpuFrameCount": cpuFrameCount, + "ganeshFrameCount": ganeshFrameCount, + "ganeshFallbackFrameCount": ganeshFallbackFrameCount, + "droppedFrameCount": droppedFrameCount, + "inputEventCount": inputEventCount, + "syntheticStrokeCount": syntheticStrokeCount, + "syntheticPointCount": syntheticPointCount, + "renderMs": distribution(renderDurationsMs), + "frameIntervalMs": distribution(presentIntervalsMs), + "presentationPauseMs": distribution(presentationPauseDurationsMs), + "presentationPauseCount": presentationPauseDurationsMs.count, + "presentationPauseTotalMs": rounded(presentationPauseTotalMs), + "inputToPresentLatencyMs": distribution(inputToPresentLatenciesMs), + "memory": [ + "startBytes": memoryStartBytes, + "endBytes": memoryEndBytes, + "peakBytes": memoryPeakBytes, + "lowBytes": memoryLowBytes, + "deltaBytes": memoryDeltaBytes, + "peakDeltaBytes": Int64(memoryPeakBytes) - Int64(memoryStartBytes), + ], + ] + } + + private func sampleMemory() { + let current = Self.currentResidentMemoryBytes() + memoryEndBytes = current + memoryPeakBytes = max(memoryPeakBytes, current) + if memoryLowBytes == 0 { + memoryLowBytes = current + } else { + memoryLowBytes = min(memoryLowBytes, current) + } + } + + private func distribution(_ values: [Double]) -> [String: Any] { + guard !values.isEmpty else { + return [ + "count": 0, + "average": 0, + "p50": 0, + "p95": 0, + "p99": 0, + "max": 0, + ] + } + + let sorted = values.sorted() + let average = values.reduce(0, +) / Double(values.count) + + return [ + "count": values.count, + "average": rounded(average), + "p50": rounded(percentile(sorted, 0.50)), + "p95": rounded(percentile(sorted, 0.95)), + "p99": rounded(percentile(sorted, 0.99)), + "max": rounded(sorted.last ?? 0), + ] + } + + private func percentile(_ sortedValues: [Double], _ percentile: Double) -> Double { + guard !sortedValues.isEmpty else { return 0 } + let boundedPercentile = min(1.0, max(0.0, percentile)) + let rawIndex = boundedPercentile * Double(sortedValues.count - 1) + let lowerIndex = Int(floor(rawIndex)) + let upperIndex = Int(ceil(rawIndex)) + if lowerIndex == upperIndex { + return sortedValues[lowerIndex] + } + let fraction = rawIndex - Double(lowerIndex) + return sortedValues[lowerIndex] * (1.0 - fraction) + sortedValues[upperIndex] * fraction + } + + private func rounded(_ value: Double) -> Double { + (value * 100.0).rounded() / 100.0 + } + + private static func currentResidentMemoryBytes() -> UInt64 { + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let result: kern_return_t = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + + guard result == KERN_SUCCESS else { + return 0 + } + + return UInt64(info.resident_size) + } +} diff --git a/ios/MobileInkModule/MobileInkBridge.mm b/ios/MobileInkModule/MobileInkBridge.mm index 830da5f..cff23dd 100644 --- a/ios/MobileInkModule/MobileInkBridge.mm +++ b/ios/MobileInkModule/MobileInkBridge.mm @@ -9,6 +9,23 @@ // Note: SkImages namespace functions are in SkImage.h #include #include +#include +#include +#include +#include +#import + +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdocumentation" +#include +#include +#include +#include +#include +#include +#include +#pragma clang diagnostic pop + #include #include #include @@ -66,6 +83,21 @@ bool deserializeDrawingBytes( return serializer.deserialize(buffer, strokes); } +void renderWithCpuColorOrdering(nativedrawing::SkiaDrawingEngine* engine, SkCanvas* canvas) { + float redBlueSwapMatrix[20] = { + 0, 0, 1, 0, 0, + 0, 1, 0, 0, 0, + 1, 0, 0, 0, 0, + 0, 0, 0, 1, 0 + }; + + SkPaint outputPaint; + outputPaint.setColorFilter(SkColorFilters::Matrix(redBlueSwapMatrix)); + canvas->saveLayer(nullptr, &outputPaint); + engine->render(canvas); + canvas->restore(); +} + void translateStrokeInPlace( nativedrawing::Stroke& stroke, float deltaY, @@ -321,6 +353,78 @@ void renderToCanvas(void* engine, void* canvas) { } } +void* createGaneshMetalContext(void* devicePtr, void* commandQueuePtr) { + if (!devicePtr || !commandQueuePtr) { + return nullptr; + } + + id device = (__bridge id)devicePtr; + id commandQueue = (__bridge id)commandQueuePtr; + + GrMtlBackendContext backendContext = {}; + backendContext.fDevice.reset((__bridge void*)device); + backendContext.fQueue.reset((__bridge void*)commandQueue); + + sk_sp context = GrDirectContexts::MakeMetal(backendContext); + if (!context) { + NSLog(@"[MobileInk][Ganesh] Failed to create GrDirectContext for Metal"); + return nullptr; + } + + return new sk_sp(std::move(context)); +} + +void destroyGaneshMetalContext(void* contextPtr) { + if (!contextPtr) { + return; + } + + auto* contextHandle = static_cast*>(contextPtr); + if (contextHandle->get()) { + (*contextHandle)->flushAndSubmit(); + } + delete contextHandle; +} + +bool renderToGaneshMetalTexture(void* engine, void* contextPtr, void* texturePtr, int width, int height) { + if (!engine || !contextPtr || !texturePtr || width <= 0 || height <= 0) { + return false; + } + + auto* contextHandle = static_cast*>(contextPtr); + GrDirectContext* directContext = contextHandle->get(); + if (!directContext) { + return false; + } + + id texture = (__bridge id)texturePtr; + GrMtlTextureInfo textureInfo; + textureInfo.fTexture.retain((__bridge void*)texture); + + GrBackendRenderTarget backendRenderTarget = + GrBackendRenderTargets::MakeMtl(width, height, textureInfo); + + sk_sp surface = SkSurfaces::WrapBackendRenderTarget( + directContext, + backendRenderTarget, + kTopLeft_GrSurfaceOrigin, + kBGRA_8888_SkColorType, + nullptr, + nullptr + ); + + if (!surface) { + NSLog(@"[MobileInk][Ganesh] Failed to wrap MTKView drawable texture"); + return false; + } + + SkCanvas* canvas = surface->getCanvas(); + canvas->clear(SK_ColorTRANSPARENT); + renderWithCpuColorOrdering(static_cast(engine), canvas); + directContext->flushAndSubmit(surface.get()); + return true; +} + void* createSkiaCanvas(void* pixels, int width, int height, int rowBytes) { SkImageInfo info = SkImageInfo::MakeN32Premul(width, height); sk_sp surface = SkSurfaces::WrapPixels(info, pixels, rowBytes); diff --git a/ios/MobileInkModule/MobileInkCanvasView+Benchmark.swift b/ios/MobileInkModule/MobileInkCanvasView+Benchmark.swift new file mode 100644 index 0000000..ce9101f --- /dev/null +++ b/ios/MobileInkModule/MobileInkCanvasView+Benchmark.swift @@ -0,0 +1,493 @@ +import Metal +import QuartzCore +import React +import UIKit + +extension MobileInkCanvasView { + func attachBenchmarkFrameCallback( + to commandBuffer: MTLCommandBuffer, + backend: MobileInkRenderBackend, + didFallbackFromGanesh: Bool, + renderDurationMs: Double + ) { + guard benchmarkRecorder.isRunning else { + return + } + + let maximumFramesPerSecond = max(1, UIScreen.main.maximumFramesPerSecond) + commandBuffer.addCompletedHandler { [weak self] _ in + let completedAt = CACurrentMediaTime() + DispatchQueue.main.async { + self?.benchmarkRecorder.recordPresentedFrame( + backend: backend, + didFallbackFromGanesh: didFallbackFromGanesh, + renderDurationMs: renderDurationMs, + completedAt: completedAt, + maximumFramesPerSecond: maximumFramesPerSecond + ) + } + } + } + + func recordBenchmarkInputSample() { + benchmarkRecorder.recordInputSample() + } + + @objc func runBenchmark(_ options: NSDictionary, callback: @escaping RCTResponseSenderBlock) { + guard !isReleased else { + callback(["Canvas has already been released", NSNull()]) + return + } + + guard drawingEngine != nil, pixelWidth > 0, pixelHeight > 0, bounds.width > 0, bounds.height > 0 else { + callback(["Canvas is not ready for benchmarking", NSNull()]) + return + } + + guard !isBenchmarkReplayRunning else { + callback(["Benchmark is already running", NSNull()]) + return + } + + let scenario = benchmarkStringOption(options, key: "scenario", defaultValue: "custom") + let backendName = benchmarkStringOption(options, key: "backend", defaultValue: renderBackend) + let backend = MobileInkRenderBackend(rawValue: backendName.lowercased()) ?? requestedRenderBackend + let toolType = benchmarkStringOption(options, key: "toolType", defaultValue: "pen") + let workload = benchmarkStringOption( + options, + key: "workload", + defaultValue: toolType == "eraser" ? "erase" : "draw" + ) + let strokeCount = benchmarkIntOption(options, key: "strokeCount", defaultValue: 120, minimum: 1, maximum: 2_000) + let pointsPerStroke = benchmarkIntOption(options, key: "pointsPerStroke", defaultValue: 28, minimum: 2, maximum: 240) + let pointIntervalMs = benchmarkDoubleOption(options, key: "pointIntervalMs", defaultValue: 8, minimum: 1, maximum: 100) + let strokeGapMs = benchmarkDoubleOption(options, key: "strokeGapMs", defaultValue: 18, minimum: 0, maximum: 1_000) + let settleMs = benchmarkDoubleOption(options, key: "settleMs", defaultValue: 500, minimum: 0, maximum: 5_000) + let strokeWidth = CGFloat(benchmarkDoubleOption(options, key: "strokeWidth", defaultValue: 3, minimum: 0.25, maximum: 96)) + let seedStrokeCount = benchmarkIntOption( + options, + key: "seedStrokeCount", + defaultValue: max(16, min(160, strokeCount / 2)), + minimum: 1, + maximum: 1_000 + ) + let moveStepCount = benchmarkIntOption(options, key: "moveStepCount", defaultValue: 80, minimum: 1, maximum: 1_000) + let moveDeltaX = benchmarkDoubleOption(options, key: "moveDeltaX", defaultValue: 1.5, minimum: -64, maximum: 64) + let moveDeltaY = benchmarkDoubleOption(options, key: "moveDeltaY", defaultValue: 0.75, minimum: -64, maximum: 64) + let eraserMode = benchmarkStringOption(options, key: "eraserMode", defaultValue: "pixel") + let toolColor = benchmarkColorOption( + options, + defaultValue: benchmarkDefaultToolColor(toolType) + ) + let shouldClearCanvas = benchmarkBoolOption(options, key: "clearCanvas", defaultValue: true) + + renderBackend = backend.rawValue + + guard let engine = drawingEngine else { + callback(["Engine became unavailable before benchmark start", NSNull()]) + return + } + + if shouldClearCanvas { + clearPredictedPoints(engine) + clearCanvas(engine) + } + resetTransientInteractionState() + + if workload == "erase" { + setTool("pen", width: Float(max(2, strokeWidth)), color: .black, eraserMode: "pixel") + benchmarkSeedStrokes( + engine, + strokeCount: seedStrokeCount, + pointsPerStroke: pointsPerStroke + ) + setTool("eraser", width: Float(max(strokeWidth, 32)), color: .white, eraserMode: eraserMode) + } else if workload == "selectionMove" { + setTool("pen", width: Float(max(2, strokeWidth)), color: .black, eraserMode: "pixel") + benchmarkSeedStrokes( + engine, + strokeCount: seedStrokeCount, + pointsPerStroke: pointsPerStroke + ) + setTool("select", width: Float(strokeWidth), color: .black, eraserMode: "pixel") + } else { + setTool(toolType, width: Float(strokeWidth), color: toolColor, eraserMode: eraserMode) + } + + requestDisplay(forceWhenSuspended: true) + + isBenchmarkReplayRunning = true + let token = UUID() + benchmarkRunToken = token + benchmarkRecorder.start(scenario: scenario, requestedBackend: backend.rawValue) + + var strokeIndex = 0 + var pointIndex = 0 + var didComplete = false + + func complete(_ error: String?, _ result: [String: Any]?) { + guard !didComplete else { return } + didComplete = true + isBenchmarkReplayRunning = false + isHoldToShapeStrokeActive = false + cancelHoldToShapePreview() + + if let error { + if benchmarkRecorder.isRunning { + _ = benchmarkRecorder.finish() + } + callback([error, NSNull()]) + } else { + onDrawingChange?([:]) + callback([NSNull(), result ?? benchmarkRecorder.finish()]) + } + } + + if workload == "selectionMove" { + benchmarkRecorder.recordSyntheticStroke() + benchmarkRecorder.recordSyntheticPoint() + recordBenchmarkInputSample() + + let selectionPoint = benchmarkReplayPoint( + strokeIndex: 0, + pointIndex: max(1, pointsPerStroke / 2), + pointsPerStroke: pointsPerStroke + ) + clearSelection(engine) + _ = selectStrokeAt( + engine, + Float(selectionPoint.x * scaleX), + Float(selectionPoint.y * scaleY) + ) + notifySelectionChange() + requestDisplay() + + var moveIndex = 0 + var replayNextMove: (() -> Void)? + replayNextMove = { [weak self] in + guard let self = self else { return } + guard token == self.benchmarkRunToken, self.isBenchmarkReplayRunning else { return } + guard let engine = self.drawingEngine else { + complete("Engine became unavailable during selection benchmark", nil) + return + } + + if moveIndex >= moveStepCount { + finalizeMove(engine) + self.requestDisplay() + DispatchQueue.main.asyncAfter(deadline: .now() + settleMs / 1_000.0) { [weak self] in + guard let self = self else { return } + guard token == self.benchmarkRunToken else { return } + complete(nil, self.benchmarkRecorder.finish()) + } + return + } + + self.benchmarkRecorder.recordSyntheticPoint() + self.recordBenchmarkInputSample() + moveSelection( + engine, + Float(moveDeltaX) * Float(self.scaleX), + Float(moveDeltaY) * Float(self.scaleY) + ) + self.notifySelectionChange() + self.requestDisplay() + moveIndex += 1 + + DispatchQueue.main.asyncAfter(deadline: .now() + pointIntervalMs / 1_000.0) { + replayNextMove?() + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + replayNextMove?() + } + return + } + + var replayNextPoint: (() -> Void)? + replayNextPoint = { [weak self] in + guard let self = self else { return } + guard token == self.benchmarkRunToken, self.isBenchmarkReplayRunning else { return } + guard let engine = self.drawingEngine else { + complete("Engine became unavailable during benchmark", nil) + return + } + + if strokeIndex >= strokeCount { + DispatchQueue.main.asyncAfter(deadline: .now() + settleMs / 1_000.0) { [weak self] in + guard let self = self else { return } + guard token == self.benchmarkRunToken else { return } + complete(nil, self.benchmarkRecorder.finish()) + } + return + } + + if pointIndex == 0 { + let point = self.benchmarkReplayPoint( + strokeIndex: strokeIndex, + pointIndex: pointIndex, + pointsPerStroke: pointsPerStroke + ) + self.benchmarkRecorder.recordSyntheticStroke() + self.benchmarkRecorder.recordSyntheticPoint() + self.recordBenchmarkInputSample() + touchBegan( + engine, + Float(point.x * self.scaleX), + Float(point.y * self.scaleY), + 1.0, + 0.0, + PencilStrokeInputNormalizer.perpendicularAltitude, + self.currentUptimeTimestampMillis(), + true + ) + self.requestDisplay() + pointIndex += 1 + } else if pointIndex < pointsPerStroke { + let point = self.benchmarkReplayPoint( + strokeIndex: strokeIndex, + pointIndex: pointIndex, + pointsPerStroke: pointsPerStroke + ) + self.benchmarkRecorder.recordSyntheticPoint() + self.recordBenchmarkInputSample() + touchMoved( + engine, + Float(point.x * self.scaleX), + Float(point.y * self.scaleY), + 1.0, + 0.0, + PencilStrokeInputNormalizer.perpendicularAltitude, + self.currentUptimeTimestampMillis(), + true + ) + self.requestDisplay() + pointIndex += 1 + } else { + clearPredictedPoints(engine) + self.recordBenchmarkInputSample() + touchEnded(engine, self.currentUptimeTimestampMillis()) + self.requestDisplay() + strokeIndex += 1 + pointIndex = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + strokeGapMs / 1_000.0) { + replayNextPoint?() + } + return + } + + DispatchQueue.main.asyncAfter(deadline: .now() + pointIntervalMs / 1_000.0) { + replayNextPoint?() + } + } + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + replayNextPoint?() + } + } + + @objc func startBenchmarkRecording(_ options: NSDictionary, callback: @escaping RCTResponseSenderBlock) { + guard !isReleased else { + callback(["Canvas has already been released", NSNull()]) + return + } + + guard drawingEngine != nil, pixelWidth > 0, pixelHeight > 0, bounds.width > 0, bounds.height > 0 else { + callback(["Canvas is not ready for benchmarking", NSNull()]) + return + } + + guard !isBenchmarkReplayRunning else { + callback(["Synthetic benchmark is already running", NSNull()]) + return + } + + guard !benchmarkRecorder.isRunning else { + callback(["Benchmark recording is already running", NSNull()]) + return + } + + let scenario = benchmarkStringOption(options, key: "scenario", defaultValue: "manual") + let backendName = benchmarkStringOption(options, key: "backend", defaultValue: renderBackend) + let backend = MobileInkRenderBackend(rawValue: backendName.lowercased()) ?? requestedRenderBackend + let shouldClearCanvas = (options["clearCanvas"] as? NSNumber)?.boolValue ?? false + + renderBackend = backend.rawValue + + if shouldClearCanvas, let engine = drawingEngine { + clearPredictedPoints(engine) + clearCanvas(engine) + resetTransientInteractionState() + requestDisplay(forceWhenSuspended: true) + } + + benchmarkRecorder.start(scenario: scenario, requestedBackend: backend.rawValue) + callback([NSNull(), true]) + } + + @objc func stopBenchmarkRecording(_ callback: @escaping RCTResponseSenderBlock) { + guard !isBenchmarkReplayRunning else { + callback(["Synthetic benchmark is still running", NSNull()]) + return + } + + guard benchmarkRecorder.isRunning else { + callback(["Benchmark recording is not running", NSNull()]) + return + } + + callback([NSNull(), benchmarkRecorder.finish()]) + } + + private func benchmarkReplayPoint( + strokeIndex: Int, + pointIndex: Int, + pointsPerStroke: Int + ) -> CGPoint { + let inset: CGFloat = 36 + let availableWidth = max(1, bounds.width - inset * 2) + let availableHeight = max(1, bounds.height - inset * 2) + let rowSpacing: CGFloat = 28 + let rowCount = max(1, Int(availableHeight / rowSpacing)) + let row = strokeIndex % rowCount + let wrap = strokeIndex / rowCount + let denominator = max(1, pointsPerStroke - 1) + let progress = CGFloat(pointIndex) / CGFloat(denominator) + let baselineY = inset + CGFloat(row) * rowSpacing + CGFloat(wrap % 4) * 3 + let wave = sin(progress * .pi * 4 + CGFloat(strokeIndex) * 0.37) * 8 + let x = inset + progress * availableWidth + let y = min(max(inset, baselineY + wave), bounds.height - inset) + return CGPoint(x: x, y: y) + } + + private func benchmarkSeedStrokes( + _ engine: OpaquePointer, + strokeCount: Int, + pointsPerStroke: Int + ) { + for strokeIndex in 0.. UIColor { + switch toolType { + case "highlighter": + return UIColor(red: 1.0, green: 0.88, blue: 0.32, alpha: 1.0) + case "crayon": + return UIColor(red: 0.12, green: 0.42, blue: 1.0, alpha: 1.0) + case "eraser": + return .white + default: + return .black + } + } + + private func benchmarkColorOption( + _ options: NSDictionary, + defaultValue: UIColor + ) -> UIColor { + guard let color = options["color"] as? String else { + return defaultValue + } + return UIColor(hex: color) ?? defaultValue + } + + private func benchmarkStringOption( + _ options: NSDictionary, + key: String, + defaultValue: String + ) -> String { + options[key] as? String ?? defaultValue + } + + private func benchmarkIntOption( + _ options: NSDictionary, + key: String, + defaultValue: Int, + minimum: Int, + maximum: Int + ) -> Int { + let value: Int + if let number = options[key] as? NSNumber { + value = number.intValue + } else if let string = options[key] as? String, let parsed = Int(string) { + value = parsed + } else { + value = defaultValue + } + return min(max(value, minimum), maximum) + } + + private func benchmarkDoubleOption( + _ options: NSDictionary, + key: String, + defaultValue: Double, + minimum: Double, + maximum: Double + ) -> Double { + let value: Double + if let number = options[key] as? NSNumber { + value = number.doubleValue + } else if let string = options[key] as? String, let parsed = Double(string) { + value = parsed + } else { + value = defaultValue + } + return min(max(value, minimum), maximum) + } + + private func benchmarkBoolOption( + _ options: NSDictionary, + key: String, + defaultValue: Bool + ) -> Bool { + if let number = options[key] as? NSNumber { + return number.boolValue + } + if let string = options[key] as? String { + let normalized = string.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["true", "1", "yes"].contains(normalized) { + return true + } + if ["false", "0", "no"].contains(normalized) { + return false + } + } + return defaultValue + } +} diff --git a/ios/MobileInkModule/MobileInkCanvasView.swift b/ios/MobileInkModule/MobileInkCanvasView.swift index 3ca3505..1ab2670 100644 --- a/ios/MobileInkModule/MobileInkCanvasView.swift +++ b/ios/MobileInkModule/MobileInkCanvasView.swift @@ -55,15 +55,23 @@ enum PencilStrokeInputNormalizer { @objc(MobileInkCanvasView) class MobileInkCanvasView: MTKView { - private var drawingEngine: OpaquePointer? + var drawingEngine: OpaquePointer? private var commandQueue: MTLCommandQueue? - private var scaleX: CGFloat = 1.0 - private var scaleY: CGFloat = 1.0 + private var ganeshMetalContext: OpaquePointer? + var requestedRenderBackend: MobileInkRenderBackend = .ganesh + var benchmarkRecorder = MobileInkBenchmarkRecorder() + var isBenchmarkReplayRunning = false + var benchmarkRunToken = UUID() + private var useExperimentalGaneshBackend: Bool { + requestedRenderBackend == .ganesh + } + var scaleX: CGFloat = 1.0 + var scaleY: CGFloat = 1.0 private var pixelBuffer: UnsafeMutablePointer? private var pixelBufferLength: Int = 0 private var pixelBytesPerRow: Int = 0 - private var pixelWidth: Int32 = 0 - private var pixelHeight: Int32 = 0 + var pixelWidth: Int32 = 0 + var pixelHeight: Int32 = 0 private var enginePixelWidth: Int32 = 0 private var enginePixelHeight: Int32 = 0 @@ -76,7 +84,7 @@ class MobileInkCanvasView: MTKView { private let holdToShapeDelay: TimeInterval = 0.30 private let holdToShapeRetryDelay: TimeInterval = 0.06 private var holdToShapeTimer: Timer? - private var isHoldToShapeStrokeActive = false + var isHoldToShapeStrokeActive = false // Eraser cursor private var eraserCursorLayer: CAShapeLayer? @@ -101,6 +109,25 @@ class MobileInkCanvasView: MTKView { // Drawing policy: "default", "anyinput", or "pencilonly" // When "pencilonly", only Apple Pencil touches are processed for drawing @objc var drawingPolicy: String = "default" + @objc var renderBackend: String = MobileInkRenderBackend.ganesh.rawValue { + didSet { + let normalizedBackend = MobileInkRenderBackend(rawValue: renderBackend.lowercased()) ?? .ganesh + requestedRenderBackend = normalizedBackend + + if normalizedBackend == .cpu { + if pixelWidth > 0 && pixelHeight > 0 { + allocatePixelBuffer(width: Int(pixelWidth), height: Int(pixelHeight)) + } + } else { + pixelBuffer?.deallocate() + pixelBuffer = nil + pixelBufferLength = 0 + pixelBytesPerRow = 0 + } + + requestDisplay(forceWhenSuspended: true) + } + } @objc var renderSuspended: Bool = false { didSet { if oldValue != renderSuspended && !renderSuspended { @@ -200,7 +227,7 @@ class MobileInkCanvasView: MTKView { /// would re-create the 13 MB buffer right after we just freed it, /// effectively undoing the release. Once true, never flips back -- /// a released view is dead. - private var isReleased: Bool = false + var isReleased: Bool = false deinit { releaseHeavyNativeState() @@ -221,6 +248,8 @@ class MobileInkCanvasView: MTKView { if isReleased { return } isReleased = true + benchmarkRunToken = UUID() + isBenchmarkReplayRunning = false holdToShapeTimer?.invalidate() holdToShapeTimer = nil pixelBuffer?.deallocate() @@ -231,6 +260,10 @@ class MobileInkCanvasView: MTKView { destroyDrawingEngine(engine) drawingEngine = nil } + if let context = ganeshMetalContext { + destroyGaneshMetalContext(context) + ganeshMetalContext = nil + } // Drop any queued JS callbacks waiting for a Metal frame that // will never come now that we're off-screen. Without this they // pin the bridge callback closures until the MTKView eventually @@ -651,6 +684,7 @@ class MobileInkCanvasView: MTKView { let isPencilInput = PencilStrokeInputNormalizer.isPencil(touch) let timestamp = Int64(touch.timestamp * 1000) // Convert to milliseconds touchBegan(engine, scaledX, scaledY, pressure, azimuth, altitude, timestamp, isPencilInput) + recordBenchmarkInputSample() isDraggingSelection = true lastDragPoint = location @@ -663,6 +697,7 @@ class MobileInkCanvasView: MTKView { let isPencilInput = PencilStrokeInputNormalizer.isPencil(touch) let timestamp = Int64(touch.timestamp * 1000) // Convert to milliseconds touchBegan(engine, scaledX, scaledY, pressure, azimuth, altitude, timestamp, isPencilInput) + recordBenchmarkInputSample() isHoldToShapeStrokeActive = true scheduleHoldToShapePreview(restart: true) requestDisplay() @@ -834,6 +869,7 @@ class MobileInkCanvasView: MTKView { // Final stroke should only contain actual touch data, not predictions clearPredictedPoints(engine) touchEnded(engine, currentUptimeTimestampMillis()) + recordBenchmarkInputSample() requestDisplay() onDrawingChange?([:]) } @@ -1144,7 +1180,7 @@ class MobileInkCanvasView: MTKView { isTextMode = (pendingTool == "text") } - private func resetTransientInteractionState() { + func resetTransientInteractionState() { isDraggingSelection = false isMovingSelection = false isTransformingSelection = false @@ -1374,7 +1410,7 @@ class MobileInkCanvasView: MTKView { onDrawingChange?([:]) } - private func notifySelectionChange() { + func notifySelectionChange() { guard let engine = drawingEngine else { return } let count = Int(getSelectionCount(engine)) var payload: [String: Any] = ["count": count] @@ -1572,7 +1608,14 @@ extension MobileInkCanvasView: MTKViewDelegate { if size.width > 0 && size.height > 0 { pixelWidth = Int32(size.width) pixelHeight = Int32(size.height) - allocatePixelBuffer(width: Int(size.width), height: Int(size.height)) + if useExperimentalGaneshBackend { + pixelBuffer?.deallocate() + pixelBuffer = nil + pixelBufferLength = 0 + pixelBytesPerRow = 0 + } else { + allocatePixelBuffer(width: Int(size.width), height: Int(size.height)) + } } if pixelWidth > 0 && pixelHeight > 0 { @@ -1597,15 +1640,82 @@ extension MobileInkCanvasView: MTKViewDelegate { } + private func ensureGaneshMetalContext() -> OpaquePointer? { + if let context = ganeshMetalContext { + return context + } + + guard let device = device, let commandQueue = commandQueue else { + return nil + } + + let devicePtr = Unmanaged.passUnretained(device).toOpaque() + let queuePtr = Unmanaged.passUnretained(commandQueue).toOpaque() + ganeshMetalContext = createGaneshMetalContext(devicePtr, queuePtr) + if ganeshMetalContext != nil { + print("[MobileInk][Ganesh] Experimental Ganesh/Metal renderer enabled") + } + return ganeshMetalContext + } + + private func attachPresentedLoadCallbacks(to commandBuffer: MTLCommandBuffer) { + guard !pendingPresentedLoadCallbacks.isEmpty else { + return + } + + let loadCallbacks = pendingPresentedLoadCallbacks + pendingPresentedLoadCallbacks.removeAll() + commandBuffer.addCompletedHandler { _ in + DispatchQueue.main.async { + loadCallbacks.forEach { callback in + callback([NSNull(), true]) + } + } + } + } + func draw(in view: MTKView) { - guard let engine = drawingEngine, - let commandBuffer = commandQueue?.makeCommandBuffer(), - let buffer = pixelBuffer else { + guard let engine = drawingEngine else { return } let width = pixelWidth let height = pixelHeight + + if useExperimentalGaneshBackend, + let commandQueue = commandQueue, + let ganeshContext = ensureGaneshMetalContext(), + let freshDrawable = view.currentDrawable { + let texturePtr = Unmanaged.passUnretained(freshDrawable.texture).toOpaque() + let renderStart = CACurrentMediaTime() + if renderToGaneshMetalTexture(engine, ganeshContext, texturePtr, width, height) { + let renderDurationMs = (CACurrentMediaTime() - renderStart) * 1000.0 + guard let commandBuffer = commandQueue.makeCommandBuffer() else { + return + } + attachPresentedLoadCallbacks(to: commandBuffer) + attachBenchmarkFrameCallback( + to: commandBuffer, + backend: .ganesh, + didFallbackFromGanesh: false, + renderDurationMs: renderDurationMs + ) + commandBuffer.present(freshDrawable) + commandBuffer.commit() + return + } + } + + if useExperimentalGaneshBackend && pixelBuffer == nil && width > 0 && height > 0 { + allocatePixelBuffer(width: Int(width), height: Int(height)) + } + + guard let commandBuffer = commandQueue?.makeCommandBuffer(), + let buffer = pixelBuffer else { + return + } + + let renderStart = CACurrentMediaTime() let bytesPerRow = pixelBytesPerRow // Clear pixel buffer to transparent (background view underneath will show through) @@ -1630,18 +1740,15 @@ extension MobileInkCanvasView: MTKViewDelegate { withBytes: UnsafeRawPointer(buffer), bytesPerRow: bytesPerRow ) - - if !pendingPresentedLoadCallbacks.isEmpty { - let loadCallbacks = pendingPresentedLoadCallbacks - pendingPresentedLoadCallbacks.removeAll() - commandBuffer.addCompletedHandler { _ in - DispatchQueue.main.async { - loadCallbacks.forEach { callback in - callback([NSNull(), true]) - } - } - } - } + let renderDurationMs = (CACurrentMediaTime() - renderStart) * 1000.0 + + attachPresentedLoadCallbacks(to: commandBuffer) + attachBenchmarkFrameCallback( + to: commandBuffer, + backend: .cpu, + didFallbackFromGanesh: useExperimentalGaneshBackend, + renderDurationMs: renderDurationMs + ) commandBuffer.present(freshDrawable) commandBuffer.commit() @@ -1660,7 +1767,7 @@ extension MobileInkCanvasView { || pendingTool == "calligraphy") } - private func currentUptimeTimestampMillis() -> Int64 { + func currentUptimeTimestampMillis() -> Int64 { Int64(ProcessInfo.processInfo.systemUptime * 1000) } @@ -1683,7 +1790,7 @@ extension MobileInkCanvasView { RunLoop.main.add(timer, forMode: .common) } - private func cancelHoldToShapePreview() { + func cancelHoldToShapePreview() { holdToShapeTimer?.invalidate() holdToShapeTimer = nil } @@ -1720,6 +1827,7 @@ extension MobileInkCanvasView { let scaledY = Float(location.y * scaleY) let timestamp = Int64(touch.timestamp * 1000) // Convert to milliseconds touchMoved(engine, scaledX, scaledY, pressure, azimuth, altitude, timestamp, isPencilInput) + recordBenchmarkInputSample() } // PREDICTIVE TOUCH: Process predicted touch samples for Apple Pencil low-latency rendering diff --git a/ios/MobileInkModule/MobileInkCanvasViewManager.m b/ios/MobileInkModule/MobileInkCanvasViewManager.m index 2dd8099..c0764b4 100644 --- a/ios/MobileInkModule/MobileInkCanvasViewManager.m +++ b/ios/MobileInkModule/MobileInkCanvasViewManager.m @@ -10,6 +10,7 @@ @interface RCT_EXTERN_MODULE(MobileInkCanvasViewManager, RCTViewManager) RCT_EXPORT_VIEW_PROPERTY(backgroundType, NSString) RCT_EXPORT_VIEW_PROPERTY(pdfBackgroundUri, NSString) RCT_EXPORT_VIEW_PROPERTY(renderSuspended, BOOL) +RCT_EXPORT_VIEW_PROPERTY(renderBackend, NSString) RCT_EXPORT_VIEW_PROPERTY(drawingPolicy, NSString) RCT_EXTERN_METHOD(clear:(nonnull NSNumber *)node) @@ -44,6 +45,17 @@ @interface RCT_EXTERN_MODULE(MobileInkCanvasViewManager, RCTViewManager) RCT_EXTERN_METHOD(simulatePencilDoubleTap:(nonnull NSNumber *)node callback:(RCTResponseSenderBlock)callback) +RCT_EXTERN_METHOD(runBenchmark:(nonnull NSNumber *)node + options:(NSDictionary *)options + callback:(RCTResponseSenderBlock)callback) + +RCT_EXTERN_METHOD(startBenchmarkRecording:(nonnull NSNumber *)node + options:(NSDictionary *)options + callback:(RCTResponseSenderBlock)callback) + +RCT_EXTERN_METHOD(stopBenchmarkRecording:(nonnull NSNumber *)node + callback:(RCTResponseSenderBlock)callback) + RCT_EXTERN_METHOD(releaseEngine:(nonnull NSNumber *)node callback:(RCTResponseSenderBlock)callback) diff --git a/ios/MobileInkModule/MobileInkCanvasViewManager.swift b/ios/MobileInkModule/MobileInkCanvasViewManager.swift index 6165e47..4bc5c7d 100644 --- a/ios/MobileInkModule/MobileInkCanvasViewManager.swift +++ b/ios/MobileInkModule/MobileInkCanvasViewManager.swift @@ -81,6 +81,14 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { } } + @objc var renderBackend: String? { + didSet { + if let backend = renderBackend { + drawingView?.renderBackend = backend + } + } + } + // Drawing policy - controls whether fingers or only Apple Pencil can draw @objc var drawingPolicy: String? { didSet { @@ -113,6 +121,9 @@ class DrawingContainerView: UIView, UIPencilInteractionDelegate { if let policy = drawingPolicy { view.drawingPolicy = policy } + if let backend = renderBackend { + view.renderBackend = backend + } view.renderSuspended = renderSuspended } @@ -325,6 +336,39 @@ class MobileInkCanvasViewManager: RCTViewManager { } } + @objc func runBenchmark(_ node: NSNumber, options: NSDictionary, callback: @escaping RCTResponseSenderBlock) { + DispatchQueue.main.async { + if let container = self.bridge.uiManager.view(forReactTag: node), + let view = self.findDrawingView(container) { + view.runBenchmark(options, callback: callback) + } else { + callback(["View not found", NSNull()]) + } + } + } + + @objc func startBenchmarkRecording(_ node: NSNumber, options: NSDictionary, callback: @escaping RCTResponseSenderBlock) { + DispatchQueue.main.async { + if let container = self.bridge.uiManager.view(forReactTag: node), + let view = self.findDrawingView(container) { + view.startBenchmarkRecording(options, callback: callback) + } else { + callback(["View not found", NSNull()]) + } + } + } + + @objc func stopBenchmarkRecording(_ node: NSNumber, callback: @escaping RCTResponseSenderBlock) { + DispatchQueue.main.async { + if let container = self.bridge.uiManager.view(forReactTag: node), + let view = self.findDrawingView(container) { + view.stopBenchmarkRecording(callback) + } else { + callback(["View not found", NSNull()]) + } + } + } + /// Explicit eager-release call from JS. The slot's React unmount path /// invokes this via the bridge before letting React tear down the view, /// so the heavy native state (13 MiB pixel buffer, multi-MB engine diff --git a/ios/MobileInkModule/SkiaEngineBridge.swift b/ios/MobileInkModule/SkiaEngineBridge.swift index 9d5280d..34f6df4 100644 --- a/ios/MobileInkModule/SkiaEngineBridge.swift +++ b/ios/MobileInkModule/SkiaEngineBridge.swift @@ -70,6 +70,21 @@ func isEmpty(_ engine: OpaquePointer) -> Bool @_silgen_name("renderToCanvas") func renderToCanvas(_ engine: OpaquePointer, _ canvas: OpaquePointer) +@_silgen_name("createGaneshMetalContext") +func createGaneshMetalContext(_ device: UnsafeMutableRawPointer, _ commandQueue: UnsafeMutableRawPointer) -> OpaquePointer? + +@_silgen_name("destroyGaneshMetalContext") +func destroyGaneshMetalContext(_ context: OpaquePointer) + +@_silgen_name("renderToGaneshMetalTexture") +func renderToGaneshMetalTexture( + _ engine: OpaquePointer, + _ context: OpaquePointer, + _ texture: UnsafeMutableRawPointer, + _ width: Int32, + _ height: Int32 +) -> Bool + // MARK: - Skia Canvas Helpers @_silgen_name("createSkiaCanvas") func createSkiaCanvas(_ pixels: UnsafeMutableRawPointer, _ width: Int32, _ height: Int32, _ rowBytes: Int32) -> OpaquePointer? diff --git a/package.json b/package.json index 6031aa6..6fbdb7d 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "src/index.ts", "src/payload.ts", "src/types.ts", + "src/benchmark", "src/utils", "ios", "cpp", diff --git a/src/ContinuousEnginePool.tsx b/src/ContinuousEnginePool.tsx index 6100f46..fb7e814 100644 --- a/src/ContinuousEnginePool.tsx +++ b/src/ContinuousEnginePool.tsx @@ -9,6 +9,15 @@ import React, { } from "react"; import { StyleSheet, View } from "react-native"; import { NativeInkCanvas } from "./NativeInkCanvas"; +import { + DEFAULT_NATIVE_INK_RENDER_BACKEND, +} from "./benchmark"; +import type { + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkRenderBackend, +} from "./benchmark"; import type { NativeInkCanvasProps, NativeInkCanvasRef, @@ -47,17 +56,23 @@ export type ContinuousEnginePoolSlotRef = { performCopy: () => void; performPaste: () => void; performDelete: () => void; + runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; + startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording?: () => Promise; }; export type ContinuousEnginePoolRef = { assignPages: (assignments: ContinuousEnginePoolAssignment[]) => Promise; applyToolState: (toolState: ContinuousEnginePoolToolState) => void; + startBenchmarkRecording: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording: () => Promise; release: () => Promise; }; export type ContinuousEnginePoolProps = { canvasHeight: number; backgroundType: string; + renderBackend?: NativeInkRenderBackend; pdfBackgroundBaseUri: string | undefined; fingerDrawingEnabled: boolean; getToolState: () => ContinuousEnginePoolToolState; @@ -96,6 +111,8 @@ type SlotAssignOptions = { type PooledCanvasSlotHandle = { assign: (options: SlotAssignOptions) => Promise; applyToolState: (toolState: ContinuousEnginePoolToolState) => void; + startBenchmarkRecording: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording: () => Promise; release: () => Promise; }; @@ -103,6 +120,7 @@ type PooledCanvasSlotProps = { poolIndex: number; canvasHeight: number; backgroundType: string; + renderBackend?: NativeInkRenderBackend; pdfBackgroundBaseUri?: string; drawingPolicy: "anyinput" | "pencilonly"; getToolState: () => ContinuousEnginePoolToolState; @@ -164,6 +182,7 @@ const PooledCanvasSlot = memo(forwardRef { canvasRef.current?.performDelete(); }, + runBenchmark: (options) => { + if (!canvasRef.current?.runBenchmark) { + return Promise.reject(new Error("Native benchmark runner is unavailable.")); + } + return canvasRef.current.runBenchmark(options); + }, + startBenchmarkRecording: (options) => { + if (!canvasRef.current?.startBenchmarkRecording) { + return Promise.reject(new Error("Native benchmark recorder is unavailable.")); + } + return canvasRef.current.startBenchmarkRecording(options); + }, + stopBenchmarkRecording: () => { + if (!canvasRef.current?.stopBenchmarkRecording) { + return Promise.reject(new Error("Native benchmark recorder is unavailable.")); + } + return canvasRef.current.stopBenchmarkRecording(); + }, }), [], ); @@ -506,6 +544,10 @@ const PooledCanvasSlot = memo(forwardRef null); + benchmarkRecordingActiveRef.current = false; + } await captureLoadedPage(pageId, nativeCanvas); await nativeCanvas.releaseEngine?.().catch(() => {}); if (lastAttachedCanvasRef.current === nativeCanvas) { @@ -514,11 +556,57 @@ const PooledCanvasSlot = memo(forwardRef { + if (benchmarkRecordingActiveRef.current) { + return true; + } + if (!isLoadedRef.current || !canvasRef.current?.startBenchmarkRecording) { + return false; + } + + const didStart = await canvasRef.current.startBenchmarkRecording(options); + benchmarkRecordingActiveRef.current = didStart; + return didStart; + }, []); + + const stopBenchmarkRecording = useCallback(async () => { + if (!benchmarkRecordingActiveRef.current) { + return null; + } + if (!canvasRef.current?.stopBenchmarkRecording) { + benchmarkRecordingActiveRef.current = false; + return null; + } + + try { + const result = await canvasRef.current.stopBenchmarkRecording(); + benchmarkRecordingActiveRef.current = false; + return result; + } catch (error) { + benchmarkRecordingActiveRef.current = false; + const message = error instanceof Error ? error.message : String(error); + if (message.includes("not running")) { + return null; + } + throw error; + } + }, []); + useImperativeHandle(ref, () => ({ + assign, + applyToolState, + startBenchmarkRecording, + stopBenchmarkRecording, + release, + }), [ assign, applyToolState, release, - }), [assign, applyToolState, release]); + startBenchmarkRecording, + stopBenchmarkRecording, + ]); useEffect(() => { return () => { @@ -574,6 +662,7 @@ const PooledCanvasSlot = memo(forwardRef(function ContinuousEnginePool({ canvasHeight, backgroundType, + renderBackend = DEFAULT_NATIVE_INK_RENDER_BACKEND, pdfBackgroundBaseUri, fingerDrawingEnabled, getToolState, @@ -628,6 +719,8 @@ export const ContinuousEnginePool = memo(forwardRef< ); const onAssignmentReadyRef = useRef(onAssignmentReady); const onPageAssignmentReadyRef = useRef(onPageAssignmentReady); + const benchmarkRecordingOptionsRef = useRef(null); + const benchmarkRecordingSlotIndexesRef = useRef(new Set()); onAssignmentReadyRef.current = onAssignmentReady; onPageAssignmentReadyRef.current = onPageAssignmentReady; @@ -652,6 +745,22 @@ export const ContinuousEnginePool = memo(forwardRef< onPageAssignmentReadyRef.current?.(pageId, pageIndex, loadedAssignmentKey); }, []); + const startSlotBenchmarkRecording = useCallback(async ( + poolIndex: number, + slotRef: PooledCanvasSlotHandle | null | undefined, + options: NativeInkBenchmarkRecordingOptions, + ) => { + if (!slotRef || benchmarkRecordingSlotIndexesRef.current.has(poolIndex)) { + return false; + } + + const didStart = await slotRef.startBenchmarkRecording(options); + if (didStart) { + benchmarkRecordingSlotIndexesRef.current.add(poolIndex); + } + return didStart; + }, []); + const assignPages = useCallback(async ( assignments: ContinuousEnginePoolAssignment[], ) => { @@ -719,10 +828,29 @@ export const ContinuousEnginePool = memo(forwardRef< ); await Promise.all(slotPromises); + const benchmarkRecordingOptions = benchmarkRecordingOptionsRef.current; + if (benchmarkRecordingOptions) { + await Promise.all( + nextSlotAssignments.map((assignment, poolIndex) => ( + assignment + ? startSlotBenchmarkRecording( + poolIndex, + slotRefs.current[poolIndex], + benchmarkRecordingOptions, + ) + : Promise.resolve(false) + )), + ); + } if (assignmentKeyRef.current === nextAssignmentKey) { onAssignmentReadyRef.current?.(nextAssignmentKey); } - }, [backgroundType, canvasHeight, pdfBackgroundBaseUri]); + }, [ + backgroundType, + canvasHeight, + pdfBackgroundBaseUri, + startSlotBenchmarkRecording, + ]); const applyToolState = useCallback((toolState: ContinuousEnginePoolToolState) => { for (const slotRef of slotRefs.current) { @@ -730,7 +858,50 @@ export const ContinuousEnginePool = memo(forwardRef< } }, []); + const startBenchmarkRecording = useCallback(async ( + options: NativeInkBenchmarkRecordingOptions = {}, + ) => { + if (benchmarkRecordingOptionsRef.current) { + return true; + } + + benchmarkRecordingOptionsRef.current = options; + benchmarkRecordingSlotIndexesRef.current.clear(); + const starts = await Promise.all( + slotRefs.current.map((slotRef, poolIndex) => ( + startSlotBenchmarkRecording(poolIndex, slotRef, options) + )), + ); + + const didStartAny = starts.some(Boolean); + if (!didStartAny) { + benchmarkRecordingOptionsRef.current = null; + benchmarkRecordingSlotIndexesRef.current.clear(); + } + return didStartAny; + }, [startSlotBenchmarkRecording]); + + const stopBenchmarkRecording = useCallback(async () => { + const recordingSlotIndexes = Array.from( + benchmarkRecordingSlotIndexesRef.current, + ); + benchmarkRecordingOptionsRef.current = null; + benchmarkRecordingSlotIndexesRef.current.clear(); + + const results = await Promise.all( + recordingSlotIndexes.map((poolIndex) => ( + slotRefs.current[poolIndex]?.stopBenchmarkRecording() ?? Promise.resolve(null) + )), + ); + + return results.filter( + (result): result is NativeInkBenchmarkResult => result !== null, + ); + }, []); + const release = useCallback(async () => { + benchmarkRecordingOptionsRef.current = null; + benchmarkRecordingSlotIndexesRef.current.clear(); await Promise.all( slotRefs.current.map((slotRef) => slotRef?.release() ?? Promise.resolve()), ); @@ -739,8 +910,16 @@ export const ContinuousEnginePool = memo(forwardRef< useImperativeHandle(ref, () => ({ assignPages, applyToolState, + startBenchmarkRecording, + stopBenchmarkRecording, + release, + }), [ + applyToolState, + assignPages, release, - }), [applyToolState, assignPages, release]); + startBenchmarkRecording, + stopBenchmarkRecording, + ]); return ( <> @@ -751,6 +930,7 @@ export const ContinuousEnginePool = memo(forwardRef< poolIndex={poolIndex} canvasHeight={canvasHeight} backgroundType={backgroundType} + renderBackend={renderBackend} pdfBackgroundBaseUri={pdfBackgroundBaseUri} drawingPolicy={fingerDrawingEnabled ? "anyinput" : "pencilonly"} getToolState={getToolState} @@ -770,6 +950,7 @@ export const ContinuousEnginePool = memo(forwardRef< }), (prev, next) => ( prev.canvasHeight === next.canvasHeight && prev.backgroundType === next.backgroundType && + prev.renderBackend === next.renderBackend && prev.pdfBackgroundBaseUri === next.pdfBackgroundBaseUri && prev.fingerDrawingEnabled === next.fingerDrawingEnabled && prev.getToolState === next.getToolState && diff --git a/src/InfiniteInkCanvas.tsx b/src/InfiniteInkCanvas.tsx index 278a599..a71ccaa 100644 --- a/src/InfiniteInkCanvas.tsx +++ b/src/InfiniteInkCanvas.tsx @@ -23,6 +23,16 @@ import type { ContinuousEnginePoolToolState, } from "./ContinuousEnginePool"; import NativeInkPageBackground from "./NativeInkPageBackground"; +import type { + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkRenderBackend, +} from "./benchmark"; +import { + aggregateNotebookBenchmarkResults, + DEFAULT_NATIVE_INK_RENDER_BACKEND, +} from "./benchmark"; import ZoomableInkViewport from "./ZoomableInkViewport"; import type { ZoomableInkViewportRef } from "./ZoomableInkViewport"; import type { @@ -61,6 +71,9 @@ export type InfiniteInkCanvasRef = { resetViewport: (animated?: boolean) => void; getCurrentPageIndex: () => number; scrollToPage: (pageIndex: number, animated?: boolean) => void; + runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; + startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording?: () => Promise; }; export type InfiniteInkCanvasProps = { @@ -70,6 +83,7 @@ export type InfiniteInkCanvasProps = { pageWidth?: number; pageHeight?: number; backgroundType?: string; + renderBackend?: NativeInkRenderBackend; pdfBackgroundBaseUri?: string; fingerDrawingEnabled?: boolean; toolState: ContinuousEnginePoolToolState; @@ -154,6 +168,7 @@ function InfiniteInkCanvasImpl( pageWidth = DEFAULT_PAGE_WIDTH, pageHeight = DEFAULT_PAGE_HEIGHT, backgroundType = "plain", + renderBackend = DEFAULT_NATIVE_INK_RENDER_BACKEND, pdfBackgroundBaseUri, fingerDrawingEnabled = false, toolState, @@ -185,6 +200,7 @@ function InfiniteInkCanvasImpl( const dirtyPageIdsRef = useRef(new Set()); const lastEditedPageIdRef = useRef(null); const nativeReadyRef = useRef(false); + const benchmarkRecordingOptionsRef = useRef(null); const contentHeight = pages.length * pageHeight; @@ -350,6 +366,11 @@ function InfiniteInkCanvasImpl( return activePage ? perPageSlotRefs.current.get(activePage.id) : undefined; }, []); + const getCurrentPageSlot = useCallback(() => { + const activePage = pagesRef.current[currentPageIndexRef.current]; + return activePage ? perPageSlotRefs.current.get(activePage.id) : undefined; + }, []); + const captureDirtyPages = useCallback(async () => { const nextPages = [...pagesRef.current]; let didChange = false; @@ -388,13 +409,15 @@ function InfiniteInkCanvasImpl( 0, Math.min(pagesRef.current.length - 1, pageIndex), ); + setCurrentPage(boundedPageIndex); + void assignEnginesToPage(boundedPageIndex); viewportRef.current?.setTransform({ scale: 1, translateX: 0, translateY: -(boundedPageIndex * pageHeight + contentPadding), animated, }); - }, [contentPadding, pageHeight]); + }, [assignEnginesToPage, contentPadding, pageHeight, setCurrentPage]); const addPage = useCallback(async () => { const capturedPages = await captureDirtyPages(); @@ -463,13 +486,45 @@ function InfiniteInkCanvasImpl( }, getCurrentPageIndex: () => currentPageIndexRef.current, scrollToPage, + runBenchmark: async (options) => { + const activeSlot = getCurrentPageSlot(); + if (!activeSlot?.runBenchmark) { + throw new Error("Native benchmark runner is unavailable for the active page."); + } + return activeSlot.runBenchmark(options); + }, + startBenchmarkRecording: async (options) => { + if (!enginePoolRef.current?.startBenchmarkRecording) { + throw new Error("Native benchmark recorder is unavailable for this notebook."); + } + benchmarkRecordingOptionsRef.current = options ?? {}; + const didStart = await enginePoolRef.current.startBenchmarkRecording(options); + if (!didStart) { + benchmarkRecordingOptionsRef.current = null; + } + return didStart; + }, + stopBenchmarkRecording: async () => { + if (!enginePoolRef.current?.stopBenchmarkRecording) { + throw new Error("Native benchmark recorder is unavailable for this notebook."); + } + const recordingOptions = benchmarkRecordingOptionsRef.current ?? { + scenario: "manual-notebook", + backend: renderBackend, + }; + benchmarkRecordingOptionsRef.current = null; + const results = await enginePoolRef.current.stopBenchmarkRecording(); + return aggregateNotebookBenchmarkResults(results, recordingOptions); + }, }), [ addPage, assignEnginesToPage, captureDirtyPages, getActiveSlot, + getCurrentPageSlot, pageWidth, replacePages, + renderBackend, scrollToPage, setCurrentPage, ]); @@ -553,6 +608,7 @@ function InfiniteInkCanvasImpl( ref={enginePoolRef} canvasHeight={pageHeight} backgroundType={backgroundType} + renderBackend={renderBackend} pdfBackgroundBaseUri={pdfBackgroundBaseUri} fingerDrawingEnabled={fingerDrawingEnabled} getToolState={getToolState} diff --git a/src/NativeInkCanvas.tsx b/src/NativeInkCanvas.tsx index 11af056..504bcf9 100644 --- a/src/NativeInkCanvas.tsx +++ b/src/NativeInkCanvas.tsx @@ -15,6 +15,15 @@ import { NativeModules, NativeSyntheticEvent, } from 'react-native'; +import { + DEFAULT_NATIVE_INK_RENDER_BACKEND, +} from './benchmark'; +import type { + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkRenderBackend, +} from './benchmark'; import type { NativeSelectionBounds } from './types'; import { normalizePagePayloadForNativeLoad } from "./payload"; @@ -49,8 +58,18 @@ const LINKING_ERROR = const ComponentName = 'MobileInkCanvasView'; +const mobileInkCanvasViewConfig = UIManager.getViewManagerConfig(ComponentName) as + | { NativeProps?: Record } + | null; +const supportsRenderBackendProp = + !!mobileInkCanvasViewConfig?.NativeProps && + Object.prototype.hasOwnProperty.call( + mobileInkCanvasViewConfig.NativeProps, + 'renderBackend', + ); + const MobileInkCanvasViewNative = - UIManager.getViewManagerConfig(ComponentName) != null + mobileInkCanvasViewConfig != null ? requireNativeComponent(ComponentName) : () => { throw new Error(LINKING_ERROR); @@ -65,6 +84,8 @@ export interface NativeInkCanvasProps { backgroundType?: string; pdfBackgroundUri?: string; renderSuspended?: boolean; + /** iOS only: Chooses the native render path for A/B performance tests. */ + renderBackend?: NativeInkRenderBackend; /** iOS only: Controls whether fingers or only Apple Pencil can draw */ drawingPolicy?: 'default' | 'anyinput' | 'pencilonly'; /** iOS only: Fired when Apple Pencil barrel is double-tapped (2nd gen+) */ @@ -144,6 +165,9 @@ export interface NativeInkCanvasRef { performPaste: () => void; performDelete: () => void; simulatePencilDoubleTap?: () => Promise; + runBenchmark?: (options?: NativeInkBenchmarkOptions) => Promise; + startBenchmarkRecording?: (options?: NativeInkBenchmarkRecordingOptions) => Promise; + stopBenchmarkRecording?: () => Promise; } export const NativeInkCanvas = forwardRef< @@ -596,21 +620,96 @@ export const NativeInkCanvas = forwardRef< ); }); }, + + runBenchmark: async ( + options: NativeInkBenchmarkOptions = {}, + ): Promise => { + const node = findNodeHandle(nativeRef.current); + if (!node || Platform.OS !== 'ios' || !MobileInkCanvasViewManager?.runBenchmark) { + throw new Error('Native benchmark runner is only available on iOS after rebuilding the app.'); + } + + return new Promise((resolve, reject) => { + MobileInkCanvasViewManager.runBenchmark( + node, + options, + (error: string | null, result: NativeInkBenchmarkResult | null) => { + if (error) { + reject(new Error(error)); + } else if (!result) { + reject(new Error('Native benchmark finished without metrics.')); + } else { + resolve(result); + } + } + ); + }); + }, + + startBenchmarkRecording: async ( + options: NativeInkBenchmarkRecordingOptions = {}, + ): Promise => { + const node = findNodeHandle(nativeRef.current); + if (!node || Platform.OS !== 'ios' || !MobileInkCanvasViewManager?.startBenchmarkRecording) { + throw new Error('Native benchmark recorder is only available on iOS after rebuilding the app.'); + } + + return new Promise((resolve, reject) => { + MobileInkCanvasViewManager.startBenchmarkRecording( + node, + options, + (error: string | null, success: boolean | null) => { + if (error) { + reject(new Error(error)); + } else { + resolve(success === true); + } + } + ); + }); + }, + + stopBenchmarkRecording: async (): Promise => { + const node = findNodeHandle(nativeRef.current); + if (!node || Platform.OS !== 'ios' || !MobileInkCanvasViewManager?.stopBenchmarkRecording) { + throw new Error('Native benchmark recorder is only available on iOS after rebuilding the app.'); + } + + return new Promise((resolve, reject) => { + MobileInkCanvasViewManager.stopBenchmarkRecording( + node, + (error: string | null, result: NativeInkBenchmarkResult | null) => { + if (error) { + reject(new Error(error)); + } else if (!result) { + reject(new Error('Native benchmark recording stopped without metrics.')); + } else { + resolve(result); + } + } + ); + }); + }, }), []); + const nativeProps = { + ref: handleNativeRef, + style: props.style, + onDrawingChange: props.onDrawingChange, + onDrawingBegin: props.onDrawingBegin, + onSelectionChange: props.onSelectionChange, + backgroundType: props.backgroundType, + pdfBackgroundUri: props.pdfBackgroundUri, + renderSuspended: props.renderSuspended, + drawingPolicy: props.drawingPolicy, + onPencilDoubleTap: props.onPencilDoubleTap, + ...(supportsRenderBackendProp + ? { renderBackend: props.renderBackend ?? DEFAULT_NATIVE_INK_RENDER_BACKEND } + : {}), + }; + return ( - + ); }); diff --git a/src/__tests__/ContinuousEnginePool.test.tsx b/src/__tests__/ContinuousEnginePool.test.tsx index c913407..c350808 100644 --- a/src/__tests__/ContinuousEnginePool.test.tsx +++ b/src/__tests__/ContinuousEnginePool.test.tsx @@ -4,6 +4,7 @@ import { ContinuousEnginePool, type ContinuousEnginePoolAssignment, type ContinuousEnginePoolRef, + type ContinuousEnginePoolSlotRef, type ContinuousEnginePoolToolState, } from "../ContinuousEnginePool"; import type { NotebookPage } from "../types"; @@ -16,6 +17,34 @@ const mockSetNativeProps = jest.fn(); const mockUndo = jest.fn(); const mockRedo = jest.fn(); const mockClear = jest.fn(); +const mockRunBenchmark = jest.fn(async (options?: Record) => ({ + sessionId: "benchmark-session", + scenario: options?.scenario ?? "custom", + requestedBackend: options?.backend ?? "ganesh", + durationMs: 1, + fpsAverage: 60, + renderFrameCount: 1, + cpuFrameCount: 0, + ganeshFrameCount: 1, + ganeshFallbackFrameCount: 0, + droppedFrameCount: 0, + inputEventCount: 1, + syntheticStrokeCount: 1, + syntheticPointCount: 1, + renderMs: { count: 1, average: 1, p50: 1, p95: 1, p99: 1, max: 1 }, + frameIntervalMs: { count: 1, average: 16, p50: 16, p95: 16, p99: 16, max: 16 }, + inputToPresentLatencyMs: { count: 1, average: 4, p50: 4, p95: 4, p99: 4, max: 4 }, + memory: { + startBytes: 1, + endBytes: 1, + peakBytes: 1, + lowBytes: 1, + deltaBytes: 0, + peakDeltaBytes: 0, + }, +})); +const mockStartBenchmarkRecording = jest.fn(async () => true); +const mockStopBenchmarkRecording = jest.fn(async () => mockRunBenchmark()); const mockNativeCanvasProps: any[] = []; jest.mock("../NativeInkCanvas", () => { @@ -35,6 +64,9 @@ jest.mock("../NativeInkCanvas", () => { loadBase64Data: mockLoadBase64Data, getBase64Data: mockGetBase64Data, releaseEngine: mockReleaseEngine, + runBenchmark: mockRunBenchmark, + startBenchmarkRecording: mockStartBenchmarkRecording, + stopBenchmarkRecording: mockStopBenchmarkRecording, })); React.useEffect(() => { @@ -148,6 +180,17 @@ describe("ContinuousEnginePool", () => { expect(mockReleaseEngine).toHaveBeenCalledTimes(3); }); + it("defaults pooled native canvases to the Ganesh backend", async () => { + renderPool(); + + await act(async () => {}); + + expect(mockNativeCanvasProps).toHaveLength(3); + for (const props of mockNativeCanvasProps) { + expect(props.renderBackend).toBe("ganesh"); + } + }); + it("applies the latest current tool when a slot is assigned", async () => { const pages = [page(0), page(1), page(2), page(3)]; let currentToolState: ContinuousEnginePoolToolState = { @@ -221,4 +264,68 @@ describe("ContinuousEnginePool", () => { expect(props.onPencilDoubleTap).toBe(onPencilDoubleTap); } }); + + it("forwards benchmark methods through the registered page slot", async () => { + const pages = [page(0), page(1), page(2)]; + const registerPerPageSlot = jest.fn(); + const { poolRef } = renderPool({ registerPerPageSlot }); + + await act(async () => {}); + await assignPages(poolRef, buildAssignments(pages, 0)); + + const slotRef = registerPerPageSlot.mock.calls.find( + ([pageId, registeredRef]) => pageId === "page-0" && registeredRef, + )?.[1] as ContinuousEnginePoolSlotRef | undefined; + + expect(slotRef).toBeDefined(); + await expect(slotRef?.runBenchmark?.({ + scenario: "eraser", + backend: "ganesh", + workload: "erase", + toolType: "eraser", + })).resolves.toMatchObject({ + scenario: "eraser", + requestedBackend: "ganesh", + }); + await expect(slotRef?.startBenchmarkRecording?.({ + scenario: "manual", + backend: "cpu", + })).resolves.toBe(true); + await expect(slotRef?.stopBenchmarkRecording?.()).resolves.toMatchObject({ + sessionId: "benchmark-session", + }); + + expect(mockRunBenchmark).toHaveBeenCalledWith({ + scenario: "eraser", + backend: "ganesh", + workload: "erase", + toolType: "eraser", + }); + expect(mockStartBenchmarkRecording).toHaveBeenCalledWith({ + scenario: "manual", + backend: "cpu", + }); + expect(mockStopBenchmarkRecording).toHaveBeenCalledTimes(1); + }); + + it("starts and stops notebook benchmark recording across the mounted pool", async () => { + const pages = [page(0), page(1), page(2)]; + const { poolRef } = renderPool(); + + await act(async () => {}); + await assignPages(poolRef, buildAssignments(pages, 0)); + + await expect(poolRef.current?.startBenchmarkRecording({ + scenario: "manual", + backend: "ganesh", + })).resolves.toBe(true); + await expect(poolRef.current?.stopBenchmarkRecording()).resolves.toHaveLength(3); + + expect(mockStartBenchmarkRecording).toHaveBeenCalledTimes(3); + expect(mockStartBenchmarkRecording).toHaveBeenCalledWith({ + scenario: "manual", + backend: "ganesh", + }); + expect(mockStopBenchmarkRecording).toHaveBeenCalledTimes(3); + }); }); diff --git a/src/benchmark/aggregate.ts b/src/benchmark/aggregate.ts new file mode 100644 index 0000000..9cacee2 --- /dev/null +++ b/src/benchmark/aggregate.ts @@ -0,0 +1,154 @@ +import type { + NativeInkBenchmarkDistribution, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, +} from "./types"; + +const emptyDistribution = (): NativeInkBenchmarkDistribution => ({ + count: 0, + average: 0, + p50: 0, + p95: 0, + p99: 0, + max: 0, +}); + +const round = (value: number) => Math.round(value * 100) / 100; + +const weightedAverage = ( + values: NativeInkBenchmarkDistribution[], + field: keyof Pick, +) => { + const totalCount = values.reduce((sum, value) => sum + value.count, 0); + if (totalCount === 0) { + return 0; + } + return values.reduce( + (sum, value) => sum + value[field] * value.count, + 0, + ) / totalCount; +}; + +const aggregateDistribution = ( + values: NativeInkBenchmarkDistribution[], +): NativeInkBenchmarkDistribution => { + const totalCount = values.reduce((sum, value) => sum + value.count, 0); + if (totalCount === 0) { + return emptyDistribution(); + } + + return { + count: totalCount, + average: round(weightedAverage(values, "average")), + p50: round(weightedAverage(values, "p50")), + p95: round(weightedAverage(values, "p95")), + p99: round(weightedAverage(values, "p99")), + max: round(Math.max(...values.map((value) => value.max))), + }; +}; + +const getDistribution = ( + distribution: NativeInkBenchmarkDistribution | undefined, +) => distribution ?? emptyDistribution(); + +const aggregateFpsAverage = (results: NativeInkBenchmarkResult[]) => { + const totalDurationMs = results.reduce( + (sum, result) => sum + result.durationMs, + 0, + ); + if (totalDurationMs === 0) { + return 0; + } + + return round( + results.reduce( + (sum, result) => sum + result.fpsAverage * result.durationMs, + 0, + ) / totalDurationMs, + ); +}; + +export const aggregateNotebookBenchmarkResults = ( + results: NativeInkBenchmarkResult[], + options: NativeInkBenchmarkRecordingOptions = {}, +): NativeInkBenchmarkResult => { + if (results.length === 0) { + throw new Error("Benchmark recording is not running."); + } + + if (results.length === 1) { + return { + ...results[0], + scope: "notebook", + recorderCount: 1, + recorderResults: results, + frameThroughputFps: results[0].fpsAverage, + }; + } + + const durationMs = Math.max(...results.map((result) => result.durationMs)); + const renderFrameCount = results.reduce( + (sum, result) => sum + result.renderFrameCount, + 0, + ); + const frameThroughputFps = round( + renderFrameCount / Math.max(0.001, durationMs / 1000), + ); + const startBytes = Math.min(...results.map((result) => result.memory.startBytes)); + const endBytes = Math.max(...results.map((result) => result.memory.endBytes)); + const peakBytes = Math.max(...results.map((result) => result.memory.peakBytes)); + const lowBytes = Math.min(...results.map((result) => result.memory.lowBytes)); + + return { + sessionId: `notebook-${Date.now().toString(36)}`, + scenario: options.scenario ?? results[0].scenario, + requestedBackend: options.backend ?? results[0].requestedBackend, + scope: "notebook", + recorderCount: results.length, + recorderResults: results, + durationMs: round(durationMs), + fpsAverage: aggregateFpsAverage(results), + frameThroughputFps, + renderFrameCount, + cpuFrameCount: results.reduce((sum, result) => sum + result.cpuFrameCount, 0), + ganeshFrameCount: results.reduce((sum, result) => sum + result.ganeshFrameCount, 0), + ganeshFallbackFrameCount: results.reduce( + (sum, result) => sum + result.ganeshFallbackFrameCount, + 0, + ), + droppedFrameCount: results.reduce((sum, result) => sum + result.droppedFrameCount, 0), + inputEventCount: results.reduce((sum, result) => sum + result.inputEventCount, 0), + syntheticStrokeCount: results.reduce( + (sum, result) => sum + result.syntheticStrokeCount, + 0, + ), + syntheticPointCount: results.reduce( + (sum, result) => sum + result.syntheticPointCount, + 0, + ), + renderMs: aggregateDistribution(results.map((result) => result.renderMs)), + frameIntervalMs: aggregateDistribution(results.map((result) => result.frameIntervalMs)), + presentationPauseMs: aggregateDistribution( + results.map((result) => getDistribution(result.presentationPauseMs)), + ), + presentationPauseCount: results.reduce( + (sum, result) => sum + (result.presentationPauseCount ?? 0), + 0, + ), + presentationPauseTotalMs: round(results.reduce( + (sum, result) => sum + (result.presentationPauseTotalMs ?? 0), + 0, + )), + inputToPresentLatencyMs: aggregateDistribution( + results.map((result) => result.inputToPresentLatencyMs), + ), + memory: { + startBytes, + endBytes, + peakBytes, + lowBytes, + deltaBytes: endBytes - startBytes, + peakDeltaBytes: peakBytes - startBytes, + }, + }; +}; diff --git a/src/benchmark/index.ts b/src/benchmark/index.ts new file mode 100644 index 0000000..ae79203 --- /dev/null +++ b/src/benchmark/index.ts @@ -0,0 +1,13 @@ +export { aggregateNotebookBenchmarkResults } from "./aggregate"; +export { + DEFAULT_NATIVE_INK_RENDER_BACKEND, +} from "./types"; +export type { + NativeInkBenchmarkDistribution, + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkBenchmarkViewportMetrics, + NativeInkBenchmarkWorkload, + NativeInkRenderBackend, +} from "./types"; diff --git a/src/benchmark/types.ts b/src/benchmark/types.ts new file mode 100644 index 0000000..8848f56 --- /dev/null +++ b/src/benchmark/types.ts @@ -0,0 +1,84 @@ +import type { ToolType } from "../types"; + +export type NativeInkRenderBackend = "cpu" | "ganesh"; + +export const DEFAULT_NATIVE_INK_RENDER_BACKEND: NativeInkRenderBackend = "ganesh"; + +export type NativeInkBenchmarkWorkload = "draw" | "erase" | "selectionMove"; + +export type NativeInkBenchmarkOptions = { + scenario?: string; + backend?: NativeInkRenderBackend; + workload?: NativeInkBenchmarkWorkload; + toolType?: ToolType; + color?: string; + eraserMode?: string; + clearCanvas?: boolean; + strokeCount?: number; + pointsPerStroke?: number; + pointIntervalMs?: number; + strokeGapMs?: number; + settleMs?: number; + strokeWidth?: number; + seedStrokeCount?: number; + moveStepCount?: number; + moveDeltaX?: number; + moveDeltaY?: number; +}; + +export type NativeInkBenchmarkRecordingOptions = { + scenario?: string; + backend?: NativeInkRenderBackend; + clearCanvas?: boolean; +}; + +export type NativeInkBenchmarkDistribution = { + count: number; + average: number; + p50: number; + p95: number; + p99: number; + max: number; +}; + +export type NativeInkBenchmarkViewportMetrics = { + durationMs: number; + fpsAverage: number; + droppedFrameCount: number; + frameIntervalMs: NativeInkBenchmarkDistribution; +}; + +export type NativeInkBenchmarkResult = { + sessionId: string; + scenario: string; + requestedBackend: NativeInkRenderBackend; + scope?: "page" | "notebook"; + recorderCount?: number; + recorderResults?: NativeInkBenchmarkResult[]; + frameThroughputFps?: number; + viewport?: NativeInkBenchmarkViewportMetrics; + durationMs: number; + fpsAverage: number; + renderFrameCount: number; + cpuFrameCount: number; + ganeshFrameCount: number; + ganeshFallbackFrameCount: number; + droppedFrameCount: number; + inputEventCount: number; + syntheticStrokeCount: number; + syntheticPointCount: number; + renderMs: NativeInkBenchmarkDistribution; + frameIntervalMs: NativeInkBenchmarkDistribution; + presentationPauseMs?: NativeInkBenchmarkDistribution; + presentationPauseCount?: number; + presentationPauseTotalMs?: number; + inputToPresentLatencyMs: NativeInkBenchmarkDistribution; + memory: { + startBytes: number; + endBytes: number; + peakBytes: number; + lowBytes: number; + deltaBytes: number; + peakDeltaBytes: number; + }; +}; diff --git a/src/index.ts b/src/index.ts index 344481d..01719fe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,19 @@ export type { NativeInkCanvasProps, NativeInkCanvasRef, } from "./NativeInkCanvas"; +export { + aggregateNotebookBenchmarkResults, + DEFAULT_NATIVE_INK_RENDER_BACKEND, +} from "./benchmark"; +export type { + NativeInkBenchmarkDistribution, + NativeInkBenchmarkOptions, + NativeInkBenchmarkRecordingOptions, + NativeInkBenchmarkResult, + NativeInkBenchmarkViewportMetrics, + NativeInkBenchmarkWorkload, + NativeInkRenderBackend, +} from "./benchmark"; export { ContinuousEnginePool,