Skip to content

perf: occasional malloc_trim to bound v1 worker RSS - #1621

Merged
mikasenghaas merged 2 commits into
feat/v1-multimodalfrom
fix/v1-malloc-trim
Jun 11, 2026
Merged

perf: occasional malloc_trim to bound v1 worker RSS#1621
mikasenghaas merged 2 commits into
feat/v1-multimodalfrom
fix/v1-malloc-trim

Conversation

@mikasenghaas

@mikasenghaas mikasenghaas commented Jun 11, 2026

Copy link
Copy Markdown
Member

Summary

  • A v1 rollout parses large base64 request bodies (e.g. screenshots) per turn via the interception server and frees them, but glibc keeps the freed arenas — so a long-lived eval / env-server worker's resting RSS climbs and never drops back.
  • Add a best-effort trim_memory() (malloc_trim(0) via ctypes) in verifiers/v1/utils.py, resolved once and cached; a no-op off glibc (musl, macOS).
  • Call it occasionally — once every _TRIM_EVERY_ROLLOUTS (16) finished rollouts, gated in Episode.run — so freed arenas go back to the OS without putting the arena walk on the hot path. Both the eval runner and the env-server training worker drive rollouts through Episode.run, so both are covered.
  • Run the trim off the event loop: malloc_trim(0) walks every arena's free lists and can block for tens of ms on exactly the large/fragmented heaps this targets. Inline that would stall the whole event loop — and under the multiplexed env server, every concurrent rollout with it. trim_memory_periodically() offloads via asyncio.to_thread; ctypes releases the GIL during the call, so the heap walk runs concurrently with the loop.

Note

Low Risk
Operational memory tuning only; best-effort no-op on non-glibc platforms and runs off the hot path in a background thread.

Overview
Adds periodic glibc heap trimming so long-lived v1 workers (eval runner and env-server) don’t keep climbing resting RSS after rollouts free large per-turn bodies (e.g. base64 screenshots) that glibc still holds in arenas.

New helpers in verifiers/v1/utils.py: trim_memory() resolves and caches malloc_trim(0) via ctypes (no-op off glibc), and trim_memory_periodically() runs it every 16 finished rollouts on a worker thread via asyncio.to_thread so the arena walk doesn’t block the event loop.

Episode.run awaits trim_memory_periodically() after each rollout completes, so every path that drives rollouts through episodes gets the behavior without per-caller wiring.

Reviewed by Cursor Bugbot for commit 8700a46. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Call malloc_trim periodically in v1 workers to bound RSS growth

  • Adds trim_memory_periodically() in utils.py that calls glibc's malloc_trim(0) every 16 rollouts via asyncio.to_thread, returning freed per-turn request body memory to the OS.
  • On non-glibc platforms, trim_memory resolves to a no-op after the first failed ctypes lookup.
  • episode.py awaits trim_memory_periodically() after each rollout completes.
  • Risk: introduces a worker-thread dispatch every 16 rollouts; negligible on glibc, no-op elsewhere.

Macroscope summarized 8700a46.

