plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


cf9073a — Peter John 2026-11-01T22:49:06+05:30
perf: Map is an insertion-ordered open-addressing hash table (dense entries + probe index), grows at 75%
Files changed (4) hide show
  1. plum-std/Http.plum +6 -15
  2. plum-std/Json.plum +2 -9
  3. plum-std/Map.plum +265 -85
  4. plum-std/Set.plum +3 -9
plum-std/Http.plum CHANGED
@@ -48,21 +48,12 @@ extern fun rawHttpRequest(method: Str, url: Str, headers: Str, body: Str) -> Str
48
48
  # expects for its own `headers` argument.
49
49
  fun encodeHeaders(headers: Map[Str, Str]) -> Str =
50
50
  buf := Buffer()
51
- i := 0
52
- while i < headers.buckets.length()
53
- bucket := headers.buckets.get(i)
54
- j := 0
55
- while j < bucket.size
51
+ for k, v := range headers
56
- pair := bucket.data.get(j)
57
- k := pair.key
58
- v := pair.val
59
- if buf.length() > 0
52
+ if buf.length() > 0
60
- buf.write("\n")
53
+ buf.write("\n")
61
- buf.write(k)
54
+ buf.write(k)
62
- buf.write(": ")
55
+ buf.write(": ")
63
- buf.write(v)
56
+ buf.write(v)
64
- j = j + 1
65
- i = i + 1
66
57
  return buf.toStr()
67
58
 
68
59
  # The reverse of `encodeHeaders`, for the response headers `rawHttpRequest`
plum-std/Json.plum CHANGED
@@ -340,15 +340,8 @@ record JsonParser(src: Str, pos: Int, text: Str) =
340
340
  # reference (as `appendJsonPair` does to `buf`) does.
341
341
  fun jsonMapToStr(pairs: Map[Str, Json]) -> Str =
342
342
  buf := Buffer()
343
- i := 0
344
- while i < pairs.buckets.length()
345
- bucket := pairs.buckets.get(i)
346
- j := 0
347
- while j < bucket.size
343
+ for k, v := range pairs
348
- pair := bucket.data.get(j)
349
- appendJsonPair(buf, pair.key, pair.val)
344
+ appendJsonPair(buf, k, v)
350
- j = j + 1
351
- i = i + 1
352
345
  return buf.toStr()
353
346
 
354
347
  fun appendJsonPair(buf: Buffer, k: Str, v: Json) -> Unit =
plum-std/Map.plum CHANGED
@@ -4,87 +4,153 @@ import std/Array
4
4
  import std/Int
5
5
  import std/Float
6
6
  import std/Str
7
+ import std/Buffer
7
8
 
8
- # A Pair is a grouping of a key with a value.
9
+ # A Pair is a grouping of a key with a value. `k => v` sugar builds Pairs for
9
- #
10
- # `toStr` exists because `List[T]` calls `toStr` on its elements (duck-typed)
10
+ # `Map(...)`'s variadic init; a Map itself stores no Pairs.
11
- # and `Map` stores `List[Pair[K, V]]` buckets.
12
11
  record Pair[K, V](key: K, val: V) =
13
12
 
14
13
  fun toStr(self) -> Str =
15
14
  return "{self.key.toStr()}: {self.val.toStr()}"
16
15
 
17
- # A Map is a hash table: `BUCKET_COUNT` buckets, each an association-list
16
+ # A Map is an open-addressing hash table with an insertion-ordered dense entry
17
+ # store (the layout of Python's dict):
18
+ #
19
+ # index : Array[Int] power-of-two sized probe table (capacity, starts at 16).
20
+ # 0 = empty, 1 = tombstone (deleted), e + 2 = entry e.
18
- # chain (`List[Pair[K, V]]`) of every key hashing into it — O(1) average
21
+ # hashes : Array[Int] per entry: the mixed hash, compared before `==` so
22
+ # most mismatches never call `==`, and reused on rehash.
23
+ # keys : Array[K] per entry (parallel arrays; `Array[V]` of a struct V is
24
+ # vals : Array[V] stored field by field, so inserting allocates nothing).
25
+ # alive : Array[Bool] per entry: false once deleted.
26
+ # used : Int entries appended so far, dead ones included.
27
+ # size : Int live entries.
28
+ #
19
- # `get`/`set`/`remove` (versus the single, whole-map linear scan a plain
29
+ # Entries are appended in insertion order; `index` is probed linearly. A delete
30
+ # writes a tombstone into `index` and clears `alive`. The entry arrays hold
31
+ # capacity * 3/4 slots, so `used == capacity * 3/4` is the 75% load limit counting
32
+ # tombstones (every dead entry owns at most one tombstone): at that point the
33
+ # table is rebuilt, doubling the capacity if at least half the slots are live,
34
+ # or just compacting dead entries away otherwise. A tombstone met while
35
+ # inserting a new key is reused.
20
- # association list costs), degrading to O(n) only if many keys collide into
36
+ # Iteration order is insertion order (a re-`set` of an existing key keeps its
21
- # the same bucket.
37
+ # original position; a removed-then-re-added key goes to the end).
22
- # `K` must have a `hash() -> Int` method (duck-typed, checked per instantiation).
38
+ # `K` must have a `hash() -> Int` method (duck-typed) and `==`.
23
- record Map[K, V](buckets: Array[List[Pair[K, V]]], size: Int) =
39
+ record Map[K, V](index: Array[Int], hashes: Array[Int], keys: Array[K], vals: Array[V], alive: Array[Bool], used: Int, size: Int) =
24
-
25
- # Number of buckets every `Map` allocates at construction — fixed for the
26
- # map's whole lifetime, no rehashing/resizing as it grows. A real hash table
27
- # would grow this as `size` outpaces it (keeping average bucket length, and
28
- # so average `get`/`set` cost, roughly constant); that's future work; for now
29
- # a big-enough fixed count keeps small-to-medium maps fast in practice.
30
- BUCKET_COUNT = 16
31
40
 
32
41
  # `Map[Str, Int]()` builds an empty map; `Map(Pair("a", 1), ...)` infers
33
42
  # `K`/`V` from its entries (a later duplicate key wins, as in `set`).
34
- # Builds `BUCKET_COUNT` empty bucket lists up front via `Array`'s `push`-to-grow.
35
43
  fun init(entries: ...Pair[K, V]) -> Map[K, V] =
36
- b := Array[List[Pair[K, V]]]()
44
+ m := Map(index: Array[Int](), hashes: Array[Int](), keys: Array[K](), vals: Array[V](), alive: Array[Bool](), used: 0, size: 0)
37
- i := 0
38
- while i < BUCKET_COUNT
39
- b = b.push(List[Pair[K, V]]())
40
- i = i + 1
41
- m := Map(buckets: b, size: 0)
45
+ m.allocate(16)
42
46
  for e := range entries
43
47
  m.set(e.key, e.val)
44
48
  return m
45
49
 
50
+ # Installs empty storage for `cap` index slots (a power of two).
51
+ fun allocate(self, cap: Int) -> Unit =
52
+ n := cap - cap / 4
53
+ self.index = Array[Int]().resize(cap)
54
+ self.hashes = Array[Int]().resize(n)
55
+ self.keys = Array[K]().resize(n)
56
+ self.vals = Array[V]().resize(n)
57
+ self.alive = Array[Bool]().resize(n)
58
+ self.used = 0
59
+ self.size = 0
60
+
46
- # `k`'s bucket index — `hash`'s sign is unconstrained (a plain `%` on a
61
+ # `k`'s hash, avalanched so identity hashes (`Int`) and sequential keys
47
- # negative `Int` stays negative in Plum, matching wasm's signed
48
- # `i64.rem_s`), so this folds a negative result back into `[0,
62
+ # spread across the low bits that pick the slot.
49
- # BUCKET_COUNT)` rather than indexing `buckets` out of range.
50
- fun bucketIndex(self, k: K) -> Int =
63
+ fun hashOf(self, k: K) -> Int =
51
- m := k.hash() % BUCKET_COUNT
64
+ h := k.hash()
52
- return if m < 0 then m + BUCKET_COUNT else m
65
+ h = {h ^ {h >> 32}} * 0x5851f42d4c957f2d
66
+ return h ^ {h >> 29}
67
+
68
+ # The `index` slot holding `k` (whose hash is `h`), or -1.
69
+ fun findSlot(self, k: K, h: Int) -> Int =
70
+ mask := self.index.length() - 1
71
+ slot := h & mask
72
+ while true
73
+ x := self.index.get(slot)
74
+ if x == 0
75
+ return -1
76
+ if x >= 2
77
+ e := x - 2
78
+ if self.hashes.get(e) == h && self.keys.get(e) == k
79
+ return slot
80
+ slot = {slot + 1} & mask
81
+ return -1
53
82
 
