Windows tells you a 10 Gbps USB device is running at 480 Mbps. It is off by 20.8x, and there is no UI anywhere in Windows that will tell you otherwise.
usbspeed is a single zero-dependency PowerShell script that reads the real negotiated link speed of every USB device on your machine, draws the hub tree, and tells you which devices are plugged into a port that is holding them back.
It is read-only. It never resets a port, never cycles power, never writes to the registry, and never touches a device. See Read-only, and how that is proved.
Every tool that reports USB speed on Windows -- Device Manager, Get-PnpDevice, USBView, WMI -- ultimately calls IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX. That structure's Speed field is a single byte with four values:
Speed |
meaning | link rate |
|---|---|---|
| 0 | UsbLowSpeed | 1.5 Mbps |
| 1 | UsbFullSpeed | 12 Mbps |
| 2 | UsbHighSpeed | 480 Mbps |
| 3 | UsbSuperSpeed | 5 Gbps |
It stops at 3, and in practice it stops at 2. On the machine this was developed on, a USB 3.1 Gen 2 hub negotiated at SuperSpeedPlus, 10 Gbps, and that ioctl reported Speed = 2 -- High-Speed, 480 Mbps. Not 5 Gbps. Not "unknown". It reported the USB 2.0 rate for a link running twenty times faster.
The truth is sitting in a second ioctl, IOCTL_USB_GET_NODE_CONNECTION_INFORMATION_EX_V2, which returns a flags word:
| bit | meaning |
|---|---|
| 0 | DeviceIsOperatingAtSuperSpeedOrHigher |
| 1 | DeviceIsSuperSpeedCapableOrHigher |
| 2 | DeviceIsOperatingAtSuperSpeedPlusOrHigher |
| 3 | DeviceIsSuperSpeedPlusCapableOrHigher |
Bits 0 and 2 are the actual link state. Bits 1 and 3 are what the device could do. No Windows UI shows any of them. usbspeed shows all four.
_EX_V2 returns ERROR_INVALID_PARAMETER (87) on every single call unless the caller sets SupportedUsbProtocols.Usb300 -- bit 2, value 0x04 -- in the input buffer at offset 8. Pass zeroes and it fails forever, which reads exactly like "this machine does not support it". A buffer-size sweep finds nothing, because size was never the problem. That one write is the whole difference between this tool and Device Manager.
Real output from the development machine (a Surface Book 3), unedited:
USB Root Hub (USB 3.0) (RootHub, 18 ports)
|- port 2: Generic USB Hub [0x05E3:0x0610]
| High-Speed -> 480 Mbps
| INTERNAL-BELOW: SuperSpeed-capable, running at USB 2.0 speed on an internal
| port (normal for the 2.0 half of a USB 3 hub chip)
| \- port 2: Surface USB Hub [0x045E:0x0C14]
| \- port 3: USB Composite Device [0x1532:0x0099]
| Full-Speed -> 12 Mbps
|- port 9: Xbox Wireless Adapter for Windows [0x045E:0x091E]
|- port 10: Intel(R) Wireless Bluetooth(R) [0x8087:0x0026]
\- port 14: Generic SuperSpeed USB Hub [0x05E3:0x0625]
SuperSpeedPlus (Windows reports High-Speed) -> 10 Gbps
Where Windows understates the link speed:
* Generic SuperSpeed USB Hub [0x05E3:0x0625]: Windows says 480 Mbps, actually 10 Gbps (20.8x)
* Generic SuperSpeed USB Hub [0x045E:0x0C15]: Windows says 480 Mbps, actually 10 Gbps (20.8x)
No device is running below its capability on a port you can reach.
(The tool draws real box-drawing characters; they are transliterated to ASCII above so they survive GitHub's markdown. The tool does the same thing automatically if your console encoding cannot represent them -- see Encoding.)
- Your external SSD is slow. Is it the drive, or did you plug a 10 Gbps enclosure into a port that negotiated USB 2.0? Windows will tell you "480 Mbps" either way.
usbspeedtells you whether the device is capable of more. - Your capture card drops frames. A capture card on a High-Speed link cannot carry 4K60. This tells you in one line whether the link is the problem.
- Your USB microphone crackles. Same question, same answer.
- You have a front-panel USB-C port and a rear one and no idea which is which. The tool reports the connector properties, including whether the port is USB-C and which port is its companion on the other bus.
- You want to know what your dock is actually doing. It draws the full tree, so you can see which hub is between you and the device.
powershell.exe -NoProfile -ExecutionPolicy Bypass -File usbspeed.ps1
Use that exact invocation. Most Windows machines ship with an execution policy that refuses to run .ps1 files. -ExecutionPolicy Bypass is process-scoped: it applies to that one PowerShell process and exits with it. It changes no setting on your machine, and this tool will never ask you to change one.
| flag | effect |
|---|---|
| (none) | draw the tree, list understated links, list actionable problems |
-All |
also show every port, including empty ones |
-Info |
diagnostics: per-hub ioctl availability, port-count agreement between two independent ioctls, V2 coverage |
-Json |
emit the whole model as JSON on stdout and nothing else |
-FromJson <path> |
re-render a previously captured -Json file instead of reading hardware |
-Quiet |
suppress commentary, keep findings |
-NoRun |
define the functions and return without doing anything (for test harnesses) |
-Json plus -FromJson means you can capture a machine's USB topology, send the file to someone else, and have them see exactly what you see. It is also how the test suite proves the renderer is deterministic: live -> snap1 -> snap2 -> snap3 produces snap2 byte-identical to snap3.
It works without administrator rights. Elevation is not required and is not requested.
The obvious rule -- "SuperSpeed-capable but not operating at SuperSpeed means something is wrong" -- is wrong, and it fires constantly.
Every USB 3 hub chip enumerates as two logical devices: a USB 2.0 hub and a SuperSpeed hub. The 2.0 half is supposed to run at High-Speed. It is capability-flagged as SuperSpeed-capable because the silicon is, but it is not a fault, it is the design. A tool that flags it reports a fault on every USB 3 hub in the machine, including ones soldered to the motherboard that you could not change if you wanted to.
The discriminator is PortIsUserConnectable, from IOCTL_USB_GET_PORT_CONNECTOR_PROPERTIES. If you cannot physically reach the connector, moving a cable is not a remedy, so it is not an actionable finding.
BELOW-CAPABILITY (actionable) = SuperSpeed-capable AND not operating at SuperSpeed
AND connected OK AND the port is user-connectable
INTERNAL-BELOW (informational) = the same thing on a port you cannot reach
A near-miss worth recording: the intuitive alternative is to compare the device's bcdUSB. It does not work. A SuperSpeed device that has fallen back to a USB 2.0 link reports bcdUSB = 0x0210 by specification -- 0x0210 means "has a BOS descriptor", not "is a USB 2.1 device" -- which is byte-identical to what a plain USB 2.1 device reports. The field cannot distinguish the two cases, and building the classifier on it would have produced a tool that was confidently wrong.
USB hub ioctls include USB_HUB_CYCLE_PORT and USB_RESET_HUB. Calling either one power-cycles or resets live hardware. usbspeed calls neither, and the test suite enforces it three ways:
- Source scan.
selftest.ps1greps the source for the function codes of every mutating USB ioctl and fails the build if one appears. - Snapshot equality.
realcheck.ps1capturesHKLM\SYSTEM\CurrentControlSet\Enum\USBand the full present-device list before and after a complete run and requires both to be byte-identical. - Repeatability. Two full runs back to back must produce the same topology.
There is no -Fix mode, no undo journal, and nothing to undo. That is the correct design for this tool and it is stated here rather than implied.
Three suites ship with the tool. All three are in the repository and all three pass on the development machine.
Synthetic ioctl output with a different distinctive value planted in every field, so a parser that transposes two fields cannot pass. Twelve sections: field-by-field parsing, speed resolution across all flag combinations, the cry-wolf classifier, descriptor sanity (driven with the real garbage values observed on empty ports: bLength = 7, ConnectionStatus = 117440512), bcdUSB rendering, number formatting, instance-id derivation, hub records, multi-line assembly, malformed input, glyph fallback, and source hygiene.
| claim | result |
|---|---|
| fields cross-checked against an independent implementation | 28, 0 mismatches |
| negative controls (corrupted references) rejected | 44 of 44 |
| undecidable mutations, excluded and named | 5 |
| devices covered | 7 of 7 connected devices |
ports answering _EX_V2 |
34 of 34 |
| hubs where two independent ioctls agree on the port count | 6 of 6 |
The independent reference is written with a deliberately different technique: the tool reads USB topology through hub DeviceIoControl calls, while the reference walks the Configuration Manager / PnP property tree (Get-PnpDevice, DEVPKEY properties, LocationInfo strings). Two different subsystems, two different code paths, same vendor ids, product ids, hub relationships and port numbers.
The negative controls are what make "0 mismatches" mean anything. A comparison that tolerates everything reports zero mismatches against anything. So after the real comparison passes, the same comparison is replayed against deliberately corrupted values -- a vendor id with one hex digit flipped, a port number read as hex instead of decimal, a parent/child relationship inverted, a speed read from the wrong field -- and every one must be rejected. 44 of 44 were. Five could not be decided and are printed individually rather than counted as passes:
[UNDETECTABLE] 0x05E3:0x0610@2: port-as-hex (decimal and hex readings are identical for 2)
Port 2 read as hex is still 2. That mutation is genuinely undetectable, and saying so is more useful than quietly inflating the score.
Ground truth is planted inside real data. Synthetic records carrying impossible-to-collide values (0xBEEF:0xCAFE, device address 97) are appended to the genuine ioctl output and the tool must find them and must not let them leak into any real record.
The headline feature is driven end to end by real hardware. A real port's PortIsUserConnectable bit is flipped in the captured data, and the classifier must move that exact device from INTERNAL-BELOW to BELOW-CAPABILITY. The feature is not trusted because an API returned a number without erroring.
The tool's own -Json is fed back through bounds checks, including aggregate checks: per-hub port record counts must sum to the global total, device addresses must lie in the USB range 1-127, open pipe counts must be plausible, and no device may be counted twice. Individually-clamped readings that go wrong only in aggregate are a real bug class.
A passing suite only proves the tests tolerate the current code. mutate.ps1 breaks the tool deliberately, one edit at a time, and requires a suite to notice. It mutates both layers: PowerShell classification logic (judged by selftest.ps1) and the native ioctl struct offsets (judged by realcheck.ps1, because only real hardware can tell that DeviceAddress was read at the natural-alignment offset instead of the pack(1) one).
mutation score: 28/28
invalid controls (anchor did not match exactly once): 0
excluded as declared no-ops: 1
Eight of those 28 die only on real hardware. The hermetic suite cannot see them at all, which is the argument for shipping both suites rather than one.
Four rules keep the score honest:
- A baseline control. Before judging a single mutant, the unmodified source is run through the exact same runner and both suites must pass. A harness that cannot tell a healthy tool from a broken one reports a perfect score against everything -- see the note below, because that is precisely what happened here.
- Anchor validation. A mutation whose anchor text is not in the source patches nothing, runs the unmodified tool, and is reported as "killed" by a suite that never saw a mutant. Every anchor must match exactly once or the mutation is reported as an INVALID CONTROL and excluded.
- Paired edits. Redundantly defensive code produces equivalent mutants -- remove one layer and nothing observable changes. The harness applies several edits as one mutation, so the redundancy can be removed together rather than deleting the assertion that "failed".
- No-op exclusion. A mutation with no semantic effect cannot be killed by anything. It is declared, run, reported and excluded from the score instead of counted as a pass.
A mutant can hang rather than fail, so every suite run is under a timeout. That is not hypothetical: see the port-count clamp below.
Verified at reduced trust level (runas /trustlevel:0x20000), which is how most people will run it:
Hubs
RootHub ports= 5 descPorts= 5 agree=True USB Root Hub (USB 3.0)
Usb30Hub ports= 3 descPorts= 3 agree=True Generic SuperSpeed USB Hub
RootHub ports=18 descPorts=18 agree=True USB Root Hub (USB 3.0)
Usb30Hub ports= 2 descPorts= 2 agree=True Generic SuperSpeed USB Hub
Usb20Hub ports= 3 descPorts= 3 agree=True Surface USB Hub
Usb20Hub ports= 3 descPorts= 3 agree=True Generic USB Hub
ports answering INFORMATION_EX_V2 : 34 of 34
Every hub opened, every port answered, every friendly name resolved, and the 20.8x understatement was still detected. Nothing here needs elevation.
Things that cost real debugging time and are not in any documentation.
A mutation harness that cannot recognise a healthy tool reports a perfect score against everything. Start-Process -PassThru without -Wait returns a Process object whose ExitCode is $null -- even after WaitForExit() has returned $true and HasExited is $true. The harness had been switched to the no--Wait form to add a timeout, so every run read $null, $null -ne 0 evaluated to true, and all 28 mutations were reported as killed. Including a mutation that only reworded a comment. The tell was that a deliberately inert edit "failed" a suite, and that the suites were suddenly finishing far too quickly. Touching $pr.Handle before waiting makes PowerShell retain the handle and the exit code survives. The durable lesson is the reason the baseline control exists: a harness must first prove it can tell a pass from a failure, or its numbers mean nothing.
A wrong port count does not make the tool fail, it makes it hang. IOCTL_USB_GET_HUB_INFORMATION_EX returns HighestPortNumber at offset 4; read it at offset 6 and you land on the hub descriptor header, yielding 0x2909 = 10,505 ports per hub. The enumeration loop issues three ioctls per port, so six hubs became roughly 190,000 ioctls and the tool simply never returned. A USB hub descriptor stores bNumberOfPorts in a single byte, so 255 is a hard spec ceiling; enumeration is now bounded by it, the clamp is reported rather than silently truncating, and the mutation harness runs every suite under a timeout. This was found by mutation testing, not by testing -- nothing in normal operation would have revealed it.
PowerShell's -eq is case-insensitive on strings, and so are its hashtable keys. Hub path matching lowercased both sides and compared with -eq, which is three layers of case-insensitivity where one would do. Removing both .ToLowerInvariant() calls changed nothing observable, so the mutation survived -- not because the code was untested, but because it was redundant. The honest fix is to remove all the redundancy in one paired mutation (and switch -eq to -ceq), not to delete the test that "failed" to catch it.
Marshal.ReadUInt16 does not exist. System.Runtime.InteropServices.Marshal has ReadByte, ReadInt16, ReadInt32, ReadInt64 and ReadIntPtr -- no unsigned variants. ReadInt16 returns a signed short, so Intel's vendor id 0x8087 reads back as -32633 and formatting it as hex yields FFFF8087. Every 16-bit read must be masked with & 0xFFFF.
USB_NODE_CONNECTION_INFORMATION_EX is pack(1). Laying it out at natural alignment puts DeviceAddress two bytes late and produces device addresses that are exact multiples of 256. Verified offsets: ConnectionIndex@0, bLength@4, bDescriptorType@5, bcdUSB@6, idVendor@12, idProduct@14, bcdDevice@16, bNumConfigurations@21, CurrentConfigurationValue@22, Speed@23, DeviceIsHub@24, DeviceAddress@25, NumberOfOpenPipes@27, ConnectionStatus@31.
DEVPKEY_Device_Address on a USB device is the port number, not the USB bus address. The hub on port 14 reports Address = 14 while the ioctl reports USB address 1; the hub on port 2 reports Address = 2 against USB address 4. LocationInfo (Port_#0014.Hub_#0001) confirms the port reading. A field whose name is not what it means.
ContainerId cannot pair the 2.0 and SuperSpeed halves of a dual-bus device. It looks like exactly the right tool. On this machine every internal device reports the null chassis GUID {00000000-0000-0000-FFFF-FFFFFFFFFFFF}, which would have "paired" eight unrelated devices including both PCI host controllers. The reciprocal CompanionPortNumber from IOCTL_USB_GET_PORT_CONNECTOR_PROPERTIES is the field that actually works.
An empty port reports Speed = 0, which is a valid value meaning Low-Speed. Mapping it through the speed table invents a 1.5 Mbps link on every vacant connector in the machine. Any lookup table whose index 0 is a legitimate value needs an explicit "nothing is connected" guard before the lookup. The hermetic suite caught this one.
$pid is a read-only automatic variable in PowerShell, and PID is the single most natural name in a USB tool. $pid = 0x0610 throws "Cannot overwrite variable PID because it is read-only or constant." PowerShell variable names are case-insensitive, so $PID, $pid and $Pid are all the same variable.
Assigning a function's result captures its entire pipeline output, not just its return value. $script:ExitCode = Invoke-Main silently swallowed the whole -Json payload into the exit-code variable, so -Json emitted zero bytes while every other mode worked perfectly. The symptom was masked by a second trap: the harness checked @(ConvertFrom-Json '').Count and got 1, because ConvertFrom-Json emits an array as a single pipeline object. It reported a phantom success on an empty string.
Get-PnpDevice -PresentOnly with no class filter, plus a per-device Get-PnpDeviceProperty, takes minutes -- one CIM round trip per device across several hundred nodes. The name lookup here is a single registry walk of HKLM\SYSTEM\CurrentControlSet\Enum\USB instead: faster, works without elevation, no CIM.
Do not blind-scan ioctl function codes against live hardware. Two of the codes in the USB hub range reset ports and cycle hub power. Enumerating them "to see which ones respond" executes them.
The source is pure ASCII with no byte-order mark. Box-drawing characters are built from code points at runtime ([char]0x251C and friends) rather than embedded, so the file cannot be misread as ANSI by PowerShell 5.1 and cannot be corrupted in transit. At startup each glyph is round-tripped through the console's actual output encoding, and if it does not survive the tool falls back to |, \, - and +. The fallback is tested against US-ASCII (20127) and Latin-1 (28591).
Not against codepage 437 -- cp437 genuinely contains the box-drawing glyphs (they are the original IBM PC characters), so a "fallback test" against it fails for the wrong reason.
The same trap appears when capturing the tool's output to a file. A redirected child process here encoded its output as cp437; reading that file back as UTF-8 produces replacement characters and makes a perfectly correct run look corrupted. Decoding the same bytes as cp437 shows |- box drawing exactly as intended. If the output looks like mojibake, check what encoding you are reading with before changing anything.
- Windows 8 or later (the
_EX_V2ioctl was introduced in Windows 8) - Windows PowerShell 5.1, which ships in the box. No modules, no downloads, no installer.
- The embedded C# is compiled by the in-box .NET Framework compiler via
Add-Type. Nothing is fetched from the internet.
Tested on Windows 11 build 26200 (25H2), PowerShell 5.1.26100.9444.
| file | what it is |
|---|---|
usbspeed.ps1 |
the tool |
selftest.ps1 |
hermetic suite, synthetic fixtures with all-distinct ground truth |
realcheck.ps1 |
real-hardware suite, independent cross-check and negative controls |
mutate.ps1 |
mutation harness that grades the two suites above |
- obs-4k60-recorder -- OBS settings for genuine 4K60 capture
- framecheck -- find out why a recording dropped frames
- diskrate -- whether your disk was the reason
- gpucheck -- whether your GPU was the reason
- miccheck -- microphone level and drift
MIT