Skip to content

Commit b3156a2

Browse files
committed
Improved search latency
1 parent 89e87a1 commit b3156a2

3 files changed

Lines changed: 129 additions & 41 deletions

File tree

src/main/java/mezz/jei/ingredients/CollapsedStack.java

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package mezz.jei.ingredients;
22

3+
import mezz.jei.Internal;
4+
import mezz.jei.api.ingredients.IIngredientHelper;
35
import mezz.jei.api.recipe.IIngredientType;
46
import mezz.jei.gui.ingredients.IIngredientListElement;
57
import net.minecraft.item.ItemStack;
68

9+
import javax.annotation.Nullable;
710
import java.util.ArrayList;
811
import java.util.List;
912
import java.util.function.Predicate;
@@ -25,6 +28,13 @@ public class CollapsedStack {
2528
private final String displayName;
2629
/** Matches against the raw ingredient object (any type). */
2730
private final Predicate<Object> matcher;
31+
/**
32+
* Optional fast-path matcher that receives a pre-computed UID string instead of the raw
33+
* ingredient. Set on custom groups by {@link CollapsedStackRegistry} so that
34+
* {@link mezz.jei.ingredients.IngredientFilter#collapse} can skip calling
35+
* {@code getUniqueIdentifierForStack()} N×M times per filter cycle.
36+
*/
37+
@Nullable private Predicate<String> uidMatcher;
2838
private boolean expanded;
2939
private final List<IIngredientListElement<?>> ingredients;
3040

@@ -83,6 +93,42 @@ public boolean matches(IIngredientListElement<?> element) {
8393
return matcher.test(ingredient);
8494
}
8595

96+
/** Returns the UID-based matcher, or {@code null} if this group uses a raw-ingredient predicate. */
97+
@Nullable
98+
public Predicate<String> getUidMatcher() {
99+
return uidMatcher;
100+
}
101+
102+
public void setUidMatcher(Predicate<String> uidMatcher) {
103+
this.uidMatcher = uidMatcher;
104+
}
105+
106+
/**
107+
* Computes a unique identifier string for any ingredient type.
108+
* Returns {@code null} if the ingredient is empty or an error occurs.
109+
* Used by {@code collapse()} to precompute UIDs once per element.
110+
*/
111+
@Nullable
112+
public static String computeIngredientUid(Object ingredient) {
113+
if (ingredient instanceof ItemStack) {
114+
ItemStack stack = (ItemStack) ingredient;
115+
if (stack.isEmpty()) return null;
116+
try {
117+
return Internal.getStackHelper().getUniqueIdentifierForStack(stack);
118+
} catch (Exception e) {
119+
return null;
120+
}
121+
}
122+
try {
123+
@SuppressWarnings("unchecked")
124+
IIngredientHelper<Object> helper = (IIngredientHelper<Object>)
125+
Internal.getIngredientRegistry().getIngredientHelper(ingredient);
126+
return helper.getUniqueId(ingredient);
127+
} catch (Exception e) {
128+
return null;
129+
}
130+
}
131+
86132
// --- Runtime ingredient list (transient per filter cycle) ---
87133

88134
public List<IIngredientListElement<?>> getIngredients() {

src/main/java/mezz/jei/ingredients/CollapsedStackRegistry.java

Lines changed: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
package mezz.jei.ingredients;
22

3-
import mezz.jei.Internal;
43
import mezz.jei.config.Config;
54
import mezz.jei.config.CustomGroupsConfig;
65
import mezz.jei.gui.ingredients.IIngredientListElement;
@@ -114,32 +113,26 @@ public void loadCustomGroups() {
114113
}
115114
}
116115
String displayName = group.displayName != null ? group.displayName : group.id;
117-
// Matcher works for both ItemStack and non-ItemStack ingredients (e.g. FluidStack):
118-
// for ItemStacks use StackHelper, for everything else use the generic IngredientRegistry helper.
119-
customEntries.add(new CollapsedStack(group.id, displayName, ingredient -> {
120-
try {
121-
String uid;
122-
if (ingredient instanceof ItemStack) {
123-
ItemStack stack = (ItemStack) ingredient;
124-
if (stack.isEmpty()) return false;
125-
uid = Internal.getStackHelper().getUniqueIdentifierForStack(stack);
126-
} else {
127-
@SuppressWarnings("unchecked")
128-
mezz.jei.api.ingredients.IIngredientHelper<Object> helper =
129-
(mezz.jei.api.ingredients.IIngredientHelper<Object>)
130-
Internal.getIngredientRegistry().getIngredientHelper(ingredient);
131-
uid = helper.getUniqueId(ingredient);
132-
}
133-
if (exactUids.contains(uid)) return true;
134-
// Check wildcard prefix: "minecraft:iron_pickaxe" matches "minecraft:iron_pickaxe:5" etc.
135-
for (String prefix : wildcardPrefixes) {
136-
if (uid.equals(prefix) || uid.startsWith(prefix + ":")) return true;
137-
}
138-
return false;
139-
} catch (Exception e) {
140-
return false;
116+
117+
// UID-based fast-path predicate: O(1) hash-set lookup, no StackHelper call.
118+
// Used by IngredientFilter.collapse() after it has pre-computed each element's UID once.
119+
final Predicate<String> uidPredicate = uid -> {
120+
if (exactUids.contains(uid)) return true;
121+
for (String prefix : wildcardPrefixes) {
122+
if (uid.equals(prefix) || uid.startsWith(prefix + ":")) return true;
141123
}
142-
}));
124+
return false;
125+
};
126+
127+
// Ingredient-level matcher (fallback for call sites that don't pre-compute UIDs,
128+
// e.g. withGroupNameMatches). Delegates UID computation to CollapsedStack.computeIngredientUid
129+
// and then uses the same uidPredicate to avoid duplicating the matching logic.
130+
CollapsedStack cs = new CollapsedStack(group.id, displayName, ingredient -> {
131+
String uid = CollapsedStack.computeIngredientUid(ingredient);
132+
return uid != null && uidPredicate.test(uid);
133+
});
134+
cs.setUidMatcher(uidPredicate);
135+
customEntries.add(cs);
143136
}
144137
Log.get().debug("Loaded {} custom collapsible groups", customEntries.size());
145138
}

