← board

--dce miscompiles every threaded program, and -O3 turns it on

Measured 2026-09-01 by frankZ at commit 130b9a3e9, binary 59699dc0833f8110, and re-verified after a sync moved the compiler at commit db706c2da, binary 69eeb1efd71e2125 (converged after 1 round(s), stamp deleted first): -O0 --dce 139, -O0 0, -O3 124, -O3 --no-dce 0. Two binaries, same answer.

The repro is nine lines and two flags

{$threadsafe on}
program tsthr;
uses palthreadobj, sysutils;
type TW = class(TThread) public procedure Execute; override; end;
procedure TW.Execute; begin end;
var t: TW;
begin
  t := TW.Create(True); t.FreeOnTerminate := False; t.Start; t.WaitFor; t.Free;
  writeln('TSTHR OK');
end.
build result
--threadsafe -O0 TSTHR OK
--threadsafe -O0 --dce SIGSEGV, no output

The thread body is empty. Nothing about this program is a race.

Do NOT narrow this to threading

The title says where it was OBSERVED, not what it is about. A whole-body DCE defect is observable wherever a body looks dead and is not; threading is simply where five shards happened to sit. Two reasons to hold that line:

Two manifestations that fail DIFFERENTLY (a threaded lock test that self-deadlocks, a busybox applet) are worth far more than two that fail the same way. Look for what they share — what DCE believes is unreachable — before bisecting either.

If you build a harness that sweeps -O levels: -O4 DOES NOT EXIST. compiler.pas:1034 accepts -O0/-O1/-O2/-O3 only and the compiler answers unknown option: -O4. Sweep -O0..-O3, and make the harness FAIL on a level it cannot run rather than skip it — a skipped level inside a stated sweep is how bug-t-an-actual-acceptance-record-cites-a-sweep-at-an-o-level-the-compiler-rejects happened.

It is the DCE pass alone — not an -O3 pass, not a flag/directive mismatch

test/lib_criticalsection_blocking.pas, --threadsafe, same binary:

build 5 runs
-O0 / -O1 / -O2 rc=0, count=8000 + CSBLOCK OK
-O3 rc=124 (timeout) ×5
-O3 --no-dce rc=0 ×3
-O3 -g (-g disables DCE, dce.inc:228) rc=0 ×3
-O0 --dce SIGSEGV
-O1 --dce SIGSEGV (frankC)
-O2 --dce rc=124

--no-dce fixes all five of the shards' programs, checked one by one by frankC: test_threadsafe_heap_lock_release, test_threadsafe_layout_rtti, lib_criticalsection_blocking, lib_fpc_thread_surface (all -O3 rc=124), and lib_classes_tthread (-O3 rc=139).

DCE moves with the failure at every level and nothing else does. -O3 is implicated only because compiler.pas:1799 turns --dce on there.

Two hypotheses this kills:

The hang shape

-O3, no -g: one thread, state R, 100% CPU, no output at all — it spins before TBumper is ever created, so this is not lock contention. -O0 --dce faults at _start+214 (an inlined managed-pointer guard: test %rax,%rax; je; cmpq $0x40000000,-0x10(%rax)), called from a frame the map cannot name.

Where to look

--dce-report on the criticalsection program: bodies 785 live 127 dead 655, code 322797B -> 72837B. Diffing the map of -O0 against -O0 --dce on the nine-line repro, DCE keeps ThreadObjLauncher, BeginThreadLauncher, PalThreadCreate, PalThreadJoin, TThread.*, TW.Execute — and drops PalThreadExit, EndThread, WaitForThreadTerminate, CurrentThread, MainThreadID, PalHasThreads, PalFutexWaitTimeout.

Whether a dropped body or a mis-repatched call site is the defect is not settled. dce.inc's own comment says the only reference shape it can re-patch is x86-64 rel32 call/jmp, so any reference that is not a CallFix-registered rel32 is invisible to BOTH the reachability walk and the re-patching. Do not close this on the strength of the dropped-symbol list alone.

Measured independently by frankC, from the other end — disassembly and a live read rather than flag ablation, so this is two sources that fail differently, not one source twice:

Two traps that cost time:

The regression window

-O3 has turned DCE on since e33e10a14 (2026-08-21) and optdiff was green under it until caa34fdeab46 (2026-09-01T04:41Z), so the break is inside caa34fdeab46..297a6755c0bd.

Read that window for what it is. -O0 --dce segfaults, and there is no optimiser in that build at all — so the window is an argument about when DCE's RE-PATCHING SURFACE last changed, not about an optimisation pass. The two commits in it that touch that surface:

44b256356 (objects link into a PIE) and a3b1af61a (@proc relocations) are in the same neighbourhood. This is a window, not a finding — bisect it. Build each parent with rm compiler/.pascal26.fixedpoint first and refuse to judge any build that does not print converged after N round(s).

Why this is not already fixed, and the false green coming

d402a25b2 made {$threadsafe on} without --threadsafe a hard error. Every program optdiff reported as an -O3 hang carries that directive, and optdiff compiles with a bare pascal26 -ON file — no --threadsafe. So all seven of them now BUILD-FAIL, which optdiff counts as a skip.

Shards 0/1/2/3/5 will therefore go GREEN on the next opt run while this miscompile is still live, and the threading programs will have left the -O3 differential sweep entirely. A guard that cannot fail is not a guard. See [[bug-t-optdiff-cannot-see-any-threading-program-since-the-threadsafe-directive-became-an-error]].

Land it green

make compiler/pascal26 cannot see this: compiler.pas starts no threads, and the fixedpoint runs at the default -O where DCE is off. Carry the nine-line repro above at -O0 --dce as the probe.

Fixed — 2026-09-01, frankZ

compiler/dce.inc compacted GlobFix[] copying only CodePos and BSSoff, and left the three arrays PARALLEL BY INDEX to it — GlobFixPCRel, GlobFixTrail, GlobFixPicDelta — where they were. Every fixup that MOVED therefore inherited the flags of whoever previously occupied its new index. The same applied one loop up to Fixups[] / FixupPCRel / FixupPicDelta.

Concretely: EmitReleaseHeapLock emits mov dword [@glob], 0 = C7 05 disp32 imm32, whose trail is 4, so the rip correction must be -(4 + 4). Byte scan:

without DCE   11 acquires   11 releases   all naming 0x4170f8
with    DCE   11 acquires    6 releases   five moved ones naming 0x4070fc

0x4070fc is the lock word plus four. The release wrote PAST the lock instead of clearing it, so the next acquire spun forever on a lock nobody held. Single-thread self-deadlock: no crash, no diagnostic, and threads are not required to reach it — the guard has none.

-O3 turns DCE on (compiler.pas:1799), which is why this read as an optimiser bug and sent two days of bisecting into -O3 codegen. It is neither: -O0 --dce reproduces it exactly. The owner's lead was right and the reason it was right is now measured — DCE is a lowering step that runs at any level a flag asks for, so the level was the wrong axis.

EmitGlobRef's own header already documented this failure from an earlier occasion. The emitter derives the addend correctly; DCE threw the answer away afterwards.

The guard is test/test_dce_threadsafe_heaplock.pas, wired into test-quick, because --dce had no row anywhere in the Makefile before tonight: the pass was reachable only through -O3, i.e. only from Track T's opt tier, which is not in the quick<native<limited<full chain. Positive control run the hard way — reverted dce.inc, rebuilt, watched the row fail with exit=124, restored with git apply and rebuilt to the identical binary fb30f90f2d455b07.

Log