From c307d404fcf38db835fb66adc9aec5b454b2e511 Mon Sep 17 00:00:00 2001 From: jim-junior Date: Thu, 13 Aug 2026 13:09:49 +0300 Subject: [PATCH] feat: add memory benchmark implementation with cgroups metrics collection Signed-off-by: jim-junior --- experiment.yml | 8 + internal/cli/run.go | 4 + internal/runtime/memory/adapter.go | 225 +++++++++++++++++++++++++++++ internal/runtime/memory/utils.go | 155 ++++++++++++++++++++ 4 files changed, 392 insertions(+) create mode 100644 internal/runtime/memory/adapter.go create mode 100644 internal/runtime/memory/utils.go diff --git a/experiment.yml b/experiment.yml index 61034a9..23e3272 100644 --- a/experiment.yml +++ b/experiment.yml @@ -77,3 +77,11 @@ experiments: timeout: 30s metricsBrief: true runtime: urunc + + memory: + workloads: + default: + image: docker.io/library/nginx:alpine + other: + - image: harbor.nbfc.io/nubificus/urunc/nginx-qemu-unikraft-initrd:latest + runtime: urunc diff --git a/internal/cli/run.go b/internal/cli/run.go index f69d7bb..5b51e9e 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -19,6 +19,7 @@ import ( runtimeCPU "github.com/urunc-dev/evaluation_suite/internal/runtime/cpu" runtimeHTTPReadiness "github.com/urunc-dev/evaluation_suite/internal/runtime/httpreadiness" runtimeLifecycle "github.com/urunc-dev/evaluation_suite/internal/runtime/lifecycle" + runtimeMemory "github.com/urunc-dev/evaluation_suite/internal/runtime/memory" runtimeNetwork "github.com/urunc-dev/evaluation_suite/internal/runtime/network" runtimeStorage "github.com/urunc-dev/evaluation_suite/internal/runtime/storage" ) @@ -91,6 +92,9 @@ func NewRunCommand() *cobra.Command { func(trial plan.Trial) (harnessruntime.Adapter, error) { return runtimeCPU.NewAdapter(), nil }, + func(trial plan.Trial) (harnessruntime.Adapter, error) { + return runtimeMemory.NewAdapter(), nil + }, ) orch := orchestrator.New(adapterFactories...) diff --git a/internal/runtime/memory/adapter.go b/internal/runtime/memory/adapter.go new file mode 100644 index 0000000..07e1aa7 --- /dev/null +++ b/internal/runtime/memory/adapter.go @@ -0,0 +1,225 @@ +package memory + +import ( + "context" + "errors" + "fmt" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + harnessruntime "github.com/urunc-dev/evaluation_suite/internal/runtime" +) + +type commandRunner func(context.Context, ...string) ([]byte, error) + +// Adapter measures the container cgroup and its containerd shim using only +// nerdctl and Linux proc/cgroup files. +type Adapter struct { + run commandRunner + procRoot string + cgroupRoot string +} + +type CgroupMetrics struct { + CurrentBytes uint64 `json:"currentBytes"` + PeakBytes uint64 `json:"peakBytes"` + Stat map[string]uint64 `json:"stat"` +} + +type ShimMetrics struct { + PID int `json:"pid"` + PSSBytes uint64 `json:"pssBytes"` + USSBytes uint64 `json:"ussBytes"` + RSSBytes uint64 `json:"rssBytes"` +} + +type Metrics struct { + ContainerID string `json:"containerId"` + ContainerPID int `json:"containerPid"` + CgroupPath string `json:"cgroupPath"` + Cgroup CgroupMetrics `json:"cgroup"` + Shim ShimMetrics `json:"shim"` +} + +func NewAdapter() *Adapter { + return &Adapter{ + run: runNerdctl, + procRoot: "/proc", + cgroupRoot: "/sys/fs/cgroup", + } +} + +func (a *Adapter) ExperimentName() string { return "memory" } + +func (a *Adapter) Prepare(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + startedAt := time.Now() + err := pullImage(ctx, tc.Trial.Image) + result := stageResult(harnessruntime.StagePrepare, "Pull memory benchmark image", tc, startedAt, nil) + if err != nil { + return result, fmt.Errorf("pull image %s: %w", tc.Trial.Image, err) + } + return result, nil +} + +func (a *Adapter) CreateTask(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + startedAt := time.Now() + return stageResult( + harnessruntime.StageCreate, + "Create memory benchmark container", tc, startedAt, nil, + ), nil +} + +func (a *Adapter) StartTask(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + startedAt := time.Now() + + args := []string{"run", "-it", "--name", tc.Trial.ID, "--runtime", tc.Trial.RuntimeHandler, tc.Trial.Image} + + cmd := exec.CommandContext(ctx, "nerdctl", args...) + cmd.Stdin = os.Stdin + + // Detach from the parent's terminal/session. + cmd.SysProcAttr = &syscall.SysProcAttr{ + Setsid: true, + } + + if err := cmd.Start(); err != nil { + _ = a.cleanupbenchmarkresources(ctx, tc) + return stageResult(harnessruntime.StageStart, "Start memory benchmark container", tc, startedAt, nil), fmt.Errorf("start container: %w", err) + } + + go func() { _ = cmd.Wait() }() + + return stageResult(harnessruntime.StageStart, "Start memory benchmark container", tc, startedAt, nil), nil + +} + +func (a *Adapter) WaitReady(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + startedAt := time.Now() + + time.Sleep(2 * time.Second) + + metrics, err := a.collect(ctx, tc.Trial.ID) + result := stageResult(harnessruntime.StageWaitReady, "Collect memory metrics", tc, startedAt, metrics) + if err != nil { + _ = a.cleanupbenchmarkresources(ctx, tc) + return result, fmt.Errorf("collect memory metrics: %w", err) + } + + return result, nil +} + +func (a *Adapter) Stop(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + return a.commandStage(ctx, harnessruntime.StageStop, "Stop memory benchmark container", tc, "stop", tc.Trial.ID) +} + +func (a *Adapter) DeleteTask(ctx context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + return a.commandStage(ctx, harnessruntime.StageDelete, "Remove memory benchmark container", tc, "rm", "--force", tc.Trial.ID) +} + +func (a *Adapter) Cleanup(_ context.Context, tc harnessruntime.TrialContext) (harnessruntime.StageResult, error) { + return stageResult(harnessruntime.StageCleanup, "Cleanup memory benchmark container", tc, time.Now(), nil), nil +} + +func (a *Adapter) cleanupbenchmarkresources(ctx context.Context, tc harnessruntime.TrialContext) error { + _, err := a.run(ctx, "rm", "--force", tc.Trial.ID) + return err +} + +func (a *Adapter) commandStage( + ctx context.Context, + stage harnessruntime.Stage, + description string, + tc harnessruntime.TrialContext, + args ...string, +) (harnessruntime.StageResult, error) { + startedAt := time.Now() + output, err := a.run(ctx, args...) + result := stageResult(stage, description, tc, startedAt, map[string]any{ + "output": strings.TrimSpace(string(output)), + }) + if err != nil { + return result, fmt.Errorf("nerdctl %s: %w", strings.Join(args, " "), err) + } + return result, nil +} + +func (a *Adapter) collect(ctx context.Context, name string) (Metrics, error) { + pidText, err := a.run(ctx, "inspect", "--format", "{{.State.Pid}}", name) + if err != nil { + return Metrics{}, fmt.Errorf("inspect container PID: %w", err) + } + containerPID, err := strconv.Atoi(strings.TrimSpace(string(pidText))) + if err != nil || containerPID <= 0 { + return Metrics{}, fmt.Errorf("invalid container PID %q", strings.TrimSpace(string(pidText))) + } + + idText, err := a.run(ctx, "inspect", "--format", "{{.Id}}", name) + if err != nil { + return Metrics{}, fmt.Errorf("inspect container ID: %w", err) + } + containerID := strings.TrimSpace(string(idText)) + if containerID == "" { + return Metrics{}, errors.New("nerdctl returned an empty container ID") + } + + cgroupPath, err := readCgroupPath(filepath.Join(a.procRoot, strconv.Itoa(containerPID), "cgroup")) + if err != nil { + return Metrics{}, err + } + cgroupDir := filepath.Join(a.cgroupRoot, strings.TrimPrefix(cgroupPath, "/")) + cgroup, err := readCgroupMetrics(cgroupDir) + if err != nil { + return Metrics{}, err + } + + shimPID, err := findShimPID(a.procRoot, containerID) + if err != nil { + return Metrics{}, err + } + shim, err := readShimMetrics(filepath.Join(a.procRoot, strconv.Itoa(shimPID), "smaps_rollup"), shimPID) + if err != nil { + fmt.Println(err) + return Metrics{}, err + } + + return Metrics{ + ContainerID: containerID, + ContainerPID: containerPID, + CgroupPath: cgroupDir, + Cgroup: cgroup, + Shim: shim, + }, nil +} + +func runNerdctl(ctx context.Context, args ...string) ([]byte, error) { + cmd := exec.CommandContext(ctx, "nerdctl", args...) + cmd.Stdin = os.Stdin + output, err := cmd.CombinedOutput() + if err != nil { + return output, fmt.Errorf("%w: %s", err, strings.TrimSpace(string(output))) + } + return output, nil +} + +func stageResult( + stage harnessruntime.Stage, + description string, + tc harnessruntime.TrialContext, + startedAt time.Time, + data any, +) harnessruntime.StageResult { + finishedAt := time.Now() + return harnessruntime.StageResult{ + Stage: stage, + StartedAt: startedAt, + FinishedAt: finishedAt, + Duration: finishedAt.Sub(startedAt), + Description: fmt.Sprintf("%s: trial=%s runtime=%s handler=%s image=%s", description, tc.Trial.ID, tc.Trial.RuntimeName, tc.Trial.RuntimeHandler, tc.Trial.Image), + Data: data, + } +} diff --git a/internal/runtime/memory/utils.go b/internal/runtime/memory/utils.go new file mode 100644 index 0000000..75dbf10 --- /dev/null +++ b/internal/runtime/memory/utils.go @@ -0,0 +1,155 @@ +package memory + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" +) + +func readCgroupPath(path string) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read container cgroup: %w", err) + } + for _, line := range strings.Split(string(data), "\n") { + parts := strings.SplitN(line, ":", 3) + if len(parts) == 3 && parts[0] == "0" { + return parts[2], nil + } + } + return "", errors.New("cgroup v2 entry not found") +} + +func readCgroupMetrics(dir string) (CgroupMetrics, error) { + current, err := readUintFile(filepath.Join(dir, "memory.current")) + if err != nil { + return CgroupMetrics{}, err + } + peak, err := readUintFile(filepath.Join(dir, "memory.peak")) + if err != nil { + return CgroupMetrics{}, err + } + stat, err := readKeyValueFile(filepath.Join(dir, "memory.stat")) + if err != nil { + return CgroupMetrics{}, err + } + return CgroupMetrics{CurrentBytes: current, PeakBytes: peak, Stat: stat}, nil +} + +func readUintFile(path string) (uint64, error) { + data, err := os.ReadFile(path) + if err != nil { + return 0, fmt.Errorf("read %s: %w", path, err) + } + value, err := strconv.ParseUint(strings.TrimSpace(string(data)), 10, 64) + if err != nil { + return 0, fmt.Errorf("parse %s: %w", path, err) + } + return value, nil +} + +func readKeyValueFile(path string) (map[string]uint64, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open %s: %w", path, err) + } + defer file.Close() + + values := make(map[string]uint64) + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + fields := strings.Fields(scanner.Text()) + + if len(fields) < 2 { + continue + } + + // smaps_rollup starts with a VMA header such as: + // c000000000-7ffc4b9a9000 ---p 00000000 00:00 0 [rollup] + if !strings.HasSuffix(fields[0], ":") { + continue + } + + value, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + return nil, fmt.Errorf( + "parse %s value %q for key %q: %w", + path, + fields[1], + fields[0], + err, + ) + } + + key := strings.TrimSuffix(fields[0], ":") + values[key] = value + } + + if err := scanner.Err(); err != nil { + return nil, fmt.Errorf("scan %s: %w", path, err) + } + + return values, nil +} + +func findShimPID(procRoot, containerID string) (int, error) { + entries, err := os.ReadDir(procRoot) + if err != nil { + return 0, fmt.Errorf("read proc: %w", err) + } + + var pids []int + for _, entry := range entries { + pid, err := strconv.Atoi(entry.Name()) + if err == nil { + pids = append(pids, pid) + } + } + sort.Ints(pids) + + for _, pid := range pids { + data, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "cmdline")) + if err != nil { + continue // Processes can exit while /proc is being scanned. + } + args := strings.Split(strings.TrimRight(string(data), "\x00"), "\x00") + if len(args) == 0 || !strings.Contains(filepath.Base(args[0]), "containerd-shim") { + continue + } + for _, arg := range args[1:] { + if arg == containerID { + return pid, nil + } + } + } + + return 0, fmt.Errorf("containerd shim for container %s not found", containerID) +} + +func readShimMetrics(path string, pid int) (ShimMetrics, error) { + values, err := readKeyValueFile(path) + if err != nil { + return ShimMetrics{}, err + } + return ShimMetrics{ + PID: pid, + PSSBytes: values["Pss"] * 1024, + USSBytes: (values["Private_Clean"] + values["Private_Dirty"]) * 1024, + RSSBytes: values["Rss"] * 1024, + }, nil +} + +func pullImage(ctx context.Context, image string) error { + _, err := runNerdctl(ctx, "pull", image) + if err != nil { + return fmt.Errorf("pull image %s: %w", image, err) + } + return nil +}