Skip to content

Commit 71c752a

Browse files
committed
Use an optimized map for compound tag
See the class comments for details on design decisions
1 parent 8b467da commit 71c752a

5 files changed

Lines changed: 584 additions & 5 deletions

File tree

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
/*
2+
* Copyright (c) EngineHub <https://enginehub.org>
3+
* Copyright (c) contributors
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package org.enginehub.linbus.tree;
20+
21+
import org.jspecify.annotations.Nullable;
22+
23+
import java.security.SecureRandom;
24+
import java.util.AbstractMap;
25+
import java.util.AbstractSet;
26+
import java.util.Iterator;
27+
import java.util.LinkedHashMap;
28+
import java.util.Map;
29+
import java.util.NoSuchElementException;
30+
import java.util.Objects;
31+
import java.util.Set;
32+
33+
/**
34+
* An immutable, insertion-ordered map backing for {@link LinCompoundTag}.
35+
*
36+
* <p>
37+
* This is used to avoid the overhead of wrapping a {@link LinkedHashMap} in an unmodifiable view,
38+
* and the cache-unfriendly design of it due to storing 2 extra pointers per entry.
39+
* </p>
40+
*/
41+
final class CompoundValueMap extends AbstractMap<String, LinTag<?>> {
42+
// There's potential for optimization of memory further here by having dedicated subclasses
43+
// for specific sizes that uses a byte[] or short[] for the table instead.
44+
// I'm unsure if it would affect CPU performance, and it's certainly a lot more code,
45+
// so I'm leaving it as-is for now.
46+
47+
private static final long K0;
48+
private static final long K1;
49+
50+
static {
51+
var seed = new SecureRandom();
52+
K0 = seed.nextLong();
53+
K1 = seed.nextLong();
54+
}
55+
56+
private static int tableSizeFor(int size) {
57+
if (size <= 0) {
58+
return 1;
59+
}
60+
if (size == 1) {
61+
return 2;
62+
}
63+
// The target size is 1.5x the number of entries, for a load factor of 0.67. Chosen because that's what
64+
// Python's dict uses, and because it's funny.
65+
long target = ((long) size) + (size >>> 1);
66+
int capacity = 1;
67+
// Calculate closest power of two >= target, but not exceeding 2^30 (the max capacity for an int[]).
68+
while (capacity < target && capacity < (1 << 30)) {
69+
capacity <<= 1;
70+
}
71+
if (capacity <= size) {
72+
throw new IllegalStateException("Map too large: " + size);
73+
}
74+
return capacity;
75+
}
76+
77+
private static int hash(String key) {
78+
// We use SipHash here to avoid hash flooding attacks, just in case untrusted input is used as keys
79+
// (perhaps if a web tool is used to edit NBT/schematics? or open access schematic folders?).
80+
// As a bonus, this also significantly reduces the likelyhood of hash collisions, which allows us to use
81+
// a larger load factor and reduce memory usage.
82+
long h = SipHash.hash24(K0, K1, key);
83+
return (int) h ^ (int) (h >>> 32);
84+
}
85+
86+
private final String[] keys;
87+
private final LinTag<?>[] values;
88+
// Open-addressed index: table[slot] holds entryIndex + 1, with 0 meaning empty.
89+
private final int[] table;
90+
private final int mask;
91+
92+
CompoundValueMap(Map<String, ? extends LinTag<?>> source) {
93+
int size = source.size();
94+
this.keys = new String[size];
95+
this.values = new LinTag<?>[size];
96+
int capacity = tableSizeFor(size);
97+
this.table = new int[capacity];
98+
this.mask = capacity - 1;
99+
int i = 0;
100+
for (Map.Entry<String, ? extends LinTag<?>> entry : source.entrySet()) {
101+
String key = Objects.requireNonNull(entry.getKey(), "compound key is null");
102+
LinTag<?> value = Objects.requireNonNull(entry.getValue(), "compound value is null");
103+
this.keys[i] = key;
104+
this.values[i] = value;
105+
insertIndex(key, i);
106+
i++;
107+
}
108+
}
109+
110+
private void insertIndex(String key, int entryIndex) {
111+
int slot = hash(key) & this.mask;
112+
while (this.table[slot] != 0) {
113+
slot = (slot + 1) & this.mask;
114+
}
115+
this.table[slot] = entryIndex + 1;
116+
}
117+
118+
private int indexOf(@Nullable Object key) {
119+
if (!(key instanceof String stringKey) || this.keys.length == 0) {
120+
return -1;
121+
}
122+
int slot = hash(stringKey) & this.mask;
123+
int probed;
124+
while ((probed = this.table[slot]) != 0) {
125+
int index = probed - 1;
126+
if (this.keys[index].equals(stringKey)) {
127+
return index;
128+
}
129+
slot = (slot + 1) & this.mask;
130+
}
131+
return -1;
132+
}
133+
134+
@Override
135+
public @Nullable LinTag<?> get(@Nullable Object key) {
136+
int index = indexOf(key);
137+
return index < 0 ? null : this.values[index];
138+
}
139+
140+
@Override
141+
public boolean containsKey(@Nullable Object key) {
142+
return indexOf(key) >= 0;
143+
}
144+
145+
@Override
146+
public int size() {
147+
return this.keys.length;
148+
}
149+
150+
@Override
151+
public @Nullable LinTag<?> put(String key, LinTag<?> value) {
152+
throw new UnsupportedOperationException();
153+
}
154+
155+
@Override
156+
public @Nullable LinTag<?> remove(Object key) {
157+
throw new UnsupportedOperationException();
158+
}
159+
160+
@Override
161+
public void putAll(Map<? extends String, ? extends LinTag<?>> m) {
162+
throw new UnsupportedOperationException();
163+
}
164+
165+
@Override
166+
public void clear() {
167+
throw new UnsupportedOperationException();
168+
}
169+
170+
@Override
171+
public Set<Map.Entry<String, LinTag<?>>> entrySet() {
172+
return new EntrySet();
173+
}
174+
175+
private final class EntrySet extends AbstractSet<Map.Entry<String, LinTag<?>>> {
176+
@Override
177+
public boolean contains(Object o) {
178+
if (!(o instanceof Map.Entry<?, ?> entry)) {
179+
return false;
180+
}
181+
int index = indexOf(entry.getKey());
182+
return index >= 0 && CompoundValueMap.this.values[index].equals(entry.getValue());
183+
}
184+
185+
@Override
186+
public Iterator<Map.Entry<String, LinTag<?>>> iterator() {
187+
return new Iterator<>() {
188+
private int cursor;
189+
190+
@Override
191+
public boolean hasNext() {
192+
return this.cursor < CompoundValueMap.this.keys.length;
193+
}
194+
195+
@Override
196+
public Map.Entry<String, LinTag<?>> next() {
197+
if (this.cursor >= CompoundValueMap.this.keys.length) {
198+
throw new NoSuchElementException();
199+
}
200+
int index = this.cursor++;
201+
return new SimpleImmutableEntry<>(
202+
CompoundValueMap.this.keys[index],
203+
CompoundValueMap.this.values[index]
204+
);
205+
}
206+
};
207+
}
208+
209+
@Override
210+
public int size() {
211+
return CompoundValueMap.this.keys.length;
212+
}
213+
}
214+
}

