Skip to content

Semaphore gets released too often #4147

Open

Description

@lfdversluis

To avoid generating too much NFS traffic, I am using a semaphore like this to copy data from NFS to a local file storage to read from later:

cluster = SLURMCluster(cores=16, memory="64 GB", #processes=1,
                       local_directory="/var/scratch/me/dask_scheduler_spill",
                       interface='ib0', walltime='24:00:00')

# Create a client to submit to.
client = Client(cluster)

# Allocate 10 nodes in the cluster
cluster.scale(10)

print("Waiting for workers")
# Wait until they are ready
client.wait_for_workers(10)
print("Workers are ready!")

sem = Semaphore(max_leases=4, name='data_copy')

def copy_data(location, target, sem):
    with sem:
         copy_tree(location, target, update=True)

client.run(copy_data, root_dataset, local_dataset, sem)
sem.close()  # Clean up the semaphore at the scheduler side

Now if I run it I get the error that

site-packages/distributed/semaphore.py", line 472, in release
    raise RuntimeError("Released too often")
RuntimeError: Released too often

I tried debugging, I found out that setting processes=1, in the construction of SLURMCluster does **not ** fix this. So it looks like a threading issue to me. #4057 should've fixed that, but clearly it didn't? Creating the semaphore with sem = Semaphore(max_leases=4, name='data_copy', client=client, register=True) also did not work.

