P4 Match-Action Engine#

The device rocm-ernic emulates is, in silicon, a P4-programmable DPU: its datapath is a match-action pipeline loaded at run time rather than a fixed function. The P4 engine is the emulator’s equivalent. It sits between the guest’s Ethernet queues and the host network backend and decides, for every frame in either direction, where the frame goes and what it looks like when it gets there.

This is built only when the ERNIC_BUILD_P4 CMake option is on, which it is not by default:

cmake -B build -G Ninja -DERNIC_BUILD_P4=ON
cmake --build build

./build/rocm-ernic \
  --socket /tmp/vfio-user-rocm-ernic.sock \
  --backend loopback --tap ernic0 --p4

Give it a program with --p4-program, which implies --p4:

./build/rocm-ernic \
  --socket /tmp/vfio-user-rocm-ernic.sock \
  --backend loopback \
  --p4-program tests/data/p4/loopback.json

A program the engine will not run stops the server starting, and says which feature it used. A bad program is a startup failure, never a silently inert pipeline.

The RDMA backend is unrelated to any of this. The backends never touch the engine, so --backend makes no difference to what the pipeline sees. The device’s RDMA data path does run a loaded program, but only one written for it; see below.

Status#

The engine loads a compiled P4 program and runs it, on two paths.

On the Ethernet path it runs over every frame. bmv2’s own loopback.json forwards a frame unchanged; simple_router.json does not load (see below). Table entries can be installed, changed and removed, so the same program and the same frame can be made to take a different path.

On the RDMA path it runs over every send work request, but only for a program that declares a metadata header called rdma_metadata. Any other program is an Ethernet program, and the RDMA path behaves as if none were loaded. The header’s fields are the operation’s opcode (bit<8>), qp_num (bit<24>), length, imm_data and rkey (bit<32> each). Each is optional, but one declared at another width is refused at load, and so is a program whose deparser emits the header: it is architecture state, not something to put on a wire. Nothing is parsed or deparsed on this path. The program decides only whether to drop the operation and whether to apply fp8 arithmetic to it, through an extern.

A dropped operation is denied: nothing is transferred, and the work request completes with a local QP operation error (IBV_WC_LOC_QP_OP_ERR), whether or not it was signalled, and the server logs that the P4 program denied it (at most 10 such lines every 5 seconds, so that a guest can’t fill the log, and a line before the next one says how many weren’t logged). The refusal is the sending NIC’s own, made before anything reaches a wire, so a UD send fails the same way as an RC operation rather than looking like a lost datagram. As with any error completion, the guest’s provider then flushes the rest of the QP’s work. The exception is a drop that goes with an extern call that did the operation, below: that completes successfully.

An extern is how a target gives a program something the language cannot express. P4 has no floating-point type – its base types are bit-vectors – so fp8 arithmetic is provided as one rather than written in P4. A program instantiates it and calls it from an action, and p4c emits the call only when given --emit-externs; the loader allow-lists the type, so a program calling an extern this target does not have is refused by name rather than loaded with a call that goes nowhere.

What the program decides is the policy: whether to act, and with which operation. What it cannot decide is which memory is touched – the operand regions come from the work request the guest posted, not from anything a table entry can name. A value a P4 program computed is not a capability, and letting an entry supply a key and an address would make the control plane an addressing authority over guest memory. So this is table-driven selection over an architecture-fixed operand convention, which is a real thing but less than “programmable” might suggest.

The RDMA data path runs the pipeline for every send work request, after the request’s own memory has been checked and before it is dispatched, so a program never sees an operation that is already a protection error. The operand convention is fixed by the architecture: an RDMA write whose gather list is exactly two equal-length entries supplies the two operands, and the work request’s rkey and address are the destination. Each operand is N bytes and so is the result; the work request’s own length field says 2N, because a driver puts the sum of the gather list there and cannot be made to say anything else. An action that calls the extern must also mark_to_drop(), since the extern writes the destination itself and the ordinary transfer would land on top of the result; an operation whose program does not is failed rather than allowed to do that.

All three regions are local. The destination the work request names is checked against this device’s own memory region table and written with a local DMA, exactly as the two operands are, so a reduction never crosses the mesh. An rkey belonging to a peer node is refused with a local QP operation error, which is deliberate: a reduction that could write a remote key would make the compute path an addressing authority over another guest’s memory. What a collective gets here is therefore “reduce locally, then ship the result”, which costs a second RDMA operation – not an in-network reduction.

