diff --git a/README.md b/README.md index 50c856a3..ea0f2693 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,16 @@ On Linux, samply uses perf events. You can grant temporary access by running: echo '-1' | sudo tee /proc/sys/kernel/perf_event_paranoid ``` +On Linux, use `--unwinding-mode fp` to record kernel frame-pointer callchains without copying user stack bytes or registers: + +```sh +samply record --unwinding-mode fp ./my-application my-arguments +``` + +The default, `--unwinding-mode dwarf`, keeps stack/register capture and framehop unwinding with the kernel frame-pointer callchain as a fallback. + +Frame-pointer recording requires the application and its libraries to preserve frame pointers. For Rust, build with `RUSTFLAGS="-C force-frame-pointers=yes"`; for C/C++, use `-fno-omit-frame-pointer -mno-omit-leaf-frame-pointer`. Missing frame pointers can truncate the stack. Kernel callchain support depends on the target architecture and kernel, and callchain depth is bounded by `/proc/sys/kernel/perf_event_max_stack`. + On Windows, you can use `samply record -a` to record all processes. You'll usually also want to use some symbol servers, most importantly the Microsoft Symbol Server so that you can see symbols for Windows libraries. Here's a command which supports symbols for Windows, Firefox and Chrome: ``` diff --git a/samply/src/cli.rs b/samply/src/cli.rs index 456e3039..9b892c3c 100644 --- a/samply/src/cli.rs +++ b/samply/src/cli.rs @@ -10,7 +10,7 @@ use super::server::{PortSelection, ServerProps}; use super::shared::included_processes::IncludedProcesses; use super::shared::prop_types::{ CoreClrProfileProps, ImportProps, ProcessLaunchProps, ProfileCreationProps, RecordingMode, - RecordingProps, SymbolProps, + RecordingProps, SymbolProps, UnwindingMode, DEFAULT_UNWINDING_STACK_SIZE, }; #[derive(Debug, Parser)] @@ -133,6 +133,17 @@ pub struct RecordArgs { #[arg(short, long, default_value = "1000")] pub rate: f64, + /// Stack unwinding method (Linux only). + #[cfg(target_os = "linux")] + #[arg(long, value_enum, default_value = "dwarf")] + pub unwinding_mode: UnwindingMode, + + /// Bytes of user stack copied with each sample for DWARF unwinding (Linux only). + /// Must be a multiple of 8 and below 65536. Ignored with `--unwinding-mode fp`. + #[cfg(target_os = "linux")] + #[arg(long, default_value_t = DEFAULT_UNWINDING_STACK_SIZE, value_parser = parse_unwinding_stack_size)] + pub unwinding_stack_size: u32, + /// Limit the recorded time to the specified number of seconds #[arg(short, long)] pub duration: Option, @@ -456,6 +467,14 @@ impl RecordArgs { output_file: self.output.clone(), time_limit, interval, + #[cfg(target_os = "linux")] + unwinding_mode: self.unwinding_mode, + #[cfg(not(target_os = "linux"))] + unwinding_mode: UnwindingMode::Dwarf, + #[cfg(target_os = "linux")] + unwinding_stack_size: self.unwinding_stack_size, + #[cfg(not(target_os = "linux"))] + unwinding_stack_size: DEFAULT_UNWINDING_STACK_SIZE, perf_events, gfx: self.gfx, browsers: self.browsers, @@ -614,6 +633,17 @@ impl SymbolArgs { } } +/// The kernel rejects `sample_stack_user` values that are not 8-byte aligned +/// or do not fit in a `u16`. +#[cfg(target_os = "linux")] +fn parse_unwinding_stack_size(s: &str) -> Result { + let size: u32 = s.parse().map_err(|e| format!("{e}"))?; + if size == 0 || size % 8 != 0 || size > u32::from(u16::MAX) { + return Err("must be a non-zero multiple of 8 below 65536".to_string()); + } + Ok(size) +} + #[cfg(test)] mod test { use super::*; @@ -648,4 +678,81 @@ mod test { let opt_res = Opt::try_parse_from(["samply", "record", "-p", "1234", "rustup"]); assert!(opt_res.is_err()); } + + #[cfg(target_os = "linux")] + #[test] + fn record_fp_unwinding_with_launched_command() { + let opt = Opt::try_parse_from([ + "samply", + "record", + "--unwinding-mode", + "fp", + "rustup", + "--version", + ]) + .unwrap(); + let Action::Record(args) = opt.action else { + panic!("expected record action"); + }; + assert_eq!(args.unwinding_mode, UnwindingMode::Fp); + assert_eq!(args.command, ["rustup", "--version"]); + assert!(matches!(args.recording_mode(), RecordingMode::Launch(_))); + } + + #[cfg(target_os = "linux")] + #[test] + fn record_fp_unwinding_with_pid_attach() { + let opt = Opt::try_parse_from([ + "samply", + "record", + "--unwinding-mode", + "fp", + "--pid", + "1234", + ]) + .unwrap(); + let Action::Record(args) = opt.action else { + panic!("expected record action"); + }; + assert_eq!(args.unwinding_mode, UnwindingMode::Fp); + assert!(matches!(args.recording_mode(), RecordingMode::Pid(1234))); + } + + #[cfg(target_os = "linux")] + #[test] + fn record_rejects_unsupported_unwinding_mode() { + let error = Opt::try_parse_from([ + "samply", + "record", + "--unwinding-mode", + "unsupported", + "--pid", + "1234", + ]) + .unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue); + } + + #[cfg(target_os = "linux")] + #[test] + fn record_unwinding_stack_size() { + let parse = |size: &str| { + Opt::try_parse_from([ + "samply", + "record", + "--unwinding-stack-size", + size, + "--pid", + "1234", + ]) + }; + let Action::Record(args) = parse("65528").unwrap().action else { + panic!("expected record action"); + }; + assert_eq!(args.unwinding_stack_size, 65528); + for invalid in ["0", "1001", "65536"] { + let error = parse(invalid).unwrap_err(); + assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation); + } + } } diff --git a/samply/src/linux/perf_event.rs b/samply/src/linux/perf_event.rs index a34cf4ba..4fa7e436 100644 --- a/samply/src/linux/perf_event.rs +++ b/samply/src/linux/perf_event.rs @@ -505,13 +505,9 @@ impl PerfBuilder { attr.read_format = PERF_FORMAT_GROUP | PERF_FORMAT_ID; } - // Always request callchain so the kernel walks frame pointers at sample - // time. The user portion serves as a fallback when DWARF unwinding - // truncates (e.g. due to the 32 KB captured stack window). + // FP-only recording uses the callchain directly; DWARF recording uses + // its user portion as a fallback when the captured stack is too short. attr.sample_type |= PERF_SAMPLE_CALLCHAIN; - if exclude_kernel { - attr.flags |= PERF_ATTR_FLAG_EXCLUDE_CALLCHAIN_KERNEL; - } attr.sample_regs_user = reg_mask; attr.sample_stack_user = stack_size; @@ -533,7 +529,7 @@ impl PerfBuilder { } if exclude_kernel { - attr.flags |= PERF_ATTR_FLAG_EXCLUDE_KERNEL; + attr.flags |= PERF_ATTR_FLAG_EXCLUDE_KERNEL | PERF_ATTR_FLAG_EXCLUDE_CALLCHAIN_KERNEL; } if inherit { diff --git a/samply/src/linux/profiler.rs b/samply/src/linux/profiler.rs index 271e01cd..9bf95f6d 100644 --- a/samply/src/linux/profiler.rs +++ b/samply/src/linux/profiler.rs @@ -26,7 +26,7 @@ use crate::linux_shared::{ }; use crate::shared::ctrl_c::CtrlC; use crate::shared::prop_types::{ - ProcessLaunchProps, ProfileCreationProps, RecordingMode, RecordingProps, + ProcessLaunchProps, ProfileCreationProps, RecordingMode, RecordingProps, UnwindingMode, }; #[cfg(target_arch = "x86_64")] @@ -93,6 +93,8 @@ pub fn run( // Launch the observer thread. This thread will manage the perf events. let interval = recording_props.interval; + let unwinding_mode = recording_props.unwinding_mode; + let unwinding_stack_size = recording_props.unwinding_stack_size; let time_limit = recording_props.time_limit; // Resolve the extra event names before launching anything, so that a // configuration error aborts immediately. @@ -117,7 +119,15 @@ pub fn run( }; // Create the perf events, setting ENABLE_ON_EXEC. - let perf_group = init_profiler(interval, pid, attach_mode, extra_events, &mut converter); + let perf_group = init_profiler( + interval, + unwinding_mode, + unwinding_stack_size, + pid, + attach_mode, + extra_events, + &mut converter, + ); // Tell the main thread to tell the child process to begin executing. profile_another_pid_reply_sender.send(true).unwrap(); @@ -281,8 +291,15 @@ fn start_profiling_pid( else { panic!("The first message should be a StartProfilingAnotherProcess") }; - let perf_group = - init_profiler(interval, pid, attach_mode, extra_events, &mut converter); + let perf_group = init_profiler( + interval, + recording_props.unwinding_mode, + recording_props.unwinding_stack_size, + pid, + attach_mode, + extra_events, + &mut converter, + ); // Tell the main thread that we are now executing. profile_another_pid_reply_sender.send(true).unwrap(); @@ -403,6 +420,8 @@ fn resolve_extra_events(specs: &[String]) -> Vec { fn init_profiler( interval: Duration, + unwinding_mode: UnwindingMode, + unwinding_stack_size: u32, pid: u32, attach_mode: AttachMode, extra_events: Vec, @@ -417,8 +436,10 @@ fn init_profiler( }; let frequency = (1_000_000_000 / interval_nanos) as u32; - let stack_size = 32000; - let regs_mask = ConvertRegsNative::regs_mask(); + let (stack_size, regs_mask) = match unwinding_mode { + UnwindingMode::Dwarf => (unwinding_stack_size, ConvertRegsNative::regs_mask()), + UnwindingMode::Fp => (0, 0), + }; let mut perf = PerfGroup::open( pid, diff --git a/samply/src/linux_shared/converter.rs b/samply/src/linux_shared/converter.rs index 120020d4..09e70e59 100644 --- a/samply/src/linux_shared/converter.rs +++ b/samply/src/linux_shared/converter.rs @@ -2159,3 +2159,167 @@ impl Marker for MmapMarker { self.0 } } + +#[cfg(test)] +mod tests { + use framehop::x86_64::{CacheX86_64, UnwinderX86_64}; + use framehop::{ExplicitModuleSectionInfo, Module, Unwinder}; + use linux_perf_data::linux_perf_event_reader; + use linux_perf_event_reader::constants::{PERF_CONTEXT_KERNEL, PERF_CONTEXT_USER}; + use linux_perf_event_reader::{CpuMode, RawData, RawDataU64, SampleRecord}; + + use super::{Converter, MmapRangeOrVec}; + use crate::linux_shared::convert_regs::ConvertRegsX86_64; + use crate::shared::types::{StackFrame, StackMode}; + + fn callchain_only_stack( + ip: u64, + cpu_mode: CpuMode, + callchain: Option<&[u64]>, + ) -> Vec { + let callchain_bytes = callchain.map(|addresses| { + addresses + .iter() + .flat_map(|address| address.to_le_bytes()) + .collect::>() + }); + let sample = SampleRecord { + id: None, + addr: None, + stream_id: None, + raw: None, + ip: Some(ip), + timestamp: None, + pid: Some(1), + tid: Some(1), + cpu: None, + period: None, + read: None, + user_regs: None, + user_stack: None, + callchain: callchain_bytes.as_ref().map(|bytes| { + RawDataU64::from_raw_data::(RawData::Single(bytes)) + }), + phys_addr: None, + data_page_size: None, + code_page_size: None, + intr_regs: None, + cpu_mode, + }; + + let mut unwinder = UnwinderX86_64::::new(); + // Known code addresses keep root trimming from discarding valid FP frames. + unwinder.add_module(Module::new( + "test-module".to_string(), + 0x1000..0x2000, + 0x1000, + ExplicitModuleSectionInfo { + base_svma: 0, + text_svma: None, + text: None, + stubs_svma: None, + stub_helper_svma: None, + got_svma: None, + unwind_info: None, + eh_frame_svma: None, + eh_frame: None, + eh_frame_hdr_svma: None, + eh_frame_hdr: None, + debug_frame: None, + text_segment_svma: None, + text_segment: None, + }, + )); + let mut stack = Vec::new(); + Converter::>::get_sample_stack::( + &sample, + &unwinder, + &mut CacheX86_64::new(), + &mut std::collections::HashMap::new(), + &mut stack, + false, + false, + ); + stack + } + + #[test] + fn fp_only_callchain_preserves_user_frame_order_without_duplicating_sampled_ip() { + let stack = callchain_only_stack( + 0x1010, + CpuMode::User, + Some(&[PERF_CONTEXT_USER, 0x1010, 0x1020, 0x1030]), + ); + + assert_eq!( + stack, + vec![ + StackFrame::InstructionPointer(0x1010, StackMode::User), + StackFrame::ReturnAddress(0x1020, StackMode::User), + StackFrame::ReturnAddress(0x1030, StackMode::User), + ] + ); + } + + #[test] + fn fp_only_callchain_preserves_kernel_to_user_context_transition() { + let kernel_ip = 0xffff_8000_0000_0010; + let kernel_caller = 0xffff_8000_0000_0020; + let stack = callchain_only_stack( + kernel_ip, + CpuMode::Kernel, + Some(&[ + PERF_CONTEXT_KERNEL, + kernel_ip, + kernel_caller, + PERF_CONTEXT_USER, + 0x1010, + 0x1020, + ]), + ); + + assert_eq!( + stack, + vec![ + StackFrame::InstructionPointer(kernel_ip, StackMode::Kernel), + StackFrame::ReturnAddress(kernel_caller, StackMode::Kernel), + StackFrame::ReturnAddress(0x1010, StackMode::User), + StackFrame::ReturnAddress(0x1020, StackMode::User), + ] + ); + } + + #[test] + fn fp_only_leaf_callchain_keeps_one_sampled_ip() { + let stack = callchain_only_stack(0x1010, CpuMode::User, Some(&[PERF_CONTEXT_USER, 0x1010])); + + assert_eq!( + stack, + vec![StackFrame::InstructionPointer(0x1010, StackMode::User)] + ); + } + + #[test] + fn fp_only_empty_callchains_fall_back_to_sampled_ip() { + let callchains: [Option<&[u64]>; 3] = [None, Some(&[]), Some(&[PERF_CONTEXT_USER])]; + for callchain in callchains { + let stack = callchain_only_stack(0x1010, CpuMode::User, callchain); + + assert_eq!( + stack, + vec![StackFrame::InstructionPointer(0x1010, StackMode::User)], + "callchain: {callchain:?}" + ); + } + } + + #[test] + fn fp_only_unmapped_leaf_callchain_falls_back_to_sampled_ip() { + let stack = callchain_only_stack(0x9000, CpuMode::User, Some(&[PERF_CONTEXT_USER, 0x9000])); + + assert_eq!( + stack, + vec![StackFrame::InstructionPointer(0x9000, StackMode::User)] + ); + } +} diff --git a/samply/src/mac/profiler.rs b/samply/src/mac/profiler.rs index 124cf02e..cd714bc7 100644 --- a/samply/src/mac/profiler.rs +++ b/samply/src/mac/profiler.rs @@ -4,7 +4,7 @@ use std::process::ExitStatus; use std::thread; use std::time::Duration; -use crossbeam_channel::unbounded; +use crossbeam_channel::{bounded, unbounded}; use fxprof_processed_profile::Profile; use super::error::SamplingError; @@ -76,9 +76,15 @@ pub fn run( }; let (task_sender, task_receiver) = unbounded(); + let (task_initialized_sender, task_initialized_receiver) = bounded(0); let sampler_thread = thread::spawn(move || { - let sampler = Sampler::new(task_receiver, recording_props, profile_creation_props); + let sampler = Sampler::new( + task_receiver, + task_initialized_sender, + recording_props, + profile_creation_props, + ); sampler.run() }); @@ -108,8 +114,11 @@ pub fn run( path_receiver, })); path_senders_per_pid.insert(pid, path_sender); - if send_result.is_err() { - // The sampler has already shut down. This task arrived too late. + if send_result.is_ok() { + // Keep the task suspended until the sampler has read its + // initial threads and libraries; short-lived tasks can + // otherwise exit before TaskProfiler::new finishes. + task_initialized_receiver.recv().ok(); } accepted_task.start_execution(); } diff --git a/samply/src/mac/sampler.rs b/samply/src/mac/sampler.rs index 4ac1fdf2..c3a3d8b4 100644 --- a/samply/src/mac/sampler.rs +++ b/samply/src/mac/sampler.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use std::time::{Duration, SystemTime}; use std::{mem, thread}; -use crossbeam_channel::Receiver; +use crossbeam_channel::{Receiver, Sender}; use fxprof_processed_profile::{ Category, CategoryColor, PlatformSpecificReferenceTimestamp, Profile, ReferenceTimestamp, SubcategoryHandle, @@ -41,6 +41,7 @@ pub struct TaskInit { pub struct Sampler { task_receiver: Receiver, + task_initialized_sender: Sender<()>, recording_props: Arc, profile_creation_props: Arc, } @@ -48,11 +49,13 @@ pub struct Sampler { impl Sampler { pub fn new( task_receiver: Receiver, + task_initialized_sender: Sender<()>, recording_props: RecordingProps, profile_creation_props: ProfileCreationProps, ) -> Self { Sampler { task_receiver, + task_initialized_sender, recording_props: Arc::new(recording_props), profile_creation_props: Arc::new(profile_creation_props), } @@ -110,8 +113,9 @@ impl Sampler { &mut profile, process_recycler.as_mut(), self.profile_creation_props.clone(), - ) - .expect("couldn't create root TaskProfiler"); + ); + self.task_initialized_sender.send(()).ok(); + let root_task = root_task.expect("couldn't create root TaskProfiler"); let mut process_sample_datas = Vec::new(); let mut stack_scratch_buffer = Vec::new(); @@ -144,14 +148,16 @@ impl Sampler { break; } }; - if let Ok(new_task) = TaskProfiler::new( + let new_task = TaskProfiler::new( task_init, timestamp_converter, &self.profile_creation_props.fallback_profile_name, &mut profile, process_recycler.as_mut(), self.profile_creation_props.clone(), - ) { + ); + self.task_initialized_sender.send(()).ok(); + if let Ok(new_task) = new_task { live_tasks.push(new_task); } else { // The task is probably already dead again. We get here for tasks which are diff --git a/samply/src/shared/prop_types.rs b/samply/src/shared/prop_types.rs index 4b901841..4018156c 100644 --- a/samply/src/shared/prop_types.rs +++ b/samply/src/shared/prop_types.rs @@ -25,6 +25,15 @@ impl CoreClrProfileProps { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)] +pub enum UnwindingMode { + Dwarf, + Fp, +} + +/// Default bytes of user stack copied per sample for DWARF unwinding. +pub const DEFAULT_UNWINDING_STACK_SIZE: u32 = 32_000; + /// Properties which are meaningful both for recording a fresh process /// as well as for recording an existing process. #[derive(Debug, Clone)] @@ -33,6 +42,11 @@ pub struct RecordingProps { pub output_file: PathBuf, pub time_limit: Option, pub interval: Duration, + #[allow(dead_code)] // Only used on Linux and Android + pub unwinding_mode: UnwindingMode, + /// Bytes of user stack copied per sample for DWARF unwinding. + #[allow(dead_code)] // Only used on Linux + pub unwinding_stack_size: u32, /// Names of extra perf events to record alongside the main sampling /// event. Only used on Linux. #[allow(dead_code)]