Skip to content

Commit d3def35

Browse files
committed
Start benchmarking append performance
Current scenarios: * baseline append with no query * append with non-conflicting tags
1 parent f9b6505 commit d3def35

7 files changed

Lines changed: 329 additions & 1 deletion

File tree

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ source "https://rubygems.org"
44

55
gemspec
66

7+
gem "benchmark"
78
gem "bigdecimal"
89
gem "irb"
910
gem "rake"

Gemfile.lock

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ GEM
2828
uri (>= 0.13.1)
2929
ast (2.4.3)
3030
base64 (0.3.0)
31+
benchmark (0.5.0)
3132
bigdecimal (4.1.2)
3233
concurrent-ruby (1.3.6)
3334
connection_pool (3.0.2)
@@ -119,6 +120,7 @@ PLATFORMS
119120

120121
DEPENDENCIES
121122
activerecord
123+
benchmark
122124
bigdecimal
123125
concurrent-ruby
124126
connection_pool
@@ -140,6 +142,7 @@ CHECKSUMS
140142
activesupport (8.1.3) sha256=21a5e0dfbd4c3ddd9e1317ec6a4d782fa226e7867dc70b0743acda81a1dca20e
141143
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
142144
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
145+
benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c
143146
bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd
144147
bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785
145148
concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab

bin/benchmark

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env ruby
2+
3+
require_relative "../lib/en57/benchmark"
4+
5+
puts En57::Benchmark::Runner.classic.run

database.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,11 @@ image = "18"
33
[instances.main.seeds.schema]
44
type = "sql-file"
55
path = "db/schema/0.1.0.sql"
6+
7+
[instances.concurrent-append-non-conflicting-tags.seeds.schema]
8+
type = "sql-file"
9+
path = "db/schema/0.1.0.sql"
10+
11+
[instances.concurrent-append-no-fail-if.seeds.schema]
12+
type = "sql-file"
13+
path = "db/schema/0.1.0.sql"

en57.gemspec

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,9 @@ Gem::Specification.new do |spec|
1717
"changelog_uri"
1818
] = "https://github.com/mostlyobvious/en57/blob/main/CHANGELOG.md"
1919

20-
spec.files = Dir["lib/**/*", "db/schema/**/*.sql"]
20+
spec.files =
21+
Dir["lib/**/*", "db/schema/**/*.sql"] -
22+
%w[bin/benchmark lib/en57/benchmark.rb]
2123
spec.require_paths = ["lib"]
2224
spec.extra_rdoc_files = %w[README.md]
2325

