DESIGN NOTE — 2026-04-10
이 문서는 멀티 GPU 잡 스케줄링 정책을 새로 결정하는 과정에서 우리가 검토한 모든 옵션과 채택/기각 이유를 기록합니다. 운영 정책 페이지가 "지금 어떻게 동작하는가"를 설명한다면, 이 노트는 "왜 그렇게 결정했는가"를 설명합니다. 향후 정책 변경을 검토할 때 이 문서가 이전 논의의 출발점이 됩니다.
이 노트는 최대한 상세를 목표로 작성되었습니다. 한 줄짜리 요약이 필요하면 운영 정책 페이지를 보세요.
BACKGROUND
이번 작업 전 JPU-Share 스케줄러는 head-of-line skip 방식이었습니다.
app/services/scheduler.py의 _select_best_job이 매 5초마다:
sort_order DESC로 정렬sinfo로 idle GPU 수를 물어본다lab.cumulative_usage ASC)로 정렬이 방식의 문제는 1순위 잡이 매칭 안 되면 그냥 건너뛴다는 점입니다. 예를 들어 1순위가 num_gpus=2인데 어떤 티어도 idle 2장이 없으면, 그 잡은 skip되고 다음 잡(num_gpus=1)이 dispatch됩니다. 큐 순서가 fair-share 점수와 일치하지 않게 되어 멀티 GPU 잡의 starvation이 잠재적으로 발생할 수 있었습니다.
| 문제 | 설명 |
|---|---|
| 학생 num_gpus 제한 | 학생은 num_gpus > 1을 못 쓰게 막아둔 코드(app/api/routes/jobs.py:79)가 있었음. 명확한 정책 근거 없이 그냥 박혀있었음. → 이번에 제거 |
| num_gpus 곱 누락 버그 (usage 계산) | record_job_usage가 tflops × duration만 곱하고 num_gpus를 곱하지 않음. 즉 num_gpus=2 잡과 num_gpus=1 잡이 같은 시간 실행하면 같은 사용량으로 카운팅됨. 명백한 버그 |
| 노드 분산 false positive | parse_sinfo_gpu_output이 파티션 전체에서 idle GPU 수를 단순 합산. 같은 티어에 노드 A 1장 + 노드 B 1장이면 idle=2로 인식되지만, sbatch는 단일 노드에서 2장을 못 모아 잡이 PENDING(Resources)로 남음. 현재 staging은 티어당 노드 1개라 표면화되지 않은 잠재 버그 |
이번 정책 재설계의 4가지 목표:
EVOLUTION
이 섹션은 결정이 어떤 순서로 합의되었는지를 시간 순으로 기록합니다. 각 단계의 핵심 질문, 답, 이유를 명시합니다.
발견: app/api/routes/jobs.py:79에 학생은 num_gpus > 1 제출 시 403을 반환하는 가드가 있었음.
어떤 정책 문서나 RBAC 명세에도 근거가 없음. MVP 시절의 잔재로 추정.
결정: 즉시 제거. 학생도 num_gpus 1~8 범위에서 자유롭게 제출 가능. 실제 상한은 티어의 max_gpus로 제한.
이유: 명문화되지 않은 정책은 정책이 아님. 운영자 임의 판단 코드는 사용자 신뢰를 떨어뜨림. 진짜 제한이 필요하면 정책으로 명시하고 코드와 일치시켜야 함.
질문: 멀티 GPU 잡과 싱글 GPU 잡을 같은 큐에서 처리할 것인가, 분리할 것인가?
| 옵션 | 장점 | 단점 |
|---|---|---|
| 단일 큐 (현행 유지) | 관리 단순. 정렬 키 1개. fair-share 일관성 | 매칭 시점에 multi/single 차이를 어떻게 처리할지 결정 필요 |
| tier별 큐 | 티어별 자원 보호 가능 | 큐 N개 관리, fair-share를 큐 간에 어떻게 조정할지 복잡 |
| multi/single 분리 큐 | multi 잡 우선권 부여 가능 | 큐 간 균형 정책이 새 매직 넘버. starvation 양방향 가능 |
결정: 단일 큐 유지.
이유: 코드는 이미 단일 큐였고(state == GLOBAL_QUEUE_WAIT), 분리하면 큐 간 정책이라는 새로운 차원이 추가되어 복잡도가 폭증함. 단순함 우선 원칙.
질문: 매칭 알고리즘의 outer loop를 무엇으로 할 것인가?
처음에는 "잡 중심"(현행)과 "device 중심"의 두 가지를 검토했습니다.
잡 중심 (현행): for job in fair_share_sorted_jobs:
for tier in tiers:
if matches: dispatch
→ 1순위 잡이 안 맞으면 skip → 다음 잡
device 중심: for device in idle_devices:
for job in fair_share_sorted_jobs:
if fits: dispatch
→ 빈 자원에 들어갈 수 있는 가장 위 잡을 찾음
그런데 사용자가 "device가 비었을 때 ... 순위대로 검색"이라고 한 표현을 분석해보니, 진짜 단위는 "노드"여야 한다는 게 드러났습니다 — 멀티 GPU 잡은 Slurm 기본 동작상 단일 노드에서 모든 GPU를 받아야 하기 때문입니다.
하지만 또 한 단계 더 들어가보니, "노드"도 정확하지 않았습니다 — 한 노드에 이종 GPU가 섞여 있을 수 있기 때문입니다.
예: 가상의 노드에 4090×4 + 5090×4
→ 노드 단위로 보면 capacity=8
→ 그런데 num_gpus=8 잡은 4090과 5090을 섞어 받지 못함
→ 노드 단위는 잘못된 단위
진짜 단위는 "같은 노드 + 같은 GPU 모델"의 묶음입니다. 용어를 "set"으로 시작해서 최종적으로 placement로 확정.
결정: placement 단위 매칭. (node_name, tier_id)를 키로 하는 derived view.
이유:
gpu_resources 테이블이 이미 있어서 GROUP BY로 자동 도출 (새 테이블 0개)잠시 "slot = 1 GPU"라는 추상화를 도입했었으나, 사용자가 "그냥 GPU라고 하면 되지 않냐"고 지적.
결정: 두 단어만 사용 — GPU(1 디바이스)와 placement(같은 노드 + 같은 모델 묶음). placement만 새 개념이고 나머지는 다 익숙한 용어.
이유: 추상화는 새로운 의미를 만들 때만 가치가 있음. "slot"은 "GPU"를 다른 이름으로 부르는 것뿐이라 가치 없음.
잡과 placement의 매칭 조건을 두 단계로 분리:
| 조건 | 정의 | 평가 시점 |
|---|---|---|
| fit | 잡이 이 placement에서 원리상 실행 가능 — tier ≥ minimum_tier AND capacity ≥ num_gpus |
제출 시 한 번 (정적) |
| available | 지금 즉시 dispatch 가능 — fit AND idle ≥ num_gpus |
매 5초 (동적) |
이유: fit은 정적이므로 잡 제출 시점에 한 번 평가. fit한 placement가 0개면 즉시 거부(STEP 6 참고). available은 동적이고 reservation 결정과 연결됨. 두 단계로 분리하면 알고리즘이 자연스럽게 정리됨.
발견: 사용자가 num_gpus=4를 요청했는데 모든 placement의 capacity가 2라면 어떤 매칭도 불가능. 이전 코드는 이 잡을 그냥 큐에 두고 영원히 대기.
결정: 제출 시점에 EXISTS (SELECT 1 FROM placements WHERE fit) 체크. 없으면 즉시 422 NO_FIT_PLACEMENT 반환.
이유: 사용자에게 영문도 모르고 무한 대기시키는 것보다 즉시 명확한 에러 메시지 반환이 압도적으로 좋음. 운영 부담도 감소.
핵심 결정: "큐 순서를 절대 어기지 않는다." fair-share 1순위 잡은 매칭 안 돼도 다음 차례를 양보하지 않음. 대신 가용한 GPU를 선점(reservation)해두고 추가 GPU를 기다림.
이유:
사용자가 도입을 제안한 옵션. Reservation 잡이 placement A에서 1/2장 선점 중인데, 같은 주기에 placement B에서 갑자기 2장이 동시에 idle해진다면?
매 주기 dispatch 알고리즘 4-(a):
잡의 fit placement 중 available한 게 있으면:
→ 즉시 dispatch
→ 만약 이 잡이 다른 placement에서 reservation 보유 중이었으면
그 reservation 해제 (★ swap)
결정: 채택. 알고리즘 4-(a)에서 자연스럽게 처리됨.
이유: Reservation은 best-effort 임시 할당. 더 빠른 dispatch 경로가 생기면 그쪽으로 점프하는 것이 latency 측면에서 무조건 이득. 코드 복잡도 증가도 거의 0 (algorithm 4-(a)가 항상 4-(c)보다 먼저 실행됨).
핵심 통찰: Reservation의 가장 큰 약점은 "선점된 GPU가 노는 시간 동안의 utilization 손실"인데, JPU-Share는 LLM 백필이 있으니 이 손실이 자동으로 0이 됩니다.
Reservation은 "다른 연구 잡 배정 금지" 마크일 뿐, LLM 백필은 그 GPU 위에서 그대로 동작합니다. dispatch 시점이 되면 LLM에게 종료 명령을 보내고 sbatch를 실행합니다.
결정:
이유: 우리 코드 단순화 + 책임 분리. LLM 라우터는 어차피 모델 dispatch 관리를 하고 있으므로 종료 상태 처리도 그쪽이 자연스러움.
이 단계가 가장 미묘했고 두 번 뒤집혔습니다.
Eager 가산 첫 안: reservation 진행 중 매 dispatch 주기마다 (이전 주기부터 지금까지의) 선점 비용을 lab.cumulative_usage에 incremental 가산. 자기교정 메커니즘.
사용자가 발견한 문제: 이 방식은 multi-GPU 잡이 single 잡보다 부가적인 비용이 누적된다 — 사용자가 single로 쪼개려는 인센티브 distortion이 생긴다. 더 큰 문제: 자기 잡의 reservation 비용이 자기 lab usage를 끌어올려 자기를 1순위에서 밀어내는 자살적 자기교정이 발생. "내가 reservation을 시작했는데 그것 때문에 내가 1순위에서 밀려난다"는 비직관적 동작.
Lazy 가산으로 전환: reservation 진행 중에는 lab usage 변동 없음. 잡이 어떤 형태로든 종료되는 시점에 한꺼번에 가산.
| 잡 종료 사건 | 가산 |
|---|---|
| dispatch 성공 → 정상 종료 | 선점 비용 + 실행 비용 (전부) |
| R3 강제 해제 | 선점 비용만 |
| Cancel (reservation 중) | 선점 비용만 |
| Placement 사라짐 (R5) | 선점 비용만 |
핵심 invariant: 잡이 점유한 모든 GPU-second는 빠짐없이 lab usage로 가산됨. 가산 시점만 lazy.
이유: Eager의 자살적 자기교정을 차단하면서, fair-share의 정직함은 그대로 유지. multi-GPU 부담은 줄어들지만 0이 되진 않음(클라우드 가격 모델과 일관).
STEP 10 이후 사용자가 추가로 짚은 미묘한 부분: "1순위 잡이 reservation을 가졌다고 해서 무조건 1순위가 영구 보장되는 것은 아니다. 같은 lab의 다른 잡이 종료되면서 lab usage가 누적되면, 결국 다른 lab이 1순위가 될 수 있다."
이는 정확히 fair-share의 의도된 동작입니다. R3는 살아있어야 하지만 트리거가 정제되어야 합니다:
| 트리거 종류 | 발생 조건 | Eager에서 | Lazy에서 |
|---|---|---|---|
| Internal | 자기 잡의 reservation 가산이 자기 lab을 끌어올림 | 발생 (자살) | 차단 |
| External | 다른 잡의 종료/새 잡 들어옴 등 외부 변동으로 lab 순위가 바뀜 | 발생 | 그대로 발생 (정상) |
R3 (개정 최종): reservation 잡이 외부 사용량 변동으로 1순위 자리에서 밀려나면 즉시 강제 해제. 그때까지의 선점 시간은 잡 종료 시점에 lab usage로 가산.
이유: external trigger는 fair-share의 본질이라 차단하면 안 됨. internal trigger는 사용자 직관과 어긋나고 불필요한 자살적 메커니즘이라 차단해야 함. Lazy가 정확히 이 둘을 분리.
사용자가 "multi-GPU 잡이 single보다 부가 비용이 많이 쌓이면 사용자들이 single로 쪼개려 할 것 아닌가" 질문.
분석: Multi-GPU의 세 가지 추가 부담:
결정: 이 부담을 받아들임. 보조나 할인 없음.
이유:
REJECTED OPTIONS
이 섹션은 검토 과정에서 등장했지만 채택되지 않은 옵션들과 그 이유를 기록합니다.
큐를 여러 개로 나누는 모델. tier당 하나, 또는 multi/single 분리.
기각 이유: 큐 간 fair-share 조정이 새로운 차원의 매직 넘버를 요구함. 한 큐에서 starvation을 막으면 다른 큐에서 발생할 수 있음. 단순함의 정반대.
평소엔 head-of-line skip(B)으로 동작하다가, 멀티 GPU 잡이 일정 시간 이상 대기하면 그때부터 새 single 잡 dispatch를 차단해서 슬롯을 모으는 모델.
기각 이유: LLM 백필 덕분에 reservation의 utilization 손실이 0이라는 것을 깨달은 후 C는 의미가 사라짐. 시간 임계치 T를 정해야 하는 매직 넘버 1개 추가. 단순한 reservation이 항상 더 우월.
Reservation 진행 중 매 주기 incremental하게 lab usage에 가산하는 모델. 자기교정 메커니즘이 의도.
기각 이유: 자기 잡의 reservation이 자기를 1순위에서 밀어내는 자살적 자기교정이 발생. 사용자 직관과 어긋남. 또한 multi-GPU 부담이 비선형으로 누적되어 인센티브 distortion 폭증. STEP 10 참고.
선점 비용에 곱하는 multiplier k를 1보다 작게 (예: 0.5) 두는 모델. "선점은 실제로 안 쓴 거니까 할인" 직관.
기각 이유: 매직 넘버 1개 추가, 정당화 어려움, k가 운영자의 튜닝 거리가 됨. k = 1이 가장 단순하고 정직함.
한 잡의 누적 선점 비용이 (예: 실행 비용의 50%)에 도달하면 더 이상 가산 안 함.
기각 이유: 매직 넘버 1개. self-correction 약화. 운영 데이터 부족 상태에서 cap 값을 정할 근거 없음. 일단 단순하게 가고, 운영 후 필요하면 추가.
Reservation 충족 후 dispatch 시 LLM에 SIGTERM 보내고 30초 기다린 후 강제 종료.
기각 이유: timeout 매직 넘버 1개. LLM 양보 진행 상태를 우리 쪽에서 추적해야 함. 책임 분리 안 됨. → LLM 라우터가 자체적으로 처리하도록 책임을 넘김. 우리 쪽 코드 단순화.
한 번 reservation 시작했으면 그 잡 종료까지 무조건 1순위 유지. 다른 잡이 끼어들 수 없음.
기각 이유: 사용자가 명확히 지적 — "같은 lab의 다른 잡이 completion되면서 lab usage가 누적되면 다른 lab이 1순위가 되는 건 자연스러운 fair-share 동작이고 막으면 안 된다." External trigger에 의한 R3는 살아있어야 함. STEP 11 참고.
FINAL SPEC
운영 정책 페이지에 반영된 최종 정책을 기술적으로 정밀하게 다시 정리합니다.
| 용어 | 정의 | 식별자 |
|---|---|---|
| GPU | 1 GPU 디바이스 | (node_name, gpu_index) |
| placement | 같은 노드 + 같은 GPU 모델로 묶인 GPU들의 집합 | (node_name, tier_id) |
| placement.capacity | placement의 GPU 총 개수 (정적) | — |
| placement.idle | 현재 비어있는 GPU 개수 (동적, LLM 점유분 포함) | — |
| fit | placement.tier.sort_order ≥ job.minimum_tier_sort_order AND placement.capacity ≥ job.num_gpus | — |
| available | fit AND placement.idle ≥ job.num_gpus | — |
| reservation | 1순위 멀티 GPU 잡이 placement의 일부 GPU를 선점한 상태 | — |
Job.state == GLOBAL_QUEUE_WAIT)lab.cumulative_usage ASC, queue_entered_at ASC (tie-breaker)def dispatch_once():
# 1. placement별 (capacity, idle) 조사
placements = query_placements()
for p in placements:
p.idle -= sum(reserved_gpus for reservations on p)
# 2. 잡 풀 정렬
jobs = query_global_queue()
jobs.sort(key=lambda j: (j.lab.cumulative_usage, j.queue_entered_at))
# 3. 잡을 차례로 처리
for i, job in enumerate(jobs):
is_top = (i == 0)
# (a) 즉시 dispatch 가능?
for p in fit_placements(job):
if p.idle >= job.num_gpus:
dispatch(job, p)
if job.has_reservation():
release_reservation(job) # ★ swap
p.idle -= job.num_gpus
break
else:
# (b) reservation 보유면 추가 idle 흡수
if job.has_reservation():
p = job.reserved_placement
absorb = min(p.idle, job.num_gpus - job.reserved_gpus)
job.reserved_gpus += absorb
p.idle -= absorb
if job.reserved_gpus >= job.num_gpus:
dispatch(job, p)
release_reservation(job)
# (c) 새 reservation 시작 (1순위 + multi-GPU만)
elif is_top and job.num_gpus >= 2:
p = pick_best_fit_placement(job) # tier DESC, idle DESC
if p and p.idle > 0:
start_reservation(job, p, take=p.idle)
p.idle = 0
| ID | 규칙 |
|---|---|
| R1 | reservation은 num_gpus ≥ 2 잡에만 적용 |
| R2 | reservation 시작 권한은 fair-share 1순위만 |
| R3 | 외부 사용량 변동으로 1순위에서 밀려나면 강제 해제. 그때까지의 선점 비용은 잡 종료 시점에 가산 |
| R4 | 잡당 동시 reservation 1개. placement 변경 불가 (단, swap rule(a)로 dispatch는 가능) |
| R5 | placement가 사라지면 reservation 강제 해제, 잡은 GLOBAL_QUEUE_WAIT 복귀 |
선점 비용 = (선점 GPU 수) × (선점 시간 초) × (티어 TFLOPS) 실행 비용 = (실행 GPU 수) × (실행 시간 초) × (티어 TFLOPS)
| 잡 종료 사건 | 가산되는 비용 |
|---|---|
| dispatch 성공 → 정상 종료 | 선점 + 실행 |
| R3 강제 해제 | 선점만 |
| Cancel (reservation 중) | 선점만 |
| R5 placement 사라짐 | 선점만 |
새 컬럼 5개 (모두 jobs 테이블):
jobs.reserved_gpus INT NOT NULL DEFAULT 0 jobs.reserved_node_name TEXT NULL jobs.reserved_tier_id UUID NULL FK gpu_tiers(id) jobs.reservation_last_accrued_at TIMESTAMPTZ NULL jobs.reservation_gpu_seconds DOUBLE PRECISION NOT NULL DEFAULT 0
새 enum 0개. 새 테이블 0개.
reservation_last_accrued_at은 매 dispatch 주기마다 갱신되며, reservation_gpu_seconds에
incremental TFLOP-seconds를 누적하는 데 사용된다 (Lazy 가산 — lab.cumulative_usage에는 미반영, 잡 종료 시점에 한꺼번에 가산).
부분 인덱스 WHERE reserved_gpus > 0 추가.
1 ≤ num_gpus ≤ 8 (현행)422 NO_FIT_PLACEMENTSCENARIOS
최종 정책을 다양한 시나리오에서 검증한 결과입니다. 모두 알고리즘이 자연스럽게 처리합니다.
큐: jobA (num_gpus=2, lab=BCG, cumulative=24k) ← 1순위 placement P (4090×2): idle=2 → 알고리즘 4-(a) 적중 → 즉시 dispatch → 잡A RUNNING
큐: jobA (num_gpus=2, BCG) ← 1순위 placement P (4090×2): idle=1 (다른 잡이 1장 점유 중) → 4-(a) 미적중 (idle < num_gpus) → 4-(c) 발동: 1순위 + multi → reservation 시작 → jobA.reserved_gpus = 1, reserved_node = P → P.idle = 0 (이후 다른 잡 매칭에서 제외)
[다음 주기] 잡A reservation 1/2장 보유 중 placement P: 다른 잡이 종료되어 idle=1 회복 → 4-(b) 발동: 같은 placement의 idle 흡수 → jobA.reserved_gpus = 2 (충족) → 4-(a)로 복귀 → dispatch → LLM 종료 + sbatch
[T+0] 잡A reservation 1/2장 보유 중 (placement P) [T+5초] placement Q에서 갑자기 2장이 한꺼번에 idle (다른 작업 동시 종료) → 4-(a) 발동: jobA의 fit placement 중 Q가 available → Q에서 즉시 dispatch → P의 reservation 자동 해제 (★ swap) → P.idle = 1 (해제된 GPU는 다음 순위 잡한테 활용 가능)
[T+0] 잡A reservation 시작 (BCG, 1/2 선점)
BCG.cumulative_usage = 24k
[T+5분] 잡Y (BCG 다른 사용자, RUNNING) 종료
→ record_job_usage(Y) → BCG = 32k
→ KDG (28k) < BCG → KDG가 1순위
[T+5분+ε] 다음 dispatch 주기. 잡A 2순위로 강등.
→ R3 발동: 잡A reservation 강제 해제
→ 선점 비용 24,780 BCG에 가산 (BCG = 56,780)
→ 잡A 다시 GLOBAL_QUEUE_WAIT
[T+15분] KDG의 잡들 진행/종료, 다시 BCG가 1순위
→ 잡A 다시 reservation 시작 가능 (처음부터)
큐: jobA (num_gpus=2, BCG) ← 1순위 placement P1 (node1: 4090×2): idle=1 placement P2 (node2: 4090×2): idle=1 → 두 placement 모두 num_gpus=2 매칭 안 됨 (idle < 2) → jobA에 대해 4-(a) 미적중 → 4-(c) 발동: best fit (P1 또는 P2 중 idle DESC, 동률이면 임의) → reservation 시작 → false positive 차단 (sbatch 잘못된 매칭 방지)
node X에 4090×4 + 5090×4 (가상) → placement P1 (X, 4090): capacity=4 → placement P2 (X, 5090): capacity=4 큐: jobA (num_gpus=4, fit ≥ 4090) → P1과 P2 모두 fit (capacity=4) → idle 충분한 곳에서 dispatch 큐: jobB (num_gpus=8) → 어떤 placement도 capacity ≥ 8 만족 안 함 → NO_FIT_PLACEMENT (제출 시 거부) — 향후 placement capacity 확장 시까지
큐: jobA (num_gpus=2, BCG) ← 1순위, reservation 진행 중 jobB (num_gpus=2, KDG) ← 2순위 placement P (4090×2): jobA가 1장 reserve, idle=0 → jobB는 multi이지만 1순위 아님 → reservation 권한 없음 (R2) → 4-(c) 미적중 → jobB는 그냥 대기 → jobA가 dispatch되거나 R3로 해제되어야 jobB가 1순위로 올라옴
큐: jobA (num_gpus=2, BCG) ← 1순위, reservation 진행 중 (P에서 1장) jobB (num_gpus=1, KDG) ← 2순위 placement P (4090×2): jobA가 1장 reserve, idle=0 placement Q (3080Ti×2): idle=2 → jobA는 P에서 reservation 진행 (Q는 jobA 입장에서 fit 아닐 수도) → jobB는 P, Q 둘 다 fit → 4-(d): jobA가 처리된 후 jobB로 진행 → jobB에 대해 4-(a): Q에서 available → dispatch → Q.idle = 1 (jobB가 1장 사용)
[Attempt 1] jobA (num_gpus=2, fit ≥ 3080Ti) 3080Ti에서 RUNNING → OOM
→ escalation: minimum_tier_sort_order 상향
→ fit 조건 변경: tier ≥ 4090
→ 다시 GLOBAL_QUEUE_WAIT
[Attempt 2] 다음 주기. jobA의 fit placement는 4090만.
→ 4090 placement에 idle=1 → 4-(c) reservation 시작
→ ... 충족되면 dispatch
(자연스럽게 작동, 별도 코드 없음)
[T+0] 잡A reservation 진행 중 (1/2 선점, 5분 경과)
[T+5분] 사용자 cancel API 호출
→ reservation 해제
→ 선점 비용 (1장 × 5분 × 82.6 ≈ 24,780) BCG에 가산
→ 잡A 상태: CANCELLED
[T+0] 잡A reservation 진행 중 placement P
[T+5분] node down 감지 (Slurm sinfo) → P 사라짐
→ R5 발동: 잡A reservation 강제 해제
→ 선점 비용 가산
→ 잡A는 GLOBAL_QUEUE_WAIT 복귀
→ 다음 주기에 다른 fit placement에서 시도
사용자 POST /v1/jobs (num_gpus=4) → 검증: fit placements 조회 - 모든 placement의 capacity = 2 - fit한 placement = 0 → 422 NO_FIT_PLACEMENT "현재 인프라에 num_gpus=4를 받을 수 있는 placement가 없습니다" → 잡 생성 안 됨
placement P (4090×2): 둘 다 LLM 백필 점유 중 (idle=2 — 양보 가능) 큐: jobA (num_gpus=2) ← 1순위 → 4-(a): P available (idle=2) → 즉시 dispatch → 우리 쪽: LLM에 즉시 종료 명령 → sbatch → LLM 라우터: 이 모델 dispatch 차단 (자체 처리) → jobA RUNNING
[T+0] BCG (cumulative=24k)이 multi-GPU 잡 5건 연속 제출
잡A1, A2, A3, A4, A5 (모두 BCG, num_gpus=2)
→ 잡A1이 1순위, dispatch (placement P)
→ 잡A2~A5는 GLOBAL_QUEUE_WAIT
[T+30분] 잡A1 정상 종료 → record_job_usage(A1)
→ BCG.cumulative_usage += 49k (대략) = 73k
→ 다른 lab과 비교: KDG=30k → KDG가 1순위
→ 잡A2는 2순위로 밀림. 대기.
[T+1시간] KDG의 잡들이 처리되며 KDG 사용량 누적
→ 다시 BCG가 1순위가 될 수도, 다른 lab이 될 수도
→ fair-share 자연 회전
→ 결과: BCG가 한 lab을 독점하지 못함. 자연스럽게 균형.
LIMITS & FUTURE
| 항목 | 조건 |
|---|---|
| 잡당 reservation 시도 횟수 cap | 실패 reservation이 같은 잡에 반복적으로 발생하는 경우 |
| Multi-GPU aging boost | 2순위 멀티 잡의 latency가 운영상 문제가 되는 경우 |
| Cross-placement 분산 학습 (NCCL multi-node) | 여러 노드에 걸친 분산 학습 수요가 명확해진 경우 (Slurm --ntasks-per-node 등 활용) |
| Reservation breakdown 시각화 | lab usage에서 "실행 비용 vs 선점 비용" 분리 표시 (운영자/사용자 가시성) |
이 정책의 코드 구현은 별도 phase로 진행됩니다:
| Phase | 변경 | 파일 |
|---|---|---|
| 1 | DB 마이그레이션 — jobs에 컬럼 4개 추가 |
alembic/versions/0016_*.py, app/db/models/jobs.py |
| 2 | usage.py — num_gpus 곱 버그 fix + lazy 가산 함수 | app/services/usage.py + 단위 테스트 |
| 3 | scheduler.py — placement-based dispatch + reservation 흐름 | app/services/scheduler.py, app/slurm/parsers.py |
| 4 | Cancel/escalation/실패 경로의 reservation 정리 | app/services/scheduler.py, app/api/routes/jobs.py |
| 5 | queue_snapshot API + monitor UI에 reservation 노출 |
app/api/routes/platform.py, app/web/assets/console.js |
| 날짜 | 변경 |
|---|---|
| 2026-04-10 | 최초 작성. STEP 1~12 정책 결정 완료. Phase 1 코드 작업 대기 중 |