Writing efficient TPy¶
Efficient TurboPython is mostly ordinary TurboPython. There is no interpreter, no garbage collector, and no reference counting to pay for. The rules that keep it efficient concern how values are represented and how they move. The constraint decorators turn those rules into compiler checks.
Hot paths are found by timing the compiled binary. The generated C++ can also be inspected directly to confirm how a value is represented (Building & running).
Keep arithmetic fixed-width¶
The int type is exact and allocates as it grows; the fixed-width types are
fixed-size machine numbers (Types). The trap is silent promotion.
One int operand converts a whole expression to big-integer arithmetic. A stray int
usually comes from a literal past the Int32 range or a value built with
int(). The fix is an explicit conversion at the boundary:
from tpy import Int32
def main():
n: int = 100 # arrives as int, e.g. from int(...)
k = Int32(n) # converted once, at the boundary
total: Int32 = 0
for i in range(3):
total += k + i # stays fixed-width all the way
print(total) # 303
main()
Promotion has no diagnostic today. The first check is the arithmetic itself:
any int operand converts the whole expression. For confirmation, the
generated C++ shows a promoted loop directly (Building) — no
C++ fluency is needed to spot a big-integer type where int32_t was expected.
Move instead of copying¶
A store into durable storage moves when it can and copies when it must, with a warning for every copy (Ownership). The resolution order:
- Restructure so the store is the last use — the copy becomes a move.
- Construct in place — a temporary always moves.
- Take
Own[T]at the boundary — the caller gives the object up. - Accept the copy with
copy()— when both the original and the stored value are genuinely needed. On a hot path it is the most expensive of the four.
The costs behind those four options explain the order. A move relocates a
small fixed-size value and copies no elements. A view — a borrow, a
contiguous slice, a StrView — costs nothing.
A deep copy allocates and copies everything the object owns; for the
Reading class, that is the sensor text plus the whole values buffer (a
list[float] is one contiguous block of machine floats, not boxed
elements). A big-integer operation can allocate. These four facts rank almost
every decision on this page.
A copy warning marks a CPython behavior difference and a runtime cost. On a
hot path, the resolution order above applies before copy() silences the
warning.
Borrow instead of copying¶
Passing never copies: parameters borrow, and non-mutating parameters are
passed read-only automatically (Functions). Slices of str
are views, not copies, and StrView makes the no-copy view part of a
signature (Types). A plain reference return gives the caller a
borrowed object with no transfer at all. None of this needs annotations; the
gain comes from the copies that are never written.
Beyond copies, allocations come from growing containers, strings, and big integers.
Keep dispatch static¶
Structural protocols and unions resolve at compile time, with no runtime
dispatch cost (Data modeling). The dynamic escape hatches
trade that away. A @dynamic protocol dispatches every method call through a
vtable, and Any holds a value of statically unknown type, checking or casting
it against a runtime type tag on each use. Any is supported but slow, and it
gives up most of the compiler's static reasoning.
Prefer a static protocol or a union where the set of types is known; reach for
@dynamic or Any only when the design genuinely needs runtime polymorphism.
Constrain the hot path¶
The markers above make efficient code possible; the constraint decorators
make it checked. Two are enforced today, @nocopy and @readonly; @noalloc
and @hotpath are planned.
Not available yet
@noalloc (a function must not allocate) and @hotpath (code that must
stay on the fast path) are accepted but not enforced today, and enforcement
will not land in the next release. Compatibility
tracks their status.
The @nocopy decorator on a class forbids every implicit copy of that type.
Code that would have compiled with a copy warning becomes an error:
from tpy import nocopy, Own
@nocopy
class Frame: # hot data: copying is never acceptable
values: list[float]
def __init__(self, values: Own[list[float]]):
self.values = values
def main():
frames: list[Frame] = []
f = Frame([0.0, 1.0])
frames.append(f) # error: f used below; @nocopy forbids the copy
print(len(f.values))
main()
error: @nocopy type 'Frame' is used after this point and cannot be moved
into 'value: Own[Frame]'. Remove later uses or restructure the code.
(The value: Own[Frame] named in the error is append's own parameter.)
The error names the fix, and the move forms compile unchanged:
def main():
frames: list[Frame] = []
frames.append(Frame([0.0, 1.0])) # a temporary moves -- no copy
print(len(frames)) # 1
main()
Read-only methods¶
The @readonly decorator on a method promises the object is not mutated, and
the compiler enforces the promise (Ownership covers the
parameter form, readonly[T]):
from tpy import readonly, Own
class Reading:
sensor: str
values: list[float]
def __init__(self, sensor: str, values: Own[list[float]]):
self.sensor = sensor
self.values = values
@readonly
def first(self) -> float: # mutating self here is a compile error
return self.values[0]
Error returns¶
Exceptions are the throw-and-catch tier of a two-tier model; @error_return is
the explicit-propagation tier for hot paths. A function marked
@error_return(E) returns its error as a value instead of throwing, so raising
E costs nothing while the caller still writes ordinary try/except. The
error class mixes in ReturnException:
from tpy import error_return, ReturnException
class NotFound(Exception, ReturnException):
pass
@error_return(NotFound)
def find(items: list[int], target: int) -> int:
for i in range(len(items)):
if items[i] == target:
return i
raise NotFound
def main():
items = [10, 20, 30]
try:
print(find(items, 20)) # 1
except NotFound:
print("not found")
main()
The same rewrite makes raise StopIteration free, so iterators get
@error_return automatically (Control flow).
The checklist¶
| On a hot path | The tool |
|---|---|
| Arithmetic | fixed-width types; convert int once, at the boundary |
| A store that warns about a copy | restructure to a move, construct in place, or take Own[T] |
| A copy that is genuinely needed | copy() — the copy is deep |
| Reading without mutating | plain parameters (read-only is inferred); slices and StrView for text |
| Runtime polymorphism | static protocols or unions; @dynamic and Any add dispatch cost |
| A type that must never be copied | @nocopy on the class |
| A method that must not mutate | @readonly |
| An exception on the hot path | usually return T \| None instead of raising; @error_return exists |
Everything here follows from one model. A type fixes an object's representation, and each object has a single owner, which is what makes copies avoidable. Parallelism — threads without a GIL — is deferred to Beyond the core. Building & running covers running a script, inspecting the generated C++, and producing a standalone binary.