@effect-bench/core 0.0.2 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Reporter.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { Console, Effect, Layer } from 'effect'
2
2
  import { type BenchResult, schemaVersion } from './BenchResult.ts'
3
3
  import { BenchReporter } from './Services.ts'
4
- import { formatNs } from './Stats.ts'
4
+ import { formatBytes, formatNs } from './Stats.ts'
5
5
 
6
6
  const json = Layer.succeed(BenchReporter, {
7
7
  onResult: result => Console.log(JSON.stringify({ type: 'result', schemaVersion, ...result })),
@@ -28,6 +28,14 @@ const row = (result: BenchResult): ReadonlyArray<string> => {
28
28
  ]
29
29
  }
30
30
 
31
+ /** The key suffix gives the unit: `firstItemNs`, `peakRssBytes`, `phase:compileNs`. */
32
+ const metric = ([key, value]: [string, number]) =>
33
+ key.endsWith('Ns')
34
+ ? `${key.slice(0, -2)} ${formatNs(value)}`
35
+ : key.endsWith('Bytes')
36
+ ? `${key.slice(0, -5)} ${formatBytes(value)}`
37
+ : `${key} ${value.toLocaleString('en-US', { maximumFractionDigits: 2 })}`
38
+
31
39
  const render = (rows: ReadonlyArray<ReadonlyArray<string>>) => {
32
40
  const widths = rows[0]!.map((_, column) => Math.max(...rows.map(cells => cells[column]!.length)))
33
41
  return rows
@@ -46,6 +54,12 @@ const table = Layer.succeed(BenchReporter, {
46
54
  Effect.gen(function* () {
47
55
  if (results.length === 0) return yield* Console.log('No benches matched.')
48
56
  yield* Console.log(render([['bench', 'median', 'p99', 'rme', 'ops/s', 'change', 'budget'], ...results.map(row)]))
57
+ const withMetrics = results.flatMap(result =>
58
+ result.status === 'ok' && result.metrics !== null && Object.keys(result.metrics).length > 0
59
+ ? [[result.id, Object.entries(result.metrics).map(metric).join(' ')]]
60
+ : []
61
+ )
62
+ if (withMetrics.length > 0) yield* Console.log(`\n${render([['bench', 'metrics'], ...withMetrics])}`)
49
63
  for (const result of results)
50
64
  if (result.status === 'failed') yield* Console.log(`\n${result.id}:\n${result.message}`)
51
65
  yield* Console.log(
package/src/Runner.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Cause, type Context, Data, Duration, Effect, Exit, Layer, Option, type Scope } from 'effect'
2
- import { type Bench, benchId, type Options, type Suite } from './Bench.ts'
1
+ import { Cause, type Context, Data, Duration, Effect, Exit, Layer, Option, type Scope, Stream } from 'effect'
2
+ import { type Bench, benchId, type Options, type Size, type Suite } from './Bench.ts'
3
3
  import type {
4
4
  Baseline,
5
5
  BaselineEntry,
@@ -15,9 +15,9 @@ import type {
15
15
  } from './BenchResult.ts'
16
16
  import { schemaVersion } from './BenchResult.ts'
17
17
  import { compare } from './Compare.ts'
18
- import { measure } from './Measure.ts'
18
+ import { CheckFailed, measure } from './Measure.ts'
19
19
  import { BaselineError, BaselineStore, BenchEnvironment, BenchReporter } from './Services.ts'
20
- import { compute, formatNs, median } from './Stats.ts'
20
+ import { compute, formatBytes, formatNs, median } from './Stats.ts'
21
21
 
22
22
  export interface MeasureOptions {
23
23
  /** Only run benches whose id contains this text, or that carry this tag. */
@@ -57,6 +57,14 @@ class BenchFailure extends Data.TaggedError('BenchFailure')<{
57
57
 
58
58
  const toNs = (input: Duration.Input) => Number(Duration.toNanosUnsafe(input))
59
59
 
60
+ const bytesPer = { B: 1, KB: 1e3, KiB: 1024, MB: 1e6, MiB: 1024 ** 2, GB: 1e9, GiB: 1024 ** 3 } as const
61
+
62
+ const toBytes = (size: Size): number => {
63
+ if (typeof size === 'number') return size
64
+ const [amount, unit] = size.split(' ') as [string, keyof typeof bytesPer]
65
+ return Number(amount) * bytesPer[unit]
66
+ }
67
+
60
68
  const failWith = (phase: FailedResult['phase']) => (cause: Cause.Cause<unknown>) =>
61
69
  new BenchFailure({ phase, message: Cause.pretty(cause) })
62
70
 
@@ -77,14 +85,19 @@ export const list = (suites: ReadonlyArray<Suite>, filter?: string): ReadonlyArr
77
85
  suite.benches.map(bench => plannedOf(suite, bench, undefined)).filter(planned => matches(planned, filter))
78
86
  )
79
87
 
80
- const checkBudget = (budgetNs: RawOk['budgetNs'], stats: Stats): BudgetCheck | null => {
81
- if (budgetNs === null) return null
82
- const violations = (['p50', 'p99'] as const).flatMap(key => {
83
- const limit = budgetNs[key]
84
- if (limit === null) return []
85
- const actual = key === 'p50' ? stats.medianNs : stats.p99Ns
86
- return actual > limit ? [`${key} ${formatNs(actual)} > ${formatNs(limit)}`] : []
87
- })
88
+ const checkBudget = (limits: RawOk['limits'], stats: Stats, heapDeltaBytes: number | null): BudgetCheck | null => {
89
+ if (limits === null) return null
90
+ const violations = [
91
+ ...(limits.p50Ns !== null && stats.medianNs > limits.p50Ns
92
+ ? [`p50 ${formatNs(stats.medianNs)} > ${formatNs(limits.p50Ns)}`]
93
+ : []),
94
+ ...(limits.p99Ns !== null && stats.p99Ns > limits.p99Ns
95
+ ? [`p99 ${formatNs(stats.p99Ns)} > ${formatNs(limits.p99Ns)}`]
96
+ : []),
97
+ ...(limits.heapDeltaBytes !== null && heapDeltaBytes !== null && heapDeltaBytes > limits.heapDeltaBytes
98
+ ? [`heapDelta ${formatBytes(heapDeltaBytes)} > ${formatBytes(limits.heapDeltaBytes)}`]
99
+ : []),
100
+ ]
88
101
  return { status: violations.length === 0 ? 'ok' : 'exceeded', violations }
89
102
  }
90
103
 
@@ -103,11 +116,15 @@ const runBench = Effect.fn('effectBench/runBench')(function* (
103
116
  ) {
104
117
  const operation = yield* bench.prepare(ctx).pipe(Effect.catchCause(cause => Effect.fail(failWith('prepare')(cause))))
105
118
 
106
- if (bench.check !== undefined) {
119
+ if (bench.check !== undefined && operation._tag !== 'Scenario') {
107
120
  const value =
108
121
  operation._tag === 'Sync'
109
122
  ? yield* Effect.try({ try: operation.run, catch: error => error })
110
- : yield* operation.run
123
+ : operation._tag === 'Effect'
124
+ ? yield* operation.single ?? operation.run
125
+ : operation._tag === 'PerOp'
126
+ ? yield* Effect.flatMap(operation.before, operation.run)
127
+ : yield* Stream.runCount(operation.stream())
111
128
  if (!bench.check(value)) {
112
129
  return yield* new BenchFailure({ phase: 'check', message: `check rejected the result: ${String(value)}` })
113
130
  }
@@ -117,7 +134,7 @@ const runBench = Effect.fn('effectBench/runBench')(function* (
117
134
  samples: options.samples ?? defaults.samples,
118
135
  minTimeNs: toNs(options.minTime ?? defaults.minTime),
119
136
  warmupNs: toNs(options.warmup ?? defaults.warmup),
120
- memory: options.memory ?? defaults.memory,
137
+ memory: options.memory === true || options.budget?.heapDelta !== undefined,
121
138
  })
122
139
 
123
140
  const { budget } = options
@@ -132,18 +149,21 @@ const runBench = Effect.fn('effectBench/runBench')(function* (
132
149
  batchSize: measurement.batchSize,
133
150
  overheadNs: measurement.overheadNs,
134
151
  heapDeltaBytes: measurement.heapDeltaBytes,
135
- budgetNs:
152
+ metrics: measurement.metrics,
153
+ limits:
136
154
  budget === undefined
137
155
  ? null
138
156
  : {
139
- p50: budget.p50 === undefined ? null : toNs(budget.p50),
140
- p99: budget.p99 === undefined ? null : toNs(budget.p99),
157
+ p50Ns: budget.p50 === undefined ? null : toNs(budget.p50),
158
+ p99Ns: budget.p99 === undefined ? null : toNs(budget.p99),
159
+ heapDeltaBytes: budget.heapDelta === undefined ? null : toBytes(budget.heapDelta),
141
160
  },
142
161
  } satisfies RawOk
143
162
  })
144
163
 
145
164
  const failureOf = (cause: Cause.Cause<unknown>): BenchFailure => {
146
165
  const error = Cause.squash(cause)
166
+ if (error instanceof CheckFailed) return new BenchFailure({ phase: 'check', message: error.message })
147
167
  return error instanceof BenchFailure ? error : failWith('run')(cause)
148
168
  }
149
169
 
@@ -177,7 +197,7 @@ const runSuite = Effect.fn('effectBench/runSuite')(function* (
177
197
 
178
198
  const results: Array<RawResult> = []
179
199
  for (const { bench, planned } of benches) {
180
- const options = { ...defaults, ...mode, ...suite.options, ...bench.options }
200
+ const options = { ...defaults, ...bench.defaults, ...mode, ...suite.options, ...bench.options }
181
201
  const exit = yield* Effect.exit(
182
202
  provideSuite(runBench(bench, planned, setup.value.ctx, options), setup.value.context)
183
203
  )
@@ -222,7 +242,9 @@ const merge = (
222
242
  const stats = compute(forks.flat())
223
243
  const heap = oks.flatMap(run => (run.heapDeltaBytes === null ? [] : [run.heapDeltaBytes]))
224
244
  const recorded = baseline?.entries.find(entry => entry.id === first.id)
225
- const { relativeTo, samplesNs, budgetNs, ...identity } = first
245
+ const { relativeTo, samplesNs, limits, metrics: _, ...identity } = first
246
+ const metricKeys = [...new Set(oks.flatMap(run => Object.keys(run.metrics ?? {})))]
247
+ const heapDeltaBytes = heap.length === 0 ? null : median(heap)
226
248
 
227
249
  return {
228
250
  result: {
@@ -231,8 +253,12 @@ const merge = (
231
253
  forks: forks.length,
232
254
  batchSize: median(oks.map(run => run.batchSize)),
233
255
  overheadNs: median(oks.map(run => run.overheadNs)),
234
- heapDeltaBytes: heap.length === 0 ? null : median(heap),
235
- budget: checkBudget(budgetNs, stats),
256
+ heapDeltaBytes,
257
+ metrics:
258
+ metricKeys.length === 0
259
+ ? null
260
+ : Object.fromEntries(metricKeys.map(key => [key, median(oks.flatMap(run => run.metrics?.[key] ?? []))])),
261
+ budget: checkBudget(limits, stats, heapDeltaBytes),
236
262
  comparison:
237
263
  recorded === undefined || baseline === undefined
238
264
  ? null
package/src/Services.ts CHANGED
@@ -12,10 +12,22 @@ export const BenchClock = Context.Reference<Clock>('effect-bench/BenchClock', {
12
12
  export interface Memory {
13
13
  readonly gc: (() => void) | undefined
14
14
  readonly heapUsed: (() => number) | undefined
15
+ /** Resident set size of the process, for stream peaks. */
16
+ readonly rss: (() => number) | undefined
15
17
  }
16
18
 
17
19
  export const BenchMemory = Context.Reference<Memory>('effect-bench/BenchMemory', {
18
- defaultValue: () => ({ gc: undefined, heapUsed: undefined }),
20
+ defaultValue: () => ({ gc: undefined, heapUsed: undefined, rss: undefined }),
21
+ })
22
+
23
+ export interface PhaseRecorder {
24
+ /** Path of the enclosing phase, empty at the top of a run. */
25
+ readonly path: string
26
+ readonly record: (path: string, ns: number) => void
27
+ }
28
+
29
+ export const BenchPhases = Context.Reference<PhaseRecorder>('effect-bench/BenchPhases', {
30
+ defaultValue: () => ({ path: '', record: () => {} }),
19
31
  })
20
32
 
21
33
  export interface EnvironmentProbe {
package/src/Stats.ts CHANGED
@@ -50,3 +50,14 @@ export const formatNs = (ns: number): string => {
50
50
  for (const [scale, unit] of units) if (Math.abs(ns) >= scale) return `${(ns / scale).toPrecision(3)} ${unit}`
51
51
  return `${ns.toPrecision(3)} ns`
52
52
  }
53
+
54
+ export const formatBytes = (bytes: number): string => {
55
+ const units = ['B', 'KiB', 'MiB', 'GiB'] as const
56
+ let value = Math.abs(bytes)
57
+ let unit = 0
58
+ while (value >= 1024 && unit < units.length - 1) {
59
+ value /= 1024
60
+ unit++
61
+ }
62
+ return `${bytes < 0 ? '-' : ''}${unit === 0 ? value.toFixed(0) : value.toPrecision(3)} ${units[unit]}`
63
+ }