icon안동민 개발노트

프로그램 최적화 및 디버깅


실습 프로그램 : 고급 텍스트 분석기

 이 절에서는 실제 프로그램을 대상으로 성능 최적화와 디버깅 기법을 적용해보는 심도 있는 실습을 진행합니다. 이를 통해 이론적 지식을 실제 상황에 적용하는 능력을 기르고, 최적화와 디버깅의 전체 과정을 경험할 수 있습니다.

 다음은 대용량 텍스트 파일을 읽어 다양한 통계를 계산하는 프로그램입니다. 이 프로그램은 의도적으로 비효율적으로 작성되었으며, 몇 가지 버그를 포함하고 있습니다.

#include <iostream>
#include <fstream>
#include <string>
#include <vector>
#include <map>
#include <algorithm>
#include <cctype>
#include <chrono>
 
struct WordStats {
    int frequency = 0;
    int total_length = 0;
    std::vector<int> sentence_positions;
};
 
class TextAnalyzer {
private:
    std::string filename;
    std::vector<std::string> words;
    std::map<std::string, WordStats> word_stats;
    int total_sentences = 0;
 
public:
    TextAnalyzer(const std::string& fname) : filename(fname) {}
 
    void read_file() {
        std::ifstream file(filename);
        std::string word;
        while (file >> word) {
            std::transform(word.begin(), word.end(), word.begin(), ::tolower);
            word.erase(std::remove_if(word.begin(), word.end(), ::ispunct), word.end());
            if (!word.empty()) {
                words.push_back(word);
            }
        }
    }
 
    void analyze() {
        int word_position = 0;
        bool in_sentence = false;
        for (const auto& word : words) {
            auto& stats = word_stats[word];
            stats.frequency++;
            stats.total_length += word.length();
            if (!in_sentence) {
                total_sentences++;
                in_sentence = true;
            }
            stats.sentence_positions.push_back(word_position);
            if (word.back() == '.' || word.back() == '!' || word.back() == '?') {
                in_sentence = false;
            }
            word_position++;
        }
    }
 
    void print_stats() {
        std::cout << "Total words: " << words.size() << std::endl;
        std::cout << "Total sentences: " << total_sentences << std::endl;
        std::cout << "Unique words: " << word_stats.size() << std::endl;
 
        std::vector<std::pair<std::string, WordStats>> sorted_stats(word_stats.begin(), word_stats.end());
        std::sort(sorted_stats.begin(), sorted_stats.end(),
                  [](const auto& a, const auto& b) { return a.second.frequency > b.second.frequency; });
 
        std::cout << "\nTop 10 most frequent words:" << std::endl;
        for (int i = 0; i < 10 && i < sorted_stats.size(); ++i) {
            const auto& [word, stats] = sorted_stats[i];
            double avg_length = static_cast<double>(stats.total_length) / stats.frequency;
            std::cout << word << ": " << stats.frequency << " times, avg length: " << avg_length << std::endl;
        }
    }
};
 
int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " <filename>" << std::endl;
        return 1;
    }
 
    TextAnalyzer analyzer(argv[1]);
 
    auto start = std::chrono::high_resolution_clock::now();
    analyzer.read_file();
    analyzer.analyze();
    auto end = std::chrono::high_resolution_clock::now();
 
    analyzer.print_stats();
 
    std::chrono::duration<double> diff = end - start;
    std::cout << "\nTime taken: " << diff.count() << " seconds" << std::endl;
 
    return 0;
}

성능 프로파일링

 이 프로그램의 성능을 프로파일링하고 분석해봅시다.

  1. 컴파일 및 실행
g++ -O2 -g text_analyzer.cpp -o text_analyzer
./text_analyzer large_text_file.txt
  1. gprof를 사용한 프로파일링
g++ -O2 -pg text_analyzer.cpp -o text_analyzer
./text_analyzer large_text_file.txt
gprof text_analyzer gmon.out > analysis.txt
  1. Valgrind의 Callgrind 도구 사용
valgrind --tool=callgrind ./text_analyzer large_text_file.txt
callgrind_annotate callgrind.out.<pid>
  1. perf 사용 (Linux)
