Skip to content

Conversation

@rwy7
Copy link
Contributor

@rwy7 rwy7 commented Oct 17, 2025

Add a domain inference pass.

Metadata

@rwy7 rwy7 force-pushed the simple-infer-domains branch from d5a833c to ad4aa5d Compare November 4, 2025 17:16
Copy link
Member

@seldridge seldridge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll split this up into several reviews as I'm able to get through different parts. Here's some very high-level stuff.

Also, please include minimal tests of the end-to-end firtool behavior.

Comment on lines 122 to 143
/// Return the number of ports for this instance.
size_t getNumPorts() {
return getNumResults();
}

/// Return the port direction for the specified result number.
Direction getPortDirection(size_t resultNo) {
return direction::get(getPortDirections()[resultNo]);
}

/// Return the port name for the specified result number.
StringAttr getPortName(size_t resultNo) {
StringAttr getPortNameAttr(size_t resultNo) {
return cast<StringAttr>(getPortNames()[resultNo]);
}
StringRef getPortNameStr(size_t resultNo) {
return getPortName(resultNo).getValue();

StringRef getPortName(size_t resultNo) {
return getPortNameAttr(resultNo).getValue();
}

Location getPortLocation(size_t) {
return getLoc();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This cleanup for consistency of naming/functionality is great. If you so desire, land this directly/separately.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this require updates to the C-API?

@rwy7 rwy7 force-pushed the simple-infer-domains branch from ad4aa5d to 565cd27 Compare November 4, 2025 18:26
Copy link
Member

@seldridge seldridge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second review (of three). This just looks at the tests and sees what the pass is supposed to be doing. Only nits, really. These look great.

Comment on lines +9 to +12
// expected-error @below {{illegal "ClockDomain" crossing in port "p"}}
// expected-note @below {{1st instance: A}}
// expected-note @below {{2nd instance: B}}
in %p: !firrtl.uint<1> domains [%A, %B]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This error originally seemed incorrect to me. However, this is actually pretty clever and correct. Nice.

Comment on lines +36 to +50
// Illegal domain crossing at matchingconnect op.
firrtl.circuit "IllegalDomainCrossing" {
firrtl.domain @ClockDomain
firrtl.module @IllegalDomainCrossing(
in %A: !firrtl.domain of @ClockDomain,
in %B: !firrtl.domain of @ClockDomain,
// expected-note @below {{2nd operand has domains: [ClockDomain: A]}}
in %a: !firrtl.uint<1> domains [%A],
// expected-note @below {{1st operand has domains: [ClockDomain: B]}}
out %b: !firrtl.uint<1> domains [%B]
) {
// expected-error @below {{illegal domain crossing in operation}}
firrtl.matchingconnect %b, %a : !firrtl.uint<1>
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(I'm reviewing backwards, so don't know the answer to this, yet:)

Are errors accumulated in a module or is exit immediate?

Nit: If not accumulated, we probably want that. If they are accumulated, it may be cleaner to combine this test with the previous one.

in D1 : !firrtl.domain of @ClockDomain,
// expected-note @below {{associated with "ClockDomain" port "D2"}}
in D2 : !firrtl.domain of @ClockDomain,
// expected-error @below {{ambiguous "ClockDomain" association for port "i"}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: This error is very similar to the DomainCrossOnPort test. However, the error message is very different. Would this be clearer/simpler as just Illegal domain crossing?

// expected-note @below {{candidate association "DO"}}
out %DO : !firrtl.domain of @ClockDomain,
in %i : !firrtl.uint<1> domains [%DO],
// expected-error @below {{ambiguous "ClockDomain" association for port "o"}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For this "ambiguous" error message, it seems like we could suggest a fix to the user, e.g., "Use an unsafe domain cast to disambiguate". I really like the "ambiguous" messaging as it's just indicating that there are multiple solutions and the compiler can't figure it out for you, just like with certain type systems that require you to provide hints in certain situations, e.g., mandatory type annotations for recursive functions.

Comment on lines +147 to +148
out %uint_output: !firrtl.uint<8>,
out %sint_output: !firrtl.sint<4>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing CHECKs.

)

firrtl.module @ExportDomain(
// CHECK: out %ClockDomain: !firrtl.domain of @ClockDomain
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Getting a better name for this may be reasonable. Just "ClockDomain" is going to run into ClockDomain_0, etc. (Though, how many different inferred domains like this are going to exist?)

Possible something like domain_foo_A to give a hint as to its provenance.

Comment on lines +245 to +249
// CHECK: firrtl.module @InstanceUpdate(in %ClockDomain: !firrtl.domain of @ClockDomain, in %i: !firrtl.uint<1> domains [%ClockDomain]) {
// CHECK: %foo_ClockDomain, %foo_i = firrtl.instance foo @Foo(in ClockDomain: !firrtl.domain of @ClockDomain, in i: !firrtl.uint<1> domains [ClockDomain])
// CHECK: firrtl.domain.define %foo_ClockDomain, %ClockDomain
// CHECK: firrtl.connect %foo_i, %i : !firrtl.uint<1>
// CHECK: }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: I have a hard time reading these when the modules have multiple ports. Consider using CHECK-SAME to split the checks across multiple lines with indentation.

Ditto throughout.

}
}

// CHECK-LABEL: ConstantInMultipleDomains
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test would be more readable if manually formatted to one-port-per-line.

Also, nice to see this working!

}
}

firrtl.circuit "Top" {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is this test checking?

Copy link
Member

@seldridge seldridge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

More partial review.

Comment on lines 96 to 99
/// Each declared domain in the circuit is assigned an index, based on the order
/// in which it appears. Domain associations for hardware values are represented
/// as a list of domains, sorted by the index of the domain type.
using DomainTypeID = size_t;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indirection to size_t was somewhat unexpected as opposed to using Operation * here (and sorting or using a set). I thought this was safe given that the underlying ops are never moved or changed, but that may not be true if any IR is modified. Do you know?

Or: is there an alternative that doesn't require the numbering here and can use something already available? (I realize that port IDs are used for associations. However, there's nothing else that can be used in that situation. 😅 )

Comment on lines +132 to +138
auto *block = arg.getOwner();
auto *owner = block->getParentOp();
auto module = cast<FModuleOp>(owner);
auto info = module.getDomainInfoAttr();
auto i = arg.getArgNumber();
return getDomainTypeID(info, i);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider keeping the logical temporaries for info and i (and possibly owner) and inlining block and module. Long chains of temporaries that are only there to indicate "the next step" tend to hurt reading comprehension:

Suggested change
auto *block = arg.getOwner();
auto *owner = block->getParentOp();
auto module = cast<FModuleOp>(owner);
auto info = module.getDomainInfoAttr();
auto i = arg.getArgNumber();
return getDomainTypeID(info, i);
auto *owner = arg.getOwner()->getParentOp();
auto info = cast<FModuleOp>(owner).getDomainInfoAttr();
auto i = arg.getArgNumber();
return getDomainTypeID(info, i);

I do recognize that this is a fine balance between "inline everything" (which probably isn't the best here) and "inline enough so that a reader can follow it".

Ditto for the later instance case.

Mega-nit: Most syntax highlighters aren't smart enough to understand when module is a keyword and when it isn't. Consider avoiding this and using moduleOp when it comes up.

Comment on lines +150 to +154
auto index = domainTable.size();
auto name = op.getNameAttr();
domainTable.push_back(op);
typeIDTable.insert({name, index});
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mega-nit: consider changing the order of modifications to domainTable and typeIDTable to move the temporaries closer to their single use sites.

Also, as always, consider inlining single use variables when it helps clarity (or using comments: {*/name=*/op.getNameAttr(), /*index=*/domainTable.size()}.

seldridge and others added 5 commits November 6, 2025 13:21
Add a pass that does domain inference and checking.  This is used to
verify the legality of a FIRRTL circuit with respect to its domains.
E.g., this pass is intended to be used for checking for illegal clock
domain crossings.

Signed-off-by: Schuyler Eldridge <[email protected]>
@rwy7 rwy7 force-pushed the simple-infer-domains branch from 565cd27 to 66e46be Compare November 6, 2025 18:43
rwy7 added 3 commits November 6, 2025 14:27
- instanceGraph.walkPostOrder
- circuit debug scoped pass logger
Copy link
Member

@seldridge seldridge left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking really great. I have not had time to actually play with it. I'd like to do this a bit more before landing it.

General comments:

  • There's a lot of use of Term * which could probably be Term &. Not super critical.
  • Try to avoid long chains of auto that are "recipes" for what the program is doing. It's difficult to know what is a true temporary vs. what is the individual steps to get to a temporary which is single-use. I tried to indicate places where this was confusing. Some ways to fix it: inline single-use, use /*foo=*/ comments for named args, use types instead of auto, etc.
  • The PR mixes some early exits with continuation to report all errors. For an inference pass, it seems like it is better to report everything that failed instead of ever exiting early.

I'm not following what all the options are doing for -domain-mode. Specifically, I expected the following to error with firtool -domain-mode check, however, it doesn't error. This only errors with -domain-mode infer-all:

FIRRTL version 6.0.0
circuit Foo:
  domain ClockDomain:
    period: Integer
  domain PowerDomain:
    voltage: Integer


  public module Foo:
    input ClockA: Domain of ClockDomain
    input ClockB: Domain of ClockDomain
    input PowerA : Domain of PowerDomain

    input a: UInt<1> domains [ClockA, PowerA]
    output b: UInt<1> domains [ClockB, PowerA]

    connect b, a

Can the PR add tests of the firtool end-to-end behavior, specifically with regard to the different -domain-mode options?

Comment on lines +207 to +211
struct VariableTerm : public TermBase<TermKind::Variable> {
VariableTerm() : leader(nullptr) {}
VariableTerm(Term *leader) : leader(leader) {}
Term *leader;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could skip the constructors and just use aggregate initializers instead:

Suggested change
struct VariableTerm : public TermBase<TermKind::Variable> {
VariableTerm() : leader(nullptr) {}
VariableTerm(Term *leader) : leader(leader) {}
Term *leader;
};
struct VariableTerm : public TermBase<TermKind::Variable> {
Term *leader;
};
VariableTerm foo{};
VariableTerm bar{term};

The LLVM coding guidelines don't seem to have an opinion here. However, it seems cleaner to not declare trivial constructors which are the same (I think / is foo{} going to use nullptr?) as the aggregate initializers.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment for ValueTerm and RowTerm.

Comment on lines +227 to +234
struct VariableIDTable {
size_t get(VariableTerm *term) {
auto [it, inserted] = table.insert({term, table.size() + 1});
return it->second;
}

DenseMap<VariableTerm *, size_t> table;
};
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the table need to be public? If not, then class with private table would be better.

Comment on lines +253 to +259
bool first = true;
for (auto *element : term->elements) {
if (!first)
out << ", ";
dump(out, element);
first = false;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using llvm::interleaveComma is preferable.

Comment on lines +266 to +273
if (!term)
return out << "null";
if (auto *var = dyn_cast<VariableTerm>(term))
return dump(out, var);
if (auto *val = dyn_cast<ValueTerm>(term))
return dump(out, val);
if (auto *row = dyn_cast<RowTerm>(term))
return dump(out, row);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider TypeSwitch as it's templating makes this very compact. (All of the cases can be collapsed to one case with a 3-entry parameter pack like .Case<VariableTerm, ValueTerm, RowTerm>([&](auto a){ dump(out, a); })

Comment on lines +283 to +291
if (auto *var = dyn_cast<VariableTerm>(x)) {
if (var->leader == nullptr)
return var;

auto *leader = find(var->leader);
if (leader != var->leader)
var->leader = leader;
return leader;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: consider inverting the match to allow for an early exit.

Comment on lines +991 to +992
// The domain is defined internally. If there value is already exported,
// or will be exported, we are done.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// The domain is defined internally. If there value is already exported,
// or will be exported, we are done.
// The domain is defined internally. If the value is already exported,
// or will be exported, we are done.

Comment on lines +1001 to +1009
auto portName = domainName;
auto portType = DomainType::get(module.getContext());
auto portDirection = Direction::Out;
auto portSym = StringAttr();
auto portLoc = port.getLoc();
auto portAnnos = std::nullopt;
auto portDomainInfo = FlatSymbolRefAttr::get(domainName);
PortInfo portInfo(portName, portType, portDirection, portSym, portLoc,
portAnnos, portDomainInfo);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: these can all be inlined and explained with comments:

PortInfo portInfo(
  /*portName=*/ domainName,
 ...
);

Ditto below.

Comment on lines +1074 to +1082
auto result = success();
module.getBodyBlock()->walk([&](Operation *op) -> WalkResult {
if (failed(updateOpDomainAssociations(op))) {
result = failure();
return WalkResult::interrupt();
}
return WalkResult::advance();
});
return result;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can instead check if interrupted.

Comment on lines +1103 to +1127
if (isa<DomainType>(type)) {
if (direction == Direction::In) {
bool driven = false;
for (auto *user : port.getUsers()) {
if (auto connect = dyn_cast<FConnectLike>(user)) {
if (connect.getDest() == port) {
driven = true;
break;
}
}
}
if (!driven) {
auto *term = getTermForDomain(port);
term = find(term);
if (auto *val = dyn_cast<ValueTerm>(term)) {
auto loc = port.getLoc();
auto value = val->value;
DomainDefineOp::create(builder, loc, port, value);
} else {
emitDomainPortInferenceError(op, i);
return failure();
}
}
}
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indentation is pretty deep here. Can this be cleaned up with condition inversion?

Comment on lines +1391 to +1393
if (auto extModule = dyn_cast<FExtModuleOp>(op)) {
return checkModuleDomains(globals, extModule);
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
if (auto extModule = dyn_cast<FExtModuleOp>(op)) {
return checkModuleDomains(globals, extModule);
}
if (auto extModule = dyn_cast<FExtModuleOp>(op))
return checkModuleDomains(globals, extModule);

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.

2 participants