← Back to DevBytes

Managing Model Weights in S3 for Scalable Inference

Managing Model Weights in S3 for Scalable Inference

As machine learning models grow larger and inference workloads become more dynamic, the way you store and distribute model weights becomes a critical architectural decision. Amazon S3, combined with a thoughtful loading strategy, offers a robust foundation for serving models at scale without locking yourself into a single instance type or availability zone. This tutorial walks through the concepts, patterns, and production-ready code you need to manage model weights in S3 for scalable inference.

What It Means to Manage Model Weights in S3

Model weights are the serialized parameters of a trained machine learning model, typically stored as checkpoint files, .safetensors, .bin, or .pt artifacts. Managing these weights in S3 means using S3 as the canonical source of truth for these artifacts, with inference instances pulling weights on demand rather than baking them into container images or attached EBS volumes.

In a typical workflow, a training job writes the final weights to a versioned S3 path such as s3://my-ml-bucket/models/llama-7b/v1.2/. Inference services then reference that path, download the weights at startup or on a cache miss, and load them into GPU or CPU memory. This decouples the model lifecycle from the compute lifecycle, which is the key to elastic scaling.

Why It Matters for Scalable Inference

How to Use S3 for Model Weight Storage

The practical implementation involves three layers: a storage layout, a download utility, and an integration point in your inference server. Let's build each one.

1. Designing the S3 Layout

A consistent prefix scheme is essential. A recommended pattern is s3://<bucket>/models/<model-name>/<version>/<shard-files>. Keep a manifest.json in each version folder that lists the expected files, their sizes, and a checksum. This makes integrity validation trivial.

{
  "model": "llama-7b-instruct",
  "version": "1.2.0",
  "framework": "pytorch",
  "files": [
    {"name": "config.json", "size": 712, "sha256": "ab12..."},
    {"name": "model-00001-of-00003.safetensors", "size": 5368709120, "sha256": "cd34..."},
    {"name": "model-00002-of-00003.safetensors", "size": 5368709120, "sha256": "ef56..."},
    {"name": "model-00003-of-00003.safetensors", "size": 4123, "sha256": "gh78..."},
    {"name": "tokenizer.json", "size": 1843210, "sha256": "ij90..."}
  ],
  "created_at": "2024-11-15T10:30:00Z"
}

2. Writing a Robust Download Utility

The download utility should handle concurrent downloads, resumable transfers, checksum validation, and local caching. Below is a Python implementation using boto3 and concurrent.futures.

import os
import json
import hashlib
import boto3
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path

class S3WeightManager:
    def __init__(self, bucket, region="us-east-1", cache_dir="/var/cache/models"):
        self.s3 = boto3.client("s3", region_name=region)
        self.bucket = bucket
        self.cache_dir = Path(cache_dir)
        self.cache_dir.mkdir(parents=True, exist_ok=True)

    def fetch_manifest(self, model_name, version):
        key = f"models/{model_name}/{version}/manifest.json"
        obj = self.s3.get_object(Bucket=self.bucket, Key=key)
        return json.loads(obj["Body"].read())

    def _local_path(self, model_name, version, filename):
        return self.cache_dir / model_name / version / filename

    def _verify_checksum(self, filepath, expected_sha256):
        h = hashlib.sha256()
        with open(filepath, "rb") as f:
            for chunk in iter(lambda: f.read(8 * 1024 * 1024), b""):
                h.update(chunk)
        return h.hexdigest() == expected_sha256

    def _download_file(self, model_name, version, file_info):
        s3_key = f"models/{model_name}/{version}/{file_info['name']}"
        local_path = self._local_path(model_name, version, file_info["name"])
        local_path.parent.mkdir(parents=True, exist_ok=True)

        # Skip if already cached and valid
        if local_path.exists() and local_path.stat().st_size == file_info["size"]:
            if self._verify_checksum(local_path, file_info["sha256"]):
                return str(local_path)

        self.s3.download_file(self.bucket, s3_key, str(local_path))

        if not self._verify_checksum(local_path, file_info["sha256"]):
            raise ValueError(f"Checksum mismatch for {file_info['name']}")
        return str(local_path)

    def download_model(self, model_name, version, max_workers=4):
        manifest = self.fetch_manifest(model_name, version)
        paths = []
        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = [
                executor.submit(self._download_file, model_name, version, f)
                for f in manifest["files"]
            ]
            for future in as_completed(futures):
                paths.append(future.result())
        paths.sort()
        return {"manifest": manifest, "paths": paths}

3. Integrating with an Inference Server

