Skip to content

Commit 94f99ff

Browse files
authored
osdc/hf-cache: cap rclone Go heap with GOMEMLIMIT (#892)
rclone is a Go binary, and its OOMs are a **GC** problem, not a genuine memory requirement: under bursty `--vfs-cache-mode full` buffering the Go heap grows past the cgroup limit before the GC runs, so the kernel OOM-kills rclone and the `/mnt/hf_cache` FUSE mount dies node-wide (→ job-pod `CreateContainerError`). ## What Give the Go runtime a soft heap ceiling (`GOMEMLIMIT`) at **~90% of the reserved limit** so the GC reclaims buffering before the kernel does — no limit changes. `deploy.sh` derives `GOMEMLIMIT` from each tier's memory (the value it already renders for `__RCLONE_MEMORY_LIMIT__`) and injects it as a **literal env value**: ```yaml env: - name: GOMEMLIMIT value: "__GOMEMLIMIT__" # e.g. 460MiB for the 512Mi tier ``` So there's: - no runtime shell arithmetic (a `bytes * 90` intermediate overflows 32-bit busybox); - no downward-API indirection to resolve at runtime; - a literal you can read straight out of the rendered manifest. Go needs `MiB`/`GiB` suffixes (it rejects k8s `Mi`/`Gi`), so the value is converted in `deploy.sh`, not reused verbatim. Per tier: 256Mi→230MiB, 512Mi→460MiB, 1Gi→921MiB, 2Gi→1843MiB, 4Gi→3686MiB. ## Verification - `just lint` 13/13, `just test` pass. - Rendered each tier via `deploy.sh`'s logic and validated with `kubeconform -strict`; confirmed the manifest carries a literal `GOMEMLIMIT` (no `resourceFieldRef`) that sits below the container limit.
1 parent 0b994ee commit 94f99ff

4 files changed

Lines changed: 47 additions & 10 deletions

File tree

osdc/integration-tests/workflows/integration-test.yaml.tpl

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@ jobs:
349349
echo "FAIL: $MODEL not in the cache ($CACHE_DIR missing) — seed it with scripts/hf-cache-seed.py"
350350
exit 1
351351
fi
352-
pip install --no-cache-dir torch # default CUDA build for the GPU runner
352+
pip install --no-cache-dir torch==2.12.1 # default CUDA build for the GPU runner
353353
pip install --no-cache-dir 'transformers>=4.45' 'huggingface_hub>=0.24' accelerate
354354
# device_map='cuda' loads weights to VRAM (see runs-on note); greedy decode.
355355
MODEL="$MODEL" python3 -c "
@@ -452,7 +452,7 @@ jobs:
452452
steps:
453453
- name: Install deps (torch, transformers, hub, awscli)
454454
run: |
455-
pip install --no-cache-dir torch # default build; runs on CPU for a tiny model
455+
pip install --no-cache-dir torch==2.12.1 # default build; runs on CPU for a tiny model
456456
# transformers <5: bert-tiny's config.json has no model_type key (5.x rejects
457457
# it; 4.x infers "bert"). hf_hub <1 stays compatible with transformers 4.x.
458458
pip install --no-cache-dir 'transformers>=4.45,<5' 'huggingface_hub>=0.24,<1' awscli
@@ -716,7 +716,7 @@ jobs:
716716
- name: Verify torch CPU install (pip)
717717
run: |
718718
echo "=== Torch CPU Install (pip) ==="
719-
pip install --no-cache-dir torch==2.11.0
719+
pip install --no-cache-dir torch==2.12.1
720720
python3 -c "
721721
import torch, sys
722722
cuda = torch.version.cuda
@@ -731,7 +731,7 @@ jobs:
731731
run: |
732732
echo "=== Torch CPU Install (uv) ==="
733733
pip uninstall -y torch >/dev/null 2>&1 || true
734-
uv pip install --system --no-cache torch==2.11.0
734+
uv pip install --system --no-cache torch==2.12.1
735735
python3 -c "
736736
import torch, sys
737737
cuda = torch.version.cuda
@@ -1076,7 +1076,7 @@ jobs:
10761076
- name: Verify torch CPU install (pip)
10771077
run: |
10781078
echo "=== Torch CPU Install (pip) ==="
1079-
pip install --no-cache-dir torch==2.11.0
1079+
pip install --no-cache-dir torch==2.12.1
10801080
python3 -c "
10811081
import torch, sys
10821082
cuda = torch.version.cuda
@@ -1091,7 +1091,7 @@ jobs:
10911091
run: |
10921092
echo "=== Torch CPU Install (uv) ==="
10931093
pip uninstall -y torch >/dev/null 2>&1 || true
1094-
uv pip install --system --no-cache torch==2.11.0
1094+
uv pip install --system --no-cache torch==2.12.1
10951095
python3 -c "
10961096
import torch, sys
10971097
cuda = torch.version.cuda
@@ -1467,7 +1467,7 @@ jobs:
14671467
- name: Verify torch CUDA install (pip)
14681468
run: |
14691469
echo "=== Torch CUDA Install (pip) ==="
1470-
pip install --no-cache-dir torch==2.11.0
1470+
pip install --no-cache-dir torch==2.12.1
14711471
python3 -c "
14721472
import torch, sys
14731473
cuda = torch.version.cuda
@@ -1482,7 +1482,7 @@ jobs:
14821482
run: |
14831483
echo "=== Torch CUDA Install (uv) ==="
14841484
pip uninstall -y torch >/dev/null 2>&1 || true
1485-
uv pip install --system --no-cache torch==2.11.0
1485+
uv pip install --system --no-cache torch==2.12.1
14861486
python3 -c "
14871487
import torch, sys
14881488
cuda = torch.version.cuda

osdc/modules/hf-cache/deploy.sh

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,17 @@ render_mount_ds() {
8787
# $1 = DaemonSet name, $2 = affinity operator, $3 = gpu-count CSV,
8888
# $4 = rclone memory (rendered as both request and limit → reserved)
8989
local values="[\"${3//,/\",\"}\"]"
90+
# GOMEMLIMIT ~= 90% of the reserved limit, in Go's byte-suffix format (MiB/GiB — Go
91+
# rejects k8s's Mi/Gi). rclone is a Go binary that OOMs from lazy GC, not real need;
92+
# a soft heap ceiling below the cgroup limit makes the GC run before the kernel kills
93+
# the mount. Computed here (bash) so it renders to a literal — no runtime arithmetic.
94+
local mib
95+
case "$4" in
96+
*Gi) mib=$((${4%Gi} * 1024)) ;;
97+
*Mi) mib=${4%Mi} ;;
98+
*) mib=0 ;;
99+
esac
100+
local gomemlimit="$((mib * 90 / 100))MiB"
90101
sed -e "s|__NAMESPACE__|${NAMESPACE}|g" \
91102
-e "s|__BUCKET__|${BUCKET}|g" \
92103
-e "s|__REGION__|${BUCKET_REGION}|g" \
@@ -97,6 +108,7 @@ render_mount_ds() {
97108
-e "s|__GPU_OP__|${2}|g" \
98109
-e "s|__MULTI_GPU_COUNTS__|${values}|g" \
99110
-e "s|__RCLONE_MEMORY_LIMIT__|${4}|g" \
111+
-e "s|__GOMEMLIMIT__|${gomemlimit}|g" \
100112
"$MODULE_DIR/kubernetes/mount-daemonset.yaml.tpl"
101113
}
102114

