[2026] C++ Performance Optimization Case Study | 200ms API Latency Cut to 20ms

[2026] C++ Performance Optimization Case Study | 200ms API Latency Cut to 20ms

이 글의 핵심

How we improved a C++ REST API latency by 10×: profiling with perf, algorithm fixes, memory optimizations, and parallel JSON serialization—end to end.

Introduction

We started from “the API feels slow.” This post shares how we followed measure → analyze → optimize → verify and cut latency from 200ms to about 20ms—roughly 10×.

What you will learn

  • A structured way to find bottlenecks
  • Practical use of perf, gprof, and Valgrind
  • Algorithm, memory, and threading techniques
  • How to quantify improvements

Table of contents

  1. Problem: API too slow
  2. Measurement: benchmark baseline
  3. Profiling: hotspots with perf
  4. Bottleneck 1: O(n²) algorithm
  5. Optimization 1: hash map → O(n) lookups
  6. Bottleneck 2: string copies
  7. Optimization 2: string_view and moves
  8. Bottleneck 3: JSON serialization
  9. Optimization 3: threading and pooling
  10. Final results: 10× faster
  11. Closing thoughts

1. Problem: API too slow

Situation

A REST endpoint that returns a user list was reported as slow:

# 100 users
$ curl -w "@curl-format.txt" http://api.example.com/users
time_total: 0.203s  # 200ms

Requirements

  • Goal: p50 latency under 50ms
  • Constraint: keep the existing API contract
  • Environment: Ubuntu 22.04, g++ 11, 4 CPU cores

2. Measurement: benchmark baseline

Simple benchmark harness

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// benchmark.cpp
#include <chrono>
#include <iostream>
#include <vector>
using namespace std::chrono;
class Benchmark {
    std::vector<double> samples_;
    
public:
    template<typename Func>
    void run(const std::string& name, Func&& func, int iterations = 100) {
        samples_.clear();
        
        for (int i = 0; i < 10; ++i) {
            func();
        }
        
        for (int i = 0; i < iterations; ++i) {
            auto start = steady_clock::now();
            func();
            auto end = steady_clock::now();
            
            auto duration = duration_cast<microseconds>(end - start).count();
            samples_.push_back(duration / 1000.0); // ms
        }
        
        std::sort(samples_.begin(), samples_.end());
        double p50 = samples_[samples_.size() / 2];
        double p95 = samples_[samples_.size() * 95 / 100];
        double p99 = samples_[samples_.size() * 99 / 100];
        
        std::cout << name << ":\n"
                  << "  p50: " << p50 << "ms\n"
                  << "  p95: " << p95 << "ms\n"
                  << "  p99: " << p99 << "ms\n";
    }
};

Baseline

아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

$ ./benchmark
getUserList (100 users):
  p50: 203.5ms
  p95: 215.2ms
  p99: 223.1ms

3. Profiling: hotspots with perf

perf

아래 코드는 bash를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

$ g++ -O2 -g -std=c++17 *.cpp -o server
$ perf record -g ./server
$ perf report
Samples: 10K of event 'cycles'
  65.23%  server  [.] UserManager::findUsersByRole
  18.45%  server  [.] std::string::string(std::string const&)
  12.34%  server  [.] json::serialize
   2.98%  server  [.] other

Findings

  1. findUsersByRole ~65% of time
  2. String copies ~18%
  3. JSON serialization ~12%

4. Bottleneck 1: O(n²) work

Original code

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

class UserManager {
    std::vector<User> users_; // 10,000 users
public:
    std::vector<User> findUsersByRole(const std::string& role) {
        std::vector<User> result;
        
        // Costly nested work per user
        for (const auto& user : users_) {
            for (const auto& r : user.roles) {
                if (r == role) {
                    result.push_back(user);
                    break;
                }
            }
        }
        
        return result;
    }
};

Complexity

  • n users, ~m roles per user on average
  • String comparisons dominate

