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;