Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/scripts/ci_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ def classify(paths):
selected.add("python_quality")
elif path.startswith("sample/python/"):
selected.update(("python_quality", "python_build", "wheels"))
elif path in (
".github/scripts/repair_windows_wheel.py",
".github/scripts/test_repair_windows_wheel.py",
):
selected.update(("python_quality", "wheels"))
elif path.startswith(".github/scripts/"):
selected.add("python_quality")
elif path == ".github/workflows/test.yaml":
Expand Down
152 changes: 152 additions & 0 deletions .github/scripts/repair_windows_wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
"""Repair x64 wheels using VS runtimes, then reject older bundled runtimes."""

import argparse
import os
import subprocess
import sys
import zipfile
from pathlib import Path

RUNTIME_PREFIXES = ("msvcp140", "vcruntime140", "vcomp140", "concrt140")


def pe_details(data):
# pefile is installed by the existing delvewheel before-build hook.
import pefile

with pefile.PE(data=data) as image:
if image.FILE_HEADER.Machine != 0x8664:
raise RuntimeError("Windows wheel repair requires x64 PE binaries")
toolset = (
image.OPTIONAL_HEADER.MajorLinkerVersion,
image.OPTIONAL_HEADER.MinorLinkerVersion,
)
version = None
if getattr(image, "VS_FIXEDFILEINFO", None):
info = image.VS_FIXEDFILEINFO[0]
version = (
info.FileVersionMS >> 16,
info.FileVersionMS & 0xFFFF,
info.FileVersionLS >> 16,
info.FileVersionLS & 0xFFFF,
)
return toolset, version


def runtime_version(data):
_, version = pe_details(data)
if version is None:
raise RuntimeError("Runtime DLL has no file-version resource")
return version


def wheel_toolset(wheel):
with zipfile.ZipFile(wheel) as archive:
extensions = [
name
for name in archive.namelist()
if Path(name).name.startswith("_rabitqlib.") and name.endswith(".pyd")
]
if len(extensions) != 1:
raise RuntimeError("Expected exactly one _rabitqlib extension in the wheel")
toolset, _ = pe_details(archive.read(extensions[0]))
return toolset


def find_runtime_dirs(installation, minimum):
candidates = []
root = installation / "VC" / "Redist" / "MSVC"
for crt in root.glob("*/x64/Microsoft.VC*.CRT"):
main_dll = crt / "msvcp140.dll"
if not main_dll.is_file():
continue
version = runtime_version(main_dll.read_bytes())
if version[:2] < minimum:
continue
for openmp in crt.parent.glob("Microsoft.VC*.OpenMP"):
omp_dll = openmp / "vcomp140.dll"
if (
omp_dll.is_file()
and runtime_version(omp_dll.read_bytes())[:2] >= minimum
):
candidates.append((version, crt, openmp))
if not candidates:
raise RuntimeError(f"No x64 VS runtime directories match toolset {minimum}")
_, crt, openmp = max(candidates, key=lambda candidate: candidate[0])
return crt, openmp


def verify_runtime_versions(wheel, minimum):
found = set()
with zipfile.ZipFile(wheel) as archive:
for name in archive.namelist():
filename = Path(name).name.lower()
if not filename.endswith(".dll") or not filename.startswith(
RUNTIME_PREFIXES
):
continue
version = runtime_version(archive.read(name))
print(f"Bundled {name}: {'.'.join(map(str, version))}", flush=True)
if version[:2] < minimum:
raise RuntimeError(
f"Bundled {name} runtime {version} is older than toolset {minimum}"
)
found.add(filename.split("-", 1)[0].removesuffix(".dll"))
if not {"msvcp140", "vcomp140"} <= found:
raise RuntimeError("Repaired wheel is missing its MSVC or OpenMP runtime")


