Project

awfy

0.0
There's a lot of open issues
No release in over a year
awfy is a benchmarking tool that allows you to define groups of benchmarks and compare against runtimes, branches etc.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Runtime

>= 2.14, < 3.0
>= 2.3, < 3.0
>= 1.1, < 2.0
>= 0.2, < 1.0
>= 0.2, < 1.0
>= 3.0, < 4.0
>= 1.3, < 2.0
 Project Readme

Awfy (Are We Fast Yet)

A CLI tool for running and comparing Ruby benchmarks across different implementations, runtimes (MRI/YJIT), and git branches or commits.

Features

  • Multiple Benchmark Types

    • IPS benchmarks (using benchmark-ips >= 2.14, < 3.0; from 2.15 the central tendency is the whole-run iterations per second)
    • Memory profiling (using memory_profiler)
    • CPU profiling (using stackprof)
    • Flamegraph generation (using vernier)
    • YJIT statistics
  • Rich Comparison Features

    • Compare multiple implementations
    • Compare across git branches or commit ranges
    • Compare with/without YJIT
    • Generate summary reports
  • Persist results

    • SQLite storage (default)
    • JSON file storage

Example output:

Example benchmark table output

Installation

Add to your application:

group :development, :test do
  gem "awfy", require: false
end

Or install directly:

gem install awfy

Quick Start

  1. Create a benchmark suite directory:
mkdir -p benchmarks/tests
  1. Create a setup file (optional):
# benchmarks/setup.rb
require "awfy"
require "json"  # Add any dependencies your benchmarks need

# Setup test data or helper methods
SAMPLE_DATA = { "name" => "test", "values" => [1, 2, 3] }.freeze

def create_test_object
  SAMPLE_DATA.dup
end
  1. Write your first benchmark:
# benchmarks/tests/json_parsing.rb
Awfy.group "JSON" do
  report "#parse" do
    # Setup test data
    json_string = SAMPLE_DATA.to_json
    
    # Benchmark standard library as control
    control "JSON.parse" do
      JSON.parse(json_string)
    end
    
    # Benchmark your implementation
    test "MyJSONParser" do
      MyJSONParser.parse(json_string)
    end
  end
end
  1. Run the benchmark:

For example:

# Run IPS benchmark
bundle exec awfy ips
# or more explicitly:
# bundle exec awfy ips start JSON "#parse"

# Compare with another branch
bundle exec awfy ips --compare-with-branch=main

# Run across a commit range
bundle exec awfy ips start --commit-range="HEAD~5..HEAD" --runner=commit_range

# Run across commits with a specific baseline
bundle exec awfy ips start --commit-range="HEAD~5..HEAD" --runner=commit_range --control-commit=HEAD~5

# Run without YJIT
bundle exec awfy ips --runtime=mri

# Run benchmark in parallel
bundle exec awfy ips --runner=forked

Branch and commit-range runs check out other refs in your working tree. awfy stashes uncommitted changes to tracked files, and afterwards it returns to your branch (or commit) and restores them, also when a run fails. It refuses to start while a rebase, merge, cherry-pick, revert or bisect is in progress, or while files are unmerged. --compare-with-branch first benchmarks your working tree as it is, then the named branch, and uses the branch as the baseline.

Documentation

For detailed documentation, see:

Collectors, run and compare

awfy run measures each test of one suite file in-process and stores one :measure result per test under a label. awfy compare compares the latest results of two labels.

awfy run benchmarks/jobs/import.rb --label master@435e4eb4 --store tmp/awfy.db \
  --collectors timing,gc,rss,memory_profiler --vernier --artefacts tmp/artefacts \
  --format json --output tmp/master.json
awfy compare --store tmp/awfy.db --baseline master@435e4eb4 --against fix@2facc52e --format md

Passes per test: one untimed warm-up call, pass 1 with every light collector, then one pass per heavy collector (memory_profiler, vernier), so heavy collectors never distort the light ones. Measured calls start after 3x GC.start.

Key Heavy Records
timing no wall and process CPU seconds
gc no GC.stat counter deltas, malloc_increase_bytes
rss no RSS MB before, after, after 3x GC.start
memory_profiler yes allocated/retained totals and top 25 by gem, file, location, class
vernier yes <artefacts>/<group>-<report>-<test>.vernier.json.gz and its sample count

Suite DSL additions (group or report level; report wins):

