한 번 세기 위해 모든 단어를 두 번 해싱했습니다
지난주에 로그 파서를 프로파일링했습니다. 일일 로그에서 에러 코드를 집계하는 프로그램입니다. 핵심 로직은 간단한 딕셔너리 업데이트입니다.
대부분의 개발자는 다음과 같이 작성합니다:
if (counts.TryGetValue(code, out int c))
counts[code] = c + 1;
else
counts[code] = 1;
이 코드는 키를 두 번 해싱합니다. TryGetValue는 해시를 계산하고 버킷을 탐색하여 항목을 찾습니다. 그런 다음 인덱서가 값을 업데이트하기 위해 정확히 똑같은 작업을 다시 수행합니다. 토큰당 동일한 키와 동일한 해시에 대해 CPU 사이클을 두 번 낭비하게 됩니다.
두 번째 과정을 건너뛸 수 있습니다. CollectionsMarshal.GetValueRefOrAddDefault를 사용하세요.
ref int slot = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, code, out _);
slot++;
이 메서드는 한 단계로 슬롯을 찾거나 생성합니다. 저장소에 대한 참조를 직접 제공하므로, 해싱과 버킷 탐색을 한 번만 수행한 뒤 값을 제자리에서(in place) 변경할 수 있습니다.
500만 개의 토큰으로 테스트했습니다.
결과: • TryGetValue + indexer: 160.0 ms • GetValueRefOrAddDefault: 95.0 ms
단일 조회 버전이 1.7배 더 빠릅니다.
중요한 점은 메모리 할당량은 동일하게 유지되었다는 것입니다. 이 트릭은 메모리를 절약하지 않습니다. 오직 CPU만 절약합니다. 가비지 컬렉션 때문에 코드가 느리다면 이 변경은 아무런 효과가 없습니다. 하지만 집계 작업이 많아서 코드가 느리다면 도움이 됩니다.
루프 내에서 기존 키에 대한 업데이트가 많이 발생하는 경우에 사용하세요. 삽입 대비 업데이트 비율이 높아질수록 이점은 커집니다.
주의사항: 이 참조는 딕셔너리 내부 저장소를 가리킵니다. 다음 구조적 변경(structural change)이 발생하기 전까지만 유효합니다. 키를 추가하거나 제거하는 경우 참조를 유지하지 마세요. 참조를 가져와 값을 변경한 뒤 바로 넘어가야 합니다.
대부분의 작업에는 TryGetValue를 사용하세요. 읽기가 더 쉽습니다. 딕셔너리 루프가 성능 병목 지점(bottleneck)일 때만 ref 버전을 사용하세요.
Source: https://dev.to/ssukhpinder/i-hashed-every-word-twice-to-count-it-once-1mg9