54
83
  # Gets the value for `k`, or fails with `KeyNotFound` if absent.
55
84
  fun get(self, k: K) -> error{KeyNotFound}!V =
56
- bucket := self.buckets.get(self.bucketIndex(k))
85
+ slot := self.findSlot(k, self.hashOf(k))
57
- i := 0
58
- while i < bucket.size
59
- pair := bucket.data.get(i)
60
- if pair.key == k
86
+ if slot < 0
61
- return pair.val
62
- i = i + 1
63
- return error.KeyNotFound
87
+ return error.KeyNotFound
88
+ return self.vals.get(self.index.get(slot) - 2)
64
89
 
65
90
  # Gets the value for `k`, or null if absent.
66
91
  fun getOrNull(self, k: K) -> V? =
67
92
  return self.get(k) catch return null
68
93
 
69
94
  fun has(self, k: K) -> Bool =
95
+ return self.findSlot(k, self.hashOf(k)) >= 0
96
+
97
+ # Rebuilds the table when the entry arrays are full: doubles the capacity if
98
+ # at least half the entries are live, else compacts at the same capacity.
99
+ fun rebuild(self) -> Unit =
100
+ cap := self.index.length()
101
+ oldHashes := self.hashes
102
+ oldKeys := self.keys
103
+ oldVals := self.vals
104
+ oldAlive := self.alive
105
+ oldUsed := self.used
106
+ n := cap - cap / 4
107
+ newCap := cap
108
+ if self.size * 2 >= n
109
+ newCap = cap * 2
110
+ self.allocate(newCap)
111
+ mask := newCap - 1
112
+ e := 0
113
+ while e < oldUsed
114
+ if oldAlive.get(e)
115
+ h := oldHashes.get(e)
116
+ slot := h & mask
117
+ while self.index.get(slot) != 0
118
+ slot = {slot + 1} & mask
119
+ j := self.used
120
+ self.index.set(slot, j + 2)
121
+ self.hashes.set(j, h)
122
+ self.keys.set(j, oldKeys.get(e))
123
+ self.vals.set(j, oldVals.get(e))
124
+ self.alive.set(j, true)
70
- self.get(k) catch e
125
+ self.used = j + 1
71
- return false
72
- return true
126
+ e = e + 1
73
-
127
+ self.size = self.used
128
+
74
- # Sets `k`'s value to `v` — replacing an existing entry for `k` in place
129
+ # Sets `k`'s value to `v`, replacing an existing entry for `k` in place.
75
- # (mutating the stored `Pair`'s `val` field) rather than appending a
76
- # duplicate, so a repeated `set` on the same key doesn't grow its bucket
77
- # unboundedly or leave a stale value reachable by `get`.
78
130
  fun set(self, k: K, v: V) -> Unit =
79
- bucket := self.buckets.get(self.bucketIndex(k))
80
- i := 0
81
- while i < bucket.size
131
+ h := self.hashOf(k)
82
- pair := bucket.data.get(i)
132
+ slot := self.findSlot(k, h)
83
- if pair.key == k
133
+ if slot >= 0
84
- pair.val = v
134
+ self.vals.set(self.index.get(slot) - 2, v)
85
- return
135
+ return
136
+ if self.used >= self.hashes.length()
137
+ self.rebuild()
138
+ mask := self.index.length() - 1
139
+ slot = h & mask
140
+ tomb := -1
141
+ while self.index.get(slot) != 0
142
+ if tomb < 0 && self.index.get(slot) == 1
143
+ tomb = slot
144
+ slot = {slot + 1} & mask
145
+ if tomb >= 0
146
+ slot = tomb
147
+ e := self.used
148
+ self.index.set(slot, e + 2)
149
+ self.hashes.set(e, h)
150
+ self.keys.set(e, k)
151
+ self.vals.set(e, v)
152
+ self.alive.set(e, true)
86
- i = i + 1
153
+ self.used = e + 1
87
- bucket.push(Pair(key: k, val: v))
88
154
  self.size = self.size + 1
