Merge pull request #1 from petrutlucian94/io_improvements

Allow retrieving compressed chunks
This commit is contained in:
Lucian Petrut
2026-09-17 18:30:42 +03:00
committed by GitHub
12 changed files with 914 additions and 94 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## Overview
- This is a test project meant to bypass/replace VDDK, which is no longer
- This is a project meant to bypass/replace VDDK, which is no longer
publicly available.
- The end goal is to have a Python library that can be used as a VDDK replacement
to retrieve VMware disk contents.
+7 -2
View File
@@ -4,6 +4,10 @@ OpenVixDiskLib is an open-source Python replacement for VMware VDDK's
`VixDiskLib` NBD path. It reads and writes VMDK contents over vSphere
NFC without the proprietary VDDK SDK.
AI tools (Cursor + Grok 4.6) have been heavily used to reverse engineer the
NBD and NFC protocols, obtaining a working VDDK replacement in a few hours and
comprehensive testing in a matter of days.
The Python package is `openvixdisklib` (lowercase, following usual
Python naming).
@@ -18,7 +22,7 @@ Implemented against vCenter 8 / ESXi 8. Default transport is `nbdssl`
- `VixDiskLib_ConnectEx` (UID credentials)
- `VixDiskLib_Open` (datastore path, read-only or read-write)
- `VixDiskLib_Read`
- `VixDiskLib_Read` (optional ``skip_decompression`` packs FastLZ extras)
- `VixDiskLib_Write`
Not implemented: compression open flags other than FastLZ, CBT /
@@ -112,7 +116,8 @@ tox -e integration -- --runslow
Compare write/read throughput of OpenVixDiskLib and native VDDK
(`64KiB`, 129-sector, and `32MiB` transfers; `nbdssl` and `nbd`;
plain and FastLZ):
plain, FastLZ, and OpenVixDiskLib FastLZ ``skip_decompression``;
AIO sessions 64 KiB×1, 1 MiB×1, 2 MiB×1, and 2 MiB×4).
```bash
tox -e perf
+22 -3
View File
@@ -158,8 +158,26 @@ obtain a file handle or to read sector 0.
### OPEN_SESSION / sockopts / resource pool
VDDK sends 16 zero bytes (`OPEN_SESSION`; server replies with 16 zeros),
12 zero bytes (`SET_SOCK_OPTS`; server returns send/recv buffer sizes
`OPEN_SESSION` payload is 16 bytes, little-endian:
| Offset | Type | Meaning |
| ------ | -------- | ---------------------------------------------------- |
| 0 | `uint32` | 0 (unused in captures) |
| 4 | `uint32` | AIO buffer size in **bytes** (VDDK default 65536) |
| 8 | `uint32` | Buffer count (VDDK ``nfcAio.Session.BufCount``) |
| 12 | `uint32` | 0 |
VDDK config `vixDiskLib.nfcAio.Session.BufSizeIn64KB` is that byte size
divided by 64 KiB (`1` → 65536, `32` → 2097152). The server replies
with 16 zeros; it still **uses** the requested size for IO extras.
A 129-sector read is two fragments at 64 KiB, and one 66048-byte
fragment at 2 MiB. Lab ESXi 8 accepted 2 MiB (`BufCount` 1 and 4) and
rejected 16 MiB and 32 MiB (`OPEN_SESSION` AIO error). Broadcom's 16 MiB
figure is session memory (`size × count`), not a larger extra; the
per-buffer max on the wire is 2 MiB. Probe:
`docs/probing_samples/vddk_aio_bufsize_probe.py`.
`SET_SOCK_OPTS` is 12 zero bytes (server returns send/recv buffer sizes
and a `uint32` flag), then `uint32` 1 (`SET_RES_POOL`, log: “Setting
Resource Pool(1)”).
@@ -206,12 +224,13 @@ classic type 4 `NFC_SESSION_COMPLETE`.
## OpenVixDiskLib
| Piece | Module |
| ----------------------------- | ----------------------------------------------- |
| ------------------------------- | --------------------------------------------------------- |
| VIM + authd | `openvixdisklib.nfc_auth.authenticate` |
| Dup fd, skip TLS for NFC | `openvixdisklib.nfc_open.takeover_authd_socket` |
| Second TLS for nbdssl | `openvixdisklib.nfc_open.wrap_nfcssl_socket` |
| FastLZ for NBD compression | `openvixdisklib.fastlz` (pip `pyfastlz`) |
| Handshake + AIO + OPEN_FILE | `openvixdisklib.nfc_open.open_disk` |
| AIO extra size / pool count | `open_disk(..., aio_buffer_size=, aio_buffer_count=)` |
| Sector read / write / close | `openvixdisklib.nfc_open.NfcDisk` |
Run:
+65 -11
View File
@@ -26,11 +26,13 @@ length = numSectors * sectorSize
| `VixDiskLib_Read(h, 0, 128, buf)` | IO length 65536 (AIO buffer size), one fragment |
| `VixDiskLib_Read(h, 0, 129, buf)` | One request of 66048; **two** reply fragments |
VDDK does **not** split a `Read` larger than 64 KiB into multiple
requests. The client sends one AIO message; the server answers with
one or more same-`opId` replies, each carrying at most
`NFC_AIO_BUFFER_SIZE` (65536) data bytes. `NfcAioInitSession` logged
that buffer size and count 4 during open.
VDDK does **not** split a `Read` larger than the AIO buffer into
multiple requests. The client sends one AIO message; the server
answers with one or more same-`opId` replies, each carrying at most
the OPEN_SESSION buffer size (VDDK default 65536).
`vixDiskLib.nfcAio.Session.BufSizeIn64KB=32` advertises 2 MiB; a
129-sector read then returns **one** 66048-byte extra, and a 2 MiB +
512 read returns 2097152 + 512. See `docs/nfc_open.md` (OPEN_SESSION).
Sparse regions are still transferred as zeros. A read of 8 sectors at
LBA 8 on this disk was 4096 zero bytes on the wire, not a skip.
@@ -82,21 +84,22 @@ payload + `chunkLength` data bytes.
Reply payload (handle is zeroed; lengths describe this fragment):
| Offset | Type | Meaning |
| ------ | -------- | ----------------------------------------------- |
| ------ | -------- | -------------------------------------------------------------------- |
| 0 | `uint64` | `0` |
| 8 | `uint64` | `1` (read) |
| 16 | `uint64` | Byte offset of the **request** |
| 16 | `uint64` | Byte offset of the **request** on disk |
| 24 | `uint32` | Total request length |
| 28 | `uint32` | Byte offset of this fragment (`0`, `65536`, …) |
| 32 | `uint32` | This fragment’s byte length |
| 36 | `uint32` | Same as offset 32 |
| 28 | `uint32` | Fragment byte offset **in this request** (`0`, `65536`, …), not disk |
| 32 | `uint32` | This fragment’s uncompressed byte length |
| 36 | `uint32` | Same as offset 32, or compressed extra size when type is FastLZ |
| 40 | `uint32` | `0` |
When there is a single fragment, offsets 24–31 look like a `uint64`
length (the fragment offset is 0). The 129-sector capture shows why
they are two `uint32`s: fragment 0 has `(66048, 0)` then chunk 65536;
fragment 1 has `(66048, 65536)` then chunk 512. `0x00010000` at offset
28 is the byte offset, not a 0-based index.
28 is the byte offset, not a 0-based index. Disk byte address of a
fragment is request offset (payload 16) plus payload 28.
Read loop: receive fragments with that `opId` until the concatenated
data length equals the request. Use the `uint32` at payload offset 32
@@ -113,6 +116,9 @@ S: type=7 opId=18 size=44 dest=0 chunk=65536 + 65536 data
S: type=7 opId=18 size=44 dest=65536 chunk=512 + 512 data
```
`dest` in that dump is payload offset 28 (`ReadFragment.dest`): 0 and
65536 are positions in this 66048-byte read, not sector numbers.
## Lab check
Integration tests create an empty 10 GiB thin disk, write a repeating
@@ -135,6 +141,54 @@ The integration test writes and then reads the captured VDDK ranges
(including a 129-sector transfer that must assemble two read
fragments).
## Skip decompression (OpenVixDiskLib extension)
`VixDiskLib_Read` always fills `buf` with uncompressed sector bytes.
OpenVixDiskLib can skip FastLZ decode so a backup application can
forward the compressed data as-is, avoiding unnecessary re-compression.
`NfcDisk.readinto(..., skip_decompression=True)` and
`VixDiskLibHandle.read(..., skip_decompression=True)` still send one
IO request and wait until uncompressed `filled == length`. They do
**not** decompress. Extras are packed densely from offset 0 of `buf`.
`ReadResult.fragments` describes each extra. Type `2` extras are
FastLZ; type `0` fallbacks are raw. Concatenating extras is not a
valid FastLZ stream; the caller must use the table to split them.
| Field | Meaning |
| ---------------------- | ------------------------------------------------------------------------------------------------ |
| `dest` | Byte offset **in this uncompressed read** (NFC payload 28). Not a disk LBA or VMDK file offset. |
| `uncompressed_length` | Uncompressed fragment size (NFC payload 32). |
| `compression_type` | `NFC_COMPRESSION_NONE` (0) or `NFC_COMPRESSION_FASTLZ` (2). |
| `offset` | Start of this extra in packed `buf` (receive order, densely from 0). |
| `length` | Extra size on the wire. |
Disk byte address of a fragment is `start_sector * 512 + dest`. A
129-sector `read` from sector 0 or from sector 1000 still reports
`dest=0` and `dest=65536` when extras are 64 KiB.
`buf` is sized for the uncompressed request, so it is always large
enough. Default `read` still decompresses; `fragments` is empty and
`compressed_length` is still the extra bytes on the wire.
`skip_decompression` with a plain (no FASTLZ) open only records raw
extras (`compressed_length == uncompressed_length`).
This is not `VixDiskLib_Read`. Do not add an open flag for it;
compression on the wire is already the FASTLZ open flag.
A 32 MiB read at 64 KiB extras is 512 fragments in **one** result. A
2 MiB OPEN_SESSION extra (`aio_buffer_size=2097152`) is 16 fragments
for the same read. One dest PUT per extra is not viable.
```
uncompressed request (offsets in this read, not on disk)
|---------------- 64KiB --|-- 64KiB --|-- ... --|
dest=0 dest=65536
extra (FastLZ or raw) extra (FastLZ or raw)
buf when skip_decompression=True: extras packed densely from offset 0
```
## What is still VDDK-only
- zlib and skipz NBD compression flags
+6 -5
View File
@@ -85,8 +85,9 @@ A 1-sector VDDK write was 572 bytes on the wire: 16 + 44 + 512.
## Fragments and the single reply
`NfcAioInitSession` advertises a 64 KiB buffer. Extra per type-7
message is at most that size. VDDK does **not** issue a new `opId` per
`OPEN_SESSION` advertises the AIO buffer size (VDDK default 64 KiB;
`BufSizeIn64KB` can raise it). Extra per type-7 message is at most
that size. VDDK does **not** issue a new `opId` per
chunk, and it does **not** coalesce separate `VixDiskLib_Write` calls
(eight 8 KiB writes stayed eight IOs). One public write becomes N
client type-7 messages with the **same** `opId`, then **one** 44-byte
@@ -105,9 +106,9 @@ for one reply per chunk; raising the window did not match VDDK
throughput because VDDK pays one RTT per `Write`, not per fragment.
`NfcAioFlushCoalescedWrites` is server-side (`nfcAioServer.c`), not a
client merge of API writes. OPEN_SESSION is 16 zero bytes both ways, so
the logged AIO buffer count of 4 is a VDDK client default
(`vixDiskLib.nfcAio.Session.BufCount`), not a server cap.
client merge of API writes. Buffer count is
`vixDiskLib.nfcAio.Session.BufCount` (OPEN_SESSION offset 8); size is
`BufSizeIn64KB` (offset 4, in bytes). `docs/nfc_open.md`.
## OpenVixDiskLib
@@ -0,0 +1,224 @@
#!/usr/bin/env python3
"""Probe VDDK ``vixDiskLib.nfcAio.Session.BufSizeIn64KB`` vs NFC read extras.
Not part of the library. Creates a temp lab VM, runs native VDDK over ``nbd``
under ``strace``, and prints OPEN_SESSION payloads plus IO reply chunk
lengths. BufSizeIn64KB=1 is 64 KiB; 32 is 2 MiB.
"""
from __future__ import annotations
import os
import pickle
import re
import struct
import subprocess
import sys
import tempfile
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../..")))
os.environ.pop("LD_PRELOAD", None)
from tests.integration import vixdisklib # noqa: E402
from tests.integration.base import ( # noqa: E402
SECTOR_SIZE,
create_lab_vm,
destroy_lab_vm,
ensure_vddk_library_path,
)
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
_VDDK = os.path.join(_REPO, ".vddk")
_AIO_MAGIC = 0xA100DA7A
_AIO_HDR = 16
_NFC_AIO_MSG_OPEN_SESSION = 2
_NFC_AIO_MSG_IO = 7
_NFC_AIO_IO_READ = 1
# 129 sectors: two 64 KiB-class fragments today. 4097: 2 MiB + 512.
_READS = ((129, "129s"), (4097, "2MiB+512"))
def _vddk_config(directory: str, buf_size_in_64kb: int, buf_count: int) -> str:
path = os.path.join(directory, "vddk.config")
log = os.path.join(directory, "vddk.log")
with open(path, "w", encoding="utf-8") as config:
config.write(f"tmpDirectory={directory}\n")
config.write(f"log.fileName={log}\n")
config.write("log.fileLevel=verbose\n")
config.write("vixDiskLib.nfc.LogLevel=4\n")
config.write("vixDiskLib.transport.LogLevel=4\n")
config.write(f"vixDiskLib.nfcAio.Session.BufSizeIn64KB={buf_size_in_64kb}\n")
config.write(f"vixDiskLib.nfcAio.Session.BufCount={buf_count}\n")
return path
def _worker(lab_pkl: str, work_dir: str, buf_size_in_64kb: int, buf_count: int) -> None:
ensure_vddk_library_path()
with open(lab_pkl, "rb") as pickle_file:
lab = pickle.load(pickle_file)
config_path = _vddk_config(work_dir, buf_size_in_64kb, buf_count)
handle = vixdisklib.VixDiskLibHandle(
vixdisklib_compatibility_version="8.0", config_path=config_path
)
kwargs = {
"server_name": lab.host,
"port": lab.port,
"thumbprint": lab.thumbprint,
"username": lab.username,
"password": lab.password,
"vmx_spec": lab.vmx_spec,
"transport_modes": "nbd",
"read_only": True,
}
with (
handle.connect(**kwargs) as conn,
handle.open(
conn, lab.disk_path, flags=vixdisklib.VIXDISKLIB_FLAG_OPEN_READ_ONLY
) as disk,
):
print("transport", handle.get_transport_mode(disk), flush=True)
for n_sectors, label in _READS:
buf = vixdisklib.get_buffer(n_sectors * SECTOR_SIZE)
handle.read(disk, 0, n_sectors, buf)
print(f"read {label} ok", flush=True)
handle.exit()
def _decode_strace_hex(quoted: str) -> bytes:
parts = re.findall(r"\\x([0-9a-fA-F]{2})", quoted)
return bytes(int(part, 16) for part in parts)
def parse_strace(path: str) -> tuple[list[bytes], list[tuple[int, int, int]]]:
"""Return client OPEN_SESSION payloads and **server** read-reply chunks.
VDDK reads the 16-byte AIO header in one syscall and the payload in
the next, so bytes are concatenated per fd before parsing.
"""
syscall_re = re.compile(r'(read|write|recv|send)\((\d+),\s*"(.*?)"')
writes: dict[int, bytearray] = {}
reads: dict[int, bytearray] = {}
with open(path, encoding="utf-8", errors="replace") as strace_file:
for line in strace_file:
match = syscall_re.search(line)
if not match:
continue
op, fd_s, quoted = match.group(1), match.group(2), match.group(3)
buf = _decode_strace_hex(quoted)
if not buf:
continue
fd = int(fd_s)
bucket = writes if op in ("write", "send") else reads
bucket.setdefault(fd, bytearray()).extend(buf)
def walk(buf: bytes, collect_open: bool, collect_io: bool) -> None:
offset = 0
while offset + _AIO_HDR <= len(buf):
magic, msg_type, size, _opid = struct.unpack_from("<IIII", buf, offset)
if magic != _AIO_MAGIC:
offset += 1
continue
payload = buf[offset + _AIO_HDR : offset + _AIO_HDR + size]
if collect_open and msg_type == _NFC_AIO_MSG_OPEN_SESSION and len(payload) >= 16:
open_sessions.append(payload[:16])
if collect_io and msg_type == _NFC_AIO_MSG_IO and len(payload) >= 40:
opcode = struct.unpack_from("<Q", payload, 8)[0]
if opcode & 0xFFFFFFFF == _NFC_AIO_IO_READ:
dest, chunk_len, extra_len = struct.unpack_from("<III", payload, 28)
io_reads.append((dest, chunk_len, extra_len))
offset += _AIO_HDR + size
open_sessions: list[bytes] = []
io_reads: list[tuple[int, int, int]] = []
for buf in writes.values():
walk(bytes(buf), collect_open=True, collect_io=False)
for buf in reads.values():
walk(bytes(buf), collect_open=False, collect_io=True)
return open_sessions, io_reads
def _interesting_log_lines(log_path: str) -> list[str]:
keys = (
"Buffer Size",
"BufCount",
"BufSize",
"AIO session",
"Aio Session",
"maximum session",
"Req. buffer",
)
lines: list[str] = []
if not os.path.isfile(log_path):
return lines
with open(log_path, encoding="utf-8", errors="replace") as log_file:
for line in log_file:
if any(key in line for key in keys):
lines.append(line.rstrip())
return lines
def _run_traced(lab_pkl: str, buf_size_in_64kb: int, buf_count: int) -> None:
work_dir = tempfile.mkdtemp(prefix=f"vddk-aio-bufsize-{buf_size_in_64kb}-")
strace_path = os.path.join(work_dir, "nfc.strace")
python = sys.executable
cmd = [
"strace",
"-f",
"-x",
"-s",
"96",
"-e",
"trace=read,write,readv,writev,send,recv,sendto,recvfrom",
"-o",
strace_path,
python,
__file__,
"--worker",
lab_pkl,
work_dir,
str(buf_size_in_64kb),
str(buf_count),
]
env = os.environ.copy()
env.pop("LD_PRELOAD", None)
lib_path = env.get("LD_LIBRARY_PATH", "")
env["LD_LIBRARY_PATH"] = _VDDK if not lib_path else f"{_VDDK}:{lib_path}"
print(f"\n=== BufSizeIn64KB={buf_size_in_64kb} BufCount={buf_count} ===")
print("work_dir", work_dir)
proc = subprocess.run(cmd, env=env, check=False, text=True, capture_output=True)
sys.stdout.write(proc.stdout)
sys.stderr.write(proc.stderr)
print("worker exit", proc.returncode)
for line in _interesting_log_lines(os.path.join(work_dir, "vddk.log")):
print("LOG", line)
open_sessions, io_reads = parse_strace(strace_path)
for payload in open_sessions:
ints = struct.unpack("<IIII", payload)
print("OPEN_SESSION hex", payload.hex(), "u32", ints)
print("read fragments (dest, chunk_len, extra_len):")
for dest, chunk_len, extra_len in io_reads:
print(f" dest={dest} chunk_len={chunk_len} extra_len={extra_len}")
if io_reads:
print("max chunk_len", max(item[1] for item in io_reads))
def main() -> None:
if "--worker" in sys.argv:
_, lab_pkl, work_dir, buf_size, buf_count = sys.argv[1:]
_worker(lab_pkl, work_dir, int(buf_size), int(buf_count))
return
ensure_vddk_library_path()
lab = create_lab_vm()
lab_pkl = "/tmp/vddk-aio-bufsize-lab.pkl"
try:
with open(lab_pkl, "wb") as pickle_file:
pickle.dump(lab, pickle_file)
print("lab", lab.disk_path, lab.vm_moref)
for buf_size, buf_count in ((1, 1), (32, 1)):
_run_traced(lab_pkl, buf_size, buf_count)
finally:
destroy_lab_vm(lab)
if __name__ == "__main__":
main()
+3 -1
View File
@@ -250,7 +250,9 @@ What that comparison showed:
- Request size stays 44; data is extra after the payload.
- VDDK sends **one** request even when `length > 65536`. The server
replies with several type-7 messages that share `opId`, each with a
chunk length at payload offset 32 (max 65536).
chunk length at payload offset 32 (max = OPEN_SESSION bufSize;
default 65536). `vixDiskLib.nfcAio.Session.BufSizeIn64KB=32` makes
that 2 MiB (`docs/probing_samples/vddk_aio_bufsize_probe.py`).
- Treating offset 36 as `NFC_DISK` (`2`) was a 1-sector coincidence;
VDDK repeats the byte length there.
- Zeros on the wire are real transferred zeros, not a sparse skip.
+130 -18
View File
@@ -25,6 +25,7 @@ import os
import socket
import ssl
import struct
from dataclasses import dataclass
from openvixdisklib import fastlz
from openvixdisklib.nfc_auth import NfcAuthSession, _ssl_client_context
@@ -34,9 +35,12 @@ NFC_AIO_MAGIC = 0xA100DA7A
NFC_AIO_HDR_SIZE = 16
NFC_SECTOR_SIZE = 512
NFC_PROTOCOL_VERSION = 11
# Max data bytes in one AIO IO request/reply fragment
# (NfcAioInitSession buffer).
# Max data bytes in one AIO IO request/reply fragment. Sent as
# OPEN_SESSION ``bufSize`` (VDDK ``vixDiskLib.nfcAio.Session.BufSizeIn64KB``
# times 64 KiB). ESXi read extras use this size; 2 MiB (32) works on
# ESXi 8, 16 MiB and 32 MiB do not.
NFC_AIO_BUFFER_SIZE = 65536
NFC_AIO_BUFFER_COUNT = 1
# Classic NFC message types observed on the wire (uint32 at offset 0).
NFC_MSG_SESSION_COMPLETE = 4
@@ -78,6 +82,46 @@ NFC_COMPRESSION_NONE = 0
NFC_COMPRESSION_FASTLZ = 2
@dataclass(frozen=True, slots=True)
class ReadFragment:
"""One NFC AIO extra in a packed skip-decompression ``buf``.
Views into ``buf`` (``buf[offset:offset + length]``) are valid
until the next ``read`` into the same buffer.
``dest`` is NFC payload offset 28: the byte offset of this fragment
**inside this uncompressed read**, starting at 0. It is not a disk
LBA and not a byte offset from the start of the VMDK. The disk byte
address is ``start_sector * sector_size + dest``.
``offset`` is where this extra sits in packed ``buf`` (densely from
0 in receive order). ``length`` is the extra on the wire.
``uncompressed_length`` is NFC payload offset 32.
"""
dest: int
uncompressed_length: int
compression_type: int
offset: int
length: int
@dataclass(frozen=True, slots=True)
class ReadResult:
"""Outcome of ``NfcDisk.readinto`` / ``VixDiskLibHandle.read``.
``compressed_length`` is bytes of extra on the wire. With
``skip_decompression=True``, extras are packed in ``buf`` from
offset 0 and ``fragments`` describes them. The decompressing path
sets ``fragments`` to empty so callers can still use the lengths
for metrics.
"""
uncompressed_length: int
compressed_length: int
fragments: tuple[ReadFragment, ...]
class NfcProtocolError(ConnectionError):
"""Raised when an NFC message is malformed or reports failure."""
@@ -162,6 +206,20 @@ def _writable_bytes(buf: bytearray | memoryview, length: int) -> memoryview:
return raw[:length]
def _aio_extra_len(ctype: int, body: bytes, chunk_len: int) -> int:
"""Return this fragment's extra size on the wire."""
if ctype == NFC_COMPRESSION_FASTLZ:
extra_len = struct.unpack_from("<I", body, 36)[0]
if extra_len < 1:
raise NfcProtocolError(
f"FastLZ extra length {extra_len} is invalid, chunk {chunk_len}"
)
return extra_len
if ctype == NFC_COMPRESSION_NONE:
return chunk_len
raise NfcProtocolError(f"unsupported NFC IO compression type {ctype}")
def _send_nfc_msg(sock: socket.socket, msg_type: int, body: bytes = b"") -> None:
if len(body) > NFC_MSG_SIZE - 4:
raise ValueError("NFC classic message body too large")
@@ -200,6 +258,8 @@ class NfcDisk:
handle: int,
sector_size: int,
compression: int = NFC_COMPRESSION_NONE,
aio_buffer_size: int = NFC_AIO_BUFFER_SIZE,
aio_buffer_count: int = NFC_AIO_BUFFER_COUNT,
) -> None:
"""Wrap an AIO session that already has ``path`` open.
@@ -210,6 +270,11 @@ class NfcDisk:
sector_size: Sector size from the OPEN_FILE reply.
compression: NFC IO compression type (``NFC_COMPRESSION_NONE``
or ``NFC_COMPRESSION_FASTLZ``).
aio_buffer_size: OPEN_SESSION extra size in bytes (default
``NFC_AIO_BUFFER_SIZE``, 64 KiB). ESXi read extras are
at most this large.
aio_buffer_count: OPEN_SESSION buffer pool count (default
``NFC_AIO_BUFFER_COUNT``).
"""
self._sock = sock
self._op_id = 0
@@ -217,6 +282,8 @@ class NfcDisk:
self.handle = handle
self.sector_size = sector_size
self.compression = compression
self.aio_buffer_size = aio_buffer_size
self.aio_buffer_count = aio_buffer_count
self._closed = False
def _next_op_id(self) -> int:
@@ -264,7 +331,7 @@ class NfcDisk:
"""Read ``num_sectors`` starting at ``start_sector``.
Matches ``VixDiskLib_Read``: one ``NFC_AIO_MSG_IO`` request in
byte units. If the length exceeds the AIO buffer (64 KiB) the
byte units. If the length exceeds the session AIO buffer the
server replies with several same-``opId`` fragments, which are
placed by the fragment byte offset in the reply (they may arrive
out of order). FASTLZ open requests compression in the opcode;
@@ -285,22 +352,30 @@ class NfcDisk:
start_sector: int,
num_sectors: int,
buf: bytearray | memoryview,
) -> int:
skip_decompression: bool = False,
) -> ReadResult:
"""Read ``num_sectors`` into ``buf`` starting at ``start_sector``.
Uncompressed fragments are received directly into ``buf``. FastLZ
still decompresses into a temporary buffer, then copies the
result. ``buf`` must be writable and at least
``num_sectors * sector_size`` bytes (a ``get_buffer`` ctypes
array is wrapped with ``memoryview`` by the VDDK-shaped handle).
result, unless ``skip_decompression`` is set. ``buf`` must be
writable and at least ``num_sectors * sector_size`` bytes (a
``get_buffer`` ctypes array is wrapped with ``memoryview`` by
the VDDK-shaped handle).
Args:
start_sector: Sector offset from the start of the disk.
num_sectors: Number of sectors to read.
buf: Destination buffer.
skip_decompression: When True, pack NFC extras densely from
offset 0 without FastLZ decompress. Fragment metadata
is in the returned ``ReadResult``. Completion still
uses uncompressed chunk lengths.
Returns:
The number of bytes written to ``buf``.
Lengths of the uncompressed request and of extras on the
wire. ``fragments`` is populated only when skipping
decompression.
"""
if num_sectors < 1:
raise ValueError("num_sectors must be at least 1")
@@ -314,6 +389,9 @@ class NfcDisk:
op_id = self._next_op_id()
self._sock.sendall(_pack_aio_hdr(NFC_AIO_MSG_IO, len(payload), op_id) + payload)
filled = 0
wire_bytes = 0
packed_offset = 0
fragments: list[ReadFragment] = []
seen: set[int] = set()
while filled < length:
rhdr = _recvn(self._sock, NFC_AIO_HDR_SIZE)
@@ -340,10 +418,26 @@ class NfcDisk:
)
seen.add(dest)
ctype = opcode >> 32
chunk_view = data[dest : dest + chunk_len]
if ctype == NFC_COMPRESSION_FASTLZ:
comp_len = struct.unpack_from("<I", body, 36)[0]
extra = _recvn(self._sock, comp_len)
extra_len = _aio_extra_len(ctype, body, chunk_len)
if skip_decompression:
end = packed_offset + extra_len
if end > length:
raise NfcProtocolError(
f"packed extras {end} bytes exceed request {length}"
)
_recvn_into(self._sock, data[packed_offset:end])
fragments.append(
ReadFragment(
dest=dest,
uncompressed_length=chunk_len,
compression_type=ctype,
offset=packed_offset,
length=extra_len,
)
)
packed_offset = end
elif ctype == NFC_COMPRESSION_FASTLZ:
extra = _recvn(self._sock, extra_len)
try:
chunk = fastlz.decompress(extra, chunk_len)
except ValueError as exc:
@@ -354,19 +448,24 @@ class NfcDisk:
raise NfcProtocolError(
f"FastLZ read got {len(chunk)} bytes, expected {chunk_len}"
)
chunk_view[:] = chunk
data[dest : dest + chunk_len] = chunk
elif ctype == NFC_COMPRESSION_NONE:
_recvn_into(self._sock, chunk_view)
_recvn_into(self._sock, data[dest : dest + chunk_len])
else:
raise NfcProtocolError(f"unsupported NFC IO compression type {ctype}")
wire_bytes += extra_len
filled += chunk_len
return length
return ReadResult(
uncompressed_length=length,
compressed_length=wire_bytes,
fragments=tuple(fragments) if skip_decompression else (),
)
def write(self, start_sector: int, num_sectors: int, data: bytes) -> None:
"""Write ``num_sectors`` starting at ``start_sector``.
Matches ``VixDiskLib_Write``: one ``NFC_AIO_MSG_IO`` ``opId``
for the whole call. Chunks larger than the AIO buffer (64 KiB)
for the whole call. Chunks larger than the session AIO buffer
are extra fragments with that same ``opId``; the server replies
once. FASTLZ open compresses each fragment when that shrinks it.
@@ -384,7 +483,7 @@ class NfcDisk:
op_id = self._next_op_id()
frag_offset = 0
while frag_offset < length:
chunk = data[frag_offset : frag_offset + NFC_AIO_BUFFER_SIZE]
chunk = data[frag_offset : frag_offset + self.aio_buffer_size]
extra = chunk
extra_len = len(chunk)
ctype = NFC_COMPRESSION_NONE
@@ -484,7 +583,10 @@ def _handshake(sock: socket.socket, client_name: str, op_id: str, version: int)
def _aio_prepare(disk: NfcDisk) -> None:
disk._aio_roundtrip(NFC_AIO_MSG_OPEN_SESSION, bytes(16))
open_session = struct.pack(
"<IIII", 0, disk.aio_buffer_size, disk.aio_buffer_count, 0
)
disk._aio_roundtrip(NFC_AIO_MSG_OPEN_SESSION, open_session)
disk._aio_roundtrip(NFC_AIO_MSG_SET_SOCK_OPTS, bytes(12))
disk._aio_roundtrip(NFC_AIO_MSG_SET_RES_POOL, struct.pack("<I", 1))
@@ -509,6 +611,8 @@ def open_disk(
version: int = NFC_PROTOCOL_VERSION,
read_only: bool = True,
compression: int = NFC_COMPRESSION_NONE,
aio_buffer_size: int = NFC_AIO_BUFFER_SIZE,
aio_buffer_count: int = NFC_AIO_BUFFER_COUNT,
) -> NfcDisk:
"""Open ``disk_path`` over the authenticated authd socket.
@@ -528,7 +632,13 @@ def open_disk(
read_only: When True, open with VDDK's read-only NFC flags.
compression: ``NFC_COMPRESSION_NONE`` or ``NFC_COMPRESSION_FASTLZ``.
OPEN_FILE flags are unchanged; compression is per IO message.
aio_buffer_size: Extra size advertised in OPEN_SESSION (bytes).
aio_buffer_count: Buffer pool count advertised in OPEN_SESSION.
"""
if aio_buffer_size < 1:
raise ValueError("aio_buffer_size must be at least 1")
if aio_buffer_count < 1:
raise ValueError("aio_buffer_count must be at least 1")
if compression not in (NFC_COMPRESSION_NONE, NFC_COMPRESSION_FASTLZ):
raise NotImplementedError(
f"NFC compression type {compression} is not supported"
@@ -546,6 +656,8 @@ def open_disk(
handle=0,
sector_size=NFC_SECTOR_SIZE,
compression=compression,
aio_buffer_size=aio_buffer_size,
aio_buffer_count=aio_buffer_count,
)
_aio_prepare(disk)
path_b = disk_path.encode("utf-8")
+40 -4
View File
@@ -26,6 +26,9 @@ from pyVmomi import vim
from openvixdisklib import nfc_auth, nfc_open
ReadResult = nfc_open.ReadResult
ReadFragment = nfc_open.ReadFragment
LOG = logging.getLogger(__name__)
VIXDISKLIB_VERSION_MAJOR = 8
@@ -264,6 +267,8 @@ class VixDiskLibHandle:
conn: _Connection,
disk_path: str,
flags: int = VIXDISKLIB_FLAG_OPEN_READ_ONLY,
aio_buffer_size: int = nfc_open.NFC_AIO_BUFFER_SIZE,
aio_buffer_count: int = nfc_open.NFC_AIO_BUFFER_COUNT,
) -> Iterator[_DiskHandle]:
"""Open ``disk_path`` over NFC. Matches ``VixDiskLib_Open``.
@@ -280,6 +285,13 @@ class VixDiskLibHandle:
the disk read-only; omit it for write.
``VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ`` compresses
NFC IO. zlib and skipz are not implemented.
aio_buffer_size: NFC AIO extra size in bytes, advertised in
OPEN_SESSION. Default 64 KiB. ESXi 8 accepts 2 MiB
(``2097152``) and rejects 16 MiB and 32 MiB. This is an
OpenVixDiskLib extension (VDDK uses
``vixDiskLib.nfcAio.Session.BufSizeIn64KB``).
aio_buffer_count: NFC AIO buffer pool count. Default 1.
VDDK's default is 4.
"""
LOG.debug("Openning VixDiskLib disk: %s", disk_path)
compression = _nfc_compression(flags)
@@ -298,7 +310,12 @@ class VixDiskLibHandle:
session = nfc_auth.NfcAuthSession(conn.si, ticket, authd_sock, nfc_ssl=nfc_ssl)
try:
disk = nfc_open.open_disk(
session, disk_path, read_only=read_only, compression=compression
session,
disk_path,
read_only=read_only,
compression=compression,
aio_buffer_size=aio_buffer_size,
aio_buffer_count=aio_buffer_count,
)
except Exception:
authd_sock.close()
@@ -315,7 +332,8 @@ class VixDiskLibHandle:
start_sector: int,
num_sectors: int,
buf: ctypes.Array | bytearray | memoryview,
) -> None:
skip_decompression: bool = False,
) -> ReadResult:
"""Read ``num_sectors`` from ``start_sector`` into ``buf``.
Args:
@@ -323,9 +341,27 @@ class VixDiskLibHandle:
start_sector: First sector to read.
num_sectors: Number of sectors to read.
buf: Destination buffer (``get_buffer`` or a writable bytes-like).
Uncompressed NFC extra is received into this buffer.
Uncompressed NFC extra is received into this buffer
unless ``skip_decompression`` is True.
skip_decompression: OpenVixDiskLib extension. When True,
pack NFC extras densely from offset 0 without FastLZ
decompress. ``ReadResult.fragments`` lists each extra
(``ReadFragment``). ``ReadFragment.dest`` is the byte
offset inside this uncompressed read, not a disk
offset. Completion still uses uncompressed
lengths. With no FASTLZ open flag this only records
raw extras (``compressed_length == uncompressed_length``).
Returns:
Uncompressed and wire lengths. ``fragments`` is empty unless
``skip_decompression`` is True.
"""
disk_handle.disk.readinto(start_sector, num_sectors, memoryview(buf))
return disk_handle.disk.readinto(
start_sector,
num_sectors,
memoryview(buf),
skip_decompression=skip_decompression,
)
def write(
self,
+52 -3
View File
@@ -10,6 +10,9 @@ import pytest
from openvixdisklib import nfc_open
from tests.integration.base import SECTOR_SIZE, LabEnv, pattern_bytes
_1MIB = 1024 * 1024
_2MIB = 2 * 1024 * 1024
_16MIB = 16 * 1024 * 1024
_32MIB = 32 * 1024 * 1024
@@ -70,14 +73,60 @@ class TestNfcReadWrite:
[nfc_open.NFC_COMPRESSION_NONE, nfc_open.NFC_COMPRESSION_FASTLZ],
ids=["plain", "fastlz"],
)
def test_write_and_read_32mb(self, lab: LabEnv, compression: int) -> None:
"""Write 32 MiB (512 AIO chunks) and read it back in one request."""
@pytest.mark.parametrize(
"aio_buffer_count, aio_buffer_size",
[
(1, nfc_open.NFC_AIO_BUFFER_SIZE),
(1, _1MIB),
(1, _2MIB),
(4, _2MIB),
pytest.param(
1,
_16MIB,
marks=pytest.mark.xfail(
raises=nfc_open.NfcProtocolError,
reason="ESXi 8 rejects OPEN_SESSION bufSize 16 MiB",
strict=True,
),
),
pytest.param(
1,
_32MIB,
marks=pytest.mark.xfail(
raises=nfc_open.NfcProtocolError,
reason="ESXi 8 rejects OPEN_SESSION bufSize 32 MiB",
strict=True,
),
),
],
ids=[
"count1-64kib",
"count1-1mib",
"count1-2mib",
"count4-2mib",
"count1-16mib",
"count1-32mib",
],
)
def test_write_and_read_32mb(
self,
lab: LabEnv,
compression: int,
aio_buffer_count: int,
aio_buffer_size: int,
) -> None:
"""Write 32 MiB and read it back for several OPEN_SESSION sizes."""
n_sectors = _32MIB // SECTOR_SIZE
to_write = os.urandom(_32MIB)
with (
lab.authenticate(read_only=False) as session,
nfc_open.open_disk(
session, lab.disk_path, read_only=False, compression=compression
session,
lab.disk_path,
read_only=False,
compression=compression,
aio_buffer_size=aio_buffer_size,
aio_buffer_count=aio_buffer_count,
) as disk,
):
disk.write(0, n_sectors, to_write)
+86
View File
@@ -3,11 +3,15 @@
"""Exercise the VDDK-compatible openvixdisklib handle against the lab."""
from typing import Any
import pytest
from pyVim.connect import Disconnect
from pyVmomi import vim
from openvixdisklib import fastlz, nfc_open
from openvixdisklib import openvixdisklib as vixdisklib
from openvixdisklib.openvixdisklib import ReadResult
from tests.integration.base import (
SECTOR_AT_1GB,
SECTOR_SIZE,
@@ -28,6 +32,29 @@ def _virtual_disk_backing(
raise AssertionError(f"{vm._moId} has no virtual disk")
_2MIB = 2 * 1024 * 1024
def _rebuild_skip(buf: Any, result: ReadResult) -> bytes:
"""Decompress packed skip-decompression extras into uncompressed bytes."""
view = buf.raw if hasattr(buf, "raw") else buf
out = bytearray(result.uncompressed_length)
packed = 0
for frag in result.fragments:
extra = bytes(view[frag.offset : frag.offset + frag.length])
packed += frag.length
if frag.compression_type == nfc_open.NFC_COMPRESSION_FASTLZ:
chunk = fastlz.decompress(extra, frag.uncompressed_length)
elif frag.compression_type == nfc_open.NFC_COMPRESSION_NONE:
chunk = extra
else:
raise AssertionError(f"unexpected compression_type {frag.compression_type}")
assert len(chunk) == frag.uncompressed_length
out[frag.dest : frag.dest + frag.uncompressed_length] = chunk
assert packed == result.compressed_length
return bytes(out)
class TestOpenvixdisklib:
@pytest.mark.parametrize("transport_mode", ["nbdssl", "nbd"])
@pytest.mark.parametrize(
@@ -134,3 +161,62 @@ class TestOpenvixdisklib:
_wait_for_task(vm.RemoveAllSnapshots_Task())
finally:
Disconnect(si)
@pytest.mark.parametrize(
"aio_buffer_size, n_sectors, n_fragments",
[
(nfc_open.NFC_AIO_BUFFER_SIZE, 128, 1),
(nfc_open.NFC_AIO_BUFFER_SIZE, 129, 2),
(_2MIB, 129, 1),
],
ids=["64kib-128s", "64kib-129s", "2mib-129s"],
)
def test_skip_decompression_fastlz(
self,
lab: LabEnv,
aio_buffer_size: int,
n_sectors: int,
n_fragments: int,
) -> None:
"""Pack FastLZ extras and rebuild the same bytes as a normal read."""
length = n_sectors * SECTOR_SIZE
expected = pattern_bytes(length, b"OVDL-SKIP-")
handle = vixdisklib.VixDiskLibHandle(
vixdisklib_compatibility_version="8.0", config_path=None
)
write_buf = vixdisklib.get_buffer(length)
plain_buf = vixdisklib.get_buffer(length)
skip_buf = vixdisklib.get_buffer(length)
write_buf[:length] = expected
connect_kwargs = lab.vixdisklib_connect_kwargs(
{"allow_untrusted": lab.allow_untrusted, "transport_modes": "nbd"}
)
flags = vixdisklib.VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ
with (
handle.connect(**connect_kwargs) as conn,
handle.open(
conn,
lab.disk_path,
flags=flags,
aio_buffer_size=aio_buffer_size,
aio_buffer_count=1,
) as disk,
):
handle.write(disk, 0, n_sectors, write_buf)
plain = handle.read(disk, 0, n_sectors, plain_buf)
skip = handle.read(disk, 0, n_sectors, skip_buf, skip_decompression=True)
assert isinstance(plain, ReadResult)
assert plain.fragments == ()
assert plain.uncompressed_length == length
assert plain.compressed_length <= length
assert skip.uncompressed_length == length
assert skip.compressed_length <= length
assert skip.compressed_length == plain.compressed_length
assert len(skip.fragments) == n_fragments
dests = {frag.dest for frag in skip.fragments}
if n_fragments == 1:
assert dests == {0}
else:
assert dests == {0, nfc_open.NFC_AIO_BUFFER_SIZE}
assert plain_buf.raw[:length] == expected
assert _rebuild_skip(skip_buf, skip) == expected
+247 -15
View File
@@ -5,18 +5,71 @@
from __future__ import annotations
import json
import os
import pickle
import subprocess
import sys
import tempfile
import time
from typing import Any
from openvixdisklib import nfc_open
from openvixdisklib import openvixdisklib as open_vix
from tests.integration import vixdisklib
from tests.integration.base import SECTOR_SIZE, LabEnv, pattern_bytes
from tests.integration.base import (
SECTOR_SIZE,
LabEnv,
ensure_vddk_library_path,
pattern_bytes,
)
_REPO = os.path.abspath(os.path.join(os.path.dirname(__file__), "../.."))
_SIZES = (
("64KiB", 64 * 1024),
("129 sectors", 129 * SECTOR_SIZE),
("32MiB", 32 * 1024 * 1024),
)
_1MIB = 1024 * 1024
_2MIB = 2 * 1024 * 1024
# ESXi 8 accepts 64 KiB, 1 MiB, and 2 MiB extras. 16 MiB and 32 MiB
# OPEN_SESSION are rejected (AIO error). Broadcom's 16 MiB cap is
# size×count session memory, not a larger extra; VDDK's per-buffer max
# is 2 MiB (``BufSizeIn64KB=16`` is 1 MiB).
_AIO_SESSIONS = (
(nfc_open.NFC_AIO_BUFFER_COUNT, nfc_open.NFC_AIO_BUFFER_SIZE),
(1, _1MIB),
(1, _2MIB),
(4, _2MIB),
)
_64KIB = 64 * 1024
def _aio_size_label(nbytes: int) -> str:
"""Return a short label for an AIO extra size."""
if nbytes % (1024 * 1024) == 0:
return f"{nbytes // (1024 * 1024)}MiB"
if nbytes % 1024 == 0:
return f"{nbytes // 1024}KiB"
return str(nbytes)
def _vddk_aio_config(
directory: str, aio_buffer_size: int, aio_buffer_count: int
) -> str:
"""Write a temp VDDK config for ``BufSizeIn64KB`` and ``BufCount``."""
if aio_buffer_size % _64KIB:
raise ValueError(
f"VDDK BufSizeIn64KB needs a 64 KiB multiple, got {aio_buffer_size}"
)
path = os.path.join(directory, "vddk.config")
with open(path, "w", encoding="utf-8") as config:
config.write(f"tmpDirectory={directory}\n")
config.write(
f"vixDiskLib.nfcAio.Session.BufSizeIn64KB={aio_buffer_size // _64KIB}\n"
)
config.write(f"vixDiskLib.nfcAio.Session.BufCount={aio_buffer_count}\n")
return path
def _connect_extra(lab: LabEnv, module: Any, transport_mode: str) -> dict[str, Any]:
@@ -27,37 +80,182 @@ def _connect_extra(lab: LabEnv, module: Any, transport_mode: str) -> dict[str, A
return extra
def _time_write_read(
def _time_write_read_once(
lab: LabEnv,
module: Any,
payload: bytes,
flags: int = 0,
transport_mode: str = "nbdssl",
aio_buffer_size: int = nfc_open.NFC_AIO_BUFFER_SIZE,
aio_buffer_count: int = nfc_open.NFC_AIO_BUFFER_COUNT,
config_dir: str | None = None,
skip_decompression: bool = False,
) -> tuple[float, float]:
"""Write ``payload`` at sector 0, read it back, and return durations."""
"""Write ``payload`` at sector 0, read it back, and return durations.
Does not call ``VixDiskLib_Exit``. Native VDDK double-frees if
``InitEx``/``Exit`` are paired more than once in the same process.
``skip_decompression`` is OpenVixDiskLib FastLZ skip; ``buf`` then
holds packed extras, not sector bytes.
"""
n_sectors = len(payload) // SECTOR_SIZE
config_path = None
if module is vixdisklib:
if config_dir is None:
raise ValueError("VDDK timings need a config_dir")
config_path = _vddk_aio_config(config_dir, aio_buffer_size, aio_buffer_count)
handle = module.VixDiskLibHandle(
vixdisklib_compatibility_version="8.0", config_path=None
vixdisklib_compatibility_version="8.0", config_path=config_path
)
write_buf = module.get_buffer(len(payload))
read_buf = module.get_buffer(len(payload))
write_buf[: len(payload)] = payload
kwargs = lab.vixdisklib_connect_kwargs(_connect_extra(lab, module, transport_mode))
open_kwargs: dict[str, Any] = {"flags": flags}
if module is open_vix:
open_kwargs["aio_buffer_size"] = aio_buffer_size
open_kwargs["aio_buffer_count"] = aio_buffer_count
with (
handle.connect(**kwargs) as conn,
handle.open(conn, lab.disk_path, flags=flags) as disk,
handle.open(conn, lab.disk_path, **open_kwargs) as disk,
):
started = time.perf_counter()
handle.write(disk, 0, n_sectors, write_buf)
write_s = time.perf_counter() - started
read_buf[: len(payload)] = b"\xa5" * len(payload)
read_kwargs: dict[str, Any] = {}
if skip_decompression:
read_kwargs["skip_decompression"] = True
started = time.perf_counter()
handle.read(disk, 0, n_sectors, read_buf)
result = handle.read(disk, 0, n_sectors, read_buf, **read_kwargs)
read_s = time.perf_counter() - started
if skip_decompression:
assert result.uncompressed_length == len(payload)
assert result.compressed_length <= len(payload)
assert result.fragments
else:
assert read_buf.raw[: len(payload)] == payload
return write_s, read_s
def _run_vddk_worker(lab_pkl: str, job_pkl: str, work_dir: str) -> None:
"""InitEx once in this process, time one write/read, write result.json."""
os.environ.pop("LD_PRELOAD", None)
ensure_vddk_library_path()
with open(lab_pkl, "rb") as pickle_file:
lab = pickle.load(pickle_file)
with open(job_pkl, "rb") as pickle_file:
job = pickle.load(pickle_file)
payload = pattern_bytes(job["nbytes"], f"PERF-{job['label']}-".encode())
write_s, read_s = _time_write_read_once(
lab,
vixdisklib,
payload,
flags=job["flags"],
transport_mode=job["transport_mode"],
aio_buffer_size=job["aio_buffer_size"],
aio_buffer_count=job["aio_buffer_count"],
config_dir=work_dir,
)
result_path = os.path.join(work_dir, "result.json")
with open(result_path, "w", encoding="utf-8") as result_file:
json.dump({"write_s": write_s, "read_s": read_s}, result_file)
def _time_vddk_subprocess(
lab: LabEnv,
label: str,
nbytes: int,
flags: int,
transport_mode: str,
aio_buffer_size: int,
aio_buffer_count: int,
) -> tuple[float, float]:
"""Time native VDDK in a child process so InitEx sees this AIO config."""
with tempfile.TemporaryDirectory(prefix="vddk-perf-") as work_dir:
lab_pkl = os.path.join(work_dir, "lab.pkl")
job_pkl = os.path.join(work_dir, "job.pkl")
result_path = os.path.join(work_dir, "result.json")
with open(lab_pkl, "wb") as pickle_file:
pickle.dump(lab, pickle_file)
with open(job_pkl, "wb") as pickle_file:
pickle.dump(
{
"label": label,
"nbytes": nbytes,
"flags": flags,
"transport_mode": transport_mode,
"aio_buffer_size": aio_buffer_size,
"aio_buffer_count": aio_buffer_count,
},
pickle_file,
)
env = os.environ.copy()
env.pop("LD_PRELOAD", None)
pythonpath = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = _REPO if not pythonpath else f"{_REPO}:{pythonpath}"
proc = subprocess.run(
[
sys.executable,
os.path.abspath(__file__),
"--vddk-worker",
lab_pkl,
job_pkl,
work_dir,
],
check=False,
capture_output=True,
text=True,
env=env,
cwd=_REPO,
)
if proc.returncode != 0 or not os.path.exists(result_path):
raise RuntimeError(
"VDDK perf worker failed "
f"(exit {proc.returncode}): {proc.stderr}\n{proc.stdout}"
)
with open(result_path, encoding="utf-8") as result_file:
result = json.load(result_file)
return float(result["write_s"]), float(result["read_s"])
def _time_write_read(
lab: LabEnv,
module: Any,
label: str,
nbytes: int,
flags: int = 0,
transport_mode: str = "nbdssl",
aio_buffer_size: int = nfc_open.NFC_AIO_BUFFER_SIZE,
aio_buffer_count: int = nfc_open.NFC_AIO_BUFFER_COUNT,
skip_decompression: bool = False,
) -> tuple[float, float]:
"""Time one write/read; native VDDK runs in a subprocess."""
if skip_decompression and module is vixdisklib:
raise ValueError("skip_decompression is OpenVixDiskLib-only")
if module is vixdisklib:
return _time_vddk_subprocess(
lab,
label,
nbytes,
flags,
transport_mode,
aio_buffer_size,
aio_buffer_count,
)
payload = pattern_bytes(nbytes, f"PERF-{label}-".encode())
return _time_write_read_once(
lab,
module,
payload,
flags=flags,
transport_mode=transport_mode,
aio_buffer_size=aio_buffer_size,
aio_buffer_count=aio_buffer_count,
skip_decompression=skip_decompression,
)
def _mib_per_s(nbytes: int, seconds: float) -> float:
if seconds <= 0:
return float("inf")
@@ -66,32 +264,54 @@ def _mib_per_s(nbytes: int, seconds: float) -> float:
class TestCompare:
def test_write_read_throughput(self, lab: LabEnv, vddk: None) -> None:
"""Time matching write/read sizes on VDDK and openvixdisklib."""
"""Time matching write/read sizes on VDDK and openvixdisklib.
Prints ``aio_size`` / ``aio_count`` for each OPEN_SESSION
(64 KiB×1, 1 MiB×1, 2 MiB×1, 2 MiB×4). VDDK gets those via
``vixDiskLib.nfcAio.Session.BufSizeIn64KB`` / ``BufCount`` in a
fresh process per row (``VixDiskLib_Exit`` is not loop-safe).
``fastlz-skip`` is OpenVixDiskLib ``skip_decompression`` (packed
extras, no FastLZ decode); VDDK has no equivalent.
"""
libraries = (
("vddk", vixdisklib),
("openvixdisklib", open_vix),
)
transports = ("nbdssl", "nbd")
open_modes = (
("plain", 0),
("fastlz", vixdisklib.VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ),
("plain", 0, False),
("fastlz", vixdisklib.VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ, False),
(
"fastlz-skip",
vixdisklib.VIXDISKLIB_FLAG_OPEN_COMPRESSION_FASTLZ,
True,
),
)
rows: list[tuple[str, str, str, str, float, float, float, float]] = []
rows: list[tuple[str, str, int, str, str, str, float, float, float, float]] = []
for label, nbytes in _SIZES:
payload = pattern_bytes(nbytes, f"PERF-{label}-".encode())
for aio_buffer_count, aio_buffer_size in _AIO_SESSIONS:
aio_label = _aio_size_label(aio_buffer_size)
for transport_mode in transports:
for mode_name, flags in open_modes:
for mode_name, flags, skip_decompression in open_modes:
for name, module in libraries:
if skip_decompression and module is vixdisklib:
continue
write_s, read_s = _time_write_read(
lab,
module,
payload,
label,
nbytes,
flags=flags,
transport_mode=transport_mode,
aio_buffer_size=aio_buffer_size,
aio_buffer_count=aio_buffer_count,
skip_decompression=skip_decompression,
)
rows.append(
(
label,
aio_label,
aio_buffer_count,
transport_mode,
mode_name,
name,
@@ -103,12 +323,15 @@ class TestCompare:
)
print()
print(
f"{'size':<14} {'transport':<10} {'flags':<8} {'library':<16} "
f"{'size':<14} {'aio_size':<8} {'aio_count':>9} "
f"{'transport':<10} {'flags':<12} {'library':<16} "
f"{'write_s':>10} {'read_s':>10} "
f"{'write_MiB/s':>12} {'read_MiB/s':>12}"
)
for (
label,
aio_label,
aio_buffer_count,
transport_mode,
mode_name,
name,
@@ -118,7 +341,16 @@ class TestCompare:
read_r,
) in rows:
print(
f"{label:<14} {transport_mode:<10} {mode_name:<8} {name:<16} "
f"{label:<14} {aio_label:<8} {aio_buffer_count:>9} "
f"{transport_mode:<10} {mode_name:<12} {name:<16} "
f"{write_s:10.3f} {read_s:10.3f} "
f"{write_r:12.1f} {read_r:12.1f}"
)
if __name__ == "__main__":
if sys.argv[1:2] == ["--vddk-worker"]:
_, _, lab_pkl, job_pkl, work_dir = sys.argv
_run_vddk_worker(lab_pkl, job_pkl, work_dir)
else:
raise SystemExit("usage: test_compare.py --vddk-worker LAB JOB DIR")