Skip to content

[LLHD] Deseq: handle signals with multiple drivers soundly#10793

Open
AmurG wants to merge 1 commit into
llvm:mainfrom
AmurG:ripe/llhd-deseq-multidriver
Open

[LLHD] Deseq: handle signals with multiple drivers soundly#10793
AmurG wants to merge 1 commit into
llvm:mainfrom
AmurG:ripe/llhd-deseq-multidriver

Conversation

@AmurG

@AmurG AmurG commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

llhd-deseq promotes a fed conditional drive to a seq.firreg plus an unconditional continuous drive even when the analyzed process is not the signal's only driver. A signal written by several processes takes the last write per write event, so the register drive clobbers every other process's write on the next delta. The common casualty is the bench idiom

initial begin read = 1; ... end          // one-shot t=0 init
always @(posedge clk) read <= ...;       // edge updates

where the initial block's value is silently lost: the register holds its power-on zero until the first clock edge.

Reproducer on current main (circt-opt --llhd-deseq)
hw.module @InitClobbered(in %clock: i1, in %d: i42) {
  %c0_i42 = hw.constant 0 : i42
  %c1_i42 = hw.constant 1 : i42
  %0 = llhd.constant_time <0ns, 1d, 0e>
  %1, %2 = llhd.process -> i42, i1 {          // always @(posedge clock)
    %true = hw.constant true
    %false = hw.constant false
    cf.br ^bb1(%c0_i42, %false : i42, i1)
  ^bb1(%3: i42, %4: i1):
    llhd.wait yield (%3, %4 : i42, i1), (%clock : i1), ^bb2(%clock : i1)
  ^bb2(%5: i1):
    %6 = comb.xor bin %5, %true : i1
    %7 = comb.and bin %6, %clock : i1
    cf.cond_br %7, ^bb1(%d, %true : i42, i1), ^bb1(%c0_i42, %false : i42, i1)
  }
  %3 = llhd.sig %c0_i42 : i42
  llhd.process {                              // initial begin sig = 1; end
    llhd.drv %3, %c1_i42 after %0 : i42
    llhd.halt
  }
  llhd.drv %3, %1 after %0 if %2 : i42
}

Output on current main: the init process survives, but the promoted register has no preset and its unconditional drive clobbers the init on the next delta.

llhd.process {
  llhd.drv %1, %c1_i42 after %0 : i42
  llhd.halt
}
%3 = seq.firreg %d clock %2 : i42
llhd.drv %1, %3 after %4 : i42

The fix classifies extra drivers instead of promoting blindly. A drive that provably executes exactly once at t=0 with a constant value (unconditional, zero delay, in the entry block of another process, no predecessors, cannot re-execute) folds into the promoted register's preset, which reproduces the constant from t=0 onward. Any other extra driver (non-constant, conditional, delayed, repeated, module-level continuous, or in llhd.final) keeps the process form.

Folding rather than always bailing matters: in mixed form, promoted registers on the same clock commit at clock evaluation before kept processes resume, so a kept process observes post-edge values of same-clock registers, a one-delta lead that breaks lockstep pipelines. We measured exactly that regression on two SVA local-variable benches under the bare guard, before adding the preset fold.

Tested: deseq.mlir gains @MultiDrivenInitFoldsToPreset, @MultiDrivenSignalKeepsProcess, and @MultiDrivenDelayedInitKeepsProcess.

Written with AI assistance; I've reviewed the diff and tests.

Deseq replaces each fed conditional drive with an unconditional
(continuous) drive of a seq.firreg result. That is only sound when the
analyzed process is the signal's sole driver: a variable written by
several processes takes the last write per write event in the source
semantics, and the continuous register drive clobbers every other
process's write on the next delta. The ubiquitous casualty is the bench
idiom

  initial begin read = 1; ... end          // one-shot t=0 init
  always @(posedge clk) read <= ...;       // edge updates

where the initial block's t=0 value is silently lost: on current main,
--llhd-deseq promotes the edge-driven process to a preset-less
`seq.firreg` plus an unconditional epsilon-delay drive while the init
process's t=0 constant drive is left in place, so the register drive
clobbers the init on the next delta and the signal reads the power-on
zero until the first clock edge.

Classify the extra drivers instead of promoting blindly:

- A drive that provably executes exactly once at t=0 with a constant
  value -- unconditional, zero-time delay, located in the entry block of
  another process (runs in that process's first activation before any
  wait, has no predecessors, cannot re-execute) -- folds into the
  promoted register's preset: the continuous register drive then
  reproduces the constant from t=0 onward, equivalent to the initial
  block's write for every t >= 0 observation.
- Any other extra driver (non-constant value, conditional, delayed,
  repeated, module-level continuous, or in llhd.final) keeps the
  genuinely-correct process form.

Folding the one-shot init rather than also bailing on it matters
beyond the lost init itself: in mixed form, sibling registers on the
same clock that do promote commit their state at clock evaluation,
before kept processes resume, so a kept process observes post-edge
values of same-clock registers -- a one-delta lead against preponed
sampling that breaks lockstep pipelines. The preset fold keeps the
promoted form for the common idiom and reserves the process form for
genuine multi-driver interleavings.

deseq.mlir gains @MultiDrivenInitFoldsToPreset (checks the folded
preset value), @MultiDrivenSignalKeepsProcess (non-constant init
value), and @MultiDrivenDelayedInitKeepsProcess (delayed init write).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AmurG AmurG force-pushed the ripe/llhd-deseq-multidriver branch from 30877eb to f12929b Compare July 8, 2026 15:17
@circt-bot

circt-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

Results of circt-tests run for f12929b compared to results for 01b5614: no change to test results.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant