Description
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_rat 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.
printfalone has ~25 mbedTLS referencers; expanding two more hops produces an unreadable wall.
Recommended default: adaptive termination + fan-out cap.
- 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.
- 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. - 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.amerges 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.afor 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.jsonrows carryreferenced_by: [{archive, object}, ...]. -
report.mdtop-N table answers "why is this linked?" withoutn/afor libc/IDF rows. -
fbuild symbolsstill works when the map has noCross Reference Tableblock (olderldversions or-Wl,--no-cref) —referenced_bybecomes[], not an error. - (Part 2)
fbuild bloat graph --symbol <S>writes a readable.dotfor both_vfprintf_r(libc, ~10 nodes after collapse) and a FastLED ctor (app, ~3-5 nodes), no manual tuning.
Related
- #434 —
symbols→bloatrename (the umbrella under which this naturally lands) - #427 — map-derived synthesis (the existing map-parse pass to extend)
- #428 —
build_info.jsontoolchain paths - FastLED#2886 — the motivating bloat audit, demonstrates the
n/agap
🤖 Generated with Claude Code
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsTriage
Activity
zackees commentedon Jun 7, 2026
Addendum: default-on back-links + embedded
.dotfor top-N symbolsUpdating the proposal —
referenced_byshould be default-on (not behind a flag), andreport.mdshould inline a.dotgraph block per symbol for the top 10 largest symbols. Specifically optimized for LLM consumers: a single self-containedreport.mdthat an AI can paste into context and answer "what's pulling X in?" without fetching additional artifacts.Concrete shape
report.mdper top-10 symbol entry:GitHub renders the fenced
```dotblock 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 rundot -Tsvglocally without copy-pasting out of markdown.Defaults (proposed)
referenced_byinreport.json.dotinreport.md.dotfiles for ALL symbols with > 256 B--no-graphflag to disable embedding--graph-top Noverride--top Nif it's bigger; never exceed it.Why default-on
The two consumers we care about benefit:
report.mdon a PR: the embedded graphs answer the "why is this here?" question without leaving the page.report.md, see the.dotblock, parse the graph, and immediately suggest the right knob (suppressESP_LOGvs. 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.mapis already > 10 MB on ESP32-P4).Acceptance update
report.jsonreferenced_byfield is populated by default (no flag).report.mdtop-N section embeds a fenced```dotblock per symbol (default top 10, capped by--graph-top).<rank>_<symbol>.dotfiles written under<output-dir>/graphs/.--no-graphdisables both embedding and sidecars.report.mdcan answer "what pulls in_vfprintf_r?" without fetching the linker map.🤖 Generated with Claude Code
feat(bloat): expose linker --cref back-references as referenced_by (#459
feat(bloat): graphviz `.dot` back-reference graphs (#463) (#468)
chore: bump version to 2.2.20
chore(deps): bump fbuild 2.2.18 → 2.2.20 (#2946) (#2947)