Ethernet · Module 13
VLAN Processing in the Switch Datapath
Widening the key to {VID, MAC} changes the capacity fractions by nothing and the memory by exactly 25%. The index map that makes 4094 VLANs affordable is also where two VLANs can silently become one.
Chapter 13.1 priced the state and specified the requirement. Chapter 13.2 decoded the tag. Chapter 13.3 assigned the VLAN and represented it on the wire. Every one of them handed something to this chapter and stopped.
Three things arrive here: a 12-bit VID that must index arrays sized for far fewer VLANs, a 60-bit key that Chapter 12.5's structure must absorb, and a 3-bit priority that must select an egress queue.
The first is where this chapter's danger lives. Chapter 13.1 §10 established that dense arrays over the full 4094-VLAN range cost 60 KiB — 0.94× the entire forwarding table — and that real designs put a VID-to-index map in front. That map is a many-to-one function from 4094 values onto however many VLANs are configured, and every check downstream of it operates on the index.
Which means two VIDs sharing an index are one VLAN as far as every mechanism in Modules 12 and 13 is concerned — including Chapter 13.1 §12's isolation monitor, which will report perfect isolation between VLANs that have already been merged.
1. Scope — What This Chapter Owns
This chapter owns the datapath: the VID-to-index map and its aliasing hazard, the 60-bit key and what it does to Chapter 12.5's structure, the recomputation of that chapter's capacity arithmetic, and the mapping from Chapter 13.2's PCP into egress queues.
It does not own the requirement — Chapter 13.1 established what must become per-VLAN, categorised every resource, and priced the state.
It does not own the tag — Chapter 13.2 owns TPID, PCP, DEI and VID, and the parser that resolves their offsets.
It does not own the transformation — Chapter 13.3 owns assignment at ingress and representation at egress. This chapter takes an assigned VID as its input and hands a queued frame to that chapter's egress transformer.
And it does not own scheduling policy beyond the mapping. Section 11's scheduler exists to show what PCP selects and what a bad selection costs; the arbitration itself is Chapter 12.1 §9's, and Module 14 is where the queue's depth becomes the subject.
2. Two Representations, Two Jobs
A VLAN arrives at this datapath as a 12-bit VID and immediately needs to be two things at once.
As an identity, it is part of the forwarding key. Chapter 13.1 §7 established that a station's identity is {VID, MAC} — the same address in two VLANs is two different stations — so the table must distinguish 4094 possible VLANs, and the key carries all twelve bits.
As an array subscript, it selects a membership bitmap, a flood mask and a set of port states. Chapter 13.1 §10 showed that indexing those arrays by the raw VID costs 60 KiB on a 24-port switch — 0.94× the whole forwarding table — for state that holds no addresses.
The map exists to reconcile those two demands and it does so by giving up something: distinctness.
| Design | Arrays sized for | Map | Total | Two VIDs can collide |
|---|---|---|---|---|
| dense | 4094 | none | 60.0 KiB | no |
| indexed, 1024 configured | 1024 | 5.0 KiB | 20.0 KiB | yes, if misprogrammed |
| indexed, 256 configured | 256 | 4.0 KiB | 7.8 KiB | yes |
| indexed, 64 configured | 64 | 3.0 KiB | 4.9 KiB | yes |
The map is 4094 entries wide regardless — every possible VID needs a translation — and it is ⌈log₂ N_VLANS⌉ bits deep. At 256 configured VLANs that is 4094 × 8 = 4.0 KiB, and the arrays it feeds shrink from 60 KiB to 3.8.
And the column on the right is the price. A dense design cannot alias, because the array subscript is the VID. An indexed design can, because the map is a function that two inputs may share an output of — and nothing downstream of the map can tell.
3. RTL 1 — The VID-to-Index Map
4094 entries of eight bits, one lookup per frame, and one hazard that no downstream check can see.
// -----------------------------------------------------------------------
// vlanidx_pkg -- shared types for the VLAN datapath.
//
// The distinction this package exists to enforce: vid_t is the identity
// and goes in the KEY; vidx_t is a subscript and goes in the ARRAYS. They
// are different types on purpose, because the only thing standing between
// an indexed design and two merged VLANs is not confusing them.
// -----------------------------------------------------------------------
package vlanidx_pkg;
localparam int VID_W = 12; // the identity -- 4094 usable
localparam int VIDX_W = 8; // the subscript -- 256 configured
localparam int ADDR_W = 48;
localparam int KEY_W = VID_W + ADDR_W; // 60 -- Chapter 13.1 Section 7
localparam int PCP_W = 3;
localparam int N_QUEUES = 4;
localparam int QSEL_W = 2;
typedef logic [VID_W-1:0] vid_t;
typedef logic [VIDX_W-1:0] vidx_t;
// Why a VID could not be translated. The first is a configuration
// state; the second is the failure this chapter is about.
typedef enum logic [1:0] {
MR_OK = 2'd0,
MR_UNCONFIGURED = 2'd1, // this VID has no index -- the VLAN does not exist here
MR_ALIASED = 2'd2, // two VIDs share this index -- Section 5
MR_RESERVED = 2'd3 // 0 or 4095
} map_result_e;
// Scheduling discipline for Section 11. Strict priority is the default
// and it is the one that starves.
typedef enum logic [1:0] {
SCHED_STRICT = 2'd0,
SCHED_WEIGHTED = 2'd1,
SCHED_ROUND = 2'd2
} sched_mode_e;
endpackage// -----------------------------------------------------------------------
// vid_index_map -- 12 bits to a compact subscript.
//
// A PROGRAMMED TRANSLATION TABLE, not a hash. It stores no key to compare
// against, so a collision is undetectable at lookup time and must be
// prevented at programming time -- which is what Section 5 exists for.
// -----------------------------------------------------------------------
module vid_index_map
import vlanidx_pkg::*;
#(
parameter int N_VIDS = 4096,
parameter int N_VLANS = 256,
parameter int CNT_W = 32
)(
input logic clk,
input logic rst_n,
// Configuration: bind one VID to one index.
input logic cfg_valid,
input vid_t cfg_vid,
input vidx_t cfg_vidx,
input logic cfg_present,
// Per-frame translation.
input logic lk_valid,
input vid_t lk_vid,
output logic idx_valid,
output vidx_t idx,
output map_result_e result,
output logic [CNT_W-1:0] c_translated,
output logic [CNT_W-1:0] c_unconfigured,
output logic [11:0] n_vids_bound,
output logic [CNT_W-1:0] map_bits
);
vidx_t tbl_idx [N_VIDS];
logic tbl_present [N_VIDS];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int v = 0; v < N_VIDS; v++) begin
tbl_idx[v] <= '0;
tbl_present[v] <= 1'b0;
end
end else if (cfg_valid) begin
tbl_idx[cfg_vid] <= cfg_vidx;
tbl_present[cfg_vid] <= cfg_present;
end
end
logic vid_reserved;
assign vid_reserved = (lk_vid == 12'd0) || (lk_vid == 12'd4095);
always_comb begin
idx_valid = 1'b0;
idx = '0;
result = MR_OK;
if (lk_valid) begin
if (vid_reserved) begin
result = MR_RESERVED;
end else if (!tbl_present[lk_vid]) begin
// The VID is legal and this switch has no VLAN for it. Distinct
// from an aliasing failure: nothing is merged, the frame simply
// has nowhere to go.
result = MR_UNCONFIGURED;
end else begin
idx_valid = 1'b1;
idx = tbl_idx[lk_vid];
end
end
end
// NOTE WHAT IS ABSENT. There is no stored VID to compare the lookup
// against, because the index IS the answer. Chapter 12.5's hashed table
// stores the full key and compares it on every lookup; this cannot,
// and that is the difference between a detectable collision and an
// undetectable one.
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
c_translated <= '0;
c_unconfigured <= '0;
end else if (lk_valid) begin
if (idx_valid) begin
c_translated <= c_translated + 1'b1;
end else if (result == MR_UNCONFIGURED) begin
if (!(&c_unconfigured)) c_unconfigured <= c_unconfigured + 1'b1;
end
end
end
always_comb begin
n_vids_bound = 12'd0;
for (int v = 0; v < N_VIDS; v++)
if (tbl_present[v]) n_vids_bound = n_vids_bound + 12'd1;
end
assign map_bits = CNT_W'(N_VIDS) * CNT_W'(VIDX_W + 1);
endmoduleClassification: synthesizable, with tbl_idx inferring a 4096-entry memory of 9 bits.
What it teaches: the absence that matters. There is no stored VID to compare the lookup against, because the index is the answer. Chapter 12.5 §6's hashed table stores the full 48-bit key beside every entry and compares it on every lookup — so two addresses hashing to one set are still two distinct entries and a lookup for one never returns the other. This module has nothing analogous, and that single structural difference is what turns a detectable collision into an undetectable one.
And it teaches that MR_UNCONFIGURED is a different condition from aliasing. A VID with no index means this switch has no VLAN by that number — the frame has nowhere to go and is discarded, loudly, with a counter. Aliasing means two VIDs got the same index, which is not a refusal at all: both frames translate successfully and both proceed into a datapath that now believes they are one VLAN.
Deliberately simplified: a flat 4096-entry table, which is 4096 × 9 = 4.5 KiB and is the honest cost. Production designs sometimes compress it — a base-and-range scheme, or a small CAM over the configured VIDs — but every compression reintroduces a lookup that can fail, and the flat table's virtue is that its translation is a single deterministic read.
Production implication: n_vids_bound against N_VLANS is the commissioning check. A switch with 300 VIDs bound and 256 index slots has necessarily aliased at least 44 of them, and the pigeonhole argument is available to management software before a single frame is forwarded. Publishing n_vids_bound lets the configuration layer refuse the 257th binding rather than accept it and silently merge two VLANs — which is Chapter 13.1 §11's vlan_ceiling argument, arriving here as a concrete mechanism.
4. What an Aliased Index Actually Does
Two VIDs mapping to one index is not a subtle degradation. It is two VLANs becoming one, completely, in every mechanism this track has built — and every one of them reports success.
Suppose VID 10 and VID 20 both map to index 7.
| Mechanism | What it does | Result |
|---|---|---|
| Chapter 13.1 §9's membership | reads members[7] | one bitmap for both VLANs |
| Chapter 13.3 §11's untagged set | reads untagged[port][7] | one setting for both |
| Chapter 12.4's flood mask | scoped to index 7 | a VLAN 10 broadcast floods to VLAN 20's ports |
| Chapter 13.1 §12's isolation monitor | checks members[7][port] | clean — the port is a member of index 7 |
| Chapter 12.3's port state | reads pstate[7][port] | one state for both |
| the table key | uses the full VID | still distinct — 10 and 20 differ |
The last row is the one that makes this diagnosable at all, and only if the key was built correctly.
Because the key carries the full 12-bit VID, the forwarding table still holds {10, A} and {20, A} as separate entries. A unicast lookup for A in VLAN 10 returns VLAN 10's port. So unicast forwarding is correct while flooding is merged — and that asymmetry is the signature.
And if the key were built from the index instead, even that would go. {7, A} is one entry, updated by whichever VLAN's station transmitted last, and Chapter 13.1 §15's silent frame loss appears on top of the merge.
| key from the VID | key from the index | |
|---|---|---|
| unicast in VLAN 10 | correct | whichever VLAN transmitted last |
| broadcast in VLAN 10 | reaches VLAN 20 too | reaches VLAN 20 too |
| the symptom | broadcast leaks, unicast does not | everything is intermittently wrong |
| what it looks like | a routing or bridging oddity | a hardware fault |
The left column's symptom is peculiar enough to be a clue. Broadcasts cross between two VLANs and unicast traffic does not is not a shape any other failure in this track produces — Chapter 13.3 §16's native-VLAN mismatch merges everything, and a membership misconfiguration merges nothing.
5. RTL 2 — Catching an Alias at Programming Time
A collision cannot be detected at lookup, so it must be prevented at configuration — and the check is a reverse map that costs one entry per index.
// -----------------------------------------------------------------------
// vid_alias_checker -- refuses a binding that would give two VIDs one
// index.
//
// The map stores no key to compare at lookup time, so the only place a
// collision can be caught is at the write. This module keeps the reverse
// direction -- which VID owns each index -- and refuses a second claimant.
// -----------------------------------------------------------------------
module vid_alias_checker
import vlanidx_pkg::*;
#(
parameter int N_VLANS = 256,
parameter int CNT_W = 32
)(
input logic clk,
input logic rst_n,
input logic cfg_valid,
input vid_t cfg_vid,
input vidx_t cfg_vidx,
input logic cfg_present,
output logic cfg_accept,
output map_result_e cfg_result,
output vid_t conflicting_vid,
output logic [CNT_W-1:0] c_bindings,
output logic [CNT_W-1:0] c_alias_refused,
output logic [11:0] indices_used,
output logic map_is_injective
);
// THE REVERSE MAP. One VID per index, which is the property the forward
// map cannot express. 256 entries of 13 bits is 416 octets -- against
// the 4.5 KiB forward map, a rounding error.
vid_t owner [N_VLANS];
logic owner_val [N_VLANS];
always_comb begin
cfg_accept = 1'b0;
cfg_result = MR_OK;
conflicting_vid = '0;
if (cfg_valid) begin
if ((cfg_vid == 12'd0) || (cfg_vid == 12'd4095)) begin
cfg_result = MR_RESERVED;
end else if (!cfg_present) begin
// Unbinding is always permitted.
cfg_accept = 1'b1;
end else if (owner_val[cfg_vidx] && (owner[cfg_vidx] != cfg_vid)) begin
// THE REFUSAL. This index already belongs to a different VID.
// Accepting would merge two VLANs in every array in the design,
// permanently, with no runtime signal.
cfg_result = MR_ALIASED;
conflicting_vid = owner[cfg_vidx];
end else begin
cfg_accept = 1'b1;
end
end
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int i = 0; i < N_VLANS; i++) begin
owner[i] <= '0; owner_val[i] <= 1'b0;
end
c_bindings <= '0;
c_alias_refused <= '0;
end else if (cfg_valid) begin
if (cfg_accept) begin
if (cfg_present) begin
owner[cfg_vidx] <= cfg_vid;
owner_val[cfg_vidx] <= 1'b1;
if (!(&c_bindings)) c_bindings <= c_bindings + 1'b1;
end else begin
owner_val[cfg_vidx] <= 1'b0;
end
end else if (cfg_result == MR_ALIASED) begin
if (!(&c_alias_refused)) c_alias_refused <= c_alias_refused + 1'b1;
end
end
end
always_comb begin
indices_used = 12'd0;
for (int i = 0; i < N_VLANS; i++)
if (owner_val[i]) indices_used = indices_used + 12'd1;
end
// THE INVARIANT, as a signal. If the map is injective, no two VIDs
// share an index and every mechanism downstream is looking at exactly
// one VLAN.
assign map_is_injective = 1'b1; // maintained by construction above
endmoduleClassification: synthesizable.
What it teaches: that the reverse map is what the forward map cannot express, and it costs almost nothing. 256 entries of 13 bits is 416 octets, against the forward map's 4.5 KiB — a rounding error that converts an undetectable runtime merge into a refused configuration write.
And it teaches where the refusal has to happen. The lookup cannot detect a collision because there is nothing to compare. The frame cannot detect it because both VIDs translate successfully. No downstream mechanism can detect it because they all operate on the index. The write is the only moment at which both VIDs are visible together, and a design that does not check there has no other opportunity.
Deliberately simplified: one binding at a time with a combinational conflict check. Production configuration paths are often a bulk write of an entire VLAN table, which needs the same check applied across the whole batch — and a bulk write that checks each entry against the previous state while the batch is internally inconsistent will accept a set of bindings no single write would have.
Production implication: c_alias_refused should be zero in a working deployment and non-zero during commissioning is a good sign — it means management software attempted a binding the hardware refused, which is the mechanism working. A design without this check reports nothing in either case, and the difference between a correctly configured switch and one with two merged VLANs is invisible from every interface. indices_used against N_VLANS is the same pigeonhole argument Section 3's n_vids_bound makes, from the index side.
6. RTL 3 — Building the Key
Sixty bits, and the only thing that matters is which twelve of them come from where.
// -----------------------------------------------------------------------
// vlan_key_builder -- forms the {VID, MAC} key for Chapter 12.5's table.
//
// THE ONE RULE: the key carries the VID, never the index. The index is
// six to ten bits and many-to-one; the VID is twelve bits and injective
// over the VLANs that exist. A key built from the index makes two VLANs
// share every entry -- Section 4's right-hand column.
// -----------------------------------------------------------------------
module vlan_key_builder
import vlanidx_pkg::*;
#(
parameter int CNT_W = 32
)(
input logic clk,
input logic rst_n,
input logic in_valid,
input vid_t vid, // the IDENTITY
input vidx_t vidx, // the SUBSCRIPT -- deliberately unused here
input logic [ADDR_W-1:0] mac,
output logic key_valid,
output logic [KEY_W-1:0] key,
output logic [7:0] key_width,
output logic [7:0] vid_bits_in_key,
output logic [CNT_W-1:0] c_keys,
output logic uses_index_in_key // must be permanently 0
);
// Chapter 13.1 Section 7: the VID goes in the HIGH bits so that one
// VLAN's entries share a key prefix, which makes a per-VLAN flush a
// range operation rather than a full-table walk.
assign key = {vid, mac};
assign key_valid = in_valid;
assign key_width = 8'(KEY_W);
assign vid_bits_in_key = 8'(VID_W);
// A STANDING ASSERTION AS A SIGNAL. The index is an input to this
// module and is deliberately not used. Exposing that as a wire lets a
// reviewer check the claim against the code, and lets Section 14's
// monitor check it against the built design.
assign uses_index_in_key = 1'b0;
// Tie off the unused subscript explicitly rather than leaving it
// dangling, so that a future edit connecting it is visible in a diff.
wire _unused_vidx = |vidx;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) c_keys <= '0;
else if (in_valid) c_keys <= c_keys + 1'b1;
end
endmoduleClassification: synthesizable, and almost entirely wires.
What it teaches: that the index is an input to this module and is deliberately unused, and that the tie-off is the point rather than an artefact. A module that simply does not have a vidx port looks like it never needed one — and the next engineer, integrating it beside a datapath where every other block takes an index, may reasonably connect one. Taking the input and refusing to use it makes the refusal reviewable, and uses_index_in_key makes it checkable by Section 17.
And it teaches why the VID sits in the high bits, which Chapter 13.1 §7 argued and this chapter depends on. One VLAN's entries share a 12-bit key prefix, so a per-VLAN flush — required when a topology change affects one VLAN and not the other 4093 — is a masked range operation rather than a walk comparing a field on every entry.
Deliberately simplified: a flat concatenation. A production design may carry the key as two fields through the pipeline and concatenate only at the table interface, which is equivalent and makes the two halves' provenance visible for longer.
Production implication: key_width and vid_bits_in_key are elaboration-time constants exposed as outputs, and their value is in a diff. A design that changes the key width — to add a bridge domain, a tenant identifier, or the second tag Chapter 13.2 §14 described — changes these numbers, and every capacity figure in Section 8 changes with them. Exposing them means the recomputation is prompted by the code rather than remembered.
7. RTL 4 — The Table at Sixty Bits
Chapter 12.5's structure absorbs the wider key without any structural change. The adapter exists to make that claim precise and to expose what did change.
// -----------------------------------------------------------------------
// vlan_table_adapter -- Chapter 12.5's hashed set-associative table with a
// 60-bit key instead of 48.
//
// WHAT DOES NOT CHANGE: the hash width, the set count, the associativity,
// the sweep, the eviction policy, and every capacity fraction in Chapter
// 12.5 Sections 7, 9 and 10.
// WHAT CHANGES: the stored key, the entry width, the set read width and
// the table's total memory -- all by exactly 25%.
// -----------------------------------------------------------------------
module vlan_table_adapter
import vlanidx_pkg::*;
#(
parameter int N_SETS = 2048,
parameter int WAYS = 4,
parameter int HASH_W = 11,
parameter int PORT_W = 5,
parameter int AGE_W = 9,
parameter int CNT_W = 32
)(
input logic clk,
input logic rst_n,
input logic lk_valid,
input logic [KEY_W-1:0] lk_key,
output logic lk_hit,
output logic [PORT_W-1:0] lk_port,
output logic [2:0] lk_latency_cy,
input logic ins_valid,
input logic [KEY_W-1:0] ins_key,
input logic [PORT_W-1:0] ins_port,
// The hash. Chapter 12.5 Section 5's fold, over 60 bits instead of 48 --
// and the extra 12 bits are the VID, which is far better distributed
// than the OUI-heavy address space it joins.
output logic [HASH_W-1:0] hash_index,
// What changed, computed rather than asserted.
output logic [7:0] entry_bits,
output logic [15:0] set_read_bits,
output logic [CNT_W-1:0] table_bits,
output logic [7:0] growth_pct
);
// Entry: the full key plus port, age, valid and static. Chapter 12.5
// Section 6 established that a hash is not reversible, so the ENTIRE
// key is stored -- there is no tag saving at 48 bits and none at 60.
localparam int ENT_RAW = KEY_W + PORT_W + AGE_W + 2;
localparam int ENT_ROUND = (ENT_RAW <= 64) ? 64 : ((ENT_RAW <= 80) ? 80 : 96);
typedef struct packed {
logic valid;
logic [KEY_W-1:0] key;
logic [PORT_W-1:0] port;
logic [AGE_W-1:0] age;
logic stat;
} ventry_t;
ventry_t mem [N_SETS][WAYS];
// THE HASH IS UNCHANGED IN WIDTH. 11 bits, 2048 sets. Widening the key
// does not widen the index, which is why Section 8's capacity fractions
// are identical to Chapter 12.5's.
always_comb begin
automatic logic [23:0] lo = lk_key[23:0];
automatic logic [23:0] mid = lk_key[47:24];
automatic logic [11:0] hi = lk_key[59:48]; // the VID
automatic logic [23:0] mix;
mix = lo ^ {mid[11:0], mid[23:12]} ^ {12'd0, hi};
hash_index = mix[HASH_W-1:0] ^ mix[23:24-HASH_W];
end
logic [WAYS-1:0] way_match;
always_comb begin
way_match = '0;
for (int w = 0; w < WAYS; w++)
// THE FULL 60-BIT COMPARE. Chapter 12.5 Section 6's rule, wider by
// twelve bits and otherwise identical.
if (mem[hash_index][w].valid && (mem[hash_index][w].key == lk_key))
way_match[w] = 1'b1;
end
always_comb begin
lk_hit = 1'b0;
lk_port = '0;
if (lk_valid) begin
lk_hit = |way_match;
for (int w = WAYS-1; w >= 0; w--)
if (way_match[w]) lk_port = mem[hash_index][w].port;
end
end
// Chapter 12.5 Section 16: hash, one set read, compare, select.
// Unchanged -- a wider comparator is still one comparator.
assign lk_latency_cy = 3'd4;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int s = 0; s < N_SETS; s++)
for (int w = 0; w < WAYS; w++) mem[s][w] <= '0;
end else if (ins_valid) begin
for (int w = 0; w < WAYS; w++)
if (!mem[hash_index][w].valid) begin
mem[hash_index][w] <= '{valid: 1'b1, key: ins_key,
port: ins_port, age: '0, stat: 1'b0};
break;
end
end
end
assign entry_bits = 8'(ENT_ROUND);
assign set_read_bits = 16'(ENT_ROUND) * 16'(WAYS);
assign table_bits = CNT_W'(N_SETS) * CNT_W'(WAYS) * CNT_W'(ENT_ROUND);
assign growth_pct = 8'(((ENT_ROUND - 64) * 100) / 64);
endmoduleClassification: synthesizable, with mem inferring a two-dimensional SRAM whose row is one 320-bit set.
What it teaches: that the hash index width does not change. Eleven bits, 2048 sets, whatever the key's width — and since Chapter 12.5 §7's overflow arithmetic depends only on the number of sets, the associativity and the number of addresses offered, every capacity fraction in that chapter is identical at 60 bits. Section 8 does the recomputation and the answer is that nothing moved.
And it teaches where the extra twelve bits actually help. Chapter 12.5 §5's callout established that MAC addresses are not uniformly distributed — a rack of identical servers shares a 24-bit OUI and differs in a handful of low serial bits. The VID is a small, densely-used, operator-assigned space that correlates with nothing in the address, so folding it into the hash adds entropy exactly where the address space has none.
Deliberately simplified: insertion without eviction or the refusal policy — Chapter 12.5 §6 and §11 own those and neither changes here. The break in a for loop is illustrative; a real design uses a priority encoder over the free-way mask.
Production implication: growth_pct is the number to carry into a floorplan review, and it is 25% — the entry goes from 64 bits to 76, which rounds to 80, so an 8192-entry table goes from 64 KiB to 80 KiB and a four-way set read from 256 bits to 320. The set read width is the one that touches timing, because Chapter 12.5 §16's four-cycle lookup depends on a whole set arriving in one memory access — and a 320-bit row is a different memory compile from a 256-bit one, on a path with ten cycles of margin and no more.
8. Chapter 12.5 Recomputed at Sixty Bits
The prompt for this chapter was to redo Chapter 12.5's capacity arithmetic with the widened key and report what changed. The answer is worth stating before the tables: the fractions did not move, and the memory grew by exactly 25%.
Chapter 12.5 §7 — sets full at a given occupancy, 4-way × 2048:
| Occupancy | 48-bit key | 60-bit key |
|---|---|---|
| 25% | 0.37% — 7.5 sets | 0.37% — 7.5 sets |
| 50% | 5.27% — 107.8 sets | 5.27% — 107.8 sets |
| 75% | 18.47% — 378.3 sets | 18.47% — 378.3 sets |
Chapter 12.5 §9 — effective capacity, offered 8192 distinct addresses:
| Structure | 48-bit key | 60-bit key |
|---|---|---|
| direct-mapped | 5178 — 63.2% | 5178 — 63.2% |
| 2-way | 5975 — 72.9% | 5975 — 72.9% |
| 4-way | 6592 — 80.5% | 6592 — 80.5% |
| 8-way | 7049 — 86.0% | 7049 — 86.0% |
Nothing moved, and the reason is structural rather than a coincidence. Chapter 12.5 §7's arithmetic is a balls-in-bins calculation over S sets, W ways and n offered addresses. The key's width appears nowhere in it. Widening the key widens what is stored and compared; it does not widen the index, which is still eleven bits selecting one of 2048 sets.
What did change is memory, and every figure by the same 25%:
| Quantity | 48-bit key | 60-bit key | Change |
|---|---|---|---|
| key stored per entry | 48 bits | 60 bits | +25% |
| entry, raw | 48 + 16 = 64 | 60 + 16 = 76 | — |
| entry, rounded | 64 bits | 80 bits | +25% |
| 8192-entry table | 64 KiB | 80 KiB | +25% |
| 4-way set read | 256 bits | 320 bits | +25% |
| CAM cells, 8192 entries | 393 216 | 491 520 | +25% |
| lookup latency — §16 | 4 cycles | 4 cycles | none |
And two things that did not change at all are worth naming, because both look as though they should have.
The lookup latency is still four cycles. Chapter 12.5 §16's pipeline is hash, one set read, compare, select — and a 60-bit comparator is still one comparator. It is wider and therefore slower in absolute terms, which is a timing-closure question rather than a cycle-count one.
The ageing sweep is still 0.0033% duty. It visits 8192 entries once a second regardless of how wide they are; the entries are read as whole rows and the row got 25% wider, so the sweep's bandwidth grew 25% — from a number four orders of magnitude below the traffic to a slightly larger number four orders of magnitude below the traffic.
9. RTL 5 — Mapping Priority to a Queue
Eight priorities, four queues, and a map that is sixteen bits per port and decides whether the priority mechanism does anything at all.
// -----------------------------------------------------------------------
// pcp_queue_mapper -- Chapter 13.2's three-bit PCP to an egress queue.
//
// Chapter 13.1 Section 6 categorised the egress arbiter as UNCHANGED by
// VLANs and said priority was a different mechanism. This is that
// mechanism, and its whole content is a per-port table of eight entries.
// -----------------------------------------------------------------------
module pcp_queue_mapper
import vlanidx_pkg::*;
#(
parameter int N_PORTS = 24,
parameter int CNT_W = 32
)(
input logic clk,
input logic rst_n,
input logic cfg_valid,
input logic [4:0] cfg_port,
input logic [PCP_W-1:0] cfg_pcp,
input logic [QSEL_W-1:0] cfg_queue,
input logic in_valid,
input logic [4:0] egress_port,
input logic [PCP_W-1:0] pcp,
input logic drop_eligible, // Chapter 13.2's DEI
output logic [QSEL_W-1:0] queue,
output logic discard_first, // to Chapter 12.1 Section 9
output logic [CNT_W-1:0] c_by_queue [N_QUEUES],
output logic [CNT_W-1:0] c_by_pcp [8],
output logic map_is_identity, // 8 -> 8, no compression
output logic map_is_flat, // everything to one queue
output logic [15:0] map_bits_per_port
);
logic [QSEL_W-1:0] map [N_PORTS][8];
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int p = 0; p < N_PORTS; p++)
for (int i = 0; i < 8; i++)
// A SENSIBLE DEFAULT, not zero. Mapping every priority to
// queue 0 makes the feature inert; mapping proportionally at
// least honours the ordering the sender expressed.
map[p][i] <= QSEL_W'(i * N_QUEUES / 8);
end else if (cfg_valid) begin
map[cfg_port][cfg_pcp] <= cfg_queue;
end
end
assign queue = map[egress_port][pcp];
// Chapter 13.2 Section 4: DEI is a hint to Chapter 12.1 Section 9's
// discard policy and is advisory in both directions. It selects no
// queue -- it marks a preferred victim within one.
assign discard_first = in_valid && drop_eligible;
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int q = 0; q < N_QUEUES; q++) c_by_queue[q] <= '0;
for (int i = 0; i < 8; i++) c_by_pcp[i] <= '0;
end else if (in_valid) begin
c_by_queue[queue] <= c_by_queue[queue] + 1'b1;
c_by_pcp[pcp] <= c_by_pcp[pcp] + 1'b1;
end
end
// Two degenerate configurations that make the mechanism inert, both
// reported rather than assumed away.
always_comb begin
map_is_flat = 1'b1;
map_is_identity = (N_QUEUES == 8);
for (int i = 1; i < 8; i++)
if (map[0][i] != map[0][0]) map_is_flat = 1'b0;
end
assign map_bits_per_port = 16'd8 * 16'(QSEL_W);
endmoduleClassification: synthesizable.
What it teaches: that the default matters more than the configurability. A map initialised to all zeros sends every priority to queue 0, so the feature is present, consuming queues, and inert — and nothing reports that unless map_is_flat is computed. A proportional default — pcp × N_QUEUES ÷ 8 — at least honours the ordering the sender expressed, which is the part of PCP that survives compression from eight levels to four.
And it teaches that DEI selects no queue. Chapter 13.2 §4 established it as a hint to Chapter 12.1 §9's discard policy — a preferred victim within a queue, not a different queue — and a design that routes DEI into the queue selection has turned a discard hint into a priority demotion, which is a different and much stronger action than the sender asked for.
Deliberately simplified: a per-port map with no ingress regeneration. Chapter 13.3 §7's callout established that a boundary port cannot trust an arriving PCP; the regeneration table belongs at ingress and this map at egress, and a design with only one of them either trusts a stranger's marking or cannot express a per-egress policy.
Production implication: c_by_pcp and c_by_queue together diagnose the two ways this feature fails silently. Everything in PCP 0 means nobody is marking — the map is fine and there is nothing for it to do. Everything in one queue with a spread of PCPs means the map is flat — the senders are marking and the switch is discarding the distinction. The two look identical from a throughput measurement and are fixed in completely different places.
10. Eight Priorities Into Four Queues
Compression is the normal case, and which eight-into-four map is chosen decides what the priority mechanism actually delivers.
| Map | PCP 0–7 → queue | What it delivers |
|---|---|---|
| flat | all → 0 | nothing — the feature is inert |
| proportional | 0,0 1,1 2,2 3,3 | ordering preserved, granularity halved |
| top-heavy | 0,0,0,0 1,2,3,3 | four low priorities merged, three high ones separated |
| bottom-heavy | 0,1,2,3 3,3,3,3 | low priorities separated, all high traffic merged |
| identity | requires 8 queues | full granularity, 8 × 3 = 24 bits per port |
The top-heavy row is what most real configurations look like, and the reason is a property of the traffic rather than of the mechanism.
Priority marking in practice is bimodal. Chapter 13.2 §4's c_by_pcp histogram on a real network is dominated by PCP 0 — untagged hosts and anything that does not mark — with a small population at one or two high values used for voice, video or control. The middle of the range is nearly empty.
So a map that spends its four queues evenly across eight priorities is spending three of them on a range nothing uses, while merging the values that carry the traffic that actually needed separating.
And the cost of the map itself is trivial in every configuration:
| Queues | Bits per PCP entry | Map per port | 24 ports |
|---|---|---|---|
| 2 | 1 | 8 bits | 24 octets |
| 4 | 2 | 16 bits | 48 octets |
| 8 | 3 | 24 bits | 72 octets |
Forty-eight octets for a 24-port switch. Against Chapter 13.1 §10's 60 KiB of per-VLAN state and this chapter's 4.5 KiB VID map, the priority map is free — and the thing that is not free is the queues themselves, which is why the compression exists.
11. RTL 6 — Scheduling the Queues
The map chose a queue. Something must now choose between queues, and the obvious discipline starves.
// -----------------------------------------------------------------------
// egress_queue_scheduler -- selects among per-priority queues on one port.
//
// Chapter 12.1 Section 9's arbiter chose among INGRESS ports contending
// for one egress. This chooses among PRIORITY CLASSES contending for one
// egress. The two are in series, not in competition, and this one runs
// first.
// -----------------------------------------------------------------------
module egress_queue_scheduler
import vlanidx_pkg::*;
#(
parameter int CNT_W = 32,
parameter int WIN = 1_000_000
)(
input logic clk,
input logic rst_n,
input logic [N_QUEUES-1:0] q_nonempty,
input logic [7:0] q_weight [N_QUEUES],
input sched_mode_e mode,
input logic grant_taken,
output logic sel_valid,
output logic [QSEL_W-1:0] sel_queue,
output logic [CNT_W-1:0] c_served [N_QUEUES],
output logic [CNT_W-1:0] starved_cycles [N_QUEUES],
output logic starvation_detected,
output logic [QSEL_W-1:0] starved_queue,
output logic [15:0] lowest_share_pct
);
logic [7:0] credit [N_QUEUES];
logic [QSEL_W-1:0] rr_q;
logic [CNT_W-1:0] win_total;
always_comb begin
sel_valid = |q_nonempty;
sel_queue = '0;
unique case (mode)
// STRICT PRIORITY. The highest non-empty queue always wins. It is
// the default, it is what "priority" means to most people, and it
// starves every lower queue for as long as a higher one has
// anything to send.
SCHED_STRICT: begin
for (int q = 0; q < N_QUEUES; q++)
if (q_nonempty[q]) sel_queue = QSEL_W'(q);
end
// WEIGHTED. Each queue gets credit proportional to its weight and
// is skipped when its credit is exhausted, so a low-priority queue
// always gets SOME share.
SCHED_WEIGHTED: begin
for (int q = N_QUEUES-1; q >= 0; q--)
if (q_nonempty[q] && (credit[q] != 8'd0)) sel_queue = QSEL_W'(q);
end
default: begin
for (int q = 0; q < N_QUEUES; q++) begin
automatic int idx = (int'(rr_q) + q) % N_QUEUES;
if (q_nonempty[idx]) sel_queue = QSEL_W'(idx);
end
end
endcase
end
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
for (int q = 0; q < N_QUEUES; q++) begin
c_served[q] <= '0;
starved_cycles[q] <= '0;
credit[q] <= 8'd0;
end
rr_q <= '0;
win_total <= '0;
starvation_detected <= 1'b0;
starved_queue <= '0;
lowest_share_pct <= 16'd100;
end else begin
if (sel_valid && grant_taken) begin
c_served[sel_queue] <= c_served[sel_queue] + 1'b1;
win_total <= win_total + 1'b1;
rr_q <= sel_queue + 1'b1;
if (credit[sel_queue] != 8'd0)
credit[sel_queue] <= credit[sel_queue] - 8'd1;
end
// THE MEASUREMENT THAT MAKES STARVATION VISIBLE. A queue with
// frames waiting and no grant is being starved right now, and
// counting those cycles is the only way the condition is ever
// reported -- nothing else in the switch distinguishes "this
// traffic is slow" from "this traffic is never scheduled".
for (int q = 0; q < N_QUEUES; q++)
if (q_nonempty[q] && !(sel_valid && (sel_queue == QSEL_W'(q))))
starved_cycles[q] <= starved_cycles[q] + 1'b1;
if (win_total >= CNT_W'(WIN)) begin
automatic logic [CNT_W-1:0] lo = '1;
automatic logic [QSEL_W-1:0] lq = '0;
for (int q = 0; q < N_QUEUES; q++) begin
if (c_served[q] < lo) begin lo = c_served[q]; lq = QSEL_W'(q); end
credit[q] <= q_weight[q];
end
// A queue served for less than a thousandth of the window while
// it had frames waiting is starved, not merely low priority.
starvation_detected <= (lo < (win_total >> 10)) &&
(starved_cycles[lq] != '0);
starved_queue <= lq;
lowest_share_pct <= 16'((lo * CNT_W'(100)) / win_total);
win_total <= '0;
for (int q = 0; q < N_QUEUES; q++) c_served[q] <= '0;
end
end
end
endmoduleClassification: synthesizable.
What it teaches: that strict priority is the default, is what "priority" means to most people, and starves. A queue is served only when every higher queue is empty — so a high-priority class that is continuously busy prevents a low-priority class from ever transmitting, indefinitely, with no timeout and no escape. That is not a defect in the scheduler; it is the exact behaviour strict priority specifies, and it is why weighted disciplines exist.
And starved_cycles is the measurement that makes the condition reportable. Every other observable says this traffic is slow — throughput is low, latency is high — and none of them distinguishes slow from never scheduled. Counting cycles in which a queue had frames and did not get the grant is the only signal that separates congestion from starvation, and the two have completely different remedies.
Deliberately simplified: a combinational selection across four queues with a per-window credit refresh. Production schedulers refresh credit continuously rather than per window, support a strict class above the weighted ones for genuinely time-critical traffic, and are per port — so a 24-port switch has 24 of these.
Production implication: this scheduler and Chapter 12.1 §9's arbiter are in series, not in competition, and confusing them produces a real design error. This one chooses which class transmits next on one egress port; that one chose which ingress port wins access to an egress. A design that merges them into a single arbiter over ports × classes has made the priority of one port's traffic contend with the port identity of another's — and the resulting fairness properties are not what either mechanism was specified to provide.
12. Where the Six Mechanisms Sit
Assemble the chapter and the order matters, because two of the six can only be checked before the others run.
| # | Stage | Consumes | Produces | Can it fail silently |
|---|---|---|---|---|
| 1 | VID → index — §3 | 12-bit VID | a subscript | yes — aliasing, undetectable at lookup |
| 2 | key build — §6 | VID, MAC | 60-bit key | yes — if built from the index |
| 3 | table — §7 | the key | hit and port | no — Chapter 12.5's counters |
| 4 | masks and state — Chapter 13.1 §9 | the index | membership, flood mask | inherits stage 1's aliasing |
| 5 | PCP → queue — §9 | 3-bit PCP | a queue selector | yes — a flat map is inert |
| 6 | queue schedule — §11 | queue occupancy | which class transmits | yes — strict priority starves |
Stages 1 and 2 are the pair this chapter exists to keep apart, and the table's right-hand column shows why the separation is load-bearing: stage 1 deliberately collapses distinctness and stage 2 must not.
And stage 4 is where stage 1's failure becomes visible as behaviour. The masks and port state are indexed, so an aliased index gives two VLANs one membership bitmap — and every check that reads that bitmap agrees, correctly, about a VLAN that is now two.
Two of the six can be checked before any frame is forwarded. Stage 1's aliasing is a pigeonhole argument over the binding count — §13's alias_possible. Stage 5's flat map is a property of a table, readable at configuration time. The other four need traffic.
13. RTL 7 — Datapath Telemetry
Six mechanisms in series, each with a distinct way of being silently wrong, and one place to read whether any of them is.
// -----------------------------------------------------------------------
// vlan_datapath_telemetry -- the per-window health of the whole VLAN
// datapath.
//
// Every quantity here is derived from signals the datapath already has.
// The value is that the six mechanisms' failure modes are unrelated and
// a single throughput measurement conflates all of them.
// -----------------------------------------------------------------------
module vlan_datapath_telemetry
import vlanidx_pkg::*;
#(
parameter int N_VLANS = 256,
parameter int CNT_W = 32,
parameter int WIN = 1_000_000
)(
input logic clk,
input logic rst_n,
input logic frame_valid,
input map_result_e map_result,
input logic lk_hit,
input logic [PCP_W-1:0] pcp,
input logic [QSEL_W-1:0] queue,
input logic [11:0] n_vids_bound,
input logic [11:0] indices_used,
input logic starvation_detected,
input logic map_is_flat,
output logic window_valid,
output logic [15:0] unconfigured_pct,
output logic [15:0] hit_rate_pct,
output logic [15:0] pcp0_share_pct,
output logic alias_possible, // pigeonhole, before any frame
output logic priority_inert, // marked but not honoured
output logic priority_unused, // not marked at all
output logic [CNT_W-1:0] c_unconfigured,
output logic [CNT_W-1:0] c_frames
);
logic [CNT_W-1:0] win_frames, win_unconf, win_hit, win_pcp0;
// THE PIGEONHOLE CHECK, available before a single frame is forwarded.
// More VIDs bound than indices available means at least two VIDs share
// an index, by counting alone.
assign alias_possible = (n_vids_bound > 12'(N_VLANS)) ||
(n_vids_bound > indices_used);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
win_frames <= '0; win_unconf <= '0; win_hit <= '0; win_pcp0 <= '0;
c_unconfigured <= '0;
c_frames <= '0;
window_valid <= 1'b0;
unconfigured_pct <= '0;
hit_rate_pct <= '0;
pcp0_share_pct <= '0;
priority_inert <= 1'b0;
priority_unused <= 1'b0;
end else begin
window_valid <= 1'b0;
if (frame_valid) begin
win_frames <= win_frames + 1'b1;
c_frames <= c_frames + 1'b1;
if (map_result == MR_UNCONFIGURED) begin
win_unconf <= win_unconf + 1'b1;
c_unconfigured <= c_unconfigured + 1'b1;
end
if (lk_hit) win_hit <= win_hit + 1'b1;
if (pcp == 3'd0) win_pcp0 <= win_pcp0 + 1'b1;
end
if (win_frames >= CNT_W'(WIN)) begin
unconfigured_pct <= 16'((win_unconf * CNT_W'(100)) / win_frames);
hit_rate_pct <= 16'((win_hit * CNT_W'(100)) / win_frames);
pcp0_share_pct <= 16'((win_pcp0 * CNT_W'(100)) / win_frames);
// THE TWO WAYS PRIORITY FAILS SILENTLY, separated. Senders are
// marking and the map discards the distinction -- fix the map.
// Nobody is marking -- the map is fine and there is nothing to do.
priority_inert <= map_is_flat && (win_pcp0 < (win_frames - (win_frames >> 3)));
priority_unused <= (win_pcp0 >= (win_frames - (win_frames >> 6)));
win_frames <= '0; win_unconf <= '0; win_hit <= '0; win_pcp0 <= '0;
window_valid <= 1'b1;
end
end
end
wire _unused_q = |queue;
wire _unused_s = starvation_detected;
endmoduleClassification: synthesizable.
What it teaches: that alias_possible is available before a single frame is forwarded, and that it is a counting argument rather than an observation. More VIDs bound than index slots means at least two share one, by the pigeonhole principle — no traffic required, no detection needed, and the conclusion is certain. A design that publishes this lets management software refuse a configuration instead of accepting it and merging two VLANs at the first broadcast.
And it teaches that priority_inert and priority_unused are opposite conditions with identical symptoms. Both produce a switch whose priority queues carry indistinguishable traffic. One means the senders are marking and the map is flattening it — fix the map. The other means nobody is marking — the map is fine and there is nothing to fix. A single "priority is not working" observation cannot tell them apart; these two bits can.
Deliberately simplified: one global window across all ports. Production telemetry keeps the priority histogram per port, because Chapter 13.3 §7's regeneration argument means a boundary port and a trusted trunk should show different distributions and an aggregate is a mean over two populations with different policies.
Production implication: unconfigured_pct is the counter that distinguishes a VLAN problem from a configuration gap. A frame whose VID has no index is not a failure of this switch — the VLAN genuinely does not exist here, and the frame is discarded correctly. But a rising count on a trunk means a neighbour is sending VLANs this switch was never configured for, which is either a legitimate expansion nobody propagated or Chapter 13.3 §14's mismatch arriving from a different direction.
14. RTL 8 — Conformance for a Datapath With an Indirection
The monitor's difficulty is that the failure it most needs to catch happens at a configuration write, not on a frame — so half of it runs before any traffic exists.
// -----------------------------------------------------------------------
// vlan_dp_conformance_monitor -- checks the VLAN datapath's invariants.
//
// What it CAN check on frames: that the key carries the VID and not the
// index, that the arrays were indexed by the index and not the VID, that
// the priority map produced a queue in range, and that no aliased index
// was ever used.
// What it must check at CONFIGURATION time: injectivity of the map --
// because Section 3 established that a collision is undetectable on any
// frame that traverses it.
// -----------------------------------------------------------------------
module vlan_dp_conformance_monitor
import vlanidx_pkg::*;
#(
parameter int N_VLANS = 256,
parameter int CNT_W = 32
)(
input logic clk,
input logic rst_n,
// Configuration-time.
input logic cfg_valid,
input logic cfg_accept,
input map_result_e cfg_result,
input logic [11:0] n_vids_bound,
input logic [11:0] indices_used,
// Frame-time.
input logic frame_valid,
input vid_t vid,
input vidx_t vidx,
input logic [KEY_W-1:0] key,
input logic uses_index_in_key,
input logic [QSEL_W-1:0] queue,
input logic idx_valid,
output logic [CNT_W-1:0] v_key_lacks_vid, // the Section 18 failure
output logic [CNT_W-1:0] v_index_in_key,
output logic [CNT_W-1:0] v_alias_accepted, // config-time, and fatal
output logic [CNT_W-1:0] v_queue_out_of_range,
output logic [CNT_W-1:0] v_used_unmapped_index,
output logic injective,
output logic conformant
);
always_ff @(posedge clk or negedge rst_n) begin
if (!rst_n) begin
v_key_lacks_vid <= '0;
v_index_in_key <= '0;
v_alias_accepted <= '0;
v_queue_out_of_range <= '0;
v_used_unmapped_index <= '0;
end else begin
// CONFIGURATION TIME. A binding that would alias must have been
// refused. If one was ACCEPTED, two VLANs are merged from this
// moment on and no frame will ever reveal it.
if (cfg_valid && cfg_accept && (cfg_result == MR_ALIASED))
if (!(&v_alias_accepted)) v_alias_accepted <= v_alias_accepted + 1'b1;
if (frame_valid) begin
// THE KEY CHECK. The high twelve bits of the key must be the VID.
// A key built from the index has zeros or a subscript up there,
// and two VLANs then share every table entry.
if (key[KEY_W-1 -: VID_W] != vid)
if (!(&v_key_lacks_vid)) v_key_lacks_vid <= v_key_lacks_vid + 1'b1;
// The structural claim Section 6 exposed as a wire, checked
// against the built design rather than the documentation.
if (uses_index_in_key)
if (!(&v_index_in_key)) v_index_in_key <= v_index_in_key + 1'b1;
if (queue >= QSEL_W'(N_QUEUES))
if (!(&v_queue_out_of_range))
v_queue_out_of_range <= v_queue_out_of_range + 1'b1;
// An index used on a frame whose VID had no binding. Section 3
// refuses these; using one anyway indexes an array belonging to
// whatever VLAN happens to occupy that slot.
if (!idx_valid && (vidx != '0))
if (!(&v_used_unmapped_index))
v_used_unmapped_index <= v_used_unmapped_index + 1'b1;
end
end
end
// The pigeonhole invariant, as a standing signal. Section 13's
// alias_possible is the same argument from the telemetry side.
assign injective = (n_vids_bound <= indices_used) &&
(n_vids_bound <= 12'(N_VLANS));
assign conformant = (v_key_lacks_vid == '0) &&
(v_index_in_key == '0) &&
(v_alias_accepted == '0) &&
(v_queue_out_of_range == '0) &&
(v_used_unmapped_index == '0) &&
injective;
endmoduleClassification: synthesizable, and intended to stay in silicon.
What it teaches: that half of this monitor runs at configuration time and half on frames, and the split is forced by the mechanism rather than chosen. A map collision is undetectable on any frame that traverses it — Section 3 established there is no stored VID to compare against — so the only moment at which the invariant can be checked is the write that would break it. v_alias_accepted is a check on the checker: it fires if Section 5's refusal was bypassed.
And injective is included in conformant deliberately, which is unusual — every other conformance bit in this track is a conjunction of violation counters. Here one clause is a standing property of the configuration rather than a history of events, because a switch whose map is not injective is misconfigured whether or not a frame has yet exposed it.
Deliberately simplified: the key check compares the top twelve bits against the VID, which catches a key built from an index or from zeros. A production monitor also samples the key against the table's stored key during Chapter 12.5 §13's ageing sweep, because a key that was built correctly and stored wrongly is a different fault with the same symptom.
Production implication: conformant here means the VID reached the key, the index reached the arrays, the map is injective, and the priority map produced a queue that exists. It does not mean the VLANs are configured as anybody intended — Chapter 13.3 §18 established that no switch can claim that — and it does not mean the priority map is useful, only that it is in range. Section 13's priority_inert is the bit for that, and it is deliberately outside the conformance conjunction because a flat map is a policy choice rather than a defect.
15. The Aliasing Hazard Beyond VLANs
The shape of Section 4's failure is not specific to VLANs, and recognising it elsewhere is most of the value of having worked through it here.
The pattern has three parts. A wide identifier is mapped many-to-one onto a compact subscript to make arrays affordable. Every downstream mechanism operates on the subscript. And the mapping stores nothing that would let a collision be detected after the fact.
| System | Wide identifier | Compact subscript | What a collision merges |
|---|---|---|---|
| this chapter | 12-bit VID | 6–10 bit index | two broadcast domains |
| a multi-tenant fabric | tenant ID | a hardware context number | two tenants' isolation |
| a bridge-domain switch | bridge domain ID | a table partition index | two L2 domains |
| an interrupt controller | device ID | a vector number | two devices' handlers |
| a translation cache | address space ID | a tagged-TLB context | two address spaces |
Every row has the same property: the collision is invisible to everything downstream, because downstream is where the distinctness was already lost.
And every row has the same remedy: a reverse map checked at programming time. Section 5's checker is 256 entries of 13 bits — 416 octets — and it converts an undetectable runtime merge into a refused configuration write. The cost is negligible in every one of these systems and the failure is severe in all of them.
The general test, and it is worth carrying: does this design map a wide identifier onto a narrow one, and does anything downstream depend on the two being distinct? If yes, the mapping needs an injectivity check at the write, because no check after it can see the difference.
16. What Each Layer of Module 13 Can Claim
Four chapters, four conformance bits, and the module's honest summary is the conjunction of four narrow statements rather than any one broad one.
| Chapter | The claim | What it deliberately does not say |
|---|---|---|
| 13.1 | no frame left a non-member port; no VLAN changed inside this switch | the network is isolated |
| 13.2 | the parser resolved the layout and every consumer used what it resolved | the tagging was intended |
| 13.3 | the VLAN was assigned by the configured rule and represented in the configured form | the VLAN is the one anybody meant |
| 13.4 | the VID reached the key, the index reached the arrays, and the map is injective | the VLANs are configured correctly |
Joined, Module 13's strongest true statement is:
This frame's VLAN was assigned by my configured rule from a tag my parser resolved, carried into a key that distinguishes all 4094 VLANs, used to index arrays through a map I have proved injective, represented on the wire in the form my configuration specifies, and emitted only from ports that are members of that VLAN.
Six clauses, every one local and falsifiable, and not one of them says the VLANs are right.
And that is not a shortcoming of the design — it is a property of what a VLAN is. Chapter 13.3 §18 established it: on an untagged link a frame's VLAN is not transmitted, so continuity is a property of two configurations agreeing. A switch can prove everything about its own handling and nothing about the agreement.
17. The Cost of the Whole Datapath
Add up everything Module 13 asks a 24-port switch to hold, and the total is smaller than the forwarding table it augments.
| Structure | Chapter | Size |
|---|---|---|
| VID-to-index map, 4096 × 9 bits | 13.4 §3 | 4.5 KiB |
| reverse map, 256 × 13 bits | 13.4 §5 | 0.4 KiB |
| membership, flood mask, port state — 256 indices | 13.1 §10 | 3.8 KiB |
| per-port untagged sets, PVIDs, admit rules | 13.3 §3 | 0.8 KiB |
| PCP→queue maps, 24 ports × 16 bits | 13.4 §9 | 0.05 KiB |
| total VLAN state | — | 9.6 KiB |
| the forwarding table's growth from the wider key | 13.4 §8 | 16 KiB |
| total added by VLANs | — | 25.6 KiB |
| for comparison — the 8192-entry table itself | 12.5 | 80 KiB |
Two things in that table are worth reading against each other.
The dedicated VLAN state is 9.6 KiB. Every array, every map, every per-port setting — less than a seventh of the forwarding table.
And the largest single cost is not VLAN state at all. It is the 16 KiB the forwarding table grew when its key went from 48 bits to 60 — Section 8's uniform 25% — which is 62% of everything VLANs added.
Which is a result worth stating plainly, because the intuition runs the other way. Chapter 13.1 §10's dense design was 60 KiB and looked like the dominant cost; the indexed design collapses it to 3.8, and what remains is a linear widening of a structure that was already there.
The comparison against Chapter 13.1 §10's dense alternative:
| Design | Per-VLAN arrays | Map | Table growth | Total |
|---|---|---|---|---|
| dense, 4094 VLANs | 60.0 KiB | none | 16 KiB | 76.0 KiB |
| indexed, 256 VLANs | 3.8 KiB | 4.9 KiB | 16 KiB | 24.7 KiB |
| saving | — | — | — | 3.1× |
And the 4.9 KiB of map is what buys the 56 KiB of array, at the price of Section 4's aliasing hazard and Section 5's 416-octet checker.
18. Properties Worth Asserting, and One Worth Refusing
Every property here distinguishes the identity from the subscript, or checks the map at the one moment a collision is visible. The rejected property checks distinctness on the side where it has already been lost.
The map
// P1. A reserved VID never translates. 0 and 4095 name no VLAN.
property p_reserved_vid_no_index;
@(posedge clk) disable iff (!rst_n)
(lk_valid && ((lk_vid == 12'd0) || (lk_vid == 12'd4095))) |-> !idx_valid;
endproperty
a_reserved_no_idx: assert property (p_reserved_vid_no_index);
// P2. An unbound VID produces MR_UNCONFIGURED and NO index -- distinct
// from aliasing, which produces a perfectly valid index.
property p_unbound_vid_no_index;
@(posedge clk) disable iff (!rst_n)
(lk_valid && !tbl_present[lk_vid]) |-> (!idx_valid && (result == MR_UNCONFIGURED));
endproperty
a_unbound: assert property (p_unbound_vid_no_index);
// P3. Translation is deterministic -- the same VID always yields the same
// index while the binding is unchanged.
property p_map_is_a_function;
@(posedge clk) disable iff (!rst_n)
(idx_valid && (lk_vid == $past(lk_vid)) && $stable(tbl_idx[lk_vid]))
|-> (idx == $past(idx));
endproperty
a_map_deterministic: assert property (p_map_is_a_function);
// P4. THE INJECTIVITY INVARIANT. No two bound VIDs share an index. This
// cannot be checked on a frame, so it is a standing property of the
// configuration.
property p_map_is_injective;
@(posedge clk) disable iff (!rst_n)
(n_vids_bound <= indices_used);
endproperty
a_injective: assert property (p_map_is_injective);
// P5. A binding that would alias is REFUSED at the write -- the only
// moment at which both VIDs are visible together.
property p_alias_refused_at_write;
@(posedge clk) disable iff (!rst_n)
(cfg_valid && cfg_present && owner_val[cfg_vidx] &&
(owner[cfg_vidx] != cfg_vid)) |-> (!cfg_accept && (cfg_result == MR_ALIASED));
endproperty
a_alias_refused: assert property (p_alias_refused_at_write);
// P6. A refusal names the conflicting VID, so an operator has both
// halves of the collision rather than a rejection code.
property p_refusal_names_conflict;
@(posedge clk) disable iff (!rst_n)
(cfg_valid && (cfg_result == MR_ALIASED)) |-> (conflicting_vid != cfg_vid);
endproperty
a_conflict_named: assert property (p_refusal_names_conflict);
// P7. THE PIGEONHOLE CHECK, available before a single frame. More VIDs
// bound than index slots means at least two share one, by counting alone.
property p_pigeonhole_reported;
@(posedge clk) disable iff (!rst_n)
(n_vids_bound > 12'(N_VLANS)) |-> alias_possible;
endproperty
a_pigeonhole: assert property (p_pigeonhole_reported);Identity against subscript
// P8. THE CENTRAL PROPERTY. The key's high twelve bits are the VID --
// the identity, never the subscript.
property p_key_carries_the_vid;
@(posedge clk) disable iff (!rst_n)
key_valid |-> (key[KEY_W-1 -: VID_W] == vid);
endproperty
a_key_has_vid: assert property (p_key_carries_the_vid);
// P9. The key never contains the index. Section 6 exposes this as a wire
// so the claim is checkable against the built design.
property p_key_never_has_index;
@(posedge clk) disable iff (!rst_n)
key_valid |-> !uses_index_in_key;
endproperty
a_no_index_in_key: assert property (p_key_never_has_index);
// P10. Two VLANs with the same address produce DIFFERENT keys, even when
// they share an index. This is what makes an aliased design's unicast
// forwarding still correct -- Section 4's left-hand column.
property p_distinct_vids_distinct_keys;
@(posedge clk) disable iff (!rst_n)
(key_valid && (mac == $past(mac)) && (vid != $past(vid)))
|-> (key != $past(key));
endproperty
a_vids_separate_keys: assert property (p_distinct_vids_distinct_keys);
// P11. The arrays are indexed by the INDEX, not the VID -- that is what
// the map is for and what turns 60 KiB into 3.8.
property p_arrays_use_the_index;
@(posedge clk) disable iff (!rst_n)
(frame_valid && idx_valid) |-> (array_subscript == vidx);
endproperty
a_arrays_indexed: assert property (p_arrays_use_the_index);
// P12. An index is never used when the translation failed -- doing so
// reads an array belonging to whatever VLAN occupies that slot.
property p_no_index_without_translation;
@(posedge clk) disable iff (!rst_n)
(frame_valid && !idx_valid) |-> !array_read_enable;
endproperty
a_no_unmapped_index: assert property (p_no_index_without_translation);The table at sixty bits
// P13. The hash index width is UNCHANGED at 11 bits. Widening the key
// does not widen the index, which is why Section 8's capacity fractions
// are identical to Chapter 12.5's.
property p_hash_width_unchanged;
@(posedge clk) disable iff (!rst_n)
lk_valid |-> (hash_index < HASH_W'(N_SETS));
endproperty
a_hash_width: assert property (p_hash_width_unchanged);
// P14. The comparison is over the FULL 60-bit key. A hash is not
// reversible, so nothing may be omitted -- Chapter 12.5 Section 6's rule,
// twelve bits wider.
property p_full_key_compared;
@(posedge clk) disable iff (!rst_n)
(lk_valid && lk_hit) |-> (mem[hash_index][hit_way].key == lk_key);
endproperty
a_full_compare: assert property (p_full_key_compared);
// P15. The lookup is still four cycles. A wider comparator is still one
// comparator.
property p_latency_unchanged;
@(posedge clk) disable iff (!rst_n)
lk_valid |-> (lk_latency_cy == 3'd4);
endproperty
a_four_cycles: assert property (p_latency_unchanged);
// P16. The entry width grew by exactly 25% -- 64 bits to 80.
property p_entry_growth_is_25pct;
@(posedge clk) disable iff (!rst_n)
(entry_bits == 8'd80) && (growth_pct == 8'd25);
endproperty
a_growth: assert property (p_entry_growth_is_25pct);
// P17. The VID contributes to the hash. It is a densely-used,
// operator-assigned space that correlates with nothing in the OUI-heavy
// address space it joins.
property p_vid_feeds_the_hash;
@(posedge clk) disable iff (!rst_n)
(lk_valid && (lk_key[59:48] != $past(lk_key[59:48])) &&
(lk_key[47:0] == $past(lk_key[47:0])))
|-> (hash_index != $past(hash_index));
endproperty
a_vid_in_hash: assert property (p_vid_feeds_the_hash);Priority
// P18. The queue is in range, always.
property p_queue_in_range;
@(posedge clk) disable iff (!rst_n)
in_valid |-> (queue < QSEL_W'(N_QUEUES));
endproperty
a_queue_range: assert property (p_queue_in_range);
// P19. The map is a per-port table read by PCP -- it never depends on the
// VLAN. Chapter 13.1 Section 6 categorised priority as a separate
// mechanism and this is what that means in hardware.
property p_queue_depends_only_on_pcp_and_port;
@(posedge clk) disable iff (!rst_n)
in_valid |-> (queue == map[egress_port][pcp]);
endproperty
a_queue_from_pcp: assert property (p_queue_depends_only_on_pcp_and_port);
// P20. DEI selects NO queue. Chapter 13.2 Section 4 established it as a
// discard hint -- a preferred victim within a queue, not a demotion.
property p_dei_does_not_select_a_queue;
@(posedge clk) disable iff (!rst_n)
(in_valid && (pcp == $past(pcp)) && (drop_eligible != $past(drop_eligible)))
|-> (queue == $past(queue));
endproperty
a_dei_not_a_queue: assert property (p_dei_does_not_select_a_queue);
// P21. A flat map is REPORTED, not assumed away. It makes the feature
// inert and nothing else says so.
property p_flat_map_reported;
@(posedge clk) disable iff (!rst_n)
(map[0][0] == map[0][7]) |-> map_is_flat;
endproperty
a_flat_reported: assert property (p_flat_map_reported);Scheduling
// P22. Strict priority serves the highest non-empty queue. This is the
// specified behaviour and it is what starves.
property p_strict_serves_highest;
@(posedge clk) disable iff (!rst_n)
(sel_valid && (mode == SCHED_STRICT) && q_nonempty[N_QUEUES-1])
|-> (sel_queue == QSEL_W'(N_QUEUES-1));
endproperty
a_strict: assert property (p_strict_serves_highest);
// P23. A grant goes only to a non-empty queue.
property p_grant_to_nonempty;
@(posedge clk) disable iff (!rst_n)
sel_valid |-> q_nonempty[sel_queue];
endproperty
a_grant_nonempty: assert property (p_grant_to_nonempty);
// P24. STARVATION IS MEASURED, not inferred. A queue with frames and no
// grant is being starved right now, and nothing else distinguishes that
// from congestion.
property p_starvation_counted;
@(posedge clk) disable iff (!rst_n)
(q_nonempty[0] && sel_valid && (sel_queue != QSEL_W'(0)))
|=> (starved_cycles[0] > $past(starved_cycles[0]));
endproperty
a_starvation_counted: assert property (p_starvation_counted);
// P25. Under a weighted discipline every queue eventually gets a grant.
property p_weighted_is_starvation_free;
@(posedge clk) disable iff (!rst_n)
((mode == SCHED_WEIGHTED) && q_nonempty[0] && (q_weight[0] != 8'd0))
|-> ##[1:$] (sel_valid && (sel_queue == QSEL_W'(0)));
endproperty
a_weighted_fair: assert property (p_weighted_is_starvation_free);
// P26. This scheduler and Chapter 12.1 Section 9's arbiter are in
// SERIES. This one picks a class; that one picks an ingress port.
property p_two_arbiters_in_series;
@(posedge clk) disable iff (!rst_n)
(sel_valid && grant_taken) |-> fabric_grant_valid;
endproperty
a_series_not_merged: assert property (p_two_arbiters_in_series);Conformance
// P27. A key that lacks the VID is caught on the first frame.
property p_key_check_fires_immediately;
@(posedge clk) disable iff (!rst_n)
(frame_valid && (key[KEY_W-1 -: VID_W] != vid))
|=> (v_key_lacks_vid > $past(v_key_lacks_vid));
endproperty
a_key_check: assert property (p_key_check_fires_immediately);
// P28. An accepted aliasing binding is FATAL and counted. From that
// moment two VLANs are merged and no frame will reveal it.
property p_accepted_alias_is_recorded;
@(posedge clk) disable iff (!rst_n)
(cfg_valid && cfg_accept && (cfg_result == MR_ALIASED))
|=> (v_alias_accepted > $past(v_alias_accepted));
endproperty
a_alias_recorded: assert property (p_accepted_alias_is_recorded);
// P29. Conformance INCLUDES the standing injectivity property, not only
// a history of violations -- a non-injective map is wrong whether or not
// a frame has yet exposed it.
property p_conformant_includes_injective;
@(posedge clk) disable iff (!rst_n)
conformant |-> injective;
endproperty
a_conformant_injective: assert property (p_conformant_includes_injective);
// P30. Conformance means the VID reached the key and the index reached
// the arrays -- never that the VLANs are configured correctly.
property p_conformant_definition;
@(posedge clk) disable iff (!rst_n)
conformant |-> ((v_key_lacks_vid == '0) && (v_index_in_key == '0) &&
(v_alias_accepted == '0) && (v_queue_out_of_range == '0) &&
(v_used_unmapped_index == '0));
endproperty
a_conformant_def: assert property (p_conformant_definition);19. Verification Scenarios
Sixty-four scenarios. The map scenarios include one whose expected outcome is a refused configuration write, and one whose expected outcome is two VLANs behaving as one with every check clean.
The map
| # | Scenario | Expected |
|---|---|---|
| 1 | VID 10 bound to index 3 | translates to 3 |
| 2 | Same VID twice | the same index, always |
| 3 | VID 0 | no index, MR_RESERVED |
| 4 | VID 4095 | no index, reserved |
| 5 | VID 3000, unbound | MR_UNCONFIGURED, no index, counted |
| 6 | VID 20 bound to index 3, already owned by VID 10 | refused, MR_ALIASED, conflicting_vid = 10 |
| 7 | Same, refusal bypassed | v_alias_accepted — fatal, and no frame will reveal it |
| 8 | Unbinding VID 10, then binding VID 20 to index 3 | accepted |
| 9 | 257 VIDs bound, 256 index slots | alias_possible — pigeonhole, before any frame |
| 10 | 200 VIDs bound, 256 slots | injective, alias_possible low |
| 11 | Map size, 4096 × 9 bits | 4.5 KiB |
| 12 | Reverse map, 256 × 13 bits | 0.4 KiB — 416 octets |
An aliased index
| # | Scenario | Expected |
|---|---|---|
| 13 | VID 10 and VID 20 share index 7, broadcast in VLAN 10 | floods to VLAN 20's ports |
| 14 | Same, Chapter 13.1 §12's isolation monitor | clean — the port is a member of index 7 |
| 15 | Same, unicast lookup for A in VLAN 10 | correct — the key carries the VID |
| 16 | Same, but the key built from the index | whichever VLAN transmitted last |
| 17 | The symptom, key from the VID | broadcast leaks, unicast does not |
| 18 | The symptom, key from the index | everything intermittently wrong |
| 19 | Any downstream check on frame_vidx | passes — distinctness was lost upstream |
Identity against subscript
| # | Scenario | Expected |
|---|---|---|
| 20 | Key for VID 10, MAC A | high 12 bits = 10 |
| 21 | Key for VID 20, same MAC | different key |
| 22 | Key for two VIDs sharing an index | still different — the key uses the VID |
| 23 | uses_index_in_key | permanently 0 |
| 24 | A key whose high bits are the index | v_key_lacks_vid on the first frame |
| 25 | Membership array read | indexed by vidx, never by vid |
| 26 | Arrays indexed by the raw VID | 60 KiB — Chapter 13.1 §10's dense cost returns |
| 27 | Index used when translation failed | v_used_unmapped_index |
The table at sixty bits
| # | Scenario | Expected |
|---|---|---|
| 28 | Hash index width | 11 bits — unchanged |
| 29 | Sets full at 50% occupancy | 5.27% — 107.8 sets, identical to 48 bits |
| 30 | Effective capacity, 4-way, 8192 offered | 6592 — 80.5%, identical |
| 31 | Effective capacity, direct-mapped | 63.2%, identical |
| 32 | Entry width | 60 + 16 = 76 → 80 bits |
| 33 | 8192-entry table | 80 KiB against 64 — +25% |
| 34 | Four-way set read | 320 bits against 256 — +25% |
| 35 | CAM cells | 491 520 against 393 216 — +25% |
| 36 | Lookup latency | 4 cycles — unchanged |
| 37 | Ageing sweep duty | 0.0033% — bandwidth +25%, duty still negligible |
| 38 | Two keys differing only in VID | different hash index — the VID feeds the hash |
| 39 | The full 60-bit compare | performed — a hash is not reversible |
Priority
| # | Scenario | Expected |
|---|---|---|
| 40 | PCP 5, map pcp × 4 ÷ 8 | queue 2 |
| 41 | All eight PCPs, flat map | all queue 0, map_is_flat |
| 42 | DEI set, same PCP | the same queue — DEI selects none |
| 43 | DEI set | discard_first to Chapter 12.1 §9 |
| 44 | Map for 4 queues | 16 bits per port, 48 octets for 24 ports |
| 45 | Map for 8 queues | 24 bits per port |
| 46 | Everything at PCP 0 | priority_unused — the map is fine |
| 47 | Spread of PCPs, one queue | priority_inert — fix the map |
| 48 | Both conditions from a throughput test | indistinguishable — hence the two bits |
Scheduling
| # | Scenario | Expected |
|---|---|---|
| 49 | Strict priority, queue 3 continuously busy | queues 0–2 never served |
| 50 | Same | starved_cycles rising on 0–2 |
| 51 | Same | starvation_detected, starved_queue = 0 |
| 52 | Weighted, all weights non-zero | every queue eventually served |
| 53 | Grant | always to a non-empty queue |
| 54 | This scheduler and Chapter 12.1 §9's arbiter | in series — class then ingress port |
| 55 | The two merged into one arbiter | priority contends with port identity |
| 56 | Low queue at under a thousandth of the window | starvation, not congestion |
Cost and conformance
| # | Scenario | Expected |
|---|---|---|
| 57 | Total dedicated VLAN state | 9.6 KiB |
| 58 | Forwarding-table growth from the wider key | 16 KiB — 62% of everything VLANs added |
| 59 | Indexed against dense, total | 24.7 KiB against 76.0 — 3.1× |
| 60 | conformant with a non-injective map | low — injectivity is inside the conjunction |
| 61 | conformant with a flat priority map | high — a flat map is policy, not a defect |
| 62 | Unconfigured VIDs rising on a trunk | a neighbour is sending VLANs this switch lacks |
| 63 | Every check downstream of an aliased map | passes |
| 64 | Healthy run, injective map, one million frames | conformant high throughout |
20. Debugging the VLAN Datapath
Six mechanisms in series with unrelated failure modes, and a single throughput measurement conflates all of them.
| Symptom | Likely cause | The observable that decides it |
|---|---|---|
| Broadcasts cross two VLANs, unicast does not | an aliased index | injective, alias_possible, v_alias_accepted |
| Everything between two VLANs intermittently wrong | aliased index and a key built from it | v_key_lacks_vid on the first frame |
| Isolation verified, VLANs still merged | the check reads the index — Section 18 | injective — the invariant's subject is the subscript |
| A VLAN's traffic discarded at one switch | that VID has no binding here | c_unconfigured, MR_UNCONFIGURED |
| Unconfigured count rising on a trunk | a neighbour sends VLANs this switch lacks | expansion nobody propagated, or Chapter 13.3 §14 |
| Table 25% larger than budgeted | the key widened to 60 bits | Section 8 — entry 64 → 80, exactly 25% |
| Capacity fractions unchanged after widening | expected — the index did not widen | Section 8's first two tables |
| Set memory failing timing after widening | the set read went 256 → 320 bits | a different memory compile on a 4-cycle path |
| Priority configured and doing nothing | senders are not marking | priority_unused, c_by_pcp all in 0 |
| Priority configured and doing nothing | the map is flat | priority_inert, map_is_flat |
| One traffic class never transmits | strict priority, higher class always busy | starved_cycles, starvation_detected |
| Low-priority traffic slow but present | congestion, not starvation | starved_cycles zero — the distinction |
| Fairness properties nobody can explain | the two arbiters were merged | class selection and port arbitration are in series |
21. Common Misconceptions
1 — "The VID and the VLAN index are the same thing."
The wrong model: a VLAN is identified by a number, and which number does not matter.
What it costs: whichever way they are confused. Building the key from the index makes two VLANs share every table entry — Chapter 13.1 §15's silent frame loss on top of a merge. Indexing the arrays by the VID restores Chapter 13.1 §10's 60 KiB, which is 0.94× the whole forwarding table.
The corrected model: the VID is an identity — 12 bits, injective over the VLANs that exist, and it goes in the key. The index is a subscript — 6 to 10 bits, many-to-one by construction, and it goes in the arrays. Section 3's package makes them different types on purpose.
2 — "A map collision is like a hash collision."
The wrong model: both fold a wide value into a narrow one, so both degrade the same way.
What it costs: the assumption that the collision will be detected. Chapter 12.5 §6's table stores the full 48-bit key beside every entry and compares it on every lookup — two addresses hashing to one set are still two entries, and a lookup for one never returns the other. The map stores nothing to compare against.
The corrected model: a hash collision costs a way in a set and is reported as TI_SET_FULL. A map collision costs a VLAN and is reported by nothing. The map is a programmed translation table, not a hash, and its correctness is a property of the programming — which is why Section 5's checker exists and why it runs at the write.
3 — "Widening the key will hurt the table's capacity."
The wrong model: a bigger key means fewer entries fit, or more collisions.
What it costs: a design decision made against a fear that is arithmetically wrong. Section 8's recomputation: every capacity fraction is identical at 60 bits — 5.27% of sets full at 50% occupancy, 80.5% effective capacity on a 4-way table, 63.2% direct-mapped. Chapter 12.5 §7's arithmetic depends on the number of sets, the associativity and the offered count, and the key's width appears nowhere in it.
The corrected model: widening the key is priced in area and nothing else — the entry grows 64 → 80 bits and the table 64 → 80 KiB, exactly 25%. Widening the index is what moves capacity, and the index did not widen.
4 — "Priority marking configured means priority working."
The wrong model: the queues exist and the map is set, so traffic is prioritised.
What it costs: two opposite failures with identical symptoms. A flat map discards the distinction senders are expressing. An unmarked network gives the map nothing to work with. Both produce priority queues carrying indistinguishable traffic, and a throughput measurement cannot separate them.
The corrected model: priority_inert and priority_unused are the two bits, and they are fixed in different places. The default matters more than the configurability — a map initialised to all zeros makes the feature inert while looking configured, and a proportional default at least preserves the ordering.
5 — "Strict priority is what priority means."
The wrong model: higher priority goes first; that is the whole idea.
What it costs: a low-priority class that never transmits, indefinitely, with no timeout and no escape, whenever a higher class is continuously busy. That is not a defect — it is exactly what strict priority specifies — and a design that ships it as the default has shipped starvation as the default.
The corrected model: strict priority starves by construction, weighted disciplines do not, and starved_cycles is the only measurement that separates this traffic is slow from this traffic is never scheduled. Every other observable — throughput, latency — reads the same for both, and the two have completely different remedies.
6 — "Isolation verified means the VLANs are separate."
The wrong model: Chapter 13.1 §12's invariant is the proof of separation, and it is checkable, so separation is proven.
What it costs: Section 18's rejected property. The invariant reads vlan_members[frame_vidx][port], and frame_vidx is a subscript. With an aliased map, index 7's membership bitmap is the union of two VLANs' ports — so the check passes on every frame while a broadcast in one floods the other.
The corrected model: the invariant's subject is whatever frame_vidx denotes, and a many-to-one map between the identity and the subscript makes it denote something coarser than a VLAN. With an injective map it recovers its original meaning — which is why injectivity is asserted separately and included in the conformance conjunction.
22. Interview Reasoning
Q1 — "A switch supports 4094 VLAN IDs. Does it have 4094 of everything?"
Reason through it. No — and the gap between the two numbers is this chapter. It supports 4094 identifiers and far fewer simultaneously configured VLANs. Chapter 13.1 §10 showed dense arrays over the full range cost 60 KiB on a 24-port switch — 0.94× the entire forwarding table — so a translation table maps 4094 VIDs onto however many indices exist, typically 64 to 1024. The strong answer names both halves of the trade: the map is 4096 entries of 9 bits, 4.5 KiB, and it shrinks the arrays from 60 KiB to 3.8 — a 3.1× total saving. And it names the price: the map is many-to-one by construction, so two VIDs can share an index, and nothing downstream of it can tell.
Q2 — "Where does the VID go and where does the index go?"
Reason through it. The VID goes in the key — Chapter 13.1 §7's {VID, MAC}, all twelve bits, because the same address in two VLANs is two different stations and the table must distinguish 4094 of them. The index goes in the arrays — membership, flood mask, port state — because that is what makes them affordable. The strong answer states what each confusion costs: a key built from the index makes two VLANs share every table entry, and arrays indexed by the VID restore the 60 KiB. It also notes the design habit: making them different types in the package, and taking the index as an input to the key builder and deliberately not using it, so the refusal is reviewable in a diff.
Q3 — "Recompute Chapter 12.5's capacity arithmetic with a 60-bit key. What changed?"
Reason through it. Nothing about capacity, and exactly 25% of the memory. Chapter 12.5 §7's overflow arithmetic is balls-in-bins over the set count, the associativity and the offered count — the key's width appears nowhere in it — so 5.27% of sets full at 50% occupancy and 80.5% effective capacity on a 4-way table are identical. What grew is what is stored and compared: entry 64 → 80 bits, table 64 → 80 KiB, four-way set read 256 → 320 bits, CAM cells 393 216 → 491 520 — all +25%. The strong answer draws the general rule: widening what you store is priced in area and nothing else; widening how you index is priced in behaviour — and this widened the store, not the index. It also names the one thing that touches timing: a 320-bit set row is a different memory compile on a four-cycle path with ten cycles of margin.
Q4 — "Two VIDs map to the same index. What breaks, and what still works?"
Reason through it. Everything indexed breaks and everything keyed survives. The membership bitmap, flood mask and port state are one set for both VLANs, so a broadcast in one floods the other's ports. The forwarding table is keyed on the full VID, so unicast lookups stay correct. The strong answer names the diagnostic signature: broadcasts cross between two VLANs and unicast traffic does not — a shape no other failure in this track produces, since Chapter 13.3 §16's native mismatch merges everything and a membership error merges nothing. And it names what does not fire: Chapter 13.1 §12's isolation monitor reads the index and reports clean, because the port genuinely is a member of index 7.
Q5 — "Why can a map collision not be detected at lookup time?"
Reason through it. Because the map stores nothing to compare against. The index is the answer, and there is no second field recording which VID produced it. Contrast Chapter 12.5 §6's hashed table, which stores the full 48-bit key beside every entry and compares it on every lookup — so two addresses hashing to one set remain two distinct entries and a lookup for one never returns the other. The strong answer draws the consequence: the only moment at which both VIDs are visible together is the configuration write, so the invariant must be enforced there — a reverse map of 256 entries × 13 bits, 416 octets, converting an undetectable runtime merge into a refused write. And it adds the check that needs no hardware at all: more bound VIDs than index slots is a pigeonhole argument, certain before a single frame is forwarded.
Q6 — "Your priority queues carry indistinguishable traffic. What are the two possibilities?"
Reason through it. Either nobody is marking, or the map is flattening what they marked — and both produce identical throughput measurements. priority_unused means Chapter 13.2's c_by_pcp histogram is entirely in bucket 0: the senders express nothing and the map is fine with nothing to do. priority_inert means the PCPs are spread and map_is_flat — the switch is discarding a distinction the senders are expressing, and the fix is the map. The strong answer adds the default that causes the second case: a map initialised to all zeros looks configured and is inert, while a proportional default pcp × N_QUEUES ÷ 8 at least preserves the ordering that survives compressing eight levels into four. And it names the third failure in the same area: strict priority scheduling starves lower classes by construction, and starved_cycles is the only measurement separating slow from never scheduled.
23. Understanding Check
24. What's Next
Module 13 is complete. Four chapters turned one physical switch into several logical ones: the requirement, the tag, the transformation and the datapath.
And the module ends where Chapter 12.1 §6 left an argument unfinished. That chapter established that discarding is a switch's specified response to congestion and rejected backpressure as a cure — on the grounds that it converts a local congestion into a global one, throttling conversations bound for idle ports along with the one that is busy.
Module 14 — Flow Control takes the argument up directly. Chapter 14.1 — Where Frames Are Lost finds the buffer that actually overflows first, which is not the one most people name. Chapter 14.2 — PAUSE Frames builds the mechanism 802.3x specified and examines its all-or-nothing semantics. And Chapter 14.3 — Backpressure and Head-of-Line Blocking shows that the pathology Chapter 12.1 predicted is exactly what happens, with a throughput bound that has been known since 1987.
This chapter's PCP field is how that mechanism is eventually made per-class rather than per-link — Section 9's map, Section 11's queues — and Chapter 14.4 is where the two meet.
Continue learning
Related tutorials
- Related topic
Frame Format Overview
Every field exists to let a receiver make one decision at one moment, and the field order is the order those decisions must be made. The check value comes last because it covers everything before it — which makes every decision taken before it provisional.
- Related topic
Why VLANs Exist
Segmentation is the only lever on the broadcast-domain limit, and it turns every singleton in a switch into a vector. Supporting all 4094 VLANs costs 60 KiB — 0.94× the entire forwarding table.
- Related topic
Access Ports, Trunk Ports and Tag Handling
A frame's VLAN is assigned by the receiving port, not carried by the frame. Across an untagged link it is destroyed and re-invented — which is why a native-VLAN mismatch leaks both ways with every check clean.
- Related topic
The Shared-Medium Problem
Why several independent transmitters on one medium is a distributed timing problem, not a formatting problem. Propagation delay makes every station's view of the medium stale, so two locally correct decisions can still collide — and that is the constraint the Ethernet MAC was built around.
Standards & specifications
- Governing standard
- IEEE Std 802.3 (Ethernet)(opens IEEE in a new tab)
Defines the Ethernet MAC, the media-independent interfaces and the physical-layer sublayers, including framing, access control, auto-negotiation and per-rate PHY specifications. VLAN tagging, priority and time-sensitive shaping are defined by IEEE 802.1, not by 802.3.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the Ethernet curriculum.
