Skip to content

Repository files navigation

MinorFlow

A browser-based pipeline visualizer for gem5's MinorCPU. It reconstructs the pipeline cycle by cycle from a gem5 debug trace and draws every instruction as a row, so you can see exactly where cycles are being lost.

MinorFlow rendering the MinorCPU pipeline

Motivation

gem5 tells you an instruction took a long time. It does not tell you why. The debug trace holds the answer, but a real workload produces hundreds of megabytes of it, and reading that by hand is not viable.

MinorFlow turns that trace into a picture. Each row is an instruction, each cell is a cycle in a stage, and the colour tells you what the core was doing: waiting on an instruction-cache fill, stalled behind a functional unit, held by the scoreboard waiting for an operand, or paying for a mispredicted branch.

Quick start

Capture a trace from gem5:

gem5.opt --debug-flags=Minor,MinorTrace,MinorTiming,CacheAll,ExecAll,Fetch,Decode,IEW,Commit,LSQ,Scoreboard,Writeback \
         --debug-file=trace.txt \
         gem5_config_MinorFlow.py <binary>

Or let run_gem5.py compile the test, run it with those flags and report the metrics, all in one command:

python3 run_gem5.py gem5_config_MinorFlow.py daxpy.S

Convert the trace to JSON. The driver leaves a copy in run_results/ next to itself, so that is the shortest path to it:

python3 MinorFlow_tracer.py run_results/daxpy_trace.txt -o trace.json

Then open MinorFlow.html in any browser and drag trace.json onto the window. There is nothing to install and nothing to serve. The viewer is a single self-contained HTML file with no dependencies.

The landing page also offers a sample trace. It is not committed, because of its size. Generate one at tests/daxpy.config1.json, or at tests/daxpy.config1.js wrapping it as window.__SAMPLE_TRACE__ = {...} for file:// use.

Tracer options

python3 MinorFlow_tracer.py <trace> [-o OUT] [--stats] [--quiet]
Option Meaning
trace Path to the gem5 MinorCPU debug trace (.txt, .log)
-o, --out Output JSON path. Defaults to <trace>.json
--stats Print a summary of committed and flushed instructions plus instruction-cache activity
--quiet Suppress the progress output

If the tracer parses zero instructions it will tell you so, which almost always means the trace was captured without the Minor debug flags.

Running a test: run_gem5.py

Capturing a trace by hand means compiling the test against gem5's m5op.S, remembering the full debug-flag list, and then reading the numbers out of stats.txt. run_gem5.py does all of it in one command, and is how every trace in tests/ was produced.

Run it from the gem5 root: the script takes the current directory as the gem5 root and looks for ./build/RISCV/gem5.opt, ./include and ./util/m5/src/abi/riscv/m5op.S from there.

python3 run_gem5.py <config>.py <test> [--build NAME] [--lang c|asm] [--no-trace]
Argument Meaning
<config>.py The gem5 MinorCPU configuration script, for example gem5_config_MinorFlow.py
<test> The program to run: C (.c) or assembly (.S, .s, .asm). The type is detected from the extension
--lang Force the type instead of detecting it
--build Which build to run: a directory name under build/, a path to one, or a path to the binary. Defaults to RISCV
A patched CVA6 build is flagged with a warning, since the overhead profile here was measured on a stock one
--no-trace Skip the debug flags and report metrics only. Use it when you only want the numbers, since the trace is the expensive part
anything else Passed on to the configuration script. A configuration that defines its own options gets them this way

What it does, in order:

  1. Compiles. riscv64-unknown-elf-gcc for rv64gc with the bit-manipulation and crypto extensions, freestanding (-nostdlib -nostartfiles -static -mcmodel=medany), linking gem5's m5op.S so the program can call m5_reset_stats, m5_dump_stats and m5_exit. C tests also get -fno-builtin -e main, since there is no crt0 to enter through.
  2. Runs gem5 into m5out/, adding the debug flags MinorFlow needs (Minor, MinorTrace, MinorTiming, CacheAll, ExecAll, Fetch, Decode, IEW, Commit, LSQ, Scoreboard, Writeback) and writing m5out/<test>_trace.txt. That file is the tracer's input.
  3. Disassembles. objdump -d -S -l into m5out/<test>.list, printing it up to the jal to m5_dump_stats, which is where the measured region ends. The printed part is saved as m5out/<test>_report.txt, under a DISASSEMBLED CODE banner and closed by an END OF DISASSEMBLED CODE one.
  4. Prints the table, parsed from the first statistics block in stats.txt, the one delimited by the m5_reset_stats and m5_dump_stats calls: cycles, instructions, I-cache and D-cache misses and accesses, branches, mispredictions plus unpredicted, elapsed microseconds and IPC. The table is appended to m5out/<test>_report.txt below the disassembly, in its own banner, so the two sections can be told apart at a glance. Its title line names the simulator, the program and the L1 geometry the run used, read from gem5's config.ini, and the line under it names the configuration file and the flags it was given.
  5. Copies out the keepers. The trace, the .list, the _report.txt and stats.txt renamed to <test>_stats.txt go into a run_results/ folder next to the script, so a run leaves everything the viewer needs in one place while gem5's own output stays in m5out/.

If a run fails nothing is deleted, and gem5's whole stdout and stderr are written to <test>_error.log in the output folder, with the end of it printed.

The test is compiled into the gem5 output folder rather than beside the source, so a run touches nothing outside its own folders. --gem5-out-dir and --results-dir move those folders, which is how the sweep gives concurrent runs one each.

The table has an OFFICIAL and a NET column. NET subtracts a fixed instrumentation overhead. A patched build adds a third, NET (CVA6).

What a patched build adds

MinorCPU_CVA6.patch puts two mechanisms in the trace that a stock build has no counterpart for, and the viewer draws both.

Store-collision hold. CVA6 has no store-to-load forwarding, so a load whose address collides with a pending store waits in the load-store queue for the store buffer to drain, then pays a restart penalty before presenting its request again. The two together fill the gap between memPush and memIssue, which is blank without them, and appear as st coll and replay strips with their own legend entries. On store_fwd 73 loads are held this way.

Data-cache holds. The D$ held toggle tints every cycle column in which the cache held a request off. Holding is a property of the cache rather than of any one instruction, so it draws as a band across every row, like the stall highlight.

The patch offers two forms and they are exclusive. Blocking refuses the port, for stock causes such as MSHRs and targets and for the patch's dirty-victim readout, fence flush and refill window. Accept-and-charge, which the production configuration uses, takes the request and charges it the window instead. The band covers both, so it reads the same either way.

Return address stack. run_gem5.py now enables gem5's RAS debug flag, which is stock but off by default. Every call that pushes and every return that pops is marked on its Fetch2 cell as ras+ and ras-, one strip row below the branch outcome so a return that both pops and mispredicts shows each of them.

A configuration script may define options of its own. Any flag run_gem5.py does not recognise is handed to it, since gem5 passes everything after the script's path to the script:

python3 run_gem5.py my_config.py daxpy.S --some-config-flag
python3 run_gem5.py my_config.py daxpy.S -- --some-config-flag   # when it takes a value

The -- form is the unambiguous one: use it for a flag that takes a value, or one whose name collides with --lang or --no-trace. Forwarded flags are echoed before the run, and if the configuration rejects them its own error comes back through.

Writing a test

benchmarks/ holds the tests used to develop MinorFlow, and test_template.c and test_template.S are the starting points. The template sets up gp, calls m5_reset_stats, leaves a MAIN PROGRAM / END OF MAIN PROGRAM region for your code, and then calls m5_dump_stats and m5_exit. Write inside the markers and the driver measures and disassembles exactly that region.

Running the sweep: run_MinorFlow_sweep.py

gem5_config_MinorFlow.py is not one machine but seventeen. Set TEST to the one you want. TEST 1 is the Reference Core, and every other entry perturbs one part of the pipeline so its effect is visible in the viewer, against the workload that shows it:

