@fuzdev/fuz_util 0.42.0 → 0.44.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.
Files changed (59) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +19 -12
  3. package/dist/async.d.ts +2 -2
  4. package/dist/async.d.ts.map +1 -1
  5. package/dist/async.js +2 -2
  6. package/dist/benchmark.d.ts +179 -0
  7. package/dist/benchmark.d.ts.map +1 -0
  8. package/dist/benchmark.js +400 -0
  9. package/dist/benchmark_baseline.d.ts +195 -0
  10. package/dist/benchmark_baseline.d.ts.map +1 -0
  11. package/dist/benchmark_baseline.js +388 -0
  12. package/dist/benchmark_format.d.ts +87 -0
  13. package/dist/benchmark_format.d.ts.map +1 -0
  14. package/dist/benchmark_format.js +266 -0
  15. package/dist/benchmark_stats.d.ts +112 -0
  16. package/dist/benchmark_stats.d.ts.map +1 -0
  17. package/dist/benchmark_stats.js +219 -0
  18. package/dist/benchmark_types.d.ts +174 -0
  19. package/dist/benchmark_types.d.ts.map +1 -0
  20. package/dist/benchmark_types.js +1 -0
  21. package/dist/git.d.ts +12 -0
  22. package/dist/git.d.ts.map +1 -1
  23. package/dist/git.js +14 -0
  24. package/dist/library_json.d.ts +3 -3
  25. package/dist/library_json.d.ts.map +1 -1
  26. package/dist/library_json.js +1 -1
  27. package/dist/maths.d.ts +4 -0
  28. package/dist/maths.d.ts.map +1 -1
  29. package/dist/maths.js +8 -0
  30. package/dist/object.js +1 -1
  31. package/dist/source_json.d.ts +4 -4
  32. package/dist/stats.d.ts +180 -0
  33. package/dist/stats.d.ts.map +1 -0
  34. package/dist/stats.js +402 -0
  35. package/dist/string.d.ts +13 -0
  36. package/dist/string.d.ts.map +1 -1
  37. package/dist/string.js +58 -0
  38. package/dist/time.d.ts +165 -0
  39. package/dist/time.d.ts.map +1 -0
  40. package/dist/time.js +264 -0
  41. package/dist/timings.d.ts +1 -7
  42. package/dist/timings.d.ts.map +1 -1
  43. package/dist/timings.js +16 -16
  44. package/package.json +21 -19
  45. package/src/lib/async.ts +3 -3
  46. package/src/lib/benchmark.ts +498 -0
  47. package/src/lib/benchmark_baseline.ts +538 -0
  48. package/src/lib/benchmark_format.ts +314 -0
  49. package/src/lib/benchmark_stats.ts +311 -0
  50. package/src/lib/benchmark_types.ts +197 -0
  51. package/src/lib/git.ts +24 -0
  52. package/src/lib/library_json.ts +3 -3
  53. package/src/lib/maths.ts +8 -0
  54. package/src/lib/object.ts +1 -1
  55. package/src/lib/stats.ts +534 -0
  56. package/src/lib/string.ts +66 -0
  57. package/src/lib/time.ts +319 -0
  58. package/src/lib/timings.ts +17 -17
  59. package/src/lib/types.ts +2 -2
