Is protobuf much faster than json even in simple web server response requests?

SJY
9 min readJul 13, 2023

--

ProtoBuffer and JSON are both formats used for data serialization and transmission. However, ProtoBuffer is generally known to be lighter and faster than JSON.

  1. Size: ProtoBuffer uses a binary format to serialize data, which typically results in much smaller sizes compared to JSON. The binary format efficiently utilizes space to represent the data, saving network bandwidth during data transmission.
  2. Serialization and deserialization speed: ProtoBuffer is generally faster in performing serialization and deserialization operations. This is because ProtoBuffer is compact and uses a binary format. On the other hand, JSON is a text-based format, which can be relatively slower in processing.
  3. Schema definition: ProtoBuffer utilizes an explicit schema called the Protocol Buffer schema to define the data structure. This schema enables easy modification and extension of the data structure. In contrast, JSON does not have an explicit schema, which can make it less flexible when dealing with evolving data structures.

Overall, ProtoBuffer’s compactness, binary format, and explicit schema contribute to its reputation for being lighter and faster than JSON.

However, we haven’t actually seen it ourselves.

 jsonS := SingleData{}
jsonS.X = "X col"
jsonS.Y = "Y col"
jsonS.Z = 2401
jsonS.End = true
jsByte, _ := json.Marshal(&jsonS)

fmt.Println("Basic Struct Size >>>", unsafe.Sizeof(jsonS))
fmt.Println("Basic Byte Size >>>", unsafe.Sizeof(jsByte))

protoS := demo_v1.SingleData{}
protoS.X = "X col"
protoS.Y = "Y col"
protoS.Z = 2401
protoS.End = true
protoByte, _ := proto.Marshal(&protoS)

fmt.Println("Proto struct Size >>>", unsafe.Sizeof(protoS))
fmt.Println("Proto Byte Size >>>", unsafe.Sizeof(protoByte))

Let’s first compare their sizes.

We found that contrary to our expectations, Protobuf turned out to be heavier in terms of size.
However, the advantage of Protobuf lies in its lightweight serialization and deserialization process, which may introduce additional functionality and potentially increase its overall size. We should always consider the essence of Protobuf, and since it allows us to retrieve structures as pointers during serialization and deserialization, we should also compare by converting them to pointers.

 jsonS := &SingleData{}
jsonS.X = "X col"
jsonS.Y = "Y col"
jsonS.Z = 2401
jsonS.End = true
jsByte, _ := json.Marshal(jsonS)

fmt.Println("Basic Struct Size >>>", unsafe.Sizeof(jsonS))
fmt.Println("Basic Byte Size >>>", unsafe.Sizeof(jsByte))

protoS := &demo_v1.SingleData{}
protoS.X = "X col"
protoS.Y = "Y col"
protoS.Z = 2401
protoS.End = true
protoByte, _ := proto.Marshal(protoS)

fmt.Println("Proto struct Size >>>", unsafe.Sizeof(protoS))
fmt.Println("Proto Byte Size >>>", unsafe.Sizeof(protoByte))

As you may already know, pointers are significantly lighter than directly retrieving structures, even for the same structure. However, it doesn’t mean that we should always use pointers. In the current situation, I believe it is appropriate to apply pointers and see the results.

Now that we don’t have any significant issues with the size of the structures, let’s compare serialization and deserialization.

