v0.74.0 — 공간을 여는 비용이 제곱으로 메모리를 먹지 않습니다
v0.72.0 은 남의 서버를 기다리는 문제를 걷어냈습니다. 이번에는 남은 하나, 메모가 많아질
때의 문제입니다.
무엇이 비쌌나
캔버스를 열면 메모마다 "이것과 닮은 생각이 몇 개"라는 숫자를 붙입니다. 그 숫자는
PerNoteCounts 가 계산합니다.
재 봤습니다. 32코어, 192차원 로컬 임베딩:
메모 수 시간 메모리
2,000 54ms 17MB
4,000 233ms — (시간만 쟀습니다)
8,000 968ms 260MB
260MB. 요청 하나가, 끝나면 버릴 숫자 배열에.
원인은 모든 쌍의 점수를 표로 만들어 들고 있었던 것입니다. 8천 개면 쌍이 3,200만 개고
float64 로 260MB 입니다. 메모 수의 제곱으로 자랍니다.
고친 것
표를 만들지 않습니다. 한 줄씩 계산하고 버립니다. 한 줄은 메모 수만큼의 float 입니다.
그리고 줄끼리는 서로를 참조하지 않습니다 — 표를 쓸 때는 참조했지만, 이제는 아닙니다. 그래서
코어에 나눠 돌립니다.
메모 수 전 후
2,000 54ms/17MB → 14.7ms/2.1MB
4,000 233ms → 52.6ms
8,000 968ms/260MB → 211ms/5.7MB
숫자는 하나도 바뀌지 않았습니다
이건 반올림 운이 아닙니다. 짚어야 할 두 가지가 있었습니다.
첫째, 한 쌍을 어느 쪽에서 곱하는가. 전에는 대각선 아래쪽 절반을 반대편에서 읽었습니다
(sparse[j]·dense[i]). 지금은 항상 그 줄의 주인에서 곱합니다 (sparse[i]·dense[j]). 두
값이 정확히 같은 이유는, 양쪽 다 0 이 아닌 차원을 오름차순으로 훑고, 상대가 0 인 항은
진짜 0 을 더하기 때문입니다 — float64 누산기는 0 을 더해도 흔들리지 않습니다.
둘째, 합의 순서. 부동소수점 덧셈은 결합법칙이 성립하지 않습니다. 그래서 한 줄의 합은
절대 goroutine 사이에서 합치지 않습니다. 줄 하나는 통째로 한 goroutine 안에서, 예전과
같은 순서로 더합니다. 나누는 것은 줄 단위뿐입니다.
기존 동치성 테스트는 n≤200 까지만 돌고 있었습니다. 분할이 시작되는 크기를 넘지 않아서, 새
경로를 전혀 지나지 않았습니다. n=600 을 추가하고, 그 크기에서 실제로 분할이 일어나는지를
테스트가 직접 단언하게 했습니다 — 임계값을 올리면 이 커버리지가 조용히 사라지기 때문입니다.
CI 에 없던 것
이 변경으로 이 저장소에 동시성 코드가 사람의 경로 위에 처음 올라왔습니다. 그런데 CI 는
-race 를 돌고 있지 않았습니다.
확인 방법은 변이였습니다. 줄을 나누는 보폭을 i += workers 에서 i++ 로 바꾸면, 모든
worker 가 모든 줄을 계산합니다 — 답은 매번 정확히 맞습니다. 같은 칸에 같은 값을 쓰기
때문입니다. 일반 테스트로는 잡히지 않고, -race 만 잡습니다.
그래서 CI 에 넣었습니다 (13초).
경계선
점수가 커트라인에 정확히 걸리면 세는가. 무작위 벡터로는 영원히 안 걸리는 경우라, 손으로
만들었습니다 — 생각 3개면 각 줄의 표본이 2개뿐이라 상대 척도가 안 서고 커트라인이 상수로
떨어지므로, 첫 쌍이 그 상수와 같은 점수가 나오도록 벡터를 짰습니다. 카드에 찍히는 숫자를
가르는 경계라서, 어느 부등호를 썼는지에 맡기지 않고 못 박았습니다.
여전히 제곱입니다
각 생각이 긋는 선은 그 생각의 전체 분포에서 나옵니다. 평균을 알려면 모든 쌍이 필요하고,
그래서 건너뛸 수 있는 쌍이 없습니다. 빨라지고 가벼워졌을 뿐, 차수는 그대로입니다.
손대지 않은 것
Clusters는Cutoff를 거치며 여전히 쌍의 표를 만듭니다. 캔버스를 여는 경로가 아니라
별도 화면이고, 정확히 같은 숫자를 유지하면서 표를 없애려면 점곱을 두 번 하게 되어
느려집니다. 바꾸지 않았습니다.onlyRenderVisibleElements— 여전히 꺼져 있습니다. PNG·PDF 내보내기가 캔버스 DOM 을
그대로 찍기 때문에 화면 밖 메모가 조용히 빠질 수 있고, 확인하지 않았습니다.
What's Changed
Full Changelog: v0.73.0...v0.74.0