perf: occasional malloc_trim to bound v1 worker RSS - #1621
Merged
Conversation
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
marked this pull request as ready for review
June 11, 2026 02:41
Contributor
ApprovabilityVerdict: 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
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
trim_memory()(malloc_trim(0)viactypes) inverifiers/v1/utils.py, resolved once and cached; a no-op off glibc (musl, macOS)._TRIM_EVERY_ROLLOUTS(16) finished rollouts, gated inEpisode.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 throughEpisode.run, so both are covered.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 viaasyncio.to_thread;ctypesreleases 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 cachesmalloc_trim(0)viactypes(no-op off glibc), andtrim_memory_periodically()runs it every 16 finished rollouts on a worker thread viaasyncio.to_threadso the arena walk doesn’t block the event loop.Episode.runawaitstrim_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_trimperiodically in v1 workers to bound RSS growthtrim_memory_periodically()in utils.py that calls glibc'smalloc_trim(0)every 16 rollouts viaasyncio.to_thread, returning freed per-turn request body memory to the OS.trim_memoryresolves to a no-op after the first failedctypeslookup.trim_memory_periodically()after each rollout completes.Macroscope summarized 8700a46.