#3002·psutil

[Windows] disk_io_counters() returns read_time/write_time in seconds instead of milliseconds

Author: markuslfCreated Sep 18, 2026Updated Sep 18, 2026
Labelsbugwindows

Summary

  • OS: Windows Server 2025
  • Architecture: 64bit
  • Psutil version: 7.2.2 (the code is unchanged on master)
  • Python version: 3.14.0
  • Type: core

Description

Expected: read_time and write_time are in milliseconds, as documented for all platforms.

Actual: on Windows they are in whole seconds (truncated), so they are 1000 times too small. Any latency derived from them, such as delta(read_time) / delta(read_count), comes out as 0 on Windows.

Cause: psutil/arch/windows/disk.c divides DISK_PERFORMANCE.ReadTime/WriteTime by 10000000. These members count 100-nanosecond units, so that division yields seconds:

  • The WDK documentation of DISK_PERFORMANCE (ntdddisk.h) says: "Contains a cumulative time, expressed in increments of 100 nanoseconds".
  • The WDK diskperf sample driver converts its performance-counter ticks into these units with * 10000000 / Frequency before returning them.

The divisor came in with f6b064e9 for #1012. It was chosen to match perfmon's "Avg. Disk sec/Read", which is a counter in seconds.

Proposed fix

Code today (wrong), in psutil/arch/windows/disk.c:

c
            // convert to ms:
            // https://github.com/giampaolo/psutil/issues/1012
            (unsigned long long)(diskPerformance->ReadTime.QuadPart)
                / 10000000,
            (unsigned long long)(diskPerformance->WriteTime.QuadPart)
                / 10000000

Code (corrected):

c
            // DISK_PERFORMANCE counts 100 ns units, convert to ms
            (unsigned long long)(diskPerformance->ReadTime.QuadPart)
                / 10000,
            (unsigned long long)(diskPerformance->WriteTime.QuadPart)
                / 10000

This is a visible change for Windows users: the values grow by a factor of 1000, to what the documentation has always promised. It may be worth a note in the changelog.

Reproducer: 20 s of synchronous, unbuffered 4 KiB reads (queue depth 1) against \\.\PhysicalDrive0, reading IOCTL_DISK_PERFORMANCE and psutil.disk_io_counters(perdisk=True)['PhysicalDrive0'] before and after (script below, needs an elevated prompt):

value
time spent inside ReadFile() 19.23 s
ReadTime delta (raw IOCTL) 172808241, i.e. 17.28 s as 100 ns units
ReadCount delta (raw IOCTL) 261702
psutil read_time delta 18 (should be about 17280; 18 instead of 17 because both totals are truncated to whole seconds)
psutil read_count delta 261702

Only a 100 ns unit fits the raw value: read as microseconds (172.81 s) or milliseconds (172808 s), the reads would have taken longer than the 20 s the whole measurement ran.

Measurement script
python
"""Measure the unit of DISK_PERFORMANCE.ReadTime independently of perfmon.

Issues synchronous, unbuffered 4 KiB reads (queue depth 1) against
PhysicalDrive0 for a fixed time and sums the wall time each ReadFile() call
took. With queue depth 1, the disk-side read time can only be a bit smaller
than that sum. Compare it against the ReadTime delta from IOCTL_DISK_PERFORMANCE
(raw) and against psutil's read_time delta.
"""

import ctypes
import random
import struct
import time
from ctypes import wintypes

import psutil

k32 = ctypes.WinDLL('kernel32', use_last_error=True)
k32.CreateFileW.restype = wintypes.HANDLE
k32.CreateFileW.argtypes = [
    wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD, wintypes.LPVOID,
    wintypes.DWORD, wintypes.DWORD, wintypes.HANDLE,
]
k32.DeviceIoControl.argtypes = [
    wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
    wintypes.LPVOID, wintypes.DWORD, ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID,
]
k32.ReadFile.argtypes = [
    wintypes.HANDLE, wintypes.LPVOID, wintypes.DWORD,
    ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID,
]
k32.SetFilePointerEx.argtypes = [
    wintypes.HANDLE, ctypes.c_longlong,
    ctypes.POINTER(ctypes.c_longlong), wintypes.DWORD,
]

GENERIC_READ = 0x80000000
FILE_SHARE_READ_WRITE = 0x3
OPEN_EXISTING = 3
FILE_FLAG_NO_BUFFERING = 0x20000000
IOCTL_DISK_PERFORMANCE = 0x70020  # CTL_CODE(IOCTL_DISK_BASE, 0x0008, BUFFERED, ANY)

h = k32.CreateFileW(r'\\.\PhysicalDrive0', GENERIC_READ, FILE_SHARE_READ_WRITE,
                    None, OPEN_EXISTING, FILE_FLAG_NO_BUFFERING, None)
assert h != wintypes.HANDLE(-1).value, ctypes.get_last_error()


def perf():
    buf = ctypes.create_string_buffer(88)
    ret = wintypes.DWORD()
    ok = k32.DeviceIoControl(h, IOCTL_DISK_PERFORMANCE, None, 0, buf, 88,
                             ctypes.byref(ret), None)
    assert ok, ctypes.get_last_error()
    br, bw, rt, wt, it, rc, wc, qd, sc, qt = struct.unpack_from('<qqqqqIIIIq', buf.raw)
    return {'ReadTime': rt, 'ReadCount': rc, 'QueryTime': qt}


def ps():
    c = psutil.disk_io_counters(perdisk=True)['PhysicalDrive0']
    return c.read_time, c.read_count


# aligned buffer for unbuffered I/O
raw = ctypes.create_string_buffer(4096 * 2)
addr = (ctypes.addressof(raw) + 4095) & ~4095
disk_bytes = 50 * 1024**3
got = wintypes.DWORD()

a, pa = perf(), ps()
user = 0.0
n = 0
end = time.perf_counter() + 20
while time.perf_counter() < end:
    off = random.randrange(0, disk_bytes // 4096) * 4096
    k32.SetFilePointerEx(h, off, None, 0)
    t0 = time.perf_counter()
    assert k32.ReadFile(h, addr, 4096, ctypes.byref(got), None), ctypes.get_last_error()
    user += time.perf_counter() - t0
    n += 1
b, pb = perf(), ps()

d_raw = b['ReadTime'] - a['ReadTime']
d_count = b['ReadCount'] - a['ReadCount']
print(f'reads issued by this script       : {n}')
print(f'time spent inside ReadFile()      : {user:.2f} s')
print(f'ReadCount delta (raw IOCTL)       : {d_count}')
print(f'ReadTime delta (raw IOCTL)        : {d_raw}')
print(f'  read as 100 ns units            : {d_raw / 1e7:.2f} s')
print(f'  read as microseconds            : {d_raw / 1e6:.2f} s')
print(f'  read as milliseconds            : {d_raw / 1e3:.2f} s')
print(f'psutil read_count delta           : {pb[1] - pa[1]}')
print(f'psutil read_time delta            : {pb[0] - pa[0]} (documented unit: ms)')