This commit adds a test that developers can run to check if the changes
they've made cause a regression in the performance of ovn-controller or
ovn-northd.

Run from inside the sandbox and compare two commits' total script run
time and peak memory usage for each process.

This test creates a topology consisting of logical switches and routers,
ACLs, port groups, NAT, DHCP, DNS, QoS, routing policies, and load
balancers. The user can also provide their own database file for the
benchmark.

* To run the test with defaults (200 nodes, track northd and controller):
    ./ovn-benchmark.sh
* To run with 50 nodes: ./ovn-benchmark.sh 50
* To run with 30 nodes and only track ovn-northd:
    ./ovn-benchmark.sh 30 northd
* To run with a custom db file: ./ovn-benchmark.sh -f file.db
* To run with debug info printed: ./ovn-benchmark.sh --debug
* To see detailed usage instructions: ./ovn-benchmark.sh --help

Each "node" is a router-switch pair with associated features:
 - 1 gateway router (with NAT, static routes, routing policies)
 - 1 logical switch (with 9 ports by default, DHCP, DNS, QoS)
 - 5 load balancers (each with 5 backends)
 - ACLs, port groups, and address sets (shared across all nodes)

For example, 200 nodes creates:
 - 200 routers + 200 switches
 - 1,800 logical switch ports (9 per switch)
 - 1,000 load balancers (5 per node)
 - 600 NAT rules, 600 static routes, 400 routing policies
 - Plus ACLs, DHCP/DNS records, QoS rules, etc.

ovn-benchmark.py creates the topology and ovn-benchmark.sh is the
wrapper that tracks peak memory and execution time. ovn-benchmark.py was
built off of ovn-lb-benchmark.py as a starting point.

Reported-at: https://redhat.atlassian.net/browse/FDP-978
Assisted-by: Claude Sonnet 4.5, Claude Code
Signed-off-by: Rosemarie O'Riorden <[email protected]>
---
v1 -> v2:
 - Topology:
   - Restructured from 1:1 chassis:node to a nodes-per-chassis style
     where each chassis handles a partition or 'batch' of the network (ascii
     diagram added).
   - Added --batch-size (-b) for nodes-per-chassis grouping.
   - Added IPv6 coverage.
 - IP addressing:
   - ip_node()/ip6_node() helpers spread index across two octets to support
     >255 nodes.
   - Added bounds checks for node count and ports-per-switch.
 - Shell fixes:
   - pgrep -x for exact process match.
   - break 2 to exit both watcher loops on process death.
   - trap cleanup EXIT with mktemp -d for temp files.
   - Renamed SECONDS bc it's a bash keyword.
   - python -> python3.
   - Quoted array expansions, fixed PID error messages.
 - Code cleanup:
   - Extracted _add_node(), _add_ports(), _add_acl() helpers.
   - Removed dead code (find_by_name(), seqno).
   - Fixed error variable shadowing imported module 'error'.
 - Docs:
   - Added ovn-benchmark section to testing.rst.
   - Added note in submitting-patches.rst.
---
 .../contributing/submitting-patches.rst       |   4 +
 Documentation/topics/testing.rst              |  32 +
 tutorial/automake.mk                          |   4 +-
 tutorial/ovn-benchmark.py                     | 836 ++++++++++++++++++
 tutorial/ovn-benchmark.sh                     | 234 +++++
 5 files changed, 1109 insertions(+), 1 deletion(-)
 create mode 100644 tutorial/ovn-benchmark.py
 create mode 100755 tutorial/ovn-benchmark.sh

diff --git a/Documentation/internals/contributing/submitting-patches.rst 
b/Documentation/internals/contributing/submitting-patches.rst
index beede72a2..6e3c4297c 100644
--- a/Documentation/internals/contributing/submitting-patches.rst
+++ b/Documentation/internals/contributing/submitting-patches.rst
@@ -68,6 +68,10 @@ Testing is also important:
   feature.  A bug fix patch should preferably add a test that would
   fail if the bug recurs.
 
+- To check for memory or performance regressions, you can run
+  ``./ovn-benchmark.sh`` inside the OVN sandbox (``make sandbox``).
+  See the "ovn-benchmark" section of :doc:`/topics/testing` for details.
+
 If you are using GitHub, then you may utilize the GitHub Actions CI system.
 This will run the above tests automatically when you push changes to your
 repository.
diff --git a/Documentation/topics/testing.rst b/Documentation/topics/testing.rst
index 579422ca0..bb7436b9a 100644
--- a/Documentation/topics/testing.rst
+++ b/Documentation/topics/testing.rst
@@ -294,6 +294,38 @@ of these cached objects, be sure to rebuild the test.
 The cached objects are stored under the relevant folder in
 ``tests/perf-testsuite.dir/cached``.
 
+ovn-benchmark
++++++++++++++
+
+The ``tutorial/`` directory contains a memory and performance benchmarking
+tool that can be used to detect regressions between commits:
+
+- ``ovn-benchmark.sh``: Shell wrapper that tracks peak memory (RSS) and
+  wall-clock time for ``ovn-northd`` and ``ovn-controller``.
+- ``ovn-benchmark.py``: Python script that populates the Northbound database
+  with a realistic ovn-kubernetes-style topology.
+
+The generated topology includes gateway routers, logical switches, NAT rules,
+ACLs, DHCP/DHCPv6, DNS, load balancers, QoS rules, address sets, port groups,
+static routes, and routing policies -- all with both IPv4 and IPv6 coverage.
+
+The benchmark must be run from inside the OVN sandbox.  Run
+``./ovn-benchmark.sh --help`` for the full list of options::
+
+    $ make sandbox
+    $ ./ovn-benchmark.sh
+
+By default, the topology is generated through many individual OVSDB
+transactions via ``ovn-benchmark.py``.
+
+.. note::
+
+   The ``-f`` option loads a Northbound database file via ``ovsdb-client
+   restore`` instead, which applies the entire database as a single
+   transaction.  This may yield different memory and timing results because
+   ``ovn-northd`` and ``ovn-controller`` process one large batch of changes
+   rather than reacting to each transaction incrementally.
+
 OVN Upgrade Testing
 ~~~~~~~~~~~~~~~~~~~
 
diff --git a/tutorial/automake.mk b/tutorial/automake.mk
index 631208639..fdea2449c 100644
--- a/tutorial/automake.mk
+++ b/tutorial/automake.mk
@@ -2,7 +2,9 @@ EXTRA_DIST += \
        tutorial/ovn-sandbox \
        tutorial/ovn-setup.sh \
        tutorial/ovn-lb-benchmark.sh \