def repair(wheel, destination, installation):
minimum = wheel_toolset(wheel)
runtime_dirs = find_runtime_dirs(installation, minimum)
print(
f"Wheel toolset: {minimum}; runtime search directories: {runtime_dirs}",
flush=True,
)
subprocess.run(
[
sys.executable,
"-m",
"delvewheel",
"repair",
"--add-path",
os.pathsep.join(map(str, runtime_dirs)),
"-w",
str(destination),
str(wheel),
],
check=True,
)
verify_runtime_versions(destination / wheel.name, minimum)


def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("wheel", type=Path)
parser.add_argument("destination", type=Path)
args = parser.parse_args()
vswhere = (
Path(os.environ["ProgramFiles(x86)"])
/ "Microsoft Visual Studio/Installer/vswhere.exe"
)
installation = subprocess.check_output(
[
str(vswhere),
"-latest",
"-products",
"*",
"-requires",
"Microsoft.VisualStudio.Component.VC.Tools.x86.x64",
"-property",
"installationPath",
"-utf8",
],
encoding="utf-8-sig",
).strip()
if not installation:
raise RuntimeError("No Visual Studio C++ installation found")
repair(args.wheel, args.destination, Path(installation))


if __name__ == "__main__":
main()
5 changes: 5 additions & 0 deletions .github/scripts/test_ci_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,11 @@ def test_known_support_paths_do_not_select_unrelated_builds(self):
"python/utils/io.py": {"python_quality", "python_build", "wheels"},
"tests/core_consumer/run.cmake": {"cpp", "python_quality"},
"tests/fixtures/core_api_v1/consumer.cpp": {"cpp"},
".github/scripts/repair_windows_wheel.py": {"python_quality", "wheels"},
".github/scripts/test_repair_windows_wheel.py": {
"python_quality",
"wheels",
},
}
for path, expected in cases.items():
with self.subTest(path=path):
Expand Down
5 changes: 4 additions & 1 deletion .github/scripts/test_ci_workflow_contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ def test_wheels_preserve_abi_coverage_and_release_gates(self):
# Keep the full installed-wheel suite and platform repair hooks.
self.assertIn("pytest {project}/tests/python", wheels["test-command"])
self.assertIn("delocate-wheel", wheels["macos"]["repair-wheel-command"])
self.assertIn("delvewheel repair", wheels["windows"]["repair-wheel-command"])
self.assertIn(
".github/scripts/repair_windows_wheel.py",
wheels["windows"]["repair-wheel-command"],
)
self.assertIn("scripts/build-macos-openmp.sh", wheels["macos"]["before-all"])
self.assertEqual(workflow.count("pattern: '*-wheels'"), 2)
artifacts = set()
Expand Down
137 changes: 137 additions & 0 deletions .github/scripts/test_repair_windows_wheel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
"""Exercise runtime selection and fail-closed wheel repair without Windows tools."""

import json
import os
import subprocess
import tempfile
import unittest
import zipfile
from pathlib import Path
from unittest.mock import patch

import repair_windows_wheel as repair


def metadata(toolset=(14, 51), version=None):
return json.dumps([toolset, version]).encode()


def read_metadata(data):
toolset, version = json.loads(data)
return tuple(toolset), tuple(version) if version is not None else None


class WindowsWheelRepairTest(unittest.TestCase):
def setUp(self):
temporary = tempfile.TemporaryDirectory()
self.addCleanup(temporary.cleanup)
self.root = Path(temporary.name)
self.installation = self.root / "Visual Studio"
mock = patch.object(repair, "pe_details", side_effect=read_metadata)
mock.start()
self.addCleanup(mock.stop)

def runtimes(self, folder, version, arch="x64", openmp=True):
root = self.installation / "VC/Redist/MSVC" / folder / arch
crt = root / "Microsoft.VC143.CRT"
crt.mkdir(parents=True)
(crt / "msvcp140.dll").write_bytes(metadata(version=version))
omp = root / "Microsoft.VC143.OpenMP"
if openmp:
omp.mkdir()
(omp / "vcomp140.dll").write_bytes(metadata(version=version))
return crt, omp

