Skip to content

Commit daa0cb8

Browse files
Improve cache performance for bare string values
This commit introduces a performance optimization for cache entries with bare string values such as view fragments. A new `7.1` cache format has been added which includes the optimization, and the `:message_pack` cache format now includes the optimization as well. (A new cache format is necessary because, during a rolling deploy, unupgraded servers must be able to read cache entries from upgraded servers, which means the optimization cannot be enabled for existing apps by default.) New apps will use the `7.1` cache format by default, and existing apps can enable the format by setting `config.load_defaults 7.1`. Cache entries written using the `6.1` or `7.0` cache formats can be read when using the `7.1` format. **Benchmark** ```ruby # frozen_string_literal: true require "benchmark/ips" serializer_7_0 = ActiveSupport::Cache::SerializerWithFallback[:marshal_7_0] serializer_7_1 = ActiveSupport::Cache::SerializerWithFallback[:marshal_7_1] entry = ActiveSupport::Cache::Entry.new(Random.bytes(10_000), version: "123") Benchmark.ips do |x| x.report("dump 7.0") do $dumped_7_0 = serializer_7_0.dump(entry) end x.report("dump 7.1") do $dumped_7_1 = serializer_7_1.dump(entry) end x.compare! end Benchmark.ips do |x| x.report("load 7.0") do serializer_7_0.load($dumped_7_0) end x.report("load 7.1") do serializer_7_1.load($dumped_7_1) end x.compare! end ``` ``` Warming up -------------------------------------- dump 7.0 5.482k i/100ms dump 7.1 10.987k i/100ms Calculating ------------------------------------- dump 7.0 73.966k (± 6.9%) i/s - 367.294k in 5.005176s dump 7.1 127.193k (±17.8%) i/s - 615.272k in 5.081387s Comparison: dump 7.1: 127192.9 i/s dump 7.0: 73966.5 i/s - 1.72x (± 0.00) slower Warming up -------------------------------------- load 7.0 7.425k i/100ms load 7.1 26.237k i/100ms Calculating ------------------------------------- load 7.0 85.574k (± 1.7%) i/s - 430.650k in 5.034065s load 7.1 264.877k (± 1.6%) i/s - 1.338M in 5.052976s Comparison: load 7.1: 264876.7 i/s load 7.0: 85573.7 i/s - 3.10x (± 0.00) slower ``` Co-authored-by: Jean Boussier <[email protected]>
1 parent e2524e5 commit daa0cb8

File tree

8 files changed

+153
-16
lines changed

8 files changed

+153
-16
lines changed

