Ray core¶
ray_io
¶
Shared Ray-engine plumbing — deterministic cluster sizing, GPU/CPU routing, cell chunking.
The Ray-on-Vertex analog of spark_io. Split along the same pure/I-O seam so
the interesting logic is offline-testable without a cluster, a GPU, or BigQuery:
- Pure (no Ray, no Vertex, no GPU):
split_gpu_cpu_models(which models want a GPU),plan_cluster(size an autoscaling cluster to the run's fan-out),calibrate_gpu_fraction(profile-drivennum_gpusper NeuralProphet task, unit-tested with injected memory numbers),chunk_cells(shard the panel by series into task-sized pandas frames),make_chunk_runner(the body one Ray task runs). - Reuse, not re-implementation. The executor-side work is the exact Spark core:
run_groupruns each cell, and the status roll-up isaggregate_status. A Ray "chunk" is the Spark "bucket" by another name — same pandas shape, samerun_cell. This module owns only what is genuinely Ray-specific: the deterministic sizing and the heterogeneous GPU/CPU split.
Autoscaling by default. Each worker pool is created with a Vertex AutoscalingSpec(min, max)
and scales with Ray's pending-task demand, so a bursty, embarrassingly-parallel fleet can grow to
chew a deep task queue and shrink the expensive T4 pool when idle. Determinism is preserved:
plan_cluster stays a pure function of the config — the autoscale flag, the per-pool
[min, max], and the fixed-size-equivalent node count the fan-out implies are all derived offline
and snapshotted into run_id + job_telemetry. ray_autoscale=False selects a fixed-size
mode instead (a fixed node_count and no autoscaling_spec). NOTE: under autoscaling the
Vertex SDK ignores node_count (the pool starts at min and scales to max), so the derived
count is the initial size only for the fixed path.
Why heterogeneous routing. Only NeuralProphet (family == "deep_learning") benefits from a
GPU, and Spark can't share a GPU fractionally across tasks — which is the whole
reason Ray is in the design. So NeuralProphet cells run in @ray.remote(num_gpus=<fraction>)
tasks that pack several onto one T4, while every other model runs in @ray.remote(num_cpus=1).
The routing decision (which models, which fraction, how many nodes) lives here; the decorators that
act on it live in ray_engine.
RayClusterPlan
dataclass
¶
RayClusterPlan(
cluster_name,
reuse,
head_machine_type,
cpu_machine_type,
cpu_node_count,
gpu_machine_type,
gpu_node_count,
accelerator_type,
accelerator_count,
sizing_gpu_fraction,
n_gpu_cells,
n_cpu_cells,
autoscale,
cpu_min_nodes,
cpu_max_nodes,
gpu_min_nodes,
gpu_max_nodes,
cpu_pool=None,
gpu_pool=None,
)
An autoscaling Vertex Ray cluster spec, sized to a run's fan-out (pure product of config).
Autoscaling by default: when autoscale each worker pool is created
with a Vertex AutoscalingSpec bounded by its resolved [cpu|gpu]_min_nodes /
[cpu|gpu]_max_nodes and starts at its min; when autoscale is False both pools are fixed
at cpu_node_count / gpu_node_count (a fixed-size mode, no autoscaling_spec).
cpu_node_count / gpu_node_count are the deterministic fixed-size-equivalent the fan-out
implies — the actual node count on the fixed path, and the initial/reference size on the
autoscaling path (where the SDK starts the pool at min instead). reuse=True means an
existing cluster is targeted by name (skip create + skip teardown); the sizing fields then
describe what it should be. sizing_gpu_fraction is the fraction used to size the GPU pool;
the on-cluster calibration may request a different actual num_gpus per task.
n_gpu_cells / n_cpu_cells are the per-pool task counts the sizing derived from.
total_worker_nodes
property
¶
Fixed-size-equivalent worker count across both pools — the number the fan-out implies.
On the fixed path this is the actual provisioned worker count; under autoscaling it is the reference size (each pool actually starts at its min and scales toward its max).
aggregate_status
¶
Fold the collected per-cell status frame into a RunOutcome (pure).
COMPLETED = every cell ok; PARTIAL = a mix of ok and error; FAILED = no ok cells
(all errored, or nothing ran at all). This is what the driver writes to
run_registry.status after the Spark job returns.
Source code in src/scale_forecasting/engines/spark_io.py
calibrate_gpu_fraction
¶
Resolve the num_gpus fraction each NeuralProphet task requests.
Two paths, mirroring the config's compute.gpu_fraction:
- fixed float → return it unchanged (the operator pinned it; no profiling).
"auto"→ size the fraction to the model's real footprint: fit NeuralProphet on a few sample series measuring peak GPU memory, take the worst case, add a safety margin, and divide by the device's memory — sofraction ≈ peak × margin / device_bytesandfloor(1/fraction)tasks pack without an OOM. Clamped to[floor, 1.0], where the floor is_MIN_FRACTIONor lower — seecatalog.device_floor_fraction, which lets it drop on a node whose cores would have run more cells than a flat0.1allows.
gpu_type picks that denominator (device_memory_bytes); None falls back to
compute.gpu_type. It is an argument rather than read from cfg because a family's
accelerator is resolved per-job and deliberately kept out of the config (the run_id digest
must stay identical across every family in a run) — the same reason plan_cluster takes it.
The measurement is injectable so the sizing math is unit-testable without a GPU in the
offline gate: pass measured_peaks_bytes to skip the live fit entirely. On a real cluster
(run) the peaks are measured live by _measure_np_peaks_on_device, which dispatches
sample_series to GPU workers. With nothing to measure it falls back to
_NOMINAL_AUTO_FRACTION. The chosen fraction + measurements are logged to the registry so
the sizing decision is auditable (done by the caller).
A failed probe is not a measurement of zero, and treating it as one inverted the result.
Earlier code returned 0 from a probe that raised; max of three zeros is zero; and
_clamp_fraction turns a zero raw fraction into exactly _MIN_FRACTION — the smallest
legal fraction, i.e. the densest packing, on the strength of a measurement that never
happened. Non-positive samples are therefore dropped before the max, and a sample list that
empties out lands on the nominal fraction, the same as no samples at all.
That fix was correct and it was not sufficient, because the probe could not run at all. It
was being called on the head node, which has no accelerator, so "no usable samples" was not an
edge case — it was every run, and the function's real output was whichever constant the
fallback held. _measure_np_peaks_on_device puts the probe on a worker that has a card. The
fallback is now what it always claimed to be: the answer when measurement is impossible, rather
than the answer.
Source code in src/scale_forecasting/engines/ray_io.py
chunk_cells
¶
Shard the panel into n_chunks task-sized pandas frames, one Ray task each (pure).
Chunks are assigned by series, not by cell. Each distinct ts_id gets a chunk index from
a stable CRC32 of the id (deterministic across processes, unlike hash()), the index is
mapped back onto the panel, and one groupby splits it. The frames come back untagged —
no _MODEL_COL — so run_group takes its per-series loop over the executed model list. The
caller must therefore build one runner per pool, with that pool's models, or a chunk would run
models that belong to the other pool.
The reason this is not a cross-join. Tagging cells means materializing one copy of the whole panel per model on the driver before anything is shipped anywhere: at 2,000 series × 1,460 rows × 4 models that measured 8.39 s and +2,416 MB of driver RAM, against 0.52 s and +185 MB for the per-series shard — 16× the time and ~13× the incremental memory, for frames that then cost 4× the object-store bytes because every row crosses the wire once per model. The cell set is identical either way; only who does the replication changes, and a Ray task replicating its own handful of series is free.
The fallback. When n_chunks > n_series there are not enough series to fill the requested
task count, and the count is not decoration — it is what keeps enough tasks pending for the pool
to reach its autoscaling ceiling (tasks_for_ceiling). So that case cross-joins as before and
returns tagged frames, buying finer-than-series granularity at the cost that made the default
path expensive. It only fires on small panels, where the cost does not matter.
Empty chunks are dropped; an empty models or empty source yields []. n_chunks is
clamped to [1, _MAX_CHUNKS]. Chunk composition is not part of the contract — which
series land together, and whether frames arrive tagged, are implementation details that have
already changed once. What is contractual: the union of the chunks covers every
(series, model) cell exactly once, and each series' full history is in exactly one chunk.
Source code in src/scale_forecasting/engines/ray_io.py
881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 | |
cluster_name
¶
The cluster name: the reuse target if set, else sf-ray-<run_id> (Vertex-legal, ≤ 63).
Vertex cluster display names must be lowercase alnum + hyphens and start with a letter; the
run_id is already a slug + hex digest, so the sf-ray- prefix keeps it legal. Clamped to
63 chars with no trailing hyphen.
Public because the name is knowable before the cluster exists, and one caller needs exactly
that: job_launch builds a single-family Ray job's probe handle before submit, when nothing has
created a cluster yet. Being derivable from the run_id is what makes a running Ray job
reachable at all.
Source code in src/scale_forecasting/engines/ray_io.py
device_memory_bytes
¶
Device memory for one accelerator of gpu_type (pure; unknown → the smallest known).
The denominator of the auto-fraction. Kept a lookup rather than a constant because the two supported devices differ by 50% (T4 16 GiB, L4 24 GiB): sizing an L4 against the T4 constant packs only two-thirds of the tasks the device could actually hold.
Source code in src/scale_forecasting/engines/ray_io.py
gpu_slots_per_device
¶
How many fractional tasks pack onto one GPU: floor(1 / fraction) (≥ 1) (pure).
Ray schedules by summing each task's num_gpus against a device's capacity of 1.0, so a
fraction of 0.25 packs 4 tasks; 0.5 packs 2. Always at least one (a task can't be
smaller than a whole device if the fraction rounds it there).
Source code in src/scale_forecasting/engines/ray_io.py
make_chunk_runner
¶
Build the function one Ray task runs: run a chunk's cells, write them, return status.
The Ray analog of make_group_runner. Closes over
the picklable cfg + settings (both frozen → cross the Ray task boundary as plain data,
the single local/cloud seam without a second env path), calls the shared pure run_group on the
chunk, appends the results with the writer (write_cells, task-side, once per chunk — appends
compose), and returns only the compact status frame so no forecast payload crosses back to the
driver.
models is load-bearing on the Ray path, not the parity nicety it is on Spark. Chunks
from chunk_cells arrive untagged, so run_group takes its per-series loop and runs exactly
the models in this list — which means a runner must be built per pool, with that pool's models.
(The one exception is the small-panel fallback, where chunks do carry _MODEL_COL and the tag
wins; passing the pool's list is right either way.)
params_by_model is the fleetwide-HPO resolution, captured in the closure like cfg /
settings and forwarded to run_group — the Ray twin of the Spark group runner's
fleetwide threading, so tuned params reach every task without entering cfg (run_id stable).
Source code in src/scale_forecasting/engines/ray_io.py
plan_cluster
¶
Size an autoscaling Vertex Ray cluster to this run's fan-out (pure).
use_gpu/gpu_type override the flat compute defaults for one family's job (the DAG
orchestrator passes the family's resolved hardware). They're kept out of cfg on purpose:
the run_id is a digest of cfg and must stay identical across every family under one run,
so a per-family GPU decision flows as an argument, not a config mutation. None falls back to
compute.use_gpu / compute.gpu_type.
Deterministic function of the config — no GCP, no GPU. Splits the executed models into GPU
(NeuralProphet, and only when this job has a GPU at all) and CPU pools, counts the cells each
pool must run (series × models, using
max_parallelism as the basis when series_limit is unbounded), and derives each pool's
fixed-size-equivalent node_count from those cell counts (plan_pool), clamped
into the pool's resolved [min, max]. Folds are not a factor in the node count — a cell
runs all its backtest folds internally in one run_cell, so folds add per-cell time, not
more tasks.
Per-pool autoscaling bounds are resolved here and carried on the plan: the floor comes from
config (ray_[cpu|gpu]_min_nodes), and the ceiling is derived from this run's own fan-out
— the pool's derived node count, floored at _AUTOSCALE_MAX_FLOOR and capped by the hard
ceiling (the per-pool override, else the shared ray_max_nodes). An explicitly pinned
ray_[cpu|gpu]_max_nodes is honoured verbatim instead. So a run implying two nodes scales to
two, not to a constant 16, while the hard ceiling still guards a runaway fan-out.
When ray_autoscale (default) the
launcher gives each pool an AutoscalingSpec(min, max) and it starts at min and scales to
max with Ray's task demand; when False both pools are fixed at the derived node_count.
Either way the whole spec is a pure product of the config — a bigger series_limit implies
more cells → a higher derived count (and, on the fixed path, more nodes); the plan is the whole
sizing decision, logged and stamped to the run for audit.
profile is an optional ComputeProfile from the driver-side measurement pre-pass
(profiling.source.resolve_profile). When given, each pool's slot is sized from what the models
actually cost — cores, host memory, and the GPU fraction — instead of from the constants this
function used to inline; None reproduces those constants exactly, so an unprofiled run is
byte-identical to one planned before any of this existed. It is an argument rather than a config
field for the same reason use_gpu/gpu_type are: run_id digests cfg, and a
measurement taken at submit time must not move it.
Source code in src/scale_forecasting/engines/ray_io.py
728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 | |
plan_pool
¶
plan_pool(
cfg,
models,
n_cells,
*,
gpu,
gpu_type=None,
profile=None,
gpu_fraction=None,
max_units=None,
)
Size one Ray worker pool from the measured profile, or from the old constants (pure).
The single place the Ray runtime turns a ComputeProfile into hardware. Takes the pool's
UnitShape from pool_unit_shape, sizes one slot per family that lands on the pool,
merge_slots them into the one slot a shared worker needs, and hands the result to
plan_fleet.
profile=None asks for nothing the measurement would have asked for: a slot is one
core, no memory request, and — on the GPU pool — _sizing_fraction. What that slot then packs
onto a node is resources.fleet's business, and its answer is the smallest of the three
bounds. So the CPU pool lands on resources.fleet.schedulable_cores, one cell per node below
the nameplate count the inline arithmetic used, because the reserved core is the one the raylet
reports progress on. The GPU pool usually still binds on devices at the coarse submit-time
fraction, and binds on cores once a calibrated fraction packs more slots per device than the
node has cores to run them on.
gpu_fraction overrides the sizing fraction with one that is better known — on the cluster
ray_engine has already run calibrate_gpu_fraction against a real device, and
that live number beats the submit-time nominal. It is only a fallback either way: a profile
that measured the device footprint wins over both.
max_units overrides the hard ceiling. plan_cluster leaves it unset on the first pass (it
needs the ceiling-clamped count before it can resolve the autoscaling ceiling from it), while
ray_engine passes the cluster's already-resolved [cpu|gpu]_max_nodes so that
tasks_for_ceiling counts against the ceiling the pool can really reach.
Source code in src/scale_forecasting/engines/ray_io.py
pool_families
¶
The distinct model families landing on one pool, in first-seen order (pure).
A pool is not a family: everything that isn't deep_learning shares the CPU pool, so its
worker has to hold a statistical cell and an ML cell alike. Order is first-seen so the merged
label is deterministic and the digest it feeds stays stable.
Source code in src/scale_forecasting/engines/ray_io.py
pool_unit_shape
¶
One worker node of the CPU or GPU pool: cores and RAM from the machine type (pure).
Shared by plan_pool and calibrate_gpu_fraction because the two have to agree about the
node. The calibration decides how finely to split a card, plan_pool decides how many cells
that card then holds, and both answers are bounded by the same cores — sizing them off two
independently-built shapes is how the two drift apart.
Source code in src/scale_forecasting/engines/ray_io.py
resolve_job_gpu
¶
This Ray job's GPU decision, from the resolved per-family compute (pure).
The single answer to "does this job have a device, and which one" — the same
config.RunConfig.resolve_family_compute the submitter provisions from and the DAG
orchestrator plans from. The engine must ask this rather than read compute.use_gpu
directly, because that flat field is only one of the two ways a run can request a GPU.
This exists because reading the flat field shipped a real defect. A config that asks
per-family — compute.families.deep_learning.hardware: "gpu", which is the documented
way — leaves compute.use_gpu at False. The submitter resolved the family and
provisioned accelerators; the engine read the flat field, sent every deep-learning cell to
the CPU pool, and the devices sat idle for the whole run with nothing reporting it. It also
ran the reverse: flat use_gpu: true with a family override of hardware: "cpu"
provisioned no GPU nodes while the engine still asked Ray for num_gpus, leaving tasks
permanently unschedulable. Both disappear once provisioning and routing read one function.
Returns (has_gpu, gpu_type). Only deep_learning can resolve to a GPU, so that is the
family asked; a config with no deep-learning model still answers, and the answer costs
nothing because the resolver is pure.
Source code in src/scale_forecasting/engines/ray_io.py
run_id_prefix_from_cluster_name
¶
Read the run_id back out of an ephemeral cluster name, or None if it isn't one.
The inverse of cluster_name, and deliberately named a prefix rather than a run id: the clamp
to 63 characters means a run whose run_name is long enough loses the tail of its id here, so
what comes back is only guaranteed to be the start of the real one. Callers must match it
against known run ids by prefix, never assume equality — ray_reaper.classify_clusters does
exactly that, and treats an ambiguous prefix as a reason to leave the cluster alone.
Source code in src/scale_forecasting/engines/ray_io.py
split_gpu_cpu_models
¶
Partition the executed models into (gpu_models, cpu_models) by family (pure).
A model routes to the GPU pool iff its registered family is deep_learning
(NeuralProphet) — the only family a GPU helps — and this job actually has a GPU pool.
Everything else (statistical/ml) is CPU. models is the executed subset (main.run);
None means cfg.models. use_gpu overrides the flat compute.use_gpu with the
family's resolved hardware, the same way plan_cluster takes it.
The use_gpu half is what keeps a GPU-less job from planning a cluster with no workers.
A deep-learning model still runs without a GPU — it falls back to CPU inside the cell — so with
no GPU pool its cells belong to the CPU pool, not to a pool that will not exist. Splitting on
family alone put them in gpu_models, whose cells were then zeroed because use_gpu was
False, while cpu_models was empty because every executed model was deep-learning: both pools
derived 0 nodes and the run hung on a head-only cluster with nothing to schedule on. That is not
an exotic config — deep_learning resolves to hardware="cpu" whenever compute.use_gpu
is left at its default, so {"python_runtime": "ray", "models": ["neuralprophet"]} reached
it.
Order is preserved within each list so logs and chunking stay deterministic. Unknown names raise
ModelError via the factory — the same up-front validation the
router does.