Adding a New Target

The P4 specification leaves some decisions about program structure and semantics up to targets. Petr4 is modular in the same way, for two key reasons: it helps us comply with the specification, and it lets users easily add support for new targets. This will be useful for anyone interested in using Petr4 to test P4 code written in an architecture we do not yet support, or those looking to design a new P4 architecture. Here, we document the process of adding an implementation of a P4 architecture to our interpreter.

What is a P4 architecture?

At surface level, an architecture in P4 provides a collection of extern functions/datatypes and defines the user-programmable components of its packet-processing pipeline. However, a closer examination of the the P4 specification reveals that many decisions in the P4 semantics are left up to targets. The architecture’s responsibilities include but are not limited to:

  • provide static analysis for enforcing target-dependent well-formedness rules

  • define semantics for initializing metadata

  • provide semantics for threading the packet and other metadata through the pipeline

  • define the behavior of reading from uninitialized/invalid headers

  • provide custom table attributes

  • define custom semantics for the invocation/execution of tables

Therefore, an architecture consists of definitions for all of these semantic concerns. Indeed, many of these components correspond directly to values required by our signature for a target implementation.

Adding to our code

The Petr4 interpreter is written as a module functor in the OCaml module system. Before the interpreter can run at all, a target implementor has to provide an implementation of the Target module type and instantiate the interpreter with their target. The specialized module will evaluate programs according to the semantics of the target.

The signature for the module type Target is given in lib/target.mli. It is worth noting that there is a large collection of helper functions defined in target.mli which will be available in the newly implemented target. Our own implementations of the V1model and eBPF architectures make use of these. Also provided by target.mli is the abstract type for the state of the program. An implementer will be primarily concerned with the functions insert_extern and find_extern, as they deal with the part of the state which contains all of the target-provided stateful objects.

We now step through the process of implementing this signature using the example architecture Very Simple Switch (VSS), a toy architecture that the P4 language specification uses to illustrate the oddities of target-dependent semantics in P4. For reference, the target description file in the P4 code is given below:

# include <core.p4>

typedef bit<4> PortId;

const PortId REAL_PORT_COUNT = 4w8;

struct InControl {
    PortId inputPort;
}

const PortId RECIRCULATE_IN_PORT = 0xD;
const PortId CPU_IN_PORT = 0xE;

struct OutControl {
    PortId outputPort;
}

const PortId DROP_PORT = 0xF;
const PortId CPU_OUT_PORT = 0xE;
const PortId RECIRCULATE_OUT_PORT = 0xD;

parser Parser<H>(packet_in b, out H parsedHeaders);

control Pipe<H>(inout H headers,
                in error parseError,
                in InControl inCtrl,
                out OutControl outCtrl);

control Deparser<H>(inout H outputHeaders, packet_out b);

package VSS<H>(Parser<H> p,
               Pipe<H> map,
               Deparser<H> d);

extern Checksum16 {
    Checksum16();
    void clear();
    void update<T>(in T data);
    void remove<T>(in T data);
    bit<16> get();
}

First, the user must provide the type obj, which will be used to represent target-provided stateful values. In the case of VSS, only one such data structure is needed – the underlying value of the Checksum16 extern. Some other commonly used externs include counter and register arrays, as in V1 Model. The types state and extern are parameterized on the type obj and should be copied into the implementation as they appear in the signature.

The user must also provide the functions write_header_field and read_header_field. These functions will be called by the main interpreter when reading and writing to headers, and they are intended to capture the fact that the semantics in these cases is left target-dependent by the P4 specification. However, the abstraction in its current form is not necessarily expressive enough to capture all possible decisions for header reads and writes. The average user will likely want to reuse our implementations of these functions in v1model.ml and ebpf.ml.

