Skip to content

Commit 277713f

Browse files
committed
Add flamegraph feature
1 parent 2973b2f commit 277713f

File tree

7 files changed

+161
-7
lines changed

7 files changed

+161
-7
lines changed

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ errno = "0.3"
3939
util = { path = "./util", version = "0.6", package = "pprof_util" }
4040
mappings = { path = "./mappings", version = "0.6" }
4141
backtrace = "0.3"
42+
inferno = "0.12"
4243

4344
[dependencies]
4445
util.workspace = true
@@ -52,6 +53,7 @@ tempfile.workspace = true
5253
tokio.workspace = true
5354

5455
[features]
56+
flamegraph = ["util/flamegraph"]
5557
symbolize = ["util/symbolize"]
5658

5759
[dev-dependencies]

README.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,43 @@ To generate symbolized profiles, enable the `symbolize` crate feature:
102102
jemalloc_pprof = { version = "0.6", features = ["symbolize"] }
103103
```
104104

105+
### Flamegraph SVGs
106+
107+
The `flamegraph` crate feature can also be enabled to generate interactive flamegraph SVGs directly
108+
(implies the `symbolize` feature):
109+
110+
```toml
111+
jemalloc_pprof = { version = "0.6", features = ["flamegraph"] }
112+
```
113+
114+
We can then adjust the example above to also emit a flamegraph SVG:
115+
116+
```rust,ignore
117+
#[tokio::main]
118+
async fn main() {
119+
let app = axum::Router::new()
120+
.route("/debug/pprof/heap", axum::routing::get(handle_get_heap))
121+
.route("/debug/pprof/heap/flamegraph", axum::routing::get(handle_get_heap_flamegraph));
122+
// ...
123+
}
124+
125+
pub async fn handle_get_heap_flamegraph() -> Result<impl IntoResponse, (StatusCode, String)> {
126+
use axum::body::Body;
127+
use axum::http::header::CONTENT_TYPE;
128+
use axum::response::Response;
129+
130+
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
131+
require_profiling_activated(&prof_ctl)?;
132+
let svg = prof_ctl
133+
.dump_flamegraph()
134+
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
135+
Response::builder()
136+
.header(CONTENT_TYPE, "image/svg+xml")
137+
.body(Body::from(svg))
138+
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
139+
}
140+
```
141+
105142
### Writeable temporary directory
106143

107144
The way this library works is that it creates a new temporary file (in the [platform-specific default temp dir](https://docs.rs/tempfile/latest/tempfile/struct.NamedTempFile.html)), and instructs jemalloc to dump a profile into that file. Therefore the platform respective temporary directory must be writeable by the process. After reading and converting it to pprof, the file is cleaned up via the destructor. A single profile tends to be only a few kilobytes large, so it doesn't require a significant space, but it's non-zero and needs to be writeable.

example/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,6 @@ tokio = { version = "1", features = ["full"] }
1010
axum = "0.7.2"
1111
[target.'cfg(not(target_env = "msvc"))'.dependencies]
1212
tikv-jemallocator = { version = "0.6", features = ["profiling", "stats", "unprefixed_malloc_on_supported_platforms", "background_threads"] }
13+
14+
[features]
15+
flamegraph = ["jemalloc_pprof/flamegraph"]

example/src/main.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,13 @@ async fn main() {
1818

1919
let app = axum::Router::new().route("/debug/pprof/heap", axum::routing::get(handle_get_heap));
2020

21+
// Add a flamegraph SVG route if enabled via `cargo run -F flamegraph`.
22+
#[cfg(feature = "flamegraph")]
23+
let app = app.route(
24+
"/debug/pprof/heap/flamegraph",
25+
axum::routing::get(handle_get_heap_flamegraph),
26+
);
27+
2128
// run our app with hyper, listening globally on port 3000
2229
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
2330
axum::serve(listener, app).await.unwrap();
@@ -32,6 +39,23 @@ pub async fn handle_get_heap() -> Result<impl IntoResponse, (StatusCode, String)
3239
Ok(pprof)
3340
}
3441

42+
#[cfg(feature = "flamegraph")]
43+
pub async fn handle_get_heap_flamegraph() -> Result<impl IntoResponse, (StatusCode, String)> {
44+
use axum::body::Body;
45+
use axum::http::header::CONTENT_TYPE;
46+
use axum::response::Response;
47+
48+
let mut prof_ctl = jemalloc_pprof::PROF_CTL.as_ref().unwrap().lock().await;
49+
require_profiling_activated(&prof_ctl)?;
50+
let svg = prof_ctl
51+
.dump_flamegraph()
52+
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))?;
53+
Response::builder()
54+
.header(CONTENT_TYPE, "image/svg+xml")
55+
.body(Body::from(svg))
56+
.map_err(|err| (StatusCode::INTERNAL_SERVER_ERROR, err.to_string()))
57+
}
58+
3559
/// Checks whether jemalloc profiling is activated an returns an error response if not.
3660
fn require_profiling_activated(
3761
prof_ctl: &jemalloc_pprof::JemallocProfCtl,

src/lib.rs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ use tempfile::NamedTempFile;
2929
use tikv_jemalloc_ctl::raw;
3030
use tokio::sync::Mutex;
3131

32+
#[cfg(feature = "flamegraph")]
33+
pub use util::FlamegraphOptions;
3234
use util::{parse_jeheap, ProfStartTime};
3335

3436
/// Activate jemalloc profiling.
@@ -166,4 +168,27 @@ impl JemallocProfCtl {
166168
let pprof = profile.to_pprof(("inuse_space", "bytes"), ("space", "bytes"), None);
167169
Ok(pprof)
168170
}
171+
172+
/// Dump a profile flamegraph in SVG format.
173+
#[cfg(feature = "flamegraph")]
174+
pub fn dump_flamegraph(&mut self) -> anyhow::Result<Vec<u8>> {
175+
let mut opts = FlamegraphOptions::default();
176+
opts.title = "inuse_space".to_string();
177+
opts.count_name = "bytes".to_string();
178+
self.dump_flamegraph_with_options(&mut opts)
179+
}
180+
181+
/// Dump a profile flamegraph in SVG format with the given options.
182+
///
183+
/// The options are taken from [`inferno::flamegraph::Options`], see its docs for details.
184+
#[cfg(feature = "flamegraph")]
185+
pub fn dump_flamegraph_with_options(
186+
&mut self,
187+
opts: &mut FlamegraphOptions,
188+
) -> anyhow::Result<Vec<u8>> {
189+
let f = self.dump()?;
190+
let dump_reader = BufReader::new(f);
191+
let profile = parse_jeheap(dump_reader, MAPPINGS.as_deref())?;
192+
profile.to_flamegraph(opts)
193+
}
169194
}

util/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ anyhow.workspace = true
1717
num.workspace = true
1818
paste.workspace = true
1919
backtrace = { workspace = true, optional = true }
20+
inferno = { workspace = true, optional = true }
2021

2122
[features]
23+
flamegraph = ["symbolize", "dep:inferno"]
2224
symbolize = ["dep:backtrace"]

util/src/lib.rs

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ use prost::Message;
1515
pub use cast::CastFrom;
1616
pub use cast::TryCastFrom;
1717

18+
#[cfg(feature = "flamegraph")]
19+
pub use inferno::flamegraph::Options as FlamegraphOptions;
20+
1821
/// Start times of the profiler.
1922
#[derive(Copy, Clone, Debug)]
2023
pub enum ProfStartTime {
@@ -51,7 +54,7 @@ impl StringTable {
5154
}
5255

5356
#[path = "perftools.profiles.rs"]
54-
mod pprof_types;
57+
mod proto;
5558

5659
/// A single sample in the profile. The stack is a list of addresses.
5760
#[derive(Clone, Debug)]
@@ -104,8 +107,21 @@ impl StackProfile {
104107
period_type: (&str, &str),
105108
anno_key: Option<String>,
106109
) -> Vec<u8> {
107-
use crate::pprof_types as proto;
110+
let profile = self.to_pprof_proto(sample_type, period_type, anno_key);
111+
let encoded = profile.encode_to_vec();
108112

113+
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
114+
gz.write_all(&encoded).unwrap();
115+
gz.finish().unwrap()
116+
}
117+
118+
/// Converts the profile into the pprof Protobuf format (see `pprof/profile.proto`).
119+
fn to_pprof_proto(
120+
&self,
121+
sample_type: (&str, &str),
122+
period_type: (&str, &str),
123+
anno_key: Option<String>,
124+
) -> proto::Profile {
109125
let mut profile = proto::Profile::default();
110126
let mut strings = StringTable::new();
111127

@@ -192,7 +208,7 @@ impl StackProfile {
192208
let addr = u64::cast_from(*addr) - 1;
193209

194210
let loc_id = *location_ids.entry(addr).or_insert_with(|| {
195-
// pprof_types.proto says the location id may be the address, but Polar Signals
211+
// profile.proto says the location id may be the address, but Polar Signals
196212
// insists that location ids are sequential, starting with 1.
197213
let id = u64::cast_from(profile.location.len()) + 1;
198214

@@ -275,11 +291,56 @@ impl StackProfile {
275291

276292
profile.string_table = strings.finish();
277293

278-
let encoded = profile.encode_to_vec();
294+
profile
295+
}
279296

280-
let mut gz = GzEncoder::new(Vec::new(), Compression::default());
281-
gz.write_all(&encoded).unwrap();
282-
gz.finish().unwrap()
297+
/// Converts the profile into a flamegraph SVG, using the given options.
298+
///
299+
/// The options are taken from [`inferno::flamegraph::Options`], see its docs for details.
300+
#[cfg(feature = "flamegraph")]
301+
pub fn to_flamegraph(&self, opts: &mut FlamegraphOptions) -> anyhow::Result<Vec<u8>> {
302+
use std::collections::HashMap;
303+
304+
// We start from a symbolized Protobuf profile. We just pass in empty type names, since
305+
// they're not used in the final flamegraph.
306+
let profile = self.to_pprof_proto(("", ""), ("", ""), None);
307+
308+
// Index locations, functions, and strings.
309+
let locations: HashMap<u64, proto::Location> =
310+
profile.location.into_iter().map(|l| (l.id, l)).collect();
311+
let functions: HashMap<u64, proto::Function> =
312+
profile.function.into_iter().map(|f| (f.id, f)).collect();
313+
let strings = profile.string_table;
314+
315+
// Resolve stacks as function name vectors, and sum sample values per stack. Also reverse
316+
// the stack, since inferno expects it bottom-up.
317+
let mut stacks: HashMap<Vec<&str>, i64> = HashMap::new();
318+
for sample in profile.sample {
319+
let mut stack = Vec::with_capacity(sample.location_id.len());
320+
for location in sample.location_id.into_iter().rev() {
321+
let location = locations.get(&location).expect("missing location");
322+
for line in location.line.iter().rev() {
323+
let function = functions.get(&line.function_id).expect("missing function");
324+
let name = strings.get(function.name as usize).expect("missing string");
325+
stack.push(name.as_str());
326+
}
327+
}
328+
let value = sample.value.first().expect("missing value");
329+
*stacks.entry(stack).or_default() += value;
330+
}
331+
332+
// Construct stack lines for inferno.
333+
let mut lines = stacks
334+
.into_iter()
335+
.map(|(stack, value)| format!("{} {}", stack.join(";"), value))
336+
.collect::<Vec<_>>();
337+
lines.sort();
338+
339+
// Generate the flamegraph SVG.
340+
let mut bytes = Vec::new();
341+
let lines = lines.iter().map(|line| line.as_str());
342+
inferno::flamegraph::from_lines(opts, lines, &mut bytes)?;
343+
Ok(bytes)
283344
}
284345
}
285346

0 commit comments

Comments
 (0)