Repository navigation
[feat] selective saving and restoring of TQ samples by key instead of the whole - #181
Conversation
Squashes the initial by-key checkpoint work with its follow-up refactors (helper extraction, restructured flow, fsync on publish). Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
…meout The socket pool applies its timeout when a socket connects, so patching TQ_SIMPLE_STORAGE_SEND_RECV_TIMEOUT after tq.init() no longer reached the sockets the load reuses and the request waited out the paused unit. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
… refactor The controller and client now route requests through a dispatch table and a shared _request_controller helper, so the copies carried over from the old elif chain shadowed those and left dead code behind. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
CLA Signature PassOutstanderWang, thanks for your pull request. All authors of the commits have signed the CLA. 👍 |
c2fa8f6 to
41a562e
Compare
CLA Signature PassOutstanderWang, thanks for your pull request. All authors of the commits have signed the CLA. 👍 |
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
Signed-off-by: neowywang <neowywang@tencent.com>
0oshowero0
left a comment
There was a problem hiding this comment.
[AI Review]
Thanks for the detailed motivation and benchmarks. The core data-plane design is solid: independent per-row records with byte offsets let each current owner unit read only its own rows in parallel, which makes cross-topology restore straightforward, and compact_pickle keeps a row from dragging its whole batch's storage into a shard.
Checked on 2e66a78: python -m compileall -q transfer_queue tutorial tests is clean, and the 142 selective dump/restore tests listed in the description pass locally.
My main concern is the size relative to the feature. Most of the +4416 lines come from three things the feature does not need:
- A custom restore reservation protocol (
BEGIN/RESTORE_UNIT/FINISH/LIST/REPORT_RESTORE, per-unit states, the.restoremarker,recover_data_load). While unresolved it blocks all KV writes and clears on the destination partition; it has no lease, so a lost unit requires restarting TQ; it makes storage units call the controller; and it makes the controller depend on SimpleStorage routing. - Readers for dump format versions 1 and 2, which exist only in this PR's own history (
data_dump.pyis new onmain). - A
FieldMeta.update/extract_field_schemachange that affects every put, plus legacy shape recovery built on top of it.
Suggested split:
- A separate PR for the
FieldMeta/NonTensorStackschema fix, with focused tests. - The selective dump/load core with one format version, restoring through the existing metadata path:
kv_retrieve_meta(create=True)for target indexes,LOAD_ROWSon owner units, thennotify_data_updateandset_custom_meta. Failure semantics matchkv_batch_put(partial writes are possible; retrying the load is idempotent because existing keys keep their indexes), sorecover_data_loadandRestorePendingErrorare not needed. - If late-write protection is still required, a follow-up that designs it together with the regular put path, which has the same late-write window today.
Details are in the inline comments. Two smaller notes without a single anchor:
- Tests build the controller and units with
__new__and set private fields by hand (for exampletests/test_restore_lifecycle.py), which couples them tightly to internals. - Shards and
row_index.ptare read withpickle.loadsandtorch.load(weights_only=False). That matches the existing checkpoint trust model, butdocs/data_dump.mdshould state that dump directories must be trusted.
| """ | ||
| with self._restore_lock: | ||
| if create: | ||
| self._assert_not_restoring(partition_id) |
There was a problem hiding this comment.
[AI Review] High: an unresolved restore blocks every KV write to the destination partition, not only conflicting key creation.
kv_put and kv_batch_put always call kv_retrieve_meta(..., create=True) (interface.py), so this check rejects all KV writes to the partition while any restore is unresolved, including writes to existing keys unrelated to the restore. The PR description and docs/data_dump.md say only "conflicting KV puts that would create keys" are blocked.
Minimal reproduction against the controller:
c.kv_retrieve_meta(["existing"], "train", create=True)
c.begin_restore("r1", "/dump", "train", {"restored": {"fields": ["x"], "tag": {}}}, ["u0"], {})
c.kv_retrieve_meta(["existing"], "train", create=True)
# RuntimeError: Restore r1 is unresolved; recover_data_load('/dump') firstIn the resume flow, rollout producers that start writing to train before the load settles fail immediately, and after a RestorePendingError the partition stays closed to KV writers until someone runs recover_data_load.
There was a problem hiding this comment.
Fixed by removing the reservation protocol. Loads now go through the ordinary kv_retrieve_meta path and no longer block other writers.
| if not commit or "failed" in restore["units"].values(): | ||
| restore["aborting"] = True | ||
| for unit, state in restore["units"].items(): | ||
| if state == "pending": | ||
| restore["units"][unit] = "cancelled" | ||
| unfinished = {unit: state for unit, state in restore["units"].items() if state in ("pending", "running")} |
There was a problem hiding this comment.
[AI Review] High: the reservation has no lease, so a lost storage unit makes the partition unusable until the whole TQ system restarts.
Cancellation only moves pending units to cancelled. A unit that claimed permission and then died stays running forever, so recover_data_load(..., cancel=True) keeps raising RestorePendingError; docs/data_dump.md confirms a full restart is required. Until then:
- every KV write and clear on the destination partition is rejected (see the comment on
kv_retrieve_meta); save_checkpointandload_checkpointare rejected for the whole system, because_assert_not_restoring()without a partition matches any restore.
The existing kv_batch_put has no such failure mode: a dead unit fails the call and the caller decides what to do. A convenience restore path should not be able to turn one unit failure into system-wide checkpoint unavailability.
There was a problem hiding this comment.
Removed together with the protocol. A failed unit now fails the load call, as with kv_batch_put, and nothing stays reserved.
| return updates | ||
|
|
||
| @with_storage_unit_socket | ||
| async def _load_selected_rows( |
There was a problem hiding this comment.
[AI Review] High: large loads fail by default on slower shared filesystems.
LOAD_ROWS goes through storage_rpc_pool, whose timeout is TQ_SIMPLE_STORAGE_SEND_RECV_TIMEOUT (200 s by default). A unit reading ~10 GiB from NFS can exceed that, and load_data_by_key then raises RestorePendingError even though every unit is healthy. The caller has to loop over recover_data_load, and REPORT_RESTORE is queued behind the still-running LOAD_ROWS on the unit's single worker thread, so recovery also reports errors until the load finishes. DUMP_ROWS uses the same 200 s timeout on the training critical path.
If the reservation protocol stays, load_data_by_key should wait for the restore to settle itself (for example by polling FINISH_RESTORE) instead of surfacing the ordinary socket timeout, so that recover_data_load is only needed after the initiating process crashes.
There was a problem hiding this comment.
Dump and load requests now use their own socket pool, with timeout TQ_SIMPLE_STORAGE_DUMP_TIMEOUT (3600 s by default), because each unit receives one request covering all of its rows. A timeout no longer leaves anything to recover; the load can simply be retried.
| directory.parent.mkdir(parents=True, exist_ok=True) | ||
| lock_path = directory.with_name(directory.name + ".lock") | ||
| with lock_path.open("a+b") as lock: |
There was a problem hiding this comment.
[AI Review] High: dumps in read-only locations cannot be read or loaded.
_dump_lock creates the parent directory and opens a sibling <dump>.lock in a+b mode, and load_data_by_key also creates <dump>.restore. Resuming from a checkpoint directory without write permission therefore fails before reading anything. Reproduced with a read-only parent directory:
read_row_index(dump) -> PermissionError: [Errno 13] Permission denied: '.../dump.lock'
Read paths also call _recover_dump, which can rename .old back into place, so a read can mutate the filesystem. Reads should not need write access; see also the comment on publication below.
There was a problem hiding this comment.
Fixed. Readers take no lock and create no marker, and a new e2e test loads a dump from a read-only parent directory.
| def _restore_controller_request(self, context: dict, action: str, result: dict | None = None) -> None: | ||
| socket = create_zmq_socket(self.zmq_context, zmq.DEALER, context["controller_ip"]) | ||
| try: | ||
| socket.setsockopt(zmq.RCVTIMEO, 10000) | ||
| socket.setsockopt(zmq.SNDTIMEO, 10000) | ||
| socket.connect(context["controller_address"]) |
There was a problem hiding this comment.
[AI Review] High (design): storage units now make synchronous RPCs to the controller.
Before this PR, units only served requests; only clients and storage managers talked to the controller. Here a unit's single worker thread blocks on a claim RPC and a completion RPC to the controller, with a hard-coded 10 s timeout and a controller address taken from the request body. Together with _restore_results and the claim states on the controller, this is the bulk of the PR's complexity, and it crosses the control-plane/data-plane boundary.
The protection it buys (a late LOAD_ROWS cannot overwrite an index that was cleared and reused after a timeout) applies equally to ordinary put / kv_batch_put, which have the same late-write window and no such protocol. A simpler restore that keeps the existing contract:
kv_retrieve_meta(keys, partition_id, create=True)to resolve or allocate target indexes;LOAD_ROWSon the owner units (keep this new op), returning the field schema of what they wrote, as_load_rowsalready does viaupdates;- the storage manager's existing
notify_data_update, thenset_custom_metafor tags.
Failure semantics then match kv_batch_put: writes may be partial, and retrying the load is idempotent because existing keys keep their indexes. That removes BEGIN/RESTORE_UNIT/FINISH/LIST/REPORT_RESTORE, _restores, _restore_outcomes, _restore_results, _clearing_indexes, the .restore marker, RestorePendingError and recover_data_load, along with most of tests/test_restore_lifecycle.py and tests/e2e/test_restore_timeout_e2e.py. Stronger guarantees, if needed, could be designed in a follow-up together with the regular put path.
There was a problem hiding this comment.
Done as you suggested. Units no longer talk to the controller, and the storage manager publishes metadata through notify_data_update only after every unit succeeded. Late-write fencing is left for a follow-up designed together with the regular put path.
|
|
||
|
|
||
| def _dump_data_by_key(dump_dir: Path, keys: list[str], partition_id: str) -> dict[str, int]: | ||
| from transfer_queue.interface import _TQ_CONTROLLER, _maybe_create_tq_client |
There was a problem hiding this comment.
[AI Review] Low: placement of the public API. These top-level functions import private state from interface.py, while every other public API lives in interface.py. Consider defining the public entry points there and keeping the file-format helpers in this module.
There was a problem hiding this comment.
dump_data_by_key and load_data_by_key now live in interface.py next to the checkpoint API. data_dump.py keeps only the file format.
|
|
||
| # Partition-GlobalIndex management | ||
| self.index_manager = PartitionIndexManager() # partition_id -> global_indexes | ||
| self._restore_lock = RLock() |
There was a problem hiding this comment.
[AI Review] Low: every method that takes _restore_lock runs on the controller's single request thread (none of them is called as a Ray actor method), so the lock protects nothing. It mostly adds re-indentation to mark_clearing, clear_partition, clear_meta, kv_retrieve_meta and save/load_checkpoint. Separately, _restore_outcomes here and _restore_results on the units grow without bound.
There was a problem hiding this comment.
Gone with the protocol.
| continue | ||
| indexes = [idx for _, idx in group] | ||
| existing = list(set(indexes) & partition.global_indexes) | ||
| self._clearing_indexes.update(existing) |
There was a problem hiding this comment.
[AI Review] Low: an index stays in _clearing_indexes if the client fails or exits between MARK_CLEARING and CLEAR_META (for example a CLEAR_META timeout, or a backend whose clear_data raises). begin_restore then rejects the partition with "unfinished clear" until the whole partition is cleared.
There was a problem hiding this comment.
Gone with the protocol.
| for update in updates: | ||
| partition.validate_field_schema(update["field_schema"]) | ||
| for update in updates: | ||
| if not partition.update_production_status(update["global_indexes"], [], update["field_schema"]): | ||
| raise RuntimeError("Controller rejected restored metadata") |
There was a problem hiding this comment.
[AI Review] Low: if a later update_production_status returns False, the earlier updates are already applied. The client then cancels, and the restore is recorded as not committed while part of its metadata is already published.
There was a problem hiding this comment.
Gone with the protocol. Metadata is now published per unit through notify_data_update, as an ordinary put does, and only after every unit has succeeded.
| raise RuntimeError("Partition has an unfinished clear; complete it before restoring") | ||
| partition.validate_field_schema(schema) | ||
| keys = list(rows) | ||
| metadata = self.kv_retrieve_meta(keys, partition_id, create=True) |
There was a problem hiding this comment.
[AI Review] Low: new keys are registered here, before any payload is written, and a cancelled or failed restore does not unregister them, so they show up in kv_list without fields or tags. The docs only say payload writes are not rolled back; this should be documented too, or the keys released on cancel.
There was a problem hiding this comment.
Documented: those keys stay registered, kv_list shows them with empty tags and no readable fields, and a retry reuses their indexes and restores the tags. The e2e failure test asserts this.
|
What will happen if the indexes of dumped keys conflict to the current TQ indexes? |
Drop the restore reservation protocol (BEGIN/RESTORE_UNIT/FINISH/LIST/ REPORT_RESTORE, the .restore marker, RestorePendingError and recover_data_load). A load now resolves target indexes with kv_retrieve_meta(create=True), has the owner units read their records, publishes the saved schemas through notify_data_update once every unit has succeeded, and then writes the tags. Failure semantics match kv_batch_put: payload writes may be partial, and retrying is idempotent because existing keys keep their indexes. Storage units no longer call the controller, and the controller no longer depends on SimpleStorage routing, so the routing helper moves back into the manager. Fencing late writes is left to a follow-up designed together with the regular put path. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
DUMP_ROWS and LOAD_ROWS send one request per storage unit covering all of its selected rows, so their duration grows with the selection size and the shared filesystem rather than with a batch. They used the put/get pool and its 200 s timeout, so a healthy unit reading a large selection from a slow filesystem failed the load. Serve them from a separate pool whose timeout is TQ_SIMPLE_STORAGE_DUMP_TIMEOUT (3600 s by default), leaving the put/get timeout unchanged. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Replacing a dump in place needed a sibling .old backup, recovery on every access, and a cross-node flock on a sibling .lock file. Readers therefore needed write access next to the dump, and a read could rename .old back into place. Stage each dump in a uniquely named sibling directory, write dump_info.json last, and rename it into place once synced. An existing target is refused, so a published dump never changes: loads need no lock, readers need no write access, and of two dumps racing to one path only the first rename succeeds. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Keep only format version 3 and move field packing back into the storage manager. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Storage units report each dumped row's dtype and shape, and the dump merges them with the field types the controller declares. This replaces the missing-shape recovery and leaves FieldMeta and put-time schema extraction unchanged. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Define dump_data_by_key and load_data_by_key in interface.py, next to the
checkpoint API, and keep only the file format in data_dump.py. Make the row
index reader private, return {key: tag} from load_data_by_key, declare the
row dump/load methods on StorageManager instead of probing with hasattr,
drop the unused describe_rows_by_key client methods, and drop the KV put
fallback: dump and load both require SimpleStorage.
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
…mp tests Loading a dump or checkpoint unpickles its files, so both documents now say to load only trusted directories. The dump unit tests construct a real storage unit, and the manager tests run against the manager tq.init builds, instead of bypassing __init__. Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
|
Thanks for the thorough review. I took the simpler restore you suggested and addressed the rest as separate commits on top of the reviewed head:
The PR is now +2325/-2 across 17 files, down from +4416/-232 across 23. On indexes in a dump conflicting with live indexes: they cannot conflict, because load never reuses a dumped index as a target. Target indexes come from Two points from the review summary:
|
0oshowero0
left a comment
There was a problem hiding this comment.
[AI Review] Re-review of 14c1804
Thanks for the quick and thorough rework. I checked every earlier finding against the code rather than the replies, and all of them are addressed: load now follows the ordinary put contract (kv_retrieve_meta(create=True) -> LOAD_ROWS -> notify_data_update / set_custom_meta), units no longer call the controller, readers need no lock or write access, only one format remains, and FieldMeta / extract_field_schema are identical to main.
Checked: python -m compileall -q transfer_queue tutorial tests is clean, and python -m pytest -q tests --ignore=tests/test_yuanrong_storage_client_e2e.py gives 733 passed, 10 skipped. The only commit on main since the PR base touches the mooncake client, so there is no interaction.
On the index-conflict question above: the code matches your answer. Target indexes come from kv_retrieve_meta by key, the dumped index only locates a record in its shard, select_dump_schema remaps nested shapes to the target index, and existing tags are merged per key by BatchMeta.update_custom_meta.
Remaining items:
- The PR description is out of date. The usage example and "What's here" still describe
read_row_index,recover_data_load,RestorePendingError, theBEGIN_RESTORElifecycle andutils/storage_routing.py. The load numbers in the performance table were measured on the previous implementation, and the load path has changed since (see the inline comment onnotify_data_update). Since the description usually becomes the merge commit message, please update it and re-measure load. - Three small inline comments: one doc correction with a reproduction, one performance note, and one unused code path.
| payload writes before a failure remain. Metadata is published only after every unit | ||
| has succeeded, so a failed load leaves those writes invisible. Keys it registered |
There was a problem hiding this comment.
[AI Review] This does not hold for keys that already exist with produced fields.
Those fields are already marked produced in controller metadata, so a unit overwriting them makes the restored values readable immediately, and they stay after the load fails. Reproduced on 14c1804:
put(1) # kv_batch_put "k": x = [1, 1, 1]
tq.dump_data_by_key(dump_dir, ["k"], "p")
put(9) # "k": x = [9, 9, 9]
manager.notify_data_update = failing_notify # raises RuntimeError("controller ACK lost")
tq.load_data_by_key(dump_dir) # raises
tq.kv_batch_get(["k"], "p", ["x"]) # [[1, 1, 1]], not [[9, 9, 9]]The behavior matches kv_batch_put, so only the wording needs fixing, for example: "writes for new keys stay invisible; fields of existing keys are overwritten in place". Otherwise a reader may conclude that a failed load left existing keys untouched and skip the retry. The same sentence is in the load_rows_by_index docstring in simple_storage_manager.py.
There was a problem hiding this comment.
Thanks for the reproduction, confirmed on 14c1804: the existing key read back [1, 1, 1] after the failed load. Fixed in 5160e32. docs/data_dump.md, the load_rows_by_index docstring and the load_data_by_key docstring now say that writes to new keys stay invisible after a failure, while fields an existing key has already produced are overwritten in place and readable at once, so a failed load has to be retried or its keys cleared.
| for result in results: | ||
| for update in result["updates"]: | ||
| await self.notify_data_update(partition_id, update["global_indexes"], update["field_schema"]) |
There was a problem hiding this comment.
[AI Review] Low: metadata is published with one sequential round trip per update.
Units return one update per shard, 128-row batch and field set, so a cross-topology load produces at least one update per (source shard, target unit) pair, for example about 1,000 sequential notify_data_update calls for a 32 -> 32 restore. The previous implementation committed in one step, so this may change the load numbers in the description. Worth checking when re-measuring; if it shows up, issuing the calls concurrently or merging updates that share a field set would bring it down.
There was a problem hiding this comment.
Agreed. 41a30d7 issues the updates with asyncio.gather. The notify socket pool still bounds how many are in flight, so this does not open a socket per update. A new test only passes if the updates run concurrently.
I kept one update per (shard, unit, batch, field set) rather than merging them, because this phase no longer shows up in local measurements (single node, so a lower bound on the multi-node cost):
- 8 → 8 units, 16,384 rows (1.5 GiB): the 128 updates take about 0.03 s of a 0.35 s load.
- 65,536 small rows (512 updates): load went from 1.00 s with sequential updates to 0.88 s with concurrent ones.
If it shows up on a multi-node run, merging updates that share a field set is the next step.
| described_fields = request.body.get("fields_by_index") | ||
| if described_fields is None: |
There was a problem hiding this comment.
[AI Review] Low: the "raw dump" mode has no caller. dump_data_by_key always passes fields_by_index; only a mocked test in test_data_dump_e2e.py omits it. This branch and the optional fields_by_index parameter in StorageManager.dump_rows_by_index, AsyncSimpleStorageManager.dump_rows_by_index / _dump_single_shard and the client's dump_rows_by_index can be removed, making the field list required.
There was a problem hiding this comment.
Removed in eb51033. fields_by_index is now required through the storage unit, both managers and the client. Once it was required, global_indexes only repeated its keys, so I dropped that parameter too. The unit tests that relied on raw mode now pass explicit fields.
|
|
||
| # ==================== Selective Data Dump API ==================== | ||
| @with_controller_socket | ||
| async def async_describe_data_dump( |
There was a problem hiding this comment.
describe_data_dump isn't really dump-specific: it is a per-key metadata lookup (key → global_index, the fields each row has produced, and its tag) plus the declared types of those fields. Only the client-side name ties it to dump; the controller method is already the generic describe_rows_by_key.
Suggest naming it for what it does and putting it in the KV family, e.g. kv_describe(keys, partition_id) with a KV_DESCRIBE request type, and moving the controller method out of the "Selective Data Dump API" section. That also fixes the argument order, which is currently (partition_id, keys) while the other kv_* APIs take (keys, partition_id).
I don't think it should be folded into kv_retrieve_meta, though: BatchMeta is columnar, so it only keeps the fields every requested row has produced, and it silently drops missing keys. Per-row field sets and a hard error on missing keys are exactly what this lookup adds. I'd also keep its scope as-is (no extra dtype/shape/consumption options) until there is a second caller.
There was a problem hiding this comment.
Done in 48b9227: TransferQueueClient.kv_describe(keys, partition_id) / async_kv_describe with a KV_DESCRIBE request type. The controller's kv_describe now sits with the other KV methods and also returns the declared field types, which the handler used to compute. As you suggested, the scope is unchanged and it is not folded into kv_retrieve_meta. I have also not exported it as a top-level tq. function for now.
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Read independently encoded rows without scanning unrelated shard data.""" |
There was a problem hiding this comment.
dump_io.py has a single caller, SimpleStorageUnit._handle_load_rows, and the code that writes these same records (the {"global_index", "fields"} layout and the [offset, length] bookkeeping) is already inline in _handle_dump_rows. Suggest moving these three functions into simple_storage.py next to the two handlers and deleting this module, so a shard's record format is written and read in one place. That also matches the existing SimpleStorage checkpoint, whose format already lives in simple_storage.py.
There was a problem hiding this comment.
Done in 8035fb8. The three readers now live in simple_storage.py next to _handle_dump_rows, and dump_io.py is deleted.
| shards = [] | ||
| seen = set() | ||
| for record in shard_records: | ||
| path = shard_dir / f"shard_{record['position']}_{record['storage_unit_id']}.pkl" |
There was a problem hiding this comment.
The shard file name is built twice: here and in AsyncSimpleStorageManager.dump_rows_by_index (shard_{pos}_{su_id}.pkl), so the two have to be kept in sync by hand. Suggest having the manager record the file name in each shard_info entry and reading it here instead of rebuilding it.
There was a problem hiding this comment.
Done in be2e50e. The manager records file in each shard_info.json entry and read_dump uses it. position only existed to build the name, so it is gone. The format stays at version 3 because it has not shipped.
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
Signed-off-by: OutstanderWang <wangweiyanster@gmail.com>
|
Thanks for the re-review. Each of the six inline comments is addressed in its own commit and answered in its thread:
Follow-up commits:
I also rewrote the PR description for the current design. PerformanceI don't have access to the 64-GPU job right now, so the description drops the old load numbers and labels the remaining save numbers as coming from an earlier revision. Here is a local V1 vs V2 comparison instead: 18 cores, 48 GB RAM, 8 storage units, 96 KiB rows, median of 3 runs.
The 1.50 GiB column shows the range over two separate runs. Caveats:
Checks
|
Summary
This PR adds a way to save a chosen set of keys from TransferQueue and later merge them back into a running deployment:
tq.dump_data_by_key(dump_dir, keys, partition_id)saves the selected keys, all fields produced for them, and their tags.tq.load_data_by_key(dump_dir)merges those rows back into the running system, possibly onto a different number of storage units.The controller handles only metadata. All payload bytes are written and read by the storage units that own them, in parallel, and never pass through the client. Load uses the ordinary put contract:
kv_retrieve_meta(create=True)resolves target indexes, the owner units write the rows, thennotify_data_updateandset_custom_metapublish metadata.Motivation
The existing checkpoint saves and restores the whole system.
save_checkpoint/load_checkpointcapture the entire controller (every partition, the global index manager and the sampler) plus each storage unit's full contents. On load, the storage-unit count must match and the system must be in a clean state. That is right for restarting a whole job. It is the wrong tool for "put these N samples back into the TransferQueue I am already running".Partial rollout needs exactly that. With partial or streaming rollout, rollout samples that are unfinished or not yet consumed live only in TransferQueue when the trainer takes a checkpoint. If they are lost, resuming throws away generation work that was already paid for. So at every checkpoint the trainer must save exactly those keys next to its model checkpoint, and on resume it must put them back:
The save also runs on the training critical path at every checkpoint, so it has to be cheap.
The current workaround is slow because all payloads go through one process. The workaround (called V1 below) calls
kv_batch_geton the client and serializes the result to disk, then restores withkv_batch_put. Every payload byte travels from the storage units to the client and is serialized by a single process, and restore reverses this.This PR (V2) keeps the client on the metadata path only. Each owner storage unit writes its own rows during a dump and reads only its own rows during a load, and all units work in parallel.
Usage
How
load_data_by_keymerges into the running system:dump_dirmust be on a filesystem that every storage unit can reach (any local directory works on a single node). Loading unpickles the dump, so only load directories you trust.What's here
Public API:
dump_data_by_keyandload_data_by_keyintransfer_queue/interface.py, exported fromtransfer_queue. The dump format lives intransfer_queue/data_dump.py.Controller (control plane only)
KV_DESCRIBE(TransferQueueClient.kv_describe(keys, partition_id)): each key's global index, its own produced fields and tag, plus the declared types of those fields. Unlikekv_retrieve_meta, rows keep their own field sets and a missing key raises instead of being dropped.VALIDATE_DUMP_SCHEMArejects destination type conflicts before any payload is written.Storage manager and SimpleStorage units (data plane)
utils/compact_pickle.py), so a row taken from a batch does not drag the rest of that batch's storage into the file.DUMP_ROWSandLOAD_ROWSuse their own socket pool withTQ_SIMPLE_STORAGE_DUMP_TIMEOUT(3600 s by default), because each unit receives one request covering all of its rows. Both are added to the storage operation metrics.NotImplementedErrorbefore any key is registered.Format (
format_version: 3, the only version)dump_info.json,row_index.pt(keys, fields, tags, field schema) andshards/shard_info.json(each shard's file name, owner unit and per-row[offset, length]), plus the shard files.dump_diris refused. Readers need no lock and no write access.Failure behavior: the same as
kv_batch_put, so load is not transactional. Writes to new keys stay invisible after a failure, but fields that existing keys have already produced are overwritten in place. Every key keeps its index, so retrying the same load is idempotent.Docs: new
docs/data_dump.md;docs/checkpoint.mdpoints to it and states the trust boundary for both formats.Performance
Real job (earlier revision). A VLM RL training job with partial (streaming) rollout on 64 H20 GPUs. V1 is the client-side path described in Motivation.
These save numbers come from an earlier revision; the dump data path is the same since (owner units write per-row shard records directly). The load path has changed since that run, so its old numbers were removed; load will be re-measured on the same job when the hardware is available.
Local, this head. Single node (18 cores, 48 GB), 8 storage units, rows of 96 KiB (8192-token int64 ids + fp32 log-probs) with a tag. Median of 3 runs; ranges cover two separate runs of the 1.5 GiB case. Every load was checked for payload and tags.
kv_batch_get+torch.save)dump_data_by_key)torch.load+kv_batch_put)load_data_by_key)Notes on the local numbers:
fsyncdoes not force a full flush, so these compare V1 and V2 on one machine; they are not a substitute for the multi-node run.pickle.loadsof the per-row tensors. That is why the gap narrows for 8 → 4 units at 1.5 GiB.Testing
python -m compileall -q transfer_queue tutorial tests: clean.pre-commit run --all-files(ruff, ruff-format, mypy): passed. Docstring and license sanity checks pass.python -m pytest -q tests --ignore=tests/test_yuanrong_storage_client_e2e.py: 729 passed, 10 skipped.tests/test_data_dump.py,tests/e2e/test_data_dump_e2e.py,tests/e2e/test_data_dump_cross_topology_e2e.py. They cover:tq.initbuilds.