1293.85 Tick 실행 흐름의 성능 최적화

1. Tick 성능 최적화의 필요성

행동 트리의 Tick은 매 주기마다 루트에서 시작하여 트리 전체를 순회하며 노드를 평가하는 과정을 반복한다. 트리의 규모가 커지고 노드의 수가 증가할수록 단일 Tick의 실행 시간이 증가하며, 이는 실시간 제약이 엄격한 로봇 시스템에서 치명적인 문제를 야기할 수 있다. Tick 실행 시간이 설정된 주기를 초과하면 제어 응답성이 저하되고, 센서 데이터의 처리가 지연되며, 궁극적으로 로봇의 안전한 동작이 보장되지 않는다(Colledanchise & Ogren, 2018).

Tick 성능 최적화란, 트리의 논리적 동작을 변경하지 않으면서 Tick 실행 시간을 단축하고 시스템 자원의 사용 효율을 높이는 일련의 기법을 의미한다. 이는 알고리즘 수준의 최적화, 메모리 접근 패턴의 개선, 불필요한 연산의 제거, 그리고 시스템 수준의 설정 조정을 포괄한다.

2. 성능 최적화의 계층 구조

Tick 성능 최적화는 여러 계층에서 이루어지며, 각 계층은 서로 다른 수준의 성능 향상을 제공한다.

2.1 알고리즘 수준 최적화

트리 순회 알고리즘 자체를 개선하여 불필요한 노드 방문을 줄이는 접근이다. 이미 IDLE 상태이거나 이전 Tick에서 SUCCESS 또는 FAILURE를 반환한 노드에 대한 재방문을 제어 노드의 정책에 따라 생략함으로써 Tick당 방문 노드 수를 최소화한다.

최적화 전 Tick 비용: O(N)  — N은 전체 노드 수
최적화 후 Tick 비용: O(A)  — A는 활성 노드 수 (A ≤ N)

2.2 노드 수준 최적화

개별 노드의 tick() 함수 실행 비용을 줄이는 접근이다. 조건 노드의 불필요한 재평가 방지, 비용이 큰 연산의 캐싱, 입출력 포트 접근의 최소화 등이 포함된다.

// 최적화 전: 매 Tick마다 계산
BT::NodeStatus tick() override {
    auto pose = getInput<geometry_msgs::msg::PoseStamped>("robot_pose");
    auto goal = getInput<geometry_msgs::msg::PoseStamped>("goal_pose");
    double distance = computeDistance(pose.value(), goal.value());
    return (distance < threshold_) ? NodeStatus::SUCCESS : NodeStatus::FAILURE;
}

// 최적화 후: 입력이 변경된 경우에만 재계산
BT::NodeStatus tick() override {
    auto pose = getInput<geometry_msgs::msg::PoseStamped>("robot_pose");
    auto goal = getInput<geometry_msgs::msg::PoseStamped>("goal_pose");
    
    if (pose.value() != last_pose_ || goal.value() != last_goal_) {
        last_pose_ = pose.value();
        last_goal_ = goal.value();
        cached_distance_ = computeDistance(last_pose_, last_goal_);
    }
    
    return (cached_distance_ < threshold_) ? NodeStatus::SUCCESS : NodeStatus::FAILURE;
}

2.3 메모리 수준 최적화

Tick 실행 중 동적 메모리 할당을 최소화하고, 캐시 친화적인 데이터 접근 패턴을 적용하는 접근이다. std::string의 반복적 생성·소멸, std::vector의 빈번한 재할당, 블랙보드 접근 시의 타입 변환 비용 등을 절감한다.

2.4 시스템 수준 최적화

운영 체제의 스케줄링 정책, 스레드 우선순위, CPU 코어 할당(affinity) 등 시스템 자원 관리를 통해 Tick 실행의 일관성과 지연을 개선하는 접근이다.

3. 프로파일링 기반 최적화 전략

성능 최적화의 첫 단계는 프로파일링을 통해 병목 지점을 식별하는 것이다. 추측에 기반한 최적화는 효과가 미미한 부분에 노력을 낭비할 위험이 있다.

