Repository navigation
Expand file tree
/
Copy pathsingle_threaded_execution_strategy.py
More file actions
255 lines (199 loc) · 12.2 KB
/
Copy pathsingle_threaded_execution_strategy.py
File metadata and controls
255 lines (199 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
import logging
from palimpzest.core.data.dataclasses import PlanStats
from palimpzest.core.elements.records import DataRecord
from palimpzest.query.execution.execution_strategy import ExecutionStrategy
from palimpzest.query.operators.aggregate import AggregateOp
from palimpzest.query.operators.limit import LimitScanOp
from palimpzest.query.operators.scan import ScanPhysicalOp
from palimpzest.query.optimizer.plan import PhysicalPlan
from palimpzest.utils.progress import create_progress_manager
logger = logging.getLogger(__name__)
class SequentialSingleThreadExecutionStrategy(ExecutionStrategy):
"""
A single-threaded execution strategy that processes operators sequentially.
This strategy processes all records through one operator completely before moving to the next operator
in the execution plan. For example, if we have operators A -> B -> C and records [1,2,3]:
1. First processes records [1,2,3] through operator A
2. Then takes A's output and processes all of it through operator B
3. Finally processes all of B's output through operator C
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.max_workers = 1
def _execute_plan(self, plan: PhysicalPlan, input_queues: dict[str, list], plan_stats: PlanStats) -> tuple[list[DataRecord], PlanStats]:
# execute the plan one operator at a time
output_records = []
for op_idx, operator in enumerate(plan.operators):
# if we've filtered out all records, terminate early
full_op_id = operator.get_full_op_id()
num_inputs = len(input_queues[full_op_id])
if num_inputs == 0:
break
# begin to process this operator
records, record_op_stats = [], []
logger.info(f"Processing operator {operator.op_name()} ({full_op_id})")
# if this operator is an aggregate, process all the records in the input_queue
if isinstance(operator, AggregateOp):
record_set = operator(candidates=input_queues[full_op_id])
records = record_set.data_records
record_op_stats = record_set.record_op_stats
# num_outputs = sum(record.passed_operator for record in records)
num_outputs = sum(record.passed_operator for record in record_set.data_records if record.passed_operator is not None)
# update the progress manager
self.progress_manager.incr(full_op_id, num_outputs=num_outputs, total_cost=record_set.get_total_cost())
# otherwise, process the records in the input queue for this operator one at a time
else:
for input_record in input_queues[full_op_id]:
record_set = operator(input_record)
records.extend(record_set.data_records)
record_op_stats.extend(record_set.record_op_stats)
num_outputs = sum(record.passed_operator for record in record_set.data_records)
# update the progress manager
self.progress_manager.incr(full_op_id, num_outputs=num_outputs, total_cost=record_set.get_total_cost())
# finish early if this is a limit
if isinstance(operator, LimitScanOp) and len(records) == operator.limit:
break
# update plan stats
plan_stats.add_record_op_stats(record_op_stats)
# add records to the cache
self._add_records_to_cache(operator.target_cache_id, records)
# update next input_queue (if it exists)
output_records = [record for record in records if record.passed_operator]
if op_idx + 1 < len(plan.operators):
next_full_op_id = plan.operators[op_idx + 1].get_full_op_id()
input_queues[next_full_op_id] = output_records
logger.info(f"Finished processing operator {operator.op_name()} ({operator.get_full_op_id()}), and generated {len(records)} records")
# close the cache
self._close_cache([op.target_cache_id for op in plan.operators])
# finalize plan stats
plan_stats.finish()
return output_records, plan_stats
def execute_plan(self, plan: PhysicalPlan) -> tuple[list[DataRecord], PlanStats]:
"""Initialize the stats and execute the plan."""
# for now, assert that the first operator in the plan is a ScanPhysicalOp
assert isinstance(plan.operators[0], ScanPhysicalOp), "First operator in physical plan must be a ScanPhysicalOp"
logger.info(f"Executing plan {plan.plan_id} with {self.max_workers} workers")
logger.info(f"Plan Details: {plan}")
# initialize plan stats
plan_stats = PlanStats.from_plan(plan)
plan_stats.start()
# initialize input queues for each operation
input_queues = self._create_input_queues(plan)
# initialize and start the progress manager
self.progress_manager = create_progress_manager(plan, num_samples=self.num_samples, progress=self.progress)
self.progress_manager.start()
# NOTE: we must handle progress manager outside of _exeecute_plan to ensure that it is shut down correctly;
# if we don't have the `finally:` branch, then program crashes can cause future program runs to fail
# because the progress manager cannot get a handle to the console
try:
# execute plan
output_records, plan_stats = self._execute_plan(plan, input_queues, plan_stats)
finally:
# finish progress tracking
self.progress_manager.finish()
logger.info(f"Done executing plan: {plan.plan_id}")
logger.debug(f"Plan stats: (plan_cost={plan_stats.total_plan_cost}, plan_time={plan_stats.total_plan_time})")
return output_records, plan_stats
class PipelinedSingleThreadExecutionStrategy(ExecutionStrategy):
"""
A single-threaded execution strategy that processes records through a pipeline of operators.
This strategy implements a pipelined execution model where each record flows through
the entire operator chain before the next record is processed.
Example Flow:
For operators A -> B -> C and records [1,2,3]:
1. Record 1: A -> B -> C
2. Record 2: A -> B -> C
3. Record 3: A -> B -> C
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.max_workers = 1
def _any_queue_not_empty(self, queues: dict[str, list]) -> bool:
"""Helper function to check if any queue is not empty."""
return any(len(queue) > 0 for queue in queues.values())
def _upstream_ops_finished(self, plan: PhysicalPlan, op_idx: int, input_queues: dict[str, list]) -> bool:
"""Helper function to check if all upstream operators have finished processing their inputs."""
for upstream_op_idx in range(op_idx):
upstream_full_op_id = plan.operators[upstream_op_idx].get_full_op_id()
if len(input_queues[upstream_full_op_id]) > 0:
return False
return True
def _execute_plan(self, plan: PhysicalPlan, input_queues: dict[str, list], plan_stats: PlanStats) -> tuple[list[DataRecord], PlanStats]:
# execute the plan until either:
# 1. all records have been processed, or
# 2. the final limit operation has completed (we break out of the loop if this happens)
final_output_records = []
while self._any_queue_not_empty(input_queues):
for op_idx, operator in enumerate(plan.operators):
# if this operator does not have enough inputs to execute, then skip it
full_op_id = operator.get_full_op_id()
num_inputs = len(input_queues[full_op_id])
agg_op_not_ready = isinstance(operator, AggregateOp) and not self._upstream_ops_finished(plan, op_idx, input_queues)
if num_inputs == 0 or agg_op_not_ready:
continue
# create empty lists for records and execution stats generated by executing this operator on its next input(s)
records, record_op_stats = [], []
# if the next operator is an aggregate, process all the records in the input_queue
if isinstance(operator, AggregateOp):
input_records = [input_queues[full_op_id].pop(0) for _ in range(num_inputs)]
record_set = operator(candidates=input_records)
records = record_set.data_records
record_op_stats = record_set.record_op_stats
num_outputs = sum(record.passed_operator for record in records)
# update the progress manager
self.progress_manager.incr(full_op_id, num_outputs=num_outputs, total_cost=record_set.get_total_cost())
# otherwise, process the next record in the input queue for this operator
else:
input_record = input_queues[full_op_id].pop(0)
record_set = operator(input_record)
records = record_set.data_records
record_op_stats = record_set.record_op_stats
num_outputs = sum(record.passed_operator for record in records)
# update the progress manager
self.progress_manager.incr(full_op_id, num_outputs=num_outputs, total_cost=record_set.get_total_cost())
# update plan stats
plan_stats.add_record_op_stats(record_op_stats)
# add records to the cache
self._add_records_to_cache(operator.target_cache_id, records)
# update next input_queue or final_output_records
output_records = [record for record in records if record.passed_operator]
if op_idx + 1 < len(plan.operators):
next_full_op_id = plan.operators[op_idx + 1].get_full_op_id()
input_queues[next_full_op_id].extend(output_records)
else:
final_output_records.extend(output_records)
logger.info(f"Finished processing operator {operator.op_name()} ({operator.get_full_op_id()}) on {num_inputs} records")
# break out of loop if the final operator is a LimitScanOp and we've reached its limit
if isinstance(plan.operators[-1], LimitScanOp) and len(final_output_records) == plan.operators[-1].limit:
break
# close the cache
self._close_cache([op.target_cache_id for op in plan.operators])
# finalize plan stats
plan_stats.finish()
return final_output_records, plan_stats
def execute_plan(self, plan: PhysicalPlan):
"""Initialize the stats and execute the plan."""
# for now, assert that the first operator in the plan is a ScanPhysicalOp
assert isinstance(plan.operators[0], ScanPhysicalOp), "First operator in physical plan must be a ScanPhysicalOp"
logger.info(f"Executing plan {plan.plan_id} with {self.max_workers} workers")
logger.info(f"Plan Details: {plan}")
# initialize plan stats
plan_stats = PlanStats.from_plan(plan)
plan_stats.start()
# initialize input queues for each operation
input_queues = self._create_input_queues(plan)
# initialize and start the progress manager
self.progress_manager = create_progress_manager(plan, self.num_samples, self.progress)
self.progress_manager.start()
# NOTE: we must handle progress manager outside of _exeecute_plan to ensure that it is shut down correctly;
# if we don't have the `finally:` branch, then program crashes can cause future program runs to fail
# because the progress manager cannot get a handle to the console
try:
# execute plan
output_records, plan_stats = self._execute_plan(plan, input_queues, plan_stats)
finally:
# finish progress tracking
self.progress_manager.finish()
logger.info(f"Done executing plan: {plan.plan_id}")
logger.debug(f"Plan stats: (plan_cost={plan_stats.total_plan_cost}, plan_time={plan_stats.total_plan_time})")
return output_records, plan_stats