Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/Dialects/Arc.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,68 @@ It transforms hardware descriptions from the HW, Seq, and Comb dialects into a f
The Arc dialect is used by the *arcilator* simulation tool, which compiles Arc IR to a binary object via LLVM for fast simulation.


## Process and Coroutine Lowering

LLHD distinguishes two suspendable constructs.
An `llhd.process` defines procedural behavior inline in an `hw.module`; it runs once and may suspend execution at `llhd.wait` ops or terminate at `llhd.halt`.
An `llhd.coroutine` is a separately-defined suspendable subroutine, invoked at `llhd.call_coroutine` sites from inside a process or another coroutine; it terminates with `llhd.return`.

A process is, semantically, a coroutine defined inline in a module and invoked exactly once at its definition site.
Both bodies are SSACFG regions that are turned into a state machine driven by a *program counter* (PC), with values live across a suspension carried in *persistent state*.
Processes and coroutines therefore share a single lowering mechanism with only minor differences.

### Outlined Form

Both constructs are rewritten into a canonical outlined form: an `arc.coroutine.define` definition plus one or more call sites that re-enter it.
For a process, the call site is an `arc.coroutine.instance` placed in the enclosing `hw.module`.
For a coroutine, each `llhd.call_coroutine` becomes an `arc.coroutine.call` inside its parent coroutine's body.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Not blocking but could you use consistent naming for call_coroutine/coroutine.call in a follow-up?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah good idea 👍 I should probably tweak the LLHD dialect to match this.

After outlining, processes and coroutines are no longer distinguished.
Recursive coroutines are rejected during lowering.

### Program Counter

Every coroutine uses the same PC encoding:

| Name | Value | Meaning |
|-----------|-----------|------------------------------------------------------|
| `START` | `0` | First entry; the body executes from its entry block. |
| resume | `1..N` | Resume at one of the body's suspension points. |
| `RETURN` | `MAX-1` | The body returned normally; results are valid. |
| `HALT` | `MAX` | The body halted; no further execution. |

`START = 0` matches the zero-initialized layout of fresh persistent state and requires no special initialization at runtime.
Resume PCs are densely packed low integers, lowering to a single `switch` and keeping the per-coroutine PC width small.
`RETURN` and `HALT` are shared constants across all coroutines, so call sites dispatch on completion uniformly.

### Persistent State

The state carried across a suspension is a union of structs, with one variant per resume block, not per `wait` op.
Multiple waits targeting the same resume block share a variant.

The values captured into each variant are the SSA values that are live across the suspension: any value defined before the `wait` and used at or after the resume block.
Concretely, this is the union of two sets:

- The resume block arguments, which carry the values passed through `wait`'s `destOperands`.
- Any other SSA value that dominates the `wait` and is used after the resume block.
Such values are not block arguments, but resuming the coroutine does not re-execute the ops that defined them, so they must be carried across the suspension as persistent state.

When multiple waits share a resume block, the variant's fields are the union of the live-across-suspension sets at each contributing wait.

When a coroutine contains an `arc.coroutine.call`, the callee's state and PC are SSA values returned from the call.
If the call site is itself suspended -- i.e. the callee did not complete in a single eval -- those values are live across the parent's "I am inside a call" suspension point and are captured into the parent's variant like any other block argument.
State allocation is therefore compositional: the size of a coroutine's persistent state is the size of its own union plus, transitively, the size of each callee's persistent state at each call site.
Lowering proceeds bottom-up over the call graph so that callee state sizes are known by the time a parent is lowered.

### Instances and Wakeup

`arc.coroutine.instance` exists only inside `hw.module` bodies and represents the once-per-module entry into a top-level coroutine.
It guards entry into the coroutine with `if (now >= my_wakeup && resume_pc != HALT)`.
The referenced coroutine must produce an `i64` wakeup time as its last result, which is not returned as a result from the instance op.
The model's `next_wakeup` slot is reset to `UINT64_MAX` by `LowerState` at the top of every eval body.
Each `arc.coroutine.instance`, regardless of whether it dispatched, contributes its current stored wakeup to a min-reduction into that slot.
The driver reads the slot after eval to decide when next to call the model.


