Skip to content

[FEAT] add HDFS support for DynamicEmb checkpoint dump and load#423

Open
PWZER wants to merge 1 commit into
NVIDIA:mainfrom
PWZER:main
Open

[FEAT] add HDFS support for DynamicEmb checkpoint dump and load#423
PWZER wants to merge 1 commit into
NVIDIA:mainfrom
PWZER:main

Conversation

@PWZER

@PWZER PWZER commented Jun 16, 2026

Copy link
Copy Markdown

Add support for reading/writing DynamicEmb checkpoints via HDFS using
fsspec[arrow], enabling distributed training jobs to store checkpoints
on Hadoop clusters without any API changes.

What changed

New: filesystem.py — lightweight filesystem abstraction

Two backends, selected automatically by path prefix:

Backend Path prefix Dependency
LocalFileSystem (default, any local path) None
HdfsFileSystem hdfs://host:port/... fsspec[arrow]>=2023.0.0 (optional)
  • HdfsFileSystem lazily initialises pyarrow.HadoopFileSystem on first
    I/O, passing host/port explicitly so core-site.xml does not need
    to set fs.defaultFS.

Refactor: route all checkpoint I/O through get_filesystem(path)

Replaced direct os.path.exists, os.makedirs, os.listdir,
os.path.getsize, open, and glob.glob calls in:

  • batched_dynamicemb_tables.pyfind_files, get_loading_files
  • dump_load.pyDynamicEmbDump, DynamicEmbLoad
  • exportable_tables.pyInferenceEmbeddingCollection.save
  • key_value_table.py_dump_table, _iter_batches_from_files,
    _validate_load_meta, save_to_json, load_from_json

Path handling for HDFS glob results

find_files now handles the hdfs:// scheme prefix: stripped for glob
pattern matching, then re-attached to returned paths so downstream
consumers can use them directly, regardless of backend behaviour.

Extras dependency in setup.py

extras_require={"hdfs": ["fsspec[arrow]>=2023.0.0"]}

Usage (no API change)

  • Local (unchanged)
DynamicEmbDump(model, "/path/to/checkpoints/model_v1")
  • HDFS — works with any hdfs:// URI
 DynamicEmbDump(model, "hdfs://namenode:8020/path/to/checkpoints/model_v1")

Checklist

  • I am familiar with the Contributing Guidelines.
  • New or existing tests cover these changes.
  • The documentation is up to date with these changes.

@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR introduces a lightweight filesystem.py abstraction layer with LocalFileSystem and HdfsFileSystem backends, then routes all checkpoint I/O in dump_load.py, key_value_table.py, batched_dynamicemb_tables.py, and exportable_tables.py through a get_filesystem(path) factory that selects the backend by path prefix — enabling HDFS checkpoint storage with no API changes.

  • filesystem.py: New module wrapping os/glob for local paths and pyarrow.HadoopFileSystem via fsspec.ArrowFSWrapper for hdfs:// URIs; the HDFS client is lazily initialized from the first URI seen, but the resulting singleton is locked to that NameNode for the process lifetime.
  • key_value_table.py_dump_table: Three of four output file handles are correctly moved into a with statement; the optimizer-states handle (fopt_states) is opened inside the block but excluded from the context-manager chain, leaving it unclosed if an exception is raised mid-write.
  • dump_load.py: os.path.join is used to append sub-collection paths to a potential hdfs:// base path; this works on Linux today but is fragile if any component path begins with /.

Confidence Score: 3/5

The core local-path checkpoint path is unchanged and safe; the HDFS path has a file-handle leak in the optimizer checkpoint write that can leave partial files unclosed after a mid-write failure, and a design constraint that silently restricts the process to a single HDFS NameNode.

The fopt_states handle in _dump_table is opened outside the with context that manages the three sibling files; any exception during the write loop exits the block without closing or flushing the optimizer file, potentially corrupting it. The global _HDFS singleton additionally locks the process to the first NameNode URI it encounters, making a second HDFS cluster silently unreachable.

key_value_table.py (_dump_table, the fopt_states open) and filesystem.py (the get_filesystem factory and _HDFS singleton) deserve the closest look before merge.

Important Files Changed

