fix(kb): guard warm-start replay on the shape the canonical_id cannot express - #1495
Conversation
4a2c251 to
aa5d189
Compare
aa5d189 to
59076b0
Compare
Blocking: the shape belongs in the identity, not in a read-side guardThe diagnosis is right and worth fixing — SPX and CPX really do share one Required change
It is gated to
What that buys, and why it is the only shape that closes the issue: a CPX pod cannot read an SPX row because it is a different Please leave TP out of this PR: encoding Why the guard cannot be patched into correctnessThree defects, each on a main path, each invisible to the six new tests. 1. class S:
tp = 8; ep = 8; conc = 64; isl = 1024; osl = 256
baseline_workload_extra = {}
compute_partition = {"mode": "CPX"}
row = {"tp": 8, "ep": 8, "partitions": 8,
"exact_history": {"canonical_id": "x", "lessons": [], "pitfalls": []}}
_shape_mismatch(S(), row)
# 'tp: row=None pod=8, ep: row=None pod=8, partitions: row=None pod=8'
_shape_mismatch(S(), {k: v for k, v in row.items() if k != "exact_history"})
# ''Same row, same pod, exact match — reported as three mismatches purely because history was attached. This is the common path, not an edge case: in local mode T0 writes its own anchor row before reading, that row is non-actionable on a first run, so 2. The demotion does not deny the replay. It only sets 0.9 clears the 0.7 default, so PRELUDE replays a config from a CPX pod anyway. The guard fires, logs, and hands the replay from a row it checked to a row it did not. 3. workload_shape(dense_run_state)
# {'tp': 8, 'ep': 1, 'conc': 64, 'isl': 1024, 'osl': 256}
_shape_mismatch(dense_run_state, {"tp": 8, "conc": 64, "isl": 1024, "osl": 256})
# 'ep: row=None pod=1'So " All three are the same failure mode. The guard compares rows across publishers that disagree by construction: One note on the flag's original promiseIt isn't vacuous. |
|
@rpoornac Thanks for adding the
kernel_optimizer, tp, conc, isl, oslThe remote client sends Therefore, This PR adds ("tp", "ep", "partitions")after a warm row has already been returned. However, That is a useful safety backstop: it prevents an incompatible config from being replayed by demoting the row to For example, these two sessions can still share the same current Store scope: If A is the scoped champion while the current pod matches B, the new guard safely demotes A. But the reader cannot continue to B because KB Store never partitions or queries candidates by Therefore, the current implementation solves:
but not yet:
Would you agree that If so, please confirm the intended compatibility behavior for historical rows, especially:
After confirmation, I can make the corresponding KB Store service, schema, and query changes. I would also prefer to avoid introducing another parallel set of variables or mappings. Ideally, both the scope projection and replay guard should derive from the existing |
The recipe canonical_id is a seven-tuple of model, hardware, framework
name, model type, architectures, framework version and precision. The
compute-partition mode is not among those dimensions, and neither is
expert parallelism on a single node, so kb_hardware_slug collapsed to the
bare GPU type and a run in SPX and a run in CPX shared one identity. The
warm-start cascade only relaxes conc/isl/osl, so an exact hit at
confidence 1.0 could hand the auto-replay a config recorded with eight
times the partitions, with --warm-replay-min-reproduce-pct noticing only
after the verify was spent.
kb_hardware_slug now suffixes the partition mode and ep at any node
count rather than only inside a cluster key. Both are fixed at launch
rather than explored, which is the argument _tp{tp} already makes for
itself, so a best_config tuned under one is invalid under the other. A
CPX pod therefore cannot reach an SPX row: it is asking a different
canonical_id. That removes the failure class instead of an instance of
it -- there is no second comparison that could be applied to a different
row than the one that ends up replayed, because resolve_kb_topology is
the single call both the reader and the writer build the key from, and
_hardware_is_compatible already requires the topology suffix to match
exactly, so the config-donor path is covered by construction.
Every suffix is omitted at its default value: ep <= 1 is dense, and SPX,
an unpublished mode and a mode this build does not recognise are all the
whole card. Existing keys stay byte-identical, so nothing in the corpus
moves. _TOPOLOGY_SUFFIX_RE learned the single-node forms as well, or
_hardware_fallback_values would have quietly stopped offering the
same-ISA SKUs for exactly the rows these suffixes were added for. tp is
deliberately left out: almost every single-node run sets it, so encoding
it would re-key the whole corpus rather than only the rows that collide.
workload_shape still publishes ep and partitions, now as a description
of the run rather than the gate on replaying it, and
knowledge_to_warm_recipe still derives its projection allowlist from the
publisher instead of restating it. Both are omitted at their default:
--ep defaults to 1, so publishing it would have every dense run claim a
formation it never chose, and one partition is the whole card. The count
a launch published wins over one re-derived from the mode name, so there
is only one derivation to keep in agreement.
--recipe-kb-strict-fingerprint is removed. It was declared in the parser
and read nowhere, and the stack_fingerprint disagreement it promised to
refuse was never the exposure, since framework version and precision are
already identity dimensions. With the mode in the key there is nothing
left for a read-side comparison to do. rocm_version and aiter_commit are
written into every row's stack_fingerprint and compared nowhere at read
time; that gap is real and is tracked separately rather than under a
flag whose name says fingerprint and whose behaviour would have been
workload shape.
Co-authored-by: Cursor <cursoragent@cursor.com>
59076b0 to
1734757
Compare
|
@ZhengGong-amd Agreed on all three, and I reproduced each one before changing anything rather than taking them on faith. Pushed as The three defects, confirmed. What the PR does now. One companion change you did not ask for, because it would otherwise have regressed silently. On the flag's original promise: you are right that it is not vacuous, and Full suite on the new base: 22113 passed, 9 failed — all nine reproduce on a clean |
|
@meinali-566 Thanks — the selection gap you describe was real, and your example is exactly the right one. The answer changed with the rework I just pushed ( Short answer: no new A pod matching B queries B's Your three compatibility questions, explicitly. All three cases are deliberately indistinguishable from each other and all three keep their existing key:
A mode this build does not recognise also falls in that bucket, so an unknown string cannot invent a key. I verified the byte-identical property directly: One honest limitation, since you asked specifically about historical rows. This prevents future collisions; it does not retroactively re-file rows already written under a colliding key. A single-node On your last point, we agree, and that is how it is built: So I do not think there is KB Store service, schema or query work needed for this issue. If you would still like |
|
Housekeeping: I've rewritten the PR description, which was still describing the previous revision's read-side guard — the No code changed — head is still The out-of-scope |
|
@rpoornac I re-reviewed the latest revision ( And no other block issues are found. LGTM cc @ZhengGong-amd |
The opening paragraph claimed "Nothing in the CLI, the environment contract, or the session record moves". The last two hold; the first does not. An operator reads that line to decide whether upgrading is safe, and three surfaces v1.1.0 accepted are gone in this tree. Two are optimizer options, and the parser is strict on purpose (cli/__init__.py), so neither is an ignored token: parse_args exits 2 with "unrecognized arguments" before the session starts. --recipe-kb-strict- fingerprint was removed in #1495; it was declared in the parser and read nowhere in v1.1.0, so only the rejection is new. --breakdown-include-transcripts was removed in #1455 along with the Session Breakdown section it inlined into, and that one was live in v1.1.0. The third is the deprecated console-script alias, which fails as "command not found" rather than a parser error -- a different failure mode, so it is stated beside the table rather than inside it. The highlights bullet no longer opens on a count: its three items are a different three from the table's, and two "three"s meaning different sets is worse than neither naming one.
Picks up the three reverts (#1523, #1524, #1525), which drop #1520, #1511 and #1495 out of the release. #1525 is the one that changes this branch. #1495 both re-keyed the Recipe KB on compute-partition shape and removed --recipe-kb-strict-fingerprint, so reverting it restores the option: the parser carries it again, and the CLI delta against v1.1.0 is now one removal (--breakdown-include-transcripts) and one addition (--extend-hours). Its two changelog entries, which main deleted from [Unreleased], are dropped from the v1.1.1 section here -- the conflict was the whole section against an emptied [Unreleased], resolved by keeping the section and deleting those two. The --breakdown-include-transcripts entry loses the comparison it drew against the option that is now back. The release notes lose the same two claims: the partition-key highlight, the warm-start clause in the opening paragraph, and the upgrade-table row, leaving one removed option rather than two. #1520 and #1511 needed no entries because neither ships. The environment contract is untouched by the reverts: the seven variables are still absent from .env.template, and the five collective ones still have no read site.
* chore(release): bump version to 1.1.1 Every site that tracks the packaged version moves together: pyproject's [project].version, the docs version_number kept in sync with it, the README badge, the compatibility matrix row, and the three copy-paste `pip install hyperloom-inference-optimizer==` pins, which would otherwise keep installing 1.1.0 after 1.1.1 ships. HYPERLOOM_WHEEL_TAG moves with it, which requires the v1.1.1 GitHub release to carry the rocm-profiler-hotfix-libs.tar.gz asset the bare-metal installer downloads from that tag. Patch rather than minor: the release carries fixes, a vLLM default bump, and removals of surfaces that were already unreachable -- no CLI, environment or session-record change an operator has to plan for. The TraceLens component version in the matrix is its own and stays at 1.0.0, as do SOURCE_RESOLUTION_SCHEMA_VERSION and the sphinx dependency pin that happen to read 1.1.0. * docs(release): cut the 1.1.1 changelog section and release notes The accumulated Unreleased entries become the 1.1.1 section, ordered Removed/Changed/Fixed; the seven subsections the section had grown into are merged into one of each, and the two consecutive vLLM default bumps are ordered oldest first so the chain from 0.27.1 to 0.29.0 reads forward. No entry text changes: all twelve entries are byte-identical to what they were under Unreleased. 1.1.1 is a patch release, so the notes lead on what was corrected rather than on new capability: a warm-start hit could replay a config measured on a differently shaped card, the prior work a session had earned was not reaching the model at all, agent-backend selection was answered three different ways, and a watchdog restart was neither resumable nor bounded. The bare-metal vLLM default and the three removed dead surfaces are named with their operator- visible consequence. The removal bullet points at CHANGELOG.md for the pre-rename console script spelling rather than repeating it: test_no_stray_kernel_agents_references scans every tracked file and exempts CHANGELOG.md as a historical record, but docs/release-notes.md is not on that list. * docs(release): say that the 1.1.1 command line does move The opening paragraph claimed "Nothing in the CLI, the environment contract, or the session record moves". The last two hold; the first does not. An operator reads that line to decide whether upgrading is safe, and three surfaces v1.1.0 accepted are gone in this tree. Two are optimizer options, and the parser is strict on purpose (cli/__init__.py), so neither is an ignored token: parse_args exits 2 with "unrecognized arguments" before the session starts. --recipe-kb-strict- fingerprint was removed in #1495; it was declared in the parser and read nowhere in v1.1.0, so only the rejection is new. --breakdown-include-transcripts was removed in #1455 along with the Session Breakdown section it inlined into, and that one was live in v1.1.0. The third is the deprecated console-script alias, which fails as "command not found" rather than a parser error -- a different failure mode, so it is stated beside the table rather than inside it. The highlights bullet no longer opens on a count: its three items are a different three from the table's, and two "three"s meaning different sets is worse than neither naming one. * docs(release): record the two CLI surfaces 1.1.1 ships undocumented An AST comparison of the optimize parser against v1.1.0 finds exactly two removed options and one added one. Only --recipe-kb-strict-fingerprint had an entry, so the section under-reported the release on both sides. --breakdown-include-transcripts goes under Removed. It is the more consequential of the two removals and the one with no record at all: v1.1.0 read it (cli/__init__.py), where --recipe-kb-strict-fingerprint's only occurrence outside tests was its own parser declaration. #1455 retired the exported specialist_runs section it inlined into, so nothing is left to inline; transcripts still reach disk and travel as transcript_path. --extend-hours opens an Added subsection the section did not have, restoring the Removed/Added/Changed/Fixed order v1.1.0 used. It is the only way to continue a run that has spent its budget, since elapsed time is summed forward across legs and never reset. The release notes gain the same option and lose a second false compatibility claim of mine: "the environment contract ... does not move". It does -- seven variables are gone from .env.template across #1424 and #1442, five of them with no read site left in the tree. Those removals have no changelog entry either, so rather than assert them in a summary the changelog does not back, the sentence now claims only the session record, which is verified: LATEST_STATE_SCHEMA_VERSION is 6 in both v1.1.0 and this tree. The twelve entries inherited from Unreleased remain byte-identical; these two are additions beside them. * docs(release): file the Forge entries under the release that ships them, and record the env contract Five entries sat under the published `## [v1.1.0]` heading that the v1.1.0 tag does not contain -- 67 entries at the tag, 72 here, five added and none removed, all from #1442. It branched after the release cut, when `[Unreleased]` was empty, and appended to the nearest heading. They describe 1.1.1 work, and the code says so rather than just the dates: v1.1.0's kernelforge/config.py reads FORGE_AGENT_MODEL and KERNEL_AGENTS_MODEL at line 217, and this tree reads neither. An operator on 1.1.0 has those variables working. Leaving the entries where they were had 1.1.0 claiming a change its own code contradicts, and hid the migration from the operators upgrading into it. The five move verbatim into the matching v1.1.1 subsections; the v1.1.0 section is now byte-identical to the tag apart from the "Current packaged version" marker this branch moved to v1.1.1. The collective lane's five environment variables get the entry they never had. #1424 merged the lane into the rewrite controller and deleted the variables with it. These fail unlike the removed options: config.py warns when KERNEL_AGENTS_MAX_TURNS is still set, but none of these five is read or warned about anywhere in the tree, so a launcher that still exports them runs with them silently ignored. The changelog entry and the upgrade note both say so, since a failure will not. With FORGE_CLAUDE_MODEL / FORGE_CODEX_MODEL now documented by the moved #1442 entry, the release notes can state the environment contract instead of staying silent about it: seven variables, in a table of their own because "nothing refuses them" is the opposite of what the option table above it says. The twelve entries inherited from Unreleased remain byte-identical. The file gains three entries in total, the three authored here. * docs(release): name the two silent migrations the upgrade section left out Both are changes the changelog marks BREAKING, and both are the silent kind the "Before upgrading" section exists for. FORGE_AGENT_MODEL joins the removed-variable table. The changelog names four model variables; the table carried two, and the count said seven because it was adding five collective variables to those two. FORGE_AGENT_MODEL was the provider-neutral rung and v1.1.0:kernelforge/config.py:217 read it, so a deployment can be sitting on it now; it falls through to the provider default rather than failing. KERNEL_AGENTS_MODEL stays out, since nothing in either repository ever set it, so the count is eight. All eight are verified read at v1.1.0 and unread here. HYPERLOOM_REASONING_EFFORT gets a paragraph of its own because it was not removed -- its accepted set shrank, so a removed-variable table cannot hold it. v1.1.0 took minimal | low | medium | high; the ladder is now low | medium | high | xhigh | max. The two readers disagree about a value outside it, which is the reason to state it here rather than leave it to the changelog: kernelforge's resolve_agent_reasoning_effort raises "'minimal' is not a reasoning effort", while apply_reasoning_effort drops the field and takes the gateway default, which is deeper and more expensive than minimal was. Half the run refuses, half of it silently gets costlier.
|
Re-landed as #1542, at @ZhengGong-amd's recommendation, now that 1.1.1 is cut. Restored by reverting the revert onto current main rather than rebuilding by hand, so this is the reviewed content: eight of the nine source files are byte-identical to |
…1542) Re-land of #1495, which was merged as 31143ab and reverted in #1525 to stabilise the 1.1.1 release rather than for any defect: two unrelated PRs were reverted in the same five minutes, the release PR names all three together, and main's CI was green with the change in it. Restored by reverting the revert onto current main, so the content is the reviewed content. Eight of the nine source files are byte-identical to what was merged. parser.py differs only because main has since refactored --max-hours into DEFAULT_MAX_HOURS; relative to current main this change still only removes the --recipe-kb-strict-fingerprint block. The changelog entries moved into the new [Unreleased] section that the 1.1.1 cut opened, and the "tracked separately" promise in the Removed entry now names the issue it refers to, #1507. Verified on the new base: 264 passed across the four affected suites, including the 14 tests that define the change, and ruff clean. No file overlap with the 21 commits main gained, and #1512's fuzzy KB fallback is in src/kernelforge, a different KB from the recipe KB this touches. Co-authored-by: Cursor <cursoragent@cursor.com>
The gap
The recipe
canonical_idis a seven-tuple:Expert parallelism is not a dimension of it, and neither is the compute-partition mode. Before this change
kb_hardware_slugappended topology only whennodes >= 2, so on a single node it returned the bare GPU type. A run in SPX and a run in CPX produced the same id — andrecipe_canonical_idhas no partition parameter, so the difference was not merely unrecorded, it was inexpressible.Nothing downstream caught it either. The warm-start cascade relaxes only
conc/isl/osl, andRecipeScope.matches_workload_shapecompares a hardcoded(tp, conc, isl, osl). So anexacttier hit at confidence 1.0 could hand the PRELUDE auto-replay a config recorded with eight times the partitions.--warm-replay-min-reproduce-pctis a real backstop and the run does recover, but only after paying for the verify round — andstatus=driftreports it as a recipe that failed to reproduce rather than as a machine that never matched.What this changes
The approach is to make the collision unrepresentable rather than detectable. An earlier revision of this PR compared shapes at read time and demoted disagreeing rows; that was blocked on mechanism and removed — see Why identity below.
The shape is part of the hardware identity.
kb_hardware_slugtakes apartition_modeand encodes it, plus expert parallelism, at any node count:The default shape is no shape. SPX and
ep <= 1append nothing, so every key written before this change is byte-identical after it:One resolver feeds both sides.
resolve_kb_topologynow returnspartition_mode, read from the state'scompute_partition["mode"]and falling back topublished_shape(). The KB reader and the writer derive the key from that one call, so they cannot drift into agreeing only by coincidence.The fallback tiers carry the shape.
_hardware_fallback_valuespreserves the topology suffix, so a partitioned pod reaches same-ISA siblings at its own shape and never the whole-card row at any tier:_TOPOLOGY_SUFFIX_REis extended so a shaped single-node slug still parses, without admitting arbitrary suffixes:The read-side guard is deleted, along with
_shape_mismatch,_GUARDED_SHAPE_KEYS, thestrict_shapeparameter, and--recipe-kb-strict-fingerprint— a flag that was declared in the parser and read nowhere.Why identity, not a read-side check
@ZhengGong-amd blocked the earlier revision, and all three defects reproduced:
_with_exact_historybuilds a fixed ten-key mapping with notp,eporpartitionsin it, so the check reported a mismatch for dimensions the dict is structurally incapable of carrying:'tp: row=None pod=8, ep: row=None pod=8, partitions: row=None pod=8'.warm_tier/warm_conftoseed_only/0.0 leaves the config-donor block reachable, because it is entered onwarm_pointbeing truthy._donor_is_trustworthythen compares onlyconc/isl/osland never looks at the demoted dimensions, so the config replayed anyway — a guard that logged a refusal and handed over the config.DEFAULT_EP = 1demoted the whole corpus. Every dense CLI run hasstate.ep == 1, soworkload_shapepublishedep: 1on every run and the check reported'ep: row=None pod=1'against every historical row.The first and third are consequences of comparing at read time, not implementation slips. Putting the distinction in the key removes the comparison altogether.
It also closes @meinali-566's point without a separate change:
RecipeScopehad no way to filter oneporpartitions, and distinctcanonical_ids mean there is nothing left to filter.Blast radius
Against a historical row that recorded no shape at all:
ep=1ep=1is not a shapeMI300X_dpx)MI300X_cpx)MI300X_ep8)A partitioned pod now misses rather than mis-hits. It falls through the normal cascade and, absent a row recorded at its shape, seeds cold — the honest outcome for a corpus that predates the distinction, and it self-heals as sessions republish under the shaped key. The previous revision's table promised demotions here; that was the mechanism that got removed.
Scope
No
value{}schema change and no allowlist change:workload_shapeis an existing published key,sanitize_shared_knowledgefilters secrets and host paths rather than structural keys, andRecipeScope.as_dict()— the Store query payload — is untouched.workload_shapestill publishesepandpartitionsfor disclosure, now omitting a denseepand preferring the partition count the run actually published over one re-derived from the mode. Nothing compares those fields; they are there to be read by a human and by the prompt.Verification
resolve_kb_topologycarrying the mode, preferring a persisted mode on resume, and leaving it unset when nobody published one; a partitioned pod not reaching a whole-card row, a whole-card pod still reaching the row it recorded, SPX staying identical to an unrecorded mode;workload_shapepublication, SPX and dense-epomission, published partition count winning; and projection completeness against the publisher.test_recipe_kb_t0_anchor,test_canonical_id_5tuple,test_multi_node_scriptsandtest_remote_recipe_v2.ruff checkandruff format --checkclean.Follow-up
rocmandaiterare still written into every row'sstack_fingerprintand compared nowhere at read time. That is a real gap and it is tracked in #1507, with the prerequisite recording fix in #1508. It is deliberately out of scope here: ROCm and AITER cannot go into the identity on these terms, because every patch bump would mint a freshcanonical_idand no row would ever be reusable. Defect 2 above is the specific trap that design has to avoid.