A table keyed on the opcode also matches ordinary writes. The opcode cannot tell a reduction from a plain RDMA write, so the action fires for both. A request that is not the two-equal-operand shape has not asked for arithmetic: the extern declines it, the pipeline stops at the call, and the operation dispatches as it would with no program at all. That is why a one-SGE RDMA write still works on a device whose guest installed a reduce entry. A decision the program made before the call still stands: if it had already called mark_to_drop() – earlier in the action, or in an earlier table – the operation is dropped, not dispatched. A request that is the compute shape but is otherwise bad – an unregistered key, an operand over the ceiling – is still failed.

tests/data/p4/fp8_policy.p4 is the worked example. A table keyed on the opcode turns an ordinary RDMA write into an elementwise fp8 add across three memory regions, and changing the one table entry changes the arithmetic with no change to the device.

Converting between formats#

The extern’s other method, cvt(src_fmt, dst_fmt), reads one region and writes the same number of elements in another format – longer, shorter or the same length. The table entry names both formats, so one program narrows or widens depending only on what the control plane put in it.

The formats, as the numbers a table entry carries (enum p4_fmt, src/p4/p4_fmt.h):

#

Format

Layout

0

fp32

IEEE 754 binary32, 1-8-23

1

fp16

IEEE 754 binary16, 1-5-10

2

bf16

bfloat16, 1-8-7

3

fp8_e4m3

OCP FP8 E4M3, 1-4-3; no infinity

4

fp8_e5m2

OCP FP8 E5M2, 1-5-2

5

fp4_e2m1

OCP MXFP4 E2M1, 1-2-1; no infinity or NaN

fp4_e2m1 is the only one narrower than a byte, so its elements are packed two to a byte, element i in byte i / 2 and in the low nibble when i is even. An odd element count is therefore half a byte at one end and is refused rather than rounded.

The operand convention follows from reading one region: an RDMA write whose gather list has exactly one entry is the source, and the work request’s rkey and address are the destination. One entry rather than two is what tells a conversion from a reduction, and it is the shape a driver already produces for an ordinary one-buffer write.

This is the only operation whose destination is not the same length as its source. Each region is checked against its own length, and nothing beyond the result is written.

The destination may not overlap the source, and the device does not check that it does not. A conversion is streamed a chunk at a time – read, convert, write – so a destination overlapping the part of the source still to be read destroys it before it is used, and the operation then completes successfully with wrong results from that point on. Widening reaches it easily: fp16 to fp32 into the same address writes two bytes for every one it has read, so the second chunk’s input is gone before the first is finished.

It is unchecked because checking it is not a range comparison. Two keys may be registered over the same guest pages, so overlap has to be decided on what the keys resolve to rather than on the keys themselves, and the answer that also covers that case is to stage the whole source before writing any of it. Until then a guest that overlaps the two regions corrupts its own memory with its own keys: it reaches nothing it has not registered, and every region is still checked for that.

Two conventions, and both follow from what a format can represent rather than from taste. A magnitude too large for the destination becomes an infinity if the format has one and saturates to the largest finite magnitude if it does not – which is what AMD’s conversion instructions do, and what a reduction needs, since one overflowed term becoming an infinity would poison a whole sum. And a NaN stays a NaN wherever there is an encoding for one; fp4_e2m1 has none, so a NaN saturates there too, sign preserved. That is lossy and cannot be otherwise.

Rounding is to nearest with ties to even throughout. The generic codec is held to p4_fp8.c for E4M3 – all 256 decodings and a four-million-float encode sweep must agree exactly – which is how it inherits that file’s own validation against ml_dtypes. Every encoding of every format must survive a decode and re-encode unchanged unless it is a NaN. fp16 was also checked against numpy when the codec was written – all 65536 decodings and 2.1 million encodes agreed exactly – but no test repeats that comparison.

tests/data/p4/fp8_convert.p4 is the worked example.

What is not built yet: checksums and calculations; negative literals in actions; and counters, meters, registers and action profiles. Each is refused by name rather than ignored.

The guest’s control surface#

A guest reaches the engine through ionic device commands 70, 71 and 72 – identify, RPC and query – which arrive both through the BAR0 devcmd window and over the Ethernet admin queue, as the RDMA commands do. Through them a driver can push a compiled program down in chunks, install, modify and delete table entries, and read the engine’s counters.

