Skip to content

Add Lium catalog provider - #259

Open
surcyf123 wants to merge 5 commits into
dstackai:mainfrom
Datura-ai:lium-provider
Open

surcyf123 wants to merge 5 commits into
dstackai:mainfrom
Datura-ai:lium-provider

Conversation

@surcyf123

Copy link
Copy Markdown

Summary

Adds Lium as an online catalog provider.

  • Lium rents NVIDIA GPU pods and publishes its whole rentable inventory as one unauthenticated JSON feed with per-GPU hourly prices, so the provider needs no credentials: LiumProvider.from_env() is cls(), like vastai. Feed: https://lium.io/api/public/v1/nodes — contract at https://docs.lium.io/developers/public-nodes-feed (fields under /public/v1 are only ever added; breaking changes ship as /public/v2). Rate limit 60 req/min per IP, snapshot rebuilt once a minute.
  • A node can be rented in part. One offer is produced per GPU count between min_rentable_gpu_count and available_gpu_count (the same expansion jarvislabs does), with CPUs, RAM and disk in proportion to the GPU share, which is how Lium allocates them (field notes). price is the per-GPU-hour price times the offer's GPU count.
  • gpu_name is normalized to the KNOWN_NVIDIA_GPUS names (RTX 5090 → RTX5090, H100 80GB HBM3 → H100, A100-SXM4-80GB → A100, RTX PRO 6000 Blackwell Server Edition → RTXPRO6000, RTX 6000 Ada Generation → RTX6000Ada). Every model in today's live feed resolves to a known GPU; a model that does not is skipped with a warning, as seeweb and jarvislabs do, and the integrity tests warn about such models in the live feed (like test_real_world_vastai_offers). gpu_memory goes through correct_gpu_memory_gib (the feed says 140 GB for the H200, the table says 141).
  • location is <iso country>-<subdivision> (us-ca, jp-13), spot is True for nodes in Lium's spot tier (the provider may reclaim them), instance_name is the node id — the identifier the rent API takes (POST /executors/{id}/rent, https://lium.io/api/openapi.json), so a future dstack backend can rent straight from the offer. provider_data carries the rent_url deep link.
  • Registered in ONLINE_PROVIDERS, ONLINE_PROVIDER_MODULES, the CLI (python -m gpuhunt lium), README, and the catalogs workflow (test-lium integrity job, no secrets needed).

Testing

  • uv run pytest src/tests — 168 passed (45 new provider tests, 16 of them against a feed sample recorded on 2026-09-10; no network)
  • uv run pytest --doctest-modules src/gpuhunt — 4 passed
  • uv run pytest src/integrity_tests/test_lium.py — 11 passed against the live feed
  • uv run pyright — 0 errors; pre-commit run -a (ruff, ruff-format) — passed
  • python -m gpuhunt lium --output lium.csv — 117 offers from 84 nodes at the time of writing

Notes

I work on Lium. Happy to adjust anything to the conventions you prefer — for example collapsing the per-GPU-count expansion to whole-node offers only.

Lium (https://lium.io) rents NVIDIA GPU pods and publishes its whole
rentable inventory as one unauthenticated JSON feed with per-GPU hourly
prices, so the provider needs no credentials and is queried live as an
online provider.

- Feed: https://lium.io/api/public/v1/nodes (contract documented at
  https://docs.lium.io/developers/public-nodes-feed; fields under
  /public/v1 are only ever added).
- A node can be rented in part: one offer is produced per GPU count
  between `min_rentable_gpu_count` and `available_gpu_count`, with CPUs,
  RAM and disk in proportion to the GPU share, as Lium allocates them
  (the same expansion the jarvislabs provider does).
- `price` is the per-GPU-hour price times the offer's GPU count.
- `gpu_name` is normalized to the KNOWN_NVIDIA_GPUS names (`RTX 5090` ->
  `RTX5090`, `H100 80GB HBM3` -> `H100`, `RTX PRO 6000 Blackwell Server
  Edition` -> `RTXPRO6000`); every model in the live feed resolves to a
  known GPU, and a model that does not is skipped with a warning, like
  the seeweb and jarvislabs providers do. `gpu_memory` goes through
  `correct_gpu_memory_gib`.
- `location` is `<iso country>-<subdivision>` (`us-ca`, `jp-13`).
- `spot` is True for nodes in Lium's spot tier (provider may reclaim).
- `instance_name` is the node id, which is what the rent API takes.
- Unit tests run against a recorded feed sample; the integrity test
  queries the live feed like the other online providers.