3.1 Tick 수준 프로파일링

class TickProfiler {
public:
    void beginTick() {
        tick_start_ = std::chrono::high_resolution_clock::now();
    }

    void endTick() {
        auto elapsed = std::chrono::high_resolution_clock::now() - tick_start_;
        auto us = std::chrono::duration_cast<std::chrono::microseconds>(elapsed).count();
        
        tick_times_.push_back(us);
        total_us_ += us;
        max_us_ = std::max(max_us_, us);
        tick_count_++;
    }

    void report() const {
        double avg = static_cast<double>(total_us_) / tick_count_;
        printf("Tick Profile: count=%d, avg=%.1fus, max=%ldus\n",
               tick_count_, avg, max_us_);
    }

private:
    std::chrono::time_point<std::chrono::high_resolution_clock> tick_start_;
    std::vector<long> tick_times_;
    long total_us_{0};
    long max_us_{0};
    int tick_count_{0};
};

3.2 노드 수준 프로파일링

BehaviorTree.CPP v4의 TreeObserver를 활용하여 각 노드의 실행 시간을 개별적으로 측정한다.

class NodeProfiler : public BT::StatusChangeLogger {
public:
    void callback(BT::Duration timestamp,
                  const BT::TreeNode& node,
                  BT::NodeStatus prev_status,
                  BT::NodeStatus status) override {
        auto& stats = node_stats_[node.name()];
        stats.call_count++;
    }

    void flush() override {}

    void report() const {
        printf("%-30s  %8s\n", "Node", "Calls");
        for (const auto& [name, stats] : node_stats_) {
            printf("%-30s  %8d\n", name.c_str(), stats.call_count);
        }
    }

private:
    struct NodeStats {
        int call_count{0};
    };
    std::map<std::string, NodeStats> node_stats_;
};

4. Tick 전파 경로의 최적화

4.1 활성 경로 추적

매 Tick에서 트리 전체를 순회하는 대신, 현재 RUNNING 상태인 노드로의 경로만을 추적하여 순회 범위를 축소한다. WithMemory 모드의 Sequence 및 Fallback 노드는 이미 이 원리를 적용하여, 이전 Tick에서 SUCCESS를 반환한 자식을 건너뛰고 RUNNING 상태의 자식부터 재개한다.

전체 트리 (노드 50개):
                    Root
                   /    \
              Seq_A      Seq_B
             / | \      / | \
           C1 C2 A1   C3 C4 A2
           ...         ...

A1이 RUNNING이면:
  활성 경로: Root → Seq_A → A1
  방문 노드: 3개 (+ WithMemory에 따라 C1, C2 생략 가능)

4.2 Reactive 노드의 선택적 재평가

ReactiveSequence에서 조건 노드의 재평가는 안전성을 위해 필수적이지만, 모든 조건을 매 Tick마다 평가할 필요는 없는 경우가 있다. 변화 빈도가 낮은 조건에 대해서는 N Tick마다 한 번씩 재평가하는 간격 기반 전략을 적용할 수 있다.

BT::NodeStatus tick() override {
    tick_count_++;
    
    // 10 Tick마다 재평가, 그 사이에는 캐싱된 결과 반환
    if (tick_count_ % reevaluation_interval_ == 0 || 
        cached_result_ == BT::NodeStatus::IDLE) {
        cached_result_ = evaluateCondition();
    }
    
    return cached_result_;
}

단, 이 기법은 조건 변화의 감지 지연을 초래하므로, 안전 관련 조건에는 적용해서는 안 된다.

5. 블랙보드 접근 최적화

블랙보드는 노드 간 데이터 공유의 중심 메커니즘이며, Tick 실행 시간의 상당 부분이 블랙보드 접근에 소비될 수 있다.

5.1 로컬 캐싱

빈번하게 읽히는 블랙보드 값을 노드의 멤버 변수에 캐싱하여 반복적인 블랙보드 조회 비용을 절감한다.