@@ -0,0 +1,498 @@
1
+ /**
2
+ * Benchmarking library.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * import {Benchmark} from '@fuzdev/fuz_util/benchmark.js';
7
+ *
8
+ * const bench = new Benchmark({
9
+ * duration_ms: 5000,
10
+ * warmup_iterations: 5,
11
+ * });
12
+ *
13
+ * bench
14
+ * .add('slugify', () => slugify(title))
15
+ * .add('slugify_slower', () => slugify_slower(title));
16
+ *
17
+ * const results = await bench.run();
18
+ * console.log(bench.table());
19
+ * ```
20
+ */
21
+
22
+ import {is_promise, wait} from './async.js';
23
+ import {BenchmarkStats} from './benchmark_stats.js';
24
+ import {timer_default, time_unit_detect_best, time_format} from './time.js';
25
+ import {
26
+ benchmark_format_table,
27
+ benchmark_format_table_grouped,
28
+ benchmark_format_markdown,
29
+ benchmark_format_json,
30
+ benchmark_format_number,
31
+ type BenchmarkFormatJsonOptions,
32
+ } from './benchmark_format.js';
33
+ import type {
34
+ BenchmarkConfig,
35
+ BenchmarkTask,
36
+ BenchmarkResult,
37
+ BenchmarkFormatTableOptions,
38
+ } from './benchmark_types.js';
39
+
40
+ // Default configuration values
41
+ const DEFAULT_DURATION_MS = 1000;
42
+ const DEFAULT_WARMUP_ITERATIONS = 10;
43
+ const DEFAULT_COOLDOWN_MS = 100;
44
+ const DEFAULT_MIN_ITERATIONS = 10;
45
+ const DEFAULT_MAX_ITERATIONS = 100_000;
46
+
47
+ /**
48
+ * Validate and normalize benchmark configuration.
49
+ * Throws if configuration is invalid.
50
+ */
51
+ const validate_config = (config: BenchmarkConfig): void => {
52
+ if (config.duration_ms !== undefined && config.duration_ms <= 0) {
53
+ throw new Error(`duration_ms must be positive, got ${config.duration_ms}`);
54
+ }
55
+ if (config.warmup_iterations !== undefined && config.warmup_iterations < 0) {
56
+ throw new Error(`warmup_iterations must be non-negative, got ${config.warmup_iterations}`);
57
+ }
58
+ if (config.cooldown_ms !== undefined && config.cooldown_ms < 0) {
59
+ throw new Error(`cooldown_ms must be non-negative, got ${config.cooldown_ms}`);
60
+ }
61
+ if (config.min_iterations !== undefined && config.min_iterations < 1) {
62
+ throw new Error(`min_iterations must be at least 1, got ${config.min_iterations}`);
63
+ }
64
+ if (config.max_iterations !== undefined && config.max_iterations < 1) {
65
+ throw new Error(`max_iterations must be at least 1, got ${config.max_iterations}`);
66
+ }
67
+ if (
68
+ config.min_iterations !== undefined &&
69
+ config.max_iterations !== undefined &&
70
+ config.min_iterations > config.max_iterations
71
+ ) {
72
+ throw new Error(
73
+ `min_iterations (${config.min_iterations}) cannot exceed max_iterations (${config.max_iterations})`,
74
+ );
75
+ }
76
+ };
77
+
78
+ /**
79
+ * Internal task representation with detected async status.
80
+ */
81
+ interface BenchmarkTaskInternal extends BenchmarkTask {
82
+ /** Whether the function returns a promise (detected during warmup or from hint) */
83
+ is_async?: boolean;
84
+ }
85
+
86
+ /**
87
+ * Warmup function by running it multiple times.
88
+ * Detects whether the function is async based on return value.
89
+ *
90
+ * @param fn - Function to warmup (sync or async)
91
+ * @param iterations - Number of warmup iterations
92
+ * @param async_hint - If provided, use this instead of detecting
93
+ * @returns Whether the function is async
94
+ *
95
+ * @example
96
+ * ```ts
97
+ * const is_async = await benchmark_warmup(() => expensive_operation(), 10);
98
+ * ```
99
+ */
100
+ export const benchmark_warmup = async (
101
+ fn: () => unknown,
102
+ iterations: number,
103
+ async_hint?: boolean,
104
+ ): Promise<boolean> => {
105
+ // If we have an explicit hint, use it
106
+ if (async_hint !== undefined) {
107
+ // Still run warmup iterations for JIT
108
+ for (let i = 0; i < iterations; i++) {
109
+ const result = fn();
110
+ if (async_hint && is_promise(result)) {
111
+ await result; // eslint-disable-line no-await-in-loop
112
+ }
113
+ }
114
+ return async_hint;
115
+ }
116
+
117
+ // Detect on first iteration
118
+ let detected_async = false;
119
+ for (let i = 0; i < iterations; i++) {
120
+ const result = fn();
121
+ if (i === 0) {
122
+ detected_async = is_promise(result);
123
+ }
124
+ if (detected_async && is_promise(result)) {
125
+ await result; // eslint-disable-line no-await-in-loop
126
+ }
127
+ }
128
+ return detected_async;
129
+ };
130
+
131
+ /**
132
+ * Benchmark class for measuring and comparing function performance.
133
+ */
134
+ export class Benchmark {
135
+ readonly #config: Required<Omit<BenchmarkConfig, 'on_iteration' | 'on_task_complete'>> &
136
+ Pick<BenchmarkConfig, 'on_iteration' | 'on_task_complete'>;
137
+ readonly #tasks: Array<BenchmarkTaskInternal> = [];
138
+ #results: Array<BenchmarkResult> = [];
139
+
140
+ constructor(config: BenchmarkConfig = {}) {
141
+ validate_config(config);
142
+ this.#config = {
143
+ duration_ms: config.duration_ms ?? DEFAULT_DURATION_MS,
144
+ warmup_iterations: config.warmup_iterations ?? DEFAULT_WARMUP_ITERATIONS,
145
+ cooldown_ms: config.cooldown_ms ?? DEFAULT_COOLDOWN_MS,
146
+ min_iterations: config.min_iterations ?? DEFAULT_MIN_ITERATIONS,
147
+ max_iterations: config.max_iterations ?? DEFAULT_MAX_ITERATIONS,
148
+ timer: config.timer ?? timer_default,
149
+ on_iteration: config.on_iteration,
150
+ on_task_complete: config.on_task_complete,
151
+ };
152
+ }
153
+
154
+ /**
155
+ * Add a benchmark task.
156
+ * @param name - Task name or full task object
157
+ * @param fn - Function to benchmark (if name is string). Return values are ignored.
158
+ * @returns This Benchmark instance for chaining
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * bench.add('simple', () => fn());
163
+ *
164
+ * // Or with setup/teardown:
165
+ * bench.add({
166
+ * name: 'with setup',
167
+ * fn: () => process(data),
168
+ * setup: () => { data = load() },
169
+ * teardown: () => { cleanup() },
170
+ * });
171
+ * ```
172
+ */
173
+ add(name: string, fn: () => unknown): this;
174
+ add(task: BenchmarkTask): this;
175
+ add(name_or_task: string | BenchmarkTask, fn?: () => unknown): this {
176
+ const task_name = typeof name_or_task === 'string' ? name_or_task : name_or_task.name;
177
+
178
+ // Validate unique task names
179
+ if (this.#tasks.some((t) => t.name === task_name)) {
180
+ throw new Error(`Task "${task_name}" already exists`);
181
+ }
182
+
183
+ if (typeof name_or_task === 'string') {
184
+ if (!fn) throw new Error('Function required when name is string');
185
+ this.#tasks.push({name: name_or_task, fn});
186
+ } else {
187
+ this.#tasks.push(name_or_task);
188
+ }
189
+ return this;
190
+ }
191
+
192
+ /**
193
+ * Remove a benchmark task by name.
194
+ * @param name - Name of the task to remove
195
+ * @returns This Benchmark instance for chaining
196
+ * @throws Error if task with given name doesn't exist
197
+ *
198
+ * @example
199
+ * ```ts
200
+ * bench.add('task1', () => fn1());
201
+ * bench.add('task2', () => fn2());
202
+ * bench.remove('task1');
203
+ * // Only task2 remains
204
+ * ```
205
+ */
206
+ remove(name: string): this {
207
+ const index = this.#tasks.findIndex((t) => t.name === name);
208
+ if (index === -1) {
209
+ throw new Error(`Task "${name}" not found`);
210
+ }
211
+ this.#tasks.splice(index, 1);
212
+ return this;
213
+ }
214
+
215
+ /**
216
+ * Mark a task to be skipped during benchmark runs.
217
+ * @param name - Name of the task to skip
218
+ * @returns This Benchmark instance for chaining
219
+ * @throws Error if task with given name doesn't exist
220
+ *
221
+ * @example
222
+ * ```ts
223
+ * bench.add('task1', () => fn1());
224
+ * bench.add('task2', () => fn2());
225
+ * bench.skip('task1');
226
+ * // Only task2 will run
227
+ * ```
228
+ */
229
+ skip(name: string): this {
230
+ const task = this.#tasks.find((t) => t.name === name);
231
+ if (!task) {
232
+ throw new Error(`Task "${name}" not found`);
233
+ }
234
+ task.skip = true;
235
+ return this;
236
+ }
237
+
238
+ /**
239
+ * Mark a task to run exclusively (along with other `only` tasks).
240
+ * @param name - Name of the task to run exclusively
241
+ * @returns This Benchmark instance for chaining
242
+ * @throws Error if task with given name doesn't exist
243
+ *
244
+ * @example
245
+ * ```ts
246
+ * bench.add('task1', () => fn1());
247
+ * bench.add('task2', () => fn2());
248
+ * bench.add('task3', () => fn3());
249
+ * bench.only('task2');
250
+ * // Only task2 will run
251
+ * ```
252
+ */
253
+ only(name: string): this {
254
+ const task = this.#tasks.find((t) => t.name === name);
255
+ if (!task) {
256
+ throw new Error(`Task "${name}" not found`);
257
+ }
258
+ task.only = true;
259
+ return this;
260
+ }
261
+
262
+ /**
263
+ * Run all benchmark tasks.
264
+ * @returns Array of benchmark results
265
+ */
266
+ async run(): Promise<Array<BenchmarkResult>> {
267
+ this.#results = [];
268
+
269
+ // Determine which tasks to run
270
+ const has_only = this.#tasks.some((t) => t.only);
271
+ const tasks_to_run = this.#tasks.filter((t) => {
272
+ if (t.skip) return false;
273
+ if (has_only) return t.only;
274
+ return true;
275
+ });
276
+
277
+ for (let i = 0; i < tasks_to_run.length; i++) {
278
+ const task = tasks_to_run[i]!;
279
+ const result = await this.#run_task(task); // eslint-disable-line no-await-in-loop
280
+ this.#results.push(result);
281
+
282
+ // Call on_task_complete callback
283
+ this.#config.on_task_complete?.(result, i, tasks_to_run.length);
284
+
285
+ // Cooldown between tasks (skip after last task)
286
+ if (this.#config.cooldown_ms > 0 && i < tasks_to_run.length - 1) {
287
+ await wait(this.#config.cooldown_ms); // eslint-disable-line no-await-in-loop
288
+ }
289
+ }
290
+
291
+ return this.#results;
292
+ }
293
+
294
+ /**
295
+ * Run a single benchmark task.
296
+ * Throws if the task fails during setup, warmup, or measurement.
297
+ */
298
+ async #run_task(task: BenchmarkTaskInternal): Promise<BenchmarkResult> {
299
+ const suite_start_ns = this.#config.timer.now();
300
+
301
+ // Pre-allocate array to avoid GC pressure during measurement
302
+ const max_iterations = this.#config.max_iterations;
303
+ const timings_ns: Array<number> = new Array(max_iterations);
304
+ let timing_count = 0;
305
+
306
+ try {
307
+ // Setup
308
+ if (task.setup) {
309
+ await task.setup();
310
+ }
311
+
312
+ // Warmup and detect async
313
+ const is_async = await benchmark_warmup(task.fn, this.#config.warmup_iterations, task.async);
314
+ task.is_async = is_async;
315
+
316
+ // Measurement phase
317
+ const target_time_ns = this.#config.duration_ms * 1_000_000; // Convert ms to ns
318
+ const min_iterations = this.#config.min_iterations;
319
+
320
+ let aborted = false as boolean;
321
+ const abort = (): void => {
322
+ aborted = true;
323
+ };
324
+ const measurement_start_ns = this.#config.timer.now();
325
+
326
+ // Use separate code paths for sync vs async for better performance
327
+ if (is_async) {
328
+ // Async code path - await each iteration
329
+ // eslint-disable-next-line no-unmodified-loop-condition
330
+ while (timing_count < max_iterations && !aborted) {
331
+ const iter_start_ns = this.#config.timer.now();
332
+ await task.fn(); // eslint-disable-line no-await-in-loop
333
+ const iter_end_ns = this.#config.timer.now();
334
+ timings_ns[timing_count++] = iter_end_ns - iter_start_ns;
335
+ this.#config.on_iteration?.(task.name, timing_count, abort);
336
+
337
+ const total_elapsed_ns = iter_end_ns - measurement_start_ns;
338
+ if (timing_count >= min_iterations && total_elapsed_ns >= target_time_ns) {
339
+ break;
340
+ }
341
+ }
342
+ } else {
343
+ // Sync code path - no promise checking overhead
344
+ // eslint-disable-next-line no-unmodified-loop-condition
345
+ while (timing_count < max_iterations && !aborted) {
346
+ const iter_start_ns = this.#config.timer.now();
347
+ task.fn();
348
+ const iter_end_ns = this.#config.timer.now();
349
+ timings_ns[timing_count++] = iter_end_ns - iter_start_ns;
350
+ this.#config.on_iteration?.(task.name, timing_count, abort);
351
+
352
+ const total_elapsed_ns = iter_end_ns - measurement_start_ns;
353
+ if (timing_count >= min_iterations && total_elapsed_ns >= target_time_ns) {
354
+ break;
355
+ }
356
+ }
357
+ }
358
+ } finally {
359
+ // Always run teardown
360
+ if (task.teardown) {
361
+ await task.teardown();
362
+ }
363
+ }
364
+
365
+ // Trim array to actual size
366
+ timings_ns.length = timing_count;
367
+
368
+ const suite_end_ns = this.#config.timer.now();
369
+ const total_time_ms = (suite_end_ns - suite_start_ns) / 1_000_000; // Convert back to ms for display
370
+
371
+ // Analyze results
372
+ const stats = new BenchmarkStats(timings_ns);
373
+
374
+ return {
375
+ name: task.name,
376
+ stats,
377
+ iterations: timing_count,
378
+ total_time_ms,
379
+ timings_ns,
380
+ };
381
+ }
382
+
383
+ /**
384
+ * Format results as an ASCII table with percentiles, min/max, and relative performance.
385
+ * @param options - Formatting options
386
+ * @returns Formatted table string
387
+ *
388
+ * @example
389
+ * ```ts
390
+ * // Standard table
391
+ * console.log(bench.table());
392
+ *
393
+ * // Grouped by category
394
+ * console.log(bench.table({
395
+ * groups: [
396
+ * { name: 'FAST PATHS', filter: (r) => r.name.includes('fast') },
397
+ * { name: 'SLOW PATHS', filter: (r) => r.name.includes('slow') },
398
+ * ]
399
+ * }));
400
+ * ```
401
+ */
402
+ table(options?: BenchmarkFormatTableOptions): string {
403
+ return options?.groups
404
+ ? benchmark_format_table_grouped(this.#results, options.groups)
405
+ : benchmark_format_table(this.#results);
406
+ }
407
+
408
+ /**
409
+ * Format results as a Markdown table.
410
+ * @returns Formatted markdown string
411
+ */
412
+ markdown(): string {
413
+ return benchmark_format_markdown(this.#results);
414
+ }
415
+
416
+ /**
417
+ * Format results as JSON.
418
+ * @param options - Formatting options (pretty, include_timings)
419
+ * @returns JSON string
420
+ */
421
+ json(options?: BenchmarkFormatJsonOptions): string {
422
+ return benchmark_format_json(this.#results, options);
423
+ }
424
+
425
+ /**
426
+ * Get the benchmark results.
427
+ * Returns a shallow copy to prevent external mutation.
428
+ * @returns Array of benchmark results
429
+ */
430
+ results(): Array<BenchmarkResult> {
431
+ return [...this.#results];
432
+ }
433
+
434
+ /**
435
+ * Reset the benchmark results.
436
+ * Keeps tasks intact so benchmarks can be rerun.
437
+ * @returns This Benchmark instance for chaining
438
+ */
439
+ reset(): this {
440
+ this.#results = [];
441
+ return this;
442
+ }
443
+
444
+ /**
445
+ * Clear everything (results and tasks).
446
+ * Use this to start fresh with a new set of benchmarks.
447
+ * @returns This Benchmark instance for chaining
448
+ */
449
+ clear(): this {
450
+ this.#results = [];
451
+ this.#tasks.length = 0;
452
+ return this;
453
+ }
454
+
455
+ /**
456
+ * Get a quick text summary of the fastest task.
457
+ * @returns Human-readable summary string
458
+ *
459
+ * @example
460
+ * ```ts
461
+ * console.log(bench.summary());
462
+ * // "Fastest: slugify_v2 (1,285,515.00 ops/sec, 786.52ns per op)"
463
+ * // "Slowest: slugify (252,955.00 ops/sec, 3.95μs per op)"
464
+ * // "Speed difference: 5.08x"
465
+ * ```
466
+ */
467
+ summary(): string {
468
+ if (this.#results.length === 0) return 'No results';
469
+
470
+ const fastest = this.#results.reduce((a, b) =>
471
+ a.stats.ops_per_second > b.stats.ops_per_second ? a : b,
472
+ );
473
+
474
+ const slowest = this.#results.reduce((a, b) =>
475
+ a.stats.ops_per_second < b.stats.ops_per_second ? a : b,
476
+ );
477
+
478
+ const ratio = fastest.stats.ops_per_second / slowest.stats.ops_per_second;
479
+
480
+ // Detect best unit for consistent display
481
+ const mean_times = this.#results.map((r) => r.stats.mean_ns);
482
+ const unit = time_unit_detect_best(mean_times);
483
+
484
+ const lines: Array<string> = [];
485
+ lines.push(
486
+ `Fastest: ${fastest.name} (${benchmark_format_number(fastest.stats.ops_per_second)} ops/sec, ${time_format(fastest.stats.mean_ns, unit)} per op)`,
487
+ );
488
+
489
+ if (this.#results.length > 1) {
490
+ lines.push(
491
+ `Slowest: ${slowest.name} (${benchmark_format_number(slowest.stats.ops_per_second)} ops/sec, ${time_format(slowest.stats.mean_ns, unit)} per op)`,
492
+ );
493
+ lines.push(`Speed difference: ${ratio.toFixed(2)}x`);
494
+ }
495
+
496
+ return lines.join('\n');
497
+ }
498
+ }