From 8c266a186d5801cf42b1e13fb20ad71776f917e9 Mon Sep 17 00:00:00 2001 From: Octavia Togami Date: Sun, 12 Jul 2026 16:26:34 -0700 Subject: [PATCH 1/4] Optimize empty compound/list --- .../java/org/enginehub/linbus/dfu/LinOps.java | 2 +- .../enginehub/linbus/tree/LinCompoundTag.java | 15 +++++++++++++++ .../org/enginehub/linbus/tree/LinListTag.java | 18 +++++++++++++++++- .../linbus/tree/LinCompoundTagTest.java | 16 ++++++++++++++++ .../enginehub/linbus/tree/LinListTagTest.java | 13 +++++++++++++ 5 files changed, 62 insertions(+), 2 deletions(-) diff --git a/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java b/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java index 6326c05..21f42fc 100644 --- a/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java +++ b/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java @@ -86,7 +86,7 @@ public LinTag emptyList() { @Override public LinTag emptyMap() { - return LinCompoundTag.builder().build(); + return LinCompoundTag.empty(); } @Override diff --git a/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java b/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java index be78cd2..e68d1f0 100644 --- a/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java +++ b/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java @@ -54,9 +54,21 @@ public final class LinCompoundTag extends LinTag * @return the tag */ public static LinCompoundTag of(Map> value) { + if (value.isEmpty()) { + return EMPTY; + } return new LinCompoundTag(copyImmutable(value), true); } + private static final LinCompoundTag EMPTY = new LinCompoundTag(Map.of(), false); + + /** + * {@return an empty compound tag} + */ + public static LinCompoundTag empty() { + return EMPTY; + } + /** * Creates a new builder. * @@ -257,6 +269,9 @@ public Builder putString(String name, String value) { * @return the built tag */ public LinCompoundTag build() { + if (this.collector.isEmpty()) { + return EMPTY; + } return new LinCompoundTag(copyImmutable(this.collector), false); } } diff --git a/tree/src/main/java/org/enginehub/linbus/tree/LinListTag.java b/tree/src/main/java/org/enginehub/linbus/tree/LinListTag.java index 177b1a7..2d44cba 100644 --- a/tree/src/main/java/org/enginehub/linbus/tree/LinListTag.java +++ b/tree/src/main/java/org/enginehub/linbus/tree/LinListTag.java @@ -18,13 +18,16 @@ package org.enginehub.linbus.tree; +import org.enginehub.linbus.common.LinTagId; import org.enginehub.linbus.stream.LinStream; import org.enginehub.linbus.stream.internal.FlatteningLinStream; import org.enginehub.linbus.stream.internal.SurroundingLinStream; import org.enginehub.linbus.stream.token.LinToken; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.function.Function; @@ -51,6 +54,9 @@ public final class LinListTag> extends LinTag> { public static > LinListTag of( LinTagType elementType, List value ) { + if (value.isEmpty()) { + return empty(elementType); + } for (T t : value) { if (t.type() != elementType) { throw new IllegalArgumentException("Element is not of type " + elementType.name() + " but " @@ -60,6 +66,11 @@ public static > LinListTag of( return new LinListTag<>(elementType, List.copyOf(value)); } + private static final List> EMPTY_LISTS = Arrays.stream(LinTagId.values()) + .sorted(Comparator.comparingInt(LinTagId::id)) + .>map(id -> new LinListTag<>(LinTagType.fromId(id), List.of())) + .toList(); + /** * Get an empty list of the given element type. * @@ -68,7 +79,9 @@ public static > LinListTag of( * @return an empty list */ public static > LinListTag empty(LinTagType elementType) { - return builder(elementType).build(); + @SuppressWarnings("unchecked") + LinListTag empty = (LinListTag) EMPTY_LISTS.get(elementType.id().id()); + return empty; } /** @@ -169,6 +182,9 @@ public Builder set(int index, T tag) { * @return the built tag */ public LinListTag build() { + if (this.collector.isEmpty()) { + return empty(this.elementType); + } return new LinListTag<>(this.elementType, List.copyOf(this.collector)); } } diff --git a/tree/src/test/java/org/enginehub/linbus/tree/LinCompoundTagTest.java b/tree/src/test/java/org/enginehub/linbus/tree/LinCompoundTagTest.java index 9012a5e..6954c56 100644 --- a/tree/src/test/java/org/enginehub/linbus/tree/LinCompoundTagTest.java +++ b/tree/src/test/java/org/enginehub/linbus/tree/LinCompoundTagTest.java @@ -50,6 +50,22 @@ void roundTripBuilder() { assertThat(initial).isEqualTo(initial.toBuilder().build()); } + @Test + void emptyImplementation() { + assertThat(LinCompoundTag.empty()).compoundValue().isEmpty(); + assertThat(LinCompoundTag.empty()).isSameInstanceAs(LinCompoundTag.empty()); + } + + @Test + void builderReturnsEmptySingleton() { + assertThat(LinCompoundTag.builder().build()).isSameInstanceAs(LinCompoundTag.empty()); + } + + @Test + void ofEmptyReturnsEmptySingleton() { + assertThat(LinCompoundTag.of(Map.of())).isSameInstanceAs(LinCompoundTag.empty()); + } + @Test void builderRemove() { var initial = LinCompoundTag.of(Map.of( diff --git a/tree/src/test/java/org/enginehub/linbus/tree/LinListTagTest.java b/tree/src/test/java/org/enginehub/linbus/tree/LinListTagTest.java index 2df125d..a027af1 100644 --- a/tree/src/test/java/org/enginehub/linbus/tree/LinListTagTest.java +++ b/tree/src/test/java/org/enginehub/linbus/tree/LinListTagTest.java @@ -95,6 +95,19 @@ void emptyImplementation() { var empty = LinListTag.empty(LinTagType.stringTag()); assertThat(empty).listValue().isEmpty(); assertThat(empty.elementType()).isEqualTo(LinTagType.stringTag()); + assertThat(empty).isSameInstanceAs(LinListTag.empty(LinTagType.stringTag())); + } + + @Test + void builderReturnsEmptySingleton() { + assertThat(LinListTag.builder(LinTagType.stringTag()).build()) + .isSameInstanceAs(LinListTag.empty(LinTagType.stringTag())); + } + + @Test + void ofEmptyReturnsEmptySingleton() { + assertThat(LinListTag.of(LinTagType.stringTag(), List.of())) + .isSameInstanceAs(LinListTag.empty(LinTagType.stringTag())); } @Test From 8b467daa20a29e52aa14ed60dc90d3d809b3e1a8 Mon Sep 17 00:00:00 2001 From: Octavia Togami Date: Sun, 12 Jul 2026 16:26:47 -0700 Subject: [PATCH 2/4] Optimize int/long array in DFU ops --- .../java/org/enginehub/linbus/dfu/LinOps.java | 52 +++++++++++++------ 1 file changed, 37 insertions(+), 15 deletions(-) diff --git a/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java b/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java index 21f42fc..0e5c6e7 100644 --- a/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java +++ b/dfu/src/main/java/org/enginehub/linbus/dfu/LinOps.java @@ -44,7 +44,7 @@ import java.nio.IntBuffer; import java.nio.LongBuffer; import java.util.ArrayList; -import java.util.Arrays; +import java.util.Iterator; import java.util.List; import java.util.Map.Entry; import java.util.function.BiConsumer; @@ -99,12 +99,18 @@ public U convertTo(DynamicOps outOps, LinTag input) { case LinLongTag tag -> outOps.createLong(tag.valueAsLong()); case LinFloatTag tag -> outOps.createFloat(tag.valueAsFloat()); case LinDoubleTag tag -> outOps.createDouble(tag.valueAsDouble()); - case LinByteArrayTag tag -> outOps.createByteList(tag.view()); case LinStringTag tag -> outOps.createString(tag.value()); case LinListTag tag -> convertList(outOps, tag); case LinCompoundTag tag -> convertMap(outOps, tag); - case LinIntArrayTag tag -> outOps.createIntList(Arrays.stream(tag.value())); - case LinLongArrayTag tag -> outOps.createLongList(Arrays.stream(tag.value())); + case LinByteArrayTag tag -> outOps.createByteList(tag.view()); + case LinIntArrayTag tag -> { + IntBuffer view = tag.view(); + yield outOps.createIntList(IntStream.range(0, view.limit()).map(view::get)); + } + case LinLongArrayTag tag -> { + LongBuffer view = tag.view(); + yield outOps.createLongList(IntStream.range(0, view.limit()).mapToLong(view::get)); + } }; } @@ -293,21 +299,27 @@ public DataResult> mergeToMap(LinTag map, MapLike> values return DataResult.error(() -> "mergeToMap called with non-map: " + map, map); } LinCompoundTag.Builder output = builderFrom(map); - List> missed = new ArrayList<>(); + List> missed = null; + Iterator, LinTag>> entries = values.entries().iterator(); try { - values.entries().forEach(entry -> { + while (entries.hasNext()) { + Pair, LinTag> entry = entries.next(); LinTag key = entry.getFirst(); if (key instanceof LinStringTag stringKey) { output.put(stringKey.value(), entry.getSecond()); } else { + if (missed == null) { + missed = new ArrayList<>(); + } missed.add(key); } - }); + } } catch (IllegalArgumentException e) { return DataResult.error(e::getMessage); } - if (!missed.isEmpty()) { - return DataResult.error(() -> "some keys are not strings: " + missed, output.build()); + if (missed != null) { + List> missedKeys = missed; + return DataResult.error(() -> "some keys are not strings: " + missedKeys, output.build()); } return DataResult.success(output.build()); } @@ -368,10 +380,18 @@ public DataResult>> getStream(LinTag input) { IntStream.range(0, values.limit()).mapToObj(i -> LinByteTag.of(values.get(i))) ); } - case LinIntArrayTag tag -> - DataResult.success(Arrays.stream(tag.value()).mapToObj(value -> (LinTag) LinIntTag.of(value))); - case LinLongArrayTag tag -> - DataResult.success(Arrays.stream(tag.value()).mapToObj(value -> (LinTag) LinLongTag.of(value))); + case LinIntArrayTag tag -> { + IntBuffer values = tag.view(); + yield DataResult.success( + IntStream.range(0, values.limit()).mapToObj(i -> (LinTag) LinIntTag.of(values.get(i))) + ); + } + case LinLongArrayTag tag -> { + LongBuffer values = tag.view(); + yield DataResult.success( + IntStream.range(0, values.limit()).mapToObj(i -> (LinTag) LinLongTag.of(values.get(i))) + ); + } default -> DataResult.error(() -> "Not a list"); }; } @@ -395,7 +415,8 @@ public LinTag createByteList(ByteBuffer input) { @Override public DataResult getIntStream(LinTag input) { if (input instanceof LinIntArrayTag tag) { - return DataResult.success(Arrays.stream(tag.value())); + IntBuffer view = tag.view(); + return DataResult.success(IntStream.range(0, view.limit()).map(view::get)); } return DynamicOps.super.getIntStream(input); } @@ -408,7 +429,8 @@ public LinTag createIntList(IntStream input) { @Override public DataResult getLongStream(LinTag input) { if (input instanceof LinLongArrayTag tag) { - return DataResult.success(Arrays.stream(tag.value())); + LongBuffer view = tag.view(); + return DataResult.success(IntStream.range(0, view.limit()).mapToLong(view::get)); } return DynamicOps.super.getLongStream(input); } From 71c752a9491358b2b2f1b825686c50267f1f2ca7 Mon Sep 17 00:00:00 2001 From: Octavia Togami Date: Mon, 13 Jul 2026 06:17:59 -0700 Subject: [PATCH 3/4] Use an optimized map for compound tag See the class comments for details on design decisions --- .../linbus/tree/CompoundValueMap.java | 214 ++++++++++++++++++ .../enginehub/linbus/tree/LinCompoundTag.java | 18 +- .../org/enginehub/linbus/tree/SipHash.java | 91 ++++++++ .../linbus/tree/CompoundValueMapTest.java | 145 ++++++++++++ .../enginehub/linbus/tree/SipHashTest.java | 121 ++++++++++ 5 files changed, 584 insertions(+), 5 deletions(-) create mode 100644 tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java create mode 100644 tree/src/main/java/org/enginehub/linbus/tree/SipHash.java create mode 100644 tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java create mode 100644 tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java diff --git a/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java b/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java new file mode 100644 index 0000000..8813852 --- /dev/null +++ b/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +import org.jspecify.annotations.Nullable; + +import java.security.SecureRandom; +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; + +/** + * An immutable, insertion-ordered map backing for {@link LinCompoundTag}. + * + *

+ * This is used to avoid the overhead of wrapping a {@link LinkedHashMap} in an unmodifiable view, + * and the cache-unfriendly design of it due to storing 2 extra pointers per entry. + *

+ */ +final class CompoundValueMap extends AbstractMap> { + // There's potential for optimization of memory further here by having dedicated subclasses + // for specific sizes that uses a byte[] or short[] for the table instead. + // I'm unsure if it would affect CPU performance, and it's certainly a lot more code, + // so I'm leaving it as-is for now. + + private static final long K0; + private static final long K1; + + static { + var seed = new SecureRandom(); + K0 = seed.nextLong(); + K1 = seed.nextLong(); + } + + private static int tableSizeFor(int size) { + if (size <= 0) { + return 1; + } + if (size == 1) { + return 2; + } + // The target size is 1.5x the number of entries, for a load factor of 0.67. Chosen because that's what + // Python's dict uses, and because it's funny. + long target = ((long) size) + (size >>> 1); + int capacity = 1; + // Calculate closest power of two >= target, but not exceeding 2^30 (the max capacity for an int[]). + while (capacity < target && capacity < (1 << 30)) { + capacity <<= 1; + } + if (capacity <= size) { + throw new IllegalStateException("Map too large: " + size); + } + return capacity; + } + + private static int hash(String key) { + // We use SipHash here to avoid hash flooding attacks, just in case untrusted input is used as keys + // (perhaps if a web tool is used to edit NBT/schematics? or open access schematic folders?). + // As a bonus, this also significantly reduces the likelyhood of hash collisions, which allows us to use + // a larger load factor and reduce memory usage. + long h = SipHash.hash24(K0, K1, key); + return (int) h ^ (int) (h >>> 32); + } + + private final String[] keys; + private final LinTag[] values; + // Open-addressed index: table[slot] holds entryIndex + 1, with 0 meaning empty. + private final int[] table; + private final int mask; + + CompoundValueMap(Map> source) { + int size = source.size(); + this.keys = new String[size]; + this.values = new LinTag[size]; + int capacity = tableSizeFor(size); + this.table = new int[capacity]; + this.mask = capacity - 1; + int i = 0; + for (Map.Entry> entry : source.entrySet()) { + String key = Objects.requireNonNull(entry.getKey(), "compound key is null"); + LinTag value = Objects.requireNonNull(entry.getValue(), "compound value is null"); + this.keys[i] = key; + this.values[i] = value; + insertIndex(key, i); + i++; + } + } + + private void insertIndex(String key, int entryIndex) { + int slot = hash(key) & this.mask; + while (this.table[slot] != 0) { + slot = (slot + 1) & this.mask; + } + this.table[slot] = entryIndex + 1; + } + + private int indexOf(@Nullable Object key) { + if (!(key instanceof String stringKey) || this.keys.length == 0) { + return -1; + } + int slot = hash(stringKey) & this.mask; + int probed; + while ((probed = this.table[slot]) != 0) { + int index = probed - 1; + if (this.keys[index].equals(stringKey)) { + return index; + } + slot = (slot + 1) & this.mask; + } + return -1; + } + + @Override + public @Nullable LinTag get(@Nullable Object key) { + int index = indexOf(key); + return index < 0 ? null : this.values[index]; + } + + @Override + public boolean containsKey(@Nullable Object key) { + return indexOf(key) >= 0; + } + + @Override + public int size() { + return this.keys.length; + } + + @Override + public @Nullable LinTag put(String key, LinTag value) { + throw new UnsupportedOperationException(); + } + + @Override + public @Nullable LinTag remove(Object key) { + throw new UnsupportedOperationException(); + } + + @Override + public void putAll(Map> m) { + throw new UnsupportedOperationException(); + } + + @Override + public void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public Set>> entrySet() { + return new EntrySet(); + } + + private final class EntrySet extends AbstractSet>> { + @Override + public boolean contains(Object o) { + if (!(o instanceof Map.Entry entry)) { + return false; + } + int index = indexOf(entry.getKey()); + return index >= 0 && CompoundValueMap.this.values[index].equals(entry.getValue()); + } + + @Override + public Iterator>> iterator() { + return new Iterator<>() { + private int cursor; + + @Override + public boolean hasNext() { + return this.cursor < CompoundValueMap.this.keys.length; + } + + @Override + public Map.Entry> next() { + if (this.cursor >= CompoundValueMap.this.keys.length) { + throw new NoSuchElementException(); + } + int index = this.cursor++; + return new SimpleImmutableEntry<>( + CompoundValueMap.this.keys[index], + CompoundValueMap.this.values[index] + ); + } + }; + } + + @Override + public int size() { + return CompoundValueMap.this.keys.length; + } + } +} diff --git a/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java b/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java index e68d1f0..046419a 100644 --- a/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java +++ b/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java @@ -28,7 +28,6 @@ import org.jspecify.annotations.Nullable; import java.io.IOException; -import java.util.Collections; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.List; @@ -47,7 +46,7 @@ public final class LinCompoundTag extends LinTag * *

* The map will not be copied using {@link Map#copyOf(Map)}, as that fails to preserve order. Instead, the - * map will be copied using {@link LinkedHashMap#LinkedHashMap(Map)}. + * map will be copied into an immutable, insertion-ordered map. *

* * @param value the value @@ -159,7 +158,7 @@ public Builder putByte(String name, byte value) { * @return this builder */ public Builder putCompound(String name, Map> value) { - return put(name, new LinCompoundTag(copyImmutable(value), true)); + return put(name, of(value)); } /** @@ -290,7 +289,16 @@ public static LinCompoundTag readFrom(LinStream tokens) throws IOException { private static Map> copyImmutable( Map> value ) { - return Collections.unmodifiableMap(new LinkedHashMap<>(value)); + if (value.isEmpty()) { + // We would like to use the EMPTY constant whenever the map is empty + // so we should never reach this. + throw new AssertionError("Should not be called with an empty map"); + } + if (value.size() == 1) { + // Not order retaining, but for a single element it doesn't matter. + return Map.copyOf(value); + } + return new CompoundValueMap(value); } private final Map> value; @@ -436,7 +444,7 @@ private LinCompoundTag withChangedTag(String name, LinTag value) { } LinkedHashMap> newMap = new LinkedHashMap<>(this.value); newMap.put(name, value); - return new LinCompoundTag(Collections.unmodifiableMap(newMap), false); + return new LinCompoundTag(copyImmutable(newMap), false); } /** diff --git a/tree/src/main/java/org/enginehub/linbus/tree/SipHash.java b/tree/src/main/java/org/enginehub/linbus/tree/SipHash.java new file mode 100644 index 0000000..fb983a5 --- /dev/null +++ b/tree/src/main/java/org/enginehub/linbus/tree/SipHash.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +/** + * SipHash-2-4, ported from the CC0-licensed reference implementation at + * github.com/veorq/SipHash. + */ +final class SipHash { + + private static final int COMPRESSION_ROUNDS = 2; + private static final int FINALIZATION_ROUNDS = 4; + + /** + * SipHash-2-4 over {@code data}'s chars, each fed as a little-endian 16-bit unit so no + * intermediate {@code byte[]} is allocated. + * + * @param k0 the low half of the 128-bit key + * @param k1 the high half of the 128-bit key + * @param data the string to hash + * @return the 64-bit hash + */ + static long hash24(long k0, long k1, String data) { + long[] v = { + 0x736F6D6570736575L ^ k0, + 0x646F72616E646F6DL ^ k1, + 0x6C7967656E657261L ^ k0, + 0x7465646279746573L ^ k1, + }; + int length = data.length(); + int fullBlocks = length & ~3; + for (int i = 0; i < fullBlocks; i += 4) { + long m = (data.charAt(i) & 0xFFFFL) + | (data.charAt(i + 1) & 0xFFFFL) << 16 + | (data.charAt(i + 2) & 0xFFFFL) << 32 + | (data.charAt(i + 3) & 0xFFFFL) << 48; + v[3] ^= m; + compress(v, COMPRESSION_ROUNDS); + v[0] ^= m; + } + long b = ((long) length * 2) << 56; + for (int i = fullBlocks; i < length; i++) { + b |= (data.charAt(i) & 0xFFFFL) << ((i - fullBlocks) * 16); + } + v[3] ^= b; + compress(v, COMPRESSION_ROUNDS); + v[0] ^= b; + v[2] ^= 0xFF; + compress(v, FINALIZATION_ROUNDS); + return v[0] ^ v[1] ^ v[2] ^ v[3]; + } + + private static void compress(long[] v, int rounds) { + for (int r = 0; r < rounds; r++) { + // See SIPROUND macro in the reference implementation. + v[0] += v[1]; + v[1] = Long.rotateLeft(v[1], 13); + v[1] ^= v[0]; + v[0] = Long.rotateLeft(v[0], 32); + v[2] += v[3]; + v[3] = Long.rotateLeft(v[3], 16); + v[3] ^= v[2]; + v[0] += v[3]; + v[3] = Long.rotateLeft(v[3], 21); + v[3] ^= v[0]; + v[2] += v[1]; + v[1] = Long.rotateLeft(v[1], 17); + v[1] ^= v[2]; + v[2] = Long.rotateLeft(v[2], 32); + } + } + + private SipHash() { + } +} diff --git a/tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java b/tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java new file mode 100644 index 0000000..cd75c68 --- /dev/null +++ b/tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static com.google.common.truth.Truth.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CompoundValueMapTest { + + private static Map> valueOf(Map> entries) { + return LinCompoundTag.of(entries).value(); + } + + @Test + void getContainsAndNullTolerance() { + var map = valueOf(Map.of( + "Hello", LinStringTag.of("World!"), + "Count", LinIntTag.of(3) + )); + assertThat(map.get("Hello")).isEqualTo(LinStringTag.of("World!")); + assertThat(map.containsKey("Count")).isTrue(); + assertThat(map.get("missing")).isNull(); + assertThat(map.containsKey("missing")).isFalse(); + // Foreign / null keys must not throw (cross-type Map.equals relies on this). + assertThat(map.get(null)).isNull(); + assertThat(map.containsKey(null)).isFalse(); + Object foreignKey = 42; + assertThat(map.get(foreignKey)).isNull(); + assertThat(map.containsKey(foreignKey)).isFalse(); + } + + @Test + void preservesInsertionOrder() { + var source = new LinkedHashMap>(); + source.put("z", LinIntTag.of(0)); + source.put("a", LinIntTag.of(1)); + source.put("m", LinIntTag.of(2)); + source.put("b", LinIntTag.of(3)); + var map = valueOf(source); + assertThat(map.keySet()).containsExactly("z", "a", "m", "b").inOrder(); + } + + @Test + void isImmutable() { + var map = valueOf(Map.of("Hello", LinStringTag.of("World!"))); + assertThrows(UnsupportedOperationException.class, () -> map.put("x", LinIntTag.of(1))); + assertThrows(UnsupportedOperationException.class, () -> map.remove("Hello")); + assertThrows(UnsupportedOperationException.class, () -> map.remove("absent")); + assertThrows(UnsupportedOperationException.class, map::clear); + assertThrows(UnsupportedOperationException.class, () -> map.putAll(Map.of("y", LinIntTag.of(2)))); + var entry = map.entrySet().iterator().next(); + assertThrows(UnsupportedOperationException.class, () -> entry.setValue(LinIntTag.of(9))); + assertThrows(UnsupportedOperationException.class, () -> map.entrySet().iterator().remove()); + } + + @Test + void equalsAndHashCodeMatchLinkedHashMapRegardlessOfOrder() { + var map = valueOf(Map.of( + "Hello", LinStringTag.of("World!"), + "Count", LinIntTag.of(3) + )); + var reordered = new LinkedHashMap>(); + reordered.put("Count", LinIntTag.of(3)); + reordered.put("Hello", LinStringTag.of("World!")); + assertThat(map).isEqualTo(reordered); + assertThat(reordered).isEqualTo(map); + assertThat(map.hashCode()).isEqualTo(reordered.hashCode()); + } + + @Test + void entrySetContainsChecksKeyAndValue() { + var map = valueOf(Map.of( + "Hello", LinStringTag.of("World!"), + "Count", LinIntTag.of(3) + )); + var entrySet = map.entrySet(); + assertThat(entrySet.contains(Map.entry("Hello", LinStringTag.of("World!")))).isTrue(); + assertThat(entrySet.contains(Map.entry("Hello", LinStringTag.of("wrong")))).isFalse(); + assertThat(entrySet.contains(Map.entry("missing", LinStringTag.of("World!")))).isFalse(); + assertThat(entrySet.contains((Object) "not an entry")).isFalse(); + } + + @Test + void handlesHashCodeCollisions() { + // Every key built from "Aa"/"BB" pairs shares the same String.hashCode(), so a raw-hashCode + // index would pile them into one probe cluster. They must still all resolve, in order. + var source = new LinkedHashMap>(); + var expectedOrder = new java.util.ArrayList(); + for (int i = 0; i < 256; i++) { + var key = new StringBuilder(); + for (int bit = 0; bit < 8; bit++) { + key.append((i & (1 << bit)) == 0 ? "Aa" : "BB"); + } + source.put(key.toString(), LinIntTag.of(i)); + expectedOrder.add(key.toString()); + } + assertThat(expectedOrder.stream().map(String::hashCode).distinct().count()).isEqualTo(1); + var map = valueOf(source); + assertThat(map).hasSize(256); + for (int i = 0; i < 256; i++) { + assertThat(map.get(expectedOrder.get(i))).isEqualTo(LinIntTag.of(i)); + } + assertThat(List.copyOf(map.keySet())).isEqualTo(expectedOrder); + } + + @Test + void largeMapLookupsAndOrder() { + var source = new LinkedHashMap>(); + var expectedOrder = new java.util.ArrayList(); + for (int i = 0; i < 200; i++) { + String key = "key-" + i; + source.put(key, LinIntTag.of(i)); + expectedOrder.add(key); + } + var map = valueOf(source); + assertThat(map).hasSize(200); + for (int i = 0; i < 200; i++) { + assertThat(map.get("key-" + i)).isEqualTo(LinIntTag.of(i)); + } + assertThat(map.get("key-200")).isNull(); + assertThat(List.copyOf(map.keySet())).isEqualTo(expectedOrder); + } +} diff --git a/tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java b/tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java new file mode 100644 index 0000000..1bea9aa --- /dev/null +++ b/tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +import org.junit.jupiter.api.Test; + +import static com.google.common.truth.Truth.assertThat; + +class SipHashTest { + + // The little-endian outputs from vectors_sip64, for the message lengths in MESSAGE_LENGTHS. + // https://github.com/veorq/SipHash/blob/32d067603b93b47828700880649198e0bfbbcffa/vectors.h + private static final int[] MESSAGE_LENGTHS = {0, 2, 4, 6, 8, 16}; + private static final int[][] VECTORS = { + { + 0x31, + 0x0E, + 0x0E, + 0xDD, + 0x47, + 0xDB, + 0x6F, + 0x72, + }, + { + 0x5A, + 0x4F, + 0xA9, + 0xD9, + 0x09, + 0x80, + 0x6C, + 0x0D, + }, + { + 0xB7, + 0x87, + 0x71, + 0x27, + 0xE0, + 0x94, + 0x27, + 0xCF, + }, + { + 0xCE, + 0xE3, + 0xFE, + 0x58, + 0x6E, + 0x46, + 0xC9, + 0xCB, + }, + { + 0x62, + 0x24, + 0x93, + 0x9A, + 0x79, + 0xF5, + 0xF5, + 0x93, + }, + { + 0xDB, + 0x9B, + 0xC2, + 0x57, + 0x7F, + 0xCC, + 0x2A, + 0x3F, + }, + }; + + private static int[] littleEndianBytes(long value) { + var bytes = new int[Long.BYTES]; + for (int i = 0; i < bytes.length; i++) { + bytes[i] = (int) (value >>> (8 * i)) & 0xFF; + } + return bytes; + } + + /** + * Creates a reference message matching the reference input vectors from the original SipHash implementation. + */ + private static String referenceMessage(int byteLength) { + var chars = new char[byteLength / 2]; + for (int i = 0; i < chars.length; i++) { + chars[i] = (char) ((2 * i) | ((2 * i + 1) << 8)); + } + return new String(chars); + } + + @Test + void matchesReferenceVectors() { + long k0 = 0x07_06_05_04_03_02_01_00L; + long k1 = 0x0F_0E_0D_0C_0B_0A_09_08L; + for (int i = 0; i < MESSAGE_LENGTHS.length; i++) { + long hash = SipHash.hash24(k0, k1, referenceMessage(MESSAGE_LENGTHS[i])); + assertThat(littleEndianBytes(hash)).isEqualTo(VECTORS[i]); + } + } +} From 09fb09bb4905f420cc1267953bb40e21182aa5b5 Mon Sep 17 00:00:00 2001 From: Octavia Togami Date: Tue, 14 Jul 2026 00:23:50 -0700 Subject: [PATCH 4/4] Add JMH benchmarks and optimize CompoundValueMap more --- gradle/libs.versions.toml | 2 + tree/build.gradle.kts | 10 + tree/src/jmh/README.md | 27 ++ .../tree/CompoundValueMapBenchmark.java | 303 ++++++++++++++++++ .../enginehub/linbus/tree/package-info.java | 22 ++ .../linbus/tree/AbstractCompoundValueMap.java | 139 ++++++++ .../linbus/tree/CompoundValueHashMap.java | 164 ++++++++++ .../linbus/tree/CompoundValueLinearMap.java | 59 ++++ .../linbus/tree/CompoundValueMap.java | 214 ------------- .../enginehub/linbus/tree/LinCompoundTag.java | 10 +- .../org/enginehub/linbus/tree/SipHash.java | 95 +++--- .../linbus/tree/CompoundValueMapTest.java | 40 +++ .../enginehub/linbus/tree/SipHashTest.java | 2 +- 13 files changed, 829 insertions(+), 258 deletions(-) create mode 100644 tree/src/jmh/README.md create mode 100644 tree/src/jmh/java/org/enginehub/linbus/tree/CompoundValueMapBenchmark.java create mode 100644 tree/src/jmh/java/org/enginehub/linbus/tree/package-info.java create mode 100644 tree/src/main/java/org/enginehub/linbus/tree/AbstractCompoundValueMap.java create mode 100644 tree/src/main/java/org/enginehub/linbus/tree/CompoundValueHashMap.java create mode 100644 tree/src/main/java/org/enginehub/linbus/tree/CompoundValueLinearMap.java delete mode 100644 tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f5745a..d6bbb36 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,6 +2,7 @@ javafx = "22.0.2" tinylog = "2.7.0" crankcase = "0.1.1" +jmh = "1.37" [plugins] crankcase-java = { id = "org.enginehub.crankcase.java", version.ref = "crankcase" } @@ -10,6 +11,7 @@ crankcase-licensing = { id = "org.enginehub.crankcase.licensing", version.ref = crankcase-publishing = { id = "org.enginehub.crankcase.publishing", version.ref = "crankcase" } crankcase-release = { id = "org.enginehub.crankcase.release", version.ref = "crankcase" } osdetector = { id = "com.google.osdetector", version = "1.7.3" } +jmh = { id = "me.champeau.jmh", version = "0.7.3" } [libraries] crankcase-checkstyle = { module = "org.enginehub.crankcase:checkstyle", version.ref = "crankcase" } diff --git a/tree/build.gradle.kts b/tree/build.gradle.kts index a394082..62de88a 100644 --- a/tree/build.gradle.kts +++ b/tree/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("org.enginehub.lin-bus.java-library-conventions") alias(libs.plugins.crankcase.licensing) alias(libs.plugins.crankcase.publishing) + alias(libs.plugins.jmh) } dependencies { @@ -15,6 +16,15 @@ dependencies { } } +jmh { + jmhVersion = libs.versions.jmh + fork = 2 + warmupIterations = 3 + warmup = "1s" + iterations = 5 + timeOnIteration = "1s" +} + publishing { publications { create("maven") { diff --git a/tree/src/jmh/README.md b/tree/src/jmh/README.md new file mode 100644 index 0000000..a9749f7 --- /dev/null +++ b/tree/src/jmh/README.md @@ -0,0 +1,27 @@ +# CompoundValueMap benchmarks + +JMH benchmarks for the production `CompoundValueMap`, the open-addressed map backing +`LinCompoundTag`. The benchmarks live in the `org.enginehub.linbus.tree` package +so they can construct the package-private map directly. + +`CompoundValueMapBenchmark` covers the map's hot paths across several sizes: + +- `getHit` tests a successful get with a reused key (its `String.hashCode` is cached after the first lookup) +- `getMiss` tests an unsuccessful get with a reused key +- `getHitFreshKey` / `getMissFreshKey` build a new `String` per lookup so the hash is uncached, forcing the map to + rehash the key every time +- `construct` tests the time to build the copy of another map (the source's keys have cached hashes) +- `constructFreshKey` builds from a source whose keys have uncached hashes, so both maps hash every key during the + copy rather than the `LinkedHashMap` path reusing cached hashes +- `iterate` tests the time to iterate over the map's entries, which is a common operation when serializing + +## Running + +```sh +# CPU: all JMH benchmarks (fork/warmup/iteration counts come from the jmh { } block in build.gradle.kts) +./gradlew :tree:jmh + +# CPU + allocation: add `profilers.add("gc")` to the jmh { } block so the benchmarks report +# `gc.alloc.rate.norm` (bytes allocated per op), or run the jar directly: +# ./gradlew :tree:jmhJar && java -jar tree/build/libs/tree-*-jmh.jar -prof gc +``` diff --git a/tree/src/jmh/java/org/enginehub/linbus/tree/CompoundValueMapBenchmark.java b/tree/src/jmh/java/org/enginehub/linbus/tree/CompoundValueMapBenchmark.java new file mode 100644 index 0000000..370a19c --- /dev/null +++ b/tree/src/jmh/java/org/enginehub/linbus/tree/CompoundValueMapBenchmark.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +import org.jspecify.annotations.Nullable; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +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.AbstractMap; +import java.util.AbstractSet; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Random; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +@State(Scope.Benchmark) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +public class CompoundValueMapBenchmark { + public enum MapType { + // Add your modified map here for testing. + COMPOUND_VALUE_HASH_MAP, + COMPOUND_VALUE_LINEAR_MAP, + UNMODIFIABLE_LINKED_HASH_MAP, + ; + + public Map> create(Map> source) { + return switch (this) { + case COMPOUND_VALUE_HASH_MAP -> new CompoundValueHashMap(source); + case COMPOUND_VALUE_LINEAR_MAP -> new CompoundValueLinearMap(source); + case UNMODIFIABLE_LINKED_HASH_MAP -> Collections.unmodifiableMap(new LinkedHashMap<>(source)); + }; + } + } + + public enum KeyCategory { + // Long resource-location-style keys with a shared prefix, e.g. block palette entries. + BLOCK { + @Override + String hitKey(int i) { + return "minecraft:block/entry_" + i + "_value"; + } + + @Override + String missKey(int i) { + return "absent:block/entry_" + i + "_value"; + } + }, + // Short keys like common entity NBT tags. + ENTITY { + @Override + String hitKey(int i) { + int round = i / ENTITY_KEYS.size(); + String base = keysForRound(round).get(i % ENTITY_KEYS.size()); + return round == 0 ? base : base + round; + } + + @Override + String missKey(int i) { + return "xyz" + i; + } + }, + ; + + private static final List ENTITY_KEYS = List.of( + "id", "Pos", "Motion", "Rotation", "FallDistance", "Fire", "Air", "OnGround", "Invulnerable", + "PortalCooldown", "UUID", "CustomName", "CustomNameVisible", "Silent", "NoGravity", "Glowing", + "TicksFrozen", "HasVisualFire", "Tags", "Passengers", "Health", "AbsorptionAmount", "HurtTime", + "HurtByTimestamp", "DeathTime", "FallFlying", "Brain", "Attributes", "ActiveEffects", "HandItems", + "ArmorItems", "HandDropChances", "ArmorDropChances", "DeathLootTable", "CanPickUpLoot", + "PersistenceRequired", "LeftHanded", "NoAI", "Leash", "Age", "ForcedAge", "InLove", "Owner", + "Sitting", "CollarColor", "Count", "Slot", "tag" + ); + + private static int cachedRound = -1; + private static List cachedRoundKeys = ENTITY_KEYS; + + private static List keysForRound(int round) { + if (round == 0) { + return ENTITY_KEYS; + } + if (round != cachedRound) { + ArrayList shuffled = new ArrayList<>(ENTITY_KEYS); + Collections.shuffle(shuffled, new Random(round)); + cachedRoundKeys = shuffled; + cachedRound = round; + } + return cachedRoundKeys; + } + + // A key present in a map of the given size. + abstract String hitKey(int i); + + // A key absent from a map of the given size, of the same shape as the hit keys. + abstract String missKey(int i); + } + + // Some interesting sizes to test. + // "4" is a nice small size. + // "256" may be affected by future optimizations to memory size. + // "4096" gives a little more confidence that the "256" results scale. + // "16384" is a large size that may be affected by future optimizations to memory size. + // "65536" is a very large size that may be affected by future optimizations to memory size. + @Param({"4", "256", "4096", "16384", "65536"}) + int size; + + @Param + @Nullable + MapType mapType; + + @Param + @Nullable + KeyCategory keyCategory; + + @Nullable + Map> source; + // A source whose keys have an uncached hash on every iteration, for the fresh-key construct benchmark. + @Nullable + Map> freshSource; + @Nullable + Map> map; + @Nullable + List hitKeys; + @Nullable + List missKeys; + // The same keys as char[]s, so the fresh-key benchmarks can build a String with an uncached hash per lookup + // without also having a toCharArray() allocation in the loop. + @Nullable + List hitKeyChars; + @Nullable + List missKeyChars; + + @Setup(Level.Trial) + public void setup() { + assert this.keyCategory != null; + this.source = new LinkedHashMap<>(); + List hitKeys = new ArrayList<>(this.size); + List missKeys = new ArrayList<>(this.size); + List hitKeyChars = new ArrayList<>(this.size); + List missKeyChars = new ArrayList<>(this.size); + List> values = new ArrayList<>(this.size); + for (int i = 0; i < this.size; i++) { + String key = this.keyCategory.hitKey(i); + String missKey = this.keyCategory.missKey(i); + LinTag value = LinIntTag.of(i); + hitKeys.add(key); + missKeys.add(missKey); + hitKeyChars.add(key.toCharArray()); + missKeyChars.add(missKey.toCharArray()); + values.add(value); + this.source.put(key, value); + } + this.hitKeys = hitKeys; + this.missKeys = missKeys; + this.hitKeyChars = hitKeyChars; + this.missKeyChars = missKeyChars; + this.freshSource = new FreshKeySource(hitKeyChars, values); + assert mapType != null; + this.map = this.mapType.create(this.source); + } + + @Benchmark + public void getHit(Blackhole blackhole) { + assert this.hitKeys != null; + assert this.map != null; + for (String key : this.hitKeys) { + blackhole.consume(this.map.get(key)); + } + } + + @Benchmark + public void getMiss(Blackhole blackhole) { + assert this.missKeys != null; + assert this.map != null; + for (String key : this.missKeys) { + blackhole.consume(this.map.get(key)); + } + } + + // Fresh key versions prevent the LinkedHashMap from getting an advantage due to cached string hashes. + // This is applicable for schematic loading use cases. + @Benchmark + public void getHitFreshKey(Blackhole blackhole) { + assert this.hitKeyChars != null; + assert this.map != null; + for (char[] key : this.hitKeyChars) { + blackhole.consume(this.map.get(new String(key))); + } + } + + @Benchmark + public void getMissFreshKey(Blackhole blackhole) { + assert this.missKeyChars != null; + assert this.map != null; + for (char[] key : this.missKeyChars) { + blackhole.consume(this.map.get(new String(key))); + } + } + + @Benchmark + public void iterate(Blackhole blackhole) { + assert this.map != null; + for (Map.Entry> entry : this.map.entrySet()) { + blackhole.consume(entry.getKey()); + blackhole.consume(entry.getValue()); + } + } + + @Benchmark + public Map> construct() { + assert this.mapType != null; + assert this.source != null; + return this.mapType.create(this.source); + } + + @Benchmark + public Map> constructFreshKey() { + assert this.mapType != null; + assert this.freshSource != null; + return this.mapType.create(this.freshSource); + } + + // Allows constructing a map from a source without computed hashes for the keys. + // This prevents the LinkedHashMap from getting an advantage due to cached string hashes. + private static final class FreshKeySource extends AbstractMap> { + private final List keys; + private final List> values; + + FreshKeySource(List keys, List> values) { + this.keys = keys; + this.values = values; + } + + @Override + public int size() { + return this.keys.size(); + } + + @Override + public Set>> entrySet() { + return new AbstractSet<>() { + @Override + public Iterator>> iterator() { + return new Iterator<>() { + private int cursor; + + @Override + public boolean hasNext() { + return this.cursor < FreshKeySource.this.keys.size(); + } + + @Override + public Entry> next() { + if (this.cursor >= FreshKeySource.this.keys.size()) { + throw new NoSuchElementException(); + } + int index = this.cursor++; + return new SimpleImmutableEntry<>( + new String(FreshKeySource.this.keys.get(index)), + FreshKeySource.this.values.get(index) + ); + } + }; + } + + @Override + public int size() { + return FreshKeySource.this.keys.size(); + } + }; + } + } +} diff --git a/tree/src/jmh/java/org/enginehub/linbus/tree/package-info.java b/tree/src/jmh/java/org/enginehub/linbus/tree/package-info.java new file mode 100644 index 0000000..3df33e5 --- /dev/null +++ b/tree/src/jmh/java/org/enginehub/linbus/tree/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +@NullMarked +package org.enginehub.linbus.tree; + +import org.jspecify.annotations.NullMarked; diff --git a/tree/src/main/java/org/enginehub/linbus/tree/AbstractCompoundValueMap.java b/tree/src/main/java/org/enginehub/linbus/tree/AbstractCompoundValueMap.java new file mode 100644 index 0000000..02cb2c3 --- /dev/null +++ b/tree/src/main/java/org/enginehub/linbus/tree/AbstractCompoundValueMap.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +import org.jspecify.annotations.Nullable; + +import java.util.AbstractMap; +import java.util.AbstractSet; +import java.util.Iterator; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Objects; +import java.util.Set; + +/** + * Shared base for the immutable, insertion-ordered map backings of {@link LinCompoundTag}. Entries + * are held in parallel {@code keys}/{@code values} arrays in insertion order; subclasses only supply + * the key lookup strategy via {@link #indexOf(Object)}. + */ +abstract class AbstractCompoundValueMap extends AbstractMap> { + final String[] keys; + final LinTag[] values; + + AbstractCompoundValueMap(Map> source) { + int size = source.size(); + this.keys = new String[size]; + this.values = new LinTag[size]; + int i = 0; + for (Map.Entry> entry : source.entrySet()) { + this.keys[i] = Objects.requireNonNull(entry.getKey(), "compound key is null"); + this.values[i] = Objects.requireNonNull(entry.getValue(), "compound value is null"); + i++; + } + } + + /** + * {@return the entry index of the given key, or {@code -1} if it is absent} + * + * @param key the key to look up + */ + abstract int indexOf(@Nullable Object key); + + @Override + public final @Nullable LinTag get(@Nullable Object key) { + int index = indexOf(key); + return index < 0 ? null : this.values[index]; + } + + @Override + public final boolean containsKey(@Nullable Object key) { + return indexOf(key) >= 0; + } + + @Override + public final int size() { + return this.keys.length; + } + + @Override + public final @Nullable LinTag put(String key, LinTag value) { + throw new UnsupportedOperationException(); + } + + @Override + public final @Nullable LinTag remove(Object key) { + throw new UnsupportedOperationException(); + } + + @Override + public final void putAll(Map> m) { + throw new UnsupportedOperationException(); + } + + @Override + public final void clear() { + throw new UnsupportedOperationException(); + } + + @Override + public final Set>> entrySet() { + return new EntrySet(); + } + + private final class EntrySet extends AbstractSet>> { + @Override + public boolean contains(Object o) { + if (!(o instanceof Map.Entry entry)) { + return false; + } + int index = indexOf(entry.getKey()); + return index >= 0 && AbstractCompoundValueMap.this.values[index].equals(entry.getValue()); + } + + @Override + public Iterator>> iterator() { + return new Iterator<>() { + private int cursor; + + @Override + public boolean hasNext() { + return this.cursor < AbstractCompoundValueMap.this.keys.length; + } + + @Override + public Map.Entry> next() { + if (this.cursor >= AbstractCompoundValueMap.this.keys.length) { + throw new NoSuchElementException(); + } + int index = this.cursor++; + return new SimpleImmutableEntry<>( + AbstractCompoundValueMap.this.keys[index], + AbstractCompoundValueMap.this.values[index] + ); + } + }; + } + + @Override + public int size() { + return AbstractCompoundValueMap.this.keys.length; + } + } +} diff --git a/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueHashMap.java b/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueHashMap.java new file mode 100644 index 0000000..937570f --- /dev/null +++ b/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueHashMap.java @@ -0,0 +1,164 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +import org.jspecify.annotations.Nullable; + +import java.security.SecureRandom; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * An immutable, insertion-ordered map backing for {@link LinCompoundTag}, using an open-addressed + * hash table for lookups. + * + *

+ * This is used to avoid the overhead of wrapping a {@link LinkedHashMap} in an unmodifiable view, + * and the cache-unfriendly design of it due to storing 2 extra pointers per entry. + *

+ * + *

+ * For small maps the extra hash is more expensive than just loading the whole array, so + * {@link CompoundValueLinearMap} is recommended instead below + * {@link CompoundValueLinearMap#RECOMMENDED_MAX_LINEAR_SIZE} entries. + *

+ */ +final class CompoundValueHashMap extends AbstractCompoundValueMap { + // There's potential for optimization of memory further here by having dedicated subclasses + // for specific sizes that uses a byte[] or short[] for the table instead. + // I'm unsure if it would affect CPU performance, and it's certainly a lot more code, + // so I'm leaving it as-is for now. + + // K0 and K1 are exposed here for JMH benchmarking, so that a copy of this class can be made that uses the same + // values, so they don't differ between the two classes and affect the benchmark results. + static final long K0; + static final long K1; + + static { + var seed = new SecureRandom(); + K0 = seed.nextLong(); + K1 = seed.nextLong(); + } + + private static int tableSizeFor(int size) { + if (size <= 0) { + return 1; + } + if (size == 1) { + return 2; + } + // The target size is 1.5x the number of entries, for a load factor of 0.67. Chosen because that's what + // Python's dict uses, and because it's funny. + long target = ((long) size) + (size >>> 1); + int capacity = 1; + // Calculate closest power of two >= target, but not exceeding 2^30 (the max capacity for an int[]). + while (capacity < target && capacity < (1 << 30)) { + capacity <<= 1; + } + if (capacity <= size) { + throw new IllegalStateException("Map too large: " + size); + } + return capacity; + } + + // This is trivial enough that JIT will reliably inline it, and hoist it out of loops, + // so it doesn't need caching in even a variable. + private static int mask(int[] table) { + return table.length - 1; + } + + /** + * Try to fill the table with the string hash, and return false if it takes too many probes to insert an entry. + * + * @return {@code true} if successfully filled + */ + private static boolean fillWithStringHash(String[] keys, int[] table) { + // Bound the number of probes by 8 * log2(len). In practice most maps should never go above 3 * log2(len). + int maxSafeProbe = Math.max(16, 8 * Integer.numberOfTrailingZeros(table.length)); + for (int i = 0; i < keys.length; i++) { + int slot = stringHash(keys[i]) & mask(table); + int probes = 0; + while (table[slot] != 0) { + slot = (slot + 1) & mask(table); + probes++; + if (probes > maxSafeProbe) { + return false; + } + } + table[slot] = i + 1; + } + return true; + } + + private static int stringHash(String key) { + // Spread the high bits down, like HashMap, since we mask with a power-of-two table. + int h = key.hashCode(); + return h ^ (h >>> 16); + } + + private static void fillWithSipHash(String[] keys, int[] table) { + for (int i = 0; i < keys.length; i++) { + int slot = sipHash(keys[i]) & mask(table); + while (table[slot] != 0) { + slot = (slot + 1) & mask(table); + } + table[slot] = i + 1; + } + } + + private static int sipHash(String key) { + // SipHash avoids hash flooding attacks, just in case untrusted input is used as keys + // (perhaps if a web tool is used to edit NBT/schematics? or open access schematic folders?). + long h = SipHash.hash(1, 3, K0, K1, key); + return (int) h ^ (int) (h >>> 32); + } + + // Open-addressed index: table[slot] holds entryIndex + 1, with 0 meaning empty. + private final int[] table; + private final boolean usingSipHash; + + CompoundValueHashMap(Map> source) { + super(source); + int[] table = new int[tableSizeFor(this.keys.length)]; + boolean notHashFlooded = fillWithStringHash(this.keys, table); + if (!notHashFlooded) { + table = new int[table.length]; + fillWithSipHash(this.keys, table); + } + this.table = table; + this.usingSipHash = !notHashFlooded; + } + + @Override + int indexOf(@Nullable Object key) { + if (!(key instanceof String stringKey) || this.keys.length == 0) { + return -1; + } + int slot = (this.usingSipHash ? sipHash(stringKey) : stringHash(stringKey)) & mask(this.table); + int probed; + while ((probed = this.table[slot]) != 0) { + int index = probed - 1; + if (this.keys[index].equals(stringKey)) { + return index; + } + slot = (slot + 1) & mask(this.table); + } + return -1; + } +} diff --git a/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueLinearMap.java b/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueLinearMap.java new file mode 100644 index 0000000..9e10acf --- /dev/null +++ b/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueLinearMap.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) EngineHub + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.enginehub.linbus.tree; + +import org.jspecify.annotations.Nullable; + +import java.util.Map; + +/** + * An immutable, insertion-ordered map backing for {@link LinCompoundTag} that looks keys up with a + * linear scan. + */ +final class CompoundValueLinearMap extends AbstractCompoundValueMap { + /** + * The largest map size for which a linear scan is used instead of {@link CompoundValueHashMap}. + * + *

+ * The crossover with the hashmap was measured (JMH, JDK 25) at around 26-28 entries, + * using a slightly lower bound just in case. + *

+ * + * @implNote This should be updated whenever implementation details change. + */ + static final int RECOMMENDED_MAX_LINEAR_SIZE = 24; + + CompoundValueLinearMap(Map> source) { + super(source); + } + + @Override + int indexOf(@Nullable Object key) { + if (!(key instanceof String stringKey)) { + return -1; + } + String[] keys = this.keys; + for (int i = 0; i < keys.length; i++) { + if (keys[i].equals(stringKey)) { + return i; + } + } + return -1; + } +} diff --git a/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java b/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java deleted file mode 100644 index 8813852..0000000 --- a/tree/src/main/java/org/enginehub/linbus/tree/CompoundValueMap.java +++ /dev/null @@ -1,214 +0,0 @@ -/* - * Copyright (c) EngineHub - * Copyright (c) contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.enginehub.linbus.tree; - -import org.jspecify.annotations.Nullable; - -import java.security.SecureRandom; -import java.util.AbstractMap; -import java.util.AbstractSet; -import java.util.Iterator; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.NoSuchElementException; -import java.util.Objects; -import java.util.Set; - -/** - * An immutable, insertion-ordered map backing for {@link LinCompoundTag}. - * - *

- * This is used to avoid the overhead of wrapping a {@link LinkedHashMap} in an unmodifiable view, - * and the cache-unfriendly design of it due to storing 2 extra pointers per entry. - *

- */ -final class CompoundValueMap extends AbstractMap> { - // There's potential for optimization of memory further here by having dedicated subclasses - // for specific sizes that uses a byte[] or short[] for the table instead. - // I'm unsure if it would affect CPU performance, and it's certainly a lot more code, - // so I'm leaving it as-is for now. - - private static final long K0; - private static final long K1; - - static { - var seed = new SecureRandom(); - K0 = seed.nextLong(); - K1 = seed.nextLong(); - } - - private static int tableSizeFor(int size) { - if (size <= 0) { - return 1; - } - if (size == 1) { - return 2; - } - // The target size is 1.5x the number of entries, for a load factor of 0.67. Chosen because that's what - // Python's dict uses, and because it's funny. - long target = ((long) size) + (size >>> 1); - int capacity = 1; - // Calculate closest power of two >= target, but not exceeding 2^30 (the max capacity for an int[]). - while (capacity < target && capacity < (1 << 30)) { - capacity <<= 1; - } - if (capacity <= size) { - throw new IllegalStateException("Map too large: " + size); - } - return capacity; - } - - private static int hash(String key) { - // We use SipHash here to avoid hash flooding attacks, just in case untrusted input is used as keys - // (perhaps if a web tool is used to edit NBT/schematics? or open access schematic folders?). - // As a bonus, this also significantly reduces the likelyhood of hash collisions, which allows us to use - // a larger load factor and reduce memory usage. - long h = SipHash.hash24(K0, K1, key); - return (int) h ^ (int) (h >>> 32); - } - - private final String[] keys; - private final LinTag[] values; - // Open-addressed index: table[slot] holds entryIndex + 1, with 0 meaning empty. - private final int[] table; - private final int mask; - - CompoundValueMap(Map> source) { - int size = source.size(); - this.keys = new String[size]; - this.values = new LinTag[size]; - int capacity = tableSizeFor(size); - this.table = new int[capacity]; - this.mask = capacity - 1; - int i = 0; - for (Map.Entry> entry : source.entrySet()) { - String key = Objects.requireNonNull(entry.getKey(), "compound key is null"); - LinTag value = Objects.requireNonNull(entry.getValue(), "compound value is null"); - this.keys[i] = key; - this.values[i] = value; - insertIndex(key, i); - i++; - } - } - - private void insertIndex(String key, int entryIndex) { - int slot = hash(key) & this.mask; - while (this.table[slot] != 0) { - slot = (slot + 1) & this.mask; - } - this.table[slot] = entryIndex + 1; - } - - private int indexOf(@Nullable Object key) { - if (!(key instanceof String stringKey) || this.keys.length == 0) { - return -1; - } - int slot = hash(stringKey) & this.mask; - int probed; - while ((probed = this.table[slot]) != 0) { - int index = probed - 1; - if (this.keys[index].equals(stringKey)) { - return index; - } - slot = (slot + 1) & this.mask; - } - return -1; - } - - @Override - public @Nullable LinTag get(@Nullable Object key) { - int index = indexOf(key); - return index < 0 ? null : this.values[index]; - } - - @Override - public boolean containsKey(@Nullable Object key) { - return indexOf(key) >= 0; - } - - @Override - public int size() { - return this.keys.length; - } - - @Override - public @Nullable LinTag put(String key, LinTag value) { - throw new UnsupportedOperationException(); - } - - @Override - public @Nullable LinTag remove(Object key) { - throw new UnsupportedOperationException(); - } - - @Override - public void putAll(Map> m) { - throw new UnsupportedOperationException(); - } - - @Override - public void clear() { - throw new UnsupportedOperationException(); - } - - @Override - public Set>> entrySet() { - return new EntrySet(); - } - - private final class EntrySet extends AbstractSet>> { - @Override - public boolean contains(Object o) { - if (!(o instanceof Map.Entry entry)) { - return false; - } - int index = indexOf(entry.getKey()); - return index >= 0 && CompoundValueMap.this.values[index].equals(entry.getValue()); - } - - @Override - public Iterator>> iterator() { - return new Iterator<>() { - private int cursor; - - @Override - public boolean hasNext() { - return this.cursor < CompoundValueMap.this.keys.length; - } - - @Override - public Map.Entry> next() { - if (this.cursor >= CompoundValueMap.this.keys.length) { - throw new NoSuchElementException(); - } - int index = this.cursor++; - return new SimpleImmutableEntry<>( - CompoundValueMap.this.keys[index], - CompoundValueMap.this.values[index] - ); - } - }; - } - - @Override - public int size() { - return CompoundValueMap.this.keys.length; - } - } -} diff --git a/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java b/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java index 046419a..0d05ed3 100644 --- a/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java +++ b/tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java @@ -289,16 +289,20 @@ public static LinCompoundTag readFrom(LinStream tokens) throws IOException { private static Map> copyImmutable( Map> value ) { - if (value.isEmpty()) { + int size = value.size(); + if (size == 0) { // We would like to use the EMPTY constant whenever the map is empty // so we should never reach this. throw new AssertionError("Should not be called with an empty map"); } - if (value.size() == 1) { + if (size == 1) { // Not order retaining, but for a single element it doesn't matter. return Map.copyOf(value); } - return new CompoundValueMap(value); + if (size <= CompoundValueLinearMap.RECOMMENDED_MAX_LINEAR_SIZE) { + return new CompoundValueLinearMap(value); + } + return new CompoundValueHashMap(value); } private final Map> value; diff --git a/tree/src/main/java/org/enginehub/linbus/tree/SipHash.java b/tree/src/main/java/org/enginehub/linbus/tree/SipHash.java index fb983a5..3e03d8b 100644 --- a/tree/src/main/java/org/enginehub/linbus/tree/SipHash.java +++ b/tree/src/main/java/org/enginehub/linbus/tree/SipHash.java @@ -19,30 +19,23 @@ package org.enginehub.linbus.tree; /** - * SipHash-2-4, ported from the CC0-licensed reference implementation at + * SipHash, ported from the CC0-licensed reference implementation at * github.com/veorq/SipHash. */ final class SipHash { - - private static final int COMPRESSION_ROUNDS = 2; - private static final int FINALIZATION_ROUNDS = 4; - /** - * SipHash-2-4 over {@code data}'s chars, each fed as a little-endian 16-bit unit so no - * intermediate {@code byte[]} is allocated. + * SipHash-{@code compressionRounds}-{@code finalizationRounds} over {@code data}'s chars, each fed as a + * little-endian 16-bit unit so no intermediate {@code byte[]} is allocated. * + * @param compressionRounds the number of compression rounds + * @param finalizationRounds the number of finalization rounds * @param k0 the low half of the 128-bit key * @param k1 the high half of the 128-bit key * @param data the string to hash * @return the 64-bit hash */ - static long hash24(long k0, long k1, String data) { - long[] v = { - 0x736F6D6570736575L ^ k0, - 0x646F72616E646F6DL ^ k1, - 0x6C7967656E657261L ^ k0, - 0x7465646279746573L ^ k1, - }; + static long hash(int compressionRounds, int finalizationRounds, long k0, long k1, String data) { + var v = new State(k0, k1); int length = data.length(); int fullBlocks = length & ~3; for (int i = 0; i < fullBlocks; i += 4) { @@ -50,39 +43,61 @@ static long hash24(long k0, long k1, String data) { | (data.charAt(i + 1) & 0xFFFFL) << 16 | (data.charAt(i + 2) & 0xFFFFL) << 32 | (data.charAt(i + 3) & 0xFFFFL) << 48; - v[3] ^= m; - compress(v, COMPRESSION_ROUNDS); - v[0] ^= m; + v.v3 ^= m; + v.compress(compressionRounds); + v.v0 ^= m; } long b = ((long) length * 2) << 56; for (int i = fullBlocks; i < length; i++) { b |= (data.charAt(i) & 0xFFFFL) << ((i - fullBlocks) * 16); } - v[3] ^= b; - compress(v, COMPRESSION_ROUNDS); - v[0] ^= b; - v[2] ^= 0xFF; - compress(v, FINALIZATION_ROUNDS); - return v[0] ^ v[1] ^ v[2] ^ v[3]; + v.v3 ^= b; + v.compress(compressionRounds); + v.v0 ^= b; + v.v2 ^= 0xFF; + v.compress(finalizationRounds); + return v.v0 ^ v.v1 ^ v.v2 ^ v.v3; } - private static void compress(long[] v, int rounds) { - for (int r = 0; r < rounds; r++) { - // See SIPROUND macro in the reference implementation. - v[0] += v[1]; - v[1] = Long.rotateLeft(v[1], 13); - v[1] ^= v[0]; - v[0] = Long.rotateLeft(v[0], 32); - v[2] += v[3]; - v[3] = Long.rotateLeft(v[3], 16); - v[3] ^= v[2]; - v[0] += v[3]; - v[3] = Long.rotateLeft(v[3], 21); - v[3] ^= v[0]; - v[2] += v[1]; - v[1] = Long.rotateLeft(v[1], 17); - v[1] ^= v[2]; - v[2] = Long.rotateLeft(v[2], 32); + /** + * Internal state of the SipHash algorithm. + * + *

+ * Uses a class instead of an array, as it proved to inline better in benchmarks. + * In the future, we can also convert it to a value class, which will inline even better. + *

+ */ + private static final class State { + long v0; + long v1; + long v2; + long v3; + + State(long k0, long k1) { + this.v0 = 0x736F6D6570736575L ^ k0; + this.v1 = 0x646F72616E646F6DL ^ k1; + this.v2 = 0x6C7967656E657261L ^ k0; + this.v3 = 0x7465646279746573L ^ k1; + } + + void compress(int rounds) { + for (int r = 0; r < rounds; r++) { + // See SIPROUND macro in the reference implementation. + this.v0 += this.v1; + this.v1 = Long.rotateLeft(this.v1, 13); + this.v1 ^= this.v0; + this.v0 = Long.rotateLeft(this.v0, 32); + this.v2 += this.v3; + this.v3 = Long.rotateLeft(this.v3, 16); + this.v3 ^= this.v2; + this.v0 += this.v3; + this.v3 = Long.rotateLeft(this.v3, 21); + this.v3 ^= this.v0; + this.v2 += this.v1; + this.v1 = Long.rotateLeft(this.v1, 17); + this.v1 ^= this.v2; + this.v2 = Long.rotateLeft(this.v2, 32); + } } } diff --git a/tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java b/tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java index cd75c68..02137a1 100644 --- a/tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java +++ b/tree/src/test/java/org/enginehub/linbus/tree/CompoundValueMapTest.java @@ -25,6 +25,7 @@ import java.util.Map; import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; import static org.junit.jupiter.api.Assertions.assertThrows; public class CompoundValueMapTest { @@ -125,6 +126,45 @@ void handlesHashCodeCollisions() { assertThat(List.copyOf(map.keySet())).isEqualTo(expectedOrder); } + private static Map> sizedSource(int size) { + var source = new LinkedHashMap>(); + for (int i = 0; i < size; i++) { + source.put("entry_" + i, LinIntTag.of(i)); + } + return source; + } + + @Test + void selectsLinearBelowThresholdAndHashAbove() { + assertThat(valueOf(sizedSource(2))) + .isInstanceOf(CompoundValueLinearMap.class); + assertThat(valueOf(sizedSource(CompoundValueLinearMap.RECOMMENDED_MAX_LINEAR_SIZE))) + .isInstanceOf(CompoundValueLinearMap.class); + assertThat(valueOf(sizedSource(CompoundValueLinearMap.RECOMMENDED_MAX_LINEAR_SIZE + 1))) + .isInstanceOf(CompoundValueHashMap.class); + } + + @Test + void behaviorIsConsistentAcrossBackingMaps() { + for (int size : new int[] { + 2, + CompoundValueLinearMap.RECOMMENDED_MAX_LINEAR_SIZE, + CompoundValueLinearMap.RECOMMENDED_MAX_LINEAR_SIZE + 1, + 100, + }) { + Map> source = sizedSource(size); + var map = valueOf(source); + assertWithMessage("size %s", size).that(map).hasSize(size); + for (int i = 0; i < size; i++) { + assertWithMessage("size %s key entry_%s", size, i) + .that(map.get("entry_" + i)).isEqualTo(LinIntTag.of(i)); + } + assertWithMessage("size %s miss", size).that(map.get("entry_" + size)).isNull(); + assertWithMessage("size %s order", size) + .that(List.copyOf(map.keySet())).isEqualTo(List.copyOf(source.keySet())); + } + } + @Test void largeMapLookupsAndOrder() { var source = new LinkedHashMap>(); diff --git a/tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java b/tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java index 1bea9aa..b3b27c7 100644 --- a/tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java +++ b/tree/src/test/java/org/enginehub/linbus/tree/SipHashTest.java @@ -114,7 +114,7 @@ void matchesReferenceVectors() { long k0 = 0x07_06_05_04_03_02_01_00L; long k1 = 0x0F_0E_0D_0C_0B_0A_09_08L; for (int i = 0; i < MESSAGE_LENGTHS.length; i++) { - long hash = SipHash.hash24(k0, k1, referenceMessage(MESSAGE_LENGTHS[i])); + long hash = SipHash.hash(2, 4, k0, k1, referenceMessage(MESSAGE_LENGTHS[i])); assertThat(littleEndianBytes(hash)).isEqualTo(VECTORS[i]); } }