osdc/modules/hf-cache/kubernetes/mount-daemonset.yaml.tpl

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
# the tiers mutually exclusive (exactly one mount per node).
99
#
1010
# Placeholders (deploy.sh): __NAMESPACE__ __BUCKET__ __REGION__ __RCLONE_IMAGE__
11-
# __VFS_CACHE_MAX_SIZE__ __TAINT_REMOVER_IMAGE__ __RCLONE_MEMORY_LIMIT__
11+
# __VFS_CACHE_MAX_SIZE__ __TAINT_REMOVER_IMAGE__ __RCLONE_MEMORY_LIMIT__ __GOMEMLIMIT__
1212
# __DS_NAME__ __GPU_OP__ __MULTI_GPU_COUNTS__
1313
apiVersion: apps/v1
1414
kind: DaemonSet
@@ -85,6 +85,13 @@ spec:
8585
# FUSE + Bidirectional propagation need privileged.
8686
securityContext:
8787
privileged: true
88+
env:
89+
# rclone (Go) OOMs from lazy GC, not a real memory need: cap the heap below the
90+
# cgroup limit so the GC runs before the kernel OOM-kills the mount node-wide.
91+
# deploy.sh derives this from the tier's memory (~90%, in Go's MiB/GiB format —
92+
# not k8s Mi/Gi), so it tracks __RCLONE_MEMORY_LIMIT__ without runtime arithmetic.
93+
- name: GOMEMLIMIT
94+
value: "__GOMEMLIMIT__"
8895
command:
8996
- /bin/sh
9097
- -c
@@ -157,7 +164,8 @@ spec:
157164
timeoutSeconds: 10
158165
failureThreshold: 3
159166
# An rclone OOM drops the mount node-wide. Memory is tiered by GPU count and
160-
# reserved (request == limit) — see deploy.sh MOUNT_TIERS.
167+
# reserved (request == limit) — see deploy.sh MOUNT_TIERS. GOMEMLIMIT (env above)
168+
# is derived from this limit so the Go GC caps the heap first.
161169
resources:
162170
requests:
163171
cpu: 100m

