Pointcept:Arm64
ARM64 에서 (정확히는 NVIDIA DGX Spark) 에서 Pointcept 를 빌드하고 사용하는 과정에서 발생하는 이슈들에 대하여 정리함.
빌드 방법
DGX Spark (GB10 = sm_121, aarch64) 에서 네이티브로 돌리는 경우입니다. 이 경우 QEMU가 전혀 필요 없고(arch == host_arch면 바로 통과), 그냥 도커만 있으면 됩니다.
시스템 사양
- NVIDIA DGX Spark (GB10 = sm_121, aarch64)
- NVIDIA-SMI 580.173.02
- NVIDIA Driver Version: 580.173.02
- CUDA Version: 13.0
- Ubuntu 24.04.4 LTS (noble)
- Python 3.12
- Torch 2.9.1 (cu130 인덱스의 최소 버전이 2.9.0)
원시코드
- https://github.com/cvprun/Pointcept 를 사용한다. (원본 코드 - https://github.com/pointcept/pointcept - 를 Fork 했다)
한방에 실행하는 간단한 방법
arm64+cu130+torch2.9.1+py3.12+sm_121
./scripts/build_wheels.sh build --arch arm64 --accel cu130 --torch 2.9.1 --python 3.12 --cuda-arch 12.1 --jobs 8 -v
./scripts/verify_wheels.sh wheelhouse/linux-arm64/cu130/torch2.9.1-cp312/
arm64+cu130+torch2.13.0+py3.12+sm_121
./scripts/build_wheels.sh build --arch arm64 --accel cu130 --torch 2.13.0 --python 3.12 --cuda-arch 12.1 --jobs 8 -v
./scripts/verify_wheels.sh wheelhouse/linux-arm64/cu130/torch2.13.0-cp312/
타깃 확정
DGX Spark의 sm_121은 CUDA 12.9 이상에서만 코드 생성이 됩니다. 스크립트의 cuda_supported_arches상 cu128은 12.0까지라 12.1이 검증 단계에서 탈락합니다. 따라서:
| 축 | 값 | 이유 |
| | arm64 | 호스트 아키텍처 (에뮬레이션 없음) |
| | cu130 | sm_121 커버 + PyTorch 인덱스 존재 (cu129도 가능) |
| | 2.9.1 | cu130 인덱스의 최소 버전이 2.9.0 |
| | 3.12 | 기본값 |
| | 12.1 | GB10 전용. 생략하면 8.7 9.0 10.0 11.0 12.0 12.1 6개를 전부 컴파일 |
공통 변수로 묶어두면 편합니다:
cd /path/to/Pointcept
BW=./scripts/build_wheels.sh
T="--arch arm64 --accel cu130 --torch 2.9.1 --python 3.12 --cuda-arch 12.1 --jobs 8 -v"
-
--jobs는 기본값이 nproc(최대 16)인데, DGX Spark는 128GB 통합 메모리를 GPU와 공유합니다. nvcc가 잡당 ~2.5GB를 쓰므로 8~10으로 낮춰 잡는 걸 권합니다. -
-v는 컴파일러 출력을 스트리밍합니다. 몇 시간짜리 빌드를 눈으로 확인하려면 필수입니다.
시작 전 확인 (아무것도 빌드하지 않음):
단계별 빌드
핵심은 --only {패키지} 입니다. 출력 디렉터리와 pip/uv 캐시 볼륨(pointcept-build-cache)이 실행 간에 공유되므로, 나눠 돌려도 결과가 한 곳에 누적됩니다.
manifest.txt에 각 휠을 프리빌트로 받았는지 컴파일했는지가 append로 기록됩니다.
| # | 명령 | 내용 | 체감 비용 | 프로젝트 링크 | 프로젝트 설명 |
| 1 | | 순수 파이썬, PyPI 프리빌트 | 초 단위 | NVIDIA의 Pure C++ Meta: C++ 코드를 파이썬으로 생성하는 메타프로그래밍 도구 | |
| 2 | | aarch64 프리빌트 없음 → 소스 | 무거움 | Concurrent Unified Memory Management: CUDA/CPU 메모리 관리 및 커널 최적화 라이브러리 | |
| 3 | | 소스 | 무거움 | Sparse Convolution: 포인트 클라우드 처리용 희소 3D 합성곱 구현 | |
| 4 | | aarch64 프리빌트 없음 → 소스 | 중간 | PyTorch Scatter: 텐서 scatter/gather 연산의 GPU 최적화 구현 | |
| 5 | | 소스 (이 셋 중 최장) | 무거움 | PyTorch Sparse: 희소 텐서 연산 및 행렬곱을 위한 GPU 최적화 레이어 | |
| 6 | | 소스 | 중간 | PyTorch Cluster: 점군 클러스터링, k-NN 그래프 생성 등 그래프 기반 연산 라이브러리 | |
| 7 | | 둘 다 순수 파이썬 | 초 단위 | PyTorch Geometric: GNN 프레임워크 / OCNN: 옥트리 기반 3D CNN | |
| 8 | | 소스 (CUDA) | 중간 | Swin3D Vision Transformer: 3D 포인트 클라우드 및 영상 처리용 transformer 백본 | |
| 9 | | ⚠️ 아래 참고 | 최장 | FlashAttention: 어텐션 연산의 IO-aware 알고리즘으로 메모리 대역폭 최적화 | |
| 10 | | | 각 수 분 | PointOps: 포인트 클라우드 연산 커널 (Pointcept 및 Point Transformer용) | |
| 11 | | | 각 수 분 | Pointcept: 포인트 클라우드 세분화(segmentation), 인스턴스 그룹핑, 위치 인코딩 커스텀 연산 |
- 의존성은 자동으로 끌어옵니다.
--only spconv만 줘도 pccm → cumm → spconv 순으로 셋 다 빌드하니, 2~3단계를 합치고 싶으면 3번만 실행해도 됩니다.
각 단계는 독립된 컨테이너로 실행되며 매번 "apt 툴체인 설치 + uv venv + torch 설치" 를 처음부터 다시 합니다(약 2~3분 고정 오버헤드). 캐시 볼륨 덕에 두 번째 실행부터는 다운로드가 아니라 캐시 복사입니다.
| INFORMATION |
| 참고로 한번에 전부 실행 시킬 예정이라면 |
sm_121 에서 알아둘 두 가지
cumm/spconv
cumm은 자체 supported_arches 테이블(cumm/common.py)을 가지고 있고 거기에 12.1이 없습니다. 스크립트가 이를 12.0+PTX로 자동 매핑하므로(cumm_arch_for), cubin 대신 PTX가 실려 첫 로드 시 드라이버가 JIT합니다.
빌드 중 이런 로그가 나오면 정상입니다:
flash-attn
flash_attn_arch_for가 아는 값은 80/90/100/120뿐이고 12.1은 매핑이 없어 패키지 자체가 스킵됩니다:
Blackwell은 sm_120 cubin이 sm_121 디바이스에 로드되지 않으므로, 이건 스크립트 버그라기보다 실제 제약입니다. 이 단계는 건너뛰고 진행하시고, flash-attn이 꼭 필요하면 별도로 확인이 필요합니다.
원하시면 12.0+PTX로 넘겨 PTX 경로가 실제로 되는지 스크립트를 손봐드릴 수 있습니다.
완료 후
cat wheelhouse/linux-arm64/cu130/torch2.9.1-cp312/manifest.txt
pip install wheelhouse/linux-arm64/cu130/torch2.9.1-cp312/*.whl
참고로 알아두면 좋은 것
- 빌드는 GPU를 전혀 쓰지 않습니다 (
--gpus플래그 없음, nvcc 크로스 컴파일). 시작 시 뜨는 드라이버/GPU 체크는 "이 휠이 이 머신에서 로드되는가"를 알려줄 뿐 빌드를 막지 않습니다. - 레포는
/src에 읽기 전용으로 마운트되고libs/는 컨테이너 안에서/tmp로 복사된 뒤 빌드되므로, 작업 트리에build/나*.egg-info가 생기지 않습니다. - 실패해도
set +e로 감싸여 있어 해당 패키지 로그 마지막 40줄이 출력됩니다. 전체 로그는 컨테이너 내부/tmp/<패키지>.log인데 컨테이너가--rm이라 사라지므로, 자세히 봐야 하면$BW shell $T로 같은 환경에 들어가서 수동으로 돌리세요. - 한 번에 다 돌려보고 싶어지면
$BW build $T --keep-going— 실패한 패키지를 건너뛰고 끝까지 갑니다.
Troubleshooting
Pointcept (PTv3) 학습 크래시 : cudaErrorIllegalAddress
DGX Spark 노드에서 PTv3 학습이 cudaErrorIllegalAddress 로 죽었다. 에러는 매번 spconv 의 pair 생성에서 보고됐지만, 죽는 iteration 과 씬 형상은 런마다 달랐다.
여러 가설을 차례로 배제하는 과정에서, 한 번은 torch 2.13.0 을 원인으로 잘못 확정했다. torch 를 2.9.1 로 내린 운영 런도 똑같이 죽어서 그 결론은 철회했다.
마지막으로 implicit gemm 경로를 통째로 빼는 spconv_native: true 로 운영 런을 돌렸고, 100 epoch(2,500 iter)를 끝까지 돌았다. 이 스위치를 기본값으로 켰다.
증상
[Exception|implicit_gemm_pair]indices=torch.Size([49093, 4]),bs=1,ss=[757, 621, 546],
algo=ConvAlgo.MaskImplicitGemm,ksize=[3, 3, 3],...,subm=True,transpose=False
File ".../spconv/pytorch/ops.py", line 550, in get_indice_pairs_implicit_gemm
SpconvOps.sort_1d_by_key_allocator(pair_mask_tv[j],
RuntimeError: merge_sort: failed to synchronize: cudaErrorIllegalAddress:
an illegal memory access was encountered
-
merge_sort: failed to synchronize는 thrust 가 앞서 실행된 다른 커널의 잘못된 메모리 접근을 늦게 알아챈 것이다. 그래서 트레이스백은 결함이 난 곳이 아니라 그 뒤의 첫 동기화 지점을 가리킨다. - 운영 런에서 사망한 지점:
| 런 | torch | 조건 | 사망 지점 |
| 1차 | 2.13.0 | 기본 (amp 켬) | 학습 25 iter 뒤 첫 평가 샘플 |
| 2차 | 2.13.0 | | epoch 2 의 17번째 iter (누적 42 iter) |
| 3차 | 2.9.1 | 클래스에서 | epoch 3 의 17번째 iter (누적 67 iter) |
| 4차 | 2.9.1 | | 사망 없음 — 100 epoch 완주 |
조사 경과
| # | 가설 | 검증 방법 | 결과 |
| 1 | 평가만 fp32 로 돈다 (학습은 autocast fp16) | | 기각 — 평가는 통과하고 학습 중에 죽음 |
| 2 | 평가를 autocast 로 감싸면 우회된다 | 로컬 amd64 에서 실제 엔트리포인트 구동 | 막다른 길 — amd64 에서도 |
| 3 | 데이터 오염 (NaN, 이상치) | 62개 PLY 전수 검사 | 기각 — 비유한 좌표 0건 |
| 4 | 특정 씬 형상이 방아쇠 | 로그의 | 기각 — 같은 씬이 로컬에서 멀쩡하고, 사망 때마다 형상이 다름 |
| 5 | sm_121 에서 PTX JIT 된 spconv 커널 | DGX Spark 에서 torch 2.9.1 휠하우스로 한 번 실행 | 기각했으나 잘못된 기각 — 통과 한 번뿐이었음 (가설 8 에서 복원) |
| 6 | 검증 런이 너무 짧고 작다 | 47.7k voxel × 60 iter 로 재실행 | 2.9.1 통과. 이것도 한 번뿐 |
| 7 | torch 2.13.0 휠 세트 | 같은 조건으로 torch 2.13.0 휠하우스 실행 | epoch 5 에서 사망 → 원인으로 확정했으나 틀림 |
| 7' | (정정) | torch 2.9.1 로 재게시한 운영 이미지 | 같은 에러로 재발 → 가설 7 철회 |
| 8 | spconv implicit gemm 경로 | 운영에 | 확인 — 수십 iter 안에 죽던 조건에서 2,500 iter 완주 |
틀린 결론에서 배운 것
가설 5·6·7 은 모두 설정마다 한 번씩만 돌린 결과로 판정했다. 매번 다른 자리에서 터지는 결함이라면 한 번 통과한 것은 운일 수 있다. 실제로 2.9.1 쪽의 통과는 운이었다. 판별력이 있었던 실험은 결함 경로를 통째로 빼는 것(가설 8)이었다.
traceback 의 줄 번호로 운영 이미지의 torch 버전을 확인했다. module.py 의 _wrapped_call_impl 줄이 2.9.1 은 1775, 2.13.0 은 1778 이다.
주요 사실
- cumm 0.8.2 는 12.1 을 받지 않는다.
cumm/common.py의supported_arches가"12.0"에서 끝난다. 그래서 sm_121 용 spconv 는12.0+PTX로 빌드되고 드라이버가 JIT 한다. torch 자신도compute_120PTX 로 같은 방식으로 돈다. - spconv 는 모든 weight 를 알고리즘과 무관하게 KRSC 로 저장한다(
ALL_WEIGHT_IS_KRSC=True). 그래서 모델을 만든 뒤ConvAlgo.Native로 바꿔도 안전하다. 훅은 이 전제를 assert 로 확인한다. - Native 의 비용은 sm_120 노트북에서 약 4% 였다.
조치
| 저장소 | 커밋 | 내용 |
| mlops | | |
| mlops | | 하이퍼파라미터 |
| mlops | | |
| mlops | | |
| Pointcept | | |
| Pointcept | | mlops 체크아웃이 없는 노드용으로 엔트리포인트 3개를 |
PointGroup 인스턴스 분할 학습 트러블슈팅
작성 2026-09-23. 대상은 instance_segmentation / pointcept / pg-ptv3 (PointGroup PG-v1m2 + PTv3 백본), GB10 (sm_121) 단일 GPU, 사용자 PLY 62개 (학습 50 / 검증 12), 단일 클래스.
플랜 문서는 docs/plans/50_pointgroup_instance_segmentation_plan.md 의 Phase 6 진행 메모이고, 이 파일은 그중 실행에서 만난 문제만 따로 모은 것이다.
1. 한눈에
| 회차 | 증상 | 원인 | 조치 | 커밋 |
| 1차 | | NumPy 2 가 | | |
| 2차 | 역전파에서 CUDA device-side assert (scatter/gather 범위 초과) | 미확정, 3차에서 재현되지 않음 | | |
| 3차 | 완주했으나 mAP/AP50/AP25 가 전부 0, | 좌표 규모가 PointGroup 기본값의 약 100배 | 스테이징이 좌표에 배율을 걸어 | |
3차에서 확인된 가장 중요한 사실 하나: pointgroup_ops 의 bfs_cluster 가 sm_121 에서 실제로 돈다. 플랜의 최대 리스크였고 CPU 폴백을 준비할 필요가 없어졌다.
2. 1차: np.in1d 없음
mask = ~np.in1d(segment, self.segment_ignore_index)
AttributeError: module 'numpy' has no attribute 'in1d'
NumPy 2.0 에서 np.in1d 가 사라졌다. 이미지의 numpy 는 2.5.2 다. Pointcept 포크에서 이 함수를 쓰는 자리는 세 곳뿐이고 전부 인스턴스 경로다 (InstanceParser, InsSegEvaluator, tester). 그래서 시맨틱 분할은 같은 이미지로 100 epoch 를 완주하면서도 이 오류를 한 번도 만나지 않았다.
조치는 pointcept_semseg.patch_numpy_compat() 다. np.in1d 를 np.isin 으로 되살린다. 세 자리 모두 1-D 라벨 배열이라 동치다. 디스패처가 트레이너를 고르기 전에 부르고, pointcept_insseg.main() 도 시작에서 한 번 부른다. 포크에 다른 NumPy 2 제거 별칭은 없다 (전수 확인).
곁가지로 이 수정의 테스트가 patch.dict(sys.modules, ...) 를 쓰고 있었는데, 이 패치는 빠져나올 때 사전을 통째로 되돌려서 블록 안에서 처음 import 된 모듈을 지운다. 그 뒤 numpy 가 두 벌이 되어 엉뚱한 테스트가 _NoValueType 으로 죽었다. 키 하나만 바꾸고 되돌리는 _stub_module 로 교체했다.
3. 2차: 역전파에서 CUDA device-side assert
ScatterGatherKernel.cu:163 ... scatter gather kernel index out of bounds (수백 줄)
spconv indice_conv_backward ... dev2host ... error 710
데이터 준비와 첫 forward 는 통과했다. CUDA 오류는 비동기라 터진 자리(spconv 역전파)와 원인 커널이 다르다. Pointcept 과 PointGroup 코드에서 ATen gather/scatter 를 직접 부르는 자리는 없고 (comm.gather 와 torch_scatter.segment_csr 뿐), 백본과 spconv 와 데이터는 같은 조합으로 시맨틱 분할 100 epoch 를 완주한 적이 있다. 코드 읽기로는 더 좁힐 수 없어서 계측을 넣었다.
- 하이퍼파라미터
cuda_launch_blocking(기본 꺼짐). 켜면 트레이너가 torch import 전에CUDA_LAUNCH_BLOCKING=1을 놓아 트레이스백이 진짜 커널을 가리킨다. 대신 느려진다. -
describe_scene()이 씬마다segment와instance범위를 검사한다. 클래스 인덱스가 모델의 클래스 수를 넘거나, "클래스는 있는데 인스턴스가 없는 점" 이 있으면 학습 시작 전에 실패한다. 앞 세 씬의 통계와, 인스턴스가 하나도 없는 씬 목록을 로그에 남긴다.
3차(blocking 켜고 실행)에서 재현되지 않았다. 비동기 경로의 일시적 현상으로 보고 계측만 남겼다. 다시 나오면 순서는 이렇다. cuda_launch_blocking: True 로 트레이스백 확보 → batch_size: 1 (씬 경계 문제 배제) → enable_amp: False.
4. 3차: 완주했는데 AP 가 전부 0
seg_loss: 0.0000 bias_l1_loss: 150 ~ 225
Val result: mAP/AP50/AP25 0.0000/0.0000/0.0000
Best validation AP50 updated to: 0.0000
seg_loss: 0.0000 은 정상이다. 클래스가 1개라 시맨틱 손실이 상수이고 seg_head 는 배울 것이 없다. 추론 때 pred_classes 는 항상 0 이다.
문제는 bias_l1_loss 다. 사용자 PLY 12개를 다시 재 본 값이다.
| 이 데이터 | ScanNet (기본값이 맞춰진 기준) | |
| 씬 크기 | 749 × 594 × 494 단위 | 5~10 m |
| 점에서 제 인스턴스 중심까지 평균 절대 오프셋 | 172.4 | 1~2 |
로그의 150~225 가 바로 이 172 다. 즉 오프셋 헤드가 아무것도 배우지 못한 상태의 손실에 그대로 앉아 있었다는 뜻이다. PointGroup 의 오프셋 헤드는 점마다 "내 인스턴스 중심까지의 변위" 를 회귀하고, 그 변위로 점들을 끌어모은 뒤 BFS 로 군집해 제안을 만든다. 오프셋이 안 모이면 제안이 없고, 제안이 없으면 AP 는 0 이다.
원인은 좌표 규모다. PointGroup 의 군집 반경(cluster_thresh)과 Pointcept 의 증강값 (RandomJitter sigma=0.005 clip=0.02)은 미터 단위 실내 씬(복셀 0.02 m)에 맞춰진 숫자다. 우리는 그 100배 규모를 그대로 넣고 있었다. 749 단위 씬에서 지터 0.005 는 아무 일도 하지 않는다. 여기에 2 epoch 만 돈 것이 겹쳤다.
조치
스테이징이 좌표에 배율만 건다. 사용자가 고른 grid_size(데이터 단위, 기본 0.6)가 TARGET_VOXEL = 0.02 가 되도록 스케일해서 저장하고, 생성되는 설정의 grid_size 와 voxel_size 는 그 값을 쓴다.
- 복셀 격자는 그대로다. 같은 점이 같은 복셀에 묶인다.
cluster_thresh가 덮는 실제 거리도 데이터 0.9 단위 그대로다. 바뀐 것은 회귀 목표 크기(172 에서 5.7)와 증강 크기뿐이다. - 배율은
model_config["coord_scale"]로 모델에 실린다. 서빙 pyfunc 이 같은 배율을 걸어 학습과 같은 규모로 본다. 결과는 점별 ID 라 좌표 규모의 영향을 받지 않는다. - 스테이징이
mean |offset| to the instance centre를 로그에 찍는다.
5. 정상 로그 읽는 법
스테이징 구간에서 볼 것.
-> scene 10_final: {'points': 45470, 'instances': 3, 'labelled': 45470, 'classes': [0]}
-> coordinates scaled by 0.0333333 so the voxel is 0.02 ...
-> mean |offset| to the instance centre: 5.7 (PointGroup's bias_l1_loss starts near this; ScanNet sits at 1-2)
-> prepared 50 train / 12 val scene(s), 249 instance(s)
-
instances가 0인 씬이 많으면 그만큼 오프셋 손실을 못 배운다. 별도 줄로 알려 준다. -
labelled가points보다 한참 작으면 미할당 인스턴스가 많다는 뜻이다. 데이터셋 화면의 할당표나 CSV 로 채운다. -
mean |offset|이 한 자리 수면 규모가 맞다. 세 자리 수면 이 문서 4장 상황이다.
학습 구간에서 볼 것.
-
bias_l1_loss가 위mean |offset|근처에서 시작해서 내려가야 한다. 고정이면 학습이 안 되는 것이다. -
seg_loss: 0.0000은 단일 클래스에서 정상이다. - best 체크포인트는 평가기가 AP50 으로 고른다. mAP 가 아니다.
- MLflow 지표 이름은
metrics/mAP,metrics/AP50,metrics/AP25다.
6. AP 가 여전히 낮을 때 만질 순서
- 에폭. 2 epoch 로는 아무것도 나오지 않는다. 50~100 부터 본다.
-
cluster_thresh(기본 1.5, 복셀 단위). 제안이 하나도 없으면 올리고, 물체가 전부 하나로 붙으면 내린다. -
cluster_min_points(기본 50),cluster_propose_points(기본 100). 작은 물체가 통째로 버려지면 내린다. -
batch_size: 1,enable_amp: False. 불안정한 실행을 좁힐 때만.
인스턴스가 2~5개인 파일 62개는 작은 데이터셋이다. ScanNet 사전학습 백본을 쓰지만 과적합 여지가 크고, 검증이 12개뿐이라 AP 의 분산도 크다. 한 번의 숫자로 결론 내지 않는 편이 좋다.
7. 재게시가 필요한 변경
트레이너와 pyfunc 은 학습 런타임 이미지 안에 들어간다. 즉 코드를 고치면 런타임을 다시 게시해야 실행에 반영된다. 지금까지 재게시가 필요했던 변경은 이렇다.
-
pointgroup_ops휠 추가 (Phase 0) -
np.in1d복원 (1차 조치) -
cuda_launch_blocking과 씬 검사 계측 (2차 조치) - 좌표 배율 (3차 조치)
서빙 이미지는 학습 이미지를 FROM 으로 하므로, 학습 이미지를 새로 게시한 뒤에 만든 모델부터 새 pyfunc 으로 서빙된다. 이미 등록된 시맨틱 분할 모델의 서빙에는 영향이 없다. pyfunc 이 geo_task 로 분기하고 기본이 시맨틱 분할이다.
8. 파일 위치
| 무엇 | 경로 |
| 트레이너 디스패처 | |
| 인스턴스 분할 트레이너 | |
| 공용 헬퍼와 NumPy 호환 | |
| 서빙 pyfunc | |
| 하이퍼파라미터 화이트리스트 | |
| 테스트 | |
| 플랜과 진행 메모 | |