tree/src/main/java/org/enginehub/linbus/tree/LinCompoundTag.java

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
import org.jspecify.annotations.Nullable;
2929

3030
import java.io.IOException;
31-
import java.util.Collections;
3231
import java.util.Iterator;
3332
import java.util.LinkedHashMap;
3433
import java.util.List;
@@ -47,7 +46,7 @@ public final class LinCompoundTag extends LinTag<Map<String, ? extends LinTag<?>
4746
*
4847
* <p>
4948
* The map <em>will not</em> be copied using {@link Map#copyOf(Map)}, as that fails to preserve order. Instead, the
50-
* map will be copied using {@link LinkedHashMap#LinkedHashMap(Map)}.
49+
* map will be copied into an immutable, insertion-ordered map.
5150
* </p>
5251
*
5352
* @param value the value
@@ -159,7 +158,7 @@ public Builder putByte(String name, byte value) {
159158
* @return this builder
160159
*/
161160
public Builder putCompound(String name, Map<String, ? extends LinTag<?>> value) {
162-
return put(name, new LinCompoundTag(copyImmutable(value), true));
161+
return put(name, of(value));
163162
}
164163

165164
/**
@@ -290,7 +289,16 @@ public static LinCompoundTag readFrom(LinStream tokens) throws IOException {
290289
private static Map<String, LinTag<?>> copyImmutable(
291290
Map<String, ? extends LinTag<?>> value
292291
) {
293-
return Collections.unmodifiableMap(new LinkedHashMap<>(value));
292+
if (value.isEmpty()) {
293+
// We would like to use the EMPTY constant whenever the map is empty
294+
// so we should never reach this.
295+
throw new AssertionError("Should not be called with an empty map");
296+
}
297+
if (value.size() == 1) {
298+
// Not order retaining, but for a single element it doesn't matter.
299+
return Map.copyOf(value);
300+
}
301+
return new CompoundValueMap(value);
294302
}
295303

296304
private final Map<String, LinTag<?>> value;
@@ -436,7 +444,7 @@ private LinCompoundTag withChangedTag(String name, LinTag<?> value) {
436444
}
437445
LinkedHashMap<String, LinTag<?>> newMap = new LinkedHashMap<>(this.value);
438446
newMap.put(name, value);
439-
return new LinCompoundTag(Collections.unmodifiableMap(newMap), false);
447+
return new LinCompoundTag(copyImmutable(newMap), false);
440448
}
441449

442450
/**
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) EngineHub <https://enginehub.org>
3+
* Copyright (c) contributors
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package org.enginehub.linbus.tree;
20+
21+
/**
22+
* SipHash-2-4, ported from the CC0-licensed reference implementation at
23+
* <a href="https://github.com/veorq/SipHash">github.com/veorq/SipHash</a>.
24+
*/
25+
final class SipHash {
26+
27+
private static final int COMPRESSION_ROUNDS = 2;
28+
private static final int FINALIZATION_ROUNDS = 4;
29+
30+
/**
31+
* SipHash-2-4 over {@code data}'s chars, each fed as a little-endian 16-bit unit so no
32+
* intermediate {@code byte[]} is allocated.
33+
*
34+
* @param k0 the low half of the 128-bit key
35+
* @param k1 the high half of the 128-bit key
36+
* @param data the string to hash
37+
* @return the 64-bit hash
38+
*/
39+
static long hash24(long k0, long k1, String data) {
40+
long[] v = {
41+
0x736F6D6570736575L ^ k0,
42+
0x646F72616E646F6DL ^ k1,
43+
0x6C7967656E657261L ^ k0,
44+
0x7465646279746573L ^ k1,
45+
};
46+
int length = data.length();
47+
int fullBlocks = length & ~3;
48+
for (int i = 0; i < fullBlocks; i += 4) {
49+
long m = (data.charAt(i) & 0xFFFFL)
50+
| (data.charAt(i + 1) & 0xFFFFL) << 16
51+
| (data.charAt(i + 2) & 0xFFFFL) << 32
52+
| (data.charAt(i + 3) & 0xFFFFL) << 48;
53+
v[3] ^= m;
54+
compress(v, COMPRESSION_ROUNDS);
55+
v[0] ^= m;
56+
}
57+
long b = ((long) length * 2) << 56;
58+
for (int i = fullBlocks; i < length; i++) {
59+
b |= (data.charAt(i) & 0xFFFFL) << ((i - fullBlocks) * 16);
60+
}
61+
v[3] ^= b;
62+
compress(v, COMPRESSION_ROUNDS);
63+
v[0] ^= b;
64+
v[2] ^= 0xFF;
65+
compress(v, FINALIZATION_ROUNDS);
66+
return v[0] ^ v[1] ^ v[2] ^ v[3];
67+
}
68+
69+
private static void compress(long[] v, int rounds) {
70+
for (int r = 0; r < rounds; r++) {
71+
// See SIPROUND macro in the reference implementation.
72+
v[0] += v[1];
73+
v[1] = Long.rotateLeft(v[1], 13);
74+
v[1] ^= v[0];
75+
v[0] = Long.rotateLeft(v[0], 32);
76+
v[2] += v[3];
77+
v[3] = Long.rotateLeft(v[3], 16);
78+
v[3] ^= v[2];
79+
v[0] += v[3];
80+
v[3] = Long.rotateLeft(v[3], 21);
81+
v[3] ^= v[0];
82+
v[2] += v[1];
83+
v[1] = Long.rotateLeft(v[1], 17);
84+
v[1] ^= v[2];
85+
v[2] = Long.rotateLeft(v[2], 32);
86+
}
87+
}
88+
89+
private SipHash() {
90+
}
91+
}

0 commit comments

Comments
 (0)