perf record ./text_analyzer large_text_file.txt
perf report

 각 도구의 출력을 분석하여 가장 시간이 많이 소요되는 함수와 라인을 식별합니다. 일반적으로 다음과 같은 부분이 병목점으로 나타날 수 있습니다.

  • 파일 읽기 (read_file 함수)
  • 단어 처리 (소문자 변환, 구두점 제거)
  • 단어 통계 계산 (analyze 함수)
  • 결과 정렬 (print_stats 함수의 정렬 부분)

최적화

 프로파일링 결과를 바탕으로 다음과 같은 최적화를 적용해볼 수 있습니다.

  1. 파일 읽기 최적화
void read_file() {
    std::ifstream file(filename, std::ios::ate);
    if (!file) {
        throw std::runtime_error("Unable to open file: " + filename);
    }
    std::streamsize size = file.tellg();
    file.seekg(0, std::ios::beg);
 
    std::vector<char> buffer(size);
    if (!file.read(buffer.data(), size)) {
        throw std::runtime_error("Failed to read file: " + filename);
    }
 
    std::string word;
    for (char c : buffer) {
        if (std::isspace(c) || std::ispunct(c)) {
            if (!word.empty()) {
                std::transform(word.begin(), word.end(), word.begin(), ::tolower);
                words.push_back(word);
                word.clear();
            }
        } else {
            word += std::tolower(c);
        }
    }
    if (!word.empty()) {
        words.push_back(word);
    }
}
  1. std::unordered_map을 사용하여 단어 통계 계산 최적화
#include <unordered_map>
 
class TextAnalyzer {
private:
    std::unordered_map<std::string, WordStats> word_stats;
    // ...
};
  1. 병렬 처리 적용
#include <execution>
#include <algorithm>
 
void analyze() {
    std::atomic<int> word_position{0};
    std::atomic<int> sentence_count{0};
 
    std::for_each(std::execution::par, words.begin(), words.end(),
        [this, &word_position, &sentence_count](const std::string& word) {
            auto& stats = word_stats[word];
            stats.frequency++;
            stats.total_length += word.length();
            int pos = word_position.fetch_add(1);
            stats.sentence_positions.push_back(pos);
 
            if (word.back() == '.' || word.back() == '!' || word.back() == '?') {
                sentence_count++;
            }
        });
 
    total_sentences = sentence_count;
}
  1. 메모리 사용 최적화
struct WordStats {
    int frequency = 0;
    int total_length = 0;
    std::vector<int> sentence_positions;
};
 
// WordStats를 다음과 같이 변경
struct WordStats {
    int frequency = 0;
    int total_length = 0;
    int first_position = -1;
    int last_position = -1;
};

메모리 누수 탐지

 Valgrind를 사용하여 메모리 누수를 탐지해봅시다.

valgrind --leak-check=full ./text_analyzer large_text_file.txt

 현재 프로그램에는 명시적인 메모리 누수가 없지만, 다음과 같은 시나리오를 추가하여 메모리 누수를 만들어볼 수 있습니다.

class LeakyResource {
public:
    LeakyResource() { data = new int[1000]; }
    ~LeakyResource() { /* 실수로 delete[] data;를 호출하지 않음 */ }
private:
    int* data;
};
 
void create_leak() {
    LeakyResource* resource = new LeakyResource();
    // resource를 delete하지 않고 함수 종료
}
 
// main 함수에서 호출
create_leak();

버그 수정

 이 프로그램에는 몇 가지 잠재적인 버그가 있습니다.

  1. 문장 경계 감지 오류 : 현재 구현은 !, ?, . 다음에 오는 공백을 고려하지 않습니다.
  2. 대용량 파일 처리 시 메모리 부족: 전체 파일을 메모리에 로드하므로 매우 큰 파일을 처리할 수 없습니다.
  3. 정수 오버플로우 : 매우 큰 파일의 경우 word_position이 오버플로우될 수 있습니다.

 이러한 버그들을 수정해봅시다.

