Skip to content

feat: replace PROC_INFO HashMap with EXEC_MAPPINGS LPM trie#80

Merged
zz85 merged 6 commits intomainfrom
lpm-trie-mappings
Feb 25, 2026
Merged

feat: replace PROC_INFO HashMap with EXEC_MAPPINGS LPM trie#80
zz85 merged 6 commits intomainfrom
lpm-trie-mappings

Conversation

@zz85
Copy link
Owner

@zz85 zz85 commented Feb 22, 2026

Summary by CodeRabbit

  • New Features
    • LPM trie for exec-mapping lookups (scalable address→mapping resolution, up to 200K entries); rebuilt per-process mappings on refresh. Improved DWARF background messaging and process-exit handling, including optional external PID stop-on-exit semantics.
  • Bug Fixes
    • Fixed address-range overflow near u64::MAX, improved mapping-load logging, added alignment/safety checks, and preserved frame-pointer fallback on lookup misses.
  • Tests
    • Added unit tests for address-range edge cases.
  • Documentation
    • Updated DWARF unwinding design to reflect LPM model.
  • Chores
    • Bumped packages to v0.3.5.

@coderabbitai
Copy link

coderabbitai bot commented Feb 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

This PR replaces per-process PROC_INFO with an LPM-trie-backed EXEC_MAPPINGS keyed by (tgid, address), adds ExecMappingKey, increases max trie capacity, updates DWARF refresh/population and per-frame unwind flows to use LPM lookups, and adds address-range summarization plus tests.

Changes

