PyVista는 3D 데이터를 시각화하는 강력한 도구이지만, PyTorch와 같은 딥러닝 프레임워크와 함께 사용할 때 성능 문제나 예상치 못한 시각화 동작에 직면할 수 있습니다. 특히 물리 시뮬레이션처럼 동적인 데이터를 실시간으로 플로팅할 때는 이러한 문제가 더욱 두드러집니다. 이 글에서는 PyTorch 기반 분자 동역학(MD) 시뮬레이션을 PyVista로 시각화할 때 발생하는 두 가지 주요 문제인 극심한 성능 저하와 시뮬레이션 박스 크기 왜곡 현상의 원인을 분석하고, 이를 해결하는 최적화된 방법을 제시합니다.
1. 에러 발생 상황
사용자는 PyTorch를 이용한 MD 시뮬레이션 코드를 작성했습니다. 시뮬레이션 자체의 텐서 계산은 0.2초 이내로 빠르게 완료되지만, 이를 PyVista로 시각화하는 과정에서는 동일한 데이터 포인트에 대해 4분 이상이 소요되는 극심한 성능 저하를 겪고 있습니다. 이와 함께 CPU 사용률은 비정상적으로 높고, GPU 활용률은 10% 미만으로 매우 저조합니다.
더 큰 문제는 시뮬레이션 박스의 크기가 시각화 도중 알 수 없는 이유로 계속해서 줄어들어 입자들이 벽을 침범하고 최종적으로 시뮬레이션이 깨져버리는 현상입니다. reset_camera = False나 pl.set_scale()과 같은 인수를 시도했지만, 이 문제는 해결되지 않았습니다. 첨부된 스크린샷은 시뮬레이션 축이 찌그러든 모습과 높은 CPU 사용량, 낮은 GPU 사용량을 명확히 보여줍니다.



