#!/usr/bin/env ruby # # run.rb: Benchmark OpenSSL ciphers on several systems, then do # the following: # # * aggregate the results as CSV files # * create SVG charts of the results # * generate HTML fragments for the SVG results # # Usage: ./run.rb config.yaml # # See included `config.yaml` for configuration options # require 'fileutils' require 'yaml' require 'csv' require 'logger' require 'json' require 'luigi-template' module PiBench # block sizes SIZES = %w{16 64 256 1024 8192 16384} # # list of tests to run. # TESTS = [{ name: 'lscpu', exec: %w{lscpu}, }, { name: 'version', exec: %w{openssl version}, }, { name: 'speed', exec: %w{openssl speed -mr -evp}, type: 'algos', # run test for each algorithm }] # # Default list of EVP algorithms. # # removed sha3-256 because it is not supported in older versions of # openssl ALGOS = %w{ blake2b512 blake2s256 sha256 sha512 aes-128-cbc aes-192-cbc aes-256-cbc } # # Map of type to column headers. # # Used to generate CSV and HTML table column headers. # COLS = { all: [{ id: 'host', name: 'host', }, { id: 'algo', name: 'algo', }, { id: 'size', name: 'size', }, { id: 'speed', name: 'speed', }], algo: [{ id: 'host', name: 'host', }, { id: 'size', name: 'size', }, { id: 'speed', name: 'speed', }], # columns for csvs/hosts.csv and html/hosts.html hosts: [{ id: 'name', name: 'Name', }, { id: 'arch', name: 'Architecture', }, { id: 'text', name: 'Description', }, { id: 'openssl', name: 'OpenSSL Version', }], }.freeze # # Architecture strings. # ARCHS = { all: { name: 'All', text: %{

