데이터 파이프라인의 시간은 세 곳으로 갑니다: 데이터 읽기 및 디코딩, 각 행에서 수행되는 작업, 그리고 대기(저장소, 락 또는 하나의 바쁜 스레드에서). 디코딩은 일반적으로 라이브러리에서 이미 튜닝되어 있습니다. 각 행의 작업은 여러분이 작성하는 부분이며, 부주의한 루프가 2~3배의 비용을 초래할 수 있는 곳입니다.
이 파일의 대부분은 스캔의 배치를 소비하는 코드에 관한 것입니다. 그 아래에 있는 리더, 라이터 또는 코덱과 신뢰할 수 있는 수치를 얻는 방법에 대해서는 마지막 두 섹션과 그 참조 자료를 참조하세요.
첫째, 시간이 어디로 가는지 측정하세요
비교할 두 번째 구현(Ray, DuckDB, pyarrow)이 있는 경우는 드뭅니다. 대신 스캔 자체를 기준으로 사용하세요:
- 스캔만 시간을 측정합니다. 동일한 스캔, 동일한 열, 필터 및
ScanOptions를 사용하여 배치를 폐기합니다. 이것은 디코딩 전용 기준선입니다: 어떤 파이프라인도 이것보다 빠를 수 없습니다. - 자체 작업만 시간을 측정합니다. 배치별 호출(
fold,add, 배치를 소비하는 모든 것)을perf_counter_ns()로 감싸고 시간을 합산합니다. - 비교합니다. 단일 스레드에서 전체 시간이 디코딩 전용 시간의 약 1.5배 이내이면 작업이 가벼운 것입니다; 그 이상이면 작업이 병목이며 최적화할 가치가 있습니다. 여러 스레드에서는 작업이 하나의 스레드에서 실행되는 반면 디코딩은 여러 스레드에서 실행되는지도 확인하세요.
측정 예: 7700만 건의 여행 데이터에 대한 그룹바이가 단일 스레드에서 4.6초가 걸렸으며, 이 중 2.1초가 행별 작업(45%)이었습니다. 8개 스레드에서는 2.6초가 걸렸으며, 동일한 2.1초가 작업(82%)이었습니다. 하나의 스레드가 모든 배치를 소비했기 때문입니다. references/profiling.md에 레시피와 함정이 있습니다.
행별 루프 규칙
각 규칙은 dista 셔플 벤치마크(7700만 행, 로컬 테이블, 단일 스레드)에서 절약한 시간과 함께 제공됩니다. 코드는 references/hot-loops.md에 있습니다.
| 규칙 | 측정된 효과 |
|---|---|
각 열의 원시 포인터를 배치당 한 번 가져온 다음 포인터를 인덱싱합니다. 루프 내부에서 List 또는 Tuple[List, List]를 인덱싱하지 마세요. | 전체 행별 작업 시간 1.40 → 0.78초 |
| 하나의 키가 축적하는 모든 것을 하나의 슬롯에 나란히 유지합니다(구조체 배열). 병렬 배열을 사용하지 마십시오. 그러면 한 번의 업데이트가 하나의 캐시 라인에만 닿습니다. | 다음 규칙과 함께 2.09 → 1.40초 |
분기 없이 필터링합니다: 모든 행을 계산하고, kept 인덱스에 쓰고, kept를 Int(keep)만큼 증가시킵니다. 그런 다음 유지된 행을 두 번째의 타이트한 루프에서 추가합니다. | 위 규칙과 함께 측정됨 |
키가 작은 정수(zone × zone × hour는 170만)인 경우, Dict 대신 밀집 배열을 키로 인덱싱합니다. | 단독 측정되지 않음; 두 버전 모두 밀집 배열 사용 |
| 최적화하는 동안 부동소수점 덧셈의 순서를 변경하지 마세요. 그러면 결과가 이전 결과와 비트 단위로 일치해야 하며, 이는 무료 정확성 검사입니다. | 위의 모든 단계가 정확히 일치함 |
전체 파이프라인 규칙
| 규칙 | 측정된 효과 |
|---|---|
| 여러 스레드가 디코딩하는 것을 하나의 스레드가 소비하도록 하지 마세요. 각 작업자에게 자체 누산기를 제공하고 마지막에 병합합니다(dista의 맵 태스크처럼). | 16개 스레드는 AWS에서 단일 프로세스 그룹바이를 202초에서 126초로만 줄임 |
하나의 디코딩 스레드에서도 여러 읽기를 동시에 진행합니다: ScanOptions.files_at_once(및 overlap_io)를 iceberg.mojo에서 사용하세요. | S3에서 읽는 단일 스레드에서 6.6–6.9배 |
| 공유 C 라이브러리를 프로세스당 한 번 로드하고 핸들을 유지하며, 호출당 로드하지 마세요. | zstd가 호출마다 열릴 때 스캔 시간의 52%가 dyld에서 소비됨; 수정 후 2,687 → 262ms |
| 바이트 단위로 반복되는 구문 분석 결과(예: 매니페스트의 Avro 스키마)를 캐시하세요. | 매니페스트 계획의 고정 비용이 3.6배 감소(113.7 → 31.9µs) |
| 레코드별로 할당하지 마세요: 재사용된 슬롯으로 디코딩하세요. | Avro 리더가 커서 전에 레코드당 약 60번 할당함 |
| 한계를 인식하세요: 몇 개의 코어를 넘으면 스캔은 메모리 대역폭에 의해 제한되며, 더 많은 스레드가 도움이 되지 않습니다. 대신 더 적은 바이트를 읽으세요. | Iceberg 스캔이 단일 코어에서 35.8ms, 4개 코어에서 12.1ms로 약 3배 |
references/case-studies.md는 각 사례에 대한 이야기와 문제를 알려준 징후를 설명합니다.
리더, 라이터 및 코덱
디코딩 자체가 느린 부분일 때, 규칙은 주로 버려지는 작업을 수행하지 않는 것에 관한 것입니다. 세부 사항과 수치는 references/readers-and-codecs.md에 있습니다.
- 각 빠른 경로의 게이트가 이를 도달하는 호출자에 의해 충족될 수 있는지 확인하고, 계측을 통해 실행됨을 증명하세요. 게이트를 어떤 리스트 열도 충족할 수 없었던 벡터화된 경로는 9KiB 열을 읽기의 43%로 남겼습니다.
- 이후 결정이 일반적으로 폐기하는 것을 계산하지 마세요. 일반 인코딩을 선택하기 위해 딕셔너리를 구축하는 것은 int64 열 쓰기의 67%를 차지했습니다; 이를 제거하면 출력이 바이트 단위로 동일하면서 쓰기가 3.3배 빨라졌습니다.
- 복사 대신 빌리고, 최종 레이아웃으로 직접 디코딩하세요(예: 정의 수준을 유효성 비트맵으로).
- 데이터에 실행(run)이 있는 경우 실행 단위로 작업하세요: 1% null에서 약 250번의
memmove가 약 25,000번의 슬롯별 분기를 대체합니다. - 단계가 이미 메모리 대역폭에 도달했을 때를 인식하세요: 18.3MB를 1.2ms에 입출력하여 약 30GB/s를 이동한 단계는 건너뛸 수밖에 없었습니다.
- 스레드를 추가하기 전에 가장 느린 항목을 찾으세요. 열 청크로 분할하면 하나의 큰 열이 항상 실행되었기 때문에 한 읽기가 1.39배로 제한되었습니다.
최적화뿐만 아니라 결과도 테스트하세요: 의도적으로 최적화를 깨뜨리고 테스트가 실패하는지 확인하세요 (references/testing-optimizations.md). Mojo 자체의 함정(예: 마지막 사용 시 값이 사라지는 현상, MutUntrackedOrigin)은 references/mojo-hazards.md에 있습니다.
신뢰할 수 있는 수치 얻기
references/measurement.md에 전체 규칙이 있습니다. magmalake에서 결론을 바꾼 규칙은 다음과 같습니다:
- 중앙값(p50)을 p90과 함께 보고하고, 평균만 보고하지 마세요. 하나의 느린 첫 호출이 평균을 30% 이동시키고 p50은 전혀 이동시키지 않았습니다.
- 벤치마크를 미리 빌드하고,
mojo run으로 시간을 측정하지 마세요. JIT가 한 벤치마크를 1.9배 부풀렸습니다. - 머신이 안정적인지 확인하세요. 동일한 벤치마크가 유휴 상태에서 31ms, 16개의 바쁜 스레드 옆에서 89ms가 걸렸습니다.
- 참조의 API와 스레드 수를 명시하세요.
pq.read_table은 pyarrow의 데이터셋 스캐너이지 parquet-cpp의 읽기 경로가 아니며, pyarrow의 스레드 설정이 한 파일의 읽기를 2.9배 이동시켰습니다. - 두 수치가 일치하지 않을 때, 차이를 설명하기 전에 동일한 방식으로 생성되었는지 확인하세요.
성능 향상을 주장하기 전에
- 결과를 비교하세요 전후, 가능한 경우 정확히 동일하게(마지막 루프 규칙 참조). 더 빠른 잘못된 답변은 느린 올바른 답변보다 나쁩니다.
- 두 수치와 설정을 보고하세요: 행, 스레드, 로컬 디스크 또는 S3, 캐시가 웜 상태였는지 여부.
- 남은 것을 말하세요. 셔플 수정 후 777ms 중 325ms는 여전히 열 복사에 사용되며, 여러 스레드에서도 작업은 여전히 하나의 스레드에서 수행됩니다.