2. 명확한 발생 원인
2.1. 시각화 성능 저하 및 낮은 GPU 활용률
PyTorch와 PyVista를 함께 사용할 때, GPU 기반의 PyTorch 텐서 데이터를 PyVista가 처리할 수 있는 형식으로 변환하는 과정에서 주로 병목 현상이 발생합니다. 사용자의 코드에는 다음과 같은 비효율적인 패턴이 발견됩니다.
- 빈번한 GPU-CPU 데이터 전송: PyTorch 텐서를 NumPy 배열로 변환하기 위해
r.detach().cpu().numpy()호출이 매 프레임마다 이루어집니다. 이는 GPU에서 CPU로 데이터를 반복적으로 전송하여 심각한 성능 저하를 유발하며, GPU가 대부분의 시간 동안 데이터를 기다리게 만듭니다. - 비효율적인 메쉬 업데이트: 매 프레임마다
pl.clear()를 호출한 후pv.PolyData(np_r)로 새로운 메쉬 객체를 생성하고pl.add_mesh()로 이를 다시 플로터에 추가하는 방식은 매우 비효율적입니다. PyVista는 한 번 생성된 메쉬의 데이터를 직접 업데이트하여 성능을 최적화할 수 있는 기능을 제공합니다. - 불필요한 지연 시간:
time.sleep(0.01)은 의도적으로 시각화 업데이트에 지연을 주어 실제 시뮬레이션 속도를 더욱 저하시킵니다.
2.2. 시뮬레이션 박스 크기 초기화/축소
시뮬레이션 박스 크기가 줄어드는 것처럼 보이는 현상은 실제 시뮬레이션 로직의 문제가 아니라 PyVista의 카메라 동작 방식과 관련이 깊습니다.
- 카메라의 자동 조정: PyVista의
Plotter는 기본적으로 뷰포트 내의 모든 데이터를 포함하도록 카메라 뷰를 자동으로 조정하려는 경향이 있습니다.reset_camera=False는 새로운 메쉬가 추가될 때 카메라를 재설정하지 않도록 하지만, 카메라의 줌 레벨이나 클리핑 범위를 고정하지는 않습니다. 입자들이 움직이면서 데이터의 외곽 경계가 미세하게 변하면, 카메라는 이에 맞춰 뷰를 지속적으로 재조정하여 고정된 시뮬레이션 경계가 마치 축소되거나 왜곡되는 듯한 착시 현상을 유발합니다. pl.set_scale()의 오해:pl.set_scale()은 플로팅되는 객체의 크기를 조절하는 함수이며, 카메라 뷰의 경계나 줌 레벨을 고정하는 직접적인 방법은 아닙니다.
3. 해결 방법 및 코드 예시
3.1. PyVista 시각화 성능 최적화
가장 중요한 최적화는 매 프레임마다 메쉬를 새로 생성하는 대신, 기존 메쉬의 점(point) 데이터를 직접 업데이트하고 pl.update_coordinates()를 사용하여 변경 사항을 플로터에 알리는 것입니다. 또한, time.sleep(0.01)을 제거하여 불필요한 지연을 없앱니다.
3.2. 고정된 시뮬레이션 박스 유지
카메라의 자동 조정을 방지하고 고정된 시뮬레이션 영역을 시각화하기 위해서는 다음과 같은 방법들을 사용합니다.
- 초기
PolyData객체를 생성할 때 한 번만add_mesh를 호출하고, 이후에는 해당 메쉬의points속성을 업데이트합니다. - 카메라 위치(
pl.camera_position)를 초기화할 때 한 번만 설정하고, 그 후에는reset_camera=False를 사용하여 카메라가 자동 재설정되지 않도록 합니다. - 시뮬레이션 박스의 경계를 나타내는
pv.Box와 같은 정적 메쉬를 추가하여 카메라가 이 경계에 맞춰지도록 유도합니다. 이 경계 메쉬는 투명하게 설정하여 시뮬레이션을 가리지 않도록 할 수 있습니다. pl.show_bounds()를 사용하여 시뮬레이션 영역의 경계를 숫자로 표시하여 시각적 참고 자료를 제공합니다.
최적화된 코드 예시
# ===== PACKAGE IMPORT ===== #
import matplotlib.pyplot as plt
import numpy as np
import pyvista as pv
import torch
import os
import ipywidgets
import time
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# ===== DEFINING PARTICLES ===== #
n_part = 100
box_size = 50
r = torch.rand((n_part,3)).to(device) * box_size
v = torch.normal(mean=500, std = 50, size = (n_part,3)).to(device)
a = torch.zeros((n_part,3)).to(device)
new_a = 0
ids = torch.arange(n_part)
ids_pairs = torch.combinations(ids, 2).to(device)
dt = 0.001
n_steps = 1000
# for ljp -> f
sig = 1
ep = 1
inv_mass = 1
# ===== POSITION - DISTANCE PAIRS ===== #
def comp_dim_pairs(r, dim):
pairs =torch.diff(torch.combinations(r[:, dim],2).to(device), axis = 1).ravel().to(device)
return pairs
def comp_dpairs(dx_pairs, dy_pairs, dz_pairs):
d_pairs = torch.sqrt(dx_pairs**2 + dy_pairs**2 + dz_pairs**2).to(device)
return d_pairs
def comp_dpairs_simp(r):
dpairs_simp = comp_dpairs(comp_dim_pairs(r, 0), comp_dim_pairs(r, 1), comp_dim_pairs(r, 2)).to(device)
return dpairs_simp
# ===== FORCE CALCULATIONS VIA NEGATIVE GRADIENT OF THE LENNARD-JONES POTENTIAL ===== #
def comp_force_pairs(r, ep, sig):
# comp_dpairs_simp(r) 호출을 줄여 성능 향상
dpairs_r = comp_dpairs_simp(r)
F_r_n = ((24 * ep) / dpairs_r) * (2 * ( sig / dpairs_r) ** 12 - ( sig / dpairs_r) ** 6).to(device)
return F_r_n
def unit_vec_pairs(r):
dpairs_r = comp_dpairs_simp(r) # comp_dpairs_simp(r) 호출을 줄여 성능 향상
unit_vec_pairs =torch.stack((comp_dim_pairs(r, 0)/dpairs_r, comp_dim_pairs(r, 1)/dpairs_r, comp_dim_pairs(r, 2)/dpairs_r), dim = 1).to(device)
return unit_vec_pairs
# ===== SCALAR TO VECTOR FORCE ===== #
def vec_force(r, ep, sig):
vec_force = comp_force_pairs(r, ep, sig)[:, None] * unit_vec_pairs(r)
return vec_force
def comp_F_sum(n_part, ids_pairs, r, ep, sig):
F_sum = torch.zeros(n_part, 3).to(device)
F_sum.index_add_(0, ids_pairs[:,0], vec_force(r, ep, sig))
F_sum.index_add_(0, ids_pairs[:,1], vec_force(r, ep, sig), alpha = -1)
return F_sum
def check_wall_coll(r, v, dim, low, high):
# 벽 충돌 감지 및 반사 로직
# r_tmp_x_low, r_tmp_x_high는 실제 위치를 변경하는 것이 아니라,
# 충돌 시 위치를 제한하고 속도를 반전시킴.
# 낮은 경계 충돌
coll_low_idx = r[:, dim] < low
v[coll_low_idx, dim] = -v[coll_low_idx, dim]
r[coll_low_idx, dim] = low # 위치를 경계로 고정
# 높은 경계 충돌
coll_high_idx = r[:, dim] > high
v[coll_high_idx, dim] = -v[coll_high_idx, dim]
r[coll_high_idx, dim] = high # 위치를 경계로 고정
return r, v
# ===== ACTUAL POSITION CALCULATIONS ===== #
def comp_a(n_part, ids_pairs, r, ep, sig, inv_mass):
new_a = (comp_F_sum(n_part, ids_pairs, r, ep, sig) * inv_mass)
return new_a
def comp_v(v, a, new_a, dt):
new_v = v + (a + new_a) * dt * 0.5
return new_v
def comp_r(r, v, a, dt):
new_r = r + (v * dt) + (a * dt * dt * 0.5)
return new_r
def run_it_down(r, v, dt, a, n_part, ids_pairs, ep, sig, inv_mass, box_size):
new_r = comp_r(r, v, a, dt)
new_a = comp_a(n_part, ids_pairs, new_r, ep, sig, inv_mass)
new_v = comp_v(v, a, new_a, dt)
# 벽 충돌 검사 및 처리
new_r, new_v = check_wall_coll(new_r, new_v, 0, 0, box_size)
new_r, new_v = check_wall_coll(new_r, new_v, 1, 0, box_size)
new_r, new_v = check_wall_coll(new_r, new_v, 2, 0, box_size)
# 변수 업데이트 (얕은 복사 대신 깊은 복사가 필요할 수 있음. PyTorch는 대부분 인플레이스 연산을 지원.)
r = new_r
v = new_v
a = new_a
return r, v, a
# ===== ANIMATION =====
filename = 'testingtesting123_optimized.mp4' # 파일명 변경
pl = pv.Plotter(off_screen=True)
pl.open_movie(filename)
# 초기 카메라 설정 및 박스 고정
pl.camera_position = [(box_size * 4, box_size * 4, box_size * 4), # 시작 위치
(box_size / 2, box_size / 2, box_size / 2), # 시선이 향하는 곳 (박스 중앙)
(0, 0, 1)] # 카메라 Up 벡터
pl.set_scale(xscale=1, yscale=1, zscale=1) # 스케일 초기화
pl.reset_camera_clipping_range() # 카메라 클리핑 범위를 재설정 (데이터에 맞게)
pl.show_grid(color="grey", location="outer") # 그리드 표시
# 시뮬레이션 박스 경계 추가 (투명하게)
# PyVista Box는 [xmin, xmax, ymin, ymax, zmin, zmax] 형식의 인자를 받음
bounds_box = pv.Box([0, box_size, 0, box_size, 0, box_size])
pl.add_mesh(bounds_box, style='wireframe', color='blue', opacity=0.1, line_width=2)
# 경계의 숫자 표시
pl.show_bounds(bounds=bounds_box.bounds, font_size=10, show_zlabels=True)
# 초기 메쉬 생성 - 한 번만 호출
np_r = r.detach().cpu().numpy()
particles_mesh = pv.PolyData(np_r)
# reset_camera=False는 이후 update_coordinates에 의해 카메라가 재설정되는 것을 방지
pl.add_mesh(particles_mesh, color='black', point_size=5, render_points_as_spheres=True, reset_camera=False)
pl.write_frame()
print(pv.GPUInfo())
# 첫 번째 가속도 계산 (Verlet 통합을 위해 루프 시작 전 필요)
a = comp_a(n_part, ids_pairs, r, ep, sig, inv_mass)
for i in range(n_steps):
# 시뮬레이션 단계 실행
r, v, a = run_it_down(r, v, dt, a, n_part, ids_pairs, ep, sig, inv_mass, box_size)
# 데이터 업데이트
np_r = r.detach().cpu().numpy()
# 기존 메쉬의 점 데이터 업데이트 (성능 최적화의 핵심)
particles_mesh.points = np_r
# 뷰어에 변경사항 업데이트 (불필요한 add_mesh 호출 방지)
pl.update_coordinates(particles_mesh)
# 카메라 위치를 매번 재설정하지 않도록 주의 (초기에 설정된 카메라를 유지)
# pl.camera_position = [(box_size * 4, box_size * 4, box_size * 4), (box_size / 2, box_size / 2, box_size / 2), (0, 0, 1)]
# pl.set_scale(xscale=1, yscale=1, zscale=1) # 스케일 초기화
pl.render() # 렌더링
pl.write_frame() # 프레임 쓰기
# time.sleep(0.01) # 불필요한 sleep 제거
pl.close()
4. 향후 예방을 위한 팁
이러한 문제를 예방하고 PyVista와 PyTorch를 더욱 효율적으로 사용하기 위한 몇 가지 팁입니다.
- 성능 프로파일링 도구 활용: Python의
cProfile이나 PyTorch의torch.profiler같은 도구를 사용하여 코드의 병목 지점을 정확히 파악하세요. 이를 통해 GPU-CPU 데이터 전송이 실제로 가장 큰 병목인지, 아니면 다른 계산 로직에 문제가 있는지 확인할 수 있습니다. - PyVista 메쉬 업데이트 패턴 이해: PyVista에서 동적인 데이터를 시각화할 때는 항상 새로운 메쉬를 생성하기보다, 기존
PolyData객체의 점(.points)이나 셀(.cells) 데이터를 직접 업데이트하고plotter.update_coordinates()또는mesh.set_points()(메쉬 타입에 따라 다름) 같은 메서드를 사용하는 습관을 들이세요. - GPU-CPU 데이터 전송 최소화: PyTorch 텐서를 NumPy로 변환하는 작업은 GPU에서 CPU로 데이터를 옮기는 비용이 큰 작업입니다. 이 작업의 빈도를 최소화하거나, 필수적인 경우 한 번에 많은 데이터를 전송하는 방식으로 최적화하세요. 만약 PyVista가 GPU 텐서를 직접 처리할 수 있는 기능이 있다면 이를 적극적으로 활용하는 것을 고려해 보세요 (현재 PyVista는 주로 NumPy/CPU 데이터를 기대합니다).
- 카메라 및 뷰포트 관리: 동적 시뮬레이션에서는 카메라가 자동으로 데이터에 맞춰 조정되는 것을 방지하기 위해 초기 카메라 위치, 초점, 업 벡터(up vector), 클리핑 범위 등을 명시적으로 설정하고, 자동 리셋 기능을 비활성화하는 것이 중요합니다.
pl.add_bounding_box()나pl.show_bounds()를 사용하여 시뮬레이션 경계를 시각적으로 고정하는 것도 좋은 방법입니다. - PyVista 고급 기능 탐색: PyVista는 대규모 데이터셋을 위한 여러 최적화 기능을 제공합니다. 예를 들어,
live_plotting=True옵션을 사용하거나, 내부적으로 C++ 백엔드를 사용하는enable_surface_rendering()등을 탐색하여 특정 시나리오에서 성능 향상을 기대할 수 있습니다.
이러한 최적화 방법을 적용하면 PyTorch 기반 시뮬레이션을 PyVista로 더욱 빠르고 정확하게 시각화할 수 있을 것입니다.
![[에러 해결] CUDA 11.1 설치 불가: PyTorch 프로젝트 환경 설정 버전 불일치 원인과 해결 방법 [에러 해결] CUDA 11.1 설치 불가: PyTorch 프로젝트 환경 설정 버전 불일치 원인과 해결 방법](https://dev-error.com/wp-content/plugins/contextual-related-posts/default.png)
![[RK45 오류 해결] N-Body 시뮬레이션 RK45 구현: 궤적 왜곡 및 적응형 스텝 사이즈 문제 원인과 해결 방법 EXPECTED (GPD, obtained with RK4 implementation):](https://i.sstatic.net/QSi7GODn.png)