Skip to main content

Memory & Parallelism

When a test, a source generator, or a compile runs out of memory — you see Java heap space, an OutOfMemoryError, or a process killed by SIGKILL (exit 137) — this page is what the build summary links you to. It explains every knob that governs how much memory a build uses and how many things run at once, and how they interact.

The short version

Two questions, and they belong to different owners:

questionwho decideswhere
how much memory does this code need?the buildplatform.jvmOptions in bleep.yaml — per project, and it sizes forks
how much of this machine may bleep use?whoever owns the machineuser config: parallelism, compileServerMaxMemory

That split is deliberate. A repo cannot know whether it is running on a 16 GB CI container or a 64 GB workstation, and a machine cannot know which of your suites is heavy.

A forked JVM (a test runner, a sourcegen script, a KSP processor) then has two costs, and they are controlled separately:

  1. How big each fork is — its -Xmx heap ceiling. If a suite genuinely needs more memory than it is given, it fails with an in-JVM OutOfMemoryError naming the suite. Fix: give that project more heap.
  2. How many forks run at once — the parallelism. If too many run together, the machine runs out of real memory and the OS kills processes with SIGKILL. Fix: lower parallelism, or lower per-fork heap so more fit.

bleep also measures what forks actually cost and sizes concurrency against what the machine can currently spare, so most of the time you never touch any of this. You reach for these knobs when a specific suite is heavier than the default, or when bleep is sharing the machine with something it can't see.

Version

The machine-wide accounting described on this page arrived after 1.0.0-M10. On M10 and earlier, concurrency is a plain count of permits with no memory dimension — so on a small machine you must set parallelism yourself, because nothing will do it for you.

Per-fork heap

Every forked JVM is given a default heap of 2 GB. A fork that states no -Xmx is not unlimited — the JVM would otherwise take a quarter of the machine, and bleep starts one per core, so a default is essential.

The build sizes forks, and only forks

bleep.yaml has exactly one resource control, and it is per project:

projects:
my-heavy-tests:
isTestProject: true
platform:
jvmOptions: -Xmx4g

This is the right and only place for a build to say "this code needs more memory", because that is a property of the code, not of the machine it happens to run on. Everything else about resources — how many things run at once, how big the compile server is — belongs to whoever owns the machine, and lives in user config.

It applies to forked processes. platform.jvmOptions reaches:

  • forked test JVMs for that project, and
  • bleep run, unless jvmRuntimeOptions is also set, in which case that wins for run only.

It does not affect compilation. Your code is compiled inside the long-lived compile server, in its heap — not in a JVM started for your project. So raising jvmOptions will not help a compile that runs out of memory, and lowering it will not make compilation lighter. The compile server's own heap is compileServerMaxMemory, in user config.

If that distinction bites, the symptom tells you which side you are on: an OutOfMemoryError naming one of your test suites is the fork, and jvmOptions is the fix; an OutOfMemoryError in the build summary with the server dying is the server, and compileServerMaxMemory is the fix.

Precedence for a test fork

A test fork is started with exactly one -Xmx, chosen from the first of these that states one:

  1. --jvm-opt on the command line — this run only
  2. the project's platform.jvmOptions — what this code needs, checked into the build
  3. testRunnerHeap from user config — the default for projects that state nothing
  4. bleep's own default, 2 GB

testRunnerHeap is a default, not a ceiling. A project that sets -Xmx in platform.jvmOptions overrides it, in either direction — a project may ask for more heap than the machine-level setting, and it gets it:

# user config says testRunnerHeap: 1g …
projects:
my-heavy-tests:
isTestProject: true
platform:
jvmOptions: -Xmx4g # … and this suite still forks with 4 GB

That is the right way round: how much heap a suite needs is a property of the code, and the code is what lives in the repo. Nothing silently shrinks a heap the build asked for. What protects the machine from one project asking for a lot is admission — bleep charges a fork its whole footprint and will run an oversized one on its own rather than alongside others — not clamping.

To see what a fork was actually given, bleep server config show prints the configured default, and the test view reports each fork's heap. If you want to check from inside a suite, Runtime.getRuntime.maxMemory is the number that settles it. Do not test this by setting an absurdly small -Xmx and expecting a crash: the JVM quietly rounds a too-small heap up to something it can start with, so -Xmx3m proves nothing.

Renamed

testRunnerHeap was called testRunnerMaxMemory, and bleep server config test-runner-max-memory is now bleep server config test-runner-heap. The old name never was a maximum — a project's own -Xmx has always outranked it. Existing config files and the old command keep working.

The other forked processes

Sourcegen scripts and KSP runners are sized in user config, since they are bleep's own tooling rather than your code:

SettingSizes
testRunnerHeaptest runner JVMs, for projects that state no -Xmx of their own
sourcegenMaxMemorysourcegen scripts
kspRunnerMaxMemoryKSP (Kotlin symbol processing) runners
compileServerMaxMemorythe compile server itself — not a fork; the long-lived heap your compiles run inside

How many run at once

One knob: parallelism — how many operations bleep may run at once on this machine. It defaults to one per core and covers everything the build scheduler runs: compiles, links, test suites, sourcegen scripts, annotation-processor and KSP resolution. It also sizes the pool of forked test JVMs.

It is a property of the machine, not of a run: the compile server is shared between every client and every workspace on it, so this is the total across all of them, not a limit each one gets separately.

