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.
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.
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.SConvert 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.jsonThen 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.
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.
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:
- Compiles.
riscv64-unknown-elf-gccforrv64gcwith the bit-manipulation and crypto extensions, freestanding (-nostdlib -nostartfiles -static -mcmodel=medany), linking gem5'sm5op.Sso the program can callm5_reset_stats,m5_dump_statsandm5_exit. C tests also get-fno-builtin -e main, since there is no crt0 to enter through. - Runs gem5 into
m5out/, adding the debug flags MinorFlow needs (Minor,MinorTrace,MinorTiming,CacheAll,ExecAll,Fetch,Decode,IEW,Commit,LSQ,Scoreboard,Writeback) and writingm5out/<test>_trace.txt. That file is the tracer's input. - Disassembles.
objdump -d -S -lintom5out/<test>.list, printing it up to thejaltom5_dump_stats, which is where the measured region ends. The printed part is saved asm5out/<test>_report.txt, under aDISASSEMBLED CODEbanner and closed by anEND OF DISASSEMBLED CODEone. - Prints the table, parsed from the first statistics block in
stats.txt, the one delimited by them5_reset_statsandm5_dump_statscalls: cycles, instructions, I-cache and D-cache misses and accesses, branches, mispredictions plus unpredicted, elapsed microseconds and IPC. The table is appended tom5out/<test>_report.txtbelow 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'sconfig.ini, and the line under it names the configuration file and the flags it was given. - Copies out the keepers. The trace, the
.list, the_report.txtandstats.txtrenamed to<test>_stats.txtgo into arun_results/folder next to the script, so a run leaves everything the viewer needs in one place while gem5's own output stays inm5out/.
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).
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 valueThe -- 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.
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.
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.
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.
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.
- 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_v25Image: https://hub.docker.com/repository/docker/manuel313/gem5_v25/general
- Python 3, standard library only
- Any modern browser
- gem5 with the MinorCPU RISC-V model, for producing traces
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.
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.
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.
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.
Released under the MIT Licence. See LICENSE.
