On Mon, Oct 19, 2026 at 2:25 PM Srikar Tati via dev
<[email protected]> wrote:
>
> From: Srikar Tati <[email protected]>
>
> An EVPN FDB entry with more than one path (multi-homing) uses an
> OpenFlow select group with selection_method=dp_hash. dp_hash needs a
> recirculation, which the tc datapath cannot offload, so the group stays
> in software on a NIC with hardware offload.
>
> Add the "dynamic-routing-fdb-ecmp-selection-fields" option to Logical
> Switches. It takes the same fields as the "selection_fields" column of
> a static route. northd validates the option and copies it to the
> Datapath_Binding of an EVPN switch. ovn-controller then creates the
> FDB select groups with selection_method=hash on these fields. tp_src
> and tp_dst become the TCP, UDP and SCTP ports, because one group serves
> every protocol and ovs-vswitchd hashes only the fields that are present
> in a packet.
>
> evpn_fdb now also depends on SB Datapath_Binding, so that a change of
> the option updates the groups.
>
> Assisted-by: Claude Opus 5.5, Claude Code
> Signed-off-by: Srikar Tati <[email protected]>
Hi Srikar,
Thanks for the patches. Please see a few comments below.
Thanks
Numan
> ---
> NEWS | 4 +++
> controller/evpn-fdb.c | 33 +++++++++++++++++++++
> controller/evpn-fdb.h | 3 ++
> controller/ovn-controller.c | 46 +++++++++++++++++++++++++++++
> controller/physical.c | 7 ++++-
> lib/ovn-util.c | 31 +++++++++++++++++++
> lib/ovn-util.h | 2 ++
> northd/en-datapath-logical-switch.c | 19 ++++++++++++
> ovn-nb.xml | 25 ++++++++++++++++
> tests/ovn-inc-proc-graph-dump.at | 2 ++
> tests/ovn-northd.at | 42 ++++++++++++++++++++++++++
> tests/system-ovn.at | 29 ++++++++++++++++++
> 12 files changed, 242 insertions(+), 1 deletion(-)
>
> diff --git a/NEWS b/NEWS
> index 5a822cc21..cfb331a45 100644
> --- a/NEWS
> +++ b/NEWS
> @@ -32,6 +32,10 @@ Post v26.09.0
> Logical Routers. It selects the fields that ECMP groups of learned
> routes hash on, the same as the "selection_fields" column of a
> static route.
> + * Add the "other_config:dynamic-routing-fdb-ecmp-selection-fields"
> + option to Logical Switches. It selects the fields that the
> + OpenFlow groups of multi-path EVPN FDB entries hash on, instead of
> + dp_hash.
> - Removed OVN's ovs-bugtool plugin and helper scripts.
> - Removed ovn-sim utility scripts.
> - Removed ovn-docker utility scripts.
> diff --git a/controller/evpn-fdb.c b/controller/evpn-fdb.c
> index 76ab816f3..acb97ab8f 100644
> --- a/controller/evpn-fdb.c
> +++ b/controller/evpn-fdb.c
> @@ -24,6 +24,7 @@
> #include "openvswitch/dynamic-string.h"
> #include "openvswitch/vlog.h"
> #include "ovn-sb-idl.h"
> +#include "ovn-util.h"
> #include "packets.h"
> #include "unixctl.h"
>
> @@ -125,6 +126,27 @@ evpn_fdb_resolve_paths(const struct evpn_fdb_ctx_in
> *f_ctx_in,
> vector_qsort(paths, evpn_fdb_path_cmp);
> }
>
> +/* Returns the fields of the ECMP select group of the FDB entries of 'edp'
> + * for selection_method=hash, or NULL for selection_method=dp_hash. */
> +static char *
Please put a comment that the caller has to free the returned string.
> +evpn_fdb_hash_fields(const struct evpn_datapath *edp)
> +{
> + const char *fields_s =
> + smap_get(&edp->ldp->datapath->external_ids,
> + "dynamic-routing-fdb-ecmp-selection-fields");
> + if (!fields_s) {
> + return NULL;
> + }
> +
> + struct sset fields = SSET_INITIALIZER(&fields);
> + char *hash_fields = NULL;
> + if (ovn_ecmp_selection_fields_parse(fields_s, &fields)) {
> + hash_fields = ovn_ecmp_selection_fields_to_hash_fields(&fields);
> + }
> + sset_destroy(&fields);
> + return hash_fields;
> +}
> +
> /* Returns true if the paths in 'fdb' match the contents of 'paths'.
> * Caller must ensure 'paths' is non-empty (memcmp with NULL is UB). */
> static bool
> @@ -192,6 +214,15 @@ evpn_fdb_run(const struct evpn_fdb_ctx_in *f_ctx_in,
> updated = true;
> }
>
> + char *hash_fields = evpn_fdb_hash_fields(edp);
> + if (!nullable_string_is_equal(fdb->hash_fields, hash_fields)) {
> + free(fdb->hash_fields);
> + fdb->hash_fields = hash_fields;
> + updated = true;
> + } else {
> + free(hash_fields);
> + }
> +
> if (updated) {
> hmapx_add(f_ctx_out->updated_fdbs, fdb);
> }
> @@ -208,6 +239,7 @@ evpn_fdb_run(const struct evpn_fdb_ctx_in *f_ctx_in,
> uuidset_insert(f_ctx_out->removed_fdbs, &fdb->flow_uuid);
> hmap_remove(f_ctx_out->fdbs, &fdb->hmap_node);
> vector_destroy(&fdb->paths);
> + free(fdb->hash_fields);
> free(fdb);
> }
>
> @@ -220,6 +252,7 @@ evpn_fdbs_destroy(struct hmap *fdbs)
> struct evpn_fdb *fdb;
> HMAP_FOR_EACH_POP (fdb, hmap_node, fdbs) {
> vector_destroy(&fdb->paths);
> + free(fdb->hash_fields);
> free(fdb);
> }
> hmap_destroy(fdbs);
> diff --git a/controller/evpn-fdb.h b/controller/evpn-fdb.h
> index 65e1fc4fd..6ed3a343e 100644
> --- a/controller/evpn-fdb.h
> +++ b/controller/evpn-fdb.h
> @@ -65,6 +65,9 @@ struct evpn_fdb {
> /* Contains 'struct evpn_fdb_path', one per ECMP path.
> * For single-path entries len == 1. */
> struct vector paths;
> + /* Fields of the ECMP select group for selection_method=hash, NULL for
> + * selection_method=dp_hash. */
> + char *hash_fields;
> };
>
> void evpn_fdb_run(const struct evpn_fdb_ctx_in *, struct evpn_fdb_ctx_out *);
> diff --git a/controller/ovn-controller.c b/controller/ovn-controller.c
> index d57ff316d..84842c2c7 100644
> --- a/controller/ovn-controller.c
> +++ b/controller/ovn-controller.c
> @@ -6967,6 +6967,47 @@ evpn_fdb_vtep_binding_handler(struct engine_node
> *node, void *data OVS_UNUSED)
> return EN_UNHANDLED;
> }
>
> +static enum engine_input_handler_result
> +evpn_fdb_datapath_binding_handler(struct engine_node *node,
> + void *data OVS_UNUSED)
> +{
> + const struct sbrec_datapath_binding_table *dp_table =
> + EN_OVSDB_GET(engine_get_input("SB_datapath_binding", node));
> + const struct ed_type_runtime_data *rt_data =
> + engine_get_input_data("runtime_data", node);
> +
> + const struct sbrec_datapath_binding *dp;
> + SBREC_DATAPATH_BINDING_TABLE_FOR_EACH_TRACKED (dp, dp_table) {
> + if (sbrec_datapath_binding_is_new(dp) ||
> + sbrec_datapath_binding_is_deleted(dp)) {
> + /* The removal and addition is handled via the
> + * en_neighbor_exchange I-P node. */
> + continue;
> + }
> +
> + struct local_datapath *ld =
> + get_local_datapath(&rt_data->local_datapaths, dp->tunnel_key);
> + if (!ld || !ld->is_switch) {
> + continue;
> + }
> +
> + int64_t vni = ovn_smap_get_llong(&dp->external_ids,
> + "dynamic-routing-vni", -1);
> + if (!ovn_is_valid_vni(vni)) {
> + continue;
> + }
> +
> + /* The ECMP selection fields of the FDB groups are stored in
> + * external_ids. */
> + if (sbrec_datapath_binding_is_updated(
> + dp, SBREC_DATAPATH_BINDING_COL_EXTERNAL_IDS)) {
> + return EN_UNHANDLED;
> + }
> + }
> +
> + return EN_HANDLED_UNCHANGED;
> +}
> +
> static void *
> en_evpn_arp_init(struct engine_node *node OVS_UNUSED,
> struct engine_arg *arg OVS_UNUSED)
> @@ -7621,6 +7662,11 @@ inc_proc_ovn_controller_init(
> engine_add_input(&en_evpn_fdb, &en_evpn_vtep_binding,
> evpn_fdb_vtep_binding_handler);
> engine_add_input(&en_evpn_fdb, &en_nexthop_exchange, NULL);
> + /* The runtime_data are needed only for local datapaths, any update of
> + * local datapath will be reflected via en_neighbor_exchange. */
> + engine_add_input(&en_evpn_fdb, &en_runtime_data, engine_noop_handler);
> + engine_add_input(&en_evpn_fdb, &en_sb_datapath_binding,
> + evpn_fdb_datapath_binding_handler);
>
> engine_add_input(&en_evpn_arp, &en_neighbor_exchange, NULL);
> engine_add_input(&en_evpn_arp, &en_evpn_vtep_binding,
> diff --git a/controller/physical.c b/controller/physical.c
> index 8c5d9b940..93022d94f 100644
> --- a/controller/physical.c
> +++ b/controller/physical.c
> @@ -3652,7 +3652,12 @@ physical_consider_evpn_fdb(const struct evpn_fdb *fdb,
> const struct mf_field *mf = mf_from_id(MFF_LOG_REMOTE_OUTPORT);
>
> ds_clear(group_ds);
> - ds_put_cstr(group_ds, "type=select,selection_method=dp_hash");
> + if (fdb->hash_fields) {
> + ds_put_format(group_ds, "type=select,selection_method=hash,"
> + "fields(%s)", fdb->hash_fields);
> + } else {
> + ds_put_cstr(group_ds, "type=select,selection_method=dp_hash");
> + }
>
> for (size_t i = 0; i < vector_len(&fdb->paths); i++) {
> struct evpn_fdb_path path =
> diff --git a/lib/ovn-util.c b/lib/ovn-util.c
> index 4ec648318..9ded1c042 100644
> --- a/lib/ovn-util.c
> +++ b/lib/ovn-util.c
> @@ -1685,6 +1685,37 @@ ovn_ecmp_selection_fields_parse(const char *fields_s,
> return ok;
> }
>
> +/* Returns the fields of an OpenFlow select group with selection_method=hash
> + * for the ECMP 'selection_fields', as a malloc'ed string that the caller
> + * must free, or NULL if 'selection_fields' is empty. One group serves
> + * every protocol, so tp_src and tp_dst become the ports of TCP, UDP and
> + * SCTP; ovs-vswitchd hashes only the fields that are present in a
> + * packet. */
Here too. Put a comment that the caller has to free the returned string.
With the comments addressed:
Acked-by: Numan Siddique <[email protected]>
Numan
> +char *
> +ovn_ecmp_selection_fields_to_hash_fields(const struct sset *selection_fields)
> +{
> + if (sset_is_empty(selection_fields)) {
> + return NULL;
> + }
> +
> + struct sset of_fields = SSET_INITIALIZER(&of_fields);
> + const char *field;
> + SSET_FOR_EACH (field, selection_fields) {
> + if (!strcmp(field, "tp_src") || !strcmp(field, "tp_dst")) {
> + const char *dir = field + strlen("tp_");
> + sset_add_and_free(&of_fields, xasprintf("tcp_%s", dir));
> + sset_add_and_free(&of_fields, xasprintf("udp_%s", dir));
> + sset_add_and_free(&of_fields, xasprintf("sctp_%s", dir));
> + } else {
> + sset_add(&of_fields, field);
> + }
> + }
> +
> + char *hash_fields = sset_join(&of_fields, ",", "");
> + sset_destroy(&of_fields);
> + return hash_fields;
> +}
> +
> struct sset *
> lrp_network_sset(const char **networks, int n_networks)
> {
> diff --git a/lib/ovn-util.h b/lib/ovn-util.h
> index fc88bf6d6..f17df69d8 100644
> --- a/lib/ovn-util.h
> +++ b/lib/ovn-util.h
> @@ -739,6 +739,8 @@ void ovn_ecmp_selection_fields_add(struct sset
> *selection_fields,
> const char *field);
> bool ovn_ecmp_selection_fields_parse(const char *fields_s,
> struct sset *selection_fields);
> +char *ovn_ecmp_selection_fields_to_hash_fields(
> + const struct sset *selection_fields);
>
> const struct sbrec_port_binding *lport_lookup_by_name(
> struct ovsdb_idl_index *sbrec_port_binding_by_name,
> diff --git a/northd/en-datapath-logical-switch.c
> b/northd/en-datapath-logical-switch.c
> index 874a608a7..9ce66cbd5 100644
> --- a/northd/en-datapath-logical-switch.c
> +++ b/northd/en-datapath-logical-switch.c
> @@ -173,6 +173,25 @@ gather_external_ids(const struct nbrec_logical_switch
> *nbs,
> smap_add(external_ids, "dynamic-routing-redistribute",
> redistribute);
> }
> +
> + const char *fdb_ecmp_fields =
> + smap_get(&nbs->other_config,
> + "dynamic-routing-fdb-ecmp-selection-fields");
> + if (fdb_ecmp_fields) {
> + struct sset fields = SSET_INITIALIZER(&fields);
> + if (ovn_ecmp_selection_fields_parse(fdb_ecmp_fields, &fields)) {
> + smap_add(external_ids,
> + "dynamic-routing-fdb-ecmp-selection-fields",
> + fdb_ecmp_fields);
> + } else {
> + static struct vlog_rate_limit rl =
> + VLOG_RATE_LIMIT_INIT(5, 1);
> + VLOG_WARN_RL(&rl, "bad 'dynamic-routing-fdb-ecmp-selection-"
> + "fields' %s on switch %s, using the default "
> + "selection method", fdb_ecmp_fields, nbs->name);
> + }
> + sset_destroy(&fields);
> + }
> }
>
> /* For backwards-compatibility, also store the NB UUID in
> diff --git a/ovn-nb.xml b/ovn-nb.xml
> index 0a3154858..b67eca7fc 100644
> --- a/ovn-nb.xml
> +++ b/ovn-nb.xml
> @@ -1014,6 +1014,31 @@
> </p>
> </column>
>
> + <column name="other_config"
> + key="dynamic-routing-fdb-ecmp-selection-fields"
> + type='{"type": "string"}'>
> + <p>
> + Only relevant if <ref column="other_config"
> key="dynamic-routing-vni"
> + table="Logical_switch"/> is set to valid VNI.
> + </p>
> +
> + <p>
> + A comma separated list of the fields that select the remote VTEP
> + for an EVPN FDB entry with more than one path (multi-homing), for
> + example <code>ip_proto,ip_src,ip_dst,tp_src,tp_dst</code>. The
> + supported fields are the same as for the
> + <ref column="selection_fields"
> table="Logical_Router_Static_Route"/>
> + column of a static route. <code>tp_src</code> and
> + <code>tp_dst</code> select the TCP, UDP and SCTP ports. When set,
> + <code>ovn-controller</code> creates the OpenFlow select group of
> + the entry with the <code>hash</code> selection method on these
> + fields instead of <code>dp_hash</code>. This lets a NIC offload
> + the group, because <code>hash</code> does not need a
> + recirculation. If the list contains a field that is not
> + supported, OVN uses <code>dp_hash</code>.
> + </p>
> + </column>
> +
> <column name="other_config" key="dynamic-routing-redistribute"
> type='{"type": "string"}'>
> <p>
> diff --git a/tests/ovn-inc-proc-graph-dump.at
> b/tests/ovn-inc-proc-graph-dump.at
> index 8348c3467..e1ef5b612 100644
> --- a/tests/ovn-inc-proc-graph-dump.at
> +++ b/tests/ovn-inc-proc-graph-dump.at
> @@ -422,6 +422,8 @@ digraph "Incremental-Processing-Engine" {
> neighbor_exchange -> evpn_fdb [[label=""]];
> evpn_vtep_binding -> evpn_fdb
> [[label="evpn_fdb_vtep_binding_handler"]];
> nexthop_exchange -> evpn_fdb [[label=""]];
> + runtime_data -> evpn_fdb [[label="engine_noop_handler"]];
> + SB_datapath_binding -> evpn_fdb
> [[label="evpn_fdb_datapath_binding_handler"]];
> evpn_arp [[style=filled, shape=box, fillcolor=white,
> label="evpn_arp"]];
> neighbor_exchange -> evpn_arp [[label=""]];
> evpn_vtep_binding -> evpn_arp
> [[label="evpn_arp_vtep_binding_handler"]];
> diff --git a/tests/ovn-northd.at b/tests/ovn-northd.at
> index 6e99e9aa4..f1ef4ddc9 100644
> --- a/tests/ovn-northd.at
> +++ b/tests/ovn-northd.at
> @@ -22306,6 +22306,48 @@ OVN_CLEANUP_NORTHD
> AT_CLEANUP
> ])
>
> +OVN_FOR_EACH_NORTHD_NO_HV([
> +AT_SETUP([dynamic-routing - EVPN FDB ecmp selection fields])
> +AT_KEYWORDS([dynamic-routing])
> +ovn_start
> +
> +check ovn-nbctl --wait=sb ls-add ls-evpn \
> + -- set logical_switch ls-evpn \
> + other_config:dynamic-routing-fdb-ecmp-selection-fields="ip_src,ip_dst"
> +dp=$(fetch_column datapath_binding _uuid external_ids:name=ls-evpn)
> +
> +# The option is only synced for an EVPN switch.
> +AT_CHECK([ovn-sbctl get datapath_binding $dp \
> + external_ids:dynamic-routing-fdb-ecmp-selection-fields], [1], [ignore],
> + [ignore])
> +
> +check ovn-nbctl --wait=sb set logical_switch ls-evpn \
> + other_config:dynamic-routing-vni=10
> +AT_CHECK([ovn-sbctl get datapath_binding $dp \
> + external_ids:dynamic-routing-fdb-ecmp-selection-fields], [0], [dnl
> +"ip_src,ip_dst"
> +])
> +
> +check ovn-nbctl --wait=sb set logical_switch ls-evpn \
> + other_config:dynamic-routing-fdb-ecmp-selection-fields="ip_src,tp_src"
> +AT_CHECK([ovn-sbctl get datapath_binding $dp \
> + external_ids:dynamic-routing-fdb-ecmp-selection-fields], [0], [dnl
> +"ip_src,tp_src"
> +])
> +
> +# A field that is not supported is not synced.
> +check ovn-nbctl --wait=sb set logical_switch ls-evpn \
> + other_config:dynamic-routing-fdb-ecmp-selection-fields="ip_src,foo"
> +AT_CHECK([ovn-sbctl get datapath_binding $dp \
> + external_ids:dynamic-routing-fdb-ecmp-selection-fields], [1], [ignore],
> + [ignore])
> +check grep -q "bad 'dynamic-routing-fdb-ecmp-selection-fields' ip_src,foo" \
> + northd/ovn-northd.log
> +
> +OVN_CLEANUP_NORTHD
> +AT_CLEANUP
> +])
> +
> OVN_FOR_EACH_NORTHD_NO_HV([
> AT_SETUP([LS incremental processing - other_config fields])
> AT_KEYWORDS([ovn, incremental])
> diff --git a/tests/system-ovn.at b/tests/system-ovn.at
> index 13e62bf9b..388df9651 100644
> --- a/tests/system-ovn.at
> +++ b/tests/system-ovn.at
> @@ -18386,6 +18386,35 @@ AT_CHECK_UNQUOTED(
>
> type=select,selection_method=dp_hash,bucket=bucket_id=0,weight:100,actions=load:2147483649->reg1[[0..31]],bucket=bucket_id=1,weight:200,actions=load:2147483650->reg1[[0..31]]:
> GID
> ])
>
> +# With ECMP selection fields the group uses the hash selection method,
> +# which a NIC can offload.
> +check ovn-nbctl --wait=hv set logical_switch ls-evpn \
> +
> other_config:dynamic-routing-fdb-ecmp-selection-fields="ip_src,ip_dst,tp_src,tp_dst"
> +AT_CHECK_UNQUOTED(
> + [ovn-appctl group-table-list | \
> + sed 's/:[[[:space:]]]*[[0-9]]*$/: GID/'], [0], [dnl
> +type=select,selection_method=hash,fields(ip_dst,ip_proto,ip_src,sctp_dst,sctp_src,tcp_dst,tcp_src,udp_dst,udp_src),bucket=bucket_id=0,weight:100,actions=load:2147483649->reg1[[0..31]],bucket=bucket_id=1,weight:200,actions=load:2147483650->reg1[[0..31]]:
> GID
> +])
> +OVS_WAIT_UNTIL([ovs-ofctl -O OpenFlow15 dump-groups br-int | \
> + grep -q 'selection_method=hash'])
> +
> +# A field that is not supported keeps dp_hash.
> +check ovn-nbctl --wait=hv set logical_switch ls-evpn \
> + other_config:dynamic-routing-fdb-ecmp-selection-fields="ip_src,foo"
> +AT_CHECK_UNQUOTED(
> + [ovn-appctl group-table-list | \
> + sed 's/:[[[:space:]]]*[[0-9]]*$/: GID/'], [0], [dnl
> +type=select,selection_method=dp_hash,bucket=bucket_id=0,weight:100,actions=load:2147483649->reg1[[0..31]],bucket=bucket_id=1,weight:200,actions=load:2147483650->reg1[[0..31]]:
> GID
> +])
> +
> +check ovn-nbctl --wait=hv remove logical_switch ls-evpn \
> + other_config dynamic-routing-fdb-ecmp-selection-fields
> +AT_CHECK_UNQUOTED(
> + [ovn-appctl group-table-list | \
> + sed 's/:[[[:space:]]]*[[0-9]]*$/: GID/'], [0], [dnl
> +type=select,selection_method=dp_hash,bucket=bucket_id=0,weight:100,actions=load:2147483649->reg1[[0..31]],bucket=bucket_id=1,weight:200,actions=load:2147483650->reg1[[0..31]]:
> GID
> +])
> +
> # Verify learning suppression flows: two ECMP drop flows added.
> AT_CHECK_UNQUOTED(
> [ovs-ofctl dump-flows br-int table=OFTABLE_LEARN_REMOTE_FDB | grep
> priority | \
> --
> 2.54.0
>
> _______________________________________________
> dev mailing list
> [email protected]
> https://mail.openvswitch.org/mailman/listinfo/ovs-dev
_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev