diff --git a/.rubocop.yml b/.rubocop.yml index fe795817..2ef5056a 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -67,6 +67,8 @@ Metrics/BlockLength: - 'spec/**/*.rb' Metrics/ClassLength: + Exclude: + - 'lib/image_optim.rb' Max: 150 Metrics/CyclomaticComplexity: diff --git a/CHANGELOG.markdown b/CHANGELOG.markdown index 507fbd0c..95efa3d9 100644 --- a/CHANGELOG.markdown +++ b/CHANGELOG.markdown @@ -3,6 +3,7 @@ ## unreleased * Correct environment variable to specify `jpeg-recompress` location [@toy](https://github.com/toy) +* Added --benchmark, to compare performance of each tool [#217](https://github.com/toy/image_optim/issues/217) [#218](https://github.com/toy/image_optim/pull/218) [@gurgeous](https://github.com/gurgeous) ## v0.31.4 (2024-11-19) diff --git a/README.markdown b/README.markdown index a6f46fbe..7cbfe9b6 100644 --- a/README.markdown +++ b/README.markdown @@ -301,6 +301,29 @@ optipng: `image_optim` uses standard ruby library for creating temporary files. Temporary directory can be changed using one of `TMPDIR`, `TMP` or `TEMP` environment variables. +### Benchmark + +Run with `--benchmark` to compare the performance of each individual tool on your images: + +```sh +image_optim --benchmark -r /tmp/corpus/ +``` + +``` +benchmarking: 100.0% (elapsed: 3.9m) + +BENCHMARK RESULTS + +name files elapsed kb saved kb/s +-------- ----- ------- -------- ------- +oxipng 50 8.906 1867.253 209.664 +pngquant 50 1.980 214.597 108.386 +pngcrush 50 22.529 1753.704 77.841 +optipng 50 142.940 1641.101 11.481 +advpng 50 137.753 962.549 6.987 +pngout 50 426.706 444.679 1.042 +``` + ## Options * `:nice` — Nice level, priority of all used tools with higher value meaning lower priority, in range `-20..19`, negative values can be set only if run by root user *(defaults to `10`)* diff --git a/lib/image_optim.rb b/lib/image_optim.rb index 184d170e..dcf0e33c 100644 --- a/lib/image_optim.rb +++ b/lib/image_optim.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'image_optim/benchmark_result' require 'image_optim/bin_resolver' require 'image_optim/cache' require 'image_optim/config' @@ -8,6 +9,7 @@ require 'image_optim/image_meta' require 'image_optim/optimized_path' require 'image_optim/path' +require 'image_optim/table' require 'image_optim/timer' require 'image_optim/worker' require 'in_threads' @@ -162,6 +164,22 @@ def optimize_image_data(original_data) end end + def benchmark_image(original) + src = Path.convert(original) + return unless (workers = workers_for_image(src)) + + workers.map do |worker| + start = ElapsedTime.now + dst = src.temp_path + begin + worker.optimize(src, dst) + BenchmarkResult.new(src, dst, ElapsedTime.now - start, worker) + ensure + dst.unlink + end + end + end + # Optimize multiple images # if block given yields path and result for each image and returns array of # yield results @@ -186,6 +204,10 @@ def optimize_images_data(datas, &block) run_method_for(datas, :optimize_image_data, &block) end + def benchmark_images(paths, &block) + run_method_for(paths, :benchmark_image, &block) + end + class << self # Optimization methods with default options def method_missing(method, *args, &block) diff --git a/lib/image_optim/benchmark_result.rb b/lib/image_optim/benchmark_result.rb new file mode 100644 index 00000000..81041bfb --- /dev/null +++ b/lib/image_optim/benchmark_result.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class ImageOptim + # Benchmark result for one worker+src + class BenchmarkResult + attr_reader :bytes, :elapsed, :worker + + def initialize(src, dst, elapsed, worker) + @bytes = bytes_saved(src, dst) + @elapsed = elapsed + @worker = worker.class.bin_sym.to_s + end + + private + + def bytes_saved(src, dst) + src, dst = src.size, dst.size + return 0 if dst == 0 # failure + return 0 if dst > src # the file got bigger + + src - dst + end + end +end diff --git a/lib/image_optim/runner.rb b/lib/image_optim/runner.rb index 94a8dba5..02cee96e 100644 --- a/lib/image_optim/runner.rb +++ b/lib/image_optim/runner.rb @@ -45,6 +45,46 @@ def size_percent(size_a, size_b) end end + # files, elapsed, kb saved, kb/s + class BenchmarkResults + def initialize + @all = [] + end + + def add(rows) + @all.concat(rows) + end + + def print + if @all.empty? + puts 'nothing to report' + return + end + + # group by worker + report = @all.group_by(&:worker).map do |name, results| + kb = (results.sum(&:bytes) / 1024.0) + elapsed = results.sum(&:elapsed) + { + 'name' => name, + 'files' => results.length, + 'elapsed' => elapsed, + 'kb saved' => kb, + 'kb/s' => (kb / elapsed), + } + end + + # sort + report = report.sort_by do |row| + [-row['kb/s'], row['name']] + end + + # output + puts "\nBENCHMARK RESULTS\n\n" + Table.new(report).write($stdout) + end + end + def initialize(options) options = HashHelpers.deep_symbolise_keys(options) @recursive = options.delete(:recursive) @@ -53,19 +93,40 @@ def initialize(options) glob = options.delete(:"exclude_#{type}_glob") || '.*' GlobHelpers.expand_braces(glob) end + + # --benchmark + @benchmark = options.delete(:benchmark) + if @benchmark + unless options[:threads].nil? + warning '--benchmark ignores --threads' + options[:threads] = 1 # for consistency + end + if options[:timeout] + warning '--benchmark ignores --timeout' + end + end + @image_optim = ImageOptim.new(options) end def run!(args) # rubocop:disable Naming/PredicateMethod to_optimize = find_to_optimize(args) unless to_optimize.empty? - results = Results.new + if @benchmark + benchmark_results = BenchmarkResults.new + benchmark_images(to_optimize).each do |_original, rows| # rubocop:disable Style/HashEachMethods + benchmark_results.add(rows) + end + benchmark_results.print + else + results = Results.new - optimize_images!(to_optimize).each do |original, optimized| - results.add(original, optimized) - end + optimize_images!(to_optimize).each do |original, optimized| + results.add(original, optimized) + end - results.print + results.print + end end !@warnings @@ -73,6 +134,11 @@ def run!(args) # rubocop:disable Naming/PredicateMethod private + def benchmark_images(to_optimize, &block) + to_optimize = to_optimize.with_progress('benchmarking') if @progress + @image_optim.benchmark_images(to_optimize, &block) + end + def optimize_images!(to_optimize, &block) to_optimize = to_optimize.with_progress('optimizing') if @progress @image_optim.optimize_images!(to_optimize, &block) diff --git a/lib/image_optim/runner/option_parser.rb b/lib/image_optim/runner/option_parser.rb index 3af60c52..ad6a21f9 100644 --- a/lib/image_optim/runner/option_parser.rb +++ b/lib/image_optim/runner/option_parser.rb @@ -153,6 +153,15 @@ def wrap_regex(width) options[:pack] = pack end + op.separator nil + op.on( + '--benchmark TYPE', + [:isolated], + 'Run benchmarks, to compare tools without modifying images. `isolated` is the only supported type so far.' + ) do |benchmark| + options[:benchmark] = benchmark + end + op.separator nil op.separator ' Caching:' diff --git a/lib/image_optim/table.rb b/lib/image_optim/table.rb new file mode 100644 index 00000000..ec458485 --- /dev/null +++ b/lib/image_optim/table.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class ImageOptim + # Handy class for pretty printing a table in the terminal. This is very simple, switch to Terminal + # Table, Table Tennis or similar if we need more. + class Table + attr_reader :rows + + def initialize(rows) + @rows = rows + end + + def write(io) + io.puts render_row(columns) + io.puts render_sep + rows.each do |row| + io.puts render_row(row.values) + end + end + + protected + + # array of column names + def columns + @columns ||= rows.first.keys + end + + # should columns be justified left or right? + def justs + @justs ||= columns.map do |col| + rows.first[col].is_a?(Numeric) ? :rjust : :ljust + end + end + + # max width of each column + def widths + @widths ||= columns.map do |col| + values = rows.map{ |row| fmt(row[col]) } + ([col] + values).map(&:length).max + end + end + + # render an array of row values + def render_row(values) + values.zip(justs, widths).map do |value, just, width| + fmt(value).send(just, width) + end.join(' ') + end + + # render a separator line + def render_sep + render_row(widths.map{ |width| '-' * width }) + end + + # format one cell value + def fmt(value) + if value.is_a?(Float) + format('%0.3f', value) + else + value.to_s + end + end + end +end diff --git a/spec/image_optim_spec.rb b/spec/image_optim_spec.rb index 6a651258..76d46586 100644 --- a/spec/image_optim_spec.rb +++ b/spec/image_optim_spec.rb @@ -269,6 +269,20 @@ def temp_copy(image) end end + describe 'benchmark_images' do + it 'does it' do + image_optim = ImageOptim.new + pairs = image_optim.benchmark_images(test_images) + test_images.zip(pairs).each do |original, (src, bm)| + expect(original).to equal(src) + expect(bm[0]).to be_a(ImageOptim::BenchmarkResult) + expect(bm[0].bytes).to be_a(Numeric) + expect(bm[0].elapsed).to be_a(Numeric) + expect(bm[0].worker).to be_a(String) + end + end + end + %w[ optimize_image optimize_image!