← board

DCE on a C program drops main

Measured 2026-09-02 at 6a084d569, x86-64, by lifting the not IsPascalFrontend refusal in dce.inc and nothing else.

$ cat tiny.c
#include <stdio.h>
static int f(int n){ return n+1; }
int main(void){ printf("%d\n", f(41)); return 0; }

$ pxx -O3 --dce-report tiny.c tiny
dce: bodies 792  live 43 (16093B)  dead 743 (280852B)  dropping 743 (280852B)
dce: code 298542B -> 17690B
$ ./tiny
Segmentation fault

The 43 survivors name the cause

--proc-map on the result lists them, and every one is a Pascal runtime entry: PXXAlloc, PXXStrConcat, TInterfacedObject._AddRef, __pxx_run_finalizers, __pxx_setjmp, PXXSysWrite, ... Not one C function, main included. At -O2 the same program emits 792 procs and main is among them, so it exists and is simply never marked.

Those 43 are precisely what DceRun's unconditional roots produce — MethodFixups (RTTI/VMT slots), ProcAddrFix, InitProcs/FiniProcs, FiniRunnerProc. So the reachability walk starts, runs, and finds the C program is not attached to it anywhere.

It is the ENTRY, not the call graph

The distinguishing measurement: a C object is correct.

pxx --emit-obj      ch.c -> 365368 B   prints "44 42"
pxx --emit-obj --dce ch.c -> 275272 B  prints "44 42"

An object is rooted at ObjProcIsExported, which lands cfa and cfb, and everything they reach — including static int helper, snprintf and the crtl bulk under them — survives correctly. So the C call graph IS in CallFix. What is missing is the one root an executable has and an object does not: its entry.

For Pascal that root is free. The program body is raw code at offset 0, owned by no Procs[] row, so DceOwnerOf returns -1 for every call it makes and DceRun marks each as a root by definition. C's main is an ordinary Procs[] entry, and whatever calls it is not producing an unowned CallFix site — that is the thing to go look at, and the 25% (not 77%) cut on the object above suggests the C entry stub may not be shaped like the Pascal one at all.

Why this is filed rather than fixed

The fix is probably one root, and "probably one root" is exactly the claim that needs a measurement rather than a patch: a wrong root here does not crash, it deletes a function somebody needed and the program fails somewhere else. The verification this wants is a C corpus at -O2 vs -O3 — Track T's optdiff tier — not a single repro, and that is more than the sitting that found it had.

The consumer is real: feature-a-every-emit-obj-object-links-its-own-full-copy-of-crtl-so-n-objects-cost-n-runtimes banked busybox at 13.7MB across 41 translation units, all of it C, and --emit-obj DCE (landed 6a084d569) does not move that number while this refusal stands. Both halves are worth having: the object half is already correct here and is what busybox needs.

2026-09-02 (frankC) — FIXED in 60edd4853. The root was one hand-patched call, and it named itself

CPatchStubCall (cparser.inc). The C entry stub emits its call to main as a forward with a zero displacement, because main has no Procs[] row yet, and patches it later with an absolute body address — never through CallFix. So the call graph contains no edge for it and never could; the pass was reading a graph in which main genuinely has no callers. Same for the pre-main __pxx_run_initializers shell, patched the same way two lines below.

The fix is a table, not a special case: RecordEntryRoot(procIdx), called by CPatchStubCall itself, so any future hand-patched stub call is rooted without anyone remembering the rule. DceRun marks them beside InitProcs/FiniProcs.

A CodeRef would NOT have been enough, and the distinction is worth keeping: RecordCodeRefAt keeps a code offset's BYTES alive and re-aims the displacement — right for a stub nobody owns, and wrong here, because a range that is merely kept is never walked. main would have survived with every routine it calls deleted underneath it. Both are needed and they are different mechanisms: the rel32 SITE also moves when the pass compacts, so CPatchStubCall's rel32 arm records a CodeRef too.

Measured after

full.c   -O2 307320 B   -O3 77944 B     both print "hello 5"
tiny.c   792 bodies -> 71 live, correct output (was 43 live, SIGSEGV)

And what the corpus sweep turned up, which was not this bug

544 C tests, -O2 against -O3. The one real difference was c_crtl_signal_and_wait row 18, and it is not a DCE defect: the row read ru.ru_maxrss > 0 on a struct rusage nobody had written, so it was testing uninitialised stack. It agreed with glibc when the file was diffed against gcc — both sides were reading their own garbage — and enabling --dce moved the frame and flipped it to 0.

ru_maxrss is also simply the wrong field: measured against the gcc oracle on this kernel, a child that loops and _exits reports 0 from both compilers, and a child that writes 8MB gets 8416 from pxx. wait4's rusage works. The row now poisons ru and asserts the kernel wrote through the pointer, which is what its comment always claimed to be testing and is true for any child.

Filed at 52363b95d (the measurement that showed the refusal was honest), fixed at 60edd4853. The --emit-obj half of the same if chain, whose refusal turned out to be stale rather than honest, is 6a084d569.