# Copyright 2024 The JAX Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Helper tool for automatic cost estimation."""
import dataclasses
import functools
import math
from typing import Any
from collections.abc import Sequence

from jax._src import tree_util
from jax._src import api_util
from jax._src import core as jax_core
from jax._src import custom_derivatives
from jax._src import linear_util as lu
from jax._src import pjit
from jax._src.state import discharge
from jax._src.pallas import core as pallas_core
from jax._src.interpreters import partial_eval as pe
from jax._src.util import safe_map
from jax._src.util import safe_zip
from jax._src.lax import lax

map, unsafe_map = safe_map, map  # pylint: disable=redefined-builtin
zip, unsafe_zip = safe_zip, zip  # pylint: disable=redefined-builtin

_cost_rules = {}

@dataclasses.dataclass(frozen=True)
class CostEstimate:
  flops: int
  transcendentals: int
  bytes_accessed: int

  def __add__(self, other: 'CostEstimate') -> 'CostEstimate':
    return CostEstimate(
        flops=self.flops + other.flops,
        transcendentals=self.transcendentals + other.transcendentals,
        bytes_accessed=self.bytes_accessed + other.bytes_accessed,
    )

def register_cost_rule(primitive: jax_core.Primitive, rule):
  _cost_rules[primitive] = rule

@dataclasses.dataclass(frozen=True)
class Context:
  avals_in: Sequence[Any]
  avals_out: Sequence[Any]

def cost_estimate_jaxpr(
    jaxpr: jax_core.ClosedJaxpr,
) -> pallas_core.CostEstimate:
  """Returns the cost estimate for the given Jaxpr."""
  jaxpr, _ = jaxpr.jaxpr, jaxpr.consts
  total_cost = CostEstimate(flops=0, transcendentals=0, bytes_accessed=0)

  for eqn in jaxpr.eqns:
    rule = _cost_rules.get(eqn.primitive, None)
    if rule is not None:
      context = Context(avals_in=[v.aval for v in eqn.invars],
                        avals_out=[v.aval for v in eqn.outvars])
      op_cost = rule(context, **eqn.params)
      total_cost = total_cost + op_cost
  return pallas_core.CostEstimate(
      flops=total_cost.flops,
      transcendentals=total_cost.transcendentals,
      bytes_accessed=total_cost.bytes_accessed,
  )

def estimate_cost(fun, *args, **kwargs) -> pallas_core.CostEstimate:
  """Computes a cost estimate for the given function.

  Args:
    fun: The function to compute the cost estimate for.
    *args: The arguments to the function. Can be jax.ShapeDtypeStruct or
      jax.Array.
    **kwargs: The keyword arguments to the function.

  Returns:
    A pallas_core.CostEstimate object containing the cost estimate.
  """
  flattened_args, treedef = tree_util.tree_flatten(args)
  partial_fun = functools.partial(fun, **kwargs)
  wrapped_fun, _ = api_util.flatten_fun_nokwargs(
      lu.wrap_init(partial_fun,
                   debug_info=api_util.debug_info("cost_estimate", fun,
                                                  args, {})),
      treedef)
  avals = [jax_core.ShapedArray(a.shape, a.dtype) for a in flattened_args]
  jaxpr, _, consts = pe.trace_to_jaxpr_dynamic(wrapped_fun, avals)
  estimate = cost_estimate_jaxpr(jax_core.ClosedJaxpr(jaxpr, consts))
  input_bytes = sum(
      math.prod(a.shape) * a.dtype.itemsize for a in flattened_args)
  output_bytes = sum(
      math.prod(a.aval.shape) * a.aval.dtype.itemsize for a in jaxpr.outvars)
  return pallas_core.CostEstimate(
      flops=estimate.flops,
      transcendentals=estimate.transcendentals,
      bytes_accessed=estimate.bytes_accessed + input_bytes + output_bytes,
  )

def binary_cost_rule(ctx: Context, **_) -> CostEstimate:
  aval_out, = ctx.avals_out
  out_flops = math.prod(aval_out.shape)
  return CostEstimate(
      flops=out_flops,
      transcendentals=0,
      bytes_accessed=0,
  )
BINARY_OPS = [
    lax.add_p,
    lax.mul_p,
    lax.sub_p,
    lax.div_p,
    lax.min_p,
    lax.max_p,
    lax.or_p,
    lax.and_p,
    lax.xor_p,
]
for op in BINARY_OPS:
  register_cost_rule(op, binary_cost_rule)


def unary_cost_rule(transcendental: bool):
  def cost_rule(ctx: Context, **_) -> CostEstimate:
    x_aval, = ctx.avals_in
    new_flops = 0
    new_transcendentals = 0
    if transcendental:
      new_transcendentals += math.prod(x_aval.shape)
    else:
      new_flops += math.prod(x_aval.shape)
    return CostEstimate(
        flops=new_flops,
        transcendentals=new_transcendentals,
        bytes_accessed=0,
    )
  return cost_rule