src/main/java/mezz/jei/ingredients/IngredientFilter.java

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import javax.annotation.Nullable;
44
import java.util.*;
55
import java.util.function.Function;
6+
import java.util.function.Predicate;
67
import java.util.regex.Pattern;
78
import java.util.stream.Collectors;
89

@@ -38,6 +39,15 @@ public class IngredientFilter implements IIngredientFilter, IIngredientGridSourc
3839
private List<IIngredientListElement> ingredientListCached = Collections.emptyList();
3940
private List<Object> collapsedListCached = Collections.emptyList();
4041
@Nullable private String filterCached;
42+
/**
43+
* Cached sorted list of all currently-visible ingredients — the result of a full
44+
* suffix-tree traversal + sort. This does NOT change when the search-bar text changes,
45+
* only when ingredients are added/removed or their visibility changes. Caching it here
46+
* avoids the expensive {@code elementSearch.getAllIngredients()} traversal on every
47+
* keystroke (once in {@link #getIngredientListUncached} for empty filter and once more
48+
* in {@link #withGroupNameMatches} for every non-empty filter).
49+
*/
50+
@Nullable private List<IIngredientListElement<?>> allVisibleIngredientsCache = null;
4151

4252
private boolean afterBlock = false;
4353
@Nullable private List<Runnable> delegatedActions;
@@ -56,13 +66,13 @@ public void logStatistics() {
5666
public void addIngredients(NonNullList<IIngredientListElement> ingredients) {
5767
ingredients.sort(IngredientListElementComparator.INSTANCE);
5868
this.elementSearch.addAll(ingredients);
59-
this.filterCached = null;
69+
invalidateCache();
6070
}
6171

6272
public <V> void addIngredient(IIngredientListElement<V> element) {
6373
updateHiddenState(element);
6474
this.elementSearch.add(element);
65-
this.filterCached = null;
75+
invalidateCache();
6676
}
6777

6878
public void delegateAfterBlock(Runnable runnable) {
@@ -97,6 +107,23 @@ public void block() {
97107

98108
public void invalidateCache() {
99109
this.filterCached = null;
110+
this.allVisibleIngredientsCache = null;
111+
}
112+
113+
/**
114+
* Returns a cached, sorted list of every currently-visible ingredient.
115+
* The cache is invalidated whenever {@link #invalidateCache()} is called (ingredient
116+
* additions, visibility changes, mode changes), but NOT on search-text changes — the
117+
* full ingredient set is independent of the search bar content.
118+
*/
119+
private List<IIngredientListElement<?>> getAllVisibleIngredients() {
120+
if (allVisibleIngredientsCache == null) {
121+
allVisibleIngredientsCache = this.elementSearch.getAllIngredients().stream()
122+
.filter(IIngredientListElement::isVisible)
123+
.sorted(IngredientListElementComparator.INSTANCE)
124+
.collect(Collectors.toList());
125+
}
126+
return allVisibleIngredientsCache;
100127
}
101128

102129
public <V> List<IIngredientListElement<V>> findMatchingElements(IIngredientListElement<V> element) {
@@ -140,7 +167,7 @@ public void modesChanged() {
140167

141168
@SubscribeEvent
142169
public void onEditModeToggleEvent(EditModeToggleEvent event) {
143-
this.filterCached = null;
170+
invalidateCache();
144171
updateHidden();
145172

146173
// In Hide Ingredients Mode the user cannot Alt+Click to expand/collapse groups,
@@ -171,7 +198,7 @@ public <V> void updateHiddenState(IIngredientListElement<V> element) {
171198
(Config.isEditModeEnabled() || !Config.isIngredientOnConfigBlacklist(ingredient, ingredientHelper));
172199
if (element.isVisible() != visible) {
173200
element.setVisible(visible);
174-
this.filterCached = null;
201+
invalidateCache();
175202
}
176203
}
177204

@@ -245,20 +272,14 @@ public void setFilterText(String filterText) {
245272

246273
private List<IIngredientListElement<?>> getIngredientListUncached(String filterText) {
247274
if (filterText.isEmpty()) {
248-
return this.elementSearch.getAllIngredients().stream()
249-
.filter(IIngredientListElement::isVisible)
250-
.sorted(IngredientListElementComparator.INSTANCE)
251-
.collect(Collectors.toList());
275+
return new ArrayList<>(getAllVisibleIngredients());
252276
}
253277
List<SearchToken> tokens = Arrays.stream(filterText.split("\\|"))
254278
.map(SearchToken::parseSearchToken)
255279
.filter(s -> !s.search.isEmpty())
256280
.collect(Collectors.toList());
257281
if (tokens.isEmpty()) {
258-
return this.elementSearch.getAllIngredients().stream()
259-
.filter(IIngredientListElement::isVisible)
260-
.sorted(IngredientListElementComparator.INSTANCE)
261-
.collect(Collectors.toList());
282+
return new ArrayList<>(getAllVisibleIngredients());
262283
}
263284
return tokens.stream()
264285
.map(token -> token.getSearchResults(this.elementSearch))
@@ -295,8 +316,9 @@ private List<IIngredientListElement<?>> withGroupNameMatches(
295316
Set<IIngredientListElement<?>> seen = Collections.newSetFromMap(new IdentityHashMap<>());
296317
seen.addAll(baseList);
297318
List<IIngredientListElement<?>> result = new ArrayList<>(baseList);
298-
for (IIngredientListElement<?> element : this.elementSearch.getAllIngredients()) {
299-
if (!element.isVisible() || seen.contains(element)) {
319+
// getAllVisibleIngredients() is cached — no extra suffix-tree traversal per keystroke.
320+
for (IIngredientListElement<?> element : getAllVisibleIngredients()) {
321+
if (seen.contains(element)) {
300322
continue;
301323
}
302324
for (CollapsedStack entry : matchingGroups) {
@@ -353,10 +375,36 @@ private List<Object> collapse(List<IIngredientListElement> ingredientList) {
353375
// Track which entries have already been added to the result list
354376
Set<CollapsedStack> addedToResult = Collections.newSetFromMap(new IdentityHashMap<>());
355377

378+
// Precompute per-element UIDs if any active entry has a UID-based matcher.
379+
// This reduces getUniqueIdentifierForStack() calls from O(n×m) to O(n) for
380+
// custom groups (which are the expensive ones in the profiler).
381+
boolean hasUidEntries = false;
382+
for (CollapsedStack entry : activeEntries) {
383+
if (entry.getUidMatcher() != null) {
384+
hasUidEntries = true;
385+
break;
386+
}
387+
}
388+
final String[] elementUids;
389+
if (hasUidEntries) {
390+
elementUids = new String[ingredientList.size()];
391+
for (int i = 0; i < ingredientList.size(); i++) {
392+
elementUids[i] = CollapsedStack.computeIngredientUid(ingredientList.get(i).getIngredient());
393+
}
394+
} else {
395+
elementUids = null;
396+
}
397+
398+
int idx = 0;
356399
for (IIngredientListElement<?> element : ingredientList) {
400+
final String cachedUid = elementUids != null ? elementUids[idx] : null;
357401
boolean matched = false;
358402
for (CollapsedStack entry : activeEntries) {
359-
if (entry.matches(element)) {
403+
Predicate<String> uidMatcher = entry.getUidMatcher();
404+
boolean isMatch = (uidMatcher != null && cachedUid != null)
405+
? uidMatcher.test(cachedUid)
406+
: entry.matches(element);
407+
if (isMatch) {
360408
if (addedToResult.add(entry)) {
361409
result.add(entry);
362410
}
@@ -367,6 +415,7 @@ private List<Object> collapse(List<IIngredientListElement> ingredientList) {
367415
if (!matched) {
368416
result.add(element);
369417
}
418+
idx++;
370419
}
371420

372421
// Remove empty collapsed stacks (shouldn't happen, but be safe)

0 commit comments

Comments
 (0)