mirror of
https://github.com/logos-blockchain/research.git
synced 2026-08-07 19:53:10 +00:00
183 lines
9.6 KiB
Python
183 lines
9.6 KiB
Python
|
|
"""Worker-count memory planning: analytic estimate, RAM cap, and the calibration probe."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import os
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from tsi_sim import sweep
|
||
|
|
from tsi_sim.config import SimConfig
|
||
|
|
|
||
|
|
# The explosion/throttle behaviours below are properties of the FULL (unpruned) arrival matrix,
|
||
|
|
# so they pin prune_arrival=False; the pruned path's much smaller footprint is tested separately.
|
||
|
|
FULL = dict(prune_arrival=False)
|
||
|
|
|
||
|
|
|
||
|
|
def test_estimate_grows_with_n_and_k():
|
||
|
|
small = sweep.estimate_worker_bytes(SimConfig(n_nodes=1000, k=256, **FULL))
|
||
|
|
big_n = sweep.estimate_worker_bytes(SimConfig(n_nodes=4000, k=256, **FULL))
|
||
|
|
big_k = sweep.estimate_worker_bytes(SimConfig(n_nodes=1000, k=2160, **FULL))
|
||
|
|
assert big_n > small and big_k > small # both N (via A + N^2) and k (via n_blocks)
|
||
|
|
|
||
|
|
|
||
|
|
def test_low_genesis_d_factor_explodes_block_estimate():
|
||
|
|
# The collapsed-D_est regime: a 100x-low genesis estimate inflates lottery wins ~100x, so
|
||
|
|
# the peak-epoch block count explodes. Without pruning that blows up A (the OOM that froze the
|
||
|
|
# box); the estimate must reflect it.
|
||
|
|
hi = SimConfig(n_nodes=1000, k=2160, stake_dist="pareto", genesis_d_factor=0.5, **FULL)
|
||
|
|
lo = SimConfig(n_nodes=1000, k=2160, stake_dist="pareto", genesis_d_factor=0.01, **FULL)
|
||
|
|
assert sweep.expected_peak_blocks(lo) > 20 * sweep.expected_peak_blocks(hi)
|
||
|
|
assert sweep.expected_peak_blocks(lo) > 10 * (10 * lo.k) # far past the ~10*k equilibrium
|
||
|
|
assert sweep.estimate_worker_bytes(lo) > 10 * 1024**3 # unpruned -> tens of GB/worker
|
||
|
|
|
||
|
|
|
||
|
|
def test_prune_shrinks_estimate_and_keeps_all_workers(monkeypatch):
|
||
|
|
# The whole point of prune_arrival (default on): the same gdf=0.01 config no longer needs a
|
||
|
|
# huge per-worker matrix, so it stays well under a GB and does NOT throttle the worker pool.
|
||
|
|
monkeypatch.setattr(sweep, "_total_ram_bytes", lambda: 51 * 1024**3)
|
||
|
|
lo = SimConfig(n_nodes=1000, k=2160, stake_dist="pareto", genesis_d_factor=0.01) # prune on
|
||
|
|
assert sweep.estimate_worker_bytes(lo) < 1 * 1024**3 # vs >10 GB unpruned
|
||
|
|
plan = sweep.plan_workers(requested=-1, configs=[lo], mem_frac=0.7, calibrate="never")
|
||
|
|
assert plan.n_jobs == (os.cpu_count() or 1) # all cores, no throttle
|
||
|
|
|
||
|
|
|
||
|
|
def test_low_gdf_caps_workers_hard(monkeypatch):
|
||
|
|
monkeypatch.setattr(sweep, "_total_ram_bytes", lambda: 51 * 1024**3)
|
||
|
|
lo = SimConfig(n_nodes=1000, k=2160, stake_dist="pareto", genesis_d_factor=0.01, **FULL)
|
||
|
|
plan = sweep.plan_workers(requested=-1, configs=[lo], mem_frac=0.7, calibrate="never")
|
||
|
|
assert plan.n_jobs * plan.per_worker_bytes <= int(0.7 * 51 * 1024**3) + plan.per_worker_bytes
|
||
|
|
assert plan.n_jobs <= 2 # unpruned: was 14 -> ~206 GB; now a couple
|
||
|
|
|
||
|
|
|
||
|
|
def test_auto_calibration_fires_on_bytes_threshold(monkeypatch):
|
||
|
|
# Even at small N, a heavy per-worker estimate (low gdf, unpruned) must trigger the probe.
|
||
|
|
seen = {}
|
||
|
|
|
||
|
|
def _probe(cfg, **k):
|
||
|
|
seen["n"] = cfg.n_nodes
|
||
|
|
return 3 * 1024**3
|
||
|
|
monkeypatch.setattr(sweep, "_total_ram_bytes", lambda: 64 * 1024**3)
|
||
|
|
monkeypatch.setattr(sweep, "measure_worker_bytes", _probe)
|
||
|
|
lo = SimConfig(n_nodes=1000, k=2160, stake_dist="pareto", genesis_d_factor=0.01, **FULL)
|
||
|
|
plan = sweep.plan_workers(requested=-1, configs=[lo], mem_frac=0.7, calibrate="auto")
|
||
|
|
assert plan.calibrated and seen.get("n") == 1000 # probed despite N <= 2000
|
||
|
|
|
||
|
|
|
||
|
|
def test_arrival_matrix_guard_raises_before_allocation(monkeypatch):
|
||
|
|
# Budget chosen so path_latency (100x100 -> ~0.18 MB) fits but the block-exploded A
|
||
|
|
# (gdf=0.01 -> ~30k blocks -> ~25 MB) does not, so the *arrival-matrix* guard is what fires.
|
||
|
|
from tsi_sim.engine import run_trajectory
|
||
|
|
from tsi_sim.memguard import ArrivalMatrixTooLarge
|
||
|
|
monkeypatch.setenv("TSI_ARRIVAL_BYTES_BUDGET", str(1_000_000))
|
||
|
|
cfg = SimConfig(n_nodes=100, k=32, epochs=1, topology="regular", degree=4,
|
||
|
|
stake_dist="pareto", genesis_d_factor=0.01, prune_arrival=False)
|
||
|
|
with pytest.raises(ArrivalMatrixTooLarge, match="arrival matrix A"):
|
||
|
|
run_trajectory(cfg)
|
||
|
|
|
||
|
|
|
||
|
|
def test_pruned_arrival_buffer_guard_raises(monkeypatch):
|
||
|
|
# The pruned buffer is guarded too. Budget (0.4 MB) is chosen so path_latency (~0.18 MB) fits
|
||
|
|
# but the sliding buffer (~1.4 MB, vs a ~21 MB full matrix here) does not — so the *pruned*
|
||
|
|
# guard fires, and the message distinguishes it from the full-matrix one.
|
||
|
|
from tsi_sim.engine import run_trajectory
|
||
|
|
from tsi_sim.memguard import ArrivalMatrixTooLarge
|
||
|
|
monkeypatch.setenv("TSI_ARRIVAL_BYTES_BUDGET", str(400_000))
|
||
|
|
cfg = SimConfig(n_nodes=100, k=32, epochs=1, topology="regular", degree=4,
|
||
|
|
stake_dist="pareto", genesis_d_factor=0.01, prune_arrival=True)
|
||
|
|
with pytest.raises(ArrivalMatrixTooLarge, match="pruned arrival buffer"):
|
||
|
|
run_trajectory(cfg)
|
||
|
|
|
||
|
|
|
||
|
|
def test_path_latency_guard_raises_before_allocation(monkeypatch):
|
||
|
|
# The (N x N) path_latency is guarded too, BEFORE the arrival matrix is ever reached
|
||
|
|
# (it is built first in run_trajectory) — so a large-N / small-n_blocks config can't slip past.
|
||
|
|
from tsi_sim import topology
|
||
|
|
from tsi_sim.memguard import ArrivalMatrixTooLarge
|
||
|
|
monkeypatch.setenv("TSI_ARRIVAL_BYTES_BUDGET", "1024")
|
||
|
|
cfg = SimConfig(n_nodes=300, k=8, topology="full_mesh")
|
||
|
|
import numpy as np
|
||
|
|
with pytest.raises(ArrivalMatrixTooLarge):
|
||
|
|
topology.build_path_latency(cfg, np.random.default_rng(0))
|
||
|
|
|
||
|
|
|
||
|
|
def test_unset_budget_defaults_to_ram_fraction(monkeypatch):
|
||
|
|
# "0"/unset is NOT unlimited: it resolves to a fraction of physical RAM, so a bare
|
||
|
|
# run_trajectory / tsi-verify / mem_frac=0 run still has an absolute per-process ceiling.
|
||
|
|
from tsi_sim import memguard
|
||
|
|
from tsi_sim.engine import run_trajectory
|
||
|
|
monkeypatch.setattr(memguard, "total_ram_bytes", lambda: 32 * 1024**3)
|
||
|
|
monkeypatch.delenv("TSI_ARRIVAL_BYTES_BUDGET", raising=False)
|
||
|
|
assert memguard.arrival_budget_bytes() == int(memguard.DEFAULT_BUDGET_FRAC * 32 * 1024**3)
|
||
|
|
monkeypatch.setenv("TSI_ARRIVAL_BYTES_BUDGET", "0")
|
||
|
|
assert memguard.arrival_budget_bytes() == int(memguard.DEFAULT_BUDGET_FRAC * 32 * 1024**3)
|
||
|
|
monkeypatch.setenv("TSI_ARRIVAL_BYTES_BUDGET", str(5 * 1024**3))
|
||
|
|
assert memguard.arrival_budget_bytes() == 5 * 1024**3 # explicit positive wins
|
||
|
|
monkeypatch.delenv("TSI_ARRIVAL_BYTES_BUDGET", raising=False)
|
||
|
|
rows = run_trajectory(SimConfig(n_nodes=80, k=8, epochs=1, topology="regular", degree=4))
|
||
|
|
assert rows # small config well under the ceiling
|
||
|
|
|
||
|
|
|
||
|
|
def test_mem_frac_zero_disables_cap():
|
||
|
|
plan = sweep.plan_workers(requested=4, configs=[SimConfig(n_nodes=9999, k=2160)],
|
||
|
|
mem_frac=0.0, calibrate="never")
|
||
|
|
assert plan.n_jobs == 4 and not plan.calibrated
|
||
|
|
|
||
|
|
|
||
|
|
def test_estimate_caps_workers_when_grid_is_heavy(monkeypatch):
|
||
|
|
# Fix RAM so the cap is machine-independent: 16 GB budget*0.7 = 11.2 GB, config >> that -> 1.
|
||
|
|
monkeypatch.setattr(sweep, "_total_ram_bytes", lambda: 16 * 1024**3)
|
||
|
|
heavy = SimConfig(n_nodes=20200, k=2160, **FULL) # unpruned -> tens of GB/worker
|
||
|
|
plan = sweep.plan_workers(requested=8, configs=[heavy], mem_frac=0.7, calibrate="never")
|
||
|
|
assert plan.n_jobs == 1 and plan.per_worker_bytes > 8 * 1024**3
|
||
|
|
|
||
|
|
|
||
|
|
def test_auto_does_not_probe_at_or_below_threshold(monkeypatch):
|
||
|
|
called = False
|
||
|
|
|
||
|
|
def _boom(*a, **k):
|
||
|
|
nonlocal called
|
||
|
|
called = True
|
||
|
|
return 1
|
||
|
|
monkeypatch.setattr(sweep, "measure_worker_bytes", _boom)
|
||
|
|
plan = sweep.plan_workers(requested=-1, configs=[SimConfig(n_nodes=2000, k=2160)],
|
||
|
|
mem_frac=0.7, calibrate="auto")
|
||
|
|
assert not called and not plan.calibrated # N<=2000 uses the analytic estimate only
|
||
|
|
|
||
|
|
|
||
|
|
def test_auto_probes_above_threshold_and_uses_measurement(monkeypatch):
|
||
|
|
measured = 2 * 1024**3 # 2 GB peak RSS reported by the probe
|
||
|
|
ram = 64 * 1024**3
|
||
|
|
monkeypatch.setattr(sweep, "_total_ram_bytes", lambda: ram)
|
||
|
|
monkeypatch.setattr(sweep, "measure_worker_bytes", lambda cfg, **k: measured)
|
||
|
|
plan = sweep.plan_workers(requested=-1, configs=[SimConfig(n_nodes=3000, k=2160)],
|
||
|
|
mem_frac=0.7, calibrate="auto")
|
||
|
|
assert plan.calibrated
|
||
|
|
assert plan.per_worker_bytes == int(measured * 1.1) # 10% headroom over the measurement
|
||
|
|
fit = int(0.7 * ram // plan.per_worker_bytes)
|
||
|
|
assert plan.n_jobs == min(os.cpu_count() or 1, fit)
|
||
|
|
|
||
|
|
|
||
|
|
def test_probe_failure_falls_back_to_estimate(monkeypatch):
|
||
|
|
monkeypatch.setattr(sweep, "measure_worker_bytes", lambda cfg, **k: None) # probe unavailable
|
||
|
|
cfg = SimConfig(n_nodes=3000, k=2160)
|
||
|
|
plan = sweep.plan_workers(requested=-1, configs=[cfg], mem_frac=0.7, calibrate="always")
|
||
|
|
assert not plan.calibrated
|
||
|
|
assert plan.per_worker_bytes == sweep.estimate_worker_bytes(cfg)
|
||
|
|
|
||
|
|
|
||
|
|
def test_ru_maxrss_unit_normalisation(monkeypatch):
|
||
|
|
monkeypatch.setattr(sweep.sys, "platform", "darwin")
|
||
|
|
assert sweep._ru_maxrss_bytes(1000) == 1000 # macOS already bytes
|
||
|
|
monkeypatch.setattr(sweep.sys, "platform", "linux")
|
||
|
|
assert sweep._ru_maxrss_bytes(1000) == 1000 * 1024 # Linux reports kibibytes
|
||
|
|
|
||
|
|
|
||
|
|
def test_measure_worker_bytes_real_spawn():
|
||
|
|
# Exercise the real probe on a tiny config. Spawn needs an importable __main__; if the test
|
||
|
|
# environment cannot bootstrap the child, the probe returns None (graceful) and we skip.
|
||
|
|
got = sweep.measure_worker_bytes(SimConfig(n_nodes=40, k=6, epochs=2, degree=4), timeout=180)
|
||
|
|
if got is None:
|
||
|
|
pytest.skip("spawn-based calibration probe unavailable in this environment")
|
||
|
|
assert got > 30 * 1024**2 # any real Python+numpy worker RSS clears tens of MB
|