Implement execution time based weight scaling.

The scaling formula is the same as libFuzzer and the legacy FuzzTest.

PiperOrigin-RevId: 823546218
diff --git a/centipede/BUILD b/centipede/BUILD
index 9e7a9d6..17550db 100644
--- a/centipede/BUILD
+++ b/centipede/BUILD
@@ -1634,6 +1634,7 @@
         ":feature",
         ":feature_set",
         ":pc_info",
+        ":runner_result",
         ":util",
         "@com_google_fuzztest//common:defs",
         "@com_google_fuzztest//common:test_util",
diff --git a/centipede/centipede.cc b/centipede/centipede.cc
index 6a1843f..e60e5aa 100644
--- a/centipede/centipede.cc
+++ b/centipede/centipede.cc
@@ -474,6 +474,7 @@
       }
     }
   }
+  corpus_.UpdateWeights(fs_, coverage_frontier_, env_.exec_time_weight_scaling);
   return batch_gained_new_coverage;
 }
 
diff --git a/centipede/centipede_flags.inc b/centipede/centipede_flags.inc
index f52e2f8..572e56d 100644
--- a/centipede/centipede_flags.inc
+++ b/centipede/centipede_flags.inc
@@ -193,6 +193,9 @@
     "If true, use weighted distribution when choosing the corpus element "
     "to mutate. This flag is mostly for Centipede developers.")
 CENTIPEDE_FLAG(
+    bool, exec_time_weight_scaling, true,
+    "If true, scale the corpus weight by the execution time of each input.")
+CENTIPEDE_FLAG(
     bool, use_coverage_frontier, false,
     "If true, use coverage frontier when choosing the corpus element to "
     "mutate. This flag is mostly for Centipede developers.")
diff --git a/centipede/centipede_test.cc b/centipede/centipede_test.cc
index deb06af..e41b171 100644
--- a/centipede/centipede_test.cc
+++ b/centipede/centipede_test.cc
@@ -620,6 +620,7 @@
   Environment env;
   env.workdir = tmp_dir.path();
   env.seed = 1;  // make the runs predictable.
+  env.exec_time_weight_scaling = false;
   env.num_runs = 100;
   env.batch_size = 10;
   env.binary = GetDataDependencyFilepath("centipede/testing/test_fuzz_target");
diff --git a/centipede/corpus.cc b/centipede/corpus.cc
index 9aaf927..4363b24 100644
--- a/centipede/corpus.cc
+++ b/centipede/corpus.cc
@@ -77,20 +77,94 @@
   return {max, total / records_.size()};
 }
 