UN_OPS = [
    lax.neg_p,
    lax.floor_p,
    lax.ceil_p,
    lax.round_p,
    lax.not_p,
]
for op in UN_OPS:
  register_cost_rule(op, unary_cost_rule(transcendental=False))

TRANSCENDENTAL_OPS = [
    lax.cos_p,
    lax.sin_p,
    lax.tan_p,
    lax.sinh_p,
    lax.cosh_p,
    lax.tanh_p,
    lax.acos_p,
    lax.asin_p,
    lax.atan_p,
    lax.exp_p,
    lax.log_p,
    lax.logistic_p,
    lax.sqrt_p,
]
for op in TRANSCENDENTAL_OPS:
  register_cost_rule(op, unary_cost_rule(transcendental=True))

def _integer_pow_cost_rule(ctx: Context, *, y: int) -> CostEstimate:
  x_aval, = ctx.avals_in
  num_elements = math.prod(x_aval.shape)
  if y == 0 or y == 1:
    # No flops, the result is 0 or a copy of the input.
    cost_per_element = 0
  else:
    # We assume integer pow is implemented using repeated squaring.
    # The cost is log(y) squarings, plus one multiply per non-zero bit.
    highest_bit = math.floor(math.log(y, 2))
    cost_per_element = highest_bit + y.bit_count()
  return CostEstimate(
      flops=num_elements * cost_per_element,
      transcendentals=0,
      bytes_accessed=0,
  )
register_cost_rule(lax.integer_pow_p, _integer_pow_cost_rule)

def dot_general_cost_rule(ctx: Context,
                          dimension_numbers: lax.DotDimensionNumbers,
                          **_) -> CostEstimate:
  x_aval, y_aval = ctx.avals_in
  x_shape, y_shape = x_aval.shape, y_aval.shape
  (lhs_contracting_dims, rhs_contracting_dims), (
      lhs_batch_dims, rhs_batch_dims) = dimension_numbers
  assert len(lhs_contracting_dims) == len(rhs_contracting_dims)
  assert len(lhs_batch_dims) == len(rhs_batch_dims)
  flops = 1
  # Flops along a contracting dim is 2*dim (addition and multiplication)
  contracting_flops = 1
  for i in range(len(lhs_contracting_dims)):
    lhs_dim, rhs_dim = lhs_contracting_dims[i], rhs_contracting_dims[i]
    assert x_shape[lhs_dim] == y_shape[rhs_dim]
    contracting_flops *= x_shape[lhs_dim]
  flops *= 2 * contracting_flops
  # Now we handle all other dimensions.
  for i, lhs_dim in enumerate(x_shape):
    if i in lhs_contracting_dims:
      continue
    flops *= lhs_dim
  for i, rhs_dim in enumerate(y_shape):
    if i in rhs_contracting_dims:
      continue
    # Don't double-count batch dims (we already counted for LHS)
    if i in rhs_batch_dims:
      continue
    flops *= rhs_dim
  return CostEstimate(
      flops=flops,
      transcendentals=0,
      bytes_accessed=0,
  )
register_cost_rule(lax.dot_general_p, dot_general_cost_rule)

# Higher-order primitives
def _pjit_cost_rule(ctx, *, jaxpr: jax_core.ClosedJaxpr, **_):
  del ctx
  inner_cost = cost_estimate_jaxpr(jaxpr)
  return CostEstimate(
      flops=inner_cost.flops,
      transcendentals=inner_cost.transcendentals,
      bytes_accessed=inner_cost.bytes_accessed,
  )
register_cost_rule(pjit.jit_p, _pjit_cost_rule)

def _custom_vjp_rule(ctx, *, call_jaxpr: jax_core.ClosedJaxpr, **_):
  del ctx
  inner_cost = cost_estimate_jaxpr(call_jaxpr)
  return CostEstimate(
      flops=inner_cost.flops,
      transcendentals=inner_cost.transcendentals,
      bytes_accessed=inner_cost.bytes_accessed,
  )
register_cost_rule(custom_derivatives.custom_vjp_call_p, _custom_vjp_rule)

def _run_state_rule(*_, jaxpr: jax_core.Jaxpr, **_2):
  inner_cost = cost_estimate_jaxpr(pe.close_jaxpr(jaxpr))
  return CostEstimate(
      flops=inner_cost.flops,
      transcendentals=inner_cost.transcendentals,
      bytes_accessed=inner_cost.bytes_accessed,
  )
register_cost_rule(discharge.run_state_p, _run_state_rule)
