본문으로 건너뛰기

[uv] macOS에서 uv 캐시 정리가 3.8배 빨라진 비결: getattrlistbulk를 활용한 일괄 메타데이터 조회

PR 링크: astral-sh/uv#21344 상태: Merged | 변경: +409 / -15

들어가며

Python 패키지 매니저인 uv는 극강의 속도를 자랑합니다. 하지만 수만 개의 파일이 쌓인 캐시를 정리하는 작업(uv cache clean 또는 prune)은 파일 시스템의 한계로 인해 병목이 발생하곤 했습니다. 특히 macOS 환경에서 각 파일의 hardlink count를 하나씩 확인하는 작업은 시스템 콜 오버헤드로 인해 성능 저하의 주범이었습니다.

이번 PR(#21327의 후속)은 macOS 전용 시스템 콜인 getattrlistbulk를 도입하여, 수만 개의 파일 메타데이터를 개별적으로 조회하던 기존 방식을 배치(Batch) 처리 방식으로 전환했습니다. 이를 통해 캐시 스캔 성능을 약 3.8배(386ms -> 101ms) 향상시켰습니다. 시니어 엔지니어의 관점에서 이 최적화가 왜 훌륭한지, 그리고 실제 구현에서 어떤 저수준(low-level) 기법들이 사용되었는지 분석해 보겠습니다.


코드 분석: 기존의 순차적 접근 vs 새로운 일괄 처리

1. crates/uv-cache/src/lib.rs: 순회 로직의 변화

기존에는 walkdir을 사용하여 모든 파일을 하나씩 방문하며 hardlink_count를 조회했습니다. 이 방식은 구현이 간단하지만, 파일마다 독립적인 stat 계열 시스템 콜을 발생시킵니다.

Before:

// 모든 엔트리를 하나씩 순회하며 개별적으로 hardlink_count 호출
for entry in walkdir::WalkDir::new(&root)
    .min_depth(1)
    .contents_first(true)
{
    let entry = entry?;
    if entry.file_type().is_file() {
        match uv_fs::hardlink_count(entry.path()) {
            Ok(1) => summary += self.remove_path(entry.path())?,
            Ok(_) => {}
            Err(err) => return Err(err),
        }
    } // ... 디렉토리 삭제 로직
}

After: 새로운 로직은 macOS에서 지원하는 경우 files_with_one_hardlink라는 함수를 통해 디렉토리 내의 파일들을 한 번에 읽어옵니다. 만약 이 최적화 경로를 사용할 수 있다면 entries.skip_current_dir()를 호출하여 중복 스캔을 방지합니다.

let mut entries = walkdir::WalkDir::new(&root).min_depth(1).into_iter();
while let Some(entry) = entries.next() {
    let entry = entry?;
    if entry.file_type().is_file() {
        // 기존 방식 유지
    } else if entry.file_type().is_dir() {
        // macOS 최적화 경로 시도
        if let Some(files) = uv_fs::files_with_one_hardlink(entry.path())? {
            entries.skip_current_dir(); // 하위 파일들을 이미 읽었으므로 스킵
            for file in files {
                summary += self.remove_path(file)?;
            }
        }
        directories.push(entry.into_path());
    }
}

이 PR의 핵심은 macOS 전용 getattrlistbulk 시스템 콜을 직접 호출하는 부분입니다. nixrustix 같은 표준 라이브러리에서 이 API를 제공하지 않기 때문에, 직접 libc를 사용하여 unsafe 블록 내에서 구현되었습니다.

핵심 데이터 구조: getattrlistbulk는 가변 길이 레코드를 8바이트 정렬된 버퍼에 담아 반환합니다. 이를 위해 #[repr(align(8))]를 사용한 버퍼 구조체를 정의했습니다.

#[repr(align(8))]
struct AttributeBuffer([u8; 64 * 1024]);

#[repr(C)]
struct FileAttributes {
    length: u32,
    returned: libc::attribute_set_t,
    error: u32,
    name: libc::attrreference_t,
    object_type: u32,
    hardlink_count: u32,
}

시스템 콜 호출 부분: 한 번의 호출로 여러 파일의 이름, 타입, 하드링크 수를 가져옵니다. 이는 수천 번의 lstat 호출을 단 몇 번의 getattrlistbulk 호출로 줄여줍니다.

let count = unsafe {
    libc::getattrlistbulk(
        directory.as_raw_fd(),
        (&raw mut attributes).cast(),
        buffer.0.as_mut_ptr().cast(),
        buffer.0.len(),
        u64::from(libc::FSOPT_PACK_INVAL_ATTRS),
    )
};

왜 이게 좋은 최적화인가?

1. 시스템 콜 오버헤드 감소 (Batching)

현대 운영체제에서 시스템 콜은 비싼 작업입니다. 유저 모드에서 커널 모드로의 컨텍스트 스위칭이 발생하기 때문입니다. 87,000개의 파일을 처리할 때 기존 방식은 최소 87,000번의 시스템 콜을 발생시키지만, 64KB 버퍼를 사용하는 배치 방식은 이를 수백 번 수준으로 줄여줍니다.

2. IOPS 및 파일 시스템 탐색 최적화

getattrlistbulk는 파일 시스템 내부적으로 디렉토리 엔트리를 순회할 때 메타데이터를 함께 추출하도록 설계되었습니다. 이는 디렉토리 엔트리를 읽은 후 다시 각 파일의 inode를 찾아가는 추가적인 디스크 탐색(seek)을 줄여줍니다.

3. 견고한 폴백(Fallback) 전략

이 최적화는 macOS에 특화되어 있습니다. 코드에서는 ENOTSUP, ENOSYS 등의 에러를 체크하여, 해당 시스템 콜을 지원하지 않는 환경이나 파일 시스템(예: 네트워크 드라이브)에서는 자동으로 기존의 안전한 WalkDir 방식으로 돌아가도록 설계되었습니다.

Some(libc::ENOTSUP | libc::ENOSYS | libc::EINVAL | libc::EACCES) => {
    debug!("Falling back to individual hardlink counts: {error}");
    Ok(None) // None을 반환하여 상위 로직에서 기본 walk를 수행하게 함
}

리뷰 피드백 반영: 안전성과 유지보수

리뷰 과정에서 konstincharliermarsh는 몇 가지 중요한 지적을 했습니다.

  • 안전한 래퍼의 부재: nixrustix에 해당 기능이 없음을 확인하고 직접 구현하는 정당성을 확보했습니다.
  • 에러 핸들링: EACCES(권한 없음) 상황에서도 전체 프로세스가 중단되지 않고 폴백을 통해 개별 파일 단위로 시도하여, 접근 가능한 파일이라도 삭제할 수 있도록 개선되었습니다.
  • 디렉토리 삭제 순서: uv-cache 수정 시 directories.push(...).rev()를 통해 역순으로 삭제하는 로직을 추가했습니다. 이는 자식 디렉토리가 먼저 삭제되어야 부모 디렉토리가 비워져 삭제 가능해지는 파일 시스템의 특성을 고려한 것입니다.

결론

이 PR은 "성능은 디테일에 있다"는 것을 잘 보여줍니다. 단순히 라이브러리를 사용하는 수준을 넘어, 타겟 플랫폼의 저수준 API를 깊이 있게 이해하고 활용했을 때 얻을 수 있는 성능 이득은 막대합니다. 특히 uv처럼 대규모 파일 처리가 빈번한 도구에서 이러한 OS 특화 최적화는 사용자 경험을 결정짓는 핵심 요소가 됩니다.

참고 자료

⚠️ 알림: 이 분석은 AI가 실제 코드 diff를 기반으로 작성했습니다.

댓글

관련 포스트

PR Analysis 의 다른글