DiskSparseAdam (DSA) is a drop-in Out-of-Core Sparse Adam optimizer for PyTorch. It reduces optimizer memory footprint from
-
Near-Zero VRAM Overhead: Optimizer momentum (
$m$ ), variance ($v$ ), weights ($w$ ) and per-row step counters ($t$ ) reside on NVMe/SSD storage vianp.memmap. Only active batch rows are loaded into RAM/VRAM. By default the optimizer does not retain the source tensors after initialization (retain_params=False): they are replaced by zero-length CPU placeholders, so a full embedding table cannot stay alive on the GPU throughparam_groups. -
True Riemannian Adam (
$k > 0$ ): The Euclidean gradient is converted to a Riemannian one, Adam's first moment lives in the tangent space and is parallel-transported after every step, the step is an exponential map (not a Euclidean subtraction followed by a clip), and the second moment is carried as a non-negative diagonal scale. The collection of$O(B)$ rows is computed infloat64, which keeps the Möbius/gyration formulas stable near the boundary; storage remainsfloat32.k = 0is standard Euclidean Adam. -
Lossless Async I/O: A background worker thread with double-buffering (
AsyncDiskWriter) flushes state updates to disk asynchronously without stalling GPU computations. A full write queue never drops batches:put_updatewaits up toput_timeoutseconds, then writes the batch synchronously. -
Read Consistency: The writer tracks in-flight rows;
step()drains the queue before reading rows the writer has not finished writing, so no step ever computes from stale state. -
Per-Row Adam Bias Correction: Each row carries its own step counter (
<name>_t.dat), so the first update of a freshly touched row is a correct Adam step regardless of the global step count. -
Explicit State Lifecycle: State directories are guarded by
<name>_meta.json(shape, dtype, format version, curvature, algorithm) and a globaldsa_global_meta.json(global step, curvature).resume=None(default) refuses to guess when a directory already exists: passresume=Trueto continue orresume=Falseto reinitialize. Shape/dtype mismatches, partial or truncated state files raise explicit errors. 0.1.0/0.2.0 directories are migrated onresume=True(weights preserved; incompatible moments and counters reset). -
Ball Invariants: Fresh hyperbolic weights are globally scaled to at most
init_max_norm_fraction(default 0.5) of the ball radius, sotorch.randninitialization does not start on the boundary. Rows read from disk are projected, resumed tables are revalidated, andset_curvature()reprojects the whole table when the ball changes. - Safe Duplicate Aggregation & Sorted I/O: Duplicate node/entity indices are summed; batch indices are sorted after deduplication so reads and writes hit the memmaps sequentially.
-
Explicit Weight Decay Semantics:
weight_decay_mode="l2"(default) addswd * wto the Euclidean gradient and is therefore the Riemannian gradient of$f + \tfrac{wd}{2}|w|^2$ ; in the Euclidean case it is classic Adam L2."decoupled"addswd * wto the tangent-space descent direction and reproduces AdamW exactly whenk = 0. -
Portable Checkpoints:
export_checkpoint(dir)writes a self-contained directory (checkpoint.json+ relative data files + SHA-256 checksums);import_checkpoint(dir)verifies and restores it, including the global step and curvature. Checkpoints can be moved between machines.save_checkpoint/load_checkpointremain as directory-only aliases. -
Honest Shutdown:
shutdown()is a retryable state machine (RUNNING → DRAINING → STOPPED, orFAILED); it never reports success while the worker is alive or the queue is unfinished, and a failed write is re-raised instead of being hidden. -
Validated Inputs: Negative/out-of-range indices, wrong gradient shapes, non-finite gradients, NaN/negative moment rows,
k = NaN/inf/negative, and unknown update keys all have explicit, test-covered behavior. - Cross-Platform: Runs on local Linux/Windows GPU machines, workstations, and cloud notebooks.
There is no fixed throughput number: DSA trades compute for memory, and its speed is bounded by your disk — expect optimizer-state I/O to be the bottleneck on large tables. The 0.3.0 correctness work (blocking writes, in-flight row tracking, a fourth per-row counter file, sorted indices, float64 batch math) adds step-time overhead compared to 0.1.0; the float64 part only touches the active batch, so on large tables it is usually small relative to disk I/O. Measure on your own hardware before drawing conclusions.
What stays true at any scale:
| Property | Since |
|---|---|
| Optimizer states reside on disk; VRAM footprint scales with batch size |
0.1.0 |
| Source tensors are not retained by the optimizer | 0.3.0 |
| Duplicate node aggregation within a batch | 0.1.0 |
| No silently dropped update batches | 0.2.0 |
| Step reads never race the background writer | 0.2.0 |
| Correct bias correction for sparsely updated rows | 0.2.0 |
| Full Riemannian Adam (exp map + parallel transport) | 0.3.0 |
| Portable checkpoints with checksums | 0.3.0 |
| Retryable, honest shutdown | 0.3.0 |
pip install git+https://github.com/Assistentus/DSA.gitgit clone https://github.com/Assistentus/DSA.git
cd DSA
pip install -e .This example demonstrates the exact PyTorch Autograd workflow for local GPU/CPU execution:
import os
import shutil
import torch
from dsa.optimizer import DiskSparseRiemannianAdam
# 1. Device Setup
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
cache_dir = "./dsa_local_cache"
if os.path.exists(cache_dir):
shutil.rmtree(cache_dir)
# 2. Define Dataset & Parameters (e.g., 100,000 entities)
num_entities = 100_000
dim = 64
batch_size = 1024
# Initial weights in RAM
initial_weights = torch.randn(num_entities, dim) * 0.1
params = {"entity_emb": initial_weights}
# 3. Initialize Optimizer (k=0.0 for standard Euclidean Adam)
optimizer = DiskSparseRiemannianAdam(
params=params,
lr=0.01,
k=0.0,
disk_dir=cache_dir
)
# Target vectors to converge towards
target_embeddings = torch.randn(num_entities, dim) * 0.1
print("🚀 Starting local training loop...")
# 4. Training Loop
for epoch in range(1, 101):
indices = torch.randint(0, num_entities, (batch_size,))
# Public read API: waits for in-flight writes and projects into the ball.
vec = optimizer.read_rows(indices).to(device).requires_grad_(True)
# Compute Loss (MSE against target)
target_vec = target_embeddings[indices].to(device)
loss = torch.mean((vec - target_vec) ** 2)
# Compute gradients via PyTorch autograd
loss.backward()
# Pass explicit indices and gradients to DSA
optimizer.step(updates={"entity_emb": (indices, vec.grad.cpu())})
# 5. Flush background writer thread to disk
optimizer.shutdown()
print(f"✅ Training complete. Final Loss: {loss.item():.4f}")
# Clean up local cache
shutil.rmtree(cache_dir)In a Kaggle Notebook, VRAM and RAM are strictly capped (~16 GB VRAM, ~30 GB RAM). DiskSparseAdam offloads optimizer states onto Kaggle's fast /kaggle/working NVMe disk, completely bypassing VRAM limitations.
# Cell 1: Install directly from GitHub
!pip install -q git+https://github.com/Assistentus/DSA.git👉 Interactive Kaggle Demo: DiskSparseAdam Kaggle Notebook
import os
import shutil
import torch
from dsa.optimizer import DiskSparseRiemannianAdam
# 1. Device Setup
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# 2. Configure Kaggle Working Disk Directory
KAGGLE_CACHE_DIR = "/kaggle/working/dsa_optimizer_cache"
os.makedirs(KAGGLE_CACHE_DIR, exist_ok=True)
num_entities = 1_000_000
dim = 128
batch_size = 2048
# Initialize embeddings
embeddings = torch.randn(num_entities, dim) * 0.01
# 3. Initialize DiskSparseRiemannianAdam
optimizer = DiskSparseRiemannianAdam(
params={"entity_emb": embeddings},
lr=0.001,
k=0.0,
disk_dir=KAGGLE_CACHE_DIR,
max_queue_size=300
)
print("🚀 Starting training on Kaggle...")
# 4. Training Loop
for epoch in range(1, 11):
indices = torch.randint(0, num_entities, (batch_size,))
# Public read API (in-flight writes are drained, rows are projected).
vec = optimizer.read_rows(indices).to(device).requires_grad_(True)
# Compute Loss
loss = (vec ** 2).sum()
loss.backward()
# Apply step on disk
optimizer.step(updates={"entity_emb": (indices, vec.grad.cpu())})
# 5. Shutdown & Clean disk
optimizer.shutdown()
if os.path.exists(KAGGLE_CACHE_DIR):
shutil.rmtree(KAGGLE_CACHE_DIR)
print("🧹 Disk cache cleaned.")For hyperbolic knowledge-graph embeddings, set k > 0 (the ball has curvature
from dsa.optimizer import DiskSparseRiemannianAdam
optimizer = DiskSparseRiemannianAdam(
params={"hyperbolic_nodes": initial_weights},
lr=1e-3,
k=1.0, # hyperbolic mode
disk_dir="./hyperbolic_cache",
resume=False, # explicit: new run, reinitialize this directory
weight_decay=0.0,
weight_decay_mode="l2", # classic Adam L2 in the Riemannian gradient
)
for indices, gradients in batches:
optimizer.step(updates={"hyperbolic_nodes": (indices, gradients)})
# Read embeddings for inference: projected into the ball, safe public API.
emb = optimizer.read_rows(indices, name="hyperbolic_nodes")
# Curvature annealing: the ball changes, so every row is reprojected.
optimizer.set_curvature(0.5)
# Portable, checksummed checkpoint (a directory, not a JSON file).
optimizer.export_checkpoint("./checkpoints/run-001")
optimizer.shutdown()What step() does when k > 0:
- Reads only the batch rows
(w, m, v, t)from the memmaps. - Converts the Euclidean gradient to the Riemannian one,
$\operatorname{grad}_R f = \lambda(w)^{-2} g_E$ , with$\lambda(w) = 2 / (1 - k|w|^2)$ . - Updates Adam's moments:
$m$ is a tangent vector,$v$ is a non-negative diagonal scale. The active rows are computed infloat64for numerical stability near the boundary. - Takes a geodesic step with the exponential map
$w \leftarrow \exp_w(-\eta, \hat m / (\sqrt{\hat v}+\epsilon))$ . - Transports
$m$ (and the square root of$v$ ) from$T_w$ to$T_{w_{\text{new}}}$ by parallel transport, so the moments follow the new tangent space. - Projects the result into the ball as a numerical guard and queues the consistent row update.
Notes:
init_max_norm_fraction=0.5(default) globally scales fresh hyperbolic weights so that no row starts closer than half the radius to the boundary. Passinit_max_norm_fraction=1.0if you want only strict projection.resume=Truerestores the saved curvature; a differing constructorkis ignored with a warning. Useset_curvature()to change it deliberately.current_k=...onstep()is a shortcut forset_curvature(); it reprojects the whole table because the ball itself changes.weight_decay_mode="decoupled"applies decay as a tangent-space term (AdamW semantics whenk = 0)."l2"is part of the gradient and is therefore scaled by the Poincaré metric.
from dsa.optimizer import DiskSparseRiemannianAdam
optimizer = DiskSparseRiemannianAdam(
params,
lr=1e-3,
beta1=0.9,
beta2=0.999,
eps=1e-8,
weight_decay=0.0,
weight_decay_mode="l2", # "l2" | "decoupled"
k=1.0,
disk_dir="./disk_cache",
max_queue_size=150,
resume=None, # None | True | False
put_timeout=5.0,
retain_params=False,
init_max_norm_fraction=0.5,
)-
params(dict or list):{name: tensor}or a list of 2-D tensors. Values are read once to initialize the on-disk state. -
lr(float): Learning rate (default1e-3). -
beta1,beta2(float): Adam moment coefficients, each in[0, 1)(defaults0.9,0.999). -
eps(float): Denominator floor, must be> 0(default1e-8). -
weight_decay(float): Non-negative decay coefficient (default0.0). -
weight_decay_mode(str):"l2"(default) putswd * winto the Euclidean gradient before the Riemannian conversion;"decoupled"addswd * wto the tangent descent direction (AdamW semantics whenk = 0). -
k(float): Curvature magnitude.k > 0is the Poincaré ball of curvature-k;k = 0is Euclidean Adam. Must be finite and non-negative; NaN/inf/negative raise immediately. -
disk_dir(str): Directory for<name>_w/_m/_v/_t.dat,<name>_meta.json, anddsa_global_meta.json. -
max_queue_size(int): Background write-queue capacity (default150). -
resume(None | bool):None(default) raises ifdisk_diralready contains state, forcing an explicit choice;Truecontinues it;Falsereinitializes fromparamswith a warning. Legacy 0.1.0/0.2.0 state is migrated onresume=True. -
put_timeout(float): Seconds to wait for queue space before a synchronous write (default5.0; writes are never dropped). -
retain_params(bool): IfFalse(default), the source tensors are replaced by zero-length CPU placeholders after initialization, so the optimizer cannot retain an$O(N)$ table in VRAM. SetTrueonly if other code inspectsparam_groups[...]["params"]. -
init_max_norm_fraction(float,(0, 1]): Fork > 0, fresh weights whose largest row norm exceeds this fraction of the ball radius are globally scaled down (default0.5).1.0means strict projection only.
step(updates, current_k=None): One optimizer step.updates(dict):{param_name: (indices, grads)}. Indices must be 1-D integer tensors/arrays in[0, N); gradients must have shape(len(indices), dim)and be finite. Duplicate indices are summed.current_k(float or Tensor, optional): Change curvature for this and subsequent steps; the whole table is reprojected.
read_rows(indices, name=None): Public read API for inference; waits for in-flight writes, validates indices, rejects corrupted rows, and projects into the current ball.set_curvature(k, reset_moments=False): Change curvature and reproject every row. Moments are kept by default (documented annealing approximation);reset_moments=Truegives a mathematically clean restart of the adaptive state.reproject_all(name=None): Scan the whole table, project finite rows into the current ball, and fully reset non-finite/negative-variance rows. Returns the number of projected rows.export_checkpoint(directory): Write a portable checkpoint directory (checkpoint.json, relative data files, SHA-256 checksums) and return the manifest path.import_checkpoint(directory, verify_hashes=True): Verify and restore a portable checkpoint, including global step and curvature.save_checkpoint(directory)/load_checkpoint(directory): Directory-only aliases for the two methods above. Passing a.jsonpath raises a clear migration error.shutdown(): Drain the queue, flush state, and persist the global step. Retryable after a transient failure; raises while data is unfinished or a write failed. Call it at the end of training.state_dict()/load_state_dict(): RaiseNotImplementedError— use the checkpoint API or reopen the directory withresume=True.
State files are an implementation detail; use read_rows() and checkpoints instead of reading state_files directly.
0.3.0 is a correctness release with several intentional breaking changes:
resumeis nowNoneby default. An existing state directory is never reused or silently reinitialized: passresume=Trueto continue orresume=Falseto start over.- Checkpoints are directories.
save_checkpoint/load_checkpointaccept a directory;export_checkpoint/import_checkpointare the primary names. 0.2.0 manifests with absolute paths are rejected — create a new checkpoint. - State format v2. 0.2.0 (
format_version=1) and 0.1.0 (no meta, not) directories are migrated onresume=True: weights are preserved, but moments and per-row counters are reset because 0.3.0 stores tangent-space moments and transports them. Re-running withresume=Falsereinitializes fromparams(this also repairs a partial 0.1.0 directory). - The Riemannian step changed. The old code was Adam in ambient coordinates with gradient scaling and a clip. 0.3.0 uses a Riemannian gradient, tangent-space moments, an exponential map and parallel transport. Hyperbolic training dynamics will differ from 0.2.0 even with the same hyperparameters.
weight_decaysemantics changed fork > 0. The default"l2"is now part of the Riemannian gradient instead of a coordinate shrink; use"decoupled"if you need AdamW-style behavior.- Source tensors are released.
param_groups[...]["params"]contains zero-length placeholders by default. Useread_rows()for weights. Passretain_params=Truefor the old behavior. - Global step is persisted.
shutdown()and checkpoints store it; reopening restores_global_step(bias correction remains per-row). - Validation is strict. Negative/out-of-range indices, wrong gradient shapes, non-finite gradients, invalid curvature, and unknown update keys now fail or warn explicitly instead of being ignored.
-
Always call
optimizer.shutdown(): it drains the write queue, flushes all memmaps, persists the global step and stops the worker. It raisesRuntimeErrorinstead of pretending that unfinished data was saved; a second call retries after a transient failure. -
Disk space. Four arrays per parameter (
_w/_m/_v.datfloat32,_t.datint32) plus two small JSON descriptors. For 1M entities with 128 dimensions this is ~1.5 GB + 4 MB. -
Hyperbolic initialization. Keep initial norms small. The default
init_max_norm_fraction=0.5rescales a too-large table globally; a warning tells you when it happens. Starting at the boundary makes$\lambda^2$ huge and learning extremely slow. -
Queue overflow. A full queue waits
put_timeoutseconds and then writes synchronously (warning +writer._sync_fallbacks). If this happens regularly, your disk is the bottleneck: increasemax_queue_sizeor reduce the step rate. -
Reopening a state directory. Use
resume=Trueto continue, orresume=Falseto reinitialize. Shape mismatch, partial/truncated files, a missing meta file, a leftover reinitialization marker, or a curvature mismatch produce explicit errors. - Checkpoints. Use a dedicated directory per checkpoint. Import verifies sizes and SHA-256 checksums; corrupted or foreign checkpoints are rejected before the live state is touched.
-
Boundary behavior. Points are kept strictly inside the ball (
BALL_EPS = 1e-5). Very long training with a largelrcan push embeddings to that margin; the optimizer stays finite and projects, but the chart is ill-conditioned there. Prefer a smallerlrand early stopping rather than training on the boundary.
This project is licensed under the MIT License.
MIT License
Copyright (c) 2024 Maksim Khotinsky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
If you use this software in your research, please cite it using the following DOI:
APA:
Khotinsky, M. (2026). DiskSparseAdam (DSA): Out-of-Core Sparse Adam Optimizer. Zenodo. https://doi.org/10.5281/zenodo.21296366
BibTeX:
@software{khotinsky_dsa,
author = {Maksim Khotinsky},
title = {DiskSparseAdam (DSA): Out-of-Core Sparse Adam Optimizer},
publisher = {Zenodo},
doi = {10.5281/zenodo.21296366},
url = {https://doi.org/10.5281/zenodo.21296366}
}