scripts: Reworked stack.py to use dwarf, dropped -fcallgraph-info=su

There were a lot of small challenges (see previous commits), but this
commit reworks stack.py to rely only on dwarf-info and symbols to build
stack + callgraph info.

Not only does this remove an annoying dependency on a GCC-specific flag,
but it also should give us more correct stack measurements by only
penalizing calls for the stack usage at the call site. This should
better account for things like shrinkwrapping, which make the
-fcallgraph-info=su results look worse than they actually are.

To make this work required jumping through a couple hoops:

1. Map symbols -> dwarf entries by address (DW_AT_low_pc).

   We use symbols here to make sure function names line up with other
   scripts.

   Note that there can be multiple dwarf entries with the same name due
   to optimization passes. Apparently the optimized name is not included
   because that would be too useful.

2. Find each functions' frame info.

   This is stored in the .debug_frames section (objdump --dwarf=frames),
   and requires _yet another state machine_ to parse, but gives us the
   stack frame info for each function at the instruction level, so
   that's nice.

3. Find call sites (DW_TAG_call_site).

   The hierchical nesting of DW_TAG_lexical_blocks gets a bit annoying
   here, but ultimately we can find all DW_TAG_call_sites by looking at
   the DW_TAG_subprogram's children tags.

4. Map call sites to frame info.

   This gets funky.

   Finding the target function is simple enough, DW_AT_call_origin
   contains its dwarf offset (but why is this the _origin_?). But we
   don't actually know what address the call originated from.

   Fortunately we do know the return address, DW_AT_call_return_pc?

   The instruction before DW_AT_call_return_pc should be the call
   instruction. Subtracting 1 will awkwardly put us in the middle of the
   instruction, but it should at least map to the correct stack frame?
   And without ISA-specific info it's the best we can do.

It's messy, but this should be all the info we need.

---

To build confidence in the new script, I included the --no-shrinkwrap
flag, which reverts to penalizing each call site for the function's
worst-case stack frame. This makes it easy to compare against the
-fcallgraph-info=su approach:

  with -fcallgraph-info=su:          2624
  with --dwarf=info --no-shrinkwrap: 2624

I was hoping that accounting for shrinkwrap-like optimizations would
reveal a lower stack cost, but for better or worse it seems that
worst-case stack usage is unchanged:

  with --dwarf=info --no-shrinkwrap: 2624
  with --dwarf=info:                 2624

