Skip to content

Commit 5eb4397

Browse files
authored
Merge pull request #24 from redhat-performance/feature/fix-fio-field-limit-remove-per-job
Fix FIO field count exceeding OpenSearch 5,000 field limit
2 parents 670ee45 + 1c81e1d commit 5eb4397

6 files changed

Lines changed: 300 additions & 7 deletions

File tree

.gitignore

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,14 @@ temp_*/
3838

3939
# Config files (credentials)
4040
config/export_config.yml
41-
src/chronicler/config/export_config.yml
41+
src/chronicler/config/export_config.yml
42+
43+
# Temporary analysis files (issue #19)
44+
FIELD_COUNT_ANALYSIS.md
45+
IMPLEMENTATION_PLAN_RPOPC-1273.md
46+
analyze_run_fields.py
47+
count_fields_by_section.py
48+
show_metrics_structure.py
49+
verify_field_count.py
50+
backups/
51+
sample_data/

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ podman run --rm \
230230
|-----------|------------------------|-----------|-------|
231231
| CoreMark | Supported | `coremark_processor.py` | Single-thread CPU performance |
232232
| CoreMark Pro | Supported | `coremark_pro_processor.py` | 9 workload types |
233-
| FIO | Supported | `fio_processor.py` | Flexible I/O tester |
233+
| FIO | Supported | `fio_processor.py` | Flexible I/O tester (see [per-job data docs](docs/fio-per-job-data.md)) |
234234
| HPL (autohpl) | Supported | `autohpl_processor.py` | High Performance Computing Linpack |
235235
| Passmark | Supported | `passmark_processor.py` | CPU & Memory marks |
236236
| Phoronix Test Suite | Supported | `phoronix_processor.py` | 51 sub-tests (BOPs) |

docs/fio-per-job-data.md

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# FIO Per-Job Data: OpenSearch vs Raw Archives
2+
3+
## Overview
4+
5+
FIO benchmark results contain both **aggregated metrics** (totals across all jobs/disks) and **per-job breakdown** (individual disk performance). Due to OpenSearch's 5,000 field limit, only aggregated data is exported to the `zathras-results` index. Per-job data remains available in the raw JSON archives.
6+
7+
## What's in OpenSearch (zathras-results)
8+
9+
Each FIO run in OpenSearch contains:
10+
11+
### Aggregated Metrics
12+
- **Bandwidth**: total_bandwidth_kbps (min/max/mean)
13+
- **IOPS**: total_iops (min/max/mean)
14+
- **Latency**: avg_latency_mean_ns, avg_clat_mean_ns, avg_slat_mean_ns (with min/max/stddev)
15+
- **Latency percentiles**: p1, p5, p10, p50, p90, p95, p99, p99.5, p99.9
16+
- **I/O totals**: total_io_bytes, total_ios
17+
- **CPU**: avg_cpu_usr_pct, avg_cpu_sys_pct
18+
- **Metadata**: num_jobs, num_disks
19+
20+
### Timeseries Summary
21+
- Statistical summary of timeseries data: count, mean, min, max, stddev
22+
23+
### Configuration
24+
- All FIO test parameters and settings
25+
26+
**Use OpenSearch for**: Aggregate performance trends, run comparisons, dashboard visualizations
27+
28+
## What's in Raw JSON Archives
29+
30+
The full JSON documents (not exported to OpenSearch) additionally contain:
31+
32+
### Per-Job Details (`metrics.jobs` array)
33+
34+
For **each disk/job**:
35+
- Job metadata: job_number, jobname, device path, elapsed_seconds
36+
- Read metrics (if read test):
37+
- Bandwidth: kbps, min, max, mean, stddev, aggregate %
38+
- IOPS: value, min, max, mean, stddev
39+
- Latency: mean, min, max, stddev (regular + clat + slat)
40+
- **Latency percentiles**: p1, p5, p10, p50, p90, p95, p99, p99.5, p99.9
41+
- I/O: bytes, count, runtime
42+
- CPU: usr%, sys%
43+
- **I/O depth distribution**: % at depth 1, 2, 4, 8, 16, 32, 64+
44+
- **Latency distribution buckets**: microsecond and millisecond ranges
45+
- Write metrics (if write test): same structure
46+
- Mixed metrics (if mixed test): both read and write
47+
48+
### Full Timeseries Data
49+
- Every timeseries point with timestamp and metrics
50+
- Available in separate `zathras-timeseries` index (if enabled)
51+
52+
**Use raw JSON for**: Per-disk analysis, identifying slow disks, latency distribution analysis
53+
54+
## Accessing Per-Job Data
55+
56+
### Method 1: Direct File Read
57+
58+
Raw JSON documents are stored in the same location as the benchmark archives:
59+
60+
```python
61+
import json
62+
from pathlib import Path
63+
64+
# Load the full document
65+
json_path = Path("/path/to/archive/fio-results.json")
66+
with open(json_path) as f:
67+
doc = json.load(f)
68+
69+
# Access per-job data for a specific run
70+
jobs = doc["results"]["runs"]["run_0"]["metrics"]["jobs"]
71+
72+
for job in jobs:
73+
print(f"Device: {job['device']}")
74+
print(f" Bandwidth: {job['read']['bandwidth_kbps']} kbps")
75+
print(f" IOPS: {job['read']['iops']}")
76+
print(f" P99 Latency: {job['read']['latency_percentiles']['p99']} ns")
77+
```
78+
79+
### Method 2: Programmatic Access (Python API)
80+
81+
```python
82+
from chronicler.processors.fio_processor import FioProcessor
83+
84+
# Process with full detail
85+
processor = FioProcessor("/path/to/benchmark/archive")
86+
document = processor.process()
87+
88+
# Get full dict (includes per-job data)
89+
full_dict = document.to_dict()
90+
91+
# Access per-job data
92+
jobs = full_dict["results"]["runs"]["run_0"]["metrics"]["jobs"]
93+
```
94+
95+
### Method 3: Query Pattern for Analysis
96+
97+
Example script to find slow disks across multiple test runs:
98+
99+
```python
100+
import json
101+
from pathlib import Path
102+
103+
def find_slow_disks(json_path, p99_threshold_ns=500_000):
104+
"""Find disks with p99 latency above threshold."""
105+
with open(json_path) as f:
106+
doc = json.load(f)
107+
108+
slow_disks = []
109+
for run_key, run_data in doc["results"]["runs"].items():
110+
if "metrics" not in run_data or "jobs" not in run_data["metrics"]:
111+
continue
112+
113+
for job in run_data["metrics"]["jobs"]:
114+
device = job.get("device", "unknown")
115+
read_data = job.get("read", {})
116+
p99 = read_data.get("latency_percentiles", {}).get("p99")
117+
118+
if p99 and p99 > p99_threshold_ns:
119+
slow_disks.append({
120+
"run": run_key,
121+
"device": device,
122+
"p99_latency_ns": p99,
123+
"bandwidth_kbps": read_data.get("bandwidth_kbps"),
124+
"iops": read_data.get("iops"),
125+
})
126+
127+
return slow_disks
128+
129+
# Usage
130+
slow = find_slow_disks("fio-results.json", p99_threshold_ns=500_000)
131+
for disk in slow:
132+
print(f"{disk['device']} in {disk['run']}: p99={disk['p99_latency_ns']}ns")
133+
```
134+
135+
## Why Not Store Per-Job Data in OpenSearch?
136+
137+
### Design Decision
138+
139+
FIO was the only benchmark that stored per-instance breakdown in OpenSearch. Other benchmarks (CoreMark, Passmark, Uperf) follow an aggregated approach:
140+
- **CoreMark**: Aggregate across threads, not per-thread
141+
- **Passmark**: Aggregate across iterations, not per-iteration
142+
- **Uperf**: Aggregate across workers, not per-worker
143+
144+
To maintain consistency and stay within OpenSearch's 5,000 field limit, FIO now follows the same pattern.
145+
146+
### Field Count Impact
147+
148+
**With per-job data** (48 runs, 1 job each):
149+
- Fields: ~6,632
150+
- Status: ❌ Exceeds 5,000 limit
151+
152+
**Without per-job data**:
153+
- Fields: ~3,176
154+
- Status: ✅ Under 5,000 limit (36% headroom)
155+
156+
### Future: Separate Per-Job Index
157+
158+
If per-job querying in OpenSearch becomes a frequent need, a separate `zathras-fio-job-timeseries` index could be implemented (similar to how general timeseries data is handled). See [GitHub issue #19](https://github.com/redhat-performance/chronicler/issues/19) for discussion.
159+
160+
## Summary
161+
162+
| Data Type | OpenSearch | Raw JSON |
163+
|-----------|------------|----------|
164+
| Aggregated metrics |||
165+
| Timeseries summary |||
166+
| Configuration |||
167+
| Per-job breakdown |||
168+
| Full timeseries |||
169+
170+
**For most analysis**: Use OpenSearch (fast queries, dashboards)
171+
**For per-disk troubleshooting**: Use raw JSON archives (full granularity)

src/chronicler/processors/README.md

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ BaseProcessor (abstract)
2929
├── UperfProcessor
3030
├── PigProcessor
3131
├── AutoHPLProcessor
32-
└── SpecCPU2017Processor
32+
├── SpecCPU2017Processor
33+
└── FioProcessor
3334
```
3435

3536
### Data Flow
@@ -417,6 +418,56 @@ Results:
417418

418419
---
419420

421+
### 12. FIO (`fio_processor.py`)
422+
423+
**Benchmarks:** Flexible I/O Tester - disk performance
424+
425+
**Key Features:**
426+
- Parses multiple workload runs (different I/O patterns)
427+
- Extracts bandwidth, IOPS, latency metrics aggregated across all jobs
428+
- Stores latency percentiles (p1, p5, p10, p50, p90, p95, p99, p99.5, p99.9)
429+
- **Per-job breakdown removed from OpenSearch** (available in raw JSON)
430+
431+
**Data Structure:**
432+
```python
433+
Run:
434+
metrics:
435+
# Aggregated across all jobs/disks
436+
total_bandwidth_kbps: 1000000
437+
total_iops: 250000
438+
avg_latency_mean_ns: 134845
439+
avg_clat_mean_ns: 131462
440+
avg_slat_mean_ns: 3382
441+
# Latency percentiles (aggregate)
442+
avg_latency_p1_ns: 72192
443+
avg_latency_p50_ns: 128512
444+
avg_latency_p99_ns: 259072
445+
# Metadata
446+
num_jobs: 8
447+
num_disks: 8
448+
timeseries_summary:
449+
count: 120
450+
mean: 473231.0
451+
min: 465628.0
452+
max: 490332.0
453+
configuration:
454+
operation: "read"
455+
block_size: "4k"
456+
iodepth: 16
457+
```
458+
459+
**Field Count:** ~3,200 fields for 48 runs (well under 5,000 limit)
460+
461+
**Design Decision:**
462+
- FIO originally stored per-job breakdown (`metrics.jobs` array) in OpenSearch
463+
- This caused field explosion (6,632 fields for 48 runs with per-job data)
464+
- Changed to aggregated-only approach (matching CoreMark, Passmark, Uperf)
465+
- Per-job data preserved in raw JSON archives
466+
467+
See `docs/fio-per-job-data.md` for accessing per-job breakdowns from raw archives.
468+
469+
---
470+
420471
## Data Organization
421472

422473
### Run Structure

src/chronicler/schema.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -406,18 +406,30 @@ def calculate_content_hash(self, exclude_processing_timestamp: bool = True) -> s
406406

407407
def to_dict_summary_only(self) -> Dict[str, Any]:
408408
"""
409-
Convert to dictionary WITHOUT timeseries data.
410-
Only includes timeseries_summary for each run.
409+
Convert to dictionary WITHOUT timeseries data and per-job details.
410+
Only includes timeseries_summary and aggregated metrics for each run.
411411
Used for the main zathras-results index.
412+
413+
Removes:
414+
- timeseries: Detailed time series data (available in zathras-timeseries index)
415+
- metrics.jobs: Per-job breakdown (available in raw JSON archives)
416+
417+
This keeps field count under OpenSearch's default 5,000 field limit and
418+
aligns FIO with the aggregated approach used by other benchmarks.
412419
"""
413420
result = self.to_dict()
414421

415-
# Remove timeseries from all runs
422+
# Remove timeseries and per-job details from all runs
416423
if 'results' in result and 'runs' in result['results']:
417-
for run_key, run_data in result['results']['runs'].items():
424+
for _, run_data in result['results']['runs'].items():
425+
# Remove timeseries data
418426
if 'timeseries' in run_data:
419427
del run_data['timeseries']
420428

429+
# Remove per-job breakdown (FIO-specific)
430+
if 'metrics' in run_data:
431+
run_data['metrics'].pop('jobs', None)
432+
421433
return result
422434

423435
def extract_timeseries_documents(self) -> List['TimeSeriesDocument']:

tests/test_schema.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,55 @@ def test_to_dict_summary_only_removes_timeseries(self, full_document):
313313
run_data = d["results"]["runs"]["run_1"]
314314
assert "timeseries" not in run_data
315315

316+
def test_to_dict_summary_only_removes_per_job_details(self):
317+
"""Test that to_dict_summary_only removes metrics.jobs array (FIO-specific)."""
318+
doc = ZathrasDocument(
319+
metadata=Metadata(document_id="fio-test"),
320+
test=TestInfo(name="fio", version="3.35"),
321+
system_under_test=SystemUnderTest(),
322+
test_configuration=TestConfiguration(),
323+
results=Results(
324+
status="PASS",
325+
runs={
326+
"run_0": Run(
327+
run_number=0,
328+
status="PASS",
329+
metrics={
330+
"total_bandwidth_kbps": 1000000,
331+
"total_iops": 250000,
332+
"jobs": [
333+
{
334+
"job_number": 0,
335+
"device": "/dev/sda",
336+
"bandwidth_kbps": 500000,
337+
"iops": 125000,
338+
},
339+
{
340+
"job_number": 1,
341+
"device": "/dev/sdb",
342+
"bandwidth_kbps": 500000,
343+
"iops": 125000,
344+
},
345+
],
346+
},
347+
)
348+
},
349+
),
350+
)
351+
352+
# Full dict should have jobs
353+
full_dict = doc.to_dict()
354+
assert "jobs" in full_dict["results"]["runs"]["run_0"]["metrics"]
355+
assert len(full_dict["results"]["runs"]["run_0"]["metrics"]["jobs"]) == 2
356+
357+
# Summary dict should NOT have jobs
358+
summary_dict = doc.to_dict_summary_only()
359+
assert "jobs" not in summary_dict["results"]["runs"]["run_0"]["metrics"]
360+
361+
# But should still have aggregated metrics
362+
assert summary_dict["results"]["runs"]["run_0"]["metrics"]["total_bandwidth_kbps"] == 1000000
363+
assert summary_dict["results"]["runs"]["run_0"]["metrics"]["total_iops"] == 250000
364+
316365
def test_validate_valid_document(self, minimal_document):
317366
is_valid, errors = minimal_document.validate()
318367
assert is_valid

0 commit comments

Comments
 (0)