Tema
/
Algoritmai ir duomenų struktūros GO
Pamoka 10 / 15 Maišos lentelės ~50 min
Teorija

transit: kaip renkamasi indeksą

transit — Vilniaus viešojo transporto maršrutų paieškos serveris — turi tą patį klausimą, kurį ką tik sprendei: kaip indeksuoti 1 531 stotelę. Jis atsakė du kartus, skirtingiems klausimams, ir vienas iš atsakymų yra klaidingas lietuviškai — sąmoningai, amžinai ir su testais, kurie neleidžia to pataisyti.

Antraštė: baitai nėra raidės

Prisimeni struktūrinio programavimo kurso septintą pamoką? len("ąžuolas") = 9, nors raidžių 7. Tai buvo to kurso centrinė spąstų vieta. Štai kas atsitinka, kai ta pati klaida patenka į paieškos indeksą.

Stotelių paieška privalo tenkinti tris reikalavimus:

įvedus "zirmunu"  turi rasti "Žirmūnų"   (diakritikų į paieškos laukelį niekas neveda)
įvedus "žirmūnų"  turi rasti "Žirmūnų"   (bet jei įveda — irgi turi veikti)
įvedus "ŽIRMŪNŲ"  turi rasti "Žirmūnų"   (registras nesvarbus abiem kryptimis)

Pirmas bandymas — įprastas C stiliaus ciklas per baitus:

func foldBytes(s string) string {
	b := []byte(s)
	for i := 0; i < len(b); i++ {
		if b[i] >= 'A' && b[i] <= 'Z' {
			b[i] += 'a' - 'A'
		}
	}
	return string(b)
}

Ir kas iš to išeina:

"Ž"  baitai C5 BD  runos 1  ilgis 2
"ž"  baitai C5 BE  runos 1  ilgis 2

"Katedros"  foldBytes -> "katedros"     Fold -> "katedros"
"Žirmūnų"   foldBytes -> "Žirmūnų"      Fold -> "zirmunu"
"ŽIRMŪNŲ"   foldBytes -> "ŽirmŪnŲ"      Fold -> "zirmunu"

Įsižiūrėk į "ŽirmŪnŲ". Latiniškos raidės sumažėjo, lietuviškos ne. Ne todėl, kad kažkas buvo pamiršta, o todėl, kad Ž nėra baitas: tai C5 BD, o ž yra C5 BE. Jie skiriasi tik antruoju baitu, tad b[i] >= 'A' && b[i] <= 'Z' jų niekada nesujungs — jokiu pataisymu, jokia papildoma sąlyga.

Antra bėda ta pati, kitoje vietoje. Medis (trie) šakojasi kiekvienam baitui:

"Žirmūnų": 10 baitų, 7 runos -> baitų medis yra 10 lygių gylio, runų medis 7
mazgas 1 lygyje laiko baitą 0xC5, o tokios raidės niekas neįvedė

Trys papildomi lygiai, o kas trečiame — pusė UTF-8 sekos. Ne raidė. Ne kažkas, ką vartotojas galėtų parašyti.

Angliškuose duomenyse abi versijos elgiasi vienodai — būtent todėl klaida išgyvena testavimą. "Katedros" per abi funkcijas eina teisingai. Studentas lietuviškai įveda „žirmūnai" ir negauna nieko.

Taisymas — iteruoti runomis, nes for _, r := range s dekoduoja UTF-8:

for _, r := range s {          // runų iteracija
	r = unicode.ToLower(r) // Unicode-aware: 'Ž' -> 'ž', ne tuščias veiksmas
	if f, ok := ltFold[r]; ok {
		r = f
	}
	b.WriteRune(r)
}

Kaip transit neleidžia to „pataisyti"

Sugadinta versija repozitorijoje lieka visam laikui, nes ji yra pamokos pirmoji pusė. Bet ji negali netyčia patekti į veikiantį serverį — ji gyvena faile search_bytetrie_demo_test.go, o Go _test.go failus kompiliuoja tik go test. Serveris, CLI ar bet kokia būsima diagnostika negali jos net paminėti: kompiliacija nulūžtų.

Tai kompiliavimo meto garantija, o ne pavadinimų susitarimas ar kodo peržiūros taisyklė.

Ir du testai reikalauja, kad ji liktų sugedusi:

$ go test ./internal/store/ -run 'TestByteTrieFailsOnRealFeed|TestFoldBytesIsBrokenOnLithuanian' -v
=== RUN   TestFoldBytesIsBrokenOnLithuanian
--- PASS: TestFoldBytesIsBrokenOnLithuanian (0.00s)
=== RUN   TestByteTrieFailsOnRealFeed
    search_realfeed_test.go:99: byte trie nodes=4850, rune trie nodes=4173
--- PASS: TestByteTrieFailsOnRealFeed (13.31s)

