Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ gem "irb"
gem "rake", "~> 13.0"

gem "minitest", "~> 6.0"
gem "benchmark", group: :development
7 changes: 5 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
PATH
remote: .
specs:
vers (1.2.0)
vers (1.2.1)

GEM
remote: https://rubygems.org/
specs:
benchmark (0.5.0)
date (3.5.1)
drb (2.2.3)
erb (6.0.2)
Expand Down Expand Up @@ -40,12 +41,14 @@ PLATFORMS
ruby

DEPENDENCIES
benchmark
irb
minitest (~> 6.0)
rake (~> 13.0)
vers!

CHECKSUMS
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
erb (6.0.2) sha256=9fe6264d44f79422c87490a1558479bd0e7dad4dd0e317656e67ea3077b5242b
Expand All @@ -61,7 +64,7 @@ CHECKSUMS
reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835
stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1
tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f
vers (1.2.0)
vers (1.2.1)

BUNDLED WITH
4.0.3
146 changes: 97 additions & 49 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -187,74 +187,122 @@ namespace :benchmark do
task :memory do
require "benchmark"
require "json"
require "objspace"
require_relative "lib/vers"
puts "💾 VERS Memory Usage Benchmarks"

puts "VERS Memory & Allocation Benchmarks"
puts "=" * 50

# Load sample ranges

test_data_file = File.join(__dir__, "test-suite-data.json")

unless File.exist?(test_data_file)
puts "test-suite-data.json not found. Using fallback examples."
puts "test-suite-data.json not found. Using fallback examples."
sample_ranges = [
{ input: "^1.2.3", scheme: "npm" },
{ input: "~> 1.2", scheme: "gem" },
{ input: ">=1.0,<2.0", scheme: "pypi" }
{ "input" => "^1.2.3", "scheme" => "npm" },
{ "input" => "~> 1.2", "scheme" => "gem" },
{ "input" => ">=1.0,<2.0", "scheme" => "pypi" }
]
else
test_data = JSON.parse(File.read(test_data_file))
sample_ranges = test_data.select { |data| !data["is_invalid"] }.first(100)
end
puts "📊 Testing with #{sample_ranges.length} version ranges"

puts "Sample size: #{sample_ranges.length} version ranges"
puts

# Parse all ranges and store objects
puts "🔍 Parsing and storing #{sample_ranges.length} VersionRange objects..."

# Measure object allocations during cold parsing (no cache)
Vers::Version.class_variable_set(:@@version_cache, {})
Vers::Constraint.class_variable_set(:@@constraint_cache, {})
Vers::Parser.class_variable_set(:@@parser_cache, {})

GC.start
GC.disable
before = ObjectSpace.count_objects.dup

version_ranges = []

parsing_time = Benchmark.realtime do
sample_ranges.each do |range|
begin
parsed = Vers.parse_native(range['input'], range['scheme'])
version_ranges << parsed
rescue
# Skip invalid ranges
end
end
sample_ranges.each do |range|
parsed = Vers.parse_native(range['input'], range['scheme']) rescue nil
version_ranges << parsed if parsed
end

puts " Parsing completed in #{(parsing_time * 1000).round(2)}ms"
puts " Successfully parsed #{version_ranges.length} ranges"

after = ObjectSpace.count_objects
GC.enable

string_alloc = after[:T_STRING] - before[:T_STRING]
array_alloc = after[:T_ARRAY] - before[:T_ARRAY]
hash_alloc = after[:T_HASH] - before[:T_HASH]
object_alloc = after[:T_OBJECT] - before[:T_OBJECT]
match_alloc = (after[:T_MATCH] || 0) - (before[:T_MATCH] || 0)
total_alloc = string_alloc + array_alloc + hash_alloc + object_alloc + match_alloc

