/build/source/nativelink-sim/src/cas_load.rs
Line | Count | Source |
1 | | // Copyright 2026 Trace Machina, Inc. All rights reserved. |
2 | | // |
3 | | // Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License"); |
4 | | // you may not use this file except in compliance with the License. |
5 | | // You may obtain a copy of the License at |
6 | | // |
7 | | // See LICENSE file for details |
8 | | // |
9 | | // Unless required by applicable law or agreed to in writing, software |
10 | | // distributed under the License is distributed on an "AS IS" BASIS, |
11 | | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
12 | | // See the License for the specific language governing permissions and |
13 | | // limitations under the License. |
14 | | |
15 | | //! The CAS load generator (RFC-0061 §3.1): the three budgets of TESTING §2 |
16 | | //! against one CAS endpoint. Each phase checks what it gets back, not only |
17 | | //! how long it took: `FindMissingBlobs` must name exactly the blobs that |
18 | | //! were never uploaded, and every read must hash to its digest. |
19 | | |
20 | | use core::fmt; |
21 | | use core::future::Future; |
22 | | use core::time::Duration; |
23 | | use std::collections::HashSet; |
24 | | |
25 | | use bytes::Bytes; |
26 | | use futures::{StreamExt, stream}; |
27 | | use nativelink_error::{Code, Error, make_err}; |
28 | | use nativelink_proto::build::bazel::remote::execution::v2::batch_update_blobs_request::Request as BlobRequest; |
29 | | use nativelink_proto::build::bazel::remote::execution::v2::content_addressable_storage_client::ContentAddressableStorageClient; |
30 | | use nativelink_proto::build::bazel::remote::execution::v2::{ |
31 | | BatchUpdateBlobsRequest, FindMissingBlobsRequest, digest_function, |
32 | | }; |
33 | | use nativelink_proto::google::bytestream::byte_stream_client::ByteStreamClient; |
34 | | use nativelink_proto::google::bytestream::{ReadRequest, WriteRequest}; |
35 | | use nativelink_util::common::DigestInfo; |
36 | | use nativelink_util::digest_hasher::{DigestHasher, DigestHasherFunc}; |
37 | | use tokio::time::Instant; |
38 | | use tonic::transport::Channel; |
39 | | |
40 | | use crate::property::Rng; |
41 | | |
42 | | /// What one run drives. |
43 | | #[derive(Clone, Debug)] |
44 | | pub struct CasLoad { |
45 | | pub instance: String, |
46 | | pub seed: u64, |
47 | | /// Digests in the one `FindMissingBlobs` call, a tenth of them uploaded |
48 | | /// first so the answer has both kinds. |
49 | | pub find_missing_digests: usize, |
50 | | /// Large blobs uploaded at once through `ByteStream`, then read back. |
51 | | pub large_uploads: usize, |
52 | | pub large_mib: usize, |
53 | | /// Small blobs uploaded, then read at once. |
54 | | pub reads: usize, |
55 | | pub read_kib: usize, |
56 | | } |
57 | | |
58 | | /// Latencies of one phase. |
59 | | #[derive(Clone, Copy, Debug, Default)] |
60 | | pub struct Phase { |
61 | | pub name: &'static str, |
62 | | pub count: usize, |
63 | | pub errors: usize, |
64 | | pub total: Duration, |
65 | | pub p50: Duration, |
66 | | pub p95: Duration, |
67 | | pub max: Duration, |
68 | | } |
69 | | |
70 | | impl Phase { |
71 | 0 | fn new( |
72 | 0 | name: &'static str, |
73 | 0 | total: Duration, |
74 | 0 | mut latencies: Vec<Duration>, |
75 | 0 | errors: usize, |
76 | 0 | ) -> Self { |
77 | 0 | latencies.sort(); |
78 | 0 | let at = |percent: usize| { |
79 | 0 | latencies |
80 | 0 | .get(latencies.len().saturating_sub(1) * percent / 100) |
81 | 0 | .copied() |
82 | 0 | .unwrap_or_default() |
83 | 0 | }; |
84 | 0 | Self { |
85 | 0 | name, |
86 | 0 | count: latencies.len(), |
87 | 0 | errors, |
88 | 0 | total, |
89 | 0 | p50: at(50), |
90 | 0 | p95: at(95), |
91 | 0 | max: at(100), |
92 | 0 | } |
93 | 0 | } |
94 | | } |
95 | | |
96 | | impl fmt::Display for Phase { |
97 | 0 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { |
98 | 0 | write!( |
99 | 0 | f, |
100 | | "{}: n={} errors={} total={}ms p50={}ms p95={}ms max={}ms", |
101 | | self.name, |
102 | | self.count, |
103 | | self.errors, |
104 | 0 | self.total.as_millis(), |
105 | 0 | self.p50.as_millis(), |
106 | 0 | self.p95.as_millis(), |
107 | 0 | self.max.as_millis(), |
108 | | ) |
109 | 0 | } |
110 | | } |
111 | | |
112 | | /// One MiB of the bytes of blob `blob`, block `block`: a function of the |
113 | | /// seed, so a reader can check what it got without holding the blob. |
114 | 8 | fn block(seed: u64, blob: u64, block: u64, len: usize) -> Vec<u8> { |
115 | 8 | let mut rng = Rng::new(seed ^ blob.rotate_left(32) ^ block.wrapping_mul(0x9e37_79b9)); |
116 | 8 | let mut out = Vec::with_capacity(len + 8); |
117 | 527k | while out.len() < len { |
118 | 527k | out.extend_from_slice(&rng.next_u64().to_le_bytes()); |
119 | 527k | } |
120 | 8 | out.truncate(len); |
121 | 8 | out |
122 | 8 | } |
123 | | |
124 | | const BLOCK: usize = 1024 * 1024; |
125 | | |
126 | | /// The digest of a generated blob of `size` bytes, hashed block by block. |
127 | 1 | fn generated_digest(seed: u64, blob: u64, size: usize) -> DigestInfo { |
128 | 1 | let mut hasher = DigestHasherFunc::Sha256.hasher(); |
129 | 1 | let mut offset = 0; |
130 | 1 | let mut index = 0; |
131 | 4 | while offset < size { |
132 | 3 | let len = BLOCK.min(size - offset); |
133 | 3 | hasher.update(&block(seed, blob, index, len)); |
134 | 3 | offset += len; |
135 | 3 | index += 1; |
136 | 3 | } |
137 | 1 | hasher.finalize_digest() |
138 | 1 | } |
139 | | |
140 | 0 | fn digest_of(bytes: &[u8]) -> DigestInfo { |
141 | 0 | let mut hasher = DigestHasherFunc::Sha256.hasher(); |
142 | 0 | hasher.update(bytes); |
143 | 0 | hasher.finalize_digest() |
144 | 0 | } |
145 | | |
146 | | /// `instance/` as a resource name prefix: none for the empty instance, as |
147 | | /// Bazel sends it. |
148 | 0 | fn prefix(instance: &str) -> String { |
149 | 0 | if instance.is_empty() { |
150 | 0 | String::new() |
151 | | } else { |
152 | 0 | format!("{instance}/") |
153 | | } |
154 | 0 | } |
155 | | |
156 | 0 | fn blob_name(instance: &str, digest: &DigestInfo) -> String { |
157 | 0 | format!( |
158 | | "{}blobs/{}/{}", |
159 | 0 | prefix(instance), |
160 | 0 | digest.packed_hash(), |
161 | 0 | digest.size_bytes() |
162 | | ) |
163 | 0 | } |
164 | | |
165 | 0 | async fn upload_small( |
166 | 0 | cas: &mut ContentAddressableStorageClient<Channel>, |
167 | 0 | instance: &str, |
168 | 0 | blobs: &[(DigestInfo, Vec<u8>)], |
169 | 0 | ) -> Result<(), Error> { |
170 | | // Batched by bytes, as Bazel does: a request over the server's 4 MiB |
171 | | // message limit is refused whole. |
172 | | const BATCH_BYTES: usize = 3 * 1024 * 1024; |
173 | 0 | let mut batches: Vec<&[(DigestInfo, Vec<u8>)]> = Vec::new(); |
174 | 0 | let mut start = 0; |
175 | 0 | let mut bytes = 0; |
176 | 0 | for (i, (_, data)) in blobs.iter().enumerate() { |
177 | 0 | if i > start && bytes + data.len() > BATCH_BYTES { |
178 | 0 | batches.push(&blobs[start..i]); |
179 | 0 | start = i; |
180 | 0 | bytes = 0; |
181 | 0 | } |
182 | 0 | bytes += data.len(); |
183 | | } |
184 | 0 | if start < blobs.len() { |
185 | 0 | batches.push(&blobs[start..]); |
186 | 0 | } |
187 | 0 | for chunk in batches { |
188 | 0 | let response = cas |
189 | 0 | .batch_update_blobs(BatchUpdateBlobsRequest { |
190 | 0 | instance_name: instance.to_string(), |
191 | 0 | requests: chunk |
192 | 0 | .iter() |
193 | 0 | .map(|(digest, data)| BlobRequest { |
194 | 0 | digest: Some((*digest).into()), |
195 | 0 | data: Bytes::from(data.clone()), |
196 | | compressor: 0, |
197 | 0 | }) |
198 | 0 | .collect(), |
199 | 0 | digest_function: digest_function::Value::Sha256.into(), |
200 | | }) |
201 | 0 | .await |
202 | 0 | .map_err(|e| make_err!(Code::Internal, "BatchUpdateBlobs failed: {e}"))? |
203 | 0 | .into_inner(); |
204 | 0 | if let Some(bad) = response |
205 | 0 | .responses |
206 | 0 | .iter() |
207 | 0 | .find(|r| r.status.as_ref().is_some_and(|s| s.code != 0)) |
208 | | { |
209 | 0 | return Err(make_err!( |
210 | 0 | Code::Internal, |
211 | 0 | "BatchUpdateBlobs refused a blob: {bad:?}" |
212 | 0 | )); |
213 | 0 | } |
214 | | } |
215 | 0 | Ok(()) |
216 | 0 | } |
217 | | |
218 | | /// One `FindMissingBlobs` over `n` digests, a tenth uploaded first; the |
219 | | /// answer must be exactly the other nine tenths. |
220 | 0 | async fn find_missing( |
221 | 0 | cas: &mut ContentAddressableStorageClient<Channel>, |
222 | 0 | load: &CasLoad, |
223 | 0 | ) -> Result<Phase, Error> { |
224 | 0 | let n = load.find_missing_digests; |
225 | 0 | let mut present = Vec::new(); |
226 | 0 | let mut digests = Vec::with_capacity(n); |
227 | 0 | for i in 0..n { |
228 | 0 | let data = format!("fleet-sim-fmb-{}-{i}", load.seed).into_bytes(); |
229 | 0 | let digest = digest_of(&data); |
230 | 0 | if i % 10 == 0 { |
231 | 0 | present.push((digest, data)); |
232 | 0 | } |
233 | 0 | digests.push(digest); |
234 | | } |
235 | 0 | upload_small(cas, &load.instance, &present).await?; |
236 | 0 | let expected: HashSet<DigestInfo> = digests |
237 | 0 | .iter() |
238 | 0 | .copied() |
239 | 0 | .filter(|d| !present.iter().any(|(p, _)| p == d)) |
240 | 0 | .collect(); |
241 | | |
242 | 0 | let start = Instant::now(); |
243 | 0 | let response = cas |
244 | 0 | .find_missing_blobs(FindMissingBlobsRequest { |
245 | 0 | instance_name: load.instance.clone(), |
246 | 0 | blob_digests: digests.iter().map(|d| (*d).into()).collect(), |
247 | 0 | digest_function: digest_function::Value::Sha256.into(), |
248 | | }) |
249 | 0 | .await |
250 | 0 | .map_err(|e| make_err!(Code::Internal, "FindMissingBlobs failed: {e}"))? |
251 | 0 | .into_inner(); |
252 | 0 | let took = start.elapsed(); |
253 | 0 | let missing: HashSet<DigestInfo> = response |
254 | 0 | .missing_blob_digests |
255 | 0 | .iter() |
256 | 0 | .filter_map(|d| DigestInfo::try_from(d).ok()) |
257 | 0 | .collect(); |
258 | 0 | let wrong = missing.symmetric_difference(&expected).count(); |
259 | 0 | Ok(Phase::new("find-missing", took, vec![took], wrong)) |
260 | 0 | } |
261 | | |
262 | | /// Streams one generated blob through `ByteStream` `Write`. |
263 | 0 | async fn write_large( |
264 | 0 | mut bytestream: ByteStreamClient<Channel>, |
265 | 0 | instance: String, |
266 | 0 | seed: u64, |
267 | 0 | blob: u64, |
268 | 0 | size: usize, |
269 | 0 | ) -> Result<DigestInfo, Error> { |
270 | 0 | let digest = generated_digest(seed, blob, size); |
271 | 0 | let resource = format!( |
272 | | "{}uploads/{:032x}/blobs/{}/{}", |
273 | 0 | prefix(&instance), |
274 | 0 | u128::from(seed) << 64 | u128::from(blob), |
275 | 0 | digest.packed_hash(), |
276 | 0 | digest.size_bytes() |
277 | | ); |
278 | 0 | let blocks = size.div_ceil(BLOCK); |
279 | 0 | let requests = stream::iter(0..blocks).map(move |index| { |
280 | 0 | let offset = index * BLOCK; |
281 | 0 | let len = BLOCK.min(size - offset); |
282 | | WriteRequest { |
283 | 0 | resource_name: if index == 0 { |
284 | 0 | resource.clone() |
285 | | } else { |
286 | 0 | String::new() |
287 | | }, |
288 | 0 | write_offset: i64::try_from(offset).unwrap_or(i64::MAX), |
289 | 0 | finish_write: index + 1 == blocks, |
290 | 0 | data: Bytes::from(block(seed, blob, index as u64, len)), |
291 | | } |
292 | 0 | }); |
293 | 0 | let response = bytestream |
294 | 0 | .write(requests) |
295 | 0 | .await |
296 | 0 | .map_err(|e| make_err!(Code::Internal, "ByteStream write failed: {e}"))? |
297 | 0 | .into_inner(); |
298 | 0 | if usize::try_from(response.committed_size).ok() != Some(size) { |
299 | 0 | return Err(make_err!( |
300 | 0 | Code::Internal, |
301 | 0 | "committed {} of {size} bytes", |
302 | 0 | response.committed_size |
303 | 0 | )); |
304 | 0 | } |
305 | 0 | Ok(digest) |
306 | 0 | } |
307 | | |
308 | | /// Reads `digest` through `ByteStream` and checks it hashes to itself. |
309 | 0 | async fn read_and_verify( |
310 | 0 | mut bytestream: ByteStreamClient<Channel>, |
311 | 0 | instance: String, |
312 | 0 | digest: DigestInfo, |
313 | 0 | ) -> Result<(), Error> { |
314 | 0 | let mut stream = bytestream |
315 | 0 | .read(ReadRequest { |
316 | 0 | resource_name: blob_name(&instance, &digest), |
317 | 0 | read_offset: 0, |
318 | 0 | read_limit: 0, |
319 | 0 | }) |
320 | 0 | .await |
321 | 0 | .map_err(|e| make_err!(Code::Internal, "ByteStream read failed: {e}"))? |
322 | 0 | .into_inner(); |
323 | 0 | let mut hasher = DigestHasherFunc::Sha256.hasher(); |
324 | 0 | while let Some(chunk) = stream.next().await { |
325 | 0 | let chunk = chunk.map_err(|e| make_err!(Code::Internal, "Read stream failed: {e}"))?; |
326 | 0 | hasher.update(&chunk.data); |
327 | | } |
328 | 0 | let got = hasher.finalize_digest(); |
329 | 0 | if got != digest { |
330 | 0 | return Err(make_err!(Code::DataLoss, "read {got}, expected {digest}")); |
331 | 0 | } |
332 | 0 | Ok(()) |
333 | 0 | } |
334 | | |
335 | | /// Runs `jobs` at once and times each; errors are counted, not fatal. |
336 | 0 | async fn timed<F>(jobs: Vec<F>) -> (Duration, Vec<Duration>, Vec<Error>) |
337 | 0 | where |
338 | 0 | F: Future<Output = Result<(), Error>> + Send + 'static, |
339 | 0 | { |
340 | 0 | let start = Instant::now(); |
341 | 0 | let handles: Vec<_> = jobs |
342 | 0 | .into_iter() |
343 | 0 | .map(|job| { |
344 | 0 | nativelink_util::background_spawn!("fleet_sim_cas_job", async move { |
345 | 0 | let begun = Instant::now(); |
346 | 0 | job.await.map(|()| begun.elapsed()) |
347 | 0 | }) |
348 | 0 | }) |
349 | 0 | .collect(); |
350 | 0 | let mut latencies = Vec::new(); |
351 | 0 | let mut errors = Vec::new(); |
352 | 0 | for handle in handles { |
353 | 0 | match handle.await { |
354 | 0 | Ok(Ok(took)) => latencies.push(took), |
355 | 0 | Ok(Err(err)) => errors.push(err), |
356 | 0 | Err(err) => errors.push(make_err!(Code::Internal, "Job panicked: {err}")), |
357 | | } |
358 | | } |
359 | 0 | (start.elapsed(), latencies, errors) |
360 | 0 | } |
361 | | |
362 | | /// The three phases, in order. `channels` spreads the streams over several |
363 | | /// connections. |
364 | 0 | pub async fn run(load: &CasLoad, channels: &[Channel]) -> Result<(Vec<Phase>, Vec<Error>), Error> { |
365 | 0 | let channel = |i: usize| channels[i % channels.len()].clone(); |
366 | 0 | let mut cas = ContentAddressableStorageClient::new(channel(0)) |
367 | 0 | .max_decoding_message_size(64 * 1024 * 1024) |
368 | 0 | .max_encoding_message_size(64 * 1024 * 1024); |
369 | 0 | let mut phases = Vec::new(); |
370 | 0 | let mut failures = Vec::new(); |
371 | | |
372 | 0 | if load.find_missing_digests > 0 { |
373 | 0 | phases.push(find_missing(&mut cas, load).await?); |
374 | 0 | } |
375 | | |
376 | 0 | if load.large_uploads > 0 { |
377 | 0 | let size = load.large_mib * BLOCK; |
378 | 0 | let jobs: Vec<_> = (0..load.large_uploads) |
379 | 0 | .map(|i| { |
380 | 0 | let client = ByteStreamClient::new(channel(i)); |
381 | 0 | let instance = load.instance.clone(); |
382 | 0 | let seed = load.seed; |
383 | 0 | async move { |
384 | 0 | let digest = |
385 | 0 | write_large(client.clone(), instance.clone(), seed, i as u64, size).await?; |
386 | 0 | read_and_verify(client, instance, digest).await |
387 | 0 | } |
388 | 0 | }) |
389 | 0 | .collect(); |
390 | 0 | let (total, latencies, errors) = timed(jobs).await; |
391 | 0 | phases.push(Phase::new( |
392 | | "large-write-read", |
393 | 0 | total, |
394 | 0 | latencies, |
395 | 0 | errors.len(), |
396 | | )); |
397 | 0 | failures.extend(errors); |
398 | 0 | } |
399 | | |
400 | 0 | if load.reads > 0 { |
401 | 0 | let blobs: Vec<_> = (0..load.reads) |
402 | 0 | .map(|i| { |
403 | 0 | let data = block(load.seed, 1_000_000 + i as u64, 0, load.read_kib * 1024); |
404 | 0 | (digest_of(&data), data) |
405 | 0 | }) |
406 | 0 | .collect(); |
407 | 0 | upload_small(&mut cas, &load.instance, &blobs).await?; |
408 | 0 | let jobs: Vec<_> = blobs |
409 | 0 | .iter() |
410 | 0 | .enumerate() |
411 | 0 | .map(|(i, (digest, _))| { |
412 | 0 | let client = ByteStreamClient::new(channel(i)); |
413 | 0 | let instance = load.instance.clone(); |
414 | 0 | let digest = *digest; |
415 | 0 | async move { read_and_verify(client, instance, digest).await } |
416 | 0 | }) |
417 | 0 | .collect(); |
418 | 0 | let (total, latencies, errors) = timed(jobs).await; |
419 | 0 | phases.push(Phase::new( |
420 | | "concurrent-reads", |
421 | 0 | total, |
422 | 0 | latencies, |
423 | 0 | errors.len(), |
424 | | )); |
425 | 0 | failures.extend(errors); |
426 | 0 | } |
427 | 0 | Ok((phases, failures)) |
428 | 0 | } |
429 | | |
430 | | /// The bytes of generated blob `blob`, whole, for tests of the generator. |
431 | 3 | pub fn generated_bytes(seed: u64, blob: u64, size: usize) -> Vec<u8> { |
432 | 3 | let mut out = Vec::with_capacity(size); |
433 | 3 | let mut index = 0; |
434 | 8 | while out.len() < size { |
435 | 5 | let len = BLOCK.min(size - out.len()); |
436 | 5 | out.extend(block(seed, blob, index, len)); |
437 | 5 | index += 1; |
438 | 5 | } |
439 | 3 | out |
440 | 3 | } |
441 | | |
442 | | /// The digest the generator streams for blob `blob`, for tests. |
443 | 1 | pub fn generated_digest_of(seed: u64, blob: u64, size: usize) -> DigestInfo { |
444 | 1 | generated_digest(seed, blob, size) |
445 | 1 | } |