This is an automated email from the ASF dual-hosted git repository.
chaokunyang pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/fory.git
The following commit(s) were added to refs/heads/main by this push:
new ccecc39be perf(python): add python benchmark suite (#3448)
ccecc39be is described below
commit ccecc39bee412a02aa6e6ad76778347a4832aa81
Author: Shawn Yang <[email protected]>
AuthorDate: Tue Mar 3 18:34:17 2026 +0800
perf(python): add python benchmark suite (#3448)
## Why?
## What does this PR do?
## Related issues
#1017
#3443
## Does this PR introduce any user-facing change?
- [ ] Does this PR introduce any public API change?
- [ ] Does this PR introduce any binary protocol compatibility change?
## Benchmark
---
AGENTS.md | 1 +
benchmarks/python/README.md | 257 ++-------
benchmarks/python/benchmark.py | 808 ++++++++++++++++++++++++++++
benchmarks/python/benchmark_report.py | 439 +++++++++++++++
benchmarks/python/run.sh | 198 +++++++
docs/benchmarks/python/README.md | 127 +++++
docs/benchmarks/python/mediacontent.png | Bin 0 -> 49948 bytes
docs/benchmarks/python/mediacontentlist.png | Bin 0 -> 57263 bytes
docs/benchmarks/python/sample.png | Bin 0 -> 53682 bytes
docs/benchmarks/python/samplelist.png | Bin 0 -> 60171 bytes
docs/benchmarks/python/struct.png | Bin 0 -> 54218 bytes
docs/benchmarks/python/structlist.png | Bin 0 -> 52290 bytes
docs/benchmarks/python/throughput.png | Bin 0 -> 79016 bytes
python/pyfory/serialization.pyx | 2 +-
python/pyfory/struct.pxi | 25 +-
15 files changed, 1621 insertions(+), 236 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index d0d1a01e3..f2d997653 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,6 +25,7 @@ While working on Fory, please remember:
- **Primary references**: `README.md`, `CONTRIBUTING.md`,
`docs/guide/DEVELOPMENT.md`, and language guides under `docs/guide/`.
- **Protocol changes**: Read and update the relevant specs in
`docs/specification/**` and align cross-language tests.
- **Docs publishing**: Updates under `docs/guide/` and `docs/benchmarks/` are
synced to https://github.com/apache/fory-site; other website content should be
changed in that repo.
+- **Benchmark docs refresh is mandatory**: When any benchmark
logic/script/config or compared serializer set changes, rerun the relevant
benchmarks and refresh corresponding artifacts under `docs/benchmarks/**`
(report + plots) before finalizing.
- **Debugging docs**: C++ debugging guidance lives in `docs/cpp_debug.md`.
- **Conflicts**: If instructions conflict, follow the most specific module
docs and call out the conflict in your response.
diff --git a/benchmarks/python/README.md b/benchmarks/python/README.md
index 1cc20d5ec..786a56e00 100644
--- a/benchmarks/python/README.md
+++ b/benchmarks/python/README.md
@@ -1,244 +1,63 @@
-# Apache Fory™ CPython Benchmark
+# Apache Fory Python Benchmarks
-Microbenchmark comparing Apache Fory™ and Pickle serialization performance in
CPython.
+This directory contains two benchmark entrypoints:
-## Quick Start
+1. `benchmark.py` + `run.sh` (new): C++-parity benchmark matrix covering:
+ - `Struct`, `Sample`, `MediaContent`
+ - `StructList`, `SampleList`, `MediaContentList`
+ - operations: `serialize`, `deserialize`
+ - serializers: `fory`, `pickle`, `protobuf`
+2. `fory_benchmark.py` (legacy): existing CPython microbench script kept
intact.
-### Step 1: Install Apache Fory™ into Python
-
-Follow the installation instructions from the main documentation.
-
-### Step 2: Execute the benchmark script
-
-```bash
-python fory_benchmark.py
-```
-
-This will run all benchmarks with both Fory and Pickle serializers using
default settings.
-
-## Usage
-
-### Basic Usage
-
-```bash
-# Run all benchmarks with both Fory and Pickle
-python fory_benchmark.py
-
-# Run all benchmarks without reference tracking
-python fory_benchmark.py --no-ref
-
-# Run specific benchmarks
-python fory_benchmark.py --benchmarks dict,large_dict,complex
-
-# Compare only Fory performance
-python fory_benchmark.py --serializers fory
-
-# Compare only Pickle performance
-python fory_benchmark.py --serializers pickle
-
-# Run with more iterations for better accuracy
-python fory_benchmark.py --iterations 50 --repeat 10
-
-# Debug with pure Python mode
-python fory_benchmark.py --disable-cython --benchmarks dict
-```
-
-## Command-Line Options
-
-### Benchmark Selection
-
-#### `--benchmarks BENCHMARK_LIST`
-
-Comma-separated list of benchmarks to run. Default: `all`
-
-Available benchmarks:
-
-- `dict` - Small dictionary serialization (28 fields with mixed types)
-- `large_dict` - Large dictionary (2^10 + 1 entries)
-- `dict_group` - Group of 3 dictionaries
-- `tuple` - Small tuple with nested list
-- `large_tuple` - Large tuple (2^20 + 1 integers)
-- `large_float_tuple` - Large tuple of floats (2^20 + 1 elements)
-- `large_boolean_tuple` - Large tuple of booleans (2^20 + 1 elements)
-- `list` - Nested lists (10x10x10 structure)
-- `large_list` - Large list (2^20 + 1 integers)
-- `complex` - Complex dataclass objects with nested structures
-
-Examples:
-
-```bash
-# Run only dictionary benchmarks
-python fory_benchmark.py --benchmarks dict,large_dict,dict_group
-
-# Run only large data benchmarks
-python fory_benchmark.py --benchmarks large_dict,large_tuple,large_list
-
-# Run only the complex object benchmark
-python fory_benchmark.py --benchmarks complex
-```
-
-#### `--serializers SERIALIZER_LIST`
-
-Comma-separated list of serializers to benchmark. Default: `all`
-
-Available serializers:
-
-- `fory` - Apache Fory™ serialization
-- `pickle` - Python's built-in pickle serialization
-
-Examples:
-
-```bash
-# Compare both serializers (default)
-python fory_benchmark.py --serializers fory,pickle
-
-# Benchmark only Fory
-python fory_benchmark.py --serializers fory
-
-# Benchmark only Pickle
-python fory_benchmark.py --serializers pickle
-```
-
-### Fory Configuration
-
-#### `--no-ref`
-
-Disable reference tracking for Fory. By default, Fory tracks references to
handle shared and circular references.
-
-```bash
-# Run without reference tracking
-python fory_benchmark.py --no-ref
-```
-
-#### `--disable-cython`
-
-Use pure Python mode instead of Cython serialization for Fory. Useful for
debugging protocol issues.
-
-```bash
-# Use pure Python serialization
-python fory_benchmark.py --disable-cython
-```
-
-### Benchmark Parameters
-
-These options control the benchmark measurement process:
-
-#### `--warmup N`
-
-Number of warmup iterations before measurement starts. Default: `3`
-
-```bash
-python fory_benchmark.py --warmup 5
-```
-
-#### `--iterations N`
-
-Number of measurement iterations to collect. Default: `20`
-
-```bash
-python fory_benchmark.py --iterations 50
-```
-
-#### `--repeat N`
-
-Number of times to repeat each iteration. Default: `5`
+## Quick Start (Comprehensive Suite)
```bash
-python fory_benchmark.py --repeat 10
+cd benchmarks/python
+./run.sh
```
-#### `--number N`
+`run.sh` will:
-Number of times to call the serialization function per measurement (inner
loop). Default: `100`
-
-```bash
-python fory_benchmark.py --number 1000
-```
+1. Generate Python protobuf bindings from `benchmarks/proto/bench.proto`
+2. Run `benchmark.py`
+3. Generate plots + markdown report via `benchmark_report.py`
+4. Copy report/plots to `docs/benchmarks/python`
-#### `--help`
-
-Display help message and exit.
+### Common Options
```bash
-python fory_benchmark.py --help
-```
-
-## Examples
+# Run only Struct benchmarks for Fory serialize
+./run.sh --data struct --serializer fory --operation serialize
-### Running Specific Comparisons
-
-```bash
-# Compare Fory and Pickle on dictionary benchmarks
-python fory_benchmark.py --benchmarks dict,large_dict,dict_group
+# Run all data types, deserialize only
+./run.sh --operation deserialize
-# Compare performance without reference tracking
-python fory_benchmark.py --no-ref
+# Adjust benchmark loops
+./run.sh --warmup 5 --iterations 30 --repeat 8 --number 1500
-# Test only Fory with high precision
-python fory_benchmark.py --serializers fory --iterations 100 --repeat 10
+# Skip docs sync
+./run.sh --no-copy-docs
```
-### Performance Tuning
+Supported values:
-```bash
-# Quick test with fewer iterations
-python fory_benchmark.py --warmup 1 --iterations 5 --repeat 3
-
-# High-precision benchmark
-python fory_benchmark.py --warmup 10 --iterations 100 --repeat 10
+- `--data`: `struct,sample,mediacontent,structlist,samplelist,mediacontentlist`
+- `--serializer`: `fory,pickle,protobuf`
+- `--operation`: `all|serialize|deserialize`
-# Benchmark large data structures with more inner loop iterations
-python fory_benchmark.py --benchmarks large_list,large_tuple --number 1000
-```
+## Legacy Script (Unchanged)
-### Debugging and Development
+`fory_benchmark.py` remains unchanged and can still be used directly:
```bash
-# Debug protocol issues with pure Python mode
-python fory_benchmark.py --disable-cython --benchmarks dict
-
-# Test complex objects only
-python fory_benchmark.py --benchmarks complex --iterations 10
-
-# Compare Fory with and without ref tracking
-python fory_benchmark.py --serializers fory --benchmarks dict
-python fory_benchmark.py --serializers fory --benchmarks dict --no-ref
+cd benchmarks/python
+python fory_benchmark.py
```
-## Output Format
-
-The benchmark script provides three sections of output:
-
-1. **Progress**: Real-time progress as each benchmark runs
-2. **Summary**: Table of all results showing mean time and standard deviation
-3. **Speedup**: Comparison table showing Fory speedup vs Pickle (only when
both serializers are tested)
+For its original options and behavior, refer to `python fory_benchmark.py
--help`.
-Example output:
+## Notes
-```
-Benchmarking 3 benchmark(s) with 2 serializer(s)
-Warmup: 3, Iterations: 20, Repeat: 5, Inner loop: 100
-Fory reference tracking: enabled
-================================================================================
-
-Running fory_dict... 12.34 us ± 0.56 us
-Running pickle_dict... 45.67 us ± 1.23 us
-...
-
-================================================================================
-SUMMARY
-================================================================================
-Serializer Benchmark Mean Std Dev
---------------------------------------------------------------------------------
-fory dict 12.34 us 0.56 us
-pickle dict 45.67 us 1.23 us
-...
-
-================================================================================
-SPEEDUP (Fory vs Pickle)
-================================================================================
-Benchmark Fory Pickle Speedup
---------------------------------------------------------------------------------
-dict 12.34 us 45.67 us 3.70x
-...
-```
+- `pyfory` must be installed in your current Python environment.
+- `protoc` is required by `run.sh` to generate `bench_pb2.py`.
+- `protobuf` benchmarks include dataclass <-> protobuf conversion in the timed
path.
diff --git a/benchmarks/python/benchmark.py b/benchmarks/python/benchmark.py
new file mode 100755
index 000000000..de85a568f
--- /dev/null
+++ b/benchmarks/python/benchmark.py
@@ -0,0 +1,808 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Comprehensive Python benchmark suite for C++ parity benchmark objects.
+
+This script mirrors `benchmarks/cpp/benchmark.cc` coverage and benchmarks:
+- Data types: Struct, Sample, MediaContent and corresponding *List variants.
+- Operations: serialize / deserialize.
+- Serializers: fory / pickle / protobuf.
+
+Results are written as JSON and consumed by `benchmark_report.py`.
+"""
+
+from __future__ import annotations
+
+import argparse
+from dataclasses import dataclass
+import json
+import os
+import pickle
+import platform
+import statistics
+import sys
+import timeit
+from pathlib import Path
+from typing import Any, Callable, Dict, Iterable, List, Tuple
+
+import pyfory
+
+
+LIST_SIZE = 5
+DATA_TYPE_ORDER = [
+ "struct",
+ "sample",
+ "mediacontent",
+ "structlist",
+ "samplelist",
+ "mediacontentlist",
+]
+SERIALIZER_ORDER = ["fory", "pickle", "protobuf"]
+OPERATION_ORDER = ["serialize", "deserialize"]
+
+DATA_LABELS = {
+ "struct": "Struct",
+ "sample": "Sample",
+ "mediacontent": "MediaContent",
+ "structlist": "StructList",
+ "samplelist": "SampleList",
+ "mediacontentlist": "MediaContentList",
+}
+SERIALIZER_LABELS = {
+ "fory": "Fory",
+ "pickle": "Pickle",
+ "protobuf": "Protobuf",
+}
+
+
+@dataclass
+class NumericStruct:
+ f1: int
+ f2: int
+ f3: int
+ f4: int
+ f5: int
+ f6: int
+ f7: int
+ f8: int
+
+
+@dataclass
+class Sample:
+ int_value: int
+ long_value: int
+ float_value: float
+ double_value: float
+ short_value: int
+ char_value: int
+ boolean_value: bool
+ int_value_boxed: int
+ long_value_boxed: int
+ float_value_boxed: float
+ double_value_boxed: float
+ short_value_boxed: int
+ char_value_boxed: int
+ boolean_value_boxed: bool
+ int_array: List[int]
+ long_array: List[int]
+ float_array: List[float]
+ double_array: List[float]
+ short_array: List[int]
+ char_array: List[int]
+ boolean_array: List[bool]
+ string: str
+
+
+@dataclass
+class Media:
+ uri: str
+ title: str
+ width: int
+ height: int
+ format: str
+ duration: int
+ size: int
+ bitrate: int
+ has_bitrate: bool
+ persons: List[str]
+ player: int
+ copyright: str
+
+
+@dataclass
+class Image:
+ uri: str
+ title: str
+ width: int
+ height: int
+ size: int
+
+
+@dataclass
+class MediaContent:
+ media: Media
+ images: List[Image]
+
+
+@dataclass
+class StructList:
+ struct_list: List[NumericStruct]
+
+
+@dataclass
+class SampleList:
+ sample_list: List[Sample]
+
+
+@dataclass
+class MediaContentList:
+ media_content_list: List[MediaContent]
+
+
+def create_numeric_struct() -> NumericStruct:
+ return NumericStruct(
+ f1=-12345,
+ f2=987654321,
+ f3=-31415,
+ f4=27182818,
+ f5=-32000,
+ f6=1000000,
+ f7=-999999999,
+ f8=42,
+ )
+
+
+def create_sample() -> Sample:
+ return Sample(
+ int_value=123,
+ long_value=1230000,
+ float_value=12.345,
+ double_value=1.234567,
+ short_value=12345,
+ char_value=ord("!"),
+ boolean_value=True,
+ int_value_boxed=321,
+ long_value_boxed=3210000,
+ float_value_boxed=54.321,
+ double_value_boxed=7.654321,
+ short_value_boxed=32100,
+ char_value_boxed=ord("$"),
+ boolean_value_boxed=False,
+ int_array=[-1234, -123, -12, -1, 0, 1, 12, 123, 1234],
+ long_array=[-123400, -12300, -1200, -100, 0, 100, 1200, 12300, 123400],
+ float_array=[-12.34, -12.3, -12.0, -1.0, 0.0, 1.0, 12.0, 12.3, 12.34],
+ double_array=[-1.234, -1.23, -12.0, -1.0, 0.0, 1.0, 12.0, 1.23, 1.234],
+ short_array=[-1234, -123, -12, -1, 0, 1, 12, 123, 1234],
+ char_array=[ord(c) for c in "asdfASDF"],
+ boolean_array=[True, False, False, True],
+ string="ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
+ )
+
+
+def create_media_content() -> MediaContent:
+ media = Media(
+ uri="http://javaone.com/keynote.ogg",
+ title="",
+ width=641,
+ height=481,
+ format="video/theora\u1234",
+ duration=18000001,
+ size=58982401,
+ bitrate=0,
+ has_bitrate=False,
+ persons=["Bill Gates, Jr.", "Steven Jobs"],
+ player=1,
+ copyright="Copyright (c) 2009, Scooby Dooby Doo",
+ )
+ images = [
+ Image(
+ uri="http://javaone.com/keynote_huge.jpg",
+ title="Javaone Keynote\u1234",
+ width=32000,
+ height=24000,
+ size=1,
+ ),
+ Image(
+ uri="http://javaone.com/keynote_large.jpg",
+ title="",
+ width=1024,
+ height=768,
+ size=1,
+ ),
+ Image(
+ uri="http://javaone.com/keynote_small.jpg",
+ title="",
+ width=320,
+ height=240,
+ size=0,
+ ),
+ ]
+ return MediaContent(media=media, images=images)
+
+
+def create_struct_list() -> StructList:
+ return StructList(struct_list=[create_numeric_struct() for _ in
range(LIST_SIZE)])
+
+
+def create_sample_list() -> SampleList:
+ return SampleList(sample_list=[create_sample() for _ in range(LIST_SIZE)])
+
+
+def create_media_content_list() -> MediaContentList:
+ return MediaContentList(
+ media_content_list=[create_media_content() for _ in range(LIST_SIZE)]
+ )
+
+
+def create_benchmark_data() -> Dict[str, Any]:
+ return {
+ "struct": create_numeric_struct(),
+ "sample": create_sample(),
+ "mediacontent": create_media_content(),
+ "structlist": create_struct_list(),
+ "samplelist": create_sample_list(),
+ "mediacontentlist": create_media_content_list(),
+ }
+
+
+def load_bench_pb2(proto_dir: Path):
+ bench_pb2_path = proto_dir / "bench_pb2.py"
+ if not bench_pb2_path.exists():
+ raise FileNotFoundError(
+ f"{bench_pb2_path} does not exist. Run benchmarks/python/run.sh
first to generate protobuf bindings."
+ )
+ proto_dir_abs = str(proto_dir.resolve())
+ if proto_dir_abs not in sys.path:
+ sys.path.insert(0, proto_dir_abs)
+ import bench_pb2 # type: ignore
+
+ return bench_pb2
+
+
+def to_pb_struct(bench_pb2, obj: NumericStruct):
+ pb = bench_pb2.Struct()
+ pb.f1 = obj.f1
+ pb.f2 = obj.f2
+ pb.f3 = obj.f3
+ pb.f4 = obj.f4
+ pb.f5 = obj.f5
+ pb.f6 = obj.f6
+ pb.f7 = obj.f7
+ pb.f8 = obj.f8
+ return pb
+
+
+def from_pb_struct(pb_obj) -> NumericStruct:
+ return NumericStruct(
+ f1=pb_obj.f1,
+ f2=pb_obj.f2,
+ f3=pb_obj.f3,
+ f4=pb_obj.f4,
+ f5=pb_obj.f5,
+ f6=pb_obj.f6,
+ f7=pb_obj.f7,
+ f8=pb_obj.f8,
+ )
+
+
+def to_pb_sample(bench_pb2, obj: Sample):
+ pb = bench_pb2.Sample()
+ pb.int_value = obj.int_value
+ pb.long_value = obj.long_value
+ pb.float_value = obj.float_value
+ pb.double_value = obj.double_value
+ pb.short_value = obj.short_value
+ pb.char_value = obj.char_value
+ pb.boolean_value = obj.boolean_value
+ pb.int_value_boxed = obj.int_value_boxed
+ pb.long_value_boxed = obj.long_value_boxed
+ pb.float_value_boxed = obj.float_value_boxed
+ pb.double_value_boxed = obj.double_value_boxed
+ pb.short_value_boxed = obj.short_value_boxed
+ pb.char_value_boxed = obj.char_value_boxed
+ pb.boolean_value_boxed = obj.boolean_value_boxed
+ pb.int_array.extend(obj.int_array)
+ pb.long_array.extend(obj.long_array)
+ pb.float_array.extend(obj.float_array)
+ pb.double_array.extend(obj.double_array)
+ pb.short_array.extend(obj.short_array)
+ pb.char_array.extend(obj.char_array)
+ pb.boolean_array.extend(obj.boolean_array)
+ pb.string = obj.string
+ return pb
+
+
+def from_pb_sample(pb_obj) -> Sample:
+ return Sample(
+ int_value=pb_obj.int_value,
+ long_value=pb_obj.long_value,
+ float_value=pb_obj.float_value,
+ double_value=pb_obj.double_value,
+ short_value=pb_obj.short_value,
+ char_value=pb_obj.char_value,
+ boolean_value=pb_obj.boolean_value,
+ int_value_boxed=pb_obj.int_value_boxed,
+ long_value_boxed=pb_obj.long_value_boxed,
+ float_value_boxed=pb_obj.float_value_boxed,
+ double_value_boxed=pb_obj.double_value_boxed,
+ short_value_boxed=pb_obj.short_value_boxed,
+ char_value_boxed=pb_obj.char_value_boxed,
+ boolean_value_boxed=pb_obj.boolean_value_boxed,
+ int_array=list(pb_obj.int_array),
+ long_array=list(pb_obj.long_array),
+ float_array=list(pb_obj.float_array),
+ double_array=list(pb_obj.double_array),
+ short_array=list(pb_obj.short_array),
+ char_array=list(pb_obj.char_array),
+ boolean_array=list(pb_obj.boolean_array),
+ string=pb_obj.string,
+ )
+
+
+def to_pb_image(bench_pb2, obj: Image):
+ pb = bench_pb2.Image()
+ pb.uri = obj.uri
+ if obj.title:
+ pb.title = obj.title
+ pb.width = obj.width
+ pb.height = obj.height
+ pb.size = obj.size
+ return pb
+
+
+def from_pb_image(pb_obj) -> Image:
+ title = pb_obj.title if pb_obj.HasField("title") else ""
+ return Image(
+ uri=pb_obj.uri,
+ title=title,
+ width=pb_obj.width,
+ height=pb_obj.height,
+ size=pb_obj.size,
+ )
+
+
+def to_pb_media(bench_pb2, obj: Media):
+ pb = bench_pb2.Media()
+ pb.uri = obj.uri
+ if obj.title:
+ pb.title = obj.title
+ pb.width = obj.width
+ pb.height = obj.height
+ pb.format = obj.format
+ pb.duration = obj.duration
+ pb.size = obj.size
+ pb.bitrate = obj.bitrate
+ pb.has_bitrate = obj.has_bitrate
+ pb.persons.extend(obj.persons)
+ pb.player = obj.player
+ pb.copyright = obj.copyright
+ return pb
+
+
+def from_pb_media(pb_obj) -> Media:
+ title = pb_obj.title if pb_obj.HasField("title") else ""
+ return Media(
+ uri=pb_obj.uri,
+ title=title,
+ width=pb_obj.width,
+ height=pb_obj.height,
+ format=pb_obj.format,
+ duration=pb_obj.duration,
+ size=pb_obj.size,
+ bitrate=pb_obj.bitrate,
+ has_bitrate=pb_obj.has_bitrate,
+ persons=list(pb_obj.persons),
+ player=pb_obj.player,
+ copyright=pb_obj.copyright,
+ )
+
+
+def to_pb_mediacontent(bench_pb2, obj: MediaContent):
+ pb = bench_pb2.MediaContent()
+ pb.media.CopyFrom(to_pb_media(bench_pb2, obj.media))
+ for image in obj.images:
+ pb.images.add().CopyFrom(to_pb_image(bench_pb2, image))
+ return pb
+
+
+def from_pb_mediacontent(pb_obj) -> MediaContent:
+ return MediaContent(
+ media=from_pb_media(pb_obj.media),
+ images=[from_pb_image(img) for img in pb_obj.images],
+ )
+
+
+def to_pb_structlist(bench_pb2, obj: StructList):
+ pb = bench_pb2.StructList()
+ for item in obj.struct_list:
+ pb.struct_list.add().CopyFrom(to_pb_struct(bench_pb2, item))
+ return pb
+
+
+def from_pb_structlist(pb_obj) -> StructList:
+ return StructList(struct_list=[from_pb_struct(item) for item in
pb_obj.struct_list])
+
+
+def to_pb_samplelist(bench_pb2, obj: SampleList):
+ pb = bench_pb2.SampleList()
+ for item in obj.sample_list:
+ pb.sample_list.add().CopyFrom(to_pb_sample(bench_pb2, item))
+ return pb
+
+
+def from_pb_samplelist(pb_obj) -> SampleList:
+ return SampleList(sample_list=[from_pb_sample(item) for item in
pb_obj.sample_list])
+
+
+def to_pb_mediacontentlist(bench_pb2, obj: MediaContentList):
+ pb = bench_pb2.MediaContentList()
+ for item in obj.media_content_list:
+ pb.media_content_list.add().CopyFrom(to_pb_mediacontent(bench_pb2,
item))
+ return pb
+
+
+def from_pb_mediacontentlist(pb_obj) -> MediaContentList:
+ return MediaContentList(
+ media_content_list=[
+ from_pb_mediacontent(item) for item in pb_obj.media_content_list
+ ]
+ )
+
+
+PROTO_CONVERTERS = {
+ "struct": (to_pb_struct, from_pb_struct, "Struct"),
+ "sample": (to_pb_sample, from_pb_sample, "Sample"),
+ "mediacontent": (to_pb_mediacontent, from_pb_mediacontent, "MediaContent"),
+ "structlist": (to_pb_structlist, from_pb_structlist, "StructList"),
+ "samplelist": (to_pb_samplelist, from_pb_samplelist, "SampleList"),
+ "mediacontentlist": (
+ to_pb_mediacontentlist,
+ from_pb_mediacontentlist,
+ "MediaContentList",
+ ),
+}
+
+
+def build_fory() -> pyfory.Fory:
+ fory = pyfory.Fory(xlang=True, compatible=True, ref=False)
+ fory.register_type(NumericStruct, type_id=1)
+ fory.register_type(Sample, type_id=2)
+ fory.register_type(Media, type_id=3)
+ fory.register_type(Image, type_id=4)
+ fory.register_type(MediaContent, type_id=5)
+ fory.register_type(StructList, type_id=6)
+ fory.register_type(SampleList, type_id=7)
+ fory.register_type(MediaContentList, type_id=8)
+ return fory
+
+
+def run_benchmark(
+ func: Callable[..., Any],
+ args: Tuple[Any, ...],
+ *,
+ warmup: int,
+ iterations: int,
+ repeat: int,
+ number: int,
+) -> Tuple[float, float]:
+ for _ in range(warmup):
+ for _ in range(number):
+ func(*args)
+
+ samples: List[float] = []
+ for _ in range(iterations):
+ timer = timeit.Timer(lambda: func(*args))
+ loop_times = timer.repeat(repeat=repeat, number=number)
+ samples.extend([time_total / number for time_total in loop_times])
+
+ mean = statistics.mean(samples)
+ stdev = statistics.stdev(samples) if len(samples) > 1 else 0.0
+ return mean, stdev
+
+
+def format_time(seconds: float) -> str:
+ if seconds < 1e-6:
+ return f"{seconds * 1e9:.2f} ns"
+ if seconds < 1e-3:
+ return f"{seconds * 1e6:.2f} us"
+ if seconds < 1:
+ return f"{seconds * 1e3:.2f} ms"
+ return f"{seconds:.2f} s"
+
+
+def fory_serialize(fory: pyfory.Fory, obj: Any) -> None:
+ fory.serialize(obj)
+
+
+def fory_deserialize(fory: pyfory.Fory, binary: bytes) -> None:
+ fory.deserialize(binary)
+
+
+def pickle_serialize(obj: Any) -> None:
+ pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
+
+
+def pickle_deserialize(binary: bytes) -> None:
+ pickle.loads(binary)
+
+
+def protobuf_serialize(bench_pb2, datatype: str, obj: Any) -> None:
+ to_pb, _, _ = PROTO_CONVERTERS[datatype]
+ pb_obj = to_pb(bench_pb2, obj)
+ pb_obj.SerializeToString()
+
+
+def protobuf_deserialize(bench_pb2, datatype: str, binary: bytes) -> None:
+ _, from_pb, pb_type_name = PROTO_CONVERTERS[datatype]
+ pb_cls = getattr(bench_pb2, pb_type_name)
+ pb_obj = pb_cls()
+ pb_obj.ParseFromString(binary)
+ from_pb(pb_obj)
+
+
+def benchmark_name(serializer: str, datatype: str, operation: str) -> str:
+ return
f"BM_{SERIALIZER_LABELS[serializer]}_{DATA_LABELS[datatype]}_{operation.capitalize()}"
+
+
+def build_case(
+ serializer: str,
+ operation: str,
+ datatype: str,
+ obj: Any,
+ *,
+ fory: pyfory.Fory,
+ bench_pb2,
+) -> Tuple[Callable[..., Any], Tuple[Any, ...]]:
+ if serializer == "fory":
+ if operation == "serialize":
+ return fory_serialize, (fory, obj)
+ return fory_deserialize, (fory, fory.serialize(obj))
+
+ if serializer == "pickle":
+ if operation == "serialize":
+ return pickle_serialize, (obj,)
+ return pickle_deserialize, (
+ pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL),
+ )
+
+ if serializer == "protobuf":
+ if operation == "serialize":
+ return protobuf_serialize, (bench_pb2, datatype, obj)
+ to_pb, _, _ = PROTO_CONVERTERS[datatype]
+ pb_binary = to_pb(bench_pb2, obj).SerializeToString()
+ return protobuf_deserialize, (bench_pb2, datatype, pb_binary)
+
+ raise ValueError(f"Unsupported serializer: {serializer}")
+
+
+def calculate_serialized_sizes(
+ benchmark_data: Dict[str, Any],
+ selected_datatypes: Iterable[str],
+ *,
+ fory: pyfory.Fory,
+ bench_pb2,
+) -> Dict[str, Dict[str, int]]:
+ sizes: Dict[str, Dict[str, int]] = {}
+ for datatype in selected_datatypes:
+ obj = benchmark_data[datatype]
+ datatype_sizes: Dict[str, int] = {}
+
+ datatype_sizes["fory"] = len(fory.serialize(obj))
+ datatype_sizes["pickle"] = len(
+ pickle.dumps(obj, protocol=pickle.HIGHEST_PROTOCOL)
+ )
+
+ to_pb, _, _ = PROTO_CONVERTERS[datatype]
+ datatype_sizes["protobuf"] = len(to_pb(bench_pb2,
obj).SerializeToString())
+
+ sizes[datatype] = datatype_sizes
+ return sizes
+
+
+def parse_csv_list(value: str, allowed: Iterable[str], default: List[str]) ->
List[str]:
+ if value == "all":
+ return list(default)
+ selected = [item.strip().lower() for item in value.split(",") if
item.strip()]
+ invalid = [item for item in selected if item not in allowed]
+ if invalid:
+ raise ValueError(
+ f"Invalid values: {', '.join(invalid)}. Allowed: {',
'.join(sorted(allowed))}"
+ )
+ ordered = [item for item in default if item in selected]
+ return ordered
+
+
+def benchmark_number(base_number: int, datatype: str) -> int:
+ scale = {
+ "struct": 1.0,
+ "sample": 0.5,
+ "mediacontent": 0.4,
+ "structlist": 0.25,
+ "samplelist": 0.2,
+ "mediacontentlist": 0.15,
+ }
+ return max(1, int(base_number * scale.get(datatype, 1.0)))
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Comprehensive Fory/Pickle/Protobuf benchmark for Python"
+ )
+ parser.add_argument(
+ "--operation",
+ default="all",
+ choices=["all", "serialize", "deserialize"],
+ help="Benchmark operation: all, serialize, deserialize",
+ )
+ parser.add_argument(
+ "--data",
+ default="all",
+ help="Comma-separated data types:
struct,sample,mediacontent,structlist,samplelist,mediacontentlist or all",
+ )
+ parser.add_argument(
+ "--serializer",
+ default="all",
+ help="Comma-separated serializers: fory,pickle,protobuf or all",
+ )
+ parser.add_argument(
+ "--warmup",
+ type=int,
+ default=3,
+ help="Warmup iterations (default: 3)",
+ )
+ parser.add_argument(
+ "--iterations",
+ type=int,
+ default=15,
+ help="Measurement iterations (default: 15)",
+ )
+ parser.add_argument(
+ "--repeat",
+ type=int,
+ default=5,
+ help="Timer repeat count per iteration (default: 5)",
+ )
+ parser.add_argument(
+ "--number",
+ type=int,
+ default=1000,
+ help="Function calls per timer measurement (default: 1000)",
+ )
+ parser.add_argument(
+ "--proto-dir",
+ default=str(Path(__file__).with_name("proto")),
+ help="Directory containing generated bench_pb2.py",
+ )
+ parser.add_argument(
+ "--output-json",
+ default=str(Path(__file__).with_name("results") /
"benchmark_results.json"),
+ help="Output JSON file path",
+ )
+ return parser.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+
+ proto_dir = Path(args.proto_dir)
+ bench_pb2 = load_bench_pb2(proto_dir)
+
+ selected_datatypes = parse_csv_list(args.data, DATA_TYPE_ORDER,
DATA_TYPE_ORDER)
+ selected_serializers = parse_csv_list(
+ args.serializer, SERIALIZER_ORDER, SERIALIZER_ORDER
+ )
+ selected_operations = (
+ OPERATION_ORDER if args.operation == "all" else [args.operation]
+ )
+
+ benchmark_data = create_benchmark_data()
+ fory = build_fory()
+
+ print(
+ f"Benchmarking {len(selected_datatypes)} data type(s), "
+ f"{len(selected_serializers)} serializer(s), "
+ f"{len(selected_operations)} operation(s)"
+ )
+ print(
+ f"Warmup={args.warmup}, Iterations={args.iterations},
Repeat={args.repeat}, Number={args.number}"
+ )
+ print("=" * 96)
+
+ results = []
+
+ for datatype in selected_datatypes:
+ obj = benchmark_data[datatype]
+ call_number = benchmark_number(args.number, datatype)
+ for operation in selected_operations:
+ for serializer in selected_serializers:
+ case_name = benchmark_name(serializer, datatype, operation)
+ print(f"Running {case_name} ...", end=" ", flush=True)
+
+ func, func_args = build_case(
+ serializer,
+ operation,
+ datatype,
+ obj,
+ fory=fory,
+ bench_pb2=bench_pb2,
+ )
+ mean, stdev = run_benchmark(
+ func,
+ func_args,
+ warmup=args.warmup,
+ iterations=args.iterations,
+ repeat=args.repeat,
+ number=call_number,
+ )
+
+ results.append(
+ {
+ "name": case_name,
+ "serializer": serializer,
+ "datatype": datatype,
+ "operation": operation,
+ "mean_seconds": mean,
+ "stdev_seconds": stdev,
+ "mean_ns": mean * 1e9,
+ "stdev_ns": stdev * 1e9,
+ "number": call_number,
+ }
+ )
+ print(f"{format_time(mean)} ± {format_time(stdev)}")
+
+ sizes = calculate_serialized_sizes(
+ benchmark_data,
+ selected_datatypes,
+ fory=fory,
+ bench_pb2=bench_pb2,
+ )
+
+ output_path = Path(args.output_json)
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+
+ payload = {
+ "context": {
+ "python_version": platform.python_version(),
+ "python_implementation": platform.python_implementation(),
+ "platform": platform.platform(),
+ "machine": platform.machine(),
+ "processor": platform.processor() or "Unknown",
+ "enable_fory_debug_output": os.getenv("ENABLE_FORY_DEBUG_OUTPUT",
"0"),
+ "warmup": args.warmup,
+ "iterations": args.iterations,
+ "repeat": args.repeat,
+ "number": args.number,
+ "operations": selected_operations,
+ "datatypes": selected_datatypes,
+ "serializers": selected_serializers,
+ "list_size": LIST_SIZE,
+ },
+ "benchmarks": results,
+ "sizes": sizes,
+ }
+
+ with output_path.open("w", encoding="utf-8") as f:
+ json.dump(payload, f, indent=2)
+
+ print("=" * 96)
+ print(f"Benchmark JSON written to: {output_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/python/benchmark_report.py
b/benchmarks/python/benchmark_report.py
new file mode 100755
index 000000000..8df4298e0
--- /dev/null
+++ b/benchmarks/python/benchmark_report.py
@@ -0,0 +1,439 @@
+#!/usr/bin/env python3
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+"""Generate plots and Markdown report from Python benchmark JSON results."""
+
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import platform
+from collections import defaultdict
+from datetime import datetime
+from pathlib import Path
+from typing import Dict
+
+import matplotlib.pyplot as plt
+import numpy as np
+
+try:
+ import psutil
+
+ HAS_PSUTIL = True
+except ImportError:
+ HAS_PSUTIL = False
+
+
+COLORS = {
+ "fory": "#FF6F01",
+ "pickle": "#4C78A8",
+ "protobuf": "#55BCC2",
+}
+SERIALIZER_ORDER = ["fory", "pickle", "protobuf"]
+SERIALIZER_LABELS = {
+ "fory": "fory",
+ "pickle": "pickle",
+ "protobuf": "protobuf",
+}
+DATATYPE_ORDER = [
+ "struct",
+ "sample",
+ "mediacontent",
+ "structlist",
+ "samplelist",
+ "mediacontentlist",
+]
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Generate markdown report and plots for Python benchmark
suite"
+ )
+ parser.add_argument(
+ "--json-file",
+ default="results/benchmark_results.json",
+ help="Benchmark JSON file produced by benchmark.py",
+ )
+ parser.add_argument(
+ "--output-dir",
+ default="results/report",
+ help="Output directory for report and plots",
+ )
+ parser.add_argument(
+ "--plot-prefix",
+ default="",
+ help="Optional image path prefix used in markdown",
+ )
+ return parser.parse_args()
+
+
+def load_json(path: Path) -> Dict:
+ with path.open("r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def get_system_info() -> Dict[str, str]:
+ info = {
+ "OS": f"{platform.system()} {platform.release()}",
+ "Machine": platform.machine(),
+ "Processor": platform.processor() or "Unknown",
+ "Python": platform.python_version(),
+ }
+ if HAS_PSUTIL:
+ info["CPU Cores (Physical)"] = str(psutil.cpu_count(logical=False))
+ info["CPU Cores (Logical)"] = str(psutil.cpu_count(logical=True))
+ info["Total RAM (GB)"] = str(
+ round(psutil.virtual_memory().total / (1024**3), 2)
+ )
+ return info
+
+
+def format_datatype_label(datatype: str) -> str:
+ mapping = {
+ "struct": "Struct",
+ "sample": "Sample",
+ "mediacontent": "MediaContent",
+ "structlist": "Struct\nList",
+ "samplelist": "Sample\nList",
+ "mediacontentlist": "MediaContent\nList",
+ }
+ return mapping.get(datatype, datatype)
+
+
+def format_datatype_table_label(datatype: str) -> str:
+ mapping = {
+ "struct": "Struct",
+ "sample": "Sample",
+ "mediacontent": "MediaContent",
+ "structlist": "StructList",
+ "samplelist": "SampleList",
+ "mediacontentlist": "MediaContentList",
+ }
+ return mapping.get(datatype, datatype)
+
+
+def format_tps_label(tps: float) -> str:
+ if tps >= 1e9:
+ return f"{tps / 1e9:.2f}G"
+ if tps >= 1e6:
+ return f"{tps / 1e6:.2f}M"
+ if tps >= 1e3:
+ return f"{tps / 1e3:.2f}K"
+ return f"{tps:.0f}"
+
+
+def build_benchmark_matrix(benchmarks):
+ data = defaultdict(lambda: defaultdict(dict))
+ for bench in benchmarks:
+ datatype = bench["datatype"]
+ operation = bench["operation"]
+ serializer = bench["serializer"]
+ data[datatype][operation][serializer] = bench["mean_ns"]
+ return data
+
+
+def plot_datatype(ax, data, datatype: str, operation: str):
+ if datatype not in data or operation not in data[datatype]:
+ ax.set_title(f"{format_datatype_table_label(datatype)} {operation}: no
data")
+ ax.axis("off")
+ return
+
+ libs = [
+ lib
+ for lib in SERIALIZER_ORDER
+ if data[datatype][operation].get(lib, 0) and
data[datatype][operation][lib] > 0
+ ]
+ if not libs:
+ ax.set_title(f"{format_datatype_table_label(datatype)} {operation}: no
data")
+ ax.axis("off")
+ return
+
+ times = [data[datatype][operation][lib] for lib in libs]
+ throughput = [1e9 / t if t > 0 else 0 for t in times]
+
+ x = np.arange(len(libs))
+ bars = ax.bar(
+ x,
+ throughput,
+ color=[COLORS.get(lib, "#999999") for lib in libs],
+ width=0.6,
+ )
+
+ ax.set_xticks(x)
+ ax.set_xticklabels([SERIALIZER_LABELS.get(lib, lib) for lib in libs])
+ ax.set_ylabel("Throughput (ops/sec)")
+ ax.set_title(f"{operation.capitalize()} Throughput (higher is better)")
+ ax.grid(True, axis="y", linestyle="--", alpha=0.45)
+ ax.ticklabel_format(style="scientific", axis="y", scilimits=(0, 0))
+
+ for bar, val in zip(bars, throughput):
+ ax.annotate(
+ format_tps_label(val),
+ xy=(bar.get_x() + bar.get_width() / 2, bar.get_height()),
+ xytext=(0, 3),
+ textcoords="offset points",
+ ha="center",
+ va="bottom",
+ fontsize=9,
+ )
+
+
+def plot_combined_subplot(ax, data, datatypes, operation: str, title: str):
+ available_dts = [dt for dt in datatypes if operation in data.get(dt, {})]
+ if not available_dts:
+ ax.set_title(f"{title}\nNo Data")
+ ax.axis("off")
+ return
+
+ x = np.arange(len(available_dts))
+ available_libs = [
+ lib
+ for lib in SERIALIZER_ORDER
+ if any(
+ data.get(dt, {}).get(operation, {}).get(lib, 0) > 0 for dt in
available_dts
+ )
+ ]
+ if not available_libs:
+ ax.set_title(f"{title}\nNo Data")
+ ax.axis("off")
+ return
+
+ width = 0.8 / len(available_libs)
+ for idx, lib in enumerate(available_libs):
+ times = [
+ data.get(dt, {}).get(operation, {}).get(lib, 0) for dt in
available_dts
+ ]
+ tps = [1e9 / val if val > 0 else 0 for val in times]
+ offset = (idx - (len(available_libs) - 1) / 2) * width
+ ax.bar(
+ x + offset,
+ tps,
+ width,
+ label=SERIALIZER_LABELS.get(lib, lib),
+ color=COLORS.get(lib, "#999999"),
+ )
+
+ ax.set_title(title)
+ ax.set_xticks(x)
+ ax.set_xticklabels([format_datatype_label(dt) for dt in available_dts])
+ ax.grid(True, axis="y", linestyle="--", alpha=0.45)
+ ax.ticklabel_format(style="scientific", axis="y", scilimits=(0, 0))
+ ax.legend()
+
+
+def generate_plots(data, output_dir: Path):
+ plot_images = []
+ operations = ["serialize", "deserialize"]
+
+ datatypes = [dt for dt in DATATYPE_ORDER if dt in data]
+ for datatype in datatypes:
+ fig, axes = plt.subplots(1, 2, figsize=(12, 5))
+ for idx, operation in enumerate(operations):
+ plot_datatype(axes[idx], data, datatype, operation)
+ fig.suptitle(f"{format_datatype_table_label(datatype)} Throughput",
fontsize=14)
+ fig.tight_layout(rect=[0, 0, 1, 0.95])
+
+ path = output_dir / f"{datatype}.png"
+ plt.savefig(path, dpi=150)
+ plt.close()
+ plot_images.append((datatype, path))
+
+ non_list_datatypes = [dt for dt in datatypes if not dt.endswith("list")]
+ list_datatypes = [dt for dt in datatypes if dt.endswith("list")]
+
+ fig, axes = plt.subplots(1, 4, figsize=(28, 6))
+ fig.supylabel("Throughput (ops/sec)")
+
+ plot_combined_subplot(
+ axes[0], data, non_list_datatypes, "serialize", "Serialize Throughput"
+ )
+ plot_combined_subplot(
+ axes[1], data, non_list_datatypes, "deserialize", "Deserialize
Throughput"
+ )
+ plot_combined_subplot(
+ axes[2], data, list_datatypes, "serialize", "Serialize Throughput
(*List)"
+ )
+ plot_combined_subplot(
+ axes[3], data, list_datatypes, "deserialize", "Deserialize Throughput
(*List)"
+ )
+
+ fig.tight_layout()
+ throughput_path = output_dir / "throughput.png"
+ plt.savefig(throughput_path, dpi=150)
+ plt.close()
+ plot_images.append(("throughput", throughput_path))
+
+ return plot_images
+
+
+def generate_markdown_report(
+ raw, data, sizes, plot_images, output_dir: Path, plot_prefix: str
+):
+ context = raw.get("context", {})
+ system_info = get_system_info()
+
+ if context.get("python_implementation"):
+ system_info["Python Implementation"] = context["python_implementation"]
+ if context.get("platform"):
+ system_info["Benchmark Platform"] = context["platform"]
+
+ datatypes = [dt for dt in DATATYPE_ORDER if dt in data]
+ operations = ["serialize", "deserialize"]
+
+ md = [
+ "# Python Benchmark Performance Report\n\n",
+ f"_Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}_\n\n",
+ "## How to Generate This Report\n\n",
+ "```bash\n",
+ "cd benchmarks/python\n",
+ "./run.sh\n",
+ "```\n\n",
+ "## Hardware & OS Info\n\n",
+ "| Key | Value |\n",
+ "|-----|-------|\n",
+ ]
+
+ for key, value in system_info.items():
+ md.append(f"| {key} | {value} |\n")
+
+ md.append("\n## Benchmark Configuration\n\n")
+ md.append("| Key | Value |\n")
+ md.append("|-----|-------|\n")
+ for key in ["warmup", "iterations", "repeat", "number", "list_size"]:
+ if key in context:
+ md.append(f"| {key} | {context[key]} |\n")
+
+ md.append("\n## Benchmark Plots\n")
+ md.append("\nAll plots show throughput (ops/sec); higher is better.\n")
+
+ plot_images_sorted = sorted(
+ plot_images, key=lambda item: (0 if item[0] == "throughput" else 1,
item[0])
+ )
+ for datatype, image_path in plot_images_sorted:
+ image_name = os.path.basename(image_path)
+ image_ref = f"{plot_prefix}{image_name}"
+ md.append(f"\n### {datatype.replace('_', ' ').title()}\n\n")
+ md.append(f'<p align="center">\n<img src="{image_ref}" width="90%"
/>\n</p>\n')
+
+ md.append("\n## Benchmark Results\n\n")
+ md.append("### Timing Results (nanoseconds)\n\n")
+ md.append(
+ "| Datatype | Operation | fory (ns) | pickle (ns) | protobuf (ns) |
Fastest |\n"
+ )
+ md.append(
+
"|----------|-----------|-----------|-------------|---------------|---------|\n"
+ )
+
+ for datatype in datatypes:
+ for operation in operations:
+ times = {
+ lib: data.get(datatype, {}).get(operation, {}).get(lib, 0)
+ for lib in SERIALIZER_ORDER
+ }
+ valid = {lib: val for lib, val in times.items() if val > 0}
+ fastest = min(valid, key=valid.get) if valid else "N/A"
+ md.append(
+ "| "
+ + f"{format_datatype_table_label(datatype)} |
{operation.capitalize()} | "
+ + " | ".join(
+ f"{times[lib]:.1f}" if times[lib] > 0 else "N/A"
+ for lib in SERIALIZER_ORDER
+ )
+ + f" | {SERIALIZER_LABELS.get(fastest, fastest)} |\n"
+ )
+
+ md.append("\n### Throughput Results (ops/sec)\n\n")
+ md.append(
+ "| Datatype | Operation | fory TPS | pickle TPS | protobuf TPS |
Fastest |\n"
+ )
+ md.append(
+
"|----------|-----------|----------|------------|--------------|---------|\n"
+ )
+
+ for datatype in datatypes:
+ for operation in operations:
+ times = {
+ lib: data.get(datatype, {}).get(operation, {}).get(lib, 0)
+ for lib in SERIALIZER_ORDER
+ }
+ tps = {lib: (1e9 / val if val > 0 else 0) for lib, val in
times.items()}
+ valid_tps = {lib: val for lib, val in tps.items() if val > 0}
+ fastest = max(valid_tps, key=valid_tps.get) if valid_tps else "N/A"
+ md.append(
+ "| "
+ + f"{format_datatype_table_label(datatype)} |
{operation.capitalize()} | "
+ + " | ".join(
+ f"{tps[lib]:,.0f}" if tps[lib] > 0 else "N/A"
+ for lib in SERIALIZER_ORDER
+ )
+ + f" | {SERIALIZER_LABELS.get(fastest, fastest)} |\n"
+ )
+
+ if sizes:
+ md.append("\n### Serialized Data Sizes (bytes)\n\n")
+ md.append("| Datatype | fory | pickle | protobuf |\n")
+ md.append("|----------|------|--------|----------|\n")
+
+ for datatype in datatypes:
+ datatype_sizes = sizes.get(datatype, {})
+ row = []
+ for lib in SERIALIZER_ORDER:
+ value = datatype_sizes.get(lib, -1)
+ row.append(str(value) if value is not None and value >= 0 else
"N/A")
+ md.append(
+ f"| {format_datatype_table_label(datatype)} | "
+ + " | ".join(row)
+ + " |\n"
+ )
+
+ report_path = output_dir / "README.md"
+ report_path.write_text("".join(md), encoding="utf-8")
+ return report_path
+
+
+def main() -> int:
+ args = parse_args()
+
+ json_file = Path(args.json_file)
+ output_dir = Path(args.output_dir)
+ output_dir.mkdir(parents=True, exist_ok=True)
+
+ raw = load_json(json_file)
+ benchmarks = raw.get("benchmarks", [])
+ sizes = raw.get("sizes", {})
+
+ data = build_benchmark_matrix(benchmarks)
+ plot_images = generate_plots(data, output_dir)
+
+ report_path = generate_markdown_report(
+ raw,
+ data,
+ sizes,
+ plot_images,
+ output_dir,
+ args.plot_prefix,
+ )
+
+ print(f"Plots saved in: {output_dir}")
+ print(f"Markdown report generated at: {report_path}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/benchmarks/python/run.sh b/benchmarks/python/run.sh
new file mode 100755
index 000000000..ff28f81ce
--- /dev/null
+++ b/benchmarks/python/run.sh
@@ -0,0 +1,198 @@
+#!/bin/bash
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+set -euo pipefail
+export ENABLE_FORY_DEBUG_OUTPUT=0
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd "$SCRIPT_DIR"
+
+PYTHON_BIN="${PYTHON_BIN:-python3}"
+OUTPUT_DIR="$SCRIPT_DIR/results"
+REPORT_DIR="$OUTPUT_DIR/report"
+PROTO_DIR="$SCRIPT_DIR/proto"
+DOCS_DIR="$SCRIPT_DIR/../../docs/benchmarks/python"
+
+DATA=""
+SERIALIZER=""
+OPERATION="all"
+WARMUP=3
+ITERATIONS=15
+REPEAT=5
+NUMBER=1000
+COPY_DOCS=true
+
+usage() {
+ cat <<'EOF'
+Usage: ./run.sh [options]
+
+Run Python comprehensive benchmarks for struct/sample/mediacontent and list
variants.
+
+Options:
+ --data <type> Filter by data type:
struct,sample,mediacontent,structlist,samplelist,mediacontentlist
+ --serializer <name> Filter by serializer: fory,pickle,protobuf
+ --operation <op> all|serialize|deserialize (default: all)
+ --warmup <n> Warmup iterations (default: 3)
+ --iterations <n> Measurement iterations (default: 15)
+ --repeat <n> Repeat count per iteration (default: 5)
+ --number <n> Inner loop call count (default: 1000)
+ --no-copy-docs Skip copying report/plots into docs/benchmarks/python
+ -h, --help Show this help message
+
+Examples:
+ ./run.sh
+ ./run.sh --data struct --serializer fory
+ ./run.sh --operation serialize --iterations 30 --repeat 8
+EOF
+}
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --data)
+ DATA="$2"
+ shift 2
+ ;;
+ --serializer)
+ SERIALIZER="$2"
+ shift 2
+ ;;
+ --operation)
+ OPERATION="$2"
+ shift 2
+ ;;
+ --warmup)
+ WARMUP="$2"
+ shift 2
+ ;;
+ --iterations)
+ ITERATIONS="$2"
+ shift 2
+ ;;
+ --repeat)
+ REPEAT="$2"
+ shift 2
+ ;;
+ --number)
+ NUMBER="$2"
+ shift 2
+ ;;
+ --no-copy-docs)
+ COPY_DOCS=false
+ shift
+ ;;
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ *)
+ echo "Unknown option: $1"
+ usage
+ exit 1
+ ;;
+ esac
+done
+
+if ! command -v "$PYTHON_BIN" >/dev/null 2>&1; then
+ echo "Error: $PYTHON_BIN is not available"
+ exit 1
+fi
+
+if ! command -v protoc >/dev/null 2>&1; then
+ echo "Error: protoc is required. Install protobuf compiler first."
+ echo " macOS: brew install protobuf"
+ exit 1
+fi
+
+echo "============================================"
+echo "Python Comprehensive Benchmark"
+echo "============================================"
+
+echo "Checking runtime dependencies..."
+if ! "$PYTHON_BIN" -c "import pyfory" >/dev/null 2>&1; then
+ echo "Error: pyfory is not installed in current Python environment."
+ echo "Install it with: cd python && pip install -e ."
+ exit 1
+fi
+
+if ! "$PYTHON_BIN" -c "import google.protobuf" >/dev/null 2>&1; then
+ echo "Installing benchmark dependency: protobuf"
+ "$PYTHON_BIN" -m pip install protobuf
+fi
+
+if ! "$PYTHON_BIN" -c "import matplotlib, numpy" >/dev/null 2>&1; then
+ echo "Installing report dependencies: matplotlib numpy psutil"
+ "$PYTHON_BIN" -m pip install matplotlib numpy psutil
+fi
+
+mkdir -p "$PROTO_DIR" "$OUTPUT_DIR" "$REPORT_DIR"
+
+if [[ ! -f "$PROTO_DIR/__init__.py" ]]; then
+ touch "$PROTO_DIR/__init__.py"
+fi
+
+echo "Generating Python protobuf bindings..."
+protoc \
+ --proto_path="$SCRIPT_DIR/../proto" \
+ --python_out="$PROTO_DIR" \
+ "$SCRIPT_DIR/../proto/bench.proto"
+
+BENCH_JSON="$OUTPUT_DIR/benchmark_results.json"
+BENCH_CMD=(
+ "$PYTHON_BIN" "$SCRIPT_DIR/benchmark.py"
+ --proto-dir "$PROTO_DIR"
+ --output-json "$BENCH_JSON"
+ --operation "$OPERATION"
+ --warmup "$WARMUP"
+ --iterations "$ITERATIONS"
+ --repeat "$REPEAT"
+ --number "$NUMBER"
+)
+
+if [[ -n "$DATA" ]]; then
+ BENCH_CMD+=(--data "$DATA")
+fi
+if [[ -n "$SERIALIZER" ]]; then
+ BENCH_CMD+=(--serializer "$SERIALIZER")
+fi
+
+echo ""
+echo "Running benchmark..."
+"${BENCH_CMD[@]}"
+
+echo ""
+echo "Generating report..."
+"$PYTHON_BIN" "$SCRIPT_DIR/benchmark_report.py" \
+ --json-file "$BENCH_JSON" \
+ --output-dir "$REPORT_DIR"
+
+if [[ "$COPY_DOCS" == true ]]; then
+ mkdir -p "$DOCS_DIR"
+ cp "$REPORT_DIR/README.md" "$DOCS_DIR/README.md"
+ cp "$REPORT_DIR"/*.png "$DOCS_DIR/" 2>/dev/null || true
+ echo "Copied report and plots to: $DOCS_DIR"
+fi
+
+echo ""
+echo "============================================"
+echo "Benchmark complete!"
+echo "============================================"
+echo "Benchmark JSON: $BENCH_JSON"
+echo "Report: $REPORT_DIR/README.md"
+if [[ "$COPY_DOCS" == true ]]; then
+ echo "Docs sync: $DOCS_DIR"
+fi
diff --git a/docs/benchmarks/python/README.md b/docs/benchmarks/python/README.md
new file mode 100644
index 000000000..2908fb876
--- /dev/null
+++ b/docs/benchmarks/python/README.md
@@ -0,0 +1,127 @@
+# Python Benchmark Performance Report
+
+_Generated on 2026-03-03 13:42:38_
+
+## How to Generate This Report
+
+```bash
+cd benchmarks/python
+./run.sh
+```
+
+## Hardware & OS Info
+
+| Key | Value |
+| --------------------- | ---------------------------- |
+| OS | Darwin 24.6.0 |
+| Machine | arm64 |
+| Processor | arm |
+| Python | 3.10.8 |
+| CPU Cores (Physical) | 12 |
+| CPU Cores (Logical) | 12 |
+| Total RAM (GB) | 48.0 |
+| Python Implementation | CPython |
+| Benchmark Platform | macOS-15.7.2-arm64-arm-64bit |
+
+## Benchmark Configuration
+
+| Key | Value |
+| ---------- | ----- |
+| warmup | 3 |
+| iterations | 15 |
+| repeat | 5 |
+| number | 1000 |
+| list_size | 5 |
+
+## Benchmark Plots
+
+All plots show throughput (ops/sec); higher is better.
+
+### Throughput
+
+<p align="center">
+<img src="throughput.png" width="90%" />
+</p>
+
+### Mediacontent
+
+<p align="center">
+<img src="mediacontent.png" width="90%" />
+</p>
+
+### Mediacontentlist
+
+<p align="center">
+<img src="mediacontentlist.png" width="90%" />
+</p>
+
+### Sample
+
+<p align="center">
+<img src="sample.png" width="90%" />
+</p>
+
+### Samplelist
+
+<p align="center">
+<img src="samplelist.png" width="90%" />
+</p>
+
+### Struct
+
+<p align="center">
+<img src="struct.png" width="90%" />
+</p>
+
+### Structlist
+
+<p align="center">
+<img src="structlist.png" width="90%" />
+</p>
+
+## Benchmark Results
+
+### Timing Results (nanoseconds)
+
+| Datatype | Operation | fory (ns) | pickle (ns) | protobuf (ns) |
Fastest |
+| ---------------- | ----------- | --------- | ----------- | ------------- |
------- |
+| Struct | Serialize | 417.9 | 868.9 | 548.9 |
fory |
+| Struct | Deserialize | 516.1 | 910.6 | 742.4 |
fory |
+| Sample | Serialize | 828.1 | 1663.5 | 2383.7 |
fory |
+| Sample | Deserialize | 1282.4 | 2296.3 | 3992.7 |
fory |
+| MediaContent | Serialize | 1139.9 | 2859.7 | 2867.1 |
fory |
+| MediaContent | Deserialize | 1719.5 | 2854.3 | 3236.1 |
fory |
+| StructList | Serialize | 1009.1 | 2630.6 | 3281.6 |
fory |
+| StructList | Deserialize | 1387.2 | 2651.9 | 3547.9 |
fory |
+| SampleList | Serialize | 2828.3 | 5541.0 | 15256.6 |
fory |
+| SampleList | Deserialize | 5043.4 | 8144.7 | 18912.5 |
fory |
+| MediaContentList | Serialize | 3417.9 | 9341.9 | 15853.2 |
fory |
+| MediaContentList | Deserialize | 6138.7 | 8435.3 | 16442.6 |
fory |
+
+### Throughput Results (ops/sec)
+
+| Datatype | Operation | fory TPS | pickle TPS | protobuf TPS |
Fastest |
+| ---------------- | ----------- | --------- | ---------- | ------------ |
------- |
+| Struct | Serialize | 2,393,086 | 1,150,946 | 1,821,982 |
fory |
+| Struct | Deserialize | 1,937,707 | 1,098,170 | 1,346,915 |
fory |
+| Sample | Serialize | 1,207,542 | 601,144 | 419,511 |
fory |
+| Sample | Deserialize | 779,789 | 435,489 | 250,460 |
fory |
+| MediaContent | Serialize | 877,300 | 349,688 | 348,780 |
fory |
+| MediaContent | Deserialize | 581,563 | 350,354 | 309,018 |
fory |
+| StructList | Serialize | 991,017 | 380,145 | 304,732 |
fory |
+| StructList | Deserialize | 720,901 | 377,081 | 281,855 |
fory |
+| SampleList | Serialize | 353,574 | 180,473 | 65,545 |
fory |
+| SampleList | Deserialize | 198,280 | 122,780 | 52,875 |
fory |
+| MediaContentList | Serialize | 292,578 | 107,045 | 63,079 |
fory |
+| MediaContentList | Deserialize | 162,902 | 118,550 | 60,818 |
fory |
+
+### Serialized Data Sizes (bytes)
+
+| Datatype | fory | pickle | protobuf |
+| ---------------- | ---- | ------ | -------- |
+| Struct | 72 | 126 | 61 |
+| Sample | 517 | 793 | 375 |
+| MediaContent | 470 | 586 | 301 |
+| StructList | 205 | 420 | 315 |
+| SampleList | 1810 | 2539 | 1890 |
+| MediaContentList | 1756 | 1377 | 1520 |
diff --git a/docs/benchmarks/python/mediacontent.png
b/docs/benchmarks/python/mediacontent.png
new file mode 100644
index 000000000..05b28cd20
Binary files /dev/null and b/docs/benchmarks/python/mediacontent.png differ
diff --git a/docs/benchmarks/python/mediacontentlist.png
b/docs/benchmarks/python/mediacontentlist.png
new file mode 100644
index 000000000..6ca7b1814
Binary files /dev/null and b/docs/benchmarks/python/mediacontentlist.png differ
diff --git a/docs/benchmarks/python/sample.png
b/docs/benchmarks/python/sample.png
new file mode 100644
index 000000000..eb318e1ab
Binary files /dev/null and b/docs/benchmarks/python/sample.png differ
diff --git a/docs/benchmarks/python/samplelist.png
b/docs/benchmarks/python/samplelist.png
new file mode 100644
index 000000000..94896bcab
Binary files /dev/null and b/docs/benchmarks/python/samplelist.png differ
diff --git a/docs/benchmarks/python/struct.png
b/docs/benchmarks/python/struct.png
new file mode 100644
index 000000000..c8fe09cfe
Binary files /dev/null and b/docs/benchmarks/python/struct.png differ
diff --git a/docs/benchmarks/python/structlist.png
b/docs/benchmarks/python/structlist.png
new file mode 100644
index 000000000..e421b1738
Binary files /dev/null and b/docs/benchmarks/python/structlist.png differ
diff --git a/docs/benchmarks/python/throughput.png
b/docs/benchmarks/python/throughput.png
new file mode 100644
index 000000000..c750a9ffb
Binary files /dev/null and b/docs/benchmarks/python/throughput.png differ
diff --git a/python/pyfory/serialization.pyx b/python/pyfory/serialization.pyx
index 9b8c73098..e28aa06da 100644
--- a/python/pyfory/serialization.pyx
+++ b/python/pyfory/serialization.pyx
@@ -48,7 +48,7 @@ from libc.stdint cimport *
from libcpp.vector cimport vector
from libcpp.memory cimport shared_ptr
from cpython cimport PyObject
-from cpython.object cimport PyTypeObject
+from cpython.object cimport PyTypeObject, PyObject_GetAttr, PyObject_SetAttr
from cpython.dict cimport PyDict_Next
from cpython.ref cimport *
from cpython.list cimport PyList_New, PyList_SET_ITEM
diff --git a/python/pyfory/struct.pxi b/python/pyfory/struct.pxi
index 63a3871cc..2e024b3fa 100644
--- a/python/pyfory/struct.pxi
+++ b/python/pyfory/struct.pxi
@@ -296,18 +296,11 @@ cdef class DataClassSerializer(Serializer):
cdef object field_name
cdef FieldRuntimeInfo *field_info
- if self.fory.compatible:
- for i in range(field_count):
- field_info = &self._field_runtime_infos[i]
- field_name = <object> field_info.field_name
- field_value = value_dict.get(field_name)
- self._write_field_value(buffer, field_info, field_value)
- else:
- for i in range(field_count):
- field_info = &self._field_runtime_infos[i]
- field_name = <object> field_info.field_name
- field_value = value_dict[field_name]
- self._write_field_value(buffer, field_info, field_value)
+ for i in range(field_count):
+ field_info = &self._field_runtime_infos[i]
+ field_name = <object> field_info.field_name
+ field_value = value_dict[field_name]
+ self._write_field_value(buffer, field_info, field_value)
cdef inline void _write_slots(self, Buffer buffer, object value):
cdef Py_ssize_t i
@@ -320,13 +313,13 @@ cdef class DataClassSerializer(Serializer):
for i in range(field_count):
field_info = &self._field_runtime_infos[i]
field_name = <object> field_info.field_name
- field_value = getattr(value, field_name, None)
+ field_value = PyObject_GetAttr(value, field_name)
self._write_field_value(buffer, field_info, field_value)
else:
for i in range(field_count):
field_info = &self._field_runtime_infos[i]
field_name = <object> field_info.field_name
- field_value = getattr(value, field_name)
+ field_value = PyObject_GetAttr(value, field_name)
self._write_field_value(buffer, field_info, field_value)
cdef inline void _write_field_value(self, Buffer buffer, FieldRuntimeInfo
*field_info, object field_value):
@@ -421,7 +414,7 @@ cdef class DataClassSerializer(Serializer):
if field_info.field_exists == 0:
continue
field_name = <object> field_info.field_name
- setattr(obj, field_name, field_value)
+ PyObject_SetAttr(obj, field_name, field_value)
cdef inline object _read_field_value(self, Buffer buffer, FieldRuntimeInfo
*field_info):
cdef uint8_t type_id = field_info.basic_type_id
@@ -460,4 +453,4 @@ cdef class DataClassSerializer(Serializer):
cdef object default_factory
for field_name, default_factory in self._missing_field_defaults:
- setattr(obj, field_name, default_factory())
+ PyObject_SetAttr(obj, field_name, default_factory())
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]