UltraDict

repository·main·Indexed 43 days ago

https://github.com/ronny-rentner/ultradict

A high-performance, synchronized Python dictionary that uses shared memory and a streaming update mechanism to share data between independent processes without a central manager process.

Tokens
2.7K
Snippets
8
Records
10
Agent score
5%

What's inside UltraDict

  1. What is UltraDict and how does it work?

    main

    UltraDict is a synchronized, streaming Python dictionary that uses multiprocessing.shared_memory as a backend to synchronize data between multiple processes.

    Instead of transferring the entire dictionary, it uses a stream of updates in a shared memory buffer. This makes it efficient because only changes (deltas) are serialized and transferred.

    Key Lifecycle Behavior:

    • When the streaming buffer becomes full, UltraDict automatically performs a "full dump" to a new shared memory space.
    • The streaming buffer is then reset.
    • All connected users/processes automatically load the new full dump and continue receiving streaming updates.

    Note for Windows users: To prevent the OS from garbage collecting shared memory when no handles are active, you can use the full_dump_size parameter to create a static full dump memory that lives as long as the creator process.

  2. Optimize read performance with buffer_size and data access

    main

    Read performance in UltraDict depends on the state of the data stream:

    1. No new updates: Fastest case. Optimized to detect no changes and return data quickly.
    2. Streaming update: Fast; performance depends on the size of the changed data, not the total dictionary size.
    3. Full dump load: Can be slow if the total data size is large, as it requires full unserialization.

    Optimization Strategies

    • Balance buffer_size: If the streaming buffer fills up, a full dump is triggered. Choose a buffer_size that avoids frequent, expensive full dumps while still capturing updates efficiently.
    • Direct Access: For maximum performance, access the underlying data attribute directly. Warning: This bypasses real-time updates.
    • Write Patterns: If you frequently update the same key, it may be more efficient to rely on full dumps rather than processing a high-volume stream of small changes.
    # Maximum performance (bypasses real-time updates)
    raw_data = ultra.data
  3. Manage shared memory lifecycle with auto_unlink

    main

    Since UltraDict uses shared memory buffers in RAM, they may persist even after a process ends. To ensure automatic cleanup, use the auto_unlink parameter.

    • Convention: Set auto_unlink=True for exactly one of the processes using the UltraDict.
    • Default Behavior: The first process that creates an UltraDict will have auto_unlink=True by default unless you explicitly set it to False.
    • Cleanup: When the process with auto_unlink=True terminates, it will attempt to unlink (free) all shared memory buffers.
    • Recursive Mode: If using recurse=True, UltraDict uses an internal registry to track nested instances, allowing child buffers to be correctly cleaned up when the main program ends.
    # The first process gets auto_unlink=True by default
    ultra = UltraDict({'key': 'value'}, auto_unlink=True)
    
    # Explicitly disable auto-cleanup in the creator process if you want buffers to outlive it
    ultra = UltraDict({'key': 'value'}, auto_unlink=False)
  4. Access high-performance local cache via .data

    main

    While UltraDict provides synchronized access, reading directly from the shared memory stream involves overhead. If you need maximum read performance and can tolerate not having real-time updates, you can access the underlying local cache directly via the .data attribute. This provides performance nearly identical to a standard Python dictionary.

    # Standard synchronized access (slower, real-time)
    value = ultra[1]
    
    # Direct cache access (fast, not real-time)
    value = ultra.data[1]
  5. Create and connect to an UltraDict

    main

    To use UltraDict across different processes, you first create an instance in one process and then connect to it in others using its unique name attribute.

    1. Create the dictionary

    Initialize UltraDict with your data. The instance will have a .name attribute representing the shared memory identifier.

    2. Connect from another process

    In a different process, initialize UltraDict by passing the name obtained from the first process.

    # Process 1: Creation
    from UltraDict import UltraDict
    ultra = UltraDict({ 1:1 }, some_key='some_value')
    shared_name = ultra.name
    
    # Process 2: Connection
    from UltraDict import UltraDict
    other = UltraDict(name=shared_name)
    print(other)  # {1: 1, 'some_key': 'some_value'}
    
    # Updates are synchronized
    other[2] = 2
    print(ultra[2])  # 2
  6. Manually clean up corrupted shared memory

    main

    If a program crashes, shared memory buffers in /dev/shm (on Linux/Unix) might become corrupted. You can clean them up manually in code using UltraDict.unlink_by_name.

    # Unlink both shared memory buffers possibly used by UltraDict
    name = 'my-dict-name'
    UltraDict.unlink_by_name(name, ignore_errors=True)
    UltraDict.unlink_by_name(f'{name}_memory', ignore_errors=True)
  7. Enable nested dictionary support with recurse=True

    main

    By default, UltraDict handles flat dictionaries. If you need to perform transparent updates on nested dictionaries across processes, set recurse=True. When a nested dictionary is added, it is automatically wrapped in an UltraDict instance.

    from UltraDict import UltraDict
    
    # Enable recursion
    ultra = UltraDict(recurse=True)
    ultra['nested'] = { 'counter': 0 }
    
    # In another process connected via name:
    # other['nested']['counter'] += 1
    from UltraDict import UltraDict
    
    ultra = UltraDict(recurse=True)
    ultra['nested'] = { 'counter': 0 }
    
    # The nested object is also an UltraDict
    type(ultra['nested'])  # <class 'UltraDict.UltraDict'>
  8. Use locking to manage concurrent access

    main

    Every UltraDict instance has a lock attribute. You can use it as a context manager or via explicit method calls.

    Lock Types

    • multiprocessing.RLock (Default): Fastest method. Works when child processes are created via forking (default on Linux).
    • UltraDict.SharedLock: Required on Windows (where spawning is used instead of forking). This requires the atomics package to be installed. Set shared_lock=True during initialization.

    Usage Patterns

    # Standard usage
    with ultra.lock:
        ultra['counter'] += 1
    
    # Busy wait (fastest, but uses ~99% CPU)
    with ultra.lock(sleep_time=0):
        ultra['counter'] += 1
    
    # With timeout
    try:
        with ultra.lock(timeout=1.5):
            ultra['counter'] += 1
    except UltraDict.Exceptions.CannotAcquireLockTimeout:
        print('Stale lock?')
    
    # With timeout and stealing
    with ultra.lock(timeout=1.5, steal_after_timeout=True):
        ultra['counter'] += 1

    Explicit Acquisition

    try:
        result = ultra.lock.acquire(block=False)
        ultra.lock.release()
    except UltraDict.Exceptions.CannotAcquireLock as e:
        print(f'Process with PID {e.blocking_pid} is holding the lock')
    ultra = UltraDict(shared_lock=True)
    
    with ultra.lock:
        ultra['counter'] += 1
  9. Use advanced UltraDict methods

    main

    Beyond standard dictionary access, UltraDict provides several methods for managing state and serialization:

    MethodDescription
    dump()Creates a full dump of the current state.
    load(force=False)Loads the latest full dump. Use force=True to reload even if already processed.
    apply_update()Applies the full dump and streaming updates to the underlying local dict.
    print_status()Displays internal status, including buffer info and remote control keys.
    unlink()Unlinks all shared memory; it will no longer be visible to new processes.
    close()Closes the connection to shared memory and returns the data as a standard dict.
    dataAccesses the underlying local dictionary directly for maximum performance.
    serializerAllows using custom serializers (e.g., jsons) if they support .loads() and .dumps() methods.
    import jsons
    ultra = UltraDict({'init': 'data'}, serializer=jsons)
    
    ultra.dump()           # Create full dump
    ultra.load(force=True) # Force load latest dump
    ultra.apply_update()   # Sync local dict with updates
    ultra.print_status()   # Show stats
    ultra.close()          # Close and return dict
  10. Configure UltraDict parameters

    main

    The UltraDict constructor accepts several parameters to control memory management, synchronization, and serialization:

    ParameterDescription
    nameName of the shared memory. A random name is chosen if not set. If a name is provided and exists, it attaches to it; otherwise, it creates it.
    createTrue: Create new (raises error if name exists). False: Attach to existing. None (default): Create if name is free, else attach.
    buffer_sizeSize of the shared memory buffer for streaming updates. Larger values help with large/deeply nested dicts to avoid frequent full dumps.
    serializerThe object/module used for serialization (default: pickle). Must support .loads() and .dumps().
    shared_lockIf True, uses an atomic lock for synchronization between independent processes. If False (default), uses multiprocessing.RLock(), which is faster but optimized for fork contexts.
    full_dump_sizeSets a static full dump memory size. Recommended for Windows to prevent OS garbage collection of shared memory.
    auto_unlinkIf True, the creator automatically unlinks the handle at exit. Existing connected processes can still use the dict.
    recurseIf True, nested dicts are automatically wrapped in UltraDict.
    recurse_registerInternal use: tracks dynamically created recursive UltraDicts for cleanup.
    **kwargsInitial dictionary data.