Skip to content

[WIP] Feat/kv share - #200

Open
OutstanderWang wants to merge 20 commits into
Ascend:mainfrom
OutstanderWang:feat/kv-share
Open

OutstanderWang wants to merge 20 commits into
Ascend:mainfrom
OutstanderWang:feat/kv-share

Conversation

@OutstanderWang

@OutstanderWang OutstanderWang commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Workloads that read one large payload from many keys store it once per key. In GRPO, for example, each prompt video is read by its n responses, so putting the video into every response key multiplies its memory and put traffic by n.

The same workloads also suffer from unbalanced storage units. SimpleStorage stores global_index on unit global_index % num_data_storage_units, and indexes were handed out globally in sequence. When puts alternate between two partitions on two units, all four rows of one partition land on unit 0 ([0, 0, 0, 0]). Large shared payloads make this imbalance much worse.

Change

1. Balance each partition's rows across SimpleStorage units

  • PartitionIndexManager counts each partition's rows per storage unit. Each new index goes to the unit where that partition has the fewest rows. On a tie it prefers a reusable index, then the counter's next index. The example above now spreads as [0, 0, 1, 1].
  • Indexes skipped to reach a unit wait in that unit's reuse pool. Each pool is FIFO.
  • The number of units comes from SimpleStorage.num_data_storage_units in store_config. Other backends, and SimpleStorage with one unit, keep the previous FIFO allocation.
  • Controller checkpoints keep the flat reusable_indexes list, which is regrouped by unit on load.

2. Share payloads through __public__ and kv_link

  • tq.PUBLIC_PARTITION ("__public__") is an ordinary partition that holds shared payloads. It is written and read with the usual KV APIs, and change 1 balances its rows like any other partition's.
  • tq.kv_link(keys, partition_id, public_key, fields) makes fields of keys read the payload of public_key without copying it. Missing keys are created. The link is a single controller request: either every key is linked or none is.
  • Linked fields take the public key's dtype, shape, and readiness. Rewriting the public key updates every key that links to it.
  • tq.kv_clear_unlinked_public() clears every public key that no key links to, and returns the cleared keys.
import transfer_queue as tq

# One copy of the video, shared by the n responses of prompt 0.
tq.kv_put(key="video_0", partition_id=tq.PUBLIC_PARTITION, fields={"video": video})
keys = [f"traj_0_{i}" for i in range(n)]
tq.kv_link(keys, partition_id="train", public_key="video_0", fields="video")
tq.kv_batch_put(keys=keys, partition_id="train", fields=TensorDict({"response": responses}, batch_size=n))

batch = tq.kv_batch_get(keys=keys, partition_id="train")  # "video" and "response" for every key

# After the step: clear the trajectories, then free the videos nobody reads any more.
tq.kv_clear(keys, partition_id="train")
tq.kv_clear_unlinked_public()  # ["video_0"]

How links are implemented:

  • The controller records links in DataPartitionStatus.links ({global_index: {field: public global_index}}) and indexes them by source in linked_by, so clear checks scale with the size of the clear.
  • BatchMeta carries the links to the storage manager. AsyncSimpleStorageManager.get_data reads each linked public row once, however many keys in the batch link to it.

How other APIs behave with links

API Behavior
kv_batch_get, get_data, samplers, StreamingDataLoader Read linked fields from the public row
kv_put / put through BatchMeta on a linked field Stores a private value in place of the link
kv_update on a linked field Refused before anything is written; kv_put replaces the link instead
kv_clear of a linking key or field Drops only the link
kv_clear, clear_samples, clear_partition on a public key that is still linked Refused; clear the linking keys first
kv_describe Each row reports links: {field: public key}
dump_data_by_key / load_data_by_key Links are dumped as references. Dump the public keys separately and load them first; the load checks that their fields are ready, then relinks
save_checkpoint / load_checkpoint Links are restored with the controller state, including the clear guard
data_parser NotImplementedError when a selected field is linked
Backends other than SimpleStorage kv_link raises NotImplementedError

Concurrency

  • kv_link refuses a public key that is being cleared (mark_clearing has zeroed its production status), and keys that are being cleared.
  • kv_clear_unlinked_public selects and marks the unlinked rows in one controller request, so a racing kv_link is refused for the keys it clears.
  • A kv_put during a public key's clear can make the key ready again and let a kv_link through. When the clear then releases the row, the controller drops that link, so the field becomes unwritten instead of pointing at an index that another partition may reuse.
  • kv_link validates field types before it creates keys, so a dtype conflict leaves no empty keys behind.

Limitations

  • Units are balanced by row count, not bytes. Public payloads of very different sizes can still leave some units fuller than others.
  • kv_clear_unlinked_public also clears a public key that was put but not linked yet. Call it when no writer is between its kv_put and kv_link, for example after a step's trajectories are cleared.
  • kv_link is SimpleStorage-only. It does not support data_parser, and kv_update cannot modify linked fields.
  • When a link replaces a private field, the old private payload stays in storage until the key is cleared.

