Skip to content

Commit 18d156c

Browse files
committed
[WIP] Reduce number of allocations in histogram metric
Signed-off-by: Konstantin Ilchenko <[email protected]>
1 parent 5514b2b commit 18d156c

File tree

7 files changed

+377
-3
lines changed

7 files changed

+377
-3
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
coverage/
22
Gemfile.lock
33
pkg/
4+
spec/benchmarks/*.json

lib/prometheus/client/histogram.rb

+1-1
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ def type
6767
# https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations
6868
# for details.
6969
def observe(value, labels: {})
70-
bucket = buckets.find {|upper_limit| upper_limit >= value }
70+
bucket = buckets.bsearch { |upper_limit| upper_limit >= value }
7171
bucket = "+Inf" if bucket.nil?
7272

7373
base_label_set = label_set_for(labels)
+165
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
# frozen_string_literal: true
2+
3+
require 'prometheus/client/metric'
4+
5+
module Prometheus
6+
module Client
7+
# A histogram samples observations (usually things like request durations
8+
# or response sizes) and counts them in configurable buckets. It also
9+
# provides a total count and sum of all observed values.
10+
class HistogramFixed < Metric
11+
# DEFAULT_BUCKETS are the default Histogram buckets. The default buckets
12+
# are tailored to broadly measure the response time (in seconds) of a
13+
# network service. (From DefBuckets client_golang)
14+
DEFAULT_BUCKETS = [
15+
0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10
16+
].freeze
17+
INF = '+Inf'
18+
SUM = 'sum'
19+
20+
attr_reader :buckets, :bucket_strings
21+
22+
# Offer a way to manually specify buckets
23+
def initialize(name,
24+
docstring:,
25+
labels: [],
26+
preset_labels: {},
27+
buckets: DEFAULT_BUCKETS,
28+
store_settings: {})
29+
raise ArgumentError, 'Unsorted buckets, typo?' unless sorted?(buckets)
30+
31+
@buckets = buckets
32+
@bucket_strings = buckets.map(&:to_s) # This is used to avoid calling `to_s` multiple times
33+
34+
super(name,
35+
docstring: docstring,
36+
labels: labels,
37+
preset_labels: preset_labels,
38+
store_settings: store_settings)
39+
end
40+
41+
def self.linear_buckets(start:, width:, count:)
42+
count.times.map { |idx| start.to_f + idx * width }
43+
end
44+
45+
def self.exponential_buckets(start:, factor: 2, count:)
46+
count.times.map { |idx| start.to_f * factor ** idx }
47+
end
48+
49+
def with_labels(labels)
50+
new_metric = self.class.new(name,
51+
docstring: docstring,
52+
labels: @labels,
53+
preset_labels: preset_labels.merge(labels),
54+
buckets: @buckets,
55+
store_settings: @store_settings)
56+
57+
# The new metric needs to use the same store as the "main" declared one, otherwise
58+
# any observations on that copy with the pre-set labels won't actually be exported.
59+
new_metric.replace_internal_store(@store)
60+
61+
new_metric
62+
end
63+
64+
def type
65+
:histogram
66+
end
67+
68+
# Records a given value. The recorded value is usually positive
69+
# or zero. A negative value is accepted but prevents current
70+
# versions of Prometheus from properly detecting counter resets
71+
# in the sum of observations. See
72+
# https://prometheus.io/docs/practices/histograms/#count-and-sum-of-observations
73+
# for details.
74+
def observe(value, labels: {})
75+
base_label_set = label_set_for(labels) # Pottentially can raise, so it should be first
76+
bucket_idx = buckets.bsearch_index { |upper_limit| upper_limit >= value }
77+
bucket_str = bucket_idx == nil ? INF : bucket_strings[bucket_idx]
78+
79+
# This is basically faster than doing `.merge`
80+
bucket_label_set = base_label_set.dup
81+
bucket_label_set[:le] = bucket_str
82+
83+
@store.synchronize do
84+
@store.increment(labels: bucket_label_set, by: 1)
85+
@store.increment(labels: base_label_set, by: value)
86+
end
87+
end
88+
89+
# Returns a hash with all the buckets plus +Inf (count) plus Sum for the given label set
90+
def get(labels: {})
91+
base_label_set = label_set_for(labels)
92+
93+
all_buckets = buckets + [INF, SUM]
94+
95+
@store.synchronize do
96+
all_buckets.each_with_object(Hash.new(0.0)) do |upper_limit, acc|
97+
acc[upper_limit.to_s] = @store.get(labels: base_label_set.merge(le: upper_limit.to_s))
98+
end.tap do |acc|
99+
accumulate_buckets!(acc)
100+
end
101+
end
102+
end
103+
104+
# Returns all label sets with their values expressed as hashes with their buckets
105+
def values
106+
values = @store.all_values
107+
default_buckets = Hash.new(0.0)
108+
bucket_strings.each { |b| default_buckets[b] = 0.0 }
109+
110+
result = values.each_with_object({}) do |(label_set, v), acc|
111+
actual_label_set = label_set.except(:le)
112+
acc[actual_label_set] ||= default_buckets.dup
113+
acc[actual_label_set][label_set[:le]] = v
114+
end
115+
116+
result.each_value { |v| accumulate_buckets!(v) }
117+
end
118+
119+
def init_label_set(labels)
120+
base_label_set = label_set_for(labels)
121+
122+
@store.synchronize do
123+
(buckets + [INF, SUM]).each do |bucket|
124+
@store.set(labels: base_label_set.merge(le: bucket.to_s), val: 0)
125+
end
126+
end
127+
end
128+
129+
private
130+
131+
# Modifies the passed in parameter
132+
def accumulate_buckets!(h)
133+
accumulator = 0
134+
135+
bucket_strings.each do |upper_limit|
136+
accumulator = (h[upper_limit] += accumulator)
137+
end
138+
139+
h[INF] += accumulator
140+
end
141+
142+
RESERVED_LABELS = [:le].freeze
143+
private_constant :RESERVED_LABELS
144+
def reserved_labels
145+
RESERVED_LABELS
146+
end
147+
148+
def sorted?(bucket)
149+
# This is faster than using `each_cons` and `all?`
150+
bucket == bucket.sort
151+
end
152+
153+
def label_set_for(labels)
154+
@label_set_for ||= Hash.new do |hash, key|
155+
_labels = key.transform_values(&:to_s)
156+
_labels = @validator.validate_labelset_new!(preset_labels.merge(_labels))
157+
_labels[:le] = SUM # We can cache this, because it's always the same
158+
hash[key] = _labels
159+
end
160+
161+
@label_set_for[labels]
162+
end
163+
end
164+
end
165+
end

lib/prometheus/client/label_set_validator.rb

+15
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,21 @@ def validate_labelset!(labelset)
4646
" keys expected: #{expected_labels}"
4747
end
4848

49+
def validate_labelset_new!(labelset)
50+
begin
51+
# keys already allocated new array, so it's safe to modify it in place with sort!
52+
return labelset if labelset.keys.sort! == expected_labels
53+
rescue ArgumentError
54+
# If labelset contains keys that are a mixture of strings and symbols, this will
55+
# raise when trying to sort them, but the error should be the same:
56+
# InvalidLabelSetError
57+
end
58+
59+
raise InvalidLabelSetError, "labels must have the same signature " \
60+
"(keys given: #{labelset.keys} vs." \
61+
" keys expected: #{expected_labels}"
62+
end
63+
4964
private
5065

5166
def keys_match?(labelset)

prometheus-client.gemspec

+1
Original file line numberDiff line numberDiff line change
@@ -20,4 +20,5 @@ Gem::Specification.new do |s|
2020
s.add_development_dependency 'benchmark-ips'
2121
s.add_development_dependency 'concurrent-ruby'
2222
s.add_development_dependency 'timecop'
23+
s.add_development_dependency 'vernier'
2324
end

spec/benchmarks/histogram.rb

+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
# frozen_string_literal: true
2+
3+
require 'benchmark'
4+
require 'benchmark/ips'
5+
require 'vernier'
6+
7+
require 'prometheus/client'
8+
require 'prometheus/client/data_stores/single_threaded'
9+
require 'prometheus/client/histogram'
10+
require 'prometheus/client/histogram_fixed'
11+
12+
# NoopStore doesn't work here, because it doesn't implement `get` and `all_values` methods
13+
Prometheus::Client.config.data_store = Prometheus::Client::DataStores::SingleThreaded.new # Simple data storage
14+
15+
BUCKETS = [
16+
0.00001, 0.000015, 0.00002, 0.000025, 0.00003, 0.000035, 0.00004, 0.000045, 0.00005, 0.000055, 0.00006, 0.000065, 0.00007, 0.000075, 0.00008, 0.000085,
17+
0.00009, 0.000095, 0.0001, 0.000101, 0.000102, 0.000103, 0.000104, 0.000105, 0.000106, 0.000107, 0.000108, 0.000109, 0.00011, 0.000111, 0.000112, 0.000113,
18+
0.000114, 0.000115, 0.000116, 0.000117, 0.000118, 0.000119, 0.00012, 0.000121, 0.000122, 0.000123, 0.000124, 0.000125, 0.000126, 0.000127, 0.000128,
19+
0.000129, 0.00013, 0.000131, 0.000132, 0.000133, 0.000134, 0.000135, 0.000136, 0.000137, 0.000138, 0.000139, 0.00014, 0.000141, 0.000142, 0.000143, 0.000144,
20+
0.000145, 0.000146, 0.000147, 0.000148, 0.000149, 0.00015, 0.000151, 0.000152, 0.000153, 0.000154, 0.000155, 0.000156, 0.000157, 0.000158, 0.000159, 0.00016,
21+
0.000161, 0.000162, 0.000163, 0.000164, 0.000165, 0.000166, 0.000167, 0.000168, 0.000169, 0.00017, 0.000171, 0.000172, 0.000173, 0.000174, 0.000175,
22+
0.000176, 0.000177, 0.000178, 0.000179, 0.00018, 0.000181, 0.000182, 0.000183, 0.000184, 0.000185, 0.000186, 0.000187, 0.000188, 0.000189, 0.00019, 0.000191,
23+
0.000192, 0.000193, 0.000194, 0.000195, 0.000196, 0.000197, 0.000198, 0.000199, 0.0002, 0.00021, 0.00022, 0.00023, 0.00024, 0.00025, 0.00026,
24+
0.00027, 0.00028, 0.00029, 0.0003, 0.00031, 0.00032, 0.00033, 0.00034, 0.00035, 0.00036, 0.00037, 0.00038, 0.00039, 0.0004, 0.00041, 0.00042,
25+
0.00043, 0.00044, 0.00045, 0.00046, 0.00047, 0.00048, 0.00049, 0.0005, 0.00051, 0.00052, 0.00053, 0.00054, 0.00055, 0.00056, 0.00057, 0.00058,
26+
0.00059, 0.0006, 0.00061, 0.00062, 0.00063, 0.00064, 0.00065, 0.00066, 0.00067, 0.00068, 0.00069, 0.0007, 0.00071, 0.00072, 0.00073, 0.00074,
27+
0.00075, 0.00076, 0.00077, 0.00078, 0.00079, 0.0008, 0.00081, 0.00082, 0.00083, 0.00084, 0.00085, 0.00086, 0.00087, 0.00088, 0.00089, 0.0009,
28+
0.00091, 0.00092, 0.00093, 0.00094, 0.00095, 0.00096, 0.00097, 0.00098, 0.00099, 0.001, 0.0015, 0.002, 0.0025, 0.003, 0.0035, 0.004, 0.0045, 0.005,
29+
0.0055, 0.006, 0.0065, 0.007, 0.0075, 0.008, 0.0085, 0.009, 0.0095, 0.01, 0.015, 0.02, 0.025, 0.03, 0.035, 0.04, 0.045, 0.05, 0.055, 0.06, 0.065, 0.07,
30+
0.075, 0.08, 0.085, 0.09, 0.095, 0.1, 0.15, 0.2, 0.25, 0.3, 0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65, 0.7, 0.75, 0.8, 0.85, 0.9, 0.95, 1.0, 1.5, 2.0, 2.5,
31+
3.0, 3.5, 4.0, 4.5, 5.0, 5.5, 6.0, 6.5, 7.0, 7.5, 8.0, 8.5, 9.0, 9.5, 10.0, 10.5, 11.0, 11.5, 12.0, 12.5, 13.0, 13.5, 14.0, 14.5, 15.0, 15.5, 16.0, 16.5, 17.0, 17.5
32+
].freeze
33+
34+
def allocations
35+
x = GC.stat(:total_allocated_objects)
36+
yield
37+
GC.stat(:total_allocated_objects) - x
38+
end
39+
40+
# Setup 4 test cases
41+
# 1. Default buckets + no labels
42+
# 2. Large buckets + no labels
43+
# 3. Default buckets + 2 labels
44+
# 4. Large buckets + 2 labels
45+
TEST_CASES = {
46+
'default buckets + no lables' => [
47+
Prometheus::Client::Histogram.new(
48+
:default_buckets_old,
49+
docstring: 'default buckets + no lables'
50+
),
51+
Prometheus::Client::HistogramFixed.new(
52+
:default_buckets_new,
53+
docstring: 'default buckets + no lables'
54+
)
55+
],
56+
'large buckets + no lables' => [
57+
Prometheus::Client::Histogram.new(
58+
:large_buckets_old,
59+
docstring: 'large buckets + no lables',
60+
buckets: BUCKETS
61+
),
62+
Prometheus::Client::HistogramFixed.new(
63+
:large_buckets_new,
64+
docstring: 'large buckets + no lables',
65+
buckets: BUCKETS
66+
)
67+
],
68+
'default buckets + labels' => [
69+
Prometheus::Client::Histogram.new(
70+
:default_buckets_with_labels_old,
71+
docstring: 'default buckets + labels',
72+
labels: [:label1, :label2]
73+
),
74+
Prometheus::Client::HistogramFixed.new(
75+
:default_buckets_with_labels_new,
76+
docstring: 'default buckets + labels',
77+
labels: [:label1, :label2]
78+
)
79+
],
80+
'large buckets + labels' => [
81+
Prometheus::Client::Histogram.new(
82+
:large_buckets_with_labels_old,
83+
docstring: 'large buckets + labels',
84+
buckets: BUCKETS,
85+
labels: [:label1, :label2]
86+
),
87+
Prometheus::Client::HistogramFixed.new(
88+
:large_buckets_with_labels_new,
89+
docstring: 'large buckets + labels',
90+
buckets: BUCKETS,
91+
labels: [:label1, :label2]
92+
)
93+
],
94+
}.freeze
95+
96+
labels = [1,2,3,4,5,6,7,8,9]
97+
98+
TEST_CASES.each do |name, (old, new)|
99+
with_labels = name.include?('+ labels')
100+
Benchmark.ips do |bm|
101+
bm.report("#{name} -> Observe old") do
102+
if with_labels
103+
old.observe(rand(BUCKETS.last + 10), labels: { label1: labels.sample, label2: labels.sample })
104+
else
105+
old.observe(rand(BUCKETS.last + 10))
106+
end
107+
end
108+
bm.report("#{name} -> Observe new") do
109+
if with_labels
110+
new.observe(rand(BUCKETS.last + 10), labels: { label1: labels.sample, label2: labels.sample })
111+
else
112+
new.observe(rand(BUCKETS.last + 10))
113+
end
114+
end
115+
116+
bm.compare!
117+
end
118+
119+
Benchmark.ips do |bm|
120+
bm.report("#{name} -> Values old") { old.values }
121+
bm.report("#{name} -> Values new") { new.values }
122+
123+
bm.compare!
124+
end
125+
end
126+
127+
# Sample usage of profiler
128+
val = rand(BUCKETS.last)
129+
l = { label1: 1, label2: 2 }
130+
131+
# Vernier.profile(mode: :wall, out: 'x.json', interval: 1, allocation_interval: 1) do
132+
# 100000.times { large_buckets_new.observe(val, labels: l) }
133+
# end
134+
135+
old, new = TEST_CASES['large buckets + labels']
136+
137+
puts "Old#observe allocations -> #{allocations { 1000.times { old.observe(val, labels: l) }}}"
138+
puts "New#observe allocations -> #{allocations { 1000.times { new.observe(val, labels: l) }}}"
139+
140+
puts "Old#values allocations -> #{allocations { 1000.times { old.values }}}"
141+
puts "New#values allocations -> #{allocations { 1000.times { new.values }}}"
142+
143+
144+
# Results:
145+
# 1. Default buckets + no labels
146+
# #observe is almost the same, but #values is 2.15x faster
147+
#
148+
# default buckets + no lables -> Observe new: 492718.9 i/s
149+
# default buckets + no lables -> Observe old: 475856.7 i/s - same-ish: difference falls within error
150+
# default buckets + no lables -> Values new: 98723.1 i/s
151+
# default buckets + no lables -> Values old: 45867.1 i/s - 2.15x slower
152+
#
153+
# 2. Large buckets + no labels
154+
# #observe is almost the same, but #values is 2.93x faster
155+
#
156+
# large buckets + no lables -> Observe new: 441325.9 i/s
157+
# large buckets + no lables -> Observe old: 437752.4 i/s - same-ish: difference falls within error
158+
# large buckets + no lables -> Values new: 4792.0 i/s
159+
# large buckets + no lables -> Values old: 1636.8 i/s - 2.93x slower
160+
#
161+
# 3. Default buckets + 2 labels
162+
# #observe is 1.44x faster, #values is 2.70x faster
163+
#
164+
# default buckets + labels -> Observe new: 450357.3 i/s
165+
# default buckets + labels -> Observe old: 311747.3 i/s - 1.44x slower
166+
# default buckets + labels -> Values new: 1633.8 i/s
167+
# default buckets + labels -> Values old: 604.2 i/s - 2.70x slower
168+
#
169+
# 4. Large buckets + 2 labels
170+
# #observe is 1.41x faster, #values is 9.57x faster
171+
#
172+
# large buckets + labels -> Observe new: 392597.2 i/s
173+
# large buckets + labels -> Observe old: 277499.9 i/s - 1.41x slower
174+
# large buckets + labels -> Values new: 247.6 i/s
175+
# large buckets + labels -> Values old: 25.9 i/s - 9.57x slower
176+
#
177+
# 5. Allocations for 1000 observations for #observe method
178+
# Old allocations -> 11001 - 11 allocations per observation
179+
# New allocations -> 1000 - 1 allocation per observation
180+
# Last place left `bucket_label_set = base_label_set.dup` in #observe method
181+
#
182+
# 6. Allocations for 1000 observations for #values method
183+
# Old#values allocations -> 96150000
184+
# New#values allocations -> 3325000
185+
# almost 30x less allocations

0 commit comments

Comments
 (0)