File size: 8,853 Bytes
dfb775d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 | """CPU throttle for the trl_cpu lane — schema validation + thread resolver."""
from __future__ import annotations
import pytest
from pydantic import ValidationError
from mindxtrain.config.schema import (
CPUThrottleCfg,
LoraMethod,
MetaCfg,
ModelCfg,
TrainCfg,
XTrainConfig,
resolve_thread_count,
)
# ---- resolver -------------------------------------------------------------
@pytest.mark.parametrize(
("percent", "total", "expected"),
[
(100, 4, 4), # full host
(50, 4, 2), # half on a 4-core laptop
(25, 4, 1), # quarter; floor at 1
(1, 4, 1), # extreme low; still ≥ 1
(100, 1, 1), # single-core box
(50, 8, 4), # 8-core CCX → half
(33, 16, 5), # rounds down (16 * 33 // 100 = 5)
(75, 12, 9), # 12 * 75 // 100 = 9
],
)
def test_resolve_thread_count_matrix(percent, total, expected):
assert resolve_thread_count(percent, total) == expected
def test_resolve_thread_count_rejects_zero_cores():
with pytest.raises(ValueError, match="total_cores"):
resolve_thread_count(50, 0)
def test_resolve_thread_count_rejects_out_of_range_percent():
with pytest.raises(ValueError, match="percent"):
resolve_thread_count(0, 4)
with pytest.raises(ValueError, match="percent"):
resolve_thread_count(101, 4)
# ---- schema validation ----------------------------------------------------
def test_cpu_throttle_defaults():
"""Default = full host, no nice, OMP affinity on. Matches prior behaviour."""
cfg = CPUThrottleCfg()
assert cfg.percent == 100
assert cfg.nice_level == 0
assert cfg.omp_proc_bind is True
def test_cpu_throttle_explicit_values():
cfg = CPUThrottleCfg(percent=50, nice_level=10, omp_proc_bind=False)
assert cfg.percent == 50
assert cfg.nice_level == 10
assert cfg.omp_proc_bind is False
def test_cpu_throttle_rejects_invalid_percent():
with pytest.raises(ValidationError):
CPUThrottleCfg(percent=0)
with pytest.raises(ValidationError):
CPUThrottleCfg(percent=101)
with pytest.raises(ValidationError):
CPUThrottleCfg(percent=-5)
def test_cpu_throttle_rejects_invalid_nice_level():
"""POSIX nice range is [-20, 19]."""
with pytest.raises(ValidationError):
CPUThrottleCfg(nice_level=-21)
with pytest.raises(ValidationError):
CPUThrottleCfg(nice_level=20)
def test_cpu_throttle_is_frozen():
cfg = CPUThrottleCfg(percent=50)
with pytest.raises(ValidationError):
cfg.percent = 75 # type: ignore[misc]
def test_cpu_throttle_extra_keys_forbidden():
with pytest.raises(ValidationError):
CPUThrottleCfg(percent=50, mystery="value") # type: ignore[call-arg]
# ---- TrainCfg wiring ------------------------------------------------------
def test_train_cfg_has_default_throttle():
"""Default TrainCfg gets CPUThrottleCfg with full-host defaults so the
schema is backward compatible (existing recipes don't need the field)."""
cfg = TrainCfg()
assert isinstance(cfg.cpu_throttle, CPUThrottleCfg)
assert cfg.cpu_throttle.percent == 100
def test_full_xtrain_config_with_throttle():
"""A recipe can opt into a throttled CPU run."""
cfg = XTrainConfig(
meta=MetaCfg(project="p", run_name="r"),
model=ModelCfg(name="HuggingFaceTB/SmolLM2-135M"),
data={"source": "hf", "hf_id": "tatsu-lab/alpaca"}, # type: ignore[arg-type]
train=TrainCfg(
backend="trl_cpu",
method=LoraMethod(r=8, alpha=16),
cpu_throttle=CPUThrottleCfg(percent=25, nice_level=10),
),
)
assert cfg.train.backend == "trl_cpu"
assert cfg.train.cpu_throttle.percent == 25
assert cfg.train.cpu_throttle.nice_level == 10
def test_recipes_with_throttle_round_trip_yaml():
"""A YAML recipe can specify cpu_throttle and round-trip through the
schema, matching the pattern existing recipes use."""
import yaml as _yaml
cfg_text = _yaml.safe_dump({
"meta": {"project": "p", "run_name": "r"},
"model": {"name": "HuggingFaceTB/SmolLM2-135M"},
"data": {"source": "hf", "hf_id": "tatsu-lab/alpaca"},
"train": {
"backend": "trl_cpu",
"cpu_throttle": {"percent": 50, "nice_level": 5, "omp_proc_bind": False},
},
})
cfg = XTrainConfig.model_validate(_yaml.safe_load(cfg_text))
assert cfg.train.cpu_throttle.percent == 50
assert cfg.train.cpu_throttle.nice_level == 5
assert cfg.train.cpu_throttle.omp_proc_bind is False
# ---- Backend-side env-var application ------------------------------------
def _xtrain_with_throttle(percent: int, nice_level: int = 0,
omp_proc_bind: bool = True) -> XTrainConfig:
return XTrainConfig(
meta=MetaCfg(project="p", run_name="r"),
model=ModelCfg(name="HuggingFaceTB/SmolLM2-135M"),
data={"source": "hf", "hf_id": "tatsu-lab/alpaca"}, # type: ignore[arg-type]
train=TrainCfg(
backend="trl_cpu",
method=LoraMethod(r=4, alpha=8),
cpu_throttle=CPUThrottleCfg(
percent=percent, nice_level=nice_level, omp_proc_bind=omp_proc_bind,
),
),
)
def test_apply_cpu_throttle_sets_thread_env_vars(monkeypatch):
"""50% on a synthetic 8-core host → 4 threads in every BLAS env var."""
monkeypatch.setattr("os.cpu_count", lambda: 8)
for var in ("OMP_NUM_THREADS", "MKL_NUM_THREADS", "OPENBLAS_NUM_THREADS",
"NUMEXPR_NUM_THREADS", "TOKENIZERS_PARALLELISM",
"OMP_PROC_BIND", "OMP_PLACES"):
monkeypatch.delenv(var, raising=False)
from mindxtrain.train.backend_trl_cpu import _apply_cpu_throttle
cfg = _xtrain_with_throttle(50)
sink_lines: list[str] = []
threads = _apply_cpu_throttle(cfg, sink_lines.append)
assert threads == 4
import os
assert os.environ["OMP_NUM_THREADS"] == "4"
assert os.environ["MKL_NUM_THREADS"] == "4"
assert os.environ["OPENBLAS_NUM_THREADS"] == "4"
assert os.environ["NUMEXPR_NUM_THREADS"] == "4"
# OMP_PROC_BIND enabled by default → close/cores set.
assert os.environ["OMP_PROC_BIND"] == "close"
assert os.environ["OMP_PLACES"] == "cores"
# tokenizers parallelism left on when threads > 2.
assert os.environ["TOKENIZERS_PARALLELISM"] == "true"
# The sink got at least one summary line.
assert any("throttle" in line.lower() for line in sink_lines)
def test_apply_cpu_throttle_disables_tokenizer_parallelism_for_low_thread_counts(
monkeypatch,
):
"""≤ 2 threads → TOKENIZERS_PARALLELISM=false (Rust tokenizer thrashes
cache at low core counts during a smoke run on a tiny model)."""
monkeypatch.setattr("os.cpu_count", lambda: 4)
monkeypatch.delenv("TOKENIZERS_PARALLELISM", raising=False)
from mindxtrain.train.backend_trl_cpu import _apply_cpu_throttle
cfg = _xtrain_with_throttle(25) # 25% of 4 → 1 thread
_apply_cpu_throttle(cfg, lambda _: None)
import os
assert os.environ["OMP_NUM_THREADS"] == "1"
assert os.environ["TOKENIZERS_PARALLELISM"] == "false"
def test_apply_cpu_throttle_skips_omp_proc_bind_when_disabled(monkeypatch):
"""Recipes can opt out of CCX pinning (e.g., on Intel where it's still
safe but unnecessary)."""
monkeypatch.setattr("os.cpu_count", lambda: 4)
for var in ("OMP_PROC_BIND", "OMP_PLACES"):
monkeypatch.delenv(var, raising=False)
from mindxtrain.train.backend_trl_cpu import _apply_cpu_throttle
cfg = _xtrain_with_throttle(50, omp_proc_bind=False)
_apply_cpu_throttle(cfg, lambda _: None)
import os
# The env vars must NOT be set when the flag is off.
assert "OMP_PROC_BIND" not in os.environ
assert "OMP_PLACES" not in os.environ
def test_apply_cpu_throttle_handles_nice_permission_error(monkeypatch):
"""Negative nice needs CAP_SYS_NICE. The throttle must not crash on
refusal — it should log and continue."""
monkeypatch.setattr("os.cpu_count", lambda: 4)
monkeypatch.setattr("os.nice", lambda _: (_ for _ in ()).throw(PermissionError("denied")))
from mindxtrain.train.backend_trl_cpu import _apply_cpu_throttle
cfg = _xtrain_with_throttle(50, nice_level=-5)
sink_lines: list[str] = []
_apply_cpu_throttle(cfg, sink_lines.append)
assert any("refused" in line.lower() for line in sink_lines)
def test_apply_cpu_throttle_full_host_when_percent_is_100(monkeypatch):
"""percent=100 (the default) caps at the host's actual cores."""
monkeypatch.setattr("os.cpu_count", lambda: 4)
from mindxtrain.train.backend_trl_cpu import _apply_cpu_throttle
cfg = _xtrain_with_throttle(100)
threads = _apply_cpu_throttle(cfg, lambda _: None)
assert threads == 4
|