Skip to content

Commit 8ed52bf

Browse files
committed
⚡️ Don't memoize normalized SequenceSet#string
Not duplicating the data in `@tuples` and `@string` saves memory. For large sequence sets, this memory savings can be substantial. But this is a tradeoff: it saves time when the string is not used, but uses more time when the string is used more than once. Working with set operations can create many ephemeral sets, so avoiding unintentional string generation can save a lot of time. Also, by quickly scanning the entries after a string is parsed, we can bypass the merge algorithm for normalized strings. But this does cause a small penalty for non-normalized strings. **Please note:** It _is still possible_ to create a memoized string on a normalized SequenceSet with `#append`. For example: create a monotonically sorted SequenceSet with non-normal final entry, then call `#append` with an adjacently following entry. `#append` coalesces the final entry and converts it into normal form, but doesn't check whether the _preceding entries_ of the SequenceSet are normalized. -------------------------------------------------------------------- Results from benchmarks/sequence_set-normalize.yml There is still room for improvement here, because #normalize generates the normalized string for comparison rather than just reparse the string. ``` normal local: 19938.9 i/s v0.5.12: 2988.7 i/s - 6.67x slower frozen and normal local: 17011413.5 i/s v0.5.12: 3574.4 i/s - 4759.30x slower unsorted local: 19434.9 i/s v0.5.12: 2957.5 i/s - 6.57x slower abnormal local: 19835.9 i/s v0.5.12: 3037.1 i/s - 6.53x slower ``` -------------------------------------------------------------------- Results from benchmarks/sequence_set-new.yml Note that this benchmark doesn't use `SequenceSet::new`; it uses `SequenceSet::[]`, which freezes the result. In this case, the benchmark result differences are mostly driven by improved performance of `#freeze`. ``` n= 10 ints (sorted) local: 118753.9 i/s v0.5.12: 85411.4 i/s - 1.39x slower n= 10 string (sorted) v0.5.12: 123087.2 i/s local: 122746.3 i/s - 1.00x slower n= 10 ints (shuffled) local: 105919.2 i/s v0.5.12: 79294.5 i/s - 1.34x slower n= 10 string (shuffled) v0.5.12: 114826.6 i/s local: 108086.2 i/s - 1.06x slower n= 100 ints (sorted) local: 16418.4 i/s v0.5.12: 11864.2 i/s - 1.38x slower n= 100 string (sorted) local: 18161.7 i/s v0.5.12: 15219.3 i/s - 1.19x slower n= 100 ints (shuffled) local: 16640.1 i/s v0.5.12: 11815.8 i/s - 1.41x slower n= 100 string (shuffled) v0.5.12: 14755.8 i/s local: 14512.8 i/s - 1.02x slower n= 1,000 ints (sorted) local: 1722.2 i/s v0.5.12: 1229.0 i/s - 1.40x slower n= 1,000 string (sorted) local: 1862.1 i/s v0.5.12: 1543.2 i/s - 1.21x slower n= 1,000 ints (shuffled) local: 1684.9 i/s v0.5.12: 1252.3 i/s - 1.35x slower n= 1,000 string (shuffled) v0.5.12: 1467.3 i/s local: 1424.6 i/s - 1.03x slower n= 10,000 ints (sorted) local: 158.1 i/s v0.5.12: 127.9 i/s - 1.24x slower n= 10,000 string (sorted) local: 187.7 i/s v0.5.12: 143.4 i/s - 1.31x slower n= 10,000 ints (shuffled) local: 145.8 i/s v0.5.12: 114.5 i/s - 1.27x slower n= 10,000 string (shuffled) v0.5.12: 138.4 i/s local: 136.9 i/s - 1.01x slower n=100,000 ints (sorted) local: 14.9 i/s v0.5.12: 10.6 i/s - 1.40x slower n=100,000 string (sorted) local: 19.2 i/s v0.5.12: 14.0 i/s - 1.37x slower ``` The new code is ~1-6% slower for shuffled strings, but ~30-40% faster for sorted sets (note that unsorted non-string inputs create a sorted set). 📚 Update SequenceSet#normalize rdoc
1 parent 6de22fd commit 8ed52bf

