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