puts "Cold parse allocations (#{version_ranges.length} ranges, no cache):"
puts " Total objects: #{total_alloc}"
puts " Strings: #{string_alloc}"
puts " Arrays: #{array_alloc}"
puts " Hashes: #{hash_alloc}"
puts " Objects: #{object_alloc}"
puts " MatchData: #{match_alloc}"
puts " Per range: #{(total_alloc.to_f / version_ranges.length).round(1)}"
puts

# Estimate memory usage
estimated_memory = version_ranges.length * 300 # ~300 bytes per VersionRange object estimate
puts "💾 Memory Usage Estimation:"
puts " #{version_ranges.length} VersionRange objects: ~#{estimated_memory} bytes"
puts " Average per object: ~300 bytes"

# Measure cached parse allocations (everything already cached)
GC.start
GC.disable
before = ObjectSpace.count_objects.dup

sample_ranges.each do |range|
Vers.parse_native(range['input'], range['scheme']) rescue nil
end

after = ObjectSpace.count_objects
GC.enable

cached_strings = after[:T_STRING] - before[:T_STRING]
cached_arrays = after[:T_ARRAY] - before[:T_ARRAY]
cached_objects = after[:T_OBJECT] - before[:T_OBJECT]
cached_match = (after[:T_MATCH] || 0) - (before[:T_MATCH] || 0)
cached_alloc = cached_strings + cached_arrays + cached_objects + cached_match

puts "Cached parse allocations (#{version_ranges.length} ranges, warm cache):"
puts " Total objects: #{cached_alloc}"
puts " Strings: #{cached_strings}"
puts " MatchData: #{cached_match}"
puts " Per range: #{(cached_alloc.to_f / version_ranges.length).round(1)}"
puts

# Test repeated operations
puts "🔄 Repeated Operations Test:"


# Measure memory size of retained objects
total_memsize = version_ranges.sum { |r| ObjectSpace.memsize_of(r) }
puts "Retained memory:"
puts " #{version_ranges.length} VersionRange objects: #{total_memsize} bytes"
puts " Average per object: #{(total_memsize.to_f / version_ranges.length).round(0)} bytes"
puts

# Repeated operation allocations
puts "Repeated operation allocations (#{version_ranges.length} calls each):"

operations = {
"to_s conversion" => proc { version_ranges.each(&:to_s) },
"contains? check" => proc { version_ranges.each { |r| r.contains?("1.5.0") } },
"empty? check" => proc { version_ranges.each(&:empty?) },
"unbounded? check" => proc { version_ranges.each(&:unbounded?) }
"to_s" => proc { version_ranges.each(&:to_s) },
"contains?" => proc { version_ranges.each { |r| r.contains?("1.5.0") } },
"empty?" => proc { version_ranges.each(&:empty?) },
}

operations.each do |op_name, op_proc|
time = Benchmark.realtime { op_proc.call }
ops_per_second = version_ranges.length / time

puts " #{op_name.ljust(20)}: #{(time * 1000).round(2)}ms (#{ops_per_second.round(0)} ops/sec)"

operations.each do |name, op|
# Warm up
op.call

GC.start
GC.disable
before = ObjectSpace.count_objects.dup
op.call
after = ObjectSpace.count_objects
GC.enable

allocs = [:T_STRING, :T_ARRAY, :T_OBJECT, :T_MATCH, :T_HASH].sum { |k| (after[k] || 0) - (before[k] || 0) }
puts " #{name.ljust(15)}: #{allocs} allocations"
end

puts
puts "Memory benchmark completed!"
puts "Memory benchmark completed!"
end

desc "Run complexity stress tests"
Expand Down
13 changes: 6 additions & 7 deletions lib/vers/constraint.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class Constraint

# Cache for parsed constraints
@@constraint_cache = {}
@@cache_size_limit = 500
@@cache_size_limit = 1000

attr_reader :operator, :version

Expand Down Expand Up @@ -54,14 +54,13 @@ def initialize(operator, version)
# Vers::Constraint.parse("!=2.0.0") # => #<Vers::Constraint:0x... @operator="!=", @version="2.0.0">
#
def self.parse(constraint_string)
# Limit cache size to prevent memory bloat
return @@constraint_cache[constraint_string] if @@constraint_cache.key?(constraint_string)