## Types

[include "Dialects/ArcTypes.md"]
Expand Down
4 changes: 4 additions & 0 deletions include/circt/Dialect/Arc/ArcOps.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@

#include "circt/Dialect/Arc/ArcInterfaces.h.inc"

namespace circt::hw {
class HWModuleOp;
} // namespace circt::hw

#define GET_OP_CLASSES
#include "circt/Dialect/Arc/Arc.h.inc"

Expand Down
283 changes: 283 additions & 0 deletions include/circt/Dialect/Arc/ArcOps.td
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,289 @@ def SetNextWakeupOp : ArcOp<"set_next_wakeup", [
}];
}

//===----------------------------------------------------------------------===//
// Coroutines
//===----------------------------------------------------------------------===//

def CoroutineDefineOp : ArcOp<"coroutine.define", [
FunctionOpInterface,
IsolatedFromAbove,
Symbol,
]> {
let summary = "Coroutine definition";
let description = [{
Define a coroutine. Coroutines are suspendable functions that are entered at
their entry block or re-entered at a resume block. Local state and values
are persisted by an opaque state held by the caller, alongside an opaque
program counter value indicating at which point the coroutine should be
resumed.

Coroutines can be suspended by the `arc.coroutine.yield` terminator, which
returns control and local state back to the caller. The caller can then
re-enter the coroutine by passing that control and local state back into the
coroutine. Coroutines can be finished by the `arc.coroutine.return` and
`arc.coroutine.halt` terminators, which return control back to the caller
with a corresponding program counter indicating return or halt.

Arguments are passed to the coroutine upon each entry, which means that
subsequent re-entry may provide different arguments. If a coroutine wants to
preserve the arguments passed to its initial entry, it must persist them as
local state.

Results are returned from the coroutine upon each suspension. Each of the
terminators must provide a set of values to be yielded back to the caller
upon suspension.

The local state of a coroutine is represented by `!arc.coroutine_state<@A>`,
and the program counter for resuming by `!arc.coroutine_pc<@A>`. These types
are opaque and are expanded to a concrete union/struct of local state and a
concrete integer PC via a lowering. These types are only used on coroutine
calls; coroutine definitions define them implicitly by values carried across
resume points.

To nest coroutines, a coroutine definition can call another coroutine and
carry the `!arc.coroutine_state` and `!arc.coroutine_pc` of that call as
local state across its own suspension points.
}];
let arguments = (ins
SymbolNameAttr:$sym_name,
TypeAttrOf<FunctionType>:$function_type,
OptionalAttr<DictArrayAttr>:$arg_attrs,
OptionalAttr<DictArrayAttr>:$res_attrs
);
let results = (outs);
let regions = (region MinSizedRegion<1>:$body);
let hasCustomAssemblyFormat = 1;

let builders = [
OpBuilder<(ins "mlir::StringAttr":$sym_name,
"mlir::TypeAttr":$function_type), [{
build($_builder, $_state, sym_name, function_type, mlir::ArrayAttr(),
mlir::ArrayAttr());
}]>,
OpBuilder<(ins "mlir::StringRef":$sym_name,
"mlir::FunctionType":$function_type), [{
build($_builder, $_state,
$_builder.getStringAttr(sym_name),
mlir::TypeAttr::get(function_type),
mlir::ArrayAttr(), mlir::ArrayAttr());
}]>,
];

let extraClassDeclaration = [{
/// Returns the argument types of this coroutine.
mlir::ArrayRef<mlir::Type> getArgumentTypes() {
return getFunctionType().getInputs();
}

/// Returns the result types of this coroutine.
mlir::ArrayRef<mlir::Type> getResultTypes() {
return getFunctionType().getResults();
}

mlir::Region *getCallableRegion() { return &getBody(); }
}];
}