The next required value is the eval_extern function. This function takes as its arguments the name of the extern to evaluate, the envrionment and state, the type arguments of the extern call (e.g. a concrete value for T in the case of update and remove in VSS provided by the type checker), and the arguments paired with their types. For implementation, we may assume that the arguments are provided in the correct order and that the list is the proper length. The is also the place where we use the number and types of the arguments to distinguish between different externs of the same name, as permitted by the P4 specification. Note that in the case of an invocation of an extern function using dot-notation, e.g. checksum.clear(), the value of checksum will be available as the addition first argument in the list of arguments. The extern evaluation should then return as a tuple the updated environment (though most externs do not change the environment), the update state, a signal (almost always Continue), and the return value. Implementing the externs will require some degree of familiarity with our types for values, environments, and states. Also, note that any mutation of an extern object should be done by updating the mapping in the state via insert_extern.

The target must also define what meta-data to initialize. Most targets initialize metadata upon packet ingress for each individual packet, consisting at least of the port number. The current version of our interpreter takes as input this port number, and it is provided as an argument to initialize_metadata. Note that the current architecture is not expressive enough to capture all values one may wish to include in the metadata, such as time stamps.

We also include a post-processing step, get_outport, which is intended to examine the state and environment for the metadata at the end of the packet-processing pipeline in order to decide on which port number the P4 program has determined the packet should be emitted.

Lastly, the target implementation should provide the function eval_pipeline, which determines how the components of the main P4 package should interact in order to process a given packet. The pipeline evaluator takes as inputs the control-plane configuration, initial environment and state, and the packet, outputting the updated state, environment, and optional packet (no packet corresponds to the packet having been dropped). The astute reader will notice the mysterious final argument of type state apply. This function is the only piece of the interpreter which we were unable to move into target.ml due to a circular dependency, so the interpreter passes it to the pipeline evaluator as an argument. A typical implementation of a pipeline will normally invoke this apply function on the packet and the other required arguments to the parser(s) and control(s). Because the target is responsible for providing arguments to its P4-programmable blocks, this will involve constructing values of our value type explicitly and loading them into the environment and state. The user should be careful to choose variable names that will not result in naming conflicts. In the case of VSS, an implementation will need to pass a packet value and an uninitialized header to the parser, pass the resulting headers, error, and metadata to the control, and finally pass the updated packet and metadata to the deparser, returning the updated state, environment and packet.

The final step is to apply the Corize functor from lib/p4core.ml to your new target to ensure that it provides the standard operations on packets provided by the core library in addition to its own externs (this is a single line at the bottom of your newly implemented target). Then, apply the MakeInterpreter functor from lib/eval.mli to your corized target in a new line at the bottom of eval.ml(i) to instantiate the semantics of P4 on your custom interpreter! This provides you with the library of evaluation functions given in lib/eval.mli which you may use to process individual packets on custom control-plane configurations.

Supported Architectures

Petr4’s implementation currently supports the V1 Model architecture, which is perhaps the most widely used architecture in P4. It is standard to bmv2, and much of the benchmark suite from P4c is written in the V1 model. Many of the standard operations we may expect a network device to perform are expressed in the V1 model’s extern library, and it’s pipeline is representative of the structure of a standard packet-processing pipeline.

We also support the eBPF Filter architecture. This is a much smaller architecture, with a minimal collection of externs and a pipeline consisting of only two components. This is supported primarily for proof-of-concept that our abstraction over targets is powerful enough to describe more than only the V1 model.

The code that implements the externs and the pipelines of the V1 model and the eBPF filter can be found in lib/v1model.ml and lib/ebpf.ml respectively. The implementation of ebpf.ml in particular may be a good reference point for someone seeking to implement their own target.

Limitations

There are two main ways in which our current implementation fails to capture the full expressivity of the P4 abstract machine. First, our implementation has no support for concurrency, and we only consider single-packet execution with a mostly static control-plane configuration. Some important externs from the V1 model library that have to do with concurrency and multiple packets (such as clone and resubmit) are unimplemented in v1model.ml. Second, our abstraction excludes from consideration some important concerns about target-dependent semantics in P4. Specifically, while we provide minimal support for target-dependent decisions about invalid headers, our abstraction is not expressive enough to describe all possible decisions a target may make about accessing invalid header fields, header union, uninitialized stack accesses, etc. We also omit target-dependent table behaviors such as certain table annotations or custom table attributes.