Skip to content

Commit 9effe91

Browse files
BarryWu0812pingsutwmachichima
authored
[Core feature] Reuse same literals in the dynamic task (#3307)
Signed-off-by: Barry Wu <a0987818905@gmail.com> Co-authored-by: Kevin Su <pingsutw@gmail.com> Co-authored-by: Nary Yeh <60069744+machichima@users.noreply.github.com>
1 parent 665a4ef commit 9effe91

4 files changed

Lines changed: 175 additions & 0 deletions

File tree

dev-requirements.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ types-protobuf<5
4646
types-croniter
4747
types-decorator
4848
types-mock
49+
types-cachetools
4950
autoflake
5051

5152
pillow

flytekit/core/type_engine.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from typing import Any, Dict, List, NamedTuple, Optional, Type, cast
2222

2323
import msgpack
24+
from cachetools import LRUCache
2425
from dataclasses_json import DataClassJsonMixin, dataclass_json
2526
from flyteidl.core import literals_pb2
2627
from fsspec.asyn import _run_coros_in_chunks # pylint: disable=W0212
@@ -1174,6 +1175,7 @@ class TypeEngine(typing.Generic[T]):
11741175
_DATACLASS_TRANSFORMER: TypeTransformer = DataclassTransformer() # type: ignore
11751176
_ENUM_TRANSFORMER: TypeTransformer = EnumTransformer() # type: ignore
11761177
lazy_import_lock = threading.Lock()
1178+
_LITERAL_CACHE: LRUCache = LRUCache(maxsize=128)
11771179

11781180
@classmethod
11791181
def register(
@@ -1377,6 +1379,22 @@ def calculate_hash(cls, python_val: typing.Any, python_type: Type[T]) -> Optiona
13771379
break
13781380
return hsh
13791381

1382+
@classmethod
1383+
def _get_literal_cache_key(cls, python_val: typing.Any, python_type: Type[T]) -> Optional[tuple]:
1384+
import cloudpickle
1385+
1386+
val_hash: int
1387+
type_hash: int
1388+
try:
1389+
val_hash = hash(cloudpickle.dumps(python_val))
1390+
type_hash = hash(cloudpickle.dumps(python_type))
1391+
1392+
return (val_hash, type_hash)
1393+
1394+
except Exception:
1395+
logger.warning(f"Could not hash python_val: {python_val} or python_type: {python_type}")
1396+
return None
1397+
13801398
@classmethod
13811399
def to_literal(
13821400
cls, ctx: FlyteContext, python_val: typing.Any, python_type: Type[T], expected: LiteralType
@@ -1386,6 +1404,10 @@ def to_literal(
13861404
to_literal function, and allowing this to_literal function, to then invoke yet another async function,
13871405
namely an async transformer.
13881406
"""
1407+
key = cls._get_literal_cache_key(python_val, python_type)
1408+
if key is not None and key in cls._LITERAL_CACHE:
1409+
return cls._LITERAL_CACHE[key]
1410+
13891411
from flytekit.core.promise import Promise
13901412

13911413
cls.to_literal_checks(python_val, python_type, expected)
@@ -1406,6 +1428,10 @@ def to_literal(
14061428

14071429
modify_literal_uris(lv)
14081430
lv.hash = cls.calculate_hash(python_val, python_type)
1431+
1432+
if key is not None:
1433+
cls._LITERAL_CACHE[key] = lv
1434+
14091435
return lv
14101436

14111437
@classmethod

tests/flytekit/unit/core/test_list.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,13 +73,19 @@ async def test_coroutine_batching_of_list_transformer():
7373

7474
lt = LiteralType(simple=SimpleType.INTEGER)
7575
python_val = [MyInt(10), MyInt(11), MyInt(12), MyInt(13), MyInt(14)]
76+
# Use the different python_val to avoid hitting the cache
77+
python_val_2 = [MyInt(11), MyInt(10), MyInt(12), MyInt(13), MyInt(14)]
7678
ctx = FlyteContext.current_context()
7779

7880
with mock.patch("flytekit.core.type_engine._TYPE_ENGINE_COROS_BATCH_SIZE", 2):
7981
TypeEngine.to_literal(ctx, python_val, typing.List[MyInt], lt)
8082

8183
with mock.patch("flytekit.core.type_engine._TYPE_ENGINE_COROS_BATCH_SIZE", 5):
8284
with pytest.raises(ValueError):
85+
TypeEngine.to_literal(ctx, python_val_2, typing.List[MyInt], lt)
86+
87+
# Cache hit for python_val prevents async_to_literal calls, avoiding the batch size limit of 2 error defined in MyIntAsyncTransformer
88+
with mock.patch("flytekit.core.type_engine._TYPE_ENGINE_COROS_BATCH_SIZE", 5):
8389
TypeEngine.to_literal(ctx, python_val, typing.List[MyInt], lt)
8490

8591
del TypeEngine._REGISTRY[MyInt]

tests/flytekit/unit/core/test_type_engine.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3851,3 +3851,145 @@ async def test_dict_transformer_annotated_type():
38513851

38523852
literal3 = await TypeEngine.async_to_literal(ctx, nested_dict, nested_dict_type, expected_type)
38533853
assert literal3.map.literals["outer"].map.literals["inner"].scalar.primitive.integer == 42
3854+
3855+
@pytest.fixture(autouse=True)
3856+
def clear_type_engine_cache():
3857+
"""Clear TypeEngine cache before and after each test"""
3858+
TypeEngine._LITERAL_CACHE.clear()
3859+
yield
3860+
TypeEngine._LITERAL_CACHE.clear()
3861+
3862+
def test_type_engine_cache_with_list():
3863+
ctx = FlyteContext.current_context()
3864+
python_val = [1, 2, 3, 4, 5]
3865+
python_type = typing.List[int]
3866+
expected = TypeEngine.to_literal_type(python_type)
3867+
list_transformer = TypeEngine.get_transformer(typing.List[int])
3868+
with mock.patch.object(list_transformer, 'async_to_literal',
3869+
wraps=list_transformer.async_to_literal) as mock_async_to_literal:
3870+
3871+
# First call
3872+
literal1 = TypeEngine.to_literal(ctx, python_val, python_type, expected)
3873+
3874+
key = TypeEngine._get_literal_cache_key(python_val, python_type)
3875+
assert key is not None
3876+
assert key in TypeEngine._LITERAL_CACHE
3877+
3878+
# Second call with same DataFrame
3879+
literal2 = TypeEngine.to_literal(ctx, python_val, python_type, expected)
3880+
3881+
# Verify async_to_literal was only called once
3882+
assert mock_async_to_literal.call_count == 1
3883+
3884+
assert literal1 is literal2
3885+
3886+
# Test with different data - should not use cache
3887+
different_val = [2, 1, 3, 4, 5]
3888+
literal3 = TypeEngine.to_literal(ctx, different_val, python_type, expected)
3889+
key_different = TypeEngine._get_literal_cache_key(different_val, python_type)
3890+
3891+
assert key_different is not key
3892+
assert key_different is not None
3893+
assert key_different in TypeEngine._LITERAL_CACHE
3894+
3895+
# Verify different literals are different objects
3896+
assert literal1 is not literal3
3897+
3898+
# Add many different values to test cache size limit
3899+
for i in range(200): # More than the default maxsize of 128
3900+
test_val = [i, i+1, i+2]
3901+
test_type = typing.List[int]
3902+
test_expected = TypeEngine.to_literal_type(test_type)
3903+
TypeEngine.to_literal(ctx, test_val, test_type, test_expected)
3904+
3905+
# Cache should not exceed maxsize
3906+
assert len(TypeEngine._LITERAL_CACHE) == 128
3907+
3908+
def test_type_engine_cache_with_dict():
3909+
ctx = FlyteContext.current_context()
3910+
python_val = {"a": [1, 2, 3]}
3911+
python_type = typing.Dict[str, typing.List[int]]
3912+
expected = TypeEngine.to_literal_type(python_type)
3913+
dict_transformer = TypeEngine.get_transformer(typing.Dict[str, typing.List[int]])
3914+
with mock.patch.object(dict_transformer, 'async_to_literal',
3915+
wraps=dict_transformer.async_to_literal) as mock_async_to_literal:
3916+
3917+
# First call
3918+
literal1 = TypeEngine.to_literal(ctx, python_val, python_type, expected)
3919+
3920+
key = TypeEngine._get_literal_cache_key(python_val, python_type)
3921+
assert key is not None
3922+
assert key in TypeEngine._LITERAL_CACHE
3923+
3924+
# Second call with same DataFrame
3925+
literal2 = TypeEngine.to_literal(ctx, python_val, python_type, expected)
3926+
3927+
# Verify async_to_literal was only called once
3928+
assert mock_async_to_literal.call_count == 1
3929+
3930+
assert literal1 is literal2
3931+
3932+
def test_make_key_with_annotated_types():
3933+
# Test with Annotated type
3934+
annotated_val = [1, 2, 3]
3935+
annotated_type = typing.Annotated[typing.List[int], "test_annotation"]
3936+
3937+
key = TypeEngine._get_literal_cache_key(annotated_val, annotated_type)
3938+
key_without_annotation = TypeEngine._get_literal_cache_key(annotated_val, typing.List[int])
3939+
# Should handle Annotated types correctly
3940+
assert key is not None
3941+
assert key_without_annotation is not None
3942+
assert key != key_without_annotation
3943+
3944+
def test_type_engine_cache_with_pandas():
3945+
pd = pytest.importorskip("pandas")
3946+
ctx = FlyteContext.current_context()
3947+
# Create DataFrame
3948+
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
3949+
df_type = pd.DataFrame
3950+
df_expected = TypeEngine.to_literal_type(df_type)
3951+
3952+
# Get the transformer for DataFrame
3953+
df_transformer = TypeEngine._REGISTRY[pd.DataFrame]
3954+
3955+
# Mock the async_to_literal method with wraps to track calls
3956+
with mock.patch.object(df_transformer, 'async_to_literal',
3957+
wraps=df_transformer.async_to_literal) as mock_async_to_literal:
3958+
3959+
# First call
3960+
literal1 = TypeEngine.to_literal(ctx, df, df_type, df_expected)
3961+
3962+
# Second call with same DataFrame
3963+
literal2 = TypeEngine.to_literal(ctx, df, df_type, df_expected)
3964+
3965+
# Verify async_to_literal was called
3966+
assert mock_async_to_literal.call_count == 1
3967+
3968+
assert literal1 is literal2
3969+
3970+
def test_type_engine_cache_with_flytefile():
3971+
3972+
transformer = TypeEngine.get_transformer(FlyteFile)
3973+
ctx = FlyteContext.current_context()
3974+
3975+
temp_dir = tempfile.mkdtemp(prefix="temp_example_")
3976+
file_path = os.path.join(temp_dir, "file.txt")
3977+
with open(file_path, "w") as file1:
3978+
file1.write("hello world")
3979+
3980+
lt = TypeEngine.to_literal_type(FlyteFile)
3981+
3982+
# Mock the file upload
3983+
with mock.patch.object(transformer, 'async_to_literal',
3984+
wraps=transformer.async_to_literal) as mock_async_to_literal:
3985+
3986+
# Test 1: Upload local file to remote
3987+
lv1 = TypeEngine.to_literal(ctx, file_path, FlyteFile, lt)
3988+
3989+
# Second call with same DataFrame
3990+
lv2 = TypeEngine.to_literal(ctx, file_path, FlyteFile, lt)
3991+
3992+
# Verify async_to_literal was called
3993+
assert mock_async_to_literal.call_count == 1
3994+
3995+
assert lv1 is lv2

0 commit comments

Comments
 (0)