5. Optimization 1: hash map index

Improved code

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

class UserManager {
    std::vector<User> users_;
    std::unordered_map<std::string, std::vector<size_t>> roleIndex_;
public:
    void buildIndex() {
        roleIndex_.clear();
        for (size_t i = 0; i < users_.size(); ++i) {
            for (const auto& role : users_[i].roles) {
                roleIndex_[role].push_back(i);
            }
        }
    }
    
    std::vector<User> findUsersByRole(const std::string& role) {
        std::vector<User> result;
        
        if (auto it = roleIndex_.find(role); it != roleIndex_.end()) {
            result.reserve(it->second.size());
            for (size_t idx : it->second) {
                result.push_back(users_[idx]);
            }
        }
        
        return result;
    }
};

Result

$ ./benchmark
getUserList (100 users):
  p50: 85.3ms  # 203.5ms → 85.3ms (~2.4×)

Effect: CPU share of the hot path dropped (65% → ~28% in later profiles).

6. Bottleneck 2: string copies

Issue

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 반복문으로 데이터를 처리합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

for (size_t idx : it->second) {
    result.push_back(users_[idx]); // full User copy
}
struct User {
    std::string id;
    std::string name;
    std::string email;
    std::vector<std::string> roles;
};

7. Optimization 2: string_view and moves

Return references

아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

std::vector<const User*> findUsersByRole(const std::string& role) const {
    std::vector<const User*> result;
    
    if (auto it = roleIndex_.find(role); it != roleIndex_.end()) {
        result.reserve(it->second.size());
        for (size_t idx : it->second) {
            result.push_back(&users_[idx]);
        }
    }
    
    return result;
}

string_view in serialization

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

std::string serializeUserView(std::string_view id, std::string_view name) {
    std::ostringstream oss;
    oss << "{\"id\":\"" << id << "\","
        << "\"name\":\"" << name << "\"}";
    return oss.str();
}

Result

$ ./benchmark
getUserList (100 users):
  p50: 42.1ms  # 85.3ms → 42.1ms (~2×)

8. Bottleneck 3: JSON serialization

Issue

아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

std::string toJson(const std::vector<const User*>& users) {
    std::string json = "[";
    for (size_t i = 0; i < users.size(); ++i) {
        json += serializeUser(*users[i]); // repeated +=
        if (i < users.size() - 1) {
            json += ",";
        }
    }
    json += "]";
    return json;
}

Repeated string += can reallocate often → worst-case O(n²) in string length.

9. Optimization 3: threading and reservation

Reserve capacity

아래 코드는 cpp를 사용한 구현 예제입니다. 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

std::string toJson(const std::vector<const User*>& users) {
    std::string json;
    json.reserve(users.size() * 100);
    
    json = "[";
    for (size_t i = 0; i < users.size(); ++i) {
        json += serializeUser(*users[i]);
        if (i < users.size() - 1) {
            json += ",";
        }
    }
    json += "]";
    return json;
}

Parallel serialization

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 비동기 처리를 통해 효율적으로 작업을 수행합니다, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

#include <thread>
#include <future>
std::string toJsonParallel(const std::vector<const User*>& users) {
    if (users.size() < 100) {
        return toJson(users);
    }
    
    size_t numThreads = std::thread::hardware_concurrency();
    size_t chunkSize = (users.size() + numThreads - 1) / numThreads;
    
    std::vector<std::future<std::string>> futures;
    
    for (size_t i = 0; i < numThreads; ++i) {
        size_t start = i * chunkSize;
        size_t end = std::min(start + chunkSize, users.size());
        
        if (start >= users.size()) break;
        
        futures.push_back(std::async(std::launch::async, [&, start, end]() {
            std::string chunk;
            chunk.reserve((end - start) * 100);
            
            for (size_t j = start; j < end; ++j) {
                chunk += serializeUser(*users[j]);
                if (j < end - 1) chunk += ",";
            }
            return chunk;
        }));
    }
    
    std::string result = "[";
    for (size_t i = 0; i < futures.size(); ++i) {
        result += futures[i].get();
        if (i < futures.size() - 1) result += ",";
    }
    result += "]";
    
    return result;
}

