Skip to content

Commit 461d027

Browse files
authored
perf: reduce memory allocations (#126)
1 parent dab3fd6 commit 461d027

File tree

10 files changed

+249
-64
lines changed

10 files changed

+249
-64
lines changed

lib/redis_client/cluster/command.rb

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
require 'redis_client'
44
require 'redis_client/cluster/errors'
5+
require 'redis_client/cluster/normalized_cmd_name'
56

67
class RedisClient
78
class Cluster
@@ -31,14 +32,16 @@ def load(nodes) # rubocop:disable Metrics/MethodLength
3132

3233
def parse_command_details(rows)
3334
rows&.reject { |row| row[0].nil? }.to_h do |row|
34-
[row[0].downcase, { arity: row[1], flags: row[2], first: row[3], last: row[4], step: row[5] }]
35+
[
36+
::RedisClient::Cluster::NormalizedCmdName.instance.get_by_name(row[0]),
37+
{ arity: row[1], flags: row[2], first: row[3], last: row[4], step: row[5] }
38+
]
3539
end
3640
end
3741
end
3842

3943
def initialize(details)
4044
@details = pick_details(details)
41-
@normalized_cmd_name_cache = {}
4245
end
4346

4447
def extract_first_key(command)
@@ -59,7 +62,8 @@ def should_send_to_replica?(command)
5962
end
6063

6164
def exists?(name)
62-
@details.key?(name.to_s.downcase)
65+
key = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_name(name)
66+
@details.key?(key)
6367
end
6468

6569
private
@@ -75,14 +79,14 @@ def pick_details(details)
7579
end
7680

7781
def dig_details(command, key)
78-
name = normalize_cmd_name(command)
82+
name = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
7983
return if name.empty? || !@details.key?(name)
8084

8185
@details.fetch(name).fetch(key)
8286
end
8387

8488
def determine_first_key_position(command) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
85-
case normalize_cmd_name(command)
89+
case ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
8690
when 'eval', 'evalsha', 'zinterstore', 'zunionstore' then 3
8791
when 'object' then 2
8892
when 'memory'
@@ -111,19 +115,6 @@ def extract_hash_tag(key)
111115

112116
key[s + 1..e - 1]
113117
end
114-
115-
def normalize_cmd_name(command)
116-
return EMPTY_STRING unless command.is_a?(Array)
117-
118-
name = case e = command.first
119-
when String then e
120-
when Array then e.first
121-
end
122-
return EMPTY_STRING if name.nil? || name.empty?
123-
124-
@normalized_cmd_name_cache[name] = name.downcase unless @normalized_cmd_name_cache.key?(name)
125-
@normalized_cmd_name_cache[name]
126-
end
127118
end
128119
end
129120
end
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# frozen_string_literal: true
2+
3+
require 'singleton'
4+
5+
class RedisClient
6+
class Cluster
7+
class NormalizedCmdName
8+
include Singleton
9+
10+
EMPTY_STRING = ''
11+
12+
def initialize
13+
@cache = {}
14+
@mutex = Mutex.new
15+
end
16+
17+
def get_by_command(command)
18+
get(command, index: 0)
19+
end
20+
21+
def get_by_subcommand(command)
22+
get(command, index: 1)
23+
end
24+
25+
def get_by_name(name)
26+
get(name, index: 0)
27+
end
28+
29+
def clear
30+
@mutex.synchronize { @cache.clear }
31+
end
32+
33+
private
34+
35+
def get(command, index:)
36+
name = extract_name(command, index: index)
37+
return EMPTY_STRING if name.nil? || name.empty?
38+
39+
normalize(name)
40+
end
41+
42+
def extract_name(command, index:)
43+
case command
44+
when String, Symbol then index.zero? ? command : nil
45+
when Array then extract_name_from_array(command, index: index)
46+
end
47+
end
48+
49+
def extract_name_from_array(command, index:)
50+
return if command.size - 1 < index
51+
52+
case e = command[index]
53+
when String, Symbol then e
54+
when Array then e[index]
55+
end
56+
end
57+
58+
def normalize(name)
59+
return @cache[name] if @cache.key?(name)
60+
return name.to_s.downcase if @mutex.locked?
61+
62+
@mutex.synchronize { @cache[name] = name.to_s.downcase }
63+
@cache[name]
64+
end
65+
end
66+
end
67+
end

lib/redis_client/cluster/router.rb

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
require 'redis_client/cluster/key_slot_converter'
77
require 'redis_client/cluster/node'
88
require 'redis_client/cluster/node_key'
9+
require 'redis_client/cluster/normalized_cmd_name'
910

1011
class RedisClient
1112
class Cluster
@@ -25,7 +26,7 @@ def initialize(config, pool: nil, **kwargs)
2526
end
2627

2728
def send_command(method, command, *args, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
28-
cmd = command.first.to_s.downcase
29+
cmd = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_command(command)
2930
case cmd
3031
when 'acl', 'auth', 'bgrewriteaof', 'bgsave', 'quit', 'save'
3132
@node.call_all(method, command, args, &block).first
@@ -65,7 +66,12 @@ def send_command(method, command, *args, &block) # rubocop:disable Metrics/AbcSi
6566
# @see https://redis.io/topics/cluster-spec#redirection-and-resharding
6667
# Redirection and resharding
6768
def try_send(node, method, command, args, retry_count: 3, &block) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
68-
node.send(method, *args, command, &block)
69+
if args.empty?
70+
# prevent memory allocation for variable-length args
71+
node.send(method, command, &block)
72+
else
73+
node.send(method, *args, command, &block)
74+
end
6975
rescue ::RedisClient::CommandError => e
7076
raise if retry_count <= 0
7177

@@ -193,34 +199,32 @@ def send_wait_command(method, command, args, retry_count: 3, &block)
193199
end
194200

195201
def send_config_command(method, command, args, &block)
196-
case command[1].to_s.downcase
202+
case ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_subcommand(command)
197203
when 'resetstat', 'rewrite', 'set'
198204
@node.call_all(method, command, args, &block).first
199205
else assign_node(command).send(method, *args, command, &block)
200206
end
201207
end
202208

203209
def send_memory_command(method, command, args, &block)
204-
case command[1].to_s.downcase
210+
case ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_subcommand(command)
205211
when 'stats' then @node.call_all(method, command, args, &block)
206212
when 'purge' then @node.call_all(method, command, args, &block).first
207213
else assign_node(command).send(method, *args, command, &block)
208214
end
209215
end
210216

211217
def send_client_command(method, command, args, &block)
212-
case command[1].to_s.downcase
218+
case ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_subcommand(command)
213219
when 'list' then @node.call_all(method, command, args, &block).flatten
214220
when 'pause', 'reply', 'setname'
215221
@node.call_all(method, command, args, &block).first
216222
else assign_node(command).send(method, *args, command, &block)
217223
end
218224
end
219225

220-
def send_cluster_command(method, command, args, &block) # rubocop:disable Metrics/MethodLength
221-
subcommand = command[1].to_s.downcase
222-
223-
case subcommand
226+
def send_cluster_command(method, command, args, &block)
227+
case subcommand = ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_subcommand(command)
224228
when 'addslots', 'delslots', 'failover', 'forget', 'meet', 'replicate',
225229
'reset', 'set-config-epoch', 'setslot'
226230
raise ::RedisClient::Cluster::OrchestrationCommandNotSupported, ['cluster', subcommand]
@@ -234,7 +238,7 @@ def send_cluster_command(method, command, args, &block) # rubocop:disable Metric
234238
end
235239

236240
def send_script_command(method, command, args, &block)
237-
case command[1].to_s.downcase
241+
case ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_subcommand(command)
238242
when 'debug', 'kill'
239243
@node.call_all(method, command, args, &block).first
240244
when 'flush', 'load'
@@ -244,7 +248,7 @@ def send_script_command(method, command, args, &block)
244248
end
245249

246250
def send_pubsub_command(method, command, args, &block) # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
247-
case command[1].to_s.downcase
251+
case ::RedisClient::Cluster::NormalizedCmdName.instance.get_by_subcommand(command)
248252
when 'channels' then @node.call_all(method, command, args, &block).flatten.uniq.sort_by(&:to_s)
249253
when 'numsub'
250254
@node.call_all(method, command, args, &block).reject(&:empty?).map { |e| Hash[*e] }

test/bench_command.rb

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,11 @@ class PrimaryOnly < BenchmarkWrapper
1010
private
1111

1212
def new_test_client
13-
config = ::RedisClient::ClusterConfig.new(
13+
::RedisClient.cluster(
1414
nodes: TEST_NODE_URIS,
1515
fixed_hostname: TEST_FIXED_HOSTNAME,
1616
**TEST_GENERIC_OPTIONS
17-
)
18-
::RedisClient::Cluster.new(config)
17+
).new_client
1918
end
2019
end
2120

@@ -25,14 +24,13 @@ class ScaleReadRandom < BenchmarkWrapper
2524
private
2625

2726
def new_test_client
28-
config = ::RedisClient::ClusterConfig.new(
27+
::RedisClient.cluster(
2928
nodes: TEST_NODE_URIS,
3029
replica: true,
3130
replica_affinity: :random,
3231
fixed_hostname: TEST_FIXED_HOSTNAME,
3332
**TEST_GENERIC_OPTIONS
34-
)
35-
::RedisClient::Cluster.new(config)
33+
).new_client
3634
end
3735
end
3836

@@ -42,14 +40,13 @@ class ScaleReadLatency < BenchmarkWrapper
4240
private
4341

4442
def new_test_client
45-
config = ::RedisClient::ClusterConfig.new(
43+
::RedisClient.cluster(
4644
nodes: TEST_NODE_URIS,
4745
replica: true,
4846
replica_affinity: :latency,
4947
fixed_hostname: TEST_FIXED_HOSTNAME,
5048
**TEST_GENERIC_OPTIONS
51-
)
52-
::RedisClient::Cluster.new(config)
49+
).new_client
5350
end
5451
end
5552

@@ -59,12 +56,11 @@ class Pooled < BenchmarkWrapper
5956
private
6057

6158
def new_test_client
62-
config = ::RedisClient::ClusterConfig.new(
59+
::RedisClient.cluster(
6360
nodes: TEST_NODE_URIS,
6461
fixed_hostname: TEST_FIXED_HOSTNAME,
6562
**TEST_GENERIC_OPTIONS
66-
)
67-
::RedisClient::Cluster.new(config, pool: { timeout: TEST_TIMEOUT_SEC, size: 2 })
63+
).new_pool(timeout: TEST_TIMEOUT_SEC, size: 2)
6864
end
6965
end
7066

test/prof_mem.rb

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,20 +11,36 @@ module ProfMem
1111

1212
def run
1313
%w[primary_only scale_read_random scale_read_latency pooled].each do |cli_type|
14-
print "################################################################################\n"
15-
print "# #{cli_type}\n"
16-
print "################################################################################\n"
17-
print "\n"
18-
14+
prepare
15+
print_letter(cli_type, 'w/ pipelining')
1916
profile do
2017
send("new_#{cli_type}_client".to_sym).pipelined do |pi|
2118
ATTEMPT_COUNT.times { |i| pi.call('SET', i, i) }
2219
ATTEMPT_COUNT.times { |i| pi.call('GET', i) }
2320
end
2421
end
22+
23+
prepare
24+
print_letter(cli_type, 'w/o pipelining')
25+
profile do
26+
cli = send("new_#{cli_type}_client".to_sym)
27+
ATTEMPT_COUNT.times { |i| cli.call('SET', i, i) }
28+
ATTEMPT_COUNT.times { |i| cli.call('GET', i) }
29+
end
2530
end
2631
end
2732

33+
def prepare
34+
::RedisClient::Cluster::NormalizedCmdName.instance.clear
35+
end
36+
37+
def print_letter(title, sub_titile)
38+
print "################################################################################\n"
39+
print "# #{title}: #{sub_titile}\n"
40+
print "################################################################################\n"
41+
print "\n"
42+
end
43+
2844
def profile(&block)
2945
# https://github.com/SamSaffron/memory_profiler
3046
report = ::MemoryProfiler.report(top: 10, &block)

0 commit comments

Comments
 (0)