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
48 changes: 48 additions & 0 deletions jdm-core/docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -530,3 +530,51 @@ vs. CDM's pre-pinned buffer pool.
- `jdm-core/src/main/java/jdiskmark/Benchmark.java` — CDM grid in `toResultString()`
- `jdm-core/src/main/java/jdiskmark/RunBenchmarkCommand.java` — `--cdm` flag
- `jdm-core/src/main/java/jdiskmark/BenchmarkCallable.java` — CLI CDM execution

---

## 4. USB Drive Benchmarking (Linux / Ubuntu)

USB flash drives present several constraints not encountered with NVMe or SATA
drives. The following findings were verified on Ubuntu with a Lexar USB 3.0
flash drive formatted as vfat (FAT32).

### 4.1 Direct IO Constraints

Java's `ExtendedOpenOption.DIRECT` enforces alignment and I/O size requirements
based on `FileStore.getBlockSize()`. On vfat, this returns the **filesystem
cluster size** (e.g. 32 KB) rather than the device's logical sector size
(512 B). The Linux kernel itself only requires 512-byte sector alignment for
Direct IO — verified with `dd oflag=direct` at 4 KB and 512 B block sizes.

Consequences of the Java NIO restriction:

- **Block size ≥ cluster size**: Direct IO works when the benchmark block size
matches or exceeds the cluster size (e.g. 32 KB blocks on a 32 KB cluster
vfat volume).
- **Block size < cluster size**: Java rejects the I/O with
`"Number of remaining bytes is not a multiple of the block size"`.
The application auto-disables Direct IO for the run and notifies the user.
- **Buffer alignment**: The native memory address of the `ByteBuffer` must also
be aligned to the cluster size. `Arena.allocate(size, alignment)` with the
cluster size as alignment satisfies this.

#### Future Improvement

Bypassing Java's NIO restriction via the Foreign Function & Memory API (FFI) to call `open()` with `O_DIRECT` at the syscall level would allow Direct IO with the device's true 512-byte sector alignment, matching `dd` behaviour.

### 4.2 Sector Alignment Auto-Adjustment

When Direct IO is enabled and the filesystem cluster size exceeds the user-selected sector alignment, `BenchmarkRunner.resolveAlignment()` adjusts the effective alignment upward and updates the GUI badge to reflect the value actually used during the run.

### 4.3 SMART Diagnostics

USB flash drives do not support SMART. `smartctl` reports `"Unknown USB bridge"` and cannot query device health data. The application detects the USB bus type via `lsblk TRAN` and shows a message instead of launching the privileged `smartctl` shell.

### 4.4 Drive Model Detection

USB drives report VENDOR and MODEL as separate `lsblk` columns (e.g. VENDOR=`Lexar`, MODEL=`USB Flash Drive`), unlike NVMe drives where MODEL includes the manufacturer. `getVendorModelLinux()` combines both columns for display, while `getDeviceModelLinux()` returns MODEL only.

### 4.5 Write Performance Characteristics

With Direct IO enabled and per-sample file creation (the default benchmark pattern), write throughput on vfat is significantly lower than raw device capability. Each new file requires synchronous FAT table metadata updates that bypass the OS write cache. Verified with `dd oflag=direct` writing to a single file at 47 MB/s versus the benchmark pattern at ~0.5 MB/s.
2 changes: 1 addition & 1 deletion jdm-core/docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ BenchmarkWorker, DiskUsageInfo, GcDetector, RenderFrequencyMode,
Sample, Smart, SmartSnapshot,
GcRetriedSamplesConverter, SampleAttributeConverter, LocalDateTimeAttributeConverter

**io** — DriveAccessChecker, UtilOs (abstract interface)
**io** — DriveChecker, UtilOs (abstract interface)
**io.win / io.mac / io.linux** — platform implementations (split from UtilOs)

**cli** — Cli, RunBenchmarkCommand, VersionProvider
Expand Down
21 changes: 15 additions & 6 deletions jdm-core/src/main/java/jdiskmark/App.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
package jdiskmark;

import static jdiskmark.DriveAccessChecker.validateTargetDirectory;
import static jdiskmark.DriveChecker.validateTargetDirectory;

