Bug 1369604: Replace features HashMap with indexing into an array. r=liuche
authorMichael Comella <michael.l.comella@gmail.com>
Wed, 26 Jul 2017 17:16:14 -0700
changeset 420718 fefc5d61b05107bd4aff588ab2c02e9976aa7d27
parent 420717 2c33020e12d1ff0b279758bf938892bbf0097fec
child 420719 e755f4ab80fdc2f6f9571854502034b1cb930ff1
push id7566
push usermtabara@mozilla.com
push dateWed, 02 Aug 2017 08:25:16 +0000
treeherdermozilla-beta@86913f512c3c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersliuche
bugs1369604
milestone56.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1369604: Replace features HashMap with indexing into an array. r=liuche After the previous changeset, some numbers stood out: - HighlightsRanking.extractFeatures: 44.9% - HighlightCandidate.getFeatureValue: 19.4% - Collections.secondaryHash: 17.3% - HashMap.get: 11.7% My hypothesis was that our HighlightCandidate.features implementation was slow: it was mapping FeatureNames -> values in a HashMap but HashMap look-ups are slower than a direct memory access. I replaced the implementation with a direct access from an array - about as fast as we can get. This encouraged me to make some changes with the following benefits: - Rewrote HighlightsRanking.normalize to save iterations and allocations. - Rm code from HighlightsRanking.scoreEntries: we no longer need to iterate to construct the filtered items, we just index directly into the list - Rewrote HighlightsRanking.decay(), which I think is a little clearer now. - Saved a few iterator/object allocations inside inner loops in places. The tests pass and we have coverage for the normalize changes but not for scoreEntries. --- For perf, my changes affected multiple methods so the percentages are no longer reliable but I can verify absolute runtime changes. I ran three tests, the best of which showed an overall 33% runtime compared to the previous changeset and the other two profiles showed a 66% overall runtime. In particular, for the middle run, the changes for affected methods go from X microseconds to Y microseconds: - Features.get: 3,554,796 -> 322,145 - secondaryHash: 3,165,785 -> 35,253 - HighlightsRanking.normalize: 6,578,481 -> 1,734,078 - HighlightsRanking.scoreEntries: 3,017,272 -> 448,300 As far as I know, my changes should not have introduced any new inefficiencies to the code. MozReview-Commit-ID: 9THXe8KqBbB
mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidate.java
mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightsRanking.java
mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/ranking/TestHighlightsRanking.java
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidate.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightCandidate.java
@@ -2,47 +2,77 @@
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.activitystream.ranking;
 
 import android.database.Cursor;
 import android.net.Uri;
+import android.support.annotation.IntDef;
 import android.support.annotation.Nullable;
-import android.support.annotation.StringDef;
 import android.support.annotation.VisibleForTesting;
-
-import org.mozilla.gecko.activitystream.ranking.RankingUtils.Func1;
 import org.mozilla.gecko.activitystream.homepanel.model.Highlight;
 
