Skip to content
This repository was archived by the owner on Mar 31, 2026. It is now read-only.

Commit 0bf17c7

Browse files
committed
Add benchmarks for downloading and uploading large objects, and improve CRC32 performance measurement
1 parent 970b162 commit 0bf17c7

6 files changed

Lines changed: 296 additions & 29 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import argparse
2+
import asyncio
3+
from datetime import datetime, timedelta
4+
from io import BytesIO
5+
import os
6+
import time
7+
import threading
8+
import logging
9+
from concurrent.futures import ProcessPoolExecutor
10+
import multiprocessing
11+
12+
from google.cloud.storage._experimental.asyncio.async_grpc_client import AsyncGrpcClient
13+
from google.cloud.storage._experimental.asyncio.async_multi_range_downloader import (
14+
AsyncMultiRangeDownloader,
15+
)
16+
import sys
17+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
18+
from tests.perf.microbenchmarks.resource_monitor import ResourceMonitor
19+
20+
async def download_one_async(bucket_name, object_name, download_size, chunk_size):
21+
"""Downloads a single object of size `download_size`, in chunks of `chunk_size`"""
22+
logging.debug(f"Downloading {object_name} of size {download_size} in chunks of {chunk_size} from {bucket_name} from process {os.getpid()} and thread {threading.get_ident()}")
23+
client = AsyncGrpcClient().grpc_client
24+
25+
mrd = AsyncMultiRangeDownloader(client, bucket_name, object_name)
26+
27+
start_time = time.perf_counter()
28+
await mrd.open()
29+
30+
offset = 0
31+
output_buffer = BytesIO()
32+
while offset < download_size:
33+
bytes_to_download = min(chunk_size, download_size - offset)
34+
await mrd.download_ranges([(offset, bytes_to_download, output_buffer)])
35+
offset += bytes_to_download
36+
37+
assert output_buffer.getbuffer().nbytes == download_size, f"downloaded size incorrect for {object_name}"
38+
39+
await mrd.close()
40+
end_time = time.perf_counter()
41+
return end_time - start_time
42+
43+
def run_coroutines(bucket_name, object_names, download_size, chunk_size):
44+
"""Runs a number of coroutines to download objects concurrently and returns their latencies."""
45+
async def main():
46+
tasks = [
47+
download_one_async(bucket_name, object_name, download_size, chunk_size)
48+
for object_name in object_names
49+
]
50+
return await asyncio.gather(*tasks)
51+
52+
return asyncio.run(main())
53+
54+
def main():
55+
multiprocessing.set_start_method("spawn")
56+
parser = argparse.ArgumentParser()
57+
parser.add_argument("--bucket_name", type=str, default='chandrasiri-rs')
58+
parser.add_argument("--download_size_mib", type=int, default=1024) # 1 GiB
59+
parser.add_argument("--chunk_size_mib", type=int, default=64) # 100 MiB
60+
parser.add_argument("--count", type=int, default=4, help="The total number of objects to download.")
61+
parser.add_argument("-m", "--num_processes", type=int, default=2, help="Number of worker processes.")
62+
parser.add_argument("-n", "--num_coros", type=int, default=2, help="Number of worker coroutines.")
63+
parser.add_argument("--start_object_num", type=int, default=0)
64+
parser.add_argument(
65+
"--run_for_minutes",
66+
type=int,
67+
default=0,
68+
help="Number of minutes to run this process. If not provided, it runs for 1 iteration.",
69+
)
70+
args = parser.parse_args()
71+
72+
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', stream=sys.stdout)
73+
74+
iteration = 0
75+
start_time = datetime.now()
76+
77+
def should_run():
78+
if args.run_for_minutes > 0:
79+
return datetime.now() - start_time < timedelta(minutes=args.run_for_minutes)
80+
else:
81+
return iteration == 0
82+
83+
while should_run():
84+
logging.info(f"\n--- Iteration {iteration + 1} ---")
85+
iteration += 1
86+
total_start_time = time.perf_counter()
87+
88+
total_objects = args.count
89+
all_object_names = [f"py-sdk-mb-mt-{i}" for i in range(args.start_object_num, args.start_object_num + total_objects)]
90+
91+
all_latencies = []
92+
m = ResourceMonitor()
93+
94+
with m, ProcessPoolExecutor(max_workers=args.num_processes) as executor:
95+
futures = []
96+
for i in range(0, len(all_object_names), args.num_coros):
97+
objects = all_object_names[i:i + args.num_coros]
98+
future = executor.submit(
99+
run_coroutines, args.bucket_name, objects, args.download_size_mib * 1024 * 1024, args.chunk_size_mib * 1024 * 1024
100+
)
101+
futures.append(future)
102+
103+
104+
for future in futures:
105+
all_latencies.extend(future.result())
106+
107+
total_end_time = time.perf_counter()
108+
total_time_taken = total_end_time - total_start_time
109+
total_downloaded_bytes = args.download_size_mib * 1024 * 1024 * total_objects
110+
# m.throughput_mb_s = (total_downloaded_bytes / (1024*1024)) / total_time_taken if total_time_taken else 0
111+
112+
# Calculate throughput per-download and get the average
113+
114+
average_throughput = total_downloaded_bytes / total_time_taken / (1024 * 1024) # MiB/s
115+
logging.info("\n--- Aggregate Results ---")
116+
logging.info(f"Total objects to download: {total_objects}")
117+
logging.info(f"Total data to download: {total_downloaded_bytes / (1024*1024*1024):.2f} GiB")
118+
logging.info(f"Total time taken: {total_time_taken:.2f} seconds")
119+
logging.info(f"Average throughput: {average_throughput:.2f} MB/s")
120+
121+
logging.info("\n--- Resource Monitor Results ---")
122+
logging.info(f"Max CPU: {m.max_cpu}")
123+
logging.info(f"Max Memory: {m.max_mem}")
124+
logging.info(f"Throughput (MB/s): {m.throughput_mb_s}")
125+
logging.info(f"vCPUs: {m.vcpus}")
126+
127+
if __name__ == "__main__":
128+
main()