# What it changes Workload
1 baseline all
2 fetch2ToDecodeForwardDelay 1 to 2 daxpy
3 decodeToExecuteForwardDelay 1 to 2 daxpy
4 fetch1LineWidth and snap 4 to 16 icache_hit_loop
5 fetch1FetchLimit 1 to 4, L1I 16KiB to 2KiB icache_hit_loop
6 fetch2InputBufferSize 3 to 6 int_loop
7 decodeInputBufferSize 4 to 8 int_loop
8 executeInputBufferSize 8 to 3 int_loop
9 dual issue, 2-wide matrix_mul
10 executeCommitLimit 2 to 1 on the 2-wide pipe, so commit becomes the binding limit against 9 matrix_mul
11 branchPred LocalBP to TournamentBP branch_stress
12 L1D access latency 1 to 3 dcache_hit_loop
13 executeLSQStoreBufferSize 16 to 2 stream_store
14 baseline at 47 MHz, clock only int_loop
15 L1I access latency 1 to 3 icache_hit_loop
16 executeBranchDelay 1 to 10 branch_stress
17 combination: 2-wide, L1I and L1D latency 3, forward delays 2, branch delay 5, 60 MHz daxpy

run_MinorFlow_sweep.py replays all of it, which is how the traces in tests/ were produced. It always sweeps gem5_config_MinorFlow.py, the config it is written for, so it takes no config argument. Run it from the gem5 root, like run_gem5.py:

python3 run_MinorFlow_sweep.py [--configs 1,4-6] [--tests-dir DIR] [--build NAME] [--no-trace] [--list]
Option Meaning
--configs Which configurations to run, for example 1,4-6. Defaults to every one in the table
--tests-dir Where the workloads live. Defaults to benchmarks/, relative to the gem5 root
--tests Comma-separated workloads to run for every configuration, instead of the ones the table names
--out-dir Where results are collected. Defaults to MinorFlow_sweep_results/
--config Sweep a copy or a variant of gem5_config_MinorFlow.py instead
--no-trace Metrics only, no traces
-j, --jobs How many runs to keep in flight. Defaults to 4. gem5 is single-threaded, so this scales with cores until memory or disk bandwidth binds
--list Print the plan and exit, touching nothing

For each configuration it sets TEST and runs that entry's workloads through run_gem5.py. An entry whose workload is all runs every workload the table names.

Results are moved out of run_results/ into the out directory as <test>_trace.config<N>.txt, <test>_report.config<N>.txt, <test>_stats.config<N>.txt and <test>.config<N>.list, which is the naming tests/ uses, so one configuration never overwrites another and each trace stays paired with the run it came from. Every metrics table is also gathered into one metrics.txt in that folder, labelled by configuration and test, so the whole sweep can be read without opening a file per run.

Each run works in its own folder under m5out/ and run_results/, and once collected that folder is deleted. A run that fails is the exception: nothing of its is collected or deleted, so its output stays in m5out/config<N>_<test>/ and is still there at the end. Both parent folders are removed if the sweep leaves them empty, and left alone otherwise, since a plain run_gem5.py run writes into them too.

The sweep never edits gem5_config_MinorFlow.py: it writes one temporary copy per configuration with TEST set, runs those, and deletes them at the end. So an interrupted sweep leaves nothing to restore, and two sweeps can run at once. Use --list first: it prints what each configuration would run, names the closest files for any workload that matches nothing, and calls out configurations left with nothing to run.

Why a separate tracer

The parser used to run in the browser. Large traces do not fit inside the browser's string-size and memory ceilings, so parsing moved to Python, where a trace is processed once, offline. The viewer loads the resulting JSON and does the windowing, the bubble and stall analysis, and the rendering. None of that logic is duplicated between the two.

What the viewer shows

Per instruction, across its whole lifetime:

  • Fetch1, request and response, with the request drawn red when the line misses the instruction cache and the fill time charged to the stage that waits for it. Instructions whose bytes span two fetch lines are drawn as two requests, so a line-spanning fetch is visible as such.
  • Fetch2, where the branch predictor is consulted, with correct predictions and mispredictions distinguished.
  • Decode, and the forward delay of each pipeline latch when the corresponding delay parameter is greater than one.
  • Execute, including the wait ahead of a functional unit for the unit itself and for operands held by the scoreboard.
  • Commit wait and commit, so in-order retirement pressure is visible.

Bubbles, front-end stalls, serialisation delays and branch delays each get their own colour and their own entry in the legend, with an explanation attached.

Plus the usual quality-of-life: fit-to-viewport zoom, a hover panel with per-instruction detail, and a PC search box that matches anywhere in the address and steps through hits across the whole fetch window rather than only the rows on screen. Every control has an in-app tooltip, so they are not repeated here.