What the guest may do is the operator’s decision, not the guest’s. --p4-guest-scope implies --p4, even with none, and takes one of:

none

The default. The surface answers nothing, not even identify, and the P4 capability bit is not advertised.

read

Identify the surface, enumerate its endpoints and operations, and read counters.

write

Also install, modify and delete table entries.

load

Also replace the running program.

The grades are ordered and a wider one includes the narrower ones. Nothing is granted by default because a guest that can replace the program decides where every frame goes and which RDMA operations become fp8 reductions; that is not a capability to hand out as a side effect of the operator enabling the engine for their own use. Each operation declares the scope it needs, query advertises that scope, and the device refuses a request for which either the operator’s grant or the caller’s own claim is too narrow – the operator’s grant first, so that probing cannot reveal which operations exist.

Shaped like pds_fwctl, not pds_fwctl#

The opcode numbers are upstream’s (PDS_FWCTL_CMD_IDENT = 70, _RPC = 71, _QUERY = 72, from include/linux/pds/pds_adminq.h) and so is the shape: an endpoint id, an operation id, a scope the device checks, and request and response buffers named by guest address.

The wire format is not. Upstream’s pds_fwctl binds to a pds_core.fwctl.N auxiliary device and never sends those opcodes as a top-level command: the driver registers for a client id and wraps each one in PDS_AQ_CMD_CLIENT_CMD with a 60-byte inner payload. rocm-ernic emulates ionic, not pds_core, so there is no pds_core admin queue to wrap into and the commands travel on their own. A pds_core-like control device, if one is ever emulated, can present the same operations without renumbering them.

One upstream behaviour is deliberately not copied. Its driver walks the entry count a device reports in a query straight out of the DMA buffer, without checking it against the length it asked for, so an over-reporting device walks the host off the end of a page. A query here reports both what fitted the buffer and how many entries exist, so a guest can retry with a larger one.

The capability bit#

ionic_if.h defines two LIF capability bits, ETH and RDMA, so bit 2 is free and is used here as IONIC_LIF_CAP_P4. It is advertised in LIF_IDENTIFY only when the engine is on and the operator granted a scope, which is how a patched guest driver decides whether to create a P4 auxiliary device. It is keyed on the command surface actually being registered rather than on a separate flag, so the bit and the surface cannot disagree: a guest told it has the capability and then refused every command would have no way to tell that from a broken device. Real hardware never sets the bit, so a driver carrying the patch is unaffected by it.

The port model#

The pipeline sees the emulated NIC as a two-port device:

P4_PORT_HOST (0)

The guest’s Ethernet queues. A frame ingresses here when the guest transmits it, and egresses here by being injected into the guest’s receive ring.

P4_PORT_UPLINK (1)

The host network backend, which is the TAP interface attached with --tap. Without one, nothing ingresses on this port and a frame sent to it is dropped.

A P4 program’s ingress_port and egress_spec metadata carry these values.

A program that runs to completion without assigning egress_spec has not chosen a port, and the frame is forwarded to the other port – what the device does with no program loaded. The field’s initial zero is not read as a request for P4_PORT_HOST: doing that sent every frame a guest transmitted straight back into its own receive ring, turning the guest’s network into a silent loopback that was counted as forwarded rather than dropped. Every program written for the RDMA path does this, fp8_policy.p4 included, so loading the worked example used to cost the guest its Ethernet. A program that does mean port 0 assigns it, and is unaffected.

The engine attaches to the emulated NIC through the two frame filters the Ethernet emulator already provides, so no frame path is duplicated. It takes the transmit filter slot and chains to whoever held it: a frame addressed to the in-band S3 control plane is consumed there and never reaches the pipeline, which is right, because that endpoint is not on the wire the pipeline models.

Everything runs on the thread that owns the vfio-user context – the transmit filter is called from the doorbell path and the receive filter from the server’s poll loop, both of which are already that thread. No lock is taken and no thread is started.

Programs#

scripts/fetch-p4c.sh puts a working p4c under build-p4c without installing anything, so a program can be compiled rather than written by hand:

scripts/fetch-p4c.sh
build-p4c/bin/p4c-bm2-ss --std p4-16 -I build-p4c/p4include \
    -o program.json program.p4