File tree

3 files changed

+107
-29
lines changed

3 files changed

+107
-29
lines changed

benchmarks/sequence_set-new.yml

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,17 @@ prelude: |
99
N_RAND = 100
1010
1111
def rand_nums(n, min: 1, max: (n * 1.25).to_i) = Array.new(n) { rand(1..max) }
12-
def rand_entries(...) = SeqSet[rand_nums(...)].elements.shuffle
13-
def rand_string(...) = SeqSet[rand_nums(...)].string.split(?,).shuffle.join(?,)
12+
def rand_entries(...) = SeqSet[rand_nums(...)].elements
13+
def rand_string(...) = SeqSet[rand_nums(...)].string
14+
15+
def shuffle(inputs)
16+
inputs.map! do
17+
case _1
18+
in Array => elements then elements.shuffle
19+
in String => string then string.split(?,).shuffle.join(?,)
20+
end
21+
end
22+
end
1423
1524
def build_string_inputs(n, n_rand, **)
1625
Array.new(n_rand) { rand_string(n, **) }
@@ -35,51 +44,83 @@ prelude: |
3544
3645
benchmark:
3746

38-
- name: n=10 ints
47+
- name: n= 10 ints (sorted)
3948
prelude: inputs = build_int_inputs 10, N_RAND
4049
script: SeqSet[inputs[i = (i+1) % N_RAND]]
4150

42-
- name: n=10 string
51+
- name: n= 10 string (sorted)
4352
prelude: inputs = build_string_inputs 10, N_RAND
4453
script: SeqSet[inputs[i = (i+1) % N_RAND]]
4554

46-
- name: n=100 ints
55+
- name: n= 10 ints (shuffled)
56+
prelude: inputs = build_int_inputs 10, N_RAND and shuffle inputs
57+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
58+
59+
- name: n= 10 string (shuffled)
60+
prelude: inputs = build_string_inputs 10, N_RAND and shuffle inputs
61+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
62+
63+
- name: n= 100 ints (sorted)
4764
prelude: inputs = build_int_inputs 100, N_RAND
4865
script: SeqSet[inputs[i = (i+1) % N_RAND]]
4966

50-
- name: n=100 string
67+
- name: n= 100 string (sorted)
5168
prelude: inputs = build_string_inputs 100, N_RAND
5269
script: SeqSet[inputs[i = (i+1) % N_RAND]]
5370

54-
- name: n=1000 ints
71+
- name: n= 100 ints (shuffled)
72+
prelude: inputs = build_int_inputs 100, N_RAND and shuffle inputs
73+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
74+
75+
- name: n= 100 string (shuffled)
76+
prelude: inputs = build_string_inputs 100, N_RAND and shuffle inputs
77+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
78+
79+
- name: n= 1,000 ints (sorted)
5580
prelude: inputs = build_int_inputs 1000, N_RAND
5681
script: SeqSet[inputs[i = (i+1) % N_RAND]]
5782

58-
- name: n=1000 string
83+
- name: n= 1,000 string (sorted)
5984
prelude: inputs = build_string_inputs 1000, N_RAND
6085
script: SeqSet[inputs[i = (i+1) % N_RAND]]
6186

62-
- name: n=10,000 ints
87+
- name: n= 1,000 ints (shuffled)
88+
prelude: inputs = build_int_inputs 1000, N_RAND and shuffle inputs
89+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
90+
91+
- name: n= 1,000 string (shuffled)
92+
prelude: inputs = build_string_inputs 1000, N_RAND and shuffle inputs
93+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
94+
95+
- name: n= 10,000 ints (sorted)
6396
prelude: inputs = build_int_inputs 10_000, N_RAND
6497
script: SeqSet[inputs[i = (i+1) % N_RAND]]
6598

