Compare commits

..

5 Commits

Author SHA1 Message Date
Ahmet Faruk GÜMÜŞTAŞ 1fe58f2d3a fix(train): guard checkpoint saving against save_freq=0 (#4112)
is_saving_step was the only step-frequency check without a `> 0` guard,
so save_freq=0 raised ZeroDivisionError from `step % 0` on the first
step. Route the decision through a should_save_checkpoint helper that
treats a non-positive save_freq as "save only the final checkpoint",
matching how log_freq/eval_freq handle non-positive values.

Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
2026-07-30 17:56:56 +02:00
Minseo Kim bd2a796217 test(datasets): guard batched-encoding video staging from post-save cleanup (#4110)
The discard-path fix (#3683) deletes staging for all camera_keys in
clear_episode_buffer(). The post-save cleanup in save_episode() must keep
iterating image_keys only: with batch_encoding_size > 1 the video staging
frames of already-saved episodes stay on disk until the batch encoder
consumes and deletes them. Add a regression test pinning that behavior,
plus a comment explaining why the two paths differ.

Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
2026-07-30 17:56:40 +02:00
Xingdong Zuo 4c302572c0 feat(lekiwi): stream observations as ZMQ multipart (-25% bandwidth) (#4088)
Observations were sent as base64-in-JSON, which inflates every camera
frame 33% purely to fit binary JPEG inside a text protocol (~11 Mbps at
3 cameras x 30 Hz). Send a ZMQ multipart message instead: a JSON header
frame (state + camera order) followed by one raw JPEG frame per camera.

Benchmarked on hardware: -25% wire size (invariant across contention),
lower and tighter latency, and no dropped frames under load where the
base64 format stalled.

ZMQ_CONFLATE does not support multipart, so the observation sockets use
2-deep high-water marks; the client's existing drain-to-latest loop
preserves the keep-newest behavior.

Breaking wire change: host and client must run the same version.

Co-authored-by: Xingdong Zuo <18168681+zuoxingdong@users.noreply.github.com>
Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
2026-07-30 17:32:11 +02:00
Caroline Pascal 72a1858015 fix(RGB only): remove the stereo module fallback when setting colors parameters on RealSense cameras (#4225)
* fix(RGB only): remove the stereo module fallback when setting RGB/color parameters to avoid unexpected impacts on depth sensing

* chore(format)
2026-07-30 17:31:56 +02:00
Xingdong Zuo 2b578e68f6 fix(lekiwi): pin MJPG capture in the default camera config (#4083)
Without an explicit fourcc, OpenCV's V4L2 auto-negotiation selects
uncompressed YUYV when the camera offers it: ~147 Mbps per camera at
640x480@30 on the Pi's shared USB 2.0 bus, versus ~9 Mbps as MJPG for
identical frames. Two default cameras already exceed the bus, so frame
rates sag silently.

Pinning fourcc="MJPG" is backward compatible: it is an existing
OpenCVCameraConfig field, frames still arrive as BGR ndarrays, user
configs that set their own fourcc are unaffected, and a camera without
MJPG fails loudly at validation rather than degrading silently.

Co-authored-by: Xingdong Zuo <18168681+zuoxingdong@users.noreply.github.com>
Co-authored-by: Steven Palma <imstevenpmwork@ieee.org>
2026-07-30 17:21:07 +02:00
14 changed files with 143 additions and 69 deletions
+2 -2
View File
@@ -164,8 +164,8 @@ includes the range reported by the sensor. Requesting an unsupported control als
Omitted controls leave the sensor's existing automatic or manual setting unchanged. These options Omitted controls leave the sensor's existing automatic or manual setting unchanged. These options
require `use_rgb=True`. require `use_rgb=True`.
On the RealSense D405, the color stream is provided by the Stereo Module, so changing manual Manual color controls require a dedicated RGB module. Cameras without one, such as the RealSense
exposure or gain also affects the depth stream. D405, do not support them and raise an error at connection time.
</hfoption> </hfoption>
</hfoptions> </hfoptions>
@@ -365,11 +365,12 @@ class RealSenseCamera(Camera):
return self._async_read(timeout_ms=10000, read_depth=read_depth) return self._async_read(timeout_ms=10000, read_depth=read_depth)
def _get_color_sensor(self) -> "rs.sensor": def _get_color_sensor(self) -> "rs.sensor":
"""Returns the sensor that controls the color stream. """Returns the dedicated "RGB Camera" sensor that controls the color stream.
Most RealSense cameras expose "RGB Camera" for color. The D405 has no Manual color controls are only applied to a dedicated RGB module. Cameras
separate RGB module — its color stream comes from "Stereo Module". without one (e.g. the D405, whose color stream comes from the shared
We try RGB Camera first, then fall back to Stereo Module. "Stereo Module") are unsupported, so we never fall back to another sensor
to avoid altering the depth stream.
""" """
if self.rs_profile is None: if self.rs_profile is None:
raise RuntimeError(f"{self}: rs_profile must be initialized before use.") raise RuntimeError(f"{self}: rs_profile must be initialized before use.")
@@ -377,12 +378,14 @@ class RealSenseCamera(Camera):
device = self.rs_profile.get_device() device = self.rs_profile.get_device()
sensors = {s.get_info(rs.camera_info.name): s for s in device.query_sensors()} sensors = {s.get_info(rs.camera_info.name): s for s in device.query_sensors()}
for name in ("RGB Camera", "Stereo Module"): if "RGB Camera" in sensors:
if name in sensors: return sensors["RGB Camera"]
return sensors[name]
available = list(sensors.keys()) available = list(sensors.keys())
raise RuntimeError(f"{self}: no color sensor found. Available sensors: {available}") raise RuntimeError(
f"{self}: manual color controls require a dedicated 'RGB Camera' module, which this camera does not have. ",
f"Available sensors: {available}.",
)
def _set_sensor_option(self, sensor: "rs.sensor", option: "rs.option", value: float, label: str) -> None: def _set_sensor_option(self, sensor: "rs.sensor", option: "rs.option", value: float, label: str) -> None:
"""Sets a sensor option, re-raising range errors with actionable diagnostics.""" """Sets a sensor option, re-raising range errors with actionable diagnostics."""
+11
View File
@@ -52,6 +52,17 @@ def get_step_checkpoint_dir(output_dir: Path, total_steps: int, step: int) -> Pa
return output_dir / CHECKPOINTS_DIR / step_identifier return output_dir / CHECKPOINTS_DIR / step_identifier
def should_save_checkpoint(step: int, save_freq: int, total_steps: int) -> bool:
"""Whether a checkpoint should be saved at ``step``.
A checkpoint is saved every ``save_freq`` steps and always after the final step. A
non-positive ``save_freq`` disables periodic saving (only the final checkpoint is
written), mirroring how ``log_freq``/``eval_freq`` treat non-positive values and
avoiding a ``ZeroDivisionError`` from ``step % 0``.
"""
return (save_freq > 0 and step % save_freq == 0) or step == total_steps
def save_training_step( def save_training_step(
step: int, save_dir: Path, num_processes: int | None = None, batch_size: int | None = None step: int, save_dir: Path, num_processes: int | None = None, batch_size: int | None = None
) -> None: ) -> None:
+1
View File
@@ -119,6 +119,7 @@ class TrainPipelineConfig(HubMixin):
tolerance_s: float = 1e-4 tolerance_s: float = 1e-4
save_checkpoint: bool = True save_checkpoint: bool = True
# Checkpoint is saved every `save_freq` training iterations and after the last training step. # Checkpoint is saved every `save_freq` training iterations and after the last training step.
# A non-positive value disables periodic saving, keeping only the final checkpoint.
save_freq: int = 20_000 save_freq: int = 20_000
use_policy_training_preset: bool = True use_policy_training_preset: bool = True
optimizer: OptimizerConfig | None = None optimizer: OptimizerConfig | None = None
+3
View File
@@ -386,6 +386,9 @@ class DatasetWriter:
self._episodes_since_last_encoding = 0 self._episodes_since_last_encoding = 0
if episode_data is None: if episode_data is None:
# Post-save cleanup deliberately does not go through clear_episode_buffer():
# staging frames of video cameras must survive here — the (possibly batched)
# encoder still needs them and deletes them once each video is written.
if len(self._meta.image_keys) > 0: if len(self._meta.image_keys) > 0:
self._delete_camera_frame_dirs(self._meta.image_keys) self._delete_camera_frame_dirs(self._meta.image_keys)
self.episode_buffer = self._create_episode_buffer() self.episode_buffer = self._create_episode_buffer()
@@ -15,13 +15,11 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
from lerobot.configs.policies import PreTrainedConfig from lerobot.configs.policies import PreTrainedConfig
from lerobot.configs.types import FeatureType, NormalizationMode, PolicyFeature from lerobot.configs.types import NormalizationMode
from lerobot.optim.optimizers import AdamWConfig from lerobot.optim.optimizers import AdamWConfig
from lerobot.optim.schedulers import CosineDecayWithWarmupSchedulerConfig from lerobot.optim.schedulers import CosineDecayWithWarmupSchedulerConfig
from lerobot.utils.constants import OBS_STATE
@PreTrainedConfig.register_subclass("vla_jepa") @PreTrainedConfig.register_subclass("vla_jepa")
@@ -124,13 +122,6 @@ class VLAJEPAConfig(PreTrainedConfig):
if self.robot_state_feature is not None: if self.robot_state_feature is not None:
self.state_dim = self.robot_state_feature.shape[0] self.state_dim = self.robot_state_feature.shape[0]
def set_dataset_feature_metadata(self, dataset_features: dict[str, Any]) -> None:
"""Add `observation.state` to `input_features` if missing, so it gets normalized."""
if OBS_STATE in self.input_features or OBS_STATE not in dataset_features:
return
shape = tuple(dataset_features[OBS_STATE]["shape"])
self.input_features[OBS_STATE] = PolicyFeature(type=FeatureType.STATE, shape=shape)
def get_optimizer_preset(self) -> AdamWConfig: def get_optimizer_preset(self) -> AdamWConfig:
return AdamWConfig( return AdamWConfig(
lr=self.optimizer_lr, lr=self.optimizer_lr,
@@ -399,8 +399,7 @@ class VLAJEPAPolicy(PreTrainedPolicy):
state = batch.get(OBS_STATE) state = batch.get(OBS_STATE)
if state is not None: if state is not None:
if state.ndim > 2: if state.ndim > 2:
# deltas are forward-looking here, so index 0 is the current observation, not -1. state = state[:, -1, :]
state = state[:, 0, :]
inputs["state"] = (state.unsqueeze(1) if state.ndim == 2 else state).float() # [B, 1, dim] inputs["state"] = (state.unsqueeze(1) if state.ndim == 2 else state).float() # [B, 1, dim]
return inputs return inputs
+12 -2
View File
@@ -23,10 +23,20 @@ from ..config import RobotConfig
def lekiwi_cameras_config() -> dict[str, CameraConfig]: def lekiwi_cameras_config() -> dict[str, CameraConfig]:
return { return {
"front": OpenCVCameraConfig( "front": OpenCVCameraConfig(
index_or_path="/dev/video0", fps=30, width=640, height=480, rotation=Cv2Rotation.ROTATE_180 index_or_path="/dev/video0",
fps=30,
width=640,
height=480,
fourcc="MJPG",
rotation=Cv2Rotation.ROTATE_180,
), ),
"wrist": OpenCVCameraConfig( "wrist": OpenCVCameraConfig(
index_or_path="/dev/video2", fps=30, width=480, height=640, rotation=Cv2Rotation.ROTATE_90 index_or_path="/dev/video2",
fps=30,
width=480,
height=640,
fourcc="MJPG",
rotation=Cv2Rotation.ROTATE_90,
), ),
} }
+30 -30
View File
@@ -14,7 +14,6 @@
# TODO(aliberts, Steven, Pepijn): use gRPC calls instead of zmq? # TODO(aliberts, Steven, Pepijn): use gRPC calls instead of zmq?
import base64
import json import json
import logging import logging
from functools import cached_property from functools import cached_property
@@ -134,7 +133,9 @@ class LeKiwiClient(Robot):
self.zmq_observation_socket = self.zmq_context.socket(zmq.PULL) self.zmq_observation_socket = self.zmq_context.socket(zmq.PULL)
zmq_observations_locator = f"tcp://{self.remote_ip}:{self.port_zmq_observations}" zmq_observations_locator = f"tcp://{self.remote_ip}:{self.port_zmq_observations}"
self.zmq_observation_socket.connect(zmq_observations_locator) self.zmq_observation_socket.connect(zmq_observations_locator)
self.zmq_observation_socket.setsockopt(zmq.CONFLATE, 1) # CONFLATE does not support multipart messages; a small receive queue plus
# the existing drain-to-latest loop keeps newest-only semantics.
self.zmq_observation_socket.setsockopt(zmq.RCVHWM, 2)
poller = zmq.Poller() poller = zmq.Poller()
poller.register(self.zmq_observation_socket, zmq.POLLIN) poller.register(self.zmq_observation_socket, zmq.POLLIN)
@@ -147,8 +148,8 @@ class LeKiwiClient(Robot):
def calibrate(self) -> None: def calibrate(self) -> None:
pass pass
def _poll_and_get_latest_message(self) -> str | None: def _poll_and_get_latest_message(self) -> list[bytes] | None:
"""Polls the ZMQ socket for a limited time and returns the latest message string.""" """Polls the ZMQ socket for a limited time and returns the latest message's frames."""
zmq = self._zmq zmq = self._zmq
poller = zmq.Poller() poller = zmq.Poller()
poller.register(self.zmq_observation_socket, zmq.POLLIN) poller.register(self.zmq_observation_socket, zmq.POLLIN)
@@ -166,7 +167,7 @@ class LeKiwiClient(Robot):
last_msg = None last_msg = None
while True: while True:
try: try:
msg = self.zmq_observation_socket.recv_string(zmq.NOBLOCK) msg = self.zmq_observation_socket.recv_multipart(zmq.NOBLOCK)
last_msg = msg last_msg = msg
except zmq.Again: except zmq.Again:
break break
@@ -176,28 +177,27 @@ class LeKiwiClient(Robot):
return last_msg return last_msg
def _parse_observation_json(self, obs_string: str) -> RobotObservation | None: def _parse_observation(self, frames: list[bytes]) -> RobotObservation | None:
"""Parses the JSON observation string.""" """Parses a multipart observation: JSON header + one raw JPEG frame per camera."""
try: try:
return json.loads(obs_string) header = json.loads(frames[0])
except json.JSONDecodeError as e: cam_names = header.pop("_cams")
logging.error(f"Error decoding JSON observation: {e}") observation: RobotObservation = header
for cam_name, jpeg in zip(cam_names, frames[1:], strict=True):
observation[cam_name] = jpeg
return observation
except (json.JSONDecodeError, KeyError, ValueError) as e:
logging.error(f"Error decoding observation: {e}")
return None return None
def _decode_image_from_b64(self, image_b64: str) -> np.ndarray | None: def _decode_image(self, jpeg: bytes) -> np.ndarray | None:
"""Decodes a base64 encoded image string to an OpenCV image.""" """Decodes a raw JPEG buffer to an OpenCV image."""
if not image_b64: if not jpeg:
return None
try:
jpg_data = base64.b64decode(image_b64)
np_arr = np.frombuffer(jpg_data, dtype=np.uint8)
frame = cv2.imdecode(np_arr, cv2.IMREAD_COLOR)
if frame is None:
logging.warning("cv2.imdecode returned None for an image.")
return frame
except (TypeError, ValueError) as e:
logging.error(f"Error decoding base64 image data: {e}")
return None return None
frame = cv2.imdecode(np.frombuffer(jpeg, dtype=np.uint8), cv2.IMREAD_COLOR)
if frame is None:
logging.warning("cv2.imdecode returned None for an image.")
return frame
def _remote_state_from_obs( def _remote_state_from_obs(
self, observation: RobotObservation self, observation: RobotObservation
@@ -212,10 +212,10 @@ class LeKiwiClient(Robot):
# Decode images # Decode images
current_frames: dict[str, np.ndarray] = {} current_frames: dict[str, np.ndarray] = {}
for cam_name, image_b64 in observation.items(): for cam_name, jpeg in observation.items():
if cam_name not in self._cameras_ft: if cam_name not in self._cameras_ft:
continue continue
frame = self._decode_image_from_b64(image_b64) frame = self._decode_image(jpeg)
if frame is not None: if frame is not None:
current_frames[cam_name] = frame current_frames[cam_name] = frame
@@ -230,15 +230,15 @@ class LeKiwiClient(Robot):
If no new data arrives or decoding fails, returns the last known values. If no new data arrives or decoding fails, returns the last known values.
""" """
# 1. Get the latest message string from the socket # 1. Get the latest message's frames from the socket
latest_message_str = self._poll_and_get_latest_message() latest_frames = self._poll_and_get_latest_message()
# 2. If no message, return cached data # 2. If no message, return cached data
if latest_message_str is None: if latest_frames is None:
return self.last_frames, self.last_remote_state return self.last_frames, self.last_remote_state
# 3. Parse the JSON message # 3. Parse the multipart message
observation = self._parse_observation_json(latest_message_str) observation = self._parse_observation(latest_frames)
# 4. If JSON parsing failed, return cached data # 4. If JSON parsing failed, return cached data
if observation is None: if observation is None:
+16 -11
View File
@@ -14,7 +14,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
import base64
import json import json
import logging import logging
import time import time
@@ -44,7 +43,9 @@ class LeKiwiHost:
self.zmq_cmd_socket.bind(f"tcp://*:{config.port_zmq_cmd}") self.zmq_cmd_socket.bind(f"tcp://*:{config.port_zmq_cmd}")
self.zmq_observation_socket = self.zmq_context.socket(zmq.PUSH) self.zmq_observation_socket = self.zmq_context.socket(zmq.PUSH)
self.zmq_observation_socket.setsockopt(zmq.CONFLATE, 1) # CONFLATE does not support multipart messages; a 2-deep send queue keeps
# near-latest-only semantics and sheds stale observations during stalls.
self.zmq_observation_socket.setsockopt(zmq.SNDHWM, 2)
self.zmq_observation_socket.bind(f"tcp://*:{config.port_zmq_observations}") self.zmq_observation_socket.bind(f"tcp://*:{config.port_zmq_observations}")
self.connection_time_s = config.connection_time_s self.connection_time_s = config.connection_time_s
@@ -99,19 +100,23 @@ def main(cfg: LeKiwiServerConfig):
last_observation = robot.get_observation() last_observation = robot.get_observation()
# Encode ndarrays to base64 strings # Send one multipart message: a JSON header frame (state + camera
for cam_key, _ in robot.cameras.items(): # order) followed by one raw JPEG frame per camera. Raw JPEG avoids
ret, buffer = cv2.imencode( # the 33% base64 inflation of embedding binary data in JSON.
".jpg", last_observation[cam_key], [int(cv2.IMWRITE_JPEG_QUALITY), 90] cam_keys = list(robot.cameras.keys())
jpeg_frames = []
for cam_key in cam_keys:
ret, jpeg = cv2.imencode(
".jpg", last_observation.pop(cam_key), [int(cv2.IMWRITE_JPEG_QUALITY), 90]
) )
if ret: jpeg_frames.append(jpeg if ret else b"")
last_observation[cam_key] = base64.b64encode(buffer).decode("utf-8") header = {"_cams": cam_keys, **last_observation}
else:
last_observation[cam_key] = ""
# Send the observation to the remote agent # Send the observation to the remote agent
try: try:
host.zmq_observation_socket.send_string(json.dumps(last_observation), flags=zmq.NOBLOCK) host.zmq_observation_socket.send_multipart(
[json.dumps(header).encode()] + jpeg_frames, flags=zmq.NOBLOCK
)
except zmq.Again: except zmq.Again:
logging.info("Dropping observation, no client connected") logging.info("Dropping observation, no client connected")
+2 -1
View File
@@ -45,6 +45,7 @@ from lerobot.common.train_utils import (
load_training_state, load_training_state,
push_checkpoint_to_hub, push_checkpoint_to_hub,
save_checkpoint, save_checkpoint,
should_save_checkpoint,
update_last_checkpoint, update_last_checkpoint,
) )
from lerobot.common.wandb_utils import WandBLogger from lerobot.common.wandb_utils import WandBLogger
@@ -616,7 +617,7 @@ def train(cfg: TrainPipelineConfig, accelerator: "Accelerator | None" = None):
progbar.update(1) progbar.update(1)
train_tracker.step() train_tracker.step()
is_log_step = cfg.log_freq > 0 and step % cfg.log_freq == 0 is_log_step = cfg.log_freq > 0 and step % cfg.log_freq == 0
is_saving_step = step % cfg.save_freq == 0 or step == cfg.steps is_saving_step = should_save_checkpoint(step, cfg.save_freq, cfg.steps)
is_env_eval_step = cfg.env_eval_freq > 0 and step % cfg.env_eval_freq == 0 is_env_eval_step = cfg.env_eval_freq > 0 and step % cfg.env_eval_freq == 0
is_eval_step = cfg.eval_steps > 0 and eval_dataloader is not None and step % cfg.eval_steps == 0 is_eval_step = cfg.eval_steps > 0 and eval_dataloader is not None and step % cfg.eval_steps == 0
+4 -3
View File
@@ -322,15 +322,16 @@ def test_get_color_sensor_prefers_rgb_camera():
assert camera._get_color_sensor() is rgb assert camera._get_color_sensor() is rgb
def test_get_color_sensor_falls_back_to_stereo_module(): def test_get_color_sensor_raises_without_dedicated_rgb_module():
"""D405 has no separate RGB module; color comes from Stereo Module.""" """D405 has no separate RGB module; we refuse to touch the shared Stereo Module."""
config = RealSenseCameraConfig(serial_number_or_name="042") config = RealSenseCameraConfig(serial_number_or_name="042")
camera = RealSenseCamera(config) camera = RealSenseCamera(config)
stereo = _make_mock_sensor("Stereo Module") stereo = _make_mock_sensor("Stereo Module")
_attach_mock_color_sensor(camera, stereo) _attach_mock_color_sensor(camera, stereo)
assert camera._get_color_sensor() is stereo with pytest.raises(RuntimeError, match="dedicated 'RGB Camera' module"):
camera._get_color_sensor()
def test_get_color_sensor_raises_with_available_sensors(): def test_get_color_sensor_raises_with_available_sensors():
+36
View File
@@ -236,6 +236,42 @@ def test_clear_removes_video_frame_staging_dir(tmp_path):
assert not video_staging_dir.exists() assert not video_staging_dir.exists()
def test_batched_encoding_staging_survives_save(tmp_path):
"""The post-save clear must NOT delete video staging frames.
With ``batch_encoding_size > 1`` the frames of already-saved episodes stay
on disk until the batch encode runs; the encoder deletes them afterwards.
A blanket switch of the post-save cleanup to ``camera_keys`` (as done in the
discard path) would silently break batched encoding.
"""
video_key = "observation.images.cam"
features = {
video_key: {
"dtype": "video",
"shape": (64, 96, 3),
"names": ["height", "width", "channels"],
},
"action": {"dtype": "float32", "shape": (2,), "names": None},
}
dataset = LeRobotDataset.create(
repo_id=DUMMY_REPO_ID,
fps=DEFAULT_FPS,
features=features,
root=tmp_path / "ds",
use_videos=True,
batch_encoding_size=2,
)
for _ in range(3):
dataset.add_frame(_make_frame(features))
staging_dir = dataset.writer._get_image_file_dir(0, video_key)
assert staging_dir.is_dir()
dataset.save_episode() # first of a batch of 2: no encoding yet
assert staging_dir.is_dir() and any(staging_dir.iterdir())
def test_finalize_is_idempotent(tmp_path): def test_finalize_is_idempotent(tmp_path):
"""Calling finalize() twice does not raise.""" """Calling finalize() twice does not raise."""
dataset = LeRobotDataset.create( dataset = LeRobotDataset.create(
+13
View File
@@ -30,6 +30,7 @@ from lerobot.common.train_utils import (
save_checkpoint, save_checkpoint,
save_training_state, save_training_state,
save_training_step, save_training_step,
should_save_checkpoint,
update_last_checkpoint, update_last_checkpoint,
) )
from lerobot.utils.constants import ( from lerobot.utils.constants import (
@@ -50,6 +51,18 @@ def test_get_step_identifier():
assert get_step_identifier(456789, 1_000_000) == "0456789" assert get_step_identifier(456789, 1_000_000) == "0456789"
def test_should_save_checkpoint():
# Periodic checkpoints land on multiples of save_freq.
assert should_save_checkpoint(10, save_freq=10, total_steps=100) is True
assert should_save_checkpoint(5, save_freq=10, total_steps=100) is False
# The final step always saves, even when it is not a multiple of save_freq.
assert should_save_checkpoint(100, save_freq=30, total_steps=100) is True
# save_freq <= 0 disables periodic saving without raising ZeroDivisionError.
assert should_save_checkpoint(1, save_freq=0, total_steps=100) is False
assert should_save_checkpoint(100, save_freq=0, total_steps=100) is True
assert should_save_checkpoint(1, save_freq=-1, total_steps=100) is False
def test_get_step_checkpoint_dir(): def test_get_step_checkpoint_dir():
output_dir = Path("/checkpoints") output_dir = Path("/checkpoints")
step_dir = get_step_checkpoint_dir(output_dir, 1000, 5) step_dir = get_step_checkpoint_dir(output_dir, 1000, 5)