Add --emit-externs for a program that calls a target-specific extern; without it p4c refuses one it does not know.

A program is supplied as a bmv2 JSON file: the target-independent artifact that

p4c --target bmv2 --arch v1model --std p4-16 \
  -o program.json program.p4

emits for any P4 program. Nothing in rocm-ernic links bmv2 itself; the JSON is the interface. bmv2 is still useful as a behavioural oracle: running the same program and the same frames through stock bmv2 shows what the engine is expected to produce.

Loading a program#

A program is loaded in pieces, because it is far larger than any one device command carries, and parsed with json-c. The dependency is taken only when ERNIC_BUILD_P4 is on, so a build without the engine – which is what a superproject sees first – keeps the dependency set it had before.

A program loads completely – the header definitions, the parse state machine, the deparser’s emit order, the actions, and the pipelines with their tables and conditionals – and then runs: parse, walk the pipeline, deparse.

A program’s headers, metadata included, share a 1024-byte packet header vector, each in whole bytes. The loader lays them out once, and refuses a program whose headers take more, saying how many bytes they need; loaded, it would drop every frame and fail every RDMA operation it governs.

Where a frame goes is the program’s decision. It writes standard_metadata.egress_spec, and the engine turns that into an egress port, so a program without that field cannot say where a packet goes and is refused at load. An egress_spec naming a port this two-port device does not have is a drop, never an index – a ported program may well name one.

Values are carried in a fixed-width big-endian buffer wide enough for any field the loader accepts, so a 128-bit address is compared, assigned and added exactly like a nine-bit port number. Nothing is limited to what fits in a machine word, and two values are comparable with one memcmp because both are right-aligned in the same width.

A frame the program cannot be run over is dropped rather than forwarded unexamined. Failing open would put a frame on the wire that the pipeline never saw, which defeats the point of having one.

Table entries#

A loaded program is immutable; its entries are not, so they live beside it rather than in it. p4_engine_update() takes a record shaped like a P4Runtime update – insert, modify or delete, naming a table and an action by name – and is the single path by which any control plane changes a table. A network-facing client and the guest’s own device commands are both meant to produce one of these, rather than each having its own way in. The engine names a table or action it can’t find in a message the server logs, so the device commands refuse an update whose table or action name holds anything but printable ASCII before the engine sees it.

Exact, longest-prefix and ternary matching all work, and the ties are broken as P4 requires: the longest prefix wins for LPM, the highest priority for ternary. Which of two overlapping ternary entries of the same priority wins is unspecified, and can change when another entry is deleted. A table’s memory is taken when its first entry arrives, not when the program loads, so a program declaring large tables and putting nothing in them costs nothing. A lookup reads only the entries installed, not the whole table, but it reads each of them in turn, so a full table of 65536 entries costs every frame that consults it a scan of all of them.

The lookup goes by the table’s match_type, so the loader checks it is the one the table’s keys make it, as p4c derives it: ternary if any key is, else LPM if one is, else exact. A table that disagrees, which would take the first hit in an “exact” table with a ternary key or rank by a prefix an “lpm” one doesn’t have, is refused, as is a table with more than one LPM key, which p4c refuses too. The LPM key need not come first: {vrf: exact; dst: lpm} ranks by dst’s prefix.

An entry is checked against its table’s keys, deletes included, and held to P4Runtime’s rules: a value or mask wider than its field, a prefix longer than the field, a value with a bit set past its prefix or outside its mask, and a prefix on a field that isn’t LPM are refused, not truncated or ignored. An entry in a table with a ternary key needs a nonzero priority, and one in any other table must give none.

P4Runtime has a client omit a “don’t care” field from the entry, where this record, which always carries every key field, writes it as a zero prefix or mask.

p4c writes a key field & constant as the whole field with the constant as its mask. As in bmv2, the packet’s key is put under that mask before it is matched, and so is every entry’s before it is stored or compared: under 0x00ff, an entry for 0x1234 is the entry for 0x0034, and matches 0xab34 too. P4Runtime knows nothing of the mask, so that is what a value it accepts means.

An entry carries at most 128 bits of a key field or an action argument, so a table keyed on a wider field, or an action taking a wider argument, is refused at load.

A table with no entry for a packet takes its default action, which is why minimal_forward.json drops a frame until an entry for its destination is installed, and forwards it afterwards.

