numbox.core.work
Overview
Functionality for fully-jitted and light-weight calculation on a graph.
Modules
numbox.core.work.builder
Overview
Pure Python abstraction for creation of JIT’ed graph of numbox.core.work.work.Work nodes.
Users can define end nodes [1]:
from numba.core.types import int16
from numbox.core.work.builder import End
w1_ = End(name="w1", init_value=137, ty=int16)
w2_ = End(name="w2", init_value=3.14)
w5_ = End(name="w5", init_value=10)
w6_ = End(name="w6", init_value=7.5)
where optional capability to specify numba types of the nodes has been illustrated. Suppose more values, w3, w4, w7, w8, w9, w10, are derived as follows:
def derive_w3(w1_, w2_):
if w1_ < 0:
return 0.0
elif w1_ < 1:
return 2 * w2_
return 3 * w2_
def derive_w4(w1_):
return 2 * w1_
def derive_w7(w3_, w5_):
return w3_ + (w5_ ** 2)
def derive_w8(w6_, w2_):
if w6_ > 5:
return w6_ * w2_
else:
return w6_ + w2_
def derive_w9(w3_, w4_, w7_):
return (w4_ - w3_) / (abs(w7_) + 1e-5)
def derive_w10(w3_, w4_, w7_, w8_, w9_):
return (w3_ + w4_ + w7_) * 0.1 + (w8_ - w9_)
Users can then declare the corresponding nodes (in fact End and Derived
are better termed as ‘node specs’, while instances of numbox.core.work.work.Work are
the actual node objects) as:
from numbox.core.work.builder import Derived
w3_ = Derived(name="w3", init_value=0.0, derive=derive_w3, sources=(w1_, w2_))
w4_ = Derived(name="w4", init_value=0.0, derive=derive_w4, sources=(w1_,))
w7_ = Derived(name="w7", init_value=0.0, derive=derive_w7, sources=(w3_, w5_))
w8_ = Derived(name="w8", init_value=0.0, derive=derive_w8, sources=(w6_, w2_))
w9_ = Derived(name="w9", init_value=0.0, derive=derive_w9, sources=(w3_, w4_, w7_))
w10_ = Derived(name="w10", init_value=0.0, derive=derive_w10, sources=(w3_, w4_, w7_, w8_, w9_))
DAG with the access nodes w7, w9, w10, can then be constructed and used as follows [2]:
from numbox.core.work.builder import make_graph
access = make_graph(w7_, w9_, w10_)
w7 = access.w7
w9 = access.w9
w10 = access.w10
Here access is a named tuple containing instances of numbox.core.work.work.Work node structure:
from numbox.core.work.work import Work
assert isinstance(w7, Work)
assert isinstance(w9, Work)
assert isinstance(w10, Work)
One can then compute and access values of the derived nodes:
from numpy import isclose
assert w10.data == 0
w10.calculate()
assert isclose(w7.data, 109.42)
assert isclose(w9.data, 2.418022)
assert isclose(w10.data, 60.416)
Nodes are required to have unique name attribute. Attempting to declare multiple nodes, either End or Derived, with the same name will raise ValueError.
Graph structure can be inspected as:
from numbox.core.work.print_tree import make_image
print(make_image(w10))
which outputs:
w10--w3--w1
| |
| w2
|
w4--w1
|
w7--w3--w1
| | |
| | w2
| |
| w5
|
w8--w6
| |
| w2
|
w9--w3--w1
| |
| w2
|
w4--w1
|
w7--w3--w1
| |
| w2
|
w5
The visualization utility for a sub-graph tree numbox.core.work.print_tree.make_graph()
spans the breadth in the vertical direction and the depth in the horizontal direction.
The nodes are uniquely identified by their names.
To simplify the image structure, the same node can appear on one graph image multiple times.
Each Work can be represented as light-weight numbox.core.work.node.Node type,
which stores its node attribute upon first invocation:
w3_n = w3.as_node()
This enables an assortment of utilities, such as:
assert w3.all_inputs_names() == ["w1", "w2"]
assert w7.all_inputs_names() == ["w3", "w1", "w2", "w5"]
assert w3.depends_on("w1")
assert w3.get_input(0).name == "w1"
assert w10.get_input(3).name == "w8"
From the given access node, values of nodes can be combined as follows:
from numba.core.types import float64
from numbox.core.work.combine_utils import make_sheaf_dict
requested = ("w1", "w4", "w7", "w8")
sheaf = make_sheaf_dict(requested)
w10.combine(sheaf)
assert isclose(sheaf["w4"].get_as(float64), 274)
assert isclose(sheaf["w7"].get_as(float64), 109.42)
assert isclose(sheaf["w8"].get_as(float64), 23.55)
Graph nodes can be loaded from the access node as follows [3]:
from numba.core.types import int16, unicode_type
from numba.typed.typeddict import Dict
from numbox.core.any.any_type import AnyType, make_any
load_data = Dict.empty(key_type=unicode_type, value_type=AnyType)
assert sheaf["w1"].get_as(int16) == 137
load_data["w1"] = make_any(12)
w10.load(load_data)
w10.combine(sheaf)
assert sheaf["w1"].get_as(int16) == 12
Recalculating the graph then renders new values of the affected nodes [4]:
w10.calculate()
w10.combine(sheaf)
assert isclose(sheaf["w4"].get_as(float64), 24)
Builder supports smart caching of compiled code that builds the graph from the specified End and Derived nodes. If caching of the JIT’ed functions is configured (which is a default behavior), the graph maker is re-compiled only when init_value or derive functions of the nodes are changed.
The init_value attribute of the End and Derived nodes can be assigned a variety of values, including scalars, arrays, and instances of StructRef. In the latter case, it is recommended to define __repr__ method of the StructRef proxy class, so that the builder knows when the values contained in the StructRef have changed. Otherwise, the default __repr__ of the StructRef containing dynamic address of the struct object will be used, making the builder recompile every time it’s invoked.
From each given accessor Work node, one can trace down its derivation to all the End nodes:
from numbox.core.work.explain import explain
derivation_of_w9 = explain(w9)
print(derivation_of_w9)
will return:
All required end nodes: ['w1', 'w2', 'w5']
w1: end node
w2: end node
w3: derive_w3(w1, w2)
def derive_w3(w1_, w2_):
if w1_ < 0:
return 0.0
elif w1_ < 1:
return 2 * w2_
return 3 * w2_
w4: derive_w4(w1)
def derive_w4(w1_):
return 2 * w1_
w5: end node
w7: derive_w7(w3, w5)
def derive_w7(w3_, w5_):
return w3_ + (w5_ ** 2)
w9: derive_w9(w3, w4, w7)
def derive_w9(w3_, w4_, w7_):
return (w4_ - w3_) / (abs(w7_) + 1e-5)
This provides information of what pure inputs / end nodes are required to derive the given node as well as the logical sequence of steps (as indicated by the graph structure) to carry out the derivation.
By default, defining End or Derived node spec will store the created node spec instance
in the global registry _specs_registry (in numbox.core.work.builder) as a value paired to the key
given by the node’s name.
Attempting to create more than one node with the same name will then raise an error.
Optionally, any number of (local) registries can be created to register given node specs in. To do so, provide a dictionary-valued argument as the registry parameter of either Derived or End node. Then the make_graph utility needs to be provided with the same registry as its keyword argument:
reg_1 = {}
end_1 = End(name="end_1", init_value=0.0, registry=reg_1)
reg_2 = {}
end_1_another = End(name="end_1", init_value=0.0, registry=reg_2)
der_1 = Derived(name="der_1", init_value=0.0, sources=(end_1,), registry=reg_1, derive=lambda x: x + 2.17)
accessors_1 = make_graph(der_1, registry=reg_1)
der_1_ = accessors_1.der_1
der_1_.calculate()
assert isclose(der_1_.data, 2.17)
der_1_another = Derived(
name="der_1", init_value=0.0, sources=(end_1_another,), registry=reg_2, derive=lambda x: x + 3.14
)
accessors_2 = make_graph(der_1_another, registry=reg_2)
der_1_another_ = accessors_2.der_1
der_1_another_.calculate()
assert isclose(der_1_another_.data, 3.14)
In this example, there are two nodes named “end_1” and two nodes named “der_1”, but they are defined in different registries, and the two corresponding graphs are built from the specs obtained from their respective registries.
- numbox.core.work.builder.code_block_hash(code_txt: str)[source]
Re-compile and re-save cache when source code has changed.
numbox.core.work.builder_utils
- numbox.core.work.builder_utils.infer_sources_dependencies(access_nodes: NamedTuple | Tuple)[source]
For all nodes names accessible from the given access_nodes, return dictionary of all nodes names that depend on each of the nodes in the accessible graph. For instance:
m1 -- m2 -- m3 -- m5 | m4
will return:
{ "m1": set(), "m2": {"m1"}, "m3": {"m1", "m2"}, "m4": {"m1"}, "m5": {"m1", "m2", "m3"} }
numbox.core.work.combine_utils
numbox.core.work.loader_utils
numbox.core.work.node
Overview
numbox.core.work.node.Node represents a node on a directed acyclic graph
(DAG)
that exists in a fully jitted scope and is accessible both at the low-level and via a Python proxy.
Node can be used on its own (in which case the recommended way to
create it is via the factory function numbox.core.work.node.make_node())
or as a prototype to more functionally-rich graph nodes,
such as numbox.core.work.work.Work.
The logic of Node and its sub-classes follows a graph-optional design - no graph orchestration structure is required to register and manage the graph of Node instance objects - which in turn reduces unnecessary computation overhead and simplifies the program design.
To that end, each node is identified by its name and contains a uniformly-typed vector-like container member (rendered by the numba-native numba.core.typed.List) with all the input nodes references that it bears a directed dependency relationship to. This enables a traversal not only of graphs of Node instances themselves but also graphs of objects representable by it, such as, the graphs of Work nodes.
Node implementation makes heavy use of the numba
meminfo
paradigm that manages memory-allocated
payload via smart pointer (pointer to numba’s meminfo object) reference counting.
This allows users to reference the desired
memory location via a ‘void’ structref type, such as,
numbox.core.any.erased_type.ErasedType, or numbox.utils.void_type.VoidType,
or base structref type, such as, numbox.core.work.node_base.NodeBaseType,
and dereference its payload accordingly when needed via the appropriate numbox.utils.lowlevel.cast().
- class numbox.core.work.node.NodeTypeClass(*args, **kwargs)[source]
Bases:
NodeBaseTypeClass
numbox.core.work.node_base
Overview
Base class for numbox.core.work.node.Node and numbox.core.work.work.Work.
Contains functionality dependent only on the node name.
numbox.core.work.print_tree
Overview
Provides utilities to print a tree from the given node’s dependencies.
The node can be either instance of numbox.core.work.node.Node
or numbox.core.work.work.Work:
from numbox.core.work.node import make_node
from numbox.core.work.print_tree import make_image
n1 = make_node("first")
n2 = make_node("second")
n3 = make_node("third", inputs=(n1, n2))
n4 = make_node("fourth")
n5 = make_node("fifth", inputs=(n3, n4))
tree_image = make_image(n5)
print(tree_image)
which outputs:
fifth--third---first
| |
| second
|
fourth
Notice that the tree depth extends in horizontal direction, the width extends in vertical direction and is aligned to recursively fit images of the sub-trees.
For the sake of readability, if multiple nodes depend on the given node, the latter will be accordingly displayed multiple times on the tree image, for instance:
n1 = make_node("n1")
n2 = make_node("n2", (n1,))
n3 = make_node("n3", inputs=(n1,))
n4 = make_node("n4", inputs=(n2, n3))
tree_image = make_image(n4)
produces:
n4--n2--n1
|
n3--n1
Here it is understood that both references to ‘n1’ point to the same node, that happens to be a source of two other nodes, ‘n2’ and ‘n3’.
numbox.core.work.work
Overview
Defines numbox.core.work.work.Work StructRef.
Work is a unit of calculation work that is designed to
be included as a node on a jitted graph of other Work nodes.
Work type subclasses numbox.core.work.node_base.NodeBase
and follows the logic of graph design of numbox.core.work.node.Node.
However, since numba StructRef does not support low-level subclasses,
there is no inheritance relation between NodeBaseType and WorkType,
leaving the data design to follow the composition pattern.
Namely, the member (name) of the NodeBase payload is a header in the payload of Work, allowing
to perform a meaningful numbox.utils.lowlevel.cast().
The main way to create Work object instance is via the numbox.core.work.work.make_work()
constructor (Work(…) instantiation is in fact disabled both in Python and jitted scope)
that can be invoked either from Python or jitted scope (plain-Python or jitted run function below):
import numpy
from numba import float64, njit
from numbox.core.work.work import make_work
from numbox.utils.highlevel import cres
@cres(float64(), cache=True)
def derive_work():
return 3.14
@njit(cache=True)
def run(derive_):
work = make_work("work", 0.0, derive=derive_)
work.calculate()
return work.data
assert numpy.isclose(run(derive_work), 3.14)
When called from jitted scope, if cacheability of the caller function
is a requirement, the derive function should be passed to run as
a FunctionType (not njit-produced CPUDispatcher) argument, i.e.,
decorated with numbox.utils.highlevel.cres()). Otherwise,
simply pulling derive_work from the global scope within
argument-less run will prevent its caching.
For performance-critical large graphs containing hundreds or more nodes created
in a jitted scope, using numbox.core.work.work.make_work() is not
feasible as it either results in large memory use (and takes up a lot of
disk space when the jitted caller is cached), or takes a substantial time to
compile when make_work is declared with inline=True directive (albeit
resulting in a much slimmer and optimized final compilation result).
For that purpose it is recommended to use a low-level intrinsic
numbox.core.work.lowlevel_work_utils.ll_make_work() as follows:
from numba import njit
from numba.core.types import float64
from numpy import isclose
from numbox.core.work.node_base import NodeBaseType
from numbox.core.work.lowlevel_work_utils import ll_make_work, create_uniform_inputs
from numbox.core.work.print_tree import make_image
from numbox.utils.highlevel import cres
@cres(float64())
def derive_v0():
return 3.14
@njit(cache=True)
def v0_maker(derive_):
return ll_make_work("v0", 0.0, (), derive_)
v0 = v0_maker(derive_v0)
assert v0.data == 0
assert v0.name == "v0"
assert v0.inputs == ()
assert not v0.derived
v0.calculate()
assert isclose(v0.data, 3.14)
Importantly, Work objects support numbox.core.work.work.ol_as_node()
rendition as_node that creates a numbox.core.work.node.Node instance
with the same name as the Work instance and the vector of inputs of the
numbox.core.work.node_base.NodeBase type referencing the original Work
instance’s sources. Upon the first invocation of as_node on the given Work
instance, Node representations for itself and recursively all its sources
are created and stored in their node attributes only once. Subsequent invocations
of as_node on either the given Work node or any of nodes on its sub-graph
will return the previously created Node objects stored as the node attribute.
Exception handling
An exception raised inside a derive propagates out of calculate into the caller, carrying its original type and message. The node’s data is left untouched and derived stays unset, so once the cause is addressed the node calculates again rather than serving a cached failure:
from numbox.core.work.work_utils import make_work_helper
def derive_reciprocal(x_):
if x_ == 0.0:
raise ValueError("zero input")
return 1.0 / x_
source = make_work_helper("source", 0.0)
node = make_work_helper("node", 1.0, sources=(source,), derive_py=derive_reciprocal)
try:
node.calculate()
except ValueError:
assert node.data == 1.0
assert node.derived == 0
This holds for every way of supplying a derive that numbox itself compiles:
numbox.core.work.work.make_work(),
numbox.core.work.lowlevel_work_utils.ll_make_work(),
numbox.core.work.work_utils.make_work_helper() (whose derive_py argument is a
plain Python function that the helper compiles with cres) and
numbox.core.work.builder.Derived, provided the derive reaches them with its
numbox type intact. Handing one from Python to a jitted parameter that is declared as
a plain FunctionType degrades it before it ever gets there; see the limits below.
This needs a numbox-owned type, for the following reason.
derive is invoked through a first-class FunctionType call. numba can lower such a
call two ways, choosing on the jit_addr slot of the function’s data model: a populated
slot selects the numba calling convention, which unwinds normally, while an empty one
selects a C wrapper that numba documents as not supporting exceptions. That wrapper
reports the exception on stderr as unraisable (Exception ignored in:
<numba.core.cpu.CPUContext ...>) and returns a zero-initialized value without
unwinding.
numba populates jit_addr only for a dispatcher, leaving it empty for the compile
result that numbox.utils.highlevel.cres() produces. numbox.utils.derive_wap
therefore defines its own DeriveWAP, which captures
the calling convention entry point, and DeriveFunctionType,
which fills the slot from it. The registrations go through numba’s public extension API,
save for lower_constant, which numba.extending does not re-export; the data
model, wrapper protocol and conversion types they build on sit outside it too. No numba
internals are patched: nothing replaces numba behaviour, it only registers against it.
These limits are worth knowing:
On numba 0.60 the jit_addr slot does not exist. cres returns a plain
CompileResultWAPthere and the exception is still discarded, leaving a zero-filled data and a set derived. See numba issue 8246 for the underlying behaviour.A derive built directly against numba as a
CompileResultWAPrather than through cres carries no entry point to call.numbox.core.work.work.make_work()upgrades such a value when it is passed from Python, because the check is on the object’s class. One reached from jitted scope cannot be upgraded and keeps the old behaviour. The upgrade is attached to the object it upgrades and reused, so the node’s derive attribute reads back as the upgraded wrapper rather than as the object that was passed in. It wraps the same compile result and is called identically; only the identity differs.A derive handed from Python into a jitted function whose parameter is declared as a plain
FunctionTypedegrades to the C convention on the way in, so the exception is discarded even though the node was built with make_work and calculated normally. An inferred-signaturenjitkeeps the numbox type and propagates, and a cast toFunctionTypereached from within jitted scope keeps it too, because that cast is an identity on a shared data model. It is specifically the declared parameter, crossed from Python, that degrades.Wherever the exception is discarded, the zero fill is not merely a wrong number. For unicode_type data it is an all-zero string struct whose data pointer is NULL, so reading work.data back from Python dereferences it and terminates the interpreter with a segmentation fault. derived is set regardless, so a later calculate is a no-op and the value is permanent.
The exception’s type and message are not recoverable inside a jitted body: numba rejects both
except ... as eand any typedexceptclause other thanException. Code that needs to react to a specific failure in jitted scope still has to encode it in the returned value.A container that mixes a cres derive with a differently typed function value: a tuple holding a cres alongside a plain
CompileResultWAP, a signature-declared njit dispatcher or acfunc. numba unifies the element types before any of numbox’s conversions apply, andnumba.core.utils.unified_function_typerequires every function type it meets to equal the first through a bareassert, so the failure arrives as anAssertionErrorcarrying no message. A lazily compiled@njitdispatcher in the mix is the one subcase that reads differently: unification accepts it, and numbox’s guard then rejects it at the unboxing boundary with aTypeErrornaming the offending type. From numba 0.61 onward a@proxybinding’s.as_funcis a DeriveFunctionType value as well (see numbox.core.proxy), so it meets this limit identically: a tuple mixing it with a plainCompileResultWAPfails on the same assertion, where before it unified and returned a value. Homogeneous containers are unaffected, including a tuple of two cres derives, or a cres derive alongside a @proxy binding’s .as_func of the same signature. This is not specific to numbox: two plainCompileResultWAPvalues of different signatures but the same argument count fail identically with numbox uninvolved. What numbox changes is how easily the case is reached, since DeriveFunctionType is a distinct type fromFunctionTypeand numba compares function types by class. Making the two compare equal is not available as a fix. numba interns types in a cache keyed by a weak reference, whose equality is the referent’s, so equal types collapse onto whichever was interned first: either the derive type resolves to the plain one and every derive goes back to discarding its exception, or the plain type resolves to the derive one and every plain function value fails to unbox.Upgrading numbox does not invalidate numba’s own on-disk cache. A module of your own compiled with
cache=Trueagainst an older numbox keeps cache-hitting after the upgrade, and where it takes a plainFunctionTypederive it goes on discarding the exception, because numba keys the entry on your source rather than on the version of the library that compiled it. ClearNUMBA_CACHE_DIRafter upgrading. A cres derive is unaffected: its type name changes, so the entry re-keys on its own.Downgrading numbox below this feature, after a cached compile has seen a derive, leaves that cache unreadable rather than merely stale. numba unpickles the stored type index before it checks the freshness stamp, so the load fails outright with
ModuleNotFoundError: No module named 'numbox.utils.derive_wap'. Editing or touching your own source does not clear it; deleting the cache directory does.
Compiling the derive itself with parallel or nogil changes nothing, including
when the raise sits inside the derive’s own prange.
Invoking calculate from inside a prange body is the one case that does differ,
and it differs by platform. On Linux the failure arrives as numba’s
SystemError: ... returned a result with an exception set, with the original exception
reachable through __cause__, so an except ValueError around such a call does not
match. On macOS nothing is raised at all, so the loop finishes and the caller reads the
node’s previous data with no indication that the derive failed. Neither behaviour is
specific to a derive: this is numba’s handling of an exception escaping a parallel
region, and a plain jitted function raising inside prange behaves the same way with
numbox uninvolved. What holds on every platform is that the node is left alone, keeping
its data and its unset derived, so the failure is not cached and a later calculate
outside the parallel region raises normally. Do not rely on a raise to detect a failed
`derive` when `calculate` is called inside a ``prange`` body.
Graph manager
While not a requirement, it is recommended that the Work instance’s name attribute matches the name of the variable to which that instance is assigned. Moreover, no out-of-the-box assertions for uniqueness of the Work names is provided. The users are free to implement their own graph managers that register the Work nodes and assert additional requirements on the names as needed. The core numbox library maintains agnostic position to whether such an overhead is universally beneficial (and is worth the performance tradeoff).
One option to build a graph manager would be via the constructor such as:
from numba.core.errors import NumbaError
from numbox.core.configurations import jit_options
from numbox.core.work.lowlevel_work_utils import ll_make_work
from numbox.core.work.node import NodeType
from numbox.utils.lowlevel import _cast
from work_registry import _get_global, registry_type
@njit(**jit_options)
def make_registered_work(name, data, sources=(), derive=None):
""" Optional graph manager. Consider using `make_work`
where performance is more critical and name clashes are
unlikely and/or inconsequential. """
registry_ = _get_global(registry_type, "_work_registry")
if name in registry_:
raise NumbaError(f"{name} is already registered")
work_ = ll_make_work(name, data, sources, derive)
registry_[name] = _cast(work_, NodeType)
return work_
Here numbox.core.work.lowlevel_work_utils.ll_make_work() is the intrinsic
Work constructor — it inlines directly into the calling jitted scope, whereas
numbox.core.work.work.make_work() is the Python-scope convenience wrapper around it,
a plain function whose jitted callers reach an @overload of the same shape.
The utility registry module can be defined as
1"""
2These functions included in this module::
3
4 get_or_make_global
5 _get_global
6 _set_global
7
8were mainly based on the
9
10 `CognitiveRuleEngine <https://github.com/DannyWeitekamp/Cognitive-Rule-Engine/blob/main/cre/utils.py>`_
11
12open-source project, distributed under
13
14MIT License
15
16Copyright (c) 2023 Daniel Weitekamp
17
18Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # noqa: E501
19copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: # noqa: E501
20
21The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. # noqa: E501
22
23THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. # noqa: E501
24"""
25
26from llvmlite import ir
27from numba import njit
28from numba.extending import intrinsic
29from numba.core import cgutils
30from numba.core.types import DictType, unicode_type, void
31from numba.typed.typeddict import Dict
32
33from numbox.core.configurations import jit_options
34from numbox.core.work.node import NodeType
35
36
37def get_or_make_global(context, builder, fe_type, name):
38 mod = builder.module
39 try:
40 gv = mod.get_global(name)
41 except KeyError:
42 ll_ty = context.get_value_type(fe_type)
43 gv = ir.GlobalVariable(mod, ll_ty, name=name)
44 gv.linkage = "common"
45 gv.initializer = cgutils.get_null_value(gv.type.pointee)
46 return gv
47
48
49@intrinsic(prefer_literal=True)
50def _get_global(typingctx, type_ref, name_ty):
51 ty = type_ref.instance_type
52 name = name_ty.literal_value
53
54 def codegen(context, builder, signature, arguments):
55 gv = get_or_make_global(context, builder, ty, name)
56 v = builder.load(gv)
57 context.nrt.incref(builder, ty, v)
58 return v
59 sig = ty(type_ref, name_ty)
60 return sig, codegen
61
62
63@intrinsic(prefer_literal=True)
64def _set_global(typingctx, type_ref, name_ty, v_ty):
65 ty = type_ref.instance_type
66 name = name_ty.literal_value
67
68 def codegen(context, builder, signature, arguments):
69 _, __, v = arguments
70 gv = get_or_make_global(context, builder, ty, name)
71 builder.store(v, gv)
72 sig = void(type_ref, name_ty, v_ty)
73 return sig, codegen
74
75
76registry_type = DictType(unicode_type, NodeType)
77
78
79@njit(**jit_options)
80def set_global(registry_):
81 _set_global(registry_type, "_work_registry", registry_)
82
83
84registry = Dict.empty(unicode_type, NodeType)
85set_global(registry)
Implementation details
Behind the scenes, Work accommodates individual access to its sources
(other Work nodes that are pointing to the given Work node on the DAG)
via a ‘Python-native compiler’ backdoor, which is essentially a relative pre-runtime
technique to leverage Python’s compile and exec functions before preparing for
overload in the numba jitted scope. This technique is fully compatible with caching of
jitted functions and facilitates a natural Python counterpart to virtual functions (unsupported in numba).
Here it is extensively utilized in
numbox.core.work.work.ol_calculate() that overloads calculate method of
the Work class.
Invoking calculate method on the Work node triggers DFS calculation of its sources - all of the sources are automatically calculated before the node itself is calculated. Calculation of the Work node sets the value of its data attribute to the outcome of the calculation, which in turn can depend on the data values of its sources.
To avoid repeated calculation of the same node, Work has derived boolean flag that is set to True once the node has been calculated, preventing subsequent re-derivation. In particular, this ensures that DFS calculation of the node’s sources happens just once. The flag is set after the derive returns, so a derive that raises leaves it unset and the node stays calculable (see Exception handling above).
- class numbox.core.work.work.Work(*args, **kws)[source]
Bases:
NodeBaseStructure describing a unit of work.
Instances of this class can be connected in a graph with other Work instances.
Attributes
- namestr
Name of the structure instance.
- inputsUniTuple[NodeBaseType]
Uniform tuple of Work.sources, cast as NodeBaseType.
- dataAny
Scalar or array data payload contained in (and calculated by) this structure.
- sourcesTuple[Work, …]
Heterogeneous tuple of Work instances that this Work instance depends on.
- deriveFunctionType
Function of the signature determined by the data types of sources and data. On numba 0.61 and later an exception raised inside it propagates out of calculate, leaving data untouched and derived unset, so the node can be calculated again once the cause is addressed. numba’s error path holds the references it took, so a node whose derive has ever failed stays pinned, along with what it references, for the life of the process; retrying the same node adds nothing further. Several cases still discard the exception and cache a zero-filled data: numba 0.60, which has no jit_addr slot to carry the entry point that can unwind; a plain CompileResultWAP built directly against numba rather than through numbox.utils.highlevel.cres and reached from jitted scope, where it cannot be upgraded (a @proxy binding’s .as_func is not one of these on numba 0.61 and later: it is a DeriveWAP and propagates on those same jitted-scope paths); a cfunc, which is not upgraded either; and a derive handed from Python to a jitted parameter that is declared as a plain FunctionType, which degrades on the way in.
- derivedint8
Flag indicating whether the data has already been calculated.
- nodeNodeType
Work as Node, with its sources in a List.
(name, ) attributes of the Work structure payload are homogeneously typed across all instances of Work and accommodate cast-ability to the
numbox.core.node_base.NodeBasebase of NodeBaseType.- property data
- property derive
- property derived
- property inputs
- property sources
- numbox.core.work.work.make_work(name, data, sources=(), derive=None)[source]
Create a Work from Python scope.
A derive compiled by
cres()already propagates its exceptions. One built directly against numba does not, so it is upgraded here. The upgrade has to happen in Python because the check is on the object’s class, which a jitted body cannot see.Jitted callers reach the overload below, which takes the value as given: by then the type is fixed and nothing can be re-wrapped.
make_work.py_funcis preserved from when this function was itself a dispatcher, since that attribute was part of the surface callers could reach for. It is not callable, then or now: the body it exposes calls the ll_make_work intrinsic and raises NotImplementedError. Note it is _make_work_jit’s body rather than this one, so it does not carry the rewrap_derive call above.
- numbox.core.work.work.ol_combine(work_ty, data_ty: ~numba.core.types.containers.DictType, harvested_ty=<class 'numba.core.types.misc.NoneType'>)[source]
Harvest nodes data from the graph with the root node work. data is provided as dictionary mapping node name to Any type containing erased payload p to be reset to data.
numbox.core.work.work_utils
Overview
Convenience utilities for creating Work-graphs from Python scope.
The numbox.core.work.work.make_work() constructor accepts
cres-compiled derive function as an argument that requires
an explicitly provided signature of the derive function.
Return type of the derive function should match the type of the data attribute
of the corresponding Work instance while its argument types
should match the data types of the Work instance sources.
Utilities defined in this module make it easier to ensure these requirements are met with a minimal amount of coding:
import numpy
from numbox.core.work.work_utils import make_init_data, make_work_helper
pi = make_work_helper("pi", 3.1415)
def derive_circumference(diameter_, pi_):
return diameter_ * pi_
def run(diameter_):
diameter = make_work_helper("diameter", diameter_)
circumference = make_work_helper(
"circumference",
make_init_data(),
sources=(diameter, pi),
derive_py=derive_circumference,
jit_options={"cache": True}
)
circumference.calculate()
return circumference.data
if __name__ == "__main__":
assert numpy.isclose(run(1.41), 3.1415 * 1.41)