Кампания на пять равных долей: контейнер сразу считает свою часть

gen_scenarios.py раскладывает 115 прогонов по пяти долям (LPT, поправка
на размер сетки по замерам dzn) — по ≈24 ч каждая; поле shard в
scenarios.json. run_campaign.sh/.ps1 получили --shard. Новая точка входа
образа entrypoint.sh: при KBC2D_SHARD сама выбирает путь к карте (dzn
или NVIDIA), проверяет vulkaninfo и parity.py и запускает долю. Для
чужих ПК под Windows/WSL2 — docker-compose.shards.yml и shard.bat.
Образ notbigghost/kbc2d:1.3.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 20:15:40 +03:00
co-authored by Claude Opus 5.5
parent 3e81ed130c
commit 1d495e3e89
12 changed files with 582 additions and 130 deletions
+59 -3
View File
@@ -5,6 +5,7 @@
# получаются циклами, а стоимость каждого прогона считается тут же и суммируется. Запуск:
# python gen_scenarios.py # перезаписать scenarios.json и напечатать смету
# python gen_scenarios.py --scale 2 # растянуть все длительности вдвое
# python gen_scenarios.py --shards 5 # на сколько равных по времени долей делить кампанию
#
# Стоимость меряется в ОБНОВЛЕНИЯХ УЗЛОВ (nodes_per_step * steps) — единственная переносимая
# между машинами мера. Часы драйвер получает из неё, поделив на фактические MLUPS, которые
@@ -23,6 +24,18 @@ GIF_MAX_W = 1280 # предельная ширина кадра после
GIF_MIN_W = 480 # ниже не опускаемся: нечитаемая гифка бесполезнее большой
GIF_PX_BUDGET = 1.5e9 # кадры×пиксели на гифку; при 0.103 байта на пиксель это ~150 МБ
# Доли кампании: прогоны раскладываются по N контейнерам так, чтобы те считали примерно
# одинаковое ВРЕМЯ, а не одинаковое число узлов. Время на узел зависит от размера сетки:
# на мелкой накладные расходы на отправку в очередь дороже самого счёта. Кривая — замер
# dzn в контейнере (Intel Iris Xe, bench/README.md, «Чего трансляция стоит по скорости»),
# потому что доли рассчитаны на чужие Windows/WSL-машины, где путь к карте — именно dzn.
# Важна только ФОРМА кривой: абсолют нормируется к ASSUMED_GPU_MLUPS.
ASSUMED_GPU_MLUPS = 1200
ASSUMED_CPU_MLUPS = 22
DZN_MLUPS = [(61_440, 44.5), (460_800, 86.0), (1_843_200, 98.7)]
SPLIT_NODES = 3_730_000 # выше — раздельные привязки популяций, у них своя скорость
SPLIT_MLUPS = 67.4 # замер на 2048×2048
def conv_steps(d_cells, n_conv, u_lat=U_LAT):
"""Сколько шагов нужно на n_conv конвективных времён D/U."""
@@ -472,15 +485,50 @@ def group_i(scale):
nx * ny, st, gif="fine_multi.gif", nx=nx)
def est_hours(r):
"""Оценка времени прогона в часах для раскладки по долям (см. DZN_MLUPS)."""
if r["backend"] == "cpu":
return r["cost"] / (ASSUMED_CPU_MLUPS * 1e6) / 3600
n = r["nodes_per_step"]
if n > SPLIT_NODES:
m = SPLIT_MLUPS
elif n <= DZN_MLUPS[0][0]:
m = DZN_MLUPS[0][1]
elif n >= DZN_MLUPS[-1][0]:
m = DZN_MLUPS[-1][1]
else:
for (n0, m0), (n1, m1) in zip(DZN_MLUPS, DZN_MLUPS[1:]):
if n <= n1:
t = math.log(n / n0) / math.log(n1 / n0)
m = m0 + t * (m1 - m0)
break
rel = m / DZN_MLUPS[-1][1]
return r["cost"] / (ASSUMED_GPU_MLUPS * 1e6 * rel) / 3600
def assign_shards(runs, n):
"""Разложить прогоны по n долям жадно (LPT): самый долгий — в самую лёгкую долю.
Порядок прогонов внутри доли остаётся порядком групп. Возвращает часы по долям."""
load = [0.0] * n
for r in sorted(runs, key=lambda r: (-est_hours(r), r["id"])):
k = min(range(n), key=lambda i: load[i])
r["shard"] = k + 1
load[k] += est_hours(r)
return load
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--scale", type=float, default=2.0,
help="общий множитель длительности всех прогонов")
ap.add_argument("--shards", type=int, default=5,
help="на сколько равных по времени долей делить кампанию")
ap.add_argument("--out", default=os.path.join(os.path.dirname(__file__), "scenarios.json"))
args = ap.parse_args()
for g in (group_a, group_b, group_c, group_d, group_e, group_f, group_g, group_h, group_i):
g(args.scale)
shard_hours = assign_shards(RUNS, args.shards)
doc = {
"meta": {
@@ -490,12 +538,14 @@ def main():
"gif_policy": "вся длительность прогона, реальная скорость, 10 кадр/с; кадр "
"прореживается до ширины не больше 1280 пикселей",
"scale": args.scale,
"assumed_gpu_mlups": 1200,
"assumed_cpu_mlups": 22,
"assumed_gpu_mlups": ASSUMED_GPU_MLUPS,
"assumed_cpu_mlups": ASSUMED_CPU_MLUPS,
"shards": args.shards,
"shard_hours": [round(h, 2) for h in shard_hours],
},
"runs": RUNS,
}
with open(args.out, "w", encoding="utf-8") as f:
with open(args.out, "w", encoding="utf-8", newline="\n") as f:
json.dump(doc, f, ensure_ascii=False, indent=1)
print(f"прогонов: {len(RUNS)} файл: {args.out}\n")
@@ -513,6 +563,12 @@ def main():
f"{tot_gpu:>12.1f}{tot_cpu:>12.1f}")
print(f"\nвсего ≈ {tot_gpu + tot_cpu:.1f} ч при 1200 MLUPS на GPU и 22 на CPU")
print("\nдоли (часы с поправкой на размер сетки, см. DZN_MLUPS):")
for k, h in enumerate(shard_hours, 1):
n = sum(1 for r in RUNS if r["shard"] == k)
print(f" доля {k}: {n:>3} прогонов ≈ {h:5.1f} ч")
print(f" разброс: {(max(shard_hours) / min(shard_hours) - 1) * 100:.1f} %")
if __name__ == "__main__":
main()