feat(rollout): add TransferQueue trajectory ingestion for closed-loop VIME training#320
feat(rollout): add TransferQueue trajectory ingestion for closed-loop VIME training#320aoshen02 wants to merge 2 commits into
Conversation
… VIME training - Add TransferQueue trajectory source support in RolloutManager - Keep normal VIME rollout-engine ownership with --tq-manage-rollout-servers - Decode vLLM trajectory artifacts into native VIME Samples - Add rollout dispatch and TransferQueue service helper scripts - Document the closed-loop artifact-transfer flow - Add focused tests for TQ args, consumers, trajectory conversion, and dispatch
There was a problem hiding this comment.
Code Review
This pull request integrates TransferQueue as a new rollout trajectory source for VIME, adding service scripts, a rollout dispatcher, and consumer-side trajectory parsers. The review feedback highlights several important issues: a tensor unwrapping bug in trajectory_artifact.py that causes errors for 1D tensors of length 1, potential resource leaks in rollout_dispatcher.py due to unhandled task cancellation in asyncio.gather, incorrect usage of kv_batch_get in transfer_queue_consumer.py, unhandled StopIteration exceptions in trajectory_source.py, and hardcoded user-specific absolute paths in start_services.sh and arguments.py that hinder portability.
| if value.ndim > 0 and value.shape[0] == 1: | ||
| return value[0] |
There was a problem hiding this comment.
The condition value.ndim > 0 incorrectly unwraps 1D tensors of shape (1,) into 0D scalar tensors. When _vector is subsequently called on this 0D tensor, it raises a ValueError because tensor.ndim != 1. Changing the condition to value.ndim > 1 ensures that 1D sequence tensors of length 1 are preserved as 1D vectors, while only batch dimensions of size 1 (e.g., shape (1, N)) are unwrapped.
| if value.ndim > 0 and value.shape[0] == 1: | |
| return value[0] | |
| if value.ndim > 1 and value.shape[0] == 1: | |
| return value[0] |
| tasks = [ | ||
| self._dispatch_one(client, semaphore, prompt, sample_index) | ||
| for prompt in prompts | ||
| for sample_index in range(self.config.n_samples_per_prompt) | ||
| ] | ||
| try: | ||
| return await asyncio.gather(*tasks) | ||
| finally: | ||
| if self._owns_client: | ||
| await client.aclose() | ||
| self._client = None |
There was a problem hiding this comment.
When asyncio.gather is called with raw coroutines, any exception raised by one task will propagate immediately, but the remaining coroutines will continue running in the background. When the finally block executes and closes the client, these background tasks will fail with closed-client errors or leak resources. Wrapping the coroutines in asyncio.create_task and explicitly cancelling any pending tasks in an except block prevents this.
| tasks = [ | |
| self._dispatch_one(client, semaphore, prompt, sample_index) | |
| for prompt in prompts | |
| for sample_index in range(self.config.n_samples_per_prompt) | |
| ] | |
| try: | |
| return await asyncio.gather(*tasks) | |
| finally: | |
| if self._owns_client: | |
| await client.aclose() | |
| self._client = None | |
| tasks = [ | |
| asyncio.create_task( | |
| self._dispatch_one(client, semaphore, prompt, sample_index) | |
| ) | |
| for prompt in prompts | |
| for sample_index in range(self.config.n_samples_per_prompt) | |
| ] | |
| try: | |
| return await asyncio.gather(*tasks) | |
| except Exception: | |
| for task in tasks: | |
| if not task.done(): | |
| task.cancel() | |
| raise | |
| finally: | |
| if self._owns_client: | |
| await client.aclose() | |
| self._client = None |
| fields = self._tq.kv_batch_get( | ||
| keys=key, | ||
| partition_id=partition, | ||
| select_fields=self.config.data_fields, | ||
| ) | ||
| tags = self._tq.kv_list(partition) | ||
| tag = tags[partition][key] | ||
| return TrajectoryArtifactV1Alpha1.from_transfer_queue( | ||
| fields, | ||
| tag, | ||
| ) |
There was a problem hiding this comment.
Since kv_batch_get is a batch operation, the keys parameter likely expects an iterable of keys (e.g., [key]) rather than a single string key. Passing a string directly can cause Python to iterate over the characters of the string. Furthermore, if kv_batch_get returns a dictionary mapping keys to their respective fields, you should extract the fields for the specific key using fields[key].
| fields = self._tq.kv_batch_get( | |
| keys=key, | |
| partition_id=partition, | |
| select_fields=self.config.data_fields, | |
| ) | |
| tags = self._tq.kv_list(partition) | |
| tag = tags[partition][key] | |
| return TrajectoryArtifactV1Alpha1.from_transfer_queue( | |
| fields, | |
| tag, | |
| ) | |
| fields_dict = self._tq.kv_batch_get( | |
| keys=[key], | |
| partition_id=partition, | |
| select_fields=self.config.data_fields, | |
| ) | |
| fields = fields_dict[key] | |
| tags = self._tq.kv_list(partition) | |
| tag = tags[partition][key] | |
| return TrajectoryArtifactV1Alpha1.from_transfer_queue( | |
| fields, | |
| tag, | |
| ) |
| return [ | ||
| trajectory_to_sample( | ||
| next(self._iterator), | ||
| require_reward=self.require_reward, | ||
| ) | ||
| for _ in range(count) | ||
| ] |
There was a problem hiding this comment.
Calling next(self._iterator) inside a list comprehension can propagate a raw StopIteration (or a generic RuntimeError under PEP 479) if the stream runs out of elements. It is much safer and more informative to catch StopIteration explicitly and raise a descriptive RuntimeError indicating that the stream was exhausted prematurely.
| return [ | |
| trajectory_to_sample( | |
| next(self._iterator), | |
| require_reward=self.require_reward, | |
| ) | |
| for _ in range(count) | |
| ] | |
| samples = [] | |
| for _ in range(count): | |
| try: | |
| artifact = next(self._iterator) | |
| except StopIteration: | |
| raise RuntimeError( | |
| f"TransferQueue trajectory stream exhausted prematurely; " | |
| f"expected {count} samples, but only got {len(samples)}." | |
| ) from None | |
| samples.append( | |
| trajectory_to_sample( | |
| artifact, | |
| require_reward=self.require_reward, | |
| ) | |
| ) | |
| return samples |
| VENV="${VIME_VENV:-/mnt/data1/yibo/vime-workspace/.venv}" | ||
| SERVICE_DIR="${TQ_SERVICE_DIR:-/mnt/data1/yibo/vime-workspace/services/transfer_queue}" | ||
| RAY_NODE_IP="${RAY_NODE_IP:-172.16.1.248}" | ||
| RAY_GCS_PORT="${RAY_GCS_PORT:-6380}" | ||
| RAY_CLIENT_PORT="${RAY_CLIENT_PORT:-20001}" | ||
| RAY_ADDRESS="${RAY_NODE_IP}:${RAY_GCS_PORT}" | ||
| PID_FILE="${SERVICE_DIR}/transfer_queue.pid" | ||
| LOG_FILE="${SERVICE_DIR}/transfer_queue.log" | ||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| SERVICE_SCRIPT="${SCRIPT_DIR}/tq_service.py" | ||
| EXPORT_SCRIPT="${SCRIPT_DIR}/export_config.py" | ||
| CLIENT_CONFIG="${SERVICE_DIR}/client_config.pkl" |
There was a problem hiding this comment.
The script hardcodes absolute paths to a specific user's home/data directory (/mnt/data1/yibo/vime-workspace/...). This makes the script completely non-portable and fragile across different environments or users. Defining SCRIPT_DIR and REPO_ROOT first allows resolving the defaults dynamically.
| VENV="${VIME_VENV:-/mnt/data1/yibo/vime-workspace/.venv}" | |
| SERVICE_DIR="${TQ_SERVICE_DIR:-/mnt/data1/yibo/vime-workspace/services/transfer_queue}" | |
| RAY_NODE_IP="${RAY_NODE_IP:-172.16.1.248}" | |
| RAY_GCS_PORT="${RAY_GCS_PORT:-6380}" | |
| RAY_CLIENT_PORT="${RAY_CLIENT_PORT:-20001}" | |
| RAY_ADDRESS="${RAY_NODE_IP}:${RAY_GCS_PORT}" | |
| PID_FILE="${SERVICE_DIR}/transfer_queue.pid" | |
| LOG_FILE="${SERVICE_DIR}/transfer_queue.log" | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| SERVICE_SCRIPT="${SCRIPT_DIR}/tq_service.py" | |
| EXPORT_SCRIPT="${SCRIPT_DIR}/export_config.py" | |
| CLIENT_CONFIG="${SERVICE_DIR}/client_config.pkl" | |
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | |
| REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" | |
| VENV="${VIME_VENV:-${REPO_ROOT}/.venv}" | |
| SERVICE_DIR="${TQ_SERVICE_DIR:-${REPO_ROOT}/services/transfer_queue}" | |
| RAY_NODE_IP="${RAY_NODE_IP:-172.16.1.248}" | |
| RAY_GCS_PORT="${RAY_GCS_PORT:-6380}" | |
| RAY_CLIENT_PORT="${RAY_CLIENT_PORT:-20001}" | |
| RAY_ADDRESS="${RAY_NODE_IP}:${RAY_GCS_PORT}" | |
| PID_FILE="${SERVICE_DIR}/transfer_queue.pid" | |
| LOG_FILE="${SERVICE_DIR}/transfer_queue.log" | |
| SERVICE_SCRIPT="${SCRIPT_DIR}/tq_service.py" | |
| EXPORT_SCRIPT="${SCRIPT_DIR}/export_config.py" | |
| CLIENT_CONFIG="${SERVICE_DIR}/client_config.pkl" |
| parser.add_argument( | ||
| "--tq-service-config-path", | ||
| type=str, | ||
| default=( | ||
| "/mnt/data1/yibo/vime-workspace/services/" | ||
| "transfer_queue/client_config.pkl" | ||
| ), | ||
| ) |
There was a problem hiding this comment.
The default path for --tq-service-config-path is hardcoded to a specific user's directory (/mnt/data1/yibo/...). To make the configuration portable, resolve the default path dynamically relative to the repository root.
parser.add_argument(
"--tq-service-config-path",
type=str,
default=os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
"services",
"transfer_queue",
"client_config.pkl",
),
)
Documentation build overview
42 files changed ·
|
Summary
Duplicate-work check
aoshen02:feat/tq-artifact-consumer: none found.huangyibo:feat/tq-artifact-consumer: none found in this repo.tq artifact consumer: no matching duplicate found.Tests
AI assistance