Skip to content

Design: collections

Why jitclass

Three candidate implementations for stateful containers usable in nopython code:

  1. Arrays + free functions (heap_push(data, size, x) -> new_size): C-style, zero overhead, cacheable — but the caller manually threads state through every call, and misuse (stale size) corrupts silently.
  2. Generic factories (make_priority_queue(dtype)): flexible, but every instantiation is a new class compile, and the API surface doubles (factory + instance).
  3. jitclass with fixed dtypes: real objects, constructible and usable inside @njit with no hacks, methods at native speed.

jitclass won: it is the framework-native expression of a container, and the ergonomic difference compounds in user code. Cost accepted: jitclass methods are not cached on disk (first use pays JIT per process) — noted in the module docstring.

Why fixed dtypes first (float64 values, int64 indices)

Generic jitclass factories increase API complexity and compilation cost. The fixed variant covers the large majority of numerical workloads (scores, priorities, entity ids) and shipped first — making every user pay the generic-API complexity up front would have served a minority need.

The generic variants arrived later as the planned additive API: the *_type(value_type) factories return jitclass specializations built from a single implementation (the float64 defaults ARE *_type(float64) — one source of truth, stack_type(float64) is Stack). Specializations are cached per Numba type so repeated calls share one compiled class; each new type still pays its own JIT cost, which is inherent. priority_queue_type rejects complex types (no ordering); index-domain containers (BitSet, SparseSet, ObjectPool) stay int64 because their integers are indices, not values.

Why counter is a function, not a Counter class

Counting is one operation over a typed dict — a class would add construction ceremony for no capability. Inside the kernel, the membership pattern (if key in d: d[key] += 1 else: d[key] = 1) is used instead of d.get(key, 0) because Numba's type inference cannot resolve .get with a default on a freshly-created dict() — this is a compiler constraint, recorded in a comment at the site.

Honest positioning (also in the docstring and BENCHMARKS.md): for one-shot counting of a materialized array, sort-based np.unique is faster. counter exists for incremental counting inside jitted loops.

Why ObjectPool allocates slots, not objects

There are no Python objects in nopython mode. The useful translation of an object pool is a slot allocator: acquire() hands out an index into whatever preallocated arrays the caller owns, release() returns it, double-release raises. Same lifecycle discipline, expressed in the terms the runtime actually has.

Why SparseSet exists next to BitSet

Both store small-int sets, but SparseSet.clear() is O(1) (reset a counter) versus BitSet.clear()'s O(capacity/64) — decisive for simulation loops that reset membership every iteration. BitSet wins on memory (1 bit vs 16 bytes per universe slot). Two structures, two documented trade-offs, rather than one compromised one.