Still, it's good to know that our stack measurement is correct.
This commit is contained in:
Christopher Haster
2024-12-05 19:30:14 -06:00
parent 02ccbdfed2
commit 56d888933f
2 changed files with 795 additions and 156 deletions
+16 -24
View File
@@ -22,7 +22,6 @@ SRC ?= $(filter-out $(wildcard *.t.* *.b.*),$(wildcard *.c))
OBJ := $(SRC:%.c=$(BUILDDIR)/%.o)
DEP := $(SRC:%.c=$(BUILDDIR)/%.d)
ASM := $(SRC:%.c=$(BUILDDIR)/%.s)
CI := $(SRC:%.c=$(BUILDDIR)/%.ci)
GCDA := $(SRC:%.c=$(BUILDDIR)/%.t.a.gcda)
TESTS ?= $(wildcard tests/*.toml)
@@ -35,7 +34,6 @@ TEST_C := $(TESTS:%.toml=$(BUILDDIR)/%.t.c) \
TEST_A := $(TEST_C:%.t.c=%.t.a.c)
TEST_OBJ := $(TEST_A:%.t.a.c=%.t.a.o)
TEST_DEP := $(TEST_A:%.t.a.c=%.t.a.d)
TEST_CI := $(TEST_A:%.t.a.c=%.t.a.ci)
TEST_GCNO := $(TEST_A:%.t.a.c=%.t.a.gcno)
TEST_GCDA := $(TEST_A:%.t.a.c=%.t.a.gcda)
TEST_PERF := $(TEST_RUNNER:%=%.perf)
@@ -52,7 +50,6 @@ BENCH_C := $(BENCHES:%.toml=$(BUILDDIR)/%.b.c) \
BENCH_A := $(BENCH_C:%.b.c=%.b.a.c)
BENCH_OBJ := $(BENCH_A:%.b.a.c=%.b.a.o)
BENCH_DEP := $(BENCH_A:%.b.a.c=%.b.a.d)
BENCH_CI := $(BENCH_A:%.b.a.c=%.b.a.ci)
BENCH_GCNO := $(BENCH_A:%.b.a.c=%.b.a.gcno)
BENCH_GCDA := $(BENCH_A:%.b.a.c=%.b.a.gcda)
BENCH_PERF := $(BENCH_RUNNER:%=%.perf)
@@ -70,7 +67,6 @@ GDB ?= gdb
PERF ?= perf
PRETTYASSERTS ?= ./scripts/prettyasserts.py
CFLAGS += -fcallgraph-info=su
CFLAGS += -g3
CFLAGS += -I.
CFLAGS += -std=c99 -Wall -Wextra -pedantic
@@ -117,6 +113,7 @@ PERFBDFLAGS += $(filter -j%,$(MAKEFLAGS))
ifneq ($(OBJDUMP),objdump)
CODEFLAGS += --objdump-path="$(OBJDUMP)"
DATAFLAGS += --objdump-path="$(OBJDUMP)"
STACKFLAGS += --objdump-path="$(OBJDUMP)"
CTXFLAGS += --objdump-path="$(OBJDUMP)"
STRUCTSFLAGS += --objdump-path="$(OBJDUMP)"
PERFFLAGS += --objdump-path="$(OBJDUMP)"
@@ -234,12 +231,12 @@ data-diff: $(OBJ)
## Find the per-function stack usage
.PHONY: stack
stack: STACKFLAGS+=-S
stack: $(CI) $(BUILDDIR)/lfs.stack.csv
./scripts/stack.py $(CI) $(STACKFLAGS)
stack: $(OBJ) $(BUILDDIR)/lfs.stack.csv
./scripts/stack.py $(OBJ) $(STACKFLAGS)
## Compare per-function stack usage
.PHONY: stack-diff
stack-diff: $(CI)
stack-diff: $(OBJ)
./scripts/stack.py $^ $(STACKFLAGS) -d $(BUILDDIR)/lfs.stack.csv
## Find the per-function context
@@ -250,7 +247,7 @@ ctx: $(OBJ) $(BUILDDIR)/lfs.ctx.csv
## Compare per-function context
.PHONY: ctx-diff
ctx-diff: $(CI)
ctx-diff: $(OBJ)
./scripts/ctx.py $^ $(CTXFLAGS) -d $(BUILDDIR)/lfs.ctx.csv
## Find function sizes
@@ -274,16 +271,16 @@ funcs: \
## Compare function sizes
.PHONY: funcs-diff
funcs-diff: SHELL=/bin/bash
funcs-diff: $(OBJ) $(CI)
funcs-diff: $(OBJ)
$(strip ./scripts/csv.py \
<(./scripts/csv.py \
<(./scripts/code.py $(OBJ) -q $(CODEFLAGS) -o-) \
<(./scripts/code.py $^ -q $(CODEFLAGS) -o-) \
-fcode=size -q -o-) \
<(./scripts/csv.py \
<(./scripts/stack.py $(CI) -q $(STACKFLAGS) -o-) \
<(./scripts/stack.py $^ -q $(STACKFLAGS) -o-) \
-fstack='max(limit)' -q -o-) \
<(./scripts/csv.py \
<(./scripts/ctx.py $(OBJ) -q $(CTXFLAGS) -o-) \
<(./scripts/ctx.py $^ -q $(CTXFLAGS) -o-) \
-fctx='max(size)' -q -o-) \
-bfunction -fcode -fstack='max(stack)' -fctx='max(ctx)' \
$(SUMMARYFLAGS) -d <(./scripts/csv.py \
@@ -375,20 +372,20 @@ summary sizes: \
## Compare compile-time sizes
.PHONY: summary-diff sizes-diff
summary-diff sizes-diff: SHELL=/bin/bash
summary-diff sizes-diff: $(OBJ) $(CI)
summary-diff sizes-diff: $(OBJ)
$(strip ./scripts/csv.py \
<(./scripts/csv.py \
<(./scripts/csv.py \
<(./scripts/code.py $(OBJ) -q $(CODEFLAGS) -o-) \
<(./scripts/code.py $^ -q $(CODEFLAGS) -o-) \
-fcode=size -q -o-) \
<(./scripts/csv.py \
<(./scripts/data.py $(OBJ) -q $(DATAFLAGS) -o-) \
<(./scripts/data.py $^ -q $(DATAFLAGS) -o-) \
-fdata=size -q -o-) \
<(./scripts/csv.py \
<(./scripts/stack.py $(CI) -q $(STACKFLAGS) -o-) \
<(./scripts/stack.py $^ -q $(STACKFLAGS) -o-) \
-fstack='max(limit)' -q -o-) \
<(./scripts/csv.py \
<(./scripts/ctx.py $(OBJ) -q $(CTXFLAGS) -o-) \
<(./scripts/ctx.py $^ -q $(CTXFLAGS) -o-) \
-fctx='max(size)' -q -o-) \
-fcode -fdata -fstack='max(stack)' -fctx='max(ctx)' \
-bbuild='"AFTER"' -q -o-) \
@@ -507,7 +504,7 @@ $(BUILDDIR)/lfs.code.csv: $(OBJ)
$(BUILDDIR)/lfs.data.csv: $(OBJ)
./scripts/data.py $^ -q $(DATAFLAGS) -o $@
$(BUILDDIR)/lfs.stack.csv: $(CI)
$(BUILDDIR)/lfs.stack.csv: $(OBJ)
./scripts/stack.py $^ -q $(STACKFLAGS) -o $@
$(BUILDDIR)/lfs.ctx.csv: $(OBJ)
@@ -543,9 +540,7 @@ $(BUILDDIR)/runners/test_runner: $(TEST_OBJ)
$(BUILDDIR)/runners/bench_runner: $(BENCH_OBJ)
$(CC) $(CFLAGS) $^ $(LFLAGS) -o $@
# our main build rule generates .o, .d, and .ci files, the latter
# used for stack analysis
$(BUILDDIR)/%.o $(BUILDDIR)/%.ci: %.c
$(BUILDDIR)/%.o: %.c
$(CC) -c -MMD $(CFLAGS) $< -o $(BUILDDIR)/$*.o
$(BUILDDIR)/%.s: %.c
@@ -587,13 +582,11 @@ clean:
rm -f $(OBJ)
rm -f $(DEP)
rm -f $(ASM)
rm -f $(CI)
rm -f $(TEST_RUNNER)
rm -f $(TEST_A)
rm -f $(TEST_C)
rm -f $(TEST_OBJ)
rm -f $(TEST_DEP)
rm -f $(TEST_CI)
rm -f $(TEST_GCNO)
rm -f $(TEST_GCDA)
rm -f $(TEST_PERF)
@@ -604,7 +597,6 @@ clean:
rm -f $(BENCH_C)
rm -f $(BENCH_OBJ)
rm -f $(BENCH_DEP)
rm -f $(BENCH_CI)
rm -f $(BENCH_GCNO)
rm -f $(BENCH_GCDA)
rm -f $(BENCH_PERF)