benchmarks/download_zb_n_workers.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,14 @@
1414

1515
async def download_one_async(bucket_name, object_name, download_size, chunk_size):
1616
"""Downloads a single object of size `download_size`, in chunks of `chunk_size`"""
17-
print(f"Downloading {object_name} of size {download_size} in chunks of {chunk_size} from {bucket_name} from process {os.getpid()} and thread {threading.get_ident()}")
18-
# raise NotImplementedError("This function is not yet implemented.")
1917
client = AsyncGrpcClient().grpc_client
2018

21-
# log_peak_memory_usage()
2219
mrd = AsyncMultiRangeDownloader(client, bucket_name, object_name)
2320
await mrd.open()
21+
# during benchmarking I've keyboard interrupted some uploads.
22+
# So the requested download size may be larger than the actual object size.
23+
download_size = min(download_size, mrd.persisted_size)
24+
print(f"Downloading {object_name} of size {download_size} in chunks of {chunk_size} from {bucket_name} from process {os.getpid()} and thread {threading.get_ident()}")
2425

2526
# download in chunks of `chunk_size`
2627
offset = 0
@@ -29,7 +30,6 @@ async def download_one_async(bucket_name, object_name, download_size, chunk_size
2930
bytes_to_download = min(chunk_size, download_size - offset)
3031
await mrd.download_ranges([(offset, bytes_to_download, output_buffer)])
3132
offset += bytes_to_download
32-
# await mrd.download_ranges([(offset, 0, output_buffer)])
3333
assert output_buffer.getbuffer().nbytes == download_size, f"downloaded size incorrect for {object_name}"
3434

3535
await mrd.close()
@@ -41,10 +41,10 @@ def download_one_sync(bucket_name, object_name, download_size, chunk_size):
4141

4242
def main():
4343
parser = argparse.ArgumentParser()
44-
parser.add_argument("--bucket_name", type=str, default='chandrasiri-rs')
44+
parser.add_argument("--bucket_name", type=str, default='chandrasiri-benchmarks-zb')
4545
parser.add_argument("--download_size", type=int, default=1024 * 1024 * 1024) # 1 GiB
4646
parser.add_argument("--chunk_size", type=int, default=64 * 1024 * 1024) # 100 MiB
47-
parser.add_argument("--count", type=int, default=100)
47+
parser.add_argument("--count", type=int, default=4)
4848
parser.add_argument("--start_object_num", type=int, default=0)
4949
parser.add_argument("-n", "--num_workers", type=int, default=2, help="Number of worker threads or processes.")
5050
parser.add_argument("--executor", type=str, choices=['thread', 'process'], default='process', help="Executor to use: 'thread' for ThreadPoolExecutor, 'process' for ProcessPoolExecutor")

benchmarks/upload_large_objects.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import argparse
2+
import asyncio
3+
import os
4+
import time
5+
6+
from google.cloud.storage._experimental.asyncio.async_grpc_client import AsyncGrpcClient
7+
from google.cloud.storage._experimental.asyncio.async_appendable_object_writer import (
8+
AsyncAppendableObjectWriter,
9+
)
10+
from google.cloud import storage
11+
12+
13+
14+
def delete_blob(bucket_name, blob_name):
15+
"""Deletes a blob from the bucket using the standard synchronous client."""
16+
try:
17+
storage_client = storage.Client()
18+
bucket = storage_client.bucket(bucket_name)
19+
blob = bucket.blob(blob_name)
20+
print(f"--- Deleting blob {blob_name} from bucket {bucket_name}. ---", flush=True)
21+
blob.delete()
22+
print(f"--- Blob {blob_name} deleted. ---", flush=True)
23+
except Exception as e:
24+
print(f"--- Error deleting blob {blob_name}: {e} ---", flush=True)
25+
26+
27+
async def upload_one(client, bucket_name, object_name, upload_size, chunk_size):
28+
"""Uploads a single object of size `upload_size`, in chunks of `chunk_size`"""
29+
print(
30+
f"Uploading {object_name} of size {upload_size / (1024**3):.0f} GiB in chunks of {chunk_size / (1024**3):.0f} GiB to {bucket_name}",
31+
flush=True
32+
)
33+
34+
writer = AsyncAppendableObjectWriter(
35+
client=client, bucket_name=bucket_name, object_name=object_name, writer_options={"FLUSH_INTERVAL_BYTES": chunk_size}
36+
)
37+
38+
await writer.open()
39+
40+
start_time = time.perf_counter()
41+
42+
# Using a pre-allocated buffer for performance instead of os.urandom.
43+
chunk = os.urandom(chunk_size)
44+
uploaded_bytes = 0
45+
while uploaded_bytes < upload_size:
46+
# In a real-world scenario, you would read data from a source here.
47+
# For this benchmark, we just send the same chunk repeatedly.
48+
bytes_to_upload = min(chunk_size, upload_size - uploaded_bytes)
49+
await writer.append(chunk[:bytes_to_upload])
50+
uploaded_bytes += bytes_to_upload
51+
52+
response = await writer.close(finalize_on_close=True)
53+
print(f"Upload response for {object_name}: {response}", flush=True)
54+
55+
end_time = time.perf_counter()
56+
latency = end_time - start_time
57+
throughput = (upload_size / latency) / (10**6) # MB/s
58+
59+
print(f"Finished uploading {object_name}", flush=True)
60+
print(f"Latency: {latency:.2f} seconds", flush=True)
61+
print(f"Throughput: {throughput:.2f} MB/s", flush=True)
62+
63+
64+
async def main():
65+
parser = argparse.ArgumentParser(
66+
description="Benchmark large object uploads and deletions."
67+
)
68+
parser.add_argument(
69+
"--bucket_name",
70+
type=str,
71+
default="chandrasiri-benchmarks-zb",
72+
help="The GCS bucket to upload to.",
73+
)
74+
args = parser.parse_args()
75+
76+
async_client = AsyncGrpcClient().grpc_client
77+
chunk_size_bytes = 1 * 1024 * 1024 * 1024 # 1 GiB
78+
79+
# Loop from 100 GiB to 1 TiB (1024 GiB) in 100 GiB increments.
80+
for i in range(2, 5):
81+
size_gib = i*100
82+
upload_size_bytes = size_gib * 1024 * 1024 * 1024
83+
object_name = f"large-upload-benchmark-{size_gib}gib"
84+
85+
print("\n" + "=" * 60, flush=True)
86+
print(f"Starting Test Case: {size_gib} GiB Object Upload", flush=True)
87+
print("=" * 60 + "\n", flush=True)
88+
89+
try:
90+
await upload_one(
91+
async_client,
92+
args.bucket_name,
93+
object_name,
94+
upload_size_bytes,
95+
chunk_size_bytes,
96+
)
97+
except Exception as e:
98+
print(f"An error occurred during the upload for {object_name}: {e}", flush=True)
99+
finally:
100+
# Ensure the object is deleted after the test, even if the upload failed.
101+
# We run the synchronous delete function in a separate thread to avoid
102+
# blocking the asyncio event loop.
103+
await asyncio.to_thread(delete_blob, args.bucket_name, object_name)
104+
105+
106+
if __name__ == "__main__":
107+
asyncio.run(main())

crc32_bench.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import timeit
2+
import os
3+
import google_crc32c
4+
from google_crc32c import Checksum
5+
6+
def benchmark():
7+
# Setup: Create a 1MB random payload
8+
data = os.urandom(2 * 1024 * 1024)
9+
10+
# Method 1: The old approach (Object instantiation + digest + conversion)
11+
def method_old():
12+
# Note: Checksum(data).digest() returns bytes, which we convert to int
13+
return int.from_bytes(Checksum(data).digest(), "big")
14+
15+
# Method 2: The new approach (Direct C-extension call)
16+
def method_new():
17+
return google_crc32c.value(data)
18+
19+
# Verify they produce the same result
20+
assert method_old() == method_new(), "Checksums do not match!"
21+
22+
# Configuration for timeit
23+
iterations = 1000
24+
25+
print(f"Benchmarking with {len(data)/1024:.0f}KB payload over {iterations} iterations...")
26+
27+
# Measure Method 1
28+
time_old = timeit.timeit(method_old, number=iterations)
29+
print(f"Old method: {time_old:.4f} seconds total")
30+
31+
# Measure Method 2
32+
time_new = timeit.timeit(method_new, number=iterations)
33+
print(f"New method: {time_new:.4f} seconds total")
34+
35+
# Calculate difference
36+
if time_new > 0:
37+
speedup = time_old / time_new
38+
print(f"\nResult: The new method is {speedup:.2f}x faster")
39+
print(f"Difference per call: {(time_old - time_new) / iterations * 1000:.4f} ms")
40+
else:
41+
print("\nNew method was too fast to measure accurately.")
42+
43+
if __name__ == "__main__":
44+
try:
45+
benchmark()
46+
except ImportError:
47+
print("Error: google-crc32c is not installed. Run 'pip install google-crc32c'")

google/cloud/storage/_experimental/asyncio/async_appendable_object_writer.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -293,8 +293,8 @@ async def close(self, finalize_on_close=False) -> Union[int, _storage_v2.Object]
293293

294294
if finalize_on_close:
295295
await self.finalize()
296-
# else:
297-
# await self.flush()
296+
else:
297+
await self.flush()
298298

299299
await self.write_obj_stream.close()
300300

samples/snippets/storage_list_files_with_prefix.py

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -46,15 +46,6 @@ def list_blobs_with_prefix(bucket_name, prefix, delimiter=None):
4646
that lists the "subfolders" under `a/`:
4747
4848
a/b/
49-
50-
51-
Note: If you only want to list prefixes a/b/ and don't want to iterate over
52-
blobs, you can do
53-
54-
```
55-
for page in blobs.pages:
56-
print(page.prefixes)
57-
```
5849
"""
5950

6051
storage_client = storage.Client()
@@ -66,22 +57,16 @@ def list_blobs_with_prefix(bucket_name, prefix, delimiter=None):
6657

6758
# Note: The call returns a response only when the iterator is consumed.
6859
print("Blobs:")
69-
count = 0
7060
for blob in blobs:
71-
if count % 500 == 0:
72-
print(blob.name)
73-
count += 1
74-
print(f"Total blobs with prefix {prefix}: {count}")
75-
76-
# blob.delete()
61+
print(blob.name)
7762

78-
# if delimiter:
79-
# print("Prefixes:")
80-
# for prefix in blobs.prefixes:
81-
# print(prefix)
63+
if delimiter:
64+
print("Prefixes:")
65+
for prefix in blobs.prefixes:
66+
print(prefix)
8267

8368

8469
# [END storage_list_files_with_prefix]
8570

8671
if __name__ == "__main__":
87-
list_blobs_with_prefix(bucket_name=sys.argv[1], prefix=sys.argv[2])
72+
list_blobs_with_prefix(bucket_name=sys.argv[1], prefix=sys.argv[2])

0 commit comments

Comments
 (0)