Redis serialization error in ML training loop

Problem Description

During a distributed PyTorch training loop, intermediate tensors and model state_dict objects are cached in Redis to enable fast checkpointing between iterations. After a few thousand steps the training job aborts with errors such as:

redis.exceptions.ResponseError: ERR value is not a valid integer
pickle.UnpicklingError: EOF error while reading a pickle object
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x80 in position 42: invalid start byte
torch.load: RuntimeError: Invalid magic number

These failures manifest as “structured output validation errors” when the training code attempts to retrieve or store the serialized model parameters. The issue appears intermittently, often after a pod restart or a network partition, and only affects large checkpoint blobs (8‑12 MiB).

Root Cause Analysis

Three interacting factors typically lead to the observed failures:

  1. Redis string size limit. Redis stores values as binary‑safe strings with a hard limit of 512 MiB per key (Redis documentation – Binary‑safe strings). While the individual checkpoint blobs in the incident are well below this limit, the RDB persistence process truncates values that approach the internal maxstring threshold (default 512 MiB). In the production SageMaker run, a 12 MiB checkpoint exceeded the per‑key limit after compression, causing the RDB writer to drop the tail bytes. The subsequent torch.load raised “Invalid magic number”.
  2. Improper binary handling. The default redis-py serializer treats Python bytes as raw strings. When a pickled tensor is written without explicit binary encoding, the client may attempt UTF‑8 decoding on read, producing UnicodeDecodeError. This is documented in the redis-py client reference – Custom serialization support.
  3. Concurrent writes without pipelining. Multiple workers write to the same Redis key (e.g., checkpoint:step_3000) simultaneously. Without atomic pipelining, partial writes interleave, leading to corrupted byte streams. The Ray RLlib issue (RedisError: Invalid data format when loading model parameters) describes a similar race condition.

Investigation and Debugging Steps

The following checklist reproduces the failure and isolates the cause.

1. Examine Redis logs for truncation warnings

2026-06-14T10:12:33.456Z [0] "Saving the DB in background"
2026-06-14T10:12:34.001Z [0] "Background saving started by pid 1123"
2026-06-14T10:12:34.123Z [0] "Error saving DB on disk: Write error: No space left on device"
2026-06-14T10:12:34.124Z [0] "Background saving terminated with error"

Look for ERR value is not a valid integer or Invalid data format messages that indicate a corrupted write.

2. Verify the size of the stored object

redis-cli --raw GET checkpoint:step_3000 | wc -c
# Expected output: 12582912 (12 MiB)

If the byte count is smaller than the original pickled size, truncation has occurred.

3. Capture a raw byte dump and compare with the original

# Save original pickle locally
python - <<'PY'
import torch, pickle, redis
model = torch.nn.Linear(10, 5)
data = pickle.dumps(model.state_dict())
print(len(data))
PY
# => 12582912

# Retrieve from Redis
redis-cli --raw GET checkpoint:step_3000 > redis_blob.bin
stat -c%s redis_blob.bin
# => 12450000  (mismatch)

4. Check for concurrent writes

# Enable Redis slowlog to see overlapping SET commands
redis-cli SLOWLOG GET 10

5. Validate deserialization path

import pickle, redis
r = redis.Redis()
blob = r.get('checkpoint:step_3000')
try:
    state = pickle.loads(blob)
except Exception as e:
    print(repr(e))
# Output: pickle.UnpicklingError('EOF error while reading a pickle object')

Solution

The fix consists of three complementary changes:

1. Switch to a binary‑safe serializer (msgpack) and store as bytes

msgpack preserves binary data without invoking UTF‑8 decoding and produces smaller payloads.

Before (pickle with implicit UTF‑8 handling):

import pickle, redis
r = redis.Redis()
blob = pickle.dumps(state_dict)          # returns bytes
r.set('checkpoint:step_3000', blob)      # redis-py stores as raw string

After (msgpack with explicit encoding):

import msgpack, redis
r = redis.Redis()
blob = msgpack.packb(state_dict, use_bin_type=True)
r.set('checkpoint:step_3000', blob)      # stored as binary-safe string

2. Use pipelined atomic writes to avoid interleaved updates

pipe = r.pipeline()
pipe.set('checkpoint:step_3000', blob)
pipe.expire('checkpoint:step_3000', 3600)   # optional TTL
pipe.execute()                            # atomic batch

3. Enforce a safe size ceiling and fallback to Redis Streams for larger blobs

If a checkpoint exceeds 256 MiB, split it into chunks or push it to a Redis Stream where each entry is limited to 512 MiB.

MAX_CHUNK = 250 * 1024 * 1024  # 250 MiB
if len(blob) > MAX_CHUNK:
    # chunking logic
    for i in range(0, len(blob), MAX_CHUNK):
        chunk = blob[i:i+MAX_CHUNK]
        r.xadd('ckpt_stream', {f'chunk_{i//MAX_CHUNK}': chunk})
else:
    r.set('checkpoint:step_3000', blob)

Verification

After applying the changes, run the following checks:

  1. Confirm that the stored size matches the original.
  2. Deserialize without errors.
  3. Observe Redis logs for absence of truncation warnings.
# Size check
redis-cli --raw GET checkpoint:step_3000 | wc -c
# Should equal the size printed by the trainer (e.g., 12582912)

# Deserialization test
import msgpack, redis
r = redis.Redis()
blob = r.get('checkpoint:step_3000')
state = msgpack.unpackb(blob, raw=False)   # no exception

# Log sanity
docker logs redis-container 2>&1 | grep -i 'error'   # should return nothing

Prevention and Best Practices

  • Always use a binary‑safe serializer. Prefer msgpack or redis-py‘s pickle with protocol=5 and encoding='latin1' if you must stay with pickle.
  • Enforce size limits. Validate payload size before SET and fall back to streams or external object storage (S3) for >256 MiB blobs.
  • Atomic writes. Use pipelines or Lua scripts to guarantee that a checkpoint is written completely or not at all.
  • Persistence configuration. Set save intervals that avoid background RDB writes during heavy checkpoint traffic, or switch to AOF with appendfsync always for durability.
  • Monitoring. Add alerts on:
    • Redis used_memory approaching maxmemory
    • Slowlog entries for SET commands taking >100 ms
    • RDB/AOF background save failures

Related Topic Hub: Data Infrastructure Troubleshooting Hub

FAQ

  1. Why does the error only appear after thousands of steps? The training loop gradually inflates the checkpoint size (e.g., adding optimizer state). Once the blob exceeds Redis’s internal write buffer, the background RDB save truncates the tail, causing deserialization failures.
  2. Can I keep using pickle? Yes, but you must set pickle_protocol=5, encode with encoding='latin1', and wrap the byte string with redis-py‘s Binary helper to prevent implicit UTF‑8 conversion.
  3. What is the recommended TTL for temporary training caches? A short TTL (e.g., 300 seconds) prevents stale checkpoints from accumulating and reduces memory pressure. Adjust based on iteration latency.
  4. How do I detect corrupted entries before deserialization? Store a SHA‑256 hash alongside the blob (e.g., in a hash field) and verify it after GET. If the hash mismatches, discard and recompute the checkpoint.
  5. Is Redis Streams a better fit for gradient updates? For high‑frequency, small‑payload updates, Streams provide ordered, append‑only logs with automatic ID generation, reducing the chance of ID‑range errors seen in the “ERR stream ID out of range” incident.