if @@constraint_cache.size >= @@cache_size_limit
@@constraint_cache.clear
keys = @@constraint_cache.keys
keys.first(keys.size / 2).each { |k| @@constraint_cache.delete(k) }
end

# Return cached constraint if available
return @@constraint_cache[constraint_string] if @@constraint_cache.key?(constraint_string)


constraint = parse_uncached(constraint_string)
@@constraint_cache[constraint_string] = constraint
constraint
Expand Down
38 changes: 24 additions & 14 deletions lib/vers/interval.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,7 @@ def initialize(min: nil, max: nil, min_inclusive: true, max_inclusive: true, sch
@min_inclusive = min_inclusive
@max_inclusive = max_inclusive
@scheme = scheme

validate_bounds!
@empty = compute_empty
end

def self.empty(scheme: nil)
Expand All @@ -37,9 +36,7 @@ def self.less_than(version, inclusive: false, scheme: nil)
end

def empty?
return true if min && max && version_compare(min, max) > 0
return true if min && max && version_compare(min, max) == 0 && (!min_inclusive || !max_inclusive)
false
@empty
end

def unbounded?
Expand Down Expand Up @@ -180,7 +177,22 @@ def union(other)

def overlaps?(other)
return false if empty? || other.empty?
!intersect(other).empty?
return true if unbounded? || other.unbounded?

# Check if the intervals can't overlap by comparing bounds directly
if max && other.min
cmp = version_compare(max, other.min)
return false if cmp < 0
return false if cmp == 0 && (!max_inclusive || !other.min_inclusive)
end

if min && other.max
cmp = version_compare(min, other.max)
return false if cmp > 0
return false if cmp == 0 && (!min_inclusive || !other.max_inclusive)
end

true
end

def adjacent?(other)
Expand Down Expand Up @@ -211,14 +223,12 @@ def to_s

private

def validate_bounds!
return unless min && max

comparison = version_compare(min, max)
if comparison > 0
return
elsif comparison == 0 && (!min_inclusive || !max_inclusive)
return
def compute_empty
if min && max
cmp = version_compare(min, max)
cmp > 0 || (cmp == 0 && (!min_inclusive || !max_inclusive))
else
false
end
end

Expand Down
16 changes: 7 additions & 9 deletions lib/vers/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,11 @@ class Parser
NPM_HYPHEN_REGEX = /\A(.+?)\s+-\s+(.+)\z/
NPM_X_RANGE_MAJOR_REGEX = /\A(\d+)\.x\z/
NPM_X_RANGE_MINOR_REGEX = /\A(\d+)\.(\d+)\.x\z/
OPERATOR_PREFIX_REGEX = /\A[><=!]+/

# Cache for parsed ranges to improve performance
@@parser_cache = {}
@@cache_size_limit = 200
@@cache_size_limit = 500

##
# Parses a vers URI string into a VersionRange
Expand Down Expand Up @@ -151,7 +152,7 @@ def to_vers_string(version_range, scheme)
private

def sort_key_for_constraint(constraint)
version = constraint.sub(/\A[><=!]+/, '')
version = constraint.sub(OPERATOR_PREFIX_REGEX, '')
v = Version.cached_new(version)
[v, constraint]
end
Expand Down Expand Up @@ -231,15 +232,12 @@ def parse_npm_range(range_string)
end

def parse_npm_single_range(range_string)
# Check cache first
cache_key = "npm:#{range_string}"
if @@parser_cache.key?(cache_key)
return @@parser_cache[cache_key]
end

# Limit cache size
return @@parser_cache[cache_key] if @@parser_cache.key?(cache_key)

if @@parser_cache.size >= @@cache_size_limit
@@parser_cache.clear
keys = @@parser_cache.keys
keys.first(keys.size / 2).each { |k| @@parser_cache.delete(k) }
end

result = case range_string
Expand Down
Loading
Loading