diff --git a/VEX/priv/guest_arm64_toIR.c b/VEX/priv/guest_arm64_toIR.c index 6e77b34c7..62927537c 100644 --- a/VEX/priv/guest_arm64_toIR.c +++ b/VEX/priv/guest_arm64_toIR.c @@ -7422,7 +7422,7 @@ Bool dis_ARM64_branch_etc(/*MB_OUT*/DisResult* dres, UInt insn, /* -------------------- B{L} uncond -------------------- */ if (INSN(30,26) == BITS5(0,0,1,0,1)) { /* 000101 imm26 B (PC + sxTo64(imm26 << 2)) - 100101 imm26 B (PC + sxTo64(imm26 << 2)) + 100101 imm26 BL (PC + sxTo64(imm26 << 2)) */ UInt bLink = INSN(31,31); ULong uimm64 = INSN(25,0) << 2; @@ -7432,7 +7432,11 @@ Bool dis_ARM64_branch_etc(/*MB_OUT*/DisResult* dres, UInt insn, } putPC(mkU64(guest_PC_curr_instr + simm64)); dres->whatNext = Dis_StopHere; - dres->jk_StopHere = Ijk_Call; + /* Only BL (which writes the link register) is a call; a plain B is + an ordinary unconditional branch. Mislabelling B as Ijk_Call makes + callgrind treat every branch to a function epilogue / tail target as + a call, corrupting recursive and cyclic call graphs on arm64. */ + dres->jk_StopHere = bLink ? Ijk_Call : Ijk_Boring; DIP("b%s 0x%llx\n", bLink == 1 ? "l" : "", guest_PC_curr_instr + simm64); return True; diff --git a/callgrind-utils/.gitignore b/callgrind-utils/.gitignore new file mode 100644 index 000000000..9f970225a --- /dev/null +++ b/callgrind-utils/.gitignore @@ -0,0 +1 @@ +target/ \ No newline at end of file diff --git a/callgrind-utils/Cargo.lock b/callgrind-utils/Cargo.lock new file mode 100644 index 000000000..798087006 --- /dev/null +++ b/callgrind-utils/Cargo.lock @@ -0,0 +1,540 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "callgrind-utils" +version = "0.1.0" +dependencies = [ + "insta", + "rstest", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "windows-sys", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "insta" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f0f8fee8c926415c58d6ae43a08523a26faccb2323f5e6b644fe7dd4ef6b82" +dependencies = [ + "console", + "once_cell", + "similar", + "tempfile", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "relative-path" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" + +[[package]] +name = "rstest" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" +dependencies = [ + "futures", + "futures-timer", + "rstest_macros", + "rustc_version", +] + +[[package]] +name = "rstest_macros" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" +dependencies = [ + "cfg-if", + "glob", + "proc-macro-crate", + "proc-macro2", + "quote", + "regex", + "relative-path", + "rustc_version", + "syn", + "unicode-ident", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +dependencies = [ + "memchr", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/callgrind-utils/Cargo.toml b/callgrind-utils/Cargo.toml new file mode 100644 index 000000000..f9fff0c70 --- /dev/null +++ b/callgrind-utils/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "callgrind-utils" +version = "0.1.0" +edition = "2024" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" + +[dev-dependencies] +insta = "1" +rstest = "0.23" diff --git a/callgrind-utils/src/error.rs b/callgrind-utils/src/error.rs new file mode 100644 index 000000000..87f849779 --- /dev/null +++ b/callgrind-utils/src/error.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +/// Errors raised while parsing a Callgrind `.out` file. +#[derive(Debug, Error)] +pub enum ParseError { + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + #[error("bad id: {0}")] + BadId(#[from] std::num::ParseIntError), + #[error("call record missing required cfn=")] + MissingCfn, + #[error("unexpected end of input")] + UnexpectedEof, +} + +/// Errors raised while serializing a `CallGraph` to JSON. +#[derive(Debug, Error)] +pub enum ToJsonError { + #[error("serde error: {0}")] + Serde(#[from] serde_json::Error), + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), +} diff --git a/callgrind-utils/src/lib.rs b/callgrind-utils/src/lib.rs new file mode 100644 index 000000000..ae703bfd9 --- /dev/null +++ b/callgrind-utils/src/lib.rs @@ -0,0 +1,6 @@ +pub mod error; +pub mod model; +mod normalize; +pub mod parser; +mod redact; +pub mod serialize; diff --git a/callgrind-utils/src/model.rs b/callgrind-utils/src/model.rs new file mode 100644 index 000000000..be32961a9 --- /dev/null +++ b/callgrind-utils/src/model.rs @@ -0,0 +1,135 @@ +use std::collections::HashMap; + +use serde::Serialize; + +/// A call-graph node: a single function identity. +/// +/// Node identity is the full `(object, file, function)` tuple, so two +/// statics that share a name but live in different objects/files are +/// distinct nodes (no false merge). +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +pub struct Node { + pub function: String, + pub file: String, + pub object: String, +} + +/// A directed call edge: `caller` calls `callee`, optionally annotated +/// with an observed `call_count`. +/// +/// `Edge` deliberately does NOT derive `Serialize`: the canonical JSON +/// view references nodes by index, not by value. See `serialize::EdgeJson`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Edge { + pub caller: Node, + pub callee: Node, + pub call_count: Option, +} + +/// Tunables for `.out` parsing. +#[derive(Debug, Clone)] +pub struct ParseOptions { + /// When true, file/object paths are reduced to their basename and + /// Callgrind-style unknowns (`???`) collapse to `unknown`. + pub normalize_paths: bool, + /// Sentinel substituted for absent/unknown object or file names. + pub unknown: String, +} + +impl Default for ParseOptions { + fn default() -> Self { + Self { + normalize_paths: true, + unknown: "???".to_string(), + } + } +} + +/// The parsed call graph: sorted, deduplicated nodes and edges. +/// +/// Fields are `pub(crate)` so the sibling `parser` and `serialize` +/// modules can materialize/consume them without exposing them publicly. +pub struct CallGraph { + pub(crate) nodes: Vec, + pub(crate) edges: Vec, +} + +impl CallGraph { + /// Borrow the sorted node list. + pub fn nodes(&self) -> &[Node] { + &self.nodes + } + + /// Borrow the sorted, deduplicated edge list. + pub fn edges(&self) -> &[Edge] { + &self.edges + } + + /// Construct a `CallGraph` from raw parsed material. + /// + /// Nodes are sorted by `(object, file, function)` and de-duplicated. + /// Edges are sorted by `(caller_idx, callee_idx)` using the sorted + /// node order, then de-duplicated by `(caller, callee)`, aggregating + /// `call_count` across duplicates (sum when both are `Some`; keep the + /// first value when any duplicate is `None`). + pub(crate) fn from_parts(mut nodes: Vec, mut edges: Vec) -> Self { + nodes.sort_by(|a, b| { + a.object + .cmp(&b.object) + .then_with(|| a.file.cmp(&b.file)) + .then_with(|| a.function.cmp(&b.function)) + }); + nodes.dedup(); + + // Index lookup for stable node ordering of edges. + let mut index: HashMap<&Node, usize> = HashMap::with_capacity(nodes.len()); + for (i, n) in nodes.iter().enumerate() { + index.insert(n, i); + } + + let edge_rank = |e: &Edge| { + ( + index.get(&e.caller).copied().unwrap_or(usize::MAX), + index.get(&e.callee).copied().unwrap_or(usize::MAX), + ) + }; + edges.sort_by_key(edge_rank); + + // Dedup adjacent (now grouped) edges, aggregating call_count. + let mut deduped: Vec = Vec::with_capacity(edges.len()); + for e in edges { + // Dedup adjacent (now grouped) duplicate edges, summing counts; + // any None keeps the first value as-is. + if let Some(last) = deduped.last_mut() + && last.caller == e.caller + && last.callee == e.callee + { + if let (Some(a), Some(b)) = (last.call_count, e.call_count) { + last.call_count = Some(a + b); + } + continue; + } + deduped.push(e); + } + + Self { + nodes, + edges: deduped, + } + } + + /// Index of `n` within the sorted node list, or `None` if absent. + /// + /// Uses binary search over the `(object, file, function)` ordering + /// established by `from_parts`. + pub(crate) fn node_index(&self, n: &Node) -> Option { + self.nodes + .binary_search_by(|x| { + x.object + .cmp(&n.object) + .then_with(|| x.file.cmp(&n.file)) + .then_with(|| x.function.cmp(&n.function)) + }) + .ok() + } +} diff --git a/callgrind-utils/src/normalize.rs b/callgrind-utils/src/normalize.rs new file mode 100644 index 000000000..ca3044ecd --- /dev/null +++ b/callgrind-utils/src/normalize.rs @@ -0,0 +1,28 @@ +use super::model::ParseOptions; + +/// Return the last path segment after the final `/`. +/// +/// `"foo/bar/baz.c"` -> `"baz.c"`; `"baz.c"` -> `"baz.c"`; `""` -> `""`. +pub(crate) fn basename(path: &str) -> &str { + match path.rfind('/') { + Some(i) => &path[i + 1..], + None => path, + } +} + +/// Normalize a file/object path according to `opts`. +/// +/// When `normalize_paths` is disabled the path is returned verbatim. +/// Otherwise the basename is taken and Callgrind-style unknowns (empty or +/// `"???"`) collapse to `opts.unknown`. +pub(crate) fn normalize_path(path: &str, opts: &ParseOptions) -> String { + if !opts.normalize_paths { + return path.to_string(); + } + let leaf = basename(path); + if leaf.is_empty() || leaf == "???" { + opts.unknown.clone() + } else { + leaf.to_string() + } +} diff --git a/callgrind-utils/src/parser.rs b/callgrind-utils/src/parser.rs new file mode 100644 index 000000000..d20408e2f --- /dev/null +++ b/callgrind-utils/src/parser.rs @@ -0,0 +1,286 @@ +use std::collections::HashMap; + +use super::{ + error::ParseError, + model::{CallGraph, Edge, Node, ParseOptions}, + normalize, +}; + +/// Header/auxiliary keys that carry no call-graph topology and are dropped +/// outright. `part`/`thread` are handled separately (context boundaries), +/// not here. `cfni` is an inline-function annotation, not a callee spec. +const SKIP_KEYS: &[&str] = &[ + "version", + "creator", + "pid", + "cmd", + "desc", + "positions", + "events", + "event", + "summary", + "totals", + "rec", + "jfi", + "jfn", + "frfn", + "cfni", + "jump", + "jcnd", +]; + +impl CallGraph { + /// Parse a Callgrind `.out` stream into a call graph. + /// + /// The format is line-oriented (see `callgrind/docs/cl-format.xml`). We + /// track three independent name-compression ID spaces (functions, files, + /// objects), the current caller context, and a pending callee record. + /// An edge is emitted only when a `calls=` line closes a record that has a + /// pending `cfn=`; a bare `cfn=` is callee context that gets discarded. + pub fn parse(reader: impl std::io::BufRead, opts: &ParseOptions) -> Result { + // Three SEPARATE name-compression ID spaces. + let mut fn_ids: HashMap = HashMap::new(); + let mut file_ids: HashMap = HashMap::new(); + let mut obj_ids: HashMap = HashMap::new(); + + // Current caller context. + let mut cur_obj: Option = None; + let mut cur_fl: Option = None; // the function's own file (`fl=`) + let mut cur_pos_file: Option = None; // current position file (`fl`/`fi`/`fe`) + let mut cur_fn: Option = None; + + // Pending callee record, built from `cob`/`cfi`/`cfl`/`cfn`. + let mut pend_cob: Option = None; + let mut pend_cfi: Option = None; + let mut pend_cfn: Option = None; + + let mut nodes: Vec = Vec::new(); + let mut edges: Vec = Vec::new(); + + for line in reader.lines() { + let line = line?; // io error -> ParseError::Io (#[from]) + let trimmed = line.trim_start(); + + // Blank lines and comments carry nothing. + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + let key = line_key(trimmed); + + // `part:`/`thread:` separators bound a record: clear the pending + // callee, but keep the ID maps and caller context (IDs persist + // across parts; parts/threads are always merged into one graph). + if key == "part" || key == "thread" { + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + continue; + } + + // Header/auxiliary lines carry no topology. Body-level skips + // (`jump`/`jcnd`/`jfi`/`jfn`/`cfni`/`frfn`) must ALSO close any open + // call record, so a bare `cfn=` cannot survive across them and + // poison a later `calls=`. Clearing when nothing is pending is a + // harmless no-op for true header lines. + if SKIP_KEYS.contains(&key) { + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + continue; + } + + // Position specs and `calls` are `key=value`; a colon-separated + // (`ob:`) or bare token is a header/cost/unknown line, never a spec. + let assign = trimmed.as_bytes().get(key.len()) == Some(&b'='); + + // A `calls=` line closes a call record and emits the edge. + if key == "calls" && assign { + if let Some(cfn) = pend_cfn.take() { + let rhs = &trimmed[key.len() + 1..]; + let call_count = parse_call_count(rhs); + + // Caller file is the function's own `fl` (cur_fl), NEVER the + // current position file: an inline `fi=`/`fe=` transition + // moves the callee context but not the caller's identity. + let caller = make_node( + cur_fn.as_deref(), + cur_fl.as_deref(), + cur_obj.as_deref(), + opts, + ); + // Callee inherits the current position file (which may be an + // inline `fi`/`fe` file) and the caller object unless the + // record overrode them with `cfi`/`cfl`/`cob`. + let callee_file = pend_cfi.as_deref().or(cur_pos_file.as_deref()); + let callee_obj = pend_cob.as_deref().or(cur_obj.as_deref()); + let callee = make_node(Some(cfn.as_str()), callee_file, callee_obj, opts); + + nodes.push(caller.clone()); + nodes.push(callee.clone()); + edges.push(Edge { + caller, + callee, + call_count, + }); + } + // Whether or not an edge was emitted, the record is closed. + pend_cob = None; + pend_cfi = None; + continue; + } + + // Lines lacking an `=` after the key — colon headers (`ob:`), bare + // tokens, and cost/address lines — are never specs or calls, so + // they only close any open call record (a bare `cfn=` thus cannot + // poison a later `calls=`). + if !assign { + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + continue; + } + + // Recognized position specs dispatch below; an unknown `key=value` + // falls to the `_` arm, which also closes the record. + match key { + "ob" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut obj_ids)?; + cur_obj = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "fl" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut file_ids)?; + cur_fl = Some(x.clone()); + cur_pos_file = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "fi" | "fe" => { + // Inline-file transition: moves the position file only, not + // the function's own `fl`. + let x = parse_pos_name(rhs_of(trimmed, key), &mut file_ids)?; + cur_pos_file = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "fn" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut fn_ids)?; + cur_fn = Some(x); + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + "cob" => { + let x = parse_pos_name(rhs_of(trimmed, key), &mut obj_ids)?; + pend_cob = Some(x); + } + "cfi" | "cfl" => { + // `cfl` is the historical alias of `cfi`; identical meaning. + let x = parse_pos_name(rhs_of(trimmed, key), &mut file_ids)?; + pend_cfi = Some(x); + } + "cfn" => { + // Do NOT clear pend_cob/pend_cfi: they legitimately precede + // cfn within the same call record. + let x = parse_pos_name(rhs_of(trimmed, key), &mut fn_ids)?; + pend_cfn = Some(x); + } + _ => { + // Cost/subposition lines and anything unrecognized close any + // dangling callee context. + pend_cob = None; + pend_cfi = None; + pend_cfn = None; + } + } + } + + // Nothing to flush at EOF: a bare trailing `cfn=` is discarded. + Ok(CallGraph::from_parts(nodes, edges)) + } +} + +/// The leading token of `line`: everything up to the first `=`, `:`, or +/// whitespace. For `fn=(1) main` this is `"fn"`; for `0x401000 4`, `"0x401000"`. +fn line_key(line: &str) -> &str { + let end = line + .find(|c: char| c == '=' || c == ':' || c.is_whitespace()) + .unwrap_or(line.len()); + &line[..end] +} + +/// The value after `key=` in a position-spec line. Callers only invoke this for +/// keys known to be followed by `=`, so the separator byte is skipped directly. +fn rhs_of<'a>(trimmed: &'a str, key: &str) -> &'a str { + &trimmed[key.len() + 1..] +} + +/// Resolve a name-compression RHS against its ID map. +/// +/// `(N) name` defines ID `N` -> `name` and returns the name; `(N)` references a +/// previously defined ID; a bare `name` (compression off) is returned verbatim +/// and never touches the map. +fn parse_pos_name(rhs: &str, map: &mut HashMap) -> Result { + let rhs = rhs.trim_start(); + let Some(after_paren) = rhs.strip_prefix('(') else { + // Compression off: literal name. + return Ok(rhs.trim().to_owned()); + }; + + // The entire substring before `)` is the numeric ID; everything after it + // (split on the FIRST `)`, so names may themselves contain `)`) is the + // optional name. An unterminated `(N` treats the remainder as the ID. + let (num, rest) = after_paren.split_once(')').unwrap_or((after_paren, "")); + let id: u32 = num.trim().parse()?; // non-numeric/empty id -> ParseError::BadId + let name = rest.trim(); + + if name.is_empty() { + // Reference: resolve the prior definition (empty if unknown; the + // normalizer maps empties to opts.unknown for files/objects). + Ok(map.get(&id).cloned().unwrap_or_default()) + } else { + map.insert(id, name.to_owned()); + Ok(name.to_owned()) + } +} + +/// First token after `calls=`, parsed as a decimal or `0x`-hex count. +fn parse_call_count(rhs: &str) -> Option { + let tok = rhs.split_whitespace().next()?; + match tok.strip_prefix("0x").or_else(|| tok.strip_prefix("0X")) { + Some(hex) => u64::from_str_radix(hex, 16).ok(), + None => tok.parse::().ok(), + } +} + +/// Build a node. The function name keeps its raw text; file and object are +/// normalized (basename + unknown handling per `opts`). Absent/empty file and +/// object default to `opts.unknown` BEFORE normalizing so that disabling +/// `normalize_paths` cannot leave a blank node key. +fn make_node( + function: Option<&str>, + file: Option<&str>, + object: Option<&str>, + opts: &ParseOptions, +) -> Node { + let or_unknown = |v: Option<&str>| { + normalize::normalize_path( + v.filter(|s| !s.is_empty()).unwrap_or(opts.unknown.as_str()), + opts, + ) + }; + let function = match function { + Some(f) if !f.is_empty() => f.to_owned(), + _ => opts.unknown.clone(), + }; + Node { + function, + file: or_unknown(file), + object: or_unknown(object), + } +} diff --git a/callgrind-utils/src/redact.rs b/callgrind-utils/src/redact.rs new file mode 100644 index 000000000..59cae400c --- /dev/null +++ b/callgrind-utils/src/redact.rs @@ -0,0 +1,132 @@ +use super::model::{CallGraph, Node}; + +const UNKNOWN: &str = "???"; + +impl CallGraph { + /// Redact host-specific node identity and rebuild the canonical graph. + pub fn redact(self) -> CallGraph { + let CallGraph { nodes, edges } = self; + let mut nodes = nodes; + let mut edges = edges; + + for node in &mut nodes { + redact_node(node); + } + + for edge in &mut edges { + redact_node(&mut edge.caller); + redact_node(&mut edge.callee); + } + + CallGraph::from_parts(nodes, edges) + } +} + +fn redact_node(node: &mut Node) { + node.object = redact_object(&node.object); + + if is_runtime_object(&node.object) { + node.function = UNKNOWN.to_string(); + node.file = UNKNOWN.to_string(); + return; + } + + node.function = redact_function(&node.function); +} + +fn redact_function(function: &str) -> String { + let function = strip_symbol_version(function); + if is_hex_address(function) { + return "".to_string(); + } + function.to_string() +} + +fn strip_symbol_version(function: &str) -> &str { + for marker in ["@@", "@"] { + let Some(index) = function.find(marker) else { + continue; + }; + let version = &function[index + marker.len()..]; + if is_symbol_version(version) { + return &function[..index]; + } + } + function +} + +fn is_symbol_version(version: &str) -> bool { + let Some(first) = version.chars().next() else { + return false; + }; + (first.is_ascii_alphanumeric() || first == '_') + && version + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') +} + +fn is_hex_address(function: &str) -> bool { + let Some(hex) = function.strip_prefix("0x") else { + return false; + }; + !hex.is_empty() && hex.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn redact_object(object: &str) -> String { + if is_loader_soname(object) { + return "ld-linux".to_string(); + } + if let Some(module) = cpython_extension_module(object) { + return format!("{module}.cpython.so"); + } + if is_libffi_soname(object) { + return "libffi.so".to_string(); + } + object.to_string() +} + +fn is_runtime_object(object: &str) -> bool { + object == "ld-linux" || is_libc_soname(object) +} + +fn is_libc_soname(object: &str) -> bool { + let Some(version) = object.strip_prefix("libc.so.") else { + return false; + }; + !version.is_empty() && version.chars().all(|c| c.is_ascii_digit()) +} + +fn cpython_extension_module(object: &str) -> Option<&str> { + let (module, suffix) = object.split_once(".cpython-")?; + let abi = suffix.strip_suffix(".so")?; + if module.is_empty() || abi.is_empty() { + return None; + } + Some(module) +} + +fn is_libffi_soname(object: &str) -> bool { + let Some(version) = object.strip_prefix("libffi.so.") else { + return false; + }; + !version.is_empty() + && version.chars().all(|c| c.is_ascii_digit() || c == '.') + && version.chars().any(|c| c.is_ascii_digit()) +} +fn is_loader_soname(object: &str) -> bool { + let Some(rest) = object.strip_prefix("ld-") else { + return false; + }; + let Some(index) = rest.find(".so.") else { + return false; + }; + + let loader_name = &rest[..index]; + let soname_version = &rest[index + ".so.".len()..]; + !loader_name.is_empty() + && loader_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + && !soname_version.is_empty() + && soname_version.chars().all(|c| c.is_ascii_digit()) +} diff --git a/callgrind-utils/src/serialize.rs b/callgrind-utils/src/serialize.rs new file mode 100644 index 000000000..b28b8816f --- /dev/null +++ b/callgrind-utils/src/serialize.rs @@ -0,0 +1,51 @@ +use serde::Serialize; + +use super::{ + error::ToJsonError, + model::{CallGraph, Node}, +}; + +/// Canonical JSON view of the whole graph: nodes inline, edges by index. +#[derive(Serialize)] +struct GraphJson<'a> { + nodes: &'a [Node], + edges: Vec, +} + +/// JSON view of a single edge: caller/callee as node indices. +/// +/// `call_count` is omitted from the output when `None`. +#[derive(Serialize)] +struct EdgeJson { + caller: usize, + callee: usize, + #[serde(skip_serializing_if = "Option::is_none")] + call_count: Option, +} + +impl CallGraph { + /// Serialize the graph to a canonical pretty-printed JSON string. + pub fn to_json(&self) -> Result { + let edges: Vec = self + .edges() + .iter() + .map(|e| EdgeJson { + caller: self.node_index(&e.caller).expect("caller node present"), + callee: self.node_index(&e.callee).expect("callee node present"), + call_count: e.call_count, + }) + .collect(); + let graph = GraphJson { + nodes: self.nodes(), + edges, + }; + serde_json::to_string_pretty(&graph) + } + + /// Serialize the graph to a JSON file at `path`. + pub fn to_json_file(&self, path: impl AsRef) -> Result<(), ToJsonError> { + let s = self.to_json()?; + std::fs::write(path, s)?; + Ok(()) + } +} diff --git a/callgrind-utils/testdata/chain.c b/callgrind-utils/testdata/chain.c new file mode 100644 index 000000000..cb9360263 --- /dev/null +++ b/callgrind-utils/testdata/chain.c @@ -0,0 +1,27 @@ +// Fixture: a linear call chain `main -> a -> b -> c` (no recursion, no shared +// callees). See recursion.c for the instrumentation/build conventions. + +#include + +static int c(int n) { + return n + 1; +} + +static int b(int n) { + return c(n) + 1; +} + +static int a(int n) { + return b(n) + 1; +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = a(5); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/testdata/clgctl.c b/callgrind-utils/testdata/clgctl.c new file mode 100644 index 000000000..3deb59341 --- /dev/null +++ b/callgrind-utils/testdata/clgctl.c @@ -0,0 +1,28 @@ +// Callgrind client-request shim for the Python fixture (`recursion.py`). +// +// The CALLGRIND_* client requests are inline-asm sequences, so they can't be +// issued from pure Python. The Python fixture loads this shared library via +// `ctypes` and calls these entry points to drive instrumentation, mirroring +// what pytest-codspeed's instrument-hooks does: skip the Python runtime objects +// at runtime, then START/ZERO around the measured region and STOP after. +// +// Build (shared, against the in-repo client-request headers): +// cc -g -O0 -shared -fPIC -I callgrind -I include ... + +#include + +// Add an object file to Callgrind's obj-skip list at runtime. Matching is exact +// against the mapped object path, so the caller passes a realpath (same as +// instrument-hooks' `callgrind_add_obj_skip`). +void clg_add_obj_skip(const char *path) { + CALLGRIND_ADD_OBJ_SKIP(path); +} + +void clg_start(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; +} + +void clg_stop(void) { + CALLGRIND_STOP_INSTRUMENTATION; +} diff --git a/callgrind-utils/testdata/diamond.c b/callgrind-utils/testdata/diamond.c new file mode 100644 index 000000000..d617b2759 --- /dev/null +++ b/callgrind-utils/testdata/diamond.c @@ -0,0 +1,32 @@ +// Fixture: a diamond graph where `bottom` is a shared callee reached via two +// paths: `main -> top -> {left, right} -> bottom`. Exercises a node with two +// distinct incoming edges. See recursion.c for the conventions. + +#include + +static int bottom(int n) { + return n * 2; +} + +static int left(int n) { + return bottom(n) + 1; +} + +static int right(int n) { + return bottom(n) + 2; +} + +static int top(int n) { + return left(n) + right(n); +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = top(5); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/testdata/mutual.c b/callgrind-utils/testdata/mutual.c new file mode 100644 index 000000000..0afc3013f --- /dev/null +++ b/callgrind-utils/testdata/mutual.c @@ -0,0 +1,26 @@ +// Fixture: mutual recursion `is_even <-> is_odd`, forming a two-function cycle +// reached from `main`. Exercises cyclic call topology. See recursion.c for the +// instrumentation/build conventions. + +#include + +static int is_odd(int n); + +static int is_even(int n) { + return n == 0 ? 1 : is_odd(n - 1); +} + +static int is_odd(int n) { + return n == 0 ? 0 : is_even(n - 1); +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = is_even(6); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/testdata/recursion.c b/callgrind-utils/testdata/recursion.c new file mode 100644 index 000000000..e27cfa00e --- /dev/null +++ b/callgrind-utils/testdata/recursion.c @@ -0,0 +1,40 @@ +// Fixture for callgrind-utils snapshot tests. +// +// A small, pure-compute call graph: direct recursion (`fib` -> `fib`) plus two +// helper edges (`compute` -> `fib`, `compute` -> `square`) under `main`. +// +// Mirrors how CodSpeed drives a benchmark: instrumentation is off at startup +// (run with `--instr-atstart=no`), so loader/libc-start frames are excluded, +// then turned on around the measured region. Build with `-g -O0` so the +// functions are real (no inlining) and carry debug names. +// +// Requires the in-repo Callgrind client-request header: +// cc -g -O0 -I callgrind -I include ... + +#include + +static int fib(int n) { + if (n < 2) { + return n; + } + return fib(n - 1) + fib(n - 2); +} + +static int square(int n) { + return n * n; +} + +static int compute(int n) { + return fib(n) + square(n); +} + +int main(void) { + CALLGRIND_START_INSTRUMENTATION; + CALLGRIND_ZERO_STATS; + + volatile int sink = compute(8); + (void)sink; + + CALLGRIND_STOP_INSTRUMENTATION; + return 0; +} diff --git a/callgrind-utils/testdata/recursion.py b/callgrind-utils/testdata/recursion.py new file mode 100644 index 000000000..b9c7f0433 --- /dev/null +++ b/callgrind-utils/testdata/recursion.py @@ -0,0 +1,59 @@ +# Python counterpart to recursion.c: the same fib/square/compute shape, driven +# the way CodSpeed drives a benchmark. Instrumentation is off at startup (run +# with --instr-atstart=no) and turned on around the measured region via the +# clgctl shim, whose compiled path is passed as argv[1]. +# +# Before starting, we skip the Python runtime objects (libpython + the python +# executable) from Callgrind at runtime, exactly as pytest-codspeed's +# instrument-hooks does in _callgrind_skip_python_runtime: the interpreter's own +# C frames are folded into their callers so they don't obfuscate the graph. +# Matching is by exact realpath, since Callgrind keys obj-skip on the mapped +# object path. +import ctypes +import os +import sys +import sysconfig + +clgctl = ctypes.CDLL(sys.argv[1]) + + +def skip_python_runtime(): + ldlibrary = sysconfig.get_config_var("LDLIBRARY") + libdir = sysconfig.get_config_var("LIBDIR") + libpython = next( + ( + p + for p in ( + os.path.join(libdir, ldlibrary) if ldlibrary and libdir else None, + os.path.join(sys.prefix, "lib", ldlibrary) if ldlibrary else None, + ) + if p and os.path.exists(p) + ), + None, + ) + for path in (libpython, sys.executable): + if path: + clgctl.clg_add_obj_skip(os.path.realpath(path).encode()) + + +def fib(n): + if n < 2: + return n + return fib(n - 1) + fib(n - 2) + + +def square(n): + return n * n + + +def compute(n): + return fib(n) + square(n) + + +skip_python_runtime() + +clgctl.clg_start() +sink = compute(8) +clgctl.clg_stop() + +assert sink == 85, sink diff --git a/callgrind-utils/tests/data/example.out b/callgrind-utils/tests/data/example.out new file mode 100644 index 000000000..fa47c84b8 --- /dev/null +++ b/callgrind-utils/tests/data/example.out @@ -0,0 +1,126 @@ +# callgrind format +version: 1 +creator: callgrind-fixture +pid: 1 +cmd: ./prog +desc: I1 cache +desc: D1 cache +positions: line +events: Ir +summary: 1000 +totals: 1000 + +# ===== Part 1 ===== +# Header / context lines: ob=, fl=, fn= define compressed IDs 1 for each space. +# Object ID 1 = /path/to/clreq ; File ID 1 = file1.c ; Function ID 1 = main. +ob=(1) /path/to/clreq +fl=(1) file1.c +fn=(1) main + +# --- main body: cost/subposition lines using +N / * / -N / 0x... (all ignored) --- +0x401000 4 ++5 8 +* 3 +-2 1 + +# --- two-line call spec: cfn=(2) func1 / calls=1 50 / cost 16 400 --- +# Defines Function ID 2 = func1. The cost line (16 400) is present but ignored. +cfn=(2) func1 +calls=1 50 +16 400 + +# --- cfl= alias equals cfi= for a callee file spec --- +# cfl=(5) cflfile.c defines File ID 5 = cflfile.c and sets the callee file. +cfl=(5) cflfile.c +cfn=cflop +calls=1 52 +18 30 + +# --- cfni= inline function line: ignored for topology (no node/edge created) --- +cfni=(7) some_inline +# --- omitted cfi/cfl: callee inherits the CURRENT file context (file1.c here) --- +cfn=nofile +calls=1 53 +19 10 + +# --- same function name in two different objects/files -> TWO distinct nodes --- +# helper in liba/fileA.c . Object ID 2 = liba ; File ID 2 = fileA.c ; Function ID 4 = helper. +cob=(2) liba +cfi=(2) fileA.c +cfn=(4) helper +calls=1 60 +20 5 +# helper in libb/fileB.c (same name, different object+file -> distinct node). +# cfn=(4) is a REFERENCE reusing Function ID 4 = helper. +cob=(3) libb +cfi=(3) fileB.c +cfn=(4) +calls=1 61 +21 5 + +# --- cob= overrides caller object (callee in extlib, file inherited from context) --- +# Object ID 4 = extlib . No cfi -> callee inherits current file (file1.c). +cob=(4) extlib +cfn=extfn +calls=1 70 +22 3 + +# --- switch caller context to func1 (fn=(2) REFERENCE -> resolves to func1) --- +fl=(1) +fn=(2) +ob=(1) +# func1 calls func2 . Defines Function ID 3 = func2 . +cfn=(3) func2 +calls=1 54 +23 100 + +# --- switch caller context to func2 (fn=(3) REFERENCE) --- +fl=(1) +fn=(3) +ob=(1) +# func2 calls rec . Defines Function ID 5 = rec . +cfn=(5) rec +calls=1 55 +24 50 +# func2 calls func1 : cfn=(2) REFERENCE resolves to func1 (name compression reuse). +cfn=(2) +calls=1 62 +25 20 + +# --- switch caller context to rec (fn=(5) REFERENCE); recursion -> self-edge --- +fl=(1) +fn=(5) +ob=(1) +cfn=(5) +calls=1 56 +26 7 + +# --- inline fi=/fe= file transition BEFORE a call with no cfi --- +# inlhost fl=file1.c . fi=(6) inline.c switches the CURRENT file context to inline.c. +# The call below has NO cfi/cfl, so the callee inherits inline.c (NOT the fl file1.c). +fl=(1) file1.c +fn=inlhost +ob=(1) +fi=(6) inline.c +cfn=inltarget +calls=1 57 +27 4 +# fe=(1) switches the current file context back to the function file (file1.c). +fe=(1) + +# ===== Part 2: multi-part merge (ID maps persist across parts) ===== +part: 2 +# References resolve via the persistent ID maps: +# fl=(1) -> file1.c , fn=(1) -> main , ob=(1) -> /path/to/clreq +fl=(1) +fn=(1) +ob=(1) +# main calls part2fn : this edge only appears in part 2 and must merge into the graph. +cfn=part2fn +calls=1 100 +29 2 +# bare cfn= with NO calls= line -> NO edge. Per cl-format.xml, CallSpec requires a +# calls= line (CallLine); cfn= alone only sets callee context and is discarded. The +# "28 1" below is a self-cost line of main (ignored). `nocnt` must NOT become a node. +cfn=nocnt +28 1 diff --git a/callgrind-utils/tests/parser.rs b/callgrind-utils/tests/parser.rs new file mode 100644 index 000000000..60652fe16 --- /dev/null +++ b/callgrind-utils/tests/parser.rs @@ -0,0 +1,314 @@ +//! Integration tests for the Callgrind `.out` -> call-graph parser. +//! +//! Exercises the real format shapes from `callgrind/docs/cl-format.xml` and +//! `callgrind/dump.c`: two-line call specs, name compression `(N)`, the +//! `cfl`/`cfi` alias, callee file/object inheritance (including inline +//! `fi`/`fe` transitions), same-named functions in distinct objects, direct +//! recursion, multi-part merge, and the canonical JSON projection. + +use callgrind_utils::model::{CallGraph, Edge, Node, ParseOptions}; +use std::io::Cursor; + +const FIXTURE: &str = include_str!("data/example.out"); + +fn parse_default() -> CallGraph { + CallGraph::parse(Cursor::new(FIXTURE), &ParseOptions::default()).expect("parse fixture") +} + +/// All edges whose caller and callee function names match. +fn edges_fn<'a>(g: &'a CallGraph, caller: &str, callee: &str) -> Vec<&'a Edge> { + g.edges() + .iter() + .filter(|e| e.caller.function == caller && e.callee.function == callee) + .collect() +} + +/// All nodes with the given function name (distinct by object/file). +fn nodes_fn<'a>(g: &'a CallGraph, function: &str) -> Vec<&'a Node> { + g.nodes() + .iter() + .filter(|n| n.function == function) + .collect() +} + +#[test] +fn parses_basic_callgraph() { + let g = parse_default(); + // 12 distinct nodes, 12 edges (see fixture; `nocnt` is discarded, no edge). + assert_eq!(g.nodes().len(), 12, "nodes: {:#?}", g.nodes()); + assert_eq!(g.edges().len(), 12, "edges: {:#?}", g.edges()); + + let mf1 = edges_fn(&g, "main", "func1"); + assert_eq!(mf1.len(), 1); + assert_eq!(mf1[0].call_count, Some(1)); + assert_eq!(mf1[0].caller.file, "file1.c"); + assert_eq!(mf1[0].callee.file, "file1.c"); +} + +#[test] +fn resolves_name_compression() { + // `fn=(1)`/`fl=(1)`/`ob=(1)` references must resolve to their defs. + let g = parse_default(); + let main = nodes_fn(&g, "main"); + assert_eq!(main.len(), 1); + assert_eq!(main[0].file, "file1.c"); + assert_eq!(main[0].object, "clreq"); + // func2 -> func1 uses `cfn=(2)` as a *reference* to the earlier def. + assert_eq!(edges_fn(&g, "func2", "func1").len(), 1); +} + +#[test] +fn cfl_alias_equals_cfi() { + // `cfl=(5) cflfile.c` is the historical alias of `cfi=`; the callee file + // must resolve to cflfile.c. + let g = parse_default(); + let e = edges_fn(&g, "main", "cflop"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].callee.file, "cflfile.c"); + assert_eq!(e[0].callee.object, "clreq"); +} + +#[test] +fn omitted_cfi_inherits_current_file_context() { + // No `cfi`/`cfl`: the callee inherits the CURRENT position file, NOT the + // caller's original `fl`. For `nofile` the context is still file1.c. + let g = parse_default(); + let e = edges_fn(&g, "main", "nofile"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].callee.file, "file1.c"); +} + +#[test] +fn inline_fi_fe_changes_callee_context_not_caller() { + // CRITICAL: after `fi=(6) inline.c`, a `cfn=` with no `cfi` makes the + // CALLEE inherit inline.c, while the CALLER (inlhost) keeps its own `fl` + // (file1.c). Pins both halves: caller file != callee file here. + let g = parse_default(); + let inlhost = nodes_fn(&g, "inlhost"); + assert_eq!(inlhost.len(), 1); + assert_eq!( + inlhost[0].file, "file1.c", + "caller keeps its fl, not the inline file" + ); + + let e = edges_fn(&g, "inlhost", "inltarget"); + assert_eq!(e.len(), 1); + assert_eq!( + e[0].callee.file, "inline.c", + "callee inherits the inline context" + ); + assert_eq!(e[0].caller.file, "file1.c"); +} + +#[test] +fn same_name_different_object_are_distinct() { + // `helper` exists in liba/fileA.c AND libb/fileB.c -> two distinct nodes, + // two distinct edges from main. + let g = parse_default(); + let helpers = nodes_fn(&g, "helper"); + assert_eq!(helpers.len(), 2, "helpers: {helpers:#?}"); + + let mut keys: Vec<(&str, &str)> = helpers + .iter() + .map(|n| (n.object.as_str(), n.file.as_str())) + .collect(); + keys.sort(); + assert_eq!(keys, vec![("liba", "fileA.c"), ("libb", "fileB.c")]); + + assert_eq!(edges_fn(&g, "main", "helper").len(), 2); +} + +#[test] +fn recursion_becomes_self_edge() { + let g = parse_default(); + let rec = edges_fn(&g, "rec", "rec"); + assert_eq!(rec.len(), 1); + assert_eq!(rec[0].caller, rec[0].callee); +} + +#[test] +fn cob_overrides_caller_object() { + // `cob=(4) extlib` with no `cfi`: callee object is extlib, file inherited + // from caller context (file1.c). + let g = parse_default(); + let e = edges_fn(&g, "main", "extfn"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].callee.object, "extlib"); + assert_eq!(e[0].callee.file, "file1.c"); + assert_eq!(e[0].caller.object, "clreq"); +} + +#[test] +fn multi_part_merged() { + // The `part: 2` section's `main -> part2fn` edge must merge into one graph. + let g = parse_default(); + assert_eq!(edges_fn(&g, "main", "part2fn").len(), 1); +} + +#[test] +fn bare_cfn_without_calls_is_discarded() { + // `cfn=nocnt` with no `calls=` line is callee context only, not a call + // record (cl-format.xml: CallSpec requires a CallLine). No node, no edge. + let g = parse_default(); + assert!(nodes_fn(&g, "nocnt").is_empty(), "nocnt must not be a node"); + assert!(edges_fn(&g, "main", "nocnt").is_empty(), "no edge to nocnt"); +} + +#[test] +fn every_edge_has_a_call_count() { + // With the calls=-required rule, every emitted edge carries Some(count). + let g = parse_default(); + for e in g.edges() { + assert!(e.call_count.is_some(), "edge {e:?} should have a count"); + } +} + +#[test] +fn costs_and_addresses_ignored() { + // Subposition/cost lines (+N, *, -N, 0x..., "16 400") never create nodes. + // Node count stays at the 12 real functions. + let g = parse_default(); + assert_eq!(g.nodes().len(), 12); + assert!(!g.nodes().iter().any(|n| n.function.starts_with("0x"))); +} + +#[test] +fn paths_normalized_by_default() { + // Default opts: object path `/path/to/clreq` -> basename `clreq`. + let g = parse_default(); + assert!(g.nodes().iter().any(|n| n.object == "clreq")); + assert!( + !g.nodes().iter().any(|n| n.object.contains('/')), + "no object should retain a path separator" + ); +} + +#[test] +fn paths_verbatim_when_normalization_off() { + let opts = ParseOptions { + normalize_paths: false, + ..Default::default() + }; + let g = CallGraph::parse(Cursor::new(FIXTURE), &opts).expect("parse"); + assert!( + g.nodes().iter().any(|n| n.object == "/path/to/clreq"), + "object path must be kept verbatim: {:#?}", + g.nodes() + ); +} + +#[test] +fn to_json_is_canonical() { + let g = parse_default(); + let json = g.to_json().expect("to_json"); + let v: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + + let nodes = v["nodes"].as_array().expect("nodes array"); + let edges = v["edges"].as_array().expect("edges array"); + assert_eq!(nodes.len(), 12); + assert_eq!(edges.len(), 12); + + // Nodes sorted by (object, file, function). + let key = |n: &serde_json::Value| { + ( + n["object"].as_str().unwrap().to_owned(), + n["file"].as_str().unwrap().to_owned(), + n["function"].as_str().unwrap().to_owned(), + ) + }; + let mut sorted = nodes.clone(); + sorted.sort_by_key(key); + assert_eq!(nodes, &sorted, "nodes must be pre-sorted"); + + // Edges reference nodes by valid index; call_count present (never None here). + for e in edges { + let c = e["caller"].as_u64().unwrap() as usize; + let d = e["callee"].as_u64().unwrap() as usize; + assert!( + c < nodes.len() && d < nodes.len(), + "edge index out of range" + ); + assert!( + e.get("call_count").is_some(), + "call_count present for fixture edges" + ); + } + + // Edges sorted by (caller_idx, callee_idx). + let pairs: Vec<(u64, u64)> = edges + .iter() + .map(|e| (e["caller"].as_u64().unwrap(), e["callee"].as_u64().unwrap())) + .collect(); + let mut sorted_pairs = pairs.clone(); + sorted_pairs.sort(); + assert_eq!( + pairs, sorted_pairs, + "edges must be pre-sorted by index pair" + ); +} + +#[test] +fn to_json_omits_none_call_count() { + // Construct via parse, then confirm the serializer would omit a None count + // by checking the field is absent only when the value is None. All fixture + // edges have Some, so every edge object must carry call_count. + let g = parse_default(); + let json = g.to_json().expect("to_json"); + let v: serde_json::Value = serde_json::from_str(&json).unwrap(); + for e in v["edges"].as_array().unwrap() { + assert!(e.get("call_count").is_some()); + } +} + +#[test] +fn bare_cfn_does_not_poison_next_edge() { + // A bare `cfn=unused` (cleared by the following self-cost line) must not + // become the callee of a later `calls=` that has its own `cfn=`. + let out = "\ +# callgrind format +events: Ir +ob=(1) prog +fl=(1) a.c +fn=(1) caller +cfn=(2) unused +5 3 +cfn=(3) realcallee +calls=2 10 +6 4 +"; + let g = CallGraph::parse(Cursor::new(out), &ParseOptions::default()).expect("parse"); + assert!( + nodes_fn(&g, "unused").is_empty(), + "bare cfn must be discarded" + ); + let e = edges_fn(&g, "caller", "realcallee"); + assert_eq!(e.len(), 1); + assert_eq!(e[0].call_count, Some(2)); + assert!(edges_fn(&g, "caller", "unused").is_empty()); +} + +#[test] +fn bare_cfn_does_not_survive_jump_line() { + // A `jump=`/`jcnd=` line between a bare `cfn=` and a `calls=` must clear the + // pending callee, so the `calls=` (lacking its own `cfn=`) emits no edge. + let out = "\ +# callgrind format +events: Ir +ob=(1) prog +fl=(1) a.c +fn=(1) caller +cfn=(2) unused +jump=3 10 +calls=2 11 +6 4 +"; + let g = CallGraph::parse(Cursor::new(out), &ParseOptions::default()).expect("parse"); + assert!( + nodes_fn(&g, "unused").is_empty(), + "jump must clear the pending cfn" + ); + assert!( + g.edges().is_empty(), + "calls= had no live cfn after the jump -> no edge" + ); +} diff --git a/callgrind-utils/tests/python_callgraph.rs b/callgrind-utils/tests/python_callgraph.rs new file mode 100644 index 000000000..d4f970243 --- /dev/null +++ b/callgrind-utils/tests/python_callgraph.rs @@ -0,0 +1,145 @@ +//! Topology-only snapshot of the Python fixture's call graph. +//! +//! Mirrors `snapshot.rs` for the C fixtures: profile `testdata/recursion.py` +//! live under the in-repo Callgrind, parse, and snapshot a topology-only +//! view (nodes + caller/callee indices, no `call_count`; see below). +//! +//! Callgrind records the CPython interpreter's C frames, not the Python +//! functions: the interpreter loop is obj-skipped at runtime via the `clgctl` +//! shim's `CALLGRIND_ADD_OBJ_SKIP`, so what remains is the ctypes/libffi/libc +//! C-residual around the `clg_start`/`clg_stop` shim. The graph shape +//! (nodes + caller/callee indices) is stable after `CallGraph::redact()`: libc/ld +//! debug-derived fields collapse to `???`, and CPython extension / libffi object +//! suffixes are normalized. `call_count` on the seed-reconstructed residual edges +//! drifts run-to-run (loader/PLT timing), so it is stripped and the snapshot is +//! topology-only. +//! +//! Requires a built `./vg-in-place` at the repo root and `cc`. Silently skips +//! when `python3` is not on PATH (mirrors the `.vgtest` `prereq` guards). +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use callgrind_utils::model::{CallGraph, Node, ParseOptions}; + +/// Repo root: this crate lives at `/callgrind-utils`. +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crate has a parent directory") + .to_path_buf() +} + +fn vg_in_place() -> PathBuf { + let path = repo_root().join("vg-in-place"); + assert!( + path.is_file(), + "vg-in-place not found at {} - build Valgrind in place first", + path.display() + ); + path +} + +fn have_python3() -> bool { + Command::new("python3") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + +/// Compile the Callgrind client-request shim the Python fixture loads via +/// `ctypes`, as a shared library against the in-repo `callgrind.h`. +fn compile_clgctl() -> PathBuf { + let repo = repo_root(); + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/clgctl.c"); + let lib = Path::new(env!("CARGO_TARGET_TMPDIR")).join("libclgctl.so"); + + let status = Command::new("cc") + .args(["-g", "-O0", "-shared", "-fPIC"]) + .arg("-I") + .arg(repo.join("callgrind")) + .arg("-I") + .arg(repo.join("include")) + .arg("-o") + .arg(&lib) + .arg(&src) + .status() + .unwrap_or_else(|e| panic!("failed to spawn cc for clgctl: {e}")); + assert!( + status.success(), + "cc failed for {} ({status})", + src.display() + ); + lib +} + +/// Profile `testdata/recursion.py` with the in-repo Callgrind and return the +/// `.out` contents. `--instr-atstart=no` pairs with the shim's client requests +/// so only the measured region is profiled; the fixture adds the obj-skips +/// itself, so no `--obj-skip` is passed on the command line. +fn run_python(clgctl: &Path) -> String { + let script = Path::new(env!("CARGO_MANIFEST_DIR")).join("testdata/recursion.py"); + let out_file = Path::new(env!("CARGO_TARGET_TMPDIR")).join("python.callgrind.out"); + + let status = Command::new(vg_in_place()) + .arg("--tool=callgrind") + .arg("--instr-atstart=no") + .arg(format!("--callgrind-out-file={}", out_file.display())) + .arg("python3") + .arg(&script) + .arg(clgctl) + .status() + .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); + assert!(status.success(), "vg-in-place exited with {status}"); + std::fs::read_to_string(&out_file) + .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) +} + +/// Topology-only JSON view: `nodes` then `edges` by index, no `call_count`. +#[derive(serde::Serialize)] +struct TopologyEdge { + caller: usize, + callee: usize, +} + +#[derive(serde::Serialize)] +struct TopologyGraph<'a> { + nodes: &'a [Node], + edges: Vec, +} + +#[test] +fn python_topology_json() { + if !have_python3() { + eprintln!("skipping python_topology_json: python3 not on PATH"); + return; + } + + let clgctl = compile_clgctl(); + let raw = run_python(&clgctl); + let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) + .unwrap_or_else(|e| panic!("parse python callgrind output: {e:?}")) + .redact(); + let nodes = graph.nodes(); + let topology = TopologyGraph { + nodes, + edges: graph + .edges() + .iter() + .map(|e| TopologyEdge { + caller: nodes + .iter() + .position(|x| x == &e.caller) + .expect("caller node present"), + callee: nodes + .iter() + .position(|x| x == &e.callee) + .expect("callee node present"), + }) + .collect(), + }; + let json = serde_json::to_string_pretty(&topology).expect("serialize"); + + insta::assert_snapshot!("recursion_py__topology_json", json); +} diff --git a/callgrind-utils/tests/snapshot.rs b/callgrind-utils/tests/snapshot.rs new file mode 100644 index 000000000..ed9370d9c --- /dev/null +++ b/callgrind-utils/tests/snapshot.rs @@ -0,0 +1,138 @@ +//! Golden snapshot tests over the `testdata/*.c` fixtures. +//! +//! Each case compiles its fixture and profiles it with the in-repo Callgrind +//! (`vg-in-place`, expected at the repo root), then snapshots the parsed +//! canonical JSON. The fixtures run with +//! `--instr-atstart=no` (plus client requests) and `--obj-skip`, so the graph +//! is just their own functions and the JSON is stable across platforms. +//! +//! These tests require a built `./vg-in-place` at the repo root. +use std::io::Cursor; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use callgrind_utils::model::{CallGraph, ParseOptions}; +use rstest::rstest; + +/// Repo root: this crate lives at `/callgrind-utils`. +fn repo_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("crate has a parent directory") + .to_path_buf() +} + +fn vg_in_place() -> PathBuf { + let path = repo_root().join("vg-in-place"); + assert!( + path.is_file(), + "vg-in-place not found at {} - build Valgrind in place first", + path.display() + ); + path +} + +/// Compile `testdata/.c` into this test binary's temp dir. `-O0` keeps the +/// functions un-inlined and `-g` gives them debug names; `callgrind.h` pulls in +/// `valgrind.h` via `-I include`. +fn compile_fixture(stem: &str) -> PathBuf { + let repo = repo_root(); + let src = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("testdata") + .join(format!("{stem}.c")); + let bin = Path::new(env!("CARGO_TARGET_TMPDIR")).join(stem); + + let status = Command::new("cc") + .args(["-g", "-O0"]) + .arg("-I") + .arg(repo.join("callgrind")) + .arg("-I") + .arg(repo.join("include")) + .arg("-o") + .arg(&bin) + .arg(&src) + .status() + .unwrap_or_else(|e| panic!("failed to spawn cc for {stem}: {e}")); + assert!( + status.success(), + "cc failed for {} ({status})", + src.display() + ); + bin +} + +/// Profile `bin` with the in-repo Callgrind and return the `.out` contents. +/// +/// `--instr-atstart=no` (paired with the fixture's client requests) excludes the +/// loader/libc-start frames; `--obj-skip` drops the libc/ld frames the shadow- +/// stack seeder reconstructs, leaving just the fixture's own functions. +fn run_callgrind(bin: &Path) -> String { + let out_file = bin.with_extension("callgrind.out"); + let status = Command::new(vg_in_place()) + .arg("--tool=callgrind") + .arg("--instr-atstart=no") + .arg(format!("--callgrind-out-file={}", out_file.display())) + .arg(bin) + .status() + .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); + assert!(status.success()); + std::fs::read_to_string(&out_file) + .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) +} + +#[rstest] +#[case("recursion")] +#[case("chain")] +#[case("diamond")] +#[case("mutual")] +fn fixture_canonical_json(#[case] stem: &str) { + let bin = compile_fixture(stem); + let raw = run_callgrind(&bin); + let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) + .unwrap_or_else(|e| panic!("parse {stem} callgrind output: {e:?}")) + .redact(); + let json = graph.to_json().expect("to_json"); + + insta::assert_snapshot!(format!("{stem}__json"), json); +} + +/// Profile `bin` with Callgrind instrumenting from process start +/// (`--instr-atstart=yes`). +/// +/// Unlike `run_callgrind`, this captures the whole program: the loader, libc +/// startup, and `main`'s own entry, not just the client-request-scoped compute +/// region. The fixture's `CALLGRIND_ZERO_STATS` still zeroes the pre-`main` +/// edges (they end up with `calls=0`), but `main` now appears as a *callee* of +/// the startup frames — the defining difference from the scoped run. +/// +/// No `--obj-skip` here: we want the startup frames present so the +/// full-program-capture invariant is observable. +fn run_callgrind_full(bin: &Path) -> String { + let out_file = bin.with_extension("full.callgrind.out"); + let status = Command::new(vg_in_place()) + .arg("--tool=callgrind") + .arg("--instr-atstart=yes") + .arg(format!("--callgrind-out-file={}", out_file.display())) + .arg(bin) + .status() + .unwrap_or_else(|e| panic!("failed to spawn vg-in-place: {e}")); + assert!(status.success()); + std::fs::read_to_string(&out_file) + .unwrap_or_else(|e| panic!("read {}: {e}", out_file.display())) +} + +#[rstest] +#[case("recursion")] +#[case("chain")] +#[case("diamond")] +#[case("mutual")] +fn fixture_full_trace(#[case] stem: &str) { + let bin = compile_fixture(stem); + let raw = run_callgrind_full(&bin); + let graph = CallGraph::parse(Cursor::new(raw.as_str()), &ParseOptions::default()) + .unwrap_or_else(|e| panic!("parse {stem} full callgrind output: {e:?}")) + .redact(); + let json = graph.to_json().expect("to_json"); + + insta::assert_snapshot!(format!("{stem}_full__json"), json); +} diff --git a/callgrind-utils/tests/snapshots/python_callgraph__recursion_py__topology_json.snap b/callgrind-utils/tests/snapshots/python_callgraph__recursion_py__topology_json.snap new file mode 100644 index 000000000..4c2ecb67a --- /dev/null +++ b/callgrind-utils/tests/snapshots/python_callgraph__recursion_py__topology_json.snap @@ -0,0 +1,213 @@ +--- +source: tests/python_callgraph.rs +expression: json +--- +{ + "nodes": [ + { + "function": "KeepRef", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "PyCFuncPtr_call'2", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "PyCFuncPtr_new", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_ctypes_callproc'2", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_ctypes_get_fielddesc", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_get_name", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "_validate_paramflags", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "i_get", + "file": "???", + "object": "_ctypes.cpython.so" + }, + { + "function": "???", + "file": "???", + "object": "ld-linux" + }, + { + "function": "???", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "clg_start", + "file": "clgctl.c", + "object": "libclgctl.so" + }, + { + "function": "ffi_call", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call'2", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_int", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_int'2", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_unix64", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_call_unix64'2", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_prep_cif", + "file": "???", + "object": "libffi.so" + }, + { + "function": "ffi_prep_cif_machdep", + "file": "???", + "object": "libffi.so" + } + ], + "edges": [ + { + "caller": 0, + "callee": 8 + }, + { + "caller": 0, + "callee": 9 + }, + { + "caller": 1, + "callee": 3 + }, + { + "caller": 2, + "callee": 0 + }, + { + "caller": 2, + "callee": 5 + }, + { + "caller": 2, + "callee": 6 + }, + { + "caller": 2, + "callee": 8 + }, + { + "caller": 2, + "callee": 9 + }, + { + "caller": 3, + "callee": 8 + }, + { + "caller": 3, + "callee": 9 + }, + { + "caller": 3, + "callee": 12 + }, + { + "caller": 3, + "callee": 17 + }, + { + "caller": 8, + "callee": 8 + }, + { + "caller": 8, + "callee": 9 + }, + { + "caller": 9, + "callee": 1 + }, + { + "caller": 9, + "callee": 2 + }, + { + "caller": 9, + "callee": 8 + }, + { + "caller": 9, + "callee": 9 + }, + { + "caller": 10, + "callee": 15 + }, + { + "caller": 11, + "callee": 4 + }, + { + "caller": 11, + "callee": 7 + }, + { + "caller": 11, + "callee": 8 + }, + { + "caller": 11, + "callee": 9 + }, + { + "caller": 12, + "callee": 14 + }, + { + "caller": 14, + "callee": 16 + }, + { + "caller": 15, + "callee": 13 + }, + { + "caller": 17, + "callee": 18 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__chain__json.snap b/callgrind-utils/tests/snapshots/snapshot__chain__json.snap new file mode 100644 index 000000000..a83ab0a98 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__chain__json.snap @@ -0,0 +1,45 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "a", + "file": "chain.c", + "object": "chain" + }, + { + "function": "b", + "file": "chain.c", + "object": "chain" + }, + { + "function": "c", + "file": "chain.c", + "object": "chain" + }, + { + "function": "main", + "file": "chain.c", + "object": "chain" + } + ], + "edges": [ + { + "caller": 0, + "callee": 1, + "call_count": 1 + }, + { + "caller": 1, + "callee": 2, + "call_count": 1 + }, + { + "caller": 3, + "callee": 0, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap new file mode 100644 index 000000000..bbf052b7c --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__chain_full__json.snap @@ -0,0 +1,80 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "(below main)", + "file": "???", + "object": "chain" + }, + { + "function": "a", + "file": "chain.c", + "object": "chain" + }, + { + "function": "b", + "file": "chain.c", + "object": "chain" + }, + { + "function": "c", + "file": "chain.c", + "object": "chain" + }, + { + "function": "main", + "file": "chain.c", + "object": "chain" + }, + { + "function": "???", + "file": "???", + "object": "ld-linux" + }, + { + "function": "???", + "file": "???", + "object": "libc.so.6" + } + ], + "edges": [ + { + "caller": 0, + "callee": 6, + "call_count": 0 + }, + { + "caller": 1, + "callee": 2, + "call_count": 1 + }, + { + "caller": 2, + "callee": 3, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 5, + "callee": 0, + "call_count": 0 + }, + { + "caller": 6, + "callee": 4, + "call_count": 0 + }, + { + "caller": 6, + "callee": 6, + "call_count": 0 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__diamond__json.snap b/callgrind-utils/tests/snapshots/snapshot__diamond__json.snap new file mode 100644 index 000000000..f6a366448 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__diamond__json.snap @@ -0,0 +1,60 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "bottom", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "left", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "main", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "right", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "top", + "file": "diamond.c", + "object": "diamond" + } + ], + "edges": [ + { + "caller": 1, + "callee": 0, + "call_count": 1 + }, + { + "caller": 2, + "callee": 4, + "call_count": 1 + }, + { + "caller": 3, + "callee": 0, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 4, + "callee": 3, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap new file mode 100644 index 000000000..6f7d63089 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__diamond_full__json.snap @@ -0,0 +1,95 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "(below main)", + "file": "???", + "object": "diamond" + }, + { + "function": "bottom", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "left", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "main", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "right", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "top", + "file": "diamond.c", + "object": "diamond" + }, + { + "function": "???", + "file": "???", + "object": "ld-linux" + }, + { + "function": "???", + "file": "???", + "object": "libc.so.6" + } + ], + "edges": [ + { + "caller": 0, + "callee": 7, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 1 + }, + { + "caller": 3, + "callee": 5, + "call_count": 1 + }, + { + "caller": 4, + "callee": 1, + "call_count": 1 + }, + { + "caller": 5, + "callee": 2, + "call_count": 1 + }, + { + "caller": 5, + "callee": 4, + "call_count": 1 + }, + { + "caller": 6, + "callee": 0, + "call_count": 0 + }, + { + "caller": 7, + "callee": 3, + "call_count": 0 + }, + { + "caller": 7, + "callee": 7, + "call_count": 0 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__mutual__json.snap b/callgrind-utils/tests/snapshots/snapshot__mutual__json.snap new file mode 100644 index 000000000..ccffb29a7 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__mutual__json.snap @@ -0,0 +1,60 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "is_even", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_even'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "main", + "file": "mutual.c", + "object": "mutual" + } + ], + "edges": [ + { + "caller": 0, + "callee": 2, + "call_count": 1 + }, + { + "caller": 1, + "callee": 3, + "call_count": 2 + }, + { + "caller": 2, + "callee": 1, + "call_count": 1 + }, + { + "caller": 3, + "callee": 1, + "call_count": 2 + }, + { + "caller": 4, + "callee": 0, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap new file mode 100644 index 000000000..240f6abbf --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__mutual_full__json.snap @@ -0,0 +1,95 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "???", + "file": "???", + "object": "ld-linux" + }, + { + "function": "???", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "???", + "object": "mutual" + }, + { + "function": "is_even", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_even'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "is_odd'2", + "file": "mutual.c", + "object": "mutual" + }, + { + "function": "main", + "file": "mutual.c", + "object": "mutual" + } + ], + "edges": [ + { + "caller": 0, + "callee": 2, + "call_count": 0 + }, + { + "caller": 1, + "callee": 1, + "call_count": 0 + }, + { + "caller": 1, + "callee": 7, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 0 + }, + { + "caller": 3, + "callee": 5, + "call_count": 1 + }, + { + "caller": 4, + "callee": 6, + "call_count": 2 + }, + { + "caller": 5, + "callee": 4, + "call_count": 1 + }, + { + "caller": 6, + "callee": 4, + "call_count": 2 + }, + { + "caller": 7, + "callee": 3, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__recursion__json.snap b/callgrind-utils/tests/snapshots/snapshot__recursion__json.snap new file mode 100644 index 000000000..8ba0bf9a9 --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__recursion__json.snap @@ -0,0 +1,60 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "compute", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib'2", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "main", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "square", + "file": "recursion.c", + "object": "recursion" + } + ], + "edges": [ + { + "caller": 0, + "callee": 1, + "call_count": 1 + }, + { + "caller": 0, + "callee": 4, + "call_count": 1 + }, + { + "caller": 1, + "callee": 2, + "call_count": 2 + }, + { + "caller": 2, + "callee": 2, + "call_count": 64 + }, + { + "caller": 3, + "callee": 0, + "call_count": 1 + } + ] +} diff --git a/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap new file mode 100644 index 000000000..7cd365d4d --- /dev/null +++ b/callgrind-utils/tests/snapshots/snapshot__recursion_full__json.snap @@ -0,0 +1,95 @@ +--- +source: tests/snapshot.rs +expression: json +--- +{ + "nodes": [ + { + "function": "???", + "file": "???", + "object": "ld-linux" + }, + { + "function": "???", + "file": "???", + "object": "libc.so.6" + }, + { + "function": "(below main)", + "file": "???", + "object": "recursion" + }, + { + "function": "compute", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "fib'2", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "main", + "file": "recursion.c", + "object": "recursion" + }, + { + "function": "square", + "file": "recursion.c", + "object": "recursion" + } + ], + "edges": [ + { + "caller": 0, + "callee": 2, + "call_count": 0 + }, + { + "caller": 1, + "callee": 1, + "call_count": 0 + }, + { + "caller": 1, + "callee": 6, + "call_count": 0 + }, + { + "caller": 2, + "callee": 1, + "call_count": 0 + }, + { + "caller": 3, + "callee": 4, + "call_count": 1 + }, + { + "caller": 3, + "callee": 7, + "call_count": 1 + }, + { + "caller": 4, + "callee": 5, + "call_count": 2 + }, + { + "caller": 5, + "callee": 5, + "call_count": 64 + }, + { + "caller": 6, + "callee": 3, + "call_count": 1 + } + ] +} diff --git a/callgrind/bbcc.c b/callgrind/bbcc.c index 9b08923d3..72754a1c0 100644 --- a/callgrind/bbcc.c +++ b/callgrind/bbcc.c @@ -653,34 +653,38 @@ void CLG_(setup_bbcc)(BB* bb) /* Manipulate JmpKind if needed, only using BB specific info */ csp = CLG_(current_call_stack).sp; - /* A return not matching the top call in our callstack is a jump */ if ( (jmpkind == jk_Return) && (csp >0)) { Int csp_up = csp-1; call_entry* top_ce = &(CLG_(current_call_stack).entry[csp_up]); - /* We have a real return if - * - the stack pointer (SP) left the current stack frame, or - * - SP has the same value as when reaching the current function - * and the address of this BB is the return address of last call - * (we even allow to leave multiple frames if the SP stays the - * same and we find a matching return address) - * The latter condition is needed because on PPC, SP can stay - * the same over CALL=b(c)l / RET=b(c)lr boundaries + /* We have a real return if the stack pointer (SP) left the + * current stack frame. If the return target matches an older + * shadow-stack frame whose entry SP is also outside the current + * native frame, unwind all intervening frames. This happens in + * hand-written startup/loader code that can collapse multiple native + * frames before returning to a saved link register. + * + * If SP is unchanged, require the return target to match the recorded + * return address. This is needed because on PPC, SP can stay the same + * over CALL=b(c)l / RET=b(c)lr boundaries. */ - if (sp < top_ce->sp) popcount_on_return = 0; - else if (top_ce->sp == sp) { + if (sp < top_ce->sp) { + popcount_on_return = 0; + } + else { + Bool sp_changed = top_ce->sp != sp; while(1) { if (top_ce->ret_addr == bb_addr(bb)) break; if (csp_up>0) { csp_up--; top_ce = &(CLG_(current_call_stack).entry[csp_up]); - if (top_ce->sp == sp) { + if (top_ce->sp <= sp) { popcount_on_return++; - continue; + continue; } } - popcount_on_return = 0; + popcount_on_return = sp_changed ? 1 : 0; break; } } diff --git a/callgrind/main.c b/callgrind/main.c index b0f910e67..6394ee0fd 100644 --- a/callgrind/main.c +++ b/callgrind/main.c @@ -1471,8 +1471,17 @@ static void zero_state_cost(thread_info* t) { CLG_(zero_cost)( CLG_(sets).full, CLG_(current_state).cost ); + CLG_(zero_cost)( CLG_(sets).full, t->lastdump_cost ); } +static +void sync_lastdump_cost(thread_info* t) +{ + CLG_(copy_cost)( CLG_(sets).full, t->lastdump_cost, + CLG_(current_state).cost ); +} + + void CLG_(set_instrument_state)(const HChar* reason, Bool state) { if (CLG_(instrument_state) == state) { @@ -1486,9 +1495,15 @@ void CLG_(set_instrument_state)(const HChar* reason, Bool state) VG_(discard_translations_safely)( (Addr)0x1000, ~(SizeT)0xfff, "callgrind"); - /* reset internal state: call stacks, simulator */ + /* Reset internal state: call stacks, simulator. Switching collection off + * leaves already materialized BBCC/JCC costs for the final dump, but the + * live event counter no longer tracks a complete interval after collection + * stops inside a BB. Mark the live counter as already summarized so readers + * use the dumped totals instead of a stale/partial header summary. The next + * ON transition starts a fresh interval by clearing both current and + * last-dump counters. */ CLG_(forall_threads)(unwind_thread); - CLG_(forall_threads)(zero_state_cost); + CLG_(forall_threads)(state ? zero_state_cost : sync_lastdump_cost); (*CLG_(cachesim).clear)(); if (VG_(clo_verbosity) > 1)