The pipeline graph is checked for cycles at load time, by an iterative depth-first search with an explicit stack. A loop there would spin the thread that also services the vfio-user poll loop, and it is reachable through four different kinds of edge – an action’s next table, a table’s miss target, and either arm of a condition – so the check covers all of them rather than the obvious one.

Two bmv2 behaviours are worth knowing because they are not in the format documentation. A table carrying an entries key is immutable in bmv2 and takes its size from that list, so an empty one is a permanently empty table rather than a harmless default; it is refused. And max_size is the only number in the document that names a large allocation without the document’s own size bounding it, so the entries all tables in every pipeline ask for between them are budgeted against a single ceiling.

Unknown keys are ignored, not rejected. That is deliberate: p4c emits program, source_info, action_ids and force_arith, none of which appear in bmv2’s own JSON_format.md, so a loader strict about unknown keys refuses every real artifact. A feature that is known and not implemented is a different case, and is refused by name – p4_engine_load_error() returns something like 'checksums' is not supported yet – so a user learns which P4 feature they used.

One consequence worth stating: simple_router.json, the example bmv2’s own format documentation links to, does not load. Its calculations and checksums carry the IPv4 checksum, which is not implemented. Removing them is not enough: it decrements the TTL with add_to_field and the literal -0x1, and a negative literal is refused too. p4c emits one only for a signed int<W> value, which is refused as a signed field anyway, and compiles an unsigned ttl - 1 to (ttl + 0xff) & 0xff. Running -0x1 as 0x1 would make the TTL go up, and what a negative value means depends on the width it is stored into, which an extern argument does not have. loopback.json has both empty, and loads.

Every document is untrusted, so there is no clamping and no truncation anywhere in the loader: an oversized array, a name with a newline in it, a width given as a floating-point number, a duplicate name or id, a literal wider than the field it is assigned to, and a reference to something that does not exist are each a rejection.

So is a program that would load and then not run as written. Tables and conditionals share one namespace across every pipeline, as in bmv2, since a next table and a table update reach only the first of a name. A condition’s operands must be what its operator takes, as bmv2 types them: a header for valid, booleans for not, and and or, values for < and its kin, and two of a kind for == and !=; the executor would read anything else as a number. A header that goes on a wire is a whole number of bytes, as p4c requires for bmv2, and standard_metadata’s egress_spec and ingress_port have v1model’s 9 bits.

So is a document that would cost too much to parse. A program may be up to 4 MiB, but json-c spends far more heap on a value than the value takes as text – about 790 bytes for {} – so 4 MiB of empty objects would take a gigabyte, allocated on the thread that also services the vfio-user poll loop. Before json-c sees a document, the loader weighs the values in it, an object as 8, an array as 2 and anything else, object keys included, as 1, and refuses one weighing more than 524288 (P4_JSON_MAX_COST). That holds the parse to about 50 MiB and tens of milliseconds. The p4c artifacts in tests/data/p4/ weigh a unit for every 8 to 13 bytes as p4c indents them, or 3.5 to 5 bytes minified, so the budget admits one of about 4 MiB as p4c writes it, or at least 1.75 MiB minified.

A parser select on several fields gives each field whole bytes of the key, right-aligned in them, as bmv2 builds it and as p4c writes the values it is compared with: selecting on a 12-bit VLAN ID and an EtherType, VLAN 10 over IPv4 is 0x000a0800.

Alignment with AMD Pensando silicon#

The point of consuming a standard artifact rather than inventing a table format is that work done here should transfer to the real device. Three things line up by construction:

  • bmv2 JSON and p4info as the program artifacts, so any P4 program p4c can compile is loadable.

  • P4Runtime semantics on the update path – table entries, INSERT/MODIFY/DELETE, and P4Runtime’s match rules – so a network-facing control API can be the one the ecosystem already speaks. Arbitration is not modelled: the update record carries no role and no election id.

  • The DASH pipeline as the target program. dash-pipeline/bmv2/dash_pipeline.p4 models the DASH overlay dataplane and is the closest open artifact to what the DPU runs. It is written for TARGET_BMV2_V1MODEL and carries @SaiTable and @SaiVal annotations that p4c emits into p4info, so the SAI naming comes with it.

Where this differs from the silicon#

These are real divergences, recorded rather than papered over.

The control path

