ConcurrentMap
import { ConcurrentMap } from "velt/collections". A map from u64 or string keys to values
that many threads read and write at once. ConcurrentMap<K, V> (K and V are u64 when not
written): create it, share it with shared(ConcurrentMap.create(n)), and call its methods from
any task. A lookup writes no shared memory, so readers on many cores never slow each other, and
writers take a latch per node, never a lock for the whole map. For a map one task owns,
BTreeMap is simpler, holds any keys and values, and walks
them in order.
import { ConcurrentMap } from "velt/collections";
async function fill(m: shared<ConcurrentMap>, from: u64) {
for (let i: u64 = from; i < from + 1000; i++) {
m.set(i, i * 2);
}
}
const m = shared(ConcurrentMap.create(10000));
await Promise.all([spawn(fill(m, 0)), spawn(fill(m, 1000)), spawn(fill(m, 2000))]);
console.log(m.get(1500), m.has(3000), m.delete(10), m.get(10)); // 3000 false true undefined
const values: u64[] = [];
const found = m.getManyInto([1, 2, 9999], values);
console.log(found, values[0], values[1]); // 3 2 4
const names: ConcurrentMap<u64, string> = ConcurrentMap.create(100);
names.set(7, "seven");
console.log(names.get(7), names.read(7, (s) => s.length)); // seven 5
const ids: ConcurrentMap<string, u64> = ConcurrentMap.create(100);
ids.set("user:000123", 123);
ids.set("user:000124", 124);
ids.add("user:000123", 1);
console.log(ids.get("user:000123"), ids.has("user:0001"), ids.delete("user:000124")); // 124 false trueOperations
ConcurrentMap.create(capacity): an empty map with room forcapacityentries, about 40 bytes an entry (17 more for string keys), taken when it is created. Asetpast its room panics with aRangeError.createpanics with aRangeErrorabove 10^11 entries, and for keys other thanu64andstring.get(key): V | undefined,has(key).read(key, (v) => …): runs the callback on the value ofkey, the map's own (borrowed, not copied), and returns what it returns, orundefinedif the map has nokey. For 64-bit integer values the callback gets asharedinteger, read withv.get(); changing it does not change the map, andgetis the cheaper read there.set(key, value): addskeyor replaces its value. Theu64key2^64 - 1cannot be held (aRangeError); every lookup of it answers absent.add(key, delta): u64 | undefined: foru64values, addsdeltato the value ofkey(wrapping) atomically and returns the value before, orundefinedif the map has nokey(then nothing is added).delete(key): bool, whether the map had it.getManyInto(keys, values): u64: foru64keys and values (aRangeErrorotherwise), looks up at most 64 keys together intovalues; where bitiof the result is set,values[i]is the value ofkeys[i]. It fills the caller's array, so a loop of batches allocates nothing, and in a map larger than the caches it costs about half as much as the samegets.
The map has no ordered walk, iteration or size yet.
Atomicity
Each call is atomic on its own: a get sees a set of the same key either wholly or not at all.
A sequence of calls is not one step: m.set(k, (m.get(k) ?? 0) + 1) can lose another thread's
increment, where m.add(k, 1) cannot. Lookups are not lock-free: a lookup waits (spins) while a
writer holds its node, so a writer preempted in the middle of a change stalls that node's readers
until it runs again.
Values and keys
A u64 value sits in the tree, next to its key. Any other V must be Frozen (a string, an
array, an object of plain fields; see velt/sync) and is boxed once by set:
get copies it, read reads it in place, and one that set replaces or delete removes is
dropped once no reader can still be reading it.
A string key is ordered in the tree by its first 8 bytes and kept whole beside its leaf. Keys
whose first 8 bytes are the same ("user:000123" and "user:000456") are told apart by a hash
of the whole string in the leaf and by comparing the strings in inner nodes. So a lookup of a
string key costs a hash of the key and one string compare in the leaf, a few cache lines more
than a u64 key's lookup; keys that share a long prefix (URLs that all start https://) cost
some string compares more per inner node, as in any ordered map.
Capacity
The room create takes is fixed. A deleted entry frees a slot in its leaf, and a leaf whose last
entry is deleted is reused, so keys that keep moving to new ranges (ids or timestamps that only
grow, old ones deleted) fit for as long as the entries present at once do. Leaves are not merged,
though: entries spread thinly over many leaves (most of a large map's keys deleted at random) can
hold a node each and need more room than their number. A set of a key the map holds can need
room too, when its leaf is full and splits first.
How it works, and how fast
It is a B+ tree with optimistic lock coupling, as
bbtree's concurrent map: a reader reads a node, then
checks the node's version and starts over in the rare case a writer changed it meanwhile. It is
written in Velt on the public API alone: its nodes are 1 KB blocks of one Atomic<u64>[], read
several words at a time with velt/simd's loadOptimistic.
On 10M random keys, on 1 to 8 threads, against bbtree:
| workload | Apple M4 | Intel i9-12900HK |
|---|---|---|
| insert | 19–23% faster | 32–48% faster |
| lookup (half the keys present) | 6–19% faster | 24–77% faster |
batched lookup (getManyInto, 64 keys) | 30–40% faster | 56–94% faster |
update (add) | 6–9% slower | 13–58% faster |
| 90% lookups, 10% updates | 3–12% faster | 27–67% faster |