vernier 0.5.0 → 0.6.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: f552d6fe2d529de743412cbc9975bfbd4ac87b894777a44ce26ef07dddc8e032
4
- data.tar.gz: b9ddcd3e4ce0acb5ac53363e041d1bd9e3a2d4c5bf21b2bbe56c48ed5fef4cdd
3
+ metadata.gz: b9ba6d060b4eb230a865df67f378f81b08721d36a9f8cf394f9a8d5d0455b844
4
+ data.tar.gz: d01c209f5fab9bc99940fb5b967a722f69c75376dd6aba0c3e7759a9c52bc698
5
5
  SHA512:
6
- metadata.gz: 1b0808ee6ae8e64866e81e7ba8ed4847788421a00517c00e7aacb54c2fdff16287f92eb10132b5802fcb93955c3e4cf1a8fe4cfc97f4a9742a8130341bea75f7
7
- data.tar.gz: 686a7397043be44451cccf9380473cda1e350ee342878ca2428d8bcd6c69aeea36244d15c92e3f998d4b6245c46c37d74ac3aac18ed58c783c288501d0cf7243
6
+ metadata.gz: 65e359b25b1cc5a5e3dc2724ec7c98eb4f20203a98e0fd8f402b5875fe329afb6a20f6fc722f8da2febdd4cd30e8e2d56a00e42a4e5262baffb7a250fba79f3d
7
+ data.tar.gz: bf9ca5416e6b7b17d316eb9c762273ab711518c7ba50c862f803eed9e2d17fcbdeee378f9d6702d4029715926d105398105c5eb0f6aec046fa540611993ec89a
data/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Next-generation Ruby 3.2.1+ sampling profiler. Tracks multiple threads, GVL activity, GC pauses, idle time, and more.
4
4
 
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
+
5
7
  ## Examples
6
8
 
