Skip to content

Commit dec52d9

Browse files
l0wl3velclaude
andcommitted
feat: memory tuning knobs and memory tracing in integration tests
Every leaf and machine is a QEMU VM, and QEMU RSS behaves as a high-water mark: the guest touches all of its RAM through the page cache eventually, so a guest started with -m 4096 stays resident at ~4 GiB no matter how little it actually needs. virtio-balloon with free page reporting barely helps because the page cache keeps almost nothing on the free lists. Add three opt-in knobs, all defaulting to the previous behaviour: MINI_LAB_LEAF_MEMORY / MINI_LAB_MACHINE_MEMORY guest RAM, substituted into the topologies. All three launchers read QEMU_MEMORY (SONiC launch.py, machine launch.py and vrnetlab for the dell flavors). MINI_LAB_KSM host kernel samepage merging, dedupes the identical guest RAM of leaf01/leaf02 and of the machine VMs MINI_LAB_THP transparent hugepage policy KSM and THP are host global and are applied/reverted by scripts/memory-tuning.sh, which records the pristine values so the host is left as it was found. scripts/memory-profile.sh bundles the knobs into one-factor-at-a-time profiles (baseline, low-memory, ksm, thp-madvise, all) so each knob can be measured on its own. baseline pins KSM and THP explicitly instead of leaving them untouched, so a comparison is not skewed by the host state. scripts/memory-trace.py samples host and per-container memory plus QEMU RSS into a CSV; scripts/memory-report.py renders per-run summaries and a cross-run comparison. The integration test starts the tracer and stops it in an EXIT trap, so a profile that is too tight for a flavor still produces data instead of nothing. The integration workflow builds its matrix from flavors x profiles. Pull requests and pushes run baseline only so regular CI cost is unchanged; workflow_dispatch defaults to the full matrix. Runs are serialised because the host level numbers would otherwise be contaminated by concurrent labs. Each run uploads its trace, and a final job merges them into a per-flavor comparison table. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent bb3badd commit dec52d9

14 files changed

Lines changed: 1187 additions & 16 deletions

.github/workflows/integration.yaml

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,16 @@ on:
77
push:
88
branches:
99
- master
10+
workflow_dispatch:
11+
inputs:
12+
flavors:
13+
description: 'Comma separated flavors to test'
14+
default: 'sonic_vs,sonic_vpp,gardener,dell_sonic'
15+
memory_profiles:
16+
description: >-
17+
Comma separated memory profiles. Each profile is one integration run
18+
per flavor, so the full list multiplies the runtime accordingly.
19+
default: 'baseline,low-memory,ksm,thp-madvise,all'
1020

1121
env:
1222
REGISTRY: ghcr.io
@@ -136,22 +146,48 @@ jobs:
136146
${{ env.MINI_LAB_SONIC_IMAGE_SHA }}
137147
cache-from: type=registry,ref=${{ env.MINI_LAB_SONIC_IMAGE }}
138148
cache-to: type=inline
149+
prepare-matrix:
150+
name: Prepare test matrix
151+
runs-on: ubuntu-latest
152+
outputs:
153+
matrix: ${{ steps.build.outputs.matrix }}
154+
steps:
155+
- name: Build flavor x memory profile matrix
156+
id: build
157+
shell: bash
158+
env:
159+
# every event runs the full flavor x profile matrix, so each pull
160+
# request produces on/off data for all three memory knobs. Runs are
161+
# serialised on the self-hosted runner, so this is a long pipeline -
162+
# narrow it down via the workflow_dispatch inputs when iterating.
163+
FLAVORS: ${{ inputs.flavors || 'sonic_vs,sonic_vpp,gardener,dell_sonic' }}
164+
PROFILES: ${{ inputs.memory_profiles || 'baseline,low-memory,ksm,thp-madvise,all' }}
165+
run: |
166+
matrix=$(python3 -c '
167+
import json, os
168+
flavors = [f.strip() for f in os.environ["FLAVORS"].split(",") if f.strip()]
169+
profiles = [p.strip() for p in os.environ["PROFILES"].split(",") if p.strip()]
170+
include = [{"flavor": f, "profile": p} for f in flavors for p in profiles]
171+
print(json.dumps({"include": include}))
172+
')
173+
echo "matrix=${matrix}" >> "$GITHUB_OUTPUT"
174+
echo "${matrix}" | python3 -m json.tool
175+
139176
test:
140-
name: Run tests
177+
name: Run tests (${{ matrix.flavor }}, ${{ matrix.profile }})
141178
runs-on: self-hosted
142179
needs:
143180
- build-mini-lab-vms-image
144181
- build-mini-lab-sonic-vs-image
145182
- build-mini-lab-sonic-vpp-image
183+
- prepare-matrix
146184

147185
continue-on-error: true
148186
strategy:
149-
matrix:
150-
flavors:
151-
- name: sonic_vs
152-
- name: sonic_vpp
153-
- name: gardener
154-
- name: dell_sonic
187+
fail-fast: false
188+
# the memory numbers are host global, so runs must not overlap
189+
max-parallel: 1
190+
matrix: ${{ fromJson(needs.prepare-matrix.outputs.matrix) }}
155191

156192
steps:
157193
- name: Gain back workspace permissions # https://github.com/actions/checkout/issues/211
@@ -186,14 +222,83 @@ jobs:
186222
echo "MINI_LAB_VM_IMAGE=ghcr.io/metal-stack/mini-lab-vms:${IMAGE_TAG}" >> $GITHUB_ENV
187223
echo "MINI_LAB_SONIC_IMAGE_TAG=${IMAGE_TAG}" >> $GITHUB_ENV
188224
225+
- name: Select memory profile
226+
shell: bash
227+
run: ./scripts/memory-profile.sh "${{ matrix.profile }}" >> "$GITHUB_ENV"
228+
189229
- name: Run integration tests
190230
shell: bash
191231
run: |
192232
eval $(make dev-env)
193233
./test/ci-cleanup.sh
194234
./test/integration.sh
195235
env:
196-
MINI_LAB_FLAVOR: ${{ matrix.flavors.name }}
236+
MINI_LAB_FLAVOR: ${{ matrix.flavor }}
197237
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }}
198238
DOCKER_HUB_TOKEN: ${{ secrets.DOCKER_HUB_TOKEN }}
199239
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
240+
241+
- name: Restore host memory tuning
242+
if: always()
243+
shell: bash
244+
run: ./scripts/memory-tuning.sh restore || true
245+
246+
- name: Publish memory summary
247+
if: always()
248+
shell: bash
249+
run: |
250+
if [ -f memory-traces/summary.md ]; then
251+
cat memory-traces/summary.md >> "$GITHUB_STEP_SUMMARY"
252+
else
253+
echo "no memory summary produced for ${{ matrix.flavor }} / ${{ matrix.profile }}" \
254+
>> "$GITHUB_STEP_SUMMARY"
255+
fi
256+
257+
- name: Upload memory trace
258+
if: always()
259+
uses: actions/upload-artifact@v4
260+
with:
261+
name: memory-trace-${{ matrix.flavor }}-${{ matrix.profile }}
262+
path: memory-traces/
263+
if-no-files-found: warn
264+
retention-days: 30
265+
266+
memory-comparison:
267+
name: Compare memory profiles
268+
runs-on: ubuntu-latest
269+
needs: test
270+
if: always()
271+
272+
steps:
273+
- name: Checkout
274+
uses: actions/checkout@v4
275+
276+
- name: Download memory traces
277+
uses: actions/download-artifact@v4
278+
with:
279+
pattern: memory-trace-*
280+
path: memory-traces
281+
282+
- name: Compare profiles
283+
shell: bash
284+
run: |
285+
mkdir -p memory-traces
286+
# every test job runs with continue-on-error, so it is possible that no
287+
# run got far enough to produce a summary
288+
if ! find memory-traces -name summary.json | grep -q .; then
289+
echo "No memory summaries were produced by this run." \
290+
| tee memory-traces/comparison.md >> "$GITHUB_STEP_SUMMARY"
291+
exit 0
292+
fi
293+
./scripts/memory-report.py compare \
294+
--input-dir memory-traces \
295+
--out-md memory-traces/comparison.md
296+
cat memory-traces/comparison.md >> "$GITHUB_STEP_SUMMARY"
297+
298+
- name: Upload comparison
299+
uses: actions/upload-artifact@v4
300+
with:
301+
name: memory-comparison
302+
path: memory-traces/
303+
if-no-files-found: warn
304+
retention-days: 30

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ files/certs/*.pem
1717
files/certs/**/*.pem
1818
files/certs/**/*.crt
1919
.vscode
20-
vrnetlab
20+
vrnetlab
21+
memory-traces
22+
.memory-tuning.state

