Repository navigation
compass(runner): a model runner that constructs without device memory (RUNNER-1) #80
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| # SPDX-License-Identifier: MIT | ||
| # Copyright (C) 2024-2025, Advanced Micro Devices, Inc. All rights reserved. | ||
|
|
||
| """The model runner a simulated run installs in place of ATOM's own. | ||
|
|
||
| Two modules, split by what each is allowed to import: | ||
|
|
||
| - `overrides` holds the behaviour -- the methods that keep construction off the | ||
| device -- and imports nothing from the engine. That is not a stylistic | ||
| preference. Importing `atom.model_engine.model_runner` runs aiter's | ||
| architecture probe, which shells out to `rocminfo` and raises in a container | ||
| with no driver, so anything reachable from that import cannot be exercised by | ||
| a test that runs without one. | ||
| - `model_runner` binds those overrides onto `ModelRunner`, and is therefore the | ||
| only module here that needs a driver to import. | ||
|
|
||
| This package stays empty at import time for the same reason, so naming it costs | ||
| nothing. | ||
|
|
||
| `COMPASS_RUNNER_QUALNAME` is the string that selects the runner: a `Config` | ||
| field, read where the worker process instantiates its runner. Installing this | ||
| class needs no change to ATOM. | ||
| """ | ||
|
|
||
| COMPASS_RUNNER_QUALNAME = "atom.compass.runner.model_runner.CompassModelRunner" | ||
|
|
||
| __all__ = ["COMPASS_RUNNER_QUALNAME"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| # SPDX-License-Identifier: MIT | ||
| # Copyright (C) 2024-2025, Advanced Micro Devices, Inc. All rights reserved. | ||
|
|
||
| """The runner class that `runner_qualname` names.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from atom.compass.runner.overrides import NonAllocatingRunner | ||
| from atom.model_engine.model_runner import ModelRunner | ||
|
|
||
|
|
||
| class CompassModelRunner(NonAllocatingRunner, ModelRunner): | ||
| """A `ModelRunner` that constructs without weights, KV tensors or a step. | ||
|
|
||
| Everything the engine does around a step -- the scheduler, admission, the | ||
| block manager, the prefix index -- is ATOM's own and runs unchanged. What | ||
| this class removes is the weights, the KV tensors and the step itself, by | ||
| overriding the five methods that own them; see `overrides`, which holds the | ||
| bodies and says why each one does what it does. | ||
|
|
||
| Construction is not free of device memory. What remains is the base's | ||
| forward-vars ring from `allocate_forward_vars`, whose dominant term is a | ||
| `max_num_batched_tokens` by `hidden_size` output buffer. It is sized by the | ||
| batch budget and the model's hidden size, not by the model's weights, and | ||
| no named tensor on the runner holds any of it. | ||
|
|
||
| `NonAllocatingRunner` comes first so its methods win over the base's. There | ||
| is deliberately no `__init__`: the base runs all of its own before a | ||
| subclass body would get control, so there is no point at which state set | ||
| here would be visible to it. | ||
| """ | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,128 @@ | ||||||||||
| # SPDX-License-Identifier: MIT | ||||||||||
| # Copyright (C) 2024-2025, Advanced Micro Devices, Inc. All rights reserved. | ||||||||||
|
|
||||||||||
| """The model-runner methods that keep construction off the device. | ||||||||||
|
|
||||||||||
| These are the memory-owning methods of ATOM's `ModelRunner`: the ones that read | ||||||||||
| a checkpoint, warm the model, size the KV pool, allocate it, and run a step. | ||||||||||
| They live here rather than beside the subclass because this module imports | ||||||||||
| nothing from the engine, and so can be executed on a machine with no driver. | ||||||||||
|
|
||||||||||
| Two of them run during construction (`_build_and_load_model`, `_maybe_warmup`) | ||||||||||
| and decline to do their work. The rest run afterwards, over the worker's RPC | ||||||||||
| channel; `allocate_kv_cache` does the arithmetic and none of the allocation, | ||||||||||
| and the other two refuse by name because their answers are not this module's to | ||||||||||
| invent -- a wrong duration or a wrong block count would be indistinguishable | ||||||||||
| from a measured one. | ||||||||||
|
|
||||||||||
| Order matters when mixing this in: `NonAllocatingRunner` must precede | ||||||||||
| `ModelRunner` in the bases so these definitions win. The class deliberately | ||||||||||
| defines no `__init__`. The base class runs the whole of its own `__init__` | ||||||||||
| before a subclass body would get control, so anything these methods read has to | ||||||||||
| come from `self.config`, which the base sets first. | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| from __future__ import annotations | ||||||||||
|
|
||||||||||
| import logging | ||||||||||
| from typing import Any | ||||||||||
|
|
||||||||||
| import torch | ||||||||||
|
|
||||||||||
| logger = logging.getLogger(__name__) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class RunnerRefusal(RuntimeError): | ||||||||||
| """Raised where this runner has no answer and will not invent one.""" | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class UnbuiltModel(torch.nn.Module): | ||||||||||
| """Stands in for the module tree a non-allocating runner never builds. | ||||||||||
|
|
||||||||||
| It registers no parameter and no buffer, so it costs nothing on any device, | ||||||||||
| and calling it raises instead of returning something a caller could mistake | ||||||||||
| for a forward pass. | ||||||||||
| """ | ||||||||||
|
|
||||||||||
| def __init__(self, model_class: Any) -> None: | ||||||||||
| super().__init__() | ||||||||||
| self.model_class_name = getattr(model_class, "__name__", repr(model_class)) | ||||||||||
|
|
||||||||||
| def forward(self, *args: Any, **kwargs: Any) -> Any: | ||||||||||
| raise RunnerRefusal( | ||||||||||
| f"{self.model_class_name} was never built: this runner predicts what a " | ||||||||||
| "forward pass costs instead of running one, so there are no weights " | ||||||||||
| "to call." | ||||||||||
| ) | ||||||||||
|
|
||||||||||
|
|
||||||||||
| class NonAllocatingRunner: | ||||||||||
| """Overrides that construct a model runner without touching device memory.""" | ||||||||||
|
|
||||||||||
| def _build_and_load_model(self, model_class: Any) -> None: | ||||||||||
| """Build nothing and read no checkpoint. | ||||||||||
|
|
||||||||||
| The base class constructs the model with the default device set to this | ||||||||||
| rank's GPU and then fills it from disk. Both halves are skipped here, so | ||||||||||
| no weight byte reaches the device and no checkpoint is read. | ||||||||||
| """ | ||||||||||
| self.model = UnbuiltModel(model_class) | ||||||||||
| # Cleared on the way out, as both of ATOM's own implementations do: the | ||||||||||
| # caller set the default device to this rank's GPU before calling, and | ||||||||||
| # the code that runs next is written against a cleared default. | ||||||||||
| torch.set_default_device(None) | ||||||||||
| logger.info( | ||||||||||
| "%s not built and no checkpoint read; no weight bytes on the device.", | ||||||||||
| self.model.model_class_name, | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| def _maybe_warmup(self) -> None: | ||||||||||
| """Skip warmup. | ||||||||||
|
|
||||||||||
| Warmup runs a forward over a dummy batch against real weights. There are | ||||||||||
| none here, so there is nothing to warm. Skipping is also what lets | ||||||||||
| construction finish at all: the base class warms the model from inside | ||||||||||
| its own `__init__`, and the forward that warmup drives is this class's, | ||||||||||
| which refuses. | ||||||||||
| """ | ||||||||||
| return | ||||||||||
|
|
||||||||||
| def get_num_blocks(self) -> dict[str, object]: | ||||||||||
| """Refuse to size the KV pool. | ||||||||||
|
|
||||||||||
| The base sizes it from what a real device reports free after the weights | ||||||||||
| are resident. Neither figure exists here, and the substitute belongs to | ||||||||||
| the memory model rather than to the runner, so answering would mean | ||||||||||
| inventing a block count that the scheduler would then treat as measured. | ||||||||||
| """ | ||||||||||
| raise RunnerRefusal( | ||||||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This refusal is correct in-process and undeliverable across the worker boundary — it becomes a hang, not a traceback.
out = func(*args)and no Nothing in RUNNER-1 runs a step, so this is not a defect in this PR and I am not asking you to change the body. The ask is one line in the "For RUNNER-2 and RUNNER-3" list: it currently warns that an absent method is silence and that
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed, and it is now item 2 of the "For RUNNER-2 and RUNNER-3" list — moved to I re-read the loop on the merged tree to pin the line numbers a successor will The One consequence of the integration head moving that this touches:
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. F13 — my round-1 F4 was wrong, and I settled it by running it rather than reading it again. This refusal is delivered; the hang is an absent method. I asked for handoff item 2 on the strength of a claim I had only read. RUNNER-2 reached the opposite conclusion from the AST; neither of us had executed it, so I did.
The premise was right and the conclusion inverted. Two things the delivery does not do, both measured, and both worth more than the hang was:
Ask: swap items 2 and 3, and rewrite 2. Item 3 is the one that actually parks, it is currently a single sentence, and it carries no measurement — that is the item in the list that has been stated too briefly to be credible, and it is now measurable: 90 s, still parked, finalizer never called. Item 2 should say that a refusal is delivered, stripped of its reason, as a bare
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both done in the body at Items 2 and 3 are swapped, and 2 is rewritten. Item 2 now says a refusal Item 3 is now the hazard, restated as absence and no longer a single F14 — the provenance was mine to get wrong and I confirmed your correction
One thing I disagreed with, and it is a shading rather than a finding: your |
||||||||||
| "a non-allocating runner cannot size the KV pool from a device it " | ||||||||||
| "never allocated on; the block count has to come from a memory model " | ||||||||||
| "this runner has not been given." | ||||||||||
| ) | ||||||||||
|
|
||||||||||
| def allocate_kv_cache(self, num_kvcache_blocks: int) -> bool: | ||||||||||
| """Record the block count and allocate nothing. | ||||||||||
|
|
||||||||||
| The block accounting above this -- the pool, the prefix index, eviction, | ||||||||||
| preemption -- is integer arithmetic and runs unmodified against the count | ||||||||||
| recorded here. Only the tensors behind the blocks are absent. | ||||||||||
| """ | ||||||||||
| self.config.num_kvcache_blocks = num_kvcache_blocks | ||||||||||
| logger.info( | ||||||||||
| "kv cache: %d blocks accounted, 0 bytes allocated.", | ||||||||||
| num_kvcache_blocks, | ||||||||||
| ) | ||||||||||
| return True | ||||||||||
|
|
||||||||||
| def forward(self, batch: Any) -> Any: | ||||||||||
|
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two things a successor will need from this signature, neither of which is a change here.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Both recorded in the handoff list; no change to the signature. Decorators — item 6. It says the omission is deliberate and right while the
|
||||||||||
| """Refuse to run a step. | ||||||||||
|
|
||||||||||
| This class is the attachment point and not the replacement for a step. A | ||||||||||
| step's output has to reproduce what the scheduler reads back from a real | ||||||||||
| one, and a plausible-looking stub that does not is worse than no answer. | ||||||||||
| """ | ||||||||||
| raise RunnerRefusal( | ||||||||||
| "this runner has no cost model and no step semantics yet, so it " | ||||||||||
| "cannot say what a step produced or how long it took." | ||||||||||
| ) | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
F15 — non-blocking, and the one clause of this sentence I read as false. Taking the digits out took two bounds out with them.
This is against the round-3 delta, not a reopening of F12 — F12 is closed and the "hidden size, not weights" clause is correct now. But round 2's paragraph carried its bounds inside its digits, and two of them left when the digits did.
(a)
no named tensor on the runner holds any of it. Round 1's instrument reported two disjoint readings side by side:The 0-byte reading was of a set that excluded the ring — the ring is the next line. This sentence takes the reading of set A and binds it to set B with "any of it", where "it" is the ring. On the plain reading it is false, and the references are nameable:
self.forward_vars = {...}—atom/model_engine/model_runner.py:1297, fromallocate_forward_vars, which__init__calls at:777self._fv_ring = [self.forward_vars]—:1358(also:1383), which is literally the "ring" this sentence namesA constructed runner holds 100% of those bytes under two named attributes for its whole lifetime, and one is named after the thing the sentence is about. The clause is true only under "a tensor bound directly to an attribute name" — the instrument's definition, and exactly the condition a docstring cannot carry. That is the round-2 ruling's own failure mode, surviving in the one clause that is still a measurement with its digits filed off.
The claim the reading actually supports is more useful to #77/#78 anyway: this subclass adds no tensor of its own.
NonAllocatingRunnersets onlyself.model(anUnbuiltModelregistering no parameter and no buffer,overrides.py:39-56,:69) andself.config.num_kvcache_blocks(overrides.py:111). Suggested:(b) The lesser one.
What remains is the base's forward-vars ringwas measured at 99.1% / 99.4%, and round 2's sentence said so. The PR body still names the other 0.6–0.9% (torch.cuda.Stream,torch.cuda.Event, the attention builder,initialize_eplb_runtime); this line now says it is not there. One word: "Almost all of what remains…".The other three clauses I checked and accept without qualification, including
dominant term— that is a structural property of the expression (outputsismax_num_batched_tokens x hidden_size x itemsizeat:1308-1313; every other entry ismax_bs-sized ormax_num_batched_tokens x 8), checkable from the code with no conditions, so I do not read it as a proportion with the proportion removed.Not blocking, and the standalone comment says why I considered it and declined: it is a precision defect, not a false number, nothing in the diff behaves differently, and a third blocking round is an escalation under
AI_DEV_RULES.mdthat would halt the base of the runner chain over eight words. If anything else is pushed to this branch, fold it in; otherwise #90 should carry it, since it edits this file.