From 720d20d3e07d6d0387063e20b5688134c43a87ba Mon Sep 17 00:00:00 2001 From: cozis Date: Mon, 8 Jun 2026 12:35:34 +0200 Subject: [PATCH] diagram: add program to generate various visual representations of the simulation trace --- diagram/main.zig | 53 +++++ diagram/strace.zig | 118 +++++++++++ diagram/timeline_ascii.zig | 260 ++++++++++++++++++++++++ diagram/timeline_html.zig | 399 +++++++++++++++++++++++++++++++++++++ 4 files changed, 830 insertions(+) create mode 100644 diagram/main.zig create mode 100644 diagram/strace.zig create mode 100644 diagram/timeline_ascii.zig create mode 100644 diagram/timeline_html.zig diff --git a/diagram/main.zig b/diagram/main.zig new file mode 100644 index 0000000..70bd1f6 --- /dev/null +++ b/diagram/main.zig @@ -0,0 +1,53 @@ +const std = @import("std"); + +const strace = @import("strace.zig"); +const timeline_html = @import("timeline_html.zig"); +const timeline_ascii = @import("timeline_ascii.zig"); + +pub fn main(init: std.process.Init) !void { + + var args = init.minimal.args.iterate(); + _ = args.next(); // Skip first + + const diagram_name = args.next() orelse { + std.debug.print("Missing diagram name\n", .{}); + return; + }; + + const input_file = args.next() orelse "simulation.log"; + + if (std.mem.eql(u8, diagram_name, "strace")) { + + const output_file = args.next() orelse "strace.txt"; + strace.renderFile(init.io, init.gpa, input_file, output_file) catch |e| { + if (e != error.FileNotFound) + return e; + std.debug.print("File {s} not found\n", .{input_file}); + return; + }; + + } else if (std.mem.eql(u8, diagram_name, "timeline_html")) { + + const output_file = args.next() orelse "timeline.html"; + timeline_html.renderFile(init.io, init.gpa, input_file, output_file) catch |e| { + if (e != error.FileNotFound) + return e; + std.debug.print("File {s} not found\n", .{input_file}); + return; + }; + + } else if (std.mem.eql(u8, diagram_name, "timeline_ascii")) { + + const output_file = args.next() orelse "timeline.txt"; + timeline_ascii.renderFile(init.io, init.gpa, input_file, output_file, 1) catch |e| { + if (e != error.FileNotFound) + return e; + std.debug.print("File {s} not found\n", .{input_file}); + return; + }; + + } else { + + std.debug.print("Invalid diagram name\n", .{}); + } +} diff --git a/diagram/strace.zig b/diagram/strace.zig new file mode 100644 index 0000000..38be21d --- /dev/null +++ b/diagram/strace.zig @@ -0,0 +1,118 @@ +const std = @import("std"); + +const TraceEvent = struct { + time: u64, + event: []const u8, + id: ?u64 = null, + node: ?u32 = null, + task: ?u64 = null, + op: ?[]const u8 = null, + resource: ?[]const u8 = null, + start: ?u64 = null, + end: ?u64 = null, + result: ?[]const u8 = null, +}; + +const Start = struct { + time: u64, +}; + +const Operation = struct { + id: u64, + start: u64, + end: u64, + duration: u64, + node: u32, + task: u64, + op: []const u8, + resource: []const u8, + result: []const u8, +}; + +pub fn renderFile(io: std.Io, gpa: std.mem.Allocator, trace_path: []const u8, output_path: []const u8) !void { + const file = try std.Io.Dir.cwd().createFile(io, output_path, .{}); + defer file.close(io); + + var writer = file.writerStreaming(io, &.{}); + try render(io, gpa, trace_path, &writer.interface); + try writer.interface.flush(); +} + +pub fn render(io: std.Io, gpa: std.mem.Allocator, trace_path: []const u8, writer: *std.Io.Writer) !void { + const trace_bytes = try std.Io.Dir.cwd().readFileAlloc(io, trace_path, gpa, .limited(64 * 1024 * 1024)); + defer gpa.free(trace_bytes); + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + + var starts: std.AutoHashMap(u64, Start) = .init(arena.allocator()); + var operations: std.ArrayList(Operation) = .empty; + + var lines = std.mem.splitScalar(u8, trace_bytes, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) continue; + + var parsed = try std.json.parseFromSlice(TraceEvent, arena.allocator(), trimmed, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + const event = parsed.value; + + if (std.mem.eql(u8, event.event, "io_start")) { + try starts.put(event.id.?, .{ .time = event.time }); + continue; + } + + if (std.mem.eql(u8, event.event, "io_complete")) { + const start_entry = starts.fetchRemove(event.id.?); + const start = event.start orelse if (start_entry) |entry| entry.value.time else event.time; + const end = event.end orelse event.time; + + try operations.append(arena.allocator(), .{ + .id = event.id.?, + .start = start, + .end = end, + .duration = end -| start, + .node = event.node.?, + .task = event.task.?, + .op = try arena.allocator().dupe(u8, event.op orelse ""), + .resource = try arena.allocator().dupe(u8, event.resource orelse ""), + .result = try arena.allocator().dupe(u8, event.result orelse ""), + }); + } + } + + sortOperations(operations.items); + + for (operations.items) |operation| { + try writer.print( + "{d}.{d:0>3} n{d} t{d} {s} = {s}\n", + .{ + operation.start / 1000, + operation.start % 1000, + operation.node, + operation.task, + operation.op, + operation.result, + }, + ); + } +} + +fn sortOperations(operations: []Operation) void { + if (operations.len < 2) return; + + var i: usize = 1; + while (i < operations.len) : (i += 1) { + const current = operations[i]; + var j = i; + while (j > 0 and operationLess(current, operations[j - 1])) : (j -= 1) { + operations[j] = operations[j - 1]; + } + operations[j] = current; + } +} + +fn operationLess(a: Operation, b: Operation) bool { + if (a.start != b.start) return a.start < b.start; + return a.id < b.id; +} diff --git a/diagram/timeline_ascii.zig b/diagram/timeline_ascii.zig new file mode 100644 index 0000000..3f16ab0 --- /dev/null +++ b/diagram/timeline_ascii.zig @@ -0,0 +1,260 @@ +const std = @import("std"); + +const Interval = struct { + node: u32, + task: u64, + state: []const u8, + start: u64, + end: u64, +}; + +const StateTick = struct { + node: u32, + task: u64, + state: []const u8, + time: u64, +}; + +const TraceEvent = struct { + time: u64, + event: []const u8, + node: ?u32 = null, + task: ?u64 = null, + state: ?[]const u8 = null, +}; + +const TaskKey = struct { + node: u32, + task: u64, +}; + +const ActiveState = struct { + state: []const u8, + start: u64, +}; + +pub fn renderFile(io: std.Io, gpa: std.mem.Allocator, trace_path: []const u8, output_path: []const u8, tick_us: u64) !void { + try validateTickSize(tick_us); + + const file = try std.Io.Dir.cwd().createFile(io, output_path, .{}); + defer file.close(io); + + var writer = file.writerStreaming(io, &.{}); + try render(io, gpa, trace_path, tick_us, &writer.interface); + try writer.interface.flush(); +} + +pub fn render(io: std.Io, gpa: std.mem.Allocator, trace_path: []const u8, tick_us: u64, writer: *std.Io.Writer) !void { + try validateTickSize(tick_us); + + const trace_bytes = try std.Io.Dir.cwd().readFileAlloc(io, trace_path, gpa, .limited(64 * 1024 * 1024)); + defer gpa.free(trace_bytes); + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + var intervals: std.ArrayList(Interval) = .empty; + var ticks: std.ArrayList(StateTick) = .empty; + var lanes: std.ArrayList(TaskKey) = .empty; + const max_time = try inferIntervals(arena_alloc, trace_bytes, &intervals, &ticks, &lanes); + sortLanes(lanes.items); + + try writeAscii(gpa, writer, intervals.items, ticks.items, lanes.items, max_time, tick_us); +} + +fn validateTickSize(tick_us: u64) !void { + if (tick_us == 0) return error.InvalidTickSize; +} + +fn inferIntervals( + arena: std.mem.Allocator, + trace_bytes: []const u8, + intervals: *std.ArrayList(Interval), + ticks: *std.ArrayList(StateTick), + lanes: *std.ArrayList(TaskKey), +) !u64 { + var active: std.AutoHashMap(TaskKey, ActiveState) = .init(arena); + var seen_lanes: std.AutoHashMap(TaskKey, void) = .init(arena); + var max_time: u64 = 0; + + var lines = std.mem.splitScalar(u8, trace_bytes, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) continue; + + var parsed = try std.json.parseFromSlice(TraceEvent, arena, trimmed, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + const event = parsed.value; + max_time = @max(max_time, event.time); + + if (std.mem.eql(u8, event.event, "state")) { + const key = TaskKey{ .node = event.node.?, .task = event.task.? }; + try rememberLane(arena, lanes, &seen_lanes, key); + + const state = try arena.dupe(u8, event.state.?); + if (try active.fetchPut(key, .{ .state = state, .start = event.time })) |previous_entry| { + try appendInterval(arena, intervals, ticks, key, previous_entry.value, event.time); + } + } else if (std.mem.eql(u8, event.event, "task_removed")) { + const key = TaskKey{ .node = event.node.?, .task = event.task.? }; + try rememberLane(arena, lanes, &seen_lanes, key); + + if (active.fetchRemove(key)) |previous_entry| { + try appendInterval(arena, intervals, ticks, key, previous_entry.value, event.time); + } + } + } + + const final_time = max_time + 1; + var active_iter = active.iterator(); + while (active_iter.next()) |entry| { + try appendInterval(arena, intervals, ticks, entry.key_ptr.*, entry.value_ptr.*, final_time); + } + return final_time; +} + +fn rememberLane(arena: std.mem.Allocator, lanes: *std.ArrayList(TaskKey), seen_lanes: *std.AutoHashMap(TaskKey, void), key: TaskKey) !void { + if (seen_lanes.contains(key)) return; + try seen_lanes.put(key, {}); + try lanes.append(arena, key); +} + +fn appendInterval( + arena: std.mem.Allocator, + intervals: *std.ArrayList(Interval), + ticks: *std.ArrayList(StateTick), + key: TaskKey, + state: ActiveState, + end: u64, +) !void { + var interval_end = end; + if (state.start == interval_end and (std.mem.eql(u8, state.state, "returned") or std.mem.eql(u8, state.state, "failed"))) { + interval_end += 1; + } + + if (state.start >= interval_end) { + try ticks.append(arena, .{ + .node = key.node, + .task = key.task, + .state = state.state, + .time = state.start, + }); + return; + } + + try intervals.append(arena, .{ + .node = key.node, + .task = key.task, + .state = state.state, + .start = state.start, + .end = interval_end, + }); +} + +fn sortLanes(lanes: []TaskKey) void { + if (lanes.len < 2) return; + + var i: usize = 1; + while (i < lanes.len) : (i += 1) { + const current = lanes[i]; + var j = i; + while (j > 0 and laneLess(current, lanes[j - 1])) : (j -= 1) { + lanes[j] = lanes[j - 1]; + } + lanes[j] = current; + } +} + +fn laneLess(a: TaskKey, b: TaskKey) bool { + if (a.node != b.node) return a.node < b.node; + return a.task < b.task; +} + +fn writeAscii( + allocator: std.mem.Allocator, + writer: *std.Io.Writer, + intervals: []const Interval, + ticks: []const StateTick, + lanes: []const TaskKey, + max_time: u64, + tick_us: u64, +) !void { + try writer.writeAll("legend: x=running r=ready b=blocked/sleeping w=waiting R=returned f=failed .=idle\n"); + try writer.print("tick: {}us; duplicate rows suppressed\n", .{tick_us}); + try writer.writeAll("columns:\n"); + for (lanes, 0..) |lane, index| { + try writer.print(" {}: node {} task {}\n", .{ index, lane.node, lane.task }); + } + try writer.writeAll("\n"); + + try writer.writeAll("time_us | "); + for (lanes, 0..) |_, index| { + try writer.writeByte(indexChar(index)); + } + try writer.writeAll("\n"); + try writer.writeAll("---------+-"); + for (lanes) |_| { + try writer.writeByte('-'); + } + try writer.writeAll("\n"); + + const previous = try allocator.alloc(u8, lanes.len); + defer allocator.free(previous); + const current = try allocator.alloc(u8, lanes.len); + defer allocator.free(current); + @memset(previous, 0); + + var have_previous = false; + var row_start: u64 = 0; + while (row_start <= max_time) : (row_start += tick_us) { + const row_end = row_start + tick_us; + for (lanes, 0..) |lane, index| { + current[index] = stateAt(intervals, ticks, lane, row_start, row_end); + } + + if (!have_previous or !std.mem.eql(u8, previous, current)) { + try writeRow(writer, row_start, current); + @memcpy(previous, current); + have_previous = true; + } + } +} + +fn writeRow(writer: *std.Io.Writer, row_start: u64, states: []const u8) !void { + try writer.print("{d: >8} | ", .{row_start}); + try writer.writeAll(states); + try writer.writeAll("\n"); +} + +fn stateAt(intervals: []const Interval, ticks: []const StateTick, lane: TaskKey, row_start: u64, row_end: u64) u8 { + for (intervals) |interval| { + if (interval.node != lane.node or interval.task != lane.task) + continue; + if (interval.start < row_end and interval.end > row_start) + return stateChar(interval.state); + } + + for (ticks) |tick| { + if (tick.node == lane.node and tick.task == lane.task and tick.time >= row_start and tick.time < row_end) + return stateChar(tick.state); + } + + return '.'; +} + +fn stateChar(state: []const u8) u8 { + if (std.mem.eql(u8, state, "running")) return 'x'; + if (std.mem.eql(u8, state, "ready")) return 'r'; + if (std.mem.eql(u8, state, "sleeping") or std.mem.eql(u8, state, "blocked")) return 'b'; + if (std.mem.startsWith(u8, state, "waiting")) return 'w'; + if (std.mem.eql(u8, state, "returned")) return 'R'; + if (std.mem.eql(u8, state, "failed")) return 'f'; + if (std.mem.eql(u8, state, "polling")) return 'p'; + return '?'; +} + +fn indexChar(index: usize) u8 { + const alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"; + return alphabet[index % alphabet.len]; +} diff --git a/diagram/timeline_html.zig b/diagram/timeline_html.zig new file mode 100644 index 0000000..7f91003 --- /dev/null +++ b/diagram/timeline_html.zig @@ -0,0 +1,399 @@ +const std = @import("std"); + +const Interval = struct { + node: u32, // Stable simulator node identifier. Intervals with the same node are grouped together. + task: u64, // Stable scheduler task identifier. Each unique node/task pair gets one timeline lane. + state: []const u8, // Visual state name. Must match one of the keys in the generated `colors` map. + start: u64, // Inclusive start time of this state interval, in trace units. + end: u64, // Exclusive end time of this state interval, in trace units. + reason: []const u8, // Human-readable explanation shown in the hover tooltip. +}; + +const DiskInterval = struct { + node: u32, + op: []const u8, + start: u64, + end: u64, + detail: []const u8, +}; + +const StateTick = struct { + node: u32, + task: u64, + state: []const u8, + time: u64, + reason: []const u8, +}; + +const Marker = struct { + time: u64, + label: []const u8, +}; + +const TraceEvent = struct { + time: u64, + event: []const u8, + node: ?u32 = null, + task: ?u64 = null, + state: ?[]const u8 = null, + reason: ?[]const u8 = null, + from: ?u64 = null, + to: ?u64 = null, + op: ?[]const u8 = null, + start: ?u64 = null, + end: ?u64 = null, + detail: ?[]const u8 = null, +}; + +const TaskKey = struct { + node: u32, + task: u64, +}; + +const ActiveState = struct { + state: []const u8, + start: u64, + reason: []const u8, +}; + +pub fn renderFile(io: std.Io, gpa: std.mem.Allocator, trace_path: []const u8, output_path: []const u8) !void { + const file = try std.Io.Dir.cwd().createFile(io, output_path, .{}); + defer file.close(io); + + var writer = file.writerStreaming(io, &.{}); + try render(io, gpa, trace_path, &writer.interface); + try writer.interface.flush(); +} + +pub fn render(io: std.Io, gpa: std.mem.Allocator, trace_path: []const u8, writer: *std.Io.Writer) !void { + const trace_bytes = try std.Io.Dir.cwd().readFileAlloc(io, trace_path, gpa, .limited(64 * 1024 * 1024)); + defer gpa.free(trace_bytes); + + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const arena_alloc = arena.allocator(); + + var intervals: std.ArrayList(Interval) = .empty; + var disk_intervals: std.ArrayList(DiskInterval) = .empty; + var ticks: std.ArrayList(StateTick) = .empty; + var markers: std.ArrayList(Marker) = .empty; + try inferIntervals(arena_alloc, trace_bytes, &intervals, &disk_intervals, &ticks, &markers); + + try writeHtml(writer, intervals.items, disk_intervals.items, ticks.items, markers.items); +} + +fn inferIntervals( + arena: std.mem.Allocator, + trace_bytes: []const u8, + intervals: *std.ArrayList(Interval), + disk_intervals: *std.ArrayList(DiskInterval), + ticks: *std.ArrayList(StateTick), + markers: *std.ArrayList(Marker), +) !void { + _ = markers; + var active: std.AutoHashMap(TaskKey, ActiveState) = .init(arena); + var max_time: u64 = 0; + + var lines = std.mem.splitScalar(u8, trace_bytes, '\n'); + while (lines.next()) |line| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + if (trimmed.len == 0) continue; + + var parsed = try std.json.parseFromSlice(TraceEvent, arena, trimmed, .{ .ignore_unknown_fields = true }); + defer parsed.deinit(); + const event = parsed.value; + max_time = @max(max_time, event.time); + + if (std.mem.eql(u8, event.event, "state")) { + const key = TaskKey{ .node = event.node.?, .task = event.task.? }; + const state = try arena.dupe(u8, event.state.?); + const reason = try arena.dupe(u8, event.reason orelse event.state.?); + + if (try active.fetchPut(key, .{ .state = state, .start = event.time, .reason = reason })) |previous_entry| { + try appendInterval(arena, intervals, ticks, key, previous_entry.value, event.time); + } + } else if (std.mem.eql(u8, event.event, "task_removed")) { + const key = TaskKey{ .node = event.node.?, .task = event.task.? }; + if (active.fetchRemove(key)) |previous_entry| { + try appendInterval(arena, intervals, ticks, key, previous_entry.value, event.time); + } + } else if (std.mem.eql(u8, event.event, "disk")) { + try disk_intervals.append(arena, .{ + .node = event.node.?, + .op = try arena.dupe(u8, event.op.?), + .start = event.start.?, + .end = event.end.?, + .detail = try arena.dupe(u8, event.detail orelse ""), + }); + } + } + + const final_time = max_time + 1; + var active_iter = active.iterator(); + while (active_iter.next()) |entry| { + const key = entry.key_ptr.*; + const state = entry.value_ptr.*; + try appendInterval(arena, intervals, ticks, key, state, final_time); + } +} + +fn appendInterval( + arena: std.mem.Allocator, + intervals: *std.ArrayList(Interval), + ticks: *std.ArrayList(StateTick), + key: TaskKey, + state: ActiveState, + end: u64, +) !void { + var interval_end = end; + if (state.start == interval_end and (std.mem.eql(u8, state.state, "returned") or std.mem.eql(u8, state.state, "failed"))) { + interval_end += 1; + } + if (state.start >= interval_end) { + try ticks.append(arena, .{ + .node = key.node, + .task = key.task, + .state = state.state, + .time = state.start, + .reason = state.reason, + }); + return; + } + try intervals.append(arena, .{ + .node = key.node, + .task = key.task, + .state = state.state, + .start = state.start, + .end = interval_end, + .reason = state.reason, + }); +} + +fn writeJsonString(writer: *std.Io.Writer, text: []const u8) !void { + try writer.writeByte('"'); + for (text) |byte| { + switch (byte) { + '"' => try writer.writeAll("\\\""), + '\\' => try writer.writeAll("\\\\"), + '\n' => try writer.writeAll("\\n"), + '\r' => try writer.writeAll("\\r"), + '\t' => try writer.writeAll("\\t"), + else => try writer.writeByte(byte), + } + } + try writer.writeByte('"'); +} + +fn writeTraceJson(writer: *std.Io.Writer, intervals: []const Interval, disk_intervals: []const DiskInterval, ticks: []const StateTick, markers: []const Marker) !void { + try writer.writeAll( + \\{ + \\ unit: "us", + \\ intervals: [ + \\ + ); + + for (intervals, 0..) |interval, index| { + if (index != 0) try writer.writeAll(",\n"); + try writer.print(" {{ node: {}, task: {}, state: ", .{ interval.node, interval.task }); + try writeJsonString(writer, interval.state); + try writer.print(", start: {}, end: {}, reason: ", .{ interval.start, interval.end }); + try writeJsonString(writer, interval.reason); + try writer.writeAll(" }"); + } + + try writer.writeAll( + \\ + \\ ], + \\ disk: [ + \\ + ); + + for (disk_intervals, 0..) |interval, index| { + if (index != 0) try writer.writeAll(",\n"); + try writer.print(" {{ node: {}, op: ", .{interval.node}); + try writeJsonString(writer, interval.op); + try writer.print(", start: {}, end: {}, detail: ", .{ interval.start, interval.end }); + try writeJsonString(writer, interval.detail); + try writer.writeAll(" }"); + } + + try writer.writeAll( + \\ + \\ ], + \\ ticks: [ + \\ + ); + + for (ticks, 0..) |tick, index| { + if (index != 0) try writer.writeAll(",\n"); + try writer.print(" {{ node: {}, task: {}, state: ", .{ tick.node, tick.task }); + try writeJsonString(writer, tick.state); + try writer.print(", time: {}, reason: ", .{tick.time}); + try writeJsonString(writer, tick.reason); + try writer.writeAll(" }"); + } + + try writer.writeAll( + \\ + \\ ], + \\ markers: [ + \\ + ); + + for (markers, 0..) |marker, index| { + if (index != 0) try writer.writeAll(",\n"); + try writer.print(" {{ time: {}, label: ", .{marker.time}); + try writeJsonString(writer, marker.label); + try writer.writeAll(" }"); + } + + try writer.writeAll( + \\ + \\ ], + \\} + ); +} + +fn writeHtml(writer: *std.Io.Writer, intervals: []const Interval, disk_intervals: []const DiskInterval, ticks: []const StateTick, markers: []const Marker) !void { + try writer.writeAll( + \\ + \\ + \\ + \\ + \\ + \\ Zigmulator Task Timeline + \\ + \\ + \\ + \\
+ \\
+ \\

Zigmulator Task Timeline

+ \\
+ \\ + \\ + \\ + \\ + \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\
+ \\ + \\ + \\ + \\ + ); +}