diff --git a/.rubocop.yml b/.rubocop.yml index bd9c7e8..e1d870d 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -36,6 +36,9 @@ Lint/UnderscorePrefixedVariableName: Lint/EmptyBlock: Enabled: false +Lint/DuplicateBranch: + Enabled: false + Lint/MissingSuper: Enabled: false @@ -63,6 +66,12 @@ Metrics/ParameterLists: Metrics/PerceivedComplexity: Enabled: false +Style/InfiniteLoop: + Enabled: false + +Style/WhileUntilModifier: + Enabled: false + Style/Alias: EnforcedStyle: prefer_alias_method diff --git a/Rakefile b/Rakefile index 58f72b8..27c8bef 100644 --- a/Rakefile +++ b/Rakefile @@ -71,12 +71,15 @@ namespace :hiredis do end end -benchmark_suites = %w(single pipelined) +benchmark_suites = %w(single pipelined drivers) benchmark_modes = %i[ruby yjit hiredis] namespace :benchmark do benchmark_suites.each do |suite| benchmark_modes.each do |mode| + next if suite == "drivers" && mode == :hiredis + name = "#{suite}_#{mode}" + desc name task name do output_path = "benchmark/#{name}.md" sh "rm", "-f", output_path diff --git a/benchmark/drivers.rb b/benchmark/drivers.rb new file mode 100644 index 0000000..ad55c7f --- /dev/null +++ b/benchmark/drivers.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require_relative "setup" + +ruby = RedisClient.new(host: "localhost", port: Servers::REDIS.real_port, driver: :ruby) +hiredis = RedisClient.new(host: "localhost", port: Servers::REDIS.real_port, driver: :hiredis) + +ruby.call("SET", "key", "value") +ruby.call("SET", "large", "value" * 10_000) +ruby.call("LPUSH", "list", *5.times.to_a) +ruby.call("LPUSH", "large-list", *1000.times.to_a) +ruby.call("HMSET", "hash", *8.times.to_a) +ruby.call("HMSET", "large-hash", *1000.times.to_a) + +benchmark("small string x 100") do |x| + x.report("hiredis") { hiredis.pipelined { |p| 100.times { p.call("GET", "key") } } } + x.report("ruby") { ruby.pipelined { |p| 100.times { p.call("GET", "key") } } } +end + +benchmark("large string x 100") do |x| + x.report("hiredis") { hiredis.pipelined { |p| 100.times { p.call("GET", "large") } } } + x.report("ruby") { ruby.pipelined { |p| 100.times { p.call("GET", "large") } } } +end + +benchmark("small list x 100") do |x| + x.report("hiredis") { hiredis.pipelined { |p| 100.times { p.call("LRANGE", "list", 0, -1) } } } + x.report("ruby") { ruby.pipelined { |p| 100.times { p.call("LRANGE", "list", 0, -1) } } } +end + +benchmark("large list") do |x| + x.report("hiredis") { hiredis.call("LRANGE", "large-list", 0, -1) } + x.report("ruby") { ruby.call("LRANGE", "large-list", 0, -1) } +end + +benchmark("small hash x 100") do |x| + x.report("hiredis") { hiredis.pipelined { |p| 100.times { p.call("HGETALL", "hash") } } } + x.report("ruby") { ruby.pipelined { |p| 100.times { p.call("HGETALL", "hash") } } } +end + +benchmark("large hash") do |x| + x.report("hiredis") { ruby.call("HGETALL", "large-hash") } + x.report("ruby") { ruby.call("HGETALL", "large-hash") } +end diff --git a/benchmark/drivers_ruby.md b/benchmark/drivers_ruby.md new file mode 100644 index 0000000..97708f7 --- /dev/null +++ b/benchmark/drivers_ruby.md @@ -0,0 +1,59 @@ +ruby: `ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23]` + +redis-server: `Redis server v=7.0.12 sha=00000000:0 malloc=libc bits=64 build=a11d0151eabf466c` + + +### small string x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23] + hiredis: 4825.5 i/s + ruby: 2863.4 i/s - 1.69x slower + +``` + +### large string x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23] + hiredis: 266.6 i/s + ruby: 198.1 i/s - 1.35x slower + +``` + +### small list x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23] + hiredis: 2416.9 i/s + ruby: 1223.3 i/s - 1.98x slower + +``` + +### large list + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23] + hiredis: 5351.6 i/s + ruby: 1718.0 i/s - 3.11x slower + +``` + +### small hash x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23] + hiredis: 2854.3 i/s + ruby: 1294.4 i/s - 2.21x slower + +``` + +### large hash + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23] + hiredis: 1580.6 i/s + ruby: 1634.7 i/s - same-ish: difference falls within error + +``` + diff --git a/benchmark/drivers_yjit.md b/benchmark/drivers_yjit.md new file mode 100644 index 0000000..c6c3b8e --- /dev/null +++ b/benchmark/drivers_yjit.md @@ -0,0 +1,59 @@ +ruby: `ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) [arm64-darwin23]` + +redis-server: `Redis server v=7.0.12 sha=00000000:0 malloc=libc bits=64 build=a11d0151eabf466c` + + +### small string x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) +YJIT [arm64-darwin23] + hiredis: 6407.8 i/s + ruby: 5852.0 i/s - same-ish: difference falls within error + +``` + +### large string x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) +YJIT [arm64-darwin23] + hiredis: 302.8 i/s + ruby: 337.3 i/s - same-ish: difference falls within error + +``` + +### small list x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) +YJIT [arm64-darwin23] + hiredis: 4067.7 i/s + ruby: 2721.5 i/s - 1.49x slower + +``` + +### large list + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) +YJIT [arm64-darwin23] + hiredis: 7138.7 i/s + ruby: 6605.4 i/s - same-ish: difference falls within error + +``` + +### small hash x 100 + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) +YJIT [arm64-darwin23] + hiredis: 4219.8 i/s + ruby: 3586.4 i/s - 1.18x slower + +``` + +### large hash + +``` +ruby 3.4.0dev (2024-03-19T14:18:56Z master 5c2937733c) +YJIT [arm64-darwin23] + hiredis: 5240.9 i/s + ruby: 5312.5 i/s - same-ish: difference falls within error + +``` + diff --git a/lib/redis_client/ruby_connection/buffered_io.rb b/lib/redis_client/ruby_connection/buffered_io.rb index 550c5e0..5d4a098 100644 --- a/lib/redis_client/ruby_connection/buffered_io.rb +++ b/lib/redis_client/ruby_connection/buffered_io.rb @@ -10,9 +10,10 @@ class BufferedIO attr_accessor :read_timeout, :write_timeout - def initialize(io, read_timeout:, write_timeout:, chunk_size: 4096) + def initialize(io, read_timeout:, write_timeout:, chunk_size: 4096, encoding: Encoding.default_external) @io = io - @buffer = "".b + @encoding = encoding + @buffer = "".dup.force_encoding(@encoding) @offset = 0 @chunk_size = chunk_size @read_timeout = read_timeout @@ -82,8 +83,10 @@ def write(string) end def getbyte - ensure_remaining(1) - byte = @buffer.getbyte(@offset) + unless byte = @buffer.getbyte(@offset) + ensure_remaining(1) + byte = @buffer.getbyte(@offset) + end @offset += 1 byte end @@ -99,6 +102,29 @@ def gets_chomp line end + def gets_integer + int = 0 + offset = @offset + while true + chr = @buffer.getbyte(offset) + + if chr + if chr == 13 # "\r".ord + @offset = offset + 2 + break + else + int = (int * 10) + chr - 48 + end + offset += 1 + else + ensure_line + return gets_integer + end + end + + int + end + def read_chomp(bytes) ensure_remaining(bytes + EOL_SIZE) str = @buffer.byteslice(@offset, bytes) @@ -108,6 +134,13 @@ def read_chomp(bytes) private + def ensure_line + fill_buffer(false) if @offset >= @buffer.bytesize + until @buffer.index(EOL, @offset) + fill_buffer(false) + end + end + def ensure_remaining(bytes) needed = bytes - (@buffer.bytesize - @offset) if needed > 0 @@ -115,9 +148,13 @@ def ensure_remaining(bytes) end end + RESET_BUFFER_ENCODING = RUBY_ENGINE == "truffleruby" + private_constant :RESET_BUFFER_ENCODING + def fill_buffer(strict, size = @chunk_size) remaining = size - empty_buffer = @offset >= @buffer.bytesize + start = @offset - @buffer.bytesize + empty_buffer = start >= 0 loop do bytes = if empty_buffer @@ -126,15 +163,6 @@ def fill_buffer(strict, size = @chunk_size) @io.read_nonblock([remaining, @chunk_size].max, exception: false) end case bytes - when String - if empty_buffer - @offset = 0 - empty_buffer = false - else - @buffer << bytes - end - remaining -= bytes.bytesize - return if !strict || remaining <= 0 when :wait_readable unless @io.to_io.wait_readable(@read_timeout) raise ReadTimeoutError, "Waited #{@read_timeout} seconds" unless @blocking_reads @@ -144,7 +172,15 @@ def fill_buffer(strict, size = @chunk_size) when nil raise EOFError else - raise "Unexpected `read_nonblock` return: #{bytes.inspect}" + if empty_buffer + @offset = start + empty_buffer = false + @buffer.force_encoding(@encoding) if RESET_BUFFER_ENCODING + else + @buffer << bytes.force_encoding(@encoding) + end + remaining -= bytes.bytesize + return if !strict || remaining <= 0 end end end diff --git a/lib/redis_client/ruby_connection/resp3.rb b/lib/redis_client/ruby_connection/resp3.rb index ccfa169..6ba6ba5 100644 --- a/lib/redis_client/ruby_connection/resp3.rb +++ b/lib/redis_client/ruby_connection/resp3.rb @@ -111,15 +111,39 @@ def dump_symbol(symbol, buffer) def parse(io) type = io.getbyte - method = PARSER_TYPES.fetch(type) do + if type == 35 # '#'.ord + parse_boolean(io) + elsif type == 36 # '$'.ord + parse_blob(io) + elsif type == 43 # '+'.ord + parse_string(io) + elsif type == 61 # '='.ord + parse_verbatim_string(io) + elsif type == 45 # '-'.ord + parse_error(io) + elsif type == 58 # ':'.ord + parse_integer(io) + elsif type == 40 # '('.ord + parse_integer(io) + elsif type == 44 # ','.ord + parse_double(io) + elsif type == 95 # '_'.ord + parse_null(io) + elsif type == 42 # '*'.ord + parse_array(io) + elsif type == 37 # '%'.ord + parse_map(io) + elsif type == 126 # '~'.ord + parse_set(io) + elsif type == 62 # '>'.ord + parse_array(io) + else raise UnknownType, "Unknown sigil type: #{type.chr.inspect}" end - send(method, io) end def parse_string(io) str = io.gets_chomp - str.force_encoding(Encoding.default_external) str.force_encoding(Encoding::BINARY) unless str.valid_encoding? str.freeze end @@ -140,16 +164,16 @@ def parse_boolean(io) end def parse_array(io) - parse_sequence(io, parse_integer(io)) + parse_sequence(io, io.gets_integer) end def parse_set(io) - parse_sequence(io, parse_integer(io)) + parse_sequence(io, io.gets_integer) end def parse_map(io) hash = {} - parse_integer(io).times do + io.gets_integer.times do hash[parse(io)] = parse(io) end hash @@ -192,11 +216,10 @@ def parse_null(io) end def parse_blob(io) - bytesize = parse_integer(io) + bytesize = io.gets_integer return if bytesize < 0 # RESP2 nil type str = io.read_chomp(bytesize) - str.force_encoding(Encoding.default_external) str.force_encoding(Encoding::BINARY) unless str.valid_encoding? str end