Activity

  1. lfdversluis commented on Oct 5, 2020

    @lfdversluis
    Author

    Probably important details:

    >>> import dask
    >>> dask.__version__
    '2.28.0'
    

    and

    >>> import dask_jobqueue
    >>> dask_jobqueue.__version__
    '0.7.1'
    
  2. mrocklin commented on Oct 5, 2020

    @mrocklin
    Member
  3. fjetter commented on Oct 6, 2020

    @fjetter
    Member

    Can you try defining the semaphore instance within the function instead of passing it via the function

    def copy_data(location, target):
        with Semaphore(max_leases=4, name='data_copy'):
             copy_tree(location, target, update=True)
    

    This should help ruling out a (de-)serialization issue. I am pretty surprised to see this and have no idea what's beneath this. The first thing I would suspect is that for whatever reason two worker threads use the same semaphore instance and they simultaneously manipulate internal state non-threadsafe. However, according to the docs, the deque in question here is supposed to be thread safe for the operations we're performing (append and popleft)


    On a different note, the semaphore module is quite verbose at logging. Can you maybe post the semaphore module logs in here? Maybe they give some insight into what's going on

  4. lfdversluis commented on Oct 6, 2020

    @lfdversluis
    Author

    No Semaphore output is ending up in the slurm-xxxxxx.out files. I ran my script with your suggested modification and it still happens.

    Here is the output of one of the slurm-xxxxx.out files

    distributed.nanny - INFO -         Start Nanny at: 'tcp://redacted'
    distributed.worker - INFO -       Start worker at:    tcp://redacted
    distributed.worker - INFO -          Listening to:    tcp://redacted
    distributed.worker - INFO -          dashboard at:          redacted
    distributed.worker - INFO - Waiting to connect to:   tcp://redacted
    distributed.worker - INFO - -------------------------------------------------
    distributed.worker - INFO -               Threads:                         16
    distributed.worker - INFO -                Memory:                   64.00 GB
    distributed.worker - INFO -       Local Directory: /var/scratch/me/dask_scheduler_spill/dask-worker-space/worker-m4iudv4x
    distributed.worker - INFO - -------------------------------------------------
    distributed.worker - INFO -         Registered to:   tcp://redacted
    distributed.worker - INFO - -------------------------------------------------
    distributed.core - INFO - Starting established connection
    distributed.worker - INFO - Run out-of-band function 'copy_data'
    /var/scratch/me/miniconda3/lib/python3.8/site-packages/distributed/semaphore.py:487: RuntimeWarning: coroutine 'Semaphore._acquire' was never awaited
      self.acquire()
    distributed.worker - WARNING - Run Failed
    Function: copy_data
    args:     ('/var/scratch/me/SurfAnalysis/', '/local/me/')
    kwargs:   {}
    Traceback (most recent call last):
      File "/var/scratch/me/miniconda3/lib/python3.8/site-packages/distributed/worker.py", line 3546, in run
        result = function(*args, **kwargs)
      File "<ipython-input-11-6819b92fcd42>", line 22, in copy_data
      File "/var/scratch/me/miniconda3/lib/python3.8/site-packages/distributed/semaphore.py", line 491, in __exit__
        self.release()
      File "/var/scratch/me/miniconda3/lib/python3.8/site-packages/distributed/semaphore.py", line 472, in release
        raise RuntimeError("Released too often")
    RuntimeError: Released too often
    slurmstepd: error: *** JOB 2685329 ON node062 CANCELLED AT 2020-10-06T14:36:40 ***
    distributed.dask_worker - INFO - Exiting on signal 15
    distributed.nanny - INFO - Closing Nanny at 'tcp://redacted'
    distributed.nanny - INFO - Worker process 13864 was killed by signal 15
    
    

    I now noticed the line coroutine 'Semaphore._acquire' was never awaited. I see the _acquire is async, yet return self.client.sync(self._acquire, timeout=timeout) in the source code should deal with this, no? I don't have enough knowledge of Dask internals to debug this fully.

  5. fjetter commented on Oct 6, 2020

    @fjetter
    Member

    I see the _acquire is async, yet return self.client.sync(self._acquire, timeout=timeout) in the source code should deal with this, no? I don't have enough knowledge of Dask internals to debug this fully.

    Well, yes I would think so. However, if this was never awaited, for whatever reason, it would explain why your code could reach the release before it actually acquired a lease. Let's just put a assert this_cannot_happen somewhere in there 😅

    If this was never awaited you should effectively not seeing your computations to slow down. The acquire is the blocking call and without it all jobs will run simultaneously.

    While I haven't understood the problem yet and believe this should be fixed, if this is the root cause you might work around it by using the following

    async def copy_data(location, target, sem):
        async with sem:
            # do stuff
    client.run(copy_data)

    by declaring it as a coroutine and awaiting explicitly you should be able to work around the issue.


    Browsing the code a bit, the client.sync method should take care of the future unless you're in an async context already. Haven't worked with dask jobque myself, yet. Is there anythign special about the setup there?

  6. fjetter commented on Oct 6, 2020

    @fjetter
    Member

    Not really pretty, but to get to the bottom of it, there are two things which would cause the client.sync to not block and this is if the client is setup asynchronously or if it detects the thread to be in an async state.

    You might want to look at the output of

    def copy_data(..., sem):
        from distributed.utils import thread_state
        print/log(f"Client.async {sem.client.asynchronous}")
        print/log(f"Thread state {getattr(thread_state, 'asynchronous', False)}")
        # ...
  7. lfdversluis commented on Oct 7, 2020

    @lfdversluis
    Author

    If I run your snippet with

    def copy_data(..., sem):
        from distributed.utils import thread_state
        print/log(f"Client.async {sem.client.asynchronous}")
        print/log(f"Thread state {getattr(thread_state, 'asynchronous', False)}")
        # ...
    

    The script crashes before it can even print, seems like the run function does not block?

    Running with

    async def copy_data(location, target, sem):
        import logging
        logger = logging.getLogger(__name__)
        from distributed.utils import thread_state
        logger.info(f"Client.async {sem.client.asynchronous}")
        logger.info(f"Thread state {getattr(thread_state, 'asynchronous', False)}")
        async with sem:
             copy_tree(location, target, update=True)
    

    outputs nothing in the logs? Just

    ...
    distributed.core - INFO - Starting established connection
    distributed.worker - INFO - Run out-of-band function 'copy_data'
    distributed.semaphore - INFO - Client-worker-036449e1-088d-11eb-9771-382c4a2fd6c3 requests lease for data_copy with ID 94598ecf1fa84e1bbdaac1c00d44e114
    distributed.semaphore - INFO - Client-worker-036449e1-088d-11eb-9771-382c4a2fd6c3 releases 94598ecf1fa84e1bbdaac1c00d44e114 for data_copy
    ...
    

    printing doesn't work either; I think as it runs on the node instead of scheduler?

  8. fjetter commented on Oct 7, 2020

    @fjetter
    Member

    I had another look at the issue and it turns out it can be rather easily reproduced with the local cluster even

    from distributed import Client, LocalCluster, Semaphore
    
    
    def do_stuff(sem):
        with sem:
            return None
    
    
    cluster = LocalCluster()
    client = Client(cluster)
    sem = Semaphore()
    client.run(do_stuff, sem)

    The issue is that you're using https://distributed.dask.org/en/latest/api.html#distributed.Client.run to schedule an out-of-band function, is this intentional? Internally this works slightly differently compared to an ordinarily scheduled task/future.

    The normal way would be to use https://distributed.dask.org/en/latest/api.html#distributed.Client.submit or https://distributed.dask.org/en/latest/api.html#distributed.Client.map

    For example

    fut = client.submit(do_stuff, sem)
    assert isinstance(fut, distributed.Future)
    res = fut.result()  # This call blocks until the computation is through

    The later works for me as expected. Can you confirm this on your end?

  9. lfdversluis commented on Oct 7, 2020

    @lfdversluis
    Author

    🤔 I was using run to ensure it runs on all nodes, but I guess submit works too? The reason I used run is because it's blocking. I need to ensure all workers have the dataset on local storage before I start the computation (otherwise the data might not be there). This shouldn't also impact the usage of the semaphore, being out-of-band or not (right?).

    I will try to use the submit function with .result() or wait(fut)

  10. fjetter commented on Oct 7, 2020

    @fjetter
    Member

    Well, if you call submit it will be scheduled as one task and will only be executed on one worker (unless you force it to be distributed among all workers).

    I believe when using Client.run it is always an asynchronous context, whether you want it to be or not since it is running in the primary server thread (ordinary tasks/futures are computed in a different thread/threadpool). Therefore, if you want to go for the Client.run you probably need to use the async def syntax, otherwise the semaphore won't work. I'll think about whether or not we can check this inthe semaphore to at least raise an appropriate exception

    Side note: For your purpose you might want to consider https://distributed.dask.org/en/latest/api.html#distributed.Client.register_worker_callbacks which ensures that this function is called every time a new worker starts. Alternatively, you can implement a WorkerPlugin with a similar purpose. Haven't tested the semaphore with this approach, though. You might run into the same issue.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions