From 1bf5e3c45e7f02e26fbd0d14dc7d4fb28e5d3322 Mon Sep 17 00:00:00 2001 From: Takeshi Yoshimura Date: Tue, 7 Jul 2026 14:56:02 +0900 Subject: [PATCH 1/5] Load D3D12/DXGI dynamically on Windows instead of linking Importing the cpp extension previously required d3d12.dll and dxgi.dll to be resolvable at Windows loader time because the .pyd linked them statically, even though they are only needed once DirectStorage is initialized. Drop the d3d12/dxgi/dxguid/uuid/ole32 link libraries, load d3d12.dll and dxgi.dll with LoadLibraryExW(LOAD_LIBRARY_SEARCH_SYSTEM32) inside init_dstorage(), resolve D3D12CreateDevice/CreateDXGIFactory1 via GetProcAddress, and define the required COM IIDs locally so dxguid/uuid are not needed. ole32 was never referenced. Plain import of the extension no longer depends on DirectX runtime availability, matching how dstorage.dll was already handled. Co-Authored-By: Claude Fable 5 Signed-off-by: Takeshi Yoshimura --- fastsafetensors/cpp/dstorage_reader.cpp | 76 ++++++++++++++++++++++--- setup.py | 4 +- 2 files changed, 70 insertions(+), 10 deletions(-) diff --git a/fastsafetensors/cpp/dstorage_reader.cpp b/fastsafetensors/cpp/dstorage_reader.cpp index cc70979..80b3465 100644 --- a/fastsafetensors/cpp/dstorage_reader.cpp +++ b/fastsafetensors/cpp/dstorage_reader.cpp @@ -13,12 +13,24 @@ namespace py = pybind11; -#pragma comment(lib, "d3d12.lib") -#pragma comment(lib, "dxgi.lib") -#pragma comment(lib, "dxguid.lib") - static constexpr UINT32 DS_STAGING_BUFFER_BYTES = 256u * 1024u * 1024u; +static const GUID FST_IID_IDXGIFactory1 = { + 0x770aae78, 0xf26f, 0x4dba, + {0xa8, 0x29, 0x25, 0x3c, 0x83, 0xd1, 0xb3, 0x87} +}; +static const GUID FST_IID_ID3D12Device = { + 0x189819f1, 0x1db6, 0x4b57, + {0xbe, 0x54, 0x18, 0x21, 0x33, 0x9b, 0x85, 0xf7} +}; +static const GUID FST_IID_ID3D12Fence = { + 0x0a753dcf, 0xc4d8, 0x4b91, + {0xad, 0xf6, 0xbe, 0x5a, 0x60, 0xd9, 0x5a, 0x76} +}; +static const GUID FST_IID_ID3D12Resource = { + 0x696442be, 0xa72e, 0x4059, + {0xbc, 0x79, 0x5b, 0x5c, 0x98, 0x04, 0x0f, 0xad} +}; static const GUID IID_IDStorageFactory = { 0x6924ea0c, 0xc3cd, 0x4826, {0xb1, 0x0a, 0xf6, 0x4f, 0x4e, 0xd9, 0x27, 0xc1} @@ -39,6 +51,16 @@ static PFN_DStorageGetFactory g_pfnGetFactory = nullptr; typedef HRESULT (__stdcall *PFN_DStorageSetConfiguration1)(DSTORAGE_CONFIGURATION1 const*); static PFN_DStorageSetConfiguration1 g_pfnSetConfig1 = nullptr; +typedef HRESULT (WINAPI *PFN_CreateDXGIFactory1)(REFIID riid, void** ppvFactory); +static PFN_CreateDXGIFactory1 g_pfnCreateDXGIFactory1 = nullptr; + +typedef HRESULT (WINAPI *PFN_D3D12CreateDevice)( + IUnknown* pAdapter, + D3D_FEATURE_LEVEL minimumFeatureLevel, + REFIID riid, + void** ppDevice); +static PFN_D3D12CreateDevice g_pfnD3D12CreateDevice = nullptr; + static std::wstring utf8_to_wstring(const std::string& input) { int wlen = MultiByteToWideChar(CP_UTF8, 0, input.c_str(), -1, nullptr, 0); if (wlen <= 0) { @@ -89,6 +111,42 @@ static bool LoadDirectStorage(const std::string& dll_dir_utf8) { return g_pfnGetFactory != nullptr; } +static bool LoadD3D12AndDXGI(std::string& error) { + static HMODULE hD3D12 = nullptr; + static HMODULE hDXGI = nullptr; + + if (!hD3D12) { + hD3D12 = LoadLibraryExW(L"d3d12.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (!hD3D12) { + error = "Failed to load d3d12.dll: " + std::to_string(GetLastError()); + return false; + } + } + if (!hDXGI) { + hDXGI = LoadLibraryExW(L"dxgi.dll", nullptr, LOAD_LIBRARY_SEARCH_SYSTEM32); + if (!hDXGI) { + error = "Failed to load dxgi.dll: " + std::to_string(GetLastError()); + return false; + } + } + + g_pfnD3D12CreateDevice = reinterpret_cast( + GetProcAddress(hD3D12, "D3D12CreateDevice")); + if (!g_pfnD3D12CreateDevice) { + error = "d3d12.dll does not export D3D12CreateDevice"; + return false; + } + + g_pfnCreateDXGIFactory1 = reinterpret_cast( + GetProcAddress(hDXGI, "CreateDXGIFactory1")); + if (!g_pfnCreateDXGIFactory1) { + error = "dxgi.dll does not export CreateDXGIFactory1"; + return false; + } + + return true; +} + // Global state, D3D12 device + DirectStorage factory class GlobalDStorageState { static inline int s_device_id = 0; @@ -121,8 +179,10 @@ class GlobalDStorageState { s_device = reinterpret_cast(provided_device); s_device->AddRef(); } else { + if (!LoadD3D12AndDXGI(last_error_)) return false; + IDXGIFactory1* factory = nullptr; - HRESULT hr = CreateDXGIFactory1(IID_IDXGIFactory1, (void**)&factory); + HRESULT hr = g_pfnCreateDXGIFactory1(FST_IID_IDXGIFactory1, (void**)&factory); if (FAILED(hr)) { last_error_ = "CreateDXGIFactory1 failed"; return false; } IDXGIAdapter1* adapter = nullptr; @@ -134,7 +194,7 @@ class GlobalDStorageState { } factory->Release(); if (!adapter) { last_error_ = "No hardware D3D12 adapter found"; return false; } - HRESULT hr2 = D3D12CreateDevice(adapter, D3D_FEATURE_LEVEL_11_0, IID_ID3D12Device, (void**)&s_device); + HRESULT hr2 = g_pfnD3D12CreateDevice(adapter, D3D_FEATURE_LEVEL_11_0, FST_IID_ID3D12Device, (void**)&s_device); adapter->Release(); if (FAILED(hr2)) { last_error_ = "D3D12CreateDevice failed"; return false; } } @@ -235,7 +295,7 @@ class dstorage_stream_reader { } hr = dev->CreateFence(0, D3D12_FENCE_FLAG_NONE, - IID_ID3D12Fence, (void**)&fence_); + FST_IID_ID3D12Fence, (void**)&fence_); if (FAILED(hr)) { fprintf(stderr, "dstorage_stream_reader: CreateFence failed hr=0x%08X\n", (unsigned)hr); return; @@ -257,7 +317,7 @@ class dstorage_stream_reader { hr = dev->CreateCommittedResource( &hp, D3D12_HEAP_FLAG_SHARED, &desc, D3D12_RESOURCE_STATE_COMMON, nullptr, - IID_ID3D12Resource, (void**)&stage_res_[i]); + FST_IID_ID3D12Resource, (void**)&stage_res_[i]); if (FAILED(hr)) { fprintf(stderr, "dstorage_stream_reader: CreateCommittedResource[%d] failed hr=0x%08X\n", i, (unsigned)hr); return; diff --git a/setup.py b/setup.py index 0b65210..8620f72 100644 --- a/setup.py +++ b/setup.py @@ -26,8 +26,8 @@ def MyExtension(name, sources, mod_name, *args, **kwargs): kwargs["libraries"] = [] # c++20 required for designated initializers at ext.hpp kwargs["extra_compile_args"] = ["/std:c++20"] - # Note: dstorage.dll is loaded at runtime via LoadLibrary, not linked. - kwargs["libraries"].extend(["ole32", "d3d12", "dxgi", "dxguid", "uuid"]) + # DirectStorage, D3D12, and DXGI DLLs are loaded at runtime so importing + # the extension does not require GPU/DirectX runtime DLLs to be present. # CUDA interop headers: if CUDA_HOME/CUDA_PATH is set, add include path # for cudaExternalMemory types used by the interop bridge. From 4b3b897dda0e437f243479679e579bff7bb35ed9 Mon Sep 17 00:00:00 2001 From: Takeshi Yoshimura Date: Tue, 7 Jul 2026 14:56:37 +0900 Subject: [PATCH 2/5] Repair Windows wheels with delvewheel; add import diagnostics The 0.3.3 release run failed on all win_amd64 wheels with "ImportError: DLL load failed while importing cpp: The specified module could not be found" while the same commit range built fine on 2026-06-04. Comparing the job logs showed the windows-latest image moved from windows-2025 to windows-2025-vs2026 between the runs (same OS build 10.0.26100), so the wheels are now compiled by a newer MSVC toolchain. The leading suspect is a new CRT satellite DLL dependency that is not installed on the image or on end-user machines, the same failure mode vcruntime140_1.dll and msvcp140_atomic_wait.dll caused when they were introduced. - Run delvewheel repair on Windows wheels so MSVC runtime dependencies are vendored into the wheel, as auditwheel already does for Linux. - Register .libs via os.add_dll_directory in smoke_import_cpp.py: delvewheel normally patches the package __init__ to do this, but the smoke test bypasses __init__ on purpose. - Add tests/dump_pyd_imports.py (temporary, Windows-only): prints the .pyd import table and where each DLL resolves from, so the next failure names the missing DLL. Remove once the root cause is recorded in windows_runner_issue.md. - Fix the version bump missed in the first 0.3.3 attempt (pyproject still said 0.3.2). - windows_runner_issue.md documents the investigation and the verification plan before re-tagging 0.3.3. Co-Authored-By: Claude Fable 5 Signed-off-by: Takeshi Yoshimura --- .github/workflows/publish.yaml | 10 ++ pyproject.toml | 2 +- tests/dump_pyd_imports.py | 60 ++++++++++ tests/smoke_import_cpp.py | 12 ++ windows_runner_issue.md | 201 +++++++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 tests/dump_pyd_imports.py create mode 100644 windows_runner_issue.md diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index e0a5a6f..166e0b3 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -46,6 +46,16 @@ jobs: CIBW_SKIP: "*-musllinux_* *-win32 *-manylinux_i686" CIBW_TEST_COMMAND: "python {project}/tests/smoke_import_cpp.py" CIBW_ARCHS: "${{ matrix.platform.cibw_arch }}" + # Bundle MSVC runtime satellite DLLs into the wheel; the VS2026 runner + # image builds against a toolchain newer than what end-user machines + # (and the test env) may have installed. See windows_runner_issue.md. + CIBW_BEFORE_BUILD_WINDOWS: "pip install delvewheel" + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}" + # Temporary diagnostic: print the .pyd import table and where each DLL + # resolves from, so a load failure names the missing DLL. Remove once + # windows_runner_issue.md records the root cause. + CIBW_TEST_REQUIRES_WINDOWS: "pefile" + CIBW_TEST_COMMAND_WINDOWS: "python {project}/tests/dump_pyd_imports.py && python {project}/tests/smoke_import_cpp.py" - name: Upload wheel artifact uses: actions/upload-artifact@v4 diff --git a/pyproject.toml b/pyproject.toml index dc0ced3..1d45d1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "fastsafetensors" -version = "0.3.2" +version = "0.3.3" description = "High-performance safetensors model loader" authors = [{name = "Takeshi Yoshimura", email = "tyos@jp.ibm.com"}] maintainers = [{name = "Takeshi Yoshimura", email = "tyos@jp.ibm.com"}] diff --git a/tests/dump_pyd_imports.py b/tests/dump_pyd_imports.py new file mode 100644 index 0000000..01c5e70 --- /dev/null +++ b/tests/dump_pyd_imports.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: Apache-2.0 + +"""Temporary diagnostic for the 0.3.3 Windows wheel investigation. + +Prints the DLL import table of the installed fastsafetensors.cpp extension and +where each imported DLL resolves from, so an import failure names the missing +DLL instead of Windows' generic "module could not be found". Requires pefile. +See windows_runner_issue.md; remove once the root cause is recorded there. +""" + +import importlib.machinery +import importlib.metadata +import os +import sys +from pathlib import Path + + +def _find_pyd(): + dist = importlib.metadata.distribution("fastsafetensors") + dist_root = Path(dist.locate_file("")) + for suffix in importlib.machinery.EXTENSION_SUFFIXES: + candidate = dist_root / "fastsafetensors" / f"cpp{suffix}" + if candidate.exists(): + return dist_root, candidate + raise FileNotFoundError( + f"fastsafetensors.cpp extension not found under {dist_root}" + ) + + +def main() -> None: + if os.name != "nt": + print("dump_pyd_imports: not Windows, nothing to do") + return + + import pefile + + dist_root, pyd = _find_pyd() + print(f"extension: {pyd}") + + search_dirs = { + "System32": Path(os.environ.get("SystemRoot", r"C:\Windows")) / "System32", + "beside pyd": pyd.parent, + "python dir": Path(sys.base_exec_prefix), + } + for libs_dir in dist_root.glob("*.libs"): + search_dirs[libs_dir.name] = libs_dir + + pe = pefile.PE(str(pyd), fast_load=True) + pe.parse_data_directories( + directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_IMPORT"]] + ) + for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []): + name = entry.dll.decode() + found_in = [label for label, d in search_dirs.items() if (d / name).exists()] + status = ", ".join(found_in) if found_in else "*** NOT FOUND ***" + print(f" imports {name}: {status}") + + +if __name__ == "__main__": + main() diff --git a/tests/smoke_import_cpp.py b/tests/smoke_import_cpp.py index 8e8abd5..8c59bee 100644 --- a/tests/smoke_import_cpp.py +++ b/tests/smoke_import_cpp.py @@ -5,13 +5,25 @@ import importlib.machinery import importlib.metadata import importlib.util +import os import sys from pathlib import Path +def _add_vendored_dll_dirs(dist_root: Path) -> None: + # delvewheel vendors dependent DLLs into .libs and registers that + # directory via a patch in the package __init__. This test bypasses + # __init__ on purpose, so register the directory here. + if sys.platform != "win32": + return + for libs_dir in dist_root.glob("*.libs"): + os.add_dll_directory(str(libs_dir)) + + def _load_cpp_extension(): dist = importlib.metadata.distribution("fastsafetensors") dist_root = Path(dist.locate_file("")) + _add_vendored_dll_dirs(dist_root) for suffix in importlib.machinery.EXTENSION_SUFFIXES: candidate = dist_root / "fastsafetensors" / f"cpp{suffix}" diff --git a/windows_runner_issue.md b/windows_runner_issue.md new file mode 100644 index 0000000..8a65102 --- /dev/null +++ b/windows_runner_issue.md @@ -0,0 +1,201 @@ +# Windows Wheel Import Failure in the 0.3.3 Release Build + +## Summary + +The first `0.3.3` release workflow failed only for Windows wheels during the +`tests/smoke_import_cpp.py` import check. Linux x86_64 and Linux aarch64 wheels +completed successfully. The `0.3.3` tag was deleted pending this investigation. + +The failure happens while importing the compiled `fastsafetensors.cpp` extension: + +```text +ImportError: DLL load failed while importing cpp: The specified module could not be found. +``` + +This is before `cpp.load_library_functions("")` is called, so the immediate +failure is at Windows loader time for the `.pyd` or one of its native DLL +dependencies. + +**Confirmed (2026-07-07):** the hosted runner image changed between the last +successful run and the failing run — from `windows-2025` to +`windows-2025-vs2026`, i.e. a new image with the Visual Studio 2026 toolchain. +See "Confirmed Runner Image Difference" below. The working hypothesis has been +revised accordingly: this is most likely a **build-toolchain change producing a +new MSVC C runtime DLL dependency**, not a DirectX runtime removal. + +## Relevant Runs + +Successful Windows wheel build: + +- Run: https://github.com/foundation-model-stack/fastsafetensors/actions/runs/26941643302 +- Commit: `f5f5911aff1903f847ebdeadb9335f7310c45edb` +- Date: 2026-06-04 +- Result: success (`cp310`–`cp314` `win_amd64` all successful) + +Failed Windows wheel build: + +- Run: https://github.com/foundation-model-stack/fastsafetensors/actions/runs/28836212587 +- Commit: `02dd37fb56ee692778856b075bf20275c3f8c0b7` +- Release branch/tag context: `0.3.3` (tag since deleted) +- Date: 2026-07-07 +- Result: failure on all `win_amd64` wheels; Linux x86_64, Linux aarch64, and + sdist succeeded + +Both runs used the `windows-latest` label (verified via the GitHub API job +metadata). + +## Failure Log Excerpt + +```text +Successfully installed annotated-doc-0.0.4 colorama-0.4.6 +fastsafetensors-0.3.2 markdown-it-py-4.2.0 mdurl-0.1.2 +pygments-2.20.0 rich-15.0.0 shellingham-1.5.4 typer-0.26.8 ++ python D:\a\fastsafetensors\fastsafetensors/tests/smoke_import_cpp.py +Traceback (most recent call last): + File "D:\a\fastsafetensors\fastsafetensors\tests\smoke_import_cpp.py", line 44, in + main() + File "D:\a\fastsafetensors\fastsafetensors\tests\smoke_import_cpp.py", line 35, in main + cpp = _load_cpp_extension() + File "D:\a\fastsafetensors\fastsafetensors\tests\smoke_import_cpp.py", line 22, in _load_cpp_extension + module = importlib.util.module_from_spec(spec) + File "", line 571, in module_from_spec + File "", line 1176, in create_module + File "", line 241, in _call_with_frames_removed +ImportError: DLL load failed while importing cpp: The specified module could not be found. +Error: cibuildwheel: Command python D:\a\fastsafetensors\fastsafetensors/tests/smoke_import_cpp.py failed with code 1. +``` + +Note: the `fastsafetensors-0.3.2` install line was caused by a version bump +mistake in `pyproject.toml` during the first 0.3.3 release attempt. That has +been fixed locally (`version = "0.3.3"`). The import failure itself is +independent of the package version string. + +## Confirmed Runner Image Difference + +From the "Set up job" log headers of both runs: + +| | Successful (2026-06-04) | Failed (2026-07-07) | +|---|---|---| +| Runner version | 2.334.0 | 2.335.1 | +| OS | Windows Server 2025, 10.0.26100 | Windows Server 2025, 10.0.26100 | +| **Image** | **`windows-2025`** | **`windows-2025-vs2026`** | +| Image version | win25/20260525.149 | win25-vs2026/20260628.158 | + +Key observations: + +- The OS build is **identical** (10.0.26100). This rules out an OS migration + removing DirectX runtime components. +- The image changed to a **Visual Studio 2026** variant. cibuildwheel builds + with whatever MSVC toolset setuptools discovers on the machine, so the + failing wheels were compiled and linked with a **different, newer MSVC + toolchain** than the passing ones. The wheel binary itself differs between + the two runs even though the C++ sources are effectively unchanged. + +This answers former open questions 1–3: the environment did change, but the +change was the VS toolchain, not a Windows Server 2022→2025 jump (that +migration had already completed in late 2025). + +## Revised Hypothesis + +Most likely: the `.pyd` built by the VS2026 toolchain imports a **new MSVC C +runtime satellite DLL** that is not present (or not current) in `System32` on +the image or on end-user machines. Historical precedents for exactly this +failure mode and error message: + +- VS2019 introduced `vcruntime140_1.dll` (x64) — binaries built with it failed + to load on machines with an older redistributable. +- VS2019 16.8 introduced `msvcp140_atomic_wait.dll` with the same symptom. +- `actions/runner-images#10396` — ONNX import failure after a runner image + update. + +Supporting evidence: + +- The error is "The specified **module** could not be found" (a DLL *file* is + missing), not "the specified procedure could not be found" (an export is + missing from an older DLL). +- All Python versions failed identically → shared native dependency, not a + per-interpreter issue. +- `d3d12.dll` / `dxgi.dll` are demoted as suspects: they are standard OS + components, the OS build is identical between runs, and loading them does + not require a GPU. If the image had actually dropped them, `runner-images` + would be flooded with reports — none were found. + +Repository-side causes are effectively ruled out: the only C++ changes between +`f5f5911` and `02dd37f` (`ext.cpp`, `gpu_compat.h`) were inspected and are all +`dlopen`/`dlsym`-based with the GDS library path compiled out on Windows +(`gdsLib = nullptr`); they add no link-time or import-time dependencies. + +## Implication for Released Wheels + +This is not just a CI problem. If the missing DLL is a new CRT satellite, +**wheels built on the vs2026 image would fail the same way on end-user machines +that lack the newest VC redistributable** — even if CI happened to pass. The CI +smoke test acted as a canary. The durable fix is to bundle the CRT dependencies +into the wheel (delvewheel), which is standard practice for distributing +Windows wheels and is what auditwheel already does for the Linux wheels. + +## Fixes + +### 1. Dynamic loading of D3D12/DXGI (applied locally) + +- Remove static links to `d3d12`, `dxgi`, `dxguid`, `uuid`, and `ole32` from + `setup.py` and the corresponding `#pragma comment(lib, ...)` lines. +- Load `d3d12.dll` / `dxgi.dll` with `LoadLibraryExW(..., + LOAD_LIBRARY_SEARCH_SYSTEM32)` inside `init_dstorage()`; resolve + `D3D12CreateDevice` / `CreateDXGIFactory1` via `GetProcAddress`; define the + required COM IIDs locally. + +Reviewed: the four locally defined IIDs match the official GUID values; no +remaining references to the removed import libraries exist (`__uuidof` appears +only in `dstorage.h` comments; no `CoInitialize`/`CoCreateInstance` calls; COM +methods go through vtables and need no import library). + +Caveat: this shrinks the import-time dependency surface (good hygiene for +GPU-less machines) but **does not fix the failure if the missing DLL is a CRT +satellite**, which is now the leading hypothesis. + +### 2. delvewheel repair in cibuildwheel (applied to `publish.yaml`) + +```yaml +CIBW_BEFORE_BUILD_WINDOWS: "pip install delvewheel" +CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}" +``` + +This vendors non-system dependent DLLs (notably the MSVC runtime satellites) +into `fastsafetensors.libs/` inside the wheel. delvewheel normally patches the +package `__init__.py` to register that directory; since +`tests/smoke_import_cpp.py` deliberately loads the extension without importing +the package, the smoke test now calls `os.add_dll_directory` on any `*.libs` +directory itself. + +### 3. Temporary import-table diagnostic (applied to `publish.yaml`) + +`tests/dump_pyd_imports.py` (requires `pefile`, wired via +`CIBW_TEST_REQUIRES_WINDOWS` / `CIBW_TEST_COMMAND_WINDOWS`) prints the DLL +import table of the built `.pyd` and whether each entry resolves from +`System32`, the extension directory, or the Python installation. Running this +once on the failing configuration identifies the missing DLL by name. Remove +the step once the root cause is recorded here. + +## Verification Plan + +1. Trigger `workflow_dispatch` on `main` with the fixes above. +2. Read the `dump_pyd_imports.py` output in the Windows jobs: confirm which + DLLs the VS2026-built `.pyd` imports and which one was previously + unresolved. Record it in this document. +3. Confirm the smoke import passes on `windows-latest` (vs2026 image). +4. Optionally cross-check by pinning `runs-on: windows-2025` (the pre-vs2026 + image label) on the previously failing commit — it should pass there + without any fix, confirming the toolchain-change theory. Note: this label + will presumably also move to vs2026 eventually, so pinning is a + short-term mitigation only, not the fix. +5. Re-tag `0.3.3` once green. + +## Remaining Open Questions + +1. Which exact DLL was unresolved? (Answered by the `dump_pyd_imports.py` + output on the next Windows run.) +2. Does delvewheel vendor it? (Expected yes for CRT satellites; if the missing + DLL turns out to be something delvewheel excludes, revisit.) +3. Does the `windows-2025` label still map to the pre-vs2026 image, and for + how long? From 4431a89081d584c98429d3570d0fed0eede62682 Mon Sep 17 00:00:00 2001 From: Takeshi Yoshimura Date: Tue, 7 Jul 2026 15:15:14 +0900 Subject: [PATCH 3/5] Match the VC redistributable to the MSVC toolset before building A verification run on the windows-2025-vs2026 image confirmed the root cause of the 0.3.3 Windows wheel import failure: the image builds with MSVC toolset 14.51 (VS2026) while its installed VC redistributable is 14.40, so delvewheel warned it was vendoring an msvcp140.dll older than the toolset that built the .pyd. With delvewheel repair the smoke import passes, and d3d12.dll/dxgi.dll no longer appear in the import table. To close the remaining version gap, install the vc_redist.x64.exe that ships inside the image's own Visual Studio (located via vswhere) before building; it matches the build toolset by construction. Exit codes 1638 (same or newer already installed) and 3010 (success, reboot required) are treated as success, so the step stays harmless once the image catches up. Also improve the import diagnostic: label api-ms-*/ext-ms-* imports as loader-resolved API sets instead of falsely reporting them as missing, and report the MSVC runtime DLLs present in System32 with their file versions to pin down what the unrepaired wheel could not resolve. Remove windows_runner_issue.md; it was a temporary investigation note and its conclusions are recorded here. Co-Authored-By: Claude Fable 5 Signed-off-by: Takeshi Yoshimura --- .github/workflows/publish.yaml | 28 ++++- tests/dump_pyd_imports.py | 45 +++++++- windows_runner_issue.md | 201 --------------------------------- 3 files changed, 66 insertions(+), 208 deletions(-) delete mode 100644 windows_runner_issue.md diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 166e0b3..1bfbf99 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -36,6 +36,26 @@ jobs: with: python-version: "3.11" + # The wheel is built with the image's newest MSVC toolset, but the + # image's installed VC redistributable can lag behind it (14.40 vs 14.51 + # on windows-2025-vs2026), so delvewheel would vendor an msvcp140.dll + # older than the toolset that built the .pyd. Install the redist that + # ships inside the image's own Visual Studio, which matches the toolset. + - name: Update VC++ redistributable to match the MSVC toolset (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products * -property installationPath + if (-not $vs) { throw "Visual Studio installation not found" } + $redist = Get-ChildItem "$vs\VC\Redist\MSVC" -Recurse -Filter vc_redist.x64.exe | + Sort-Object { $_.VersionInfo.FileVersionRaw } | Select-Object -Last 1 + if (-not $redist) { throw "vc_redist.x64.exe not found under $vs" } + Write-Host "Installing $($redist.FullName) ($($redist.VersionInfo.FileVersion))" + $p = Start-Process -FilePath $redist.FullName -ArgumentList '/install','/quiet','/norestart' -Wait -PassThru + # 1638: same or newer version already installed; 3010: success, reboot required + if ($p.ExitCode -notin 0, 1638, 3010) { throw "vc_redist.x64.exe exited with $($p.ExitCode)" } + - name: Install cibuildwheel run: python -m pip install cibuildwheel setuptools pybind11 - name: Build wheels with cibuildwheel @@ -48,12 +68,12 @@ jobs: CIBW_ARCHS: "${{ matrix.platform.cibw_arch }}" # Bundle MSVC runtime satellite DLLs into the wheel; the VS2026 runner # image builds against a toolchain newer than what end-user machines - # (and the test env) may have installed. See windows_runner_issue.md. + # (and the test env) may have installed. CIBW_BEFORE_BUILD_WINDOWS: "pip install delvewheel" CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}" - # Temporary diagnostic: print the .pyd import table and where each DLL - # resolves from, so a load failure names the missing DLL. Remove once - # windows_runner_issue.md records the root cause. + # Diagnostic: print the .pyd import table and where each DLL resolves + # from, so a load failure names the missing DLL instead of Windows' + # generic "module could not be found". CIBW_TEST_REQUIRES_WINDOWS: "pefile" CIBW_TEST_COMMAND_WINDOWS: "python {project}/tests/dump_pyd_imports.py && python {project}/tests/smoke_import_cpp.py" diff --git a/tests/dump_pyd_imports.py b/tests/dump_pyd_imports.py index 01c5e70..2ad319b 100644 --- a/tests/dump_pyd_imports.py +++ b/tests/dump_pyd_imports.py @@ -1,11 +1,11 @@ # SPDX-License-Identifier: Apache-2.0 -"""Temporary diagnostic for the 0.3.3 Windows wheel investigation. +"""Diagnostic run before the Windows wheel smoke test in CI. Prints the DLL import table of the installed fastsafetensors.cpp extension and where each imported DLL resolves from, so an import failure names the missing -DLL instead of Windows' generic "module could not be found". Requires pefile. -See windows_runner_issue.md; remove once the root cause is recorded there. +DLL instead of Windows' generic "module could not be found". Also reports the +MSVC runtime DLLs present in System32 and their versions. Requires pefile. """ import importlib.machinery @@ -51,10 +51,49 @@ def main() -> None: ) for entry in getattr(pe, "DIRECTORY_ENTRY_IMPORT", []): name = entry.dll.decode() + # API set names (api-ms-*, ext-ms-*) are resolved virtually by the OS + # loader, not by file lookup, so absence on disk is not a failure. + if name.lower().startswith(("api-ms-", "ext-ms-")): + print(f" imports {name}: API set (loader-resolved)") + continue found_in = [label for label, d in search_dirs.items() if (d / name).exists()] status = ", ".join(found_in) if found_in else "*** NOT FOUND ***" print(f" imports {name}: {status}") + # The failing wheel was never inspected before delvewheel repair, so also + # report which MSVC CRT DLLs the image itself provides and their versions; + # this identifies what the unrepaired .pyd could(n't) have resolved. + print("MSVC runtime DLLs in System32:") + crt_names = [ + "msvcp140.dll", + "msvcp140_1.dll", + "msvcp140_2.dll", + "msvcp140_atomic_wait.dll", + "msvcp140_codecvt_ids.dll", + "vcruntime140.dll", + "vcruntime140_1.dll", + "concrt140.dll", + ] + for crt_name in crt_names: + path = search_dirs["System32"] / crt_name + if path.exists(): + print(f" {crt_name}: {_file_version(pefile, path)}") + else: + print(f" {crt_name}: *** NOT PRESENT ***") + + +def _file_version(pefile, path): + try: + pe = pefile.PE(str(path), fast_load=True) + pe.parse_data_directories( + directories=[pefile.DIRECTORY_ENTRY["IMAGE_DIRECTORY_ENTRY_RESOURCE"]] + ) + info = pe.VS_FIXEDFILEINFO[0] + ms, ls = info.FileVersionMS, info.FileVersionLS + return f"{ms >> 16}.{ms & 0xFFFF}.{ls >> 16}.{ls & 0xFFFF}" + except Exception as e: + return f"present (version unreadable: {e})" + if __name__ == "__main__": main() diff --git a/windows_runner_issue.md b/windows_runner_issue.md deleted file mode 100644 index 8a65102..0000000 --- a/windows_runner_issue.md +++ /dev/null @@ -1,201 +0,0 @@ -# Windows Wheel Import Failure in the 0.3.3 Release Build - -## Summary - -The first `0.3.3` release workflow failed only for Windows wheels during the -`tests/smoke_import_cpp.py` import check. Linux x86_64 and Linux aarch64 wheels -completed successfully. The `0.3.3` tag was deleted pending this investigation. - -The failure happens while importing the compiled `fastsafetensors.cpp` extension: - -```text -ImportError: DLL load failed while importing cpp: The specified module could not be found. -``` - -This is before `cpp.load_library_functions("")` is called, so the immediate -failure is at Windows loader time for the `.pyd` or one of its native DLL -dependencies. - -**Confirmed (2026-07-07):** the hosted runner image changed between the last -successful run and the failing run — from `windows-2025` to -`windows-2025-vs2026`, i.e. a new image with the Visual Studio 2026 toolchain. -See "Confirmed Runner Image Difference" below. The working hypothesis has been -revised accordingly: this is most likely a **build-toolchain change producing a -new MSVC C runtime DLL dependency**, not a DirectX runtime removal. - -## Relevant Runs - -Successful Windows wheel build: - -- Run: https://github.com/foundation-model-stack/fastsafetensors/actions/runs/26941643302 -- Commit: `f5f5911aff1903f847ebdeadb9335f7310c45edb` -- Date: 2026-06-04 -- Result: success (`cp310`–`cp314` `win_amd64` all successful) - -Failed Windows wheel build: - -- Run: https://github.com/foundation-model-stack/fastsafetensors/actions/runs/28836212587 -- Commit: `02dd37fb56ee692778856b075bf20275c3f8c0b7` -- Release branch/tag context: `0.3.3` (tag since deleted) -- Date: 2026-07-07 -- Result: failure on all `win_amd64` wheels; Linux x86_64, Linux aarch64, and - sdist succeeded - -Both runs used the `windows-latest` label (verified via the GitHub API job -metadata). - -## Failure Log Excerpt - -```text -Successfully installed annotated-doc-0.0.4 colorama-0.4.6 -fastsafetensors-0.3.2 markdown-it-py-4.2.0 mdurl-0.1.2 -pygments-2.20.0 rich-15.0.0 shellingham-1.5.4 typer-0.26.8 -+ python D:\a\fastsafetensors\fastsafetensors/tests/smoke_import_cpp.py -Traceback (most recent call last): - File "D:\a\fastsafetensors\fastsafetensors\tests\smoke_import_cpp.py", line 44, in - main() - File "D:\a\fastsafetensors\fastsafetensors\tests\smoke_import_cpp.py", line 35, in main - cpp = _load_cpp_extension() - File "D:\a\fastsafetensors\fastsafetensors\tests\smoke_import_cpp.py", line 22, in _load_cpp_extension - module = importlib.util.module_from_spec(spec) - File "", line 571, in module_from_spec - File "", line 1176, in create_module - File "", line 241, in _call_with_frames_removed -ImportError: DLL load failed while importing cpp: The specified module could not be found. -Error: cibuildwheel: Command python D:\a\fastsafetensors\fastsafetensors/tests/smoke_import_cpp.py failed with code 1. -``` - -Note: the `fastsafetensors-0.3.2` install line was caused by a version bump -mistake in `pyproject.toml` during the first 0.3.3 release attempt. That has -been fixed locally (`version = "0.3.3"`). The import failure itself is -independent of the package version string. - -## Confirmed Runner Image Difference - -From the "Set up job" log headers of both runs: - -| | Successful (2026-06-04) | Failed (2026-07-07) | -|---|---|---| -| Runner version | 2.334.0 | 2.335.1 | -| OS | Windows Server 2025, 10.0.26100 | Windows Server 2025, 10.0.26100 | -| **Image** | **`windows-2025`** | **`windows-2025-vs2026`** | -| Image version | win25/20260525.149 | win25-vs2026/20260628.158 | - -Key observations: - -- The OS build is **identical** (10.0.26100). This rules out an OS migration - removing DirectX runtime components. -- The image changed to a **Visual Studio 2026** variant. cibuildwheel builds - with whatever MSVC toolset setuptools discovers on the machine, so the - failing wheels were compiled and linked with a **different, newer MSVC - toolchain** than the passing ones. The wheel binary itself differs between - the two runs even though the C++ sources are effectively unchanged. - -This answers former open questions 1–3: the environment did change, but the -change was the VS toolchain, not a Windows Server 2022→2025 jump (that -migration had already completed in late 2025). - -## Revised Hypothesis - -Most likely: the `.pyd` built by the VS2026 toolchain imports a **new MSVC C -runtime satellite DLL** that is not present (or not current) in `System32` on -the image or on end-user machines. Historical precedents for exactly this -failure mode and error message: - -- VS2019 introduced `vcruntime140_1.dll` (x64) — binaries built with it failed - to load on machines with an older redistributable. -- VS2019 16.8 introduced `msvcp140_atomic_wait.dll` with the same symptom. -- `actions/runner-images#10396` — ONNX import failure after a runner image - update. - -Supporting evidence: - -- The error is "The specified **module** could not be found" (a DLL *file* is - missing), not "the specified procedure could not be found" (an export is - missing from an older DLL). -- All Python versions failed identically → shared native dependency, not a - per-interpreter issue. -- `d3d12.dll` / `dxgi.dll` are demoted as suspects: they are standard OS - components, the OS build is identical between runs, and loading them does - not require a GPU. If the image had actually dropped them, `runner-images` - would be flooded with reports — none were found. - -Repository-side causes are effectively ruled out: the only C++ changes between -`f5f5911` and `02dd37f` (`ext.cpp`, `gpu_compat.h`) were inspected and are all -`dlopen`/`dlsym`-based with the GDS library path compiled out on Windows -(`gdsLib = nullptr`); they add no link-time or import-time dependencies. - -## Implication for Released Wheels - -This is not just a CI problem. If the missing DLL is a new CRT satellite, -**wheels built on the vs2026 image would fail the same way on end-user machines -that lack the newest VC redistributable** — even if CI happened to pass. The CI -smoke test acted as a canary. The durable fix is to bundle the CRT dependencies -into the wheel (delvewheel), which is standard practice for distributing -Windows wheels and is what auditwheel already does for the Linux wheels. - -## Fixes - -### 1. Dynamic loading of D3D12/DXGI (applied locally) - -- Remove static links to `d3d12`, `dxgi`, `dxguid`, `uuid`, and `ole32` from - `setup.py` and the corresponding `#pragma comment(lib, ...)` lines. -- Load `d3d12.dll` / `dxgi.dll` with `LoadLibraryExW(..., - LOAD_LIBRARY_SEARCH_SYSTEM32)` inside `init_dstorage()`; resolve - `D3D12CreateDevice` / `CreateDXGIFactory1` via `GetProcAddress`; define the - required COM IIDs locally. - -Reviewed: the four locally defined IIDs match the official GUID values; no -remaining references to the removed import libraries exist (`__uuidof` appears -only in `dstorage.h` comments; no `CoInitialize`/`CoCreateInstance` calls; COM -methods go through vtables and need no import library). - -Caveat: this shrinks the import-time dependency surface (good hygiene for -GPU-less machines) but **does not fix the failure if the missing DLL is a CRT -satellite**, which is now the leading hypothesis. - -### 2. delvewheel repair in cibuildwheel (applied to `publish.yaml`) - -```yaml -CIBW_BEFORE_BUILD_WINDOWS: "pip install delvewheel" -CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}" -``` - -This vendors non-system dependent DLLs (notably the MSVC runtime satellites) -into `fastsafetensors.libs/` inside the wheel. delvewheel normally patches the -package `__init__.py` to register that directory; since -`tests/smoke_import_cpp.py` deliberately loads the extension without importing -the package, the smoke test now calls `os.add_dll_directory` on any `*.libs` -directory itself. - -### 3. Temporary import-table diagnostic (applied to `publish.yaml`) - -`tests/dump_pyd_imports.py` (requires `pefile`, wired via -`CIBW_TEST_REQUIRES_WINDOWS` / `CIBW_TEST_COMMAND_WINDOWS`) prints the DLL -import table of the built `.pyd` and whether each entry resolves from -`System32`, the extension directory, or the Python installation. Running this -once on the failing configuration identifies the missing DLL by name. Remove -the step once the root cause is recorded here. - -## Verification Plan - -1. Trigger `workflow_dispatch` on `main` with the fixes above. -2. Read the `dump_pyd_imports.py` output in the Windows jobs: confirm which - DLLs the VS2026-built `.pyd` imports and which one was previously - unresolved. Record it in this document. -3. Confirm the smoke import passes on `windows-latest` (vs2026 image). -4. Optionally cross-check by pinning `runs-on: windows-2025` (the pre-vs2026 - image label) on the previously failing commit — it should pass there - without any fix, confirming the toolchain-change theory. Note: this label - will presumably also move to vs2026 eventually, so pinning is a - short-term mitigation only, not the fix. -5. Re-tag `0.3.3` once green. - -## Remaining Open Questions - -1. Which exact DLL was unresolved? (Answered by the `dump_pyd_imports.py` - output on the next Windows run.) -2. Does delvewheel vendor it? (Expected yes for CRT satellites; if the missing - DLL turns out to be something delvewheel excludes, revisit.) -3. Does the `windows-2025` label still map to the pre-vs2026 image, and for - how long? From fe23e0645919a2d8b9e02bd6b729b3c0bc237216 Mon Sep 17 00:00:00 2001 From: Takeshi Yoshimura Date: Tue, 7 Jul 2026 15:24:03 +0900 Subject: [PATCH 4/5] Make delvewheel pick msvcp140.dll from System32 The VC redist update step worked (System32 CRT DLLs are now 14.51.36247.0, matching the MSVC toolset), but delvewheel still vendored a stale 14.40 msvcp140.dll: its DLL search follows PATH order, and some other tool on the runner ships an older copy in a directory that comes before System32. Pass --add-path C:/Windows/System32 so the freshly updated copy takes precedence. Co-Authored-By: Claude Fable 5 Signed-off-by: Takeshi Yoshimura --- .github/workflows/publish.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 1bfbf99..e003fba 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -70,7 +70,10 @@ jobs: # image builds against a toolchain newer than what end-user machines # (and the test env) may have installed. CIBW_BEFORE_BUILD_WINDOWS: "pip install delvewheel" - CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair -w {dest_dir} {wheel}" + # --add-path makes delvewheel take msvcp140.dll from System32 (kept + # current by the redist step above) instead of a stale copy that some + # other tool ships earlier on PATH. + CIBW_REPAIR_WHEEL_COMMAND_WINDOWS: "delvewheel repair --add-path C:/Windows/System32 -w {dest_dir} {wheel}" # Diagnostic: print the .pyd import table and where each DLL resolves # from, so a load failure names the missing DLL instead of Windows' # generic "module could not be found". From c7ba76d5c9a4e677640f83ae0e61d505f5b678be Mon Sep 17 00:00:00 2001 From: Takeshi Yoshimura Date: Tue, 7 Jul 2026 15:41:49 +0900 Subject: [PATCH 5/5] Sync README and docs with current ROCm/GDS behavior - README: ROCm is no longer nogds-only, drop "without GDS". - overview.md: document hipFile-based direct loading on ROCm >= 7.2 (added in #85) instead of claiming ROCm has no GDS equivalent, and reflect that the gds copier now falls back to the nogds path at runtime with a warning (#87) rather than failing to open files. - development.md: the Makefile target is test-vllm, not vllm. Co-Authored-By: Claude Fable 5 Signed-off-by: Takeshi Yoshimura --- README.md | 2 +- docs/development.md | 2 +- docs/overview.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 778d5fc..3bab297 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ fastsafetensors fastsafetensors is an efficient safetensors loader. If you develop your own code that loads large safetensors files, you can try fastsafetensors APIs (see [docs](./docs/overview.md)). For example, vLLM and SGLang have `--load-format fastsafetensors` command-line argument to speed up their initialization. -This library supports Linux/CUDA, ROCm without GDS, Windows, [3FS](https://github.com/deepseek-ai/3fs), unified-memory systems such as DGX Spark, and so on. We welcome more platform/storage-specific optimizations like them by adding new [copier backends](fastsafetensors/copier/). Our CI tests Python 3.10-3.14 with PyTorch 2.11.0. +This library supports Linux/CUDA, ROCm, Windows, [3FS](https://github.com/deepseek-ai/3fs), unified-memory systems such as DGX Spark, and so on. We welcome more platform/storage-specific optimizations like them by adding new [copier backends](fastsafetensors/copier/). Our CI tests Python 3.10-3.14 with PyTorch 2.11.0. # Performance Highlights diff --git a/docs/development.md b/docs/development.md index d842a31..99ef74c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -25,7 +25,7 @@ You can also use Makefile on your local environment. ``` make unittest make unittest-parallel -make vllm +make test-vllm ``` # Pre-commit Hooks diff --git a/docs/overview.md b/docs/overview.md index b14d70b..455882c 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -24,7 +24,7 @@ The technology helps minimize copy overheads from NVMe SSDs to GPU memory by byp # Basic API usage -`SafeTensorsFileLoader` is a low-level entrypoint. To use it, pass either `SingleGroup()` for simple inference or `ProcessGroup()` (from `torch.distributed`) for tensor-parallel inference. The loader supports both CPU and CUDA devices, with optional GPU Direct Storage (GDS) support. You can specify the device and GDS settings using the `device` and `nogds` arguments, respectively. Note that if GDS is not available, the loader will fail to open files when `nogds=False`. For more information on enabling GDS, please refer to the NVIDIA documentation. +`SafeTensorsFileLoader` is a low-level entrypoint. To use it, pass either `SingleGroup()` for simple inference or `ProcessGroup()` (from `torch.distributed`) for tensor-parallel inference. The loader supports both CPU and CUDA devices, with optional GPU Direct Storage (GDS) support. You can specify the device and GDS settings using the `device` and `nogds` arguments, respectively. If GDS turns out to be unavailable at runtime (e.g., file handle registration fails), the loader logs a warning and falls back to the bounce-buffer (`nogds`) path instead of failing; you can also set `nogds=True` explicitly to skip GDS initialization. For more information on enabling GDS, please refer to the NVIDIA documentation. After creating a `SafeTensorsFileLoader` instance, first map target files and a rank using the `.add_filenames()` method. Then, call `.copy_file_to_device()` to trigger the actual file copies on aggregated GPU memory fragments and directly instantiate a group of tensors. Once the files are loaded, you can retrieve a tensor using the `.get_tensor()` method. Additionally, you can obtain sharded tensors by `.get_sharded()`, which internally runs collective operations in `torch.distributed`. @@ -45,8 +45,8 @@ See [Configuration Guide](./configuration.md) for defaults, examples, and all av # ROCm -On ROCm, there is no GDS-equivalent support, so fastsafetensors only supports `nogds=True` mode. -The performance gain example can be found at [amd-perf.md](./amd-perf.md). +On ROCm, direct storage-to-GPU loading is supported through hipFile (ROCm >= 7.2): when `libhipfile.so` is available, the GDS code path uses it transparently. On older ROCm without hipFile, the loader falls back to the bounce-buffer (`nogds`) path. +A performance gain example with the `nogds` path can be found at [amd-perf.md](./amd-perf.md). # Windows