import picocli.CommandLine;
import java.io.File;
Expand Down Expand Up @@ -106,6 +106,7 @@ public enum SectorAlignment {
ALIGN_4K(4096, "4 KB (Standard)"),
ALIGN_8K(8192, "8 KB (Enterprise)"),
ALIGN_16K(16384, "16 KB (High-End)"),
ALIGN_32K(32768, "32 KB (FAT32)"),
ALIGN_64K(65536, "64 KB (RAID/Stripe)");

public final int bytes;
Expand Down Expand Up @@ -139,6 +140,7 @@ public String toString() {
public static String arch;
public static String processorName;
public static String jdk;
public static String osLabel;
// PII: OS username collection removed (#117 — use anonymous or a non-PII system
// id instead).
// public static String username;
Expand Down Expand Up @@ -382,6 +384,8 @@ public static void init() {
arch = System.getProperty("os.arch");
processorName = Util.getProcessorName();
jdk = Util.getJvmInfo();
osLabel = isLinux() ? UtilOs.getLinuxDistroName() : null;
if (osLabel == null) osLabel = os;

checkPermission();
if (!APP_CACHE_DIR.exists()) {
Expand Down Expand Up @@ -974,16 +978,21 @@ public static void startBenchmark() {
return;
}

// 3. update state
// 3. check enough disk space for the configured benchmark
if (!DriveChecker.checkDiskSpace(locationDir)) {
return;
}

// 4. update state
state = State.DISK_TEST_STATE;
if (mode == Mode.GUI) {
Gui.mainFrame.adjustSensitivity();
}

// 4. create data dir reference
// 5. create data dir reference
dataDir = new File(locationDir.getAbsolutePath() + File.separator + DATADIRNAME);

// 5. remove existing test data if present (recursive — File.delete() only
// 6. remove existing test data if present (recursive — File.delete() only
// removes empty dirs)
if (autoRemoveData && dataDir.exists()) {
boolean removed = Util.deleteDirectory(dataDir);
Expand All @@ -996,12 +1005,12 @@ public static void startBenchmark() {
}
}

// 6. create data dir if not already present
// 7. create data dir if not already present
if (dataDir.exists() == false) {
dataDir.mkdirs();
}

// 7. start benchmark job thread
// 8. start benchmark job thread
switch (mode) {
case GUI -> {
worker = new BenchmarkWorker();
Expand Down
27 changes: 19 additions & 8 deletions jdm-core/src/main/java/jdiskmark/Benchmark.java
Original file line number Diff line number Diff line change
Expand Up @@ -308,15 +308,26 @@ static List<Benchmark> findAll() {
@JsonIgnore
static int deleteAll() {
EntityManager em = EM.getEntityManager();
em.getTransaction().begin();
int deletedOperationsCount = em.createQuery("DELETE FROM BenchmarkOperation").executeUpdate();
int deletedBenchmarksCount = em.createQuery("DELETE FROM Benchmark").executeUpdate();
if (App.verbose) {
App.msg("deletedOperations=" + deletedOperationsCount);
App.msg("deletedBenchmarks=" + deletedBenchmarksCount);
// If a prior operation left a transaction open, roll it back first.
if (em.getTransaction().isActive()) {
em.getTransaction().rollback();
}
try {
em.getTransaction().begin();
int deletedOperationsCount = em.createQuery("DELETE FROM BenchmarkOperation").executeUpdate();
int deletedBenchmarksCount = em.createQuery("DELETE FROM Benchmark").executeUpdate();
if (App.verbose) {
App.msg("deletedOperations=" + deletedOperationsCount);
App.msg("deletedBenchmarks=" + deletedBenchmarksCount);
}
em.getTransaction().commit();
return deletedBenchmarksCount;
} catch (Exception e) {
if (em.getTransaction().isActive()) {
em.getTransaction().rollback();
}
throw e;
}
em.getTransaction().commit();
return deletedBenchmarksCount;
}

@JsonIgnore
Expand Down
77 changes: 73 additions & 4 deletions jdm-core/src/main/java/jdiskmark/BenchmarkRunner.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@

import static jdiskmark.GcDetector.MAX_GC_RETRIES;

import java.io.File;

import java.util.logging.Level;
import java.util.logging.Logger;
import java.time.LocalDateTime;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.lang.foreign.MemoryLayout;
import java.lang.foreign.ValueLayout;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
Expand Down Expand Up @@ -54,6 +58,7 @@ private interface IOAction {
final LongAdder readUnitsComplete = new LongAdder();
long unitsTotal;
long blockSize;
long effectiveAlignment;
byte[] blockArr; // for legacy jdk io

public static int[][] divideIntoRanges(int startIndex, int endIndex, int numThreads) {
Expand Down Expand Up @@ -102,6 +107,7 @@ public Benchmark execute() throws Exception {
unitsTotal = wUnitsTotal + rUnitsTotal;

blockSize = config.blockSize;
effectiveAlignment = resolveAlignment(config);

if (config.ioEngine == IoEngine.LEGACY) {
blockArr = new byte[(int)blockSize];
Expand All @@ -110,10 +116,10 @@ public Benchmark execute() throws Exception {
}
}

//TODO: use config if possible
String driveModel = Util.getDriveModel(App.locationDir);
String partitionId = Util.getPartitionId(App.locationDir.toPath());
DiskUsageInfo usageInfo = Util.getDiskUsage(App.locationDir.toString());
File configDir = new File(config.testDir);
String driveModel = Util.getDriveModel(configDir);
String partitionId = Util.getPartitionId(configDir.toPath());
DiskUsageInfo usageInfo = Util.getDiskUsage(configDir.getAbsolutePath());

// Initialize Benchmark

Expand Down Expand Up @@ -381,6 +387,7 @@ private void mapEnvironment(Benchmark b, String model, String partId, DiskUsageI
b.systemInfo.os = App.os;
b.systemInfo.arch = App.arch;
b.systemInfo.jdk = App.jdk;
b.systemInfo.osLabel = App.osLabel;
b.systemInfo.locationDir = App.locationDir.toString();

b.driveInfo.driveModel = model;
Expand All @@ -389,4 +396,66 @@ private void mapEnvironment(Benchmark b, String model, String partId, DiskUsageI
b.driveInfo.usedGb = u.usedGb;
b.driveInfo.totalGb = u.totalGb;
}

/**
* Determines the effective byte alignment for Direct IO. When Direct IO is
* enabled, the alignment must be at least the filesystem block size or the
* write/read will fail with an {@code IOException}. This is critical for
* vfat (FAT32) which typically uses 32 KB blocks.
*/
private static long resolveAlignment(BenchmarkConfig config) {
long userAlign = config.sectorAlignment.bytes;
if (userAlign <= 0) {
return MemoryLayout.sequenceLayout(
config.blockSize, ValueLayout.JAVA_BYTE).byteAlignment();
}
if (config.getDirectIoEnabled() == null || !config.getDirectIoEnabled()) {
return userAlign;
}
try {
long fsBlockSize = java.nio.file.Files.getFileStore(
Path.of(config.testDir)).getBlockSize();

// Java's ExtendedOpenOption.DIRECT enforces that every I/O
// transfer is a multiple of FileStore.getBlockSize(), which on
// vfat returns the cluster size (e.g. 32 KB) rather than the
// device sector size (512 B). The Linux kernel itself only
// requires sector-size alignment, but Java's NIO layer is more
// restrictive. When the user-chosen block size is smaller than
// the cluster size, disable Direct IO upfront and inform the
// user rather than silently retrying on every sample.
if (config.blockSize < fsBlockSize) {
App.msg("Direct I/O disabled: block size ("
+ (config.blockSize / 1024) + " KB) is smaller than "
+ "the filesystem block size ("
+ (fsBlockSize / 1024) + " KB).");
config.setDirectIoEnabled(false);
App.directEnable = false;
Gui.refreshChartBadges();
return userAlign;
}

if (fsBlockSize > userAlign) {
App.msg("Direct I/O: adjusting alignment from "
+ userAlign + " to " + fsBlockSize
+ " (filesystem block size)");
// Update config so the benchmark record reflects
// the actual alignment used during this run.
for (App.SectorAlignment sa : App.SectorAlignment.values()) {
if (sa.bytes == fsBlockSize) {
config.setSectorAlignment(sa);
// Also update the live global so the GUI badge
// shows the effective alignment during this run.
App.sectorAlignment = sa;
Gui.refreshChartBadges();
break;
}
}
return fsBlockSize;
}
} catch (Exception e) {
logger.log(Level.WARNING, "Could not query filesystem block size", e);
}
return userAlign;
}
}
3 changes: 3 additions & 0 deletions jdm-core/src/main/java/jdiskmark/BenchmarkSystemInfo.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ public class BenchmarkSystemInfo implements Serializable {
@Column
String locationDir;
public String getLocationDir() { return locationDir; }
@Column
String osLabel;
public String getOsLabel() { return osLabel; }

public BenchmarkSystemInfo() {}
}
14 changes: 11 additions & 3 deletions jdm-core/src/main/java/jdiskmark/BenchmarkWorker.java
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,17 @@ protected Benchmark doInBackground() throws Exception {

if (App.autoSave) {
EntityManager em = EM.getEntityManager();
em.getTransaction().begin();
em.persist(benchmark);
em.getTransaction().commit();
try {
em.getTransaction().begin();
em.persist(benchmark);
em.getTransaction().commit();
} catch (Exception e) {
Logger.getLogger(BenchmarkWorker.class.getName())
.log(Level.SEVERE, "Failed to save benchmark to DB", e);
if (em.getTransaction().isActive()) {
em.getTransaction().rollback();
}
}
Comment thread
jamesmarkchan marked this conversation as resolved.
}
// #67 upload to community portal (in progress)
// Run asynchronously so the benchmark result returns to the UI immediately
Expand Down
62 changes: 0 additions & 62 deletions jdm-core/src/main/java/jdiskmark/DriveAccessChecker.java

This file was deleted.

Loading
Loading