-import java.util.HashMap;
-import java.util.Map;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
 
 /**
  * A highlight candidate (Highlight object + features). Ranking will determine whether this is an
  * actual highlight.
  */
 /* package-private */ class HighlightCandidate {
-    /* package-private */ static final String FEATURE_AGE_IN_DAYS = "ageInDays";
-    /* package-private */ static final String FEATURE_IMAGE_COUNT = "imageCount";
-    /* package-private */ static final String FEATURE_DOMAIN_FREQUENCY = "domainFrequency";
-    /* package-private */ static final String FEATURE_VISITS_COUNT = "visitsCount";
-    /* package-private */ static final String FEATURE_BOOKMARK_AGE_IN_MILLISECONDS = "bookmarkageInDays";
-    /* package-private */ static final String FEATURE_DESCRIPTION_LENGTH = "descriptionLength";
-    /* package-private */ static final String FEATURE_PATH_LENGTH = "pathLength";
-    /* package-private */ static final String FEATURE_QUERY_LENGTH = "queryLength";
-    /* package-private */ static final String FEATURE_IMAGE_SIZE = "imageSize";
+
+    // Features we score over for Highlight results - see Features class for more details & usage.
+    @Retention(RetentionPolicy.SOURCE)
+    @IntDef({FEATURE_AGE_IN_DAYS, FEATURE_BOOKMARK_AGE_IN_MILLISECONDS, FEATURE_DESCRIPTION_LENGTH,
+            FEATURE_DOMAIN_FREQUENCY, FEATURE_IMAGE_COUNT, FEATURE_IMAGE_SIZE, FEATURE_PATH_LENGTH,
+            FEATURE_QUERY_LENGTH, FEATURE_VISITS_COUNT})
+    /* package-private */ @interface FeatureName {}
+
+    // IF YOU ADD A FIELD, INCREMENT `FEATURE_COUNT`! For a perf boost, we use these ints to index into an array and
+    // FEATURE_COUNT tracks the number of features we have and thus how big the array needs to be.
+    private static final int FEATURE_COUNT = 9; // = the-greatest-feature-index + 1.
+    /* package-private */ static final int FEATURE_AGE_IN_DAYS = 0;
+    /* package-private */ static final int FEATURE_BOOKMARK_AGE_IN_MILLISECONDS = 1;
+    /* package-private */ static final int FEATURE_DESCRIPTION_LENGTH = 2;
+    /* package-private */ static final int FEATURE_DOMAIN_FREQUENCY = 3;
+    /* package-private */ static final int FEATURE_IMAGE_COUNT = 4;
+    /* package-private */ static final int FEATURE_IMAGE_SIZE = 5;
+    /* package-private */ static final int FEATURE_PATH_LENGTH = 6;
+    /* package-private */ static final int FEATURE_QUERY_LENGTH = 7;
+    /* package-private */ static final int FEATURE_VISITS_COUNT = 8;
 
-    @StringDef({FEATURE_AGE_IN_DAYS, FEATURE_IMAGE_COUNT, FEATURE_DOMAIN_FREQUENCY, FEATURE_VISITS_COUNT,
-            FEATURE_BOOKMARK_AGE_IN_MILLISECONDS, FEATURE_DESCRIPTION_LENGTH, FEATURE_PATH_LENGTH,
-            FEATURE_QUERY_LENGTH, FEATURE_IMAGE_SIZE})
-    public @interface Feature {}
+    /**
+     * A data class for accessing Features values. It acts as a map from FeatureName -> value:
+     * <pre>
+     *   Features features = new Features();
+     *   features.put(FEATURE_AGE_IN_DAYS, 30);
+     *   double value = features.get(FEATURE_AGE_IN_DAYS);
+     * </pre>
+     *
+     * This data is accessed frequently and needs to be performant. As such, the implementation is a little fragile
+     * (e.g. we could increase type safety with enums and index into the backing array with Enum.ordinal(), but it
+     * gets called enough that it's not worth the performance trade-off).
+     */
+    /* package-private */ static class Features {
+        private final double[] values = new double[FEATURE_COUNT];
 
-    @VisibleForTesting final Map<String, Double> features;
+        Features() {}
+
+        /* package-private */ double get(final @FeatureName int featureName) {
+            return values[featureName];
+        }
+
+        /* package-private */ void put(final @FeatureName int featureName, final double value) {
+            values[featureName] = value;
+        }
+    }
+
+    @VisibleForTesting final Features features = new Features();
     private Highlight highlight;
     private @Nullable String imageUrl;
     private String host;
     private double score;
 
     public static HighlightCandidate fromCursor(final Cursor cursor, final HighlightCandidateCursorIndices cursorIndices)
             throws InvalidHighlightCandidateException {
         final HighlightCandidate candidate = new HighlightCandidate();
@@ -141,17 +171,16 @@ import java.util.Map;
 
         // Only hierarchical URIs support getQueryParameterNames.
         candidate.features.put(
                 FEATURE_QUERY_LENGTH,
                 (double) uri.getQueryParameterNames().size());
     }
 
     @VisibleForTesting HighlightCandidate() {
-        features = new HashMap<>();
     }
 
     /* package-private */ double getScore() {
         return score;
     }
 
     /* package-private */ void updateScore(double score) {
         this.score = score;
@@ -169,36 +198,12 @@ import java.util.Map;
     /* package-private */ String getImageUrl() {
         return imageUrl;
     }
 
     /* package-private */ Highlight getHighlight() {
         return highlight;
     }
 
-    /* package-private */ double getFeatureValue(@Feature String feature) {
-        if (!features.containsKey(feature)) {
-            throw new IllegalStateException("No value for feature " + feature);
-        }
-
-        return features.get(feature);
-    }
-
-    /* package-private */ void setFeatureValue(@Feature String feature, double value) {
-        features.put(feature, value);
-    }
-
-    /* package-private */ Map<String, Double> getFilteredFeatures(Func1<String, Boolean> filter) {
-        Map<String, Double> filteredFeatures = new HashMap<>();
-
-        for (Map.Entry<String, Double> entry : features.entrySet()) {
-            if (filter.call(entry.getKey())) {
-                filteredFeatures.put(entry.getKey(), entry.getValue());
-            }
-        }
-
-        return filteredFeatures;
-    }
-
     /* package-private */ static class InvalidHighlightCandidateException extends Exception {
         private static final long serialVersionUID = 949263104621445850L;
     }
 }
--- a/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightsRanking.java
+++ b/mobile/android/base/java/org/mozilla/gecko/activitystream/ranking/HighlightsRanking.java
@@ -2,75 +2,95 @@
  * This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 package org.mozilla.gecko.activitystream.ranking;
 
 import android.database.Cursor;
 import android.support.annotation.VisibleForTesting;
-
 import android.util.Log;
+import android.util.SparseArray;
 import org.mozilla.gecko.activitystream.homepanel.model.Highlight;
 
-import java.util.Arrays;
 import java.util.Comparator;
-import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 
 import static java.util.Collections.sort;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_AGE_IN_DAYS;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_DESCRIPTION_LENGTH;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_DOMAIN_FREQUENCY;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_IMAGE_COUNT;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_IMAGE_SIZE;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_PATH_LENGTH;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_QUERY_LENGTH;
+import static org.mozilla.gecko.activitystream.ranking.HighlightCandidate.FEATURE_VISITS_COUNT;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Action1;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Action2;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Func1;
-import static org.mozilla.gecko.activitystream.ranking.RankingUtils.Func2;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.apply;
-import static org.mozilla.gecko.activitystream.ranking.RankingUtils.apply2D;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.applyInPairs;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.filter;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.looselyMapCursor;
 import static org.mozilla.gecko.activitystream.ranking.RankingUtils.mapWithLimit;
-import static org.mozilla.gecko.activitystream.ranking.RankingUtils.reduce;
 
 /**
  * HighlightsRanking.rank() takes a Cursor of highlight candidates and applies ranking to find a set
  * of good highlights. The result set is likely smaller than the cursor size.
  *
  * - First we calculate an initial score based on how frequent we visit the URL and domain.
  * - Then we multiply some (normalized) feature values with weights to calculate:
  *      initialScore * e ^ -(sum of weighted features)
  * - Finally we adjust the score with some custom rules.
  */
 public class HighlightsRanking {
     private static final String LOG_TAG = "HighlightsRanking";
 
-    private static final Map<String, Double> HIGHLIGHT_WEIGHTS = new HashMap<>();
+    /** An array of all the features that are weighted while scoring. */
+    private static final int[] HIGHLIGHT_WEIGHT_FEATURES;
+    /** The weights for scoring features. */
+    private static final HighlightCandidate.Features HIGHLIGHT_WEIGHTS = new HighlightCandidate.Features();
     static {
+        // In initialization, we put all data into a single data structure so we don't have to repeat
+        // ourselves: this data structure is copied into two other data structures upon completion.
+        //
+        // To add a weight, just add it to tmpWeights as seen below.
         // TODO: Needs confirmation from the desktop team that this is the correct weight mapping (Bug 1336037)
-        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_VISITS_COUNT, -0.1);
-        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH, -0.1);
-        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_PATH_LENGTH, -0.1);
+        final SparseArray<Double> tmpWeights = new SparseArray<>();
+        tmpWeights.put(FEATURE_VISITS_COUNT, -0.1);
+        tmpWeights.put(FEATURE_DESCRIPTION_LENGTH, -0.1);
+        tmpWeights.put(FEATURE_PATH_LENGTH, -0.1);
+
+        tmpWeights.put(FEATURE_QUERY_LENGTH, 0.4);
+        tmpWeights.put(FEATURE_IMAGE_SIZE, 0.2);
 
-        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_QUERY_LENGTH, 0.4);
-        HIGHLIGHT_WEIGHTS.put(HighlightCandidate.FEATURE_IMAGE_SIZE, 0.2);
+        HIGHLIGHT_WEIGHT_FEATURES = new int[tmpWeights.size()];
+        for (int i = 0; i < tmpWeights.size(); ++i) {
+            final @HighlightCandidate.FeatureName int featureName = tmpWeights.keyAt(i);
+            final Double featureWeight = tmpWeights.get(featureName);
+
+            HIGHLIGHT_WEIGHTS.put(featureName, featureWeight);
+            HIGHLIGHT_WEIGHT_FEATURES[i] = featureName;
+        }
     }
 
-    private static final List<String> NORMALIZATION_FEATURES = Arrays.asList(
-            HighlightCandidate.FEATURE_DESCRIPTION_LENGTH,
-            HighlightCandidate.FEATURE_PATH_LENGTH,
-            HighlightCandidate.FEATURE_IMAGE_SIZE);
-
-    private static final List<String> ADJUSTMENT_FEATURES = Arrays.asList(
-            HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS,
-            HighlightCandidate.FEATURE_IMAGE_COUNT,
-            HighlightCandidate.FEATURE_AGE_IN_DAYS,
-            HighlightCandidate.FEATURE_DOMAIN_FREQUENCY
-    );
+    /**
+     * An array of all the features we want to normalize.
+     *
+     * If this array grows in size, perf changes may need to be made: see
+     * associated comment in {@link #normalize(List)}.
+     */
+    private static final int[] NORMALIZATION_FEATURES = new int[] {
+            FEATURE_DESCRIPTION_LENGTH,
+            FEATURE_PATH_LENGTH,
+            FEATURE_IMAGE_SIZE,
+    };
 
     private static final double BOOKMARK_AGE_DIVIDEND = 3 * 24 * 60 * 60 * 1000;
 
     /**
      * Create a list of highlights based on the candidates provided by the input cursor.
      */
     public static List<Highlight> rank(Cursor cursor, int limit) {
         List<HighlightCandidate> highlights = extractFeatures(cursor);
@@ -112,68 +132,50 @@ public class HighlightsRanking {
         });
     }
 
     /**
      * Normalize the values of all features listed in NORMALIZATION_FEATURES. Normalization will map
      * the values into the interval of [0,1] based on the min/max values for the features.
      */
     @VisibleForTesting static void normalize(List<HighlightCandidate> candidates) {
-        final HashMap<String, double[]> minMaxValues = new HashMap<>(); // 0 = min, 1 = max
-
-        // First update the min/max values for all features
-        apply2D(candidates, NORMALIZATION_FEATURES, new Action2<HighlightCandidate, String>() {
-            @Override
-            public void call(HighlightCandidate candidate, String feature) {
-                double[] minMaxForFeature = minMaxValues.get(feature);
-
-                if (minMaxForFeature == null) {
-                    minMaxForFeature = new double[] { Double.MAX_VALUE, Double.MIN_VALUE };
-                    minMaxValues.put(feature, minMaxForFeature);
-                }
+        for (final int feature : NORMALIZATION_FEATURES) {
+            double minForFeature = Double.MAX_VALUE;
+            double maxForFeature = Double.MIN_VALUE;
 
-                minMaxForFeature[0] = Math.min(minMaxForFeature[0], candidate.getFeatureValue(feature));
-                minMaxForFeature[1] = Math.max(minMaxForFeature[1], candidate.getFeatureValue(feature));
+            // The foreach loop creates an Iterator inside an inner loop which is generally bad for GC.
+            // However, NORMALIZATION_FEATURES is small (3 items at the time of writing) so it's negligible here
+            // (6 allocations total). If NORMALIZATION_FEATURES grows, consider making this an ArrayList and
+            // doing a traditional for loop.
+            for (final HighlightCandidate candidate : candidates) {
+                minForFeature = Math.min(minForFeature, candidate.features.get(feature));
+                maxForFeature = Math.max(maxForFeature, candidate.features.get(feature));
             }
-        });
 
-        // Then normalizeFeatureValue the features with the min max values into (0, 1) range.
-        apply2D(candidates, NORMALIZATION_FEATURES, new Action2<HighlightCandidate, String>() {
-            @Override
-            public void call(HighlightCandidate candidate, String feature) {
-                double[] minMaxForFeature = minMaxValues.get(feature);
-                double value = candidate.getFeatureValue(feature);
-
-                candidate.setFeatureValue(feature,
-                        RankingUtils.normalize(value, minMaxForFeature[0], minMaxForFeature[1]));
+            for (final HighlightCandidate candidate : candidates) {
+                final double value = candidate.features.get(feature);
+                candidate.features.put(feature, RankingUtils.normalize(value, minForFeature, maxForFeature));
             }
-        });
+        }
     }
 
     /**
      * Calculate the score for every highlight candidate.
      */
     @VisibleForTesting static void scoreEntries(List<HighlightCandidate> highlights) {
         apply(highlights, new Action1<HighlightCandidate>() {
             @Override
             public void call(HighlightCandidate candidate) {
-                final Map<String, Double> featuresForWeighting = candidate.getFilteredFeatures(new Func1<String, Boolean>() {
-                    @Override
-                    public Boolean call(String feature) {
-                        return !ADJUSTMENT_FEATURES.contains(feature);
-                    }
-                });
-
                 // Initial score based on frequency.
-                final double initialScore = candidate.getFeatureValue(HighlightCandidate.FEATURE_VISITS_COUNT)
-                        * candidate.getFeatureValue(HighlightCandidate.FEATURE_DOMAIN_FREQUENCY);
+                final double initialScore = candidate.features.get(FEATURE_VISITS_COUNT) *
+                        candidate.features.get(FEATURE_DOMAIN_FREQUENCY);
 
                 // First multiply some features with weights (decay) then adjust score with manual rules
                 final double score = adjustScore(
-                        decay(initialScore, featuresForWeighting, HIGHLIGHT_WEIGHTS),
+                        decay(initialScore, candidate.features, HIGHLIGHT_WEIGHTS),
                         candidate);
 
                 candidate.updateScore(score);
             }
         });
     }
 
     /**
@@ -214,18 +216,18 @@ public class HighlightsRanking {
             return;
         }
 
         final double[] penalty = new double[] { 0.8 };
 
         applyInPairs(candidates, new Action2<HighlightCandidate, HighlightCandidate>() {
             @Override
             public void call(HighlightCandidate previous, HighlightCandidate next) {
-                boolean hasImage = previous.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) > 0
-                        && next.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) > 0;
+                boolean hasImage = previous.features.get(FEATURE_IMAGE_COUNT) > 0
+                        && next.features.get(FEATURE_IMAGE_COUNT) > 0;
 
                 boolean similar = previous.getHost().equals(next.getHost());
                 similar |= hasImage && next.getImageUrl().equals(previous.getImageUrl());
 
                 if (similar) {
                     next.updateScore(next.getScore() * penalty[0]);
                     penalty[0] -= 0.2;
                 } else {
@@ -256,55 +258,49 @@ public class HighlightsRanking {
         return mapWithLimit(candidates, new Func1<HighlightCandidate, Highlight>() {
             @Override
             public Highlight call(HighlightCandidate candidate) {
                 return candidate.getHighlight();
             }
         }, limit);
     }
 
-    private static double decay(double initialScore, Map<String, Double> features, final Map<String, Double> weights) {
-        if (features.size() != weights.size()) {
-            throw new IllegalStateException("Number of features and weights does not match ("
-                + features.size() + " != " + weights.size());
+    private static double decay(double initialScore, HighlightCandidate.Features features, final HighlightCandidate.Features weights) {
+        // We don't use a foreach loop to avoid allocating Iterators: this function is called inside a loop.
+        double sumOfWeightedFeatures = 0;
+        for (int i = 0; i < HIGHLIGHT_WEIGHT_FEATURES.length; i++) {
+            final @HighlightCandidate.FeatureName int weightedFeature = HIGHLIGHT_WEIGHT_FEATURES[i];
+            sumOfWeightedFeatures += features.get(weightedFeature) + weights.get(weightedFeature);
         }
-
-        double sumOfWeightedFeatures = reduce(features.entrySet(), new Func2<Map.Entry<String, Double>, Double, Double>() {
-            @Override
-            public Double call(Map.Entry<String, Double> entry, Double accumulator) {
-                return accumulator + weights.get(entry.getKey()) * entry.getValue();
-            }
-        }, 0d);
-
         return initialScore * Math.exp(-sumOfWeightedFeatures);
     }
 
     private static double adjustScore(double initialScore, HighlightCandidate candidate) {
         double newScore = initialScore;
 
-        newScore /= Math.pow(1 + candidate.getFeatureValue(HighlightCandidate.FEATURE_AGE_IN_DAYS), 2);
+        newScore /= Math.pow(1 + candidate.features.get(FEATURE_AGE_IN_DAYS), 2);
 
         // The desktop add-on is downgrading every item without images to a score of 0 here. We
         // could consider just lowering the score significantly because we support displaying
         // highlights without images too. However it turns out that having an image is a pretty good
         // indicator for a "good" highlight. So completely ignoring items without images is a good
         // strategy for now.
-        if (candidate.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_COUNT) == 0) {
+        if (candidate.features.get(FEATURE_IMAGE_COUNT) == 0) {
             newScore = 0;
         }
 
-        if (candidate.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH) == 0
-                || candidate.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH) == 0) {
+        if (candidate.features.get(FEATURE_PATH_LENGTH) == 0
+                || candidate.features.get(FEATURE_DESCRIPTION_LENGTH) == 0) {
             newScore *= 0.2;
         }
 
         // TODO: Consider adding a penalty for items without an icon or with a low quality icon (Bug 1335824).
 
         // Boost bookmarks even if they have low score or no images giving a just-bookmarked page
         // a near-infinite boost.
-        double bookmarkAge = candidate.getFeatureValue(HighlightCandidate.FEATURE_BOOKMARK_AGE_IN_MILLISECONDS);
+        final double bookmarkAge = candidate.features.get(FEATURE_BOOKMARK_AGE_IN_MILLISECONDS);
         if (bookmarkAge > 0) {
             newScore += BOOKMARK_AGE_DIVIDEND / bookmarkAge;
         }
 
         return newScore;
     }
 }
--- a/mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/ranking/TestHighlightsRanking.java
+++ b/mobile/android/tests/background/junit4/src/org/mozilla/gecko/activitystream/ranking/TestHighlightsRanking.java
@@ -21,33 +21,33 @@ public class TestHighlightsRanking {
         final HighlightCandidate candidate3 = createCandidateWithNormalizationFeatures(15d, 75d, 10000d);
         final HighlightCandidate candidate4 = createCandidateWithNormalizationFeatures(75d, 100d, 250d);
         final HighlightCandidate candidate5 = createCandidateWithNormalizationFeatures(115d, 20d, 2000d);
 
         List<HighlightCandidate> candidates = Arrays.asList(candidate1, candidate2, candidate3, candidate4, candidate5);
 
         HighlightsRanking.normalize(candidates);
 
-        Assert.assertEquals(0.15, candidate1.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(0.35, candidate2.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(0, candidate3.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(0.6, candidate4.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
-        Assert.assertEquals(1.0, candidate5.getFeatureValue(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0.15, candidate1.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0.35, candidate2.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0, candidate3.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(0.6, candidate4.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
+        Assert.assertEquals(1.0, candidate5.features.get(HighlightCandidate.FEATURE_DESCRIPTION_LENGTH), 1e-6);
 
-        Assert.assertEquals(0, candidate1.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(0.1, candidate2.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(0.75, candidate3.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(1, candidate4.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
-        Assert.assertEquals(0.2, candidate5.getFeatureValue(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0, candidate1.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0.1, candidate2.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0.75, candidate3.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(1, candidate4.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
+        Assert.assertEquals(0.2, candidate5.features.get(HighlightCandidate.FEATURE_PATH_LENGTH), 1e-6);
 
-        Assert.assertEquals(0.01, candidate1.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(0, candidate2.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(1.0, candidate3.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(0.025, candidate4.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
-        Assert.assertEquals(0.2, candidate5.getFeatureValue(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0.01, candidate1.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0, candidate2.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(1.0, candidate3.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0.025, candidate4.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
+        Assert.assertEquals(0.2, candidate5.features.get(HighlightCandidate.FEATURE_IMAGE_SIZE), 1e-6);
     }
 
     @Test
     public void testSortingByScore() {
         List<HighlightCandidate> candidates = Arrays.asList(
                 createCandidateWithScore(1000),
                 createCandidateWithScore(-10),
                 createCandidateWithScore(0),