// A coroutine state/PC type whose `coroutine` parameter matches the enclosing
// op's `callee` symbol attribute.
class CoroutineTypeOfCallee<ArcTypeDef baseType> : ConfinedType<baseType, [
CPred<"::llvm::cast<" # baseType.cppType # ">($_self).getCoroutine() == "
"($_op).getAttrOfType<::mlir::FlatSymbolRefAttr>(\"callee\")">
], baseType.summary # " bound to the op's callee symbol", baseType.cppType>;

def CoroutineStateOfCallee : CoroutineTypeOfCallee<CoroutineStateType>;
def CoroutinePCOfCallee : CoroutineTypeOfCallee<CoroutinePCType>;

def CoroutineCallOp : ArcOp<"coroutine.call", [
CallOpInterface,
DeclareOpInterfaceMethods<SymbolUserOpInterface>,
]> {
let summary = "Call a coroutine";
let description = [{
Call an `arc.coroutine.define`. The coroutine is resumed at the point
indicated by the `pc` operand, and local state is restored from the `state`
operand. The `args` are passed into the coroutine and may differ between
subsequent re-entries. When the coroutine suspends or finishes, control is
transferred back to the caller and the call op returns the coroutine's
resume program counter, resume state, and the values yielded back from the
coroutine as results.

The caller is responsible for interpreting the program counter returned from
the coroutine. A `return` indicates that the coroutine is finished and
control shall continue in the parent. A `halt` indicates that the coroutine
suspends forever, and the parent should also return `halt` if it is a
coroutine itself. Any other value indicates that the callee has suspended
and expects to be re-entered at a later point, and the caller must suspend
itself and re-enter the callee if it is a coroutine itself.
}];
let arguments = (ins
FlatSymbolRefAttr:$callee,
CoroutineStateOfCallee:$state,
CoroutinePCOfCallee:$pc,
Variadic<AnyType>:$args
);
let results = (outs
CoroutineStateOfCallee:$resumeState,
CoroutinePCOfCallee:$resumePC,
Variadic<AnyType>:$results
);
let assemblyFormat = [{
$callee `(` $state `,` $pc (`,` $args^)? `)` attr-dict
`:` functional-type(operands, results)
}];

let extraClassDeclaration = [{
operand_range getArgOperands() {
return getArgs();
}
MutableOperandRange getArgOperandsMutable() {
return getArgsMutable();
}

mlir::CallInterfaceCallable getCallableForCallee() {
return (*this)->getAttrOfType<mlir::SymbolRefAttr>("callee");
}

void setCalleeFromCallable(mlir::CallInterfaceCallable callee) {
(*this)->setAttr(getCalleeAttrName(),
llvm::cast<mlir::SymbolRefAttr>(callee));
}

/// CallOpInterface requires ArgAndResultAttrsOpInterface. Call sites
/// don't carry these attributes, so stub them out as no-ops.
mlir::ArrayAttr getArgAttrsAttr() { return nullptr; }
mlir::ArrayAttr getResAttrsAttr() { return nullptr; }
void setArgAttrsAttr(mlir::ArrayAttr args) {}
void setResAttrsAttr(mlir::ArrayAttr args) {}
mlir::Attribute removeArgAttrsAttr() { return nullptr; }
mlir::Attribute removeResAttrsAttr() { return nullptr; }
}];
}

