Fixing painfully slow GNS3 Cloud throughput with one ethtool command

Table of Contents

The setup

I was building a lab in GNS3 to test a switch-provisioning system: a controller VM plus some switches, all wired to the outside world through Cloud nodes bound to a physical NIC on the GNS3 host.

Everything worked, until I had to copy a ~2.5 GB SONiC image onto the controller VM. The upload crawled. My first instinct was the same as everyone’s: “it’s the network virtualization overhead”. It wasn’t — or at least, not in the way I expected.

Measuring instead of guessing

Before touching anything, I measured throughput to different destinations with a trivial dd | ssh 'cat > file' test:

Path Throughput
my host → GNS3 host (bare metal, physical NIC) ~66 MB/s
my host → controller VM (behind a Cloud node) ~1.5 MB/s
controller VM → sentinel VM (VM-to-VM, same host) ~100 MB/s

That table is the whole story:

  • The physical host is fast.
  • VM-to-VM inside the topology is fast.
  • Only traffic entering a VM from outside through a Cloud node is slow.

So it is not the CPU emulation, not the disk, not the routing. Both fast and slow paths even share the same default gateway. The one thing unique to the slow path is the Cloud node bridging external frames into the VM.

Ruling out the usual suspects

The GNS3 community’s classic “slow performance” advice is about QEMU acceleration and NIC models, so I checked them all on the host:

# KVM acceleration present and used?
ls -l /dev/kvm                       # crw-rw---- ... /dev/kvm  ✔
ps -eo args | grep qemu-system | tr ' ' '\n' | grep enable-kvm   # -enable-kvm  ✔
systemd-detect-virt                  # none  (bare metal, so nested virt isn't a problem)  ✔

# Guest NIC model actually virtio (not e1000)?
ethtool -i ens3 | grep driver        # driver: virtio_net  ✔

KVM on, virtio on, no CPU throttling, bare-metal host. The VM was fully accelerated and still slow from outside. The problem had to be the Cloud/pcap path.

The actual cause: NIC offloading vs. ubridge

A GNS3 Cloud node bound to a physical interface doesn’t use kernel bridging. It uses ubridge, which captures frames off the host NIC with pcap and injects them into the VM’s tap interface.

Modern NICs enable offloading by default:

  • GRO (generic-receive-offload) and LRO (large-receive-offload) — the kernel coalesces many incoming TCP segments into one giant “super-frame” (up to 64 KB) before software sees it.
  • TSO/GSO (TCP/generic-segmentation-offload) — the reverse, on transmit.

Here is the trap. With GRO enabled, ubridge’s pcap capture receives those 64 KB coalesced frames and forwards them to the VM’s tap — where they blow straight past the 1500-byte MTU and get dropped or mangled. TCP reacts the only way it can: relentless retransmission and a collapsed congestion window. The result is the ~1.5 MB/s I was seeing.

Disable the offloads and every frame stays ≤ 1500 bytes end-to-end, so nothing is dropped on the way into the VM.

The fix

One command on the GNS3 host, against the physical interface your Cloud node is bound to (mine is enp1s0f0):

sudo ethtool -K enp1s0f0 gro off gso off tso off

The effect was immediate. I watched the live rsync progress meter jump the instant I ran it:

513,376,256   20%   1.58MB/s   0:20:32     <-- offloads on
...
1,877,671,936 74%  82.94MB/s   0:00:07     <-- offloads off

From ~1.5 MB/s to ~83 MB/s — a ~50× improvement — with no topology changes. The 2.5 GB image finished in seconds instead of the projected 40+ minutes.

Diagnosing it yourself

Check the offloads on the interface your Cloud is bridged to:

sudo ethtool -k enp1s0f0 | grep -E \
  'generic-receive-offload|large-receive-offload|tcp-segmentation|generic-segmentation'

If generic-receive-offload, tcp-segmentation-offload or generic-segmentation-offload show on, you are very likely hitting this. Find which physical interface a Cloud node uses via the GNS3 API:

PID=<project-id>
curl -s http://127.0.0.1:3080/v2/projects/$PID/nodes \
  | python3 -c 'import sys,json
for n in json.load(sys.stdin):
    if n["node_type"]=="cloud":
        for p in n["properties"].get("ports_mapping",[]):
            print(n["name"], "->", p.get("interface"))'

Making it persistent

ethtool -K does not survive a reboot or a NIC reset. To make it durable, drop in a tiny systemd unit on the GNS3 host:

# /etc/systemd/system/gns3-nic-offload-off.service
[Unit]
Description=Disable NIC offloading for GNS3 Cloud interfaces
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/sbin/ethtool -K enp1s0f0 gro off gso off tso off
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now gns3-nic-offload-off.service

If you bridge more than one physical interface into your topology, add an ExecStart line for each.

Takeaways

  • Measure the matrix, don’t guess. Comparing host vs. VM vs. VM-to-VM immediately localised the problem to “external → Cloud”, saving me from chasing QEMU, disks and routing.
  • “Network virtualization is just slow” is rarely the real answer. Here the emulated data path was perfectly capable of 100 MB/s VM-to-VM; the killer was a NIC feature interacting badly with pcap.
  • GRO/GSO/TSO + pcap bridging is a known footgun. Any time userspace pcap sits between a NIC and a virtual interface with a hard MTU — GNS3 Cloud nodes, some tcpdump-in-the-middle setups, certain bridge configs — offloading can silently destroy throughput.
  • One ethtool -K … gro off gso off tso off is cheap to try and easy to revert. Make it the first thing you reach for.

Happy labbing.