lib/en57/benchmark.rb

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
# frozen_string_literal: true
2+
3+
require "benchmark"
4+
require "concurrent-ruby"
5+
require "connection_pool"
6+
require "pg_ephemeral"
7+
8+
require_relative "../en57"
9+
10+
module En57
11+
module Benchmark
12+
Result = Data.define(:name, :runs, :mean, :stddev, :verified)
13+
14+
class Table
15+
def format(results)
16+
rows = results.select(&:verified)
17+
return "" if rows.empty?
18+
19+
header = ["Scenario", "Runs", "Mean latency", "Stddev"]
20+
body =
21+
rows.map do |result|
22+
[
23+
result.name,
24+
result.runs.to_s,
25+
milliseconds(result.mean),
26+
milliseconds(result.stddev),
27+
]
28+
end
29+
widths = header.zip(*body).map { |values| values.map(&:length).max }
30+
rule = "+-#{widths.map { "-" * it }.join("-+-")}-+"
31+
32+
[
33+
rule,
34+
table_row(header, widths, %i[left left left left]),
35+
rule,
36+
*body.map { table_row(it, widths, %i[left right right right]) },
37+
rule,
38+
].join("\n")
39+
end
40+
41+
private
42+
43+
def table_row(values, widths, alignments)
44+
cells =
45+
values
46+
.zip(widths, alignments)
47+
.map do |value, width, alignment|
48+
alignment == :right ? value.rjust(width) : value.ljust(width)
49+
end
50+
51+
"| #{cells.join(" | ")} |"
52+
end
53+
54+
def milliseconds(seconds) = Kernel.format("%.2f ms", seconds * 1000)
55+
end
56+
57+
class Measurement
58+
def self.from(samples)
59+
mean = samples.sum.fdiv(samples.size)
60+
61+
new(
62+
mean:,
63+
stddev:
64+
Math.sqrt(
65+
samples.sum { |sample| (sample - mean)**2 }.fdiv(samples.size),
66+
),
67+
)
68+
end
69+
70+
attr_reader :mean, :stddev
71+
72+
def initialize(mean:, stddev:)
73+
@mean = mean
74+
@stddev = stddev
75+
end
76+
end
77+
78+
class Scenario
79+
def initialize(
80+
name:,
81+
database_url:,
82+
measure:,
83+
runs:,
84+
concurrency:,
85+
batch_size:
86+
)
87+
@name = name
88+
@batch_size = batch_size
89+
@concurrency = concurrency
90+
@database_url = database_url
91+
@measure = measure
92+
@runs = runs
93+
end
94+
95+
attr_reader :name, :runs
96+
97+
def run
98+
@runs.times { call }
99+
verify
100+
end
101+
102+
private
103+
104+
def call = nil
105+
def verify = true
106+
107+
def concurrently(concurrency)
108+
Array
109+
.new(concurrency) do
110+
Thread.new do
111+
Thread.report_on_exception = false
112+
yield
113+
end
114+
end
115+
.each(&:join)
116+
end
117+
end
118+
119+
class Runner
120+
def self.classic
121+
new(
122+
formatter: Table.new,
123+
scenarios: {
124+
"concurrent-append-non-conflicting-tags" => ->(
125+
database_url,
126+
measure
127+
) do
128+
ConcurrentAppendNonConflictingTags.new(
129+
name: "Concurrent append, non-conflicting tags",
130+
database_url:,
131+
measure:,
132+
runs: ENV.fetch("BENCHMARK_RUNS", 1),
133+
concurrency: 10,
134+
batch_size: 100,
135+
)
136+
end,
137+
"concurrent-append-no-fail-if" => ->(database_url, measure) do
138+
ConcurrentAppendNoFailIf.new(
139+
name: "Concurrent append, no fail_if",
140+
database_url:,
141+
measure:,
142+
runs: ENV.fetch("BENCHMARK_RUNS", 1),
143+
concurrency: 10,
144+
batch_size: 100,
145+
)
146+
end,
147+
},
148+
)
149+
end
150+
151+
def initialize(scenarios:, formatter:)
152+
@formatter = formatter
153+
@scenarios = scenarios
154+
end
155+
156+
def run
157+
@formatter.format(
158+
@scenarios.map do |instance_name, mk_scenario|
159+
PgEphemeral.with_server(instance_name:) do |server|
160+
samples = []
161+
scenario =
162+
mk_scenario.call(
163+
server.url,
164+
->(&block) { samples << ::Benchmark.realtime { block.call } },
165+
)
166+
verified = scenario.run
167+
measurement = Measurement.from(samples)
168+
169+
Result.new(
170+
name: scenario.name,
171+
runs: scenario.runs,
172+
mean: measurement.mean,
173+
stddev: measurement.stddev,
174+
verified:,
175+
)
176+
end
177+
end,
178+
)
179+
end
180+
end
181+
182+
class ConcurrentAppendNoFailIf < Scenario
183+
def initialize(...)
184+
super
185+
@event_store =
186+
EventStore.for_pooled_pg(@database_url, max_connections: @concurrency)
187+
end
188+
189+
private
190+
191+
def call
192+
type = "event_benchmarked"
193+
barrier = Concurrent::CyclicBarrier.new(@concurrency)
194+
195+
concurrently(@concurrency) do
196+
tags = %W[writer:#{SecureRandom.hex(4)}]
197+
scope = @event_store.read.of_type(type).with_tag(tags)
198+
events =
199+
Array.new(@batch_size) { En57::Event.new(type: type, tags: tags) }
200+
201+
barrier.wait
202+
203+
@measure.call do
204+
begin
205+
@event_store.append(events)
206+
rescue AppendConditionViolated
207+
retry
208+
end
209+
end
210+
end
211+
end
212+
213+
def verify =
214+
@event_store.read.each.to_a.size == @runs * @concurrency * @batch_size
215+
end
216+
217+
class ConcurrentAppendNonConflictingTags < Scenario
218+
def initialize(...)
219+
super
220+
@event_store =
221+
EventStore.for_pooled_pg(@database_url, max_connections: @concurrency)
222+
end
223+
224+
private
225+
226+
def call
227+
type = "event_benchmarked"
228+
barrier = Concurrent::CyclicBarrier.new(@concurrency)
229+
230+
concurrently(@concurrency) do
231+
tags = %W[writer:#{SecureRandom.hex(4)}]
232+
scope = @event_store.read.of_type(type).with_tag(tags)
233+
events =
234+
Array.new(@batch_size) { En57::Event.new(type: type, tags: tags) }
235+
236+
barrier.wait
237+
238+
@measure.call do
239+
begin
240+
@event_store.append(events, fail_if: scope.after(position = 0))
241+
rescue AppendConditionViolated
242+
retry
243+
end
244+
end
245+
end
246+
end
247+
248+
def verify =
249+
@event_store.read.each.to_a.size == @runs * @concurrency * @batch_size
250+
end
251+
end
252+
end

test/test_benchmark.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# frozen_string_literal: true
2+
3+
require "test_helper"
4+
require "en57/benchmark"
5+
6+
module En57
7+
module Benchmark
8+
class TestBenchmark < Minitest::Test
9+
def test_table_formats_verified_results
10+
output =
11+
Table.new.format(
12+
[
13+
Result.new(
14+
name: "scenario",
15+
runs: 50,
16+
mean: 0.00123,
17+
stddev: 0.00045,
18+
verified: true,
19+
),
20+
],
21+
)
22+
23+
assert_equal(<<~TABLE.chomp, output)
24+
+----------+------+--------------+---------+
25+
| Scenario | Runs | Mean latency | Stddev |
26+
+----------+------+--------------+---------+
27+
| scenario | 50 | 1.23 ms | 0.45 ms |
28+
+----------+------+--------------+---------+
29+
TABLE
30+
end
31+
32+
def test_table_omits_unverified_results
33+
assert_equal(
34+
"",
35+
Table.new.format(
36+
[
37+
Result.new(
38+
name: "scenario",
39+
runs: 50,
40+
mean: 0.00123,
41+
stddev: 0.00045,
42+
verified: false,
43+
),
44+
],
45+
),
46+
)
47+
end
48+
49+
def test_measurement_calculates_mean_and_stddev
50+
measurement = Measurement.from([0.1, 0.2, 0.3])
51+
52+
assert_in_delta(0.2, measurement.mean)
53+
assert_in_delta(0.08165, measurement.stddev, 0.00001)
54+
end
55+
end
56+
end
57+
end

0 commit comments

Comments
 (0)