Skip to content

feat(bloat): expose linker --cref back-references as referenced_by + optional graphviz .dot export #459

Closed

Description

@zackees

Problem

fbuild bloat (a.k.a. fbuild symbols) currently parses the linker map for section/symbol attribution — it tells you where a symbol is defined (archive + object + output section) and which input section a .rodata.<owner>.str1.N pool belongs to. It does not extract the linker's Cross Reference Table, even though GNU ld writes that table into the same firmware.map file we're already reading.

This shows up in real bloat audits. From FastLED#2886 (the ESP32-S3 Blink reduction plan, generated by bash bloat esp32s3 --top 25):

# Bytes Symbol "FL_WARN/FL_LOG sites contributing rodata"
2 11,309 _vfprintf_r n/a
3 11,118 _svfprintf_r n/a
4 7,337 _svfiprintf_r n/a
5 7,265 _vfiprintf_r n/a
8 3,219 _dtoa_r n/a

The n/a column is unavoidable today — fbuild has nothing to say about who pulled in a libc symbol, so the agent gives up. But the answer is literally already in the map file. From the cref table of the matching ESP32-P4 build I checked:

_vfprintf_r   libc.a(libc_a-vfprintf.o)        ← defining TU
              libc.a(libc_a-vprintf.o)         ← referencer
              libc.a(libc_a-printf.o)
              libc.a(libc_a-fprintf.o)

vprintf       libc.a(libc_a-vprintf.o)
              liblog.a(log_write.c.obj)        ← ESP-IDF logging
printf        libc.a(libc_a-printf.o)
              libmbedcrypto.a(pkcs5.c.obj, sha512.c.obj, …)
              libheap.a(tlsf.c.obj, heap_caps.c.obj)
              libconsole.a(commands.c.obj)
              libnvs_flash.a(nvs_page.cpp.obj)
              … (~25 more)
fprintf       libc.a(libc_a-fprintf.o)
              libsdmmc.a, libesp_timer.a, libesp_mm.a, libvfs.a,
              libesp_hw_support.a (sleep/intr/gpio/regdma/ldo),
              libesp_driver_gpio.a, libconsole.a(linenoise.c)

That is exactly the "why was this symbol linked?" information the bloat report should be answering, and it's two layers of indentation-aware parsing away from what we already do.

Proposal

Part 1 — referenced_by in report.json (minimum viable)

Add a parser pass over the Cross Reference Table block of the linker map. For each symbol, emit a new per-row field in the JSON:

{
  "demangled": "_vfprintf_r",
  "size": 11309,
  "archive": "libc.a",
  "object": "libc_a-vfprintf.o",
  "source": "nm",
  "referenced_by": [
    {"archive": "libc.a", "object": "libc_a-vprintf.o"},
    {"archive": "libc.a", "object": "libc_a-printf.o"},
    {"archive": "libc.a", "object": "libc_a-fprintf.o"}
  ]
}

In report.md, add a "Why was this linked?" column to the top-N table (or a follow-up table that lists top-K referencers per top symbol). For symbols with > K=5 referencers, render top-K + "(… and N more)".

Cref granularity is archive-member (.o), not symbol — that's a ld --cref characteristic, not a fbuild limitation. Documentation should call it out.

Part 2 — fbuild bloat graph subcommand for .dot export (optional follow-up)

Generate Graphviz .dot files from a target symbol, walking the back-reference graph outward. The depth/N question is where most of the design work lives:

Fixed N is the wrong knob. I worked through this in FastLED#2886 discussion — quick summary:

  • N=1 is insufficient for any libc/IDF symbol. _vfprintf_r at N=1 stops at libc-internal wrappers (vprintf.o, printf.o, fprintf.o) — same archive as the target. You learn nothing actionable.
  • N=1 is sufficient for any app-side symbol whose direct referencers are already outside the defining archive (e.g. FastLED ctor → main.cpp.o).
  • Fixed N≥3 explodes for hub symbols. printf alone has ~25 mbedTLS referencers; expanding two more hops produces an unreadable wall.

Recommended default: adaptive termination + fan-out cap.

  1. Cross-archive termination. Stop expanding a branch the first time it reaches a referencer whose archive is different from the original target's archive. Naturally surfaces the boundary where the symbol "escapes" its own library.
  2. Per-node fan-out cap K (default 5). Sort referencers by their own contributed flash bytes, keep top-K, collapse the rest into a [… and N more] super-node.
  3. Hard depth cap N=4 as a safety belt — if cross-archive termination hasn't fired by depth 4 you're chasing infra and the graph is unreadable.

Render aids:

  • Color nodes by archive (FastLED / ESP-IDF / Arduino / libc / mbedTLS / libstdc++ / app).
  • Size nodes by attributed flash bytes (size= proportional to log(bytes)).
  • Collapse mode: --collapse-archive libc.a merges all libc-internal hops into a single super-node so the eye jumps straight to the non-libc edges (ESP_LOG, mbedTLS, etc.).
  • Archive filter: --exclude-archive libc.a,libm.a for users who only want non-system referencers.

