Skip to main content

max / everycycle

Decode the aarch64 CPU model and time the triad on the device clock Two defects found by running the appraisal probes against the RTX 5070 Ti on astra, an aarch64 host. The host identity reported "0xd0c", a raw ARM MIDR part number. The key priority list in read_cpu_model was inert because the scan was line-major, so the first line matching any key won regardless of rank, and aarch64 has no "model name" line for the preferred key to match. Prose keys are now tried in order and the MIDR is decoded as a fallback, keeping the raw implementer and part in the string so a reader can check the decode rather than trust it. Parsing moved into a pure function over the file contents, with tests covering the aarch64, x86_64 and board-kernel shapes. The bandwidth probe timed the host loop, charging queue submission, the fence wait, and a per-iteration command buffer reset and re-record to the memory system. The dispatch is now timed on the device with a timestamp query either side of it, and the command buffer is recorded once and resubmitted. A queue family with no valid timestamp bits still falls back to the host clock, and the result records which clock produced it because the two are not comparable. Measured on the 5070 Ti: combined triad throughput reads 804 GB/s rather than 623, and run-to-run spread drops to under half a percent.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 16:06 UTC
Signed with PGP, not checked
Commit: 081f32e8eaf3a4c0c9c47fbd364f09c124da4a12
Parent: 183362c
5 files changed, +354 insertions, -69 deletions
@@ -36,26 +36,202 @@
36 36 }
37 37
38 38 /// Read a representative CPU model line from `/proc/cpuinfo`.
39 - ///
40 - /// x86_64 uses `model name`. Aarch64 typically does not — different
41 - /// kernels and SoCs surface the identifier under `Model`, `Hardware`,
42 - /// or `CPU part`. Walk the candidates in order; return the first hit.
43 39 fn read_cpu_model() -> String {
44 - const KEYS: &[&str] = &["model name", "Model", "Hardware", "CPU part"];
45 - let Ok(content) = fs::read_to_string("/proc/cpuinfo") else {
46 - return UNKNOWN.to_string();
47 - };
48 - for line in content.lines() {
49 - for key in KEYS {
50 - if let Some(rest) = line.strip_prefix(key)
51 - && let Some(value) = rest.split(':').nth(1)
52 - {
53 - let trimmed = value.trim();
54 - if !trimmed.is_empty() {
55 - return trimmed.to_string();
56 - }
57 - }
40 + fs::read_to_string("/proc/cpuinfo")
41 + .map_or_else(|_| UNKNOWN.to_string(), |c| parse_cpu_model(&c))
42 + }
43 +
44 + /// Derive a human-readable CPU model from the contents of `/proc/cpuinfo`.
45 + ///
46 + /// x86_64 supplies `model name`. Aarch64 does not: the kernel exposes
47 + /// the MIDR fields (`CPU implementer`, `CPU part`) and leaves naming to
48 + /// userspace, though board-level kernels often add `Model` or
49 + /// `Hardware`. Prose keys are preferred in order; the MIDR decode is
50 + /// the fallback.
51 + ///
52 + /// The raw implementer and part are always kept in the decoded string.
53 + /// This value is identity metadata in a signed report, so a reader has
54 + /// to be able to check the decode rather than take its word for it.
55 + fn parse_cpu_model(cpuinfo: &str) -> String {
56 + const PROSE_KEYS: &[&str] = &["model name", "Model", "Hardware"];
57 + for key in PROSE_KEYS {
58 + if let Some(value) = field(cpuinfo, key) {
59 + return value;
58 60 }
59 61 }
60 - UNKNOWN.to_string()
62 + decode_midr(cpuinfo).unwrap_or_else(|| UNKNOWN.to_string())
63 + }
64 +
65 + /// First non-empty `key : value` line in `/proc/cpuinfo`.
66 + fn field(cpuinfo: &str, key: &str) -> Option<String> {
67 + cpuinfo
68 + .lines()
69 + .filter_map(|line| {
70 + let (name, value) = line.split_once(':')?;
71 + (name.trim() == key).then(|| value.trim().to_string())
72 + })
73 + .find(|value| !value.is_empty())
74 + }
75 +
76 + /// ARM implementer byte to vendor name. Only entries we are confident
77 + /// of; an unrecognized implementer reports its raw byte rather than a
78 + /// guess.
79 + fn implementer_name(implementer: u32) -> Option<&'static str> {
80 + Some(match implementer {
81 + 0x41 => "ARM",
82 + 0x42 => "Broadcom",
83 + 0x43 => "Cavium",
84 + 0x4e => "NVIDIA",
85 + 0x50 => "Applied Micro",
86 + 0x51 => "Qualcomm",
87 + 0x53 => "Samsung",
88 + 0x61 => "Apple",
89 + 0xc0 => "Ampere",
90 + _ => return None,
91 + })
92 + }
93 +
94 + /// ARM-designed core names by part number. Deliberately partial: a
95 + /// wrong core name in a signed report is worse than an honest
96 + /// "unknown core".
97 + fn core_name(implementer: u32, part: u32) -> Option<&'static str> {
98 + if implementer != 0x41 {
99 + return None;
100 + }
101 + Some(match part {
102 + 0xd03 => "Cortex-A53",
103 + 0xd05 => "Cortex-A55",
104 + 0xd07 => "Cortex-A57",
105 + 0xd08 => "Cortex-A72",
106 + 0xd09 => "Cortex-A73",
107 + 0xd0b => "Cortex-A76",
108 + 0xd0c => "Neoverse-N1",
109 + 0xd40 => "Neoverse-V1",
110 + 0xd49 => "Neoverse-N2",
111 + 0xd4f => "Neoverse-V2",
112 + _ => return None,
113 + })
114 + }
115 +
116 + /// Decode the aarch64 MIDR fields into a readable name, keeping the
117 + /// raw ids for verification.
118 + fn decode_midr(cpuinfo: &str) -> Option<String> {
119 + let implementer_raw = field(cpuinfo, "CPU implementer")?;
120 + let part_raw = field(cpuinfo, "CPU part")?;
121 + let implementer = parse_hex(&implementer_raw)?;
122 + let part = parse_hex(&part_raw)?;
123 +
124 + let ids = format!("{implementer_raw}:{part_raw}");
125 + Some(
126 + match (implementer_name(implementer), core_name(implementer, part)) {
127 + (Some(vendor), Some(core)) => format!("{vendor} {core} ({ids})"),
128 + (Some(vendor), None) => format!("{vendor} unknown core ({ids})"),
129 + (None, _) => format!("unknown implementer ({ids})"),
130 + },
131 + )
132 + }
133 +
134 + fn parse_hex(value: &str) -> Option<u32> {
135 + let digits = value
136 + .trim()
137 + .strip_prefix("0x")
138 + .or_else(|| value.trim().strip_prefix("0X"))
139 + .unwrap_or(value.trim());
140 + u32::from_str_radix(digits, 16).ok()
141 + }
142 +
143 + #[cfg(test)]
144 + mod tests {
145 + use super::*;
146 +
147 + /// Trimmed from astra (Ampere Altra, Thelio Astra). No `model name`
148 + /// anywhere in the file, which is what made the raw part number leak
149 + /// into reports.
150 + const AARCH64_ALTRA: &str = "\
151 + processor\t: 0
152 + BogoMIPS\t: 50.00
153 + Features\t: fp asimd evtstrm aes pmull sha1 sha2 crc32
154 + CPU implementer\t: 0x41
155 + CPU architecture: 8
156 + CPU variant\t: 0x3
157 + CPU part\t: 0xd0c
158 + CPU revision\t: 1
159 + ";
160 +
161 + const X86_64: &str = "\
162 + processor\t: 0
163 + vendor_id\t: GenuineIntel
164 + model\t\t: 186
165 + model name\t: 13th Gen Intel(R) Core(TM) i7-1370P
166 + stepping\t: 2
167 + ";
168 +
169 + const RASPBERRY_PI: &str = "\
170 + processor\t: 0
171 + BogoMIPS\t: 108.00
172 + CPU implementer\t: 0x41
173 + CPU part\t: 0xd08
174 + Hardware\t: BCM2835
175 + Model\t\t: Raspberry Pi 4 Model B Rev 1.4
176 + ";
177 +
178 + #[test]
179 + fn aarch64_midr_decodes_to_a_core_name() {
180 + assert_eq!(
181 + parse_cpu_model(AARCH64_ALTRA),
182 + "ARM Neoverse-N1 (0x41:0xd0c)"
183 + );
184 + }
185 +
186 + #[test]
187 + fn x86_prefers_the_model_name_line() {
188 + assert_eq!(
189 + parse_cpu_model(X86_64),
190 + "13th Gen Intel(R) Core(TM) i7-1370P"
191 + );
192 + }
193 +
194 + #[test]
195 + fn prose_keys_win_over_the_midr_decode() {
196 + // `Model` outranks `Hardware`, and both outrank the MIDR, even
197 + // though the MIDR lines come first in the file. The old
198 + // line-major scan returned whichever key appeared earliest.
199 + assert_eq!(
200 + parse_cpu_model(RASPBERRY_PI),
201 + "Raspberry Pi 4 Model B Rev 1.4"
202 + );
203 + }
204 +
205 + #[test]
206 + fn unknown_part_stays_honest() {
207 + let unknown_core = AARCH64_ALTRA.replace("0xd0c", "0xd8e");
208 + assert_eq!(
209 + parse_cpu_model(&unknown_core),
210 + "ARM unknown core (0x41:0xd8e)"
211 + );
212 +
213 + let unknown_vendor = AARCH64_ALTRA.replace("0x41", "0x69");
214 + assert_eq!(
215 + parse_cpu_model(&unknown_vendor),
216 + "unknown implementer (0x69:0xd0c)"
217 + );
218 + }
219 +
220 + #[test]
221 + fn nothing_recognizable_reports_unknown() {
222 + assert_eq!(parse_cpu_model(""), UNKNOWN);
223 + assert_eq!(parse_cpu_model("processor\t: 0\n"), UNKNOWN);
224 + }
225 +
226 + #[test]
227 + fn empty_values_do_not_win() {
228 + let blank = "model name\t:\nModel\t\t: Thelio Astra\n";
229 + assert_eq!(parse_cpu_model(blank), "Thelio Astra");
230 + }
231 +
232 + #[test]
233 + fn values_containing_colons_survive() {
234 + let clocked = "model name\t: Some CPU @ 3.00GHz: turbo\n";
235 + assert_eq!(parse_cpu_model(clocked), "Some CPU @ 3.00GHz: turbo");
236 + }
61 237 }
@@ -7,8 +7,8 @@
7 7
8 8 use everycycle_appraise::enumerate::enumerate;
9 9 use everycycle_appraise::probe::{
10 - BandwidthError, LaunchError, VulkanContext, VulkanContextError, measure_bandwidth,
11 - measure_launch_overhead,
10 + BandwidthError, LaunchError, TimingSource, VulkanContext, VulkanContextError,
11 + measure_bandwidth, measure_launch_overhead,
12 12 };
13 13
14 14 fn main() {
@@ -53,11 +53,19 @@
53 53 Ok(r) => {
54 54 eprintln!(" read: {:>7.1} GB/s", r.read_bps / 1e9);
55 55 eprintln!(" write: {:>7.1} GB/s", r.write_bps / 1e9);
56 + eprintln!(" clock: {}", timing_label(r.timing));
56 57 }
57 58 Err(e) => eprintln!(" failed: {}", render_bw_err(&e)),
58 59 }
59 60 }
60 61
62 + fn timing_label(t: TimingSource) -> &'static str {
63 + match t {
64 + TimingSource::DeviceTimestamps => "device timestamps",
65 + TimingSource::HostWallClock => "host wall clock (lower bound)",
66 + }
67 + }
68 +
61 69 fn render_ctx_err(e: &VulkanContextError) -> String {
62 70 format!("{e:?}")
63 71 }
@@ -2,7 +2,7 @@
2 2 //!
3 3 //! Allocates three device-local buffers (A, B, C), dispatches a
4 4 //! compute shader that computes `c[i] = a[i] + s * b[i]` for `i` in
5 - //! `[0, N)`, times the dispatch loop, and reports observed read and
5 + //! `[0, N)`, times the dispatches, and reports observed read and
6 6 //! write bandwidth from the triad:
7 7 //!
8 8 //! ```text
@@ -13,6 +13,21 @@
13 13 //! Two reads per element (A and B), one write (C). The shader is a
14 14 //! pure streaming kernel so the achieved figure tracks the device's
15 15 //! DRAM throughput, not its cache or compute path.
16 + //!
17 + //! # Timing
18 + //!
19 + //! The dispatch is timed **on the device**, with a timestamp query
20 + //! written either side of it inside the command buffer, and only the
21 + //! deltas are accumulated. Queue submission and the fence wait cost
22 + //! tens of microseconds against a dispatch of a few hundred, so timing
23 + //! the host-side loop instead charges that overhead to the memory
24 + //! system and understates bandwidth by low double-digit percent —
25 + //! measured at ~13% on an RTX 5070 Ti.
26 + //!
27 + //! A queue family that reports no valid timestamp bits falls back to
28 + //! host wall-clock timing. The result records which clock produced it,
29 + //! because the two are not comparable and a report must not imply they
30 + //! are.
16 31
17 32 use std::time::Instant;
18 33
@@ -58,10 +73,22 @@
58 73 const WARMUP_ITERATIONS: u32 = 4;
59 74 const MEASURE_ITERATIONS: u32 = 32;
60 75
76 + /// Which clock produced a measurement.
77 + #[derive(Clone, Copy, Debug, PartialEq, Eq)]
78 + pub enum TimingSource {
79 + /// Timestamp queries around the dispatch. Excludes submit and
80 + /// fence-wait overhead.
81 + DeviceTimestamps,
82 + /// Host wall clock around submit-and-wait. Includes per-dispatch
83 + /// submission overhead, so the figure is a lower bound.
84 + HostWallClock,
85 + }
86 +
61 87 #[derive(Clone, Copy, Debug)]
62 88 pub struct BandwidthResult {
63 89 pub read_bps: f64,
64 90 pub write_bps: f64,
91 + pub timing: TimingSource,
65 92 }
66 93
67 94 #[derive(Debug)]
@@ -106,8 +133,8 @@
106 133
107 134 unsafe { destroy_buffers(device, &buffers) };
108 135
109 - let elapsed = result?;
110 - Ok(compute_bandwidth(elapsed, MEASURE_ITERATIONS))
136 + let (elapsed, timing) = result?;
137 + Ok(compute_bandwidth(elapsed, MEASURE_ITERATIONS, timing))
111 138 }
112 139
113 140 fn compile_triad_shader() -> Result<Vec<u32>, BandwidthError> {
@@ -177,7 +204,7 @@
177 204 ctx: &VulkanContext,
178 205 spirv: &[u32],
179 206 buffers: &TriadBuffers,
180 - ) -> Result<f64, BandwidthError> {
207 + ) -> Result<(f64, TimingSource), BandwidthError> {
181 208 let device = &ctx.device;
182 209
183 210 // Descriptor set layout: three storage-buffer bindings.
@@ -257,35 +284,58 @@
257 284 let cmd =
258 285 unsafe { device.allocate_command_buffers(&cmd_info) }.map_err(BandwidthError::Vulkan)?[0];
259 286
287 + // A queue that cannot write timestamps leaves only the host clock.
288 + let timing = if ctx.timestamp_valid_bits > 0 {
289 + TimingSource::DeviceTimestamps
290 + } else {
291 + TimingSource::HostWallClock
292 + };
293 +
294 + let query_pool = if timing == TimingSource::DeviceTimestamps {
295 + let info = vk::QueryPoolCreateInfo::default()
296 + .query_type(vk::QueryType::TIMESTAMP)
297 + .query_count(2);
298 + Some(unsafe { device.create_query_pool(&info, None) }.map_err(BandwidthError::Vulkan)?)
299 + } else {
300 + None
301 + };
302 +
260 303 let begin = vk::CommandBufferBeginInfo::default();
261 304 let groups = ELEMENTS.div_ceil(WORKGROUP_SIZE);
262 305
263 - let record = || -> Result<(), BandwidthError> {
264 - unsafe {
265 - device
266 - .begin_command_buffer(cmd, &begin)
267 - .map_err(BandwidthError::Vulkan)?;
268 - device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::COMPUTE, pipeline);
269 - device.cmd_bind_descriptor_sets(
270 - cmd,
271 - vk::PipelineBindPoint::COMPUTE,
272 - pipeline_layout,
273 - 0,
274 - &[descriptor_set],
275 - &[],
276 - );
277 - device.cmd_dispatch(cmd, groups, 1, 1);
278 - device
279 - .end_command_buffer(cmd)
280 - .map_err(BandwidthError::Vulkan)?;
306 + // Recorded once and resubmitted: the buffer carries no
307 + // ONE_TIME_SUBMIT flag, so re-recording per iteration bought
308 + // nothing and put another host-side cost inside the timed region.
309 + // The query-pool reset lives in the command buffer so each
310 + // submission overwrites the previous pair.
311 + unsafe {
312 + device
313 + .begin_command_buffer(cmd, &begin)
314 + .map_err(BandwidthError::Vulkan)?;
315 + if let Some(pool) = query_pool {
316 + device.cmd_reset_query_pool(cmd, pool, 0, 2);
317 + device.cmd_write_timestamp(cmd, vk::PipelineStageFlags::TOP_OF_PIPE, pool, 0);
281 318 }
282 - Ok(())
283 - };
319 + device.cmd_bind_pipeline(cmd, vk::PipelineBindPoint::COMPUTE, pipeline);
320 + device.cmd_bind_descriptor_sets(
321 + cmd,
322 + vk::PipelineBindPoint::COMPUTE,
323 + pipeline_layout,
324 + 0,
325 + &[descriptor_set],
326 + &[],
327 + );
328 + device.cmd_dispatch(cmd, groups, 1, 1);
329 + if let Some(pool) = query_pool {
330 + device.cmd_write_timestamp(cmd, vk::PipelineStageFlags::BOTTOM_OF_PIPE, pool, 1);
331 + }
332 + device
333 + .end_command_buffer(cmd)
334 + .map_err(BandwidthError::Vulkan)?;
335 + }
284 336
285 - record()?;
286 -
287 - let submit = |buffer: vk::CommandBuffer| -> Result<(), BandwidthError> {
288 - let info = vk::SubmitInfo::default().command_buffers(std::slice::from_ref(&buffer));
337 + let submit = || -> Result<(), BandwidthError> {
338 + let info = vk::SubmitInfo::default().command_buffers(std::slice::from_ref(&cmd));
289 339 unsafe {
290 340 device
291 341 .queue_submit(ctx.queue, &[info], vk::Fence::null())
@@ -297,31 +347,60 @@
297 347 Ok(())
298 348 };
299 349
350 + // Ticks are only meaningful in the low `timestamp_valid_bits`, so
351 + // mask before subtracting or a wrapped counter reads as a huge
352 + // interval.
353 + let tick_mask: u64 = if ctx.timestamp_valid_bits >= 64 {
354 + u64::MAX
355 + } else {
356 + (1_u64 << ctx.timestamp_valid_bits) - 1
357 + };
358 +
359 + let read_dispatch_ns = |pool: vk::QueryPool| -> Result<f64, BandwidthError> {
360 + let mut ticks = [0_u64; 2];
361 + unsafe {
362 + device
363 + .get_query_pool_results(
364 + pool,
365 + 0,
366 + &mut ticks,
367 + vk::QueryResultFlags::TYPE_64 | vk::QueryResultFlags::WAIT,
368 + )
369 + .map_err(BandwidthError::Vulkan)?;
370 + }
371 + let delta = (ticks[1] & tick_mask).wrapping_sub(ticks[0] & tick_mask) & tick_mask;
372 + #[allow(clippy::cast_precision_loss)]
373 + Ok(delta as f64 * f64::from(ctx.timestamp_period_ns))
374 + };
375 +
300 376 // Warm-up: drive the device into steady state so the measured pass
301 377 // reflects thermal-stable behavior, not first-launch overhead.
302 378 for _ in 0..WARMUP_ITERATIONS {
303 - submit(cmd)?;
304 - unsafe {
305 - device
306 - .reset_command_buffer(cmd, vk::CommandBufferResetFlags::empty())
307 - .map_err(BandwidthError::Vulkan)?;
308 - }
309 - record()?;
379 + submit()?;
310 380 }
311 381
312 - let start = Instant::now();
313 - for _ in 0..MEASURE_ITERATIONS {
314 - submit(cmd)?;
315 - unsafe {
316 - device
317 - .reset_command_buffer(cmd, vk::CommandBufferResetFlags::empty())
318 - .map_err(BandwidthError::Vulkan)?;
382 + let result = match query_pool {
383 + Some(pool) => {
384 + let mut total_ns = 0.0_f64;
385 + for _ in 0..MEASURE_ITERATIONS {
386 + submit()?;
387 + total_ns += read_dispatch_ns(pool)?;
388 + }
389 + total_ns / 1e9
319 390 }
320 - record()?;
321 - }
322 - let elapsed = start.elapsed().as_secs_f64();
391 + None => {
392 + let start = Instant::now();
393 + for _ in 0..MEASURE_ITERATIONS {
394 + submit()?;
395 + }
396 + start.elapsed().as_secs_f64()
397 + }
398 + };
323 399
324 400 unsafe {
401 + if let Some(pool) = query_pool {
402 + device.destroy_query_pool(pool, None);
403 + }
325 404 device.free_command_buffers(ctx.command_pool, &[cmd]);
326 405 device.destroy_descriptor_pool(descriptor_pool, None);
327 406 device.destroy_pipeline(pipeline, None);
@@ -330,16 +409,21 @@
330 409 device.destroy_descriptor_set_layout(dsl, None);
331 410 }
332 411
333 - Ok(elapsed)
412 + Ok((result, timing))
334 413 }
335 414
336 415 #[allow(clippy::cast_precision_loss)]
337 - fn compute_bandwidth(elapsed_seconds: f64, iterations: u32) -> BandwidthResult {
416 + fn compute_bandwidth(
417 + elapsed_seconds: f64,
418 + iterations: u32,
419 + timing: TimingSource,
420 + ) -> BandwidthResult {
338 421 let bytes_per_iter_read = 2.0 * (BUFFER_BYTES as f64);
339 422 let bytes_per_iter_write = BUFFER_BYTES as f64;
340 423 let iters = f64::from(iterations);
341 424 BandwidthResult {
342 425 read_bps: bytes_per_iter_read * iters / elapsed_seconds,
343 426 write_bps: bytes_per_iter_write * iters / elapsed_seconds,
427 + timing,
344 428 }
345 429 }
@@ -10,6 +10,6 @@
10 10 pub mod launch;
11 11 pub mod vulkan_ctx;
12 12
13 - pub use bandwidth::{BandwidthError, BandwidthResult, measure_bandwidth};
13 + pub use bandwidth::{BandwidthError, BandwidthResult, TimingSource, measure_bandwidth};
14 14 pub use launch::{LaunchError, LaunchOverheadResult, measure_launch_overhead};
15 15 pub use vulkan_ctx::{VulkanContext, VulkanContextError};
@@ -44,6 +44,13 @@
44 44 pub physical_device: vk::PhysicalDevice,
45 45 pub memory_properties: vk::PhysicalDeviceMemoryProperties,
46 46
47 + /// Nanoseconds per timestamp-query tick on this device.
48 + pub timestamp_period_ns: f32,
49 + /// Meaningful low-order bits in a timestamp query on the chosen
50 + /// queue family. Zero means the queue cannot write timestamps, and
51 + /// probes must fall back to host timing.
52 + pub timestamp_valid_bits: u32,
53 +
47 54 // Held for cleanup. Dropped in reverse declaration order.
48 55 pub device: ash::Device,
49 56 pub instance: Instance,
@@ -63,6 +70,14 @@
63 70
64 71 let queue_family_index = pick_compute_queue_family(&instance, physical)?;
65 72
73 + let timestamp_period_ns = unsafe { instance.get_physical_device_properties(physical) }
74 + .limits
75 + .timestamp_period;
76 + let timestamp_valid_bits =
77 + unsafe { instance.get_physical_device_queue_family_properties(physical) }
78 + .get(queue_family_index as usize)
79 + .map_or(0, |fam| fam.timestamp_valid_bits);
80 +
66 81 let priorities = [1.0_f32];
67 82 let queue_info = vk::DeviceQueueCreateInfo::default()
68 83 .queue_family_index(queue_family_index)
@@ -86,6 +101,8 @@
86 101 command_pool,
87 102 physical_device: physical,
88 103 memory_properties,
104 + timestamp_period_ns,
105 + timestamp_valid_bits,
89 106 device,
90 107 instance,
91 108 _entry: entry,