Keys: + and to zoom, arrows to navigate, Home and End to jump.

Tested with

  • gem5 v25.0.0.1, MinorCPU RISC-V model.
  • A ready-to-use Docker image with gem5 already built, so you can produce traces without compiling anything:
docker pull manuel313/gem5_v25

Image: https://hub.docker.com/repository/docker/manuel313/gem5_v25/general

Requirements

  • Python 3, standard library only
  • Any modern browser
  • gem5 with the MinorCPU RISC-V model, for producing traces

Paper

MinorFlow is described in MinorFlow: A gem5 Pipeline Visualizer for Teaching Computer Architecture, by Manuel Nieto, Francisco Cortez Casini, María Delfina Vélez Ibarra and Gonzalo Tomás Vodanovic, submitted to CARLA 2026, the Latin America High Performance Computing Conference. It motivates the tool from the gap between the textbook five-stage pipeline and what gem5 actually reports, describes the tracer and the viewer, and validates the timeline against gem5's own stats.txt on daxpy.

Everything behind the paper lives in docs/CARLA2026/, frozen at the state it was submitted in:

Path Contents
MinorFlow: A gem5 Pipeline Visualizer for Teaching Computer Architecture.pdf The submitted paper
latex/ LaTeX sources, bibliography and LNCS style files
images/ Figures: the pipeline and workflow diagrams, the renderer, and the three case studies
gem5_config_Reference_Core.py The gem5 configuration of the Reference Core the paper measures
daxpy_validation/ The daxpy kernel, its trace-derived JSON and the stats.txt behind the validation table
MinorFlow.html, MinorFlow_tracer.py The viewer and tracer as submitted

The Reference Core is the single-issue in-order 64-bit RISC-V MinorCPU of Table 1 in the paper: 100 MHz, a 16 KiB 4-way L1I and a 32 KiB 8-way L1D at one-cycle hit, a 1024-entry local branch predictor with a 256-entry BTB and a 16-entry RAS. Run it the same way as any other config:

gem5.opt --debug-flags=Minor,MinorTrace,MinorTiming,CacheAll,ExecAll,Fetch,Decode,IEW,Commit,LSQ,Scoreboard,Writeback \
         --debug-file=trace.txt \
         gem5_config_Reference_Core.py <binary>

It is the baseline of the sweep in gem5_config_MinorFlow.py, flattened into a standalone file: identical parameters, without the test table. Use the sweep instead when you want to perturb one part of the pipeline against this baseline.

If you use MinorFlow in academic work, please cite it. CITATION.cff carries the metadata for both the software and the paper.

Related

CVA6Flow is the sibling tool. It visualises the CORE-V CVA6 RISC-V core running under Verilator, reconstructed from raw VCD signal dumps, and is deliberately built to look and behave like MinorFlow so that a simulated pipeline and a real RTL pipeline can be compared side by side.

Both come out of an undergraduate thesis at FaMAF, Universidad Nacional de Córdoba, asking how closely a gem5 MinorCPU configuration can be made to match a real RISC-V core.

Cleaning up

clean_repo.py deletes what a run leaves in this repository: every .list and gem5 debug trace, and every __pycache__. A .vcd or .fst belongs to the Verilator side and is never touched.

python3 clean_repo.py [-y] [--dry-run] [-v]

It lists what it found with its size and asks before deleting. The viewer JSONs are left alone and docs/ is kept whole, since the CARLA 2026 daxpy validation under it is the evidence behind the paper.

Oversized JSONs

A tracer JSON is never deleted, since it is what the viewer reads, but a long run makes one too big to commit: GitHub warns above 50 MiB and refuses above 100 MiB, and git matches a path and never a size. ignore_big_json.py measures the JSONs and the .js wrappers in this repository and writes the oversized ones into a block of .gitignore that it owns.

python3 ignore_big_json.py [-y] [--dry-run] [-v] [-l MIB] [--prune]

It only ever adds, so a second run changes nothing. --prune drops the entries whose file has gone or shrunk, and -l sets a different threshold in MiB. A file git already tracks is reported rather than ignored.

Licence

Released under the MIT Licence. See LICENSE.

About

Browser-based, cycle-by-cycle pipeline visualizer for gem5's MinorCPU, built from gem5 debug traces.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages