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 22     22   15280 use strict;
  22         47  
  22         738  
4 22     22   114 use warnings;
  22         38  
  22         845  
5 22     22   96 use Algorithm::Classifier::IsolationForest ();
  22         34  
  22         407  
6 22     22   108 use Algorithm::Classifier::IsolationForest::App -command;
  22         36  
  22         168  
7 22     22   5756 use File::Slurp qw(read_file);
  22         42  
  22         1173  
8 22     22   100 use Scalar::Util qw(looks_like_number);
  22         35  
  22         926  
9 22     22   10713 use Time::HiRes qw(time);
  22         23506  
  22         103  
10              
11             sub opt_spec {
12             return (
13             [
14 1     1 1 29714 '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 5 my ( $self, $opt, $args ) = @_;
47              
48 1 50       54 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       24 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       13 if ( $opt->{'secs'} <= 0 ) {
63 0         0 $self->usage_error('--secs must be > 0');
64             }
65              
66 1 50 33     17 if ( $opt->{'t'} <= 0 || $opt->{'t'} >= 1 ) {
67 0         0 $self->usage_error('-t must satisfy 0 < t < 1');
68             }
69              
70 1         5 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         28 my $t0 = time();
78 8         45 $code->() while time() - $t0 < 0.3;
79 8         32 $t0 = time();
80 8         19 my $n = 0;
81 8         66 $code->(), $n++ while time() - $t0 < $secs;
82 8         42 return $n / ( time() - $t0 );
83             }
84              
85             sub _read_csv {
86 1     1   2 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         233 $line++;
92 7         11 chomp $row;
93 7 50       17 next if $row =~ /^\s*$/;
94 7         14 my @f = split /,/, $row, -1;
95 7   100     15 $expected //= scalar @f;
96 7 50       19 die "line $line of '$path' has $row but expected $expected columns\n"
97             if scalar @f != $expected;
98 7         9 for my $v (@f) {
99 21 50       39 die "line $line of '$path' value '$v' is not numeric\n"
100             unless looks_like_number($v);
101             }
102 7         11 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 9 my ( $self, $opt, $args ) = @_;
109              
110 1         12 my $model = Algorithm::Classifier::IsolationForest->load( $opt->{'m'} );
111              
112 1         7 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         2 my $n_pts = scalar @$data;
117 1         3 my $secs = $opt->{'secs'};
118 1         3 my $thresh = $opt->{'t'};
119 1 50       4 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         19 $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         10 printf "Budget: %.1fs per measurement (0.3s warm-up)\n", $secs;
126 1 50       7 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       15 my $packed = $has_c ? $model->pack_data($data) : undef;
134              
135             my @bench = (
136 7113     7113   16009 [ 'score_samples' => sub { $model->score_samples($data) } ],
137 6012     6012   14156 [ 'predict' => sub { $model->predict( $data, $thresh ) } ],
138 5312     5312   13082 [ 'score_predict_samples' => sub { $model->score_predict_samples( $data, $thresh ) } ],
139 6155     6155   15719 [ 'score_predict_split' => sub { my @r = $model->score_predict_split( $data, $thresh ); } ],
140 1     6814   20 [ 'path_lengths' => sub { $model->path_lengths($data) } ],
  6814         16295  
141             );
142              
143 1 50       3 if ( defined $packed ) {
144             push @bench,
145             (
146 8531     8531   19352 [ 'score_samples (packed)' => sub { $model->score_samples($packed) } ],
147 8155     8155   18974 [ 'predict (packed)' => sub { $model->predict( $packed, $thresh ) } ],
148 1     7495   12 [ 'score_predict_split (packed)' => sub { my @r = $model->score_predict_split( $packed, $thresh ); } ],
  7495         17318  
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         3 for my $row (@bench) {
155 8         26 my ( $label, $code ) = @$row;
156 8         29 my $rate = _bench( $code, $secs );
157 8 50       264 printf " %-30s %14.1f %14.2f\n", $label, $rate, $rate > 0 ? 1000 / $rate : 0;
158             }
159 1         370 return 1;
160             } ## end sub execute
161              
162             return 1;