Ta pati forma, kaip septintos pamokos atrankinis rikiavimas ir aštuntos quicksort: trūkumas įtvirtinamas testu, kad liktų matomas.

Kur maišos lentelė iš tikrųjų laimėjo

Dabar — pati struktūra. Kiek verta maišos lentelė vietoj tiesinio stotelių sąrašo? Izoliuotai (mūsų matavimas, docs/reference/transit-benchmarks.md):

vienos stotelės paieška, n = 1 531 mūsų matavimas
tiesinė 1.22 µs
maiša 7.02 ns
santykis 174×

174 kartai atrodo įtikinamai. Ir vis dėlto to neužtenka, ir transit repozitorija tai pasako pati: ir 1.22 µs, ir 7 ns žmogui yra nulis. Vienam paieškos veiksmui šis skirtumas nepastebimas.

Argumentas yra kitas. Kraunant maršrutų duomenis, kiekvienas stop_time įvykis turi išsiaiškinti savo stotelę — apie 207 000 paieškų vieno paleidimo metu. Ten skirtumas matomas:

viso duomenų įkėlimo trukmė mūsų matavimas (3 paleidimai)
tiesinis indeksas 1549 ms
maišos indeksas 1200 ms
skirtumas ~350 ms

Trečdalis sekundės kiekvieną kartą paleidžiant serverį. transit README skelbia 468 ms; mūsų kompiuteryje išėjo ~25 % mažiau, o trijų matavimų sklaida yra 165 ms, tad tikslesnio skaičiaus nei „maždaug trečdalis sekundės" čia nė nereikia bandyti.

Struktūrą pateisino ne izoliuotas santykis, o kur ji naudojama.

Kitas indeksas kitam klausimui

Yra ir antra struktūra: prefiksų medis (trie), kuris atsako į kitą klausimą. Maišos lentelė moka „ar yra tokia stotelė"; medis moka „kokios stotelės prasideda šiomis raidėmis" — o būtent tai daro paieškos laukelis, kai vartotojas rašo.

Jo nestatysi. Bet verta pamatyti, kaip priimamas sprendimas jo nenaudoti.

Autoįvedimo užklausai medis laimi aiškiai — nuo 7.9× (3 raidės) iki 22× (8 raidės). Bet vartotojas neužduoda vienos užklausos: jis rašo, ir kiekvienas paspaudimas yra nauja užklausa. Visai „zirmunai" rašymo sesijai (8 paspaudimai):

„zirmunai" sesija transit kompiuteris mūsų kompiuteris
tiesinis 130 µs ~135 µs
runų medis 250 µs ~42 µs
laimėtojas tiesinis medis, ~3.2×

Atsakymas apsivertė. Jų mašinoje medis sesiją pralaimėjo ~120 µs; mūsų — laimėjo ~93 µs. Skiriasi ne dydis, o kryptis.

Ir vis dėlto išvada abiem atvejais ta pati, nes argumentas niekada nebuvo santykis:

  • ten medis kainuoja ~120 µs, čia sutaupo ~93 µs;
  • abu skaičiai yra dešimtoji milisekundės dalis;
  • rašymo sesija žmogui trunka ~160 ms, o įdiegtoje programoje dar prisideda tinklo kelionė;
  • vadinasi, visas ginčas vyksta trys eilės žemiau už tai, su kuo lyginamas.

O medis kainuoja 777 KB prieš 113 KB atminties (6.9×) ir 3.1× ilgesnį pastatymą. Tai atlyginimo reikalavimas už skaičių, kurio nė vienoje mašinoje neįmanoma pajusti.

Teisinga struktūra tam klausimui — ir vis tiek neverta šiame dydyje.

Palygink tai su šeštąja pamoka

Šeštoje pamokoje matavimas taip pat priklausė nuo mašinos: tiesinės ir dvejetainės paieškos persikirtimo taškas juda. Bet ten jis keičia sprendimą, nes matavimas vyksta ties pačia riba.

Čia matavimas irgi juda, netgi apsiverčia — ir sprendimas nesikeičia, nes skirtumas yra tris eiles nuo bet kokios ribos.

Skirti šiuos du atvejus ir yra įgūdis:

Klausk ne „ar mano skaičius sutampa su kito", o „ar mano skaičius yra arti sprendimo ribos". Jei taip — matuok savo mašinoje. Jei ne — svetimas matavimas gali visai nesutapti su tavo, ir tai nieko nekeičia.

Skaičiai — iš docs/reference/transit-benchmarks.md (mūsų matavimas, i9-14900HX, go1.26.4). Atminties dydžiai 777 KB / 113 KB paimti iš paties transit testo TestSearchIndexMemoryFootprint ir mūsų nepermatuoti; ~160 ms rašymo sesija yra žmogaus sąveikos mastelis, ne matavimas.