magus v0.4.2 is out. See what's new
¶ View markdown source · ✎ Suggest an edit
5 min read

Profiling a magusfile

Magusfiles are Buzz, and Buzz runs on a VM whose object heap never shrinks. Every string, list, map and object a program allocates is appended to one table and pinned for the life of the process. That is a deliberate trade for short scripts, and it has one sharp edge: a magusfile can exhaust a machine without any single value being large, and without a Go heap profile pointing anywhere useful.

This page is about finding that line.

The symptom

A target dies with no failing command. In CI the job simply stops, often with

The runner has received a shutdown signal.

Nothing failed. The machine ran out of memory and took magus with it, so magus never reached the point where it prints a summary.

What magus tells you

magus watches host memory for the life of an invocation and streams a warning the moment headroom collapses. Streamed, not summarized, because a killed process never gets to print a summary - only what already reached the log survives.

[warn] memory headroom low: 265MB available of 15989MB total; a target here is
close to taking the machine down; running: .:test; buzz heap: 8402931 objects
(peak 8402931), most of it from coverage.buzz:88

Three facts, in the order you need them:

  • the machine is nearly out - available against total
  • what was running - the project and target, read from the registry that survives a SIGKILL
  • whether it was Buzz, and where - the heap object count and the source position responsible for most of its growth

That last clause is the one that separates "a subprocess ate the memory" from "our own script did", which is otherwise a day of bisecting.

The warning is silent on a healthy run. It fires below an eighth of total memory and then only on each further 250MB drop, so a tight build costs a handful of lines rather than one every two seconds.

Reading the heap figure

Objects, not bytes. The pathological shape is millions of small strings, which a byte reading makes look unremarkable. What diagnoses the problem is the shape of the growth: an object count that climbs with the size of the input is quadratic in memory whatever each object weighs.

The source position is the growing loop, not always the exact allocating statement. magus samples the interpreter rather than instrumenting every allocation, and in a tight loop the loop control runs as often as its body, so either line may be reported. Both put you on the same few lines.

Profiling interactively

Set a breakpoint and ask:

import "magus";

export fun report(ctx: magus\Context, args: [str]) > void {
    final data = build();
    magus\pry();
}

Run the target, then at the prompt:

pry> .heap
heap: 30660 objects live, 30660 peak this run
  growth by source position, largest first:
    coverage.buzz:88                         30352
  a position that climbs with input size is quadratic in memory;
  build into a list and join once rather than reassigning a string.

.heap sits alongside .where, .locals and .globals - see Debugging for the rest of the pry surface. It answers the one question a paused stack cannot: where you are says nothing about what filled memory getting there.

The peak is rebased per invocation, so under magus server a long-lived daemon reports what the current run did rather than the worst of everything it has ever served.

The pattern that costs gigabytes

This is the one worth recognizing on sight:

var kept = "";
foreach (line in profile.split("\n")) {
    kept = kept + line + "\n";
}

Every + builds a whole new string, and every intermediate is pinned. Over a 30,803-line file that measured a 13.1GB peak to produce 2.1MB of output, which is enough to kill a 16GB CI runner.

Collect and join once:

var kept = mut [<str>];
foreach (line in profile.split("\n")) {
    kept.append(line);
}
final out = kept.join("\n");

Same output, 29.5MB peak. A list appends a reference per line; the intermediates never exist.

The same shape appears whenever a value is rebuilt inside a loop:

Instead of Write
s = s + x in a loop append to a list, join once
while (s.indexOf(x) != null) { s = s.replace(x, y) } s.split(x).join(y)
counting with replace in a loop s.split(x).len() - 1

Note str.replace substitutes only the FIRST occurrence, which is why the rescanning loop gets written in the first place.

Scale is what makes it fatal

None of these patterns is wrong in itself. Building a ten-row table with + is fine and always will be. The cost is the pattern multiplied by the input, so the same three lines are harmless over a config file and fatal over a coverage profile.

That is also why magus does not lint for it. A source scan for x = x + … across this repository flags several hundred sites, nearly all of them loop counters and small string building. No static check can see the input size, so magus measures instead and speaks only when the measurement says something.

Declaring what a target needs

A target that legitimately needs a lot of memory can say so, and magus will keep peers off the machine while it runs:

magus\project({
    "targets": {
        "test": {"memory_mb": 10240},
    },
});

magus divides that by the host's memory-per-slot share and holds that many concurrency slots, so the declaration throttles on a 16GB runner and barely registers on a 64GB workstation without naming either machine. See configuration for the rest of the target policy.

This bounds what runs alongside the target. It cannot shrink a single target that alone exceeds the machine - for that, size the work itself:

final procs = platform\memoryBytes() / (8 * 1024 * 1024 * 1024);

platform\memoryBytes() reports 0 when the host cannot be measured, so branch on it rather than treating it as "no memory".

profilingmemoryperformanceheapprybuzzmagusfiletroubleshootingdiagnosticsci
Last updated (39e02449)
Earlier changes on this page (1)

Full history ↗ · Blame source ↗

Glossary

Project

A directory magus recognizes as a unit of work (it has a magusfile); the unit of caching, scheduling, and dependency tracking. See workspace.

Magusfile

The magusfile.buzz that declares a project's targets (as export funs) and binds its spells. See targets.

Target

A named operation (build, test, ...) you invoke with magus run <target>; it may compose a spell's tool-native operations and depend on other targets. See targets.

Op

A single tool-native command a target composes (long form: operation); the middle of the work hierarchy (Spell to Op to Target). See operations.

Buzz

The language magusfiles are written in (the .buzz engine). See engines.

Daemon

The background magus host that owns shared state such as services and the warm knowledge graph. See daemon.

CI

An ordinary magusfile-defined target you compose yourself with magus\needs - magus does not hardcode its stages. Magus.RunCI treats it specially only in that it strips the rw charm, it is the anchor magus affected ci keys off, and a selected scope with no project declaring it is a load error rather than a silent no-op. See targets.

Slot

One unit of the pool's capacity. A target acquires the slots it needs to run (most take one) and releases them when it finishes; the pool tracks capacity (total slots), running (acquired), and queued (blocked). See daemon.

Concurrency

How many targets run at once. It is bounded by the pool's capacity and set with --concurrency, MAGUS_CONCURRENCY, or the concurrency config key. See daemon.

Health

The at-a-glance daemon state derived from the pool: healthy when the pool is reporting, degraded when it reports an error, down when there is no pool. The dashboard color-codes each state. See daemon.

Conventions

This page uses none of the site's convention markers. The full set is on the conventions page.