89
155
 
90
156
  # Sets `k`'s value to `v` only if `k` isn't already present.
@@ -92,16 +158,13 @@ record Map[K, V](buckets: Array[List[Pair[K, V]]], size: Int) =
92
158
  if !self.has(k)
93
159
  self.set(k, v)
94
160
 
95
- # Removes `k`'s entry, if present.
161
+ # Removes `k`'s entry, if present (leaves a tombstone).
96
162
  fun remove(self, k: K) -> Unit =
97
- bucket := self.buckets.get(self.bucketIndex(k))
163
+ slot := self.findSlot(k, self.hashOf(k))
98
- i := 0
164
+ if slot >= 0
99
- while i < bucket.size
100
- if bucket.data.get(i).key == k
165
+ self.alive.set(self.index.get(slot) - 2, false)
101
- bucket.removeAt(i)
166
+ self.index.set(slot, 1)
102
- self.size = self.size - 1
167
+ self.size = self.size - 1
103
- return
104
- i = i + 1
105
168
 
106
169
  fun length(self) -> Int =
107
170
  return self.size
@@ -110,28 +173,18 @@ record Map[K, V](buckets: Array[List[Pair[K, V]]], size: Int) =
110
173
  return self.size == 0
111
174
 
112
175
  fun clear(self) -> Unit =
113
- i := 0
114
- while i < self.buckets.length()
115
- self.buckets.get(i).clear()
116
- i = i + 1
117
- self.size = 0
176
+ self.allocate(16)
118
177
 
119
- # `for k, v := range m` (or `m.entries()`): every key with its value, in bucket
178
+ # `for k, v := range m` (or `m.entries()`): every key with its value, in
120
- # order, then insertion order within a bucket. One loop, so `break` and
179
+ # insertion order. One loop, so `break` and `continue` in the body act on the
121
- # `continue` in the body act on the whole walk; the cursor moves before the
180
+ # whole walk; the cursor moves before the `yield` for the same reason.
122
- # `yield` for the same reason.
123
181
  fun* entries(self) *(K, V) =
124
182
  i := 0
183
+ while i < self.used
125
- j := 0
184
+ e := i
126
- while i < self.buckets.length()
127
- bucket := self.buckets.get(i)
128
- if j >= bucket.size
129
- i = i + 1
185
+ i = i + 1
130
- j = 0
131
- continue
132
- pair := bucket.data.get(j)
186
+ if self.alive.get(e)
133
- j = j + 1
134
- yield pair.key, pair.val
187
+ yield self.keys.get(e), self.vals.get(e)
135
188
 
136
189
  # `for k := range m.keys()`.
137
190
  fun* keys(self) *K =
@@ -143,6 +196,8 @@ record Map[K, V](buckets: Array[List[Pair[K, V]]], size: Int) =
143
196
  for k, v := range self
144
197
  yield v
145
198
 
199
+ struct MapPoint(x: Float, y: Float)
200
+
146
201
  test "get/set/has round-trip and set replaces an existing key in place"
147
202
  m := Map[Str, Int]()
148
203
  assert m.has("a") == false
@@ -333,3 +388,128 @@ test "init_negative_int_keys_and_duplicates"
333
388
  assert m.length() == 2
334
389
  assert m.get(-5)! == "c"
335
390
  assert m.get(-17)! == "b"