Awfy.group "Import" do
  setup { }            # once, before the first call
  before_each { }      # before every call, warm-up included (not measured)
  after_each { }       # after every call
  isolate :transaction # :transaction | :none | :snapshot (recorded; the caller restores)
  report "#perform" do
    test("perform") { perform_job(ImportJob) }
  end
end

A test block may return an object responding to awfy_outcome; compare reports outcome_differs when two labels' outcomes differ, and (when neither label recorded one) outcome_recorded: false. Custom collectors subclass Awfy::Collector (.key, optional .heavy?, .order, .metrics, .extras; instance #around, #start, #stop returning a string-keyed Hash) and register with Awfy::Collectors.register(klass). awfy compare does not load the setup file, so a custom collector registered only there is unknown to it: its results still compare (numeric fields, base/other/delta/pct), but through the default Collector.compare, not any collector-specific one, and its .extras are not shown.

awfy run saves the run meta (Ruby description, YJIT, awfy version, and with awfy/rails the Rails env, Rails version and jemalloc) with each result. For each test, awfy compare lists in warnings each of isolation, runtime and those meta fields that differs between the two labels, with both values; the Markdown and table output show them under "Environment differs". Results saved without meta are compared on isolation and runtime only. A collector recorded under one label only is listed in collectors_missing, mapped to that label, instead of being dropped.

--store names the SQLite file itself with --storage-backend sqlite (the default), and the directory that holds the .awfy-result.json files with --storage-backend json. Git is optional: without it the default label is unlabelled.

Performance assertions

Declare bounds in a group or a report, and awfy run checks them after it measures each test:

Awfy.group "Orders" do
  assert "sql.queries" => ..5                     # every report of the group
  report "#checkout" do
    assert "memory_profiler.allocated_memsize" => 1_000_000, "timing.wall_s" => 0.01..0.5
    test("checkout") { Order.first.checkout! }
  end
end

Keys are "<collector>.<metric>" paths into the collected data. A number is an inclusive maximum, and a Range must cover the value. A report assertion replaces a group assertion on the same metric. A failed assertion lists the test under failures with the metric, the value and the bound. The result is still saved, and awfy run exits 1. An assertion on a collector that did not run (not in --collectors) is also a failure.

awfy/rails

require "awfy/rails" (in the setup file) adds Rails collectors and helpers. It does not load Rails; call Awfy::Rails.boot!(app_root) to require config/environment.

Key Heavy Records
sql yes queries (excluding SCHEMA, TRANSACTION, cached), cached, sql_ms, fingerprints (count, total ms, first app frame), N+1 candidates (same fingerprint >= AWFY_N_PLUS_ONE_THRESHOLD, default 5, from one app frame)
instantiation no records instantiated per class
cache no cache reads, hits, misses, writes; cas (keys touched by compare-and-set) and, with IdentityCache, identity_cache_fetches, _keys, _memo_hits, _hits, _misses, _resolve_miss_ms, _writes, _deletes, _hydrations (these keys appear only when such events occur)
sidekiq no jobs enqueued by class and queue, scheduled
elasticsearch no requests through elastic-transport's Client#perform_request, total and by method and normalized path (GET /<index>/_search, POST /_bulk); transport is false when the gem is not loaded

The identity_cache_* counts are IdentityCache's own view of its fetches. The store calls it makes to serve them are also in reads/writes/cas, so do not add the two together.

sql gets its own heavy pass: per-query caller capture and backtrace cleaning is expensive enough that running it in the light pass biased the timing/gc numbers. Its frame finder skips this gem's own lib/ frames so N+1 candidates attribute to the first app frame.

The elasticsearch collector prepends a module to Elastic::Transport::Client#perform_request the first time it starts, and counts only the requests of its own thread. It also sees raw client calls that fire no notification. An index keeps its name or alias; a run of six or more digits (a timestamped index) becomes *.

Helpers in suite and test blocks: perform_job(klass, *args, **kwargs) (outcome: the sidekiq summary; warns once to stderr if the sidekiq collector is not active), request(verb, path, host:, headers: {}, params: {}, as: nil, expect: 200..399) (an integration session; as: logs in through Warden; expect: accepts an Integer or a Range; a status outside expect raises Awfy::UnexpectedStatus). isolate :transaction (the default once awfy/rails is loaded and Active Record is connected) pins a non-joinable transaction per call and rolls it back, as Rails transactional tests do; after_commit callbacks still run.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/awfy.

License

Available as open source under the terms of the MIT License.