gvl-tracing 1.5.2 → 1.6.1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4f2845379f02ef9e8cd317a293b0c24c92dbd9aebad15ccb9b378ea1c41cf894
4
- data.tar.gz: ce639fb1bffb10262e1dee5f5bf7a80f4425d9db7b690a494b6cefedae507265
3
+ metadata.gz: 2bf28c6665bf4afa02c13299947bb65d7c62c3af783858afdc3a5383e334fa47
4
+ data.tar.gz: b1999e7e17d56e76ba732962708c14979e2690cd78204946d2f05e7146b05725
5
5
  SHA512:
6
- metadata.gz: '01659c02734923610f4c771afcd1a863de369781a7c21cf42c46e5d956f897c65643f0ae6a5a1eebb7fcb81895556271f57f7e164b3ac3581177509d31381db7'
7
- data.tar.gz: f0e0894e7d3dcc5c7f00485b8e741be31f832142ef0436524f26e5996da9f901dc17b46080b26f2ecf5bcfc0ca37b6bdaae48b014eaeb00518dfa56b6c892167
6
+ metadata.gz: feb5fb173e07343af18c929608b20b2463fc0b676565b926242585f3fed31991e34ab5f549d7cbfe0e26c883d4a1e6eebf1d8da9e1586f0fa63d0890cdd3b108
7
+ data.tar.gz: 615226e345e230b989d7738754f02a058935d67679b4ca16c610cbbb6fe34f82aff6de3299d9548af579a30d55bccaedf25cd52d4f631023cff93a683fe3c8bb
data/README.adoc CHANGED
@@ -25,7 +25,7 @@ def fib(n)
25
25
  fib(n - 1) + fib(n - 2)
26
26
  end
27
27
 
28
- GvlTracing.start("example1.json") do
28
+ GvlTracing.start("example1.json", os_threads_view_enabled: false) do
29
29
  Thread.new { sleep(0.05) while true }
30
30
 
31
31
  sleep(0.05)
@@ -68,6 +68,12 @@ This gem only provides a single module (`GvlTracing`) with methods:
68
68
 
69
69
  The resulting traces can be analyzed by going to https://ui.perfetto.dev/[Perfetto UI].
70
70
 
71
+ == Experimental features
72
+
73
+ 1. Sleep tracking: Add `require 'gvl_tracing/sleep_tracking'` to add a more specific `sleeping` state for sleeps (which are otherwise rendered as `waiting` without this feature)
74
+
75
+ 2. OS threads view: Pass in `os_threads_view_enabled: true` to `GvlTracing.start` to also render a view of Ruby thread activity from the OS native threads point-of-view. This is useful when using M:N thread scheduling, which is used on Ruby 3.3+ Ractors, and when using the `RUBY_MN_THREADS=1` setting.
76
+
71
77
  == Tips
72
78
 
73
79
  You can "embed" links to the perfetto UI which trigger loading of a trace by following the instructions on https://perfetto.dev/docs/visualization/deep-linking-to-perfetto-ui .
@@ -32,6 +32,7 @@
32
32
  #include <stdbool.h>
33
33
  #include <sys/types.h>
34
34
  #include <pthread.h>
35
+ #include <stdint.h>
35
36
 
36
37
  #include "extconf.h"
37
38
 
@@ -56,6 +57,10 @@
56
57
  #define RUBY_3_2
57
58
  #endif
58
59
 