391
+
392
+ test "100k inserts, every key found, 100k missing keys absent"
393
+ m := Map[Int, Int]()
394
+ i := 0
395
+ while i < 100000
396
+ m.set(i, i * 2)
397
+ i = i + 1
398
+ assert m.length() == 100000
399
+ i = 0
400
+ bad := 0
401
+ while i < 100000
402
+ if m.get(i)! != i * 2
403
+ bad = bad + 1
404
+ i = i + 1
405
+ assert bad == 0
406
+ i = 100000
407
+ found := 0
408
+ while i < 200000
409
+ if m.has(i)
410
+ found = found + 1
411
+ i = i + 1
412
+ assert found == 0
413
+
414
+ test "delete half, reinsert, tombstones are reused and lookups stay right"
415
+ m := Map[Int, Int]()
416
+ i := 0
417
+ while i < 20000
418
+ m.set(i, i)
419
+ i = i + 1
420
+ i = 0
421
+ while i < 20000
422
+ m.remove(i)
423
+ i = i + 2
424
+ assert m.length() == 10000
425
+ i = 0
426
+ bad := 0
427
+ while i < 20000
428
+ if m.has(i) != {i % 2 == 1}
429
+ bad = bad + 1
430
+ i = i + 1
431
+ assert bad == 0
432
+ i = 0
433
+ while i < 20000
434
+ m.set(i, i + 1)
435
+ i = i + 2
436
+ assert m.length() == 20000
437
+ i = 0
438
+ while i < 20000
439
+ if m.get(i)! != {if i % 2 == 0 then i + 1 else i}
440
+ bad = bad + 1
441
+ i = i + 1
442
+ assert bad == 0
443
+
444
+ test "repeated set/remove of a few keys never grows the table without bound"
445
+ m := Map[Int, Int]()
446
+ i := 0
447
+ while i < 100000
448
+ m.set(i % 7, i)
449
+ m.remove(i % 7)
450
+ i = i + 1
451
+ assert m.length() == 0
452
+ m.set(3, 9)
453
+ assert m.get(3)! == 9
454
+ assert m.index.length() == 16
455
+
456
+ test "growth past several doublings"
457
+ m := Map[Int, Int]()
458
+ assert m.index.length() == 16
459
+ i := 0
460
+ while i < 1000
461
+ m.set(i, i)
462
+ i = i + 1
463
+ assert m.index.length() == 2048
464
+ assert m.length() == 1000
465
+
466
+ test "Map[Str, Int] with Buffer-built keys"
467
+ m := Map[Str, Int]()
468
+ i := 0
469
+ while i < 2000
470
+ b := Buffer()
471
+ b.write("k")
472
+ b.write(i.toStr())
473
+ m.set(b.toStr(), i)
474
+ i = i + 1
475
+ assert m.length() == 2000
476
+ assert m.get("k1999")! == 1999
477
+ assert m.has("k2000") == false
478
+
479
+ test "Map[Int, MapPoint] struct values"
480
+ m := Map[Int, MapPoint]()
481
+ i := 0
482
+ while i < 500
483
+ m.set(i, MapPoint(x: Float(i), y: Float(i) * 2.0))
484
+ i = i + 1
485
+ v := m.get(499)!
486
+ assert v.x == 499.0
487
+ assert v.y == 998.0
488
+ m.set(499, MapPoint(x: 1.0, y: 2.0))
489
+ assert m.get(499)!.y == 2.0
490
+ assert m.length() == 500
491
+
492
+ test "iteration is insertion order, skipping removed entries"
493
+ m := Map[Int, Int]()
494
+ m.set(30, 1)
495
+ m.set(10, 2)
496
+ m.set(20, 3)
497
+ m.set(10, 4)
498
+ m.remove(30)
499
+ m.set(30, 5)
500
+ order := ""
501
+ for k, v := range m
502
+ order = "{order}{k}={v} "
503
+ assert order == "10=4 20=3 30=5 "
504
+
505
+ test "clear then reuse"
506
+ m := Map[Int, Int]()
507
+ i := 0
508
+ while i < 100
509
+ m.set(i, i)
510
+ i = i + 1
511
+ m.clear()
512
+ assert m.length() == 0
513
+ assert m.has(5) == false
514
+ m.set(5, 1)
515
+ assert m.get(5)! == 1
plum-std/Set.plum CHANGED
@@ -31,17 +31,11 @@ record Set[T](map: Map[T, Bool]) =
31
31
  fun clear(self) -> Unit =
32
32
  self.map.clear()
33
33
 
34
- # Every member, in no particular order.
34
+ # Every member, in insertion order.
35
35
  fun toList(self) -> List[T] =
36
36
  result := List[T]()
37
- i := 0
38
- while i < self.map.buckets.length()
39
- bucket := self.map.buckets.get(i)
37
+ for k := range self.map.keys()
40
- j := 0
41
- while j < bucket.size
42
- result.push(bucket.data.get(j).key)
38
+ result.push(k)
43
- j = j + 1
44
- i = i + 1
45
39
  return result
46
40
 
47
41
  test "set_dedup: adding duplicates keeps one copy"