activesupport/CHANGELOG.md

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,29 @@
1+
* A new `7.1` cache format is available which includes an optimization for
2+
bare string values such as view fragments. The `:message_pack` cache format
3+
has also been modified to include this optimization.
4+
5+
The `7.1` cache format is used by default for new apps, and existing apps
6+
can enable the format by setting `config.load_defaults 7.1` or by setting
7+
`config.active_support.cache_format_version = 7.1` in `config/application.rb`
8+
or a `config/environments/*.rb` file.
9+
10+
Cache entries written using the `6.1` or `7.0` cache formats can be read
11+
when using the `7.1` format. To perform a rolling deploy of a Rails 7.1
12+
upgrade, wherein servers that have not yet been upgraded must be able to
13+
read caches from upgraded servers, leave the cache format unchanged on the
14+
first deploy, then enable the `7.1` cache format on a subsequent deploy.
15+
16+
*Jonathan Hefner*
17+
118
* `config.active_support.cache_format_version` now accepts `:message_pack` as
219
an option. `:message_pack` can reduce cache entry sizes and improve
320
performance, but requires the [`msgpack` gem](https://rubygems.org/gems/msgpack)
421
(>= 1.7.0).
522

6-
Cache entries written using the `6.1` or `7.0` cache formats can be read
23+
Cache entries written using the `6.1`, `7.0`, or `7.1` cache formats can be read
724
when using the `:message_pack` cache format. Additionally, cache entries
825
written using the `:message_pack` cache format can now be read when using
9-
the `6.1` or `7.0` cache formats. These behaviors makes it easy to migrate
26+
the `6.1`, `7.0`, or `7.1` cache formats. These behaviors makes it easy to migrate
1027
between formats without invalidating the entire cache.
1128

1229
*Jonathan Hefner*

activesupport/lib/active_support/cache.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,8 @@ def default_coder
651651
Cache::SerializerWithFallback[:marshal_6_1]
652652
when 7.0
653653
Cache::SerializerWithFallback[:marshal_7_0]
654+
when 7.1
655+
Cache::SerializerWithFallback[:marshal_7_1]
654656
else
655657
Cache::SerializerWithFallback[Cache.format_version]
656658
end

activesupport/lib/active_support/cache/serializer_with_fallback.rb

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ def self.[](format)
1111
SERIALIZERS.fetch(format)
1212
end
1313

14+
def dump(entry)
15+
try_dump_bare_string(entry) || _dump(entry)
16+
end
17+
1418
def dump_compressed(entry, threshold)
1519
dumped = dump(entry)
1620
try_compress(dumped, threshold) || dumped
@@ -21,10 +25,12 @@ def load(dumped)
2125
dumped = decompress(dumped) if compressed?(dumped)
2226

2327
case
28+
when loaded = try_load_bare_string(dumped)
29+
loaded
2430
when MessagePackWithFallback.dumped?(dumped)
2531
MessagePackWithFallback._load(dumped)
26-
when Marshal70WithFallback.dumped?(dumped)
27-
Marshal70WithFallback._load(dumped)
32+
when Marshal71WithFallback.dumped?(dumped)
33+
Marshal71WithFallback._load(dumped)
2834
when Marshal61WithFallback.dumped?(dumped)
2935
Marshal61WithFallback._load(dumped)
3036
else
@@ -40,6 +46,45 @@ def load(dumped)
4046
end
4147

4248
private
49+
BARE_STRING_SIGNATURES = {
50+
255 => Encoding::UTF_8,
51+
254 => Encoding::BINARY,
52+
253 => Encoding::US_ASCII,
53+
}
54+
BARE_STRING_TEMPLATE = "CEl<"
55+
BARE_STRING_EXPIRES_AT_TEMPLATE = "@1E"
56+
BARE_STRING_VERSION_LENGTH_TEMPLATE = "@#{[0].pack(BARE_STRING_EXPIRES_AT_TEMPLATE).bytesize}l<"
57+
BARE_STRING_VERSION_INDEX = [0].pack(BARE_STRING_VERSION_LENGTH_TEMPLATE).bytesize
58+
59+
def try_dump_bare_string(entry)
60+
value = entry.value
61+
return if !value.instance_of?(String)
62+
63+
version = entry.version
64+
return if version && version.encoding != Encoding::UTF_8
65+
66+
signature = BARE_STRING_SIGNATURES.key(value.encoding)
67+
return if !signature
68+
69+
packed = [signature, entry.expires_at || -1.0, version&.bytesize || -1].pack(BARE_STRING_TEMPLATE)
70+
packed << version if version
71+
packed << value
72+
end
73+
74+
def try_load_bare_string(dumped)
75+
encoding = BARE_STRING_SIGNATURES[dumped.getbyte(0)]
76+
return if !encoding
77+
expires_at = dumped.unpack1(BARE_STRING_EXPIRES_AT_TEMPLATE)
78+
version_length = dumped.unpack1(BARE_STRING_VERSION_LENGTH_TEMPLATE)
79+
value_index = BARE_STRING_VERSION_INDEX + [version_length, 0].max
80+
81+
Cache::Entry.new(
82+
dumped.byteslice(value_index..-1).force_encoding(encoding),
83+
version: dumped.byteslice(BARE_STRING_VERSION_INDEX, version_length)&.force_encoding(Encoding::UTF_8),
84+
expires_at: (expires_at unless expires_at < 0),
85+
)
86+
end
87+
4388
ZLIB_HEADER = "\x78".b.freeze
4489

4590
def compressed?(dumped)
@@ -105,14 +150,14 @@ def dumped?(dumped)
105150
end
106151
end
107152

108-
module Marshal70WithFallback
153+
module Marshal71WithFallback
109154
include SerializerWithFallback
110155
extend self
111156

112157
MARK_UNCOMPRESSED = "\x00".b.freeze
113158
MARK_COMPRESSED = "\x01".b.freeze
114159

115-
def dump(entry)
160+
def _dump(entry)
116161
MARK_UNCOMPRESSED + Marshal.dump(entry.pack)
117162
end
118163

@@ -136,11 +181,17 @@ def dumped?(dumped)
136181
end
137182
end
138183

184+
module Marshal70WithFallback
185+
include Marshal71WithFallback
186+
extend self
187+
alias :dump :_dump # Prevent dumping bare strings.
188+
end
189+
139190
module MessagePackWithFallback
140191
include SerializerWithFallback
141192
extend self
142193

143-
def dump(entry)
194+
def _dump(entry)
144195
ActiveSupport::MessagePack::CacheSerializer.dump(entry.pack)
145196
end
146197

@@ -167,6 +218,7 @@ def available?
167218
passthrough: PassthroughWithFallback,
168219
marshal_6_1: Marshal61WithFallback,
169220
marshal_7_0: Marshal70WithFallback,
221+
marshal_7_1: Marshal71WithFallback,
170222
message_pack: MessagePackWithFallback,
171223
}
172224
end