void read_file() {
    std::ifstream file(filename);
    if (!file) {
        throw std::runtime_error("Unable to open file: " + filename);
    }
 
    std::string line;
    std::string word;
    while (std::getline(file, line)) {
        std::istringstream iss(line);
        while (iss >> word) {
            std::transform(word.begin(), word.end(), word.begin(), ::tolower);
            word.erase(std::remove_if(word.begin(), word.end(), ::ispunct), word.end());
            if (!word.empty()) {
                words.push_back(word);
            }
        }
    }
}
 
void analyze() {
    long long word_position = 0;  // long long 사용
    bool in_sentence = false;
    char last_char = ' ';
 
    for (const auto& word : words) {
        auto& stats = word_stats[word];
        stats.frequency++;
        stats.total_length += word.length();
 
        if (!in_sentence && std::isalnum(word[0])) {
            total_sentences++;
            in_sentence = true;
        }
 
        if (stats.first_position == -1) {
            stats.first_position = word_position;
        }
        stats.last_position = word_position;
 
        char current_last_char = word.back();
        if (current_last_char == '.' || current_last_char == '!' || current_last_char == '?') {
            if (last_char != '.' && last_char != '!' && last_char != '?') {
                in_sentence = false;
            }
        }
 
        last_char = current_last_char;
        word_position++;
    }
}

고급 최적화 기법

  1. 메모리 매핑 파일 (Memory-Mapped File) 사용 : 대용량 파일을 효율적으로 처리하기 위해 메모리 매핑을 사용할 수 있습니다.
#include <sys/mman.h>
#include <fcntl.h>
#include <unistd.h>
 
void read_file_mmap() {
    int fd = open(filename.c_str(), O_RDONLY);
    if (fd == -1) {
        throw std::runtime_error("Unable to open file: " + filename);
    }
 
    struct stat sb;
    if (fstat(fd, &sb) == -1) {
        close(fd);
        throw std::runtime_error("Unable to get file size: " + filename);
    }
 
    char* file_content = static_cast<char*>(mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0));
    if (file_content == MAP_FAILED) {
        close(fd);
        throw std::runtime_error("Unable to map file: " + filename);
    }
 
    std::string word;
    for (off_t i = 0; i < sb.st_size; ++i) {
        char c = file_content[i];
        if (std::isspace(c) || std::ispunct(c)) {
            if (!word.empty()) {
                std::transform(word.begin(), word.end(), word.begin(), ::tolower);
                words.push_back(word);
                word.clear();
            }
        } else {
            word += std::tolower(c);
        }
    }
    if (!word.empty()) {
        words.push_back(word);
    }
 
    munmap(file_content, fd, sb.st_size);
    close(fd);
}
  1. 문자열 인터닝 (String Interning) : 반복되는 문자열을 한 번만 저장하여 메모리 사용량을 줄이고 비교 연산을 최적화할 수 있습니다.
#include <unordered_set>
 
class StringInterner {
private:
    std::unordered_set<std::string> strings;
 
public:
    const std::string& intern(const std::string& s) {
        auto it = strings.find(s);
        if (it != strings.end()) {
            return *it;
        }
        auto result = strings.insert(s);
        return *result.first;
    }
};
 
// TextAnalyzer 클래스에 StringInterner 추가
class TextAnalyzer {
private:
    StringInterner interner;
    // ...
 
public:
    void analyze() {
        // ...
        for (const auto& word : words) {
            const std::string& interned_word = interner.intern(word);
            auto& stats = word_stats[interned_word];
            // ...
        }
        // ...
    }
};
  1. 비트 필드를 사용한 메모리 최적화 : 불리언 값들을 비트 필드로 저장하여 메모리 사용량을 줄일 수 있습니다.
struct WordFlags {
    unsigned int is_start_of_sentence : 1;
    unsigned int is_end_of_sentence : 1;
    unsigned int is_capitalized : 1;
};
 
struct WordStats {
    int frequency = 0;
    int total_length = 0;
    int first_position = -1;
    int last_position = -1;
    WordFlags flags;
};
  1. SIMD (Single Instruction, Multiple Data) 활용 : SIMD 명령어를 사용하여 병렬 처리를 최적화할 수 있습니다.
#include <immintrin.h>
 
