From f37d6788dc980f6c32865489ecf2fad02da0917c Mon Sep 17 00:00:00 2001 From: Jason Koch Date: Wed, 20 Nov 2024 16:31:51 -0800 Subject: [PATCH 1/2] refactor: Extract interfaces for NodeLocator algos A HashRingAlgorithm defines the algorithm that will be used to split a node into a ring. It assumes the ring will be Ketama-styled in that one node can be responsible for multiple keyspace ranges. Hash-rings can also be 'Simple' when they have a 1:1 mapping (one node receives one keyspace). A NodeLocatorLookup carries the data layout and associated search functions to locate a node for a given hash in the hashring. We also wire in the EVCacheNodeLocator to use the existing default implementations. --- .../evcache/pool/EVCacheNodeLocator.java | 86 +++++++++---------- .../evcache/pool/HashRingAlgorithm.java | 65 ++++++++++++++ .../evcache/pool/NodeLocatorLookup.java | 36 ++++++++ .../evcache/pool/NodeLocatorLookupTest.java | 36 ++++++++ evcache-core/src/test/java/test-suite.xml | 7 +- 5 files changed, 185 insertions(+), 45 deletions(-) create mode 100644 evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java create mode 100644 evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java create mode 100644 evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java diff --git a/evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheNodeLocator.java b/evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheNodeLocator.java index 62da2d5e..0ef51b98 100644 --- a/evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheNodeLocator.java +++ b/evcache-core/src/main/java/com/netflix/evcache/pool/EVCacheNodeLocator.java @@ -7,11 +7,15 @@ import java.util.List; import java.util.Map; import java.util.TreeMap; +import java.util.function.Function; import com.netflix.archaius.api.Property; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import com.netflix.evcache.pool.HashRingAlgorithm.SimpleHashRingAlgorithm; +import com.netflix.evcache.pool.HashRingAlgorithm.KetamaMd5HashRingAlgorithm; +import com.netflix.evcache.pool.NodeLocatorLookup.TreeMapNodeLocatorLookup; import com.netflix.evcache.util.EVCacheConfig; import net.spy.memcached.DefaultHashAlgorithm; @@ -24,15 +28,17 @@ public class EVCacheNodeLocator implements NodeLocator { private static final Logger log = LoggerFactory.getLogger(EVCacheNodeLocator.class); - private TreeMap ketamaNodes; + private TreeMap ketamaNodesTreeMap; + private NodeLocatorLookup ketamaNodes; protected final EVCacheClient client; + private final Function, NodeLocatorLookup> lookupFactory; private final Property partialStringHash; private final Property hashDelimiter; private final Collection allNodes; - private final HashAlgorithm hashingAlgorithm; + private final HashRingAlgorithm hashRingAlgorithm; private final KetamaNodeLocatorConfiguration config; /** @@ -47,12 +53,13 @@ public class EVCacheNodeLocator implements NodeLocator { * consistent hash continuum * @param conf */ - public EVCacheNodeLocator(EVCacheClient client, List nodes, HashAlgorithm alg, KetamaNodeLocatorConfiguration conf) { + public EVCacheNodeLocator(EVCacheClient client, List nodes, HashRingAlgorithm hashRingAlgorithm, KetamaNodeLocatorConfiguration conf, Function, NodeLocatorLookup> lookupFactory) { super(); this.allNodes = nodes; - this.hashingAlgorithm = alg; + this.hashRingAlgorithm = hashRingAlgorithm; this.config = conf; this.client = client; + this.lookupFactory = lookupFactory; this.partialStringHash = EVCacheConfig.getInstance().getPropertyRepository().get(client.getAppName() + "." + client.getServerGroupName() + ".hash.on.partial.key", Boolean.class) .orElseGet(client.getAppName()+ ".hash.on.partial.key").orElse(false); @@ -63,11 +70,22 @@ public EVCacheNodeLocator(EVCacheClient client, List nodes, HashA setKetamaNodes(nodes); } - private EVCacheNodeLocator(EVCacheClient client, TreeMap smn, Collection an, HashAlgorithm alg, KetamaNodeLocatorConfiguration conf) { + public EVCacheNodeLocator(EVCacheClient client, List nodes, HashAlgorithm alg, KetamaNodeLocatorConfiguration conf) { + this(client, + nodes, + alg == DefaultHashAlgorithm.KETAMA_HASH ? new KetamaMd5HashRingAlgorithm() + : new SimpleHashRingAlgorithm(alg), + conf, + TreeMapNodeLocatorLookup::new); + } + + private EVCacheNodeLocator(EVCacheClient client, TreeMap smn, Collection an, HashRingAlgorithm hashRingAlgorithm, KetamaNodeLocatorConfiguration conf, Function, NodeLocatorLookup> lookupFactory) { super(); - this.ketamaNodes = smn; + this.ketamaNodes = lookupFactory.apply(smn); + this.lookupFactory = lookupFactory; + this.ketamaNodesTreeMap = smn; this.allNodes = an; - this.hashingAlgorithm = alg; + this.hashRingAlgorithm = hashRingAlgorithm; this.config = conf; this.client = client; @@ -88,20 +106,17 @@ public Collection getAll() { * @see net.spy.memcached.NodeLocator#getPrimary */ public MemcachedNode getPrimary(String k) { + CharSequence key = k; if (partialStringHash.get()) { final int index = k.indexOf(hashDelimiter.get()); if (index > 0) { - k = k.substring(0, index); + key = k.subSequence(0, index); } } - final long hash = hashingAlgorithm.hash(k); + final long hash = hashRingAlgorithm.hash(key); - Map.Entry entry = ketamaNodes.ceilingEntry(hash); - if (entry == null) { - entry = ketamaNodes.firstEntry(); - } - return entry.getValue(); + return ketamaNodes.wrappingCeilingValue(hash); } /* @@ -114,12 +129,7 @@ public long getMaxKey() { public MemcachedNode getNodeForKey(long _hash) { long start = (log.isDebugEnabled()) ? System.nanoTime() : 0; try { - Long hash = Long.valueOf(_hash); - hash = ketamaNodes.ceilingKey(hash); - if (hash == null) { - hash = ketamaNodes.firstKey(); - } - return ketamaNodes.get(hash); + return ketamaNodes.wrappingCeilingValue(_hash); } finally { if (log.isDebugEnabled()) { final long end = System.nanoTime(); @@ -147,14 +157,14 @@ public NodeLocator getReadonlyCopy() { aNodes.add(new EVCacheMemcachedNodeROImpl(n)); } - return new EVCacheNodeLocator(client, ketamaNaodes, aNodes, hashingAlgorithm, config); + return new EVCacheNodeLocator(client, ketamaNaodes, aNodes, hashRingAlgorithm, config, lookupFactory); } /** * @return the ketamaNodes */ protected TreeMap getKetamaNodes() { - return ketamaNodes; + return ketamaNodesTreeMap; } /** @@ -162,7 +172,7 @@ protected TreeMap getKetamaNodes() { * purposes */ public Map getKetamaNodeMap() { - return Collections. unmodifiableMap(ketamaNodes); + return Collections. unmodifiableMap(ketamaNodesTreeMap); } /** @@ -175,26 +185,13 @@ public Map getKetamaNodeMap() { protected final void setKetamaNodes(List nodes) { TreeMap newNodeMap = new TreeMap(); final int numReps = config.getNodeRepetitions(); + long[] parts = new long[hashRingAlgorithm.getCountHashParts()]; for (MemcachedNode node : nodes) { - // Ketama does some special work with md5 where it reuses chunks. - if (hashingAlgorithm == DefaultHashAlgorithm.KETAMA_HASH) { - for (int i = 0; i < numReps / 4; i++) { - final String hashString = config.getKeyForNode(node, i); - byte[] digest = DefaultHashAlgorithm.computeMd5(hashString); - if (log.isDebugEnabled()) log.debug("digest : " + digest); - for (int h = 0; h < 4; h++) { - long k = ((long) (digest[3 + h * 4] & 0xFF) << 24) - | ((long) (digest[2 + h * 4] & 0xFF) << 16) - | ((long) (digest[1 + h * 4] & 0xFF) << 8) - | (digest[h * 4] & 0xFF); - newNodeMap.put(Long.valueOf(k), node); - if (log.isDebugEnabled()) log.debug("Key : " + hashString + " ; hash : " + k + "; node " + node ); - } - } - } else { - for (int i = 0; i < numReps; i++) { - final Long hashL = Long.valueOf(hashingAlgorithm.hash(config.getKeyForNode(node, i))); - newNodeMap.put(hashL, node); + for (int i = 0; i < numReps / 4; i++) { + final String hashString = config.getKeyForNode(node, i); + hashRingAlgorithm.getHashPartsInto(hashString, parts); + for (int h = 0; h < parts.length; h++) { + newNodeMap.put(Long.valueOf(parts[h]), node); } } } @@ -204,7 +201,8 @@ protected final void setKetamaNodes(List nodes) { log.trace("Hash : " + key + "; Node : " + newNodeMap.get(key)); } } - ketamaNodes = newNodeMap; + ketamaNodes = lookupFactory.apply(newNodeMap); + ketamaNodesTreeMap = newNodeMap; } @Override @@ -215,7 +213,7 @@ public void updateLocator(List nodes) { @Override public String toString() { return "EVCacheNodeLocator [ketamaNodes=" + ketamaNodes + ", EVCacheClient=" + client + ", partialStringHash=" + partialStringHash - + ", hashDelimiter=" + hashDelimiter + ", allNodes=" + allNodes + ", hashingAlgorithm=" + hashingAlgorithm + ", config=" + config + "]"; + + ", hashDelimiter=" + hashDelimiter + ", allNodes=" + allNodes + ", hashRingAlgorithm=" + hashRingAlgorithm + ", config=" + config + "]"; } } diff --git a/evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java b/evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java new file mode 100644 index 00000000..cfde961c --- /dev/null +++ b/evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java @@ -0,0 +1,65 @@ +package com.netflix.evcache.pool; + +import net.spy.memcached.DefaultHashAlgorithm; +import net.spy.memcached.HashAlgorithm; + +/** + * Description of a hash ring algorithm. A hash ring algorithm has a hash + * function, and a way to split the hash into parts in the case of a + * ketama-like algorithm. + */ +public interface HashRingAlgorithm { + + long hash(CharSequence key); + + int getCountHashParts(); + + void getHashPartsInto(CharSequence key, long[] parts); + + static class SimpleHashRingAlgorithm implements HashRingAlgorithm { + HashAlgorithm hashAlgorithm; + + public SimpleHashRingAlgorithm(HashAlgorithm hashAlgorithm) { + this.hashAlgorithm = hashAlgorithm; + } + + @Override + public long hash(CharSequence key) { + return hashAlgorithm.hash(key.toString()); + } + + @Override + public int getCountHashParts() { + return 1; + } + + @Override + public void getHashPartsInto(CharSequence key, long[] parts) { + parts[0] = hash(key); + } + } + + static class KetamaMd5HashRingAlgorithm implements HashRingAlgorithm { + @Override + public long hash(CharSequence key) { + return DefaultHashAlgorithm.KETAMA_HASH.hash(key.toString()); + } + + @Override + public int getCountHashParts() { + return 4; + } + + @Override + public void getHashPartsInto(CharSequence key, long[] parts) { + byte[] digest = DefaultHashAlgorithm.computeMd5(key.toString()); + for (int h = 0; h < 4; h++) { + parts[h] = ((long) (digest[3 + h * 4] & 0xFF) << 24) + | ((long) (digest[2 + h * 4] & 0xFF) << 16) + | ((long) (digest[1 + h * 4] & 0xFF) << 8) + | (digest[h * 4] & 0xFF); + } + } + } + +} diff --git a/evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java b/evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java new file mode 100644 index 00000000..3a9c24fa --- /dev/null +++ b/evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java @@ -0,0 +1,36 @@ +package com.netflix.evcache.pool; + +import java.util.Map; +import java.util.TreeMap; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.HashMap; + +/** + * A lookup for the node locator. + * + * The hash is a 64-bit long, but the value is masked to a 32-bit int and the + * upper 32-bits are ignored. + */ +public interface NodeLocatorLookup { + V wrappingCeilingValue(long hash); + + static class TreeMapNodeLocatorLookup implements NodeLocatorLookup { + + private final TreeMap map; + + TreeMapNodeLocatorLookup(TreeMap map) { + this.map = map; + } + + @Override + public V wrappingCeilingValue(long hash) { + Map.Entry entry = map.ceilingEntry(hash); + if (entry == null) { + entry = map.firstEntry(); + } + return entry.getValue(); + } + } + +} diff --git a/evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java b/evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java new file mode 100644 index 00000000..106e314d --- /dev/null +++ b/evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java @@ -0,0 +1,36 @@ +package com.netflix.evcache.pool; + +import org.testng.annotations.Test; +import static org.testng.Assert.*; + +import java.util.TreeMap; + +public class NodeLocatorLookupTest { + + @Test + public void testTreeMapNodeLocatorLookup() { + TreeMap map = new TreeMap<>(); + map.put(10L, "node1"); + map.put(20L, "node2"); + map.put(30L, "node3"); + + NodeLocatorLookup lookup = new NodeLocatorLookup.TreeMapNodeLocatorLookup<>(map); + + // Test exact matches + assertEquals(lookup.wrappingCeilingValue(10L), "node1"); + assertEquals(lookup.wrappingCeilingValue(20L), "node2"); + assertEquals(lookup.wrappingCeilingValue(30L), "node3"); + + // Test ceiling behavior + assertEquals(lookup.wrappingCeilingValue(15L), "node2"); + assertEquals(lookup.wrappingCeilingValue(25L), "node3"); + + // Test wrapping behavior (values greater than max should wrap to first node) + assertEquals(lookup.wrappingCeilingValue(35L), "node1"); + assertEquals(lookup.wrappingCeilingValue(Long.MAX_VALUE), "node1"); + + // Test values less than min + assertEquals(lookup.wrappingCeilingValue(5L), "node1"); + assertEquals(lookup.wrappingCeilingValue(0L), "node1"); + } +} diff --git a/evcache-core/src/test/java/test-suite.xml b/evcache-core/src/test/java/test-suite.xml index 1e02b2ad..f031a615 100644 --- a/evcache-core/src/test/java/test-suite.xml +++ b/evcache-core/src/test/java/test-suite.xml @@ -1,5 +1,10 @@ + + + + + @@ -12,4 +17,4 @@ --> - \ No newline at end of file + From 9c688fdd770937981de009fa42b3d5ad120d101f Mon Sep 17 00:00:00 2001 From: Jason Koch Date: Wed, 20 Nov 2024 16:36:38 -0800 Subject: [PATCH 2/2] perf: Add more impls for hash-ring and node-lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This introduces a combination of options for hash-ring generation and for the node lookup locator. This does not change any of the default configuration, but paves the way for future configuration to use the new code. In the right configuration this will generate anywhere from a 30% to a 10x increase in throughput while still allowing the choice of a backwards-compatible configuration. Also introduce some tests and benchmarks to demonstrate the impact. Node lookup - legacy uses the existing TreeMap implementation, this is the existing implementation. - array uses a straightforward sorted array of all values and binary-search, this is backwards compatible. - eytzinger uses a BFS-sorted array with a more efficient search tree (aka Eytzinger index), this is backwards compatible. - direct uses an efficient direct-array index approximation of the ketama hash-space, this is not backwards compatible as the hash space is split at slightly different locations. Hash ring - the ketama-md5 ring is the existing Ketama implementation using MD5 for a 128-bit hash - the ketama-murmur3 ring is the Ketama implementation modified to use Murmur3 as a 128-bit hash - the simple-fnv1a ring uses a simple (not-Ketama) ring, using FNV-1A hash Benchmark (hashRing) (keyCount) (keyTailLength) (locator) (nodeCount) Mode Cnt Score Error Units testGetPrimary ketama-md5 2000 0 legacy 10 thrpt 15 2607.833 ± 52.342 ops/s <-- testGetPrimary ketama-md5 2000 0 legacy 120 thrpt 15 2137.626 ± 22.707 ops/s <-- testGetPrimary ketama-md5 2000 50 legacy 10 thrpt 15 1798.278 ± 27.627 ops/s <-- testGetPrimary ketama-md5 2000 50 legacy 120 thrpt 15 1539.314 ± 13.284 ops/s <-- testGetPrimary ketama-murmur3 2000 0 legacy 10 thrpt 15 5589.163 ± 125.725 ops/s testGetPrimary ketama-murmur3 2000 0 legacy 120 thrpt 15 3730.209 ± 46.291 ops/s testGetPrimary ketama-murmur3 2000 50 legacy 10 thrpt 15 4160.228 ± 55.954 ops/s testGetPrimary ketama-murmur3 2000 50 legacy 120 thrpt 15 2925.185 ± 66.501 ops/s testGetPrimary ketama-md5 2000 0 array 10 thrpt 15 2856.391 ± 29.325 ops/s testGetPrimary ketama-md5 2000 0 array 120 thrpt 15 2567.685 ± 58.148 ops/s testGetPrimary ketama-md5 2000 50 array 10 thrpt 15 1918.709 ± 11.799 ops/s testGetPrimary ketama-md5 2000 50 array 120 thrpt 15 1764.622 ± 10.229 ops/s testGetPrimary ketama-murmur3 2000 0 array 10 thrpt 15 8139.093 ± 391.241 ops/s testGetPrimary ketama-murmur3 2000 0 array 120 thrpt 15 5646.702 ± 192.760 ops/s testGetPrimary ketama-murmur3 2000 50 array 10 thrpt 15 4894.453 ± 143.582 ops/s testGetPrimary ketama-murmur3 2000 50 array 120 thrpt 15 3910.925 ± 189.508 ops/s testGetPrimary ketama-md5 2000 0 eytzinger 10 thrpt 15 2939.745 ± 21.005 ops/s <-- testGetPrimary ketama-md5 2000 0 eytzinger 120 thrpt 15 2661.046 ± 25.636 ops/s <-- testGetPrimary ketama-md5 2000 50 eytzinger 10 thrpt 15 1939.034 ± 12.243 ops/s <-- testGetPrimary ketama-md5 2000 50 eytzinger 120 thrpt 15 1822.782 ± 10.516 ops/s <-- testGetPrimary ketama-murmur3 2000 0 eytzinger 10 thrpt 15 7761.497 ± 47.165 ops/s testGetPrimary ketama-murmur3 2000 0 eytzinger 120 thrpt 15 6125.655 ± 100.586 ops/s testGetPrimary ketama-murmur3 2000 50 eytzinger 10 thrpt 15 4920.855 ± 42.134 ops/s testGetPrimary ketama-murmur3 2000 50 eytzinger 120 thrpt 15 4084.432 ± 26.123 ops/s testGetPrimary ketama-md5 2000 0 direct 10 thrpt 15 3982.198 ± 42.075 ops/s <-- testGetPrimary ketama-md5 2000 0 direct 120 thrpt 15 3908.419 ± 41.634 ops/s <-- testGetPrimary ketama-md5 2000 50 direct 10 thrpt 15 2359.383 ± 14.831 ops/s <-- testGetPrimary ketama-md5 2000 50 direct 120 thrpt 15 2332.527 ± 12.630 ops/s <-- testGetPrimary ketama-murmur3 2000 0 direct 10 thrpt 15 25727.606 ± 236.122 ops/s <-- testGetPrimary ketama-murmur3 2000 0 direct 120 thrpt 15 24404.013 ± 223.451 ops/s <-- testGetPrimary ketama-murmur3 2000 50 direct 10 thrpt 15 8182.122 ± 43.455 ops/s <-- testGetPrimary ketama-murmur3 2000 50 direct 120 thrpt 15 7798.550 ± 90.895 ops/s <-- testGetPrimary simple-fnv1a 2000 0 legacy 10 thrpt 15 16497.086 ± 237.651 ops/s testGetPrimary simple-fnv1a 2000 0 legacy 120 thrpt 15 15026.541 ± 64.791 ops/s testGetPrimary simple-fnv1a 2000 50 legacy 10 thrpt 15 5256.310 ± 88.628 ops/s testGetPrimary simple-fnv1a 2000 50 legacy 120 thrpt 15 3969.989 ± 15.371 ops/s testGetPrimary simple-fnv1a 2000 0 array 10 thrpt 15 33851.510 ± 320.860 ops/s testGetPrimary simple-fnv1a 2000 0 array 120 thrpt 15 25844.239 ± 106.294 ops/s testGetPrimary simple-fnv1a 2000 50 array 10 thrpt 15 5650.150 ± 181.146 ops/s testGetPrimary simple-fnv1a 2000 50 array 120 thrpt 15 4232.681 ± 212.107 ops/s testGetPrimary simple-fnv1a 2000 0 eytzinger 10 thrpt 15 34050.744 ± 83.166 ops/s testGetPrimary simple-fnv1a 2000 0 eytzinger 120 thrpt 15 29417.993 ± 484.761 ops/s testGetPrimary simple-fnv1a 2000 50 eytzinger 10 thrpt 15 5699.404 ± 17.079 ops/s testGetPrimary simple-fnv1a 2000 50 eytzinger 120 thrpt 15 4516.383 ± 144.211 ops/s testGetPrimary simple-fnv1a 2000 0 direct 10 thrpt 15 44406.089 ± 290.314 ops/s testGetPrimary simple-fnv1a 2000 0 direct 120 thrpt 15 43076.871 ± 1227.180 ops/s testGetPrimary simple-fnv1a 2000 50 direct 10 thrpt 15 7761.636 ± 252.395 ops/s testGetPrimary simple-fnv1a 2000 50 direct 120 thrpt 15 7564.380 ± 218.651 ops/s --- evcache-core/build.gradle | 15 ++ .../evcache/pool/NodeLocatorBenchmark.java | 112 +++++++++ .../evcache/pool/HashRingAlgorithm.java | 28 +++ .../evcache/pool/NodeLocatorLookup.java | 212 ++++++++++++++++++ .../evcache/pool/NodeLocatorLookupTest.java | 82 +++++++ 5 files changed, 449 insertions(+) create mode 100644 evcache-core/src/jmh/java/com/netflix/evcache/pool/NodeLocatorBenchmark.java diff --git a/evcache-core/build.gradle b/evcache-core/build.gradle index 42d84dcd..7bdca6d0 100644 --- a/evcache-core/build.gradle +++ b/evcache-core/build.gradle @@ -1,5 +1,10 @@ +plugins { + id "me.champeau.jmh" version "0.6.6" +} + apply plugin: 'java' apply plugin: 'eclipse' +apply plugin: 'me.champeau.jmh' sourceSets.main.java.srcDir 'src/main/java' sourceSets.main.resources.srcDir 'src/main/resources' @@ -39,6 +44,7 @@ dependencies { compile group:"joda-time", name:"joda-time", version:"latest.release" compile group:"javax.annotation", name:"javax.annotation-api", version:"latest.release" compile group:"com.github.fzakaria", name:"ascii85", version:"latest.release" + compile group:"net.openhft", name:"zero-allocation-hashing", version:"latest.release" testCompile group:"org.testng", name:"testng", version:"7.5" testCompile group:"com.beust", name:"jcommander", version:"1.72" @@ -49,3 +55,12 @@ dependencies { javadoc { failOnError = false } + +jmh { + jmhVersion = '1.35' + warmupIterations = 2 + iterations = 2 + fork = 1 + zip64 = true + profilers = ['gc', 'stack'] +} diff --git a/evcache-core/src/jmh/java/com/netflix/evcache/pool/NodeLocatorBenchmark.java b/evcache-core/src/jmh/java/com/netflix/evcache/pool/NodeLocatorBenchmark.java new file mode 100644 index 00000000..f013a435 --- /dev/null +++ b/evcache-core/src/jmh/java/com/netflix/evcache/pool/NodeLocatorBenchmark.java @@ -0,0 +1,112 @@ +package com.netflix.evcache.pool; + +import net.spy.memcached.DefaultHashAlgorithm; +import net.spy.memcached.MemcachedNode; +import net.spy.memcached.NodeLocator; +import net.spy.memcached.util.DefaultKetamaNodeLocatorConfiguration; +import net.spy.memcached.util.KetamaNodeLocatorConfiguration; +import org.openjdk.jmh.annotations.Benchmark; +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 com.netflix.evcache.pool.NodeLocatorLookup.ArrayNodeLocatorLookup; +import com.netflix.evcache.pool.NodeLocatorLookup.EytzingerNodeLocatorLookup; +import com.netflix.evcache.pool.NodeLocatorLookup.TreeMapNodeLocatorLookup; +import com.netflix.evcache.pool.NodeLocatorLookup.DirectApproximateNodeLocatorLookup; + +import java.net.InetSocketAddress; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeMap; +import java.util.function.Function; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@State(Scope.Benchmark) +public class NodeLocatorBenchmark { + @Param({"10", "120"}) + int nodeCount; + + @Param({"2000"}) + int keyCount; + + @Param({"0", "50"}) + int keyTailLength; + + @Param({"legacy", "array", "eytzinger", "direct"}) + String locator; + + @Param({"ketama-md5", "simple-fnv1a", "ketama-murmur3"}) + String hashRing; + + NodeLocator impl; + + List nodes; + + String[] keys; + + static Function, NodeLocatorLookup> findLookupFactory(String locator) { + if (locator.equals("legacy")) { + return TreeMapNodeLocatorLookup::new; + } else if (locator.equals("array")) { + return ArrayNodeLocatorLookup::new; + } else if (locator.equals("eytzinger")) { + return EytzingerNodeLocatorLookup::new; + } else if (locator.equals("direct")) { + return DirectApproximateNodeLocatorLookup::new; + } else { + throw new RuntimeException("Unknown locator: " + locator); + } + } + + static HashRingAlgorithm findHashRingAlgorithm(String hashRing) { + if (hashRing.equals("ketama-md5")) { + return new HashRingAlgorithm.KetamaMd5HashRingAlgorithm(); + } else if (hashRing.equals("simple-fnv1a")) { + return new HashRingAlgorithm.SimpleHashRingAlgorithm(DefaultHashAlgorithm.FNV1A_64_HASH); + } else if (hashRing.equals("ketama-murmur3")) { + return new HashRingAlgorithm.KetamaMurmur3HashRingAlgorithm(); + } else { + throw new RuntimeException("Unknown hash ring: " + hashRing); + } + } + + @Setup + public void setup() { + nodes = new ArrayList<>(); + for (int i = 1; i <= nodeCount; i++) { + MemcachedNode node = mock(MemcachedNode.class); + when(node.getSocketAddress()).thenReturn(new InetSocketAddress("100.94.221." + i, 11211)); + nodes.add(node); + } + + EVCacheClient client = mock(EVCacheClient.class); + when(client.getAppName()).thenReturn("mockApp"); + when(client.getServerGroupName()).thenReturn("mockAppServerGroup"); + KetamaNodeLocatorConfiguration conf = new DefaultKetamaNodeLocatorConfiguration(); + + HashRingAlgorithm hashRingAlgorithm = findHashRingAlgorithm(hashRing); + Function, NodeLocatorLookup> lookupFactory = findLookupFactory(locator); + + impl = new EVCacheNodeLocator(client, nodes, hashRingAlgorithm, conf, lookupFactory); + + keys = new String[keyCount]; + for (int i = 0; i < keyCount; i++) { + keys[i] = "key_" + i; + for (int j = 0; j < keyTailLength; j++) { + keys[i] += (char)('a' + (i % 26)); + } + } + } + + @Benchmark + public void testGetPrimary(Blackhole bh) { + for (int i = 0; i < keyCount; i++) { + bh.consume(impl.getPrimary(keys[i])); + } + } +} \ No newline at end of file diff --git a/evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java b/evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java index cfde961c..c26d0283 100644 --- a/evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java +++ b/evcache-core/src/main/java/com/netflix/evcache/pool/HashRingAlgorithm.java @@ -1,5 +1,6 @@ package com.netflix.evcache.pool; +import net.openhft.hashing.LongTupleHashFunction; import net.spy.memcached.DefaultHashAlgorithm; import net.spy.memcached.HashAlgorithm; @@ -62,4 +63,31 @@ public void getHashPartsInto(CharSequence key, long[] parts) { } } + static class KetamaMurmur3HashRingAlgorithm implements HashRingAlgorithm { + static final LongTupleHashFunction murmur3 = LongTupleHashFunction.murmur_3(); + + @Override + public long hash(CharSequence key) { + long[] results = new long[2]; + murmur3.hashChars(key, results); + return results[1] & 0xffffffffL; /* Truncate to 32-bits */ + } + + @Override + public int getCountHashParts() { + return 4; + } + + @Override + public void getHashPartsInto(CharSequence key, long[] parts) { + long[] results = new long[2]; + murmur3.hashChars(key, results); + + // Split the two 64-bit values into four 32-bit chunks + parts[0] = results[0] & 0xffffffffL; // Lower 32 bits of first long + parts[1] = (results[0] >>> 32) & 0xffffffffL; // Upper 32 bits of first long + parts[2] = results[1] & 0xffffffffL; // Lower 32 bits of second long + parts[3] = (results[1] >>> 32) & 0xffffffffL; // Upper 32 bits of second long + } + } } diff --git a/evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java b/evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java index 3a9c24fa..beaadd67 100644 --- a/evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java +++ b/evcache-core/src/main/java/com/netflix/evcache/pool/NodeLocatorLookup.java @@ -33,4 +33,216 @@ public V wrappingCeilingValue(long hash) { } } + /** + * A lookup for the node locator using sorted arrays and binary search. + */ + static class ArrayNodeLocatorLookup implements NodeLocatorLookup { + private final long[] hashes; + private final V[] values; + + @SuppressWarnings("unchecked") + ArrayNodeLocatorLookup(TreeMap map) { + int size = map.size(); + this.hashes = new long[size]; + this.values = (V[]) new Object[size]; + + int i = 0; + for (Map.Entry entry : map.entrySet()) { + hashes[i] = entry.getKey(); + values[i] = entry.getValue(); + i++; + } + } + + @Override + public V wrappingCeilingValue(long hash) { + int index = binarySearchCeiling(hash); + return values[index]; + } + + private int binarySearchCeiling(long hash) { + // If the hash is greater than all values, wrap around to first element + if (hash > hashes[hashes.length - 1]) { + return 0; + } + + int index = Arrays.binarySearch(hashes, hash); + if (index >= 0) { + return index; // exact match + } + + // Arrays.binarySearch returns (-(insertion point) - 1) when key not found + // We want the insertion point, which is the ceiling + return -index - 1; + } + } + + /** + * A lookup for the node locator using the Eytzinger layout. + */ + static class EytzingerNodeLocatorLookup implements NodeLocatorLookup { + + private final int[] hashes; + private final V[] values; + private final int leftmostNodePosition; + + EytzingerNodeLocatorLookup(TreeMap newNodeMap) { + // Convert the sorted hashes to Eytzinger layout + long[] sortedHashes = newNodeMap.keySet().stream().mapToLong(Long::longValue).toArray(); + this.hashes = new int[sortedHashes.length]; + sortedToEytzinger(sortedHashes, this.hashes, 0, new int[]{0}); + + // Store nodes in corresponding Eytzinger order + this.values = constructValueArray(hashes.length); + for (int i = 0; i < hashes.length; i++) { + this.values[i] = newNodeMap.get(Long.valueOf((long)hashes[i] & 0xffffffffL)); + } + + // we can set the leftmost node position directly because the + // Eytzinger layout is a complete binary tree, this avoids some + // unnecessary iterations when the search wraps around the ring + int _leftmostNodePosition = 0; + while (2 * _leftmostNodePosition + 1 < hashes.length) { + _leftmostNodePosition = 2 * _leftmostNodePosition + 1; + } + this.leftmostNodePosition = _leftmostNodePosition; + + } + + @SuppressWarnings("unchecked") + private V[] constructValueArray(int length) { + return (V[]) new Object[length]; + } + + @Override + public V wrappingCeilingValue(long hash) { + int pos = eytzingerCeilingPosition(hash); + return values[pos]; + } + + private int eytzingerCeilingPosition(long hash) { + int pos = 0; + int candidate = -1; + + while (pos < hashes.length) { + long nodeHash = ((long) hashes[pos]) & 0xffffffffL; + if (hash <= nodeHash) { + candidate = pos; + pos = 2 * pos + 1; + } else { + pos = 2 * pos + 2; + } + } + + if (candidate == -1) { + return leftmostNodePosition; + } + + return candidate; + } + + private static void sortedToEytzinger(long[] sorted, int[] eytzinger, int i, int[] pos) { + if (i >= eytzinger.length || pos[0] >= sorted.length) return; + + sortedToEytzinger(sorted, eytzinger, 2 * i + 1, pos); + eytzinger[i] = (int)sorted[pos[0]]; + pos[0]++; + sortedToEytzinger(sorted, eytzinger, 2 * i + 2, pos); + } + } + + /** + * A lookup using a two-level direct mapping approach. + * Level 1: Fixed-size array of bytes/chars mapping hash ranges to node indices + * Level 2: Compact array of actual nodes + */ + static class DirectApproximateNodeLocatorLookup implements NodeLocatorLookup { + private static final int BUCKET_BITS = 19; + private static final int BUCKET_COUNT = 1 << BUCKET_BITS; + private static final int BUCKET_MASK = BUCKET_COUNT - 1; + private static final int BYTE_MAX_VALUE = 255; + + private final Object bucketToNodeIndex; // First level: either byte[] or char[] + private final V[] nodes; // Second level: actual nodes + private final int nodeCount; // Number of unique nodes + private final boolean usingBytes; + + @SuppressWarnings("unchecked") + DirectApproximateNodeLocatorLookup(TreeMap map) { + // Determine if we can use bytes based on unique node count + Object[] uniqueValues = new LinkedHashSet<>(map.values()).toArray(); + this.nodeCount = uniqueValues.length; + this.usingBytes = nodeCount <= BYTE_MAX_VALUE; + + // Initialize appropriate array type + this.bucketToNodeIndex = usingBytes ? new byte[BUCKET_COUNT] : new char[BUCKET_COUNT]; + + this.nodes = (V[]) new Object[nodeCount]; + System.arraycopy(uniqueValues, 0, nodes, 0, nodeCount); + + Map valueToIndex = new HashMap<>(); + for (int i = 0; i < nodeCount; i++) { + valueToIndex.put((V) uniqueValues[i], i); + } + + // Fill bucket table + long prevHash = -1; + int prevIndex = 0; + + for (Map.Entry entry : map.entrySet()) { + long hash = entry.getKey(); + int nodeIndex = valueToIndex.get(entry.getValue()); + + int startBucket = (prevHash == -1) ? 0 : getBucketIndex(prevHash + 1); + int endBucket = getBucketIndex(hash); + + int indexToUse = nodeIndex; + if (prevHash != -1) { + indexToUse = prevIndex; + } + + while (startBucket != endBucket) { + setBucketIndex(startBucket, indexToUse); + startBucket = (startBucket + 1) & BUCKET_MASK; + } + setBucketIndex(endBucket, nodeIndex); + + prevHash = hash; + prevIndex = nodeIndex; + } + + // Fill remaining buckets to handle wrap-around + if (prevHash != -1) { + int startBucket = getBucketIndex(prevHash + 1); + int endBucket = getBucketIndex(-1L); + while (startBucket != endBucket) { + setBucketIndex(startBucket, prevIndex); + startBucket = (startBucket + 1) & BUCKET_MASK; + } + setBucketIndex(endBucket, prevIndex); + } + } + + private void setBucketIndex(int bucket, int value) { + if (usingBytes) { + ((byte[]) bucketToNodeIndex)[bucket] = (byte) value; + } else { + ((char[]) bucketToNodeIndex)[bucket] = (char) value; + } + } + + private int getBucketValue(int bucket) { + return usingBytes ? ((byte[]) bucketToNodeIndex)[bucket] & 0xFF : ((char[]) bucketToNodeIndex)[bucket]; + } + + private static int getBucketIndex(long hash) { + return (int) ((hash >>> (32 - BUCKET_BITS)) & BUCKET_MASK); + } + + @Override + public V wrappingCeilingValue(long hash) { + return nodes[getBucketValue(getBucketIndex(hash))]; + } + } + } diff --git a/evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java b/evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java index 106e314d..f59fbbd5 100644 --- a/evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java +++ b/evcache-core/src/test/java/com/netflix/evcache/pool/NodeLocatorLookupTest.java @@ -33,4 +33,86 @@ public void testTreeMapNodeLocatorLookup() { assertEquals(lookup.wrappingCeilingValue(5L), "node1"); assertEquals(lookup.wrappingCeilingValue(0L), "node1"); } + + @Test + public void testArrayNodeLocatorLookup() { + TreeMap map = new TreeMap<>(); + map.put(10L, "node1"); + map.put(20L, "node2"); + map.put(30L, "node3"); + + NodeLocatorLookup lookup = new NodeLocatorLookup.ArrayNodeLocatorLookup<>(map); + + // Test exact matches + assertEquals(lookup.wrappingCeilingValue(10L), "node1"); + assertEquals(lookup.wrappingCeilingValue(20L), "node2"); + assertEquals(lookup.wrappingCeilingValue(30L), "node3"); + + // Test ceiling behavior + assertEquals(lookup.wrappingCeilingValue(15L), "node2"); + assertEquals(lookup.wrappingCeilingValue(25L), "node3"); + + // Test wrapping behavior (values greater than max should wrap to first node) + assertEquals(lookup.wrappingCeilingValue(35L), "node1"); + assertEquals(lookup.wrappingCeilingValue(Long.MAX_VALUE), "node1"); + + // Test values less than min + assertEquals(lookup.wrappingCeilingValue(5L), "node1"); + assertEquals(lookup.wrappingCeilingValue(0L), "node1"); + } + + @Test + public void testEytzingerNodeLocatorLookup() { + TreeMap map = new TreeMap<>(); + map.put(10L, "node1"); + map.put(20L, "node2"); + map.put(30L, "node3"); + + NodeLocatorLookup lookup = new NodeLocatorLookup.EytzingerNodeLocatorLookup<>(map); + + // Test exact matches + assertEquals(lookup.wrappingCeilingValue(10L), "node1"); + assertEquals(lookup.wrappingCeilingValue(20L), "node2"); + assertEquals(lookup.wrappingCeilingValue(30L), "node3"); + + // Test ceiling behavior + assertEquals(lookup.wrappingCeilingValue(15L), "node2"); + assertEquals(lookup.wrappingCeilingValue(25L), "node3"); + + // Test wrapping behavior (values greater than max should wrap to first node) + assertEquals(lookup.wrappingCeilingValue(35L), "node1"); + assertEquals(lookup.wrappingCeilingValue(Long.MAX_VALUE), "node1"); + + // Test values less than min + assertEquals(lookup.wrappingCeilingValue(5L), "node1"); + assertEquals(lookup.wrappingCeilingValue(0L), "node1"); + } + + @Test + public void testLargeScaleConsistency() { + // Generate a large TreeMap with 1 million entries + TreeMap map = new TreeMap<>(); + int numEntries = 1_000_000; + for (int i = 0; i < numEntries; i++) { + map.put((long) i * 100, "node" + i); + } + + // Create all three implementations + NodeLocatorLookup arrayLookup = new NodeLocatorLookup.ArrayNodeLocatorLookup<>(map); + NodeLocatorLookup eytzingerLookup = new NodeLocatorLookup.EytzingerNodeLocatorLookup<>(map); + NodeLocatorLookup treeMapLookup = new NodeLocatorLookup.TreeMapNodeLocatorLookup<>(map); + + // Test a range of values including edge cases + for (long i = -1000; i <= (numEntries * 100L + 1000); i++) { + String arrayResult = arrayLookup.wrappingCeilingValue(i); + String eytzingerResult = eytzingerLookup.wrappingCeilingValue(i); + String treeMapResult = treeMapLookup.wrappingCeilingValue(i); + + // Assert all implementations return the same result + assertEquals(arrayResult, eytzingerResult, + "Array and Eytzinger implementations differ for key " + i); + assertEquals(arrayResult, treeMapResult, + "Array and TreeMap implementations differ for key " + i); + } + } }