Tests

  • tests/e2e/test_kv_link_e2e.py (new) covers:
    • balanced spread of public and private rows;
    • reads through links;
    • argument checks;
    • the clear guard on every clear path;
    • put replacing a link;
    • refusal of kv_update and data_parser;
    • readiness and shape following the public key;
    • dump/load and checkpoint round trips;
    • kv_clear_unlinked_public;
    • the concurrency cases above.
  • tests/test_controller_data_partitions.py:
    • the allocator balances each partition and stays FIFO with one unit;
    • the link index follows put and clear;
    • a checkpoint saved before links existed still loads.
  • tests/test_metadata.py: add_fields and union handle links.
  • python -m pytest -q: 883 passed, 12 skipped. tests/test_yuanrong_storage_client_e2e.py was deselected locally because it needs a Yuanrong environment.

GRPO-style rollouts put one prompt and link its n responses to it, so the
prompt payload is stored once. Links are controller metadata only; the
SimpleStorage read path resolves them and reads each source row once.
A put on a linked field replaces the link, clearing a linked key drops only
the link, and a source cannot be cleared while other keys still link to it.
dump_data_by_key records links and load_data_by_key relinks.
kv_link checked field metadata, which stays until the clear finishes, so a
link made between mark_clearing and the storage delete pointed at a payload
about to vanish. Check production status, which mark_clearing zeroes first.
A repeated missing key was registered twice, leaving a keyless row that held
a link and could never be cleared. Reject duplicates before any RPC, as the
batch writes do.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 19, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-19T05:25:05.995929Z 5837879 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5837879910

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

<dump_dir>/
dump_info.json # version, partition, counts
row_index.pt # key -> {global_index, fields, unfinalized, tag}
row_index.pt # key -> {global_index, fields, unfinalized, links, tag}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bump the dump format before writing links

When a link-bearing dump is restored by the pre-change build, dump_info.json still advertises format 4, which that reader accepts. That reader ignores the new links member while fields deliberately omits linked payloads, so the restore succeeds with those fields silently lost. Allocate a new dump format version (while retaining v3/v4 read compatibility) so older readers reject link-aware dumps.

Useful? React with 👍 / 👎.

Comment on lines +831 to +834
fields_by_row: dict[int, set[str]] = defaultdict(set)
for global_index, links in zip(metadata.global_indexes, metadata._links, strict=True):
for field in metadata.field_names:
fields_by_row[links.get(field, global_index)].add(field)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve duplicate samples through parser grouping

When kv_batch_get receives repeated keys/global indexes together with data_parser and per-key parser_args, this dictionary coalesces occurrences before routing. The parser runs only once with one occurrence's argument and the reordered output duplicates that result, so another duplicate ignores its own argument; the previous routing path kept duplicate positions. Preserve occurrences for parser-backed reads or reject duplicate read keys.

Useful? React with 👍 / 👎.

metadata = self.kv_retrieve_meta(keys, partition_id, create=True)
partition = self._get_partition(partition_id)
assert partition is not None
partition.link_fields(metadata.global_indexes, public, source, fields)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delete private cells when linking existing fields

When a destination key already stores any requested field, kv_link only changes controller link maps; no client or storage-manager call removes the destination cell. Reads switch to the public source, but the old tensor or SSD file remains allocated until an explicit overwrite or clear, so relinking an existing dataset retains all N copies and can still exhaust memory. Reject already-produced destinations or clear their private cells through the data plane when linking.

AGENTS.md reference: AGENTS.md:L13-L14

Useful? React with 👍 / 👎.