66-
- name: n=10,000 string
99+
- name: n= 10,000 string (sorted)
67100
prelude: inputs = build_string_inputs 10_000, N_RAND
68101
script: SeqSet[inputs[i = (i+1) % N_RAND]]
69102

70-
- name: n=100,000 ints
103+
- name: n= 10,000 ints (shuffled)
104+
prelude: inputs = build_int_inputs 10_000, N_RAND and shuffle inputs
105+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
106+
107+
- name: n= 10,000 string (shuffled)
108+
prelude: inputs = build_string_inputs 10_000, N_RAND and shuffle inputs
109+
script: SeqSet[inputs[i = (i+1) % N_RAND]]
110+
111+
- name: n=100,000 ints (sorted)
71112
prelude: inputs = build_int_inputs 100_000, N_RAND / 2
72113
script: SeqSet[inputs[i = (i+1) % N_RAND]]
73114

74-
- name: n=100,000 string
115+
- name: n=100,000 string (sorted)
75116
prelude: inputs = build_string_inputs 100_000, N_RAND / 2
76117
script: SeqSet[inputs[i = (i+1) % (N_RAND / 2)]]
77118

78-
# - name: n=1,000,000 ints
119+
# - name: n=1,000,000 ints
79120
# prelude: inputs = build_int_inputs 1_000_000
80121
# script: SeqSet[inputs[i = (i+1) % N_RAND]]
81122

82-
# - name: n=10,000,000 ints
123+
# - name: n=10,000,000 ints
83124
# prelude: inputs = build_int_inputs 10_000_000
84125
# script: SeqSet[inputs[i = (i+1) % N_RAND]]
85126

@@ -89,6 +130,10 @@ contexts:
89130
$LOAD_PATH.unshift "./lib"
90131
$allowed_to_profile = true # only profile local code
91132
require: false
133+
- name: v0.5.12
134+
gems:
135+
net-imap: 0.5.12
136+
require: false
92137
- name: v0.5.9
93138
gems:
94139
net-imap: 0.5.9

benchmarks/sequence_set-normalize.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,10 @@ benchmark:
9999

100100
contexts:
101101
# n.b: can't use anything newer as the baseline: it's over 500x faster!
102+
- name: v0.5.12
103+
gems:
104+
net-imap: 0.5.12
105+
require: false
102106
- name: v0.5.9
103107
gems:
104108
net-imap: 0.5.9

lib/net/imap/sequence_set.rb

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,10 @@ class IMAP
145145
# #entries and #elements are identical. Use #append to preserve #entries
146146
# order while modifying a set.
147147
#
148+
# Non-normalized sets store both representations of the set, which can more
149+
# than double memory usage. Very large sequence sets should avoid
150+
# denormalizing methods (such as #append) unless order is significant.
151+
#
148152
# == Using <tt>*</tt>
149153
#
150154
# \IMAP sequence sets may contain a special value <tt>"*"</tt>, which
@@ -586,7 +590,7 @@ def valid_string
586590
# the set is updated the string will be normalized.
587591
#
588592
# Related: #valid_string, #normalized_string, #to_s, #inspect
589-
def string; @string ||= normalized_string if valid? end
593+
def string; @string || normalized_string if valid? end
590594

591595
# Returns an array with #normalized_string when valid and an empty array
592596
# otherwise.
@@ -605,13 +609,18 @@ def string=(input)
605609
clear
606610
elsif (str = String.try_convert(input))
607611
modifying! # short-circuit before parsing the string
608-
tuples = str_to_tuples str
609-
@tuples, @string = [], -str
610-
tuples_add tuples
612+
entries = each_parsed_entry(str).to_a
613+
clear
614+
if normalized_entries?(entries)
615+
@tuples.replace entries.map!(&:minmax)
616+
else
617+
tuples_add entries.map!(&:minmax)
618+
@string = -str
619+
end
611620
else
612621
raise ArgumentError, "expected a string or nil, got #{input.class}"
613622
end
614-
str
623+
input
615624
end
616625