osdc/modules/hf-cache/tests/smoke/test_hf_cache.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,23 @@ def test_mount_is_read_only(self, mount_pod_spec: dict) -> None:
8484
cmd = " ".join(mount_pod_spec["containers"][0].get("command", []))
8585
assert "--read-only" in cmd, "rclone mount must be --read-only — job pods must not write the shared cache."
8686

87+
def test_gomemlimit_below_container_limit(self, mount_pod_spec: dict) -> None:
88+
"""deploy.sh renders GOMEMLIMIT from the tier's memory limit (~90%, in Go's MiB
89+
format) so the Go GC caps the heap before the kernel OOM-kills the mount. It must be
90+
set and sit below the container limit (headroom for non-heap memory)."""
91+
rclone = mount_pod_spec["containers"][0]
92+
gomemlimit = next((e.get("value") for e in rclone.get("env", []) if e["name"] == "GOMEMLIMIT"), None)
93+
assert gomemlimit is not None, "rclone must set a literal GOMEMLIMIT env."
94+
assert gomemlimit.endswith("MiB"), f"GOMEMLIMIT must be Go MiB format (not k8s Mi); got {gomemlimit!r}."
95+
gml_mib = int(gomemlimit[:-3])
96+
97+
limit = rclone.get("resources", {}).get("limits", {}).get("memory", "")
98+
limit_mib = int(limit[:-2]) * (1024 if limit.endswith("Gi") else 1)
99+
assert 0 < gml_mib < limit_mib, (
100+
f"GOMEMLIMIT ({gml_mib}MiB) must be >0 and below the container limit ({limit}) — "
101+
"a soft ceiling with headroom, not the hard cap."
102+
)
103+
87104
def test_hostpath_bidirectional(self, mount_pod_spec: dict) -> None:
88105
mounts = mount_pod_spec["containers"][0].get("volumeMounts", [])
89106
hf = [m for m in mounts if m.get("mountPath") == MOUNT_PATH]

0 commit comments

Comments
 (0)