@arhangel66

Copy link
Copy Markdown

Gentle ping: this PR is still green and merges cleanly. It adds Lium as a provider using only the public, unauthenticated node feed (https://lium.io/api/public/v1/nodes, contract at https://docs.lium.io/developers/public-nodes-feed.md), so the catalog refresh needs no credentials. Happy to adjust anything to your conventions or to be listed as the maintainer for this provider.

@peterschmidt85 peterschmidt85 left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left a couple of questions below about RAM allocation and the minimum GPU count. Could you clarify how these work on Lium’s side?

Comment thread src/gpuhunt/providers/lium.py Outdated
Comment thread src/gpuhunt/providers/lium.py Outdated

@peterschmidt85 peterschmidt85 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR looks good to me now. Thanks for addressing both comments. Just one final ask: could we also handle B300 SXM6 PC?

Comment thread src/gpuhunt/providers/lium.py Outdated
jvstme pushed a commit that referenced this pull request Oct 1, 2026
Vast lists B300 boards that report "NVIDIA B300 SXM6 PC" as "B300 PC",
next to the "B300" it uses for "NVIDIA B300 SXM6 AC" boards.
get_dstack_gpu_name turned that into "B300PC", which is not a known GPU,
so those offers never matched a request for B300.

Normalize "B300 PC" to "B300", and ask Vast for both names when the
query is for B300. This is the same normalization requested for Lium in
#259 (comment)

Co-authored-by: Harry Eakins <harry.eakins@span.io>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

@peterschmidt85 peterschmidt85 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @surcyf123, the earlier fixes look good. One remaining ask on disk allocation below. I prepared and tested a fix locally, but GitHub won't let me push to your fork.

Could you also add $50 in credits to andrey.cheptsov@gmail.com so we can test reliability and explore adding a dstack backend?

Comment thread src/gpuhunt/providers/lium.py Outdated
Use disk_free_gb, not total disk_gb, and sum the separately rounded-down 2/3 volume and 1/3 container storage quotas (docs.lium.io/pod-users/create-pod). Skip nodes with no free disk reported and offers whose quotas round to zero, since disk_size=None passes every disk filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@surcyf123

Copy link
Copy Markdown
Author

@peterschmidt85 thanks. The disk fix is in 0fc9b50 (details in the thread). I've passed the $50 test-credit request for andrey.cheptsov@gmail.com to the team, and I'll post here once it's decided. If that address has no Lium account yet, signing up at https://lium.io with it lets the credit go straight on.

@surcyf123

Copy link
Copy Markdown
Author

@peterschmidt85 $50 in credits is now on the Lium account for andrey.cheptsov@gmail.com. It's valid for 90 days, until 13 Jan 2027, and a confirmation email went to that address. Thanks for looking at a dstack backend; if anything comes up while you're testing, just ask here.

@peterschmidt85 peterschmidt85 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the credits, @surcyf123. We now have a draft Lium backend in dstackai/dstack#4377. Focused E2E checks passed for private images, non-root jobs, root-volume persistence, cancellation, and cleanup.

Could you try the backend from that PR with your own workloads and share feedback on the integration? We'd like your input before merging it.

I left two resource-accounting issues found during testing below. Once we resolve those and get your backend feedback, we can proceed with merging/releasing gpuhunt and merging the backend.

Comment thread src/gpuhunt/providers/lium.py Outdated
# proportion to its GPU share.
share = count / gpu_count
cpu = int(cpu_count * share)
memory = max(1.0, math.floor(pod_ram_gb * share * 100) / 100)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A live one-GPU H200 rental exposed a remaining RAM mismatch:

share = (2015 - max(4, 2015 * 0.01)) / 8
math.floor(share * 100) / 100  # 249.35 GiB advertised
267361714176 / 2**30           # 249 GiB, measured memory.max

So QueryFilter(min_memory=249.1) accepts an offer whose rental only has 249 GiB.

Could we use unrounded host RAM or an authoritative per-rental allocation, and add boundary filtering tests? Simply flooring the current result isn't sufficient: another host's feed says 63 GiB, while its raw telemetry says 62.796 GiB. After the 4 GiB reserve, those round down to 59 versus 58 GiB. That second example is from telemetry, not a rented-pod measurement.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a405412. Lium sizes memory.max as the pod's RAM share rounded down to whole GiB. The feed rounds ram_gb to the nearest GiB, so the provider now starts from the smallest host value consistent with the feed (ram_gb - 0.5), takes off the host reserve and rounds the share down to whole GiB. The one-GPU H200 now reports 249, which matches the measured memory.max. It can come out up to 1 GiB below the real allocation, but it never comes out above it. test_memory_filter_boundary pins it: min_memory=249 matches and min_memory=249.1 does not.

If you'd rather have it exact, we can add an unrounded per-GPU allocation field to the public feed (fields are only ever added under /public/v1) and switch to it later.

Comment thread src/gpuhunt/providers/lium.py Outdated
>>> get_disk_quota_gb(1)
0
"""
return int(disk_gb * 2 / 3) + int(disk_gb / 3)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You implemented exactly what I asked for; I need to correct that advice after integration testing—sorry. These are separate filesystem quotas, so adding them doesn't give a job that much space in one directory.

Using the same 1467 GB / 8 GPU example:

root_quota = int((1467 / 8) * 2 / 3)  # 122
container_quota = int((1467 / 8) / 3) # 61
# Current disk_size: 122 + 61 = 183

A 150 GB disk request matches, although neither filesystem can hold that much. The backend's /workflow directory uses the container filesystem, which gets 61 GB in this example.

Could we agree on and report the capacity available at the job's storage path, rather than the sum, and update the disk-filtering tests? With the current /workflow layout, that would be 61 GB here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a405412. disk_size is now only the container storage quota, the filesystem that holds /workflow: int((disk_free_gb - 0.5) * share / 3). The - 0.5 makes it a lower bound, because the feed rounds free disk to the nearest GB. Your example now gives 61 GB. test_disk_filter_uses_the_pod_quota checks that 150 GB and 62 GB requests don't match it while 61 GB does, and offers whose quota rounds to zero are skipped.

…unds

The feed rounds RAM and free disk to the nearest GB, so size offers from
the lowest true value and round down to whole GiB, matching the pod's
memory.max. Disk is now the container storage quota (1/3 of the share)
that holds /workflow, not volume + storage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@surcyf123

Copy link
Copy Markdown
Author

@peterschmidt85 both resource issues are fixed in a405412 (details in the two threads): memory is whole-GiB memory.max, and disk is the /workflow storage quota, both as lower bounds. Thanks for running the backend E2E. We're setting up dstack#4377 against our own workloads now and will post feedback on that PR.

@surcyf123

Copy link
Copy Markdown
Author

@peterschmidt85 our feedback on the backend is in dstackai/dstack#4377 (comment). In short, the task, dev environment, cancel during provisioning and cleanup all worked against this branch at a405412. On live pods, memory, CPUs and disk matched or exceeded what the offers advertised. Nothing on the gpuhunt side is left open from our end.

@peterschmidt85

Copy link
Copy Markdown
Contributor

@surcyf123 one issue from testing the dstack backend: S3-backed volumes don't work for non-root jobs.

On a fresh unencrypted A6000 pod, UID 1000 can create a 0600 file on the volume, but the file shows up as owned by root. After closing it, the same user can't reopen, overwrite, or append to it (Permission denied). A 0700 directory has the same problem. Root chown reports success but leaves ownership unchanged. Root jobs pass the read/write and persistence tests.

We also checked bindfs, but ordinary unencrypted pods don't expose /dev/fuse. Is there a supported way to make volumes writable for the job's UID/GID? If not, is this something you could potentially fix on your side? Happy to retest it with dstack.

@surcyf123

Copy link
Copy Markdown
Author

@peterschmidt85 thanks for the precise report. It's a bug on our side, and we've found the cause.

An S3 volume is mounted with s3fs, and the mount reports every object as owned by the pod's root. s3fs checks permissions itself against that owner and the stored mode. So a UID 1000 job creates a 0600 file that then appears root-owned, and it is locked out of that file. That's also why chown is a no-op.

The fix is to mount S3 volumes with umask=0000, so every file and directory on the volume is readable and writable by any user in the pod. The volume belongs to one renter only, so nothing is opened to anyone else. You don't need bindfs or /dev/fuse in the pod.

The fix is built and in review, not yet released. It applies to pods rented after the release, and I'll post here when it is live so you can retest with dstack.

@peterschmidt85

Copy link
Copy Markdown
Contributor

@surcyf123 thanks for tracking this down and fixing it! Please let us know once it's live so we can retest non-root volume access on fresh pods.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants