Anatomy of an Op

Understand when and how to use Contexts and type annotations

Type annotations

import tinychain as tc

# This is a native Python function.
# It doesn't need type annotations, although you can add them if you want,
# for example if they make the code more clear,
# or if you're using a linter which requires them.
#
# Since this is a native Python function, TinyChain code can only call it
# at compile-time.
def example1(b):
    a = 2
    return b + a, b * a

# This is a TinyChain Op. It can be evaluated at run-time.
# Every TinyChain Op has its own Context, which you can access
# by using the reserved names "cxt" or "txn" as the first argument of the Op.
#
# Since this is meant to be called at run-time, TinyChain doesn't know
# at compile-time what type of arguments to expect. So an Op needs
# type annotations in order to provide the correct API when compiled to JSON.
#
# For example, if you leave out the tc.Number annotation, TinyChain will
# provide a generic tc.State when this Op is compiled, and you'll get an
# error that says that tc.State doesn't support the "+" or "*" operators.
@tc.get_op
def example2(cxt, b: tc.Number) -> tc.Tuple:
    cxt.a = 2
    return b + cxt.a, b * cxt.a

Notice the type annotation Number on the parameter b. The actual value of parameter b may not be known when example is encoded as part of a graph configuration, so the type annotation is necessary in order to provide the correct API methods from inside the Python function definition. For example, if someone calls example(URI("http://example.com/numeric_constant")), this is perfectly valid code, but it doesn't explicitly provide the type of the Value at http://example.com/numeric_constant.

Likewise, the return type annotation Tuple is provided for the benefit of the calling code. Without it, TinyChain would not know what type of State to return at encoding time (when the compute graph configuration is generated) and helper methods like unpack would be unavailable.

The Op Context

The cxt parameter in the example function above is a Context object. It allows you to explicitly name the states which define your Op—in this case, there is one state called b passed in as an argument and one state called a defined in the body of the Op. TensorFlow users should find this familiar because it provides the same functionality as the name_scope function and name= keyword argument in TensorFlow.

Developers new to TinyChain sometimes find it confusing that the name "cxt" is used to refer to many different, independent contexts. Consider this (non-working!) example:

import tinychain as tc

@tc.get_op
def my_constant() -> tc.Int:
    return 2

@tc.get_op
def example(cxt):
    return cxt.my_constant() * 5  # this is where the error happens!

if __name__ == "__main__":
    cxt = tc.Context()
    cxt.my_constant = my_constant
    cxt.example = example

    # note: here's a run-time call to the example op
    cxt.product = cxt.example()

This won't work! Calling example in this case will raise a NotFoundException because example has a completely different and independent Op context. You can verify this yourself by callingtc.print_json(cxt)from within the example function.

This should make intuitive sense. If example and my_context were hosted on different servers, would it make sense for the one to be able to access the other's internal state? What about from a security perspective?

Automatic concurrency

Let's take another look at the example function:

@tc.get_op
def example(cxt, b: tc.Number) -> tc.Int:
    cxt.a = 2
    return cxt.a + b, cxt.a * b

The Python interpreter is imperative and single-threaded, so as a Python developer you're probably accustomed to the assumption that each line of your code will execute in exactly the order that it's written. TinyChain, however, is multi-threaded and features automatic concurrency (like TensorFlow). So, in the example above, cxt.a + b and cxt.a * b both execute simultaneously when a user executes your compute graph.

Automatic concurrency is crucial for a performant distributed runtime, but it comes with some trade-offs. For example, Values are immutable:

@tc.get_op
def example(cxt, b: tc.Number) -> tc.Int:
    cxt.a = 1
    cxt.a += b  # this is where the error happens!
    return cxt.a

This won't work! If it did, it would be impossible for TinyChain to calculate the dependencies of each state in the Op context, and thus impossible to resolve independent states concurrently.

Only Collections like a BTree, Table, or Tensor are mutable. This introduces the need to handle side-effects with the After flow control, which we'll cover in the next section.

Last updated