bleep server config parallelism 2
bleep server config parallelism-clear # back to one per core

parallelismRatio expresses the same thing as a fraction of cores (0.5 for half) if you prefer that in a config file shared across machines of different sizes.

Lowering parallelism is the direct fix when the machine is being overwhelmed — many forks killed by SIGKILL at once, rather than one suite failing with a clean OutOfMemoryError. Fewer forks run together, so peak memory drops.

Setting it for a CI job

There is deliberately no environment variable for this, and no per-invocation flag. The compile server is shared: it reads parallelism once at startup and passes it to every fork it spawns, so a per-client override would mean whichever client happened to start the daemon silently configured it for everyone else — including runs that had no opinion. The same goes for the server's own heap, which is part of the key that decides which daemon you get: two different values would quietly give you two daemons.

A CI job is one machine, so configure the machine, as a step before you build:

- name: Configure bleep for this runner
run: |
bleep server config parallelism 2
bleep server config max-memory 4g

- name: Test
run: bleep test

That writes the same user config a developer would keep, which is what a fresh runner wants: it has no standing preferences, so state them once and let every later invocation agree.

Small machines and CI

The compile server's heap and the test forks' heaps come out of one pot, and a quarter of RAM is held back for everything that isn't bleep. It is worth doing the arithmetic once, because the failure mode is forks being killed at startup rather than anything that names memory.

For a fork to be admitted, its heap must fit in:

budget = RAM − (server heap × 1.25) − max(4 GB, RAM/4)

On a typical 16 GB CI runner, that quarter-of-RAM reserve and the server's own footprint dominate:

server heapreservefork budgetwhat fits
8 GB4 GB2 GBone small fork, serialised
4 GB4 GB7 GBone 6 GB fork, or two 3 GB forks
2 GB4 GB9.5 GBthree 3 GB forks

The trap is asking for a big compile server and big forks on a small box: an 8 GB server on 16 GB leaves 2 GB for everything else, so every fork queues behind every other fork and the job takes far longer than the numbers suggest. On a 16 GB runner, a 4 GB server is usually the better trade.

Two rules of thumb for CI:

  1. Size the server first. It is long-lived and shared; the forks divide what's left.
  2. Set parallelism explicitly. A CI runner's core count is not a good guide to its memory, and the default is one fork per core.

What decides when something starts

You never configure this, but it explains why bleep sometimes runs fewer things than the raw numbers suggest — and why it never runs more.

There is one place that decides. The build scheduler holds every task whose dependencies are met, sorts them so the ones unblocking the most other work come first, and asks the machine whether each fits right now. Anything that fits starts; anything that doesn't stays queued and is asked again the moment a running task finishes, which is exactly when resources come back. So the order you'd want is the order you get, even when the machine is full.

"Fits" is two questions:

  • A slot. parallelism is how many things may run at once, so this is simply whether one is free.
  • Room for a fork. Only work that starts a process asks this: test runners, sourcegen scripts, KSP, and the linkers for Scala.js, Scala Native, Kotlin/JS and Kotlin/Native. Compiles run inside the server and take a slot but no fork memory.

The fork-memory budget is not a fixed number. The server continuously measures how much of the machine's non-reclaimable memory is held by other processes — your IDE, a browser, another build — and sizes the budget as what's left, minus a safety margin. Open something memory-hungry mid-build and bleep stops admitting new forks until there is room, rather than pushing the machine into swap; close it and bleep uses more.

A fork is also charged its measured cost rather than its -Xmx ceiling: a 2 GB-ceiling fork that only ever uses 600 MB is counted as 600 MB, so many more fit than the ceilings would suggest.

How the knobs trade off

They are not independent — think of it as peak memory ≈ per-fork heap × forks running at once, bounded by what the machine can spare.

  • Raising per-fork heap lets a heavy suite pass, but each fork now costs more, so fewer fit at a given budget — effective parallelism drops.
  • Raising parallelism finishes the build faster, but only if each fork is small enough that they collectively fit. Past that point you get SIGKILLs instead of speed.
  • Lowering per-fork heap lets more run in parallel, but a suite that needed the headroom will OutOfMemoryError.

The practical recipe:

  1. A single suite fails with Java heap space / OutOfMemoryError. That suite needs more heap. Give that project a larger platform.jvmOptions: -Xmx…. Don't lower parallelism — the rest of the build is fine.
  2. Many forks are killed by SIGKILL (exit 137) at once, especially late in a big run. The machine is over-subscribed. Lower parallelism (or parallelismRatio), or lower a too-generous per-fork heap so more fit.
  3. A fork is killed before it even starts (terminated before sending Ready — killed by SIGKILL). Same cause as (2): the machine had no room to back a new JVM. Same fixes.

Reading the diagnostic

bleep distinguishes who did the killing, because it matters for the fix:

  • Java heap space / OutOfMemoryError — the fork hit its own -Xmx ceiling. Raise that project's heap (recipe 1).
  • killed by SIGKILL (exit 137), not by bleep — the OS killed it, almost always memory pressure. Reduce concurrent memory (recipe 2/3). Check your system log for a memory-pressure kill to confirm.
  • terminated by bleep (…reason…) — bleep itself killed it, and the reason is stated (a suite timeout, a cancellation, pool cleanup). Not a memory problem; read the reason.