60
+ // For the OS threads view, we emit data as if it was for another pid so it gets grouped separately in perfetto.
61
+ // This is a really big hack, but I couldn't think of a better way?
62
+ #define OS_THREADS_VIEW_PID (INT64_C(0))
63
+
59
64
  typedef struct {
60
65
  bool initialized;
61
66
  int32_t current_thread_serial;
@@ -78,20 +83,27 @@ static VALUE gc_tracepoint = Qnil;
78
83
  static int thread_storage_key = 0;
79
84
  static VALUE all_seen_threads = Qnil;
80
85
  static pthread_mutex_t all_seen_threads_mutex = PTHREAD_MUTEX_INITIALIZER;
86
+ static pthread_mutex_t output_mutex = PTHREAD_MUTEX_INITIALIZER;
87
+ static bool os_threads_view_enabled;
81
88
 
82
89
  static VALUE tracing_init_local_storage(VALUE, VALUE);
83
- static VALUE tracing_start(VALUE _self, VALUE output_path);
90
+ static VALUE tracing_start(UNUSED_ARG VALUE _self, VALUE output_path, VALUE os_threads_view_enabled_arg);
84
91
  static VALUE tracing_stop(VALUE _self);
85
92
  static double timestamp_microseconds(void);
86
- static void render_event(thread_local_state *, const char *event_name);
93
+ static double render_event(thread_local_state *, const char *event_name);
87
94
  static void on_thread_event(rb_event_flag_t event, const rb_internal_thread_event_data_t *_unused1, void *_unused2);
88
95
  static void on_gc_event(VALUE tpval, void *_unused1);
89
96
  static VALUE mark_sleeping(VALUE _self);
90
97
  static size_t thread_local_state_memsize(UNUSED_ARG const void *_unused);
91
98
  static void thread_local_state_mark(void *data);
99
+ static inline void output_mutex_lock(void);
100
+ static inline void output_mutex_unlock(void);
92
101
  static inline int32_t thread_id_for(thread_local_state *state);
93
102
  static VALUE ruby_thread_id_for(UNUSED_ARG VALUE _self, VALUE thread);
94
103
  static VALUE trim_all_seen_threads(UNUSED_ARG VALUE _self);
104
+ static void render_os_thread_event(thread_local_state *state, double now_microseconds);
105
+ static void finish_previous_os_thread_event(double now_microseconds);
106
+ static inline uint32_t current_native_thread_id(void);
95
107
 
96
108
  #pragma GCC diagnostic ignored "-Wunused-const-variable"
97
109
  static const rb_data_type_t thread_local_state_type = {
@@ -131,7 +143,7 @@ void Init_gvl_tracing_native_extension(void) {
131
143
  VALUE gvl_tracing_module = rb_define_module("GvlTracing");
132
144
 
133
145
  rb_define_singleton_method(gvl_tracing_module, "_init_local_storage", tracing_init_local_storage, 1);
134
- rb_define_singleton_method(gvl_tracing_module, "_start", tracing_start, 1);
146
+ rb_define_singleton_method(gvl_tracing_module, "_start", tracing_start, 2);
135
147
  rb_define_singleton_method(gvl_tracing_module, "_stop", tracing_stop, 0);
136
148
  rb_define_singleton_method(gvl_tracing_module, "mark_sleeping", mark_sleeping, 0);
137
149
  rb_define_singleton_method(gvl_tracing_module, "_thread_id_for", ruby_thread_id_for, 1);
@@ -143,24 +155,7 @@ static inline void initialize_thread_local_state(thread_local_state *state) {
143
155
  state->current_thread_serial = RUBY_ATOMIC_FETCH_ADD(thread_serial, 1);
144
156
 
145
157
  #ifdef RUBY_3_2
146
- uint32_t native_thread_id = 0;
147
-
148
- #ifdef HAVE_PTHREAD_THREADID_NP
149
- uint64_t full_native_thread_id;
150
- pthread_threadid_np(pthread_self(), &full_native_thread_id);
151
- // Note: `pthread_threadid_np` is declared as taking in a `uint64_t` but I don't think macOS uses such really
152
- // high thread ids, and anyway perfetto doesn't like full 64-bit ids for threads so let's go with a simplification
153
- // for now.
154
- native_thread_id = (uint32_t) full_native_thread_id;
155
- #elif HAVE_GETTID
156
- native_thread_id = gettid();
157
- #else
158
- // Note: We could use the current_thread_serial as a crappy fallback, but this would make getting thread names
159
- // not work very well
160
- #error No native thread id available?
161
- #endif
162
-
163
- state->native_thread_id = native_thread_id;
158
+ state->native_thread_id = current_native_thread_id();
164
159
  #endif
165
160
  }
166
161
 
@@ -174,8 +169,9 @@ static VALUE tracing_init_local_storage(UNUSED_ARG VALUE _self, VALUE threads) {
174
169
  return Qtrue;
175
170
  }
176
171
 
177
- static VALUE tracing_start(UNUSED_ARG VALUE _self, VALUE output_path) {
172
+ static VALUE tracing_start(UNUSED_ARG VALUE _self, VALUE output_path, VALUE os_threads_view_enabled_arg) {
178
173
  Check_Type(output_path, T_STRING);
174
+ if (os_threads_view_enabled_arg != Qtrue && os_threads_view_enabled_arg != Qfalse) rb_raise(rb_eArgError, "os_threads_view_enabled must be true/false");
179
175
 
180
176
  trim_all_seen_threads(Qnil);
181
177
 
@@ -183,13 +179,26 @@ static VALUE tracing_start(UNUSED_ARG VALUE _self, VALUE output_path) {
183
179
  output_file = fopen(StringValuePtr(output_path), "w");
184
180
  if (output_file == NULL) rb_syserr_fail(errno, "Failed to open GvlTracing output file");
185
181
 
186
- fprintf(output_file, "[\n");
187
-
188
182
  thread_local_state *state = GT_CURRENT_THREAD_LOCAL_STATE();
189
183
  started_tracing_at_microseconds = timestamp_microseconds();
190
184
  process_id = getpid();
185
+ os_threads_view_enabled = (os_threads_view_enabled_arg == Qtrue);
186
+
187
+ VALUE ruby_version = rb_const_get(rb_cObject, rb_intern("RUBY_VERSION"));
188
+ Check_Type(ruby_version, T_STRING);
189
+
190
+ fprintf(output_file, "[\n");
191
+ fprintf(output_file,
192
+ " {\"ph\": \"M\", \"pid\": %"PRId64", \"name\": \"process_name\", \"args\": {\"name\": \"Ruby threads view (%s)\"}},\n",
193
+ process_id, StringValuePtr(ruby_version)
194
+ );
195
+
196
+ double now_microseconds = render_event(state, "started_tracing");
191
197
 
192
- render_event(state, "started_tracing");
198
+ if (os_threads_view_enabled) {
199
+ fprintf(output_file, " {\"ph\": \"M\", \"pid\": %"PRId64", \"name\": \"process_name\", \"args\": {\"name\": \"OS threads view\"}},\n", OS_THREADS_VIEW_PID);
200
+ render_os_thread_event(state, now_microseconds);
201
+ }
193
202
 
194
203
  current_hook = rb_internal_thread_add_event_hook(
195
204
  on_thread_event,
@@ -218,7 +227,9 @@ static VALUE tracing_stop(UNUSED_ARG VALUE _self) {
218
227
  rb_tracepoint_disable(gc_tracepoint);
219
228
  gc_tracepoint = Qnil;
220
229
 
221
- render_event(state, "stopped_tracing");
230
+ double now_microseconds = render_event(state, "stopped_tracing");
231
+ if (os_threads_view_enabled) finish_previous_os_thread_event(now_microseconds);
232
+
222
233
  // closing the json syntax in the output file is handled in GvlTracing.stop code
223
234
 
224
235
  if (fclose(output_file) != 0) rb_syserr_fail(errno, "Failed to close GvlTracing output file");
@@ -240,7 +251,7 @@ static double timestamp_microseconds(void) {
240
251
 
241
252
  // Render output using trace event format for perfetto:
242
253
  // https://chromium.googlesource.com/catapult/+/refs/heads/main/docs/trace-event-format.md
243
- static void render_event(thread_local_state *state, const char *event_name) {
254
+ static double render_event(thread_local_state *state, const char *event_name) {
244
255
  // Event data
245
256
  double now_microseconds = timestamp_microseconds() - started_tracing_at_microseconds;
246
257
 
@@ -251,6 +262,7 @@ static void render_event(thread_local_state *state, const char *event_name) {
251
262
  // Important note: We've observed some rendering issues in perfetto if the tid or pid are numbers that are "too big",
252
263
  // see https://github.com/ivoanjo/gvl-tracing/pull/4#issuecomment-1196463364 for an example.
253
264
 
265
+ output_mutex_lock();
254
266
  fprintf(output_file,
255
267
  // Finish previous duration
256
268
  " {\"ph\": \"E\", \"pid\": %"PRId64", \"tid\": %d, \"ts\": %f},\n" \
@@ -261,6 +273,9 @@ static void render_event(thread_local_state *state, const char *event_name) {
261
273
  // Args for second line
262
274
  process_id, thread_id_for(state), now_microseconds, event_name
263
275
  );
276
+ output_mutex_unlock();
277
+
278
+ return now_microseconds;
264
279
  }
265
280
 
266
281
  static void on_thread_event(rb_event_flag_t event_id, const rb_internal_thread_event_data_t *event_data, UNUSED_ARG void *_unused2) {
@@ -298,7 +313,15 @@ static void on_thread_event(rb_event_flag_t event_id, const rb_internal_thread_e
298
313
  case RUBY_INTERNAL_THREAD_EVENT_STARTED: event_name = "started"; break;
299
314
  case RUBY_INTERNAL_THREAD_EVENT_EXITED: event_name = "died"; break;
300
315
  };
301
- render_event(state, event_name);
316
+ double now_microseconds = render_event(state, event_name);
317
+
318
+ if (os_threads_view_enabled) {
319
+ if (event_id == RUBY_INTERNAL_THREAD_EVENT_RESUMED) {
320
+ render_os_thread_event(state, now_microseconds);
321
+ } else if (event_id == RUBY_INTERNAL_THREAD_EVENT_SUSPENDED || event_id == RUBY_INTERNAL_THREAD_EVENT_EXITED) {
322
+ finish_previous_os_thread_event(now_microseconds);
323
+ }
324
+ }
302
325
  }
303
326
 
304
327
  static void on_gc_event(VALUE tpval, UNUSED_ARG void *_unused1) {
@@ -337,6 +360,18 @@ static inline void all_seen_threads_mutex_unlock(void) {
337
360
  if (error) rb_syserr_fail(error, "Failed to unlock GvlTracing mutex");
338
361
  }
339
362
 
363
+ static inline void output_mutex_lock(void) {
364
+ int error = pthread_mutex_lock(&output_mutex);
365
+ // Can't raise exceptions on error since it gets used from outside the GVL
366
+ if (error) fprintf(stderr, "Failed to lock the GvlTracing output_mutex");
367
+ }
368
+
369
+ static inline void output_mutex_unlock(void) {
370
+ int error = pthread_mutex_unlock(&output_mutex);
371
+ // Can't raise exceptions on error since it gets used from outside the GVL
372
+ if (error) fprintf(stderr, "Failed to unlock the GvlTracing output_mutex");
373
+ }
374
+
340
375
  #ifdef RUBY_3_3_PLUS
341
376
  static inline thread_local_state *GT_LOCAL_STATE(VALUE thread, bool allocate) {
342
377
  thread_local_state *state = rb_internal_thread_specific_get(thread, thread_storage_key);
@@ -414,3 +449,52 @@ static VALUE trim_all_seen_threads(UNUSED_ARG VALUE _self) {
414
449
  all_seen_threads_mutex_unlock();
415
450
  return Qtrue;
416
451
  }
452
+
453
+ // Creates an event that follows the current native thread. Note that this assumes that whatever event
454
+ // made us call `render_os_thread_event` is an event about the current (native) thread; if the event is not about the
455
+ // current thread, the results will be incorrect.
456
+ static void render_os_thread_event(thread_local_state *state, double now_microseconds) {
457
+ finish_previous_os_thread_event(now_microseconds);
458
+
459
+ // Hack: If we name threads as "Thread N", perfetto seems to color them all with the same color, which looks awful.
460
+ // I did not check the code, but in practice perfetto seems to be doing some kind of hashing based only on regular
461
+ // chars, so here we append a different letter to each thread to cause the color hashing to differ.
462
+ char color_suffix_hack = ('a' + (thread_id_for(state) % 26));
463
+
464
+ output_mutex_lock();
465
+ fprintf(output_file,
466
+ " {\"ph\": \"B\", \"pid\": %"PRId64", \"tid\": %u, \"ts\": %f, \"name\": \"Thread %d (%c)\"},\n",
467
+ OS_THREADS_VIEW_PID, current_native_thread_id(), now_microseconds, thread_id_for(state), color_suffix_hack
468
+ );
469
+ output_mutex_unlock();
470
+ }
471
+
472
+ static void finish_previous_os_thread_event(double now_microseconds) {
473
+ output_mutex_lock();
474
+ fprintf(output_file,
475
+ " {\"ph\": \"E\", \"pid\": %"PRId64", \"tid\": %u, \"ts\": %f},\n",
476
+ OS_THREADS_VIEW_PID, current_native_thread_id(), now_microseconds
477
+ );
478
+ output_mutex_unlock();
479
+ }
480
+
481
+ static inline uint32_t current_native_thread_id(void) {
482
+ uint32_t native_thread_id = 0;
483
+
484
+ #ifdef HAVE_PTHREAD_THREADID_NP
485
+ uint64_t full_native_thread_id;
486
+ pthread_threadid_np(pthread_self(), &full_native_thread_id);
487
+ // Note: `pthread_threadid_np` is declared as taking in a `uint64_t` but I don't think macOS uses such really
488
+ // high thread ids, and anyway perfetto doesn't like full 64-bit ids for threads so let's go with a simplification
489
+ // for now.
490
+ native_thread_id = (uint32_t) full_native_thread_id;
491
+ #elif HAVE_GETTID
492
+ native_thread_id = gettid();
493
+ #else
494
+ // Note: We could use a native thread-local crappy fallback, but I think the two above alternatives are available
495
+ // on all OSs that support the GVL tracing API.
496
+ #error No native thread id available?
497
+ #endif
498
+
499
+ return native_thread_id;
500
+ }
data/gvl-tracing.gemspec CHANGED
@@ -42,7 +42,8 @@ Gem::Specification.new do |spec|
42
42
  # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
43
43
  spec.files = Dir.chdir(__dir__) do
44
44
  `git ls-files -z`.split("\x0").reject do |f|
45
- (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features|examples)/|\.(?:git|travis|circleci)|appveyor)})
45
+ (f == __FILE__) || f.match(%r{\A(?:(?:bin|test|spec|features|examples)/|\.(?:git|travis|circleci)|appveyor)}) ||
46
+ [".editorconfig", ".ruby-version", ".standard.yml", "gems.rb", "Rakefile"].include?(f)
46
47
  end
47
48
  end
48
49
  spec.require_paths = ["lib", "ext"]
data/lib/gvl-tracing.rb CHANGED
@@ -34,8 +34,8 @@ module GvlTracing
34
34
  private :_start
35
35
  private :_stop
36
36
 
37
- def start(file)
38
- _start(file)
37
+ def start(file, os_threads_view_enabled: false)
38
+ _start(file, os_threads_view_enabled)
39
39
  _init_local_storage(Thread.list)
40
40
  @path = file
41
41
 
@@ -5,7 +5,7 @@
5
5
  module GvlTracing::SleepTracking
6
6
  def sleep(...)
7
7
  GvlTracing.mark_sleeping
8
- super(...)
8
+ super
9
9
  end
10
10
  end
11
11
 
@@ -26,5 +26,5 @@
26
26
  # frozen_string_literal: true
27
27
 
28
28
  module GvlTracing
29
- VERSION = "1.5.2"
29
+ VERSION = "1.6.1"
30
30
  end
data/preview.png CHANGED
Binary file
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: gvl-tracing
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.5.2
4
+ version: 1.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ivo Anjo
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2024-04-06 00:00:00.000000000 Z
11
+ date: 2024-12-08 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description:
14
14
  email:
@@ -18,16 +18,11 @@ extensions:
18
18
  - ext/gvl_tracing_native_extension/extconf.rb
19
19
  extra_rdoc_files: []
20
20
  files:
21
- - ".editorconfig"
22
- - ".ruby-version"
23
- - ".standard.yml"
24
21
  - CODE_OF_CONDUCT.adoc
25
22
  - LICENSE
26
23
  - README.adoc
27
- - Rakefile
28
24
  - ext/gvl_tracing_native_extension/extconf.rb
29
25
  - ext/gvl_tracing_native_extension/gvl_tracing.c
30
- - gems.rb
31
26
  - gvl-tracing.gemspec
32
27
  - lib/gvl-tracing.rb
33
28
  - lib/gvl_tracing/sleep_tracking.rb
@@ -53,7 +48,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
53
48
  - !ruby/object:Gem::Version
54
49
  version: '0'
55
50
  requirements: []
56
- rubygems_version: 3.5.3
51
+ rubygems_version: 3.4.1
57
52
  signing_key:
58
53
  specification_version: 4
59
54
  summary: Get a timeline view of Global VM Lock usage in your Ruby app
data/.editorconfig DELETED
@@ -1,22 +0,0 @@
1
- # EditorConfig is awesome: https://EditorConfig.org
2
-
3
- # top-most EditorConfig file
4
- root = true
5
-
6
- # Unix-style newlines with a newline ending every file
7
- [*]
8
- end_of_line = lf
9
- insert_final_newline = true
10
- trim_trailing_whitespace = true
11
-
12
- [*.h]
13
- indent_style = space
14
- indent_size = 2
15
-
16
- [*.c]
17
- indent_style = space
18
- indent_size = 2
19
-
20
- [*.yml]
21
- indent_style = space
22
- indent_size = 2
data/.ruby-version DELETED
@@ -1 +0,0 @@
1
- ruby-3.2.2
data/.standard.yml DELETED
@@ -1,9 +0,0 @@
1
- ruby_version: 3.2
2
-
3
- ignore:
4
- - 'examples/example1.rb':
5
- - Style/InfiniteLoop
6
- - 'examples/example5.rb':
7
- - Style/InfiniteLoop
8
- - 'lib/gvl_tracing/sleep_tracking.rb':
9
- - Style/MixinUsage
data/Rakefile DELETED
@@ -1,36 +0,0 @@
1
- # gvl-tracing: Ruby gem for getting a timelinew view of GVL usage
2
- # Copyright (c) 2022 Ivo Anjo <ivo@ivoanjo.me>
3
- #
4
- # This file is part of gvl-tracing.
5
- #
6
- # MIT License
7
- #
8
- # Permission is hereby granted, free of charge, to any person obtaining a copy
9
- # of this software and associated documentation files (the "Software"), to deal
10
- # in the Software without restriction, including without limitation the rights
11
- # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
12
- # copies of the Software, and to permit persons to whom the Software is
13
- # furnished to do so, subject to the following conditions:
14
- #
15
- # The above copyright notice and this permission notice shall be included in all
16
- # copies or substantial portions of the Software.
17
- #
18
- # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
- # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
- # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
- # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
- # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
- # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
- # SOFTWARE.
25
-
26
- # frozen_string_literal: true
27
-
28
- require "bundler/gem_tasks"
29
- require "standard/rake"
30
- require "rake/extensiontask"
31
- require "rspec/core/rake_task"
32
-
33
- Rake::ExtensionTask.new("gvl_tracing_native_extension")
34
- RSpec::Core::RakeTask.new(:spec)
35
-
36
- task default: [:compile, :"standard:fix", :spec]
data/gems.rb DELETED
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source "https://rubygems.org"
4
-
5
- gemspec
6
-
7
- gem "rake", "~> 13.0"
8
- gem "rake-compiler", "~> 1.2"
9
- gem "pry"
10
- gem "pry-byebug"
11
- gem "rspec"
12
- gem "standard", "~> 1.33"
13
- gem "concurrent-ruby"
14
- gem "benchmark-ips", "~> 2.13"