On hardware the Ethernet path is ionic and the control path is pds_core, a separate PCI function on the auxiliary bus, reached from the host through pds_fwctl with an (endpoint id, operation id, buffers) RPC whose scope the driver checks. rocm-ernic emulates only ionic, so the host-facing API is ionic device commands shaped like those RPCs (see The guest’s control surface). A faithful emulation eventually means a pds_core-like control device.

The architecture

The DPU’s P4 architecture is not v1model, and the real architecture file is proprietary. v1model is used here because DASH uses it.

Port numbering

P4_PORT_HOST is 0 and P4_PORT_UPLINK is 1, and that is our convention rather than one inherited from DASH. DASH’s two bmv2 ports are both underlay-facing, and its pipeline derives direction from the outer VXLAN VNI (stages/direction_lookup.p4), never from an ingress port – standard_metadata.ingress_port appears once in its whole bmv2 tree, as a default echo. Two consequences for a ported DASH program: it will drop every frame arriving on P4_PORT_HOST, because guest traffic has no underlay encap for the vip table to match, and it can set an egress_spec of 2, which this two-port model must reject rather than index.

Stateful connection tracking

DASH’s own roadmap notes that stock bmv2 lacks the stateful processing DASH wants. This engine has the same gap.

Arithmetic width

bmv2 evaluates expressions at infinite precision and truncates on assignment to a field. A fixed-width C engine will not always agree. Here every value is carried at the widest field the loader accepts, 512 bits (P4_MAX_FIELD_BITS).

Security#

The guest is untrusted, and so is a program file. Both the program and every table entry are validated against the loaded program before use: field, header, action and table identifiers are range-checked, frame lengths are bounded at both ends of the pipeline, and a frame larger than the engine will handle is rejected and counted rather than truncated. See AGENTS.md for the rules this code is held to.

Testing#

Three tiers, none of which needs a VM, RDMA hardware or root.

p4-engine-unit (tests/test_p4_engine.c)

The engine as a function: the port model, the input validation, the frame bounds and the statistics. Links the engine’s own translation units – p4_engine.c, p4_exec.c, p4_json.c and p4_table.c – and none of the device’s. The loader is held to tests/data/p4/reject_cases.json, each case of which must be refused with the errno and the reason it names, and tests/data/p4/features.json runs frames through a parser select with masks, LPM and ternary tables, every arithmetic primitive and every comparison. tests/data/p4/key_mask.json covers a masked table key and a 128-bit key and argument.

ionic-harness-p4-port (tests/test_ionic_harness.c)

The engine attached to the whole in-process device, with libvfio-user replaced by a fake. Covers the transmit filter chain: a frame claimed by the endpoint that already held the slot must not reach the pipeline, one it declines must, and detaching must hand the slot back rather than leave it empty. ionic-harness-p4-devcmd and ionic-harness-p4-devcmd-errors drive the guest’s control surface through the BAR0 devcmd window, including every way it refuses a command, and ionic-harness-p4-log-limit checks that a guest looping on a denied operation or a refused command gets only a burst of log lines in each interval.

ernic-p4-driver (tests/py/test_p4.py)

The engine on a real server process, reached over a real vfio-user socket with real DMA. ernic.eth drives the emulated NIC’s Ethernet transmit ring the way the guest driver does, so the frames the pipeline sees are gathered out of guest memory rather than handed over in-process. This is the tier that tests the server rather than the emulator.

Reading the counters#

The engine’s counters live inside the process. The server prints them as one line at shutdown, at the default log level:

rocm-ernic: P4 stats: host_in=3 host_out=0 uplink_in=0 uplink_out=3 \
  dropped=0 too_big=0 parse_errors=0 to_guest=0 to_net=0 \
  to_upstream=0 egress_errors=3

With no TAP attached, a frame forwarded to the uplink port has nowhere to go, so egress_errors counts it. That is the expected result today and is what the Python test asserts on.

What cannot be tested without privilege#

Creating a TAP needs CAP_NET_ADMIN, so ingress on the uplink port is unreachable in a registered test: the receive filter is applied only to frames read from the host backend. Attaching to a pre-created, user-owned persistent TAP does not need privilege, and is the pattern tests/test_ionic_ci.sh already uses for its optional checks.

The round trip needs no TAP: with loopback.json loaded, a frame the guest transmits is sent back to the port it arrived on and lands in the guest’s receive ring, which ernic-p4-driver checks.