PSS 3.1: Target Runtime
The two ways a PSS description connects to a platform have very different tradeoffs. Target-code generation – last post’s subject – gives you fine-grained control over the syntax of the code that implements a test; it treats PSS as a language for generating test code. The cost is that the model has to specify everything the generated text contains, and means the tool can’t do much with the model beyond expanding it.
The procedural interface trades that control away and gets something back. When the model is written against an API instead of against a body of text, the tool knows what is being called and with what values, and it gets to choose how to make that happen – emit a C file, walk the scenario in a runtime linked into the test, drive it from the host. That’s the target runtime, and it’s where PSS 3.1 does some of its most interesting work: once something of PSS is present while the test runs, the standard can start defining behavior that happens during the test rather than behavior decided before it started.
What it is
There are really three ways a PSS model can reach the platform, and it’s worth being precise about where they differ, because two of them look identical in the model and differ entirely in what shows up on the target.
All three end up calling dma_start(64). The diagram is deliberately arranged
so that the outcome is constant and only the path to it changes.
- Literal code. The model carries a snippet of target-language text and the tool expands it. The call in the generated file is there because the model author typed it into a string; the tool has no idea what the snippet means and never sees a function at all. This is last post’s subject.
- Generated calls. The model declares the environment’s API with
import target functionand calls it from a native PSSexec body. The tool still emits the same text file – but now it wrote the call, because it knows the signature and it knows the argument values. - Runtime calls. The same model as (2), realized differently: no test text is generated at all. The tool’s target runtime holds the scenario, walks the activity, and invokes the environment’s API directly.
Rows 1 and 2 differ only in who knows what. Rows 2 and 3 are exactly the same PSS description. The difference between rows 2 and 3 is entirely in how the tool implements the output.
Rows 2 and 3 are the ones that the rest of this post depend on because, in both cases, the PSS tool provides a runtime library accessible via function calls. And, that’s what makes it possible to talk about behavior specified in PSS that happens during the test.
Channels
An activity says what order actions run in. parallel says two actions run
concurrently; a binding says one’s output is the other’s input. All of that is
settled before the test starts – it’s scheduling, and scheduling is a
solve-time concern.
What an activity cannot specify is coordination between concurrently-executing
actions at runtime. Two actions running
concurrently on the target have had no way to tell each other anything while
they run. PSS 3.1 adds sync_pkg (21.9) for exactly this, and in this draft it
contains one type: channel_c.
component channel_c<type T, int DEPTH=1> {
target function T get();
target function void put(T t);
target function bool try_get(output T t);
target function bool try_put(T t);
}
A FIFO of DEPTH elements (default 1), holding values of type T, with
blocking and non-blocking access at both ends. If you’ve written SystemC or
SystemVerilog, this likely looks familiar. What’s new is where it lives:
the channel is a PSS component, the operations are target functions, and the
implementation is the tool’s problem instead of yours.
An example that isn’t statically determinable
The usual sketch of a channel is a producer and a consumer passing data items, which is fine but doesn’t show why this needs to be in the language – you can schedule producers and consumers with an activity today. The case that needs a runtime handshake is the one where the moment matters and nothing at solve time knows when it arrives.
So: inject a parity error into a NoC stream, but only once that stream is genuinely congested. Congestion is the whole point of the test – an error injected into an idle fabric exercises a different path entirely – and the occupancy threshold is reached whenever the DUT gets around to reaching it. There’s no traversal count that means “backed up.”
Two actions, run in parallel. One drives load and watches occupancy; the
other waits, injects, and reports back.
import sync_pkg::*;
component noc_c {
// Environment APIs available on the target platform
import target function void send_burst(bit[8] strm, bit[16] len);
import target function bit[8] occupancy(bit[8] strm);
import target function void inject_parity_err(bit[8] strm);
import target function bit[8] err_status(bit[8] strm);
import target function void expect_parity_err(bit[8] status);
channel_c<bit[8]> congested; // load -> injector: "backed up on this stream"
channel_c<bit[8]> injected; // injector -> load: "done; here is the status"
action load_a {
rand bit[8] strm;
rand bit[8] in [60..90] threshold;
exec body {
bit[8] status;
bool signaled = false;
// Keep the traffic up until the injector reports back
while (!comp.injected.try_get(status)) {
send_burst(strm, 256);
if (!signaled && occupancy(strm) >= threshold) {
comp.congested.put(strm);
signaled = true;
}
}
expect_parity_err(status);
}
}
action inject_a {
exec body {
// Blocks until the load action reports congestion, and learns
// which stream got there from the data the channel carries
bit[8] strm = comp.congested.get();
inject_parity_err(strm);
comp.injected.put(err_status(strm));
}
}
action inject_under_load_a {
activity {
parallel {
do load_a;
do inject_a;
}
}
}
}
Three things in there are worth pulling out.
The blocking end is the easy end. inject_a consists of one get() and
the work that follows it. It doesn’t poll, doesn’t know what the threshold is,
and doesn’t know which stream it’s injecting into until the channel tells it –
strm arrives as data, not as a solved attribute. Meanwhile load_a, which
must keep driving traffic or the backpressure it just built up drains away,
uses try_get and folds the check into the loop it was already running. Same
channel type, two different needs.
A blocking get() doesn’t wedge the executor. This is the part that would
be genuinely awkward to arrange yourself on bare metal. 20.8 requires that when
target exec code blocks on a channel operation, other exec blocks assigned to
the same executor keep being evaluated – so inject_a can sit in get() on
the same core that’s running load_a’s traffic loop. (The converse obligation
is on you: a busy-wait loop that never blocks and never calls yield really
will starve everything else on that executor.)
Two channels, not one. Each one is unidirectional and each carries a
payload that happens to be useful – the stream id going out, the error status
coming back. It’s tempting to declare one channel and use it both ways; with
DEPTH 1 and two concurrent participants that’s a deadlock waiting for a
scheduling change to find it.
Here are a few detailed by critical And the details that will bite eventually (21.9.1):
- Element types are limited to numeric types, Booleans, enums with a base type, packed structs, and arrays thereof. Data is copied in and out by value, so a channel is a message queue, not a shared object.
- All four operations are thread-safe, but the order in which multiple waiters
are released is non-deterministic. If two actions are blocked in
get()on the same channel, exactly one gets each element and you don’t get to say which. putblocks when the channel is full, which for the defaultDEPTHof 1 means the secondputblocks until someone drains the first.try_putexists for the cases where you’d rather drop than wait.
What’s actually new here
Almost nothing in the example above is impossible today. You could write it against your own semaphore library and your own FIFO and get the same test. The difference is what travels with the content.
That’s the portability argument for pulling a primitive in-house: a model that
uses channel_c is a model someone else can run. A model that uses your
acme_sync_pkg often is not.
It’s worth noting that sync_pkg currently contains exactly one type. No
semaphore, no barrier, no event. channel_c can be pressed into service as all
three – a channel_c<bit> whose payload nobody reads is an event, a
DEPTH-N channel pre-loaded
with tokens is a semaphore – but “can be pressed into service as” is a
reasonable thing to have an opinion about during a public review.
Export functions
In the channel example, both ends of the handshake were PSS. One exec block signalled another. But the event you most often actually want to wait for isn’t produced by PSS at all – it’s an interrupt signalled by the runtime platform.
Every mechanism we’ve looked at so far points one direction: the model calls
out. export function (20.4.2) is the one that points back. It takes a native
PSS function and makes it callable from the environment, which means the
environment now has a way to put something into a channel.
That’s the whole round trip, and the interesting constraint is in the middle of
it. An exported function is static – 20.4.2 allows the qualifier only on
static functions in a component or package – so it has no action, no
attributes, and no comp. Consequently, it must access required data relative
to something accessible in the global space. What it wants to access is the
DMA’s completion channels.
import sync_pkg::*;
import executor_pkg::*;
package platform_pkg {
// Hands the environment a handle naming the executor PSS is running on
import target function void irq_register(chandle ctxt);
}
import platform_pkg::*;
component dma_c {
static const int NCHAN = 4;
// Environment APIs on the target platform
import target function void dma_program(bit[8] chan, bit[64] src,
bit[64] dst, bit[32] len);
import target function void dma_start(bit[8] chan);
import target function bit[32] dma_ack_irq(bit[8] chan); // read and clear
// One rendezvous per hardware channel, so concurrent transfers don't
// race for each other's completions
channel_c<bit[32]> completion[NCHAN];
action xfer_a {
rand bit[8] chan;
rand bit[64] src;
rand bit[64] dst;
rand bit[32] len;
constraint chan < NCHAN;
exec body {
dma_program(chan, src, dst, len);
dma_start(chan);
// Blocks here. No polling loop, no yield, no status register read
// -- the handler will deliver the status when the DMA finishes.
bit[32] status = comp.completion[chan].get();
if (status != 0) {
error("DMA channel %d completed with status %d", chan, status);
}
}
}
}
component pss_top {
dma_c dma;
// Give the environment the context handle before any test code runs
exec run_start {
irq_register(executor().get_context());
}
}
// What the C handler calls back into. Runs in interrupt context, so it
// uses try_put: a full channel must not become a hung handler.
static function void dma_irq(bit[8] c) {
pss_top.dma.completion[c].try_put(dma_ack_irq(c));
}
export target function dma_irq;
The environment side is unremarkable, which is the point:
/* Generated by the PSS tool for 'export function dma_irq'. The leading
* context parameter is what tells the runtime which executor is calling. */
extern void dma_irq(void *ctxt, unsigned char chan);
static void *irq_ctxt;
/* Called from the PSS model's run_start exec */
void irq_register(void *ctxt)
{
irq_ctxt = ctxt;
plic_enable(IRQ_DMA);
}
/* Ordinary platform interrupt vector */
void dma_isr(void)
{
dma_irq(irq_ctxt, dma_pending_chan());
}
Three things to observe about the example:
The status comes back through the channel, not through the wait. It would
be easy to build this so the exported function just signals “done” and the
action then reads the status register itself. Don’t – by then the handler has
already cleared it. dma_ack_irq reads and clears in interrupt context, and
the value rides the channel to whoever was waiting. The channel is carrying
data, not just a wakeup.
The handler uses try_put, not put. An exported function called from an
ISR is running in interrupt context on the real platform, and put blocks when
the channel is full. try_put can’t. If it returns false you have a dropped
completion, which is a bug you want to find as a dropped completion rather than
as a hung interrupt handler.
The action never spins. Compare this against the polling loop it replaces:
a while loop reading a status register, with a yield in it so it doesn’t
starve everything else on the core (20.8). That loop works, but it burns the
core it’s running on and it does the thing the DUT is supposed to be doing –
it observes completion rather than being told about it. Blocking in get()
leaves the executor free to evaluate the other exec blocks assigned to it.
Worth knowing (20.4.2):
- Parameter and return types are restricted:
bit/intup to 64 bits,bool,enumwith a base type no wider than 64 bits,float32/float64,string,chandle, and structs – but a struct parameter has to be declaredconst. AnnexD has the C, C++ and SystemVerilog type mappings. - The context handle round trip –
executor().get_context()out inrun_start, stored by the environment, passed back on every call – is the pattern from Example300 and Example301. There’s no ambient “current executor” for the environment to discover; it has to be told. Note that this is still needed even with a path-addressed channel: the handle is what tells the runtime whose context the exported function body runs in. - The array isn’t decoration. 21.9.1 makes the release order non-deterministic
when several actions are blocked in
get()on the same channel, so a singlecompletionwould hand channel 2’s status to whoever happened to be woken. One channel per hardware channel means each waiter can only be released by its own completion.
This is the part of the target runtime that’s genuinely a new capability rather than a nicer spelling of an old one. You could always call out of a PSS model. Calling into one requires that there be something there to call.
Conclusions and providing feedback
Target-code generation in 3.1 was a list of accumulated workarounds being
retired. The target runtime is a different kind of change: channel_c and
export function let a PSS model participate in what happens while the test
runs, not just describe what should happen before it starts. A scenario can
wait for a condition nothing at solve time can predict, and the platform can
reach back into the model to tell it the condition arrived.
Here’s what I’d suggest thinking about while reading Clause 20 and Clause 21.9 of the draft:
- Find the place in your environment where you hand-rolled a
synchronization primitive – a semaphore, a mailbox, a “wait for IRQ”
helper – and try to express it with
channel_c. Does the single type cover it, or did you want the barrier and the event thatsync_pkgdoesn’t have yet? - Look at how your tests wait for completion today. If the answer is a
polling loop over a status register, work out what it would take to replace
it with an export function and a blocking
get(). The interesting question is what the environment side costs you. - Check the mixed-language case. 21.9.1(d) promises the handshake works across executors implemented in different languages. If you have a design where the C side and the SystemVerilog side both need to participate, that requirement is the one carrying the weight – and it’s worth saying so if it doesn’t cover your case.
That also wraps up this tour of the PSS 3.1 public review draft. Constraints, composition, target code, target runtime – four posts, and the review closes at the end of September, so there’s still time. It takes a community to build a standard, and your eyes and attention are a much-appreciated contribution!
Next time we’ll change gears and start looking at open-source tooling that helps support PSS adoption.
References
- Feedback Forum
- PSS 3.1 Draft
- Post 1: PSS 3.1 Is Out for Public Review
- Post 2: Simplifying Constraint Modeling
- Post 3: Simplifying Composition
- Post 4: Target Code