activesupport/test/cache/behaviors/cache_store_format_version_behavior.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
module CacheStoreFormatVersionBehavior
66
extend ActiveSupport::Concern
77

8-
FORMAT_VERSIONS = [6.1, 7.0, :message_pack]
8+
FORMAT_VERSIONS = [6.1, 7.0, 7.1, :message_pack]
99

1010
included do
1111
test "format version affects default coder" do

activesupport/test/cache/serializer_with_fallback_test.rb

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,64 @@ class CacheSerializerWithFallbackTest < ActiveSupport::TestCase
4545
end
4646
end
4747

48+
(FORMATS - [:passthrough, :marshal_6_1, :marshal_7_0]).each do |format|
49+
test "#{format.inspect} serializer preserves version with bare string" do
50+
entry = ActiveSupport::Cache::Entry.new("abc", version: "123")
51+
assert_entry entry, roundtrip(format, entry)
52+
end
53+
54+
test "#{format.inspect} serializer preserves expiration with bare string" do
55+
entry = ActiveSupport::Cache::Entry.new("abc", expires_in: 123)
56+
assert_entry entry, roundtrip(format, entry)
57+
end
58+
59+
test "#{format.inspect} serializer preserves encoding of version with bare string" do
60+
[Encoding::UTF_8, Encoding::BINARY].each do |encoding|
61+
version = "123".encode(encoding)
62+
roundtripped = roundtrip(format, ActiveSupport::Cache::Entry.new("abc", version: version))
63+
assert_equal version.encoding, roundtripped.version.encoding
64+
end
65+
end
66+
67+
test "#{format.inspect} serializer preserves encoding of bare string" do
68+
[Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].each do |encoding|
69+
string = "abc".encode(encoding)
70+
roundtripped = roundtrip(format, ActiveSupport::Cache::Entry.new(string))
71+
assert_equal string.encoding, roundtripped.value.encoding
72+
end
73+
end
74+
75+
test "#{format.inspect} serializer dumps bare string with reduced overhead when possible" do
76+
string = "abc"
77+
options = { version: "123", expires_in: 123 }
78+
79+
unsupported = string.encode(Encoding::WINDOWS_1252)
80+
unoptimized = serializer(format).dump(ActiveSupport::Cache::Entry.new(unsupported, **options))
81+
82+
[Encoding::UTF_8, Encoding::BINARY, Encoding::US_ASCII].each do |encoding|
83+
supported = string.encode(encoding)
84+
optimized = serializer(format).dump(ActiveSupport::Cache::Entry.new(supported, **options))
85+
assert_operator optimized.size, :<, unoptimized.size
86+
end
87+
end
88+
89+
test "#{format.inspect} serializer can compress bare strings" do
90+
entry = ActiveSupport::Cache::Entry.new("abc" * 100, version: "123", expires_in: 123)
91+
compressed = serializer(format).dump_compressed(entry, 1)
92+
uncompressed = serializer(format).dump_compressed(entry, 100_000)
93+
assert_operator compressed.bytesize, :<, uncompressed.bytesize
94+
end
95+
end
96+
97+
[:passthrough, :marshal_6_1, :marshal_7_0].each do |format|
98+
test "#{format.inspect} serializer dumps bare string in a backward compatible way" do
99+
string = +"abc"
100+
string.instance_variable_set(:@baz, true)
101+
roundtripped = roundtrip(format, ActiveSupport::Cache::Entry.new(string))
102+
assert roundtripped.value.instance_variable_get(:@baz)
103+
end
104+
end
105+
48106
test ":message_pack serializer handles missing class gracefully" do
49107
klass = Class.new do
50108
def self.name; "DoesNotActuallyExist"; end
@@ -68,10 +126,14 @@ def serializer(format)
68126
ActiveSupport::Cache::SerializerWithFallback[format]
69127
end
70128