def CoroutineInstanceOp : ArcOp<"coroutine.instance", [
CallOpInterface,
DeclareOpInterfaceMethods<SymbolUserOpInterface>,
HasParent<"hw::HWModuleOp">,
]> {
let summary = "Continuously run a coroutine in an hw.module";
let description = [{
Execute a coroutine concurrently in an `hw.module`. The program counter and
state of the coroutine are held implicitly by the instance and passed into
the coroutine when executed next. The values yielded by the coroutine are
produced as results of the instance. The callee must produce a wakeup time
as its last result value. This wakeup time is not exposed as a result of
the instance op and is instead used to schedule the next execution.
}];
let arguments = (ins
FlatSymbolRefAttr:$callee,
Variadic<AnyType>:$args
);
let results = (outs Variadic<AnyType>:$results);
let assemblyFormat = [{
$callee `(` $args `)` attr-dict `:` functional-type(operands, results)
}];

let extraClassDeclaration = [{
operand_range getArgOperands() {
return getArgs();
}
MutableOperandRange getArgOperandsMutable() {
return getArgsMutable();
}

mlir::CallInterfaceCallable getCallableForCallee() {
return (*this)->getAttrOfType<mlir::SymbolRefAttr>("callee");
}

void setCalleeFromCallable(mlir::CallInterfaceCallable callee) {
(*this)->setAttr(getCalleeAttrName(),
llvm::cast<mlir::SymbolRefAttr>(callee));
}

mlir::ArrayAttr getArgAttrsAttr() { return nullptr; }
mlir::ArrayAttr getResAttrsAttr() { return nullptr; }
void setArgAttrsAttr(mlir::ArrayAttr args) {}
void setResAttrsAttr(mlir::ArrayAttr args) {}
mlir::Attribute removeArgAttrsAttr() { return nullptr; }
mlir::Attribute removeResAttrsAttr() { return nullptr; }
}];
}

def CoroutineYieldOp : ArcOp<"coroutine.yield", [
AttrSizedOperandSegments,
HasParent<"CoroutineDefineOp">,
Terminator,
]> {
let summary = "Suspend a coroutine and request resumption at a block";
let description = [{
Suspend a coroutine. Control is transferred back to the caller, alongside
a program counter value indicating the destination block, and the local
state needed to restore the values live across the yield op. Additionally,
the yield operands are returned to the caller and must match the result
types of the coroutine.
}];
let arguments = (ins
Variadic<AnyType>:$yieldOperands,
Variadic<AnyType>:$destOperands
);
let successors = (successor AnySuccessor:$dest);
let assemblyFormat = [{
(` ` `(` $yieldOperands^ `:` type($yieldOperands) `)` `,`)?
$dest (`(` $destOperands^ `:` type($destOperands) `)`)?
attr-dict
}];
let hasVerifier = 1;
}

def CoroutineReturnOp : ArcOp<"coroutine.return", [
HasParent<"CoroutineDefineOp">,
Terminator,
]> {
let summary = "Return from a coroutine";
let description = [{
Returns control from a coroutine to the caller, yielding back a special
sentinel program counter value indicating that the coroutine has run to
completion. Additionally, the yield operands are returned to the caller and
must match the result types of the coroutine.
}];
let arguments = (ins Variadic<AnyType>:$yieldOperands);
let assemblyFormat = [{
($yieldOperands^ `:` type($yieldOperands))? attr-dict
}];
let hasVerifier = 1;
}

def CoroutineHaltOp : ArcOp<"coroutine.halt", [
HasParent<"CoroutineDefineOp">,
Terminator,
]> {
let summary = "Halt a coroutine permanently";
let description = [{
Halts execution of a coroutine forever. Control effectively gets stuck
indefinitely at the halt operation, also preventing all callers from making
progress. Yields back a special sentinel program counter value to the caller
which the caller must translate into either halting itself if it is a
coroutine, or arranging for the coroutine to never be re-entered again.
}];
let arguments = (ins Variadic<AnyType>:$yieldOperands);
let assemblyFormat = [{
($yieldOperands^ `:` type($yieldOperands))? attr-dict
}];
let hasVerifier = 1;
}

class CoroutinePCBase<string mnemonic> : ArcOp<mnemonic, [Pure]> {
let summary = "Check whether a coroutine PC is a sentinel value";
let arguments = (ins CoroutinePCType:$pc);
let results = (outs I1:$result);
let assemblyFormat = [{
$pc `:` type($pc) attr-dict
}];
}
def CoroutinePCIsReturnOp : CoroutinePCBase<"coroutine.pc_is_return">;
def CoroutinePCIsHaltOp : CoroutinePCBase<"coroutine.pc_is_halt">;

//===----------------------------------------------------------------------===//
// Procedural Ops
//===----------------------------------------------------------------------===//
Expand Down
Loading
Loading