Comment thread transfer_queue/client.py
Comment on lines +1199 to +1203
await self._request_controller(
socket=socket,
request_type=ZMQRequestType.KV_LINK,
response_type=ZMQRequestType.KV_LINK_RESPONSE,
body={"keys": keys, "partition_id": partition_id, "public_key": public_key, "fields": fields},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Enforce the backend check in the low-level link client

Under Mooncake or YuanRong, callers of the exported get_client().kv_link/TransferQueueClient.kv_link reach this RPC directly and bypass the backend guard that exists only in interface.async_kv_link. The controller returns success and marks fields linked, but those storage managers do not resolve BatchMeta._links, so subsequent reads use missing or stale destination cells. Put the backend check in this low-level client path as well.

AGENTS.md reference: AGENTS.md:L17-L18

Useful? React with 👍 / 👎.

Comment on lines +1284 to +1285
keys = [keys] if isinstance(keys, str) else keys
_check_unique_keys(keys)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject empty destination lists before recording links

kv_link([], ...) is accepted here: the controller creates a destination partition and link_fields records linked_by[source] = set() despite having no rows. mark_unlinked_public_clearing considers the reverse-map keys rather than nonempty sets, so that public source is skipped by every bulk-clear call until the empty destination partition is removed. Reject the empty list before the RPC or avoid creating an empty reverse entry.

Useful? React with 👍 / 👎.

key
for row in rows.values()
for name, key in row.get("links", {}).items()
if name not in public_rows[key]["fields"] or name in public_rows[key]["unfinalized"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate linked field types before loading payloads

When loading a linked dump into an existing partition that declares the linked field with an incompatible dtype or tensor kind, this preflight checks only public readiness because linked fields are absent from row_index["field_schema"]. load_rows_by_key then registers keys and may write shards, and the subsequent client.kv_link is the first type validation and fails, leaving a partial restore. Validate the destination against the public fields' schemas before calling load_rows_by_key.

Useful? React with 👍 / 👎.

@Chase-Rong Chase-Rong left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[AI comments] Links reference a mutable key name, not a snapshot. A linked public row can still be rewritten, and L1271-L1279 propagates the new value and readiness to every linking key. If a later step reuses a public key name (video_0) while older trajectories still link it, they silently read the new payload.

Dump/load has the same gap. Repro: link 4 keys to v0 (values 0..5), dump both, clear them, kv_put("v0", <all 9s>), then load_data_by_key(link_dir). The load succeeds and the restored keys read [9, 9, ...]. The preflight only checks that the public field is ready.

Suggestion: refuse writes to a public row while it is linked, symmetric to the clear guard; this also removes the need for the cascade loop. Record a fingerprint or dump id of the public rows in the link dump so load can verify it, or offer an option to dereference links at dump time.

assert partition is not None
partition.link_fields(metadata.global_indexes, public, source, fields)

def mark_unlinked_public_clearing(self) -> tuple[BatchMeta, list[str]]:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kv_clear_unlinked_public() sweeps every unlinked key, including keys another producer has kv_put but not yet kv_linked. The documented precondition ("no writer between kv_put and kv_link") rarely holds in async/off-policy GRPO, where the rollout of step k+1 overlaps the trainer cleanup of step k. The producer kv_link then fails ("must have produced" / "not found") and its payload is gone. __public__ is also one global namespace, so two jobs sharing a TransferQueue can collide on key names and sweep each other's payloads.

tq_client._run_coroutine(async_kv_clear(keys=keys, partition_id=partition_id, fields=fields))


def kv_link(keys: list[str] | str, partition_id: str, public_key: str, fields: list[str] | str) -> None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each call to kv_link requires a public_key, so processing B prompts requires B RPC calls, and the entire process is not atomic: if a failure occurs midway, it will result in an earlier set of prompts being linked while a later set remains unlinked. I suggest adding an optional batch processing feature, eg. accept public_keys: list[str] aligned with keys or a {key: public_key} mapping

@Chase-Rong

Copy link
Copy Markdown
Collaborator

[AI comments] A plain read-modify-write silently turns every link into a private copy. Repro: data = kv_batch_get(keys, P), data["resp"] += 100, kv_batch_put(keys, P, data). Afterwards links is {} for every key, video is stored n times, and kv_clear_unlinked_public() frees v0 as unreferenced. client.put(data, metadata=meta) behaves the same way through add_fields. This is the most common RL update pattern, so dedup is lost without any signal.

Suggestion: refuse a put that lands on a linked field and require an explicit unlink (kv_clear(fields=...)), consistent with how kv_update already refuses. At minimum, log a warning.

@Chase-Rong

Copy link
Copy Markdown
Collaborator

[AI comments] kv_describe disagrees with the read path on linked fields. fields omits video, while kv_retrieve_meta and kv_batch_get include it. After kv_update(public_key, ..., finalize=False), kv_batch_get(select_fields="video") raises "not ready", but kv_describe(...)["unfinalized"] is [], which breaks its documented contract. There is also no reverse lookup: when clearing a public key is refused, the error names only the public key, and nothing reports which keys or partitions link to it.

Suggestion: keep fields meaning "readable fields", include linked fields in unfinalized when their source is not ready, and keep links as extra information. Dump can then use fields - links.keys() to pick the payloads to read. Include the linking partitions and keys in the refusal message.

Comment on lines +2053 to +2063
raise KeyError(f"key {public_key!r} not found in partition {PUBLIC_PARTITION!r}")
source = public.keys_mapping[public_key]
# Production status, not field metadata: mark_clearing zeroes it before storage deletes the payload.
missing = [
name
for name in fields
if name not in public.field_name_mapping
or public.production_status[source, public.field_name_mapping[name]] != 1
]
if not fields or missing:
raise ValueError(f"public key {public_key!r} must have produced fields {missing or fields}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR description says kv_link refuses keys that are being cleared, but only the public source's production status is checked. I conduct a simple test: kv_put("t0", P), mark_clearing([t0]), then kv_link("t0", P, "v0", "video") is accepted, and video reads as ready during the clear window. The following clear_meta drops the link, so the state converges and the impact is small. A destination being cleared cannot be told apart from one not produced yet by production status alone, so correcting the description is likely simpler than adding the check.

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.

2 participants