Description
When running dstack apply -f service.yaml (with fleets: [my-fleet]), the offers displayed in the run plan are not sorted by price. Cheap offers (e.g., Vast.ai RTX 4080 at $0.37/hr) appear after expensive offers (e.g., RunPod A6000 at $0.98/hr), making them invisible with the default --max-offers=3. By contrast, dstack offer --fleet my-fleet correctly shows offers sorted by price ascending.
Steps to Reproduce
- Configure a fleet named
my-fleet with backends: [vastai, runpod] and GPU requirements (e.g., total_memory: 60GB..)
- Configure a service that references
fleets: [my-fleet] with matching resource/backends config
- Run
dstack apply -f service.yaml — observe first offers are expensive (e.g., $0.98+)
- Run
dstack offer --fleet my-fleet — observe cheapest offers first (e.g., $0.37+)
Expected Behavior
Both commands should show offers sorted by price ascending (cheapest first), since all offers from Vast.ai and RunPod are marked AVAILABLE.
Actual Behavior
dstack apply shows offers in an approximately-sorted order where cheap Vast.ai offers are buried after expensive RunPod offers. With the default --max-offers=3, users only see:
# BACKEND RESOURCES INSTANCE TYPE PRICE
1 runpod (US...) 1xA6000, 48GB... ... $0.98
2 vastai (...) 1xRTXPRO6000WS... ... $1.16
3 runpod (US...) 1xRTXPRO4500... ... $1.28
Shown 3 of 97 offers
While dstack offer correctly shows:
# BACKEND RESOURCES INSTANCE TYPE PRICE
1 vastai (...) 1xRTX4080S... ... $0.3744
2 vastai (...) 1xV100... ... $0.43
3 vastai (...) 1xQRTX8000... ... $0.45
...
Root Cause Analysis
Two related issues:
1. _get_job_plan() sorts by availability only, not price
In src/dstack/_internal/server/services/runs/plan.py:835:
job_offers.sort(key=lambda offer: not offer.availability.is_available())
This is a stable sort by availability. Since all Vast.ai/RunPod offers are AVAILABLE, it’s a no-op — preserving the input order, which is not price-sorted.
2. Backend offers are not guaranteed to be price-sorted
get_backend_offers() (src/dstack/_internal/server/services/backends/__init__.py:495) uses heapq.merge to merge per-backend offer lists:
offers = heapq.merge(*offers_by_backend, key=lambda i: i[1].price)
heapq.merge requires each input iterable to be pre-sorted. However:
- Vast.ai (
core/backends/vastai/compute.py:58-76): get_offers_by_requirements() returns the raw get_catalog_offers() result without sorting
- RunPod (
core/backends/runpod/compute.py:79-90): get_all_offers_with_availability() similarly returns unsorted results from get_catalog_offers()
- gpuhunt
Catalog.query() explicitly documents that its results are "not strictly sorted by the price" — it uses heapq.merge on per-provider lists that may not be individually sorted
So offers flowing into _get_job_plan() via the dstack apply path are in an approximately-sorted but not strictly price-sorted order.
Why dstack offer works correctly
get_backend_offers_in_run_candidate_fleets() explicitly re-sorts by price at plan.py:732:
backend_offers.sort(key=lambda offer: offer[1].price)
The dstack apply path (find_optimal_fleet_with_offers() → _get_backend_offers_in_fleet() → get_offers_by_requirements()) does not sort by price after the availability-only sort in offers.py:116.
Code Flow
dstack apply path (not sorted by price)
get_job_plans() → find_optimal_fleet_with_offers() → _get_backend_offers_in_fleet() → get_offers_by_requirements()
get_offers_by_requirements() sorts by availability only (offers.py:116)
_get_job_plan() sorts by availability only (plan.py:835)
- Result: offers in approximately-sorted order, not by price
dstack offer path (correctly sorted by price)
get_job_plans() → _get_offers_in_run_candidate_fleets() → get_backend_offers_in_run_candidate_fleets()
get_backend_offers_in_run_candidate_fleets() sorts by price (plan.py:732)
_get_job_plan() sorts by availability only — but since all are AVAILABLE, the price order is preserved
- Result: offers sorted by price ascending
Proposed Fix
Add price as a secondary sort key in _get_job_plan():
# plan.py:835, change from:
job_offers.sort(key=lambda offer: not offer.availability.is_available())
# to:
job_offers.sort(key=lambda offer: (not offer.availability.is_available(), offer.price))
This ensures that within each availability group, offers are sorted by price ascending — matching the behavior of dstack offer.
An alternative (or complementary) fix would be to add the same price sort in get_offers_by_requirements() (offers.py:116), which would fix the root cause for all callers. However, the _get_job_plan() fix is more targeted and consistent with the existing explicit sort in get_backend_offers_in_run_candidate_fleets().
Secondary Issue
dstack apply --max-offers defaults to 3 (src/dstack/_internal/cli/services/configurators/run.py:350), while dstack offer --max-offers defaults to 50 (src/dstack/_internal/cli/commands/offer.py:47). The low default for dstack apply amplifies the impact of incorrect sorting — users only see 3 offers and never discover the cheaper ones further down the list.
This default could be raised to match dstack offer (50), or at least increased enough (e.g., 10-20) so users can see a representative sample of available pricing.
Description
When running
dstack apply -f service.yaml(withfleets: [my-fleet]), the offers displayed in the run plan are not sorted by price. Cheap offers (e.g., Vast.ai RTX 4080 at $0.37/hr) appear after expensive offers (e.g., RunPod A6000 at $0.98/hr), making them invisible with the default--max-offers=3. By contrast,dstack offer --fleet my-fleetcorrectly shows offers sorted by price ascending.Steps to Reproduce
my-fleetwithbackends: [vastai, runpod]and GPU requirements (e.g.,total_memory: 60GB..)fleets: [my-fleet]with matching resource/backends configdstack apply -f service.yaml— observe first offers are expensive (e.g., $0.98+)dstack offer --fleet my-fleet— observe cheapest offers first (e.g., $0.37+)Expected Behavior
Both commands should show offers sorted by price ascending (cheapest first), since all offers from Vast.ai and RunPod are marked
AVAILABLE.Actual Behavior
dstack applyshows offers in an approximately-sorted order where cheap Vast.ai offers are buried after expensive RunPod offers. With the default--max-offers=3, users only see:While
dstack offercorrectly shows:Root Cause Analysis
Two related issues:
1.
_get_job_plan()sorts by availability only, not priceIn
src/dstack/_internal/server/services/runs/plan.py:835:This is a stable sort by availability. Since all Vast.ai/RunPod offers are
AVAILABLE, it’s a no-op — preserving the input order, which is not price-sorted.2. Backend offers are not guaranteed to be price-sorted
get_backend_offers()(src/dstack/_internal/server/services/backends/__init__.py:495) usesheapq.mergeto merge per-backend offer lists:heapq.mergerequires each input iterable to be pre-sorted. However:core/backends/vastai/compute.py:58-76):get_offers_by_requirements()returns the rawget_catalog_offers()result without sortingcore/backends/runpod/compute.py:79-90):get_all_offers_with_availability()similarly returns unsorted results fromget_catalog_offers()Catalog.query()explicitly documents that its results are "not strictly sorted by the price" — it usesheapq.mergeon per-provider lists that may not be individually sortedSo offers flowing into
_get_job_plan()via thedstack applypath are in an approximately-sorted but not strictly price-sorted order.Why
dstack offerworks correctlyget_backend_offers_in_run_candidate_fleets()explicitly re-sorts by price atplan.py:732:The
dstack applypath (find_optimal_fleet_with_offers()→_get_backend_offers_in_fleet()→get_offers_by_requirements()) does not sort by price after the availability-only sort inoffers.py:116.Code Flow
dstack applypath (not sorted by price)get_job_plans()→find_optimal_fleet_with_offers()→_get_backend_offers_in_fleet()→get_offers_by_requirements()get_offers_by_requirements()sorts by availability only (offers.py:116)_get_job_plan()sorts by availability only (plan.py:835)dstack offerpath (correctly sorted by price)get_job_plans()→_get_offers_in_run_candidate_fleets()→get_backend_offers_in_run_candidate_fleets()get_backend_offers_in_run_candidate_fleets()sorts by price (plan.py:732)_get_job_plan()sorts by availability only — but since all are AVAILABLE, the price order is preservedProposed Fix
Add price as a secondary sort key in
_get_job_plan():This ensures that within each availability group, offers are sorted by price ascending — matching the behavior of
dstack offer.An alternative (or complementary) fix would be to add the same price sort in
get_offers_by_requirements()(offers.py:116), which would fix the root cause for all callers. However, the_get_job_plan()fix is more targeted and consistent with the existing explicit sort inget_backend_offers_in_run_candidate_fleets().Secondary Issue
dstack apply --max-offersdefaults to 3 (src/dstack/_internal/cli/services/configurators/run.py:350), whiledstack offer --max-offersdefaults to 50 (src/dstack/_internal/cli/commands/offer.py:47). The low default fordstack applyamplifies the impact of incorrect sorting — users only see 3 offers and never discover the cheaper ones further down the list.This default could be raised to match
dstack offer(50), or at least increased enough (e.g., 10-20) so users can see a representative sample of available pricing.