feat: count the GPUs, the VRAM and the other resources of the machine - #24
Open
stephenc wants to merge 1 commit into
Open
feat: count the GPUs, the VRAM and the other resources of the machine#24stephenc wants to merge 1 commit into
stephenc wants to merge 1 commit into
Conversation
The scheduler counted two things, the cores and the memory, and it held named locks. A machine with four GPUs had no way to say `--gpu 1`. This change gives the scheduler one new concept: a POOL. A pool has a name, a total, and, when qex must say WHICH one, a list of devices with a capacity for each. `--lock NAME` is a pool of one unit. There is one arithmetic and one admission path. `--gpu N` and `--vram SIZE` are fixed names for a claim on the pool `gpu`, and `--claim NAME=N` is the general form. The next accelerator is an entry in the config file, and not a change to the code. qex reads no driver. The devices come from the configuration only, so a machine with no CUDA and no driver library schedules GPU claims correctly. VRAM is a quantity on EACH device, and qex never adds the capacity of the devices together: four devices of 24GB are not 96GB for one job. A claim above the largest device is refused, and the message says that the job can never start. A claim above the pool total is refused in the same way, whatever `[queue] oversized` says: an empty machine does not make a fifth device. The coordinator gives the devices with the most free capacity first, and the lowest index for a tie, inside the same lock hold that moves the job to `starting`. The result goes into `status.json` as `assigned`, and not into `spec.json`, because an assignment is a result and not a request. The supervisor then writes `CUDA_VISIBLE_DEVICES`, `QEX_GPU_DEVICES` and `QEX_GPU_VRAM` for the job. The job thus sees the variable, which a framework reads with no change to its code, and the record, which stays after the job stops. `locks` keeps its own field on the wire. A coordinator that has `locks` and not `pools` reads that field and obeys it, and the capability `pools` covers the new claims. Closes part of #10. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements section 2 of
design-decisions.md. Closes part of #10.What it does
The scheduler counted two things, the cores and the memory, and it held named locks. A machine with four GPUs had no way to say
--gpu 1. This adds one new concept to the scheduler — a pool — and gives the command line the two names that an agent must get right on the first try.The model, in one paragraph
A pool is a name, a total, and — when qex must say which one — a list of devices with a capacity for each.
--lock NAMEis a pool of one unit, and a name that the configuration does not declare is a pool of one unit, so a lock still needs no configuration.--gpuand--vramare fixed names on the command line for a claim on the poolgpu; the scheduler holds no special case for that name and sees one claim map and one arithmetic. A plain pool admits a job when the free units cover the claim. An indexed pool admits a job when enough devices each have the claimed quantity free — the capacity of the devices is never added together, because four devices of 24GB are not 96GB for one job. qex reads no driver: the devices come from the configuration, so a machine with no CUDA and no driver library schedules GPU claims correctly. When the job starts, the coordinator chooses the devices with the most free capacity first (lowest index for a tie), inside the same lock hold that moves the job tostarting, and writes the choice tostatus.jsonasassigned— an assignment is a result and not a request, so it never goes inspec.json. The supervisor then writesCUDA_VISIBLE_DEVICES,QEX_GPU_DEVICESandQEX_GPU_VRAMfor the job, and those values replace the captured environment. A coordinator that starts again readsassignedback and rebuilds the occupancy of every pool.The three traps, and how each is covered
Peerneeds#[serde(default)]. Both new fields have it, with a comment that says why, anda_peer_file_of_an_earlier_version_still_parses_and_still_countsparses a real 0.7.1 record and asserts that its cores and memory still count.locks: Vec<String>stays on the wire. No claim is ever written intolocks, and no lock is ever written intoclaims.effective_claimsdoes the conversion inside the coordinator, after the capability test.required_bygiveslocksfor a lock andpoolsfor a claim, anda_lock_does_not_need_the_pools_capabilityproves that a coordinator withlocksand nopoolsstill accepts--lock.Infofields areOption.pools: Option<Vec<PoolReport>>.qex infoprintsunknownand names the coordinator version when the value isNone, and never0ornone.StagemirrorsJobFile.gpu,vramandclaimswent intospec::Resources, whichStagealready reuses andstage_specalready copies whole. A pipeline stage therefore cannot drop a claim, and there is no third place to forget.Decisions where the document left something open
gpupool is an error, and an undeclarednetis a lock. The document gives both a "there is no poolgpu" refusal and an "an undeclared name is a lock" rule. They conflict for--gpu 1. The rule I used: a name that the configuration does not declare is a lock of one unit, except the built-in alias namegpu.--gpupromises a device index and an environment variable; a silent lock would give the job neither, and the job would then use a card that qex is not accounting for. One small special case, inpool_check, with the reasoning in a comment.--vramforgpuand--claim NAME=Nfor everything else, which leaves no way to claim a quantity on a device of another indexed pool. I added--claim NAME=N:SIZEand the job-file table formtpu = { count = 2, size = "8GB" }. That makesPoolClaim.sizereachable generically, so--vramreally is only a friendly name and not the only path to the feature.Assignment.sizestaysNonefor a whole-device claim. Writing the device capacity there looks equivalent and is not: the devices of a pool can differ in size, and any single number would leave part of the largest device free for a second job.Held::addresolvesNoneto the capacity of each device it was given.status.jsonand toCUDA_VISIBLE_DEVICESis sorted, so two equal assignments give one text and2,3reads the way a person expects.CUDA_VISIBLE_DEVICESconflict is refused for an explicit value only. The check reads the job file[env]and--env, and not the captured shell environment. Refusing a captured value would stop anyone who exports the variable in a login file from submitting any GPU job; the supervisor replaces the value for that job, which is the correct result.[[pool]]with neithercountnordevicesis refused. The document does not say. Such an entry declares no size, and inventing one would be a size that no person chose.continue, so a lock still does not park the queue. A counted claim goes throughadmit. An indexed pool of one device with a partial--vramclaim is divisible, so it is counted and not a lock.I did not find anything in section 2 that I think is wrong.
What I measured
--test-threads=2on this machine, unchanged frommain.admitgained one pool test. It reads no/procand no driver; it walks the claim map of the job, which is empty for a job with no claim, so a queue with no pools does the same work as before.peers::claimsis now read once peradmitcall rather than once peradmitcall — unchanged — and the result is reused for the cores, the memory and the pools instead of being read twice.blocked_reason, so no reason text rewritesstatus.jsonon every tick.What I tested
Unit (202 pass, up from 172).
sched: a machine with no GPU admits a GPU claim from the configuration; VRAM is never summed; a claim above the pool total can never start; an undeclared name is a lock and two units of it are not; a GPU claim with nogpupool names the configuration; VRAM with no device is refused; a size on a plain pool is refused; two jobs get different devices and a third waits; a whole-device claim leaves no room for a second job; a device holds three 8GB jobs on 24GB and no fourth; the most free capacity comes first; a device another user holds is not given again; a lock becomes a pool of one unit; a lock in an old record still counts; a counted pool admits until full.config: the documented example parses (now with both pools);countwithdevicesis refused;cpu/memcannot be a pool name; two entries with one name are refused; an entry with no size is refused.spec: the aliases become one claim; no--vramtakes the whole device;[defaults] vramapplies; a job file gives the same claims; an explicitCUDA_VISIBLE_DEVICESis refused and a captured one is not;--claimparsing.capabilities: a lock does not needpools; a claim is refused by a coordinator withoutpools.peers: an 0.7.1 record still parses and still counts; a record carries the pools and the devices.End-to-end (81 pass, up from 71), all on a machine with no GPU.
a_machine_with_no_gpu_schedules_a_gpu_claim_from_the_configuration— runs a job that printsCUDA_VISIBLE_DEVICES,QEX_GPU_DEVICESandQEX_GPU_VRAM, and checks the record as well.two_gpu_jobs_get_different_devices_and_a_third_waitsa_vram_claim_above_the_largest_device_is_refused_at_the_submission— the message says "never start", and no record is left.a_gpu_claim_above_the_pool_total_is_refused_whatever_the_oversized_policya_gpu_assignment_survives_a_coordinator_that_stops_and_starts— takes the pid fromqex info --no-start --json, never a process-list search.a_lock_needs_no_configuration_and_still_excludes— and a job with no lock still passes it.a_counted_pool_lets_n_jobs_operate_and_makes_the_next_one_waitan_undeclared_pool_is_a_lock_and_two_units_of_it_are_refuseda_job_that_sets_the_device_variable_itself_is_refusedinfo_reports_the_pools_and_their_devicesAlso updated
docs/reference.md(a new pools section, and the job-file example at line 180 no longer setsCUDA_VISIBLE_DEVICES),src/help.rs(the submit options, the job-file fields, the pipeline stage fields, the config topic and theresourcestopic — which now contains the sentence "qex does not add the VRAM of the devices together"),src/schema.rs(gpu,vramandclaimson the job and pipeline schemas;locks,claimsandassignedon the status schema), andCargo.tomlto0.8.0.A note
Another agent is changing the head-of-line rule in
src/sched.rson a separate branch. This branch works frommainand does not implement that design. A merge conflict inchooseis expected.🤖 Generated with Claude Code
https://claude.ai/code/session_01KNvagiMEU3myn8EXGaGEM9