Once weights are downloaded, the inference server loads them into memory. The key is to perform this download during a warm-up phase, before the server accepts traffic. Here is an example using FastAPI and Hugging Face Transformers.

from fastapi import FastAPI, BackgroundTasks
from contextlib import asynccontextmanager
from transformers import AutoModelForCausalLM, AutoTokenizer
from s3_weight_manager import S3WeightManager

MODEL_NAME = "llama-7b-instruct"
MODEL_VERSION = "1.2.0"
BUCKET = "my-ml-bucket"

state = {}

@asynccontextmanager
async def lifespan(app: FastAPI):
    manager = S3WeightManager(bucket=BUCKET)
    result = manager.download_model(MODEL_NAME, MODEL_VERSION)
    model_dir = str(manager._local_path(MODEL_NAME, MODEL_VERSION, ""))

    tokenizer = AutoTokenizer.from_pretrained(model_dir)
    model = AutoModelForCausalLM.from_pretrained(
        model_dir, device_map="auto", torch_dtype="auto"
    )
    state["tokenizer"] = tokenizer
    state["model"] = model
    state["version"] = MODEL_VERSION
    yield
    state.clear()

app = FastAPI(lifespan=lifespan)

@app.get("/health")
def health():
    return {"status": "ready", "model_version": state.get("version")}

@app.post("/predict")
def predict(payload: dict):
    inputs = state["tokenizer"](payload["prompt"], return_tensors="pt").to("cuda")
    outputs = state["model"].generate(**inputs, max_new_tokens=payload.get("max_tokens", 128))
    return {"text": state["tokenizer"].decode(outputs[0], skip_special_tokens=True)}

4. Using S3 Range Requests for Partial Loading

For very large models, you may want to load only specific layers or shards. S3 supports HTTP range requests, which let you fetch a byte range of an object. This is useful for tensor-parallel inference where each worker loads only its shard.

def fetch_range(self, key, start, end):
    """Fetch bytes [start, end] from an S3 object."""
    response = self.s3.get_object(
        Bucket=self.bucket,
        Key=key,
        Range=f"bytes={start}-{end}"
    )
    return response["Body"].read()

def load_shard_to_gpu(self, model_name, version, shard_name, device="cuda:0"):
    import torch
    key = f"models/{model_name}/{version}/{shard_name}"
    head = self.s3.head_object(Bucket=self.bucket, Key=key)
    total = head["ContentLength"]
    chunk = 256 * 1024 * 1024  # 256 MB chunks
    buffers = []
    for offset in range(0, total, chunk):
        end = min(offset + chunk - 1, total - 1)
        buffers.append(self.fetch_range(key, offset, end))
    raw = b"".join(buffers)
    return torch.load(io.BytesIO(raw), map_location=device)

5. Automating Promotion with a CI/CD Pipeline

When a new model version is trained, a promotion step should upload weights to S3, write the manifest, and update a pointer file such as s3://my-ml-bucket/models/llama-7b/latest.txt containing the new version string. Inference services poll this pointer or subscribe to an SNS notification to trigger a graceful reload.

import boto3, json, hashlib, os

def promote_model(local_dir, bucket, model_name, version):
    s3 = boto3.client("s3")
    files = []
    for fname in sorted(os.listdir(local_dir)):
        path = os.path.join(local_dir, fname)
        size = os.path.getsize(path)
        sha = hashlib.sha256(open(path, "rb").read()).hexdigest()
        s3.upload_file(path, bucket, f"models/{model_name}/{version}/{fname}")
        files.append({"name": fname, "size": size, "sha256": sha})

    manifest = {
        "model": model_name,
        "version": version,
        "files": files,
    }
    s3.put_object(
        Bucket=bucket,
        Key=f"models/{model_name}/{version}/manifest.json",
        Body=json.dumps(manifest, indent=2),
    )
    s3.put_object(
        Bucket=bucket,
        Key=f"models/{model_name}/latest.txt",
        Body=version.encode("utf-8"),
    )
    print(f"Promoted {model_name} to version {version}")

Best Practices

Conclusion

Managing model weights in S3 transforms inference from a rigid, image-bound deployment into an elastic, version-aware system. By combining a clear storage layout, a resilient download utility with checksum validation, and tight integration with your inference server's lifecycle, you gain the ability to scale horizontally, roll back instantly, and ship new models without rebuilding infrastructure. The patterns in this tutorial give you a production-ready foundation that grows with your model sizes and traffic demands while keeping costs and operational complexity under control.

— Ad —

Google AdSense will appear here after approval

← Back to all articles