def wheel(self, path, dlls):
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("rabitqlib/_rabitqlib.cp311-win_amd64.pyd", metadata())
for name, version in dlls.items():
archive.writestr("rabitqlib.libs/" + name, metadata(version=version))

def test_selects_matching_x64_runtime_not_older_or_wrong_architecture(self):
self.runtimes("14.40", (14, 40, 33810, 0))
expected = self.runtimes("14.51", (14, 51, 36231, 0))
self.runtimes("14.99", (14, 99, 0, 0), arch="x86")
self.assertEqual(
repair.find_runtime_dirs(self.installation, (14, 51)), expected
)

def test_checks_actual_file_version_instead_of_directory_name(self):
self.runtimes("14.99", (14, 40, 33810, 0))
with self.assertRaisesRegex(RuntimeError, "No x64 VS runtime"):
repair.find_runtime_dirs(self.installation, (14, 51))

def test_requires_openmp_from_the_same_redist_tree(self):
self.runtimes("14.51", (14, 51, 36231, 0), openmp=False)
self.runtimes("14.40", (14, 40, 33810, 0))
with self.assertRaisesRegex(RuntimeError, "No x64 VS runtime"):
repair.find_runtime_dirs(self.installation, (14, 51))

def test_rejects_observed_mixed_runtime_wheel(self):
wheel = self.root / "mixed.whl"
self.wheel(
wheel,
{
"msvcp140-old.dll": (14, 40, 33810, 0),
"msvcp140_1-new.dll": (14, 51, 36231, 0),
"vcomp140-new.dll": (14, 51, 36231, 0),
},
)
with self.assertRaisesRegex(RuntimeError, "older than toolset"):
repair.verify_runtime_versions(wheel, repair.wheel_toolset(wheel))

def test_rejects_missing_or_unversioned_runtime(self):
wheel = self.root / "missing.whl"
for dlls in ({}, {"msvcp140-hash.dll": (14, 51, 36231, 0)}):
self.wheel(wheel, dlls)
with self.assertRaisesRegex(RuntimeError, "missing"):
repair.verify_runtime_versions(wheel, (14, 51))
self.wheel(wheel, {"msvcp140-hash.dll": None})
with self.assertRaisesRegex(RuntimeError, "no file-version"):
repair.verify_runtime_versions(wheel, (14, 51))

def test_repair_prioritizes_selected_directories_and_checks_output(self):
paths = self.runtimes("14.51", (14, 51, 36231, 0))
source = self.root / "source.whl"
destination = self.root / "output"
destination.mkdir()
self.wheel(source, {})

def run(command, *, check):
self.assertTrue(check)
self.assertEqual(command[1:4], ["-m", "delvewheel", "repair"])
self.assertEqual(
command[command.index("--add-path") + 1],
os.pathsep.join(map(str, paths)),
)
self.wheel(
destination / source.name,
{
"msvcp140-hash.dll": (14, 51, 36231, 0),
"vcomp140-hash.dll": (14, 51, 36231, 0),
},
)

with patch.object(repair.subprocess, "run", side_effect=run):
repair.repair(source, destination, self.installation)

def bad_output(*args, **kwargs):
self.wheel(
destination / source.name, {"msvcp140-old.dll": (14, 40, 33810, 0)}
)

with patch.object(repair.subprocess, "run", side_effect=bad_output):
with self.assertRaisesRegex(RuntimeError, "older than toolset"):
repair.repair(source, destination, self.installation)

with patch.object(
repair.subprocess,
"run",
side_effect=subprocess.CalledProcessError(1, "repair"),
):
with self.assertRaises(subprocess.CalledProcessError):
repair.repair(source, destination, self.installation)