class OptimizedCondition : public BT::ConditionNode {
public:
    BT::NodeStatus tick() override {
        // Tick 시작 시 한 번만 블랙보드에서 읽기
        if (!cached_this_tick_) {
            getInput("battery_level", cached_battery_);
            cached_this_tick_ = true;
        }
        return (cached_battery_ > threshold_) 
            ? NodeStatus::SUCCESS : NodeStatus::FAILURE;
    }

    void resetCache() { cached_this_tick_ = false; }

private:
    double cached_battery_{0.0};
    double threshold_{0.2};
    bool cached_this_tick_{false};
};

5.2 타입 변환 비용 절감

블랙보드의 BT::Any 타입에서 실제 타입으로의 변환(cast)은 런타임 타입 검사를 수반한다. 동일한 키에 대한 반복적인 타입 변환을 피하기 위해, 변환된 값을 재사용하거나 포트 바인딩을 통해 직접 접근한다.

6. ROS2 통신 최적화

6.1 콜백 처리 분리

spin_some()의 실행 시간이 Tick 시간에 포함되므로, 콜백 처리량이 많은 경우 Tick 실행 시간이 증가한다. 콜백 전용 스레드를 분리하여 Tick 실행과 독립적으로 콜백을 처리하면 Tick 시간의 변동성을 줄일 수 있다.

// 콜백 전용 스레드 분리
auto callback_executor = std::make_shared<
    rclcpp::executors::SingleThreadedExecutor>();
callback_executor->add_node(node);

std::thread callback_thread([&callback_executor]() {
    callback_executor->spin();
});

// Tick 루프는 콜백 처리와 독립적으로 실행
rclcpp::Rate rate(100);  // 100 Hz
while (rclcpp::ok()) {
    tree.tickOnce();
    rate.sleep();
}

6.2 구독 QoS 최적화

불필요하게 큰 큐 크기나 부적절한 QoS 설정은 메모리 사용량 증가와 콜백 지연을 유발한다. 행동 트리 노드에서 사용하는 토픽 구독은 센서 데이터의 특성에 맞는 최소한의 큐 크기와 적절한 신뢰성 정책을 설정해야 한다.

rclcpp::QoS sensor_qos(1);  // 큐 크기 1: 최신 값만 유지
sensor_qos.best_effort();    // 센서 데이터는 best_effort
sensor_qos.durability_volatile();

7. 성능 최적화 기법의 비교

최적화 기법적용 대상성능 향상 정도구현 복잡도위험도
활성 경로 추적트리 순회높음중간낮음
조건 캐싱조건 노드중간낮음중간
블랙보드 로컬 캐싱데이터 접근중간낮음낮음
메모리 사전 할당메모리 관리중간낮음낮음
콜백 스레드 분리ROS2 통합높음중간중간
선택적 재평가Reactive 노드중간낮음높음
CPU 코어 할당시스템 수준낮음~중간낮음낮음

8. 최적화 시의 주의 사항

성능 최적화는 항상 정확성을 전제로 해야 한다. 캐싱이나 재평가 간격 조정은 트리의 의미론적 동작을 변경할 수 있으므로, 최적화 전후의 동작 동등성을 반드시 검증해야 한다. 특히, 안전 관련 조건 노드의 평가 빈도를 줄이는 최적화는 로봇의 안전에 직접적인 위험을 초래할 수 있으므로 극도로 신중하게 적용해야 한다.

또한, 성능 최적화는 프로파일링 결과에 기반하여 병목 지점에 집중해야 하며, 추측에 의한 조기 최적화(premature optimization)는 코드의 복잡성만 증가시키고 실질적인 성능 향상은 미미할 수 있다.


참고 문헌

  • Colledanchise, M., & Ogren, P. (2018). Behavior Trees in Robotics and AI: An Introduction. CRC Press.
  • Faconti, D. (2022). BehaviorTree.CPP documentation and API reference. https://www.behaviortree.dev/
  • Williams, A. (2019). C++ Concurrency in Action (2nd ed.). Manning Publications.