617626
# Returns the \IMAP +sequence-set+ string representation, or an empty
@@ -624,7 +633,6 @@ def to_s; string || "" end
624633
# Freezes and returns the set. A frozen SequenceSet is Ractor-safe.
625634
def freeze
626635
return self if frozen?
627-
string
628636
@tuples.each(&:freeze).freeze
629637
super
630638
end
@@ -971,7 +979,10 @@ def add(element)
971979
# set = Net::IMAP::SequenceSet.new("2,1,9:10")
972980
# set.append(11..12) # => Net::IMAP::SequenceSet("2,1,9:12")
973981
#
974-
# See SequenceSet@Ordered+and+Normalized+sets.
982+
# Non-normalized sets store the string <em>in addition to</em> an internal
983+
# normalized uint32 set representation. This can more than double memory
984+
# usage, so large sets should avoid using #append unless preserving order
985+
# is required. See SequenceSet@Ordered+and+Normalized+sets.
975986
#
976987
# Related: #add, #merge, #union
977988
def append(entry)
@@ -1685,20 +1696,20 @@ def xor!(other)
16851696
merge(other).subtract(both)
16861697
end
16871698

1688-
# Returns a new SequenceSet with a normalized string representation.
1699+
# Returns a SequenceSet with a normalized string representation: entries
1700+
# have been sorted, deduplicated, and coalesced, and all entries
1701+
# are in normal form. Returns +self+ for frozen normalized sets, and a
1702+
# normalized duplicate otherwise.
16891703
#
1690-
# The returned set's #string is sorted and deduplicated. Adjacent or
1691-
# overlapping elements will be merged into a single larger range.
16921704
# See SequenceSet@Ordered+and+Normalized+sets.
16931705
#
16941706
# Net::IMAP::SequenceSet["1:5,3:7,10:9,10:11"].normalize
16951707
# #=> Net::IMAP::SequenceSet["1:7,9:11"]
16961708
#
16971709
# Related: #normalize!, #normalized_string
16981710
def normalize
1699-
str = normalized_string
1700-
return self if frozen? && str == string
1701-
remain_frozen dup.instance_exec { @string = str&.-@; self }
1711+
return self if frozen? && (@string.nil? || @string == normalized_string)
1712+
remain_frozen dup.normalize!
17021713
end
17031714

17041715
# Resets #string to be sorted, deduplicated, and coalesced. Returns
@@ -1884,9 +1895,27 @@ def export_string_entries(entries)
18841895

18851896
def tuple_to_str(tuple) tuple.uniq.map{ from_tuple_int _1 }.join(":") end
18861897
def str_to_tuples(str) str.split(",", -1).map! { str_to_tuple _1 } end
1887-
def str_to_tuple(str)
1898+
def str_to_tuple(str) parse_string_entry(str).minmax end
1899+
1900+
def parse_string_entry(str)
18881901
raise DataFormatError, "invalid sequence set string" if str.empty?
1889-
str.split(":", 2).map! { to_tuple_int _1 }.minmax
1902+
str.split(":", 2).map! { to_tuple_int _1 }
1903+
end
1904+
1905+
# yields validated but unsorted [num] or [num, num]
1906+
def each_parsed_entry(str)
1907+
return to_enum(__method__, str) unless block_given?
1908+
str&.split(",", -1) do |entry| yield parse_string_entry(entry) end
1909+
end
1910+
1911+
def normalized_entries?(entries)
1912+
max = nil
1913+
entries.each do |first, last|
1914+
return false if last && last <= first # 1:1 or 2:1
1915+
return false if max && first <= max + 1 # 2,1 or 1,1 or 1,2
1916+
max = last || first
1917+
end
1918+
true
18901919
end
18911920

18921921
def include_tuple?((min, max)) range_gte_to(min)&.cover?(min..max) end

0 commit comments

Comments
 (0)