pf2 0.5.1 → 0.6.0

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: 29a7c92a2a313dec57af9a3427cd723224f64bf53cf74a488253741e1098c12d
4
- data.tar.gz: 4958d188fdddec9fb8143a1b864e9c40d989bbb3360745eb0846687cefc97b9d
3
+ metadata.gz: 1aa1dd1d3caa1dd9327382e7de0a44b2c0ff4555a9dff8fdc7244f61c3a13405
4
+ data.tar.gz: c50895ea4d8285cc9bb223d575cbec7042aacd119a1390b836b818232ef7e214
5
5
  SHA512:
6
- metadata.gz: 2a2a7ecd0a4cb0d84f022c3ec1e0844e2c537cfad88f91b0051609006d8c2f4d596f15a1fd259709c97c1ffccbae87e96758a4bcfb70293d88fb44d7888f4071
7
- data.tar.gz: 28b108cd63c75d7ead6f34fdd88fdaa867a9601a0217899b024176a4e5508ef9dec7395bf7ae77513de6eb4cc765be90e010146bdd114b989b33359e4ac55068
6
+ metadata.gz: 1b5db496a58016e11203334b71e80e7a4c0d7d0dce6b2cb288884da4aecd8820d8915fd0b9f4ebd546802c314d621f69da23c474d254bff17ec3931cb8f7604b
7
+ data.tar.gz: 5e3a8fcf25be2dbf28cf8c36e03839cbeed13aa8d944e43991672a39f06fe16f6eae07aab5cd282c3237171bfff94b39a8abb176153a091452c3177266eb1364
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.6.0] - 2024-07-15
4
+
5
+ ### Changed
6
+
7
+ - The default sampling interval is now 9 ms (was originally 49 ms).
8
+ - It is intentional that the default is not 10 (or 50) ms - this is to avoid lockstep sampling.
9
+ - BREAKING: `Pf2::Reporter` has been moved to `Pf2::Reporter::FirefoxProfiler`.
10
+ - This is to make space for other planned reporters.
11
+
12
+
13
+ ## [0.5.2] - 2024-07-13
14
+
15
+ ### Fixed
16
+
17
+ - Properly default to TimerThread scheduler on non-Linux environments.
18
+
3
19
 
4
20
  ## [0.5.1] - 2024-03-25
5
21
 
