Fluid Blocks¶
TBD
fluid lambdas and the valid/retry/fire elastic machinery are not yet
implemented in LiveHD (the syntax parses, but nothing lowers). See
Implementation status.
Motivation¶
comb, pipe, and mod describe static dataflow and static cycle
relationships. A pipe result is available at a known latency, and @[N]
checks that a value is aligned to a known cycle.
A fluid block is different. It describes a transactional stream where each input and output has a valid/retry handshake. The latency of a transaction may stretch because an internal unit is busy or because a downstream consumer applies backpressure.
A typical example is a floating-point unit in a CPU. The CPU presents an operation when it can issue one. The FPU may retry the request when a long operation such as square root is already running. Later, when a result is ready, the CPU or scoreboard may retry the response if writeback is blocked.
This is not a pipe@[1..] case. @[N] is a static cycle-alignment type
check; fluid availability is dynamic and protocol-driven.
Fluid Lambdas (fluid)¶
A fluid lambda is a block whose inputs and outputs are transactional by
default:
type FpuReq = (
const state: u10 = nil,
const round: u2 = nil,
const op: u7 = nil,
const src1: u64 = nil,
const src2: u64 = nil,
)
type FpuResp = (
const state: u10 = nil,
const result: u64 = nil,
const icc: u3 = nil,
)
fluid fpu(req:FpuReq) -> (resp:FpuResp) {
// req and resp have payload fields plus .[valid], .[retry], and .[fire]
}
For each fluid port x, the compiler provides:
x.[valid]- producer to consumer; the payload is meaningful this cycle.x.[retry]- consumer to producer; true means "do not consume/advance".x.[fire]- shorthand forx.[valid] and !x.[retry].
The payload is still the declared type. For tuple payloads, fields are accessed normally:
if req.[fire] {
const op = req.op
}
The two handshake directions are written differently:
validis inferred. Assigning a fluid value sets its.[valid]to the path condition of the assignment — the same inference Pyrope already performs for ordinary optional values.if c { out = f(x) }produces a token exactly whencholds. An explicitout.[valid] = ...assignment overrides the inference (allowed, never required), and an output that is never assigned is never valid. An unconditional assignment means a token every cycle.retryis explicit. "I can not accept this token" is a policy decision (busy, hazard, arbitration loss), not a fact the compiler can derive. The consumer must drive.[retry], or explicitly tie it false.
For a fluid input, the caller assigns the payload (its .[valid] is
inferred from the assignment) and the callee drives .[retry]. For a fluid
output, the callee assigns the payload and the caller drives .[retry].
Calling Fluid Blocks¶
A fluid call must be bound with a fluid declaration:
mod cpu(/*...*/) -> (/*...*/) {
fluid mut fpu_req:FpuReq
if issue_fpu {
fpu_req = (state=rd_state, round=round, op=op, src1=rs1, src2=rs2)
} // fpu_req.[valid] inferred = issue_fpu
fluid fpu_resp = fpu(fpu_req)
// The CPU must account for request retry or the operation could be lost.
stall = fpu_req.[retry] or other_stall
fpu_resp.[retry] = writeback_blocked
if fpu_resp.[fire] {
regfile[fpu_resp.state] = fpu_resp.result
}
}
The following is rejected:
const fpu_resp = fpu(fpu_req) // ERROR: fluid call result must be fluid
fluid calls are allowed only inside mod and fluid lambdas:
combmay not instantiatefluid.pipemay not instantiatefluid.modmay instantiatefluid.fluidmay instantiatefluid.
The fluid-inside-fluid rule is needed for hierarchy. A large unit such as
an FPU can contain internal retry stages, arbiters, result queues, and other
fluid sub-blocks while exposing one fluid request/response interface.
The Fluid Contract¶
A fluid port makes protocol promises, not cycle promises:
- A transaction is consumed only when
input.[fire]is true. - A transaction is produced or released only when
output.[fire]is true. - Payload remains stable while
valid and retryis true. - An input retry must be asserted when the block cannot accept the current transaction.
- An output valid must remain asserted while a produced transaction is being retried.
- A block that accepts a request whose result is not immediately consumed must buffer enough state to preserve the transaction.
For example:
fluid fpu(req:FpuReq) -> (resp:FpuResp) {
reg busy = false
reg pending:FpuResp = nil
reg pending_valid = false
req.[retry] = busy or pending_valid
if req.[fire] {
busy = true
start_operation(req)
}
if operation_done {
pending = operation_result
pending_valid = true
busy = false
}
if pending_valid {
resp = pending // resp.[valid] inferred = pending_valid
}
if resp.[fire] {
pending_valid = false
}
}
Holding a Transaction¶
Fluid control often needs to hold a transaction at the current block. A CPU
decode stage, for example, may see an instruction but hold it until a RAW
hazard clears. Holding is expressed by driving .[retry]:
fluid decode(tok:FetchTok) -> (out:DecodeTok) {
tok.[retry] = tok.[valid] and raw_hazard(tok.inst)
if tok.[fire] {
out = decode_inst(tok) // out.[valid] inferred from the path condition
}
}
The idiom has two levels, matching the protocol checks:
- The retry decision reads the payload — legal under
.[valid], even though the token may not fire this cycle. - The consumption — deriving
outfromtok— is guarded by.[fire].
A retried transaction costs no local storage: the producer must keep the
payload stable while valid and retry holds, so the data simply stays
available at the input. To use a transaction's data beyond the cycle
where it fires, capture what is needed into a reg at fire time. These are
the only two ways to extend a transaction's lifetime: retry it (the
producer holds it) or consume it and store locally.
Pass-Through Transactions¶
Pipeline-style CPU stages often carry the same token through several blocks, adding or changing a few fields along the way. Prefer a tuple payload and return an updated token:
type FetchTok = (const pc:u64 = nil, const inst:u32 = nil)
type DecodeTok = (...FetchTok, const src1:u64 = nil, const src2:u64 = nil)
fluid decode(tok:FetchTok) -> (out:DecodeTok) {
tok.[retry] = tok.[valid] and hazard(tok.inst)
if tok.[fire] {
out = (
...tok,
src1 = read_src1(tok.inst),
src2 = read_src2(tok.inst),
)
}
}
This replaces older input output stage ports with explicit transaction
payloads. The handshake remains attached to the whole transaction, not to
each individual field.
Protocol Type Checks¶
The compiler should check fluid protocol use in addition to ordinary type compatibility:
- A fluid call argument must be a fluid value.
- A fluid call result must be bound with
fluid. - A fluid producer assigns the payload;
.[valid]is the inferred path condition of the assignment (an explicit.[valid]assignment overrides). An output that is never assigned is never valid. - A fluid consumer must drive
.[retry], or explicitly tie it to false. - A payload read must be guarded by
.[valid](or a proof that valid holds). Reading while retrying is legal and normal — hazard checks and arbitration need it. - A consuming effect — updating a
regfrom a transaction, or deriving an output transaction from it — must be guarded by.[fire]. - Data lifetime: a payload is only meaningful in cycles where
.[valid]holds. To use it longer, either assert.[retry](the producer holds the payload stable undervalid and retry) or let it fire and capture the needed fields into areg. Reading the payload in a later cycle without one of the two is a compile error. - An input's
.[retry]must not combinationally depend on that same input's.[fire](checked as a handshake combinational loop). - A request-side
.[retry]must be used to stall, hold, or otherwise preserve the request. Ignoring retry is a compile error unless explicitly waived. - Payload written under
valid and retrymust be stable across cycles.
The retry-use rule is intentionally strict. If a CPU issues an FPU operation
and ignores fpu_req.[retry], the operation can be silently dropped. The
language should reject that pattern.
When several internal producers can finish at once, the fluid block owns the arbitration and retries the producers that lose:
fluid result_queue(add:AddResp, mul:MulResp, sqrt:SqrtResp, div:DivResp)
-> (resp:FpuResp) {
// fixed priority: add > mul > sqrt > div; losers are retried
add.[retry] = resp.[retry]
mul.[retry] = resp.[retry] or add.[valid]
sqrt.[retry] = resp.[retry] or add.[valid] or mul.[valid]
div.[retry] = resp.[retry] or add.[valid] or mul.[valid] or sqrt.[valid]
if add.[fire] { resp = add_to_resp(add) }
elif mul.[fire] { resp = mul_to_resp(mul) }
elif sqrt.[fire] { resp = sqrt_to_resp(sqrt) }
elif div.[fire] { resp = div_to_resp(div) }
// resp.[valid] inferred: the or of the four fire conditions
}
The example gives add highest priority, then multiply, square root, and divide. A real implementation may use a different policy, but the policy belongs inside the fluid block that merges the streams.
Relation to Optional Valid¶
Fluid validity is not a new mechanism. Pyrope already infers .[valid] for
ordinary values as the path condition of their assignment. That one
inferred bit serves three roles:
- Read safety — consuming a value whose valid is false is an error. This is the existing optional-valid rule.
- Power — a flop holding a value with inferred valid
Cdoes not need to be clocked when!C. Because invalid data is unreadable, payload flops are don't-care on invalid cycles, which licenses automatic clock gating — including the compiler-insertedpipestage flops, which inherit the valid of the value they transport and gate themselves as bubbles travel (see Pipelining). Simulation randomizes invalid payloads so code that peeks at gated-off data fails loudly instead of working in simulation and breaking on the netlist. - Protocol — under
fluid, the same bit becomes the producer half of the handshake, with.[retry]and.[fire]added on top.
valid is inferred from where you assign; retry is explicit because it
is a decision, not a fact. Do not use a fluid value as if it were an
ordinary optional value unless the handshake has fired.
Relation to pipe, stage[N], and @[N]¶
pipe, stage[N], and @[N] remain the right tools for static latency:
stage[3] tmp = mul(a=a, b=b)
stage[1] out@[4] = add(a=tmp@[3], b=c@[3])
Fluid is for dynamic availability:
fluid resp = fpu(req)
if resp.[fire] {
writeback(resp)
}
resp@[N] is rejected for fluid values. Even if a fluid block has a bounded
internal latency, downstream retry can delay the visible transaction.
If latency bounds are useful for verification or scheduling, express them as fluid attributes:
fluid[lat=1..=70, ordered=true] fpu(req:FpuReq) -> (resp:FpuResp) {
// ...
}
These attributes constrain protocol behavior, but they do not make the
result cycle-addressable with @[N].
Lowering¶
A fluid port lowers to payload plus handshake signals. For a request/response FPU:
req payload -> state, round, op, src1, src2
req.[valid] -> start
req.[retry] <- out_retry
resp payload <- fpu_result, fpu_state, fpu_icc
resp.[valid] <- fpu_ready
resp.[retry] -> in_retry
For a simple retry stage:
input payload -> din
input.[valid] -> dinValid
input.[retry] <- dinRetry
output payload <- q
output.[valid] <- qValid
output.[retry] -> qRetry
.[fire] does not need to lower to a stored signal unless it is referenced;
it is the combinational expression valid and !retry.