Filename Overview
corelib/dynamicemb/dynamicemb/filesystem.py New filesystem abstraction module with LocalFileSystem and HdfsFileSystem backends; the global singleton design locks the process to a single HDFS NameNode for its lifetime.
corelib/dynamicemb/dynamicemb/key_value_table.py All file I/O routed through the new filesystem abstraction; fopt_states is opened inside the with block but excluded from the context-manager chain, leaking the handle on any exception during the write loop.
corelib/dynamicemb/dynamicemb/batched_dynamicemb_tables.py glob and path-existence checks migrated to filesystem abstraction; scheme-prefix reattachment and dual-check validation logic for HDFS glob results looks correct.
corelib/dynamicemb/dynamicemb/dump_load.py DynamicEmbDump/Load wired to filesystem abstraction; os.path.join used to build HDFS sub-paths which is fragile if a collection_path starts with /.
corelib/dynamicemb/dynamicemb/exportable_tables.py Two os.path.exists calls replaced with get_filesystem().exists(); straightforward, correct substitution.
corelib/dynamicemb/setup.py Adds fsspec[arrow]>=2023.0.0 as an optional hdfs extra; no issues.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant get_filesystem
    participant LocalFileSystem
    participant HdfsFileSystem
    participant ArrowFSWrapper
    participant HadoopFS as pyarrow.HadoopFileSystem

    Caller->>get_filesystem: get_filesystem("hdfs://host:8020/ckpt")
    get_filesystem-->>HdfsFileSystem: return singleton _HDFS

    Caller->>HdfsFileSystem: exists(path)
    HdfsFileSystem->>HdfsFileSystem: _parse_uri(path) — set host/port once
    HdfsFileSystem->>HdfsFileSystem: _ensure_fs() — lazy init
    HdfsFileSystem->>HadoopFS: HadoopFileSystem(host, port)
    HadoopFS-->>ArrowFSWrapper: wrapped
    HdfsFileSystem->>ArrowFSWrapper: exists(path)
    ArrowFSWrapper-->>Caller: bool

    Caller->>HdfsFileSystem: glob(pattern)
    HdfsFileSystem->>ArrowFSWrapper: glob(pattern)
    ArrowFSWrapper-->>Caller: paths (scheme may be stripped)
    Note over Caller: batched_dynamicemb_tables.py re-attaches hdfs://host:port prefix

    Caller->>HdfsFileSystem: open(path, mode)
    HdfsFileSystem->>ArrowFSWrapper: open(path, mode)
    ArrowFSWrapper-->>Caller: file handle

    Caller->>get_filesystem: get_filesystem("/local/path")
    get_filesystem-->>LocalFileSystem: return singleton _LOCAL
    Caller->>LocalFileSystem: open/exists/glob/…
    LocalFileSystem-->>Caller: stdlib result
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant get_filesystem
    participant LocalFileSystem
    participant HdfsFileSystem
    participant ArrowFSWrapper
    participant HadoopFS as pyarrow.HadoopFileSystem

    Caller->>get_filesystem: get_filesystem("hdfs://host:8020/ckpt")
    get_filesystem-->>HdfsFileSystem: return singleton _HDFS

    Caller->>HdfsFileSystem: exists(path)
    HdfsFileSystem->>HdfsFileSystem: _parse_uri(path) — set host/port once
    HdfsFileSystem->>HdfsFileSystem: _ensure_fs() — lazy init
    HdfsFileSystem->>HadoopFS: HadoopFileSystem(host, port)
    HadoopFS-->>ArrowFSWrapper: wrapped
    HdfsFileSystem->>ArrowFSWrapper: exists(path)
    ArrowFSWrapper-->>Caller: bool

    Caller->>HdfsFileSystem: glob(pattern)
    HdfsFileSystem->>ArrowFSWrapper: glob(pattern)
    ArrowFSWrapper-->>Caller: paths (scheme may be stripped)
    Note over Caller: batched_dynamicemb_tables.py re-attaches hdfs://host:port prefix

    Caller->>HdfsFileSystem: open(path, mode)
    HdfsFileSystem->>ArrowFSWrapper: open(path, mode)
    ArrowFSWrapper-->>Caller: file handle

    Caller->>get_filesystem: get_filesystem("/local/path")
    get_filesystem-->>LocalFileSystem: return singleton _LOCAL
    Caller->>LocalFileSystem: open/exists/glob/…
    LocalFileSystem-->>Caller: stdlib result
Loading