-       tutorial/ovn-lb-benchmark.py
+       tutorial/ovn-lb-benchmark.py \
+       tutorial/ovn-benchmark.sh \
+       tutorial/ovn-benchmark.py
 sandbox: all
        cd $(srcdir)/tutorial && MAKE=$(MAKE) HAVE_OPENSSL=$(HAVE_OPENSSL) \
                ./ovn-sandbox -b $(abs_builddir) --ovs-src $(ovs_srcdir) 
--ovs-build $(ovs_builddir) $(SANDBOXFLAGS)
diff --git a/tutorial/ovn-benchmark.py b/tutorial/ovn-benchmark.py
new file mode 100644
index 000000000..1ecf0b5b4
--- /dev/null
+++ b/tutorial/ovn-benchmark.py
@@ -0,0 +1,836 @@
+#!/usr/bin/env python3
+"""OVN memory regression testing tool.
+
+Creates a broad OVN topology to detect memory regressions between commits.
+Designed to be run via ovn-benchmark.sh. (Run ./ovn-benchmark.sh --help
+to see usage).
+
+Topology (simulates the default ovn-kubernetes topology):
+
+         lsp-0-*            lsp-1-*          (Workload ports)
+            |                  |
+         ls-0               ls-1             (Logical Switches)
+            |                  |
+         s2c-0/c2s-0        s2c-1/c2s-1
+              \\               /
+         +--- cluster (LR) ---+              (Cluster Router)
+                    |
+                sjc/rcj
+                    |
+         +----  join (LS)  ----+              (Join Switch)
+              /               \\
+         j2lr-0/lr2j-0     j2lr-1/lr2j-1
+            |                  |
+         lr-0               lr-1             (Gateway Routers)
+
+  Chassis binding (with batch size B, default n/10):
+    chassis-0: c2s-[0..B), lr-[0..B)
+    chassis-1: c2s-[B..2B), lr-[B..2B)
+    ...
+
+Each node creates a dual-stack gateway router + logical switch pair
+with:
+  - Dual-stack addressing (IPv4 10.x.x.x/24, IPv6 fd00:x::/64)
+  - NAT (SNAT/DNAT/DNAT_AND_SNAT), static routes, routing policies
+  - Configurable ports per switch with port security
+  - Security: Address sets, port groups, ACLs
+  - Services: DHCP, DHCPv6, DNS, load balancers
+  - QoS: Bandwidth limiting, DSCP marking
+
+Note: Uses explicit (non-templated) load balancers to maximize memory
+usage for regression testing. For templated LB testing, see
+ovn-lb-benchmark.py.
+"""
+
+import argparse
+import sys
+
+import ovs.db.idl
+import ovs.jsonrpc
+import ovs.poller
+import ovs.stream
+import ovs.vlog
+from ovs.db import error
+
+vlog = ovs.vlog.Vlog('ovn-benchmark')
+vlog.set_levels_from_string('console:warn')
+vlog.init(None)
+
+SCHEMA = '../ovn-nb.ovsschema'
+
+
+def die(msg):
+    sys.stderr.write(f'\nError: {msg}\n')
+    sys.exit(1)
+
+
+def ip_node(i):
+    """Convert node index to two IP octets, supporting up to 65535 nodes."""
+    return f'{i >> 8}.{i & 0xff}'
+
+
+def ip6_node(i):
+    """Convert node index to an IPv6 hextet for ULA addresses."""
+    return f'{i:x}'
+
+
+def create_address_sets(idl, n):
+    """Create address sets for security groups."""
+    vlog.info('Creating address sets')
+    txn = ovs.db.idl.Transaction(idl)
+
+    web_as = txn.insert(idl.tables['Address_Set'])
+    web_as.name = 'web_servers'
+    web_as.addresses = (
+        [f'10.{ip_node(i)}.10' for i in range(n)]
+        + [f'fd00:{ip6_node(i)}::a' for i in range(n)])
+
+    db_as = txn.insert(idl.tables['Address_Set'])
+    db_as.name = 'db_servers'
+    db_as.addresses = (
+        [f'10.{ip_node(i)}.20' for i in range(n)]
+        + [f'fd00:{ip6_node(i)}::14' for i in range(n)])
+
+    app_as = txn.insert(idl.tables['Address_Set'])
+    app_as.name = 'app_servers'
+    app_as.addresses = (
+        [f'10.{ip_node(i)}.30' for i in range(n)]
+        + [f'fd00:{ip6_node(i)}::1e' for i in range(n)])
+
+    trusted_as = txn.insert(idl.tables['Address_Set'])
+    trusted_as.name = 'trusted_networks'
+    trusted_as.addresses = ['192.168.0.0/16', '172.16.0.0/12',
+                            'fc00::/7']
+
+    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+        die(f'Failed to create address sets ({txn.get_error()})')
+
+
+def create_port_groups(idl):
+    """Create port groups for security group implementation."""
+    vlog.info('Creating port groups')
+    txn = ovs.db.idl.Transaction(idl)
+
+    web_pg = txn.insert(idl.tables['Port_Group'])
+    web_pg.name = 'web_tier'
+
+    db_pg = txn.insert(idl.tables['Port_Group'])
+    db_pg.name = 'db_tier'
+
+    app_pg = txn.insert(idl.tables['Port_Group'])
+    app_pg.name = 'app_tier'
+
+    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+        die(f'Failed to create port groups ({txn.get_error()})')
+
+
+def create_dhcp_options(idl, n):
+    """Create DHCP options for each subnet."""
+    for i in range(n):
+        vlog.info(f'Creating DHCP options for node {i}')
+        txn = ovs.db.idl.Transaction(idl)
+        dhcp_opts = txn.insert(idl.tables['DHCP_Options'])
+        dhcp_opts.cidr = f'10.{ip_node(i)}.0/24'
+        dhcp_opts.setkey('options', 'server_id', f'10.{ip_node(i)}.1')
+        dhcp_opts.setkey('options', 'server_mac', '00:00:00:00:00:01')
+        dhcp_opts.setkey('options', 'lease_time', '3600')
+        dhcp_opts.setkey('options', 'router', f'10.{ip_node(i)}.1')
+        dhcp_opts.setkey('options', 'dns_server', f'10.{ip_node(i)}.2')
+        dhcp_opts.setkey('options', 'domain_name', '"example.com"')
+        dhcp_opts.setkey('options', 'mtu', '1500')
+        dhcp_opts.setkey('external_ids', 'subnet', f'ls-{i}')
+
+        dhcpv6_opts = txn.insert(idl.tables['DHCP_Options'])
+        dhcpv6_opts.cidr = f'fd00:{ip6_node(i)}::/64'
+        dhcpv6_opts.setkey('options', 'server_id',
+                           '00:00:00:00:00:01')
+        dhcpv6_opts.setkey('options', 'dns_server',
+                           f'fd00:{ip6_node(i)}::2')
+        dhcpv6_opts.setkey('options', 'domain_search',
+                           '"example.com"')
+        dhcpv6_opts.setkey('external_ids', 'subnet',
+                           f'ls-{i}-v6')
+
+        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+            die(f'Failed to create DHCP options for node {i} '
+                f'({txn.get_error()})')
+
+
+def create_qos_rules(idl, n, switches):
+    """Create QoS rules for bandwidth limiting and DSCP marking."""
+    for i in range(n):
+        vlog.info(f'Creating QoS rules for node {i}')
+        txn = ovs.db.idl.Transaction(idl)
+
+        ls = switches.get(f'ls-{i}')
+        if ls:
+            qos_bw = txn.insert(idl.tables['QoS'])
+            qos_bw.priority = 100
+            qos_bw.direction = 'to-lport'
+            qos_bw.match = f'inport == "lsp-{i}-0"'
+            qos_bw.setkey('bandwidth', 'rate', 1000)
+            qos_bw.setkey('bandwidth', 'burst', 100)
+            qos_bw.setkey('external_ids', 'type', 'rate-limit')
+            ls.addvalue('qos_rules', qos_bw.uuid)
+
+            qos_dscp = txn.insert(idl.tables['QoS'])
+            qos_dscp.priority = 200
+            qos_dscp.direction = 'from-lport'
+            qos_dscp.match = 'ip4 && tcp.dst == 22'
+            qos_dscp.setkey('action', 'dscp', 46)
+            qos_dscp.setkey('external_ids', 'type', 'dscp-marking')
+            ls.addvalue('qos_rules', qos_dscp.uuid)
+
+            qos_mark = txn.insert(idl.tables['QoS'])
+            qos_mark.priority = 150
+            qos_mark.direction = 'from-lport'
+            qos_mark.match = 'ip4 && udp'
+            qos_mark.setkey('action', 'mark', 1)
+            qos_mark.setkey('external_ids', 'type', 'packet-marking')
+            ls.addvalue('qos_rules', qos_mark.uuid)
+
+            qos_dscp_v6 = txn.insert(idl.tables['QoS'])
+            qos_dscp_v6.priority = 200
+            qos_dscp_v6.direction = 'from-lport'
+            qos_dscp_v6.match = 'ip6 && tcp.dst == 22'
+            qos_dscp_v6.setkey('action', 'dscp', 46)
+            qos_dscp_v6.setkey('external_ids', 'type',
+                               'dscp-marking-v6')
+            ls.addvalue('qos_rules', qos_dscp_v6.uuid)
+
+            qos_mark_v6 = txn.insert(idl.tables['QoS'])
+            qos_mark_v6.priority = 150
+            qos_mark_v6.direction = 'from-lport'
+            qos_mark_v6.match = 'ip6 && udp'
+            qos_mark_v6.setkey('action', 'mark', 1)
+            qos_mark_v6.setkey('external_ids', 'type',
+                               'packet-marking-v6')
+            ls.addvalue('qos_rules', qos_mark_v6.uuid)
+
+        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+            die(f'Failed to create QoS rules for node {i} ({txn.get_error()})')
+
+
+def create_acls_for_port_group(idl, pg_name, allowed_ports, priority_base):
+    """Create ACLs for a specific port group."""
+    txn = ovs.db.idl.Transaction(idl)
+
+    for row in idl.tables['Port_Group'].rows.values():
+        if row.name == pg_name:
+            acl_allow_est = txn.insert(idl.tables['ACL'])
+            acl_allow_est.priority = priority_base + 100
+            acl_allow_est.direction = 'to-lport'
+            acl_allow_est.match = 'ct.est && !ct.rel && !ct.new && !ct.inv'
+            acl_allow_est.action = 'allow-related'
+            row.addvalue('acls', acl_allow_est.uuid)
+
+            acl_allow_rel = txn.insert(idl.tables['ACL'])
+            acl_allow_rel.priority = priority_base + 100
+            acl_allow_rel.direction = 'to-lport'
+            acl_allow_rel.match = 'ct.rel && !ct.est && !ct.new && !ct.inv'
+            acl_allow_rel.action = 'allow-related'
+            row.addvalue('acls', acl_allow_rel.uuid)
+
+            for port in allowed_ports:
+                acl_new = txn.insert(idl.tables['ACL'])
+                acl_new.priority = priority_base + 50
+                acl_new.direction = 'to-lport'
+                acl_new.match = f'ct.new && tcp.dst == {port}'
+                acl_new.action = 'allow-related'
+                row.addvalue('acls', acl_new.uuid)
+
+            acl_drop = txn.insert(idl.tables['ACL'])
+            acl_drop.priority = priority_base
+            acl_drop.direction = 'to-lport'
+            acl_drop.match = 'inport == @' + pg_name
+            acl_drop.action = 'drop'
+            row.addvalue('acls', acl_drop.uuid)
+
+            acl_arp = txn.insert(idl.tables['ACL'])
+            acl_arp.priority = priority_base + 10
+            acl_arp.direction = 'to-lport'
+            acl_arp.match = 'arp || nd'
+            acl_arp.action = 'allow'
+            row.addvalue('acls', acl_arp.uuid)
+            break
+
+    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+        die(f'Failed to create ACLs for {pg_name} ({txn.get_error()})')
+
+
+def create_acls(idl):
+    """Create comprehensive ACLs for port groups.
+
+    ACL Priority Allocation:
+      2500-2999: Security enforcement (anti-spoofing, etc.)
+      2000-2499: Management access (SSH, ICMP, DHCP)
+      1000-1499: Port group ACLs (security groups)
+         1100: Connection tracking (established, related)
+         1050: New connection per-port rules
+         1010: ARP/ND allow
+         1000: Default drop
+
+    Switch ACLs (higher priority) can override port-group ACLs, with
+    security enforcement (anti-spoofing) taking highest priority.
+    """
+    vlog.info('Creating ACLs for port groups')
+    create_acls_for_port_group(idl, 'web_tier', [80, 443], 1000)
+    create_acls_for_port_group(idl, 'app_tier', [8080, 9000], 1000)
+    create_acls_for_port_group(idl, 'db_tier', [5432, 3306], 1000)
+
+
+def _add_acl(txn, idl, ls, priority, direction, match, action,
+             description=None):
+    """Create an ACL and attach it to a logical switch."""
+    acl = txn.insert(idl.tables['ACL'])
+    acl.priority = priority
+    acl.direction = direction
+    acl.match = match
+    acl.action = action
+    if description:
+        acl.setkey('external_ids', 'description', description)
+    ls.addvalue('acls', acl.uuid)
+
+
+def add_acls_to_switch(idl, switch_name, node_id, switches):
+    """Add ACLs directly to a logical switch.
+
+    Adds switch-level ACLs for SSH, ICMP, anti-spoofing, and DHCP.
+    Anti-spoofing (priority 2500) prevents VMs from using IPs outside
+    their assigned subnet, blocking IP address spoofing attacks.
+    """
+    txn = ovs.db.idl.Transaction(idl)
+
+    ls = switches.get(switch_name)
+    if ls:
+        _add_acl(txn, idl, ls, 2000, 'from-lport',
+                 'tcp.dst == 22 && ip4.src == 10.0.0.0/8',
+                 'allow', 'Allow SSH from internal')
+        _add_acl(txn, idl, ls, 1500, 'from-lport',
+                 'icmp4 || icmp6', 'allow')
+        _add_acl(txn, idl, ls, 2500, 'from-lport',
+                 f'ip4.src != 10.{ip_node(node_id)}.0/24',
+                 'drop', 'Anti-spoofing')
+        _add_acl(txn, idl, ls, 2000, 'from-lport',
+                 'udp.src == 68 && udp.dst == 67', 'allow')
+
+        _add_acl(txn, idl, ls, 2000, 'from-lport',
+                 'tcp.dst == 22 && ip6.src == fd00::/16',
+                 'allow', 'Allow SSH from internal (IPv6)')
+        _add_acl(txn, idl, ls, 2500, 'from-lport',
+                 f'ip6.src != fd00:{ip6_node(node_id)}::/64',
+                 'drop', 'Anti-spoofing (IPv6)')
+        _add_acl(txn, idl, ls, 2000, 'from-lport',
+                 'udp.src == 546 && udp.dst == 547', 'allow')
+
+    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+        die(f'Failed to add ACLs to switch {switch_name} '
+            f'({txn.get_error()})')
+
+
+def create_dns_records(idl, n, switches):
+    """Create DNS records in the NB database."""
+    for i in range(n):
+        vlog.info(f'Creating DNS records for node {i}')
+        txn = ovs.db.idl.Transaction(idl)
+        dns = txn.insert(idl.tables['DNS'])
+        dns.setkey('records', f'web-{i}.example.com',
+                   f'10.{ip_node(i)}.10 '
+                   f'fd00:{ip6_node(i)}::a')
+        dns.setkey('records', f'app-{i}.example.com',
+                   f'10.{ip_node(i)}.30 '
+                   f'fd00:{ip6_node(i)}::1e')
+        dns.setkey('records', f'db-{i}.example.com',
+                   f'10.{ip_node(i)}.20 '
+                   f'fd00:{ip6_node(i)}::14')
+        dns.setkey('external_ids', 'zone', f'zone-{i}')
+
+        ls = switches.get(f'ls-{i}')
+        if ls:
+            ls.addvalue('dns_records', dns.uuid)
+
+        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+            die(f'Failed to create DNS records for node {i} '
+                f'({txn.get_error()})')
+
+
+def add_nat_rules(idl, n, routers):
+    """Add NAT rules to routers.
+
+    Creates SNAT, DNAT, and DNAT_AND_SNAT rules to exercise all NAT code paths.
+    """
+    for i in range(n):
+        vlog.info(f'Adding NAT rules to router {i}')
+        txn = ovs.db.idl.Transaction(idl)
+
+        lr = routers.get(f'lr-{i}')
+        if lr:
+            nat_snat = txn.insert(idl.tables['NAT'])
+            nat_snat.type = 'snat'
+            nat_snat.logical_ip = f'10.{ip_node(i)}.0/24'
+            nat_snat.external_ip = f'192.{ip_node(i)}.1'
+            lr.addvalue('nat', nat_snat.uuid)
+
+            nat_dnat = txn.insert(idl.tables['NAT'])
+            nat_dnat.type = 'dnat'
+            nat_dnat.logical_ip = f'10.{ip_node(i)}.10'
+            nat_dnat.external_ip = f'192.{ip_node(i)}.10'
+            nat_dnat.setkey('external_ids', 'service', 'web')
+            lr.addvalue('nat', nat_dnat.uuid)
+
+            nat_dnat_and_snat = txn.insert(idl.tables['NAT'])
+            nat_dnat_and_snat.type = 'dnat_and_snat'
+            nat_dnat_and_snat.logical_ip = f'10.{ip_node(i)}.20'
+            nat_dnat_and_snat.external_ip = f'192.{ip_node(i)}.20'
+            nat_dnat_and_snat.setkey('external_ids', 'service', 'db')
+            lr.addvalue('nat', nat_dnat_and_snat.uuid)
+
+            nat_snat_v6 = txn.insert(idl.tables['NAT'])
+            nat_snat_v6.type = 'snat'
+            nat_snat_v6.logical_ip = f'fd00:{ip6_node(i)}::/64'
+            nat_snat_v6.external_ip = f'fd01:{ip6_node(i)}::1'
+            lr.addvalue('nat', nat_snat_v6.uuid)
+
+            nat_dnat_v6 = txn.insert(idl.tables['NAT'])
+            nat_dnat_v6.type = 'dnat'
+            nat_dnat_v6.logical_ip = f'fd00:{ip6_node(i)}::a'
+            nat_dnat_v6.external_ip = f'fd01:{ip6_node(i)}::a'
+            nat_dnat_v6.setkey('external_ids',
+                               'service', 'web-v6')
+            lr.addvalue('nat', nat_dnat_v6.uuid)
+
+            nat_ds_v6 = txn.insert(idl.tables['NAT'])
+            nat_ds_v6.type = 'dnat_and_snat'
+            nat_ds_v6.logical_ip = f'fd00:{ip6_node(i)}::14'
+            nat_ds_v6.external_ip = f'fd01:{ip6_node(i)}::14'
+            nat_ds_v6.setkey('external_ids',
+                             'service', 'db-v6')
+            lr.addvalue('nat', nat_ds_v6.uuid)
+
+        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+            die(f'Failed to add NAT rules for node {i} '
+                f'({txn.get_error()})')
+
+
+def add_static_routes(idl, n, routers):
+    """Add static routes to routers."""
+    for i in range(n):
+        vlog.info(f'Adding static routes to router {i}')
+        txn = ovs.db.idl.Transaction(idl)
+
+        lr = routers.get(f'lr-{i}')
+        if lr:
+            route_default = txn.insert(
+                idl.tables['Logical_Router_Static_Route'])
+            route_default.ip_prefix = '0.0.0.0/0'
+            route_default.nexthop = '10.0.0.1'
+            route_default.setkey('external_ids', 'type', 'default')
+            lr.addvalue('static_routes', route_default.uuid)
+
+            route_specific = txn.insert(
+                idl.tables['Logical_Router_Static_Route'])
+            route_specific.ip_prefix = f'172.{ip_node(i)}.0/24'
+            route_specific.nexthop = f'10.{ip_node(i)}.254'
+            route_specific.setkey('external_ids', 'type', 'specific')
+            lr.addvalue('static_routes', route_specific.uuid)
+
+            route_discard = txn.insert(
+                idl.tables['Logical_Router_Static_Route'])
+            route_discard.ip_prefix = '192.0.2.0/24'
+            route_discard.nexthop = 'discard'
+            route_discard.setkey('external_ids', 'type', 'blackhole')
+            lr.addvalue('static_routes', route_discard.uuid)
+
+            route_default_v6 = txn.insert(
+                idl.tables['Logical_Router_Static_Route'])
+            route_default_v6.ip_prefix = '::/0'
+            route_default_v6.nexthop = 'fd00::1'
+            route_default_v6.setkey('external_ids', 'type',
+                                    'default-v6')
+            lr.addvalue('static_routes', route_default_v6.uuid)
+
+            route_specific_v6 = txn.insert(
+                idl.tables['Logical_Router_Static_Route'])
+            route_specific_v6.ip_prefix = (
+                f'fd02:{ip6_node(i)}::/64')
+            route_specific_v6.nexthop = (
+                f'fd00:{ip6_node(i)}::fe')
+            route_specific_v6.setkey('external_ids', 'type',
+                                     'specific-v6')
+            lr.addvalue('static_routes',
+                        route_specific_v6.uuid)
+
+            route_discard_v6 = txn.insert(
+                idl.tables['Logical_Router_Static_Route'])
+            route_discard_v6.ip_prefix = '2001:db8::/32'
+            route_discard_v6.nexthop = 'discard'
+            route_discard_v6.setkey('external_ids', 'type',
+                                    'blackhole-v6')
+            lr.addvalue('static_routes',
+                        route_discard_v6.uuid)
+
+        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+            die(f'Failed to add static routes for node {i} '
+                f'({txn.get_error()})')
+
+
+def add_routing_policies(idl, n, routers):
+    """Add routing policies to routers."""
+    for i in range(n):
+        vlog.info(f'Adding routing policies to router {i}')
+        txn = ovs.db.idl.Transaction(idl)
+
+        lr = routers.get(f'lr-{i}')
+        if lr:
+            policy_reroute = txn.insert(idl.tables['Logical_Router_Policy'])
+            policy_reroute.priority = 100
+            policy_reroute.match = f'ip4.src == 10.{ip_node(i)}.0/24'
+            policy_reroute.action = 'reroute'
+            policy_reroute.nexthops = [f'10.{ip_node((i + 1) % n)}.1']
+            policy_reroute.setkey('external_ids', 'policy',
+                                  'traffic-engineering')
+            lr.addvalue('policies', policy_reroute.uuid)
+
+            policy_allow = txn.insert(idl.tables['Logical_Router_Policy'])
+            policy_allow.priority = 50
+            policy_allow.match = 'ip4.dst == $trusted_networks'
+            policy_allow.action = 'allow'
+            lr.addvalue('policies', policy_allow.uuid)
+
+            policy_reroute_v6 = txn.insert(
+                idl.tables['Logical_Router_Policy'])
+            policy_reroute_v6.priority = 100
+            policy_reroute_v6.match = (
+                f'ip6.src == fd00:{ip6_node(i)}::/64')
+            policy_reroute_v6.action = 'reroute'
+            policy_reroute_v6.nexthops = [
+                f'fd00:{ip6_node((i + 1) % n)}::1']
+            policy_reroute_v6.setkey(
+                'external_ids', 'policy',
+                'traffic-engineering-v6')
+            lr.addvalue('policies',
+                        policy_reroute_v6.uuid)
+
+            policy_allow_v6 = txn.insert(
+                idl.tables['Logical_Router_Policy'])
+            policy_allow_v6.priority = 50
+            policy_allow_v6.match = (
+                'ip6.dst == $trusted_networks')
+            policy_allow_v6.action = 'allow'
+            lr.addvalue('policies', policy_allow_v6.uuid)
+
+        if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+            die(f'Failed to add routing policies for node {i} '
+                f'({txn.get_error()})')
+
+
+def _add_ports(txn, idl, s, i, ports_per_switch):
+    for p in range(ports_per_switch):
+        lsp = txn.insert(idl.tables['Logical_Switch_Port'])
+        lsp.name = f'lsp-{i}-{p}'
+        mac_byte = (p + 10) % 256
+        mac = (f'00:00:{i >> 8:02x}:{i & 0xff:02x}'
+               f':{p:02x}:{mac_byte:02x}')
+        ip = f'10.{ip_node(i)}.{10 + p}'
+        ip6 = f'fd00:{ip6_node(i)}::{10 + p:x}'
+        lsp.addresses = [f'{mac} {ip} {ip6}']
+        lsp.port_security = [f'{mac} {ip} {ip6}']
+        lsp.setkey('external_ids', 'vm-id', f'vm-{i}-{p}')
+
+        if p % 3 == 0:
+            lsp.setkey('external_ids', 'tier', 'web')
+        elif p % 3 == 1:
+            lsp.setkey('external_ids', 'tier', 'app')
+        else:
+            lsp.setkey('external_ids', 'tier', 'db')
+
+        s.addvalue('ports', lsp.uuid)
+
+
+def _add_node(txn, idl, i, chassis, cluster_rtr, join_sw, lbg,
+              ports_per_switch):
+    gwr = txn.insert(idl.tables['Logical_Router'])
+    gwr.name = f'lr-{i}'
+    gwr.addvalue('load_balancer_group', lbg.uuid)
+    gwr.setkey('options', 'chassis', chassis)
+
+    gwr2join = txn.insert(idl.tables['Logical_Router_Port'])
+    gwr2join.name = f'lr2j-{i}'
+    gwr2join.mac = '00:00:00:00:00:01'
+    gwr2join.networks = ['10.0.0.1/8', 'fd00::1/48']
+    gwr.addvalue('ports', gwr2join.uuid)
+
+    join2gwr = txn.insert(idl.tables['Logical_Switch_Port'])
+    join2gwr.name = f'j2lr-{i}'
+    join2gwr.type = 'router'
+    join2gwr.addresses = ['router']
+    join2gwr.setkey('options', 'router-port', gwr2join.name)
+    join_sw.addvalue('ports', join2gwr.uuid)
+
+    s = txn.insert(idl.tables['Logical_Switch'])
+    s.name = f'ls-{i}'
+    s.addvalue('load_balancer_group', lbg.uuid)
+    s.setkey('other_config', 'subnet', f'10.{ip_node(i)}.0/24')
+    s.setkey('other_config', 'mcast_snoop', 'true')
+
+    cluster2s = txn.insert(idl.tables['Logical_Router_Port'])
+    cluster2s.name = f'c2s-{i}'
+    cluster2s.mac = '00:00:00:00:00:01'
+    cluster2s.networks = [f'10.{ip_node(i)}.1/24',
+                          f'fd00:{ip6_node(i)}::1/64']
+    cluster_rtr.addvalue('ports', cluster2s.uuid)
+
+    gw_chassis = txn.insert(idl.tables['Gateway_Chassis'])
+    gw_chassis.name = f'{cluster2s.name}-{chassis}'
+    gw_chassis.chassis_name = chassis
+    gw_chassis.priority = 1
+    cluster2s.addvalue('gateway_chassis', gw_chassis.uuid)
+
+    s2cluster = txn.insert(idl.tables['Logical_Switch_Port'])
+    s2cluster.name = f's2c-{i}'
+    s2cluster.type = 'router'
+    s2cluster.addresses = ['router']
+    s2cluster.setkey('options', 'router-port', cluster2s.name)
+    s.addvalue('ports', s2cluster.uuid)
+
+    _add_ports(txn, idl, s, i, ports_per_switch)
+
+
+def create_topology(idl, n, ports_per_switch, batch_size):
+    """Create the basic topology with routers, switches, and ports."""
+    vlog.info('Creating topology')
+    txn = ovs.db.idl.Transaction(idl)
+    lbg = txn.insert(idl.tables['Load_Balancer_Group'])
+    lbg.name = 'lbg'
+
+    vlog.info('Adding join switch')
+    join_sw = txn.insert(idl.tables['Logical_Switch'])
+    join_sw.name = 'join'
+
+    cluster_rtr = txn.insert(idl.tables['Logical_Router'])
+    cluster_rtr.name = 'cluster'
+
+    rcj = txn.insert(idl.tables['Logical_Router_Port'])
+    rcj.name = 'rcj'
+    rcj.mac = '00:00:00:00:00:01'
+    rcj.networks = ['10.0.0.1/8', 'fd00::1/48']
+    cluster_rtr.addvalue('ports', rcj.uuid)
+
+    sjc = txn.insert(idl.tables['Logical_Switch_Port'])
+    sjc.name = 'sjc'
+    sjc.type = 'router'
+    sjc.addresses = ['router']
+    sjc.setkey('options', 'router-port', 'rcj')
+    join_sw.addvalue('ports', sjc.uuid)
+
+    for i in range(n):
+        vlog.info(f'Provisioning node {i}')
+        chassis = f'chassis-{i // batch_size}'
+        _add_node(txn, idl, i, chassis, cluster_rtr, join_sw, lbg,
+                  ports_per_switch)
+
+    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+        die(f'Failed to create topology ({txn.get_error()})')
+
+
+def assign_ports_to_groups(idl):
+    """Assign logical switch ports to port groups based on tier."""
+    vlog.info('Assigning ports to port groups')
+
+    tier_ports = {'web': [], 'app': [], 'db': []}
+
+    for row in idl.tables['Logical_Switch_Port'].rows.values():
+        tier = row.external_ids.get('tier')
+        if tier in tier_ports:
+            tier_ports[tier].append(row.uuid)
+
+    txn = ovs.db.idl.Transaction(idl)
+    tier_to_group = {'web': 'web_tier', 'app': 'app_tier', 'db': 'db_tier'}
+    for row in idl.tables['Port_Group'].rows.values():
+        for tier, group_name in tier_to_group.items():
+            if row.name == group_name:
+                for port_uuid in tier_ports[tier]:
+                    row.addvalue('ports', port_uuid)
+
+    if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+        die(f'Failed to assign ports to groups ({txn.get_error()})')
+
+
+def add_explicit_lbs(idl, n, n_vips, n_backends, routers, switches):
+    """Add explicit (non-templated) load balancers.
+
+    Uses explicit (non-templated) LBs to maximize memory usage for
+    regression testing.
+    """
+    for i in range(n):
+        lr = routers.get(f'lr-{i}')
+        ls = switches.get(f'ls-{i}')
+        for j in range(n_vips):
+            vlog.info(f'Adding LB {j} for node {i}')
+            txn = ovs.db.idl.Transaction(idl)
+            port = j + 1
+            j1 = (j + 1) // 250
+            j2 = (j + 1) % 250
+
+            lb = txn.insert(idl.tables['Load_Balancer'])
+            lb.name = f'lb-{j}-{i}'
+            lb.setkey('vips', f'42.42.{ip_node(i)}:{port}',
+                      ','.join(f'42.{k}.{j1}.{j2}:{port}'
+                               for k in range(n_backends)))
+            lb.setkey(
+                'vips',
+                f'[fd42::{ip6_node(i)}:{j:x}]:{port}',
+                ','.join(f'[fd42:{k:x}::{j1:x}:{j2:x}]:{port}'
+                         for k in range(n_backends)))
+            lb.protocol = 'tcp'
+            lr.addvalue('load_balancer', lb.uuid)
+            ls.addvalue('load_balancer', lb.uuid)
+            if txn.commit_block() != ovs.db.idl.Transaction.SUCCESS:
+                die(f'Failed to add LB ({txn.get_error()})')
+
+
+def run(remote, n, n_vips, n_backends, ports_per_switch, batch_size):
+    """Main execution function."""
+    schema_helper = ovs.db.idl.SchemaHelper(SCHEMA)
+    schema_helper.register_all()
+    idl = ovs.db.idl.Idl(remote, schema_helper, leader_only=False)
+
+    err, stream = ovs.stream.Stream.open_block(
+        ovs.stream.Stream.open(remote), 2000
+    )
+    if err:
+        sys.stderr.write(f'failed to connect to "{remote}"')
+        sys.exit(1)
+
+    if not stream:
+        sys.stderr.write(f'failed to connect to "{remote}"')
+        sys.exit(1)
+    rpc = ovs.jsonrpc.Connection(stream)
+
+    while idl.change_seqno == 0 and not idl.run():
+        rpc.run()
+
+        poller = ovs.poller.Poller()
+        idl.wait(poller)
+        rpc.wait(poller)
+        poller.block()
+
+    # Check if database is clean before proceeding
+    if (len(idl.tables['Load_Balancer_Group'].rows) > 0 or
+        len(idl.tables['Logical_Switch'].rows) > 0 or
+        len(idl.tables['Logical_Router'].rows) > 0):
+        die('Database is not empty. Please restart the sandbox or clear the '
+            'database before running this script.')
+
+    create_topology(idl, n, ports_per_switch, batch_size)
+
+    # Build lookup dictionaries for O(1) access to switches and routers
+    switches = {row.name: row
+                for row in idl.tables['Logical_Switch'].rows.values()}
+    routers = {row.name: row
+               for row in idl.tables['Logical_Router'].rows.values()}
+
+    create_address_sets(idl, n)
+    create_port_groups(idl)
+    assign_ports_to_groups(idl)
+    create_dhcp_options(idl, n)
+    create_dns_records(idl, n, switches)
+    add_nat_rules(idl, n, routers)
+    add_static_routes(idl, n, routers)
+    create_acls(idl)
+    for i in range(n):
+        add_acls_to_switch(idl, f'ls-{i}', i, switches)
+    create_qos_rules(idl, n, switches)
+    add_routing_policies(idl, n, routers)
+    add_explicit_lbs(idl, n, n_vips, n_backends, routers, switches)
+
+
+def main():
+    """Parse arguments and run the benchmark."""
+    parser = argparse.ArgumentParser(
+        description='Create a complex OVN topology with various features'
+    )
+    parser.add_argument(
+        '-r', '--remote', required=True, help='NB connection string'
+    )
+    parser.add_argument(
+        '-n', '--nodes', type=int, required=True, help='Number of nodes'
+    )
+    parser.add_argument(
+        '-p',
+        '--ports-per-switch',
+        type=int,
+        default=9,
+        help='Number of logical switch ports per switch (default: 9, '
+             'provides 3 ports per tier)',
+    )
+    parser.add_argument(
+        '-v', '--vips', type=int, default=5,
+        help='Number of LB VIPs per node (default: 5)'
+    )
+    parser.add_argument(
+        '-B',
+        '--backends',
+        type=int,
+        default=5,
+        help='Number backends per VIP (default: 5)',
+    )
+    parser.add_argument(
+        '-b',
+        '--batch-size',
+        type=int,
+        default=0,
+        help='Nodes per chassis (default: n/10)',
+    )
+    parser.add_argument(
+        '-d', '--debug',
+        action='store_true',
+        help='Enable debug output (show info messages)',
+    )
+    args = parser.parse_args()
+
+    if args.batch_size <= 0:
+        args.batch_size = max(1, args.nodes // 10)
+
+    if args.nodes > 65535:
+        sys.stderr.write('Error: maximum supported node count is 65535\n')
+        sys.exit(1)
+
+    if args.ports_per_switch > 245:
+        sys.stderr.write(
+            'Error: maximum supported ports per switch is 245\n')
+        sys.exit(1)
+
+    if args.debug:
+        vlog.set_levels_from_string('console:info')
+
+    # Print configuration summary
+    sys.stderr.write('\n=== OVN Benchmark Configuration ===\n')
+    sys.stderr.write(f'Nodes:                        {args.nodes}'
+                     f' ({args.nodes} routers + {args.nodes} switches)\n')
+    sys.stderr.write(f'Ports per switch:             {args.ports_per_switch} '
+                     f'({args.ports_per_switch * args.nodes} total ports)\n')
+    sys.stderr.write(f'Load balancer VIPs per node:  {args.vips} '
+                     f'({args.vips * args.nodes} total VIPs)\n')
+    sys.stderr.write(f'Backends per VIP:             {args.backends}\n')
+    sys.stderr.write(f'Total load balancers:         '
+                     f'{args.nodes * args.vips}\n')
+    n_chassis = ((args.nodes + args.batch_size - 1)
+                 // args.batch_size)
+    sys.stderr.write(f'Nodes per chassis (batch):    '
+                     f'{args.batch_size} '
+                     f'({n_chassis} chassis)\n')
+    sys.stderr.write(f'Debug logging:                '
+                     f'{"enabled" if args.debug else "disabled"}\n')
+    sys.stderr.write('===================================\n\n')
+
+    run(args.remote, args.nodes, args.vips, args.backends,
+        args.ports_per_switch, args.batch_size)
+
+
+if __name__ == '__main__':
+    try:
+        main()
+    except error.Error as e:
+        sys.stderr.write(f'{e}\n')
+        sys.exit(1)
diff --git a/tutorial/ovn-benchmark.sh b/tutorial/ovn-benchmark.sh
new file mode 100755
index 000000000..73df6970e
--- /dev/null
+++ b/tutorial/ovn-benchmark.sh
@@ -0,0 +1,234 @@
+#!/bin/bash
+
+DEFAULT_NODES=200
+
+PROCESS_NAME=()
+FILE_NAME=""
+NODES=""
+PROCESS_PIDS=()
+CURRENT_MEM=()
+PEAK_MEM=()
+FINAL_PEAK_KB=()
+FINAL_PEAK_MB=()
+DEBUG=false
+BATCH_SIZE=""
+WATCHER_PID=""
+BENCHMARK_TMPDIR=""
+
+# Cleanup function to kill background watcher and remove temp files.
+cleanup() {
+    if [ -n "$WATCHER_PID" ]; then
+        kill $WATCHER_PID 2>/dev/null
+    fi
+    if [ -n "$BENCHMARK_TMPDIR" ]; then
+        rm -rf "$BENCHMARK_TMPDIR"
+    fi
+}
+
+# Register cleanup to run on script exit.
+trap cleanup EXIT
+
+while [[ $# -gt 0 ]]; do
+    case "$1" in
+        -h|--help|--usage)
+            echo "Usage: $0 [OPTIONS] [NODES] [PROCESS...]"
+            echo ""
+            echo "Arguments:"
+            echo "  NODES         Number of nodes to create" \
+                 "(default: $DEFAULT_NODES)"
+            echo "  PROCESS       Process(es) to track:" \
+                 "ovn-northd, ovn-controller"
+            echo "                (default: both)"
+            echo ""
+            echo "Options:"
+            echo "  -f, --file FILE       Load NB database from" \
+                 "file instead of generating"
+            echo "  -b, --batch-size N    Nodes per chassis" \
+                 "(default: NODES/10)"
+            echo "  -d, --debug           Enable debug output"
+            echo "  -h, --help            Show this help message"
+            echo ""
+            echo "Examples:"
+            echo "  $0                      # 200 nodes, track both processes"
+            echo "  $0 50                   # 50 nodes"
+            echo "  $0 50 ovn-northd        # 50 nodes, track only ovn-northd"
+            echo "  $0 --debug 20           # 20 nodes with debug output"
+            echo "  $0 --file ovnnb_db.db   # Load from file"
+            echo "  $0 50 -b 10             # 50 nodes, 10 per chassis"
+            exit 0
+            ;;
+        -b|--batch-size)
+            BATCH_SIZE="$2"
+            shift 2
+            ;;
+        -d|--debug)
+            DEBUG=true
+            shift
+            ;;
+        -f|--file)
+            FILE_NAME="$2"
+            shift 2
+            ;;
+        -*)
+            echo "Unknown option: $1"
+            exit 1
+            ;;
+        *)
+            if [ -z "$NODES" ]; then
+                NODES="$1"
+            else
+                # Normalize process names: accept both "northd" and
+                # "ovn-northd".
+                case "$1" in
+                    northd)
+                        PROCESS_NAME+=("ovn-northd")
+                        ;;
+                    controller)
+                        PROCESS_NAME+=("ovn-controller")
+                        ;;
+                    *)
+                        PROCESS_NAME+=("$1")
+                        ;;
+                esac
+            fi
+            shift
+            ;;
+    esac
+done
+
+# Apply defaults if not set by user.
+NODES=${NODES:-$DEFAULT_NODES}
+
+if [ -z "$BATCH_SIZE" ]; then
+    BATCH_SIZE=$((NODES / 10))
+fi
+if [ "$BATCH_SIZE" -lt 1 ]; then
+    BATCH_SIZE=1
+fi
+
+# Track both processes if not specified.
+if [ ${#PROCESS_NAME[@]} -eq 0 ]; then
+    PROCESS_NAME=("ovn-controller" "ovn-northd")
+fi
+
+if [ "$DEBUG" = true ]; then
+    echo "Nodes:       $NODES"
+    echo "Batch size:  $BATCH_SIZE"
+    echo "Processes:   ${PROCESS_NAME[*]}"
+    echo "File:        ${FILE_NAME:-None}"
+fi
+
+for pn in "${PROCESS_NAME[@]}"; do
+    pid=$(pgrep -x "$pn" | head -n 1)
+    if [ -z "$pid" ]; then
+        echo "Error: Could not find process matching '$pn'"
+        exit 1
+    fi
+    PROCESS_PIDS+=("$pid")
+done
+
+if [ "$DEBUG" = true ]; then
+    for i in "${!PROCESS_NAME[@]}"; do
+        echo "Tracking memory for ${PROCESS_NAME[$i]}" \
+             "(PID: ${PROCESS_PIDS[$i]})"
+    done
+fi
+
+# Create a temporary file to store the highest memory value we see.
+BENCHMARK_TMPDIR=$(mktemp -d)
+for pn in "${PROCESS_NAME[@]}"; do
+    echo 0 > "$BENCHMARK_TMPDIR/peak_mem_$pn.txt"
+done
+
+
+# Start the background "Watcher" loop.
+while true; do
+    for i in "${!PROCESS_NAME[@]}"; do
+        pn="${PROCESS_NAME[$i]}"
+        pid="${PROCESS_PIDS[$i]}"
+
+        # Get the Resident Set Size (RSS) memory in KB.
+        CURRENT_MEM[$i]=$(ps -p $pid -o rss= 2>/dev/null)
+
+        # If the process died, break out of both loops.
+        if [ -z "${CURRENT_MEM[$i]}" ]; then break 2; fi
+
+        PEAK_MEM[$i]=$(cat "$BENCHMARK_TMPDIR/peak_mem_$pn.txt")
+
+        if [ "${CURRENT_MEM[$i]}" -gt "${PEAK_MEM[$i]}" ]; then
+            echo "${CURRENT_MEM[$i]}" > "$BENCHMARK_TMPDIR/peak_mem_$pn.txt"
+        fi
+    done
+
+    sleep 0.5
+done &
+
+WATCHER_PID=$!
+
+START_TIME=$(date +%s%2N)
+
+if [ "$DEBUG" = true ]; then
+    DEBUG_FLAG="-d"
+else
+    DEBUG_FLAG=""
+fi
+
+# Load database from file or generate with Python script.
+if [ -n "$FILE_NAME" ]; then
+    echo "Loading database from file: $FILE_NAME"
+    if [ ! -f "$FILE_NAME" ]; then
+        echo "Error: File '$FILE_NAME' not found"
+        exit 1
+    fi
+    ovsdb-client restore unix:$PWD/sandbox/nb1.ovsdb < "$FILE_NAME"
+else
+    echo "Generating database with Python script"
+    python3 ovn-benchmark.py -n $NODES -b $BATCH_SIZE \
+        -r unix:$PWD/sandbox/nb1.ovsdb $DEBUG_FLAG
+    if [ $? -ne 0 ]; then
+        echo "Error: Failed to generate database"
+        exit 1
+    fi
+fi
+
+# Bind the first port of each switch assigned to chassis-0.
+for i in $(seq 0 $((BATCH_SIZE - 1))); do
+    ovs-vsctl add-port br-int lsp-${i}-0 -- \
+        set interface lsp-${i}-0 \
+        external_ids:iface-id=lsp-${i}-0
+done
+
+# Wait for ovn-controller to claim ports and finish processing.
+ovn-nbctl --wait=hv sync
+
+ovs-appctl -t $PWD/sandbox/nb1 ovsdb-server/compact
+ovs-appctl -t $PWD/sandbox/sb1 ovsdb-server/compact
+
+END_TIME=$(date +%s%2N)
+
+kill $WATCHER_PID 2>/dev/null
+wait $WATCHER_PID 2>/dev/null
+
+ELAPSED_TIME=$((END_TIME - START_TIME))
+ELAPSED_SECS=$((ELAPSED_TIME / 100))
+ELAPSED_HSECS=$((ELAPSED_TIME % 100))
+
+for i in "${!PROCESS_NAME[@]}"; do
+    pn=${PROCESS_NAME[$i]}
+    FINAL_PEAK_KB[$i]=$(cat "$BENCHMARK_TMPDIR/peak_mem_$pn.txt")
+    FINAL_PEAK_MB[$i]=$((FINAL_PEAK_KB[$i] / 1024))
+done
+
+echo ""
+echo "=== Benchmark Results ==="
+printf "Total time:                  %d.%02d seconds\n" \
+    $ELAPSED_SECS $ELAPSED_HSECS
+
+for i in "${!PROCESS_NAME[@]}"; do
+    printf "%-28s %s MB\n" \
+        "${PROCESS_NAME[$i]} peak memory:" "${FINAL_PEAK_MB[$i]}"
+done
+echo "========================="
+echo ""
+
+# Cleanup handled by trap on EXIT.
-- 
2.54.0

_______________________________________________
dev mailing list
[email protected]
https://mail.openvswitch.org/mailman/listinfo/ovs-dev

Reply via email to