129+
def roundtrip(format, entry)
130+
serializer(format).load(serializer(format).dump(entry))
131+
end
132+
71133
def assert_entry(expected, actual)
72-
assert_equal expected.value, actual.value
73-
assert_equal expected.version, actual.version
74-
assert_equal expected.expires_at, actual.expires_at
134+
assert_equal \
135+
[expected.value, expected.version, expected.expires_at],
136+
[actual.value, actual.version, actual.expires_at]
75137
end
76138

77139
def assert_logs(pattern, &block)

guides/source/configuring.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ Below are the default values associated with each target version. In cases of co
7474
- [`config.active_record.run_after_transaction_callbacks_in_order_defined`](#config-active-record-run-after-transaction-callbacks-in-order-defined): `true`
7575
- [`config.active_record.run_commit_callbacks_on_first_saved_instances_in_transaction`](#config-active-record-run-commit-callbacks-on-first-saved-instances-in-transaction): `false`
7676
- [`config.active_record.sqlite3_adapter_strict_strings_by_default`](#config-active-record-sqlite3-adapter-strict-strings-by-default): `true`
77+
- [`config.active_support.cache_format_version`](#config-active-support-cache-format-version): `7.1`
7778
- [`config.active_support.default_message_encryptor_serializer`](#config-active-support-default-message-encryptor-serializer): `:json`
7879
- [`config.active_support.default_message_verifier_serializer`](#config-active-support-default-message-verifier-serializer): `:json`
7980
- [`config.active_support.raise_on_invalid_cache_expiration_time`](#config-active-support-raise-on-invalid-cache-expiration-time): `true`
@@ -2266,10 +2267,11 @@ The default value depends on the `config.load_defaults` target version:
22662267
#### `config.active_support.cache_format_version`
22672268
22682269
Specifies which serialization format to use for the cache. Possible values are
2269-
`6.1`, `7.0`, and `:message_pack`.
2270+
`6.1`, `7.0`, `7.1`, and `:message_pack`.
22702271
2271-
The `6.1` and `7.0` formats both use `Marshal`, but the latter uses a more
2272-
efficient cache entry representation.
2272+
The `6.1`, `7.0`, and `7.1` formats all use `Marshal`, but `7.0` uses a more
2273+
efficient representation for cache entries, and `7.1` includes an additional
2274+
optimization for bare string values such as view fragments.
22732275
22742276
The `:message_pack` format uses `ActiveSupport::MessagePack`, and may further
22752277
reduce cache entry sizes and improve performance, but requires the
@@ -2285,6 +2287,7 @@ The default value depends on the `config.load_defaults` target version:
22852287
| --------------------- | -------------------- |
22862288
| (original) | `6.1` |
22872289
| 7.0 | `7.0` |
2290+
| 7.1 | `7.1` |
22882291
22892292
#### `config.active_support.deprecation`
22902293

railties/lib/rails/application/configuration.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,7 @@ def load_defaults(target_version)
302302
end
303303

304304
if respond_to?(:active_support)
305+
active_support.cache_format_version = 7.1
305306
active_support.default_message_encryptor_serializer = :json
306307
active_support.default_message_verifier_serializer = :json
307308
active_support.use_message_serializer_for_metadata = true

railties/test/application/configuration_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4125,10 +4125,10 @@ def new(app); self; end
41254125
assert_equal :fiber, ActiveSupport::IsolatedExecutionState.isolation_level
41264126
end
41274127

4128-
test "ActiveSupport::Cache.format_version is 7.0 by default for new apps" do
4128+
test "ActiveSupport::Cache.format_version is 7.1 by default for new apps" do
41294129
app "development"
41304130

4131-
assert_equal 7.0, ActiveSupport::Cache.format_version
4131+
assert_equal 7.1, ActiveSupport::Cache.format_version
41324132
end
41334133

41344134
test "ActiveSupport::Cache.format_version is 6.1 by default for upgraded apps" do

0 commit comments

Comments
 (0)