Skip to content
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,40 @@

`./train.py` accepts `--render` argument with "human" or "none" ("none" is the default). "none" trains silently, while "human" runs intentionally slower, adds some debug output and graph output after each episode.

### Curriculum Training

The current training setup is intentionally curriculum-based. The target behavior is not merely "use fewer nodes" or "wait longer"; it is the more specific policy:

- execute little work during expensive hours,
- defer safely while cheap hours are still ahead,
- then clear backlog aggressively during cheap hours,
- while keeping overdue backlog and job loss near zero.

This is now encoded directly in the environment and reward design:

- the agent sees a 24h price forecast window,
- cheap-hour execution is rewarded and expensive-hour execution is penalized,
- cheap hours penalize under-service when backlog exists,
- overdue backlog after the 24h grace period becomes intrinsically bad,
- and end-of-episode pending and overdue metrics make "saving money by not serving work" visible.

The practical reason for using a curriculum instead of only training longer is that the full problem has several easy but wrong local optima:

- serve immediately and ignore price timing,
- trickle a small amount of work continuously,
- or over-defer until backlog becomes unstable.

Those behaviors can produce tolerable short-horizon rewards, so simply running PPO for more steps does not guarantee discovery of the desired defer-then-clear policy. The curriculum reduces variance and improves credit assignment by first teaching the core phase behavior under deterministic logic prices and only then adding load, burstiness, realistic arrivals, price noise, and finally real prices.

Current intended sequence:

1. Stage A: flat arrivals + logic prices.
2. Stage B: high-load flat arrivals + logic prices.
3. Stage C: expensive-half-heavy or bursty arrivals + logic prices.
4. Stage D: main arrivals + logic prices.
5. Stage E: main arrivals + noisy logic prices.
6. Stage F: main arrivals + real prices.

In short: more steps on the full problem mostly improve whatever basin the optimizer already occupies; the curriculum is meant to make the correct basin discoverable first.

For a more formal write-up, see [analysis/curriculum_argument.md](analysis/curriculum_argument.md).
450 changes: 374 additions & 76 deletions analyze_arrivalscale_occupancy.py

Large diffs are not rendered by default.

44 changes: 30 additions & 14 deletions analyze_jobs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Report the maximum jobs-per-hour for a given log file across three views:
Report jobs-per-hour statistics for a given log file across three views:
1. Raw – jobs as parsed, one entry per actual job
2. Aggregated – jobs grouped by (nodes, cores, duration), one entry per unique profile
3. Hourly – aggregated jobs converted to 1-hour equivalents (what the env receives)
Expand All @@ -15,15 +15,28 @@
from src.config import MAX_NODES_PER_JOB, CORES_PER_NODE


def summarize_jobs_per_hour(counts: dict[str, int], bin_minutes: int) -> tuple[str, float, float, float]:
rates = [count * 60.0 / bin_minutes for count in counts.values()]
max_period = max(counts, key=counts.get)
return max_period, max(rates), statistics.mean(rates), statistics.pstdev(rates)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


def count_hourly_instances(jobs: list[dict[str, int]]) -> int:
return sum(max(1, int(job.get("instances", 1))) for job in jobs)


def main() -> None:
parser = argparse.ArgumentParser(description="Report max jobs-per-hour from a Slurm log file.")
parser = argparse.ArgumentParser(description="Report jobs-per-hour statistics from a Slurm log file.")
parser.add_argument("--file-path", required=True, help="Path to the job log file")
parser.add_argument("--bin-minutes", type=int, default=60, help="Bin size in minutes (default: 60)")
Comment thread
coderabbitai[bot] marked this conversation as resolved.
parser.add_argument("--cores-per-node", type=int, default=CORES_PER_NODE, help=f"Cores per node (default: {CORES_PER_NODE})")
parser.add_argument("--max-nodes-per-job", type=int, default=MAX_NODES_PER_JOB, help=f"Max nodes per job (default: {MAX_NODES_PER_JOB})")
parser.add_argument("--verbose", action="store_true", help="Print top-N hours for each view")
parser.add_argument("--top", type=int, default=5, help="Number of top hours to show with --verbose (default: 5)")
args = parser.parse_args()
if args.bin_minutes <= 0:
print("--bin-minutes must be a positive integer.", file=sys.stderr)
sys.exit(2)

s = DurationSampler()
result = s.parse_jobs(args.file_path, args.bin_minutes)
Expand All @@ -33,20 +46,23 @@ def main() -> None:

# --- Raw ---
raw_counts = {period: len(jobs) for period, jobs in s.jobs.items()}
max_raw_period = max(raw_counts, key=raw_counts.get)
max_raw = raw_counts[max_raw_period]
if not raw_counts or all(v == 0 for v in raw_counts.values()):
print("No jobs found in parsed data; cannot compute statistics.", file=sys.stderr)
sys.exit(1)
max_raw_period, max_raw, mean_raw, std_raw = summarize_jobs_per_hour(raw_counts, args.bin_minutes)
total_hours_raw = len(raw_counts)

# --- Aggregated ---
agg_counts = {period: len(jobs) for period, jobs in s.aggregated_jobs.items()}
max_agg_period = max(agg_counts, key=agg_counts.get)
max_agg = agg_counts[max_agg_period]
max_agg_period, max_agg, mean_agg, std_agg = summarize_jobs_per_hour(agg_counts, args.bin_minutes)

# --- Hourly-converted ---
s.precalculate_hourly_jobs(args.cores_per_node, args.max_nodes_per_job)
hourly_counts = {period: len(jobs) for period, jobs in s.hourly_jobs.items()}
max_hourly_period = max(hourly_counts, key=hourly_counts.get)
max_hourly = hourly_counts[max_hourly_period]
hourly_counts = {period: count_hourly_instances(jobs) for period, jobs in s.hourly_jobs.items()}
if not hourly_counts or all(v == 0 for v in hourly_counts.values()):
print("No jobs found in parsed data; cannot compute statistics.", file=sys.stderr)
sys.exit(1)
max_hourly_period, max_hourly, mean_hourly, std_hourly = summarize_jobs_per_hour(hourly_counts, args.bin_minutes)

# --- Duration stats (from all raw jobs) ---
all_durations = [job["duration_minutes"] for jobs in s.jobs.values() for job in jobs]
Expand All @@ -62,11 +78,11 @@ def main() -> None:
print(f"Total jobs : {total_jobs}")
print(f"Cores/node : {args.cores_per_node} | Max nodes/job: {args.max_nodes_per_job}")
print()
print(f"{'View':<12} {'Max jobs/hour':>14} {'At period'}")
print("-" * 60)
print(f"{'Raw':<12} {max_raw:>14} {max_raw_period}")
print(f"{'Aggregated':<12} {max_agg:>14} {max_agg_period}")
print(f"{'Hourly':<12} {max_hourly:>14} {max_hourly_period}")
print(f"{'View':<12} {'Max jobs/hour':>14} {'Mean jobs/hour':>15} {'Std jobs/hour':>14} {'At period'}")
print("-" * 92)
print(f"{'Raw':<12} {max_raw:>14.2f} {mean_raw:>15.2f} {std_raw:>14.2f} {max_raw_period}")
print(f"{'Aggregated':<12} {max_agg:>14.2f} {mean_agg:>15.2f} {std_agg:>14.2f} {max_agg_period}")
print(f"{'Hourly':<12} {max_hourly:>14.2f} {mean_hourly:>15.2f} {std_hourly:>14.2f} {max_hourly_period}")
print()
print(f"{'Job duration':<10} {'minutes':>10} {'hours':>8}")
print("-" * 32)
Expand Down
Loading
Loading