vernier 1.11.0 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e8d610b954485d3bccefa47408932dddd5806b618caa2958d689f8c0c8fbb3d
4
- data.tar.gz: 2423bf5e06dd1168e21f0a5063050e897137c37bd09ce68a540af9d84b0a7546
3
+ metadata.gz: 245f2f630047ca4214da2d8a939a3948f96486f626137e4cc951fb0161e8bba4
4
+ data.tar.gz: 8b6b4240f03d8e058a1fcae78029d2cb102de0bdebf53da66690143a14a7b8fc
5
5
  SHA512:
6
- metadata.gz: 942b097d843b63457650254b2e9717698939df9ee40c02ff367832d344121e6ffdafffbc2e0824d70229bf5d4187d1cf0eb64491402a4c54c4c5f6dd8fb0b98f
7
- data.tar.gz: 2f710c6b9eeb8e4e352290902aed1a4142ea5b48df7039ee49de66dc7c9f7411e25cb77319addba69a15f072f08b3820a51535fe19070a4e77341aa6c000a57b
6
+ metadata.gz: e0f0a556ccb06c4b95e531f724224de59805ef0821c275f84b0c72e15603b045a24ccc6b420d17fd6cf34bda8c15e100fe4b433fdfa8b0dc2450a76267814c12
7
+ data.tar.gz: 453a6a97efa3ea492cd79dd4a7455933d88e3dd3128e298c75c466bdecbae88d7594a671e8c383f89b9201f67d1f6e442e2cb7060bb5274dcba29bcba673082a
data/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Vernier
2
2
 
3
- Next-generation Ruby 3.2.1+ sampling profiler. Tracks multiple threads, GVL activity, GC pauses, idle time, and more.
3
+ Next-generation Ruby 3.3+ sampling profiler. Tracks multiple threads, GVL activity, GC pauses, idle time, and more.
4
4
 
5
5
  <img width="500" alt="Screenshot 2024-02-29 at 22 47 43" src="https://github.com/jhawthorn/vernier/assets/131752/aa995a41-d74f-405f-8ada-2522dd72c2c8">
6
6
 
@@ -24,7 +24,7 @@ Rails benchmark - lobste.rs (time)
24
24
 
25
25
  ## Installation
26
26
 
27
- Vernier requires Ruby version 3.2.1 or greater and a Unix-like operating system.
27
+ Vernier requires Ruby version 3.3 or greater and a Unix-like operating system.
28
28
 
29
29
  ```ruby
30
30
  gem "vernier", "~> 1.0"
@@ -193,14 +193,15 @@ See [`examples/custom_hook.rb`](examples/custom_hook.rb) for a complete example.
193
193
 
194
194
  ### Options
195
195
 
196
- | Option | Middleware Param | Description | Default (Middleware Default) |
197
- |-----------------------|-------------------------------|---------------------------------------------------------------|------------------------------|
198
- | `mode` | N/A | Sampling mode: `:wall`, `:retained`, or `:custom`. | `:wall` (`:wall`) |
199
- | `out` | N/A | File to write the profile to. | N/A (Auto-generated) |
200
- | `interval` | `vernier_interval` | Sampling interval (µs). Only in `:wall` mode. | `500` (`200`) |
201
- | `allocation_interval` | `vernier_allocation_interval` | Allocation sampling interval. Only in `:wall` mode. | `0` i.e. disabled (`200`) |
202
- | `gc` | N/A | Run full GC cycle before profiling. Only in `:retained` mode. | `true` (N/A) |
203
- | `metadata` | N/A | Metadata key-value pairs to include in the profile. | `{}` (N/A) |
196
+ | Option | Middleware Param | Description | Default (Middleware Default) |
197
+ |--------------------------|-------------------------------|---------------------------------------------------------------|------------------------------|
198
+ | `mode` | N/A | Sampling mode: `:wall`, `:retained`, or `:custom`. | `:wall` (`:wall`) |
199
+ | `out` | N/A | File to write the profile to. | N/A (Auto-generated) |
200
+ | `interval` | `vernier_interval` | Sampling interval (µs). Only in `:wall` mode. | `500` (`200`) |
201
+ | `allocation_interval` | `vernier_allocation_interval` | Allocation sampling interval. Only in `:wall` mode. | `0` i.e. disabled (`200`) |
202
+ | `gc` | N/A | Run full GC cycle before profiling. Only in `:retained` mode. | `true` (N/A) |
203
+ | `metadata` | N/A | Metadata key-value pairs to include in the profile. | `{}` (N/A) |
204
+ | `hide_anonymous_threads` | N/A | Anonymous threads will be initially hidden in profile output. | `false` (N/A) |
204
205
 
205
206
  #### Hook options
206
207
 
@@ -218,6 +219,14 @@ Vernier.profile(out: "time_profile.json", hooks: [:rails, :memory_usage]) do
218
219
  end
219
220
  ```