func ChkMemStruct() {
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
////////////////
jsonS := &SingleData{}
jsonS.X = "X col"
jsonS.Y = "Y col"
jsonS.Z = 2401
jsonS.End = true
jsByte, _ := json.Marshal(jsonS)
////////////////////
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("Struct -> Byte Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("Struct -> Byte CPU time:", elapsedTime)

startCPU = new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime = time.Now()
//////////
byteS := &SingleData{}
json.Unmarshal(jsByte, byteS)
/////////
elapsedTime = time.Since(startTime)
elapsedCPU = new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("Byte -> Struct Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("Byte -> Struct CPU time:", elapsedTime)
}
func ChkMemProtobuf() {
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
/////////////////
protoS := &demo_v1.SingleData{}
protoS.X = "X col"
protoS.Y = "Y col"
protoS.Z = 2401
protoS.End = true
protoByte, _ := proto.Marshal(protoS)
/////////////////
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("Protobuf -> Byte Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("Protobuf -> Byte CPU time:", elapsedTime)

startCPU = new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime = time.Now()
///////////
ByteP := &demo_v1.SingleData{}
proto.Unmarshal(protoByte, ByteP)
///////////////
elapsedTime = time.Since(startTime)
elapsedCPU = new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("Byte -> Protobuf Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("Byte -> Protobuf CPU time:", elapsedTime)
}

Let’s compare the resources used for serialization and deserialization by using the two functions mentioned above.

As we can see, serialization is slightly faster with JSON, while deserialization is significantly faster with Protobuf, about three times faster.

However, based on this information alone, it may be difficult to fully grasp the merits of using Protobuf, especially if we assume that gRPC is not being used.

func BredisJsonMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
for i := 0; i < b.N; i++ {
in := &SingleData{}
in.X = fmt.Sprintf("x_%d", i)
in.Y = fmt.Sprintf("y_%d", i)
in.Z = int32(i)
in.End = true
jb, _ := json.Marshal(in)
client.Set(ctx, fmt.Sprintf("json_%d", i), jb, time.Duration(time.Minute*1))
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("JsonMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("JsonMarshal to Redis -> CPU time:", elapsedTime)
}

func BredisProtobufMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
for i := 0; i < b.N; i++ {
in := &demo_v1.SingleData{}
in.X = fmt.Sprintf("x_%d", i)
in.Y = fmt.Sprintf("y_%d", i)
in.Z = int32(i)
in.End = true
pb, _ := proto.Marshal(in)
client.Set(ctx, fmt.Sprintf("proto_%d", i), pb, time.Duration(time.Minute*1))
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("ProtoMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("ProtoMarshal to Redis -> CPU time:", elapsedTime)
}

func BredisJsonUnMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
errOrNil := 0
for i := 0; i < b.N; i++ {
b, err := client.Get(ctx, fmt.Sprintf("json_%d", i)).Result()
if err != nil || err == redis.Nil {
errOrNil++
}
j := &SingleData{}
json.Unmarshal([]byte(b), j)
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("JsonMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("JsonMarshal to Redis -> CPU time:", elapsedTime)
fmt.Println("error OR nill -> ", errOrNil)
}

func BredisProtobufUnMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
errOrNil := 0
for i := 0; i < b.N; i++ {
b, err := client.Get(ctx, fmt.Sprintf("proto_%d", i)).Result()
if err != nil || err == redis.Nil {
errOrNil++
}
p := &demo_v1.SingleData{}
proto.Unmarshal([]byte(b), p)
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("ProtoMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("ProtoMarshal to Redis -> CPU time:", elapsedTime)
fmt.Println("error OR nill -> ", errOrNil)
}

Sure, I have prepared a test for that. We will serialize 10,000 data structures and store them in Redis, and then deserialize them to retrieve. This will give us a practical example of using serialization and deserialization with Redis, considering that the functions supporting structure insertion in Redis internally perform serialization. This will be an important point to consider.

In Summary

JsonMarshal to Redis:

  • Memory usage: 3768944, 5740856
  • CPU time: 4.3898424s, 4.2871468s

ProtoMarshal to Redis:

  • Memory usage: 3846216, 3208424
  • CPU time: 4.3408826s, 4.1870866s

When comparing the results, we can see that using ProtoMarshal generally results in lower memory usage compared to using JsonMarshal. Additionally, the CPU time is slightly shorter. This demonstrates the advantages of Protocol Buffers, as ProtoMarshal efficiently serializes the data and helps manage memory more effectively.

How can we compare commonly used struct arrays in general?

func MBredisJsonMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
for i := 0; i < b.N; i++ {
mul := make([]*SingleData, 10)
for i2 := 0; i2 < 10; i2++ {
in := &SingleData{}
in.X = fmt.Sprintf("x_%d_%d", i, i2)
in.Y = fmt.Sprintf("y_%d_%d", i, i2)
in.Z = int32(i)
in.End = true
mul[i2] = in
}
arrays := &MultipleData{}
arrays.Data = mul
arrays.Status = true
jb, _ := json.Marshal(arrays)
client.Set(ctx, fmt.Sprintf("json_%d", i), jb, time.Duration(time.Minute*1))
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("JsonMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("JsonMarshal to Redis -> CPU time:", elapsedTime)
}

func MBredisProtobufMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
for i := 0; i < b.N; i++ {
mul := make([]*demo_v1.SingleData, 10)
for i2 := 0; i2 < 10; i2++ {
in := &demo_v1.SingleData{}
in.X = fmt.Sprintf("x_%d_%d", i, i2)
in.Y = fmt.Sprintf("y_%d_%d", i, i2)
in.Z = int32(i)
in.End = true
mul[i2] = in
}
arrays := &demo_v1.MultipleData{}
arrays.Data = mul
arrays.Status = true
pb, _ := proto.Marshal(arrays)
client.Set(ctx, fmt.Sprintf("proto_%d", i), pb, time.Duration(time.Minute*1))
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("ProtoMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("ProtoMarshal to Redis -> CPU time:", elapsedTime)
}

func MBredisJsonUnMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
errOrNil := 0
for i := 0; i < b.N; i++ {
b, err := client.Get(ctx, fmt.Sprintf("json_%d", i)).Result()
if err != nil || err == redis.Nil {
errOrNil++
}
j := &MultipleData{}
json.Unmarshal([]byte(b), j)
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("JsonMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("JsonMarshal to Redis -> CPU time:", elapsedTime)
fmt.Println("error OR nill -> ", errOrNil)
}

func MBredisProtobufUnMarshal(b *testing.B) {
client := redis.NewClient(&redis.Options{
Addr: "127.0.0.1:6379",
Password: "",
DB: 0,
PoolSize: 10,
})
ctx := context.Background()
startCPU := new(runtime.MemStats)
runtime.ReadMemStats(startCPU)
startTime := time.Now()
b.N = 10000
errOrNil := 0
for i := 0; i < b.N; i++ {
b, err := client.Get(ctx, fmt.Sprintf("proto_%d", i)).Result()
if err != nil || err == redis.Nil {
errOrNil++
}
p := &demo_v1.MultipleData{}
proto.Unmarshal([]byte(b), p)
}
elapsedTime := time.Since(startTime)
elapsedCPU := new(runtime.MemStats)
runtime.ReadMemStats(elapsedCPU)

fmt.Println("ProtoMarshal to Redis -> Memory usage:", elapsedCPU.TotalAlloc-startCPU.TotalAlloc)
fmt.Println("ProtoMarshal to Redis -> CPU time:", elapsedTime)
fmt.Println("error OR nill -> ", errOrNil)
}

The code has simply been modified from a single struct to an array of structs. Let’s see the results.

In Summary

JsonMarshal to Redis:

  • Memory usage: 17359512, 27367512
  • CPU time: 4.3941977s, 4.3876318s

ProtoMarshal to Redis:

  • Memory usage: 18109736, 20352296
  • CPU time: 4.3003062s, 4.2445293s

First, let’s compare the memory usage. In both the JsonMarshal to Redis and ProtoMarshal to Redis scenarios, the second test results show higher memory usage compared to the first test results. Therefore, both methods have increased memory usage. However, there is still a significant difference in relative memory usage between the two methods, with ProtoMarshal to Redis utilizing less memory.

Next, let’s compare the CPU time. In both the JsonMarshal to Redis and ProtoMarshal to Redis scenarios, the second test results show slightly shorter CPU time compared to the first test results. Both methods perform similarly in terms of CPU time efficiency.

Firstly, I would like to thank everyone who commented. I apologize for my lack of experience and for carelessly using the importance of writing. Without excuse, my original post title was misleading and different from my intention. The title, questioning whether protobuf is really faster than JSON, and its interpretation, led to a lot of misunderstandings.

I apologize once again.

After revising my post, the purpose of this writing is to address a scenario where developers, too fond of protobuf, opt for it over JSON in simple web server environments, even without using gRPC. I’ve heard that in the early stages of an MVP web server, the structures used are typically simple and not complex (although there are certainly exceptions). I believe that working on and managing protobuf in these early stages deviates from the goal of an MVP project, and hence I analyzed simple structures.

I regret that my post seemed to generalize and analyze protobuf and JSON as a whole. I hope there are no misunderstandings, and for the record, I too am a developer who prefers protobuf over JSON.

--

--