Test results for all systems. Note that the x86-64 systems include AES-NI and SHA2 hardware acceleration.

}.strip, }, arm: { name: 'Pis', text: %{

Test results for Raspberry Pi systems only.

}.strip, }, x86: { name: 'x86-64', text: %{

Test results for x86-64 systems only.

}.strip, }, }.freeze LINKS = [{ href: 'csvs/all-all.csv', title: 'Download all results as a CSV file.', text: 'Download Results (CSV)', }, { href: 'https://github.com/pablotron/p4-bench', title: 'View code on GitHub.', text: 'GitHub Page', }].freeze TEMPLATES = Luigi::Cache.new({ index: %{ %{title|h}

%{title|h}

This page contains OpenSSL benchmarks across several Raspberry Pis and x86-64 systems generated using the openssl speed command.

Systems

Test system details.

%{hosts}
%{sections}
}.strip, all: %{ %{cols} %{rows}
}.strip, col: %{ %{name|h} }.strip, row: %{ %{row} }.strip, cell: %{ %{text|h} }.strip, svg_title: %{ Speed Test Results (Systems: %{arch|h}, Algorithm: %{algo|h}) }.strip, svg: %{ %{name|h} }.strip, link: %{
  • %{text|h}
  • }.strip, section: %{

    Results: %{name|h}

    %{text} %{svgs}
    }.strip, }) # # Background process mixin. # module BG # # Generate SSH command. # def ssh(host, cmd) ['/usr/bin/ssh', host, *cmd] end # # Spawn background task that writes standard output to given file # and return the PID. # def bg(out, cmd) @log.debug('bg') do JSON.unparse({ out: out, cmd: cmd, }) end spawn(*cmd, in: '/dev/null', out: out, err: '/dev/null', close_others: true ) end end # # Process a map of hosts to background command queues in parallel. # class HostQueue include BG # # Allow singleton invocation. # def self.run(log, queues) new(log, queues).run end def initialize(log, queues) @log, @queues = log, queues @pids = {} end # # Block until all commands have been run successfully on all hosts, # or until any command on any host fails. # def run @queues.keys.each do |host| drain(host) end until done? @log.debug('HostQueue#run') do 'Process.wait(): %s ' % [JSON.unparse({ pids: @pids, queues: @queues, })] end # get pid and status of child that exited pid = Process.wait(-1, Process::WUNTRACED) st = $? # map pid to host if host = @pids.delete(pid) if st.success? # log success @log.debug('HostQueue#run') do 'command done: %s' % [JSON.unparse({ host: host, pid: pid, })] end else # build error message err = 'command failed: %s' % [JSON.unparse({ host: host, pid: pid, })] # log and raise error @log.fatal('HostQueue#run') { err } raise err end # start next command from host drain(host) end end end private # # Start next queued command from given host. # def drain(host) # get queue for host queue = @queues[host] return unless queue && queue.size > 0 # drain host queue of commands that can be skipped while queue.size > 0 && File.exists?(queue.first[:out]) @log.debug('HostQueue#drain') do 'skipping command: %s' % [JSON.unparse({ host: host, row: queue.first })] end # remove skipped command queue.shift end if row = queue.shift # invoke task, grab pid pid = bg(row[:out], ssh(host, row[:cmd])) # log task @log.debug('HostQueue#drain') do JSON.unparse({ host: host, row: row, pid: pid, }) end # add pid to pid to host map @pids[pid] = host end nil end def done? @pids.size == 0 && @queues.keys.all? { |k| @queues[k].size == 0 } end end class Runner include BG # # Allow one-shot invocation. # def initialize(config) # cache config @config = config # get log level log_level = (@config['log_level'] || 'info').upcase # create logger and set log level @log = ::Logger.new(STDERR) @log.level = ::Logger.const_get(log_level) @log.debug { "log level = #{log_level}" } end # # Run benchmarks (if necessary) and generate output CSVs and SVGs. # def run # create output directories make_output_dirs # connect to hosts in background, wait for all to complete spawn_benchmarks # generate CSVs, SVGs, and HTML fragments, wait for all to # complete, and return HTML fragments by section html = save(parse_data).merge({ # generate hosts.csv and hosts html hosts: make_hosts, }) # save index.html save_index_html(html) end private # # Create output directories # def make_output_dirs dirs = (%w{html csvs svgs} + @config['hosts'].map { |row| 'hosts/%s' % [row['name']] }).map { |dir| '%s/%s' % [out_dir, dir] } @log.debug { 'creating output dirs: %s' % [dirs.join(', ')] } FileUtils.mkdir_p(dirs) end # # Spawn benchmark tasks in background and block until they are # complete. # def spawn_benchmarks # build map of hosts to commands queues = Hash.new { |h, k| h[k] = [] } # populate map @config['hosts'].each do |row| TESTS.each do |test| case test[:type] when 'algos' # queue test command for each algorithm (@config['algos'] || ALGOS).each do |algo| queues[row['host']] << { cmd: [*test[:exec], algo], out: '%s/hosts/%s/%s-%s.txt' % [ out_dir, row['name'], test[:name], algo, ], } end else # queue command for test queues[row['host']] << { cmd: test[:exec], out: '%s/hosts/%s/%s.txt' % [ out_dir, row['name'], test[:name], ] } end end end # block until all task queues have completed successfully HostQueue.run(@log, queues) end # # Parse openssl benchmark data into a map of algorithm => rows # def parse_data @config['hosts'].reduce(Hash.new do |h, k| h[k] = Hash.new do |h2, k2| h2[k2] = { max: 0, rows: [] } end end) do |r, row| # build absolute path to openssl speed data files glob = '%s/hosts/%s/speed-*.txt' % [out_dir, row['name']] # parse speed files Dir[glob].each do |path| # get arch arch = row['pi'] ? 'arm' : 'x86' # parse file lines = File.readlines(path).select { |line| # match on result rows line =~ /^\+F:/ }.each do |line| # split to results vals = line.strip.split(':') # build algorithm name algo = vals[2].gsub(/\s+/, '-') # walk block sizes SIZES.each_with_index do |size, i| 4.times.map { |j| { algo: ((j & 1) != 0) ? 'all' : algo, arch: ((j & 2) != 0) ? 'all' : arch, } }.each do |agg| val = vals[i + 3].to_f max = r[agg[:arch]][agg[:algo]][:max] r[agg[:arch]][agg[:algo]][:max] = val if val > max r[agg[:arch]][agg[:algo]][:rows] << (if agg[:algo] == 'all' # build row for all-*.csv [row['name'], algo, size, val] else # row for algo-specific CSV [row['name'], size, val] end) end end end end r end end # # Generate CSVs, SVGs, and HTML fragments, then return map of arch # to HTML fragments. # def save(all_data) save_csvs(all_data) svgs = save_svgs(all_data) make_html(svgs) end # # Save all CSVs. # def save_csvs(all_data) all_data.each do |arch, algos| algos.each do |algo, data| save_csv( '%s/csvs/%s-%s.csv' % [out_dir, arch, algo], COLS[(algo == 'all') ? :all : :algo].map { |col| col[:id] }, data[:rows] ) end end end # # Save SVGs and return a lut of arch to svgs. # def save_svgs(all_data) pids = [] svgs = Hash.new { |h, k| h[k] = [] } # generate svgs data = all_data.keys.each do |arch| # omit algo=all svgs because they are too large all_data[arch].keys.select { |algo| algo != 'all' }.each do |algo| # get rows rows = all_data[arch][algo][:rows] # get maximum value for plot max = get_max_value(all_data, arch, algo) # build svg data svg = make_svg(arch, algo, max, rows) # add to svg lut svgs[arch] << { algo: algo, path: svg[:path], title: svg[:title], } # save in background and add pid list of pids pids << save_svg(svg) end end # wait for background tasks to complete join('save_svgs', pids) # return svg data lut svgs end # # Generate HTML fragments for each architecture. # def make_html(svgs) svgs.keys.reduce({}) do |r, arch| r[arch] = svgs[arch].sort { |a, b| a[:path] <=> b[:path] }.map { |row| TEMPLATES[:svg].run({ path: 'svgs/%s' % [File.basename(row[:path])], name: row[:title], }) }.join r end end # # Generate CSV and HTML table of hosts and return generated HTML. # def make_hosts save_hosts_csv make_hosts_html end # # Generate out/csvs/hosts.csv and return thread. # def save_hosts_csv save_csv( '%s/csvs/hosts.csv' % [out_dir], COLS[:hosts].map { |col| col[:name] }, @config['hosts'].map { |row| COLS[:hosts].map { |col| row[col[:id]] } } ) end # # Generate and return hosts HTML. # def make_hosts_html TEMPLATES[:all].run({ cols: COLS[:hosts].map { |col| TEMPLATES[:col].run(col) }.join, rows: @config['hosts'].map { |row| path = '%s/hosts/%s/version.txt' % [out_dir, row['name']] row.merge({ 'openssl' => File.read(path).strip, }) }.map { |row| TEMPLATES[:row].run({ row: COLS[:hosts].map { |col| TEMPLATES[:cell].run({ text: row[col[:id]] }) }.join }) }.join, }) end # # Generate and write out/index.html. # def save_index_html(html) File.write('%s/index.html' % [out_dir], TEMPLATES[:index].run({ title: 'OpenSSL Benchmark Results', hosts: html[:hosts], links: LINKS.map { |row| TEMPLATES[:link].run(row) }.join, sections: %i{all arm x86}.map { |arch| TEMPLATES[:section].run({ svgs: html[arch.to_s], }.merge(ARCHS[arch])) }.join, })) end # # Save CSV file. # def save_csv(path, cols, rows) CSV.open(path, 'wb') do |csv| # write headers csv << cols # write rows rows.each do |row| csv << row end end end # # Build data for SVG. # def make_svg(arch, algo, max, rows) { # build output path path: '%s/svgs/%s-%s.svg' % [out_dir, arch, algo], # build title title: TEMPLATES[:svg_title].run({ arch: ARCHS[arch.intern][:name], algo: algo, }), # output image size (in inches) size: [6.4 * 2, 4.8 * 2], # output image DPI dpi: 100, # font sizes (in points) fontsize: { yticks: 10, title: 14, }, # calculate xlimit (round up to nearest 50) xlimit: (max / (1048576 * 50.0)).ceil * 50, xlabel: 'Speed (MB/s)', # rows (sorted in reverse order) rows: rows.map { |row| [ '%s (%d bytes)' % [row[0], row[1].to_i], row[2].to_f / 1048576, ] }.reverse, } end # # Render SVG in background and return SVG path, title, and PID. # def save_svg(svg) # invoke plot in background, return pid bg('/dev/null', [ # absolute path to python '/usr/bin/python3', # build path to plot.py '%s/plot.py' % [__dir__], # build chart json data JSON.unparse(svg), ]) end # # get maximum value depending for chart # def get_max_value(data, arch, algo) is_aes = is_aes?(algo) data['all'].keys.select { |k| is_aes == is_aes?(k) }.map { |k| data[arch][k][:max] }.reduce(0) { |rm, v| v > rm ? v : rm } end # # Is the given algorithm AES? # def is_aes?(algo) @is_aes_cache ||= {} @is_aes_cache[algo] ||= !!(algo =~ /^aes/) end # # join set of PIDs together # def join(set_name, pids = []) @log.debug('join') do JSON.unparse({ set_name: set_name, pids: pids, }) end # wait for all tasks to complete and check for errors errors = pids.reduce([]) do |r, pid| Process.wait(pid) $?.success? ? r : (r << pid) end if errors.size > 0 # build error message err = 'pids failed: %s' % [JSON.unparse({ set_name: set_name, pids: errors, })] # log and raise error @log.fatal('join') { err } raise err end end # # Get output directory. # def out_dir @config['out_dir'] end end # # Allow one-shot invocation. # def self.run(app, args) # check command-line arguments unless config_path = args.shift raise "Usage: #{app} config.yaml" end Runner.new(load_config(config_path)).run end # # Load config file and check for required keys. # def self.load_config(path) # read/check config ::YAML.load_file(path).tap do |r| # check for required config keys missing = %w{out_dir hosts}.reject { |key| r.key?(key) } raise "Missing required config keys: #{missing}" if missing.size > 0 end end end # allow cli invocation PiBench.run($0, ARGV) if __FILE__ == $0