├── .gitignore ├── res └── output.png ├── src ├── main.zig ├── fmt_u32.zig ├── cpu_name.zig └── benchmark.zig ├── LICENSE └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | zig-* 2 | benchmark.json -------------------------------------------------------------------------------- /res/output.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tr1ckydev/zoop/HEAD/res/output.png -------------------------------------------------------------------------------- /src/main.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const Benchmark = @import("zoop").Benchmark; 3 | 4 | pub fn main() !void { 5 | var bench = Benchmark.init(std.heap.page_allocator, .{ 6 | .export_json = "benchmark.json", 7 | }); 8 | defer bench.deinit(); 9 | try bench.add("kinda slow function", testfn1); 10 | try bench.add("fast function", testfn2); 11 | try bench.add("slowest function", testfn3); 12 | try bench.run(); 13 | } 14 | 15 | fn testfn1() !void { 16 | std.time.sleep(5e4); 17 | } 18 | 19 | fn testfn2() !void { 20 | std.time.sleep(0); 21 | } 22 | 23 | fn testfn3() !void { 24 | std.time.sleep(2e5); 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aritra Karak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/fmt_u32.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | fn formatIntu32(float: f32, comptime fmt: []const u8, options: std.fmt.FormatOptions, w: anytype) !void { 3 | _ = fmt; 4 | var buf: [24]u8 = undefined; 5 | var fbs = std.io.fixedBufferStream(&buf); 6 | var buf_writer = fbs.writer(); 7 | inline for (.{ 8 | .{ 'P', 1_000_000_000_000_000 }, 9 | .{ 'T', 1_000_000_000_000 }, 10 | .{ 'G', 1_000_000_000 }, 11 | .{ 'M', 1_000_000 }, 12 | .{ 'k', 1_000 }, 13 | .{ null, 1 }, 14 | }) |pair| { 15 | const suffix: ?u8, const val = pair; 16 | if (suffix) |s| { 17 | if (float >= val) { 18 | try buf_writer.print("{d:.3}{c}", .{ float / val, s }); 19 | break; 20 | } 21 | } else { 22 | try buf_writer.print("{d}", .{float}); 23 | break; 24 | } 25 | } 26 | return std.fmt.formatBuf(fbs.getWritten(), options, w); 27 | } 28 | 29 | /// Format an integer into short form like 26.312k, 2.906M. 30 | pub fn fmtIntu32(int: u32) std.fmt.Formatter(formatIntu32) { 31 | return .{ .data = @floatFromInt(int) }; 32 | } 33 | -------------------------------------------------------------------------------- /src/cpu_name.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | 3 | /// Returns the name of the host device cpu. 4 | pub fn getCpuName(allocator: std.mem.Allocator) ![]const u8 { 5 | return switch (@import("builtin").os.tag) { 6 | .linux => { 7 | const file = try std.fs.cwd().openFile("/proc/cpuinfo", .{}); 8 | defer file.close(); 9 | var buffer = try allocator.alloc(u8, 128); 10 | _ = try file.read(buffer); 11 | const start = if (std.mem.indexOf(u8, buffer, "model name")) |pos| pos + 13 else unreachable; 12 | const end = if (std.mem.indexOfScalar(u8, buffer[start..], '\n')) |pos| start + pos else unreachable; 13 | return buffer[start..end]; 14 | }, 15 | .windows => { 16 | const stdout = try spawn(allocator, &.{ "wmic", "cpu", "get", "name" }); 17 | return stdout[41 .. stdout.len - 7]; 18 | }, 19 | .macos => try spawn(allocator, &.{ "sysctl", "-n", "machdep.cpu.brand_string" }), 20 | else => "err_unknown_platform", 21 | }; 22 | } 23 | 24 | fn spawn(allocator: std.mem.Allocator, args: []const []const u8) ![]const u8 { 25 | const stdout = (try std.process.Child.run(.{ .allocator = allocator, .argv = args })).stdout; 26 | return stdout[0 .. stdout.len - 1]; 27 | } 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](res/output.png) 2 | 3 | # zoop 4 | 5 | A benchmarking library for zig. 6 | 7 | - Uses built in monotonic, high performance timer. 8 | - Warms up to remove function call overhead time. 9 | - Supports lifecycle hooks like *beforeAll*, *afterEach*, etc. 10 | - Can export raw benchmark data to JSON. 11 | - Vibrant terminal output with easy to read data. 12 | - Highly customizable. 13 | 14 | 15 | 16 | ## Installation 17 | 18 | > Zig master version is required to use zoop. 19 | 20 | To install zoop in your own project, 21 | 22 | 1. Add the dependency to the `build.zig.zon` of your project. 23 | 24 | ```zig 25 | .dependencies = .{ 26 | .zoop = .{ 27 | .url = "https://github.com/tr1ckydev/zoop/archive/0ce2e985206e28fe13ed3c6dcb6b88650688238e.tar.gz", 28 | .hash = "1220bad2ffae51552957e6255289dd452ee1a5e687d9f4c8c9427008edb0a802d874", 29 | }, 30 | }, 31 | ``` 32 | 33 | 2. Add the dependency and module to your `build.zig`. 34 | 35 | ```zig 36 | const zoop_dep = b.dependency("zoop", .{}); 37 | const zoop_mod = zoop_dep.module("zoop"); 38 | exe.addModule("zoop", zoop_mod); 39 | ``` 40 | 41 | 3. Import it inside your project. 42 | 43 | ```zig 44 | const Benchmark = @import("zoop").Benchmark; 45 | ``` 46 | 47 | or, if you just want to try out the benchmark from the above image, clone this repository and run `zig build run`. 48 | 49 | 50 | 51 | ## Documentation 52 | 53 | > *CallbackFn* refers to the type `*const fn () anyerror!void`. 54 | 55 | - ### Benchmark.init(allocator, config) 56 | 57 | Create a new Benchmark instance with the provided `config` options. (Provide `.{}` to use the default configuration.) 58 | 59 | ```zig 60 | var bench = Benchmark.init(allocator, .{}); 61 | ``` 62 | 63 | - `allocator`*(Allocator)*: The allocator to use inside the benchmark. 64 | - `config`*(struct)*: Configuration for the benchmark. 65 | - `show_cpu_name`*(bool)*: If true, shows the name of host device cpu. (Default is true) 66 | - `show_zig_version`*(bool)*: If true, shows the version of zig currently used to run the benchmark. (Default is true) 67 | - `show_summary`*(bool)*: If true, shows the summary after the entire benchmark is finished, i.e. the fastest test and comparison with other tests. (Default is true) 68 | - `show_summary_comparison`*(bool)*: If true, shows the comparison of the fastest test with the other tests else summary only displays the fastest test. (Default is true) 69 | - `show_output`*(bool)*: If false, no output is printed to stdout. (Default is true) 70 | - `enable_warmup`*(bool)*: If true, measures a [noop](https://en.wikipedia.org/wiki/NOP_(code)#JavaScript) function to calculate function call overhead which will be subtracted from results for more accurate data. (Default is true) 71 | - `iterations`*(u16)*: The maximum number of iterations to perform if the budget expires. (Default is 10) 72 | - `budget`*(u64)*: The maximum time (in nanoseconds) allotted to measure if the maximum iterations expire. (Default is 2 secs) 73 | - `hooks`*(struct)*: Hooks to execute during the benchmark lifecycle. (Defaults are noop) 74 | - `beforeAll`*(CallbackFn)*: Runs once before starting the benchmark. 75 | - `afterAll`*(CallbackFn)*: Runs once after finishing the benchmark. 76 | - `beforeEach`*(CallbackFn)*: Runs before measuring each test. 77 | - `afterEach`*(CallbackFn)*: Runs after measuring each test. 78 | - `export_json`*(?[]const u8)*: Exports the raw benchmark data in nanoseconds to the provided JSON file path. (Default is null) 79 | 80 | - ### Benchmark.deinit() 81 | 82 | Release all allocated memory. 83 | 84 | ```zig 85 | defer bench.deinit(); 86 | ``` 87 | 88 | - ### Benchmark.add(name: `[]const u8`, function: `CallbackFn`) 89 | 90 | Add a function to the test suite. 91 | 92 | ```zig 93 | try bench.add("my function", myFunction); 94 | // ... 95 | // ... 96 | fn myFunction() !void { 97 | // some code here to be measured 98 | } 99 | ``` 100 | 101 | - ### Benchmark.run() 102 | 103 | After adding all the tests, start the benchmark. (After finishing, results are sorted from fastest to slowest.) 104 | 105 | ```zig 106 | try bench.run(); 107 | ``` 108 | 109 | 110 | 111 | Some of the internal functions are exposed for user's convenience. 112 | 113 | - ### getCpuName(allocator) 114 | 115 | Returns the name of the host device cpu, such as `12th Gen Intel(R) Core(TM) i7-12700H`, `Apple M1 Max`, etc. 116 | 117 | - ### fmtIntu32(u32_int) 118 | 119 | Returns a formatter which can convert u32 integers to short forms like 26.312k, 2.906M. 120 | 121 | 122 | 123 | If you have any questions, feel free to join the discord server [here](https://discord.com/invite/tfBA2z8mbq). 124 | 125 | 126 | 127 | ## Credits 128 | 129 | zoop is inspired from [hyperfine](https://github.com/sharkdp/hyperfine) and [mitata](https://github.com/evanwashere/mitata). 130 | 131 | 132 | 133 | ## License 134 | 135 | This repository uses MIT License. See [LICENSE](https://github.com/tr1ckydev/zoop/blob/main/LICENSE) for full license text. 136 | -------------------------------------------------------------------------------- /src/benchmark.zig: -------------------------------------------------------------------------------- 1 | const std = @import("std"); 2 | const builtin = @import("builtin"); 3 | const Chameleon = @import("chameleon").Chameleon; 4 | pub const getCpuName = @import("cpu_name.zig").getCpuName; 5 | pub const fmtIntu32 = @import("fmt_u32.zig").fmtIntu32; 6 | 7 | const CallbackFn = *const fn () anyerror!void; 8 | 9 | fn noop() !void {} 10 | 11 | pub const Config = struct { 12 | show_cpu_name: bool = true, 13 | show_zig_version: bool = true, 14 | show_summary: bool = true, 15 | show_summary_comparison: bool = true, 16 | show_output: bool = true, 17 | enable_warmup: bool = true, 18 | iterations: u16 = 10, 19 | budget: u64 = 2e9, // 2 seconds 20 | hooks: LifecycleHooks = .{}, 21 | export_json: ?[]const u8 = null, 22 | }; 23 | 24 | const LifecycleHooks = struct { 25 | beforeAll: CallbackFn = noop, 26 | afterAll: CallbackFn = noop, 27 | beforeEach: CallbackFn = noop, 28 | afterEach: CallbackFn = noop, 29 | }; 30 | 31 | const Test = struct { 32 | name: []const u8, 33 | function: CallbackFn, 34 | }; 35 | 36 | const Result = struct { 37 | name: []const u8, 38 | iterations: u32 = 0, 39 | total: u128 = 0, 40 | avg: u64 = 0, 41 | min: u64 = std.math.maxInt(u64), 42 | max: u64 = 0, 43 | }; 44 | 45 | fn sortResult(_: @TypeOf(.{}), a: Result, b: Result) bool { 46 | return a.avg < b.avg; 47 | } 48 | 49 | pub const Benchmark = struct { 50 | allocator: std.mem.Allocator, 51 | config: Config, 52 | tests: std.ArrayList(Test), 53 | results: std.ArrayList(Result), 54 | noop: u64 = 0, 55 | stdout: std.fs.File.Writer = std.io.getStdOut().writer(), 56 | cpu: []const u8 = undefined, 57 | /// Create a new Benchmark instance with provided `config` options. 58 | /// 59 | /// Deinitialize with `deinit`. 60 | pub fn init(allocator: std.mem.Allocator, config: Config) Benchmark { 61 | return .{ 62 | .allocator = allocator, 63 | .config = config, 64 | .tests = std.ArrayList(Test).init(allocator), 65 | .results = std.ArrayList(Result).init(allocator), 66 | }; 67 | } 68 | /// Release all allocated memory. 69 | pub fn deinit(self: *Benchmark) void { 70 | self.tests.deinit(); 71 | self.results.deinit(); 72 | } 73 | /// Add a function to the test suite. 74 | pub fn add(self: *Benchmark, name: []const u8, function: CallbackFn) !void { 75 | try self.tests.append(.{ .name = name, .function = function }); 76 | } 77 | fn measure(self: *Benchmark, t: Test) !Result { 78 | var result = Result{ .name = t.name }; 79 | while (result.total < self.config.budget or result.iterations < self.config.iterations) { 80 | var timer = try std.time.Timer.start(); 81 | try t.function(); 82 | var taken = timer.lap(); 83 | result.total += taken; 84 | result.iterations += 1; 85 | taken = taken -| self.noop; 86 | if (taken < result.min) result.min = taken; 87 | if (taken > result.max) result.max = taken; 88 | } 89 | result.avg = @intCast((result.total / result.iterations) -| self.noop); 90 | return result; 91 | } 92 | fn warmup(self: *Benchmark) !void { 93 | if (!self.config.enable_warmup) return; 94 | if (self.config.show_output) try self.stdout.print("Warming up...\r", .{}); 95 | const noop_time = try self.measure(.{ .name = "warmup", .function = noop }); 96 | self.noop = @intCast(noop_time.total / noop_time.iterations); 97 | } 98 | fn printHeader(self: *Benchmark) !void { 99 | if (!self.config.show_output) return; 100 | comptime var cham = Chameleon.init(.Auto); 101 | if (self.config.show_cpu_name) { 102 | try self.stdout.print(cham.gray().fmt("{s} {s}\n"), .{ cham.bold().fmt("cpu:"), self.cpu }); 103 | } 104 | if (self.config.show_zig_version) { 105 | try self.stdout.print(cham.gray().fmt("{s} {s}\n"), .{ cham.bold().fmt("zig:"), builtin.zig_version_string }); 106 | } 107 | try self.stdout.print(cham.bold().fmt("\nBenchmark\t\tTime (avg)\tIterations\t({s} … {s})\n" ++ "─" ** 80 ++ "\n"), .{ 108 | cham.cyan().fmt("min"), 109 | cham.magenta().fmt("max"), 110 | }); 111 | } 112 | fn printResult(self: *Benchmark, result: Result) !void { 113 | if (!self.config.show_output) return; 114 | comptime var cham = Chameleon.init(.Auto); 115 | try self.stdout.print("{s: <23}\t" ++ cham.yellow().fmt("{: <15}\t") ++ "{: <15}\t(" ++ cham.cyan().fmt("{}") ++ " … " ++ cham.magenta().fmt("{}") ++ ")\n", .{ 116 | result.name, 117 | std.fmt.fmtDuration(result.avg), 118 | fmtIntu32(result.iterations), 119 | std.fmt.fmtDuration(result.min), 120 | std.fmt.fmtDuration(result.max), 121 | }); 122 | } 123 | fn printSummary(self: *Benchmark) !void { 124 | if (!self.config.show_output or !self.config.show_summary) return; 125 | comptime var cham = Chameleon.init(.Auto); 126 | try self.stdout.print(cham.bold().fmt("\nSummary\n") ++ "─" ** 80 ++ cham.green().fmt("\n{s}") ++ " ran fastest\n", .{self.results.items[0].name}); 127 | if (!self.config.show_summary_comparison) return; 128 | for (self.results.items[1..]) |item| { 129 | const timesFaster = @as(f64, @floatFromInt(item.avg)) / @as(f64, @floatFromInt(self.results.items[0].avg)); 130 | try self.stdout.print(" └─ " ++ cham.bold().greenBright().fmt("{d:.2}") ++ " times faster than " ++ cham.blue().fmt("{s}\n"), .{ timesFaster, item.name }); 131 | } 132 | } 133 | fn exportJSON(self: *Benchmark) !void { 134 | if (self.config.export_json == null) return; 135 | const file = try std.fs.cwd().createFile(self.config.export_json.?, .{}); 136 | defer file.close(); 137 | const writer = file.writer(); 138 | _ = try writer.write("{\n"); 139 | if (self.config.show_cpu_name) _ = try writer.print(" \"cpu\": \"{s}\",\n", .{self.cpu}); 140 | if (self.config.show_zig_version) _ = try writer.print(" \"zig\": \"{s}\",\n", .{builtin.zig_version_string}); 141 | _ = try writer.write(" \"results\": [\n"); 142 | for (self.results.items, 0..) |item, i| { 143 | _ = try writer.print( 144 | \\ {{ 145 | \\ "name": "{s}", 146 | \\ "iterations": {}, 147 | \\ "totalTime": {}, 148 | \\ "avgTime": {}, 149 | \\ "min": {}, 150 | \\ "max": {} 151 | \\ }} 152 | , .{ item.name, item.iterations, item.total, item.avg, item.min, item.max }); 153 | _ = try writer.write(if (i == self.results.items.len - 1) "\n" else ",\n"); 154 | } 155 | _ = try writer.write(" ]\n}"); 156 | } 157 | /// Start the benchmark. 158 | pub fn run(self: *Benchmark) !void { 159 | if (self.tests.items.len == 0) return error.NoTestsAdded; 160 | try self.warmup(); 161 | if (self.config.show_cpu_name) self.cpu = try getCpuName(self.allocator); 162 | try self.printHeader(); 163 | try self.config.hooks.beforeAll(); 164 | for (self.tests.items) |item| { 165 | try self.config.hooks.beforeEach(); 166 | if (self.config.show_output) { 167 | comptime var cham = Chameleon.init(.Auto); 168 | try self.stdout.print(cham.gray().fmt("Measuring {s}...\r"), .{item.name}); 169 | } 170 | const measured = try self.measure(item); 171 | try self.results.append(measured); 172 | if (self.config.show_output) try self.stdout.print(" " ** 80 ++ "\r", .{}); 173 | try self.printResult(measured); 174 | try self.config.hooks.afterEach(); 175 | } 176 | try self.config.hooks.afterAll(); 177 | std.mem.sort(Result, self.results.items, .{}, sortResult); 178 | try self.printSummary(); 179 | try self.exportJSON(); 180 | } 181 | }; 182 | --------------------------------------------------------------------------------