diff --git a/spectator-reg-atlas/src/jmh/java/com/netflix/spectator/atlas/QueryIndexMatch.java b/spectator-reg-atlas/src/jmh/java/com/netflix/spectator/atlas/QueryIndexMatch.java new file mode 100644 index 000000000..671b32c4e --- /dev/null +++ b/spectator-reg-atlas/src/jmh/java/com/netflix/spectator/atlas/QueryIndexMatch.java @@ -0,0 +1,112 @@ +/* + * Copyright 2014-2025 Netflix, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.netflix.spectator.atlas; + +import com.netflix.spectator.api.Id; +import com.netflix.spectator.api.NoopRegistry; +import com.netflix.spectator.api.Registry; +import com.netflix.spectator.atlas.impl.Parser; +import com.netflix.spectator.atlas.impl.Query; +import com.netflix.spectator.atlas.impl.QueryIndex; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.infra.Blackhole; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +/** + * Exercises the hot matching path used by the LWC bridge: an index of many subscription + * queries matched against a stream of ids. The dominant cost in profiles is + * {@link QueryIndex#forEachMatch} walking the sorted keys of each id and comparing them + * against the keys stored in the index. + * + *
+ * Benchmark              Mode  Cnt  Score   Error  Units
+ * QueryIndexMatch.match  avgt    5    ...
+ * 
+ */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.MICROSECONDS) +public class QueryIndexMatch { + + private static final String[] KEYS = { + "nf.app", "nf.cluster", "nf.asg", "nf.region", "nf.zone", "nf.stack", + "device", "country", "status", "statistic" + }; + + private static String val(Random r) { + char c = (char) ('a' + r.nextInt(8)); + return String.valueOf(c); + } + + private QueryIndex idx; + private List ids; + + @Setup + public void setup() { + Registry registry = new NoopRegistry(); + Random r = new Random(42); + + // Build a realistic set of subscriptions: each anchored on the name plus a couple of + // additional dimension clauses (mix of :eq and :re), combined with :and. + idx = QueryIndex.newInstance(registry); + int numQueries = 5_000; + for (int i = 0; i < numQueries; ++i) { + StringBuilder sb = new StringBuilder(); + sb.append("name,m").append(r.nextInt(50)).append(",:eq"); + int extra = 1 + r.nextInt(3); + for (int j = 0; j < extra; ++j) { + String k = KEYS[r.nextInt(KEYS.length)]; + if (r.nextInt(4) == 0) { + sb.append(',').append(k).append(',').append(val(r)).append(",:re,:and"); + } else { + sb.append(',').append(k).append(',').append(val(r)).append(",:eq,:and"); + } + } + Query q = Parser.parseQuery(sb.toString()); + idx.add(q, i); + } + + // Build a stream of ids with a name and several sorted tags, matching the shape of + // datapoints flowing through the bridge. + ids = new ArrayList<>(); + int numIds = 200; + for (int i = 0; i < numIds; ++i) { + Id id = Id.create("m" + r.nextInt(50)); + int numTags = 4 + r.nextInt(5); + for (int j = 0; j < numTags; ++j) { + id = id.withTag(KEYS[r.nextInt(KEYS.length)], val(r)); + } + ids.add(id); + } + } + + @Benchmark + public void match(Blackhole bh) { + for (int i = 0; i < ids.size(); ++i) { + idx.forEachMatch(ids.get(i), bh::consume); + } + } +} diff --git a/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/QueryIndex.java b/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/QueryIndex.java index 18109f5e9..556d3f7d3 100644 --- a/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/QueryIndex.java +++ b/spectator-reg-atlas/src/main/java/com/netflix/spectator/atlas/impl/QueryIndex.java @@ -137,12 +137,37 @@ private static QueryIndex empty(CacheSupplier cacheSupplier) { * This allows the {@link Id} to be traversed in order while performing the lookup. */ private static int compare(String k1, String k2) { + return compare(k1, k2, "name".equals(k2)); + } + + /** + * Variant of {@link #compare(String, String)} where the caller has already determined + * whether {@code k2} is the {@code name} key. In the hot matching path {@code k2} is the + * fixed key for a node, so this check can be hoisted out of the per-tag loop rather than + * recomputed on every comparison. + */ + private static int compare(String k1, String k2, boolean k2IsName) { if ("name".equals(k1)) { - return "name".equals(k2) ? 0 : -1; - } else if ("name".equals(k2)) { - return 1; + return k2IsName ? 0 : -1; } else { - return k1.compareTo(k2); + return k2IsName ? 1 : k1.compareTo(k2); + } + } + + /** + * Compare a tag key at a given position against the fixed key for a node while traversing + * an id. Only position 0 holds the synthesized {@code name} key, so it needs the full + * name-first comparison. Positions {@code >= 1} come from the tag list, which is sorted by + * {@link String#compareTo}, so the name-first special-casing can be skipped there: even if + * a tag is literally keyed {@code name}, any {@code keyRef} that sorts before {@code name} + * cannot appear after that entry in the sorted scan, so a plain comparison never skips a + * match. + */ + private static int compareTagKey(String k, String keyRef, boolean keyRefIsName, int position) { + if (position == 0) { + return compare(k, keyRef, keyRefIsName); + } else { + return keyRefIsName ? 1 : k.compareTo(keyRef); } } @@ -450,11 +475,15 @@ private void forEachMatch(Id tags, int i, Consumer consumer) { boolean keyPresent = false; + // keyRef is fixed for this node, so the "name" check only needs to be done once + // here rather than on every comparison within the loop below. + final boolean keyRefIsName = "name".equals(keyRef); + final int tagsSize = tags.size(); for (int j = i; j < tagsSize; ++j) { String k = tags.getKey(j); String v = tags.getValue(j); - int cmp = compare(k, keyRef); + int cmp = compareTagKey(k, keyRef, keyRefIsName, j); if (cmp == 0) { final int nextPos = j + 1; keyPresent = true;