Reviews (1): Last reviewed commit: "feat: add hdfs support for dump and load" | Re-trigger Greptile

Comment on lines +1097 to +1118
fopt_states = None
if include_optim:
fopt_states = fs.open(opt_file_path, mode)

for keys, embeddings, opt_states_batch, scores in export_keys_values_iter(
state, device=device, table_id=table_id
):
fkey.write(keys.cpu().numpy().tobytes())
fembedding.write(embeddings.cpu().numpy().tobytes())
if state.evict_strategy == EvictStrategy.KLru:
scores = timestamp - scores
fscore.write(scores.cpu().numpy().tobytes())
if fopt_states and opt_states_batch is not None:
to_write = truncate_optimizer_states_for_checkpoint(
state.optimizer,
state.table_emb_dims_cpu[table_id],
opt_states_batch,
)
fopt_states.write(to_write.cpu().numpy().tobytes())

fkey.close()
fembedding.close()
if fscore:
fscore.close()
if fopt_states:
fopt_states.close()
if fopt_states:
fopt_states.close()

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.

P1 fopt_states file handle not closed on exception

fopt_states is opened with fs.open(opt_file_path, mode) inside the with block but is not part of the context-manager chain. If export_keys_values_iter raises (e.g., CUDA OOM or a network error writing to HDFS), execution exits the with block without hitting if fopt_states: fopt_states.close(), leaving the file handle open and the partially-written optimizer file unflushed/untruncated. The three sibling files (fkey, fembedding, fscore) are correctly protected by the with statement; fopt_states should be treated consistently — either added to the with chain or wrapped in its own with block or try/finally.

Comment on lines +202 to +228
_HDFS: HdfsFileSystem | None = None
_LOCAL: LocalFileSystem | None = None


def get_filesystem(path: str) -> AbstractFileSystem:
"""Return the appropriate filesystem for *path*.

Parameters
----------
path : str
A filesystem path. If it starts with ``"hdfs://"`` an
:class:`HdfsFileSystem` is returned; otherwise the default
:class:`LocalFileSystem` is returned.

Returns
-------
AbstractFileSystem
"""
if path.startswith("hdfs://"):
global _HDFS
if _HDFS is None:
_HDFS = HdfsFileSystem()
return _HDFS
global _LOCAL
if _LOCAL is None:
_LOCAL = LocalFileSystem()
return _LOCAL

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.

P2 Single _HDFS singleton silently ignores a second HDFS host

get_filesystem() returns the same HdfsFileSystem instance for every hdfs:// URI. _parse_uri guards with if self._host is not None: return, so the host/port captured from the very first URI is permanent. If a caller later uses a path on a different NameNode (e.g., hdfs://standby:8020/...), the singleton silently continues to talk to the original host. A production job that mixes two HDFS clusters — or that switches between a primary and DR NameNode — would silently read/write to the wrong cluster with no error.

Comment on lines 177 to +181
for collection_path, _, _ in collections_list:
full_collection_path = os.path.join(path, collection_path)
if not os.path.exists(full_collection_path):
os.makedirs(full_collection_path, exist_ok=True)
col_fs = get_filesystem(full_collection_path)
if not col_fs.exists(full_collection_path):
col_fs.makedirs(full_collection_path, exist_ok=True)

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.

P2 os.path.join used to build HDFS sub-paths

os.path.join(path, collection_path) is called twice (lines 178 and 185) after the HDFS path refactor. On Linux this happens to produce the correct hdfs://host:port/parent/child string, but os.path.join is OS-aware path manipulation, not URI manipulation. If collection_path ever begins with /, os.path.join silently discards the base, producing a broken path like /collection instead of hdfs://host:port/checkpoints/model_v1/collection. Using f"{path.rstrip('/')}/{collection_path}" or a filesystem-aware join would be unambiguous.

@shijieliu

Copy link
Copy Markdown
Collaborator

thanks for your contribution! @PWZER could you help resolve the ai comments?

And @jiashuy could you help review this PR?

@jiashuy

jiashuy commented Jun 24, 2026

Copy link
Copy Markdown
Collaborator

Hi @PWZER , can you also update it to the documents, for example: using pip install dynamicemb[hdfs] to support HDFS? thanks

@jiashuy jiashuy left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

LGTM, I will explore how to test HDFS in a single node with multi GPUs and then add relevant tests to the current CI tests

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