mikasenghaas and others added 2 commits June 11, 2026 02:38
A rollout parses large base64 request bodies (e.g. screenshots) per turn and
frees them, but glibc retains the freed arenas, so a long-lived eval / env-server
worker's resting RSS climbs and never drops. Call malloc_trim(0) once every Nth
finished rollout (gated in Episode.run) to hand those arenas back to the OS.
Best-effort and resolved once: a no-op off glibc (musl, macOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
malloc_trim(0) walks every arena's free lists and can block for tens of ms on a
large heap. Called inline it stalls the whole event loop — and under the
multiplexed env server, every concurrent rollout with it. Offload to a worker
thread via asyncio.to_thread; ctypes releases the GIL during the call, so the
heap walk runs concurrently with the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mikasenghaas
mikasenghaas marked this pull request as ready for review June 11, 2026 02:41
@macroscopeapp

macroscopeapp Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Approved

This PR adds a memory optimization that periodically calls glibc's malloc_trim to reduce worker RSS. The change is purely operational (no functional behavior changes), defensive (no-op on non-glibc systems), and well-isolated. The author is the primary maintainer of this v1 module.

You can customize Macroscope's approvability policy. Learn more.

@mikasenghaas
mikasenghaas merged commit b7f482e into feat/v1-multimodal Jun 11, 2026
5 checks passed
mikasenghaas added a commit that referenced this pull request Jun 11, 2026
…-v1 (#1618)

* feat(v1): carry multimodal images through the message graph

Make a VLM trainable through the v1 message-graph trace, in two layers.

Ingress: a content-part union (TextContentPart / ImageUrlContentPart,
MessageContent) lets user/system messages hold images, and Task.instruction
becomes `str | Messages` so a taskset can seed an image-bearing initial prompt
(the default harness opts in via SUPPORTS_MESSAGE_INSTRUCTION; others reject a
Messages instruction). The interception server and the v0 legacy bridge preserve
image parts (shared `content_to_parts`) instead of flattening them to text, and
`message_hash` hashes list content stably.

Egress: TurnTokens / MessageNode carry the renderer's MultiModalData as a
transient, serialization-excluded sidecar (offsets stored node-local); add_turn
attributes each image to the node that introduced it; Branch.multi_modal_data
merges the nodes' items and rebases offsets to branch-global. The pixel tensors
never reach the wire or results.jsonl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(v1): colocate the user simulator in the agent runtime

By default the user simulator now runs inside the agent's (harness's) already-
started runtime — reusing it via serve_tools(colocated=True, host_reachable=True)
with its port published back to the host — instead of spawning a separate runtime
per rollout. This removes the per-rollout runtime start/stop churn (and the
startup races it caused) for multi-turn tasksets. UserConfig gains `colocated`
(default True); set it False to give the user its own runtime, e.g. a remote
sandbox that can't publish the colocated port back to the host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(v1): add the color-codeword-v1 taskset

A multi-turn VLM decoding task (the v1 port of the v0 `color-codeword` env): each
turn shows colored squares mapping to letters; the model accumulates the codeword
and outputs it in full on the final turn. Turn-0 squares ride in the task's
`Messages` instruction; later turns are injected by a colocated `vf.User`. Reward
is an exact match of the final codeword, with a partial-match metric. Exercises
multimodal images end-to-end through the v1 message graph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf: occasional malloc_trim to bound v1 worker RSS (#1621)

* perf(v1): occasional malloc_trim to bound worker RSS

A rollout parses large base64 request bodies (e.g. screenshots) per turn and
frees them, but glibc retains the freed arenas, so a long-lived eval / env-server
worker's resting RSS climbs and never drops. Call malloc_trim(0) once every Nth
finished rollout (gated in Episode.run) to hand those arenas back to the OS.
Best-effort and resolved once: a no-op off glibc (musl, macOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(v1): run malloc_trim off the event loop

malloc_trim(0) walks every arena's free lists and can block for tens of ms on a
large heap. Called inline it stalls the whole event loop — and under the
multiplexed env server, every concurrent rollout with it. Offload to a worker
thread via asyncio.to_thread; ctypes releases the GIL during the call, so the
heap walk runs concurrently with the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(v1): review-pass cleanups for multimodal support

- harness: express the capability flags (APPENDS_SYSTEM_PROMPT, SUPPORTS_*)
  as docstrings; tighten resolve_prompt.
- graph/types: trim the multimodal transient-carrier comments to the essentials.
- color-codeword-v1: hard-code MAX_TURNS / SEED as module constants, move the
  >=1 check into a Field(ge=...) validator, drop the redundant assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(v1): attribute multimodal items by content part, drop offset machinery

Each image's renderer item is now attributed to the node whose message introduced
it (by counting media content parts in prompt order) instead of mapping placeholder
token offsets to node spans. Removes `_node_for_offset` and the node-local →
branch-global offset rebasing in both `graph.add_turn` and `Branch.multi_modal_data`
— those placeholders were never read (training uses `mm_items` + the token→type map).
The reused prefix is skipped via a cursor (`num_reused`), so earlier turns' images
aren't overwritten.

Also: default harness drops the redundant inline flag comments, and program.py reads
`sys.argv[1]` only in the no-INITIAL_MESSAGES branch (an image-prompt rollout passes
INITIAL_MESSAGES and no argv, which previously raised IndexError).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(v1): ruff-format graph.py multimodal attribution

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pull Bot pushed a commit to Stars1233/verifiers that referenced this pull request Jun 23, 2026
…-v1 (PrimeIntellect-ai#1618)

* feat(v1): carry multimodal images through the message graph

Make a VLM trainable through the v1 message-graph trace, in two layers.

Ingress: a content-part union (TextContentPart / ImageUrlContentPart,
MessageContent) lets user/system messages hold images, and Task.instruction
becomes `str | Messages` so a taskset can seed an image-bearing initial prompt
(the default harness opts in via SUPPORTS_MESSAGE_INSTRUCTION; others reject a
Messages instruction). The interception server and the v0 legacy bridge preserve
image parts (shared `content_to_parts`) instead of flattening them to text, and
`message_hash` hashes list content stably.

Egress: TurnTokens / MessageNode carry the renderer's MultiModalData as a
transient, serialization-excluded sidecar (offsets stored node-local); add_turn
attributes each image to the node that introduced it; Branch.multi_modal_data
merges the nodes' items and rebases offsets to branch-global. The pixel tensors
never reach the wire or results.jsonl.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(v1): colocate the user simulator in the agent runtime

By default the user simulator now runs inside the agent's (harness's) already-
started runtime — reusing it via serve_tools(colocated=True, host_reachable=True)
with its port published back to the host — instead of spawning a separate runtime
per rollout. This removes the per-rollout runtime start/stop churn (and the
startup races it caused) for multi-turn tasksets. UserConfig gains `colocated`
(default True); set it False to give the user its own runtime, e.g. a remote
sandbox that can't publish the colocated port back to the host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(v1): add the color-codeword-v1 taskset

A multi-turn VLM decoding task (the v1 port of the v0 `color-codeword` env): each
turn shows colored squares mapping to letters; the model accumulates the codeword
and outputs it in full on the final turn. Turn-0 squares ride in the task's
`Messages` instruction; later turns are injected by a colocated `vf.User`. Reward
is an exact match of the final codeword, with a partial-match metric. Exercises
multimodal images end-to-end through the v1 message graph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf: occasional malloc_trim to bound v1 worker RSS (PrimeIntellect-ai#1621)

* perf(v1): occasional malloc_trim to bound worker RSS

A rollout parses large base64 request bodies (e.g. screenshots) per turn and
frees them, but glibc retains the freed arenas, so a long-lived eval / env-server
worker's resting RSS climbs and never drops. Call malloc_trim(0) once every Nth
finished rollout (gated in Episode.run) to hand those arenas back to the OS.
Best-effort and resolved once: a no-op off glibc (musl, macOS).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(v1): run malloc_trim off the event loop

malloc_trim(0) walks every arena's free lists and can block for tens of ms on a
large heap. Called inline it stalls the whole event loop — and under the
multiplexed env server, every concurrent rollout with it. Offload to a worker
thread via asyncio.to_thread; ctypes releases the GIL during the call, so the
heap walk runs concurrently with the loop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(v1): review-pass cleanups for multimodal support

- harness: express the capability flags (APPENDS_SYSTEM_PROMPT, SUPPORTS_*)
  as docstrings; tighten resolve_prompt.
- graph/types: trim the multimodal transient-carrier comments to the essentials.
- color-codeword-v1: hard-code MAX_TURNS / SEED as module constants, move the
  >=1 check into a Field(ge=...) validator, drop the redundant assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(v1): attribute multimodal items by content part, drop offset machinery

Each image's renderer item is now attributed to the node whose message introduced
it (by counting media content parts in prompt order) instead of mapping placeholder
token offsets to node spans. Removes `_node_for_offset` and the node-local →
branch-global offset rebasing in both `graph.add_turn` and `Branch.multi_modal_data`
— those placeholders were never read (training uses `mm_items` + the token→type map).
The reused prefix is skipped via a cursor (`num_reused`), so earlier turns' images
aren't overwritten.

Also: default harness drops the redundant inline flag comments, and program.py reads
`sys.argv[1]` only in the no-INITIAL_MESSAGES branch (an image-prompt rollout passes
INITIAL_MESSAGES and no argv, which previously raised IndexError).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(v1): ruff-format graph.py multimodal attribution

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant