From b3351efca62b9ec8fba99d06eed70684000ea878 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:12:33 +0000 Subject: [PATCH 1/5] Initial plan From 3d749cee93eb0c6ea66bdb73d7b5e69c86e07517 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:31:05 +0000 Subject: [PATCH 2/5] Add spectrogram functionality with timestamp support - Enhanced collector to capture timestamps from strace and fs_usage - Added --spectrogram flag to magic.py for time-series visualization - Created generate_spectrogram function in display.py with heatmap plots - Added comprehensive tests for timestamp parsing and spectrogram generation - All existing tests pass with new timestamp fields Co-authored-by: mtauraso <31012+mtauraso@users.noreply.github.com> --- src/iops_profiler/collector.py | 79 ++++++-- src/iops_profiler/display.py | 135 +++++++++++++ src/iops_profiler/magic.py | 34 +++- tests/test_integration.py | 6 +- tests/test_spectrogram.py | 344 +++++++++++++++++++++++++++++++++ 5 files changed, 577 insertions(+), 21 deletions(-) create mode 100644 tests/test_spectrogram.py diff --git a/src/iops_profiler/collector.py b/src/iops_profiler/collector.py index 47d8348..7f20413 100644 --- a/src/iops_profiler/collector.py +++ b/src/iops_profiler/collector.py @@ -60,29 +60,52 @@ def __init__(self, shell): # Compile regex patterns for better performance # Pattern matches: PID syscall(args) = result self._strace_pattern = re.compile(r"^\s*(\d+)\s+(\w+)\([^)]+\)\s*=\s*(-?\d+)") + # Pattern matches strace with timestamp: TIMESTAMP PID syscall(args) = result + self._strace_timestamp_pattern = re.compile(r"^\s*(\d+\.\d+)\s+(\d+)\s+(\w+)\([^)]+\)\s*=\s*(-?\d+)") # Pattern matches: B=0x[hex] in fs_usage output self._fs_usage_byte_pattern = re.compile(FS_USAGE_BYTE_PATTERN) + # Pattern matches fs_usage timestamp at start of line (HH:MM:SS or HH:MM:SS.ffffff format) + self._fs_usage_timestamp_pattern = re.compile(r"^\s*(\d{2}:\d{2}:\d{2}(?:\.\d+)?)") # Set of syscall names for I/O operations (lowercase) self._io_syscalls = set(STRACE_IO_SYSCALLS) @staticmethod - def parse_fs_usage_line_static(line, byte_pattern=None, collect_ops=False): + def parse_fs_usage_line_static(line, byte_pattern=None, collect_ops=False, timestamp_pattern=None): """Parse a single fs_usage output line for I/O operations (static version) Args: line: The line to parse byte_pattern: Compiled regex pattern for extracting byte count (optional) collect_ops: If True, return full operation info for histogram collection + timestamp_pattern: Compiled regex pattern for extracting timestamp (optional) Returns: If collect_ops is False: (op_type, bytes_transferred) - If collect_ops is True: {'type': op_type, 'bytes': bytes_transferred} + If collect_ops is True: {'type': op_type, 'bytes': bytes_transferred, 'timestamp': timestamp} """ parts = line.split() if len(parts) < 2: return None if collect_ops else (None, 0) - syscall = parts[1].lower() + # Check if line starts with timestamp (HH:MM:SS.ffffff or HH:MM:SS format) + # Timestamp detection: if first token contains colons and matches time pattern + timestamp = None + syscall_index = 0 + if parts[0] and ':' in parts[0]: + # Try to match timestamp pattern if provided + if timestamp_pattern: + ts_match = timestamp_pattern.match(line) + if ts_match: + timestamp = ts_match.group(1) + syscall_index = 1 + else: + # Fallback: check if first token looks like a timestamp (HH:MM:SS format) + # Pattern: digits:digits:digits (optionally followed by .digits) + if re.match(r"^\d{1,2}:\d{2}:\d{2}(?:\.\d+)?$", parts[0]): + timestamp = parts[0] if collect_ops else None + syscall_index = 1 + + syscall = parts[syscall_index].lower() is_read = "read" in syscall is_write = "write" in syscall @@ -100,7 +123,10 @@ def parse_fs_usage_line_static(line, byte_pattern=None, collect_ops=False): op_type = "read" if is_read else "write" if collect_ops: - return {"type": op_type, "bytes": bytes_transferred} + result = {"type": op_type, "bytes": bytes_transferred} + if timestamp: + result["timestamp"] = timestamp + return result return op_type, bytes_transferred def parse_fs_usage_line(self, line, collect_ops=False): @@ -108,10 +134,14 @@ def parse_fs_usage_line(self, line, collect_ops=False): This is a convenience wrapper that uses the instance's compiled byte pattern. """ - return self.parse_fs_usage_line_static(line, self._fs_usage_byte_pattern, collect_ops) + return self.parse_fs_usage_line_static( + line, self._fs_usage_byte_pattern, collect_ops, self._fs_usage_timestamp_pattern + ) @staticmethod - def parse_strace_line_static(line, strace_pattern, io_syscalls, collect_ops=False): + def parse_strace_line_static( + line, strace_pattern, io_syscalls, collect_ops=False, strace_timestamp_pattern=None + ): """Parse a single strace output line for I/O operations (static version) Example strace lines: @@ -119,25 +149,40 @@ def parse_strace_line_static(line, strace_pattern, io_syscalls, collect_ops=Fals 3385 read(3, "data", 4096) = 133 3385 pread64(3, "...", 1024, 0) = 1024 + Example strace lines with timestamps: + 1234567890.123456 3385 write(3, "Hello World...", 1100) = 1100 + 1234567890.123457 3385 read(3, "data", 4096) = 133 + Note: Lines with or <... resumed> are not matched as they don't contain complete result information in a single line. Args: line: The line to parse - strace_pattern: Compiled regex pattern for strace output + strace_pattern: Compiled regex pattern for strace output (without timestamp) io_syscalls: Set of I/O syscall names to track collect_ops: If True, return full operation info for histogram collection + strace_timestamp_pattern: Compiled regex pattern for strace with timestamp (optional) Returns: If collect_ops is False: (op_type, bytes_transferred) - If collect_ops is True: {'type': op_type, 'bytes': bytes_transferred} + If collect_ops is True: {'type': op_type, 'bytes': bytes_transferred, 'timestamp': timestamp} """ - # Match patterns like: PID syscall(fd, ..., size) = result - match = strace_pattern.match(line) + timestamp = None + match = None + + # Try to match with timestamp pattern first if provided + if strace_timestamp_pattern: + match = strace_timestamp_pattern.match(line) + if match: + timestamp, pid, syscall, result = match.groups() + + # If no timestamp match, try regular pattern if not match: - return None if collect_ops else (None, 0) + match = strace_pattern.match(line) + if not match: + return None if collect_ops else (None, 0) + pid, syscall, result = match.groups() - pid, syscall, result = match.groups() syscall = syscall.lower() # Check if it's one of the I/O syscalls we're tracking @@ -160,7 +205,10 @@ def parse_strace_line_static(line, strace_pattern, io_syscalls, collect_ops=Fals op_type = "read" if is_read else "write" if collect_ops: - return {"type": op_type, "bytes": bytes_transferred} + result = {"type": op_type, "bytes": bytes_transferred} + if timestamp: + result["timestamp"] = timestamp + return result return op_type, bytes_transferred def parse_strace_line(self, line, collect_ops=False): @@ -168,7 +216,9 @@ def parse_strace_line(self, line, collect_ops=False): This is a convenience wrapper that uses the instance's strace pattern and syscalls. """ - return self.parse_strace_line_static(line, self._strace_pattern, self._io_syscalls, collect_ops) + return self.parse_strace_line_static( + line, self._strace_pattern, self._io_syscalls, collect_ops, self._strace_timestamp_pattern + ) @staticmethod def _create_helper_script(pid, output_file, control_file): @@ -385,6 +435,7 @@ def measure_linux_strace(self, code, collect_ops=False): strace_cmd = [ "strace", "-f", # Follow forks + "-ttt", # Add absolute timestamps with microseconds (Unix time) "-e", f"trace={syscalls_to_trace}", "-o", diff --git a/src/iops_profiler/display.py b/src/iops_profiler/display.py index 984db2e..7657b90 100644 --- a/src/iops_profiler/display.py +++ b/src/iops_profiler/display.py @@ -56,6 +56,141 @@ def format_bytes(bytes_val): return f"{bytes_val:.2f} TB" +def generate_spectrogram(operations, elapsed_time): + """Generate spectrogram-like heatmaps for I/O operations over time + + Args: + operations: List of dicts with 'type', 'bytes', and 'timestamp' keys + elapsed_time: Total elapsed time of the profiled code + """ + if not plt or not np: + print("⚠️ matplotlib or numpy not available. Cannot generate spectrograms.") + return + + if not operations: + print("⚠️ No operations captured for spectrogram generation.") + return + + # Filter operations with timestamps and non-zero bytes + ops_with_time = [op for op in operations if "timestamp" in op and op["bytes"] > 0] + + if not ops_with_time: + print("⚠️ No operations with timestamps for spectrogram generation.") + return + + # Convert timestamps to relative time (seconds from start) + # Handle both Unix timestamp format (strace) and HH:MM:SS.ffffff format (fs_usage) + start_timestamp = None + relative_times = [] + + for op in ops_with_time: + ts_str = op["timestamp"] + # Check if it's Unix timestamp format (decimal number) + if ":" not in ts_str: + # Unix timestamp from strace + timestamp = float(ts_str) + if start_timestamp is None: + start_timestamp = timestamp + relative_times.append(timestamp - start_timestamp) + else: + # HH:MM:SS.ffffff format from fs_usage + # Parse the time format + parts = ts_str.split(":") + if len(parts) == 3: + hours = float(parts[0]) + minutes = float(parts[1]) + seconds = float(parts[2]) + timestamp = hours * 3600 + minutes * 60 + seconds + if start_timestamp is None: + start_timestamp = timestamp + relative_times.append(timestamp - start_timestamp) + + # Handle case where no valid timestamps were extracted + if not relative_times: + print("⚠️ Could not parse timestamps for spectrogram generation.") + return + + # Extract byte sizes + byte_sizes = [op["bytes"] for op in ops_with_time] + + # Create log-scale bins for byte sizes + min_bytes = max(1, min(byte_sizes)) + max_bytes = max(byte_sizes) + + if min_bytes == max_bytes: + size_bins = np.array([min_bytes * 0.9, min_bytes * 1.1]) + else: + # Create bins in log space - using fewer bins for spectrogram + size_bins = np.logspace(np.log10(min_bytes * 0.99), np.log10(max_bytes * 1.01), 30) + + # Create time bins + max_time = max(relative_times) + if max_time == 0: + max_time = elapsed_time + time_bins = np.linspace(0, max_time, 50) + + # Create 2D histograms for operation counts + all_count_hist, time_edges, size_edges = np.histogram2d( + relative_times, byte_sizes, bins=[time_bins, size_bins] + ) + + # Create 2D histograms for total bytes + all_bytes_hist, _, _ = np.histogram2d( + relative_times, byte_sizes, bins=[time_bins, size_bins], weights=byte_sizes + ) + + # Create figure with 2 subplots (operation count and total bytes) + fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) + + # Plot 1: Operation count spectrogram + # Use pcolormesh for heatmap visualization + time_centers = (time_edges[:-1] + time_edges[1:]) / 2 + size_centers = (size_edges[:-1] + size_edges[1:]) / 2 + time_mesh, size_mesh = np.meshgrid(time_centers, size_centers) + + im1 = ax1.pcolormesh(time_mesh, size_mesh, all_count_hist.T, cmap="viridis", shading="auto") + ax1.set_yscale("log") + ax1.set_xlabel("Time (seconds)") + ax1.set_ylabel("Operation Size (bytes, log scale)") + ax1.set_title("I/O Operation Count Over Time") + plt.colorbar(im1, ax=ax1, label="Number of Operations") + ax1.grid(True, alpha=0.3) + + # Plot 2: Total bytes spectrogram (with auto-scaling) + max_bytes_in_bin = np.max(all_bytes_hist) if all_bytes_hist.size > 0 else 0 + if max_bytes_in_bin < 1024: + unit, divisor = "B", 1 + elif max_bytes_in_bin < 1024**2: + unit, divisor = "KB", 1024 + elif max_bytes_in_bin < 1024**3: + unit, divisor = "MB", 1024**2 + elif max_bytes_in_bin < 1024**4: + unit, divisor = "GB", 1024**3 + else: + unit, divisor = "TB", 1024**4 + + im2 = ax2.pcolormesh(time_mesh, size_mesh, (all_bytes_hist / divisor).T, cmap="plasma", shading="auto") + ax2.set_yscale("log") + ax2.set_xlabel("Time (seconds)") + ax2.set_ylabel("Operation Size (bytes, log scale)") + ax2.set_title("I/O Total Bytes Over Time") + plt.colorbar(im2, ax=ax2, label=f"Total Bytes ({unit})") + ax2.grid(True, alpha=0.3) + + plt.tight_layout() + + # Check if running in plain IPython vs notebook environment + if is_notebook_environment(): + # In notebook, show the plot inline + plt.show() + else: + # In plain IPython, save to file + output_file = "iops_spectrogram.png" + plt.savefig(output_file, dpi=100, bbox_inches="tight") + plt.close(fig) + print(f"📊 Spectrogram saved to: {output_file}") + + def generate_histograms(operations): """Generate histograms for I/O operations using numpy diff --git a/src/iops_profiler/magic.py b/src/iops_profiler/magic.py index 1970c3d..51edc40 100644 --- a/src/iops_profiler/magic.py +++ b/src/iops_profiler/magic.py @@ -30,19 +30,20 @@ def __init__(self, shell): # Initialize the collector with shell context self.collector = Collector(shell) - def _profile_code(self, code, show_histogram=False): + def _profile_code(self, code, show_histogram=False, show_spectrogram=False): """ Internal method to profile code with I/O measurements. Args: code: The code string to profile show_histogram: Whether to generate histograms + show_spectrogram: Whether to generate spectrogram Returns: Dictionary with profiling results """ # Determine if we should collect individual operations - collect_ops = show_histogram + collect_ops = show_histogram or show_spectrogram # Determine measurement method based on platform if self.platform == "darwin": # macOS @@ -55,12 +56,16 @@ def _profile_code(self, code, show_histogram=False): results = self.collector.measure_systemwide_fallback(code) if show_histogram: print("⚠️ Histograms not available for system-wide measurement mode.") + if show_spectrogram: + print("⚠️ Spectrograms not available for system-wide measurement mode.") else: print(f"⚠️ Could not start fs_usage: {e}") print("Falling back to system-wide measurement.\n") results = self.collector.measure_systemwide_fallback(code) if show_histogram: print("⚠️ Histograms not available for system-wide measurement mode.") + if show_spectrogram: + print("⚠️ Spectrograms not available for system-wide measurement mode.") elif self.platform in ("linux", "linux2"): # Use strace on Linux (no elevated privileges required) @@ -72,11 +77,15 @@ def _profile_code(self, code, show_histogram=False): results = self.collector.measure_linux_windows(code) if show_histogram: print("⚠️ Histograms not available for psutil measurement mode.") + if show_spectrogram: + print("⚠️ Spectrograms not available for psutil measurement mode.") elif self.platform == "win32": results = self.collector.measure_linux_windows(code) if show_histogram: print("⚠️ Histograms not available for psutil measurement mode on Windows.") + if show_spectrogram: + print("⚠️ Spectrograms not available for psutil measurement mode on Windows.") else: print(f"⚠️ Platform '{self.platform}' not fully supported.") @@ -84,6 +93,8 @@ def _profile_code(self, code, show_histogram=False): results = self.collector.measure_systemwide_fallback(code) if show_histogram: print("⚠️ Histograms not available for system-wide measurement mode.") + if show_spectrogram: + print("⚠️ Spectrograms not available for system-wide measurement mode.") return results @@ -95,6 +106,7 @@ def iops(self, line, cell=None): Line magic usage (single line): %iops open('test.txt', 'w').write('data') %iops --histogram open('test.txt', 'w').write('data') + %iops --spectrogram open('test.txt', 'w').write('data') Cell magic usage (multiple lines): %%iops @@ -106,10 +118,16 @@ def iops(self, line, cell=None): # Your code here (with histograms) with open('test.txt', 'w') as f: f.write('data') + + %%iops --spectrogram + # Your code here (with spectrogram) + with open('test.txt', 'w') as f: + f.write('data') """ try: # Parse command line arguments show_histogram = False + show_spectrogram = False code = None # Determine what code to execute @@ -119,20 +137,24 @@ def iops(self, line, cell=None): if line_stripped == "--histogram" or line_stripped.startswith("--histogram "): show_histogram = True code = line_stripped[len("--histogram") :].strip() + elif line_stripped == "--spectrogram" or line_stripped.startswith("--spectrogram "): + show_spectrogram = True + code = line_stripped[len("--spectrogram") :].strip() else: code = line_stripped if not code: print("❌ Error: No code provided to profile in line magic mode.") - print(" Usage: %iops [--histogram] ") + print(" Usage: %iops [--histogram|--spectrogram] ") return else: # Cell magic mode - code is in the cell parameter show_histogram = "--histogram" in line + show_spectrogram = "--spectrogram" in line code = cell # Profile the code - results = self._profile_code(code, show_histogram) + results = self._profile_code(code, show_histogram, show_spectrogram) # Display results table display.display_results(results) @@ -141,6 +163,10 @@ def iops(self, line, cell=None): if show_histogram and "operations" in results: display.generate_histograms(results["operations"]) + # Display spectrogram if requested and available + if show_spectrogram and "operations" in results: + display.generate_spectrogram(results["operations"], results["elapsed_time"]) + except Exception as e: print(f"❌ Error during IOPS profiling: {e}") print("\nYour code was not executed. Please fix the profiling issue and try again.") diff --git a/tests/test_integration.py b/tests/test_integration.py index d714d97..92d262f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -340,9 +340,9 @@ def test_collect_ops_fs_usage_multiple_lines(self, profiler): operations.append(op) assert len(operations) == 3 - assert operations[0] == {"type": "read", "bytes": 0x100} - assert operations[1] == {"type": "write", "bytes": 0x200} - assert operations[2] == {"type": "read", "bytes": 0x300} + assert operations[0] == {"type": "read", "bytes": 0x100, "timestamp": "12:34:56"} + assert operations[1] == {"type": "write", "bytes": 0x200, "timestamp": "12:34:57"} + assert operations[2] == {"type": "read", "bytes": 0x300, "timestamp": "12:34:58"} def test_collect_ops_filters_invalid_lines(self, profiler): """Test that collect_ops filters out invalid lines""" diff --git a/tests/test_spectrogram.py b/tests/test_spectrogram.py new file mode 100644 index 0000000..f726b14 --- /dev/null +++ b/tests/test_spectrogram.py @@ -0,0 +1,344 @@ +""" +Tests for spectrogram generation and timestamp parsing in iops_profiler. + +This module focuses on testing spectrogram generation and timestamp extraction +from strace and fs_usage output. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from iops_profiler import display +from iops_profiler.collector import Collector +from iops_profiler.magic import IOPSProfiler + + +def create_test_profiler(): + """Helper function to create a test profiler instance""" + mock_shell = MagicMock() + mock_shell.configurables = [] + profiler = IOPSProfiler.__new__(IOPSProfiler) + profiler.shell = mock_shell + # Initialize the profiler attributes manually to avoid traitlets + import sys + + profiler.platform = sys.platform + # Initialize the collector with the mock shell + profiler.collector = Collector(mock_shell) + return profiler + + +class TestTimestampParsing: + """Test cases for timestamp parsing in strace and fs_usage output""" + + @pytest.fixture + def collector(self): + """Create a Collector instance with a mock shell""" + mock_shell = MagicMock() + return Collector(mock_shell) + + def test_strace_with_unix_timestamp(self, collector): + """Test parsing strace line with Unix timestamp""" + line = "1234567890.123456 3385 write(3, \"Hello\", 5) = 5" + result = collector.parse_strace_line(line, collect_ops=True) + + assert result is not None + assert result["type"] == "write" + assert result["bytes"] == 5 + assert result["timestamp"] == "1234567890.123456" + + def test_strace_without_timestamp(self, collector): + """Test parsing strace line without timestamp (backward compatibility)""" + line = "3385 write(3, \"Hello\", 5) = 5" + result = collector.parse_strace_line(line, collect_ops=True) + + assert result is not None + assert result["type"] == "write" + assert result["bytes"] == 5 + assert "timestamp" not in result + + def test_strace_read_with_timestamp(self, collector): + """Test parsing strace read operation with timestamp""" + line = "1234567890.654321 3385 read(3, \"data\", 4096) = 1024" + result = collector.parse_strace_line(line, collect_ops=True) + + assert result is not None + assert result["type"] == "read" + assert result["bytes"] == 1024 + assert result["timestamp"] == "1234567890.654321" + + def test_fs_usage_with_timestamp(self, collector): + """Test parsing fs_usage line with timestamp""" + line = "12:34:56.789012 write B=0x1000 /path/to/file Python" + result = collector.parse_fs_usage_line(line, collect_ops=True) + + assert result is not None + assert result["type"] == "write" + assert result["bytes"] == 4096 # 0x1000 in hex + assert result["timestamp"] == "12:34:56.789012" + + def test_fs_usage_read_with_timestamp(self, collector): + """Test parsing fs_usage read operation with timestamp""" + line = "01:23:45.123456 read B=0x800 /path/to/file Python" + result = collector.parse_fs_usage_line(line, collect_ops=True) + + assert result is not None + assert result["type"] == "read" + assert result["bytes"] == 2048 # 0x800 in hex + assert result["timestamp"] == "01:23:45.123456" + + def test_fs_usage_without_timestamp(self, collector): + """Test parsing fs_usage line without timestamp field""" + line = "write B=0x1000 /path/to/file Python" + result = collector.parse_fs_usage_line(line, collect_ops=True) + + assert result is not None + assert result["type"] == "write" + assert result["bytes"] == 4096 + # timestamp field may or may not be present depending on the line format + + +class TestSpectrogramGeneration: + """Test cases for spectrogram generation""" + + @pytest.fixture + def profiler(self): + """Create an IOPSProfiler instance with a mock shell""" + return create_test_profiler() + + @pytest.fixture(autouse=True) + def close_figures(self): + """Automatically close all matplotlib figures after each test""" + import matplotlib.pyplot as plt + + yield + plt.close("all") + + @pytest.fixture(autouse=True) + def mock_notebook_environment(self, profiler): + """Mock is_notebook_environment to return True for spectrogram tests""" + with patch("iops_profiler.display.is_notebook_environment", return_value=True): + yield + + @patch("iops_profiler.display.plt.show") + def test_empty_operations_list(self, mock_show, profiler): + """Test spectrogram generation with empty operations list""" + import matplotlib.pyplot as plt + + operations = [] + display.generate_spectrogram(operations, 1.0) + + # plt.show should not be called since no plots were created + mock_show.assert_not_called() + + # No figures should have been created + assert len(plt.get_fignums()) == 0 + + @patch("iops_profiler.display.plt.show") + def test_operations_without_timestamps(self, mock_show, profiler): + """Test spectrogram generation when operations lack timestamps""" + import matplotlib.pyplot as plt + + operations = [ + {"type": "read", "bytes": 1024}, + {"type": "write", "bytes": 2048}, + ] + display.generate_spectrogram(operations, 1.0) + + # plt.show should not be called since no plots were created + mock_show.assert_not_called() + + # No figures should have been created + assert len(plt.get_fignums()) == 0 + + @patch("iops_profiler.display.plt.show") + def test_unix_timestamp_format(self, mock_show, profiler): + """Test spectrogram with Unix timestamp format (strace)""" + import matplotlib.pyplot as plt + + operations = [ + {"type": "read", "bytes": 1024, "timestamp": "1234567890.100000"}, + {"type": "write", "bytes": 2048, "timestamp": "1234567890.200000"}, + {"type": "read", "bytes": 512, "timestamp": "1234567890.300000"}, + {"type": "write", "bytes": 4096, "timestamp": "1234567890.400000"}, + ] + display.generate_spectrogram(operations, 1.0) + + # plt.show should be called once + mock_show.assert_called_once() + + # Should create one figure with two subplots (plus colorbars) + figs = plt.get_fignums() + assert len(figs) == 1 + + fig = plt.figure(figs[0]) + axes = fig.get_axes() + # 4 axes total: 2 main plots + 2 colorbars + assert len(axes) >= 2 + + # Check first subplot (operation count) + ax1 = axes[0] + assert "Time" in ax1.get_xlabel() + assert "Operation Size" in ax1.get_ylabel() + assert ax1.get_yscale() == "log" + + # Check second subplot (total bytes) + ax2 = axes[1] + assert "Time" in ax2.get_xlabel() + assert "Operation Size" in ax2.get_ylabel() + assert ax2.get_yscale() == "log" + + @patch("iops_profiler.display.plt.show") + def test_fs_usage_timestamp_format(self, mock_show, profiler): + """Test spectrogram with HH:MM:SS.ffffff timestamp format (fs_usage)""" + import matplotlib.pyplot as plt + + operations = [ + {"type": "read", "bytes": 1024, "timestamp": "12:34:56.100000"}, + {"type": "write", "bytes": 2048, "timestamp": "12:34:56.200000"}, + {"type": "read", "bytes": 512, "timestamp": "12:34:56.300000"}, + {"type": "write", "bytes": 4096, "timestamp": "12:34:56.400000"}, + ] + display.generate_spectrogram(operations, 1.0) + + # plt.show should be called once + mock_show.assert_called_once() + + # Should create one figure with two subplots (plus colorbars) + figs = plt.get_fignums() + assert len(figs) == 1 + + fig = plt.figure(figs[0]) + axes = fig.get_axes() + # 4 axes total: 2 main plots + 2 colorbars + assert len(axes) >= 2 + + @patch("iops_profiler.display.plt.show") + def test_wide_range_of_sizes_over_time(self, mock_show, profiler): + """Test spectrogram with wide range of operation sizes over time""" + import matplotlib.pyplot as plt + + operations = [] + # Generate operations with varying sizes over time + for i in range(20): + timestamp = f"1234567890.{i:06d}" + byte_size = 2 ** (i % 10 + 1) # Sizes from 2 to 1024 bytes + op_type = "read" if i % 2 == 0 else "write" + operations.append({"type": op_type, "bytes": byte_size, "timestamp": timestamp}) + + display.generate_spectrogram(operations, 1.0) + + # plt.show should be called once + mock_show.assert_called_once() + + # Should create one figure with two subplots (plus colorbars) + figs = plt.get_fignums() + assert len(figs) == 1 + + fig = plt.figure(figs[0]) + axes = fig.get_axes() + # 4 axes total: 2 main plots + 2 colorbars + assert len(axes) >= 2 + + @patch("iops_profiler.display.plt.show") + def test_many_operations_over_time(self, mock_show, profiler): + """Test spectrogram with many operations""" + import matplotlib.pyplot as plt + + operations = [] + for i in range(1000): + timestamp = f"1234567890.{i:06d}" + byte_size = (i % 100 + 1) * 100 + op_type = "read" if i % 2 == 0 else "write" + operations.append({"type": op_type, "bytes": byte_size, "timestamp": timestamp}) + + display.generate_spectrogram(operations, 10.0) + + # plt.show should be called once + mock_show.assert_called_once() + + # Should create one figure with two subplots + figs = plt.get_fignums() + assert len(figs) == 1 + + @patch("iops_profiler.display.plt.show") + def test_operations_with_zero_bytes_ignored(self, mock_show, profiler): + """Test that operations with zero bytes are ignored in spectrogram""" + operations = [ + {"type": "read", "bytes": 0, "timestamp": "1234567890.100000"}, + {"type": "write", "bytes": 2048, "timestamp": "1234567890.200000"}, + {"type": "read", "bytes": 0, "timestamp": "1234567890.300000"}, + ] + display.generate_spectrogram(operations, 1.0) + + # Should still create a plot (has one non-zero operation) + mock_show.assert_called_once() + + def test_no_matplotlib_installed(self, profiler): + """Test spectrogram generation when matplotlib is not available""" + from iops_profiler import display + + original_plt = display.plt + + # Set plt to None + display.plt = None + + try: + operations = [{"type": "read", "bytes": 1024, "timestamp": "1234567890.100000"}] + # Should print warning and return early + display.generate_spectrogram(operations, 1.0) + finally: + # Restore original plt + display.plt = original_plt + + def test_no_numpy_installed(self, profiler): + """Test spectrogram generation when numpy is not available""" + from iops_profiler import display + + original_np = display.np + + # Set np to None + display.np = None + + try: + operations = [{"type": "read", "bytes": 1024, "timestamp": "1234567890.100000"}] + # Should print warning and return early + display.generate_spectrogram(operations, 1.0) + finally: + # Restore original np + display.np = original_np + + @patch("iops_profiler.display.plt") + @patch("iops_profiler.display.np") + def test_spectrogram_saves_to_file_in_terminal(self, mock_np, mock_plt, profiler, capsys): + """Test spectrogram saves to file in terminal mode""" + import numpy as np + + mock_np.histogram2d = np.histogram2d + mock_np.logspace = np.logspace + mock_np.log10 = np.log10 + mock_np.linspace = np.linspace + mock_np.array = np.array + mock_np.meshgrid = np.meshgrid + mock_np.max = np.max + + operations = [ + {"type": "read", "bytes": 1024, "timestamp": "1234567890.100000"}, + {"type": "write", "bytes": 2048, "timestamp": "1234567890.200000"}, + ] + + mock_fig = MagicMock() + mock_ax1 = MagicMock() + mock_ax2 = MagicMock() + mock_plt.subplots.return_value = (mock_fig, (mock_ax1, mock_ax2)) + + # Test terminal mode - saves to file + with patch("iops_profiler.display.is_notebook_environment", return_value=False): + display.generate_spectrogram(operations, 1.0) + + mock_plt.savefig.assert_called_once() + mock_plt.show.assert_not_called() + mock_plt.close.assert_called_once_with(mock_fig) + captured = capsys.readouterr() + assert "iops_spectrogram.png" in captured.out From 02ac5c505233993997715cf7479814b49e3fb6f9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:34:57 +0000 Subject: [PATCH 3/5] Add spectrogram example notebook and update documentation - Created spectrogram_example.ipynb showcasing time-series visualization - Configured docs to execute notebooks during build with nbsphinx - Updated README with spectrogram usage examples and documentation - Added nbsphinx and ipykernel to docs requirements Co-authored-by: mtauraso <31012+mtauraso@users.noreply.github.com> --- README.md | 46 ++++- docs/conf.py | 15 +- docs/index.rst | 1 + docs/notebooks/spectrogram_example.ipynb | 239 +++++++++++++++++++++++ docs/requirements.txt | 4 +- 5 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 docs/notebooks/spectrogram_example.ipynb diff --git a/README.md b/README.md index ec45a90..d608344 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,48 @@ When enabled, two histogram charts are displayed alongside the results table: Both charts display separate lines for reads, writes, and all operations combined, making it easy to identify patterns in your code's I/O behavior. +### Spectrogram Visualization + +Use the `--spectrogram` flag to visualize I/O operations as a time-series heatmap (available for `strace` and `fs_usage` measurement modes): + +**Example - Visualizing I/O patterns over time:** +```python +%%iops --spectrogram +import tempfile +import os +import time + +test_dir = tempfile.mkdtemp() + +try: + # Phase 1: Small writes over time + for i in range(5): + with open(os.path.join(test_dir, f'small_{i}.txt'), 'w') as f: + f.write('x' * 1024) # 1 KB + time.sleep(0.05) + + # Phase 2: Large writes over time + for i in range(5): + with open(os.path.join(test_dir, f'large_{i}.txt'), 'w') as f: + f.write('z' * (100 * 1024)) # 100 KB + time.sleep(0.05) + +finally: + import shutil + if os.path.exists(test_dir): + shutil.rmtree(test_dir) +``` + +When enabled, two spectrogram heatmaps are displayed alongside the results table: +1. **Operation Count Over Time**: Shows when I/O operations of different sizes occurred (X-axis: time, Y-axis: operation size in log scale, Color: operation count) +2. **Total Bytes Over Time**: Shows data transfer patterns over time (X-axis: time, Y-axis: operation size in log scale, Color: total bytes) + +The spectrogram visualization helps identify: +- Temporal patterns in I/O behavior +- When different I/O sizes occur during execution +- Bursts or gaps in I/O activity +- Correlation between application phases and I/O patterns + ## Platform Support - **Linux/Windows**: Uses `psutil` for per-process I/O tracking @@ -125,8 +167,8 @@ Both charts display separate lines for reads, writes, and all operations combine - Python 3.10+ - IPython/Jupyter - psutil -- matplotlib (for histogram visualization) -- numpy (for histogram visualization) +- matplotlib (for histogram and spectrogram visualization) +- numpy (for histogram and spectrogram visualization) ## Dev Guide - Getting Started diff --git a/docs/conf.py b/docs/conf.py index a482e40..5485776 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -24,7 +24,12 @@ # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = ["sphinx.ext.mathjax", "sphinx.ext.napoleon", "sphinx.ext.viewcode"] +extensions = [ + "sphinx.ext.mathjax", + "sphinx.ext.napoleon", + "sphinx.ext.viewcode", + "nbsphinx", # For executing and rendering Jupyter notebooks +] extensions.append("autoapi.extension") @@ -54,4 +59,12 @@ autoapi_add_toc_tree_entry = False autoapi_member_order = "bysource" +# -- nbsphinx configuration ------------------------------------------------- +# Execute notebooks before conversion +nbsphinx_execute = "always" +# Allow errors in notebook execution (for documentation purposes) +nbsphinx_allow_errors = True +# Timeout for notebook execution (in seconds) +nbsphinx_timeout = 600 + html_theme = "sphinx_rtd_theme" diff --git a/docs/index.rst b/docs/index.rst index f717498..36a4a3d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -48,4 +48,5 @@ Notes: Home page API Reference + Spectrogram Example diff --git a/docs/notebooks/spectrogram_example.ipynb b/docs/notebooks/spectrogram_example.ipynb new file mode 100644 index 0000000..414339d --- /dev/null +++ b/docs/notebooks/spectrogram_example.ipynb @@ -0,0 +1,239 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Spectrogram Visualization Example\n", + "\n", + "This notebook demonstrates the spectrogram feature of iops-profiler, which provides a time-series heatmap view of I/O operations.\n", + "\n", + "The spectrogram shows:\n", + "- **X-axis**: Time (runtime in seconds)\n", + "- **Y-axis**: Operation size (bytes, log scale)\n", + "- **Color**: Either the number of operations or total bytes transferred\n", + "\n", + "This visualization helps identify I/O patterns over time, showing when different sizes of operations occur during program execution." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Load the iops-profiler extension\n", + "%load_ext iops_profiler" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 1: Simple I/O with Varying Sizes\n", + "\n", + "Let's create a workload that performs I/O operations of different sizes over time:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%iops --spectrogram\n", + "import tempfile\n", + "import os\n", + "import time\n", + "\n", + "# Create a temporary directory for our test files\n", + "test_dir = tempfile.mkdtemp()\n", + "\n", + "try:\n", + " # Phase 1: Small writes (1 KB each)\n", + " for i in range(5):\n", + " with open(os.path.join(test_dir, f'small_{i}.txt'), 'w') as f:\n", + " f.write('x' * 1024) # 1 KB\n", + " time.sleep(0.05) # Small delay to spread operations over time\n", + " \n", + " # Phase 2: Medium writes (10 KB each)\n", + " for i in range(5):\n", + " with open(os.path.join(test_dir, f'medium_{i}.txt'), 'w') as f:\n", + " f.write('y' * (10 * 1024)) # 10 KB\n", + " time.sleep(0.05)\n", + " \n", + " # Phase 3: Large writes (100 KB each)\n", + " for i in range(5):\n", + " with open(os.path.join(test_dir, f'large_{i}.txt'), 'w') as f:\n", + " f.write('z' * (100 * 1024)) # 100 KB\n", + " time.sleep(0.05)\n", + " \n", + " # Phase 4: Mixed reads - read back all files in order\n", + " for size_category in ['small', 'medium', 'large']:\n", + " for i in range(5):\n", + " filepath = os.path.join(test_dir, f'{size_category}_{i}.txt')\n", + " if os.path.exists(filepath):\n", + " with open(filepath, 'r') as f:\n", + " _ = f.read()\n", + " time.sleep(0.03)\n", + "\n", + "finally:\n", + " # Cleanup\n", + " import shutil\n", + " if os.path.exists(test_dir):\n", + " shutil.rmtree(test_dir)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The spectrogram above shows:\n", + "\n", + "1. **Left plot (Operation Count)**: How many I/O operations occurred in each time/size bin\n", + "2. **Right plot (Total Bytes)**: How much data was transferred in each time/size bin\n", + "\n", + "You can see distinct phases:\n", + "- Early time: Small operations (1 KB writes)\n", + "- Middle time: Medium operations (10 KB writes)\n", + "- Later time: Large operations (100 KB writes)\n", + "- Final time: Mixed reads of all sizes" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Example 2: Bursty I/O Pattern\n", + "\n", + "Let's create a workload with bursts of activity at different scales:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%iops --spectrogram\n", + "import tempfile\n", + "import os\n", + "import time\n", + "\n", + "test_dir = tempfile.mkdtemp()\n", + "\n", + "try:\n", + " # Burst 1: Many small operations\n", + " for i in range(20):\n", + " with open(os.path.join(test_dir, f'burst1_{i}.txt'), 'w') as f:\n", + " f.write('a' * 512) # 512 bytes\n", + " \n", + " # Pause\n", + " time.sleep(0.2)\n", + " \n", + " # Burst 2: Few large operations\n", + " for i in range(3):\n", + " with open(os.path.join(test_dir, f'burst2_{i}.txt'), 'w') as f:\n", + " f.write('b' * (200 * 1024)) # 200 KB\n", + " \n", + " # Pause\n", + " time.sleep(0.2)\n", + " \n", + " # Burst 3: Medium-sized operations\n", + " for i in range(10):\n", + " with open(os.path.join(test_dir, f'burst3_{i}.txt'), 'w') as f:\n", + " f.write('c' * (5 * 1024)) # 5 KB\n", + "\n", + "finally:\n", + " import shutil\n", + " if os.path.exists(test_dir):\n", + " shutil.rmtree(test_dir)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "This spectrogram shows a bursty pattern with clear gaps between activity phases. The color intensity helps identify which phases had the most activity or transferred the most data." + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Comparing with Histogram View\n", + "\n", + "For comparison, here's the same workload with the histogram view:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "%%iops --histogram\n", + "import tempfile\n", + "import os\n", + "import time\n", + "\n", + "test_dir = tempfile.mkdtemp()\n", + "\n", + "try:\n", + " # Same workload as Example 1\n", + " for i in range(5):\n", + " with open(os.path.join(test_dir, f'small_{i}.txt'), 'w') as f:\n", + " f.write('x' * 1024)\n", + " time.sleep(0.05)\n", + " \n", + " for i in range(5):\n", + " with open(os.path.join(test_dir, f'medium_{i}.txt'), 'w') as f:\n", + " f.write('y' * (10 * 1024))\n", + " time.sleep(0.05)\n", + " \n", + " for i in range(5):\n", + " with open(os.path.join(test_dir, f'large_{i}.txt'), 'w') as f:\n", + " f.write('z' * (100 * 1024))\n", + " time.sleep(0.05)\n", + "\n", + "finally:\n", + " import shutil\n", + " if os.path.exists(test_dir):\n", + " shutil.rmtree(test_dir)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "The histogram shows the distribution of operation sizes but doesn't reveal when they occurred. The spectrogram adds the temporal dimension, making it easier to:\n", + "\n", + "- Identify when different I/O patterns occur during execution\n", + "- Spot bursts or gaps in I/O activity\n", + "- Correlate I/O behavior with application phases\n", + "- Debug performance issues related to I/O timing" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.0" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/requirements.txt b/docs/requirements.txt index 34071a0..c581757 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,4 +2,6 @@ sphinx sphinx-autoapi sphinx-copybutton -sphinx-rtd-theme \ No newline at end of file +sphinx-rtd-theme +nbsphinx +ipykernel \ No newline at end of file From fbb7c494b7c97849c107a0ba48c297af49a64bb0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 5 Nov 2025 23:39:33 +0000 Subject: [PATCH 4/5] Address code review feedback - Clarified strace timestamp comment (Unix epoch with microsecond precision) - Added named constants for spectrogram magic numbers - Compiled regex pattern for timestamp detection to improve performance - Reduced test operation count from 1000 to 200 for faster tests - Fixed line length issues Co-authored-by: mtauraso <31012+mtauraso@users.noreply.github.com> --- iops_spectrogram.png | Bin 0 -> 41203 bytes src/iops_profiler/collector.py | 6 ++++-- src/iops_profiler/display.py | 19 ++++++++++++++++--- tests/test_spectrogram.py | 3 ++- 4 files changed, 22 insertions(+), 6 deletions(-) create mode 100644 iops_spectrogram.png diff --git a/iops_spectrogram.png b/iops_spectrogram.png new file mode 100644 index 0000000000000000000000000000000000000000..1cfece7e7cbc20234b125ae809564fb692d7aa43 GIT binary patch literal 41203 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

#ghoMhpH+m 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 Date: Fri, 7 Nov 2025 21:32:47 +0000 Subject: [PATCH 5/5] Rename --spectrogram to --heatmap and add human-readable time axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Renamed generate_spectrogram to generate_heatmap - Renamed --spectrogram flag to --heatmap throughout codebase - Added format_time_axis function to auto-select appropriate time unit (s, ms, μs, ns) - Updated x-axis labels to show time in human-readable units with limited decimal places - Renamed test file from test_spectrogram.py to test_heatmap.py - Renamed notebook from spectrogram_example to heatmap_visualization - Updated all documentation and constants to use 'heatmap' terminology - Added heatmap notebook to docs/notebooks.rst with description - All 136 tests pass Co-authored-by: mtauraso <31012+mtauraso@users.noreply.github.com> --- README.md | 14 ++-- docs/notebooks.rst | 19 +++++ ...mple.ipynb => heatmap_visualization.ipynb} | 16 ++-- iops_heatmap.png | Bin 0 -> 39644 bytes src/iops_profiler/display.py | 77 +++++++++++++----- src/iops_profiler/magic.py | 52 ++++++------ .../{test_spectrogram.py => test_heatmap.py} | 54 ++++++------ 7 files changed, 142 insertions(+), 90 deletions(-) rename docs/notebooks/{spectrogram_example.ipynb => heatmap_visualization.ipynb} (91%) create mode 100644 iops_heatmap.png rename tests/{test_spectrogram.py => test_heatmap.py} (86%) diff --git a/README.md b/README.md index 3663087..508623f 100644 --- a/README.md +++ b/README.md @@ -137,13 +137,13 @@ When enabled, two histogram charts are displayed alongside the results table: Both charts display separate lines for reads, writes, and all operations combined, making it easy to identify patterns in your code's I/O behavior. -### Spectrogram Visualization +### Heatmap Visualization -Use the `--spectrogram` flag to visualize I/O operations as a time-series heatmap (available for `strace` and `fs_usage` measurement modes): +Use the `--heatmap` flag to visualize I/O operations as a time-series heatmap (available for `strace` and `fs_usage` measurement modes): **Example - Visualizing I/O patterns over time:** ```python -%%iops --spectrogram +%%iops --heatmap import tempfile import os import time @@ -169,11 +169,11 @@ finally: shutil.rmtree(test_dir) ``` -When enabled, two spectrogram heatmaps are displayed alongside the results table: +When enabled, two heatmap heatmaps are displayed alongside the results table: 1. **Operation Count Over Time**: Shows when I/O operations of different sizes occurred (X-axis: time, Y-axis: operation size in log scale, Color: operation count) 2. **Total Bytes Over Time**: Shows data transfer patterns over time (X-axis: time, Y-axis: operation size in log scale, Color: total bytes) -The spectrogram visualization helps identify: +The heatmap visualization helps identify: - Temporal patterns in I/O behavior - When different I/O sizes occur during execution - Bursts or gaps in I/O activity @@ -189,8 +189,8 @@ The spectrogram visualization helps identify: - Python 3.10+ - IPython/Jupyter - psutil -- matplotlib (for histogram and spectrogram visualization) -- numpy (for histogram and spectrogram visualization) +- matplotlib (for histogram and heatmap visualization) +- numpy (for histogram and heatmap visualization) ## Dev Guide - Getting Started diff --git a/docs/notebooks.rst b/docs/notebooks.rst index 0a70941..ea692c1 100644 --- a/docs/notebooks.rst +++ b/docs/notebooks.rst @@ -9,6 +9,7 @@ These Jupyter notebooks demonstrate the key features of iops-profiler with pract notebooks/basic_usage notebooks/histogram_visualization + notebooks/heatmap_visualization Running the Notebooks --------------------- @@ -72,6 +73,24 @@ Explore the histogram feature for visualizing I/O patterns: **Note:** Histogram mode is available on Linux and macOS, but not Windows. +Time-Series Heatmap Visualization +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +:doc:`notebooks/heatmap_visualization` + +Explore the heatmap feature for visualizing I/O patterns over time: + +- Enabling heatmap mode with ``--heatmap`` +- Understanding when operations of different sizes occur +- Analyzing temporal I/O patterns and bursts +- Identifying phases of different I/O behavior +- Comparing heatmap with histogram views +- Real-world temporal analysis examples + +**Recommended for:** Users who want to understand how I/O behavior changes during program execution. + +**Note:** Heatmap mode is available on Linux (with strace) and macOS (with fs_usage), but not Windows. + Additional Resources -------------------- diff --git a/docs/notebooks/spectrogram_example.ipynb b/docs/notebooks/heatmap_visualization.ipynb similarity index 91% rename from docs/notebooks/spectrogram_example.ipynb rename to docs/notebooks/heatmap_visualization.ipynb index 414339d..c549d28 100644 --- a/docs/notebooks/spectrogram_example.ipynb +++ b/docs/notebooks/heatmap_visualization.ipynb @@ -4,11 +4,11 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Spectrogram Visualization Example\n", + "# Heatmap Visualization Example\n", "\n", - "This notebook demonstrates the spectrogram feature of iops-profiler, which provides a time-series heatmap view of I/O operations.\n", + "This notebook demonstrates the heatmap feature of iops-profiler, which provides a time-series heatmap view of I/O operations.\n", "\n", - "The spectrogram shows:\n", + "The heatmap shows:\n", "- **X-axis**: Time (runtime in seconds)\n", "- **Y-axis**: Operation size (bytes, log scale)\n", "- **Color**: Either the number of operations or total bytes transferred\n", @@ -41,7 +41,7 @@ "metadata": {}, "outputs": [], "source": [ - "%%iops --spectrogram\n", + "%%iops --heatmap\n", "import tempfile\n", "import os\n", "import time\n", @@ -88,7 +88,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The spectrogram above shows:\n", + "The heatmap above shows:\n", "\n", "1. **Left plot (Operation Count)**: How many I/O operations occurred in each time/size bin\n", "2. **Right plot (Total Bytes)**: How much data was transferred in each time/size bin\n", @@ -115,7 +115,7 @@ "metadata": {}, "outputs": [], "source": [ - "%%iops --spectrogram\n", + "%%iops --heatmap\n", "import tempfile\n", "import os\n", "import time\n", @@ -154,7 +154,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "This spectrogram shows a bursty pattern with clear gaps between activity phases. The color intensity helps identify which phases had the most activity or transferred the most data." + "This heatmap shows a bursty pattern with clear gaps between activity phases. The color intensity helps identify which phases had the most activity or transferred the most data." ] }, { @@ -206,7 +206,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "The histogram shows the distribution of operation sizes but doesn't reveal when they occurred. The spectrogram adds the temporal dimension, making it easier to:\n", + "The histogram shows the distribution of operation sizes but doesn't reveal when they occurred. The heatmap adds the temporal dimension, making it easier to:\n", "\n", "- Identify when different I/O patterns occur during execution\n", "- Spot bursts or gaps in I/O activity\n", diff --git a/iops_heatmap.png b/iops_heatmap.png new file mode 100644 index 0000000000000000000000000000000000000000..b49a5e87585ef1f8e4a1b6134fa168abe7e1472c GIT binary patch literal 39644 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%0D5;bIt;m~_vI^M9*6~^{t=UVXQd%J0 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 KL*lx9fBg?{p^dHp literal 0 HcmV?d00001 diff --git a/src/iops_profiler/display.py b/src/iops_profiler/display.py index ff25d2b..15010ae 100644 --- a/src/iops_profiler/display.py +++ b/src/iops_profiler/display.py @@ -17,11 +17,11 @@ from IPython.display import HTML, display -# Constants for spectrogram generation -SPECTROGRAM_SIZE_BIN_EXPANSION = 0.01 # 1% expansion for size bins (0.99 to 1.01) -SPECTROGRAM_SINGLE_VALUE_EXPANSION = 0.1 # 10% expansion for single value (0.9 to 1.1) -SPECTROGRAM_SIZE_BINS = 30 # Number of bins for operation size (log scale) -SPECTROGRAM_TIME_BINS = 50 # Number of bins for time +# Constants for heatmap generation +HEATMAP_SIZE_BIN_EXPANSION = 0.01 # 1% expansion for size bins (0.99 to 1.01) +HEATMAP_SINGLE_VALUE_EXPANSION = 0.1 # 10% expansion for single value (0.9 to 1.1) +HEATMAP_SIZE_BINS = 30 # Number of bins for operation size (log scale) +HEATMAP_TIME_BINS = 50 # Number of bins for time def is_notebook_environment(): @@ -62,26 +62,49 @@ def format_bytes(bytes_val): return f"{bytes_val:.2f} TB" -def generate_spectrogram(operations, elapsed_time): - """Generate spectrogram-like heatmaps for I/O operations over time +def format_time_axis(max_time_seconds): + """Determine appropriate time unit and formatting for axis display. + + Args: + max_time_seconds: Maximum time value in seconds + + Returns: + tuple: (time_unit_name, time_divisor, decimal_places) + """ + if max_time_seconds >= 1.0: + # Use seconds + return "s", 1.0, 2 + elif max_time_seconds >= 0.001: + # Use milliseconds + return "ms", 0.001, 1 + elif max_time_seconds >= 0.000001: + # Use microseconds + return "μs", 0.000001, 1 + else: + # Use nanoseconds + return "ns", 0.000000001, 0 + + +def generate_heatmap(operations, elapsed_time): + """Generate time-series heatmaps for I/O operations over time Args: operations: List of dicts with 'type', 'bytes', and 'timestamp' keys elapsed_time: Total elapsed time of the profiled code """ if not plt or not np: - print("⚠️ matplotlib or numpy not available. Cannot generate spectrograms.") + print("⚠️ matplotlib or numpy not available. Cannot generate heatmaps.") return if not operations: - print("⚠️ No operations captured for spectrogram generation.") + print("⚠️ No operations captured for heatmap generation.") return # Filter operations with timestamps and non-zero bytes ops_with_time = [op for op in operations if "timestamp" in op and op["bytes"] > 0] if not ops_with_time: - print("⚠️ No operations with timestamps for spectrogram generation.") + print("⚠️ No operations with timestamps for heatmap generation.") return # Convert timestamps to relative time (seconds from start) @@ -113,7 +136,7 @@ def generate_spectrogram(operations, elapsed_time): # Handle case where no valid timestamps were extracted if not relative_times: - print("⚠️ Could not parse timestamps for spectrogram generation.") + print("⚠️ Could not parse timestamps for heatmap generation.") return # Extract byte sizes @@ -125,22 +148,25 @@ def generate_spectrogram(operations, elapsed_time): if min_bytes == max_bytes: # Single value case: expand range to create meaningful bins - expansion_factor = 1 - SPECTROGRAM_SINGLE_VALUE_EXPANSION + expansion_factor = 1 - HEATMAP_SINGLE_VALUE_EXPANSION size_bins = np.array([min_bytes * expansion_factor, min_bytes / expansion_factor]) else: - # Create bins in log space - using fewer bins for spectrogram - expansion_factor = 1 - SPECTROGRAM_SIZE_BIN_EXPANSION + # Create bins in log space - using fewer bins for heatmap + expansion_factor = 1 - HEATMAP_SIZE_BIN_EXPANSION size_bins = np.logspace( np.log10(min_bytes * expansion_factor), np.log10(max_bytes / expansion_factor), - SPECTROGRAM_SIZE_BINS, + HEATMAP_SIZE_BINS, ) # Create time bins max_time = max(relative_times) if max_time == 0: max_time = elapsed_time - time_bins = np.linspace(0, max_time, SPECTROGRAM_TIME_BINS) + time_bins = np.linspace(0, max_time, HEATMAP_TIME_BINS) + + # Determine appropriate time unit and formatting for axis + time_unit, time_divisor, decimal_places = format_time_axis(max_time) # Create 2D histograms for operation counts all_count_hist, time_edges, size_edges = np.histogram2d( @@ -155,20 +181,24 @@ def generate_spectrogram(operations, elapsed_time): # Create figure with 2 subplots (operation count and total bytes) fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5)) - # Plot 1: Operation count spectrogram + # Plot 1: Operation count heatmap # Use pcolormesh for heatmap visualization time_centers = (time_edges[:-1] + time_edges[1:]) / 2 size_centers = (size_edges[:-1] + size_edges[1:]) / 2 - time_mesh, size_mesh = np.meshgrid(time_centers, size_centers) + # Convert time to appropriate unit for display + time_mesh, size_mesh = np.meshgrid(time_centers / time_divisor, size_centers) im1 = ax1.pcolormesh(time_mesh, size_mesh, all_count_hist.T, cmap="viridis", shading="auto") ax1.set_yscale("log") - ax1.set_xlabel("Time (seconds)") + ax1.set_xlabel(f"Time ({time_unit})") ax1.set_ylabel("Operation Size (bytes, log scale)") ax1.set_title("I/O Operation Count Over Time") plt.colorbar(im1, ax=ax1, label="Number of Operations") ax1.grid(True, alpha=0.3) + # Format x-axis tick labels with limited decimal places + ax1.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.{decimal_places}f}')) + # Plot 2: Total bytes spectrogram (with auto-scaling) max_bytes_in_bin = np.max(all_bytes_hist) if all_bytes_hist.size > 0 else 0 if max_bytes_in_bin < 1024: @@ -184,12 +214,15 @@ def generate_spectrogram(operations, elapsed_time): im2 = ax2.pcolormesh(time_mesh, size_mesh, (all_bytes_hist / divisor).T, cmap="plasma", shading="auto") ax2.set_yscale("log") - ax2.set_xlabel("Time (seconds)") + ax2.set_xlabel(f"Time ({time_unit})") ax2.set_ylabel("Operation Size (bytes, log scale)") ax2.set_title("I/O Total Bytes Over Time") plt.colorbar(im2, ax=ax2, label=f"Total Bytes ({unit})") ax2.grid(True, alpha=0.3) + # Format x-axis tick labels with limited decimal places + ax2.xaxis.set_major_formatter(plt.FuncFormatter(lambda x, p: f'{x:.{decimal_places}f}')) + plt.tight_layout() # Check if running in plain IPython vs notebook environment @@ -198,10 +231,10 @@ def generate_spectrogram(operations, elapsed_time): plt.show() else: # In plain IPython, save to file - output_file = "iops_spectrogram.png" + output_file = "iops_heatmap.png" plt.savefig(output_file, dpi=100, bbox_inches="tight") plt.close(fig) - print(f"📊 Spectrogram saved to: {output_file}") + print(f"📊 Heatmap saved to: {output_file}") def generate_histograms(operations): diff --git a/src/iops_profiler/magic.py b/src/iops_profiler/magic.py index 51edc40..b99f122 100644 --- a/src/iops_profiler/magic.py +++ b/src/iops_profiler/magic.py @@ -30,20 +30,20 @@ def __init__(self, shell): # Initialize the collector with shell context self.collector = Collector(shell) - def _profile_code(self, code, show_histogram=False, show_spectrogram=False): + def _profile_code(self, code, show_histogram=False, show_heatmap=False): """ Internal method to profile code with I/O measurements. Args: code: The code string to profile show_histogram: Whether to generate histograms - show_spectrogram: Whether to generate spectrogram + show_heatmap: Whether to generate time-series heatmap Returns: Dictionary with profiling results """ # Determine if we should collect individual operations - collect_ops = show_histogram or show_spectrogram + collect_ops = show_histogram or show_heatmap # Determine measurement method based on platform if self.platform == "darwin": # macOS @@ -56,16 +56,16 @@ def _profile_code(self, code, show_histogram=False, show_spectrogram=False): results = self.collector.measure_systemwide_fallback(code) if show_histogram: print("⚠️ Histograms not available for system-wide measurement mode.") - if show_spectrogram: - print("⚠️ Spectrograms not available for system-wide measurement mode.") + if show_heatmap: + print("⚠️ Heatmaps not available for system-wide measurement mode.") else: print(f"⚠️ Could not start fs_usage: {e}") print("Falling back to system-wide measurement.\n") results = self.collector.measure_systemwide_fallback(code) if show_histogram: print("⚠️ Histograms not available for system-wide measurement mode.") - if show_spectrogram: - print("⚠️ Spectrograms not available for system-wide measurement mode.") + if show_heatmap: + print("⚠️ Heatmaps not available for system-wide measurement mode.") elif self.platform in ("linux", "linux2"): # Use strace on Linux (no elevated privileges required) @@ -77,15 +77,15 @@ def _profile_code(self, code, show_histogram=False, show_spectrogram=False): results = self.collector.measure_linux_windows(code) if show_histogram: print("⚠️ Histograms not available for psutil measurement mode.") - if show_spectrogram: - print("⚠️ Spectrograms not available for psutil measurement mode.") + if show_heatmap: + print("⚠️ Heatmaps not available for psutil measurement mode.") elif self.platform == "win32": results = self.collector.measure_linux_windows(code) if show_histogram: print("⚠️ Histograms not available for psutil measurement mode on Windows.") - if show_spectrogram: - print("⚠️ Spectrograms not available for psutil measurement mode on Windows.") + if show_heatmap: + print("⚠️ Heatmaps not available for psutil measurement mode on Windows.") else: print(f"⚠️ Platform '{self.platform}' not fully supported.") @@ -93,8 +93,8 @@ def _profile_code(self, code, show_histogram=False, show_spectrogram=False): results = self.collector.measure_systemwide_fallback(code) if show_histogram: print("⚠️ Histograms not available for system-wide measurement mode.") - if show_spectrogram: - print("⚠️ Spectrograms not available for system-wide measurement mode.") + if show_heatmap: + print("⚠️ Heatmaps not available for system-wide measurement mode.") return results @@ -106,7 +106,7 @@ def iops(self, line, cell=None): Line magic usage (single line): %iops open('test.txt', 'w').write('data') %iops --histogram open('test.txt', 'w').write('data') - %iops --spectrogram open('test.txt', 'w').write('data') + %iops --heatmap open('test.txt', 'w').write('data') Cell magic usage (multiple lines): %%iops @@ -119,15 +119,15 @@ def iops(self, line, cell=None): with open('test.txt', 'w') as f: f.write('data') - %%iops --spectrogram - # Your code here (with spectrogram) + %%iops --heatmap + # Your code here (with time-series heatmap) with open('test.txt', 'w') as f: f.write('data') """ try: # Parse command line arguments show_histogram = False - show_spectrogram = False + show_heatmap = False code = None # Determine what code to execute @@ -137,24 +137,24 @@ def iops(self, line, cell=None): if line_stripped == "--histogram" or line_stripped.startswith("--histogram "): show_histogram = True code = line_stripped[len("--histogram") :].strip() - elif line_stripped == "--spectrogram" or line_stripped.startswith("--spectrogram "): - show_spectrogram = True - code = line_stripped[len("--spectrogram") :].strip() + elif line_stripped == "--heatmap" or line_stripped.startswith("--heatmap "): + show_heatmap = True + code = line_stripped[len("--heatmap") :].strip() else: code = line_stripped if not code: print("❌ Error: No code provided to profile in line magic mode.") - print(" Usage: %iops [--histogram|--spectrogram] ") + print(" Usage: %iops [--histogram|--heatmap] ") return else: # Cell magic mode - code is in the cell parameter show_histogram = "--histogram" in line - show_spectrogram = "--spectrogram" in line + show_heatmap = "--heatmap" in line code = cell # Profile the code - results = self._profile_code(code, show_histogram, show_spectrogram) + results = self._profile_code(code, show_histogram, show_heatmap) # Display results table display.display_results(results) @@ -163,9 +163,9 @@ def iops(self, line, cell=None): if show_histogram and "operations" in results: display.generate_histograms(results["operations"]) - # Display spectrogram if requested and available - if show_spectrogram and "operations" in results: - display.generate_spectrogram(results["operations"], results["elapsed_time"]) + # Display heatmap if requested and available + if show_heatmap and "operations" in results: + display.generate_heatmap(results["operations"], results["elapsed_time"]) except Exception as e: print(f"❌ Error during IOPS profiling: {e}") diff --git a/tests/test_spectrogram.py b/tests/test_heatmap.py similarity index 86% rename from tests/test_spectrogram.py rename to tests/test_heatmap.py index 31e913e..41dfc19 100644 --- a/tests/test_spectrogram.py +++ b/tests/test_heatmap.py @@ -1,7 +1,7 @@ """ -Tests for spectrogram generation and timestamp parsing in iops_profiler. +Tests for heatmap generation and timestamp parsing in iops_profiler. -This module focuses on testing spectrogram generation and timestamp extraction +This module focuses on testing heatmap generation and timestamp extraction from strace and fs_usage output. """ @@ -99,8 +99,8 @@ def test_fs_usage_without_timestamp(self, collector): # timestamp field may or may not be present depending on the line format -class TestSpectrogramGeneration: - """Test cases for spectrogram generation""" +class TestHeatmapGeneration: + """Test cases for heatmap generation""" @pytest.fixture def profiler(self): @@ -117,17 +117,17 @@ def close_figures(self): @pytest.fixture(autouse=True) def mock_notebook_environment(self, profiler): - """Mock is_notebook_environment to return True for spectrogram tests""" + """Mock is_notebook_environment to return True for heatmap tests""" with patch("iops_profiler.display.is_notebook_environment", return_value=True): yield @patch("iops_profiler.display.plt.show") def test_empty_operations_list(self, mock_show, profiler): - """Test spectrogram generation with empty operations list""" + """Test heatmap generation with empty operations list""" import matplotlib.pyplot as plt operations = [] - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) # plt.show should not be called since no plots were created mock_show.assert_not_called() @@ -137,14 +137,14 @@ def test_empty_operations_list(self, mock_show, profiler): @patch("iops_profiler.display.plt.show") def test_operations_without_timestamps(self, mock_show, profiler): - """Test spectrogram generation when operations lack timestamps""" + """Test heatmap generation when operations lack timestamps""" import matplotlib.pyplot as plt operations = [ {"type": "read", "bytes": 1024}, {"type": "write", "bytes": 2048}, ] - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) # plt.show should not be called since no plots were created mock_show.assert_not_called() @@ -154,7 +154,7 @@ def test_operations_without_timestamps(self, mock_show, profiler): @patch("iops_profiler.display.plt.show") def test_unix_timestamp_format(self, mock_show, profiler): - """Test spectrogram with Unix timestamp format (strace)""" + """Test heatmap with Unix timestamp format (strace)""" import matplotlib.pyplot as plt operations = [ @@ -163,7 +163,7 @@ def test_unix_timestamp_format(self, mock_show, profiler): {"type": "read", "bytes": 512, "timestamp": "1234567890.300000"}, {"type": "write", "bytes": 4096, "timestamp": "1234567890.400000"}, ] - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) # plt.show should be called once mock_show.assert_called_once() @@ -191,7 +191,7 @@ def test_unix_timestamp_format(self, mock_show, profiler): @patch("iops_profiler.display.plt.show") def test_fs_usage_timestamp_format(self, mock_show, profiler): - """Test spectrogram with HH:MM:SS.ffffff timestamp format (fs_usage)""" + """Test heatmap with HH:MM:SS.ffffff timestamp format (fs_usage)""" import matplotlib.pyplot as plt operations = [ @@ -200,7 +200,7 @@ def test_fs_usage_timestamp_format(self, mock_show, profiler): {"type": "read", "bytes": 512, "timestamp": "12:34:56.300000"}, {"type": "write", "bytes": 4096, "timestamp": "12:34:56.400000"}, ] - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) # plt.show should be called once mock_show.assert_called_once() @@ -216,7 +216,7 @@ def test_fs_usage_timestamp_format(self, mock_show, profiler): @patch("iops_profiler.display.plt.show") def test_wide_range_of_sizes_over_time(self, mock_show, profiler): - """Test spectrogram with wide range of operation sizes over time""" + """Test heatmap with wide range of operation sizes over time""" import matplotlib.pyplot as plt operations = [] @@ -227,7 +227,7 @@ def test_wide_range_of_sizes_over_time(self, mock_show, profiler): op_type = "read" if i % 2 == 0 else "write" operations.append({"type": op_type, "bytes": byte_size, "timestamp": timestamp}) - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) # plt.show should be called once mock_show.assert_called_once() @@ -243,7 +243,7 @@ def test_wide_range_of_sizes_over_time(self, mock_show, profiler): @patch("iops_profiler.display.plt.show") def test_many_operations_over_time(self, mock_show, profiler): - """Test spectrogram with many operations""" + """Test heatmap with many operations""" import matplotlib.pyplot as plt operations = [] @@ -254,7 +254,7 @@ def test_many_operations_over_time(self, mock_show, profiler): op_type = "read" if i % 2 == 0 else "write" operations.append({"type": op_type, "bytes": byte_size, "timestamp": timestamp}) - display.generate_spectrogram(operations, 10.0) + display.generate_heatmap(operations, 10.0) # plt.show should be called once mock_show.assert_called_once() @@ -265,19 +265,19 @@ def test_many_operations_over_time(self, mock_show, profiler): @patch("iops_profiler.display.plt.show") def test_operations_with_zero_bytes_ignored(self, mock_show, profiler): - """Test that operations with zero bytes are ignored in spectrogram""" + """Test that operations with zero bytes are ignored in heatmap""" operations = [ {"type": "read", "bytes": 0, "timestamp": "1234567890.100000"}, {"type": "write", "bytes": 2048, "timestamp": "1234567890.200000"}, {"type": "read", "bytes": 0, "timestamp": "1234567890.300000"}, ] - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) # Should still create a plot (has one non-zero operation) mock_show.assert_called_once() def test_no_matplotlib_installed(self, profiler): - """Test spectrogram generation when matplotlib is not available""" + """Test heatmap generation when matplotlib is not available""" from iops_profiler import display original_plt = display.plt @@ -288,13 +288,13 @@ def test_no_matplotlib_installed(self, profiler): try: operations = [{"type": "read", "bytes": 1024, "timestamp": "1234567890.100000"}] # Should print warning and return early - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) finally: # Restore original plt display.plt = original_plt def test_no_numpy_installed(self, profiler): - """Test spectrogram generation when numpy is not available""" + """Test heatmap generation when numpy is not available""" from iops_profiler import display original_np = display.np @@ -305,15 +305,15 @@ def test_no_numpy_installed(self, profiler): try: operations = [{"type": "read", "bytes": 1024, "timestamp": "1234567890.100000"}] # Should print warning and return early - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) finally: # Restore original np display.np = original_np @patch("iops_profiler.display.plt") @patch("iops_profiler.display.np") - def test_spectrogram_saves_to_file_in_terminal(self, mock_np, mock_plt, profiler, capsys): - """Test spectrogram saves to file in terminal mode""" + def test_heatmap_saves_to_file_in_terminal(self, mock_np, mock_plt, profiler, capsys): + """Test heatmap saves to file in terminal mode""" import numpy as np mock_np.histogram2d = np.histogram2d @@ -336,10 +336,10 @@ def test_spectrogram_saves_to_file_in_terminal(self, mock_np, mock_plt, profiler # Test terminal mode - saves to file with patch("iops_profiler.display.is_notebook_environment", return_value=False): - display.generate_spectrogram(operations, 1.0) + display.generate_heatmap(operations, 1.0) mock_plt.savefig.assert_called_once() mock_plt.show.assert_not_called() mock_plt.close.assert_called_once_with(mock_fig) captured = capsys.readouterr() - assert "iops_spectrogram.png" in captured.out + assert "iops_heatmap.png" in captured.out