StorScore/lib/Recipe.pm

1055 строки
25 KiB
Perl

# StorScore
#
# Copyright (c) Microsoft Corporation
#
# All rights reserved.
#
# MIT License
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
package Recipe;
use strict;
use warnings;
use Moose;
use English;
use feature 'state';
use POSIX 'strftime';
use Symbol 'delete_package';
use Text::Balanced 'extract_bracketed';
use Cwd;
use Util;
use PreconditionRunner;
use DiskSpdParser;
use SqlioParser;
no if $PERL_VERSION >= 5.017011,
warnings => 'experimental::smartmatch';
has 'file_name' => (
is => 'ro',
isa => 'Str',
required => 1
);
has 'output_dir' => (
is => 'ro',
isa => 'Str',
required => 1
);
has 'target' => (
is => 'ro',
isa => 'Target',
required => 1
);
has 'cmd_line' => (
is => 'ro',
isa => 'CommandLine',
required => 1
);
has 'recipe_string' => (
is => 'ro',
isa => 'Str',
writer => '_recipe_string'
);
has 'steps' => (
is => 'ro',
isa => 'ArrayRef',
default => sub { [] },
);
has 'io_generator' => (
is => 'ro',
does => 'IOGenerator',
writer => '_io_generator'
);
has 'smart_runner' => (
is => 'ro',
isa => 'Maybe[SmartRunner]',
default => undef,
writer => '_smart_runner'
);
has 'logman_runner' => (
is => 'rw',
isa => 'Maybe[LogmanRunner]',
default => undef,
writer => '_logman_runner'
);
has 'power' => (
is => 'rw',
isa => 'Maybe[Power]',
default => undef,
writer => '_power'
);
has 'current_step' => (
traits => ['Counter'],
is => 'rw',
isa => 'Num',
default => 1,
handles => {
advance_current_step => 'inc',
reset_current_step => 'reset'
},
);
has 'bg_processes' => (
is => 'ro',
isa => 'ArrayRef',
default => sub { [] },
);
sub try_legalize_arguments
{
my $self = shift;
my $step_ref = shift;
my $kind = $step_ref->{'kind'};
return 1 unless $kind eq 'test';
if( $self->cmd_line->io_generator eq 'sqlio' )
{
# sqlio requires time >= 5 sec
if( $step_ref->{'run_time'} < 5 )
{
$step_ref->{'run_time'} = 5;
}
if( $step_ref->{'warmup_time'} > 0 and
$step_ref->{'warmup_time'} < 5 )
{
$step_ref->{'warmup_time'} = 5;
}
}
# N.B.: You can return zero here to skip this test
return 1;
}
sub warn_illegal_args
{
my $self = shift;
my $step_ref = shift;
my $kind = $step_ref->{'kind'};
return unless $kind eq 'test';
if( $self->cmd_line->io_generator eq 'sqlio' )
{
# sqlio requires time >= 5 sec
if( $step_ref->{'run_time'} < 5 )
{
my $msg = "\tWarning!\n";
$msg .= "\tTest run_time below minimum of 5 sec. ";
$msg .= "Modifying step ". $self->current_step . ".";
warn "$msg\n\n";
}
if( $step_ref->{'warmup_time'} > 0 and
$step_ref->{'warmup_time'} < 5 )
{
my $msg = "\tWarning!\n";
$msg .= "\tStep warmup_time below minimum of 5 sec. ";
$msg .= "Modifying step ". $self->current_step . ".";
warn "$msg\n\n";
}
}
}
sub canonicalize_step
{
my $step_ref = shift;
my $kind = $step_ref->{'kind'};
my $number = $step_ref->{'number'};
return unless $kind ~~ [qw( test precondition )];
# create read_percentage from write_percentage
$step_ref->{'read_percentage'} =
100 - $step_ref->{'write_percentage'};
# ensure description can be used as a unique filename
$step_ref->{'description'} = make_legal_filename( $step_ref->{'description'});
}
sub apply_overrides
{
my $self = shift;
my $step_ref = shift;
my $kind = $step_ref->{'kind'};
return unless $kind eq 'test';
if( defined $self->cmd_line->test_time_override )
{
$step_ref->{'run_time'} = $self->cmd_line->test_time_override;
}
if( defined $self->cmd_line->warmup_time_override )
{
$step_ref->{'warmup_time'} = $self->cmd_line->warmup_time_override;
}
}
sub make_step
{
my $kind = shift;
my $number = shift;
my @args = @_;
my %step_args;
# Every step records its kind and number
$step_args{'kind'} = $kind;
$step_args{'number'} = $number;
# These kinds use the named-parameter idiom
%step_args = ( %step_args, @args )
if $kind ~~ [qw( test initialize precondition bg_exec fg_exec )];
# idle has a single unnamed argument, the time
%step_args = ( %step_args, run_time => shift @args )
if $kind eq 'idle';
return %step_args;
}
sub handle_step
{
my $self = shift;
my $callback = shift;
my $do_warnings = shift;
my $kind = shift;
my @step_args = @_;
my $step_number = $self->current_step;
my %step = make_step( $kind, $step_number, @step_args );
canonicalize_step( \%step );
$self->apply_overrides( \%step );
$self->warn_illegal_args( \%step ) if $do_warnings;
# Keep track of the context in which we were called
my $context;
$context = 'list' if wantarray;
$context = 'void' unless defined wantarray;
$context = 'scalar' unless wantarray;
my $scalar_retval;
my @list_retval;
my $is_legal = $self->try_legalize_arguments( \%step );
if( $is_legal )
{
# Carry forward our caller's context
$callback->( %step ) if $context eq 'void';
$scalar_retval = $callback->( %step ) if $context eq 'scalar';
@list_retval = $callback->( %step ) if $context eq 'list';
$self->advance_current_step();
}
return $scalar_retval if $context eq 'scalar';
return @list_retval if $context eq 'list';
}
sub generate_header($$$$)
{
my $file_name = shift;
my $package = shift;
my $be_permissive = shift;
my $be_quiet = shift;
my $header = <<"HEADER";
package $package;
use SharedVariables;
use Util; # Allows access to by_human, etc
HEADER
if( $be_permissive )
{
$header .= "no warnings;\n";
}
else
{
$header .= "use warnings;\n";
# Hide warning "Subroutine do_workload redefined"
$header .= "no warnings 'redefine';\n";
# Hide warning "Prototype mismatch: sub do_workload"
$header .= "no warnings 'prototype';\n";
}
if( $pretend )
{
# Hide warning "Use of unitialized value"
$header .= "no warnings 'uninitialized';\n";
}
if( $be_quiet )
{
# Replace STDOUT and STDERR with NUL
$header .= "local *STDOUT; open STDOUT, '>NUL';\n";
$header .= "local *STDERR; open STDERR, '>NUL';\n";
}
# Improve the quality of diagnostic messages
$header .= qq(\n# line 1 "$file_name"\n);
return $header;
}
sub generate_footer
{
my $footer = <<"FOOTER";
1; # Return true if everything goes well
FOOTER
return $footer;
}
sub execute_recipe
{
my $self = shift;
my %args = @_;
my $callback = $args{'callback'}
// die "Must provide a callback";
my $recipe_warnings = $args{'recipe_warnings'} // 0;
my $permissive_perl = $args{'permissive_perl'} // 0;
my $quiet_stdio = $args{'quiet_stdio'} // 0;
state $eval_count = 0;
# Throw-away package to "insulate" us from the eval code
my $package = "RecipeEval" . $eval_count++;
my @step_kinds =
qw(test purge initialize precondition bg_exec fg_exec bg_killall idle);
{
no strict 'refs';
# Install our handler for each kind of step
foreach my $kind ( @step_kinds )
{
my $sym = "${package}::$kind";
*$sym = sub
{
return $self->handle_step(
$callback,
$recipe_warnings,
$kind,
@_
)
};
}
# Generate and inject an include function that adds our header
my $sym = "${package}::include";
*$sym = sub
{
my $file_name = shift;
# Emulate the behavior of Perl's require statement
foreach my $prefix (@INC)
{
if( -e "$prefix\\$file_name" )
{
$file_name = "$prefix\\$file_name";
last;
}
}
my $recipe_str = slurp_file( $file_name );
# For SSD: expand test macros to follow proper methodology
# We must do this every time we include a new file.
$recipe_str = $self->expand_test_macro( $recipe_str )
if $self->target->is_ssd();
my $eval_string;
$eval_string .=
generate_header(
$file_name,
$package,
$permissive_perl,
$quiet_stdio
);
$eval_string .= $recipe_str;
$eval_string .= generate_footer();
my $retval = eval( $eval_string );
die $EVAL_ERROR unless defined $retval;
};
}
# Allow recipes to require ".rpm" recipe modules from lib subdir
local @INC = ( @INC, "$recipes_dir\\lib" );
# Ensure that requires happen every time we eval
local %INC = %INC;
# recipes are executed with the recipes subdir as cwd
my $previous_cwd = getcwd();
chdir( $recipes_dir );
my $eval_string;
# Prefix our header to recipe string
$eval_string =
generate_header(
$self->file_name,
$package,
$permissive_perl,
$quiet_stdio
);
$eval_string .= $self->recipe_string;
$eval_string .= generate_footer();
# Eval recipe code with our hooks installed
my $retval = eval( $eval_string );
die $EVAL_ERROR unless defined $retval;
# Restore cwd
chdir( $previous_cwd );
# Delete the throw-away package
delete_package( $package );
$self->reset_current_step();
}
sub check_unique_test_descriptions
{
my $self = shift;
my %desc_hash = ();
foreach my $step (@{$self->steps})
{
my $desc = $step->{'description'};
if ( $desc && $step->{'kind'} eq "test" )
{
die "Test description '$desc' is not unique"
if ( $desc_hash{ $desc } );
$desc_hash{ $desc } = 1;
}
}
}
sub get_num_steps
{
my $self = shift;
return scalar @{$self->steps};
}
sub get_num_test_steps
{
my $self = shift;
return scalar grep { $_->{'kind'} eq 'test' } @{$self->steps};
}
sub calculate_step_run_time($)
{
my $self = shift;
my $step_ref = shift;
my $time = 0;
$time += $step_ref->{'run_time'} // 0;
$time += $step_ref->{'warmup_time'} // 0;
return $time;
}
sub estimate_run_time(;$)
{
my $self = shift;
my $est_pc_time =
shift // PreconditionRunner::NORMAL_MIN_RUN_SECONDS;
my $total = 0;
foreach my $step_ref ( @{$self->steps} )
{
$total += $self->calculate_step_run_time( $step_ref );
}
if( $self->target->do_precondition )
{
foreach my $step_ref ( @{$self->steps} )
{
if( $step_ref->{'kind'} eq 'test' )
{
$total += $est_pc_time;
}
}
}
return $total;
}
sub contains_writes()
{
my $self = shift;
foreach my $step_ref ( @{$self->steps} )
{
no warnings 'uninitialized';
return 1 if $step_ref->{'write_percentage'} > 0;
}
return 0;
}
sub prefix_target_init
{
my $self = shift;
my $input = shift;
my $str = "";
# It's important that we don't add extra newlines otherwise
# error messages may reference the wrong recipe line number.
$str .= "purge();" if $self->target->do_purge;
$str .= "initialize();" if $self->target->do_initialize;
return $str . $input;
}
sub get_test_macro_string
{
my $self = shift;
my $test_args = shift;
my $str;
# It's important that we don't add extra newlines otherwise
# error messages may reference the wrong recipe line number.
$str .= q( do { );
$str .= q( my %args = ) . $test_args . ';';
$str .= q( purge() if $args{'purge'} // 1; )
if $self->target->do_purge;
$str .= q( initialize( %args ) if $args{'initialize'} // 1; )
if $self->target->do_initialize;
$str .= q( precondition( %args ) if $args{'precondition'} // 1; )
if $self->target->do_precondition;
$str .= q( test( %args ); );
$str .= q( } );
return $str;
}
sub expand_test_macro
{
my $self = shift;
my $input = shift;
my $output;
while( $input =~ s/(.*?)\btest(\(.*)/$2/s )
{
$output .= $1;
my $test_args = extract_bracketed( $input, '()' );
die $EVAL_ERROR unless defined $test_args;
$output .= $self->get_test_macro_string( $test_args );
}
$output .= $input; # Handle remainder
return $output;
}
sub BUILD
{
my $self = shift;
my $recipe_str = slurp_file( $self->file_name );
# For HDD: do this exactly once before running the recipe
$recipe_str = $self->prefix_target_init( $recipe_str )
if $self->target->is_hdd();
# For SSD: expand test macros to follow proper methodology
$recipe_str = $self->expand_test_macro( $recipe_str )
if $self->target->is_ssd();
$self->_recipe_string( $recipe_str );
# Phase 1: Parse the recipe to estimate runtime, etc.
#
# In this case, our callback merely records the step's details,
# so we aren't really "executing" much of anything. Here we
# generate recipe warnings, and we also hide all Perl warnings,
# such as undefined variables.
$self->execute_recipe(
recipe_warnings => 1,
permissive_perl => 1,
quiet_stdio => 1,
callback => sub { push @{$self->steps}, {@_}; }
);
}
sub get_time_message
{
my $self = shift;
my $step_ref = shift;
my $msg = "";
# Only do this for "kinds" that take a while
if( $step_ref->{'kind'} ~~ [qw( test idle )] )
{
my $start_time = strftime "%I:%M:%S %p", localtime( time() );
my $run_time = $self->calculate_step_run_time( $step_ref );
my $end_time =
strftime "%I:%M:%S %p", localtime( time() + $run_time );
$msg = "$start_time -> $end_time";
}
return $msg;
}
sub get_announcement_style
{
my $step_ref = shift;
my $kind = $step_ref->{'kind'};
# These step "kinds" announce their own progress
return 'internal'
if $kind ~~ [qw( purge precondition initialize )];
return 'external';
}
sub get_announcement_message
{
my $step_ref = shift;
my $msg;
my $command = $step_ref->{'command'};
if( $step_ref->{'kind'} eq 'test' )
{
my $short_pattern =
$step_ref->{'access_pattern'} eq "random" ? "rnd" : "seq";
$msg = sprintf( " %4s, %3s, %3d%% wri, QD=%3d, Used=%3d%%",
$step_ref->{'block_size'},
$short_pattern,
$step_ref->{'write_percentage'},
$step_ref->{'queue_depth'},
$step_ref->{'percent_used'} // 100
);
}
elsif( $step_ref->{'kind'} eq 'idle' )
{
$msg = "Idling";
}
elsif( $step_ref->{'kind'} eq 'bg_exec' )
{
$msg = qq{Executing "$command" in the background};
}
elsif( $step_ref->{'kind'} eq 'fg_exec' )
{
$msg = qq{Executing "$command" in the foreground};
}
elsif( $step_ref->{'kind'} eq 'bg_killall' )
{
$msg = "Killing all background processes";
}
return $msg;
}
sub bg_killall
{
my $self = shift;
foreach my $proc ( @{$self->bg_processes} )
{
my $pid = $proc->{'pid'};
kill_task( $pid );
}
@{$self->bg_processes} = ();
}
sub run_step
{
my $self = shift;
my $step_ref = shift;
my $kind = $step_ref->{'kind'};
# Keep track of the context in which we were called
my $context;
$context = 'list' if wantarray;
$context = 'void' unless defined wantarray;
$context = 'scalar' unless wantarray;
my $scalar_retval;
my @list_retval;
my $num_step_digits = length( $self->get_num_steps );
my $progress = sprintf(
"%${num_step_digits}d/%${num_step_digits}d: ",
$self->current_step, $self->get_num_steps
);
my $announcement_style = get_announcement_style( $step_ref );
if( $announcement_style eq 'external' )
{
my $announce = get_announcement_message( $step_ref );
my $time = $self->get_time_message( $step_ref );
my $cols_remaining = 80;
$cols_remaining -=
length( $progress ) +
length( $announce ) +
length( $time ) + 1;
my $pad = ' ' x $cols_remaining;
print $progress . $announce . $pad . $time;
}
if( $kind eq 'purge' )
{
$self->target->purge(
msg_prefix => $progress,
);
}
elsif( $kind eq 'initialize' )
{
$self->target->initialize(
msg_prefix => $progress,
test_ref => $step_ref
);
}
elsif( $kind eq 'precondition' )
{
$self->target->precondition(
msg_prefix => $progress,
output_dir => $self->output_dir,
test_ref => $step_ref
);
}
elsif( $kind eq 'test' )
{
$self->target->prepare( $step_ref )
unless $self->target->is_prepared();
unless( $pretend or $self->cmd_line->raw_disk )
{
die "Attempt to test non-existent target file?\n"
unless -e $self->target->file_name;
}
my $desc = $step_ref->{'description'};
# Record statistics about this volume
my $wmic_runner = WmicRunner->new(
target => $self->target,
output_dir => $self->output_dir
);
$wmic_runner->collect( "wmic-$desc.txt" );
# Record background activity, if any, during this test
if( scalar @{$self->bg_processes} > 0 )
{
open my $FH, '>', $self->output_dir . "\\background-$desc.txt";
foreach my $proc ( @{$self->bg_processes} )
{
print $FH $proc->{'description'} . "\n";
print $FH $proc->{'command'} . "\n";
print $FH $proc->{'pid'} . "\n\n";
}
close $FH;
}
if( $step_ref->{'warmup_time'} > 0 )
{
$self->io_generator->run( $step_ref, 'warmup' );
}
$self->smart_runner->collect(
file_name => "smart-before-$desc.txt",
output_dir => $self->output_dir,
)
if defined $self->smart_runner;
if( defined $self->logman_runner )
{
$self->logman_runner->description( $desc );
$self->logman_runner->start();
}
if( defined $self->power )
{
$self->power->description( $desc );
$self->power->start();
}
$self->io_generator->run( $step_ref, 'test' );
$self->smart_runner->collect(
file_name => "smart-after-$desc.txt",
output_dir => $self->output_dir,
)
if defined $self->smart_runner;
$self->logman_runner->stop() if defined $self->logman_runner;
$self->power->stop() if defined $self->power;
# If test() called in list context, parse and return results
if( $context eq 'list' )
{
my $iogen_parser;
$iogen_parser = DiskSpdParser->new()
if $self->cmd_line->io_generator eq 'diskspd';
$iogen_parser = SqlioParser->new()
if $self->cmd_line->io_generator eq 'sqlio';
# ISSUE-REVIEW: is this guaranteed to be correct?
my $iogen_outfile = $self->output_dir . "\\test-$desc.txt";
open my $IOGEN_OUT, "<$iogen_outfile"
or die "Error opening $iogen_outfile";
my %stats;
$iogen_parser->parse( $IOGEN_OUT, \%stats );
@list_retval = %stats;
close $IOGEN_OUT;
}
# Allow test to specify that output files be discarded
my $discard_results = $step_ref->{'discard_results'} // 0;
unlink( glob( $self->output_dir . "\\*$desc*" ) )
if $discard_results;
}
elsif( $kind eq 'idle' )
{
my $run_time = $step_ref->{'run_time'};
sleep( $run_time ) unless $pretend;
}
elsif( $kind eq 'bg_exec' || $kind eq 'fg_exec' )
{
# ISSUE-REVIEW:
#
# Ensure that the target file exists to support stuff like
# purge();
# bg_exec( do something to target file here );
#
# Does this make sense in a general purpose exec (fg & bg)?
$self->target->prepare( $step_ref )
unless $self->target->is_prepared();
unless( $pretend )
{
die "Attempt to test non-existent target file?\n"
unless -e $self->target->file_name;
}
my $command = $step_ref->{'command'};
my $pid = execute_task(
$command,
background => ($kind eq 'bg_exec'),
new_window => 1
);
if( $kind eq 'bg_exec')
{
my $description = $step_ref->{'description'};
push @{$self->bg_processes}, {
pid => $pid,
command => $command,
description => $description
};
}
}
elsif( $kind eq "bg_killall" )
{
$self->bg_killall();
}
print "\n" if $announcement_style eq 'external';
return $scalar_retval if $context eq 'scalar';
return @list_retval if $context eq 'list';
}
sub run
{
my $self = shift;
my %args = @_;
$self->_io_generator( $args{'io_generator'} );
$self->_smart_runner( $args{'smart_runner'} );
$self->_logman_runner( $args{'logman_runner'} );
$self->_power( $args{'power'} );
# Phase 2: Execute the recipe
#
# Our callback calls run_step(), so we will truly /execute/ the recipe.
# This time we do not emit recipe warnings, but we do enable most Perl
# warnings.
$self->execute_recipe(
recipe_warnings => 0,
permissive_perl => 0,
callback => sub
{
unless( ($self->current_step < $self->cmd_line->start_on_step) or
($self->current_step > $self->cmd_line->stop_on_step) )
{
return $self->run_step( {@_} );
}
# Maintain proper context on skipped steps.
# Avoids "Odd number of elements in hash assignment" warning.
return () if wantarray;
}
);
# kill any leftover background processes
$self->bg_killall();
}
sub warn_expected_run_time
{
my $self = shift;
my $num_pc =
$self->target->do_precondition ? $self->get_num_test_steps() : 0;
my $est_pc_time = 0;
if( $num_pc > 0 )
{
if( $self->cmd_line->demo_mode )
{
$est_pc_time =
PreconditionRunner::QUICK_TEST_MIN_RUN_SECONDS;
}
else
{
$est_pc_time =
PreconditionRunner::NORMAL_MIN_RUN_SECONDS;
}
}
my $time_string =
seconds_to_human(
$self->estimate_run_time( $est_pc_time )
);
print "\tRun time will be >= $time_string.\n";
if( $self->target->do_initialize )
{
print "\tThis does not include target init ";
if( $self->target->is_ssd )
{
print "(2 overwrites per-test).\n";
}
else
{
print "(1 overwrite).\n";
}
}
if( $num_pc > 0 )
{
my $human_pc_time = seconds_to_human( $est_pc_time );
print "\tRecipe includes $num_pc " .
"preconditions, each >= $human_pc_time.\n"
}
print "\n";
}
no Moose;
__PACKAGE__->meta->make_immutable;
1;