220
221
 
222
+ ### Overhead
223
+
224
+ Vernier is a sampling profiler, so its overhead is low and depends on the sampling `interval`. Profiling a CPU-bound loop at the default interval runs within a few percent of its unprofiled speed.
225
+
226
+ Allocation sampling (`allocation_interval`) costs more, since every allocation takes a slower path and sampled allocations record a stack. The cost grows with how often the code allocates and how small the interval is.
227
+
228
+ See [`examples/measure_overhead.rb`](examples/measure_overhead.rb) to measure it yourself.
229
+
221
230
  ## Development
222
231
 
223
232
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake test` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
data/exe/vernier CHANGED
@@ -51,6 +51,9 @@ FLAGS:
51
51
  options[:metadata] ||= Metadata.new
52
52
  options[:metadata] << [key, value]
53
53
  end
54
+ o.on('--hide-anonymous-threads', "Anonymous threads will be initially hidden in profiles") do
55
+ options[:hide_anonymous_threads] = true
56
+ end
54
57
  o.on('--format [FORMAT]', String, "output format: firefox (default) or cpuprofile") do |output_format|
55
58
  options[:format] = output_format
56
59
  end
@@ -6,10 +6,11 @@ $CXXFLAGS += " -fvisibility=hidden "
6
6
  $CXXFLAGS += " -std=c++14 "
7
7
  $CXXFLAGS += " -ggdb3 -Og "
8
8
 
9
- have_header("ruby/thread.h")
10
- have_struct_member("rb_internal_thread_event_data_t", "thread", ["ruby/thread.h"])
9
+ # Make VERNIER_ASSERT always check, even if Ruby wasn't built with RUBY_DEBUG.
10
+ # Set by our CI; gem installs get RUBY_ASSERT (compiled out) by default.
11
+ $defs << "-DVERNIER_ALWAYS_ASSERT" if ["1", "true"].include?(ENV["VERNIER_ALWAYS_ASSERT"])
11
12
 
12
- have_func("rb_profile_thread_frames", "ruby/debug.h")
13
+ have_header("ruby/thread.h")
13
14
 
14
15
  have_func("pthread_setname_np")
15
16
  have_func("pthread_condattr_setclock")
@@ -27,14 +27,20 @@ class HeapTracker {
27
27
  unsigned long long objects_allocated = 0;
28
28
  unsigned long long tombstones = 0;
29
29
 
30
+ HeapTracker() = default;
31
+ HeapTracker(const HeapTracker &) = delete;
32
+ HeapTracker &operator=(const HeapTracker &) = delete;
33
+
30
34
  std::unordered_map<VALUE, int> object_index;
31
35
  std::vector<VALUE> object_list;
32
36
  std::vector<int> frame_list;
33
37
 
34
38
  static VALUE rb_new(VALUE self, VALUE stack_table_value) {
39
+ // get_stack_table can raise, so do it before allocating anything
40
+ StackTable *stack_table = get_stack_table(stack_table_value);
35
41
  HeapTracker *heap_tracker = new HeapTracker();
36
42
  heap_tracker->stack_table_value = stack_table_value;
37
- heap_tracker->stack_table = get_stack_table(stack_table_value);
43
+ heap_tracker->stack_table = stack_table;
38
44
  VALUE obj = TypedData_Wrap_Struct(rb_cHeapTracker, &rb_heap_tracker_type, heap_tracker);
39
45
  rb_ivar_set(obj, rb_intern("@stack_table"), stack_table_value);
40
46
  return obj;
@@ -63,7 +69,7 @@ class HeapTracker {
63
69
  frame_list.push_back(stack_index);
64
70
  object_index.emplace(obj, idx);
65
71
 
66
- assert(frame_list.size() == object_list.size());
72
+ VERNIER_ASSERT(frame_list.size() == object_list.size());
67
73
  }
68
74
 
69
75
  void rebuild() {
@@ -175,7 +181,7 @@ class HeapTracker {
175
181
  }
176
182
 
177
183
  VALUE data() {
178
- assert(stopped);
184
+ VERNIER_ASSERT(stopped);
179
185
  VALUE hash = rb_hash_new();
180
186
  VALUE samples = rb_ary_new();
181
187
  rb_hash_aset(hash, sym("samples"), samples);
@@ -222,6 +228,7 @@ class HeapTracker {
222
228
  object_index.clear();
223
229
  for (int i = 0; i < object_list.size(); i++) {
224
230
  VALUE obj = object_list[i];
231
+ if (obj == Qfalse) continue; // tombstone
225
232
  VALUE reloc_obj = rb_gc_location(obj);
226
233
 
227
234
  object_list[i] = reloc_obj;
@@ -72,7 +72,7 @@ class MemoryTracker : public PeriodicThread {
72
72
  MemoryTracker() : PeriodicThread(TimeStamp::from_milliseconds(10)) {
73
73
  }
74
74
 
75
- void run_iteration() {
75
+ void run_iteration() override {
76
76
  record();
77
77
  }
78
78
 
@@ -82,12 +82,19 @@ class MemoryTracker : public PeriodicThread {
82
82
  }
83
83
  };
84
84
 
85
+ static void
86
+ memory_tracker_free(void *data) {
87
+ MemoryTracker *memory_tracker = static_cast<MemoryTracker *>(data);
88
+ // The sampler thread holds a pointer to the tracker, so it must be
89
+ // stopped (and joined) before the tracker is destroyed.
90
+ memory_tracker->stop();
91
+ delete memory_tracker;
92
+ }
93
+
85
94
  static const rb_data_type_t rb_memory_tracker_type = {
86
95
  .wrap_struct_name = "vernier/memory_tracker",
87
96
  .function = {
88
- //.dmark = memory_tracker_mark,
89
- //.dfree = memory_tracker_free,
90
- //.dsize = memory_tracker_memsize,
97
+ .dfree = memory_tracker_free,
91
98
  },
92
99
  };
93
100
 
@@ -116,9 +123,16 @@ VALUE memory_tracker_record(VALUE self) {
116
123
  VALUE memory_tracker_results(VALUE self) {
117
124
  MemoryTracker *memory_tracker;
118
125
  TypedData_Get_Struct(self, MemoryTracker, &rb_memory_tracker_type, memory_tracker);
126
+ // Copy under the lock; don't call into Ruby (which can allocate, GC or
127
+ // raise) while holding it.
128
+ std::vector<MemoryTracker::Record> results;
129
+ {
130
+ const std::lock_guard<std::mutex> lock(memory_tracker->mutex);
131
+ results = memory_tracker->results;
132
+ }
119
133
  VALUE timestamps = rb_ary_new();
120
134
  VALUE memory = rb_ary_new();
121
- for (const auto& record: memory_tracker->results) {
135
+ for (const auto& record: results) {
122
136
  rb_ary_push(timestamps, ULL2NUM(record.timestamp.nanoseconds()));
123
137
  rb_ary_push(memory, ULL2NUM(record.memory_rss));
124
138
  }
@@ -1,6 +1,7 @@
1
1
  #include "ruby.h"
2
2
 
3
3
  #include <atomic>
4
+ #include <errno.h>
4
5
  #include "timestamp.hh"
5
6
 
6
7
  #ifdef __APPLE__
@@ -41,7 +42,7 @@ inline void upgrade_thread_priority(pthread_t pthread) {
41
42
  #endif
42
43
 
43
44
  class PeriodicThread {
44
- pthread_t pthread;
45
+ pthread_t pthread = 0;
45
46
  TimeStamp interval;
46
47
 
47
48
  pthread_mutex_t running_mutex = PTHREAD_MUTEX_INITIALIZER;
@@ -56,8 +57,20 @@ class PeriodicThread {
56
57
  pthread_condattr_setclock(&attr, CLOCK_MONOTONIC);
57
58
  #endif
58
59
  pthread_cond_init(&running_cv, &attr);
60
+ pthread_condattr_destroy(&attr);
59
61
  }
60
62
 
63
+ // Deleting through a base pointer must run the derived destructor.
64
+ // Subclasses must stop() the thread before they are destroyed, as it
65
+ // calls their virtual run_iteration().
66
+ virtual ~PeriodicThread() {
67
+ pthread_cond_destroy(&running_cv);
68
+ pthread_mutex_destroy(&running_mutex);
69
+ }
70
+
71
+ PeriodicThread(const PeriodicThread &) = delete;
72
+ PeriodicThread &operator=(const PeriodicThread &) = delete;
73
+
61
74
  void set_interval(TimeStamp timestamp) {
62
75
  interval = timestamp;
63
76
  }
@@ -99,10 +112,12 @@ class PeriodicThread {
99
112
  auto offset = TimeStamp::NowRealtime() - TimeStamp::Now();
100
113
  struct timespec next_sample_ts = (next_sample_schedule + offset).timespec();
101
114
  #endif
115
+ // Wait until the deadline passes or we are asked to stop.
116
+ // Wakeups may be spurious, so only a timeout ends the wait.
102
117
  int ret;
103
118
  do {
104
119
  ret = pthread_cond_timedwait(&running_cv, &running_mutex, &next_sample_ts);
105
- } while(running && ret == EINTR);
120
+ } while (running && ret != ETIMEDOUT);
106
121
  }
107
122
  done = !running;
108
123
  pthread_mutex_unlock(&running_mutex);
@@ -25,6 +25,9 @@ class SignalSafeSemaphore {
25
25
 
26
26
  public:
27
27
 
28
+ SignalSafeSemaphore(const SignalSafeSemaphore &) = delete;
29
+ SignalSafeSemaphore &operator=(const SignalSafeSemaphore &) = delete;
30
+
28
31
  SignalSafeSemaphore(unsigned int value = 0) {
29
32
  #ifdef __APPLE__
30
33
  sem = dispatch_semaphore_create(value);
@@ -56,7 +59,6 @@ class SignalSafeSemaphore {
56
59
  if (ret != 0) {
57
60
  rb_bug("VERNIER: sem_timedwait waited over 5 seconds");
58
61
  }
59
- assert(ret == 0);
60
62
  #endif
61
63
  }
62
64
 
@@ -53,6 +53,9 @@ stack_table_current_stack(int argc, VALUE *argv, VALUE self) {
53
53
  RawSample stack;
54
54
  stack.sample(offset);
55
55
  int stack_index = stack_table->stack_index(stack);
56
+ if (stack_index < 0) {
57
+ rb_raise(rb_eArgError, "no stack at the given offset");
58
+ }
56
59
  return INT2NUM(stack_index);
57
60
  }
58
61
 
@@ -174,7 +177,6 @@ StackTable::stack_table_func_name(VALUE self, VALUE idxval) {
174
177
  return Qnil;
175
178
  } else {
176
179
  const auto &func_info = table[idx];
177
- std::string label = func_info.full_label();
178
180
 
179
181
  // Ruby constants are in an arbitrary (ASCII compatible) encoding and
180
182
  // method names are in an arbitrary (ASCII compatible) encoding. These
@@ -188,7 +190,18 @@ StackTable::stack_table_func_name(VALUE self, VALUE idxval) {
188
190
  //
189
191
  // In the future we might keep class and method name separate for
190
192
  // longer, preserve encodings, and defer formatting to the Ruby side.
191
- return rb_enc_interned_str(label.c_str(), label.length(), rb_utf8_encoding());
193
+ //
194
+ // The label is assembled directly in a Ruby string so that no C++
195
+ // temporaries are alive across calls that may raise.
196
+ VALUE str = rb_utf8_str_new("", 0);
197
+ if (!func_info.method_name.empty()) {
198
+ rb_str_cat(str, func_info.classpath.data(), func_info.classpath.size());
199
+ rb_str_cat(str, func_info.is_singleton ? "." : "#", 1);
200
+ rb_str_cat(str, func_info.method_name.data(), func_info.method_name.size());
201
+ } else {
202
+ rb_str_cat(str, func_info.label.data(), func_info.label.size());
203
+ }
204
+ return rb_str_to_interned_str(str);
192
205
  }
193
206
  }
194
207
 
@@ -202,8 +215,7 @@ StackTable::stack_table_func_filename(VALUE self, VALUE idxval) {
202
215
  return Qnil;
203
216
  } else {
204
217
  const auto &func_info = table[idx];
205
- std::string filename = func_info.absolute_path;
206
- if (filename.empty()) filename = func_info.path;
218
+ const std::string &filename = func_info.absolute_path.empty() ? func_info.path : func_info.absolute_path;
207
219
 
208
220
  // Technically filesystems are binary and then Ruby interprets that as
209
221
  // default_external encoding. But to keep things simple for now we are
@@ -222,7 +234,7 @@ StackTable::stack_table_func_path(VALUE self, VALUE idxval) {
222
234
  return Qnil;
223
235
  } else {
224
236
  const auto &func_info = table[idx];
225
- std::string filename = func_info.path;
237
+ const std::string &filename = func_info.path;
226
238
 
227
239
  // Technically filesystems are binary and then Ruby interprets that as
228
240
  // default_external encoding. But to keep things simple for now we are
@@ -241,7 +253,7 @@ StackTable::stack_table_func_absolute_path(VALUE self, VALUE idxval) {
241
253
  return Qnil;
242
254
  } else {
243
255
  const auto &func_info = table[idx];
244
- std::string filename = func_info.absolute_path;
256
+ const std::string &filename = func_info.absolute_path;
245
257
 
246
258
  // Technically filesystems are binary and then Ruby interprets that as
247
259
  // default_external encoding. But to keep things simple for now we are
@@ -2,7 +2,7 @@
2
2
  #define STACK_TABLE_HH STACK_TABLE_HH
3
3
 
4
4
  #include <mutex>
5
- #include <stdexcept>
5
+ #include <algorithm>
6
6
  #include <string>
7
7
  #include <unordered_map>
8
8
  #include <vector>
@@ -30,7 +30,11 @@ namespace std {
30
30
  {
31
31
  std::size_t operator()(Frame const& s) const noexcept
32
32
  {
33
- return s.frame ^ s.line;
33
+ // VALUEs are aligned so their low bits carry no entropy; mix in
34
+ // the line number rather than xoring it into the low bits.
35
+ std::size_t h = static_cast<std::size_t>(s.frame) >> 3;
36
+ h ^= static_cast<std::size_t>(s.line) + 0x9e3779b97f4a7c15ULL + (h << 6) + (h >> 2);
37
+ return h;
34
38
  }
35
39
  };
36
40
  }
@@ -50,7 +54,7 @@ class RawSample {
50
54
 
51
55
  public:
52
56
 
53
- RawSample() : len(0), gc(false), offset(0) { }
57
+ RawSample() : len(0), offset(0), gc(false) { }
54
58
 
55
59
  int size() const {
56
60
  return len - offset;
@@ -58,7 +62,7 @@ class RawSample {
58
62
 
59
63
  Frame frame(int i) const {
60
64
  int idx = len - i - 1;
61
- if (idx < 0) throw std::out_of_range("VERNIER BUG: index out of range");
65
+ if (idx < 0) rb_bug("VERNIER BUG: index out of range");
62
66
  const Frame frame = {frames[idx], lines[idx]};
63
67
  return frame;
64
68
  }
@@ -95,34 +99,22 @@ struct FuncInfo {
95
99
  return NIL_P(first_lineno) ? 0 : FIX2INT(first_lineno);
96
100
  }
97
101
 
102
+ // Must not raise: this runs while std::string members are under
103
+ // construction, and a longjmp would skip their destructors.
98
104
  static std::string convert_rstring(VALUE rstring) {
99
- if (NIL_P(rstring)) {
105
+ if (!RB_TYPE_P(rstring, T_STRING)) {
100
106
  return "";
101
- } else {
102
- const char *cstring = StringValueCStr(rstring);
103
- return cstring;
104
- }
105
- }
106
-
107
- std::string full_label() const {
108
- std::string output;
109
- if (!method_name.empty()) {
110
- output.append(classpath);
111
- output.append(is_singleton ? "." : "#");
112
- output.append(method_name);
113
- } else {
114
- output.append(label);
115
107
  }
116
- return output;
108
+ return std::string(RSTRING_PTR(rstring), RSTRING_LEN(rstring));
117
109
  }
118
110
 
119
111
  FuncInfo(VALUE frame) :
120
112
  label(convert_rstring(rb_profile_frame_label(frame))),
121
113
  base_label(convert_rstring(rb_profile_frame_base_label(frame))),
122
114
  classpath(convert_rstring(rb_profile_frame_classpath(frame))),
115
+ path(convert_rstring(rb_profile_frame_path(frame))),
123
116
  absolute_path(convert_rstring(rb_profile_frame_absolute_path(frame))),
124
117
  method_name(convert_rstring(rb_profile_frame_method_name(frame))),
125
- path(convert_rstring(rb_profile_frame_path(frame))),
126
118
  first_lineno(first_lineno_int(frame)),
127
119
  is_singleton(RTEST(rb_profile_frame_singleton_method_p(frame)))
128
120
  { }
@@ -171,6 +163,10 @@ class IndexMap {
171
163
  };
172
164
 
173
165
  struct StackTable {
166
+ StackTable() = default;
167
+ StackTable(const StackTable &) = delete;
168
+ StackTable &operator=(const StackTable &) = delete;
169
+
174
170
  private:
175
171
 
176
172
  IndexMap<Frame> frame_map;
@@ -184,10 +180,10 @@ struct StackTable {
184
180
  int parent;
185
181
  int index;
186
182
 
187
- StackNode(Frame frame, int index, int parent) : frame(frame), index(index), parent(parent) {}
183
+ StackNode(Frame frame, int index, int parent) : frame(frame), parent(parent), index(index) {}
188
184
 
189
185
  // root
190
- StackNode() : frame(Frame{0, 0}), index(-1), parent(-1) {}
186
+ StackNode() : frame(Frame{0, 0}), parent(-1), index(-1) {}
191
187
  };
192
188
 
193
189
  // This mutex guards the StackNodes only. The rest of the maps and vectors
@@ -218,9 +214,11 @@ struct StackTable {
218
214
 
219
215
  public:
220
216
 
217
+ // Returns -1 for an empty stack. Never throws, as it may be called from
218
+ // C callbacks invoked by Ruby.
221
219
  int stack_index(const RawSample &stack) {
222
220
  if (stack.empty()) {
223
- throw std::runtime_error("VERNIER BUG: empty stack");
221
+ return -1;
224
222
  }
225
223
 
226
224
  const std::lock_guard<std::mutex> lock(stack_mutex);
@@ -260,8 +258,8 @@ struct StackTable {
260
258
  const auto &stack_node = stack_node_list[i];
261
259
  frame_map.index(stack_node.frame);
262
260
  func_map.index(stack_node.frame.frame);
263
- stack_node_list_finalized_idx = i;
264
261
  }
262
+ stack_node_list_finalized_idx = stack_node_list.size();
265
263
  }
266
264
 
267
265
  for (int i = func_info_list.size(); i < func_map.size(); i++) {
@@ -274,7 +272,7 @@ struct StackTable {
274
272
  void mark_frames() {
275
273
  const std::lock_guard<std::mutex> lock(stack_mutex);
276
274
 
277
- for (auto stack_node: stack_node_list) {
275
+ for (const auto &stack_node: stack_node_list) {
278
276
  rb_gc_mark(stack_node.frame.frame);
279
277
  }
280
278
  }