Result

아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

$ ./benchmark
getUserList (100 users):
  p50: 20.3ms  # 42.1ms → 20.3ms (~2×)
getUserList (1000 users):
  p50: 45.2ms

10. Final results: ~10× improvement

Stage-by-stage

StageChangep50Factor
0Baseline203.5ms
1Role index85.3ms~2.4×
2Pointer views, less copying42.1ms~2×
3reserve + parallel JSON20.3ms~2×
TotalCombined20.3ms~10×

CPU profile shift

Before: findUsersByRole dominated; string copy + JSON next.
After: JSON + I/O + hash lookup more balanced.

11. Lessons and more ideas

Takeaways

  1. Don’t optimize without measurement
  2. Fix algorithmic cost first
  3. Remove needless copies (references, moves, string_view)
  4. Parallelize last, after serial optimizations

Optimization flow

아래 코드는 mermaid를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

graph TD
    A[Performance issue] --> B[Measure and profile]
    B --> C{Find bottleneck}
    C --> D[Improve algorithmic complexity]
    D --> E[Memory / copies]
    E --> F[Compiler options]
    F --> G[Multithreading]
    G --> H[Verify and ship]

Tooling

ToolUseExample
perfCPU hotspotsperf record -g ./app && perf report
gprofPer-function timeg++ -pg ....&& ./app && gprof app
Valgrind CallgrindCall graphsvalgrind --tool=callgrind ./app
HeaptrackAllocationsheaptrack ./app

12. More ideas

Short TTL cache

다음은 cpp를 활용한 상세한 구현 코드입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

class UserManager {
    std::unordered_map<std::string, std::vector<const User*>> cache_;
    std::chrono::steady_clock::time_point cacheTime_;
    
public:
    std::vector<const User*> findUsersByRole(const std::string& role) {
        auto now = std::chrono::steady_clock::now();
        
        if (now - cacheTime_ < std::chrono::seconds(5)) {
            if (auto it = cache_.find(role); it != cache_.end()) {
                return it->second;
            }
        }
        
        auto result = findUsersByRoleImpl(role);
        cache_[role] = result;
        cacheTime_ = now;
        
        return result;
    }
};

DB index

CREATE INDEX idx_user_roles ON users USING GIN(roles);

Compress responses

// gzip JSON to reduce transfer time
#include <zlib.h>

Closing thoughts

Performance work is measure → analyze → optimize → verify, repeated.

  1. perf located the real hotspots
  2. Indexing gave the largest win
  3. Fewer copies helped again
  4. Parallel JSON finished the job
    ~10× faster responses materially improved UX.

FAQ

Q1. When should we optimize? Only when you have measurable pain. “It feels slow” without numbers usually means more complex code for little gain. Q2. Should we use -O3? Often -O2 is enough; -O3 can grow code and hurt caches. Measure. Q3. Thread first? Optimize the serial path first. Parallel slow code is just “fast slow code.”


Checklists

Performance optimization

  • Clear numeric goals
  • Baseline benchmarks
  • Profile (perf, gprof, Valgrind)
  • Identify top 3 hotspots
  • Analyze complexity
  • Apply changes
  • Re-benchmark
  • Regression tests
  • Ship and monitor

Code review

  • Hidden O(n²)+ loops?
  • Unnecessary copies?
  • Repeated string +=?
  • reserve() where needed?
  • Parallel-safe where parallelized?
  • Caching considered?

Keywords

C++, performance optimization, profiling, perf, gprof, bottleneck, algorithm, complexity, hash map, string_view, move semantics, multithreading, parallelism, benchmark, case study

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3