Makefile

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,17 @@ MINI_LAB_VM_IMAGE := $(or $(MINI_LAB_VM_IMAGE),ghcr.io/metal-stack/mini-lab-vms:
3131
MINI_LAB_DELL_SONIC_VERSION := $(or $(MINI_LAB_DELL_SONIC_VERSION),4.5.1)
3232
MINI_LAB_SONIC_IMAGE_TAG := $(or $(MINI_LAB_SONIC_IMAGE_TAG),latest)
3333

34+
# Memory tuning. All of these are opt-in: the defaults reproduce the behaviour
35+
# the lab had before the knobs existed. See docs/memory-tuning.md.
36+
# QEMU RAM handed to the guests, substituted into the topology files.
37+
MINI_LAB_LEAF_MEMORY := $(or $(MINI_LAB_LEAF_MEMORY),4096)
38+
MINI_LAB_MACHINE_MEMORY := $(or $(MINI_LAB_MACHINE_MEMORY),2048)
39+
# host-global knobs, empty means "do not touch"
40+
MINI_LAB_KSM := $(or $(MINI_LAB_KSM),)
41+
MINI_LAB_THP := $(or $(MINI_LAB_THP),)
42+
# where the memory tracer writes its samples
43+
MEMORY_TRACE_DIR := $(or $(MEMORY_TRACE_DIR),memory-traces)
44+
3445
MINI_LAB_INTERNAL_NETWORK=mini_lab_internal
3546
# define this here as well so that kind picks up the network on a clean checkout,
3647
# where .env does not exist yet at make parse time (-include .env above)
@@ -139,7 +150,7 @@ partition: partition-bake
139150
docker compose $(COMPOSE_ARGS) up --remove-orphans --force-recreate partition
140151

141152
.PHONY: partition-bake
142-
partition-bake: external_network
153+
partition-bake: external_network memory-tuning-apply
143154
docker pull $(MINI_LAB_VM_IMAGE)
144155
ifeq ($(CI),true)
145156
docker pull $(MINI_LAB_SONIC_IMAGE)
@@ -148,7 +159,7 @@ ifneq ($(filter $(MINI_LAB_FLAVOR),dell_sonic capms_dell_sonic),$(MINI_LAB_FLAVO
148159
docker pull $(MINI_LAB_SONIC_IMAGE)
149160
endif
150161
@if ! sudo $(CONTAINERLAB) --topo $(LAB_TOPOLOGY) inspect | grep -i leaf01 > /dev/null; then \
151-
sudo --preserve-env=MINI_LAB_SONIC_IMAGE --preserve-env=MINI_LAB_DELL_SONIC_VERSION --preserve-env=MINI_LAB_VM_IMAGE $(CONTAINERLAB) deploy --topo $(LAB_TOPOLOGY) --reconfigure; fi
162+
sudo --preserve-env=MINI_LAB_SONIC_IMAGE --preserve-env=MINI_LAB_DELL_SONIC_VERSION --preserve-env=MINI_LAB_VM_IMAGE --preserve-env=MINI_LAB_LEAF_MEMORY --preserve-env=MINI_LAB_MACHINE_MEMORY $(CONTAINERLAB) deploy --topo $(LAB_TOPOLOGY) --reconfigure; fi
152163

153164
.PHONY: verify-deployment-image
154165
verify-deployment-image:
@@ -186,6 +197,69 @@ files/certs/ca.pem:
186197
.PHONY: gen-certs # keep as a convenience alias
187198
gen-certs: files/certs/ca.pem
188199

200+
## MEMORY TUNING & TRACING ##
201+
202+
# print the environment of a named profile, e.g.
203+
# eval $(make memory-profile PROFILE=low-memory)
204+
.PHONY: memory-profile
205+
memory-profile:
206+
@./scripts/memory-profile.sh $(or $(PROFILE),$(MINI_LAB_MEMORY_PROFILE),baseline) --export
207+
208+
.PHONY: memory-tuning-apply
209+
memory-tuning-apply:
210+
@./scripts/memory-tuning.sh apply
211+
212+
.PHONY: memory-tuning-restore
213+
memory-tuning-restore:
214+
@./scripts/memory-tuning.sh restore
215+
216+
.PHONY: memory-tuning-show
217+
memory-tuning-show:
218+
@./scripts/memory-tuning.sh show
219+
220+
.PHONY: memory-trace-start
221+
memory-trace-start:
222+
@mkdir -p $(MEMORY_TRACE_DIR)
223+
@if [ -f $(MEMORY_TRACE_DIR)/tracer.pid ] && kill -0 $$(cat $(MEMORY_TRACE_DIR)/tracer.pid) 2> /dev/null; then \
224+
echo "memory tracer already running"; \
225+
else \
226+
nohup ./scripts/memory-trace.py sample \
227+
--out $(MEMORY_TRACE_DIR)/trace.csv \
228+
--meta $(MEMORY_TRACE_DIR)/meta.json \
229+
--interval $(or $(MEMORY_TRACE_INTERVAL),5) \
230+
--flavor $(MINI_LAB_FLAVOR) \
231+
--profile $(or $(MINI_LAB_MEMORY_PROFILE),unset) \
232+
> $(MEMORY_TRACE_DIR)/tracer.log 2>&1 & echo $$! > $(MEMORY_TRACE_DIR)/tracer.pid; \
233+
echo "memory tracer started, writing to $(MEMORY_TRACE_DIR)/trace.csv"; \
234+
fi
235+
236+
.PHONY: memory-trace-stop
237+
memory-trace-stop:
238+
@if [ -f $(MEMORY_TRACE_DIR)/tracer.pid ]; then \
239+
kill $$(cat $(MEMORY_TRACE_DIR)/tracer.pid) 2> /dev/null || true; \
240+
sleep 1; \
241+
rm -f $(MEMORY_TRACE_DIR)/tracer.pid; \
242+
echo "memory tracer stopped"; \
243+
else \
244+
echo "no memory tracer running"; \
245+
fi
246+
247+
.PHONY: memory-report
248+
memory-report:
249+
@./scripts/memory-report.py summarize \
250+
--trace $(MEMORY_TRACE_DIR)/trace.csv \
251+
--meta $(MEMORY_TRACE_DIR)/meta.json \
252+
--out-json $(MEMORY_TRACE_DIR)/summary.json \
253+
--out-md $(MEMORY_TRACE_DIR)/summary.md \
254+
--flavor $(MINI_LAB_FLAVOR) \
255+
--profile $(or $(MINI_LAB_MEMORY_PROFILE),unset)
256+
@cat $(MEMORY_TRACE_DIR)/summary.md
257+
258+
# compare all summaries below MEMORY_TRACE_DIR (or DIR=...)
259+
.PHONY: memory-compare
260+
memory-compare:
261+
@./scripts/memory-report.py compare --input-dir $(or $(DIR),$(MEMORY_TRACE_DIR))
262+
189263
.PHONY: cleanup
190264
cleanup: cleanup-control-plane cleanup-partition
191265
docker network rm --force mini_lab_internal
@@ -197,7 +271,7 @@ cleanup-control-plane:
197271
rm -f $(KUBECONFIG)
198272

199273
.PHONY: cleanup-partition
200-
cleanup-partition:
274+
cleanup-partition: memory-tuning-restore
201275
mkdir -p clab-mini-lab
202276
sudo --preserve-env $(CONTAINERLAB) destroy --topo mini-lab.dell_sonic.yaml
203277
sudo --preserve-env $(CONTAINERLAB) destroy --topo mini-lab.sonic.yaml

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,23 @@ export MINI_LAB_FLAVOR=sonic_vs
252252
make
253253
```
254254

255+
## Memory tuning
256+
257+
Every leaf and every machine is a QEMU VM, so a lab run is memory hungry. Three
258+
opt-in knobs are available to shrink the footprint — the guest RAM size, host
259+
side KSM and the transparent hugepage policy — together with a tracer that
260+
records the memory usage of a run:
261+
262+
```bash
263+
# start the lab with reduced guest RAM, KSM and THP=madvise
264+
eval "$(make memory-profile PROFILE=all)"
265+
make up
266+
```
267+
268+
The defaults are unchanged when none of the knobs is set. See
269+
[docs/memory-tuning.md](docs/memory-tuning.md) for the available profiles,
270+
the tradeoffs of each knob and how the CI matrix compares them.
271+
255272
## Network topology
256273

257274
An Nginx is running inside of the www container to allow automatic testing of outgoing connections.

0 commit comments

Comments
 (0)