if __name__ == "__main__":
unittest.main()
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ CMAKE_ARGS = '-DRABITQ_ENABLE_NATIVE_OPTIMIZATION=OFF -DOpenMP_ROOT="$RABITQ_OPE

[tool.cibuildwheel.windows]
before-build = "python -m pip install delvewheel"
repair-wheel-command = "delvewheel repair -w {dest_dir} {wheel}"
repair-wheel-command = 'python "{project}/.github/scripts/repair_windows_wheel.py" "{wheel}" "{dest_dir}"'

[tool.pytest.ini_options]
testpaths = ["tests/python"]
Expand Down
4 changes: 3 additions & 1 deletion tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -157,10 +157,12 @@ endif()

# Run allocation replacement in a separate process; this also exercises core-only builds.
add_executable(compact_codes_failure_test failures/compact_codes_failure_test.cpp)
target_link_libraries(compact_codes_failure_test PRIVATE GTest::gtest_main rabitqlib::core)
target_link_libraries(compact_codes_failure_test PRIVATE GTest::gtest_main)
if(RABITQ_BUILD_FASTSCAN)
target_link_libraries(compact_codes_failure_test PRIVATE rabitqlib::fastscan)
target_compile_definitions(compact_codes_failure_test PRIVATE RABITQ_TEST_FASTSCAN)
else()
target_link_libraries(compact_codes_failure_test PRIVATE rabitqlib::core)
endif()
rabitq_configure_target(compact_codes_failure_test)
if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
Expand Down
5 changes: 4 additions & 1 deletion tests/unit/rabitqlib/index/qg_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -949,7 +949,10 @@ TEST(QGEstimatorTest, CandidateMaskBackendsMatchScalarOrdering) {
distances[29] = -std::numeric_limits<float>::infinity();

constexpr float kThreshold = 2.0F;
const uint32_t expected = simd::qg_candidate_mask_generic(distances.data(), kThreshold);
// Keep residues 0..4 in each group of seven, except +inf at lane 17.
// Unordered NaN at lane 3 and -inf at lane 29 remain candidates.
constexpr uint32_t expected = 0xF3E5CF9FU;
EXPECT_EQ(simd::qg_candidate_mask_generic(distances.data(), kThreshold), expected);
#if defined(__x86_64__) || defined(_M_X64)
if (cpu::has_avx2()) {
EXPECT_EQ(simd::qg_candidate_mask_avx2(distances.data(), kThreshold), expected);
Expand Down
11 changes: 7 additions & 4 deletions tests/unit/rabitqlib/utils/space_test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ TEST(WarmupIpX0Q, SupportsUnalignedCodes) {

const float expected = delta * static_cast<float>(weighted_intersection) +
vl * static_cast<float>(data_popcount);
EXPECT_FLOAT_EQ(
warmup_ip_x0_q_512(codes, query.data(), delta, vl, dim, b_query), expected
);
#if defined(__x86_64__) || defined(_M_X64)
if (cpu::has_avx2()) {
EXPECT_FLOAT_EQ(
Expand All @@ -227,10 +230,6 @@ TEST(WarmupIpX0Q, SupportsUnalignedCodes) {
),
expected
);
EXPECT_FLOAT_EQ(
warmup_ip_x0_q_512(codes, query.data(), delta, vl, dim, b_query),
expected
);
}
#endif
#if defined(__x86_64__) || defined(_M_X64)
Expand All @@ -250,6 +249,10 @@ TEST(WarmupIpX0Q, SupportsUnalignedCodes) {
TEST(WarmupIpX0Q, RejectsQueryWidthsBeyondByte) {
const std::array<uint8_t, 8> data{};
const std::array<uint64_t, 9> query{};
EXPECT_THROW(
warmup_ip_x0_q_512(data.data(), query.data(), 1.0F, 0.0F, 64, 9),
std::invalid_argument
);
#if defined(__x86_64__) || defined(_M_X64)
if (cpu::has_avx2()) {
EXPECT_THROW(
Expand Down
Loading