data/README.md CHANGED
@@ -58,7 +58,7 @@ File.write("my_program.pf2profile", profile)
58
58
  Profiles can be visualized using the [Firefox Profiler](https://profiler.firefox.com/).
59
59
 
60
60
  ```console
61
- $ pf2 -o report.json my_program.pf2profile
61
+ $ pf2 report -o report.json my_program.pf2profile
62
62
  ```
63
63
 
64
64
  ### Configuration
@@ -67,7 +67,7 @@ Pf2 accepts the following configuration keys:
67
67
 
68
68
  ```rb
69
69
  Pf2.start(
70
- interval_ms: 49, # Integer: The sampling interval in milliseconds (default: 49)
70
+ interval_ms: 9, # Integer: The sampling interval in milliseconds (default: 9)
71
71
  time_mode: :cpu, # `:cpu` or `:wall`: The sampling timer's mode
72
72
  # (default: `:cpu` for SignalScheduler, `:wall` for TimerThreadScheduler)
73
73
  threads: [th1, th2], # `Array<Thread>` | `:all`: A list of Ruby Threads to be tracked.
@@ -108,9 +108,9 @@ Schedulers determine when to execute sample collection, based on configuration (
108
108
 
109
109
  #### SignalScheduler (Linux-only)
110
110
 
111
- The first is the `SignalScheduler`, based on POSIX timers. Pf2 will use this scheduler when possible. SignalScheduler creates a POSIX timer for each Ruby Thread (the underlying pthread to be more accurate) using `timer_create(3)`. This leaves the actual time-keeping to the OS, which is capable of tracking accurate per-thread CPU time usage.
111
+ The first is the `SignalScheduler`, based on POSIX timers. Pf2 will use this scheduler when possible. SignalScheduler creates a POSIX timer for each Ruby Thread (the underlying pthread to be more accurate) using `timer_create(2)`. This leaves the actual time-keeping to the OS, which is capable of tracking accurate per-thread CPU time usage.
112
112
 
113
- When the specified interval has arrived (the timer has _expired_), the OS delivers us a SIGALRM (note: Unlike `setitimer(2)`, `timer_create(3)` allows us to choose which signal to be delivered, and Pf2 uses SIGALRM regardless of time mode). This is why the scheduler is named SignalScheduler.
113
+ When the specified interval has arrived (the timer has _expired_), the OS delivers us a SIGALRM (note: Unlike `setitimer(2)`, `timer_create(2)` allows us to choose which signal to be delivered, and Pf2 uses SIGALRM regardless of time mode). This is why the scheduler is named SignalScheduler.
114
114
 
115
115
  Signals are directed to Ruby Threads' underlying pthread, effectively "pausing" the Thread's activity. This routing is done using `SIGEV_THREAD_ID`, which is a Linux-only feature. Sample collection is done in the signal handler, which is expected to be more _accurate_, capturing the paused Thread's activity.
116
116
 
data/ext/pf2/src/lib.rs CHANGED
@@ -10,6 +10,7 @@ mod profile_serializer;
10
10
  mod ringbuffer;
11
11
  mod sample;
12
12
  mod scheduler;
13
+ mod serialization;
13
14
  mod session;
14
15
  #[cfg(target_os = "linux")]
15
16
  mod signal_scheduler;
@@ -1,4 +1,4 @@
1
- use std::time::Instant;
1
+ use std::time::{Instant, SystemTime};
2
2
  use std::{collections::HashSet, ptr::null_mut};
3
3
 
4
4
  use rb_sys::*;
@@ -15,7 +15,9 @@ const DEFAULT_RINGBUFFER_CAPACITY: usize = 320;
15
15
 
16
16
  #[derive(Debug)]
17
17
  pub struct Profile {
18
- pub start_timestamp: Instant,
18
+ pub start_timestamp: SystemTime,
19
+ pub start_instant: Instant,
20
+ pub end_instant: Option<Instant>,
19
21
  pub samples: Vec<Sample>,
20
22
  pub temporary_sample_buffer: Ringbuffer,
21
23
  pub backtrace_state: BacktraceState,
@@ -35,7 +37,9 @@ impl Profile {
35
37
  };
36
38
 
37
39
  Self {
38
- start_timestamp: Instant::now(),
40
+ start_timestamp: SystemTime::now(),
41
+ start_instant: Instant::now(),
42
+ end_instant: None,
39
43
  samples: vec![],
40
44
  temporary_sample_buffer: Ringbuffer::new(DEFAULT_RINGBUFFER_CAPACITY),
41
45
  backtrace_state,
@@ -221,7 +221,7 @@ impl ProfileSerializer {
221
221
 
222
222
  if merged_stack.is_empty() {
223
223
  // This is the leaf node, record a Sample
224
- let elapsed_ns = (sample.timestamp - profile.start_timestamp).as_nanos();
224
+ let elapsed_ns = (sample.timestamp - profile.start_instant).as_nanos();
225
225
  thread_serializer.samples.push(ProfileSample {
226
226
  elapsed_ns,
227
227
  stack_tree_id: stack_tree.node_id,
@@ -0,0 +1,46 @@
1
+ #[derive(Clone, Deserialize, Serialize)]
2
+ pub struct Profile {
3
+ pub samples: Vec<Sample>,
4
+ pub locations: Vec<Location>,
5
+ pub functions: Vec<Function>,
6
+ pub start_timestamp_ns: u128,
7
+ pub duration_ns: u128,
8
+ }
9
+
10
+ pub type LocationIndex = usize;
11
+ pub type FunctionIndex = usize;
12
+
13
+ /// Sample
14
+ #[derive(Clone, Serialize, Deserialize)]
15
+ pub struct Sample {
16
+ /// The stack leading to this sample.
17
+ /// The leaf node will be stored at `stack[0]`.
18
+ pub stack: Vec<LocationIndex>,
19
+ pub ruby_thread_id: Option<u64>,
20
+ }
21
+
22
+ /// Location represents a location (line) in the source code when a sample was captured.
23
+ #[derive(Clone, PartialEq, Serialize, Deserialize)]
24
+ pub struct Location {
25
+ pub function_index: FunctionIndex,
26
+ pub lineno: i32,
27
+ pub address: Option<usize>,
28
+ }
29
+
30
+ /// Function represents a Ruby method or a C function in the profile.
31
+ #[derive(Clone, PartialEq, Serialize, Deserialize)]
32
+ pub struct Function {
33
+ pub implementation: FunctionImplementation,
34
+ pub name: Option<String>, // unique key
35
+ pub filename: Option<String>,
36
+ /// The first line number in the method/function definition.
37
+ /// For the actual location (line) which was hit during sample capture, refer to `Location.lineno`.
38
+ pub start_lineno: Option<i32>,
39
+ pub start_address: Option<usize>,
40
+ }
41
+
42
+ #[derive(Clone, PartialEq, Serialize, Deserialize)]
43
+ pub enum FunctionImplementation {
44
+ Ruby,
45
+ C,
46
+ }
@@ -0,0 +1,146 @@
1
+ use std::ffi::CStr;
2
+
3
+ use rb_sys::*;
4
+
5
+ use super::profile::{Function, FunctionImplementation, Location, LocationIndex, Profile, Sample};
6
+ use crate::util::RTEST;
7
+
8
+ pub struct ProfileSerializer2 {
9
+ profile: Profile,
10
+ }
11
+
12
+ impl ProfileSerializer2 {
13
+ pub fn new() -> ProfileSerializer2 {
14
+ ProfileSerializer2 {
15
+ profile: Profile {
16
+ start_timestamp_ns: 0,
17
+ duration_ns: 0,
18
+ samples: vec![],
19
+ locations: vec![],
20
+ functions: vec![],
21
+ },
22
+ }
23
+ }
24
+
25
+ pub fn serialize(&mut self, source: &crate::profile::Profile) -> String {
26
+ // Fill in meta fields
27
+ self.profile.start_timestamp_ns = source
28
+ .start_timestamp
29
+ .duration_since(std::time::UNIX_EPOCH)
30
+ .unwrap()
31
+ .as_nanos();
32
+ self.profile.duration_ns = source
33
+ .end_instant
34
+ .unwrap()
35
+ .duration_since(source.start_instant)
36
+ .as_nanos();
37
+
38
+ // Create a Sample for each sample collected
39
+ for sample in source.samples.iter() {
40
+ let mut stack: Vec<LocationIndex> = vec![];
41
+
42
+ // Iterate over the Ruby stack
43
+ let ruby_stack_depth = sample.line_count;
44
+ for i in 0..ruby_stack_depth {
45
+ let frame: VALUE = sample.frames[i as usize];
46
+ let lineno: i32 = sample.linenos[i as usize];
47
+
48
+ // Get the Location corresponding to the frame.
49
+ let location_index = self.process_ruby_frame(frame, lineno);
50
+
51
+ stack.push(location_index);
52
+ }
53
+
54
+ self.profile.samples.push(Sample {
55
+ stack,
56
+ ruby_thread_id: Some(sample.ruby_thread),
57
+ });
58
+ }
59
+
60
+ serde_json::to_string(&self.profile).unwrap()
61
+ }
62
+
63
+ /// Process a collected Ruby frame.
64
+ /// Calling this method will modify `self.profile` in place.
65
+ ///
66
+ /// Returns the index of the location in `locations`.
67
+ fn process_ruby_frame(&mut self, frame: VALUE, lineno: i32) -> LocationIndex {
68
+ // Build a Function corresponding to the frame, and get the index in `functions`
69
+ let function = Self::extract_function_from_frame(frame);
70
+ let function_index = match self
71
+ .profile
72
+ .functions
73
+ .iter_mut()
74
+ .position(|f| *f == function)
75
+ {
76
+ Some(index) => index,
77
+ None => {
78
+ self.profile.functions.push(function);
79
+ self.profile.functions.len() - 1
80
+ }
81
+ };
82
+
83
+ // Build a Location based on (1) the Function and (2) the actual line hit during sampling.
84
+ let location = Location {
85
+ function_index,
86
+ lineno,
87
+ address: None,
88
+ };
89
+ // Get the index of the location in `locations`
90
+ match self
91
+ .profile
92
+ .locations
93
+ .iter_mut()
94
+ .position(|l| *l == location)
95
+ {
96
+ Some(index) => index,
97
+ None => {
98
+ self.profile.locations.push(location);
99
+ self.profile.locations.len() - 1
100
+ }
101
+ }
102
+ }
103
+
104
+ fn extract_function_from_frame(frame: VALUE) -> Function {
105
+ unsafe {
106
+ let mut frame_full_label: VALUE = rb_profile_frame_full_label(frame);
107
+ let frame_full_label: Option<String> = if RTEST(frame_full_label) {
108
+ Some(
109
+ CStr::from_ptr(rb_string_value_cstr(&mut frame_full_label))
110
+ .to_str()
111
+ .unwrap()
112
+ .to_owned(),
113
+ )
114
+ } else {
115
+ None
116
+ };
117
+
118
+ let mut frame_path: VALUE = rb_profile_frame_path(frame);
119
+ let frame_path: Option<String> = if RTEST(frame_path) {
120
+ Some(
121
+ CStr::from_ptr(rb_string_value_cstr(&mut frame_path))
122
+ .to_str()
123
+ .unwrap()
124
+ .to_owned(),
125
+ )
126
+ } else {
127
+ None
128
+ };
129
+
130
+ let frame_first_lineno: VALUE = rb_profile_frame_first_lineno(frame);
131
+ let frame_first_lineno: Option<i32> = if RTEST(frame_first_lineno) {
132
+ Some(rb_num2int(frame_first_lineno).try_into().unwrap())
133
+ } else {
134
+ None
135
+ };
136
+
137
+ Function {
138
+ implementation: FunctionImplementation::Ruby,
139
+ name: frame_full_label,
140
+ filename: frame_path,
141
+ start_lineno: frame_first_lineno,
142
+ start_address: None,
143
+ }
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,2 @@
1
+ pub mod profile;
2
+ pub mod serializer;
@@ -6,9 +6,16 @@ use rb_sys::*;
6
6
 
7
7
  use crate::util::cstr;
8
8
 
9
+ #[cfg(target_os = "linux")]
9
10
  pub const DEFAULT_SCHEDULER: Scheduler = Scheduler::Signal;
10
- pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(49);
11
+ #[cfg(target_os = "linux")]
11
12
  pub const DEFAULT_TIME_MODE: TimeMode = TimeMode::CpuTime;
13
+ #[cfg(not(target_os = "linux"))]
14
+ pub const DEFAULT_SCHEDULER: Scheduler = Scheduler::TimerThread;
15
+ #[cfg(not(target_os = "linux"))]
16
+ pub const DEFAULT_TIME_MODE: TimeMode = TimeMode::WallTime;
17
+
18
+ pub const DEFAULT_INTERVAL: Duration = Duration::from_millis(9);
12
19
 
13
20
  #[derive(Clone, Debug)]
14
21
  pub struct Configuration {
@@ -16,6 +23,7 @@ pub struct Configuration {
16
23
  pub interval: Duration,
17
24
  pub time_mode: TimeMode,
18
25
  pub target_ruby_threads: Threads,
26
+ pub use_experimental_serializer: bool,
19
27
  }
20
28
 
21
29
  #[derive(Clone, Debug, PartialEq)]
@@ -1,7 +1,7 @@
1
1
  use std::ffi::{c_int, c_void};
2
2
  use std::mem;
3
3
  use std::mem::ManuallyDrop;
4
- use std::ptr::null_mut;
4
+ use std::ptr::{addr_of, null_mut};
5
5
 
6
6
  use rb_sys::*;
7
7
 
@@ -43,7 +43,7 @@ impl SessionRubyObject {
43
43
  // Extract the SessionRubyObject struct from a Ruby object
44
44
  unsafe fn get_struct_from(obj: VALUE) -> ManuallyDrop<Box<Self>> {
45
45
  unsafe {
46
- let ptr = rb_check_typeddata(obj, &RBDATA);
46
+ let ptr = rb_check_typeddata(obj, addr_of!(RBDATA));
47
47
  ManuallyDrop::new(Box::from_raw(ptr as *mut SessionRubyObject))
48
48
  }
49
49
  }
@@ -55,7 +55,11 @@ impl SessionRubyObject {
55
55
  let rb_mPf2: VALUE = rb_define_module(cstr!("Pf2"));
56
56
  let rb_cSession = rb_define_class_under(rb_mPf2, cstr!("Session"), rb_cObject);
57
57
  // Wrap the struct into a Ruby object
58
- rb_data_typed_object_wrap(rb_cSession, Box::into_raw(obj) as *mut c_void, &RBDATA)
58
+ rb_data_typed_object_wrap(
59
+ rb_cSession,
60
+ Box::into_raw(obj) as *mut c_void,
61
+ addr_of!(RBDATA),
62
+ )
59
63
  }
60
64
 
61
65
  unsafe extern "C" fn dmark(ptr: *mut c_void) {
@@ -38,7 +38,7 @@ impl Session {
38
38
  unsafe {
39
39
  rb_scan_args(argc, argv, cstr!(":"), &kwargs);
40
40
  };
41
- let mut kwargs_values: [VALUE; 4] = [Qnil.into(); 4];
41
+ let mut kwargs_values: [VALUE; 5] = [Qnil.into(); 5];
42
42
  unsafe {
43
43
  rb_get_kwargs(
44
44
  kwargs,
@@ -47,10 +47,11 @@ impl Session {
47
47
  rb_intern(cstr!("threads")),
48
48
  rb_intern(cstr!("time_mode")),
49
49
  rb_intern(cstr!("scheduler")),
50
+ rb_intern(cstr!("use_experimental_serializer")),
50
51
  ]
51
52
  .as_mut_ptr(),
52
53
  0,
53
- 4,
54
+ 5,
54
55
  kwargs_values.as_mut_ptr(),
55
56
  );
56
57
  };
@@ -59,12 +60,15 @@ impl Session {
59
60
  let threads = Self::parse_option_threads(kwargs_values[1]);
60
61
  let time_mode = Self::parse_option_time_mode(kwargs_values[2]);
61
62
  let scheduler = Self::parse_option_scheduler(kwargs_values[3]);
63
+ let use_experimental_serializer =
64
+ Self::parse_option_use_experimental_serializer(kwargs_values[4]);
62
65
 
63
66
  let configuration = Configuration {
64
67
  scheduler,
65
68
  interval,
66
69
  target_ruby_threads: threads.clone(),
67
70
  time_mode,
71
+ use_experimental_serializer,
68
72
  };
69
73
 
70
74
  match configuration.validate() {
@@ -93,12 +97,17 @@ impl Session {
93
97
  )),
94
98
  };
95
99
 
100
+ let running = Arc::new(AtomicBool::new(false));
101
+
96
102
  let new_thread_watcher = match threads {
97
103
  configuration::Threads::All => {
98
104
  let scheduler = Arc::clone(&scheduler);
105
+ let running = Arc::clone(&running);
99
106
  Some(NewThreadWatcher::watch(move |thread: VALUE| {
100
- log::debug!("New Ruby thread detected: {:?}", thread);
101
- scheduler.on_new_thread(thread);
107
+ if running.load(Ordering::Relaxed) {
108
+ log::debug!("New Ruby thread detected: {:?}", thread);
109
+ scheduler.on_new_thread(thread);
110
+ }
102
111
  }))
103
112
  }
104
113
  configuration::Threads::Targeted(_) => None,
@@ -108,7 +117,7 @@ impl Session {
108
117
  configuration,
109
118
  scheduler,
110
119
  profile,
111
- running: Arc::new(AtomicBool::new(false)),
120
+ running,
112
121
  new_thread_watcher,
113
122
  }
114
123
  }
@@ -122,10 +131,10 @@ impl Session {
122
131
  let interval_ms = unsafe { rb_num2long(value) };
123
132
  Duration::from_millis(interval_ms.try_into().unwrap_or_else(|_| {
124
133
  eprintln!(
125
- "[Pf2] Warning: Specified interval ({}) is not valid. Using default value (49ms).",
134
+ "[Pf2] Warning: Specified interval ({}) is not valid. Using default value (9ms).",
126
135
  interval_ms
127
136
  );
128
- 49
137
+ 9
129
138
  }))
130
139
  }
131
140
 
@@ -202,6 +211,13 @@ impl Session {
202
211
  scheduler
203
212
  }
204
213
 
214
+ fn parse_option_use_experimental_serializer(value: VALUE) -> bool {
215
+ if value == Qundef as VALUE {
216
+ return false;
217
+ }
218
+ RTEST(value)
219
+ }
220
+
205
221
  pub fn start(&mut self) -> VALUE {
206
222
  self.running.store(true, Ordering::Relaxed);
207
223
  self.start_profile_buffer_flusher_thread();
@@ -5,6 +5,7 @@ use crate::profile_serializer::ProfileSerializer;
5
5
  use crate::ruby_internal_apis::rb_thread_getcpuclockid;
6
6
  use crate::sample::Sample;
7
7
  use crate::scheduler::Scheduler;
8
+ use crate::serialization::serializer::ProfileSerializer2;
8
9
  use crate::session::configuration::{self, Configuration};
9
10
 
10
11
  use core::panic;
@@ -46,6 +47,7 @@ impl Scheduler for SignalScheduler {
46
47
  match self.profile.try_write() {
47
48
  Ok(mut profile) => {
48
49
  profile.flush_temporary_sample_buffer();
50
+ profile.end_instant = Some(std::time::Instant::now());
49
51
  }
50
52
  Err(_) => {
51
53
  println!("[pf2 ERROR] stop: Failed to acquire profile lock.");
@@ -56,7 +58,11 @@ impl Scheduler for SignalScheduler {
56
58
  let profile = self.profile.try_read().unwrap();
57
59
  log::debug!("Number of samples: {}", profile.samples.len());
58
60
 
59
- let serialized = ProfileSerializer::serialize(&profile);
61
+ let serialized = if self.configuration.use_experimental_serializer {
62
+ ProfileSerializer2::new().serialize(&profile)
63
+ } else {
64
+ ProfileSerializer::serialize(&profile)
65
+ };
60
66
  let serialized = CString::new(serialized).unwrap();
61
67
  unsafe { rb_str_new_cstr(serialized.as_ptr()) }
62
68
  }
@@ -103,6 +109,7 @@ impl SignalScheduler {
103
109
  if err != 0 {
104
110
  panic!("sigaction failed: {}", err);
105
111
  }
112
+ log::debug!("Signal handler installed");
106
113
  }
107
114
 
108
115
  // Respond to the signal and collect a sample.
@@ -12,6 +12,7 @@ use crate::profile::Profile;
12
12
  use crate::profile_serializer::ProfileSerializer;
13
13
  use crate::sample::Sample;
14
14
  use crate::scheduler::Scheduler;
15
+ use crate::serialization::serializer::ProfileSerializer2;
15
16
  use crate::session::configuration::{self, Configuration};
16
17
  use crate::util::*;
17
18
 
@@ -61,6 +62,7 @@ impl Scheduler for TimerThreadScheduler {
61
62
  match self.profile.try_write() {
62
63
  Ok(mut profile) => {
63
64
  profile.flush_temporary_sample_buffer();
65
+ profile.end_instant = Some(std::time::Instant::now());
64
66
  }
65
67
  Err(_) => {
66
68
  println!("[pf2 ERROR] stop: Failed to acquire profile lock.");
@@ -71,7 +73,11 @@ impl Scheduler for TimerThreadScheduler {
71
73
  let profile = self.profile.try_read().unwrap();
72
74
  log::debug!("Number of samples: {}", profile.samples.len());
73
75
 
74
- let serialized = ProfileSerializer::serialize(&profile);
76
+ let serialized = if self.configuration.use_experimental_serializer {
77
+ ProfileSerializer2::new().serialize(&profile)
78
+ } else {
79
+ ProfileSerializer::serialize(&profile)
80
+ };
75
81
  let serialized = CString::new(serialized).unwrap();
76
82
  unsafe { rb_str_new_cstr(serialized.as_ptr()) }
77
83
  }
data/lib/pf2/cli.rb CHANGED
@@ -55,7 +55,7 @@ module Pf2
55
55
  option_parser.parse!(argv)
56
56
 
57
57
  profile = JSON.parse(File.read(argv[0]), symbolize_names: true, max_nesting: false)
58
- report = JSON.generate(Pf2::Reporter.new(profile).emit)
58
+ report = JSON.generate(Pf2::Reporter::FirefoxProfiler.new(profile).emit)
59
59
 
60
60
  if options[:output_file]
61
61
  File.write(options[:output_file], report)