diff --git a/packages/app-compose/knip.json b/packages/app-compose/knip.json index e7785aa..ad5c0e1 100644 --- a/packages/app-compose/knip.json +++ b/packages/app-compose/knip.json @@ -3,5 +3,5 @@ "ignore": ["**/__tests__/**", "*.config.ts"], "entry": ["./src/index.ts!"], "ignoreBinaries": ["tsdown", "knip", "oxlint", "vitest"], - "vitest": { "config": "vitest.config.ts", "entry": "src/**/*.{test,test-d}.ts" } + "vitest": { "config": "vitest.config.ts", "entry": "src/**/*.{test,test-d,bench}.ts" } } diff --git a/packages/app-compose/package.json b/packages/app-compose/package.json index d8e1781..279dca5 100644 --- a/packages/app-compose/package.json +++ b/packages/app-compose/package.json @@ -57,6 +57,7 @@ "build": "tsdown", "dev": "tsdown --watch", "prepack": "pnpm build", + "bench": "vitest bench", "test": "vitest run", "lint": "knip && oxlint ./src" }, diff --git a/packages/app-compose/src/compose/__tests__/compose.bench.ts b/packages/app-compose/src/compose/__tests__/compose.bench.ts new file mode 100644 index 0000000..16491a5 --- /dev/null +++ b/packages/app-compose/src/compose/__tests__/compose.bench.ts @@ -0,0 +1,94 @@ +import { literal, shape, type Spot } from "@computable" +import { bind, createTag, createTask } from "@runnable" +import { bench, describe, vi } from "vitest" +import { compose } from "../compose" + +const double = (x: number) => x * 2 + +describe("multi layer, compute shapes", () => { + const rootTag = createTag({ name: "root" }) + + const app = compose() + .meta({ name: "bench" }) + .step(bind(rootTag, literal(1))) + + let previous: Spot = rootTag.value + + for (let layer = 0; layer < 20; layer++) { + const l = shape({ layer: literal(layer), previous }, ({ layer, previous }) => layer + previous) + + const a = createTag({ name: `${layer}:a` }) + const b = createTag({ name: `${layer}:b` }) + const c = createTag({ name: `${layer}:c` }) + const d = createTag({ name: `${layer}:d` }) + + const task = createTask({ + name: `${layer}:task`, + run: { + context: [a.value, b.value, c.value, d.value], + fn: (list) => list.reduce((acc, x) => acc + x, 0), + }, + }) + + app + .step([bind(a, shape(l, double)), bind(b, shape(l, double))]) + .step([bind(c, shape(l, double)), bind(d, shape(l, double))]) + .step(task) + + previous = task.result + } + + describe("compose.guard", () => { + bench("app", () => void app.guard(), { time: 1000 }) + }) + + describe("compose.graph", () => { + bench("app", () => void app.graph(), { time: 1000 }) + }) +}) + +describe("single layer, wide sequence", () => { + const layer = compose() + + for (let i = 0; i < 100; i++) { + const task = createTask({ name: `task:${i}`, run: { fn: vi.fn() } }) + + layer.step(task) + } + + const app = compose().meta({ name: "bench" }).step(layer) + + describe("compose.guard", () => { + bench("app", () => void app.guard(), { time: 1000 }) + }) + + describe("compose.graph", () => { + bench("app", () => void app.graph(), { time: 1000 }) + }) +}) + +describe("multi layer, deep nesting", () => { + let current = compose().meta({ name: "root" }) + let previous: Spot = literal(1) + + for (let layer = 0; layer < 100; layer++) { + const task = createTask({ name: `task:${layer}`, run: { fn: (v) => v + 1, context: previous } }) + + current = compose() + .meta({ name: `layer:${layer}` }) + .step(current) + .step(task) + + previous = task.result + } + + const app = current + + describe("compose.guard", () => { + bench("app", () => void app.guard(), { time: 1000 }) + }) + + describe("compose.graph", () => { + bench("app", () => void app.graph(), { time: 1000 }) + }) +}) diff --git a/packages/app-compose/src/compose/analyze.ts b/packages/app-compose/src/compose/analyze.ts new file mode 100644 index 0000000..cc5e1c0 --- /dev/null +++ b/packages/app-compose/src/compose/analyze.ts @@ -0,0 +1,31 @@ +import { Context$, Dispatch$, type Binding, type Runnable, type RunnableInternal, type Task } from "@runnable" +import type { ComposableKind } from "./definition" +import { resolve, type Dependency } from "./resolver" + +type RunnableMeta = { type: ComposableKind; display: { name: string }; writes: symbol[]; dependencies: Dependency } + +type ComposeAnalyzer = { + get: (runnable: RunnableInternal) => RunnableMeta +} + +const analyze = (runnable: RunnableInternal): RunnableMeta => { + const internal = runnable as RunnableInternal & (Task | Binding) + + const writes = Object.getOwnPropertySymbols(internal[Dispatch$]) + const dependencies = resolve(internal[Context$]) + + return { type: internal.kind, display: { name: internal.name }, writes, dependencies } +} + +const createAnalyzer = (): ComposeAnalyzer => { + const cache = new WeakMap() + + const get = (runnable: Runnable): RunnableMeta => { + const analysis = cache.get(runnable) ?? analyze(runnable as RunnableInternal) + return (cache.set(runnable, analysis), analysis) + } + + return { get } +} + +export { createAnalyzer } diff --git a/packages/app-compose/src/compose/convert.ts b/packages/app-compose/src/compose/convert.ts deleted file mode 100644 index 67c9686..0000000 --- a/packages/app-compose/src/compose/convert.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { Dispatch$, type Binding, type Runnable, type RunnableInternal, type Task } from "@runnable" - -const toID = (step: Runnable) => { - const internal = step as RunnableInternal & (Task | Binding) - - const writes = Object.getOwnPropertySymbols(internal[Dispatch$]) - - return { type: internal.kind, display: { name: internal.name }, writes } -} - -export { toID } diff --git a/packages/app-compose/src/compose/graph.ts b/packages/app-compose/src/compose/graph.ts index 9ad52e8..a905855 100644 --- a/packages/app-compose/src/compose/graph.ts +++ b/packages/app-compose/src/compose/graph.ts @@ -1,7 +1,6 @@ -import { Context$, type RunnableInternal } from "@runnable" -import { toID } from "./convert" +import type { RunnableInternal } from "@runnable" +import { createAnalyzer } from "./analyze" import type { ComposableKind, ComposeNode } from "./definition" -import { resolve } from "./resolver" type EntryID = number @@ -18,9 +17,10 @@ const graph = (root: ComposeNode): GraphNode => { let entryID = 0 const symbolToID = new Map() + const analyzer = createAnalyzer() + const toEntry = (runnable: RunnableInternal): GraphNode => { - const deps = resolve(runnable[Context$]) - const { type, display, writes } = toID(runnable) + const { type, display, writes, dependencies } = analyzer.get(runnable) writes.forEach((x) => symbolToID.set(x, entryID)) @@ -29,8 +29,8 @@ const graph = (root: ComposeNode): GraphNode => { meta: { name: display.name, kind: type }, id: entryID++, dependencies: { - required: Array.from(deps.required, (x) => symbolToID.get(x) ?? -1), - optional: Array.from(deps.optional, (x) => symbolToID.get(x)).filter((x) => x !== undefined), + required: Array.from(dependencies.required, (x) => symbolToID.get(x) ?? -1), + optional: Array.from(dependencies.optional, (x) => symbolToID.get(x)).filter((x) => x !== undefined), }, } } diff --git a/packages/app-compose/src/compose/guard.ts b/packages/app-compose/src/compose/guard.ts index fa851a4..a5a45b1 100644 --- a/packages/app-compose/src/compose/guard.ts +++ b/packages/app-compose/src/compose/guard.ts @@ -1,8 +1,6 @@ -import { Context$, type RunnableInternal } from "@runnable" -import { difference, union } from "@shared" -import { toID } from "./convert" +import type { RunnableInternal } from "@runnable" +import { createAnalyzer } from "./analyze" import type { ComposableKind, ComposeMeta, ComposeNode } from "./definition" -import { resolve } from "./resolver" type NotifyEntry = { index?: number; node: ComposeNode } @@ -49,6 +47,7 @@ type GuardConfig = { handler: GuardHandler } const createGuard = ({ handler }: GuardConfig) => { const notify = createNotify(handler) + const analyzer = createAnalyzer() const duplicate = (root: ComposeNode) => { const seen = new Set() @@ -60,7 +59,7 @@ const createGuard = ({ handler }: GuardConfig) => { return current.children.forEach((node, index) => traverse([...stack, { node, index }])) if (current.type === "run") { - const { type, display, writes } = toID(current.value) + const { type, display, writes } = analyzer.get(current.value as RunnableInternal) if (writes.some((write) => seen.has(write))) notify.duplicate({ type, name: display.name, stack }) @@ -81,15 +80,12 @@ const createGuard = ({ handler }: GuardConfig) => { return current.children.forEach((node, index) => traverse([...stack, { node, index }])) if (current.type === "run") { - const internal = current.value as RunnableInternal - - const { type, display, writes } = toID(current.value), - deps = resolve(internal[Context$]) + const { type, display, writes, dependencies } = analyzer.get(current.value as RunnableInternal) if (type === "binding") writes.forEach((id) => candidates.set(id, { type, name: display.name, stack })) - deps.required.forEach((id) => candidates.delete(id)) - deps.optional.forEach((id) => candidates.delete(id)) + dependencies.required.forEach((id) => candidates.delete(id)) + dependencies.optional.forEach((id) => candidates.delete(id)) } } @@ -102,37 +98,38 @@ const createGuard = ({ handler }: GuardConfig) => { const traverse = (stack: NotifyEntry[], available: Set): Set => { const { node: current } = stack.at(-1)! - if (current.type === "run") { - const internal = current.value as RunnableInternal - const { type, display, writes } = toID(current.value) - - const deps = resolve(internal[Context$]) + switch (current.type) { + case "run": + const { type, display, writes, dependencies } = analyzer.get(current.value as RunnableInternal) - const missing = difference(deps.required, available) - if (missing.size > 0) notify.notSatisfied({ type, name: display.name, stack, missing }) + const missing = new Set() + for (const id of dependencies.required) if (!available.has(id)) missing.add(id) - return new Set(writes) - } + if (missing.size > 0) notify.notSatisfied({ type, name: display.name, stack, missing }) - // all concurrent steps are isolated - if (current.type === "con") - return current.children.map((node, index) => traverse([...stack, { node, index }], available)).reduce(union) + return new Set(writes) - // sequential steps provide their writes to the next steps - if (current.type === "seq") { - let wrote = new Set() + // all concurrent steps are isolated + case "con": { + const wrote = new Set() - for (const [index, node] of current.children.entries()) { - const local = union(wrote, available) - const added = traverse([...stack, { node, index }], local) + for (const [index, node] of current.children.entries()) + traverse([...stack, { node, index }], available).forEach((item) => wrote.add(item)) - wrote = union(wrote, added) + return wrote } - return wrote - } + // sequential steps share the context of previous steps + case "seq": { + const wrote = new Set(), + local = new Set(available) + + for (const [index, node] of current.children.entries()) + traverse([...stack, { node, index }], local).forEach((item) => (wrote.add(item), local.add(item))) - throw new Error(/* unreachable */) + return wrote + } + } } traverse([{ node: root }], new Set()) diff --git a/packages/app-compose/src/compose/resolver.ts b/packages/app-compose/src/compose/resolver.ts index 2a4e10e..576ad1f 100644 --- a/packages/app-compose/src/compose/resolver.ts +++ b/packages/app-compose/src/compose/resolver.ts @@ -31,4 +31,4 @@ function resolve(spot: SpotInternal): Dependency { return { required, optional } } -export { resolve } +export { resolve, type Dependency } diff --git a/packages/app-compose/src/shared.ts b/packages/app-compose/src/shared.ts index ffb43f2..5e413f0 100644 --- a/packages/app-compose/src/shared.ts +++ b/packages/app-compose/src/shared.ts @@ -37,17 +37,5 @@ const isObject = (x: unknown): x is object => Object.prototype.toString.call(x) const identity = (x: T): T => x const T = (): true => true -const difference = (left: Set, right: Set): Set => { - const out = new Set(left) - for (const item of right) out.delete(item) - return out -} - -const union = (left: Set, right: Set): Set => { - const out = new Set(left) - for (const item of right) out.add(item) - return out -} - -export { LIBRARY_NAME, T, difference, identity, isObject, union } +export { LIBRARY_NAME, T, identity, isObject } export type { AnyShape, BuiltInObject, Eventual }