Proposed surface:

fbuild bloat graph <input> --symbol _vfprintf_r [--depth adaptive|N] \
                          [--fan-out K] [--collapse-archive A,B] \
                          [--exclude-archive A,B] -o vfprintf.dot

For the FastLED#2886 use case, the four newlib rows would each render a ~6-10 node graph with libc wrappers collapsed and the real upstream consumers (log_write.c.obj, tlsf.c.obj, ESP-IDF sleep/intr) as the leaves. Exactly the data needed to choose between "kill IDF logging", "drop mbedTLS self-tests", or "switch to nano-printf".

Acceptance

  • report.json rows carry referenced_by: [{archive, object}, ...].
  • report.md top-N table answers "why is this linked?" without n/a for libc/IDF rows.
  • fbuild symbols still works when the map has no Cross Reference Table block (older ld versions or -Wl,--no-cref) — referenced_by becomes [], not an error.
  • (Part 2) fbuild bloat graph --symbol <S> writes a readable .dot for both _vfprintf_r (libc, ~10 nodes after collapse) and a FastLED ctor (app, ~3-5 nodes), no manual tuning.

Related

  • #434 — symbols → bloat rename (the umbrella under which this naturally lands)
  • #427 — map-derived synthesis (the existing map-parse pass to extend)
  • #428 — build_info.json toolchain paths
  • FastLED#2886 — the motivating bloat audit, demonstrates the n/a gap

🤖 Generated with Claude Code

Activity

  1. zackees commented on Jun 7, 2026

    @zackees
    MemberAuthor

    Addendum: default-on back-links + embedded .dot for top-N symbols

    Updating the proposal — referenced_by should be default-on (not behind a flag), and report.md should inline a .dot graph block per symbol for the top 10 largest symbols. Specifically optimized for LLM consumers: a single self-contained report.md that an AI can paste into context and answer "what's pulling X in?" without fetching additional artifacts.

    Concrete shape

    report.md per top-10 symbol entry:

    ### #2 `_vfprintf_r` — 11,309 B
    - Defined in: `libc.a(libc_a-vfprintf.o)`
    - Output section: `.flash.text`
    
    <details>
    <summary>Back-reference graph (Graphviz)</summary>
    
    ```dot
    digraph "_vfprintf_r" {
      rankdir=LR;
      node [shape=box, style=filled];
      "_vfprintf_r" [fillcolor=red, label="_vfprintf_r\n11,309 B"];
      "libc_a-vprintf.o" [fillcolor=lightgray];
      "libc_a-printf.o" [fillcolor=lightgray];
      "libc_a-fprintf.o" [fillcolor=lightgray];
      "log_write.c.obj" [fillcolor=lightblue, label="log_write.c\n(liblog.a)"];
      "tlsf.c.obj" [fillcolor=lightblue, label="tlsf.c\n(libheap.a)"];
      // ... edges
      "libc_a-vprintf.o" -> "_vfprintf_r";
      "log_write.c.obj" -> "libc_a-vprintf.o";
      // ...
    }
    ```

    GitHub renders the fenced ```dot block as a static graph in some clients and otherwise as code — either way the text is in-band, which is what matters for AI.

    Sidecar files still get written: .build/symbols/<board>/graphs/<rank>_<sanitized-symbol>.dot. Lets humans run dot -Tsvg locally without copy-pasting out of markdown.

    Defaults (proposed)

    Knob Default Reason
    referenced_by in report.json on The data is free; downstream tools should be able to assume it's there.
    Embedded .dot in report.md on, top 10 Matches the report's existing top-N convention. AI-friendly.
    Sidecar .dot files for ALL symbols with > 256 B on Cheap to write; useful for ad-hoc rendering.
    Adaptive depth (cross-archive termination + fan-out cap K=5 + hard cap N=4) on Keeps top-10 graphs readable without per-symbol tuning.
    --no-graph flag to disable embedding available Escape hatch for users who want a slim report.
    --graph-top N override available, default 10 Match --top N if it's bigger; never exceed it.

    Why default-on

    The two consumers we care about benefit:

    • Humans reading report.md on a PR: the embedded graphs answer the "why is this here?" question without leaving the page.
    • Coding agents iterating on bloat reduction: the agent can read report.md, see the .dot block, parse the graph, and immediately suggest the right knob (suppress ESP_LOG vs. drop mbedTLS self-tests vs. switch to nano-printf) without a second tool call.

    The cost is tens of KB of markdown for a top-10 build — negligible compared to the source map (firmware.map is already > 10 MB on ESP32-P4).

    Acceptance update

    • report.json referenced_by field is populated by default (no flag).
    • report.md top-N section embeds a fenced ```dot block per symbol (default top 10, capped by --graph-top).
    • Sidecar <rank>_<symbol>.dot files written under <output-dir>/graphs/.
    • --no-graph disables both embedding and sidecars.
    • AI-friendliness sanity check: a fresh agent reading only report.md can answer "what pulls in _vfprintf_r?" without fetching the linker map.

    🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions