npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

@nicholaswmin/dyno

v0.5.1

Published

a multithreaded benchmarker

Downloads

1,227

Readme

test-workflow coverage-workflow codeql-workflow deps-report

:stopwatch: dyno

test code against a certain rate of production traffic

Overview

Loops a task function, for a given duration, across multiple threads.

A test is deemed succesful if it ends without creating a cycle backlog.

example: benchmark a recursive fibonacci function across 4 threads

// benchmark.js
import { dyno } from '@nicholaswmin/dyno'

await dyno(async function cycle() { 
  // <benchmarked-code>

  function fibonacci(n) {
    return n < 1 ? 0
    : n <= 2 ? 1 : fibonacci(n - 1) + fibonacci(n - 2)
  }

  fibonacci(35)

  // </benchmarked-code>
}, {
  // test parameters
  parameters: { cyclesPerSecond: 100, threads: 4, durationMs: 5 * 1000 },
  
  // log live stats
  onTick: list => {    
    console.clear()
    console.table(list().primary().pick('count'))
    console.table(list().threads().pick('mean'))
  }
})

run it:

node benchmark.js

logs:

cycle stats

┌─────────┬────────┬───────────┬─────────┐
│ uptime  │ issued │ completed │ backlog │
├─────────┼────────┼───────────┼─────────┤
│ 4       │ 100    │ 95        │ 5       │
└─────────┴────────┴───────────┴─────────┘

average timings/durations, in ms

┌─────────┬───────────┬────────┐
│ thread  │ evt_loop  │ cycle  │
├─────────┼───────────┼────────┤
│ '46781' │ 10.47     │ 10.42  │
│ '46782' │ 10.51     │ 10.30  │
│ '46783' │ 10.68     │ 10.55  │
│ '46784' │ 10.47     │ 10.32  │
└─────────┴───────────┴────────┘

Install

npm i @nicholaswmin/dyno

Generate benchmark

npx init

creates a preconfigured sample benchmark.js.

Run it:

node benchmark.js

Configuration

import { dyno } from '@nicholaswmin/dyno'

await dyno(async function cycle() { 

  // add benchmarked task
  // code in this block runs in its own thread

}, {
  parameters: { 
    // add test parameters
  },
  
  onTick: list => {    
    // build logging from the provided measurements
  }
})

Test parameters

| name | type | default | description | |----------------- |---------- |----------- |----------------------------- | | cyclesPerSecond | Number | 50 | global cycle issue rate | | durationMs | Number | 5000 | how long the test should run | | threads | Number | auto | number of spawned threads |

auto means it detects the available cores but can be overriden

these parameters are user-configurable on test startup.

The test process

The primary spawns the benchmarked code as task threads.

Then, it starts issuing cycle commands to each one, in round-robin, at a set rate, for a set duration.

The task threads must execute their tasks faster than the time it takes for their next cycle command to come through, otherwise the test will start accumulating a cycle backlog.

When that happens, the test stops; the configured cycle rate is deemed as the current breaking point of the benchmarked code.

An example:

A benchmark configured to use threads: 4 & cyclesPerSecond: 4.

Each task thread must execute its own code in < 1 second since this is the rate at which it receives cycle commands.

Glossary

primary

The main process. Orchestrates the test and the spawned task threads.

task thread

The benchmarked code, running in its own separate process.

Receives cycle commands from the primary, executes it's code and records its timings.

task

The benchmarked code

cycle

A command that signals a task thread to execute it's code.

cycle rate

The rate at which the primary sends cycle commands to the task threads

cycle timing

Amount of time it takes a task thread to execute it's own code

cycle backlog

Count of issued cycle commands that have been issued/sent but not executed yet.

The process model

This is how the process model would look, if sketched out.

// assume `fib()` is the benchmarked code

Primary 0: cycles issued: 100, finished: 93, backlog: 7
│
│
├── Thread 1
│   └── function fib(n) {
│       ├── return n < 1 ? 0
│       └── : n <= 2 ? 1 : fib(n - 1) + fib(n - 2)}
│
├── Thread 2
│   └── function fib(n) {
│       ├── return n < 1 ? 0
│       └── : n <= 2 ? 1 : fib(n - 1) + fib(n - 2)}
│
└── Thread 3
    └── function fib(n) {
        ├── return n < 1 ? 0
        └── : n <= 2 ? 1 : fib(n - 1) + fib(n - 2)}

Metrics

The benchmarker comes with a statistical measurement system that can be optionally used to diagnose bottlenecks.

Some metrics are recorded by default; others can be recorded by the user within a task thread.

Every recorded value is tracked as a Metric, represented as a histogram with min, mean, max properties.

Histogram

A metric is represented as a histogram with the following properties:

| name | description | |-------------|-----------------------------------| | count | number of values/samples. | | min | minimum value | | mean | mean/average of values | | max | maximum value | | stddev | standard deviation between values | | last | last value | | snapshots | last 50 states |

Timing metrics are collected in milliseconds.

Querying metrics

Metrics can be queried from the list argument of the onTick callback.

// ...
onTick: list => {    
  // primary metrics
  console.log(list().primary())

  // task thread metrics
  console.log(list().threads()) 
}

.primary()

get all primary/main metrics

// log all primary metrics
console.log(list().primary())

.threads()

get all metrics, for each task thread

// log all metric of every task-thread
console.log(list().threads())

.pick()

reduce all metrics to a single histogram property

list().threads().pick('min')

// from this: { cycle: [{ min: 4, max: 5 }, evt_loop: { min: 2, max: 8 } ... 
// to this  : { cycle: 4, evt_loop: 2 ...

available: min, mean, max, stdev, snapshots, count, last

  • stddev: standard deviation between recorded values
  • last : last recorded value
  • count : number of recorded values

.of()

reduce all metrics that have been pick-ed to an array of histograms, to an array of single histogram values.

list().primary().pick('snapshots').of('max')
// from this: [{ cycle: [{ ... max: 5 }, { ... max: 3 }, { ... max: 2 } ] } ... 
// to this  : [{ cycle: [5,3,2 ....] } ...

note: only makes sense if it comes after .pick('snapshots')

.metrics()

get specific metric(s) instead of all of them

const loopMetrics = list().threads().metrics('evt_loop', 'fibonacci')
// only the `evt_loop` and `fibonacci` metrics

.sortBy()

sort by specific metric

const sorted = list().threads().pick('min').sort('cycle', 'desc')
// sort by descending min 'cycle' durations

available: desc, asc

.group()

get result as an Object, like `Object.groupBy with the metric name used as the key.

const obj = list().threads().pick('snapshots').of('mean').group()

Default metrics

The following metrics are collected by default:

primary

| name | description | |-------------|---------------------------| | issued | count of issued cycles | | completed | count of completed cycles | | backlog | size of cycles backlog | | uptime | seconds since test start |

threads

| name | description | |--------------------|---------------------| | cycles | cycle timings | | evt_loop | event loop timings |

any custom metrics will appear here.

Recording custom metrics

Custom metrics can be recorded with either:

both of them are native extensions of the User Timing APIs.

The metrics collector records their timings and attaches the tracked Metric histogram to its corresponding task thread.

example: instrumenting a function using performance.timerify:

// performance.timerify example

import { dyno } from '@nicholaswmin/dyno'

await dyno(async function cycle() { 

  performance.timerify(function fibonacci(n) {
    return n < 1 ? 0
      : n <= 2 ? 1
      : fibonacci(n - 1) + fibonacci(n - 2)
  })(30)

}, {
  parameters: { cyclesPerSecond: 20 },
  
  onTick: list => {    
    console.log(list().threads().metrics().pick('mean'))
  }
})

// logs 
// ┌─────────┬───────────┐
// │ cycle   │ fibonacci │
// ├─────────┼───────────┤
// │ 7       │ 7         │
// │ 11      │ 5         │
// │ 11      │ 5         │
// └─────────┴───────────┘

note: the stats collector uses the function name for the metric name, so named functions should be preffered to anonymous arrow-functions

Plotting

Each metric contains up to 50 snapshots of its past states.

This allows plotting them as a timeline, using the console.plot module.

The following example benchmarks 2 sleep functions & plots their timings as an ASCII chart

// Requires: 
// `npm i @nicholaswmin/console-plot --no-save`

import { dyno } from '@nicholaswmin/dyno'
import console from '@nicholaswmin/console-plot'

await dyno(async function cycle() { 

  await performance.timerify(function sleepRandom1(ms) {
    return new Promise(r => setTimeout(r, Math.random() * ms))
  })(Math.random() * 20)
  
  await performance.timerify(function sleepRandom2(ms) {
    return new Promise(r => setTimeout(r, Math.random() * ms))
  })(Math.random() * 20)
  
}, {

  parameters: { cyclesPerSecond: 15, durationMs: 20 * 1000 },

  onTick: list => {  
    console.clear()
    console.plot(list().threads().pick('snapshots').of('mean').group(), {
      title: 'Plot',
      subtitle: 'mean durations (ms)'
    })
  }
})

which logs:


Plot

-- sleepRandom1  -- cycle  -- sleepRandom2  -- evt_loop

11.75 ┤╭╮                                                                                                   
11.28 ┼─────────────────────────────────────────────────────────────────────╮                               
10.82 ┤│╰───╮    ╭╯ ╰╮   │╰╮  ╭─────────╯╰──────────╮ ╭─────────────────╯   ╰───────────╮╭─╮    ╭────────── 
10.35 ┼╯    ╰╮╭╮╭╯   ╰───╯ ╰──╯                     ╰─╯                                 ╰╯ ╰────╯           
 9.88 ┤      ╰╯╰╯                                                                                           
 9.42 ┤                                                                                                     
 8.95 ┤                                                                                                     
 8.49 ┤                                                                                                     
 8.02 ┤                                                                                                     
 7.55 ┤                                                                                                     
 7.09 ┤╭╮                                                                                                   
 6.62 ┼╯╰───╮    ╭─────────╮   ╭──╮                                                                         
 6.16 ┤     ╰╮╭──╯         ╰───╯  ╰───────────────────────╮       ╭─────────────────────╮╭───╮   ╭───────── 
 5.69 ┤╭╮    ╰╯                                        ╭───────────╮  ╭╮╭──────╮        ╰╯   ╰──╭╮╭─╮╭───── 
 5.22 ┤│╰╮╭─╮   ╭──╮     ╭───╮╭─╮ ╭────────────────────╯           ╰──╯╰╯      ╰────────────────╯╰╯ ╰╯      
 4.76 ┤│ ╰╯ ╰───╯  ╰─────╯   ╰╯ ╰─╯                                                                         
 4.29 ┼╯                                                                                                    

mean durations (ms)

- last: 100 items

Gotchas

Missing custom metrics

Using lambdas/arrow functions means the metrics collector has no function name to use for the metric. By their own definition, they are anonymous.

Change this:

const foo = () => {
  // test code
}

performance.timerify(foo)()

to this:

function foo() {
  // test code
}

performance.timerify(foo)()

code running multiple times

The benchmark file self-forks itself. 👀

This means that any code that exists outside the dyno block will also run in multiple threads.

This is a design tradeoff, made to provide the ability to create simple, single-file benchmarks but it can create issues if you intent to run code after the dyno() resolves/ends; or when running this as part of an automated test suite.

In this example, 'done' is logged 3 times instead of 1:

import { dyno } from '@nicholaswmin/dyno'

const result = await dyno(async function cycle() { 
  // task code, expected to run 3 times ...
}, { threads: 3 })

console.log('done')
// 'done'
// 'done'
// 'done'

Using hooks

To work around this, the before/after hooks can be used for setup and teardown, like so:

await dyno(async function cycle() { 
  console.log('task')
}, {
  parameters: { durationMs: 5 * 1000, },

  before: async parameters => {
    console.log('before')
  },

  after: async parameters => {
    console.log('after')
  }
})

// "before"
// ...
// "task"
// "task"
// "task"
// "task"
// ...  
// "after"

Fallback to using a task file

Alternatively, the task function can be extracted to it's own file.

// task.js
import { task } from '@nicholaswmin/dyno'

task(async function task(parameters) {
  // task code ...

  // `benchmark.js` test parameters are
  // available here.
})

then referenced as a path in benchmark.js:

// benchmark.js

import { join } from 'node:path'
import { dyno } from '@nicholaswmin/dyno'

const result = await dyno(join(import.meta.dirname, './task.js'), { 
  threads: 5
})

console.log('done')
// 'done'

This should be the preferred method when running this as part of a test suite.

Not a load-testing tool

This is not a stress-testing tool.
Stress-tests are far more complex and require a near-perfect replication of an actual production environment.

This is a prototyping tool that helps testing whether some prototype idea is worth proceeding with or whether it has unworkable scalability issues.

It's multi-threaded model is meant to mimic the execution model of horizontally-scalable, share-nothing services.

It's original purpose was for benchmarking a module prototype that heavily interacts with a data store over a network.

It's not meant for side-to-side benchmarking of synchronous code, Google's Tachometer being a much better fit.

Tests

install deps:

npm ci

unit & integration tests:

npm test

test coverage:

npm run test:coverage

note: the parameter prompt is suppressed when NODE_ENV=test

meta checks:

npm run checks

Misc

generate a sample benchmark:

npx init

generate Heroku-deployable benchmark:

npx init-cloud

Contributors

Todos are available here

Scripts

update README.md code snippets:

npm run examples:update

source examples are located in: /bin/example

Authors

@nicholaswmin

License

MIT-0 License