7
9
  [Livestreamed demo: Pairin' with Aaron (YouTube)](https://www.youtube.com/watch?v=9nvX3OHykGQ#t=27m43)
@@ -35,7 +37,7 @@ gem 'vernier'
35
37
  Vernier.trace(out: "time_profile.json") { some_slow_method }
36
38
  ```
37
39
 
38
- The output can then be viewed in the Firefox Profiler (demo) or the [`profile-viewer` gem](https://github.com/tenderlove/profiler/tree/ruby) (a Ruby-customized version of the firefox profiler.
40
+ The output can be viewed in the web app at https://vernier.prof or locally using the [`profile-viewer` gem](https://github.com/tenderlove/profiler/tree/ruby) (both are lightly customized versions of the firefox profiler frontend, which profiles are also compatible with).
39
41
 
40
42
  - **Flame Graph**: Shows proportionally how much time is spent within particular stack frames. Frames are grouped together, which means that x-axis / left-to-right order is not meaningful.
41
43
  - **Stack Chart**: Shows the stack at each sample with the x-axis representing time and can be read left-to-right.
data/examples/rails.rb CHANGED
@@ -59,12 +59,17 @@ end
59
59
 
60
60
  silence do
61
61
  ActiveRecord::Schema.define do
62
+ create_table :users, force: true do |t|
63
+ t.string :login
64
+ end
62
65
  create_table :posts, force: true do |t|
66
+ t.belongs_to :user
63
67
  t.string :title
64
68
  t.text :body
65
69
  t.integer :likes
66
70
  end
67
71
  create_table :comments, force: true do |t|
72
+ t.belongs_to :user
68
73
  t.belongs_to :post
69
74
  t.string :title
70
75
  t.text :body
@@ -73,25 +78,33 @@ silence do
73
78
  end
74
79
  end
75
80
 
81
+ class User < ActiveRecord::Base
82
+ end
83
+
76
84
  class Post < ActiveRecord::Base
77
85
  has_many :comments
86
+ belongs_to :user
78
87
  end
79
88
 
80
89
  class Comment < ActiveRecord::Base
81
90
  belongs_to :post
91
+ belongs_to :user
82
92
  end
83
93
 
94
+ users = 0.upto(100).map do |i|
95
+ User.create!(login: "user#{i}")
96
+ end
84
97
  0.upto(100) do |i|
85
- post = Post.create!(title: "Post number #{i}", body: "blog " * 50, likes: ((i * 1337) % 30))
98
+ post = Post.create!(title: "Post number #{i}", body: "blog " * 50, likes: ((i * 1337) % 30), user: users.sample)
86
99
  5.times do
87
- post.comments.create!(post: post, title: "nice post!", body: "keep it up!", posted_at: Time.now)
100
+ post.comments.create!(post: post, title: "nice post!", body: "keep it up!", posted_at: Time.now, user: users.sample)
88
101
  end
89
102
  end
90
103
 
91
104
  class HomeController < ActionController::Base
92
105
  def show
93
- posts = Post.order(likes: :desc).includes(:comments).first(10)
94
- render json: posts
106
+ posts = Post.order(likes: :desc).includes(:user, comments: :user).first(10)
107
+ render json: posts, include: [:user, comments: { include: :user } ]
95
108
  end
96
109
  end
97
110
 
@@ -110,15 +123,7 @@ end
110
123
  # warm up
111
124
  make_request.call
112
125
 
113
- Vernier.trace(out: "rails.json") do |collector|
114
- ActiveSupport::Notifications.monotonic_subscribe do |name, start, finish, id, payload|
115
- collector.add_marker(
116
- name:,
117
- start: (start * 1_000_000_000).to_i,
118
- finish: (finish * 1_000_000_000).to_i,
119
- )
120
- end
121
-
126
+ Vernier.trace(out: "rails.json", hooks: [:rails], allocation_sample_rate: 100) do |collector|
122
127
  1000.times do
123
128
  make_request.call
124
129
  end
@@ -1,6 +1,6 @@
1
1
  require "vernier"
2
2
 
3
- Vernier.trace(out: "http_requests.json") do
3
+ Vernier.trace(out: "http_requests.json", allocation_sample_rate: 100) do
4
4
 
5
5
  require "net/http"
6
6
  require "uri"
data/exe/vernier CHANGED
@@ -16,6 +16,9 @@ parser = OptionParser.new(banner) do |o|
16
16
  o.on('--interval [MICROSECONDS]', Integer, "sampling interval (default 500)") do |i|
17
17
  options[:interval] = i
18
18
  end
19
+ o.on('--allocation_sample_rate [ALLOCATIONS]', Integer, "allocation sampling interval (default 0 disabled)") do |i|
20
+ options[:allocation_sample_rate] = i
21
+ end
19
22
  o.on('--signal [NAME]', String, "specify a signal to start and stop the profiler") do |s|
20
23
  options[:signal] = s
21
24
  end
@@ -331,7 +331,7 @@ struct RawSample {
331
331
 
332
332
  Frame frame(int i) const {
333
333
  int idx = len - i - 1;
334
- if (idx < 0) throw std::out_of_range("out of range");
334
+ if (idx < 0) throw std::out_of_range("VERNIER BUG: index out of range");
335
335
  const Frame frame = {frames[idx], lines[idx]};
336
336
  return frame;
337
337
  }
@@ -446,7 +446,7 @@ struct FrameList {
446
446
 
447
447
  int stack_index(const RawSample &stack) {
448
448
  if (stack.empty()) {
449
- throw std::runtime_error("empty stack");
449
+ throw std::runtime_error("VERNIER BUG: empty stack");
450
450
  }
451
451
 
452
452
  StackNode *node = &root_stack_node;
@@ -659,8 +659,8 @@ class Marker {
659
659
 
660
660
  class MarkerTable {
661
661
  public:
662
- std::vector<Marker> list;
663
662
  std::mutex mutex;
663
+ std::vector<Marker> list;
664
664
 
665
665
  void record_interval(Marker::Type type, TimeStamp from, TimeStamp to, int stack_index = -1) {
666
666
  const std::lock_guard<std::mutex> lock(mutex);
@@ -693,6 +693,51 @@ enum Category{
693
693
  CATEGORY_IDLE
694
694
  };
695
695
 
696
+ class ObjectSampleList {
697
+ public:
698
+
699
+ std::vector<int> stacks;
700
+ std::vector<TimeStamp> timestamps;
701
+ std::vector<int> weights;
702
+
703
+ size_t size() {
704
+ return stacks.size();
705
+ }
706
+
707
+ bool empty() {
708
+ return size() == 0;
709
+ }
710
+
711
+ void record_sample(int stack_index, TimeStamp time, int weight) {
712
+ stacks.push_back(stack_index);
713
+ timestamps.push_back(time);
714
+ weights.push_back(1);
715
+ }
716
+
717
+ void write_result(VALUE result) const {
718
+ VALUE allocations = rb_hash_new();
719
+ rb_hash_aset(result, sym("allocations"), allocations);
720
+
721
+ VALUE samples = rb_ary_new();
722
+ rb_hash_aset(allocations, sym("samples"), samples);
723
+ for (auto& stack_index: this->stacks) {
724
+ rb_ary_push(samples, INT2NUM(stack_index));
725
+ }
726
+
727
+ VALUE weights = rb_ary_new();
728
+ rb_hash_aset(allocations, sym("weights"), weights);
729
+ for (auto& weight: this->weights) {
730
+ rb_ary_push(weights, INT2NUM(weight));
731
+ }
732
+
733
+ VALUE timestamps = rb_ary_new();
734
+ rb_hash_aset(allocations, sym("timestamps"), timestamps);
735
+ for (auto& timestamp: this->timestamps) {
736
+ rb_ary_push(timestamps, ULL2NUM(timestamp.nanoseconds()));
737
+ }
738
+ }
739
+ };
740
+
696
741
  class SampleList {
697
742
  public:
698
743
 
@@ -758,6 +803,7 @@ class SampleList {
758
803
  class Thread {
759
804
  public:
760
805
  SampleList samples;
806
+ ObjectSampleList allocation_samples;
761
807
 
762
808
  enum State {
763
809
  STARTED,
@@ -780,25 +826,29 @@ class Thread {
780
826
  int stack_on_suspend_idx;
781
827
  SampleTranslator translator;
782
828
 
783
- MarkerTable *markers;
784
-
785
- std::string name;
829
+ unique_ptr<MarkerTable> markers;
786
830
 
787
- // FIXME: don't use pthread at start
831
+ // FIXME: don't use pthread at start
788
832
  Thread(State state, pthread_t pthread_id, VALUE ruby_thread) : pthread_id(pthread_id), ruby_thread(ruby_thread), state(state), stack_on_suspend_idx(-1) {
789
- name = Qnil;
790
833
  ruby_thread_id = rb_obj_id(ruby_thread);
791
- //ruby_thread_id = ULL2NUM(ruby_thread);
834
+ //ruby_thread_id = ULL2NUM(ruby_thread);
792
835
  native_tid = get_native_thread_id();
793
836
  started_at = state_changed_at = TimeStamp::Now();
794
- name = "";
795
- markers = new MarkerTable();
837
+ markers = std::make_unique<MarkerTable>();
796
838
 
797
839
  if (state == State::STARTED) {
798
840
  markers->record(Marker::Type::MARKER_GVL_THREAD_STARTED);
799
841
  }
800
842
  }
801
843
 
844
+ void record_newobj(VALUE obj, FrameList &frame_list) {
845
+ RawSample sample;
846
+ sample.sample();
847
+
848
+ int stack_idx = translator.translate(frame_list, sample);
849
+ allocation_samples.record_sample(stack_idx, TimeStamp::Now(), 1);
850
+ }
851
+
802
852
  void set_state(State new_state) {
803
853
  if (state == Thread::State::STOPPED) {
804
854
  return;
@@ -867,6 +917,10 @@ class Thread {
867
917
  state_changed_at = now;
868
918
  }
869
919
 
920
+ bool is_main() {
921
+ return rb_thread_main() == ruby_thread;
922
+ }
923
+
870
924
  bool running() {
871
925
  return state != State::STOPPED;
872
926
  }
@@ -879,15 +933,15 @@ class ThreadTable {
879
933
  public:
880
934
  FrameList &frame_list;
881
935
 
882
- std::vector<Thread> list;
936
+ std::vector<std::unique_ptr<Thread> > list;
883
937
  std::mutex mutex;
884
938
 
885
939
  ThreadTable(FrameList &frame_list) : frame_list(frame_list) {
886
940
  }
887
941
 
888
942
  void mark() {
889
- for (auto &thread : list) {
890
- thread.mark();
943
+ for (const auto &thread : list) {
944
+ thread->mark();
891
945
  }
892
946
  }
893
947
 
@@ -923,7 +977,8 @@ class ThreadTable {
923
977
 
924
978
  //fprintf(stderr, "th %p (tid: %i) from %s to %s\n", (void *)th, native_tid, gvl_event_name(state), gvl_event_name(new_state));
925
979
 
926
- for (auto &thread : list) {
980
+ for (auto &threadptr : list) {
981
+ auto &thread = *threadptr;
927
982
  if (thread_equal(th, thread.ruby_thread)) {
928
983
  if (new_state == Thread::State::SUSPENDED) {
929
984
 
@@ -950,7 +1005,7 @@ class ThreadTable {
950
1005
  }
951
1006
 
952
1007
  //fprintf(stderr, "NEW THREAD: th: %p, state: %i\n", th, new_state);
953
- list.emplace_back(new_state, pthread_self(), th);
1008
+ list.push_back(std::make_unique<Thread>(new_state, pthread_self(), th));
954
1009
  }
955
1010
 
956
1011
  bool thread_equal(VALUE a, VALUE b) {
@@ -1226,17 +1281,22 @@ class GlobalSignalHandler {
1226
1281
  if (count == 0) clear_signal_handler();
1227
1282
  }
1228
1283
 
1229
- void record_sample(LiveSample &sample, pthread_t pthread_id) {
1284
+ bool record_sample(LiveSample &sample, pthread_t pthread_id) {
1230
1285
  const std::lock_guard<std::mutex> lock(mutex);
1231
1286
 
1232
1287
  assert(pthread_id);
1233
1288
 
1234
1289
  live_sample = &sample;
1235
- if (pthread_kill(pthread_id, SIGPROF)) {
1236
- rb_bug("pthread_kill failed");
1290
+ int rc = pthread_kill(pthread_id, SIGPROF);
1291
+ if (rc) {
1292
+ fprintf(stderr, "VERNIER BUG: pthread_kill of %lu failed (%i)\n", (unsigned long)pthread_id, rc);
1293
+ live_sample = NULL;
1294
+ return false;
1295
+ } else {
1296
+ sample.wait();
1297
+ live_sample = NULL;
1298
+ return true;
1237
1299
  }
1238
- sample.wait();
1239
- live_sample = NULL;
1240
1300
  }
1241
1301
 
1242
1302
  private:
@@ -1276,9 +1336,40 @@ class TimeCollector : public BaseCollector {
1276
1336
  SamplerSemaphore thread_stopped;
1277
1337
 
1278
1338
  TimeStamp interval;
1339
+ unsigned int allocation_sample_rate;
1340
+ unsigned int allocation_sample_tick = 0;
1341
+
1342
+ VALUE tp_newobj = Qnil;
1343
+
1344
+ static void newobj_i(VALUE tpval, void *data) {
1345
+ TimeCollector *collector = static_cast<TimeCollector *>(data);
1346
+ rb_trace_arg_t *tparg = rb_tracearg_from_tracepoint(tpval);
1347
+ VALUE obj = rb_tracearg_object(tparg);
1348
+
1349
+ collector->record_newobj(obj);
1350
+ }
1279
1351
 
1280
1352
  public:
1281
- TimeCollector(TimeStamp interval) : interval(interval), threads(frame_list) {
1353
+ TimeCollector(TimeStamp interval, unsigned int allocation_sample_rate) : interval(interval), allocation_sample_rate(allocation_sample_rate), threads(frame_list) {
1354
+ }
1355
+
1356
+ void record_newobj(VALUE obj) {
1357
+ if (++allocation_sample_tick < allocation_sample_rate) {
1358
+ return;
1359
+ }
1360
+ allocation_sample_tick = 0;
1361
+
1362
+ VALUE current_thread = rb_thread_current();
1363
+ threads.mutex.lock();
1364
+ for (auto &threadptr : threads.list) {
1365
+ auto &thread = *threadptr;
1366
+ if (current_thread == thread.ruby_thread) {
1367
+ thread.record_newobj(obj, threads.frame_list);
1368
+ break;
1369
+ }
1370
+ }
1371
+ threads.mutex.unlock();
1372
+
1282
1373
  }
1283
1374
 
1284
1375
  private:
@@ -1307,9 +1398,9 @@ class TimeCollector : public BaseCollector {
1307
1398
  rb_ary_push(list, ary);
1308
1399
  }
1309
1400
  for (auto &thread : threads.list) {
1310
- for (auto& marker: thread.markers->list) {
1401
+ for (auto& marker: thread->markers->list) {
1311
1402
  VALUE ary = marker.to_array();
1312
- RARRAY_ASET(ary, 0, thread.ruby_thread_id);
1403
+ RARRAY_ASET(ary, 0, thread->ruby_thread_id);
1313
1404
  rb_ary_push(list, ary);
1314
1405
  }
1315
1406
  }
@@ -1325,14 +1416,21 @@ class TimeCollector : public BaseCollector {
1325
1416
  TimeStamp sample_start = TimeStamp::Now();
1326
1417
 
1327
1418
  threads.mutex.lock();
1328
- for (auto &thread : threads.list) {
1419
+ for (auto &threadptr : threads.list) {
1420
+ auto &thread = *threadptr;
1421
+
1329
1422
  //if (thread.state == Thread::State::RUNNING) {
1330
1423
  //if (thread.state == Thread::State::RUNNING || (thread.state == Thread::State::SUSPENDED && thread.stack_on_suspend_idx < 0)) {
1331
1424
  if (thread.state == Thread::State::RUNNING) {
1332
1425
  //fprintf(stderr, "sampling %p on tid:%i\n", thread.ruby_thread, thread.native_tid);
1333
- GlobalSignalHandler::get_instance()->record_sample(sample, thread.pthread_id);
1334
-
1335
- if (sample.sample.gc) {
1426
+ bool signal_sent = GlobalSignalHandler::get_instance()->record_sample(sample, thread.pthread_id);
1427
+
1428
+ if (!signal_sent) {
1429
+ // The thread has died. We probably should have caught
1430
+ // that by the GVL instrumentation, but let's try to get
1431
+ // it to a consistent state and stop profiling it.
1432
+ thread.set_state(Thread::State::STOPPED);
1433
+ } else if (sample.sample.gc) {
1336
1434
  // fprintf(stderr, "skipping GC sample\n");
1337
1435
  } else {
1338
1436
  record_sample(sample.sample, sample_start, thread, CATEGORY_NORMAL);
@@ -1458,6 +1556,11 @@ class TimeCollector : public BaseCollector {
1458
1556
  return false;
1459
1557
  }
1460
1558
 
1559
+ if (allocation_sample_rate > 0) {
1560
+ tp_newobj = rb_tracepoint_new(0, RUBY_INTERNAL_EVENT_NEWOBJ, newobj_i, this);
1561
+ rb_tracepoint_enable(tp_newobj);
1562
+ }
1563
+
1461
1564
  GlobalSignalHandler::get_instance()->install();
1462
1565
 
1463
1566
  running = true;
@@ -1465,7 +1568,7 @@ class TimeCollector : public BaseCollector {
1465
1568
  int ret = pthread_create(&sample_thread, NULL, &sample_thread_entry, this);
1466
1569
  if (ret != 0) {
1467
1570
  perror("pthread_create");
1468
- rb_bug("pthread_create");
1571
+ rb_bug("VERNIER: pthread_create failed");
1469
1572
  }
1470
1573
 
1471
1574
  // Set the state of the current Ruby thread to RUNNING, which we know it
@@ -1490,6 +1593,11 @@ class TimeCollector : public BaseCollector {
1490
1593
 
1491
1594
  GlobalSignalHandler::get_instance()->uninstall();
1492
1595
 
1596
+ if (RTEST(tp_newobj)) {
1597
+ rb_tracepoint_disable(tp_newobj);
1598
+ tp_newobj = Qnil;
1599
+ }
1600
+
1493
1601
  rb_internal_thread_remove_event_hook(thread_hook);
1494
1602
  rb_remove_event_hook(internal_gc_event_cb);
1495
1603
  rb_remove_event_hook(internal_thread_event_cb);
@@ -1511,15 +1619,16 @@ class TimeCollector : public BaseCollector {
1511
1619
 
1512
1620
  for (const auto& thread: this->threads.list) {
1513
1621
  VALUE hash = rb_hash_new();
1514
- thread.samples.write_result(hash);
1515
-
1516
- rb_hash_aset(threads, thread.ruby_thread_id, hash);
1517
- rb_hash_aset(hash, sym("tid"), ULL2NUM(thread.native_tid));
1518
- rb_hash_aset(hash, sym("started_at"), ULL2NUM(thread.started_at.nanoseconds()));
1519
- if (!thread.stopped_at.zero()) {
1520
- rb_hash_aset(hash, sym("stopped_at"), ULL2NUM(thread.stopped_at.nanoseconds()));
1622
+ thread->samples.write_result(hash);
1623
+ thread->allocation_samples.write_result(hash);
1624
+
1625
+ rb_hash_aset(threads, thread->ruby_thread_id, hash);
1626
+ rb_hash_aset(hash, sym("tid"), ULL2NUM(thread->native_tid));
1627
+ rb_hash_aset(hash, sym("started_at"), ULL2NUM(thread->started_at.nanoseconds()));
1628
+ if (!thread->stopped_at.zero()) {
1629
+ rb_hash_aset(hash, sym("stopped_at"), ULL2NUM(thread->stopped_at.nanoseconds()));
1521
1630
  }
1522
- rb_hash_aset(hash, sym("name"), rb_str_new(thread.name.data(), thread.name.length()));
1631
+ rb_hash_aset(hash, sym("is_main"), thread->is_main() ? Qtrue : Qfalse);
1523
1632
 
1524
1633
  }
1525
1634
 
@@ -1615,12 +1724,20 @@ static VALUE collector_new(VALUE self, VALUE mode, VALUE options) {
1615
1724
  } else {
1616
1725
  interval = TimeStamp::from_microseconds(NUM2UINT(intervalv));
1617
1726
  }
1618
- collector = new TimeCollector(interval);
1727
+
1728
+ VALUE allocation_sample_ratev = rb_hash_aref(options, sym("allocation_sample_rate"));
1729
+ unsigned int allocation_sample_rate;
1730
+ if (NIL_P(allocation_sample_ratev)) {
1731
+ allocation_sample_rate = 0;
1732
+ } else {
1733
+ allocation_sample_rate = NUM2UINT(allocation_sample_ratev);
1734
+ }
1735
+ collector = new TimeCollector(interval, allocation_sample_rate);
1619
1736
  } else {
1620
1737
  rb_raise(rb_eArgError, "invalid mode");
1621
1738
  }
1622
1739
  VALUE obj = TypedData_Wrap_Struct(self, &rb_collector_type, collector);
1623
- rb_funcall(obj, rb_intern("initialize"), 1, mode);
1740
+ rb_funcall(obj, rb_intern("initialize"), 2, mode, options);
1624
1741
  return obj;
1625
1742
  }
1626
1743
 
@@ -17,10 +17,11 @@ module Vernier
17
17
 
18
18
  def self.start
19
19
  interval = options.fetch(:interval, 500).to_i
20
+ allocation_sample_rate = options.fetch(:allocation_sample_rate, 0).to_i
20
21
 
21
22
  STDERR.puts("starting profiler with interval #{interval}")
22
23
 
23
- @collector = Vernier::Collector.new(:wall, interval:)
24
+ @collector = Vernier::Collector.new(:wall, interval:, allocation_sample_rate:)
24
25
  @collector.start
25
26
  end
26
27
 
@@ -1,12 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "marker"
4
+ require_relative "thread_names"
4
5
 
5
6
  module Vernier
6
7
  class Collector
7
- def initialize(mode)
8
+ def initialize(mode, options={})
8
9
  @mode = mode
9
10
  @markers = []
11
+ @hooks = []
12
+
13
+ @thread_names = ThreadNames.new
14
+
15
+ if options[:hooks]
16
+ Array(options[:hooks]).each do |hook|
17
+ add_hook(hook)
18
+ end
19
+ end
20
+ @hooks.each do |hook|
21
+ hook.enable
22
+ end
23
+ end
24
+
25
+ private def add_hook(hook)
26
+ case hook
27
+ when :rails, :activesupport
28
+ @hooks << Vernier::Hooks::ActiveSupport.new(self)
29
+ else
30
+ warn "Unknown hook: #{hook}"
31
+ end
10
32
  end
11
33
 
12
34
  ##
@@ -47,6 +69,18 @@ module Vernier
47
69
  def stop
48
70
  result = finish
49
71
 
72
+ @thread_names.finish
73
+
74
+ @hooks.each do |hook|
75
+ hook.disable
76
+ end
77
+
78
+ result.threads.each do |obj_id, thread|
79
+ thread[:name] ||= @thread_names[obj_id]
80
+ end
81
+
82
+ result.hooks = @hooks
83
+
50
84
  end_time = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond)
51
85
  result.pid = Process.pid
52
86
  result.end_time = end_time
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vernier
4
+ module Hooks
5
+ class ActiveSupport
6
+ FIREFOX_MARKER_SCHEMA = Ractor.make_shareable([
7
+ {
8
+ name: "sql.active_record",
9
+ display: [ "marker-chart", "marker-table" ],
10
+ data: [
11
+ { key: "sql", format: "string" },
12
+ { key: "name", format: "string" },
13
+ { key: "type_casted_binds", label: "binds", format: "string"
14
+ }
15
+ ]
16
+ },
17
+ {
18
+ name: "instantiation.active_record",
19
+ display: [ "marker-chart", "marker-table" ],
20
+ data: [
21
+ { key: "record_count", format: "integer" },
22
+ { key: "class_name", format: "string" }
23
+ ]
24
+ },
25
+ {
26
+ name: "process_action.action_controller",
27
+ display: [ "marker-chart", "marker-table" ],
28
+ data: [
29
+ { key: "controller", format: "string" },
30
+ { key: "action", format: "string" },
31
+ { key: "status", format: "integer" },
32
+ { key: "path", format: "string" },
33
+ { key: "method", format: "string" }
34
+ ]
35
+ },
36
+ {
37
+ name: "cache_read.active_support",
38
+ display: [ "marker-chart", "marker-table" ],
39
+ data: [
40
+ { key: "key", format: "string" },
41
+ { key: "store", format: "string" },
42
+ { key: "hit", format: "string" },
43
+ { key: "super_operation", format: "string" }
44
+ ]
45
+ },
46
+ {
47
+ name: "cache_read_multi.active_support",
48
+ display: [ "marker-chart", "marker-table" ],
49
+ data: [
50
+ { key: "key", format: "string" },
51
+ { key: "store", format: "string" },
52
+ { key: "hit", format: "string" },
53
+ { key: "super_operation", format: "string" }
54
+ ]
55
+ },
56
+ {
57
+ name: "cache_fetch_hit.active_support",
58
+ display: [ "marker-chart", "marker-table" ],
59
+ data: [
60
+ { key: "key", format: "string" },
61
+ { key: "store", format: "string" }
62
+ ]
63
+ }
64
+ ])
65
+
66
+ SERIALIZED_KEYS = FIREFOX_MARKER_SCHEMA.map do |format|
67
+ [
68
+ format[:name],
69
+ format[:data].map { _1[:key].to_sym }.freeze
70
+ ]
71
+ end.to_h.freeze
72
+
73
+ def initialize(collector)
74
+ @collector = collector
75
+ end
76
+
77
+ def enable
78
+ require "active_support"
79
+ @subscription = ::ActiveSupport::Notifications.monotonic_subscribe(/\A[^!]/) do |name, start, finish, id, payload|
80
+ # Notifications.publish API may reach here without proper timing information included
81
+ unless Float === start && Float === finish
82
+ next
83
+ end
84
+
85
+ data = { type: name }
86
+ if keys = SERIALIZED_KEYS[name]
87
+ keys.each do |key|
88
+ data[key] = payload[key]
89
+ end
90
+ end
91
+ @collector.add_marker(
92
+ name: name,
93
+ start: (start * 1_000_000_000.0).to_i,
94
+ finish: (finish * 1_000_000_000.0).to_i,
95
+ data: data
96
+ )
97
+ end
98
+ end
99
+
100
+ def disable
101
+ ::ActiveSupport::Notifications.unsubscribe(@subscription)
102
+ @subscription = nil
103
+ end
104
+
105
+ def firefox_marker_schema
106
+ FIREFOX_MARKER_SCHEMA
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vernier
4
+ module Hooks
5
+ autoload :ActiveSupport, "vernier/hooks/active_support"
6
+ end
7
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "rbconfig"
4
5
 
5
6
  module Vernier
6
7
  module Output
@@ -133,7 +134,8 @@ module Vernier
133
134
  color: category.color,
134
135
  subcategories: []
135
136
  }
136
- end
137
+ end,
138
+ sourceCodeIsNotOnSearchfox: true
137
139
  },
138
140
  libs: [],
139
141
  threads: thread_data
@@ -141,9 +143,15 @@ module Vernier
141
143
  end
142
144
 
143
145
  def marker_schema
146
+ hook_additions = profile.hooks.flat_map do |hook|
147
+ if hook.respond_to?(:firefox_marker_schema)
148
+ hook.firefox_marker_schema
149
+ end
150
+ end.compact
151
+
144
152
  [
145
153
  {
146
- name: "GVL_THREAD_RESUMED",
154
+ name: "THREAD_RUNNING",
147
155
  display: [ "marker-chart", "marker-table" ],
148
156
  data: [
149
157
  {
@@ -151,19 +159,57 @@ module Vernier
151
159
  value: "The thread has acquired the GVL and is executing"
152
160
  }
153
161
  ]
154
- }
162
+ },
163
+ {
164
+ name: "THREAD_STALLED",
165
+ display: [ "marker-chart", "marker-table" ],
166
+ data: [
167
+ {
168
+ label: "Description",
169
+ value: "The thread is ready, but stalled waiting for the GVL to be available"
170
+ }
171
+ ]
172
+ },
173
+ {
174
+ name: "THREAD_SUSPENDED",
175
+ display: [ "marker-chart", "marker-table" ],
176
+ data: [
177
+ {
178
+ label: "Description",
179
+ value: "The thread has voluntarily released the GVL (ex. to sleep, for I/O, waiting on a lock)"
180
+ }
181
+ ]
182
+ },
183
+ {
184
+ name: "GC_PAUSE",
185
+ display: [ "marker-chart", "marker-table", "timeline-overview" ],
186
+ tooltipLabel: "{marker.name} - {marker.data.state}",
187
+ data: [
188
+ {
189
+ label: "Description",
190
+ value: "All threads are paused as GC is performed"
191
+ }
192
+ ]
193
+ },
194
+ *hook_additions
155
195
  ]
156
196
  end
157
197
 
158
198
  class Thread
159
199
  attr_reader :profile
160
200
 
161
- def initialize(ruby_thread_id, profile, categorizer, name:, tid:, samples:, weights:, timestamps: nil, sample_categories: nil, markers:, started_at:, stopped_at: nil)
201
+ def initialize(ruby_thread_id, profile, categorizer, name:, tid:, samples:, weights:, timestamps: nil, sample_categories: nil, markers:, started_at:, stopped_at: nil, allocations: nil, is_main: nil)
162
202
  @ruby_thread_id = ruby_thread_id
163
203
  @profile = profile
164
204
  @categorizer = categorizer
165
205
  @tid = tid
166
- @name = pretty_name(name)
206
+ @allocations = allocations
207
+ @name = name
208
+ @is_main = is_main
209
+ if is_main.nil?
210
+ @is_main = @ruby_thread_id == ::Thread.main.object_id
211
+ end
212
+ @is_main = true if profile.threads.size == 1
167
213
 
168
214
  timestamps ||= [0] * samples.size
169
215
  @samples, @weights, @timestamps = samples, weights, timestamps
@@ -184,7 +230,8 @@ module Vernier
184
230
  @func_names = names.map do |name|
185
231
  @strings[name]
186
232
  end
187
- @filenames = filenames.map do |filename|
233
+
234
+ @filenames = filter_filenames(filenames).map do |filename|
188
235
  @strings[filename]
189
236
  end
190
237
 
@@ -211,10 +258,31 @@ module Vernier
211
258
  end
212
259
  end
213
260
 
261
+ def filter_filenames(filenames)
262
+ pwd = "#{Dir.pwd}/"
263
+ gem_regex = %r{\A#{Regexp.union(Gem.path)}/gems/}
264
+ gem_match_regex = %r{\A#{Regexp.union(Gem.path)}/gems/([a-zA-Z](?:[a-zA-Z0-9\.\_]|-[a-zA-Z])*)-([0-9][0-9A-Za-z\-_\.]*)/(.*)\z}
265
+ rubylibdir = "#{RbConfig::CONFIG["rubylibdir"]}/"
266
+
267
+ filenames.map do |filename|
268
+ if filename.match?(gem_regex)
269
+ gem_match_regex =~ filename
270
+ "gem:#$1-#$2:#$3"
271
+ elsif filename.start_with?(pwd)
272
+ filename.delete_prefix(pwd)
273
+ elsif filename.start_with?(rubylibdir)
274
+ path = filename.delete_prefix(rubylibdir)
275
+ "rubylib:#{RUBY_VERSION}:#{path}"
276
+ else
277
+ filename
278
+ end
279
+ end
280
+ end
281
+
214
282
  def data
215
283
  {
216
284
  name: @name,
217
- isMainThread: @ruby_thread_id == ::Thread.main.object_id || (profile.threads.size == 1),
285
+ isMainThread: @is_main,
218
286
  processStartupTime: 0, # FIXME
219
287
  processShutdownTime: nil, # FIXME
220
288
  registerTime: (@started_at - 0) / 1_000_000.0,
@@ -226,6 +294,7 @@ module Vernier
226
294
  funcTable: func_table,
227
295
  nativeSymbols: {},
228
296
  samples: samples_table,
297
+ jsAllocations: allocations_table,
229
298
  stackTable: stack_table,
230
299
  resourceTable: {
231
300
  length: 0,
@@ -236,7 +305,7 @@ module Vernier
236
305
  },
237
306
  markers: markers_table,
238
307
  stringArray: string_table
239
- }
308
+ }.compact
240
309
  end
241
310
 
242
311
  def markers_table
@@ -277,6 +346,25 @@ module Vernier
277
346
  }
278
347
  end
279
348
 
349
+ def allocations_table
350
+ return nil if !@allocations
351
+ samples, weights, timestamps = @allocations.values_at(:samples, :weights, :timestamps)
352
+ return nil if samples.size == 0
353
+ size = samples.size
354
+ timestamps = timestamps.map { _1 / 1_000_000.0 }
355
+ ret = {
356
+ "time": timestamps,
357
+ "className": ["Object"]*size,
358
+ "typeName": ["JSObject"]*size,
359
+ "coarseType": ["Object"]*size,
360
+ "weight": weights,
361
+ "inNursery": [false] * size,
362
+ "stack": samples,
363
+ "length": size
364
+ }
365
+ ret
366
+ end
367
+
280
368
  def samples_table
281
369
  samples = @samples
282
370
  weights = @weights
@@ -366,14 +454,21 @@ module Vernier
366
454
 
367
455
  cfunc_idx = @strings["<cfunc>"]
368
456
  is_js = @filenames.map { |fn| fn != cfunc_idx }
457
+ line_numbers = profile.func_table.fetch(:first_line).map.with_index do |line, i|
458
+ if is_js[i] || line != 0
459
+ line
460
+ else
461
+ nil
462
+ end
463
+ end
369
464
  {
370
465
  name: @func_names,
371
466
  isJS: is_js,
372
467
  relevantForJS: is_js,
373
468
  resource: [-1] * size, # set to unidentified for now
374
469
  fileName: @filenames,
375
- lineNumber: profile.func_table.fetch(:first_line),
376
- columnNumber: [0] * size,
470
+ lineNumber: line_numbers,
471
+ columnNumber: [nil] * size,
377
472
  #columnNumber: functions.map { _1.column },
378
473
  length: size
379
474
  }
@@ -385,25 +480,6 @@ module Vernier
385
480
 
386
481
  private
387
482
 
388
- def pretty_name(name)
389
- if name.empty?
390
- begin
391
- tr = ObjectSpace._id2ref(@ruby_thread_id)
392
- name = tr.inspect if tr
393
- rescue RangeError
394
- # Thread was already GC'd
395
- end
396
- end
397
- return name unless name.start_with?("#<Thread")
398
- pretty = []
399
- obj_address = name[/Thread:(0x\w+)/,1]
400
- best_id = name[/\#<Thread:0x\w+@?\s?(.*)\s+\S+>/,1] || ""
401
- Gem.path.each { |gem_dir| best_id = best_id.gsub(gem_dir, "...") }
402
- pretty << best_id unless best_id.empty?
403
- pretty << "(#{obj_address})"
404
- pretty.join(' ')
405
- end
406
-
407
483
  def gc_category
408
484
  @categorizer.get_category("GC")
409
485
  end
@@ -3,10 +3,16 @@ module Vernier
3
3
  attr_reader :stack_table, :frame_table, :func_table
4
4
  attr_reader :markers
5
5
 
6
+ attr_accessor :hooks
7
+
6
8
  attr_accessor :pid, :end_time
7
9
  attr_accessor :threads
8
10
  attr_accessor :meta
9
11
 
12
+ def main_thread
13
+ threads.values.detect {|x| x[:is_main] }
14
+ end
15
+
10
16
  # TODO: remove these
11
17
  def weights; threads.values.flat_map { _1[:weights] }; end
12
18
  def samples; threads.values.flat_map { _1[:samples] }; end
@@ -0,0 +1,52 @@
1
+ module Vernier
2
+ # Collects names of all seen threads
3
+ class ThreadNames
4
+ def initialize
5
+ @names = {}
6
+ @tp = TracePoint.new(:thread_end) do |e|
7
+ collect_thread(e.self)
8
+ end
9
+ @tp.enable
10
+ end
11
+
12
+ def [](object_id)
13
+ @names[object_id] || "unknown thread #{object_id}"
14
+ end
15
+
16
+ def finish
17
+ collect_running
18
+ @tp.disable
19
+ end
20
+
21
+ private
22
+
23
+ def collect_running
24
+ Thread.list.each do |th|
25
+ collect_thread(th)
26
+ end
27
+ end
28
+
29
+ def collect_thread(th)
30
+ @names[th.object_id] = pretty_name(th)
31
+ end
32
+
33
+ def pretty_name(thread)
34
+ name = thread.name
35
+ return name if name && !name.empty?
36
+
37
+ if thread == Thread.main
38
+ return "main"
39
+ end
40
+
41
+ name = Thread.instance_method(:inspect).bind_call(thread)
42
+ pretty = []
43
+ best_id = name[/\#<Thread:0x\w+@?\s?(.*)\s+\S+>/, 1]
44
+ if best_id
45
+ Gem.path.each { |gem_dir| best_id.gsub!(gem_dir, "") }
46
+ pretty << best_id unless best_id.empty?
47
+ end
48
+ pretty << "(#{thread.object_id})"
49
+ pretty.join(' ')
50
+ end
51
+ end
52
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Vernier
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/vernier.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "vernier/version"
4
4
  require_relative "vernier/collector"
5
5
  require_relative "vernier/result"
6
+ require_relative "vernier/hooks"
6
7
  require_relative "vernier/vernier"
7
8
  require_relative "vernier/output/firefox"
8
9
  require_relative "vernier/output/top"
@@ -10,8 +11,8 @@ require_relative "vernier/output/top"
10
11
  module Vernier
11
12
  class Error < StandardError; end
12
13
 
13
- def self.trace(mode: :wall, out: nil, interval: nil)
14
- collector = Vernier::Collector.new(mode, { interval: })
14
+ def self.trace(mode: :wall, out: nil, **collector_options)
15
+ collector = Vernier::Collector.new(mode, collector_options)
15
16
  collector.start
16
17
 
17
18
  result = nil
data/vernier.gemspec CHANGED
@@ -8,8 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.authors = ["John Hawthorn"]
9
9
  spec.email = ["john@hawthorn.email"]
10
10
 
11
- spec.summary = "An experimental profiler"
12
- spec.description = spec.summary
11
+ spec.summary = "A next generation CRuby profiler"
12
+ spec.description = "Next-generation Ruby 3.2.1+ sampling profiler. Tracks multiple threads, GVL activity, GC pauses, idle time, and more."
13
13
  spec.homepage = "https://github.com/jhawthorn/vernier"
14
14
  spec.license = "MIT"
15
15
  spec.required_ruby_version = ">= 3.2.1"
@@ -29,4 +29,6 @@ Gem::Specification.new do |spec|
29
29
  spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
30
30
  spec.require_paths = ["lib"]
31
31
  spec.extensions = ["ext/vernier/extconf.rb"]
32
+
33
+ spec.add_development_dependency "activesupport"
32
34
  end
metadata CHANGED
@@ -1,16 +1,31 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: vernier
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - John Hawthorn
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2024-02-27 00:00:00.000000000 Z
12
- dependencies: []
13
- description: An experimental profiler
11
+ date: 2024-03-29 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: activesupport
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :development
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ description: Next-generation Ruby 3.2.1+ sampling profiler. Tracks multiple threads,
28
+ GVL activity, GC pauses, idle time, and more.
14
29
  email:
15
30
  - john@hawthorn.email
16
31
  executables:
@@ -39,10 +54,13 @@ files:
39
54
  - lib/vernier.rb
40
55
  - lib/vernier/autorun.rb
41
56
  - lib/vernier/collector.rb
57
+ - lib/vernier/hooks.rb
58
+ - lib/vernier/hooks/active_support.rb
42
59
  - lib/vernier/marker.rb
43
60
  - lib/vernier/output/firefox.rb
44
61
  - lib/vernier/output/top.rb
45
62
  - lib/vernier/result.rb
63
+ - lib/vernier/thread_names.rb
46
64
  - lib/vernier/version.rb
47
65
  - vernier.gemspec
48
66
  homepage: https://github.com/jhawthorn/vernier
@@ -70,5 +88,5 @@ requirements: []
70
88
  rubygems_version: 3.4.10
71
89
  signing_key:
72
90
  specification_version: 4
73
- summary: An experimental profiler
91
+ summary: A next generation CRuby profiler
74
92
  test_files: []