Limitations and Edge Cases¶
What Clustrix cannot do, what it does differently from what you might expect, and what to do instead. Everything on this page was checked against the code and, where an error message is quoted, produced by running it.
Functions whose source cannot be read¶
What is true: a function defined in the REPL, in a notebook cell, or by
exec() serializes and runs correctly. Dill and cloudpickle serialize by
value – they embed the code object – so the worker never needs the source
text.
Older versions of this documentation said such functions “cannot be
serialized”. That was wrong, and it mattered: the claim was paired with
machinery that substituted a rewritten or hardcoded function whenever
inspect.getsource failed, which at one point returned the literal string
"Function execution completed" as your result. That machinery has been
deleted (issues #89, #90). The function you wrote is the function that gets
serialized. Nothing is substituted for it, ever.
What is actually lost is everything that reads source text:
loop parallelization (
detect_loops,find_parallelizable_loops– both start withinspect.getsource),the source-text fallback the worker would use if binary deserialization failed.
from clustrix.utils import serialize_function, deserialize_function
from clustrix.loop_analysis import find_parallelizable_loops
namespace = {}
exec(
"def sourceless(n):\n"
" out = 0\n"
" for i in range(n):\n"
" out = i\n"
" return out\n",
namespace,
)
sourceless = namespace["sourceless"]
data = serialize_function(sourceless, (10,), {})
print("function_source:", data["function_source"])
print("parallelizable loops:", find_parallelizable_loops(sourceless, (10,), {}))
func, args, kwargs = deserialize_function(data)
print("still runs:", func(*args, **kwargs))
Output:
function_source: None
parallelizable loops: []
still runs: 9
(The round trip above happens in one process for brevity; the same payload was
also loaded in a fresh interpreter, where it returned 9 as well.)
Workaround: none needed for correctness. If you want loop parallelization,
move the function into a .py file.
Editable installs, git checkouts and local source trees¶
An editable install, a pip install git+https://..., or a bare .egg-info
in a source tree cannot be recreated on the cluster. Their version exists,
but pip install name==version would install an unrelated package of the
same name off an index, or nothing at all.
Such distributions are excluded from the mirrored requirement set. If your function actually reaches into one of them – and clustrix knows, because it walks the object graph and maps import names back to distributions – the submission is refused at submit time, naming the package:
RuntimeError: This function uses package(s) that cannot be installed on the
cluster: mylib (installed in editable mode from file:///home/me/src/mylib).
clustrix mirrors your environment with `pip install name==version`, which for
these would install something other than what you are running. Publish the
package, vendor the code into your project directory so clustrix can send it
by value, or list it in `excluded_packages` if the remote job genuinely does
not need it.
You can see the list before submitting:
from clustrix.utils import get_unreproducible_requirements
offenders = get_unreproducible_requirements()
print("unreproducible distributions found:", isinstance(offenders, dict))
for name, reason in sorted(offenders.items()):
print(" -", name, "--", reason)
Workarounds, in order of preference:
Publish the package (PyPI or a private index the cluster can reach) so
name==versionresolves.Vendor it into your project directory. A module that is not under site-packages is classified project-local by
_is_local_moduleand is shipped by value inside the payload – no install required. This is why your own project modules already work.Add it to
cluster_packageswith a spec the cluster can install (e.g."mylib @ git+https://github.com/me/mylib@v1.2"), which is passed topip installverbatim.Add it to
excluded_packagesif the remote job genuinely does not need it.
A caveat worth knowing: whether you hit the refusal depends on where the code lives, not on what the metadata says. An editable install whose source tree is outside site-packages is classified project-local and gets shipped by value instead – which usually works. The refusal fires for the cases where the code really is in site-packages but the metadata is unreproducible, notably VCS installs.
Loop detection is much narrower than it looks¶
auto_parallel defaults to True, which suggests loops are routinely
parallelized. In practice find_parallelizable_loops rejects most real
loops. Three rules do the rejecting.
1. The loop target must be a bare name. _analyze_for_loop returns
None unless node.target is an ast.Name. So
for i, x in enumerate(items) and for k, v in d.items() are not merely
non-parallelizable – they are not detected as loops at all.
2. Reading any external name in the body disqualifies the loop.
final_dependencies = dep_analyzer.reads - {variable}, and
find_parallelizable_loops requires not loop.dependencies. Every
ast.Name in Load context counts, including the receiver of a method
call. So the single most common loop shape in Python –
results.append(f(x)) – is rejected, because results is read.
3. An augmented assignment counts as a read. total += i adds total
to reads even though the AST target’s context is Store. This one is
correct and deliberate: without it, an accumulator loop looked dependency-free
and was falsely classified as safe to parallelize (issues #106, #131).
Demonstrated:
from clustrix.loop_analysis import detect_loops_in_function, find_parallelizable_loops
def tuple_target(items):
total = 0
for i, x in enumerate(items):
total += i * x
return total
def appending(items):
results = []
for x in items:
results.append(x * 2)
return results
def independent():
out = 0
for i in range(1000):
out = i
return out
for fn, argv in [(tuple_target, ([1, 2, 3],)), (appending, ([1, 2, 3],)),
(independent, ())]:
loops = detect_loops_in_function(fn, argv, {})
print(
fn.__name__,
"| detected:",
[(lp.variable, sorted(lp.dependencies), lp.is_parallelizable) for lp in loops],
"| parallelizable:",
[lp.variable for lp in find_parallelizable_loops(fn, argv, {})],
)
Real output:
tuple_target | detected: [] | parallelizable: []
appending | detected: [('x', ['results'], False)] | parallelizable: []
independent | detected: [('i', [], True)] | parallelizable: ['i']
Workarounds:
Do not rely on automatic parallelization. Split the work yourself and submit one
@clustercall per chunk; that is explicit, backend-independent, and produces a shape you chose.Rewrite
for i, x in enumerate(items)asfor i in range(len(items))if you want the loop to be seen at all.Set
auto_parallel=Falseto remove the guesswork entirely.
Auto-parallelization needs chunk parameters, and they differ local vs remote¶
The two paths use different keyword names, which is easy to trip over:
Path |
Keywords your function must accept |
|---|---|
Local ( |
|
Remote ( |
|
Either path declines, and logs at INFO, when the function cannot accept
its chunk. Neither injects the argument any more: doing so used to raise
TypeError: ... got an unexpected keyword argument '_chunk_range_i' on the
remote path, and on the local path the TypeError was swallowed into a
silent sequential run.
Both paths also require the loop’s range to be a literal range(<int>).
A range whose bound is only known at run time – range(n),
range(len(data)) – is declined. It used to be guessed as range(10),
which meant the caller silently received a tenth of the work.
When _create_local_work_chunks splits a loop, it hands each chunk to your
function as a keyword argument named _parallel_<loop variable>. A function
that neither declares that parameter nor collects **kwargs cannot receive
it, so clustrix declines to parallelize and runs the function sequentially –
and, since this was previously silent, it now says so:
INFO clustrix.decorator: Not parallelizing no_chunk_param locally:
it takes no '_parallel_i' parameter.
That is an INFO on the clustrix.decorator logger, so you will not see
it unless logging is configured at that level.
Parallel and sequential runs can return different shapes¶
This is the trap most likely to produce a wrong answer rather than an error.
_combine_local_results does this:
no results ->
Noneexactly one chunk -> that chunk’s result, unwrapped
all chunks returned lists -> the lists concatenated
otherwise -> the list of per-chunk results
So a function that returns a scalar returns a list of scalars when it is
parallelized, and the length of that list depends on os.cpu_count() on the
machine that ran it.
# shape_demo.py
import clustrix
clustrix.configure(cluster_type="local", auto_parallel=True)
def body(_parallel_i=None):
total = 0
for i in range(1000):
total = i
return total
@clustrix.cluster(cores=4)
def counted(_parallel_i=None):
total = 0
for i in range(1000):
total = i
return total
Called from another module, so that inspect.getsource can see it:
import shape_demo
parallel = shape_demo.counted()
sequential = shape_demo.body()
print("parallel ->", type(parallel).__name__, repr(parallel)[:60])
print("sequential ->", type(sequential).__name__, repr(sequential))
assert isinstance(sequential, int)
On a 12-core machine that prints:
parallel -> list [999, 999, 999, 999, 999, 999, 999, 999, 999, 999, 999, 999,
sequential -> int 999
Note also that the function above accepts _parallel_i and then ignores
it, which is why every chunk computed the same thing. Accepting the parameter
is what makes clustrix willing to parallelize; using it is your
responsibility.
The remote path has the same shape problem: _combine_results in
decorator.py sorts by chunk index and returns
[result[1] for result in results] unconditionally.
Workaround: either set parallel=False on the decorator (or
auto_parallel=False globally) so a function always returns what its body
returns, or write the function to take _parallel_<var> and return a list,
so both shapes agree.
Python version skew is refused, not worked around¶
Dill and cloudpickle embed CPython bytecode, which does not load across minor
versions. When conda is unavailable and clustrix has to use a system
interpreter, _select_remote_python requires an exact major.minor match:
from clustrix.utils import _select_remote_python
try:
_select_remote_python([("python3.9", "3.9")], "3.12")
except RuntimeError as exc:
print(exc)
The remote system has Python 3.9, but this session runs Python 3.12.
Serialized functions carry CPython bytecode, which cannot be loaded by a
different minor version, so the cluster needs a Python 3.12 interpreter -- or
conda, which clustrix will use to create one.
With no Python 3 at all:
No Python 3 interpreter found on the remote system. Consider installing conda.
Workarounds: install conda on the cluster (clustrix will create a matching
environment itself), install a matching interpreter, or match the cluster’s
version locally. The same constraint applies to the HuggingFace backend, whose
image defaults to python:<your minor version>-slim for exactly this reason;
if you override hf_image, keep the minor version identical.
Things that genuinely cannot be sent¶
Sockets, live database connections, open file handles and locks cannot cross to a worker in any useful sense. What actually happens depends on which serialization branch you land in, and the difference matters.
If your payload reaches a project-local module, cloudpickle is used with no fallback, and you get a clear refusal that names the object:
RuntimeError: Cannot serialize this job: something it reaches cannot be
pickled (cannot pickle 'socket' object). Locks, open files, sockets and
database handles cannot cross to a worker. Create it where it is used instead
of capturing it, or install the package on the cluster so the worker imports
it rather than receiving a copy.
If it does not, _dumps_by_value falls back through
dill(recurse=True) -> dill -> cloudpickle -> pickle. The
recurse=True attempt fails on the socket; the plain dill.dumps attempt
succeeds by not capturing the offending global at all. Submission looks fine
and the failure surfaces on the worker:
NameError: name 's' is not defined
That was produced by serializing a module-level function that closes over
s = socket.socket(), writing the payload to disk, and loading it in a
separate interpreter. It is a real gap: the local submission gives no warning.
A related surprise: some objects you might expect to be rejected are pickled
happily. dill.dumps(threading.Lock()) succeeds in 47 bytes and round-trips
– but what arrives is a different, unlocked lock in a different process, so
any coordination you were relying on is silently gone.
Workarounds:
Create the resource inside the function, not outside it. Open the file, dial the socket, connect to the database in the body, so it exists on the worker and nothing needs to travel.
Pass a description (a path, a DSN, a URL) rather than a live handle.
For files, pass paths and use the filesystem utilities (
cluster_ls,cluster_glob,cluster_stat) which work locally and remotely from the same code.
Payload size limits¶
The object graph walk is capped at 2,000,000 nodes; beyond that
WalkTooLargeError is raised and the job is refused rather than shipped with
an unknown payload. On the HuggingFace backend, an encoded payload over 256 KB
is automatically staged through a private dataset repo instead of an
environment variable – this is handled for you, but it does mean the payload
briefly exists in a Hub repo under your namespace.
Results can only be collected by the process that submitted them¶
The per-job HMAC key lives in SchedulerManager.active_jobs, in memory. If
your interpreter exits while a job is queued, the job still runs, but its
result can no longer be authenticated:
PayloadAuthenticationError: No result-signing key is recorded for Job
slurm_1234, so what it produced cannot be authenticated. Refusing to
deserialize it: loading a pickle executes code. Re-run the job from this
process, which records a key at submission.
This is a refusal, not a warning, and there is no override. “No key recorded” and “forged” are indistinguishable from the caller’s side.
Workaround: keep the submitting process alive (use async_submit=True
if you need it to do other things meanwhile), or have the remote function
write its own output to a durable location – a file on the cluster, a
database – and return only a path or a summary.
Unverified backends¶
Only slurm, ssh and huggingface have been demonstrated running a
real job end to end (scripts/collect_execution_evidence.py). local
works and is exercised by the test suite. The rest are implemented but
unverified:
Backend |
Caveat |
|---|---|
|
Never run against real hardware. It now shares the staging
and environment setup the other schedulers use; previously
it ran |
|
Never run against real hardware. |
|
Never verified against a real cluster. Additionally it does
not replicate your environment: the container installs
only |
Cloud VM providers |
Every |
Use cluster_type="huggingface" (HuggingFace Jobs), not
provider="huggingface" (Spaces).
Windows clients: config and credential files are not permission-restricted¶
Clustrix runs on Windows as a client: it submits to a Linux cluster, and the job scripts it generates are quoted for the cluster’s POSIX shell regardless of what your own machine runs. One security property does not carry across, however, and Windows users should know about it.
On Linux and macOS, files that can hold credentials are created with mode
0600 – readable and writable by the owner only:
the config file written by
ClusterConfig.save_to_file()andclustrix.config.save_config(withinclude_secrets=True, or with a secret inenvironment_variables, this file contains plaintext credentials);~/.clustrix/.env, the credential template and store;the SSH keys and
~/.ssh/configentries clustrix generates.
On Windows none of these files are restricted. POSIX permission bits are a
POSIX concept: NTFS controls access with ACLs instead, Python’s os.chmod
there only toggles the read-only attribute, and os.fchmod does not exist at
all before Python 3.13. Clustrix does not depend on pywin32, so it has no
way to set an ACL. The files are therefore created with whatever permissions
they inherit from their parent directory – typically readable by every account
on the machine, and by anything that can reach the directory over a network
share.
What to do about it on Windows:
Prefer keeping secrets out of files entirely: set them in environment variables, or let clustrix prompt for them, rather than saving them with
include_secrets=True.If you must save them, put the config directory somewhere already restricted and restrict it explicitly, e.g.:
icacls "%USERPROFILE%\.clustrix" /inheritance:r /grant:r "%USERNAME%:(OI)(CI)F"
Set
CLUSTRIX_CONFIG_DIRif you want that directory to be somewhere other than%USERPROFILE%\.clustrix.Treat a saved clustrix config on Windows as you would any other unprotected file: do not put it on a shared drive, and do not commit it.
The corresponding tests assert the 0600 mode only on POSIX, because there
is no mode on Windows for them to assert – the property genuinely does not
exist there, rather than merely being untested.
Smaller sharp edges¶
The single-venv fallback does not install your packages. If
use_two_venv=False, or the two-venv setup raises or exceedsvenv_setup_timeout, the job gets one venv containing onlydillandcloudpickle. The warningTwo-venv setup failed, falling back to basic setup:should be treated as an error.Local jobs cannot be cancelled.
cluster_type="local"runs the function duringsubmit_job, so by the time you could cancel it, it has finished and its side effects have happened.cancel_jobraises rather than pretending otherwise.A conda environment name proves nothing. Reuse requires the
.clustrix_readymarker, written only after every install succeeded.``pre_execution_commands`` is not validated or quoted. It is a raw shell injection point by design.
module_loadsandenvironment_variableskeys are validated and will refuse metacharacters.``cores=0`` falls back to the default. The merge is written as
cores or config.default_cores, so any falsy value takes the default.``@cluster`` mutates global configuration. Passing
platform=,auto_provision=,cluster_name=,node_count=,node_type=,kubernetes_version=orfrom_scratch=writes the corresponding field onto the sharedClusterConfig, where it stays for every later call.Unknown ``@cluster`` keywords are warned about, not rejected, and only on the first call – so a typo in a keyword name is easy to miss if you are not watching the log.
Some recognised ``@cluster`` keywords are still ignored by their backend.
k8s_namespace,k8s_image,k8s_service_accountandk8s_pull_policyare accepted and placed injob_config, butKubernetesJobManagerreads onlyself.config.k8s_*. Likewisehf_namespace,hf_tokenandhf_usernameare accepted butHFJobsManagerresolves them from configuration. These produce no warning, because the keywords are on the recognised list. Set them throughclustrix.configure().
See also¶
Execution Model – the mechanism behind each of these limits.
Configuration – including the list of settings that have no effect.