File Coverage

blib/lib/Algorithm/Classifier/IsolationForest/App/Command/bench.pm
Criterion Covered Total %
statement 81 90 90.0
branch 17 34 50.0
condition 3 5 60.0
subroutine 20 22 90.9
pod 4 5 80.0
total 125 156 80.1


line stmt bran cond sub pod time code
1             package Algorithm::Classifier::IsolationForest::App::Command::bench;
2              
3 81     81   57070 use strict;
  81         152  
  81         2741  
4 81     81   335 use warnings;
  81         129  
  81         3190  
5 81     81   416 use Algorithm::Classifier::IsolationForest ();
  81         146  
  81         1469  
6 81     81   257 use Algorithm::Classifier::IsolationForest::App -command;
  81         147  
  81         574  
7 81     81   21627 use File::Slurp qw(read_file);
  81         152  
  81         3982  
8 81     81   372 use Scalar::Util qw(looks_like_number);
  81         133  
  81         3077  
9 81     81   38328 use Time::HiRes qw(time);
  81         87954  
  81         417  
10              
11             sub opt_spec {
12             return (
13             [
14 1     1 1 24578 'm=s',
15             'Input model JSON file path/name.',
16             { 'default' => 'iforest_model.json', 'completion' => 'files' }
17             ],
18             [ 'i=s', 'Input CSV (rows of features to score).', { 'completion' => 'files' } ],
19             [ 'secs|s=f', 'Seconds per measurement (after a 0.3s warm-up).', { 'default' => 2 } ],
20             [ 't=f', 'Threshold to use for predict / score_predict_*.', { 'default' => 0.5 } ],
21             );
22             } ## end sub opt_spec
23              
24 0     0 1 0 sub abstract { 'Measure scoring throughput of a saved model on a CSV dataset' }
25              
26             sub description {
27 0     0 1 0 'Loads a model and a CSV dataset, then times each of the
28             public scoring methods over the configured wall-clock budget. Reports
29             ops-per-second for each.
30              
31             When the Inline::C backend is active the bench also runs pack_data once
32             up front and times the *_packed variants so users can see how much
33             pre-packing saves on their workload.
34              
35             Use this to answer:
36              
37             * is my Inline::C / OpenMP / SIMD build actually faster than the
38             pure-Perl fallback?
39             * how much does pack_data help on my data shape?
40             * what is the per-call throughput I can expect at production-typical
41             query-set sizes?
42             ';
43             } ## end sub description
44              
45             sub validate {
46 1     1 0 3 my ( $self, $opt, $args ) = @_;
47              
48 1 50       34 if ( !defined $opt->{'i'} ) {
    50          
    50          
49 0         0 $self->usage_error('-i has not been specified');
50             } elsif ( !-f $opt->{'i'} ) {
51 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not a file or does not exist' );
52             } elsif ( !-r $opt->{'i'} ) {
53 0         0 $self->usage_error( '-i, "' . $opt->{'i'} . '", is not readable' );
54             }
55              
56 1 50       17 if ( !-f $opt->{'m'} ) {
    50          
57 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not a file or does not exist' );
58             } elsif ( !-r $opt->{'m'} ) {
59 0         0 $self->usage_error( '-m, "' . $opt->{'m'} . '", is not readable' );
60             }
61              
62 1 50       9 if ( $opt->{'secs'} <= 0 ) {
63 0         0 $self->usage_error('--secs must be > 0');
64             }
65              
66 1 50 33     8 if ( $opt->{'t'} <= 0 || $opt->{'t'} >= 1 ) {
67 0         0 $self->usage_error('-t must satisfy 0 < t < 1');
68             }
69              
70 1         3 return 1;
71             } ## end sub validate
72              
73             # Standard bench helper: warm up briefly, then time exactly $secs of
74             # back-to-back calls. Returns ops/second.
75             sub _bench {
76 8     8   26 my ( $code, $secs ) = @_;
77 8         24 my $t0 = time();
78 8         67 $code->() while time() - $t0 < 0.3;
79 8         35 $t0 = time();
80 8         36 my $n = 0;
81 8         48 $code->(), $n++ while time() - $t0 < $secs;
82 8         47 return $n / ( time() - $t0 );
83             }
84              
85             sub _read_csv {
86 1     1   4 my ($path) = @_;
87 1         2 my @data;
88             my $expected;
89 1         2 my $line = 0;
90 1         8 for my $row ( read_file($path) ) {
91 7         232 $line++;
92 7         9 chomp $row;
93 7 50       15 next if $row =~ /^\s*$/;
94 7         25 my @f = split /,/, $row, -1;
95 7   100     18 $expected //= scalar @f;
96 7 50       11 die "line $line of '$path' has $row but expected $expected columns\n"
97             if scalar @f != $expected;
98 7         11 for my $v (@f) {
99 21 50       36 die "line $line of '$path' value '$v' is not numeric\n"
100             unless looks_like_number($v);
101             }
102 7         14 push @data, \@f;
103             } ## end for my $row ( read_file($path) )
104 1         4 return ( \@data, $expected );
105             } ## end sub _read_csv
106              
107             sub execute {
108 1     1 1 6 my ( $self, $opt, $args ) = @_;
109              
110 1         9 my $model = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
111              
112 1         5 my ( $data, $cols ) = _read_csv( $opt->{'i'} );
113             die "input CSV has $cols feature columns but model expects " . $model->{n_features} . "\n"
114 1 50       4 if $cols != $model->{n_features};
115              
116 1         3 my $n_pts = scalar @$data;
117 1         2 my $secs = $opt->{'secs'};
118 1         3 my $thresh = $opt->{'t'};
119 1 50       3 my $has_c = $Algorithm::Classifier::IsolationForest::HAS_C ? 1 : 0;
120              
121             printf "Model: %s (n_trees=%d, mode=%s, n_features=%d)\n",
122 1         32 $opt->{'m'}, scalar @{ $model->{trees} },
123 1         2 $model->{mode}, $model->{n_features};
124 1         4 printf "Input: %s (%d rows)\n", $opt->{'i'}, $n_pts;
125 1         9 printf "Budget: %.1fs per measurement (0.3s warm-up)\n", $secs;
126 1 50       5 printf "Backend: HAS_C=%d HAS_OPENMP=%d HAS_SIMD=%d\n\n",
    50          
127             $has_c,
128             $Algorithm::Classifier::IsolationForest::HAS_OPENMP ? 1 : 0,
129             $Algorithm::Classifier::IsolationForest::HAS_SIMD ? 1 : 0;
130              
131             # Pre-pack once (when C is available) so the *_packed rows measure
132             # scoring in isolation, without the per-call pack_input_xs cost.
133 1 50       7 my $packed = $has_c ? $model->pack_data($data) : undef;
134              
135             my @bench = (
136 124     124   405 [ 'score_samples' => sub { $model->score_samples($data) } ],
137 115     115   434 [ 'predict' => sub { $model->predict( $data, $thresh ) } ],
138 106     106   455 [ 'score_predict_samples' => sub { $model->score_predict_samples( $data, $thresh ) } ],
139 112     112   607 [ 'score_predict_split' => sub { my @r = $model->score_predict_split( $data, $thresh ); } ],
140 1     106   19 [ 'path_lengths' => sub { $model->path_lengths($data) } ],
  106         478  
141             );
142              
143 1 50       20 if ( defined $packed ) {
144             push @bench,
145             (
146 114     114   523 [ 'score_samples (packed)' => sub { $model->score_samples($packed) } ],
147 111     111   420 [ 'predict (packed)' => sub { $model->predict( $packed, $thresh ) } ],
148 1     111   9 [ 'score_predict_split (packed)' => sub { my @r = $model->score_predict_split( $packed, $thresh ); } ],
  111         446  
149             );
150             }
151              
152 1         4 printf " %-30s %14s %14s\n", 'method', 'ops/s', 'ms/call';
153 1         3 printf " %-30s %14s %14s\n", '-' x 30, '-' x 14, '-' x 14;
154 1         2 for my $row (@bench) {
155 8         28 my ( $label, $code ) = @$row;
156 8         25 my $rate = _bench( $code, $secs );
157 8 50       274 printf " %-30s %14.1f %14.2f\n", $label, $rate, $rate > 0 ? 1000 / $rate : 0;
158             }
159 1         411 return 1;
160             } ## end sub execute
161              
162             return 1;