+void Corpus::UpdateWeights(const FeatureSet& fs,
+                           const CoverageFrontier& coverage_frontier,
+                           bool scale_by_exec_time) {
+  std::vector<double> weights;
+  weights.resize(records_.size());
+  for (size_t i = 0, n = records_.size(); i < n; ++i) {
+    auto& record = records_[i];
+    const size_t unseen = fs.PruneFeaturesAndCountUnseen(record.features);
+    FUZZTEST_CHECK_EQ(unseen, 0);
+    weights[i] = fs.ComputeWeight(record.features);
+  }
+  if (scale_by_exec_time) {
+    double total_exec_time_usec = 0;
+    // For loaded corpus, we don't have the exec time recorded. Thus we don't
+    // count them when calculating the average exec time or scale their weights.
+    size_t exec_time_divider = 0;
+    for (const auto& record : records_) {
+      if (!(record.stats == ExecutionResult::Stats{})) {
+        total_exec_time_usec += record.stats.exec_time_usec;
+        ++exec_time_divider;
+      }
+    }
+    const double avg_exec_time_usec =
+        exec_time_divider == 0 ? 0 : total_exec_time_usec / exec_time_divider;
+    for (size_t i = 0; i < records_.size(); ++i) {
+      const auto& record = records_[i];
+      if (record.stats == ExecutionResult::Stats{}) {
+        continue;
+      }
+      // Same as the scaling method from libFuzzer:
+      // https://github.com/llvm/llvm-project/blob/10bec2cd9dab796d5685fa8aadf47b912e3558fe/compiler-rt/lib/fuzzer/FuzzerCorpus.h#L101
+      if (record.stats.exec_time_usec > avg_exec_time_usec * 10) {
+        weights[i] *= 0.1;
+      } else if (record.stats.exec_time_usec > avg_exec_time_usec * 4) {
+        weights[i] *= 0.25;
+      } else if (record.stats.exec_time_usec > avg_exec_time_usec * 2) {
+        weights[i] *= 0.5;
+      } else if (record.stats.exec_time_usec * 3 > avg_exec_time_usec * 4) {
+        weights[i] *= 0.75;
+      } else if (record.stats.exec_time_usec * 4 < avg_exec_time_usec) {
+        weights[i] *= 3;
+      } else if (record.stats.exec_time_usec * 3 < avg_exec_time_usec) {
+        weights[i] *= 2;
+      } else if (record.stats.exec_time_usec * 2 < avg_exec_time_usec) {
+        weights[i] *= 1.5;
+      }
+    }
+  }
+  // Normalize weights into integers in [0, 2^16].
+  double highest_weight = 0;
+  double lowest_weight = 0;
+  double weight_sum = 0;
+  for (size_t i = 0; i < records_.size(); ++i) {
+    if (i == 0 || weights[i] > highest_weight) {
+      highest_weight = weights[i];
+    }
+    if (i == 0 || weights[i] < lowest_weight) {
+      lowest_weight = weights[i];
+    }
+    weight_sum += weights[i];
+  }
+  FUZZTEST_VLOG(1) << "Recomputed weight with average: "
+                   << weight_sum / records_.size()
+                   << " highest: " << highest_weight
+                   << " lowest: " << lowest_weight;
+  FUZZTEST_CHECK(lowest_weight >= 0) << "Must not have negative corpus weight!";
+  for (size_t i = 0; i < records_.size(); ++i) {
+    // If all weights are zeros, fall back to prioritize recent corpus.
+    const double normalized_weight = highest_weight > 0
+                                         ? (weights[i] / highest_weight)
+                                         : ((i + 1.0) / records_.size());
+    weighted_distribution_.ChangeWeight(i, normalized_weight * (1 << 16));
+  }
+  weighted_distribution_.RecomputeInternalState();
+}
+
 size_t Corpus::Prune(const FeatureSet &fs,
                      const CoverageFrontier &coverage_frontier,
                      size_t max_corpus_size, Rng &rng) {
   // TODO(kcc): use coverage_frontier.
   FUZZTEST_CHECK(max_corpus_size);
   if (records_.size() < 2UL) return 0;
-  // Recompute the weights.
+
   size_t num_zero_weights = 0;
-  for (size_t i = 0, n = records_.size(); i < n; ++i) {
-    fs.PruneFeaturesAndCountUnseen(records_[i].features);
-    auto new_weight =
-        ComputeWeight(records_[i].features, fs, coverage_frontier);
-    weighted_distribution_.ChangeWeight(i, new_weight);
-    if (new_weight == 0) ++num_zero_weights;
+  for (size_t i = 0; i < records_.size(); ++i) {
+    if (weighted_distribution_.weights()[i] == 0) {
+      ++num_zero_weights;
+    }
   }
 
   // Remove zero weights and the corresponding corpus record.
diff --git a/centipede/corpus.h b/centipede/corpus.h
index 89bcd6d..0716466 100644
--- a/centipede/corpus.h
+++ b/centipede/corpus.h
@@ -44,6 +44,8 @@
   // Removes the last weight and returns it.
   // Precondition: size() > 0.
   uint64_t PopBack();
+  // Read-only weight accessor.
+  const std::vector<uint64_t>& weights() const { return weights_; }
   // Changes the existing idx-th weight to new_weight.
   void ChangeWeight(size_t idx, uint64_t new_weight);
   // Returns a random number in [0,size()), using a random number `random`.
@@ -118,6 +120,12 @@
   // Returns the number of removed elements.
   size_t Prune(const FeatureSet &fs, const CoverageFrontier &coverage_frontier,
                size_t max_corpus_size, Rng &rng);
+  // Updates the corpus weights according to `fs` and `coverage_frontier`. If
+  // `scale_by_exec_time` is set, scales the weights by the corpus execution
+  // time relative to the average.
+  void UpdateWeights(const FeatureSet& fs,
+                     const CoverageFrontier& coverage_frontier,
+                     bool scale_by_exec_time);
 
   // Accessors.
 
diff --git a/centipede/corpus_test.cc b/centipede/corpus_test.cc
index 220239d..a75ddb0 100644
--- a/centipede/corpus_test.cc
+++ b/centipede/corpus_test.cc
@@ -28,6 +28,7 @@
 #include "./centipede/feature.h"
 #include "./centipede/feature_set.h"
 #include "./centipede/pc_info.h"
+#include "./centipede/runner_result.h"
 #include "./centipede/util.h"
 #include "./common/defs.h"
 #include "./common/test_util.h"
@@ -113,6 +114,7 @@
   Add({{2}, {30, 40}});
   Add({{3}, {40, 50}});
   Add({{4}, {10, 20}});
+  corpus.UpdateWeights(fs, coverage_frontier, /*scale_by_exec_time=*/false);
 
   // Prune. Features 20 and 40 are frequent => input {0} will be removed.
   EXPECT_EQ(corpus.NumActive(), 5);
@@ -122,6 +124,8 @@
   VerifyActiveInputs({{1}, {2}, {3}, {4}});
 
   Add({{5}, {30, 60}});
+  corpus.UpdateWeights(fs, coverage_frontier, /*scale_by_exec_time=*/false);
+
   EXPECT_EQ(corpus.NumTotal(), 6);
   // Prune. Feature 30 is now frequent => inputs {1} and {2} will be removed.
   EXPECT_EQ(corpus.NumActive(), 5);
@@ -141,6 +145,55 @@
   EXPECT_EQ(corpus.NumTotal(), 6);
 }
 
+TEST(Corpus, ScalesWeightsWithExecTime) {
+  PCTable pc_table(100);
+  CFTable cf_table(100);
+  BinaryInfo bin_info{pc_table, {}, cf_table, {}, {}, {}};
+  CoverageFrontier coverage_frontier(bin_info);
+  FeatureSet fs(2, {});
+  Corpus corpus;
+
+  auto Add = [&](const CorpusRecord& record, uint64_t exec_time_usec) {
+    fs.MergeFeatures(record.features);
+    ExecutionResult::Stats stats = {};
+    stats.exec_time_usec = exec_time_usec;
+    corpus.Add(record.data, record.features, /*metadata=*/{}, stats, fs,
+               coverage_frontier);
+  };
+
+  Add({/*data=*/{0}, /*features=*/{10}}, /*exec_time_usec=*/1);
+  Add({/*data=*/{1}, /*features=*/{20}}, /*exec_time_usec=*/5);
+  Add({/*data=*/{2}, /*features=*/{30}}, /*exec_time_usec=*/9);
+
+  constexpr int kNumIter = 10000;
+  std::vector<uint64_t> freq;
+
+  Rng rng;
+  auto ComputeFreq = [&]() {
+    freq.clear();
+    freq.resize(corpus.NumActive());
+    for (int i = 0; i < kNumIter; i++) {
+      const auto& record = corpus.WeightedRandom(rng);
+      const auto id = record.data[0];
+      ASSERT_LT(id, freq.size());
+      freq[id]++;
+    }
+  };
+
+  // The weights should be equal without exec time scaling.
+  corpus.UpdateWeights(fs, coverage_frontier, /*scale_by_exec_time=*/false);
+  ComputeFreq();
+  EXPECT_NEAR(freq[0], kNumIter / 3, 100);
+  EXPECT_NEAR(freq[1], kNumIter / 3, 100);
+  EXPECT_NEAR(freq[2], kNumIter / 3, 100);
+
+  // The weights should favor {0} over {1} over {2} with exec time scaling.
+  corpus.UpdateWeights(fs, coverage_frontier, /*scale_by_exec_time=*/true);
+  ComputeFreq();
+  EXPECT_GT(freq[0], freq[1] + 100);
+  EXPECT_GT(freq[1], freq[2] + 100);
+}
+
 // Regression test for a crash in Corpus::Prune().
 TEST(Corpus, PruneRegressionTest1) {
   PCTable pc_table(100);