void count_chars_simd(const char* text, size_t length, int* counts) {
    __m256i counts_vec = _mm256_setzero_si256();
 
    for (size_t i = 0; i < length; i += 32) {
        __m256i chars = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(text + i));
        counts_vec = _mm256_add_epi8(counts_vec, chars);
    }
 
    alignas(32) int temp[32];
    _mm256_store_si256(reinterpret_cast<__m256i*>(temp), counts_vec);
 
    for (int i = 0; i < 32; ++i) {
        counts[i] += temp[i];
    }
}

실습 : 대용량 로그 파일 분석기

 실제 상황에서 발생할 수 있는 대용량 로그 파일을 분석하는 프로그램을 개발해봅시다. 이 프로젝트를 통해 지금까지 학습한 최적화 기법과 디버깅 도구를 실제로 적용해볼 수 있습니다.

 프로젝트 요구사항

  1. 수 GB 크기의 로그 파일을 효율적으로 처리할 수 있어야 합니다.
  2. 다음과 같은 통계를 계산해야 합니다.
    • 시간대별 요청 수
    • 가장 많이 접근된 URL
    • HTTP 상태 코드별 요청 수
    • 사용자 에이전트별 요청 수
  3. 메모리 사용량을 최소화해야 합니다.
  4. 멀티스레딩을 활용하여 처리 속도를 최적화해야 합니다.
  5. 진행 상황을 실시간으로 표시해야 합니다.

 이 프로젝트를 구현하면서 다음과 같은 기술을 적용해볼 수 있습니다.

  • 메모리 매핑 파일을 사용한 대용량 파일 처리
  • 정규 표현식을 사용한 로그 파싱
  • 스레드 풀을 사용한 병렬 처리
  • 락프리 자료구조를 사용한 동시성 제어
  • 프로파일링 도구를 사용한 성능 분석 및 최적화

연습 문제

  1. 텍스트 분석기 프로그램에 단어 빈도수의 시각화 기능을 추가해보세요. (예 : ASCII 아트를 사용한 히스토그램)
  2. 멀티스레딩을 사용하여 파일 읽기와 분석을 동시에 수행하도록 프로그램을 수정해보세요. 생산자-소비자 패턴을 활용하여 구현해보세요.
  3. 텍스트 분석기에 메모리 풀(memory pool)을 구현하여 적용해보세요. 기존 구현과 성능을 비교해보세요.

결론

 우리는 실제 프로그램을 대상으로 성능 최적화와 디버깅 기법을 적용해보았습니다. 프로파일링을 통한 병목점 식별, 다양한 최적화 기법 적용, 그리고 디버깅 도구를 사용한 버그 수정 과정을 경험했습니다. 성능 최적화는 단순히 코드를 빠르게 만드는 것 이상의 의미를 갖습니다.

 최적화를 통해 프로그램의 구조를 개선하고, 리소스 사용을 효율화하며, 확장성을 높이는 과정입니다. 디버깅 역시 단순한 버그 수정을 넘어, 프로그램의 동작을 깊이 이해하고 더 나은 설계를 위한 통찰을 얻는 과정입니다. 최적화와 디버깅은 지속적인 학습과 경험이 필요한 분야입니다.

 16장에서 다룬 내용을 바탕으로, 다양한 프로젝트에 이러한 기법들을 적용해보고, 새로운 도구와 기술을 계속해서 탐구해 나가시기 바랍니다. 효율적이고 안정적인 소프트웨어를 개발하는 능력은 여러분의 경험과 함께 계속해서 성장할 것입니다.



참고 자료

  • "Optimizing C++" by Kurt Guntheroth
  • "C++ High Performance" by Viktor Sehr and Björn Andrist
  • "Intel Threading Building Blocks" 공식 문서
  • "Fundamentals of Deep Learning with SIMD" - Intel Developer Zone
  • "C++ Concurrency in Action" by Anthony Williams
  • "Advanced C++ Programming Cookbook" by Przemysław Całus
  • CppCon 발표 영상들 - 특히 성능 최적화와 관련된 세션들
  • "The Art of Computer Programming, Volume 1" by Donald Knuth - 특히 알고리즘 분석 부분