Cohort / File(s) Summary
Common types
profile-bee-common/src/lib.rs
Added MAX_EXEC_MAPPING_ENTRIES, ExecMappingKey (#[repr(C)] with explicit padding) and EXEC_MAPPING_KEY_BITS; docs note big-endian storage and alignment considerations.
eBPF maps & integration
profile-bee-ebpf/src/lib.rs, profile-bee/src/ebpf.rs
Replaced PROC_INFO HashMap with EXEC_MAPPINGS: LpmTrie<ExecMappingKey, ExecMapping> and added DWARF_TGIDS; introduced ExecMappingKeyPod/ExecMappingPod; population now inserts LPM keys per summarized address block and logs total entries and dwarf tgids.
CLI / refresh logic
profile-bee/bin/profile-bee.rs
Replaced per-process ProcInfo payloads with MappingsDiff and added DwarfThreadMsg (LoadProcess / ProcessExited); updated channels, DWARF refresh handling, monitor-exit flows, and propagation of mapping diffs to eBPF.
Unwind manager & utilities
profile-bee/src/dwarf_unwind.rs
Replaced proc_info with proc_mappings: HashMap<u32, Vec<ExecMapping>>; added AddressBlockRange and summarize_address_range(low, high) (u128-safe); added MappingsDiff and remove_process; rebuilds mappings on refresh; added tests and X86_64_RA constant.
Unwind flow API & docs
docs/tail_call_unwinding.md, docs/dwarf_unwinding_design.md
Updated design and APIs to reflect per-(tgid,IP) LPM lookup; changed dwarf_unwind_one_frame signature to drop proc_info; documented new maps: exec_mappings, unwind_shards, and dwarf_tgids.
Tests, changelog & manifests
profile-bee/tests/dwarf_unit.rs, CHANGELOG.md, profile-bee-common/Cargo.toml, profile-bee-tui/Cargo.toml, profile-bee/Cargo.toml
Updated unit tests to use proc_mappings and added summarize_address_range tests; added v0.3.5 changelog entry; bumped crate/package versions to 0.3.5 and updated local dependency versions.

Sequence Diagram

sequenceDiagram
    participant Manager as DwarfUnwindManager
    participant Refresh as DWARF Refresh
    participant BPF as eBPF (EXEC_MAPPINGS LPM)
    participant Unwind as DWARF Unwinder

    Manager->>Manager: scan_and_update(tgid) -> Vec<ExecMapping>
    Manager->>Refresh: emit MappingsDiff / mapping_diffs
    Refresh->>BPF: send mapping_diffs -> update exec_mappings & dwarf_tgids
    BPF->>BPF: for each ExecMapping -> summarize_address_range() -> insert LpmKey(tgid, addr_block)

    Unwind->>BPF: lookup EXEC_MAPPINGS LpmKey(tgid, current_ip)
    BPF-->>Unwind: ExecMapping (hit) / miss
    alt mapping found
        Unwind->>Unwind: use DWARF tables (shard_id, load_bias)
    else mapping miss
        Unwind->>Unwind: fallback to frame-pointer unwinding
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

Poem

🐰 I split the ranges into snug power-of-two dens,
Packed keys with tgid and addresses like pens.
Tries hop longest-prefix to point each IP home,
When a path is missed, FP unwinds through the loam.
Hooray — more hops, fewer stalls, and maps that roam.

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely describes the main architectural change: replacing a HashMap-based PROC_INFO map with an LPM trie-based EXEC_MAPPINGS map.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch lpm-trie-mappings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
profile-bee/src/dwarf_unwind.rs (1)

521-741: ⚠️ Potential issue | 🟠 Major

Refresh can retain stale mappings when ranges are reused

The refresh path clones existing mappings and skips any mapping with the same (begin, end). If a library is dlclosed and another is mapped at the same range (or a PID is reused), you keep the old shard_id/load_bias/table_count, so LPM lookups return stale unwind data. Consider rebuilding the mappings list from scratch each scan (caches still prevent expensive re-parsing) or replacing entries when ranges match but metadata differs.

🔧 Rebuild mappings each scan to avoid stale entries
-        let existing = self.proc_mappings.get(&tgid);
-        let existing_ranges: Vec<(u64, u64)> = existing
-            .map(|mappings| mappings.iter().map(|m| (m.begin, m.end)).collect())
-            .unwrap_or_default();
-
-        let mut mappings: Vec<ExecMapping> = existing.cloned().unwrap_or_default();
+        let mut mappings: Vec<ExecMapping> = Vec::new();
...
-            // Skip mappings we already have
-            if existing_ranges
-                .iter()
-                .any(|&(b, e)| b == start_addr && e == end_addr)
-            {
-                continue;
-            }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@profile-bee/src/dwarf_unwind.rs` around lines 521 - 741, The current refresh
clones existing mappings (existing / existing_ranges / mappings) and skips any
map with the same (begin,end), which can retain stale
shard_id/load_bias/table_count when ranges get reused; instead rebuild the
mappings list from scratch each scan: stop cloning existing into mappings and
remove the early skip that checks existing_ranges.iter().any(|&(b,e)|
b==start_addr && e==end_addr), always compute load_bias/shard_id/table_count for
each executable mapping (caches like metadata_cache/binary_cache/path_cache
still used to avoid expensive parsing), then push into mappings and finally
self.proc_mappings.insert(tgid, mappings) as before.
🧹 Nitpick comments (1)
profile-bee/bin/profile-bee.rs (1)

1150-1150: Consider avoiding full proc_mappings clones on every refresh

send_refresh clones all mappings on each update. With many tracked PIDs, this can become a noticeable allocation and CPU hotspot (the refresh loop runs every ~1s). Consider sending only the updated TGIDs/mappings or a diff set to reduce cloning.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@profile-bee/bin/profile-bee.rs` at line 1150, The current send_refresh path
clones the entire manager.proc_mappings into proc_mappings
(manager.proc_mappings.iter().map(|(&t, m)| (t, m.clone())).collect()), which is
expensive; instead, modify send_refresh to only clone and send changed
TGIDs/mappings by tracking updates (e.g., add a dirty set or per-TGID version in
the manager) and iterate that set to collect Vec<(u32, Vec<ExecMapping>)> of
deltas, or switch manager.proc_mappings values to Arc<Vec<ExecMapping>>/Rc so
only cheap reference clones are done; update the code that consumes
proc_mappings to accept diffs or Arced mappings and ensure symbol names
referenced are manager.proc_mappings, proc_mappings, ExecMapping, TGID, and the
send_refresh function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@profile-bee-common/src/lib.rs`:
- Around line 123-143: The ExecMappingKey struct is currently #[repr(C, packed)]
causing unaligned 64-bit access; change it to #[repr(C)] and add an explicit
4-byte padding field (e.g., pub _pad: u32) between tgid and address so address
is 8-byte aligned while preserving big-endian semantics and the existing derives
(Copy, Clone, Debug, Eq, PartialEq, Hash); update the struct declaration for
ExecMappingKey accordingly and then rebuild the eBPF and userspace artifacts.

In `@profile-bee/bin/profile-bee.rs`:
- Around line 1213-1240: Replace the silent discard of trie.insert results with
explicit error handling: instead of using "let _ = trie.insert(...)" in the loop
that iterates update.proc_mappings and
profile_bee::dwarf_unwind::summarize_address_range, match or if let Err(e) =
trie.insert(...) and emit a clear log (include tgid, mapping.begin/mapping.end,
block.addr and block.prefix_len, and the error) so map-full or other failures
are visible; keep inserting on success and decide whether to continue or abort
based on the returned error (preferably continue after logging to attempt other
entries).

In `@profile-bee/src/dwarf_unwind.rs`:
- Around line 79-105: The loop in summarize_address_range can overflow when
computing (high - curr + 1); change the range-length computation to use u128 and
a saturating subtraction: compute let range_len = (high as
u128).saturating_sub(curr as u128).saturating_add(1) and derive the bits from
range_len.leading_zeros() (adjusting the same -1/64 arithmetic as before), then
compute number_of_bits = std::cmp::min(curr.trailing_zeros(), /*bits derived
from range_len*/); keep curr as u64 and advance it with curr =
curr.wrapping_add(1u64 << number_of_bits) (or curr += 1 << number_of_bits) and
preserve the existing u64 wrap/stop check; this ensures no wrap when high is
near u64::MAX while keeping the rest of summarize_address_range logic intact.

---

Outside diff comments:
In `@profile-bee/src/dwarf_unwind.rs`:
- Around line 521-741: The current refresh clones existing mappings (existing /
existing_ranges / mappings) and skips any map with the same (begin,end), which
can retain stale shard_id/load_bias/table_count when ranges get reused; instead
rebuild the mappings list from scratch each scan: stop cloning existing into
mappings and remove the early skip that checks
existing_ranges.iter().any(|&(b,e)| b==start_addr && e==end_addr), always
compute load_bias/shard_id/table_count for each executable mapping (caches like
metadata_cache/binary_cache/path_cache still used to avoid expensive parsing),
then push into mappings and finally self.proc_mappings.insert(tgid, mappings) as
before.

---

Nitpick comments:
In `@profile-bee/bin/profile-bee.rs`:
- Line 1150: The current send_refresh path clones the entire
manager.proc_mappings into proc_mappings (manager.proc_mappings.iter().map(|(&t,
m)| (t, m.clone())).collect()), which is expensive; instead, modify send_refresh
to only clone and send changed TGIDs/mappings by tracking updates (e.g., add a
dirty set or per-TGID version in the manager) and iterate that set to collect
Vec<(u32, Vec<ExecMapping>)> of deltas, or switch manager.proc_mappings values
to Arc<Vec<ExecMapping>>/Rc so only cheap reference clones are done; update the
code that consumes proc_mappings to accept diffs or Arced mappings and ensure
symbol names referenced are manager.proc_mappings, proc_mappings, ExecMapping,
TGID, and the send_refresh function.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This pull request replaces the HashMap-based process info lookup (PROC_INFO) with an LPM (Longest Prefix Match) trie-based approach (EXEC_MAPPINGS) for executable mapping lookups during DWARF-based stack unwinding. This optimization improves the performance of address-to-mapping resolution from O(n) linear scan to O(log n) trie lookup, while removing the previous per-process limit of 8 executable mappings.

Changes:

  • Replaced PROC_INFO HashMap with EXEC_MAPPINGS LPM trie for O(log n) mapping lookups
  • Removed MAX_PROC_MAPS limitation, allowing processes to have unlimited executable mappings
  • Added summarize_address_range algorithm to decompose memory ranges into LPM prefix blocks

Reviewed changes

Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
profile-bee-common/src/lib.rs Added ExecMappingKey structure and MAX_EXEC_MAPPING_ENTRIES constant for LPM trie
profile-bee-ebpf/src/lib.rs Replaced PROC_INFO HashMap with EXEC_MAPPINGS LPM trie; updated lookup logic in unwinding functions
profile-bee/src/dwarf_unwind.rs Changed proc_info to proc_mappings using Vec; added summarize_address_range function
profile-bee/src/ebpf.rs Updated update_dwarf_tables to populate LPM trie instead of HashMap
profile-bee/bin/profile-bee.rs Updated apply_dwarf_refresh to use LPM trie and handle Vec-based mappings
profile-bee/tests/dwarf_unit.rs Updated tests to use proc_mappings instead of proc_info

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


res
}

Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The summarize_address_range function lacks unit tests to verify correctness for edge cases. Consider adding tests for:

  1. Empty ranges where low > high (e.g., from mapping.end.saturating_sub(1) when end == begin)
  2. Maximum range (0, u64::MAX) to verify overflow handling
  3. Ranges that span power-of-2 boundaries
  4. Single-address ranges where low == high

The overflow protection at line 102-104 appears correct but should be validated with tests.

Suggested change
#[cfg(test)]
mod tests {
use super::{summarize_address_range, AddressBlockRange};
use std::collections::HashSet;
/// Expand a set of AddressBlockRange entries into the set of covered addresses.
/// This is only used for small test ranges to validate coverage and alignment.
fn expand_blocks(blocks: &[AddressBlockRange]) -> HashSet<u64> {
let mut addrs = HashSet::new();
for block in blocks {
let prefix_len = block.prefix_len;
assert!(
prefix_len <= 64,
"prefix_len must be in [0, 64], got {}",
prefix_len
);
let size: u128 = if prefix_len == 0 {
1u128 << 64
} else {
1u128 << (64 - prefix_len)
};
let start = block.addr as u128;
// Block must be aligned to its size (power-of-two boundary).
assert_eq!(
start % size,
0,
"block at 0x{:x} with prefix {} is not aligned",
block.addr,
prefix_len
);
let end = start + size - 1;
// For tests we only call this with ranges small enough that this
// enumeration is feasible.
for a in start..=end {
addrs.insert(a as u64);
}
}
addrs
}
#[test]
fn summarize_empty_range_returns_empty() {
let blocks = summarize_address_range(10, 5);
assert!(
blocks.is_empty(),
"expected empty result for low > high, got {:?}",
blocks
);
}
#[test]
fn summarize_full_address_space_single_block() {
let blocks = summarize_address_range(0, u64::MAX);
assert_eq!(
blocks.len(),
1,
"expected a single block summarizing full address space, got {:?}",
blocks
);
let b = &blocks[0];
assert_eq!(b.addr, 0, "full-space summary should start at 0");
assert_eq!(
b.prefix_len, 0,
"full-space summary should have prefix_len 0 (covers all 64 bits)"
);
}
#[test]
fn summarize_single_address_range() {
let addr = 0x1234_5678_9abc_def0u64;
let blocks = summarize_address_range(addr, addr);
assert_eq!(
blocks.len(),
1,
"expected a single block for single-address range, got {:?}",
blocks
);
let b = &blocks[0];
let size: u128 = if b.prefix_len == 0 {
1u128 << 64
} else {
1u128 << (64 - b.prefix_len)
};
let start = b.addr as u128;
let end = start + size - 1;
// The block must cover the given address.
assert!(
(start as u64) <= addr && (end as u64) >= addr,
"block [{:#x}, {:#x}] does not cover address {:#x}",
start,
end,
addr
);
// For a precise single-address range we expect the block to be of size 1
// and aligned at that exact address.
assert_eq!(size, 1, "single-address range should produce block of size 1");
assert_eq!(b.addr, addr, "single-address block should start at the address");
}
#[test]
fn summarize_range_spanning_power_of_two_boundary() {
// This range crosses the 0x1000 (4096) boundary: 0x0fff < 0x1000 <= 0x1100.
let low = 0x0FFFu64;
let high = 0x1100u64;
let blocks = summarize_address_range(low, high);
assert!(
!blocks.is_empty(),
"expected non-empty result for non-empty range"
);
let covered = expand_blocks(&blocks);
// All addresses in [low, high] must be covered.
for a in low..=high {
assert!(
covered.contains(&a),
"address {:#x} in input range is not covered by blocks {:?}",
a,
blocks
);
}
// And no addresses outside [low, high] may be covered.
let expected_count = high - low + 1;
assert_eq!(
covered.len() as u64,
expected_count,
"blocks cover addresses outside the requested range"
);
}
}

Copilot uses AI. Check for mistakes.
Comment on lines +574 to +577
for block in crate::dwarf_unwind::summarize_address_range(
mapping.begin,
mapping.end.saturating_sub(1),
) {
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a memory mapping has begin >= end (which should not happen with valid mappings from /proc/pid/maps but could occur with corrupted data), mapping.end.saturating_sub(1) would result in high < low being passed to summarize_address_range, returning an empty Vec. This would silently skip inserting LPM entries for that mapping without any error indication. Consider adding a debug assertion or validation check: debug_assert!(mapping.end > mapping.begin, "Invalid mapping range").

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +163
/// LPM trie for exec mapping lookups: maps (tgid, virtual_address) → ExecMapping.
/// Replaces the old proc_info HashMap + linear scan with O(log n) longest prefix match.
/// Userspace decomposes each memory mapping's address range into aligned power-of-2
/// blocks and inserts each as a separate LPM trie entry.
#[map(name = "exec_mappings")]
pub static EXEC_MAPPINGS: LpmTrie<ExecMappingKey, ExecMapping> =
Copy link

Copilot AI Feb 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Documentation files reference the old PROC_INFO HashMap that has been replaced with EXEC_MAPPINGS LPM trie. The documentation should be updated to reflect the new architecture:

  • docs/dwarf_unwinding_design.md line 14 and 30
  • docs/tail_call_unwinding.md line 119

Update these files to describe the LPM trie-based exec mapping lookup instead of the HashMap-based approach.

Copilot uses AI. Check for mistakes.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (2)
CHANGELOG.md (1)

7-13: Consider varying the bullet prefixes for readability. Several consecutive items start with “Fix…”, so a small rephrase would improve flow.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 7 - 13, The CHANGELOG entries are repetitive
because many bullets start with "Fix…"; edit the listed bullets (references:
PROC_INFO/EXEC_MAPPINGS, ExecMappingKey, summarize_address_range, send_refresh,
trie.insert, and the LPM trie insert/logging entry) to vary the leading verbs
for readability — e.g., replace some "Fix" with "Prevent", "Avoid", "Address",
"Improve", or "Ensure" and rephrase one or two items to start with a noun or
outcome (e.g., "Overflow protection in summarize_address_range" or "DWARF
mapping refresh rebuilds process mappings") so the list reads more smoothly
while preserving the exact technical details and symbols shown.
profile-bee-ebpf/src/lib.rs (1)

626-634: Derive LPM key bit-length from ExecMappingKey size to prevent silent breakage on future layout changes.

The current hardcoded 128 is correct (16 bytes × 8 = 128 bits), but a compile-time constant derived from core::mem::size_of::<ExecMappingKey>() eliminates the magic number. This aligns with the coding guideline requiring explicit validation of shared data structure layouts. Apply to all three LpmKey::new(128, ...) call sites in this file:

♻️ Proposed refactor
+const EXEC_MAPPING_KEY_BITS: u32 =
+    (core::mem::size_of::<ExecMappingKey>() * 8) as u32;

Then replace each LpmKey::new(128, ...) with LpmKey::new(EXEC_MAPPING_KEY_BITS, ...) across all occurrences.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@profile-bee-ebpf/src/lib.rs` around lines 626 - 634, The code uses a magic
constant 128 when constructing LpmKey (LpmKey::new(128, ...)), which can break
if ExecMappingKey layout changes; declare a compile-time constant like
EXEC_MAPPING_KEY_BITS = core::mem::size_of::<ExecMappingKey>() * 8 and replace
all three LpmKey::new(128, ...) call sites with
LpmKey::new(EXEC_MAPPING_KEY_BITS, ...) so the bit-length is derived from
ExecMappingKey's size rather than hardcoded.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@profile-bee/bin/profile-bee.rs`:
- Around line 1079-1084: The code currently only calls send_refresh when
manager.refresh_process(new_tgid) returns non-empty new_shard_ids, which skips
DWARF refreshes for execs that changed mappings but not shard creation; remove
the !new_shard_ids.is_empty() guard so that after calling
manager.refresh_process(new_tgid) you always call send_refresh(&manager, &tx,
new_tgid, new_shard_ids) and handle its Result (return on Err) even when
new_shard_ids is empty; apply the same change to the other occurrence that calls
manager.refresh_process and send_refresh (the second block around send_refresh
usage).

---

Nitpick comments:
In `@CHANGELOG.md`:
- Around line 7-13: The CHANGELOG entries are repetitive because many bullets
start with "Fix…"; edit the listed bullets (references: PROC_INFO/EXEC_MAPPINGS,
ExecMappingKey, summarize_address_range, send_refresh, trie.insert, and the LPM
trie insert/logging entry) to vary the leading verbs for readability — e.g.,
replace some "Fix" with "Prevent", "Avoid", "Address", "Improve", or "Ensure"
and rephrase one or two items to start with a noun or outcome (e.g., "Overflow
protection in summarize_address_range" or "DWARF mapping refresh rebuilds
process mappings") so the list reads more smoothly while preserving the exact
technical details and symbols shown.

In `@profile-bee-ebpf/src/lib.rs`:
- Around line 626-634: The code uses a magic constant 128 when constructing
LpmKey (LpmKey::new(128, ...)), which can break if ExecMappingKey layout
changes; declare a compile-time constant like EXEC_MAPPING_KEY_BITS =
core::mem::size_of::<ExecMappingKey>() * 8 and replace all three
LpmKey::new(128, ...) call sites with LpmKey::new(EXEC_MAPPING_KEY_BITS, ...) so
the bit-length is derived from ExecMappingKey's size rather than hardcoded.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ed8b8f6 and da8745a.

⛔ Files ignored due to path filters (1)
  • profile-bee/ebpf-bin/profile-bee.bpf.o is excluded by !**/*.o
📒 Files selected for processing (11)
  • CHANGELOG.md
  • docs/dwarf_unwinding_design.md
  • docs/tail_call_unwinding.md
  • profile-bee-common/Cargo.toml
  • profile-bee-common/src/lib.rs
  • profile-bee-ebpf/src/lib.rs
  • profile-bee-tui/Cargo.toml
  • profile-bee/Cargo.toml
  • profile-bee/bin/profile-bee.rs
  • profile-bee/src/dwarf_unwind.rs
  • profile-bee/src/ebpf.rs
✅ Files skipped from review due to trivial changes (1)
  • profile-bee-common/Cargo.toml

zz85 added 3 commits February 24, 2026 07:41
Eliminates the MAX_PROC_MAPS=8 per-process mapping limit that caused
silent data loss for processes with many shared libraries. The BPF
verifier loop over mappings is completely removed, replaced by a single
LPM trie lookup per frame.

Key changes:
- Add ExecMappingKey (big-endian tgid+address) to profile-bee-common
- Replace PROC_INFO HashMap + linear scan with EXEC_MAPPINGS LpmTrie in BPF
- Port summarize_address_range() from lightswitch for address decomposition
- Update userspace to populate LPM trie with ~10-20 entries per mapping
- Per-process limit goes from 8 to effectively unlimited (200K total entries)
- Verification time unchanged (188.6ms -> 193.7ms, noise)
- All 16 e2e tests and 51 unit tests pass
Fix ExecMappingKey alignment (#[repr(C)] with explicit padding instead
of packed), overflow in summarize_address_range near u64::MAX, stale
DWARF mappings on refresh, and silent trie.insert error discard.

Optimize send_refresh to only clone changed process mappings. Add
debug_assert for invalid mapping ranges, unit tests for address range
edge cases, and update docs to reflect LPM trie architecture.

Rebuild prebuilt eBPF binary with all changes.
@zz85 zz85 force-pushed the lpm-trie-mappings branch from da8745a to 745edd4 Compare February 24, 2026 08:02
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/dwarf_unwinding_design.md`:
- Around line 115-119: Update the doc to remove or replace references to
ProcInfo and MAX_PROC_MAPS and instead describe the new exec_mappings structure
and its limits; specifically, search for occurrences of "ProcInfo", "PROC_INFO",
and "MAX_PROC_MAPS" and replace the text with the new design notes for
exec_mappings (LPM Trie per-process exec mapping lookup by (tgid, address))
including updated entry counts, memory estimates and any sharding/eviction
behavior so the sections are consistent with the earlier table that lists
`exec_mappings` and `unwind_tables`.

In `@profile-bee/bin/profile-bee.rs`:
- Around line 1223-1263: apply_dwarf_refresh currently only inserts into the
exec_mappings LPM trie and never removes stale entries, which leaks entries when
processes exit; update apply_dwarf_refresh to prune stale mappings by comparing
current live TGIDs (from update.proc_mappings) against existing entries in the
LPM trie (retrieved via bpf.map_mut("exec_mappings") and
aya::maps::lpm_trie::LpmTrie) and removing entries whose ExecMappingKeyPod.tgid
is not in the live set (use trie.remove(&key) for matching keys), or
alternatively rebuild the trie each refresh by clearing the map and reinserting
only mappings from update.proc_mappings to ensure ExecMappingKeyPod entries
reflect current processes only.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between da8745a and 745edd4.

⛔ Files ignored due to path filters (1)
  • profile-bee/ebpf-bin/profile-bee.bpf.o is excluded by !**/*.o
📒 Files selected for processing (12)
  • CHANGELOG.md
  • docs/dwarf_unwinding_design.md
  • docs/tail_call_unwinding.md
  • profile-bee-common/Cargo.toml
  • profile-bee-common/src/lib.rs
  • profile-bee-ebpf/src/lib.rs
  • profile-bee-tui/Cargo.toml
  • profile-bee/Cargo.toml
  • profile-bee/bin/profile-bee.rs
  • profile-bee/src/dwarf_unwind.rs
  • profile-bee/src/ebpf.rs
  • profile-bee/tests/dwarf_unit.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • profile-bee-common/src/lib.rs
  • profile-bee-common/Cargo.toml

…M trie refresh

Add a dwarf_tgids BPF HashMap so the eBPF process-exit handler can
detect when DWARF-tracked processes exit and notify userspace to clean
up their LPM trie entries. Previously, trie entries leaked on exit.

Key changes:
- eBPF: new DWARF_TGIDS map, handle_process_exit checks both monitored
  PID and DWARF-tracked tgids
- Userspace: MappingsDiff struct for incremental add/remove of LPM trie
  entries instead of re-sending all mappings on every refresh
- DwarfThreadMsg enum replaces raw u32 channel to support both
  LoadProcess and ProcessExited messages
- All three code paths (batch, streaming, TUI) forward exit events to
  the DWARF thread for cleanup, only stopping when the target PID exits
- Update dwarf_unwinding_design.md for current data structures and maps
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@profile-bee/bin/profile-bee.rs`:
- Around line 1408-1418: On PerfWork::ProcessExit (and the two other similar
handlers at the other locations), after sending DwarfThreadMsg::ProcessExited
and before/after the monitor_exit_pid check, remove the exiting pid from the
in-memory set that tracks seen pids (known_tgids) so PID reuse won't block
future LoadProcess events; call the appropriate remove method (e.g.,
known_tgids.remove(&exit_event.pid) or via Mutex/lock if known_tgids is shared)
for the exact pid, keeping the existing DwarfThreadMsg send and monitor_exit_pid
logic intact.
- Around line 1316-1329: The current logic in the dwarf_tgids update loop
(bpf.map_mut("dwarf_tgids") / aya::maps::HashMap and iterating
update.mapping_diffs) removes a tgid whenever diff.removed is non-empty and
diff.added.is_empty(), which erroneously drops DWARF exit tracking on partial
mapping removals; change the removal predicate to only remove the tgid when the
diff explicitly indicates the process exited (e.g., an exit/terminated flag on
the diff structure such as diff.exited or diff.is_exit) and keep the entry for
partial unloads, so use diff.tgid removal only when that explicit exit marker is
true and otherwise do not call dwarf_tgids.remove.

In `@profile-bee/src/dwarf_unwind.rs`:
- Around line 760-784: The diff currently uses only (m.begin, m.end) as the
identity when computing old_set/new_set in the proc_mappings -> MappingsDiff
logic, which misses changes to mapping metadata (e.g., load_bias, shard_id,
table_count) and leaves stale data in the LPM trie; update the identity used in
old_set/new_set and the filters that build `removed` and `added` to include the
relevant ExecMapping metadata fields (for example, use a tuple like (m.begin,
m.end, m.load_bias, m.shard_id, m.table_count) or another unique key derived
from ExecMapping) so that `proc_mappings`, `MappingsDiff`, and the added/removed
detection correctly treat mappings with identical ranges but different metadata
as changes.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 745edd4 and 1b42ba8.

⛔ Files ignored due to path filters (1)
  • profile-bee/ebpf-bin/profile-bee.bpf.o is excluded by !**/*.o
📒 Files selected for processing (5)
  • docs/dwarf_unwinding_design.md
  • profile-bee-ebpf/src/lib.rs
  • profile-bee/bin/profile-bee.rs
  • profile-bee/src/dwarf_unwind.rs
  • profile-bee/src/ebpf.rs

…d diff identity

Three correctness fixes for the process exit cleanup path:

- Clear exiting PID from known_tgids in all three ProcessExit handlers
  (batch, streaming, TUI) so PID reuse triggers a fresh LoadProcess
- Add is_exit flag to MappingsDiff; only remove tgid from dwarf_tgids
  BPF map on actual process exit, not on partial mapping changes like
  dlclose during periodic rescan
- Include load_bias, shard_id, table_count in MappingsDiff identity so
  a dlclose+dlopen of a different library at the same address range is
  correctly detected as a remove+add rather than a no-op
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
profile-bee/bin/profile-bee.rs (1)

699-726: ⚠️ Potential issue | 🟡 Minor

Noisy println! in setup_process_exit_ring_buffer_task will fire for every system-wide process exit when DWARF is enabled.

Expanding the condition at Line 702 from external_pid.is_some() to external_pid.is_some() || dwarf_enabled now starts setup_process_exit_ring_buffer_task for all DWARF sessions — including system-wide profiling with no --pid. The pre-existing println!("eBPF detected: PID {} has exited", exit_event.pid) at Line 1051 fires for every event delivered to the ring buffer, which on a busy host can be dozens per second.

Consider downgrading Line 1051 to tracing::debug!, or gating on monitor_exit_pid:

-                println!("eBPF detected: PID {} has exited", exit_event.pid);
+                tracing::debug!("eBPF detected: PID {} has exited", exit_event.pid);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@profile-bee/bin/profile-bee.rs` around lines 699 - 726, The noisy println in
setup_process_exit_ring_buffer_task floods logs for every system-wide process
exit when DWARF-only sessions are enabled; update the task to either change that
println (e.g., "eBPF detected: PID {} has exited") to tracing::debug! or wrap
the log behind a check of monitor_exit_pid/external_pid so it only logs when a
specific PID is being monitored (use the existing monitor_exit_pid variable or
test external_pid.is_some()); locate the logging inside
setup_process_exit_ring_buffer_task and the related
setup_process_exit_ring_buffer invocation and make the log conditional or
lower-severity accordingly.
🧹 Nitpick comments (1)
profile-bee/src/dwarf_unwind.rs (1)

92-95: is_empty() ignores is_exit — can skip dwarf_tgids cleanup in a degenerate case.

send_refresh short-circuits with if shard_updates.is_empty() && diff.is_empty(). If a process exits while proc_mappings[tgid] is already empty (all exec mappings had been removed before exit), diff.removed and diff.added are both empty, diff.is_empty() is true, and the message is dropped — leaving the tgid in dwarf_tgids forever.

In practice this is unreachable (a running process always retains its executable mapping), but making is_empty() aware of is_exit is safer:

🛡️ Proposed fix
 pub fn is_empty(&self) -> bool {
-    self.added.is_empty() && self.removed.is_empty()
+    self.added.is_empty() && self.removed.is_empty() && !self.is_exit
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@profile-bee/src/dwarf_unwind.rs` around lines 92 - 95, The current
MappingsDiff::is_empty only checks added and removed and ignores is_exit, which
lets send_refresh skip cleanup when a process exit happened; update the
MappingsDiff::is_empty method to also consider the is_exit flag (i.e., return
false if is_exit is true) so that diffs representing an exit are not treated as
empty and will trigger dwarf_tgids cleanup in send_refresh; locate the is_empty
implementation on struct MappingsDiff and modify its boolean expression to
include !self.is_exit (or equivalent) alongside the existing added/removed
checks.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@profile-bee/bin/profile-bee.rs`:
- Around line 699-726: The noisy println in setup_process_exit_ring_buffer_task
floods logs for every system-wide process exit when DWARF-only sessions are
enabled; update the task to either change that println (e.g., "eBPF detected:
PID {} has exited") to tracing::debug! or wrap the log behind a check of
monitor_exit_pid/external_pid so it only logs when a specific PID is being
monitored (use the existing monitor_exit_pid variable or test
external_pid.is_some()); locate the logging inside
setup_process_exit_ring_buffer_task and the related
setup_process_exit_ring_buffer invocation and make the log conditional or
lower-severity accordingly.

---

Nitpick comments:
In `@profile-bee/src/dwarf_unwind.rs`:
- Around line 92-95: The current MappingsDiff::is_empty only checks added and
removed and ignores is_exit, which lets send_refresh skip cleanup when a process
exit happened; update the MappingsDiff::is_empty method to also consider the
is_exit flag (i.e., return false if is_exit is true) so that diffs representing
an exit are not treated as empty and will trigger dwarf_tgids cleanup in
send_refresh; locate the is_empty implementation on struct MappingsDiff and
modify its boolean expression to include !self.is_exit (or equivalent) alongside
the existing added/removed checks.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 1b42ba8 and 9d0b128.

📒 Files selected for processing (2)
  • profile-bee/bin/profile-bee.rs
  • profile-bee/src/dwarf_unwind.rs

…xit-aware

- Change println! to tracing::debug! in setup_process_exit_ring_buffer_task
  to avoid log spam for every DWARF-tracked process exit
- Include is_exit in MappingsDiff::is_empty() so exit-cleanup diffs are
  not skipped by the early return in send_refresh
@zz85 zz85 merged commit 5a47350 into main Feb 25, 2026
5 checks passed
@zz85 zz85 deleted the lpm-trie-mappings branch February 25, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants