LLM学習データの準備:FineWebの100GBスライスをS3に置いてみた

LLM学習データの準備:FineWebの100GBスライスをS3に置いてみた

LLMの学習データセットFineWebを自分のS3に置く際、HuggingFace側の情報は多いですが、AWS側の実装・認証・インフラ面での詰まりどころはあまり情報がありません。この記事では、100GBのスライスをS3に置き、manifestで再現性を検証するまでの実装と、ぶつかった5つのポイントを紹介します。
2026.07.28

はじめに

LLMのトレーニングを学び始めると、最初に「学習データをどう用意して、どこに置くか」という話にぶつかります。今回はその第一歩として、LLMの学習データセットとして有名なFineWeb(Common Crawl由来、約4TB)の一部を、自分のS3に置いて使えるようにしてみました。

ネットのチュートリアルは load_dataset(...)snapshot_download(...) といったHuggingFace側の話が中心です。でも自分がやりたかったのは「決まったスライスをS3に、再現可能な形で置く」というAWS側の話で、こちらはあまり情報が見つかりませんでした。

今回のゴールはこの2つです。

  • FineWebから決まった約100GBのスライスをS3バケットに置く
  • 半年後に他人(や自分)が同じバイト列かどうかを、manifestで検証できるようにする

転送のコア自体は短いPythonで済みます。ただ、それをAWS上で実際に完走させるまでに何度か詰まったので、そこも含めて残しておきます。

検証環境

  • 検証用のAWSアカウント(Tokyoリージョン ap-northeast-1、admin権限あり)
  • AWSの認証は一時credential(SSO / assume-role のようなもの。静的なaccess keyファイルは持たない)
  • SkyPilot(EC2をワンショットで立てる用。導入は次のセクション)
  • Python 3.12
  • Terraform v1.13(S3バケットの管理用)
  • ローカルはmacOS

セットアップ:SkyPilotを入れてAWSにつなぐ

最初の関門はここです。SkyPilotを入れて、AWSにアクセスできる状態を作ります。

インストールは uv を使いました(SkyPilotはPython 3.9〜3.13対応)。プロジェクトごとに入れるパターンはこう。

uv venv --seed --python 3.12
uv pip install 'skypilot[aws]'

マシン全体で使うなら uv tool install 'skypilot[aws]' でも入ります(この場合はvenv不要)。[aws] はAWS用の依存だけを入れる指定です。

次にAWSのcredential。SkyPilotは独自の認証ファイルを持たず、AWS CLIと同じcredentialチェーンを使います。なので、AWS CLIが通る状態にしておけばOKです(access keyなら aws configure、SSOなら aws sso login)。

つながっているかは sky check で確認します。ここが最初の関門で、通らないと後の sky launch は全部こけます。

uv run sky check aws

成功するとこう出ます。

🎉 Enabled infra 🎉
  AWS [compute, storage]

llm-data-prep-hf-fineweb-100g-to-s3-1

この表示が出れば、SkyPilotからAWS(compute = EC2、storage = S3)を使える状態です。ここを確認してから先に進みます。

参考: SkyPilot Installation

設計の全体像

パイプラインはこれだけです。

アーキテクチャ図(HuggingFace Hub → EC2/SkyPilot → S3。(1)ストリーミング (2)書き込み (3)manifest生成)

  • ローカルにファイルを一切落とさない。hf:// の読み込みストリームと s3:// の書き込みストリームをfsspecでつないでバイトを流すだけ。
  • 転送はラップトップではなく、SkyPilotで立てたEC2上で走らせる(帯域とスリープ問題を回避)。
  • manifestは転送とは別パスで、アップロード後にS3から生成する。こうすると「バケットさえあればHuggingFaceに再アクセスせず再生成できる」独立したartifactになる。

スライスは「1つのdumpから、ファイル名ソートで先頭N件」と決めます。同じdump・同じ件数を指定すれば、(データセットのrevisionが同じなら)誰がやっても同じファイル集合が落ちてきます。ここで注意なのは、この選び方だけでは「取ってくるファイル」は揃うものの、将来 main の中身が更新されると同じにならない点です。厳密な再現のために、後述のmanifestにrevisionとハッシュを記録して、後から同一性を検証できるようにします。

フォルダ構成

自動生成ファイル(.venv/.terraform/*.tfstate)は省いています。

test/
├── .sky.yaml                 # SkyPilot project config (auth method)
├── fineweb.yaml              # SkyPilot task (instance + run steps)
├── stream_fineweb_slice.py   # hf:// -> s3:// streaming
├── build_manifest.py         # build the manifest after upload
└── infra/
    └── main.tf               # S3 bucket (Terraform)

S3バケットはTerraformで作る

バケットはこのプロジェクトで唯一の永続リソースです(EC2は使い終わったら消す)。後で綺麗に消せるようにTerraformで管理します。ポイントは2つ。

  • S3のバケット名は全世界でユニークでないといけない。なので fineweb-mirror のようなprefixにランダムなsuffixを付ける。こうするとコミットするコードにアカウント固有の情報が残らない。
  • force_destroy = true にすると、100GB入ったバケットでも terraform destroy で消せる。検証環境なので割り切り。
# infra/main.tf
terraform {
  required_providers {
    aws    = { source = "hashicorp/aws", version = "~> 5.0" }
    random = { source = "hashicorp/random", version = "~> 3.0" }
  }
}

provider "aws" {
  region = var.region
}

variable "region" {
  default = "ap-northeast-1"
}

variable "bucket_prefix" {
  default = "fineweb-mirror"
}

# S3 bucket names are globally unique; a random suffix avoids collisions
resource "random_id" "suffix" {
  byte_length = 4 # -> 8 hex chars, e.g. "a3f9c1d0"
}

resource "aws_s3_bucket" "fineweb" {
  bucket = "${var.bucket_prefix}-${random_id.suffix.hex}"

  # allow destroy even when the bucket still holds ~100GB (test env)
  force_destroy = true
}

output "bucket_name" {
  value = aws_s3_bucket.fineweb.bucket
}
cd test/infra
terraform init
terraform apply                # create the bucket
terraform output bucket_name   # pass this name to sky launch

SkyPilotのタスクと2つのスクリプト

転送本体が stream_fineweb_slice.pyhf:// を読んで s3:// に流すだけです。

# stream_fineweb_slice.py
import argparse
import concurrent.futures
import os
import shutil
import sys

import fsspec
from huggingface_hub import HfFileSystem

HF_REPO = "datasets/HuggingFaceFW/fineweb"

def list_slice(dump: str, count: int) -> list[str]:
    """Return the first `count` parquet paths in `dump`, name-sorted (deterministic)."""
    hf = HfFileSystem()
    all_files = hf.glob(f"{HF_REPO}/data/{dump}/*.parquet")
    if not all_files:
        sys.exit(f"No parquet files found for dump {dump!r}")
    chosen = sorted(all_files)[:count]
    print(f"Selected {len(chosen)} / {len(all_files)} files from {dump}")
    return chosen

def stream_one(hf_path: str, bucket: str, dump: str, region: str) -> str:
    """Stream one file hf:// -> s3:// without touching local disk."""
    filename = hf_path.split("/")[-1]
    s3_key = f"fineweb/{dump}/{filename}"
    src = f"hf://{hf_path}"
    dst = f"s3://{bucket}/{s3_key}"

    # region in client_kwargs is REQUIRED (see below). Without it s3fs writes to
    # the wrong regional endpoint and PutObject fails: "No AWSAccessKey was presented".
    with fsspec.open(src, "rb") as fin, \
            fsspec.open(dst, "wb", client_kwargs={"region_name": region}) as fout:
        shutil.copyfileobj(fin, fout, length=32 * 1024 * 1024)  # 32MB chunks
    print(f"  uploaded {s3_key}")
    return s3_key

def main() -> None:
    p = argparse.ArgumentParser()
    p.add_argument("--dump", required=True)
    p.add_argument("--count", type=int, default=50)
    p.add_argument("--bucket", required=True)
    p.add_argument("--workers", type=int, default=8)
    p.add_argument("--region", default=os.environ.get("AWS_DEFAULT_REGION", "ap-northeast-1"))
    args = p.parse_args()

    files = list_slice(args.dump, args.count)

    # network I/O -> threads (not processes) overlap the waiting
    with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
        futures = [pool.submit(stream_one, f, args.bucket, args.dump, args.region) for f in files]
        for fut in concurrent.futures.as_completed(futures):
            fut.result()

    print(f"Done: {len(files)} files -> s3://{args.bucket}/fineweb/{args.dump}/")

if __name__ == "__main__":
    main()

manifestは転送とは別で、アップロード後にS3側だけを見て作ります。各行が1ファイルをsize + ETag + 行数でピン留めするので、読者が「自分は本当に同じバイト列を持っているか」をチェックできます。これがこの記事の実質的な成果物です。

# build_manifest.py
import argparse
import json
import os

import boto3
import pyarrow.parquet as pq
import fsspec

def build(bucket, dump, revision, pulled_at, region):
    s3 = boto3.client("s3", region_name=region)
    paginator = s3.get_paginator("list_objects_v2")
    entries = []

    for page in paginator.paginate(Bucket=bucket, Prefix=f"fineweb/{dump}/"):
        for obj in page.get("Contents", []):
            key = obj["Key"]
            if not key.endswith(".parquet"):
                continue

            # read only the parquet footer for the row count (not the whole file)
            with fsspec.open(f"s3://{bucket}/{key}", "rb",
                             client_kwargs={"region_name": region}) as f:
                rows = pq.read_metadata(f).num_rows

            entries.append({
                "path": key,
                "size_bytes": obj["Size"],
                "etag": obj["ETag"].strip('"'),  # multipart ETags carry a "-N" suffix
                "rows": rows,
                "hf_revision": revision,
                "pulled_at": pulled_at,
            })
            print(f"  manifested {key} ({rows} rows)")

    entries.sort(key=lambda e: e["path"])
    return entries

def main():
    p = argparse.ArgumentParser()
    p.add_argument("--dump", required=True)
    p.add_argument("--bucket", required=True)
    p.add_argument("--revision", default="main")
    p.add_argument("--pulled-at", default="unknown")
    p.add_argument("--region", default=os.environ.get("AWS_DEFAULT_REGION", "ap-northeast-1"))
    args = p.parse_args()

    entries = build(args.bucket, args.dump, args.revision, args.pulled_at, args.region)
    if not entries:
        raise SystemExit(f"No parquet objects under fineweb/{args.dump}/")

    body = "\n".join(json.dumps(e) for e in entries) + "\n"
    manifest_key = f"manifests/{args.dump}-{len(entries)}files-v1.jsonl"

    boto3.client("s3", region_name=args.region).put_object(
        Bucket=args.bucket, Key=manifest_key,
        Body=body.encode("utf-8"), ContentType="application/x-ndjson",
    )
    print(f"Wrote manifest: s3://{args.bucket}/{manifest_key} ({len(entries)} entries)")

if __name__ == "__main__":
    main()

生成されるmanifestは1行1ファイルのjsonlです。

{"path": "fineweb/CC-MAIN-2024-10/000_00000.parquet", "size_bytes": 2147483648, "etag": "a4f3...-21", "rows": 973991, "hf_revision": "main", "pulled_at": "2026-07-25T09:12:33Z"}

これを束ねるSkyPilotのタスク定義が fineweb.yaml。バケット名とHFトークンは起動時に --env で渡すので、ファイル自体はそのまま公開しても大丈夫です。

# fineweb.yaml
resources:
  cloud: aws
  region: ap-northeast-1
  instance_type: c7g.2xlarge   # 16GB RAM (see the memory-shortage section below)
  use_spot: false              # on-demand (see the spot section below)
  disk_size: 30                # boot disk only; nothing lands on local disk

envs:
  HF_TOKEN: null                     # supplied via --env at launch
  S3_BUCKET: null                    # supplied via --env at launch
  CC_DUMP: CC-MAIN-2024-10
  FILE_COUNT: 50                     # ~2.0 GB/file measured, so 50 x 2.0 = ~100 GB
  AWS_DEFAULT_REGION: ap-northeast-1 # must match the bucket region (see below)

workdir: .

setup: |
  pip install 'huggingface_hub[hf_xet]>=1.5' 's3fs' 'pyarrow' 'boto3'

run: |
  set -e
  python stream_fineweb_slice.py --dump "$CC_DUMP" --count "$FILE_COUNT" --bucket "$S3_BUCKET" --workers 4
  python build_manifest.py --dump "$CC_DUMP" --bucket "$S3_BUCKET" \
      --revision main --pulled-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)"

もう1つ、認証まわりの設定として .sky.yaml を置きます。これが無いとEC2上のジョブがS3に書けず、後述のようにハマります。 SkyPilotはデフォルトだと、ローカルのAWS認証情報をインスタンスにアップロードして使わせます。ところがSSOやassume-roleのような一時credentialだと、アップロードすべき静的なファイルが無く、インスタンス側がS3にアクセスできません(No AWSAccessKey was presented で落ちる)。

そこで remote_identity: SERVICE_ACCOUNT を指定し、インスタンス自身のIAM roleでアクセスさせます。認証情報をインスタンスに渡さないので、こちらの方が安全でもあります(静的access keyを使っている場合はデフォルトのままでも動きますが、この設定にしておくのが無難です)。

# .sky.yaml (placed in test/; auto-loaded when you run sky from that dir)
aws:
  remote_identity: SERVICE_ACCOUNT

ここまでのファイル(infra/main.tfstream_fineweb_slice.pybuild_manifest.pyfineweb.yaml.sky.yaml)が揃えば、起動できます。-i 5 --down は「アイドル5分でインスタンスを自動で落とす」設定です。

cd test
uv run sky launch -c fw-pull -i 5 --down fineweb.yaml \
    --env HF_TOKEN=none \
    --env S3_BUCKET=$(terraform -chdir=infra output -raw bucket_name) \
    --env FILE_COUNT=50

(FineWebはpublicなのでトークンなしで読めます。スクリプトも HF_TOKEN を使っていません。上の HF_TOKEN=none は、YAMLで必須envにしている都合でダミーを渡しているだけです。トークンが要るgatedなデータセットを扱うときだけ本物を渡してください。)

まず1ファイルで試す

上のコマンドをいきなり50ファイルで回す前に、FILE_COUNT=1 にして1ファイルだけ流しました。1ファイルなら短時間・数円で終わるので、パイプラインが通るかを安く確認できます。

この1ファイル段階で分かったのは主に2つ。

  • 読み込み → S3書き込み → manifest生成まで、一通り動く
  • 1ファイルは約2.0GB(この数字を見て本番のファイル数を決めた)

ちなみに、次に挙げる詰まりどころのうち認証まわり(1〜3)は、ほとんどこの1ファイルのテストで出し切りました。並列数や実行時間に効くもの(4・5)は、50ファイルに増やして初めて出てきます。小さく回して問題を早めに潰してから本番に進む、という流れです。

llm-data-prep-hf-fineweb-100g-to-s3-2

動かすまでに詰まった5つのポイント

ここまでで出したファイルをそのまま作れば動きます。ただ、.sky.yamlremote_identity: SERVICE_ACCOUNTfineweb.yamluse_spot: falsec7g.2xlargeAWS_DEFAULT_REGION といった値は、最初から知っていたものではなく、全部ハマった結果たどり着いたものです。なぜその値なのかを、ぶつかった順に並べます。

# 症状 原因 対処
1 awsコマンドが InvalidClientTokenId aws が別の認証ツールにaliasされていて、こちらのセッションを上書きしていた alias側を無効化(unalias aws 等)
2 EC2上のジョブがS3書き込みで No AWSAccessKey was presented SkyPilotのデフォルト(LOCAL_CREDENTIALS)はローカルの ~/.aws をアップロードする方式。一時credentialは静的ファイルが無いのでインスタンスに何も渡らなかった .sky.yamlremote_identity: SERVICE_ACCOUNT にしてインスタンス自身のIAM roleを使わせる
3 roleを付けても書き込みだけ No AWSAccessKey のまま s3fsにregionが無く、書き込みが誤ったendpointに行って署名されなかった AWS_DEFAULT_REGION + コードで region_name を明示
4 実行中に UP → INIT に戻って停止 spotインスタンスがpreemption(AWSに回収)された。sky launch はpreemptされたジョブを自動再開しない use_spot: false(on-demand)に切り替え
5 Rayがドライバをメモリ不足でkill (FAILED_DRIVER) c7g.largeは4GB RAM。8並列で各ストリームが約2GB/fileを両端でバッファ → 4GB超過 インスタンスを16GBに、workersを4に

個別の対処はテーブルの通りです。ここでは、特に理解しておくと応用が効く3つだけ深掘りします。

(2) IAM roleは「権限を持つ」んじゃなくて「一時credentialを配る」

エラー No AWSAccessKey was presented を最初に見たとき、「roleにS3権限が付いてるのに何を言ってるんだ」と思いました。でもこれは誤解でした。

AWSのAPIリクエストは必ず署名(SigV4)が要ります。「このインスタンスは権限があるから通す」という暗黙の許可はありません。IAM roleがやっているのは、EC2のメタデータサービス(IMDS, 169.254.169.254)経由で一時的なcredentialを配ること。SDK(boto3やs3fs)はそれを取ってきてリクエストに署名します。

  1. roleをアタッチ → AWSが一時credentialをIMDSに載せる
  2. SDKがIMDSからcredentialを取得する
  3. SDKがそのcredentialでリクエストに署名する(ここで「access key」が使われる)
  4. S3が署名を検証 → その後で初めてroleの権限をチェックする

No AWSAccessKey was presented は「リクエストが署名されていない」=ステップ3が起きていない、という意味でした。S3は認証(署名)を認可(権限)より先に見ます。だから、roleにS3FullAccessが付いていても、署名されていないリクエストは権限を見る前に弾かれます。

(2)の原因は、SkyPilotのデフォルトがローカルの ~/.aws ファイルをアップロードする方式だったこと。SSOやassume-roleのような一時credentialは環境変数で渡されることが多く、アップロードすべき静的な認証ファイルがありません。結果、インスタンスには何も渡らず、IMDSのroleもデフォルトではアタッチされない。SERVICE_ACCOUNT に切り替えると、SkyPilotが自前のIAM role(skypilot-v1)をインスタンスにアタッチしてくれて解決しました。

補足すると、さきほど置いた .sky.yaml は、プロジェクト(test/)に置くと、そのディレクトリで sky を叩いたときに自動で読まれます(グローバルの ~/.sky/config.yaml より優先)。「何をやったか」がリポジトリだけで完結するのが利点です。ただし、プロジェクトの .sky.yaml を自動で読むかはSkyPilotのバージョンに依存します。反映されない場合は同じ内容を ~/.sky/config.yaml に置くか、SKYPILOT_CONFIG で明示してください。

(3) s3fsは「読み込み」は通っても「書き込み」でregionが要る

(3)が一番てこずりました。boto3のsts確認は通る。s3fsの ls(バケット一覧)も通る。なのにs3fsの書き込みだけが No AWSAccessKey で落ちる。同じプロセス、同じcredentialなのに、です。

原因はregionでした。ListBucketsglobalなendpointを使うので通ります。一方 PutObjectバケットのリージョン固有のendpointが要ります。regionを指定しないと、s3fsが違うendpointに書きに行き、署名がずれてあのエラーになっていました。

対処はregionを明示するだけ。ただしスクリプトの複数箇所でS3にアクセスするので、環境変数 AWS_DEFAULT_REGION をYAMLで1回渡しつつ、コードでも region_name を明示する二段構えにしました。

この切り分けのハシゴは他でも使えるので残しておきます。

# 1. Is the credential chain (IMDS/role) working at all?
python -c "import boto3; print(boto3.client('sts').get_caller_identity())"
# 2. Is an empty AWS_* env var breaking the chain?
env | grep -i aws
# 3. s3fs READ auth test
python -c "import s3fs; print(s3fs.S3FileSystem().ls(''))"
# 4. test WRITE separately (read working != write working)

(5) 「ディスクを使わないストリーミング」でもRAMは食う

(5)は思い込みでハマりました。「ローカルディスクに落とさないから軽い」と考えて c7g.large(4GB RAM)で8並列を回したら、Rayがメモリ不足でジョブを殺しました。

ストリーミングでもメモリは要ります。hf_xet(HuggingFaceの転送層)が読み込み側でチャンクをバッファし、s3fsが書き込み側でmultipartのパートをバッファします。実測では1ファイルあたり両端で最大2GB程度を抱えました。8並列だとその数倍になり、4GBのマシンでは足りません。1ファイルだけのテストが通っていたのは、並列度が1で収まっていただけでした。

対処はメモリを増やす(c7g.2xlarge, 16GB)のと並列度を下げる(--workers 4)の両方。4並列 × 約2GB = 約8GBなら16GBに余裕を持って収まります。「streaming, no disk」でもインスタンスは並列度に合わせて選ぶ、という話でした。

結果

llm-data-prep-hf-fineweb-100g-to-s3-3

llm-data-prep-hf-fineweb-100g-to-s3-4

llm-data-prep-hf-fineweb-100g-to-s3-5

最終的な設定(on-demand c7g.2xlarge, 4 workers, 50ファイル)で完走しました。完走まで約40分(HFのCDN速度次第で前後します)。

  uploaded fineweb/CC-MAIN-2024-10/000_00049.parquet
Done: 50 files -> s3://<bucket>/fineweb/CC-MAIN-2024-10/
  manifested fineweb/CC-MAIN-2024-10/000_00049.parquet (957982 rows)
Wrote manifest: s3://<bucket>/manifests/CC-MAIN-2024-10-50files-v1.jsonl (50 entries)
  • s3://<bucket>/fineweb/CC-MAIN-2024-10/ に50個のparquet(約100GB)
  • s3://<bucket>/manifests/CC-MAIN-2024-10-50files-v1.jsonl にmanifest(50件、各ファイル約95万〜97万行)
  • ジョブが終わって5分後にインスタンスは自動でterminate

ファイル数は、さきほどの1ファイル約2.0GBから逆算して50にしました。50ファイルでおよそ100GBという規模感です。なお、実際のLLM学習でデータ量を「ちょうど何GB」と決め打ちすることはまずないので、ここでの100GBはデモ用のざっくりした目安くらいに思ってください。

コストの目安はこんな感じです。

項目 コスト
EC2 c7g.2xlarge on-demand 約40分 約$0.2
HF egress(HuggingFaceからの転送) $0(HF側が負担)
S3 PUTリクエスト <$0.01
S3ストレージ 100GB/月 約$2.30
初月合計 約$2.5

HuggingFaceからの転送(egress)はHF側が負担するので0です。ただしEC2とS3ストレージは普通に課金されます。AWSの無料枠に収まる規模ではないので、個人で試すなら、置きっぱなしにするとストレージ代(100GBで月$2.3前後)が継続でかかる点に注意してください。使い終わったら terraform destroy でバケットごと消せます。

読者が同じスライスを検証するときは、manifestと突き合わせるだけです。ハッシュ(ETag)が合わなければ、そのベンチマーク結果は比較可能ではない、というのがこの設計の狙いです。

head = s3.head_object(Bucket=bucket, Key=entry["path"])
assert head["ETag"].strip('"') == entry["etag"]
assert head["ContentLength"] == entry["size_bytes"]

おわりに

やる前は「FineWebをS3に置くだけ」と思っていました。実際、転送のコアは10行ちょっとのPythonで終わっています。

でも本当の作業は、その10行を実在のAWSアカウント上で完走させるところにありました。どれも転送コードそのものではなく、その周辺の認証・インフラ・リソース設計で起きた問題で、snapshot_download で止まっているチュートリアルには出てこない部分です。

同じように「HuggingFaceのデータを自分のAWSに再現可能な形で置きたい」人がいたら、この5つのポイントは先回りして踏んでおいたので、あとは自分の環境に合わせて調整してみてください。

この記事をシェアする

関連記事